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
@@ -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 {
+91 -5
View File
@@ -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 />);
+131 -8
View File
@@ -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>
+8
View File
@@ -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);
+13 -3
View File
@@ -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"
+9
View File
@@ -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),
},
+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,
});
+13
View File
@@ -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.
+138 -13
View File
@@ -108,16 +108,20 @@ describe('builderWarnings (Tier B advisories)', () => {
expect(w.some((m) => /need both an X and a Y/.test(m.message))).toBe(true);
});
it('warns when two measures are drawn on a non-scatter mark', () => {
const w = builderWarnings({
it('warns when two measures are drawn on a non-scatter mark, offering [Switch to Point]', () => {
const config: BuilderConfig = {
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'a', type: 'quantitative' },
y: { field: 'b', type: 'quantitative' },
},
});
expect(w.some((m) => /scatter/.test(m.message))).toBe(true);
};
const w = builderWarnings(config);
const hint = w.find((m) => /scatter/.test(m.message));
const fix = hint?.fixes?.find((f) => f.label === 'Switch to Point');
expect(fix).toBeDefined();
expect(fix!.apply(config).mark).toBe('point');
});
it('warns when a bar/line/area has no measure on either axis', () => {
@@ -129,8 +133,8 @@ describe('builderWarnings (Tier B advisories)', () => {
expect(w.some((m) => /need a measure/.test(m.message))).toBe(true);
});
it('warns when an area chart is split into colour series', () => {
const w = builderWarnings({
it('warns when an area chart is split into colour series, offering [Stack] / [Remove colour]', () => {
const config: BuilderConfig = {
datasetName: 'D',
mark: 'area',
encodings: {
@@ -138,8 +142,20 @@ describe('builderWarnings (Tier B advisories)', () => {
y: { field: 'v', type: 'quantitative' },
color: { field: 'g', type: 'nominal' },
},
});
expect(w.some((m) => m.channel === 'color')).toBe(true);
};
const w = builderWarnings(config);
const hint = w.find((m) => m.channel === 'color');
expect(hint).toBeDefined();
const labels = hint?.fixes?.map((f) => f.label) ?? [];
expect(labels).toEqual(['Stack', 'Remove colour']); // most-recommended first
// [Remove colour] clears the colour channel, so the hint re-derives away.
const cleared = hint!.fixes!.find((f) => f.label === 'Remove colour')!.apply(config);
expect(cleared.encodings.color).toBeNull();
expect(builderWarnings(cleared).some((m) => m.channel === 'color')).toBe(false);
// [Stack] turns it into a part-to-whole stack, which is no longer flagged.
const stacked = hint!.fixes!.find((f) => f.label === 'Stack')!.apply(config);
expect(stacked.stack).toBe('zero');
expect(builderWarnings(stacked).some((m) => m.channel === 'color')).toBe(false);
});
it('is silent for a clean configuration', () => {
@@ -170,7 +186,29 @@ describe('builderWarnings (Tier B advisories)', () => {
const hint = w.find((m) => /one mark per row/.test(m.message));
expect(hint?.channel).toBe('x'); // the category axis
expect(hint?.message).toContain('406 in this dataset');
expect(hint?.message).toMatch(/Swap X\/Y/); // bar → horizontal-bar remedy
// Remedies are one-click fixes, not prose; most-recommended first.
const labels = hint?.fixes?.map((f) => f.label) ?? [];
expect(labels).toEqual(['Aggregate as Sum', 'Swap X/Y']); // aggregate before swap
});
it('[Aggregate as Sum] resolves the one-mark-per-row hint', () => {
const w = crowded();
const hint = w.find((m) => /one mark per row/.test(m.message));
const fix = hint?.fixes?.find((f) => f.label === 'Aggregate as Sum');
expect(fix).toBeDefined();
const fixed = fix!.apply({
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'name', type: 'nominal' },
y: { field: 'mpg', type: 'quantitative' },
},
});
expect(fixed.encodings.y?.aggregate).toBe('sum');
// and the hint is gone once applied
expect(builderWarnings(fixed, 406).some((m) => /one mark per row/.test(m.message))).toBe(
false,
);
});
it('is silent once the measure is aggregated (one bar per category)', () => {
@@ -219,8 +257,9 @@ describe('builderWarnings (Tier B advisories)', () => {
);
const hint = w.find((m) => /one mark per row/.test(m.message));
expect(hint).toBeDefined();
expect(hint?.message).not.toMatch(/Swap X\/Y/);
expect(hint?.message).toMatch(/reduce the number of categories/);
const labels = hint?.fixes?.map((f) => f.label) ?? [];
expect(labels).not.toContain('Swap X/Y'); // a horizontal line makes no sense
expect(labels).toContain('Aggregate as Sum'); // aggregate still applies
});
});
@@ -262,7 +301,7 @@ describe('builderWarnings (Tier B advisories)', () => {
const hint = w.find((m) => /distinct values/.test(m.message));
expect(hint?.channel).toBe('x');
expect(hint?.message).toMatch(/48 distinct values/);
expect(hint?.message).toMatch(/Swap X\/Y/); // bar → horizontal-bar remedy
expect(hint?.fixes?.map((f) => f.label)).toContain('Swap X/Y'); // horizontal-bar remedy
});
it('reports "more than 50" when the category cardinality hit the profiler cap', () => {
@@ -426,7 +465,8 @@ describe('builderWarnings (Tier B advisories)', () => {
});
describe('defaultBuilderConfig', () => {
it('puts the first column on X and the second on Y, each with derived type', () => {
it('falls back to first-on-X, second-on-Y when the dataset is unprofiled', () => {
// `columns` carries no columnStats → no data-aware pick → positional default.
const config = defaultBuilderConfig('Sales', columns);
expect(config.mark).toBe('bar');
expect(config.datasetName).toBe('Sales');
@@ -467,6 +507,91 @@ describe('defaultBuilderConfig', () => {
});
});
describe('defaultBuilderConfig — data-aware "safest bet" (profiled datasets)', () => {
/** Stats for one column. */
const stat = (name: string, distinct: number, capped = false) => ({
name,
distinct,
distinctCapped: capped,
numericExtent: null,
});
it('opens on a low-cardinality category vs a count of records, not the first two columns', () => {
// Superstore-shaped: an id-like number first, a high-cardinality id, then tidy
// categories — the case a positional first-two-columns default would open as a
// 9994-bar degenerate chart.
const wide: BuilderColumns = {
columns: ['Row ID', 'Order ID', 'Segment', 'Sales'],
columnTypes: [
{ name: 'Row ID', type: 'number' },
{ name: 'Order ID', type: 'string' },
{ name: 'Segment', type: 'string' },
{ name: 'Sales', type: 'number' },
],
columnStats: [
stat('Row ID', 50, true),
stat('Order ID', 50, true), // high cardinality → not a category axis
stat('Segment', 3), // tidy category → the pick
stat('Sales', 50, true),
],
};
const config = defaultBuilderConfig('Superstore', wide);
expect(config.mark).toBe('bar');
expect(config.encodings.x).toEqual({ field: 'Segment', type: 'nominal' });
expect(config.encodings.y).toEqual({ type: 'quantitative', aggregate: 'count' });
});
it('picks the lowest-cardinality readable category among several', () => {
const cols: BuilderColumns = {
columns: ['Region', 'Segment', 'City'],
columnTypes: [
{ name: 'Region', type: 'string' },
{ name: 'Segment', type: 'string' },
{ name: 'City', type: 'string' },
],
columnStats: [stat('Region', 4), stat('Segment', 3), stat('City', 50, true)],
};
const config = defaultBuilderConfig('D', cols);
expect(config.encodings.x).toEqual({ field: 'Segment', type: 'nominal' }); // 3 < 4
});
it('falls through to a time series (date vs count) when no tidy category exists', () => {
const cols: BuilderColumns = {
columns: ['Order ID', 'Order Date', 'Sales'],
columnTypes: [
{ name: 'Order ID', type: 'string' },
{ name: 'Order Date', type: 'date' },
{ name: 'Sales', type: 'number' },
],
columnStats: [
stat('Order ID', 50, true),
stat('Order Date', 50, true),
stat('Sales', 50, true),
],
};
const config = defaultBuilderConfig('D', cols);
expect(config.mark).toBe('line');
expect(config.encodings.x).toEqual({ field: 'Order Date', type: 'temporal' });
expect(config.encodings.y).toEqual({ field: 'Sales', type: 'quantitative' }); // the measure, raw
});
it('falls through to a scatter of two measures when there is no category or date', () => {
const cols: BuilderColumns = {
columns: ['Order ID', 'Sales', 'Profit'],
columnTypes: [
{ name: 'Order ID', type: 'string' },
{ name: 'Sales', type: 'number' },
{ name: 'Profit', type: 'number' },
],
columnStats: [stat('Order ID', 50, true), stat('Sales', 50, true), stat('Profit', 50, true)],
};
const config = defaultBuilderConfig('D', cols);
expect(config.mark).toBe('point');
expect(config.encodings.x).toEqual({ field: 'Sales', type: 'quantitative' });
expect(config.encodings.y).toEqual({ field: 'Profit', type: 'quantitative' });
});
});
describe('isBuilderConfigValid', () => {
const base: BuilderConfig = { datasetName: 'D', mark: 'bar', encodings: {} };
+177 -28
View File
@@ -230,14 +230,87 @@ function fieldTypeForColumn(name: string, columns: BuilderColumns): FieldType {
return defaultFieldType(match?.type ?? 'string');
}
/**
* Above this many distinct values, a column is too high-cardinality to be a good
* default category axis: its labels overlap into an unreadable axis, and an
* unaggregated chart that wide can exceed the canvas size limit. Matches the
* crowded-axis warning threshold.
*/
const CATEGORY_DEFAULT_MAX_DISTINCT = 30;
/**
* Pick a *sensible, renderable* default X/Y from the data shape, using profiled
* cardinality (`columnStats`). This avoids opening the builder on a degenerate
* one-mark-per-row chart — the shape a positional first-two-columns rule yields when
* the leading columns are an id and a high-cardinality key. Returns `null` when there
* are no stats to reason about (older / URL datasets), so the caller falls back to the
* positional default.
*
* Preference order (each guaranteed to render and read cleanly):
* 1. a low-cardinality **category** vs a **count of records** → a tidy bar;
* 2. else a **temporal** axis vs count → a time series (continuous x, always fits);
* 3. else two **measures** → a scatter (continuous axes, always fit).
*
* The category case pairs with the field-less **count**, not a raw measure: count is
* always meaningful and avoids summing an id-like numeric (Row ID, Postal Code) into
* nonsense. This is the builder's *opening* state only; a later intent-first entry
* point can layer richer recommendations on top.
*/
function smartDefaultEncodings(
columns: BuilderColumns,
): { x: ChannelMapping; y: ChannelMapping } | null {
const stats = columns.columnStats;
if (!stats || stats.length === 0) return null; // no profiling → positional fallback
const typeOf = (name: string): ColumnType =>
columns.columnTypes.find((c) => c.name === name)?.type ?? 'string';
const knownDistinct = (name: string): number | undefined => {
const s = stats.find((x) => x.name === name);
return s && !s.distinctCapped ? s.distinct : undefined;
};
const count: ChannelMapping = { type: 'quantitative', aggregate: 'count' };
const numbers = columns.columns.filter((name) => typeOf(name) === 'number');
// 1. The lowest-cardinality readable category (string/boolean) → a tidy bar. Pair it
// with **count** (not a raw measure): a raw measure would draw one bar per row.
const category = columns.columns
.filter((name) => {
const t = typeOf(name);
if (t !== 'string' && t !== 'boolean') return false;
const d = knownDistinct(name);
return d !== undefined && d >= 2 && d <= CATEGORY_DEFAULT_MAX_DISTINCT;
})
.sort((a, b) => (knownDistinct(a) ?? 0) - (knownDistinct(b) ?? 0))[0];
if (category) return { x: { field: category, type: 'nominal' }, y: count };
// 2. A date → a time series of the first measure (a temporal axis is continuous, so a
// line of raw values always fits); fall back to count if there is no measure.
const temporal = columns.columns.find((name) => typeOf(name) === 'date');
if (temporal) {
const y: ChannelMapping = numbers[0] ? { field: numbers[0], type: 'quantitative' } : count;
return { x: { field: temporal, type: 'temporal' }, y };
}
// 3. Two measures → a scatter (continuous axes, always renderable).
if (numbers.length >= 2) {
return {
x: { field: numbers[0], type: 'quantitative' },
y: { field: numbers[1], type: 'quantitative' },
};
}
return null;
}
/**
* The builder's opening configuration for a dataset (spec §06 → Default
* pre-population, Tier B): the first column on X and the second (if any) on Y, each
* with its derived field type; Color and Size start unmapped, no transforms. The
* mark is the **smart default** for the resulting X/Y shape (`defaultMark`) rather
* than always Bar — a date-vs-number dataset opens as a Line, two measures as a
* Point — so the first preview is already the conventional chart. A dataset with no
* detected columns yields an all-unmapped config (the modal then prompts).
* pre-population, Tier B). When the dataset is profiled, X/Y are chosen as a
* **data-aware "safest bet"** (`smartDefaultEncodings`) — a low-cardinality category
* vs a count of records (a tidy bar), else a time series, else a scatter — so the
* builder never opens on a degenerate one-mark-per-row chart that can't render. When
* there are no stats to reason about, it falls back to the positional rule: first
* column on X, second (if any) on Y, each with its derived field type. Either way the
* mark is the **smart default** for the resulting X/Y shape (`defaultMark`), Color
* and Size start unmapped with no transforms, and a dataset with no detected columns
* yields an all-unmapped config (the modal then prompts).
*/
export function defaultBuilderConfig(datasetName: string, columns: BuilderColumns): BuilderConfig {
const encodings: Partial<Record<ChannelName, ChannelMapping | null>> = {
@@ -246,12 +319,20 @@ export function defaultBuilderConfig(datasetName: string, columns: BuilderColumn
color: null,
size: null,
};
const [first, second] = columns.columns;
if (first !== undefined) {
encodings.x = { field: first, type: fieldTypeForColumn(first, columns) };
}
if (second !== undefined) {
encodings.y = { field: second, type: fieldTypeForColumn(second, columns) };
// Prefer a data-aware "safest bet" (a renderable, readable chart) when the dataset
// is profiled; otherwise fall back to the positional first-on-X, second-on-Y rule.
const smart = smartDefaultEncodings(columns);
if (smart) {
encodings.x = smart.x;
encodings.y = smart.y;
} else {
const [first, second] = columns.columns;
if (first !== undefined) {
encodings.x = { field: first, type: fieldTypeForColumn(first, columns) };
}
if (second !== undefined) {
encodings.y = { field: second, type: fieldTypeForColumn(second, columns) };
}
}
const mark = defaultMark(encodings.x?.type ?? null, encodings.y?.type ?? null);
return { datasetName, mark, encodings };
@@ -329,12 +410,27 @@ export function supportsStack(config: BuilderConfig): boolean {
);
}
/**
* A one-click remedy a warning can offer. `apply` is a pure config→config transform;
* the modal renders `label` as a button that runs it. It is an *offer*, never a forced
* change — once applied, the warning re-derives away. Lives in core so the remedies
* unit-test alongside the warnings.
*/
export interface BuilderWarningFix {
/** The button label naming the remedy, e.g. "Aggregate as Sum". */
label: string;
/** Produce the corrected configuration from the current one (pure). */
apply: (config: BuilderConfig) => BuilderConfig;
}
/** A non-blocking advisory about a configuration (spec §06 → Tier B warnings). */
export interface BuilderWarning {
/** The channel the hint is about, when it's channel-specific. */
channel?: ChannelName;
/** A short, plain-language hint the modal shows inline (not an error). */
message: string;
/** Optional one-click remedies the modal renders as buttons next to the hint. */
fixes?: BuilderWarningFix[];
}
/**
@@ -371,6 +467,41 @@ function cardinalityText(stats: ColumnStats): string {
return stats.distinctCapped ? `more than ${DISTINCT_CAP}` : `${stats.distinct}`;
}
// --- Pure config transforms backing the actionable-hint fixes. They mirror the
// store's setChannelAggregate / swapXY / setStack / setChannelColumn(null) / setMark
// actions, so applying a fix and making the equivalent manual edit land on the same
// config. Kept here (not the store) so the remedies are pure and unit-testable. ---
/** Aggregate one channel's field (clearing any bin — the two are mutually exclusive). */
function withChannelAggregate(
config: BuilderConfig,
channel: ChannelName,
aggregate: AggregateOp,
): BuilderConfig {
const current = config.encodings[channel];
if (!current) return config;
const next: ChannelMapping = { ...current, aggregate };
delete next.bin;
return { ...config, encodings: { ...config.encodings, [channel]: next } };
}
/** Exchange the X and Y mappings (the manual Swap X/Y, as a pure transform). */
function withSwappedXY(config: BuilderConfig): BuilderConfig {
return {
...config,
encodings: {
...config.encodings,
x: config.encodings.y ?? null,
y: config.encodings.x ?? null,
},
};
}
/** Clear one channel back to "None" (drops its mapping from the spec). */
function withChannelCleared(config: BuilderConfig, channel: ChannelName): BuilderConfig {
return { ...config, encodings: { ...config.encodings, [channel]: null } };
}
/**
* Non-blocking advisories for the current configuration (spec §06 → Tier B): the
* encodings that render but read poorly, drawn from the research's soft rules
@@ -427,17 +558,25 @@ export function builderWarnings(
mark !== 'circle'
) {
warnings.push({
message: 'Two measures usually read best as a scatter — try Point or Circle.',
message: 'Two measures usually read best as a scatter.',
fixes: [{ label: 'Switch to Point', apply: (c) => ({ ...c, mark: 'point' }) }],
});
}
// Area split into many series hides per-component change (FT Visual Vocabulary:
// "seeing change in components can be very difficult").
if (mark === 'area' && config.encodings.color) {
// (Stacking turns overlapping series into a cumulative part-to-whole, a valid read,
// so a stacked area is not flagged — applying the [Stack] fix below clears this.)
if (mark === 'area' && config.encodings.color && !config.stack) {
const fixes: BuilderWarningFix[] = [];
if (supportsStack(config)) {
fixes.push({ label: 'Stack', apply: (c) => ({ ...c, stack: 'zero' }) });
}
fixes.push({ label: 'Remove colour', apply: (c) => withChannelCleared(c, 'color') });
warnings.push({
channel: 'color',
message:
'Area charts make per-series change hard to read; consider Line for multiple series.',
message: 'Area charts make per-series change hard to read.',
fixes,
});
}
@@ -455,28 +594,38 @@ export function builderWarnings(
// long category lists belong on a horizontal bar).
if (mark === 'bar' || mark === 'line' || mark === 'area') {
const category = sortableCategoryChannel(config); // discrete axis of a category-vs-measure pair
const measure = category ? config.encodings[category === 'x' ? 'y' : 'x'] : null;
if (category && measure) {
const measureChannel = category ? (category === 'x' ? 'y' : 'x') : null;
const measure = measureChannel ? (config.encodings[measureChannel] ?? null) : null;
if (category && measureChannel && measure) {
const rawMeasure = !measure.aggregate && !measure.bin;
if (rawMeasure && typeof rowCount === 'number' && rowCount > CROWDED_CATEGORY_ROWS) {
const fix =
mark === 'bar'
? 'Aggregate the measure (e.g. Sum or Mean) for one bar per category, or use Swap X/Y for a horizontal bar where long labels stay readable.'
: 'Aggregate the measure (e.g. Sum or Mean) so there is one mark per category, or reduce the number of categories.';
// Aggregating the measure collapses one-mark-per-row to one-per-category; a bar
// can also flip horizontal (Swap X/Y) where long labels stay readable. Offer
// aggregate only for a quantitative measure (Sum is meaningless on a date).
const fixes: BuilderWarningFix[] = [];
if (effectiveType(measure) === 'quantitative') {
fixes.push({
label: 'Aggregate as Sum',
apply: (c) => withChannelAggregate(c, measureChannel, 'sum'),
});
}
if (mark === 'bar') fixes.push({ label: 'Swap X/Y', apply: withSwappedXY });
warnings.push({
channel: category,
message: `This draws one mark per row (${rowCount} in this dataset), so the category-axis labels will overlap. ${fix}`,
message: `This draws one mark per row (${rowCount} in this dataset), so the category-axis labels will overlap.`,
fixes: fixes.length ? fixes : undefined,
});
} else {
const stats = statsFor(config.encodings[category]?.field, columns);
if (stats && stats.distinct > CROWDED_CATEGORY_DISTINCT) {
const fix =
mark === 'bar'
? 'Use Swap X/Y for a horizontal bar where long lists stay readable, or filter to fewer categories.'
: 'Filter to fewer categories, or group the long tail into an "Other".';
// Aggregated already, so the remedy is fewer categories (filter — not yet a
// builder control) or, for a bar, a horizontal flip where long lists fit.
const fixes: BuilderWarningFix[] =
mark === 'bar' ? [{ label: 'Swap X/Y', apply: withSwappedXY }] : [];
warnings.push({
channel: category,
message: `This category axis has ${cardinalityText(stats)} distinct values, so its labels will overlap. ${fix}`,
message: `This category axis has ${cardinalityText(stats)} distinct values, so its labels will overlap.`,
fixes: fixes.length ? fixes : undefined,
});
}
}