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.
Files changed (67) hide show
  1. package/dist/bench/PrimitiveBench.svelte +66 -0
  2. package/dist/bench/PrimitiveBench.svelte.d.ts +10 -0
  3. package/dist/bench/primitives.svelte.bench.d.ts +1 -0
  4. package/dist/bench/primitives.svelte.bench.js +42 -0
  5. package/dist/components/Axis.svelte +14 -3
  6. package/dist/components/Axis.svelte.d.ts +1 -1
  7. package/dist/components/Chart.svelte +110 -12
  8. package/dist/components/Circle.svelte +20 -17
  9. package/dist/components/Contour.svelte +90 -13
  10. package/dist/components/Contour.svelte.d.ts +8 -0
  11. package/dist/components/Ellipse.svelte +18 -16
  12. package/dist/components/GeoPath.svelte +1 -1
  13. package/dist/components/Group.svelte +14 -12
  14. package/dist/components/Image.svelte +18 -16
  15. package/dist/components/Labels.svelte +56 -11
  16. package/dist/components/Labels.svelte.d.ts +3 -2
  17. package/dist/components/Line.svelte +18 -16
  18. package/dist/components/LinearGradient.svelte +1 -1
  19. package/dist/components/Marker.svelte +8 -3
  20. package/dist/components/Marker.svelte.d.ts +1 -1
  21. package/dist/components/Month.svelte +273 -0
  22. package/dist/components/Month.svelte.d.ts +70 -0
  23. package/dist/components/Path.svelte +28 -12
  24. package/dist/components/Polygon.svelte +25 -23
  25. package/dist/components/RadialGradient.svelte +1 -1
  26. package/dist/components/Raster.svelte +117 -29
  27. package/dist/components/Raster.svelte.d.ts +8 -0
  28. package/dist/components/Rect.svelte +26 -20
  29. package/dist/components/Spline.svelte +123 -25
  30. package/dist/components/Spline.svelte.d.ts +18 -1
  31. package/dist/components/Text.svelte +45 -20
  32. package/dist/components/Text.svelte.d.ts +6 -0
  33. package/dist/components/TransformContext.svelte +8 -0
  34. package/dist/components/TransformContext.svelte.test.d.ts +1 -0
  35. package/dist/components/TransformContext.svelte.test.js +166 -0
  36. package/dist/components/Vector.svelte +14 -12
  37. package/dist/components/index.d.ts +2 -0
  38. package/dist/components/index.js +2 -0
  39. package/dist/components/tests/TransformTestHarness.svelte +27 -0
  40. package/dist/components/tests/TransformTestHarness.svelte.d.ts +8 -0
  41. package/dist/states/__fixtures__/ComponentNodeLifecycleChild.svelte +1 -1
  42. 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
  43. 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
  44. package/dist/states/brush.svelte.d.ts +26 -17
  45. package/dist/states/brush.svelte.js +118 -25
  46. package/dist/states/brush.svelte.test.js +126 -1
  47. package/dist/states/chart.svelte.d.ts +6 -0
  48. package/dist/states/chart.svelte.js +100 -21
  49. package/dist/states/chart.svelte.test.js +16 -1
  50. package/dist/states/transform.svelte.js +3 -1
  51. package/dist/utils/dataProp.d.ts +2 -10
  52. package/dist/utils/dataProp.js +16 -5
  53. package/dist/utils/index.d.ts +1 -0
  54. package/dist/utils/index.js +1 -0
  55. package/dist/utils/motion.svelte.d.ts +12 -2
  56. package/dist/utils/motion.svelte.js +22 -0
  57. package/dist/utils/motion.test.js +49 -1
  58. package/dist/utils/rasterBounds.d.ts +18 -0
  59. package/dist/utils/rasterBounds.js +98 -0
  60. package/dist/utils/rasterBounds.test.d.ts +1 -0
  61. package/dist/utils/rasterBounds.test.js +63 -0
  62. package/dist/utils/scales.svelte.js +4 -2
  63. package/dist/utils/scales.svelte.test.d.ts +1 -0
  64. package/dist/utils/scales.svelte.test.js +67 -0
  65. package/dist/utils/ticks.js +7 -3
  66. package/dist/utils/ticks.test.js +13 -3
  67. package/package.json +3 -2
@@ -6,7 +6,7 @@
6
6
  ctx.registerComponent({
7
7
  name: 'ComponentNodeLifecycleChild',
8
8
  kind: 'mark',
9
- markInfo: () => ({ y: 'value' }),
9
+ markInfo: () => ({ y: 'other' }),
10
10
  });
11
11
  </script>
12
12
 
@@ -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
- yScale: (v: any) => number;
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: number;
60
- y: number;
68
+ x: any;
69
+ y: any;
61
70
  }, currentValue: {
62
- x: number;
63
- y: number;
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: [number, number];
68
- y: [number, number];
76
+ x: [any, any];
77
+ y: [any, any];
69
78
  value: {
70
- x: number;
71
- y: number;
79
+ x: any;
80
+ y: any;
72
81
  };
73
82
  }, currentValue: {
74
- x: number;
75
- y: number;
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: [number, number];
80
- y: [number, number];
88
+ x: [any, any];
89
+ y: [any, any];
81
90
  }, currentValue: {
82
- x: number;
83
- y: number;
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()[1];
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()[1];
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 top = this.ctx.yScale(this.y?.[1]);
36
- const bottom = this.ctx.yScale(this.y?.[0]);
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.x = [
76
- clamp(min([startValue.x, currentValue.x]), this.xDomainMin, this.xDomainMax),
77
- clamp(max([startValue.x, currentValue.x]), this.xDomainMin, this.xDomainMax),
78
- ];
79
- this.y = [
80
- clamp(min([startValue.y, currentValue.y]), this.yDomainMin, this.yDomainMax),
81
- clamp(max([startValue.y, currentValue.y]), this.yDomainMin, this.yDomainMax),
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 dx = clamp(currentValue.x - start.value.x, this.xDomainMin - +start.x[0], this.xDomainMax - +start.x[1]);
87
- this.x = [add(start.x[0], dx), add(start.x[1], dx)];
88
- const dy = clamp(currentValue.y - start.value.y, this.yDomainMin - +start.y[0], this.yDomainMax - +start.y[1]);
89
- this.y = [add(start.y[0], dy), add(start.y[1], dy)];
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
- clamp(currentValue.y < +start.y[0] ? currentValue.y : start.y[0], this.yDomainMin, this.yDomainMax),
97
- clamp(currentValue.y < +start.y[0] ? start.y[0] : currentValue.y, this.yDomainMin, this.yDomainMax),
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
- clamp(currentValue.y > +start.y[1] ? start.y[1] : currentValue.y, this.yDomainMin, this.yDomainMax),
103
- clamp(currentValue.y > +start.y[1] ? currentValue.y : start.y[1], this.yDomainMin, this.yDomainMax),
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
- clamp(currentValue.x > +start.x[1] ? start.x[1] : currentValue.x, this.xDomainMin, this.xDomainMax),
109
- clamp(currentValue.x > +start.x[1] ? currentValue.x : start.x[1], this.xDomainMin, this.xDomainMax),
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
- clamp(currentValue.x < +start.x[0] ? currentValue.x : start.x[0], this.xDomainMin, this.xDomainMax),
115
- clamp(currentValue.x < +start.x[0] ? start.x[0] : currentValue.x, this.xDomainMin, this.xDomainMax),
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
- return untrack(() => this.registerMark(markInfo()));
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.props.valueAxis ?? 'y';
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.xRangeProp,
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.yRangeProp,
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
- xRange = $derived(getRange(this.xScale));
787
- yRange = $derived(getRange(this.yScale));
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 baseMinX = +this._baseXDomain[0];
929
- const baseRangeX = +this._baseXDomain[1] - baseMinX;
930
- const brushMinX = +brushX[0];
931
- const brushRangeX = +brushX[1] - brushMinX;
932
- if (brushRangeX > 0 && baseRangeX > 0) {
933
- const newScale = baseRangeX / brushRangeX;
934
- const newTranslateX = -((brushMinX - baseMinX) / baseRangeX) * this.width * newScale;
935
- let newTranslateY = 0;
936
- if (axis === 'both' && brushY[0] != null && brushY[1] != null) {
937
- const baseMinY = +this._baseYDomain[0];
938
- const baseRangeY = +this._baseYDomain[1] - baseMinY;
939
- const brushMinY = +brushY[0];
940
- newTranslateY = -((brushMinY - baseMinY) / baseRangeY) * this.height * newScale;
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 },