svelteplot 0.12.0 → 0.14.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.
Files changed (54) hide show
  1. package/dist/core/Plot.svelte +3 -6
  2. package/dist/helpers/group.d.ts +1 -1
  3. package/dist/helpers/group.js +3 -3
  4. package/dist/helpers/scales.js +8 -0
  5. package/dist/helpers/vectorShapes.d.ts +13 -0
  6. package/dist/helpers/vectorShapes.js +57 -0
  7. package/dist/marks/Arrow.svelte +70 -59
  8. package/dist/marks/Arrow.svelte.d.ts +2 -0
  9. package/dist/marks/ColorLegend.svelte +7 -3
  10. package/dist/marks/Contour.svelte +684 -0
  11. package/dist/marks/Contour.svelte.d.ts +152 -0
  12. package/dist/marks/DelaunayLink.svelte +127 -0
  13. package/dist/marks/DelaunayLink.svelte.d.ts +175 -0
  14. package/dist/marks/DelaunayMesh.svelte +102 -0
  15. package/dist/marks/DelaunayMesh.svelte.d.ts +172 -0
  16. package/dist/marks/Density.svelte +461 -0
  17. package/dist/marks/Density.svelte.d.ts +87 -0
  18. package/dist/marks/Hull.svelte +103 -0
  19. package/dist/marks/Hull.svelte.d.ts +175 -0
  20. package/dist/marks/Image.svelte +37 -27
  21. package/dist/marks/Image.svelte.d.ts +2 -0
  22. package/dist/marks/Link.svelte +68 -50
  23. package/dist/marks/Link.svelte.d.ts +2 -0
  24. package/dist/marks/Raster.svelte +6 -1
  25. package/dist/marks/Vector.svelte +12 -81
  26. package/dist/marks/Vector.svelte.d.ts +2 -4
  27. package/dist/marks/Voronoi.svelte +118 -0
  28. package/dist/marks/Voronoi.svelte.d.ts +172 -0
  29. package/dist/marks/VoronoiMesh.svelte +109 -0
  30. package/dist/marks/VoronoiMesh.svelte.d.ts +172 -0
  31. package/dist/marks/helpers/ArrowCanvas.svelte +132 -0
  32. package/dist/marks/helpers/ArrowCanvas.svelte.d.ts +39 -0
  33. package/dist/marks/helpers/BaseAxisX.svelte +5 -7
  34. package/dist/marks/helpers/DensityCanvas.svelte +118 -0
  35. package/dist/marks/helpers/DensityCanvas.svelte.d.ts +18 -0
  36. package/dist/marks/helpers/GeoPathCanvas.svelte +125 -0
  37. package/dist/marks/helpers/GeoPathCanvas.svelte.d.ts +24 -0
  38. package/dist/marks/helpers/GeoPathGroup.svelte +103 -0
  39. package/dist/marks/helpers/GeoPathGroup.svelte.d.ts +37 -0
  40. package/dist/marks/helpers/ImageCanvas.svelte +126 -0
  41. package/dist/marks/helpers/ImageCanvas.svelte.d.ts +34 -0
  42. package/dist/marks/helpers/LinkCanvas.svelte +103 -0
  43. package/dist/marks/helpers/LinkCanvas.svelte.d.ts +32 -0
  44. package/dist/marks/helpers/PathGroup.svelte +100 -0
  45. package/dist/marks/helpers/PathGroup.svelte.d.ts +16 -0
  46. package/dist/marks/helpers/PathItems.svelte +112 -0
  47. package/dist/marks/helpers/PathItems.svelte.d.ts +16 -0
  48. package/dist/marks/helpers/VectorCanvas.svelte +127 -0
  49. package/dist/marks/helpers/VectorCanvas.svelte.d.ts +36 -0
  50. package/dist/marks/index.d.ts +7 -0
  51. package/dist/marks/index.js +7 -0
  52. package/dist/types/mark.d.ts +1 -1
  53. package/dist/types/plot.d.ts +33 -1
  54. package/package.json +185 -181
@@ -0,0 +1,684 @@
1
+ <!-- @component
2
+ Renders contour lines (or filled contour bands) from a scalar field using
3
+ the marching-squares algorithm.
4
+
5
+ Supports the same three input modes as the `Raster` mark:
6
+
7
+ **Dense grid mode** (`data` is a flat row-major array, `width`/`height` are
8
+ set, no `x`/`y` channels): each datum is its own scalar value (unless `value`
9
+ is specified).
10
+
11
+ **Function sampling mode** (`data` is omitted/null, `value` is an
12
+ `(x, y) => number` function): the function is evaluated on a pixel grid.
13
+
14
+ **Scatter interpolation mode** (`data` is an array with `x`/`y` channels):
15
+ each datum contributes a position and scalar value; the mark spatially
16
+ interpolates over the grid before running marching squares.
17
+
18
+ Styling: `fill` and `stroke` accept ordinary CSS color strings **or** the
19
+ special keyword `"value"`, which maps each contour level's threshold through
20
+ the plot's color scale. Defaults: `fill="none"`, `stroke="currentColor"`.
21
+ -->
22
+ <script lang="ts" generics="Datum extends DataRow">
23
+ interface ContourMarkProps {
24
+ /**
25
+ * Input data. For **dense grid** mode supply a flat row-major array and
26
+ * set `width`/`height`. Omit (or set null) for **function-sampling**
27
+ * mode. For **scatter interpolation** supply an array of records with
28
+ * `x`/`y` channels.
29
+ */
30
+ data?: Datum[] | null;
31
+ /** x position channel (scatter mode) */
32
+ x?: ChannelAccessor<Datum>;
33
+ /** y position channel (scatter mode) */
34
+ y?: ChannelAccessor<Datum>;
35
+ /**
36
+ * Scalar field accessor, identity function for dense grid, or an
37
+ * `(x, y) => number` function for function-sampling mode.
38
+ */
39
+ value?: ChannelAccessor<Datum> | ((x: number, y: number) => number);
40
+ /**
41
+ * Contour threshold levels. Can be:
42
+ * - a **count** (number): approximately that many nicely-spaced levels
43
+ * - an explicit **array** of threshold values
44
+ * - a **function** `(values, min, max) => number[]`
45
+ * - a d3 **threshold scheme** object with `.floor()` / `.range()`
46
+ *
47
+ * Defaults to Sturges' formula applied to the value range.
48
+ */
49
+ thresholds?:
50
+ | number
51
+ | number[]
52
+ | ((values: number[], min: number, max: number) => number[])
53
+ | { floor(x: number): number; range(a: number, b: number): number[] };
54
+ /**
55
+ * Step interval between contour levels (alternative to `thresholds`).
56
+ * Can be a number (constant step) or an interval object with `.floor()`
57
+ * / `.range()`.
58
+ */
59
+ interval?: number | { floor(x: number): number; range(a: number, b: number): number[] };
60
+ /**
61
+ * Whether to apply linear interpolation when tracing contour edges
62
+ * (default `true`). Set to `false` for a blockier, faster appearance.
63
+ */
64
+ smooth?: boolean;
65
+ /** left bound of the domain in data coordinates */
66
+ x1?: number;
67
+ /** top bound of the domain in data coordinates */
68
+ y1?: number;
69
+ /** right bound of the domain in data coordinates */
70
+ x2?: number;
71
+ /** bottom bound of the domain in data coordinates */
72
+ y2?: number;
73
+ /**
74
+ * Explicit grid width; required for dense grid mode, overrides
75
+ * `pixelSize` in other modes.
76
+ */
77
+ width?: number;
78
+ /**
79
+ * Explicit grid height; required for dense grid mode, overrides
80
+ * `pixelSize` in other modes.
81
+ */
82
+ height?: number;
83
+ /** pixel size in screen pixels (default 2) */
84
+ pixelSize?: number;
85
+ /** Gaussian blur radius applied before contouring (default 0) */
86
+ blur?: number;
87
+ /**
88
+ * Spatial interpolation for scatter mode:
89
+ * `"none"` | `"nearest"` | `"barycentric"` | `"random-walk"` or a
90
+ * custom `(index, w, h, X, Y, V) => W` function.
91
+ * Defaults to `"nearest"` when data is provided.
92
+ */
93
+ interpolate?: 'none' | 'nearest' | 'barycentric' | 'random-walk' | InterpolateFunction;
94
+ /**
95
+ * Fill color for contour polygons. Use `"value"` to map each
96
+ * threshold level through the plot's color scale. Default `"none"`.
97
+ *
98
+ * **Shorthand**: if `value` is omitted and `fill` is a field name or
99
+ * accessor function (not a CSS color), it is automatically promoted to
100
+ * the `value` channel and `fill` is set to `"value"`.
101
+ */
102
+ fill?: string | ChannelAccessor<Datum>;
103
+ /**
104
+ * Stroke color for contour lines. Use `"value"` to map each
105
+ * threshold level through the plot's color scale. Default
106
+ * `"currentColor"`.
107
+ *
108
+ * **Shorthand**: if `value` is omitted and `stroke` is a field name or
109
+ * accessor function (not a CSS color), it is automatically promoted to
110
+ * the `value` channel and `stroke` is set to `"value"`.
111
+ */
112
+ stroke?: string | ChannelAccessor<Datum>;
113
+ strokeWidth?: number;
114
+ strokeOpacity?: number;
115
+ fillOpacity?: number;
116
+ opacity?: number;
117
+ strokeMiterlimit?: number;
118
+ clipPath?: string;
119
+ class?: string;
120
+ /** Render using a canvas element instead of SVG paths. */
121
+ canvas?: boolean;
122
+ /** the horizontal facet channel */
123
+ fx?: ChannelAccessor<Datum>;
124
+ /** the vertical facet channel */
125
+ fy?: ChannelAccessor<Datum>;
126
+ }
127
+
128
+ import type {
129
+ DataRow,
130
+ DataRecord,
131
+ ChannelAccessor,
132
+ ScaledDataRecord,
133
+ MarkType,
134
+ RawValue
135
+ } from '../types/index.js';
136
+ import { SvelteMap } from 'svelte/reactivity';
137
+ import { blur2, ticks, nice, range, thresholdSturges } from 'd3-array';
138
+ import { contours } from 'd3-contour';
139
+ import { geoPath } from 'd3-geo';
140
+ import Mark from '../Mark.svelte';
141
+ import GeoPathGroup from './helpers/GeoPathGroup.svelte';
142
+ import { usePlot } from '../hooks/usePlot.svelte.js';
143
+ import {
144
+ interpolateNone,
145
+ interpolateNearest,
146
+ interpolatorBarycentric,
147
+ interpolatorRandomWalk,
148
+ type InterpolateFunction
149
+ } from '../helpers/rasterInterpolate.js';
150
+ import { RAW_VALUE } from '../transforms/recordize.js';
151
+ import { ORIGINAL_NAME_KEYS } from '../constants.js';
152
+ import { scaleLinear } from 'd3-scale';
153
+ import { isColorOrNull } from '../helpers/typeChecks.js';
154
+ import { getPlotDefaults } from '../hooks/plotDefaults.js';
155
+
156
+ // Per-band fake-datum symbols — used to attach the contour geometry,
157
+ // extent, and pre-resolved facet values to the synthetic records passed
158
+ // to <Mark> for scale-domain registration and facet filtering.
159
+ const GEOM = Symbol('contour_geom');
160
+ const FX_VAL = Symbol('contour_fx');
161
+ const FY_VAL = Symbol('contour_fy');
162
+ const X1_VAL = Symbol('contour_x1');
163
+ const X2_VAL = Symbol('contour_x2');
164
+ const Y1_VAL = Symbol('contour_y1');
165
+ const Y2_VAL = Symbol('contour_y2');
166
+
167
+ const DEFAULTS = {
168
+ ...getPlotDefaults().contour
169
+ };
170
+
171
+ let markProps: ContourMarkProps = $props();
172
+
173
+ const {
174
+ data,
175
+ value: rawValue,
176
+ x: xAcc,
177
+ y: yAcc,
178
+ fx: fxAcc,
179
+ fy: fyAcc,
180
+ x1: x1Prop,
181
+ y1: y1Prop,
182
+ x2: x2Prop,
183
+ y2: y2Prop,
184
+ width: widthProp,
185
+ height: heightProp,
186
+ pixelSize = 4,
187
+ blur = 0,
188
+ smooth = true,
189
+ thresholds,
190
+ interval,
191
+ interpolate,
192
+ fill: rawFill = 'none',
193
+ stroke: rawStroke,
194
+ strokeWidth,
195
+ strokeOpacity,
196
+ fillOpacity,
197
+ opacity,
198
+ strokeMiterlimit = 1,
199
+ clipPath,
200
+ class: className = '',
201
+ canvas = false,
202
+ ...options
203
+ }: ContourMarkProps = $derived({ ...DEFAULTS, ...markProps });
204
+
205
+ /**
206
+ * Returns true when a fill/stroke value should be treated as a data
207
+ * accessor (field name or function) rather than a constant color.
208
+ */
209
+ function isContourAccessor(v: string | ChannelAccessor<Datum> | undefined): boolean {
210
+ if (v == null) return false;
211
+ if (typeof v === 'function') return true;
212
+ if (typeof v !== 'string') return true;
213
+ const lower = v.toLowerCase();
214
+ if (lower === 'none' || lower === 'value' || lower === 'inherit') return false;
215
+ return !isColorOrNull(v);
216
+ }
217
+
218
+ /**
219
+ * Apply Observable Plot's shorthand: when `value` is omitted and `fill`
220
+ * or `stroke` is a data accessor, promote it to the `value` channel.
221
+ */
222
+ const { fill, stroke, value } = $derived.by(() => {
223
+ if (rawValue !== undefined) {
224
+ return {
225
+ fill: rawFill as string,
226
+ stroke: rawStroke as string | undefined,
227
+ value: rawValue
228
+ };
229
+ }
230
+ const fillIsAccessor = isContourAccessor(rawFill);
231
+ const strokeIsAccessor = isContourAccessor(rawStroke);
232
+ if (fillIsAccessor && strokeIsAccessor) {
233
+ throw new Error('ambiguous contour value: both fill and stroke are data accessors');
234
+ }
235
+ if (fillIsAccessor) {
236
+ return { fill: 'value', stroke: rawStroke as string | undefined, value: rawFill };
237
+ }
238
+ if (strokeIsAccessor) {
239
+ return { fill: rawFill as string, stroke: 'value', value: rawStroke };
240
+ }
241
+ return {
242
+ fill: rawFill as string,
243
+ stroke: rawStroke as string | undefined,
244
+ value: rawValue
245
+ };
246
+ });
247
+
248
+ const plot = usePlot();
249
+
250
+ /** No data: value is an (x,y) function */
251
+ const isSamplerMode = $derived(data == null);
252
+
253
+ /** Dense grid: data is a flat array, width+height given, no x/y channels. */
254
+ const isDenseGridMode = $derived(
255
+ data != null && widthProp != null && heightProp != null && xAcc == null && yAcc == null
256
+ );
257
+
258
+ const interpolateFn = $derived(resolveInterpolate(interpolate));
259
+
260
+ /** When a fill is active, stroke defaults to none; otherwise currentColor. */
261
+ const effectiveStroke = $derived(stroke ?? (fill !== 'none' ? 'none' : 'currentColor'));
262
+
263
+ /**
264
+ * True when fill or stroke uses the `"value"` keyword, meaning threshold
265
+ * levels must be mapped through the plot's color scale.
266
+ */
267
+ const markUsesColorScale = $derived(fill === 'value' || effectiveStroke === 'value');
268
+
269
+ function resolveInterpolate(interp: ContourMarkProps['interpolate']): InterpolateFunction {
270
+ if (typeof interp === 'function') return interp;
271
+ const resolved = interp ?? (isSamplerMode || isDenseGridMode ? 'none' : 'nearest');
272
+ switch (String(resolved).toLowerCase()) {
273
+ case 'none':
274
+ return interpolateNone;
275
+ case 'nearest':
276
+ return interpolateNearest;
277
+ case 'barycentric':
278
+ return interpolatorBarycentric();
279
+ case 'random-walk':
280
+ return interpolatorRandomWalk();
281
+ }
282
+ throw new Error(`invalid interpolate: ${interp}`);
283
+ }
284
+
285
+ /** Pixel-space bounds of the current facet (or full plot if not faceted). */
286
+ function getBounds() {
287
+ const facetWidth = plot.facetWidth ?? 100;
288
+ const facetHeight = plot.facetHeight ?? 100;
289
+ const marginLeft = plot.options.marginLeft ?? 0;
290
+ const marginTop = plot.options.marginTop ?? 0;
291
+ return {
292
+ bx1: marginLeft,
293
+ by1: marginTop,
294
+ bx2: marginLeft + facetWidth,
295
+ by2: marginTop + facetHeight
296
+ };
297
+ }
298
+
299
+ /** Resolve the scalar value from a single datum. */
300
+ function resolveValue(datum: any): number | null {
301
+ if (value == null) return typeof datum === 'number' ? datum : null;
302
+ if (typeof value === 'string') return datum[value] ?? null;
303
+ if (typeof value === 'function') return (value as (d: any) => number)(datum);
304
+ return null;
305
+ }
306
+
307
+ type ContourGeometry = {
308
+ type: 'MultiPolygon';
309
+ coordinates: number[][][][];
310
+ /** threshold value that produced this band */
311
+ value: number;
312
+ /** pre-resolved fx channel value for facet filtering (undefined when not faceted) */
313
+ fxVal?: RawValue;
314
+ /** pre-resolved fy channel value for facet filtering (undefined when not faceted) */
315
+ fyVal?: RawValue;
316
+ };
317
+
318
+ /**
319
+ * Resolve a channel accessor against a single raw datum.
320
+ * Works for string field names and function accessors.
321
+ */
322
+ function resolveAcc(acc: ChannelAccessor<any> | undefined, d: any): any {
323
+ if (acc == null) return undefined;
324
+ if (typeof acc === 'function') return (acc as (d: any) => any)(d);
325
+ return (d as any)[acc as string];
326
+ }
327
+
328
+ /**
329
+ * Compute contour bands from a scalar field.
330
+ *
331
+ * @param scatterData Scatter-mode point array (uses `data` prop if omitted).
332
+ * @param fxVal Pre-resolved fx facet value to tag onto each geometry.
333
+ * @param fyVal Pre-resolved fy facet value to tag onto each geometry.
334
+ */
335
+ function computeContours(
336
+ scatterData?: any[] | null,
337
+ fxVal?: RawValue,
338
+ fyVal?: RawValue
339
+ ): ContourGeometry[] | null {
340
+ const { bx1, by1, bx2, by2 } = getBounds();
341
+ const dx = bx2 - bx1;
342
+ const dy = by2 - by1;
343
+ const w = widthProp ?? Math.round(Math.abs(dx) / pixelSize);
344
+ const h = heightProp ?? Math.round(Math.abs(dy) / pixelSize);
345
+ if (w <= 0 || h <= 0) return null;
346
+ const n = w * h;
347
+
348
+ // --- Build numeric value grid V ---
349
+ let V: number[] | null = null;
350
+
351
+ if (isDenseGridMode) {
352
+ const Vraw = (data as any[]).map((d) => resolveValue(d) ?? NaN);
353
+ V = new Array(n);
354
+ for (let row = 0; row < h; ++row) {
355
+ const srcRow = h - 1 - row;
356
+ for (let col = 0; col < w; ++col) {
357
+ V[row * w + col] = Vraw[srcRow * w + col];
358
+ }
359
+ }
360
+ } else if (isSamplerMode) {
361
+ if (typeof value !== 'function') return null;
362
+ const xScale = scaleLinear()
363
+ .range([x1Prop as number, x2Prop as number])
364
+ .domain([bx1, bx2]);
365
+ const yScale = scaleLinear()
366
+ .range([y1Prop as number, y2Prop as number])
367
+ .domain([by1, by2]);
368
+ const kx = dx / w;
369
+ const ky = dy / h;
370
+ V = new Array(n);
371
+ let i = 0;
372
+ for (let yi = 0.5; yi < h; ++yi) {
373
+ for (let xi = 0.5; xi < w; ++xi, ++i) {
374
+ V[i] = (value as (x: number, y: number) => number)(
375
+ xScale(bx1 + xi * kx),
376
+ yScale(by1 + yi * ky)
377
+ );
378
+ }
379
+ }
380
+ } else {
381
+ // Scatter interpolation — use the provided subset or the full dataset.
382
+ const pts = scatterData ?? (data as any[] | null);
383
+ if (!pts || pts.length === 0) return null;
384
+
385
+ const xFn = plot.scales.x?.fn;
386
+ const yFn = plot.scales.y?.fn;
387
+ if (!xFn || !yFn) return null;
388
+
389
+ type ScatterPt = { px: number; py: number; v: number | null };
390
+ const validData: ScatterPt[] = [];
391
+ for (const d of pts) {
392
+ const xv = resolveAcc(xAcc, d);
393
+ const yv = resolveAcc(yAcc, d);
394
+ if (xv == null || yv == null) continue;
395
+ const px = xFn(xv) as number;
396
+ const py = yFn(yv) as number;
397
+ if (!Number.isFinite(px) || !Number.isFinite(py)) continue;
398
+ validData.push({ px, py, v: resolveValue(d) });
399
+ }
400
+ if (validData.length === 0) return null;
401
+
402
+ const kx = w / dx;
403
+ const ky = h / dy;
404
+ const index = validData.map((_, i) => i);
405
+ const IX = new Float64Array(validData.map((d) => (d.px - bx1) * kx));
406
+ const IY = new Float64Array(validData.map((d) => (d.py - by1) * ky));
407
+ const rawValues = validData.map((d) => d.v);
408
+ if (rawValues.some((v) => v != null)) {
409
+ V = Array.from(interpolateFn(index, w, h, IX, IY, rawValues));
410
+ }
411
+ }
412
+
413
+ if (!V) return null;
414
+
415
+ // --- Optional Gaussian blur ---
416
+ if (blur > 0) blur2({ data: V, width: w, height: h }, blur);
417
+
418
+ // --- Compute thresholds from the actual grid ---
419
+ const T = computeThresholds(V, w, h, thresholds, interval);
420
+ if (T.length === 0) return null;
421
+
422
+ // --- Run marching squares ---
423
+ const kx = w / dx;
424
+ const ky = h / dy;
425
+ const contourFn = contours().size([w, h]).smooth(smooth);
426
+
427
+ return T.map((t) => {
428
+ const geom = contourFn.contour(V!, t) as ContourGeometry;
429
+
430
+ // Rescale from grid coordinates to SVG pixel coordinates
431
+ for (const rings of geom.coordinates) {
432
+ for (const ring of rings) {
433
+ for (const point of ring) {
434
+ point[0] = point[0] / kx + bx1;
435
+ point[1] = point[1] / ky + by1;
436
+ }
437
+ }
438
+ }
439
+
440
+ // Tag with facet identity so the Mark can filter per panel
441
+ if (fxVal !== undefined) geom.fxVal = fxVal;
442
+ if (fyVal !== undefined) geom.fyVal = fyVal;
443
+
444
+ return geom;
445
+ });
446
+ }
447
+
448
+ /** Compute an array of threshold tick values from the V grid. */
449
+ function computeThresholds(
450
+ V: number[],
451
+ w: number,
452
+ h: number,
453
+ thresholdSpec: ContourMarkProps['thresholds'],
454
+ intervalSpec: ContourMarkProps['interval']
455
+ ): number[] {
456
+ let vMin = Infinity,
457
+ vMax = -Infinity;
458
+ for (const v of V) {
459
+ if (isFinite(v)) {
460
+ if (v < vMin) vMin = v;
461
+ if (v > vMax) vMax = v;
462
+ }
463
+ }
464
+ if (!isFinite(vMin) || !isFinite(vMax) || vMin === vMax) return [];
465
+
466
+ if (thresholdSpec == null && intervalSpec != null) {
467
+ if (typeof intervalSpec === 'number') {
468
+ const step = intervalSpec;
469
+ return range(Math.floor(vMin / step) * step, vMax, step);
470
+ }
471
+ return intervalSpec.range(intervalSpec.floor(vMin), vMax);
472
+ }
473
+
474
+ const tSpec: any = thresholdSpec ?? thresholdSturges;
475
+
476
+ if (typeof tSpec === 'object' && tSpec !== null && 'range' in tSpec) {
477
+ return tSpec.range(tSpec.floor(vMin), vMax);
478
+ }
479
+
480
+ if (Array.isArray(tSpec)) return tSpec;
481
+
482
+ let resolved: any = tSpec;
483
+ if (typeof tSpec === 'function') {
484
+ const finiteV = V.filter(isFinite);
485
+ resolved = tSpec(finiteV, vMin, vMax);
486
+ }
487
+
488
+ if (Array.isArray(resolved)) return resolved;
489
+
490
+ const count = resolved as number;
491
+ const [nMin, nMax] = nice(vMin, vMax, count) as [number, number];
492
+ const tz = ticks(nMin, nMax, count);
493
+ while (tz.length > 0 && tz[tz.length - 1] >= vMax) tz.pop();
494
+ while (tz.length > 1 && tz[1] < vMin) tz.shift();
495
+ return tz;
496
+ }
497
+
498
+ const path = geoPath();
499
+
500
+ /**
501
+ * All contour bands across every facet group, computed from the full
502
+ * dataset at root level.
503
+ *
504
+ * - Non-faceted / dense-grid / sampler: single call to `computeContours`.
505
+ * - Faceted scatter: data is grouped by (fx, fy) value combinations and
506
+ * `computeContours` is called once per group; each geometry is tagged with
507
+ * its `fxVal`/`fyVal` so that <Mark> can filter per panel.
508
+ */
509
+ const contourResult = $derived.by((): ContourGeometry[] | null => {
510
+ const isScatterFaceted =
511
+ !isDenseGridMode && !isSamplerMode && (fxAcc != null || fyAcc != null);
512
+
513
+ if (!isScatterFaceted) {
514
+ return computeContours();
515
+ }
516
+
517
+ // Group scatter data by (fxVal, fyVal)
518
+ if (!data || !(data as any[]).length) return null;
519
+ const groups = new SvelteMap<string, { fxVal: RawValue; fyVal: RawValue; items: any[] }>();
520
+ for (const d of data as any[]) {
521
+ const fxVal = resolveAcc(fxAcc, d);
522
+ const fyVal = resolveAcc(fyAcc, d);
523
+ const key = `${fxVal}\0${fyVal}`;
524
+ if (!groups.has(key)) groups.set(key, { fxVal, fyVal, items: [] });
525
+ groups.get(key)!.items.push(d);
526
+ }
527
+
528
+ const allBands: ContourGeometry[] = [];
529
+ for (const { fxVal, fyVal, items } of groups.values()) {
530
+ const bands = computeContours(items, fxVal, fyVal);
531
+ if (bands) allBands.push(...bands);
532
+ }
533
+ return allBands.length > 0 ? allBands : null;
534
+ });
535
+
536
+ // --- Mark registration data ---
537
+
538
+ /**
539
+ * Data-space extent of the mark, stored as x1/x2/y1/y2 values that are
540
+ * attached to every per-band fake datum so <Mark> can register the x/y
541
+ * scale domains without needing separate corner records.
542
+ */
543
+ const extent = $derived.by(() => {
544
+ if (isDenseGridMode) {
545
+ return { x1: 0, x2: widthProp! - 1, y1: 0, y2: heightProp! - 1 };
546
+ }
547
+ if (isSamplerMode) {
548
+ if (x1Prop != null && x2Prop != null && y1Prop != null && y2Prop != null) {
549
+ return {
550
+ x1: x1Prop as number,
551
+ x2: x2Prop as number,
552
+ y1: y1Prop as number,
553
+ y2: y2Prop as number
554
+ };
555
+ }
556
+ return null;
557
+ }
558
+ // Scatter: compute from the full dataset (global extent across all facets)
559
+ if (!data) return null;
560
+ let xMin = Infinity,
561
+ xMax = -Infinity,
562
+ yMin = Infinity,
563
+ yMax = -Infinity;
564
+ for (const d of data as any[]) {
565
+ const xv = resolveAcc(xAcc, d);
566
+ const yv = resolveAcc(yAcc, d);
567
+ if (typeof xv === 'number' && isFinite(xv)) {
568
+ if (xv < xMin) xMin = xv;
569
+ if (xv > xMax) xMax = xv;
570
+ }
571
+ if (typeof yv === 'number' && isFinite(yv)) {
572
+ if (yv < yMin) yMin = yv;
573
+ if (yv > yMax) yMax = yv;
574
+ }
575
+ }
576
+ return isFinite(xMin) ? { x1: xMin, x2: xMax, y1: yMin, y2: yMax } : null;
577
+ });
578
+
579
+ /**
580
+ * Unified mark data:
581
+ *
582
+ * For scatter mode, one extent-only record is always included so that the
583
+ * x/y scale domain is bootstrapped before `contourResult` is available
584
+ * (scatter contours depend on `plot.scales.x/y.fn`, which needs this record
585
+ * to exist first; dense/sampler modes don't have this circular dependency
586
+ * since they compute V without needing x/y scales).
587
+ *
588
+ * Each per-band fake datum carries:
589
+ * - [X1_VAL]..[Y2_VAL] data-space extent → x/y scale domain registration
590
+ * - [RAW_VALUE] threshold → color scale domain registration
591
+ * - [FX_VAL]/[FY_VAL] pre-resolved facet values → Mark facet filtering
592
+ * - [GEOM] geometry reference → rendered by children snippet
593
+ */
594
+ const markData = $derived.by((): DataRecord[] => {
595
+ const ext = extent;
596
+ const records: any[] = [];
597
+
598
+ // Scatter mode: always include a bootstrap extent record so x/y scales
599
+ // are initialized independently of contourResult.
600
+ if (!isDenseGridMode && !isSamplerMode && ext && !contourResult) {
601
+ records.push({
602
+ [X1_VAL]: ext.x1,
603
+ [X2_VAL]: ext.x2,
604
+ [Y1_VAL]: ext.y1,
605
+ [Y2_VAL]: ext.y2
606
+ });
607
+ }
608
+
609
+ if (contourResult) {
610
+ for (const geom of contourResult) {
611
+ records.push({
612
+ [X1_VAL]: ext?.x1,
613
+ [X2_VAL]: ext?.x2,
614
+ [Y1_VAL]: ext?.y1,
615
+ [Y2_VAL]: ext?.y2,
616
+ [RAW_VALUE]: geom.value,
617
+ [FX_VAL]: geom.fxVal,
618
+ [FY_VAL]: geom.fyVal,
619
+ [GEOM]: geom
620
+ });
621
+ }
622
+ }
623
+
624
+ return records as DataRecord[];
625
+ });
626
+
627
+ const markChannels = $derived(
628
+ markUsesColorScale
629
+ ? (['x1', 'x2', 'y1', 'y2', 'fill'] as const)
630
+ : (['x1', 'x2', 'y1', 'y2'] as const)
631
+ );
632
+
633
+ const markFill = $derived(markUsesColorScale ? (RAW_VALUE as any) : undefined);
634
+
635
+ // Custom fx/fy accessors that read the pre-resolved facet values stored on
636
+ // the fake datums. These replace the user's original accessors (which
637
+ // pointed to fields on the raw scatter data) so that <Mark>'s facet
638
+ // filtering operates on the band records rather than the raw datums.
639
+ const markFx = $derived(fxAcc != null ? FX_VAL : undefined);
640
+ const markFy = $derived(fyAcc != null ? FY_VAL : undefined);
641
+
642
+ // Channel overrides passed to <Mark> as a spread so TypeScript's excess-property
643
+ // check doesn't fire (explicit named props on a component trigger that check,
644
+ // but spreading a typed variable does not).
645
+ const markChannelProps = $derived({
646
+ x1: X1_VAL as unknown as ContourMarkProps['x1'],
647
+ x2: X2_VAL as unknown as ContourMarkProps['x1'],
648
+ y1: Y1_VAL as unknown as ContourMarkProps['y1'],
649
+ y2: Y2_VAL as unknown as ContourMarkProps['y1'],
650
+ fill: markFill as ContourMarkProps['fill'],
651
+ fx: markFx as ContourMarkProps['fx'],
652
+ fy: markFy as ContourMarkProps['fy'],
653
+ ...(typeof xAcc === 'string' && { [ORIGINAL_NAME_KEYS.x]: xAcc }),
654
+ ...(typeof yAcc === 'string' && { [ORIGINAL_NAME_KEYS.y]: yAcc }),
655
+ ...(markUsesColorScale && typeof value === 'string' && { [ORIGINAL_NAME_KEYS.fill]: value })
656
+ } satisfies Partial<ContourMarkProps>);
657
+ </script>
658
+
659
+ <Mark
660
+ type={'contour' as MarkType}
661
+ data={markData}
662
+ channels={markChannels as any}
663
+ {...options}
664
+ {...markChannelProps}>
665
+ {#snippet children({ scaledData }: { scaledData: ScaledDataRecord[] })}
666
+ <GeoPathGroup
667
+ {scaledData}
668
+ {path}
669
+ geomKey={GEOM}
670
+ colorKeyword="value"
671
+ {fill}
672
+ stroke={effectiveStroke}
673
+ {strokeWidth}
674
+ {strokeOpacity}
675
+ {fillOpacity}
676
+ {opacity}
677
+ {strokeMiterlimit}
678
+ {clipPath}
679
+ className={className || undefined}
680
+ ariaLabel="contour"
681
+ {canvas}
682
+ {plot} />
683
+ {/snippet}
684
+ </Mark>