@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.
291 lines (290 loc) • 8.56 kB
JavaScript
import {
layoutCategoricalLegendItems,
resolveCategoricalLegendItems
} from "./legend-layout-internal.js";
function colorLegend(options = {}) {
const gradient = colorGradientLegend({
label: options.label,
width: options.width,
format: options.format,
placement: options.placement
});
const minimumItemWidth = Math.max(64, options.itemWidth ?? 110);
const labelOffset = options.label ? 13 : 0;
return {
placement: options.placement,
height(itemCount, context) {
if (isQuantitativeLegend(context.colors.kind)) {
return gradient.height(itemCount, context);
}
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 { colors, bounds, theme } = context;
const items = resolveCategoricalLegendItems(colors);
const layout = layoutCategoricalLegendItems(
items.length,
bounds.width,
minimumItemWidth
);
const children = [];
if (options.label) {
children.push({
kind: "label",
key: "legend-label",
x: bounds.x,
y: bounds.y + 11,
text: options.label,
fontSize: 11,
fontWeight: 600,
style: { fill: theme.foreground, fillOpacity: 0.78 }
});
}
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,
baseline: "middle",
fontSize: 11,
style: { fill: theme.foreground, fillOpacity: 0.76 }
}
);
});
return {
kind: "group",
key: "legend",
className: "ts-chart__legend",
ariaHidden: true,
children
};
}
};
}
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 }) {
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,
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,
anchor: "middle"
})) : [
...typeof first === "number" ? [{ value: first, index: 0, anchor: "start" }] : [],
...thresholds.map((value, index) => ({
value,
index: index + 1,
anchor: "middle"
})),
...typeof last === "number" ? [
{
value: last,
index: colors.range.length,
anchor: "end"
}
] : []
];
boundaries.forEach(({ value, index, anchor }) => {
children.push({
kind: "label",
key: `legend-step-label:${index}:${value}`,
x: x + index * itemWidth,
y: y + 21,
text: format(value),
anchor,
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 }) {
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,
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: "start",
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: "end",
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
};