svelteplot 0.14.0 → 0.14.1-pr-549.1

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.
@@ -12,6 +12,12 @@
12
12
  density through the plot's color scale. Defaults: `fill="none"`,
13
13
  `stroke="currentColor"`.
14
14
 
15
+ Grouping: when a `z` channel is specified (or when `fill`/`stroke` is a
16
+ field name or function rather than a CSS color), the mark computes a
17
+ separate density estimate per group and renders each group independently.
18
+ Using `fill` or `stroke` as a data accessor also maps the group value
19
+ through the plot's color scale.
20
+
15
21
  Supports faceting via `fx`/`fy`.
16
22
  -->
17
23
  <script lang="ts" generics="Datum extends DataRecord">
@@ -24,6 +30,13 @@
24
30
  y?: ChannelAccessor<Datum>;
25
31
  /** Optional weight channel; defaults to 1 for each point. */
26
32
  weight?: ChannelAccessor<Datum>;
33
+ /**
34
+ * Grouping channel. When specified the mark computes a separate
35
+ * density estimate per unique z value and renders the groups
36
+ * independently. Unlike `fill`/`stroke` as accessors, `z` does not
37
+ * automatically add a color channel.
38
+ */
39
+ z?: ChannelAccessor<Datum>;
27
40
  /**
28
41
  * Gaussian kernel bandwidth in screen pixels (default 20).
29
42
  * Larger values produce smoother, more blurred density estimates.
@@ -40,14 +53,22 @@
40
53
  /**
41
54
  * Fill color for density bands. Use `"density"` to map each band's
42
55
  * estimated density through the plot's color scale. Default `"none"`.
56
+ *
57
+ * When a field name or accessor function is provided (instead of a CSS
58
+ * color), the mark groups by the fill channel and maps group values
59
+ * through the plot's fill color scale.
43
60
  */
44
- fill?: string;
61
+ fill?: ChannelAccessor<Datum> | string;
45
62
  /**
46
63
  * Stroke color for density isolines. Use `"density"` to map each
47
64
  * isoline's estimated density through the plot's color scale.
48
65
  * Default `"currentColor"` when fill is `"none"`, otherwise `"none"`.
66
+ *
67
+ * When a field name or accessor function is provided (instead of a CSS
68
+ * color), the mark groups by the stroke channel and maps group values
69
+ * through the plot's stroke color scale.
49
70
  */
50
- stroke?: string;
71
+ stroke?: ChannelAccessor<Datum> | string;
51
72
  strokeWidth?: number;
52
73
  strokeOpacity?: number;
53
74
  fillOpacity?: number;
@@ -79,6 +100,7 @@
79
100
  import { RAW_VALUE } from '../transforms/recordize.js';
80
101
  import { ORIGINAL_NAME_KEYS } from '../constants.js';
81
102
  import { getPlotDefaults } from '../hooks/plotDefaults.js';
103
+ import { isColorOrNull } from '../helpers/typeChecks.js';
82
104
 
83
105
  // Per-band fake-datum symbols — used to attach the density geometry,
84
106
  // extent, and pre-resolved facet values to the synthetic records passed
@@ -86,6 +108,7 @@
86
108
  const GEOM = Symbol('density_geom');
87
109
  const FX_VAL = Symbol('density_fx');
88
110
  const FY_VAL = Symbol('density_fy');
111
+ const Z_VAL = Symbol('density_z');
89
112
  const X1_VAL = Symbol('density_x1');
90
113
  const X2_VAL = Symbol('density_x2');
91
114
  const Y1_VAL = Symbol('density_y1');
@@ -108,10 +131,11 @@
108
131
  data,
109
132
  x: xAcc,
110
133
  y: yAcc,
134
+ z: zAcc,
111
135
  weight: weightAcc,
112
136
  bandwidth = 20,
113
137
  thresholds: thresholdSpec = 20,
114
- fill = 'none',
138
+ fill: rawFill = 'none',
115
139
  stroke: rawStroke,
116
140
  strokeWidth,
117
141
  strokeOpacity,
@@ -128,13 +152,61 @@
128
152
 
129
153
  const plot = usePlot();
130
154
 
155
+ /**
156
+ * Returns true when a fill/stroke value should be treated as a data
157
+ * accessor (field name or function) rather than a constant color.
158
+ */
159
+ function isDensityAccessor(v: ChannelAccessor<Datum> | string | undefined): boolean {
160
+ if (v == null) return false;
161
+ if (typeof v === 'function') return true;
162
+ // Descriptor form { value, scale } — recurse into the inner value.
163
+ if (typeof v === 'object' && 'value' in (v as object))
164
+ return isDensityAccessor((v as { value: any }).value);
165
+ if (typeof v !== 'string') return false;
166
+ const lower = v.toLowerCase();
167
+ if (
168
+ lower === 'none' ||
169
+ lower === 'density' ||
170
+ lower === 'inherit' ||
171
+ lower === 'currentcolor'
172
+ )
173
+ return false;
174
+ return !isColorOrNull(v);
175
+ }
176
+
177
+ // Whether fill/stroke are data accessors (trigger z-grouping + color scale)
178
+ const fillIsAccessor = $derived(isDensityAccessor(rawFill));
179
+ const strokeIsAccessor = $derived(isDensityAccessor(rawStroke));
180
+
181
+ // Effective grouping accessor: explicit z > fill (if accessor) > stroke (if accessor)
182
+ const zGroupAcc = $derived<ChannelAccessor<Datum> | null>(
183
+ zAcc != null
184
+ ? zAcc
185
+ : fillIsAccessor
186
+ ? (rawFill as ChannelAccessor<Datum>)
187
+ : strokeIsAccessor
188
+ ? (rawStroke as ChannelAccessor<Datum>)
189
+ : null
190
+ );
191
+ const isZGrouped = $derived(zGroupAcc != null);
192
+
193
+ // Resolved static fill/stroke strings (after extracting accessor info)
194
+ const fill = $derived<string>(fillIsAccessor ? 'none' : ((rawFill as string) ?? 'none'));
195
+
131
196
  const fillDensity = $derived(/^density$/i.test(fill ?? ''));
132
197
 
133
198
  /**
134
199
  * When fill is active (not "none" or "density"), stroke defaults to "none";
135
200
  * otherwise it defaults to "currentColor".
201
+ * fillIsAccessor also counts as "fill active" even though the static fill
202
+ * prop is forced to "none" for GeoPathGroup — per-path fills flow via d.fill.
136
203
  */
137
- const effectiveStroke = $derived(rawStroke ?? (fill !== 'none' ? 'none' : 'currentColor'));
204
+ const effectiveStroke = $derived<string>(
205
+ strokeIsAccessor
206
+ ? 'currentColor'
207
+ : ((rawStroke as string | undefined) ??
208
+ (fill !== 'none' || fillIsAccessor ? 'none' : 'currentColor'))
209
+ );
138
210
 
139
211
  const strokeDensity = $derived(/^density$/i.test(effectiveStroke ?? ''));
140
212
 
@@ -145,9 +217,12 @@
145
217
  const markUsesColorScale = $derived(fillDensity || strokeDensity);
146
218
 
147
219
  /** Resolve a channel accessor against a single datum. */
148
- function resolveAcc(acc: ChannelAccessor<any> | undefined, d: any): any {
220
+ function resolveAcc(acc: ChannelAccessor<any> | undefined | null, d: any): any {
149
221
  if (acc == null) return undefined;
150
222
  if (typeof acc === 'function') return (acc as (d: any) => any)(d);
223
+ // Descriptor form { value, scale } — resolve the inner value.
224
+ if (typeof acc === 'object' && 'value' in (acc as object))
225
+ return resolveAcc((acc as { value: any }).value, d);
151
226
  return (d as any)[acc as string];
152
227
  }
153
228
 
@@ -174,6 +249,8 @@
174
249
  fxVal?: RawValue;
175
250
  /** pre-resolved fy channel value for facet filtering */
176
251
  fyVal?: RawValue;
252
+ /** pre-resolved z group value for per-group coloring */
253
+ zVal?: RawValue;
177
254
  };
178
255
 
179
256
  /**
@@ -183,7 +260,8 @@
183
260
  * Phase 2: derive thresholds from global max (for consistent color scale).
184
261
  * Phase 3: compute contour geometries at those thresholds.
185
262
  *
186
- * Geometries are tagged with fxVal/fyVal so <Mark> can filter per panel.
263
+ * Geometries are tagged with fxVal/fyVal/zVal so <Mark> can filter per
264
+ * panel and apply per-group colors.
187
265
  */
188
266
  const densityResult = $derived.by((): DensityGeometry[] | null => {
189
267
  const xFn = plot.scales.x?.fn;
@@ -193,13 +271,17 @@
193
271
  const { bx1, by1, w, h } = getBounds();
194
272
  const isFaceted = fxAcc != null || fyAcc != null;
195
273
 
196
- // Group data by (fxVal, fyVal) — a single group when not faceted.
197
- const groups = new SvelteMap<string, { fxVal: RawValue; fyVal: RawValue; items: any[] }>();
274
+ // Group data by (fxVal, fyVal, zVal) — a single group when not faceted/z-grouped.
275
+ const groups = new SvelteMap<
276
+ string,
277
+ { fxVal: RawValue; fyVal: RawValue; zVal: RawValue; items: any[] }
278
+ >();
198
279
  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: [] });
280
+ const fxVal = fxAcc != null ? resolveAcc(fxAcc, d) : undefined;
281
+ const fyVal = fyAcc != null ? resolveAcc(fyAcc, d) : undefined;
282
+ const zVal = isZGrouped ? resolveAcc(zGroupAcc, d) : undefined;
283
+ const key = `${fxVal}\0${fyVal}\0${zVal}`;
284
+ if (!groups.has(key)) groups.set(key, { fxVal, fyVal, zVal, items: [] });
203
285
  groups.get(key)!.items.push(d);
204
286
  }
205
287
 
@@ -207,6 +289,7 @@
207
289
  type GroupEntry = {
208
290
  fxVal: RawValue;
209
291
  fyVal: RawValue;
292
+ zVal: RawValue;
210
293
  /** function returned by kde.contours(data); call it with a threshold */
211
294
  contourFn: (t: number) => DensityGeometry;
212
295
  maxVal: number;
@@ -214,7 +297,7 @@
214
297
  const groupEntries: GroupEntry[] = [];
215
298
  let globalMax = 0;
216
299
 
217
- for (const { fxVal, fyVal, items } of groups.values()) {
300
+ for (const { fxVal, fyVal, zVal, items } of groups.values()) {
218
301
  if (!items.length) continue;
219
302
 
220
303
  const kde = contourDensity<any>()
@@ -231,7 +314,7 @@
231
314
  };
232
315
  const maxVal = contourFn.max;
233
316
  if (maxVal > globalMax) globalMax = maxVal;
234
- groupEntries.push({ fxVal, fyVal, contourFn, maxVal });
317
+ groupEntries.push({ fxVal, fyVal, zVal, contourFn, maxVal });
235
318
  }
236
319
 
237
320
  if (!groupEntries.length || globalMax === 0) return null;
@@ -250,7 +333,7 @@
250
333
  // Phase 3: compute contour geometries for each group at each threshold.
251
334
  const allGeoms: DensityGeometry[] = [];
252
335
 
253
- for (const { fxVal, fyVal, contourFn } of groupEntries) {
336
+ for (const { fxVal, fyVal, zVal, contourFn } of groupEntries) {
254
337
  for (const t of T) {
255
338
  // contourFn expects actual density values; t is k-scaled.
256
339
  const geom = contourFn(t / k);
@@ -268,6 +351,7 @@
268
351
  geom.value = t; // k-scaled, used for color mapping
269
352
  if (isFaceted && fxVal !== undefined) geom.fxVal = fxVal;
270
353
  if (isFaceted && fyVal !== undefined) geom.fyVal = fyVal;
354
+ if (isZGrouped && zVal !== undefined) geom.zVal = zVal;
271
355
  allGeoms.push(geom);
272
356
  }
273
357
  }
@@ -277,7 +361,7 @@
277
361
  // no record ever has an undefined fx/fy value, which would otherwise
278
362
  // introduce a spurious null facet panel.
279
363
  if (markUsesColorScale) {
280
- for (const { fxVal, fyVal } of groupEntries) {
364
+ for (const { fxVal, fyVal, zVal } of groupEntries) {
281
365
  const anchor: DensityGeometry = {
282
366
  type: 'MultiPolygon',
283
367
  coordinates: [],
@@ -285,6 +369,7 @@
285
369
  };
286
370
  if (isFaceted && fxVal !== undefined) anchor.fxVal = fxVal;
287
371
  if (isFaceted && fyVal !== undefined) anchor.fyVal = fyVal;
372
+ if (isZGrouped && zVal !== undefined) anchor.zVal = zVal;
288
373
  allGeoms.push(anchor);
289
374
  }
290
375
  }
@@ -348,7 +433,8 @@
348
433
  * computed (scatter-style circular dependency with x/y scales). Each
349
434
  * per-band fake datum carries:
350
435
  * [X1_VAL]..[Y2_VAL] data-space extent → x/y scale domain
351
- * [RAW_VALUE] k-scaled density → color scale domain
436
+ * [RAW_VALUE] k-scaled density → color scale domain (density mode)
437
+ * [Z_VAL] z group value → fill/stroke scale domain (z-group mode)
352
438
  * [FX_VAL]/[FY_VAL] facet values → Mark facet filtering
353
439
  * [GEOM] geometry → rendered by children snippet
354
440
  */
@@ -356,17 +442,18 @@
356
442
  const ext = extent;
357
443
  const records: any[] = [];
358
444
  // 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).
445
+ // computation on the first render pass. When faceted or z-grouped, emit
446
+ // one record per unique (fxVal, fyVal, zVal) combination so no record
447
+ // carries an undefined fx/fy value (which would create a spurious null facet).
362
448
  if (ext && !densityResult) {
363
449
  const isFaceted = fxAcc != null || fyAcc != null;
364
- if (isFaceted && data?.length) {
450
+ if ((isFaceted || isZGrouped) && data?.length) {
365
451
  const seen = new SvelteSet<string>();
366
452
  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}`;
453
+ const fxVal = fxAcc != null ? resolveAcc(fxAcc, d) : undefined;
454
+ const fyVal = fyAcc != null ? resolveAcc(fyAcc, d) : undefined;
455
+ const zVal = isZGrouped ? resolveAcc(zGroupAcc, d) : undefined;
456
+ const key = `${fxVal}\0${fyVal}\0${zVal}`;
370
457
  if (!seen.has(key)) {
371
458
  seen.add(key);
372
459
  records.push({
@@ -375,7 +462,8 @@
375
462
  [Y1_VAL]: ext.y1,
376
463
  [Y2_VAL]: ext.y2,
377
464
  [FX_VAL]: fxVal,
378
- [FY_VAL]: fyVal
465
+ [FY_VAL]: fyVal,
466
+ [Z_VAL]: zVal
379
467
  });
380
468
  }
381
469
  }
@@ -399,6 +487,7 @@
399
487
  [RAW_VALUE]: geom.value,
400
488
  [FX_VAL]: geom.fxVal,
401
489
  [FY_VAL]: geom.fyVal,
490
+ [Z_VAL]: geom.zVal,
402
491
  [GEOM]: geom
403
492
  });
404
493
  }
@@ -407,13 +496,24 @@
407
496
  return records as DataRecord[];
408
497
  });
409
498
 
410
- const markChannels = $derived(
411
- markUsesColorScale
412
- ? (['x1', 'x2', 'y1', 'y2', 'fill'] as const)
413
- : (['x1', 'x2', 'y1', 'y2'] as const)
499
+ const markChannels = $derived.by(() => {
500
+ const base = ['x1', 'x2', 'y1', 'y2'] as const;
501
+ if (markUsesColorScale || fillIsAccessor) return [...base, 'fill'] as const;
502
+ if (strokeIsAccessor) return [...base, 'stroke'] as const;
503
+ return base;
504
+ });
505
+
506
+ // fill channel accessor:
507
+ // density color scale (fill or stroke="density") → RAW_VALUE (registers domain)
508
+ // fill accessor (z-grouping) → Z_VAL
509
+ // else → undefined
510
+ const markFill = $derived(
511
+ markUsesColorScale ? (RAW_VALUE as any) : fillIsAccessor ? (Z_VAL as any) : undefined
414
512
  );
415
513
 
416
- const markFill = $derived(markUsesColorScale ? (RAW_VALUE as any) : undefined);
514
+ // stroke channel accessor: only set when stroke is a z-group accessor
515
+ const markStroke = $derived(strokeIsAccessor ? (Z_VAL as any) : undefined);
516
+
417
517
  const markFx = $derived(fxAcc != null ? FX_VAL : undefined);
418
518
  const markFy = $derived(fyAcc != null ? FY_VAL : undefined);
419
519
 
@@ -423,11 +523,16 @@
423
523
  y1: Y1_VAL as any,
424
524
  y2: Y2_VAL as any,
425
525
  fill: markFill as any,
526
+ stroke: markStroke as any,
426
527
  fx: markFx as any,
427
528
  fy: markFy as any,
428
529
  ...(typeof xAcc === 'string' && { [ORIGINAL_NAME_KEYS.x]: xAcc }),
429
530
  ...(typeof yAcc === 'string' && { [ORIGINAL_NAME_KEYS.y]: yAcc }),
430
- ...(markUsesColorScale && { [ORIGINAL_NAME_KEYS.fill]: 'Density' })
531
+ ...(markUsesColorScale && { [ORIGINAL_NAME_KEYS.fill]: 'Density' }),
532
+ ...(fillIsAccessor &&
533
+ typeof rawFill === 'string' && { [ORIGINAL_NAME_KEYS.fill]: rawFill }),
534
+ ...(strokeIsAccessor &&
535
+ typeof rawStroke === 'string' && { [ORIGINAL_NAME_KEYS.stroke]: rawStroke })
431
536
  });
432
537
 
433
538
  const path = geoPath();
@@ -456,6 +561,8 @@
456
561
  className={className || undefined}
457
562
  ariaLabel="density"
458
563
  {canvas}
459
- {plot} />
564
+ {plot}
565
+ usePerPathFill={fillIsAccessor}
566
+ usePerPathStroke={strokeIsAccessor} />
460
567
  {/snippet}
461
568
  </Mark>
@@ -9,6 +9,13 @@ declare function $$render<Datum extends DataRecord>(): {
9
9
  y?: ChannelAccessor<Datum>;
10
10
  /** Optional weight channel; defaults to 1 for each point. */
11
11
  weight?: ChannelAccessor<Datum>;
12
+ /**
13
+ * Grouping channel. When specified the mark computes a separate
14
+ * density estimate per unique z value and renders the groups
15
+ * independently. Unlike `fill`/`stroke` as accessors, `z` does not
16
+ * automatically add a color channel.
17
+ */
18
+ z?: ChannelAccessor<Datum>;
12
19
  /**
13
20
  * Gaussian kernel bandwidth in screen pixels (default 20).
14
21
  * Larger values produce smoother, more blurred density estimates.
@@ -25,14 +32,22 @@ declare function $$render<Datum extends DataRecord>(): {
25
32
  /**
26
33
  * Fill color for density bands. Use `"density"` to map each band's
27
34
  * estimated density through the plot's color scale. Default `"none"`.
35
+ *
36
+ * When a field name or accessor function is provided (instead of a CSS
37
+ * color), the mark groups by the fill channel and maps group values
38
+ * through the plot's fill color scale.
28
39
  */
29
- fill?: string;
40
+ fill?: ChannelAccessor<Datum> | string;
30
41
  /**
31
42
  * Stroke color for density isolines. Use `"density"` to map each
32
43
  * isoline's estimated density through the plot's color scale.
33
44
  * Default `"currentColor"` when fill is `"none"`, otherwise `"none"`.
45
+ *
46
+ * When a field name or accessor function is provided (instead of a CSS
47
+ * color), the mark groups by the stroke channel and maps group values
48
+ * through the plot's stroke color scale.
34
49
  */
35
- stroke?: string;
50
+ stroke?: ChannelAccessor<Datum> | string;
36
51
  strokeWidth?: number;
37
52
  strokeOpacity?: number;
38
53
  fillOpacity?: number;
@@ -80,6 +95,12 @@ interface $$IsomorphicComponent {
80
95
  * density through the plot's color scale. Defaults: `fill="none"`,
81
96
  * `stroke="currentColor"`.
82
97
  *
98
+ * Grouping: when a `z` channel is specified (or when `fill`/`stroke` is a
99
+ * field name or function rather than a CSS color), the mark computes a
100
+ * separate density estimate per group and renders each group independently.
101
+ * Using `fill` or `stroke` as a data accessor also maps the group value
102
+ * through the plot's color scale.
103
+ *
83
104
  * Supports faceting via `fx`/`fy`.
84
105
  */
85
106
  declare const Density: $$IsomorphicComponent;
@@ -184,7 +184,7 @@
184
184
  {:else}
185
185
  <g class={['link', className]} data-use-x={usedScales.x ? 1 : 0}>
186
186
  {#each scaledData as d, i (i)}
187
- {#if d.valid || true}
187
+ {#if d.valid}
188
188
  {@const [style, styleClass] = resolveStyles(
189
189
  plot,
190
190
  d,
@@ -34,7 +34,7 @@
34
34
  );
35
35
  </script>
36
36
 
37
- <Mark type="regression">
37
+ <Mark type="regression" {data} fx={options.fx} fy={options.fy}>
38
38
  {#each groups as group, g (g)}
39
39
  <Regression data={group as any} dependent="x" {...options as any} />
40
40
  {/each}
@@ -33,7 +33,7 @@
33
33
  );
34
34
  </script>
35
35
 
36
- <Mark type="regression">
36
+ <Mark type="regression" {data} fx={options.fx} fy={options.fy}>
37
37
  {#each groups as group, i (i)}
38
38
  <Regression data={group as any} dependent="y" {...options as any} />
39
39
  {/each}
@@ -19,7 +19,9 @@
19
19
  strokeOpacity,
20
20
  fillOpacity,
21
21
  opacity,
22
- strokeMiterlimit
22
+ strokeMiterlimit,
23
+ usePerPathFill = false,
24
+ usePerPathStroke = false
23
25
  }: {
24
26
  scaledData: ScaledDataRecord[];
25
27
  path: GeoPath;
@@ -38,6 +40,8 @@
38
40
  fillOpacity?: number;
39
41
  opacity?: number;
40
42
  strokeMiterlimit?: number;
43
+ usePerPathFill?: boolean;
44
+ usePerPathStroke?: boolean;
41
45
  } = $props();
42
46
 
43
47
  const plot = usePlot();
@@ -86,8 +90,16 @@
86
90
  if (!geom?.coordinates?.length) continue;
87
91
 
88
92
  const thresholdValue = (d.datum[RAW_VALUE as any] as number) ?? 0;
89
- const fillColor = resolveCanvasColor(resolveColorProp(fill, thresholdValue));
90
- const strokeColor = resolveCanvasColor(resolveColorProp(stroke, thresholdValue));
93
+ const fillColor = resolveCanvasColor(
94
+ usePerPathFill && d.fill
95
+ ? (d.fill as string)
96
+ : resolveColorProp(fill, thresholdValue)
97
+ );
98
+ const strokeColor = resolveCanvasColor(
99
+ usePerPathStroke && d.stroke
100
+ ? (d.stroke as string)
101
+ : resolveColorProp(stroke, thresholdValue)
102
+ );
91
103
 
92
104
  context.beginPath();
93
105
  path(geom);
@@ -18,6 +18,8 @@ type $$ComponentProps = {
18
18
  fillOpacity?: number;
19
19
  opacity?: number;
20
20
  strokeMiterlimit?: number;
21
+ usePerPathFill?: boolean;
22
+ usePerPathStroke?: boolean;
21
23
  };
22
24
  declare const GeoPathCanvas: import("svelte").Component<$$ComponentProps, {}, "">;
23
25
  type GeoPathCanvas = ReturnType<typeof GeoPathCanvas>;
@@ -26,7 +26,9 @@
26
26
  className,
27
27
  ariaLabel,
28
28
  canvas = false,
29
- plot
29
+ plot,
30
+ usePerPathFill = false,
31
+ usePerPathStroke = false
30
32
  }: {
31
33
  scaledData: ScaledDataRecord[];
32
34
  /** d3 geoPath renderer (must NOT have a canvas context set). */
@@ -52,6 +54,17 @@
52
54
  /** Render using a canvas element instead of SVG paths. */
53
55
  canvas?: boolean;
54
56
  plot: PlotState;
57
+ /**
58
+ * When true, per-path fill color is read from `d.fill` in scaledData
59
+ * (z-group coloring). Must be set explicitly; avoids accidental fill
60
+ * when fill channel is only registered for color-scale domain purposes.
61
+ */
62
+ usePerPathFill?: boolean;
63
+ /**
64
+ * When true, per-path stroke color is read from `d.stroke` in scaledData
65
+ * (z-group coloring).
66
+ */
67
+ usePerPathStroke?: boolean;
55
68
  } = $props();
56
69
 
57
70
  /** Resolve a fill/stroke prop that may be the colorKeyword. */
@@ -63,10 +76,14 @@
63
76
  }
64
77
 
65
78
  /** Build the inline style string for a single contour/density path. */
66
- function buildStyle(value: number): string {
79
+ function buildStyle(d: ScaledDataRecord, value: number): string {
67
80
  const parts: string[] = [];
68
- parts.push(`fill:${resolveColor(fill, value)}`);
69
- parts.push(`stroke:${resolveColor(stroke, value)}`);
81
+ parts.push(
82
+ `fill:${usePerPathFill && d.fill ? (d.fill as string) : resolveColor(fill, value)}`
83
+ );
84
+ parts.push(
85
+ `stroke:${usePerPathStroke && d.stroke ? (d.stroke as string) : resolveColor(stroke, value)}`
86
+ );
70
87
  if (strokeWidth != null) parts.push(`stroke-width:${strokeWidth}`);
71
88
  if (strokeOpacity != null) parts.push(`stroke-opacity:${strokeOpacity}`);
72
89
  if (fillOpacity != null) parts.push(`fill-opacity:${fillOpacity}`);
@@ -88,7 +105,9 @@
88
105
  {strokeOpacity}
89
106
  {fillOpacity}
90
107
  {opacity}
91
- {strokeMiterlimit} />
108
+ {strokeMiterlimit}
109
+ {usePerPathFill}
110
+ {usePerPathStroke} />
92
111
  {:else}
93
112
  <g clip-path={clipPath} class={className || null} aria-label={ariaLabel}>
94
113
  {#each scaledData as d, i (i)}
@@ -96,7 +115,7 @@
96
115
  {#if geom?.coordinates?.length}
97
116
  <path
98
117
  d={path(geom)}
99
- style={buildStyle((d.datum[RAW_VALUE as any] as number) ?? 0)} />
118
+ style={buildStyle(d, (d.datum[RAW_VALUE as any] as number) ?? 0)} />
100
119
  {/if}
101
120
  {/each}
102
121
  </g>
@@ -25,6 +25,17 @@ type $$ComponentProps = {
25
25
  /** Render using a canvas element instead of SVG paths. */
26
26
  canvas?: boolean;
27
27
  plot: PlotState;
28
+ /**
29
+ * When true, per-path fill color is read from `d.fill` in scaledData
30
+ * (z-group coloring). Must be set explicitly; avoids accidental fill
31
+ * when fill channel is only registered for color-scale domain purposes.
32
+ */
33
+ usePerPathFill?: boolean;
34
+ /**
35
+ * When true, per-path stroke color is read from `d.stroke` in scaledData
36
+ * (z-group coloring).
37
+ */
38
+ usePerPathStroke?: boolean;
28
39
  };
29
40
  /**
30
41
  * Renders GeoJSON geometries as SVG <path> elements (or via canvas when
@@ -43,7 +43,6 @@
43
43
  regressionLoess
44
44
  } from '../../regression/index.js';
45
45
  import { resolveChannel } from '../../helpers/resolve.js';
46
- import { isTemporalScale } from '../../helpers/typeChecks.js';
47
46
  import { confidenceInterval } from '../../helpers/math.js';
48
47
  import callWithProps from '../../helpers/callWithProps.js';
49
48
  import type { DataRecord, FacetContext, RawValue } from '../../types/index.js';
@@ -93,8 +92,12 @@
93
92
  }
94
93
 
95
94
  // Convert generated points back to Date for time scales so downstream marks render correctly.
96
- function toOutputX(value: number, scaleType: string): RawValue {
97
- return isTemporalScale(scaleType) ? new Date(value) : value;
95
+ // Takes a boolean instead of the scale type to avoid a circular dependency: if we used
96
+ // plot.scales[independent].type, the Line mark would register numeric __x values during the
97
+ // first render pass (before the scale type is resolved), causing scale inference to see a mix
98
+ // of Dates and numbers and fall back to 'linear' permanently.
99
+ function toOutputX(value: number, isTemporal: boolean): RawValue {
100
+ return isTemporal ? new Date(value) : value;
98
101
  }
99
102
 
100
103
  function makeTicks(domain: [number, number], count = 40): number[] {
@@ -129,6 +132,16 @@
129
132
 
130
133
  const regressionFn = $derived(maybeRegression(type));
131
134
 
135
+ // Detect temporality from the raw data rather than from the computed scale type to avoid a
136
+ // circular dependency that would cause the scale type to be permanently inferred as 'linear'.
137
+ // Scan for the first row that resolves to a non-null independent value — checking only
138
+ // filteredData[0] would give false negatives when the leading row has a missing/null value.
139
+ const independentIsDate = $derived(
140
+ filteredData.some(
141
+ (d) => resolveChannel(independent, d, options as any) instanceof Date
142
+ )
143
+ );
144
+
132
145
  // Build a clean numeric input set for regression fitting, dropping invalid rows early.
133
146
  const regressionInput = $derived(
134
147
  filteredData
@@ -193,18 +206,18 @@
193
206
  // Prefer batch prediction when supported, then per-point predict, then raw curve output.
194
207
  if (typeof regression.predictMany === 'function') {
195
208
  return regression.predictMany(regrPoints).map((__y, i) => ({
196
- __x: toOutputX(regrPoints[i], plot.scales[independent].type),
209
+ __x: toOutputX(regrPoints[i], independentIsDate),
197
210
  __y
198
211
  }));
199
212
  }
200
213
  if (typeof regression.predict === 'function') {
201
214
  return regrPoints.map((point) => ({
202
- __x: toOutputX(point, plot.scales[independent].type),
215
+ __x: toOutputX(point, independentIsDate),
203
216
  __y: regression.predict!(point)
204
217
  }));
205
218
  }
206
219
  return regression.map(([__x, __y]) => ({
207
- __x: toOutputX(__x, plot.scales[independent].type),
220
+ __x: toOutputX(__x, independentIsDate),
208
221
  __y
209
222
  }));
210
223
  });
@@ -230,7 +243,7 @@
230
243
  return regrPoints.map((x) => {
231
244
  const { x: __x, left, right } = confBandGen(x);
232
245
  return {
233
- __x: toOutputX(__x, plot.scales[independent].type),
246
+ __x: toOutputX(__x, independentIsDate),
234
247
  __y1: left,
235
248
  __y2: right
236
249
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelteplot",
3
- "version": "0.14.0",
3
+ "version": "0.14.1-pr-549.1",
4
4
  "description": "A Svelte-native data visualization framework based on the layered grammar of graphics principles.",
5
5
  "keywords": [
6
6
  "svelte",
@@ -94,7 +94,7 @@
94
94
  "d3-scale-chromatic": "^3.1.0",
95
95
  "d3-shape": "^3.2.0",
96
96
  "d3-time": "^3.1.0",
97
- "es-toolkit": "^1.44.0",
97
+ "es-toolkit": "^1.45.1",
98
98
  "fast-equals": "^6.0.0",
99
99
  "interval-tree-1d": "^1.0.4",
100
100
  "merge-deep": "^3.0.3"
@@ -105,13 +105,13 @@
105
105
  "@shikijs/twoslash": "^3.22.0",
106
106
  "@sveltejs/adapter-auto": "^7.0.1",
107
107
  "@sveltejs/adapter-static": "^3.0.10",
108
- "@sveltejs/enhanced-img": "^0.10.2",
108
+ "@sveltejs/enhanced-img": "^0.10.4",
109
109
  "@sveltejs/eslint-config": "^8.3.5",
110
- "@sveltejs/kit": "^2.52.2",
110
+ "@sveltejs/kit": "^2.57.1",
111
111
  "@sveltejs/package": "^2.5.7",
112
112
  "@sveltejs/vite-plugin-svelte": "6.2.4",
113
- "@sveltepress/twoslash": "^1.3.6",
114
- "@sveltepress/vite": "^1.3.6",
113
+ "@sveltepress/twoslash": "^1.3.10",
114
+ "@sveltepress/vite": "^1.3.10",
115
115
  "@testing-library/svelte": "^5.3.1",
116
116
  "@testing-library/user-event": "^14.6.1",
117
117
  "@types/d3-array": "^3.2.2",
@@ -128,11 +128,12 @@
128
128
  "@types/d3-scale-chromatic": "^3.1.0",
129
129
  "@types/d3-shape": "^3.1.8",
130
130
  "@types/geojson": "^7946.0.16",
131
+ "@types/node": "^25.6.0",
131
132
  "@types/topojson": "^3.2.6",
132
133
  "@types/topojson-client": "^3.1.5",
133
- "@typescript-eslint/eslint-plugin": "^8.56.0",
134
- "@typescript-eslint/parser": "^8.56.0",
135
- "@unocss/extractor-svelte": "^66.6.0",
134
+ "@typescript-eslint/eslint-plugin": "^8.58.1",
135
+ "@typescript-eslint/parser": "^8.58.1",
136
+ "@unocss/extractor-svelte": "^66.6.8",
136
137
  "@vite-pwa/sveltekit": "^1.1.0",
137
138
  "csstype": "^3.2.3",
138
139
  "d3-dsv": "^3.0.1",
@@ -142,50 +143,50 @@
142
143
  "eslint-config-prettier": "^10.1.8",
143
144
  "eslint-plugin-package-json": "^0.88.3",
144
145
  "eslint-plugin-regexp": "^2.10.0",
145
- "eslint-plugin-svelte": "3.15.0",
146
+ "eslint-plugin-svelte": "3.17.0",
146
147
  "jqmath": "^0.4.9",
147
148
  "jsdom": "^27.4.0",
148
149
  "log-update": "^7.1.0",
149
- "lru-cache": "^11.2.6",
150
+ "lru-cache": "^11.3.3",
150
151
  "magic-string": "^0.30.21",
151
- "mdast-util-from-markdown": "^2.0.2",
152
+ "mdast-util-from-markdown": "^2.0.3",
152
153
  "mdast-util-gfm": "^3.1.0",
153
- "oxlint": "^1.48.0",
154
+ "oxlint": "^1.59.0",
154
155
  "oxlint-tsgolint": "^0.14.0",
155
156
  "pixelmatch": "^7.1.0",
156
157
  "pngjs": "^7.0.0",
157
- "prettier": "^3.8.1",
158
- "prettier-plugin-svelte": "^3.5.0",
159
- "puppeteer": "^24.37.4",
158
+ "prettier": "^3.8.2",
159
+ "prettier-plugin-svelte": "^3.5.1",
160
+ "puppeteer": "^24.40.0",
160
161
  "remark-code-extra": "^1.0.1",
161
162
  "remark-code-frontmatter": "^1.0.0",
162
163
  "remark-math": "^6.0.0",
163
164
  "resize-observer-polyfill": "^1.5.1",
164
- "sass": "^1.97.3",
165
+ "sass": "^1.99.0",
165
166
  "shiki": "^3.22.0",
166
167
  "svelte": "5",
167
- "svelte-check": "^4.4.1",
168
- "svelte-eslint-parser": "1.4.1",
168
+ "svelte-check": "^4.4.6",
169
+ "svelte-eslint-parser": "1.6.0",
169
170
  "svelte-highlight": "^7.9.0",
170
171
  "svg-path-parser": "^1.1.0",
171
- "temml": "^0.13.1",
172
+ "temml": "^0.13.2",
172
173
  "topojson-client": "^3.1.0",
173
174
  "ts-essentials": "^10.1.1",
174
175
  "tslib": "^2.8.1",
175
- "typedoc": "^0.28.17",
176
- "typedoc-plugin-markdown": "^4.10.0",
176
+ "typedoc": "^0.28.18",
177
+ "typedoc-plugin-markdown": "^4.11.0",
177
178
  "typescript": "^5.9.3",
178
179
  "uid": "^2.0.2",
179
180
  "unist-util-visit": "^5.1.0",
180
- "unocss": "^66.6.0",
181
+ "unocss": "^66.6.8",
181
182
  "vite": "^7.3.1",
182
- "vitest": "^4.0.18",
183
+ "vitest": "^4.1.4",
183
184
  "vitest-matchmedia-mock": "^2.0.3",
184
- "wx-svelte-grid": "^2.5.0",
185
+ "wx-svelte-grid": "^2.6.1",
185
186
  "yoctocolors": "^2.1.2"
186
187
  },
187
188
  "peerDependencies": {
188
189
  "svelte": "^5.43.0"
189
190
  },
190
- "packageManager": "pnpm@10.30.0"
191
+ "packageManager": "pnpm@10.33.0"
191
192
  }