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