Theme Builder: Layout/Axes/Legend/Type panels, scheme-render fix, fail-loud gallery

This commit is contained in:
2026-06-14 13:08:29 +03:00
parent b03c3b08ab
commit 91b8b1e7fe
19 changed files with 1381 additions and 135 deletions
+61
View File
@@ -1,7 +1,11 @@
import { describe, expect, it } from 'vitest';
import {
THEME_SCHEMES,
asBoolean,
asNumber,
asString,
getConfigValue,
normalizeRangeSchemes,
schemeColors,
schemesByKind,
setConfigValue,
@@ -100,6 +104,63 @@ describe('THEME_SCHEMES catalog', () => {
});
});
describe('normalizeRangeSchemes', () => {
it('wraps a bare scheme string in the { scheme } object form', () => {
expect(normalizeRangeSchemes({ range: { category: 'category20b' } })).toEqual({
range: { category: { scheme: 'category20b' } },
});
});
it('normalizes every range family and preserves siblings', () => {
const out = normalizeRangeSchemes({
background: 'transparent',
range: { category: ['#111'], heatmap: 'viridis', ramp: 'viridis', diverging: 'redblue' },
});
expect(out).toEqual({
background: 'transparent',
range: {
category: ['#111'],
heatmap: { scheme: 'viridis' },
ramp: { scheme: 'viridis' },
diverging: { scheme: 'redblue' },
},
});
});
it('is idempotent and returns the same object when nothing changes', () => {
const config = { range: { category: { scheme: 'category10' }, ordinal: ['#000'] } };
expect(normalizeRangeSchemes(config)).toBe(config);
});
it('is a no-op when there is no range object', () => {
const config = { font: 'Inter' };
expect(normalizeRangeSchemes(config)).toBe(config);
});
});
describe('leaf coercion', () => {
it('asString accepts strings only', () => {
expect(asString('x')).toBe('x');
expect(asString(1)).toBeUndefined();
expect(asString(undefined)).toBeUndefined();
});
it('asNumber accepts finite numbers only', () => {
expect(asNumber(0)).toBe(0);
expect(asNumber(-45)).toBe(-45);
expect(asNumber(NaN)).toBeUndefined();
expect(asNumber(Infinity)).toBeUndefined();
expect(asNumber('11')).toBeUndefined();
});
it('asBoolean accepts booleans only', () => {
expect(asBoolean(false)).toBe(false);
expect(asBoolean(true)).toBe(true);
expect(asBoolean('true')).toBeUndefined();
expect(asBoolean(undefined)).toBeUndefined();
});
});
describe('schemeColors', () => {
it('returns the full fixed palette for a categorical scheme (count ignored)', () => {
const colors = schemeColors('tableau10', 3);
+37
View File
@@ -76,6 +76,43 @@ function omitKey(obj: JsonObject, key: string): JsonObject {
return rest;
}
// ── Range scheme normalization ────────────────────────────────────────────────
/**
* Convert any bare scheme-name string under `config.range.*` to Vega's
* range-scheme object `{ scheme: name }`. A bare string passes vega-lite compile
* but Vega rejects it at render ("Unrecognized scale range value"), silently
* blanking the chart; the object form is the one Vega accepts. Idempotent —
* arrays, existing `{ scheme }` objects, and every other key pass through
* untouched. Applied where a theme config becomes a render config, so a config
* authored, imported, or saved by an earlier build with the bare form still
* renders. Returns the same object when nothing needed changing.
*/
export function normalizeRangeSchemes(config: JsonObject): JsonObject {
const range = config.range;
if (!isJsonObject(range)) return config;
let changed = false;
const out: JsonObject = { ...range };
for (const [family, value] of Object.entries(range)) {
if (typeof value === 'string') {
out[family] = { scheme: value };
changed = true;
}
}
return changed ? { ...config, range: out } : config;
}
// ── Leaf coercion ─────────────────────────────────────────────────────────────
// Read a config leaf back into the type a structured control binds to, or
// `undefined` when it holds something else (a hand-edit, a preset's richer
// form) — the control then shows its default rather than misrendering.
export const asString = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined);
export const asNumber = (v: unknown): number | undefined =>
typeof v === 'number' && Number.isFinite(v) ? v : undefined;
export const asBoolean = (v: unknown): boolean | undefined =>
typeof v === 'boolean' ? v : undefined;
// ── Named color schemes ─────────────────────────────────────────────────────
type SchemeKind = 'categorical' | 'sequential' | 'diverging';
+5 -1
View File
@@ -29,6 +29,7 @@ import type { Config } from 'vega-lite';
// 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 { normalizeRangeSchemes } from './theme-controls';
import type { UiTheme } from './theme';
const PLEX = '"IBM Plex Sans", system-ui, -apple-system, sans-serif';
@@ -298,7 +299,10 @@ export function chartConfigForSelection(
const customId = customThemeIdOf(selection);
if (customId !== null) {
const theme = customThemes.find((t) => t.id === customId);
return theme ? theme.config : CHART_CONFIG[uiTheme];
// Heal a custom config saved/imported with a bare range scheme string, which
// Vega rejects at render (see normalizeRangeSchemes); built-in configs below
// are already well-formed.
return theme ? normalizeRangeSchemes(theme.config) : CHART_CONFIG[uiTheme];
}
if (selection === 'astrolabe') return CHART_CONFIG[uiTheme];
if (selection === 'stock') return STOCK_CONFIG;