@fjader/svelte 0.2.0 → 0.2.2
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/components/rendering/Areas.svelte +2 -3
- package/dist/components/rendering/Lines.svelte +2 -3
- package/dist/components/rendering/ParallelLines.svelte +2 -1
- package/dist/data/DataAccessor.js +1 -1
- package/dist/gpu/ColorScaleShaderLib.d.ts +1 -1
- package/dist/gpu/ColorScaleShaderLib.js +3 -2
- package/dist/gpu/NanShaderLib.d.ts +30 -0
- package/dist/gpu/NanShaderLib.js +42 -0
- package/dist/gpu/ResolveShader.d.ts +1 -1
- package/dist/gpu/ResolveShader.js +4 -3
- package/dist/gpu/SizeScaleShaderLib.d.ts +1 -1
- package/dist/gpu/SizeScaleShaderLib.js +2 -1
- package/dist/gpu/decimation/M4Shader.d.ts +13 -7
- package/dist/gpu/decimation/M4Shader.js +79 -64
- 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
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { RendererHost } from './RendererHost.svelte';
|
|
4
4
|
import { getGPUContext } from '../../gpu';
|
|
5
5
|
import { getCurveWGSL, getCurveResolution } from '../../gpu/CurveShaderLib';
|
|
6
|
+
import { NAN_WGSL } from '../../gpu/NanShaderLib';
|
|
6
7
|
import { DEFAULT_FILL_COLOR } from '../../scales/color-utils';
|
|
7
8
|
import type { ChartContext, WebGPUCanvasContext, CurveType } from '../../types';
|
|
8
9
|
|
|
@@ -159,9 +160,7 @@
|
|
|
159
160
|
@group(1) @binding(4) var<storage, read> colorValues: array<f32>;
|
|
160
161
|
{{FACET_BINDING}}
|
|
161
162
|
|
|
162
|
-
|
|
163
|
-
return v != v;
|
|
164
|
-
}
|
|
163
|
+
${NAN_WGSL}
|
|
165
164
|
|
|
166
165
|
// Map (dimension, boundary) to (x, y) based on orientation
|
|
167
166
|
fn makePoint(dimVal: f32, boundaryVal: f32) -> vec2<f32> {
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { RendererHost } from './RendererHost.svelte';
|
|
4
4
|
import { getGPUContext } from '../../gpu';
|
|
5
5
|
import { getCurveWGSL, getCurveResolution } from '../../gpu/CurveShaderLib';
|
|
6
|
+
import { NAN_WGSL } from '../../gpu/NanShaderLib';
|
|
6
7
|
import { DEFAULT_STROKE_COLOR } from '../../scales/color-utils';
|
|
7
8
|
import type { ChartContext, WebGPUCanvasContext, CurveType } from '../../types';
|
|
8
9
|
import type { M4Decimator } from '../../gpu/decimation/M4Decimator.svelte';
|
|
@@ -156,9 +157,7 @@
|
|
|
156
157
|
@group(1) @binding(3) var<storage, read> colorValues: array<f32>;
|
|
157
158
|
{{FACET_BINDING}}
|
|
158
159
|
|
|
159
|
-
|
|
160
|
-
return v != v;
|
|
161
|
-
}
|
|
160
|
+
${NAN_WGSL}
|
|
162
161
|
|
|
163
162
|
const DISCARD_POS = vec4<f32>(2.0, 2.0, 2.0, 1.0);
|
|
164
163
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { RendererHost } from './RendererHost.svelte';
|
|
4
4
|
import { getGPUContext } from '../../gpu';
|
|
5
5
|
import { getCurveWGSL, getCurveResolution } from '../../gpu/CurveShaderLib';
|
|
6
|
+
import { NAN_WGSL } from '../../gpu/NanShaderLib';
|
|
6
7
|
import { DEFAULT_STROKE_COLOR } from '../../scales/color-utils';
|
|
7
8
|
import type { ChartContext, CurveType, WebGPUCanvasContext } from '../../types';
|
|
8
9
|
|
|
@@ -123,7 +124,7 @@
|
|
|
123
124
|
// writes; only linear math is applied to them today.
|
|
124
125
|
@group(1) @binding(3) var<uniform> axisScales: array<ScaleParam, ${MAX_AXES}>;
|
|
125
126
|
|
|
126
|
-
|
|
127
|
+
${NAN_WGSL}
|
|
127
128
|
const DISCARD_POS = vec4<f32>(2.0, 2.0, 2.0, 1.0);
|
|
128
129
|
|
|
129
130
|
fn getAxisOrder(visualIdx: u32) -> u32 {
|
|
@@ -29,7 +29,7 @@ export class ArrowDataAccessor {
|
|
|
29
29
|
// FAST PATH: Single chunk numeric - try to return underlying buffer directly.
|
|
30
30
|
// NULL handling: Query-level COALESCE(CAST(expr AS REAL), 'NaN'::REAL)
|
|
31
31
|
// converts SQL NULLs to the NaN float value, so Arrow columns from Fjader
|
|
32
|
-
// queries have nullCount=0 and clean buffers. GPU shaders detect NaN
|
|
32
|
+
// queries have nullCount=0 and clean buffers. GPU shaders detect NaN by its bit pattern (NanShaderLib)
|
|
33
33
|
// and discard those vertices to create gaps in lines/areas.
|
|
34
34
|
if (!isStringType && col.data.length === 1) {
|
|
35
35
|
const chunk = col.data[0];
|
|
@@ -17,4 +17,4 @@ export declare const COLOR_CONFIG_SLOT_BASE_OFFSET = 32;
|
|
|
17
17
|
export declare const NO_SLOT_TABLE = 4294967295;
|
|
18
18
|
export declare const COLOR_SCALE_STRUCTS = "\nstruct ColorConfig {\n domainMin: f32, // byte 0 \u2014 pre-transformed for non-linear types\n domainMax: f32, // byte 4 \u2014 pre-transformed for non-linear types\n rampOffset: u32, // byte 8\n paletteOffset: u32, // byte 12\n paletteSize: u32, // byte 16\n scaleType: u32, // byte 20 \u2014 scheme rendering type (ordinal/sequential/diverging/cyclical)\n transformType: u32, // byte 24 \u2014 normalization transform (linear/log/symlog/sqrt/pow)\n transformParam: f32, // byte 28 \u2014 symlog constant C, or pow exponent\n slotBase: u32, // byte 32 \u2014 base into identitySlots, or NO_SLOT_TABLE\n _pad0: u32, // byte 36\n _pad1: u32, // byte 40\n _pad2: u32, // byte 44\n}\n\n/**\n * slotBase when this scale's channel already carries a palette index rather\n * than an identity id. Distinct from base 0, which is a real block \u2014 the\n * permanently empty one \u2014 so it cannot be confused with \"not placed\".\n */\nconst NO_SLOT_TABLE: u32 = 0xFFFFFFFFu;\n\nconst COLOR_SCALE_ORDINAL: u32 = 0u;\nconst COLOR_SCALE_SEQUENTIAL: u32 = 1u;\nconst COLOR_SCALE_DIVERGING: u32 = 2u;\nconst COLOR_SCALE_CYCLICAL: u32 = 3u;\n\nconst COLOR_TRANSFORM_LINEAR: u32 = 0u;\nconst COLOR_TRANSFORM_LOG: u32 = 1u;\nconst COLOR_TRANSFORM_SYMLOG: u32 = 2u;\nconst COLOR_TRANSFORM_SQRT: u32 = 3u;\nconst COLOR_TRANSFORM_POW: u32 = 4u;\n";
|
|
19
19
|
export declare const COLOR_SCALE_BINDINGS = "\n@group(2) @binding(1) var<storage, read> colorConfigs: array<ColorConfig>;\n@group(2) @binding(2) var<storage, read> colorRamps: array<vec4<f32>>;\n@group(2) @binding(3) var<storage, read> ordinalPalettes: array<vec4<f32>>;\n";
|
|
20
|
-
export declare const COLOR_SCALE_FUNCTIONS
|
|
20
|
+
export declare const COLOR_SCALE_FUNCTIONS: string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isNanExpr } from './NanShaderLib';
|
|
1
2
|
// src/lib/gpu/ColorScaleShaderLib.ts
|
|
2
3
|
// ---------------------------------------------------------------------------
|
|
3
4
|
// Design note: branching vs JIT-compiled shaders for color normalization
|
|
@@ -148,7 +149,7 @@ fn getColorFromScale(scaleIndex: u32, value: f32, nullColor: vec4<f32>) -> vec4<
|
|
|
148
149
|
return samplePalette(config, catIdx);
|
|
149
150
|
}
|
|
150
151
|
|
|
151
|
-
if (value
|
|
152
|
+
if (${isNanExpr('value')}) {
|
|
152
153
|
return nullColor;
|
|
153
154
|
}
|
|
154
155
|
|
|
@@ -168,7 +169,7 @@ fn getOrdinalColor(scaleIndex: u32, categoryIndex: u32, nullColor: vec4<f32>) ->
|
|
|
168
169
|
}
|
|
169
170
|
|
|
170
171
|
fn getContinuousColor(scaleIndex: u32, value: f32, nullColor: vec4<f32>) -> vec4<f32> {
|
|
171
|
-
if (value
|
|
172
|
+
if (${isNanExpr('value')}) {
|
|
172
173
|
return nullColor;
|
|
173
174
|
}
|
|
174
175
|
let config = colorConfigs[scaleIndex];
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NaN tests for WGSL that a shader compiler cannot remove.
|
|
3
|
+
*
|
|
4
|
+
* fjader sends nulls to the GPU as NaN. WGSL lets an implementation assume
|
|
5
|
+
* floats are finite, and Apple's Metal compilers act on that: `v != v` and
|
|
6
|
+
* `!(v == v)` can compile to `false`, so a NaN goes undetected. Whether that
|
|
7
|
+
* happens depends on the GPU generation and on the code around the test, so a
|
|
8
|
+
* check that works on one Mac can be gone on another. /test/nan-probe shows
|
|
9
|
+
* what a given GPU does.
|
|
10
|
+
*
|
|
11
|
+
* These test the bit pattern instead: an exponent of all ones and a non-zero
|
|
12
|
+
* mantissa. That is integer arithmetic, which no float assumption touches.
|
|
13
|
+
*
|
|
14
|
+
* `NAN_BITS` is the quiet NaN written wherever a shader needs to produce one.
|
|
15
|
+
* Writing it as bits into a u32 view of the buffer keeps it exact; producing
|
|
16
|
+
* a NaN through float arithmetic is not guaranteed to.
|
|
17
|
+
*/
|
|
18
|
+
/** The quiet NaN fjader writes, as bits. */
|
|
19
|
+
export declare const NAN_BITS_HEX = "0x7FC00000u";
|
|
20
|
+
/**
|
|
21
|
+
* `isNanBits(u)` for a value read through a u32 view, and `isNaN(v)` for one
|
|
22
|
+
* already loaded as f32. Prefer the u32 view where a shader can read the
|
|
23
|
+
* buffer that way: it never loads the NaN as a float at all.
|
|
24
|
+
*/
|
|
25
|
+
export declare const NAN_WGSL = "\nconst NAN_BITS: u32 = 0x7FC00000u;\n\nfn isNanBits(u: u32) -> bool {\n\treturn (u & 0x7fffffffu) > 0x7f800000u;\n}\n\nfn isNaN(v: f32) -> bool {\n\treturn isNanBits(bitcast<u32>(v));\n}\n";
|
|
26
|
+
/**
|
|
27
|
+
* The same test as an expression, for shader libraries that are pasted into
|
|
28
|
+
* other shaders and so cannot define functions those shaders may define too.
|
|
29
|
+
*/
|
|
30
|
+
export declare function isNanExpr(f32Expr: string): string;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NaN tests for WGSL that a shader compiler cannot remove.
|
|
3
|
+
*
|
|
4
|
+
* fjader sends nulls to the GPU as NaN. WGSL lets an implementation assume
|
|
5
|
+
* floats are finite, and Apple's Metal compilers act on that: `v != v` and
|
|
6
|
+
* `!(v == v)` can compile to `false`, so a NaN goes undetected. Whether that
|
|
7
|
+
* happens depends on the GPU generation and on the code around the test, so a
|
|
8
|
+
* check that works on one Mac can be gone on another. /test/nan-probe shows
|
|
9
|
+
* what a given GPU does.
|
|
10
|
+
*
|
|
11
|
+
* These test the bit pattern instead: an exponent of all ones and a non-zero
|
|
12
|
+
* mantissa. That is integer arithmetic, which no float assumption touches.
|
|
13
|
+
*
|
|
14
|
+
* `NAN_BITS` is the quiet NaN written wherever a shader needs to produce one.
|
|
15
|
+
* Writing it as bits into a u32 view of the buffer keeps it exact; producing
|
|
16
|
+
* a NaN through float arithmetic is not guaranteed to.
|
|
17
|
+
*/
|
|
18
|
+
/** The quiet NaN fjader writes, as bits. */
|
|
19
|
+
export const NAN_BITS_HEX = '0x7FC00000u';
|
|
20
|
+
/**
|
|
21
|
+
* `isNanBits(u)` for a value read through a u32 view, and `isNaN(v)` for one
|
|
22
|
+
* already loaded as f32. Prefer the u32 view where a shader can read the
|
|
23
|
+
* buffer that way: it never loads the NaN as a float at all.
|
|
24
|
+
*/
|
|
25
|
+
export const NAN_WGSL = /* wgsl */ `
|
|
26
|
+
const NAN_BITS: u32 = ${NAN_BITS_HEX};
|
|
27
|
+
|
|
28
|
+
fn isNanBits(u: u32) -> bool {
|
|
29
|
+
return (u & 0x7fffffffu) > 0x7f800000u;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
fn isNaN(v: f32) -> bool {
|
|
33
|
+
return isNanBits(bitcast<u32>(v));
|
|
34
|
+
}
|
|
35
|
+
`;
|
|
36
|
+
/**
|
|
37
|
+
* The same test as an expression, for shader libraries that are pasted into
|
|
38
|
+
* other shaders and so cannot define functions those shaders may define too.
|
|
39
|
+
*/
|
|
40
|
+
export function isNanExpr(f32Expr) {
|
|
41
|
+
return `((bitcast<u32>(${f32Expr}) & 0x7fffffffu) > 0x7f800000u)`;
|
|
42
|
+
}
|
|
@@ -69,7 +69,7 @@ export declare const RESOLVE_IMMEDIATE_SIZE = 12;
|
|
|
69
69
|
* hand every dispatch the last one's values. Immediates are pass state, set in
|
|
70
70
|
* command order, so each dispatch reads its own.
|
|
71
71
|
*/
|
|
72
|
-
export declare const RESOLVE_SHADER
|
|
72
|
+
export declare const RESOLVE_SHADER: string;
|
|
73
73
|
/**
|
|
74
74
|
* The same expression in JS, and the reason it exists.
|
|
75
75
|
*
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isNanExpr } from './NanShaderLib';
|
|
1
2
|
/**
|
|
2
3
|
* The resolve kernel: `resolved[j] = mix(from[j], to[j], t)` over one range.
|
|
3
4
|
*
|
|
@@ -96,10 +97,10 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
|
|
96
97
|
let b = valueTo[j];
|
|
97
98
|
// A NaN on either side means one end of this transition is not a number, so
|
|
98
99
|
// there is nothing to interpolate along. Take the destination and be done.
|
|
99
|
-
// Note the test has to be explicit
|
|
100
|
-
//
|
|
100
|
+
// Note the test has to be explicit, and on the bits: mix() would silently
|
|
101
|
+
// propagate a NaN, and a compiler may fold a float test like a != a to false.
|
|
101
102
|
// Equal ends take b too, so they come out exact on any driver — see above.
|
|
102
|
-
let takeTo = a == b || a
|
|
103
|
+
let takeTo = a == b || ${isNanExpr('a')} || ${isNanExpr('b')};
|
|
103
104
|
resolved[j] = select(mix(a, b, params.t), b, takeTo);
|
|
104
105
|
}
|
|
105
106
|
`;
|
|
@@ -28,4 +28,4 @@ export declare const MAX_SIZE_SLOTS = 64;
|
|
|
28
28
|
* in WGSL, and the shared slot table already relies on the same thing.
|
|
29
29
|
*/
|
|
30
30
|
export declare const SIZE_SCALE_BINDINGS = "\n@group(2) @binding(4) var<uniform> sizeConfigs: array<SizeConfig, 64>;\n";
|
|
31
|
-
export declare const SIZE_SCALE_FUNCTIONS
|
|
31
|
+
export declare const SIZE_SCALE_FUNCTIONS: string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isNanExpr } from './NanShaderLib';
|
|
1
2
|
// src/lib/gpu/SizeScaleShaderLib.js
|
|
2
3
|
export const SIZE_SCALE_STRUCTS = /* wgsl */ `
|
|
3
4
|
struct SizeConfig {
|
|
@@ -43,7 +44,7 @@ fn getSizeFromScale(scaleIndex: u32, value: f32) -> f32 {
|
|
|
43
44
|
let config = sizeConfigs[scaleIndex];
|
|
44
45
|
|
|
45
46
|
// Handle NaN → rangeMin
|
|
46
|
-
if (value
|
|
47
|
+
if (${isNanExpr('value')}) {
|
|
47
48
|
return config.rangeMin;
|
|
48
49
|
}
|
|
49
50
|
|