← Files DataARCHIVED FILE
templates/data-app/inline/chart-presentation.js
13.2 KB · Sep 30, 2026 · 23:19 UTC
import { chartDataShape, chartSpecKeys, projectChartSpec, resolvedChartType } from "../base/src/charting/chart-data-shape.js";
import { changeChartEditorSpec } from "../base/src/charting/chart-editor-state.js";
import { chartTypes } from "../base/src/charting/chart-theme.js";
const conventionalTypes = new Set(["line", "area", "bar", "horizontalBar", "sparkline"]);
const stackedTypes = new Set(["stackedArea", "stackedBar", "horizontalStackedBar"]);
const proportionalTypes = new Set(["stackedBar100", "horizontalStackedBar100"]);
const editableKeys = new Set([
"type",
"x",
"y",
"series",
"fields",
"xLabel",
"yLabel",
"yAxisPosition",
"rightAxisFields",
"showXAxisLabel",
"showYAxisLabel",
"startAtZero",
"showValues",
"showLegend",
"showAnnotations",
"baseColor",
"colors",
"sortOrder",
]);
const booleanKeys = new Set([
"showXAxisLabel",
"showYAxisLabel",
"startAtZero",
"showValues",
"showLegend",
"showAnnotations",
]);
const knownKeys = new Set(chartSpecKeys);
const forbiddenKeys = new Set(["__proto__", "prototype", "constructor"]);
const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
const record = (value) =>
value !== null &&
typeof value === "object" &&
!Array.isArray(value) &&
[Object.prototype, null].includes(Object.getPrototypeOf(value));
const own = (value, key) => Object.hasOwn(value, key);
function fail(message) {
throw new Error(`Cannot apply chart edit: ${message}`);
}
function boundedText(value, label, { empty = false, max = 500 } = {}) {
if (typeof value !== "string" || value.length > max || (!empty && !value.trim())) {
fail(`${label} must be ${empty ? "text" : "nonempty text"} of at most ${max} characters.`);
}
return value.trim();
}
function cleanChart(spec) {
const chart = projectChartSpec(spec);
chart.type = resolvedChartType(chart);
for (const key of Object.keys(chart)) if (chart[key] === undefined) delete chart[key];
return chart;
}
/** The reviewed payload remains the reset point; no source data enters presentation state. */
export function originalInlineChartPresentation(component) {
return {
chart: cleanChart(component.chart),
title: component.title,
description: component.description ?? "",
};
}
function dataFields(rows, original) {
const columns = [...new Set(rows.flatMap(Object.keys))];
const originalMeasures = new Set([original.y, ...(original.fields ?? [])]);
const numeric = columns.filter((field) => {
const values = rows.map((row) => row[field]).filter((value) => value != null);
return (
values.every((value) => typeof value === "number" && Number.isFinite(value)) &&
(values.length > 0 || originalMeasures.has(field))
);
});
return {
columns,
numeric,
// A reviewed numeric code/year may already be an intentional grouping role.
categorical: columns.filter((field) => !numeric.includes(field) || field === original.series),
};
}
function policyFor(original, fields) {
const composed = Boolean(original.presentation || original.barOptions)
|| (Array.isArray(original.barFields) && original.barFields.length > 0);
const originalMeasures = original.series ? [original.y] : original.fields ?? [original.y];
const typedMeasures = originalMeasures.every((field) => fields.numeric.includes(field));
const fieldsEditable =
!composed &&
typedMeasures &&
(conventionalTypes.has(original.type) || stackedTypes.has(original.type) || proportionalTypes.has(original.type));
let allowed = new Set([original.type]);
if (!composed && typedMeasures && conventionalTypes.has(original.type)) {
allowed = new Set(conventionalTypes);
if (original.stackable === true) for (const type of stackedTypes) allowed.add(type);
} else if (!composed && typedMeasures && stackedTypes.has(original.type)) {
allowed = new Set([...conventionalTypes, ...stackedTypes]);
} else if (!composed && typedMeasures && proportionalTypes.has(original.type)) {
allowed = new Set(proportionalTypes);
}
if (original.stackable === false) {
for (const type of allowed)
if (type !== original.type && type.toLowerCase().includes("stacked")) allowed.delete(type);
}
return { fieldsEditable, types: chartTypes.filter((type) => allowed.has(type)) };
}
function mapping(spec) {
return [
spec.x ?? "",
spec.y,
spec.series ?? "",
spec.fields ?? [spec.y],
spec.barFields ?? [],
spec.source ?? "",
spec.target ?? "",
spec.stages ?? [],
];
}
function stackedMapping(spec) {
return mapping(spec.series ? { ...spec, fields: [spec.y] } : spec);
}
// The canonical pivot does not aggregate duplicate cells. A new mapping must not
// silently discard a reviewed observation or overwrite its category column.
function preservesGrain(rows, x, series) {
const seen = new Map();
for (const row of rows) {
if (!own(row, x) || row[x] == null) return false;
const group = series ? row[series] : "";
if (series && (!own(row, series) || group == null || String(group) === x || forbiddenKeys.has(String(group)))) {
return false;
}
const groups = seen.get(row[x]) ?? new Set();
const key = series ? String(group) : "";
if (groups.has(key)) return false;
groups.add(key);
seen.set(row[x], groups);
}
return true;
}
function validateMapping(original, rows, chart, fields, policy) {
if (!policy.types.includes(chart.type)) fail("that chart type is not compatible with the reviewed data.");
if (!policy.fieldsEditable && !same(mapping(chart), mapping(original))) {
fail("this chart's reviewed data mapping is fixed.");
}
if (chart.type.toLowerCase().includes("stacked") && !same(stackedMapping(chart), stackedMapping(original))) {
fail("stacking and percentage normalization require the original reviewed data mapping.");
}
const shape = chartDataShape(chart, rows);
for (const field of shape.requiredRowFields) {
if (!fields.columns.includes(field)) fail(`the reviewed field ${JSON.stringify(field)} is unavailable.`);
}
if (!policy.fieldsEditable) return;
if (shape.requiresX && (!fields.columns.includes(chart.x) || typeof chart.x !== "string")) {
fail("choose an available reviewed category field.");
}
if (!fields.numeric.includes(chart.y)) fail("choose a numeric reviewed measure.");
if (chart.series && (chart.series === chart.x || !fields.categorical.includes(chart.series))) {
fail("choose a reviewed grouping field distinct from the category.");
}
if (!chart.series && (chart.fields ?? [chart.y]).some((field) => !fields.numeric.includes(field))) {
fail("every displayed measure must be a numeric reviewed field.");
}
if (
chart.series &&
!same(chart.fields, original.fields) &&
(chart.fields ?? [chart.y]).some((field) => !fields.numeric.includes(field))
) {
fail("every selected measure must be a numeric reviewed field.");
}
if (
(chart.x !== original.x || (chart.series ?? "") !== (original.series ?? "")) &&
!preservesGrain(rows, chart.x, chart.series)
) {
fail("that grouping would collapse reviewed rows; ask Data for a new aggregation.");
}
}
/** Choices are derived only from the already serialized, privacy-reviewed rows. */
export function inlineChartEditorCapabilities(component, rows, draftChart = component.chart) {
const original = cleanChart(component.chart);
const chart = cleanChart(draftChart);
const fields = dataFields(rows, original);
const policy = policyFor(original, fields);
const accepts = (candidate) => {
try {
validateMapping(original, rows, candidate, fields, policy);
return true;
} catch {
return false;
}
};
return {
types: policy.types.filter((type) => accepts({ ...chart, type })),
fieldsEditable: policy.fieldsEditable && !chart.type.toLowerCase().includes("stacked"),
xFields: fields.columns.filter((field) => accepts(changeChartEditorSpec(chart, "x", field, fields))),
yFields: fields.numeric.filter((field) => accepts(changeChartEditorSpec(chart, "y", field, fields))),
seriesFields: ["", ...fields.categorical.filter((field) => field !== chart.x)].filter((field) =>
accepts(changeChartEditorSpec(chart, "series", field, fields)),
),
notice: proportionalTypes.has(original.type)
? "Percentage charts keep their reviewed data mapping and normalization. Ask Data for a different measure or grouping."
: chart.type.toLowerCase().includes("stacked")
? "Stacked charts keep their reviewed data mapping. Choose an unstacked chart to select a different measure or grouping."
: policy.fieldsEditable
? null
: original.presentation
? "This bar presentation keeps its reviewed configuration. Its title and description can still be edited."
: "This chart's reviewed type and data mapping are fixed. Labels, colors, and appearance can still be edited.",
};
}
function allowedColor(value) {
return (
typeof value === "string" &&
value.length <= 200 &&
(/^#[\da-f]{6}$/iu.test(value) ||
/^var\(--(?:chart-[1-8]|secondary|positive|negative)\)$/u.test(value) ||
/^color-mix\(in srgb, var\(--chart-[1-8]\) 42%, var\(--surface\)\)$/u.test(value))
);
}
function validateOptions(original, rows, chart) {
for (const [key, value] of Object.entries(chart)) {
if (!knownKeys.has(key) || forbiddenKeys.has(key)) fail(`unsupported chart option ${JSON.stringify(key)}.`);
if (!editableKeys.has(key) && !same(value, original[key])) fail(`${key} is fixed by the reviewed chart.`);
if (booleanKeys.has(key) && typeof value !== "boolean") fail(`${key} must be a boolean.`);
}
for (const key of knownKeys) {
if (!editableKeys.has(key) && !same(chart[key], original[key])) fail(`${key} is fixed by the reviewed chart.`);
}
for (const key of ["x", "y", "series"]) {
if (chart[key] != null) boundedText(chart[key], key, { empty: key === "series", max: 200 });
}
for (const key of ["xLabel", "yLabel"]) {
if (chart[key] != null) boundedText(chart[key], key, { empty: true });
}
if (
chart.fields != null &&
(!Array.isArray(chart.fields) ||
!chart.fields.length ||
chart.fields.length > 40 ||
new Set(chart.fields).size !== chart.fields.length)
)
fail("choose a bounded list of distinct measures.");
for (const field of chart.fields ?? []) boundedText(field, "measure", { max: 200 });
if (chart.yAxisPosition != null && !["left", "right"].includes(chart.yAxisPosition)) {
fail("choose a supported axis side.");
}
if (chart.rightAxisFields !== undefined && !same(chart.rightAxisFields, original.rightAxisFields)) {
const measures = chart.fields ?? [chart.y];
if (!Array.isArray(chart.rightAxisFields) || chart.rightAxisFields.length > 40
|| new Set(chart.rightAxisFields).size !== chart.rightAxisFields.length
|| chart.rightAxisFields.some((field) => !measures.includes(field))
|| chart.series || chart.barFields?.length || !["line", "bar", "area", "horizontalBar"].includes(chart.type)) {
fail("choose a secondary axis from the displayed reviewed measures.");
}
}
if (chart.sortOrder != null && !["original", "ascending", "descending"].includes(chart.sortOrder)) {
fail("choose a supported sort order.");
}
if (chart.baseColor != null && chart.baseColor !== original.baseColor && !allowedColor(chart.baseColor)) {
fail("choose a supported chart color.");
}
if (chart.colors != null) {
if (!record(chart.colors) || Object.keys(chart.colors).length > 200) fail("chart colors must be a bounded map.");
const colorKeys = new Set([
...rows.flatMap((row) => [
...Object.keys(row),
...Object.values(row)
.filter((value) => value != null)
.map(String),
]),
...Object.keys(original.colors ?? {}),
]);
for (const [key, value] of Object.entries(chart.colors)) {
if (
forbiddenKeys.has(key) ||
!colorKeys.has(key) ||
key.length > 500 ||
typeof value !== "string" ||
value.length > 200 ||
(value !== original.colors?.[key] && !allowedColor(value))
)
fail("choose a supported reviewed series color.");
}
}
}
/** Validate a presentation-only edit without changing the source snapshot or row grain. */
export function validateInlineChartPresentation(component, rows, candidate) {
if (!record(candidate) || Object.keys(candidate).some((key) => !["chart", "title", "description"].includes(key))) {
fail("only chart presentation can be changed.");
}
if (!record(candidate.chart)) fail("chart configuration is required.");
const original = cleanChart(component.chart);
validateOptions(original, rows, candidate.chart);
const chart = cleanChart(candidate.chart);
const fields = dataFields(rows, original);
validateMapping(original, rows, chart, fields, policyFor(original, fields));
// The shared editor supplies these defaults. Preserve the original authored
// omissions so changing only copy does not falsely mark source fields as edited.
if (!own(original, "series") && !chart.series) delete chart.series;
if (!own(original, "fields") && same(chart.fields, [chart.y])) delete chart.fields;
return {
chart,
title: boundedText(candidate.title, "title"),
description: boundedText(candidate.description ?? "", "description", { empty: true, max: 2_000 }),
};
}
SHA-256: abd1d0abec1624f86c2765979eb728b3753772919a24e6115c6d4d9dbb2a321c