Files
astrolabe/src/core/rendering.ts
T
oleh 693f5d7073 Fix dataset-ref walk false positive and wire spec-mandated success toasts
- Prune the data payload and top-level datasets keys in all three ref walks
  (extractDatasetRefs, renameDatasetInSpec, resolveDatasetRefs) so a data row
  carrying a field named "data" is no longer misread as a library reference,
  spuriously rewritten, or made to throw DatasetNotFoundError. Adds tests,
  including a guard that lookup-transform refs (from.data) still resolve.
- Wire the deferred success toasts now the Toaster has landed: publish, revert,
  extract-to-dataset, and snippet/dataset delete. Copy follows the council
  title-vs-message rule (title states the action, message adds the consequence).
- Reconcile the spec's blanket toast mandate to "toast only what the user can't
  already see": no toast on visible-result creates (snippet, dataset form);
  Copy Reference stays inline and gains an aria-live announcement (new shared
  .visually-hidden utility) instead of a toast-per-copy.
- Move the toast region to bottom-right so it stops covering the header action
  cluster (Publish/Revert, theme/datasets).
- Update docs/spec 01F/02/05 and docs/architecture/07 + 10 to match.
2026-06-05 16:34:58 +03:00

221 lines
8.5 KiB
TypeScript

/**
* 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<ResolvableDataset>;
}
/** 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<string, unknown>;
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<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 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<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);
}
}
// 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<T>(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<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');
return copy;
}