mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Theme Builder: structured color/type controls, scheme catalog, diverging preview
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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])}`;
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user