Editor: view-scoped Extract-to-Dataset for inline and self-defined data

This commit is contained in:
2026-06-29 02:08:59 +03:00
parent 8cad80f738
commit 6656811b8e
16 changed files with 910 additions and 180 deletions
@@ -164,35 +164,18 @@ export function libraryRefName(data: unknown, selfDefined: ReadonlySet<string>):
References appear in several places — top-level `data`, per-layer `data`, `data`
inside `spec`/`facet`/`hconcat`/`vconcat`, and a lookup transform's `from.data`.
Rather than enumerate the grammar, each pass walks the spec recursively but
**prunes two keys**: a `data` object's payload (its `values`/rows) and the
top-level `datasets` map hold user data, not nested specs. Without the prune, a
data _row_ carrying a field literally named `data: { name: "x" }` is misread as a
reference.
Rather than enumerate the grammar, the walk recurses the spec but **prunes two
keys**: a `data` object's payload (its `values`/rows) and the top-level `datasets`
map hold user data, not nested specs. Without the prune, a data _row_ carrying a
field literally named `data: { name: "x" }` is misread as a reference.
```ts
// src/core/spec-refs.ts — the recursive walk; classification routes through spec-data
export function extractDatasetRefs(spec: Json): string[] {
const root = typeof spec === 'string' ? safeParse(spec) : spec; // unparseable → no refs
const selfDefined = selfDefinedNames(root);
const names = new Set<string>();
const walk = (node: Json): void => {
if (Array.isArray(node)) return void node.forEach(walk);
if (node && typeof node === 'object') {
const obj = node as Record<string, Json>;
const refName = libraryRefName(obj.data, selfDefined);
if (refName !== null) names.add(refName);
for (const key of Object.keys(obj)) {
if (key === 'data' || key === 'datasets') continue; // prune user-data payloads
walk(obj[key]);
}
}
};
walk(root);
return [...names];
}
```
That walk is **one shared pair** in `core/spec-data`, not re-implemented per pass:
`forEachDataBinding(spec, visit)` (read-only, `visit` returns `true` to stop early)
and `mapDataBindings(spec, mapData)` (returns a copy). Both compute the spec's
self-defined names once and hand them to the callback, so a caller writes only its
own rule — _what is a reference_ (`libraryRefName`) or _what to rewrite_ — never the
walk. `extractDatasetRefs` collects through `forEachDataBinding`; `renameDatasetInSpec`
and `promoteSelfDefinedDataset` (§3.2) rewrite through `mapDataBindings`.
`recomputeDatasetRefs` is the thin sorted/de-duped wrapper stored on
`snippet.datasetRefs`, run on every draft change and on publish.
@@ -211,22 +194,50 @@ export function extractDatasetRefs(spec: Json): string[] {
programmatic extract/revert rewrites, never on raw keystrokes. That keeps the
links in step with the edited draft while never recomputing from a
transiently-invalid spec.
- Keep the three ref walks in lockstep — extraction here, `renameDatasetInSpec`,
and the renderer's `resolveDatasetRefs` (`src/core/rendering.ts`). They agree
because they share both halves: the `libraryRefName` classifier (what is a
reference) and the prune of the **same two keys** (`data`, `datasets`). If one
classified differently, or descended into data payloads while another didn't, a
row field named `data` would get counted, rewritten, or throw
`DatasetNotFoundError`.
- Route every binding pass through the shared `forEachDataBinding` /
`mapDataBindings`. They share both halves that must agree — the `libraryRefName`
classifier (what is a reference) and the prune of the **same two keys** (`data`,
`datasets`) — so extraction, rename, and the reverse extraction cannot drift. If
one classified differently, or descended into data payloads while another didn't,
a row field named `data` would get counted, rewritten, or throw
`DatasetNotFoundError`. (The renderer's `resolveDatasetRefs` mutates in place and
can throw mid-walk, so it keeps its own copy of the walk — the one exception, held
in step by the same prune-two-keys rule.)
**Don't**
- Don't let two code paths each have their own idea of "referenced names".
Renamer, ref-recomputer, and renderer must use the same walk shape.
- Don't add a fresh prune-walk for a new binding pass — reuse the shared pair. A
hand-written copy is one classifier tweak away from disagreeing with the others.
- Don't "enumerate the grammar" (scope the walk to a fixed list of container
keys) to fix the payload-descent problem — pruning the two data-bearing keys
stays correct as Vega-Lite's composition grammar grows; an allow-list rots.
### 3.2 Extracting embedded data into a dataset — the reverse (`spec-inline-data.ts`, `spec-refs.ts`)
Extract-to-Dataset is the inverse of a reference: it lifts a view's **embedded**
data into a stored dataset and rewrites the spec to reference it by name. It is a
cursor-scoped editor action — `services/extract-action` resolves the focused view's
binding (`dataBindingAtPath`), seeds the modal, then opens it; the gate
`specHasExtractableData` hides the toolbar action when no view carries liftable
data. Two embedded shapes lift, both routing through the same `spec-data` classifier
so they never disagree with reference detection:
- **Inline `values`** — `inlineValuesOf` captures the payload verbatim (a CSV/TSV
string is kept as-is); confirm rewrites that view's `data` block, at its anchor
path, to `{ name }`.
- **A self-defined `datasets` entry** the view references — `selfDefinedPayloadOf`
reads the named rows and `promoteSelfDefinedDataset` drops the `datasets` entry
(and the map when it empties). Keeping the name needs no reference rewrite — it
un-shadows onto the new library dataset; renaming rewrites every matching
reference. This is the reverse direction of the self-defined-vs-library
precedence in §3.1.
A `lookup` transform's inline `from.data` lifts like any view binding —
`dataBindingAtPath` finds it. A `url`, a generator, or an existing library
reference carries nothing to lift.
---
## 4. Reverse lookup: who uses this dataset?