diff --git a/.changeset/accessor-plain-key-fast-path.md b/.changeset/accessor-plain-key-fast-path.md new file mode 100644 index 000000000..79e84065e --- /dev/null +++ b/.changeset/accessor-plain-key-fast-path.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +perf(Chart): Resolve a plain-key accessor (`x="date"`) directly instead of re-parsing the path string on every row diff --git a/.changeset/chart-empty-explicit-series.md b/.changeset/chart-empty-explicit-series.md new file mode 100644 index 000000000..3d4868ebf --- /dev/null +++ b/.changeset/chart-empty-explicit-series.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +fix(Chart): Treat an explicit but empty `series` array as authoritative, rather than deriving implicit series from the marks it just rendered (the two never settled, locking the page) diff --git a/.changeset/chart-mount-domain-registry.md b/.changeset/chart-mount-domain-registry.md new file mode 100644 index 000000000..aa03e607a --- /dev/null +++ b/.changeset/chart-mount-domain-registry.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +perf(Chart): Keep the value domain off the mark registry, so mounting a chart no longer rebuilds its scales — and every path drawn from them — once per mark registered diff --git a/.changeset/highlight-nested-data-c-points.md b/.changeset/highlight-nested-data-c-points.md new file mode 100644 index 000000000..21e636b0f --- /dev/null +++ b/.changeset/highlight-nested-data-c-points.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +fix(Highlight): Point `c`-grouped rows from `flatData`, fixing a crash on nested `stack()` data and `NaN` points where a category's value is a span (`y={[0, 1]}`) diff --git a/.changeset/highlight-single-point-mode.md b/.changeset/highlight-single-point-mode.md new file mode 100644 index 000000000..4c39070b5 --- /dev/null +++ b/.changeset/highlight-single-point-mode.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +fix(Highlight): Mark only the hovered row in `quadtree` / `voronoi` tooltips, rather than every row sharing its position diff --git a/.changeset/implied-color-channel.md b/.changeset/implied-color-channel.md new file mode 100644 index 000000000..c10556fd6 --- /dev/null +++ b/.changeset/implied-color-channel.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +feat(Chart): Take the colour channel from a mark's own `fill` / `stroke` (``) when no `c` is declared, so the colour domain and legend hold the categories rather than the rows — hiding one still needs `c` diff --git a/.changeset/tooltip-interval-data-position.md b/.changeset/tooltip-interval-data-position.md new file mode 100644 index 000000000..86c222b6b --- /dev/null +++ b/.changeset/tooltip-interval-data-position.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +fix(Tooltip): Center `x="data"` / `y="data"` on the interval when `xInterval` / `yInterval` is set, instead of on its leading edge diff --git a/.changeset/tooltip-pointer-events-hide.md b/.changeset/tooltip-pointer-events-hide.md new file mode 100644 index 000000000..379a7d579 --- /dev/null +++ b/.changeset/tooltip-pointer-events-hide.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +fix(Tooltip): Hide after the pointer leaves the tooltip when `pointerEvents` is enabled (hovering it suppressed the pending hide, and leaving never re-armed it) diff --git a/.github/workflows/update-visual-baseline.yml b/.github/workflows/update-visual-baseline.yml index 5ff8dcdc1..11a6e9d0d 100644 --- a/.github/workflows/update-visual-baseline.yml +++ b/.github/workflows/update-visual-baseline.yml @@ -45,15 +45,27 @@ jobs: - name: Build packages run: pnpm build:packages + # `stackblitz-files.json` is generated and gitignored, so a fresh checkout has none — and + # every example route imports it transitively, through `Example`'s "open in StackBlitz". + # `prebuild` covers this for a real build; a bare `vite dev` doesn't. + - name: Generate the docs server's inputs + run: pnpm --filter docs generate:stackblitz + - name: Start the docs server run: | pnpm --filter docs exec vite dev --port 3002 > /tmp/server.log 2>&1 & for i in $(seq 1 60); do - curl -sf -o /dev/null http://localhost:3002/ && exit 0 + curl -sf -o /dev/null "http://localhost:3002/docs/screenshot/AnnotationLine/horizontal" && exit 0 sleep 2 done echo "docs server did not start"; cat /tmp/server.log; exit 1 + # A full capture takes ~40 minutes, so prove the app renders at all before spending them. + # `capture` exits non-zero once more than a few examples draw nothing. + - name: Smoke test the docs server + working-directory: docs + run: node scripts/visual-snapshot.mjs capture /tmp/smoke.json --filter AnnotationLine --repeat 1 + # Two passes so examples that are still nondeterministic are recorded as such and skipped # by later comparisons rather than reported as regressions - name: Capture baseline diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index a862cfbbb..dfa0ac4e6 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -52,16 +52,30 @@ jobs: echo "No baseline on ${{ github.base_ref }} — run the Update Visual Baseline workflow." >> "$GITHUB_STEP_SUMMARY" fi + # `stackblitz-files.json` is generated and gitignored, so a fresh checkout has none — and + # every example route imports it transitively, through `Example`'s "open in StackBlitz". + # `prebuild` covers this for a real build; a bare `vite dev` doesn't. + - name: Generate the docs server's inputs + if: steps.baseline.outputs.found == 'true' + run: pnpm --filter docs generate:stackblitz + - name: Start the docs server if: steps.baseline.outputs.found == 'true' run: | pnpm --filter docs exec vite dev --port 3002 > /tmp/visual/server.log 2>&1 & for i in $(seq 1 60); do - curl -sf -o /dev/null http://localhost:3002/ && exit 0 + curl -sf -o /dev/null "http://localhost:3002/docs/screenshot/AnnotationLine/horizontal" && exit 0 sleep 2 done echo "docs server did not start"; cat /tmp/visual/server.log; exit 1 + # Fails in seconds if the app is not rendering, so a broken docs server reads as a broken + # job rather than as 978 examples that "have no baseline" and silently pass + - name: Smoke test the docs server + if: steps.baseline.outputs.found == 'true' + working-directory: docs + run: node scripts/visual-snapshot.mjs capture /tmp/visual/smoke.json --filter AnnotationLine --repeat 1 + - name: Capture this branch if: steps.baseline.outputs.found == 'true' working-directory: docs diff --git a/docs/scripts/visual-snapshot.mjs b/docs/scripts/visual-snapshot.mjs index 4ce200e21..156dcfd23 100644 --- a/docs/scripts/visual-snapshot.mjs +++ b/docs/scripts/visual-snapshot.mjs @@ -23,6 +23,9 @@ import { dirname, join } from 'node:path'; const CATALOG_DIR = 'src/examples/catalog'; const DEFAULT_URL = 'http://localhost:3002'; +// Above this share of examples failing, the run says more about the environment than the charts +const MAX_FAILURE_RATE = 0.05; + // Fixed so a chart built from `new Date()` lands on the same dates every run const FROZEN_NOW = Date.UTC(2026, 0, 15, 12, 0, 0); @@ -163,8 +166,28 @@ function listExamples(filter) { async function captureOne(page, baseUrl, item, attempt = 0) { const { component, example } = item; const url = `${baseUrl}/docs/screenshot/${component}/${example}`; + // A dev server 500, a module that failed to load and a chart that genuinely draws nothing are + // indistinguishable from the outside — all three end up as an empty page. Record what the + // browser reported so a failure on a runner can be diagnosed from the job log alone. + const problems = []; + const onConsole = (msg) => { + if (msg.type() === 'error') + problems.push(`console: ${msg.text().split('\n')[0].slice(0, 200)}`); + }; + const onPageError = (err) => { + problems.push( + `pageerror: ${String(err?.message ?? err) + .split('\n')[0] + .slice(0, 200)}` + ); + }; + page.on('console', onConsole); + page.on('pageerror', onPageError); + const why = (reason) => (problems.length ? `${reason} — ${problems[0]}` : reason); try { - await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 }); + const response = await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 }); + const status = response?.status() ?? 0; + if (status >= 400) problems.unshift(`http ${status}`); // Marks tween in; wait for the drawing to stop changing rather than guessing a delay let previous = null; for (let i = 0; i < (attempt ? 40 : 12); i++) { @@ -180,7 +203,7 @@ async function captureOne(page, baseUrl, item, attempt = 0) { } // An example fetching its data can outlast the settle window; give it one slower go if (attempt === 0) return captureOne(page, baseUrl, item, 1); - return { error: 'nothing drawn' }; + return { error: why('nothing drawn') }; } catch (e) { // A dev server under load aborts navigations — that says nothing about the chart if (attempt < 2) { @@ -188,10 +211,15 @@ async function captureOne(page, baseUrl, item, attempt = 0) { return captureOne(page, baseUrl, item, attempt + 1); } return { - error: String(e.message ?? e) - .split('\n')[0] - .slice(0, 120) + error: why( + String(e.message ?? e) + .split('\n')[0] + .slice(0, 120) + ) }; + } finally { + page.off('console', onConsole); + page.off('pageerror', onPageError); } } @@ -224,7 +252,11 @@ async function capture(outPath, { url, filter, concurrency, repeat, seed }) { const existing = results.get(key); if (pass === 0) { results.set(key, result); - } else if (existing && existing.hash !== result.hash) { + } else if (existing?.error && !result.error) { + // Rendering on a later pass means the first pass hit a flake, not a broken + // example — keep the drawing rather than recording the failure + results.set(key, result); + } else if (existing && !existing.error && existing.hash !== result.hash) { results.set(key, { ...existing, unstable: true }); } if (++done % 100 === 0) console.log(` pass ${pass + 1}: ${done}/${examples.length}`); @@ -238,13 +270,32 @@ async function capture(outPath, { url, filter, concurrency, repeat, seed }) { const entries = Object.fromEntries([...results].sort(([a], [b]) => a.localeCompare(b))); const unstable = Object.values(entries).filter((r) => r.unstable).length; const failed = Object.values(entries).filter((r) => r.error).length; + const seconds = Math.round((Date.now() - started) / 1000); + if (failed) { + console.log(`\n${failed} of ${Object.keys(entries).length} examples failed to render`); + for (const [key, r] of Object.entries(entries) + .filter(([, r]) => r.error) + .slice(0, 10)) { + console.log(` ${key} — ${r.error}`); + } + } + + // A run where most examples drew nothing says the docs server was broken, not that the charts + // changed. Recording it would be worse than recording nothing: `compare` has no baseline to + // diff an errored entry against, so every one of them would turn into a silent pass from then + // on. Bail before writing so a run this broken cannot be picked up as a baseline. + if (failed > Object.keys(entries).length * MAX_FAILURE_RATE) { + throw new Error( + `${failed}/${Object.keys(entries).length} examples failed to render — refusing to write a snapshot this broken. ` + + `Check that the docs server is serving /docs/screenshot//.` + ); + } + mkdirSync(dirname(outPath), { recursive: true }); writeFileSync(outPath, JSON.stringify({ seed, examples: entries }, null, 2) + '\n'); - const seconds = Math.round((Date.now() - started) / 1000); console.log(`\nwrote ${outPath} — ${Object.keys(entries).length} examples in ${seconds}s`); if (unstable) console.log(` ${unstable} unstable (excluded from comparison)`); - if (failed) console.log(` ${failed} failed to render`); } /** Screenshot one example from a running server. */ @@ -364,7 +415,18 @@ function compare(baselinePath, currentPath) { console.log( `\n${changed.length} changed, ${broke.length} broken, ${added.length} added, ${removed.length} removed, ${incomparable.length} without a baseline, ${skipped.length} skipped as unstable` ); - return { keys: [...changed, ...broke].map((line) => line.split(/\s+/)[0]), differences: broke.length + changed.length }; // prettier-ignore + + // Entries the baseline failed to capture are unreviewable, not unchanged. A handful is the + // cost of a flaky example; a large share means the baseline itself needs re-recording, and + // staying quiet about it would report a check that never actually ran as a pass. + const unusable = incomparable.length > Object.keys(baseline).length * MAX_FAILURE_RATE; + if (unusable) { + console.log( + `\n${incomparable.length} of ${Object.keys(baseline).length} baseline entries are errors — the baseline is unusable. ` + + `Re-record it with the Update Visual Baseline workflow.` + ); + } + return { keys: [...changed, ...broke].map((line) => line.split(/\s+/)[0]), differences: broke.length + changed.length + (unusable ? 1 : 0) }; // prettier-ignore } /** Re-render one example against two servers and print the drawing attributes that differ. */ diff --git a/docs/src/content/guides/facets.md b/docs/src/content/guides/facets.md index b8d4f466d..ff20efe00 100644 --- a/docs/src/content/guides/facets.md +++ b/docs/src/content/guides/facets.md @@ -264,6 +264,64 @@ To build the rows yourself — a single tooltip listing every panel, say — `pa A faceted chart resolves tooltips against the rows the panels were partitioned from — the chart's `data`. Marks given their own `data` aren't part of that partition, so their points are not found by the pointer. :: +## Clicks + +The panel is a property of the row, so a click that resolves to one already carries it. Read it back with the chart's own `fx` / `fy` accessors rather than the key you passed, and it keeps working when `fx` is a function: + +```svelte + { + const party = context.facet.x?.(data); + }} +/> +``` + +:example{ component="Chart" name="facet-click" } + +`onTooltipClick` is what a chart with a tooltip wants. The modes that draw hit targets — `bounds`, `band`, `facet` and `voronoi` — put them above the marks, so a mark's own `onclick` (and `Bars`' `onBarClick`) never sees the click. A hand-composed chart passes the same handler as `tooltipContext={{ onclick }}`. + +The `bisect-*` and `quadtree*` modes draw nothing over the marks, so there the mark keeps its click — and the panel comes from the snippet it's drawn in: + +```svelte +{#snippet marks({ facet })} + select(facet.fx)} /> +{/snippet} +``` + +### Clicking the panel itself + +A click on something that _isn't_ a row — the panel's background, an annotation, a panel with nothing in it — has no row to read from, so take the panel from the snippet. A `Frame` fills the panel it's rendered in, which makes it the target: + +```svelte +{#snippet belowMarks({ facet })} + (selected = facet.fx)} + /> +{/snippet} +``` + +:example{ component="Chart" name="facet-click-panel" } + +Below the marks rather than above, so the rows stay clickable in their own right. A crossed grid keys on the pair — `[facet.fx, facet.fy]` — and `facet.empty` says whether the panel holds anything. + +### From a pixel + +Where the click is neither a row nor inside a panel's snippet — a handler on the container, a drop target — `panelAt` resolves plot-area coordinates to the panel covering them, and gives `undefined` in the gap between panels: + +```svelte +onclick={(e) => { + const rect = e.currentTarget.getBoundingClientRect(); + const panel = context.facet.panelAt( + e.clientX - rect.left - context.padding.left, + e.clientY - rect.top - context.padding.top + ); +}} +``` + ## Brushing A brush gesture belongs to the panel it starts in, so the range it produces is read from that panel's coordinates. What it produces is a range of the _shared_ scales, though — so the selection applies to every panel, and is drawn in each of them. @@ -379,6 +437,7 @@ The layout itself lives on the chart state as `context.facet`: | `context.facet.panels` | Every panel, with offsets, rows, and edge flags | | `context.facet.xScale` / `yScale` | Band scales laying the panels out | | `context.facet.width` / `height` | One panel's dimensions | +| `context.facet.panelAt(x, y)` | The panel covering a plot-area point | `context.width` / `height` are that same panel box — `context.box` stays the whole plot area. diff --git a/docs/src/content/guides/styles.md b/docs/src/content/guides/styles.md index 747cd64b0..c08552511 100644 --- a/docs/src/content/guides/styles.md +++ b/docs/src/content/guides/styles.md @@ -319,7 +319,7 @@ Many components support data-driven colors via the `c` (color) prop on `Chart`, :::tabs{key="color-enhancements"} ::tab{label="Linear gradient"} - :example{ component="AreaChart" name="gradient" noResize showCode highlight="11-15" } + :example{ component="AreaChart" name="gradient" noResize showCode highlight="10-14" } :: ::tab{label="Radial gradient"} @@ -341,7 +341,7 @@ Many components support data-driven colors via the `c` (color) prop on `Chart`, :: ::tab{label="Pattern"} - :example{ component="AnnotationRange" name="vertical-with-pattern-range" noResize showCode highlight="14-20" } + :example{ component="AnnotationRange" name="vertical-with-pattern-range" noResize showCode highlight="20-25" } :: ::: diff --git a/docs/src/examples/catalog/BarChart.json b/docs/src/examples/catalog/BarChart.json index ed287437e..d9691e492 100644 --- a/docs/src/examples/catalog/BarChart.json +++ b/docs/src/examples/catalog/BarChart.json @@ -2452,6 +2452,13 @@ "lineNumber": 84, "line": "" } ], - "updatedAt": "2026-08-20T12:48:38.692Z" + "updatedAt": "2026-08-23T18:52:25.346Z" } \ No newline at end of file diff --git a/docs/src/examples/catalog/Frame.json b/docs/src/examples/catalog/Frame.json index 5c2b6b94e..e93faef78 100644 --- a/docs/src/examples/catalog/Frame.json +++ b/docs/src/examples/catalog/Frame.json @@ -123,6 +123,13 @@ "lineNumber": 39, "line": "" }, + { + "example": "facet-click-panel", + "component": "Chart", + "path": "/docs/components/Chart/facet-click-panel", + "lineNumber": 36, + "line": "" } ], - "updatedAt": "2026-08-19T14:28:18.858Z" + "updatedAt": "2026-08-23T18:52:25.602Z" } \ No newline at end of file diff --git a/docs/src/examples/components/BrushContext/synced-brushes.svelte b/docs/src/examples/components/BrushContext/synced-brushes.svelte index ceb2e2fec..347dcc125 100644 --- a/docs/src/examples/components/BrushContext/synced-brushes.svelte +++ b/docs/src/examples/components/BrushContext/synced-brushes.svelte @@ -66,10 +66,7 @@ - + {#snippet children({ gradient })} {/snippet} diff --git a/docs/src/examples/components/Chart/facet-click-panel.svelte b/docs/src/examples/components/Chart/facet-click-panel.svelte new file mode 100644 index 000000000..4fccb97d9 --- /dev/null +++ b/docs/src/examples/components/Chart/facet-click-panel.svelte @@ -0,0 +1,53 @@ + + + + +
+ {#if selected} + Selected {selected} — {data.filter((d) => d.species === selected).length} penguins + {:else} + Click a panel + {/if} +
+ + + + {#snippet belowMarks({ facet })} + (selected = selected === facet.fx ? null : facet.fx)} + /> + {/snippet} + + {#snippet marks({ facet })} + + {/snippet} + diff --git a/docs/src/examples/components/Chart/facet-click.svelte b/docs/src/examples/components/Chart/facet-click.svelte new file mode 100644 index 000000000..f77a308b8 --- /dev/null +++ b/docs/src/examples/components/Chart/facet-click.svelte @@ -0,0 +1,68 @@ + + +
+ {#if selected} + Clicked {selected.party} in {selected.row.year} — {(selected.row.percent * 100).toFixed( + 1 + )}% + {:else} + Click a bar + {/if} +
+ + { + // The panel is a property of the row, so the chart's own `fx` accessor reads it back out — + // which also works when `fx` is a function rather than a key + selected = { party: context.facet.x?.(data), row: data }; + }} + facet={{ + axis: { + placement: 'bottom', + tickLabelProps: { dy: 24, class: 'text-xs font-semibold fill-surface-content' } + } + }} + props={{ + xAxis: { format: 'none' }, + yAxis: { format: 'percentRound' }, + tooltip: { + header: { format: 'none' }, + item: { format: { type: 'percent', options: { fractionDigits: 1 } } }, + hideTotal: true + } + }} + padding={{ left: 44, bottom: 40, top: 8, right: 8 }} + height={300} +/> diff --git a/docs/src/examples/components/Chart/facet-tooltip.svelte b/docs/src/examples/components/Chart/facet-tooltip.svelte index 4a7f96d6b..164d20abb 100644 --- a/docs/src/examples/components/Chart/facet-tooltip.svelte +++ b/docs/src/examples/components/Chart/facet-tooltip.svelte @@ -23,7 +23,6 @@ height={300} > - diff --git a/docs/src/examples/components/ChartGroup/synced-brushes.svelte b/docs/src/examples/components/ChartGroup/synced-brushes.svelte index 41eb81d6a..3b517dfe1 100644 --- a/docs/src/examples/components/ChartGroup/synced-brushes.svelte +++ b/docs/src/examples/components/ChartGroup/synced-brushes.svelte @@ -61,10 +61,7 @@ - + {#snippet children({ gradient })} {/snippet} diff --git a/docs/src/lib/components/Example.svelte b/docs/src/lib/components/Example.svelte index e2bb2f2fe..aec89ab4e 100644 --- a/docs/src/lib/components/Example.svelte +++ b/docs/src/lib/components/Example.svelte @@ -262,7 +262,7 @@
{#each layers as layer} {toTitleCase(layer)} diff --git a/docs/static/screenshots/Chart/facet-click-dark-240w.webp b/docs/static/screenshots/Chart/facet-click-dark-240w.webp new file mode 100644 index 000000000..36b06770c Binary files /dev/null and b/docs/static/screenshots/Chart/facet-click-dark-240w.webp differ diff --git a/docs/static/screenshots/Chart/facet-click-dark-400w.webp b/docs/static/screenshots/Chart/facet-click-dark-400w.webp new file mode 100644 index 000000000..1dffc3461 Binary files /dev/null and b/docs/static/screenshots/Chart/facet-click-dark-400w.webp differ diff --git a/docs/static/screenshots/Chart/facet-click-dark-800w.webp b/docs/static/screenshots/Chart/facet-click-dark-800w.webp new file mode 100644 index 000000000..782d241c0 Binary files /dev/null and b/docs/static/screenshots/Chart/facet-click-dark-800w.webp differ diff --git a/docs/static/screenshots/Chart/facet-click-light-240w.webp b/docs/static/screenshots/Chart/facet-click-light-240w.webp new file mode 100644 index 000000000..1e22d75cf Binary files /dev/null and b/docs/static/screenshots/Chart/facet-click-light-240w.webp differ diff --git a/docs/static/screenshots/Chart/facet-click-light-400w.webp b/docs/static/screenshots/Chart/facet-click-light-400w.webp new file mode 100644 index 000000000..c1d1724cd Binary files /dev/null and b/docs/static/screenshots/Chart/facet-click-light-400w.webp differ diff --git a/docs/static/screenshots/Chart/facet-click-light-800w.webp b/docs/static/screenshots/Chart/facet-click-light-800w.webp new file mode 100644 index 000000000..18efd77d1 Binary files /dev/null and b/docs/static/screenshots/Chart/facet-click-light-800w.webp differ diff --git a/docs/static/screenshots/Chart/facet-click-panel-dark-240w.webp b/docs/static/screenshots/Chart/facet-click-panel-dark-240w.webp new file mode 100644 index 000000000..1e3c261f2 Binary files /dev/null and b/docs/static/screenshots/Chart/facet-click-panel-dark-240w.webp differ diff --git a/docs/static/screenshots/Chart/facet-click-panel-dark-400w.webp b/docs/static/screenshots/Chart/facet-click-panel-dark-400w.webp new file mode 100644 index 000000000..d1ad9f67f Binary files /dev/null and b/docs/static/screenshots/Chart/facet-click-panel-dark-400w.webp differ diff --git a/docs/static/screenshots/Chart/facet-click-panel-dark-800w.webp b/docs/static/screenshots/Chart/facet-click-panel-dark-800w.webp new file mode 100644 index 000000000..cc99d8ff6 Binary files /dev/null and b/docs/static/screenshots/Chart/facet-click-panel-dark-800w.webp differ diff --git a/docs/static/screenshots/Chart/facet-click-panel-light-240w.webp b/docs/static/screenshots/Chart/facet-click-panel-light-240w.webp new file mode 100644 index 000000000..c2e87da97 Binary files /dev/null and b/docs/static/screenshots/Chart/facet-click-panel-light-240w.webp differ diff --git a/docs/static/screenshots/Chart/facet-click-panel-light-400w.webp b/docs/static/screenshots/Chart/facet-click-panel-light-400w.webp new file mode 100644 index 000000000..b400a0a87 Binary files /dev/null and b/docs/static/screenshots/Chart/facet-click-panel-light-400w.webp differ diff --git a/docs/static/screenshots/Chart/facet-click-panel-light-800w.webp b/docs/static/screenshots/Chart/facet-click-panel-light-800w.webp new file mode 100644 index 000000000..0b3d4035b Binary files /dev/null and b/docs/static/screenshots/Chart/facet-click-panel-light-800w.webp differ diff --git a/docs/static/screenshots/index.json b/docs/static/screenshots/index.json index 050c2b527..e9a0166c0 100644 --- a/docs/static/screenshots/index.json +++ b/docs/static/screenshots/index.json @@ -1,7 +1,7 @@ { - "generatedAt": "2026-08-25T17:59:23.475Z", + "generatedAt": "2026-08-23T18:54:59.937Z", "totalComponents": 80, - "totalExamples": 980, + "totalExamples": 982, "components": [ { "component": "AnnotationLine", @@ -2156,6 +2156,16 @@ "path": "/example/Chart/facet-brush-zoom", "checksum": "2e16a5ad96c47b72e01f8f1ac61c53c6" }, + { + "name": "facet-click", + "path": "/example/Chart/facet-click", + "checksum": "ea6c3e1b40e02112839d1cb5212d308f" + }, + { + "name": "facet-click-panel", + "path": "/example/Chart/facet-click-panel", + "checksum": "f7cd534e38cef18bc42bddd749ee5584" + }, { "name": "facet-color", "path": "/example/Chart/facet-color", diff --git a/packages/layerchart/src/lib/components/Highlight/Highlight.shared.svelte.ts b/packages/layerchart/src/lib/components/Highlight/Highlight.shared.svelte.ts index 2b2efc72c..db2bccc1b 100644 --- a/packages/layerchart/src/lib/components/Highlight/Highlight.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/Highlight/Highlight.shared.svelte.ts @@ -9,7 +9,7 @@ import type Line from '../Line/Line.svelte'; import type Rect from '../Rect/Rect.svelte'; import { accessor, chartDataArray, isEqualValue, type Accessor } from '$lib/utils/common.js'; import { isScaleBand, isScaleTime } from '$lib/utils/scales.svelte.js'; -import { panelDatum } from '$lib/utils/tooltip.js'; +import { isSinglePointMode, panelDatum } from '$lib/utils/tooltip.js'; import { getChartContext } from '$lib/contexts/chart.js'; import { getFacetPanel } from '$lib/contexts/facet.js'; import type { ChartState } from '$lib/states/chart.svelte.js'; @@ -360,7 +360,7 @@ export class HighlightState { * the rule the `facetAll` copies and the series-less path already follow. */ #pointFill(seriesInfo: { color?: string | null }) { - if (this.ctx.config.c && this.ctx.series.isDefaultSeries) { + if (this.ctx.cChannel && this.ctx.series.isDefaultSeries) { return (this.ctx.cGet(this.highlightData) as string) ?? seriesInfo.color ?? ''; } return seriesInfo.color ?? ''; @@ -385,7 +385,7 @@ export class HighlightState { return { x: (this.ctx.xScale(x) as number) + this.xOffset, y: (this.ctx.yScale(y) as number) + this.yOffset, - fill: (this.ctx.config.c ? this.ctx.cGet(match) : null) as string, + fill: (this.ctx.cChannel ? this.ctx.cGet(match) : null) as string, data: { x, y }, seriesKey: undefined, } satisfies HighlightPoint; @@ -401,33 +401,52 @@ export class HighlightState { // series list names only the row the pointer resolved to — one point, on whichever category // happened to be last. Point every category at this position instead, as the tooltip lists // them. - if (props.data === undefined && this.ctx.cGroups && this.ctx.series.isDefaultSeries) { + // + // Only where the pointer resolved to a *position*, though. `quadtree` and `voronoi` resolve + // to one row by proximity in both axes — a scatter, where the rows sharing an x are unrelated + // points rather than a category's line, and pointing all of them marks places nothing was + // hovered. + if ( + props.data === undefined && + this.ctx.cGroups && + this.ctx.series.isDefaultSeries && + !isSinglePointMode(this.ctx.tooltip.mode) + ) { const value = this.ctx.valueAxis === 'y' ? this.ctx.y : this.ctx.x; // Within a facet, only that panel's rows — the same category appears in every panel, and - // pointing all of them would put the other panels' values in this one - const source = this.#facetPanel?.().data ?? chartDataArray(this.ctx.data); + // pointing all of them would put the other panels' values in this one. Outside one it's + // `flatData`, which is what the tooltip resolves against and what the accessors are written + // for: a chart whose `data` is nested (a `stack()`, say) passes its rows as `flatData`, and + // iterating `data` there would hand the accessors a series rather than a row. + const source = this.#facetPanel?.().data ?? chartDataArray(this.ctx.flatData); const rows = source.filter((d: any) => isEqualValue(this.x(d), this.xValue)); + // A multi-value accessor (`y={[0, 1]}`, as a stack's bands use) gives a category a span + // rather than a position, so there is no single point to put on it. Those belong to the + // series path below, which points each of the values. + const spans = rows.some((row: any) => Array.isArray(value(row))); + // No visibility filter needed — rows of a `c` category the legend has hidden are already // gone from `ctx.data`, which is what keeps the scales in step with the legend - return rows - .map((row: any) => { - const seriesValue = value(row); - return { - x: - this.ctx.valueAxis === 'x' - ? this.ctx.xScale(seriesValue) + this.xOffset - : (this.xCoordScalar as number) + this.xOffset, - y: - this.ctx.valueAxis === 'x' - ? (this.yCoordScalar as number) + this.yOffset - : this.ctx.yScale(seriesValue) + this.yOffset, - fill: this.ctx.config.c ? this.ctx.cGet(row) : null, - data: { x: this.xValue, y: seriesValue }, - seriesKey: this.ctx.cKey(row) ?? undefined, - }; - }) - .filter(notNull) as HighlightPoint[]; + if (!spans) + return rows + .map((row: any) => { + const seriesValue = value(row); + return { + x: + this.ctx.valueAxis === 'x' + ? this.ctx.xScale(seriesValue) + this.xOffset + : (this.xCoordScalar as number) + this.xOffset, + y: + this.ctx.valueAxis === 'x' + ? (this.yCoordScalar as number) + this.yOffset + : this.ctx.yScale(seriesValue) + this.yOffset, + fill: this.ctx.cChannel ? this.ctx.cGet(row) : null, + data: { x: this.xValue, y: seriesValue }, + seriesKey: this.ctx.cKey(row) ?? undefined, + }; + }) + .filter(notNull) as HighlightPoint[]; } if (props.data === undefined && this.ctx.tooltip.series.length > 0) { @@ -526,7 +545,7 @@ export class HighlightState { tmpPoints = seriesPointsData .map((seriesPoint) => { - const fill = this.ctx.config.c ? this.ctx.cGet(seriesPoint.series) : null; + const fill = this.ctx.cChannel ? this.ctx.cGet(seriesPoint.series) : null; return { x: this.ctx.xScale(seriesPoint.point[1]) + this.xOffset, y: (this.yCoordScalar as number) + this.yOffset, @@ -544,7 +563,7 @@ export class HighlightState { // @ts-expect-error - TODO: fix type const _key = this.ctx.config.x?.[i]; - const fill = this.ctx.config.c + const fill = this.ctx.cChannel ? this.ctx.cGet({ ...this.highlightData, $key: _key }) : null; @@ -571,7 +590,7 @@ export class HighlightState { tmpPoints = seriesPointsData .map((seriesPoint) => { - const fill = this.ctx.config.c ? this.ctx.cGet(seriesPoint.series) : null; + const fill = this.ctx.cChannel ? this.ctx.cGet(seriesPoint.series) : null; return { x: (this.xCoord as number) + this.xOffset, y: this.ctx.yScale(seriesPoint.point[1]) + this.yOffset, @@ -589,7 +608,7 @@ export class HighlightState { // @ts-expect-error - TODO: fix type const _key = this.ctx.config.y[i]; - const fill = this.ctx.config.c + const fill = this.ctx.cChannel ? this.ctx.cGet({ ...this.highlightData, $key: _key }) : null; @@ -604,7 +623,7 @@ export class HighlightState { .filter(notNull) as HighlightPoint[]; } } else if (this.xCoord != null && this.yCoord != null) { - const fill = this.ctx.config.c ? this.ctx.cGet(this.highlightData) : null; + const fill = this.ctx.cChannel ? this.ctx.cGet(this.highlightData) : null; tmpPoints = [ { x: (this.xCoord as number) + this.xOffset, diff --git a/packages/layerchart/src/lib/components/Highlight/Highlight.svelte.test.ts b/packages/layerchart/src/lib/components/Highlight/Highlight.svelte.test.ts new file mode 100644 index 000000000..71bca1416 --- /dev/null +++ b/packages/layerchart/src/lib/components/Highlight/Highlight.svelte.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render } from 'vitest-browser-svelte'; + +import StackedCategoryChart from './__fixtures__/StackedCategoryChart.svelte'; + +const keys = ['apples', 'bananas']; +const data = [ + { date: 0, apples: 10, bananas: 20 }, + { date: 1, apples: 30, bananas: 40 }, + { date: 2, apples: 20, bananas: 50 }, +]; + +describe('Highlight points, `c`-grouped', () => { + /** Hover the middle of the plot area and hand back the highlight points drawn there */ + async function hoverForPoints(container: HTMLElement) { + const tooltipRect = container.querySelector('.lc-tooltip-context') as HTMLElement | null; + await expect.element(tooltipRect).toBeInTheDocument(); + + const rect = tooltipRect!.getBoundingClientRect(); + const eventInit = { + bubbles: true, + clientX: rect.x + rect.width / 2, + clientY: rect.y + rect.height / 2, + }; + + let points: Element[] = []; + await vi.waitFor(() => { + tooltipRect!.dispatchEvent(new PointerEvent('pointerenter', eventInit)); + tooltipRect!.dispatchEvent(new PointerEvent('pointermove', eventInit)); + + points = Array.from(document.querySelectorAll('.lc-highlight-point')); + expect(points.length).toBeGreaterThan(0); + }); + return points; + } + + it('resolves against the rows when the chart data is nested', async () => { + // `data` is a `stack()` — an array per key — so iterating it hands the chart's `x` accessor a + // series rather than a row, and the accessor throws on the row fields it isn't given. + // Rendering at all is the assertion: there is no boundary here, so the throw fails the test. + const { container } = render(StackedCategoryChart, { + data, + keys, + height: 300, + width: 400, + } as any); + + const points = await hoverForPoints(container as HTMLElement); + expect(points.length).toBe(keys.length); + }); + + it('places a point on each band rather than at NaN', async () => { + // `y={[0, 1]}` gives a category a span, and scaling the pair as though it were a single value + // puts the point at NaN — which the browser drops on the floor with a console error + const { container } = render(StackedCategoryChart, { + data, + keys, + height: 300, + width: 400, + } as any); + + const points = await hoverForPoints(container as HTMLElement); + for (const point of points) { + expect(Number(point.getAttribute('cx'))).toBeTypeOf('number'); + expect(Number.isFinite(Number(point.getAttribute('cx')))).toBe(true); + expect(Number.isFinite(Number(point.getAttribute('cy')))).toBe(true); + } + }); +}); diff --git a/packages/layerchart/src/lib/components/Highlight/__fixtures__/StackedCategoryChart.svelte b/packages/layerchart/src/lib/components/Highlight/__fixtures__/StackedCategoryChart.svelte new file mode 100644 index 000000000..c125a1398 --- /dev/null +++ b/packages/layerchart/src/lib/components/Highlight/__fixtures__/StackedCategoryChart.svelte @@ -0,0 +1,39 @@ + + + d.data.date} + y={[0, 1]} + c="key" + cDomain={keys} + cRange={['rgb(1, 1, 1)', 'rgb(2, 2, 2)']} + tooltipContext={{ mode: 'quadtree-x' }} + {...props} +> + + {#each series as seriesData (seriesData.key)} + + {/each} + + + diff --git a/packages/layerchart/src/lib/components/Waffle/Waffle.svelte.test.ts b/packages/layerchart/src/lib/components/Waffle/Waffle.svelte.test.ts new file mode 100644 index 000000000..eb5f0e16f --- /dev/null +++ b/packages/layerchart/src/lib/components/Waffle/Waffle.svelte.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render } from 'vitest-browser-svelte'; + +import StackedWaffle from './__fixtures__/StackedWaffle.svelte'; + +describe('Waffle mark registration', () => { + const data = [ + { period: 'Q1', apples: 400, bananas: 300 }, + { period: 'Q2', apples: 200, bananas: 500 }, + ]; + const series = [ + { key: 'apples', color: 'red' }, + { key: 'bananas', color: 'yellow' }, + ]; + + it('settles instead of re-registering itself in a loop', async () => { + // `Waffle`'s registered mark info tracks its props, so anything the chart derives from the + // mark registry and then feeds back into the scales would re-register on every pass. A loop + // here blows the update depth rather than failing an assertion, so rendering at all is the test + const { container } = render(StackedWaffle, { props: { data, series } } as any); + + await vi.waitFor(() => { + expect(container.querySelectorAll('.lc-waffle path, .lc-waffle rect').length).toBeGreaterThan( + 0 + ); + }); + }); +}); diff --git a/packages/layerchart/src/lib/components/Waffle/__fixtures__/StackedWaffle.svelte b/packages/layerchart/src/lib/components/Waffle/__fixtures__/StackedWaffle.svelte new file mode 100644 index 000000000..6ee525f1b --- /dev/null +++ b/packages/layerchart/src/lib/components/Waffle/__fixtures__/StackedWaffle.svelte @@ -0,0 +1,34 @@ + + + + {#snippet marks({ context }: any)} + {#each context.series.visibleSeries as s (s.key)} + + {/each} + {/snippet} + diff --git a/packages/layerchart/src/lib/components/charts/AreaChart/AreaChart.base.svelte b/packages/layerchart/src/lib/components/charts/AreaChart/AreaChart.base.svelte index 0a2543733..2bf2cd66f 100644 --- a/packages/layerchart/src/lib/components/charts/AreaChart/AreaChart.base.svelte +++ b/packages/layerchart/src/lib/components/charts/AreaChart/AreaChart.base.svelte @@ -101,9 +101,9 @@ }, }} > - {#snippet marks({ context }: { context: any })} + {#snippet marks({ context, facet }: { context: any; facet: any })} {#if typeof marks === 'function'} - {@render marks({ context })} + {@render marks({ context, facet })} {:else} {#each context.series.visibleSeries as s (s.key)} - {#snippet marks({ context }: { context: any })} + {#snippet marks({ context, facet }: { context: any; facet: any })} {#if typeof marks === 'function'} - {@render marks({ context })} + {@render marks({ context, facet })} {:else} {#each context.series.visibleSeries as s, i (s.key)} s.key === context.tooltip.data?.seriesKey) ?? context.tooltip.series[0]) : null @@ -221,7 +220,7 @@ {#snippet children({ data })} - {#if isSinglePointMode} + {#if singlePointMode} {#if activeSeries && activeSeries.key !== 'default'} { }); }); +describe('legend over a colour a mark implied', () => { + // The same chart as above with no `c` — `stroke="fruit"` on the mark names the column instead. + // Mounting this at all is half the test: the colour channel is derived from the mark registry, + // and marks register as they mount, so a bad edge here would loop rather than settle. + const longData = [ + { date: 0, fruit: 'apples', value: 10 }, + { date: 1, fruit: 'apples', value: 30 }, + { date: 0, fruit: 'bananas', value: 20 }, + { date: 1, fruit: 'bananas', value: 50 }, + ]; + + const impliedProps = { + data: longData, + x: 'date', + y: 'value', + cRange: ['red', 'yellow'], + props: { spline: { stroke: 'fruit' } }, + legend: true, + width: 400, + height: 300, + }; + + const splines = (container: HTMLElement) => + Array.from(container.querySelectorAll('.lc-path')).map((el) => ({ + stroke: el.getAttribute('stroke'), + opacity: (el as SVGElement).style.opacity || el.getAttribute('opacity') || '1', + })); + + it('lists the categories, not the rows', async () => { + const { container } = render(LineChart, { props: impliedProps } as any); + + await vi.waitFor(() => { + const labels = Array.from(container.querySelectorAll('.lc-legend-swatch-label')).map((el) => + el.textContent?.trim() + ); + expect(labels).toEqual(['apples', 'bananas']); + }); + }); + + it('does not filter on click — selection still needs a declared `c`', async () => { + // The swatches come from the colour scale, but hiding a category is `cGroups`' job and that + // stays on the declared prop. Pinned so the gap is deliberate rather than discovered + const { container } = render(LineChart, { props: impliedProps } as any); + + let buttons: NodeListOf = container.querySelectorAll('.lc-legend-swatch-button'); + await vi.waitFor(() => { + buttons = container.querySelectorAll('.lc-legend-swatch-button'); + expect(buttons.length).toBe(2); + }); + await vi.waitFor(() => expect(splines(container).length).toBe(2)); + + (buttons[0] as HTMLElement).click(); + await new Promise((r) => setTimeout(r, 50)); + + expect(splines(container).length).toBe(2); + }); +}); + describe('LineChart highlight over `c` groups', () => { // One implicit series draws every line here, so the tooltip's series list names only the row the // pointer resolved to — a single point, on whichever category happened to be last diff --git a/packages/layerchart/src/lib/components/charts/LineChart/LineChart.base.svelte b/packages/layerchart/src/lib/components/charts/LineChart/LineChart.base.svelte index 677683462..72f92930f 100644 --- a/packages/layerchart/src/lib/components/charts/LineChart/LineChart.base.svelte +++ b/packages/layerchart/src/lib/components/charts/LineChart/LineChart.base.svelte @@ -114,9 +114,9 @@ {legend} {props} > - {#snippet marks({ context }: { context: any })} + {#snippet marks({ context, facet }: { context: any; facet: any })} {#if typeof marks === 'function'} - {@render marks({ context })} + {@render marks({ context, facet })} {:else} {#each context.series.visibleSeries as s, i (s.key)} diff --git a/packages/layerchart/src/lib/components/charts/ScatterChart/ScatterChart.base.svelte b/packages/layerchart/src/lib/components/charts/ScatterChart/ScatterChart.base.svelte index 049c44487..cdd1af106 100644 --- a/packages/layerchart/src/lib/components/charts/ScatterChart/ScatterChart.base.svelte +++ b/packages/layerchart/src/lib/components/charts/ScatterChart/ScatterChart.base.svelte @@ -85,9 +85,9 @@ tooltip={tooltipProp} {props} > - {#snippet marks({ context }: { context: any })} + {#snippet marks({ context, facet }: { context: any; facet: any })} {#if typeof marks === 'function'} - {@render marks({ context })} + {@render marks({ context, facet })} {:else} {#each context.series.visibleSeries as s, i (s.key)} diff --git a/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte b/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte index 06754e029..b5c27412d 100644 --- a/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte +++ b/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte @@ -538,8 +538,12 @@ onpointerenter={() => { ctx.tooltip.isHoveringTooltipContent = true; }} - onpointerleave={() => { + onpointerleave={(e) => { ctx.tooltip.isHoveringTooltipContent = false; + // `TooltipContext` is the only thing that schedules a hide, and its timer has already fired + // and been blocked by the flag above while the pointer was in here. Without re-arming it, + // leaving the tooltip anywhere other than back over the hit area strands it on screen. + ctx.tooltip.hide(e); }} >
{ ); }); }); + + describe('pointerEvents', () => { + /** + * With `pointerEvents` enabled the tooltip can be hovered, which sets + * `isHoveringTooltipContent` and makes the hide timer `TooltipContext` already scheduled a + * no-op. Nothing else re-arms that timer, so leaving the tooltip anywhere other than back + * over the hit area used to strand it on screen indefinitely. + */ + it('should hide after the pointer leaves an interactive tooltip', async () => { + const { container } = render(LineChart, { + props: { + ...baseProps, + props: { tooltip: { root: { pointerEvents: true } } }, + }, + }); + + const tooltipCtx = container.querySelector('.lc-tooltip-context') as HTMLElement; + await expect.element(tooltipCtx).toBeInTheDocument(); + await waitForTooltip(tooltipCtx, undefined, () => { + expect(getTooltipRoot()).not.toBeNull(); + }); + + const tooltipRoot = getTooltipRoot()!; + + // Move off the plot and onto the tooltip, which blocks the pending hide + tooltipCtx.dispatchEvent(new PointerEvent('pointerleave', { bubbles: true })); + tooltipRoot.dispatchEvent(new PointerEvent('pointerenter', { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(getTooltipRoot(), 'should stay visible while hovered').not.toBeNull(); + + // Leaving the tooltip has to re-arm it + tooltipRoot.dispatchEvent(new PointerEvent('pointerleave', { bubbles: true })); + await vi.waitFor(() => expect(getTooltipRoot()).toBeNull()); + }); + }); }); diff --git a/packages/layerchart/src/lib/states/chart.svelte.test.ts b/packages/layerchart/src/lib/states/chart.svelte.test.ts index 3592f4312..bcf2c2997 100644 --- a/packages/layerchart/src/lib/states/chart.svelte.test.ts +++ b/packages/layerchart/src/lib/states/chart.svelte.test.ts @@ -378,6 +378,31 @@ describe('ChartState mark registration', () => { } }); + it('should treat an empty explicit series array as authoritative', () => { + // Charts render their marks from the series, so deriving the series back from those marks + // leaves the two oscillating with no fixed point — a chart whose data emptied out mid-flight + // (a refetch, say) would hang the tab rather than render as empty. + const data: MultiSeriesData[] = [{ date: '2024-01', apples: 10, bananas: 15 }]; + + const { state, cleanup } = createChartState({ + seriesLayout: 'overlap', + data, + x: 'date', + series: [], + }); + + try { + // Marks left over from the previous, non-empty series + state.registerMark({ y: 'apples', color: 'red' }); + state.registerMark({ y: 'bananas', color: 'yellow' }); + flushSync(); + + expect(state.seriesState.series).toHaveLength(0); + } finally { + cleanup(); + } + }); + it('should deduplicate implicit series with the same key', () => { const { state, cleanup } = createChartState({ data: [{ date: '2024-01', value: 10 }], @@ -2597,3 +2622,153 @@ describe('ChartState explicit null domain', () => { } }); }); + +describe('ChartState implied colour channel', () => { + type Row = { date: string; value: number; island: string }; + const data: Row[] = [ + { date: '2024-01', value: 10, island: 'Torgersen' }, + { date: '2024-02', value: 20, island: 'Biscoe' }, + { date: '2024-03', value: 30, island: 'Dream' }, + ]; + + const cRange = ['red', 'green', 'blue']; + + it('takes the colour column from a mark that named one', () => { + // `` names the column without the chart declaring `c="island"` + const { state, cleanup } = createChartState({ data, x: 'date', y: 'value', cRange }); + try { + state.registerMark({ x: 'date', y: 'value', color: 'island' }); + flushSync(); + + expect(state.cChannel).toBe('island'); + expect(state.cDomain).toEqual(['Torgersen', 'Biscoe', 'Dream']); + expect(state.cGet(data[0])).toBe('red'); + } finally { + cleanup(); + } + }); + + it('leaves the declared `c` alone', () => { + const { state, cleanup } = createChartState({ + data, + x: 'date', + y: 'value', + c: 'date', + cRange, + }); + try { + state.registerMark({ x: 'date', y: 'value', color: 'island' }); + flushSync(); + + // The chart said `c="date"`; a mark can't overrule it + expect(state.cChannel).toBe('date'); + expect(state.cDomain).toEqual(['2024-01', '2024-02', '2024-03']); + } finally { + cleanup(); + } + }); + + it('ignores a CSS colour, which names no column', () => { + const { state, cleanup } = createChartState({ data, x: 'date', y: 'value', cRange }); + try { + state.registerMark({ x: 'date', y: 'value', color: 'var(--color-primary)' }); + state.registerMark({ x: 'date', y: 'value', color: '#ff0000' }); + flushSync(); + + expect(state.cChannel).toBeUndefined(); + } finally { + cleanup(); + } + }); + + it('ignores a string that names nothing in the data', () => { + const { state, cleanup } = createChartState({ data, x: 'date', y: 'value', cRange }); + try { + state.registerMark({ x: 'date', y: 'value', color: 'rebeccapurple' }); + flushSync(); + + expect(state.cChannel).toBeUndefined(); + } finally { + cleanup(); + } + }); + + it('stays out of it when marks name different columns', () => { + // Nothing makes one of them the chart's colour channel, and picking either would be a guess + const { state, cleanup } = createChartState({ data, x: 'date', y: 'value', cRange }); + try { + state.registerMark({ x: 'date', y: 'value', color: 'island' }); + state.registerMark({ x: 'date', y: 'value', color: 'date' }); + flushSync(); + + expect(state.cChannel).toBeUndefined(); + } finally { + cleanup(); + } + }); + + it('colours without grouping — no legend selection, row filtering, or stack splitting', () => { + // `cGroups` drives all three, and `ChartState.data` is built from it. `data` is upstream of + // every scale, so letting it depend on the mark registry closes a loop: a scale change re-runs + // each mark's registration effect, which re-registers, which invalidates `data`. Measured on + // the stacked-waffle shape: drawn in 13s on the declared prop, blank after 254s on the implied + // channel. `Waffle.svelte.test.ts` guards the shape; this pins the boundary. + const { state, cleanup } = createChartState({ data, x: 'date', y: 'value', cRange }); + try { + state.registerMark({ x: 'date', y: 'value', color: 'island' }); + flushSync(); + + expect(state.cChannel).toBe('island'); + expect(state.cGroups).toBeNull(); + expect(state.cKey(data[0])).toBeNull(); + expect(state.data).toEqual(data); + } finally { + cleanup(); + } + }); + + it('groups nothing when a computed accessor gives a colour per row', () => { + // Naming a column says that column holds the category. A mark colouring by a function is a + // colour per row, which would cut one series into a path per colour + const { state, cleanup } = createChartState({ + data, + x: 'date', + y: 'value', + c: (d: Row) => (d.value > 15 ? 'high' : 'low'), + cRange, + }); + try { + flushSync(); + expect(state.cGroups).toBeNull(); + } finally { + cleanup(); + } + }); + + it('keeps a category on the same colour whatever else is in the data', () => { + // Without a channel the domain is the rows themselves, so an ordinal scale meets each category + // as an unknown value and answers with whatever the row count left next in the range — which + // moved every colour when a single row came or went + // Looked up by value, the way a mark's own `fill="island"` resolves it + const colourOf = (rows: Row[], island: string, withMark: boolean) => { + const { state, cleanup } = createChartState({ data: rows, x: 'date', y: 'value', cRange }); // prettier-ignore + try { + if (withMark) state.registerMark({ x: 'date', y: 'value', color: 'island' }); + flushSync(); + return state.cScale?.(island); + } finally { + cleanup(); + } + }; + + const extra = [...data, { date: '2024-04', value: 40, island: 'Biscoe' }]; + + // implied channel: 'Dream' is the third category either way + expect(colourOf(data, 'Dream', true)).toBe('blue'); + expect(colourOf(extra, 'Dream', true)).toBe('blue'); + + // without one, the domain is the rows, so the category is an unknown value the scale answers + // by extending — and one more row moves it to a different colour + expect(colourOf(data, 'Dream', false)).not.toBe(colourOf(extra, 'Dream', false)); + }); +}); diff --git a/packages/layerchart/src/lib/states/chart.svelte.ts b/packages/layerchart/src/lib/states/chart.svelte.ts index a9801a625..2c46143c9 100644 --- a/packages/layerchart/src/lib/states/chart.svelte.ts +++ b/packages/layerchart/src/lib/states/chart.svelte.ts @@ -18,6 +18,7 @@ import { import type { ChartPropsWithoutHTML } from '$lib/components/Chart/Chart.svelte'; import type { Extents } from '$lib/utils/types.js'; import { accessor, chartDataArray, defaultChartPadding, type Accessor } from '$lib/utils/common.js'; +import { colorPropDataKey } from '$lib/utils/dataProp.js'; import { filterObject } from '$lib/utils/filterObject.js'; import { calcDomain, calcScaleExtents, createGetter, createChartScale } from '$lib/utils/chart.js'; import { printDebug } from '$lib/utils/debug.js'; @@ -402,7 +403,10 @@ export class ChartState< this.seriesState = new SeriesState( () => { const explicit = this.props.series; - if (explicit && explicit.length > 0) return explicit; + // An explicitly-provided array is authoritative even when empty. Falling through to the + // implicit path on `[]` deadlocks: implicit series are derived from registered marks, but + // charts render their marks from the series, so the two oscillate and never settle. + if (explicit) return explicit; // Generate implicit series from registered marks. // Use the value axis accessor (y for horizontal charts, x for vertical). @@ -606,6 +610,42 @@ export class ChartState< return []; }); + /** + * The colour column a mark named in its own `fill` / `stroke` — `` — for a + * chart that declared no `c` of its own. + * + * Only the domain and per-row lookups follow from this. `cGroups` deliberately does not, so an + * implied channel can't start naming a legend, filtering rows, or splitting a stack: those are + * what `ChartState.data` is built from, and `data` is read while marks are still registering. + * A colour never decides *which* marks render, so deriving one from the registry can't change + * the set being registered — the same reason `flatData` can read it. + */ + #impliedC = $derived.by(() => { + if (this.props.c != null) return undefined; + + const first = chartDataArray(this.#sourceData)[0]; + if (first == null) return undefined; + + let key: string | undefined; + for (const { info } of this._markInfos) { + const named = colorPropDataKey(info.color, first); + if (named == null) continue; + // Marks naming different columns leave the chart with no one colour channel, and picking + // either would be a guess — so it keeps what it declared, which is nothing + if (key != null && key !== named) return undefined; + key = named; + } + return key; + }); + + /** + * The colour channel: `c` where the chart declared one, else the column a mark implied. + * + * What to read to ask *whether the chart has colours at all* — `config.c` answers the narrower + * question of whether `c` was passed. + */ + cChannel = $derived(this.props.c ?? this.#impliedC); + /** * The groups `c` names, or `null` when the chart's `series` name them instead. * @@ -616,13 +656,16 @@ export class ChartState< * What a legend lists, what stacks, and what `cKey` reads a row's group from. */ cGroups = $derived.by(() => { + // The declared prop, never `cChannel` — an implied channel must not reach here. `data` is + // built from this and is upstream of every scale, so a dependency on the mark registry closes + // a loop: a scale change re-runs each mark's registration effect, which re-registers, which + // invalidates `data`, which changes the scales. Measured on `Waffle/stacked`: 13s and drawn + // with the prop, 254s and blank with `cChannel`. See `Waffle.svelte.test.ts`. if (this.props.c == null) return null; // Read from the `series` prop rather than `SeriesState.isDefaultSeries`, which also counts - // the series inferred from registered marks. `ChartState.data` depends on this, and marks - // register while deriveds are being read — so reaching into the mark registry from here - // makes registering a mark mutate state something is mid-read of. Series inferred from - // marks name nothing a legend could select anyway. + // the series inferred from registered marks — the same hazard by a different route. Series + // inferred from marks name nothing a legend could select anyway. const configured = this.props.series ?? []; const seriesNameThem = configured.length > 1 || (configured.length === 1 && configured[0].key !== 'default'); @@ -916,7 +959,7 @@ export class ChartState< y = $derived(this.resolveAccessor('y')); z = $derived(makeAccessor(this.props.z)); r = $derived(makeAccessor(this.props.r)); - c = $derived(accessor(this.props.c)); + c = $derived(accessor(this.cChannel)); x1 = $derived(makeAccessor(this.props.x1)); y1 = $derived(makeAccessor(this.props.y1)); @@ -1187,9 +1230,13 @@ export class ChartState< // `Spline`s compared against each other, say) would otherwise be scaled to totals nothing // on it draws. No registered marks at all means the chart hasn't been composed by hand, // so the stack stands. - const marks = this._markInfos; - if (this.isStacked) { + // Read inside the guard. This is the value domain — every scale hangs off it, and every + // mark off those — so subscribing it to the mark registry meant each mark registering + // during mount invalidated the domain, rebuilt the scales, and redrew every path from + // them. Measured on a 5,000-point `LineChart`: 4 path builds per mount instead of 2. + const marks = this._markInfos; + // Collect in a single pass — see `getStackedValues`, which hoists the // `keyBy` accessor and stack derived reads out of the per-row loop. const stacked = this.seriesState.getStackedValues(chartDataArray(this.data)); diff --git a/packages/layerchart/src/lib/utils/common.ts b/packages/layerchart/src/lib/utils/common.ts index b45afebb4..e61398e5a 100644 --- a/packages/layerchart/src/lib/utils/common.ts +++ b/packages/layerchart/src/lib/utils/common.ts @@ -19,7 +19,12 @@ export function accessor(prop: Accessor): (d: TData) => any // function return prop; } else if (typeof prop === 'string' || typeof prop === 'number') { - // path string or number (array index) + // A plain key needs no path resolution, and `get` re-parses the string on every call — which + // is once per row per channel, so a 5,000-point chart parses `"date"` 5,000 times per pass + if (typeof prop === 'number' || (!prop.includes('.') && !prop.includes('['))) { + return (d: TData) => (d == null ? undefined : (d as any)[prop]); + } + // path string (dot / bracket notation) return (d: TData) => get(d, prop); } else { // return full object diff --git a/packages/layerchart/src/lib/utils/tooltip.test.ts b/packages/layerchart/src/lib/utils/tooltip.test.ts index cdb4a44ac..457bedb9c 100644 --- a/packages/layerchart/src/lib/utils/tooltip.test.ts +++ b/packages/layerchart/src/lib/utils/tooltip.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { scaleBand, scaleLinear } from 'd3-scale'; +import { scaleBand, scaleLinear, scaleUtc } from 'd3-scale'; +import { utcDay } from 'd3-time'; import { bisectData, dataCoords, findDatumByValue, pickNearest } from './tooltip.js'; @@ -184,4 +185,76 @@ describe('dataCoords', () => { expect(dataCoords(coordCtx, {})).toEqual({ x: 150, y: 50 }); }); + + describe('with an interval', () => { + // Ten days across 300px, so a day is 30px wide and its center sits 15px in + const timeScale = scaleUtc() + .domain([new Date('2024-01-01'), new Date('2024-01-11')]) + .range([0, 300]); + + it('positions to the center of the interval rather than its leading edge', () => { + const coordCtx = { + xGet: (d: any) => timeScale(d.date), + yGet: (d: any) => yScale(d.y), + x: (d: any) => d.date, + y: (d: any) => d.y, + xScale: timeScale, + yScale, + xInterval: utcDay, + padding: { top: 0, right: 0, bottom: 0, left: 0 }, + } as any; + + expect(dataCoords(coordCtx, { date: new Date('2024-01-01'), y: 0 }).x).toBe(15); + expect(dataCoords(coordCtx, { date: new Date('2024-01-03'), y: 0 }).x).toBe(75); + }); + + it('centers on the interval a value falls in, not on the value', () => { + const coordCtx = { + xGet: (d: any) => timeScale(d.date), + yGet: () => 0, + x: (d: any) => d.date, + y: () => 0, + xScale: timeScale, + yScale, + xInterval: utcDay, + padding: { top: 0, right: 0, bottom: 0, left: 0 }, + } as any; + + // Midday on the 1st still belongs to the 1st, so it lands where the boundary does + expect(dataCoords(coordCtx, { date: new Date('2024-01-01T12:00:00Z') }).x).toBe(15); + }); + + it('applies to the y axis too, as a horizontal chart uses it', () => { + const yTimeScale = scaleUtc() + .domain([new Date('2024-01-01'), new Date('2024-01-11')]) + .range([0, 200]); + const coordCtx = { + xGet: () => 0, + yGet: (d: any) => yTimeScale(d.date), + x: () => 0, + y: (d: any) => d.date, + xScale, + yScale: yTimeScale, + yInterval: utcDay, + padding: { top: 0, right: 0, bottom: 0, left: 0 }, + } as any; + + expect(dataCoords(coordCtx, { date: new Date('2024-01-01') }).y).toBe(10); + }); + + it('leaves a multi-value accessor alone, which already spans the mark', () => { + const coordCtx = { + xGet: () => [100, 200], + yGet: () => 0, + x: (d: any) => d.date, + y: () => 0, + xScale: timeScale, + yScale, + xInterval: utcDay, + padding: { top: 0, right: 0, bottom: 0, left: 0 }, + } as any; + + expect(dataCoords(coordCtx, { date: new Date('2024-01-01') }).x).toBe(150); + }); + }); }); diff --git a/packages/layerchart/src/lib/utils/tooltip.ts b/packages/layerchart/src/lib/utils/tooltip.ts index 60eaf6d37..04540657b 100644 --- a/packages/layerchart/src/lib/utils/tooltip.ts +++ b/packages/layerchart/src/lib/utils/tooltip.ts @@ -1,4 +1,5 @@ import { bisector } from 'd3-array'; +import type { TimeInterval } from 'd3-time'; import { sortFunc } from '@layerstack/utils'; import { isEqualValue } from './common.js'; @@ -6,6 +7,14 @@ import { isEqualValue } from './common.js'; import { isScaleBand, type AnyScale } from './scales.svelte.js'; import type { TooltipMode } from '$lib/components/tooltip/TooltipContext.svelte'; +/** + * Whether a mode resolves to one specific row, by proximity in both axes, rather than to every + * series at a position on one — which is what decides whether a hover marks a point or a column. + */ +export function isSinglePointMode(mode: TooltipMode) { + return mode === 'quadtree' || mode === 'voronoi'; +} + /** * Strategy for picking between the two data points surrounding a value. * - `closest` — whichever of the two is nearer the value @@ -33,6 +42,12 @@ export type TooltipCoordContext = { xScale: AnyScale; yScale: AnyScale; padding: { top: number; right: number; bottom: number; left: number }; + /** Domain accessors, needed to find the interval a value falls in */ + x?: (d: any) => any; + y?: (d: any) => any; + /** Interval giving a non-band scale a band-like width, as `xInterval` / `yInterval` do */ + xInterval?: TimeInterval | null; + yInterval?: TimeInterval | null; /** Present on a real `ChartState`; used to offset into the panel a row belongs to */ facet?: { enabled: boolean; panels: Array<{ x: number; y: number; has(row: any): boolean }> }; }; @@ -167,6 +182,33 @@ function coordCenter(value: any) { return Array.isArray(value) ? (value[0] + value[value.length - 1]) / 2 : value; } +/** + * Center of the span a data point occupies along one axis, in that axis' pixel space. + * + * Three ways a value can occupy a span rather than a point, in the order they take precedence: + * a band scale, a multi-value accessor (ex. `x={['start', 'end']}`), and an interval — which gives + * a time scale a band-like width, the same span `Rect` draws a bar across. All three place the + * value at the leading edge, so a tooltip anchored to the raw coordinate sits off to one side. + */ +function axisCenter( + scale: AnyScale, + interval: TimeInterval | null | undefined, + scaled: any, + value: any +) { + if (isScaleBand(scale)) return coordCenter(scaled) + bandCenterOffset(scale); + if (Array.isArray(scaled)) return coordCenter(scaled); + + if (interval && value != null) { + const start = interval.floor(value); + // The midpoint of the interval the value falls in, rather than of the value itself — the two + // differ for data that isn't already on an interval boundary + return (scale(start) + scale(interval.offset(start))) / 2; + } + + return coordCenter(scaled); +} + /** * Container-relative pixel coordinates of a data point, derived from the chart's own scales. * @@ -179,8 +221,8 @@ export function dataCoords(ctx: TooltipCoordContext, data: any) { const panel = ctx.facet?.enabled ? ctx.facet.panels.find((p) => p.has(data)) : undefined; return { - x: coordCenter(ctx.xGet(data)) + ctx.padding.left + bandCenterOffset(ctx.xScale) + (panel?.x ?? 0), // prettier-ignore - y: coordCenter(ctx.yGet(data)) + ctx.padding.top + bandCenterOffset(ctx.yScale) + (panel?.y ?? 0), // prettier-ignore + x: axisCenter(ctx.xScale, ctx.xInterval, ctx.xGet(data), ctx.x?.(data)) + ctx.padding.left + (panel?.x ?? 0), // prettier-ignore + y: axisCenter(ctx.yScale, ctx.yInterval, ctx.yGet(data), ctx.y?.(data)) + ctx.padding.top + (panel?.y ?? 0), // prettier-ignore }; }