@tanstack/charts
Version:
<div align="center"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://tanstack.com/api/readme/charts.png?theme=dark" /> <source media="(prefers-color-scheme: light)" srcset="https://tanstack.com/
604 lines (603 loc) • 18.1 kB
JavaScript
import {
estimateSceneText,
physicalTextAnchor,
withChartTextTypography
} from "./guide-layout.js";
import {
layoutCategoricalLegendFlow,
layoutCategoricalLegendItems,
resolveCategoricalLegendItems
} from "./legend-layout-internal.js";
import { valueKey } from "./scales.js";
function colorLegendItems(options = {}) {
const items = (context, minimumItemWidth, label, render) => {
const title = resolveLegendTitleLayout(label, context);
const presentation = resolveCategoricalLegendPresentation(
options,
context,
minimumItemWidth
);
if (!render) {
return title.offset + presentation.rows * presentation.rowHeight;
}
const { bounds, theme, direction } = context;
const children = [];
if (label) {
children.push({
kind: "label",
key: "legend-label",
x: bounds.x,
y: bounds.y + title.y,
text: label,
anchor: physicalTextAnchor("left", direction),
fontSize: 11,
fontWeight: 600,
style: { fill: theme.foreground, fillOpacity: 0.78 }
});
}
presentation.items.forEach(({ item, row, width, x }) => {
const y = bounds.y + presentation.firstRowBaseline + title.offset + row * presentation.rowHeight;
const indicatorWidth = Math.min(presentation.indicatorWidth, width);
const indicatorGap = Math.min(
presentation.indicatorGap,
Math.max(0, width - indicatorWidth)
);
const indicatorBounds = {
x: bounds.x + x,
y: y - presentation.indicatorHeight / 2,
width: indicatorWidth,
height: presentation.indicatorHeight
};
children.push(
...renderCategoricalLegendIndicator(
options.indicator,
item.value,
item.context,
indicatorBounds,
theme.background
),
{
kind: "label",
key: `legend-label:${item.key}`,
x: indicatorBounds.x + indicatorBounds.width + indicatorGap - item.textX,
y,
text: item.label,
anchor: physicalTextAnchor("left", direction),
baseline: "middle",
fontSize: presentation.fontSize,
fontWeight: presentation.fontWeight,
style: {
fill: resolveItemValue(
options.label?.fill,
item.value,
item.context,
theme.foreground
),
fillOpacity: options.label?.fillOpacity ?? (options.label?.fill === void 0 ? 0.76 : 1)
}
}
);
});
return children;
};
return items;
}
function colorLegend(options = {}) {
const gradient = colorGradientLegend({
label: options.label,
width: options.width,
format: options.format,
placement: options.placement
});
const items = options.items;
const minimumItemWidth = Math.max(64, options.itemWidth ?? 110);
return {
placement: options.placement,
height(itemCount, context) {
if (isQuantitativeLegend(context.colors.kind)) {
return gradient.height(itemCount, context);
}
if (items) {
return 18 + items(context, minimumItemWidth, options.label);
}
const labelOffset = options.label ? 13 : 0;
const layout = layoutCategoricalLegendItems(
itemCount,
context.chart.width,
minimumItemWidth
);
return 18 + labelOffset + layout.rows * 19;
},
render(context) {
if (isContinuousLegend(context.colors.kind)) {
return gradient.render(context);
}
if (isSteppedLegend(context.colors.kind)) {
return renderSteppedLegend(options, context);
}
const { bounds, theme, direction } = context;
const children = [];
if (items) {
children.push(
...items(
context,
minimumItemWidth,
options.label,
true
)
);
} else {
const labelOffset = options.label ? 13 : 0;
if (options.label) {
children.push({
kind: "label",
key: "legend-label",
x: bounds.x,
y: bounds.y + 11,
text: options.label,
anchor: physicalTextAnchor("left", direction),
fontSize: 11,
fontWeight: 600,
style: { fill: theme.foreground, fillOpacity: 0.78 }
});
}
children.push(
...renderDefaultCategoricalLegend(
context,
labelOffset,
minimumItemWidth
)
);
}
return {
kind: "group",
key: "legend",
className: "ts-chart__legend",
ariaHidden: true,
children
};
}
};
}
function resolveLegendTitleLayout(label, context) {
if (!label) return { offset: 0, y: 11 };
const measureOptions = {
fontSize: 11,
fontWeight: 600,
fontFamily: "sans-serif",
fontStyle: "normal",
fontStretch: "normal",
letterSpacing: 0,
direction: context.direction ?? "inherit",
fontScale: 1,
anchor: physicalTextAnchor("left", context.direction),
baseline: "auto"
};
const fallback = withChartTextTypography(
estimateSceneText,
context.layout?.typography
)(label, measureOptions);
const candidate = context.layout?.measureText?.(label, measureOptions) ?? fallback;
const measured = validTextMetrics(candidate) ? candidate : fallback;
const y = Math.max(11, -measured.y);
return {
offset: y + measured.y + measured.height + 2,
y
};
}
function renderDefaultCategoricalLegend({
colors,
bounds,
theme,
direction
}, labelOffset, minimumItemWidth) {
const items = resolveCategoricalLegendItems(colors);
const layout = layoutCategoricalLegendItems(
items.length,
bounds.width,
minimumItemWidth
);
const children = [];
items.forEach((item, index) => {
const column = index % layout.columns;
const row = Math.floor(index / layout.columns);
const x = bounds.x + column * layout.itemWidth;
const y = bounds.y + 10 + labelOffset + row * 19;
children.push(
{
kind: "dot",
key: `legend-dot:${item.key}`,
x: x + 4,
y,
radius: 4,
style: { fill: item.color }
},
{
kind: "label",
key: `legend-label:${item.key}`,
x: x + 13,
y,
text: item.label,
anchor: physicalTextAnchor("left", direction),
baseline: "middle",
fontSize: 11,
style: { fill: theme.foreground, fillOpacity: 0.76 }
}
);
});
return children;
}
function resolveCategoricalLegendPresentation(options, context, minimumItemWidth) {
const labelOptions = options.label;
const indicatorOptions = options.indicator;
const fontSize = finiteNonnegative(labelOptions?.fontSize, 11);
const fontWeight = Number.isFinite(labelOptions?.fontWeight) ? labelOptions?.fontWeight : void 0;
const indicatorWidth = finiteNonnegative(indicatorOptions?.width, 8);
const indicatorHeight = finiteNonnegative(indicatorOptions?.height, 8);
const indicatorGap = finiteNonnegative(indicatorOptions?.gap, 5);
const rowGap = finiteNonnegative(options.rowGap, 8);
const resolvedItems = resolveCategoricalLegendItems(
context.colors,
labelOptions?.format
);
const estimateText = withChartTextTypography(
estimateSceneText,
context.layout?.typography
);
let rowTop = indicatorHeight / 2;
let rowBottom = indicatorHeight / 2;
const items = resolvedItems.map((item, index) => {
const itemContext = { color: item.color, index, label: item.label };
const measureOptions = {
fontSize,
fontWeight,
fontFamily: "sans-serif",
fontStyle: "normal",
fontStretch: "normal",
letterSpacing: 0,
direction: context.direction ?? "inherit",
fontScale: 1,
anchor: physicalTextAnchor("left", context.direction),
baseline: "middle"
};
const fallback = estimateText(item.label, measureOptions);
const candidate = context.layout?.measureText?.(item.label, measureOptions) ?? fallback;
const measured = validTextMetrics(candidate) ? candidate : fallback;
rowTop = Math.max(rowTop, -measured.y, 0);
rowBottom = Math.max(rowBottom, measured.y + measured.height, 0);
return {
...item,
context: itemContext,
textX: measured.x,
width: indicatorWidth + indicatorGap + measured.width
};
});
const rowHeight = rowTop + rowBottom + rowGap;
const firstRowBaseline = Math.max(10, rowTop);
const justify = options.justify ?? "stretch";
if (justify === "stretch") {
const layout2 = layoutCategoricalLegendItems(
items.length,
context.bounds.width,
minimumItemWidth
);
return {
rows: layout2.rows,
rowHeight,
firstRowBaseline,
indicatorWidth,
indicatorHeight,
indicatorGap,
fontSize,
fontWeight,
items: items.map((item, index) => ({
item,
row: Math.floor(index / layout2.columns),
width: layout2.itemWidth,
x: index % layout2.columns * layout2.itemWidth
}))
};
}
const layout = layoutCategoricalLegendFlow(
items.map((item) => item.width),
context.bounds.width,
finiteNonnegative(options.gap, 16),
justify
);
return {
rows: layout.rows,
rowHeight,
firstRowBaseline,
indicatorWidth,
indicatorHeight,
indicatorGap,
fontSize,
fontWeight,
items: layout.items.map(({ index, row, width, x }) => ({
item: items[index],
row,
width,
x
}))
};
}
function renderCategoricalLegendIndicator(options, value, context, bounds, background) {
if (options?.render) {
const rendered = options.render(value, { ...context, bounds });
return "kind" in rendered ? [rendered] : rendered;
}
const shape = resolveItemValue(options?.shape, value, context, "dot");
const centerX = bounds.x + bounds.width / 2;
const centerY = bounds.y + bounds.height / 2;
const size = Math.min(bounds.width, bounds.height);
if (shape === "square") {
return [
{
kind: "rect",
key: `legend-square:${valueKey(value)}`,
x: centerX - size / 2,
y: centerY - size / 2,
width: size,
height: size,
style: { fill: context.color }
}
];
}
if (shape === "line" || shape === "line-dot") {
const nodes = [
{
kind: "rule",
key: `legend-line:${valueKey(value)}`,
x1: bounds.x,
x2: bounds.x + bounds.width,
y1: centerY,
y2: centerY,
style: { stroke: context.color, strokeWidth: 3 }
}
];
if (shape === "line-dot") {
const radius = Math.min(4, size / 2);
nodes.push({
kind: "dot",
key: `legend-line-dot:${valueKey(value)}`,
x: centerX,
y: centerY,
radius,
style: {
fill: background === "transparent" ? "Canvas" : background,
stroke: context.color,
strokeWidth: Math.min(2, radius)
}
});
}
return nodes;
}
return [
{
kind: "dot",
key: `legend-dot:${valueKey(value)}`,
x: centerX,
y: centerY,
radius: size / 2,
style: { fill: context.color }
}
];
}
function resolveItemValue(input, value, context, fallback) {
return typeof input === "function" ? input(value, context) : input ?? fallback;
}
function finiteNonnegative(value, fallback) {
return typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : fallback;
}
function validTextMetrics(metrics) {
return Number.isFinite(metrics.x) && Number.isFinite(metrics.y) && Number.isFinite(metrics.width) && metrics.width >= 0 && Number.isFinite(metrics.height) && metrics.height >= 0;
}
function isContinuousLegend(kind) {
return kind === "continuous";
}
function isSteppedLegend(kind) {
return kind === "quantile" || kind === "quantize" || kind === "threshold";
}
function isQuantitativeLegend(kind) {
return isContinuousLegend(kind) || isSteppedLegend(kind);
}
function renderSteppedLegend(options, {
colors,
bounds,
theme,
direction
}) {
const width = Math.min(bounds.width, Math.max(80, options.width ?? 240));
const x = bounds.x;
const y = bounds.y + (options.label ? 20 : 7);
const itemWidth = width / Math.max(1, colors.range.length);
const format = options.format ?? ((value) => String(value));
const children = [];
if (options.label) {
children.push({
kind: "label",
key: "legend-label",
x,
y: bounds.y + 10,
text: options.label,
anchor: physicalTextAnchor("left", direction),
fontSize: 11,
fontWeight: 600,
style: { fill: theme.foreground, fillOpacity: 0.78 }
});
}
colors.range.forEach((fill, index) => {
children.push({
kind: "rect",
key: `legend-step:${index}`,
x: x + index * itemWidth,
y,
width: itemWidth + 0.5,
height: 8,
style: { fill }
});
});
const thresholds = legendThresholds(colors);
const first = colors.domain[0];
const last = colors.domain.at(-1);
const boundaries = colors.kind === "threshold" ? thresholds.map((value, index) => ({
value,
index: index + 1,
side: "middle"
})) : [
...typeof first === "number" ? [{ value: first, index: 0, side: "left" }] : [],
...thresholds.map((value, index) => ({
value,
index: index + 1,
side: "middle"
})),
...typeof last === "number" ? [
{
value: last,
index: colors.range.length,
side: "right"
}
] : []
];
boundaries.forEach(({ value, index, side }) => {
children.push({
kind: "label",
key: `legend-step-label:${index}:${value}`,
x: x + index * itemWidth,
y: y + 21,
text: format(value),
anchor: physicalTextAnchor(side, direction),
fontSize: 10,
style: { fill: theme.muted, fillOpacity: 0.72 }
});
});
return {
kind: "group",
key: "legend",
className: "ts-chart__legend ts-chart__legend--stepped",
ariaHidden: true,
children
};
}
function legendThresholds(colors) {
if (colors.thresholds) {
return colors.thresholds.filter(Number.isFinite);
}
const numericDomain = colors.domain.filter(
(value) => typeof value === "number" && Number.isFinite(value)
);
if (colors.kind === "threshold") return numericDomain;
const domain = numericDomain.slice().sort((left, right) => left - right);
const first = domain[0];
const last = domain.at(-1);
if (first === void 0 || last === void 0) return [];
const count = colors.range.length;
if (colors.kind === "quantize") {
return Array.from(
{ length: Math.max(0, count - 1) },
(_value, index) => first + (last - first) * (index + 1) / count
);
}
if (colors.kind === "quantile") {
return Array.from(
{ length: Math.max(0, count - 1) },
(_value, index) => quantileSorted(domain, (index + 1) / count)
).filter(Number.isFinite);
}
return [];
}
function quantileSorted(values, probability) {
const count = values.length;
if (count === 0) return Number.NaN;
if (probability <= 0 || count < 2) return values[0] ?? Number.NaN;
if (probability >= 1) return values[count - 1] ?? Number.NaN;
const position = (count - 1) * probability;
const lowerIndex = Math.floor(position);
const lower = values[lowerIndex] ?? Number.NaN;
const upper = values[lowerIndex + 1] ?? lower;
return lower + (upper - lower) * (position - lowerIndex);
}
function colorGradientLegend(options = {}) {
return {
placement: options.placement,
height() {
return options.label ? 55 : 42;
},
render({ colors, bounds, theme, direction }) {
const first = colors.domain[0];
const last = colors.domain.at(-1);
if (typeof first !== "number" || typeof last !== "number") {
throw new TypeError(
"A gradient legend requires a numeric color-scale domain"
);
}
const steps = Math.max(2, Math.floor(options.steps ?? 32));
const width = Math.min(bounds.width, Math.max(80, options.width ?? 240));
const x = bounds.x;
const y = bounds.y + (options.label ? 20 : 7);
const itemWidth = width / steps;
const format = options.format ?? ((value) => String(value));
const children = [];
if (options.label) {
children.push({
kind: "label",
key: "legend-label",
x,
y: bounds.y + 10,
text: options.label,
anchor: physicalTextAnchor("left", direction),
fontSize: 11,
fontWeight: 600,
style: { fill: theme.foreground, fillOpacity: 0.78 }
});
}
for (let index = 0; index < steps; index += 1) {
const ratio = index / (steps - 1);
const value = first + (last - first) * ratio;
children.push({
kind: "rect",
key: `legend-gradient:${index}`,
x: x + index * itemWidth,
y,
width: itemWidth + 0.5,
height: 8,
style: { fill: colors.map(value) }
});
}
children.push(
{
kind: "label",
key: "legend-gradient:min",
x,
y: y + 21,
text: format(first),
anchor: physicalTextAnchor("left", direction),
fontSize: 10,
style: { fill: theme.muted, fillOpacity: 0.72 }
},
{
kind: "label",
key: "legend-gradient:max",
x: x + width,
y: y + 21,
text: format(last),
anchor: physicalTextAnchor("right", direction),
fontSize: 10,
style: { fill: theme.muted, fillOpacity: 0.72 }
}
);
return {
kind: "group",
key: "legend",
className: "ts-chart__legend ts-chart__legend--gradient",
ariaHidden: true,
children
};
}
};
}
export {
colorGradientLegend,
colorLegend,
colorLegendItems
};