From 5dc41b6323c0aaf96156e2f31398a07e1c11f248 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Tue, 8 Sep 2026 10:15:24 -0400 Subject: [PATCH 1/2] fix(Spline): Maintain style-split segments colored and animated --- .changeset/spline-line-ends.md | 5 + .changeset/spline-style-run-color.md | 5 + .changeset/spline-tween-style-runs.md | 5 + docs/src/content/components/Spline.md | 35 ++++- .../Spline/missing-data-dashed.svelte | 78 ++++++++++ .../Spline/missing-data-with-markers.svelte | 75 +++++++++ .../lib/components/Spline/Spline.base.svelte | 24 +++ .../Spline/Spline.motion.svelte.test.ts | 81 ++++++++++ .../components/Spline/Spline.shared.svelte.ts | 51 +++++-- .../components/Spline/Spline.svelte.test.ts | 144 ++++++++++++++++++ 10 files changed, 485 insertions(+), 18 deletions(-) create mode 100644 .changeset/spline-line-ends.md create mode 100644 .changeset/spline-style-run-color.md create mode 100644 .changeset/spline-tween-style-runs.md create mode 100644 docs/src/examples/components/Spline/missing-data-dashed.svelte create mode 100644 docs/src/examples/components/Spline/missing-data-with-markers.svelte diff --git a/.changeset/spline-line-ends.md b/.changeset/spline-line-ends.md new file mode 100644 index 000000000..64ed8fc2c --- /dev/null +++ b/.changeset/spline-line-ends.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +fix(Spline): Render `startContent`, `endContent`, `markerStart`, and `markerEnd` once per line rather than once per style-split segment diff --git a/.changeset/spline-style-run-color.md b/.changeset/spline-style-run-color.md new file mode 100644 index 000000000..3e55506dd --- /dev/null +++ b/.changeset/spline-style-run-color.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +fix(Spline): Keep the line's color on segments split by a `class`, `opacity`, or `fill` function diff --git a/.changeset/spline-tween-style-runs.md b/.changeset/spline-tween-style-runs.md new file mode 100644 index 000000000..79176bc0f --- /dev/null +++ b/.changeset/spline-tween-style-runs.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +fix(Spline): Animate segments split by a style function, which `motion` previously skipped diff --git a/docs/src/content/components/Spline.md b/docs/src/content/components/Spline.md index 231d2e53b..c84a7aec9 100644 --- a/docs/src/content/components/Spline.md +++ b/docs/src/content/components/Spline.md @@ -53,19 +53,42 @@ Pass a function to `stroke`, `fill`, `opacity`, or `class` to style each segment (d.year === 2024 ? 'stroke-primary' : 'stroke-surface-content')} /> ``` -:example{ name="stroke-grouping" showCode } +:example{ name="stroke-grouping" } + +A run keeps its line's color unless the function itself names one, so a `class` that only changes the dashes doesn't have to restate the `stroke`. And each run animates: `motion` tweens it to the next update's run of the same style, so a dashed stretch follows the dashed stretch rather than the solid one beside it. + +### Bridging missing data + +Days a source never reported are absent from the data, not zero — so the line spans them, and a `class` function is what marks that span as a bridge. Because a run takes its style from the point it _starts_ at, the flag belongs on the last point before the gap: + +```svelte + (d.bridged ? 'stroke-2 [stroke-dasharray:4_4]' : 'stroke-2')} +/> +``` + +:example{ name="missing-data-dashed" } + +This draws the bridge _in_ the line rather than under it, so fading or hiding the series takes the dashes with it. + +### Line ends across runs + +However many paths a style function splits a line into, `startContent`, `endContent`, `markerStart`, and `markerEnd` belong to the line — they render once, at its ends. The seams between runs are interior points, so they take `markerMid`. + +:example{ name="missing-data-with-markers" } ### Geo mode When inside a `GeoProjection` context, Spline automatically renders as a projected geographic path. The `x` and `y` accessors extract longitude/latitude from each data point, which are converted to a GeoJSON `LineString` and rendered via `geoPath(projection)` — providing geodesic interpolation (great circle arcs) and proper antimeridian wrapping. -:example{ name="geo-routes" showCode } +:example{ name="geo-routes" } ### Parallel coordinates One line per row across an axis per dimension, from a single `Spline` grouped by `z`. Each dimension keeps its own domain — `Axis` takes a `scale` override, so the ticks read in real units — while positions are normalized to a shared `0–1` domain so every dimension can share the chart's `y` scale. `Group` places each axis at its point on the categorical `x` scale. -:example{ name="parallel-coordinates" showCode } +:example{ name="parallel-coordinates" } ### Brushable parallel coordinates @@ -77,7 +100,7 @@ A chart's `brush` prop owns one selection over the whole plot area. For several `contains()` then filters the lines — a row is kept when every brushed dimension contains it, so brushing several intersects them. -:example{ name="parallel-coordinates-brush" showCode } +:example{ name="parallel-coordinates-brush" } ### Mixed dimension types @@ -85,13 +108,13 @@ A dimension doesn't have to be numeric. Give each one the scale its own data cal Here `species` is an axis in its own right as well as the color, so brushing it narrows to those species and intersects with the numeric dimensions like any other. -:example{name="parallel-coordinates-mixed" showCode} +:example{name="parallel-coordinates-mixed" } ### Faceted parallel coordinates A line crosses every dimension, so the dimensions can't be panels — but `fy` gives one plot per group, sharing the dimension scales so the panels stay comparable. The per-dimension axes repeat in each panel with `facetAll`, while the dimension names, being an axis over the shared `x`, draw above the top panel only. -:example{ name="parallel-coordinates-faceted" showCode } +:example{ name="parallel-coordinates-faceted" } ### Playground diff --git a/docs/src/examples/components/Spline/missing-data-dashed.svelte b/docs/src/examples/components/Spline/missing-data-dashed.svelte new file mode 100644 index 000000000..7a56f6235 --- /dev/null +++ b/docs/src/examples/components/Spline/missing-data-dashed.svelte @@ -0,0 +1,78 @@ + + + + + + + + + + (d.bridged ? 'stroke-2 [stroke-dasharray:4_4]' : 'stroke-2')} + /> + + + + diff --git a/docs/src/examples/components/Spline/missing-data-with-markers.svelte b/docs/src/examples/components/Spline/missing-data-with-markers.svelte new file mode 100644 index 000000000..f19c2d765 --- /dev/null +++ b/docs/src/examples/components/Spline/missing-data-with-markers.svelte @@ -0,0 +1,75 @@ + + + + + + + + (d.bridged ? 'stroke-2 [stroke-dasharray:4_4]' : 'stroke-2')} + markerMid={{ type: 'circle', size: 6 }} + > + {#snippet endContent()} + + + {/snippet} + + + diff --git a/packages/layerchart/src/lib/components/Spline/Spline.base.svelte b/packages/layerchart/src/lib/components/Spline/Spline.base.svelte index ed3c095de..a2fc2c73f 100644 --- a/packages/layerchart/src/lib/components/Spline/Spline.base.svelte +++ b/packages/layerchart/src/lib/components/Spline/Spline.base.svelte @@ -27,6 +27,14 @@ // Pulled out of `restProps` so a function-valued `class` isn't spread onto the element class: className, motion, + // Ends of the *line*, not of each path. A style function splits one line into a path per run + // of matching style, and spreading these would give every run its own arrow head / end label. + marker, + markerStart, + markerMid, + markerEnd, + startContent, + endContent, ...restProps }: SplineBaseProps = $props(); @@ -50,6 +58,11 @@ {#if c.segments} + {#each c.segments as seg, i (i)} @@ -69,6 +87,12 @@ opacity={(typeof opacity === 'number' ? opacity : undefined) ?? (c.seriesOpacity === 1 ? undefined : c.seriesOpacity)} class={c.resolvedClass} + {marker} + {markerStart} + {markerMid} + {markerEnd} + {startContent} + {endContent} {...c.series?.props} {...restProps} /> diff --git a/packages/layerchart/src/lib/components/Spline/Spline.motion.svelte.test.ts b/packages/layerchart/src/lib/components/Spline/Spline.motion.svelte.test.ts index 1d18725dc..61a23b757 100644 --- a/packages/layerchart/src/lib/components/Spline/Spline.motion.svelte.test.ts +++ b/packages/layerchart/src/lib/components/Spline/Spline.motion.svelte.test.ts @@ -19,6 +19,8 @@ vi.mock('d3-interpolate-path', async (importOriginal) => { }); import LineChart from '../charts/LineChart/LineChart.svelte'; +import TestHarness from '$lib/tests/TestHarness.svelte'; +import Spline from './Spline.svelte'; function data(offset: number, n = 300) { return Array.from({ length: n }, (_, i) => ({ @@ -56,3 +58,82 @@ describe('Spline path tween is only built when `motion` is set', () => { expect(calls.invoked).toBe(0); }); }); + +/** + * A style function splits a line into one path per run of matching style. Those runs move with + * the data, but "the nth run of this style" is identity enough to tween across — without it a + * line drawn with a `class` function animated not at all, while the same line drawn with a + * static class animated normally. + */ +describe('Spline tweens style-split runs', () => { + /** One line, dashed across the middle, at a height the `offset` shifts */ + const bridged = (offset: number) => + Array.from({ length: 6 }, (_, i) => ({ + date: new Date(Date.UTC(2024, i, 1)), + value: 10 + i * 10 + offset, + gap: i === 2 || i === 3, + })); + + const harness = (motion?: any) => ({ + component: Spline, + chartProps: { data: bridged(0), x: 'date', y: 'value', yDomain: [0, 200], height: 300 }, + componentProps: { + motion, + class: (d: { gap: boolean }) => (d.gap ? 'gap-run' : 'solid-run'), + }, + }); + + const dashed = () => document.querySelector('svg path.gap-run')?.getAttribute('d') ?? null; + + /** The dashed run's `d` on each of the next `n` frames */ + async function sample(n: number) { + const seen: (string | null)[] = []; + for (let i = 0; i < n; i++) { + await frame(); + seen.push(dashed()); + } + return seen; + } + + beforeEach(() => cleanup()); + + it('animates a style-split run across a data change', async () => { + const props = harness({ type: 'tween', duration: 400 }); + const { rerender } = render(TestHarness, props as any); + + await expect.poll(dashed).not.toBeNull(); + // Let the initial grow-in finish, so what follows is only the data change + await new Promise((r) => setTimeout(r, 500)); + const before = dashed(); + + await rerender({ + ...props, + chartProps: { ...props.chartProps, data: bridged(60) }, + } as any); + + const seen = await sample(8); + // Interpolated, so the run passes through intermediate paths rather than snapping + expect(new Set(seen).size).toBeGreaterThan(2); + expect(seen[0]).not.toBe(before); + + // ...and lands on the target, which is where an unanimated run would have gone immediately + await new Promise((r) => setTimeout(r, 600)); + const after = dashed(); + expect(after).not.toBe(before); + await frame(); + expect(dashed()).toBe(after); + }); + + it('snaps without `motion`, as before', async () => { + const props = harness(); + const { rerender } = render(TestHarness, props as any); + + await expect.poll(dashed).not.toBeNull(); + await rerender({ + ...props, + chartProps: { ...props.chartProps, data: bridged(60) }, + } as any); + + expect(new Set(await sample(6)).size).toBe(1); + }); +}); 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 b8fecf4a9..b8c9855f9 100644 --- a/packages/layerchart/src/lib/components/Spline/Spline.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/Spline/Spline.shared.svelte.ts @@ -66,6 +66,10 @@ export type SplineSegment = { opacity?: number; class?: string; d: string; + /** Whether this path starts its line, rather than continuing one a style function split */ + lineStart: boolean; + /** Whether this path ends its line */ + lineEnd: boolean; }; /** @@ -90,7 +94,7 @@ export class SplineState { #tweenState!: ReturnType>; - /** One tween per `z` group — see `#segmentTargets` for why the style-function split is excluded */ + /** One tween per path this mark draws — see `#segmentTargets` for what identifies each */ #segmentTweens: ReturnType = null; constructor(getProps: () => SplineProps) { @@ -285,9 +289,12 @@ export class SplineState { * 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. + * The key composes both splits: the `z` group, then — where a style function splits that line + * further into one path per run of matching style — the style itself plus how many runs of that + * style came before it. A run's position in the line moves with the data, so a raw index + * carries no identity, but "the second dashed stretch" does: it tweens to the next render's + * second dashed stretch rather than morphing into the solid one beside it, and the + * interpolator handles the two having different point counts. */ #segmentTargets = $derived.by<(SplineSegment & { key?: any; data: any[] })[] | null>(() => { if (!this.hasAnyStyleFn && !this.zAccessor) return null; @@ -311,14 +318,30 @@ export class SplineState { style: { stroke: s, fill: f, opacity: o, class: c }, }; }); - for (const group of groups) { + + // The line's own color, for the runs a `stroke` function didn't name one for — a `class` + // function alone splits the line without saying anything about its color, and without + // this every run would fall through to `Path`'s unstroked default. Resolved from the + // line's first point, as the single-path case is, so the split doesn't recolor anything. + const lineStroke = this.#colorFromC(lineData[0]) ?? this.series?.color; + const lineKey = this.zAccessor ? this.zAccessor(lineData[0]) : ''; + const seen = new Map(); + + groups.forEach((group, index) => { + const ordinal = seen.get(group.key) ?? 0; + seen.set(group.key, ordinal + 1); + out.push({ ...group.style, + stroke: group.style.stroke ?? lineStroke, opacity: group.style.opacity ?? lineOpacity, d: this.#buildPath(group.data), data: group.data, + key: `${lineKey}\0${group.key}\0${ordinal}`, + lineStart: index === 0, + lineEnd: index === groups.length - 1, }); - } + }); } else { // One path for the whole line, styled from its first point — so `stroke="species"` picks // the line's color out of the data via the chart's color scale @@ -333,6 +356,8 @@ export class SplineState { d: this.#buildPath(lineData), data: lineData, key: this.zAccessor ? this.zAccessor(lineData[0]) : undefined, + lineStart: true, + lineEnd: true, }); } } @@ -340,7 +365,7 @@ export class SplineState { return out; }); - /** `#segmentTargets` with each `z` group's path swapped for its in-flight tween */ + /** `#segmentTargets` with each path swapped for its in-flight tween */ segments = $derived.by(() => { const targets = this.#segmentTargets; const tweens = this.#segmentTweens; @@ -462,33 +487,35 @@ export class SplineState { }); } -type SegmentStyle = { stroke?: string; fill?: string; opacity?: number }; +type SegmentStyle = { stroke?: string; fill?: string; opacity?: number; class?: string }; /** * Groups consecutive data points by a composite key derived from function-valued style props. * The key at index `i` determines the style for the segment from point `i` to point `i+1`. * Each group includes an overlap of 1 point at boundaries for curve continuity. + * + * The key comes back out alongside the style so the caller can identify a run across renders. */ function groupConsecutive( data: any[], keyFn: (d: any, index: number, data: any[]) => { key: string; style: SegmentStyle } -): Array<{ style: SegmentStyle; data: any[] }> { +): Array<{ key: string; style: SegmentStyle; data: any[] }> { if (data.length < 2) return []; - const groups: Array<{ style: SegmentStyle; data: any[] }> = []; + const groups: Array<{ key: string; style: SegmentStyle; data: any[] }> = []; let current = keyFn(data[0], 0, data); let startIdx = 0; for (let i = 1; i < data.length; i++) { const next = keyFn(data[i], i, data); if (next.key !== current.key) { - groups.push({ style: current.style, data: data.slice(startIdx, i + 1) }); + groups.push({ key: current.key, style: current.style, data: data.slice(startIdx, i + 1) }); startIdx = i; current = next; } } if (data.length - startIdx >= 2) { - groups.push({ style: current.style, data: data.slice(startIdx) }); + groups.push({ key: current.key, style: current.style, data: data.slice(startIdx) }); } return groups; 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 2c75266c7..6d3af178a 100644 --- a/packages/layerchart/src/lib/components/Spline/Spline.svelte.test.ts +++ b/packages/layerchart/src/lib/components/Spline/Spline.svelte.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { createRawSnippet } from 'svelte'; import { render } from 'vitest-browser-svelte'; import TestHarness from '$lib/tests/TestHarness.svelte'; @@ -331,6 +332,149 @@ describe('Spline', () => { }); }); + describe('style-split runs', () => { + /** One line, dashed across the middle two points — the shape a "no data reported" bridge has */ + const bridged = [ + { date: new Date('2024-01-01'), value: 10, gap: false }, + { date: new Date('2024-02-01'), value: 20, gap: false }, + { date: new Date('2024-03-01'), value: 30, gap: true }, + { date: new Date('2024-04-01'), value: 40, gap: true }, + { date: new Date('2024-05-01'), value: 50, gap: false }, + { date: new Date('2024-06-01'), value: 60, gap: false }, + ]; + + const dashOn = (d: (typeof bridged)[number]) => (d.gap ? 'dashed' : 'solid'); + + it('takes each run`s style from the point it starts at, sharing the boundary point', async () => { + render(TestHarness, { + component: Spline, + chartProps: { data: bridged, x: 'date', y: 'value', yDomain: [0, 100] }, + componentProps: { class: dashOn }, + }); + + await expect.poll(() => paths().length).toBe(3); + expect(paths().map((p) => p.classList.contains('dashed'))).toEqual([false, true, false]); + // The overlap: 3 + 3 + 2 points across the runs covers 6 rows twice-counting 2 boundaries + expect(paths().map((p) => p.getAttribute('d')!.split(/[ML]/).length - 1)).toEqual([3, 3, 2]); + }); + + it('keeps the `c` color on every run when only `class` splits the line', async () => { + render(TestHarness, { + component: Spline, + chartProps: { + ...chartProps({ c: 'group', cRange: ['rgb(255, 0, 0)', 'rgb(0, 0, 255)'] }), + }, + componentProps: { class: (d: any) => (d.value % 20 === 0 ? 'even' : 'odd') }, + }); + + // Two lines (`c` names the groups), each split further by the class function + await expect.poll(() => paths().length).toBeGreaterThan(2); + // Every run is colored, and only by which line it belongs to — not by its style run + expect( + paths().every((p) => + ['rgb(255, 0, 0)', 'rgb(0, 0, 255)'].includes(p.getAttribute('stroke')!) + ) + ).toBe(true); + expect(paths().some((p) => p.classList.contains('even'))).toBe(true); + expect(paths().some((p) => p.classList.contains('odd'))).toBe(true); + }); + + it('keeps the series color on every run when only `class` splits the line', async () => { + render(TestHarness, { + component: Spline, + chartProps: { + data: bridged, + x: 'date', + yDomain: [0, 100], + series: [{ key: 'value', color: 'rgb(0, 128, 0)' }], + }, + componentProps: { seriesKey: 'value', class: dashOn }, + }); + + await expect.poll(() => paths().length).toBe(3); + expect(paths().map((p) => p.getAttribute('stroke'))).toEqual([ + 'rgb(0, 128, 0)', + 'rgb(0, 128, 0)', + 'rgb(0, 128, 0)', + ]); + }); + + it('lets a `stroke` function color runs individually', async () => { + // The fallback must not overwrite what the style function actually asked for + render(TestHarness, { + component: Spline, + chartProps: { + data: bridged, + x: 'date', + yDomain: [0, 100], + series: [{ key: 'value', color: 'rgb(0, 128, 0)' }], + }, + componentProps: { + seriesKey: 'value', + stroke: (d: (typeof bridged)[number]) => (d.gap ? 'rgb(255, 0, 0)' : 'rgb(0, 0, 255)'), + }, + }); + + await expect.poll(() => paths().length).toBe(3); + expect(paths().map((p) => p.getAttribute('stroke'))).toEqual([ + 'rgb(0, 0, 255)', + 'rgb(255, 0, 0)', + 'rgb(0, 0, 255)', + ]); + }); + + describe('the ends of the line, not of each run', () => { + const dot = createRawSnippet(() => ({ render: () => '' })); + + const ends = () => ({ + start: document.querySelectorAll('.lc-path-g-start').length, + end: document.querySelectorAll('.lc-path-g-end').length, + }); + + it('renders start/end content once per line, not once per run', async () => { + render(TestHarness, { + component: Spline, + chartProps: { data: bridged, x: 'date', y: 'value', yDomain: [0, 100] }, + componentProps: { class: dashOn, startContent: dot, endContent: dot }, + }); + + await expect.poll(() => paths().length).toBe(3); + await expect.poll(ends).toEqual({ start: 1, end: 1 }); + }); + + it('still renders them per line when `z` splits the mark', async () => { + render(TestHarness, { + component: Spline, + chartProps: chartProps(), + componentProps: { z: 'group', startContent: dot, endContent: dot }, + }); + + await expect.poll(() => paths().length).toBe(2); + await expect.poll(ends).toEqual({ start: 2, end: 2 }); + }); + + it('puts start/end markers on the line`s ends and mid markers on the seams', async () => { + render(TestHarness, { + component: Spline, + chartProps: { data: bridged, x: 'date', y: 'value', yDomain: [0, 100] }, + componentProps: { + class: dashOn, + markerStart: 'circle', + markerMid: 'circle', + markerEnd: 'arrow', + }, + }); + + await expect.poll(() => paths().length).toBe(3); + // The two seams are interior points of the line, so they take the mid marker — once each, + // from the run that starts there rather than the one that ends there + expect(paths().map((p) => p.hasAttribute('marker-end'))).toEqual([false, false, true]); + expect(paths().every((p) => p.hasAttribute('marker-start'))).toBe(true); + expect(paths().every((p) => p.hasAttribute('marker-mid'))).toBe(true); + }); + }); + }); + describe('stacked series', () => { /** Wide rows, so the series stack across columns */ const stackedData = [ From baf351a66b69ffd160d307ae505e595acf18e71f Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Tue, 8 Sep 2026 15:27:29 -0400 Subject: [PATCH 2/2] fix(Spline): Grow new style-split runs from their join point --- .../Spline/Spline.motion.svelte.test.ts | 94 +++++++++++++++++++ .../components/Spline/Spline.shared.svelte.ts | 53 ++++++++++- 2 files changed, 144 insertions(+), 3 deletions(-) diff --git a/packages/layerchart/src/lib/components/Spline/Spline.motion.svelte.test.ts b/packages/layerchart/src/lib/components/Spline/Spline.motion.svelte.test.ts index 61a23b757..0d0fa2438 100644 --- a/packages/layerchart/src/lib/components/Spline/Spline.motion.svelte.test.ts +++ b/packages/layerchart/src/lib/components/Spline/Spline.motion.svelte.test.ts @@ -136,4 +136,98 @@ describe('Spline tweens style-split runs', () => { expect(new Set(await sample(6)).size).toBe(1); }); + + /** + * A run that appears has to come from somewhere. A whole new line rises from the baseline, as + * it always has — but a run appearing *within* a line already on screen would rise through the + * chart to reach it, so it grows out of the point it joins instead. Consecutive runs share + * their boundary point, so that point is the end of the run before it. + */ + describe('where a run enters from', () => { + /** Every `y` in a path's `d`, as `M x,y L x,y ...` */ + const ys = (d: string | null) => + [...(d ?? '').matchAll(/[ML]-?[\d.]+,(-?[\d.]+)/g)].map((m) => Number(m[1])); + + /** Flat lines high up the chart, so the baseline is nowhere near them */ + const flat = (groups: string[], gapAt: number | null) => + Array.from({ length: 6 }, (_, i) => i).flatMap((i) => + groups.map((group) => ({ + date: new Date(Date.UTC(2024, i, 1)), + value: 150, + group, + gap: i === gapAt, + })) + ); + + const chartProps = { x: 'date', y: 'value', yDomain: [0, 200], height: 300 }; + // Slow, so a handful of frames lands well inside the entrance rather than after it + const componentProps = { + motion: { type: 'tween', duration: 2000 }, + z: 'group', + class: (d: { gap: boolean }) => (d.gap ? 'gap-run' : 'solid-run'), + }; + + /** The `y` of a value of 150 in a 300px chart over `[0, 200]`, and of the baseline */ + const lineY = 300 * (1 - 150 / 200); + const baselineY = 300; + const midway = (lineY + baselineY) / 2; + + /** How low the lowest of the `n` most recently drawn paths gets over the next `frames` */ + async function lowestOfLast(n: number, frames: number) { + let lowest = -Infinity; + for (let i = 0; i < frames; i++) { + await frame(); + const paths = [...document.querySelectorAll('svg path')].slice(-n); + for (const p of paths) lowest = Math.max(lowest, ...ys(p.getAttribute('d'))); + } + return lowest; + } + + it('grows the runs of a line that appears in from the baseline with it', async () => { + const props = { + component: Spline, + chartProps: { ...chartProps, data: flat(['a'], 2) }, + componentProps, + }; + const { rerender } = render(TestHarness, props as any); + + await expect.poll(() => document.querySelectorAll('svg path').length).toBe(3); + await new Promise((r) => setTimeout(r, 2200)); + + // A second line, split the same way — nothing of it was on screen, so it enters as a line + await rerender({ ...props, chartProps: { ...chartProps, data: flat(['a', 'b'], 2) } } as any); + await expect.poll(() => document.querySelectorAll('svg path').length).toBe(6); + + expect(await lowestOfLast(3, 12)).toBeGreaterThan(midway); + }); + + it('grows a run appearing in a drawn line out from the point it joins', async () => { + const props = { + component: Spline, + chartProps: { ...chartProps, data: flat(['a'], null) }, + componentProps, + }; + const { rerender } = render(TestHarness, props as any); + + // One solid run, fully entered — there is no dashed run yet + await expect.poll(() => document.querySelectorAll('svg path').length).toBe(1); + await new Promise((r) => setTimeout(r, 2200)); + expect(dashed()).toBeNull(); + + await rerender({ ...props, chartProps: { ...chartProps, data: flat(['a'], 2) } } as any); + await expect.poll(dashed).not.toBeNull(); + + // It starts collapsed on the line, so it never goes near the baseline... + let lowest = -Infinity; + for (let i = 0; i < 12; i++) { + await frame(); + lowest = Math.max(lowest, ...ys(dashed())); + } + expect(lowest).toBeLessThan(midway); + + // ...and collapsed means it starts with no extent to speak of + const entering = ys(dashed()); + expect(Math.max(...entering) - Math.min(...entering)).toBeLessThan(lineY / 2); + }); + }); }); 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 b8c9855f9..cef66b2df 100644 --- a/packages/layerchart/src/lib/components/Spline/Spline.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/Spline/Spline.shared.svelte.ts @@ -132,17 +132,31 @@ export class SplineState { this.#segmentTweens = createPathMotionMap(initial.motion, interpolatePath); if (this.#segmentTweens) { const tweens = this.#segmentTweens; + // The lines that were on screen last pass, so a run appearing in one of them can be told + // apart from a run of a line being drawn for the first time. Rebuilt each pass, which + // prunes lines the data dropped — one that comes back is new again, and enters as one. + let drawnLines = new Set(); + $effect(() => { const targets = this.#segmentTargets; if (!targets) return; const active = new Set(); + const lines = new Set(); for (const seg of targets) { if (seg.key === undefined) continue; active.add(seg.key); + if (seg.lineKey !== undefined) lines.add(seg.lineKey); + + const intoDrawnLine = seg.lineKey !== undefined && drawnLines.has(seg.lineKey); // `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.update(seg.key, seg.d, () => + intoDrawnLine ? this.#collapsedPathData(seg.data) : this.#defaultPathData(seg.data) + ) + ); } + drawnLines = lines; untrack(() => tweens.cleanup(active)); }); } @@ -296,12 +310,14 @@ export class SplineState { * second dashed stretch rather than morphing into the solid one beside it, and the * interpolator handles the two having different point counts. */ - #segmentTargets = $derived.by<(SplineSegment & { key?: any; data: any[] })[] | null>(() => { + #segmentTargets = $derived.by< + (SplineSegment & { key?: any; lineKey?: any; data: any[] })[] | null + >(() => { if (!this.hasAnyStyleFn && !this.zAccessor) return null; const props = this.#props; if (this.geo.projection) return null; - const out: (SplineSegment & { key?: any; data: any[] })[] = []; + const out: (SplineSegment & { key?: any; lineKey?: any; data: any[] })[] = []; for (const lineData of this.lines) { const lineOpacity = this.#lineOpacity(lineData); @@ -338,6 +354,7 @@ export class SplineState { d: this.#buildPath(group.data), data: group.data, key: `${lineKey}\0${group.key}\0${ordinal}`, + lineKey, lineStart: index === 0, lineEnd: index === groups.length - 1, }); @@ -452,6 +469,36 @@ export class SplineState { return ''; } + /** + * The segment collapsed onto its own first point — what a run tweens out of when it appears in + * a line that is already on screen. + * + * Consecutive runs share their boundary point, so a run's first point is the last point of the + * run before it: a dashed bridge that appears mid-line grows out along the line, rather than + * rising from the baseline the way a line drawn for the first time does. + */ + #collapsedPathData(data: any[]): string { + const props = this.#props; + if (!extractTweenConfig(props.motion) || data[0] == null) return ''; + + const x = this.#getScaleValue(data[0], this.ctx.xScale, this.xAccessor); + const y = this.#getScaleValue(data[0], this.ctx.yScale, this.yAccessor); + if (!Number.isFinite(x) || !Number.isFinite(y)) return ''; + + const path = this.ctx.radial + ? lineRadial() + .angle(() => x) + .radius(() => y + this.yOffset) + : d3Line() + .x(() => x + this.xOffset) + .y(() => y + this.yOffset); + + path.defined(props.defined ?? ((d) => this.xAccessor(d) != null && this.yAccessor(d) != null)); + if (props.curve) path.curve(props.curve); + + return path(data) ?? ''; + } + /** * `stroke` / `fill` for the single-path case, resolved the way every other mark resolves them: * a string naming a data property goes through the chart's color scale, anything else is a