svelteplot 0.13.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 (32) hide show
  1. package/dist/helpers/group.d.ts +1 -1
  2. package/dist/helpers/group.js +3 -3
  3. package/dist/marks/ColorLegend.svelte +5 -1
  4. package/dist/marks/Contour.svelte +21 -30
  5. package/dist/marks/Contour.svelte.d.ts +2 -0
  6. package/dist/marks/DelaunayLink.svelte +127 -0
  7. package/dist/marks/DelaunayLink.svelte.d.ts +175 -0
  8. package/dist/marks/DelaunayMesh.svelte +102 -0
  9. package/dist/marks/DelaunayMesh.svelte.d.ts +172 -0
  10. package/dist/marks/Density.svelte +461 -0
  11. package/dist/marks/Density.svelte.d.ts +87 -0
  12. package/dist/marks/Hull.svelte +103 -0
  13. package/dist/marks/Hull.svelte.d.ts +175 -0
  14. package/dist/marks/Voronoi.svelte +118 -0
  15. package/dist/marks/Voronoi.svelte.d.ts +172 -0
  16. package/dist/marks/VoronoiMesh.svelte +109 -0
  17. package/dist/marks/VoronoiMesh.svelte.d.ts +172 -0
  18. package/dist/marks/helpers/DensityCanvas.svelte +118 -0
  19. package/dist/marks/helpers/DensityCanvas.svelte.d.ts +18 -0
  20. package/dist/marks/helpers/GeoPathCanvas.svelte +125 -0
  21. package/dist/marks/helpers/GeoPathCanvas.svelte.d.ts +24 -0
  22. package/dist/marks/helpers/GeoPathGroup.svelte +103 -0
  23. package/dist/marks/helpers/GeoPathGroup.svelte.d.ts +37 -0
  24. package/dist/marks/helpers/PathGroup.svelte +100 -0
  25. package/dist/marks/helpers/PathGroup.svelte.d.ts +16 -0
  26. package/dist/marks/helpers/PathItems.svelte +112 -0
  27. package/dist/marks/helpers/PathItems.svelte.d.ts +16 -0
  28. package/dist/marks/index.d.ts +7 -1
  29. package/dist/marks/index.js +7 -1
  30. package/dist/types/mark.d.ts +1 -1
  31. package/dist/types/plot.d.ts +25 -1
  32. package/package.json +1 -1
@@ -0,0 +1,172 @@
1
+ import type { DataRecord, ChannelAccessor } from '../types/index.js';
2
+ declare function $$render<Datum = DataRecord>(): {
3
+ props: Partial<{
4
+ filter: import("../types/index.js").ConstantAccessor<boolean, Datum>;
5
+ facet: "auto" | "include" | "exclude";
6
+ fx: ChannelAccessor<Datum>;
7
+ fy: ChannelAccessor<Datum>;
8
+ dx: import("../types/index.js").ConstantAccessor<number, Datum>;
9
+ dy: import("../types/index.js").ConstantAccessor<number, Datum>;
10
+ dodgeX: import("../transforms/dodge.js").DodgeXOptions;
11
+ dodgeY: import("../transforms/dodge.js").DodgeYOptions;
12
+ fill: ChannelAccessor<Datum>;
13
+ fillOpacity: import("../types/index.js").ConstantAccessor<number, Datum>;
14
+ fontFamily: import("../types/index.js").ConstantAccessor<import("csstype").Property.FontFamily, Datum>;
15
+ fontSize: import("../types/index.js").ConstantAccessor<import("csstype").Property.FontSize<number>, Datum>;
16
+ fontStyle: import("../types/index.js").ConstantAccessor<import("csstype").Property.FontStyle, Datum>;
17
+ fontVariant: import("../types/index.js").ConstantAccessor<import("csstype").Property.FontVariant, Datum>;
18
+ fontWeight: import("../types/index.js").ConstantAccessor<import("csstype").Property.FontWeight, Datum>;
19
+ letterSpacing: import("../types/index.js").ConstantAccessor<import("csstype").Property.LetterSpacing<0 | (string & {})>, Datum>;
20
+ wordSpacing: import("../types/index.js").ConstantAccessor<import("csstype").Property.WordSpacing<0 | (string & {})>, Datum>;
21
+ textAnchor: import("../types/index.js").ConstantAccessor<import("csstype").Property.TextAnchor, Datum>;
22
+ textTransform: import("../types/index.js").ConstantAccessor<import("csstype").Property.TextTransform, Datum>;
23
+ textDecoration: import("../types/index.js").ConstantAccessor<import("csstype").Property.TextDecoration<0 | (string & {})>, Datum>;
24
+ sort: ((a: import("../types/data.js").RawValue, b: import("../types/data.js").RawValue) => number) | {
25
+ channel: string;
26
+ order?: "ascending" | "descending";
27
+ } | import("../types/index.js").ConstantAccessor<import("../types/data.js").RawValue, Datum>;
28
+ stroke: ChannelAccessor<Datum>;
29
+ strokeWidth: import("../types/index.js").ConstantAccessor<number, Datum>;
30
+ strokeOpacity: import("../types/index.js").ConstantAccessor<number, Datum>;
31
+ strokeLinejoin: import("../types/index.js").ConstantAccessor<import("csstype").Property.StrokeLinejoin, Datum>;
32
+ strokeLinecap: import("../types/index.js").ConstantAccessor<import("csstype").Property.StrokeLinecap, Datum>;
33
+ strokeMiterlimit: import("../types/index.js").ConstantAccessor<number, Datum>;
34
+ opacity: ChannelAccessor<Datum>;
35
+ strokeDasharray: import("../types/index.js").ConstantAccessor<string, Datum>;
36
+ strokeDashoffset: import("../types/index.js").ConstantAccessor<number, Datum>;
37
+ blend: import("../types/index.js").ConstantAccessor<import("csstype").Property.MixBlendMode, Datum>;
38
+ mixBlendMode: import("../types/index.js").ConstantAccessor<import("csstype").Property.MixBlendMode, Datum>;
39
+ clipPath: string;
40
+ mask: string;
41
+ imageFilter: import("../types/index.js").ConstantAccessor<string, Datum>;
42
+ shapeRendering: import("../types/index.js").ConstantAccessor<import("csstype").Property.ShapeRendering, Datum>;
43
+ paintOrder: import("../types/index.js").ConstantAccessor<string, Datum>;
44
+ onclick: (event: Event & {
45
+ currentTarget: SVGPathElement;
46
+ }, datum: Datum, index: number) => void;
47
+ ondblclick: (event: Event & {
48
+ currentTarget: SVGPathElement;
49
+ }, datum: Datum, index: number) => void;
50
+ onmouseup: (event: Event & {
51
+ currentTarget: SVGPathElement;
52
+ }, datum: Datum, index: number) => void;
53
+ onmousedown: (event: Event & {
54
+ currentTarget: SVGPathElement;
55
+ }, datum: Datum, index: number) => void;
56
+ onmouseenter: (event: Event & {
57
+ currentTarget: SVGPathElement;
58
+ }, datum: Datum, index: number) => void;
59
+ onmousemove: (event: Event & {
60
+ currentTarget: SVGPathElement;
61
+ }, datum: Datum, index: number) => void;
62
+ onmouseleave: (event: Event & {
63
+ currentTarget: SVGPathElement;
64
+ }, datum: Datum, index: number) => void;
65
+ onmouseout: (event: Event & {
66
+ currentTarget: SVGPathElement;
67
+ }, datum: Datum, index: number) => void;
68
+ onmouseover: (event: Event & {
69
+ currentTarget: SVGPathElement;
70
+ }, datum: Datum, index: number) => void;
71
+ onpointercancel: (event: Event & {
72
+ currentTarget: SVGPathElement;
73
+ }, datum: Datum, index: number) => void;
74
+ onpointerdown: (event: Event & {
75
+ currentTarget: SVGPathElement;
76
+ }, datum: Datum, index: number) => void;
77
+ onpointerup: (event: Event & {
78
+ currentTarget: SVGPathElement;
79
+ }, datum: Datum, index: number) => void;
80
+ onpointerenter: (event: Event & {
81
+ currentTarget: SVGPathElement;
82
+ }, datum: Datum, index: number) => void;
83
+ onpointerleave: (event: Event & {
84
+ currentTarget: SVGPathElement;
85
+ }, datum: Datum, index: number) => void;
86
+ onpointermove: (event: Event & {
87
+ currentTarget: SVGPathElement;
88
+ }, datum: Datum, index: number) => void;
89
+ onpointerover: (event: Event & {
90
+ currentTarget: SVGPathElement;
91
+ }, datum: Datum, index: number) => void;
92
+ onpointerout: (event: Event & {
93
+ currentTarget: SVGPathElement;
94
+ }, datum: Datum, index: number) => void;
95
+ ondrag: (event: Event & {
96
+ currentTarget: SVGPathElement;
97
+ }, datum: Datum, index: number) => void;
98
+ ondrop: (event: Event & {
99
+ currentTarget: SVGPathElement;
100
+ }, datum: Datum, index: number) => void;
101
+ ondragstart: (event: Event & {
102
+ currentTarget: SVGPathElement;
103
+ }, datum: Datum, index: number) => void;
104
+ ondragenter: (event: Event & {
105
+ currentTarget: SVGPathElement;
106
+ }, datum: Datum, index: number) => void;
107
+ ondragleave: (event: Event & {
108
+ currentTarget: SVGPathElement;
109
+ }, datum: Datum, index: number) => void;
110
+ ondragover: (event: Event & {
111
+ currentTarget: SVGPathElement;
112
+ }, datum: Datum, index: number) => void;
113
+ ondragend: (event: Event & {
114
+ currentTarget: SVGPathElement;
115
+ }, datum: Datum, index: number) => void;
116
+ ontouchstart: (event: Event & {
117
+ currentTarget: SVGPathElement;
118
+ }, datum: Datum, index: number) => void;
119
+ ontouchmove: (event: Event & {
120
+ currentTarget: SVGPathElement;
121
+ }, datum: Datum, index: number) => void;
122
+ ontouchend: (event: Event & {
123
+ currentTarget: SVGPathElement;
124
+ }, datum: Datum, index: number) => void;
125
+ ontouchcancel: (event: Event & {
126
+ currentTarget: SVGPathElement;
127
+ }, datum: Datum, index: number) => void;
128
+ oncontextmenu: (event: Event & {
129
+ currentTarget: SVGPathElement;
130
+ }, datum: Datum, index: number) => void;
131
+ onwheel: (event: Event & {
132
+ currentTarget: SVGPathElement;
133
+ }, datum: Datum, index: number) => void;
134
+ class: string;
135
+ style: string;
136
+ cursor: import("../types/index.js").ConstantAccessor<import("csstype").Property.Cursor, Datum>;
137
+ title: import("../types/index.js").ConstantAccessor<string, Datum>;
138
+ }> & {
139
+ /** the input data array */
140
+ data?: Datum[];
141
+ /** the horizontal position channel */
142
+ x?: ChannelAccessor<Datum>;
143
+ /** the vertical position channel */
144
+ y?: ChannelAccessor<Datum>;
145
+ /** the grouping channel; separate triangulations per group */
146
+ z?: ChannelAccessor<Datum>;
147
+ /** Render using a canvas element instead of SVG paths. */
148
+ canvas?: boolean;
149
+ };
150
+ exports: {};
151
+ bindings: "";
152
+ slots: {};
153
+ events: {};
154
+ };
155
+ declare class __sveltets_Render<Datum = DataRecord> {
156
+ props(): ReturnType<typeof $$render<Datum>>['props'];
157
+ events(): ReturnType<typeof $$render<Datum>>['events'];
158
+ slots(): ReturnType<typeof $$render<Datum>>['slots'];
159
+ bindings(): "";
160
+ exports(): {};
161
+ }
162
+ interface $$IsomorphicComponent {
163
+ new <Datum = DataRecord>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<Datum>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<Datum>['props']>, ReturnType<__sveltets_Render<Datum>['events']>, ReturnType<__sveltets_Render<Datum>['slots']>> & {
164
+ $$bindings?: ReturnType<__sveltets_Render<Datum>['bindings']>;
165
+ } & ReturnType<__sveltets_Render<Datum>['exports']>;
166
+ <Datum = DataRecord>(internal: unknown, props: ReturnType<__sveltets_Render<Datum>['props']> & {}): ReturnType<__sveltets_Render<Datum>['exports']>;
167
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
168
+ }
169
+ /** Renders the full Delaunay triangulation as a single SVG path. */
170
+ declare const DelaunayMesh: $$IsomorphicComponent;
171
+ type DelaunayMesh<Datum = DataRecord> = InstanceType<typeof DelaunayMesh<Datum>>;
172
+ export default DelaunayMesh;
@@ -0,0 +1,461 @@
1
+ <!-- @component
2
+ Renders two-dimensional kernel density estimation as filled or stroked
3
+ contour paths.
4
+
5
+ Data points with `x` and `y` channels are projected into pixel space and
6
+ passed to d3's `contourDensity` estimator, which uses a Gaussian kernel to
7
+ produce a density grid. Iso-density contour bands are then drawn using the
8
+ marching-squares algorithm.
9
+
10
+ Styling: `fill` and `stroke` accept ordinary CSS color strings **or** the
11
+ special keyword `"density"`, which maps each contour level's estimated
12
+ density through the plot's color scale. Defaults: `fill="none"`,
13
+ `stroke="currentColor"`.
14
+
15
+ Supports faceting via `fx`/`fy`.
16
+ -->
17
+ <script lang="ts" generics="Datum extends DataRecord">
18
+ interface DensityMarkProps {
19
+ /** Input data — an array of records with x/y positions. */
20
+ data?: Datum[] | null;
21
+ /** x position channel (data space). */
22
+ x?: ChannelAccessor<Datum>;
23
+ /** y position channel (data space). */
24
+ y?: ChannelAccessor<Datum>;
25
+ /** Optional weight channel; defaults to 1 for each point. */
26
+ weight?: ChannelAccessor<Datum>;
27
+ /**
28
+ * Gaussian kernel bandwidth in screen pixels (default 20).
29
+ * Larger values produce smoother, more blurred density estimates.
30
+ */
31
+ bandwidth?: number;
32
+ /**
33
+ * Density threshold levels. Can be:
34
+ * - a **count** (number): that many evenly-spaced levels from 0 to the
35
+ * maximum density (default 20)
36
+ * - an explicit **array** of threshold values in k-scaled density units
37
+ * (where k = 100; values from 0 to roughly 100× the peak density)
38
+ */
39
+ thresholds?: number | number[];
40
+ /**
41
+ * Fill color for density bands. Use `"density"` to map each band's
42
+ * estimated density through the plot's color scale. Default `"none"`.
43
+ */
44
+ fill?: string;
45
+ /**
46
+ * Stroke color for density isolines. Use `"density"` to map each
47
+ * isoline's estimated density through the plot's color scale.
48
+ * Default `"currentColor"` when fill is `"none"`, otherwise `"none"`.
49
+ */
50
+ stroke?: string;
51
+ strokeWidth?: number;
52
+ strokeOpacity?: number;
53
+ fillOpacity?: number;
54
+ opacity?: number;
55
+ strokeMiterlimit?: number;
56
+ clipPath?: string;
57
+ class?: string;
58
+ /** Render using a canvas element instead of SVG paths. */
59
+ canvas?: boolean;
60
+ /** the horizontal facet channel */
61
+ fx?: ChannelAccessor<Datum>;
62
+ /** the vertical facet channel */
63
+ fy?: ChannelAccessor<Datum>;
64
+ }
65
+
66
+ import type {
67
+ DataRecord,
68
+ ChannelAccessor,
69
+ ScaledDataRecord,
70
+ MarkType,
71
+ RawValue
72
+ } from '../types/index.js';
73
+ import GeoPathGroup from './helpers/GeoPathGroup.svelte';
74
+ import { SvelteMap, SvelteSet } from 'svelte/reactivity';
75
+ import { contourDensity } from 'd3-contour';
76
+ import { geoPath } from 'd3-geo';
77
+ import Mark from '../Mark.svelte';
78
+ import { usePlot } from '../hooks/usePlot.svelte.js';
79
+ import { RAW_VALUE } from '../transforms/recordize.js';
80
+ import { ORIGINAL_NAME_KEYS } from '../constants.js';
81
+ import { getPlotDefaults } from '../hooks/plotDefaults.js';
82
+
83
+ // Per-band fake-datum symbols — used to attach the density geometry,
84
+ // extent, and pre-resolved facet values to the synthetic records passed
85
+ // to <Mark> for scale-domain registration and facet filtering.
86
+ const GEOM = Symbol('density_geom');
87
+ const FX_VAL = Symbol('density_fx');
88
+ const FY_VAL = Symbol('density_fy');
89
+ const X1_VAL = Symbol('density_x1');
90
+ const X2_VAL = Symbol('density_x2');
91
+ const Y1_VAL = Symbol('density_y1');
92
+ const Y2_VAL = Symbol('density_y2');
93
+
94
+ /**
95
+ * Arbitrary scale factor matching Observable Plot's density mark.
96
+ * Multiplying raw density values (in 1/px²) by k makes thresholds
97
+ * human-readable integers rather than tiny floating-point numbers.
98
+ */
99
+ const k = 100;
100
+
101
+ const DEFAULTS = {
102
+ ...getPlotDefaults().density
103
+ };
104
+
105
+ let markProps: DensityMarkProps = $props();
106
+
107
+ const {
108
+ data,
109
+ x: xAcc,
110
+ y: yAcc,
111
+ weight: weightAcc,
112
+ bandwidth = 20,
113
+ thresholds: thresholdSpec = 20,
114
+ fill = 'none',
115
+ stroke: rawStroke,
116
+ strokeWidth,
117
+ strokeOpacity,
118
+ fillOpacity,
119
+ opacity,
120
+ strokeMiterlimit = 1,
121
+ clipPath,
122
+ class: className = '',
123
+ canvas = false,
124
+ fx: fxAcc,
125
+ fy: fyAcc,
126
+ ...options
127
+ }: DensityMarkProps = $derived({ ...DEFAULTS, ...markProps });
128
+
129
+ const plot = usePlot();
130
+
131
+ const fillDensity = $derived(/^density$/i.test(fill ?? ''));
132
+
133
+ /**
134
+ * When fill is active (not "none" or "density"), stroke defaults to "none";
135
+ * otherwise it defaults to "currentColor".
136
+ */
137
+ const effectiveStroke = $derived(rawStroke ?? (fill !== 'none' ? 'none' : 'currentColor'));
138
+
139
+ const strokeDensity = $derived(/^density$/i.test(effectiveStroke ?? ''));
140
+
141
+ /**
142
+ * True when fill or stroke uses the `"density"` keyword — the color scale
143
+ * is used to encode density values.
144
+ */
145
+ const markUsesColorScale = $derived(fillDensity || strokeDensity);
146
+
147
+ /** Resolve a channel accessor against a single datum. */
148
+ function resolveAcc(acc: ChannelAccessor<any> | undefined, d: any): any {
149
+ if (acc == null) return undefined;
150
+ if (typeof acc === 'function') return (acc as (d: any) => any)(d);
151
+ return (d as any)[acc as string];
152
+ }
153
+
154
+ /** Pixel-space bounds of the current facet (or full plot if not faceted). */
155
+ function getBounds() {
156
+ const facetWidth = plot.facetWidth ?? 100;
157
+ const facetHeight = plot.facetHeight ?? 100;
158
+ const marginLeft = plot.options.marginLeft ?? 0;
159
+ const marginTop = plot.options.marginTop ?? 0;
160
+ return {
161
+ bx1: marginLeft,
162
+ by1: marginTop,
163
+ w: Math.max(1, Math.round(facetWidth)),
164
+ h: Math.max(1, Math.round(facetHeight))
165
+ };
166
+ }
167
+
168
+ type DensityGeometry = {
169
+ type: 'MultiPolygon';
170
+ coordinates: number[][][][];
171
+ /** k-scaled density threshold (value × k); used for color mapping. */
172
+ value: number;
173
+ /** pre-resolved fx channel value for facet filtering */
174
+ fxVal?: RawValue;
175
+ /** pre-resolved fy channel value for facet filtering */
176
+ fyVal?: RawValue;
177
+ };
178
+
179
+ /**
180
+ * All density contour bands across every facet group.
181
+ *
182
+ * Phase 1: compute density grids per facet group, find global max density.
183
+ * Phase 2: derive thresholds from global max (for consistent color scale).
184
+ * Phase 3: compute contour geometries at those thresholds.
185
+ *
186
+ * Geometries are tagged with fxVal/fyVal so <Mark> can filter per panel.
187
+ */
188
+ const densityResult = $derived.by((): DensityGeometry[] | null => {
189
+ const xFn = plot.scales.x?.fn;
190
+ const yFn = plot.scales.y?.fn;
191
+ if (!xFn || !yFn || !data?.length) return null;
192
+
193
+ const { bx1, by1, w, h } = getBounds();
194
+ const isFaceted = fxAcc != null || fyAcc != null;
195
+
196
+ // Group data by (fxVal, fyVal) — a single group when not faceted.
197
+ const groups = new SvelteMap<string, { fxVal: RawValue; fyVal: RawValue; items: any[] }>();
198
+ for (const d of data as any[]) {
199
+ const fxVal = isFaceted ? resolveAcc(fxAcc, d) : undefined;
200
+ const fyVal = isFaceted ? resolveAcc(fyAcc, d) : undefined;
201
+ const key = `${fxVal}\0${fyVal}`;
202
+ if (!groups.has(key)) groups.set(key, { fxVal, fyVal, items: [] });
203
+ groups.get(key)!.items.push(d);
204
+ }
205
+
206
+ // Phase 1: build a density estimator per group, record max density.
207
+ type GroupEntry = {
208
+ fxVal: RawValue;
209
+ fyVal: RawValue;
210
+ /** function returned by kde.contours(data); call it with a threshold */
211
+ contourFn: (t: number) => DensityGeometry;
212
+ maxVal: number;
213
+ };
214
+ const groupEntries: GroupEntry[] = [];
215
+ let globalMax = 0;
216
+
217
+ for (const { fxVal, fyVal, items } of groups.values()) {
218
+ if (!items.length) continue;
219
+
220
+ const kde = contourDensity<any>()
221
+ .x((d) => (xFn(resolveAcc(xAcc, d)) as number) - bx1)
222
+ .y((d) => (yFn(resolveAcc(yAcc, d)) as number) - by1)
223
+ .weight(weightAcc != null ? (d) => +(resolveAcc(weightAcc, d) ?? 1) : () => 1)
224
+ .size([w, h])
225
+ .bandwidth(bandwidth);
226
+
227
+ // .contours() is in d3-contour v4 but not typed in @types/d3-contour
228
+ const contourFn = (kde as any).contours(items) as {
229
+ (t: number): DensityGeometry;
230
+ max: number;
231
+ };
232
+ const maxVal = contourFn.max;
233
+ if (maxVal > globalMax) globalMax = maxVal;
234
+ groupEntries.push({ fxVal, fyVal, contourFn, maxVal });
235
+ }
236
+
237
+ if (!groupEntries.length || globalMax === 0) return null;
238
+
239
+ // Phase 2: derive thresholds from global max density.
240
+ let T: number[];
241
+ if (Array.isArray(thresholdSpec)) {
242
+ T = thresholdSpec as number[];
243
+ } else {
244
+ const n = thresholdSpec as number;
245
+ // Generate n-1 levels evenly spaced over (0, globalMax × k]
246
+ T = Array.from({ length: n - 1 }, (_, i) => (globalMax * k * (i + 1)) / n);
247
+ }
248
+ if (!T.length) return null;
249
+
250
+ // Phase 3: compute contour geometries for each group at each threshold.
251
+ const allGeoms: DensityGeometry[] = [];
252
+
253
+ for (const { fxVal, fyVal, contourFn } of groupEntries) {
254
+ for (const t of T) {
255
+ // contourFn expects actual density values; t is k-scaled.
256
+ const geom = contourFn(t / k);
257
+
258
+ // Translate from facet-local [0, w]×[0, h] to SVG coordinates.
259
+ for (const rings of geom.coordinates) {
260
+ for (const ring of rings) {
261
+ for (const point of ring) {
262
+ point[0] += bx1;
263
+ point[1] += by1;
264
+ }
265
+ }
266
+ }
267
+
268
+ geom.value = t; // k-scaled, used for color mapping
269
+ if (isFaceted && fxVal !== undefined) geom.fxVal = fxVal;
270
+ if (isFaceted && fyVal !== undefined) geom.fyVal = fyVal;
271
+ allGeoms.push(geom);
272
+ }
273
+ }
274
+
275
+ // Anchor the color scale at zero so the first density band is
276
+ // distinguishable from the background. One anchor per facet group so
277
+ // no record ever has an undefined fx/fy value, which would otherwise
278
+ // introduce a spurious null facet panel.
279
+ if (markUsesColorScale) {
280
+ for (const { fxVal, fyVal } of groupEntries) {
281
+ const anchor: DensityGeometry = {
282
+ type: 'MultiPolygon',
283
+ coordinates: [],
284
+ value: 0
285
+ };
286
+ if (isFaceted && fxVal !== undefined) anchor.fxVal = fxVal;
287
+ if (isFaceted && fyVal !== undefined) anchor.fyVal = fyVal;
288
+ allGeoms.push(anchor);
289
+ }
290
+ }
291
+
292
+ return allGeoms.length > 0 ? allGeoms : null;
293
+ });
294
+
295
+ /**
296
+ * Data-space extent used to bootstrap the x/y scale domains before
297
+ * densityResult is available (density computation needs the scale fns,
298
+ * which need the domain — this breaks the circular dependency).
299
+ */
300
+ const extent = $derived.by(() => {
301
+ if (!data?.length) return null;
302
+ let xMin = Infinity,
303
+ xMax = -Infinity,
304
+ yMin = Infinity,
305
+ yMax = -Infinity;
306
+ let xUsesDate = false,
307
+ yUsesDate = false;
308
+ for (const d of data as any[]) {
309
+ const xv = resolveAcc(xAcc, d);
310
+ const yv = resolveAcc(yAcc, d);
311
+ if (xv instanceof Date) {
312
+ xUsesDate = true;
313
+ const ms = xv.getTime();
314
+ if (isFinite(ms)) {
315
+ if (ms < xMin) xMin = ms;
316
+ if (ms > xMax) xMax = ms;
317
+ }
318
+ } else if (typeof xv === 'number' && isFinite(xv)) {
319
+ if (xv < xMin) xMin = xv;
320
+ if (xv > xMax) xMax = xv;
321
+ }
322
+ if (yv instanceof Date) {
323
+ yUsesDate = true;
324
+ const ms = yv.getTime();
325
+ if (isFinite(ms)) {
326
+ if (ms < yMin) yMin = ms;
327
+ if (ms > yMax) yMax = ms;
328
+ }
329
+ } else if (typeof yv === 'number' && isFinite(yv)) {
330
+ if (yv < yMin) yMin = yv;
331
+ if (yv > yMax) yMax = yv;
332
+ }
333
+ }
334
+ if (!isFinite(xMin) || !isFinite(xMax) || !isFinite(yMin) || !isFinite(yMax)) return null;
335
+ return {
336
+ x1: xUsesDate ? new Date(xMin) : xMin,
337
+ x2: xUsesDate ? new Date(xMax) : xMax,
338
+ y1: yUsesDate ? new Date(yMin) : yMin,
339
+ y2: yUsesDate ? new Date(yMax) : yMax
340
+ };
341
+ });
342
+
343
+ /**
344
+ * Unified mark data passed to <Mark> for scale-domain registration and
345
+ * facet filtering.
346
+ *
347
+ * A bootstrap extent record is always included before densityResult is
348
+ * computed (scatter-style circular dependency with x/y scales). Each
349
+ * per-band fake datum carries:
350
+ * [X1_VAL]..[Y2_VAL] data-space extent → x/y scale domain
351
+ * [RAW_VALUE] k-scaled density → color scale domain
352
+ * [FX_VAL]/[FY_VAL] facet values → Mark facet filtering
353
+ * [GEOM] geometry → rendered by children snippet
354
+ */
355
+ const markData = $derived.by((): DataRecord[] => {
356
+ const ext = extent;
357
+ const records: any[] = [];
358
+ // Bootstrap extent record(s) so x/y scales are available for density
359
+ // computation on the first render pass. When faceted, emit one record
360
+ // per unique (fxVal, fyVal) combination so no record carries an
361
+ // undefined fx/fy value (which would create a spurious null facet).
362
+ if (ext && !densityResult) {
363
+ const isFaceted = fxAcc != null || fyAcc != null;
364
+ if (isFaceted && data?.length) {
365
+ const seen = new SvelteSet<string>();
366
+ for (const d of data as any[]) {
367
+ const fxVal = resolveAcc(fxAcc, d);
368
+ const fyVal = resolveAcc(fyAcc, d);
369
+ const key = `${fxVal}\0${fyVal}`;
370
+ if (!seen.has(key)) {
371
+ seen.add(key);
372
+ records.push({
373
+ [X1_VAL]: ext.x1,
374
+ [X2_VAL]: ext.x2,
375
+ [Y1_VAL]: ext.y1,
376
+ [Y2_VAL]: ext.y2,
377
+ [FX_VAL]: fxVal,
378
+ [FY_VAL]: fyVal
379
+ });
380
+ }
381
+ }
382
+ } else {
383
+ records.push({
384
+ [X1_VAL]: ext.x1,
385
+ [X2_VAL]: ext.x2,
386
+ [Y1_VAL]: ext.y1,
387
+ [Y2_VAL]: ext.y2
388
+ });
389
+ }
390
+ }
391
+
392
+ if (densityResult) {
393
+ for (const geom of densityResult) {
394
+ records.push({
395
+ [X1_VAL]: ext?.x1,
396
+ [X2_VAL]: ext?.x2,
397
+ [Y1_VAL]: ext?.y1,
398
+ [Y2_VAL]: ext?.y2,
399
+ [RAW_VALUE]: geom.value,
400
+ [FX_VAL]: geom.fxVal,
401
+ [FY_VAL]: geom.fyVal,
402
+ [GEOM]: geom
403
+ });
404
+ }
405
+ }
406
+
407
+ return records as DataRecord[];
408
+ });
409
+
410
+ const markChannels = $derived(
411
+ markUsesColorScale
412
+ ? (['x1', 'x2', 'y1', 'y2', 'fill'] as const)
413
+ : (['x1', 'x2', 'y1', 'y2'] as const)
414
+ );
415
+
416
+ const markFill = $derived(markUsesColorScale ? (RAW_VALUE as any) : undefined);
417
+ const markFx = $derived(fxAcc != null ? FX_VAL : undefined);
418
+ const markFy = $derived(fyAcc != null ? FY_VAL : undefined);
419
+
420
+ const markChannelProps = $derived({
421
+ x1: X1_VAL as any,
422
+ x2: X2_VAL as any,
423
+ y1: Y1_VAL as any,
424
+ y2: Y2_VAL as any,
425
+ fill: markFill as any,
426
+ fx: markFx as any,
427
+ fy: markFy as any,
428
+ ...(typeof xAcc === 'string' && { [ORIGINAL_NAME_KEYS.x]: xAcc }),
429
+ ...(typeof yAcc === 'string' && { [ORIGINAL_NAME_KEYS.y]: yAcc }),
430
+ ...(markUsesColorScale && { [ORIGINAL_NAME_KEYS.fill]: 'Density' })
431
+ });
432
+
433
+ const path = geoPath();
434
+ </script>
435
+
436
+ <Mark
437
+ type={'density' as MarkType}
438
+ data={markData}
439
+ channels={markChannels as any}
440
+ {...options}
441
+ {...markChannelProps}>
442
+ {#snippet children({ scaledData }: { scaledData: ScaledDataRecord[] })}
443
+ <GeoPathGroup
444
+ {scaledData}
445
+ {path}
446
+ geomKey={GEOM}
447
+ colorKeyword="density"
448
+ {fill}
449
+ stroke={effectiveStroke}
450
+ {strokeWidth}
451
+ {strokeOpacity}
452
+ {fillOpacity}
453
+ {opacity}
454
+ {strokeMiterlimit}
455
+ {clipPath}
456
+ className={className || undefined}
457
+ ariaLabel="density"
458
+ {canvas}
459
+ {plot} />
460
+ {/snippet}
461
+ </Mark>