/ src / lib / utils / interaction.svelte.ts
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  }