← Files DataARCHIVED FILE

templates/data-app/base/src/charting/chart-editor-state.js

7.69 KB · Sep 30, 2026 · 23:19 UTC

↓ Download file

import { chartTypes } from "./chart-theme.js";

const continuousFields = new Set(["xLabel", "yLabel", "title", "description"]);
const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value);

function copy(value) {
  if (Array.isArray(value)) return value.map(copy);
  if (record(value)) return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, copy(child)]));
  return value;
}

function ordered(value) {
  if (Array.isArray(value)) return value.map(ordered);
  if (record(value))
    return Object.fromEntries(
      Object.keys(value)
        .sort()
        .map((key) => [key, ordered(value[key])]),
    );
  return value;
}

// Expand defaults only for comparison. The stored draft and reset value retain
// the exact authored chart spec, including deliberately omitted properties.
export function normalizeChartEditorSpec(chart = {}) {
  return {
    ...chart,
    fields: chart.fields ?? [chart.y].filter(Boolean),
    series: chart.series ?? "",
    showXAxisLabel: chart.showXAxisLabel ?? true,
    showYAxisLabel: chart.showYAxisLabel ?? true,
    startAtZero: chart.startAtZero ?? true,
    showValues: chart.showValues ?? false,
    showLegend: chart.showLegend ?? true,
    sortOrder: chart.sortOrder ?? (["leaderboard", "rankedList"].includes(chart.type) ? "descending" : "original"),
    colors: chart.colors ?? {},
  };
}

export function chartEditorSpecsEqual(left, right) {
  return (
    JSON.stringify(ordered(normalizeChartEditorSpec(left))) === JSON.stringify(ordered(normalizeChartEditorSpec(right)))
  );
}

export function changeChartEditorSpec(spec, field, value, { columns } = {}) {
  const clearsSeries = field === "x" && value === spec.series;
  const changesSeries = clearsSeries || (field === "series" && value !== (spec.series ?? ""));
  // A split temporarily hides wide-form measures. Keep their entire authored
  // list only when every name is a real reviewed column; partial matches may
  // instead be coincidentally named pivot labels from a long-form chart.
  const retainedFields =
    changesSeries &&
    Array.isArray(columns) &&
    Array.isArray(spec.fields) &&
    spec.fields.length > 0 &&
    spec.fields.every((candidate) => columns.includes(candidate))
      ? spec.fields
      : [spec.y];
  return {
    ...spec,
    [field]: value,
    ...(field === "y" ? { fields: [value] } : {}),
    ...(changesSeries ? { fields: retainedFields } : {}),
    ...(clearsSeries ? { series: "" } : {}),
    ...(field === "xLabel" ? { showXAxisLabel: Boolean(value.trim()) } : {}),
    ...(field === "yLabel" ? { showYAxisLabel: Boolean(value.trim()) } : {}),
  };
}

export function availableChartTypes(chart) {
  return chartTypes.filter((type) => type !== "leaderboard"
    && (chart.stackable !== false || type === chart.type || !type.toLowerCase().includes("stacked")));
}

export function chartEditorChoices(originalChart, spec, { columns, numeric }, capabilities = {}) {
  const restrict = (choices, allowed) =>
    allowed === undefined
      ? choices
      : Array.isArray(allowed)
        ? choices.filter((choice) => allowed.includes(choice))
        : [];
  const categorical = columns.filter((field) => !numeric.includes(field));
  const supportedTypes = availableChartTypes(originalChart);
  return {
    availableTypes: restrict(supportedTypes, capabilities.types),
    fieldsEditable: capabilities.fieldsEditable !== false,
    xFields: restrict(columns, capabilities.xFields),
    // Explicit policies know the authored roles, including all-null measures
    // and numeric categories. Still intersect them with approved row columns.
    yFields: restrict(capabilities.yFields === undefined ? numeric : columns, capabilities.yFields),
    seriesFields: restrict(
      capabilities.seriesFields === undefined
        ? ["", ...categorical.filter((field) => field !== spec.x)]
        : ["", ...columns],
      capabilities.seriesFields,
    ),
  };
}

export function copyChartEditorPresentation({ chart, title, description = "" }) {
  return { chart: copy(chart), title, description: description ?? "" };
}

function checkedMetadata(presentation) {
  if (typeof presentation.title !== "string" || !presentation.title.trim() || presentation.title.length > 500) {
    throw new Error("Chart title must contain 1–500 characters.");
  }
  if (typeof presentation.description !== "string" || presentation.description.length > 2_000) {
    throw new Error("Chart description must contain at most 2,000 characters.");
  }
  return { title: presentation.title.trim(), description: presentation.description.trim() };
}

export async function saveChartEditorPresentation(
  presentation,
  { rows, editMetadata = false, validatePresentation, onSave } = {},
) {
  const candidate = copyChartEditorPresentation(presentation);
  const checked = validatePresentation ? await validatePresentation(candidate, rows) : candidate;
  if (!record(checked) || !record(checked.chart)) throw new Error("Chart presentation is invalid.");
  const accepted = copyChartEditorPresentation(checked);
  if (typeof onSave !== "function") throw new Error("Chart edits cannot be saved here.");
  if (editMetadata) await onSave(accepted.chart, checkedMetadata(accepted));
  else await onSave(accepted.chart);
  return accepted;
}

export function chartEditorPresentationsEqual(left, right) {
  return (
    left.title === right.title &&
    (left.description ?? "") === (right.description ?? "") &&
    chartEditorSpecsEqual(left.chart, right.chart)
  );
}

export function createChartEditorState(presentation) {
  const initial = copyChartEditorPresentation(presentation);
  return {
    initial,
    draft: initial,
    history: [initial],
    historyIndex: 0,
    dirty: false,
    lastField: null,
  };
}

export function changeChartEditorState(current, presentation, field) {
  const draft = copyChartEditorPresentation(presentation);
  const dirty = !chartEditorPresentationsEqual(draft, current.initial);
  if (chartEditorPresentationsEqual(draft, current.draft)) {
    const history = [...current.history];
    history[current.historyIndex] = draft;
    return { ...current, draft, history, dirty, lastField: null };
  }
  const continuous = current.historyIndex > 0 && continuousFields.has(field) && current.lastField === field;
  const history = [...current.history.slice(0, current.historyIndex + (continuous ? 0 : 1)), draft];
  return { ...current, draft, history, historyIndex: history.length - 1, dirty, lastField: field ?? null };
}

export function stepChartEditorHistory(current, direction) {
  const historyIndex = current.historyIndex + direction;
  if (historyIndex < 0 || historyIndex >= current.history.length) return current;
  const draft = current.history[historyIndex];
  return {
    ...current,
    draft,
    historyIndex,
    dirty: !chartEditorPresentationsEqual(draft, current.initial),
    lastField: null,
  };
}

export function resetChartEditorState(current, presentation) {
  return changeChartEditorState(current, presentation, "reset");
}

export function chartEditorShortcut(event) {
  if (event.defaultPrevented || event.altKey || !(event.metaKey || event.ctrlKey)) return null;
  const key = event.key?.toLowerCase();
  if (key === "z") return event.shiftKey ? "redo" : "undo";
  return key === "s" ? "save" : null;
}

export function chartEditorPreviewState(original, draft, { visibleSeries, zoomRange } = {}) {
  const before = normalizeChartEditorSpec(original);
  const after = normalizeChartEditorSpec(draft);
  const remapped =
    before.type !== after.type ||
    before.x !== after.x ||
    before.y !== after.y ||
    before.series !== after.series ||
    JSON.stringify(before.fields) !== JSON.stringify(after.fields);
  return {
    visibleSeries: remapped ? undefined : visibleSeries,
    zoomRange: remapped ? undefined : zoomRange,
  };
}

SHA-256: 57e1439926bff4e9aab3847b3e1bf92f1ab5554f53688112da56344c5a9cc0b8