mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Chart builder: data-aware defaults, one-click hint fixes, canvas preview, fullscreen modal
This commit is contained in:
@@ -3,7 +3,13 @@
|
||||
.builder {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 360px) 1fr;
|
||||
min-height: 480px;
|
||||
/* Fill the modal body and never exceed it, so each pane scrolls internally rather
|
||||
than the whole modal growing past the viewport (otherwise a tall chart pushes the
|
||||
Create/Cancel actions below the fold). The `minmax(0, 1fr)` row lets the panes
|
||||
shrink below their content height so their own overflow kicks in. */
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -24,6 +30,7 @@
|
||||
border-right: var(--border-width) solid var(--border);
|
||||
overflow-y: auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.datasetName {
|
||||
@@ -213,6 +220,13 @@
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* These take focus only programmatically (tabIndex -1) after a hint fix is applied,
|
||||
to keep focus off <body>; no visible ring for that script-driven move. */
|
||||
.warnings:focus,
|
||||
.configPane:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -230,6 +244,42 @@
|
||||
color: var(--support-warning-fg);
|
||||
}
|
||||
|
||||
/* The hint text and its one-click remedies stacked, growing beside the icon. */
|
||||
.warningBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Actionable-hint remedies: each fix is an offer, never forced, so they're
|
||||
low-emphasis ghost buttons — suggestions beside the advice, not commands. */
|
||||
.warningFixes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.warningFix {
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: var(--border-width) solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.warningFix:hover {
|
||||
background: var(--layer-01);
|
||||
}
|
||||
|
||||
.warningFix:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* Explains the disabled Create action (contract 10: a disabled control must say
|
||||
why). `margin-top: auto` pins it just above the actions so the two read as one. */
|
||||
.createHint {
|
||||
@@ -292,6 +342,8 @@
|
||||
flex-direction: column;
|
||||
padding: var(--space-5);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
@@ -305,9 +357,14 @@
|
||||
.previewFrame {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* A chart taller/wider than the pane scrolls *here*, inside a fixed viewport, so
|
||||
the modal keeps its shape. `safe` centring aligns to the start instead of
|
||||
clipping the top/left when the chart overflows. */
|
||||
overflow: auto;
|
||||
align-items: safe center;
|
||||
justify-content: safe center;
|
||||
}
|
||||
|
||||
.previewHost {
|
||||
|
||||
@@ -8,11 +8,26 @@ import { useSnippetStore } from '../stores/SnippetStore';
|
||||
import { ChartBuilderModal } from './ChartBuilderModal';
|
||||
|
||||
// The builder preview embeds a real Vega chart in an effect; stub the renderer so
|
||||
// this render test stays a pure React/DOM check (the loop we guard against happens
|
||||
// during commit, long before any chart is drawn).
|
||||
vi.mock('../services/chart-renderer', () => ({
|
||||
renderSpec: () => Promise.resolve({ destroy() {}, resize() {} }),
|
||||
}));
|
||||
// these tests stay pure React/DOM checks. `renderSpec` is a vi.fn so a test can make
|
||||
// it reject (e.g. the canvas-too-large path); the mocked `ChartTooLargeError` is the
|
||||
// same class the component imports, so its `instanceof` check matches. The class is
|
||||
// declared inside the factory because vi.mock is hoisted above module-scope code.
|
||||
vi.mock('../services/chart-renderer', () => {
|
||||
class ChartTooLargeError extends Error {
|
||||
heightPx: number;
|
||||
limitPx: number;
|
||||
constructor(heightPx: number, limitPx: number) {
|
||||
super('too large');
|
||||
this.name = 'ChartTooLargeError';
|
||||
this.heightPx = heightPx;
|
||||
this.limitPx = limitPx;
|
||||
}
|
||||
}
|
||||
return {
|
||||
renderSpec: vi.fn(() => Promise.resolve({ destroy() {}, resize() {} })),
|
||||
ChartTooLargeError,
|
||||
};
|
||||
});
|
||||
|
||||
// React 19 wants this flag set for act() to drive effects without warnings.
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
@@ -69,6 +84,77 @@ describe('ChartBuilderModal', () => {
|
||||
expect(container.textContent).toContain('scatter'); // the guidance hint rendered
|
||||
});
|
||||
|
||||
test('a guidance hint offers a one-click fix that resolves it (actionable hints, §06)', async () => {
|
||||
const ds = createDataset({
|
||||
name: 'Nums',
|
||||
data: [
|
||||
{ a: 1, b: 2 },
|
||||
{ a: 3, b: 4 },
|
||||
],
|
||||
format: 'json',
|
||||
source: 'inline',
|
||||
now: T,
|
||||
});
|
||||
useDatasetStore.getState().add(ds);
|
||||
const id = useDatasetStore.getState().datasets[0].id;
|
||||
useChartBuilderStore.getState().init(id);
|
||||
useChartBuilderStore.getState().setMark('bar'); // two measures on a bar → scatter hint
|
||||
|
||||
await act(async () => {
|
||||
root.render(<ChartBuilderModal />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// The hint renders a [Switch to Point] button (not just prose).
|
||||
const fixButton = Array.from(container.querySelectorAll('button')).find(
|
||||
(b) => b.textContent === 'Switch to Point',
|
||||
);
|
||||
expect(fixButton).toBeDefined();
|
||||
expect(container.textContent).toContain('scatter');
|
||||
|
||||
await act(async () => {
|
||||
fixButton!.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Applying it switches the mark and the hint re-derives away.
|
||||
expect(useChartBuilderStore.getState().config.mark).toBe('point');
|
||||
expect(container.textContent).not.toContain('scatter');
|
||||
});
|
||||
|
||||
test('shows the canvas-limit message when the chart resolves too large to render', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { renderSpec, ChartTooLargeError } = await import('../services/chart-renderer');
|
||||
vi.mocked(renderSpec).mockRejectedValueOnce(new ChartTooLargeError(200_000, 16_383));
|
||||
|
||||
const ds = createDataset({
|
||||
name: 'Big',
|
||||
data: [
|
||||
{ a: 1, b: 'x' },
|
||||
{ a: 2, b: 'y' },
|
||||
],
|
||||
format: 'json',
|
||||
source: 'inline',
|
||||
now: T,
|
||||
});
|
||||
useDatasetStore.getState().add(ds);
|
||||
const id = useDatasetStore.getState().datasets[0].id;
|
||||
useChartBuilderStore.getState().init(id);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<ChartBuilderModal />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
// Drive the debounced render so renderSpec runs and rejects with the limit error.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('larger than the browser can draw on a canvas');
|
||||
expect(container.textContent).toContain('200,000'); // the measured height
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('shows the empty state when no dataset is loaded', async () => {
|
||||
await act(async () => {
|
||||
root.render(<ChartBuilderModal />);
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
supportsTimeUnit,
|
||||
validFieldTypes,
|
||||
type AggregateOp,
|
||||
type BuilderWarningFix,
|
||||
type ChannelMapping,
|
||||
type ChannelName,
|
||||
type FieldType,
|
||||
@@ -42,7 +43,7 @@ import {
|
||||
import type { ColumnType } from '@core/type-inference';
|
||||
import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering';
|
||||
import { chartConfigFor } from '@core/vega-themes';
|
||||
import { renderSpec, type RenderHandle } from '../services/chart-renderer';
|
||||
import { ChartTooLargeError, renderSpec, type RenderHandle } from '../services/chart-renderer';
|
||||
import { closeModal } from '../modals/ModalCoordinator';
|
||||
import { useAppStore } from '../stores/AppStore';
|
||||
import { useDatasetStore } from '../stores/DatasetStore';
|
||||
@@ -58,6 +59,35 @@ import styles from './ChartBuilderModal.module.css';
|
||||
|
||||
const RENDER_DEBOUNCE_MS = 300;
|
||||
|
||||
/**
|
||||
* Render-timing diagnostics for the builder preview. A many-mark chart (e.g. the
|
||||
* default one-bar-per-row on a 10k-row dataset) is cheap to compile but expensive
|
||||
* for the browser to lay out as **SVG**, and that cost lands *after* `embed()`
|
||||
* resolves, in the next paint — the chart appears, then the tab freezes for a moment.
|
||||
* Each phase is timed, including that post-embed paint (a double rAF lands just after
|
||||
* it), so the numbers attribute the cost to layout rather than chart compilation.
|
||||
* Logged in dev always; in prod only when a render is slow.
|
||||
*/
|
||||
const SLOW_RENDER_MS = 250;
|
||||
function logBuilderRenderTiming(t: {
|
||||
parse: number;
|
||||
prepare: number;
|
||||
destroy: number;
|
||||
embed: number;
|
||||
paint: number;
|
||||
total: number;
|
||||
}): void {
|
||||
const total = Math.round(t.total);
|
||||
if (!import.meta.env.DEV && total < SLOW_RENDER_MS) return;
|
||||
const ms = (n: number) => Math.round(n);
|
||||
const { rowCount, config } = useChartBuilderStore.getState();
|
||||
console.info(
|
||||
`[chart-builder] render ${total}ms — parse ${ms(t.parse)} · prepare ${ms(t.prepare)} · ` +
|
||||
`destroy ${ms(t.destroy)} · embed ${ms(t.embed)} · paint ${ms(t.paint)} ` +
|
||||
`(mark=${config.mark}, rows=${rowCount ?? 'n/a'})`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Title-case a token for display (e.g. `bar` → `Bar`, `sum` → `Sum`). */
|
||||
function titleCase(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
@@ -264,6 +294,9 @@ function BuilderPreview() {
|
||||
const handleRef = useRef<RenderHandle | null>(null);
|
||||
const generationRef = useRef(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Set when the chart resolves larger than the canvas backend can draw — a
|
||||
// physical render-size limit, distinct from the readability cardinality warnings.
|
||||
const [tooLarge, setTooLarge] = useState<{ heightPx: number; limitPx: number } | null>(null);
|
||||
|
||||
const specText = useChartBuilderStore(selectBuilderSpecText);
|
||||
const valid = useChartBuilderStore(selectBuilderValid);
|
||||
@@ -279,18 +312,26 @@ function BuilderPreview() {
|
||||
handleRef.current?.destroy();
|
||||
handleRef.current = null;
|
||||
setError(null);
|
||||
setTooLarge(null);
|
||||
return;
|
||||
}
|
||||
if (!node) return;
|
||||
try {
|
||||
const t0 = performance.now();
|
||||
const parsed: unknown = JSON.parse(specText);
|
||||
const t1 = performance.now();
|
||||
const prepared = prepareSpecForRender(parsed, { fitMode: 'width', datasets });
|
||||
handleRef.current?.destroy();
|
||||
const t2 = performance.now();
|
||||
handleRef.current?.destroy(); // finalizing a huge prior SVG is itself a cost
|
||||
handleRef.current = null;
|
||||
const t3 = performance.now();
|
||||
const handle = await renderSpec(
|
||||
node,
|
||||
prepared as VisualizationSpec,
|
||||
chartConfigFor(uiTheme),
|
||||
// Canvas, not SVG: a many-mark preview (one bar per row of a big dataset)
|
||||
// costs seconds of SVG layout/paint; canvas paints in ms (see renderer).
|
||||
{ renderer: 'canvas' },
|
||||
);
|
||||
if (mine !== generationRef.current) {
|
||||
handle.destroy();
|
||||
@@ -298,12 +339,37 @@ function BuilderPreview() {
|
||||
}
|
||||
handleRef.current = handle;
|
||||
setError(null);
|
||||
setTooLarge(null);
|
||||
const t4 = performance.now();
|
||||
// The browser lays out/paints the (possibly huge) SVG after embed resolves;
|
||||
// a double rAF lands just after that paint, capturing the freeze the user
|
||||
// feels. Skipped if a newer render has already superseded this one.
|
||||
requestAnimationFrame(() =>
|
||||
requestAnimationFrame(() => {
|
||||
if (mine !== generationRef.current) return;
|
||||
const t5 = performance.now();
|
||||
logBuilderRenderTiming({
|
||||
parse: t1 - t0,
|
||||
prepare: t2 - t1,
|
||||
destroy: t3 - t2,
|
||||
embed: t4 - t3,
|
||||
paint: t5 - t4,
|
||||
total: t5 - t0,
|
||||
});
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mine !== generationRef.current) return;
|
||||
if (e instanceof DatasetNotFoundError) {
|
||||
if (e instanceof ChartTooLargeError) {
|
||||
// A physical render-size limit (canvas max dimension), not a data error.
|
||||
setTooLarge({ heightPx: e.heightPx, limitPx: e.limitPx });
|
||||
setError(null);
|
||||
} else if (e instanceof DatasetNotFoundError) {
|
||||
setError(`Dataset "${e.datasetName}" not found.`);
|
||||
setTooLarge(null);
|
||||
} else {
|
||||
setError(`Couldn't render this chart: ${(e as Error).message}`);
|
||||
setTooLarge(null);
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -325,10 +391,18 @@ function BuilderPreview() {
|
||||
{!valid && (
|
||||
<p className={styles.previewHint}>Map at least one channel to a column to see a chart.</p>
|
||||
)}
|
||||
<div className={styles.previewFrame} hidden={!valid || error !== null}>
|
||||
{valid && tooLarge && (
|
||||
<p className={styles.previewHint} role="status">
|
||||
This chart would be about {Math.round(tooLarge.heightPx).toLocaleString()} px tall —
|
||||
larger than the browser can draw on a canvas (
|
||||
{Math.round(tooLarge.limitPx).toLocaleString()} px max here). Aggregate the measure or
|
||||
filter to fewer rows so it fits.
|
||||
</p>
|
||||
)}
|
||||
<div className={styles.previewFrame} hidden={!valid || tooLarge !== null || error !== null}>
|
||||
<div className={styles.previewHost} ref={hostRef} />
|
||||
</div>
|
||||
{valid && error !== null && (
|
||||
{valid && tooLarge === null && error !== null && (
|
||||
<pre className={styles.previewError} role="alert">
|
||||
{error}
|
||||
</pre>
|
||||
@@ -351,6 +425,7 @@ export function ChartBuilderModal() {
|
||||
const setStack = useChartBuilderStore((s) => s.setStack);
|
||||
const setWidth = useChartBuilderStore((s) => s.setWidth);
|
||||
const setHeight = useChartBuilderStore((s) => s.setHeight);
|
||||
const applyWarningFix = useChartBuilderStore((s) => s.applyWarningFix);
|
||||
const runCreate = useChartBuilderStore((s) => s.createSnippet);
|
||||
|
||||
// Validity + guidance + which chart-level controls apply are derived from the
|
||||
@@ -369,6 +444,30 @@ export function ChartBuilderModal() {
|
||||
const canSort = useMemo(() => supportsSort(config), [config]);
|
||||
const canStack = useMemo(() => supportsStack(config), [config]);
|
||||
|
||||
// Applying a hint's fix removes that hint's list item, so focus would otherwise fall
|
||||
// to <body>. The change is announced politely (the chart updates silently for sighted
|
||||
// users) and focus moves to the guidance region, or the config pane if the last hint
|
||||
// just cleared — the pattern for a control that removes its own container (arch 10 §5).
|
||||
const configPaneRef = useRef<HTMLDivElement>(null);
|
||||
const warningsRef = useRef<HTMLUListElement>(null);
|
||||
const pendingFixFocus = useRef(false);
|
||||
const [fixAnnouncement, setFixAnnouncement] = useState('');
|
||||
|
||||
const handleFix = (fix: BuilderWarningFix) => {
|
||||
applyWarningFix(fix); // re-derives `warnings`, firing the focus effect below
|
||||
setFixAnnouncement(`Applied: ${fix.label}.`);
|
||||
pendingFixFocus.current = true;
|
||||
};
|
||||
|
||||
// After a fix re-derives the warnings, move focus off the (now-removed) button:
|
||||
// to the guidance region if hints remain, else the config pane. Ref-flag, not
|
||||
// state, so we never setState inside the effect (react-hooks/set-state-in-effect).
|
||||
useEffect(() => {
|
||||
if (!pendingFixFocus.current) return;
|
||||
pendingFixFocus.current = false;
|
||||
(warningsRef.current ?? configPaneRef.current)?.focus();
|
||||
}, [warnings]);
|
||||
|
||||
if (datasetId === null) {
|
||||
return <p className={styles.muted}>No dataset loaded. Open this from a dataset in Datasets.</p>;
|
||||
}
|
||||
@@ -381,7 +480,10 @@ export function ChartBuilderModal() {
|
||||
|
||||
return (
|
||||
<div className={styles.builder}>
|
||||
<div className={styles.configPane}>
|
||||
<div className={styles.configPane} ref={configPaneRef} tabIndex={-1}>
|
||||
<div className="visually-hidden" role="status" aria-live="polite">
|
||||
{fixAnnouncement}
|
||||
</div>
|
||||
<p className={styles.datasetName}>
|
||||
Building from <strong>{datasetName}</strong>
|
||||
</p>
|
||||
@@ -464,11 +566,32 @@ export function ChartBuilderModal() {
|
||||
</div>
|
||||
|
||||
{warnings.length > 0 && (
|
||||
<ul className={styles.warnings}>
|
||||
<ul
|
||||
className={styles.warnings}
|
||||
ref={warningsRef}
|
||||
tabIndex={-1}
|
||||
aria-label="Chart guidance"
|
||||
>
|
||||
{warnings.map((w) => (
|
||||
<li key={w.message} className={styles.warning}>
|
||||
<Icon name="status-warning" className={styles.warningIcon} />
|
||||
<span>{w.message}</span>
|
||||
<div className={styles.warningBody}>
|
||||
<span>{w.message}</span>
|
||||
{w.fixes && w.fixes.length > 0 && (
|
||||
<div className={styles.warningFixes}>
|
||||
{w.fixes.map((fix) => (
|
||||
<button
|
||||
key={fix.label}
|
||||
type="button"
|
||||
className={styles.warningFix}
|
||||
onClick={() => handleFix(fix)}
|
||||
>
|
||||
{fix.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -28,6 +28,14 @@
|
||||
height: min(700px, 88vh);
|
||||
}
|
||||
|
||||
/* Extra-large: the Chart Builder — a work surface (config + a chart that wants room),
|
||||
with nothing useful behind it. Near-fullscreen, capped so it doesn't stretch absurdly
|
||||
on ultra-wide displays. Definite height so its panes scroll internally (not the modal). */
|
||||
.xlarge {
|
||||
width: min(1800px, 96vw);
|
||||
height: min(1100px, 92vh);
|
||||
}
|
||||
|
||||
/* Small: single-form modals (Extract). Grows with content up to a cap. */
|
||||
.small {
|
||||
width: min(560px, 92vw);
|
||||
|
||||
@@ -23,7 +23,11 @@ export function ModalShell() {
|
||||
const name = useAppStore((s) => s.activeModal);
|
||||
const config = getModalConfig(name);
|
||||
|
||||
const isLarge = name === 'datasets' || name === 'chartBuilder';
|
||||
// The Chart Builder is a near-fullscreen work surface; the Datasets manager is the
|
||||
// standard large two-pane modal; everything else is a small form. Both large kinds
|
||||
// get the static-title initial focus (APG dialog-modal) so content isn't skipped.
|
||||
const isXLarge = name === 'chartBuilder';
|
||||
const isLarge = name === 'datasets' || isXLarge;
|
||||
|
||||
// Move focus into the modal on open, return it to the trigger on close. For a
|
||||
// large manager (list + detail), APG dialog-modal advises focusing a static
|
||||
@@ -38,10 +42,16 @@ export function ModalShell() {
|
||||
if (!config) return null;
|
||||
const Body = config.component;
|
||||
|
||||
// Modals with in-progress work (the Chart Builder's config) opt out of
|
||||
// click-outside-to-close so an accidental backdrop click can't discard it; Escape
|
||||
// and the close button still dismiss. Other modals keep backdrop dismissal.
|
||||
const dismissOnBackdrop = config.dismissOnBackdrop !== false;
|
||||
const sizeClass = isXLarge ? styles.xlarge : isLarge ? styles.large : styles.small;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.backdrop}
|
||||
onClick={() => void closeModal()}
|
||||
onClick={dismissOnBackdrop ? () => void closeModal() : undefined}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
@@ -51,7 +61,7 @@ export function ModalShell() {
|
||||
>
|
||||
<div
|
||||
ref={modalRef}
|
||||
className={`${styles.modal} ${isLarge ? styles.large : styles.small}`}
|
||||
className={`${styles.modal} ${sizeClass}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
|
||||
@@ -42,6 +42,12 @@ export interface ModalConfig {
|
||||
getState?: () => Record<string, unknown> | null;
|
||||
/** Whether the modal is reflected in the URL hash (navigable). */
|
||||
isUrlNavigable?: boolean;
|
||||
/**
|
||||
* Whether a backdrop (click-outside) closes the modal. Defaults to `true`. Set
|
||||
* `false` for modals holding in-progress work an accidental click shouldn't
|
||||
* discard (the Chart Builder) — Escape and the close button still dismiss.
|
||||
*/
|
||||
dismissOnBackdrop?: boolean;
|
||||
}
|
||||
|
||||
export const MODAL_REGISTRY: Partial<Record<ModalName, ModalConfig>> = {
|
||||
@@ -71,11 +77,14 @@ export const MODAL_REGISTRY: Partial<Record<ModalName, ModalConfig>> = {
|
||||
// Opened from a selected dataset's "Build Chart" action; `arg` is its id. Loads
|
||||
// the dataset and pre-populates a smart default config (§06). Applies on Create
|
||||
// (a new snippet), so there is nothing transient to lose on close — no getState.
|
||||
// Backdrop dismissal is off: the config is real in-progress work, and a stray
|
||||
// click outside this large surface shouldn't throw it away (Escape/× still close).
|
||||
chartBuilder: {
|
||||
name: 'chartBuilder',
|
||||
title: 'Chart Builder',
|
||||
component: ChartBuilderModal,
|
||||
isUrlNavigable: true,
|
||||
dismissOnBackdrop: false,
|
||||
init: (datasetId) => useChartBuilderStore.getState().init(datasetId ? Number(datasetId) : null),
|
||||
},
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -127,6 +127,19 @@ describe('sort / stack', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyWarningFix', () => {
|
||||
test('applies a hint fix to the working config (actionable hints, §06)', () => {
|
||||
const id = seedDataset('Nums', [
|
||||
{ a: 1, b: 2 },
|
||||
{ a: 3, b: 4 },
|
||||
]);
|
||||
cb().init(id);
|
||||
cb().setMark('bar'); // two quantitative axes on a bar → "scatter" hint with a fix
|
||||
cb().applyWarningFix({ label: 'Switch to Point', apply: (c) => ({ ...c, mark: 'point' }) });
|
||||
expect(cb().config.mark).toBe('point');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSnippet', () => {
|
||||
test('builds a linked snippet, activates it, and resets the builder', () => {
|
||||
const id = seedDataset('Sales', [
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user