Skip to content

Commit f279660

Browse files
committed
feat: New Trail component for variable-width lines
1 parent 435d3bd commit f279660

15 files changed

Lines changed: 1560 additions & 5 deletions

File tree

.changeset/add-trail-component.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'layerchart': minor
3+
---
4+
5+
feat: New Trail component for variable-width lines

docs/src/content/components/Spline.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
description: Marking component which applies data points connected by smooth, curved lines to show trends or patterns over a continuous range.
33
category: marks
44
layers: [svg, canvas]
5-
related: [Path, LineChart]
5+
related: [Path, Trail, LineChart]
66
---
77

88
::tip
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
description: Marking component which renders a line with variable width, useful for encoding a secondary data dimension along a path.
3+
category: marks
4+
layers: [svg, canvas]
5+
related: [Spline, Path]
6+
---
7+
8+
## Usage
9+
10+
:example{ name="basic" showCode }
11+
12+
### Curves and caps
13+
14+
The trail mark supports curve interpolation and two cap styles: `round` (default) and `butt`.
15+
16+
:example{ name="curves" showCode }
17+
18+
## Playground
19+
20+
:example{ name="playground" showCode }
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
{
2+
"component": "Trail",
3+
"examples": [
4+
{
5+
"name": "basic",
6+
"title": "basic",
7+
"path": "/docs/components/Trail/basic",
8+
"components": [
9+
{
10+
"component": "Chart",
11+
"lineNumber": 10,
12+
"line": "<Chart {data} x=\"date\" y=\"value\" yDomain={[0, null]} yNice padding={25} height={300}>"
13+
},
14+
{
15+
"component": "Layer",
16+
"lineNumber": 11,
17+
"line": "<Layer>"
18+
},
19+
{
20+
"component": "Axis",
21+
"lineNumber": 12,
22+
"line": "<Axis placement=\"left\" grid rule />"
23+
},
24+
{
25+
"component": "Trail",
26+
"lineNumber": 14,
27+
"line": "<Trail r={6} class=\"fill-primary\" />"
28+
}
29+
]
30+
},
31+
{
32+
"name": "tdf-stage",
33+
"title": "Tour de France Stage Profile",
34+
"path": "/docs/components/Trail/tdf-stage",
35+
"components": [
36+
{
37+
"component": "Chart",
38+
"lineNumber": 15,
39+
"line": "<Chart"
40+
},
41+
{
42+
"component": "Layer",
43+
"lineNumber": 24,
44+
"line": "<Layer>"
45+
},
46+
{
47+
"component": "Axis",
48+
"lineNumber": 25,
49+
"line": "<Axis placement=\"left\" grid label=\"Latitude\" />"
50+
},
51+
{
52+
"component": "Trail",
53+
"lineNumber": 27,
54+
"line": "<Trail r=\"elev\" class=\"fill-danger/40\" />"
55+
},
56+
{
57+
"component": "Spline",
58+
"lineNumber": 28,
59+
"line": "<Spline class=\"stroke-1 stroke-surface-content\" />"
60+
}
61+
],
62+
"description": "Elevation profile of a Tour de France stage using trail width to encode elevation."
63+
},
64+
{
65+
"name": "variable-width",
66+
"title": "variable width",
67+
"path": "/docs/components/Trail/variable-width",
68+
"components": [
69+
{
70+
"component": "Chart",
71+
"lineNumber": 11,
72+
"line": "<Chart {data} x=\"date\" y=\"value\" yDomain={[0, null]} yNice r=\"value\" rScale={scaleLinear()} rRange={[2, 16]} padding={25} height={300}>"
73+
},
74+
{
75+
"component": "Layer",
76+
"lineNumber": 12,
77+
"line": "<Layer>"
78+
},
79+
{
80+
"component": "Axis",
81+
"lineNumber": 13,
82+
"line": "<Axis placement=\"left\" grid rule />"
83+
},
84+
{
85+
"component": "Trail",
86+
"lineNumber": 15,
87+
"line": "<Trail r=\"value\" class=\"fill-primary\" />"
88+
}
89+
]
90+
}
91+
],
92+
"usage": [
93+
{
94+
"example": "basic",
95+
"component": "Trail",
96+
"path": "/docs/components/Trail/basic",
97+
"lineNumber": 14,
98+
"line": "<Trail r={6} class=\"fill-primary\" />"
99+
},
100+
{
101+
"example": "tdf-stage",
102+
"component": "Trail",
103+
"path": "/docs/components/Trail/tdf-stage",
104+
"lineNumber": 27,
105+
"line": "<Trail r=\"elev\" class=\"fill-danger/40\" />"
106+
},
107+
{
108+
"example": "variable-width",
109+
"component": "Trail",
110+
"path": "/docs/components/Trail/variable-width",
111+
"lineNumber": 15,
112+
"line": "<Trail r=\"value\" class=\"fill-primary\" />"
113+
}
114+
],
115+
"updatedAt": "2026-04-03T15:31:25.944Z"
116+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<script lang="ts">
2+
import { Axis, Chart, Layer, Trail } from 'layerchart';
3+
import { createDateSeries } from '$lib/utils/data.js';
4+
5+
const data = createDateSeries({ count: 30, min: 20, max: 100, value: 'integer' });
6+
7+
export { data };
8+
</script>
9+
10+
<Chart
11+
{data}
12+
x="date"
13+
y="value"
14+
yBaseline={0}
15+
yNice
16+
r="value"
17+
rRange={[0, 15]}
18+
padding={20}
19+
xPadding={[20, 20]}
20+
height={300}
21+
>
22+
<Layer>
23+
<Axis placement="left" grid rule />
24+
<Axis placement="bottom" rule />
25+
<Trail r="value" class="fill-primary" />
26+
</Layer>
27+
</Chart>
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<script module>
2+
export const title = 'Curves and caps';
3+
export const description =
4+
'The trail mark supports round and butt capping and different interpolation methods.';
5+
</script>
6+
7+
<script lang="ts">
8+
import { Chart, Layer, Spline, Trail } from 'layerchart';
9+
import {
10+
curveLinear,
11+
curveNatural,
12+
curveBasis,
13+
curveBumpX,
14+
curveCatmullRom,
15+
curveMonotoneX,
16+
type CurveFactory
17+
} from 'd3-shape';
18+
19+
const curves: { label: string; value: CurveFactory }[] = [
20+
{ label: 'linear', value: curveLinear },
21+
{ label: 'natural', value: curveNatural },
22+
{ label: 'basis', value: curveBasis },
23+
{ label: 'bump-x', value: curveBumpX },
24+
{ label: 'catmull-rom', value: curveCatmullRom },
25+
{ label: 'monotone-x', value: curveMonotoneX }
26+
];
27+
28+
const caps = ['round', 'butt'] as const;
29+
30+
const data = [
31+
{ x: 0, y: 1, r: 2 },
32+
{ x: 1, y: 3, r: 8 },
33+
{ x: 2, y: 0.5, r: 14 },
34+
{ x: 3, y: 2, r: 5 }
35+
];
36+
</script>
37+
38+
<div class="grid grid-cols-2 gap-4">
39+
{#each caps as cap (cap)}
40+
<div>
41+
<div class="text-center text-sm font-semibold text-surface-content/60 mb-2">{cap}</div>
42+
<div class="flex flex-col gap-2">
43+
{#each curves as curve (curve.label)}
44+
<div class="flex items-center gap-2">
45+
<Chart
46+
{data}
47+
x="x"
48+
y="y"
49+
r="r"
50+
rRange={[2, 14]}
51+
padding={10}
52+
height={60}
53+
class="flex-1"
54+
>
55+
<Layer>
56+
<Trail curve={curve.value} {cap} class="fill-primary/40" />
57+
<Spline curve={curve.value} class="stroke-surface-content/40 stroke-1" />
58+
</Layer>
59+
</Chart>
60+
<span class="text-xs text-surface-content/50 w-24 shrink-0">{curve.label}</span>
61+
</div>
62+
{/each}
63+
</div>
64+
</div>
65+
{/each}
66+
</div>
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<script lang="ts">
2+
import type { ComponentProps } from 'svelte';
3+
import { Axis, Chart, Layer, Spline, Trail } from 'layerchart';
4+
import { scaleLinear } from 'd3-scale';
5+
import TrailControls from '$lib/components/controls/TrailControls.svelte';
6+
import CurveMenuField from '$lib/components/controls/fields/CurveMenuField.svelte';
7+
8+
let config = $state({
9+
show: true,
10+
pathGenerator: (x: number) => x,
11+
amplitude: 1,
12+
frequency: 10,
13+
phase: 0,
14+
curve: undefined as ComponentProps<typeof CurveMenuField>['value'],
15+
cap: 'round' as 'round' | 'butt',
16+
pointCount: 30,
17+
showLine: true,
18+
motion: 'none' as 'tween' | 'none'
19+
});
20+
21+
const data = $derived(
22+
Array.from({ length: config.pointCount }).map((_, i) => {
23+
return {
24+
x: i + 1,
25+
y: config.pathGenerator(i / config.pointCount) ?? i
26+
};
27+
})
28+
);
29+
30+
export { data };
31+
</script>
32+
33+
<TrailControls bind:config />
34+
35+
<Chart
36+
{data}
37+
x="x"
38+
y="y"
39+
yNice
40+
r="y"
41+
rScale={scaleLinear()}
42+
rRange={[2, 16]}
43+
padding={25}
44+
height={300}
45+
>
46+
<Layer>
47+
<Axis placement="left" grid rule />
48+
<Axis placement="bottom" rule />
49+
{#if config.show}
50+
<Trail curve={config.curve} cap={config.cap} motion={config.motion} class="fill-primary" />
51+
{#if config.showLine}
52+
<Spline
53+
curve={config.curve}
54+
motion={config.motion}
55+
class="stroke-surface-content/30 stroke-1"
56+
/>
57+
{/if}
58+
{/if}
59+
</Layer>
60+
</Chart>
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<script module>
2+
export const title = 'Tour de France Stage Profile';
3+
export const description = 'Elevation profile of a Tour de France stage using trail width to encode elevation.';
4+
</script>
5+
6+
<script lang="ts">
7+
import { Axis, Chart, Layer, Spline, Trail } from 'layerchart';
8+
import { getTdfStage } from '$lib/data.remote';
9+
10+
const data = $derived(await getTdfStage());
11+
12+
export { data };
13+
</script>
14+
15+
<Chart
16+
{data}
17+
x="long"
18+
y="lat"
19+
r="elev"
20+
rRange={[1, 20]}
21+
padding={{ left: 50, bottom: 30 }}
22+
height={500}
23+
>
24+
<Layer>
25+
<Axis placement="left" grid label="Latitude" />
26+
<Axis placement="bottom" label="Longitude" />
27+
<Trail class="fill-danger/40" />
28+
<Spline class="stroke-1 stroke-surface-content" />
29+
</Layer>
30+
</Chart>

docs/src/lib/components/controls/SplineControls.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
}: Props = $props();
3636
</script>
3737

38-
<div class="grid grid-cols-[auto_1fr_1fr_1fr] gap-2 screenshot-hidden">
38+
<div class="grid grid-cols-[auto_1fr_1fr] gap-2 screenshot-hidden">
3939
<Field label="Show" let:id>
4040
<Switch checked={config.show} on:change={() => (config.show = !config.show)} {id} size="md" />
4141
</Field>
@@ -46,11 +46,11 @@
4646
phase={config.phase}
4747
/>
4848
<CurveMenuField bind:value={config.curve} />
49-
<RangeField label="Points" bind:value={config.pointCount} min={2} />
5049
{#if config.motion !== undefined}
5150
<Field label="Show points" let:id>
5251
<Switch bind:checked={config.showPoints} {id} size="md" />
5352
</Field>
53+
<RangeField label="Points" bind:value={config.pointCount} min={2} />
5454
<Field label="Motion" classes={{ input: 'mt-1 mb-[6px]' }}>
5555
<ToggleGroup bind:value={config.motion} variant="outline" size="sm">
5656
<ToggleOption value="tween">tween</ToggleOption>

0 commit comments

Comments
 (0)