@fjader/svelte 0.2.0 → 0.2.1

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.
@@ -426,6 +426,8 @@
426
426
  onInteractorPointerDown: null,
427
427
  onInteractorPointerMove: null,
428
428
  onInteractorPointerUp: null,
429
+ onInteractorDragEnd: null,
430
+ reroutePress: () => reroutePress(),
429
431
  activators: new Set()
430
432
  };
431
433
 
@@ -469,10 +471,87 @@
469
471
  }
470
472
  }
471
473
 
474
+ // The origin is read on pointer enter, but the chart can move under a pointer
475
+ // already inside it: a scroll that carries on after the pointer arrives (the
476
+ // wheel passes through to the page for a moment, see WheelInteractionControl),
477
+ // a resize, or content above it changing height. Scroll and resize mark it
478
+ // stale; layout shifts fire no event, so it is also re-read every
479
+ // ORIGIN_CHECK_MS while hovered. Re-reading every frame instead cost a forced
480
+ // layout on most frames, for a chart that almost never moves.
481
+ const ORIGIN_CHECK_MS = 250;
482
+ let pointerInside = false;
483
+ let originStale = false;
484
+ let originCheckedAt = 0;
485
+ // Whether the pointer is over the SVG and its last position there, to
486
+ // re-apply the hover when the chart moves under a still pointer. The position
487
+ // is current only while pointerOverSvg holds: moves over the facet label or
488
+ // palette are not tracked.
489
+ let pointerOverSvg = false;
490
+ let lastClientX = 0;
491
+ let lastClientY = 0;
492
+
493
+ /**
494
+ * The element of the SVG at a viewport position, or null outside it. A fresh
495
+ * hit test on the chart's root (a shadow root, if the host renders into one),
496
+ * for when an event's target cannot say: during pointer capture it is the
497
+ * capturing element, and a chart moving under a still pointer fires no event.
498
+ */
499
+ function svgElementAt(clientX: number, clientY: number): Element | null {
500
+ const root = chartCellEl?.getRootNode() as Document | ShadowRoot | undefined;
501
+ const hit = root?.elementFromPoint?.(clientX, clientY) ?? null;
502
+ return hit && svg?.contains(hit) ? hit : null;
503
+ }
504
+
505
+ function markOriginStale(): void {
506
+ originStale = true;
507
+ }
508
+
509
+ /** Re-read the origin when stale or due, from the frame callback while hovered. */
510
+ function checkSvgOrigin(): void {
511
+ const now = performance.now();
512
+ if (!originStale && now - originCheckedAt < ORIGIN_CHECK_MS) return;
513
+ originStale = false;
514
+ originCheckedAt = now;
515
+
516
+ const left = svgLeft;
517
+ const top = svgTop;
518
+ refreshSvgOrigin();
519
+ if (svgLeft === left && svgTop === top) return;
520
+ // The chart moved under a still pointer: move the crosshair with it. A
521
+ // drag or pan in progress gets its next position from the next move.
522
+ if (!pointerOverSvg || pressPointerId != null) return;
523
+ // Something else may be under the pointer now. Layout was just flushed by
524
+ // the origin read, so the hit test is cheap, and this runs only on a move.
525
+ const overBrush = !!svgElementAt(lastClientX, lastClientY)?.closest('.brush-overlay');
526
+ updateHover(lastClientX - svgLeft, lastClientY - svgTop, overBrush);
527
+ }
528
+
529
+ // Its own callback rather than the tooltip's, which faceted cells do not have.
530
+ onMount(() => {
531
+ const originCallback = () => {
532
+ if (pointerInside) checkSvgOrigin();
533
+ };
534
+ gpuCoordinator.addFrameCallback(originCallback);
535
+ return () => gpuCoordinator.removeFrameCallback(originCallback);
536
+ });
537
+
538
+ function handleSvgPointerEnter(event: PointerEvent) {
539
+ pointerOverSvg = true;
540
+ lastClientX = event.clientX;
541
+ lastClientY = event.clientY;
542
+ }
543
+
544
+ function handleSvgPointerLeave() {
545
+ pointerOverSvg = false;
546
+ }
547
+
472
548
  function handleChartPointerEnter(event: PointerEvent) {
473
549
  activateSources();
474
550
  hoverSettle.enter();
475
551
  refreshSvgOrigin();
552
+ pointerInside = true;
553
+ originStale = false;
554
+ originCheckedAt = performance.now();
476
555
  gpuCoordinator.setChartHovered(layout.chartId);
477
556
  wheelControl.handlePointerEnter();
478
557
  // Focus so [ / ] keyboard shortcuts work for tooltip cycling.
@@ -485,6 +564,8 @@
485
564
  }
486
565
 
487
566
  function handleChartPointerLeave() {
567
+ pointerInside = false;
568
+ pointerOverSvg = false;
488
569
  hoverSettle.leave();
489
570
  gpuCoordinator.setChartUnhovered(layout.chartId);
490
571
  wheelControl.handlePointerLeave();
@@ -557,7 +638,20 @@
557
638
  paletteOpen = !paletteOpen;
558
639
  }
559
640
 
641
+ /** Whether a key event's target takes text: an input, a textarea, or contenteditable. */
642
+ function isEditable(target: EventTarget | null): boolean {
643
+ const el = target as HTMLElement | null;
644
+ return !!el && (el.isContentEditable || el.tagName === 'INPUT' || el.tagName === 'TEXTAREA');
645
+ }
646
+
560
647
  function handleKeyDown(event: KeyboardEvent) {
648
+ // Space over a chart is the brushes' pan key, so it must not scroll the
649
+ // page, which is what the browser does with Space on the focused cell.
650
+ // Only while the pointer is inside: the cell keeps focus after it leaves.
651
+ // Not in a text field, such as the command palette's input.
652
+ if (event.code === 'Space' && pointerInside && !isEditable(event.target)) {
653
+ event.preventDefault();
654
+ }
561
655
  if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
562
656
  event.preventDefault();
563
657
  paletteOpen = !paletteOpen;
@@ -643,22 +737,59 @@
643
737
  activePanY = false;
644
738
  }
645
739
 
740
+ // The press under way, for reroutePress: its pointer, where it is now, and
741
+ // whether it was re-routed (then never a click, which would clear a brush).
742
+ let pressPointerId: number | null = null;
743
+ let pressClientX = 0;
744
+ let pressClientY = 0;
745
+ let pressRerouted = false;
746
+
646
747
  function handlePointerDown(event: PointerEvent) {
647
748
  hoverSettle.hold(true);
648
749
  event.preventDefault();
649
750
  chartCellEl?.focus({ preventScroll: true });
650
751
 
651
- pointerBuffer.x = ptrX(event);
652
- pointerBuffer.y = ptrY(event);
752
+ pressPointerId = event.pointerId;
753
+ pressRerouted = false;
754
+ pressClientX = event.clientX;
755
+ pressClientY = event.clientY;
756
+ routePress(event.target as Element, event.pointerId, event.clientX, event.clientY);
757
+ }
758
+
759
+ /**
760
+ * Route the press under way again where the pointer is now, ending what it
761
+ * was doing first. The element under the pointer comes from a fresh hit
762
+ * test, since during pointer capture the events' target is the capturing
763
+ * element.
764
+ */
765
+ function reroutePress() {
766
+ if (pressPointerId == null || isReordering) return;
767
+ if (isPanning) stopPan();
768
+ else chartContext.onInteractorDragEnd?.();
769
+ pressRerouted = true;
770
+
771
+ const target = svgElementAt(pressClientX, pressClientY) ?? svg;
772
+ if (target) routePress(target, pressPointerId, pressClientX, pressClientY);
773
+ }
774
+
775
+ /**
776
+ * Offer a press to the interactor, and pan if it declines. `target` is the
777
+ * element under the pointer: a press on a brush's overlay goes to the brush
778
+ * even just outside the plot area, where its edge handles reach.
779
+ */
780
+ function routePress(target: Element, pointerId: number, clientX: number, clientY: number) {
781
+ pointerBuffer.x = clientX - svgLeft;
782
+ pointerBuffer.y = clientY - svgTop;
653
783
  const { xInBounds, yInBounds, innerPoint } = layout.getActiveAxes(pointerBuffer);
654
784
 
655
785
  // Parallel axes are vertical — yInBounds is sufficient, nearestParallelAxis handles x-proximity.
656
786
  // This mirrors how regular charts let margin interactions target the in-bounds axis.
657
787
  const parallelInBounds = yInBounds && !!scales.parallelScales;
658
788
 
789
+ const onBrush = !!target.closest?.('[data-brush-handle]');
659
790
  if (
660
- ((xInBounds && yInBounds) || parallelInBounds) &&
661
- chartContext.onInteractorPointerDown?.(event, innerPoint)
791
+ ((xInBounds && yInBounds) || parallelInBounds || onBrush) &&
792
+ chartContext.onInteractorPointerDown?.(target, pointerId, innerPoint)
662
793
  ) {
663
794
  currentInnerX = null;
664
795
  currentInnerY = null;
@@ -676,7 +807,7 @@
676
807
  scales.clearCurrentDataPoints();
677
808
  currentInnerX = null;
678
809
  currentInnerY = null;
679
- (event.target as Element | null)?.setPointerCapture?.(event.pointerId);
810
+ target.setPointerCapture?.(pointerId);
680
811
  return;
681
812
  }
682
813
 
@@ -688,7 +819,7 @@
688
819
  scales.startParallelReorder(innerPoint.x, layout.innerWidth)
689
820
  ) {
690
821
  isReordering = true;
691
- (event.target as Element | null)?.setPointerCapture?.(event.pointerId);
822
+ target.setPointerCapture?.(pointerId);
692
823
  return;
693
824
  }
694
825
 
@@ -708,7 +839,7 @@
708
839
  scales.clearCurrentDataPoints();
709
840
  currentInnerX = null;
710
841
  currentInnerY = null;
711
- (event.target as Element | null)?.setPointerCapture?.(event.pointerId);
842
+ target.setPointerCapture?.(pointerId);
712
843
  }
713
844
 
714
845
  /** The pointer buffer set to an SVG-root position, for layout methods that read a Point. */
@@ -719,6 +850,12 @@
719
850
  }
720
851
 
721
852
  function processPointerMove(event: PointerEvent) {
853
+ if (event.pointerId === pressPointerId) {
854
+ pressClientX = event.clientX;
855
+ pressClientY = event.clientY;
856
+ }
857
+ lastClientX = event.clientX;
858
+ lastClientY = event.clientY;
722
859
  const x = ptrX(event);
723
860
  const y = ptrY(event);
724
861
  if (isReordering) {
@@ -732,8 +869,8 @@
732
869
  } else if (
733
870
  chartContext.onInteractorPointerMove?.(event, layout.toInnerCoordinates(setPointer(x, y)))
734
871
  ) {
735
- // Brush move/resize use stopPropagation, bypassing handlePointerDown cleanup.
736
- // Guard ensures this only runs once on the first captured move.
872
+ // Clear the hover point once per drag. A press clears it when the
873
+ // interactor takes it, but a press re-routed mid-drag can start one too.
737
874
  if (currentInnerX != null) {
738
875
  currentInnerX = null;
739
876
  currentInnerY = null;
@@ -741,31 +878,36 @@
741
878
  tooltipManager?.updatePosition(null, null);
742
879
  }
743
880
  } else {
744
- pointerBuffer.x = x;
745
- pointerBuffer.y = y;
746
-
747
- if (layout.isWithinInnerBounds(pointerBuffer)) {
748
- const innerPoint = layout.toInnerCoordinates(pointerBuffer);
749
- currentInnerX = innerPoint.x;
750
- currentInnerY = innerPoint.y;
751
- scales.updateCurrentDataPoints(innerPoint, layout.innerWidth, layout.innerHeight);
752
- // Suppress tooltip over brush overlays — crosshair/margin labels stay active
753
- const overBrush = (event.target as Element)?.closest?.('.brush-overlay');
754
- if (overBrush) {
755
- tooltipManager?.updatePosition(null, null);
756
- } else {
757
- tooltipManager?.updatePointerScreen(x, y);
758
- tooltipManager?.updatePosition(
759
- scales.xScaleState?.currentDataPoint,
760
- scales.yScaleState?.currentDataPoint
761
- );
762
- }
763
- } else {
764
- currentInnerX = null;
765
- currentInnerY = null;
766
- scales.clearCurrentDataPoints();
881
+ const overBrush = !!(event.target as Element)?.closest?.('.brush-overlay');
882
+ updateHover(x, y, overBrush);
883
+ }
884
+ }
885
+
886
+ /** Crosshair, hover points and tooltip for the pointer at an SVG-root position. */
887
+ function updateHover(x: number, y: number, overBrush: boolean): void {
888
+ pointerBuffer.x = x;
889
+ pointerBuffer.y = y;
890
+
891
+ if (layout.isWithinInnerBounds(pointerBuffer)) {
892
+ const innerPoint = layout.toInnerCoordinates(pointerBuffer);
893
+ currentInnerX = innerPoint.x;
894
+ currentInnerY = innerPoint.y;
895
+ scales.updateCurrentDataPoints(innerPoint, layout.innerWidth, layout.innerHeight);
896
+ // Suppress tooltip over brush overlays — crosshair/margin labels stay active
897
+ if (overBrush) {
767
898
  tooltipManager?.updatePosition(null, null);
899
+ } else {
900
+ tooltipManager?.updatePointerScreen(x, y);
901
+ tooltipManager?.updatePosition(
902
+ scales.xScaleState?.currentDataPoint,
903
+ scales.yScaleState?.currentDataPoint
904
+ );
768
905
  }
906
+ } else {
907
+ currentInnerX = null;
908
+ currentInnerY = null;
909
+ scales.clearCurrentDataPoints();
910
+ tooltipManager?.updatePosition(null, null);
769
911
  }
770
912
  }
771
913
 
@@ -780,6 +922,11 @@
780
922
  }
781
923
 
782
924
  function handlePointerUp(event: PointerEvent) {
925
+ const rerouted = pressRerouted && event.pointerId === pressPointerId;
926
+ if (event.pointerId === pressPointerId) {
927
+ pressPointerId = null;
928
+ pressRerouted = false;
929
+ }
783
930
  hoverSettle.hold(false);
784
931
  if (isReordering) {
785
932
  isReordering = false;
@@ -787,11 +934,17 @@
787
934
  scales.endParallelReorder();
788
935
  return;
789
936
  }
790
- if (chartContext.onInteractorPointerUp?.(event)) return;
937
+ // A re-routed press ends as a drag even if it never moved: it is no click.
938
+ if (rerouted) chartContext.onInteractorDragEnd?.();
939
+ else if (chartContext.onInteractorPointerUp?.(event)) return;
940
+ (event.target as Element | null)?.releasePointerCapture?.(event.pointerId);
941
+ stopPan();
942
+ }
943
+
944
+ function stopPan() {
791
945
  isPanning = false;
792
946
  activePanX = false;
793
947
  activePanY = false;
794
- (event.target as Element | null)?.releasePointerCapture?.(event.pointerId);
795
948
  scales.endPan();
796
949
  scales.endParallelPan();
797
950
  }
@@ -872,6 +1025,8 @@
872
1025
  const cellReady = $derived(layout.isReady);
873
1026
  </script>
874
1027
 
1028
+ <svelte:window onscrollcapture={markOriginStale} onresize={markOriginStale} />
1029
+
875
1030
  <!-- svelte-ignore a11y_no_static_element_interactions -->
876
1031
  <div
877
1032
  class="chart-cell"
@@ -912,6 +1067,8 @@
912
1067
  width={layout.width}
913
1068
  height={layout.height}
914
1069
  onwheel={handleWheel}
1070
+ onpointerenter={handleSvgPointerEnter}
1071
+ onpointerleave={handleSvgPointerLeave}
915
1072
  onpointerdown={handlePointerDown}
916
1073
  onpointermove={handlePointerMove}
917
1074
  onpointerrawupdate={handlePointerRawUpdate}
@@ -441,6 +441,9 @@
441
441
  onInteractorPointerDown: null,
442
442
  onInteractorPointerMove: null,
443
443
  onInteractorPointerUp: null,
444
+ onInteractorDragEnd: null,
445
+ // Presses belong to the chart cells, whose context the brushes get.
446
+ reroutePress: () => {},
444
447
  activators: new Set()
445
448
  };
446
449
 
@@ -366,5 +366,4 @@
366
366
  dx={entry.dx ?? 0}
367
367
  dy={entry.dy ?? 0}
368
368
  />
369
- >
370
369
  {/if}
@@ -5,12 +5,19 @@
5
5
  width: number;
6
6
  height: number;
7
7
  orientation: 'x' | 'y' | '2d';
8
- onMoveStart: (event: PointerEvent) => void;
9
- onResizeStart: (edge: string, event: PointerEvent) => void;
10
8
  onWheel: (event: WheelEvent) => void;
9
+ /** Which brush this is, when a chart has several (a parallel axis's column). */
10
+ owner?: string;
11
11
  }
12
12
 
13
- let { x, y, width, height, orientation, onMoveStart, onResizeStart, onWheel }: Props = $props();
13
+ /*
14
+ * The overlay only draws the brush and labels its parts with
15
+ * data-brush-handle. Presses bubble to the chart, which hands them to the
16
+ * brush's handlePointerDown; it reads the label (brushHandleAt) to move,
17
+ * resize or draw. Routing lives there alone, so the chart can route a press
18
+ * again mid-drag, at the element under the pointer then (reroutePress).
19
+ */
20
+ let { x, y, width, height, orientation, onWheel, owner }: Props = $props();
14
21
 
15
22
  // Normalize to ensure positive dimensions (avoid GC by using primitives)
16
23
  const nx = $derived(width < 0 ? x + width : x);
@@ -19,7 +26,7 @@
19
26
  const nh = $derived(Math.abs(height));
20
27
  </script>
21
28
 
22
- <g class="brush-overlay" onwheel={onWheel}>
29
+ <g class="brush-overlay" data-brush-owner={owner} onwheel={onWheel}>
23
30
  <!-- Selection box - minimal style like D3 defaults -->
24
31
  <rect
25
32
  class="brush-selection"
@@ -30,7 +37,7 @@
30
37
  fill="rgba(128, 128, 128, 0.1)"
31
38
  stroke="rgba(96, 96, 96, 0.8)"
32
39
  stroke-width="1"
33
- onpointerdown={onMoveStart}
40
+ data-brush-handle="move"
34
41
  style="cursor: move;"
35
42
  />
36
43
 
@@ -44,7 +51,7 @@
44
51
  height={nh}
45
52
  fill="transparent"
46
53
  style="cursor: ew-resize;"
47
- onpointerdown={(e) => onResizeStart('left', e)}
54
+ data-brush-handle="left"
48
55
  />
49
56
  <!-- Right edge -->
50
57
  <rect
@@ -54,7 +61,7 @@
54
61
  height={nh}
55
62
  fill="transparent"
56
63
  style="cursor: ew-resize;"
57
- onpointerdown={(e) => onResizeStart('right', e)}
64
+ data-brush-handle="right"
58
65
  />
59
66
  {/if}
60
67
 
@@ -67,7 +74,7 @@
67
74
  height={10}
68
75
  fill="transparent"
69
76
  style="cursor: ns-resize;"
70
- onpointerdown={(e) => onResizeStart('top', e)}
77
+ data-brush-handle="top"
71
78
  />
72
79
  <!-- Bottom edge -->
73
80
  <rect
@@ -77,7 +84,7 @@
77
84
  height={10}
78
85
  fill="transparent"
79
86
  style="cursor: ns-resize;"
80
- onpointerdown={(e) => onResizeStart('bottom', e)}
87
+ data-brush-handle="bottom"
81
88
  />
82
89
  {/if}
83
90
 
@@ -90,7 +97,7 @@
90
97
  r={8}
91
98
  fill="transparent"
92
99
  style="cursor: {corner === 'nw' || corner === 'se' ? 'nwse' : 'nesw'}-resize;"
93
- onpointerdown={(e) => onResizeStart(corner, e)}
100
+ data-brush-handle={corner}
94
101
  />
95
102
  {/each}
96
103
  {/if}
@@ -4,9 +4,9 @@ interface Props {
4
4
  width: number;
5
5
  height: number;
6
6
  orientation: 'x' | 'y' | '2d';
7
- onMoveStart: (event: PointerEvent) => void;
8
- onResizeStart: (edge: string, event: PointerEvent) => void;
9
7
  onWheel: (event: WheelEvent) => void;
8
+ /** Which brush this is, when a chart has several (a parallel axis's column). */
9
+ owner?: string;
10
10
  }
11
11
  declare const BrushOverlay: import("svelte").Component<Props, {}, "">;
12
12
  type BrushOverlay = ReturnType<typeof BrushOverlay>;
@@ -7,7 +7,7 @@
7
7
  * selection → target group. Marks declare which group they belong to via
8
8
  * their own `selection` prop.
9
9
  */
10
- import { getContext, onMount } from 'svelte';
10
+ import { getContext, onMount, untrack } from 'svelte';
11
11
  import { isBetween, column as sqlColumn } from '@uwdata/mosaic-sql';
12
12
  import GhostBrushOverlay from './GhostBrushOverlay.svelte';
13
13
  import { buildColumnRef } from './brush-utils';
@@ -111,8 +111,18 @@
111
111
  let lastEmittedMin: number | null = null;
112
112
  let lastEmittedMax: number | null = null;
113
113
 
114
+ // Tracks only the derived extent and the column it filters. The rest runs
115
+ // untracked: writing to the target group reads state it then writes, which
116
+ // would make the effect re-run itself without end.
114
117
  $effect(() => {
115
- if (!derivedExtent) {
118
+ const extent = derivedExtent;
119
+ const ref = columnRef;
120
+ const fieldStr = resolvedField ?? '';
121
+ untrack(() => emitDerived(extent, ref, fieldStr));
122
+ });
123
+
124
+ function emitDerived(extent: [number, number] | null, ref: typeof columnRef, fieldStr: string) {
125
+ if (!extent) {
116
126
  // Clear derived filter when primary is cleared
117
127
  if (lastEmittedMin != null) {
118
128
  lastEmittedMin = null;
@@ -122,8 +132,8 @@
122
132
  return;
123
133
  }
124
134
 
125
- const dataMin = Math.min(derivedExtent[0], derivedExtent[1]);
126
- const dataMax = Math.max(derivedExtent[0], derivedExtent[1]);
135
+ const dataMin = Math.min(extent[0], extent[1]);
136
+ const dataMax = Math.max(extent[0], extent[1]);
127
137
 
128
138
  // Skip if unchanged
129
139
  if (
@@ -137,13 +147,11 @@
137
147
  lastEmittedMin = dataMin;
138
148
  lastEmittedMax = dataMax;
139
149
 
140
- if (!columnRef) return;
141
-
142
- const fieldStr = resolvedField ?? '';
150
+ if (!ref) return;
143
151
 
144
152
  targetGroup.upsertManual(
145
153
  sourceKey,
146
- isBetween(columnRef, [dataMin, dataMax]),
154
+ isBetween(ref, [dataMin, dataMax]),
147
155
  [dataMin, dataMax],
148
156
  new Set<string>(), // No excludeMarkIds — target group only has comparison marks
149
157
  {
@@ -153,7 +161,7 @@
153
161
  values: [dataMin, dataMax]
154
162
  }
155
163
  );
156
- });
164
+ }
157
165
 
158
166
  // =========================================================================
159
167
  // Relay non-axis clauses from source → target
@@ -161,18 +169,21 @@
161
169
 
162
170
  let relayedKeys = new Set<string>();
163
171
 
172
+ // Tracks only the source group's clauses and what decides which to relay.
173
+ // The writes to the target group run untracked, as above: tracked, they
174
+ // made this effect re-run itself without end once the source had a clause
175
+ // to relay (a day-of-week toggle beside the brush).
164
176
  $effect(() => {
165
- if (!relayClauses) {
166
- for (const key of relayedKeys) targetGroup.removeManual(key);
167
- relayedKeys = new Set();
168
- return;
169
- }
170
-
177
+ const relay = relayClauses;
171
178
  const items = sourceGroup.items;
172
179
  const fieldStr = resolvedField ?? '';
180
+ untrack(() => relayItems(relay ? items : null, fieldStr));
181
+ });
173
182
 
183
+ /** Mirror the source's clauses, but the axis range, into the target group. */
184
+ function relayItems(items: typeof sourceGroup.items | null, fieldStr: string) {
174
185
  const currentKeys = new Set<string>();
175
- for (const item of items) {
186
+ for (const item of items ?? []) {
176
187
  // Skip the range clause on the axis column — we replace it with the shifted version
177
188
  if (item.schema?.kind === 'range' && item.schema?.column === fieldStr) continue;
178
189
 
@@ -194,7 +205,7 @@
194
205
  if (!currentKeys.has(key)) targetGroup.removeManual(key);
195
206
  }
196
207
  relayedKeys = currentKeys;
197
- });
208
+ }
198
209
 
199
210
  onMount(() => {
200
211
  return () => {
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { getContext, onMount } from 'svelte';
2
+ import { getContext, onMount, untrack } from 'svelte';
3
3
  import { isBetween } from '@uwdata/mosaic-sql';
4
4
  import { BrushState1D } from './brush-state.svelte';
5
5
  import BrushOverlay from './BrushOverlay.svelte';
@@ -9,6 +9,7 @@
9
9
  isDragging,
10
10
  scaleBrushExtent1D,
11
11
  createPanModeHandlers,
12
+ brushHandleAt,
12
13
  WHEEL_SENSITIVITY
13
14
  } from './brush-utils';
14
15
  import type { ChartContext, FieldRef, Point, D3Scale } from '../types';
@@ -103,7 +104,7 @@
103
104
  let current = 0;
104
105
  let isCapturing = false;
105
106
  let hasDragged = false;
106
- let isPanModeActive = $state(false);
107
+ let isPanModeActive = false;
107
108
 
108
109
  // Reusable point object to reduce GC pressure
109
110
  const point = { x: 0, y: 0 };
@@ -113,9 +114,12 @@
113
114
  let lastEmittedMax: number | null = null;
114
115
 
115
116
  // Keyboard handlers for Space key pan mode
116
- const { handleKeyDown, handleKeyUp } = createPanModeHandlers((active) => {
117
- isPanModeActive = active;
118
- });
117
+ const { handleKeyDown, handleKeyUp, handleBlur } = createPanModeHandlers(
118
+ (active) => {
119
+ isPanModeActive = active;
120
+ },
121
+ () => chart.reroutePress()
122
+ );
119
123
 
120
124
  // Scale validation
121
125
  // Always validate as 'linear': Fjader time scales use float offsets, not Dates,
@@ -128,19 +132,26 @@
128
132
  });
129
133
 
130
134
  // Watch for external selection changes (from detail chart pan/zoom)
131
- // to keep brush visual in sync and update Mosaic filter
135
+ // to keep brush visual in sync and update Mosaic filter.
136
+ // Tracks only the selection extent and its source. The rest runs untracked:
137
+ // emitting the clause reads state it then writes (the filter group's
138
+ // selection, the scales' active values), which made this effect re-run
139
+ // itself without end while a detail chart was panned.
132
140
  $effect(() => {
133
141
  if (!(scaleState instanceof LinearScaleState)) return;
134
142
  const extent = scaleState.selectionExtent;
135
- if (extent && scaleState.selectionSource === 'external') {
136
- brush.setExtent(extent[0], extent[1]);
137
- lastEmittedMin = extent[0];
138
- lastEmittedMax = extent[1];
139
- emitFilterClause(extent[0], extent[1]);
140
- } else if (!extent && brush.active) {
141
- // Selection was cleared externally (e.g. detail chart reset) — clear brush
142
- clearBrush();
143
- }
143
+ const source = scaleState.selectionSource;
144
+ untrack(() => {
145
+ if (extent && source === 'external') {
146
+ brush.setExtent(extent[0], extent[1]);
147
+ lastEmittedMin = extent[0];
148
+ lastEmittedMax = extent[1];
149
+ emitFilterClause(extent[0], extent[1]);
150
+ } else if (!extent && brush.active) {
151
+ // Selection was cleared externally (e.g. detail chart reset) — clear brush
152
+ clearBrush();
153
+ }
154
+ });
144
155
  });
145
156
 
146
157
  // Derived screen coordinates — read chart.xScale/yScale directly so the
@@ -223,16 +234,25 @@
223
234
 
224
235
  // --- Pointer Event Handlers (registered as chart context callbacks) ---
225
236
 
226
- function handlePointerDown(event: PointerEvent, innerPoint: Point) {
237
+ /** A press: move or resize on the brush's handles, else draw; decline in pan mode. */
238
+ function handlePointerDown(target: Element, pointerId: number, innerPoint: Point) {
227
239
  if (brush.phase !== 'idle') return false;
228
240
  if (isPanModeActive) return false;
229
241
 
230
- (event.target as Element).setPointerCapture(event.pointerId);
242
+ target.setPointerCapture(pointerId);
231
243
  isCapturing = true;
232
244
  hasDragged = false;
233
245
 
234
246
  anchor = innerCoord(innerPoint);
235
247
  current = anchor;
248
+
249
+ const hit = brushHandleAt(target);
250
+ if (hit?.handle === 'move') {
251
+ brush.phase = 'moving';
252
+ } else if (hit) {
253
+ brush.phase = 'resizing';
254
+ brush.resizeEdge = hit.handle === edgeStart ? 'start' : 'end';
255
+ }
236
256
  return true;
237
257
  }
238
258
 
@@ -280,44 +300,30 @@
280
300
  (event.target as Element).releasePointerCapture(event.pointerId);
281
301
  isCapturing = false;
282
302
 
303
+ // A press that never moved is a click, which clears the brush.
283
304
  if (!hasDragged && brush.active && (brush.phase === 'idle' || brush.phase === 'moving')) {
284
305
  clearBrush();
285
306
  return true;
286
307
  }
287
308
 
309
+ endDrag();
310
+ return true;
311
+ }
312
+
313
+ /** Keep what the drag did and go idle. */
314
+ function endDrag() {
288
315
  if (brush.phase !== 'idle') {
289
316
  emitUpdate();
290
317
  brush.phase = 'idle';
291
318
  }
292
-
293
319
  hasDragged = false;
294
- return true;
295
320
  }
296
321
 
297
- function handleMoveStart(event: PointerEvent) {
298
- event.stopPropagation();
299
- (event.target as Element).setPointerCapture(event.pointerId);
300
- isCapturing = true;
301
- hasDragged = false;
302
-
303
- point.x = event.offsetX;
304
- point.y = event.offsetY;
305
- const innerCoords = chart.toInnerCoordinates(point);
306
- anchor = innerCoord(innerCoords);
307
- brush.phase = 'moving';
308
- }
309
-
310
- function handleResizeStart(edge: string, event: PointerEvent) {
311
- event.stopPropagation();
312
- (event.target as Element).setPointerCapture(event.pointerId);
313
- isCapturing = true;
314
-
315
- point.x = event.offsetX;
316
- point.y = event.offsetY;
317
- const innerCoords = chart.toInnerCoordinates(point);
318
- anchor = innerCoord(innerCoords);
319
- brush.phase = 'resizing';
320
- brush.resizeEdge = edge === edgeStart ? 'start' : 'end';
322
+ /** The chart re-routes the press: end the drag as it stands, never as a click. */
323
+ function handleDragEnd() {
324
+ if (!isCapturing) return;
325
+ isCapturing = false;
326
+ endDrag();
321
327
  }
322
328
 
323
329
  // Register pointer callbacks; the keys are on <svelte:window> below.
@@ -325,19 +331,21 @@
325
331
  chart.onInteractorPointerDown = handlePointerDown;
326
332
  chart.onInteractorPointerMove = handlePointerMove;
327
333
  chart.onInteractorPointerUp = handlePointerUp;
334
+ chart.onInteractorDragEnd = handleDragEnd;
328
335
  chart.activators.add(brushHandle.activate);
329
336
 
330
337
  return () => {
331
338
  chart.onInteractorPointerDown = null;
332
339
  chart.onInteractorPointerMove = null;
333
340
  chart.onInteractorPointerUp = null;
341
+ chart.onInteractorDragEnd = null;
334
342
  chart.activators.delete(brushHandle.activate);
335
343
  brushHandle.destroy();
336
344
  };
337
345
  });
338
346
  </script>
339
347
 
340
- <svelte:window onkeydown={handleKeyDown} onkeyup={handleKeyUp} />
348
+ <svelte:window onkeydown={handleKeyDown} onkeyup={handleKeyUp} onblur={handleBlur} />
341
349
 
342
350
  {#if brush.active && brushScreen}
343
351
  <BrushOverlay
@@ -346,8 +354,6 @@
346
354
  width={isX ? brushScreen.size : chart.innerWidth}
347
355
  height={isX ? chart.innerHeight : brushScreen.size}
348
356
  orientation={axis}
349
- onMoveStart={handleMoveStart}
350
- onResizeStart={handleResizeStart}
351
357
  onWheel={handleWheel}
352
358
  />
353
359
  {/if}
@@ -8,6 +8,8 @@
8
8
  closeTo,
9
9
  isDragging,
10
10
  scaleBrushExtent2D,
11
+ createPanModeHandlers,
12
+ brushHandleAt,
11
13
  WHEEL_SENSITIVITY
12
14
  } from './brush-utils';
13
15
  import type { ChartContext, FieldRef, Point } from '../types';
@@ -104,6 +106,15 @@
104
106
  let currentY = 0;
105
107
  let isCapturing = false;
106
108
  let hasDragged = false;
109
+ let isPanModeActive = false;
110
+
111
+ // Space held: presses pan the chart instead of brushing
112
+ const { handleKeyDown, handleKeyUp, handleBlur } = createPanModeHandlers(
113
+ (active) => {
114
+ isPanModeActive = active;
115
+ },
116
+ () => chart.reroutePress()
117
+ );
107
118
 
108
119
  // Reusable point object to reduce GC pressure
109
120
  const point = { x: 0, y: 0 };
@@ -218,10 +229,12 @@
218
229
 
219
230
  // --- Pointer Event Handlers (registered as chart context callbacks) ---
220
231
 
221
- function handlePointerDown(event: PointerEvent, innerPoint: Point) {
232
+ /** A press: move or resize on the brush's handles, else draw; decline in pan mode. */
233
+ function handlePointerDown(target: Element, pointerId: number, innerPoint: Point) {
222
234
  if (brush.phase !== 'idle') return false;
235
+ if (isPanModeActive) return false;
223
236
 
224
- (event.target as Element).setPointerCapture(event.pointerId);
237
+ target.setPointerCapture(pointerId);
225
238
  isCapturing = true;
226
239
  hasDragged = false;
227
240
 
@@ -229,6 +242,23 @@
229
242
  anchorY = innerPoint.y;
230
243
  currentX = innerPoint.x;
231
244
  currentY = innerPoint.y;
245
+
246
+ const hit = brushHandleAt(target);
247
+ if (hit?.handle === 'move') {
248
+ brush.phase = 'moving';
249
+ } else if (hit) {
250
+ brush.phase = 'resizing';
251
+ const edge = hit.handle;
252
+ if (edge === 'nw' || edge === 'ne' || edge === 'sw' || edge === 'se') {
253
+ brush.resizeCorner = edge;
254
+ brush.x.resizeEdge = null;
255
+ brush.y.resizeEdge = null;
256
+ } else {
257
+ brush.resizeCorner = null;
258
+ brush.x.resizeEdge = edge === 'left' || edge === 'right' ? edge : null;
259
+ brush.y.resizeEdge = edge === 'top' || edge === 'bottom' ? edge : null;
260
+ }
261
+ }
232
262
  return true;
233
263
  }
234
264
 
@@ -302,11 +332,18 @@
302
332
  (event.target as Element).releasePointerCapture(event.pointerId);
303
333
  isCapturing = false;
304
334
 
335
+ // A press that never moved is a click, which clears the brush.
305
336
  if (!hasDragged && brush.active && (brush.phase === 'idle' || brush.phase === 'moving')) {
306
337
  clearBrush();
307
338
  return true;
308
339
  }
309
340
 
341
+ endDrag();
342
+ return true;
343
+ }
344
+
345
+ /** Keep what the drag did and go idle. */
346
+ function endDrag() {
310
347
  if (brush.phase !== 'idle') {
311
348
  emitClause();
312
349
  brush.phase = 'idle';
@@ -314,58 +351,14 @@
314
351
  brush.x.resizeEdge = null;
315
352
  brush.y.resizeEdge = null;
316
353
  }
317
-
318
354
  hasDragged = false;
319
- return true;
320
- }
321
-
322
- function handleMoveStart(event: PointerEvent) {
323
- event.stopPropagation();
324
- (event.target as Element).setPointerCapture(event.pointerId);
325
- isCapturing = true;
326
- hasDragged = false;
327
-
328
- point.x = event.offsetX;
329
- point.y = event.offsetY;
330
- const innerCoords = chart.toInnerCoordinates(point);
331
- anchorX = innerCoords.x;
332
- anchorY = innerCoords.y;
333
- currentX = innerCoords.x;
334
- currentY = innerCoords.y;
335
- brush.phase = 'moving';
336
355
  }
337
356
 
338
- function handleResizeStart(
339
- edge: 'left' | 'right' | 'top' | 'bottom' | 'nw' | 'ne' | 'sw' | 'se',
340
- event: PointerEvent
341
- ) {
342
- event.stopPropagation();
343
- (event.target as HTMLElement).setPointerCapture(event.pointerId);
344
- isCapturing = true;
345
-
346
- point.x = event.offsetX;
347
- point.y = event.offsetY;
348
- const innerCoords = chart.toInnerCoordinates(point);
349
- anchorX = innerCoords.x;
350
- anchorY = innerCoords.y;
351
- currentX = innerCoords.x;
352
- currentY = innerCoords.y;
353
- brush.phase = 'resizing';
354
-
355
- if (edge === 'nw' || edge === 'ne' || edge === 'sw' || edge === 'se') {
356
- brush.resizeCorner = edge;
357
- brush.x.resizeEdge = null;
358
- brush.y.resizeEdge = null;
359
- } else {
360
- brush.resizeCorner = null;
361
- if (edge === 'left' || edge === 'right') {
362
- brush.x.resizeEdge = edge;
363
- brush.y.resizeEdge = null;
364
- } else {
365
- brush.x.resizeEdge = null;
366
- brush.y.resizeEdge = edge;
367
- }
368
- }
357
+ /** The chart re-routes the press: end the drag as it stands, never as a click. */
358
+ function handleDragEnd() {
359
+ if (!isCapturing) return;
360
+ isCapturing = false;
361
+ endDrag();
369
362
  }
370
363
 
371
364
  // Register callbacks
@@ -373,18 +366,22 @@
373
366
  chart.onInteractorPointerDown = handlePointerDown;
374
367
  chart.onInteractorPointerMove = handlePointerMove;
375
368
  chart.onInteractorPointerUp = handlePointerUp;
369
+ chart.onInteractorDragEnd = handleDragEnd;
376
370
  chart.activators.add(brushHandle.activate);
377
371
 
378
372
  return () => {
379
373
  chart.onInteractorPointerDown = null;
380
374
  chart.onInteractorPointerMove = null;
381
375
  chart.onInteractorPointerUp = null;
376
+ chart.onInteractorDragEnd = null;
382
377
  chart.activators.delete(brushHandle.activate);
383
378
  brushHandle.destroy();
384
379
  };
385
380
  });
386
381
  </script>
387
382
 
383
+ <svelte:window onkeydown={handleKeyDown} onkeyup={handleKeyUp} onblur={handleBlur} />
384
+
388
385
  {#if brush.active && brushScreen}
389
386
  <BrushOverlay
390
387
  x={brushScreen.x}
@@ -392,8 +389,6 @@
392
389
  width={brushScreen.width}
393
390
  height={brushScreen.height}
394
391
  orientation="2d"
395
- onMoveStart={handleMoveStart}
396
- onResizeStart={handleResizeStart}
397
392
  onWheel={handleWheel}
398
393
  />
399
394
  {/if}
@@ -4,7 +4,7 @@
4
4
  import { SvelteMap } from 'svelte/reactivity';
5
5
  import BrushOverlay from './BrushOverlay.svelte';
6
6
  import ParallelAxisSource, { type ParallelAxis } from './ParallelAxisSource.svelte';
7
- import { closeTo, isDragging, createPanModeHandlers } from './brush-utils';
7
+ import { closeTo, isDragging, createPanModeHandlers, brushHandleAt } from './brush-utils';
8
8
  import { LinearScaleState } from '../scales/LinearScaleState.svelte';
9
9
  import type { ChartContext, Point } from '../types';
10
10
  import type { FilterGroupRegistry } from '../query-engine/FilterGroupRegistry.svelte';
@@ -62,10 +62,13 @@
62
62
  }
63
63
 
64
64
  // Pan mode (Space key) — when active, pointer events fall through to ChartCell for pan
65
- let isPanModeActive = $state(false);
66
- const { handleKeyDown, handleKeyUp } = createPanModeHandlers((active) => {
67
- isPanModeActive = active;
68
- });
65
+ let isPanModeActive = false;
66
+ const { handleKeyDown, handleKeyUp, handleBlur } = createPanModeHandlers(
67
+ (active) => {
68
+ isPanModeActive = active;
69
+ },
70
+ () => chart.reroutePress()
71
+ );
69
72
 
70
73
  // The axis the user is dragging on, by column
71
74
  let activeAxis: string | null = null;
@@ -166,19 +169,35 @@
166
169
 
167
170
  // --- Pointer handlers ---
168
171
 
169
- function handlePointerDown(event: PointerEvent, innerPoint: Point) {
172
+ /**
173
+ * A press: move or resize on an axis's brush handles (that axis, from the
174
+ * overlay's owner), else draw on the nearest axis; decline in pan mode.
175
+ */
176
+ function handlePointerDown(target: Element, pointerId: number, innerPoint: Point) {
170
177
  if (!ps) return false;
171
178
  if (isPanModeActive) return false; // Fall through to ChartCell for parallel axis pan
172
179
 
173
- const col = nearestAxis(innerPoint.x);
180
+ const hit = brushHandleAt(target);
181
+ const col = hit?.owner ?? nearestAxis(innerPoint.x);
174
182
  if (col == null || !axes.has(col)) return false;
183
+ // A drag that starts on a brush overlay hovers its axis, even without a
184
+ // pointer move over the axis first.
185
+ if (hit) hoverAxis(col);
175
186
 
176
- (event.target as Element).setPointerCapture(event.pointerId);
187
+ target.setPointerCapture(pointerId);
177
188
  activeAxis = col;
178
189
  isCapturing = true;
179
190
  hasDragged = false;
180
191
  anchor = innerPoint.y;
181
192
  current = anchor;
193
+
194
+ const brush = axes.get(col)!.brush;
195
+ if (hit?.handle === 'move') {
196
+ brush.phase = 'moving';
197
+ } else if (hit) {
198
+ brush.phase = 'resizing';
199
+ brush.resizeEdge = hit.handle === 'top' ? 'start' : 'end';
200
+ }
182
201
  return true;
183
202
  }
184
203
 
@@ -240,59 +259,39 @@
240
259
  const col = activeAxis;
241
260
  activeAxis = null;
242
261
  const brush = axes.get(col)?.brush;
243
- if (!brush) {
244
- hasDragged = false;
245
- return true;
246
- }
247
262
 
248
- if (!hasDragged && brush.active && (brush.phase === 'idle' || brush.phase === 'moving')) {
263
+ // A press that never moved is a click, which clears the brush.
264
+ if (
265
+ brush &&
266
+ !hasDragged &&
267
+ brush.active &&
268
+ (brush.phase === 'idle' || brush.phase === 'moving')
269
+ ) {
249
270
  clearBrush(col);
250
271
  return true;
251
272
  }
252
273
 
253
- if (brush.phase !== 'idle') {
254
- emitUpdate(col);
255
- brush.phase = 'idle';
256
- }
257
-
258
- hasDragged = false;
274
+ endDrag(col);
259
275
  return true;
260
276
  }
261
277
 
262
- function handleMoveStart(col: string, event: PointerEvent) {
278
+ /** Keep what the drag on an axis did and go idle. */
279
+ function endDrag(col: string) {
263
280
  const brush = axes.get(col)?.brush;
264
- if (!brush) return;
265
- event.stopPropagation();
266
- // A drag that starts on a brush overlay hovers its axis, even without a
267
- // pointer move over the axis first.
268
- hoverAxis(col);
269
- (event.target as Element).setPointerCapture(event.pointerId);
270
- activeAxis = col;
271
- isCapturing = true;
281
+ if (brush && brush.phase !== 'idle') {
282
+ emitUpdate(col);
283
+ brush.phase = 'idle';
284
+ }
272
285
  hasDragged = false;
273
-
274
- point.x = event.offsetX;
275
- point.y = event.offsetY;
276
- const innerCoords = chart.toInnerCoordinates(point);
277
- anchor = innerCoords.y;
278
- brush.phase = 'moving';
279
286
  }
280
287
 
281
- function handleResizeStart(col: string, edge: string, event: PointerEvent) {
282
- const brush = axes.get(col)?.brush;
283
- if (!brush) return;
284
- event.stopPropagation();
285
- hoverAxis(col);
286
- (event.target as Element).setPointerCapture(event.pointerId);
287
- activeAxis = col;
288
- isCapturing = true;
289
-
290
- point.x = event.offsetX;
291
- point.y = event.offsetY;
292
- const innerCoords = chart.toInnerCoordinates(point);
293
- anchor = innerCoords.y;
294
- brush.phase = 'resizing';
295
- brush.resizeEdge = edge === 'top' ? 'start' : 'end';
288
+ /** The chart re-routes the press: end the drag as it stands, never as a click. */
289
+ function handleDragEnd() {
290
+ if (!isCapturing || activeAxis == null) return;
291
+ isCapturing = false;
292
+ const col = activeAxis;
293
+ activeAxis = null;
294
+ endDrag(col);
296
295
  }
297
296
 
298
297
  // Brush screen extents, one per active brush, at its axis's x-position —
@@ -324,18 +323,20 @@
324
323
  chart.onInteractorPointerDown = handlePointerDown;
325
324
  chart.onInteractorPointerMove = handlePointerMove;
326
325
  chart.onInteractorPointerUp = handlePointerUp;
326
+ chart.onInteractorDragEnd = handleDragEnd;
327
327
  chart.activators.add(activateHoveredAxis);
328
328
 
329
329
  return () => {
330
330
  chart.onInteractorPointerDown = null;
331
331
  chart.onInteractorPointerMove = null;
332
332
  chart.onInteractorPointerUp = null;
333
+ chart.onInteractorDragEnd = null;
333
334
  chart.activators.delete(activateHoveredAxis);
334
335
  };
335
336
  });
336
337
  </script>
337
338
 
338
- <svelte:window onkeydown={handleKeyDown} onkeyup={handleKeyUp} />
339
+ <svelte:window onkeydown={handleKeyDown} onkeyup={handleKeyUp} onblur={handleBlur} />
339
340
 
340
341
  {#if ps}
341
342
  {#each ps.columns as col (col)}
@@ -356,8 +357,7 @@
356
357
  width={brushHalfWidth * 2}
357
358
  height={screen.size}
358
359
  orientation="y"
359
- onMoveStart={(e) => handleMoveStart(screen.col, e)}
360
- onResizeStart={(edge, e) => handleResizeStart(screen.col, edge, e)}
360
+ owner={screen.col}
361
361
  onWheel={() => {}}
362
362
  />
363
363
  {/each}
@@ -4,10 +4,30 @@ export declare const EPS_ABSOLUTE = 1e-12;
4
4
  export declare const DRAG_THRESHOLD = 3;
5
5
  export declare const WHEEL_SENSITIVITY = 0.001;
6
6
  export declare function closeTo(a: number | null | undefined, b: number | null | undefined): boolean;
7
- export declare function createPanModeHandlers(setActive: (active: boolean) => void): {
7
+ /**
8
+ * Space-held pan mode for brushes, wired to <svelte:window> keydown, keyup and
9
+ * blur. Each change calls `reroutePress` (the chart's), so a press already
10
+ * under way switches between panning and brushing where the pointer is now:
11
+ * with three-finger drag on a trackpad, one press can last seconds after the
12
+ * fingers lift. Key repeats while Space is held change nothing. Blur ends pan
13
+ * mode: a Space released while the window is unfocused sends no keyup.
14
+ */
15
+ export declare function createPanModeHandlers(setActive: (active: boolean) => void, reroutePress: () => void): {
8
16
  handleKeyDown: (event: KeyboardEvent) => void;
9
17
  handleKeyUp: (event: KeyboardEvent) => void;
18
+ handleBlur: () => void;
10
19
  };
20
+ /** The parts of a brush overlay a press can land on, from its `data-brush-handle`. */
21
+ export type BrushHandle = 'move' | 'left' | 'right' | 'top' | 'bottom' | 'nw' | 'ne' | 'sw' | 'se';
22
+ /**
23
+ * The brush handle under a press, read from the overlay's DOM: its element's
24
+ * `data-brush-handle`, and the `data-brush-owner` of its overlay (a parallel
25
+ * axis's column), or null when the press is not on a brush.
26
+ */
27
+ export declare function brushHandleAt(target: Element | null): {
28
+ handle: BrushHandle;
29
+ owner: string | null;
30
+ } | null;
11
31
  export declare function scaleBrushExtent1D(min: number, max: number, scaleFactor: number): [number, number];
12
32
  export declare function scaleBrushExtent2D(xMin: number, xMax: number, yMin: number, yMax: number, scaleFactor: number): {
13
33
  x: [number, number];
@@ -15,18 +15,46 @@ export function closeTo(a, b) {
15
15
  }
16
16
  return diff < EPS_ABSOLUTE;
17
17
  }
18
- export function createPanModeHandlers(setActive) {
18
+ /**
19
+ * Space-held pan mode for brushes, wired to <svelte:window> keydown, keyup and
20
+ * blur. Each change calls `reroutePress` (the chart's), so a press already
21
+ * under way switches between panning and brushing where the pointer is now:
22
+ * with three-finger drag on a trackpad, one press can last seconds after the
23
+ * fingers lift. Key repeats while Space is held change nothing. Blur ends pan
24
+ * mode: a Space released while the window is unfocused sends no keyup.
25
+ */
26
+ export function createPanModeHandlers(setActive, reroutePress) {
27
+ let active = false;
28
+ const set = (next) => {
29
+ if (next === active)
30
+ return;
31
+ active = next;
32
+ setActive(next);
33
+ reroutePress();
34
+ };
19
35
  const handleKeyDown = (event) => {
20
- if (event.code === 'Space') {
21
- setActive(true);
22
- }
36
+ if (event.code === 'Space')
37
+ set(true);
23
38
  };
24
39
  const handleKeyUp = (event) => {
25
- if (event.code === 'Space') {
26
- setActive(false);
27
- }
40
+ if (event.code === 'Space')
41
+ set(false);
42
+ };
43
+ return { handleKeyDown, handleKeyUp, handleBlur: () => set(false) };
44
+ }
45
+ /**
46
+ * The brush handle under a press, read from the overlay's DOM: its element's
47
+ * `data-brush-handle`, and the `data-brush-owner` of its overlay (a parallel
48
+ * axis's column), or null when the press is not on a brush.
49
+ */
50
+ export function brushHandleAt(target) {
51
+ const el = target?.closest?.('[data-brush-handle]');
52
+ if (!el)
53
+ return null;
54
+ return {
55
+ handle: el.getAttribute('data-brush-handle'),
56
+ owner: el.closest('[data-brush-owner]')?.getAttribute('data-brush-owner') ?? null
28
57
  };
29
- return { handleKeyDown, handleKeyUp };
30
58
  }
31
59
  export function scaleBrushExtent1D(min, max, scaleFactor) {
32
60
  const center = (min + max) / 2;
@@ -5,13 +5,23 @@
5
5
  * import { setQueryContext } from './';
6
6
  * setQueryContext(wasm.getConnection()); // Promise OK — resolves lazily
7
7
  *
8
- * Usage in spec renderer:
9
- * context.set('query-engine', new QueryEngine(connection));
10
- * context.set('filter-groups', new FilterGroupRegistry());
8
+ * Usage outside a component (the spec renderer), passing contexts to mount():
9
+ * const { filterGroups } = createQueryServices(queryEngine);
10
+ * context.set('query-engine', queryEngine);
11
+ * context.set('filter-groups', filterGroups);
11
12
  */
12
13
  import { QueryEngine } from './QueryEngine';
13
14
  import type { DuckDBConnection } from './QueryEngine';
14
15
  import { FilterGroupRegistry } from './FilterGroupRegistry.svelte';
16
+ /**
17
+ * Create the FilterGroupRegistry for an engine and wire both to the schema
18
+ * service. Brushes and toggles reach the engine only through this wiring:
19
+ * a FilterGroup without an engine updates its selection but never re-queries.
20
+ */
21
+ export declare function createQueryServices(queryEngine: QueryEngine): {
22
+ queryEngine: QueryEngine;
23
+ filterGroups: FilterGroupRegistry;
24
+ };
15
25
  /**
16
26
  * Create QueryEngine + FilterGroupRegistry and set Svelte contexts.
17
27
  * Call this synchronously in a component's <script> block.
@@ -5,24 +5,33 @@
5
5
  * import { setQueryContext } from './';
6
6
  * setQueryContext(wasm.getConnection()); // Promise OK — resolves lazily
7
7
  *
8
- * Usage in spec renderer:
9
- * context.set('query-engine', new QueryEngine(connection));
10
- * context.set('filter-groups', new FilterGroupRegistry());
8
+ * Usage outside a component (the spec renderer), passing contexts to mount():
9
+ * const { filterGroups } = createQueryServices(queryEngine);
10
+ * context.set('query-engine', queryEngine);
11
+ * context.set('filter-groups', filterGroups);
11
12
  */
12
13
  import { setContext, getContext } from 'svelte';
13
14
  import { QueryEngine } from './QueryEngine';
14
15
  import { FilterGroupRegistry } from './FilterGroupRegistry.svelte';
15
16
  import { tableSchemaService } from '../data/TableSchemaService.svelte';
17
+ /**
18
+ * Create the FilterGroupRegistry for an engine and wire both to the schema
19
+ * service. Brushes and toggles reach the engine only through this wiring:
20
+ * a FilterGroup without an engine updates its selection but never re-queries.
21
+ */
22
+ export function createQueryServices(queryEngine) {
23
+ const filterGroups = new FilterGroupRegistry();
24
+ filterGroups.setEngine(queryEngine);
25
+ tableSchemaService.setQueryEngine(queryEngine);
26
+ return { queryEngine, filterGroups };
27
+ }
16
28
  /**
17
29
  * Create QueryEngine + FilterGroupRegistry and set Svelte contexts.
18
30
  * Call this synchronously in a component's <script> block.
19
31
  * Accepts a Promise<DuckDBConnection> — queries defer until it resolves.
20
32
  */
21
33
  export function setQueryContext(connection) {
22
- const queryEngine = new QueryEngine(connection);
23
- const filterGroups = new FilterGroupRegistry();
24
- filterGroups.setEngine(queryEngine);
25
- tableSchemaService.setQueryEngine(queryEngine);
34
+ const { queryEngine, filterGroups } = createQueryServices(new QueryEngine(connection));
26
35
  setContext('query-engine', queryEngine);
27
36
  setContext('filter-groups', filterGroups);
28
37
  return { queryEngine, filterGroups };
@@ -17,8 +17,7 @@
17
17
  import { mount, unmount } from 'svelte';
18
18
  import { ScaleRegistry } from '../scales/ScaleRegistry.svelte';
19
19
  import { GPUCoordinator } from '../gpu/GPUCoordinator.svelte';
20
- import { FilterGroupRegistry } from '../query-engine/FilterGroupRegistry.svelte';
21
- import { tableSchemaService } from '../data/TableSchemaService.svelte';
20
+ import { createQueryServices } from '../query-engine';
22
21
  import { getOrCreateQueryEngine, loadData } from './LazyQueryEngine';
23
22
  import LayoutFromSpec from './LayoutFromSpec.svelte';
24
23
  /**
@@ -47,8 +46,7 @@ export async function render(target, spec, options = {}) {
47
46
  }
48
47
  // 4. Build context map — injected into Svelte mount() so child components
49
48
  // find these via getContext() calls (same keys as initFjader())
50
- const filterGroups = new FilterGroupRegistry();
51
- tableSchemaService.setQueryEngine(queryEngine);
49
+ const { filterGroups } = createQueryServices(queryEngine);
52
50
  const context = new Map();
53
51
  context.set('query-engine', queryEngine);
54
52
  context.set('filter-groups', filterGroups);
package/dist/types.d.ts CHANGED
@@ -338,12 +338,34 @@ export interface ChartContext {
338
338
  readonly fxDomain?: DimValue[] | null;
339
339
  readonly config: ChartConfig;
340
340
  onChartClick: ((event: PointerEvent) => void) | null;
341
- onInteractorPointerDown: ((event: PointerEvent, innerPoint: Point) => boolean) | null;
341
+ /**
342
+ * A press, offered to the interactor before the chart pans. `target` is the
343
+ * element under the pointer, which a brush reads for the handle it is on
344
+ * (brushHandleAt); `innerPoint` is in plot-area coordinates. Returns true when
345
+ * the interactor takes the press. Called again when the chart re-routes a
346
+ * press under way (reroutePress).
347
+ */
348
+ onInteractorPointerDown: ((target: Element, pointerId: number, innerPoint: Point) => boolean) | null;
342
349
  /** Every pointer move but a pan; `innerPoint` is the pointer in plot-area coordinates (a
343
350
  * shared buffer: read it before calling back into the layout). Returns true when the
344
351
  * interactor handled the move (a drag it captured). */
345
352
  onInteractorPointerMove: ((event: PointerEvent, innerPoint: Point) => boolean) | null;
346
353
  onInteractorPointerUp: ((event: PointerEvent) => boolean) | null;
354
+ /**
355
+ * End the interactor's drag where it is, as a pointer up would but without
356
+ * treating a drag that never moved as a click (which clears a brush). The
357
+ * chart calls it when it re-routes the press.
358
+ */
359
+ onInteractorDragEnd: (() => void) | null;
360
+ /**
361
+ * Route the press under way again, as if it began now where the pointer is:
362
+ * end what it is doing (a pan, or the interactor's drag), then offer it to
363
+ * the interactor and pan if declined, as a new press would be. Brushes call
364
+ * it when Space changes, since one press can outlast that: with three-finger
365
+ * drag on a trackpad it goes on for seconds after the fingers lift. Without a
366
+ * press under way it does nothing.
367
+ */
368
+ reroutePress: () => void;
347
369
  /**
348
370
  * Selection sources to activate when the pointer enters the chart, so their
349
371
  * preagg tables exist before the first gesture. Interactors add their
package/package.json CHANGED
@@ -18,7 +18,7 @@
18
18
  "type": "git",
19
19
  "url": "git+https://github.com/dlyngman/fjader.git"
20
20
  },
21
- "version": "0.2.0",
21
+ "version": "0.2.1",
22
22
  "type": "module",
23
23
  "svelte": "./dist/index.js",
24
24
  "exports": {