layerchart 2.0.0-next.48 → 2.0.0-next.49
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/bench/PrimitiveBench.svelte +66 -0
- package/dist/bench/PrimitiveBench.svelte.d.ts +10 -0
- package/dist/bench/primitives.svelte.bench.d.ts +1 -0
- package/dist/bench/primitives.svelte.bench.js +42 -0
- package/dist/components/Axis.svelte +14 -3
- package/dist/components/Axis.svelte.d.ts +1 -1
- package/dist/components/Chart.svelte +110 -12
- package/dist/components/Circle.svelte +20 -17
- package/dist/components/Contour.svelte +90 -13
- package/dist/components/Contour.svelte.d.ts +8 -0
- package/dist/components/Ellipse.svelte +18 -16
- package/dist/components/GeoPath.svelte +1 -1
- package/dist/components/Group.svelte +14 -12
- package/dist/components/Image.svelte +18 -16
- package/dist/components/Labels.svelte +56 -11
- package/dist/components/Labels.svelte.d.ts +3 -2
- package/dist/components/Line.svelte +18 -16
- package/dist/components/LinearGradient.svelte +1 -1
- package/dist/components/Marker.svelte +8 -3
- package/dist/components/Marker.svelte.d.ts +1 -1
- package/dist/components/Month.svelte +273 -0
- package/dist/components/Month.svelte.d.ts +70 -0
- package/dist/components/Path.svelte +28 -12
- package/dist/components/Polygon.svelte +25 -23
- package/dist/components/RadialGradient.svelte +1 -1
- package/dist/components/Raster.svelte +117 -29
- package/dist/components/Raster.svelte.d.ts +8 -0
- package/dist/components/Rect.svelte +26 -20
- package/dist/components/Spline.svelte +123 -25
- package/dist/components/Spline.svelte.d.ts +18 -1
- package/dist/components/Text.svelte +45 -20
- package/dist/components/Text.svelte.d.ts +6 -0
- package/dist/components/TransformContext.svelte +8 -0
- package/dist/components/TransformContext.svelte.test.d.ts +1 -0
- package/dist/components/TransformContext.svelte.test.js +166 -0
- package/dist/components/Vector.svelte +14 -12
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.js +2 -0
- package/dist/components/tests/TransformTestHarness.svelte +27 -0
- package/dist/components/tests/TransformTestHarness.svelte.d.ts +8 -0
- package/dist/states/__screenshots__/chart.component-node.svelte.test.ts/ChartState-registerComponent-cleans-up-child-nodes-and-mark-registrations-when-components-unmount-1.png +0 -0
- package/dist/states/__screenshots__/chart.component-node.svelte.test.ts/ChartState-registerComponent-cleans-up-child-nodes-and-mark-registrations-when-components-unmount-2.png +0 -0
- package/dist/states/brush.svelte.d.ts +26 -17
- package/dist/states/brush.svelte.js +118 -25
- package/dist/states/brush.svelte.test.js +126 -1
- package/dist/states/chart.svelte.d.ts +6 -0
- package/dist/states/chart.svelte.js +93 -20
- package/dist/states/transform.svelte.js +3 -1
- package/dist/utils/dataProp.d.ts +2 -10
- package/dist/utils/dataProp.js +16 -5
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/dist/utils/motion.svelte.d.ts +12 -2
- package/dist/utils/motion.svelte.js +22 -0
- package/dist/utils/motion.test.js +49 -1
- package/dist/utils/rasterBounds.d.ts +18 -0
- package/dist/utils/rasterBounds.js +98 -0
- package/dist/utils/rasterBounds.test.d.ts +1 -0
- package/dist/utils/rasterBounds.test.js +63 -0
- package/dist/utils/scales.svelte.js +4 -2
- package/dist/utils/scales.svelte.test.d.ts +1 -0
- package/dist/utils/scales.svelte.test.js +67 -0
- package/dist/utils/ticks.js +7 -3
- package/dist/utils/ticks.test.js +13 -3
- package/package.json +3 -2
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
export type BrushDomainType = Array<number | Date | null>;
|
|
1
|
+
export type BrushDomainType = Array<number | Date | string | null>;
|
|
2
|
+
/**
|
|
3
|
+
* For band scales, expand a [first, last] brush selection into the full category subarray.
|
|
4
|
+
* For continuous scales, returns the domain unchanged.
|
|
5
|
+
*/
|
|
6
|
+
export declare function expandBandBrushDomain(brushDomain: BrushDomainType, baseDomain: any[]): BrushDomainType;
|
|
2
7
|
export type BrushRange = {
|
|
3
8
|
x: number;
|
|
4
9
|
y: number;
|
|
@@ -10,8 +15,12 @@ export type BrushRange = {
|
|
|
10
15
|
* Narrowed from ChartState to only what brush needs, enabling easier testing.
|
|
11
16
|
*/
|
|
12
17
|
export type BrushChartContext = {
|
|
13
|
-
xScale: (v: any) => number
|
|
14
|
-
|
|
18
|
+
xScale: ((v: any) => number) & {
|
|
19
|
+
bandwidth?: () => number;
|
|
20
|
+
};
|
|
21
|
+
yScale: ((v: any) => number) & {
|
|
22
|
+
bandwidth?: () => number;
|
|
23
|
+
};
|
|
15
24
|
baseXScale: {
|
|
16
25
|
domain: () => any[];
|
|
17
26
|
};
|
|
@@ -56,31 +65,31 @@ export declare class BrushState {
|
|
|
56
65
|
}): void;
|
|
57
66
|
/** Set brush to a new range, clamped to domain bounds */
|
|
58
67
|
setRange(startValue: {
|
|
59
|
-
x:
|
|
60
|
-
y:
|
|
68
|
+
x: any;
|
|
69
|
+
y: any;
|
|
61
70
|
}, currentValue: {
|
|
62
|
-
x:
|
|
63
|
-
y:
|
|
71
|
+
x: any;
|
|
72
|
+
y: any;
|
|
64
73
|
}): void;
|
|
65
74
|
/** Move the entire brush range by a delta, clamped to domain bounds */
|
|
66
75
|
moveRange(start: {
|
|
67
|
-
x: [
|
|
68
|
-
y: [
|
|
76
|
+
x: [any, any];
|
|
77
|
+
y: [any, any];
|
|
69
78
|
value: {
|
|
70
|
-
x:
|
|
71
|
-
y:
|
|
79
|
+
x: any;
|
|
80
|
+
y: any;
|
|
72
81
|
};
|
|
73
82
|
}, currentValue: {
|
|
74
|
-
x:
|
|
75
|
-
y:
|
|
83
|
+
x: any;
|
|
84
|
+
y: any;
|
|
76
85
|
}): void;
|
|
77
86
|
/** Adjust a single edge of the brush, clamped to domain bounds. Handles inversion if dragged past opposite edge. */
|
|
78
87
|
adjustEdge(edge: 'top' | 'bottom' | 'left' | 'right', start: {
|
|
79
|
-
x: [
|
|
80
|
-
y: [
|
|
88
|
+
x: [any, any];
|
|
89
|
+
y: [any, any];
|
|
81
90
|
}, currentValue: {
|
|
82
|
-
x:
|
|
83
|
-
y:
|
|
91
|
+
x: any;
|
|
92
|
+
y: any;
|
|
84
93
|
}): void;
|
|
85
94
|
/**
|
|
86
95
|
* Sync external domain values into brush state.
|
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
import { clamp } from '@layerstack/utils';
|
|
2
2
|
import { min, max } from 'd3-array';
|
|
3
3
|
import { add } from '../utils/math.js';
|
|
4
|
+
/**
|
|
5
|
+
* For band scales, expand a [first, last] brush selection into the full category subarray.
|
|
6
|
+
* For continuous scales, returns the domain unchanged.
|
|
7
|
+
*/
|
|
8
|
+
export function expandBandBrushDomain(brushDomain, baseDomain) {
|
|
9
|
+
if (brushDomain[0] == null ||
|
|
10
|
+
brushDomain[1] == null ||
|
|
11
|
+
typeof brushDomain[0] !== 'string') {
|
|
12
|
+
return brushDomain;
|
|
13
|
+
}
|
|
14
|
+
const startIdx = baseDomain.indexOf(brushDomain[0]);
|
|
15
|
+
const endIdx = baseDomain.indexOf(brushDomain[1]);
|
|
16
|
+
if (startIdx === -1 || endIdx === -1)
|
|
17
|
+
return brushDomain;
|
|
18
|
+
return baseDomain.slice(startIdx, endIdx + 1);
|
|
19
|
+
}
|
|
20
|
+
/** Check if a domain array is categorical (string-based) */
|
|
21
|
+
function isCategoricalDomain(domain) {
|
|
22
|
+
return domain.length > 0 && typeof domain[0] === 'string';
|
|
23
|
+
}
|
|
24
|
+
/** Get the min (by domain index) of two values */
|
|
25
|
+
function minByIndex(a, b, domain) {
|
|
26
|
+
return domain.indexOf(a) <= domain.indexOf(b) ? a : b;
|
|
27
|
+
}
|
|
28
|
+
/** Get the max (by domain index) of two values */
|
|
29
|
+
function maxByIndex(a, b, domain) {
|
|
30
|
+
return domain.indexOf(a) >= domain.indexOf(b) ? a : b;
|
|
31
|
+
}
|
|
32
|
+
/** Clamp a value to domain bounds by index */
|
|
33
|
+
function clampByIndex(value, minVal, maxVal, domain) {
|
|
34
|
+
const idx = domain.indexOf(value);
|
|
35
|
+
const minIdx = domain.indexOf(minVal);
|
|
36
|
+
const maxIdx = domain.indexOf(maxVal);
|
|
37
|
+
if (idx === -1)
|
|
38
|
+
return minVal;
|
|
39
|
+
if (idx < minIdx)
|
|
40
|
+
return minVal;
|
|
41
|
+
if (idx > maxIdx)
|
|
42
|
+
return maxVal;
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
4
45
|
export class BrushState {
|
|
5
46
|
ctx;
|
|
6
47
|
x = $state([null, null]);
|
|
@@ -20,22 +61,24 @@ export class BrushState {
|
|
|
20
61
|
return this.ctx?.baseXScale.domain()[0];
|
|
21
62
|
}
|
|
22
63
|
get xDomainMax() {
|
|
23
|
-
return this.ctx?.baseXScale.domain()
|
|
64
|
+
return this.ctx?.baseXScale.domain().at(-1);
|
|
24
65
|
}
|
|
25
66
|
get yDomainMin() {
|
|
26
67
|
return this.ctx?.baseYScale.domain()[0];
|
|
27
68
|
}
|
|
28
69
|
get yDomainMax() {
|
|
29
|
-
return this.ctx?.baseYScale.domain()
|
|
70
|
+
return this.ctx?.baseYScale.domain().at(-1);
|
|
30
71
|
}
|
|
31
72
|
get range() {
|
|
32
73
|
if (!this.ctx) {
|
|
33
74
|
return { x: 0, y: 0, width: 0, height: 0 };
|
|
34
75
|
}
|
|
35
|
-
const
|
|
36
|
-
const
|
|
76
|
+
const xBw = this.ctx.xScale.bandwidth?.() ?? 0;
|
|
77
|
+
const yBw = this.ctx.yScale.bandwidth?.() ?? 0;
|
|
37
78
|
const left = this.ctx.xScale(this.x?.[0]);
|
|
38
|
-
const right = this.ctx.xScale(this.x?.[1]);
|
|
79
|
+
const right = this.ctx.xScale(this.x?.[1]) + xBw;
|
|
80
|
+
const top = this.ctx.yScale(this.y?.[1]);
|
|
81
|
+
const bottom = this.ctx.yScale(this.y?.[0]) + yBw;
|
|
39
82
|
return {
|
|
40
83
|
x: this.axis === 'both' || this.axis === 'x' ? left : 0,
|
|
41
84
|
y: this.axis === 'both' || this.axis === 'y' ? top : 0,
|
|
@@ -72,47 +115,97 @@ export class BrushState {
|
|
|
72
115
|
/** Set brush to a new range, clamped to domain bounds */
|
|
73
116
|
setRange(startValue, currentValue) {
|
|
74
117
|
this.active = true;
|
|
75
|
-
this.
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
118
|
+
const xDomain = this.ctx?.baseXScale.domain() ?? [];
|
|
119
|
+
const yDomain = this.ctx?.baseYScale.domain() ?? [];
|
|
120
|
+
if (isCategoricalDomain(xDomain)) {
|
|
121
|
+
this.x = [
|
|
122
|
+
clampByIndex(minByIndex(startValue.x, currentValue.x, xDomain), this.xDomainMin, this.xDomainMax, xDomain),
|
|
123
|
+
clampByIndex(maxByIndex(startValue.x, currentValue.x, xDomain), this.xDomainMin, this.xDomainMax, xDomain),
|
|
124
|
+
];
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
this.x = [
|
|
128
|
+
clamp(min([startValue.x, currentValue.x]), this.xDomainMin, this.xDomainMax),
|
|
129
|
+
clamp(max([startValue.x, currentValue.x]), this.xDomainMin, this.xDomainMax),
|
|
130
|
+
];
|
|
131
|
+
}
|
|
132
|
+
if (isCategoricalDomain(yDomain)) {
|
|
133
|
+
this.y = [
|
|
134
|
+
clampByIndex(minByIndex(startValue.y, currentValue.y, yDomain), this.yDomainMin, this.yDomainMax, yDomain),
|
|
135
|
+
clampByIndex(maxByIndex(startValue.y, currentValue.y, yDomain), this.yDomainMin, this.yDomainMax, yDomain),
|
|
136
|
+
];
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
this.y = [
|
|
140
|
+
clamp(min([startValue.y, currentValue.y]), this.yDomainMin, this.yDomainMax),
|
|
141
|
+
clamp(max([startValue.y, currentValue.y]), this.yDomainMin, this.yDomainMax),
|
|
142
|
+
];
|
|
143
|
+
}
|
|
83
144
|
}
|
|
84
145
|
/** Move the entire brush range by a delta, clamped to domain bounds */
|
|
85
146
|
moveRange(start, currentValue) {
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
147
|
+
const xDomain = this.ctx?.baseXScale.domain() ?? [];
|
|
148
|
+
const yDomain = this.ctx?.baseYScale.domain() ?? [];
|
|
149
|
+
if (isCategoricalDomain(xDomain)) {
|
|
150
|
+
const startIdx = xDomain.indexOf(start.value.x);
|
|
151
|
+
const currentIdx = xDomain.indexOf(currentValue.x);
|
|
152
|
+
const origStartIdx = xDomain.indexOf(start.x[0]);
|
|
153
|
+
const origEndIdx = xDomain.indexOf(start.x[1]);
|
|
154
|
+
const delta = Math.max(-origStartIdx, Math.min(xDomain.length - 1 - origEndIdx, currentIdx - startIdx));
|
|
155
|
+
this.x = [xDomain[origStartIdx + delta], xDomain[origEndIdx + delta]];
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
const dx = clamp(currentValue.x - start.value.x, this.xDomainMin - +start.x[0], this.xDomainMax - +start.x[1]);
|
|
159
|
+
this.x = [add(start.x[0], dx), add(start.x[1], dx)];
|
|
160
|
+
}
|
|
161
|
+
if (isCategoricalDomain(yDomain)) {
|
|
162
|
+
const startIdx = yDomain.indexOf(start.value.y);
|
|
163
|
+
const currentIdx = yDomain.indexOf(currentValue.y);
|
|
164
|
+
const origStartIdx = yDomain.indexOf(start.y[0]);
|
|
165
|
+
const origEndIdx = yDomain.indexOf(start.y[1]);
|
|
166
|
+
const delta = Math.max(-origStartIdx, Math.min(yDomain.length - 1 - origEndIdx, currentIdx - startIdx));
|
|
167
|
+
this.y = [yDomain[origStartIdx + delta], yDomain[origEndIdx + delta]];
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
const dy = clamp(currentValue.y - start.value.y, this.yDomainMin - +start.y[0], this.yDomainMax - +start.y[1]);
|
|
171
|
+
this.y = [add(start.y[0], dy), add(start.y[1], dy)];
|
|
172
|
+
}
|
|
90
173
|
}
|
|
91
174
|
/** Adjust a single edge of the brush, clamped to domain bounds. Handles inversion if dragged past opposite edge. */
|
|
92
175
|
adjustEdge(edge, start, currentValue) {
|
|
176
|
+
const xDomain = this.ctx?.baseXScale.domain() ?? [];
|
|
177
|
+
const yDomain = this.ctx?.baseYScale.domain() ?? [];
|
|
178
|
+
const xCat = isCategoricalDomain(xDomain);
|
|
179
|
+
const yCat = isCategoricalDomain(yDomain);
|
|
180
|
+
const clampX = (v) => xCat ? clampByIndex(v, this.xDomainMin, this.xDomainMax, xDomain) : clamp(v, this.xDomainMin, this.xDomainMax);
|
|
181
|
+
const clampY = (v) => yCat ? clampByIndex(v, this.yDomainMin, this.yDomainMax, yDomain) : clamp(v, this.yDomainMin, this.yDomainMax);
|
|
182
|
+
const ltX = (a, b) => xCat ? xDomain.indexOf(a) < xDomain.indexOf(b) : a < +b;
|
|
183
|
+
const gtX = (a, b) => xCat ? xDomain.indexOf(a) > xDomain.indexOf(b) : a > +b;
|
|
184
|
+
const ltY = (a, b) => yCat ? yDomain.indexOf(a) < yDomain.indexOf(b) : a < +b;
|
|
185
|
+
const gtY = (a, b) => yCat ? yDomain.indexOf(a) > yDomain.indexOf(b) : a > +b;
|
|
93
186
|
switch (edge) {
|
|
94
187
|
case 'top':
|
|
95
188
|
this.y = [
|
|
96
|
-
|
|
97
|
-
|
|
189
|
+
clampY(ltY(currentValue.y, start.y[0]) ? currentValue.y : start.y[0]),
|
|
190
|
+
clampY(ltY(currentValue.y, start.y[0]) ? start.y[0] : currentValue.y),
|
|
98
191
|
];
|
|
99
192
|
break;
|
|
100
193
|
case 'bottom':
|
|
101
194
|
this.y = [
|
|
102
|
-
|
|
103
|
-
|
|
195
|
+
clampY(gtY(currentValue.y, start.y[1]) ? start.y[1] : currentValue.y),
|
|
196
|
+
clampY(gtY(currentValue.y, start.y[1]) ? currentValue.y : start.y[1]),
|
|
104
197
|
];
|
|
105
198
|
break;
|
|
106
199
|
case 'left':
|
|
107
200
|
this.x = [
|
|
108
|
-
|
|
109
|
-
|
|
201
|
+
clampX(gtX(currentValue.x, start.x[1]) ? start.x[1] : currentValue.x),
|
|
202
|
+
clampX(gtX(currentValue.x, start.x[1]) ? currentValue.x : start.x[1]),
|
|
110
203
|
];
|
|
111
204
|
break;
|
|
112
205
|
case 'right':
|
|
113
206
|
this.x = [
|
|
114
|
-
|
|
115
|
-
|
|
207
|
+
clampX(ltX(currentValue.x, start.x[0]) ? currentValue.x : start.x[0]),
|
|
208
|
+
clampX(ltX(currentValue.x, start.x[0]) ? start.x[0] : currentValue.x),
|
|
116
209
|
];
|
|
117
210
|
break;
|
|
118
211
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { BrushState } from './brush.svelte.js';
|
|
2
|
+
import { BrushState, expandBandBrushDomain } from './brush.svelte.js';
|
|
3
3
|
/** Create a mock chart context with a simple linear scale over [0, 100] */
|
|
4
4
|
function createMockCtx(options) {
|
|
5
5
|
const xDomain = options?.xDomain ?? [0, 100];
|
|
@@ -289,4 +289,129 @@ describe('BrushState', () => {
|
|
|
289
289
|
expect(brush.x).toBe(originalX);
|
|
290
290
|
});
|
|
291
291
|
});
|
|
292
|
+
describe('band scale support', () => {
|
|
293
|
+
const categories = ['A', 'B', 'C', 'D', 'E'];
|
|
294
|
+
const bandwidth = 80; // 500 / 5 = 100 step, with padding ~80 band
|
|
295
|
+
function createBandMockCtx() {
|
|
296
|
+
const width = 500;
|
|
297
|
+
const height = 300;
|
|
298
|
+
const step = width / categories.length;
|
|
299
|
+
const bw = bandwidth;
|
|
300
|
+
const xScale = Object.assign((v) => {
|
|
301
|
+
const idx = categories.indexOf(v);
|
|
302
|
+
return idx * step + (step - bw) / 2;
|
|
303
|
+
}, { bandwidth: () => bw });
|
|
304
|
+
const yScale = (v) => height - (v / 100) * height;
|
|
305
|
+
return {
|
|
306
|
+
xScale,
|
|
307
|
+
yScale,
|
|
308
|
+
baseXScale: { domain: () => categories },
|
|
309
|
+
baseYScale: { domain: () => [0, 100] },
|
|
310
|
+
width,
|
|
311
|
+
height,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
it('should return correct domain min/max for band scales', () => {
|
|
315
|
+
const ctx = createBandMockCtx();
|
|
316
|
+
const brush = new BrushState(ctx);
|
|
317
|
+
expect(brush.xDomainMin).toBe('A');
|
|
318
|
+
expect(brush.xDomainMax).toBe('E');
|
|
319
|
+
});
|
|
320
|
+
it('should selectAll with first and last categories', () => {
|
|
321
|
+
const ctx = createBandMockCtx();
|
|
322
|
+
const brush = new BrushState(ctx);
|
|
323
|
+
brush.selectAll();
|
|
324
|
+
expect(brush.x).toEqual(['A', 'E']);
|
|
325
|
+
});
|
|
326
|
+
it('should setRange with categorical values', () => {
|
|
327
|
+
const ctx = createBandMockCtx();
|
|
328
|
+
const brush = new BrushState(ctx);
|
|
329
|
+
brush.setRange({ x: 'B', y: 30 }, { x: 'D', y: 70 });
|
|
330
|
+
expect(brush.active).toBe(true);
|
|
331
|
+
expect(brush.x).toEqual(['B', 'D']);
|
|
332
|
+
});
|
|
333
|
+
it('should setRange with reversed categorical values', () => {
|
|
334
|
+
const ctx = createBandMockCtx();
|
|
335
|
+
const brush = new BrushState(ctx);
|
|
336
|
+
brush.setRange({ x: 'D', y: 30 }, { x: 'B', y: 70 });
|
|
337
|
+
expect(brush.x).toEqual(['B', 'D']);
|
|
338
|
+
});
|
|
339
|
+
it('should clamp setRange to domain bounds', () => {
|
|
340
|
+
const ctx = createBandMockCtx();
|
|
341
|
+
const brush = new BrushState(ctx);
|
|
342
|
+
// 'A' is the min, 'E' is the max
|
|
343
|
+
brush.setRange({ x: 'A', y: 0 }, { x: 'E', y: 100 });
|
|
344
|
+
expect(brush.x).toEqual(['A', 'E']);
|
|
345
|
+
});
|
|
346
|
+
it('should compute range with bandwidth for band scales', () => {
|
|
347
|
+
const ctx = createBandMockCtx();
|
|
348
|
+
const brush = new BrushState(ctx, { x: ['B', 'D'], axis: 'x' });
|
|
349
|
+
const range = brush.range;
|
|
350
|
+
// Right edge should include bandwidth of last category
|
|
351
|
+
const leftPx = ctx.xScale('B');
|
|
352
|
+
const rightPx = ctx.xScale('D') + bandwidth;
|
|
353
|
+
expect(range.x).toBe(leftPx);
|
|
354
|
+
expect(range.width).toBe(rightPx - leftPx);
|
|
355
|
+
});
|
|
356
|
+
it('should moveRange by category offset', () => {
|
|
357
|
+
const ctx = createBandMockCtx();
|
|
358
|
+
const brush = new BrushState(ctx, { x: ['B', 'C'], y: [20, 40] });
|
|
359
|
+
brush.moveRange({ x: ['B', 'C'], y: [20, 40], value: { x: 'B', y: 30 } }, { x: 'C', y: 40 });
|
|
360
|
+
// Delta of 1 category to the right
|
|
361
|
+
expect(brush.x).toEqual(['C', 'D']);
|
|
362
|
+
});
|
|
363
|
+
it('should clamp moveRange to domain bounds', () => {
|
|
364
|
+
const ctx = createBandMockCtx();
|
|
365
|
+
const brush = new BrushState(ctx, { x: ['D', 'E'] });
|
|
366
|
+
brush.moveRange({ x: ['D', 'E'], y: [0, 100], value: { x: 'D', y: 50 } }, { x: 'E', y: 50 } // try to move right by 1
|
|
367
|
+
);
|
|
368
|
+
// Should stay at domain boundary
|
|
369
|
+
expect(brush.x).toEqual(['D', 'E']);
|
|
370
|
+
});
|
|
371
|
+
it('should adjustEdge right for categorical', () => {
|
|
372
|
+
const ctx = createBandMockCtx();
|
|
373
|
+
const brush = new BrushState(ctx, { x: ['B', 'D'] });
|
|
374
|
+
brush.adjustEdge('right', { x: ['B', 'D'], y: [0, 100] }, { x: 'E', y: 50 });
|
|
375
|
+
expect(brush.x).toEqual(['B', 'E']);
|
|
376
|
+
});
|
|
377
|
+
it('should adjustEdge left for categorical', () => {
|
|
378
|
+
const ctx = createBandMockCtx();
|
|
379
|
+
const brush = new BrushState(ctx, { x: ['B', 'D'] });
|
|
380
|
+
brush.adjustEdge('left', { x: ['B', 'D'], y: [0, 100] }, { x: 'A', y: 50 });
|
|
381
|
+
expect(brush.x).toEqual(['A', 'D']);
|
|
382
|
+
});
|
|
383
|
+
it('should invert edges when dragged past opposite edge (categorical)', () => {
|
|
384
|
+
const ctx = createBandMockCtx();
|
|
385
|
+
const brush = new BrushState(ctx, { x: ['B', 'D'] });
|
|
386
|
+
// Drag left handle past right edge
|
|
387
|
+
brush.adjustEdge('left', { x: ['B', 'D'], y: [0, 100] }, { x: 'E', y: 50 });
|
|
388
|
+
expect(brush.x).toEqual(['D', 'E']);
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
describe('expandBandBrushDomain', () => {
|
|
393
|
+
const baseDomain = ['A', 'B', 'C', 'D', 'E'];
|
|
394
|
+
it('should expand [first, last] to full category subarray', () => {
|
|
395
|
+
expect(expandBandBrushDomain(['B', 'D'], baseDomain)).toEqual(['B', 'C', 'D']);
|
|
396
|
+
});
|
|
397
|
+
it('should return full domain for [first, last] matching full extent', () => {
|
|
398
|
+
expect(expandBandBrushDomain(['A', 'E'], baseDomain)).toEqual(['A', 'B', 'C', 'D', 'E']);
|
|
399
|
+
});
|
|
400
|
+
it('should return single category when first equals last', () => {
|
|
401
|
+
expect(expandBandBrushDomain(['C', 'C'], baseDomain)).toEqual(['C']);
|
|
402
|
+
});
|
|
403
|
+
it('should pass through numeric domains unchanged', () => {
|
|
404
|
+
expect(expandBandBrushDomain([10, 50], [0, 100])).toEqual([10, 50]);
|
|
405
|
+
});
|
|
406
|
+
it('should pass through null domains unchanged', () => {
|
|
407
|
+
expect(expandBandBrushDomain([null, null], baseDomain)).toEqual([null, null]);
|
|
408
|
+
});
|
|
409
|
+
it('should pass through Date domains unchanged', () => {
|
|
410
|
+
const d1 = new Date('2024-01-01');
|
|
411
|
+
const d2 = new Date('2024-06-01');
|
|
412
|
+
expect(expandBandBrushDomain([d1, d2], [d1, d2])).toEqual([d1, d2]);
|
|
413
|
+
});
|
|
414
|
+
it('should return unchanged if category not found in domain', () => {
|
|
415
|
+
expect(expandBandBrushDomain(['X', 'Y'], baseDomain)).toEqual(['X', 'Y']);
|
|
416
|
+
});
|
|
292
417
|
});
|
|
@@ -97,6 +97,9 @@ export declare class ChartState<TData = any, XScale extends AnyScale = AnyScale,
|
|
|
97
97
|
_rScaleProp: AnyScale | import("d3-scale").ScalePower<any, any, any>;
|
|
98
98
|
xRangeProp: import("../utils/types.js").BaseRange | undefined;
|
|
99
99
|
yRangeProp: import("../utils/types.js").BaseRange | undefined;
|
|
100
|
+
/** Transform-aware range for band scales in domain mode (D3 range-rescaling pattern) */
|
|
101
|
+
private _xScaleRange;
|
|
102
|
+
private _yScaleRange;
|
|
100
103
|
yReverse: boolean;
|
|
101
104
|
private resolveAccessor;
|
|
102
105
|
x: (d: TData) => any;
|
|
@@ -187,6 +190,9 @@ export declare class ChartState<TData = any, XScale extends AnyScale = AnyScale,
|
|
|
187
190
|
yDomainPossiblyNice: any[];
|
|
188
191
|
zDomainPossiblyNice: any[];
|
|
189
192
|
rDomainPossiblyNice: any[];
|
|
193
|
+
/** Viewport range — always [0, width] / [height, 0] for layout components (axis, grid, etc).
|
|
194
|
+
* When band scale domain transform is active, xScale.range() is wider than the viewport,
|
|
195
|
+
* so we return the base scale's range instead. */
|
|
190
196
|
xRange: any[];
|
|
191
197
|
yRange: any[];
|
|
192
198
|
zRange: any[];
|
|
@@ -119,7 +119,12 @@ export class ChartState {
|
|
|
119
119
|
});
|
|
120
120
|
if (markInfo && !insideCompositeMark) {
|
|
121
121
|
$effect(() => {
|
|
122
|
-
|
|
122
|
+
const info = markInfo();
|
|
123
|
+
// Skip registration for empty mark info (e.g. pixel-mode marks)
|
|
124
|
+
// to avoid unnecessary array push/splice and version bumps
|
|
125
|
+
if (!info.x && !info.y && !info.data && !info.color && !info.seriesKey && !info.label)
|
|
126
|
+
return;
|
|
127
|
+
return untrack(() => this.registerMark(info));
|
|
123
128
|
});
|
|
124
129
|
}
|
|
125
130
|
return node;
|
|
@@ -358,6 +363,27 @@ export class ChartState {
|
|
|
358
363
|
xRangeProp = $derived(this.props.xRange ? this.props.xRange : this.props.radial ? [0, 2 * Math.PI] : undefined);
|
|
359
364
|
yRangeProp = $derived(this.props.yRange ??
|
|
360
365
|
(this.props.radial ? ({ height }) => [0, height / 2] : undefined));
|
|
366
|
+
/** Transform-aware range for band scales in domain mode (D3 range-rescaling pattern) */
|
|
367
|
+
_xScaleRange = $derived.by(() => {
|
|
368
|
+
if (this.transformState?.mode === 'domain' &&
|
|
369
|
+
(this.transformState.axis === 'x' || this.transformState.axis === 'both') &&
|
|
370
|
+
isScaleBand(this._xScaleProp) &&
|
|
371
|
+
this.width > 0) {
|
|
372
|
+
const { scale, translate } = this.transformState;
|
|
373
|
+
return [translate.x, translate.x + this.width * scale];
|
|
374
|
+
}
|
|
375
|
+
return this.xRangeProp;
|
|
376
|
+
});
|
|
377
|
+
_yScaleRange = $derived.by(() => {
|
|
378
|
+
if (this.transformState?.mode === 'domain' &&
|
|
379
|
+
(this.transformState.axis === 'y' || this.transformState.axis === 'both') &&
|
|
380
|
+
isScaleBand(this._yScaleProp) &&
|
|
381
|
+
this.height > 0) {
|
|
382
|
+
const { scale, translate } = this.transformState;
|
|
383
|
+
return [translate.y, translate.y + this.height * scale];
|
|
384
|
+
}
|
|
385
|
+
return this.yRangeProp;
|
|
386
|
+
});
|
|
361
387
|
yReverse = $derived(!isScaleBand(this._yScaleProp) && !isScaleTime(this._yScaleProp));
|
|
362
388
|
resolveAccessor(axis) {
|
|
363
389
|
const axisAccessor = axis === 'x' ? this.props.x : this.props.y;
|
|
@@ -682,7 +708,7 @@ export class ChartState {
|
|
|
682
708
|
nice: this.xNice,
|
|
683
709
|
reverse: this.props.xReverse ?? false,
|
|
684
710
|
percentRange: this.props.percentRange ?? false,
|
|
685
|
-
range: this.
|
|
711
|
+
range: this._xScaleRange,
|
|
686
712
|
height: this.height,
|
|
687
713
|
width: this.width,
|
|
688
714
|
extents: this.snappedExtents,
|
|
@@ -695,7 +721,7 @@ export class ChartState {
|
|
|
695
721
|
nice: this.yNice,
|
|
696
722
|
reverse: this.yReverse,
|
|
697
723
|
percentRange: this.props.percentRange ?? false,
|
|
698
|
-
range: this.
|
|
724
|
+
range: this._yScaleRange,
|
|
699
725
|
height: this.height,
|
|
700
726
|
width: this.width,
|
|
701
727
|
extents: this.filteredExtents,
|
|
@@ -789,8 +815,25 @@ export class ChartState {
|
|
|
789
815
|
yDomainPossiblyNice = $derived(this.yScale.domain());
|
|
790
816
|
zDomainPossiblyNice = $derived(this.zScale.domain());
|
|
791
817
|
rDomainPossiblyNice = $derived(this.rScale.domain());
|
|
792
|
-
|
|
793
|
-
|
|
818
|
+
/** Viewport range — always [0, width] / [height, 0] for layout components (axis, grid, etc).
|
|
819
|
+
* When band scale domain transform is active, xScale.range() is wider than the viewport,
|
|
820
|
+
* so we return the base scale's range instead. */
|
|
821
|
+
xRange = $derived.by(() => {
|
|
822
|
+
if (this.transformState?.mode === 'domain' &&
|
|
823
|
+
(this.transformState.axis === 'x' || this.transformState.axis === 'both') &&
|
|
824
|
+
isScaleBand(this._xScaleProp)) {
|
|
825
|
+
return getRange(this.baseXScale);
|
|
826
|
+
}
|
|
827
|
+
return getRange(this.xScale);
|
|
828
|
+
});
|
|
829
|
+
yRange = $derived.by(() => {
|
|
830
|
+
if (this.transformState?.mode === 'domain' &&
|
|
831
|
+
(this.transformState.axis === 'y' || this.transformState.axis === 'both') &&
|
|
832
|
+
isScaleBand(this._yScaleProp)) {
|
|
833
|
+
return getRange(this.baseYScale);
|
|
834
|
+
}
|
|
835
|
+
return getRange(this.yScale);
|
|
836
|
+
});
|
|
794
837
|
zRange = $derived(getRange(this.zScale));
|
|
795
838
|
rRange = $derived(getRange(this.rScale));
|
|
796
839
|
aspectRatio = $derived(this.width / this.height);
|
|
@@ -931,22 +974,52 @@ export class ChartState {
|
|
|
931
974
|
const brushX = brush.x;
|
|
932
975
|
const brushY = brush.y;
|
|
933
976
|
if ((axis === 'x' || axis === 'both') && brushX[0] != null && brushX[1] != null) {
|
|
934
|
-
const
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
const
|
|
940
|
-
const
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
const
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
977
|
+
const baseDomainX = this._baseXDomain;
|
|
978
|
+
if (typeof baseDomainX[0] === 'string') {
|
|
979
|
+
// Categorical: compute scale/translate from domain indices
|
|
980
|
+
const totalCount = baseDomainX.length;
|
|
981
|
+
const startIdx = baseDomainX.indexOf(brushX[0]);
|
|
982
|
+
const endIdx = baseDomainX.indexOf(brushX[1]) + 1;
|
|
983
|
+
const selectedCount = endIdx - startIdx;
|
|
984
|
+
if (selectedCount > 0 && totalCount > 0) {
|
|
985
|
+
const newScale = totalCount / selectedCount;
|
|
986
|
+
const newTranslateX = -(startIdx / totalCount) * this.width * newScale;
|
|
987
|
+
let newTranslateY = 0;
|
|
988
|
+
if (axis === 'both' && brushY[0] != null && brushY[1] != null) {
|
|
989
|
+
const baseDomainY = this._baseYDomain;
|
|
990
|
+
if (typeof baseDomainY[0] === 'string') {
|
|
991
|
+
const yTotal = baseDomainY.length;
|
|
992
|
+
const yStart = baseDomainY.indexOf(brushY[0]);
|
|
993
|
+
const yEnd = baseDomainY.indexOf(brushY[1]) + 1;
|
|
994
|
+
const ySelected = yEnd - yStart;
|
|
995
|
+
if (ySelected > 0) {
|
|
996
|
+
newTranslateY = -(yStart / yTotal) * this.height * newScale;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
this.transform.setScale(newScale);
|
|
1001
|
+
this.transform.setTranslate({ x: newTranslateX, y: newTranslateY });
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
else {
|
|
1005
|
+
// Continuous: existing numeric logic
|
|
1006
|
+
const baseMinX = +baseDomainX[0];
|
|
1007
|
+
const baseRangeX = +baseDomainX[1] - baseMinX;
|
|
1008
|
+
const brushMinX = +brushX[0];
|
|
1009
|
+
const brushRangeX = +brushX[1] - brushMinX;
|
|
1010
|
+
if (brushRangeX > 0 && baseRangeX > 0) {
|
|
1011
|
+
const newScale = baseRangeX / brushRangeX;
|
|
1012
|
+
const newTranslateX = -((brushMinX - baseMinX) / baseRangeX) * this.width * newScale;
|
|
1013
|
+
let newTranslateY = 0;
|
|
1014
|
+
if (axis === 'both' && brushY[0] != null && brushY[1] != null) {
|
|
1015
|
+
const baseMinY = +this._baseYDomain[0];
|
|
1016
|
+
const baseRangeY = +this._baseYDomain[1] - baseMinY;
|
|
1017
|
+
const brushMinY = +brushY[0];
|
|
1018
|
+
newTranslateY = -((brushMinY - baseMinY) / baseRangeY) * this.height * newScale;
|
|
1019
|
+
}
|
|
1020
|
+
this.transform.setScale(newScale);
|
|
1021
|
+
this.transform.setTranslate({ x: newTranslateX, y: newTranslateY });
|
|
947
1022
|
}
|
|
948
|
-
this.transform.setScale(newScale);
|
|
949
|
-
this.transform.setTranslate({ x: newTranslateX, y: newTranslateY });
|
|
950
1023
|
}
|
|
951
1024
|
}
|
|
952
1025
|
}
|
|
@@ -100,7 +100,9 @@ export class TransformState {
|
|
|
100
100
|
if (this.processTranslate)
|
|
101
101
|
return this.processTranslate(x, y, deltaX, deltaY);
|
|
102
102
|
if (this.mode === 'domain') {
|
|
103
|
-
// Negate deltaY because screen Y (top→bottom) is inverted vs data Y (bottom→top)
|
|
103
|
+
// Negate deltaY because screen Y (top→bottom) is inverted vs data Y (bottom→top).
|
|
104
|
+
// This works for both normal and reversed Y domains because _computeTransformDomain
|
|
105
|
+
// uses signed range, which naturally handles the reversal.
|
|
104
106
|
if (this.axis === 'x')
|
|
105
107
|
return { x: x + deltaX, y: 0 };
|
|
106
108
|
if (this.axis === 'y')
|
package/dist/utils/dataProp.d.ts
CHANGED
|
@@ -103,18 +103,10 @@ export type DataDrivenStyleProps<T = any> = {
|
|
|
103
103
|
*/
|
|
104
104
|
class?: StyleProp<string | undefined, T>;
|
|
105
105
|
};
|
|
106
|
-
|
|
107
|
-
* Resolves a ColorProp for a specific data item, optionally through a color scale.
|
|
108
|
-
*
|
|
109
|
-
* - `string`: checks if `get(d, value)` is defined → data property, passed through cScale.
|
|
110
|
-
* Otherwise returns the string as a literal CSS color.
|
|
111
|
-
* - `function`: called with data item, result passed through cScale.
|
|
112
|
-
* - `undefined`/`null`: returns undefined.
|
|
113
|
-
*/
|
|
114
|
-
export declare function resolveColorProp<T>(value: ColorProp<T> | undefined | null, d: T, cScale: AnyScale | null | undefined): string | undefined;
|
|
106
|
+
export declare function resolveColorProp<T>(value: ColorProp<T> | undefined | null, d: T, cScale: AnyScale | null | undefined, ...args: any[]): string | undefined;
|
|
115
107
|
/**
|
|
116
108
|
* Resolves a StyleProp for a specific data item.
|
|
117
109
|
* If the value is a function, calls it with the data item.
|
|
118
110
|
* Otherwise returns the static value.
|
|
119
111
|
*/
|
|
120
|
-
export declare function resolveStyleProp<V, T>(value: StyleProp<V, T> | undefined, d: T): V | undefined;
|
|
112
|
+
export declare function resolveStyleProp<V, T>(value: StyleProp<V, T> | undefined, d: T, ...args: any[]): V | undefined;
|