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
@@ -80,22 +80,33 @@ export function resolveGeoDataPair(xProp, yProp, d, projection, defaults = [0, 0
80
80
  * - `function`: called with data item, result passed through cScale.
81
81
  * - `undefined`/`null`: returns undefined.
82
82
  */
83
- export function resolveColorProp(value, d, cScale) {
83
+ /**
84
+ * Returns true if the string looks like a CSS color value rather than a data property name.
85
+ * Matches: `#hex`, and functional notation like `rgb(...)`, `hsl(...)`, `var(...)`,
86
+ * `url(...)`, `color-mix(...)`, etc.
87
+ */
88
+ function isCSSColor(value) {
89
+ return value.startsWith('#') || value.includes('(');
90
+ }
91
+ export function resolveColorProp(value, d, cScale, ...args) {
84
92
  if (value === undefined || value === null)
85
93
  return undefined;
86
94
  if (typeof value === 'function') {
87
- const rawValue = value(d);
95
+ const rawValue = value(d, ...args);
88
96
  if (rawValue === undefined || rawValue === null)
89
97
  return undefined;
90
98
  return cScale ? String(cScale(rawValue)) : String(rawValue);
91
99
  }
92
100
  if (typeof value === 'string') {
101
+ // CSS color literals are never data property lookups
102
+ if (isCSSColor(value))
103
+ return value;
93
104
  const dataValue = get(d, value);
94
105
  if (dataValue !== undefined) {
95
106
  // Data property — resolve through cScale
96
107
  return cScale ? String(cScale(dataValue)) : String(dataValue);
97
108
  }
98
- // Not a data property — literal CSS color
109
+ // Not a data property — treat as literal CSS color (e.g. named colors like 'red')
99
110
  return value;
100
111
  }
101
112
  return undefined;
@@ -105,10 +116,10 @@ export function resolveColorProp(value, d, cScale) {
105
116
  * If the value is a function, calls it with the data item.
106
117
  * Otherwise returns the static value.
107
118
  */
108
- export function resolveStyleProp(value, d) {
119
+ export function resolveStyleProp(value, d, ...args) {
109
120
  if (value === undefined)
110
121
  return undefined;
111
122
  if (typeof value === 'function')
112
- return value(d);
123
+ return value(d, ...args);
113
124
  return value;
114
125
  }
@@ -14,6 +14,7 @@ export * from './ticks.js';
14
14
  export * from './treemap.js';
15
15
  export * from './threshold.js';
16
16
  export * from './rasterInterpolate.js';
17
+ export * from './rasterBounds.js';
17
18
  export * from './stats.js';
18
19
  export * from './types.js';
19
20
  export * from './graph/dagre.js';
@@ -14,6 +14,7 @@ export * from './ticks.js';
14
14
  export * from './treemap.js';
15
15
  export * from './threshold.js';
16
16
  export * from './rasterInterpolate.js';
17
+ export * from './rasterBounds.js';
17
18
  export * from './stats.js';
18
19
  export * from './types.js';
19
20
  export * from './graph/dagre.js';
@@ -108,12 +108,22 @@ type InternalMotionOptions = {
108
108
  */
109
109
  controlled?: boolean;
110
110
  };
111
- export declare function createMotion<T = any>(initialValue: T, getValue: () => T, motionProp: MotionOptions | undefined, options?: InternalMotionOptions): MotionSpring<T> | MotionTween<any> | MotionNone<T>;
111
+ export declare function createMotion<T = any>(initialValue: T, getValue: () => T, motionProp: MotionOptions | undefined, options?: InternalMotionOptions): MotionSpring<T> | MotionTween<any> | MotionNone<T> | {
112
+ type: "none";
113
+ readonly current: T;
114
+ target: T;
115
+ set(_value: T, _options?: any): Promise<void>;
116
+ };
112
117
  /**
113
118
  * Creates a controlled motion state that only updates when explicitly set
114
119
  * rather than automatically tracking changes to the source value
115
120
  */
116
- export declare function createControlledMotion<T = any>(initialValue: T, motionProp: MotionOptions | undefined): MotionTween<any> | MotionSpring<T> | MotionNone<T>;
121
+ export declare function createControlledMotion<T = any>(initialValue: T, motionProp: MotionOptions | undefined): MotionTween<any> | MotionSpring<T> | MotionNone<T> | {
122
+ type: "none";
123
+ readonly current: T;
124
+ target: T;
125
+ set(_value: T, _options?: any): Promise<void>;
126
+ };
117
127
  /**
118
128
  * Creates a state tracker for animation completion
119
129
  * This helps track whether any motion transitions are currently in progress
@@ -74,6 +74,25 @@ function setupTracking(motion, getValue, options) {
74
74
  });
75
75
  }
76
76
  export function createMotion(initialValue, getValue, motionProp, options = {}) {
77
+ // Fast path: when no motion is configured, skip all state/effect overhead
78
+ // and return a lightweight passthrough that reads directly from the getter.
79
+ if (motionProp === undefined) {
80
+ return {
81
+ type: 'none',
82
+ get current() {
83
+ return getValue();
84
+ },
85
+ get target() {
86
+ return getValue();
87
+ },
88
+ set target(v) {
89
+ // no-op for passthrough
90
+ },
91
+ set(_value, _options) {
92
+ return Promise.resolve();
93
+ },
94
+ };
95
+ }
77
96
  const motion = parseMotionProp(motionProp);
78
97
  const motionState = motion.type === 'spring'
79
98
  ? new MotionSpring(initialValue, motion.options)
@@ -128,6 +147,9 @@ export function createMotionTracker() {
128
147
  * Returns null if no motion is configured (type: 'none').
129
148
  */
130
149
  export function createDataMotionMap(motionProp) {
150
+ // Fast path: skip parseMotionProp overhead when no motion is configured
151
+ if (motionProp === undefined)
152
+ return null;
131
153
  const config = parseMotionProp(motionProp);
132
154
  if (config.type === 'none')
133
155
  return null;
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { parseMotionProp, extractTweenConfig } from './motion.svelte.js';
2
+ import { createMotion, parseMotionProp, extractTweenConfig } from './motion.svelte.js';
3
3
  describe('parseMotionProp', () => {
4
4
  it('should return "none" type when config is undefined', () => {
5
5
  const result = parseMotionProp(undefined);
@@ -117,6 +117,54 @@ describe('parseMotionProp', () => {
117
117
  });
118
118
  });
119
119
  });
120
+ describe('createMotion', () => {
121
+ describe('fast path (motionProp === undefined)', () => {
122
+ it('should return a passthrough that reads from the getter', () => {
123
+ let value = 42;
124
+ const motion = createMotion(0, () => value, undefined);
125
+ expect(motion.current).toBe(42);
126
+ value = 99;
127
+ expect(motion.current).toBe(99);
128
+ });
129
+ it('should have type "none"', () => {
130
+ const motion = createMotion(0, () => 0, undefined);
131
+ expect(motion.type).toBe('none');
132
+ });
133
+ it('should return resolved promise from set()', async () => {
134
+ const motion = createMotion(0, () => 0, undefined);
135
+ const result = motion.set(10);
136
+ expect(result).toBeInstanceOf(Promise);
137
+ await expect(result).resolves.toBeUndefined();
138
+ });
139
+ it('target getter should read from getValue', () => {
140
+ let value = 5;
141
+ const motion = createMotion(0, () => value, undefined);
142
+ expect(motion.target).toBe(5);
143
+ value = 20;
144
+ expect(motion.target).toBe(20);
145
+ });
146
+ });
147
+ describe('with explicit "none" motion', () => {
148
+ it('should create a MotionNone state (not a passthrough)', () => {
149
+ const motion = createMotion(0, () => 42, 'none');
150
+ // MotionNone stores its own state, so current reflects the initial value
151
+ // until the tracking effect runs (which requires Svelte runtime)
152
+ expect(motion.type).toBe('none');
153
+ });
154
+ });
155
+ describe('with spring motion', () => {
156
+ it('should create a spring motion state', () => {
157
+ const motion = createMotion(0, () => 42, 'spring');
158
+ expect(motion.type).toBe('spring');
159
+ });
160
+ });
161
+ describe('with tween motion', () => {
162
+ it('should create a tween motion state', () => {
163
+ const motion = createMotion(0, () => 42, 'tween');
164
+ expect(motion.type).toBe('tween');
165
+ });
166
+ });
167
+ });
120
168
  describe('extractTweenConfig', () => {
121
169
  // Test case 1: Undefined input
122
170
  it('should return undefined when config is undefined', () => {
@@ -0,0 +1,18 @@
1
+ import type { InterpolateMethod } from './rasterInterpolate.js';
2
+ export type RasterBounds = {
3
+ x1: number;
4
+ y1: number;
5
+ x2: number;
6
+ y2: number;
7
+ };
8
+ export declare function resolveRasterBounds(width: number, height: number, x1?: number, y1?: number, x2?: number, y2?: number): RasterBounds;
9
+ export declare function gridPointToBounds(x: number, y: number, width: number, height: number, bounds: RasterBounds): {
10
+ x: number;
11
+ y: number;
12
+ };
13
+ export declare function gridCellCenterToBounds(column: number, row: number, width: number, height: number, bounds: RasterBounds): {
14
+ x: number;
15
+ y: number;
16
+ };
17
+ export declare function sampleGridAtBounds(values: ArrayLike<number>, width: number, height: number, bounds: RasterBounds, x: number, y: number, method?: InterpolateMethod): number;
18
+ export declare function blurGridIgnoringNaN(values: ArrayLike<number>, width: number, height: number, radius: number): Float64Array<ArrayBufferLike>;
@@ -0,0 +1,98 @@
1
+ import { blur2 } from 'd3-array';
2
+ export function resolveRasterBounds(width, height, x1, y1, x2, y2) {
3
+ return {
4
+ x1: x1 ?? 0,
5
+ y1: y1 ?? 0,
6
+ x2: x2 ?? width,
7
+ y2: y2 ?? height,
8
+ };
9
+ }
10
+ export function gridPointToBounds(x, y, width, height, bounds) {
11
+ return {
12
+ x: bounds.x1 + (x / width) * (bounds.x2 - bounds.x1),
13
+ y: bounds.y1 + (y / height) * (bounds.y2 - bounds.y1),
14
+ };
15
+ }
16
+ export function gridCellCenterToBounds(column, row, width, height, bounds) {
17
+ return {
18
+ x: bounds.x1 + ((column + 0.5) / width) * (bounds.x2 - bounds.x1),
19
+ y: bounds.y1 + ((row + 0.5) / height) * (bounds.y2 - bounds.y1),
20
+ };
21
+ }
22
+ export function sampleGridAtBounds(values, width, height, bounds, x, y, method = 'barycentric') {
23
+ const dx = bounds.x2 - bounds.x1;
24
+ const dy = bounds.y2 - bounds.y1;
25
+ if (!width || !height || dx === 0 || dy === 0)
26
+ return NaN;
27
+ const fx = ((x - bounds.x1) / dx) * width - 0.5;
28
+ const fy = ((y - bounds.y1) / dy) * height - 0.5;
29
+ if (fx < -0.5 || fx > width - 0.5 || fy < -0.5 || fy > height - 0.5) {
30
+ return NaN;
31
+ }
32
+ switch (method) {
33
+ case 'barycentric':
34
+ return sampleBilinear(values, width, height, fx, fy);
35
+ case 'nearest':
36
+ case 'none':
37
+ default:
38
+ return sampleNearest(values, width, height, fx, fy);
39
+ }
40
+ }
41
+ export function blurGridIgnoringNaN(values, width, height, radius) {
42
+ if (!radius || !values.length) {
43
+ return values instanceof Float64Array ? values : Float64Array.from(values);
44
+ }
45
+ const numerators = new Float64Array(values.length);
46
+ const weights = new Float64Array(values.length);
47
+ for (let index = 0; index < values.length; index++) {
48
+ const value = values[index] ?? NaN;
49
+ if (!Number.isFinite(value))
50
+ continue;
51
+ numerators[index] = value;
52
+ weights[index] = 1;
53
+ }
54
+ blur2({ data: numerators, width, height }, radius);
55
+ blur2({ data: weights, width, height }, radius);
56
+ const result = new Float64Array(values.length);
57
+ for (let index = 0; index < values.length; index++) {
58
+ const weight = weights[index];
59
+ result[index] = weight > 1e-12 ? numerators[index] / weight : NaN;
60
+ }
61
+ return result;
62
+ }
63
+ function sampleNearest(values, width, height, x, y) {
64
+ const column = clamp(Math.round(x), 0, width - 1);
65
+ const row = clamp(Math.round(y), 0, height - 1);
66
+ return values[row * width + column] ?? NaN;
67
+ }
68
+ function sampleBilinear(values, width, height, x, y) {
69
+ const x0 = Math.floor(x);
70
+ const y0 = Math.floor(y);
71
+ const x1 = x0 + 1;
72
+ const y1 = y0 + 1;
73
+ const tx = x - x0;
74
+ const ty = y - y0;
75
+ const weightedValues = [
76
+ [getSample(values, width, height, x0, y0), (1 - tx) * (1 - ty)],
77
+ [getSample(values, width, height, x1, y0), tx * (1 - ty)],
78
+ [getSample(values, width, height, x0, y1), (1 - tx) * ty],
79
+ [getSample(values, width, height, x1, y1), tx * ty],
80
+ ];
81
+ let totalWeight = 0;
82
+ let totalValue = 0;
83
+ for (const [value, weight] of weightedValues) {
84
+ if (!Number.isFinite(value))
85
+ continue;
86
+ totalWeight += weight;
87
+ totalValue += value * weight;
88
+ }
89
+ return totalWeight > 0 ? totalValue / totalWeight : NaN;
90
+ }
91
+ function getSample(values, width, height, x, y) {
92
+ const column = clamp(x, 0, width - 1);
93
+ const row = clamp(y, 0, height - 1);
94
+ return values[row * width + column] ?? NaN;
95
+ }
96
+ function clamp(value, min, max) {
97
+ return Math.max(min, Math.min(max, value));
98
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { blurGridIgnoringNaN, gridCellCenterToBounds, gridPointToBounds, resolveRasterBounds, sampleGridAtBounds, } from './rasterBounds.js';
3
+ describe('resolveRasterBounds', () => {
4
+ it('defaults to grid dimensions', () => {
5
+ expect(resolveRasterBounds(360, 180)).toEqual({
6
+ x1: 0,
7
+ y1: 0,
8
+ x2: 360,
9
+ y2: 180,
10
+ });
11
+ });
12
+ it('uses explicit geographic bounds', () => {
13
+ expect(resolveRasterBounds(360, 180, -180, 90, 180, -90)).toEqual({
14
+ x1: -180,
15
+ y1: 90,
16
+ x2: 180,
17
+ y2: -90,
18
+ });
19
+ });
20
+ });
21
+ describe('gridPointToBounds', () => {
22
+ it('maps contour-space coordinates back into bound coordinates', () => {
23
+ expect(gridPointToBounds(180, 90, 360, 180, resolveRasterBounds(360, 180, -180, 90, 180, -90))).toEqual({
24
+ x: 0,
25
+ y: 0,
26
+ });
27
+ });
28
+ });
29
+ describe('gridCellCenterToBounds', () => {
30
+ it('maps grid cell centers into bound coordinates', () => {
31
+ expect(gridCellCenterToBounds(0, 0, 360, 180, resolveRasterBounds(360, 180, -180, 90, 180, -90))).toEqual({
32
+ x: -179.5,
33
+ y: 89.5,
34
+ });
35
+ });
36
+ });
37
+ describe('sampleGridAtBounds', () => {
38
+ const grid = [0, 1, 2, 3];
39
+ const bounds = resolveRasterBounds(2, 2, -1, 1, 1, -1);
40
+ it('samples the nearest grid cell center', () => {
41
+ expect(sampleGridAtBounds(grid, 2, 2, bounds, -0.5, 0.5, 'nearest')).toBe(0);
42
+ expect(sampleGridAtBounds(grid, 2, 2, bounds, 0.5, -0.5, 'nearest')).toBe(3);
43
+ });
44
+ it('interpolates between adjacent cells', () => {
45
+ expect(sampleGridAtBounds(grid, 2, 2, bounds, 0, 0, 'barycentric')).toBe(1.5);
46
+ });
47
+ it('returns NaN outside the raster bounds', () => {
48
+ expect(sampleGridAtBounds(grid, 2, 2, bounds, 4, 4, 'barycentric')).toBeNaN();
49
+ });
50
+ });
51
+ describe('blurGridIgnoringNaN', () => {
52
+ it('preserves nearby finite values when blurring through NaN gaps', () => {
53
+ const blurred = blurGridIgnoringNaN([1, NaN, 3], 3, 1, 1);
54
+ expect(blurred[0]).toBeGreaterThan(0);
55
+ expect(blurred[1]).toBeGreaterThan(1);
56
+ expect(blurred[1]).toBeLessThan(3);
57
+ expect(blurred[2]).toBeGreaterThan(0);
58
+ });
59
+ it('keeps fully empty regions as NaN', () => {
60
+ const blurred = blurGridIgnoringNaN([NaN, NaN, NaN], 3, 1, 1);
61
+ expect(Array.from(blurred).every((value) => Number.isNaN(value))).toBe(true);
62
+ });
63
+ });
@@ -60,9 +60,11 @@ export function createMotionScale(scale, motion, options) {
60
60
  export function scaleBandInvert(scale) {
61
61
  const domain = scale.domain();
62
62
  const eachBand = scale.step();
63
- const paddingOuter = eachBand * (scale.paddingOuter?.() ?? scale.padding()); // `scaleBand` uses paddingOuter(), while `scalePoint` uses padding() for outer paddding - https://github.com/d3/d3-scale#point_padding
63
+ const rangeStart = scale.range()[0];
64
+ const paddingOuter = scale.paddingOuter?.() ?? scale.padding(); // `scaleBand` uses paddingOuter(), while `scalePoint` uses padding() for outer paddding - https://github.com/d3/d3-scale#point_padding
64
65
  return function (value) {
65
- const index = Math.floor((value - paddingOuter / 2) / eachBand);
66
+ // band[i] = rangeStart + step * (paddingOuter + i), so: i = (value - rangeStart) / step - paddingOuter
67
+ const index = Math.floor((value - rangeStart) / eachBand - paddingOuter);
66
68
  return domain[Math.max(0, Math.min(index, domain.length - 1))];
67
69
  };
68
70
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,67 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { scaleBand } from 'd3-scale';
3
+ import { scaleBandInvert, scaleInvert } from './scales.svelte.js';
4
+ describe('scaleBandInvert', () => {
5
+ const domain = ['A', 'B', 'C', 'D', 'E'];
6
+ it('should return correct category for standard range [0, width]', () => {
7
+ const scale = scaleBand().domain(domain).range([0, 500]).padding(0.1);
8
+ const invert = scaleBandInvert(scale);
9
+ // Each category should map back from its center
10
+ for (const category of domain) {
11
+ const x = scale(category) + scale.bandwidth() / 2;
12
+ expect(invert(x)).toBe(category);
13
+ }
14
+ });
15
+ it('should clamp to first category for values before range', () => {
16
+ const scale = scaleBand().domain(domain).range([0, 500]).padding(0.1);
17
+ const invert = scaleBandInvert(scale);
18
+ expect(invert(-50)).toBe('A');
19
+ });
20
+ it('should clamp to last category for values after range', () => {
21
+ const scale = scaleBand().domain(domain).range([0, 500]).padding(0.1);
22
+ const invert = scaleBandInvert(scale);
23
+ expect(invert(600)).toBe('E');
24
+ });
25
+ it('should handle offset range (non-zero start)', () => {
26
+ // Simulates a zoomed band scale where the range is shifted
27
+ const scale = scaleBand().domain(domain).range([-200, 800]).padding(0.1);
28
+ const invert = scaleBandInvert(scale);
29
+ // Each category should still map correctly
30
+ for (const category of domain) {
31
+ const x = scale(category) + scale.bandwidth() / 2;
32
+ expect(invert(x)).toBe(category);
33
+ }
34
+ });
35
+ it('should return correct category at viewport edges with offset range', () => {
36
+ // Range [-400, 600] means categories span 1000px but viewport is [0, ~500]
37
+ const scale = scaleBand().domain(domain).range([-400, 600]).padding(0.4);
38
+ const invert = scaleBandInvert(scale);
39
+ // At x=0, we should get whichever category is nearest that pixel position
40
+ const categoryAtZero = invert(0);
41
+ expect(domain).toContain(categoryAtZero);
42
+ // The returned category should be close to x=0 (within one step)
43
+ const pos = scale(categoryAtZero);
44
+ expect(Math.abs(pos)).toBeLessThan(scale.step());
45
+ });
46
+ it('should work with no padding', () => {
47
+ const scale = scaleBand().domain(domain).range([0, 500]);
48
+ const invert = scaleBandInvert(scale);
49
+ // bandwidth = 100, each category is 100px wide
50
+ expect(invert(50)).toBe('A');
51
+ expect(invert(150)).toBe('B');
52
+ expect(invert(450)).toBe('E');
53
+ });
54
+ it('should work with single-element domain', () => {
55
+ const scale = scaleBand().domain(['X']).range([0, 500]).padding(0.1);
56
+ const invert = scaleBandInvert(scale);
57
+ expect(invert(250)).toBe('X');
58
+ expect(invert(0)).toBe('X');
59
+ });
60
+ });
61
+ describe('scaleInvert', () => {
62
+ it('should use scaleBandInvert for band scales', () => {
63
+ const scale = scaleBand().domain(['A', 'B', 'C']).range([0, 300]).padding(0.1);
64
+ const center = scale('B') + scale.bandwidth() / 2;
65
+ expect(scaleInvert(scale, center)).toBe('B');
66
+ });
67
+ });
@@ -126,9 +126,13 @@ export function autoTickVals(scale, ticks, count) {
126
126
  }
127
127
  // Band (use domain)
128
128
  if (isScaleBand(scale)) {
129
- return ticks && typeof ticks === 'number'
130
- ? scale.domain().filter((_, i) => i % ticks === 0)
131
- : scale.domain();
129
+ const domain = scale.domain();
130
+ const resolvedCount = typeof ticks === 'number' ? ticks : count;
131
+ if (resolvedCount != null && resolvedCount < domain.length) {
132
+ const step = Math.max(1, Math.ceil(domain.length / resolvedCount));
133
+ return domain.filter((_, i) => i % step === 0);
134
+ }
135
+ return domain;
132
136
  }
133
137
  // Ticks from scale
134
138
  if (scale.ticks && typeof scale.ticks === 'function') {
@@ -27,11 +27,21 @@ describe('autoTickVals', () => {
27
27
  const scale = { ticks: mockTicksFn };
28
28
  expect(autoTickVals(scale, ticksConfig)).toEqual([]);
29
29
  });
30
- it('filters band scale domain with number ticks', () => {
30
+ it('filters band scale domain with explicit number ticks', () => {
31
31
  const scale = { domain: mockDomain, bandwidth: vi.fn() };
32
- expect(autoTickVals(scale, 2)).toEqual(['a', 'c', 'e']);
32
+ // ticks=2, domain has 5 items → step = ceil(5/2) = 3 → indices 0, 3
33
+ expect(autoTickVals(scale, 2)).toEqual(['a', 'd']);
33
34
  });
34
- it('returns full domain for band scale without ticks', () => {
35
+ it('filters band scale domain using count parameter (from tickSpacing)', () => {
36
+ const scale = { domain: mockDomain, bandwidth: vi.fn() };
37
+ // count=3, domain has 5 items → step = ceil(5/3) = 2 → indices 0, 2, 4
38
+ expect(autoTickVals(scale, undefined, 3)).toEqual(['a', 'c', 'e']);
39
+ });
40
+ it('returns full domain for band scale when count >= domain length', () => {
41
+ const scale = { domain: mockDomain, bandwidth: vi.fn() };
42
+ expect(autoTickVals(scale, undefined, 10)).toEqual(['a', 'b', 'c', 'd', 'e']);
43
+ });
44
+ it('returns full domain for band scale without ticks or count', () => {
35
45
  const scale = { domain: mockDomain, bandwidth: vi.fn() };
36
46
  expect(autoTickVals(scale)).toEqual(['a', 'b', 'c', 'd', 'e']);
37
47
  });
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "license": "MIT",
6
6
  "repository": "techniq/layerchart",
7
7
  "homepage": "https://layerchart.com",
8
- "version": "2.0.0-next.48",
8
+ "version": "2.0.0-next.49",
9
9
  "devDependencies": {
10
10
  "@changesets/cli": "^2.30.0",
11
11
  "@sveltejs/adapter-auto": "^7.0.1",
@@ -111,7 +111,8 @@
111
111
  "check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
112
112
  "test:unit": "TZ=UTC-5 vitest",
113
113
  "test:ui": "TZ=UTC-5 vitest --ui",
114
- "bench": "pnpm bench:linechart && pnpm bench:composable && pnpm bench:svg-vs-canvas",
114
+ "bench": "pnpm bench:primitives && pnpm bench:linechart && pnpm bench:composable && pnpm bench:svg-vs-canvas",
115
+ "bench:primitives": "TZ=UTC-5 vitest bench --project bench src/lib/bench/primitives.svelte.bench.ts",
115
116
  "bench:linechart": "TZ=UTC-5 vitest bench --project bench src/lib/components/charts/LineChart.svelte.bench.ts",
116
117
  "bench:composable": "TZ=UTC-5 vitest bench --project bench src/lib/bench/composable-vs-linechart.svelte.bench.ts",
117
118
  "bench:svg-vs-canvas": "TZ=UTC-5 vitest bench --project bench src/lib/bench/svg-vs-canvas.svelte.bench.ts",