UNPKG

chartjs-plugin-cursors

Version:

Chart.js plugin to draw and sync cursor lines

374 lines (320 loc) 11.5 kB
import { Chart, ChartEvent, Plugin, Scale } from "chart.js"; import { CursorOptions, CursorPosition } from "./types"; const defaultOptions: CursorOptions = { enabled: false, visible: true, positions: [], line: { color: "blue", width: 1, dashPattern: [], }, }; const getXScale = (chart: Chart): Scale | null => { const meta = chart.getDatasetMeta(0); return chart.data.datasets.length && meta.xAxisID ? chart.scales[meta.xAxisID] : null; }; const getYScale = (chart: Chart): Scale | null => { const meta = chart.getDatasetMeta(0); return chart.data.datasets.length && meta.yAxisID ? chart.scales[meta.yAxisID] : null; }; const CursorsPlugin: Plugin<"line"> = { id: "cursors", afterInit(chart: Chart) { if (!chart.options?.scales?.x) { return; } const xScaleType = chart.options.scales.x.type; if ( ![ "linear", "time", "timeseries", "category", "logarithmic", "realtime", ].includes(xScaleType as string) ) { return; } if (!chart.options?.plugins) { chart.options.plugins = {}; } if (chart.options.plugins?.cursors === undefined) { chart.options.plugins.cursors = defaultOptions; } if (!chart.options.plugins.cursors.enabled) { return; } let positions: CursorPosition[] = []; if (chart.options.plugins.cursors.positions) { positions = chart.options.plugins.cursors.positions.map( (position) => ({ previousX: undefined, previousY: undefined, x: position.x ?? 0, y: position.y ?? 0, }) ); } chart.cursors = { enabled: true, visible: chart.options.plugins.cursors.visible ?? true, suppressUpdate: false, ignoreNextEvents: 0, selected: -1, positions, startTime: undefined, centreCursors: this.centreCursors, hitTest: (x: number, y: number, threshold: number) => this.hitTestCursor(chart, x, y, threshold), setStartTime: (time: number) => { chart.cursors.startTime = time; }, }; }, afterEvent( chart: Chart, args: { event: ChartEvent; replay: boolean; changed?: boolean; cancelable: false; inChartArea: boolean; } ) { if ( !chart.cursors?.enabled || chart.cursors.suppressUpdate || !chart.options?.scales?.x ) { return; } // do nothing if xScale is not linear, time, timeseries, category, logarithmic or realtime const xScaleType = chart.options.scales.x.type as string; if ( ![ "linear", "time", "timeseries", "category", "logarithmic", "realtime", ].includes(xScaleType) ) { return; } // if startTime is not set, check options else do nothing if ( chart.cursors.startTime === undefined && xScaleType === "realtime" ) { chart.cursors.startTime = chart.options.plugins?.cursors?.startTime; if (chart.cursors.startTime === undefined) { return; } } const xScale = getXScale(chart); const yScale = getYScale(chart); if (!xScale || !yScale) { return; } if (chart.cursors.ignoreNextEvents > 0) { chart.cursors.ignoreNextEvents -= 1; return; } const e = args.event; if (!e.x || !e.y) { return false; } // used to do a check for cursor outside chart here switch (e.type) { case "mousedown": const selected = chart.cursors.hitTest(e.x, e.y, 10); chart.cursors.selected = selected; break; case "mouseup": chart.cursors.selected = -1; break; } if ( chart.cursors.positions && chart.cursors.selected >= chart.cursors.positions.length ) { chart.cursors.selected = -1; } if (chart.cursors.selected >= 0) { chart.cursors.positions[chart.cursors.selected].x = xScale.getValueForPixel(e.x) ?? 0; chart.cursors.positions[chart.cursors.selected].y = yScale.getValueForPixel(e.y) ?? 0; } chart.draw(); }, afterDraw(chart: Chart) { if (!chart.cursors?.enabled) { return false; } const newVisible = chart.options.plugins?.cursors?.visible; if (!newVisible) { chart.cursors.visible = false; return false; } // if visible has changed, centre cursors if (chart.cursors.visible !== newVisible) { chart.cursors.visible = newVisible; this.centreCursors(chart); } const xScaleType = chart.options.scales?.x?.type; if ( (xScaleType as string) === "realtime" && chart.cursors.startTime === undefined ) { chart.cursors.startTime = chart.options.plugins?.cursors?.startTime; if (chart.cursors.startTime === undefined) { return false; } } this.drawCursors(chart); return true; }, drawCursors(chart: Chart) { if (!chart.cursors?.enabled || !chart.cursors.visible) { return; } const xScale = getXScale(chart); const yScale = getYScale(chart); if (!xScale || !yScale) { return; } const xScaleType = chart.options.scales?.x?.type as string; if ( chart.cursors.positions?.some( (p) => p.x === undefined || p.y === undefined ) ) { this.centreCursors(chart); chart.draw(); } const xMinPixel = xScale.getPixelForValue(xScale.min); const xMaxPixel = xScale.getPixelForValue(xScale.max); const yMinPixel = yScale.getPixelForValue(yScale.min); const yMaxPixel = yScale.getPixelForValue(yScale.max); const lineWidth = chart.options.plugins?.cursors?.line?.width ?? 1; const color = chart.options.plugins?.cursors?.line?.color ?? "blue"; const dashPattern = (chart.options.plugins?.cursors?.line ?.dashPattern ?? []) as number[]; chart.cursors.positions?.forEach((cursor, index) => { if (cursor.x === undefined || cursor.y === undefined) { return; } const cursorX = xScale.getPixelForValue(cursor.x); const cursorY = yScale.getPixelForValue(cursor.y); let xString = cursor.x.toFixed(3); if ( xScaleType === "realtime" && chart.cursors.startTime !== undefined ) { const secondsSinceStart = ( (cursor.x - chart.cursors.startTime) / 1000 ).toFixed(2); xString = `(${secondsSinceStart}s`; } const ctx = chart.ctx; ctx.save(); ctx.fillStyle = color; ctx.font = "12px Arial"; if (chart.cursors.selected === index) { ctx.setLineDash([]); ctx.fillText( `${xString}, ${cursor.y.toFixed(3)})`, cursorX + 5, cursorY - 5 ); } else { ctx.beginPath(); ctx.arc(cursorX, cursorY, 3, 0, 2 * Math.PI); ctx.fill(); ctx.fillText( `${index + 1}`, cursorX - 12, cursorY + 12 ); } // Draw vertical and horizontal lines ctx.beginPath(); ctx.setLineDash(dashPattern); ctx.lineWidth = lineWidth; ctx.strokeStyle = color; ctx.moveTo(cursorX, yMaxPixel); ctx.lineTo(cursorX, yMinPixel); ctx.moveTo(xMinPixel, cursorY); ctx.lineTo(xMaxPixel, cursorY); ctx.stroke(); ctx.restore(); }); const afterMoveCallback = chart.options.plugins?.cursors?.callbacks?.afterMove; if (afterMoveCallback && chart.cursors.positions) { const changed = chart.cursors.positions.some( (cursor) => cursor.previousX !== cursor.x || cursor.previousY !== cursor.y ); if (changed) { const positions = chart.cursors.positions.map((p, i) => ({ index: i, x: parseFloat(p.x?.toFixed(2) ?? "0"), y: parseFloat(p.y?.toFixed(2) ?? "0"), })); afterMoveCallback(positions); chart.cursors.positions.forEach((cursor) => { cursor.previousX = cursor.x; cursor.previousY = cursor.y; }); } } }, hitTestCursor( chart: Chart, x: number, y: number, threshold: number ): number { const xScale = getXScale(chart); const yScale = getYScale(chart); if (!xScale || !yScale || !chart.cursors.positions) return -1; return chart.cursors.positions.findIndex((cursor) => { if (cursor.x === undefined || cursor.y === undefined) return false; const cursorX = xScale.getPixelForValue(cursor.x); const cursorY = yScale.getPixelForValue(cursor.y); const distance = Math.sqrt((x - cursorX) ** 2 + (y - cursorY) ** 2); return distance < threshold; }); }, centreCursors(chart: Chart) { const xScale = getXScale(chart); const yScale = getYScale(chart); if (!xScale || !yScale) { return; } const xDistance = xScale.max - xScale.min; const yDistance = yScale.max - yScale.min; const xPrecision = xDistance <= 5 ? 1 : 0; const yPrecision = yDistance <= 5 ? 1 : 0; chart.cursors.positions?.forEach(function (cursor) { cursor.x = parseFloat( ((xScale.min + xScale.max) / 2).toFixed(xPrecision) ); cursor.y = parseFloat( ((yScale.min + yScale.max) / 2).toFixed(yPrecision) ); }); }, }; export default CursorsPlugin;