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:
@@ -236,6 +236,30 @@ key-by-key), so a snippet can always override or opt out locally. The
|
||||
that boundary deliberately: merge bakes the selected theme into `spec.config`
|
||||
(spec keys win — rendering unchanged), extract lifts `spec.config` out.
|
||||
|
||||
### Structured controls
|
||||
|
||||
The builder's panels (`ColorControls`; the Type tab's font control) are
|
||||
accelerators over the same `draftConfig`: each reads a value and writes one back
|
||||
through `CustomThemeStore.mutateDraftConfig(fn)` — the single transform path,
|
||||
which reparses, reformats, and updates `draftConfig` so the JSON editor and
|
||||
gallery follow (a parse error disables the controls). The pure transforms live
|
||||
in `core/theme-controls.ts`: immutable config path get/set, the named-scheme
|
||||
catalog (`THEME_SCHEMES`), and `schemeColors` (scheme name → hex swatches, from
|
||||
the `vega-scale` registry — a focused vega sub-package). A color family holds
|
||||
**either** a named scheme string **or** an explicit color array; the picker
|
||||
materializes one to the other. Family by scale: `range.category` (nominal),
|
||||
`range.ramp` (continuous; `range.heatmap` for `rect`), `range.diverging`
|
||||
(continuous color with a `domainMid`).
|
||||
|
||||
- **Do** route structured edits through `mutateDraftConfig` + `setConfigValue`,
|
||||
which sets a value at a path **immutably, preserving sibling keys**, and
|
||||
deletes (pruning emptied ancestors) on `undefined` so a theme stays a diff.
|
||||
- **Don't** rebuild the config from a fixed schema: vega-themes presets carry
|
||||
Vega-_layer_ keys (`symbol`/`shape`/`path`/`group`) absent from the Vega-Lite
|
||||
`Config` schema but forwarded to Vega — a rebuild drops them. Merge in place.
|
||||
- **Do** add a `theme-preview-specs.ts` gallery card for any new color family,
|
||||
so no control ships without a visible mirror.
|
||||
|
||||
### Rules
|
||||
|
||||
- **Do** keep `chartConfigForSelection` as the _only_ place that maps the user's
|
||||
|
||||
@@ -165,7 +165,80 @@ ships, it is an explicit per-font user action, never automatic.
|
||||
**Rejected:** per-snippet theme field (2026-06-12 — `spec.config` + merge/extract covers
|
||||
it without a second mechanism).
|
||||
|
||||
## 5. Status log
|
||||
## 5. Structured controls (slice 4b)
|
||||
|
||||
The builder today is a raw JSON textarea + one font dropdown + the live gallery. Slice 4b
|
||||
adds a strip of structured controls above the editor — accelerators that write into the
|
||||
JSON, never replacing it. The JSON stays the source of truth and the full-power escape
|
||||
hatch; controls cover the common ~80% (color, type, spacing, grid), not all 72 config
|
||||
properties (that is the trap vega-editor deliberately avoids by staying JSON).
|
||||
|
||||
**Hard constraint — the builder must preserve unknown keys.** Verified by compiling: the
|
||||
vega-themes presets carry Vega-_layer_ keys (`symbol`, `shape`, `path`, `group`) that are
|
||||
not in the Vega-Lite `Config` schema, and Vega-Lite forwards the whole config to Vega
|
||||
unchanged — they take effect. So a structured control must **merge into** the existing
|
||||
config (immutable path-set that spreads siblings), never rebuild it from a closed
|
||||
schema-typed model, or it silently drops those keys on a round-trip. Same shape as
|
||||
`applyFontToConfig`, which walks and rewrites rather than reconstructing.
|
||||
|
||||
**Resolved design points:**
|
||||
|
||||
- **Surfacing — inline tab strip** (not popovers, not sub-modals). Tabs (Color / Type /
|
||||
Layout / Axes & grid / Legend) sit between the toolbar and the JSON+gallery, all in the
|
||||
one xlarge modal. No nested overlays/focus traps, no contention with the one-open-popover
|
||||
registry, and panels + JSON + gallery stay visible together.
|
||||
- **Color model — scheme picker that materializes to swatches.** A `range` family takes
|
||||
either an explicit color array or a named Vega scheme string (both verified to compile).
|
||||
Pick a named scheme for the quick path; "materialize" expands it to an editable swatch
|
||||
array for brand tuning. Catalog ships 15 categorical + 24 sequential + 10 diverging
|
||||
schemes; categorical schemes resolve to arrays, continuous ones to interpolators sampled
|
||||
into stops for the gradient preview and the materialize action.
|
||||
- Structured controls are gated on valid JSON (same as the font control): a parse error
|
||||
disables them and the textarea is the fix.
|
||||
- Controls write **minimal** config — clearing a value deletes the key rather than writing
|
||||
a default, so a theme stays a diff against stock, not a full dump.
|
||||
|
||||
**Panels:** Color (`range.category` swatches/scheme, `mark.color`, `range.heatmap`/`ramp`/
|
||||
`diverging`) · Type (base `font`, title/axis/legend size+weight) · Layout (`background` incl.
|
||||
transparent, `padding`, `view.stroke`/`fill`/cornerRadius) · Axes & grid (grid on/off + color
|
||||
|
||||
- dash, domain, label color/angle — base `axis` only; the 25 variants stay JSON) · Legend
|
||||
(orient, label/title color+size, symbol size).
|
||||
|
||||
**Build order:** (a) core foundation — scheme catalog + immutable config path get/set +
|
||||
`schemeColors` materialize, with tests; (b) Color panel (highest payoff); (c) Type, Layout,
|
||||
Axes, Legend panels; (d) wire the tab strip into the modal.
|
||||
|
||||
**Not in slice 4b:** the house style's own gaps — no `mark.color` (single-series charts stay
|
||||
Vega-blue), unset legend/header/padding — are left for a separate house-style redo, not
|
||||
papered over here. Minor cleanup noted: `theme-preview-specs.ts` declares `$schema` v5 while
|
||||
the app standardizes on v6.
|
||||
|
||||
## 6. Status log
|
||||
|
||||
- **2026-06-14 (slice 4b, first increment)** — **structured-control foundation + Color
|
||||
panel.** Core `theme-controls.ts`: immutable config path get/set (preserves siblings —
|
||||
the Vega-layer-key guarantee — and prunes on delete) + the named-scheme catalog (15
|
||||
categorical / 24 sequential / 10 diverging) + `schemeColors` resolution (categorical
|
||||
arrays passthrough, continuous interpolators sampled to hex), all tested. `vega-scale`
|
||||
added as a declared dep (focused sub-package, like `vega-expression`) with a typings
|
||||
shim in `vite-env.d.ts` (its package.json `exports` omits `types`). Store gains the
|
||||
generic `mutateDraftConfig(fn)` write path; `applyDraftFont` refactored onto it. Modal
|
||||
gains an APG tab strip — **Color** (categorical scheme/swatches + materialize, default
|
||||
`mark.color`, sequential/diverging gradient pickers) and **Type** (the relocated font
|
||||
control). Tabpanel gated on valid JSON. From first-use feedback, same day: the modal
|
||||
body is now **controls + JSON on the left, gallery as a full-height right rail** (the
|
||||
previews were starved before); `SelectControl` gained an optional per-option `preview`
|
||||
so the scheme dropdowns show swatch strips (categorical) / gradient bars (continuous);
|
||||
every swatch is a reusable `SwatchRow` (color picker + copyable/editable hex field); and
|
||||
sequential/diverging gained **Materialize → editable stops**, so custom gradient colors
|
||||
are possible, not just named schemes. Second feedback pass: the raw JSON is now a
|
||||
**collapsed disclosure** at the bottom of the controls column (it was eating half the
|
||||
first screen), forced open only on a parse error; the structured controls fill the
|
||||
column. `SelectControl` options gained a `labelStyle`, so the **font dropdown renders
|
||||
each name in its own family** (the type analogue of the color swatches) and its trigger
|
||||
shows the current font in-face. Verified: typecheck, lint, full tests (950).
|
||||
Remaining: Layout / Axes & grid / Legend panels; swatch reorder.
|
||||
|
||||
- **2026-06-12 (slice 4 close-out)** — **custom themes in the §08 envelope.** The
|
||||
workspace export now writes a `themes` array (additive — no format bump; importers
|
||||
|
||||
Generated
+1
@@ -18,6 +18,7 @@
|
||||
"vega-embed": "^7.1.0",
|
||||
"vega-expression": "^6.1.0",
|
||||
"vega-lite": "^6.4.2",
|
||||
"vega-scale": "^8.1.0",
|
||||
"vega-themes": "3.0.0",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"vega-embed": "^7.1.0",
|
||||
"vega-expression": "^6.1.0",
|
||||
"vega-lite": "^6.4.2",
|
||||
"vega-scale": "^8.1.0",
|
||||
"vega-themes": "3.0.0",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/* Theme Builder Color panel — structured controls over the draft config. */
|
||||
|
||||
.panel {
|
||||
display: grid;
|
||||
gap: var(--space-6);
|
||||
padding: var(--space-5);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.group {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.groupTitle {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.caret {
|
||||
color: var(--text-secondary);
|
||||
margin-left: var(--space-3);
|
||||
}
|
||||
|
||||
/* Editable swatch rows (palette colors / gradient stops) wrap into a grid. */
|
||||
.swatches {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3) var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* One swatch: color picker + copyable hex field, with a hover remove. */
|
||||
.swatchUnit {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.hexInput {
|
||||
width: 8ch;
|
||||
height: var(--control-height);
|
||||
padding: 0 var(--space-2);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Native color input, sized to a swatch and stripped of its chrome. */
|
||||
.colorInput {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: var(--border-width) solid var(--border-strong);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
.colorInput::-webkit-color-swatch-wrapper {
|
||||
padding: 2px;
|
||||
}
|
||||
.colorInput::-webkit-color-swatch {
|
||||
border: none;
|
||||
}
|
||||
.colorInput::-moz-color-swatch {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.swatchRemove {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
color: var(--text);
|
||||
background: var(--layer-02);
|
||||
border: var(--border-width) solid var(--border-strong);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity var(--dur-fast) var(--ease);
|
||||
}
|
||||
.swatchUnit:hover .swatchRemove,
|
||||
.swatchRemove:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.gradientPreview {
|
||||
width: 96px;
|
||||
height: 24px;
|
||||
flex: 0 0 auto;
|
||||
border: var(--border-width) solid var(--border-strong);
|
||||
}
|
||||
|
||||
/* Dropdown-option previews: a swatch strip (categorical) / gradient bar. */
|
||||
.optStrip {
|
||||
display: inline-flex;
|
||||
width: 84px;
|
||||
}
|
||||
.optSwatch {
|
||||
flex: 1 1 0;
|
||||
height: 14px;
|
||||
min-width: 4px;
|
||||
}
|
||||
.optGradient {
|
||||
display: inline-block;
|
||||
width: 84px;
|
||||
height: 14px;
|
||||
border: var(--border-width) solid var(--border);
|
||||
}
|
||||
|
||||
/* Mini current-value preview inside a SelectControl trigger. */
|
||||
.triggerPreview {
|
||||
display: inline-flex;
|
||||
margin-right: var(--space-2);
|
||||
}
|
||||
.triggerPreview .optSwatch {
|
||||
width: 6px;
|
||||
flex: 0 0 6px;
|
||||
height: 14px;
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Color panel — behavioural wiring through the live modal. The pure config
|
||||
* transforms are covered in core/theme-controls.test.ts; these confirm the
|
||||
* controls read the draft config and write back through `mutateDraftConfig`
|
||||
* (scheme pick, materialize, swatch add/remove, mark color, the JSON gate).
|
||||
* vega-embed is mocked (the gallery is integration-heavy).
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import type { JsonObject } from '@core/spec-config';
|
||||
import { useCustomThemeStore } from '../stores/CustomThemeStore';
|
||||
import { usePopoverStore } from '../stores/PopoverStore';
|
||||
import { ThemeBuilderModal } from './ThemeBuilderModal';
|
||||
|
||||
vi.mock('../services/chart-renderer', () => ({
|
||||
renderSpec: vi.fn(() =>
|
||||
Promise.resolve({ destroy() {}, resize() {}, toImageURL: () => Promise.resolve('') }),
|
||||
),
|
||||
}));
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
useCustomThemeStore.getState().reset();
|
||||
usePopoverStore.getState().close(); // the open-popover registry is global; isolate tests
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const render = () => act(() => root.render(<ThemeBuilderModal />));
|
||||
|
||||
/** Open a draft seeded with `config` (block body so `act` returns void). */
|
||||
const open = (config: JsonObject) =>
|
||||
act(() => {
|
||||
useCustomThemeStore.getState().createTheme('Brand', config);
|
||||
});
|
||||
|
||||
const draftConfig = () => useCustomThemeStore.getState().draftConfig;
|
||||
const range = () => (draftConfig()?.range ?? {}) as Record<string, unknown>;
|
||||
|
||||
/** Drive an input's value through the native setter so React's value tracker
|
||||
* registers the change and fires onChange (a bare `input.value =` doesn't). */
|
||||
function setNativeValue(el: HTMLInputElement, value: string) {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method -- invoked immediately via .call
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;
|
||||
setter.call(el, value);
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
/** A button anywhere in the document (popovers portal to <body>) by exact label. */
|
||||
const button = (label: string) =>
|
||||
[...document.querySelectorAll('button')].find((b) => b.textContent === label);
|
||||
|
||||
/** The trigger of a SelectControl by its `label`-derived aria-label prefix. */
|
||||
const picker = (labelPrefix: string) =>
|
||||
[...container.querySelectorAll('button')].find((b) =>
|
||||
b.getAttribute('aria-label')?.startsWith(labelPrefix),
|
||||
);
|
||||
|
||||
describe('ColorControls', () => {
|
||||
test('materialize expands a named scheme into an editable array', () => {
|
||||
render();
|
||||
open({ range: { category: 'tableau10' } });
|
||||
|
||||
expect(range().category).toBe('tableau10');
|
||||
act(() => button('Materialize to edit')!.click());
|
||||
|
||||
expect(Array.isArray(range().category)).toBe(true);
|
||||
expect((range().category as string[]).length).toBe(10);
|
||||
});
|
||||
|
||||
test('add and remove palette colors', () => {
|
||||
render();
|
||||
open({ range: { category: ['#111111', '#222222'] } });
|
||||
|
||||
act(() => button('Add color')!.click());
|
||||
expect((range().category as string[]).length).toBe(3);
|
||||
|
||||
const remove = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Remove Palette color 1"]',
|
||||
)!;
|
||||
act(() => remove.click());
|
||||
expect(range().category).toEqual(['#222222', '#888888']);
|
||||
});
|
||||
|
||||
test('removing the last swatch deletes range.category', () => {
|
||||
render();
|
||||
open({ range: { category: ['#111111'] } });
|
||||
|
||||
const remove = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Remove Palette color 1"]',
|
||||
)!;
|
||||
act(() => remove.click());
|
||||
expect('category' in range()).toBe(false);
|
||||
});
|
||||
|
||||
test('setting a default mark color writes mark.color; clear removes it', () => {
|
||||
render();
|
||||
open({});
|
||||
|
||||
const input = container.querySelector<HTMLInputElement>(
|
||||
'input[aria-label="Default mark color"]',
|
||||
)!;
|
||||
act(() => setNativeValue(input, '#ff0000'));
|
||||
expect((draftConfig()?.mark as Record<string, unknown>).color).toBe('#ff0000');
|
||||
|
||||
act(() => button('Clear')!.click());
|
||||
expect(draftConfig()?.mark).toBeUndefined();
|
||||
});
|
||||
|
||||
test('typing in a swatch hex field updates that color', () => {
|
||||
render();
|
||||
open({ range: { category: ['#111111', '#222222'] } });
|
||||
|
||||
const hex = container.querySelector<HTMLInputElement>(
|
||||
'input[aria-label="Palette color 1 hex value"]',
|
||||
)!;
|
||||
act(() => setNativeValue(hex, '#abcdef'));
|
||||
expect((range().category as string[])[0]).toBe('#abcdef');
|
||||
});
|
||||
|
||||
test('a sequential scheme materializes to editable stops written to heatmap and ramp', () => {
|
||||
render();
|
||||
// Seed the other families as arrays so the only "Materialize" button is the
|
||||
// sequential one (categorical/diverging show "Add color"/"Add stop" instead).
|
||||
open({
|
||||
range: {
|
||||
category: ['#111111'],
|
||||
heatmap: 'viridis',
|
||||
ramp: 'viridis',
|
||||
diverging: ['#aa0000', '#0000aa'],
|
||||
},
|
||||
});
|
||||
|
||||
act(() => button('Materialize to edit')!.click());
|
||||
expect(Array.isArray(range().heatmap)).toBe(true);
|
||||
expect(range().ramp).toEqual(range().heatmap);
|
||||
|
||||
const remove = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Remove Sequential stop 1"]',
|
||||
)!;
|
||||
const before = (range().heatmap as string[]).length;
|
||||
act(() => remove.click());
|
||||
expect((range().heatmap as string[]).length).toBe(before - 1);
|
||||
expect(range().ramp).toEqual(range().heatmap);
|
||||
});
|
||||
|
||||
test('scheme dropdown options carry a color preview', () => {
|
||||
render();
|
||||
open({});
|
||||
act(() => picker('Categorical color scheme')!.click());
|
||||
|
||||
const tableau = button('Tableau 10')!;
|
||||
// The decorative swatch strip renders as spans inside the option button.
|
||||
expect(tableau.querySelectorAll('span span').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('picking a scheme writes range.category as a string', () => {
|
||||
render();
|
||||
open({});
|
||||
|
||||
act(() => picker('Categorical color scheme')!.click());
|
||||
act(() => button('Category 10')!.click());
|
||||
|
||||
expect(range().category).toBe('category10');
|
||||
});
|
||||
|
||||
test('a sequential pick sets both heatmap and ramp', () => {
|
||||
render();
|
||||
open({});
|
||||
|
||||
act(() => picker('Sequential color scheme')!.click());
|
||||
act(() => button('Viridis')!.click());
|
||||
|
||||
expect(range().heatmap).toBe('viridis');
|
||||
expect(range().ramp).toBe('viridis');
|
||||
});
|
||||
|
||||
test('invalid JSON disables the controls', () => {
|
||||
render();
|
||||
open({});
|
||||
act(() => useCustomThemeStore.getState().updateDraft({ configText: '{oops' }));
|
||||
|
||||
expect(container.textContent).toContain('Fix the JSON below to use these controls');
|
||||
expect(button('Materialize to edit')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('the Type tab exposes the font-apply control', () => {
|
||||
render();
|
||||
open({});
|
||||
|
||||
const typeTab = [...container.querySelectorAll('button')].find(
|
||||
(b) => b.getAttribute('role') === 'tab' && b.textContent === 'Type',
|
||||
)!;
|
||||
act(() => typeTab.click());
|
||||
expect(picker('Font family')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('font options render their name in their own family', () => {
|
||||
render();
|
||||
open({});
|
||||
|
||||
const typeTab = [...container.querySelectorAll('button')].find(
|
||||
(b) => b.getAttribute('role') === 'tab' && b.textContent === 'Type',
|
||||
)!;
|
||||
act(() => typeTab.click());
|
||||
act(() => picker('Font family')!.click());
|
||||
|
||||
const georgia = button('Georgia')!;
|
||||
expect(georgia.querySelector<HTMLElement>('span')!.style.fontFamily).toContain('Georgia');
|
||||
});
|
||||
|
||||
test('the raw JSON is collapsed by default and toggles open', () => {
|
||||
render();
|
||||
open({});
|
||||
|
||||
expect(container.querySelector('#theme-config')).toBeNull();
|
||||
const toggle = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-controls="theme-config"]',
|
||||
)!;
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false');
|
||||
|
||||
act(() => toggle.click());
|
||||
expect(container.querySelector('#theme-config')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('a parse error forces the JSON open', () => {
|
||||
render();
|
||||
open({});
|
||||
expect(container.querySelector('#theme-config')).toBeNull();
|
||||
|
||||
act(() => useCustomThemeStore.getState().updateDraft({ configText: '{oops' }));
|
||||
expect(container.querySelector('#theme-config')).toBeTruthy();
|
||||
expect(
|
||||
container
|
||||
.querySelector('button[aria-controls="theme-config"]')!
|
||||
.getAttribute('aria-expanded'),
|
||||
).toBe('true');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* Theme Builder — Color panel (docs/chart-theming-scope.md §5).
|
||||
*
|
||||
* Structured controls over the draft config's color surface: the categorical
|
||||
* palette (`range.category`), the default single-series mark color
|
||||
* (`mark.color`), and the sequential/diverging gradients (`range.heatmap`+`ramp`
|
||||
* and `range.diverging`). Each writes through `mutateDraftConfig` so the JSON
|
||||
* and the gallery follow; reads come from the parsed draft config, so a JSON
|
||||
* hand-edit reflects straight back into the controls.
|
||||
*
|
||||
* Color model (§5): every family holds either a named Vega scheme (compact, the
|
||||
* quick path) or an explicit color array (custom tuning). Picking a scheme from
|
||||
* the preview-bearing dropdown writes the name; "Materialize" expands it to an
|
||||
* editable array of swatches — categorical, sequential, and diverging alike.
|
||||
* Each swatch pairs the native color picker with a hex text field, so a value
|
||||
* can be read, copied, and retyped anywhere.
|
||||
*/
|
||||
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import type { JsonObject } from '@core/spec-config';
|
||||
import {
|
||||
type ConfigPath,
|
||||
getConfigValue,
|
||||
schemeColors,
|
||||
schemesByKind,
|
||||
setConfigValue,
|
||||
} from '@core/theme-controls';
|
||||
import { useCustomThemeStore } from '../stores/CustomThemeStore';
|
||||
import { Button } from './Button';
|
||||
import { SelectControl, type SelectControlOption } from './SelectControl';
|
||||
import styles from './ColorControls.module.css';
|
||||
|
||||
const CATEGORY: ConfigPath = ['range', 'category'];
|
||||
const MARK_COLOR: ConfigPath = ['mark', 'color'];
|
||||
const HEATMAP: ConfigPath = ['range', 'heatmap'];
|
||||
const RAMP: ConfigPath = ['range', 'ramp'];
|
||||
const DIVERGING: ConfigPath = ['range', 'diverging'];
|
||||
|
||||
/** Vega-Lite's default mark color — shown as the starting value when unset. */
|
||||
const VEGA_DEFAULT_MARK = '#4c78a8';
|
||||
/** Seeded into a new swatch and the materialize-from-default paths. */
|
||||
const NEW_SWATCH = '#888888';
|
||||
const DEFAULT_CATEGORICAL = 'tableau10';
|
||||
const DEFAULT_SEQUENTIAL = 'viridis';
|
||||
const DEFAULT_DIVERGING = 'redblue';
|
||||
/** Stops a materialized gradient starts with (continuous schemes are sampled). */
|
||||
const GRADIENT_STOPS = 7;
|
||||
|
||||
const isHex = (c: string): boolean => /^#[0-9a-f]{6}$/i.test(c);
|
||||
const asArray = (v: unknown): string[] | null =>
|
||||
Array.isArray(v) && v.every((c) => typeof c === 'string') ? v : null;
|
||||
const asString = (v: unknown): string | null => (typeof v === 'string' ? v : null);
|
||||
|
||||
/** `#rrggbb` (lowercased) from loose input, or null if not six hex digits. */
|
||||
function normalizeHex(raw: string): string | null {
|
||||
const v = raw.trim().replace(/^#/, '');
|
||||
return /^[0-9a-f]{6}$/i.test(v) ? `#${v.toLowerCase()}` : null;
|
||||
}
|
||||
|
||||
const gradientCss = (colors: string[]): string =>
|
||||
colors.length ? `linear-gradient(90deg, ${colors.join(', ')})` : 'transparent';
|
||||
|
||||
// Dropdown previews, built once: a swatch strip for categorical, a gradient bar
|
||||
// for the continuous families.
|
||||
const swatchStrip = (colors: string[]): ReactNode => (
|
||||
<span className={styles.optStrip}>
|
||||
{colors.slice(0, 12).map((c, i) => (
|
||||
<span key={i} className={styles.optSwatch} style={{ background: c }} />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
const gradientBar = (colors: string[]): ReactNode => (
|
||||
<span className={styles.optGradient} style={{ background: gradientCss(colors) }} />
|
||||
);
|
||||
|
||||
const CATEGORICAL_OPTIONS: SelectControlOption<string>[] = schemesByKind('categorical').map(
|
||||
(s) => ({
|
||||
value: s.name,
|
||||
label: s.label,
|
||||
preview: swatchStrip(schemeColors(s.name)),
|
||||
}),
|
||||
);
|
||||
const SEQUENTIAL_OPTIONS: SelectControlOption<string>[] = schemesByKind('sequential').map((s) => ({
|
||||
value: s.name,
|
||||
label: s.label,
|
||||
preview: gradientBar(schemeColors(s.name, 12)),
|
||||
}));
|
||||
const DIVERGING_OPTIONS: SelectControlOption<string>[] = schemesByKind('diverging').map((s) => ({
|
||||
value: s.name,
|
||||
label: s.label,
|
||||
preview: gradientBar(schemeColors(s.name, 12)),
|
||||
}));
|
||||
|
||||
/** A color picker paired with a copyable, editable hex field; optional remove. */
|
||||
function SwatchRow({
|
||||
color,
|
||||
label,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: {
|
||||
color: string;
|
||||
label: string;
|
||||
onChange: (hex: string) => void;
|
||||
onRemove?: () => void;
|
||||
}) {
|
||||
// Local text so a partially-typed hex isn't rejected mid-keystroke; commits on
|
||||
// a valid value, and reverts to the committed color on blur. Resync to the
|
||||
// committed color when it changes from the outside (color picker, materialize,
|
||||
// scheme) — but not from this field's own commit, so an in-progress edit isn't
|
||||
// clobbered. The render-time adjustment is React's documented alternative to a
|
||||
// sync effect.
|
||||
const [text, setText] = useState(color);
|
||||
const [synced, setSynced] = useState(color);
|
||||
if (color !== synced) {
|
||||
setSynced(color);
|
||||
if (normalizeHex(text) !== color) setText(color);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={styles.swatchUnit}>
|
||||
<input
|
||||
type="color"
|
||||
className={styles.colorInput}
|
||||
aria-label={label}
|
||||
value={isHex(color) ? color : '#000000'}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.hexInput}
|
||||
aria-label={`${label} hex value`}
|
||||
spellCheck={false}
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
const norm = normalizeHex(e.target.value);
|
||||
if (norm) onChange(norm);
|
||||
}}
|
||||
onBlur={() => setText(color)}
|
||||
/>
|
||||
{onRemove && (
|
||||
// TODO: icon-only control as a raw <button> with a literal "×" rather
|
||||
// than IconButton + <Icon name="close"/> (the controlled glyph vocabulary
|
||||
// arch 09 §5 uses everywhere else). The corner badge is 16px — below
|
||||
// IconButton's 24px sm and tighter than a 16px Icon fits — so neither
|
||||
// primitive drops in unchanged. Reconcile the badge size with the
|
||||
// primitive in the batched control-consistency pass.
|
||||
<button
|
||||
type="button"
|
||||
className={styles.swatchRemove}
|
||||
aria-label={`Remove ${label}`}
|
||||
title="Remove"
|
||||
onClick={onRemove}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ColorControls({ config }: { config: JsonObject }) {
|
||||
const mutate = useCustomThemeStore((s) => s.mutateDraftConfig);
|
||||
const set = (path: ConfigPath, value: unknown) => mutate((c) => setConfigValue(c, path, value));
|
||||
// Sequential color lives in two slots (heatmaps + continuous legends); keep them together.
|
||||
const setSeq = (value: unknown) =>
|
||||
mutate((c) => setConfigValue(setConfigValue(c, HEATMAP, value), RAMP, value));
|
||||
|
||||
const catValue = getConfigValue(config, CATEGORY);
|
||||
const catArray = asArray(catValue);
|
||||
const catScheme = asString(catValue);
|
||||
|
||||
const markColor = asString(getConfigValue(config, MARK_COLOR));
|
||||
|
||||
const seqValue = getConfigValue(config, HEATMAP);
|
||||
const seqArray = asArray(seqValue);
|
||||
const seqScheme = asString(seqValue);
|
||||
|
||||
const divValue = getConfigValue(config, DIVERGING);
|
||||
const divArray = asArray(divValue);
|
||||
const divScheme = asString(divValue);
|
||||
|
||||
/** Stops to drive a gradient preview for a family in any of its states. */
|
||||
const previewStops = (array: string[] | null, scheme: string | null): string[] =>
|
||||
array ?? (scheme ? schemeColors(scheme, 9) : []);
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
{/* ── Categorical palette ─────────────────────────────────────────── */}
|
||||
<section className={styles.group}>
|
||||
<h4 className={styles.groupTitle}>Categorical palette</h4>
|
||||
<p className={styles.hint}>Series colors — assigned to discrete categories in order.</p>
|
||||
|
||||
<div className={styles.row}>
|
||||
<SelectControl
|
||||
id="color-categorical-scheme"
|
||||
label="Categorical color scheme"
|
||||
heading="Color scheme"
|
||||
options={CATEGORICAL_OPTIONS}
|
||||
value={catScheme ?? undefined}
|
||||
onSelect={(name) => set(CATEGORY, name)}
|
||||
triggerContent={
|
||||
<>
|
||||
<span className={styles.triggerPreview} aria-hidden="true">
|
||||
{(catArray ?? (catScheme ? schemeColors(catScheme) : []))
|
||||
.slice(0, 6)
|
||||
.map((c, i) => (
|
||||
<span key={i} className={styles.optSwatch} style={{ background: c }} />
|
||||
))}
|
||||
</span>
|
||||
<span>
|
||||
{catArray ? `Custom (${catArray.length})` : (catScheme ?? 'Theme default')}
|
||||
</span>
|
||||
<span className={styles.caret} aria-hidden="true">
|
||||
▾
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
triggerTitle="Pick a named palette"
|
||||
/>
|
||||
{catArray ? (
|
||||
<Button variant="ghost" onClick={() => set(CATEGORY, [...catArray, NEW_SWATCH])}>
|
||||
Add color
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => set(CATEGORY, schemeColors(catScheme ?? DEFAULT_CATEGORICAL))}
|
||||
>
|
||||
Materialize to edit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{catArray && (
|
||||
<div className={styles.swatches}>
|
||||
{catArray.map((color, i) => (
|
||||
<SwatchRow
|
||||
key={i}
|
||||
color={color}
|
||||
label={`Palette color ${i + 1}`}
|
||||
onChange={(hex) =>
|
||||
set(
|
||||
CATEGORY,
|
||||
catArray.map((c, j) => (j === i ? hex : c)),
|
||||
)
|
||||
}
|
||||
onRemove={() =>
|
||||
set(
|
||||
CATEGORY,
|
||||
catArray.length === 1 ? undefined : catArray.filter((_, j) => j !== i),
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── Default mark color ──────────────────────────────────────────── */}
|
||||
<section className={styles.group}>
|
||||
<h4 className={styles.groupTitle}>Default mark color</h4>
|
||||
<p className={styles.hint}>
|
||||
Single-series fill — bars, points, and lines with no color encoding.
|
||||
</p>
|
||||
<div className={styles.row}>
|
||||
<SwatchRow
|
||||
color={markColor && isHex(markColor) ? markColor : VEGA_DEFAULT_MARK}
|
||||
label="Default mark color"
|
||||
onChange={(hex) => set(MARK_COLOR, hex)}
|
||||
/>
|
||||
{markColor ? (
|
||||
<Button variant="ghost" onClick={() => set(MARK_COLOR, undefined)}>
|
||||
Clear
|
||||
</Button>
|
||||
) : (
|
||||
<span className={styles.hint}>Unset — Vega default ({VEGA_DEFAULT_MARK})</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Sequential gradient ─────────────────────────────────────────── */}
|
||||
<section className={styles.group}>
|
||||
<h4 className={styles.groupTitle}>Sequential gradient</h4>
|
||||
<p className={styles.hint}>Continuous color — heatmaps and quantitative legends.</p>
|
||||
<div className={styles.row}>
|
||||
<span
|
||||
className={styles.gradientPreview}
|
||||
aria-hidden="true"
|
||||
style={{ background: gradientCss(previewStops(seqArray, seqScheme)) }}
|
||||
/>
|
||||
<SelectControl
|
||||
id="color-sequential-scheme"
|
||||
label="Sequential color scheme"
|
||||
heading="Sequential scheme"
|
||||
options={SEQUENTIAL_OPTIONS}
|
||||
value={seqScheme ?? undefined}
|
||||
onSelect={(name) => setSeq(name)}
|
||||
triggerContent={
|
||||
<>
|
||||
<span>
|
||||
{seqArray ? `Custom (${seqArray.length})` : (seqScheme ?? 'Theme default')}
|
||||
</span>
|
||||
<span className={styles.caret} aria-hidden="true">
|
||||
▾
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{seqArray ? (
|
||||
<Button variant="ghost" onClick={() => setSeq([...seqArray, NEW_SWATCH])}>
|
||||
Add stop
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setSeq(schemeColors(seqScheme ?? DEFAULT_SEQUENTIAL, GRADIENT_STOPS))}
|
||||
>
|
||||
Materialize to edit
|
||||
</Button>
|
||||
)}
|
||||
{(seqArray || seqScheme) && (
|
||||
<Button variant="ghost" onClick={() => setSeq(undefined)}>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{seqArray && (
|
||||
<div className={styles.swatches}>
|
||||
{seqArray.map((color, i) => (
|
||||
<SwatchRow
|
||||
key={i}
|
||||
color={color}
|
||||
label={`Sequential stop ${i + 1}`}
|
||||
onChange={(hex) => setSeq(seqArray.map((c, j) => (j === i ? hex : c)))}
|
||||
onRemove={() =>
|
||||
setSeq(seqArray.length === 1 ? undefined : seqArray.filter((_, j) => j !== i))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── Diverging gradient ──────────────────────────────────────────── */}
|
||||
<section className={styles.group}>
|
||||
<h4 className={styles.groupTitle}>Diverging gradient</h4>
|
||||
<p className={styles.hint}>Two-ended color — values around a meaningful midpoint.</p>
|
||||
<div className={styles.row}>
|
||||
<span
|
||||
className={styles.gradientPreview}
|
||||
aria-hidden="true"
|
||||
style={{ background: gradientCss(previewStops(divArray, divScheme)) }}
|
||||
/>
|
||||
<SelectControl
|
||||
id="color-diverging-scheme"
|
||||
label="Diverging color scheme"
|
||||
heading="Diverging scheme"
|
||||
options={DIVERGING_OPTIONS}
|
||||
value={divScheme ?? undefined}
|
||||
onSelect={(name) => set(DIVERGING, name)}
|
||||
triggerContent={
|
||||
<>
|
||||
<span>
|
||||
{divArray ? `Custom (${divArray.length})` : (divScheme ?? 'Theme default')}
|
||||
</span>
|
||||
<span className={styles.caret} aria-hidden="true">
|
||||
▾
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{divArray ? (
|
||||
<Button variant="ghost" onClick={() => set(DIVERGING, [...divArray, NEW_SWATCH])}>
|
||||
Add stop
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
set(DIVERGING, schemeColors(divScheme ?? DEFAULT_DIVERGING, GRADIENT_STOPS))
|
||||
}
|
||||
>
|
||||
Materialize to edit
|
||||
</Button>
|
||||
)}
|
||||
{(divArray || divScheme) && (
|
||||
<Button variant="ghost" onClick={() => set(DIVERGING, undefined)}>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{divArray && (
|
||||
<div className={styles.swatches}>
|
||||
{divArray.map((color, i) => (
|
||||
<SwatchRow
|
||||
key={i}
|
||||
color={color}
|
||||
label={`Diverging stop ${i + 1}`}
|
||||
onChange={(hex) =>
|
||||
set(
|
||||
DIVERGING,
|
||||
divArray.map((c, j) => (j === i ? hex : c)),
|
||||
)
|
||||
}
|
||||
onRemove={() =>
|
||||
set(
|
||||
DIVERGING,
|
||||
divArray.length === 1 ? undefined : divArray.filter((_, j) => j !== i),
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -127,9 +127,17 @@
|
||||
.optionLabel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Decorative leading visual (e.g. a color-scheme swatch strip / gradient bar). */
|
||||
.optionPreview {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* caller intercept the click entirely (the armed-channel fast path).
|
||||
*/
|
||||
|
||||
import { Fragment, type ReactNode } from 'react';
|
||||
import { Fragment, type CSSProperties, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { usePopover } from '../hooks/usePopover';
|
||||
import styles from './SelectControl.module.css';
|
||||
@@ -47,6 +47,16 @@ export interface SelectControlOption<V extends string> {
|
||||
* activating discloses further UI, matching the "…" label convention.
|
||||
*/
|
||||
hasPopup?: 'dialog';
|
||||
/**
|
||||
* Optional visual leading the option label — e.g. a color-scheme swatch strip
|
||||
* or gradient bar. Decorative (`aria-hidden`); the label carries the meaning.
|
||||
*/
|
||||
preview?: ReactNode;
|
||||
/**
|
||||
* Style applied to the label text — e.g. render a font option's name in its
|
||||
* own family, so the option previews the value (the type analogue of a swatch).
|
||||
*/
|
||||
labelStyle?: CSSProperties;
|
||||
}
|
||||
|
||||
export interface SelectControlProps<V extends string> {
|
||||
@@ -171,7 +181,12 @@ export function SelectControl<V extends string>({
|
||||
aria-haspopup={o.hasPopup}
|
||||
onClick={() => choose(o.value)}
|
||||
>
|
||||
<span className={styles.optionLabel}>
|
||||
{o.preview !== undefined && (
|
||||
<span className={styles.optionPreview} aria-hidden="true">
|
||||
{o.preview}
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.optionLabel} style={o.labelStyle}>
|
||||
{o.label}
|
||||
{o.detail !== undefined && (
|
||||
<span className={styles.detail}>{o.detail}</span>
|
||||
|
||||
@@ -141,31 +141,145 @@
|
||||
border-bottom: var(--border-width) solid var(--border);
|
||||
}
|
||||
|
||||
/* ── Editor + gallery split ────────────────────────────────────────────── */
|
||||
/* ── Structured-control tabs ───────────────────────────────────────────── */
|
||||
|
||||
.work {
|
||||
.tabs {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
gap: 0;
|
||||
padding: 0 var(--space-5);
|
||||
border-bottom: var(--border-width) solid var(--border);
|
||||
}
|
||||
|
||||
.tab {
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color var(--dur-fast) var(--ease);
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tabActive {
|
||||
color: var(--text);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
/* The structured controls are the first-screen surface: they fill the column
|
||||
and scroll, with the collapsible JSON section docked below. */
|
||||
.tabpanel {
|
||||
flex: 1 1 auto;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
border-bottom: var(--border-width) solid var(--border);
|
||||
}
|
||||
|
||||
.controlsDisabled {
|
||||
margin: 0;
|
||||
padding: var(--space-5);
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.typePanel {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.typeTitle {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.typeHint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.fontCaret {
|
||||
margin-left: var(--space-3);
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ── Controls + JSON (left) | gallery rail (right) ─────────────────────── */
|
||||
|
||||
/* The gallery takes the whole right side, full height; the controls column —
|
||||
tabs, the active panel, and the JSON editor stacked — sits on the left,
|
||||
capped so the previews get the room. */
|
||||
.body {
|
||||
flex: 1 1 auto;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 400px) 1fr;
|
||||
grid-template-columns: clamp(420px, 38%, 600px) 1fr;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.controlsCol {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
border-right: var(--border-width) solid var(--border);
|
||||
}
|
||||
|
||||
/* Collapsible raw-JSON disclosure, docked at the bottom of the controls column. */
|
||||
.jsonSection {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-top: var(--border-width) solid var(--border);
|
||||
}
|
||||
|
||||
.jsonToggle {
|
||||
appearance: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-5);
|
||||
background: none;
|
||||
border: none;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.jsonToggle:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.jsonCaret {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.editorPane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-right: var(--border-width) solid var(--border);
|
||||
min-height: 0;
|
||||
padding: 0 var(--space-5) var(--space-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.configText {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
height: clamp(180px, 34vh, 340px);
|
||||
padding: var(--space-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* is exactly what selecting the theme will do.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { THEME_FONT_OPTIONS } from '@core/custom-theme';
|
||||
import type { JsonObject } from '@core/spec-config';
|
||||
import { THEME_PREVIEW_SPECS, type ThemePreviewSpec } from '@core/theme-preview-specs';
|
||||
@@ -27,9 +27,28 @@ import {
|
||||
import { notify } from '../stores/NotificationStore';
|
||||
import { resnapshot } from '../modals/ModalCoordinator';
|
||||
import { Button } from './Button';
|
||||
import { SelectControl } from './SelectControl';
|
||||
import { ColorControls } from './ColorControls';
|
||||
import { SelectControl, type SelectControlOption } from './SelectControl';
|
||||
import styles from './ThemeBuilderModal.module.css';
|
||||
|
||||
/** Structured-control tabs, in display order (docs/chart-theming-scope.md §5). */
|
||||
const THEME_TABS = [
|
||||
{ id: 'color', label: 'Color' },
|
||||
{ id: 'type', label: 'Type' },
|
||||
] as const;
|
||||
type ThemeTab = (typeof THEME_TABS)[number]['id'];
|
||||
|
||||
/**
|
||||
* Font options, each labelled in its own family so the dropdown previews the
|
||||
* typeface (the type analogue of the color dropdowns' swatches). Render-safe
|
||||
* faces only today — see THEME_FONT_OPTIONS.
|
||||
*/
|
||||
const FONT_OPTIONS: SelectControlOption<string>[] = THEME_FONT_OPTIONS.map(({ value, label }) => ({
|
||||
value,
|
||||
label,
|
||||
labelStyle: { fontFamily: value },
|
||||
}));
|
||||
|
||||
/** Debounce for gallery re-renders while the config text is edited (ms). */
|
||||
const GALLERY_DEBOUNCE = 250;
|
||||
|
||||
@@ -108,6 +127,35 @@ export function ThemeBuilderModal() {
|
||||
const saveError = useCustomThemeStore((s) => s.saveError);
|
||||
const dirty = useCustomThemeStore(selectIsDraftDirty);
|
||||
const selectedTheme = useCustomThemeStore(selectSelectedTheme);
|
||||
const [activeTab, setActiveTab] = useState<ThemeTab>('color');
|
||||
// The raw JSON is the advanced/escape-hatch surface, collapsed off the first
|
||||
// screen so the structured controls own it; a parse error forces it open
|
||||
// (it's the only place to fix the JSON).
|
||||
const [jsonExpanded, setJsonExpanded] = useState(false);
|
||||
const jsonOpen = jsonExpanded || parseError !== null;
|
||||
// The font option matching the config's top-level font (if any) — drives the
|
||||
// Type tab's selected state and its in-face trigger label.
|
||||
const currentFont =
|
||||
typeof draftConfig?.font === 'string'
|
||||
? FONT_OPTIONS.find((o) => o.value === draftConfig.font)
|
||||
: undefined;
|
||||
|
||||
// APG tabs: arrow/Home/End move selection, which follows focus (automatic
|
||||
// activation — the panel swap is cheap). The portaled SelectControl popovers
|
||||
// inside a panel manage their own focus.
|
||||
const onTabKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const ids = THEME_TABS.map((t) => t.id);
|
||||
const i = ids.indexOf(activeTab);
|
||||
let next = -1;
|
||||
if (e.key === 'ArrowRight') next = (i + 1) % ids.length;
|
||||
else if (e.key === 'ArrowLeft') next = (i - 1 + ids.length) % ids.length;
|
||||
else if (e.key === 'Home') next = 0;
|
||||
else if (e.key === 'End') next = ids.length - 1;
|
||||
if (next < 0) return;
|
||||
e.preventDefault();
|
||||
setActiveTab(ids[next]);
|
||||
(e.currentTarget.children[next] as HTMLElement | undefined)?.focus();
|
||||
};
|
||||
|
||||
const handleNew = () => {
|
||||
const app = useAppStore.getState();
|
||||
@@ -199,15 +247,6 @@ export function ThemeBuilderModal() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<SelectControl
|
||||
id="theme-builder-font"
|
||||
label="Apply a font across the config"
|
||||
heading="Apply font"
|
||||
options={THEME_FONT_OPTIONS.map(({ value, label }) => ({ value, label }))}
|
||||
onSelect={(family) => useCustomThemeStore.getState().applyDraftFont(family)}
|
||||
triggerContent={<>Font…</>}
|
||||
triggerTitle="Write one font family into every font slot of the config"
|
||||
/>
|
||||
<div className={styles.toolbarEnd}>
|
||||
<Button variant="danger-outline" onClick={() => void handleDelete()}>
|
||||
Delete
|
||||
@@ -222,20 +261,99 @@ export function ThemeBuilderModal() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
<div className={styles.controlsCol}>
|
||||
<div
|
||||
className={styles.tabs}
|
||||
role="tablist"
|
||||
aria-label="Theme controls"
|
||||
onKeyDown={onTabKeyDown}
|
||||
>
|
||||
{THEME_TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`theme-tab-${t.id}`}
|
||||
aria-selected={activeTab === t.id}
|
||||
aria-controls={`theme-tabpanel-${t.id}`}
|
||||
tabIndex={activeTab === t.id ? 0 : -1}
|
||||
className={`${styles.tab} ${activeTab === t.id ? styles.tabActive : ''}`}
|
||||
onClick={() => setActiveTab(t.id)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(saveError ?? parseError) !== null && (
|
||||
<p className={styles.errorMessage} role="alert">
|
||||
{saveError ?? parseError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={styles.work}>
|
||||
<div className={styles.editorPane}>
|
||||
<label className={styles.label} htmlFor="theme-config">
|
||||
<div
|
||||
className={styles.tabpanel}
|
||||
role="tabpanel"
|
||||
id={`theme-tabpanel-${activeTab}`}
|
||||
aria-labelledby={`theme-tab-${activeTab}`}
|
||||
>
|
||||
{parseError !== null ? (
|
||||
<p className={styles.controlsDisabled}>
|
||||
Fix the JSON below to use these controls.
|
||||
</p>
|
||||
) : activeTab === 'color' && draftConfig !== null ? (
|
||||
<ColorControls config={draftConfig} />
|
||||
) : activeTab === 'type' ? (
|
||||
<div className={styles.typePanel}>
|
||||
<h4 className={styles.typeTitle}>Font family</h4>
|
||||
<p className={styles.typeHint}>
|
||||
Writes one family into every font slot of the config.
|
||||
</p>
|
||||
<SelectControl
|
||||
id="theme-builder-font"
|
||||
label="Font family"
|
||||
heading="Apply font"
|
||||
options={FONT_OPTIONS}
|
||||
value={currentFont?.value}
|
||||
onSelect={(family) => useCustomThemeStore.getState().applyDraftFont(family)}
|
||||
triggerContent={
|
||||
<>
|
||||
<span style={currentFont ? { fontFamily: currentFont.value } : undefined}>
|
||||
{currentFont?.label ?? 'Apply font…'}
|
||||
</span>
|
||||
<span className={styles.fontCaret} aria-hidden="true">
|
||||
▾
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
triggerTitle="Write one font family into every font slot of the config"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Raw JSON — collapsed by default (the structured controls are the
|
||||
first-screen surface); forced open while a parse error is the
|
||||
only thing that needs the textarea. */}
|
||||
<div className={styles.jsonSection}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.jsonToggle}
|
||||
aria-expanded={jsonOpen}
|
||||
aria-controls="theme-config"
|
||||
onClick={() => setJsonExpanded((o) => !o)}
|
||||
>
|
||||
<span className={styles.jsonCaret} aria-hidden="true">
|
||||
{jsonOpen ? '▾' : '▸'}
|
||||
</span>
|
||||
Config (Vega-Lite JSON)
|
||||
</label>
|
||||
</button>
|
||||
{jsonOpen && (
|
||||
<div className={styles.editorPane}>
|
||||
{/* A plain textarea, deliberately not Monaco: a second Monaco mount
|
||||
is heavy inside a modal for an occasional surface, and the parse
|
||||
error below is the feedback channel that matters here. Revisit
|
||||
error above is the feedback channel that matters here. Revisit
|
||||
only if real usage asks for config completions. */}
|
||||
<textarea
|
||||
id="theme-config"
|
||||
@@ -247,6 +365,9 @@ export function ThemeBuilderModal() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.gallery} aria-label="Theme preview gallery">
|
||||
{draftConfig !== null &&
|
||||
THEME_PREVIEW_SPECS.map((card) => (
|
||||
|
||||
@@ -64,6 +64,13 @@ export interface CustomThemeState {
|
||||
* the current text is invalid JSON — fix the JSON first.
|
||||
*/
|
||||
applyDraftFont: (family: string) => void;
|
||||
/**
|
||||
* Apply a pure transform to the draft config and reflect it in both the
|
||||
* parsed config (the gallery follows) and the reformatted text. The single
|
||||
* write path for every structured control. No-op with a parse error — the
|
||||
* controls are disabled while the JSON is invalid; fix the JSON first.
|
||||
*/
|
||||
mutateDraftConfig: (fn: (config: JsonObject) => JsonObject) => void;
|
||||
/**
|
||||
* Validate and commit the draft to the selected theme. On failure sets
|
||||
* `saveError`/`parseError` and returns false.
|
||||
@@ -166,7 +173,10 @@ export const useCustomThemeStore = create<CustomThemeState>((set, get) => ({
|
||||
set({ draft: next, saveError: null });
|
||||
},
|
||||
|
||||
applyDraftFont: (family) => {
|
||||
applyDraftFont: (family) =>
|
||||
get().mutateDraftConfig((config) => applyFontToConfig(config, family)),
|
||||
|
||||
mutateDraftConfig: (fn) => {
|
||||
const draft = get().draft;
|
||||
if (!draft) return;
|
||||
const parsed = parseConfigText(draft.configText);
|
||||
@@ -174,7 +184,7 @@ export const useCustomThemeStore = create<CustomThemeState>((set, get) => ({
|
||||
set({ parseError: parsed.error });
|
||||
return;
|
||||
}
|
||||
const config = applyFontToConfig(parsed.config, family);
|
||||
const config = fn(parsed.config);
|
||||
set({
|
||||
draft: { ...draft, configText: configToText(config) },
|
||||
draftConfig: config,
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
Vendored
+8
@@ -21,3 +21,11 @@ declare module 'vega-lite/vega-lite-schema.json' {
|
||||
declare module 'monaco-editor/esm/vs/editor/edcore.main' {
|
||||
export * from 'monaco-editor/esm/vs/editor/editor.api';
|
||||
}
|
||||
|
||||
// vega-scale ships types at `index.d.ts` but its package.json `exports` maps
|
||||
// only `default` (no `types`), so `bundler` resolution can't find them. Declare
|
||||
// the one symbol core uses: `scheme(name)` returns a categorical color array, a
|
||||
// continuous interpolator, or undefined for an unknown name.
|
||||
declare module 'vega-scale' {
|
||||
export function scheme(name: string): string[] | ((t: number) => string) | undefined;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user