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.
@@ -150,11 +150,7 @@
150
150
  rule = false,
151
151
  grid = false,
152
152
  ticks,
153
- tickSpacing = ['top', 'bottom', 'angle'].includes(placement)
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: tickCount,
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
- if (!tweenOptions) return '';
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
- const tweenState = createMotion(defaultPathData(), () => d, tweenOptions);
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={tweenOptions ? tweenState.current : d}
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';
@@ -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
@@ -5,7 +5,7 @@
5
5
  "license": "MIT",
6
6
  "repository": "techniq/layerchart",
7
7
  "homepage": "https://layerchart.com",
8
- "version": "2.0.0-next.49",
8
+ "version": "2.0.0-next.50",
9
9
  "devDependencies": {
10
10
  "@changesets/cli": "^2.30.0",
11
11
  "@sveltejs/adapter-auto": "^7.0.1",