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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user