mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Core: classify spec data blocks by Vega-Lite fidelity (spec-data) for dataset refs
This commit is contained in:
@@ -74,6 +74,10 @@ This is the at-a-glance list; keep it in sync with them.
|
|||||||
|
|
||||||
**Next (flagged for build):**
|
**Next (flagged for build):**
|
||||||
|
|
||||||
|
- **Multi-view data model** ([`multi-view-data-model-scope.md`](exploration/multi-view-data-model-scope.md)) —
|
||||||
|
durable composition support across the data-facing features. M1 (Vega-Lite-fidelity
|
||||||
|
reference classifier, `core/spec-data`) is done; M2–M4 extend the editor data context,
|
||||||
|
Extract, and the data inspector to be view-scoped.
|
||||||
- **Chart Builder · 3B starter examples** ([`chart-builder-enhancement-scope.md`](exploration/chart-builder-enhancement-scope.md) §3) —
|
- **Chart Builder · 3B starter examples** ([`chart-builder-enhancement-scope.md`](exploration/chart-builder-enhancement-scope.md) §3) —
|
||||||
a small set of curated starters, one per covered FT intent. Reshaped by 3C: a
|
a small set of curated starters, one per covered FT intent. Reshaped by 3C: a
|
||||||
builder-openable starter must reference a dataset, so it ships paired sample datasets (or is
|
builder-openable starter must reference a dataset, so it ships paired sample datasets (or is
|
||||||
|
|||||||
@@ -135,70 +135,67 @@ without waiting for a publish. Recomputation runs only on a _valid_ spec —
|
|||||||
auto-save is debounced and parse-gated (spec §03B) — so a transiently-invalid
|
auto-save is debounced and parse-gated (spec §03B) — so a transiently-invalid
|
||||||
draft never disturbs the links.
|
draft never disturbs the links.
|
||||||
|
|
||||||
### 3.1 Extracting referenced names from a spec (pure — `src/core/spec-refs.ts`)
|
### 3.1 What counts as a reference, and extracting them (pure — `src/core/spec-data.ts`, `spec-refs.ts`)
|
||||||
|
|
||||||
A Vega-Lite spec can reference named data in several places: the top-level
|
A library reference is exactly Vega-Lite **named data**: a `data` block with a
|
||||||
`data`, per-layer `data`, `data` inside `spec`/`facet`/`hconcat`/`vconcat`, and a
|
string `name` and no `values`, `url`, or generator key (`sequence`/`sphere`/
|
||||||
lookup transform's `from.data`. A spec may also define its OWN inline datasets via
|
`graticule`), whose name the spec does not define for itself via a top-level
|
||||||
a top-level `datasets` map — those are self-defined, not library references.
|
`datasets` map. A `name` riding on inline `values` or a `url` is Vega-Lite's
|
||||||
Rather than enumerate Vega-Lite's grammar, we walk the spec recursively and
|
runtime-rebind label — not a dependency — and self-defined `datasets` names
|
||||||
collect every `{ data: { name } }` — but **prune two keys**: never recurse into a
|
resolve natively; both are left untouched. This mirrors Vega-Lite's own
|
||||||
`data` object's payload (its `values`/rows) or the top-level `datasets` map,
|
`isNamedData`, so Astrolabe **extends** Vega-Lite rather than diverging: every
|
||||||
because those hold user data, not nested specs. Without the prune, a data _row_
|
native data form keeps working, and only true references are tracked and resolved.
|
||||||
carrying a field literally named `data: { name: "x" }` is misread as a reference.
|
|
||||||
This is pure, deterministic, and the most heavily unit-tested function here.
|
That classification lives in **`core/spec-data`** (`classifyData`,
|
||||||
|
`libraryRefName`) — the single predicate that reference extraction, rename
|
||||||
|
(`spec-refs`), and render-time resolution (`rendering`) all route through, so they
|
||||||
|
cannot disagree on what is a dependency.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// src/core/spec-refs.ts
|
// src/core/spec-data.ts — the shared classifier (mirrors vega-lite/src/data.ts)
|
||||||
|
|
||||||
type Json = unknown;
|
/** The library name a `data` block references, or null for native VL data / self-defined names. */
|
||||||
|
export function libraryRefName(data: unknown, selfDefined: ReadonlySet<string>): string | null {
|
||||||
|
if (classifyData(data) !== 'named') return null; // url | values | generator → not a reference
|
||||||
|
const { name } = data as { name: string };
|
||||||
|
return selfDefined.has(name) ? null : name;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/core/spec-refs.ts — the recursive walk; classification routes through spec-data
|
||||||
|
|
||||||
/** Collects every library dataset name referenced by `{ data: { name } }`, excluding self-defined ones. */
|
|
||||||
export function extractDatasetRefs(spec: Json): string[] {
|
export function extractDatasetRefs(spec: Json): string[] {
|
||||||
const root = typeof spec === 'string' ? safeParse(spec) : spec;
|
const root = typeof spec === 'string' ? safeParse(spec) : spec; // unparseable → no refs
|
||||||
const selfDefined = selfDefinedNames(root); // names from the spec's own top-level `datasets`
|
const selfDefined = selfDefinedNames(root);
|
||||||
const names = new Set<string>();
|
const names = new Set<string>();
|
||||||
|
|
||||||
const walk = (node: Json): void => {
|
const walk = (node: Json): void => {
|
||||||
if (Array.isArray(node)) {
|
if (Array.isArray(node)) return void node.forEach(walk);
|
||||||
for (const item of node) walk(item);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (node && typeof node === 'object') {
|
if (node && typeof node === 'object') {
|
||||||
const obj = node as Record<string, Json>;
|
const obj = node as Record<string, Json>;
|
||||||
const data = obj.data as Record<string, Json> | undefined;
|
const refName = libraryRefName(obj.data, selfDefined);
|
||||||
if (data && typeof data === 'object' && typeof data.name === 'string') {
|
if (refName !== null) names.add(refName);
|
||||||
if (!selfDefined.has(data.name)) names.add(data.name);
|
|
||||||
}
|
|
||||||
// Prune: a `data` payload and the `datasets` map hold user data, not refs.
|
|
||||||
for (const key of Object.keys(obj)) {
|
for (const key of Object.keys(obj)) {
|
||||||
if (key === 'data' || key === 'datasets') continue;
|
if (key === 'data' || key === 'datasets') continue; // prune user-data payloads
|
||||||
walk(obj[key]);
|
walk(obj[key]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
walk(root);
|
walk(root);
|
||||||
return [...names];
|
return [...names];
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeParse(s: string): Json {
|
|
||||||
try {
|
|
||||||
return JSON.parse(s);
|
|
||||||
} catch {
|
|
||||||
return null; // an unparseable draft simply has no resolvable refs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
```ts
|
`recomputeDatasetRefs` is the thin sorted/de-duped wrapper stored on
|
||||||
// src/core/spec-refs.ts — thin wrapper, run on every draft change and on publish
|
`snippet.datasetRefs`, run on every draft change and on publish.
|
||||||
|
|
||||||
/** The list stored on snippet.datasetRefs. Sorted + de-duped for stable diffs. */
|
|
||||||
export function recomputeDatasetRefs(spec: Json): string[] {
|
|
||||||
return extractDatasetRefs(spec).sort();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> A `spec` may be an object or a string (see the Data Model). Normalize once,
|
> A `spec` may be an object or a string (see the Data Model). Normalize once,
|
||||||
> at the boundary, so the recursive walk never has to care.
|
> at the boundary, so the recursive walk never has to care.
|
||||||
@@ -207,17 +204,19 @@ export function recomputeDatasetRefs(spec: Json): string[] {
|
|||||||
|
|
||||||
- Treat `extractDatasetRefs` as the single source of truth for "what does this
|
- Treat `extractDatasetRefs` as the single source of truth for "what does this
|
||||||
spec reference". The reverse-lookup and rename paths both depend on it
|
spec reference". The reverse-lookup and rename paths both depend on it
|
||||||
agreeing with what the renderer actually resolves.
|
agreeing with what the renderer actually resolves — which holds because all of
|
||||||
|
them classify through the same `core/spec-data` predicate.
|
||||||
- Recompute and store `datasetRefs` on **every draft change and on publish** —
|
- Recompute and store `datasetRefs` on **every draft change and on publish** —
|
||||||
but only through the parse-gated, debounced auto-save (`commitDraft`) and the
|
but only through the parse-gated, debounced auto-save (`commitDraft`) and the
|
||||||
programmatic extract/revert rewrites, never on raw keystrokes. That keeps the
|
programmatic extract/revert rewrites, never on raw keystrokes. That keeps the
|
||||||
links in step with the edited draft while never recomputing from a
|
links in step with the edited draft while never recomputing from a
|
||||||
transiently-invalid spec.
|
transiently-invalid spec.
|
||||||
- Prune the **same two keys** (`data`, `datasets`) in all three ref walks —
|
- Keep the three ref walks in lockstep — extraction here, `renameDatasetInSpec`,
|
||||||
extraction here, `renameDatasetInSpec`, and the renderer's `resolveDatasetRefs`
|
and the renderer's `resolveDatasetRefs` (`src/core/rendering.ts`). They agree
|
||||||
(`src/core/rendering.ts`). They must agree on what counts as a reference; if one
|
because they share both halves: the `libraryRefName` classifier (what is a
|
||||||
descends into data payloads and another doesn't, extraction and rendering
|
reference) and the prune of the **same two keys** (`data`, `datasets`). If one
|
||||||
disagree and a row field named `data` either gets counted, rewritten, or throws
|
classified differently, or descended into data payloads while another didn't, a
|
||||||
|
row field named `data` would get counted, rewritten, or throw
|
||||||
`DatasetNotFoundError`.
|
`DatasetNotFoundError`.
|
||||||
|
|
||||||
**Don't**
|
**Don't**
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Multi-view data model — scope & plan
|
||||||
|
|
||||||
|
Astrolabe authors arbitrary Vega-Lite, including **composed** specs (`layer`,
|
||||||
|
`hconcat`/`vconcat`/`concat`, `facet`, `repeat`). Most of the spec-structure
|
||||||
|
machinery already handles composition; the data-facing features carried
|
||||||
|
single-view assumptions. This memo records the assessment, the data-model
|
||||||
|
contract that anchors the work, and the milestone plan to make multi-view support
|
||||||
|
durable. The guiding constraint: **extend Vega-Lite, never break it** — every
|
||||||
|
native data form must keep working.
|
||||||
|
|
||||||
|
## The data-model contract
|
||||||
|
|
||||||
|
A dataset reference is exactly Vega-Lite **named data**: a `data` block with a
|
||||||
|
string `name` and no `values`/`url`/generator key, whose name the spec does not
|
||||||
|
self-define via top-level `datasets`. This mirrors Vega-Lite's `isNamedData`
|
||||||
|
(`reference/vega-lite/src/data.ts`). The classification is owned by
|
||||||
|
`core/spec-data` (`classifyData`, `libraryRefName`); reference extraction
|
||||||
|
(`spec-refs`), rename (`spec-refs`), and render-time resolution (`rendering`) all
|
||||||
|
route through it. See `docs/architecture/07` §3.1.
|
||||||
|
|
||||||
|
Library references resolve to inline data before embedding
|
||||||
|
(`core/rendering` → `prepareSpecForRender`); a self-defined `datasets` name is
|
||||||
|
left for Vega-Lite to resolve natively.
|
||||||
|
|
||||||
|
## Assessment: already multi-view vs. single-view assumptions
|
||||||
|
|
||||||
|
**Already composition-aware** (recurse all view operators): ref extraction/rename
|
||||||
|
(`spec-refs`), reference resolution + fit-mode (`rendering`), structural wrap/
|
||||||
|
unwrap/add-view (`spec-transforms`, `spec-cursor`, `spec-insert`), derived-field
|
||||||
|
collection (`spec-fields`), config baking (`spec-config`), standalone export
|
||||||
|
(`chart-export`, reuses `prepareSpecForRender`).
|
||||||
|
|
||||||
|
**Single-view assumptions** (the work):
|
||||||
|
|
||||||
|
- **Editor data context** (`app/services/active-dataset`) resolves _one_ dataset
|
||||||
|
for the whole draft (first ref, or first inline data), with no notion of which
|
||||||
|
view the cursor sits in. Completion/hover/inlay (`spec-dataset-hints`) and the
|
||||||
|
facet/repeat field defaults (`spec-transform-actions`) therefore offer the wrong
|
||||||
|
view's columns in a composition whose views bind different datasets.
|
||||||
|
- **Extract inline data** (`app/stores/ExtractStore`) lifts only the top-level
|
||||||
|
`data` block.
|
||||||
|
- **Data inspector** (`core/result-data`, `DataInspector`) surfaces one input +
|
||||||
|
one resolved table; a composition produces several `source_<n>`/`data_<n>`.
|
||||||
|
- **`deriveSnippetName`** (`core/snippet`) reads top-level `mark`/`encoding` only,
|
||||||
|
so a composed spec falls back to the default name (graceful, not a bug).
|
||||||
|
- **Chart builder** is single-view by design; its strict round-trip hydration
|
||||||
|
returns `null` for composed specs, so they stay Monaco-only (correct).
|
||||||
|
|
||||||
|
## Vega-Lite fidelity clashes
|
||||||
|
|
||||||
|
1. **Named inline/url data misread as a reference** — classifying on "has a
|
||||||
|
string `name`" alone caught named-inline (`{ name, values }`) and named-url,
|
||||||
|
breaking valid specs (spurious `DatasetNotFoundError`, or clobbered inline
|
||||||
|
values). _Resolved_ by the `core/spec-data` classifier (M1).
|
||||||
|
2. **Shadowing** — a library dataset whose name equals a self-defined `datasets`
|
||||||
|
key is silently ignored (self-defined wins). Documented precedence; candidate
|
||||||
|
for a user-facing note, no code change required.
|
||||||
|
3. **Case-rule split** — library matching is case-insensitive (`naming.ts`);
|
||||||
|
self-defined exclusion and Vega-Lite's own named-data lookup are case-sensitive.
|
||||||
|
These are distinct namespaces, so the split is defensible; minor.
|
||||||
|
4. **Runtime-injected named data** — Vega-Lite allows binding `{ name }` at
|
||||||
|
runtime; Astrolabe always pre-resolves, so an imported spec relying on runtime
|
||||||
|
injection won't render. Out of scope.
|
||||||
|
|
||||||
|
## Milestone plan
|
||||||
|
|
||||||
|
- **M1 — data-model foundation** ✅ — `core/spec-data` classifier mirroring
|
||||||
|
`isNamedData`; `spec-refs` + `rendering` routed through it. Closes clash 1.
|
||||||
|
- **M2 — view-scoped editor context** — pure `dataContextAtPath(spec, path)`:
|
||||||
|
climb the cursor's JSON path to the nearest enclosing `data` (honoring
|
||||||
|
Vega-Lite's parent→child data inheritance), classify it, and collect
|
||||||
|
ancestor-chain derived fields. Rework `active-dataset` to be cursor-scoped and
|
||||||
|
thread the offset through the three Monaco providers and the facet/repeat
|
||||||
|
defaults. Resolve columns for every form: library ref, inline `values`,
|
||||||
|
named-inline, self-defined `datasets`, url (no static rows), generator (none).
|
||||||
|
- **M3 — view-scoped extract** — seed Extract from the focused view's inline data
|
||||||
|
(reusing the cursor-scope machinery) and rewrite that view's `data`.
|
||||||
|
- **M4 — multi-view inspection** — group the dataflow's datasets into per-view
|
||||||
|
input/resolved pairs (`result-data`) and add a view selector to `DataInspector`
|
||||||
|
(new interactive widget → `/council`).
|
||||||
|
|
||||||
|
Delivery is incremental, one milestone per commit, verified against real behavior.
|
||||||
|
The consolidated data-model contract write-up into `docs/architecture` (05/08)
|
||||||
|
lands once the shape is final.
|
||||||
@@ -203,6 +203,26 @@ describe('prepareSpecForRender — dataset resolution (spec §04 Rendering Contr
|
|||||||
expect(out.data).toEqual({ name: 'local' });
|
expect(out.data).toEqual({ name: 'local' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('native Vega-Lite data with a name label is left untouched (not resolved, never throws)', () => {
|
||||||
|
// A name on inline/url data, or a generator, is native Vega-Lite — even when
|
||||||
|
// the name is absent from the library. Resolution must not touch or reject it.
|
||||||
|
const inline = { data: { name: 'pts', values: [{ a: 1 }] }, mark: 'point' };
|
||||||
|
expect(prepareSpecForRender(inline, { datasets }).data).toEqual({
|
||||||
|
name: 'pts',
|
||||||
|
values: [{ a: 1 }],
|
||||||
|
});
|
||||||
|
const url = { data: { name: 'remote', url: 'https://x/y.csv' } };
|
||||||
|
expect(prepareSpecForRender(url, { datasets }).data).toEqual({
|
||||||
|
name: 'remote',
|
||||||
|
url: 'https://x/y.csv',
|
||||||
|
});
|
||||||
|
const gen = { data: { name: 'seq', sequence: { start: 0, stop: 5 } } };
|
||||||
|
expect(prepareSpecForRender(gen, { datasets }).data).toEqual({
|
||||||
|
name: 'seq',
|
||||||
|
sequence: { start: 0, stop: 5 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('resolution runs before fit-mode: a ref + fit mode produce both transforms', () => {
|
test('resolution runs before fit-mode: a ref + fit mode produce both transforms', () => {
|
||||||
const spec = { data: { name: 'JsonDs' }, mark: 'bar' };
|
const spec = { data: { name: 'JsonDs' }, mark: 'bar' };
|
||||||
const out = prepareSpecForRender(spec, { datasets, fitMode: 'full' }) as unknown as {
|
const out = prepareSpecForRender(spec, { datasets, fitMode: 'full' }) as unknown as {
|
||||||
|
|||||||
+25
-32
@@ -10,13 +10,15 @@
|
|||||||
* 1. Dataset reference resolution — implemented in M3 (see below).
|
* 1. Dataset reference resolution — implemented in M3 (see below).
|
||||||
* 2. Fit-mode sizing — implemented in M2.
|
* 2. Fit-mode sizing — implemented in M2.
|
||||||
*
|
*
|
||||||
* Step 1 (spec §04 → Rendering Contract): every named-data reference
|
* Step 1 (spec §04 → Rendering Contract): every library reference
|
||||||
* (`{ data: { name } }`) is replaced in-place with the referenced library
|
* (`{ data: { name } }`) is replaced in-place with the referenced library
|
||||||
* dataset's actual contents, shaped by source and format. A name the spec defines
|
* dataset's actual contents, shaped by source and format. What counts as a library
|
||||||
* for itself via a top-level `datasets` object is left untouched (Vega-Lite
|
* reference is decided by `core/spec-data` (named data, not self-defined), so a
|
||||||
* resolves it natively); an unknown library name throws `DatasetNotFoundError`.
|
* `name` riding on inline `values`/`url`/a generator is native Vega-Lite data and
|
||||||
* Resolution recurses into the same nested sub-specs as fit-mode, runs before
|
* is left untouched; a name the spec defines for itself via a top-level `datasets`
|
||||||
* sizing, and operates only on the copy.
|
* object is left untouched too (Vega-Lite resolves it natively); an unknown library
|
||||||
|
* name throws `DatasetNotFoundError`. Resolution recurses into the same nested
|
||||||
|
* sub-specs as fit-mode, runs before sizing, and operates only on the copy.
|
||||||
*
|
*
|
||||||
* The copy-not-mutate invariant and the call site the renderer depends on are
|
* The copy-not-mutate invariant and the call site the renderer depends on are
|
||||||
* fixed.
|
* fixed.
|
||||||
@@ -24,6 +26,7 @@
|
|||||||
|
|
||||||
import type { DataFormat } from './format-detection';
|
import type { DataFormat } from './format-detection';
|
||||||
import type { DataSource } from './dataset';
|
import type { DataSource } from './dataset';
|
||||||
|
import { libraryRefName, selfDefinedNames } from './spec-data';
|
||||||
|
|
||||||
/** Preview sizing modes (spec §04 → Fit / Sizing Modes). `default` = Original. */
|
/** Preview sizing modes (spec §04 → Fit / Sizing Modes). `default` = Original. */
|
||||||
export type FitMode = 'default' | 'width' | 'height' | 'full';
|
export type FitMode = 'default' | 'width' | 'height' | 'full';
|
||||||
@@ -113,16 +116,6 @@ function applyFitMode(node: unknown, mode: FitMode): void {
|
|||||||
if (isSpecNode(node.spec)) applyFitMode(node.spec, mode);
|
if (isSpecNode(node.spec)) applyFitMode(node.spec, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The set of dataset names a spec defines for itself via top-level `datasets`. */
|
|
||||||
function selfDefinedDatasetNames(spec: unknown): Set<string> {
|
|
||||||
const names = new Set<string>();
|
|
||||||
if (isSpecNode(spec)) {
|
|
||||||
const datasets = spec.datasets;
|
|
||||||
if (isSpecNode(datasets)) for (const key of Object.keys(datasets)) names.add(key);
|
|
||||||
}
|
|
||||||
return names;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the replacement `data` object for one resolved reference (spec §04 →
|
* Build the replacement `data` object for one resolved reference (spec §04 →
|
||||||
* Rendering Contract, step 1). `rest` is the reference's other keys (e.g. a
|
* Rendering Contract, step 1). `rest` is the reference's other keys (e.g. a
|
||||||
@@ -157,13 +150,16 @@ function resolvedData(dataset: ResolvableDataset, rest: SpecNode): SpecNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace every named-data reference in `node` with its library dataset's
|
* Replace every library reference in `node` with its dataset's contents, recursing
|
||||||
* contents, recursing through the spec (arrays and objects) so refs anywhere are
|
* through the spec (arrays and objects) so refs anywhere are resolved — matching
|
||||||
* resolved — matching `extractDatasetRefs`, including pruning the `data` and
|
* `extractDatasetRefs`, including pruning the `data` and top-level `datasets`
|
||||||
* top-level `datasets` payload keys so resolution never descends into user data
|
* payload keys so resolution never descends into user data rows. What counts as a
|
||||||
* rows. A self-defined name is left untouched; an unknown library name throws
|
* library reference is decided by the shared `libraryRefName` (core/spec-data):
|
||||||
* `DatasetNotFoundError`. Matching is case-insensitive, mirroring naming.ts.
|
* named data the spec does not define for itself. A `name` riding on inline
|
||||||
* Mutates in place; the caller already works on a copy.
|
* `values`, a `url`, or a generator is native Vega-Lite data and is left untouched;
|
||||||
|
* an unknown library name throws `DatasetNotFoundError`. Matching is
|
||||||
|
* case-insensitive, mirroring naming.ts. Mutates in place; the caller already works
|
||||||
|
* on a copy.
|
||||||
*/
|
*/
|
||||||
function resolveDatasetRefs(
|
function resolveDatasetRefs(
|
||||||
node: unknown,
|
node: unknown,
|
||||||
@@ -176,16 +172,13 @@ function resolveDatasetRefs(
|
|||||||
}
|
}
|
||||||
if (!isSpecNode(node)) return;
|
if (!isSpecNode(node)) return;
|
||||||
|
|
||||||
const data = node.data;
|
const refName = libraryRefName(node.data, selfDefined);
|
||||||
if (isSpecNode(data) && typeof data.name === 'string') {
|
if (refName !== null) {
|
||||||
const name = data.name;
|
const dataset = byName.get(refName.toLowerCase());
|
||||||
if (!selfDefined.has(name)) {
|
if (!dataset) throw new DatasetNotFoundError(refName);
|
||||||
const dataset = byName.get(name.toLowerCase());
|
const { name: _drop, ...rest } = node.data as SpecNode;
|
||||||
if (!dataset) throw new DatasetNotFoundError(name);
|
|
||||||
const { name: _drop, ...rest } = data;
|
|
||||||
node.data = resolvedData(dataset, rest);
|
node.data = resolvedData(dataset, rest);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Recurse into every key except the two that hold data payloads (`data` —
|
// Recurse into every key except the two that hold data payloads (`data` —
|
||||||
// resolved/captured above; `datasets` — the spec's own inline data). Pruning
|
// resolved/captured above; `datasets` — the spec's own inline data). Pruning
|
||||||
@@ -229,7 +222,7 @@ export function prepareSpecForRender<T>(spec: T, options: PrepareOptions = {}):
|
|||||||
const datasets = options.datasets ?? [];
|
const datasets = options.datasets ?? [];
|
||||||
const byName = new Map<string, ResolvableDataset>();
|
const byName = new Map<string, ResolvableDataset>();
|
||||||
for (const d of datasets) byName.set(d.name.toLowerCase(), d);
|
for (const d of datasets) byName.set(d.name.toLowerCase(), d);
|
||||||
resolveDatasetRefs(copy, byName, selfDefinedDatasetNames(copy));
|
resolveDatasetRefs(copy, byName, selfDefinedNames(copy));
|
||||||
|
|
||||||
// 2. Fit-mode sizing.
|
// 2. Fit-mode sizing.
|
||||||
applyFitMode(copy, options.fitMode ?? 'default');
|
applyFitMode(copy, options.fitMode ?? 'default');
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
import { classifyData, libraryRefName, selfDefinedNames } from './spec-data';
|
||||||
|
|
||||||
|
describe('classifyData', () => {
|
||||||
|
test('classifies the four Vega-Lite data shapes', () => {
|
||||||
|
expect(classifyData({ url: 'x.csv' })).toBe('url');
|
||||||
|
expect(classifyData({ values: [{ a: 1 }] })).toBe('inline');
|
||||||
|
expect(classifyData({ sequence: { start: 0, stop: 5 } })).toBe('generator');
|
||||||
|
expect(classifyData({ sphere: true })).toBe('generator');
|
||||||
|
expect(classifyData({ graticule: true })).toBe('generator');
|
||||||
|
expect(classifyData({ name: 'Sales' })).toBe('named');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('url / values / generator win over a bare name (Vega-Lite precedence)', () => {
|
||||||
|
// A name on these shapes is a runtime-rebind label, not a named reference.
|
||||||
|
expect(classifyData({ name: 'pts', values: [{ a: 1 }] })).toBe('inline');
|
||||||
|
expect(classifyData({ name: 'remote', url: 'x.csv' })).toBe('url');
|
||||||
|
expect(classifyData({ name: 'seq', sequence: { start: 0, stop: 5 } })).toBe('generator');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for non-data values', () => {
|
||||||
|
expect(classifyData(null)).toBeNull();
|
||||||
|
expect(classifyData(undefined)).toBeNull();
|
||||||
|
expect(classifyData('string')).toBeNull();
|
||||||
|
expect(classifyData([])).toBeNull();
|
||||||
|
expect(classifyData({})).toBeNull();
|
||||||
|
expect(classifyData({ name: 123 })).toBeNull(); // a non-string name is not named data
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('selfDefinedNames', () => {
|
||||||
|
test('collects top-level datasets keys', () => {
|
||||||
|
expect([...selfDefinedNames({ datasets: { a: [], b: [] } })].sort()).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is empty when there is no datasets map', () => {
|
||||||
|
expect(selfDefinedNames({ data: { name: 'x' } }).size).toBe(0);
|
||||||
|
expect(selfDefinedNames(null).size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('libraryRefName', () => {
|
||||||
|
const none = new Set<string>();
|
||||||
|
|
||||||
|
test('returns the name of a true named reference', () => {
|
||||||
|
expect(libraryRefName({ name: 'Sales' }, none)).toBe('Sales');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for inline/url/generator data carrying a name', () => {
|
||||||
|
expect(libraryRefName({ name: 'pts', values: [{ a: 1 }] }, none)).toBeNull();
|
||||||
|
expect(libraryRefName({ name: 'remote', url: 'x.csv' }, none)).toBeNull();
|
||||||
|
expect(libraryRefName({ name: 'seq', sequence: { start: 0, stop: 5 } }, none)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for a self-defined name', () => {
|
||||||
|
expect(libraryRefName({ name: 'local' }, new Set(['local']))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for a non-data value', () => {
|
||||||
|
expect(libraryRefName(null, none)).toBeNull();
|
||||||
|
expect(libraryRefName({ values: [{ a: 1 }] }, none)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* The Vega-Lite data model — the single place that decides what a `data` block
|
||||||
|
* *means* (docs/architecture/05 → Rendering Contract; 07 → Relationships). Pure
|
||||||
|
* core: no browser APIs, no React.
|
||||||
|
*
|
||||||
|
* A Vega-Lite `data` block is exactly one of four shapes (mirrored from
|
||||||
|
* vega-lite/src/data.ts — `isUrlData`/`isInlineData`/`isNamedData`/`isGenerator`):
|
||||||
|
*
|
||||||
|
* - **url** — has `url`
|
||||||
|
* - **inline** — has `values` (and MAY also carry a `name` label)
|
||||||
|
* - **generator** — has `sequence` | `sphere` | `graticule`
|
||||||
|
* - **named** — has a string `name`, and NONE of the above
|
||||||
|
*
|
||||||
|
* Astrolabe binds its library datasets through *named* data: `{ data: { name } }`
|
||||||
|
* resolves to the library dataset's rows at render time (core/rendering). The
|
||||||
|
* fidelity rule, taken verbatim from Vega-Lite, is that a `name` ALONE does not
|
||||||
|
* make a reference — a `name` riding on inline `values`, a `url`, or a generator
|
||||||
|
* is a label Vega-Lite uses to rebind data at runtime, and Astrolabe must leave it
|
||||||
|
* untouched. Classifying by this rule (not by "has a string name") is what keeps
|
||||||
|
* Astrolabe a strict *extension* of Vega-Lite rather than a divergence: every
|
||||||
|
* native data form keeps working, and only true named references are resolved.
|
||||||
|
*
|
||||||
|
* Reference detection (`extractDatasetRefs`/rename in core/spec-refs) and reference
|
||||||
|
* resolution (core/rendering) both run through `libraryRefName` here, so the two
|
||||||
|
* can never disagree on what counts as a library dependency.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { isJsonObject } from './spec-config';
|
||||||
|
|
||||||
|
/** The four Vega-Lite data shapes; `null` when the value is not a data block. */
|
||||||
|
export type DataKind = 'url' | 'inline' | 'generator' | 'named';
|
||||||
|
|
||||||
|
/** The keys that mark a Vega-Lite data *generator* (sequence/sphere/graticule). */
|
||||||
|
const GENERATOR_KEYS = ['sequence', 'sphere', 'graticule'] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify a Vega-Lite `data` value, matching Vega-Lite's own precedence: `url`
|
||||||
|
* and `values` and generator keys win over a bare `name`, so a named-inline or
|
||||||
|
* named-url block is inline/url data — not a named reference. Returns `null` for a
|
||||||
|
* non-object, `data: null`, or an empty/unrecognized block.
|
||||||
|
*/
|
||||||
|
export function classifyData(data: unknown): DataKind | null {
|
||||||
|
if (!isJsonObject(data)) return null;
|
||||||
|
if ('url' in data) return 'url';
|
||||||
|
if ('values' in data) return 'inline';
|
||||||
|
if (GENERATOR_KEYS.some((key) => key in data)) return 'generator';
|
||||||
|
if (typeof data.name === 'string') return 'named';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The set of dataset names a spec defines for itself via a top-level `datasets`
|
||||||
|
* object. These are resolved natively by Vega-Lite, so they are never library
|
||||||
|
* dependencies and are excluded from reference detection and resolution.
|
||||||
|
*/
|
||||||
|
export function selfDefinedNames(spec: unknown): Set<string> {
|
||||||
|
const names = new Set<string>();
|
||||||
|
if (isJsonObject(spec) && isJsonObject(spec.datasets)) {
|
||||||
|
for (const key of Object.keys(spec.datasets)) names.add(key);
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The library dataset name a `data` block references, or `null` when it is not a
|
||||||
|
* library reference — i.e. it is named data (`classifyData` → `'named'`) whose
|
||||||
|
* name is not one the spec defines for itself. This is the single predicate behind
|
||||||
|
* both reference extraction and render-time resolution.
|
||||||
|
*/
|
||||||
|
export function libraryRefName(data: unknown, selfDefined: ReadonlySet<string>): string | null {
|
||||||
|
if (classifyData(data) !== 'named') return null;
|
||||||
|
const { name } = data as { name: string };
|
||||||
|
return selfDefined.has(name) ? null : name;
|
||||||
|
}
|
||||||
@@ -33,11 +33,24 @@ describe('extractDatasetRefs', () => {
|
|||||||
expect(extractDatasetRefs('{ not valid json')).toEqual([]);
|
expect(extractDatasetRefs('{ not valid json')).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('inline-data and url-data references (no name) are ignored', () => {
|
test('inline-data and url-data (no name) are not references', () => {
|
||||||
expect(extractDatasetRefs({ data: { values: [{ a: 1 }] } })).toEqual([]);
|
expect(extractDatasetRefs({ data: { values: [{ a: 1 }] } })).toEqual([]);
|
||||||
expect(extractDatasetRefs({ data: { url: 'http://x/y.csv' } })).toEqual([]);
|
expect(extractDatasetRefs({ data: { url: 'http://x/y.csv' } })).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a name on inline/url data is a Vega-Lite label, not a library reference', () => {
|
||||||
|
// `{ name, values }` is inline data and `{ name, url }` is url data — the name
|
||||||
|
// is Vega-Lite's runtime-rebind label, not a dependency (vega-lite/src/data.ts).
|
||||||
|
expect(extractDatasetRefs({ data: { name: 'pts', values: [{ a: 1 }] } })).toEqual([]);
|
||||||
|
expect(extractDatasetRefs({ data: { name: 'remote', url: 'http://x/y.csv' } })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generator data is not a reference', () => {
|
||||||
|
expect(extractDatasetRefs({ data: { sequence: { start: 0, stop: 10 } } })).toEqual([]);
|
||||||
|
expect(extractDatasetRefs({ data: { name: 'globe', sphere: true } })).toEqual([]);
|
||||||
|
expect(extractDatasetRefs({ data: { graticule: true } })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test('excludes names the spec defines for itself via top-level datasets', () => {
|
test('excludes names the spec defines for itself via top-level datasets', () => {
|
||||||
const spec = {
|
const spec = {
|
||||||
datasets: { foo: [{ a: 1 }] },
|
datasets: { foo: [{ a: 1 }] },
|
||||||
@@ -128,14 +141,16 @@ describe('renameDatasetInSpec', () => {
|
|||||||
expect(Object.keys(out.datasets)).toEqual(['Old']);
|
expect(Object.keys(out.datasets)).toEqual(['Old']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('does not rewrite a "data" field buried in inline data rows', () => {
|
test('does not rewrite a named-inline block or a "data" field buried in its rows', () => {
|
||||||
|
// `{ name, values }` is inline data — the name is a Vega-Lite label, not a
|
||||||
|
// library reference — so neither it nor the row payload is renamed.
|
||||||
const spec = {
|
const spec = {
|
||||||
data: { name: 'Old', values: [{ data: { name: 'Old' } }] },
|
data: { name: 'Old', values: [{ data: { name: 'Old' } }] },
|
||||||
mark: 'bar',
|
mark: 'bar',
|
||||||
};
|
};
|
||||||
const out = renameDatasetInSpec(spec, 'Old', 'New');
|
const out = renameDatasetInSpec(spec, 'Old', 'New');
|
||||||
expect(out.data.name).toBe('New'); // the real reference is renamed
|
expect(out.data.name).toBe('Old');
|
||||||
expect(out.data.values[0].data.name).toBe('Old'); // the row payload is left alone
|
expect(out.data.values[0].data.name).toBe('Old');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('renames a reference inside a lookup transform (from.data)', () => {
|
test('renames a reference inside a lookup transform (from.data)', () => {
|
||||||
|
|||||||
+46
-55
@@ -3,34 +3,35 @@
|
|||||||
* (spec §09F → Cross-entity relationships; docs/architecture/07 §3.1, §6).
|
* (spec §09F → Cross-entity relationships; docs/architecture/07 §3.1, §6).
|
||||||
*
|
*
|
||||||
* Portable core: no browser APIs, no React, no store access. A Vega-Lite spec
|
* Portable core: no browser APIs, no React, no store access. A Vega-Lite spec
|
||||||
* references named data through `{ "data": { "name": "MyDataset" } }`, which can
|
* references a library dataset through *named* data — `{ "data": { "name": "X" } }`
|
||||||
* appear at the top level, per-layer, inside `spec`/`facet`/concat children, or in
|
* — which can appear at the top level, per-layer, inside `spec`/`facet`/concat
|
||||||
* a lookup transform's `from.data`. Rather than enumerate the grammar, we walk the
|
* children, or in a lookup transform's `from.data`. Rather than enumerate the
|
||||||
* spec recursively and collect every `{ data: { name } }` we find — the single
|
* grammar, we walk the spec recursively and collect every library reference we
|
||||||
* source of truth for "what does this spec reference", which the renderer's
|
* find — the single source of truth for "what does this spec reference", which the
|
||||||
* resolution must agree with.
|
* renderer's resolution must agree with.
|
||||||
|
*
|
||||||
|
* What counts as a *library reference* is decided by `libraryRefName`
|
||||||
|
* (core/spec-data): named data (a string `name` with no `values`/`url`/generator)
|
||||||
|
* whose name the spec does not define for itself via a top-level `datasets` object.
|
||||||
|
* A `name` riding on inline `values` or a `url` is a Vega-Lite label, not a
|
||||||
|
* dependency, so it is left alone — keeping Astrolabe a strict extension of
|
||||||
|
* Vega-Lite. The renderer resolves through the same predicate, so the two cannot
|
||||||
|
* disagree.
|
||||||
*
|
*
|
||||||
* The walk recurses into every key EXCEPT two, which hold user data payloads
|
* The walk recurses into every key EXCEPT two, which hold user data payloads
|
||||||
* rather than nested specs: a `data` object (its `name` is captured at the parent
|
* rather than nested specs: a `data` object (classified at the parent site; its
|
||||||
* site; its `values`/`format` are payload, never a nested ref) and a top-level
|
* `values`/`format` are payload, never a nested ref) and a top-level `datasets`
|
||||||
* `datasets` map (the spec's own inline data). Pruning those is what keeps a data
|
* map (the spec's own inline data). Pruning those is what keeps a data *row* that
|
||||||
* *row* that happens to carry a field literally named `data: { name: "x" }` from
|
* happens to carry a field literally named `data: { name: "x" }` from being misread
|
||||||
* being misread as a library reference. The renderer's resolution prunes the same
|
* as a library reference. The renderer's resolution prunes the same two keys.
|
||||||
* two keys so the two stay in lockstep.
|
|
||||||
*
|
*
|
||||||
* A spec may be stored as an **object** or as **JSON text** (see spec §09A); we
|
* A spec may be stored as an **object** or as **JSON text** (see spec §09A); we
|
||||||
* normalize once at the boundary (unparseable text → no refs / unchanged spec) so
|
* normalize once at the boundary (unparseable text → no refs / unchanged spec) so
|
||||||
* the recursive walk never has to care.
|
* the recursive walk never has to care.
|
||||||
*
|
|
||||||
* Refinement beyond the doc sketch: a spec may define its OWN inline named
|
|
||||||
* datasets via a top-level `datasets` object (e.g.
|
|
||||||
* `{ "datasets": { "foo": [...] }, "data": { "name": "foo" } }`). Names satisfied
|
|
||||||
* by the spec's own `datasets` are NOT library dependencies, so they are excluded
|
|
||||||
* from extraction and left untouched on rename — keeping extraction consistent
|
|
||||||
* with the renderer's resolution, which likewise must not treat self-defined
|
|
||||||
* names as library refs.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { libraryRefName, selfDefinedNames } from './spec-data';
|
||||||
|
|
||||||
type Json = unknown;
|
type Json = unknown;
|
||||||
|
|
||||||
/** Parse a string spec; an unparseable draft simply has no resolvable refs. */
|
/** Parse a string spec; an unparseable draft simply has no resolvable refs. */
|
||||||
@@ -42,22 +43,10 @@ function safeParse(s: string): Json {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The set of dataset names a spec defines for itself via top-level `datasets`. */
|
|
||||||
function selfDefinedNames(spec: Json): Set<string> {
|
|
||||||
const names = new Set<string>();
|
|
||||||
if (spec && typeof spec === 'object' && !Array.isArray(spec)) {
|
|
||||||
const datasets = (spec as Record<string, Json>).datasets;
|
|
||||||
if (datasets && typeof datasets === 'object' && !Array.isArray(datasets)) {
|
|
||||||
for (const key of Object.keys(datasets)) names.add(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return names;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collects every **library** dataset name referenced by `{ data: { name } }`
|
* Collects every **library** dataset name referenced anywhere in the spec
|
||||||
* anywhere in the spec, excluding names the spec defines for itself via a
|
* (`libraryRefName`), excluding names the spec defines for itself via a top-level
|
||||||
* top-level `datasets` object. Accepts an object or JSON text.
|
* `datasets` object. Accepts an object or JSON text.
|
||||||
*/
|
*/
|
||||||
export function extractDatasetRefs(spec: Json): string[] {
|
export function extractDatasetRefs(spec: Json): string[] {
|
||||||
const root = typeof spec === 'string' ? safeParse(spec) : spec;
|
const root = typeof spec === 'string' ? safeParse(spec) : spec;
|
||||||
@@ -71,12 +60,10 @@ export function extractDatasetRefs(spec: Json): string[] {
|
|||||||
}
|
}
|
||||||
if (node && typeof node === 'object') {
|
if (node && typeof node === 'object') {
|
||||||
const obj = node as Record<string, Json>;
|
const obj = node as Record<string, Json>;
|
||||||
const data = obj.data as Record<string, Json> | undefined;
|
const refName = libraryRefName(obj.data, selfDefined);
|
||||||
if (data && typeof data === 'object' && typeof data.name === 'string') {
|
if (refName !== null) names.add(refName);
|
||||||
if (!selfDefined.has(data.name)) names.add(data.name);
|
|
||||||
}
|
|
||||||
// Recurse into every key except the two that hold data payloads (`data` —
|
// Recurse into every key except the two that hold data payloads (`data` —
|
||||||
// captured above; `datasets` — the spec's own inline data). Pruning them
|
// classified above; `datasets` — the spec's own inline data). Pruning them
|
||||||
// keeps the walk out of user data rows, where a field named `data` would
|
// keeps the walk out of user data rows, where a field named `data` would
|
||||||
// otherwise be misread as a reference. Kept in step with rendering.ts.
|
// otherwise be misread as a reference. Kept in step with rendering.ts.
|
||||||
for (const key of Object.keys(obj)) {
|
for (const key of Object.keys(obj)) {
|
||||||
@@ -96,12 +83,14 @@ export function recomputeDatasetRefs(spec: Json): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a copy of `spec` with every `data.name === oldName` replaced by
|
* Returns a copy of `spec` with every library reference to `oldName` (per
|
||||||
* `newName`, recursing through arrays and objects. A name that is a top-level
|
* `libraryRefName`) renamed to `newName`, recursing through arrays and objects.
|
||||||
* `datasets` key (self-defined) is left untouched. The input's stored shape is
|
* Native Vega-Lite data that merely carries the name as a label (inline `values`,
|
||||||
* preserved: a string spec is parsed, rewritten, and re-serialized as pretty JSON
|
* a `url`, a generator) and a self-defined top-level `datasets` key are left
|
||||||
* text; an object spec returns an object. The generic `<T>` reflects this shape
|
* untouched. The input's stored shape is preserved: a string spec is parsed,
|
||||||
* preservation. The input is never mutated.
|
* rewritten, and re-serialized as pretty JSON text; an object spec returns an
|
||||||
|
* object. The generic `<T>` reflects this shape preservation; the input is never
|
||||||
|
* mutated.
|
||||||
*/
|
*/
|
||||||
export function renameDatasetInSpec<T>(spec: T, oldName: string, newName: string): T {
|
export function renameDatasetInSpec<T>(spec: T, oldName: string, newName: string): T {
|
||||||
const isString = typeof spec === 'string';
|
const isString = typeof spec === 'string';
|
||||||
@@ -116,15 +105,17 @@ export function renameDatasetInSpec<T>(spec: T, oldName: string, newName: string
|
|||||||
if (node && typeof node === 'object') {
|
if (node && typeof node === 'object') {
|
||||||
const out: Record<string, Json> = {};
|
const out: Record<string, Json> = {};
|
||||||
for (const [k, v] of Object.entries(node as Record<string, Json>)) {
|
for (const [k, v] of Object.entries(node as Record<string, Json>)) {
|
||||||
// A `data` object is a reference site, not a container: rename a matching
|
// A `data` block is a reference site, not a container: rename it when it is
|
||||||
// name and stop — never recurse into its payload. A `datasets` map is the
|
// a library reference to `oldName`, and never recurse into its payload. A
|
||||||
// spec's own inline data: leave it whole. Pruning both (mirroring
|
// `datasets` map is the spec's own inline data: leave it whole. Pruning
|
||||||
// extractDatasetRefs) keeps rename out of user data rows, where a field
|
// both (mirroring extractDatasetRefs) keeps rename out of user data rows,
|
||||||
// named `data` would otherwise be rewritten as if it were a reference.
|
// where a field named `data` would otherwise be rewritten as a reference.
|
||||||
if (k === 'data' && v && typeof v === 'object' && !Array.isArray(v)) {
|
if (k === 'data') {
|
||||||
const dv = v as Record<string, Json>;
|
out[k] =
|
||||||
out[k] = dv.name === oldName && !selfDefined.has(oldName) ? { ...dv, name: newName } : dv;
|
libraryRefName(v, selfDefined) === oldName
|
||||||
} else if (k === 'data' || k === 'datasets') {
|
? { ...(v as Record<string, Json>), name: newName }
|
||||||
|
: v;
|
||||||
|
} else if (k === 'datasets') {
|
||||||
out[k] = v;
|
out[k] = v;
|
||||||
} else {
|
} else {
|
||||||
out[k] = rewrite(v);
|
out[k] = rewrite(v);
|
||||||
|
|||||||
Reference in New Issue
Block a user