interaction.svelte.ts
1 import type { Action } from 'svelte/action'; 2 3 export interface InteractionPoint { 4 x: number; 5 y: number; 6 } 7 8 export interface InteractionResult<T> { 9 node: T | null; 10 tooltipX?: number; 11 tooltipY?: number; 12 } 13 14 export interface ChartInteractionOptions<T> { 15 /** Reference to the element for coordinate mapping (SVG or DOM). */ 16 getElement: () => HTMLElement | SVGSVGElement | null; 17 /** Callback to find the node by ID and its preferred tooltip snap point. */ 18 findNode: (id: string | null, x: number, y: number) => InteractionResult<T>; 19 /** Initial touch threshold before committing to a scrub (default: 5px). */ 20 threshold?: number; 21 } 22 23 /** 24 * A Svelte 5 utility for unified high-fidelity chart interactions. 25 * PIVOT: Uses Semantic Hit-Testing via [data-interaction-target] attributes. 26 */ 27 export function createChartInteraction<T>(options: ChartInteractionOptions<T>) { 28 let isScrubbing = $state(false); 29 let scrollLocked = false; 30 let hoveredNode = $state<T | null>(null); 31 let tooltipPos = $state<InteractionPoint>({ x: 0, y: 0 }); 32 33 const threshold = options.threshold ?? 5; 34 let startPos = { x: 0, y: 0 }; 35 36 function handleInteraction(e: PointerEvent | TouchEvent) { 37 const el = options.getElement(); 38 if (!el) return; 39 40 const isTouch = 'touches' in e; 41 const touches = isTouch ? (e as TouchEvent).touches : null; 42 if (isTouch && touches && touches.length === 0) return; 43 44 const clientX = touches ? touches[0].clientX : (e as PointerEvent).clientX; 45 const clientY = touches ? touches[0].clientY : (e as PointerEvent).clientY; 46 47 // 1. Determine Semantic Target 48 // For move events, we use elementFromPoint to see what is currently under the finger 49 // For start events, e.target is fine. 50 const targetElement = e.type.includes('move') 51 ? document.elementFromPoint(clientX, clientY) 52 : e.target as Element; 53 54 const interactionTarget = targetElement?.closest?.('[data-interaction-target]') as HTMLElement | null; 55 const targetId = interactionTarget?.dataset?.interactionTarget || null; 56 57 // 2. Intent Detection 58 if (e.type === 'touchstart' || e.type === 'pointerdown' || e.type === 'mousedown') { 59 startPos = { x: clientX, y: clientY }; 60 scrollLocked = false; 61 // For mouse/pointer, we commit immediately. For touch, we wait. 62 if (!isTouch) { 63 isScrubbing = !!targetId; 64 } 65 } 66 67 if (isTouch && !isScrubbing && !scrollLocked) { 68 const dx = Math.abs(clientX - startPos.x); 69 const dy = Math.abs(clientY - startPos.y); 70 71 if (dy > threshold && dy > dx) { 72 scrollLocked = true; 73 return; 74 } 75 76 if (dx > threshold && dx >= dy) { 77 if (targetId) { 78 isScrubbing = true; 79 } 80 } 81 } 82 83 // 3. Coordination Mapping (Local space) 84 let x = 0, y = 0; 85 if (el instanceof SVGSVGElement) { 86 const pt = el.createSVGPoint(); 87 pt.x = clientX; pt.y = clientY; 88 const ctm = el.getScreenCTM(); 89 if (ctm) { 90 const svgPt = pt.matrixTransform(ctm.inverse()); 91 x = svgPt.x; y = svgPt.y; 92 } 93 } else { 94 const rect = el.getBoundingClientRect(); 95 x = clientX - rect.left; 96 y = clientY - rect.top; 97 } 98 99 // 4. Node Lookup 100 const result = options.findNode(targetId, x, y); 101 102 if (isScrubbing || !isTouch) { 103 // Only block scrolling if we actually have a node or are mouse-dragging 104 if (isTouch && result.node && e.cancelable) e.preventDefault(); 105 106 hoveredNode = result.node; 107 108 if (result.node && result.tooltipX !== undefined && result.tooltipY !== undefined) { 109 if (el instanceof SVGSVGElement) { 110 const snapPt = el.createSVGPoint(); 111 snapPt.x = result.tooltipX; 112 snapPt.y = result.tooltipY; 113 const ctm = el.getScreenCTM(); 114 if (ctm) { 115 const screenPos = snapPt.matrixTransform(ctm); 116 tooltipPos = { x: screenPos.x, y: screenPos.y }; 117 } 118 } else { 119 const rect = el.getBoundingClientRect(); 120 tooltipPos = { 121 x: rect.left + result.tooltipX, 122 y: rect.top + result.tooltipY 123 }; 124 } 125 } else if (!isTouch) { 126 tooltipPos = { x: clientX, y: clientY }; 127 } 128 } 129 } 130 131 function handleEnd() { 132 isScrubbing = false; 133 hoveredNode = null; 134 } 135 136 function handlePointerLeave(e: PointerEvent) { 137 if (e.pointerType === 'mouse') { 138 handleEnd(); 139 } 140 } 141 142 const setupTouch: Action<HTMLElement> = (node) => { 143 const opts = { passive: false }; 144 node.addEventListener('touchstart', handleInteraction, opts); 145 node.addEventListener('touchmove', handleInteraction, opts); 146 node.addEventListener('touchend', handleEnd, opts); 147 node.addEventListener('touchcancel', handleEnd, opts); 148 149 return { 150 destroy() { 151 node.removeEventListener('touchstart', handleInteraction); 152 node.removeEventListener('touchmove', handleInteraction); 153 node.removeEventListener('touchend', handleEnd); 154 node.removeEventListener('touchcancel', handleEnd); 155 } 156 }; 157 }; 158 159 return { 160 get hoveredNode() { return hoveredNode; }, 161 get tooltipPos() { return tooltipPos; }, 162 get isScrubbing() { return isScrubbing; }, 163 setupTouch, 164 handlers: { 165 onpointermove: handleInteraction, 166 onpointerdown: handleInteraction, 167 onpointerup: handleEnd, 168 onpointerleave: handlePointerLeave 169 } 170 }; 171 }