layerchart 2.0.0-next.48 → 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 (65) 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/__screenshots__/chart.component-node.svelte.test.ts/ChartState-registerComponent-cleans-up-child-nodes-and-mark-registrations-when-components-unmount-1.png +0 -0
  42. 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
  43. package/dist/states/brush.svelte.d.ts +26 -17
  44. package/dist/states/brush.svelte.js +118 -25
  45. package/dist/states/brush.svelte.test.js +126 -1
  46. package/dist/states/chart.svelte.d.ts +6 -0
  47. package/dist/states/chart.svelte.js +93 -20
  48. package/dist/states/transform.svelte.js +3 -1
  49. package/dist/utils/dataProp.d.ts +2 -10
  50. package/dist/utils/dataProp.js +16 -5
  51. package/dist/utils/index.d.ts +1 -0
  52. package/dist/utils/index.js +1 -0
  53. package/dist/utils/motion.svelte.d.ts +12 -2
  54. package/dist/utils/motion.svelte.js +22 -0
  55. package/dist/utils/motion.test.js +49 -1
  56. package/dist/utils/rasterBounds.d.ts +18 -0
  57. package/dist/utils/rasterBounds.js +98 -0
  58. package/dist/utils/rasterBounds.test.d.ts +1 -0
  59. package/dist/utils/rasterBounds.test.js +63 -0
  60. package/dist/utils/scales.svelte.js +4 -2
  61. package/dist/utils/scales.svelte.test.d.ts +1 -0
  62. package/dist/utils/scales.svelte.test.js +67 -0
  63. package/dist/utils/ticks.js +7 -3
  64. package/dist/utils/ticks.test.js +13 -3
  65. package/package.json +3 -2
@@ -0,0 +1,66 @@
1
+ <script lang="ts">
2
+ import Chart from '../components/Chart.svelte';
3
+ import Layer from '../components/layers/Layer.svelte';
4
+ import Rect from '../components/Rect.svelte';
5
+ import Circle from '../components/Circle.svelte';
6
+ import Ellipse from '../components/Ellipse.svelte';
7
+ import Line from '../components/Line.svelte';
8
+ import Group from '../components/Group.svelte';
9
+ import Text from '../components/Text.svelte';
10
+ import Path from '../components/Path.svelte';
11
+
12
+ type Primitive = 'rect' | 'circle' | 'ellipse' | 'line' | 'group' | 'text' | 'path';
13
+ type Mode = 'layerchart' | 'native';
14
+
15
+ type Props = {
16
+ primitive: Primitive;
17
+ mode: Mode;
18
+ count?: number;
19
+ };
20
+
21
+ let { primitive, mode, count = 100 }: Props = $props();
22
+ </script>
23
+
24
+ {#if mode === 'layerchart'}
25
+ <Chart width={500} height={300}>
26
+ <Layer type="svg">
27
+ {#each Array(count) as _, i (i)}
28
+ {#if primitive === 'rect'}
29
+ <Rect x={10} y={10} width={50} height={30} fill="steelblue" />
30
+ {:else if primitive === 'circle'}
31
+ <Circle cx={30} cy={30} r={15} fill="steelblue" />
32
+ {:else if primitive === 'ellipse'}
33
+ <Ellipse cx={30} cy={30} rx={20} ry={10} fill="steelblue" />
34
+ {:else if primitive === 'line'}
35
+ <Line x1={0} y1={0} x2={50} y2={50} stroke="steelblue" strokeWidth={2} />
36
+ {:else if primitive === 'group'}
37
+ <Group x={10} y={10} />
38
+ {:else if primitive === 'text'}
39
+ <Text x={10} y={20} value="Hello" fill="steelblue" />
40
+ {:else if primitive === 'path'}
41
+ <Path pathData="M0,0 L50,50 L100,0 Z" fill="none" stroke="steelblue" strokeWidth={2} />
42
+ {/if}
43
+ {/each}
44
+ </Layer>
45
+ </Chart>
46
+ {:else}
47
+ <svg width={500} height={300}>
48
+ {#each Array(count) as _, i (i)}
49
+ {#if primitive === 'rect'}
50
+ <rect x={10} y={10} width={50} height={30} fill="steelblue" />
51
+ {:else if primitive === 'circle'}
52
+ <circle cx={30} cy={30} r={15} fill="steelblue" />
53
+ {:else if primitive === 'ellipse'}
54
+ <ellipse cx={30} cy={30} rx={20} ry={10} fill="steelblue" />
55
+ {:else if primitive === 'line'}
56
+ <line x1={0} y1={0} x2={50} y2={50} stroke="steelblue" stroke-width={2} />
57
+ {:else if primitive === 'group'}
58
+ <g transform="translate(10,10)"></g>
59
+ {:else if primitive === 'text'}
60
+ <text x={10} y={20} fill="steelblue">Hello</text>
61
+ {:else if primitive === 'path'}
62
+ <path d="M0,0 L50,50 L100,0 Z" fill="none" stroke="steelblue" stroke-width={2} />
63
+ {/if}
64
+ {/each}
65
+ </svg>
66
+ {/if}
@@ -0,0 +1,10 @@
1
+ type Primitive = 'rect' | 'circle' | 'ellipse' | 'line' | 'group' | 'text' | 'path';
2
+ type Mode = 'layerchart' | 'native';
3
+ type Props = {
4
+ primitive: Primitive;
5
+ mode: Mode;
6
+ count?: number;
7
+ };
8
+ declare const PrimitiveBench: import("svelte").Component<Props, {}, "">;
9
+ type PrimitiveBench = ReturnType<typeof PrimitiveBench>;
10
+ export default PrimitiveBench;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,42 @@
1
+ import { describe, bench, afterEach } from 'vitest';
2
+ import { render, cleanup } from 'vitest-browser-svelte';
3
+ import PrimitiveBench from './PrimitiveBench.svelte';
4
+ afterEach(() => {
5
+ cleanup();
6
+ });
7
+ const COUNT = 100;
8
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
9
+ // LayerChart primitives vs native SVG elements — 100 instances each
10
+ //
11
+ // Representative subset: rect (shape), circle (shape), group (container),
12
+ // text (complex), path (path-based). Ellipse/line follow the same
13
+ // architecture as rect/circle and would show the same ratio.
14
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
15
+ const PRIMITIVES = ['rect', 'circle', 'group', 'text', 'path'];
16
+ for (const primitive of PRIMITIVES) {
17
+ describe(`${primitive} — ${COUNT} instances`, () => {
18
+ bench(`Native <${primitive}>`, () => {
19
+ cleanup();
20
+ render(PrimitiveBench, { primitive, mode: 'native', count: COUNT });
21
+ });
22
+ bench(`LayerChart <${primitive[0].toUpperCase()}${primitive.slice(1)}>`, () => {
23
+ cleanup();
24
+ render(PrimitiveBench, { primitive, mode: 'layerchart', count: COUNT });
25
+ });
26
+ });
27
+ }
28
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
29
+ // Scaling: rect at 10, 100, 250 instances
30
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
31
+ describe('rect — scaling', () => {
32
+ for (const count of [10, 100, 250]) {
33
+ bench(`Native <rect> × ${count}`, () => {
34
+ cleanup();
35
+ render(PrimitiveBench, { primitive: 'rect', mode: 'native', count });
36
+ });
37
+ bench(`LayerChart <Rect> × ${count}`, () => {
38
+ cleanup();
39
+ render(PrimitiveBench, { primitive: 'rect', mode: 'layerchart', count });
40
+ });
41
+ }
42
+ });
@@ -47,7 +47,7 @@
47
47
  * Width or height of each tick in pixels (enabling responsive count)
48
48
  * @default 80 (top|bottom|angle) or 50 (left|right|radius)
49
49
  */
50
- tickSpacing?: number;
50
+ tickSpacing?: number | null;
51
51
 
52
52
  /**
53
53
  * Whether to render tick labels on multiple lines for additional context
@@ -220,11 +220,22 @@
220
220
  : null
221
221
  );
222
222
 
223
+ // For band scales with domain-mode transform, scale up effective size by the zoom factor
224
+ // so that more tick labels appear as bands get wider when zoomed in
225
+ const effectiveSize = $derived.by(() => {
226
+ if (!ctxSize) return ctxSize;
227
+ const ts = ctx.transformState;
228
+ if (ts?.mode === 'domain' && isScaleBand(scale) && ts.scale > 1) {
229
+ return ctxSize * ts.scale;
230
+ }
231
+ return ctxSize;
232
+ });
233
+
223
234
  const tickCount = $derived(
224
235
  typeof ticks === 'number'
225
236
  ? ticks
226
- : tickSpacing && ctxSize
227
- ? Math.round(ctxSize / tickSpacing)
237
+ : tickSpacing && effectiveSize
238
+ ? Math.round(effectiveSize / tickSpacing)
228
239
  : undefined
229
240
  );
230
241
  const tickVals = $derived.by(() => {
@@ -40,7 +40,7 @@ export type AxisPropsWithoutHTML<In extends Transition = Transition> = {
40
40
  * Width or height of each tick in pixels (enabling responsive count)
41
41
  * @default 80 (top|bottom|angle) or 50 (left|right|radius)
42
42
  */
43
- tickSpacing?: number;
43
+ tickSpacing?: number | null;
44
44
  /**
45
45
  * Whether to render tick labels on multiple lines for additional context
46
46
  *
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { getObjectOrNull, type Accessor } from '../utils/common.js';
9
9
  import type { MotionProp } from '../utils/motion.svelte.js';
10
- import { type AnyScale, type DomainType } from '../utils/scales.svelte.js';
10
+ import { type AnyScale, type DomainType, isScaleBand } from '../utils/scales.svelte.js';
11
11
  import type {
12
12
  BaseRange,
13
13
  Nice,
@@ -22,7 +22,7 @@
22
22
  import { geoFitObjectTransform } from '../utils/geo.js';
23
23
  import TransformContext from './TransformContext.svelte';
24
24
  import BrushContext from './BrushContext.svelte';
25
- import { type BrushDomainType, type BrushState } from '../states/brush.svelte.js';
25
+ import { type BrushDomainType, type BrushState, expandBandBrushDomain } from '../states/brush.svelte.js';
26
26
 
27
27
  import { setChartContext } from '../contexts/chart.js';
28
28
  import { ChartState } from '../states/chart.svelte.js';
@@ -751,7 +751,7 @@
751
751
  }
752
752
 
753
753
  const defaults = isGlobe
754
- ? { rotation: true, scale: false, translate: false }
754
+ ? { rotation: true, scale: true, translate: false }
755
755
  : { rotation: false, scale: true, translate: true };
756
756
 
757
757
  // User overrides win; enforce mutual exclusion
@@ -833,6 +833,9 @@
833
833
 
834
834
  const d0 = baseDomain[0] as unknown;
835
835
  const d1 = baseDomain[1] as unknown;
836
+
837
+ // Skip domain extent constraint for categorical scales (range clamping handles boundaries)
838
+ if (typeof d0 === 'string') return axisTranslate;
836
839
  const isDate = d0 instanceof Date;
837
840
  const rawD0 = isDate ? (d0 as Date).getTime() : (d0 as number);
838
841
  const rawD1 = isDate ? (d1 as Date).getTime() : (d1 as number);
@@ -925,15 +928,104 @@
925
928
  };
926
929
  });
927
930
 
928
- // Compose user-provided constrain with domainExtent constrain
931
+ // Whether this is a band scale domain transform (affects scaleExtent and constrain defaults)
932
+ const isBandDomainTransform = $derived(
933
+ transform?.mode === 'domain' && (
934
+ ((transform.axis ?? 'both') !== 'y' && isScaleBand(chartState._xScaleProp)) ||
935
+ ((transform.axis ?? 'both') !== 'x' && isScaleBand(chartState._yScaleProp))
936
+ )
937
+ );
938
+
939
+ // For projection mode, scaleExtent is relative to the initial fitted scale (like d3-zoom).
940
+ // e.g. [0.5, 8] means 0.5x to 8x of the fitted projection scale.
941
+ // For band scale domain transforms, enforce minimum of 1 (can't zoom out past initial view).
942
+ const resolvedScaleExtent = $derived.by(() => {
943
+ if (transform?.mode === 'projection' && transform?.scaleExtent && initialTransform) {
944
+ const baseScale = initialTransform.scale;
945
+ return [
946
+ transform.scaleExtent[0] * baseScale,
947
+ transform.scaleExtent[1] * baseScale,
948
+ ] as [number, number];
949
+ }
950
+ if (!isBandDomainTransform) return transform?.scaleExtent;
951
+ const userExtent = transform?.scaleExtent;
952
+ return [Math.max(1, userExtent?.[0] ?? 1), userExtent?.[1] ?? Infinity] as [number, number];
953
+ });
954
+
955
+ // For projection mode with flat projections, translateExtent defines the pannable world bounds
956
+ // at the initial (1x) zoom level, similar to d3-zoom. The allowed translate range scales with
957
+ // the zoom ratio so you can pan more when zoomed in.
958
+ // For rotation mode (globes), translateExtent is passed through as degrees (yaw/pitch).
959
+ const resolvedTranslateExtent = $derived.by(() => {
960
+ if (transform?.mode === 'projection' && transform?.translateExtent) {
961
+ if (resolvedApply.rotation) {
962
+ // Rotation mode: values are degrees (yaw/pitch), pass through as-is
963
+ return transform.translateExtent;
964
+ }
965
+ // Flat projection translate mode: handled via projectionTranslateConstrain below
966
+ return undefined;
967
+ }
968
+ return transform?.translateExtent;
969
+ });
970
+
971
+ // For flat projection mode, implement d3-zoom-style translate constraining:
972
+ // The viewport (at current zoom) must overlap with the translateExtent world bounds.
973
+ // As zoom increases, the allowed translate range grows proportionally.
974
+ const projectionTranslateConstrain = $derived.by(() => {
975
+ if (transform?.mode !== 'projection' || !transform?.translateExtent || !initialTransform || resolvedApply.rotation) {
976
+ return undefined;
977
+ }
978
+
979
+ const baseScale = initialTransform.scale;
980
+ const baseTranslate = initialTransform.translate;
981
+ const [[x0, y0], [x1, y1]] = transform.translateExtent;
982
+
983
+ return (t: { scale: number; translate: { x: number; y: number } }) => {
984
+ let { scale, translate } = t;
985
+ // Zoom ratio relative to fitted scale
986
+ const k = scale / baseScale;
987
+
988
+ // Allowed translate range scales with zoom ratio
989
+ translate = {
990
+ x: Math.max(baseTranslate.x + x0 * k, Math.min(baseTranslate.x + x1 * k, translate.x)),
991
+ y: Math.max(baseTranslate.y + y0 * k, Math.min(baseTranslate.y + y1 * k, translate.y)),
992
+ };
993
+
994
+ return { scale, translate };
995
+ };
996
+ });
997
+
998
+ // Default constrain for band scale domain transforms: prevent panning past data boundaries
999
+ const bandScaleConstrain = $derived.by(() => {
1000
+ if (!isBandDomainTransform) return undefined;
1001
+ const xIsBand =
1002
+ (transform!.axis ?? 'both') !== 'y' && isScaleBand(chartState._xScaleProp);
1003
+ const yIsBand =
1004
+ (transform!.axis ?? 'both') !== 'x' && isScaleBand(chartState._yScaleProp);
1005
+
1006
+ return (t: { scale: number; translate: { x: number; y: number } }) => {
1007
+ let { scale, translate } = t;
1008
+ let tx = translate.x;
1009
+ let ty = translate.y;
1010
+ if (xIsBand) {
1011
+ // translate.x must be in [width * (1 - scale), 0]
1012
+ tx = Math.max(chartState.width * (1 - scale), Math.min(0, tx));
1013
+ }
1014
+ if (yIsBand) {
1015
+ ty = Math.max(chartState.height * (1 - scale), Math.min(0, ty));
1016
+ }
1017
+ return { scale, translate: { x: tx, y: ty } };
1018
+ };
1019
+ });
1020
+
1021
+ // Compose user-provided constrain with domainExtent constrain and band scale constrain
929
1022
  const composedConstrain = $derived.by(() => {
930
1023
  const userConstrain = transform?.constrain;
931
- if (!domainExtentConstrain && !userConstrain) return undefined;
932
- if (!domainExtentConstrain) return userConstrain;
933
- if (!userConstrain) return domainExtentConstrain;
934
- // Domain extent first, then user constrain
1024
+ const constrains = [bandScaleConstrain, domainExtentConstrain, projectionTranslateConstrain, userConstrain].filter(Boolean) as Array<(t: { scale: number; translate: { x: number; y: number } }) => { scale: number; translate: { x: number; y: number } }>;
1025
+ if (constrains.length === 0) return undefined;
1026
+ if (constrains.length === 1) return constrains[0];
935
1027
  return (t: { scale: number; translate: { x: number; y: number } }) => {
936
- return userConstrain(domainExtentConstrain(t));
1028
+ return constrains.reduce((acc, fn) => fn(acc), t);
937
1029
  };
938
1030
  });
939
1031
 
@@ -954,8 +1046,12 @@
954
1046
  chartState.zoomToBrush(e.brush, userProps.axis ?? 'x');
955
1047
  } else if (zoomOnBrush) {
956
1048
  const axis = userProps.axis ?? 'x';
957
- if (axis === 'x' || axis === 'both') brushXDomain = e.brush.x;
958
- if (axis === 'y' || axis === 'both') brushYDomain = e.brush.y;
1049
+ if (axis === 'x' || axis === 'both') {
1050
+ brushXDomain = expandBandBrushDomain(e.brush.x, chartState._baseXDomain);
1051
+ }
1052
+ if (axis === 'y' || axis === 'both') {
1053
+ brushYDomain = expandBandBrushDomain(e.brush.y, chartState._baseYDomain);
1054
+ }
959
1055
  }
960
1056
  userOnBrushEnd?.(e);
961
1057
  e.brush.reset();
@@ -993,7 +1089,7 @@
993
1089
  >
994
1090
  {#key chartState.isMounted}
995
1091
  <!-- svelte-ignore ownership_invalid_binding -->
996
- {@const { domainExtent: _de, constrain: _uc, apply: _apply, ...transformProps } = transform ?? {}}
1092
+ {@const { domainExtent: _de, constrain: _uc, apply: _apply, scaleExtent: _se, translateExtent: _te, ...transformProps } = transform ?? {}}
997
1093
  <TransformContext
998
1094
  bind:state={chartState.transformState}
999
1095
  mode={transform?.mode ?? 'none'}
@@ -1001,6 +1097,8 @@
1001
1097
  initialScale={resolvedApply.scale ? initialTransform?.scale : undefined}
1002
1098
  {processTranslate}
1003
1099
  {...transformProps}
1100
+ scaleExtent={resolvedScaleExtent}
1101
+ translateExtent={resolvedTranslateExtent}
1004
1102
  constrain={composedConstrain}
1005
1103
  disablePointer={(brush === true || (typeof brush === 'object' && !brush.disabled)) || transform?.disablePointer}
1006
1104
  {ondragstart}
@@ -154,19 +154,21 @@
154
154
  // --- Data mode: resolved items with optional motion ---
155
155
  const dataMotionMap = createDataMotionMap(motion);
156
156
 
157
- // Update motion targets when resolved values change
158
- $effect(() => {
159
- if (!dataMode || !dataMotionMap) return;
160
- const activeKeys = new Set<any>();
161
- for (let i = 0; i < resolvedData.length; i++) {
162
- const d = resolvedData[i];
163
- const key = keyFn(d, i);
164
- activeKeys.add(key);
165
- const resolved = resolveCircle(d);
166
- untrack(() => dataMotionMap.update(key, resolved));
167
- }
168
- untrack(() => dataMotionMap.cleanup(activeKeys));
169
- });
157
+ // Only create the data motion tracking effect when motion is actually configured
158
+ if (dataMotionMap) {
159
+ $effect(() => {
160
+ if (!dataMode) return;
161
+ const activeKeys = new Set<any>();
162
+ for (let i = 0; i < resolvedData.length; i++) {
163
+ const d = resolvedData[i];
164
+ const key = keyFn(d, i);
165
+ activeKeys.add(key);
166
+ const resolved = resolveCircle(d);
167
+ untrack(() => dataMotionMap.update(key, resolved));
168
+ }
169
+ untrack(() => dataMotionMap.cleanup(activeKeys));
170
+ });
171
+ }
170
172
 
171
173
  // Single source of truth: resolved values with animated overlay
172
174
  // Reading Spring .current here makes this reactive to animation frames
@@ -266,8 +268,9 @@
266
268
  }
267
269
 
268
270
  // TODO: Use objectId to work around Svelte 4 reactivity issue (even when memoizing gradients)
269
- const fillKey = createKey(() => fill);
270
- const strokeKey = createKey(() => stroke);
271
+ // Only create key trackers when in canvas mode (they're only used for canvas dep tracking)
272
+ const fillKey = layerCtx === 'canvas' ? createKey(() => fill) : undefined;
273
+ const strokeKey = layerCtx === 'canvas' ? createKey(() => stroke) : undefined;
271
274
 
272
275
  chartCtx.registerComponent({
273
276
  name: 'Circle',
@@ -296,9 +299,9 @@
296
299
  motionCx.current,
297
300
  motionCy.current,
298
301
  motionR.current,
299
- fillKey.current,
302
+ fillKey!.current,
300
303
  fillOpacity,
301
- strokeKey.current,
304
+ strokeKey!.current,
302
305
  strokeWidth,
303
306
  opacity,
304
307
  className,
@@ -17,6 +17,15 @@
17
17
  /** Grid height (rows). When set, `data` is treated as a flat grid array. */
18
18
  height?: number;
19
19
 
20
+ /** Left bound of the raster in data coordinates. Defaults to `0` in grid mode. */
21
+ x1?: number;
22
+ /** Top bound of the raster in data coordinates. Defaults to `0` in grid mode. */
23
+ y1?: number;
24
+ /** Right bound of the raster in data coordinates. Defaults to `width` in grid mode. */
25
+ x2?: number;
26
+ /** Bottom bound of the raster in data coordinates. Defaults to `height` in grid mode. */
27
+ y2?: number;
28
+
20
29
  /**
21
30
  * Value channel. Interpretation depends on the type:
22
31
  * - `(x, y) => number`: continuous function evaluated at each pixel (function sampling mode)
@@ -51,20 +60,32 @@
51
60
  import { geoPath, geoTransform } from 'd3-geo';
52
61
  import { scaleSequential } from 'd3-scale';
53
62
  import { interpolateYlGnBu } from 'd3-scale-chromatic';
54
- import { max, min, blur2 } from 'd3-array';
63
+ import { max, min } from 'd3-array';
55
64
 
56
65
  import Group from './Group.svelte';
57
66
  import Path from './Path.svelte';
58
67
  import { accessor as resolveAccessor, chartDataArray } from '../utils/common.js';
59
68
  import { getChartContext } from '../contexts/chart.js';
69
+ import { getGeoContext } from '../contexts/geo.js';
70
+ import {
71
+ blurGridIgnoringNaN,
72
+ gridCellCenterToBounds,
73
+ gridPointToBounds,
74
+ resolveRasterBounds,
75
+ } from '../utils/index.js';
60
76
  import { interpolateGrid } from '../utils/rasterInterpolate.js';
61
77
 
62
78
  const ctx = getChartContext();
79
+ const geo = getGeoContext();
63
80
 
64
81
  let {
65
82
  data: dataProp,
66
83
  width: widthProp,
67
84
  height: heightProp,
85
+ x1: x1Prop,
86
+ y1: y1Prop,
87
+ x2: x2Prop,
88
+ y2: y2Prop,
68
89
  value: valueProp,
69
90
  x: xProp,
70
91
  y: yProp,
@@ -83,6 +104,12 @@
83
104
 
84
105
  // Detect grid mode: data + width/height
85
106
  const isGridMode = $derived(!!(dataProp && widthProp && heightProp));
107
+ const hasExplicitBounds = $derived(
108
+ x1Prop !== undefined || y1Prop !== undefined || x2Prop !== undefined || y2Prop !== undefined
109
+ );
110
+ const gridBounds = $derived(
111
+ resolveRasterBounds(widthProp ?? 0, heightProp ?? 0, x1Prop, y1Prop, x2Prop, y2Prop)
112
+ );
86
113
 
87
114
  // Register as composite-mark with markInfo for grid mode domain participation
88
115
  ctx.registerComponent({
@@ -92,8 +119,8 @@
92
119
  if (!isGridMode) return {};
93
120
  return {
94
121
  data: [
95
- { x: 0, y: 0 },
96
- { x: widthProp, y: heightProp },
122
+ { x: gridBounds.x1, y: gridBounds.y1 },
123
+ { x: gridBounds.x2, y: gridBounds.y2 },
97
124
  ],
98
125
  x: 'x',
99
126
  y: 'y',
@@ -108,6 +135,9 @@
108
135
  // Scale factors from grid coordinates to chart pixel coordinates
109
136
  const scaleX = $derived(ctx.width / gridW);
110
137
  const scaleY = $derived(ctx.height / gridH);
138
+ const contourScaleX = $derived(gridW / ctx.width);
139
+ const contourScaleY = $derived(gridH / ctx.height);
140
+ const useProjectedGridSampling = $derived(!!(geo.projection && isGridMode && hasExplicitBounds));
111
141
 
112
142
  // Resolve grid values from one of three input modes
113
143
  const gridValues = $derived.by(() => {
@@ -115,9 +145,7 @@
115
145
 
116
146
  // Mode 1: Grid data (flat array + width/height)
117
147
  if (isGridMode) {
118
- return dataProp instanceof Float64Array
119
- ? dataProp
120
- : Float64Array.from(dataProp as number[]);
148
+ return dataProp instanceof Float64Array ? dataProp : Float64Array.from(dataProp as number[]);
121
149
  }
122
150
 
123
151
  // Mode 2: Continuous function — evaluate at each grid cell
@@ -144,7 +172,7 @@
144
172
 
145
173
  const xAcc = xProp ? resolveAccessor(xProp) : ctx.x;
146
174
  const yAcc = yProp ? resolveAccessor(yProp) : ctx.y;
147
- const valAcc = resolveAccessor(valueProp ?? 'value');
175
+ const valAcc = resolveAccessor((valueProp ?? 'value') as Accessor);
148
176
 
149
177
  const points: [number, number, number][] = chartData.map((d: any) => [
150
178
  ctx.xScale(xAcc(d)) / scaleX,
@@ -155,12 +183,39 @@
155
183
  return interpolateGrid(points, gridW, gridH, interpolateMethod);
156
184
  });
157
185
 
186
+ const projectedGridPoints = $derived.by(() => {
187
+ if (!useProjectedGridSampling || !widthProp || !heightProp || !geo.projection) return [];
188
+
189
+ const points: [number, number, number][] = [];
190
+ for (let row = 0; row < heightProp; row++) {
191
+ for (let column = 0; column < widthProp; column++) {
192
+ const value = gridValues[row * widthProp + column];
193
+ if (!Number.isFinite(value)) continue;
194
+
195
+ const point = gridCellCenterToBounds(column, row, widthProp, heightProp, gridBounds);
196
+ const projected = geo.projection([point.x, point.y]);
197
+ if (!projected || !Number.isFinite(projected[0]) || !Number.isFinite(projected[1]))
198
+ continue;
199
+
200
+ points.push([projected[0] * contourScaleX, projected[1] * contourScaleY, value]);
201
+ }
202
+ }
203
+
204
+ return points;
205
+ });
206
+
207
+ const contourGridValues = $derived.by(() => {
208
+ if (useProjectedGridSampling) {
209
+ return interpolateGrid(projectedGridPoints, gridW, gridH, interpolateMethod);
210
+ }
211
+
212
+ return gridValues;
213
+ });
214
+
158
215
  // Apply optional blur
159
216
  const blurredValues = $derived.by(() => {
160
- if (!blurRadius || gridValues.length === 0) return gridValues;
161
- const copy = new Float64Array(gridValues);
162
- blur2({ data: copy, width: gridW, height: gridH }, blurRadius);
163
- return copy;
217
+ if (!blurRadius || contourGridValues.length === 0) return contourGridValues;
218
+ return blurGridIgnoringNaN(contourGridValues, gridW, gridH, blurRadius);
164
219
  });
165
220
 
166
221
  // Run d3 contour generator
@@ -171,8 +226,29 @@
171
226
  return generator(Array.from(blurredValues));
172
227
  });
173
228
 
174
- // Path generator that scales from grid to chart pixel coordinates
229
+ // Path generator that maps contour geometry into either data or geo space.
175
230
  const pathGenerator = $derived.by(() => {
231
+ if (useProjectedGridSampling) {
232
+ return geoPath(
233
+ geoTransform({
234
+ point(x, y) {
235
+ this.stream.point(x * scaleX, y * scaleY);
236
+ },
237
+ })
238
+ );
239
+ }
240
+
241
+ if (isGridMode && hasExplicitBounds) {
242
+ return geoPath(
243
+ geoTransform({
244
+ point(x, y) {
245
+ const point = gridPointToBounds(x, y, gridW, gridH, gridBounds);
246
+ this.stream.point(ctx.xScale(point.x), ctx.yScale(point.y));
247
+ },
248
+ })
249
+ );
250
+ }
251
+
176
252
  if (scaleX === 1 && scaleY === 1) return geoPath();
177
253
  return geoPath(
178
254
  geoTransform({
@@ -189,7 +265,8 @@
189
265
  const minValue = min(contourData, (d) => d.value) ?? 0;
190
266
  const maxValue = max(contourData, (d) => d.value) ?? 1;
191
267
  if (ctx.cScale) {
192
- return ctx.cScale.copy().domain([minValue, maxValue]);
268
+ const scale = ctx.cScale.copy();
269
+ return ctx.props.cDomain ? scale : scale.domain([minValue, maxValue]);
193
270
  }
194
271
  return scaleSequential([minValue, maxValue], interpolateYlGnBu);
195
272
  });
@@ -14,6 +14,14 @@ export type ContourPropsWithoutHTML = {
14
14
  width?: number;
15
15
  /** Grid height (rows). When set, `data` is treated as a flat grid array. */
16
16
  height?: number;
17
+ /** Left bound of the raster in data coordinates. Defaults to `0` in grid mode. */
18
+ x1?: number;
19
+ /** Top bound of the raster in data coordinates. Defaults to `0` in grid mode. */
20
+ y1?: number;
21
+ /** Right bound of the raster in data coordinates. Defaults to `width` in grid mode. */
22
+ x2?: number;
23
+ /** Bottom bound of the raster in data coordinates. Defaults to `height` in grid mode. */
24
+ y2?: number;
17
25
  /**
18
26
  * Value channel. Interpretation depends on the type:
19
27
  * - `(x, y) => number`: continuous function evaluated at each pixel (function sampling mode)
@@ -170,18 +170,20 @@
170
170
  // --- Data mode motion ---
171
171
  const dataMotionMap = createDataMotionMap(motion);
172
172
 
173
- $effect(() => {
174
- if (!dataMode || !dataMotionMap) return;
175
- const activeKeys = new Set<any>();
176
- for (let i = 0; i < resolvedData.length; i++) {
177
- const d = resolvedData[i];
178
- const key = keyFn(d, i);
179
- activeKeys.add(key);
180
- const resolved = resolveEllipse(d);
181
- untrack(() => dataMotionMap.update(key, resolved));
182
- }
183
- untrack(() => dataMotionMap.cleanup(activeKeys));
184
- });
173
+ if (dataMotionMap) {
174
+ $effect(() => {
175
+ if (!dataMode) return;
176
+ const activeKeys = new Set<any>();
177
+ for (let i = 0; i < resolvedData.length; i++) {
178
+ const d = resolvedData[i];
179
+ const key = keyFn(d, i);
180
+ activeKeys.add(key);
181
+ const resolved = resolveEllipse(d);
182
+ untrack(() => dataMotionMap.update(key, resolved));
183
+ }
184
+ untrack(() => dataMotionMap.cleanup(activeKeys));
185
+ });
186
+ }
185
187
 
186
188
  // Single source of truth: resolved values with animated overlay
187
189
  const resolvedItems = $derived.by(() => {
@@ -290,8 +292,8 @@
290
292
  }
291
293
 
292
294
  // TODO: Use objectId to work around Svelte 4 reactivity issue (even when memoizing gradients)
293
- const fillKey = createKey(() => fill);
294
- const strokeKey = createKey(() => stroke);
295
+ const fillKey = layerCtx === 'canvas' ? createKey(() => fill) : undefined;
296
+ const strokeKey = layerCtx === 'canvas' ? createKey(() => stroke) : undefined;
295
297
 
296
298
  chartCtx.registerComponent({
297
299
  name: 'Ellipse',
@@ -321,9 +323,9 @@
321
323
  motionCy.current,
322
324
  motionRx.current,
323
325
  motionRy.current,
324
- fillKey.current,
326
+ fillKey!.current,
325
327
  fillOpacity,
326
- strokeKey.current,
328
+ strokeKey!.current,
327
329
  strokeWidth,
328
330
  opacity,
329
331
  className,