mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Theme Builder: accordion panels across the full config surface, vertical-tab nav, reflective gallery
This commit is contained in:
@@ -303,13 +303,19 @@ width rather than a variable font's possibly-condensed default instance.
|
|||||||
|
|
||||||
### Structured controls
|
### Structured controls
|
||||||
|
|
||||||
The builder's panels — Color, Type, Layout, Axes & grid, Legend
|
The builder's panels — Color, Marks, Type, Title, Layout, Axes & grid, Legend,
|
||||||
(`ColorControls` + `TypeControls`/`LayoutControls`/`AxesControls`/`LegendControls`
|
Headers, Formats — are accelerators over the same `draftConfig`, one panel per
|
||||||
on the shared `ThemeFields` field primitives) — are accelerators over the same
|
config domain (each an `XxxControls` component on the shared `ThemeFields`
|
||||||
`draftConfig`: each reads a value and writes one back through
|
primitives). `ThemeBuilderModal` holds the single `id → label → Panel` registry
|
||||||
`CustomThemeStore.mutateDraftConfig(fn)` — the single transform path, which
|
(`THEME_TABS`); panels switch via a **vertical tab list** (APG vertical tabs),
|
||||||
reparses, reformats, and updates `draftConfig` so the JSON editor and gallery
|
and each panel groups its properties into a **single-expand accordion**
|
||||||
follow (a parse error disables the controls). The pure transforms live in
|
(`ThemeFields` `Accordion` — one section open at a time, each with a set-count
|
||||||
|
badge so customized sections are scannable while collapsed). Each control 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 raw
|
||||||
|
JSON below the panels is the full-power escape hatch for the long tail the
|
||||||
|
structured controls deliberately omit. The pure transforms live in
|
||||||
`core/theme-controls.ts`: immutable config path get/set, leaf coercion, the
|
`core/theme-controls.ts`: immutable config path get/set, leaf coercion, the
|
||||||
named-scheme catalog (`THEME_SCHEMES`), and `schemeColors` (scheme name → hex
|
named-scheme catalog (`THEME_SCHEMES`), and `schemeColors` (scheme name → hex
|
||||||
swatches, from the `vega-scale` registry — a focused vega sub-package). A color
|
swatches, from the `vega-scale` registry — a focused vega sub-package). A color
|
||||||
@@ -339,8 +345,13 @@ reader announces is unambiguous.
|
|||||||
`normalizeRangeSchemes` (core) heals the bare form at the render-resolution
|
`normalizeRangeSchemes` (core) heals the bare form at the render-resolution
|
||||||
points (`chartConfigForSelection`; the builder gallery) for configs authored or
|
points (`chartConfigForSelection`; the builder gallery) for configs authored or
|
||||||
saved before this was enforced.
|
saved before this was enforced.
|
||||||
- **Do** add a `theme-preview-specs.ts` gallery card for any new color family,
|
- **Do** give every control a visible mirror in the `theme-preview-specs.ts`
|
||||||
so no control ships without a visible mirror.
|
gallery, and keep those sample specs on **bare marks** (`mark: 'point'`, not
|
||||||
|
`{ type: 'point', size: 80 }`): a property hard-coded in a spec overrides the
|
||||||
|
injected config, making the matching control a no-op in the preview. Mark
|
||||||
|
styling belongs in the config (a theme), never inline in a card. Default chart
|
||||||
|
size and tooltips are the unavoidable exceptions — fixed-size swatches, and
|
||||||
|
hover-only respectively.
|
||||||
|
|
||||||
### Rules
|
### Rules
|
||||||
|
|
||||||
|
|||||||
@@ -660,6 +660,31 @@ without clipping, wrapping, or crowding.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 9. Organizing a large control surface (two levels)
|
||||||
|
|
||||||
|
A control surface too big for one scroll (the Theme Builder spans most of the
|
||||||
|
Vega-Lite config) is organized in two levels, each with a settled widget so the
|
||||||
|
choice isn't re-litigated per surface:
|
||||||
|
|
||||||
|
- **Level 1 — switch by domain with tabs.** Mutually-exclusive top-level
|
||||||
|
categories (Color, Marks, Type, …) are an APG **tab set**, one panel visible at
|
||||||
|
a time. Past a handful of tabs a horizontal strip wraps raggedly and the active
|
||||||
|
tab shifts rows; a **vertical tab list** (`aria-orientation="vertical"`, Up/Down
|
||||||
|
- Home/End) scales without wrapping and keeps the active panel anchored. Never
|
||||||
|
**nest** tab sets — two roving tablists collide.
|
||||||
|
- **Level 2 — group within a panel by how it's read.** Sub-groups a user reads in
|
||||||
|
full get **flat headings** (`role="group"` labelled by the heading). Sub-groups
|
||||||
|
where a user tunes one or two and skips the rest get a **single-expand
|
||||||
|
accordion** (APG accordion — heading-button toggles a `role="region"`; Up/Down
|
||||||
|
between headers) — Carbon's rule: accordion is for content "not crucial to read
|
||||||
|
in full." A per-section **modified badge** (count of set properties) keeps an
|
||||||
|
override scannable while collapsed (NN/g #6, recognition).
|
||||||
|
|
||||||
|
_(Consulted via /council → WAI-ARIA APG tabs/accordion/disclosure, IBM Carbon
|
||||||
|
accordion usage, NN/g #6/#8.)_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Do / Don't
|
## Do / Don't
|
||||||
|
|
||||||
**Do**
|
**Do**
|
||||||
|
|||||||
@@ -205,10 +205,12 @@ schema-typed model, or it silently drops those keys on a round-trip. Same shape
|
|||||||
|
|
||||||
**Resolved design points:**
|
**Resolved design points:**
|
||||||
|
|
||||||
- **Surfacing — inline tab strip** (not popovers, not sub-modals). Tabs (Color / Type /
|
- **Surfacing — vertical tab list + per-panel accordion** (not popovers, not sub-modals),
|
||||||
Layout / Axes & grid / Legend) sit between the toolbar and the JSON+gallery, all in the
|
all in the one xlarge modal. One panel per config domain, switched by a vertical tab rail
|
||||||
one xlarge modal. No nested overlays/focus traps, no contention with the one-open-popover
|
(L1); within a panel, related properties group into a single-expand accordion (L2). No
|
||||||
registry, and panels + JSON + gallery stay visible together.
|
nested overlays/focus traps, no contention with the one-open-popover registry; panel + JSON
|
||||||
|
- gallery stay visible together. The two-level grouping rule is recorded in arch 10
|
||||||
|
(_Organizing a large control surface_).
|
||||||
- **Color model — scheme picker that materializes to swatches.** A `range` family takes
|
- **Color model — scheme picker that materializes to swatches.** A `range` family takes
|
||||||
either an explicit color array or a named scheme written as Vega's range-scheme object
|
either an explicit color array or a named scheme written as Vega's range-scheme object
|
||||||
`{ scheme: name }`. (A bare scheme-name _string_ passes vega-lite compile but Vega
|
`{ scheme: name }`. (A bare scheme-name _string_ passes vega-lite compile but Vega
|
||||||
@@ -223,24 +225,34 @@ schema-typed model, or it silently drops those keys on a round-trip. Same shape
|
|||||||
- Controls write **minimal** config — clearing a value deletes the key rather than writing
|
- 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.
|
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`/
|
**Panels** (each an accordion of the groups below): Color (`range.category`, `mark.color`,
|
||||||
`diverging`) · Type (base `font`, title + axis title/label size+weight) · Layout (`background`
|
sequential `range.heatmap`/`ramp`, `range.diverging`) · Marks (per type — bars, lines &
|
||||||
incl. transparent, `padding`, `view.stroke`/`fill`/cornerRadius) · Axes & grid (grid on/off +
|
areas, points, arc — plus generic opacity/fill/tooltips) · Type (base `font`, axis
|
||||||
color + dash, domain, label color/angle, title color — base `axis` only; the 25 variants stay
|
title/label size+weight) · Title (anchor, colour, size/weight/style, offset, subtitle block)
|
||||||
JSON) · Legend (orient, title/label color+size, symbol size). Legend _type_ (size) lives in
|
· Layout (`background`, `view` fill/border/radius, `padding`, default size) · Axes & grid
|
||||||
the Legend panel rather than Type, so every legend property a brand tunes sits together.
|
(grid, ticks, domain, labels — base `axis` only; the per-channel variants stay JSON) · Legend
|
||||||
|
(placement/direction, title, labels, symbols, gradient, box) · Headers (facet title/label
|
||||||
|
colour/size/weight) · Formats (number/date/normalized formats, count title).
|
||||||
|
|
||||||
**Build order:** (a) core foundation — scheme catalog + immutable config path get/set +
|
**Build order:** (a) core foundation — scheme catalog + immutable config path get/set +
|
||||||
`schemeColors` materialize, with tests; (b) Color panel (highest payoff); (c) Type, Layout,
|
`schemeColors` materialize, with tests; (b) Color panel (highest payoff); (c) the remaining
|
||||||
Axes, Legend panels; (d) wire the tab strip into the modal.
|
per-domain panels; (d) wire the navigation (vertical tabs + per-panel accordion) 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
|
## 6. Status log
|
||||||
|
|
||||||
|
- **2026-06-21** — **structured-control surface expanded + accordion everywhere.** New panels
|
||||||
|
— Marks (per mark type), Title & subtitle, Headers (facets), Formats — and deepened
|
||||||
|
Axes/Legend/Layout, covering the brand-tuning bulk of the config; the raw JSON stays the
|
||||||
|
escape hatch for the long tail. Every panel is a single-expand accordion with a per-section
|
||||||
|
set-count badge, switched by a vertical tab list, and the modal holds one
|
||||||
|
`id → label → Panel` registry. Shared primitives extracted: `WeightRow` (UI),
|
||||||
|
`enumValue`/`countSet` (core). The gallery is now fully reflective — sample specs use bare
|
||||||
|
marks so no control is shadowed (guarded by a test), and a normalized area + a temporal
|
||||||
|
facet were added so `normalizedNumberFormat` and `timeFormat` have mirrors; default chart
|
||||||
|
size and tooltips are the only non-previewable controls. Recorded in arch 10 (_Organizing a
|
||||||
|
large control surface_), arch 05 (_Structured controls_), and spec §04. Remaining in §4:
|
||||||
|
Google Fonts opt-in tier; built-in preset gallery; Color-panel swatch reorder.
|
||||||
- **2026-06-16 (slice 7)** — **font export round-trip + SVG embed.** Uploaded faces now
|
- **2026-06-16 (slice 7)** — **font export round-trip + SVG embed.** Uploaded faces now
|
||||||
survive a workspace transfer and travel inside an exported SVG. Core: `serializeFontAsset`/
|
survive a workspace transfer and travel inside an exported SVG. Core: `serializeFontAsset`/
|
||||||
`deserializeFontAsset` (+ base64 helpers), `primaryFamilyName`, and `fontDataUri` in
|
`deserializeFontAsset` (+ base64 helpers), `primaryFamilyName`, and `fontDataUri` in
|
||||||
|
|||||||
@@ -61,9 +61,10 @@ The **Theme Builder** is a full-size modal for creating and editing custom chart
|
|||||||
Layout: a saved-theme list on the left; the open theme's editor on the right.
|
Layout: a saved-theme list on the left; the open theme's editor on the right.
|
||||||
|
|
||||||
- **New theme** creates a theme seeded as a **copy of the chart theme currently selected** in the preview (house style, stock, a preset, or another custom theme), named after its source (e.g. "FiveThirtyEight copy") and auto-suffixed if taken. Duplicating a preset is the expected starting point. The other creation path is the editor's **Extract Config to New Theme** action (see _Spec Editor → Spec ↔ Config Actions_), which turns a pasted spec's `config` block into a theme directly.
|
- **New theme** creates a theme seeded as a **copy of the chart theme currently selected** in the preview (house style, stock, a preset, or another custom theme), named after its source (e.g. "FiveThirtyEight copy") and auto-suffixed if taken. Duplicating a preset is the expected starting point. The other creation path is the editor's **Extract Config to New Theme** action (see _Spec Editor → Spec ↔ Config Actions_), which turns a pasted spec's `config` block into a theme directly.
|
||||||
- The editor shows the theme's **name** and its **config as editable JSON text**. Invalid JSON is reported inline and blocks saving; the text must parse to a JSON object.
|
- **Structured controls** organize the config into panels by domain — Color, Marks, Type, Title, Layout, Axes & grid, Legend, Headers, Formats — navigated by a vertical tab list, each panel's properties grouped into collapsible sections. A control writes one config property, and clearing it removes the key, so a theme stays a minimal diff against stock. The controls cover the common brand-tuning surface, not every Vega-Lite property.
|
||||||
- A **font control** applies a chosen font family across the whole config in one step: it sets the top-level `font` (Vega-Lite's default for every text mark, label, and title) and rewrites every explicit `font`/`labelFont`/`titleFont`/`subtitleFont` slot anywhere in the config — the slots that would otherwise keep overriding the new default. Offered fonts are limited to faces that render without loading (the app's own Plex faces and web-safe/system stacks) until the self-hosted font roster ships.
|
- The theme's **name** and its full **config as editable JSON text** sit below the controls as the escape hatch for anything they don't expose. Invalid JSON is reported inline and blocks saving (and is the only editing surface while invalid); the text must parse to a JSON object.
|
||||||
- A **gallery** of small fixed sample charts (bar with title, multi-series line with subtitle, stacked area, scatter with a gradient legend, heatmap, donut, facets with headers) re-renders live from the draft config — the same config-injection path the preview uses — so one edit is previewed across every chart surface a config styles. While the JSON is invalid, the gallery keeps showing the last valid state.
|
- A **font control** applies a chosen font family across the whole config in one step: it sets the top-level `font` (Vega-Lite's default for every text mark, label, and title) and rewrites every explicit `font`/`labelFont`/`titleFont`/`subtitleFont` slot anywhere in the config — the slots that would otherwise keep overriding the new default. Offered fonts are the self-hosted roster plus any the user uploads (see _Data Model → FontAsset_), the user's own faces listed first.
|
||||||
|
- A **gallery** of small fixed sample charts re-renders live from the draft config — the same config-injection path the preview uses — spanning the mark types, the color families, faceting, and titled charts so one edit is previewed across every surface a config styles. **Every structured control has a visible mirror** in at least one card; the default chart size and tooltips are the exceptions (the cards are fixed-size; tooltips are hover-only). While the JSON is invalid, the gallery keeps the last valid state.
|
||||||
- **Save** commits the draft (disabled while unchanged or unparseable). Names are unique case-insensitively, like dataset names. **Delete** removes the theme after confirmation.
|
- **Save** commits the draft (disabled while unchanged or unparseable). Names are unique case-insensitively, like dataset names. **Delete** removes the theme after confirmation.
|
||||||
- Closing with unsaved edits prompts for discard, like other form modals. A backdrop click does not dismiss the builder (Escape and the close button do).
|
- Closing with unsaved edits prompts for discard, like other form modals. A backdrop click does not dismiss the builder (Escape and the close button do).
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Theme Builder — Axes & grid panel (docs/chart-theming-scope.md §5).
|
* Theme Builder — Axes & grid panel (docs/chart-theming-scope.md §5).
|
||||||
*
|
*
|
||||||
* Structured controls over the base `axis` config only — grid visibility, grid
|
* Structured controls over the base `axis` config only — grid, ticks, the domain
|
||||||
* color and dash style, the domain line, label color and angle, and title
|
* line, labels, and title. The 25 per-channel variants (`axisX`, `axisY`,
|
||||||
* color. The 25 per-channel variants (`axisX`, `axisY`, `axisBand`, …) stay in
|
* `axisBand`, …) stay in the JSON; this is the common surface a brand actually
|
||||||
* the JSON; this is the common surface a brand actually tunes. Axis *type*
|
* tunes. Axis *type* (label/title size and weight) lives in the Type panel.
|
||||||
* (label/title size and weight) lives in the Type panel.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { JsonObject } from '@core/spec-config';
|
import type { JsonObject } from '@core/spec-config';
|
||||||
@@ -14,31 +13,38 @@ import {
|
|||||||
asNumber,
|
asNumber,
|
||||||
asString,
|
asString,
|
||||||
type ConfigPath,
|
type ConfigPath,
|
||||||
|
countSet,
|
||||||
getConfigValue,
|
getConfigValue,
|
||||||
} from '@core/theme-controls';
|
} from '@core/theme-controls';
|
||||||
import { useConfigSetter } from '../stores/CustomThemeStore';
|
import { useConfigSetter } from '../stores/CustomThemeStore';
|
||||||
import { ColorRow, ControlSection, NumberRow, SelectRow } from './ThemeFields';
|
import { Accordion, type AccordionSection, ColorRow, NumberRow, SelectRow } from './ThemeFields';
|
||||||
import type { SelectControlOption } from './SelectControl';
|
import type { SelectControlOption } from './SelectControl';
|
||||||
import styles from './ThemeFields.module.css';
|
|
||||||
|
|
||||||
const GRID: ConfigPath = ['axis', 'grid'];
|
const GRID: ConfigPath = ['axis', 'grid'];
|
||||||
const GRID_COLOR: ConfigPath = ['axis', 'gridColor'];
|
const GRID_COLOR: ConfigPath = ['axis', 'gridColor'];
|
||||||
const GRID_DASH: ConfigPath = ['axis', 'gridDash'];
|
const GRID_DASH: ConfigPath = ['axis', 'gridDash'];
|
||||||
|
const GRID_WIDTH: ConfigPath = ['axis', 'gridWidth'];
|
||||||
const DOMAIN_COLOR: ConfigPath = ['axis', 'domainColor'];
|
const DOMAIN_COLOR: ConfigPath = ['axis', 'domainColor'];
|
||||||
|
const DOMAIN_WIDTH: ConfigPath = ['axis', 'domainWidth'];
|
||||||
const LABEL_COLOR: ConfigPath = ['axis', 'labelColor'];
|
const LABEL_COLOR: ConfigPath = ['axis', 'labelColor'];
|
||||||
const LABEL_ANGLE: ConfigPath = ['axis', 'labelAngle'];
|
const LABEL_ANGLE: ConfigPath = ['axis', 'labelAngle'];
|
||||||
|
const LABEL_PADDING: ConfigPath = ['axis', 'labelPadding'];
|
||||||
const TITLE_COLOR: ConfigPath = ['axis', 'titleColor'];
|
const TITLE_COLOR: ConfigPath = ['axis', 'titleColor'];
|
||||||
|
const TICKS: ConfigPath = ['axis', 'ticks'];
|
||||||
|
const TICK_COLOR: ConfigPath = ['axis', 'tickColor'];
|
||||||
|
const TICK_SIZE: ConfigPath = ['axis', 'tickSize'];
|
||||||
|
|
||||||
const GRID_GREY = '#888888';
|
const GRID_GREY = '#888888';
|
||||||
|
|
||||||
// Grid visibility: tri-state (theme default · shown · hidden) over a boolean.
|
// Visibility: tri-state (theme default · shown · hidden) over a boolean — shared
|
||||||
type GridState = '' | 'true' | 'false';
|
// by grid lines and ticks (both `boolean | undefined` config keys).
|
||||||
const gridOptions: SelectControlOption<GridState>[] = [
|
type ShowState = '' | 'true' | 'false';
|
||||||
|
const showOptions: SelectControlOption<ShowState>[] = [
|
||||||
{ value: '', label: 'Theme default' },
|
{ value: '', label: 'Theme default' },
|
||||||
{ value: 'true', label: 'Shown' },
|
{ value: 'true', label: 'Shown' },
|
||||||
{ value: 'false', label: 'Hidden' },
|
{ value: 'false', label: 'Hidden' },
|
||||||
];
|
];
|
||||||
const gridState = (v: boolean | undefined): GridState =>
|
const showState = (v: boolean | undefined): ShowState =>
|
||||||
v === undefined ? '' : v ? 'true' : 'false';
|
v === undefined ? '' : v ? 'true' : 'false';
|
||||||
|
|
||||||
// Dash presets, matched by array shape; an unrecognised array reads as no preset
|
// Dash presets, matched by array shape; an unrecognised array reads as no preset
|
||||||
@@ -67,73 +73,139 @@ const dashStyle = (v: unknown): DashStyle => {
|
|||||||
export function AxesControls({ config }: { config: JsonObject }) {
|
export function AxesControls({ config }: { config: JsonObject }) {
|
||||||
const set = useConfigSetter();
|
const set = useConfigSetter();
|
||||||
|
|
||||||
const grid = asBoolean(getConfigValue(config, GRID));
|
const sections: AccordionSection[] = [
|
||||||
const gridColor = asString(getConfigValue(config, GRID_COLOR));
|
{
|
||||||
const dash = dashStyle(getConfigValue(config, GRID_DASH));
|
id: 'grid',
|
||||||
const domainColor = asString(getConfigValue(config, DOMAIN_COLOR));
|
title: 'Grid',
|
||||||
const labelColor = asString(getConfigValue(config, LABEL_COLOR));
|
hint: 'Reference lines behind the marks.',
|
||||||
const labelAngle = asNumber(getConfigValue(config, LABEL_ANGLE));
|
badge: countSet(config, [GRID, GRID_COLOR, GRID_DASH, GRID_WIDTH]),
|
||||||
const titleColor = asString(getConfigValue(config, TITLE_COLOR));
|
children: (
|
||||||
|
<>
|
||||||
|
<SelectRow
|
||||||
|
id="axes-grid"
|
||||||
|
label="Grid lines"
|
||||||
|
options={showOptions}
|
||||||
|
value={showState(asBoolean(getConfigValue(config, GRID)))}
|
||||||
|
onSelect={(s) => set(GRID, s === '' ? undefined : s === 'true')}
|
||||||
|
/>
|
||||||
|
<ColorRow
|
||||||
|
label="Grid color"
|
||||||
|
value={asString(getConfigValue(config, GRID_COLOR))}
|
||||||
|
fallback={GRID_GREY}
|
||||||
|
onChange={(hex) => set(GRID_COLOR, hex)}
|
||||||
|
onClear={() => set(GRID_COLOR, undefined)}
|
||||||
|
/>
|
||||||
|
<SelectRow
|
||||||
|
id="axes-grid-dash"
|
||||||
|
label="Grid style"
|
||||||
|
options={dashOptions}
|
||||||
|
value={dashStyle(getConfigValue(config, GRID_DASH))}
|
||||||
|
onSelect={(s) =>
|
||||||
|
set(GRID_DASH, s === '' ? undefined : DASH_VALUES[s as keyof typeof DASH_VALUES])
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<NumberRow
|
||||||
|
label="Grid width"
|
||||||
|
value={asNumber(getConfigValue(config, GRID_WIDTH))}
|
||||||
|
min={0}
|
||||||
|
unit="px"
|
||||||
|
onChange={(n) => set(GRID_WIDTH, n)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ticks',
|
||||||
|
title: 'Ticks',
|
||||||
|
hint: 'The marks along the axis line.',
|
||||||
|
badge: countSet(config, [TICKS, TICK_COLOR, TICK_SIZE]),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<SelectRow
|
||||||
|
id="axes-ticks"
|
||||||
|
label="Ticks"
|
||||||
|
options={showOptions}
|
||||||
|
value={showState(asBoolean(getConfigValue(config, TICKS)))}
|
||||||
|
onSelect={(s) => set(TICKS, s === '' ? undefined : s === 'true')}
|
||||||
|
/>
|
||||||
|
<ColorRow
|
||||||
|
label="Tick color"
|
||||||
|
value={asString(getConfigValue(config, TICK_COLOR))}
|
||||||
|
fallback={GRID_GREY}
|
||||||
|
onChange={(hex) => set(TICK_COLOR, hex)}
|
||||||
|
onClear={() => set(TICK_COLOR, undefined)}
|
||||||
|
/>
|
||||||
|
<NumberRow
|
||||||
|
label="Tick size"
|
||||||
|
value={asNumber(getConfigValue(config, TICK_SIZE))}
|
||||||
|
min={0}
|
||||||
|
unit="px"
|
||||||
|
onChange={(n) => set(TICK_SIZE, n)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'domain',
|
||||||
|
title: 'Domain & labels',
|
||||||
|
hint: 'The axis line, its tick labels, and title.',
|
||||||
|
badge: countSet(config, [
|
||||||
|
DOMAIN_COLOR,
|
||||||
|
DOMAIN_WIDTH,
|
||||||
|
LABEL_COLOR,
|
||||||
|
LABEL_ANGLE,
|
||||||
|
LABEL_PADDING,
|
||||||
|
TITLE_COLOR,
|
||||||
|
]),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<ColorRow
|
||||||
|
label="Domain line"
|
||||||
|
value={asString(getConfigValue(config, DOMAIN_COLOR))}
|
||||||
|
fallback={GRID_GREY}
|
||||||
|
onChange={(hex) => set(DOMAIN_COLOR, hex)}
|
||||||
|
onClear={() => set(DOMAIN_COLOR, undefined)}
|
||||||
|
/>
|
||||||
|
<NumberRow
|
||||||
|
label="Domain width"
|
||||||
|
value={asNumber(getConfigValue(config, DOMAIN_WIDTH))}
|
||||||
|
min={0}
|
||||||
|
unit="px"
|
||||||
|
onChange={(n) => set(DOMAIN_WIDTH, n)}
|
||||||
|
/>
|
||||||
|
<ColorRow
|
||||||
|
label="Label color"
|
||||||
|
value={asString(getConfigValue(config, LABEL_COLOR))}
|
||||||
|
fallback={GRID_GREY}
|
||||||
|
onChange={(hex) => set(LABEL_COLOR, hex)}
|
||||||
|
onClear={() => set(LABEL_COLOR, undefined)}
|
||||||
|
/>
|
||||||
|
<NumberRow
|
||||||
|
label="Label angle"
|
||||||
|
value={asNumber(getConfigValue(config, LABEL_ANGLE))}
|
||||||
|
min={-90}
|
||||||
|
max={90}
|
||||||
|
unit="°"
|
||||||
|
onChange={(n) => set(LABEL_ANGLE, n)}
|
||||||
|
/>
|
||||||
|
<NumberRow
|
||||||
|
label="Label padding"
|
||||||
|
value={asNumber(getConfigValue(config, LABEL_PADDING))}
|
||||||
|
min={0}
|
||||||
|
unit="px"
|
||||||
|
onChange={(n) => set(LABEL_PADDING, n)}
|
||||||
|
/>
|
||||||
|
<ColorRow
|
||||||
|
label="Title color"
|
||||||
|
value={asString(getConfigValue(config, TITLE_COLOR))}
|
||||||
|
fallback={GRID_GREY}
|
||||||
|
onChange={(hex) => set(TITLE_COLOR, hex)}
|
||||||
|
onClear={() => set(TITLE_COLOR, undefined)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return <Accordion sections={sections} idPrefix="axes" />;
|
||||||
<div className={styles.panel}>
|
|
||||||
<ControlSection title="Grid" hint="Reference lines behind the marks.">
|
|
||||||
<SelectRow
|
|
||||||
id="axes-grid"
|
|
||||||
label="Grid lines"
|
|
||||||
options={gridOptions}
|
|
||||||
value={gridState(grid)}
|
|
||||||
onSelect={(s) => set(GRID, s === '' ? undefined : s === 'true')}
|
|
||||||
/>
|
|
||||||
<ColorRow
|
|
||||||
label="Grid color"
|
|
||||||
value={gridColor}
|
|
||||||
fallback={GRID_GREY}
|
|
||||||
onChange={(hex) => set(GRID_COLOR, hex)}
|
|
||||||
onClear={() => set(GRID_COLOR, undefined)}
|
|
||||||
/>
|
|
||||||
<SelectRow
|
|
||||||
id="axes-grid-dash"
|
|
||||||
label="Grid style"
|
|
||||||
options={dashOptions}
|
|
||||||
value={dash}
|
|
||||||
onSelect={(s) =>
|
|
||||||
set(GRID_DASH, s === '' ? undefined : DASH_VALUES[s as keyof typeof DASH_VALUES])
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ControlSection>
|
|
||||||
|
|
||||||
<ControlSection title="Domain & labels" hint="The axis line, its tick labels, and title.">
|
|
||||||
<ColorRow
|
|
||||||
label="Domain line"
|
|
||||||
value={domainColor}
|
|
||||||
fallback={GRID_GREY}
|
|
||||||
onChange={(hex) => set(DOMAIN_COLOR, hex)}
|
|
||||||
onClear={() => set(DOMAIN_COLOR, undefined)}
|
|
||||||
/>
|
|
||||||
<ColorRow
|
|
||||||
label="Label color"
|
|
||||||
value={labelColor}
|
|
||||||
fallback={GRID_GREY}
|
|
||||||
onChange={(hex) => set(LABEL_COLOR, hex)}
|
|
||||||
onClear={() => set(LABEL_COLOR, undefined)}
|
|
||||||
/>
|
|
||||||
<NumberRow
|
|
||||||
label="Label angle"
|
|
||||||
value={labelAngle}
|
|
||||||
min={-90}
|
|
||||||
max={90}
|
|
||||||
unit="°"
|
|
||||||
onChange={(n) => set(LABEL_ANGLE, n)}
|
|
||||||
/>
|
|
||||||
<ColorRow
|
|
||||||
label="Title color"
|
|
||||||
value={titleColor}
|
|
||||||
fallback={GRID_GREY}
|
|
||||||
onChange={(hex) => set(TITLE_COLOR, hex)}
|
|
||||||
onClear={() => set(TITLE_COLOR, undefined)}
|
|
||||||
/>
|
|
||||||
</ControlSection>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,7 @@
|
|||||||
/* Theme Builder Color panel — structured controls over the draft config. */
|
/* Theme Builder Color panel — structured controls over the draft config.
|
||||||
|
The container, section headings, and badges come from the shared Accordion
|
||||||
.panel {
|
(ThemeFields); this module styles only the color-specific bits — swatch rows,
|
||||||
display: grid;
|
gradient/scheme previews, and the dropdown-option previews. */
|
||||||
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 {
|
.hint {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -20,13 +20,20 @@
|
|||||||
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import type { JsonObject } from '@core/spec-config';
|
import type { JsonObject } from '@core/spec-config';
|
||||||
import { type ConfigPath, getConfigValue, schemeColors, schemesByKind } from '@core/theme-controls';
|
import {
|
||||||
|
type ConfigPath,
|
||||||
|
countSet,
|
||||||
|
getConfigValue,
|
||||||
|
schemeColors,
|
||||||
|
schemesByKind,
|
||||||
|
} from '@core/theme-controls';
|
||||||
import { Button } from './Button';
|
import { Button } from './Button';
|
||||||
import { ColorField } from './ColorField';
|
import { ColorField } from './ColorField';
|
||||||
import { Icon } from './Icon';
|
import { Icon } from './Icon';
|
||||||
import { IconButton } from './IconButton';
|
import { IconButton } from './IconButton';
|
||||||
import { SelectControl, type SelectControlOption } from './SelectControl';
|
import { SelectControl, type SelectControlOption } from './SelectControl';
|
||||||
import { useConfigSetter } from '../stores/CustomThemeStore';
|
import { useConfigSetter } from '../stores/CustomThemeStore';
|
||||||
|
import { Accordion, type AccordionSection } from './ThemeFields';
|
||||||
import styles from './ColorControls.module.css';
|
import styles from './ColorControls.module.css';
|
||||||
|
|
||||||
const CATEGORY: ConfigPath = ['range', 'category'];
|
const CATEGORY: ConfigPath = ['range', 'category'];
|
||||||
@@ -154,236 +161,252 @@ export function ColorControls({ config }: { config: JsonObject }) {
|
|||||||
const previewStops = (array: string[] | null, scheme: string | null): string[] =>
|
const previewStops = (array: string[] | null, scheme: string | null): string[] =>
|
||||||
array ?? (scheme ? schemeColors(scheme, 9) : []);
|
array ?? (scheme ? schemeColors(scheme, 9) : []);
|
||||||
|
|
||||||
return (
|
const sections: AccordionSection[] = [
|
||||||
<div className={styles.panel}>
|
{
|
||||||
{/* ── Categorical palette ─────────────────────────────────────────── */}
|
id: 'categorical',
|
||||||
<section className={styles.group}>
|
title: 'Categorical palette',
|
||||||
<h4 className={styles.groupTitle}>Categorical palette</h4>
|
hint: 'Series colors — assigned to discrete categories in order.',
|
||||||
<p className={styles.hint}>Series colors — assigned to discrete categories in order.</p>
|
badge: countSet(config, [CATEGORY]),
|
||||||
|
children: (
|
||||||
<div className={styles.row}>
|
<>
|
||||||
<SelectControl
|
<div className={styles.row}>
|
||||||
id="color-categorical-scheme"
|
<SelectControl
|
||||||
label="Categorical color scheme"
|
id="color-categorical-scheme"
|
||||||
heading="Color scheme"
|
label="Categorical color scheme"
|
||||||
options={CATEGORICAL_OPTIONS}
|
heading="Color scheme"
|
||||||
value={catScheme ?? undefined}
|
options={CATEGORICAL_OPTIONS}
|
||||||
onSelect={(name) => set(CATEGORY, schemeRange(name))}
|
value={catScheme ?? undefined}
|
||||||
triggerContent={
|
onSelect={(name) => set(CATEGORY, schemeRange(name))}
|
||||||
<>
|
triggerContent={
|
||||||
<span className={styles.triggerPreview} aria-hidden="true">
|
<>
|
||||||
{(catArray ?? (catScheme ? schemeColors(catScheme) : []))
|
<span className={styles.triggerPreview} aria-hidden="true">
|
||||||
.slice(0, 6)
|
{(catArray ?? (catScheme ? schemeColors(catScheme) : []))
|
||||||
.map((c, i) => (
|
.slice(0, 6)
|
||||||
<span key={i} className={styles.optSwatch} style={{ background: c }} />
|
.map((c, i) => (
|
||||||
))}
|
<span key={i} className={styles.optSwatch} style={{ background: c }} />
|
||||||
</span>
|
))}
|
||||||
<span>
|
</span>
|
||||||
{catArray ? `Custom (${catArray.length})` : (catScheme ?? 'Theme default')}
|
<span>
|
||||||
</span>
|
{catArray ? `Custom (${catArray.length})` : (catScheme ?? 'Theme default')}
|
||||||
<span className={styles.caret} aria-hidden="true">
|
</span>
|
||||||
▾
|
<span className={styles.caret} aria-hidden="true">
|
||||||
</span>
|
▾
|
||||||
</>
|
</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 || 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(schemeRange(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, schemeRange(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))
|
|
||||||
}
|
}
|
||||||
>
|
triggerTitle="Pick a named palette"
|
||||||
Materialize to edit
|
/>
|
||||||
</Button>
|
{catArray ? (
|
||||||
)}
|
<Button variant="ghost" onClick={() => set(CATEGORY, [...catArray, NEW_SWATCH])}>
|
||||||
{(divArray || divScheme) && (
|
Add color
|
||||||
<Button variant="ghost" onClick={() => set(DIVERGING, undefined)}>
|
</Button>
|
||||||
Clear
|
) : (
|
||||||
</Button>
|
<Button
|
||||||
)}
|
variant="ghost"
|
||||||
</div>
|
onClick={() => set(CATEGORY, schemeColors(catScheme ?? DEFAULT_CATEGORICAL))}
|
||||||
{divArray && (
|
>
|
||||||
<div className={styles.swatches}>
|
Materialize to edit
|
||||||
{divArray.map((color, i) => (
|
</Button>
|
||||||
<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>
|
</div>
|
||||||
)}
|
|
||||||
</section>
|
{catArray && (
|
||||||
</div>
|
<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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'markColor',
|
||||||
|
title: 'Default mark color',
|
||||||
|
hint: 'Single-series fill — bars, points, and lines with no color encoding.',
|
||||||
|
badge: countSet(config, [MARK_COLOR]),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<div className={styles.row}>
|
||||||
|
<SwatchRow
|
||||||
|
color={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>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sequential',
|
||||||
|
title: 'Sequential gradient',
|
||||||
|
hint: 'Continuous color — heatmaps and quantitative legends.',
|
||||||
|
badge: countSet(config, [HEATMAP]),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<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(schemeRange(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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'diverging',
|
||||||
|
title: 'Diverging gradient',
|
||||||
|
hint: 'Two-ended color — values around a meaningful midpoint.',
|
||||||
|
badge: countSet(config, [DIVERGING]),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<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, schemeRange(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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return <Accordion sections={sections} idPrefix="color" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* Theme Builder — Formats panel (docs/chart-theming-scope.md §5).
|
||||||
|
*
|
||||||
|
* Default number and date formatting (`config.numberFormat`,
|
||||||
|
* `normalizedNumberFormat`, `timeFormat`) plus the count-field title — the
|
||||||
|
* brand-level "always show currency / thousands / short dates" knobs Vega-Lite
|
||||||
|
* applies to guide labels, text marks, and tooltips. The values are d3-format /
|
||||||
|
* d3-time-format strings; each field is free text with quick-fill presets for
|
||||||
|
* the common patterns, since the pattern space is open (the JSON stays the way
|
||||||
|
* to write any string). Each writes a minimal diff via the shared setter.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { JsonObject } from '@core/spec-config';
|
||||||
|
import { asString, type ConfigPath, countSet, getConfigValue } from '@core/theme-controls';
|
||||||
|
import { useConfigSetter } from '../stores/CustomThemeStore';
|
||||||
|
import { Button } from './Button';
|
||||||
|
import { Accordion, type AccordionSection, TextRow } from './ThemeFields';
|
||||||
|
import styles from './ThemeFields.module.css';
|
||||||
|
|
||||||
|
const NUMBER: ConfigPath = ['numberFormat'];
|
||||||
|
const NORMALIZED: ConfigPath = ['normalizedNumberFormat'];
|
||||||
|
const TIME: ConfigPath = ['timeFormat'];
|
||||||
|
const COUNT_TITLE: ConfigPath = ['countTitle'];
|
||||||
|
|
||||||
|
interface Preset {
|
||||||
|
/** The d3 pattern written into the config. */
|
||||||
|
code: string;
|
||||||
|
/** A rendered example, shown as the chip label. */
|
||||||
|
sample: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NUMBER_PRESETS: Preset[] = [
|
||||||
|
{ code: ',', sample: '1,234' },
|
||||||
|
{ code: '$,.2f', sample: '$1,234.00' },
|
||||||
|
{ code: '.0%', sample: '12%' },
|
||||||
|
{ code: '.2s', sample: '1.2k' },
|
||||||
|
{ code: '+,', sample: '+1,234' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TIME_PRESETS: Preset[] = [
|
||||||
|
{ code: '%b %Y', sample: 'Jan 2026' },
|
||||||
|
{ code: '%Y-%m-%d', sample: '2026-01-01' },
|
||||||
|
{ code: '%b %-d', sample: 'Jan 1' },
|
||||||
|
{ code: '%-d %b %Y', sample: '1 Jan 2026' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** A row of quick-fill chips that write a preset pattern into a config key. */
|
||||||
|
function Presets({ presets, onPick }: { presets: Preset[]; onPick: (code: string) => void }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.presets}>
|
||||||
|
{presets.map((p) => (
|
||||||
|
<Button key={p.code} variant="ghost" onClick={() => onPick(p.code)} title={p.code}>
|
||||||
|
{p.sample}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormatControls({ config }: { config: JsonObject }) {
|
||||||
|
const set = useConfigSetter();
|
||||||
|
|
||||||
|
const sections: AccordionSection[] = [
|
||||||
|
{
|
||||||
|
id: 'numbers',
|
||||||
|
title: 'Numbers',
|
||||||
|
hint: 'd3-format pattern for labels, text, and tooltips.',
|
||||||
|
badge: countSet(config, [NUMBER, NORMALIZED]),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<TextRow
|
||||||
|
label="Format"
|
||||||
|
name="Number format"
|
||||||
|
value={asString(getConfigValue(config, NUMBER))}
|
||||||
|
placeholder="e.g. $,.2f"
|
||||||
|
onChange={(v) => set(NUMBER, v)}
|
||||||
|
/>
|
||||||
|
<Presets presets={NUMBER_PRESETS} onPick={(code) => set(NUMBER, code)} />
|
||||||
|
<TextRow
|
||||||
|
label="Stacked %"
|
||||||
|
name="Normalized number format"
|
||||||
|
value={asString(getConfigValue(config, NORMALIZED))}
|
||||||
|
placeholder="e.g. .0%"
|
||||||
|
onChange={(v) => set(NORMALIZED, v)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dates',
|
||||||
|
title: 'Dates',
|
||||||
|
hint: 'd3-time-format pattern for raw time values.',
|
||||||
|
badge: countSet(config, [TIME]),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<TextRow
|
||||||
|
label="Format"
|
||||||
|
name="Date format"
|
||||||
|
value={asString(getConfigValue(config, TIME))}
|
||||||
|
placeholder="e.g. %b %Y"
|
||||||
|
onChange={(v) => set(TIME, v)}
|
||||||
|
/>
|
||||||
|
<Presets presets={TIME_PRESETS} onPick={(code) => set(TIME, code)} />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'labels',
|
||||||
|
title: 'Labels',
|
||||||
|
badge: countSet(config, [COUNT_TITLE]),
|
||||||
|
children: (
|
||||||
|
<TextRow
|
||||||
|
label="Count title"
|
||||||
|
name="Count title"
|
||||||
|
value={asString(getConfigValue(config, COUNT_TITLE))}
|
||||||
|
placeholder="Count of Records"
|
||||||
|
onChange={(v) => set(COUNT_TITLE, v)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return <Accordion sections={sections} idPrefix="formats" />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* Theme Builder — Headers panel (docs/chart-theming-scope.md §5).
|
||||||
|
*
|
||||||
|
* The base facet-header config (`config.header.*`) — the titles and labels that
|
||||||
|
* caption each panel of a faceted chart (the `row`/`column`/`facet` channels).
|
||||||
|
* The per-position variants (`headerRow`, `headerColumn`, `headerFacet`) stay in
|
||||||
|
* the JSON; this is the common surface a brand tunes. Each control writes a
|
||||||
|
* minimal diff through the shared setter.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { JsonObject } from '@core/spec-config';
|
||||||
|
import {
|
||||||
|
asNumber,
|
||||||
|
asString,
|
||||||
|
type ConfigPath,
|
||||||
|
countSet,
|
||||||
|
getConfigValue,
|
||||||
|
} from '@core/theme-controls';
|
||||||
|
import { useConfigSetter } from '../stores/CustomThemeStore';
|
||||||
|
import { Accordion, type AccordionSection, ColorRow, NumberRow, WeightRow } from './ThemeFields';
|
||||||
|
|
||||||
|
const TEXT_GREY = '#888888';
|
||||||
|
|
||||||
|
const TITLE_COLOR: ConfigPath = ['header', 'titleColor'];
|
||||||
|
const TITLE_SIZE: ConfigPath = ['header', 'titleFontSize'];
|
||||||
|
const TITLE_WEIGHT: ConfigPath = ['header', 'titleFontWeight'];
|
||||||
|
const LABEL_COLOR: ConfigPath = ['header', 'labelColor'];
|
||||||
|
const LABEL_SIZE: ConfigPath = ['header', 'labelFontSize'];
|
||||||
|
const LABEL_WEIGHT: ConfigPath = ['header', 'labelFontWeight'];
|
||||||
|
|
||||||
|
export function HeaderControls({ config }: { config: JsonObject }) {
|
||||||
|
const set = useConfigSetter();
|
||||||
|
|
||||||
|
const sections: AccordionSection[] = [
|
||||||
|
{
|
||||||
|
id: 'titles',
|
||||||
|
title: 'Facet titles',
|
||||||
|
hint: "The caption naming each facet's field.",
|
||||||
|
badge: countSet(config, [TITLE_COLOR, TITLE_SIZE, TITLE_WEIGHT]),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<ColorRow
|
||||||
|
label="Color"
|
||||||
|
name="Header title color"
|
||||||
|
value={asString(getConfigValue(config, TITLE_COLOR))}
|
||||||
|
fallback={TEXT_GREY}
|
||||||
|
onChange={(hex) => set(TITLE_COLOR, hex)}
|
||||||
|
onClear={() => set(TITLE_COLOR, undefined)}
|
||||||
|
/>
|
||||||
|
<NumberRow
|
||||||
|
label="Size"
|
||||||
|
name="Header title size"
|
||||||
|
value={asNumber(getConfigValue(config, TITLE_SIZE))}
|
||||||
|
min={0}
|
||||||
|
unit="px"
|
||||||
|
onChange={(n) => set(TITLE_SIZE, n)}
|
||||||
|
/>
|
||||||
|
<WeightRow
|
||||||
|
id="header-title-weight"
|
||||||
|
label="Weight"
|
||||||
|
name="Header title weight"
|
||||||
|
value={getConfigValue(config, TITLE_WEIGHT)}
|
||||||
|
onChange={(w) => set(TITLE_WEIGHT, w)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'labels',
|
||||||
|
title: 'Facet labels',
|
||||||
|
hint: 'The per-panel value labels.',
|
||||||
|
badge: countSet(config, [LABEL_COLOR, LABEL_SIZE, LABEL_WEIGHT]),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<ColorRow
|
||||||
|
label="Color"
|
||||||
|
name="Header label color"
|
||||||
|
value={asString(getConfigValue(config, LABEL_COLOR))}
|
||||||
|
fallback={TEXT_GREY}
|
||||||
|
onChange={(hex) => set(LABEL_COLOR, hex)}
|
||||||
|
onClear={() => set(LABEL_COLOR, undefined)}
|
||||||
|
/>
|
||||||
|
<NumberRow
|
||||||
|
label="Size"
|
||||||
|
name="Header label size"
|
||||||
|
value={asNumber(getConfigValue(config, LABEL_SIZE))}
|
||||||
|
min={0}
|
||||||
|
unit="px"
|
||||||
|
onChange={(n) => set(LABEL_SIZE, n)}
|
||||||
|
/>
|
||||||
|
<WeightRow
|
||||||
|
id="header-label-weight"
|
||||||
|
label="Weight"
|
||||||
|
name="Header label weight"
|
||||||
|
value={getConfigValue(config, LABEL_WEIGHT)}
|
||||||
|
onChange={(w) => set(LABEL_WEIGHT, w)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return <Accordion sections={sections} idPrefix="headers" />;
|
||||||
|
}
|
||||||
@@ -2,18 +2,25 @@
|
|||||||
* Theme Builder — Layout panel (docs/chart-theming-scope.md §5).
|
* Theme Builder — Layout panel (docs/chart-theming-scope.md §5).
|
||||||
*
|
*
|
||||||
* Structured controls over the draft config's surfaces and spacing: the chart
|
* Structured controls over the draft config's surfaces and spacing: the chart
|
||||||
* `background`, the plot area's `view` fill / border / corner radius, and outer
|
* `background`, the plot area's `view` fill / border / corner radius, outer
|
||||||
* `padding`. Fill controls are tri-state (theme default · transparent/none ·
|
* `padding`, and the default chart size. Fill controls are tri-state (theme
|
||||||
* custom color) because "transparent" is a meaningful, distinct choice from
|
* default · transparent/none · custom color) because "transparent" is a
|
||||||
* "unset" here — the house style sets `background` and `view.stroke` to
|
* meaningful, distinct choice from "unset" here — the house style sets
|
||||||
* transparent, and stock Vega-Lite defaults them to white / `#ddd`.
|
* `background` and `view.stroke` to transparent, and stock Vega-Lite defaults
|
||||||
|
* them to white / `#ddd`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { JsonObject } from '@core/spec-config';
|
import type { JsonObject } from '@core/spec-config';
|
||||||
import { isJsonObject } from '@core/spec-config';
|
import { isJsonObject } from '@core/spec-config';
|
||||||
import { asNumber, asString, type ConfigPath, getConfigValue } from '@core/theme-controls';
|
import {
|
||||||
|
asNumber,
|
||||||
|
asString,
|
||||||
|
type ConfigPath,
|
||||||
|
countSet,
|
||||||
|
getConfigValue,
|
||||||
|
} from '@core/theme-controls';
|
||||||
import { useConfigSetter } from '../stores/CustomThemeStore';
|
import { useConfigSetter } from '../stores/CustomThemeStore';
|
||||||
import { ColorRow, ControlSection, NumberRow, SelectRow } from './ThemeFields';
|
import { Accordion, type AccordionSection, ColorRow, NumberRow, SelectRow } from './ThemeFields';
|
||||||
import type { SelectControlOption } from './SelectControl';
|
import type { SelectControlOption } from './SelectControl';
|
||||||
import styles from './ThemeFields.module.css';
|
import styles from './ThemeFields.module.css';
|
||||||
|
|
||||||
@@ -22,6 +29,9 @@ const VIEW_FILL: ConfigPath = ['view', 'fill'];
|
|||||||
const VIEW_STROKE: ConfigPath = ['view', 'stroke'];
|
const VIEW_STROKE: ConfigPath = ['view', 'stroke'];
|
||||||
const VIEW_RADIUS: ConfigPath = ['view', 'cornerRadius'];
|
const VIEW_RADIUS: ConfigPath = ['view', 'cornerRadius'];
|
||||||
const PADDING: ConfigPath = ['padding'];
|
const PADDING: ConfigPath = ['padding'];
|
||||||
|
const VIEW_WIDTH: ConfigPath = ['view', 'continuousWidth'];
|
||||||
|
const VIEW_HEIGHT: ConfigPath = ['view', 'continuousHeight'];
|
||||||
|
const VIEW_STEP: ConfigPath = ['view', 'step'];
|
||||||
|
|
||||||
type FillMode = 'default' | 'transparent' | 'custom';
|
type FillMode = 'default' | 'transparent' | 'custom';
|
||||||
|
|
||||||
@@ -48,86 +58,134 @@ export function LayoutControls({ config }: { config: JsonObject }) {
|
|||||||
|
|
||||||
const bg = asString(getConfigValue(config, BACKGROUND));
|
const bg = asString(getConfigValue(config, BACKGROUND));
|
||||||
const bgMode = fillMode(getConfigValue(config, BACKGROUND));
|
const bgMode = fillMode(getConfigValue(config, BACKGROUND));
|
||||||
|
|
||||||
const viewFill = asString(getConfigValue(config, VIEW_FILL));
|
const viewFill = asString(getConfigValue(config, VIEW_FILL));
|
||||||
|
|
||||||
const stroke = asString(getConfigValue(config, VIEW_STROKE));
|
const stroke = asString(getConfigValue(config, VIEW_STROKE));
|
||||||
const strokeMode = fillMode(getConfigValue(config, VIEW_STROKE));
|
const strokeMode = fillMode(getConfigValue(config, VIEW_STROKE));
|
||||||
|
|
||||||
const radius = asNumber(getConfigValue(config, VIEW_RADIUS));
|
const radius = asNumber(getConfigValue(config, VIEW_RADIUS));
|
||||||
|
|
||||||
const padding = getConfigValue(config, PADDING);
|
const padding = getConfigValue(config, PADDING);
|
||||||
const paddingNum = asNumber(padding);
|
const paddingNum = asNumber(padding);
|
||||||
|
|
||||||
return (
|
const sections: AccordionSection[] = [
|
||||||
<div className={styles.panel}>
|
{
|
||||||
<ControlSection title="Background" hint="Fill behind the whole chart, padding included.">
|
id: 'background',
|
||||||
<SelectRow
|
title: 'Background',
|
||||||
id="layout-bg-mode"
|
hint: 'Fill behind the whole chart, padding included.',
|
||||||
label="Background"
|
badge: countSet(config, [BACKGROUND]),
|
||||||
options={bgOptions}
|
children: (
|
||||||
value={bgMode}
|
<>
|
||||||
onSelect={(m) => set(BACKGROUND, fillValue(m, bg, '#ffffff'))}
|
<SelectRow
|
||||||
/>
|
id="layout-bg-mode"
|
||||||
{bgMode === 'custom' && (
|
label="Background"
|
||||||
|
options={bgOptions}
|
||||||
|
value={bgMode}
|
||||||
|
onSelect={(m) => set(BACKGROUND, fillValue(m, bg, '#ffffff'))}
|
||||||
|
/>
|
||||||
|
{bgMode === 'custom' && (
|
||||||
|
<ColorRow
|
||||||
|
label="Color"
|
||||||
|
name="Background color"
|
||||||
|
value={bg}
|
||||||
|
fallback="#ffffff"
|
||||||
|
onChange={(hex) => set(BACKGROUND, hex)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'plot',
|
||||||
|
title: 'Plot area',
|
||||||
|
hint: 'The plotting rectangle inside the axes.',
|
||||||
|
badge: countSet(config, [VIEW_FILL, VIEW_STROKE, VIEW_RADIUS]),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
<ColorRow
|
<ColorRow
|
||||||
label="Color"
|
label="Fill"
|
||||||
name="Background color"
|
name="Plot area fill"
|
||||||
value={bg}
|
value={viewFill}
|
||||||
fallback="#ffffff"
|
fallback="#ffffff"
|
||||||
onChange={(hex) => set(BACKGROUND, hex)}
|
onChange={(hex) => set(VIEW_FILL, hex)}
|
||||||
|
onClear={() => set(VIEW_FILL, undefined)}
|
||||||
/>
|
/>
|
||||||
)}
|
<SelectRow
|
||||||
</ControlSection>
|
id="layout-stroke-mode"
|
||||||
|
label="Border"
|
||||||
<ControlSection title="Plot area" hint="The plotting rectangle inside the axes.">
|
options={strokeOptions}
|
||||||
<ColorRow
|
value={strokeMode}
|
||||||
label="Fill"
|
onSelect={(m) => set(VIEW_STROKE, fillValue(m, stroke, '#cccccc'))}
|
||||||
name="Plot area fill"
|
|
||||||
value={viewFill}
|
|
||||||
fallback="#ffffff"
|
|
||||||
onChange={(hex) => set(VIEW_FILL, hex)}
|
|
||||||
onClear={() => set(VIEW_FILL, undefined)}
|
|
||||||
/>
|
|
||||||
<SelectRow
|
|
||||||
id="layout-stroke-mode"
|
|
||||||
label="Border"
|
|
||||||
options={strokeOptions}
|
|
||||||
value={strokeMode}
|
|
||||||
onSelect={(m) => set(VIEW_STROKE, fillValue(m, stroke, '#cccccc'))}
|
|
||||||
/>
|
|
||||||
{strokeMode === 'custom' && (
|
|
||||||
<ColorRow
|
|
||||||
label="Border color"
|
|
||||||
value={stroke}
|
|
||||||
fallback="#cccccc"
|
|
||||||
onChange={(hex) => set(VIEW_STROKE, hex)}
|
|
||||||
/>
|
/>
|
||||||
)}
|
{strokeMode === 'custom' && (
|
||||||
<NumberRow
|
<ColorRow
|
||||||
label="Corner radius"
|
label="Border color"
|
||||||
value={radius}
|
value={stroke}
|
||||||
min={0}
|
fallback="#cccccc"
|
||||||
unit="px"
|
onChange={(hex) => set(VIEW_STROKE, hex)}
|
||||||
onChange={(n) => set(VIEW_RADIUS, n)}
|
/>
|
||||||
/>
|
)}
|
||||||
</ControlSection>
|
|
||||||
|
|
||||||
<ControlSection title="Spacing" hint="Margin between the chart and its container edge.">
|
|
||||||
{isJsonObject(padding) ? (
|
|
||||||
<p className={styles.hint}>
|
|
||||||
Padding is set per-side as an object — edit it in the JSON below.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<NumberRow
|
<NumberRow
|
||||||
label="Padding"
|
label="Corner radius"
|
||||||
value={paddingNum}
|
value={radius}
|
||||||
min={0}
|
min={0}
|
||||||
unit="px"
|
unit="px"
|
||||||
onChange={(n) => set(PADDING, n)}
|
onChange={(n) => set(VIEW_RADIUS, n)}
|
||||||
/>
|
/>
|
||||||
)}
|
</>
|
||||||
</ControlSection>
|
),
|
||||||
</div>
|
},
|
||||||
);
|
{
|
||||||
|
id: 'spacing',
|
||||||
|
title: 'Spacing',
|
||||||
|
hint: 'Margin between the chart and its container edge.',
|
||||||
|
badge: countSet(config, [PADDING]),
|
||||||
|
children: isJsonObject(padding) ? (
|
||||||
|
<p className={styles.hint}>
|
||||||
|
Padding is set per-side as an object — edit it in the JSON below.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<NumberRow
|
||||||
|
label="Padding"
|
||||||
|
value={paddingNum}
|
||||||
|
min={0}
|
||||||
|
unit="px"
|
||||||
|
onChange={(n) => set(PADDING, n)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'size',
|
||||||
|
title: 'Default size',
|
||||||
|
hint: "The plot size when a spec doesn't set its own width/height.",
|
||||||
|
badge: countSet(config, [VIEW_WIDTH, VIEW_HEIGHT, VIEW_STEP]),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<NumberRow
|
||||||
|
label="Width"
|
||||||
|
name="Default width"
|
||||||
|
value={asNumber(getConfigValue(config, VIEW_WIDTH))}
|
||||||
|
min={0}
|
||||||
|
unit="px"
|
||||||
|
onChange={(n) => set(VIEW_WIDTH, n)}
|
||||||
|
/>
|
||||||
|
<NumberRow
|
||||||
|
label="Height"
|
||||||
|
name="Default height"
|
||||||
|
value={asNumber(getConfigValue(config, VIEW_HEIGHT))}
|
||||||
|
min={0}
|
||||||
|
unit="px"
|
||||||
|
onChange={(n) => set(VIEW_HEIGHT, n)}
|
||||||
|
/>
|
||||||
|
<NumberRow
|
||||||
|
label="Step"
|
||||||
|
name="Discrete step"
|
||||||
|
value={asNumber(getConfigValue(config, VIEW_STEP))}
|
||||||
|
min={0}
|
||||||
|
unit="px"
|
||||||
|
onChange={(n) => set(VIEW_STEP, n)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return <Accordion sections={sections} idPrefix="layout" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,40 @@
|
|||||||
/**
|
/**
|
||||||
* Theme Builder — Legend panel (docs/chart-theming-scope.md §5).
|
* Theme Builder — Legend panel (docs/chart-theming-scope.md §5).
|
||||||
*
|
*
|
||||||
* Structured controls over the base `legend` config: placement (`orient`), the
|
* Structured controls over the base `legend` config: placement, the title's and
|
||||||
* title's and labels' color and size, and the symbol size. Legend type (size)
|
* labels' colour and size, the symbol keys, the continuous gradient bar, and an
|
||||||
* lives here rather than the Type panel so every legend property a brand tunes
|
* optional box behind the legend. Legend type (size) lives here rather than the
|
||||||
* sits together — the scope's "label/title color+size" grouping.
|
* Type panel so every legend property a brand tunes sits together.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { JsonObject } from '@core/spec-config';
|
import type { JsonObject } from '@core/spec-config';
|
||||||
import { asNumber, asString, type ConfigPath, getConfigValue } from '@core/theme-controls';
|
import {
|
||||||
|
asNumber,
|
||||||
|
asString,
|
||||||
|
type ConfigPath,
|
||||||
|
countSet,
|
||||||
|
enumValue,
|
||||||
|
getConfigValue,
|
||||||
|
} from '@core/theme-controls';
|
||||||
import { useConfigSetter } from '../stores/CustomThemeStore';
|
import { useConfigSetter } from '../stores/CustomThemeStore';
|
||||||
import { ColorRow, ControlSection, NumberRow, SelectRow } from './ThemeFields';
|
import { Accordion, type AccordionSection, ColorRow, NumberRow, SelectRow } from './ThemeFields';
|
||||||
import type { SelectControlOption } from './SelectControl';
|
import type { SelectControlOption } from './SelectControl';
|
||||||
import styles from './ThemeFields.module.css';
|
|
||||||
|
|
||||||
const ORIENT: ConfigPath = ['legend', 'orient'];
|
const ORIENT: ConfigPath = ['legend', 'orient'];
|
||||||
|
const DIRECTION: ConfigPath = ['legend', 'direction'];
|
||||||
|
const COLUMNS: ConfigPath = ['legend', 'columns'];
|
||||||
const TITLE_COLOR: ConfigPath = ['legend', 'titleColor'];
|
const TITLE_COLOR: ConfigPath = ['legend', 'titleColor'];
|
||||||
const TITLE_SIZE: ConfigPath = ['legend', 'titleFontSize'];
|
const TITLE_SIZE: ConfigPath = ['legend', 'titleFontSize'];
|
||||||
const LABEL_COLOR: ConfigPath = ['legend', 'labelColor'];
|
const LABEL_COLOR: ConfigPath = ['legend', 'labelColor'];
|
||||||
const LABEL_SIZE: ConfigPath = ['legend', 'labelFontSize'];
|
const LABEL_SIZE: ConfigPath = ['legend', 'labelFontSize'];
|
||||||
const SYMBOL_SIZE: ConfigPath = ['legend', 'symbolSize'];
|
const SYMBOL_SIZE: ConfigPath = ['legend', 'symbolSize'];
|
||||||
|
const SYMBOL_TYPE: ConfigPath = ['legend', 'symbolType'];
|
||||||
|
const GRADIENT_LENGTH: ConfigPath = ['legend', 'gradientLength'];
|
||||||
|
const GRADIENT_THICKNESS: ConfigPath = ['legend', 'gradientThickness'];
|
||||||
|
const FILL_COLOR: ConfigPath = ['legend', 'fillColor'];
|
||||||
|
const STROKE_COLOR: ConfigPath = ['legend', 'strokeColor'];
|
||||||
|
const CORNER_RADIUS: ConfigPath = ['legend', 'cornerRadius'];
|
||||||
|
const PADDING: ConfigPath = ['legend', 'padding'];
|
||||||
|
|
||||||
const TEXT_GREY = '#888888';
|
const TEXT_GREY = '#888888';
|
||||||
|
|
||||||
@@ -35,80 +50,205 @@ const orientOptions: SelectControlOption<Orient>[] = [
|
|||||||
{ value: 'top-right', label: 'Top-right' },
|
{ value: 'top-right', label: 'Top-right' },
|
||||||
{ value: 'none', label: 'Hidden' },
|
{ value: 'none', label: 'Hidden' },
|
||||||
];
|
];
|
||||||
const orientValue = (v: unknown): Orient => {
|
|
||||||
const s = asString(v);
|
type Direction = '' | 'horizontal' | 'vertical';
|
||||||
return s !== undefined && orientOptions.some((o) => o.value === s) ? (s as Orient) : '';
|
const directionOptions: SelectControlOption<Direction>[] = [
|
||||||
};
|
{ value: '', label: 'Theme default' },
|
||||||
|
{ value: 'vertical', label: 'Vertical' },
|
||||||
|
{ value: 'horizontal', label: 'Horizontal' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Discrete-legend key shapes (continuous legends render a gradient bar instead).
|
||||||
|
type SymbolType = '' | 'circle' | 'square' | 'cross' | 'diamond' | 'triangle-up' | 'stroke';
|
||||||
|
const symbolTypeOptions: SelectControlOption<SymbolType>[] = [
|
||||||
|
{ value: '', label: 'Theme default' },
|
||||||
|
{ value: 'circle', label: 'Circle' },
|
||||||
|
{ value: 'square', label: 'Square' },
|
||||||
|
{ value: 'cross', label: 'Cross' },
|
||||||
|
{ value: 'diamond', label: 'Diamond' },
|
||||||
|
{ value: 'triangle-up', label: 'Triangle' },
|
||||||
|
{ value: 'stroke', label: 'Line' },
|
||||||
|
];
|
||||||
|
|
||||||
export function LegendControls({ config }: { config: JsonObject }) {
|
export function LegendControls({ config }: { config: JsonObject }) {
|
||||||
const set = useConfigSetter();
|
const set = useConfigSetter();
|
||||||
|
|
||||||
const orient = orientValue(getConfigValue(config, ORIENT));
|
const sections: AccordionSection[] = [
|
||||||
const titleColor = asString(getConfigValue(config, TITLE_COLOR));
|
{
|
||||||
const titleSize = asNumber(getConfigValue(config, TITLE_SIZE));
|
id: 'placement',
|
||||||
const labelColor = asString(getConfigValue(config, LABEL_COLOR));
|
title: 'Placement',
|
||||||
const labelSize = asNumber(getConfigValue(config, LABEL_SIZE));
|
hint: 'Where the legend sits relative to the plot.',
|
||||||
const symbolSize = asNumber(getConfigValue(config, SYMBOL_SIZE));
|
badge: countSet(config, [ORIENT, DIRECTION, COLUMNS]),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<SelectRow
|
||||||
|
id="legend-orient"
|
||||||
|
label="Position"
|
||||||
|
options={orientOptions}
|
||||||
|
value={enumValue(getConfigValue(config, ORIENT), orientOptions)}
|
||||||
|
onSelect={(o) => set(ORIENT, o === '' ? undefined : o)}
|
||||||
|
/>
|
||||||
|
<SelectRow
|
||||||
|
id="legend-direction"
|
||||||
|
label="Direction"
|
||||||
|
options={directionOptions}
|
||||||
|
value={enumValue(getConfigValue(config, DIRECTION), directionOptions)}
|
||||||
|
onSelect={(d) => set(DIRECTION, d === '' ? undefined : d)}
|
||||||
|
/>
|
||||||
|
<NumberRow
|
||||||
|
label="Columns"
|
||||||
|
name="Legend columns"
|
||||||
|
value={asNumber(getConfigValue(config, COLUMNS))}
|
||||||
|
min={0}
|
||||||
|
onChange={(n) => set(COLUMNS, n)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'title',
|
||||||
|
title: 'Title',
|
||||||
|
badge: countSet(config, [TITLE_COLOR, TITLE_SIZE]),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<ColorRow
|
||||||
|
label="Color"
|
||||||
|
name="Legend title color"
|
||||||
|
value={asString(getConfigValue(config, TITLE_COLOR))}
|
||||||
|
fallback={TEXT_GREY}
|
||||||
|
onChange={(hex) => set(TITLE_COLOR, hex)}
|
||||||
|
onClear={() => set(TITLE_COLOR, undefined)}
|
||||||
|
/>
|
||||||
|
<NumberRow
|
||||||
|
label="Size"
|
||||||
|
name="Legend title size"
|
||||||
|
value={asNumber(getConfigValue(config, TITLE_SIZE))}
|
||||||
|
min={0}
|
||||||
|
unit="px"
|
||||||
|
onChange={(n) => set(TITLE_SIZE, n)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'labels',
|
||||||
|
title: 'Labels',
|
||||||
|
badge: countSet(config, [LABEL_COLOR, LABEL_SIZE]),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<ColorRow
|
||||||
|
label="Color"
|
||||||
|
name="Legend label color"
|
||||||
|
value={asString(getConfigValue(config, LABEL_COLOR))}
|
||||||
|
fallback={TEXT_GREY}
|
||||||
|
onChange={(hex) => set(LABEL_COLOR, hex)}
|
||||||
|
onClear={() => set(LABEL_COLOR, undefined)}
|
||||||
|
/>
|
||||||
|
<NumberRow
|
||||||
|
label="Size"
|
||||||
|
name="Legend label size"
|
||||||
|
value={asNumber(getConfigValue(config, LABEL_SIZE))}
|
||||||
|
min={0}
|
||||||
|
unit="px"
|
||||||
|
onChange={(n) => set(LABEL_SIZE, n)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'symbols',
|
||||||
|
title: 'Symbols',
|
||||||
|
hint: 'The colored keys beside each label.',
|
||||||
|
badge: countSet(config, [SYMBOL_TYPE, SYMBOL_SIZE]),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<SelectRow
|
||||||
|
id="legend-symbol-type"
|
||||||
|
label="Shape"
|
||||||
|
name="Legend symbol shape"
|
||||||
|
options={symbolTypeOptions}
|
||||||
|
value={enumValue(getConfigValue(config, SYMBOL_TYPE), symbolTypeOptions)}
|
||||||
|
onSelect={(t) => set(SYMBOL_TYPE, t === '' ? undefined : t)}
|
||||||
|
/>
|
||||||
|
<NumberRow
|
||||||
|
label="Symbol size"
|
||||||
|
value={asNumber(getConfigValue(config, SYMBOL_SIZE))}
|
||||||
|
min={0}
|
||||||
|
unit="px²"
|
||||||
|
onChange={(n) => set(SYMBOL_SIZE, n)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gradient',
|
||||||
|
title: 'Gradient',
|
||||||
|
hint: 'The continuous color bar (quantitative legends).',
|
||||||
|
badge: countSet(config, [GRADIENT_LENGTH, GRADIENT_THICKNESS]),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<NumberRow
|
||||||
|
label="Length"
|
||||||
|
name="Gradient length"
|
||||||
|
value={asNumber(getConfigValue(config, GRADIENT_LENGTH))}
|
||||||
|
min={0}
|
||||||
|
unit="px"
|
||||||
|
onChange={(n) => set(GRADIENT_LENGTH, n)}
|
||||||
|
/>
|
||||||
|
<NumberRow
|
||||||
|
label="Thickness"
|
||||||
|
name="Gradient thickness"
|
||||||
|
value={asNumber(getConfigValue(config, GRADIENT_THICKNESS))}
|
||||||
|
min={0}
|
||||||
|
unit="px"
|
||||||
|
onChange={(n) => set(GRADIENT_THICKNESS, n)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'box',
|
||||||
|
title: 'Box',
|
||||||
|
hint: 'An optional filled/bordered panel behind the legend.',
|
||||||
|
badge: countSet(config, [FILL_COLOR, STROKE_COLOR, CORNER_RADIUS, PADDING]),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<ColorRow
|
||||||
|
label="Fill"
|
||||||
|
name="Legend fill"
|
||||||
|
value={asString(getConfigValue(config, FILL_COLOR))}
|
||||||
|
fallback="#ffffff"
|
||||||
|
onChange={(hex) => set(FILL_COLOR, hex)}
|
||||||
|
onClear={() => set(FILL_COLOR, undefined)}
|
||||||
|
/>
|
||||||
|
<ColorRow
|
||||||
|
label="Border"
|
||||||
|
name="Legend border"
|
||||||
|
value={asString(getConfigValue(config, STROKE_COLOR))}
|
||||||
|
fallback="#cccccc"
|
||||||
|
onChange={(hex) => set(STROKE_COLOR, hex)}
|
||||||
|
onClear={() => set(STROKE_COLOR, undefined)}
|
||||||
|
/>
|
||||||
|
<NumberRow
|
||||||
|
label="Corner radius"
|
||||||
|
name="Legend corner radius"
|
||||||
|
value={asNumber(getConfigValue(config, CORNER_RADIUS))}
|
||||||
|
min={0}
|
||||||
|
unit="px"
|
||||||
|
onChange={(n) => set(CORNER_RADIUS, n)}
|
||||||
|
/>
|
||||||
|
<NumberRow
|
||||||
|
label="Padding"
|
||||||
|
name="Legend padding"
|
||||||
|
value={asNumber(getConfigValue(config, PADDING))}
|
||||||
|
min={0}
|
||||||
|
unit="px"
|
||||||
|
onChange={(n) => set(PADDING, n)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return <Accordion sections={sections} idPrefix="legend" />;
|
||||||
<div className={styles.panel}>
|
|
||||||
<ControlSection title="Placement" hint="Where the legend sits relative to the plot.">
|
|
||||||
<SelectRow
|
|
||||||
id="legend-orient"
|
|
||||||
label="Position"
|
|
||||||
options={orientOptions}
|
|
||||||
value={orient}
|
|
||||||
onSelect={(o) => set(ORIENT, o === '' ? undefined : o)}
|
|
||||||
/>
|
|
||||||
</ControlSection>
|
|
||||||
|
|
||||||
<ControlSection title="Title">
|
|
||||||
<ColorRow
|
|
||||||
label="Color"
|
|
||||||
name="Legend title color"
|
|
||||||
value={titleColor}
|
|
||||||
fallback={TEXT_GREY}
|
|
||||||
onChange={(hex) => set(TITLE_COLOR, hex)}
|
|
||||||
onClear={() => set(TITLE_COLOR, undefined)}
|
|
||||||
/>
|
|
||||||
<NumberRow
|
|
||||||
label="Size"
|
|
||||||
name="Legend title size"
|
|
||||||
value={titleSize}
|
|
||||||
min={0}
|
|
||||||
unit="px"
|
|
||||||
onChange={(n) => set(TITLE_SIZE, n)}
|
|
||||||
/>
|
|
||||||
</ControlSection>
|
|
||||||
|
|
||||||
<ControlSection title="Labels">
|
|
||||||
<ColorRow
|
|
||||||
label="Color"
|
|
||||||
name="Legend label color"
|
|
||||||
value={labelColor}
|
|
||||||
fallback={TEXT_GREY}
|
|
||||||
onChange={(hex) => set(LABEL_COLOR, hex)}
|
|
||||||
onClear={() => set(LABEL_COLOR, undefined)}
|
|
||||||
/>
|
|
||||||
<NumberRow
|
|
||||||
label="Size"
|
|
||||||
name="Legend label size"
|
|
||||||
value={labelSize}
|
|
||||||
min={0}
|
|
||||||
unit="px"
|
|
||||||
onChange={(n) => set(LABEL_SIZE, n)}
|
|
||||||
/>
|
|
||||||
</ControlSection>
|
|
||||||
|
|
||||||
<ControlSection title="Symbols" hint="The colored keys beside each label.">
|
|
||||||
<NumberRow
|
|
||||||
label="Symbol size"
|
|
||||||
value={symbolSize}
|
|
||||||
min={0}
|
|
||||||
unit="px²"
|
|
||||||
onChange={(n) => set(SYMBOL_SIZE, n)}
|
|
||||||
/>
|
|
||||||
</ControlSection>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,347 @@
|
|||||||
|
/**
|
||||||
|
* Theme Builder — Marks panel (docs/chart-theming-scope.md §5).
|
||||||
|
*
|
||||||
|
* The mark defaults a brand tunes, grouped by mark TYPE: writing
|
||||||
|
* `config.bar.cornerRadiusEnd` rounds only bars, where `config.mark.cornerRadius`
|
||||||
|
* would round everything — the honest brand model is per-type. The default
|
||||||
|
* single-series mark color stays in the Color panel (it sits with the palette);
|
||||||
|
* everything else mark-shaped lives here.
|
||||||
|
*
|
||||||
|
* Unlike the scalar panels, this one is an Accordion (one mark type open at a
|
||||||
|
* time): a brand styles one or two mark types, not all five, so the per-type
|
||||||
|
* groups are "not crucial to read in full" — Carbon's accordion rule (arch 10 →
|
||||||
|
* L2 grouping). Each control writes one config path through the shared
|
||||||
|
* `useConfigSetter`, deleting the key (and pruning the emptied per-type object)
|
||||||
|
* when cleared, so a theme stays a minimal diff against stock.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { JsonObject } from '@core/spec-config';
|
||||||
|
import {
|
||||||
|
asBoolean,
|
||||||
|
asNumber,
|
||||||
|
type ConfigPath,
|
||||||
|
countSet,
|
||||||
|
enumValue,
|
||||||
|
getConfigValue,
|
||||||
|
} from '@core/theme-controls';
|
||||||
|
import { useConfigSetter } from '../stores/CustomThemeStore';
|
||||||
|
import { Accordion, type AccordionSection, NumberRow, SelectRow } from './ThemeFields';
|
||||||
|
import type { SelectControlOption } from './SelectControl';
|
||||||
|
|
||||||
|
// ── Enum option sets ────────────────────────────────────────────────────────
|
||||||
|
// '' is the unset (theme default) sentinel for every enum; a config value the
|
||||||
|
// control doesn't enumerate (a hand-edit) reads back as '' so the control shows
|
||||||
|
// the default rather than misreporting — same convention as the Axes panel.
|
||||||
|
|
||||||
|
type Interpolate =
|
||||||
|
| ''
|
||||||
|
| 'linear'
|
||||||
|
| 'monotone'
|
||||||
|
| 'basis'
|
||||||
|
| 'cardinal'
|
||||||
|
| 'natural'
|
||||||
|
| 'step'
|
||||||
|
| 'step-before'
|
||||||
|
| 'step-after';
|
||||||
|
const interpolateOptions: SelectControlOption<Interpolate>[] = [
|
||||||
|
{ value: '', label: 'Theme default' },
|
||||||
|
{ value: 'linear', label: 'Linear' },
|
||||||
|
{ value: 'monotone', label: 'Monotone' },
|
||||||
|
{ value: 'basis', label: 'Basis (smooth)' },
|
||||||
|
{ value: 'cardinal', label: 'Cardinal' },
|
||||||
|
{ value: 'natural', label: 'Natural' },
|
||||||
|
{ value: 'step', label: 'Step' },
|
||||||
|
{ value: 'step-before', label: 'Step before' },
|
||||||
|
{ value: 'step-after', label: 'Step after' },
|
||||||
|
];
|
||||||
|
|
||||||
|
type Shape = '' | 'circle' | 'square' | 'cross' | 'diamond' | 'triangle-up' | 'triangle-down';
|
||||||
|
const shapeOptions: SelectControlOption<Shape>[] = [
|
||||||
|
{ value: '', label: 'Theme default' },
|
||||||
|
{ value: 'circle', label: 'Circle' },
|
||||||
|
{ value: 'square', label: 'Square' },
|
||||||
|
{ value: 'cross', label: 'Cross' },
|
||||||
|
{ value: 'diamond', label: 'Diamond' },
|
||||||
|
{ value: 'triangle-up', label: 'Triangle up' },
|
||||||
|
{ value: 'triangle-down', label: 'Triangle down' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Boolean tri-state row ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A boolean config key as a tri-state select (theme default · true · false).
|
||||||
|
* "Unset" is a distinct, meaningful state from either boolean here — filled vs.
|
||||||
|
* outlined marks, tooltips on vs. off — so a plain checkbox (which can't express
|
||||||
|
* "inherit") would lie. Same shape as the Axes panel's grid-visibility control.
|
||||||
|
*/
|
||||||
|
function BoolRow({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
trueLabel,
|
||||||
|
falseLabel,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
value: boolean | undefined;
|
||||||
|
onChange: (value: boolean | undefined) => void;
|
||||||
|
trueLabel: string;
|
||||||
|
falseLabel: string;
|
||||||
|
}) {
|
||||||
|
type S = '' | 'true' | 'false';
|
||||||
|
const options: SelectControlOption<S>[] = [
|
||||||
|
{ value: '', label: 'Theme default' },
|
||||||
|
{ value: 'true', label: trueLabel },
|
||||||
|
{ value: 'false', label: falseLabel },
|
||||||
|
];
|
||||||
|
const s: S = value === undefined ? '' : value ? 'true' : 'false';
|
||||||
|
return (
|
||||||
|
<SelectRow
|
||||||
|
id={id}
|
||||||
|
label={label}
|
||||||
|
options={options}
|
||||||
|
value={s}
|
||||||
|
onSelect={(v) => onChange(v === '' ? undefined : v === 'true')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each section's badge counts how many of *its own* controls are set (the uniform
|
||||||
|
// `countSet` contract — "fields set here"). Listed explicitly, not `Object.keys`
|
||||||
|
// on the per-type object: `mark.color` lives under `mark` but is owned by the
|
||||||
|
// Color panel, so a key count would inflate the "All marks" badge.
|
||||||
|
const MARK_PATHS: ConfigPath[] = [
|
||||||
|
['mark', 'opacity'],
|
||||||
|
['mark', 'filled'],
|
||||||
|
['mark', 'tooltip'],
|
||||||
|
];
|
||||||
|
const BAR_PATHS: ConfigPath[] = [
|
||||||
|
['bar', 'cornerRadiusEnd'],
|
||||||
|
['bar', 'discreteBandSize'],
|
||||||
|
['bar', 'opacity'],
|
||||||
|
];
|
||||||
|
const LINE_AREA_PATHS: ConfigPath[] = [
|
||||||
|
['line', 'interpolate'],
|
||||||
|
['line', 'strokeWidth'],
|
||||||
|
['line', 'point'],
|
||||||
|
['area', 'opacity'],
|
||||||
|
['area', 'line'],
|
||||||
|
];
|
||||||
|
const POINT_PATHS: ConfigPath[] = [
|
||||||
|
['point', 'size'],
|
||||||
|
['point', 'shape'],
|
||||||
|
['point', 'filled'],
|
||||||
|
['point', 'opacity'],
|
||||||
|
];
|
||||||
|
const ARC_PATHS: ConfigPath[] = [
|
||||||
|
['arc', 'innerRadius'],
|
||||||
|
['arc', 'cornerRadius'],
|
||||||
|
['arc', 'padAngle'],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function MarksControls({ config }: { config: JsonObject }) {
|
||||||
|
const set = useConfigSetter();
|
||||||
|
|
||||||
|
const num = (path: ConfigPath) => asNumber(getConfigValue(config, path));
|
||||||
|
const bool = (path: ConfigPath) => asBoolean(getConfigValue(config, path));
|
||||||
|
|
||||||
|
const sections: AccordionSection[] = [
|
||||||
|
{
|
||||||
|
id: 'all',
|
||||||
|
title: 'All marks',
|
||||||
|
badge: countSet(config, MARK_PATHS),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<NumberRow
|
||||||
|
label="Opacity"
|
||||||
|
name="Mark opacity"
|
||||||
|
value={num(['mark', 'opacity'])}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.1}
|
||||||
|
onChange={(n) => set(['mark', 'opacity'], n)}
|
||||||
|
/>
|
||||||
|
<BoolRow
|
||||||
|
id="marks-filled"
|
||||||
|
label="Fill style"
|
||||||
|
value={bool(['mark', 'filled'])}
|
||||||
|
onChange={(b) => set(['mark', 'filled'], b)}
|
||||||
|
trueLabel="Filled"
|
||||||
|
falseLabel="Outlined"
|
||||||
|
/>
|
||||||
|
<BoolRow
|
||||||
|
id="marks-tooltip"
|
||||||
|
label="Tooltips"
|
||||||
|
value={bool(['mark', 'tooltip'])}
|
||||||
|
onChange={(b) => set(['mark', 'tooltip'], b)}
|
||||||
|
trueLabel="On"
|
||||||
|
falseLabel="Off"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bar',
|
||||||
|
title: 'Bars',
|
||||||
|
badge: countSet(config, BAR_PATHS),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<NumberRow
|
||||||
|
label="Corner radius"
|
||||||
|
name="Bar corner radius"
|
||||||
|
value={num(['bar', 'cornerRadiusEnd'])}
|
||||||
|
min={0}
|
||||||
|
unit="px"
|
||||||
|
onChange={(n) => set(['bar', 'cornerRadiusEnd'], n)}
|
||||||
|
/>
|
||||||
|
<NumberRow
|
||||||
|
label="Bar width"
|
||||||
|
name="Bar width"
|
||||||
|
// discreteBandSize = bar thickness on a categorical axis (the common
|
||||||
|
// case, and what the gallery's nominal-axis bars show); continuous-axis
|
||||||
|
// bars (continuousBandSize) stay in the JSON.
|
||||||
|
value={num(['bar', 'discreteBandSize'])}
|
||||||
|
min={0}
|
||||||
|
unit="px"
|
||||||
|
onChange={(n) => set(['bar', 'discreteBandSize'], n)}
|
||||||
|
/>
|
||||||
|
<NumberRow
|
||||||
|
label="Opacity"
|
||||||
|
name="Bar opacity"
|
||||||
|
value={num(['bar', 'opacity'])}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.1}
|
||||||
|
onChange={(n) => set(['bar', 'opacity'], n)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lineArea',
|
||||||
|
title: 'Lines & areas',
|
||||||
|
badge: countSet(config, LINE_AREA_PATHS),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<SelectRow
|
||||||
|
id="marks-line-interpolate"
|
||||||
|
label="Line curve"
|
||||||
|
options={interpolateOptions}
|
||||||
|
value={enumValue(getConfigValue(config, ['line', 'interpolate']), interpolateOptions)}
|
||||||
|
onSelect={(v) => set(['line', 'interpolate'], v === '' ? undefined : v)}
|
||||||
|
/>
|
||||||
|
<NumberRow
|
||||||
|
label="Line width"
|
||||||
|
name="Line width"
|
||||||
|
value={num(['line', 'strokeWidth'])}
|
||||||
|
min={0}
|
||||||
|
unit="px"
|
||||||
|
onChange={(n) => set(['line', 'strokeWidth'], n)}
|
||||||
|
/>
|
||||||
|
<BoolRow
|
||||||
|
id="marks-line-point"
|
||||||
|
label="Line markers"
|
||||||
|
value={bool(['line', 'point'])}
|
||||||
|
onChange={(b) => set(['line', 'point'], b)}
|
||||||
|
trueLabel="Show"
|
||||||
|
falseLabel="Hide"
|
||||||
|
/>
|
||||||
|
<NumberRow
|
||||||
|
label="Area opacity"
|
||||||
|
name="Area opacity"
|
||||||
|
value={num(['area', 'opacity'])}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.1}
|
||||||
|
onChange={(n) => set(['area', 'opacity'], n)}
|
||||||
|
/>
|
||||||
|
<BoolRow
|
||||||
|
id="marks-area-line"
|
||||||
|
label="Area outline"
|
||||||
|
value={bool(['area', 'line'])}
|
||||||
|
onChange={(b) => set(['area', 'line'], b)}
|
||||||
|
trueLabel="Show"
|
||||||
|
falseLabel="Hide"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'point',
|
||||||
|
title: 'Points',
|
||||||
|
badge: countSet(config, POINT_PATHS),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<NumberRow
|
||||||
|
label="Size"
|
||||||
|
name="Point size"
|
||||||
|
value={num(['point', 'size'])}
|
||||||
|
min={0}
|
||||||
|
onChange={(n) => set(['point', 'size'], n)}
|
||||||
|
/>
|
||||||
|
<SelectRow
|
||||||
|
id="marks-point-shape"
|
||||||
|
label="Shape"
|
||||||
|
name="Point shape"
|
||||||
|
options={shapeOptions}
|
||||||
|
value={enumValue(getConfigValue(config, ['point', 'shape']), shapeOptions)}
|
||||||
|
onSelect={(v) => set(['point', 'shape'], v === '' ? undefined : v)}
|
||||||
|
/>
|
||||||
|
<BoolRow
|
||||||
|
id="marks-point-filled"
|
||||||
|
label="Fill style"
|
||||||
|
value={bool(['point', 'filled'])}
|
||||||
|
onChange={(b) => set(['point', 'filled'], b)}
|
||||||
|
trueLabel="Filled"
|
||||||
|
falseLabel="Outlined"
|
||||||
|
/>
|
||||||
|
<NumberRow
|
||||||
|
label="Opacity"
|
||||||
|
name="Point opacity"
|
||||||
|
value={num(['point', 'opacity'])}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.1}
|
||||||
|
onChange={(n) => set(['point', 'opacity'], n)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'arc',
|
||||||
|
title: 'Arc (pie & donut)',
|
||||||
|
badge: countSet(config, ARC_PATHS),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<NumberRow
|
||||||
|
label="Donut hole"
|
||||||
|
name="Donut hole radius"
|
||||||
|
value={num(['arc', 'innerRadius'])}
|
||||||
|
min={0}
|
||||||
|
unit="px"
|
||||||
|
onChange={(n) => set(['arc', 'innerRadius'], n)}
|
||||||
|
/>
|
||||||
|
<NumberRow
|
||||||
|
label="Corner radius"
|
||||||
|
name="Arc corner radius"
|
||||||
|
value={num(['arc', 'cornerRadius'])}
|
||||||
|
min={0}
|
||||||
|
unit="px"
|
||||||
|
onChange={(n) => set(['arc', 'cornerRadius'], n)}
|
||||||
|
/>
|
||||||
|
<NumberRow
|
||||||
|
label="Pad angle"
|
||||||
|
name="Arc pad angle"
|
||||||
|
value={num(['arc', 'padAngle'])}
|
||||||
|
min={0}
|
||||||
|
step={0.01}
|
||||||
|
onChange={(n) => set(['arc', 'padAngle'], n)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return <Accordion sections={sections} idPrefix="marks" />;
|
||||||
|
}
|
||||||
@@ -141,27 +141,43 @@
|
|||||||
border-bottom: var(--border-width) solid var(--border);
|
border-bottom: var(--border-width) solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Structured-control tabs ───────────────────────────────────────────── */
|
/* ── Structured-control tabs (vertical rail | panel) ───────────────────── */
|
||||||
|
|
||||||
.tabs {
|
/* The rail of panel names beside the active panel; the divider above the JSON
|
||||||
flex: 0 0 auto;
|
section spans both, so it sits on the vsplit, not the panel. */
|
||||||
display: flex;
|
.vsplit {
|
||||||
gap: 0;
|
flex: 1 1 auto;
|
||||||
padding: 0 var(--space-5);
|
display: grid;
|
||||||
|
grid-template-columns: 118px minmax(0, 1fr);
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
border-bottom: var(--border-width) solid var(--border);
|
border-bottom: var(--border-width) solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Vertical tab list — a tinted strip, distinct from the (untinted) themes list
|
||||||
|
pane on the far left. */
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: auto;
|
||||||
|
min-height: 0;
|
||||||
|
background: var(--layer-01);
|
||||||
|
border-right: var(--border-width) solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
.tab {
|
.tab {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
border-bottom: 2px solid transparent;
|
border-left: 2px solid transparent;
|
||||||
margin-bottom: -1px;
|
|
||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color var(--dur-fast) var(--ease);
|
transition: color var(--dur-fast) var(--ease);
|
||||||
}
|
}
|
||||||
@@ -172,16 +188,17 @@
|
|||||||
|
|
||||||
.tabActive {
|
.tabActive {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border-bottom-color: var(--accent);
|
border-left-color: var(--accent);
|
||||||
|
background: var(--bg);
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The structured controls are the first-screen surface: they fill the column
|
/* The active panel scrolls within its grid cell; the JSON section is docked
|
||||||
and scroll, with the collapsible JSON section docked below. */
|
below the whole vsplit. */
|
||||||
.tabpanel {
|
.tabpanel {
|
||||||
flex: 1 1 auto;
|
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
border-bottom: var(--border-width) solid var(--border);
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controlsDisabled {
|
.controlsDisabled {
|
||||||
|
|||||||
@@ -26,22 +26,39 @@ import {
|
|||||||
} from '../stores/CustomThemeStore';
|
} from '../stores/CustomThemeStore';
|
||||||
import { notify } from '../stores/NotificationStore';
|
import { notify } from '../stores/NotificationStore';
|
||||||
import { resnapshot } from '../modals/ModalCoordinator';
|
import { resnapshot } from '../modals/ModalCoordinator';
|
||||||
|
import type { ComponentType } from 'react';
|
||||||
import { AxesControls } from './AxesControls';
|
import { AxesControls } from './AxesControls';
|
||||||
import { Button } from './Button';
|
import { Button } from './Button';
|
||||||
import { ColorControls } from './ColorControls';
|
import { ColorControls } from './ColorControls';
|
||||||
|
import { FormatControls } from './FormatControls';
|
||||||
|
import { HeaderControls } from './HeaderControls';
|
||||||
import { LayoutControls } from './LayoutControls';
|
import { LayoutControls } from './LayoutControls';
|
||||||
|
import { MarksControls } from './MarksControls';
|
||||||
import { LegendControls } from './LegendControls';
|
import { LegendControls } from './LegendControls';
|
||||||
|
import { TitleControls } from './TitleControls';
|
||||||
import { TypeControls } from './TypeControls';
|
import { TypeControls } from './TypeControls';
|
||||||
import styles from './ThemeBuilderModal.module.css';
|
import styles from './ThemeBuilderModal.module.css';
|
||||||
|
|
||||||
/** Structured-control tabs, in display order (docs/chart-theming-scope.md §5). */
|
/**
|
||||||
|
* Structured-control tabs, in display order (docs/chart-theming-scope.md §5).
|
||||||
|
* Each panel takes the parsed draft config and writes through the shared
|
||||||
|
* `useConfigSetter`; the map is the single registry of id → label → panel.
|
||||||
|
*/
|
||||||
const THEME_TABS = [
|
const THEME_TABS = [
|
||||||
{ id: 'color', label: 'Color' },
|
{ id: 'color', label: 'Color', Panel: ColorControls },
|
||||||
{ id: 'type', label: 'Type' },
|
{ id: 'marks', label: 'Marks', Panel: MarksControls },
|
||||||
{ id: 'layout', label: 'Layout' },
|
{ id: 'type', label: 'Type', Panel: TypeControls },
|
||||||
{ id: 'axes', label: 'Axes & grid' },
|
{ id: 'title', label: 'Title', Panel: TitleControls },
|
||||||
{ id: 'legend', label: 'Legend' },
|
{ id: 'layout', label: 'Layout', Panel: LayoutControls },
|
||||||
] as const;
|
{ id: 'axes', label: 'Axes & grid', Panel: AxesControls },
|
||||||
|
{ id: 'legend', label: 'Legend', Panel: LegendControls },
|
||||||
|
{ id: 'headers', label: 'Headers', Panel: HeaderControls },
|
||||||
|
{ id: 'formats', label: 'Formats', Panel: FormatControls },
|
||||||
|
] as const satisfies ReadonlyArray<{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
Panel: ComponentType<{ config: JsonObject }>;
|
||||||
|
}>;
|
||||||
type ThemeTab = (typeof THEME_TABS)[number]['id'];
|
type ThemeTab = (typeof THEME_TABS)[number]['id'];
|
||||||
|
|
||||||
/** Debounce for gallery re-renders while the config text is edited (ms). */
|
/** Debounce for gallery re-renders while the config text is edited (ms). */
|
||||||
@@ -143,16 +160,17 @@ export function ThemeBuilderModal() {
|
|||||||
// (it's the only place to fix the JSON).
|
// (it's the only place to fix the JSON).
|
||||||
const [jsonExpanded, setJsonExpanded] = useState(false);
|
const [jsonExpanded, setJsonExpanded] = useState(false);
|
||||||
const jsonOpen = jsonExpanded || parseError !== null;
|
const jsonOpen = jsonExpanded || parseError !== null;
|
||||||
|
const ActivePanel = THEME_TABS.find((t) => t.id === activeTab)!.Panel;
|
||||||
|
|
||||||
// APG tabs: arrow/Home/End move selection, which follows focus (automatic
|
// APG tabs (vertical orientation): Up/Down + Home/End move selection, which
|
||||||
// activation — the panel swap is cheap). The portaled SelectControl popovers
|
// follows focus (automatic activation — the panel swap is cheap). The portaled
|
||||||
// inside a panel manage their own focus.
|
// SelectControl popovers inside a panel manage their own focus.
|
||||||
const onTabKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
const onTabKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
const ids = THEME_TABS.map((t) => t.id);
|
const ids = THEME_TABS.map((t) => t.id);
|
||||||
const i = ids.indexOf(activeTab);
|
const i = ids.indexOf(activeTab);
|
||||||
let next = -1;
|
let next = -1;
|
||||||
if (e.key === 'ArrowRight') next = (i + 1) % ids.length;
|
if (e.key === 'ArrowDown') next = (i + 1) % ids.length;
|
||||||
else if (e.key === 'ArrowLeft') next = (i - 1 + ids.length) % ids.length;
|
else if (e.key === 'ArrowUp') next = (i - 1 + ids.length) % ids.length;
|
||||||
else if (e.key === 'Home') next = 0;
|
else if (e.key === 'Home') next = 0;
|
||||||
else if (e.key === 'End') next = ids.length - 1;
|
else if (e.key === 'End') next = ids.length - 1;
|
||||||
if (next < 0) return;
|
if (next < 0) return;
|
||||||
@@ -267,56 +285,51 @@ export function ThemeBuilderModal() {
|
|||||||
|
|
||||||
<div className={styles.body}>
|
<div className={styles.body}>
|
||||||
<div className={styles.controlsCol}>
|
<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 && (
|
{(saveError ?? parseError) !== null && (
|
||||||
<p className={styles.errorMessage} role="alert">
|
<p className={styles.errorMessage} role="alert">
|
||||||
{saveError ?? parseError}
|
{saveError ?? parseError}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div className={styles.vsplit}>
|
||||||
className={styles.tabpanel}
|
<div
|
||||||
role="tabpanel"
|
className={styles.tabs}
|
||||||
id={`theme-tabpanel-${activeTab}`}
|
role="tablist"
|
||||||
aria-labelledby={`theme-tab-${activeTab}`}
|
aria-label="Theme controls"
|
||||||
>
|
aria-orientation="vertical"
|
||||||
{parseError !== null ? (
|
onKeyDown={onTabKeyDown}
|
||||||
<p className={styles.controlsDisabled}>
|
>
|
||||||
Fix the JSON below to use these controls.
|
{THEME_TABS.map((t) => (
|
||||||
</p>
|
<button
|
||||||
) : draftConfig === null ? null : activeTab === 'color' ? (
|
key={t.id}
|
||||||
<ColorControls config={draftConfig} />
|
type="button"
|
||||||
) : activeTab === 'type' ? (
|
role="tab"
|
||||||
<TypeControls config={draftConfig} />
|
id={`theme-tab-${t.id}`}
|
||||||
) : activeTab === 'layout' ? (
|
aria-selected={activeTab === t.id}
|
||||||
<LayoutControls config={draftConfig} />
|
aria-controls={`theme-tabpanel-${t.id}`}
|
||||||
) : activeTab === 'axes' ? (
|
tabIndex={activeTab === t.id ? 0 : -1}
|
||||||
<AxesControls config={draftConfig} />
|
className={`${styles.tab} ${activeTab === t.id ? styles.tabActive : ''}`}
|
||||||
) : (
|
onClick={() => setActiveTab(t.id)}
|
||||||
<LegendControls config={draftConfig} />
|
>
|
||||||
)}
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
) : draftConfig === null ? null : (
|
||||||
|
<ActivePanel config={draftConfig} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Raw JSON — collapsed by default (the structured controls are the
|
{/* Raw JSON — collapsed by default (the structured controls are the
|
||||||
|
|||||||
@@ -143,6 +143,13 @@ describe('AxesControls', () => {
|
|||||||
act(() => setNativeValue(numberInput('Label angle'), '-45'));
|
act(() => setNativeValue(numberInput('Label angle'), '-45'));
|
||||||
expect(at('axis', 'labelAngle')).toBe(-45);
|
expect(at('axis', 'labelAngle')).toBe(-45);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('ticks "Hidden" writes axis.ticks false', () => {
|
||||||
|
render();
|
||||||
|
open({}, 'Axes & grid');
|
||||||
|
pick('Ticks', 'Hidden');
|
||||||
|
expect(at('axis', 'ticks')).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('LegendControls', () => {
|
describe('LegendControls', () => {
|
||||||
@@ -159,20 +166,158 @@ describe('LegendControls', () => {
|
|||||||
act(() => setNativeValue(numberInput('Symbol size'), '120'));
|
act(() => setNativeValue(numberInput('Symbol size'), '120'));
|
||||||
expect(at('legend', 'symbolSize')).toBe(120);
|
expect(at('legend', 'symbolSize')).toBe(120);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('direction "Horizontal" writes legend.direction', () => {
|
||||||
|
render();
|
||||||
|
open({}, 'Legend');
|
||||||
|
pick('Direction', 'Horizontal');
|
||||||
|
expect(at('legend', 'direction')).toBe('horizontal');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('accordion panels', () => {
|
||||||
|
// Every panel groups its controls into the single-expand accordion. Verify the
|
||||||
|
// structure on the Color panel: each section is a collapsible header, the first
|
||||||
|
// is open, the rest collapsed.
|
||||||
|
const headers = () =>
|
||||||
|
[...container.querySelectorAll('button')].filter((b) =>
|
||||||
|
b.hasAttribute('data-accordion-header'),
|
||||||
|
);
|
||||||
|
|
||||||
|
test('a converted panel renders collapsible sections, first open', () => {
|
||||||
|
render();
|
||||||
|
open({}, 'Color');
|
||||||
|
const hs = headers();
|
||||||
|
expect(hs).toHaveLength(4); // categorical / mark color / sequential / diverging
|
||||||
|
expect(hs[0].textContent).toContain('Categorical palette');
|
||||||
|
expect(hs[3].textContent).toContain('Diverging gradient');
|
||||||
|
expect(hs[0].getAttribute('aria-expanded')).toBe('true');
|
||||||
|
expect(hs[1].getAttribute('aria-expanded')).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a section header shows a count badge of the properties set within it', () => {
|
||||||
|
render();
|
||||||
|
// Two axis-grid keys set → the Grid section badge reads 2.
|
||||||
|
open({ axis: { grid: false, gridColor: '#fff' } }, 'Axes & grid');
|
||||||
|
const grid = headers().find((h) => h.textContent?.includes('Grid'))!;
|
||||||
|
expect(grid.querySelector('[aria-label="2 set"]')?.textContent).toBe('2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MarksControls', () => {
|
||||||
|
// The per-type accordion sections start collapsed except the first ("All
|
||||||
|
// marks"); open a section by clicking its header before reaching its controls.
|
||||||
|
const openSection = (title: string) => {
|
||||||
|
const header = [...container.querySelectorAll('button')].find(
|
||||||
|
(b) => b.hasAttribute('data-accordion-header') && b.textContent?.includes(title),
|
||||||
|
)!;
|
||||||
|
act(() => header.click());
|
||||||
|
};
|
||||||
|
|
||||||
|
test('bar corner radius writes the bar-only path, not the generic mark', () => {
|
||||||
|
render();
|
||||||
|
open({}, 'Marks');
|
||||||
|
openSection('Bars');
|
||||||
|
act(() => setNativeValue(numberInput('Bar corner radius'), '4'));
|
||||||
|
expect(at('bar', 'cornerRadiusEnd')).toBe(4);
|
||||||
|
expect(at('mark', 'cornerRadius')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearing a bar control prunes the emptied bar object (minimal diff)', () => {
|
||||||
|
render();
|
||||||
|
open({ bar: { cornerRadiusEnd: 4 } }, 'Marks');
|
||||||
|
openSection('Bars');
|
||||||
|
act(() => setNativeValue(numberInput('Bar corner radius'), ''));
|
||||||
|
expect(config().bar).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('line curve writes line.interpolate', () => {
|
||||||
|
render();
|
||||||
|
open({}, 'Marks');
|
||||||
|
openSection('Lines & areas');
|
||||||
|
pick('Line curve', 'Monotone');
|
||||||
|
expect(at('line', 'interpolate')).toBe('monotone');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('arc donut hole writes arc.innerRadius', () => {
|
||||||
|
render();
|
||||||
|
open({}, 'Marks');
|
||||||
|
openSection('Arc');
|
||||||
|
act(() => setNativeValue(numberInput('Donut hole radius'), '40'));
|
||||||
|
expect(at('arc', 'innerRadius')).toBe(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tooltips tri-state "Off" writes mark.tooltip false', () => {
|
||||||
|
render();
|
||||||
|
open({}, 'Marks');
|
||||||
|
pick('Tooltips', 'Off'); // "All marks" section is open by default
|
||||||
|
expect(at('mark', 'tooltip')).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('TypeControls', () => {
|
describe('TypeControls', () => {
|
||||||
test('title weight "Bold" writes title.fontWeight 700', () => {
|
|
||||||
render();
|
|
||||||
open({}, 'Type');
|
|
||||||
pick('Title weight', 'Bold');
|
|
||||||
expect(at('title', 'fontWeight')).toBe(700);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('axis label size writes axis.labelFontSize', () => {
|
test('axis label size writes axis.labelFontSize', () => {
|
||||||
render();
|
render();
|
||||||
open({}, 'Type');
|
open({}, 'Type');
|
||||||
act(() => setNativeValue(numberInput('Axis label size'), '9'));
|
act(() => setNativeValue(numberInput('Axis label size'), '9'));
|
||||||
expect(at('axis', 'labelFontSize')).toBe(9);
|
expect(at('axis', 'labelFontSize')).toBe(9);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('axis title weight "Bold" (shared WeightRow) writes 700', () => {
|
||||||
|
render();
|
||||||
|
open({}, 'Type');
|
||||||
|
pick('Axis title weight', 'Bold');
|
||||||
|
expect(at('axis', 'titleFontWeight')).toBe(700);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TitleControls', () => {
|
||||||
|
test('alignment "Left" writes title.anchor start', () => {
|
||||||
|
render();
|
||||||
|
open({}, 'Title');
|
||||||
|
pick('Alignment', 'Left');
|
||||||
|
expect(at('title', 'anchor')).toBe('start');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('title weight "Bold" writes title.fontWeight 700 (relocated from Type)', () => {
|
||||||
|
render();
|
||||||
|
open({}, 'Title');
|
||||||
|
pick('Title weight', 'Bold');
|
||||||
|
expect(at('title', 'fontWeight')).toBe(700);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subtitle size writes title.subtitleFontSize', () => {
|
||||||
|
render();
|
||||||
|
open({}, 'Title');
|
||||||
|
act(() => setNativeValue(numberInput('Subtitle size'), '11'));
|
||||||
|
expect(at('title', 'subtitleFontSize')).toBe(11);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FormatControls', () => {
|
||||||
|
test('a number preset chip fills numberFormat; clearing the field removes it', () => {
|
||||||
|
render();
|
||||||
|
open({}, 'Formats');
|
||||||
|
act(() => button('$1,234.00')!.click());
|
||||||
|
expect(config().numberFormat).toBe('$,.2f');
|
||||||
|
|
||||||
|
act(() => setNativeValue(container.querySelector('input[aria-label="Number format"]')!, ''));
|
||||||
|
expect(config().numberFormat).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('date format typed free-text writes timeFormat', () => {
|
||||||
|
render();
|
||||||
|
open({}, 'Formats');
|
||||||
|
act(() => setNativeValue(container.querySelector('input[aria-label="Date format"]')!, '%Y'));
|
||||||
|
expect(config().timeFormat).toBe('%Y');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HeaderControls', () => {
|
||||||
|
test('facet title size writes header.titleFontSize', () => {
|
||||||
|
render();
|
||||||
|
open({}, 'Headers');
|
||||||
|
act(() => setNativeValue(numberInput('Header title size'), '13'));
|
||||||
|
expect(at('header', 'titleFontSize')).toBe(13);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,23 +1,5 @@
|
|||||||
/* Theme Builder structured-control field primitives — shared by the Layout,
|
/* Theme Builder structured-control field primitives — shared by the panels
|
||||||
Axes & grid, Legend, and Type panels (docs/chart-theming-scope.md §5). */
|
(docs/chart-theming-scope.md §5). */
|
||||||
|
|
||||||
.panel {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-6);
|
|
||||||
padding: var(--space-5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.group {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.groupTitle {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -25,12 +7,6 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fields within a section stack with a label column for cross-row alignment. */
|
|
||||||
.fields {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 132px 1fr;
|
grid-template-columns: 132px 1fr;
|
||||||
@@ -59,11 +35,28 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
flex: 1 1 140px;
|
||||||
|
max-width: 220px;
|
||||||
|
height: var(--control-height);
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
font-size: 13px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
.unit {
|
.unit {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Quick-fill format presets (Formats panel) — a wrapping row of chips. */
|
||||||
|
.presets {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding-left: 132px; /* align under the control column, past the label */
|
||||||
|
}
|
||||||
|
|
||||||
.defaultNote {
|
.defaultNote {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@@ -125,3 +118,80 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Accordion (Marks panel) — single-expand per-mark-type sections. */
|
||||||
|
.accordion {
|
||||||
|
display: grid;
|
||||||
|
padding: var(--space-5);
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accSection {
|
||||||
|
border-bottom: var(--border-width) solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accSection:first-child {
|
||||||
|
border-top: var(--border-width) solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accHeading {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-3) var(--space-1);
|
||||||
|
appearance: none;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accHeader:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accCaret {
|
||||||
|
flex: none;
|
||||||
|
width: 1em;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accTitle {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Count of set properties — bordered chip, passive chrome (arch 09 §4). */
|
||||||
|
.accBadge {
|
||||||
|
flex: none;
|
||||||
|
min-width: 18px;
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accPanel {
|
||||||
|
padding: var(--space-2) 0 var(--space-4) var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid only when open: author `display` would otherwise beat the UA
|
||||||
|
`[hidden] { display: none }` rule and leave a collapsed panel visible. */
|
||||||
|
.accPanel:not([hidden]) {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,61 +2,28 @@
|
|||||||
* Theme Builder structured-control field primitives (docs/chart-theming-scope.md
|
* Theme Builder structured-control field primitives (docs/chart-theming-scope.md
|
||||||
* §5).
|
* §5).
|
||||||
*
|
*
|
||||||
* The Layout / Axes & grid / Legend / Type panels are forms of scalar controls
|
* The structured-control panels are forms of scalar controls over the draft
|
||||||
* over the draft config — a labelled color, number, or enum per config key. This
|
* config — a labelled color, number, enum, or text per config key. This is the
|
||||||
* is the small shared vocabulary they're built from, so the panels read
|
* small shared vocabulary they're built from, so the panels read declaratively
|
||||||
* declaratively and look identical:
|
* and look identical:
|
||||||
*
|
*
|
||||||
* - `ControlSection` — a titled group with an optional hint.
|
* - `Accordion` — the single-expand sections a panel groups its controls into.
|
||||||
* - `ColorRow` / `NumberRow` / `SelectRow` — one labelled control each.
|
* - `ColorRow` / `NumberRow` / `SelectRow` / `TextRow` / `WeightRow` — one
|
||||||
|
* labelled control each.
|
||||||
*
|
*
|
||||||
* Each control reports a value or `undefined` (cleared); the panel writes it
|
* Each control reports a value or `undefined` (cleared); the panel writes it
|
||||||
* through `mutateDraftConfig` + `setConfigValue`, where `undefined` deletes the
|
* through `mutateDraftConfig` + `setConfigValue`, where `undefined` deletes the
|
||||||
* key for a minimal diff. The Color panel predates this module and keeps its own
|
* key for a minimal diff. The Color panel keeps its own swatch/gradient controls
|
||||||
* swatch/gradient controls; these primitives cover the scalar surface the later
|
* for the color families. Leaf coercion (`asString`/`asNumber`/`asBoolean`) plus
|
||||||
* panels share. Leaf coercion (`asString`/`asNumber`/`asBoolean`) lives in core
|
* `enumValue`/`countSet` live in core `theme-controls.ts` with the path get/set.
|
||||||
* `theme-controls.ts` with the path get/set it pairs with.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, type ReactNode } from 'react';
|
import { useRef, useState, type ReactNode } from 'react';
|
||||||
import { Button } from './Button';
|
import { Button } from './Button';
|
||||||
import { ColorField } from './ColorField';
|
import { ColorField } from './ColorField';
|
||||||
import { SelectControl, type SelectControlOption } from './SelectControl';
|
import { SelectControl, type SelectControlOption } from './SelectControl';
|
||||||
import styles from './ThemeFields.module.css';
|
import styles from './ThemeFields.module.css';
|
||||||
|
|
||||||
const slug = (s: string): string =>
|
|
||||||
s
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
|
||||||
.replace(/(^-|-$)/g, '');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A titled group of fields with an optional one-line hint. A `role="group"`
|
|
||||||
* labelled by its heading, so a screen reader announces the group context when
|
|
||||||
* entering it — which is what lets the rows inside carry short labels ("Size",
|
|
||||||
* "Color") that repeat across sections without colliding (APG group pattern).
|
|
||||||
*/
|
|
||||||
export function ControlSection({
|
|
||||||
title,
|
|
||||||
hint,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
hint?: string;
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
const headingId = `theme-group-${slug(title)}`;
|
|
||||||
return (
|
|
||||||
<section className={styles.group} role="group" aria-labelledby={headingId}>
|
|
||||||
<h4 id={headingId} className={styles.groupTitle}>
|
|
||||||
{title}
|
|
||||||
</h4>
|
|
||||||
{hint && <p className={styles.hint}>{hint}</p>}
|
|
||||||
<div className={styles.fields}>{children}</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A labelled color. When unset, the swatch shows `fallback` (the rendered
|
* A labelled color. When unset, the swatch shows `fallback` (the rendered
|
||||||
* default) with a "Default" note rather than a blank chip; once set, a Clear
|
* default) with a "Default" note rather than a blank chip; once set, a Clear
|
||||||
@@ -159,6 +126,113 @@ export function NumberRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progressive-disclosure accordion: single-expand (one section open at a time, or
|
||||||
|
* none), grouping a panel's controls by sub-domain. The Theme Builder
|
||||||
|
* standardizes on it across every panel for consistency; flat `role="group"`
|
||||||
|
* headings remain the documented alternative for groups read in full (arch 10 →
|
||||||
|
* Organizing a large control surface).
|
||||||
|
*
|
||||||
|
* APG accordion: each header is a `<button>` inside a heading, toggling a
|
||||||
|
* `role="region"` panel via `aria-expanded`/`aria-controls`; Up/Down move between
|
||||||
|
* headers, Home/End jump to the ends. An optional `badge` (count of set
|
||||||
|
* properties) makes overrides scannable while collapsed (NN/g recognition).
|
||||||
|
*/
|
||||||
|
export interface AccordionSection {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
/** Count of set properties in this section, shown collapsed as a recognition cue. */
|
||||||
|
badge?: number;
|
||||||
|
/** One-line description, shown at the top of the open panel. */
|
||||||
|
hint?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Accordion({
|
||||||
|
sections,
|
||||||
|
idPrefix,
|
||||||
|
defaultOpenId,
|
||||||
|
}: {
|
||||||
|
sections: AccordionSection[];
|
||||||
|
/** Namespaces the header/panel ids so multiple accordions can't collide. */
|
||||||
|
idPrefix: string;
|
||||||
|
/** Section open on first render; defaults to the first. */
|
||||||
|
defaultOpenId?: string;
|
||||||
|
}) {
|
||||||
|
const [openId, setOpenId] = useState<string | null>(defaultOpenId ?? sections[0]?.id ?? null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const focusHeader = (index: number) => {
|
||||||
|
const headers =
|
||||||
|
containerRef.current?.querySelectorAll<HTMLButtonElement>('[data-accordion-header]');
|
||||||
|
if (!headers || headers.length === 0) return;
|
||||||
|
const n = headers.length;
|
||||||
|
headers[((index % n) + n) % n].focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onHeaderKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>, i: number) => {
|
||||||
|
const move =
|
||||||
|
e.key === 'ArrowDown'
|
||||||
|
? i + 1
|
||||||
|
: e.key === 'ArrowUp'
|
||||||
|
? i - 1
|
||||||
|
: e.key === 'Home'
|
||||||
|
? 0
|
||||||
|
: e.key === 'End'
|
||||||
|
? sections.length - 1
|
||||||
|
: null;
|
||||||
|
if (move === null) return;
|
||||||
|
e.preventDefault();
|
||||||
|
focusHeader(move);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.accordion} ref={containerRef}>
|
||||||
|
{sections.map((section, i) => {
|
||||||
|
const open = section.id === openId;
|
||||||
|
const headerId = `${idPrefix}-acc-h-${section.id}`;
|
||||||
|
const panelId = `${idPrefix}-acc-p-${section.id}`;
|
||||||
|
return (
|
||||||
|
<div key={section.id} className={styles.accSection}>
|
||||||
|
<h4 className={styles.accHeading}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-accordion-header
|
||||||
|
id={headerId}
|
||||||
|
className={styles.accHeader}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={panelId}
|
||||||
|
onClick={() => setOpenId(open ? null : section.id)}
|
||||||
|
onKeyDown={(e) => onHeaderKeyDown(e, i)}
|
||||||
|
>
|
||||||
|
<span className={styles.accCaret} aria-hidden="true">
|
||||||
|
{open ? '▾' : '▸'}
|
||||||
|
</span>
|
||||||
|
<span className={styles.accTitle}>{section.title}</span>
|
||||||
|
{section.badge ? (
|
||||||
|
<span className={styles.accBadge} aria-label={`${section.badge} set`}>
|
||||||
|
{section.badge}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
</h4>
|
||||||
|
<div
|
||||||
|
id={panelId}
|
||||||
|
role="region"
|
||||||
|
aria-labelledby={headerId}
|
||||||
|
className={styles.accPanel}
|
||||||
|
hidden={!open}
|
||||||
|
>
|
||||||
|
{section.hint && <p className={styles.hint}>{section.hint}</p>}
|
||||||
|
{section.children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A labelled enum, on the app's SelectControl. The caller maps the config value
|
* A labelled enum, on the app's SelectControl. The caller maps the config value
|
||||||
* to/from option strings; an unset config key is conventionally the `''` option
|
* to/from option strings; an unset config key is conventionally the `''` option
|
||||||
@@ -193,3 +267,95 @@ export function SelectRow<V extends string>({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A labelled free-text field — for config leaves with no closed option set (d3
|
||||||
|
* number/time format strings, the count title). Bound straight to the value
|
||||||
|
* (strings round-trip cleanly, so no transient-entry buffering like NumberRow);
|
||||||
|
* an empty field clears the key.
|
||||||
|
*/
|
||||||
|
export function TextRow({
|
||||||
|
label,
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
placeholder,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
name?: string;
|
||||||
|
value: string | undefined;
|
||||||
|
placeholder?: string;
|
||||||
|
onChange: (value: string | undefined) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={styles.field}>
|
||||||
|
<span className={styles.fieldLabel}>{label}</span>
|
||||||
|
<div className={styles.control}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={styles.text}
|
||||||
|
aria-label={name ?? label}
|
||||||
|
value={value ?? ''}
|
||||||
|
placeholder={placeholder}
|
||||||
|
spellCheck={false}
|
||||||
|
onChange={(e) => onChange(e.target.value === '' ? undefined : e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Font weight as a tri-state-ish select shared by the Type / Title / Headers
|
||||||
|
* panels: "Theme default" plus the nine numeric weights. A static face
|
||||||
|
* faux-renders weights it doesn't carry; a variable font's weight axis is fully
|
||||||
|
* selectable. A config weight outside the nine (a hand-edit) reads back as
|
||||||
|
* "Theme default" rather than a blank trigger.
|
||||||
|
*/
|
||||||
|
const WEIGHT_OPTIONS: SelectControlOption<string>[] = [
|
||||||
|
{ value: '', label: 'Theme default' },
|
||||||
|
{ value: '100', label: 'Thin' },
|
||||||
|
{ value: '200', label: 'Extra Light' },
|
||||||
|
{ value: '300', label: 'Light' },
|
||||||
|
{ value: '400', label: 'Normal' },
|
||||||
|
{ value: '500', label: 'Medium' },
|
||||||
|
{ value: '600', label: 'Semibold' },
|
||||||
|
{ value: '700', label: 'Bold' },
|
||||||
|
{ value: '800', label: 'Extra Bold' },
|
||||||
|
{ value: '900', label: 'Black' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function weightOption(v: unknown): string {
|
||||||
|
if (typeof v === 'number') {
|
||||||
|
const s = String(v);
|
||||||
|
return WEIGHT_OPTIONS.some((o) => o.value === s) ? s : '';
|
||||||
|
}
|
||||||
|
if (v === 'normal') return '400';
|
||||||
|
if (v === 'bold') return '700';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WeightRow({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
name?: string;
|
||||||
|
value: unknown;
|
||||||
|
onChange: (weight: number | undefined) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectRow
|
||||||
|
id={id}
|
||||||
|
label={label}
|
||||||
|
name={name}
|
||||||
|
options={WEIGHT_OPTIONS}
|
||||||
|
value={weightOption(value)}
|
||||||
|
onSelect={(w) => onChange(w === '' ? undefined : Number(w))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* Theme Builder — Title & subtitle panel (docs/chart-theming-scope.md §5).
|
||||||
|
*
|
||||||
|
* The full title block (`config.title.*`), the editorial signature the Type
|
||||||
|
* panel couldn't reach: the anchor (left-aligned titles are the FT/Economist
|
||||||
|
* hallmark), color, italic, and offset, plus a subtitle group. Title size and
|
||||||
|
* weight moved here from the Type panel so every title property sits together;
|
||||||
|
* Type keeps font family and the axis type scale.
|
||||||
|
*
|
||||||
|
* Vega keeps title and subtitle as siblings under `config.title` (subtitle*
|
||||||
|
* keys), so both groups write into the one object; each control writes a minimal
|
||||||
|
* diff through the shared `useConfigSetter`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { JsonObject } from '@core/spec-config';
|
||||||
|
import {
|
||||||
|
asNumber,
|
||||||
|
asString,
|
||||||
|
type ConfigPath,
|
||||||
|
countSet,
|
||||||
|
enumValue,
|
||||||
|
getConfigValue,
|
||||||
|
} from '@core/theme-controls';
|
||||||
|
import { useConfigSetter } from '../stores/CustomThemeStore';
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
type AccordionSection,
|
||||||
|
ColorRow,
|
||||||
|
NumberRow,
|
||||||
|
SelectRow,
|
||||||
|
WeightRow,
|
||||||
|
} from './ThemeFields';
|
||||||
|
import type { SelectControlOption } from './SelectControl';
|
||||||
|
|
||||||
|
const TEXT_GREY = '#888888';
|
||||||
|
|
||||||
|
const ANCHOR: ConfigPath = ['title', 'anchor'];
|
||||||
|
const COLOR: ConfigPath = ['title', 'color'];
|
||||||
|
const SIZE: ConfigPath = ['title', 'fontSize'];
|
||||||
|
const WEIGHT: ConfigPath = ['title', 'fontWeight'];
|
||||||
|
const STYLE: ConfigPath = ['title', 'fontStyle'];
|
||||||
|
const OFFSET: ConfigPath = ['title', 'offset'];
|
||||||
|
const SUB_COLOR: ConfigPath = ['title', 'subtitleColor'];
|
||||||
|
const SUB_SIZE: ConfigPath = ['title', 'subtitleFontSize'];
|
||||||
|
const SUB_WEIGHT: ConfigPath = ['title', 'subtitleFontWeight'];
|
||||||
|
const SUB_STYLE: ConfigPath = ['title', 'subtitleFontStyle'];
|
||||||
|
const SUB_PADDING: ConfigPath = ['title', 'subtitlePadding'];
|
||||||
|
|
||||||
|
// Title `anchor` positions the title across the chart width; "Left" (start) is
|
||||||
|
// the editorial default a brand most often wants.
|
||||||
|
type Anchor = '' | 'start' | 'middle' | 'end';
|
||||||
|
const anchorOptions: SelectControlOption<Anchor>[] = [
|
||||||
|
{ value: '', label: 'Theme default' },
|
||||||
|
{ value: 'start', label: 'Left' },
|
||||||
|
{ value: 'middle', label: 'Center' },
|
||||||
|
{ value: 'end', label: 'Right' },
|
||||||
|
];
|
||||||
|
|
||||||
|
type FontStyle = '' | 'normal' | 'italic';
|
||||||
|
const styleOptions: SelectControlOption<FontStyle>[] = [
|
||||||
|
{ value: '', label: 'Theme default' },
|
||||||
|
{ value: 'normal', label: 'Normal' },
|
||||||
|
{ value: 'italic', label: 'Italic' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function TitleControls({ config }: { config: JsonObject }) {
|
||||||
|
const set = useConfigSetter();
|
||||||
|
|
||||||
|
const sections: AccordionSection[] = [
|
||||||
|
{
|
||||||
|
id: 'title',
|
||||||
|
title: 'Title',
|
||||||
|
badge: countSet(config, [ANCHOR, COLOR, SIZE, WEIGHT, STYLE, OFFSET]),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<SelectRow
|
||||||
|
id="title-anchor"
|
||||||
|
label="Alignment"
|
||||||
|
options={anchorOptions}
|
||||||
|
value={enumValue(getConfigValue(config, ANCHOR), anchorOptions)}
|
||||||
|
onSelect={(a) => set(ANCHOR, a === '' ? undefined : a)}
|
||||||
|
/>
|
||||||
|
<ColorRow
|
||||||
|
label="Color"
|
||||||
|
name="Title color"
|
||||||
|
value={asString(getConfigValue(config, COLOR))}
|
||||||
|
fallback={TEXT_GREY}
|
||||||
|
onChange={(hex) => set(COLOR, hex)}
|
||||||
|
onClear={() => set(COLOR, undefined)}
|
||||||
|
/>
|
||||||
|
<NumberRow
|
||||||
|
label="Size"
|
||||||
|
name="Title size"
|
||||||
|
value={asNumber(getConfigValue(config, SIZE))}
|
||||||
|
min={0}
|
||||||
|
unit="px"
|
||||||
|
onChange={(n) => set(SIZE, n)}
|
||||||
|
/>
|
||||||
|
<WeightRow
|
||||||
|
id="title-weight"
|
||||||
|
label="Weight"
|
||||||
|
name="Title weight"
|
||||||
|
value={getConfigValue(config, WEIGHT)}
|
||||||
|
onChange={(w) => set(WEIGHT, w)}
|
||||||
|
/>
|
||||||
|
<SelectRow
|
||||||
|
id="title-style"
|
||||||
|
label="Style"
|
||||||
|
name="Title style"
|
||||||
|
options={styleOptions}
|
||||||
|
value={enumValue(getConfigValue(config, STYLE), styleOptions)}
|
||||||
|
onSelect={(s) => set(STYLE, s === '' ? undefined : s)}
|
||||||
|
/>
|
||||||
|
<NumberRow
|
||||||
|
label="Offset"
|
||||||
|
name="Title offset"
|
||||||
|
value={asNumber(getConfigValue(config, OFFSET))}
|
||||||
|
min={0}
|
||||||
|
unit="px"
|
||||||
|
onChange={(n) => set(OFFSET, n)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'subtitle',
|
||||||
|
title: 'Subtitle',
|
||||||
|
hint: 'Styles the subtitle when a spec sets one.',
|
||||||
|
badge: countSet(config, [SUB_COLOR, SUB_SIZE, SUB_WEIGHT, SUB_STYLE, SUB_PADDING]),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<ColorRow
|
||||||
|
label="Color"
|
||||||
|
name="Subtitle color"
|
||||||
|
value={asString(getConfigValue(config, SUB_COLOR))}
|
||||||
|
fallback={TEXT_GREY}
|
||||||
|
onChange={(hex) => set(SUB_COLOR, hex)}
|
||||||
|
onClear={() => set(SUB_COLOR, undefined)}
|
||||||
|
/>
|
||||||
|
<NumberRow
|
||||||
|
label="Size"
|
||||||
|
name="Subtitle size"
|
||||||
|
value={asNumber(getConfigValue(config, SUB_SIZE))}
|
||||||
|
min={0}
|
||||||
|
unit="px"
|
||||||
|
onChange={(n) => set(SUB_SIZE, n)}
|
||||||
|
/>
|
||||||
|
<WeightRow
|
||||||
|
id="subtitle-weight"
|
||||||
|
label="Weight"
|
||||||
|
name="Subtitle weight"
|
||||||
|
value={getConfigValue(config, SUB_WEIGHT)}
|
||||||
|
onChange={(w) => set(SUB_WEIGHT, w)}
|
||||||
|
/>
|
||||||
|
<SelectRow
|
||||||
|
id="subtitle-style"
|
||||||
|
label="Style"
|
||||||
|
name="Subtitle style"
|
||||||
|
options={styleOptions}
|
||||||
|
value={enumValue(getConfigValue(config, SUB_STYLE), styleOptions)}
|
||||||
|
onSelect={(s) => set(SUB_STYLE, s === '' ? undefined : s)}
|
||||||
|
/>
|
||||||
|
<NumberRow
|
||||||
|
label="Gap"
|
||||||
|
name="Subtitle gap"
|
||||||
|
value={asNumber(getConfigValue(config, SUB_PADDING))}
|
||||||
|
min={0}
|
||||||
|
unit="px"
|
||||||
|
onChange={(n) => set(SUB_PADDING, n)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return <Accordion sections={sections} idPrefix="title" />;
|
||||||
|
}
|
||||||
+129
-161
@@ -2,10 +2,11 @@
|
|||||||
* Theme Builder — Type panel (docs/chart-theming-scope.md §5).
|
* Theme Builder — Type panel (docs/chart-theming-scope.md §5).
|
||||||
*
|
*
|
||||||
* The font family (written into every font slot via `applyDraftFont`, the one
|
* The font family (written into every font slot via `applyDraftFont`, the one
|
||||||
* transform that walks the whole config) plus the type scale a brand tunes:
|
* transform that walks the whole config) plus the axis type scale a brand tunes:
|
||||||
* title size/weight and axis title/label size/weight. Legend type lives in the
|
* axis title and label size/weight. The title block (size, weight, anchor,
|
||||||
* Legend panel so all legend properties sit together. Color of text lives in
|
* colour, subtitle) lives in its own Title panel so every title property sits
|
||||||
* the per-domain panels (Axes, Legend); this panel is family, size, and weight.
|
* together; legend type lives in the Legend panel. Colour of text lives in the
|
||||||
|
* per-domain panels (Axes, Legend, Headers); this panel is family, size, weight.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useMemo, useRef } from 'react';
|
import { useMemo, useRef } from 'react';
|
||||||
@@ -13,18 +14,17 @@ import { THEME_FONT_OPTIONS } from '@core/custom-theme';
|
|||||||
import { type FontAxis, fontFamilyStack, isVariableFont } from '@core/font-asset';
|
import { type FontAxis, fontFamilyStack, isVariableFont } from '@core/font-asset';
|
||||||
import type { JsonObject } from '@core/spec-config';
|
import type { JsonObject } from '@core/spec-config';
|
||||||
import { humanizeBytes } from '@core/storage-estimate';
|
import { humanizeBytes } from '@core/storage-estimate';
|
||||||
import { asNumber, type ConfigPath, getConfigValue } from '@core/theme-controls';
|
import { asNumber, type ConfigPath, countSet, getConfigValue } from '@core/theme-controls';
|
||||||
import { removeFont, uploadFontFiles } from '../services/fonts';
|
import { removeFont, uploadFontFiles } from '../services/fonts';
|
||||||
import { confirm } from '../stores/ConfirmStore';
|
import { confirm } from '../stores/ConfirmStore';
|
||||||
import { useConfigSetter, useCustomThemeStore } from '../stores/CustomThemeStore';
|
import { useConfigSetter, useCustomThemeStore } from '../stores/CustomThemeStore';
|
||||||
import { useFontStore } from '../stores/FontStore';
|
import { useFontStore } from '../stores/FontStore';
|
||||||
import { Button } from './Button';
|
import { Button } from './Button';
|
||||||
import { SelectControl, type SelectControlOption } from './SelectControl';
|
import { SelectControl, type SelectControlOption } from './SelectControl';
|
||||||
import { ControlSection, NumberRow, SelectRow } from './ThemeFields';
|
import { Accordion, type AccordionSection, NumberRow, WeightRow } from './ThemeFields';
|
||||||
import styles from './ThemeFields.module.css';
|
import styles from './ThemeFields.module.css';
|
||||||
|
|
||||||
const TITLE_SIZE: ConfigPath = ['title', 'fontSize'];
|
const FONT: ConfigPath = ['font'];
|
||||||
const TITLE_WEIGHT: ConfigPath = ['title', 'fontWeight'];
|
|
||||||
const AXIS_TITLE_SIZE: ConfigPath = ['axis', 'titleFontSize'];
|
const AXIS_TITLE_SIZE: ConfigPath = ['axis', 'titleFontSize'];
|
||||||
const AXIS_TITLE_WEIGHT: ConfigPath = ['axis', 'titleFontWeight'];
|
const AXIS_TITLE_WEIGHT: ConfigPath = ['axis', 'titleFontWeight'];
|
||||||
const AXIS_LABEL_SIZE: ConfigPath = ['axis', 'labelFontSize'];
|
const AXIS_LABEL_SIZE: ConfigPath = ['axis', 'labelFontSize'];
|
||||||
@@ -46,34 +46,6 @@ function variableBadge(axes: FontAxis[] | undefined): string {
|
|||||||
return wght ? `Variable ${wght.min}–${wght.max}` : 'Variable';
|
return wght ? `Variable ${wght.min}–${wght.max}` : 'Variable';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Numeric font weights as an enum; '' is the unset (default) sentinel. The full
|
|
||||||
// 100–900 range is offered so a variable font's weight axis is fully selectable;
|
|
||||||
// a static face simply faux-renders the weights it doesn't physically carry.
|
|
||||||
type Weight = '' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900' | 'custom';
|
|
||||||
const weightOptions: SelectControlOption<Weight>[] = [
|
|
||||||
{ value: '', label: 'Theme default' },
|
|
||||||
{ value: '100', label: 'Thin' },
|
|
||||||
{ value: '200', label: 'Extra Light' },
|
|
||||||
{ value: '300', label: 'Light' },
|
|
||||||
{ value: '400', label: 'Normal' },
|
|
||||||
{ value: '500', label: 'Medium' },
|
|
||||||
{ value: '600', label: 'Semibold' },
|
|
||||||
{ value: '700', label: 'Bold' },
|
|
||||||
{ value: '800', label: 'Extra Bold' },
|
|
||||||
{ value: '900', label: 'Black' },
|
|
||||||
];
|
|
||||||
const weightValue = (v: unknown): Weight => {
|
|
||||||
if (v === undefined) return '';
|
|
||||||
if (typeof v === 'number') {
|
|
||||||
const s = String(v) as Weight;
|
|
||||||
return weightOptions.some((o) => o.value === s) ? s : 'custom';
|
|
||||||
}
|
|
||||||
// Named CSS weights map onto the two presets that have names.
|
|
||||||
if (v === 'normal') return '400';
|
|
||||||
if (v === 'bold') return '700';
|
|
||||||
return 'custom';
|
|
||||||
};
|
|
||||||
|
|
||||||
export function TypeControls({ config }: { config: JsonObject }) {
|
export function TypeControls({ config }: { config: JsonObject }) {
|
||||||
const set = useConfigSetter();
|
const set = useConfigSetter();
|
||||||
const fonts = useFontStore((s) => s.fonts);
|
const fonts = useFontStore((s) => s.fonts);
|
||||||
@@ -96,9 +68,6 @@ export function TypeControls({ config }: { config: JsonObject }) {
|
|||||||
const currentFont =
|
const currentFont =
|
||||||
typeof config.font === 'string' ? fontOptions.find((o) => o.value === config.font) : undefined;
|
typeof config.font === 'string' ? fontOptions.find((o) => o.value === config.font) : undefined;
|
||||||
|
|
||||||
const setWeight = (path: ConfigPath) => (w: Weight) =>
|
|
||||||
set(path, w === '' ? undefined : Number(w));
|
|
||||||
|
|
||||||
const onPickFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const onPickFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const picked = e.target.files;
|
const picked = e.target.files;
|
||||||
if (picked && picked.length > 0) void uploadFontFiles(picked);
|
if (picked && picked.length > 0) void uploadFontFiles(picked);
|
||||||
@@ -117,129 +86,128 @@ export function TypeControls({ config }: { config: JsonObject }) {
|
|||||||
if (ok) removeFont(id);
|
if (ok) removeFont(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const sections: AccordionSection[] = [
|
||||||
<div className={styles.panel}>
|
{
|
||||||
<ControlSection title="Font family" hint="Applied to every text slot in the config.">
|
id: 'font',
|
||||||
<div className={styles.field}>
|
title: 'Font family',
|
||||||
<span className={styles.fieldLabel}>Font</span>
|
hint: 'Applied to every text slot in the config.',
|
||||||
<div className={styles.control}>
|
badge: countSet(config, [FONT]),
|
||||||
<SelectControl
|
children: (
|
||||||
id="theme-builder-font"
|
<>
|
||||||
label="Font family"
|
<div className={styles.field}>
|
||||||
heading="Apply font"
|
<span className={styles.fieldLabel}>Font</span>
|
||||||
options={fontOptions}
|
<div className={styles.control}>
|
||||||
value={currentFont?.value}
|
<SelectControl
|
||||||
onSelect={(family) => useCustomThemeStore.getState().applyDraftFont(family)}
|
id="theme-builder-font"
|
||||||
triggerContent={
|
label="Font family"
|
||||||
<>
|
heading="Apply font"
|
||||||
<span style={currentFont ? { fontFamily: currentFont.value } : undefined}>
|
options={fontOptions}
|
||||||
{currentFont?.label ?? 'Apply font…'}
|
value={currentFont?.value}
|
||||||
</span>
|
onSelect={(family) => useCustomThemeStore.getState().applyDraftFont(family)}
|
||||||
<span className={styles.caret} aria-hidden="true">
|
triggerContent={
|
||||||
▾
|
<>
|
||||||
</span>
|
<span style={currentFont ? { fontFamily: currentFont.value } : undefined}>
|
||||||
</>
|
{currentFont?.label ?? 'Apply font…'}
|
||||||
}
|
</span>
|
||||||
triggerTitle="Write one font family into every font slot of the config"
|
<span className={styles.caret} aria-hidden="true">
|
||||||
/>
|
▾
|
||||||
<Button variant="ghost" onClick={() => fileRef.current?.click()}>
|
</span>
|
||||||
Upload font…
|
</>
|
||||||
</Button>
|
}
|
||||||
<input
|
triggerTitle="Write one font family into every font slot of the config"
|
||||||
ref={fileRef}
|
/>
|
||||||
type="file"
|
<Button variant="ghost" onClick={() => fileRef.current?.click()}>
|
||||||
accept=".woff2,.woff,.ttf,.otf"
|
Upload font…
|
||||||
multiple
|
</Button>
|
||||||
hidden
|
<input
|
||||||
onChange={onPickFiles}
|
ref={fileRef}
|
||||||
/>
|
type="file"
|
||||||
|
accept=".woff2,.woff,.ttf,.otf"
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
onChange={onPickFiles}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{fonts.length > 0 && (
|
||||||
{fonts.length > 0 && (
|
<ul className={styles.fontList}>
|
||||||
<ul className={styles.fontList}>
|
{fonts.map((f) => (
|
||||||
{fonts.map((f) => (
|
<li key={f.id} className={styles.fontItem}>
|
||||||
<li key={f.id} className={styles.fontItem}>
|
<span className={styles.fontNameWrap}>
|
||||||
<span className={styles.fontNameWrap}>
|
<span
|
||||||
<span
|
className={styles.fontName}
|
||||||
className={styles.fontName}
|
style={{ fontFamily: fontFamilyStack(f.family) }}
|
||||||
style={{ fontFamily: fontFamilyStack(f.family) }}
|
>
|
||||||
>
|
{f.family}
|
||||||
{f.family}
|
</span>
|
||||||
|
{isVariableFont(f.axes) && (
|
||||||
|
<span className={styles.fontBadge}>{variableBadge(f.axes)}</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
{isVariableFont(f.axes) && (
|
<span className={styles.fontMeta}>{humanizeBytes(f.size)}</span>
|
||||||
<span className={styles.fontBadge}>{variableBadge(f.axes)}</span>
|
<Button
|
||||||
)}
|
variant="ghost"
|
||||||
</span>
|
onClick={() => void onRemoveFont(f.id, f.family)}
|
||||||
<span className={styles.fontMeta}>{humanizeBytes(f.size)}</span>
|
aria-label={`Remove ${f.family}`}
|
||||||
<Button
|
>
|
||||||
variant="ghost"
|
Remove
|
||||||
onClick={() => void onRemoveFont(f.id, f.family)}
|
</Button>
|
||||||
aria-label={`Remove ${f.family}`}
|
</li>
|
||||||
>
|
))}
|
||||||
Remove
|
</ul>
|
||||||
</Button>
|
)}
|
||||||
</li>
|
</>
|
||||||
))}
|
),
|
||||||
</ul>
|
},
|
||||||
)}
|
{
|
||||||
</ControlSection>
|
id: 'axisTitles',
|
||||||
|
title: 'Axis titles',
|
||||||
|
badge: countSet(config, [AXIS_TITLE_SIZE, AXIS_TITLE_WEIGHT]),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<NumberRow
|
||||||
|
label="Size"
|
||||||
|
name="Axis title size"
|
||||||
|
value={asNumber(getConfigValue(config, AXIS_TITLE_SIZE))}
|
||||||
|
min={0}
|
||||||
|
unit="px"
|
||||||
|
onChange={(n) => set(AXIS_TITLE_SIZE, n)}
|
||||||
|
/>
|
||||||
|
<WeightRow
|
||||||
|
id="type-axis-title-weight"
|
||||||
|
label="Weight"
|
||||||
|
name="Axis title weight"
|
||||||
|
value={getConfigValue(config, AXIS_TITLE_WEIGHT)}
|
||||||
|
onChange={(w) => set(AXIS_TITLE_WEIGHT, w)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'axisLabels',
|
||||||
|
title: 'Axis labels',
|
||||||
|
badge: countSet(config, [AXIS_LABEL_SIZE, AXIS_LABEL_WEIGHT]),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<NumberRow
|
||||||
|
label="Size"
|
||||||
|
name="Axis label size"
|
||||||
|
value={asNumber(getConfigValue(config, AXIS_LABEL_SIZE))}
|
||||||
|
min={0}
|
||||||
|
unit="px"
|
||||||
|
onChange={(n) => set(AXIS_LABEL_SIZE, n)}
|
||||||
|
/>
|
||||||
|
<WeightRow
|
||||||
|
id="type-axis-label-weight"
|
||||||
|
label="Weight"
|
||||||
|
name="Axis label weight"
|
||||||
|
value={getConfigValue(config, AXIS_LABEL_WEIGHT)}
|
||||||
|
onChange={(w) => set(AXIS_LABEL_WEIGHT, w)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
<ControlSection title="Title">
|
return <Accordion sections={sections} idPrefix="type" />;
|
||||||
<NumberRow
|
|
||||||
label="Size"
|
|
||||||
name="Title size"
|
|
||||||
value={asNumber(getConfigValue(config, TITLE_SIZE))}
|
|
||||||
min={0}
|
|
||||||
unit="px"
|
|
||||||
onChange={(n) => set(TITLE_SIZE, n)}
|
|
||||||
/>
|
|
||||||
<SelectRow
|
|
||||||
id="type-title-weight"
|
|
||||||
label="Weight"
|
|
||||||
name="Title weight"
|
|
||||||
options={weightOptions}
|
|
||||||
value={weightValue(getConfigValue(config, TITLE_WEIGHT))}
|
|
||||||
onSelect={setWeight(TITLE_WEIGHT)}
|
|
||||||
/>
|
|
||||||
</ControlSection>
|
|
||||||
|
|
||||||
<ControlSection title="Axis titles">
|
|
||||||
<NumberRow
|
|
||||||
label="Size"
|
|
||||||
name="Axis title size"
|
|
||||||
value={asNumber(getConfigValue(config, AXIS_TITLE_SIZE))}
|
|
||||||
min={0}
|
|
||||||
unit="px"
|
|
||||||
onChange={(n) => set(AXIS_TITLE_SIZE, n)}
|
|
||||||
/>
|
|
||||||
<SelectRow
|
|
||||||
id="type-axis-title-weight"
|
|
||||||
label="Weight"
|
|
||||||
name="Axis title weight"
|
|
||||||
options={weightOptions}
|
|
||||||
value={weightValue(getConfigValue(config, AXIS_TITLE_WEIGHT))}
|
|
||||||
onSelect={setWeight(AXIS_TITLE_WEIGHT)}
|
|
||||||
/>
|
|
||||||
</ControlSection>
|
|
||||||
|
|
||||||
<ControlSection title="Axis labels">
|
|
||||||
<NumberRow
|
|
||||||
label="Size"
|
|
||||||
name="Axis label size"
|
|
||||||
value={asNumber(getConfigValue(config, AXIS_LABEL_SIZE))}
|
|
||||||
min={0}
|
|
||||||
unit="px"
|
|
||||||
onChange={(n) => set(AXIS_LABEL_SIZE, n)}
|
|
||||||
/>
|
|
||||||
<SelectRow
|
|
||||||
id="type-axis-label-weight"
|
|
||||||
label="Weight"
|
|
||||||
name="Axis label weight"
|
|
||||||
options={weightOptions}
|
|
||||||
value={weightValue(getConfigValue(config, AXIS_LABEL_WEIGHT))}
|
|
||||||
onSelect={setWeight(AXIS_LABEL_WEIGHT)}
|
|
||||||
/>
|
|
||||||
</ControlSection>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,4 +155,34 @@ describe('THEME_PREVIEW_SPECS', () => {
|
|||||||
);
|
);
|
||||||
expect(colors.some((c) => c?.scale?.domainMid !== undefined)).toBe(true);
|
expect(colors.some((c) => c?.scale?.domainMid !== undefined)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses bare marks so config controls are not shadowed in the preview', () => {
|
||||||
|
// A visual mark property hard-coded in a card overrides the same key in the
|
||||||
|
// injected config, making the matching Marks control a no-op in the gallery.
|
||||||
|
// Mark styling belongs in the config (a theme), never inline here.
|
||||||
|
const SHADOWING = [
|
||||||
|
'size',
|
||||||
|
'filled',
|
||||||
|
'point',
|
||||||
|
'innerRadius',
|
||||||
|
'outerRadius',
|
||||||
|
'cornerRadius',
|
||||||
|
'cornerRadiusEnd',
|
||||||
|
'opacity',
|
||||||
|
'interpolate',
|
||||||
|
'shape',
|
||||||
|
'strokeWidth',
|
||||||
|
'padAngle',
|
||||||
|
'discreteBandSize',
|
||||||
|
'continuousBandSize',
|
||||||
|
];
|
||||||
|
for (const card of THEME_PREVIEW_SPECS) {
|
||||||
|
const mark: unknown = card.spec.mark;
|
||||||
|
if (mark && typeof mark === 'object') {
|
||||||
|
for (const key of SHADOWING) {
|
||||||
|
expect(mark, `${card.id} hard-codes mark.${key}`).not.toHaveProperty(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
asBoolean,
|
asBoolean,
|
||||||
asNumber,
|
asNumber,
|
||||||
asString,
|
asString,
|
||||||
|
countSet,
|
||||||
|
enumValue,
|
||||||
getConfigValue,
|
getConfigValue,
|
||||||
normalizeRangeSchemes,
|
normalizeRangeSchemes,
|
||||||
schemeColors,
|
schemeColors,
|
||||||
@@ -182,3 +184,38 @@ describe('schemeColors', () => {
|
|||||||
expect(schemeColors('not-a-scheme')).toEqual([]);
|
expect(schemeColors('not-a-scheme')).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('enumValue', () => {
|
||||||
|
const options = [{ value: '' }, { value: 'start' }, { value: 'end' }] as const;
|
||||||
|
|
||||||
|
it('passes an enumerated value through', () => {
|
||||||
|
expect(enumValue('start', options)).toBe('start');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the "" sentinel for a value the control does not enumerate', () => {
|
||||||
|
// A hand-edited config holding a richer value must not blank the trigger.
|
||||||
|
expect(enumValue('middle', options)).toBe('');
|
||||||
|
expect(enumValue(42, options)).toBe('');
|
||||||
|
expect(enumValue(undefined, options)).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('countSet', () => {
|
||||||
|
const config = { axis: { grid: false, gridColor: '#fff' }, background: 'transparent' };
|
||||||
|
|
||||||
|
it('counts how many of the given paths are set', () => {
|
||||||
|
expect(
|
||||||
|
countSet(config, [
|
||||||
|
['axis', 'grid'],
|
||||||
|
['axis', 'gridColor'],
|
||||||
|
['axis', 'gridWidth'],
|
||||||
|
]),
|
||||||
|
).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts a false/transparent value as set (only undefined is unset)', () => {
|
||||||
|
expect(countSet(config, [['axis', 'grid']])).toBe(1); // false is a set value
|
||||||
|
expect(countSet(config, [['background']])).toBe(1);
|
||||||
|
expect(countSet(config, [['missing']])).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -113,6 +113,28 @@ export const asNumber = (v: unknown): number | undefined =>
|
|||||||
export const asBoolean = (v: unknown): boolean | undefined =>
|
export const asBoolean = (v: unknown): boolean | undefined =>
|
||||||
typeof v === 'boolean' ? v : undefined;
|
typeof v === 'boolean' ? v : undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read an enum config leaf back to one of `options`' values, or `''` (the
|
||||||
|
* conventional "Theme default" sentinel a structured-control option set carries)
|
||||||
|
* when it holds something the control doesn't enumerate — a hand-edit, a preset's
|
||||||
|
* richer form. Keeps a select from showing a blank trigger for an unrecognized
|
||||||
|
* value. `options` is matched structurally, so the UI's `SelectControlOption`
|
||||||
|
* satisfies it without core depending on the component.
|
||||||
|
*/
|
||||||
|
export function enumValue<V extends string>(v: unknown, options: ReadonlyArray<{ value: V }>): V {
|
||||||
|
return typeof v === 'string' && options.some((o) => o.value === v) ? (v as V) : ('' as V);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many of `paths` are set (non-undefined) in `config` — a control section's
|
||||||
|
* "modified" count, shown as its accordion badge so overrides are scannable
|
||||||
|
* while a section is collapsed (NN/g recognition). Counts only the listed
|
||||||
|
* control paths, so it reads as "fields you've set here", not "every key".
|
||||||
|
*/
|
||||||
|
export function countSet(config: JsonObject, paths: ReadonlyArray<ConfigPath>): number {
|
||||||
|
return paths.reduce((n, p) => (getConfigValue(config, p) !== undefined ? n + 1 : n), 0);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Named color schemes ─────────────────────────────────────────────────────
|
// ── Named color schemes ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
type SchemeKind = 'categorical' | 'sequential' | 'diverging';
|
type SchemeKind = 'categorical' | 'sequential' | 'diverging';
|
||||||
|
|||||||
@@ -3,12 +3,22 @@
|
|||||||
*
|
*
|
||||||
* A fixed set of small, self-contained Vega-Lite specs the Theme Builder
|
* 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
|
* 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,
|
* every chart surface a config styles. Every structured control should have a
|
||||||
* facet headers, the major mark types, and — so every color control has a
|
* live mirror here: titles + subtitles, axes/grids/ticks, facet headers, the
|
||||||
* mirror — each color family: categorical (`range.category`), sequential
|
* mark types (bar, line, area, point, arc), and each color family — categorical
|
||||||
* (`range.heatmap` for rect, `range.ramp` for a continuous legend), and
|
* (`range.category`), sequential (`range.heatmap` for rect, `range.ramp` for a
|
||||||
* diverging (`range.diverging`, selected by a quantitative color scale with a
|
* continuous legend), and diverging (`range.diverging`, a quantitative color
|
||||||
* `domainMid`). Inline data only, compact fixed sizes — swatches, not analyses.
|
* scale with a `domainMid`).
|
||||||
|
*
|
||||||
|
* The specs deliberately use BARE mark strings (`mark: 'point'`, not
|
||||||
|
* `{ type: 'point', size: 80 }`): a property hard-coded in the spec overrides
|
||||||
|
* the same key in the injected config, which would make the corresponding Marks
|
||||||
|
* control a no-op in the preview. Add visual mark properties to the config (a
|
||||||
|
* theme), never inline here. Two controls have no static mirror by nature — the
|
||||||
|
* default chart SIZE (`view.continuousWidth/Height`: the cards are fixed-size
|
||||||
|
* swatches) and TOOLTIPS (`mark.tooltip`: hover-only) — and `countTitle` has
|
||||||
|
* none (no count aggregation in the samples). Inline data only, compact fixed
|
||||||
|
* sizes — swatches, not analyses.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { JsonObject } from './spec-config';
|
import type { JsonObject } from './spec-config';
|
||||||
@@ -50,7 +60,7 @@ const bar: ThemePreviewSpec = {
|
|||||||
|
|
||||||
const line: ThemePreviewSpec = {
|
const line: ThemePreviewSpec = {
|
||||||
id: 'line',
|
id: 'line',
|
||||||
caption: 'Line — subtitle, series legend',
|
caption: 'Line — subtitle, curve & markers',
|
||||||
spec: {
|
spec: {
|
||||||
$schema: SCHEMA,
|
$schema: SCHEMA,
|
||||||
title: { text: 'Signups over time', subtitle: 'Weekly, by plan' },
|
title: { text: 'Signups over time', subtitle: 'Weekly, by plan' },
|
||||||
@@ -72,7 +82,10 @@ const line: ThemePreviewSpec = {
|
|||||||
{ week: 4, plan: 'Team', n: 12 },
|
{ week: 4, plan: 'Team', n: 12 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
mark: { type: 'line', point: true },
|
// Bare `mark: 'line'` — the curve, stroke width, and point markers come from
|
||||||
|
// the draft config (the Marks panel's Lines & areas group), not the spec, so
|
||||||
|
// those controls have a live mirror. Same for every card below.
|
||||||
|
mark: 'line',
|
||||||
encoding: {
|
encoding: {
|
||||||
x: { field: 'week', type: 'quantitative', axis: { tickCount: 4 } },
|
x: { field: 'week', type: 'quantitative', axis: { tickCount: 4 } },
|
||||||
y: { field: 'n', type: 'quantitative' },
|
y: { field: 'n', type: 'quantitative' },
|
||||||
@@ -83,7 +96,7 @@ const line: ThemePreviewSpec = {
|
|||||||
|
|
||||||
const area: ThemePreviewSpec = {
|
const area: ThemePreviewSpec = {
|
||||||
id: 'area',
|
id: 'area',
|
||||||
caption: 'Stacked area — categorical palette',
|
caption: 'Normalized area — palette, %',
|
||||||
spec: {
|
spec: {
|
||||||
$schema: SCHEMA,
|
$schema: SCHEMA,
|
||||||
width: 200,
|
width: 200,
|
||||||
@@ -107,7 +120,9 @@ const area: ThemePreviewSpec = {
|
|||||||
mark: 'area',
|
mark: 'area',
|
||||||
encoding: {
|
encoding: {
|
||||||
x: { field: 'q', type: 'quantitative', axis: { tickCount: 4 } },
|
x: { field: 'q', type: 'quantitative', axis: { tickCount: 4 } },
|
||||||
y: { field: 'v', type: 'quantitative' },
|
// `stack: 'normalize'` makes the y-axis a 0–100% scale, so its labels use
|
||||||
|
// `config.normalizedNumberFormat` — the mirror for that Formats control.
|
||||||
|
y: { field: 'v', type: 'quantitative', stack: 'normalize' },
|
||||||
color: { field: 'channel', type: 'nominal' },
|
color: { field: 'channel', type: 'nominal' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -115,7 +130,7 @@ const area: ThemePreviewSpec = {
|
|||||||
|
|
||||||
const scatter: ThemePreviewSpec = {
|
const scatter: ThemePreviewSpec = {
|
||||||
id: 'scatter',
|
id: 'scatter',
|
||||||
caption: 'Scatter — gradient legend',
|
caption: 'Points — size, shape, gradient legend',
|
||||||
spec: {
|
spec: {
|
||||||
$schema: SCHEMA,
|
$schema: SCHEMA,
|
||||||
width: 200,
|
width: 200,
|
||||||
@@ -133,7 +148,10 @@ const scatter: ThemePreviewSpec = {
|
|||||||
{ x: 36, y: 20, z: 88 },
|
{ x: 36, y: 20, z: 88 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
mark: { type: 'point', filled: true, size: 80 },
|
// Bare `mark: 'point'` — size, shape, and fill come from the Marks panel's
|
||||||
|
// Points group, so those controls have a mirror (a hard-coded size/filled
|
||||||
|
// here would shadow them).
|
||||||
|
mark: 'point',
|
||||||
encoding: {
|
encoding: {
|
||||||
x: { field: 'x', type: 'quantitative' },
|
x: { field: 'x', type: 'quantitative' },
|
||||||
y: { field: 'y', type: 'quantitative' },
|
y: { field: 'y', type: 'quantitative' },
|
||||||
@@ -198,7 +216,7 @@ const diverging: ThemePreviewSpec = {
|
|||||||
|
|
||||||
const donut: ThemePreviewSpec = {
|
const donut: ThemePreviewSpec = {
|
||||||
id: 'donut',
|
id: 'donut',
|
||||||
caption: 'Donut — palette, symbol legend',
|
caption: 'Pie & donut — palette, legend',
|
||||||
spec: {
|
spec: {
|
||||||
$schema: SCHEMA,
|
$schema: SCHEMA,
|
||||||
width: 200,
|
width: 200,
|
||||||
@@ -211,7 +229,9 @@ const donut: ThemePreviewSpec = {
|
|||||||
{ browser: 'Other', share: 9 },
|
{ browser: 'Other', share: 9 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
mark: { type: 'arc', innerRadius: 32 },
|
// Bare `mark: 'arc'` renders a pie; the Marks panel's Arc group (donut hole,
|
||||||
|
// corner radius, pad angle) reshapes it — so those controls have a mirror.
|
||||||
|
mark: 'arc',
|
||||||
encoding: {
|
encoding: {
|
||||||
theta: { field: 'share', type: 'quantitative' },
|
theta: { field: 'share', type: 'quantitative' },
|
||||||
color: { field: 'browser', type: 'nominal' },
|
color: { field: 'browser', type: 'nominal' },
|
||||||
@@ -221,29 +241,30 @@ const donut: ThemePreviewSpec = {
|
|||||||
|
|
||||||
const facet: ThemePreviewSpec = {
|
const facet: ThemePreviewSpec = {
|
||||||
id: 'facet',
|
id: 'facet',
|
||||||
caption: 'Facets — header labels',
|
caption: 'Facets — headers, date format',
|
||||||
spec: {
|
spec: {
|
||||||
$schema: SCHEMA,
|
$schema: SCHEMA,
|
||||||
width: 70,
|
width: 80,
|
||||||
height: 110,
|
height: 110,
|
||||||
|
// Faceted by a raw temporal field (no time unit) so the header labels are
|
||||||
|
// dates formatted by `config.timeFormat` — the mirror for that Formats
|
||||||
|
// control (which does NOT affect axes, only text/legend/header labels) — on
|
||||||
|
// top of the header colour/size/weight the Headers panel styles.
|
||||||
data: {
|
data: {
|
||||||
values: [
|
values: [
|
||||||
{ team: 'Alpha', month: 'Jan', v: 14 },
|
{ period: '2026-01-15', team: 'Alpha', v: 14 },
|
||||||
{ team: 'Alpha', month: 'Feb', v: 21 },
|
{ period: '2026-01-15', team: 'Beta', v: 9 },
|
||||||
{ team: 'Alpha', month: 'Mar', v: 17 },
|
{ period: '2026-01-15', team: 'Gamma', v: 11 },
|
||||||
{ team: 'Beta', month: 'Jan', v: 9 },
|
{ period: '2026-06-15', team: 'Alpha', v: 21 },
|
||||||
{ team: 'Beta', month: 'Feb', v: 13 },
|
{ period: '2026-06-15', team: 'Beta', v: 13 },
|
||||||
{ team: 'Beta', month: 'Mar', v: 19 },
|
{ period: '2026-06-15', team: 'Gamma', v: 15 },
|
||||||
{ team: 'Gamma', month: 'Jan', v: 11 },
|
|
||||||
{ team: 'Gamma', month: 'Feb', v: 8 },
|
|
||||||
{ team: 'Gamma', month: 'Mar', v: 15 },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
mark: 'bar',
|
mark: 'bar',
|
||||||
encoding: {
|
encoding: {
|
||||||
x: { field: 'month', type: 'nominal', axis: { labelAngle: 0 } },
|
x: { field: 'team', type: 'nominal', axis: { labelAngle: 0 } },
|
||||||
y: { field: 'v', type: 'quantitative' },
|
y: { field: 'v', type: 'quantitative' },
|
||||||
column: { field: 'team', type: 'nominal' },
|
column: { field: 'period', type: 'temporal' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ const EDITORIAL: JsonObject = {
|
|||||||
titleColor: '#3a3326',
|
titleColor: '#3a3326',
|
||||||
},
|
},
|
||||||
view: { stroke: 'transparent' },
|
view: { stroke: 'transparent' },
|
||||||
|
// Mark styling lives in the theme (not the shared specs), so the demo charts
|
||||||
|
// stay rich while the Theme Builder gallery reflects its own config.
|
||||||
|
point: { filled: true, size: 55 },
|
||||||
|
line: { strokeWidth: 2.5 },
|
||||||
range: {
|
range: {
|
||||||
category: ['#7a5c3e', '#b07d3e', '#c9a14a', '#5e6b3f', '#9a5a44', '#46544c'],
|
category: ['#7a5c3e', '#b07d3e', '#c9a14a', '#5e6b3f', '#9a5a44', '#46544c'],
|
||||||
ramp: ['#f0e6d2', '#b07d3e', '#5e3a1e'],
|
ramp: ['#f0e6d2', '#b07d3e', '#5e3a1e'],
|
||||||
@@ -77,6 +81,8 @@ const BLUEPRINT: JsonObject = {
|
|||||||
titleColor: '#10314f',
|
titleColor: '#10314f',
|
||||||
},
|
},
|
||||||
view: { stroke: '#cdd9e5' },
|
view: { stroke: '#cdd9e5' },
|
||||||
|
point: { filled: false, size: 45, strokeWidth: 1.5 },
|
||||||
|
line: { strokeWidth: 2, point: true },
|
||||||
range: {
|
range: {
|
||||||
category: ['#0d6fb8', '#3aa0d1', '#7cc4e0', '#0d3b66', '#5a8fb3', '#9ec9e0'],
|
category: ['#0d6fb8', '#3aa0d1', '#7cc4e0', '#0d3b66', '#5a8fb3', '#9ec9e0'],
|
||||||
ramp: ['#e3eef7', '#3aa0d1', '#0d3b66'],
|
ramp: ['#e3eef7', '#3aa0d1', '#0d3b66'],
|
||||||
@@ -109,6 +115,8 @@ const SUNSET: JsonObject = {
|
|||||||
titleColor: '#3a2233',
|
titleColor: '#3a2233',
|
||||||
},
|
},
|
||||||
view: { stroke: 'transparent' },
|
view: { stroke: 'transparent' },
|
||||||
|
point: { filled: true, size: 70 },
|
||||||
|
line: { strokeWidth: 3 },
|
||||||
range: {
|
range: {
|
||||||
category: ['#ff6b6b', '#f06595', '#cc5de8', '#845ef7', '#ff922b', '#fcc419'],
|
category: ['#ff6b6b', '#f06595', '#cc5de8', '#845ef7', '#ff922b', '#fcc419'],
|
||||||
ramp: ['#ffe3c9', '#ff922b', '#cc5de8'],
|
ramp: ['#ffe3c9', '#ff922b', '#cc5de8'],
|
||||||
|
|||||||
Reference in New Issue
Block a user