mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Chart theming: custom named themes + Theme Builder
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
CURRENT_THEME_VERSION,
|
||||
THEME_FONT_OPTIONS,
|
||||
applyFontToConfig,
|
||||
createCustomTheme,
|
||||
} from './custom-theme';
|
||||
import { THEME_PREVIEW_SPECS } from './theme-preview-specs';
|
||||
|
||||
describe('createCustomTheme', () => {
|
||||
it('stamps version, timestamps, and carries the config through', () => {
|
||||
const now = new Date('2026-06-12T10:00:00Z');
|
||||
const theme = createCustomTheme({ name: 'Brand', config: { font: 'Georgia' }, now });
|
||||
expect(theme.version).toBe(CURRENT_THEME_VERSION);
|
||||
expect(theme.name).toBe('Brand');
|
||||
expect(theme.config).toEqual({ font: 'Georgia' });
|
||||
expect(theme.created).toBe(now.toISOString());
|
||||
expect(theme.modified).toBe(now.toISOString());
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyFontToConfig', () => {
|
||||
it('sets the top-level font on an empty config', () => {
|
||||
expect(applyFontToConfig({}, 'Georgia, serif')).toEqual({ font: 'Georgia, serif' });
|
||||
});
|
||||
|
||||
it('rewrites every explicit font slot at any depth', () => {
|
||||
const config = {
|
||||
font: 'Helvetica',
|
||||
title: { font: 'Helvetica', subtitleFont: 'Helvetica', fontSize: 16 },
|
||||
axis: { labelFont: 'Helvetica', titleFont: 'Helvetica', labelFontSize: 11 },
|
||||
axisX: { labelFont: 'Helvetica' },
|
||||
legend: { labelFont: 'Helvetica', titleFont: 'Helvetica' },
|
||||
header: { labelFont: 'Helvetica', titleFont: 'Helvetica' },
|
||||
text: { font: 'Helvetica' },
|
||||
};
|
||||
const out = applyFontToConfig(config, 'Georgia');
|
||||
expect(out).toEqual({
|
||||
font: 'Georgia',
|
||||
title: { font: 'Georgia', subtitleFont: 'Georgia', fontSize: 16 },
|
||||
axis: { labelFont: 'Georgia', titleFont: 'Georgia', labelFontSize: 11 },
|
||||
axisX: { labelFont: 'Georgia' },
|
||||
legend: { labelFont: 'Georgia', titleFont: 'Georgia' },
|
||||
header: { labelFont: 'Georgia', titleFont: 'Georgia' },
|
||||
text: { font: 'Georgia' },
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves non-font properties (including fontSize/fontWeight) untouched', () => {
|
||||
const config = {
|
||||
background: '#fff',
|
||||
title: { fontSize: 16, fontWeight: 600 },
|
||||
range: { category: ['#111', '#222'] },
|
||||
};
|
||||
expect(applyFontToConfig(config, 'Georgia')).toEqual({ ...config, font: 'Georgia' });
|
||||
});
|
||||
|
||||
it('does not mutate the input', () => {
|
||||
const config = { title: { font: 'Helvetica' } };
|
||||
applyFontToConfig(config, 'Georgia');
|
||||
expect(config).toEqual({ title: { font: 'Helvetica' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('THEME_FONT_OPTIONS', () => {
|
||||
it('offers distinct, non-empty CSS stacks', () => {
|
||||
expect(THEME_FONT_OPTIONS.length).toBeGreaterThanOrEqual(4);
|
||||
const values = THEME_FONT_OPTIONS.map((f) => f.value);
|
||||
expect(new Set(values).size).toBe(values.length);
|
||||
expect(values.every((v) => v.trim().length > 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('THEME_PREVIEW_SPECS', () => {
|
||||
it('every gallery card is a self-contained inline-data spec', () => {
|
||||
expect(THEME_PREVIEW_SPECS.length).toBeGreaterThanOrEqual(6);
|
||||
for (const card of THEME_PREVIEW_SPECS) {
|
||||
expect(card.id).toBeTruthy();
|
||||
expect(card.caption).toBeTruthy();
|
||||
const data = card.spec.data as { values?: unknown[] };
|
||||
expect(Array.isArray(data.values)).toBe(true);
|
||||
expect(data.values!.length).toBeGreaterThan(0);
|
||||
expect(card.spec.$schema).toContain('vega-lite');
|
||||
}
|
||||
});
|
||||
|
||||
it('card ids are unique', () => {
|
||||
const ids = THEME_PREVIEW_SPECS.map((c) => c.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
});
|
||||
@@ -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' },
|
||||
];
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Theme Builder gallery specs (docs/chart-theming-scope.md §4.4).
|
||||
*
|
||||
* A fixed set of small, self-contained Vega-Lite specs the Theme Builder
|
||||
* renders side by side with the draft config, so an edit is previewed across
|
||||
* every chart surface a config styles: titles and subtitles, axes and grids,
|
||||
* categorical and gradient legends, facet headers, and the major mark types.
|
||||
* Inline data only, compact fixed sizes — these are swatches, not analyses.
|
||||
*/
|
||||
|
||||
import type { JsonObject } from './spec-config';
|
||||
|
||||
const SCHEMA = 'https://vega.github.io/schema/vega-lite/v5.json';
|
||||
|
||||
export interface ThemePreviewSpec {
|
||||
/** Stable key for React lists and test assertions. */
|
||||
id: string;
|
||||
/** What surface this card exercises (shown as the card caption). */
|
||||
caption: string;
|
||||
spec: JsonObject;
|
||||
}
|
||||
|
||||
const bar: ThemePreviewSpec = {
|
||||
id: 'bar',
|
||||
caption: 'Bar — title, axes, grid',
|
||||
spec: {
|
||||
$schema: SCHEMA,
|
||||
title: 'Revenue by region',
|
||||
width: 200,
|
||||
height: 140,
|
||||
data: {
|
||||
values: [
|
||||
{ region: 'North', revenue: 42 },
|
||||
{ region: 'South', revenue: 61 },
|
||||
{ region: 'East', revenue: 28 },
|
||||
{ region: 'West', revenue: 55 },
|
||||
{ region: 'Central', revenue: 47 },
|
||||
],
|
||||
},
|
||||
mark: 'bar',
|
||||
encoding: {
|
||||
x: { field: 'region', type: 'nominal', axis: { labelAngle: 0 } },
|
||||
y: { field: 'revenue', type: 'quantitative' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const line: ThemePreviewSpec = {
|
||||
id: 'line',
|
||||
caption: 'Line — subtitle, series legend',
|
||||
spec: {
|
||||
$schema: SCHEMA,
|
||||
title: { text: 'Signups over time', subtitle: 'Weekly, by plan' },
|
||||
width: 200,
|
||||
height: 140,
|
||||
data: {
|
||||
values: [
|
||||
{ week: 1, plan: 'Free', n: 20 },
|
||||
{ week: 2, plan: 'Free', n: 28 },
|
||||
{ week: 3, plan: 'Free', n: 26 },
|
||||
{ week: 4, plan: 'Free', n: 34 },
|
||||
{ week: 1, plan: 'Pro', n: 8 },
|
||||
{ week: 2, plan: 'Pro', n: 11 },
|
||||
{ week: 3, plan: 'Pro', n: 17 },
|
||||
{ week: 4, plan: 'Pro', n: 21 },
|
||||
{ week: 1, plan: 'Team', n: 3 },
|
||||
{ week: 2, plan: 'Team', n: 4 },
|
||||
{ week: 3, plan: 'Team', n: 9 },
|
||||
{ week: 4, plan: 'Team', n: 12 },
|
||||
],
|
||||
},
|
||||
mark: { type: 'line', point: true },
|
||||
encoding: {
|
||||
x: { field: 'week', type: 'quantitative', axis: { tickCount: 4 } },
|
||||
y: { field: 'n', type: 'quantitative' },
|
||||
color: { field: 'plan', type: 'nominal' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const area: ThemePreviewSpec = {
|
||||
id: 'area',
|
||||
caption: 'Stacked area — categorical palette',
|
||||
spec: {
|
||||
$schema: SCHEMA,
|
||||
width: 200,
|
||||
height: 140,
|
||||
data: {
|
||||
values: [
|
||||
{ q: 1, channel: 'Web', v: 30 },
|
||||
{ q: 2, channel: 'Web', v: 36 },
|
||||
{ q: 3, channel: 'Web', v: 41 },
|
||||
{ q: 4, channel: 'Web', v: 38 },
|
||||
{ q: 1, channel: 'Store', v: 22 },
|
||||
{ q: 2, channel: 'Store', v: 19 },
|
||||
{ q: 3, channel: 'Store', v: 24 },
|
||||
{ q: 4, channel: 'Store', v: 27 },
|
||||
{ q: 1, channel: 'Partner', v: 12 },
|
||||
{ q: 2, channel: 'Partner', v: 16 },
|
||||
{ q: 3, channel: 'Partner', v: 14 },
|
||||
{ q: 4, channel: 'Partner', v: 18 },
|
||||
],
|
||||
},
|
||||
mark: 'area',
|
||||
encoding: {
|
||||
x: { field: 'q', type: 'quantitative', axis: { tickCount: 4 } },
|
||||
y: { field: 'v', type: 'quantitative' },
|
||||
color: { field: 'channel', type: 'nominal' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const scatter: ThemePreviewSpec = {
|
||||
id: 'scatter',
|
||||
caption: 'Scatter — gradient legend',
|
||||
spec: {
|
||||
$schema: SCHEMA,
|
||||
width: 200,
|
||||
height: 140,
|
||||
data: {
|
||||
values: [
|
||||
{ x: 4, y: 7, z: 12 },
|
||||
{ x: 8, y: 3, z: 31 },
|
||||
{ x: 12, y: 11, z: 45 },
|
||||
{ x: 16, y: 6, z: 22 },
|
||||
{ x: 20, y: 14, z: 60 },
|
||||
{ x: 24, y: 9, z: 38 },
|
||||
{ x: 28, y: 17, z: 74 },
|
||||
{ x: 32, y: 12, z: 51 },
|
||||
{ x: 36, y: 20, z: 88 },
|
||||
],
|
||||
},
|
||||
mark: { type: 'point', filled: true, size: 80 },
|
||||
encoding: {
|
||||
x: { field: 'x', type: 'quantitative' },
|
||||
y: { field: 'y', type: 'quantitative' },
|
||||
color: { field: 'z', type: 'quantitative' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const heatmap: ThemePreviewSpec = {
|
||||
id: 'heatmap',
|
||||
caption: 'Heatmap — sequential scale',
|
||||
spec: {
|
||||
$schema: SCHEMA,
|
||||
width: 200,
|
||||
height: 140,
|
||||
data: {
|
||||
values: ['Mon', 'Tue', 'Wed', 'Thu'].flatMap((day, d) =>
|
||||
['AM', 'Noon', 'PM'].map((slot, s) => ({ day, slot, v: (d + 1) * (s + 2) * 3 })),
|
||||
),
|
||||
},
|
||||
mark: 'rect',
|
||||
encoding: {
|
||||
x: { field: 'day', type: 'nominal', axis: { labelAngle: 0 } },
|
||||
y: { field: 'slot', type: 'nominal' },
|
||||
color: { field: 'v', type: 'quantitative' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const donut: ThemePreviewSpec = {
|
||||
id: 'donut',
|
||||
caption: 'Donut — palette, symbol legend',
|
||||
spec: {
|
||||
$schema: SCHEMA,
|
||||
width: 200,
|
||||
height: 140,
|
||||
data: {
|
||||
values: [
|
||||
{ browser: 'Firefox', share: 32 },
|
||||
{ browser: 'Chrome', share: 41 },
|
||||
{ browser: 'Safari', share: 18 },
|
||||
{ browser: 'Other', share: 9 },
|
||||
],
|
||||
},
|
||||
mark: { type: 'arc', innerRadius: 32 },
|
||||
encoding: {
|
||||
theta: { field: 'share', type: 'quantitative' },
|
||||
color: { field: 'browser', type: 'nominal' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const facet: ThemePreviewSpec = {
|
||||
id: 'facet',
|
||||
caption: 'Facets — header labels',
|
||||
spec: {
|
||||
$schema: SCHEMA,
|
||||
width: 70,
|
||||
height: 110,
|
||||
data: {
|
||||
values: [
|
||||
{ team: 'Alpha', month: 'Jan', v: 14 },
|
||||
{ team: 'Alpha', month: 'Feb', v: 21 },
|
||||
{ team: 'Alpha', month: 'Mar', v: 17 },
|
||||
{ team: 'Beta', month: 'Jan', v: 9 },
|
||||
{ team: 'Beta', month: 'Feb', v: 13 },
|
||||
{ team: 'Beta', month: 'Mar', v: 19 },
|
||||
{ team: 'Gamma', month: 'Jan', v: 11 },
|
||||
{ team: 'Gamma', month: 'Feb', v: 8 },
|
||||
{ team: 'Gamma', month: 'Mar', v: 15 },
|
||||
],
|
||||
},
|
||||
mark: 'bar',
|
||||
encoding: {
|
||||
x: { field: 'month', type: 'nominal', axis: { labelAngle: 0 } },
|
||||
y: { field: 'v', type: 'quantitative' },
|
||||
column: { field: 'team', type: 'nominal' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** The gallery, in display order. */
|
||||
export const THEME_PREVIEW_SPECS: ReadonlyArray<ThemePreviewSpec> = [
|
||||
bar,
|
||||
line,
|
||||
area,
|
||||
scatter,
|
||||
heatmap,
|
||||
donut,
|
||||
facet,
|
||||
];
|
||||
@@ -3,7 +3,11 @@ import {
|
||||
CHART_THEME_OPTIONS,
|
||||
chartConfigFor,
|
||||
chartConfigForSelection,
|
||||
chartThemeOptions,
|
||||
customThemeIdOf,
|
||||
customThemeSelection,
|
||||
isChartThemeId,
|
||||
isChartThemeSelection,
|
||||
darkBaseConfig,
|
||||
darkChartConfig,
|
||||
darkExpressiveConfig,
|
||||
@@ -123,3 +127,51 @@ describe('isChartThemeId', () => {
|
||||
expect(isChartThemeId(7)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom theme selections', () => {
|
||||
const themes = [
|
||||
{ id: 3, name: 'Brand', config: { font: 'Georgia', background: '#fff8f0' } },
|
||||
{ id: 9, name: 'Mono', config: { font: 'Courier' } },
|
||||
];
|
||||
|
||||
it('round-trips an id through the selection string', () => {
|
||||
expect(customThemeSelection(3)).toBe('custom:3');
|
||||
expect(customThemeIdOf('custom:3')).toBe(3);
|
||||
});
|
||||
|
||||
it('customThemeIdOf rejects non-custom and malformed values', () => {
|
||||
expect(customThemeIdOf('astrolabe')).toBeNull();
|
||||
expect(customThemeIdOf('custom:')).toBeNull();
|
||||
expect(customThemeIdOf('custom:abc')).toBeNull();
|
||||
expect(customThemeIdOf('custom:1.5')).toBeNull();
|
||||
});
|
||||
|
||||
it('isChartThemeSelection accepts built-ins, presets, and custom ids', () => {
|
||||
expect(isChartThemeSelection('astrolabe')).toBe(true);
|
||||
expect(isChartThemeSelection('fivethirtyeight')).toBe(true);
|
||||
expect(isChartThemeSelection('custom:42')).toBe(true);
|
||||
expect(isChartThemeSelection('custom:nope')).toBe(false);
|
||||
expect(isChartThemeSelection('comic-sans')).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves a custom selection to its saved config', () => {
|
||||
expect(chartConfigForSelection('custom:3', 'light', themes)).toBe(themes[0].config);
|
||||
});
|
||||
|
||||
it('falls back to the house config when the record is missing (deleted / not hydrated)', () => {
|
||||
expect(chartConfigForSelection('custom:404', 'light', themes)).toBe(lightChartConfig);
|
||||
expect(chartConfigForSelection('custom:404', 'dark', themes)).toBe(darkChartConfig);
|
||||
});
|
||||
|
||||
it('lists custom themes after the built-ins and before the presets', () => {
|
||||
const options = chartThemeOptions(themes);
|
||||
const values = options.map((o) => o.value);
|
||||
expect(values.slice(0, 4)).toEqual(['astrolabe', 'stock', 'custom:3', 'custom:9']);
|
||||
expect(values.length).toBe(CHART_THEME_OPTIONS.length + themes.length);
|
||||
expect(options[2].label).toBe('Brand');
|
||||
});
|
||||
|
||||
it('lists no custom entries when the library has none', () => {
|
||||
expect(chartThemeOptions([])).toEqual([...CHART_THEME_OPTIONS]);
|
||||
});
|
||||
});
|
||||
|
||||
+63
-4
@@ -28,6 +28,7 @@ import type { Config } from 'vega-lite';
|
||||
// Preset chart styles from the vega-themes package (already in the dependency
|
||||
// tree via vega-embed). Pure data — config objects only — so portable for core.
|
||||
import * as presets from 'vega-themes';
|
||||
import type { CustomTheme } from './custom-theme';
|
||||
import type { UiTheme } from './theme';
|
||||
|
||||
const PLEX = '"IBM Plex Sans", system-ui, -apple-system, sans-serif';
|
||||
@@ -186,7 +187,7 @@ export type ChartThemePresetId = (typeof PRESET_IDS)[number];
|
||||
const STOCK_CONFIG: Config = {};
|
||||
|
||||
export interface ChartThemeOption {
|
||||
value: ChartThemeId;
|
||||
value: ChartThemeSelection;
|
||||
label: string;
|
||||
/** Secondary line for pickers (what the choice means). */
|
||||
detail?: string;
|
||||
@@ -219,13 +220,71 @@ export function isChartThemeId(value: unknown): value is ChartThemeId {
|
||||
return typeof value === 'string' && CHART_THEME_IDS.has(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* A saved custom theme as a selection — `custom:<record id>`. The numeric id
|
||||
* (not the name) keys the selection so a rename never invalidates it.
|
||||
*/
|
||||
export type CustomThemeSelection = `custom:${number}`;
|
||||
|
||||
/** Everything the chart-theme picker can hold: built-ins, presets, or a custom theme. */
|
||||
export type ChartThemeSelection = ChartThemeId | CustomThemeSelection;
|
||||
|
||||
/** The picker/persistence id for a custom theme record. */
|
||||
export function customThemeSelection(id: number): CustomThemeSelection {
|
||||
return `custom:${id}`;
|
||||
}
|
||||
|
||||
/** The record id inside a `custom:<id>` selection, or null for any other value. */
|
||||
export function customThemeIdOf(selection: string): number | null {
|
||||
const match = /^custom:(\d+)$/.exec(selection);
|
||||
return match ? Number(match[1]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for persisted selections (load-with-fallback). A `custom:<id>`
|
||||
* passes on shape alone — whether the record still exists is only knowable
|
||||
* after the async theme hydration, so resolution (not validation) handles a
|
||||
* deleted id by falling back to the house config.
|
||||
*/
|
||||
export function isChartThemeSelection(value: unknown): value is ChartThemeSelection {
|
||||
return isChartThemeId(value) || (typeof value === 'string' && customThemeIdOf(value) !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The full picker option list: built-ins, the user's saved themes (by name, in
|
||||
* library order), then the presets. Pure derivation — callers memoize.
|
||||
*/
|
||||
export function chartThemeOptions(
|
||||
customThemes: ReadonlyArray<Pick<CustomTheme, 'id' | 'name'>>,
|
||||
): ChartThemeOption[] {
|
||||
const custom: ChartThemeOption[] = customThemes.map((t) => ({
|
||||
value: customThemeSelection(t.id),
|
||||
label: t.name,
|
||||
detail: 'Custom theme',
|
||||
}));
|
||||
// Built-ins first, the user's own themes next, the preset roster last.
|
||||
return [...CHART_THEME_OPTIONS.slice(0, 2), ...custom, ...CHART_THEME_OPTIONS.slice(2)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the user's chart-theme selection to the config to inject at embed
|
||||
* time. `'astrolabe'` follows the UI theme; presets ignore it (their look is
|
||||
* fixed — that's the point of previewing a destination style).
|
||||
* fixed — that's the point of previewing a destination style). A `custom:<id>`
|
||||
* resolves to that saved theme's config; an id with no record (not yet
|
||||
* hydrated, or deleted elsewhere) falls back to the house config rather than
|
||||
* rendering unstyled.
|
||||
*/
|
||||
export function chartConfigForSelection(selection: ChartThemeId, uiTheme: UiTheme): Config {
|
||||
export function chartConfigForSelection(
|
||||
selection: ChartThemeSelection,
|
||||
uiTheme: UiTheme,
|
||||
customThemes: ReadonlyArray<Pick<CustomTheme, 'id' | 'config'>> = [],
|
||||
): Config {
|
||||
const customId = customThemeIdOf(selection);
|
||||
if (customId !== null) {
|
||||
const theme = customThemes.find((t) => t.id === customId);
|
||||
return theme ? theme.config : CHART_CONFIG[uiTheme];
|
||||
}
|
||||
if (selection === 'astrolabe') return CHART_CONFIG[uiTheme];
|
||||
if (selection === 'stock') return STOCK_CONFIG;
|
||||
return presets[selection] as Config;
|
||||
return presets[selection as ChartThemePresetId] as Config;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user