mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
146 lines
5.8 KiB
TypeScript
146 lines
5.8 KiB
TypeScript
/**
|
|
* 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<ThemeFontOption> = [
|
|
// 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<string> {
|
|
const out = new Set<string>();
|
|
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;
|
|
}
|