← Files DataARCHIVED FILE
templates/data-app/base/src/charting/chart-data-shape.js
26.3 KB · Sep 30, 2026 · 23:19 UTC
import { boxPlotSummaryFields, hasReviewedBoxPlotSummary, waterfallRowFields } from "./chart-transforms.js";
import { ratioMetric } from "./chart-theme.js";
import { projectBarOptions } from "./bar-family.js";
import { chartAnnotationFields, normalizeChartAnnotations } from "./chart-annotations.js";
// Reviewed values supplied with the current rows, independently of edited settings.
export const chartDataInputKeys = Object.freeze(["beginning", "ending"]);
const scalarSpecKeys = [
"type",
"presentation",
"x",
"y",
"series",
"source",
"target",
"xLabel",
"yLabel",
"rightYAxisLabel",
"yAxisPosition",
"xLabelPosition",
"xTickLabelLayout",
"axisPercentDigits",
"showXAxisLabel",
"showYAxisLabel",
"showCategoryTicks",
"startAtZero",
"showValues",
"showLegend",
"showAnnotations",
"baseColor",
"valueDecimals",
"currency",
"yTickCount",
"missingValues",
"reverseRows",
"colorScaleStartAtZero",
"hideMissingCells",
"colorBySign",
"colorByColumn",
"sortOrder",
"centerLabel",
"centerValue",
...chartDataInputKeys,
"rowHeight",
"markRadius",
"markStartRadius",
"initialVisibleCount",
"labelMaxLength",
"penultimateLabelAlignment",
"sankeyLabelFontSize",
"sankeyLinkColorMode",
"sankeyNodeWidth",
"sankeySortNodes",
"sankeyTerminalLabelAlignment",
"sankeyValueFontSize",
"groupOther",
"maxCategories",
"distribution",
"variant",
"stackable",
];
const listSpecKeys = [
"fields",
"barFields",
"dashedFields",
"stages",
"preserveCategories",
"categoryOrder",
"seriesOrder",
"rightAxisFields",
];
export function isTemporalCategory(value) {
return typeof value === "string" && (/^\d{4}-(?:0[1-9]|1[0-2])$/u.test(value)
|| /^\d{4}-\d{2}-\d{2}(?:T|$)/u.test(value) && Number.isFinite(Date.parse(value)));
}
/** Calendar order is a display transform, never a mutation of reviewed rows. */
export function orderCalendarRows(rows, field, spec = {}) {
if (!rows.length || ["ascending", "descending"].includes(spec.sortOrder)) return rows;
if (spec.categoryOrder?.length) {
const order = new Map(spec.categoryOrder.map((value, index) => [value, index]));
return [...rows].sort((a, b) => (order.get(a[field]) ?? order.size) - (order.get(b[field]) ?? order.size));
}
if (spec.sortOrder === "original") return rows;
const weekdays = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
const weekday = (value) => typeof value === "string"
&& /^(?:mon(?:day)?|tue(?:s(?:day)?)?|wed(?:nesday)?|thu(?:rs?(?:day)?)?|fri(?:day)?|sat(?:urday)?|sun(?:day)?)\.?$/iu.test(value.trim())
? weekdays.indexOf(value.trim().slice(0, 3).toLowerCase()) : -1;
const weekdayIndices = rows.map((row) => weekday(row[field]));
// Repeated weekdays may represent successive weeks, not a categorical week.
if (weekdayIndices.every((day) => day >= 0) && new Set(weekdayIndices).size === rows.length) {
return [...rows].sort((a, b) => weekday(a[field]) - weekday(b[field]));
}
if (rows.every((row) => isTemporalCategory(row[field]))) {
return [...rows].sort((a, b) => Date.parse(a[field]) - Date.parse(b[field]));
}
return rows;
}
/** Separate two measures, not two categories of the same measure. Empty explicit fields opt out. */
export function secondaryAxisFields(spec, rows, fields) {
if (Array.isArray(spec.rightAxisFields)) {
const right = fields.filter((field) => spec.rightAxisFields.includes(field));
return right.length < fields.length ? right : [];
}
if (spec.series || spec.barFields?.length || !["line", "bar", "area", "horizontalBar"].includes(spec.type)
|| fields.length < 2) return [];
if (fields.length !== 2) return [];
const measureKey = field => String(field).replace(/([a-z])([A-Z])/gu, "$1 $2")
.replace(/[_·-]/gu, " ").toLowerCase().trim()
.replace(/^(?:current|latest|recent|previous|prior|preceding)\s+/u, "")
.replace(/\s+(?:current|latest|recent|previous|prior|preceding)$/u, "");
if (measureKey(fields[0]) === measureKey(fields[1])) return [];
const values = fields.map((field) => rows.map((row) => row[field]).filter(Number.isFinite));
if (values.some((series) => !series.length)) return [];
const magnitude = values.map((series) => Math.max(...series.map(Math.abs)));
const differentUnits = ratioMetric(fields[0], values[0]) !== ratioMetric(fields[1], values[1]);
const differentScales = Math.min(...magnitude) > 0 && Math.max(...magnitude) / Math.min(...magnitude) >= 20;
return differentUnits || differentScales ? [fields[1]] : [];
}
/** Avoid restating categorical ticks and the chart title; retain axis identity when needed. */
export function axisTitleVisibility(spec, separateAxes = false) {
const numericPair = spec.type === "scatter";
return {
x: spec.showXAxisLabel ?? Boolean(spec.xLabel || numericPair || spec.type === "histogram"),
y: spec.showYAxisLabel ?? Boolean(spec.yLabel || spec.rightYAxisLabel
|| separateAxes || numericPair || spec.type === "boxPlot"),
};
}
/** Options consumed by the canonical chart renderer and editor. */
export const chartSpecKeys = Object.freeze([
...scalarSpecKeys, ...listSpecKeys, "colors", "lineGradients", "colorDomain", "colorBands", "tooltipFields", "legend", "cellLabel", "hoverHighlight", "barOptions", "annotations",
]);
const scalar = (value) =>
value == null ||
typeof value === "string" ||
typeof value === "boolean" ||
(typeof value === "number" && Number.isFinite(value));
const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
export function lineGradients(value) {
if (!record(value)) return {};
return Object.fromEntries(Object.entries(value).flatMap(([field,stops]) => {
if (!Array.isArray(stops) || stops.length < 2 || stops.length > 16) return [];
const normalized = stops.map((stop,index) => typeof stop === "string"
? {color:stop,offset:index/(stops.length-1)}
: {color:stop?.color,offset:stop?.offset});
return normalized.every((stop,index) => typeof stop.color === "string" && Number.isFinite(stop.offset)
&& stop.offset >= 0 && stop.offset <= 1 && (!index || stop.offset >= normalized[index-1].offset))
? [[field,normalized]] : [];
}));
}
/** Project a separately shareable chart spec without carrying arbitrary extension metadata. */
export function projectChartSpec(spec = {}) {
const result = {};
if (record(spec.lineGradients)) result.lineGradients = lineGradients(spec.lineGradients);
if (record(spec.barOptions)) result.barOptions = projectBarOptions(spec.barOptions);
for (const key of scalarSpecKeys) {
if (Object.hasOwn(spec, key) && scalar(spec[key])) result[key] = spec[key];
}
if (result.type === "horizontal-bar") result.type = resolvedChartType(result);
for (const key of listSpecKeys) {
if (Array.isArray(spec[key])) result[key] = spec[key].filter((field) => typeof field === "string");
}
if (record(spec.colors)) {
result.colors = Object.fromEntries(Object.entries(spec.colors).filter(([, color]) => typeof color === "string"));
}
if (Array.isArray(spec.tooltipFields)) result.tooltipFields = spec.tooltipFields.slice(0, 10).flatMap(entry =>
typeof entry?.field === "string" && typeof entry.label === "string"
? [{ field: entry.field.slice(0, 200), label: entry.label.slice(0, 200) }] : []);
if (Array.isArray(spec.colorDomain)) {
result.colorDomain = spec.colorDomain.slice(0, 2).map((value) => (Number.isFinite(value) ? value : null));
}
if (Array.isArray(spec.colorBands)) {
result.colorBands = spec.colorBands.slice(0, 24).filter(record).flatMap((band) => {
if (typeof band.color !== "string") return [];
return [{
...(Number.isFinite(band.max) ? { max: band.max } : {}),
color: band.color,
...(typeof band.label === "string" ? { label: band.label } : {}),
...(typeof band.legendGroup === "string" ? { legendGroup: band.legendGroup } : {}),
}];
});
}
if (record(spec.legend)) {
const legend = {};
if (typeof spec.legend.position === "string") legend.position = spec.legend.position;
if (spec.legend.comparisons === "grouped") legend.comparisons = "grouped";
if (record(spec.legend.labels)) legend.labels = Object.fromEntries(Object.entries(spec.legend.labels)
.filter(([field, label]) => field.length <= 200 && typeof label === "string" && label.length <= 200));
if (Object.keys(legend).length) result.legend = legend;
}
if (record(spec.cellLabel)) {
const cellLabel = {};
if (typeof spec.cellLabel.text === "string") cellLabel.text = spec.cellLabel.text;
if (typeof spec.cellLabel.color === "string") cellLabel.color = spec.cellLabel.color;
if (Number.isFinite(spec.cellLabel.showWhen)) cellLabel.showWhen = spec.cellLabel.showWhen;
if (Number.isFinite(spec.cellLabel.fontSize)) cellLabel.fontSize = spec.cellLabel.fontSize;
if (Number.isFinite(spec.cellLabel.fontWeight)) cellLabel.fontWeight = spec.cellLabel.fontWeight;
if (Object.keys(cellLabel).length) result.cellLabel = cellLabel;
}
if (typeof spec.hoverHighlight === "boolean") result.hoverHighlight = spec.hoverHighlight;
else if (record(spec.hoverHighlight)) {
const hoverHighlight = {};
if (typeof spec.hoverHighlight.color === "string") hoverHighlight.color = spec.hoverHighlight.color;
if (Number.isFinite(spec.hoverHighlight.width) && spec.hoverHighlight.width > 0) {
hoverHighlight.width = spec.hoverHighlight.width;
}
result.hoverHighlight = hoverHighlight;
}
if (Object.hasOwn(spec, "annotations")) result.annotations = normalizeChartAnnotations(spec.annotations);
return result;
}
/** Collapse only a single reviewed, nonnegative additive category measure. */
export function groupAdditiveCategories(
rows = [],
{ categoryField, valueField, maxCategories = 7, preserveCategories = [], enabled = false } = {},
) {
const limit = Number(maxCategories);
if (!enabled || !categoryField || !valueField || !Number.isSafeInteger(limit) || limit < 2 || rows.length <= limit) {
return rows;
}
const measureWords = String(valueField)
.replace(/([a-z])([A-Z])/gu, "$1 $2")
.replaceAll("_", " ");
if (/\b(?:rate|ratio|percent(?:age)?|pct|avg|average|mean|median|share|per)\b/iu.test(measureWords)) return rows;
const categories = new Set();
for (const row of rows) {
const category = row?.[categoryField];
const value = row?.[valueField];
if (typeof category !== "string" || !category.trim() || categories.has(category)
|| /^other$/iu.test(category.trim()) || !Number.isFinite(value) || value < 0) return rows;
categories.add(category);
}
const requested = Array.isArray(preserveCategories) ? preserveCategories : [];
const preserved = new Set(requested.map(String).filter((category) => categories.has(category)));
if (preserved.size >= limit) return rows;
const sorted = [...rows].sort((left, right) => right[valueField] - left[valueField]);
const leading = new Set([
...sorted.filter((row) => preserved.has(row[categoryField])),
...sorted.filter((row) => !preserved.has(row[categoryField])),
].slice(0, limit - 1).map((row) => row[categoryField]));
const other = sorted.filter((row) => !leading.has(row[categoryField]));
if (!other.length) return rows;
return [
...sorted.filter((row) => leading.has(row[categoryField])),
{ [categoryField]: "Other", [valueField]: other.reduce((total, row) => total + row[valueField], 0) },
];
}
/** Group only independently additive, mutually exclusive series at each reviewed x value. */
export function groupAdditiveSeries(
rows = [],
{ groupField, categoryField, valueField, maxCategories = 7, preserveCategories = [], enabled = false } = {},
) {
if (!enabled || !groupField || !categoryField || !valueField || !rows.length) return rows;
const groups = new Map();
const categories = new Set();
for (const row of rows) {
const group = row?.[groupField];
const category = row?.[categoryField];
const value = row?.[valueField];
if (group == null || typeof category !== "string" || !category.trim()
|| /^other$/iu.test(category.trim()) || !Number.isFinite(value) || value < 0) return rows;
if (!groups.has(group)) groups.set(group, new Map());
const current = groups.get(group);
if (current.has(category)) return rows;
current.set(category, row);
categories.add(category);
}
const reference = [...categories].map((category) => ({
[categoryField]: category,
[valueField]: [...groups.values()]
.reduce((peak, period) => Math.max(peak, period.get(category)?.[valueField] ?? 0), 0),
}));
const grouped = groupAdditiveCategories(reference, {
categoryField,
valueField,
maxCategories,
preserveCategories,
enabled: true,
});
if (grouped === reference) return rows;
const leading = new Set(grouped.filter((row) => row[categoryField] !== "Other")
.map((row) => row[categoryField]));
return [...groups].flatMap(([group, current]) => {
const prominent = [...current.values()].filter((row) => leading.has(row[categoryField]));
const remaining = [...current.values()].filter((row) => !leading.has(row[categoryField]));
if (!remaining.length) return prominent;
return [
...prominent,
{
[groupField]: group,
[categoryField]: "Other",
[valueField]: remaining.reduce((total, row) => total + row[valueField], 0),
},
];
});
}
/** Reconcile grouped totals with the reviewed source categories that remain visible. */
export function visibleGroupedCategories(
rows = [],
grouped = [],
{ categoryField, valueField, groupField, visibleCategories } = {},
) {
if (!visibleCategories || !grouped.some((row) => row[categoryField] === "Other")) return grouped;
const visible = new Set([...visibleCategories].map(String));
const leading = new Set(grouped.filter((row) => row[categoryField] !== "Other")
.map((row) => String(row[categoryField])));
return grouped.map((row) => row[categoryField] === "Other"
? {
...row,
[valueField]: rows.reduce((total, source) => {
const category = String(source[categoryField]);
return !leading.has(category) && visible.has(category)
&& (!groupField || source[groupField] === row[groupField])
? total + source[valueField]
: total;
}, 0),
}
: row);
}
/** Recognize ordered numeric or time buckets without collapsing ordinary categories. */
export function orderedDistribution(spec = {}, rows = []) {
if (typeof spec.distribution === "boolean") return spec.distribution;
const words = String(spec.x ?? "")
.replace(/([a-z])([A-Z])/gu, "$1 $2")
.replaceAll("_", " ");
if (!/\b(?:bands?|buckets?|bins?|intervals?)\b/iu.test(words)) return false;
const values = [...new Set(rows.map((row) => row?.[spec.x]).filter((value) => value != null))];
return values.length >= 3 && values.every((value) => typeof value === "number" && Number.isFinite(value)
|| typeof value === "string" && (/^\d{1,2}:\d{2}(?::\d{2})?$/u.test(value.trim())
|| /^(?:[<>≤≥]\s*)?\d+(?:[.,]\d+)?(?:\s*(?:-|–|—|to)\s*\d+(?:[.,]\d+)?)?\+?(?:\s*(?:m(?:in(?:ute)?s?)?|h(?:ours?)?|days?|weeks?|months?|years?))?$/iu.test(value.trim())));
}
/** Label every Nth bucket. Do not append an endpoint that breaks the cadence. */
export function temporalAxisTicks(values = [], { maxTicks = 6 } = {}) {
const reviewed = [...new Set(values.filter(value => value != null))];
const limit = Math.max(1, Number.isSafeInteger(maxTicks) ? maxTicks : 6);
if (reviewed.length <= limit) return reviewed;
if (limit === 1) return [reviewed.at(-1)];
const stride = Math.ceil(reviewed.length / limit);
return reviewed.filter((_, index) => index % stride === 0);
}
const dayMilliseconds = 86_400_000;
/** Follow the observed cadence when it is regular. Only irregular series need
* independent calendar ticks; neither mode synthesizes or moves observations. */
export function temporalTimeTicks(values = [], { maxTicks = 6 } = {}) {
const times = [...new Set(values.map(value => Date.parse(value)).filter(Number.isFinite))].sort((a,b)=>a-b);
if (!times.length) return [];
const start = Math.min(...times), end = Math.max(...times);
const limit = Math.max(1, maxTicks);
if (start === end) return [start];
if (limit === 1) return [end];
const step = times[1] - times[0];
const regularTime = times.every((time,index)=>!index || time-times[index-1]===step);
const dates = times.map(time=>new Date(time));
const months = dates.map(date=>date.getUTCFullYear()*12+date.getUTCMonth());
const monthStep = months[1]-months[0];
const monthEnd = date=>date.getUTCDate()===new Date(Date.UTC(date.getUTCFullYear(),date.getUTCMonth()+1,0)).getUTCDate();
const regularMonths = monthStep>0 && months.every((month,index)=>!index || month-months[index-1]===monthStep)
&& times.every(time=>time%dayMilliseconds===times[0]%dayMilliseconds)
&& (dates.every(date=>date.getUTCDate()===dates[0].getUTCDate()) || dates.every(monthEnd));
if (regularTime || regularMonths) return temporalAxisTicks(times,{maxTicks:limit});
const fixed = [1, 5, 10, 50, 100, 250, 500, 1000, 5000, 15000, 30000,
60000, 300000, 900000, 1800000, 3600000, 10800000, 21600000, 43200000,
dayMilliseconds, 2 * dayMilliseconds, 3 * dayMilliseconds, 5 * dayMilliseconds,
7 * dayMilliseconds, 14 * dayMilliseconds];
const minimumStep = times.every(time => time % dayMilliseconds === 0) ? dayMilliseconds : 1;
for (const step of fixed) {
if (step < minimumStep) continue;
// Weekly ticks use Mondays, including across month/year boundaries.
const origin = step >= 7 * dayMilliseconds ? 4 * dayMilliseconds : 0;
const first = Math.ceil((start - origin) / step) * step + origin;
const count = Math.floor((end - first) / step) + 1;
if (count >= 2 && count <= limit) return Array.from({ length: count }, (_, i) => first + i * step);
}
const firstDate = new Date(start), lastDate = new Date(end);
const firstMonth = firstDate.getUTCFullYear() * 12 + firstDate.getUTCMonth();
const lastMonth = lastDate.getUTCFullYear() * 12 + lastDate.getUTCMonth();
const yearPower = 10 ** Math.max(0, Math.floor(Math.log10(Math.max(1, (lastMonth - firstMonth) / 12 / limit))));
for (const step of [...new Set([1, 2, 3, 6, 12, ...[1, 2, 5, 10, 20].map(n => n * yearPower * 12)])].sort((a,b)=>a-b)) {
const ticks = [];
for (let month = Math.ceil(firstMonth / step) * step; month <= lastMonth; month += step) {
const time = Date.UTC(Math.floor(month / 12), month % 12, 1);
if (time >= start) ticks.push(time);
if (ticks.length > limit) break;
}
if (ticks.length >= 2 && ticks.length <= limit) return ticks;
}
return [end];
}
/** Plan labels against actual plot and measured text. Continuous trends use time;
* bucketed bars keep band centers and a constant index stride. */
export function temporalAxisLayout(values = [], { plotWidth = 0, leftRoom = 0, rightRoom = 0,
measureLabel = value => String(value).length * 8, maxTicks, gap = 12, edgeGap = 4, banded = false, continuous = false } = {}) {
const reviewed = [...new Set(values.filter(value => value != null))];
if (!reviewed.length) return { ticks: [], padding: { left: 0, right: 0 }, bounds: [] };
const width = Math.max(0, Number(plotWidth) || 0);
const times = continuous ? reviewed.map(value => Date.parse(value)) : [];
const domain = continuous ? [Math.min(...times), Math.max(...times)] : undefined;
const measure = value => Math.max(0, Number(measureLabel(value)) || 0);
const estimatedWidth = Math.max(1, ...reviewed.map(value => measure(continuous ? Date.parse(value) : value)));
const limit = Number.isSafeInteger(maxTicks) ? Math.max(1, maxTicks)
: Math.max(1, Math.floor(width / (estimatedWidth + gap)) + 1);
for (let count=continuous ? limit : Math.min(reviewed.length,limit);count>=1;count--) {
const ticks = (continuous ? temporalTimeTicks : temporalAxisTicks)(reviewed,{maxTicks:count});
const half = Math.max(0,...ticks.map(measure)) / 2 + edgeGap;
const padding = { left:Math.min(width/2,Math.max(0,half-leftRoom)), right:Math.min(width/2,Math.max(0,half-rightRoom)) };
const available = Math.max(0,width-padding.left-padding.right);
const bounds = ticks.map(value => {
const index = reviewed.indexOf(value);
const fraction = reviewed.length === 1 || continuous && domain[0] === domain[1] ? .5
: continuous ? (value-domain[0])/(domain[1]-domain[0])
: banded ? (index+.5)/reviewed.length : index/(reviewed.length-1);
return {value,x:padding.left+available*fraction,width:measure(value)};
});
if (bounds.every((label,index) => label.x-label.width/2 >= -leftRoom+edgeGap
&& label.x+label.width/2 <= width+rightRoom-edgeGap
&& (!index || label.x-label.width/2 >= bounds[index-1].x+bounds[index-1].width/2+gap))) {
return {ticks,padding,bounds};
}
}
return {ticks:[],padding:{left:0,right:0},bounds:[]};
}
/** Prefer horizontal labels; reserve diagonal treatment for genuinely crowded named categories. */
export function categoryAxisLayout(values = [], { preference = "auto", minimumAngledCount = 8 } = {}) {
const labels = [...new Set(values.filter((value) => value != null).map(String))];
if (labels.length && labels.every((value) => /^\d{4}-\d{2}-\d{2}(?:T|$)/u.test(value)
|| /^\d{1,2}:\d{2}(?::\d{2})?$/u.test(value)
|| /^-?\d+(?:\.\d+)?$/u.test(value))) return "horizontal";
if (["horizontal", "wrapped", "angled"].includes(preference)) return preference;
if (labels.length < minimumAngledCount) return "wrapped";
return labels.some((value) => value.length > 8) ? "angled" : "wrapped";
}
/** Keep authored chart types explicit while supporting the legacy leaderboard name. */
export function resolvedChartType(spec = {}) {
if (spec.type === "leaderboard") return "rankedList";
// Accept the common spelling at the public boundary instead of silently
// falling through to a vertical chart with unrelated category-tick rules.
return spec.type === "horizontal-bar" ? "horizontalBar" : spec.type;
}
/** Tile hit areas exactly, while preserving a visual gap even in dense heatmaps. */
export function heatmapCellSize(plotWidth, plotHeight, columns, rows) {
const pitchX = Math.max(0, plotWidth) / Math.max(1, columns);
const pitchY = Math.max(0, plotHeight) / Math.max(1, rows);
return { pitchX, pitchY, width: pitchX - Math.min(5, pitchX / 4), height: pitchY - Math.min(5, pitchY / 4) };
}
/** Keep the compact baseline while revealing additional reviewed rows that genuinely fit. */
export function rankedListCapacity({ availableHeight, rowHeight, rowGap = 0, minimumCount = 5, totalCount }) {
const minimum = Math.max(1, Number(minimumCount) || 5);
const total = Math.max(minimum, Number(totalCount) || minimum);
if (!(availableHeight > 0) || !(rowHeight > 0)) return Math.min(minimum, total);
const visible = Math.floor((availableHeight + rowGap) / (rowHeight + rowGap));
return Math.min(total, Math.max(minimum, visible));
}
const scatterIdentityFields = [
"featureName",
"feature",
"name",
"label",
"account",
"plan",
"country",
"action",
"product",
"surface",
"segment",
"status",
];
export function scatterTooltipIdentityField(row) {
return scatterIdentityFields.find((field) => typeof row?.[field] === "string" && row[field]);
}
const fieldNames = (values) => [...new Set(values.filter((field) => typeof field === "string" && field.length > 0))];
/**
* Resolve the renderer's real data shape before projecting raw rows. Series values
* become output columns after pivoting; they are not raw field dependencies.
*/
export function chartDataShape(spec, rows) {
const { type, x, y, series = "" } = spec;
const columns = [...new Set(rows.flatMap(Object.keys))];
const numeric = columns.filter((column) => rows.some((row) => Number.isFinite(row[column])));
const seriesValues = series ? [...new Set(rows.map((row) => row[series]).filter((value) => value != null))] : [];
const fields = spec.presentation && spec.barOptions?.series?.length ? spec.barOptions.series.map(item => item.key)
: seriesValues.length ? seriesValues : spec.fields ?? [y];
const barFields = type === "line" && Array.isArray(spec.barFields) ? spec.barFields : [];
const heatmapGroup = series || columns.find((column) => column !== x && !numeric.includes(column)) || x;
const sankeyStages =
type === "sankey" ? (spec.stages ?? [spec.source ?? x, spec.target ?? series]).filter(Boolean) : [];
const longForm = seriesValues.length > 0;
const reviewedBoxPlot = type === "boxPlot" && hasReviewedBoxPlotSummary(rows);
const requiresX = type !== "histogram" && type !== "sankey";
const wideFields = longForm ? [] : [...fields, ...barFields];
let required = [requiresX ? x : null, y, ...(longForm ? [series] : wideFields)];
if (type === "histogram") required = [y];
if (type === "sankey") required = [y, ...sankeyStages];
if (type === "boxPlot") required = reviewedBoxPlot ? [x, ...boxPlotSummaryFields] : [x, y];
if (type === "heatmap") required = [x, y, heatmapGroup];
if (spec.presentation && spec.barOptions) required = [x, ...fields, ...(spec.barOptions.range ?? []),
spec.barOptions.target, spec.barOptions.projection, spec.barOptions.track?.max];
const requiredRowFields = fieldNames(required);
const optional = [x, y, series, ...wideFields, ...(spec.tooltipFields ?? []).map(item => item.field)];
if (spec.presentation && spec.barOptions) optional.push(spec.barOptions.rangeLabelField, spec.barOptions.unitField, spec.barOptions.detailField,
spec.barOptions.style?.colorField, spec.barOptions.style?.textColorField, spec.barOptions.style?.thicknessField,
...(spec.barOptions.tooltipFields ?? []).map(item => item.key));
const annotations = normalizeChartAnnotations(spec.annotations);
optional.push(...chartAnnotationFields(annotations.filter((entry) => entry.kind !== "point" || !longForm)));
if (type === "waterfall") optional.push(...waterfallRowFields(rows, x));
if (type === "scatter") optional.push(...rows.map(scatterTooltipIdentityField));
const requiredSet = new Set(requiredRowFields);
const optionalRowFields = fieldNames(optional).filter((field) => columns.includes(field) && !requiredSet.has(field));
const retained = new Set([...requiredRowFields, ...optionalRowFields]);
return {
columns,
numeric,
seriesValues,
fields,
barFields,
heatmapGroup,
sankeyStages,
longForm,
reviewedBoxPlot,
requiresX,
requiredRowFields,
optionalRowFields,
// Preserve source order so a second shape inference selects the same heatmap dimension.
rowFields: columns.filter((field) => retained.has(field)),
};
}
SHA-256: 4c1dea15118a8dffd68bfa3c0b6291a691d1929862a32a02edb8c711dc1fcedc