Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this

## [Unreleased]

### Fixed

- **library:** rejected updates preserve the last valid chart/table; animated maps, tile maps and tables resume resizing after entrance completion. Sankey updates no longer replay entrances, and disabling tooltips no longer disables callbacks.
- **inspection:** layered charts retain each leaf's tooltip formatting, observation identity and crosshair evidence even when series names repeat or paint order changes. Reader pins reconcile filter visibility without requiring data replacement; unsupported layered pinning clears with an announcement.
- **keyboard:** maps, tile maps, bar lists and Sankey share keyboard inspection/activation; graph Tab no longer traps focus. Tables preserve logical focus, cancel pending search on Escape, distinguish typed/ambiguous row keys and preserve native mobile disclosure controls.
- **data:** missing, blank, boolean and nonfinite measurements no longer fabricate zero-valued marks, coordinates or differences. Sankey rejects undrawable flows with diagnostics. Line/area gaps remain intact during interrupted animation.
- **export:** specialized SVG/raster exports render settled geometry without transient hover effects; maps preserve the current camera and clipping. Font embedding respects inline typography and inherited span fonts. Table CSV includes every filtered/sorted row across pagination.
- **typography:** bar lists and Sankey use shared chrome wrapping and measured font styles, keeping headings inside narrow containers.

### Changed

- **graph!:** `highlight()` now layers over the sticky category filter instead of replacing it. In v8.0 the two shared one slot and the last writer won, so hovering a custom legend row threw away the filter the reader had just clicked. The effective emphasis is now the intersection of filter and highlight, except that a highlight disjoint from the filter previews on its own and a highlight matching no nodes is a no-op layer. Two consequences: `clearHighlight()` releases only the transient highlight and returns to the filtered view (clearing the filter is `setActiveCategories([])`), and `onHighlightChange` reports the effective set, so it can fire with an array where it previously fired `null`. Legend `active` flags now track the filter alone. See [migration guide](docs/migrating-v8.md#24-graph-highlight-layers-over-the-category-filter-81).
Expand Down
17 changes: 11 additions & 6 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,11 +196,17 @@ Visual features are computed per-column based on the column config: heatmap buil

## Rendering strategy

The vanilla adapter renders charts by building a fresh SVG element from the `ChartLayout`. It doesn't diff or patch. On every update or resize, it tears down the old SVG and creates a new one.
The vanilla adapter compiles a candidate layout before committing an update. Rejected chart, map, tile map, bar list, Sankey and table specifications leave the last valid view and its interactions usable. Cartesian charts reconcile compatible SVG scenes by stable mark keys; incompatible scenes and specialized renderers rebuild from their compiled layouts. This keeps the engine independent of DOM lifetime.

Line and area geometry can morph when point identity and path topology permit it. Paths with missing-observation breaks use an opacity crossfade of the canonical paths, including interrupted updates, so motion never connects absent evidence. Entrance completion releases the resize gate and replays any deferred resize.

Scatter charts past a point-count threshold (or mounted with the `renderer: 'canvas'` option) render their dots on a `<canvas>` instead (see [canvas mark mode](spec-reference.md#canvas-mark-mode)). The canvas is a sibling inserted *before* the SVG and painting the background, gridlines and marks; the SVG keeps axes, trendline, annotations and chrome. The engine only records the decision as `ChartLayout.markRenderMode` -- the renderer keys strictly on its own `canvasMarks` option, so any caller that omits it (SSR, exports) gets a complete SVG regardless of what the layout says.

For tables, it creates HTML elements (table, thead, tbody, etc.) with interactivity wired up: sort headers, search input, pagination controls, keyboard navigation.
For tables, it creates HTML elements (table, thead, tbody, etc.) with interactivity wired up: sort headers, search input, pagination controls, keyboard navigation. Logical row/column focus survives rebuilds; typed row keys prevent collisions between `1` and `"1"`. Duplicate or missing authored keys receive revision-local identifiers and a warning. DOM row IDs are opaque implementation details.

Maps, tile maps, bar lists and Sankey diagrams share mark keyboard navigation. Arrows inspect marks, Enter/Space invoke existing activation callbacks, Escape dismisses inspection and Tab follows the page's native focus order. Focus restores by mark identity after a rebuild, with a chart-root fallback for removed or ambiguous marks. Tooltip visibility is independent from callback wiring. Tables leave nested links and disclosure controls in charge of their own keys.

Static chart exports render the terminal layout rather than serializing hover or in-flight geometry. Map exports retain the current camera, authored focus and zoom viewport clipping. Font embedding resolves inline styles before presentation attributes and inherits font declarations for text spans, including detached export scenes. Table CSV exports use all filtered/sorted rows, independently of the displayed page.

Both adapters use `ResizeObserver` to detect container size changes and trigger recompilation at the new dimensions. The responsive system uses breakpoints to adjust layout strategy (label density, legend position, annotation placement).

Expand All @@ -218,9 +224,8 @@ sequenceDiagram
React->>Vanilla: chart.update(spec)
Vanilla->>Engine: compileChart(spec, options)
Engine-->>Vanilla: ChartLayout
Vanilla->>DOM: Remove old SVG
Vanilla->>DOM: Create new SVG from layout
Vanilla->>DOM: Wire tooltips + keyboard nav
Vanilla->>DOM: Reconcile compatible scene or replace specialized view
Vanilla->>DOM: Restore valid focus and wire interactions
```

## Theme resolution
Expand All @@ -231,7 +236,7 @@ Themes flow through three stages:
2. **Deep merge** (`resolveTheme()`): Merge user config onto `DEFAULT_THEME`. Produces a `ResolvedTheme` with every field set.
3. **Dark mode adaptation** (`adaptTheme()`): If dark mode is active, transform the resolved theme. Swap background/text, adjust palette brightness, lighten gridlines.

The default theme uses Inter as the primary font, editorial typography hierarchy (22px title, 15px subtitle, 13px body, 11px axis ticks), and a categorical palette tuned for distinguishability.
The default theme uses Inter as the primary font and a categorical palette tuned for distinguishability. Theme tokens remain the source of truth for typography; the opt-in `clarity` preset provides the newer editorial hierarchy without changing v8 defaults.

Themes can be applied per-component (via prop) or globally (via `VizThemeProvider` in React, or the `theme` option in vanilla).

Expand Down
4 changes: 4 additions & 0 deletions docs/codebase-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@
| SVG DOM helpers (`createSVGElement`, `setAttrs`, `applyTextStyle`) | `packages/vanilla/src/renderers/svg-dom.ts` (note: `computeXAxisExtent` was removed; use `layout.chrome.bottomAnchorY` instead) |
| Gradient utilities (`LinearGradient` resolution) | `packages/vanilla/src/gradient-utils.ts` |
| Resize observer wiring | `packages/vanilla/src/resize-observer.ts` |
| Specialized mark keyboard inspection and focus restoration | `packages/vanilla/src/interactions/mark-navigation.ts` → `wireMarkNavigation`, `captureMarkFocus`, `restoreMarkFocus`; used by map/tilemap/barlist/Sankey mounts. |
| Numeric observation validity | `packages/engine/src/data/finite-number.ts` → `finiteNumber`; shared by measured mark channels, scales, geographic coordinates, aggregates, bins, density and window comparisons. |
| Library lifecycle/interaction QA | `packages/vanilla/src/__tests__/lifecycle-contracts.test.ts`; manual Ladle fixture `examples/src/testing/library-contracts.stories.tsx`; browser checks `e2e/invariants/library-interactions.spec.ts`. |
| Zoom viewport clipping for live maps and exports | `packages/vanilla/src/map-camera.ts` → `ensureMapClip`; reused by `map-zoom.ts` and terminal map export. |
| Animation lifecycle / cleanup | `packages/vanilla/src/animation.ts`. `computeAnimationDuration(svg)` is the shared total-entrance-time calc (used by cleanup timing and GIF capture). |
| Data-update transition driver (rAF-based mark/axis tweening) | `packages/vanilla/src/transition/` (driver.ts, gate.ts, svg-tweens.ts, canvas-tweens.ts, chrome-tweens.ts, ...; `transition.ts` is the re-export barrel) |
| Export (SVG/PNG/JPG/CSV) | `packages/vanilla/src/export.ts` → `exportSVG`, `exportPNG`, `exportJPG`, `exportCSV`, plus `rasterizeSVGToCanvas`/`getSVGDimensions`/`embedFonts` helpers reused by GIF export. Wired into `chart.export(format)` in `mount.ts`. |
Expand Down
10 changes: 9 additions & 1 deletion docs/spec-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -1892,7 +1892,15 @@ The reference [Studio workbench](workbench.md) consumes these additive interface
- **Motion:** `animation: 'fluid' | 'subtle' | 'legacy'`; explicit phases override their preset. A phase can use `spring: { stiffness: 320, damping: 36, mass: 1 }` or the existing duration/easing configuration. Combining a spring with duration/easing in the same phase is an error. Quantitative springs are bounded, with a finite terminal state. Omitted/false animation remains disabled.
- **Responsiveness:** `responsive: { preserveEnd: true, labelSummary: true }` retains the final x-axis boundary and moves overflowing endpoint identities to a wrapped list. Explicit axis values and author choices remain authoritative. `chromeLayout: 'grow'` gives that content space. Tables may opt into `mobileLayout: 'priority'`; `cards` remains the default, with `scroll` also available.
- **Field metadata:** chart, table and map documents accept `fields`, keyed by field name. Each entry may declare `type` (`text`, `number`, `date`), `role: 'identity'`, `label`, `unit`, `measure`, `provenance`, `representation` (`number`, `percent`, `proportion`), `interval: { lower: 'lowerField', upper: 'upperField' }`, or `statusField`. Supplied intervals are never synthesized. Percent means 0–100 and proportion means 0–1.
- **Reader identity:** a unique authored `encoding.key` supports observation persistence. Missing/duplicate keys clear observation pins on data replacement. Aggregated observations are groups, not raw rows, and the reference reader clears them on data/grouping revision. The `createReader` controller is independent from editor selection and uses `ChartInstance.sampleLayout()` for supported moving geometry.
- **Reader identity:** a unique authored `encoding.key` supports observation persistence. Missing/duplicate keys clear observation pins on data replacement. Aggregated observations are groups, not raw rows, and the reference reader clears them on data/grouping revision. The `createReader` controller is independent from editor selection and uses `ChartInstance.sampleLayout()` for supported moving geometry. Call `reader.update(spec)` after `chart.update(spec)`; it reconciles visibility against the compiled destination marks even when the source data array is unchanged. An explicit `visibleKeys` override remains supported. Layered charts retain per-layer hover/crosshair inspection, but reader pinning across layers is not yet supported: a `LayerSpec` clears pins with an announcement rather than guessing cross-layer identity.
- **Design review:** `diagnoseChart(spec, layout)` returns structured checks with code, severity, checkability, path, viewport, explanation and origin. `CompileOptions.onDiagnostic` receives these checks. `recommendCompositions(data, question, roles)` requires explicit field roles and returns explained alternatives. `applySpecPatch` rejects stale snapshot preconditions; hosts should preview patches and retain undo. The reference editor uses the full authored document for patches even while previewing a filtered scene.

Host-neutral identity, comparison and explicit dataset-binding helpers are also exported from the engine. They provide foundations for future linked views, without implicitly declaring compatible measures or raw-row/aggregate relationships. See the [delivery record](design-evolution.md) for the advanced capabilities and release gates still pending.

### Numeric observations and interaction contracts

Measured channels accept finite numbers and nonblank numeric strings, including zero. Null, blank text, booleans, `NaN` and infinities are absent measurements, not zeroes. This applies to chart quantities, map coordinates/colors, size/domain extraction, aggregates, bins, density and window differences/percentage changes. Supplied missing line/area observations remain gaps during rendering, updates and exports. Sankey requires positive finite flow values; omitted flows produce a warning and an all-invalid dataset produces an empty finite layout. Existing cumulative-sum, text-size fallback and absent-series stack conventions are unchanged.

Maps, tile maps, bar lists and Sankey support arrows for inspection and Enter/Space for their existing click callbacks, including when `tooltip: false`. Escape dismisses inspection; Tab exits naturally. A keyboard-focused map mark uses arrows for mark navigation; the map root retains its separate zoom/pan shortcuts. Tables restore keyed cell/header focus across updates and keep native mobile detail disclosures operable.

Table `rowKey` values must be unique to support identity across revisions. Numeric `1` and string `"1"` are distinct. Missing/duplicate keys emit `TABLE_ROW_KEY_AMBIGUOUS` and use revision-local identities. Treat compiled row IDs as opaque. CSV export includes all filtered and sorted rows, not just the current pagination page.
113 changes: 113 additions & 0 deletions e2e/invariants/library-interactions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { expect, test } from '@playwright/test';

const fixture = '/?story=testing--library-contracts--lifecycle&mode=preview';
const renderers = [
['Bar list', '.oc-barlist-row'], ['Map', '.oc-map-feature'], ['Tile map', '.oc-tilemap-tile'], ['Sankey', '.oc-sankey-node'],
] as const;

for (const [name, selector] of renderers) {
test(`${name}: keyboard evidence, native Tab exit, resize, rejected edit and settled export`, async ({ page }) => {
const errors: string[] = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.goto(fixture);
await page.getByRole('combobox', { name: 'Renderer', exact: true }).selectOption(name);
const host = page.getByTestId('library-host');
const first = host.locator(selector).first();
await expect(first).toBeVisible();
await expect(host.locator('svg').first()).not.toHaveClass(/oc-animate/);
await first.focus();
await page.keyboard.press('ArrowRight');
const focused = host.locator('[data-navigation-key]:focus');
await expect(focused).toHaveCount(1);
const key = await focused.getAttribute('data-navigation-key');
await page.keyboard.press('Enter');
await expect(page.getByTestId('selected-evidence')).not.toHaveText('No selection');
if (name !== 'Map' || await focused.getAttribute('data-nodata') !== 'true') {
await expect(host.locator('.oc-tooltip')).toBeVisible();
}
await page.keyboard.press('Escape');
await expect(host.locator('.oc-tooltip')).toBeHidden();
await page.keyboard.press('Tab');
if (name === 'Map') {
// Zoom controls are legitimate extra stops, but map marks must not trap Tab.
await expect(focused).toHaveCount(0);
} else {
await expect(focused).toHaveCount(0);
// Attribution links remain ordinary page navigation stops.
if (await host.locator('a:focus').count()) await page.keyboard.press('Tab');
await expect(host.locator('[data-navigation-key]:focus')).toHaveCount(0);
}
await first.focus();
await page.keyboard.press('ArrowRight');
await page.setViewportSize({ width: 390, height: 844 });
await expect.poll(() => host.locator('[data-navigation-key]:focus').getAttribute('data-navigation-key')).toBe(key);
await expect.poll(async () => Number((await host.locator('svg').first().getAttribute('viewBox'))!.split(' ')[2])).toBeLessThan(391);
await page.getByRole('button', { name: 'Reject invalid update', exact: true }).click();
await expect(page.getByRole('status')).toHaveText('Invalid data rejected; previous view preserved');
await expect(first).toBeVisible();
await page.getByRole('button', { name: 'Update', exact: true }).click();
await expect(host).toContainText(`${name}: revision 1`);
const textFits = await host.evaluate((element) => {
const bounds = element.getBoundingClientRect();
return [...element.querySelectorAll('.oc-title,.oc-subtitle')].every((text) => {
const box = text.getBoundingClientRect();
return box.left >= bounds.left - 1 && box.right <= bounds.right + 1;
});
});
expect(textFits).toBe(true);
await expect(host.locator('svg').first()).not.toHaveClass(/oc-animate/);
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Export', exact: true }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/\.svg$/);
expect(await download.failure()).toBeNull();
expect(errors).toEqual([]);
});
}

test('table: Escape wins over pending search, sort retains focus and priority cells stay navigable', async ({ page }) => {
await page.goto(fixture);
await page.getByRole('combobox', { name: 'Renderer', exact: true }).selectOption('Table');
const host = page.getByTestId('library-host');
const search = host.locator('input');
await search.fill('California');
await search.press('Escape');
await expect(search).toHaveValue('');
await expect(host.locator('tbody')).toBeFocused();
// Wait past the debounce and prove the stale query never returns.
await expect(host.locator('tbody tr')).toHaveCount(10);
await page.waitForTimeout(250);
await expect(host.locator('tbody tr')).toHaveCount(10);
const header = host.locator('th[data-column="state"]');
await header.focus();
await page.keyboard.press('Enter');
await expect(header).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(host.locator('.oc-table-cell-focus')).toHaveText('California');
await page.keyboard.press('Enter');
await expect(page.getByTestId('selected-evidence')).toContainText('California');
await page.setViewportSize({ width: 390, height: 844 });
await expect(host.locator('.oc-table--priority')).toBeVisible();
await expect(host.locator('tbody')).toBeFocused();
await page.keyboard.press('End');
await expect(host.locator('.oc-table-cell-focus')).toBeVisible();
await expect(host.locator('.oc-table-cell-focus')).toContainText('More');
const summary = host.locator('summary').first();
await summary.focus();
await page.keyboard.press('Enter');
await expect(host.locator('details').first()).toHaveAttribute('open', '');
await page.keyboard.press('Space');
await expect(host.locator('details').first()).not.toHaveAttribute('open');
});

test('graph: the canvas releases both Tab directions', async ({ page }) => {
await page.goto('/?story=graphs--graphs&mode=preview');
const canvas = page.locator('.oc-graph-canvas').first();
await expect(canvas).toBeVisible();
await canvas.focus();
await page.keyboard.press('Tab');
await expect(canvas).not.toBeFocused();
await canvas.focus();
await page.keyboard.press('Shift+Tab');
await expect(canvas).not.toBeFocused();
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading