Initial commit
This commit is contained in:
293
src/index.ts
Executable file
293
src/index.ts
Executable 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user