Files
brk/website_next/chart/xy/index.js
T
2026-07-04 13:27:57 +02:00

252 lines
7.2 KiB
JavaScript

import { createSeriesHighlight } from "../highlight.js";
import { createLegend } from "../legend/index.js";
import { getChartPointRadius, layoutChartMarker } from "../marker.js";
import { createLinePathData } from "../path.js";
import { createSvgElement } from "../svg.js";
import { createChartFrame, VIEWBOX_WIDTH } from "../viewbox.js";
/**
* @param {Object} args
* @param {string} args.title
* @param {ChartUnit} args.unit
* @param {string} args.ariaLabel
* @param {number} args.fallbackHeight
* @param {ChartFrameOptions} [args.gutter]
* @param {XySeries[]} args.series
* @param {(frame: ChartFrame) => XyPlottedSeries[]} args.plot
* @param {false | ((series: XySeries, plotted: XyPlottedSeries, point: ChartPoint) => string)} [args.marker]
*/
export function createXyChart({
title,
unit,
ariaLabel,
fallbackHeight,
gutter,
series,
plot,
marker,
}) {
const frameOptions =
gutter ?? (marker === false ? { topPadding: 0 } : {});
const figure = document.createElement("figure");
const plotElement = document.createElement("div");
const svg = createSvgElement("svg");
const group = createSvgElement("g");
const guide = createSvgElement("line");
const markerElement = document.createElement("div");
const { legend, menu, items, readout } = createLegend({
title,
unit,
series,
});
const highlight = createSeriesHighlight(items, menu);
const resizeObserver = new ResizeObserver(render);
/** @type {XyPlottedSeries[]} */
let currentSeries = [];
/** @type {ChartFrame | undefined} */
let currentFrame;
let rect = svg.getBoundingClientRect();
let pointerX = 0;
let pointerY = 0;
let pointerFrame = 0;
figure.dataset.chart = "xy";
figure.dataset.chartLegend = "";
plotElement.dataset.chart = "plot";
guide.dataset.chart = "xy-guide";
markerElement.dataset.chartMarker = "xy";
svg.setAttribute("viewBox", `0 0 ${VIEWBOX_WIDTH} ${fallbackHeight}`);
svg.setAttribute("role", "img");
svg.setAttribute("aria-label", ariaLabel);
svg.append(guide, group);
plotElement.append(svg, markerElement);
figure.append(legend, plotElement);
function measure() {
rect = svg.getBoundingClientRect();
}
function render() {
const frame = createChartFrame(svg, fallbackHeight, frameOptions);
const plottedSeries = plot(frame);
const radius = getChartPointRadius(svg.getBoundingClientRect().width);
svg.setAttribute("viewBox", `0 0 ${VIEWBOX_WIDTH} ${frame.height}`);
currentFrame = frame;
currentSeries = plottedSeries;
highlight.clearNodes();
group.replaceChildren();
hideMarker();
updateReadout(readout, unit, plottedSeries);
plottedSeries.forEach((plotted, index) => {
const item = series[index];
if (item.kind === "line") {
appendLine(group, highlight, item, plotted, index);
} else {
appendPoints(group, highlight, item, plotted, index, radius);
}
});
}
/** @param {PointerEvent} event */
function updateFromPointer(event) {
pointerX = event.clientX;
pointerY = event.clientY;
if (pointerFrame) return;
pointerFrame = requestAnimationFrame(() => {
pointerFrame = 0;
if (!currentFrame) return;
const x = ((pointerX - rect.left) / rect.width) * VIEWBOX_WIDTH;
const y = ((pointerY - rect.top) / rect.height) * currentFrame.height;
const closest = findClosestPoint(series, currentSeries, x, y);
if (!closest) {
hideMarker();
return;
}
showMarker(closest, currentFrame);
});
}
/**
* @param {{ index: number, point: ChartPoint }} closest
* @param {ChartFrame} frame
*/
function showMarker(closest, frame) {
const plotted = currentSeries[closest.index];
const item = series[closest.index];
guide.setAttribute("x1", closest.point.plotX.toFixed(2));
guide.setAttribute("x2", closest.point.plotX.toFixed(2));
guide.setAttribute("y1", "0");
guide.setAttribute("y2", frame.bottom.toString());
const text =
marker === false
? ""
: (marker?.(item, plotted, closest.point) ??
unit.format(closest.point.y));
markerElement.textContent = text;
markerElement.hidden = !text;
if (text) layoutChartMarker(markerElement, closest.point.plotX);
svg.dataset.xyHover = "true";
highlight.preview(closest.index);
}
function hideMarker() {
delete svg.dataset.xyHover;
markerElement.textContent = "";
markerElement.hidden = true;
highlight.clearPreview();
}
function disconnect() {
if (pointerFrame) cancelAnimationFrame(pointerFrame);
pointerFrame = 0;
resizeObserver.disconnect();
}
render();
requestAnimationFrame(render);
resizeObserver.observe(svg);
svg.addEventListener("pointerenter", measure);
svg.addEventListener("pointermove", updateFromPointer);
svg.addEventListener("pointerleave", () => {
if (pointerFrame) cancelAnimationFrame(pointerFrame);
pointerFrame = 0;
hideMarker();
});
figure.addEventListener("chart:destroy", disconnect, { once: true });
return figure;
}
/**
* @param {XySeries[]} series
* @param {XyPlottedSeries[]} plottedSeries
* @param {number} x
* @param {number} y
*/
function findClosestPoint(series, plottedSeries, x, y) {
/** @type {{ index: number, point: ChartPoint } | null} */
let closest = null;
let closestDistance = Infinity;
plottedSeries.forEach((item, index) => {
if (series[index].hidden) return;
for (const point of item.points) {
const distance = Math.hypot(point.plotX - x, point.plotY - y);
if (distance < closestDistance) {
closest = { index, point };
closestDistance = distance;
}
}
});
return closest;
}
/**
* @param {LegendReadout} readout
* @param {ChartUnit} unit
* @param {XyPlottedSeries[]} plottedSeries
*/
function updateReadout(readout, unit, plottedSeries) {
readout.rows.forEach((row, index) => {
if (!row) return;
const value = plottedSeries[index]?.readout;
row.value.textContent =
typeof value === "number" ? unit.format(value) : (value ?? "");
});
}
/**
* @param {SVGGElement} group
* @param {SeriesHighlight} highlight
* @param {XySeries} series
* @param {XyPlottedSeries} plotted
* @param {number} index
*/
function appendLine(group, highlight, series, plotted, index) {
const path = createSvgElement("path");
path.dataset.chart = "xy-line";
path.dataset.series = index.toString();
path.style.setProperty("--color", series.color());
path.setAttribute("d", createLinePathData(plotted.points));
highlight.addNode(path, index);
group.append(path);
}
/**
* @param {SVGGElement} group
* @param {SeriesHighlight} highlight
* @param {XySeries} series
* @param {XyPlottedSeries} plotted
* @param {number} index
* @param {number} radius
*/
function appendPoints(group, highlight, series, plotted, index, radius) {
for (const point of plotted.points) {
const circle = createSvgElement("circle");
circle.dataset.chart = "xy-point";
circle.dataset.series = index.toString();
circle.style.setProperty("--color", series.color());
circle.setAttribute("cx", point.plotX.toFixed(2));
circle.setAttribute("cy", point.plotY.toFixed(2));
circle.setAttribute("r", radius.toString());
highlight.addNode(circle, index);
group.append(circle);
}
}