layerchart 0.92.0 → 0.93.0

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.
@@ -0,0 +1,476 @@
1
+ <script lang="ts" context="module">
2
+ import { getContext, setContext } from 'svelte';
3
+ import { writable, type Readable } from 'svelte/store';
4
+
5
+ export const brushContextKey = Symbol();
6
+
7
+ export type BrushContextValue = {
8
+ xDomain: DomainType;
9
+ yDomain: DomainType;
10
+ isActive: boolean;
11
+ range: {
12
+ x: number;
13
+ y: number;
14
+ width: number;
15
+ height: number;
16
+ };
17
+ handleSize: number;
18
+ };
19
+
20
+ export type BrushContext = Readable<BrushContextValue>;
21
+
22
+ const defaultContext: BrushContext = writable({
23
+ xDomain: null,
24
+ yDomain: null,
25
+ isActive: false,
26
+ range: {
27
+ x: 0,
28
+ y: 0,
29
+ width: 0,
30
+ height: 0,
31
+ },
32
+ handleSize: 0,
33
+ });
34
+ export function brushContext() {
35
+ return getContext<BrushContext>(brushContextKey) ?? defaultContext;
36
+ }
37
+
38
+ function setBrushContext(brush: BrushContext) {
39
+ setContext(brushContextKey, brush);
40
+ }
41
+ </script>
42
+
43
+ <script lang="ts">
44
+ import { extent, min, max } from 'd3-array';
45
+ import { clamp } from '@layerstack/utils';
46
+ import { cls } from '@layerstack/tailwind';
47
+ import { Logger } from '@layerstack/utils';
48
+
49
+ import { chartContext } from './ChartContext.svelte';
50
+
51
+ import { localPoint } from '../utils/event.js';
52
+ import type { DomainType } from '../utils/scales.js';
53
+ import { add } from '../utils/math.js';
54
+ import type { HTMLAttributes } from 'svelte/elements';
55
+
56
+ const { xScale, yScale, width, height, padding, containerWidth, containerHeight, config } =
57
+ chartContext();
58
+
59
+ /** Axis to apply brushing */
60
+ export let axis: 'x' | 'y' | 'both' = 'x';
61
+
62
+ /** Size of draggable handles (width/height) */
63
+ export let handleSize = 5;
64
+
65
+ /** Only show range while actively brushing. Useful with `brushEnd` event */
66
+ export let resetOnEnd = false;
67
+
68
+ export let xDomain: DomainType = $xScale.domain() as [number, number];
69
+ export let yDomain: DomainType = $yScale.domain() as [number, number];
70
+
71
+ /** Mode of operation
72
+ * `integrated`: use with single chart
73
+ * `separated`: use with separate (typically smaller) chart and state can be managed externally (sync with other charts, etc). Show active selection when domain does not equal original
74
+ */
75
+ export let mode: 'integrated' | 'separated' = 'integrated';
76
+
77
+ /** Disable brush */
78
+ export let disabled = false;
79
+
80
+ // Capture original domains for reset()
81
+ const originalXDomain = $config.xDomain;
82
+ const originalYDomain = $config.yDomain;
83
+
84
+ $: [xDomainMin, xDomainMax] = extent<number>($xScale.domain()) as [number, number];
85
+ $: [yDomainMin, yDomainMax] = extent<number>($yScale.domain()) as [number, number];
86
+
87
+ /** Attributes passed to range <div> element */
88
+ export let range: Partial<HTMLAttributes<HTMLDivElement>> | undefined = undefined;
89
+
90
+ /** Attributes passed to handle <div> elements */
91
+ export let handle: Partial<HTMLAttributes<HTMLDivElement>> | undefined = undefined;
92
+
93
+ export let classes: {
94
+ root?: string;
95
+ frame?: string;
96
+ range?: string;
97
+ handle?: string;
98
+ labels?: string;
99
+ } = {};
100
+
101
+ export let onchange: (detail: { xDomain?: DomainType; yDomain?: DomainType }) => void = () => {};
102
+ export let onbrushstart: (detail: {
103
+ xDomain?: DomainType;
104
+ yDomain?: DomainType;
105
+ }) => void = () => {};
106
+ export let onbrushend: (detail: {
107
+ xDomain?: DomainType;
108
+ yDomain?: DomainType;
109
+ }) => void = () => {};
110
+ export let onreset: (detail: { xDomain?: DomainType; yDomain?: DomainType }) => void = () => {};
111
+
112
+ /** Exposed to allow binding in Chart */
113
+ export let brush = writable<BrushContextValue>({
114
+ xDomain: null,
115
+ yDomain: null,
116
+ isActive: false,
117
+ range: {
118
+ x: 0,
119
+ y: 0,
120
+ width: 0,
121
+ height: 0,
122
+ },
123
+ handleSize: 0,
124
+ });
125
+ setBrushContext(brush);
126
+
127
+ let rootEl: HTMLDivElement;
128
+
129
+ const logger = new Logger('BrushContext');
130
+ const RESET_THRESHOLD = 1; // size of pointer delta to ignore
131
+
132
+ function handler(
133
+ fn: (
134
+ start: {
135
+ xDomain: [number, number];
136
+ yDomain: [number, number];
137
+ value: { x: number; y: number };
138
+ },
139
+ value: { x: number; y: number }
140
+ ) => void
141
+ ) {
142
+ return (e: PointerEvent) => {
143
+ logger.debug('drag start');
144
+ e.stopPropagation();
145
+
146
+ const startPoint = localPoint(rootEl, e);
147
+ const start = {
148
+ xDomain: [xDomain?.[0] ?? xDomainMin, xDomain?.[1] ?? xDomainMax] as [number, number],
149
+ yDomain: [yDomain?.[0] ?? yDomainMin, yDomain?.[1] ?? yDomainMax] as [number, number],
150
+ value: {
151
+ x: $xScale.invert?.(startPoint?.x ?? 0),
152
+ y: $yScale.invert?.(startPoint?.y ?? 0),
153
+ },
154
+ };
155
+
156
+ onbrushstart({ xDomain, yDomain });
157
+
158
+ const onPointerMove = (e: PointerEvent) => {
159
+ const currentPoint = localPoint(rootEl, e);
160
+ fn(start, {
161
+ x: $xScale.invert?.(currentPoint?.x ?? 0),
162
+ y: $yScale.invert?.(currentPoint?.y ?? 0),
163
+ });
164
+
165
+ onchange({ xDomain, yDomain });
166
+ };
167
+
168
+ const onPointerUp = (e: PointerEvent) => {
169
+ const currentPoint = localPoint(rootEl, e);
170
+ const xPointDelta = Math.abs((startPoint?.x ?? 0) - (currentPoint?.x ?? 0));
171
+ const yPointDelta = Math.abs((startPoint?.y ?? 0) - (currentPoint?.y ?? 0));
172
+
173
+ // Is click on frame (i.e. not on the `.range` or `.handle`)
174
+ const isClickOutside = !Array.from((e.target as Element).classList).some((cls) =>
175
+ ['range', 'handle'].includes(cls)
176
+ );
177
+
178
+ if (
179
+ (isClickOutside && xPointDelta < RESET_THRESHOLD && yPointDelta < RESET_THRESHOLD) ||
180
+ _range.width < RESET_THRESHOLD ||
181
+ _range.height < RESET_THRESHOLD
182
+ ) {
183
+ // Clicked on frame, or pointer delta was <1
184
+ logger.debug('resetting due to frame click');
185
+ reset();
186
+ onchange({ xDomain, yDomain });
187
+ } else {
188
+ logger.debug('drag end', {
189
+ target: e.target,
190
+ xPointDelta,
191
+ yPointDelta,
192
+ rangeWidth: _range.width,
193
+ rangeHeight: _range.height,
194
+ });
195
+ }
196
+
197
+ onbrushend({ xDomain, yDomain });
198
+
199
+ if (resetOnEnd) {
200
+ reset();
201
+ }
202
+
203
+ window.removeEventListener('pointermove', onPointerMove);
204
+ window.removeEventListener('pointerup', onPointerUp);
205
+ };
206
+
207
+ window.addEventListener('pointermove', onPointerMove);
208
+ window.addEventListener('pointerup', onPointerUp);
209
+ };
210
+ }
211
+
212
+ const createRange = handler((start, value) => {
213
+ logger.debug('createRange');
214
+ isActive = true;
215
+
216
+ xDomain = [
217
+ // @ts-expect-error
218
+ clamp(min([start.value.x, value.x]), xDomainMin, xDomainMax),
219
+ // @ts-expect-error
220
+ clamp(max([start.value.x, value.x]), xDomainMin, xDomainMax),
221
+ ];
222
+ // xDomain = [start.value.x, value.x];
223
+
224
+ yDomain = [
225
+ // @ts-expect-error
226
+ clamp(min([start.value.y, value.y]), yDomainMin, yDomainMax),
227
+ // @ts-expect-error
228
+ clamp(max([start.value.y, value.y]), yDomainMin, yDomainMax),
229
+ ];
230
+ });
231
+
232
+ const adjustRange = handler((start, value) => {
233
+ logger.debug('adjustRange');
234
+ const dx = clamp(
235
+ value.x - start.value.x,
236
+ xDomainMin - start.xDomain[0],
237
+ xDomainMax - start.xDomain[1]
238
+ );
239
+ xDomain = [add(start.xDomain[0], dx), add(start.xDomain[1], dx)];
240
+
241
+ const dy = clamp(
242
+ value.y - start.value.y,
243
+ yDomainMin - start.yDomain[0],
244
+ yDomainMax - start.yDomain[1]
245
+ );
246
+ yDomain = [add(start.yDomain[0], dy), add(start.yDomain[1], dy)];
247
+ });
248
+
249
+ const adjustTop = handler((start, value) => {
250
+ logger.debug('adjustTop');
251
+ yDomain = [
252
+ clamp(value.y < start.yDomain[0] ? value.y : start.yDomain[0], yDomainMin, yDomainMax),
253
+ clamp(value.y < start.yDomain[0] ? start.yDomain[0] : value.y, yDomainMin, yDomainMax),
254
+ ];
255
+ });
256
+
257
+ const adjustBottom = handler((start, value) => {
258
+ logger.debug('adjustBottom');
259
+ yDomain = [
260
+ clamp(value.y > start.yDomain[1] ? start.yDomain[1] : value.y, yDomainMin, yDomainMax),
261
+ clamp(value.y > start.yDomain[1] ? value.y : start.yDomain[1], yDomainMin, yDomainMax),
262
+ ];
263
+ });
264
+
265
+ const adjustLeft = handler((start, value) => {
266
+ logger.debug('adjustLeft');
267
+ xDomain = [
268
+ clamp(value.x > start.xDomain[1] ? start.xDomain[1] : value.x, xDomainMin, xDomainMax),
269
+ clamp(value.x > start.xDomain[1] ? value.x : start.xDomain[1], xDomainMin, xDomainMax),
270
+ ];
271
+ });
272
+
273
+ const adjustRight = handler((start, value) => {
274
+ logger.debug('adjustRight');
275
+ xDomain = [
276
+ clamp(value.x < start.xDomain[0] ? value.x : start.xDomain[0], xDomainMin, xDomainMax),
277
+ clamp(value.x < start.xDomain[0] ? start.xDomain[0] : value.x, xDomainMin, xDomainMax),
278
+ ];
279
+ });
280
+
281
+ function reset() {
282
+ logger.debug('reset');
283
+ isActive = false;
284
+
285
+ xDomain = originalXDomain;
286
+ yDomain = originalYDomain;
287
+
288
+ onreset({ xDomain, yDomain });
289
+ }
290
+
291
+ function selectAll() {
292
+ logger.debug('selectedAll');
293
+ xDomain = [xDomainMin, xDomainMax];
294
+ yDomain = [yDomainMin, yDomainMax];
295
+ }
296
+
297
+ $: top = $yScale(yDomain?.[1]);
298
+ $: bottom = $yScale(yDomain?.[0]);
299
+ $: left = $xScale(xDomain?.[0]);
300
+ $: right = $xScale(xDomain?.[1]);
301
+
302
+ $: _range = {
303
+ x: axis === 'both' || axis === 'x' ? left : 0,
304
+ y: axis === 'both' || axis === 'y' ? top : 0,
305
+ width: axis === 'both' || axis === 'x' ? right - left : $width,
306
+ height: axis === 'both' || axis === 'y' ? bottom - top : $height,
307
+ };
308
+
309
+ let isActive = false;
310
+ $: if (mode === 'separated') {
311
+ // Set reactively to handle cases where xDomain/yDomain are set externally (ex. `bind:xDomain`)
312
+ const isXAxisActive =
313
+ xDomain?.[0]?.valueOf() !== originalXDomain?.[0]?.valueOf() ||
314
+ xDomain?.[1]?.valueOf() !== originalXDomain?.[1]?.valueOf();
315
+
316
+ const isYAxisActive =
317
+ yDomain?.[0]?.valueOf() !== originalYDomain?.[0]?.valueOf() ||
318
+ yDomain?.[1]?.valueOf() !== originalYDomain?.[1]?.valueOf();
319
+
320
+ isActive =
321
+ axis === 'x' ? isXAxisActive : axis == 'y' ? isYAxisActive : isXAxisActive || isYAxisActive;
322
+ }
323
+
324
+ $: $brush = {
325
+ xDomain,
326
+ yDomain,
327
+ isActive,
328
+ range: _range,
329
+ handleSize,
330
+ };
331
+ </script>
332
+
333
+ {#if disabled}
334
+ <slot />
335
+ {:else}
336
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
337
+ <div
338
+ style:top="{$padding.top}px"
339
+ style:left="{$padding.left}px"
340
+ style:width="{$width}px"
341
+ style:height="{$height}px"
342
+ class={cls('BrushContext absolute touch-none')}
343
+ on:pointerdown={createRange}
344
+ on:dblclick={() => selectAll()}
345
+ bind:this={rootEl}
346
+ >
347
+ <div
348
+ class="absolute"
349
+ style:top="-{$padding.top ?? 0}px"
350
+ style:left="-{$padding.left ?? 0}px"
351
+ style:width="{$containerWidth}px"
352
+ style:height="{$containerHeight}px"
353
+ >
354
+ <slot brush={$brush} />
355
+ </div>
356
+
357
+ {#if isActive}
358
+ <div
359
+ {...range}
360
+ style:left="{_range.x}px"
361
+ style:top="{_range.y}px"
362
+ style:width="{_range.width}px"
363
+ style:height="{_range.height}px"
364
+ class={cls(
365
+ 'range',
366
+ 'absolute bg-surface-content/10 cursor-move select-none',
367
+ 'z-10',
368
+ classes.range,
369
+ range?.class
370
+ )}
371
+ on:pointerdown={adjustRange}
372
+ on:dblclick={() => reset()}
373
+ ></div>
374
+
375
+ {#if axis === 'both' || axis === 'y'}
376
+ <div
377
+ {...handle}
378
+ style:left="{_range.x}px"
379
+ style:top="{_range.y}px"
380
+ style:width="{_range.width}px"
381
+ style:height="{handleSize}px"
382
+ class={cls(
383
+ 'handle top',
384
+ 'cursor-ns-resize select-none',
385
+ 'range absolute',
386
+ 'z-10',
387
+ classes.handle,
388
+ handle?.class
389
+ )}
390
+ on:pointerdown={adjustTop}
391
+ on:dblclick={(e) => {
392
+ e.stopPropagation();
393
+ if (yDomain) {
394
+ yDomain[0] = yDomainMin;
395
+ onchange({ xDomain, yDomain });
396
+ }
397
+ }}
398
+ ></div>
399
+
400
+ <div
401
+ {...handle}
402
+ style:left="{_range.x}px"
403
+ style:top="{bottom - handleSize}px"
404
+ style:width="{_range.width}px"
405
+ style:height="{handleSize}px"
406
+ class={cls(
407
+ 'handle bottom',
408
+ 'cursor-ns-resize select-none',
409
+ 'range absolute',
410
+ 'z-10',
411
+ classes.handle,
412
+ handle?.class
413
+ )}
414
+ on:pointerdown={adjustBottom}
415
+ on:dblclick={(e) => {
416
+ e.stopPropagation();
417
+ if (yDomain) {
418
+ yDomain[1] = yDomainMax;
419
+ onchange({ xDomain, yDomain });
420
+ }
421
+ }}
422
+ ></div>
423
+ {/if}
424
+
425
+ {#if axis === 'both' || axis === 'x'}
426
+ <div
427
+ {...handle}
428
+ style:left="{_range.x}px"
429
+ style:top="{_range.y}px"
430
+ style:width="{handleSize}px"
431
+ style:height="{_range.height}px"
432
+ class={cls(
433
+ 'handle left',
434
+ 'cursor-ew-resize select-none',
435
+ 'range absolute',
436
+ 'z-10',
437
+ classes.handle,
438
+ handle?.class
439
+ )}
440
+ on:pointerdown={adjustLeft}
441
+ on:dblclick={(e) => {
442
+ e.stopPropagation();
443
+ if (xDomain) {
444
+ xDomain[0] = xDomainMin;
445
+ onchange({ xDomain, yDomain });
446
+ }
447
+ }}
448
+ ></div>
449
+
450
+ <div
451
+ {...handle}
452
+ style:left="{right - handleSize + 1}px"
453
+ style:top="{_range.y}px"
454
+ style:width="{handleSize}px"
455
+ style:height="{_range.height}px"
456
+ class={cls(
457
+ 'handle right',
458
+ 'cursor-ew-resize select-none',
459
+ 'range absolute',
460
+ 'z-10',
461
+ classes.handle,
462
+ handle?.class
463
+ )}
464
+ on:pointerdown={adjustRight}
465
+ on:dblclick={(e) => {
466
+ e.stopPropagation();
467
+ if (xDomain) {
468
+ xDomain[1] = xDomainMax;
469
+ onchange({ xDomain, yDomain });
470
+ }
471
+ }}
472
+ ></div>
473
+ {/if}
474
+ {/if}
475
+ </div>
476
+ {/if}
@@ -0,0 +1,73 @@
1
+ import { SvelteComponentTyped } from "svelte";
2
+ import { type Readable } from 'svelte/store';
3
+ export declare const brushContextKey: unique symbol;
4
+ export type BrushContextValue = {
5
+ xDomain: DomainType;
6
+ yDomain: DomainType;
7
+ isActive: boolean;
8
+ range: {
9
+ x: number;
10
+ y: number;
11
+ width: number;
12
+ height: number;
13
+ };
14
+ handleSize: number;
15
+ };
16
+ export type BrushContext = Readable<BrushContextValue>;
17
+ export declare function brushContext(): BrushContext;
18
+ import type { DomainType } from '../utils/scales.js';
19
+ import type { HTMLAttributes } from 'svelte/elements';
20
+ declare const __propDef: {
21
+ props: {
22
+ /** Axis to apply brushing */ axis?: "x" | "y" | "both";
23
+ /** Size of draggable handles (width/height) */ handleSize?: number;
24
+ /** Only show range while actively brushing. Useful with `brushEnd` event */ resetOnEnd?: boolean;
25
+ xDomain?: DomainType;
26
+ yDomain?: DomainType;
27
+ /** Mode of operation
28
+ * `integrated`: use with single chart
29
+ * `separated`: use with separate (typically smaller) chart and state can be managed externally (sync with other charts, etc). Show active selection when domain does not equal original
30
+ */ mode?: "integrated" | "separated";
31
+ /** Disable brush */ disabled?: boolean;
32
+ /** Attributes passed to range <div> element */ range?: Partial<HTMLAttributes<HTMLDivElement>> | undefined;
33
+ /** Attributes passed to handle <div> elements */ handle?: Partial<HTMLAttributes<HTMLDivElement>> | undefined;
34
+ classes?: {
35
+ root?: string;
36
+ frame?: string;
37
+ range?: string;
38
+ handle?: string;
39
+ labels?: string;
40
+ };
41
+ onchange?: (detail: {
42
+ xDomain?: DomainType;
43
+ yDomain?: DomainType;
44
+ }) => void;
45
+ onbrushstart?: (detail: {
46
+ xDomain?: DomainType;
47
+ yDomain?: DomainType;
48
+ }) => void;
49
+ onbrushend?: (detail: {
50
+ xDomain?: DomainType;
51
+ yDomain?: DomainType;
52
+ }) => void;
53
+ onreset?: (detail: {
54
+ xDomain?: DomainType;
55
+ yDomain?: DomainType;
56
+ }) => void;
57
+ /** Exposed to allow binding in Chart */ brush?: import("svelte/store").Writable<BrushContextValue>;
58
+ };
59
+ events: {
60
+ [evt: string]: CustomEvent<any>;
61
+ };
62
+ slots: {
63
+ default: {
64
+ brush: BrushContextValue;
65
+ };
66
+ };
67
+ };
68
+ export type BrushContextProps = typeof __propDef.props;
69
+ export type BrushContextEvents = typeof __propDef.events;
70
+ export type BrushContextSlots = typeof __propDef.slots;
71
+ export default class BrushContext extends SvelteComponentTyped<BrushContextProps, BrushContextEvents, BrushContextSlots> {
72
+ }
73
+ export {};