layerchart 2.0.0-next.49 → 2.0.0-next.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/Axis.svelte +30 -6
- package/dist/components/Spline.svelte +12 -20
- package/dist/components/Trail.svelte +256 -0
- package/dist/components/Trail.svelte.d.ts +88 -0
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.js +2 -0
- package/dist/utils/trail.d.ts +36 -0
- package/dist/utils/trail.js +341 -0
- package/package.json +1 -1
|
@@ -150,11 +150,7 @@
|
|
|
150
150
|
rule = false,
|
|
151
151
|
grid = false,
|
|
152
152
|
ticks,
|
|
153
|
-
tickSpacing
|
|
154
|
-
? 80
|
|
155
|
-
: ['left', 'right', 'radius'].includes(placement)
|
|
156
|
-
? 50
|
|
157
|
-
: undefined,
|
|
153
|
+
tickSpacing: tickSpacingProp,
|
|
158
154
|
tickMultiline = false,
|
|
159
155
|
tickLength = 4,
|
|
160
156
|
tickMarks = true,
|
|
@@ -185,10 +181,28 @@
|
|
|
185
181
|
const scale = $derived(
|
|
186
182
|
scaleProp ?? (['horizontal', 'angle'].includes(orientation) ? ctx.xScale : ctx.yScale)
|
|
187
183
|
);
|
|
184
|
+
|
|
188
185
|
const interval = $derived(
|
|
189
186
|
['horizontal', 'angle'].includes(orientation) ? ctx.xInterval : ctx.yInterval
|
|
190
187
|
);
|
|
191
188
|
|
|
189
|
+
const defaultTickSpacing = $derived(
|
|
190
|
+
['top', 'bottom', 'angle'].includes(placement)
|
|
191
|
+
? 80
|
|
192
|
+
: ['left', 'right', 'radius'].includes(placement)
|
|
193
|
+
? 50
|
|
194
|
+
: undefined
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// Disable tick thinning for categorical band scales (no interval), but keep spacing for date-based band scales
|
|
198
|
+
const tickSpacing = $derived(
|
|
199
|
+
tickSpacingProp !== undefined
|
|
200
|
+
? tickSpacingProp
|
|
201
|
+
: isScaleBand(scale) && interval == null
|
|
202
|
+
? null
|
|
203
|
+
: defaultTickSpacing
|
|
204
|
+
);
|
|
205
|
+
|
|
192
206
|
// Default format to 'percentRound' for stackExpand layout considering axis direction
|
|
193
207
|
const resolvedFormat = $derived.by(() => {
|
|
194
208
|
if (format !== undefined) return format;
|
|
@@ -231,6 +245,7 @@
|
|
|
231
245
|
return ctxSize;
|
|
232
246
|
});
|
|
233
247
|
|
|
248
|
+
// Count used for tick thinning (null tickSpacing disables thinning)
|
|
234
249
|
const tickCount = $derived(
|
|
235
250
|
typeof ticks === 'number'
|
|
236
251
|
? ticks
|
|
@@ -238,6 +253,15 @@
|
|
|
238
253
|
? Math.round(effectiveSize / tickSpacing)
|
|
239
254
|
: undefined
|
|
240
255
|
);
|
|
256
|
+
|
|
257
|
+
// Count used for formatting (always based on default spacing so time formatting works)
|
|
258
|
+
const formatCount = $derived(
|
|
259
|
+
typeof ticks === 'number'
|
|
260
|
+
? ticks
|
|
261
|
+
: defaultTickSpacing && effectiveSize
|
|
262
|
+
? Math.round(effectiveSize / defaultTickSpacing)
|
|
263
|
+
: undefined
|
|
264
|
+
);
|
|
241
265
|
const tickVals = $derived.by(() => {
|
|
242
266
|
let tickVals = autoTickVals(scale, ticks, tickCount);
|
|
243
267
|
|
|
@@ -280,7 +304,7 @@
|
|
|
280
304
|
autoTickFormat({
|
|
281
305
|
scale,
|
|
282
306
|
ticks,
|
|
283
|
-
count:
|
|
307
|
+
count: formatCount,
|
|
284
308
|
formatType: resolvedFormat,
|
|
285
309
|
multiline: tickMultiline,
|
|
286
310
|
placement,
|
|
@@ -90,11 +90,7 @@
|
|
|
90
90
|
import { getGeoContext } from '../contexts/geo.js';
|
|
91
91
|
import { resolveColorProp, resolveStyleProp } from '../utils/dataProp.js';
|
|
92
92
|
import Path, { type PathProps } from './Path.svelte';
|
|
93
|
-
import {
|
|
94
|
-
createMotion,
|
|
95
|
-
extractTweenConfig,
|
|
96
|
-
type ResolvedMotion,
|
|
97
|
-
} from '../utils/motion.svelte.js';
|
|
93
|
+
import { createMotion, extractTweenConfig } from '../utils/motion.svelte.js';
|
|
98
94
|
|
|
99
95
|
const ctx = getChartContext();
|
|
100
96
|
const geo = getGeoContext();
|
|
@@ -231,24 +227,13 @@
|
|
|
231
227
|
}));
|
|
232
228
|
});
|
|
233
229
|
|
|
234
|
-
const extractedTween = extractTweenConfig(motion);
|
|
235
|
-
|
|
236
|
-
const tweenOptions: ResolvedMotion | undefined = extractedTween
|
|
237
|
-
? {
|
|
238
|
-
type: extractedTween.type,
|
|
239
|
-
options: {
|
|
240
|
-
interpolate: interpolatePath,
|
|
241
|
-
...extractedTween.options,
|
|
242
|
-
},
|
|
243
|
-
}
|
|
244
|
-
: undefined;
|
|
245
|
-
|
|
246
230
|
/**
|
|
247
231
|
* Provide initial `0` horizontal baseline so the line animates up from y=0 on mount.
|
|
248
232
|
* Computes a proper flattened path using d3-line with all y-values at baseline.
|
|
249
233
|
*/
|
|
250
234
|
function defaultPathData() {
|
|
251
|
-
|
|
235
|
+
// Skip baseline computation when motion is not initially enabled (faster initial render)
|
|
236
|
+
if (!extractTweenConfig(motion)) return '';
|
|
252
237
|
|
|
253
238
|
if (ctx.config.x) {
|
|
254
239
|
const resolvedData = data ?? series?.data ?? ctx.data;
|
|
@@ -271,7 +256,14 @@
|
|
|
271
256
|
return '';
|
|
272
257
|
}
|
|
273
258
|
|
|
274
|
-
|
|
259
|
+
// Always create tween motion so it's ready when motion is toggled on
|
|
260
|
+
const tweenState = createMotion(defaultPathData(), () => d, {
|
|
261
|
+
type: 'tween',
|
|
262
|
+
interpolate: interpolatePath,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
/** Reactively check whether motion is enabled */
|
|
266
|
+
const isTweened = $derived(extractTweenConfig(motion) != null);
|
|
275
267
|
|
|
276
268
|
const seriesOpacity = $derived(
|
|
277
269
|
series?.key == null ||
|
|
@@ -298,7 +290,7 @@
|
|
|
298
290
|
{/each}
|
|
299
291
|
{:else}
|
|
300
292
|
<Path
|
|
301
|
-
pathData={
|
|
293
|
+
pathData={isTweened ? tweenState.current : d}
|
|
302
294
|
stroke={(typeof stroke === 'string' ? stroke : undefined) ?? series?.color}
|
|
303
295
|
fill={typeof fill === 'string' ? fill : undefined}
|
|
304
296
|
{...series?.props}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { CurveFactory, CurveFactoryLineOnly, Line } from 'd3-shape';
|
|
3
|
+
|
|
4
|
+
import { accessor, type Accessor } from '../utils/common.js';
|
|
5
|
+
import type { MotionProp } from '../utils/motion.svelte.js';
|
|
6
|
+
import type { DataProp } from '../utils/dataProp.js';
|
|
7
|
+
import type { TrailCap } from '../utils/trail.js';
|
|
8
|
+
import type { PathProps } from './Path.svelte';
|
|
9
|
+
|
|
10
|
+
export type TrailPropsWithoutHTML = {
|
|
11
|
+
/**
|
|
12
|
+
* Override data instead of using context
|
|
13
|
+
*/
|
|
14
|
+
data?: any;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Override `x` accessor from Chart context
|
|
18
|
+
*/
|
|
19
|
+
x?: Accessor;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Override `y` accessor from Chart context
|
|
23
|
+
*/
|
|
24
|
+
y?: Accessor;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Series key to use for accessor. Only applicable if `<Chart>` uses `series` and `x`/`y` are not set.
|
|
28
|
+
*/
|
|
29
|
+
seriesKey?: string;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Function to determine if a point is defined
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* <Trail defined={(d) => d.value !== null} />
|
|
36
|
+
*/
|
|
37
|
+
defined?: Parameters<Line<any>['defined']>[0];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Width at each point. Falls back to Chart's `r` accessor if not set.
|
|
41
|
+
* - `number`: pixel value (direct)
|
|
42
|
+
* - `string`: data property name, resolved via rScale
|
|
43
|
+
* - `function(d)`: accessor called per data item, result passed through rScale
|
|
44
|
+
*
|
|
45
|
+
* @default 4 (when Chart `r` is also not set)
|
|
46
|
+
*/
|
|
47
|
+
r?: DataProp;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Curve interpolation applied to the trail centerline.
|
|
51
|
+
* @example
|
|
52
|
+
* import { curveNatural } from 'd3-shape';
|
|
53
|
+
* <Trail curve={curveNatural} />
|
|
54
|
+
*/
|
|
55
|
+
curve?: CurveFactory | CurveFactoryLineOnly;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Cap style for trail endpoints.
|
|
59
|
+
* - 'round' (default): semicircular end caps
|
|
60
|
+
* - 'butt': flat ends with polygon offset outline
|
|
61
|
+
* @default 'round'
|
|
62
|
+
*/
|
|
63
|
+
cap?: TrailCap;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Tension parameter for applicable curve factories (0–1).
|
|
67
|
+
* Applied via curveCardinal.tension(), curveCatmullRom.alpha(), or curveBundle.beta().
|
|
68
|
+
*/
|
|
69
|
+
tension?: number;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Number of interpolated samples per segment for curve resampling.
|
|
73
|
+
* Auto-estimated when omitted.
|
|
74
|
+
*/
|
|
75
|
+
resolution?: number;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Fill color
|
|
79
|
+
*/
|
|
80
|
+
fill?: string;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Fill opacity
|
|
84
|
+
*/
|
|
85
|
+
fillOpacity?: number;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Opacity
|
|
89
|
+
*/
|
|
90
|
+
opacity?: number;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* CSS class
|
|
94
|
+
*/
|
|
95
|
+
class?: string;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Whether to animate the path using tweened interpolation.
|
|
99
|
+
*/
|
|
100
|
+
motion?: MotionProp;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export type TrailProps = TrailPropsWithoutHTML &
|
|
104
|
+
Omit<PathProps, keyof TrailPropsWithoutHTML | 'r'>;
|
|
105
|
+
</script>
|
|
106
|
+
|
|
107
|
+
<script lang="ts">
|
|
108
|
+
import { max } from 'd3-array';
|
|
109
|
+
import { interpolatePath } from 'd3-interpolate-path';
|
|
110
|
+
import { cls } from '@layerstack/tailwind';
|
|
111
|
+
|
|
112
|
+
import { isScaleBand } from '../utils/scales.svelte.js';
|
|
113
|
+
import { resolveDataProp } from '../utils/dataProp.js';
|
|
114
|
+
import { getChartContext } from '../contexts/chart.js';
|
|
115
|
+
import { computeTrailPath } from '../utils/trail.js';
|
|
116
|
+
import { createMotion, extractTweenConfig } from '../utils/motion.svelte.js';
|
|
117
|
+
import Path from './Path.svelte';
|
|
118
|
+
|
|
119
|
+
const ctx = getChartContext();
|
|
120
|
+
|
|
121
|
+
let {
|
|
122
|
+
data,
|
|
123
|
+
x,
|
|
124
|
+
y,
|
|
125
|
+
seriesKey,
|
|
126
|
+
defined,
|
|
127
|
+
r,
|
|
128
|
+
curve,
|
|
129
|
+
cap,
|
|
130
|
+
tension,
|
|
131
|
+
resolution,
|
|
132
|
+
fill,
|
|
133
|
+
fillOpacity,
|
|
134
|
+
opacity,
|
|
135
|
+
motion,
|
|
136
|
+
class: className,
|
|
137
|
+
...restProps
|
|
138
|
+
}: TrailProps = $props();
|
|
139
|
+
|
|
140
|
+
let series = $derived(ctx.series.series.find((s) => s.key === seriesKey));
|
|
141
|
+
let seriesAccessor = $derived(series?.value ?? (series?.data ? undefined : series?.key));
|
|
142
|
+
|
|
143
|
+
const xAccessor = $derived(
|
|
144
|
+
accessor(x ?? (ctx.valueAxis === 'x' ? seriesAccessor : undefined) ?? ctx.x)
|
|
145
|
+
);
|
|
146
|
+
const yAccessor = $derived(
|
|
147
|
+
accessor(y ?? (ctx.valueAxis === 'y' ? seriesAccessor : undefined) ?? ctx.y)
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const xOffset = $derived(isScaleBand(ctx.xScale) ? ctx.xScale.bandwidth() / 2 : 0);
|
|
151
|
+
const yOffset = $derived(isScaleBand(ctx.yScale) ? ctx.yScale.bandwidth() / 2 : 0);
|
|
152
|
+
|
|
153
|
+
function getScaleValue(
|
|
154
|
+
data: any,
|
|
155
|
+
scale: typeof ctx.xScale | typeof ctx.yScale,
|
|
156
|
+
accessor: Function
|
|
157
|
+
) {
|
|
158
|
+
let value = accessor(data);
|
|
159
|
+
|
|
160
|
+
if (Array.isArray(value)) {
|
|
161
|
+
value = max(value);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (scale.domain().length) {
|
|
165
|
+
return scale(value);
|
|
166
|
+
} else {
|
|
167
|
+
return value;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Resolve r per data point: prop > Chart r accessor > default 4 */
|
|
172
|
+
const resolvedR = $derived(r ?? (ctx.config.r as DataProp | undefined));
|
|
173
|
+
|
|
174
|
+
const trailPath = $derived.by(() => {
|
|
175
|
+
const resolvedData = data ?? series?.data ?? ctx.data;
|
|
176
|
+
const definedFn = defined ?? ((d: any) => xAccessor(d) != null && yAccessor(d) != null);
|
|
177
|
+
|
|
178
|
+
const points = resolvedData
|
|
179
|
+
.filter((d: any, i: number) => definedFn(d, i, resolvedData))
|
|
180
|
+
.map((d: any) => ({
|
|
181
|
+
x: getScaleValue(d, ctx.xScale, xAccessor) + xOffset,
|
|
182
|
+
y: getScaleValue(d, ctx.yScale, yAccessor) + yOffset,
|
|
183
|
+
r: resolvedR != null
|
|
184
|
+
? resolveDataProp(resolvedR, d, ctx.rScale, typeof resolvedR === 'number' ? resolvedR : 4)
|
|
185
|
+
: 4,
|
|
186
|
+
}));
|
|
187
|
+
|
|
188
|
+
return computeTrailPath(points, { curve, cap, tension, resolution });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Provide initial baseline trail so it animates up from y=0 on mount.
|
|
193
|
+
* Computes a trail path with all y-values at baseline.
|
|
194
|
+
*/
|
|
195
|
+
function defaultPathData() {
|
|
196
|
+
// Skip baseline computation when motion is not initially enabled (faster initial render)
|
|
197
|
+
if (!extractTweenConfig(motion)) return '';
|
|
198
|
+
|
|
199
|
+
if (ctx.config.x) {
|
|
200
|
+
const resolvedData = data ?? series?.data ?? ctx.data;
|
|
201
|
+
const definedFn = defined ?? ((d: any) => xAccessor(d) != null && yAccessor(d) != null);
|
|
202
|
+
const baseline = Math.min(ctx.yScale(0) ?? ctx.yRange[0], ctx.yRange[0]);
|
|
203
|
+
|
|
204
|
+
const points = resolvedData
|
|
205
|
+
.filter((d: any, i: number) => definedFn(d, i, resolvedData))
|
|
206
|
+
.map((d: any) => ({
|
|
207
|
+
x: getScaleValue(d, ctx.xScale, xAccessor) + xOffset,
|
|
208
|
+
y: baseline,
|
|
209
|
+
r: resolvedR != null
|
|
210
|
+
? resolveDataProp(resolvedR, d, ctx.rScale, typeof resolvedR === 'number' ? resolvedR : 4)
|
|
211
|
+
: 4,
|
|
212
|
+
}));
|
|
213
|
+
|
|
214
|
+
return computeTrailPath(points, { curve, cap, tension, resolution });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return '';
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Always create tween motion so it's ready when motion is toggled on
|
|
221
|
+
const tweenState = createMotion(defaultPathData(), () => trailPath, {
|
|
222
|
+
type: 'tween',
|
|
223
|
+
interpolate: interpolatePath,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
/** Reactively check whether motion is enabled */
|
|
227
|
+
const isTweened = $derived(extractTweenConfig(motion) != null);
|
|
228
|
+
|
|
229
|
+
ctx.registerComponent({
|
|
230
|
+
name: 'Trail',
|
|
231
|
+
kind: 'mark',
|
|
232
|
+
markInfo: () => ({ data, x, y, seriesKey, curve }),
|
|
233
|
+
});
|
|
234
|
+
</script>
|
|
235
|
+
|
|
236
|
+
<Path
|
|
237
|
+
pathData={isTweened ? tweenState.current : trailPath}
|
|
238
|
+
{fill}
|
|
239
|
+
fillOpacity={fillOpacity}
|
|
240
|
+
{opacity}
|
|
241
|
+
stroke="none"
|
|
242
|
+
class={cls('lc-trail', className)}
|
|
243
|
+
{...restProps}
|
|
244
|
+
/>
|
|
245
|
+
|
|
246
|
+
<style>
|
|
247
|
+
@layer base {
|
|
248
|
+
:global(:where(.lc-trail)) {
|
|
249
|
+
--fill-color: var(--color-surface-content, currentColor);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
:global(:where(.lc-layout-svg .lc-trail, svg.lc-trail):not([fill])) {
|
|
253
|
+
fill: var(--fill-color);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
</style>
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { CurveFactory, CurveFactoryLineOnly, Line } from 'd3-shape';
|
|
2
|
+
import { type Accessor } from '../utils/common.js';
|
|
3
|
+
import type { MotionProp } from '../utils/motion.svelte.js';
|
|
4
|
+
import type { DataProp } from '../utils/dataProp.js';
|
|
5
|
+
import type { TrailCap } from '../utils/trail.js';
|
|
6
|
+
import type { PathProps } from './Path.svelte';
|
|
7
|
+
export type TrailPropsWithoutHTML = {
|
|
8
|
+
/**
|
|
9
|
+
* Override data instead of using context
|
|
10
|
+
*/
|
|
11
|
+
data?: any;
|
|
12
|
+
/**
|
|
13
|
+
* Override `x` accessor from Chart context
|
|
14
|
+
*/
|
|
15
|
+
x?: Accessor;
|
|
16
|
+
/**
|
|
17
|
+
* Override `y` accessor from Chart context
|
|
18
|
+
*/
|
|
19
|
+
y?: Accessor;
|
|
20
|
+
/**
|
|
21
|
+
* Series key to use for accessor. Only applicable if `<Chart>` uses `series` and `x`/`y` are not set.
|
|
22
|
+
*/
|
|
23
|
+
seriesKey?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Function to determine if a point is defined
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* <Trail defined={(d) => d.value !== null} />
|
|
29
|
+
*/
|
|
30
|
+
defined?: Parameters<Line<any>['defined']>[0];
|
|
31
|
+
/**
|
|
32
|
+
* Width at each point. Falls back to Chart's `r` accessor if not set.
|
|
33
|
+
* - `number`: pixel value (direct)
|
|
34
|
+
* - `string`: data property name, resolved via rScale
|
|
35
|
+
* - `function(d)`: accessor called per data item, result passed through rScale
|
|
36
|
+
*
|
|
37
|
+
* @default 4 (when Chart `r` is also not set)
|
|
38
|
+
*/
|
|
39
|
+
r?: DataProp;
|
|
40
|
+
/**
|
|
41
|
+
* Curve interpolation applied to the trail centerline.
|
|
42
|
+
* @example
|
|
43
|
+
* import { curveNatural } from 'd3-shape';
|
|
44
|
+
* <Trail curve={curveNatural} />
|
|
45
|
+
*/
|
|
46
|
+
curve?: CurveFactory | CurveFactoryLineOnly;
|
|
47
|
+
/**
|
|
48
|
+
* Cap style for trail endpoints.
|
|
49
|
+
* - 'round' (default): semicircular end caps
|
|
50
|
+
* - 'butt': flat ends with polygon offset outline
|
|
51
|
+
* @default 'round'
|
|
52
|
+
*/
|
|
53
|
+
cap?: TrailCap;
|
|
54
|
+
/**
|
|
55
|
+
* Tension parameter for applicable curve factories (0–1).
|
|
56
|
+
* Applied via curveCardinal.tension(), curveCatmullRom.alpha(), or curveBundle.beta().
|
|
57
|
+
*/
|
|
58
|
+
tension?: number;
|
|
59
|
+
/**
|
|
60
|
+
* Number of interpolated samples per segment for curve resampling.
|
|
61
|
+
* Auto-estimated when omitted.
|
|
62
|
+
*/
|
|
63
|
+
resolution?: number;
|
|
64
|
+
/**
|
|
65
|
+
* Fill color
|
|
66
|
+
*/
|
|
67
|
+
fill?: string;
|
|
68
|
+
/**
|
|
69
|
+
* Fill opacity
|
|
70
|
+
*/
|
|
71
|
+
fillOpacity?: number;
|
|
72
|
+
/**
|
|
73
|
+
* Opacity
|
|
74
|
+
*/
|
|
75
|
+
opacity?: number;
|
|
76
|
+
/**
|
|
77
|
+
* CSS class
|
|
78
|
+
*/
|
|
79
|
+
class?: string;
|
|
80
|
+
/**
|
|
81
|
+
* Whether to animate the path using tweened interpolation.
|
|
82
|
+
*/
|
|
83
|
+
motion?: MotionProp;
|
|
84
|
+
};
|
|
85
|
+
export type TrailProps = TrailPropsWithoutHTML & Omit<PathProps, keyof TrailPropsWithoutHTML | 'r'>;
|
|
86
|
+
declare const Trail: import("svelte").Component<TrailProps, {}, "">;
|
|
87
|
+
type Trail = ReturnType<typeof Trail>;
|
|
88
|
+
export default Trail;
|
|
@@ -140,6 +140,8 @@ export { default as Svg } from './layers/Svg.svelte';
|
|
|
140
140
|
export * from './layers/Svg.svelte';
|
|
141
141
|
export { default as Text } from './Text.svelte';
|
|
142
142
|
export * from './Text.svelte';
|
|
143
|
+
export { default as Trail } from './Trail.svelte';
|
|
144
|
+
export * from './Trail.svelte';
|
|
143
145
|
export { default as Threshold } from './Threshold.svelte';
|
|
144
146
|
export * from './Threshold.svelte';
|
|
145
147
|
export { default as TileImage } from './TileImage.svelte';
|
package/dist/components/index.js
CHANGED
|
@@ -140,6 +140,8 @@ export { default as Svg } from './layers/Svg.svelte';
|
|
|
140
140
|
export * from './layers/Svg.svelte';
|
|
141
141
|
export { default as Text } from './Text.svelte';
|
|
142
142
|
export * from './Text.svelte';
|
|
143
|
+
export { default as Trail } from './Trail.svelte';
|
|
144
|
+
export * from './Trail.svelte';
|
|
143
145
|
export { default as Threshold } from './Threshold.svelte';
|
|
144
146
|
export * from './Threshold.svelte';
|
|
145
147
|
export { default as TileImage } from './TileImage.svelte';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trail path utilities for variable-width lines.
|
|
3
|
+
*
|
|
4
|
+
* Round cap (capsule) approach adapted from Vega's trail mark.
|
|
5
|
+
* Copyright (c) 2015-2023, University of Washington Interactive Data Lab.
|
|
6
|
+
* BSD 3-Clause License: https://github.com/vega/vega/blob/main/LICENSE
|
|
7
|
+
*
|
|
8
|
+
* Butt cap (polygon offset) and curve resampling via fake context
|
|
9
|
+
* adapted from SveltePlot's trail mark.
|
|
10
|
+
* Copyright 2024-2026, Gregor Aisch.
|
|
11
|
+
* ISC License: https://github.com/svelteplot/svelteplot/blob/main/LICENSE
|
|
12
|
+
*/
|
|
13
|
+
import type { CurveFactory, CurveFactoryLineOnly } from 'd3-shape';
|
|
14
|
+
export type TrailPoint = {
|
|
15
|
+
x: number;
|
|
16
|
+
y: number;
|
|
17
|
+
r: number;
|
|
18
|
+
};
|
|
19
|
+
export type TrailCap = 'round' | 'butt';
|
|
20
|
+
export type TrailPathOptions = {
|
|
21
|
+
/** Curve interpolation factory from d3-shape */
|
|
22
|
+
curve?: CurveFactory | CurveFactoryLineOnly;
|
|
23
|
+
/** Cap style for trail endpoints. @default 'round' */
|
|
24
|
+
cap?: TrailCap;
|
|
25
|
+
/** Tension parameter for applicable curve factories (0–1) */
|
|
26
|
+
tension?: number;
|
|
27
|
+
/** Samples per segment for curve interpolation. Auto-estimated when omitted. */
|
|
28
|
+
resolution?: number;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Computes a filled SVG path for a trail with variable width.
|
|
32
|
+
*
|
|
33
|
+
* Supports optional curve interpolation (via d3-shape curve factories)
|
|
34
|
+
* and two cap styles: 'round' (capsule-based) and 'butt' (polygon offset).
|
|
35
|
+
*/
|
|
36
|
+
export declare function computeTrailPath(points: TrailPoint[], options?: TrailPathOptions): string;
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trail path utilities for variable-width lines.
|
|
3
|
+
*
|
|
4
|
+
* Round cap (capsule) approach adapted from Vega's trail mark.
|
|
5
|
+
* Copyright (c) 2015-2023, University of Washington Interactive Data Lab.
|
|
6
|
+
* BSD 3-Clause License: https://github.com/vega/vega/blob/main/LICENSE
|
|
7
|
+
*
|
|
8
|
+
* Butt cap (polygon offset) and curve resampling via fake context
|
|
9
|
+
* adapted from SveltePlot's trail mark.
|
|
10
|
+
* Copyright 2024-2026, Gregor Aisch.
|
|
11
|
+
* ISC License: https://github.com/svelteplot/svelteplot/blob/main/LICENSE
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Computes a filled SVG path for a trail with variable width.
|
|
15
|
+
*
|
|
16
|
+
* Supports optional curve interpolation (via d3-shape curve factories)
|
|
17
|
+
* and two cap styles: 'round' (capsule-based) and 'butt' (polygon offset).
|
|
18
|
+
*/
|
|
19
|
+
export function computeTrailPath(points, options = {}) {
|
|
20
|
+
if (points.length === 0)
|
|
21
|
+
return '';
|
|
22
|
+
const { curve, cap = 'round', tension, resolution } = options;
|
|
23
|
+
let drawPoints = points;
|
|
24
|
+
// Resample through curve if provided
|
|
25
|
+
if (curve) {
|
|
26
|
+
drawPoints = resampleWithCurve(points, curve, tension, resolution);
|
|
27
|
+
}
|
|
28
|
+
if (drawPoints.length === 0)
|
|
29
|
+
return '';
|
|
30
|
+
if (drawPoints.length === 1) {
|
|
31
|
+
const { x, y, r } = drawPoints[0];
|
|
32
|
+
if (cap === 'butt')
|
|
33
|
+
return '';
|
|
34
|
+
return `M${x - r},${y}A${r},${r},0,1,1,${x + r},${y}A${r},${r},0,1,1,${x - r},${y}Z`;
|
|
35
|
+
}
|
|
36
|
+
return cap === 'butt' ? trailPathButt(drawPoints) : trailPathRound(drawPoints);
|
|
37
|
+
}
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Round caps (capsule-based, original approach)
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
function trailPathRound(points) {
|
|
42
|
+
let d = '';
|
|
43
|
+
for (let i = 0; i < points.length - 1; i++) {
|
|
44
|
+
const p1 = points[i];
|
|
45
|
+
const p2 = points[i + 1];
|
|
46
|
+
const r1 = p1.r;
|
|
47
|
+
const r2 = p2.r;
|
|
48
|
+
let nx = p1.y - p2.y;
|
|
49
|
+
let ny = p2.x - p1.x;
|
|
50
|
+
const len = Math.hypot(nx, ny);
|
|
51
|
+
if (len < 1e-6)
|
|
52
|
+
continue;
|
|
53
|
+
nx /= len;
|
|
54
|
+
ny /= len;
|
|
55
|
+
const x1L = p1.x - nx * r1, y1L = p1.y - ny * r1;
|
|
56
|
+
const x1R = p1.x + nx * r1, y1R = p1.y + ny * r1;
|
|
57
|
+
const x2L = p2.x - nx * r2, y2L = p2.y - ny * r2;
|
|
58
|
+
const x2R = p2.x + nx * r2, y2R = p2.y + ny * r2;
|
|
59
|
+
d += `M${x1L},${y1L}`;
|
|
60
|
+
d += `L${x2L},${y2L}`;
|
|
61
|
+
d += `A${r2},${r2},0,0,1,${x2R},${y2R}`;
|
|
62
|
+
d += `L${x1R},${y1R}`;
|
|
63
|
+
d += `A${r1},${r1},0,0,1,${x1L},${y1L}`;
|
|
64
|
+
d += 'Z';
|
|
65
|
+
}
|
|
66
|
+
return d;
|
|
67
|
+
}
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Butt caps (polygon offset using angle bisector normals)
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
function trailPathButt(points) {
|
|
72
|
+
const n = points.length;
|
|
73
|
+
if (n < 2)
|
|
74
|
+
return '';
|
|
75
|
+
const left = [];
|
|
76
|
+
const right = [];
|
|
77
|
+
const normalize = (x, y) => {
|
|
78
|
+
const len = Math.hypot(x, y);
|
|
79
|
+
return len === 0 ? [0, 0] : [x / len, y / len];
|
|
80
|
+
};
|
|
81
|
+
for (let i = 0; i < n; i++) {
|
|
82
|
+
const curr = points[i];
|
|
83
|
+
const r = curr.r;
|
|
84
|
+
const hasPrev = i > 0;
|
|
85
|
+
const hasNext = i < n - 1;
|
|
86
|
+
const prev = hasPrev ? points[i - 1] : curr;
|
|
87
|
+
const next = hasNext ? points[i + 1] : curr;
|
|
88
|
+
const dirPrev = hasPrev
|
|
89
|
+
? normalize(curr.x - prev.x, curr.y - prev.y)
|
|
90
|
+
: normalize(next.x - curr.x, next.y - curr.y);
|
|
91
|
+
const dirNext = hasNext
|
|
92
|
+
? normalize(next.x - curr.x, next.y - curr.y)
|
|
93
|
+
: dirPrev;
|
|
94
|
+
// Perpendicular normals (rotate 90° CCW)
|
|
95
|
+
const normPrev = [-dirPrev[1], dirPrev[0]];
|
|
96
|
+
const normNext = [-dirNext[1], dirNext[0]];
|
|
97
|
+
// Average normal (bisector direction)
|
|
98
|
+
let nx = normPrev[0] + normNext[0];
|
|
99
|
+
let ny = normPrev[1] + normNext[1];
|
|
100
|
+
const nLen = Math.hypot(nx, ny);
|
|
101
|
+
if (nLen < 1e-6) {
|
|
102
|
+
nx = normPrev[0];
|
|
103
|
+
ny = normPrev[1];
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
nx /= nLen;
|
|
107
|
+
ny /= nLen;
|
|
108
|
+
}
|
|
109
|
+
// Miter scale: compensate for the angle so offset width is correct
|
|
110
|
+
const dot = nx * normPrev[0] + ny * normPrev[1];
|
|
111
|
+
const safeDot = Math.abs(dot) < 1e-6 ? 1 : dot;
|
|
112
|
+
// Clamp miter to avoid extreme spikes at sharp turns
|
|
113
|
+
const scale = Math.min(r / safeDot, r * 4);
|
|
114
|
+
const ox = nx * scale;
|
|
115
|
+
const oy = ny * scale;
|
|
116
|
+
left.push([curr.x + ox, curr.y + oy]);
|
|
117
|
+
right.push([curr.x - ox, curr.y - oy]);
|
|
118
|
+
}
|
|
119
|
+
let d = `M${left[0][0]},${left[0][1]}`;
|
|
120
|
+
for (let i = 1; i < left.length; i++) {
|
|
121
|
+
d += `L${left[i][0]},${left[i][1]}`;
|
|
122
|
+
}
|
|
123
|
+
for (let i = right.length - 1; i >= 0; i--) {
|
|
124
|
+
d += `L${right[i][0]},${right[i][1]}`;
|
|
125
|
+
}
|
|
126
|
+
d += 'Z';
|
|
127
|
+
return d;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Resample points through a d3-shape curve factory, producing dense
|
|
131
|
+
* intermediate points with interpolated radii.
|
|
132
|
+
*/
|
|
133
|
+
function resampleWithCurve(points, curveFactory, tension, resolution) {
|
|
134
|
+
if (points.length < 2)
|
|
135
|
+
return points;
|
|
136
|
+
// Apply tension if the curve factory supports it
|
|
137
|
+
const factory = applyTension(curveFactory, tension);
|
|
138
|
+
// Capture curve commands via a fake context
|
|
139
|
+
const commands = [];
|
|
140
|
+
let currentPoint = null;
|
|
141
|
+
let currentRadius = points[0].r;
|
|
142
|
+
let pendingRadius = points[0].r;
|
|
143
|
+
const ctx = {
|
|
144
|
+
moveTo(x, y) {
|
|
145
|
+
currentPoint = [x, y];
|
|
146
|
+
currentRadius = pendingRadius;
|
|
147
|
+
commands.push({ type: 'M', x, y, r: currentRadius });
|
|
148
|
+
},
|
|
149
|
+
lineTo(x, y) {
|
|
150
|
+
const from = currentPoint ?? [x, y];
|
|
151
|
+
commands.push({
|
|
152
|
+
type: 'L',
|
|
153
|
+
from: [from[0], from[1], currentRadius],
|
|
154
|
+
to: [x, y, pendingRadius],
|
|
155
|
+
});
|
|
156
|
+
currentPoint = [x, y];
|
|
157
|
+
currentRadius = pendingRadius;
|
|
158
|
+
},
|
|
159
|
+
bezierCurveTo(x1, y1, x2, y2, x, y) {
|
|
160
|
+
const from = currentPoint ?? [x, y];
|
|
161
|
+
commands.push({
|
|
162
|
+
type: 'C',
|
|
163
|
+
from: [from[0], from[1], currentRadius],
|
|
164
|
+
cp1: [x1, y1],
|
|
165
|
+
cp2: [x2, y2],
|
|
166
|
+
to: [x, y, pendingRadius],
|
|
167
|
+
});
|
|
168
|
+
currentPoint = [x, y];
|
|
169
|
+
currentRadius = pendingRadius;
|
|
170
|
+
},
|
|
171
|
+
quadraticCurveTo(x1, y1, x, y) {
|
|
172
|
+
const from = currentPoint ?? [x, y];
|
|
173
|
+
commands.push({
|
|
174
|
+
type: 'Q',
|
|
175
|
+
from: [from[0], from[1], currentRadius],
|
|
176
|
+
cp: [x1, y1],
|
|
177
|
+
to: [x, y, pendingRadius],
|
|
178
|
+
});
|
|
179
|
+
currentPoint = [x, y];
|
|
180
|
+
currentRadius = pendingRadius;
|
|
181
|
+
},
|
|
182
|
+
closePath() { },
|
|
183
|
+
beginPath() { },
|
|
184
|
+
arc() { },
|
|
185
|
+
rect() { },
|
|
186
|
+
};
|
|
187
|
+
// Drive the curve factory with our points
|
|
188
|
+
const curve = factory(ctx);
|
|
189
|
+
curve.lineStart();
|
|
190
|
+
for (const pt of points) {
|
|
191
|
+
pendingRadius = pt.r;
|
|
192
|
+
curve.point(pt.x, pt.y);
|
|
193
|
+
}
|
|
194
|
+
curve.lineEnd();
|
|
195
|
+
// Determine samples per segment
|
|
196
|
+
const samplesPerSegment = resolution ?? estimateSamplesPerSegment(points);
|
|
197
|
+
// Flatten captured commands into dense points
|
|
198
|
+
const dense = flattenCommands(commands, samplesPerSegment);
|
|
199
|
+
if (dense.length === 0)
|
|
200
|
+
return points;
|
|
201
|
+
// Interpolate radii based on arc-length proportion
|
|
202
|
+
interpolateRadii(points, dense);
|
|
203
|
+
return dense;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Apply tension to a curve factory if it supports it.
|
|
207
|
+
* d3-shape conventions: curveCardinal.tension(), curveCatmullRom.alpha(), curveBundle.beta()
|
|
208
|
+
*/
|
|
209
|
+
function applyTension(factory, tension) {
|
|
210
|
+
if (tension == null)
|
|
211
|
+
return factory;
|
|
212
|
+
const f = factory;
|
|
213
|
+
if (typeof f.tension === 'function')
|
|
214
|
+
return f.tension(tension);
|
|
215
|
+
if (typeof f.alpha === 'function')
|
|
216
|
+
return f.alpha(tension);
|
|
217
|
+
if (typeof f.beta === 'function')
|
|
218
|
+
return f.beta(tension);
|
|
219
|
+
return factory;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Auto-estimate samples per segment based on average segment length relative to radius.
|
|
223
|
+
*/
|
|
224
|
+
function estimateSamplesPerSegment(points) {
|
|
225
|
+
let distSum = 0;
|
|
226
|
+
let rSum = 0;
|
|
227
|
+
for (let i = 0; i < points.length; i++) {
|
|
228
|
+
rSum += points[i].r;
|
|
229
|
+
if (i > 0) {
|
|
230
|
+
distSum += Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const meanDist = points.length > 1 ? distSum / (points.length - 1) : 0;
|
|
234
|
+
const meanRadius = rSum / points.length;
|
|
235
|
+
const base = meanRadius > 0 ? meanDist / meanRadius : meanDist;
|
|
236
|
+
return Math.max(4, Math.min(32, Math.round(base || 8)));
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Flatten captured path commands into dense {x, y, r} samples.
|
|
240
|
+
* Bézier curves are subdivided parametrically.
|
|
241
|
+
*/
|
|
242
|
+
function flattenCommands(commands, samplesPerSegment) {
|
|
243
|
+
const result = [];
|
|
244
|
+
let last = null;
|
|
245
|
+
const push = (x, y, r) => {
|
|
246
|
+
// Deduplicate consecutive identical points
|
|
247
|
+
if (last && Math.abs(last[0] - x) < 1e-6 && Math.abs(last[1] - y) < 1e-6)
|
|
248
|
+
return;
|
|
249
|
+
result.push({ x, y, r });
|
|
250
|
+
last = [x, y];
|
|
251
|
+
};
|
|
252
|
+
for (const cmd of commands) {
|
|
253
|
+
if (cmd.type === 'M') {
|
|
254
|
+
push(cmd.x, cmd.y, cmd.r);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (cmd.type === 'L') {
|
|
258
|
+
const [x0, y0, r0] = cmd.from;
|
|
259
|
+
const [x1, y1, r1] = cmd.to;
|
|
260
|
+
for (let s = 1; s <= samplesPerSegment; s++) {
|
|
261
|
+
const t = s / samplesPerSegment;
|
|
262
|
+
push(lerp(x0, x1, t), lerp(y0, y1, t), lerp(r0, r1, t));
|
|
263
|
+
}
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (cmd.type === 'C') {
|
|
267
|
+
const [x0, y0, r0] = cmd.from;
|
|
268
|
+
const [x1, y1] = cmd.cp1;
|
|
269
|
+
const [x2, y2] = cmd.cp2;
|
|
270
|
+
const [x3, y3, r3] = cmd.to;
|
|
271
|
+
for (let s = 1; s <= samplesPerSegment; s++) {
|
|
272
|
+
const t = s / samplesPerSegment;
|
|
273
|
+
push(cubic(x0, x1, x2, x3, t), cubic(y0, y1, y2, y3, t), lerp(r0, r3, t));
|
|
274
|
+
}
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (cmd.type === 'Q') {
|
|
278
|
+
const [x0, y0, r0] = cmd.from;
|
|
279
|
+
const [cx, cy] = cmd.cp;
|
|
280
|
+
const [x1, y1, r1] = cmd.to;
|
|
281
|
+
for (let s = 1; s <= samplesPerSegment; s++) {
|
|
282
|
+
const t = s / samplesPerSegment;
|
|
283
|
+
push(quad(x0, cx, x1, t), quad(y0, cy, y1, t), lerp(r0, r1, t));
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return result;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Assign interpolated radii to dense resampled points based on
|
|
291
|
+
* arc-length proportion relative to the original points.
|
|
292
|
+
*/
|
|
293
|
+
function interpolateRadii(original, dense) {
|
|
294
|
+
if (original.length < 2 || dense.length < 2)
|
|
295
|
+
return;
|
|
296
|
+
// Cumulative arc-length of original points
|
|
297
|
+
const origCum = [0];
|
|
298
|
+
for (let i = 1; i < original.length; i++) {
|
|
299
|
+
origCum.push(origCum[i - 1] + Math.hypot(original[i].x - original[i - 1].x, original[i].y - original[i - 1].y));
|
|
300
|
+
}
|
|
301
|
+
const origTotal = origCum[origCum.length - 1] || 1;
|
|
302
|
+
// Cumulative arc-length of dense points
|
|
303
|
+
const denseCum = [0];
|
|
304
|
+
for (let i = 1; i < dense.length; i++) {
|
|
305
|
+
denseCum.push(denseCum[i - 1] + Math.hypot(dense[i].x - dense[i - 1].x, dense[i].y - dense[i - 1].y));
|
|
306
|
+
}
|
|
307
|
+
const denseTotal = denseCum[denseCum.length - 1] || 1;
|
|
308
|
+
for (let i = 0; i < dense.length; i++) {
|
|
309
|
+
const frac = denseCum[i] / denseTotal;
|
|
310
|
+
const target = frac * origTotal;
|
|
311
|
+
// Find bracketing segment in original
|
|
312
|
+
let idx = 1;
|
|
313
|
+
while (idx < origCum.length && origCum[idx] < target)
|
|
314
|
+
idx++;
|
|
315
|
+
if (idx >= origCum.length) {
|
|
316
|
+
dense[i].r = original[original.length - 1].r;
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
const t0 = origCum[idx - 1];
|
|
320
|
+
const t1 = origCum[idx];
|
|
321
|
+
const r0 = original[idx - 1].r;
|
|
322
|
+
const r1 = original[idx].r;
|
|
323
|
+
const t = t1 === t0 ? 0 : (target - t0) / (t1 - t0);
|
|
324
|
+
dense[i].r = lerp(r0, r1, t);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
// Math helpers
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
function lerp(a, b, t) {
|
|
332
|
+
return a + (b - a) * t;
|
|
333
|
+
}
|
|
334
|
+
function cubic(p0, p1, p2, p3, t) {
|
|
335
|
+
const it = 1 - t;
|
|
336
|
+
return it * it * it * p0 + 3 * it * it * t * p1 + 3 * it * t * t * p2 + t * t * t * p3;
|
|
337
|
+
}
|
|
338
|
+
function quad(p0, p1, p2, t) {
|
|
339
|
+
const it = 1 - t;
|
|
340
|
+
return it * it * p0 + 2 * it * t * p1 + t * t * p2;
|
|
341
|
+
}
|
package/package.json
CHANGED