@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.
- package/dist/components/charts/ChartCell.svelte +191 -34
- package/dist/components/charts/ChartContainer.svelte +3 -0
- package/dist/components/marks/RectYMark.svelte +0 -1
- package/dist/interactors/BrushOverlay.svelte +17 -10
- package/dist/interactors/BrushOverlay.svelte.d.ts +2 -2
- package/dist/interactors/DerivedInterval1D.svelte +28 -17
- package/dist/interactors/Interval1D.svelte +52 -46
- package/dist/interactors/Interval2D.svelte +48 -53
- package/dist/interactors/ParallelBrushes.svelte +52 -52
- package/dist/interactors/brush-utils.d.ts +21 -1
- package/dist/interactors/brush-utils.js +36 -8
- package/dist/query-engine/index.d.ts +13 -3
- package/dist/query-engine/index.js +16 -7
- package/dist/spec/index.js +2 -4
- package/dist/types.d.ts +23 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
652
|
-
|
|
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?.(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
736
|
-
//
|
|
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
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
126
|
-
const dataMax = Math.max(
|
|
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 (!
|
|
141
|
-
|
|
142
|
-
const fieldStr = resolvedField ?? '';
|
|
150
|
+
if (!ref) return;
|
|
143
151
|
|
|
144
152
|
targetGroup.upsertManual(
|
|
145
153
|
sourceKey,
|
|
146
|
-
isBetween(
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
117
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
isCapturing =
|
|
301
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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 =
|
|
66
|
-
const { handleKeyDown, handleKeyUp } = createPanModeHandlers(
|
|
67
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
254
|
-
emitUpdate(col);
|
|
255
|
-
brush.phase = 'idle';
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
hasDragged = false;
|
|
274
|
+
endDrag(col);
|
|
259
275
|
return true;
|
|
260
276
|
}
|
|
261
277
|
|
|
262
|
-
|
|
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 (
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
if (!
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
}
|
|
36
|
+
if (event.code === 'Space')
|
|
37
|
+
set(true);
|
|
23
38
|
};
|
|
24
39
|
const handleKeyUp = (event) => {
|
|
25
|
-
if (event.code === 'Space')
|
|
26
|
-
|
|
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
|
|
9
|
-
*
|
|
10
|
-
* context.set('
|
|
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
|
|
9
|
-
*
|
|
10
|
-
* context.set('
|
|
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 };
|
package/dist/spec/index.js
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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
|
-
|
|
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
|