Files
astrolabe/src/app/services/chart-renderer.ts
T

395 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
},
};
}