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
+10 -1
View File
@@ -11,7 +11,9 @@ authoritative architecture for adding, opening, closing, and rendering modals.
- **At most one modal open at a time** (mandated by the product spec). Opening a
modal closes any other; the two never overlap.
- **Uniform dismissal**: close button, `Escape`, or backdrop click — never a
click inside the body.
click inside the body. A modal holding in-progress work can opt out of the
**backdrop** click (`dismissOnBackdrop: false`) so a stray click can't discard it
(the Chart Builder does); close button and `Escape` still dismiss.
- **Accessible by default**: focus moves into the modal on open and returns to
the trigger on close.
- **Unsaved-change safety** for editing modals, with an explicit opt-out for
@@ -492,6 +494,13 @@ export function useFocusTrap<T extends HTMLElement = HTMLDivElement>(
- Don't dismiss on clicks inside the body, and don't let Escape fire when no
modal is open (the handler only exists while a modal renders).
**Sizing & backdrop opt-out.** The shell picks a **size tier** by modal: a small form
(Extract), a large two-pane manager (Datasets), or a near-fullscreen **work surface**
(Chart Builder — a config pane plus a chart that wants room). The two larger tiers have a
definite height so their inner panes scroll **internally** rather than the modal growing
past the viewport. A modal opts a backdrop click out of dismissal with the registry's
`dismissOnBackdrop: false` (above).
---
## Confirmation & alert dialogs
@@ -101,6 +101,23 @@ async function rerender(node: HTMLElement, spec: TopLevelSpec, config: Config) {
- **Do** pass `actions: false`. Astrolabe owns its own export/copy affordances;
the library's overlay menu does not belong on the preview.
- **SVG is the default renderer, canvas is an opt-in for many-mark previews.** SVG is
crisp/inspectable/copyable and stays the default for the editor's LivePreview. But an
SVG chart renders one DOM node per mark, so a many-mark chart (e.g. the Chart Builder's
default one-bar-per-row on a 10k-row dataset) costs **seconds** of main-thread
layout/paint per render (measured ~6.5s on 9994 rows; the chart paints _after_ it first
appears, freezing the tab). The **Chart Builder preview** therefore passes
`renderSpec(…, { renderer: 'canvas' })` — canvas is a single node and paints in
milliseconds. The raster trade-off is invisible for an ephemeral preview, and image
export (`view.toImageURL`) is renderer-agnostic.
- **Canvas has a hard max dimension; SVG doesn't.** A canvas larger than the browser's
limit (~32k px/side, less on Safari) fails to allocate and draws _nothing_ — silently.
So for canvas, `renderSpec` first runs a headless (`'none'`) layout probe, reads the
resolved height, and throws `ChartTooLargeError(heightPx, limitPx)` when it exceeds
`MAX_CANVAS_PX ÷ devicePixelRatio`, so the caller can show the real cause. This is a
**render-size** limit (the chart is physically too big), distinct from the readability
cardinality warnings — don't conflate them. Only an _unbounded_ axis overflows: a
`width: 'container'` axis is bounded, so it's the deleted (natural-height) axis to watch.
- **Do** call `view.finalize()` on every previous view before rendering a new
one, and on component unmount.
- **Do** keep exactly one live view per preview node.
+4 -2
View File
@@ -301,8 +301,10 @@ trivially testable. The caller passes `null` for URL and non-tabular datasets.
### 3.3 Column stats: cardinality + numeric extent
Alongside the display type, each column carries the two data-shape signals the
**Chart Builder** needs for its data-aware Tier-B hints (spec §06; see
`chart-builder.ts` `builderWarnings`):
**Chart Builder** needs for its data-aware Tier-B hints (`chart-builder.ts`
`builderWarnings`) **and** for its default pre-population (`smartDefaultEncodings`
prefers a low-cardinality category over a high-cardinality key, so the builder never
opens on a degenerate chart; spec §06):
- **`distinct`** — the count of distinct non-empty values **in the sample**,
counted only up to `DISTINCT_CAP` (50). Past the cap the exact number stops
@@ -167,6 +167,18 @@ lives in [04 · Routing & Global Events](04-routing-and-events.md).
uses `alert`/`status` roles by severity. Don't invent keyboard models; adopt the
documented one.
**Resolved — a control that removes its own container.** When activating a control deletes
the element it lives in (e.g. a Chart Builder guidance hint's one-click **fix** button —
the hint re-derives away once applied), focus must not fall to `<body>`. The rule (council:
Carbon _Actionable notification_ + APG _Alert_): **announce the change politely and move
focus to a stable neighbour.** Concretely, the builder writes "Applied: `<label>`." to a
visually-hidden `role="status" aria-live="polite"` node and moves focus to the guidance
region if any hints remain, else the surrounding pane (`tabIndex={-1}` anchors, focused only
programmatically — no visible ring). Advisory hints themselves don't _grab_ focus (APG: an
alert "must not affect keyboard focus"); the fix's remedy lives in a **low-emphasis ghost
button** beside the advice (Carbon: inline actionable → ghost button, wraps under the body
on narrow widths), an _offer_, never a forced change.
**Resolved — pane resize handle (window splitter).** A `ResizeHandle` is a focusable
`role="separator"` that **reports the controlled pane's size**, per APG → Window Splitter: