mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 10:12:34 +00:00
Add dataset library, extract-to-dataset, and render-time reference resolution
This commit is contained in:
+125
-3
@@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user