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:
@@ -203,6 +203,26 @@ describe('prepareSpecForRender — dataset resolution (spec §04 Rendering Contr
|
||||
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', () => {
|
||||
const spec = { data: { name: 'JsonDs' }, mark: 'bar' };
|
||||
const out = prepareSpecForRender(spec, { datasets, fitMode: 'full' }) as unknown as {
|
||||
|
||||
+26
-33
@@ -10,13 +10,15 @@
|
||||
* 1. Dataset reference resolution — implemented in M3 (see below).
|
||||
* 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
|
||||
* dataset's actual contents, shaped by source and format. A name the spec defines
|
||||
* for itself via a top-level `datasets` object is left untouched (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.
|
||||
* dataset's actual contents, shaped by source and format. What counts as a library
|
||||
* reference is decided by `core/spec-data` (named data, not self-defined), so a
|
||||
* `name` riding on inline `values`/`url`/a generator is native Vega-Lite data and
|
||||
* is left untouched; a name the spec defines for itself via a top-level `datasets`
|
||||
* 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
|
||||
* fixed.
|
||||
@@ -24,6 +26,7 @@
|
||||
|
||||
import type { DataFormat } from './format-detection';
|
||||
import type { DataSource } from './dataset';
|
||||
import { libraryRefName, selfDefinedNames } from './spec-data';
|
||||
|
||||
/** Preview sizing modes (spec §04 → Fit / Sizing Modes). `default` = Original. */
|
||||
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);
|
||||
}
|
||||
|
||||
/** 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 →
|
||||
* 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
|
||||
* contents, recursing through the spec (arrays and objects) so refs anywhere are
|
||||
* resolved — matching `extractDatasetRefs`, including pruning the `data` and
|
||||
* top-level `datasets` payload keys so resolution never descends into user data
|
||||
* rows. A self-defined name 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.
|
||||
* Replace every library reference in `node` with its dataset's contents, recursing
|
||||
* through the spec (arrays and objects) so refs anywhere are resolved — matching
|
||||
* `extractDatasetRefs`, including pruning the `data` and top-level `datasets`
|
||||
* payload keys so resolution never descends into user data rows. What counts as a
|
||||
* library reference is decided by the shared `libraryRefName` (core/spec-data):
|
||||
* named data the spec does not define for itself. A `name` riding on inline
|
||||
* `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(
|
||||
node: unknown,
|
||||
@@ -176,15 +172,12 @@ function resolveDatasetRefs(
|
||||
}
|
||||
if (!isSpecNode(node)) return;
|
||||
|
||||
const data = node.data;
|
||||
if (isSpecNode(data) && typeof data.name === 'string') {
|
||||
const name = data.name;
|
||||
if (!selfDefined.has(name)) {
|
||||
const dataset = byName.get(name.toLowerCase());
|
||||
if (!dataset) throw new DatasetNotFoundError(name);
|
||||
const { name: _drop, ...rest } = data;
|
||||
node.data = resolvedData(dataset, rest);
|
||||
}
|
||||
const refName = libraryRefName(node.data, selfDefined);
|
||||
if (refName !== null) {
|
||||
const dataset = byName.get(refName.toLowerCase());
|
||||
if (!dataset) throw new DatasetNotFoundError(refName);
|
||||
const { name: _drop, ...rest } = node.data as SpecNode;
|
||||
node.data = resolvedData(dataset, rest);
|
||||
}
|
||||
|
||||
// Recurse into every key except the two that hold data payloads (`data` —
|
||||
@@ -229,7 +222,7 @@ export function prepareSpecForRender<T>(spec: T, options: PrepareOptions = {}):
|
||||
const datasets = options.datasets ?? [];
|
||||
const byName = new Map<string, ResolvableDataset>();
|
||||
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.
|
||||
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([]);
|
||||
});
|
||||
|
||||
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: { 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', () => {
|
||||
const spec = {
|
||||
datasets: { foo: [{ a: 1 }] },
|
||||
@@ -128,14 +141,16 @@ describe('renameDatasetInSpec', () => {
|
||||
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 = {
|
||||
data: { name: 'Old', values: [{ data: { name: 'Old' } }] },
|
||||
mark: 'bar',
|
||||
};
|
||||
const out = renameDatasetInSpec(spec, 'Old', 'New');
|
||||
expect(out.data.name).toBe('New'); // the real reference is renamed
|
||||
expect(out.data.values[0].data.name).toBe('Old'); // the row payload is left alone
|
||||
expect(out.data.name).toBe('Old');
|
||||
expect(out.data.values[0].data.name).toBe('Old');
|
||||
});
|
||||
|
||||
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).
|
||||
*
|
||||
* Portable core: no browser APIs, no React, no store access. A Vega-Lite spec
|
||||
* references named data through `{ "data": { "name": "MyDataset" } }`, which can
|
||||
* appear at the top level, per-layer, inside `spec`/`facet`/concat children, or in
|
||||
* a lookup transform's `from.data`. Rather than enumerate the grammar, we walk the
|
||||
* spec recursively and collect every `{ data: { name } }` we find — the single
|
||||
* source of truth for "what does this spec reference", which the renderer's
|
||||
* resolution must agree with.
|
||||
* references a library dataset through *named* data — `{ "data": { "name": "X" } }`
|
||||
* — which can appear at the top level, per-layer, inside `spec`/`facet`/concat
|
||||
* children, or in a lookup transform's `from.data`. Rather than enumerate the
|
||||
* grammar, we walk the spec recursively and collect every library reference we
|
||||
* find — the single source of truth for "what does this spec reference", which the
|
||||
* 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
|
||||
* rather than nested specs: a `data` object (its `name` is captured at the parent
|
||||
* site; its `values`/`format` are payload, never a nested ref) and a top-level
|
||||
* `datasets` map (the spec's own inline data). Pruning those is what keeps a data
|
||||
* *row* that happens to carry a field literally named `data: { name: "x" }` from
|
||||
* being misread as a library reference. The renderer's resolution prunes the same
|
||||
* two keys so the two stay in lockstep.
|
||||
* rather than nested specs: a `data` object (classified at the parent site; its
|
||||
* `values`/`format` are payload, never a nested ref) and a top-level `datasets`
|
||||
* map (the spec's own inline data). Pruning those is what keeps a data *row* that
|
||||
* happens to carry a field literally named `data: { name: "x" }` from being misread
|
||||
* as a library reference. The renderer's resolution prunes the same two keys.
|
||||
*
|
||||
* 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
|
||||
* 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;
|
||||
|
||||
/** 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 } }`
|
||||
* anywhere in the spec, excluding names the spec defines for itself via a
|
||||
* top-level `datasets` object. Accepts an object or JSON text.
|
||||
* Collects every **library** dataset name referenced anywhere in the spec
|
||||
* (`libraryRefName`), excluding names the spec defines for itself via a top-level
|
||||
* `datasets` object. Accepts an object or JSON text.
|
||||
*/
|
||||
export function extractDatasetRefs(spec: Json): string[] {
|
||||
const root = typeof spec === 'string' ? safeParse(spec) : spec;
|
||||
@@ -71,12 +60,10 @@ export function extractDatasetRefs(spec: Json): string[] {
|
||||
}
|
||||
if (node && typeof node === 'object') {
|
||||
const obj = node as Record<string, Json>;
|
||||
const data = obj.data as Record<string, Json> | undefined;
|
||||
if (data && typeof data === 'object' && typeof data.name === 'string') {
|
||||
if (!selfDefined.has(data.name)) names.add(data.name);
|
||||
}
|
||||
const refName = libraryRefName(obj.data, selfDefined);
|
||||
if (refName !== null) names.add(refName);
|
||||
// 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
|
||||
// otherwise be misread as a reference. Kept in step with rendering.ts.
|
||||
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
|
||||
* `newName`, recursing through arrays and objects. A name that is a top-level
|
||||
* `datasets` key (self-defined) is left untouched. The input's stored shape is
|
||||
* preserved: a string spec is parsed, 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.
|
||||
* Returns a copy of `spec` with every library reference to `oldName` (per
|
||||
* `libraryRefName`) renamed to `newName`, recursing through arrays and objects.
|
||||
* Native Vega-Lite data that merely carries the name as a label (inline `values`,
|
||||
* a `url`, a generator) and a self-defined top-level `datasets` key are left
|
||||
* untouched. The input's stored shape is preserved: a string spec is parsed,
|
||||
* 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 {
|
||||
const isString = typeof spec === 'string';
|
||||
@@ -116,15 +105,17 @@ export function renameDatasetInSpec<T>(spec: T, oldName: string, newName: string
|
||||
if (node && typeof node === 'object') {
|
||||
const out: 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
|
||||
// name and stop — never recurse into its payload. A `datasets` map is the
|
||||
// spec's own inline data: leave it whole. Pruning both (mirroring
|
||||
// extractDatasetRefs) keeps rename out of user data rows, where a field
|
||||
// named `data` would otherwise be rewritten as if it were a reference.
|
||||
if (k === 'data' && v && typeof v === 'object' && !Array.isArray(v)) {
|
||||
const dv = v as Record<string, Json>;
|
||||
out[k] = dv.name === oldName && !selfDefined.has(oldName) ? { ...dv, name: newName } : dv;
|
||||
} else if (k === 'data' || k === 'datasets') {
|
||||
// A `data` block is a reference site, not a container: rename it when it is
|
||||
// a library reference to `oldName`, and never recurse into its payload. A
|
||||
// `datasets` map is the spec's own inline data: leave it whole. Pruning
|
||||
// both (mirroring extractDatasetRefs) keeps rename out of user data rows,
|
||||
// where a field named `data` would otherwise be rewritten as a reference.
|
||||
if (k === 'data') {
|
||||
out[k] =
|
||||
libraryRefName(v, selfDefined) === oldName
|
||||
? { ...(v as Record<string, Json>), name: newName }
|
||||
: v;
|
||||
} else if (k === 'datasets') {
|
||||
out[k] = v;
|
||||
} else {
|
||||
out[k] = rewrite(v);
|
||||
|
||||
Reference in New Issue
Block a user