layerchart 2.0.0-next.47 → 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/__fixtures__/ComponentNodeLifecycleChild.svelte +1 -1
- 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 +100 -21
- package/dist/states/chart.svelte.test.js +16 -1
- 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;
|
|
@@ -152,7 +157,8 @@ export class ChartState {
|
|
|
152
157
|
return explicit;
|
|
153
158
|
// Generate implicit series from registered marks.
|
|
154
159
|
// Use the value axis accessor (y for horizontal charts, x for vertical).
|
|
155
|
-
const valueAxis = this.
|
|
160
|
+
const valueAxis = this.valueAxis;
|
|
161
|
+
const chartValueProp = valueAxis === 'y' ? this.props.y : this.props.x;
|
|
156
162
|
const implicitSeries = [];
|
|
157
163
|
for (const { info } of this._markInfos) {
|
|
158
164
|
const valueAccessor = valueAxis === 'y' ? info.y : info.x;
|
|
@@ -160,6 +166,11 @@ export class ChartState {
|
|
|
160
166
|
(typeof valueAccessor === 'string' ? valueAccessor : undefined);
|
|
161
167
|
if (!key)
|
|
162
168
|
continue;
|
|
169
|
+
// Skip if the mark just reuses the chart's own axis accessor and has no
|
|
170
|
+
// separate data — it's not defining a new series, just using the chart's axis.
|
|
171
|
+
// Marks with their own data arrays are kept (multi-dataset scenario).
|
|
172
|
+
if (key === chartValueProp && !info.data)
|
|
173
|
+
continue;
|
|
163
174
|
if (implicitSeries.some((s) => s.key === key))
|
|
164
175
|
continue;
|
|
165
176
|
implicitSeries.push({
|
|
@@ -352,6 +363,27 @@ export class ChartState {
|
|
|
352
363
|
xRangeProp = $derived(this.props.xRange ? this.props.xRange : this.props.radial ? [0, 2 * Math.PI] : undefined);
|
|
353
364
|
yRangeProp = $derived(this.props.yRange ??
|
|
354
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
|
+
});
|
|
355
387
|
yReverse = $derived(!isScaleBand(this._yScaleProp) && !isScaleTime(this._yScaleProp));
|
|
356
388
|
resolveAccessor(axis) {
|
|
357
389
|
const axisAccessor = axis === 'x' ? this.props.x : this.props.y;
|
|
@@ -676,7 +708,7 @@ export class ChartState {
|
|
|
676
708
|
nice: this.xNice,
|
|
677
709
|
reverse: this.props.xReverse ?? false,
|
|
678
710
|
percentRange: this.props.percentRange ?? false,
|
|
679
|
-
range: this.
|
|
711
|
+
range: this._xScaleRange,
|
|
680
712
|
height: this.height,
|
|
681
713
|
width: this.width,
|
|
682
714
|
extents: this.snappedExtents,
|
|
@@ -689,7 +721,7 @@ export class ChartState {
|
|
|
689
721
|
nice: this.yNice,
|
|
690
722
|
reverse: this.yReverse,
|
|
691
723
|
percentRange: this.props.percentRange ?? false,
|
|
692
|
-
range: this.
|
|
724
|
+
range: this._yScaleRange,
|
|
693
725
|
height: this.height,
|
|
694
726
|
width: this.width,
|
|
695
727
|
extents: this.filteredExtents,
|
|
@@ -783,8 +815,25 @@ export class ChartState {
|
|
|
783
815
|
yDomainPossiblyNice = $derived(this.yScale.domain());
|
|
784
816
|
zDomainPossiblyNice = $derived(this.zScale.domain());
|
|
785
817
|
rDomainPossiblyNice = $derived(this.rScale.domain());
|
|
786
|
-
|
|
787
|
-
|
|
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
|
+
});
|
|
788
837
|
zRange = $derived(getRange(this.zScale));
|
|
789
838
|
rRange = $derived(getRange(this.rScale));
|
|
790
839
|
aspectRatio = $derived(this.width / this.height);
|
|
@@ -925,22 +974,52 @@ export class ChartState {
|
|
|
925
974
|
const brushX = brush.x;
|
|
926
975
|
const brushY = brush.y;
|
|
927
976
|
if ((axis === 'x' || axis === 'both') && brushX[0] != null && brushX[1] != null) {
|
|
928
|
-
const
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
const
|
|
934
|
-
const
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
const
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
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 });
|
|
941
1022
|
}
|
|
942
|
-
this.transform.setScale(newScale);
|
|
943
|
-
this.transform.setTranslate({ x: newTranslateX, y: newTranslateY });
|
|
944
1023
|
}
|
|
945
1024
|
}
|
|
946
1025
|
}
|
|
@@ -195,7 +195,6 @@ describe('ChartState mark registration', () => {
|
|
|
195
195
|
const { state, cleanup } = createChartState({
|
|
196
196
|
data: [{ date: '2024-01', value: 10 }],
|
|
197
197
|
x: 'date',
|
|
198
|
-
y: 'value',
|
|
199
198
|
});
|
|
200
199
|
try {
|
|
201
200
|
expect(state.seriesState.isDefaultSeries).toBe(true);
|
|
@@ -215,6 +214,22 @@ describe('ChartState mark registration', () => {
|
|
|
215
214
|
cleanup();
|
|
216
215
|
}
|
|
217
216
|
});
|
|
217
|
+
it('should not create implicit series when mark accessor matches chart accessor', () => {
|
|
218
|
+
const { state, cleanup } = createChartState({
|
|
219
|
+
data: [{ date: '2024-01', value: 10 }],
|
|
220
|
+
x: 'date',
|
|
221
|
+
y: 'value',
|
|
222
|
+
});
|
|
223
|
+
try {
|
|
224
|
+
// Mark with same y as chart — not a new series, just using chart's axis
|
|
225
|
+
state.registerMark({ y: 'value', color: 'red' });
|
|
226
|
+
flushSync();
|
|
227
|
+
expect(state.seriesState.isDefaultSeries).toBe(true);
|
|
228
|
+
}
|
|
229
|
+
finally {
|
|
230
|
+
cleanup();
|
|
231
|
+
}
|
|
232
|
+
});
|
|
218
233
|
it('should generate implicit series from marks with string y accessors', () => {
|
|
219
234
|
const data = [
|
|
220
235
|
{ date: '2024-01', apples: 10, bananas: 15 },
|