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.
Files changed (65) 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/__screenshots__/chart.component-node.svelte.test.ts/ChartState-registerComponent-cleans-up-child-nodes-and-mark-registrations-when-components-unmount-1.png +0 -0
  42. 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
  43. package/dist/states/brush.svelte.d.ts +26 -17
  44. package/dist/states/brush.svelte.js +118 -25
  45. package/dist/states/brush.svelte.test.js +126 -1
  46. package/dist/states/chart.svelte.d.ts +6 -0
  47. package/dist/states/chart.svelte.js +93 -20
  48. package/dist/states/transform.svelte.js +3 -1
  49. package/dist/utils/dataProp.d.ts +2 -10
  50. package/dist/utils/dataProp.js +16 -5
  51. package/dist/utils/index.d.ts +1 -0
  52. package/dist/utils/index.js +1 -0
  53. package/dist/utils/motion.svelte.d.ts +12 -2
  54. package/dist/utils/motion.svelte.js +22 -0
  55. package/dist/utils/motion.test.js +49 -1
  56. package/dist/utils/rasterBounds.d.ts +18 -0
  57. package/dist/utils/rasterBounds.js +98 -0
  58. package/dist/utils/rasterBounds.test.d.ts +1 -0
  59. package/dist/utils/rasterBounds.test.js +63 -0
  60. package/dist/utils/scales.svelte.js +4 -2
  61. package/dist/utils/scales.svelte.test.d.ts +1 -0
  62. package/dist/utils/scales.svelte.test.js +67 -0
  63. package/dist/utils/ticks.js +7 -3
  64. package/dist/utils/ticks.test.js +13 -3
  65. 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
- 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;
@@ -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.xRangeProp,
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.yRangeProp,
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
- xRange = $derived(getRange(this.xScale));
793
- 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
+ });
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 baseMinX = +this._baseXDomain[0];
935
- const baseRangeX = +this._baseXDomain[1] - baseMinX;
936
- const brushMinX = +brushX[0];
937
- const brushRangeX = +brushX[1] - brushMinX;
938
- if (brushRangeX > 0 && baseRangeX > 0) {
939
- const newScale = baseRangeX / brushRangeX;
940
- const newTranslateX = -((brushMinX - baseMinX) / baseRangeX) * this.width * newScale;
941
- let newTranslateY = 0;
942
- if (axis === 'both' && brushY[0] != null && brushY[1] != null) {
943
- const baseMinY = +this._baseYDomain[0];
944
- const baseRangeY = +this._baseYDomain[1] - baseMinY;
945
- const brushMinY = +brushY[0];
946
- 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 });
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')
@@ -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;