layerchart 2.0.0-next.64 → 2.0.0-next.66

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.
@@ -1,7 +1,12 @@
1
1
  <script lang="ts" module>
2
2
  import type { Snippet } from 'svelte';
3
3
 
4
- import { BrushState, type BrushDomainType } from '../states/brush.svelte.js';
4
+ import {
5
+ BrushState,
6
+ type BrushDomainType,
7
+ type BrushExtent,
8
+ type BrushSelection,
9
+ } from '../states/brush.svelte.js';
5
10
 
6
11
  type BrushEventPayload = {
7
12
  brush: BrushState;
@@ -41,6 +46,34 @@
41
46
  */
42
47
  y?: BrushDomainType;
43
48
 
49
+ /**
50
+ * Minimum selection size per axis. In domain units for continuous scales (e.g. milliseconds
51
+ * for time scales), or number of categories for band/point scales.
52
+ */
53
+ minExtent?: BrushExtent;
54
+
55
+ /**
56
+ * Maximum selection size per axis, e.g. `{ x: 30 * 24 * 60 * 60 * 1000 }` to cap a time-scale
57
+ * brush at 30 days. In domain units for continuous scales, or number of categories for band scales.
58
+ */
59
+ maxExtent?: BrushExtent;
60
+
61
+ /**
62
+ * Custom constraint function, called after `min/maxExtent` on every selection update. Receives
63
+ * the candidate `{ x, y }` domain selection and returns a corrected one (e.g. snapping edges to
64
+ * boundaries). Mirrors `TransformContext`'s `constrain`.
65
+ */
66
+ constrain?: (selection: BrushSelection) => BrushSelection;
67
+
68
+ /**
69
+ * Keep the selection within the domain extent. Pointer gestures already clamp to the domain;
70
+ * this additionally clamps `constrain` output (e.g. a snap that rounds past the first/last
71
+ * value). Set `false` to allow `constrain` to place edges outside the domain.
72
+ *
73
+ * @default true
74
+ */
75
+ constrainToDomain?: boolean;
76
+
44
77
  /**
45
78
  * Disable brush
46
79
  *
@@ -108,6 +141,10 @@
108
141
  handleSize = 5,
109
142
  clickToReset = true,
110
143
  disabled = false,
144
+ minExtent,
145
+ maxExtent,
146
+ constrain,
147
+ constrainToDomain = true,
111
148
  range = {},
112
149
  handle = {},
113
150
  classes = {},
@@ -119,13 +156,29 @@
119
156
 
120
157
  let rootEl = $state<HTMLElement>();
121
158
 
122
- const brushState = new BrushState(ctx, { x, y, axis });
159
+ const brushState = new BrushState(ctx, {
160
+ x,
161
+ y,
162
+ axis,
163
+ minExtent,
164
+ maxExtent,
165
+ constrain,
166
+ constrainToDomain,
167
+ });
123
168
  stateProp = brushState;
124
169
 
125
170
  $effect(() => {
126
171
  brushState.handleSize = handleSize;
127
172
  });
128
173
 
174
+ // Keep constraint config in sync when props change reactively
175
+ $effect(() => {
176
+ brushState.minExtent = minExtent;
177
+ brushState.maxExtent = maxExtent;
178
+ brushState.constrain = constrain;
179
+ brushState.constrainToDomain = constrainToDomain;
180
+ });
181
+
129
182
  const logger = new Logger('BrushContext');
130
183
  const RESET_THRESHOLD = 1; // size of pointer delta to ignore
131
184
 
@@ -1,5 +1,5 @@
1
1
  import type { Snippet } from 'svelte';
2
- import { BrushState, type BrushDomainType } from '../states/brush.svelte.js';
2
+ import { BrushState, type BrushDomainType, type BrushExtent, type BrushSelection } from '../states/brush.svelte.js';
3
3
  type BrushEventPayload = {
4
4
  brush: BrushState;
5
5
  };
@@ -32,6 +32,30 @@ type BrushContextPropsWithoutHTML = {
32
32
  * When provided, the brush reactively updates to reflect this value.
33
33
  */
34
34
  y?: BrushDomainType;
35
+ /**
36
+ * Minimum selection size per axis. In domain units for continuous scales (e.g. milliseconds
37
+ * for time scales), or number of categories for band/point scales.
38
+ */
39
+ minExtent?: BrushExtent;
40
+ /**
41
+ * Maximum selection size per axis, e.g. `{ x: 30 * 24 * 60 * 60 * 1000 }` to cap a time-scale
42
+ * brush at 30 days. In domain units for continuous scales, or number of categories for band scales.
43
+ */
44
+ maxExtent?: BrushExtent;
45
+ /**
46
+ * Custom constraint function, called after `min/maxExtent` on every selection update. Receives
47
+ * the candidate `{ x, y }` domain selection and returns a corrected one (e.g. snapping edges to
48
+ * boundaries). Mirrors `TransformContext`'s `constrain`.
49
+ */
50
+ constrain?: (selection: BrushSelection) => BrushSelection;
51
+ /**
52
+ * Keep the selection within the domain extent. Pointer gestures already clamp to the domain;
53
+ * this additionally clamps `constrain` output (e.g. a snap that rounds past the first/last
54
+ * value). Set `false` to allow `constrain` to place edges outside the domain.
55
+ *
56
+ * @default true
57
+ */
58
+ constrainToDomain?: boolean;
35
59
  /**
36
60
  * Disable brush
37
61
  *
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import { render } from 'vitest-browser-svelte';
3
3
  import { tick } from 'svelte';
4
- import Chart from "./Chart/Chart.svelte";
4
+ import Chart from './Chart/Chart.svelte';
5
5
  import BrushTestHarness from './tests/BrushTestHarness.svelte';
6
6
  const data = [
7
7
  { x: 0, y: 10 },
@@ -535,4 +535,19 @@
535
535
  .lc-root-container :global(*) {
536
536
  box-sizing: border-box;
537
537
  }
538
+
539
+ /*
540
+ * Treat the chart as an interactive widget rather than selectable text: dragging to brush,
541
+ * pan, or zoom should never select axis labels or surrounding page content. Text selection
542
+ * anchors at the pointerdown target, so disabling it on the root (it inherits to descendants)
543
+ * stops any in-chart gesture from starting a selection.
544
+ *
545
+ * Overridable via the `--lc-user-select` custom property — set it to `text` (on the chart, an
546
+ * ancestor, or a subtree) to re-enable selection where needed. Individual selectable regions
547
+ * can also just set `user-select: text` on themselves (it overrides the inherited value).
548
+ */
549
+ .lc-root-container {
550
+ -webkit-user-select: var(--lc-user-select, none);
551
+ user-select: var(--lc-user-select, none);
552
+ }
538
553
  </style>
@@ -47,4 +47,13 @@ describe('ChartCore', () => {
47
47
  await tick();
48
48
  expect(container.querySelector('.lc-root-container')).not.toBeNull();
49
49
  });
50
+ it('should disable text selection on the root container', async () => {
51
+ const { container } = render(ChartCoreTestHarness, {
52
+ chartProps: { data, x: 'x', y: 'y', height: 200 },
53
+ });
54
+ await tick();
55
+ // Charts are interactive widgets — dragging (brush/pan/zoom) must not select text.
56
+ const root = container.querySelector('.lc-root-container');
57
+ expect(getComputedStyle(root).userSelect).toBe('none');
58
+ });
50
59
  });
@@ -64,6 +64,12 @@
64
64
  const drawTransition = $derived(draw ? _drawTransition : () => ({}));
65
65
  let startPoint = $state<DOMPoint | undefined>();
66
66
 
67
+ // Compute the class string here rather than inline in the `class={...}`
68
+ // attribute: a TS cast in markup survives into `dist` and breaks tooling that
69
+ // parses class expressions independently of Svelte (e.g. @unocss/svelte-scoped,
70
+ // whose acorn pass chokes on the `as` keyword).
71
+ const pathClass = $derived(cls('lc-path', classProp as string | undefined));
72
+
67
73
  const endPointDuration = $derived.by(() => {
68
74
  if (
69
75
  typeof draw === 'object' &&
@@ -123,7 +129,7 @@
123
129
  stroke-opacity={strokeOpacityProp}
124
130
  stroke-width={strokeWidthProp}
125
131
  opacity={opacityProp}
126
- class={cls('lc-path', classProp as string | undefined)}
132
+ class={pathClass}
127
133
  marker-start={markerStartId ? `url(#${markerStartId})` : undefined}
128
134
  marker-mid={markerMidId ? `url(#${markerMidId})` : undefined}
129
135
  marker-end={markerEndId ? `url(#${markerEndId})` : undefined}
@@ -48,8 +48,8 @@
48
48
  <Group
49
49
  {x}
50
50
  {y}
51
- opacity={opacity as number}
52
- class={className as string}
51
+ {opacity}
52
+ class={className}
53
53
  {...extractLayerProps(restProps, 'lc-geo-point-group')}
54
54
  >
55
55
  {@render children({ x, y })}
@@ -10,6 +10,16 @@ export type BrushRange = {
10
10
  width: number;
11
11
  height: number;
12
12
  };
13
+ /** Minimum/maximum selection size, per axis. */
14
+ export type BrushExtent = {
15
+ x?: number;
16
+ y?: number;
17
+ };
18
+ /** Candidate brush selection passed to a custom `constrain` function. */
19
+ export type BrushSelection = {
20
+ x: BrushDomainType;
21
+ y: BrushDomainType;
22
+ };
13
23
  /**
14
24
  * Minimal interface for the chart context that BrushState depends on.
15
25
  * Narrowed from ChartState to only what brush needs, enabling easier testing.
@@ -37,11 +47,37 @@ export declare class BrushState {
37
47
  active: boolean | undefined;
38
48
  axis: "x" | "y" | "both";
39
49
  handleSize: number;
50
+ /**
51
+ * Minimum selection size per axis. In domain units for continuous scales (e.g. milliseconds
52
+ * for time scales), or number of categories for band/point scales.
53
+ */
54
+ minExtent: BrushExtent | undefined;
55
+ /**
56
+ * Maximum selection size per axis. In domain units for continuous scales (e.g. milliseconds
57
+ * for time scales), or number of categories for band/point scales.
58
+ */
59
+ maxExtent: BrushExtent | undefined;
60
+ /**
61
+ * Custom constraint function, called after `min/maxExtent` on every selection update
62
+ * (create/resize/move/programmatic). Return corrected `{ x, y }` domain values — e.g. to snap
63
+ * edges to boundaries. Mirrors `TransformState.constrain`.
64
+ */
65
+ constrain: ((selection: BrushSelection) => BrushSelection) | undefined;
66
+ /**
67
+ * Keep the selection within the domain extent. Enabled by default — pointer gestures already
68
+ * clamp to the domain, so this additionally clamps `constrain` output (e.g. a snap that rounds
69
+ * past the first/last value). Set `false` to allow `constrain` to place edges outside the domain.
70
+ */
71
+ constrainToDomain: boolean;
40
72
  constructor(ctx: typeof this.ctx, options?: {
41
73
  x?: BrushDomainType;
42
74
  y?: BrushDomainType;
43
75
  active?: boolean;
44
76
  axis?: 'x' | 'y' | 'both';
77
+ minExtent?: BrushExtent;
78
+ maxExtent?: BrushExtent;
79
+ constrain?: (selection: BrushSelection) => BrushSelection;
80
+ constrainToDomain?: boolean;
45
81
  });
46
82
  /** The domain extent bounds from the base (unzoomed) scales */
47
83
  get xDomainMin(): any;
@@ -56,7 +92,7 @@ export declare class BrushState {
56
92
  };
57
93
  /** Reset brush to cleared state */
58
94
  reset(): void;
59
- /** Select the full domain extent */
95
+ /** Select the full domain extent (capped by `maxExtent` from the domain start, if set) */
60
96
  selectAll(): void;
61
97
  /** Programmatically set the brush selection. Like d3's `brush.move()`. */
62
98
  move(selection: {
@@ -91,6 +127,22 @@ export declare class BrushState {
91
127
  x: any;
92
128
  y: any;
93
129
  }): void;
130
+ /** Which endpoint (if any) equals `anchorValue` and should be held fixed while clamping extent. */
131
+ private _anchorIndex;
132
+ /**
133
+ * Clamp one axis' selection to `min/maxExtent`, holding the endpoint at `anchorValue` fixed
134
+ * (the edge the user isn't dragging). A `null` anchor shrinks/grows symmetrically about the
135
+ * center. Extent is measured in domain units (continuous) or category count (band/point).
136
+ */
137
+ private _clampExtent;
138
+ /** Clamp both endpoints of one axis to the domain extent. */
139
+ private _clampToDomain;
140
+ /**
141
+ * Apply extent limits (for each axis whose anchor is provided), then the custom `constrain`,
142
+ * then re-clamp to the domain (unless `constrainToDomain` is disabled). `anchor.x`/`anchor.y`
143
+ * is the domain value to hold fixed; `null` = symmetric. Mirrors `TransformState._applyConstraints`.
144
+ */
145
+ private _applyConstraints;
94
146
  /**
95
147
  * Sync external domain values into brush state.
96
148
  * Only writes when values actually differ to avoid reactive loops.
@@ -47,12 +47,38 @@ export class BrushState {
47
47
  active = $state();
48
48
  axis = $state('x');
49
49
  handleSize = $state(0);
50
+ /**
51
+ * Minimum selection size per axis. In domain units for continuous scales (e.g. milliseconds
52
+ * for time scales), or number of categories for band/point scales.
53
+ */
54
+ minExtent = $state();
55
+ /**
56
+ * Maximum selection size per axis. In domain units for continuous scales (e.g. milliseconds
57
+ * for time scales), or number of categories for band/point scales.
58
+ */
59
+ maxExtent = $state();
60
+ /**
61
+ * Custom constraint function, called after `min/maxExtent` on every selection update
62
+ * (create/resize/move/programmatic). Return corrected `{ x, y }` domain values — e.g. to snap
63
+ * edges to boundaries. Mirrors `TransformState.constrain`.
64
+ */
65
+ constrain = $state();
66
+ /**
67
+ * Keep the selection within the domain extent. Enabled by default — pointer gestures already
68
+ * clamp to the domain, so this additionally clamps `constrain` output (e.g. a snap that rounds
69
+ * past the first/last value). Set `false` to allow `constrain` to place edges outside the domain.
70
+ */
71
+ constrainToDomain = $state(true);
50
72
  constructor(ctx, options) {
51
73
  this.ctx = ctx;
52
74
  this.x = options?.x ?? [null, null];
53
75
  this.y = options?.y ?? [null, null];
54
76
  this.active = options?.active;
55
77
  this.axis = options?.axis ?? 'x';
78
+ this.minExtent = options?.minExtent;
79
+ this.maxExtent = options?.maxExtent;
80
+ this.constrain = options?.constrain;
81
+ this.constrainToDomain = options?.constrainToDomain ?? true;
56
82
  }
57
83
  /** The domain extent bounds from the base (unzoomed) scales */
58
84
  get xDomainMin() {
@@ -90,11 +116,12 @@ export class BrushState {
90
116
  this.x = [null, null];
91
117
  this.y = [null, null];
92
118
  }
93
- /** Select the full domain extent */
119
+ /** Select the full domain extent (capped by `maxExtent` from the domain start, if set) */
94
120
  selectAll() {
95
121
  this.active = true;
96
122
  this.x = [this.xDomainMin, this.xDomainMax];
97
123
  this.y = [this.yDomainMin, this.yDomainMax];
124
+ this._applyConstraints({ x: this.xDomainMin, y: this.yDomainMin });
98
125
  }
99
126
  /** Programmatically set the brush selection. Like d3's `brush.move()`. */
100
127
  move(selection) {
@@ -104,6 +131,8 @@ export class BrushState {
104
131
  if ('y' in selection) {
105
132
  this.y = selection.y ?? [null, null];
106
133
  }
134
+ // Hold each selection's start edge fixed while enforcing extent limits / `constrain`.
135
+ this._applyConstraints({ x: this.x[0], y: this.y[0] });
107
136
  // Determine active state from current values
108
137
  const hasX = this.x[0] != null && this.x[1] != null;
109
138
  const hasY = this.y[0] != null && this.y[1] != null;
@@ -138,6 +167,8 @@ export class BrushState {
138
167
  clamp(max([startValue.y, currentValue.y]), this.yDomainMin, this.yDomainMax),
139
168
  ];
140
169
  }
170
+ // Hold the drag origin fixed and pull the moving edge back to satisfy the extent limits.
171
+ this._applyConstraints({ x: startValue.x, y: startValue.y });
141
172
  }
142
173
  /** Move the entire brush range by a delta, clamped to domain bounds */
143
174
  moveRange(start, currentValue) {
@@ -167,6 +198,8 @@ export class BrushState {
167
198
  const dy = clamp(currentValue.y - start.value.y, this.yDomainMin - +start.y[0], this.yDomainMax - +start.y[1]);
168
199
  this.y = [add(start.y[0], dy), add(start.y[1], dy)];
169
200
  }
201
+ // Panning preserves width (extent limits are a no-op), but still run `constrain` (e.g. snapping).
202
+ this._applyConstraints({ x: null, y: null });
170
203
  }
171
204
  /** Adjust a single edge of the brush, clamped to domain bounds. Handles inversion if dragged past opposite edge. */
172
205
  adjustEdge(edge, start, currentValue) {
@@ -210,6 +243,168 @@ export class BrushState {
210
243
  ];
211
244
  break;
212
245
  }
246
+ // Hold the opposite (undragged) edge fixed while enforcing the extent limits.
247
+ switch (edge) {
248
+ case 'left':
249
+ this._applyConstraints({ x: start.x[1] });
250
+ break;
251
+ case 'right':
252
+ this._applyConstraints({ x: start.x[0] });
253
+ break;
254
+ case 'top':
255
+ this._applyConstraints({ y: start.y[0] });
256
+ break;
257
+ case 'bottom':
258
+ this._applyConstraints({ y: start.y[1] });
259
+ break;
260
+ }
261
+ }
262
+ /** Which endpoint (if any) equals `anchorValue` and should be held fixed while clamping extent. */
263
+ _anchorIndex(values, anchorValue) {
264
+ if (anchorValue == null)
265
+ return null;
266
+ const a = anchorValue?.valueOf?.() ?? anchorValue;
267
+ if ((values[0]?.valueOf?.() ?? values[0]) === a)
268
+ return 0;
269
+ if ((values[1]?.valueOf?.() ?? values[1]) === a)
270
+ return 1;
271
+ return null;
272
+ }
273
+ /**
274
+ * Clamp one axis' selection to `min/maxExtent`, holding the endpoint at `anchorValue` fixed
275
+ * (the edge the user isn't dragging). A `null` anchor shrinks/grows symmetrically about the
276
+ * center. Extent is measured in domain units (continuous) or category count (band/point).
277
+ */
278
+ _clampExtent(axis, values, anchorValue) {
279
+ const minExt = this.minExtent?.[axis];
280
+ const maxExt = this.maxExtent?.[axis];
281
+ if ((minExt == null && maxExt == null) || values[0] == null || values[1] == null) {
282
+ return values;
283
+ }
284
+ const domain = (axis === 'x' ? this.ctx?.baseXScale : this.ctx?.baseYScale)?.domain() ?? [];
285
+ const domainMin = axis === 'x' ? this.xDomainMin : this.yDomainMin;
286
+ const domainMax = axis === 'x' ? this.xDomainMax : this.yDomainMax;
287
+ const anchor = this._anchorIndex(values, anchorValue);
288
+ if (isCategoricalDomain(domain)) {
289
+ let startIdx = domain.indexOf(values[0]);
290
+ let endIdx = domain.indexOf(values[1]);
291
+ if (startIdx === -1 || endIdx === -1)
292
+ return values;
293
+ if (startIdx > endIdx)
294
+ [startIdx, endIdx] = [endIdx, startIdx];
295
+ const count = endIdx - startIdx + 1;
296
+ if (maxExt != null && count > maxExt) {
297
+ if (anchor === 1)
298
+ startIdx = endIdx - (maxExt - 1);
299
+ else if (anchor === 0)
300
+ endIdx = startIdx + (maxExt - 1);
301
+ else {
302
+ const trim = count - maxExt;
303
+ startIdx += Math.ceil(trim / 2);
304
+ endIdx -= Math.floor(trim / 2);
305
+ }
306
+ }
307
+ else if (minExt != null && count < minExt) {
308
+ if (anchor === 1)
309
+ startIdx = endIdx - (minExt - 1);
310
+ else if (anchor === 0)
311
+ endIdx = startIdx + (minExt - 1);
312
+ else {
313
+ const grow = minExt - count;
314
+ startIdx -= Math.floor(grow / 2);
315
+ endIdx += Math.ceil(grow / 2);
316
+ }
317
+ }
318
+ // Shift back inside [0, lastIdx] if growth pushed past an edge.
319
+ const lastIdx = domain.length - 1;
320
+ if (startIdx < 0) {
321
+ endIdx += -startIdx;
322
+ startIdx = 0;
323
+ }
324
+ if (endIdx > lastIdx) {
325
+ startIdx -= endIdx - lastIdx;
326
+ endIdx = lastIdx;
327
+ }
328
+ startIdx = Math.max(0, startIdx);
329
+ endIdx = Math.min(lastIdx, endIdx);
330
+ return [domain[startIdx], domain[endIdx]];
331
+ }
332
+ // Continuous scale — measure width as a numeric/Date difference.
333
+ let lo = values[0];
334
+ let hi = values[1];
335
+ let width = +hi - +lo;
336
+ if (maxExt != null && width > maxExt) {
337
+ if (anchor === 1)
338
+ lo = add(hi, -maxExt);
339
+ else if (anchor === 0)
340
+ hi = add(lo, maxExt);
341
+ else {
342
+ const trim = (width - maxExt) / 2;
343
+ lo = add(lo, trim);
344
+ hi = add(hi, -trim);
345
+ }
346
+ }
347
+ else if (minExt != null && width < minExt) {
348
+ if (anchor === 1)
349
+ lo = add(hi, -minExt);
350
+ else if (anchor === 0)
351
+ hi = add(lo, minExt);
352
+ else {
353
+ const grow = (minExt - width) / 2;
354
+ lo = add(lo, -grow);
355
+ hi = add(hi, grow);
356
+ }
357
+ // Growth can overrun the domain — shift the window back inside, then hard-clamp.
358
+ if (domainMin != null && +lo < +domainMin) {
359
+ hi = add(hi, +domainMin - +lo);
360
+ lo = domainMin;
361
+ }
362
+ if (domainMax != null && +hi > +domainMax) {
363
+ lo = add(lo, +domainMax - +hi);
364
+ hi = domainMax;
365
+ }
366
+ lo = clamp(lo, domainMin, domainMax);
367
+ hi = clamp(hi, domainMin, domainMax);
368
+ }
369
+ return [lo, hi];
370
+ }
371
+ /** Clamp both endpoints of one axis to the domain extent. */
372
+ _clampToDomain(axis, values) {
373
+ if (values[0] == null || values[1] == null)
374
+ return values;
375
+ const domain = (axis === 'x' ? this.ctx?.baseXScale : this.ctx?.baseYScale)?.domain() ?? [];
376
+ const domainMin = axis === 'x' ? this.xDomainMin : this.yDomainMin;
377
+ const domainMax = axis === 'x' ? this.xDomainMax : this.yDomainMax;
378
+ if (isCategoricalDomain(domain)) {
379
+ return [
380
+ clampByIndex(values[0], domainMin, domainMax, domain),
381
+ clampByIndex(values[1], domainMin, domainMax, domain),
382
+ ];
383
+ }
384
+ return [clamp(values[0], domainMin, domainMax), clamp(values[1], domainMin, domainMax)];
385
+ }
386
+ /**
387
+ * Apply extent limits (for each axis whose anchor is provided), then the custom `constrain`,
388
+ * then re-clamp to the domain (unless `constrainToDomain` is disabled). `anchor.x`/`anchor.y`
389
+ * is the domain value to hold fixed; `null` = symmetric. Mirrors `TransformState._applyConstraints`.
390
+ */
391
+ _applyConstraints(anchor) {
392
+ if ('x' in anchor && this.x[0] != null && this.x[1] != null) {
393
+ this.x = this._clampExtent('x', [this.x[0], this.x[1]], anchor.x);
394
+ }
395
+ if ('y' in anchor && this.y[0] != null && this.y[1] != null) {
396
+ this.y = this._clampExtent('y', [this.y[0], this.y[1]], anchor.y);
397
+ }
398
+ if (this.constrain) {
399
+ const constrained = this.constrain({ x: this.x, y: this.y });
400
+ this.x = constrained.x;
401
+ this.y = constrained.y;
402
+ }
403
+ // `constrain` output isn't bounded — keep the selection within the domain by default.
404
+ if (this.constrainToDomain) {
405
+ this.x = this._clampToDomain('x', this.x);
406
+ this.y = this._clampToDomain('y', this.y);
407
+ }
213
408
  }
214
409
  /**
215
410
  * Sync external domain values into brush state.
@@ -388,6 +388,124 @@ describe('BrushState', () => {
388
388
  expect(brush.x).toEqual(['D', 'E']);
389
389
  });
390
390
  });
391
+ describe('constraints (min/maxExtent + constrain)', () => {
392
+ const categories = ['A', 'B', 'C', 'D', 'E'];
393
+ function createBandCtx() {
394
+ return {
395
+ xScale: Object.assign((v) => categories.indexOf(v) * 100, { bandwidth: () => 80 }),
396
+ yScale: (v) => v,
397
+ baseXScale: { domain: () => categories },
398
+ baseYScale: { domain: () => [0, 100] },
399
+ width: 500,
400
+ height: 300,
401
+ };
402
+ }
403
+ it('leaves the selection untouched when no constraints are set', () => {
404
+ const ctx = createMockCtx({ xDomain: [0, 100] });
405
+ const brush = new BrushState(ctx, { axis: 'x' });
406
+ brush.setRange({ x: 10, y: 0 }, { x: 90, y: 0 });
407
+ expect(brush.x).toEqual([10, 90]);
408
+ });
409
+ it('caps a fresh draw to maxExtent, anchored to the drag origin', () => {
410
+ const ctx = createMockCtx({ xDomain: [0, 100] });
411
+ const brush = new BrushState(ctx, { axis: 'x', maxExtent: { x: 30 } });
412
+ brush.setRange({ x: 10, y: 0 }, { x: 90, y: 0 });
413
+ expect(brush.x).toEqual([10, 40]);
414
+ });
415
+ it('caps a right-edge resize to maxExtent, holding the left edge fixed', () => {
416
+ const ctx = createMockCtx({ xDomain: [0, 100] });
417
+ const brush = new BrushState(ctx, { axis: 'x', maxExtent: { x: 30 }, x: [20, 40] });
418
+ brush.adjustEdge('right', { x: [20, 40], y: [0, 100] }, { x: 90, y: 50 });
419
+ expect(brush.x).toEqual([20, 50]);
420
+ });
421
+ it('caps a left-edge resize to maxExtent, holding the right edge fixed', () => {
422
+ const ctx = createMockCtx({ xDomain: [0, 100] });
423
+ const brush = new BrushState(ctx, { axis: 'x', maxExtent: { x: 30 }, x: [50, 80] });
424
+ brush.adjustEdge('left', { x: [50, 80], y: [0, 100] }, { x: 10, y: 50 });
425
+ expect(brush.x).toEqual([50, 80]);
426
+ });
427
+ it('grows a too-small resize to minExtent, holding the dragged-from edge fixed', () => {
428
+ const ctx = createMockCtx({ xDomain: [0, 100] });
429
+ const brush = new BrushState(ctx, { axis: 'x', minExtent: { x: 20 }, x: [20, 60] });
430
+ brush.adjustEdge('right', { x: [20, 60], y: [0, 100] }, { x: 25, y: 50 });
431
+ expect(brush.x).toEqual([20, 40]);
432
+ });
433
+ it('shifts a minExtent selection back inside the domain when growth overruns the edge', () => {
434
+ const ctx = createMockCtx({ xDomain: [0, 100] });
435
+ const brush = new BrushState(ctx, { axis: 'x', minExtent: { x: 30 }, x: [90, 95] });
436
+ brush.adjustEdge('right', { x: [90, 95], y: [0, 100] }, { x: 96, y: 50 });
437
+ expect(brush.x).toEqual([70, 100]);
438
+ });
439
+ it('does not shrink the window while panning (extent is a no-op for moveRange)', () => {
440
+ const ctx = createMockCtx({ xDomain: [0, 100] });
441
+ const brush = new BrushState(ctx, { axis: 'x', maxExtent: { x: 30 }, x: [20, 40] });
442
+ brush.moveRange({ x: [20, 40], y: [0, 100], value: { x: 30, y: 50 } }, { x: 50, y: 50 });
443
+ expect(brush.x).toEqual([40, 60]);
444
+ });
445
+ it('caps selectAll to maxExtent from the domain start', () => {
446
+ const ctx = createMockCtx({ xDomain: [0, 100] });
447
+ const brush = new BrushState(ctx, { axis: 'x', maxExtent: { x: 30 } });
448
+ brush.selectAll();
449
+ expect(brush.x).toEqual([0, 30]);
450
+ });
451
+ it('applies a custom constrain function (e.g. snapping)', () => {
452
+ const ctx = createMockCtx({ xDomain: [0, 100] });
453
+ const snap10 = (sel) => ({
454
+ x: [Math.round(sel.x[0] / 10) * 10, Math.round(sel.x[1] / 10) * 10],
455
+ y: sel.y,
456
+ });
457
+ const brush = new BrushState(ctx, { axis: 'x', constrain: snap10 });
458
+ brush.setRange({ x: 12, y: 0 }, { x: 47, y: 0 });
459
+ expect(brush.x).toEqual([10, 50]);
460
+ });
461
+ it('runs constrain after maxExtent', () => {
462
+ const ctx = createMockCtx({ xDomain: [0, 100] });
463
+ const snap10 = (sel) => ({
464
+ x: [Math.round(sel.x[0] / 10) * 10, Math.round(sel.x[1] / 10) * 10],
465
+ y: sel.y,
466
+ });
467
+ const brush = new BrushState(ctx, { axis: 'x', maxExtent: { x: 30 }, constrain: snap10 });
468
+ brush.setRange({ x: 11, y: 0 }, { x: 88, y: 0 });
469
+ // capped to [11, 41] by maxExtent, then snapped to [10, 40]
470
+ expect(brush.x).toEqual([10, 40]);
471
+ });
472
+ it('clamps constrain output back into the domain by default (constrainToDomain)', () => {
473
+ const ctx = createMockCtx({ xDomain: [0, 100] });
474
+ // constrain pushes the end past the domain max
475
+ const overshoot = (sel) => ({ x: [sel.x[0], 130], y: sel.y });
476
+ const brush = new BrushState(ctx, { axis: 'x', constrain: overshoot });
477
+ brush.setRange({ x: 20, y: 0 }, { x: 60, y: 0 });
478
+ expect(brush.x).toEqual([20, 100]);
479
+ });
480
+ it('allows out-of-domain constrain output when constrainToDomain is false', () => {
481
+ const ctx = createMockCtx({ xDomain: [0, 100] });
482
+ const overshoot = (sel) => ({ x: [sel.x[0], 130], y: sel.y });
483
+ const brush = new BrushState(ctx, {
484
+ axis: 'x',
485
+ constrain: overshoot,
486
+ constrainToDomain: false,
487
+ });
488
+ brush.setRange({ x: 20, y: 0 }, { x: 60, y: 0 });
489
+ expect(brush.x).toEqual([20, 130]);
490
+ });
491
+ it('measures maxExtent as a category count for band scales', () => {
492
+ const brush = new BrushState(createBandCtx(), { axis: 'x', maxExtent: { x: 2 } });
493
+ brush.setRange({ x: 'A', y: 0 }, { x: 'E', y: 0 });
494
+ expect(brush.x).toEqual(['A', 'B']);
495
+ });
496
+ it('constrains a Date-domain time brush to a max window', () => {
497
+ const start = new Date('2024-01-01');
498
+ const end = new Date('2025-01-01');
499
+ const ctx = createMockCtx();
500
+ ctx.baseXScale = { domain: () => [start, end] };
501
+ const day = 24 * 60 * 60 * 1000;
502
+ const brush = new BrushState(ctx, { axis: 'x', maxExtent: { x: 30 * day } });
503
+ const origin = new Date('2024-06-01');
504
+ brush.setRange({ x: origin, y: 0 }, { x: new Date('2024-10-01'), y: 0 });
505
+ expect(brush.x[0]).toEqual(origin);
506
+ expect(brush.x[1].getTime() - origin.getTime()).toBe(30 * day);
507
+ });
508
+ });
391
509
  });
392
510
  describe('expandBandBrushDomain', () => {
393
511
  const baseDomain = ['A', 'B', 'C', 'D', 'E'];
@@ -52,7 +52,8 @@ function colorizeArray(arr) {
52
52
  function printScale(s, scale, acc) {
53
53
  const scaleName = findScaleName(scale);
54
54
  console.log(`${indent}${s}:`);
55
- console.log(`${indent}${indent}Accessor: "${acc.toString()}"`);
55
+ if (acc != null)
56
+ console.log(`${indent}${indent}Accessor: "${acc.toString()}"`);
56
57
  console.log(`${indent}${indent}Type: ${scaleName}`);
57
58
  printValues(scale, 'domain');
58
59
  printValues(scale, 'range', ' ');
@@ -1,5 +1,5 @@
1
1
  import { flatGroup, group, max, rollup, sum } from 'd3-array';
2
- import { stack } from 'd3-shape';
2
+ import { stack, stackOffsetNone, stackOrderNone } from 'd3-shape';
3
3
  import { pivotWider } from './pivot.js';
4
4
  export function groupStackData(data, options) {
5
5
  const dataByKey = group(data, (d) => d[options.xKey]);
@@ -13,12 +13,11 @@ export function groupStackData(data, options) {
13
13
  const stackKeys = [
14
14
  ...new Set(groupData.map((d) => d[options.stackBy ?? ''])),
15
15
  ];
16
- // @ts-expect-error
17
16
  const stackData = stack()
18
17
  .keys(stackKeys)
19
18
  .value((d, key) => d[key] ?? 0)
20
- .order(options.order)
21
- .offset(options.offset)(pivotData);
19
+ .order(options.order ?? stackOrderNone)
20
+ .offset(options.offset ?? stackOffsetNone)(pivotData);
22
21
  return stackData.flatMap((series) => {
23
22
  return series.flatMap((s) => {
24
23
  const keys = {
@@ -46,12 +45,11 @@ export function groupStackData(data, options) {
46
45
  const pivotData = pivotWider(data, options.xKey, options.stackBy, 'value');
47
46
  // @ts-expect-error
48
47
  const stackKeys = [...new Set(data.map((d) => d[options.stackBy ?? '']))];
49
- // @ts-expect-error
50
48
  const stackData = stack()
51
49
  .keys(stackKeys)
52
50
  .value((d, key) => d[key] ?? 0)
53
- .order(options.order)
54
- .offset(options.offset)(pivotData);
51
+ .order(options.order ?? stackOrderNone)
52
+ .offset(options.offset ?? stackOffsetNone)(pivotData);
55
53
  const result = stackData.flatMap((series) => {
56
54
  return series.flatMap((s) => {
57
55
  const keys = {
package/package.json CHANGED
@@ -5,14 +5,14 @@
5
5
  "license": "MIT",
6
6
  "repository": "techniq/layerchart",
7
7
  "homepage": "https://layerchart.com",
8
- "version": "2.0.0-next.64",
8
+ "version": "2.0.0-next.66",
9
9
  "devDependencies": {
10
- "@changesets/cli": "^2.30.0",
10
+ "@changesets/cli": "^2.31.0",
11
11
  "@napi-rs/canvas": "^0.1.97",
12
12
  "@sveltejs/adapter-auto": "^7.0.1",
13
- "@sveltejs/kit": "^2.57.1",
14
- "@sveltejs/package": "^2.5.7",
15
- "@sveltejs/vite-plugin-svelte": "^7.0.0",
13
+ "@sveltejs/kit": "^2.62.0",
14
+ "@sveltejs/package": "^2.5.8",
15
+ "@sveltejs/vite-plugin-svelte": "^7.1.2",
16
16
  "@svitejs/changesets-changelog-github-compact": "^1.2.0",
17
17
  "@types/d3": "^7.4.3",
18
18
  "@types/d3-array": "^3.2.2",
@@ -33,20 +33,20 @@
33
33
  "@types/d3-scale-chromatic": "^3.1.0",
34
34
  "@types/d3-shape": "^3.1.8",
35
35
  "@types/d3-time": "^3.0.4",
36
- "@vitest/browser": "^4.1.4",
37
- "@vitest/browser-playwright": "^4.1.4",
38
- "@vitest/ui": "^4.1.4",
39
- "playwright": "^1.59.1",
40
- "prettier": "^3.8.2",
41
- "prettier-plugin-svelte": "^3.5.1",
42
- "svelte": "5.55.3",
43
- "svelte-check": "^4.4.6",
44
- "svelte2tsx": "^0.7.53",
36
+ "@vitest/browser": "^4.1.8",
37
+ "@vitest/browser-playwright": "^4.1.8",
38
+ "@vitest/ui": "^4.1.8",
39
+ "playwright": "^1.60.0",
40
+ "prettier": "^3.8.3",
41
+ "prettier-plugin-svelte": "^4.1.0",
42
+ "svelte": "5.56.1",
43
+ "svelte-check": "^4.5.0",
44
+ "svelte2tsx": "^0.7.56",
45
45
  "tslib": "^2.8.1",
46
- "typescript": "^6.0.2",
47
- "vite": "^8.0.8",
48
- "vitest": "^4.1.4",
49
- "vitest-browser-svelte": "^2.1.0"
46
+ "typescript": "^6.0.3",
47
+ "vite": "^8.0.16",
48
+ "vitest": "^4.1.8",
49
+ "vitest-browser-svelte": "^2.1.1"
50
50
  },
51
51
  "type": "module",
52
52
  "sideEffects": [