← Files DataARCHIVED FILE

templates/data-app/base/src/chart-export.js

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

↓ Download file

import { reviewedSource } from "./source-provenance.js";
import { formatDateRange } from "./date-range.js";
import { dataAppThemes, dataAppThemePalette, dataAppThemeTokens } from "./theme-presets.js";

export const chartExportPresets = [
  { id: "original", label: "Original" },
  { id: "slide", label: "Slide", width: 920, height: 480, description: "Wide image for a slide" },
  { id: "document", label: "Document", width: 720, height: 400, description: "Image within a text column", adaptiveHeight: true },
  { id: "slack", label: "Slack", width: 640, height: 400, description: "Compact image for a message", adaptiveHeight: true },
  { id: "custom", label: "Custom" },
];

export function chartExportSize(width, height) {
  const bounded = (value, fallback, minimum) => Number.isFinite(Number(value))
    ? Math.max(minimum, Math.min(2400, Math.round(Number(value)))) : fallback;
  return { width: bounded(width, 1000, 320), height: bounded(height, 600, 240) };
}

export function chartExportControls(spec) {
  return {
    values: !spec.presentation && !["waterfall", "funnel", "sparkline", "heatmap", "boxPlot", "rankedList"].includes(spec.type),
    axes: !spec.presentation && !["pie", "funnel", "sparkline", "rankedList", "sankey"].includes(spec.type),
  };
}

export function chartExportSpec(spec, options) {
  const controls = chartExportControls(spec);
  return { ...spec,
    ...(spec.presentation ? { barOptions: { ...spec.barOptions, valueWidth: spec.barOptions?.valueWidth ?? 64 } } : {}),
    ...(controls.values ? { showValues: options.values } : {}),
    ...(controls.axes ? { showXAxisLabel: options.axes && spec.showXAxisLabel !== false,
      showYAxisLabel: options.axes && spec.showYAxisLabel !== false } : {}),
  };
}

export function chartExportMetadata(query, filters = []) {
  const source = reviewedSource(query?.source);
  const names = [...new Set([
    source.label !== "Reviewed query" ? source.label : "",
    ...source.tables,
    ...source.files.map(file => file.label.split(/[\\/]/u).at(-1)),
    ...source.links.map(link => typeof link === "string" ? new URL(link).hostname : link.label ?? new URL(link.href).hostname),
  ].filter(Boolean))];
  const captured = Number.isFinite(Date.parse(source.executedAt))
    ? `Captured ${formatDateRange(new Date(source.executedAt).toISOString().slice(0, 10))}` : "";
  return {
    source: [names.length ? `Source: ${names.join(" · ")}` : "", captured].filter(Boolean).join(" · "),
    filters: filters.filter(filter => filter.value !== "all" && filter.value != null)
      .map(filter => `${filter.label ?? filter.field}: ${Array.isArray(filter.value)
        ? filter.value.map(formatDateRange).join(", ") : formatDateRange(filter.value)}`).join(" · "),
  };
}

export function chartExportProvenance(query, filters = []) {
  return Object.values(chartExportMetadata(query, filters)).filter(Boolean).join(" · ");
}

export function chartExportFilename(title) {
  return `${String(title).normalize("NFKC").replace(/[^\p{L}\p{N}._ -]/gu, "")
    .trim().replace(/\s+/gu, "-").slice(0, 100) || "chart"}.png`;
}

export function chartExportAppearance(themeId, appearance, currentScheme = "light") {
  if (!["light", "dark"].includes(appearance)) return currentScheme === "dark" ? {} : { "--surface": "#fff" };
  const theme = dataAppThemes.find(theme => theme.id === themeId && theme.tokens && !(theme.darkOnly && appearance === "light"))
    ?? dataAppThemes.find(theme => theme.id === "codex-classic");
  const palette = dataAppThemePalette(theme, appearance);
  return { colorScheme: appearance,
    ...Object.fromEntries(dataAppThemeTokens.map((token, index) => [`--${token}`, palette[index]])),
    ...(appearance === "light" ? { "--surface": "#fff" } : {}),
    "--lightningcss-light": appearance === "light" ? "initial" : " ",
    "--lightningcss-dark": appearance === "dark" ? "initial" : " ",
  };
}

SHA-256: f3eecf07f28799612c5e5b246e0cbd83d20850eac66599d29a72df7db92a69a3