diff --git a/.changeset/area-line-shared-props.md b/.changeset/area-line-shared-props.md new file mode 100644 index 0000000000..f918fdab66 --- /dev/null +++ b/.changeset/area-line-shared-props.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +fix(Area): Apply `mask`, `filter`, and `clip-path` to the `line` path as well as the fill (unless overrideden by `line={{...}}`) diff --git a/.changeset/axis-tick-occlusion.md b/.changeset/axis-tick-occlusion.md new file mode 100644 index 0000000000..fbeecd0b03 --- /dev/null +++ b/.changeset/axis-tick-occlusion.md @@ -0,0 +1,5 @@ +--- +'layerchart': minor +--- + +feat(Axis): Add `tickOcclusion` to drop ticks whose labels would overlap, measured at the size they render (including rotation), with `priority` of `'end'` (default), `'start'`, or `'start-end'` to choose which survive diff --git a/.changeset/calendar-month-label-offset.md b/.changeset/calendar-month-label-offset.md new file mode 100644 index 0000000000..3b3b090a01 --- /dev/null +++ b/.changeset/calendar-month-label-offset.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +fix(Calendar): Adjust month labels after recent Text verticalAnchor improvements diff --git a/.changeset/calendar-month-path-width.md b/.changeset/calendar-month-path-width.md new file mode 100644 index 0000000000..d275d106ac --- /dev/null +++ b/.changeset/calendar-month-path-width.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +fix(Calendar): Size cells to fit `monthPath`, which can extend past the last cell when the range ends mid-month diff --git a/.changeset/chart-single-mount.md b/.changeset/chart-single-mount.md new file mode 100644 index 0000000000..5c961029fc --- /dev/null +++ b/.changeset/chart-single-mount.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +perf(Chart): Measure the container before building the subtree, so marks are only built once diff --git a/.changeset/download-gradient-stops.md b/.changeset/download-gradient-stops.md new file mode 100644 index 0000000000..aa3f248ed9 --- /dev/null +++ b/.changeset/download-gradient-stops.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +fix(downloadImage, downloadSvg): Inline `stop-color`/`stop-opacity` so gradients don't export as solid black diff --git a/.changeset/getter-missing-accessor.md b/.changeset/getter-missing-accessor.md new file mode 100644 index 0000000000..c522152c67 --- /dev/null +++ b/.changeset/getter-missing-accessor.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +fix(Chart): Return `undefined` from `xGet`/`yGet` when that axis has no accessor, instead of throwing `accessor is not a function` diff --git a/.changeset/grouped-path-motion.md b/.changeset/grouped-path-motion.md new file mode 100644 index 0000000000..3684cfd588 --- /dev/null +++ b/.changeset/grouped-path-motion.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +fix(Spline, Area): Animate each path when the mark is split by `z`, which `motion` previously skipped — segments split by a style function are still redrawn outright diff --git a/.changeset/path-end-content-without-draw.md b/.changeset/path-end-content-without-draw.md new file mode 100644 index 0000000000..77443c1d6f --- /dev/null +++ b/.changeset/path-end-content-without-draw.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +fix(Path, Spline): Render `endContent` without a `draw` transition diff --git a/.changeset/text-rect-rotate.md b/.changeset/text-rect-rotate.md new file mode 100644 index 0000000000..3dcb7ad5c4 --- /dev/null +++ b/.changeset/text-rect-rotate.md @@ -0,0 +1,5 @@ +--- +'layerchart': minor +--- + +feat(utils): Add `rotate` and multiline support to `getTextRect`, so a rotated or stacked `` can be measured for `occlude` without the caller reproducing ``'s pivot diff --git a/docs/generated/releases/layerchart-2.3.1.md b/docs/generated/releases/layerchart-2.3.1.md new file mode 100644 index 0000000000..bcb9a6b88c --- /dev/null +++ b/docs/generated/releases/layerchart-2.3.1.md @@ -0,0 +1,26 @@ +--- +title: "layerchart@2.3.1" +tag: "layerchart@2.3.1" +date: "2026-08-27T11:33:17Z" +url: "https://github.com/techniq/layerchart/releases/tag/layerchart%402.3.1" +draft: false +prerelease: false +author: "github-actions[bot]" +--- +### Patch Changes + +- perf(Chart): Resolve a plain-key accessor (`x="date"`) directly instead of re-parsing the path string ([#906](https://github.com/techniq/layerchart/pull/906)) + +- fix(Chart): Treat an explicit but empty `series` array as authoritative, rather than deriving implicit series from marks ([#906](https://github.com/techniq/layerchart/pull/906)) + +- 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 ([#906](https://github.com/techniq/layerchart/pull/906)) + +- fix(Highlight): Fix crash highlighting `c`-grouped points over nested chart data ([#906](https://github.com/techniq/layerchart/pull/906)) + +- fix(Highlight): Mark only the hovered row in `quadtree` / `voronoi` tooltips, rather than every row sharing its position ([#906](https://github.com/techniq/layerchart/pull/906)) + +- feat(Chart): Take the color channel from a mark's own `fill` / `stroke` (``) when no `c` is declared ([#906](https://github.com/techniq/layerchart/pull/906)) + +- fix(Tooltip): Center on internal when data snapping (instead of on its leading edge) ([#906](https://github.com/techniq/layerchart/pull/906)) + +- fix(Tooltip): Fix persistent tooltip when rendered outside Chart container and `pointerEvents` is enabled ([#906](https://github.com/techniq/layerchart/pull/906)) \ No newline at end of file diff --git a/docs/src/content/components/Axis.md b/docs/src/content/components/Axis.md index bac6f7c0d5..eb582aca74 100644 --- a/docs/src/content/components/Axis.md +++ b/docs/src/content/components/Axis.md @@ -25,6 +25,80 @@ Default: `80` for horizontal axes (top/bottom/angle) and `50` for vertical axes See [time scales](#time-scales) for how tick labels are chosen, and [brush](/docs/components/Axis/time-scale-brush-multiline) for tick spacing while zooming. :: +### tickOcclusion + +`tickSpacing` divides the axis length by a pixel budget to pick a tick **count**. It never looks at +the labels, so it is only right when every label is about as wide as the budget assumes. +`tickOcclusion` measures each label at the size it renders and drops the ticks whose labels would +overlap one already kept. + +Reach for `tickSpacing` when labels are uniform (a numeric scale, `12:00`-style times) — it is +cheaper and gives evenly spaced ticks. Reach for `tickOcclusion` when they are not: + +:example{ name="tick-occlusion" } + +`padding` is the minimum gap required between two kept labels — raise it to thin the axis further +than mere non-overlap would. + +#### Why not just lower `tickSpacing`? + +Because one budget has to serve every label. Set it wide enough for `Customer Success Operations` +and you throw away ticks next to `HR`; set it for `HR` and the long ones still collide. + +:example{ name="tick-occlusion-vs-tick-spacing" } + +::note +Band scales default `tickSpacing` to `null` and draw a tick per band, so a category axis with long +names overlaps out of the box. This is the case `tickOcclusion` is most useful for. +:: + +#### Ticks `tickSpacing` cannot thin at all + +An explicit `ticks` array or time interval sets the tick values outright — `tickSpacing` and +`count` have no say (see [time scale (explicit)](#time-scale-explicit)). `tickOcclusion` runs after +the values are chosen, so it is the only way to thin them while keeping the interval you asked for. + +:example{ name="tick-occlusion-explicit-ticks" } + +#### Priority + +Which ticks win when labels compete: + +- `'end'` (default) — keep the last tick and walk backwards. The newest value on a time axis. +- `'start'` — keep the first and walk forwards. +- `'start-end'` — anchor both ends of the axis, thin what is between them, so the reader always + sees the range the chart spans. + +:example{ name="tick-occlusion-priority" } + +#### Rotated labels + +Rotation is the other way out of a crowded category axis, and the two compose: labels are measured +after rotation, so an angled label — which needs far less horizontal room — keeps neighbours that a +flat one would push out. + +:example{ name="tick-occlusion-rotated" } + +#### Options + +```svelte + +``` + +- `priority` — `'end'` (default), `'start'`, or `'start-end'`. +- `padding` — minimum gap, in pixels, required between two kept labels. Defaults to `4`. + +::note +The whole tick is dropped, not just its label — its tick mark and grid line go with it, the same way +a lower `tickSpacing` would. +:: + +::note +Labels are tested against each other, not against the plot bounds. A kept label at either end of the +axis is drawn wherever its tick sits, so reserve horizontal `padding` for it — especially with +`priority: 'start-end'`, which anchors both ends by design. +:: + ### time scales With no `format`, tick labels are chosen automatically from the duration between ticks — a domain spanning years is labelled with years, one spanning a minute with seconds. Resizing the chart (or changing `tickSpacing`) changes the tick density, and the labels follow. diff --git a/docs/src/examples/catalog/Area.json b/docs/src/examples/catalog/Area.json index edcc3d50e7..ea9df6ab02 100644 --- a/docs/src/examples/catalog/Area.json +++ b/docs/src/examples/catalog/Area.json @@ -139,6 +139,43 @@ } ] }, + { + "name": "fade-mask", + "title": "fade mask", + "path": "/docs/components/Area/fade-mask", + "components": [ + { + "component": "Chart", + "lineNumber": 14, + "line": "" + }, + { + "component": "Layer", + "lineNumber": 16, + "line": "" + }, + { + "component": "Axis", + "lineNumber": 17, + "line": "" + }, + { + "component": "LinearGradient", + "lineNumber": 20, + "line": "" + }, + { + "component": "Rect", + "lineNumber": 24, + "line": "" + }, + { + "component": "Area", + "lineNumber": 28, + "line": "" + }, + { + "component": "Axis", + "lineNumber": 48, + "line": "" + }, + { + "component": "Area", + "lineNumber": 51, + "line": "" + } + ] + }, { "name": "multiple-series-using-overrides", "title": "multiple series using overrides", @@ -963,6 +1027,13 @@ "lineNumber": 18, "line": "" }, + { + "example": "fade-mask", + "component": "Area", + "path": "/docs/components/Area/fade-mask", + "lineNumber": 28, + "line": "" }, + { + "example": "multiple-series-tweened", + "component": "Area", + "path": "/docs/components/Area/multiple-series-tweened", + "lineNumber": 51, + "line": "" + }, { "example": "multiple-series-using-overrides", "component": "Area", @@ -1394,14 +1472,14 @@ "example": "synced-brushes", "component": "BrushContext", "path": "/docs/components/BrushContext/synced-brushes", - "lineNumber": 74, + "lineNumber": 71, "line": "" }, { "example": "synced-brushes", "component": "BrushContext", "path": "/docs/components/BrushContext/synced-brushes", - "lineNumber": 93, + "lineNumber": 90, "line": "" }, { "example": "synced-brushes", "component": "ChartGroup", "path": "/docs/components/ChartGroup/synced-brushes", - "lineNumber": 78, + "lineNumber": 75, "line": "" + }, + { + "component": "Layer", + "lineNumber": 20, + "line": "" + }, + { + "component": "Axis", + "lineNumber": 21, + "line": "" + }, + { + "component": "Spline", + "lineNumber": 23, + "line": "" + } + ] + }, + { + "name": "tick-occlusion-priority", + "title": "tick occlusion priority", + "path": "/docs/components/Axis/tick-occlusion-priority", + "components": [ + { + "component": "Chart", + "lineNumber": 33, + "line": "" + }, + { + "component": "Layer", + "lineNumber": 34, + "line": "" + }, + { + "component": "Axis", + "lineNumber": 35, + "line": "" + }, + { + "component": "Spline", + "lineNumber": 43, + "line": "" + } + ] + }, + { + "name": "tick-occlusion-rotated", + "title": "tick occlusion rotated", + "path": "/docs/components/Axis/tick-occlusion-rotated", + "components": [ + { + "component": "BarChart", + "lineNumber": 33, + "line": " scale.domain()}>" }, + { + "example": "fade-mask", + "component": "Area", + "path": "/docs/components/Area/fade-mask", + "lineNumber": 17, + "line": "" + }, + { + "example": "fade-mask", + "component": "Area", + "path": "/docs/components/Area/fade-mask", + "lineNumber": 18, + "line": "" + }, { "example": "gradient", "component": "Area", @@ -1067,6 +1171,20 @@ "lineNumber": 56, "line": "" }, + { + "example": "multiple-series-tweened", + "component": "Area", + "path": "/docs/components/Area/multiple-series-tweened", + "lineNumber": 48, + "line": "" + }, + { + "example": "multiple-series-tweened", + "component": "Area", + "path": "/docs/components/Area/multiple-series-tweened", + "lineNumber": 49, + "line": "" + }, { "example": "multiple-series-using-overrides", "component": "Area", @@ -1655,6 +1773,34 @@ "lineNumber": 11, "line": "" + }, + { + "example": "tick-occlusion-explicit-ticks", + "component": "Axis", + "path": "/docs/components/Axis/tick-occlusion-explicit-ticks", + "lineNumber": 22, + "line": "" + }, + { + "example": "tick-occlusion-priority", + "component": "Axis", + "path": "/docs/components/Axis/tick-occlusion-priority", + "lineNumber": 35, + "line": "" + }, + { + "example": "tick-occlusion-priority", + "component": "Axis", + "path": "/docs/components/Axis/tick-occlusion-priority", + "lineNumber": 36, + "line": "" }, { "example": "facet-tooltip", "component": "Chart", "path": "/docs/components/Chart/facet-tooltip", - "lineNumber": 30, + "lineNumber": 29, "line": "" }, { @@ -4749,6 +4895,20 @@ "lineNumber": 53, "line": "" }, + { + "example": "multiple-series-tweened", + "component": "Spline", + "path": "/docs/components/Spline/multiple-series-tweened", + "lineNumber": 48, + "line": "" + }, + { + "example": "multiple-series-tweened", + "component": "Spline", + "path": "/docs/components/Spline/multiple-series-tweened", + "lineNumber": 49, + "line": "" + }, { "example": "multiple-series-using-overrides", "component": "Spline", @@ -5478,5 +5638,5 @@ "line": "" }, { "component": "Area", - "lineNumber": 74, + "lineNumber": 71, "line": "" } ] @@ -690,5 +690,5 @@ } ], "usage": [], - "updatedAt": "2026-08-14T18:32:52.361Z" + "updatedAt": "2026-09-03T20:00:06.000Z" } \ No newline at end of file diff --git a/docs/src/examples/catalog/BrushContextAccessHarness.json b/docs/src/examples/catalog/BrushContextAccessHarness.json deleted file mode 100644 index 27c38cd841..0000000000 --- a/docs/src/examples/catalog/BrushContextAccessHarness.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "component": "BrushContextAccessHarness", - "examples": [], - "usage": [], - "updatedAt": "2026-07-22T16:53:01.378Z" -} \ No newline at end of file diff --git a/docs/src/examples/catalog/BrushMarkHarness.json b/docs/src/examples/catalog/BrushMarkHarness.json deleted file mode 100644 index 6281752536..0000000000 --- a/docs/src/examples/catalog/BrushMarkHarness.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "component": "BrushMarkHarness", - "examples": [], - "usage": [], - "updatedAt": "2026-08-20T12:48:38.329Z" -} \ No newline at end of file diff --git a/docs/src/examples/catalog/BrushTestHarness.json b/docs/src/examples/catalog/BrushTestHarness.json deleted file mode 100644 index 53dac8b154..0000000000 --- a/docs/src/examples/catalog/BrushTestHarness.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "component": "BrushTestHarness", - "examples": [], - "usage": [], - "updatedAt": "2026-03-24T16:00:54.970Z" -} \ No newline at end of file diff --git a/docs/src/examples/catalog/Chart.json b/docs/src/examples/catalog/Chart.json index 31170beb88..10195ebc81 100644 --- a/docs/src/examples/catalog/Chart.json +++ b/docs/src/examples/catalog/Chart.json @@ -580,32 +580,32 @@ }, { "component": "FacetAxis", - "lineNumber": 27, + "lineNumber": 26, "line": "" }, { "component": "Grid", - "lineNumber": 28, + "lineNumber": 27, "line": "" }, { "component": "Axis", - "lineNumber": 29, + "lineNumber": 28, "line": "" }, { "component": "Circle", - "lineNumber": 31, + "lineNumber": 30, "line": "" }, { "component": "Highlight", - "lineNumber": 32, + "lineNumber": 31, "line": "" }, { "component": "Tooltip", - "lineNumber": 35, + "lineNumber": 34, "line": "" } ] @@ -922,6 +922,13 @@ "lineNumber": 10, "line": "" }, + { + "example": "fade-mask", + "component": "Area", + "path": "/docs/components/Area/fade-mask", + "lineNumber": 14, + "line": "" + }, { "example": "gradient", "component": "Area", @@ -964,6 +971,13 @@ "lineNumber": 40, "line": "" }, + { + "example": "tick-occlusion-explicit-ticks", + "component": "Axis", + "path": "/docs/components/Axis/tick-occlusion-explicit-ticks", + "lineNumber": 19, + "line": "" + }, + { + "example": "tick-occlusion-priority", + "component": "Axis", + "path": "/docs/components/Axis/tick-occlusion-priority", + "lineNumber": 33, + "line": "" + }, { "example": "tick-spacing", "component": "Axis", @@ -2186,7 +2214,7 @@ "example": "synced-brushes", "component": "BrushContext", "path": "/docs/components/BrushContext/synced-brushes", - "lineNumber": 81, + "lineNumber": 78, "line": "" }, { @@ -4667,6 +4695,13 @@ "lineNumber": 37, "line": "" }, { "component": "Area", - "lineNumber": 69, + "lineNumber": 66, "line": "" } ] @@ -302,5 +302,5 @@ "line": "" } ], - "updatedAt": "2026-08-20T17:01:01.416Z" + "updatedAt": "2026-09-03T20:00:06.232Z" } \ No newline at end of file diff --git a/docs/src/examples/catalog/ChartGroupTestHarness.json b/docs/src/examples/catalog/ChartGroupTestHarness.json deleted file mode 100644 index ed0039579b..0000000000 --- a/docs/src/examples/catalog/ChartGroupTestHarness.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "component": "ChartGroupTestHarness", - "examples": [], - "usage": [], - "updatedAt": "2026-08-14T18:32:52.574Z" -} \ No newline at end of file diff --git a/docs/src/examples/catalog/ChartWithMarkSeries.json b/docs/src/examples/catalog/ChartWithMarkSeries.json deleted file mode 100644 index 5531c73e04..0000000000 --- a/docs/src/examples/catalog/ChartWithMarkSeries.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "component": "ChartWithMarkSeries", - "examples": [], - "usage": [], - "updatedAt": "2026-08-19T20:49:33.235Z" -} \ No newline at end of file diff --git a/docs/src/examples/catalog/Circle.json b/docs/src/examples/catalog/Circle.json index b07570e1ef..4a48482b65 100644 --- a/docs/src/examples/catalog/Circle.json +++ b/docs/src/examples/catalog/Circle.json @@ -377,7 +377,7 @@ "example": "facet-tooltip", "component": "Chart", "path": "/docs/components/Chart/facet-tooltip", - "lineNumber": 31, + "lineNumber": 30, "line": "" }, { @@ -1214,5 +1214,5 @@ "line": "" } ], - "updatedAt": "2026-08-23T18:52:25.346Z" + "updatedAt": "2026-09-03T20:00:06.279Z" } \ No newline at end of file diff --git a/docs/src/examples/catalog/FacetAxis.json b/docs/src/examples/catalog/FacetAxis.json index e7ff63d923..b1388fa211 100644 --- a/docs/src/examples/catalog/FacetAxis.json +++ b/docs/src/examples/catalog/FacetAxis.json @@ -13,9 +13,9 @@ "example": "facet-tooltip", "component": "Chart", "path": "/docs/components/Chart/facet-tooltip", - "lineNumber": 27, + "lineNumber": 26, "line": "" } ], - "updatedAt": "2026-08-20T12:48:38.992Z" + "updatedAt": "2026-09-03T20:00:06.790Z" } \ No newline at end of file diff --git a/docs/src/examples/catalog/GeoPath.json b/docs/src/examples/catalog/GeoPath.json index 6c9b571510..3abb0b5074 100644 --- a/docs/src/examples/catalog/GeoPath.json +++ b/docs/src/examples/catalog/GeoPath.json @@ -652,7 +652,7 @@ { "component": "Text", "lineNumber": 40, - "line": " + + + diff --git a/docs/src/examples/components/Axis/barchart-tickspacing.svelte b/docs/src/examples/components/Axis/barchart-tickspacing.svelte index ef301abb30..4a297dc34d 100644 --- a/docs/src/examples/components/Axis/barchart-tickspacing.svelte +++ b/docs/src/examples/components/Axis/barchart-tickspacing.svelte @@ -1,7 +1,7 @@ - +
+ + + +
diff --git a/docs/src/examples/components/Axis/linechart-tickspacing.svelte b/docs/src/examples/components/Axis/linechart-tickspacing.svelte index 43849191ec..6abcb5a841 100644 --- a/docs/src/examples/components/Axis/linechart-tickspacing.svelte +++ b/docs/src/examples/components/Axis/linechart-tickspacing.svelte @@ -1,24 +1,29 @@ - +
+ + + +
diff --git a/docs/src/examples/components/Axis/tick-occlusion-explicit-ticks.svelte b/docs/src/examples/components/Axis/tick-occlusion-explicit-ticks.svelte new file mode 100644 index 0000000000..1e6dee2695 --- /dev/null +++ b/docs/src/examples/components/Axis/tick-occlusion-explicit-ticks.svelte @@ -0,0 +1,25 @@ + + + + + + + + + + + diff --git a/docs/src/examples/components/Axis/tick-occlusion-priority.svelte b/docs/src/examples/components/Axis/tick-occlusion-priority.svelte new file mode 100644 index 0000000000..6060e78057 --- /dev/null +++ b/docs/src/examples/components/Axis/tick-occlusion-priority.svelte @@ -0,0 +1,45 @@ + + + + + {#each priorities as opt (opt.value)} + {opt.label} + {/each} + + + + + + + + + + + diff --git a/docs/src/examples/components/Axis/tick-occlusion-rotated.svelte b/docs/src/examples/components/Axis/tick-occlusion-rotated.svelte new file mode 100644 index 0000000000..743655d508 --- /dev/null +++ b/docs/src/examples/components/Axis/tick-occlusion-rotated.svelte @@ -0,0 +1,45 @@ + + + + + diff --git a/docs/src/examples/components/Axis/tick-occlusion-vs-tick-spacing.svelte b/docs/src/examples/components/Axis/tick-occlusion-vs-tick-spacing.svelte new file mode 100644 index 0000000000..a5c2161faf --- /dev/null +++ b/docs/src/examples/components/Axis/tick-occlusion-vs-tick-spacing.svelte @@ -0,0 +1,57 @@ + + +
+
+
+ tickSpacing={80} — one budget for every label, so the short ones waste room and the + long ones still collide +
+ +
+ +
+
+ tickOcclusion — measures each label, so a run of short names keeps more ticks than + a run of long ones +
+ +
+
diff --git a/docs/src/examples/components/Axis/tick-occlusion.svelte b/docs/src/examples/components/Axis/tick-occlusion.svelte new file mode 100644 index 0000000000..39436dde7a --- /dev/null +++ b/docs/src/examples/components/Axis/tick-occlusion.svelte @@ -0,0 +1,47 @@ + + +
+ + + +
+ + diff --git a/docs/src/examples/components/GeoPath/us-country-map.svelte b/docs/src/examples/components/GeoPath/us-country-map.svelte index 7ec55a2af0..9bd5d88984 100644 --- a/docs/src/examples/components/GeoPath/us-country-map.svelte +++ b/docs/src/examples/components/GeoPath/us-country-map.svelte @@ -9,8 +9,8 @@ import { Chart, Layer, Text } from 'layerchart'; import { GeoPath } from 'layerchart/geo'; - const states = feature(topology, topology.objects.states); + const states = feature(topology, topology.objects.states); const data = { topology, states }; export { data }; @@ -36,15 +36,16 @@ {#snippet children({ geoPath })} {@const [x, y] = geoPath?.centroid(feature) ?? []} - - + {#if Number.isFinite(x) && Number.isFinite(y)} + + {/if} {/snippet} {/each} diff --git a/docs/src/examples/components/Spline/multiple-series-tweened.svelte b/docs/src/examples/components/Spline/multiple-series-tweened.svelte new file mode 100644 index 0000000000..d5acad85b2 --- /dev/null +++ b/docs/src/examples/components/Spline/multiple-series-tweened.svelte @@ -0,0 +1,53 @@ + + + + + + + + + + + + diff --git a/docs/static/screenshots/Area/fade-mask-dark-240w.webp b/docs/static/screenshots/Area/fade-mask-dark-240w.webp new file mode 100644 index 0000000000..caa501a9b2 Binary files /dev/null and b/docs/static/screenshots/Area/fade-mask-dark-240w.webp differ diff --git a/docs/static/screenshots/Area/fade-mask-dark-400w.webp b/docs/static/screenshots/Area/fade-mask-dark-400w.webp new file mode 100644 index 0000000000..964b184856 Binary files /dev/null and b/docs/static/screenshots/Area/fade-mask-dark-400w.webp differ diff --git a/docs/static/screenshots/Area/fade-mask-dark-800w.webp b/docs/static/screenshots/Area/fade-mask-dark-800w.webp new file mode 100644 index 0000000000..18e873832b Binary files /dev/null and b/docs/static/screenshots/Area/fade-mask-dark-800w.webp differ diff --git a/docs/static/screenshots/Area/fade-mask-light-240w.webp b/docs/static/screenshots/Area/fade-mask-light-240w.webp new file mode 100644 index 0000000000..23b12a81b2 Binary files /dev/null and b/docs/static/screenshots/Area/fade-mask-light-240w.webp differ diff --git a/docs/static/screenshots/Area/fade-mask-light-400w.webp b/docs/static/screenshots/Area/fade-mask-light-400w.webp new file mode 100644 index 0000000000..f2d069c58c Binary files /dev/null and b/docs/static/screenshots/Area/fade-mask-light-400w.webp differ diff --git a/docs/static/screenshots/Area/fade-mask-light-800w.webp b/docs/static/screenshots/Area/fade-mask-light-800w.webp new file mode 100644 index 0000000000..2287bbf8d5 Binary files /dev/null and b/docs/static/screenshots/Area/fade-mask-light-800w.webp differ diff --git a/docs/static/screenshots/Area/multiple-series-tweened-dark-240w.webp b/docs/static/screenshots/Area/multiple-series-tweened-dark-240w.webp new file mode 100644 index 0000000000..3ec059bade Binary files /dev/null and b/docs/static/screenshots/Area/multiple-series-tweened-dark-240w.webp differ diff --git a/docs/static/screenshots/Area/multiple-series-tweened-dark-400w.webp b/docs/static/screenshots/Area/multiple-series-tweened-dark-400w.webp new file mode 100644 index 0000000000..5607aebd44 Binary files /dev/null and b/docs/static/screenshots/Area/multiple-series-tweened-dark-400w.webp differ diff --git a/docs/static/screenshots/Area/multiple-series-tweened-dark-800w.webp b/docs/static/screenshots/Area/multiple-series-tweened-dark-800w.webp new file mode 100644 index 0000000000..084fb1652e Binary files /dev/null and b/docs/static/screenshots/Area/multiple-series-tweened-dark-800w.webp differ diff --git a/docs/static/screenshots/Area/multiple-series-tweened-light-240w.webp b/docs/static/screenshots/Area/multiple-series-tweened-light-240w.webp new file mode 100644 index 0000000000..67edd0dd98 Binary files /dev/null and b/docs/static/screenshots/Area/multiple-series-tweened-light-240w.webp differ diff --git a/docs/static/screenshots/Area/multiple-series-tweened-light-400w.webp b/docs/static/screenshots/Area/multiple-series-tweened-light-400w.webp new file mode 100644 index 0000000000..4bac97dc74 Binary files /dev/null and b/docs/static/screenshots/Area/multiple-series-tweened-light-400w.webp differ diff --git a/docs/static/screenshots/Area/multiple-series-tweened-light-800w.webp b/docs/static/screenshots/Area/multiple-series-tweened-light-800w.webp new file mode 100644 index 0000000000..4769833556 Binary files /dev/null and b/docs/static/screenshots/Area/multiple-series-tweened-light-800w.webp differ diff --git a/docs/static/screenshots/Axis/barchart-tickspacing-dark-240w.webp b/docs/static/screenshots/Axis/barchart-tickspacing-dark-240w.webp index fc6cfd388e..f1576c6315 100644 Binary files a/docs/static/screenshots/Axis/barchart-tickspacing-dark-240w.webp and b/docs/static/screenshots/Axis/barchart-tickspacing-dark-240w.webp differ diff --git a/docs/static/screenshots/Axis/barchart-tickspacing-dark-400w.webp b/docs/static/screenshots/Axis/barchart-tickspacing-dark-400w.webp index eb82c78c64..c1b3c864e4 100644 Binary files a/docs/static/screenshots/Axis/barchart-tickspacing-dark-400w.webp and b/docs/static/screenshots/Axis/barchart-tickspacing-dark-400w.webp differ diff --git a/docs/static/screenshots/Axis/barchart-tickspacing-dark-800w.webp b/docs/static/screenshots/Axis/barchart-tickspacing-dark-800w.webp index 6f50d208f7..23de52826d 100644 Binary files a/docs/static/screenshots/Axis/barchart-tickspacing-dark-800w.webp and b/docs/static/screenshots/Axis/barchart-tickspacing-dark-800w.webp differ diff --git a/docs/static/screenshots/Axis/barchart-tickspacing-light-240w.webp b/docs/static/screenshots/Axis/barchart-tickspacing-light-240w.webp index 700d078c43..f2d19bf386 100644 Binary files a/docs/static/screenshots/Axis/barchart-tickspacing-light-240w.webp and b/docs/static/screenshots/Axis/barchart-tickspacing-light-240w.webp differ diff --git a/docs/static/screenshots/Axis/barchart-tickspacing-light-400w.webp b/docs/static/screenshots/Axis/barchart-tickspacing-light-400w.webp index e1095a7858..f089168944 100644 Binary files a/docs/static/screenshots/Axis/barchart-tickspacing-light-400w.webp and b/docs/static/screenshots/Axis/barchart-tickspacing-light-400w.webp differ diff --git a/docs/static/screenshots/Axis/barchart-tickspacing-light-800w.webp b/docs/static/screenshots/Axis/barchart-tickspacing-light-800w.webp index 2739682e4f..baf4dbf4dc 100644 Binary files a/docs/static/screenshots/Axis/barchart-tickspacing-light-800w.webp and b/docs/static/screenshots/Axis/barchart-tickspacing-light-800w.webp differ diff --git a/docs/static/screenshots/Axis/linechart-tickspacing-dark-240w.webp b/docs/static/screenshots/Axis/linechart-tickspacing-dark-240w.webp index e56101840b..66b82fac05 100644 Binary files a/docs/static/screenshots/Axis/linechart-tickspacing-dark-240w.webp and b/docs/static/screenshots/Axis/linechart-tickspacing-dark-240w.webp differ diff --git a/docs/static/screenshots/Axis/linechart-tickspacing-dark-400w.webp b/docs/static/screenshots/Axis/linechart-tickspacing-dark-400w.webp index 3c1e7dec49..ce971c29a2 100644 Binary files a/docs/static/screenshots/Axis/linechart-tickspacing-dark-400w.webp and b/docs/static/screenshots/Axis/linechart-tickspacing-dark-400w.webp differ diff --git a/docs/static/screenshots/Axis/linechart-tickspacing-dark-800w.webp b/docs/static/screenshots/Axis/linechart-tickspacing-dark-800w.webp index 8fcdcda2a2..f0c74c057f 100644 Binary files a/docs/static/screenshots/Axis/linechart-tickspacing-dark-800w.webp and b/docs/static/screenshots/Axis/linechart-tickspacing-dark-800w.webp differ diff --git a/docs/static/screenshots/Axis/linechart-tickspacing-light-240w.webp b/docs/static/screenshots/Axis/linechart-tickspacing-light-240w.webp index 14e791c85e..70e7d84ebf 100644 Binary files a/docs/static/screenshots/Axis/linechart-tickspacing-light-240w.webp and b/docs/static/screenshots/Axis/linechart-tickspacing-light-240w.webp differ diff --git a/docs/static/screenshots/Axis/linechart-tickspacing-light-400w.webp b/docs/static/screenshots/Axis/linechart-tickspacing-light-400w.webp index 73732c91f4..de4823adf2 100644 Binary files a/docs/static/screenshots/Axis/linechart-tickspacing-light-400w.webp and b/docs/static/screenshots/Axis/linechart-tickspacing-light-400w.webp differ diff --git a/docs/static/screenshots/Axis/linechart-tickspacing-light-800w.webp b/docs/static/screenshots/Axis/linechart-tickspacing-light-800w.webp index 9607193098..bb06ad5aca 100644 Binary files a/docs/static/screenshots/Axis/linechart-tickspacing-light-800w.webp and b/docs/static/screenshots/Axis/linechart-tickspacing-light-800w.webp differ diff --git a/docs/static/screenshots/Axis/tick-occlusion-dark-240w.webp b/docs/static/screenshots/Axis/tick-occlusion-dark-240w.webp new file mode 100644 index 0000000000..86ee8cddf9 Binary files /dev/null and b/docs/static/screenshots/Axis/tick-occlusion-dark-240w.webp differ diff --git a/docs/static/screenshots/Axis/tick-occlusion-dark-400w.webp b/docs/static/screenshots/Axis/tick-occlusion-dark-400w.webp new file mode 100644 index 0000000000..c399298f55 Binary files /dev/null and b/docs/static/screenshots/Axis/tick-occlusion-dark-400w.webp differ diff --git a/docs/static/screenshots/Axis/tick-occlusion-dark-800w.webp b/docs/static/screenshots/Axis/tick-occlusion-dark-800w.webp new file mode 100644 index 0000000000..90eb9d8c82 Binary files /dev/null and b/docs/static/screenshots/Axis/tick-occlusion-dark-800w.webp differ diff --git a/docs/static/screenshots/Axis/tick-occlusion-explicit-ticks-dark-240w.webp b/docs/static/screenshots/Axis/tick-occlusion-explicit-ticks-dark-240w.webp new file mode 100644 index 0000000000..eaff1dbf5a Binary files /dev/null and b/docs/static/screenshots/Axis/tick-occlusion-explicit-ticks-dark-240w.webp differ diff --git a/docs/static/screenshots/Axis/tick-occlusion-explicit-ticks-dark-400w.webp b/docs/static/screenshots/Axis/tick-occlusion-explicit-ticks-dark-400w.webp new file mode 100644 index 0000000000..28d766cbd2 Binary files /dev/null and b/docs/static/screenshots/Axis/tick-occlusion-explicit-ticks-dark-400w.webp differ diff --git a/docs/static/screenshots/Axis/tick-occlusion-explicit-ticks-dark-800w.webp b/docs/static/screenshots/Axis/tick-occlusion-explicit-ticks-dark-800w.webp new file mode 100644 index 0000000000..17b7ac85ad Binary files /dev/null and b/docs/static/screenshots/Axis/tick-occlusion-explicit-ticks-dark-800w.webp differ diff --git a/docs/static/screenshots/Axis/tick-occlusion-explicit-ticks-light-240w.webp b/docs/static/screenshots/Axis/tick-occlusion-explicit-ticks-light-240w.webp new file mode 100644 index 0000000000..2efbae4e9b Binary files /dev/null and b/docs/static/screenshots/Axis/tick-occlusion-explicit-ticks-light-240w.webp differ diff --git a/docs/static/screenshots/Axis/tick-occlusion-explicit-ticks-light-400w.webp b/docs/static/screenshots/Axis/tick-occlusion-explicit-ticks-light-400w.webp new file mode 100644 index 0000000000..f7440f0ef8 Binary files /dev/null and b/docs/static/screenshots/Axis/tick-occlusion-explicit-ticks-light-400w.webp differ diff --git a/docs/static/screenshots/Axis/tick-occlusion-explicit-ticks-light-800w.webp b/docs/static/screenshots/Axis/tick-occlusion-explicit-ticks-light-800w.webp new file mode 100644 index 0000000000..e53cb7f0d6 Binary files /dev/null and b/docs/static/screenshots/Axis/tick-occlusion-explicit-ticks-light-800w.webp differ diff --git a/docs/static/screenshots/Axis/tick-occlusion-light-240w.webp b/docs/static/screenshots/Axis/tick-occlusion-light-240w.webp new file mode 100644 index 0000000000..6072d7401b Binary files /dev/null and b/docs/static/screenshots/Axis/tick-occlusion-light-240w.webp differ diff --git a/docs/static/screenshots/Axis/tick-occlusion-light-400w.webp b/docs/static/screenshots/Axis/tick-occlusion-light-400w.webp new file mode 100644 index 0000000000..19dedf7c99 Binary files /dev/null and b/docs/static/screenshots/Axis/tick-occlusion-light-400w.webp differ diff --git a/docs/static/screenshots/Axis/tick-occlusion-light-800w.webp b/docs/static/screenshots/Axis/tick-occlusion-light-800w.webp new file mode 100644 index 0000000000..da1539a1ce Binary files /dev/null and b/docs/static/screenshots/Axis/tick-occlusion-light-800w.webp differ diff --git a/docs/static/screenshots/Axis/tick-occlusion-priority-dark-240w.webp b/docs/static/screenshots/Axis/tick-occlusion-priority-dark-240w.webp new file mode 100644 index 0000000000..540e761e49 Binary files /dev/null and b/docs/static/screenshots/Axis/tick-occlusion-priority-dark-240w.webp differ diff --git a/docs/static/screenshots/Axis/tick-occlusion-priority-dark-400w.webp b/docs/static/screenshots/Axis/tick-occlusion-priority-dark-400w.webp new file mode 100644 index 0000000000..8aabe571c3 Binary files /dev/null and b/docs/static/screenshots/Axis/tick-occlusion-priority-dark-400w.webp differ diff --git a/docs/static/screenshots/Axis/tick-occlusion-priority-dark-800w.webp b/docs/static/screenshots/Axis/tick-occlusion-priority-dark-800w.webp new file mode 100644 index 0000000000..332f1821bf Binary files /dev/null and b/docs/static/screenshots/Axis/tick-occlusion-priority-dark-800w.webp differ diff --git a/docs/static/screenshots/Axis/tick-occlusion-priority-light-240w.webp b/docs/static/screenshots/Axis/tick-occlusion-priority-light-240w.webp new file mode 100644 index 0000000000..8177b5d502 Binary files /dev/null and b/docs/static/screenshots/Axis/tick-occlusion-priority-light-240w.webp differ diff --git a/docs/static/screenshots/Axis/tick-occlusion-priority-light-400w.webp b/docs/static/screenshots/Axis/tick-occlusion-priority-light-400w.webp new file mode 100644 index 0000000000..6645554618 Binary files /dev/null and b/docs/static/screenshots/Axis/tick-occlusion-priority-light-400w.webp differ diff --git a/docs/static/screenshots/Axis/tick-occlusion-priority-light-800w.webp b/docs/static/screenshots/Axis/tick-occlusion-priority-light-800w.webp new file mode 100644 index 0000000000..2ce1ac3999 Binary files /dev/null and b/docs/static/screenshots/Axis/tick-occlusion-priority-light-800w.webp differ diff --git a/docs/static/screenshots/Axis/tick-occlusion-rotated-dark-240w.webp b/docs/static/screenshots/Axis/tick-occlusion-rotated-dark-240w.webp new file mode 100644 index 0000000000..ab5cb32507 Binary files /dev/null and b/docs/static/screenshots/Axis/tick-occlusion-rotated-dark-240w.webp differ diff --git a/docs/static/screenshots/Axis/tick-occlusion-rotated-dark-400w.webp b/docs/static/screenshots/Axis/tick-occlusion-rotated-dark-400w.webp new file mode 100644 index 0000000000..f2f31792bf Binary files /dev/null and b/docs/static/screenshots/Axis/tick-occlusion-rotated-dark-400w.webp differ diff --git a/docs/static/screenshots/Axis/tick-occlusion-rotated-dark-800w.webp b/docs/static/screenshots/Axis/tick-occlusion-rotated-dark-800w.webp new file mode 100644 index 0000000000..0dc5a9653c Binary files /dev/null and b/docs/static/screenshots/Axis/tick-occlusion-rotated-dark-800w.webp differ diff --git a/docs/static/screenshots/Axis/tick-occlusion-rotated-light-240w.webp b/docs/static/screenshots/Axis/tick-occlusion-rotated-light-240w.webp new file mode 100644 index 0000000000..3bc06e86d6 Binary files /dev/null and b/docs/static/screenshots/Axis/tick-occlusion-rotated-light-240w.webp differ diff --git a/docs/static/screenshots/Axis/tick-occlusion-rotated-light-400w.webp b/docs/static/screenshots/Axis/tick-occlusion-rotated-light-400w.webp new file mode 100644 index 0000000000..bf6a9ea910 Binary files /dev/null and b/docs/static/screenshots/Axis/tick-occlusion-rotated-light-400w.webp differ diff --git a/docs/static/screenshots/Axis/tick-occlusion-rotated-light-800w.webp b/docs/static/screenshots/Axis/tick-occlusion-rotated-light-800w.webp new file mode 100644 index 0000000000..be967839d3 Binary files /dev/null and b/docs/static/screenshots/Axis/tick-occlusion-rotated-light-800w.webp differ diff --git a/docs/static/screenshots/Axis/tick-occlusion-vs-tick-spacing-dark-240w.webp b/docs/static/screenshots/Axis/tick-occlusion-vs-tick-spacing-dark-240w.webp new file mode 100644 index 0000000000..e5f7e4299c Binary files /dev/null and b/docs/static/screenshots/Axis/tick-occlusion-vs-tick-spacing-dark-240w.webp differ diff --git a/docs/static/screenshots/Axis/tick-occlusion-vs-tick-spacing-dark-400w.webp b/docs/static/screenshots/Axis/tick-occlusion-vs-tick-spacing-dark-400w.webp new file mode 100644 index 0000000000..c759b5dbab Binary files /dev/null and b/docs/static/screenshots/Axis/tick-occlusion-vs-tick-spacing-dark-400w.webp differ diff --git a/docs/static/screenshots/Axis/tick-occlusion-vs-tick-spacing-dark-800w.webp b/docs/static/screenshots/Axis/tick-occlusion-vs-tick-spacing-dark-800w.webp new file mode 100644 index 0000000000..6577b5c137 Binary files /dev/null and b/docs/static/screenshots/Axis/tick-occlusion-vs-tick-spacing-dark-800w.webp differ diff --git a/docs/static/screenshots/Axis/tick-occlusion-vs-tick-spacing-light-240w.webp b/docs/static/screenshots/Axis/tick-occlusion-vs-tick-spacing-light-240w.webp new file mode 100644 index 0000000000..cbf9874c5d Binary files /dev/null and b/docs/static/screenshots/Axis/tick-occlusion-vs-tick-spacing-light-240w.webp differ diff --git a/docs/static/screenshots/Axis/tick-occlusion-vs-tick-spacing-light-400w.webp b/docs/static/screenshots/Axis/tick-occlusion-vs-tick-spacing-light-400w.webp new file mode 100644 index 0000000000..890b7cecf5 Binary files /dev/null and b/docs/static/screenshots/Axis/tick-occlusion-vs-tick-spacing-light-400w.webp differ diff --git a/docs/static/screenshots/Axis/tick-occlusion-vs-tick-spacing-light-800w.webp b/docs/static/screenshots/Axis/tick-occlusion-vs-tick-spacing-light-800w.webp new file mode 100644 index 0000000000..5a50bee679 Binary files /dev/null and b/docs/static/screenshots/Axis/tick-occlusion-vs-tick-spacing-light-800w.webp differ diff --git a/docs/static/screenshots/BrushContext/synced-brushes-dark-240w.webp b/docs/static/screenshots/BrushContext/synced-brushes-dark-240w.webp index 9c911bea16..22f73963b0 100644 Binary files a/docs/static/screenshots/BrushContext/synced-brushes-dark-240w.webp and b/docs/static/screenshots/BrushContext/synced-brushes-dark-240w.webp differ diff --git a/docs/static/screenshots/BrushContext/synced-brushes-dark-400w.webp b/docs/static/screenshots/BrushContext/synced-brushes-dark-400w.webp index 2a12202923..7c799e038e 100644 Binary files a/docs/static/screenshots/BrushContext/synced-brushes-dark-400w.webp and b/docs/static/screenshots/BrushContext/synced-brushes-dark-400w.webp differ diff --git a/docs/static/screenshots/BrushContext/synced-brushes-dark-800w.webp b/docs/static/screenshots/BrushContext/synced-brushes-dark-800w.webp index 01e4ad6342..2da3a6794e 100644 Binary files a/docs/static/screenshots/BrushContext/synced-brushes-dark-800w.webp and b/docs/static/screenshots/BrushContext/synced-brushes-dark-800w.webp differ diff --git a/docs/static/screenshots/BrushContext/synced-brushes-light-240w.webp b/docs/static/screenshots/BrushContext/synced-brushes-light-240w.webp index 1e79fa20df..1f4edb2ffd 100644 Binary files a/docs/static/screenshots/BrushContext/synced-brushes-light-240w.webp and b/docs/static/screenshots/BrushContext/synced-brushes-light-240w.webp differ diff --git a/docs/static/screenshots/BrushContext/synced-brushes-light-400w.webp b/docs/static/screenshots/BrushContext/synced-brushes-light-400w.webp index dbe1c7a359..4eeb76720a 100644 Binary files a/docs/static/screenshots/BrushContext/synced-brushes-light-400w.webp and b/docs/static/screenshots/BrushContext/synced-brushes-light-400w.webp differ diff --git a/docs/static/screenshots/BrushContext/synced-brushes-light-800w.webp b/docs/static/screenshots/BrushContext/synced-brushes-light-800w.webp index 4b01bbb1c4..8c490370bf 100644 Binary files a/docs/static/screenshots/BrushContext/synced-brushes-light-800w.webp and b/docs/static/screenshots/BrushContext/synced-brushes-light-800w.webp differ diff --git a/docs/static/screenshots/ChartGroup/synced-brushes-dark-240w.webp b/docs/static/screenshots/ChartGroup/synced-brushes-dark-240w.webp index 9036b9d4d2..aef32f9af3 100644 Binary files a/docs/static/screenshots/ChartGroup/synced-brushes-dark-240w.webp and b/docs/static/screenshots/ChartGroup/synced-brushes-dark-240w.webp differ diff --git a/docs/static/screenshots/ChartGroup/synced-brushes-dark-400w.webp b/docs/static/screenshots/ChartGroup/synced-brushes-dark-400w.webp index a5cd5146c7..61b1b04908 100644 Binary files a/docs/static/screenshots/ChartGroup/synced-brushes-dark-400w.webp and b/docs/static/screenshots/ChartGroup/synced-brushes-dark-400w.webp differ diff --git a/docs/static/screenshots/ChartGroup/synced-brushes-dark-800w.webp b/docs/static/screenshots/ChartGroup/synced-brushes-dark-800w.webp index f8946e58a1..f53dec85d6 100644 Binary files a/docs/static/screenshots/ChartGroup/synced-brushes-dark-800w.webp and b/docs/static/screenshots/ChartGroup/synced-brushes-dark-800w.webp differ diff --git a/docs/static/screenshots/ChartGroup/synced-brushes-light-240w.webp b/docs/static/screenshots/ChartGroup/synced-brushes-light-240w.webp index 2dacf3edfa..f8e5e65117 100644 Binary files a/docs/static/screenshots/ChartGroup/synced-brushes-light-240w.webp and b/docs/static/screenshots/ChartGroup/synced-brushes-light-240w.webp differ diff --git a/docs/static/screenshots/ChartGroup/synced-brushes-light-400w.webp b/docs/static/screenshots/ChartGroup/synced-brushes-light-400w.webp index db78b161e4..6728639308 100644 Binary files a/docs/static/screenshots/ChartGroup/synced-brushes-light-400w.webp and b/docs/static/screenshots/ChartGroup/synced-brushes-light-400w.webp differ diff --git a/docs/static/screenshots/ChartGroup/synced-brushes-light-800w.webp b/docs/static/screenshots/ChartGroup/synced-brushes-light-800w.webp index ca40a6941d..36f7961825 100644 Binary files a/docs/static/screenshots/ChartGroup/synced-brushes-light-800w.webp and b/docs/static/screenshots/ChartGroup/synced-brushes-light-800w.webp differ diff --git a/docs/static/screenshots/index.json b/docs/static/screenshots/index.json index e9a0166c05..c85ddb8342 100644 --- a/docs/static/screenshots/index.json +++ b/docs/static/screenshots/index.json @@ -1,7 +1,7 @@ { - "generatedAt": "2026-08-23T18:54:59.937Z", + "generatedAt": "2026-09-03T20:01:04.033Z", "totalComponents": 80, - "totalExamples": 982, + "totalExamples": 990, "components": [ { "component": "AnnotationLine", @@ -361,6 +361,11 @@ "path": "/example/Area/explicit-axis-ticks-min-max", "checksum": "fd7137092a61faa36d965a8f62307987" }, + { + "name": "fade-mask", + "path": "/example/Area/fade-mask", + "checksum": "4b4dee345976ed3d2ae6f510c3a480a8" + }, { "name": "gradient", "path": "/example/Area/gradient", @@ -391,6 +396,11 @@ "path": "/example/Area/multiple-series-highlight-on-hover", "checksum": "7803a5a1ed70d70cdba18309491ae727" }, + { + "name": "multiple-series-tweened", + "path": "/example/Area/multiple-series-tweened", + "checksum": "a03de351686765ce2126c29b25e37674" + }, { "name": "multiple-series-using-overrides", "path": "/example/Area/multiple-series-using-overrides", @@ -714,7 +724,7 @@ { "name": "barchart-tickspacing", "path": "/example/Axis/barchart-tickspacing", - "checksum": "9a654f6fda830f6a8a2bb35621b64a93" + "checksum": "34e8b7d93bc5f8b0960d1700a3830e3a" }, { "name": "barchart-xinterval-xinset", @@ -774,7 +784,7 @@ { "name": "linechart-tickspacing", "path": "/example/Axis/linechart-tickspacing", - "checksum": "b6942a3a773b12fa00d4ff20f15d9ed2" + "checksum": "6c805788a037881afb9cc2d9183094ac" }, { "name": "log-scale", @@ -866,6 +876,31 @@ "path": "/example/Axis/tick-label-styling", "checksum": "0792f03cca1d6671235eab0da9770be5" }, + { + "name": "tick-occlusion", + "path": "/example/Axis/tick-occlusion", + "checksum": "5031b26a62dd4d77422255f80876d7d6" + }, + { + "name": "tick-occlusion-explicit-ticks", + "path": "/example/Axis/tick-occlusion-explicit-ticks", + "checksum": "d2c6490ee292134100c9e9cbc7c563e9" + }, + { + "name": "tick-occlusion-priority", + "path": "/example/Axis/tick-occlusion-priority", + "checksum": "c9b20bd4b09a6ff5ffdec46393892766" + }, + { + "name": "tick-occlusion-rotated", + "path": "/example/Axis/tick-occlusion-rotated", + "checksum": "ecb0ea33d14830eb382d28a8095d019d" + }, + { + "name": "tick-occlusion-vs-tick-spacing", + "path": "/example/Axis/tick-occlusion-vs-tick-spacing", + "checksum": "d65535723ae907998be5192211a80255" + }, { "name": "tick-spacing", "path": "/example/Axis/tick-spacing", @@ -1979,7 +2014,7 @@ { "name": "synced-brushes", "path": "/example/BrushContext/synced-brushes", - "checksum": "4437f58eddef3689dc1a7ecbf5badadf" + "checksum": "709efa62cf3aa2b7f2bf6aff82bb1415" }, { "name": "tooltip-interop", @@ -2199,7 +2234,7 @@ { "name": "facet-tooltip", "path": "/example/Chart/facet-tooltip", - "checksum": "60477e105a22f7c2b4ba39c441b27813" + "checksum": "357c62b9fbfb0723f457691c83661de2" }, { "name": "facet-two-dimensional", @@ -2269,7 +2304,7 @@ { "name": "synced-brushes", "path": "/example/ChartGroup/synced-brushes", - "checksum": "65e8b1d5ec4291c7f1865ac499dda55e" + "checksum": "a2c7f2bb9dec9cda6c3e388168e01914" }, { "name": "synced-legend", @@ -4786,6 +4821,11 @@ "path": "/example/Spline/multiple-series-highlight-on-hover", "checksum": "403a7d99d8180c6296f7d9a6e28d303e" }, + { + "name": "multiple-series-tweened", + "path": "/example/Spline/multiple-series-tweened", + "checksum": "" + }, { "name": "multiple-series-using-overrides", "path": "/example/Spline/multiple-series-using-overrides", diff --git a/packages/layerchart/src/lib/components/Arc/Arc.svelte.test.ts b/packages/layerchart/src/lib/components/Arc/Arc.svelte.test.ts index 5457b3fc5f..171241056b 100644 --- a/packages/layerchart/src/lib/components/Arc/Arc.svelte.test.ts +++ b/packages/layerchart/src/lib/components/Arc/Arc.svelte.test.ts @@ -3,7 +3,7 @@ import { render } from 'vitest-browser-svelte'; import { page, type Locator } from 'vitest/browser'; import type { ComponentProps } from 'svelte'; -import TestHarness, { componentTestId } from '../tests/TestHarness.svelte'; +import TestHarness, { componentTestId } from '$lib/tests/TestHarness.svelte'; import Arc from './Arc.svelte'; import Text from '../Text/Text.svelte'; import type { ChartState } from '$lib/states/chart.svelte.js'; diff --git a/packages/layerchart/src/lib/components/ArcLabel/ArcLabel.svelte.test.ts b/packages/layerchart/src/lib/components/ArcLabel/ArcLabel.svelte.test.ts index 72c8b37d02..158d812c08 100644 --- a/packages/layerchart/src/lib/components/ArcLabel/ArcLabel.svelte.test.ts +++ b/packages/layerchart/src/lib/components/ArcLabel/ArcLabel.svelte.test.ts @@ -3,7 +3,7 @@ import { render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import type { ComponentProps } from 'svelte'; -import TestHarness, { componentTestId } from '../tests/TestHarness.svelte'; +import TestHarness, { componentTestId } from '$lib/tests/TestHarness.svelte'; import Arc from '../Arc/Arc.svelte'; import ArcLabel from './ArcLabel.svelte'; diff --git a/packages/layerchart/src/lib/components/Area/Area.base.svelte b/packages/layerchart/src/lib/components/Area/Area.base.svelte index 8ae87d70b1..36d38fa172 100644 --- a/packages/layerchart/src/lib/components/Area/Area.base.svelte +++ b/packages/layerchart/src/lib/components/Area/Area.base.svelte @@ -56,6 +56,21 @@ seriesKey, }) as AreaProps ); + + /** + * The props that describe the mark as a whole rather than one of its paths. + * + * An `` draws two paths — the filled body and the top curve — so anything that + * clips or filters "the area" has to reach both, or a reveal mask wipes the fill in while + * the curve appears instantly. Only these are forwarded: `id` and event handlers stay on + * the fill path alone, so nothing is duplicated and the pointer fires once. Whatever + * `line={{ ... }}` sets wins, since it is spread after. + */ + const sharedLineProps = $derived({ + mask: restProps.mask, + filter: restProps.filter, + 'clip-path': restProps['clip-path'], + }); {#if c.areas} @@ -70,6 +85,8 @@ {curve} {defined} stroke={area.fill} + {motion} + {...sharedLineProps} {...extractLayerProps(line, 'lc-area-line')} /> {/if} @@ -92,6 +109,7 @@ {curve} {defined} {motion} + {...sharedLineProps} {...extractLayerProps(line, 'lc-area-line')} /> {/if} diff --git a/packages/layerchart/src/lib/components/Area/Area.line-props.svelte.test.ts b/packages/layerchart/src/lib/components/Area/Area.line-props.svelte.test.ts new file mode 100644 index 0000000000..62f3bc3287 --- /dev/null +++ b/packages/layerchart/src/lib/components/Area/Area.line-props.svelte.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; + +import TestHarness from '$lib/tests/TestHarness.svelte'; +import Area from './Area.svelte'; + +const data = [ + { date: new Date('2024-01-01'), value: 10, group: 'a' }, + { date: new Date('2024-02-01'), value: 30, group: 'a' }, + { date: new Date('2024-03-01'), value: 20, group: 'a' }, + { date: new Date('2024-01-01'), value: 50, group: 'b' }, + { date: new Date('2024-02-01'), value: 40, group: 'b' }, + { date: new Date('2024-03-01'), value: 60, group: 'b' }, +]; + +const chartProps = (extra: Record = {}) => ({ + data, + x: 'date', + y: 'value', + yDomain: [0, 100], + ...extra, +}); + +const linePath = () => document.querySelector('.lc-area-line'); +const fillPath = () => document.querySelector('.lc-area-path'); + +describe('Area line prop forwarding', () => { + // These query the document, so a leftover chart from the previous test would answer first + beforeEach(cleanup); + + it('applies `mask` to the line as well as the fill', async () => { + render(TestHarness, { + chartProps: chartProps(), + component: Area, + componentProps: { line: true, mask: 'url(#reveal)' }, + }); + await expect.poll(() => linePath()).toBeTruthy(); + expect(fillPath()?.getAttribute('mask')).toBe('url(#reveal)'); + expect(linePath()?.getAttribute('mask')).toBe('url(#reveal)'); + }); + + it('applies `filter` and `clip-path` to both paths', async () => { + render(TestHarness, { + chartProps: chartProps(), + component: Area, + componentProps: { line: true, filter: 'url(#glow)', 'clip-path': 'url(#plot)' }, + }); + await expect.poll(() => linePath()).toBeTruthy(); + expect(linePath()?.getAttribute('filter')).toBe('url(#glow)'); + expect(linePath()?.getAttribute('clip-path')).toBe('url(#plot)'); + }); + + it('lets `line` override a forwarded value', async () => { + render(TestHarness, { + chartProps: chartProps(), + component: Area, + componentProps: { line: { mask: 'url(#line-only)' }, mask: 'url(#reveal)' }, + }); + await expect.poll(() => linePath()).toBeTruthy(); + expect(linePath()?.getAttribute('mask')).toBe('url(#line-only)'); + expect(fillPath()?.getAttribute('mask')).toBe('url(#reveal)'); + }); + + it('keeps `id` and click handlers on the fill path only', async () => { + let clicks = 0; + render(TestHarness, { + chartProps: chartProps(), + component: Area, + componentProps: { line: true, id: 'the-area', onclick: () => clicks++ }, + }); + await expect.poll(() => linePath()).toBeTruthy(); + expect(fillPath()?.getAttribute('id')).toBe('the-area'); + expect(linePath()?.getAttribute('id')).toBe(null); + + (fillPath() as SVGPathElement).dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(clicks).toBe(1); + }); + + it('forwards to every line when grouped by `z`', async () => { + render(TestHarness, { + chartProps: chartProps({ z: 'group' }), + component: Area, + componentProps: { line: true, mask: 'url(#reveal)' }, + }); + await expect.poll(() => document.querySelectorAll('.lc-area-line').length).toBe(2); + for (const line of document.querySelectorAll('.lc-area-line')) { + expect(line.getAttribute('mask')).toBe('url(#reveal)'); + } + }); +}); diff --git a/packages/layerchart/src/lib/components/Area/Area.shared.svelte.ts b/packages/layerchart/src/lib/components/Area/Area.shared.svelte.ts index 9117b9f082..12bb41d79c 100644 --- a/packages/layerchart/src/lib/components/Area/Area.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/Area/Area.shared.svelte.ts @@ -1,4 +1,4 @@ -import type { ComponentProps } from 'svelte'; +import { untrack, type ComponentProps } from 'svelte'; import type { SVGAttributes } from 'svelte/elements'; import { type Area as D3Area, area as d3Area, areaRadial, type CurveFactory } from 'd3-shape'; import { group as d3Group, min } from 'd3-array'; @@ -17,6 +17,7 @@ import { isScaleBand } from '$lib/utils/scales.svelte.js'; import { flattenPathData } from '$lib/utils/path.js'; import { createMotion, + createPathMotionMap, extractTweenConfig, type MotionProp, type ResolvedMotion, @@ -87,6 +88,9 @@ export class AreaState { #tweenState!: ReturnType>; + /** One tween per `z` group, for the grouped branch that draws an area each */ + #areaTweens: ReturnType = null; + constructor(getProps: () => AreaProps) { this.#getProps = getProps; @@ -120,6 +124,27 @@ export class AreaState { () => this.d, tweenOptions ); + + // `#tweenState` animates the single-path case; grouped areas each need their own, since the + // set of them changes with the data. + this.#areaTweens = createPathMotionMap(initial.motion, interpolatePath); + if (this.#areaTweens) { + const tweens = this.#areaTweens; + $effect(() => { + const targets = this.#areaTargets; + if (!targets) return; + + const active = new Set(); + for (const area of targets) { + active.add(area.key); + // `update` reads and writes the tween's own state, so it must not be tracked here + untrack(() => + tweens.update(area.key, area.d, () => this.#defaultPathData(tweenOptions, area.data)) + ); + } + untrack(() => tweens.cleanup(active)); + }); + } } series = $derived(this.ctx.series.series.find((s) => s.key === this.#props.seriesKey)); @@ -194,14 +219,19 @@ export class AreaState { /** * One entry per area, with its own path and styles — or `null` when there's no grouping, which - * leaves the single-path (and tweenable) branch in place. + * leaves the single-path branch in place. + * + * Before motion: kept separate from `areas` so the effect driving the tweens can read the + * targets without reading the tweens' own output, which would be a cycle. */ - areas = $derived.by(() => { + #areaTargets = $derived.by(() => { if (!this.zAccessor || this.#props.pathData) return null; const props = this.#props; + const zAccessor = this.zAccessor; - return Array.from(d3Group(this.resolvedData, this.zAccessor).values()).map((data) => ({ + return Array.from(d3Group(this.resolvedData, zAccessor).values()).map((data) => ({ data, + key: zAccessor(data[0]), d: this.#buildPath(data), // Styles are uniform across an area, so they resolve from its first point fill: @@ -214,6 +244,15 @@ export class AreaState { })); }); + /** `#areaTargets` with each group's path swapped for its in-flight tween */ + areas = $derived.by(() => { + const targets = this.#areaTargets; + const tweens = this.#areaTweens; + if (!targets || !tweens) return targets; + + return targets.map((area) => ({ ...area, d: tweens.get(area.key) ?? area.d })); + }); + /** `fill` / `stroke` / `class` for the ungrouped case, resolved the same way */ resolvedFill = $derived( resolveColorProp(this.#props.fill, this.resolvedData?.[0], this.ctx.cScale) ?? @@ -232,7 +271,8 @@ export class AreaState { xOffset = $derived(isScaleBand(this.ctx.xScale) ? this.ctx.xScale.bandwidth() / 2 : 0); yOffset = $derived(isScaleBand(this.ctx.yScale) ? this.ctx.yScale.bandwidth() / 2 : 0); - #defaultPathData(tweenOptions: ResolvedMotion | undefined): string { + /** The area flattened to the baseline — what a group tweens out of when it first appears */ + #defaultPathData(tweenOptions: ResolvedMotion | undefined, data?: any[]): string { const props = this.#props; if (!tweenOptions) return ''; if (props.pathData) { @@ -254,7 +294,7 @@ export class AreaState { ); if (props.curve) path.curve(props.curve); - return path(this.resolvedData) ?? ''; + return path(data ?? this.resolvedData) ?? ''; } return ''; } diff --git a/packages/layerchart/src/lib/components/Area/Area.svelte.test.ts b/packages/layerchart/src/lib/components/Area/Area.svelte.test.ts index 4ea2c8f24e..65f17f6c08 100644 --- a/packages/layerchart/src/lib/components/Area/Area.svelte.test.ts +++ b/packages/layerchart/src/lib/components/Area/Area.svelte.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { render } from 'vitest-browser-svelte'; -import TestHarness from '../tests/TestHarness.svelte'; +import TestHarness from '$lib/tests/TestHarness.svelte'; import Area from './Area.svelte'; /** Two series interleaved in one flat array, as long-format data arrives */ diff --git a/packages/layerchart/src/lib/components/Area/grouped-motion.svelte.test.ts b/packages/layerchart/src/lib/components/Area/grouped-motion.svelte.test.ts new file mode 100644 index 0000000000..aa6035e13b --- /dev/null +++ b/packages/layerchart/src/lib/components/Area/grouped-motion.svelte.test.ts @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; + +import TestHarness from '$lib/tests/TestHarness.svelte'; +import Area from './Area.svelte'; +import Spline from '../Spline/Spline.svelte'; + +/** + * A mark grouped by `z` draws one path per group, and those paths used to be built straight from + * the data on every change — `Spline` renders `seg.d` and `Area` renders `area.d`, neither of + * which consulted the mark's single `tweenedPath`. So `motion` animated an ungrouped mark and + * did nothing at all once `z` (or the chart's `c`) split it. + * + * A tween caught between two shapes is the observable: sampling `d` every frame during a data + * change gives one value if the path snapped, and a spread of them if it animated. + */ + +const data = [ + { date: new Date('2024-01-01'), value: 10, group: 'a' }, + { date: new Date('2024-02-01'), value: 30, group: 'a' }, + { date: new Date('2024-03-01'), value: 20, group: 'a' }, + { date: new Date('2024-01-01'), value: 50, group: 'b' }, + { date: new Date('2024-02-01'), value: 40, group: 'b' }, + { date: new Date('2024-03-01'), value: 60, group: 'b' }, +]; + +/** Same rows and groups, different values — enough movement to be visible mid-flight */ +const movedData = data.map((d) => ({ ...d, value: 100 - d.value })); + +const motion = { type: 'tween' as const, duration: 400 }; + +const frame = () => new Promise((r) => requestAnimationFrame(() => r(null))); +const settle = () => new Promise((r) => setTimeout(r, 700)); + +/** + * Render, let the mount animation finish, then swap the data and watch one path for 12 frames. + * `selector` picks which of a grouped mark's paths to follow. + */ +async function sampleDuringDataChange(options: { + component: any; + componentProps?: Record; + chartExtra?: Record; + selector: string; +}) { + const { component, componentProps = {}, chartExtra = {}, selector } = options; + const chart = { x: 'date', y: 'value', yDomain: [0, 100], ...chartExtra }; + + const props = { + chartProps: { data, ...chart }, + component, + componentProps: { motion, ...componentProps }, + }; + + const { rerender } = render(TestHarness, props); + await expect.poll(() => document.querySelector(selector)).toBeTruthy(); + await settle(); + + const d = () => document.querySelector(selector)?.getAttribute('d') ?? ''; + const before = d(); + + await rerender({ ...props, chartProps: { data: movedData, ...chart } }); + + const frames: string[] = []; + for (let i = 0; i < 12; i++) { + frames.push(d()); + await frame(); + } + await settle(); + + return { + /** A tween starts where the old path was; a snap is already at the destination */ + startedFromPreviousPath: frames[0] === before, + distinctFrames: new Set(frames).size, + reachedNewPath: d() !== before, + }; +} + +describe('marks grouped by `z` animate their paths', () => { + beforeEach(cleanup); + + it('tweens each line of a grouped `Spline`', async () => { + const result = await sampleDuringDataChange({ + component: Spline, + chartExtra: { z: 'group' }, + selector: 'path', + }); + + expect(result.startedFromPreviousPath).toBe(true); + expect(result.distinctFrames).toBeGreaterThan(1); + expect(result.reachedNewPath).toBe(true); + }); + + it("tweens each group's fill in a grouped `Area`", async () => { + const result = await sampleDuringDataChange({ + component: Area, + chartExtra: { z: 'group' }, + selector: '.lc-area-path', + }); + + expect(result.startedFromPreviousPath).toBe(true); + expect(result.distinctFrames).toBeGreaterThan(1); + expect(result.reachedNewPath).toBe(true); + }); + + it("tweens each group's line in a grouped `Area`", async () => { + const result = await sampleDuringDataChange({ + component: Area, + componentProps: { line: true }, + chartExtra: { z: 'group' }, + selector: '.lc-area-line', + }); + + expect(result.startedFromPreviousPath).toBe(true); + expect(result.distinctFrames).toBeGreaterThan(1); + expect(result.reachedNewPath).toBe(true); + }); + + it('still tweens when ungrouped, the case that already worked', async () => { + const result = await sampleDuringDataChange({ + component: Area, + componentProps: { line: true }, + selector: '.lc-area-path', + }); + + expect(result.startedFromPreviousPath).toBe(true); + expect(result.distinctFrames).toBeGreaterThan(1); + }); + + it('leaves grouped paths alone without `motion`', async () => { + const result = await sampleDuringDataChange({ + component: Area, + componentProps: { motion: undefined }, + chartExtra: { z: 'group' }, + selector: '.lc-area-path', + }); + + expect(result.distinctFrames).toBe(1); + expect(result.startedFromPreviousPath).toBe(false); + expect(result.reachedNewPath).toBe(true); + }); +}); diff --git a/packages/layerchart/src/lib/components/Axis/Axis.base.svelte b/packages/layerchart/src/lib/components/Axis/Axis.base.svelte index 63932d27bc..f0577b71c7 100644 --- a/packages/layerchart/src/lib/components/Axis/Axis.base.svelte +++ b/packages/layerchart/src/lib/components/Axis/Axis.base.svelte @@ -40,6 +40,7 @@ grid = false, ticks, tickSpacing, + tickOcclusion = false, tickMultiline = false, tickLength = 4, tickMarks = true, @@ -69,6 +70,7 @@ grid, ticks, tickSpacing, + tickOcclusion, tickMultiline, tickLength, tickMarks, diff --git a/packages/layerchart/src/lib/components/Axis/Axis.occlusion.svelte.test.ts b/packages/layerchart/src/lib/components/Axis/Axis.occlusion.svelte.test.ts new file mode 100644 index 0000000000..b341cd89af --- /dev/null +++ b/packages/layerchart/src/lib/components/Axis/Axis.occlusion.svelte.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import { scaleBand } from 'd3-scale'; + +import TestHarness from '$lib/tests/TestHarness.svelte'; +import Axis from './Axis.svelte'; + +/** Long category names, so the labels collide well before the ticks do */ +const data = [ + 'Wednesday the 1st', + 'Wednesday the 2nd', + 'Wednesday the 3rd', + 'Wednesday the 4th', + 'Wednesday the 5th', + 'Wednesday the 6th', +].map((day, i) => ({ day, value: i + 1 })); + +const chartProps = { + data, + x: 'day', + xScale: scaleBand(), + y: 'value', + width: 400, + height: 200, + padding: { bottom: 24, left: 24 }, +}; + +function labels() { + return Array.from(document.querySelectorAll('.lc-axis-tick-label')).map( + (el) => el.textContent?.trim() ?? '' + ); +} + +describe('Axis tickOcclusion', () => { + it('draws every colliding label when not enabled', async () => { + render(TestHarness, { + chartProps, + component: Axis, + componentProps: { placement: 'bottom' }, + }); + await expect.poll(() => labels().length).toBe(data.length); + }); + + it('drops labels that would overlap one already kept', async () => { + render(TestHarness, { + chartProps, + component: Axis, + componentProps: { placement: 'bottom', tickOcclusion: true }, + }); + await expect.poll(() => labels().length).toBeLessThan(data.length); + expect(labels().length).toBeGreaterThan(0); + }); + + it('keeps the last tick with the default `end` priority', async () => { + render(TestHarness, { + chartProps, + component: Axis, + componentProps: { placement: 'bottom', tickOcclusion: true }, + }); + await expect.poll(() => labels().length).toBeLessThan(data.length); + expect(labels().at(-1)).toBe(data.at(-1)!.day); + }); + + it('keeps the first tick with `start` priority', async () => { + render(TestHarness, { + chartProps, + component: Axis, + componentProps: { placement: 'bottom', tickOcclusion: { priority: 'start' } }, + }); + await expect.poll(() => labels().length).toBeLessThan(data.length); + expect(labels()[0]).toBe(data[0].day); + }); + + it('drops more labels as `padding` grows', async () => { + const { unmount } = render(TestHarness, { + chartProps, + component: Axis, + componentProps: { placement: 'bottom', tickOcclusion: { padding: 0 } }, + }); + await expect.poll(() => labels().length).toBeGreaterThan(0); + const tight = labels().length; + unmount(); + + render(TestHarness, { + chartProps, + component: Axis, + componentProps: { placement: 'bottom', tickOcclusion: { padding: 80 } }, + }); + await expect.poll(() => labels().length).toBeGreaterThan(0); + expect(labels().length).toBeLessThan(tight); + }); + + it('returns the kept labels in axis order', async () => { + render(TestHarness, { + chartProps, + component: Axis, + componentProps: { placement: 'bottom', tickOcclusion: true }, + }); + await expect.poll(() => labels().length).toBeLessThan(data.length); + const kept = labels(); + const order = kept.map((label) => data.findIndex((d) => d.day === label)); + expect(order).toEqual([...order].sort((a, b) => a - b)); + }); + + it('keeps both ends with `start-end` priority', async () => { + render(TestHarness, { + chartProps, + component: Axis, + componentProps: { placement: 'bottom', tickOcclusion: { priority: 'start-end' } }, + }); + await expect.poll(() => labels().length).toBeLessThan(data.length); + expect(labels()[0]).toBe(data[0].day); + expect(labels().at(-1)).toBe(data.at(-1)!.day); + }); + + it('drops the first tick under the default `end` priority', async () => { + // The contrast `start-end` exists for: greedy packing from the end runs out of room before + // it reaches the leading tick, so the axis loses the value it starts at. + render(TestHarness, { + chartProps, + component: Axis, + componentProps: { placement: 'bottom', tickOcclusion: { padding: 40 } }, + }); + await expect.poll(() => labels().length).toBeLessThan(data.length); + expect(labels()[0]).not.toBe(data[0].day); + }); + + it('measures rotated labels at their rotated size', async () => { + // Angled labels need far less horizontal room, so more of them survive than when flat. + const { unmount } = render(TestHarness, { + chartProps, + component: Axis, + componentProps: { placement: 'bottom', tickOcclusion: true }, + }); + await expect.poll(() => labels().length).toBeGreaterThan(0); + const flat = labels().length; + unmount(); + + render(TestHarness, { + chartProps, + component: Axis, + componentProps: { + placement: 'bottom', + tickOcclusion: true, + tickLabelProps: { rotate: -60, textAnchor: 'end' }, + }, + }); + await expect.poll(() => labels().length).toBeGreaterThan(flat); + }); +}); diff --git a/packages/layerchart/src/lib/components/Axis/Axis.shared.svelte.ts b/packages/layerchart/src/lib/components/Axis/Axis.shared.svelte.ts index cf120b4a3f..338c657824 100644 --- a/packages/layerchart/src/lib/components/Axis/Axis.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/Axis/Axis.shared.svelte.ts @@ -12,6 +12,8 @@ import type { GroupProps } from '../Group/Group.shared.svelte.js'; import type { TextProps } from '../Text/Text.shared.svelte.js'; import type Rule from '../Rule/Rule.svelte'; import { isScaleBand, isScaleUtc } from '$lib/utils/scales.svelte.js'; +import { occlude } from '$lib/utils/occlusion.js'; +import { getTextRect } from '$lib/utils/string.js'; import { getChartContext } from '$lib/contexts/chart.js'; import { getFacetPanel } from '$lib/contexts/facet.js'; import type { ChartState } from '$lib/states/chart.svelte.js'; @@ -79,6 +81,23 @@ export type AxisPropsWithoutHTML = { */ tickSpacing?: number | null; + /** + * Drop ticks whose labels would overlap one already kept, measuring each label at the size + * it actually renders. + * + * `tickSpacing` only picks a tick *count* — it cannot know how wide a formatted label turns + * out to be, so a long format (`'Wednesday'`, `'$1,234,567'`) still collides at any count + * that fits the axis. This measures instead. + * + * - `priority`: which ticks win when labels compete. `'end'` (default) keeps the last tick and + * walks backwards (the newest value on a time axis); `'start'` keeps the first; `'start-end'` + * anchors both ends of the axis and thins what is between them. + * - `padding`: minimum gap, in pixels, required between two kept labels. + * + * @default false + */ + tickOcclusion?: boolean | { padding?: number; priority?: 'start' | 'end' | 'start-end' }; + /** * Whether to render tick labels on multiple lines for additional context * @@ -524,9 +543,68 @@ export class AxisState { } as TextProps; }); + /** + * Box each tick label occupies once rendered, for `tickOcclusion`. + * + * Measured with the same metrics `` draws with, anchored the way + * `getDefaultTickLabelProps()` anchors it. Rotation matters: an angled label is what makes + * long categories fit in the first place, and measuring it flat would keep dropping ticks that + * now have room. + */ + #tickLabelRect(labelProps: TextProps) { + const value = labelProps.value; + const lines = (Array.isArray(value) ? value : [value]).filter(Boolean).map(String); + + return getTextRect(lines, Number(labelProps.x) || 0, Number(labelProps.y) || 0, { + textAnchor: labelProps.textAnchor as 'start' | 'middle' | 'end', + verticalAnchor: labelProps.verticalAnchor as 'start' | 'middle' | 'end', + fontSize: Number.parseFloat(String(labelProps.fontSize ?? 10)) || 10, + dx: Number(labelProps.dx) || 0, + dy: Number(labelProps.dy) || 0, + rotate: Number(labelProps.rotate) || 0, + }); + } + + /** + * Ticks whose labels fit, in axis order. + * + * `occlude()` returns what it kept in *priority* order, so the result is put back into tick + * order before rendering — otherwise the `{#each}` key order would jump around on resize. + */ + #occludeTickItems(items: AxisTickItem[]): AxisTickItem[] { + const config = this.#props.tickOcclusion; + if (!config || items.length <= 1) return items; + + const { padding = 4, priority = 'end' } = config === true ? {} : config; + + const order = new Map(items.map((item, index) => [item, index])); + const last = items.length - 1; + + /** + * `occlude()` places the highest priority first, so ranking by index makes the last tick win + * (`'end'`) and negating it makes the first win (`'start'`). + * + * `'start-end'` lifts both ends above every interior tick — `items.length` clears the whole + * index range — and thins what is left between them from the end inwards. + */ + function rank(item: AxisTickItem) { + const index = order.get(item)!; + if (priority === 'start') return -index; + if (priority === 'start-end' && (index === 0 || index === last)) return items.length; + return index; + } + + const kept = occlude(items, (item) => this.#tickLabelRect(item.tickLabelProps), { + priority: rank, + padding, + }); + + return kept.sort((a, b) => order.get(a)! - order.get(b)!); + } + tickItems = $derived.by(() => { const { motion, stroke, fill, tickLabelProps, classes = {} } = this.#props; - return this.tickVals.map((tick, index) => { + const items = this.tickVals.map((tick, index) => { const tickCoords = this.getCoords(tick); const [radialTickCoordsX, radialTickCoordsY] = pointRadial(tickCoords.x, tickCoords.y); const [radialTickMarkCoordsX, radialTickMarkCoordsY] = pointRadial( @@ -559,5 +637,7 @@ export class AxisState { tickLabelProps: labelProps, }; }); + + return this.#occludeTickItems(items); }); } diff --git a/packages/layerchart/src/lib/components/Bars/Bars.stack.svelte.test.ts b/packages/layerchart/src/lib/components/Bars/Bars.stack.svelte.test.ts index b69e62c573..04964ffca6 100644 --- a/packages/layerchart/src/lib/components/Bars/Bars.stack.svelte.test.ts +++ b/packages/layerchart/src/lib/components/Bars/Bars.stack.svelte.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { render } from 'vitest-browser-svelte'; -import BarsStackTestHarness from '../tests/BarsStackTestHarness.svelte'; +import BarsStackTestHarness from '$lib/tests/BarsStackTestHarness.svelte'; /** Long rows — the category is a column, so no `series` names the layers */ const longData = [ diff --git a/packages/layerchart/src/lib/components/Brush/Brush.svelte.test.ts b/packages/layerchart/src/lib/components/Brush/Brush.svelte.test.ts index 8eaacdbd76..ea501a74fd 100644 --- a/packages/layerchart/src/lib/components/Brush/Brush.svelte.test.ts +++ b/packages/layerchart/src/lib/components/Brush/Brush.svelte.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { render } from 'vitest-browser-svelte'; -import BrushMarkHarness from '../tests/BrushMarkHarness.svelte'; +import BrushMarkHarness from '$lib/tests/BrushMarkHarness.svelte'; describe('Brush', () => { describe('touch', () => { diff --git a/packages/layerchart/src/lib/components/BrushContext.svelte.test.ts b/packages/layerchart/src/lib/components/BrushContext.svelte.test.ts index b25a184935..0bb9a91771 100644 --- a/packages/layerchart/src/lib/components/BrushContext.svelte.test.ts +++ b/packages/layerchart/src/lib/components/BrushContext.svelte.test.ts @@ -3,8 +3,8 @@ import { render } from 'vitest-browser-svelte'; import { tick } from 'svelte'; import Chart from './Chart/Chart.svelte'; -import BrushTestHarness from './tests/BrushTestHarness.svelte'; -import BrushContextAccessHarness from './tests/BrushContextAccessHarness.svelte'; +import BrushTestHarness from '$lib/tests/BrushTestHarness.svelte'; +import BrushContextAccessHarness from '$lib/tests/BrushContextAccessHarness.svelte'; const data = [ { x: 0, y: 10 }, diff --git a/packages/layerchart/src/lib/components/Calendar/Calendar.base.svelte b/packages/layerchart/src/lib/components/Calendar/Calendar.base.svelte index 9de56b3052..1f57702f0f 100644 --- a/packages/layerchart/src/lib/components/Calendar/Calendar.base.svelte +++ b/packages/layerchart/src/lib/components/Calendar/Calendar.base.svelte @@ -13,7 +13,7 @@ {#if ssr === true || typeof window !== 'undefined'} @@ -518,7 +539,7 @@ class={['lc-root-container', className]} {...restProps} > - {#key chartState.isMounted} + {#if !needsMeasure || chartState.isMeasured} {#if transform && TransformContext} {@const { @@ -552,7 +573,7 @@ {:else} {@render inner()} {/if} - {/key} + {/if} {/if} diff --git a/packages/layerchart/src/lib/components/Chart/Chart.mount.svelte.test.ts b/packages/layerchart/src/lib/components/Chart/Chart.mount.svelte.test.ts new file mode 100644 index 0000000000..a2bef88686 --- /dev/null +++ b/packages/layerchart/src/lib/components/Chart/Chart.mount.svelte.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, beforeEach } from 'vitest'; +import { render } from 'vitest-browser-svelte'; + +import TestHarness from '$lib/tests/TestHarness.svelte'; +import MountCounter, { + mountCounts, + resetMountCounts, + sizeAtConstruction, +} from '$lib/tests/MountCounter.svelte'; + +/** + * `Chart` used to wrap its subtree in `{#key chartState.isMounted}`, remounting every child once + * the container had been measured — a leftover from when LayerCake reported a placeholder width + * until `bind:clientWidth` ran. Children paid for it twice: double DOM construction, replayed + * intro animations, and mount-time registrations that had to guard against a spurious teardown. + */ +describe('Chart child lifecycle', () => { + beforeEach(resetMountCounts); + + it('mounts children exactly once', async () => { + render(TestHarness, { + chartProps: { data: [{ x: 1, y: 1 }], x: 'x', y: 'y' }, + component: MountCounter, + }); + await expect.poll(() => mountCounts.mount).toBe(1); + expect(mountCounts.destroy).toBe(0); + }); + + it('mounts children exactly once with a transform', async () => { + render(TestHarness, { + chartProps: { + data: [{ x: 1, y: 1 }], + x: 'x', + y: 'y', + transform: { mode: 'domain' as const, axis: 'x' as const }, + }, + component: MountCounter, + }); + // `TransformContext` is lazy-loaded, so give the dynamic import a chance to land and rebuild + // the subtree before asserting it did not. + await expect.poll(() => mountCounts.mount).toBe(1); + expect(mountCounts.destroy).toBe(0); + }); + + /** + * Building the subtree once is only safe if it is built against the measured layout. Marks read + * the scales as they are constructed, so a child that sees the placeholder would leave a mark + * animating out of a 100x100 corner rather than the plot it belongs to. + */ + it('builds children against the measured size, not the placeholder', async () => { + render(TestHarness, { + chartProps: { data: [{ x: 1, y: 1 }], x: 'x', y: 'y' }, + component: MountCounter, + }); + await expect.poll(() => mountCounts.mount).toBe(1); + + const container = document.querySelector('.lc-root-container') as HTMLElement; + expect(container.clientWidth).toBeGreaterThan(100); + expect(sizeAtConstruction.containerWidth).toBe(container.clientWidth); + expect(sizeAtConstruction.width).toBeGreaterThan(0); + expect(sizeAtConstruction.height).toBeGreaterThan(0); + }); +}); diff --git a/packages/layerchart/src/lib/components/Chart/ChartCore.svelte.test.ts b/packages/layerchart/src/lib/components/Chart/ChartCore.svelte.test.ts index f103bff25c..3f9e0257df 100644 --- a/packages/layerchart/src/lib/components/Chart/ChartCore.svelte.test.ts +++ b/packages/layerchart/src/lib/components/Chart/ChartCore.svelte.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { render } from 'vitest-browser-svelte'; import { tick } from 'svelte'; -import ChartCoreTestHarness from '../tests/ChartCoreTestHarness.svelte'; +import ChartCoreTestHarness from '$lib/tests/ChartCoreTestHarness.svelte'; const data = [ { x: 0, y: 10 }, diff --git a/packages/layerchart/src/lib/components/Circle/Circle.svelte.test.ts b/packages/layerchart/src/lib/components/Circle/Circle.svelte.test.ts index fb6214dd96..d68d8eac24 100644 --- a/packages/layerchart/src/lib/components/Circle/Circle.svelte.test.ts +++ b/packages/layerchart/src/lib/components/Circle/Circle.svelte.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; -import TestHarness, { componentTestId } from '../tests/TestHarness.svelte'; +import TestHarness, { componentTestId } from '$lib/tests/TestHarness.svelte'; import Circle from './Circle.svelte'; describe('Circle', () => { diff --git a/packages/layerchart/src/lib/components/Ellipse/Ellipse.svelte.test.ts b/packages/layerchart/src/lib/components/Ellipse/Ellipse.svelte.test.ts index cb5a1eac9b..a46a85519e 100644 --- a/packages/layerchart/src/lib/components/Ellipse/Ellipse.svelte.test.ts +++ b/packages/layerchart/src/lib/components/Ellipse/Ellipse.svelte.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; -import TestHarness, { componentTestId } from '../tests/TestHarness.svelte'; +import TestHarness, { componentTestId } from '$lib/tests/TestHarness.svelte'; import Ellipse from './Ellipse.svelte'; describe('Ellipse', () => { diff --git a/packages/layerchart/src/lib/components/Group/Group.svelte.test.ts b/packages/layerchart/src/lib/components/Group/Group.svelte.test.ts index 3092f18440..29d4531148 100644 --- a/packages/layerchart/src/lib/components/Group/Group.svelte.test.ts +++ b/packages/layerchart/src/lib/components/Group/Group.svelte.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; -import TestHarness, { componentTestId } from '../tests/TestHarness.svelte'; +import TestHarness, { componentTestId } from '$lib/tests/TestHarness.svelte'; import Group from './Group.svelte'; describe('Group', () => { diff --git a/packages/layerchart/src/lib/components/Image/Image.svelte.test.ts b/packages/layerchart/src/lib/components/Image/Image.svelte.test.ts index 12d97ce592..7abbd873b3 100644 --- a/packages/layerchart/src/lib/components/Image/Image.svelte.test.ts +++ b/packages/layerchart/src/lib/components/Image/Image.svelte.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; -import TestHarness, { componentTestId } from '../tests/TestHarness.svelte'; +import TestHarness, { componentTestId } from '$lib/tests/TestHarness.svelte'; import Image from './Image.svelte'; describe('Image', () => { diff --git a/packages/layerchart/src/lib/components/Legend.svelte.test.ts b/packages/layerchart/src/lib/components/Legend.svelte.test.ts index 210f94b7a2..e963faf316 100644 --- a/packages/layerchart/src/lib/components/Legend.svelte.test.ts +++ b/packages/layerchart/src/lib/components/Legend.svelte.test.ts @@ -5,7 +5,7 @@ import { interpolateTurbo } from 'd3-scale-chromatic'; import BarChart from './charts/BarChart/BarChart.svelte'; import LineChart from './charts/LineChart/LineChart.svelte'; -import ChartWithMarkSeries from './tests/ChartWithMarkSeries.svelte'; +import ChartWithMarkSeries from '$lib/tests/ChartWithMarkSeries.svelte'; const longData = [ { year: 2019, fruit: 'apples', value: 30 }, diff --git a/packages/layerchart/src/lib/components/Line/Line.svelte.test.ts b/packages/layerchart/src/lib/components/Line/Line.svelte.test.ts index 67f0e9d7e6..a9ed7cc910 100644 --- a/packages/layerchart/src/lib/components/Line/Line.svelte.test.ts +++ b/packages/layerchart/src/lib/components/Line/Line.svelte.test.ts @@ -3,7 +3,7 @@ import { render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import type { Component } from 'svelte'; -import TestHarness, { componentTestId } from '../tests/TestHarness.svelte'; +import TestHarness, { componentTestId } from '$lib/tests/TestHarness.svelte'; import Line from './Line.svelte'; describe('Line', () => { diff --git a/packages/layerchart/src/lib/components/Path/Path.svelte.test.ts b/packages/layerchart/src/lib/components/Path/Path.svelte.test.ts new file mode 100644 index 0000000000..bf45701c24 --- /dev/null +++ b/packages/layerchart/src/lib/components/Path/Path.svelte.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { render } from 'vitest-browser-svelte'; + +import PathEndpointsHarness from '$lib/tests/PathEndpointsHarness.svelte'; + +/** `translate(x, y)` of the group wrapping the rendered snippet */ +function groupTranslate(className: string) { + const el = document.querySelector(`.${className}`); + const match = el?.style.transform.match(/translate\((-?[\d.]+)px,\s*(-?[\d.]+)px\)/); + return match ? [Math.round(Number(match[1])), Math.round(Number(match[2]))] : null; +} + +describe('Path', () => { + describe('startContent / endContent', () => { + it('renders both without a `draw` transition', async () => { + render(PathEndpointsHarness, {}); + + await expect + .poll(() => document.querySelectorAll('[data-testid="start-dot"]').length) + .toBe(1); + await expect.poll(() => document.querySelectorAll('[data-testid="end-dot"]').length).toBe(1); + }); + + it('renders both with a `draw` transition', async () => { + render(PathEndpointsHarness, { draw: true }); + + await expect + .poll(() => document.querySelectorAll('[data-testid="start-dot"]').length) + .toBe(1); + await expect + .poll(() => document.querySelectorAll('[data-testid="end-dot"]').length, { timeout: 2000 }) + .toBe(1); + }); + + it('positions the content at each end of the path', async () => { + // x 1..3 over 200px, y 10..30 over 100px (inverted), so the line runs (0,100) → (200,50) + render(PathEndpointsHarness, {}); + + await expect.poll(() => groupTranslate('lc-path-g-start')).toEqual([0, 100]); + await expect.poll(() => groupTranslate('lc-path-g-end')).toEqual([200, 50]); + }); + }); +}); diff --git a/packages/layerchart/src/lib/components/Path/Path.svg.svelte b/packages/layerchart/src/lib/components/Path/Path.svg.svelte index 2a2b0b3474..f03e53a43c 100644 --- a/packages/layerchart/src/lib/components/Path/Path.svg.svelte +++ b/packages/layerchart/src/lib/components/Path/Path.svg.svelte @@ -63,6 +63,8 @@ const drawTransition = $derived(draw ? _drawTransition : () => ({})); let startPoint = $state(); + // Used when `draw` is not configured, where the end point does not animate + let staticEndPoint = $state(); // Compute the class string here rather than inline in the `class={...}` // attribute: a TS cast in markup survives into `dist` and breaks tooling that @@ -83,21 +85,25 @@ // Only allocate the controlled motion container when `draw` is configured; // otherwise the per-Path `MotionNone` × hundreds of paths was a measurable - // mount-time cost in mark-heavy scenes. - const endPoint = draw - ? createControlledMotion(undefined, { - type: 'tween', - duration: () => endPointDuration, - easing: typeof draw === 'object' && draw.easing ? draw.easing : cubicInOut, - interpolate() { - return (t: number) => { - const totalLength = pathRef?.getTotalLength() ?? 0; - const point = pathRef?.getPointAtLength(totalLength * t); - return point; - }; - }, - }) - : null; + // mount-time cost in mark-heavy scenes. Without `draw`, `staticEndPoint` is + // used instead. + const endPoint = + draw && endContent + ? createControlledMotion(undefined, { + type: 'tween', + duration: () => endPointDuration, + easing: typeof draw === 'object' && draw.easing ? draw.easing : cubicInOut, + interpolate() { + return (t: number) => { + const totalLength = pathRef?.getTotalLength() ?? 0; + const point = pathRef?.getPointAtLength(totalLength * t); + return point; + }; + }, + }) + : null; + + const currentEndPoint = $derived(endPoint ? endPoint.current : staticEndPoint); // Only set up path-end tracking when startContent/endContent require it. if (startContent || endContent) { @@ -113,6 +119,8 @@ startPoint = pathRef.getPointAtLength(0); if (endPoint) { endPoint.target = pathRef.getPointAtLength(totalLength); + } else { + staticEndPoint = pathRef.getPointAtLength(totalLength); } }); }); @@ -158,13 +166,13 @@ {/if} - {#if endContent && endPoint?.current} - + {#if endContent && currentEndPoint} + {@render endContent({ - point: endPoint.current, + point: currentEndPoint, value: { - x: c.chartCtx.xScale?.invert?.(endPoint.current.x), - y: c.chartCtx.yScale?.invert?.(endPoint.current.y), + x: c.chartCtx.xScale?.invert?.(currentEndPoint.x), + y: c.chartCtx.yScale?.invert?.(currentEndPoint.y), }, })} diff --git a/packages/layerchart/src/lib/components/Polygon/Polygon.svelte.test.ts b/packages/layerchart/src/lib/components/Polygon/Polygon.svelte.test.ts index 8d4a1ffdac..5a472d95f2 100644 --- a/packages/layerchart/src/lib/components/Polygon/Polygon.svelte.test.ts +++ b/packages/layerchart/src/lib/components/Polygon/Polygon.svelte.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; -import TestHarness, { componentTestId } from '../tests/TestHarness.svelte'; +import TestHarness, { componentTestId } from '$lib/tests/TestHarness.svelte'; import Polygon from './Polygon.svelte'; describe('Polygon', () => { diff --git a/packages/layerchart/src/lib/components/Rect/Rect.svelte.test.ts b/packages/layerchart/src/lib/components/Rect/Rect.svelte.test.ts index 286bad3055..17abc07c88 100644 --- a/packages/layerchart/src/lib/components/Rect/Rect.svelte.test.ts +++ b/packages/layerchart/src/lib/components/Rect/Rect.svelte.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; -import TestHarness, { componentTestId } from '../tests/TestHarness.svelte'; +import TestHarness, { componentTestId } from '$lib/tests/TestHarness.svelte'; import Rect from './Rect.svelte'; describe('Rect', () => { diff --git a/packages/layerchart/src/lib/components/Spline/Spline.shared.svelte.ts b/packages/layerchart/src/lib/components/Spline/Spline.shared.svelte.ts index c3c3945d67..b8fecf4a91 100644 --- a/packages/layerchart/src/lib/components/Spline/Spline.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/Spline/Spline.shared.svelte.ts @@ -1,3 +1,4 @@ +import { untrack } from 'svelte'; import type { SVGAttributes } from 'svelte/elements'; import type { CurveFactory, CurveFactoryLineOnly, Line } from 'd3-shape'; import { line as d3Line, lineRadial } from 'd3-shape'; @@ -7,7 +8,12 @@ import { interpolatePath } from 'd3-interpolate-path'; import { accessor, type Accessor } from '$lib/utils/common.js'; import { isScaleBand } from '$lib/utils/scales.svelte.js'; -import { createMotion, extractTweenConfig, type MotionProp } from '$lib/utils/motion.svelte.js'; +import { + createMotion, + createPathMotionMap, + extractTweenConfig, + type MotionProp, +} from '$lib/utils/motion.svelte.js'; import { colorPropDataKey, resolveColorProp, resolveStyleProp } from '$lib/utils/dataProp.js'; import type { ColorProp, StyleProp } from '$lib/utils/dataProp.js'; import { getChartContext } from '$lib/contexts/chart.js'; @@ -84,6 +90,9 @@ export class SplineState { #tweenState!: ReturnType>; + /** One tween per `z` group — see `#segmentTargets` for why the style-function split is excluded */ + #segmentTweens: ReturnType = null; + constructor(getProps: () => SplineProps) { this.#getProps = getProps; @@ -113,6 +122,26 @@ export class SplineState { () => this.d, tween ? { type: 'tween', interpolate: interpolatePath, ...tween.options } : undefined ); + + // `#tweenState` animates the single-path case; grouped lines each need their own, since the + // set of them changes with the data. + this.#segmentTweens = createPathMotionMap(initial.motion, interpolatePath); + if (this.#segmentTweens) { + const tweens = this.#segmentTweens; + $effect(() => { + const targets = this.#segmentTargets; + if (!targets) return; + + const active = new Set(); + for (const seg of targets) { + if (seg.key === undefined) continue; + active.add(seg.key); + // `update` reads and writes the tween's own state, so it must not be tracked here + untrack(() => tweens.update(seg.key, seg.d, () => this.#defaultPathData(seg.data))); + } + untrack(() => tweens.cleanup(active)); + }); + } } #getScaleValue( @@ -249,12 +278,23 @@ export class SplineState { return this.#buildPath(resolvedData); }); - segments = $derived.by(() => { + /** + * The segments as the data says they should be, before motion — each with the key its tween is + * stored under and the rows it was built from. + * + * Separate from `segments` so the effect driving the tweens can read the targets without + * reading the tweens' own output, which would be a cycle. + * + * Only the `z` split gets a key. A style function splits a line further, into one path per run + * of matching style, and those runs are redrawn by the data — their count and boundaries move, + * so there is no identity to carry a tween across. + */ + #segmentTargets = $derived.by<(SplineSegment & { key?: any; data: any[] })[] | null>(() => { if (!this.hasAnyStyleFn && !this.zAccessor) return null; const props = this.#props; if (this.geo.projection) return null; - const out: SplineSegment[] = []; + const out: (SplineSegment & { key?: any; data: any[] })[] = []; for (const lineData of this.lines) { const lineOpacity = this.#lineOpacity(lineData); @@ -276,6 +316,7 @@ export class SplineState { ...group.style, opacity: group.style.opacity ?? lineOpacity, d: this.#buildPath(group.data), + data: group.data, }); } } else { @@ -290,6 +331,8 @@ export class SplineState { opacity: resolveStyleProp(props.opacity, lineData[0]) ?? lineOpacity, class: resolveStyleProp(props.class, lineData[0]), d: this.#buildPath(lineData), + data: lineData, + key: this.zAccessor ? this.zAccessor(lineData[0]) : undefined, }); } } @@ -297,6 +340,17 @@ export class SplineState { return out; }); + /** `#segmentTargets` with each `z` group's path swapped for its in-flight tween */ + segments = $derived.by(() => { + const targets = this.#segmentTargets; + const tweens = this.#segmentTweens; + if (!targets || !tweens) return targets; + + return targets.map((seg) => + seg.key === undefined ? seg : { ...seg, d: tweens.get(seg.key) ?? seg.d } + ); + }); + /** * The chart's `c` channel as the colour, when `c` is what names the groups — the lines split by * it would otherwise all come out in the series' one colour. @@ -345,12 +399,13 @@ export class SplineState { return this.ctx.series.visibleSeries.some((s) => s.key === key); } - #defaultPathData(): string { + /** The path flattened to the baseline — what a line tweens out of when it first appears */ + #defaultPathData(data?: any[]): string { const props = this.#props; if (!extractTweenConfig(props.motion)) return ''; if (this.ctx.config.x) { - const resolvedData = this.resolvedData; + const resolvedData = data ?? this.resolvedData; const baseline = Math.min(this.ctx.yScale(0) ?? this.ctx.yRange[0], this.ctx.yRange[0]); const path = this.ctx.radial diff --git a/packages/layerchart/src/lib/components/Spline/Spline.svelte.test.ts b/packages/layerchart/src/lib/components/Spline/Spline.svelte.test.ts index 58ab62dec4..2c75266c71 100644 --- a/packages/layerchart/src/lib/components/Spline/Spline.svelte.test.ts +++ b/packages/layerchart/src/lib/components/Spline/Spline.svelte.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { render } from 'vitest-browser-svelte'; -import TestHarness from '../tests/TestHarness.svelte'; +import TestHarness from '$lib/tests/TestHarness.svelte'; import Spline from './Spline.svelte'; /** Two series interleaved in one flat array, as long-format data arrives */ diff --git a/packages/layerchart/src/lib/components/Text/Text.svelte.test.ts b/packages/layerchart/src/lib/components/Text/Text.svelte.test.ts index e02cc9046a..dfe332a4e8 100644 --- a/packages/layerchart/src/lib/components/Text/Text.svelte.test.ts +++ b/packages/layerchart/src/lib/components/Text/Text.svelte.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; -import TestHarness, { componentTestId } from '../tests/TestHarness.svelte'; +import TestHarness, { componentTestId } from '$lib/tests/TestHarness.svelte'; import Text from './Text.svelte'; describe('Text', () => { diff --git a/packages/layerchart/src/lib/components/TransformContext.svelte.test.ts b/packages/layerchart/src/lib/components/TransformContext.svelte.test.ts index 5bb5cec352..6ab0284c0c 100644 --- a/packages/layerchart/src/lib/components/TransformContext.svelte.test.ts +++ b/packages/layerchart/src/lib/components/TransformContext.svelte.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { render } from 'vitest-browser-svelte'; import { tick } from 'svelte'; -import TransformTestHarness from './tests/TransformTestHarness.svelte'; +import TransformTestHarness from '$lib/tests/TransformTestHarness.svelte'; import { geoMercator, geoOrthographic } from 'd3-geo'; describe('TransformContext', () => { diff --git a/packages/layerchart/src/lib/components/tooltip/TooltipState.svelte.test.ts b/packages/layerchart/src/lib/components/tooltip/TooltipState.svelte.test.ts index ca2c29e8e0..19ac46eca2 100644 --- a/packages/layerchart/src/lib/components/tooltip/TooltipState.svelte.test.ts +++ b/packages/layerchart/src/lib/components/tooltip/TooltipState.svelte.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; -import TooltipTestHarness from '../tests/TooltipTestHarness.svelte'; +import TooltipTestHarness from '$lib/tests/TooltipTestHarness.svelte'; import type { ChartState } from '$lib/states/chart.svelte.js'; const data = [ @@ -37,6 +37,19 @@ async function renderChart(chartProps: Record = {}) { describe('TooltipState', () => { describe('show({ value })', () => { + /** + * A chart can leave an axis unconfigured — `` with no `y` — which makes + * `ctx.y` null. `dataCoords` guards `ctx.y?.(data)` but still reads `ctx.yGet(data)` to place + * the tooltip, and `createGetter` called the accessor before checking anything, so showing a + * tooltip on such a chart threw `accessor is not a function`. + */ + it('places the tooltip on a chart with no `y` configured', async () => { + const ctx = await renderChart({ y: undefined, tooltipContext: { mode: 'bisect-x' } }); + + expect(() => ctx.tooltip.show({ value: { x: new Date('2024-01-02') } })).not.toThrow(); + expect(ctx.tooltip.data).toEqual(data[1]); + }); + it('resolves the nearest data point from a domain value', async () => { const ctx = await renderChart({ tooltipContext: { mode: 'bisect-x' } }); @@ -190,11 +203,17 @@ describe('TooltipState', () => { }); }); - it('is not re-shown by a chart mounting under a parked cursor', async () => { + it('is shielded from a chart mounting under a parked cursor by the harness', async () => { + // Covers `TooltipTestHarness`'s `pointer-events: none`, rather than anything `hide()` does. + // // Vitest's browser mode tiles every test file's iframe into one page sharing a single // cursor, so a `hover()` elsewhere can leave it wherever this chart later mounts. Park it // first, then mount underneath it: the browser fires a boundary event at the element that // appears under a stationary cursor, which would otherwise show data never asked for. + // + // `TooltipState` does not ignore that event — disabled pointer input is what keeps it away. + // Drop the guard from the harness and this fails, which is the point of keeping it: the + // other files on the page render charts directly and depend on the same behaviour. const parked = await renderChart({ tooltipContext: { mode: 'bisect-x' } }); const rect = parked.containerRef!.getBoundingClientRect(); await page @@ -216,10 +235,6 @@ describe('TooltipState', () => { // give a boundary event from the parked cursor a chance to land await new Promise((resolve) => setTimeout(resolve, 50)); expect(ctx.tooltip.data).toBeNull(); - - // Put the cursor back where it started. Files sharing the page render charts directly - // rather than through this harness, so leaving it parked would hand them the same hazard. - await page.elementLocator(document.body).hover({ position: { x: 0, y: 0 } }); }); }); }); diff --git a/packages/layerchart/src/lib/states/chart.cScale.svelte.test.ts b/packages/layerchart/src/lib/states/chart.cScale.svelte.test.ts index 89362a5f7e..efe5dc2ada 100644 --- a/packages/layerchart/src/lib/states/chart.cScale.svelte.test.ts +++ b/packages/layerchart/src/lib/states/chart.cScale.svelte.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { render } from 'vitest-browser-svelte'; -import TestHarness from '../components/tests/TestHarness.svelte'; +import TestHarness from '$lib/tests/TestHarness.svelte'; import Circle from '../components/Circle/Circle.svelte'; import Density from '../components/Density/Density.svelte'; import Contour from '../components/Contour/Contour.svelte'; diff --git a/packages/layerchart/src/lib/states/chart.svelte.ts b/packages/layerchart/src/lib/states/chart.svelte.ts index 2c46143c94..69adaba978 100644 --- a/packages/layerchart/src/lib/states/chart.svelte.ts +++ b/packages/layerchart/src/lib/states/chart.svelte.ts @@ -226,6 +226,17 @@ export class ChartState< // Mount state isMounted = $state(false); + /** + * Whether the container has been measured, so the scales describe the real layout rather than + * the placeholder above. + * + * Marks read the scales as they are *constructed* — a motion baseline, an initial position — and + * those reads happen once. Building them against the placeholder leaves a mark animating out of + * a 100×100 corner instead of the layout it belongs to, so `Chart` holds its subtree back until + * this is true. + */ + isMeasured = $state(false); + // Mark registration — marks register stable MarkInfo snapshots on mount for // domain/series calculation. Snapshots are updated via $effect (not $derived) // in registerComponent, so reads here never create circular derived refs. diff --git a/packages/layerchart/src/lib/states/facet.svelte.test.ts b/packages/layerchart/src/lib/states/facet.svelte.test.ts index 9c0eb3b2b3..afb36c75a5 100644 --- a/packages/layerchart/src/lib/states/facet.svelte.test.ts +++ b/packages/layerchart/src/lib/states/facet.svelte.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { render } from 'vitest-browser-svelte'; import { scaleBand } from 'd3-scale'; -import TestHarness from '../components/tests/TestHarness.svelte'; +import TestHarness from '$lib/tests/TestHarness.svelte'; import Circle from '../components/Circle/Circle.svelte'; import Highlight from '../components/Highlight/Highlight.svelte'; import ScatterChart from '../components/charts/ScatterChart/ScatterChart.svelte'; diff --git a/packages/layerchart/src/lib/states/group-transform.svelte.test.ts b/packages/layerchart/src/lib/states/group-transform.svelte.test.ts index 3fc72a1000..fc4e594f58 100644 --- a/packages/layerchart/src/lib/states/group-transform.svelte.test.ts +++ b/packages/layerchart/src/lib/states/group-transform.svelte.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { render } from 'vitest-browser-svelte'; -import ChartGroupTestHarness from '../components/tests/ChartGroupTestHarness.svelte'; +import ChartGroupTestHarness from '$lib/tests/ChartGroupTestHarness.svelte'; import LineChart from '../components/charts/LineChart/LineChart.svelte'; import { ChartGroupState } from './group.svelte.js'; import type { ChartState } from './chart.svelte.js'; diff --git a/packages/layerchart/src/lib/states/group.svelte.test.ts b/packages/layerchart/src/lib/states/group.svelte.test.ts index 6765cee3cc..2fea0fd9eb 100644 --- a/packages/layerchart/src/lib/states/group.svelte.test.ts +++ b/packages/layerchart/src/lib/states/group.svelte.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { render } from 'vitest-browser-svelte'; -import ChartGroupTestHarness from '../components/tests/ChartGroupTestHarness.svelte'; +import ChartGroupTestHarness from '$lib/tests/ChartGroupTestHarness.svelte'; import LineChart from '../components/charts/LineChart/LineChart.svelte'; import ChartCanvas from '../components/Chart/Chart.canvas.svelte'; import ChartHtml from '../components/Chart/Chart.html.svelte'; diff --git a/packages/layerchart/src/lib/states/mixed-marks.svelte.test.ts b/packages/layerchart/src/lib/states/mixed-marks.svelte.test.ts index fab5e196fa..4467c833c6 100644 --- a/packages/layerchart/src/lib/states/mixed-marks.svelte.test.ts +++ b/packages/layerchart/src/lib/states/mixed-marks.svelte.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { render } from 'vitest-browser-svelte'; -import MixedMarksHarness from '../components/tests/MixedMarksHarness.svelte'; +import MixedMarksHarness from '$lib/tests/MixedMarksHarness.svelte'; import type { ChartState } from './chart.svelte.js'; /** Long rows — `c` names the layers, so a bare `Bars` stacks them */ diff --git a/packages/layerchart/src/lib/components/tests/BarsStackTestHarness.svelte b/packages/layerchart/src/lib/tests/BarsStackTestHarness.svelte similarity index 68% rename from packages/layerchart/src/lib/components/tests/BarsStackTestHarness.svelte rename to packages/layerchart/src/lib/tests/BarsStackTestHarness.svelte index d65171ad69..dde77c9de8 100644 --- a/packages/layerchart/src/lib/components/tests/BarsStackTestHarness.svelte +++ b/packages/layerchart/src/lib/tests/BarsStackTestHarness.svelte @@ -1,7 +1,7 @@ diff --git a/packages/layerchart/src/lib/components/tests/BrushMarkHarness.svelte b/packages/layerchart/src/lib/tests/BrushMarkHarness.svelte similarity index 81% rename from packages/layerchart/src/lib/components/tests/BrushMarkHarness.svelte rename to packages/layerchart/src/lib/tests/BrushMarkHarness.svelte index 50f9c131b1..efaf5908f8 100644 --- a/packages/layerchart/src/lib/components/tests/BrushMarkHarness.svelte +++ b/packages/layerchart/src/lib/tests/BrushMarkHarness.svelte @@ -1,8 +1,8 @@ + + diff --git a/packages/layerchart/src/lib/tests/PathEndpointsHarness.svelte b/packages/layerchart/src/lib/tests/PathEndpointsHarness.svelte new file mode 100644 index 0000000000..fa6aee382c --- /dev/null +++ b/packages/layerchart/src/lib/tests/PathEndpointsHarness.svelte @@ -0,0 +1,40 @@ + + + + + + {#snippet startContent()} + + {/snippet} + {#snippet endContent()} + + {/snippet} + + + diff --git a/packages/layerchart/src/lib/components/tests/TestHarness.svelte b/packages/layerchart/src/lib/tests/TestHarness.svelte similarity index 84% rename from packages/layerchart/src/lib/components/tests/TestHarness.svelte rename to packages/layerchart/src/lib/tests/TestHarness.svelte index 59fa148988..400ab2c9c2 100644 --- a/packages/layerchart/src/lib/components/tests/TestHarness.svelte +++ b/packages/layerchart/src/lib/tests/TestHarness.svelte @@ -5,12 +5,16 @@