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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/spline-line-ends.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': patch
---

fix(Spline): Render `startContent`, `endContent`, `markerStart`, and `markerEnd` once per line rather than once per style-split segment
5 changes: 5 additions & 0 deletions .changeset/spline-style-run-color.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': patch
---

fix(Spline): Keep the line's color on segments split by a `class`, `opacity`, or `fill` function
5 changes: 5 additions & 0 deletions .changeset/spline-tween-style-runs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': patch
---

fix(Spline): Animate segments split by a style function, which `motion` previously skipped
35 changes: 29 additions & 6 deletions docs/src/content/components/Spline.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,19 +53,42 @@ Pass a function to `stroke`, `fill`, `opacity`, or `class` to style each segment
<Spline z="year" class={(d) => (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
<Spline
motion="tween"
class={(d) => (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

Expand All @@ -77,21 +100,21 @@ 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

A dimension doesn't have to be numeric. Give each one the scale its own data calls for — a point scale over the distinct values of a categorical column, a linear scale over the extent of a quantitative one — and they still share the chart's `y`, since each normalizes to `0–1`.

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

Expand Down
78 changes: 78 additions & 0 deletions docs/src/examples/components/Spline/missing-data-dashed.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<script lang="ts">
import { timeDay } from 'd3-time';
import { Axis, Chart, Layer, Legend, Spline, defaultChartPadding } from 'layerchart';
import { Button } from 'svelte-ux';
import LucideRefreshCw from '~icons/lucide/refresh-cw';

const packages = ['@acme/core', '@acme/charts', '@acme/ui'];

/**
* Daily downloads, with the days the registry never reported simply absent — the way a
* downloads API returns them. `bridged` marks the point a gap *leaves* from, since a run
* takes its style from the point it starts at.
*/
function generate() {
const today = timeDay.floor(new Date());

return packages.flatMap((name, p) => {
const reported: { date: Date; package: string; downloads: number }[] = [];
let downloads = 400 + p * 260;

for (let i = 59; i >= 0; i--) {
downloads = Math.max(80, downloads + Math.round((Math.random() - 0.5) * 90));
// A handful of days nobody reported. Kept off both ends so every line has one.
if (i < 55 && i > 4 && Math.random() < 0.07) continue;
reported.push({ date: timeDay.offset(today, -i), package: name, downloads });
}

return reported.map((d, i, arr) => ({
...d,
bridged: i < arr.length - 1 && timeDay.count(d.date, arr[i + 1].date) > 1
}));
});
}

let data = $state(generate());

export { data };
</script>

<Button
variant="outline"
size="sm"
icon={LucideRefreshCw}
class="mb-2"
onclick={() => (data = generate())}
>
Update data
</Button>

<!--
One line per package (`c` names them, so it splits them too), each split again wherever the
registry stopped reporting. With no `stroke` of its own every run keeps its line's color, and
each tweens to the next update's run of the same style — so the dashed stretches animate
alongside the solid ones instead of redrawing.
-->
<Chart
{data}
x="date"
y="downloads"
yDomain={[0, null]}
yNice
c="package"
cDomain={packages}
cRange={['var(--color-primary)', 'var(--color-secondary)', 'var(--color-warning)']}
padding={defaultChartPadding({ legend: true, left: 44, bottom: 24 })}
height={300}
>
<Layer>
<Axis placement="left" grid rule format="metric" />
<Axis placement="bottom" rule />
<Spline
motion="tween"
class={(d) => (d.bridged ? 'stroke-2 [stroke-dasharray:4_4]' : 'stroke-2')}
/>
</Layer>

<Legend placement="bottom" />
</Chart>
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<script lang="ts">
import { timeDay } from 'd3-time';
import { Axis, Chart, Circle, Layer, Spline, Text } from 'layerchart';

const start = new Date('2024-06-01T00:00:00');

// Downloads by day offset — days 5-7 and day 11 never came back from the registry, so they are
// absent rather than zero
const reported: [offset: number, downloads: number][] = [
[0, 420],
[1, 465],
[2, 430],
[3, 510],
[4, 495],
[8, 620],
[9, 640],
[10, 590],
[12, 705],
[13, 760],
[14, 720],
[15, 810]
];

const rows = reported.map(([offset, downloads]) => ({
date: timeDay.offset(start, offset),
downloads
}));

// A run takes its style from the point it starts at, so the flag marks the day a gap leaves
// from — not the days that are missing, which aren't in the data at all
const data = rows.map((d, i) => ({
...d,
bridged: i < rows.length - 1 && timeDay.count(d.date, rows[i + 1].date) > 1
}));

export { data };
</script>

<!--
The dashes split this one line into five paths, but its ends are still its own: `endContent`
renders once, at the last reported day, and the seams between runs take `markerMid` like the
interior points they are. The dots pick up the line's color from `context-stroke`, which the
runs inherit from the series rather than each having to name it.
-->
<Chart
{data}
x="date"
y="downloads"
yDomain={[0, null]}
yNice
series={[{ key: 'downloads', color: 'var(--color-primary)' }]}
padding={{ top: 8, right: 72, bottom: 24, left: 44 }}
height={300}
>
<Layer>
<Axis placement="left" grid rule format="metric" />
<Axis placement="bottom" rule />
<Spline
seriesKey="downloads"
class={(d) => (d.bridged ? 'stroke-2 [stroke-dasharray:4_4]' : 'stroke-2')}
markerMid={{ type: 'circle', size: 6 }}
>
{#snippet endContent()}
<Circle r={4} class="fill-primary" />
<Text
value={data.at(-1)?.downloads}
format="metric"
verticalAnchor="middle"
dx={8}
class="text-xs fill-primary"
/>
{/snippet}
</Spline>
</Layer>
</Chart>
24 changes: 24 additions & 0 deletions packages/layerchart/src/lib/components/Spline/Spline.base.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -50,13 +58,23 @@
</script>

{#if c.segments}
<!--
Consecutive style runs share their boundary point — it is the last vertex of one path and the
first of the next. That point is interior to the line, so it takes the *mid* marker, once:
from the following run's start, with the preceding run's end left bare.
-->
{#each c.segments as seg, i (i)}
<Path
pathData={seg.d}
stroke={seg.stroke}
fill={seg.fill}
opacity={seg.opacity ?? (c.seriesOpacity === 1 ? undefined : c.seriesOpacity)}
class={seg.class}
markerMid={markerMid ?? marker}
markerStart={seg.lineStart ? (markerStart ?? marker) : (markerMid ?? marker)}
markerEnd={seg.lineEnd ? (markerEnd ?? marker) : undefined}
startContent={seg.lineStart ? startContent : undefined}
endContent={seg.lineEnd ? endContent : undefined}
{...c.series?.props}
{...restProps}
/>
Expand All @@ -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}
/>
Expand Down
Loading
Loading