Chart theming: custom named themes + Theme Builder

This commit is contained in:
2026-06-12 18:15:54 +03:00
parent 44a601affd
commit b193464f55
32 changed files with 2220 additions and 70 deletions
+101
View File
@@ -0,0 +1,101 @@
/**
* 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 today: the two self-hosted Plex faces the app
* already loads, plus web-safe/system stacks that need no loading at all. Every
* entry is render-safe without a `document.fonts.load` gate — Plex is loaded by
* the UI before any chart renders, the rest resolve to locally installed faces.
* The self-hosted roster (scope doc §4.5) extends this list and brings the
* pre-render loading gate with it.
*/
export const THEME_FONT_OPTIONS: ReadonlyArray<ThemeFontOption> = [
{ value: '"IBM Plex Sans", system-ui, -apple-system, sans-serif', label: 'IBM Plex Sans' },
{ value: '"IBM Plex Mono", ui-monospace, monospace', label: 'IBM Plex Mono' },
{ 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' },
];