/**
* For all details and documentation: {@link https://www.horizontalcharts.org|www.horizontalcharts.org}
* @copyright Andrea Giovanni Bianchessi 2022
* @author Andrea Giovanni Bianchessi <andrea.g.bianchessi@gmail.com>
* @license MIT
* @version 1.1.9
*
* @module HorizontalCharts
*/
; (function (exports) {
'use strict';
const Util = {
merge: function () {
arguments[0] = arguments[0] || {};
for (let i = 1; i < arguments.length; i++) {
for (let key in arguments[i]) {
if (arguments[i].hasOwnProperty(key)) {
if (typeof (arguments[i][key]) === 'object') {
if (arguments[i][key] instanceof Array) {
arguments[0][key] = arguments[i][key];
} else {
arguments[0][key] = Util.merge(arguments[0][key], arguments[i][key]);
}
} else {
arguments[0][key] = arguments[i][key];
}
}
}
}
return arguments[0];
},
resizeCanvas: function (canvas, factor) {
const width = canvas.clientWidth;
const height = canvas.height;
canvas.width = 0 | (width * factor);
canvas.height = 0 | (height * factor);
canvas.style.height = height + 'px';
canvas.getContext("2d").scale(factor, factor);
}
}
/**
* Initialises a new <code>DataSample</code>.
*
* @constructor
* @param {Object} data - An object with <code>DataSample</code> data.
* @param {number} data.ts - This <code>DataSample</code> timestamp (milliseconds since the Unix Epoch).
* @param {string|CanvasPattern} data.color - The color or pattern of this <code>DataSample</code>.
* @param {number} [data.value=NaN] - The value of this <code>DataSample</code>.
* @param {string} [data.desc=""] - A short text describing this <code>DataSample</code>.
* @memberof module:HorizontalCharts
*/
function DataSample(data) {
this.ts = typeof data.ts === 'number' ? data.ts : Number.NaN;
this.color = data.color;
this.value = typeof data.value === 'number' ? data.value : Number.NaN;
this.desc = typeof data.desc === 'string' ? data.desc : '';
this.xStart = Number.NaN;
this.xEnd = Number.NaN;
this.path2D = null;
}
/**
* Initialises a new <code>TimeSeries</code> with optional data options.
*
* @constructor
* @param {number} position - Unique, integer and strictly positive value, it sorts series on the graph from top to bottom.
* @param {DefaultTimeSeriesOptions} [options] - <code>TimeSeries</code> options.
* @memberof module:HorizontalCharts
*/
function TimeSeries(position, options) {
this.position = position;
this.options = Util.merge({}, TimeSeries.defaultTimeSeriesOptions, options);
this.clear();
};
/**
* @typedef {Object} DefaultTimeSeriesOptions - Contains default chart options.
* @property {number} [barHeight=22] - The thickness of the bars.
* @property {boolean} [showValues=true] - Enables the printing of data samples values inside bars.
* @property {string} [labelText=""] - A short text describing this <code>TimeSeries</code>.
* @property {boolean} [replaceValue=false] - If data sample <code>ts</code> has an exact match in the series, this flag controls whether it is replaced, or not.
* @property {boolean} [disabled=false] - This flag controls wheter this timeseries is displayed or not.
*/
TimeSeries.defaultTimeSeriesOptions = {
barHeight: 25,
showValues: true,
labelText: "",
replaceValue: false,
disabled: false
};
/**
* Clears all data from this <code>TimeSeries</code>.
*/
TimeSeries.prototype.clear = function () {
this.data = [];
};
/**
* Adds a new data sample to the <code>TimeSeries</code>, preserving chronological order.
*
* @param {DataSample} dataSample - The <code>DataSample</code> to add.
*/
TimeSeries.prototype.append = function (dataSample) {
if (isNaN(dataSample.ts)) {
// Add to the end of the array
this.data.push(dataSample);
return;
}
// Rewind until we hit an older x
let i = this.data.length - 1;
while (i >= 0 && this.data[i].ts > dataSample.ts) {
i--;
}
if (i === -1) {
// This new item is the oldest data
this.data.splice(0, 0, dataSample);
} else if (this.data.length > 0 && this.data[i].ts === dataSample.ts) {
// Replace existing values in the array
if (this.options.replaceValue)
this.data[i] = dataSample;
} else {
//insert
if (i < this.data.length - 1) {
// Splice into the correct position to keep the ts's in order
this.data.splice(i + 1, 0, dataSample);
} else {
// Add to the end of the array
this.data.push(dataSample);
}
}
};
/**
*
* @private
*/
TimeSeries.prototype._dropOldData = function (canvasWidth) {
let lengthSum = 0;
for (let i = this.data.length - 1; i >= 0; i--) {
if (isNaN(this.data[i].xEnd) || isNaN(this.data[i].xStart))
break
lengthSum += this.data[i].xEnd - this.data[i].xStart;
if (lengthSum > canvasWidth) {
this.data.splice(0, i + 1);
break;
}
}
};
/**
* Initialises a new <code>HorizontalChart</code>.
*
* @constructor
* @param {DefaultChartOptions} [options] - <code>HorizontalChart</code> options.
* @param {boolean} [isRealTime=false] - Enables the real-time data visualization mode.
* @memberof module:HorizontalCharts
*/
function HorizontalChart(options, isRealTime = false) {
this.seriesSet = [];
this.isRealTime = isRealTime;
this.options = Util.merge({}, HorizontalChart.defaultChartOptions, options);
};
/**
* @typedef {Object} DefaultChartOptions - Contains default chart options.
* @property {number} [overSampleFactor=3] - Canvas scaling factor.
* @property {string} [backgroundColor="#00000000"] - Background color (RGB[A] string) of the chart.
* @property {number} [padding=5] - Space between timeseries.
* @property {function} [formatTime] - Timestamp formatting function.
* @property {number} [axesWidth=2] - The thickness of the X and Y axes.
* @property {string} [axesColor="#000000"] - The color of the X and Y axes.
* @property {Object} [grid] - Grid options.
* @property {Object} [grid.y] - Y grid axis options.
* @property {boolean} [grid.y.enabled=false] - If true Y grid axis are shown.
* @property {string} [grid.y.color="#000000"] - Y grid axis color.
* @property {Object} [grid.x] - X grid axis options.
* @property {boolean} [grid.x.enabled=false] - If true X grid axis are shown.
* @property {number} [grid.x.stepSize=20] - X grid axis step size.
* @property {string} [grid.x.color="#000000"] - X grid axis color.
* @property {Object} [tooltip] - Tooltip options.
* @property {boolean} [tooltip.enabled=true] - If true tooltips are shown.
* @property {string} [tooltip.backgroundColor="#FFFFFFDD"] - Tooltips backround color.
* @property {number} [minBarLength=0] - Minimum bar length.
* @property {Object} [xAxis] - X axis options.
* @property {number} [xAxis.xUnitsPerPixel=10] - X axis scaling factor.
* @property {number} [xAxis.max=105] - On real time charts this is the maximum value on the X axis. On non real time charts it is ignored.
* @property {string} [xAxis.xLabel=""] - X axis title.
* @property {number} [xAxis.fontSize=12] - Font size of the X axis title.
* @property {string} [xAxis.fontFamily="monospace"] - Font family of the X axis title.
* @property {string} [xAxis.fontColor="#000000"] - Font color of the X axis title.
* @property {Object} [yLabels] - Y labels options.
* @property {boolean} [yLabels.enabled=true] - If true Y labels are shown.
* @property {boolean} [yLabels.fontSize=12] - Font size of the Y labels.
* @property {string} [yLabels.fontFamily="monospace"] - Font family of the Y labels.
* @property {string} [yLabels.fontColor="#000000"] - Font color of the Y labels.
*
*/
HorizontalChart.defaultChartOptions = {
overSampleFactor: 3,
backgroundColor: '#00000000',
padding: 5,
formatTime: function (ms) {
function pad3(number) { if (number < 10) return '00' + number; if (number < 100) return '0' + number; return number; }
const date = new Date(ms);
const msStr = (pad3(ms - Math.floor(ms / 1000) * 1000) / 1000);
return date.toLocaleString('en-US', { hour12: false }) + msStr;
},
axesWidth: 2,
axesColor: '#000000',
grid: {
y: {
enabled: false,
color: '#000000'
},
x: {
enabled: false,
stepSize: 20,
color: '#000000'
}
},
tooltip: {
enabled: true,
backgroundColor: '#FFFFFFDD'
},
minBarLength: 0,
xAxis: {
xUnitsPerPixel: 10,
max: 105,
xLabel: "",
fontSize: 12,
fontFamily: 'monospace',
fontColor: '#000000'
},
yLabels: {
enabled: true,
fontSize: 12,
fontFamily: 'monospace',
fontColor: '#000000'
}
};
/**
* Adds <code>TimeSeries</code> to this chart.
*
* @param {...TimeSeries} timeSeries - The <code>TimeSeries</code> to add.
*/
HorizontalChart.prototype.addTimeSeries = function (...timeSeries) {
this.seriesSet.push(...timeSeries);
};
/**
* Instructs the <code>HorizontalChart</code> to start rendering to the provided <code>Canvas</code>.
*
* @param {Canvas} canvas - The target canvas element.
*/
HorizontalChart.prototype.streamTo = function (canvas) {
// DataSet check
const valDataOk = this.seriesSet.every(s => s.data.every(
(d, i, arr) => i == 0 ? true : isNaN(arr[i].value) === isNaN(arr[i - 1].value)
));
if (!valDataOk)
throw new Error('Invalid DataSet!');
// Render on Canvas
this.canvas = canvas;
window.requestAnimationFrame((this._render.bind(this)));
// Add mouse listeners
this.canvas.addEventListener('click', this._mouseclick.bind(this));
this.canvas.addEventListener('mousemove', this._mousemove.bind(this));
this.canvas.addEventListener('mouseout', this._mouseout.bind(this));
};
/**
*
* @private
*/
HorizontalChart.prototype._render = function () {
const xUnitsPerPixel = this.options.xAxis.xUnitsPerPixel;
const xMax = this.options.xAxis.max;
const ctx = this.canvas.getContext("2d");
const seriesCount = this.seriesSet.reduce(function (prevValue, currentSeries) {
if (currentSeries.options.disabled)
return prevValue;
return ++prevValue;
}, 0);
//Canvas heigth
let canvasHeight = this.seriesSet.reduce(function (prevValue, currentSeries) {
if (currentSeries.options.disabled)
return prevValue;
return prevValue + currentSeries.options.barHeight;
}, 0);
canvasHeight += (seriesCount + 1) * this.options.padding;
//X axis width
canvasHeight += this.options.axesWidth;
//X Axis labels space
let xLabelSpace = 0;
if (typeof this.options.xAxis.xLabel === "string" && this.options.xAxis.xLabel.length > 0) {
xLabelSpace = this.options.xAxis.fontSize + 5;
canvasHeight += xLabelSpace;
}
// Resize canvas
this.canvas.style.height = canvasHeight + "px";
this.canvas.height = canvasHeight;
Util.resizeCanvas(this.canvas, this.options.overSampleFactor);
const canvasWidth = this.canvas.width;
// Clear the working area.
ctx.fillStyle = this.options.backgroundColor;
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
// Compute y labels max width
let labelsMaxWidth = 0;
// For each data set...
for (const timeSeries of this.seriesSet) {
if (timeSeries.options.disabled)
continue;
if (this.options.yLabels.enabled) {
ctx.font = "bold " + this.options.yLabels.fontSize + 'px ' + this.options.yLabels.fontFamily;
const labelString = timeSeries.options.labelText.length > 0
? timeSeries.options.labelText
: timeSeries.position;
const textWidth = Math.ceil(ctx.measureText(labelString).width);
if (textWidth > labelsMaxWidth)
labelsMaxWidth = textWidth;
}
}
if (labelsMaxWidth > 0)
labelsMaxWidth += 4;
// Scale factor for non real-time charts
const xScale = (canvasWidth - (labelsMaxWidth + this.options.axesWidth) * this.options.overSampleFactor) / (this.options.overSampleFactor * xMax);
// X Y Axis
ctx.lineJoin = "round";
ctx.lineWidth = this.options.axesWidth;
ctx.strokeStyle = this.options.axesColor;
ctx.beginPath();
ctx.moveTo(canvasWidth / this.options.overSampleFactor, this.canvas.clientHeight - (ctx.lineWidth / 2) - xLabelSpace);
ctx.lineTo(labelsMaxWidth, this.canvas.clientHeight - (ctx.lineWidth / 2) - xLabelSpace);
ctx.lineTo(labelsMaxWidth, 0);
ctx.stroke();
// X grid
if (this.options.grid.x.enabled) {
let xPos = this.options.grid.x.stepSize;
ctx.lineWidth = 1;
ctx.strokeStyle = this.options.grid.x.color;
while (xMax - xPos > 0) {
ctx.beginPath();
ctx.moveTo((xPos * xScale) + labelsMaxWidth + this.options.axesWidth, this.canvas.clientHeight - (ctx.lineWidth / 2) - xLabelSpace);
ctx.lineTo((xPos * xScale) + labelsMaxWidth + this.options.axesWidth, 0);
ctx.stroke();
xPos += this.options.grid.x.stepSize;
}
}
// X Axis label
if (xLabelSpace > 0) {
const labelText = this.options.xAxis.xLabel;
const textWidth = Math.ceil(ctx.measureText(labelText).width);
ctx.fillStyle = this.options.xAxis.fontColor;
ctx.font = "bold " + this.options.xAxis.fontSize + 'px ' + this.options.xAxis.fontFamily;
ctx.fillText(labelText,
canvasWidth / (2 * this.options.overSampleFactor) - textWidth / 2,
this.canvas.clientHeight - xLabelSpace / 2 + this.options.xAxis.fontSize / 2
);
}
// Y Axis labels and bars, for each data set...
for (const timeSeries of this.seriesSet) {
if (timeSeries.options.disabled)
continue;
const dataSet = timeSeries.data;
const position = timeSeries.position;
const barPaddedHeight = (canvasHeight - this.options.axesWidth - xLabelSpace) / seriesCount;
const yBarPosition = Math.round(barPaddedHeight * (position - 1) + this.options.padding / 2);
const yCenteredPosition = Math.round(barPaddedHeight * (position - 1) + (barPaddedHeight / 2));
// Draw y labels on the chart.
if (this.options.yLabels.enabled) {
const labelString = timeSeries.options.labelText.length > 0
? timeSeries.options.labelText
: timeSeries.position;
// Label's text
ctx.fillStyle = this.options.yLabels.fontColor;
ctx.font = "bold " + this.options.yLabels.fontSize + 'px ' + this.options.yLabels.fontFamily;
ctx.fillText(labelString, 0, yCenteredPosition);
}
// Y grid
if (this.options.grid.y.enabled && position > 1) {
ctx.lineWidth = 1;
ctx.strokeStyle = this.options.grid.y.color;
ctx.beginPath();
ctx.moveTo(labelsMaxWidth, yBarPosition - this.options.padding / 2);
ctx.lineTo(canvasWidth / this.options.overSampleFactor, yBarPosition - this.options.padding / 2);
ctx.stroke();
}
// Draw bars
let lastXend = 0, lineEnd = 0;
for (let i = 0; i < dataSet.length; i++) {
const value = dataSet[i].value;
if (i === 0) {
const lineStart = 0 + labelsMaxWidth + this.options.axesWidth;
lineEnd = (value / xUnitsPerPixel) + labelsMaxWidth + this.options.axesWidth;
if (!this.isRealTime)
lineEnd = (value * xScale) + labelsMaxWidth + this.options.axesWidth;
if (this.options.minBarLength > 0 && (lineEnd - lineStart) < this.options.minBarLength)
lineEnd = lineStart + this.options.minBarLength
this._drawBar(yBarPosition, lineStart, lineEnd, dataSet[i], timeSeries.options);
} else {
const lineStart = lastXend;
lineEnd = lineStart + (value / xUnitsPerPixel);
if (!this.isRealTime)
lineEnd = lineStart + (value * xScale);
if (this.options.minBarLength > 0 && (lineEnd - lineStart) < this.options.minBarLength)
lineEnd = lineStart + this.options.minBarLength
this._drawBar(yBarPosition, lineStart, lineEnd, dataSet[i], timeSeries.options);
}
lastXend = lineEnd;
}
// Delete old data that's moved off the left of the chart.
if (dataSet.length > 1 && this.isRealTime)
timeSeries._dropOldData(Math.floor(canvasWidth / this.options.overSampleFactor));
}
// Periodic render
window.requestAnimationFrame((this._render.bind(this)));
};
/**
*
* @private
*/
HorizontalChart.prototype._drawBar = function (y, xStart, xEnd, dataSample, tsOptions) {
const ctx = this.canvas.getContext("2d");
// Start - End
dataSample.xStart = xStart;
dataSample.xEnd = xEnd;
dataSample.y = y;
//
if (xEnd > this.canvas.width / this.options.overSampleFactor)
return
// bar
ctx.save();
let bar = new Path2D();
ctx.translate(xStart, y); // Aligns the bar starting point to the pattern starting point
bar.rect(0, 0, xEnd - xStart, tsOptions.barHeight);
ctx.fillStyle = dataSample.color;
ctx.fill(bar);
dataSample.path2D = bar;
ctx.restore();
// Print value
if (tsOptions.showValues && !isNaN(dataSample.value)) {
const fontSize = (tsOptions.barHeight - 4 > 0 ? tsOptions.barHeight - 4 : 0);
ctx.font = 'bold ' + fontSize + 'px ' + 'monospace';
const valueString = Number(dataSample.value.toFixed(2)).toString();
const textWidth = Math.ceil(ctx.measureText(valueString).width);
if (textWidth < xEnd - xStart && fontSize > 0) {
ctx.lineWidth = 1;
ctx.fillStyle = "#FFFFFF";
ctx.strokeStyle = 'black';
ctx.fillText(valueString, Math.round(xStart + ((xEnd - xStart) / 2) - (textWidth / 2)), y + fontSize);
ctx.strokeText(valueString, Math.round(xStart + ((xEnd - xStart) / 2) - (textWidth / 2)), y + fontSize);
}
}
}
/**
* Mouse click event callback function.
*
* @param {Object} evt - The mouse click event.
* @private
*/
HorizontalChart.prototype._mouseclick = function (evt) {
return;
};
/**
* Mouse move event callback function.
*
* @param {Object} evt - The mouse move event.
* @private
*/
HorizontalChart.prototype._mousemove = function (evt) {
this.mouseover = true;
if (!this.options.tooltip.enabled)
return;
let el = this._getTooltipEl();
el.style.top = Math.round(evt.pageY) + 'px';
el.style.left = Math.round(evt.pageX) + 'px';
this._updateTooltip(evt);
};
/**
* Mouse out event callback function.
*
* @param {Object} evt - The mouse out event.
* @private
*/
HorizontalChart.prototype._mouseout = function () {
this.mouseover = false;
if (this.tooltipEl)
this.tooltipEl.style.display = 'none';
};
/**
* Retrieve the tooltip element.
*
* @returns The tooltip element.
* @private
*/
HorizontalChart.prototype._getTooltipEl = function () {
if (!this.tooltipEl) {
this.tooltipEl = document.createElement('div');
this.tooltipEl.className = 'horizontalcharts-tooltip';
this.tooltipEl.style.backgroundColor = this.options.tooltip.backgroundColor;
this.tooltipEl.style.border = '0.06em solid black';
this.tooltipEl.style.pointerEvents = 'none';
this.tooltipEl.style.position = 'absolute';
this.tooltipEl.style.display = 'none';
document.body.appendChild(this.tooltipEl);
}
return this.tooltipEl;
};
/**
* Update the tooltip content.
*
* @param {Object} evt - The mouse event.
* @private
*/
HorizontalChart.prototype._updateTooltip = function (evt) {
let el = this._getTooltipEl();
if (!this.mouseover || !this.options.tooltip.enabled) {
el.style.display = 'none';
return;
}
const ctx = this.canvas.getContext("2d");
const osf = this.options.overSampleFactor;
let lines = [];
for (const s of this.seriesSet) {
for (const d of s.data) {
if (d.path2D != null) {
if (ctx.isPointInPath(d.path2D, (evt.offsetX - d.xStart) * osf, (evt.offsetY - d.y) * osf)) {
let line = "";
if (d.desc.length > 0) {
line = "<span><b>" + d.desc + "</b></span>";
lines.push(line);
}
if (!isNaN(d.ts)) {
line = "<span><b>Time:</b> " + this.options.formatTime(d.ts) + "</span>";
lines.push(line);
}
if (!isNaN(d.value)) {
line = "<span><b>Value:</b> " + Number(d.value.toFixed(2)) + "</span>";
lines.push(line);
}
}
}
}
}
if (lines.length > 0) {
el.innerHTML = lines.join('<br>');
el.style.display = 'block';
} else {
el.innerHTML = "";
el.style.display = 'none';
}
};
exports.DataSample = DataSample;
exports.TimeSeries = TimeSeries;
exports.HorizontalChart = HorizontalChart;
})(typeof exports === 'undefined' ? this : exports);