303 lines
9.7 KiB
TypeScript
Executable File
303 lines
9.7 KiB
TypeScript
Executable File
export type ChartSeries = {
|
|
data: [number, number][];
|
|
strokeColor?: string;
|
|
};
|
|
|
|
export function drawChart(
|
|
ctx: CanvasRenderingContext2D,
|
|
seriesList: ChartSeries[],
|
|
opts: {
|
|
width: number;
|
|
height: number;
|
|
min?: number;
|
|
max?: number;
|
|
showYAxis?: boolean;
|
|
yTicks?: number;
|
|
backgroundColor?: string;
|
|
cursorX?: number | null;
|
|
maxTimeDelta?: number | null;
|
|
dateFormat?: (ts: number) => string;
|
|
}
|
|
): void {
|
|
const {
|
|
width,
|
|
height,
|
|
min,
|
|
max,
|
|
showYAxis = false,
|
|
yTicks = 5,
|
|
cursorX = null,
|
|
maxTimeDelta
|
|
} = opts;
|
|
|
|
const topPadding = 10;
|
|
const bottomPadding = 10;
|
|
const usableHeight = height - topPadding - bottomPadding;
|
|
|
|
const allY = seriesList.flatMap((s) => s.data.map((d) => d[1]));
|
|
const allX = seriesList.flatMap((s) => s.data.map((d) => d[0]));
|
|
const minVal = min !== undefined ? min : Math.min(...allY);
|
|
const maxVal = max !== undefined ? max : Math.max(...allY);
|
|
const rangeY = maxVal - minVal || 1;
|
|
const minX = Math.min(...allX);
|
|
const maxX = Math.max(...allX);
|
|
const rangeX = maxX - minX || 1;
|
|
|
|
ctx.clearRect(0, 0, width, height);
|
|
|
|
if (showYAxis) {
|
|
ctx.beginPath();
|
|
ctx.strokeStyle = '#aaa';
|
|
ctx.lineWidth = 1;
|
|
ctx.font = '15px sans-serif';
|
|
ctx.fillStyle = seriesList[0]?.strokeColor || '#000';
|
|
for (let i = 0; i <= yTicks; i++) {
|
|
const value = minVal + (rangeY * i) / yTicks;
|
|
const y = topPadding + (1 - (value - minVal) / rangeY) * usableHeight;
|
|
ctx.moveTo(0, y);
|
|
ctx.lineTo(5, y);
|
|
ctx.fillText(value.toFixed(2), 8, y + 5);
|
|
}
|
|
ctx.stroke();
|
|
}
|
|
|
|
for (const series of seriesList) {
|
|
const data = series.data;
|
|
const color = series.strokeColor || '#000';
|
|
ctx.beginPath();
|
|
ctx.strokeStyle = color;
|
|
ctx.lineWidth = 1;
|
|
|
|
let penDown = false;
|
|
let lastT: number | null = null;
|
|
// let MAX_TIME_DELTA = 600;
|
|
let max_time_delta = maxTimeDelta || 600;
|
|
|
|
for (let i = 0; i < data.length; i++) {
|
|
const [ti, vi] = data[i];
|
|
|
|
const invalid =
|
|
ti == null ||
|
|
vi == null ||
|
|
isNaN(ti) ||
|
|
isNaN(vi) ||
|
|
!isFinite(ti) ||
|
|
!isFinite(vi) ||
|
|
vi < minVal ||
|
|
vi > maxVal;
|
|
if (invalid) {
|
|
penDown = false;
|
|
lastT = null;
|
|
continue;
|
|
}
|
|
if (lastT != null && ti - lastT > max_time_delta) penDown = false;
|
|
const x = ((ti - minX) / rangeX) * width;
|
|
const y = topPadding + (1 - (vi - minVal) / rangeY) * usableHeight;
|
|
if (!penDown) {
|
|
ctx.moveTo(x, y);
|
|
penDown = true;
|
|
} else {
|
|
ctx.lineTo(x, y);
|
|
}
|
|
lastT = ti;
|
|
}
|
|
ctx.stroke();
|
|
}
|
|
|
|
if (cursorX !== null && cursorX >= 0 && cursorX <= width) {
|
|
const tAtCursor = minX + (cursorX / width) * rangeX;
|
|
ctx.beginPath();
|
|
ctx.strokeStyle = '#888';
|
|
ctx.lineWidth = 1;
|
|
ctx.moveTo(cursorX, 0);
|
|
ctx.lineTo(cursorX, height);
|
|
ctx.stroke();
|
|
|
|
ctx.font = '12px sans-serif';
|
|
let textY = 15;
|
|
ctx.fillStyle = '#fff';
|
|
const formatTime = opts.dateFormat ?? ((ts: number) => new Date(ts).toLocaleTimeString());
|
|
ctx.fillText(formatTime(tAtCursor), cursorX + 6, textY);
|
|
textY += 15;
|
|
|
|
for (const series of seriesList) {
|
|
const { data, strokeColor = '#fff' } = series;
|
|
let nearest = data[0];
|
|
let dist = Math.abs(nearest[0] - tAtCursor);
|
|
for (let i = 1; i < data.length; i++) {
|
|
const d = Math.abs(data[i][0] - tAtCursor);
|
|
if (d < dist) {
|
|
dist = d;
|
|
nearest = data[i];
|
|
}
|
|
}
|
|
ctx.fillStyle = strokeColor;
|
|
ctx.fillText(nearest[1].toFixed(2), cursorX + 6, textY);
|
|
textY += 15;
|
|
}
|
|
}
|
|
}
|
|
|
|
export function createChartElement(
|
|
seriesList: ChartSeries[],
|
|
opts: {
|
|
width?: number;
|
|
height?: number;
|
|
min?: number;
|
|
max?: number;
|
|
showYAxis?: boolean;
|
|
yTicks?: number;
|
|
backgroundColor?: string;
|
|
maxTimeDelta?: number | null;
|
|
dateFormat?: (ts: number) => string;
|
|
}
|
|
) {
|
|
const container = document.createElement('div');
|
|
container.style.position = 'relative';
|
|
container.style.width = opts.width !== undefined ? `${opts.width}px` : '100%';
|
|
container.style.height = opts.height !== undefined ? `${opts.height}px` : '100%';
|
|
|
|
const baseCanvas = document.createElement('canvas');
|
|
const overlayCanvas = document.createElement('canvas');
|
|
|
|
[baseCanvas, overlayCanvas].forEach((c) => {
|
|
c.style.position = 'absolute';
|
|
c.style.top = c.style.left = '0';
|
|
c.style.width = c.style.height = '100%';
|
|
container.appendChild(c);
|
|
});
|
|
overlayCanvas.style.pointerEvents = 'none';
|
|
|
|
const baseCtx = baseCanvas.getContext('2d')!;
|
|
const overlayCtx = overlayCanvas.getContext('2d')!;
|
|
|
|
let currentSeries = seriesList;
|
|
let currentOpts = { ...opts };
|
|
let currentCursor: number | null = null;
|
|
let rafPending = false;
|
|
|
|
const resize = () => {
|
|
const w = container.clientWidth;
|
|
const h = container.clientHeight;
|
|
if (!w || !h) return;
|
|
if (baseCanvas.width === w && baseCanvas.height === h) return;
|
|
baseCanvas.width = w;
|
|
baseCanvas.height = h;
|
|
overlayCanvas.width = w;
|
|
overlayCanvas.height = h;
|
|
drawBase();
|
|
drawOverlay(currentCursor);
|
|
};
|
|
|
|
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(resize) : null;
|
|
ro?.observe(container);
|
|
window.addEventListener('resize', resize);
|
|
|
|
const drawBase = () => {
|
|
drawChart(baseCtx, currentSeries, {
|
|
...currentOpts,
|
|
width: baseCanvas.width,
|
|
height: baseCanvas.height
|
|
});
|
|
};
|
|
|
|
const drawOverlay = (cursorX: number | null) => {
|
|
overlayCtx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
|
|
if (cursorX == null) return;
|
|
|
|
const allY = currentSeries.flatMap(s => s.data.map(d => d[1]));
|
|
const allX = currentSeries.flatMap(s => s.data.map(d => d[0]));
|
|
const minVal = currentOpts.min ?? Math.min(...allY);
|
|
const maxVal = currentOpts.max ?? Math.max(...allY);
|
|
const rangeY = maxVal - minVal || 1;
|
|
const minX = Math.min(...allX);
|
|
const maxX = Math.max(...allX);
|
|
const rangeX = maxX - minX || 1;
|
|
|
|
const tAtCursor = minX + (cursorX / baseCanvas.width) * rangeX;
|
|
|
|
overlayCtx.beginPath();
|
|
overlayCtx.strokeStyle = '#888';
|
|
overlayCtx.lineWidth = 1;
|
|
overlayCtx.moveTo(cursorX, 0);
|
|
overlayCtx.lineTo(cursorX, overlayCanvas.height);
|
|
overlayCtx.stroke();
|
|
|
|
overlayCtx.font = '12px sans-serif';
|
|
overlayCtx.fillStyle = '#fff';
|
|
const formatTime = currentOpts.dateFormat ?? ((ts: number) => new Date(ts).toLocaleTimeString());
|
|
overlayCtx.fillText(formatTime(tAtCursor), cursorX > baseCanvas.width - 100 ? cursorX - 100 : cursorX + 6, 15);
|
|
|
|
const topPadding = 10;
|
|
const bottomPadding = 10;
|
|
const usableHeight = overlayCanvas.height - topPadding - bottomPadding;
|
|
let textY = 30;
|
|
|
|
for (const { data, strokeColor = '#fff' } of currentSeries) {
|
|
let nearest = data[0];
|
|
let dist = Math.abs(nearest[0] - tAtCursor);
|
|
for (let i = 1; i < data.length; i++) {
|
|
const d = Math.abs(data[i][0] - tAtCursor);
|
|
if (d < dist) {
|
|
dist = d;
|
|
nearest = data[i];
|
|
}
|
|
}
|
|
overlayCtx.fillStyle = strokeColor;
|
|
overlayCtx.fillText(nearest[1].toFixed(2),cursorX > baseCanvas.width - 100 ? cursorX - 100 : cursorX + 6, textY);
|
|
textY += 15;
|
|
|
|
const y = topPadding + (1 - (nearest[1] - minVal) / rangeY) * usableHeight;
|
|
overlayCtx.beginPath();
|
|
overlayCtx.arc(cursorX, y, 3, 0, Math.PI * 2);
|
|
overlayCtx.fill();
|
|
}
|
|
};
|
|
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
const rect = container.getBoundingClientRect();
|
|
const scaleX = baseCanvas.width / rect.width;
|
|
currentCursor = (e.clientX - rect.left) * scaleX;
|
|
if (!rafPending) {
|
|
rafPending = true;
|
|
requestAnimationFrame(() => {
|
|
drawOverlay(currentCursor);
|
|
rafPending = false;
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleMouseLeave = () => {
|
|
currentCursor = null;
|
|
drawOverlay(null);
|
|
};
|
|
|
|
container.addEventListener('mousemove', handleMouseMove);
|
|
container.addEventListener('mouseleave', handleMouseLeave);
|
|
|
|
setTimeout(resize, 0);
|
|
|
|
return {
|
|
element: container,
|
|
setSeries(series: ChartSeries[]) {
|
|
currentSeries = series;
|
|
resize();
|
|
drawBase();
|
|
drawOverlay(currentCursor);
|
|
},
|
|
setOptions(o: Partial<typeof opts>) {
|
|
currentOpts = { ...currentOpts, ...o };
|
|
resize();
|
|
drawBase();
|
|
drawOverlay(currentCursor);
|
|
},
|
|
destroy() {
|
|
ro?.disconnect();
|
|
window.removeEventListener('resize', resize);
|
|
container.removeEventListener('mousemove', handleMouseMove);
|
|
container.removeEventListener('mouseleave', handleMouseLeave);
|
|
}
|
|
};
|
|
}
|
|
|