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

257 lines
12 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';
/** 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;
}
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;
}
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 },
});
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'));
},
};
}