/** * 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 ``. 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; } /** 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>; /** Post-transform rows the marks draw — the output (equals `input` when the view * has no transforms). */ resolved: ReadonlyArray>; } /** * 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 | 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; /** * 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 `` as the first child of the root ``, * 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(/(]*>)/, `$1`); } /** 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 { 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 { if (typeof document === 'undefined' || !document.fonts?.load) return; const families = new Set([...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 { 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 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 ). 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> => (result.view.data(name) ?? []) as Record[]; 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); }, }; }