← Files DataARCHIVED FILE

templates/data-app/base/src/charting/chart-spec-validation.js

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

↓ Download file

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

const fieldName = (value) => typeof value === "string" && value.trim().length > 0;
const wideMeasureTypes = ["line", "sparkline", "area", "stackedArea", "bar", "horizontalBar",
  "horizontal-bar", "stackedBar", "stackedBar100", "horizontalStackedBar", "horizontalStackedBar100"];

/** Validate raw bindings before projection can silently discard malformed values. */
export function getChartSpecError(spec, { id, partial = false } = {}) {
  const invalid = (message) => `Invalid chart${id ? ` ${JSON.stringify(id)}` : ""}: ${message}`;
  if (spec === null || typeof spec !== "object" || Array.isArray(spec)) {
    return invalid("chart must be a configuration object.");
  }
  if ((!partial || Object.hasOwn(spec, "type")) && !chartTypes.includes(spec.type) && spec.type !== "horizontal-bar") {
    return invalid(`chart.type must be one of: ${chartTypes.join(", ")}.`);
  }
  if (Array.isArray(spec.y)) {
    const fields = spec.y.length && spec.y.length <= 40 && spec.y.every(fieldName) ? spec.y : ["measureA", "measureB"];
    return invalid(`chart.y must be a single field name, not an array. For multiple measures, use y: ${JSON.stringify(fields[0])}, fields: ${JSON.stringify(fields)}.`);
  }
  // Existing wide-form charts may bind all measures through fields or a bar recipe.
  const hasMeasureList = wideMeasureTypes.includes(spec.type)
    && (spec.fields?.length || spec.presentation && spec.barOptions?.series?.length);
  if (((!partial && !hasMeasureList) || (Object.hasOwn(spec, "y") && spec.y !== undefined)) && !fieldName(spec.y)) {
    return invalid("chart.y must be a nonempty string field name.");
  }
  const requiresX = !["histogram", "sankey"].includes(spec.type);
  if (((!partial && requiresX) || (spec.x != null && spec.x !== "")) && !fieldName(spec.x)) {
    return invalid("chart.x must be a nonempty string field name.");
  }
  for (const key of ["series", "source", "target"]) {
    if (spec[key] != null && spec[key] !== "" && !fieldName(spec[key])) {
      return invalid(`chart.${key} must be a single string field name.`);
    }
  }
  for (const key of ["fields", "barFields", "stages"]) {
    if (spec[key] == null) continue;
    // An empty combination list clears bars; an empty measure/stage list cannot plot anything.
    if (!Array.isArray(spec[key]) || (key !== "barFields" && !spec[key].length)) {
      return invalid(`chart.${key} must be a field list${key === "barFields" ? "" : " with at least one field"}.`);
    }
    if (!spec[key].every(fieldName)) {
      return invalid(`chart.${key} must contain only nonempty string field names.`);
    }
  }
  if (!partial && spec.type === "sankey" && (spec.stages ?? [spec.source ?? spec.x, spec.target ?? spec.series]).filter(Boolean).length < 2) {
    return invalid("a Sankey chart needs at least two stage fields, using chart.stages or chart.source and chart.target.");
  }
  // This contract is independent of row values: zero, null and empty reviewed data are valid.
  return null;
}

export function assertChartSpec(spec, options) {
  const error = getChartSpecError(spec, options);
  if (error) throw new Error(error);
}

SHA-256: a293a06bc346734f25742f79d100ce45c45d107ec5c368965f931bf2d7b5c5f8