Chart Builder: open a snippet in the builder to edit it in place

This commit is contained in:
2026-06-20 12:36:37 +03:00
parent efb5a9bbe0
commit be8cfc402d
16 changed files with 1046 additions and 14 deletions
+11 -5
View File
@@ -515,11 +515,17 @@ is in the maintained plan, not the archive.
**Chart Builder** (`exploration/chart-builder-enhancement-scope.md`): **Chart Builder** (`exploration/chart-builder-enhancement-scope.md`):
- **Open-in-builder + builder starter examples (3B/3C)** — the next builder slice in the - ~~**Open-in-builder (3C, strict hydration)**~~ ✅ shipped (2026-06-18): an editor-toolbar
scope doc: hydrate the **builder** from an existing spec (strict spec→config parse), behind **Open in builder** action reopens a builder-representable snippet to **edit it in place**
a starter-example front door. Distinct from the shipped onboarding gallery (`parseChartSpec` is the strict inverse of `buildChartSpec`, gated by re-assemble +
(`core/examples.ts``Onboarding.tsx`), which seeds a snippet straight into the editor, not deep-compare). Only dataset-referencing specs hydrate (the builder's data model). See the
the builder — there is no spec→builder-config path yet. scope doc status log.
- **Builder starter examples (3B)** — a small set of curated starters, one per covered FT
intent, **authored in the builder dialect so they reopen via 3C**. Reshaped by 3C's
data-model edge: a builder-openable starter must reference a dataset, so 3B ships paired
sample datasets (or is reframed) — decide its shape now that the gate is live. Distinct from
the shipped onboarding gallery (`core/examples.ts``Onboarding.tsx`), which seeds an
inline-data snippet straight into the editor (Monaco-only, never the builder).
- ~~**Transform-aware data inspector** — show resolved post-transform rows~~ ✅ shipped: an - ~~**Transform-aware data inspector** — show resolved post-transform rows~~ ✅ shipped: an
Input | Resolved data inspector below the Live Preview and Chart Builder charts, with a Input | Resolved data inspector below the Live Preview and Chart Builder charts, with a
draggable height divider (spec §04; arch 05 → "data inspector rides the boundary"). draggable height divider (spec §04; arch 05 → "data inspector rides the boundary").
+7
View File
@@ -284,6 +284,13 @@ export function toggleDatasets(): void {
} }
``` ```
> **Pre-hydrate variant — open without `init`.** When a modal must open onto state the
> caller already loaded (the Chart Builder's _Open in builder_ edit flow — `openChartBuilderForEdit`
> hydrates the builder from a snippet first), the opener sets the active modal, snapshots, and
> syncs the URL **itself** and **skips the registry `init`** — running `init` would re-derive the
> default state and clobber the hydration. Such an opener is the exception, not the rule: ordinary
> opens go through `openModal` so `init` is the single place transient state is seeded.
### Change detection ### Change detection
```ts ```ts
@@ -495,6 +495,22 @@ call the same functions as the visible control — one code path, two doors.
_(Consulted via /council → NN/g #6/#7, Carbon menu-buttons/overflow-menu. This bullet is _(Consulted via /council → NN/g #6/#7, Carbon menu-buttons/overflow-menu. This bullet is
the contract; cite it, not the source.)_ the contract; cite it, not the source.)_
**Resolved — content-gated toolbar actions hide; state-gated actions disable.** Two ways a
toolbar action can be inapplicable, with opposite affordances. **State-gated** — applicable
to this object in principle, just inert right now (Revert with no draft changes, Config in
the read-only Published view) → **disabled**: the user can act (edit / switch view) and it
lights up. **Content-gated** — inapplicable to _this spec's shape_, and nothing the user can
do in the moment changes that (_Extract to Dataset_ needs inline data; _Open in builder_
needs a builder-representable spec referencing an existing dataset) → **hidden**. A
permanently-disabled control the user cannot enable reads as broken or teasing, not as
guidance (NN/g #6 — a disabled state must imply "do X and this becomes available"; Carbon
button states). So _Open in builder_ (spec §06) sits in the editor toolbar beside _Extract
to Dataset_ and follows its visibility — present only when the active snippet round-trips
through the builder and its dataset exists. This refines "disabled is for temporarily
unavailable actions" (below) from the unbuilt-feature case to the per-spec case.
_(Consulted via /council → NN/g #4/#6, Carbon button usage/states. This bullet is the
contract; cite it, not the source.)_
**Resolved — field→channel assignment: explicit choice, visible armed state.** Clicking a **Resolved — field→channel assignment: explicit choice, visible armed state.** Clicking a
shelf field with no channel armed opens an explicit **channel chooser** (the channels that shelf field with no channel armed opens an explicit **channel chooser** (the channels that
accept the field; an occupied one is labelled with what it replaces) — never a silent accept the field; an occupied one is labelled with what it replaces) — never a silent
@@ -25,6 +25,32 @@
Newest first. The at-a-glance build-order tracker is §4; per-item detail is §3. This log is Newest first. The at-a-glance build-order tracker is §4; per-item detail is §3. This log is
the quick "where are we" — read it first. the quick "where are we" — read it first.
- **2026-06-18 (3C open-in-builder, edit-in-place)** — **the builder became create-and-revise.**
Core: `parseChartSpec` / `parseChartSpecText` (`chart-builder.ts`) — the strict inverse of
`buildChartSpec`, gated by **re-assemble + canonical deep-compare** (ignoring key order,
`$schema`, the injected `mark.tooltip`), so the gate widens for free as the dialect grows and
never lets the builder overwrite a richer spec. `unescapeVegaField` added beside its escape
twin (`rendering.ts`). Save semantics chosen with the user = **edit in place** (not
always-create): `SnippetStore.replaceSnippetSpec` republishes the built spec into both the
snippet's versions (auto names re-derive like publish; user names kept), and
`ChartBuilderStore` gained `editingSnippetId`/`editingSnippetName` + `openForEdit`/`saveEdits`.
Entry point: an **editor-toolbar** _Open in builder_ action (`SpecEditor`), beside _Extract to
Dataset_, shown only when the published spec round-trips _and_ its dataset exists — **hidden
otherwise** (content-gated, like Extract). The modal's primary becomes **Save changes** with an
"Editing _name_" banner; a dedicated `openChartBuilderForEdit` coordinator opener hydrates
before showing the modal so the registry's default `init` can't clobber it. **Data-model edge
(called out, not a bug):** the builder references a dataset by name, so only dataset-referencing
specs hydrate — inline-data snippets (incl. the onboarding gallery) stay Monaco-only. This
reshapes **3B**: builder-openable starters need a paired dataset, so 3B's shape is decided
_after_ this lands (see §3). Spec §06 gained an _Open in builder_ section. **Placement settled
by council:** an initial library metadata-panel placement (beside Duplicate/Delete) tested
undiscoverable; `/council` (NN/g #6 recognition / #4 consistency; Carbon button usage) put it in
the editor toolbar and settled the hidden-vs-disabled question via the toolbar's own precedent —
**content-gated** actions hide (Extract, Open-in-builder), **state-gated** ones disable (Revert,
Config). Recorded as a contract rule in `architecture/10` §5; the parked `ux-second-pass.md` row
is closed. Verified: typecheck + full tests (1089) + eslint + prettier. **Owed:** a visual/manual
pass on the live edit flow (the toolbar action, the "Editing" banner, Save changes).
- **2026-06-13 (guidance: reason over role, not raw type)** — closed a **false-positive class** - **2026-06-13 (guidance: reason over role, not raw type)** — closed a **false-positive class**
in `builderWarnings` (eng-council + council consult). A histogram (bar, binned-Q X, count Y) in `builderWarnings` (eng-council + council consult). A histogram (bar, binned-Q X, count Y)
tripped "two measures → scatter" because `isMeasureMapping`/`effectiveType` ignored `bin`. tripped "two measures → scatter" because `isMeasureMapping`/`effectiveType` ignored `bin`.
@@ -599,8 +625,8 @@ Phase 2 2A value-or-field channels (Property model) ✓ done
2B field shelf + in-place type cycling ✓ done (field-first + on-chart shelves) 2B field shelf + in-place type cycling ✓ done (field-first + on-chart shelves)
Phase 3 3D entry points & discoverability ✓ done (2026-06-12) Phase 3 3D entry points & discoverability ✓ done (2026-06-12)
3A intent-first front door (Tier C) ✓ done (2026-06-13); persistent strip 3A intent-first front door (Tier C) ✓ done (2026-06-13); persistent strip
3B starter examples ← next; pairs with 3C 3C open in builder (strict hydration) ✓ done (2026-06-18); edit-in-place
3C open in builder (strict hydration) ← added 2026-06-11; pairs with 3B 3B starter examples ← next; now hydrates via 3C
Marks +rect (Heatmap) ✓ done (2026-06-13) Marks +rect (Heatmap) ✓ done (2026-06-13)
Phase 4 (gated) theta/facets/styling-overrides/undo/lookup — decide after Phase 3 Phase 4 (gated) theta/facets/styling-overrides/undo/lookup — decide after Phase 3
+10
View File
@@ -164,6 +164,16 @@ Selecting "Create Snippet" produces the final artifact:
- Links the snippet to the dataset by recording the dataset reference, so the bidirectional snippet↔dataset relationship is established (see _Datasets_). - Links the snippet to the dataset by recording the dataset reference, so the bidirectional snippet↔dataset relationship is established (see _Datasets_).
- Closes the builder; the newly created snippet becomes the active snippet in the library/editor. **No success toast** — the result is immediately visible (the new snippet opens in the editor), so a toast would be noise (architecture 10 §1, "toast only what the user can't already see"). This refines the earlier blanket "every action toasts" rule, consistent with the Extract-to-dataset / publish reconciliation. - Closes the builder; the newly created snippet becomes the active snippet in the library/editor. **No success toast** — the result is immediately visible (the new snippet opens in the editor), so a toast would be noise (architecture 10 §1, "toast only what the user can't already see"). This refines the earlier blanket "every action toasts" rule, consistent with the Extract-to-dataset / publish reconciliation.
## Open in builder (edit in place)
The builder is also the way to **revise** a chart it could have produced — not only create one. The **editor toolbar** (top of the Spec Editor, alongside the Draft/Published toggle, Config, Revert, and Publish) offers an **Open in builder** action that reopens the active snippet in the builder, populated from its spec — the visual counterpart to editing the same chart's JSON, placed where that editing happens.
- **When it is offered.** Only when the active snippet's **published spec** is **losslessly representable** in the builder's dialect _and_ the dataset it references still exists. Losslessness is judged by parsing the spec back to a builder configuration and **re-assembling it for an exact comparison** against the original (ignoring key order, the `$schema` stamp, and the builder's injected `tooltip`), not by enumerating supported features — so the gate stays correct automatically as the builder's dialect grows. A spec the builder cannot reproduce exactly (hand-authored richness, an unsupported channel/mark, inline `data.values`, a `url` source) stays **Monaco-only**, and the action is **hidden** for it — the same content-gated treatment as _Extract to Dataset_ (a permanently-disabled control the user can't enable in the moment would read as broken; see _Interaction & Feedback_ → action visibility). The builder references a dataset **by name** — its sole data model — so only a snippet that references a saved dataset can hydrate.
- **What opening does.** Hydrates the builder from the snippet — mark, encodings, transforms, sort/stack, title/subtitle, size — and loads the referenced dataset's columns. The configuration pane names the snippet under edit ("Editing _name_"), and the dataset picker behaves as a **rebase** (built-on work) for any subsequent dataset switch — a loaded chart is never treated as a fresh default.
- **An edit session is builder-local, not part of the URL.** Like the builder's in-progress configuration generally, the "editing _name_" context is transient: a reload, Back, or shared link reopens the builder as a fresh **create** flow on the same dataset rather than restoring the edit. No data is lost — the published snippet is untouched until _Save changes_ — and re-entering the edit is one click from the toolbar.
- **Saving.** The primary action becomes **Save changes** (in place of "Create Snippet"): it **republishes** the built spec into the same snippet — overwriting **both** its published and draft versions, so there is no pending draft to reconcile — while keeping the snippet's identity, timestamps' `created`, and dataset links. A user-chosen name is preserved; an auto-named snippet re-derives its name from the new content (as _Publish_ does). The edited snippet becomes the active snippet. As with Create, there is no success toast (the result is immediately visible) and the action is gated on the same validation (at least one channel bound).
- **The JSON stays the document.** Because Open-in-builder is strict, the builder never silently overwrites a richer spec it cannot represent — it is a view that emits the spec, never a competing source of truth.
## Closing ## Closing
- The builder can be dismissed without creating anything (close control / modal dismissal). - The builder can be dismissed without creating anything (close control / modal dismissal).
@@ -58,6 +58,19 @@
gap: var(--space-3); gap: var(--space-3);
} }
/* Edit-in-place banner (spec §06 → Open in builder): names the snippet the builder
is editing, so the primary "Save changes" action reads unambiguously. */
.editingBanner {
margin: 0;
font-size: 12px;
color: var(--text-secondary);
}
.editingBanner strong {
color: var(--text-primary);
font-weight: 600;
}
/* ── Intent front door (spec §06 → Intent) ─────────────────────────────── /* ── Intent front door (spec §06 → Intent) ───────────────────────────────
A persistent "what do you want to show?" strip under the dataset picker. A A persistent "what do you want to show?" strip under the dataset picker. A
quiet accent-soft wash marks it as the guided on-ramp without competing with quiet accent-soft wash marks it as the guided on-ramp without competing with
+15 -4
View File
@@ -1466,6 +1466,12 @@ export function ChartBuilderModal() {
const setStack = useChartBuilderStore((s) => s.setStack); const setStack = useChartBuilderStore((s) => s.setStack);
const applyWarningFix = useChartBuilderStore((s) => s.applyWarningFix); const applyWarningFix = useChartBuilderStore((s) => s.applyWarningFix);
const runCreate = useChartBuilderStore((s) => s.createSnippet); const runCreate = useChartBuilderStore((s) => s.createSnippet);
const runSave = useChartBuilderStore((s) => s.saveEdits);
// Edit-in-place (spec §06 → Open in builder): when a snippet was opened in the
// builder, the primary action *saves back* to it instead of creating a new snippet.
const editingSnippetId = useChartBuilderStore((s) => s.editingSnippetId);
const editingSnippetName = useChartBuilderStore((s) => s.editingSnippetName);
const editing = editingSnippetId !== null;
// Validity + guidance + which chart-level controls apply are derived from the // Validity + guidance + which chart-level controls apply are derived from the
// stable `config` reference via useMemo, NOT a store selector that would build a // stable `config` reference via useMemo, NOT a store selector that would build a
@@ -1526,6 +1532,11 @@ export function ChartBuilderModal() {
<div className="visually-hidden" role="status" aria-live="polite"> <div className="visually-hidden" role="status" aria-live="polite">
{fixAnnouncement} {fixAnnouncement}
</div> </div>
{editing && (
<p className={styles.editingBanner}>
Editing <strong>{editingSnippetName}</strong>
</p>
)}
<DatasetPicker datasetId={datasetId} /> <DatasetPicker datasetId={datasetId} />
<IntentStrip /> <IntentStrip />
@@ -1610,7 +1621,7 @@ export function ChartBuilderModal() {
{!valid && ( {!valid && (
<p id="cb-create-hint" className={styles.createHint}> <p id="cb-create-hint" className={styles.createHint}>
Map at least one channel to a column to create a snippet. Map at least one channel to a column to {editing ? 'save changes' : 'create a snippet'}.
</p> </p>
)} )}
<div className={styles.actions}> <div className={styles.actions}>
@@ -1622,12 +1633,12 @@ export function ChartBuilderModal() {
size="lg" size="lg"
disabled={!valid} disabled={!valid}
aria-describedby={!valid ? 'cb-create-hint' : undefined} aria-describedby={!valid ? 'cb-create-hint' : undefined}
// The create is the user's confirmation — close with no discard prompt. // The commit is the user's confirmation — close with no discard prompt.
onClick={() => { onClick={() => {
if (runCreate()) void closeModal(true); if (editing ? runSave() : runCreate()) void closeModal(true);
}} }}
> >
Create Snippet {editing ? 'Save changes' : 'Create Snippet'}
</Button> </Button>
</div> </div>
</div> </div>
+36 -2
View File
@@ -13,7 +13,8 @@
* inline near the editor (spec §03E), mirroring the preview via PreviewStore. * inline near the editor (spec §03E), mirroring the preview via PreviewStore.
*/ */
import { useEffect, useRef, type RefObject } from 'react'; import { useEffect, useMemo, useRef, type RefObject } from 'react';
import { useShallow } from 'zustand/react/shallow';
// `edcore.main` is the full standalone editor — every feature contribution // `edcore.main` is the full standalone editor — every feature contribution
// (folding, suggest widget, word operations like Cmd+Backspace, find, bracket // (folding, suggest widget, word operations like Cmd+Backspace, find, bracket
// colorization, multi-cursor, …) — but WITHOUT the `monaco-editor` barrel's // colorization, multi-cursor, …) — but WITHOUT the `monaco-editor` barrel's
@@ -24,7 +25,8 @@ import 'monaco-editor/esm/vs/language/json/monaco.contribution';
import '../infrastructure/monaco-env'; // side-effect: wire workers before create import '../infrastructure/monaco-env'; // side-effect: wire workers before create
import { configureVegaLiteJson } from '../infrastructure/monaco-schema'; import { configureVegaLiteJson } from '../infrastructure/monaco-schema';
import { configureJsonFormatter, installFormatOnPaste } from '../infrastructure/monaco-format'; import { configureJsonFormatter, installFormatOnPaste } from '../infrastructure/monaco-format';
import { openModal } from '../modals/ModalCoordinator'; import { parseChartSpecText } from '@core/chart-builder';
import { openChartBuilderForEdit, openModal } from '../modals/ModalCoordinator';
import { import {
installSpecConfigActions, installSpecConfigActions,
runExtractConfig, runExtractConfig,
@@ -33,6 +35,7 @@ import {
} from '../services/spec-config-actions'; } from '../services/spec-config-actions';
import { useAppStore } from '../stores/AppStore'; import { useAppStore } from '../stores/AppStore';
import { confirm } from '../stores/ConfirmStore'; import { confirm } from '../stores/ConfirmStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { hasInlineData } from '../stores/ExtractStore'; import { hasInlineData } from '../stores/ExtractStore';
import { publishActiveSnippet } from '../services/snippet-actions'; import { publishActiveSnippet } from '../services/snippet-actions';
import { notify } from '../stores/NotificationStore'; import { notify } from '../stores/NotificationStore';
@@ -193,6 +196,26 @@ function EditorToolbar({
(s) => s.activeSnippetId !== null && hasInlineData(s.draftText), (s) => s.activeSnippetId !== null && hasInlineData(s.draftText),
); );
// Offer "Open in builder" only when the active snippet's published spec is
// losslessly representable in the builder dialect AND its referenced dataset
// exists — the gate the builder's openForEdit enforces (spec §06 → Open in
// builder). Content-gated like Extract, so it is **hidden** when inapplicable
// (the toolbar's convention for content-gated actions — vs Revert/Config, which
// disable because they are merely state-gated; arch 10 §5). `datasetNames` is a
// shallow-stable string[] so the memo doesn't churn (MEMORY → stable selectors).
const activeSpec = useSnippetStore((s) => selectActiveSnippet(s)?.spec ?? null);
const datasetNames = useDatasetStore(useShallow((s) => s.datasets.map((d) => d.name)));
const canOpenInBuilder = useMemo(() => {
if (activeSpec === null) return false;
const config = parseChartSpecText(activeSpec);
return config !== null && datasetNames.includes(config.datasetName);
}, [activeSpec, datasetNames]);
const handleOpenInBuilder = () => {
const snippet = selectActiveSnippet(useSnippetStore.getState());
if (snippet) openChartBuilderForEdit(snippet);
};
// Publish + its success toast live in one place (services/snippet-actions) so // Publish + its success toast live in one place (services/snippet-actions) so
// the button and the Cmd/Ctrl+S shortcut (EventRouter) behave identically. // the button and the Cmd/Ctrl+S shortcut (EventRouter) behave identically.
const handlePublish = publishActiveSnippet; const handlePublish = publishActiveSnippet;
@@ -239,6 +262,17 @@ function EditorToolbar({
to two lines and Revert/Publish stay on one row. The label rides in to two lines and Revert/Publish stay on one row. The label rides in
`title` (and the accessible name) when only the icon shows. Publish — the `title` (and the accessible name) when only the icon shows. Publish — the
one primary action — keeps its label at every width. */} one primary action — keeps its label at every width. */}
{canOpenInBuilder && (
<Button
className={styles.collapsible}
onClick={handleOpenInBuilder}
title="Open this chart in the visual builder to edit it"
aria-label="Open in builder"
>
<Icon name="chart" className={styles.actionIcon} />
<span className={styles.actionLabel}>Open in builder</span>
</Button>
)}
{canExtract && ( {canExtract && (
<Button <Button
className={styles.collapsible} className={styles.collapsible}
+25
View File
@@ -9,6 +9,8 @@
*/ */
import { useAppStore } from '../stores/AppStore'; import { useAppStore } from '../stores/AppStore';
import { useChartBuilderStore } from '../stores/ChartBuilderStore';
import type { Snippet } from '@core/snippet';
import type { ModalName } from './types'; import type { ModalName } from './types';
import { getModalConfig } from './modal-registry'; import { getModalConfig } from './modal-registry';
import { clearModalFromUrl, syncModalToUrl } from './UrlStateSync'; import { clearModalFromUrl, syncModalToUrl } from './UrlStateSync';
@@ -43,6 +45,29 @@ export function openModal(name: ModalName, arg?: string): void {
syncModalToUrl(name, arg); syncModalToUrl(name, arg);
} }
/**
* Open the Chart Builder to edit an existing snippet in place (spec §06 → Open in
* builder). Hydrates the builder from the snippet (strict spec→config parse + its
* referenced dataset) *before* showing the modal, deliberately skipping the
* registry's default `init` — which would re-derive a fresh config and clobber the
* hydration. A no-op when the snippet isn't losslessly builder-representable or its
* dataset is gone; the caller gates the entry point on the same two conditions, so
* this guard only ever fires on a race. Mirrors `openModal`'s bookkeeping minus init
* (the builder has no `getState`, so there is no editable snapshot to capture).
*/
export function openChartBuilderForEdit(snippet: Snippet): void {
// An edit session is builder-local and intentionally not URL-serializable: the
// hydrated builder serializes to `#datasets/dataset-N/build` (the same hash as a
// *create* flow on that dataset), so a reload / Back / shared link re-runs `init`
// and reopens a fresh create flow. The published spec is untouched until Save
// changes, so nothing is lost — only the transient edit context, matching the
// builder's already-non-persistent configuration (spec §06 → Open in builder).
if (!useChartBuilderStore.getState().openForEdit(snippet)) return;
useAppStore.getState().setActiveModal('chartBuilder');
stateSnapshot = snapshotOf('chartBuilder');
syncModalToUrl('chartBuilder');
}
/** /**
* Re-baseline the change snapshot to the modal's current state. Call after a * Re-baseline the change snapshot to the modal's current state. Call after a
* commit (the form was saved) or when a form view opens, so subsequent * commit (the form was saved) or when a form view opens, so subsequent
+67
View File
@@ -464,3 +464,70 @@ describe('switchDataset (the builder dataset picker)', () => {
expect(cb().config).toBe(before); expect(cb().config).toBe(before);
}); });
}); });
describe('openForEdit / saveEdits (edit-in-place, spec §06 → Open in builder)', () => {
/** Seed a dataset, build a snippet from it, and return that linked snippet. */
function buildSnippetFrom(name: string, data: unknown) {
const id = seedDataset(name, data);
cb().init(id);
cb().createSnippet(T); // creates a linked, builder-dialect snippet; resets the builder
return { id, snippet: useSnippetStore.getState().snippets[0] };
}
test('hydrates the builder from a builder-made snippet and records the edit target', () => {
const { id, snippet } = buildSnippetFrom('Sales', [
{ region: 'E', sales: 5 },
{ region: 'W', sales: 9 },
]);
expect(cb().openForEdit(snippet)).toBe(true);
expect(cb().editingSnippetId).toBe(snippet.id);
expect(cb().editingSnippetName).toBe(snippet.name);
expect(cb().datasetId).toBe(id);
expect(cb().config.datasetName).toBe('Sales');
// A loaded chart is built-on work: a dataset switch must rebase, not re-derive,
// so config must not be reference-equal to initialConfig (see switchDataset).
expect(cb().config).not.toBe(cb().initialConfig);
});
test('saveEdits republishes into the same snippet and clears edit state', () => {
const { snippet } = buildSnippetFrom('Sales', [
{ region: 'E', sales: 5 },
{ region: 'W', sales: 9 },
]);
const originalSpec = snippet.spec;
cb().openForEdit(snippet);
cb().setMark('point');
expect(cb().saveEdits(new Date('2026-06-02T00:00:00Z'))).toBe(true);
const after = useSnippetStore.getState().snippets.find((s) => s.id === snippet.id)!;
expect(after.spec).not.toBe(originalSpec);
expect(after.spec).toContain('"point"');
expect(after.spec).toBe(after.draftSpec); // republished — no pending draft
expect(useSnippetStore.getState().activeSnippetId).toBe(snippet.id);
// The builder reset out of edit mode.
expect(cb().editingSnippetId).toBeNull();
expect(cb().datasetId).toBeNull();
});
test('openForEdit refuses a spec the builder cannot represent (inline data)', () => {
seedDataset('Sales', [{ region: 'E', sales: 5 }]);
// The default sample template is an inline-data bar chart — no `{ data: { name } }`,
// so it is not builder-representable.
useSnippetStore.getState().createSnippet();
const inline = useSnippetStore.getState().snippets[0];
expect(cb().openForEdit(inline)).toBe(false);
expect(cb().editingSnippetId).toBeNull();
});
test('openForEdit refuses when the referenced dataset is gone', () => {
const { snippet } = buildSnippetFrom('Sales', [{ region: 'E', sales: 5 }]);
useDatasetStore.getState().reset(); // the dataset no longer exists
expect(cb().openForEdit(snippet)).toBe(false);
});
test('saveEdits is a no-op outside an edit session', () => {
expect(cb().saveEdits(T)).toBe(false);
});
});
+81
View File
@@ -29,6 +29,7 @@ import {
isBuilderConfigValid, isBuilderConfigValid,
isChannelTypeAllowed, isChannelTypeAllowed,
isColumnAllowedOnChannel, isColumnAllowedOnChannel,
parseChartSpecText,
pruneEncodings, pruneEncodings,
rebaseBuilderConfig, rebaseBuilderConfig,
supportsBin, supportsBin,
@@ -53,6 +54,7 @@ import {
type TimeUnit, type TimeUnit,
} from '@core/chart-builder'; } from '@core/chart-builder';
import type { ColumnType } from '@core/type-inference'; import type { ColumnType } from '@core/type-inference';
import type { Snippet } from '@core/snippet';
import { useDatasetStore } from './DatasetStore'; import { useDatasetStore } from './DatasetStore';
import { useSnippetStore } from './SnippetStore'; import { useSnippetStore } from './SnippetStore';
@@ -99,12 +101,38 @@ export interface ChartBuilderState {
* first empty channel that accepts it). * first empty channel that accepts it).
*/ */
activeChannel: ChannelName | null; activeChannel: ChannelName | null;
/**
* The snippet being **edited in place** (spec §06 → Open in builder), or null in
* the ordinary create flow. When set, the modal's primary action saves back to
* this snippet (`saveEdits`) instead of creating a new one, and its header names
* the snippet (`editingSnippetName`).
*/
editingSnippetId: string | null;
/** The edited snippet's name, for the modal header (snapshot at open). */
editingSnippetName: string | null;
/** /**
* Load a dataset and pre-populate a smart default config (spec §06 → Opening). * Load a dataset and pre-populate a smart default config (spec §06 → Opening).
* `null` picks the most recently modified dataset (the un-targeted entry points). * `null` picks the most recently modified dataset (the un-targeted entry points).
* Clears any edit-in-place state — a fresh open is a create flow.
*/ */
init: (datasetId: number | null) => void; init: (datasetId: number | null) => void;
/**
* Hydrate the builder from an existing snippet for **edit-in-place** (spec §06 →
* Open in builder). Parses the snippet's published spec back to a config (strict —
* `parseChartSpecText`), loads the referenced dataset's columns, and records the
* snippet so `saveEdits` writes back to it. Returns false (a no-op) when the spec
* isn't losslessly representable or its dataset is gone — the caller gates the
* entry point on the same two conditions, so this is a defensive guard.
*/
openForEdit: (snippet: Snippet) => boolean;
/**
* Save the working config back to the edited snippet (the **Save changes** action,
* spec §06 → Open in builder): republishes the built spec into both the snippet's
* versions via `SnippetStore.replaceSnippetSpec`, then resets. Returns false when
* not in edit mode or the config is invalid. `now` injectable.
*/
saveEdits: (now?: Date) => boolean;
/** /**
* Re-point the open builder at another dataset (the header picker, spec §06). * Re-point the open builder at another dataset (the header picker, spec §06).
* An untouched default config re-derives fresh smart defaults for the new * An untouched default config re-derives fresh smart defaults for the new
@@ -252,6 +280,8 @@ export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
config: EMPTY_CONFIG, config: EMPTY_CONFIG,
initialConfig: EMPTY_CONFIG, initialConfig: EMPTY_CONFIG,
activeChannel: null, activeChannel: null,
editingSnippetId: null,
editingSnippetName: null,
init: (datasetId) => { init: (datasetId) => {
const all = useDatasetStore.getState().datasets; const all = useDatasetStore.getState().datasets;
@@ -272,6 +302,8 @@ export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
config: EMPTY_CONFIG, config: EMPTY_CONFIG,
initialConfig: EMPTY_CONFIG, initialConfig: EMPTY_CONFIG,
activeChannel: null, activeChannel: null,
editingSnippetId: null,
editingSnippetName: null,
}); });
return; return;
} }
@@ -288,9 +320,56 @@ export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
config, config,
initialConfig: config, initialConfig: config,
activeChannel: null, activeChannel: null,
editingSnippetId: null,
editingSnippetName: null,
}); });
}, },
openForEdit: (snippet) => {
const config = parseChartSpecText(snippet.spec);
if (!config) return false; // not losslessly representable (caller gates on this)
const dataset = useDatasetStore.getState().datasets.find((d) => d.name === config.datasetName);
if (!dataset) return false; // the referenced dataset is gone (caller gates too)
const columns: BuilderColumns = {
columns: dataset.columns,
columnTypes: dataset.columnTypes,
columnStats: dataset.columnStats,
};
// Re-stamp the parsed transform ids through this store's own sequence so a later
// addFilter/addCalculate can't collide with one (parse numbers them by position).
const hydrated: BuilderConfig = { ...config };
if (config.calculates) {
hydrated.calculates = config.calculates.map((c) => ({ ...c, id: nextTransformId('c') }));
}
if (config.filters) {
hydrated.filters = config.filters.map((f) => ({ ...f, id: nextTransformId('f') }));
}
set({
datasetId: dataset.id,
columns,
rowCount: dataset.rowCount,
config: hydrated,
// A loaded chart has no re-derivable "opening default"; keep initialConfig
// distinct from config so a dataset switch rebases (preserving the loaded
// chart), never re-derives fresh defaults (see switchDataset).
initialConfig: EMPTY_CONFIG,
activeChannel: null,
editingSnippetId: snippet.id,
editingSnippetName: snippet.name,
});
return true;
},
saveEdits: (now) => {
const { config, editingSnippetId } = get();
if (editingSnippetId === null) return false; // not an edit session
if (!isBuilderConfigValid(config)) return false; // guarded by a disabled action too
const specText = buildSnippetSpecText(config);
const ok = useSnippetStore.getState().replaceSnippetSpec(editingSnippetId, specText, now);
if (ok) get().reset();
return ok;
},
switchDataset: (datasetId) => { switchDataset: (datasetId) => {
const s = get(); const s = get();
if (datasetId === s.datasetId) return; if (datasetId === s.datasetId) return;
@@ -576,6 +655,8 @@ export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
config: EMPTY_CONFIG, config: EMPTY_CONFIG,
initialConfig: EMPTY_CONFIG, initialConfig: EMPTY_CONFIG,
activeChannel: null, activeChannel: null,
editingSnippetId: null,
editingSnippetName: null,
}), }),
})); }));
+46
View File
@@ -639,3 +639,49 @@ describe('library view state (spec §02 → Search / Sort)', () => {
expect(store().activeSnippetId).toBe('a'); expect(store().activeSnippetId).toBe('a');
}); });
}); });
describe('replaceSnippetSpec (Chart Builder edit-in-place save)', () => {
const newSpec = JSON.stringify(
{
$schema: 'https://vega.github.io/schema/vega-lite/v6.json',
data: { name: 'D' },
mark: { type: 'point', tooltip: true },
encoding: { x: { field: 'a', type: 'quantitative' } },
},
null,
2,
);
test('republishes both versions, activates the snippet, and bumps modified', () => {
const a = createSnippet({
id: 'a',
spec: '{"mark":"bar"}',
now: new Date('2026-01-01T00:00:00Z'),
});
const b = createSnippet({ id: 'b', now: new Date('2026-02-01T00:00:00Z') });
store().hydrate([a, b]); // active = b (newest)
const ok = store().replaceSnippetSpec('a', newSpec, new Date('2026-03-01T00:00:00Z'));
expect(ok).toBe(true);
const after = store().snippets.find((s) => s.id === 'a')!;
expect(after.spec).toBe(newSpec);
expect(after.draftSpec).toBe(newSpec); // republished — no pending draft
expect(after.modified).toBe('2026-03-01T00:00:00.000Z');
expect(after.datasetRefs).toEqual(['D']); // recomputed from the new spec
expect(store().activeSnippetId).toBe('a'); // the edited snippet becomes active
expect(store().draftText).toBe(newSpec);
expect(store().editorView).toBe('draft');
});
test('keeps a user-chosen name; returns false for an unknown id', () => {
const a = createSnippet({ id: 'a', now: new Date('2026-01-01T00:00:00Z') });
store().hydrate([a]);
store().renameSnippet('a', 'My chart'); // nameSource -> 'user'
store().replaceSnippetSpec('a', newSpec);
expect(store().snippets.find((s) => s.id === 'a')!.name).toBe('My chart');
expect(store().replaceSnippetSpec('missing', newSpec)).toBe(false);
});
});
+45
View File
@@ -118,6 +118,17 @@ export interface SnippetState {
* by-name reference. No-op when no snippet is active. `now` injectable. * by-name reference. No-op when no snippet is active. `now` injectable.
*/ */
replaceActiveDraft: (text: string, now?: Date) => void; replaceActiveDraft: (text: string, now?: Date) => void;
/**
* Replace a snippet's spec wholesale and make it active — the Chart Builder's
* **Save changes** (edit-in-place, spec §06 Open in builder). Unlike the draft
* rewrite above, this *republishes*: both `spec` and `draftSpec` become the built
* text, so the edited snippet has no pending draft (the builder is the source).
* An auto-named snippet re-derives its name from the new content (like `publish`);
* a user-renamed one keeps its name. Flushes the outgoing active buffer first,
* recomputes `datasetRefs`, and reloads the editor on the now-active snippet.
* No-op (returns false) for an unknown id. `now` injectable.
*/
replaceSnippetSpec: (id: string, specText: string, now?: Date) => boolean;
/** /**
* Persist the editor buffer into the active snippet's **draft**, if it parses * Persist the editor buffer into the active snippet's **draft**, if it parses
* as JSON. Returns whether it committed (a half-typed, unparseable buffer is * as JSON. Returns whether it committed (a half-typed, unparseable buffer is
@@ -338,6 +349,40 @@ export const useSnippetStore = create<SnippetState>((set, get) => ({
})); }));
}, },
replaceSnippetSpec: (id, specText, now) => {
// Flush the outgoing active snippet's valid in-progress edits before we switch
// the active snippet (mirrors createSnippet / selectSnippet).
get().commitDraft(now);
const target = get().snippets.find((s) => s.id === id);
if (!target) return false;
const modified = (now ?? new Date()).toISOString();
const datasetRefs = recomputeDatasetRefs(specText);
set((s) => ({
snippets: s.snippets.map((x) =>
x.id === id
? {
...x,
// Auto names track content (as publish does); a user-chosen name stays.
...(isAutoNamed(x)
? { name: deriveSnippetName(specText) ?? x.name, nameSource: 'auto' as const }
: {}),
// Republish: the built spec becomes both versions, so there is no
// pending draft to reconcile after a builder save.
spec: specText,
draftSpec: specText,
datasetRefs,
modified,
}
: x,
),
activeSnippetId: id,
draftText: specText,
editorView: 'draft',
bufferEpoch: s.bufferEpoch + 1,
}));
return true;
},
commitDraft: (now) => { commitDraft: (now) => {
const { activeSnippetId, draftText, snippets } = get(); const { activeSnippetId, draftText, snippets } = get();
if (!activeSnippetId) return false; if (!activeSnippetId) return false;
+285
View File
@@ -27,6 +27,8 @@ import {
activeIntent, activeIntent,
buildChartSpec, buildChartSpec,
buildSnippetSpecText, buildSnippetSpecText,
parseChartSpec,
parseChartSpecText,
generateChartName, generateChartName,
validFilterOps, validFilterOps,
filterOpArity, filterOpArity,
@@ -1692,3 +1694,286 @@ describe('rebaseBuilderConfig (dataset switch)', () => {
expect(out).toEqual({ ...config, datasetName: 'New' }); expect(out).toEqual({ ...config, datasetName: 'New' });
}); });
}); });
describe('parseChartSpec (strict spec → config hydration, spec §06 → Open in builder)', () => {
// The master invariant: parse is the exact inverse of build *at the spec level*.
// Transform ids and an ignored value-type may legitimately differ on the config but
// never reach the spec, so the round-trip is asserted on the rebuilt spec, not the
// config: build ∘ parse ∘ build === build.
const roundTrips = (config: BuilderConfig) => {
const spec = buildChartSpec(config);
const parsed = parseChartSpec(spec);
expect(parsed).not.toBeNull();
expect(buildChartSpec(parsed!)).toEqual(spec);
};
it('round-trips the full range of builder configs', () => {
roundTrips({
datasetName: 'Sales',
mark: 'bar',
encodings: {
x: { field: 'category', type: 'nominal' },
y: { field: 'value', type: 'quantitative' },
},
});
// count + distinct + bin + timeUnit + aggregate
roundTrips({
datasetName: 'Sales',
mark: 'bar',
encodings: {
x: { field: 'value', type: 'quantitative', bin: true },
y: { type: 'quantitative', aggregate: 'count' },
},
});
roundTrips({
datasetName: 'Sales',
mark: 'line',
encodings: {
x: { field: 'when', type: 'temporal', timeUnit: 'yearmonth' },
y: { field: 'value', type: 'quantitative', aggregate: 'sum' },
color: { field: 'category', type: 'nominal' },
},
});
roundTrips({
datasetName: 'Sales',
mark: 'rect',
encodings: {
x: { field: 'region', type: 'nominal' },
y: { field: 'segment', type: 'ordinal' },
color: { field: 'value', type: 'quantitative', aggregate: 'distinct' },
},
});
// constant colour + size, explicit dimensions, title + subtitle
roundTrips({
datasetName: 'Sales',
mark: 'point',
encodings: {
x: { field: 'a', type: 'quantitative' },
y: { field: 'b', type: 'quantitative' },
color: { value: '#ff0000', type: 'nominal' },
size: { value: 100, type: 'quantitative' },
},
width: 480,
height: 320,
title: 'Profit by region',
subtitle: 'FY26',
});
// sort (ranking) + a field whose name needs escaping
roundTrips({
datasetName: 'Sales',
mark: 'bar',
sort: 'descending',
encodings: {
x: { field: 'user.region', type: 'nominal' },
y: { type: 'quantitative', aggregate: 'count' },
},
});
// stack (part-to-whole)
roundTrips({
datasetName: 'Sales',
mark: 'bar',
stack: 'normalize',
encodings: {
x: { field: 'when', type: 'temporal' },
y: { field: 'value', type: 'quantitative' },
color: { field: 'series', type: 'nominal' },
},
});
// every guarded filter operator + a calculated field
roundTrips({
datasetName: 'Sales',
mark: 'point',
encodings: {
x: { field: 'value', type: 'quantitative' },
y: { field: 'ratio', type: 'quantitative' },
},
calculates: [calc({ as: 'ratio', expr: 'datum.profit / datum.value' })],
filters: [
filter({ field: 'value', fieldType: 'quantitative', op: 'gte', value: '10' }),
filter({
id: 'f2',
field: 'value',
fieldType: 'quantitative',
op: 'range',
value: '0',
value2: '100',
}),
filter({
id: 'f3',
field: 'region',
fieldType: 'nominal',
op: 'oneOf',
value: 'North,South',
}),
filter({ id: 'f4', field: 'tier', fieldType: 'nominal', op: 'notEqual', value: 'Z' }),
filter({ id: 'f5', mode: 'expression', expr: 'datum.value > 0' }),
],
});
});
it('parses a clean bar chart to the exact config', () => {
const spec = {
$schema: VEGA_LITE_SCHEMA_URL,
data: { name: 'Sales' },
mark: { type: 'bar', tooltip: true },
encoding: {
x: { field: 'category', type: 'nominal' },
y: { aggregate: 'count', type: 'quantitative' },
},
};
expect(parseChartSpec(spec)).toEqual({
datasetName: 'Sales',
mark: 'bar',
encodings: {
x: { field: 'category', type: 'nominal' },
y: { type: 'quantitative', aggregate: 'count' },
},
});
});
it('accepts a bare-string mark and a missing tooltip (injected tooltip is ignored)', () => {
expect(
parseChartSpec({
data: { name: 'D' },
mark: 'point',
encoding: {
x: { field: 'a', type: 'quantitative' },
y: { field: 'b', type: 'quantitative' },
},
}),
).not.toBeNull();
});
it('rejects specs whose data is not a bare name reference', () => {
const enc = { x: { field: 'a', type: 'quantitative' } };
expect(parseChartSpec({ data: { values: [{ a: 1 }] }, mark: 'bar', encoding: enc })).toBeNull();
expect(parseChartSpec({ data: { url: 'x.csv' }, mark: 'bar', encoding: enc })).toBeNull();
expect(parseChartSpec({ mark: 'bar', encoding: enc })).toBeNull();
});
it('rejects content the builder cannot represent', () => {
const data = { name: 'D' };
// a mark outside the builder's set
expect(
parseChartSpec({
data,
mark: 'arc',
encoding: { theta: { field: 'v', type: 'quantitative' } },
}),
).toBeNull();
// a mark carrying an extra property
expect(
parseChartSpec({
data,
mark: { type: 'bar', filled: true },
encoding: { x: { field: 'a', type: 'nominal' } },
}),
).toBeNull();
// a channel the builder doesn't own
expect(
parseChartSpec({
data,
mark: 'arc',
encoding: { theta: { field: 'v', type: 'quantitative' } },
}),
).toBeNull();
expect(
parseChartSpec({
data,
mark: 'point',
encoding: {
x: { field: 'a', type: 'quantitative' },
tooltip: { field: 'b', type: 'nominal' },
},
}),
).toBeNull();
// an encoding field carrying an extra key (scale/axis/legend/title)
expect(
parseChartSpec({
data,
mark: 'bar',
encoding: {
x: { field: 'a', type: 'nominal', axis: { title: 'X' } },
y: { field: 'b', type: 'quantitative' },
},
}),
).toBeNull();
// a non-bare bin
expect(
parseChartSpec({
data,
mark: 'bar',
encoding: {
x: { field: 'a', type: 'quantitative', bin: { maxbins: 10 } },
y: { aggregate: 'count', type: 'quantitative' },
},
}),
).toBeNull();
// a foreign transform
expect(
parseChartSpec({
data,
mark: 'bar',
transform: [{ fold: ['a', 'b'] }],
encoding: { x: { field: 'a', type: 'nominal' } },
}),
).toBeNull();
// a calculated field with an extra key
expect(
parseChartSpec({
data,
mark: 'bar',
transform: [{ calculate: 'datum.a', as: 'c', extra: 1 }],
encoding: { x: { field: 'a', type: 'nominal' } },
}),
).toBeNull();
// an unknown top-level key (params/selection/config/…)
expect(
parseChartSpec({
data,
mark: 'bar',
params: [{ name: 'sel' }],
encoding: { x: { field: 'a', type: 'nominal' } },
}),
).toBeNull();
// a title object with an extra key
expect(
parseChartSpec({
data,
mark: 'bar',
title: { text: 'T', anchor: 'start' },
encoding: { x: { field: 'a', type: 'nominal' } },
}),
).toBeNull();
});
it('rejects a transform order the builder would not emit (filter before calculate)', () => {
// The builder always emits calculated fields before filters; the reverse order
// re-assembles differently, so the round-trip gate rejects it.
expect(
parseChartSpec({
data: { name: 'D' },
mark: 'point',
transform: [{ filter: 'datum.x > 0' }, { calculate: 'datum.a * 2', as: 'b' }],
encoding: {
x: { field: 'b', type: 'quantitative' },
y: { field: 'x', type: 'quantitative' },
},
}),
).toBeNull();
});
it('parseChartSpecText parses valid JSON and rejects the rest', () => {
const text = buildSnippetSpecText({
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'a', type: 'nominal' },
y: { aggregate: 'count', type: 'quantitative' },
},
});
expect(parseChartSpecText(text)).not.toBeNull();
expect(parseChartSpecText('{ not valid json')).toBeNull();
expect(parseChartSpecText('42')).toBeNull();
});
});
+352 -1
View File
@@ -27,7 +27,7 @@ import { DISTINCT_CAP } from './profile';
import type { ColumnType } from './type-inference'; import type { ColumnType } from './type-inference';
import { VEGA_LITE_SCHEMA_URL } from './snippet'; import { VEGA_LITE_SCHEMA_URL } from './snippet';
import { validateExpression } from './expr-validate'; import { validateExpression } from './expr-validate';
import { escapeVegaField } from './rendering'; import { escapeVegaField, unescapeVegaField } from './rendering';
/** The six mark types the builder offers, in selector order (spec §06). `rect` is the /** The six mark types the builder offers, in selector order (spec §06). `rect` is the
* heatmap mark an X×Y grid of cells shaded by a Colour measure. */ * heatmap mark an X×Y grid of cells shaded by a Colour measure. */
@@ -1458,6 +1458,357 @@ export function buildSnippetSpecText(config: BuilderConfig): string {
return JSON.stringify(buildChartSpec(config), null, 2); return JSON.stringify(buildChartSpec(config), null, 2);
} }
// ── Spec → config hydration (spec §06 → Open in builder) ────────────────────────
//
// The strict inverse of `buildChartSpec`: re-enter the builder populated from an
// existing snippet. Strict means *losslessly representable in the builder's
// dialect* — checked not by enumerating supported features but by parsing a
// candidate config, **re-assembling it, and deep-comparing against the original**
// (ignoring key order, `$schema`, and the injected `mark.tooltip`). That keeps the
// gate exact and self-correcting: every channel/transform the assembler learns to
// emit widens what hydrates for free, with no second list to maintain. A spec the
// builder can't reproduce exactly returns `null` and stays Monaco-only — the GUI
// never overwrites a richer spec (the source of truth stays the JSON).
type JsonObject = Record<string, unknown>;
/** A plain (non-array) object guard. */
function isObject(v: unknown): v is JsonObject {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
/** A canonical string for a JSON value with object keys sorted — order-independent equality. */
function canonicalJson(v: unknown): string {
if (Array.isArray(v)) return `[${v.map(canonicalJson).join(',')}]`;
if (isObject(v)) {
return `{${Object.keys(v)
.sort()
.map((k) => `${JSON.stringify(k)}:${canonicalJson(v[k])}`)
.join(',')}}`;
}
return JSON.stringify(v) ?? 'null';
}
/** Reduce a spec to the form the round-trip compares: drop `$schema`, and treat a
* bare-string mark and an `{ type, tooltip: true }` mark as one (the builder always
* injects `tooltip: true`, so it must not count as a difference). */
function normalizeForRoundTrip(spec: JsonObject): JsonObject {
const { $schema: _schema, ...rest } = spec;
void _schema;
if (rest.mark !== undefined) {
if (typeof rest.mark === 'string') {
rest.mark = { type: rest.mark };
} else if (isObject(rest.mark)) {
const mark = { ...rest.mark };
if (mark.tooltip === true) delete mark.tooltip;
rest.mark = mark;
}
}
return rest;
}
/** A `FieldType` if the value is one, else `null`. */
function asFieldType(v: unknown): FieldType | null {
return v === 'quantitative' || v === 'nominal' || v === 'ordinal' || v === 'temporal' ? v : null;
}
/** A constant value back-coerced to its `BuilderFilter` string shape (for the predicate forms). */
function filterValueToString(v: unknown): string | null {
if (typeof v === 'string') return v;
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
return null;
}
/** The `fieldType` whose `coerceFilterValue` reproduces this JSON value: a number
* came from a quantitative compare, anything else from a string compare. */
function filterTypeFor(v: unknown): FieldType {
return typeof v === 'number' ? 'quantitative' : 'nominal';
}
/** Parse one `{ filter }` transform entry into a `BuilderFilter`, or `null` if it is
* not a shape the guarded shelf / expression mode can represent. */
function parseFilter(entry: unknown, id: string): BuilderFilter | null {
if (!isObject(entry) || !('filter' in entry)) return null;
const f = entry.filter;
if (typeof f === 'string') return { id, mode: 'expression', expr: f };
if (!isObject(f)) return null;
// `notEqual` is the only wrapped predicate the assembler emits: `{ not: { field, equal } }`.
if (isObject(f.not) && Object.keys(f).length === 1) {
const inner = f.not;
if (typeof inner.field === 'string' && 'equal' in inner && Object.keys(inner).length === 2) {
const value = filterValueToString(inner.equal);
if (value === null) return null;
return {
id,
mode: 'predicate',
field: unescapeVegaField(inner.field),
fieldType: filterTypeFor(inner.equal),
op: 'notEqual',
value,
};
}
return null;
}
if (typeof f.field !== 'string') return null;
const field = unescapeVegaField(f.field);
const keys = Object.keys(f).filter((k) => k !== 'field');
if (keys.length !== 1) return null; // exactly one predicate operator besides `field`
const op = keys[0];
const base = { id, mode: 'predicate' as const, field };
switch (op) {
case 'equal':
case 'lt':
case 'lte':
case 'gt':
case 'gte': {
const value = filterValueToString(f[op]);
if (value === null) return null;
return { ...base, fieldType: filterTypeFor(f[op]), op, value };
}
case 'range': {
const r = f.range;
if (!Array.isArray(r) || r.length !== 2) return null;
const lo = filterValueToString(r[0]);
const hi = filterValueToString(r[1]);
if (lo === null || hi === null) return null;
return { ...base, fieldType: filterTypeFor(r[0]), op: 'range', value: lo, value2: hi };
}
case 'oneOf': {
const list = f.oneOf;
if (!Array.isArray(list) || list.length === 0) return null;
const items = list.map(filterValueToString);
if (items.some((s) => s === null)) return null;
// The shelf rebuilds the list by splitting on "," and trimming — so a member
// that itself contains a comma or edge whitespace can't round-trip; the gate
// (re-assemble + compare) rejects those, keeping this strict.
return {
...base,
fieldType: filterTypeFor(list[0]),
op: 'oneOf',
value: (items as string[]).join(','),
};
}
default:
return null;
}
}
/** Parse the top-level `transform` array into the config's calculates + filters
* (the two kinds the builder emits), or `null` if any entry is foreign. */
function parseTransforms(
transform: unknown,
): { calculates: BuilderCalculate[]; filters: BuilderFilter[] } | null {
const calculates: BuilderCalculate[] = [];
const filters: BuilderFilter[] = [];
if (transform === undefined) return { calculates, filters };
if (!Array.isArray(transform)) return null;
const entries: unknown[] = transform;
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (isObject(entry) && 'calculate' in entry) {
// A calculated field is exactly `{ calculate, as }` — any other key is foreign.
if (
typeof entry.calculate !== 'string' ||
typeof entry.as !== 'string' ||
Object.keys(entry).length !== 2
) {
return null;
}
calculates.push({ id: `c${i}`, expr: entry.calculate, as: entry.as });
continue;
}
const filter = parseFilter(entry, `f${i}`);
if (!filter) return null; // a transform the builder can't represent ⇒ reject
filters.push(filter);
}
return { calculates, filters };
}
/** Parse one encoding channel object into a `ChannelMapping`, plus any channel-level
* `sort`/`stack` the builder lifts to config level. `null` not builder dialect. */
function parseChannel(
channel: ChannelName,
enc: unknown,
): { mapping: ChannelMapping; sort?: SortOrder; stack?: StackMode } | null {
if (!isObject(enc)) return null;
const rest = { ...enc };
// Lift channel-level sort/stack out before reading the field mapping.
let sort: SortOrder | undefined;
let stack: StackMode | undefined;
if ('sort' in rest) {
const s = rest.sort;
if (s === 'x' || s === 'y') sort = 'ascending';
else if (s === '-x' || s === '-y') sort = 'descending';
else return null; // a richer sort spec (object/array) isn't builder dialect
delete rest.sort;
}
if ('stack' in rest) {
if (rest.stack === 'zero' || rest.stack === 'normalize') stack = rest.stack;
else return null;
delete rest.stack;
}
// A constant value: `{ value }` only.
if ('value' in rest) {
if (Object.keys(rest).length !== 1) return null;
const value = rest.value;
if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
return null;
}
const type: FieldType = channel === 'size' ? 'quantitative' : 'nominal';
return { mapping: { value, type }, sort, stack };
}
// A field-less count: `{ aggregate: 'count', type: 'quantitative' }`.
if (rest.aggregate === 'count') {
if (rest.type !== 'quantitative' || 'field' in rest || Object.keys(rest).length !== 2) {
return null;
}
return { mapping: { type: 'quantitative', aggregate: 'count' }, sort, stack };
}
// A field mapping: field + type (+ optional aggregate/bin/timeUnit), nothing else.
if (typeof rest.field !== 'string') return null;
const type = asFieldType(rest.type);
if (type === null) return null;
const mapping: ChannelMapping = { field: unescapeVegaField(rest.field), type };
const consumed = new Set(['field', 'type']);
if ('aggregate' in rest) {
const agg = rest.aggregate;
const valid: AggregateOp[] = ['distinct', 'sum', 'mean', 'median', 'min', 'max'];
if (typeof agg !== 'string' || !valid.includes(agg as AggregateOp)) return null;
mapping.aggregate = agg as AggregateOp;
consumed.add('aggregate');
}
if ('bin' in rest) {
if (rest.bin !== true) return null; // only the bare `bin: true` form
mapping.bin = true;
consumed.add('bin');
}
if ('timeUnit' in rest) {
if (!TIME_UNITS.includes(rest.timeUnit as TimeUnit)) return null;
mapping.timeUnit = rest.timeUnit as TimeUnit;
consumed.add('timeUnit');
}
if (Object.keys(rest).some((k) => !consumed.has(k))) return null; // a foreign key (axis/scale/…)
return { mapping, sort, stack };
}
/**
* The strict inverse of {@link buildChartSpec} (spec §06 Open in builder): a
* Vega-Lite spec object the `BuilderConfig` that produces it, or `null` when the
* spec is not **losslessly** representable in the builder's dialect. Losslessness is
* the authority a candidate config is parsed leniently, then re-assembled and
* deep-compared against the input (key order, `$schema`, and the injected
* `mark.tooltip` ignored). Only a spec the builder can reproduce *exactly* hydrates;
* everything else stays Monaco-only, so the builder never silently drops a richer
* spec's content.
*
* The dataset reference is read by name only (`{ data: { name } }`) the builder's
* sole data model; an inline-`values`/`url` spec has no builder representation and
* returns `null`. The caller still confirms the named dataset exists before opening.
*/
export function parseChartSpec(spec: unknown): BuilderConfig | null {
if (!isObject(spec)) return null;
// Data must be a bare name reference — the only data shape the builder emits.
if (!isObject(spec.data) || typeof spec.data.name !== 'string') return null;
const datasetName = spec.data.name;
// Mark: a known type, as a bare string or `{ type, tooltip? }`.
const markValue = isObject(spec.mark) ? spec.mark.type : spec.mark;
if (typeof markValue !== 'string' || !MARK_TYPES.includes(markValue as MarkType)) return null;
const mark = markValue as MarkType;
const config: BuilderConfig = { datasetName, mark, encodings: {} };
// Title: a bare string, or `{ text, subtitle? }`.
if ('title' in spec) {
if (typeof spec.title === 'string') {
config.title = spec.title;
} else if (isObject(spec.title) && typeof spec.title.text === 'string') {
config.title = spec.title.text;
if ('subtitle' in spec.title) {
if (typeof spec.title.subtitle !== 'string') return null;
config.subtitle = spec.title.subtitle;
}
} else {
return null;
}
}
// Transforms → calculated fields + filters.
const transforms = parseTransforms(spec.transform);
if (transforms === null) return null;
if (transforms.calculates.length > 0) config.calculates = transforms.calculates;
if (transforms.filters.length > 0) config.filters = transforms.filters;
// Encodings → channel mappings, lifting channel-level sort/stack to config level.
if ('encoding' in spec) {
if (!isObject(spec.encoding)) return null;
for (const key of Object.keys(spec.encoding)) {
if (!CHANNELS.includes(key as ChannelName)) return null; // a channel the builder doesn't own
const parsed = parseChannel(key as ChannelName, spec.encoding[key]);
if (parsed === null) return null;
config.encodings[key as ChannelName] = parsed.mapping;
if (parsed.sort) config.sort = parsed.sort;
if (parsed.stack) config.stack = parsed.stack;
}
}
// Explicit dimensions.
if ('width' in spec) {
if (typeof spec.width !== 'number') return null;
config.width = spec.width;
}
if ('height' in spec) {
if (typeof spec.height !== 'number') return null;
config.height = spec.height;
}
// Any top-level key the assembler doesn't emit ⇒ not builder dialect.
const allowedTop = new Set([
'$schema',
'data',
'mark',
'encoding',
'transform',
'title',
'width',
'height',
]);
if (Object.keys(spec).some((k) => !allowedTop.has(k))) return null;
// The authority: the parsed config must re-assemble to the original spec exactly.
if (
canonicalJson(normalizeForRoundTrip(buildChartSpec(config))) !==
canonicalJson(normalizeForRoundTrip(spec))
) {
return null;
}
return config;
}
/**
* Parse a snippet's spec **text** into a `BuilderConfig`, or `null` when the text
* isn't valid JSON or isn't losslessly representable in the builder (see
* {@link parseChartSpec}). The convenience the library/store use snippets store
* their spec as text.
*/
export function parseChartSpecText(specText: string): BuilderConfig | null {
let parsed: unknown;
try {
parsed = JSON.parse(specText);
} catch {
return null;
}
return parseChartSpec(parsed);
}
/** Title-case a single mark type for display/naming (e.g. `bar` → `Bar`). */ /** Title-case a single mark type for display/naming (e.g. `bar` → `Bar`). */
function markLabel(mark: MarkType): string { function markLabel(mark: MarkType): string {
return mark.charAt(0).toUpperCase() + mark.slice(1); return mark.charAt(0).toUpperCase() + mark.slice(1);
+9
View File
@@ -209,6 +209,15 @@ export function escapeVegaField(name: string): string {
return name.replace(/([.[\]])/g, '\\$1'); return name.replace(/([.[\]])/g, '\\$1');
} }
/**
* Inverse of {@link escapeVegaField}: drop the backslash escaping `.`/`[`/`]` to
* recover the literal column name. Used when *reading* a constructed `field:` back
* (the chart builder's specconfig hydration, spec §06 Open in builder).
*/
export function unescapeVegaField(field: string): string {
return field.replace(/\\([.[\]])/g, '$1');
}
/** /**
* Transform the authored spec into the spec to embed. Operates on a deep copy * Transform the authored spec into the spec to embed. Operates on a deep copy
* and returns it; the input is never mutated. * and returns it; the input is never mutated.