mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Chart theming: selectable chart theme + spec↔config merge/extract
This commit is contained in:
@@ -20,7 +20,7 @@ describe('defaultSettings', () => {
|
||||
tabSize: 2,
|
||||
},
|
||||
performance: { renderDebounce: 1500 },
|
||||
ui: { theme: 'light', previewFitMode: 'default' },
|
||||
ui: { theme: 'light', previewFitMode: 'default', chartTheme: 'astrolabe' },
|
||||
formatting: { dateFormat: 'smart', customDateFormat: '' },
|
||||
});
|
||||
});
|
||||
@@ -64,7 +64,7 @@ describe('loadSettings — valid records', () => {
|
||||
tabSize: 4,
|
||||
},
|
||||
performance: { renderDebounce: 2500 },
|
||||
ui: { theme: 'dark', previewFitMode: 'full' },
|
||||
ui: { theme: 'dark', previewFitMode: 'full', chartTheme: 'fivethirtyeight' },
|
||||
formatting: { dateFormat: 'custom', customDateFormat: 'yyyy-MM-dd' },
|
||||
};
|
||||
expect(loadSettings(record)).toEqual(record);
|
||||
@@ -160,6 +160,7 @@ describe('loadSettings — enum validation', () => {
|
||||
expect(loadSettings({ ui: { previewFitMode: 'tall' } }).ui.previewFitMode).toBe(
|
||||
d.ui.previewFitMode,
|
||||
);
|
||||
expect(loadSettings({ ui: { chartTheme: 'comic-sans' } }).ui.chartTheme).toBe(d.ui.chartTheme);
|
||||
expect(loadSettings({ formatting: { dateFormat: 'relative' } }).formatting.dateFormat).toBe(
|
||||
d.formatting.dateFormat,
|
||||
);
|
||||
@@ -171,6 +172,8 @@ describe('loadSettings — enum validation', () => {
|
||||
expect(loadSettings({ ui: { theme: 'dark' } }).ui.theme).toBe('dark');
|
||||
expect(loadSettings({ ui: { previewFitMode: 'width' } }).ui.previewFitMode).toBe('width');
|
||||
expect(loadSettings({ ui: { previewFitMode: 'height' } }).ui.previewFitMode).toBe('height');
|
||||
expect(loadSettings({ ui: { chartTheme: 'stock' } }).ui.chartTheme).toBe('stock');
|
||||
expect(loadSettings({ ui: { chartTheme: 'latimes' } }).ui.chartTheme).toBe('latimes');
|
||||
expect(loadSettings({ formatting: { dateFormat: 'iso' } }).formatting.dateFormat).toBe('iso');
|
||||
});
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
* guarantees that (the read-time migration, mirroring `migrateSnippet`).
|
||||
*/
|
||||
|
||||
import { isChartThemeId, type ChartThemeId } from './vega-themes';
|
||||
|
||||
/** Current schema version for a UserSettings record (read-time migration target). */
|
||||
export const CURRENT_SETTINGS_VERSION = 1;
|
||||
|
||||
@@ -57,6 +59,13 @@ export interface UserSettings {
|
||||
* Preview_). Default `'default'`.
|
||||
*/
|
||||
previewFitMode: 'default' | 'width' | 'height' | 'full';
|
||||
/**
|
||||
* Chart theme — which config is injected when charts render (spec §04;
|
||||
* docs/chart-theming-scope.md §4.2). `'astrolabe'` (default) is the house
|
||||
* style following the UI theme; `'stock'` injects nothing; other ids are
|
||||
* vega-themes presets. Set by the preview's settings cluster.
|
||||
*/
|
||||
chartTheme: ChartThemeId;
|
||||
};
|
||||
/** Date-rendering preferences (spec §07 → Formatting). */
|
||||
formatting: {
|
||||
@@ -97,6 +106,7 @@ export function defaultSettings(): UserSettings {
|
||||
ui: {
|
||||
theme: 'light',
|
||||
previewFitMode: 'default',
|
||||
chartTheme: 'astrolabe',
|
||||
},
|
||||
formatting: {
|
||||
dateFormat: 'smart',
|
||||
@@ -194,6 +204,7 @@ export function loadSettings(raw: unknown): UserSettings {
|
||||
['default', 'width', 'height', 'full'] as const,
|
||||
d.ui.previewFitMode,
|
||||
),
|
||||
chartTheme: isChartThemeId(ui.chartTheme) ? ui.chartTheme : d.ui.chartTheme,
|
||||
},
|
||||
formatting: {
|
||||
dateFormat: asEnum(
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { extractConfigFromSpec, isJsonObject, mergeConfigIntoSpec } from './spec-config';
|
||||
|
||||
describe('mergeConfigIntoSpec', () => {
|
||||
const theme = {
|
||||
background: 'transparent',
|
||||
font: 'IBM Plex Sans',
|
||||
axis: { labelColor: '#525252', gridDash: [2, 2] },
|
||||
};
|
||||
|
||||
it('adds the config block to a spec without one', () => {
|
||||
const spec = { mark: 'bar', data: { values: [] } };
|
||||
const out = mergeConfigIntoSpec(spec, theme);
|
||||
expect(out.config).toEqual(theme);
|
||||
expect(out.mark).toBe('bar');
|
||||
expect(spec).not.toHaveProperty('config'); // input untouched
|
||||
});
|
||||
|
||||
it('the spec’s existing config wins key-by-key, deep', () => {
|
||||
const spec = {
|
||||
mark: 'bar',
|
||||
config: { font: 'Georgia', axis: { labelColor: 'red' } },
|
||||
};
|
||||
const out = mergeConfigIntoSpec(spec, theme);
|
||||
expect(out.config).toEqual({
|
||||
background: 'transparent', // from the theme
|
||||
font: 'Georgia', // spec wins
|
||||
axis: { labelColor: 'red', gridDash: [2, 2] }, // merged: spec wins inside
|
||||
});
|
||||
});
|
||||
|
||||
it('arrays are replaced, not merged', () => {
|
||||
const spec = { config: { axis: { gridDash: [8] } } };
|
||||
const out = mergeConfigIntoSpec(spec, theme) as { config: { axis: { gridDash: number[] } } };
|
||||
expect(out.config.axis.gridDash).toEqual([8]);
|
||||
});
|
||||
|
||||
it('an empty config into a config-less spec stays config-less', () => {
|
||||
expect(mergeConfigIntoSpec({ mark: 'bar' }, {})).toEqual({ mark: 'bar' });
|
||||
});
|
||||
|
||||
it('a non-object spec config is replaced by the merge', () => {
|
||||
const out = mergeConfigIntoSpec({ config: 'junk' }, theme);
|
||||
expect(out.config).toEqual(theme);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractConfigFromSpec', () => {
|
||||
it('removes and returns the config block', () => {
|
||||
const spec = { mark: 'bar', config: { font: 'Georgia' } };
|
||||
const out = extractConfigFromSpec(spec);
|
||||
expect(out.config).toEqual({ font: 'Georgia' });
|
||||
expect(out.spec).toEqual({ mark: 'bar' });
|
||||
expect(spec).toHaveProperty('config'); // input untouched
|
||||
});
|
||||
|
||||
it('returns null config when the spec has none', () => {
|
||||
const out = extractConfigFromSpec({ mark: 'bar' });
|
||||
expect(out.config).toBeNull();
|
||||
expect(out.spec).toEqual({ mark: 'bar' });
|
||||
});
|
||||
|
||||
it('an empty or non-object config extracts as null but is still removed', () => {
|
||||
expect(extractConfigFromSpec({ mark: 'bar', config: {} })).toEqual({
|
||||
spec: { mark: 'bar' },
|
||||
config: null,
|
||||
});
|
||||
expect(extractConfigFromSpec({ mark: 'bar', config: 7 })).toEqual({
|
||||
spec: { mark: 'bar' },
|
||||
config: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isJsonObject', () => {
|
||||
it('accepts plain objects only', () => {
|
||||
expect(isJsonObject({})).toBe(true);
|
||||
expect(isJsonObject([])).toBe(false);
|
||||
expect(isJsonObject(null)).toBe(false);
|
||||
expect(isJsonObject('x')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Spec ↔ config operations (docs/chart-theming-scope.md §4.3).
|
||||
*
|
||||
* The two halves of making the injected chart theme portable, mirroring the
|
||||
* Vega editor's "Merge Config Into Spec" / "Extract Config From Spec" pair:
|
||||
*
|
||||
* - **Merge** bakes a config into the spec's own `config` block — for
|
||||
* publishing a snippet somewhere the app's theme won't follow it. The spec's
|
||||
* existing `config` wins on conflicts, matching the render-time precedence
|
||||
* (vega-lite layers `spec.config` over the injected config), so baking never
|
||||
* changes how the chart looks.
|
||||
* - **Extract** lifts the `config` block out of a spec — for cleaning styling
|
||||
* out of a pasted-in spec (the caller decides where the extracted config
|
||||
* goes: clipboard today, a saved theme later).
|
||||
*
|
||||
* Pure object-in/object-out; JSON text handling (parse, format, undo) is the
|
||||
* editor integration's job.
|
||||
*/
|
||||
|
||||
/** A parsed JSON object (the only spec shape these operations accept). */
|
||||
export type JsonObject = Record<string, unknown>;
|
||||
|
||||
/** Is the value a plain JSON object (not an array, not null)? */
|
||||
export function isJsonObject(value: unknown): value is JsonObject {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-merge `upper` over `lower`: plain objects merge recursively, everything
|
||||
* else (arrays, scalars) is replaced by the upper value. The same shape of
|
||||
* merge vega-lite applies between the embed-time config and `spec.config`.
|
||||
*/
|
||||
function deepMerge(lower: JsonObject, upper: JsonObject): JsonObject {
|
||||
const out: JsonObject = { ...lower };
|
||||
for (const [key, upperValue] of Object.entries(upper)) {
|
||||
const lowerValue = out[key];
|
||||
out[key] =
|
||||
isJsonObject(lowerValue) && isJsonObject(upperValue)
|
||||
? deepMerge(lowerValue, upperValue)
|
||||
: upperValue;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bake `config` into the spec's `config` block. The spec's existing `config`
|
||||
* takes precedence key-by-key (deep), so the rendered result is unchanged —
|
||||
* the theme just travels with the spec now. Returns a new object; the input
|
||||
* is not mutated. An empty merge result still writes `config: {}` only when
|
||||
* the spec already had one; baking an empty config into a config-less spec is
|
||||
* a no-op.
|
||||
*/
|
||||
export function mergeConfigIntoSpec(spec: JsonObject, config: JsonObject): JsonObject {
|
||||
const specConfig = isJsonObject(spec.config) ? spec.config : {};
|
||||
const merged = deepMerge(config, specConfig);
|
||||
if (Object.keys(merged).length === 0 && !('config' in spec)) return { ...spec };
|
||||
return { ...spec, config: merged };
|
||||
}
|
||||
|
||||
/** Result of `extractConfigFromSpec`. */
|
||||
export interface ExtractedConfig {
|
||||
/** The spec without its `config` block (new object; input not mutated). */
|
||||
spec: JsonObject;
|
||||
/** The removed `config`, or null when the spec had none worth extracting. */
|
||||
config: JsonObject | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the spec's `config` block and hand it back separately. A missing,
|
||||
* empty, or non-object `config` extracts as `null` (an empty/junk block is
|
||||
* still removed from the spec — there is nothing to keep).
|
||||
*/
|
||||
export function extractConfigFromSpec(spec: JsonObject): ExtractedConfig {
|
||||
if (!('config' in spec)) return { spec: { ...spec }, config: null };
|
||||
const { config, ...rest } = spec;
|
||||
const extracted = isJsonObject(config) && Object.keys(config).length > 0 ? config : null;
|
||||
return { spec: rest, config: extracted };
|
||||
}
|
||||
@@ -1,5 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { chartConfigFor, darkChartConfig, lightChartConfig } from './vega-themes';
|
||||
import {
|
||||
CHART_THEME_OPTIONS,
|
||||
chartConfigFor,
|
||||
chartConfigForSelection,
|
||||
isChartThemeId,
|
||||
darkBaseConfig,
|
||||
darkChartConfig,
|
||||
darkExpressiveConfig,
|
||||
lightBaseConfig,
|
||||
lightChartConfig,
|
||||
lightExpressiveConfig,
|
||||
mergeChartLayers,
|
||||
} from './vega-themes';
|
||||
import type { UiTheme } from './theme';
|
||||
|
||||
/**
|
||||
@@ -37,3 +49,77 @@ describe('chartConfigFor', () => {
|
||||
expect(lightChartConfig.range?.category).not.toEqual(darkChartConfig.range?.category);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The layer split (docs/chart-theming-scope.md §1): base carries only the
|
||||
* legibility minimum (background + guide colors); everything brand-flavored
|
||||
* (font, palette, grid dash, sizes/weights, view stroke) lives in expressive.
|
||||
* A future stock/custom chart style keeps base and swaps expressive.
|
||||
*/
|
||||
describe('chart config layers', () => {
|
||||
const layers = [
|
||||
{ base: lightBaseConfig, expressive: lightExpressiveConfig, full: lightChartConfig },
|
||||
{ base: darkBaseConfig, expressive: darkExpressiveConfig, full: darkChartConfig },
|
||||
];
|
||||
|
||||
it.each(layers)('base stays free of house style', ({ base }) => {
|
||||
expect(base.font).toBeUndefined();
|
||||
expect(base.range).toBeUndefined();
|
||||
expect(base.view).toBeUndefined();
|
||||
expect(base.axis?.gridDash).toBeUndefined();
|
||||
expect(base.title).not.toHaveProperty('fontSize');
|
||||
});
|
||||
|
||||
it.each(layers)('expressive stays free of legibility colors', ({ expressive }) => {
|
||||
expect(expressive.background).toBeUndefined();
|
||||
expect(expressive.axis?.labelColor).toBeUndefined();
|
||||
expect(expressive.title).not.toHaveProperty('color');
|
||||
});
|
||||
|
||||
it.each(layers)('layers merge into the full theme config', ({ base, expressive, full }) => {
|
||||
expect(mergeChartLayers(base, expressive)).toEqual(full);
|
||||
});
|
||||
|
||||
it('merges the nested title and axis groups instead of replacing them', () => {
|
||||
const merged = mergeChartLayers(lightBaseConfig, lightExpressiveConfig);
|
||||
// One property from each layer survives in the same nested object.
|
||||
expect(merged.title).toMatchObject({ color: '#161616', fontSize: 16 });
|
||||
expect(merged.axis).toMatchObject({ labelColor: '#525252', gridDash: [2, 2] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('chartConfigForSelection', () => {
|
||||
it('astrolabe follows the UI theme', () => {
|
||||
expect(chartConfigForSelection('astrolabe', 'light')).toBe(lightChartConfig);
|
||||
expect(chartConfigForSelection('astrolabe', 'dark')).toBe(darkChartConfig);
|
||||
});
|
||||
|
||||
it('stock injects nothing (vega-lite defaults apply)', () => {
|
||||
expect(chartConfigForSelection('stock', 'light')).toEqual({});
|
||||
expect(chartConfigForSelection('stock', 'dark')).toEqual({});
|
||||
});
|
||||
|
||||
it('presets resolve to a non-empty config independent of UI theme', () => {
|
||||
const light = chartConfigForSelection('fivethirtyeight', 'light');
|
||||
expect(Object.keys(light).length).toBeGreaterThan(0);
|
||||
expect(chartConfigForSelection('fivethirtyeight', 'dark')).toBe(light);
|
||||
});
|
||||
|
||||
it('every option id resolves to a config', () => {
|
||||
for (const { value } of CHART_THEME_OPTIONS) {
|
||||
expect(chartConfigForSelection(value, 'light')).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isChartThemeId', () => {
|
||||
it('accepts every option id', () => {
|
||||
for (const { value } of CHART_THEME_OPTIONS) expect(isChartThemeId(value)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects unknown and non-string values', () => {
|
||||
expect(isChartThemeId('comic-sans')).toBe(false);
|
||||
expect(isChartThemeId(undefined)).toBe(false);
|
||||
expect(isChartThemeId(7)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+149
-21
@@ -5,17 +5,29 @@
|
||||
* visually belong to the app rather than looking like stock Vega-Lite. This is
|
||||
* the single source of truth mapping a `UiTheme` to a config; it is applied at
|
||||
* embed time (never baked into the user's stored spec). Adding a UI theme = one
|
||||
* config object plus one map entry here.
|
||||
* base + expressive pair plus one map entry here.
|
||||
*
|
||||
* Values track the design language: IBM Plex font, axis/grid colors from the
|
||||
* Carbon neutral ramp (matching `--text-secondary` / `--border`), and a
|
||||
* categorical `range.category` palette transcribed from Carbon's data-viz
|
||||
* 14-color pairing (white theme for light, g100 for dark — see
|
||||
* carbon-charts `packages/core/scss/_color-palette.scss`). This is the
|
||||
* expressive "free color" layer (doc §3.5, §5).
|
||||
* Each theme's config is two layers (docs/chart-theming-scope.md §1):
|
||||
*
|
||||
* - **Base** — the legibility/integration minimum: transparent background (the
|
||||
* pane color shows through) and guide colors readable on the app's surfaces.
|
||||
* Without this layer, stock black-on-white chart text is illegible on the
|
||||
* dark pane. Colors match the app tokens (`--text`, `--text-secondary`,
|
||||
* `--border`, `--border-strong`).
|
||||
* - **Expressive** — the house style: IBM Plex, the Carbon data-viz categorical
|
||||
* palette (white theme for light, g100 for dark — see carbon-charts
|
||||
* `packages/core/scss/_color-palette.scss`), dotted grid, bumped guide
|
||||
* sizes/weights, no plot border. This is the "free color" layer (doc §3.5,
|
||||
* §5); charts render fine without it, just stock-looking.
|
||||
*
|
||||
* The split exists so a non-house chart style (stock preview, future custom
|
||||
* themes) can keep the base layer while replacing the expressive one.
|
||||
*/
|
||||
|
||||
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 { UiTheme } from './theme';
|
||||
|
||||
const PLEX = '"IBM Plex Sans", system-ui, -apple-system, sans-serif';
|
||||
@@ -56,42 +68,74 @@ const darkCategory = [
|
||||
'#d4bbff', // purple 30
|
||||
];
|
||||
|
||||
export const lightChartConfig: Config = {
|
||||
/** Base layer — light: transparent background + guide colors on app tokens. */
|
||||
export const lightBaseConfig: Config = {
|
||||
background: 'transparent',
|
||||
font: PLEX,
|
||||
title: { fontSize: 16, fontWeight: 600, color: '#161616' },
|
||||
title: { color: '#161616' }, // --text (light)
|
||||
axis: {
|
||||
domainColor: '#c6c6c6', // --border-strong (light)
|
||||
gridColor: '#e0e0e0', // --border (light)
|
||||
gridDash: [2, 2],
|
||||
labelColor: '#525252', // --text-secondary (light)
|
||||
titleColor: '#161616', // --text (light)
|
||||
labelFontSize: 11,
|
||||
titleFontSize: 12,
|
||||
titleFontWeight: 600,
|
||||
},
|
||||
range: { category: lightCategory },
|
||||
view: { stroke: 'transparent' },
|
||||
};
|
||||
|
||||
export const darkChartConfig: Config = {
|
||||
/** Base layer — dark: transparent background + guide colors on app tokens. */
|
||||
export const darkBaseConfig: Config = {
|
||||
background: 'transparent',
|
||||
font: PLEX,
|
||||
title: { fontSize: 16, fontWeight: 600, color: '#f4f4f4' },
|
||||
title: { color: '#f4f4f4' }, // --text (dark)
|
||||
axis: {
|
||||
domainColor: '#525252', // --border-strong (dark)
|
||||
gridColor: '#393939', // --border (dark)
|
||||
gridDash: [2, 2],
|
||||
labelColor: '#a8a8a8', // --text-secondary (dark)
|
||||
titleColor: '#f4f4f4', // --text (dark)
|
||||
},
|
||||
};
|
||||
|
||||
/** Expressive layer parts shared by both themes (everything but the palette). */
|
||||
const sharedExpressive: Config = {
|
||||
font: PLEX,
|
||||
title: { fontSize: 16, fontWeight: 600 },
|
||||
axis: {
|
||||
gridDash: [2, 2],
|
||||
labelFontSize: 11,
|
||||
titleFontSize: 12,
|
||||
titleFontWeight: 600,
|
||||
},
|
||||
range: { category: darkCategory },
|
||||
view: { stroke: 'transparent' },
|
||||
};
|
||||
|
||||
/** Expressive layer — light: house style + the light categorical palette. */
|
||||
export const lightExpressiveConfig: Config = {
|
||||
...sharedExpressive,
|
||||
range: { category: lightCategory },
|
||||
};
|
||||
|
||||
/** Expressive layer — dark: house style + the dark categorical palette. */
|
||||
export const darkExpressiveConfig: Config = {
|
||||
...sharedExpressive,
|
||||
range: { category: darkCategory },
|
||||
};
|
||||
|
||||
/**
|
||||
* Merge a base and an expressive layer into one chart config. Shallow spread
|
||||
* plus the two nested objects both layers contribute to (`title`, `axis`);
|
||||
* the expressive layer wins on conflicts (there are none today — the layers
|
||||
* own disjoint properties).
|
||||
*/
|
||||
export function mergeChartLayers(base: Config, expressive: Config): Config {
|
||||
return {
|
||||
...base,
|
||||
...expressive,
|
||||
title: { ...base.title, ...expressive.title },
|
||||
axis: { ...base.axis, ...expressive.axis },
|
||||
};
|
||||
}
|
||||
|
||||
export const lightChartConfig: Config = mergeChartLayers(lightBaseConfig, lightExpressiveConfig);
|
||||
|
||||
export const darkChartConfig: Config = mergeChartLayers(darkBaseConfig, darkExpressiveConfig);
|
||||
|
||||
const CHART_CONFIG: Record<UiTheme, Config> = {
|
||||
light: lightChartConfig,
|
||||
dark: darkChartConfig,
|
||||
@@ -101,3 +145,87 @@ const CHART_CONFIG: Record<UiTheme, Config> = {
|
||||
export function chartConfigFor(theme: UiTheme): Config {
|
||||
return CHART_CONFIG[theme];
|
||||
}
|
||||
|
||||
/**
|
||||
* Selectable chart themes (docs/chart-theming-scope.md §4.2) — the user-facing
|
||||
* choice of how charts render, distinct from (and composed with) the UI theme:
|
||||
*
|
||||
* - `'astrolabe'` — the house style above; resolves per UI theme. Default.
|
||||
* - `'stock'` — no injected config at all: charts render exactly as Vega-Lite
|
||||
* defaults would anywhere else (white background, tableau10, sans-serif).
|
||||
* - a `vega-themes` preset id — that preset's config verbatim, UI-theme
|
||||
* independent, exactly as it would render in the Vega editor's theme dropdown.
|
||||
*
|
||||
* The spec's own `config` overrides whatever is selected, property by property
|
||||
* (vega-lite merges `opt.config` under `spec.config`), so a snippet can always
|
||||
* opt out locally.
|
||||
*/
|
||||
export type ChartThemeId = 'astrolabe' | 'stock' | ChartThemePresetId;
|
||||
|
||||
/** The vega-themes presets we surface, in display order. */
|
||||
const PRESET_IDS = [
|
||||
'excel',
|
||||
'ggplot2',
|
||||
'quartz',
|
||||
'vox',
|
||||
'fivethirtyeight',
|
||||
'latimes',
|
||||
'urbaninstitute',
|
||||
'googlecharts',
|
||||
'powerbi',
|
||||
'carbonwhite',
|
||||
'carbong10',
|
||||
'carbong90',
|
||||
'carbong100',
|
||||
'dark',
|
||||
] as const;
|
||||
|
||||
export type ChartThemePresetId = (typeof PRESET_IDS)[number];
|
||||
|
||||
/** Empty config — the stock sentinel resolves to "inject nothing". */
|
||||
const STOCK_CONFIG: Config = {};
|
||||
|
||||
export interface ChartThemeOption {
|
||||
value: ChartThemeId;
|
||||
label: string;
|
||||
/** Secondary line for pickers (what the choice means). */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/** Display metadata for every selectable chart theme, in display order. */
|
||||
export const CHART_THEME_OPTIONS: ReadonlyArray<ChartThemeOption> = [
|
||||
{ value: 'astrolabe', label: 'Astrolabe', detail: 'House style, follows light/dark' },
|
||||
{ value: 'stock', label: 'Stock Vega-Lite', detail: 'No theme applied' },
|
||||
{ value: 'excel', label: 'Excel' },
|
||||
{ value: 'ggplot2', label: 'ggplot2' },
|
||||
{ value: 'quartz', label: 'Quartz' },
|
||||
{ value: 'vox', label: 'Vox' },
|
||||
{ value: 'fivethirtyeight', label: 'FiveThirtyEight' },
|
||||
{ value: 'latimes', label: 'LA Times' },
|
||||
{ value: 'urbaninstitute', label: 'Urban Institute' },
|
||||
{ value: 'googlecharts', label: 'Google Charts' },
|
||||
{ value: 'powerbi', label: 'Power BI' },
|
||||
{ value: 'carbonwhite', label: 'Carbon — White' },
|
||||
{ value: 'carbong10', label: 'Carbon — G10' },
|
||||
{ value: 'carbong90', label: 'Carbon — G90' },
|
||||
{ value: 'carbong100', label: 'Carbon — G100' },
|
||||
{ value: 'dark', label: 'Vega Dark' },
|
||||
];
|
||||
|
||||
const CHART_THEME_IDS: ReadonlySet<string> = new Set(['astrolabe', 'stock', ...PRESET_IDS]);
|
||||
|
||||
/** Type guard for persisted values (load-with-fallback; unknown ids fall back). */
|
||||
export function isChartThemeId(value: unknown): value is ChartThemeId {
|
||||
return typeof value === 'string' && CHART_THEME_IDS.has(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
export function chartConfigForSelection(selection: ChartThemeId, uiTheme: UiTheme): Config {
|
||||
if (selection === 'astrolabe') return CHART_CONFIG[uiTheme];
|
||||
if (selection === 'stock') return STOCK_CONFIG;
|
||||
return presets[selection] as Config;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user