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