mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
693f5d7073
- 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.
221 lines
8.5 KiB
TypeScript
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;
|
|
}
|