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) { currentOpts = { ...currentOpts, ...o }; resize(); drawBase(); drawOverlay(currentCursor); }, destroy() { ro?.disconnect(); window.removeEventListener('resize', resize); container.removeEventListener('mousemove', handleMouseMove); container.removeEventListener('mouseleave', handleMouseLeave); } }; }