@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
JavaScript
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
};