/** * Rendering contract — pure spec preparation (spec §04 → Rendering Contract). * * Portable core: no browser APIs, no React, no vega-embed. `prepareSpecForRender` * is the single transform that sits between "parsed spec the user authored" and * "spec the preview actually embeds" (see docs/architecture/05). It performs two * deterministic steps, in order, **on a deep copy** so the user's stored spec is * never mutated by rendering: * * 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. */ 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; } /** The container/sub-spec keys the rendering contract recurses into (spec §04). */ const CHILD_ARRAY_KEYS = ['layer', 'concat', 'hconcat', 'vconcat'] as const; /** A spec node we might rewrite sizing on; loose by design (any Vega-Lite spec). */ type SpecNode = Record; function isSpecNode(value: unknown): value is SpecNode { return value !== null && typeof value === 'object' && !Array.isArray(value); } /** * Rewrite one node's sizing to the fit mode (spec §04 → Rendering Contract, * step 2). `'container'` is Vega-Lite's responsive keyword; the unconstrained * dimension is removed so it recomputes naturally. */ function applyFitToNode(node: SpecNode, mode: FitMode): void { switch (mode) { case 'width': node.width = 'container'; delete node.height; break; case 'height': node.height = 'container'; delete node.width; break; case 'full': node.width = 'container'; node.height = 'container'; break; // 'default' (Original) leaves sizing untouched and never reaches here. } } /** * Apply the fit mode to a spec and every nested sub-spec it recurses into — * layered (`layer`) and concatenated (`concat`/`hconcat`/`vconcat`) children, * and a parent spec's single child `spec` (facet/repeat). Mutates in place; the * caller (`prepareSpecForRender`) already works on a copy. */ function applyFitMode(node: unknown, mode: FitMode): void { if (!isSpecNode(node)) return; applyFitToNode(node, mode); for (const key of CHILD_ARRAY_KEYS) { const children = node[key]; if (Array.isArray(children)) for (const child of children) applyFitMode(child, 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 { const names = new Set(); 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 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. */ function resolveDatasetRefs( node: unknown, byName: Map, selfDefined: Set, ): 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); } } // Recurse into every key except the two that hold data payloads (`data` — // resolved/captured above; `datasets` — the spec's own inline data). Pruning // them avoids descending into the just-resolved `values` and into user data // rows, where a field named `data` holding `{ name: "x" }` would otherwise be // spuriously resolved or throw DatasetNotFoundError. extractDatasetRefs prunes // the same two keys so resolution and extraction stay in agreement. for (const key of Object.keys(node)) { if (key === 'data' || key === 'datasets') continue; 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 * Astrolabe *constructs* a `field:` from a data-derived column name (chart * builder, M4); hand-authored specs are the user's responsibility. */ export function escapeVegaField(name: string): string { return name.replace(/([.[\]])/g, '\\$1'); } /** * Transform the authored spec into the spec to embed. Operates on a deep copy * and returns it; the input is never mutated. */ export function prepareSpecForRender(spec: T, options: PrepareOptions = {}): T { const copy = structuredClone(spec); // 1. Dataset reference resolution — runs before sizing, on the same copy. const datasets = options.datasets ?? []; const byName = new Map(); 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'); return copy; }