svelteplot 0.11.1 → 0.12.0

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.
@@ -74,6 +74,7 @@
74
74
  colorScheme: 'turbo',
75
75
  unknown: '#cccccc99',
76
76
  sortOrdinalDomains: true,
77
+ divergingColorScheme: 'RdBu',
77
78
  categoricalColorScheme: 'observable10',
78
79
  pointScaleHeight: 20,
79
80
  bandScaleHeight: 30,
@@ -209,6 +210,27 @@
209
210
  (fixedWidth || width) - plotOptions.marginLeft - plotOptions.marginRight
210
211
  );
211
212
 
213
+ // Width used for aspectRatio/projection height computation only.
214
+ // Excludes reactive auto-margins to prevent a feedback loop:
215
+ // height → plotHeight → y-tick density → y-label widths → autoMarginLeft → plotWidth → height
216
+ // Using explicit user-specified margins (0 when set to 'auto') keeps height stable
217
+ // while still updating when the container width or user-specified margins change.
218
+ const plotWidthForAspectRatio = $derived(
219
+ (fixedWidth || width) -
220
+ maybeMargin(
221
+ initialOptions.margin as number | 'auto' | PlotMargin | undefined,
222
+ 'left',
223
+ DEFAULTS.margin,
224
+ { left: 0, right: 0, top: 0, bottom: 0 }
225
+ ) -
226
+ maybeMargin(
227
+ initialOptions.margin as number | 'auto' | PlotMargin | undefined,
228
+ 'right',
229
+ DEFAULTS.margin,
230
+ { left: 0, right: 0, top: 0, bottom: 0 }
231
+ )
232
+ );
233
+
212
234
  // the facet and y domain counts are used for computing the automatic height
213
235
  const xFacetCount = $derived(Math.max(1, preScales.fx.domain.length));
214
236
  const yFacetCount = $derived(Math.max(1, preScales.fy.domain.length));
@@ -235,7 +257,8 @@
235
257
  : maybeNumber(plotOptions.height) === null || plotOptions.height === 'auto'
236
258
  ? Math.round(
237
259
  preScales.projection && (preScales.projection as any).aspectRatio
238
- ? ((plotWidth * (preScales.projection as any).aspectRatio) / xFacetCount) *
260
+ ? ((plotWidthForAspectRatio * (preScales.projection as any).aspectRatio) /
261
+ xFacetCount) *
239
262
  yFacetCount +
240
263
  plotOptions.marginTop +
241
264
  plotOptions.marginBottom
@@ -244,7 +267,7 @@
244
267
  preScales.x,
245
268
  preScales.y,
246
269
  plotOptions.aspectRatio,
247
- plotWidth,
270
+ plotWidthForAspectRatio,
248
271
  plotOptions.marginTop,
249
272
  plotOptions.marginBottom
250
273
  )
@@ -396,6 +419,16 @@
396
419
  y.type === 'band' || y.type === 'point'
397
420
  ? y.domain.length
398
421
  : Math.abs((y.domain[1] as number) - (y.domain[0] as number));
422
+ // Guard against degenerate/empty domains (e.g. before marks have mounted).
423
+ // Returning NaN here would propagate through scale ranges → NaN pixel coords.
424
+ if (
425
+ !xDomainExtent ||
426
+ !yDomainExtent ||
427
+ !isFinite(xDomainExtent) ||
428
+ !isFinite(yDomainExtent)
429
+ ) {
430
+ return DEFAULTS.height + marginTop + marginBottom;
431
+ }
399
432
  return (
400
433
  ((plotWidth / xDomainExtent) * yDomainExtent) / aspectRatio + marginTop + marginBottom
401
434
  );
@@ -171,10 +171,12 @@ export function autoScaleColor({ type, domain, scaleOptions, plotOptions: _plotO
171
171
  }
172
172
  else if (SequentialScales[type] ||
173
173
  DivergingScales[type]) {
174
+ const isDivergingType = DivergingScales.hasOwnProperty(type);
174
175
  // continuous color scale
175
176
  const scale = (SequentialScales[type] ||
176
177
  DivergingScales[type]);
177
- const scheme_ = scheme || plotDefaults.colorScheme;
178
+ const scheme_ = scheme ||
179
+ (isDivergingType ? plotDefaults.divergingColorScheme : plotDefaults.colorScheme);
178
180
  if (interpolate) {
179
181
  // user-defined interpolation function [0, 1] -> color
180
182
  fn = scale(domain, interpolate);
@@ -46,8 +46,8 @@ export declare function findFacetFromDOM(target: Element | null): {
46
46
  * translation of the facet from the plot body origin.
47
47
  */
48
48
  export declare function detectFacet(evt: MouseEvent, plot: PlotState): {
49
- fxValue: RawValue | boolean;
50
- fyValue: RawValue | boolean;
49
+ fxValue: RawValue;
50
+ fyValue: RawValue;
51
51
  offsetX: number;
52
52
  offsetY: number;
53
53
  };
@@ -0,0 +1,26 @@
1
+ export type InterpolateFunction = (index: number[], width: number, height: number, X: Float64Array, Y: Float64Array, V: ArrayLike<any>) => ArrayLike<any>;
2
+ /**
3
+ * Simple forward mapping: each sample is binned to its nearest pixel.
4
+ * If multiple samples map to the same pixel, the last one wins.
5
+ */
6
+ export declare function interpolateNone(index: number[], width: number, height: number, X: Float64Array, Y: Float64Array, V: ArrayLike<any>): any[];
7
+ /**
8
+ * Nearest-neighbor interpolation using Delaunay triangulation.
9
+ */
10
+ export declare function interpolateNearest(index: number[], width: number, height: number, X: Float64Array, Y: Float64Array, V: ArrayLike<any>): any;
11
+ /**
12
+ * Barycentric interpolation: fills the interior of each Delaunay triangle with
13
+ * barycentric-weighted values, then extrapolates exterior pixels to the hull.
14
+ */
15
+ export declare function interpolatorBarycentric({ random }?: {
16
+ random?: (() => number) | undefined;
17
+ }): InterpolateFunction;
18
+ /**
19
+ * Walk-on-spheres algorithm for smooth interpolation.
20
+ * https://observablehq.com/@observablehq/walk-on-spheres-precision
21
+ */
22
+ export declare function interpolatorRandomWalk({ random, minDistance, maxSteps }?: {
23
+ random?: (() => number) | undefined;
24
+ minDistance?: number | undefined;
25
+ maxSteps?: number | undefined;
26
+ }): InterpolateFunction;
@@ -0,0 +1,220 @@
1
+ import { Delaunay } from 'd3-delaunay';
2
+ import { randomLcg } from 'd3-random';
3
+ /**
4
+ * Simple forward mapping: each sample is binned to its nearest pixel.
5
+ * If multiple samples map to the same pixel, the last one wins.
6
+ */
7
+ export function interpolateNone(index, width, height, X, Y, V) {
8
+ const W = new Array(width * height);
9
+ for (const i of index) {
10
+ if (X[i] < 0 || X[i] >= width || Y[i] < 0 || Y[i] >= height)
11
+ continue;
12
+ W[Math.floor(Y[i]) * width + Math.floor(X[i])] = V[i];
13
+ }
14
+ return W;
15
+ }
16
+ /**
17
+ * Nearest-neighbor interpolation using Delaunay triangulation.
18
+ */
19
+ export function interpolateNearest(index, width, height, X, Y, V) {
20
+ const n = width * height;
21
+ // Use typed array if V is typed, otherwise plain array
22
+ const W = isTypedArray(V) ? new V.constructor(n) : new Array(n);
23
+ const delaunay = Delaunay.from(index, (i) => X[i], (i) => Y[i]);
24
+ // Memoize delaunay.find for the line start (iy) and current pixel (ix)
25
+ let iy, ix;
26
+ for (let y = 0.5, k = 0; y < height; ++y) {
27
+ ix = iy;
28
+ for (let x = 0.5; x < width; ++x, ++k) {
29
+ ix = delaunay.find(x, y, ix);
30
+ if (x === 0.5)
31
+ iy = ix;
32
+ W[k] = V[index[ix]];
33
+ }
34
+ }
35
+ return W;
36
+ }
37
+ /**
38
+ * Barycentric interpolation: fills the interior of each Delaunay triangle with
39
+ * barycentric-weighted values, then extrapolates exterior pixels to the hull.
40
+ */
41
+ export function interpolatorBarycentric({ random = randomLcg(42) } = {}) {
42
+ return (index, width, height, X, Y, V) => {
43
+ const { points, triangles, hull } = Delaunay.from(index, (i) => X[i], (i) => Y[i]);
44
+ const n = width * height;
45
+ const W = isTypedArray(V)
46
+ ? new V.constructor(n).fill(NaN)
47
+ : new Array(n).fill(NaN);
48
+ const S = new Uint8Array(n); // 1 if pixel has been written
49
+ const mix = mixer(V, random);
50
+ for (let i = 0; i < triangles.length; i += 3) {
51
+ const ta = triangles[i];
52
+ const tb = triangles[i + 1];
53
+ const tc = triangles[i + 2];
54
+ const Ax = points[2 * ta];
55
+ const Bx = points[2 * tb];
56
+ const Cx = points[2 * tc];
57
+ const Ay = points[2 * ta + 1];
58
+ const By = points[2 * tb + 1];
59
+ const Cy = points[2 * tc + 1];
60
+ const x1 = Math.min(Ax, Bx, Cx);
61
+ const x2 = Math.max(Ax, Bx, Cx);
62
+ const y1 = Math.min(Ay, By, Cy);
63
+ const y2 = Math.max(Ay, By, Cy);
64
+ const z = (By - Cy) * (Ax - Cx) + (Ay - Cy) * (Cx - Bx);
65
+ if (!z)
66
+ continue;
67
+ const va = V[index[ta]];
68
+ const vb = V[index[tb]];
69
+ const vc = V[index[tc]];
70
+ for (let x = Math.floor(x1); x < x2; ++x) {
71
+ for (let y = Math.floor(y1); y < y2; ++y) {
72
+ if (x < 0 || x >= width || y < 0 || y >= height)
73
+ continue;
74
+ const xp = x + 0.5;
75
+ const yp = y + 0.5;
76
+ const s = Math.sign(z);
77
+ const ga = (By - Cy) * (xp - Cx) + (yp - Cy) * (Cx - Bx);
78
+ if (ga * s < 0)
79
+ continue;
80
+ const gb = (Cy - Ay) * (xp - Cx) + (yp - Cy) * (Ax - Cx);
81
+ if (gb * s < 0)
82
+ continue;
83
+ const gc = z - (ga + gb);
84
+ if (gc * s < 0)
85
+ continue;
86
+ const idx = x + width * y;
87
+ W[idx] = mix(va, ga / z, vb, gb / z, vc, gc / z, x, y);
88
+ S[idx] = 1;
89
+ }
90
+ }
91
+ }
92
+ extrapolateBarycentric(W, S, X, Y, V, width, height, hull, index, mix);
93
+ return W;
94
+ };
95
+ }
96
+ function extrapolateBarycentric(W, S, X, Y, V, width, height, hull, index, mix) {
97
+ const hX = Float64Array.from(hull, (i) => X[index[i]]);
98
+ const hY = Float64Array.from(hull, (i) => Y[index[i]]);
99
+ const hV = Array.from(hull, (i) => V[index[i]]);
100
+ const n = hX.length;
101
+ const rays = Array.from({ length: n }, (_, j) => ray(j, hX, hY));
102
+ let k = 0;
103
+ for (let y = 0; y < height; ++y) {
104
+ const yp = y + 0.5;
105
+ for (let x = 0; x < width; ++x) {
106
+ const i = x + width * y;
107
+ if (!S[i]) {
108
+ const xp = x + 0.5;
109
+ for (let l = 0; l < n; ++l) {
110
+ const j = (n + k + (l % 2 ? (l + 1) / 2 : -l / 2)) % n;
111
+ if (rays[j](xp, yp)) {
112
+ const t = segmentProject(at(hX, j - 1), at(hY, j - 1), hX[j], hY[j], xp, yp);
113
+ W[i] = mix(at(hV, j - 1), t, hV[j], 1 - t, hV[j], 0, x, y);
114
+ k = j;
115
+ break;
116
+ }
117
+ }
118
+ }
119
+ }
120
+ }
121
+ }
122
+ /**
123
+ * Walk-on-spheres algorithm for smooth interpolation.
124
+ * https://observablehq.com/@observablehq/walk-on-spheres-precision
125
+ */
126
+ export function interpolatorRandomWalk({ random = randomLcg(42), minDistance = 0.5, maxSteps = 2 } = {}) {
127
+ return (index, width, height, X, Y, V) => {
128
+ const n = width * height;
129
+ const W = isTypedArray(V) ? new V.constructor(n) : new Array(n);
130
+ const delaunay = Delaunay.from(index, (i) => X[i], (i) => Y[i]);
131
+ let iy, ix, iw;
132
+ for (let y = 0.5, k = 0; y < height; ++y) {
133
+ ix = iy;
134
+ for (let x = 0.5; x < width; ++x, ++k) {
135
+ let cx = x;
136
+ let cy = y;
137
+ iw = ix = delaunay.find(cx, cy, ix);
138
+ if (x === 0.5)
139
+ iy = ix;
140
+ let distance;
141
+ let step = 0;
142
+ while ((distance = Math.hypot(X[index[iw]] - cx, Y[index[iw]] - cy)) > minDistance &&
143
+ step < maxSteps) {
144
+ const angle = random(x, y, step) * 2 * Math.PI;
145
+ cx += Math.cos(angle) * distance;
146
+ cy += Math.sin(angle) * distance;
147
+ iw = delaunay.find(cx, cy, iw);
148
+ ++step;
149
+ }
150
+ W[k] = V[index[iw]];
151
+ }
152
+ }
153
+ return W;
154
+ };
155
+ }
156
+ // --- Internal helpers ---
157
+ function blend(a, ca, b, cb, c, cc) {
158
+ return ca * a + cb * b + cc * c;
159
+ }
160
+ function pick(random) {
161
+ return (a, ca, b, cb, c, _cc, x, y) => {
162
+ const u = random(x, y);
163
+ return u < ca ? a : u < ca + cb ? b : c;
164
+ };
165
+ }
166
+ function mixer(V, random) {
167
+ const first = findFirst(V);
168
+ return typeof first === 'number' || first instanceof Date ? blend : pick(random);
169
+ }
170
+ function findFirst(V) {
171
+ for (let i = 0; i < V.length; ++i)
172
+ if (V[i] != null)
173
+ return V[i];
174
+ return undefined;
175
+ }
176
+ function isTypedArray(V) {
177
+ return ArrayBuffer.isView(V) && !(V instanceof DataView);
178
+ }
179
+ function at(arr, i) {
180
+ return arr[(i + arr.length) % arr.length];
181
+ }
182
+ function segmentProject(x1, y1, x2, y2, x, y) {
183
+ const dx = x2 - x1;
184
+ const dy = y2 - y1;
185
+ const a = dx * (x2 - x) + dy * (y2 - y);
186
+ const b = dx * (x - x1) + dy * (y - y1);
187
+ return a > 0 && b > 0 ? a / (a + b) : +(a > b);
188
+ }
189
+ function cross(xa, ya, xb, yb) {
190
+ return xa * yb - xb * ya;
191
+ }
192
+ function ray(j, X, Y) {
193
+ const n = X.length;
194
+ const xc = at(X, j - 2);
195
+ const yc = at(Y, j - 2);
196
+ const xa = at(X, j - 1);
197
+ const ya = at(Y, j - 1);
198
+ const xb = X[j];
199
+ const yb = Y[j];
200
+ const xd = at(X, j + 1 - n);
201
+ const yd = at(Y, j + 1 - n);
202
+ const dxab = xa - xb;
203
+ const dyab = ya - yb;
204
+ const dxca = xc - xa;
205
+ const dyca = yc - ya;
206
+ const dxbd = xb - xd;
207
+ const dybd = yb - yd;
208
+ const hab = Math.hypot(dxab, dyab);
209
+ const hca = Math.hypot(dxca, dyca);
210
+ const hbd = Math.hypot(dxbd, dybd);
211
+ return (x, y) => {
212
+ const dxa = x - xa;
213
+ const dya = y - ya;
214
+ const dxb = x - xb;
215
+ const dyb = y - yb;
216
+ return (cross(dxa, dya, dxb, dyb) > -1e-6 &&
217
+ cross(dxa, dya, dxab, dyab) * hca - cross(dxa, dya, dxca, dyca) * hab > -1e-6 &&
218
+ cross(dxb, dyb, dxbd, dybd) * hab - cross(dxb, dyb, dxab, dyab) * hbd <= 0);
219
+ };
220
+ }
@@ -45,6 +45,7 @@
45
45
  import Anchor from './helpers/Anchor.svelte';
46
46
  import { getPlotDefaults } from '../hooks/plotDefaults.js';
47
47
  import { usePlot } from '../hooks/usePlot.svelte.js';
48
+ import GroupMultiple from './helpers/GroupMultiple.svelte';
48
49
 
49
50
  const plot = usePlot();
50
51
 
@@ -85,6 +86,8 @@
85
86
  })
86
87
  )
87
88
  );
89
+
90
+ const classes = $derived(['geo', geoType && `geo-${geoType}`, className]);
88
91
  </script>
89
92
 
90
93
  <Mark
@@ -92,10 +95,10 @@
92
95
  channels={['fill', 'stroke', 'opacity', 'fillOpacity', 'strokeOpacity', 'r']}
93
96
  {...args}>
94
97
  {#snippet children({ mark, scaledData, usedScales })}
95
- <g
98
+ <GroupMultiple
96
99
  aria-label="geo"
97
- class={['geo', geoType && `geo-${geoType}`, className]}
98
- style="fill:currentColor">
100
+ class={scaledData.length > 1 ? classes.filter(Boolean).join(' ') : null}
101
+ length={scaledData.length}>
99
102
  {#if canvas}
100
103
  <GeoCanvas data={scaledData} {path} {mark} {usedScales} />
101
104
  {:else}
@@ -116,7 +119,8 @@
116
119
  <path
117
120
  d={path(geometry as any)}
118
121
  {style}
119
- class={[styleClass]}
122
+ aria-label="geo"
123
+ class={[scaledData.length > 1 ? null : classes, styleClass]}
120
124
  filter={resolveProp(args.svgFilter, d.datum, undefined) as
121
125
  | string
122
126
  | undefined}
@@ -131,6 +135,6 @@
131
135
  {/if}
132
136
  {/each}
133
137
  {/if}
134
- </g>
138
+ </GroupMultiple>
135
139
  {/snippet}
136
140
  </Mark>
@@ -94,28 +94,65 @@
94
94
  const args = $derived(sort(recordizeXY({ data, ...options })));
95
95
 
96
96
  /**
97
- * Groups the data by the specified key
97
+ * Groups the data by the specified key (and optionally a secondary key).
98
+ * When a secondary key is provided, each primary group is further split by
99
+ * the secondary key, with each sub-segment extended to include the first point
100
+ * of the next sub-segment so consecutive segments share an endpoint (enabling
101
+ * multi-colored lines without gaps).
98
102
  */
99
- function groupIndex(data: ScaledDataRecord[], groupByKey: ChannelAccessor<Datum> | null) {
100
- if (!groupByKey) return [data];
101
- let group: ScaledDataRecord[] = [];
102
- const groups: ScaledDataRecord[][] = [group];
103
- let lastGroupValue;
103
+ function groupIndex(
104
+ data: ScaledDataRecord[],
105
+ groupByKey: ChannelAccessor<Datum> | null,
106
+ secondaryKey: ChannelAccessor<Datum> | null = null
107
+ ) {
108
+ if (!groupByKey && !secondaryKey) return [data];
109
+
110
+ // Group by the primary key
111
+ const primaryGroups: ScaledDataRecord[][] = [];
112
+ let primaryGroup: ScaledDataRecord[] = [];
113
+ let lastPrimaryValue: unknown;
104
114
  for (const d of data) {
105
- const groupValue = resolveProp(groupByKey, d.datum);
106
- if (groupValue === lastGroupValue) {
107
- group.push(d);
115
+ const primaryValue = resolveProp(groupByKey!, d.datum);
116
+ if (primaryValue === lastPrimaryValue) {
117
+ primaryGroup.push(d);
108
118
  } else {
109
- // new group
110
- group = [d];
111
- groups.push(group);
112
- lastGroupValue = groupValue;
119
+ primaryGroup = [d];
120
+ primaryGroups.push(primaryGroup);
121
+ lastPrimaryValue = primaryValue;
122
+ }
123
+ }
124
+
125
+ if (!secondaryKey) return primaryGroups;
126
+
127
+ // Further split each primary group by the secondary key. Each sub-segment is
128
+ // extended to include the first point of the next sub-segment so that
129
+ // consecutive segments share an endpoint (no gaps in multi-colored lines).
130
+ const result: ScaledDataRecord[][] = [];
131
+ for (const pGroup of primaryGroups) {
132
+ if (pGroup.length === 0) continue;
133
+ let subGroup: ScaledDataRecord[] = [pGroup[0]];
134
+ let lastSecondaryValue = resolveProp(secondaryKey, pGroup[0].datum);
135
+ for (let i = 1; i < pGroup.length; i++) {
136
+ const d = pGroup[i];
137
+ const secondaryValue = resolveProp(secondaryKey, d.datum);
138
+ if (secondaryValue === lastSecondaryValue) {
139
+ subGroup.push(d);
140
+ } else {
141
+ subGroup.push(d); // extend to connect to next sub-segment
142
+ result.push(subGroup);
143
+ subGroup = [d]; // new sub-segment begins here
144
+ lastSecondaryValue = secondaryValue;
145
+ }
113
146
  }
147
+ result.push(subGroup);
114
148
  }
115
- return groups;
149
+ return result;
116
150
  }
117
151
 
118
152
  const groupByKey = $derived(args.z || args.stroke) as ChannelAccessor<Datum> | null;
153
+ const secondaryKey = $derived(
154
+ args.z && args.stroke ? args.stroke : null
155
+ ) as ChannelAccessor<Datum> | null;
119
156
 
120
157
  const plot = usePlot();
121
158
 
@@ -155,7 +192,7 @@
155
192
  {...args}>
156
193
  {#snippet children({ mark, usedScales, scaledData })}
157
194
  {#if scaledData.length > 0}
158
- {@const groupedLineData = groupIndex(scaledData, groupByKey)}
195
+ {@const groupedLineData = groupIndex(scaledData, groupByKey, secondaryKey)}
159
196
  {#if canvas}
160
197
  <LineCanvas {groupedLineData} {mark} {usedScales} {linePath} {groupByKey} />
161
198
  {:else}