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,044 lines (1,043 loc) 33 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 { 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 { ...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 xChannels = collectScaleChannels(initialized, "x"); const yChannels = collectScaleChannels(initialized, "y"); const axes = definition.guides === false ? 0 : +(definition.x != null && definition.x.axis !== false) | +(definition.y != null && definition.y.axis !== false) << 1; const resolvedLayout = resolveSceneLayout( definition, initialized, width, height, theme, xChannels, yChannels, axes, resolveScale, layoutOptions ); const { margin, chart, scales, axes: axisNodes, marks, colors, legend, legendBounds } = resolvedLayout; const markEntries = []; const defaultFocusEntries = []; const points = []; const translateX = scales.x.viewport?.translate ?? 0; const translateY = scales.y.viewport?.translate ?? 0; const focusGuides = []; const firstBaseMarkIndex = marks.findIndex( (mark) => !mark.focus && !mark.focusGuideOnly ); marks.forEach((mark, markIndex) => { const viewportX = Boolean( scales.x.viewport && markUsesViewportAxis(mark, "x") ); const viewportY = Boolean( scales.y.viewport && markUsesViewportAxis(mark, "y") ); 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 + (viewportX ? translateX : 0), y: point.y + (viewportY ? translateY : 0) } : point; 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, viewportX, viewportY }); }); const markNodes = arrangeViewportMarkNodes( markEntries, translateX, translateY, chart ); const nodes = [ { kind: "group", key: "marks", className: "ts-chart__marks", clip: definition.clip ? chart : void 0, children: markNodes } ]; if (definition.guides !== false && (definition.x?.grid || definition.y?.grid)) { nodes.unshift(createGrid(chart, scales, definition, theme)); } if (axes) { 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 markUsesViewportAxis(mark, axis) { const ownership = mark.viewport?.[axis]; if (ownership) return ownership === "content"; return Object.values(mark.channels).some((channel) => channel.scale === axis); } function arrangeViewportMarkNodes(entries, translateX, translateY, chart) { return entries.flatMap((entry) => { if (!entry.viewportX && !entry.viewportY) 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.viewportX ? { translateX } : {}, ...entry.viewportY ? { 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 (!scene.scales.x?.viewport && !scene.scales.y?.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 Boolean( scene.scales.x?.viewport && markUsesViewportAxis(mark, "x") || scene.scales.y?.viewport && markUsesViewportAxis(mark, "y") ); } 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 }; } const automaticGuideInset = 4; const layoutPassLimit = 4; const layoutTolerance = 0.25; function resolveSceneLayout(definition, initialized, width, height, theme, xChannels, yChannels, axes, resolveScale, layout) { const locks = resolveMarginLocks(definition.margin); const inset = axes ? 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 xTickCount = resolveTickCount(definition.x, chart.width, 92, 8); const yTickCount = resolveTickCount(definition.y, chart.height, 48, 7); const scales = { x: definition.x == null ? createUnusedScale("x", xChannels.materialized, definition.x) : resolveScale({ id: "x", values: xChannels.values, range: [chart.x, chart.x + chart.width], options: definition.x, tickCount: xTickCount, includeZero: xChannels.includeZero }), y: definition.y == null ? createUnusedScale("y", yChannels.materialized, definition.y) : resolveScale({ id: "y", values: yChannels.values, range: [chart.y + chart.height, chart.y], options: definition.y, tickCount: yTickCount, includeZero: yChannels.includeZero }) }; 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 resolvedAxes = createAxes( chart, scales, definition, theme, width, axes, layout.measureText ); return { margin: margin2, chart, scales, axes: resolvedAxes.axes, 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( resolved2.scales.x.viewport && markUsesViewportAxis(mark, "x") || resolved2.scales.y.viewport && markUsesViewportAxis(mark, "y") ); 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 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; 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); return bounds; } 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, scales, definition, theme) { const children = []; if (definition.y?.grid) { for (const tick of scales.y.ticks) { children.push({ kind: "rule", key: `y-grid:${valueKey(tick.value)}`, x1: chart.x, x2: chart.x + chart.width, y1: tick.position, y2: tick.position }); } } if (definition.x?.grid) { for (const tick of scales.x.ticks) { children.push({ kind: "rule", key: `x-grid:${valueKey(tick.value)}`, x1: tick.position, x2: tick.position, y1: chart.y, y2: chart.y + chart.height }); } } return { kind: "group", key: "grid", className: "ts-chart__grid", ariaHidden: true, children, style: { stroke: theme.grid, strokeOpacity: 0.11, strokeWidth: 1 } }; } function createAxes(chart, scales, definition, theme, width, axes, measureText) { const showX = axes & 1; const showY = axes & 2; const xAxis = axisPresentation(definition.x); const yAxis = axisPresentation(definition.y); const children = !showX || xAxis?.line === false ? [] : [ { kind: "rule", key: "x-axis", x1: chart.x, x2: chart.x + chart.width, y1: chart.y + chart.height, y2: chart.y + chart.height, style: { stroke: theme.foreground, strokeOpacity: 0.28 } } ]; if (showY && yAxis?.line !== false) { children.push({ kind: "rule", key: "y-axis", x1: chart.x, x2: chart.x, y1: chart.y, y2: chart.y + chart.height, style: { stroke: theme.foreground, strokeOpacity: 0.28 } }); } const xTickLabels = tickLabelPresentation(xAxis); const yTickLabels = tickLabelPresentation(yAxis); let xTickBottom = chart.y + chart.height; let yTickLeft = chart.x; const inset = axes ? automaticGuideInset : 0; const margin = uniformMargin(inset); const addLabel = (label) => includeLabelMargin(margin, chart, label, measureText); const xTicks = xAxis?.ticks === false ? [] : scales.x.ticks; const yTicks = yAxis?.ticks === false ? [] : scales.y.ticks; const xTickSize = finiteMargin( xAxis?.ticks === false ? 0 : xAxis?.ticks?.size ?? 4 ); const yTickSize = finiteMargin( yAxis?.ticks === false ? 0 : yAxis?.ticks?.size ?? 4 ); const xTickPadding = finiteMargin( xAxis?.ticks === false ? 0 : xAxis?.ticks?.padding ?? 4 ); const yTickPadding = finiteMargin( yAxis?.ticks === false ? 0 : yAxis?.ticks?.padding ?? 4 ); const xLabelCandidates = xTickLabels === false ? [] : createTickLabelCandidates( "x", withKeptTicks(scales.x, definition.x, xTickLabels), chart, xTickSize, xTickPadding, xTickLabels, scales.x.bandwidth, width, theme, measureText ); const yLabelCandidates = yTickLabels === false ? [] : createTickLabelCandidates( "y", withKeptTicks(scales.y, definition.y, yTickLabels), chart, yTickSize, yTickPadding, yTickLabels, scales.y.bandwidth, width, theme, measureText ); const visibleXLabels = xTickLabels === false ? [] : thinTickLabels(xLabelCandidates, xTickLabels, scales.x.type === "band"); const visibleYLabels = yTickLabels === false ? [] : thinTickLabels(yLabelCandidates, yTickLabels, false); for (const tick of showX ? xTicks : []) { const key = valueKey(tick.value); if (xTickSize > 0) { children.push({ kind: "rule", key: `x-tick-rule:${key}`, x1: tick.position, x2: tick.position, y1: chart.y + chart.height, y2: chart.y + chart.height + xTickSize, style: { stroke: theme.foreground, strokeOpacity: 0.28 } }); } } for (const candidate of showX ? visibleXLabels : []) { const bounds = addLabel(candidate.label); if (axisLabelText(xAxis) && axisLabelOffset(xAxis) === "auto") { xTickBottom = Math.max(xTickBottom, bounds.y + bounds.height); } children.push(candidate.label); } for (const tick of showY ? yTicks : []) { const key = valueKey(tick.value); if (yTickSize > 0) { children.push({ kind: "rule", key: `y-tick-rule:${key}`, x1: chart.x - yTickSize, x2: chart.x, y1: tick.position, y2: tick.position, style: { stroke: theme.foreground, strokeOpacity: 0.28 } }); } } for (const candidate of showY ? visibleYLabels : []) { const bounds = addLabel(candidate.label); if (axisLabelText(yAxis) && axisLabelOffset(yAxis) === "auto") { yTickLeft = Math.min(yTickLeft, bounds.x); } children.push(candidate.label); } const xAxisLabel = axisLabelText(xAxis); if (showX && xAxisLabel) { const offset = axisLabelOffset(xAxis); const hasOffset = offset !== "auto"; const label = { kind: "label", key: "x-label", x: chart.x + chart.width / 2, y: hasOffset ? chart.y + chart.height + Math.max(0, finiteMargin(offset)) : xTickBottom + 8, text: xAxisLabel, anchor: "middle", baseline: hasOffset ? "auto" : "hanging", fontSize: width < 360 ? 10 : 11, fontWeight: 600, style: { fill: theme.foreground, fillOpacity: 0.76 } }; addLabel(label); children.push(label); } const yAxisLabel = axisLabelText(yAxis); if (showY && yAxisLabel) { const yLabel = { kind: "label", key: "y-label", x: chart.x, y: chart.y + chart.height / 2, text: yAxisLabel, anchor: "middle", baseline: "middle", rotate: -90, fontSize: 11, fontWeight: 600, style: { fill: theme.foreground, fillOpacity: 0.76 } }; const offset = axisLabelOffset(yAxis); if (offset !== "auto") { yLabel.x = chart.x - Math.max(0, finiteMargin(offset)); } else { const localBounds = measureSceneLabelBounds( { ...yLabel, x: 0, y: 0 }, measureText ); yLabel.x = yTickLeft - 8 - (localBounds.x + localBounds.width); } addLabel(yLabel); children.push(yLabel); } return { axes: { kind: "group", key: "axes", className: "ts-chart__axes", ariaHidden: true, children }, margin }; } 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(axis, ticks, chart, size, padding, options, bandwidth, width, theme, measureText) { const defaultFontSize = width < 360 ? 10 : 11; return ticks.map((tick, index) => { const context = { value: tick.value, index, position: tick.position, 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 = axis === "y" ? "end" : (rotate ?? 0) < 0 ? "end" : (rotate ?? 0) > 0 ? "start" : "middle"; const anchor = resolveTickLabelValue(options.anchor, context) ?? defaultAnchor; const label = axis === "x" ? { kind: "label", key: `x-tick-label:${valueKey(tick.value)}`, x: tick.position + dx, y: chart.y + chart.height + 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: `y-tick-label:${valueKey(tick.value)}`, x: chart.x - 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 };