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.

1,244 lines (1,243 loc) 41.9 kB
import { createColorScale, valueKey } from "./scales.js"; import { resolveConfiguredScale } from "./configured-scale.js"; import { measureSceneLabelBounds, withChartTextTypography } from "./guide-layout.js"; import { nearestScenePoint } from "./nearest.js"; import { setMappedFocusCoordinate } from "./focus-coordinate-internal.js"; import { readMaterializedPositionChannel } from "./materialized-channel-internal.js"; import { mapScenePointReferences } from "./scene-point-map.js"; import { chartSceneSource } from "./scene-source.js"; const defaultChartTheme = { foreground: "currentColor", muted: "currentColor", grid: "currentColor", background: "transparent", palette: [ "var(--ts-chart-1, #2563eb)", "var(--ts-chart-2, #f97316)", "var(--ts-chart-3, #10b981)", "var(--ts-chart-4, #8b5cf6)", "var(--ts-chart-5, #ec4899)", "var(--ts-chart-6, #06b6d4)" ] }; function defineChart(definition, options) { if (options) { return typeof definition === "function" ? { chart: definition, ...options } : { ...definition, ...options }; } return typeof definition === "function" ? { chart: definition } : definition; } function createChartScene(definition, size, layout = {}) { return createChartSceneWithScaleResolver( definition, size, (context) => { if (!context.options?.scale) { throw new TypeError( `Chart scale "${context.id}" requires a configured scale` ); } return resolveSuppliedScale(context.options.scale, context); }, layout ); } function resolveSuppliedScale(scale, context) { if (typeof scale === "function") return resolveConfiguredScale(scale, context); if (context.options?.viewport) { throw new TypeError( `Chart viewport "${context.id}" requires a configured or inferable continuous scale` ); } return scale.resolve(context); } function createChartSceneWithScaleResolver(definition, size, resolveScale, layout) { const width = finiteSize(size.width); const height = finiteSize(size.height); const layoutOptions = { ...layout, measureText: withChartTextTypography(layout.measureText, layout.typography) }; const platformTheme = { ...defaultChartTheme, ...layoutOptions.defaultTheme, palette: layoutOptions.defaultTheme?.palette ?? defaultChartTheme.palette }; const theme = { ...platformTheme, ...definition.theme, palette: definition.theme?.palette ?? platformTheme.palette }; const initialized = definition.marks.map( (mark, markIndex) => mark.initialize({ markIndex }) ); const scaleChannels = collectPositionScaleChannels(initialized); const scaleDefinitions = resolveScaleDefinitions(definition, scaleChannels); const resolvedLayout = resolveSceneLayout( definition, initialized, width, height, theme, scaleDefinitions, resolveScale, layoutOptions ); const { margin, chart, scales, axes: axisNodes, marks, colors, legend, legendBounds, positionScales, scaleGuides, gridScales } = resolvedLayout; const markEntries = []; const defaultFocusEntries = []; const points = []; const focusGuides = []; const firstBaseMarkIndex = marks.findIndex( (mark) => !mark.focus && !mark.focusGuideOnly ); marks.forEach((mark, markIndex) => { const translateX = markViewportTranslation( mark, "x", positionScales, scales ); const translateY = markViewportTranslation( mark, "y", positionScales, scales ); const viewportX = translateX !== void 0; const viewportY = translateY !== void 0; const pointMap = /* @__PURE__ */ new Map(); const presentPoint = (point) => { const existing = pointMap.get(point); if (existing) return existing; const presented = viewportX || viewportY ? { ...point, x: point.x + (translateX ?? 0), y: point.y + (translateY ?? 0) } : point; registerMappedFocusCoordinates( presented, mark, scales, translateX, translateY ); pointMap.set(point, presented); return presented; }; let rendered = mark.render({ markIndex, surface: { x: 0, y: 0, width, height }, chart, scales, theme, color: colors.map, colors, layout: layoutOptions }); if (legend?.filterMark) { rendered = legend.filterMark(rendered, { seriesFromColor: mark.seriesFromColor }); } if (mark.postDomain) rendered = mark.postDomain(rendered); const renderedPoints = collectRenderedPoints( rendered.nodes, rendered.points ); const renderedNodes = viewportX || viewportY ? mapScenePointReferences(rendered.nodes, presentPoint) : rendered.nodes; const presentedPoints = renderedPoints.map(presentPoint); const entryNodes = []; const placement = firstBaseMarkIndex < 0 || markIndex < firstBaseMarkIndex ? "under" : "over"; for (const guide of rendered.focusGuides ?? []) { focusGuides.push({ ...guide, placement: guide.placement ?? placement }); } if (mark.focus) { const retarget = mark.focus.retarget === true; entryNodes.push({ kind: "group", key: `focus:${mark.id}`, className: "ts-chart__focus-layer", ariaHidden: true, focus: { match: mark.focus.match ?? "primary", anchors: rendered.focusAnchors ?? renderedPoints, points: presentedPoints, placement, ...retarget ? { retarget: true, candidates: renderedNodes } : {} }, children: retarget ? [] : renderedNodes }); } else { const markPoints = presentedPoints; if (mark.states) { entryNodes.push({ kind: "group", key: `states:${mark.id}`, children: renderedNodes, states: { data: mark.states.data, definitions: mark.states.definitions, points: markPoints } }); } else { for (const node of renderedNodes) entryNodes.push(node); } for (const point of markPoints) points.push(point); if (markPoints.length) { defaultFocusEntries.push({ markId: mark.id, points: markPoints, clipped: viewportX || viewportY }); } } markEntries.push({ key: mark.id, nodes: entryNodes, translateX, translateY }); }); const markNodes = arrangeViewportMarkNodes(markEntries, chart); const nodes = [ { kind: "group", key: "marks", className: "ts-chart__marks", clip: definition.clip ? chart : void 0, children: markNodes } ]; if (gridScales.length) { nodes.unshift(createGrid(chart, gridScales, theme)); } if (scaleGuides.length) { nodes.push(axisNodes); } const controls = []; const controlIds = /* @__PURE__ */ new Set(); for (const control of definition.controls ?? []) { if (!control.id.trim()) { throw new TypeError("Chart control ids must be nonempty"); } if (controlIds.has(control.id)) { throw new TypeError(`Duplicate chart control id "${control.id}"`); } controlIds.add(control.id); const resolved = control.resolve({ chart, scales, colors, theme, width, height }); if (resolved.nodes) nodes.push(...resolved.nodes); if (resolved.controls) controls.push(...resolved.controls); } if (legend && legendBounds) { const legendContext = { colors, chart, bounds: legendBounds, theme, width, height }; nodes.push(legend.render(legendContext)); if (legend.control) controls.push(legend.control(legendContext)); } const hostControlIds = /* @__PURE__ */ new Set(); for (const control of controls) { const identity = `${control.extension.id}:${control.key}`; if (hostControlIds.has(identity)) { throw new TypeError(`Duplicate chart host control "${identity}"`); } hostControlIds.add(identity); } if (definition.focus !== false && definition.focusRing !== false && points.length) { for (const entry of defaultFocusEntries) { nodes.push({ kind: "group", key: `default-focus:${entry.markId}`, className: "ts-chart__focus-layer ts-chart__focus-layer--default", ariaHidden: true, clip: entry.clipped ? chart : void 0, focus: { match: "primary", anchors: entry.points, points: entry.points, placement: "over" }, children: entry.points.map((point) => ({ kind: "dot", key: point.key, x: point.x, y: point.y, radius: 5, style: { fill: "var(--ts-chart-focus-fill, Canvas)", stroke: point.color, strokeWidth: 2.5 } })) }); } } return { width, height, margin, chart, nodes, points, scales, colors, gradients: definition.gradients ?? [], theme, ...controls.length ? { controls } : {}, ...focusGuides.length ? { focusGuides } : {}, [chartSceneSource]: [definition, initialized] }; } function registerMappedFocusCoordinates(point, mark, scales, translateX, translateY) { register("x", point.xValue, point.x, translateX); register("y", point.yValue, point.y, translateY); function register(axis, value, coordinate, translate) { const scaleId = mark.channels[axis]?.scale; const scale = scaleId === void 0 ? void 0 : scales[scaleId]; if (!scale || scale.type === "none") return; const mapped = scale.map(value) + (translate ?? 0); if (Number.isFinite(mapped) && mapped !== coordinate) { setMappedFocusCoordinate(point, axis, mapped); } } } function markViewportTranslation(mark, channel, positionScales, scales) { const ownership = mark.viewport?.[channel]; if (ownership === "fixed") return void 0; for (const positionScale of positionScales) { if (positionScale.channel === channel && positionScale.scale.viewport && Object.values(mark.channels).some( (materialized) => materialized.scale === positionScale.id )) { return positionScale.scale.viewport.translate; } } return ownership === "content" ? scales[channel]?.viewport?.translate : void 0; } function markUsesAnyViewport(mark, positionScales) { return ["x", "y"].some( (channel) => positionScales.some( (positionScale) => positionScale.channel === channel && positionScale.scale.viewport && mark.viewport?.[channel] !== "fixed" && (mark.viewport?.[channel] === "content" || Object.values(mark.channels).some( (materialized) => materialized.scale === positionScale.id )) ) ); } function arrangeViewportMarkNodes(entries, chart) { return entries.flatMap((entry) => { if (entry.translateX === void 0 && entry.translateY === void 0) { return [...entry.nodes]; } return [ { kind: "group", key: `viewport-clip:${entry.key}`, className: "ts-chart__viewport-clip", clip: chart, children: [ { kind: "group", key: `viewport-content:${entry.key}`, className: "ts-chart__viewport-content", ...entry.translateX === void 0 ? {} : { translateX: entry.translateX }, ...entry.translateY === void 0 ? {} : { translateY: entry.translateY }, children: entry.nodes } ] } ]; }); } function findNearestPoint(scene, x, y, maxDistance = Infinity, points = scene.points) { return nearestScenePoint(scene, x, y, maxDistance, points); } function viewportInteractionPoints(scene, points = scene.points) { if (!Object.values(scene.scales).some((scale) => scale.viewport)) return points; const { x, y, width, height } = scene.chart; const right = x + width; const bottom = y + height; const visible = points.filter( (point) => !pointUsesViewportClip(scene, point) || point.x >= x && point.x <= right && point.y >= y && point.y <= bottom ); return visible.length === points.length ? points : visible; } function pointUsesViewportClip(scene, point) { const source = scene[chartSceneSource]; const mark = source?.[1].find((candidate) => candidate.id === point.markId); if (!mark) return true; return ["x", "y"].some((axis) => { const ownership = mark.viewport?.[axis]; if (ownership === "fixed") return false; if (ownership === "content" && scene.scales[axis]?.viewport) return true; return Object.entries(mark.channels).some( ([channelName, channel]) => channelName === axis && channel.scale !== void 0 && scene.scales[channel.scale]?.viewport !== void 0 ); }); } function collectRenderedPoints(nodes, emitted) { const points = emitted ? [...emitted] : []; const seen = new Set(points); const visit = (children) => { for (const node of children) { if (node.kind === "group") { if (!node.focus) visit(node.children); continue; } if (node.kind === "label" || !node.interaction) continue; const interaction = node.interaction; if (interaction.point) { if (!seen.has(interaction.point)) { seen.add(interaction.point); points.push(interaction.point); } } else { for (const point of interaction.points) { if (seen.has(point)) continue; seen.add(point); points.push(point); } } } }; visit(nodes); return points; } function collectScaleChannels(marks, scaleId) { const values = []; let includeZero = false; let materialized = false; for (const mark of marks) { for (const channel of Object.values(mark.channels)) { if (channel.scale !== scaleId) continue; materialized = true; for (const value of channel.values) values.push(value); includeZero ||= channel.includeZero ?? false; } } return { values, includeZero, materialized }; } function collectPositionScaleChannels(marks) { const collected = /* @__PURE__ */ new Map(); for (const mark of marks) { for (const [channelName, channel] of Object.entries(mark.channels)) { const scaleId = channel.scale; if (scaleId === void 0) continue; const positionChannel = readMaterializedPositionChannel( channelName, channel ); if (scaleId === "color") { if (positionChannel) { throw new TypeError('Position scales cannot use reserved ID "color"'); } continue; } const current = collected.get(scaleId) ?? { values: [], includeZero: false, materialized: false }; if (positionChannel && current.channel && current.channel !== positionChannel) { throw new TypeError( `Chart scale "${scaleId}" cannot materialize both x and y channels` ); } current.channel ??= positionChannel; current.materialized ||= !mark.focusGuideOnly; current.includeZero ||= channel.includeZero ?? false; for (const value of channel.values) current.values.push(value); collected.set(scaleId, current); } } return collected; } let warnedLegacyScaleOptions = false; function resolveScaleDefinitions(definition, collected) { if ((definition.scales === void 0 || definition.x !== void 0 || definition.y !== void 0) && !warnedLegacyScaleOptions) { try { if (process.env.NODE_ENV !== "production") { warnedLegacyScaleOptions = true; console.warn( "[TanStack Charts] Root `x` and `y` options are deprecated. Move them to `scales.x` and `scales.y`. When neither Cartesian scale is used, set `scales` to `{ x: null, y: null }`. This compatibility will be removed when TanStack Charts enters Alpha." ); } } catch { warnedLegacyScaleOptions = true; /* @__PURE__ */ console.warn( "[TanStack Charts] Root `x` and `y` options are deprecated. Move them to `scales.x` and `scales.y`. When neither Cartesian scale is used, set `scales` to `{ x: null, y: null }`. This compatibility will be removed when TanStack Charts enters Alpha." ); } } const scales = definition.scales ?? { x: definition.x, y: definition.y }; if (!Object.hasOwn(scales, "x") || !Object.hasOwn(scales, "y")) { throw new TypeError("Chart scales must define reserved `x` and `y` entries"); } for (const scaleId of collected.keys()) { if (!Object.hasOwn(scales, scaleId)) { throw new TypeError( `Chart scale "${scaleId}" is used by a mark but is not configured` ); } } return Object.entries(scales).map(([id, options]) => { if (id === "color") { throw new TypeError('Position scales cannot use reserved ID "color"'); } const channels = collected.get(id) ?? { values: [], includeZero: false, materialized: false }; const reservedChannel = id === "x" || id === "y" ? id : void 0; const configuredChannel = options?.channel; if (!reservedChannel && options !== null && !configuredChannel) { throw new TypeError( `Named chart scale "${id}" requires channel: "x" or channel: "y"` ); } const channel = reservedChannel ?? configuredChannel ?? channels.channel ?? "x"; if (configuredChannel && configuredChannel !== channel || channels.channel && channels.channel !== channel) { throw new TypeError( `Chart scale "${id}" is configured for ${channel} but is used as ${channels.channel ?? configuredChannel}` ); } const side = options?.side ?? (channel === "x" ? "bottom" : "left"); if (channel === "x" && side !== "top" && side !== "bottom" || channel === "y" && side !== "left" && side !== "right") { throw new TypeError( `Chart scale "${id}" uses ${channel} and cannot render an axis on the ${side} side` ); } return { id, channel, side, options, channels }; }); } const automaticGuideInset = 4; const layoutPassLimit = 4; const layoutTolerance = 0.25; function resolveSceneLayout(definition, initialized, width, height, theme, scaleDefinitions, resolveScale, layout) { const locks = resolveMarginLocks(definition.margin); const hasGuides = definition.guides !== false && scaleDefinitions.some(hasScaleGuide); const inset = hasGuides ? automaticGuideInset : 0; let margin = mergeMarginLocks(uniformMargin(inset), locks); let safeMargin = margin; for (let pass = 0; pass < layoutPassLimit; pass += 1) { const resolved2 = compileSceneLayout(margin); const next = measureMargin(resolved2); safeMargin = mergeMarginLocks(next, locks, safeMargin); if (marginsEqual(margin, next)) return resolved2; margin = next; } let resolved = compileSceneLayout(safeMargin); const finalMargin = mergeMarginLocks( measureMargin(resolved), locks, safeMargin ); if (!marginsEqual(safeMargin, finalMargin)) { resolved = compileSceneLayout(finalMargin); } return resolved; function compileSceneLayout(margin2) { const chart = { x: margin2.left, y: margin2.top, width: Math.max(1, width - margin2.left - margin2.right), height: Math.max(1, height - margin2.top - margin2.bottom) }; const scales = {}; const resolvedScales = []; for (const scaleDefinition of scaleDefinitions) { const { id, channel, options, channels } = scaleDefinition; const length = channel === "x" ? chart.width : chart.height; const tickCount = resolveTickCount( options, length, channel === "x" ? 92 : 48, channel === "x" ? 8 : 7 ); const range = channel === "x" ? [chart.x, chart.x + chart.width] : [chart.y + chart.height, chart.y]; const scale = options == null ? createUnusedScale(id, channels.materialized, options) : resolveScale({ id, channel, values: channels.values, range, options, tickCount, includeZero: channels.includeZero }); scales[id] = scale; resolvedScales.push({ ...scaleDefinition, scale }); } const marks = resolveMarkLayouts(initialized, { chart, scales, theme, layout }); const colorChannels = collectScaleChannels(marks, "color"); const colors = createColorScale( colorChannels.values, definition.color, theme ); if (colors.kind !== "categorical" && marks.some((mark) => mark.seriesFromColor)) { throw new TypeError( "A continuous color channel cannot infer series identity; supply z explicitly" ); } const legend = colors.domain.length ? definition.color?.legend : void 0; if (legend?.seriesVisible && colors.kind !== "categorical") { throw new TypeError( "An interactive color legend requires a categorical color scale" ); } const legendHeight = legend?.height(colors.domain.length, { colors, chart, bounds: { x: chart.x, y: 0, width: chart.width, height: 0 }, theme, width, height }); const legendBounds = legend && legendHeight !== void 0 ? { x: chart.x, y: legend.placement === "bottom" ? height - legendHeight : 0, width: chart.width, height: legendHeight } : void 0; const scaleGuides = definition.guides === false ? [] : resolvedScales.filter(hasScaleGuide); const gridScales = definition.guides === false ? [] : resolvedScales.filter(hasScaleGrid); const resolvedAxes = createAxes( chart, scaleGuides, theme, width, layout.measureText ); return { margin: margin2, chart, scales, axes: resolvedAxes.axes, positionScales: resolvedScales, scaleGuides, gridScales, guideMargin: resolvedAxes.margin, marks, colors, legend, legendBounds }; } function measureMargin(resolved2) { const automatic = resolved2.guideMargin; if (resolved2.legend) { const legendHeight = resolved2.legend.height( resolved2.colors.domain.length, { colors: resolved2.colors, chart: resolved2.chart, bounds: { x: resolved2.chart.x, y: 0, width: resolved2.chart.width, height: 0 }, theme, width, height } ); if (resolved2.legend.placement === "bottom") { if (locks.bottom === void 0) automatic.bottom += legendHeight; } else if (locks.top === void 0) { automatic.top = Math.max(automatic.top, legendHeight); } } if (!definition.clip) { resolved2.marks.forEach((mark, markIndex) => { const autoClipped = Boolean( markUsesAnyViewport(mark, resolved2.positionScales) ); if (autoClipped) return; const labels = mark.layoutLabels?.({ markIndex, surface: { x: 0, y: 0, width, height }, chart: resolved2.chart, scales: resolved2.scales, theme, color: resolved2.colors.map, colors: resolved2.colors, layout }); for (const label of labels ?? []) { includeLabelMargin( automatic, resolved2.chart, label, layout.measureText ); } }); } return mergeMarginLocks(automatic, locks); } } function hasScaleGuide(scale) { return scale.options != null && scale.options.axis !== false; } function hasScaleGrid(scale) { return scale.options != null && scale.options.grid === true; } function resolveMarkLayouts(marks, context) { return marks.map((mark, markIndex) => { if (typeof mark.resolveLayout !== "function") { return mark; } const resolved = mark.resolveLayout({ ...context, markIndex }); return { id: mark.id, channels: resolved.channels ?? mark.channels, viewport: mark.viewport, focusGuideOnly: mark.focusGuideOnly, seriesFromColor: mark.seriesFromColor, focus: mark.focus, states: resolved.states ?? mark.states, postDomain: resolved.postDomain ?? mark.postDomain, layoutLabels: resolved.layoutLabels ?? mark.layoutLabels, render: resolved.render }; }); } function includeLabelMargin(margin, chart, label, measureText) { const bounds = measureSceneLabelBounds(label, measureText); if (!label.text) return bounds; includeBoundsMargin(margin, chart, bounds); return bounds; } function includeBoundsMargin(margin, chart, bounds) { margin.top = Math.max(margin.top, chart.y - bounds.y + automaticGuideInset); margin.right = Math.max( margin.right, bounds.x + bounds.width - chart.x - chart.width + automaticGuideInset ); margin.bottom = Math.max( margin.bottom, bounds.y + bounds.height - chart.y - chart.height + automaticGuideInset ); margin.left = Math.max(margin.left, chart.x - bounds.x + automaticGuideInset); } function resolveMarginLocks(margin) { if (typeof margin === "number") { return uniformMargin(finiteMargin(margin)); } if (!margin) return {}; const locks = {}; for (const side of marginSides) { if (margin[side] !== void 0) locks[side] = finiteMargin(margin[side]); } return locks; } const marginSides = ["top", "right", "bottom", "left"]; function mergeMarginLocks(automatic, locks, previous) { const margin = { ...automatic }; for (const side of marginSides) { margin[side] = locks[side] ?? (previous ? Math.max(previous[side], automatic[side]) : automatic[side]); } return margin; } function marginsEqual(left, right) { return marginSides.every( (side) => Math.abs(left[side] - right[side]) <= layoutTolerance ); } function finiteMargin(value) { return value !== void 0 && Number.isFinite(value) ? Math.max(0, value) : 0; } function uniformMargin(value) { return { top: value, right: value, bottom: value, left: value }; } function createUnusedScale(id, materialized, axis) { if (materialized) { throw new TypeError( axis === null ? `Chart scale "${id}" cannot be null when a mark materializes its channel` : `Chart scale "${id}" requires a configured scale when a mark materializes its channel` ); } return { id, type: "none", domain: [], map: () => { throw new TypeError(`Chart scale "${id}" is not configured`); }, ticks: [], bandwidth: 0 }; } function createGrid(chart, guides, theme) { const children = []; for (const guide of guides) { if (!guide.options?.grid) continue; for (const tick of guide.scale.ticks) { const key = `${guide.id}-grid:${valueKey(tick.value)}`; children.push( guide.channel === "x" ? { kind: "rule", key, x1: tick.position, x2: tick.position, y1: chart.y, y2: chart.y + chart.height } : { kind: "rule", key, x1: chart.x, x2: chart.x + chart.width, y1: tick.position, y2: tick.position } ); } } return { kind: "group", key: "grid", className: "ts-chart__grid", ariaHidden: true, children, style: { stroke: theme.grid, strokeOpacity: 0.11, strokeWidth: 1 } }; } function createAxes(chart, guides, theme, width, measureText) { const children = []; const inset = guides.length ? automaticGuideInset : 0; const margin = uniformMargin(inset); const offsets = { top: 0, right: 0, bottom: 0, left: 0 }; const chartRight = chart.x + chart.width; const chartBottom = chart.y + chart.height; for (const guide of guides) { const offset = offsets[guide.side]; margin[guide.side] = Math.max( margin[guide.side], offset + automaticGuideInset ); const axisPosition = guide.side === "top" ? chart.y - offset : guide.side === "right" ? chartRight + offset : guide.side === "bottom" ? chartBottom + offset : chart.x - offset; let outward = axisPosition; const includeOutward = (bounds) => { includeBoundsMargin(margin, chart, bounds); if (guide.side === "top") outward = Math.min(outward, bounds.y); else if (guide.side === "right") { outward = Math.max(outward, bounds.x + bounds.width); } else if (guide.side === "bottom") { outward = Math.max(outward, bounds.y + bounds.height); } else outward = Math.min(outward, bounds.x); }; const includeCoordinate = (coordinate) => { if (guide.side === "top" || guide.side === "left") { outward = Math.min(outward, coordinate); } else { outward = Math.max(outward, coordinate); } }; if (guide.channel === "x") { renderXAxis(guide, axisPosition, includeOutward, includeCoordinate); } else { renderYAxis(guide, axisPosition, includeOutward, includeCoordinate); } const distance = guide.side === "top" ? chart.y - outward : guide.side === "right" ? outward - chartRight : guide.side === "bottom" ? outward - chartBottom : chart.x - outward; offsets[guide.side] = Math.max(offset, distance) + 8; } return { axes: { kind: "group", key: "axes", className: "ts-chart__axes", ariaHidden: true, children }, margin }; function renderXAxis(guide, axisY, includeOutward, includeCoordinate) { const presentation = axisPresentation(guide.options); const bottom = guide.side === "bottom"; const direction = bottom ? 1 : -1; if (presentation?.line !== false) { children.push({ kind: "rule", key: `${guide.id}-axis`, x1: chart.x, x2: chartRight, y1: axisY, y2: axisY, style: axisStyle() }); } const ticks = presentation?.ticks === false ? [] : guide.scale.ticks; const tickSize = finiteMargin( presentation?.ticks === false ? 0 : presentation?.ticks?.size ?? 4 ); const tickPadding = finiteMargin( presentation?.ticks === false ? 0 : presentation?.ticks?.padding ?? 4 ); const tickLabels = tickLabelPresentation(presentation); const candidates = tickLabels === false ? [] : createTickLabelCandidates( guide, withKeptTicks(guide.scale, guide.options, tickLabels), axisY, tickSize, tickPadding, tickLabels, width, theme, measureText ); const visibleLabels = tickLabels === false ? [] : thinTickLabels(candidates, tickLabels, guide.scale.type === "band"); let tickOuter = axisY; for (const tick of ticks) { if (tickSize <= 0) continue; const tickEnd = axisY + direction * tickSize; includeCoordinate(tickEnd); tickOuter = bottom ? Math.max(tickOuter, tickEnd) : Math.min(tickOuter, tickEnd); children.push({ kind: "rule", key: `${guide.id}-tick-rule:${valueKey(tick.value)}`, x1: tick.position, x2: tick.position, y1: axisY, y2: tickEnd, style: axisStyle() }); } for (const candidate of visibleLabels) { includeOutward(candidate.bounds); tickOuter = bottom ? Math.max(tickOuter, candidate.bounds.y + candidate.bounds.height) : Math.min(tickOuter, candidate.bounds.y); children.push(candidate.label); } const labelText = axisLabelText(presentation); if (!labelText) return; const labelOffset = axisLabelOffset(presentation); const explicitOffset = labelOffset !== "auto"; const label = { kind: "label", key: `${guide.id}-label`, x: chart.x + chart.width / 2, y: explicitOffset ? axisY + direction * Math.max(0, finiteMargin(labelOffset)) : tickOuter + direction * 8, text: labelText, anchor: "middle", baseline: bottom && !explicitOffset ? "hanging" : "auto", fontSize: width < 360 ? 10 : 11, fontWeight: 600, style: { fill: theme.foreground, fillOpacity: 0.76 } }; includeOutward(measureSceneLabelBounds(label, measureText)); children.push(label); } function renderYAxis(guide, axisX, includeOutward, includeCoordinate) { const presentation = axisPresentation(guide.options); const right = guide.side === "right"; const direction = right ? 1 : -1; if (presentation?.line !== false) { children.push({ kind: "rule", key: `${guide.id}-axis`, x1: axisX, x2: axisX, y1: chart.y, y2: chartBottom, style: axisStyle() }); } const ticks = presentation?.ticks === false ? [] : guide.scale.ticks; const tickSize = finiteMargin( presentation?.ticks === false ? 0 : presentation?.ticks?.size ?? 4 ); const tickPadding = finiteMargin( presentation?.ticks === false ? 0 : presentation?.ticks?.padding ?? 4 ); const tickLabels = tickLabelPresentation(presentation); const candidates = tickLabels === false ? [] : createTickLabelCandidates( guide, withKeptTicks(guide.scale, guide.options, tickLabels), axisX, tickSize, tickPadding, tickLabels, width, theme, measureText ); const visibleLabels = tickLabels === false ? [] : thinTickLabels(candidates, tickLabels, false); let tickOuter = axisX; for (const tick of ticks) { if (tickSize <= 0) continue; const tickEnd = axisX + direction * tickSize; includeCoordinate(tickEnd); tickOuter = right ? Math.max(tickOuter, tickEnd) : Math.min(tickOuter, tickEnd); children.push({ kind: "rule", key: `${guide.id}-tick-rule:${valueKey(tick.value)}`, x1: axisX, x2: tickEnd, y1: tick.position, y2: tick.position, style: axisStyle() }); } for (const candidate of visibleLabels) { includeOutward(candidate.bounds); tickOuter = right ? Math.max(tickOuter, candidate.bounds.x + candidate.bounds.width) : Math.min(tickOuter, candidate.bounds.x); children.push(candidate.label); } const labelText = axisLabelText(presentation); if (!labelText) return; const label = { kind: "label", key: `${guide.id}-label`, x: axisX, y: chart.y + chart.height / 2, text: labelText, anchor: "middle", baseline: "middle", rotate: right ? 90 : -90, fontSize: 11, fontWeight: 600, style: { fill: theme.foreground, fillOpacity: 0.76 } }; const labelOffset = axisLabelOffset(presentation); if (labelOffset !== "auto") { label.x = axisX + direction * Math.max(0, finiteMargin(labelOffset)); } else { const localBounds = measureSceneLabelBounds( { ...label, x: 0, y: 0 }, measureText ); label.x = right ? tickOuter + 8 - localBounds.x : tickOuter - 8 - (localBounds.x + localBounds.width); } includeOutward(measureSceneLabelBounds(label, measureText)); children.push(label); } function axisStyle() { return { stroke: theme.foreground, strokeOpacity: 0.28 }; } } function resolveTickCount(axis, length, defaultSpacing, maximum) { const ticks = axis?.axis === false ? void 0 : axis?.axis?.ticks; if (ticks === false) { return Math.max(2, Math.min(maximum, Math.floor(length / defaultSpacing))); } const configured = ticks ?? {}; const policies = [ configured.count !== void 0, configured.spacing !== void 0, configured.values !== void 0 ].filter(Boolean).length; if (policies > 1) { throw new TypeError( "Axis ticks accept only one candidate policy: count, spacing, or values" ); } if (configured.values) return Math.max(1, configured.values.length); if (configured.count !== void 0) { return Math.max(1, Math.floor(finiteMargin(configured.count))); } if (configured.spacing !== void 0) { const spacing = Math.max(1, finiteMargin(configured.spacing)); return Math.max(1, Math.floor(length / spacing)); } return Math.max(2, Math.min(maximum, Math.floor(length / defaultSpacing))); } function axisPresentation(axis) { if (!axis || axis.axis === false) return void 0; return axis.axis ?? {}; } function tickLabelPresentation(axis) { if (axis?.ticks === false || axis?.tickLabels === false) return false; return axis?.tickLabels ?? {}; } function axisLabelText(axis) { return typeof axis?.label === "string" ? axis.label : axis?.label?.text; } function axisLabelOffset(axis) { return typeof axis?.label === "object" ? axis.label.offset ?? "auto" : "auto"; } function withKeptTicks(scale, axis, labels) { const thin = typeof labels.thin === "object" ? labels.thin : void 0; const keep = thin?.keep ?? []; if (!keep.length) return scale.ticks; const formatter = axis?.axis === false || axis?.axis?.ticks === false ? void 0 : axis?.axis?.ticks?.format; const ticks = scale.ticks.map((tick) => ({ ...tick, hard: keep.some((value) => valueKey(value) === valueKey(tick.value)) })); const seen = new Set(ticks.map((tick) => valueKey(tick.value))); for (const value of keep) { const position = scale.map(value); if (seen.has(valueKey(value)) || !Number.isFinite(position)) continue; ticks.push({ value, position, label: formatter?.(value) ?? formatAxisValue(value), hard: true }); } return ticks; } function createTickLabelCandidates(guide, ticks, axisPosition, size, padding, options, width, theme, measureText) { const defaultFontSize = width < 360 ? 10 : 11; const positiveSide = guide.side === "bottom" || guide.side === "right"; const direction = positiveSide ? 1 : -1; return ticks.map((tick, index) => { const context = { value: tick.value, index, position: tick.position, bandwidth: guide.scale.bandwidth }; const rotate = options.rotate; const fontSize = resolveTickLabelValue(options.fontSize, context) ?? defaultFontSize; const fontWeight = resolveTickLabelValue(options.fontWeight, context); const opacity = resolveTickLabelValue(options.opacity, context); const dx = resolveTickLabelValue(options.dx, context) ?? 0; const dy = resolveTickLabelValue(options.dy, context) ?? 0; const defaultAnchor = guide.channel === "y" ? positiveSide ? "start" : "end" : (rotate ?? 0) < 0 ? "end" : (rotate ?? 0) > 0 ? "start" : "middle"; const anchor = resolveTickLabelValue(options.anchor, context) ?? defaultAnchor; const label = guide.channel === "x" ? { kind: "label", key: `${guide.id}-tick-label:${valueKey(tick.value)}`, x: tick.position + dx, y: axisPosition + direction * (size + padding + fontSize * 0.8) + dy, text: tick.label, anchor, rotate, fontSize, fontWeight, style: { fill: theme.muted, ...opacity === void 0 ? { fillOpacity: 0.68 } : { opacity } } } : { kind: "label", key: `${guide.id}-tick-label:${valueKey(tick.value)}`, x: axisPosition + direction * (size + padding) + dx, y: tick.position + dy, text: tick.label, anchor, baseline: "middle", rotate, fontSize, fontWeight, style: { fill: theme.muted, ...opacity === void 0 ? { fillOpacity: 0.68 } : { opacity } } }; return { value: tick.value, label, bounds: measureSceneLabelBounds(label, measureText), hard: tick.hard ?? false }; }); } function resolveTickLabelValue(value, context) { return typeof value === "function" ? value(context) : value; } function thinTickLabels(candidates, options, categoricalX) { if (options.thin === false || candidates.length < 2) return [...candidates]; const thin = typeof options.thin === "object" ? options.thin : {}; const minGap = Math.max(0, finiteMargin(thin.minGap ?? 4)); const selected = candidates.filter( (candidate) => candidate.hard ); const soft = candidates.filter((candidate) => !candidate.hard); const prioritizeEnds = thin.priority === "ends" || categoricalX; if (prioritizeEnds && soft.length) { const first = soft[0]; const last = soft.at(-1); if (!collidesWithAny(first, selected, minGap)) selected.push(first); if (last !== first && !collidesWithAny(last, selected, minGap)) { selected.push(last); } } const ordered = distributedCandidates( soft.filter((candidate) => !selected.includes(candidate)) ); for (const candidate of ordered) { if (!collidesWithAny(candidate, selected, minGap)) selected.push(candidate); } const selectedSet = new Set(selected); return candidates.filter((candidate) => selectedSet.has(candidate)); } function distributedCandidates(candidates) { if (candidates.length < 3) return [...candidates]; const result = []; const queue = [candidates]; while (queue.length) { const range = queue.shift(); if (!range.length) continue; const middle = Math.floor(range.length / 2); result.push(range[middle]); queue.push(range.slice(0, middle), range.slice(middle + 1)); } return result; } function collidesWithAny(candidate, selected, gap) { return selected.some( (other) => boundsCollide(candidate.bounds, other.bounds, gap) ); } function boundsCollide(left, right, gap) { return !(left.x + left.width + gap <= right.x || right.x + right.width + gap <= left.x || left.y + left.height + gap <= right.y || right.y + right.height + gap <= left.y); } function formatAxisValue(value) { return value instanceof Date ? value.toLocaleDateString() : String(value); } function finiteSize(value) { return Number.isFinite(value) ? Math.max(1, value) : 1; } export { createChartScene, defaultChartTheme, defineChart, findNearestPoint, viewportInteractionPoints };