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
5 changes: 5 additions & 0 deletions .changeset/accessor-plain-key-fast-path.md
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions .changeset/chart-empty-explicit-series.md
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 5 additions & 0 deletions .changeset/chart-mount-domain-registry.md
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions .changeset/highlight-nested-data-c-points.md
Original file line number Diff line number Diff line change
@@ -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]}`)
5 changes: 5 additions & 0 deletions .changeset/highlight-single-point-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': patch
---

fix(Highlight): Mark only the hovered row in `quadtree` / `voronoi` tooltips, rather than every row sharing its position
5 changes: 5 additions & 0 deletions .changeset/implied-color-channel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': patch
---

feat(Chart): Take the colour channel from a mark's own `fill` / `stroke` (`<Circle fill="island">`) when no `c` is declared, so the colour domain and legend hold the categories rather than the rows — hiding one still needs `c`
5 changes: 5 additions & 0 deletions .changeset/tooltip-interval-data-position.md
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions .changeset/tooltip-pointer-events-hide.md
Original file line number Diff line number Diff line change
@@ -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)
14 changes: 13 additions & 1 deletion .github/workflows/update-visual-baseline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion .github/workflows/visual-regression.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 71 additions & 9 deletions docs/scripts/visual-snapshot.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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++) {
Expand All @@ -180,18 +203,23 @@ 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) {
await page.waitForTimeout(500 * (attempt + 1));
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);
}
}

Expand Down Expand Up @@ -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}`);
Expand All @@ -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/<Component>/<example>.`
);
}

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. */
Expand Down Expand Up @@ -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. */
Expand Down
59 changes: 59 additions & 0 deletions docs/src/content/guides/facets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<BarChart
bind:context
{data}
fx="party"
onTooltipClick={(e, { data }) => {
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 })}
<Circle cx="year" cy="percent" onclick={() => 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 })}
<Frame
class={selected === facet.fx ? 'fill-primary/10' : 'fill-transparent'}
onclick={() => (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.
Expand Down Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions docs/src/content/guides/styles.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand All @@ -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" }
::

:::
Expand Down
9 changes: 8 additions & 1 deletion docs/src/examples/catalog/BarChart.json
Original file line number Diff line number Diff line change
Expand Up @@ -2452,6 +2452,13 @@
"lineNumber": 84,
"line": "<BarChart"
},
{
"example": "facet-click",
"component": "Chart",
"path": "/docs/components/Chart/facet-click",
"lineNumber": 37,
"line": "<BarChart"
},
{
"example": "truncate-axis-labels",
"component": "Text",
Expand All @@ -2467,5 +2474,5 @@
"line": "<BarChart"
}
],
"updatedAt": "2026-08-20T18:45:22.168Z"
"updatedAt": "2026-08-23T18:52:24.929Z"
}
Loading
Loading