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.
- package/dist/components/BrushContext.svelte +55 -2
- package/dist/components/BrushContext.svelte.d.ts +25 -1
- package/dist/components/BrushContext.svelte.test.js +1 -1
- package/dist/components/Chart/Chart.base.svelte +15 -0
- package/dist/components/Chart/ChartCore.svelte.test.js +9 -0
- package/dist/components/Path/Path.svg.svelte +7 -1
- package/dist/components/geo/GeoPoint/GeoPoint.base.svelte +2 -2
- package/dist/states/brush.svelte.d.ts +53 -1
- package/dist/states/brush.svelte.js +196 -1
- package/dist/states/brush.svelte.test.js +118 -0
- package/dist/utils/debug.js +2 -1
- package/dist/utils/stack.js +5 -7
- package/package.json +18 -18
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
<script lang="ts" module>
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
3
|
|
|
4
|
-
import {
|
|
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, {
|
|
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
|
|
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={
|
|
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}
|
|
@@ -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'];
|
package/dist/utils/debug.js
CHANGED
|
@@ -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
|
-
|
|
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', ' ');
|
package/dist/utils/stack.js
CHANGED
|
@@ -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.
|
|
8
|
+
"version": "2.0.0-next.66",
|
|
9
9
|
"devDependencies": {
|
|
10
|
-
"@changesets/cli": "^2.
|
|
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.
|
|
14
|
-
"@sveltejs/package": "^2.5.
|
|
15
|
-
"@sveltejs/vite-plugin-svelte": "^7.
|
|
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.
|
|
37
|
-
"@vitest/browser-playwright": "^4.1.
|
|
38
|
-
"@vitest/ui": "^4.1.
|
|
39
|
-
"playwright": "^1.
|
|
40
|
-
"prettier": "^3.8.
|
|
41
|
-
"prettier-plugin-svelte": "^
|
|
42
|
-
"svelte": "5.
|
|
43
|
-
"svelte-check": "^4.
|
|
44
|
-
"svelte2tsx": "^0.7.
|
|
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.
|
|
47
|
-
"vite": "^8.0.
|
|
48
|
-
"vitest": "^4.1.
|
|
49
|
-
"vitest-browser-svelte": "^2.1.
|
|
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": [
|