Add dataset library, extract-to-dataset, and render-time reference resolution

This commit is contained in:
2026-06-05 15:49:40 +03:00
parent 25849461e0
commit a4e4d96d3b
41 changed files with 3909 additions and 19 deletions
+125 -3
View File
@@ -7,19 +7,59 @@
* deterministic steps, in order, **on a deep copy** so the user's stored spec is
* never mutated by rendering:
*
* 1. Dataset reference resolution — arrives in M3 (no-op here).
* 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
* (`{ 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.
*
* The copy-not-mutate invariant and the call site the renderer depends on are
* fixed; M3 fills in step 1 without the preview pipeline changing shape.
* fixed.
*/
import type { DataFormat } from './format-detection';
import type { DataSource } from './dataset';
/** Preview sizing modes (spec §04 → Fit / Sizing Modes). `default` = Original. */
export type FitMode = 'default' | 'width' | 'height' | 'full';
/**
* The minimal structural view of a dataset that reference resolution needs. The
* DatasetStore's records are structurally compatible, so passing them works
* without importing the full `Dataset` type (and keeps this free of any cycle).
*/
export interface ResolvableDataset {
/** The library name a spec references via `{ data: { name } }`. */
name: string;
/** The payload — see `Dataset.data` for the per-source/format shape. */
data: unknown;
/** One of `json`, `csv`, `tsv`, `topojson`. */
format: DataFormat;
/** One of `inline` or `url`. */
source: DataSource;
}
/** Thrown when a spec references a library dataset name that does not exist. */
export class DatasetNotFoundError extends Error {
/** The missing dataset's name, so callers can build a tailored, fixable message. */
readonly datasetName: string;
constructor(name: string) {
super(`Dataset not found: "${name}"`);
this.name = 'DatasetNotFoundError';
this.datasetName = name;
}
}
export interface PrepareOptions {
/** Active fit mode. Defaults to `'default'` (Original — spec sizing untouched). */
fitMode?: FitMode;
/** The dataset library used to resolve named-data references (step 1). */
datasets?: ReadonlyArray<ResolvableDataset>;
}
/** The container/sub-spec keys the rendering contract recurses into (spec §04). */
@@ -71,6 +111,83 @@ 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
* `format` carrying a TopoJSON `feature`); the incoming `name` is dropped and any
* pre-existing `format` is merged so such keys survive.
*/
function resolvedData(dataset: ResolvableDataset, rest: SpecNode): SpecNode {
const restFormat = isSpecNode(rest.format) ? rest.format : {};
if (dataset.source === 'url') {
return {
...rest,
url: typeof dataset.data === 'string' ? dataset.data : (JSON.stringify(dataset.data) ?? ''),
format: { ...restFormat, type: dataset.format },
};
}
switch (dataset.format) {
case 'json':
return { ...rest, values: dataset.data };
case 'topojson':
return { ...rest, values: dataset.data, format: { ...restFormat, type: 'topojson' } };
case 'csv':
case 'tsv':
return { ...rest, values: dataset.data, format: { ...restFormat, type: dataset.format } };
}
}
/**
* Replace every named-data reference in `node` with its library dataset's
* contents, recursing through the entire spec (arrays and objects) so refs
* anywhere are resolved — matching `extractDatasetRefs`. 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.
*/
function resolveDatasetRefs(
node: unknown,
byName: Map<string, ResolvableDataset>,
selfDefined: Set<string>,
): void {
if (Array.isArray(node)) {
for (const item of node) resolveDatasetRefs(item, byName, selfDefined);
return;
}
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);
}
}
// TODO: this walks EVERY key, so it also descends into inlined data payloads
// (the just-resolved `values`, a spec's `datasets`/`data.values`). That's
// wasteful for large inline data on every debounced render, and a row with a
// field literally named `data` holding `{ name: "x" }` would be spuriously
// resolved or throw DatasetNotFoundError. extractDatasetRefs shares this broad
// walk. A scoped walk (recurse only into the known sub-spec/container keys +
// `transform[].lookup.from`, never into data payloads) would be safer and
// faster — change deliberately, with tests for where refs may legally appear.
for (const key of Object.keys(node)) resolveDatasetRefs(node[key], byName, selfDefined);
}
/**
* Escape `.`/`[`/`]` so Vega-Lite treats a string as a literal field name rather
* than a nested-property accessor (docs/architecture/05 §4). Used wherever
@@ -88,7 +205,12 @@ export function escapeVegaField(name: string): string {
export function prepareSpecForRender<T>(spec: T, options: PrepareOptions = {}): T {
const copy = structuredClone(spec);
// 1. M3: resolveDatasetRefs(copy, datasets)
// 1. Dataset reference resolution — runs before sizing, on the same copy.
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));
// 2. Fit-mode sizing.
applyFitMode(copy, options.fitMode ?? 'default');