Skip to content

Commit 1274e2c

Browse files
committed
feat(Spline): Support function-valued stroke, fill, and opacity for per-segment styling
1 parent 2badfaa commit 1274e2c

6 files changed

Lines changed: 352 additions & 30 deletions

File tree

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(Spline): Support function-valued `stroke`, `fill`, and `opacity` for per-segment styling

docs/src/content/components/Spline.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ See also: [LineChart](/docs/components/LineChart) for simplified examples
1313

1414
:example{ name="basic" showCode }
1515

16+
### Per-segment styling
17+
18+
Pass a function to `stroke`, `fill`, or `opacity` to style each segment independently. Consecutive data points with the same resolved value are grouped into separate path segments.
19+
20+
:example{ name="stroke-grouping" showCode }
21+
1622
### Geo mode
1723

1824
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.
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
<script lang="ts">
2+
import { Chart, Axis, LinearGradient, Layer, Spline } from 'layerchart';
3+
import { curveBumpX } from 'd3-shape';
4+
import { scaleLinear, scalePoint } from 'd3-scale';
5+
import { cls } from '@layerstack/tailwind';
6+
7+
const states = [
8+
{ name: 'AK', ranks: [51, 51, 51, 51, 51, 51, 51, 50, 48, 47, 48] },
9+
{ name: 'AL', ranks: [18, 15, 17, 17, 19, 21, 22, 22, 23, 23, 24] },
10+
{ name: 'AR', ranks: [25, 25, 24, 31, 31, 32, 33, 33, 33, 32, 33] },
11+
{ name: 'AZ', ranks: [46, 44, 44, 38, 35, 33, 29, 24, 20, 16, 14] },
12+
{ name: 'CA', ranks: [8, 6, 4, 2, 2, 1, 1, 1, 1, 1, 1] },
13+
{ name: 'CO', ranks: [33, 33, 33, 34, 33, 30, 28, 26, 24, 22, 21] },
14+
{ name: 'CT', ranks: [29, 29, 31, 28, 25, 24, 25, 27, 29, 29, 29] },
15+
{ name: 'DC', ranks: [42, 41, 37, 36, 40, 41, 47, 48, 50, 50, 49] },
16+
{ name: 'DE', ranks: [48, 48, 48, 48, 47, 47, 48, 46, 45, 45, 45] },
17+
{ name: 'FL', ranks: [32, 31, 25, 20, 10, 9, 7, 4, 4, 4, 3] },
18+
{ name: 'GA', ranks: [12, 14, 14, 13, 16, 15, 13, 11, 10, 9, 8] },
19+
{ name: 'HI', ranks: [47, 46, 46, 46, 44, 40, 39, 40, 42, 40, 40] },
20+
{ name: 'IA', ranks: [17, 19, 20, 22, 24, 25, 27, 30, 30, 30, 31] },
21+
{ name: 'ID', ranks: [43, 43, 43, 44, 43, 43, 41, 42, 39, 39, 38] },
22+
{ name: 'IL', ranks: [3, 3, 3, 4, 4, 5, 5, 6, 5, 5, 6] },
23+
{ name: 'IN', ranks: [11, 11, 12, 11, 11, 11, 12, 14, 14, 15, 17] },
24+
{ name: 'KS', ranks: [24, 24, 29, 30, 28, 28, 32, 32, 32, 33, 35] },
25+
{ name: 'KY', ranks: [15, 16, 16, 19, 22, 23, 23, 23, 25, 26, 26] },
26+
{ name: 'LA', ranks: [22, 22, 21, 21, 20, 20, 19, 21, 22, 25, 25] },
27+
{ name: 'MA', ranks: [6, 8, 8, 9, 9, 10, 11, 13, 13, 14, 15] },
28+
{ name: 'MD', ranks: [28, 28, 28, 24, 21, 18, 18, 19, 19, 19, 18] },
29+
{ name: 'ME', ranks: [35, 35, 35, 35, 36, 38, 38, 38, 40, 41, 42] },
30+
{ name: 'MI', ranks: [7, 7, 7, 7, 7, 7, 8, 8, 8, 8, 10] },
31+
{ name: 'MN', ranks: [16, 18, 18, 18, 18, 19, 21, 20, 21, 21, 22] },
32+
{ name: 'MO', ranks: [9, 10, 10, 12, 13, 13, 15, 15, 17, 18, 19] },
33+
{ name: 'MS', ranks: [23, 23, 23, 26, 29, 29, 31, 31, 31, 31, 34] },
34+
{ name: 'MT', ranks: [39, 39, 40, 43, 42, 44, 44, 44, 44, 44, 44] },
35+
{ name: 'NC', ranks: [14, 12, 11, 10, 12, 12, 10, 10, 11, 10, 9] },
36+
{ name: 'ND', ranks: [36, 38, 39, 42, 45, 46, 46, 47, 47, 48, 47] },
37+
{ name: 'NE', ranks: [31, 32, 32, 33, 34, 35, 35, 36, 38, 38, 37] },
38+
{ name: 'NH', ranks: [41, 42, 45, 45, 46, 42, 42, 41, 41, 42, 41] },
39+
{ name: 'NJ', ranks: [10, 9, 9, 8, 8, 8, 9, 9, 9, 11, 11] },
40+
{ name: 'NM', ranks: [44, 45, 42, 40, 37, 37, 37, 37, 36, 36, 36] },
41+
{ name: 'NV', ranks: [50, 50, 50, 50, 50, 48, 43, 39, 35, 35, 32] },
42+
{ name: 'NY', ranks: [1, 1, 1, 1, 1, 2, 2, 2, 3, 3, 4] },
43+
{ name: 'OH', ranks: [4, 4, 5, 5, 5, 6, 6, 7, 7, 7, 7] },
44+
{ name: 'OK', ranks: [21, 21, 22, 25, 27, 27, 26, 28, 27, 28, 28] },
45+
{ name: 'OR', ranks: [34, 34, 34, 32, 32, 31, 30, 29, 28, 27, 27] },
46+
{ name: 'PA', ranks: [2, 2, 2, 3, 3, 3, 4, 5, 6, 6, 5] },
47+
{ name: 'RI', ranks: [38, 37, 36, 37, 39, 39, 40, 43, 43, 43, 43] },
48+
{ name: 'SC', ranks: [26, 26, 27, 27, 26, 26, 24, 25, 26, 24, 23] },
49+
{ name: 'SD', ranks: [37, 36, 38, 41, 41, 45, 45, 45, 46, 46, 46] },
50+
{ name: 'TN', ranks: [20, 17, 15, 15, 17, 17, 17, 18, 16, 17, 16] },
51+
{ name: 'TX', ranks: [5, 5, 6, 6, 6, 4, 3, 3, 2, 2, 2] },
52+
{ name: 'UT', ranks: [40, 40, 41, 39, 38, 36, 36, 35, 34, 34, 30] },
53+
{ name: 'VA', ranks: [19, 20, 19, 16, 14, 14, 14, 12, 12, 12, 12] },
54+
{ name: 'VT', ranks: [45, 47, 47, 47, 48, 49, 49, 49, 49, 49, 50] },
55+
{ name: 'WA', ranks: [30, 30, 30, 23, 23, 22, 20, 17, 15, 13, 13] },
56+
{ name: 'WI', ranks: [13, 13, 13, 14, 15, 16, 16, 16, 18, 20, 20] },
57+
{ name: 'WV', ranks: [27, 27, 26, 29, 30, 34, 34, 34, 37, 37, 39] },
58+
{ name: 'WY', ranks: [49, 49, 49, 49, 49, 50, 50, 51, 51, 51, 51] }
59+
];
60+
61+
const years = [
62+
'1920',
63+
'1930',
64+
'1940',
65+
'1950',
66+
'1960',
67+
'1970',
68+
'1980',
69+
'1990',
70+
'2000',
71+
'2010',
72+
'2020'
73+
];
74+
const maxRank = 51;
75+
const rowHeight = 14;
76+
77+
const data = years.map((year, i) => {
78+
const row: Record<string, string | number> = { year };
79+
for (const state of states) {
80+
row[state.name] = state.ranks[i];
81+
}
82+
return row;
83+
});
84+
export { data };
85+
86+
const keys = states.map((s) => s.name);
87+
88+
let hoveredState = $state<string | null>(null);
89+
</script>
90+
91+
<Chart
92+
{data}
93+
x="year"
94+
xScale={scalePoint()}
95+
y={keys}
96+
yScale={scaleLinear()}
97+
yDomain={[maxRank + 0.5, 0.5]}
98+
padding={{ top: 30, bottom: 30, left: 14, right: 18 }}
99+
height={maxRank * rowHeight + 60}
100+
>
101+
{#snippet children({ context })}
102+
<Layer>
103+
<LinearGradient
104+
id="gradient-improved"
105+
stops={['var(--color-success-700)', 'var(--color-success-300)']}
106+
/>
107+
<LinearGradient
108+
id="gradient-declined"
109+
stops={['var(--color-danger-300)', 'var(--color-danger-700)']}
110+
/>
111+
112+
<Axis placement="top" rule={false} />
113+
<Axis placement="bottom" rule={false} />
114+
115+
<!-- Lines (one Spline per state) -->
116+
{#each states as state (state.name)}
117+
{@const dimmed = hoveredState !== null && hoveredState !== state.name}
118+
<!-- svelte-ignore a11y_no_static_element_interactions -->
119+
<g
120+
onmouseenter={() => (hoveredState = state.name)}
121+
class={cls('transition-opacity duration-200', dimmed && 'opacity-[0.15]')}
122+
>
123+
<Spline
124+
y={state.name}
125+
curve={curveBumpX}
126+
stroke={(d, i, arr) => {
127+
if (i >= arr.length - 1)
128+
return 'color-mix(in srgb, var(--color-surface-content) 30%, transparent)';
129+
const from = d[state.name];
130+
const to = arr[i + 1][state.name];
131+
return from > to
132+
? 'url(#gradient-improved)'
133+
: from < to
134+
? 'url(#gradient-declined)'
135+
: 'color-mix(in srgb, var(--color-surface-content) 30%, transparent)';
136+
}}
137+
strokeWidth={4}
138+
/>
139+
</g>
140+
{/each}
141+
142+
<!-- Labels at each point -->
143+
{#each states as state (state.name)}
144+
{@const dimmed = hoveredState !== null && hoveredState !== state.name}
145+
{#each data as point, i (point.year)}
146+
{@const x = context.xScale(point.year)}
147+
{@const y = context.yScale(point[state.name])}
148+
{@const from = state.ranks[i === 0 ? 0 : i - 1]}
149+
{@const to = state.ranks[i === 0 ? 1 : i]}
150+
<!-- svelte-ignore a11y_no_static_element_interactions -->
151+
<rect
152+
x={x - 12}
153+
y={y - rowHeight / 2}
154+
width={24}
155+
height={rowHeight}
156+
class={cls('fill-surface-200 transition-opacity duration-200', dimmed && '_opacity-10')}
157+
onmouseenter={() => (hoveredState = state.name)}
158+
onmouseleave={() => (hoveredState = null)}
159+
/>
160+
<text
161+
{x}
162+
{y}
163+
text-anchor="middle"
164+
dominant-baseline="central"
165+
pointer-events="none"
166+
class={cls(
167+
'text-[12px] font-semibold font-[monospace] transition-opacity duration-200',
168+
from > to ? 'fill-success' : from < to ? 'fill-danger' : 'fill-surface-content/50',
169+
dimmed && 'opacity-10'
170+
)}
171+
>
172+
{state.name}
173+
</text>
174+
{/each}
175+
{/each}
176+
</Layer>
177+
{/snippet}
178+
</Chart>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<script lang="ts">
2+
import { scaleOrdinal } from 'd3-scale';
3+
import { quantize } from 'd3-interpolate';
4+
import { interpolateSpectral } from 'd3-scale-chromatic';
5+
import { Axis, Chart, Layer, Spline } from 'layerchart';
6+
import { getAppleStock } from '$lib/data.remote';
7+
8+
const data = $derived(await getAppleStock());
9+
10+
const yearColor = scaleOrdinal<number, string>(quantize(interpolateSpectral, 6));
11+
12+
export { data };
13+
</script>
14+
15+
<Chart {data} x="date" y="value" yNice padding={25} height={300}>
16+
<Layer>
17+
<Axis placement="left" grid rule />
18+
<Axis placement="bottom" rule />
19+
<Spline stroke={(d) => yearColor(d.date.getFullYear())} class="stroke-2" />
20+
</Layer>
21+
</Chart>

0 commit comments

Comments
 (0)