Initial commit

This commit is contained in:
2025-07-15 17:05:35 +02:00
commit ef5ba17e6d
6 changed files with 468 additions and 0 deletions

293
src/index.ts Executable file
View File

@@ -0,0 +1,293 @@
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;
}
): void {
const {
width,
height,
min,
max,
showYAxis = false,
yTicks = 5,
cursorX = null
} = 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;
const MAX_TIME_DELTA = 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';
ctx.fillText(new Date(tAtCursor).toLocaleTimeString(), 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;
}
) {
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';
overlayCtx.fillText(new Date(tAtCursor).toLocaleTimeString(), 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);
}
};
}