@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.
129 lines (128 loc) • 6.82 kB
JavaScript
function renderChartSvgWithHooks(scene, options, hooks) {
const idPrefix = options.idPrefix ?? "";
const className = options.className ? `ts-chart ${options.className}` : "ts-chart";
const description = options.ariaDescription ? `<desc>${escapeText(options.ariaDescription)}</desc>` : "";
const definitions = hooks?.renderDefinitions?.(scene, idPrefix) ?? "";
const background = scene.theme.background === "transparent" ? "" : renderNode(
{
kind: "rect",
key: "background",
x: 0,
y: 0,
width: scene.width,
height: scene.height,
style: { fill: scene.theme.background }
},
hooks,
idPrefix
);
return `<svg class="${escapeAttribute(className)}" width="100%" height="100%" viewBox="0 0 ${number(scene.width)} ${number(scene.height)}" role="img" aria-roledescription="chart" aria-label="${escapeAttribute(options.ariaLabel)}" tabindex="${number(options.tabIndex ?? 0)}" style="display:block;overflow:visible">${description}${definitions}${background}${renderSceneNodes(scene.nodes, idPrefix, hooks)}</svg>`;
}
function renderSceneNodes(nodes, idPrefix = "", hooks) {
return nodes.map((node) => renderNode(node, hooks, idPrefix)).join("");
}
function renderFocusGuideLayer(nodes, placement, idPrefix = "", hooks) {
const visibility = nodes.length ? "visible" : "hidden";
return `<g data-ts-key="focus-guide-layer:${placement}" class="ts-chart__focus-guide-layer ts-chart__focus-guide-layer--${placement}" data-ts-focus-layer="${placement}" data-ts-focus-guide-layer="${placement}" aria-hidden="true" visibility="${visibility}">${renderSceneNodes(nodes, idPrefix, hooks ?? focusGuideRenderHooks)}</g>`;
}
const focusGuideRenderHooks = {
renderGroup: renderFocusGuideClip
};
function renderNode(node, hooks, idPrefix) {
const common = renderCommon(node, hooks, idPrefix);
switch (node.kind) {
case "group": {
const transform = node.translateX === void 0 && node.translateY === void 0 ? "" : ` transform="translate(${number(node.translateX ?? 0)} ${number(node.translateY ?? 0)})"`;
const extension = hooks?.renderGroup?.(node, idPrefix);
const focus = node.focus ? ` data-ts-focus-layer="${node.focus.placement}"${node.focus.retarget ? ' data-ts-focus-retarget="true"' : ""} visibility="hidden"` : "";
return `<g${common}${transform}${focus}${extension?.attributes ?? ""}>${extension?.content ?? ""}${node.children.map((child) => renderNode(child, hooks, idPrefix)).join("")}</g>`;
}
case "rule":
return `<line${common} x1="${number(node.x1)}" y1="${number(node.y1)}" x2="${number(node.x2)}" y2="${number(node.y2)}"/>`;
case "polyline": {
const path = node.path ?? node.points.map(
([x, y], index) => `${index === 0 ? "M" : "L"}${number(x)},${number(y)}`
).join("");
return `<path${common} d="${path}" vector-effect="non-scaling-stroke"/>`;
}
case "area": {
const path = node.polygons !== void 0 ? polygonsPath(node.polygons) : node.path ?? pointsPath(node.points, true);
const fillRule = node.polygons === void 0 ? "" : ' fill-rule="evenodd"';
return `<path${common} d="${path}"${fillRule} vector-effect="non-scaling-stroke"/>`;
}
case "dot":
return `<circle${common} cx="${number(node.x)}" cy="${number(node.y)}" r="${number(node.radius)}"/>`;
case "rect":
return `<rect${common} x="${number(node.x)}" y="${number(node.y)}" width="${number(node.width)}" height="${number(node.height)}"${node.radius === void 0 ? "" : ` rx="${number(node.radius)}"`}/>`;
case "label": {
const transform = node.rotate === void 0 ? "" : ` transform="rotate(${number(node.rotate)} ${number(node.x)} ${number(node.y)})"`;
const anchor = node.anchor ? ` text-anchor="${node.anchor}"` : "";
const baseline = node.baseline ? ` dominant-baseline="${node.baseline}"` : "";
const fontSize = node.fontSize === void 0 ? "" : ` font-size="${number(node.fontSize)}"`;
const fontWeight = node.fontWeight === void 0 ? "" : ` font-weight="${number(node.fontWeight)}"`;
return `<text${common} x="${number(node.x)}" y="${number(node.y)}"${anchor}${baseline}${transform}${fontSize}${fontWeight} font-family="inherit">${escapeText(node.text)}</text>`;
}
}
}
function polygonsPath(polygons) {
return polygons.flatMap((polygon) => polygon).filter((ring) => ring.length > 0).map((ring) => pointsPath(ring, true)).join("");
}
function pointsPath(points, close) {
return `${points.map(
([x, y], index) => `${index === 0 ? "M" : "L"}${number(x)},${number(y)}`
).join("")}${close ? "Z" : ""}`;
}
function renderFocusGuideClip(node, idPrefix) {
if (!node.clip) return void 0;
const prefix = idPrefix.replaceAll(/[^a-zA-Z0-9_-]/g, "");
const id = `${prefix ? `${prefix}-` : ""}ts-chart-clip-${stableId(node.key)}`;
return {
attributes: ` clip-path="url(#${id})"`,
content: `<defs data-ts-key="${escapeAttribute(`${node.key}:clip-defs`)}"><clipPath id="${id}"><rect x="${number(node.clip.x)}" y="${number(node.clip.y)}" width="${number(node.clip.width)}" height="${number(node.clip.height)}"/></clipPath></defs>`
};
}
function stableId(value) {
let hash = 2166136261;
for (let index = 0; index < value.length; index += 1) {
hash = Math.imul(hash ^ value.charCodeAt(index), 16777619);
}
return (hash >>> 0).toString(36);
}
function renderCommon(node, hooks, idPrefix) {
const key = ` data-ts-key="${escapeAttribute(node.key)}"`;
const className = node.className ? ` class="${escapeAttribute(node.className)}"` : "";
const ariaHidden = node.ariaHidden ? ' aria-hidden="true"' : "";
return `${key}${className}${ariaHidden}${renderStyle(node.style, hooks, idPrefix)}`;
}
function renderStyle(style, hooks, idPrefix) {
if (!style) return "";
const paint = (value) => value && hooks?.resolvePaint ? hooks.resolvePaint(value, idPrefix) : value;
const attributes = [
["fill", paint(style.fill)],
["fill-opacity", style.fillOpacity],
["stroke", paint(style.stroke)],
["stroke-opacity", style.strokeOpacity],
["stroke-width", style.strokeWidth],
["opacity", style.opacity],
["stroke-linecap", style.lineCap],
["stroke-linejoin", style.lineJoin],
["stroke-dasharray", style.strokeDasharray]
];
return attributes.filter((entry) => entry[1] != null).map(
([name, value]) => ` ${name}="${typeof value === "number" ? number(value) : escapeAttribute(value)}"`
).join("");
}
function number(value) {
return String(Math.round(value * 100) / 100);
}
function escapeText(value) {
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
}
function escapeAttribute(value) {
return escapeText(value).replaceAll('"', """);
}
export {
renderChartSvgWithHooks,
renderFocusGuideLayer,
renderSceneNodes
};