Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion panel/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
"@codemirror/view": "^6.43.15",
"@lezer/highlight": "^1.2.5",
"@preact/signals-core": "^1.14.5",
"chartist": "^1.5.0",
"chartist": "2.2.0",
"prosemirror-commands": "^1.7.2",
"prosemirror-history": "^1.5.1",
"prosemirror-inputrules": "^1.5.1",
Expand Down
10 changes: 5 additions & 5 deletions panel/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

168 changes: 168 additions & 0 deletions panel/src/ts/components/ui/plugins/chartist-hover-line.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { type ChartPlugin, findAll } from "chartist";

declare module "chartist" {
interface ChartEvents {
/** The pointer moved to another point (by index in the series) */
"hover-line:change": { readonly index: number; readonly element: Element };
/** The pointer left the grids, or the chart is about to render again */
"hover-line:leave": Record<string, never>;
}
}

/**
* Describes a point that can be reached by the hover line
*/
interface HoverPoint {
x: number;
index: number;
element: Element;
}

/**
* Describes what the plugin adds to the rendered chart
*/
interface Mounted {
svg: SVGSVGElement;
line: SVGLineElement;
points: HoverPoint[];
// Half of the points' stroke: points on the edges of the grids extend beyond them
margin: number;
}

/**
* Find the point nearest to a position
*/
const findNearestPoint = (points: HoverPoint[], x: number) => points.reduce((a, b) => (Math.abs(b.x - x) < Math.abs(a.x - x) ? b : a));

/**
* Convert the pointer position to the coordinate system of the SVG, which is the one of the points
*/
const getPointerPosition = (event: PointerEvent, svg: SVGSVGElement) => {
const matrix = svg.getScreenCTM();
return matrix ? new DOMPoint(event.clientX, event.clientY).matrixTransform(matrix.inverse()) : undefined;
};

/**
* Show a vertical line on the point nearest to the pointer, while it is inside the grids,
* and emit `hover-line:change` and `hover-line:leave` for other plugins to react to
*/
export const hoverLine = (): ChartPlugin => ({
name: "hover-line",
provides: ["hover-line:change", "hover-line:leave"],
install(chart) {
const container = chart.container;

if (!(container instanceof HTMLElement)) {
return;
}

let mounted: Mounted | undefined;
let currentIndex = -1;

const hide = () => {
mounted?.line.setAttribute("visibility", "hidden");
if (currentIndex !== -1) {
currentIndex = -1;
chart.emit("hover-line:leave", {});
}
};

const onPointer = (event: PointerEvent) => {
if (!mounted || mounted.points.length === 0) {
return;
}

const { svg, line, points, margin } = mounted;
const grids = svg.querySelector<SVGGElement>(".ct-grids");
const position = getPointerPosition(event, svg);

if (!grids || !position) {
return hide();
}

const { x, y, width, height } = grids.getBBox();

if (position.x < x - margin || position.x > x + width + margin || position.y < y - margin || position.y > y + height + margin) {
return hide();
}

const nearest = findNearestPoint(points, position.x);

line.setAttribute("x1", String(nearest.x));
line.setAttribute("x2", String(nearest.x));
line.setAttribute("y1", String(y));
line.setAttribute("y2", String(y + height));
line.removeAttribute("visibility");

if (nearest.index !== currentIndex) {
currentIndex = nearest.index;
chart.emit("hover-line:change", { index: nearest.index, element: nearest.element });
}
};

// The output is touched only once mounted, and what is added here is undone before the next render
chart.on("mount", ({ scene, elementOf }) => {
const svg = container.querySelector("svg");

if (!svg) {
return;
}

const points = findAll(scene, { role: "point" }).flatMap((node) => {
const element = elementOf(node);
return element && node.ctx.valueIndex !== undefined ? [{ x: node.ctx.x, index: node.ctx.valueIndex, element }] : [];
});

const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
line.classList.add("ct-point-line");
line.setAttribute("stroke", "currentColor");
line.setAttribute("stroke-opacity", "0.3");
line.setAttribute("pointer-events", "none");
line.setAttribute("visibility", "hidden");
svg.prepend(line);

const margin = points.length > 0 ? parseFloat(getComputedStyle(points[0].element).strokeWidth) / 2 : 0;

mounted = { svg, line, points, margin };

return () => {
hide();
line.remove();
mounted = undefined;
};
});

// A touch has no hover: the line stays after the finger is lifted, until the next touch elsewhere
const onPointerLeave = (event: PointerEvent) => {
if (event.pointerType !== "touch") {
hide();
}
};

const onDocumentPointerDown = (event: PointerEvent) => {
if (!container.contains(event.target as Node)) {
hide();
}
};

// Lets horizontal touch moves reach the chart, while vertical ones still scroll the page
const previousTouchAction = container.style.touchAction;
container.style.touchAction = "pan-y";

container.addEventListener("pointerdown", onPointer);
container.addEventListener("pointermove", onPointer);
container.addEventListener("pointerleave", onPointerLeave);
container.addEventListener("pointercancel", hide);
container.ownerDocument.addEventListener("pointerdown", onDocumentPointerDown);

return () => {
container.removeEventListener("pointerdown", onPointer);
container.removeEventListener("pointermove", onPointer);
container.removeEventListener("pointerleave", onPointerLeave);
container.removeEventListener("pointercancel", hide);
container.ownerDocument.removeEventListener("pointerdown", onDocumentPointerDown);
container.style.touchAction = previousTouchAction;
hide();
};
},
});
39 changes: 39 additions & 0 deletions panel/src/ts/components/ui/plugins/chartist-hover-tooltip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { ChartPlugin } from "chartist";
import { Tooltip } from "../tooltip";
import "./chartist-hover-line";

/**
* Holds the options of the hover tooltip
*/
interface HoverTooltipOptions {
renderTooltip: (index: number) => string;
}

/**
* Show a tooltip on the point reported by the `hover-line` plugin
*/
export const hoverTooltip = (options: HoverTooltipOptions): ChartPlugin => ({
name: "hover-tooltip",
install(chart) {
let tooltip: Tooltip | undefined;

const hide = () => {
tooltip?.remove();
tooltip = undefined;
};

chart.on("hover-line:change", ({ index, element }) => {
hide();
const strokeWidth = parseFloat(getComputedStyle(element).strokeWidth);
// Tooltip expects an HTMLElement as reference, but only uses the bounding rect of SVG points
tooltip = new Tooltip(options.renderTooltip(index), { referenceElement: element as unknown as HTMLElement, offset: { x: 0, y: -strokeWidth }, delay: 0, removeOnMouseout: false });
// Prevents the tooltip from stealing the pointer and causing flickering
tooltip.element.style.pointerEvents = "none";
tooltip.show();
});

chart.on("hover-line:leave", hide);

return hide;
},
});
48 changes: 12 additions & 36 deletions panel/src/ts/components/ui/statistics-chart.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { circleSmallFill } from "./icons";
import { Tooltip } from "./tooltip";
import { hoverLine } from "./plugins/chartist-hover-line";
import { hoverTooltip } from "./plugins/chartist-hover-tooltip";

/**
* Describes the data of a chart
Expand Down Expand Up @@ -27,11 +28,11 @@ export class StatisticsChart {
private async init(container: HTMLElement, data: NumericChartistData) {
const spacing = 100;

const options = {
const { lineChart } = await import("chartist");

lineChart(container, data, {
showArea: true,
fullWidth: true,
scaleMinSpace: 20,
divisor: 5,
chartPadding: 20,
lineSmooth: false,
low: 0,
Expand All @@ -50,45 +51,20 @@ export class StatisticsChart {
x: 0,
y: 5,
},
scaleMinSpace: 20,
divisor: 5,
},
};

const { LineChart } = await import("chartist");

const chart = new LineChart(container, data, options);

container.classList.remove("is-loading");

chart.on("draw", (event) => {
if (event.type === "point") {
event.element.attr({ "ct:index": event.index });
}
plugins: [hoverLine(), hoverTooltip({ renderTooltip: (index) => this.renderTooltip(data, index) })],
});

container.addEventListener("mouseover", (event) => this.showPointTooltip(event, data));
container.classList.remove("is-loading");
}

/**
* Show a tooltip with the values of the point under the pointer
* Render the content of the tooltip of a point
*/
private showPointTooltip(event: MouseEvent, data: NumericChartistData) {
const target = event.target as SVGElement;

if (target.getAttribute("class") !== "ct-point" || !target.hasAttribute("ct:index")) {
return;
}

const strokeWidth = parseFloat(getComputedStyle(target).strokeWidth);
const index = parseInt(target.getAttribute("ct:index") as string, 10);

const text = `<div>${data.labels[index]}<br><span class="text-color-blue">${circleSmallFill}</span> ${data.series[0][index]} <span class="text-color-amber ml-2">${circleSmallFill}</span>${data.series[1][index]}</div>`;

const tooltip = new Tooltip(text, {
referenceElement: target as unknown as HTMLElement,
offset: { x: 0, y: -strokeWidth },
});

tooltip.show();
private renderTooltip(data: NumericChartistData, index: number) {
return `<div>${data.labels[index]}<br><span class="text-color-blue">${circleSmallFill}</span> ${data.series[0][index]} <span class="text-color-amber ml-2">${circleSmallFill}</span>${data.series[1][index]}</div>`;
}
}

Expand Down
Loading