/** * Custom chart theme — a user-named Vega-Lite config saved in the library * (docs/chart-theming-scope.md §4.4; spec §04 → Chart theme). * * Portable core: record shape, factory, and the pure config transforms the * Theme Builder runs. A custom theme is "whatever config the user saved" — it * is injected at embed time exactly like a preset (vega-embed `opt.config`), * so a snippet's own `config` still overrides it property by property. */ import { isJsonObject, type JsonObject } from './spec-config'; /** Current schema version for a CustomTheme record (read-time migration target). */ export const CURRENT_THEME_VERSION = 1; export interface CustomTheme { /** Unique numeric identifier (IndexedDB key). */ id: number; /** Record schema version, for read-time migration. */ version: number; /** Unique, human-readable name shown in the chart-theme picker. */ name: string; /** The Vega-Lite config injected when this theme is selected. */ config: JsonObject; /** ISO timestamp — when first created. */ created: string; /** ISO timestamp — when last changed. */ modified: string; } /** * Build a new CustomTheme. The default id is provisional — the store's id * authority reassigns it on insert (same contract as `createDataset`). */ export function createCustomTheme(opts: { name: string; config: JsonObject; now?: Date; }): CustomTheme { const iso = (opts.now ?? new Date()).toISOString(); return { id: Date.now(), version: CURRENT_THEME_VERSION, name: opts.name, config: opts.config, created: iso, modified: iso, }; } /** * Apply one font family across a config: sets the top-level `font` (Vega-Lite's * default for every text mark, label, and title) AND rewrites every explicit * font slot already present anywhere in the config — `font`, `labelFont`, * `titleFont`, `subtitleFont`, … at any nesting depth (`axis`, `axisX`, * `legend`, `header`, `title`, mark configs). The explicit slots must be * rewritten because they would otherwise keep overriding the new top-level * default — this is exactly the "populate the font in many places" job the * Theme Builder's font control does. Returns a new object; input not mutated. */ export function applyFontToConfig(config: JsonObject, family: string): JsonObject { const walk = (obj: JsonObject): JsonObject => { const out: JsonObject = {}; for (const [key, value] of Object.entries(obj)) { if ((key === 'font' || key.endsWith('Font')) && typeof value === 'string') { out[key] = family; } else if (isJsonObject(value)) { out[key] = walk(value); } else { out[key] = value; } } return out; }; return { ...walk(config), font: family }; } /** A font choice the Theme Builder's font control offers. */ export interface ThemeFontOption { /** The CSS family stack written into the config. */ value: string; /** Display name. */ label: string; } /** * Fonts the builder can apply: the self-hosted roster (scope doc §3 — chart * fonts, registered in styles/chart-fonts.css) followed by web-safe/system * stacks that need no loading. The roster faces require their woff2 to be loaded * before a chart measures text, which the render path's font gate handles * (`collectFontFamilies` + `document.fonts.load`); the system stacks resolve to * locally installed faces. Order is by role so the in-face dropdown reads as a * specimen. Every primary family pairs with a category-appropriate fallback. */ export const THEME_FONT_OPTIONS: ReadonlyArray = [ // Sans { value: '"IBM Plex Sans", system-ui, -apple-system, sans-serif', label: 'IBM Plex Sans' }, { value: '"Inter", system-ui, sans-serif', label: 'Inter' }, { value: '"Libre Franklin", system-ui, sans-serif', label: 'Libre Franklin' }, { value: '"Roboto Condensed", system-ui, sans-serif', label: 'Roboto Condensed' }, { value: '"IBM Plex Sans Condensed", system-ui, sans-serif', label: 'IBM Plex Sans Condensed' }, // Serif { value: '"IBM Plex Serif", Georgia, serif', label: 'IBM Plex Serif' }, { value: '"Source Serif 4", Georgia, serif', label: 'Source Serif 4' }, { value: '"Spectral", Georgia, serif', label: 'Spectral' }, // Mono { value: '"IBM Plex Mono", ui-monospace, monospace', label: 'IBM Plex Mono' }, { value: '"Space Mono", ui-monospace, monospace', label: 'Space Mono' }, // Display { value: '"Space Grotesk", system-ui, sans-serif', label: 'Space Grotesk' }, { value: '"Playfair Display", Georgia, serif', label: 'Playfair Display' }, // Handwriting { value: '"Caveat", cursive', label: 'Caveat' }, // System / web-safe (no load) { value: 'system-ui, -apple-system, sans-serif', label: 'System UI' }, { value: 'Helvetica, Arial, sans-serif', label: 'Helvetica / Arial' }, { value: 'Georgia, "Times New Roman", serif', label: 'Georgia' }, { value: '"Courier New", Courier, monospace', label: 'Courier' }, ]; /** * Collect every font family stack referenced under a `font` or `*Font` key * anywhere in `value` (a Vega-Lite spec or config) — the read-counterpart of * `applyFontToConfig`'s write. The render path uses it to know which faces to * `document.fonts.load` before measuring text. `data`/`datasets` are skipped: * they hold dataset rows (potentially huge, never fonts), so walking them is * wasted work. Returns the distinct stacks, in no particular order. */ export function collectFontFamilies(value: unknown): Set { const out = new Set(); const walk = (node: unknown): void => { if (Array.isArray(node)) { for (const item of node) walk(item); return; } if (!isJsonObject(node)) return; for (const [key, v] of Object.entries(node)) { if (key === 'data' || key === 'datasets') continue; if ((key === 'font' || key.endsWith('Font')) && typeof v === 'string') out.add(v); else walk(v); } }; walk(value); return out; }