Theme Builder: structured color/type controls, scheme catalog, diverging preview

This commit is contained in:
2026-06-14 01:25:44 +03:00
parent 0470389b41
commit e4d1466ab0
17 changed files with 1607 additions and 52 deletions
+16
View File
@@ -88,4 +88,20 @@ describe('THEME_PREVIEW_SPECS', () => {
const ids = THEME_PREVIEW_SPECS.map((c) => c.id);
expect(new Set(ids).size).toBe(ids.length);
});
it('exercises every color family the builder controls', () => {
type ColorDef = { type?: string; scale?: { domainMid?: unknown } };
const colorOf = (spec: (typeof THEME_PREVIEW_SPECS)[number]['spec']): ColorDef | undefined =>
(spec.encoding as { color?: ColorDef } | undefined)?.color;
const colors = THEME_PREVIEW_SPECS.map((c) => colorOf(c.spec));
// mark.color (a card with no color encoding), categorical, sequential
// (quantitative, no midpoint), and diverging (quantitative with a midpoint).
expect(colors.some((c) => c === undefined)).toBe(true);
expect(colors.some((c) => c?.type === 'nominal')).toBe(true);
expect(colors.some((c) => c?.type === 'quantitative' && c.scale?.domainMid === undefined)).toBe(
true,
);
expect(colors.some((c) => c?.scale?.domainMid !== undefined)).toBe(true);
});
});
+123
View File
@@ -0,0 +1,123 @@
import { describe, expect, it } from 'vitest';
import {
THEME_SCHEMES,
getConfigValue,
schemeColors,
schemesByKind,
setConfigValue,
} from './theme-controls';
describe('getConfigValue', () => {
const config = { range: { category: ['#111', '#222'] }, mark: { color: '#333' } };
it('reads a nested value', () => {
expect(getConfigValue(config, ['range', 'category'])).toEqual(['#111', '#222']);
expect(getConfigValue(config, ['mark', 'color'])).toBe('#333');
});
it('returns undefined for a missing key', () => {
expect(getConfigValue(config, ['legend', 'orient'])).toBeUndefined();
});
it('returns undefined when a segment is not an object', () => {
expect(getConfigValue(config, ['mark', 'color', 'deeper'])).toBeUndefined();
});
});
describe('setConfigValue', () => {
it('sets a leaf and preserves siblings', () => {
const config = { background: 'transparent', font: 'Inter' };
expect(setConfigValue(config, ['font'], 'Georgia')).toEqual({
background: 'transparent',
font: 'Georgia',
});
});
it('creates intermediate objects when setting deep', () => {
expect(setConfigValue({}, ['range', 'category'], ['#111'])).toEqual({
range: { category: ['#111'] },
});
});
it('preserves unknown sibling keys at the edited level (Vega-layer keys survive)', () => {
const config = { range: { category: ['#111'], symbol: { size: 60 } } };
expect(setConfigValue(config, ['range', 'category'], ['#aaa', '#bbb'])).toEqual({
range: { category: ['#aaa', '#bbb'], symbol: { size: 60 } },
});
});
it('deletes a leaf when the value is undefined', () => {
const config = { background: 'white', font: 'Inter' };
expect(setConfigValue(config, ['font'], undefined)).toEqual({ background: 'white' });
});
it('prunes an ancestor object the deletion empties', () => {
const config = { range: { category: ['#111'] }, font: 'Inter' };
expect(setConfigValue(config, ['range', 'category'], undefined)).toEqual({ font: 'Inter' });
});
it('keeps an ancestor that still holds sibling keys after a deletion', () => {
const config = { range: { category: ['#111'], symbol: { size: 60 } } };
expect(setConfigValue(config, ['range', 'category'], undefined)).toEqual({
range: { symbol: { size: 60 } },
});
});
it('is a no-op deleting an absent key', () => {
const config = { font: 'Inter' };
expect(setConfigValue(config, ['range', 'category'], undefined)).toEqual({ font: 'Inter' });
});
it('does not mutate the input', () => {
const config = { range: { category: ['#111'] } };
const snapshot = structuredClone(config);
setConfigValue(config, ['range', 'category'], ['#999']);
setConfigValue(config, ['range', 'category'], undefined);
expect(config).toEqual(snapshot);
});
it('returns the config unchanged for an empty path', () => {
const config = { font: 'Inter' };
expect(setConfigValue(config, [], 'x')).toBe(config);
});
});
describe('THEME_SCHEMES catalog', () => {
it('every catalog scheme resolves to a non-empty palette (guards registry drift)', () => {
const blank = THEME_SCHEMES.filter((s) => schemeColors(s.name).length === 0);
expect(blank.map((s) => s.name)).toEqual([]);
});
it('has no duplicate names', () => {
const names = THEME_SCHEMES.map((s) => s.name);
expect(new Set(names).size).toBe(names.length);
});
it('groups by kind in catalog order', () => {
expect(schemesByKind('categorical').every((s) => s.kind === 'categorical')).toBe(true);
expect(schemesByKind('sequential').length).toBeGreaterThan(0);
expect(schemesByKind('diverging').map((s) => s.name)).toContain('spectral');
});
});
describe('schemeColors', () => {
it('returns the full fixed palette for a categorical scheme (count ignored)', () => {
const colors = schemeColors('tableau10', 3);
expect(colors).toHaveLength(10);
expect(colors.every((c) => /^#[0-9a-f]{6}$/i.test(c))).toBe(true);
});
it('samples a continuous scheme at count stops, normalized to hex', () => {
const colors = schemeColors('viridis', 5);
expect(colors).toHaveLength(5);
expect(colors.every((c) => /^#[0-9a-f]{6}$/.test(c))).toBe(true);
});
it('samples the midpoint for a single continuous stop', () => {
expect(schemeColors('viridis', 1)).toHaveLength(1);
});
it('returns [] for an unknown scheme', () => {
expect(schemeColors('not-a-scheme')).toEqual([]);
});
});
+186
View File
@@ -0,0 +1,186 @@
/**
* Theme Builder structured-control primitives (docs/chart-theming-scope.md §5).
*
* Pure core: the read/write transforms the builder's structured controls run on
* a draft config, plus the named-color-scheme catalog and its resolution. Three
* concerns:
*
* - **Path get/set** — read a value at a nested config path, and set one back
* immutably while preserving every sibling key at each level. That guarantee
* is the point: the vega-themes presets carry Vega-_layer_ keys (`symbol`,
* `shape`, `path`, `group`) that are absent from the Vega-Lite `Config`
* schema but forwarded to Vega unchanged, so they must survive a control's
* edit rather than be dropped by a rebuild-from-schema. Setting `undefined`
* deletes the leaf and prunes objects the deletion empties, so a control
* writes a minimal diff, never a default dump.
* - **Scheme catalog** — the named Vega color schemes the color controls offer,
* grouped by kind (categorical / sequential / diverging).
* - **Scheme resolution** (`schemeColors`) — a scheme name to hex swatches, for
* the picker preview and the "materialize to an editable array" action.
*
* `vega-scale` (a focused vega sub-package, like core's `vega-expression`) owns
* the scheme registry; importing it here keeps the umbrella `vega` out of core.
* The one control transform that predates this module, `applyFontToConfig`,
* stays in custom-theme.ts with the record entity.
*/
import { scheme } from 'vega-scale';
import { isJsonObject, type JsonObject } from './spec-config';
// ── Path get/set ──────────────────────────────────────────────────────────
/** A nested config location, e.g. `['range', 'category']` or `['mark', 'color']`. */
export type ConfigPath = readonly string[];
/** Read the value at `path`, or undefined if any segment is missing/non-object. */
export function getConfigValue(config: JsonObject, path: ConfigPath): unknown {
let cur: unknown = config;
for (const key of path) {
if (!isJsonObject(cur)) return undefined;
cur = cur[key];
}
return cur;
}
/**
* Immutably set `value` at `path`. Siblings at every level are preserved (the
* key-preservation guarantee §5 rests on). `value === undefined` deletes the
* leaf and prunes any ancestor object the deletion leaves empty. Intermediate
* objects are created as needed; a non-object value blocking the path is
* replaced with a fresh object. Returns a new object; input not mutated. An
* empty path returns the config unchanged.
*/
export function setConfigValue(config: JsonObject, path: ConfigPath, value: unknown): JsonObject {
if (path.length === 0) return config;
const [head, ...rest] = path;
if (rest.length === 0) {
if (value === undefined) return omitKey(config, head);
return { ...config, [head]: value };
}
const child = isJsonObject(config[head]) ? config[head] : {};
const nextChild = setConfigValue(child, rest, value);
// A deletion that empties the child prunes the child too (minimal diff); a
// child still holding sibling keys (e.g. a preset's Vega-layer keys) stays.
if (value === undefined && Object.keys(nextChild).length === 0) {
return omitKey(config, head);
}
return { ...config, [head]: nextChild };
}
/** A copy of `obj` without `key` (returns the same object when the key is absent). */
function omitKey(obj: JsonObject, key: string): JsonObject {
if (!(key in obj)) return obj;
const { [key]: _omit, ...rest } = obj;
return rest;
}
// ── Named color schemes ─────────────────────────────────────────────────────
type SchemeKind = 'categorical' | 'sequential' | 'diverging';
interface ThemeScheme {
/** The Vega scheme id — written verbatim into `range.*` and resolved by Vega. */
name: string;
/** Human label for the picker. */
label: string;
kind: SchemeKind;
}
/**
* The named Vega schemes the color controls offer, in display order within each
* kind. Categorical feeds `range.category`; sequential feeds `range.heatmap` /
* `ramp` / `ordinal`; diverging feeds `range.diverging`. Every name is verified
* present in the installed vega-scale registry by the catalog test, so a
* registry change surfaces as a test failure rather than a blank swatch.
*/
export const THEME_SCHEMES: ReadonlyArray<ThemeScheme> = [
// Categorical
{ name: 'tableau10', label: 'Tableau 10', kind: 'categorical' },
{ name: 'tableau20', label: 'Tableau 20', kind: 'categorical' },
{ name: 'category10', label: 'Category 10', kind: 'categorical' },
{ name: 'category20', label: 'Category 20', kind: 'categorical' },
{ name: 'category20b', label: 'Category 20b', kind: 'categorical' },
{ name: 'category20c', label: 'Category 20c', kind: 'categorical' },
{ name: 'observable10', label: 'Observable 10', kind: 'categorical' },
{ name: 'accent', label: 'Accent', kind: 'categorical' },
{ name: 'dark2', label: 'Dark 2', kind: 'categorical' },
{ name: 'paired', label: 'Paired', kind: 'categorical' },
{ name: 'set1', label: 'Set 1', kind: 'categorical' },
{ name: 'set2', label: 'Set 2', kind: 'categorical' },
{ name: 'set3', label: 'Set 3', kind: 'categorical' },
{ name: 'pastel1', label: 'Pastel 1', kind: 'categorical' },
{ name: 'pastel2', label: 'Pastel 2', kind: 'categorical' },
// Sequential (single- and multi-hue)
{ name: 'viridis', label: 'Viridis', kind: 'sequential' },
{ name: 'magma', label: 'Magma', kind: 'sequential' },
{ name: 'inferno', label: 'Inferno', kind: 'sequential' },
{ name: 'plasma', label: 'Plasma', kind: 'sequential' },
{ name: 'cividis', label: 'Cividis', kind: 'sequential' },
{ name: 'turbo', label: 'Turbo', kind: 'sequential' },
{ name: 'blues', label: 'Blues', kind: 'sequential' },
{ name: 'greens', label: 'Greens', kind: 'sequential' },
{ name: 'greys', label: 'Greys', kind: 'sequential' },
{ name: 'oranges', label: 'Oranges', kind: 'sequential' },
{ name: 'purples', label: 'Purples', kind: 'sequential' },
{ name: 'reds', label: 'Reds', kind: 'sequential' },
{ name: 'bluegreen', label: 'Blue-Green', kind: 'sequential' },
{ name: 'bluepurple', label: 'Blue-Purple', kind: 'sequential' },
{ name: 'greenblue', label: 'Green-Blue', kind: 'sequential' },
{ name: 'orangered', label: 'Orange-Red', kind: 'sequential' },
{ name: 'purpleblue', label: 'Purple-Blue', kind: 'sequential' },
{ name: 'purplebluegreen', label: 'Purple-Blue-Green', kind: 'sequential' },
{ name: 'purplered', label: 'Purple-Red', kind: 'sequential' },
{ name: 'redpurple', label: 'Red-Purple', kind: 'sequential' },
{ name: 'yellowgreen', label: 'Yellow-Green', kind: 'sequential' },
{ name: 'yellowgreenblue', label: 'Yellow-Green-Blue', kind: 'sequential' },
{ name: 'yelloworangebrown', label: 'Yellow-Orange-Brown', kind: 'sequential' },
{ name: 'yelloworangered', label: 'Yellow-Orange-Red', kind: 'sequential' },
// Diverging
{ name: 'blueorange', label: 'Blue-Orange', kind: 'diverging' },
{ name: 'brownbluegreen', label: 'Brown-Blue-Green', kind: 'diverging' },
{ name: 'purplegreen', label: 'Purple-Green', kind: 'diverging' },
{ name: 'pinkyellowgreen', label: 'Pink-Yellow-Green', kind: 'diverging' },
{ name: 'purpleorange', label: 'Purple-Orange', kind: 'diverging' },
{ name: 'redblue', label: 'Red-Blue', kind: 'diverging' },
{ name: 'redgrey', label: 'Red-Grey', kind: 'diverging' },
{ name: 'redyellowblue', label: 'Red-Yellow-Blue', kind: 'diverging' },
{ name: 'redyellowgreen', label: 'Red-Yellow-Green', kind: 'diverging' },
{ name: 'spectral', label: 'Spectral', kind: 'diverging' },
];
/** The schemes of one kind, in catalog order. */
export function schemesByKind(kind: SchemeKind): ThemeScheme[] {
return THEME_SCHEMES.filter((s) => s.kind === kind);
}
// ── Scheme resolution ───────────────────────────────────────────────────────
/**
* Resolve a named Vega scheme to hex swatches. A categorical scheme returns its
* fixed palette in full; a continuous scheme (sequential/diverging) is sampled
* at `count` evenly-spaced stops (`count` applies to continuous schemes only).
* Continuous interpolators yield `rgb(...)`, normalized to hex here. An unknown
* name returns `[]` — the picker shows that scheme without a preview rather than
* throwing. Used for the swatch/gradient preview and "materialize to swatches".
*/
export function schemeColors(name: string, count = 9): string[] {
const resolved: unknown = scheme(name);
if (Array.isArray(resolved)) return resolved.map((c) => toHex(String(c)));
if (typeof resolved === 'function' && count > 0) {
const interp = resolved as (t: number) => string;
if (count === 1) return [toHex(interp(0.5))];
return Array.from({ length: count }, (_, i) => toHex(interp(i / (count - 1))));
}
return [];
}
/** Normalize a CSS color to `#rrggbb`; passes through existing hex and unknowns. */
function toHex(color: string): string {
if (color.startsWith('#')) return color;
const m = /rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i.exec(color);
if (!m) return color;
const h = (n: string) => Math.round(Number(n)).toString(16).padStart(2, '0');
return `#${h(m[1])}${h(m[2])}${h(m[3])}`;
}
+42 -4
View File
@@ -4,13 +4,16 @@
* 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.
* facet headers, the major mark types, and — so every color control has a
* mirror — each color family: categorical (`range.category`), sequential
* (`range.heatmap` for rect, `range.ramp` for a continuous legend), and
* diverging (`range.diverging`, selected by a quantitative color scale with a
* `domainMid`). Inline data only, compact fixed sizes — swatches, not analyses.
*/
import type { JsonObject } from './spec-config';
const SCHEMA = 'https://vega.github.io/schema/vega-lite/v5.json';
const SCHEMA = 'https://vega.github.io/schema/vega-lite/v6.json';
export interface ThemePreviewSpec {
/** Stable key for React lists and test assertions. */
@@ -160,6 +163,39 @@ const heatmap: ThemePreviewSpec = {
},
};
const diverging: ThemePreviewSpec = {
id: 'diverging',
caption: 'Diverging — scale around a midpoint',
spec: {
$schema: SCHEMA,
title: 'Net change by topic',
width: 200,
height: 140,
data: {
values: [
{ topic: 'Cost', delta: -8 },
{ topic: 'Speed', delta: -3 },
{ topic: 'Help', delta: 1 },
{ topic: 'Look', delta: 6 },
{ topic: 'Value', delta: 11 },
],
},
mark: 'bar',
encoding: {
x: { field: 'topic', type: 'nominal', axis: { labelAngle: 0 } },
y: { field: 'delta', type: 'quantitative' },
// `domainMid` makes this a diverging color scale, so it reads from
// `range.diverging` rather than `range.ramp`/`heatmap` (the others above).
color: {
field: 'delta',
type: 'quantitative',
scale: { domainMid: 0 },
legend: { title: null },
},
},
},
};
const donut: ThemePreviewSpec = {
id: 'donut',
caption: 'Donut — palette, symbol legend',
@@ -212,13 +248,15 @@ const facet: ThemePreviewSpec = {
},
};
/** The gallery, in display order. */
/** The gallery, in display order — the three continuous-color examples
* (scatter → ramp, heatmap → heatmap, diverging → diverging) sit together. */
export const THEME_PREVIEW_SPECS: ReadonlyArray<ThemePreviewSpec> = [
bar,
line,
area,
scatter,
heatmap,
diverging,
donut,
facet,
];