UNPKG

@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.

576 lines (575 loc) 17 kB
import { channelValues } from "./mark.js"; import { createMarkWithScaleValues } from "./mark-with-scale-values.js"; import { resolveGuideMargins } from "./guide-layout.js"; import { valueKey } from "./scales.js"; import { createChartScene } from "./scene.js"; import { embedChartScene } from "./scene-embed-internal.js"; function facet(source, options) { const data = Array.isArray(source) ? source : Array.from(source); const mark = createMarkWithScaleValues(({ markIndex }) => { const id = options.id ?? `facet-${markIndex}`; const keys = channelValues(data, options.by, () => ""); const groups = /* @__PURE__ */ new Map(); data.forEach((datum, index) => { const key = keys[index]; if (!isKey(key)) return; const identity = valueKey(key); const group = groups.get(identity); if (group) group.data.push(datum); else groups.set(identity, { key, data: [datum] }); }); return { id, channels: {}, render: ({ chart, theme, layout }) => { const entries = [...groups.values()]; const gap = Math.max(0, options.gap ?? 16); const automaticColumns = Math.max( 1, Math.floor((chart.width + gap) / ((options.minWidth ?? 220) + gap)) ); const columns = Math.max( 1, Math.min( entries.length || 1, Math.floor(options.columns ?? automaticColumns) ) ); const rows = Math.max(1, Math.ceil(entries.length / columns)); const cellWidth = cellSize(chart.width, gap, columns); const cellHeight = cellSize(chart.height, gap, rows); const showLabel = options.label !== false; const labelHeight = showLabel ? 22 : 0; const definitions = entries.map( (entry) => mergeTheme(options.chart(entry.data, { key: entry.key }), theme) ); if (options.axes === "cell" || entries.length <= 1 || definitions.every((definition) => definition.guides === false)) { return renderCellAxes({ id, entries, definitions, chart, columns, cellWidth, cellHeight, gap, labelHeight, showLabel, label: options.label, theme, layout }); } const guideScenes = entries.map( (entry, index) => createFacetScene( id, entry, { ...definitions[index], marks: definitions[index].marks.map(withoutMarkRendering) }, { width: cellWidth, height: Math.max(1, cellHeight - labelHeight) }, layout ) ); assertOuterAxes(id, definitions, guideScenes); const margin = resolveOuterMargin({ id, entries, definitions, chart, columns, rows, gap, labelHeight, initial: maxSceneMargins(guideScenes), layout }); return renderOuterAxes({ id, entries, definitions, chart, columns, rows, gap, labelHeight, showLabel, label: options.label, theme, margin, layout }); } }; }, options.motion); return mark; } function resolveOuterMargin(options) { const { id, entries, definitions, chart, columns, rows, gap, labelHeight, layout } = options; let margin = options.initial; for (let pass = 0; pass < 4; pass += 1) { const plotWidth = cellSize( Math.max(1, chart.width - margin.left - margin.right), gap, columns ); const plotHeight = cellSize( Math.max( 1, chart.height - margin.top - margin.bottom - labelHeight * rows ), gap, rows ); const scenes = entries.map( (entry, index) => createFacetScene( id, entry, { ...definitions[index], marks: definitions[index].marks.map(withoutMarkRendering), margin: 0 }, { width: plotWidth, height: plotHeight }, layout ) ); assertOuterAxes(id, definitions, scenes); const measured = scenes.map((scene) => { const axes = scene.nodes.find( (node) => node.kind === "group" && node.key === "axes" ); return axes ? resolveGuideMargins( axes, { x: 0, y: 0, width: plotWidth, height: plotHeight }, { measureText: layout?.measureText } ) : { top: 0, right: 0, bottom: 0, left: 0 }; }); const next = maxMargins([margin, ...measured]); if (sameMargin(margin, next)) return next; margin = next; } return margin; } function renderCellAxes(options) { const { id, entries, definitions, chart, columns, cellWidth, cellHeight, gap, labelHeight, showLabel, label, theme, layout } = options; const points = []; const focusGuides = []; const children = entries.map((entry, index) => { const column = index % columns; const row = Math.floor(index / columns); const x = chart.x + column * (cellWidth + gap); const y = chart.y + row * (cellHeight + gap); const scene = createFacetScene( id, entry, definitions[index], { width: cellWidth, height: Math.max(1, cellHeight - labelHeight) }, layout ); const identity = valueKey(entry.key); const offset = offsetScene(id, identity, scene, x, y + labelHeight); points.push(...offset.points); focusGuides.push(...offset.focusGuides); return facetCell({ id, identity, entry, x, y, width: cellWidth, labelHeight, showLabel, label, theme, nodes: offset.nodes }); }); return facetScene(id, children, points, focusGuides); } function renderOuterAxes(options) { const { id, entries, definitions, chart, columns, rows, gap, labelHeight, showLabel, label, theme, margin, layout } = options; const plotWidth = cellSize( Math.max(1, chart.width - margin.left - margin.right), gap, columns ); const plotHeight = cellSize( Math.max(1, chart.height - margin.top - margin.bottom - labelHeight * rows), gap, rows ); const originX = chart.x + margin.left; const originY = chart.y + margin.top; const points = []; const focusGuides = []; const axes = []; let xTitle; let yTitle; let deepestPlotBottom = originY; const children = entries.map((entry, index) => { const column = index % columns; const row = Math.floor(index / columns); const x = originX + column * (plotWidth + gap); const y = originY + row * (plotHeight + labelHeight + gap); const plotY = y + labelHeight; const scene = createFacetScene( id, entry, { ...definitions[index], margin: 0 }, { width: plotWidth, height: plotHeight }, layout ); const axis = scene.nodes.find( (node) => node.kind === "group" && node.key === "axes" ); const identity = valueKey(entry.key); deepestPlotBottom = Math.max(deepestPlotBottom, plotY + plotHeight); const offset = offsetScene(id, identity, scene, x, plotY); points.push(...offset.points); focusGuides.push(...offset.focusGuides); if (axis && column === 0) { const yChildren = axis.children.filter( (node) => node.key.startsWith("y-") && node.key !== "y-label" ); if (yChildren.length) { axes.push({ kind: "group", key: `${id}:outer-y:${row}`, className: "ts-chart__facet-axis ts-chart__facet-axis--y", ariaHidden: true, translateX: x, translateY: plotY, children: yChildren }); } } if (axis && index + columns >= entries.length) { const xChildren = axis.children.filter( (node) => node.key.startsWith("x-") && node.key !== "x-label" ); if (xChildren.length) { axes.push({ kind: "group", key: `${id}:outer-x:${column}`, className: "ts-chart__facet-axis ts-chart__facet-axis--x", ariaHidden: true, translateX: x, translateY: plotY, children: xChildren }); } } xTitle ??= axis?.children.find( (node) => node.kind === "label" && node.key === "x-label" ); yTitle ??= axis?.children.find( (node) => node.kind === "label" && node.key === "y-label" ); return facetCell({ id, identity, entry, x, y, width: plotWidth, labelHeight, showLabel, label, theme, nodes: offset.nodes.filter((node) => node.key !== "axes") }); }); if (xTitle) { axes.push({ kind: "group", key: `${id}:outer-x-title`, className: "ts-chart__facet-axis-title", ariaHidden: true, children: [ { ...xTitle, key: `${id}:x-label`, x: originX + (columns * plotWidth + Math.max(0, columns - 1) * gap) / 2, y: deepestPlotBottom + xTitle.y - plotHeight } ] }); } if (yTitle) { axes.push({ kind: "group", key: `${id}:outer-y-title`, className: "ts-chart__facet-axis-title", ariaHidden: true, children: [ { ...yTitle, key: `${id}:y-label`, x: originX + yTitle.x, y: (originY + labelHeight + deepestPlotBottom) / 2 } ] }); } return facetScene( id, [ ...children, { kind: "group", key: `${id}:outer-axes`, className: "ts-chart__facet-axes", ariaHidden: true, children: axes } ], points, focusGuides ); } function facetCell(options) { const { id, identity, entry, x, y, width, labelHeight, showLabel, label, theme, nodes } = options; return { kind: "group", key: `${id}:${identity}`, className: "ts-chart__facet-cell", translateX: x, translateY: y, children: [ ...showLabel ? [ { kind: "label", key: `${id}:${identity}:label`, x: width / 2, y: 11, text: typeof label === "function" ? label(entry.key) : String(entry.key), anchor: "middle", fontSize: 11, fontWeight: 600, style: { fill: theme.foreground, fillOpacity: 0.78 } } ] : [], { kind: "group", key: `${id}:${identity}:chart`, translateY: labelHeight, children: nodes } ] }; } function facetScene(id, children, points, focusGuides) { return { nodes: [ { kind: "group", key: id, className: "ts-chart__facet", children } ], points, ...focusGuides.length ? { focusGuides } : {} }; } function createFacetScene(id, entry, definition, size, layout) { try { return createChartScene(definition, size, layout); } catch (error) { throw new TypeError( `Facet "${id}" cell "${String(entry.key)}" could not compile: ${error instanceof Error ? error.message : String(error)}`, { cause: error } ); } } function withoutMarkRendering(mark) { return { initialize: (context) => { const initialized = mark.initialize(context); const resolveLayout = initialized.resolveLayout; if (resolveLayout) { return { ...initialized, render: () => ({ nodes: [] }), resolveLayout: (layoutContext) => ({ ...resolveLayout(layoutContext), render: () => ({ nodes: [] }) }) }; } return { ...initialized, render: () => ({ nodes: [] }) }; } }; } function assertOuterAxes(id, definitions, scenes) { const firstDefinition = definitions[0]; const firstScene = scenes[0]; const incompatible = !firstDefinition || !firstScene || definitions.some( (definition) => definition.guides === false || definition.margin !== void 0 || definition.color?.legend !== void 0 ) || scenes.slice(1).some((scene, index) => { const definition = definitions[index + 1]; return !sameAxis(firstDefinition.x, definition.x) || !sameAxis(firstDefinition.y, definition.y) || !sameScale(firstScene.scales.x, scene.scales.x) || !sameScale(firstScene.scales.y, scene.scales.y) || !sameMaterializedAxes(firstScene, scene) || firstScene.theme.foreground !== scene.theme.foreground || firstScene.theme.muted !== scene.theme.muted; }); if (incompatible) { throw new TypeError( `Facet "${id}" cannot share outer axes because its cell scales or guide options differ; use axes: "cell" for independent scales` ); } } function sameAxis(left, right) { if (left === null || right === null) return left === right; const leftAxis = left?.axis; const rightAxis = right?.axis; if (leftAxis === false || rightAxis === false) return leftAxis === rightAxis; const leftTicks = leftAxis?.ticks; const rightTicks = rightAxis?.ticks; const leftLabels = leftAxis?.tickLabels; const rightLabels = rightAxis?.tickLabels; const leftLabel = leftAxis?.label; const rightLabel = rightAxis?.label; return leftAxis?.line === rightAxis?.line && sameAxisTicks(leftTicks, rightTicks) && sameAxisTickLabels(leftLabels, rightLabels) && (typeof leftLabel === "string" ? leftLabel : leftLabel?.text) === (typeof rightLabel === "string" ? rightLabel : rightLabel?.text) && (typeof leftLabel === "object" ? leftLabel.offset : void 0) === (typeof rightLabel === "object" ? rightLabel.offset : void 0); } function sameAxisTicks(left, right) { if (left === false || right === false) return left === right; return left?.count === right?.count && left?.spacing === right?.spacing && left?.size === right?.size && left?.padding === right?.padding && sameValues(left?.values ?? [], right?.values ?? []); } function sameAxisTickLabels(left, right) { if (left === false || right === false) return left === right; if (left?.rotate !== right?.rotate) return false; const leftThin = left?.thin; const rightThin = right?.thin; if (typeof leftThin !== "object" || typeof rightThin !== "object") { return leftThin === rightThin; } return leftThin.minGap === rightThin.minGap && leftThin.priority === rightThin.priority && sameValues(leftThin.keep ?? [], rightThin.keep ?? []); } function sameScale(left, right) { return left.type === right.type && sameValues(left.domain, right.domain) && left.ticks.length === right.ticks.length && left.ticks.every( (tick, index) => valueKey(tick.value) === valueKey(right.ticks[index]?.value) && tick.label === right.ticks[index]?.label ) && scaleDirection(left) === scaleDirection(right); } function sameMaterializedAxes(left, right) { const axes = (scene) => scene.nodes.find( (node) => node.kind === "group" && node.key === "axes" ); return JSON.stringify(axes(left)) === JSON.stringify(axes(right)); } function sameValues(left, right) { return left.length === right.length && left.every((value, index) => valueKey(value) === valueKey(right[index])); } function scaleDirection(scale) { const first = scale.domain[0]; const last = scale.domain.at(-1); if (first === void 0 || last === void 0) return 0; return Math.sign(scale.map(last) - scale.map(first)); } function mergeTheme(spec, theme) { return { ...spec, theme: { ...theme, ...spec.theme, palette: spec.theme?.palette ?? theme.palette } }; } function offsetScene(id, identity, scene, x, y) { return embedChartScene(scene, { ownerId: id, childId: identity, x, y }); } function maxSceneMargins(scenes) { return maxMargins(scenes.map((scene) => scene.margin)); } function maxMargins(margins) { return margins.reduce( (margin, scene) => ({ top: Math.max(margin.top, scene.top), right: Math.max(margin.right, scene.right), bottom: Math.max(margin.bottom, scene.bottom), left: Math.max(margin.left, scene.left) }), { top: 0, right: 0, bottom: 0, left: 0 } ); } function sameMargin(left, right) { return Math.abs(left.top - right.top) < 0.25 && Math.abs(left.right - right.right) < 0.25 && Math.abs(left.bottom - right.bottom) < 0.25 && Math.abs(left.left - right.left) < 0.25; } function cellSize(size, gap, count) { return Math.max(1, (size - gap * (count - 1)) / count); } function facetChart(source, options) { return { marks: [facet(source, options)], guides: false, margin: 0, x: null, y: null }; } function isKey(value) { return typeof value === "string" || typeof value === "number"; } export { facet, facetChart };