uce/site/examples/uce-starter/js/u-timeseries-chart.js

354 lines
12 KiB
JavaScript

(function (root, factory) {
if (typeof define === 'function' && define.amd) {
define([], factory);
} else if (typeof module === 'object' && module.exports) {
module.exports = factory();
} else {
root.UTimeSeriesChart = factory();
}
}(typeof self !== 'undefined' ? self : this, function () {
'use strict';
const globalScope = typeof globalThis !== 'undefined' ? globalThis : (typeof window !== 'undefined' ? window : this);
class TimeSeriesChart {
constructor(canvasOrId, options = {}) {
this.canvas = typeof canvasOrId === 'string' ? document.getElementById(canvasOrId) : canvasOrId;
if (!this.canvas || !this.canvas.getContext) {
throw new Error('UTimeSeriesChart requires a canvas element');
}
this.ctx = this.canvas.getContext('2d');
const theme = this.resolveThemeColors();
this.options = Object.assign({
xAxisLabel: 'Time',
yAxisLeftLabel: 'Value',
yAxisRightLabel: '',
yAxisLeftFormat: 'number',
yAxisRightFormat: 'number',
padding: { top: 18, right: 62, bottom: 34, left: 58 },
gridColor: theme.gridColor,
axisColor: theme.axisColor,
textColor: theme.textColor,
legendBackground: theme.legendBackground,
legendTextColor: theme.legendTextColor,
tooltipBackground: theme.tooltipBackground,
tooltipBorder: theme.tooltipBorder,
tooltipTitleColor: theme.tooltipTitleColor,
tooltipTextColor: theme.tooltipTextColor,
hoverLineColor: 'rgba(255,255,255,0.35)',
}, options);
this.series = [];
this.xLabels = [];
this.hoverIndex = null;
this.hoverX = 0;
this.width = 0;
this.height = 0;
this.onMouseMove = this.onMouseMove.bind(this);
this.onMouseLeave = this.onMouseLeave.bind(this);
this.onResize = this.onResize.bind(this);
this.canvas.addEventListener('mousemove', this.onMouseMove);
this.canvas.addEventListener('mouseleave', this.onMouseLeave);
globalScope.addEventListener('resize', this.onResize);
if (typeof ResizeObserver !== 'undefined') {
this.resizeObserver = new ResizeObserver(this.onResize);
this.resizeObserver.observe(this.canvas);
}
this.resize();
}
resolveThemeColors() {
const styles = globalScope.getComputedStyle ? globalScope.getComputedStyle(document.documentElement) : null;
const pick = function (name, fallback) {
if (!styles) return fallback;
const value = styles.getPropertyValue(name).trim();
return value || fallback;
};
return {
gridColor: pick('--border', 'rgba(148, 163, 184, 0.20)'),
axisColor: pick('--border-hover', pick('--border', 'rgba(148, 163, 184, 0.42)')),
textColor: pick('--text-secondary', '#64748b'),
legendBackground: pick('--surface', 'rgba(15, 23, 42, 0.72)'),
legendTextColor: pick('--text-primary', '#0f172a'),
tooltipBackground: pick('--surface', '#0f172a'),
tooltipBorder: pick('--border', 'rgba(148, 163, 184, 0.30)'),
tooltipTitleColor: pick('--primary', '#3b82f6'),
tooltipTextColor: pick('--text-primary', '#0f172a'),
};
}
destroy() {
this.canvas.removeEventListener('mousemove', this.onMouseMove);
this.canvas.removeEventListener('mouseleave', this.onMouseLeave);
globalScope.removeEventListener('resize', this.onResize);
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
}
onResize() {
this.resize();
}
onMouseLeave() {
this.hoverIndex = null;
this.draw();
}
resize() {
const rect = this.canvas.getBoundingClientRect();
const cssWidth = Math.max(280, Math.round(rect.width || this.canvas.width || 640));
const cssHeight = Math.max(180, Math.round(rect.height || this.canvas.height || 320));
const pixelRatio = globalScope.devicePixelRatio || 1;
this.width = cssWidth;
this.height = cssHeight;
this.canvas.width = Math.round(cssWidth * pixelRatio);
this.canvas.height = Math.round(cssHeight * pixelRatio);
this.ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
this.draw();
}
setData(series, xLabels = []) {
this.series = (Array.isArray(series) ? series : []).map((item) => ({
key: item.key || '',
label: item.label || 'Series',
color: item.color || '#60a5fa',
axis: item.axis === 'right' ? 'right' : 'left',
unit: item.unit || '',
format: item.format || '',
maxHint: Number(item.maxHint || 0),
decimals: Number.isInteger(item.decimals) ? item.decimals : 2,
values: Array.isArray(item.values) ? item.values.map((value) => Number(value || 0)) : [],
}));
this.xLabels = Array.isArray(xLabels) ? xLabels : [];
this.draw();
}
getPlotRect() {
const padding = this.options.padding;
return {
x: padding.left,
y: padding.top,
w: this.width - padding.left - padding.right,
h: this.height - padding.top - padding.bottom,
};
}
getMaxForAxis(axis) {
const relevant = this.series.filter((item) => item.axis === axis);
if (!relevant.length) return 1;
let max = 1;
relevant.forEach((item) => {
const localMax = Math.max(...(item.values.length ? item.values : [0]), item.maxHint || 0, 1);
if (localMax > max) max = localMax;
});
return max;
}
xForIndex(index, pointCount, plot) {
if (pointCount <= 1) return plot.x;
return plot.x + (index / (pointCount - 1)) * plot.w;
}
yForValue(value, axisMax, plot) {
const clamped = Math.max(0, Number(value || 0));
return plot.y + plot.h - ((clamped / axisMax) * plot.h);
}
formatValue(value, format, unit, decimals) {
const number = Number(value || 0);
if (globalScope.UFormat) {
if (format === 'bytes' && globalScope.UFormat.formatBytes) return globalScope.UFormat.formatBytes(number);
if (format === 'disk-bytes' && globalScope.UFormat.formatDiskBytes) return globalScope.UFormat.formatDiskBytes(number);
if (format === 'count' && globalScope.UFormat.formatCount) return globalScope.UFormat.formatCount(number);
if (format === 'duration-ms' && globalScope.UFormat.formatDurationMs) return globalScope.UFormat.formatDurationMs(number);
}
return `${number.toFixed(decimals)}${unit || ''}`;
}
drawGridAndAxes(plot, maxLeft, maxRight) {
const ctx = this.ctx;
ctx.strokeStyle = this.options.gridColor;
ctx.lineWidth = 1;
for (let index = 0; index <= 4; index += 1) {
const y = plot.y + (plot.h / 4) * index;
ctx.beginPath();
ctx.moveTo(plot.x, y);
ctx.lineTo(plot.x + plot.w, y);
ctx.stroke();
const leftValue = maxLeft - ((maxLeft / 4) * index);
const leftTick = this.formatValue(leftValue, this.options.yAxisLeftFormat, '', 1);
ctx.fillStyle = this.options.textColor;
ctx.font = '11px Inter, sans-serif';
const leftWidth = ctx.measureText(leftTick).width;
ctx.fillText(leftTick, Math.max(4, plot.x - leftWidth - 8), y + 4);
if (this.options.yAxisRightLabel) {
const rightValue = maxRight - ((maxRight / 4) * index);
const rightTick = this.formatValue(rightValue, this.options.yAxisRightFormat, '', 1);
ctx.fillText(rightTick, plot.x + plot.w + 8, y + 4);
}
}
ctx.strokeStyle = this.options.axisColor;
ctx.beginPath();
ctx.moveTo(plot.x, plot.y);
ctx.lineTo(plot.x, plot.y + plot.h);
ctx.lineTo(plot.x + plot.w, plot.y + plot.h);
ctx.stroke();
ctx.fillStyle = this.options.textColor;
ctx.font = '12px Inter, sans-serif';
ctx.save();
ctx.translate(14, plot.y + (plot.h / 2));
ctx.rotate(-Math.PI / 2);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(this.options.yAxisLeftLabel, 0, 0);
ctx.restore();
if (this.options.yAxisRightLabel) {
ctx.save();
ctx.translate(this.width - 14, plot.y + (plot.h / 2));
ctx.rotate(Math.PI / 2);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(this.options.yAxisRightLabel, 0, 0);
ctx.restore();
}
const xLabel = this.options.xAxisLabel;
const xLabelWidth = ctx.measureText(xLabel).width;
ctx.fillText(xLabel, plot.x + plot.w - xLabelWidth, this.height - 10);
}
drawSeries(plot, maxLeft, maxRight) {
const ctx = this.ctx;
const pointCount = Math.max(...this.series.map((item) => item.values.length), 0);
if (pointCount <= 0) return;
this.series.forEach((item) => {
if (!item.values.length) return;
const axisMax = item.axis === 'right' ? maxRight : maxLeft;
ctx.beginPath();
item.values.forEach((value, index) => {
const x = this.xForIndex(index, item.values.length, plot);
const y = this.yForValue(value, axisMax, plot);
if (index === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
});
ctx.strokeStyle = item.color;
ctx.lineWidth = 2;
ctx.stroke();
});
if (this.series.length > 1) {
ctx.fillStyle = this.options.legendBackground;
ctx.fillRect(plot.x + 4, plot.y + 4, Math.min(plot.w - 8, 320), 24);
let offsetX = plot.x + 12;
this.series.forEach((item) => {
ctx.fillStyle = item.color;
ctx.fillRect(offsetX, plot.y + 13, 14, 3);
offsetX += 20;
ctx.fillStyle = this.options.legendTextColor;
ctx.font = '11px Inter, sans-serif';
ctx.fillText(item.label, offsetX, plot.y + 17);
offsetX += ctx.measureText(item.label).width + 18;
});
}
}
drawHover(plot, maxLeft, maxRight) {
if (this.hoverIndex == null) return;
const ctx = this.ctx;
const pointCount = Math.max(...this.series.map((item) => item.values.length), 0);
if (pointCount <= 0) return;
const index = Math.max(0, Math.min(pointCount - 1, this.hoverIndex));
const x = this.xForIndex(index, pointCount, plot);
ctx.strokeStyle = this.options.hoverLineColor;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x, plot.y);
ctx.lineTo(x, plot.y + plot.h);
ctx.stroke();
const lines = [];
lines.push(this.xLabels[index] || `Sample ${index + 1}`);
this.series.forEach((item) => {
if (index >= item.values.length) return;
const axisMax = item.axis === 'right' ? maxRight : maxLeft;
const value = item.values[index] ?? 0;
const y = this.yForValue(value, axisMax, plot);
ctx.fillStyle = item.color;
ctx.beginPath();
ctx.arc(x, y, 3.5, 0, Math.PI * 2);
ctx.fill();
lines.push(`${item.label}: ${this.formatValue(value, item.format || (item.axis === 'right' ? this.options.yAxisRightFormat : this.options.yAxisLeftFormat), item.unit || '', item.decimals)}`);
});
ctx.font = '11px Inter, sans-serif';
const padding = 8;
const lineHeight = 14;
const tooltipWidth = Math.max(...lines.map((line) => ctx.measureText(line).width)) + padding * 2;
const tooltipHeight = lines.length * lineHeight + padding * 2 - 2;
let tooltipX = this.hoverX + 12;
let tooltipY = plot.y + 8;
if (tooltipX + tooltipWidth > this.width - 4) tooltipX = this.hoverX - tooltipWidth - 12;
if (tooltipX < 4) tooltipX = 4;
if (tooltipY + tooltipHeight > this.height - 4) tooltipY = this.height - tooltipHeight - 4;
ctx.fillStyle = this.options.tooltipBackground;
ctx.fillRect(tooltipX, tooltipY, tooltipWidth, tooltipHeight);
ctx.strokeStyle = this.options.tooltipBorder;
ctx.strokeRect(tooltipX, tooltipY, tooltipWidth, tooltipHeight);
lines.forEach((line, lineIndex) => {
ctx.fillStyle = lineIndex === 0 ? this.options.tooltipTitleColor : this.options.tooltipTextColor;
ctx.fillText(line, tooltipX + padding, tooltipY + padding + 10 + lineIndex * lineHeight);
});
}
onMouseMove(event) {
const rect = this.canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
this.hoverX = x;
const plot = this.getPlotRect();
const pointCount = Math.max(...this.series.map((item) => item.values.length), 0);
if (pointCount <= 0) {
this.hoverIndex = null;
this.draw();
return;
}
const relative = Math.max(0, Math.min(plot.w, x - plot.x));
this.hoverIndex = pointCount <= 1 ? 0 : Math.round((relative / plot.w) * (pointCount - 1));
this.draw();
}
draw() {
const ctx = this.ctx;
ctx.clearRect(0, 0, this.width, this.height);
const plot = this.getPlotRect();
if (plot.w <= 0 || plot.h <= 0) return;
const maxLeft = this.getMaxForAxis('left');
const maxRight = this.getMaxForAxis('right');
this.drawGridAndAxes(plot, maxLeft, maxRight);
this.drawSeries(plot, maxLeft, maxRight);
this.drawHover(plot, maxLeft, maxRight);
}
}
return TimeSeriesChart;
}));