chartjs-plugin-cursors
Version:
Chart.js plugin to draw and sync cursor lines
374 lines (320 loc) • 11.5 kB
text/typescript
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;