@tanstack/charts
Version:
A chart grammar for TypeScript and JavaScript. Marks consume your data directly, channels describe visual encodings, and the engine compiles them into a renderer-neutral keyed scene. TanStack's compact scales cover common numeric and categorical mappings.
932 lines (931 loc) • 33.3 kB
JavaScript
import { mountChartRenderer } from "./renderer.js";
import { createChartRuntime } from "./runtime.js";
import { resolveFocusScene } from "./focus-layer.js";
import { resolveFocusPresentation } from "./focus-presentation.js";
import { resolveMarkStateScene } from "./mark-state.js";
import { resolveMarkStateTransition } from "./mark-state-transition.js";
const defaultPaint = {
fill: "black",
fillOpacity: 1,
stroke: null,
strokeOpacity: 1,
strokeWidth: 1,
opacity: 1,
lineCap: "butt",
lineJoin: "miter",
strokeDasharray: ""
};
function createCanvasChartRenderer(rendererOptions = {}) {
return createUniversalCanvasChartRenderer(rendererOptions);
}
function createUniversalCanvasChartRenderer(rendererOptions = {}) {
const renderer = {
id: "canvas",
prerender(scene, options) {
return renderCanvasShell(scene, options);
},
mount(container, requestRender) {
const document = container.ownerDocument;
const view = document.defaultView;
const root = findOrCreateRoot(container);
const backgroundCanvas = findOrCreateCanvas(
root,
"ts-chart-canvas__background"
);
const focusUnderCanvas = findOrCreateCanvas(
root,
"ts-chart-canvas__focus-under"
);
const sceneCanvas = findOrCreateCanvas(root, "ts-chart-canvas__scene");
const focusCanvas = findOrCreateCanvas(root, "ts-chart-canvas__focus");
const canvas = findOrCreateCanvas(root, "ts-chart-canvas__base");
canvas.style.display = "none";
root.append(
backgroundCanvas,
focusUnderCanvas,
sceneCanvas,
focusCanvas,
canvas
);
const resolver = new CanvasPaintResolver(root);
const mutationObserver = observeTheme(container, requestRender);
const colorScheme = view?.matchMedia?.("(prefers-color-scheme: dark)");
const forcedColors = view?.matchMedia?.("(forced-colors: active)");
const handleEnvironmentChange = () => requestRender(true);
colorScheme?.addEventListener?.("change", handleEnvironmentChange);
forcedColors?.addEventListener?.("change", handleEnvironmentChange);
view?.addEventListener("resize", handleEnvironmentChange);
let scene;
let pixelRatio = 1;
let cancelAnimation = () => {
};
let backgroundAnimationActive = false;
let stateTransition;
let markStatePainted = false;
let destroyed = false;
const startCoordinatedAnimation = (nextScene, animation, captureBase) => {
backgroundAnimationActive = true;
const cancel = animateSceneUpdate(
backgroundCanvas,
sceneCanvas,
captureBase ? canvas : void 0,
nextScene,
pixelRatio,
animation,
resolver,
root,
() => {
backgroundAnimationActive = false;
}
);
cancelAnimation = () => {
backgroundAnimationActive = false;
cancel();
};
};
const surface = {
renderer,
element: root,
canvas,
backgroundCanvas,
focusUnderCanvas,
sceneCanvas,
focusCanvas,
render(nextScene, options) {
if (destroyed) return;
cancelAnimation();
backgroundAnimationActive = false;
cancelAnimation = () => {
};
configureRoot(root, options);
resolver.refresh();
const nextPixelRatio = resolvePixelRatio(
rendererOptions.pixelRatio,
view
);
const canAnimate = options.animation !== void 0 && scene !== void 0 && scene.width === nextScene.width && scene.height === nextScene.height && pixelRatio === nextPixelRatio;
pixelRatio = nextPixelRatio;
sizeCanvas(backgroundCanvas, nextScene, pixelRatio);
sizeCanvas(canvas, nextScene, pixelRatio);
sizeCanvas(sceneCanvas, nextScene, pixelRatio);
sizeCanvas(focusUnderCanvas, nextScene, pixelRatio);
sizeCanvas(focusCanvas, nextScene, pixelRatio);
root.dataset.tsChartWidth = String(nextScene.width);
root.dataset.tsChartHeight = String(nextScene.height);
root.dataset.tsChartPixelRatio = String(pixelRatio);
clearCanvas(focusUnderCanvas, nextScene, pixelRatio);
clearCanvas(focusCanvas, nextScene, pixelRatio);
if (canAnimate) {
startCoordinatedAnimation(nextScene, options.animation, true);
} else {
paintBackgroundCanvas(
backgroundCanvas,
nextScene,
pixelRatio,
resolver
);
paintCanvas(sceneCanvas, nextScene, pixelRatio, resolver, root);
composeBaseCanvas(
canvas,
backgroundCanvas,
sceneCanvas,
nextScene,
pixelRatio
);
}
scene = nextScene;
stateTransition = void 0;
markStatePainted = false;
},
clientToScene(currentScene, clientX, clientY) {
const bounds = root.getBoundingClientRect();
if (!bounds.width || !bounds.height) return null;
return {
x: (clientX - bounds.left) / bounds.width * currentScene.width,
y: (clientY - bounds.top) / bounds.height * currentScene.height
};
},
paintFocus(focus, pointer, cursor) {
if (!scene || destroyed) return;
const state = resolveMarkStateScene(scene, focus, pointer);
const resolved = resolveFocusScene(state.scene, focus);
const previousTransition = stateTransition;
if (state.scene !== scene || markStatePainted || previousTransition) {
const interruptedBackground = backgroundAnimationActive;
cancelAnimation();
backgroundAnimationActive = false;
const transition = resolveMarkStateTransition(
state.transition ?? previousTransition,
root
);
if (transition) {
if (interruptedBackground) {
startCoordinatedAnimation(state.scene, transition, false);
} else {
cancelAnimation = animateScene(
sceneCanvas,
state.scene,
pixelRatio,
transition,
resolver,
root
);
}
} else {
if (interruptedBackground) {
paintBackgroundCanvas(
backgroundCanvas,
state.scene,
pixelRatio,
resolver
);
}
paintCanvas(sceneCanvas, state.scene, pixelRatio, resolver, root);
cancelAnimation = () => {
};
}
}
markStatePainted = Boolean(focus && state.scene !== scene);
stateTransition = focus ? state.transition ?? previousTransition : void 0;
const presentation = resolveFocusPresentation(
resolved.scene,
focus,
pointer,
cursor
);
paintFocusCanvas(
focusUnderCanvas,
resolved.scene,
presentation.under,
pixelRatio,
resolver,
root
);
paintFocusCanvas(
focusCanvas,
resolved.scene,
presentation.over,
pixelRatio,
resolver,
root
);
return resolved.scene;
},
destroy() {
if (destroyed) return;
destroyed = true;
cancelAnimation();
mutationObserver?.disconnect();
colorScheme?.removeEventListener?.("change", handleEnvironmentChange);
forcedColors?.removeEventListener?.("change", handleEnvironmentChange);
view?.removeEventListener("resize", handleEnvironmentChange);
resolver.destroy();
}
};
return surface;
}
};
return renderer;
}
const canvasChartRenderer = createUniversalCanvasChartRenderer();
function mountCanvasChart(container, initialOptions, runtime = createChartRuntime()) {
const withRenderer = (options) => {
return { ...options, renderer: canvasChartRenderer };
};
const host = mountChartRenderer(
container,
withRenderer(initialOptions),
runtime
);
return {
interaction: host.interaction,
update(options) {
host.update(withRenderer(options));
},
getScene: host.getScene,
destroy: host.destroy
};
}
function renderCanvasShell(scene, options) {
const className = options.className ? `ts-chart ts-chart-canvas ${options.className}` : "ts-chart ts-chart-canvas";
const description = options.ariaDescription ? ` aria-description="${escapeAttribute(options.ariaDescription)}"` : "";
const width = integer(scene.width);
const height = integer(scene.height);
return `<div class="${escapeAttribute(className)}" role="img" aria-roledescription="chart" aria-label="${escapeAttribute(options.ariaLabel)}"${description} tabindex="${integer(options.tabIndex ?? 0)}" data-ts-chart-width="${width}" data-ts-chart-height="${height}" data-ts-chart-pixel-ratio="1" style="display:block;position:relative;width:100%;height:100%;overflow:visible"><canvas class="ts-chart-canvas__background" width="${width}" height="${height}" aria-hidden="true" style="display:block;position:absolute;inset:0;width:100%;height:100%;pointer-events:none"></canvas><canvas class="ts-chart-canvas__focus-under" width="${width}" height="${height}" aria-hidden="true" style="display:block;position:absolute;inset:0;width:100%;height:100%;pointer-events:none"></canvas><canvas class="ts-chart-canvas__scene" width="${width}" height="${height}" aria-hidden="true" style="display:block;position:absolute;inset:0;width:100%;height:100%;pointer-events:none"></canvas><canvas class="ts-chart-canvas__focus" width="${width}" height="${height}" aria-hidden="true" style="display:block;position:absolute;inset:0;width:100%;height:100%;pointer-events:none"></canvas><canvas class="ts-chart-canvas__base" width="${width}" height="${height}" aria-hidden="true" style="display:none"></canvas></div>`;
}
function findOrCreateRoot(container) {
const existing = container.querySelector(
":scope > .ts-chart-canvas"
);
if (existing) return existing;
const root = container.ownerDocument.createElement("div");
container.replaceChildren(root);
return root;
}
function findOrCreateCanvas(root, className) {
const existing = root.querySelector(`.${className}`);
if (existing) return existing;
const canvas = root.ownerDocument.createElement("canvas");
canvas.className = className;
canvas.setAttribute("aria-hidden", "true");
Object.assign(canvas.style, {
display: "block",
position: "absolute",
inset: "0",
width: "100%",
height: "100%",
pointerEvents: "none"
});
root.append(canvas);
return canvas;
}
function configureRoot(root, options) {
root.className = options.className ? `ts-chart ts-chart-canvas ${options.className}` : "ts-chart ts-chart-canvas";
root.setAttribute("role", "img");
root.setAttribute("aria-roledescription", "chart");
root.setAttribute("aria-label", options.ariaLabel);
if (options.ariaDescription) {
root.setAttribute("aria-description", options.ariaDescription);
} else {
root.removeAttribute("aria-description");
}
root.tabIndex = options.tabIndex ?? 0;
Object.assign(root.style, {
display: "block",
position: "relative",
width: "100%",
height: "100%",
overflow: "visible"
});
}
function resolvePixelRatio(configured, view) {
const value = configured ?? view?.devicePixelRatio ?? 1;
return Number.isFinite(value) && value > 0 ? value : 1;
}
function sizeCanvas(canvas, scene, pixelRatio) {
const width = Math.max(1, Math.ceil(scene.width * pixelRatio));
const height = Math.max(1, Math.ceil(scene.height * pixelRatio));
if (canvas.width !== width) canvas.width = width;
if (canvas.height !== height) canvas.height = height;
}
function clearCanvas(canvas, scene, pixelRatio) {
const context = requiredContext(canvas);
resetContext(context, pixelRatio);
context.clearRect(0, 0, scene.width, scene.height);
}
function paintCanvas(canvas, scene, pixelRatio, resolver, root) {
const context = requiredContext(canvas);
resetContext(context, pixelRatio);
context.clearRect(0, 0, scene.width, scene.height);
paintScene(context, scene, resolver, root);
}
function paintBackgroundCanvas(canvas, scene, pixelRatio, resolver) {
const context = requiredContext(canvas);
resetContext(context, pixelRatio);
context.clearRect(0, 0, scene.width, scene.height);
paintSceneBackground(context, scene, resolver);
}
function composeBaseCanvas(canvas, backgroundCanvas, sceneCanvas, scene, pixelRatio) {
const context = requiredContext(canvas);
resetContext(context, pixelRatio);
context.clearRect(0, 0, scene.width, scene.height);
context.drawImage(backgroundCanvas, 0, 0, scene.width, scene.height);
context.drawImage(sceneCanvas, 0, 0, scene.width, scene.height);
}
function paintSceneBackground(context, scene, resolver) {
if (scene.theme.background === "transparent") return;
const background = resolver.resolve(scene.theme.background);
if (!background) return;
context.fillStyle = background;
context.fillRect(0, 0, scene.width, scene.height);
}
function resetContext(context, pixelRatio) {
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
context.globalAlpha = 1;
context.setLineDash([]);
}
function animateScene(canvas, scene, pixelRatio, animation, resolver, root) {
const document = canvas.ownerDocument;
const view = document.defaultView;
const duration = Math.max(0, animation.duration ?? 250);
if (!view?.requestAnimationFrame || duration === 0) {
paintCanvas(canvas, scene, pixelRatio, resolver, root);
return () => {
};
}
const previous = copyCanvas(canvas);
const target = sizedCanvas(document, canvas);
paintCanvas(target, scene, pixelRatio, resolver, root);
return crossfadeCanvasLayers(
view,
[{ canvas, previous, target }],
duration,
animation
);
}
function animateSceneUpdate(backgroundCanvas, sceneCanvas, baseCanvas, scene, pixelRatio, animation, resolver, root, onComplete) {
const document = sceneCanvas.ownerDocument;
const view = document.defaultView;
const duration = Math.max(0, animation.duration ?? 250);
if (!view?.requestAnimationFrame || duration === 0) {
paintBackgroundCanvas(backgroundCanvas, scene, pixelRatio, resolver);
paintCanvas(sceneCanvas, scene, pixelRatio, resolver, root);
if (baseCanvas) {
composeBaseCanvas(
baseCanvas,
backgroundCanvas,
sceneCanvas,
scene,
pixelRatio
);
}
onComplete?.();
return () => {
};
}
const previousBackground = copyCanvas(backgroundCanvas);
const targetBackground = sizedCanvas(document, backgroundCanvas);
paintBackgroundCanvas(targetBackground, scene, pixelRatio, resolver);
const previousScene = copyCanvas(sceneCanvas);
const targetScene = sizedCanvas(document, sceneCanvas);
paintCanvas(targetScene, scene, pixelRatio, resolver, root);
if (baseCanvas) {
composeBaseCanvas(
baseCanvas,
targetBackground,
targetScene,
scene,
pixelRatio
);
}
return crossfadeCanvasLayers(
view,
[
{
canvas: backgroundCanvas,
previous: previousBackground,
target: targetBackground
},
{ canvas: sceneCanvas, previous: previousScene, target: targetScene }
],
duration,
animation,
onComplete
);
}
function crossfadeCanvasLayers(view, layers, duration, animation, onComplete) {
const ease = resolveEasing(animation.easing);
let frame;
let canceled = false;
let start;
const paintFrame = (time) => {
if (canceled) return;
start ??= time;
const progress = duration === 0 ? 1 : Math.min(1, (time - start) / duration);
const eased = ease(progress);
for (const { canvas, previous, target } of layers) {
const context = requiredContext(canvas);
context.setTransform(1, 0, 0, 1, 0, 0);
context.clearRect(0, 0, canvas.width, canvas.height);
context.globalAlpha = 1 - eased;
context.drawImage(previous, 0, 0);
context.globalAlpha = eased;
context.drawImage(target, 0, 0);
context.globalAlpha = 1;
}
if (progress < 1) {
frame = view.requestAnimationFrame(paintFrame);
} else {
frame = void 0;
onComplete?.();
}
};
frame = view.requestAnimationFrame(paintFrame);
return () => {
canceled = true;
if (frame !== void 0) view.cancelAnimationFrame(frame);
};
}
function copyCanvas(canvas) {
const copy = sizedCanvas(canvas.ownerDocument, canvas);
requiredContext(copy).drawImage(canvas, 0, 0);
return copy;
}
function sizedCanvas(document, source) {
const canvas = document.createElement("canvas");
canvas.width = source.width;
canvas.height = source.height;
return canvas;
}
function resolveEasing(easing) {
if (typeof easing === "function") return easing;
switch (easing) {
case "ease-in":
return (progress) => progress * progress;
case "ease-out":
return (progress) => 1 - (1 - progress) * (1 - progress);
case "ease":
case "ease-in-out":
return (progress) => progress < 0.5 ? 2 * progress * progress : 1 - Math.pow(-2 * progress + 2, 2) / 2;
default:
return (progress) => progress;
}
}
function paintScene(context, scene, resolver, root) {
const Path = root.ownerDocument.defaultView?.Path2D;
const font = readFont(root);
const painter = { context, resolver, scene, Path, font };
paintNodes(painter, scene.nodes, defaultPaint);
}
function paintFocusCanvas(canvas, scene, nodes, pixelRatio, resolver, root) {
const context = requiredContext(canvas);
resetContext(context, pixelRatio);
context.clearRect(0, 0, scene.width, scene.height);
if (!nodes.length) return;
const Path = root.ownerDocument.defaultView?.Path2D;
const font = readFont(root);
paintNodes({ context, resolver, scene, Path, font }, nodes, defaultPaint);
}
function paintNodes(painter, nodes, parent) {
for (let index = 0; index < nodes.length; index += 1) {
const node = nodes[index];
if (!node) continue;
if (node.kind === "dot") {
index = paintDotRun(painter, nodes, index, parent);
} else {
paintNode(painter, node, parent);
}
}
}
function paintNode(painter, node, parent) {
const state = resolveStyle(parent, node.style);
const { context } = painter;
context.save();
try {
switch (node.kind) {
case "group": {
if (node.focus) return;
context.translate(node.translateX ?? 0, node.translateY ?? 0);
if (node.clip) {
context.beginPath();
context.rect(
node.clip.x,
node.clip.y,
node.clip.width,
node.clip.height
);
context.clip();
}
paintNodes(painter, node.children, state);
return;
}
case "rule":
context.beginPath();
context.moveTo(node.x1, node.y1);
context.lineTo(node.x2, node.y2);
strokeCurrentPath(painter, state, boundsForNode(node));
return;
case "polyline": {
if (node.path) {
const path = pathFromData(painter, node.path);
paintPath(painter, path, state, boundsForNode(node));
} else {
beginPointPath(context, node.points, false);
paintCurrentPath(painter, state, boundsForNode(node));
}
return;
}
case "area": {
if (node.polygons !== void 0) {
beginPolygonPath(context, node.polygons);
paintCurrentPath(painter, state, boundsForNode(node), "evenodd");
} else if (node.path) {
const path = pathFromData(painter, node.path);
paintPath(painter, path, state, boundsForNode(node));
} else {
beginPointPath(context, node.points, true);
paintCurrentPath(painter, state, boundsForNode(node));
}
return;
}
case "dot":
context.beginPath();
context.arc(node.x, node.y, node.radius, 0, Math.PI * 2);
paintCurrentPath(painter, state, boundsForNode(node));
return;
case "rect":
beginRoundedRect(
context,
node.x,
node.y,
node.width,
node.height,
node.radius ?? 0
);
paintCurrentPath(painter, state, boundsForNode(node));
return;
case "label":
paintLabel(painter, node, state);
}
} finally {
context.restore();
}
}
function paintDotRun(painter, nodes, start, parent) {
const first = nodes[start];
if (!first || first.kind !== "dot") return start;
const state = resolveStyle(parent, first.style);
if (usesGradient(state)) {
paintNode(painter, first, parent);
return start;
}
const { context } = painter;
context.save();
try {
context.beginPath();
let index = start;
for (; index < nodes.length; index += 1) {
const node = nodes[index];
if (!node || node.kind !== "dot" || !samePaintState(resolveStyle(parent, node.style), state)) {
break;
}
context.moveTo(node.x + node.radius, node.y);
context.arc(node.x, node.y, node.radius, 0, Math.PI * 2);
}
paintCurrentPath(painter, state, null);
return index - 1;
} finally {
context.restore();
}
}
function usesGradient(state) {
return Boolean(
state.fill?.startsWith("url(#") || state.stroke?.startsWith("url(#")
);
}
function samePaintState(left, right) {
return left.fill === right.fill && left.fillOpacity === right.fillOpacity && left.stroke === right.stroke && left.strokeOpacity === right.strokeOpacity && left.strokeWidth === right.strokeWidth && left.opacity === right.opacity && left.lineCap === right.lineCap && left.lineJoin === right.lineJoin && left.strokeDasharray === right.strokeDasharray;
}
function pathFromData(painter, data) {
if (!painter.Path) {
throw new Error(
"Canvas rendering of curved, polar, or geographic paths requires Path2D."
);
}
return new painter.Path(data);
}
function beginPointPath(context, points, close) {
context.beginPath();
points.forEach(([x, y], index) => {
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
if (close && points.length) context.closePath();
}
function beginPolygonPath(context, polygons) {
context.beginPath();
for (const polygon of polygons) {
for (const ring of polygon) {
ring.forEach(([x, y], index) => {
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
if (ring.length) context.closePath();
}
}
}
function beginRoundedRect(context, x, y, width, height, radius) {
const resolved = Math.max(0, Math.min(radius, width / 2, height / 2));
context.beginPath();
if (resolved === 0) {
context.rect(x, y, width, height);
return;
}
context.moveTo(x + resolved, y);
context.lineTo(x + width - resolved, y);
context.arcTo(x + width, y, x + width, y + resolved, resolved);
context.lineTo(x + width, y + height - resolved);
context.arcTo(
x + width,
y + height,
x + width - resolved,
y + height,
resolved
);
context.lineTo(x + resolved, y + height);
context.arcTo(x, y + height, x, y + height - resolved, resolved);
context.lineTo(x, y + resolved);
context.arcTo(x, y, x + resolved, y, resolved);
context.closePath();
}
function paintPath(painter, path, state, bounds) {
fillPath(painter, state, bounds, path);
strokePath(painter, state, bounds, path);
}
function paintCurrentPath(painter, state, bounds, fillRule) {
fillCurrentPath(painter, state, bounds, fillRule);
strokeCurrentPath(painter, state, bounds);
}
function fillPath(painter, state, bounds, path) {
const fill = resolvePaint(painter, state.fill, bounds);
if (!fill) return;
painter.context.globalAlpha = state.opacity * state.fillOpacity;
painter.context.fillStyle = fill;
painter.context.fill(path);
}
function strokePath(painter, state, bounds, path) {
const stroke = resolvePaint(painter, state.stroke, bounds);
if (!stroke) return;
configureStroke(painter.context, state, stroke);
painter.context.stroke(path);
}
function fillCurrentPath(painter, state, bounds, fillRule) {
const fill = resolvePaint(painter, state.fill, bounds);
if (!fill) return;
painter.context.globalAlpha = state.opacity * state.fillOpacity;
painter.context.fillStyle = fill;
if (fillRule === void 0) painter.context.fill();
else painter.context.fill(fillRule);
}
function strokeCurrentPath(painter, state, bounds) {
const stroke = resolvePaint(painter, state.stroke, bounds);
if (!stroke) return;
configureStroke(painter.context, state, stroke);
painter.context.stroke();
}
function configureStroke(context, state, stroke) {
context.globalAlpha = state.opacity * state.strokeOpacity;
context.strokeStyle = stroke;
context.lineWidth = state.strokeWidth;
context.lineCap = state.lineCap;
context.lineJoin = state.lineJoin;
context.setLineDash(parseDasharray(state.strokeDasharray));
}
function paintLabel(painter, node, state) {
const { context } = painter;
const fontSize = node.fontSize ?? painter.font.size;
const fontWeight = node.fontWeight ?? painter.font.weight;
context.translate(node.x, node.y);
if (node.rotate !== void 0) {
context.rotate(node.rotate * Math.PI / 180);
}
context.font = [
painter.font.style,
fontWeight,
`${fontSize}px`,
painter.font.family
].join(" ");
if ("fontStretch" in context) context.fontStretch = painter.font.stretch;
if ("letterSpacing" in context) {
context.letterSpacing = painter.font.letterSpacing;
}
context.direction = painter.font.direction;
context.textAlign = node.anchor === "middle" ? "center" : node.anchor === "end" ? "right" : "left";
context.textBaseline = node.baseline === "middle" ? "middle" : node.baseline === "hanging" ? "hanging" : "alphabetic";
const fill = resolvePaint(painter, state.fill, null);
if (fill) {
context.globalAlpha = state.opacity * state.fillOpacity;
context.fillStyle = fill;
context.fillText(node.text, 0, 0);
}
const stroke = resolvePaint(painter, state.stroke, null);
if (stroke) {
configureStroke(context, state, stroke);
context.strokeText(node.text, 0, 0);
}
}
function resolveStyle(parent, style) {
if (!style) return parent;
return {
fill: style.fill === void 0 ? parent.fill : paintValue(style.fill),
fillOpacity: style.fillOpacity ?? parent.fillOpacity,
stroke: style.stroke === void 0 ? parent.stroke : paintValue(style.stroke),
strokeOpacity: style.strokeOpacity ?? parent.strokeOpacity,
strokeWidth: style.strokeWidth ?? parent.strokeWidth,
opacity: parent.opacity * (style.opacity ?? 1),
lineCap: style.lineCap ?? parent.lineCap,
lineJoin: resolveLineJoin(style.lineJoin) ?? parent.lineJoin,
strokeDasharray: style.strokeDasharray ?? parent.strokeDasharray
};
}
function paintValue(value) {
return value === "none" ? null : value;
}
function resolveLineJoin(value) {
if (value === "arcs") return "round";
if (value === "miter-clip") return "miter";
return value;
}
function resolvePaint(painter, value, bounds) {
if (!value) return null;
const match = /^url\(#([^)]+)\)$/.exec(value);
if (!match) return painter.resolver.resolve(value);
const gradient = painter.scene.gradients.find(
(candidate) => candidate.id === match[1]
);
if (!gradient) return null;
if (!bounds) {
throw new Error(
`Canvas gradient "${gradient.id}" requires geometry with measurable bounds.`
);
}
const canvasGradient = painter.context.createLinearGradient(
bounds.x + (gradient.x1 ?? 0) * bounds.width,
bounds.y + (gradient.y1 ?? 1) * bounds.height,
bounds.x + (gradient.x2 ?? 0) * bounds.width,
bounds.y + (gradient.y2 ?? 0) * bounds.height
);
for (const stop of gradient.stops) {
const color = painter.resolver.resolve(stop.color);
if (!color) continue;
canvasGradient.addColorStop(
Math.max(0, Math.min(1, stop.offset)),
stop.opacity === void 0 ? color : painter.resolver.withOpacity(color, stop.opacity)
);
}
return canvasGradient;
}
function boundsForNode(node) {
switch (node.kind) {
case "rule":
return boundsFromPoints([
[node.x1, node.y1],
[node.x2, node.y2]
]);
case "polyline":
return boundsFromPoints(node.points);
case "area":
return node.polygons === void 0 ? boundsFromPoints(node.points) : boundsFromPolygons(node.polygons);
case "dot":
return {
x: node.x - node.radius,
y: node.y - node.radius,
width: node.radius * 2,
height: node.radius * 2
};
case "rect":
return { x: node.x, y: node.y, width: node.width, height: node.height };
}
}
function boundsFromPolygons(polygons) {
return boundsFromPoints(polygons.flatMap((polygon) => polygon.flat()));
}
function boundsFromPoints(points) {
if (!points.length) return null;
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
for (const [x, y] of points) {
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
if (!Number.isFinite(minX) || !Number.isFinite(minY)) return null;
return {
x: minX,
y: minY,
width: Math.max(0, maxX - minX),
height: Math.max(0, maxY - minY)
};
}
function parseDasharray(value) {
if (!value || value === "none") return [];
return value.split(/[,\s]+/).map(Number).filter((part) => Number.isFinite(part) && part >= 0);
}
function readFont(root) {
const computed = root.ownerDocument.defaultView?.getComputedStyle(root);
const size = Number.parseFloat(computed?.fontSize ?? "");
return {
family: computed?.fontFamily || "sans-serif",
size: Number.isFinite(size) && size > 0 ? size : 16,
style: computed?.fontStyle || "normal",
weight: computed?.fontWeight || "400",
stretch: normalizeFontStretch(computed?.fontStretch),
direction: computed?.direction === "rtl" ? "rtl" : computed?.direction === "ltr" ? "ltr" : "inherit",
letterSpacing: computed?.letterSpacing || "0px"
};
}
function normalizeFontStretch(value) {
if (value === "ultra-condensed" || value === "extra-condensed" || value === "condensed" || value === "semi-condensed" || value === "normal" || value === "semi-expanded" || value === "expanded" || value === "extra-expanded" || value === "ultra-expanded") {
return value;
}
return "normal";
}
class CanvasPaintResolver {
#root;
#probe;
#cache = /* @__PURE__ */ new Map();
constructor(root) {
this.#root = root;
this.#probe = root.ownerDocument.createElement("span");
this.#probe.setAttribute("aria-hidden", "true");
Object.assign(this.#probe.style, {
position: "absolute",
width: "0",
height: "0",
overflow: "hidden",
pointerEvents: "none",
visibility: "hidden"
});
root.append(this.#probe);
}
refresh() {
this.#cache.clear();
}
resolve(value) {
if (value === "none") return null;
const cached = this.#cache.get(value);
if (cached) return cached;
this.#probe.style.color = "";
this.#probe.style.color = value;
if (!this.#probe.style.color) {
throw new TypeError(`Invalid Canvas paint: ${value}`);
}
const resolved = this.#root.ownerDocument.defaultView?.getComputedStyle(this.#probe).color || value;
this.#cache.set(value, resolved);
return resolved;
}
withOpacity(color, opacity) {
const value = `color-mix(in srgb, ${color} ${Math.max(0, Math.min(1, opacity)) * 100}%, transparent)`;
return this.resolve(value) ?? "transparent";
}
destroy() {
this.#probe.remove();
this.#cache.clear();
}
}
function observeTheme(container, requestRender) {
const Observer = container.ownerDocument.defaultView?.MutationObserver;
if (!Observer) return void 0;
const observer = new Observer(() => requestRender(true));
let current = container;
while (current) {
observer.observe(current, {
attributes: true,
attributeFilter: ["class", "style", "data-theme"]
});
current = current.parentElement;
}
return observer;
}
function requiredContext(canvas) {
const context = canvas.getContext("2d");
if (!context) {
throw new Error("The Canvas renderer requires a Canvas 2D context.");
}
return context;
}
function integer(value) {
return String(Math.max(0, Math.round(value)));
}
function escapeAttribute(value) {
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
}
export {
canvasChartRenderer,
createCanvasChartRenderer,
mountCanvasChart
};