mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
395 lines
18 KiB
TypeScript
395 lines
18 KiB
TypeScript
/**
|
||
* Chart renderer (docs/architecture/05 §2).
|
||
*
|
||
* The one place in the app that touches `vega-embed` and the chart DOM.
|
||
* Components ask it to draw a prepared spec into a node and own the returned
|
||
* handle's lifecycle: every successful embed yields a live Vega `View` that must
|
||
* be finalized before the next embed (or it leaks timers, listeners, and DOM).
|
||
*/
|
||
|
||
import vegaEmbed, { type Result as EmbedResult } from 'vega-embed';
|
||
import type { VisualizationSpec } from 'vega-embed';
|
||
import type { Config } from 'vega-lite';
|
||
import { collectFontFamilies } from '@core/custom-theme';
|
||
import { embedFontsInSvg } from '@core/chart-export';
|
||
import { inspectViewLabel, inspectableViews } from '@core/inspect-views';
|
||
import type { FontAsset } from '@core/font-asset';
|
||
|
||
/** Options for `RenderHandle.toImageURL` (spec §08 → Per-chart export). */
|
||
interface ImageExportOptions {
|
||
/**
|
||
* Pixel-density multiplier for the **PNG** raster, **relative to the device**.
|
||
* The image is drawn at `scale × devicePixelRatio` logical-pixel density, so
|
||
* `scale: 1` already matches on-screen crispness on a Retina display — the fix
|
||
* for a soft "1×" export (`toImageURL`'s raw `scaleFactor` ignores dpr, so a
|
||
* naive 1× looks half-resolution on a 2× display). Raise for print/zoom.
|
||
* Ignored for the resolution-independent SVG. Default `1`.
|
||
*/
|
||
scale?: number;
|
||
/**
|
||
* Opaque colour to paint behind the chart. The chart config renders a
|
||
* **transparent** background (so the on-screen chart shows the pane colour),
|
||
* which makes a naive export transparent; pass a colour to fill it. PNG is
|
||
* composited onto the colour; SVG gets a background `<rect>`. Null/omitted keeps
|
||
* it transparent. Default `null`.
|
||
*/
|
||
background?: string | null;
|
||
/**
|
||
* Uploaded font faces the chart references, embedded into the **SVG** as base64
|
||
* `@font-face` rules so it renders the right type off-app (spec §08; scope doc
|
||
* §4). Ignored for PNG (the raster already bakes in the glyphs). Omitted/empty
|
||
* leaves only the family name, which falls back to a system font elsewhere. The
|
||
* caller (which holds the font library) resolves which faces are referenced.
|
||
*/
|
||
embedFonts?: ReadonlyArray<FontAsset>;
|
||
}
|
||
|
||
/** One inspectable drawn table — the two ends of its pipeline (spec §04). */
|
||
export interface InspectableTable {
|
||
/** Stable selection id — the resolved table's compiled name. */
|
||
id: string;
|
||
/** User-facing label (never a compiler name — `@core/inspect-views`). */
|
||
label: string;
|
||
/** Parsed source rows, before the view's transforms run — the input. */
|
||
input: ReadonlyArray<Record<string, unknown>>;
|
||
/** Post-transform rows the marks draw — the output (equals `input` when the view
|
||
* has no transforms). */
|
||
resolved: ReadonlyArray<Record<string, unknown>>;
|
||
}
|
||
|
||
/**
|
||
* The chart's inspectable data: one table per distinct table the marks draw, in
|
||
* document order (a multi-view spec yields several). `tables` is empty when the
|
||
* chart draws nothing inspectable, distinct from a `null` handle (no chart).
|
||
*/
|
||
export interface InspectedData {
|
||
tables: InspectableTable[];
|
||
}
|
||
|
||
/**
|
||
* Debounce for the live data inspector (spec §04; multi-view scope doc M5). A
|
||
* brush drag pulses `addDataListener` continuously; coalescing to one re-read per
|
||
* ~quiet-frame keeps the table feeling live without thrashing the grid on every
|
||
* pixel of the drag.
|
||
*/
|
||
export const LIVE_INSPECT_DEBOUNCE_MS = 120;
|
||
|
||
/** The minimal Vega `View` surface the live-inspect watcher needs. */
|
||
interface DataChangeView {
|
||
addDataListener(name: string, handler: () => void): unknown;
|
||
removeDataListener(name: string, handler: () => void): unknown;
|
||
}
|
||
|
||
/**
|
||
* Attach debounced listeners to every table the inspector shows so an interactive
|
||
* selection that recomputes a drawn table (a `filter: {param}` brush) re-reads the
|
||
* inspector live — see `RenderHandle.onDataChange`. Watches the union of each
|
||
* drawn table's resolved + input names (deduped); a highlight selection changes no
|
||
* data, so none fire. Returns an unsubscribe that cancels any pending re-read and
|
||
* detaches the listeners — but skips detaching once the view is finalized, since
|
||
* `view.finalize()` has already dropped every listener (and the unmount cleanup
|
||
* order can run this after the destroy). `isFinalized` is a getter, not a boolean,
|
||
* so it reflects the view's state at unsubscribe time, not subscribe time.
|
||
*/
|
||
export function watchInspectableData(
|
||
view: DataChangeView,
|
||
vgSpec: unknown,
|
||
onChange: () => void,
|
||
isFinalized: () => boolean,
|
||
): () => void {
|
||
if (isFinalized()) return () => {};
|
||
const names = [...new Set(inspectableViews(vgSpec).flatMap((v) => [v.resolved, v.input]))];
|
||
if (names.length === 0) return () => {};
|
||
|
||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||
const handler = (): void => {
|
||
if (timer !== null) clearTimeout(timer);
|
||
timer = setTimeout(() => {
|
||
timer = null;
|
||
onChange();
|
||
}, LIVE_INSPECT_DEBOUNCE_MS);
|
||
};
|
||
for (const name of names) view.addDataListener(name, handler);
|
||
|
||
return () => {
|
||
if (timer !== null) {
|
||
clearTimeout(timer);
|
||
timer = null;
|
||
}
|
||
if (!isFinalized()) for (const name of names) view.removeDataListener(name, handler);
|
||
};
|
||
}
|
||
|
||
export interface RenderHandle {
|
||
/** Finalize the underlying Vega view and clear the node. */
|
||
destroy(): void;
|
||
/**
|
||
* Rasterize/serialize the current view to a downloadable URL (spec §08 →
|
||
* Per-chart export). `'png'` resolves to a `blob:` object URL (caller revokes
|
||
* after download); `'svg'` to a `data:` URL. Renderer-agnostic — works from the
|
||
* SVG-backed LivePreview view as well as a canvas one — because Vega draws to its
|
||
* own off-screen surface here, independent of the display backend. Honors
|
||
* `options` (dpr-aware scale, background fill). Rejects if the view was already
|
||
* finalized.
|
||
*/
|
||
toImageURL(format: 'png' | 'svg', options?: ImageExportOptions): Promise<string>;
|
||
/**
|
||
* Re-fit the chart to its container's current size (spec §04 Responsiveness).
|
||
*
|
||
* Vega-Lite compiles `"container"` sizing to width/height signals that re-read
|
||
* `containerSize()` ONLY on a `window:resize` event (nothing observes the
|
||
* element, and `view.resize()` alone re-runs layout with the stale size). So a
|
||
* pane drag — which fires no window resize — needs us to synthesize that event.
|
||
* Doing it this way also means only the container-bound dimensions re-fit
|
||
* (fixed ones have no such handler), which is exactly right for Width/Height.
|
||
*/
|
||
resize(): void;
|
||
/**
|
||
* The chart's inspectable tables — for the data inspector (spec §04). Enumerates
|
||
* the tables the marks draw from the compiled Vega spec (`@core/inspect-views`),
|
||
* and for each reads its input + resolved rows from the live view. A multi-view
|
||
* spec yields several tables; a unit spec yields one. This is the one place
|
||
* besides export that reaches into the view, so the embedding boundary holds
|
||
* (arch 05 §1–§2) — callers get rows, never the `view`.
|
||
*
|
||
* Returns `null` when the view was finalized (no chart). The result's `tables`
|
||
* is empty when a chart draws nothing inspectable, and any table's `input`/
|
||
* `resolved` can be `[]` (e.g. a filter removed every row) — kept distinct from
|
||
* "no chart" so the inspector can say which.
|
||
*/
|
||
inspectData(): InspectedData | null;
|
||
/**
|
||
* Subscribe to live changes of the inspected tables, for the data inspector's
|
||
* live mode (spec §04; multi-view scope doc M5). An interactive selection that
|
||
* *filters* a downstream view recomputes that view's compiled table in place —
|
||
* no re-embed — so a static inspector would show stale rows until the next full
|
||
* render; this fires (debounced) so the caller can re-read via `inspectData()`.
|
||
* A highlight selection (a `condition` encoding) changes no data, so it never
|
||
* fires. Returns an unsubscribe; a no-op when the view is already finalized.
|
||
*/
|
||
onDataChange(listener: () => void): () => void;
|
||
}
|
||
|
||
export interface RenderOptions {
|
||
/**
|
||
* Renderer backend. **Default `'svg'`** — crisp at any zoom, themeable, the
|
||
* contract default for the editor's LivePreview (docs/architecture/05 §2). The
|
||
* Chart Builder preview passes **`'canvas'`**: an SVG chart with thousands of
|
||
* marks (e.g. one bar per row of a 10k-row dataset) costs *seconds* of
|
||
* main-thread layout/paint per render — measured ~6.5s paint on 9994 rows —
|
||
* because each mark is a DOM node; canvas is a single node and paints in
|
||
* milliseconds. Canvas is raster (not crisp on zoom) but that's invisible for an
|
||
* ephemeral preview, and image export (`RenderHandle.toImageURL`) is renderer-agnostic.
|
||
*/
|
||
renderer?: 'svg' | 'canvas';
|
||
}
|
||
|
||
/**
|
||
* Maximum canvas side length, in CSS px before the device-pixel-ratio multiplier.
|
||
* Browsers cap a canvas backing store at ~32767px per side (Chrome/Firefox; Safari
|
||
* is lower and area-bound); past that the canvas fails to allocate and draws
|
||
* nothing. SVG has no such cap. `renderSpec` measures a canvas chart's resolved
|
||
* size against this (÷ dpr, since the backing store is dpr× the CSS size) and
|
||
* throws `ChartTooLargeError` rather than handing back a blank canvas.
|
||
*/
|
||
const MAX_CANVAS_PX = 32767;
|
||
|
||
/**
|
||
* Thrown by `renderSpec` when a **canvas**-backed chart resolves to a height larger
|
||
* than the browser can allocate (see `MAX_CANVAS_PX`). Carries the measured size and
|
||
* the limit so the caller can explain the *actual* cause — the chart is physically
|
||
* too large to draw — rather than guessing at "too many categories". This is a
|
||
* render-backend limit, distinct from the readability cardinality warnings.
|
||
*/
|
||
export class ChartTooLargeError extends Error {
|
||
/** The chart's resolved height, in CSS px. */
|
||
readonly heightPx: number;
|
||
/** The per-side limit at the current device-pixel-ratio, in CSS px. */
|
||
readonly limitPx: number;
|
||
constructor(heightPx: number, limitPx: number) {
|
||
super(
|
||
`Chart is ${Math.round(heightPx)}px tall — over the ~${Math.round(limitPx)}px canvas limit`,
|
||
);
|
||
this.name = 'ChartTooLargeError';
|
||
this.heightPx = heightPx;
|
||
this.limitPx = limitPx;
|
||
}
|
||
}
|
||
|
||
/** The canvas side limit in CSS px at the current display's device-pixel-ratio. */
|
||
function canvasLimitPx(): number {
|
||
const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;
|
||
return MAX_CANVAS_PX / dpr;
|
||
}
|
||
|
||
/** Encode an SVG string as a `data:` URL (no blob to revoke). */
|
||
function svgDataUrl(svg: string): string {
|
||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||
}
|
||
|
||
/** Paint a full-bleed background `<rect>` as the first child of the root `<svg>`,
|
||
* so the exported SVG isn't transparent. Vega emits explicit width/height on the
|
||
* root, so `100%` resolves to the chart's box. */
|
||
function withSvgBackground(svg: string, color: string): string {
|
||
return svg.replace(/(<svg\b[^>]*>)/, `$1<rect width="100%" height="100%" fill="${color}"/>`);
|
||
}
|
||
|
||
/** Composite a (transparent) chart canvas onto an opaque colour, same dimensions. */
|
||
function compositeOnColor(chart: HTMLCanvasElement, color: string): HTMLCanvasElement {
|
||
const out = document.createElement('canvas');
|
||
out.width = chart.width;
|
||
out.height = chart.height;
|
||
const ctx = out.getContext('2d');
|
||
if (!ctx) return chart; // no 2d context — fall back to the transparent chart
|
||
ctx.fillStyle = color;
|
||
ctx.fillRect(0, 0, out.width, out.height);
|
||
ctx.drawImage(chart, 0, 0);
|
||
return out;
|
||
}
|
||
|
||
/** A canvas → `blob:` object URL (PNG). The caller revokes it after the download. */
|
||
function canvasObjectUrl(canvas: HTMLCanvasElement): Promise<string> {
|
||
return new Promise((resolve, reject) => {
|
||
canvas.toBlob((blob) => {
|
||
if (blob) resolve(URL.createObjectURL(blob));
|
||
else reject(new Error('Could not encode the chart as a PNG.'));
|
||
}, 'image/png');
|
||
});
|
||
}
|
||
|
||
/** Weights to preload per family — the roster ships 400/600 (700 for the mono). */
|
||
const FONT_LOAD_WEIGHTS = ['400', '600', '700'];
|
||
/**
|
||
* Cap on waiting for fonts before rendering anyway. A precached/cached face
|
||
* resolves near-instantly; this only bounds the first fetch of an uncached
|
||
* subset so a slow network never freezes the preview — it renders with fallback
|
||
* metrics and sharpens on a later re-render once the face is cached.
|
||
*/
|
||
const FONT_LOAD_TIMEOUT_MS = 3000;
|
||
|
||
/**
|
||
* Load the font faces a spec/config will use before rendering. Vega measures
|
||
* every text label via canvas `measureText` regardless of renderer (even the
|
||
* 'none' probe runs layout), so a face that finishes loading *after* embed
|
||
* leaves the whole chart laid out with fallback metrics.
|
||
*
|
||
* Font loading is a non-critical enhancement, and an individual face failing
|
||
* (offline, a 404, or a system family with no `@font-face`) is *expected* — the
|
||
* correct response is to render with fallback metrics, not to fail the chart. So
|
||
* we wait on `allSettled` (which never rejects; a rejected load is ignored by
|
||
* design) and bound a slow first fetch with the timeout. This is the sanctioned
|
||
* "safe to swallow" case from arch 02's fail-loud rule — the failure mode is
|
||
* graceful fallback, not a buried bug or lost data — kept explicit rather than
|
||
* hidden in a catch-all. No-op where the Font Loading API is absent (tests).
|
||
*/
|
||
async function ensureFontsLoaded(spec: VisualizationSpec, config: Config): Promise<void> {
|
||
if (typeof document === 'undefined' || !document.fonts?.load) return;
|
||
const families = new Set<string>([...collectFontFamilies(config), ...collectFontFamilies(spec)]);
|
||
if (families.size === 0) return;
|
||
const loads = [...families].flatMap((family) =>
|
||
FONT_LOAD_WEIGHTS.map((weight) => document.fonts.load(`${weight} 16px ${family}`)),
|
||
);
|
||
await Promise.race([
|
||
Promise.allSettled(loads),
|
||
new Promise((resolve) => setTimeout(resolve, FONT_LOAD_TIMEOUT_MS)),
|
||
]);
|
||
}
|
||
|
||
/** Embed a prepared spec into `node`. Always: no actions menu. Renderer per `options`. */
|
||
export async function renderSpec(
|
||
node: HTMLElement,
|
||
spec: VisualizationSpec,
|
||
config: Config,
|
||
options: RenderOptions = {},
|
||
): Promise<RenderHandle> {
|
||
const renderer = options.renderer ?? 'svg';
|
||
|
||
// Load the chart's fonts before any layout pass measures text (see the helper).
|
||
await ensureFontsLoaded(spec, config);
|
||
|
||
// Canvas can't allocate past the browser's max dimension, and an oversized canvas
|
||
// fails *silently* (a blank/broken surface, sometimes a null 2d context). So for
|
||
// canvas we first run a headless ('none') layout pass — no canvas allocated — read
|
||
// the chart's resolved height, and throw with the real numbers if it won't fit.
|
||
// SVG renders any size (just slowly), so it skips this. The probe uses a detached
|
||
// node and is finalized immediately; only its computed `height` signal is read.
|
||
if (renderer === 'canvas') {
|
||
const probeHost = document.createElement('div');
|
||
const probe = await vegaEmbed(probeHost, spec, { actions: false, renderer: 'none', config });
|
||
const height = probe.view.height();
|
||
probe.view.finalize();
|
||
const limit = canvasLimitPx();
|
||
if (typeof height === 'number' && height > limit) throw new ChartTooLargeError(height, limit);
|
||
}
|
||
|
||
const result: EmbedResult = await vegaEmbed(node, spec, {
|
||
actions: false, // Astrolabe owns its own export/copy affordances
|
||
renderer,
|
||
config,
|
||
// Suppress vega-tooltip's bundled light/dark stylesheet so the app owns the
|
||
// tooltip's look. With the default style gone, the single `#vg-tooltip-element`
|
||
// it appends to <body> is styled entirely from our design tokens in base.css —
|
||
// surface, type, and the key/value table — and follows `[data-theme]` for free
|
||
// (the element inherits the cascade from <html>). vega-tooltip still positions
|
||
// the element (inline top/left) and toggles `.visible`; our CSS supplies the
|
||
// structural rules (position/visibility/z-index) the default sheet used to.
|
||
tooltip: { disableDefaultStyle: true },
|
||
});
|
||
|
||
// Reads of a finalized view throw; `inspectData` checks this to no-op safely
|
||
// after destroy() (the inspector may read on a render that resolved late).
|
||
let finalized = false;
|
||
|
||
return {
|
||
destroy() {
|
||
finalized = true;
|
||
result.view.finalize();
|
||
node.replaceChildren();
|
||
},
|
||
async toImageURL(format, options = {}) {
|
||
const { scale = 1, background = null, embedFonts = [] } = options;
|
||
if (format === 'svg') {
|
||
// Vector — resolution-independent, so dpr/scale don't apply. The view
|
||
// writes only family names, so referenced uploaded faces are embedded as
|
||
// @font-face data-URIs; a background is added as a full-bleed rect. Both
|
||
// are injected into the serialized string, not the live view.
|
||
let svg = embedFontsInSvg(await result.view.toSVG(), embedFonts);
|
||
if (background) svg = withSvgBackground(svg, background);
|
||
return svgDataUrl(svg);
|
||
}
|
||
// Multiply the requested scale by the device pixel ratio so a "1×" export is
|
||
// as crisp as the chart on screen (the Retina fix — see ImageExportOptions).
|
||
const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;
|
||
const chart = await result.view.toCanvas(scale * dpr);
|
||
const out = background ? compositeOnColor(chart, background) : chart;
|
||
return canvasObjectUrl(out);
|
||
},
|
||
resize() {
|
||
// Synthesize the window:resize the container signals listen for (see the
|
||
// interface doc). The view re-reads containerSize() and re-renders itself;
|
||
// a finalized view has already removed its listener, so this is a safe
|
||
// no-op after destroy().
|
||
if (typeof window !== 'undefined') window.dispatchEvent(new Event('resize'));
|
||
},
|
||
inspectData() {
|
||
if (finalized) return null;
|
||
// The tables the marks draw + their input lineage come from the compiled Vega
|
||
// spec (a byproduct of the embed, not recompiled); the rows come from
|
||
// view.data(name), which hands back the live array (no copy).
|
||
const views = inspectableViews(result.vgSpec);
|
||
const rows = (name: string): ReadonlyArray<Record<string, unknown>> =>
|
||
(result.view.data(name) ?? []) as Record<string, unknown>[];
|
||
const tables = views.map((v, i) => ({
|
||
id: v.resolved,
|
||
label: inspectViewLabel(v.input, i),
|
||
input: rows(v.input),
|
||
resolved: rows(v.resolved),
|
||
}));
|
||
return { tables };
|
||
},
|
||
onDataChange(listener) {
|
||
return watchInspectableData(result.view, result.vgSpec, listener, () => finalized);
|
||
},
|
||
};
|
||
}
|