layerchart 2.0.0-next.47 → 2.0.0-next.49

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.
Files changed (67) hide show
  1. package/dist/bench/PrimitiveBench.svelte +66 -0
  2. package/dist/bench/PrimitiveBench.svelte.d.ts +10 -0
  3. package/dist/bench/primitives.svelte.bench.d.ts +1 -0
  4. package/dist/bench/primitives.svelte.bench.js +42 -0
  5. package/dist/components/Axis.svelte +14 -3
  6. package/dist/components/Axis.svelte.d.ts +1 -1
  7. package/dist/components/Chart.svelte +110 -12
  8. package/dist/components/Circle.svelte +20 -17
  9. package/dist/components/Contour.svelte +90 -13
  10. package/dist/components/Contour.svelte.d.ts +8 -0
  11. package/dist/components/Ellipse.svelte +18 -16
  12. package/dist/components/GeoPath.svelte +1 -1
  13. package/dist/components/Group.svelte +14 -12
  14. package/dist/components/Image.svelte +18 -16
  15. package/dist/components/Labels.svelte +56 -11
  16. package/dist/components/Labels.svelte.d.ts +3 -2
  17. package/dist/components/Line.svelte +18 -16
  18. package/dist/components/LinearGradient.svelte +1 -1
  19. package/dist/components/Marker.svelte +8 -3
  20. package/dist/components/Marker.svelte.d.ts +1 -1
  21. package/dist/components/Month.svelte +273 -0
  22. package/dist/components/Month.svelte.d.ts +70 -0
  23. package/dist/components/Path.svelte +28 -12
  24. package/dist/components/Polygon.svelte +25 -23
  25. package/dist/components/RadialGradient.svelte +1 -1
  26. package/dist/components/Raster.svelte +117 -29
  27. package/dist/components/Raster.svelte.d.ts +8 -0
  28. package/dist/components/Rect.svelte +26 -20
  29. package/dist/components/Spline.svelte +123 -25
  30. package/dist/components/Spline.svelte.d.ts +18 -1
  31. package/dist/components/Text.svelte +45 -20
  32. package/dist/components/Text.svelte.d.ts +6 -0
  33. package/dist/components/TransformContext.svelte +8 -0
  34. package/dist/components/TransformContext.svelte.test.d.ts +1 -0
  35. package/dist/components/TransformContext.svelte.test.js +166 -0
  36. package/dist/components/Vector.svelte +14 -12
  37. package/dist/components/index.d.ts +2 -0
  38. package/dist/components/index.js +2 -0
  39. package/dist/components/tests/TransformTestHarness.svelte +27 -0
  40. package/dist/components/tests/TransformTestHarness.svelte.d.ts +8 -0
  41. package/dist/states/__fixtures__/ComponentNodeLifecycleChild.svelte +1 -1
  42. package/dist/states/__screenshots__/chart.component-node.svelte.test.ts/ChartState-registerComponent-cleans-up-child-nodes-and-mark-registrations-when-components-unmount-1.png +0 -0
  43. package/dist/states/__screenshots__/chart.component-node.svelte.test.ts/ChartState-registerComponent-cleans-up-child-nodes-and-mark-registrations-when-components-unmount-2.png +0 -0
  44. package/dist/states/brush.svelte.d.ts +26 -17
  45. package/dist/states/brush.svelte.js +118 -25
  46. package/dist/states/brush.svelte.test.js +126 -1
  47. package/dist/states/chart.svelte.d.ts +6 -0
  48. package/dist/states/chart.svelte.js +100 -21
  49. package/dist/states/chart.svelte.test.js +16 -1
  50. package/dist/states/transform.svelte.js +3 -1
  51. package/dist/utils/dataProp.d.ts +2 -10
  52. package/dist/utils/dataProp.js +16 -5
  53. package/dist/utils/index.d.ts +1 -0
  54. package/dist/utils/index.js +1 -0
  55. package/dist/utils/motion.svelte.d.ts +12 -2
  56. package/dist/utils/motion.svelte.js +22 -0
  57. package/dist/utils/motion.test.js +49 -1
  58. package/dist/utils/rasterBounds.d.ts +18 -0
  59. package/dist/utils/rasterBounds.js +98 -0
  60. package/dist/utils/rasterBounds.test.d.ts +1 -0
  61. package/dist/utils/rasterBounds.test.js +63 -0
  62. package/dist/utils/scales.svelte.js +4 -2
  63. package/dist/utils/scales.svelte.test.d.ts +1 -0
  64. package/dist/utils/scales.svelte.test.js +67 -0
  65. package/dist/utils/ticks.js +7 -3
  66. package/dist/utils/ticks.test.js +13 -3
  67. package/package.json +3 -2
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { accessor, type Accessor } from '../utils/common.js';
7
7
  import type { MotionProp } from '../utils/motion.svelte.js';
8
+ import type { ColorProp, StyleProp } from '../utils/dataProp.js';
8
9
 
9
10
  export type SplinePropsWithoutHTML = {
10
11
  /**
@@ -46,12 +47,31 @@
46
47
  */
47
48
  curve?: CurveFactory | CurveFactoryLineOnly;
48
49
 
50
+ /**
51
+ * Stroke color or function returning stroke per data point.
52
+ * When a function, consecutive points with the same value are grouped
53
+ * into separate path segments, enabling per-segment styling.
54
+ */
55
+ stroke?: ColorProp;
56
+
57
+ /**
58
+ * Fill color or function returning fill per data point.
59
+ * When a function, acts as a grouping channel like `stroke`.
60
+ */
61
+ fill?: ColorProp;
62
+
63
+ /**
64
+ * Opacity or function returning opacity per data point.
65
+ * When a function, acts as a grouping channel like `stroke`.
66
+ */
67
+ opacity?: StyleProp<number | undefined>;
68
+
49
69
  /**
50
70
  * Whether to animate the path using tweened interpolation.
51
71
  * When set, the line will animate from the baseline (y=0) on mount.
52
72
  */
53
73
  motion?: MotionProp;
54
- } & Omit<PathProps, 'x' | 'y' | 'motion'>;
74
+ } & Omit<PathProps, 'x' | 'y' | 'motion' | 'stroke' | 'fill' | 'opacity'>;
55
75
 
56
76
  export type SplineProps = SplinePropsWithoutHTML &
57
77
  Without<SVGAttributes<SVGPathElement>, SplinePropsWithoutHTML>;
@@ -68,6 +88,7 @@
68
88
  import { isScaleBand } from '../utils/scales.svelte.js';
69
89
  import { getChartContext } from '../contexts/chart.js';
70
90
  import { getGeoContext } from '../contexts/geo.js';
91
+ import { resolveColorProp, resolveStyleProp } from '../utils/dataProp.js';
71
92
  import Path, { type PathProps } from './Path.svelte';
72
93
  import {
73
94
  createMotion,
@@ -78,12 +99,12 @@
78
99
  const ctx = getChartContext();
79
100
  const geo = getGeoContext();
80
101
 
81
- let { data, x, y, seriesKey, defined, curve, stroke, motion, ...restProps }: SplineProps = $props();
102
+ let { data, x, y, seriesKey, defined, curve, stroke, fill, opacity, motion, ...restProps }: SplineProps = $props();
82
103
 
83
104
  ctx.registerComponent({
84
105
  name: 'Spline',
85
106
  kind: 'mark',
86
- markInfo: () => ({ data, x, y, seriesKey, color: stroke as string | undefined }),
107
+ markInfo: () => ({ data, x, y, seriesKey, color: typeof stroke === 'string' ? stroke : undefined }),
87
108
  });
88
109
 
89
110
  function getScaleValue(
@@ -119,7 +140,25 @@
119
140
  const xOffset = $derived(isScaleBand(ctx.xScale) ? ctx.xScale.bandwidth() / 2 : 0);
120
141
  const yOffset = $derived(isScaleBand(ctx.yScale) ? ctx.yScale.bandwidth() / 2 : 0);
121
142
 
143
+ function buildPath(resolvedData: any[]) {
144
+ const path = ctx.radial
145
+ ? lineRadial()
146
+ .angle((d) => getScaleValue(d, ctx.xScale, xAccessor) + 0) // Never apply xOffset (LineChart radar, BarChart radial, ...)?
147
+ .radius((d) => getScaleValue(d, ctx.yScale, yAccessor) + yOffset)
148
+ : d3Line()
149
+ .x((d) => getScaleValue(d, ctx.xScale, xAccessor) + xOffset)
150
+ .y((d) => getScaleValue(d, ctx.yScale, yAccessor) + yOffset);
151
+
152
+ path.defined(defined ?? ((d) => xAccessor(d) != null && yAccessor(d) != null));
153
+ if (curve) path.curve(curve);
154
+
155
+ return path(resolvedData) ?? '';
156
+ }
157
+
122
158
  const d = $derived.by(() => {
159
+ // Skip full-path computation when segments mode handles rendering
160
+ if (hasAnyStyleFn && !geo.projection) return '';
161
+
123
162
  const resolvedData = data ?? series?.data ?? ctx.data;
124
163
 
125
164
  // Geo mode: convert data to GeoJSON LineString and render via geoPath
@@ -135,19 +174,61 @@
135
174
  return d3GeoPath(geo.projection)(lineString) ?? '';
136
175
  }
137
176
 
138
- const path = ctx.radial
139
- ? lineRadial()
140
- .angle((d) => getScaleValue(d, ctx.xScale, xAccessor) + 0) // Never apply xOffset (LineChart radar, BarChart radial, ...)?
177
+ return buildPath(resolvedData);
178
+ });
141
179
 
142
- .radius((d) => getScaleValue(d, ctx.yScale, yAccessor) + yOffset)
143
- : d3Line()
144
- .x((d) => getScaleValue(d, ctx.xScale, xAccessor) + xOffset)
145
- .y((d) => getScaleValue(d, ctx.yScale, yAccessor) + yOffset);
180
+ type SegmentStyle = { stroke?: string; fill?: string; opacity?: number };
146
181
 
147
- path.defined(defined ?? ((d) => xAccessor(d) != null && yAccessor(d) != null));
148
- if (curve) path.curve(curve);
182
+ /**
183
+ * Groups consecutive data points by a composite key derived from function-valued style props.
184
+ * The key at index `i` determines the style for the segment from point `i` to point `i+1`.
185
+ * Each group includes an overlap of 1 point at boundaries for curve continuity.
186
+ */
187
+ function groupConsecutive(
188
+ data: any[],
189
+ keyFn: (d: any, index: number, data: any[]) => { key: string; style: SegmentStyle }
190
+ ): Array<{ style: SegmentStyle; data: any[] }> {
191
+ if (data.length < 2) return [];
192
+
193
+ const groups: Array<{ style: SegmentStyle; data: any[] }> = [];
194
+ let current = keyFn(data[0], 0, data);
195
+ let startIdx = 0;
196
+
197
+ for (let i = 1; i < data.length; i++) {
198
+ const next = keyFn(data[i], i, data);
199
+ if (next.key !== current.key) {
200
+ groups.push({ style: current.style, data: data.slice(startIdx, i + 1) });
201
+ startIdx = i;
202
+ current = next;
203
+ }
204
+ }
205
+ if (data.length - startIdx >= 2) {
206
+ groups.push({ style: current.style, data: data.slice(startIdx) });
207
+ }
149
208
 
150
- return path(resolvedData) ?? '';
209
+ return groups;
210
+ }
211
+
212
+ const hasAnyStyleFn = $derived(
213
+ typeof stroke === 'function' || typeof fill === 'function' || typeof opacity === 'function'
214
+ );
215
+
216
+ const segments = $derived.by(() => {
217
+ if (!hasAnyStyleFn) return null;
218
+ const resolvedData = data ?? series?.data ?? ctx.data;
219
+ if (geo.projection) return null;
220
+
221
+ const groups = groupConsecutive(resolvedData, (d, i, arr) => {
222
+ const s = resolveColorProp(stroke, d, ctx.cScale, i, arr);
223
+ const f = resolveColorProp(fill, d, ctx.cScale, i, arr);
224
+ const o = resolveStyleProp(opacity, d, i, arr);
225
+ return { key: `${s}\0${f}\0${o}`, style: { stroke: s, fill: f, opacity: o } };
226
+ });
227
+
228
+ return groups.map((group) => ({
229
+ ...group.style,
230
+ d: buildPath(group.data),
231
+ }));
151
232
  });
152
233
 
153
234
  const extractedTween = extractTweenConfig(motion);
@@ -191,20 +272,37 @@
191
272
  }
192
273
 
193
274
  const tweenState = createMotion(defaultPathData(), () => d, tweenOptions);
275
+
276
+ const seriesOpacity = $derived(
277
+ series?.key == null ||
278
+ ctx.series.visibleSeries.length <= 1 ||
279
+ ctx.series.isHighlighted(series.key, true)
280
+ ? 1
281
+ : 0.1
282
+ );
194
283
  </script>
195
284
 
196
285
  <!-- TODO: handle in LineChart/etc? -->
197
286
  <!-- class: cls(props.spline?.class, s.props?.class), -->
198
287
 
199
- <Path
200
- pathData={tweenOptions ? tweenState.current : d}
201
- stroke={stroke ?? series?.color}
202
- opacity={series?.key == null ||
203
- // Checking `visibleSeries.length <= 1` fixes re-animated tweened areas on hover
204
- ctx.series.visibleSeries.length <= 1 ||
205
- ctx.series.isHighlighted(series.key, true)
206
- ? 1
207
- : 0.1}
208
- {...series?.props}
209
- {...restProps}
210
- />
288
+ {#if segments}
289
+ {#each segments as seg, i (i)}
290
+ <Path
291
+ pathData={seg.d}
292
+ stroke={seg.stroke}
293
+ fill={seg.fill}
294
+ {...series?.props}
295
+ {...restProps}
296
+ opacity={seg.opacity ?? seriesOpacity}
297
+ />
298
+ {/each}
299
+ {:else}
300
+ <Path
301
+ pathData={tweenOptions ? tweenState.current : d}
302
+ stroke={(typeof stroke === 'string' ? stroke : undefined) ?? series?.color}
303
+ fill={typeof fill === 'string' ? fill : undefined}
304
+ {...series?.props}
305
+ {...restProps}
306
+ opacity={(typeof opacity === 'number' ? opacity : undefined) ?? seriesOpacity}
307
+ />
308
+ {/if}
@@ -3,6 +3,7 @@ import type { SVGAttributes } from 'svelte/elements';
3
3
  import type { CurveFactory, CurveFactoryLineOnly, Line } from 'd3-shape';
4
4
  import { type Accessor } from '../utils/common.js';
5
5
  import type { MotionProp } from '../utils/motion.svelte.js';
6
+ import type { ColorProp, StyleProp } from '../utils/dataProp.js';
6
7
  export type SplinePropsWithoutHTML = {
7
8
  /**
8
9
  * Override data instead of using context
@@ -37,12 +38,28 @@ export type SplinePropsWithoutHTML = {
37
38
  * @type {CurveFactory | CurveFactoryLineOnly | undefined}
38
39
  */
39
40
  curve?: CurveFactory | CurveFactoryLineOnly;
41
+ /**
42
+ * Stroke color or function returning stroke per data point.
43
+ * When a function, consecutive points with the same value are grouped
44
+ * into separate path segments, enabling per-segment styling.
45
+ */
46
+ stroke?: ColorProp;
47
+ /**
48
+ * Fill color or function returning fill per data point.
49
+ * When a function, acts as a grouping channel like `stroke`.
50
+ */
51
+ fill?: ColorProp;
52
+ /**
53
+ * Opacity or function returning opacity per data point.
54
+ * When a function, acts as a grouping channel like `stroke`.
55
+ */
56
+ opacity?: StyleProp<number | undefined>;
40
57
  /**
41
58
  * Whether to animate the path using tweened interpolation.
42
59
  * When set, the line will animate from the baseline (y=0) on mount.
43
60
  */
44
61
  motion?: MotionProp;
45
- } & Omit<PathProps, 'x' | 'y' | 'motion'>;
62
+ } & Omit<PathProps, 'x' | 'y' | 'motion' | 'stroke' | 'fill' | 'opacity'>;
46
63
  export type SplineProps = SplinePropsWithoutHTML & Without<SVGAttributes<SVGPathElement>, SplinePropsWithoutHTML>;
47
64
  import { type PathProps } from './Path.svelte';
48
65
  declare const Spline: import("svelte").Component<SplineProps, {}, "">;
@@ -3,6 +3,7 @@
3
3
  import type { DataDrivenStyleProps } from '../utils/dataProp.js';
4
4
  import type { SVGAttributes } from 'svelte/elements';
5
5
  import { createMotion, type MotionProp } from '../utils/motion.svelte.js';
6
+ import type { FormatType, FormatConfig } from '@layerstack/utils';
6
7
 
7
8
  /**
8
9
  * Check if a string looks like a CSS/SVG value (percentage, em, px, etc.)
@@ -163,6 +164,12 @@
163
164
  */
164
165
  ref?: SVGTextElement;
165
166
 
167
+ /**
168
+ * Format the displayed value. When set with `motion` and a numeric `value`,
169
+ * the number will tween smoothly and be formatted for display.
170
+ */
171
+ format?: FormatType | FormatConfig;
172
+
166
173
  /** Motion configuration (pixel mode only). */
167
174
  motion?: MotionProp;
168
175
 
@@ -224,7 +231,7 @@
224
231
  <script lang="ts">
225
232
  import { untrack } from 'svelte';
226
233
  import { cls } from '@layerstack/tailwind';
227
- import { merge } from '@layerstack/utils';
234
+ import { merge, format as formatValue } from '@layerstack/utils';
228
235
 
229
236
  import { getLayerContext } from '../contexts/layer.js';
230
237
  import { getChartContext } from '../contexts/chart.js';
@@ -263,6 +270,7 @@
263
270
  stroke,
264
271
  fill,
265
272
  fillOpacity,
273
+ format,
266
274
  motion,
267
275
  svgRef: svgRefProp = $bindable(),
268
276
  ref: refProp = $bindable(),
@@ -318,18 +326,20 @@
318
326
  // --- Data mode motion ---
319
327
  const dataMotionMap = createDataMotionMap(motion);
320
328
 
321
- $effect(() => {
322
- if (!dataMode || !dataMotionMap) return;
323
- const activeKeys = new Set<any>();
324
- for (let i = 0; i < resolvedData.length; i++) {
325
- const d = resolvedData[i];
326
- const key = keyFn(d, i);
327
- activeKeys.add(key);
328
- const resolved = resolveTextPosition(d);
329
- untrack(() => dataMotionMap.update(key, resolved));
330
- }
331
- untrack(() => dataMotionMap.cleanup(activeKeys));
332
- });
329
+ if (dataMotionMap) {
330
+ $effect(() => {
331
+ if (!dataMode) return;
332
+ const activeKeys = new Set<any>();
333
+ for (let i = 0; i < resolvedData.length; i++) {
334
+ const d = resolvedData[i];
335
+ const key = keyFn(d, i);
336
+ activeKeys.add(key);
337
+ const resolved = resolveTextPosition(d);
338
+ untrack(() => dataMotionMap.update(key, resolved));
339
+ }
340
+ untrack(() => dataMotionMap.cleanup(activeKeys));
341
+ });
342
+ }
333
343
 
334
344
  // Single source of truth: resolved values with animated overlay
335
345
  const resolvedItems = $derived.by(() => {
@@ -387,11 +397,26 @@
387
397
  };
388
398
  });
389
399
 
390
- // Handle null and convert `\n` strings back to newline characters
391
- const rawText = $derived(
392
- typeof value !== 'function' && value != null ? value.toString().replace(/\\n/g, '\n') : ''
400
+ // Tween numeric values when motion is configured
401
+ const motionValue = createMotion(
402
+ typeof value === 'number' ? value : 0,
403
+ () => (typeof value === 'number' ? value : 0),
404
+ typeof value === 'number' && motion ? (typeof motion === 'object' && 'type' in motion ? motion : undefined) : undefined
393
405
  );
394
406
 
407
+ // Handle null and convert `\n` strings back to newline characters
408
+ const rawText = $derived.by(() => {
409
+ if (typeof value === 'function' || value == null) return '';
410
+ if (typeof value === 'number' && motion) {
411
+ const v = motionValue.current;
412
+ // @ts-expect-error - improve format types
413
+ return format ? formatValue(v, format) : String(v);
414
+ }
415
+ // @ts-expect-error - improve format types
416
+ const text = format ? formatValue(value, format) : value.toString();
417
+ return text.replace(/\\n/g, '\n');
418
+ });
419
+
395
420
  const textValue = $derived.by(() => {
396
421
  if (!truncateConfig) return rawText;
397
422
  return truncateText(rawText, truncateConfig);
@@ -628,8 +653,8 @@
628
653
  }
629
654
 
630
655
  // TODO: Use objectId to work around Svelte 4 reactivity issue (even when memoizing gradients)
631
- const fillKey = createKey(() => fill);
632
- const strokeKey = createKey(() => stroke);
656
+ const fillKey = layerCtx === 'canvas' ? createKey(() => fill) : undefined;
657
+ const strokeKey = layerCtx === 'canvas' ? createKey(() => stroke) : undefined;
633
658
 
634
659
  chartCtx.registerComponent({
635
660
  name: 'Text',
@@ -651,8 +676,8 @@
651
676
  value,
652
677
  motionX.current,
653
678
  motionY.current,
654
- fillKey.current,
655
- strokeKey.current,
679
+ fillKey!.current,
680
+ strokeKey!.current,
656
681
  strokeWidth,
657
682
  opacity,
658
683
  className,
@@ -2,6 +2,7 @@ import type { Without } from '../utils/types.js';
2
2
  import type { DataDrivenStyleProps } from '../utils/dataProp.js';
3
3
  import type { SVGAttributes } from 'svelte/elements';
4
4
  import { type MotionProp } from '../utils/motion.svelte.js';
5
+ import type { FormatType, FormatConfig } from '@layerstack/utils';
5
6
  /**
6
7
  * Check if a Text prop value is a data-space prop.
7
8
  * Functions are always data props.
@@ -122,6 +123,11 @@ export type TextPropsWithoutHTML = {
122
123
  * @bindable
123
124
  */
124
125
  ref?: SVGTextElement;
126
+ /**
127
+ * Format the displayed value. When set with `motion` and a numeric `value`,
128
+ * the number will tween smoothly and be formatted for display.
129
+ */
130
+ format?: FormatType | FormatConfig;
125
131
  /** Motion configuration (pixel mode only). */
126
132
  motion?: MotionProp;
127
133
  /**
@@ -110,6 +110,14 @@
110
110
  }
111
111
  });
112
112
 
113
+ $effect.pre(() => {
114
+ transformState.processTranslate = processTranslate;
115
+ });
116
+
117
+ $effect.pre(() => {
118
+ transformState.disablePointer = disablePointer ?? false;
119
+ });
120
+
113
121
  $effect.pre(() => {
114
122
  transformState.constrain = constrain;
115
123
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,166 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { render } from 'vitest-browser-svelte';
3
+ import { tick } from 'svelte';
4
+ import TransformTestHarness from './tests/TransformTestHarness.svelte';
5
+ import { geoMercator, geoOrthographic } from 'd3-geo';
6
+ describe('TransformContext', () => {
7
+ describe('reactive prop syncing', () => {
8
+ it('should sync processTranslate when projection changes from flat to globe', async () => {
9
+ let chartContext;
10
+ const chartProps = $state({
11
+ height: 300,
12
+ geo: {
13
+ projection: geoMercator,
14
+ fitGeojson: { type: 'Sphere' },
15
+ },
16
+ transform: {
17
+ mode: 'projection',
18
+ },
19
+ });
20
+ render(TransformTestHarness, {
21
+ chartProps,
22
+ oncontext: (ctx) => {
23
+ chartContext = ctx;
24
+ },
25
+ });
26
+ await vi.waitFor(() => expect(chartContext).toBeDefined());
27
+ // Mercator is a flat projection — processTranslate should be undefined
28
+ expect(chartContext.transform.processTranslate).toBeUndefined();
29
+ // Switch to orthographic (globe)
30
+ chartProps.geo = {
31
+ projection: geoOrthographic,
32
+ fitGeojson: { type: 'Sphere' },
33
+ };
34
+ await tick();
35
+ // Orthographic is a globe — processTranslate should now be a function
36
+ await vi.waitFor(() => {
37
+ expect(chartContext.transform.processTranslate).toBeTypeOf('function');
38
+ });
39
+ });
40
+ it('should sync processTranslate when projection changes from globe to flat', async () => {
41
+ let chartContext;
42
+ const chartProps = $state({
43
+ height: 300,
44
+ geo: {
45
+ projection: geoOrthographic,
46
+ fitGeojson: { type: 'Sphere' },
47
+ },
48
+ transform: {
49
+ mode: 'projection',
50
+ },
51
+ });
52
+ render(TransformTestHarness, {
53
+ chartProps,
54
+ oncontext: (ctx) => {
55
+ chartContext = ctx;
56
+ },
57
+ });
58
+ await vi.waitFor(() => expect(chartContext).toBeDefined());
59
+ // Orthographic is a globe — processTranslate should be a function
60
+ expect(chartContext.transform.processTranslate).toBeTypeOf('function');
61
+ // Switch to Mercator (flat)
62
+ chartProps.geo = {
63
+ projection: geoMercator,
64
+ fitGeojson: { type: 'Sphere' },
65
+ };
66
+ await tick();
67
+ // Mercator is flat — processTranslate should be undefined
68
+ await vi.waitFor(() => {
69
+ expect(chartContext.transform.processTranslate).toBeUndefined();
70
+ });
71
+ });
72
+ it('should enable scale for globe projections so scroll zoom works', async () => {
73
+ let chartContext;
74
+ render(TransformTestHarness, {
75
+ chartProps: {
76
+ height: 300,
77
+ geo: {
78
+ projection: geoOrthographic,
79
+ fitGeojson: { type: 'Sphere' },
80
+ },
81
+ transform: {
82
+ mode: 'projection',
83
+ scrollMode: 'scale',
84
+ },
85
+ },
86
+ oncontext: (ctx) => {
87
+ chartContext = ctx;
88
+ },
89
+ });
90
+ await vi.waitFor(() => expect(chartContext).toBeDefined());
91
+ const initialScale = chartContext.transform.scale;
92
+ expect(initialScale).toBeGreaterThan(1); // fitSize should give a scale > 1
93
+ // Simulate zoom in
94
+ chartContext.transform.setScale(initialScale * 2, { instant: true });
95
+ await tick();
96
+ await vi.waitFor(() => {
97
+ expect(chartContext.transform.scale).toBeCloseTo(initialScale * 2, 0);
98
+ });
99
+ // Verify the projection scale also updated (transformApply.scale = true for globes)
100
+ expect(chartContext.geo.projection?.scale()).toBeCloseTo(initialScale * 2, 0);
101
+ });
102
+ it('should interpret scaleExtent as relative multipliers in projection mode', async () => {
103
+ let chartContext;
104
+ render(TransformTestHarness, {
105
+ chartProps: {
106
+ height: 300,
107
+ geo: {
108
+ projection: geoMercator,
109
+ fitGeojson: { type: 'Sphere' },
110
+ },
111
+ transform: {
112
+ mode: 'projection',
113
+ scrollMode: 'scale',
114
+ scaleExtent: [0.5, 2],
115
+ },
116
+ },
117
+ oncontext: (ctx) => {
118
+ chartContext = ctx;
119
+ },
120
+ });
121
+ await vi.waitFor(() => expect(chartContext).toBeDefined());
122
+ const initialScale = chartContext.transform.scale;
123
+ expect(initialScale).toBeGreaterThan(10); // Mercator fitSize scale is typically large
124
+ // Try to zoom way beyond 2x — should be clamped to 2x initial
125
+ chartContext.transform.setScale(initialScale * 5, { instant: true });
126
+ await tick();
127
+ await vi.waitFor(() => {
128
+ // Should be clamped to ~2x the initial scale
129
+ expect(chartContext.transform.scale).toBeCloseTo(initialScale * 2, 0);
130
+ });
131
+ // Try to zoom below 0.5x — should be clamped to 0.5x initial
132
+ chartContext.transform.setScale(initialScale * 0.1, { instant: true });
133
+ await tick();
134
+ await vi.waitFor(() => {
135
+ expect(chartContext.transform.scale).toBeCloseTo(initialScale * 0.5, 0);
136
+ });
137
+ });
138
+ it('should sync disablePointer reactively', async () => {
139
+ let chartContext;
140
+ const chartProps = $state({
141
+ height: 300,
142
+ transform: {
143
+ mode: 'canvas',
144
+ disablePointer: false,
145
+ },
146
+ });
147
+ render(TransformTestHarness, {
148
+ chartProps,
149
+ oncontext: (ctx) => {
150
+ chartContext = ctx;
151
+ },
152
+ });
153
+ await vi.waitFor(() => expect(chartContext).toBeDefined());
154
+ expect(chartContext.transform.disablePointer).toBe(false);
155
+ // Enable disablePointer
156
+ chartProps.transform = {
157
+ mode: 'canvas',
158
+ disablePointer: true,
159
+ };
160
+ await tick();
161
+ await vi.waitFor(() => {
162
+ expect(chartContext.transform.disablePointer).toBe(true);
163
+ });
164
+ });
165
+ });
166
+ });
@@ -208,18 +208,20 @@
208
208
  // --- Data mode motion ---
209
209
  const dataMotionMap = createDataMotionMap(motion);
210
210
 
211
- $effect(() => {
212
- if (!dataMode || !dataMotionMap) return;
213
- const activeKeys = new Set<any>();
214
- for (let i = 0; i < resolvedData.length; i++) {
215
- const d = resolvedData[i];
216
- const key = keyFn(d, i);
217
- activeKeys.add(key);
218
- const resolved = resolveVector(d);
219
- untrack(() => dataMotionMap.update(key, resolved));
220
- }
221
- untrack(() => dataMotionMap.cleanup(activeKeys));
222
- });
211
+ if (dataMotionMap) {
212
+ $effect(() => {
213
+ if (!dataMode) return;
214
+ const activeKeys = new Set<any>();
215
+ for (let i = 0; i < resolvedData.length; i++) {
216
+ const d = resolvedData[i];
217
+ const key = keyFn(d, i);
218
+ activeKeys.add(key);
219
+ const resolved = resolveVector(d);
220
+ untrack(() => dataMotionMap.update(key, resolved));
221
+ }
222
+ untrack(() => dataMotionMap.cleanup(activeKeys));
223
+ });
224
+ }
223
225
 
224
226
  // Single source of truth: resolved values with animated overlay
225
227
  const resolvedItems = $derived.by(() => {
@@ -104,6 +104,8 @@ export { default as Link } from './Link.svelte';
104
104
  export * from './Link.svelte';
105
105
  export { default as MotionPath } from './MotionPath.svelte';
106
106
  export * from './MotionPath.svelte';
107
+ export { default as Month } from './Month.svelte';
108
+ export * from './Month.svelte';
107
109
  export { default as Pack } from './Pack.svelte';
108
110
  export * from './Pack.svelte';
109
111
  export { default as Partition } from './Partition.svelte';
@@ -104,6 +104,8 @@ export { default as Link } from './Link.svelte';
104
104
  export * from './Link.svelte';
105
105
  export { default as MotionPath } from './MotionPath.svelte';
106
106
  export * from './MotionPath.svelte';
107
+ export { default as Month } from './Month.svelte';
108
+ export * from './Month.svelte';
107
109
  export { default as Pack } from './Pack.svelte';
108
110
  export * from './Pack.svelte';
109
111
  export { default as Partition } from './Partition.svelte';
@@ -0,0 +1,27 @@
1
+ <script lang="ts">
2
+ import Chart from '../Chart.svelte';
3
+ import type { ChartState } from '../../states/chart.svelte.js';
4
+
5
+ let {
6
+ chartProps = {},
7
+ oncontext,
8
+ }: {
9
+ chartProps?: Record<string, any>;
10
+ oncontext?: (ctx: ChartState<any, any, any>) => void;
11
+ } = $props();
12
+
13
+ let chartContext = $state<ChartState<any, any, any>>();
14
+
15
+ $effect(() => {
16
+ if (chartContext) {
17
+ oncontext?.(chartContext);
18
+ }
19
+ });
20
+
21
+ const mergedChartProps = $derived({
22
+ height: 300,
23
+ ...chartProps,
24
+ });
25
+ </script>
26
+
27
+ <Chart {...mergedChartProps} bind:context={chartContext} />
@@ -0,0 +1,8 @@
1
+ import type { ChartState } from '../../states/chart.svelte.js';
2
+ type $$ComponentProps = {
3
+ chartProps?: Record<string, any>;
4
+ oncontext?: (ctx: ChartState<any, any, any>) => void;
5
+ };
6
+ declare const TransformTestHarness: import("svelte").Component<$$ComponentProps, {}, "">;
7
+ type TransformTestHarness = ReturnType<typeof TransformTestHarness>;
8
+ export default TransformTestHarness;