/** * 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'; /** 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; } 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; } 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 }, }); return { destroy() { result.view.finalize(); node.replaceChildren(); }, async toImageURL(format, options = {}) { const { scale = 1, background = null } = options; if (format === 'svg') { // Vector — resolution-independent, so dpr/scale don't apply. A background // is added as a full-bleed rect rather than baked into the live view. const svg = await result.view.toSVG(); return svgDataUrl(background ? withSvgBackground(svg, background) : 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')); }, }; }