Chart builder: data-aware defaults, one-click hint fixes, canvas preview, fullscreen modal

This commit is contained in:
2026-06-10 17:10:18 +03:00
parent 68a044752f
commit 62d0697f0e
20 changed files with 1133 additions and 73 deletions
+72 -2
View File
@@ -27,15 +27,85 @@ export interface RenderHandle {
resize(): void;
}
/** Embed a prepared spec into `node`. Non-negotiable: no actions menu, SVG output. */
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 (`view.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.
*/
export 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;
}
/** 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';
// 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: 'svg',
renderer,
config,
});