// Pan, zoom, parallax, and keyboard navigation for the tree. // // All shared mutable state lives on the controller (see controller.ts); these // handlers read/write it by reference. attach* functions return a cleanup that // removes the window-level listeners they added. import { gsap } from 'gsap'; import { ZOOM, PARALLAX, PAN_SPEED } from '$lib/config'; import type { SkillTreeController } from './controller'; /** Offset the background layers behind the tree to create parallax depth. */ export function updateParallax(ctrl: SkillTreeController) { const treeDeltaX = ctrl.treeContainer.x - ctrl.initialTreeX; const treeDeltaY = ctrl.treeContainer.y - ctrl.initialTreeY; ctrl.bg.starContainer.x = treeDeltaX * PARALLAX.star; ctrl.bg.starContainer.y = treeDeltaY * PARALLAX.star; ctrl.bg.nebulaContainer.x = treeDeltaX * PARALLAX.nebula; ctrl.bg.nebulaContainer.y = treeDeltaY * PARALLAX.nebula; ctrl.bg.deepBackgroundContainer.x = treeDeltaX * PARALLAX.gradient; ctrl.bg.deepBackgroundContainer.y = treeDeltaY * PARALLAX.gradient; // No scale parallax - backgrounds keep scale 1.0 } interface PanZoomOpts { onTooltipMove: (x: number, y: number) => void; } /** Wire mouse/touch/wheel pan + zoom. Returns a cleanup for window listeners. */ export function attachPanZoom(ctrl: SkillTreeController, opts: PanZoomOpts): () => void { const { app, treeContainer } = ctrl; const minZoom = ZOOM.min; const maxZoom = ZOOM.max; // ===== MOUSE PANNING ===== app.canvas.addEventListener('mousedown', (e: MouseEvent) => { ctrl.isDragging = true; ctrl.hasMoved = false; ctrl.dragStartX = e.clientX; ctrl.dragStartY = e.clientY; ctrl.containerStartX = treeContainer.x; ctrl.containerStartY = treeContainer.y; app.canvas.style.cursor = 'grabbing'; }); const onWindowMouseMove = (e: MouseEvent) => { if (!ctrl.isDragging) return; const dx = e.clientX - ctrl.dragStartX; const dy = e.clientY - ctrl.dragStartY; if (Math.abs(dx) > 5 || Math.abs(dy) > 5) { ctrl.hasMoved = true; } treeContainer.x = ctrl.containerStartX + dx; treeContainer.y = ctrl.containerStartY + dy; updateParallax(ctrl); }; window.addEventListener('mousemove', onWindowMouseMove); const onWindowMouseUp = () => { ctrl.isDragging = false; app.canvas.style.cursor = 'grab'; }; window.addEventListener('mouseup', onWindowMouseUp); app.canvas.style.cursor = 'grab'; // Track mouse position for tooltip app.canvas.addEventListener('mousemove', (e: MouseEvent) => { opts.onTooltipMove(e.clientX, e.clientY); }); // ===== TOUCH PANNING & PINCH ZOOM ===== app.canvas.addEventListener( 'touchstart', (e: TouchEvent) => { if (e.touches.length === 1) { ctrl.isDragging = true; ctrl.hasMoved = false; ctrl.isTouchZooming = false; ctrl.dragStartX = e.touches[0].clientX; ctrl.dragStartY = e.touches[0].clientY; ctrl.containerStartX = treeContainer.x; ctrl.containerStartY = treeContainer.y; } else if (e.touches.length === 2) { ctrl.isDragging = false; ctrl.isTouchZooming = true; ctrl.hasMoved = true; // Pinch zoom counts as movement const dx = e.touches[0].clientX - e.touches[1].clientX; const dy = e.touches[0].clientY - e.touches[1].clientY; ctrl.lastTouchDistance = Math.sqrt(dx * dx + dy * dy); } }, { passive: true } ); app.canvas.addEventListener( 'touchmove', (e: TouchEvent) => { if (e.touches.length === 1 && ctrl.isDragging && !ctrl.isTouchZooming) { const dx = e.touches[0].clientX - ctrl.dragStartX; const dy = e.touches[0].clientY - ctrl.dragStartY; if (Math.abs(dx) > 10 || Math.abs(dy) > 10) { ctrl.hasMoved = true; treeContainer.x = ctrl.containerStartX + dx; treeContainer.y = ctrl.containerStartY + dy; updateParallax(ctrl); } } else if (e.touches.length === 2 && ctrl.isTouchZooming) { const dx = e.touches[0].clientX - e.touches[1].clientX; const dy = e.touches[0].clientY - e.touches[1].clientY; const distance = Math.sqrt(dx * dx + dy * dy); if (ctrl.lastTouchDistance > 0) { const scale = distance / ctrl.lastTouchDistance; let newScale = treeContainer.scale.x * scale; newScale = Math.max(minZoom, Math.min(maxZoom, newScale)); // Zoom centered on origin node (same as mouse wheel zoom) treeContainer.scale.set(newScale); updateParallax(ctrl); } ctrl.lastTouchDistance = distance; ctrl.hasMoved = true; } }, { passive: true } ); app.canvas.addEventListener( 'touchend', (e: TouchEvent) => { if (e.touches.length === 0) { ctrl.isDragging = false; ctrl.isTouchZooming = false; ctrl.lastTouchDistance = 0; } else if (e.touches.length === 1) { ctrl.isTouchZooming = false; ctrl.isDragging = true; ctrl.hasMoved = true; // Consider this movement since we were zooming ctrl.dragStartX = e.touches[0].clientX; ctrl.dragStartY = e.touches[0].clientY; ctrl.containerStartX = treeContainer.x; ctrl.containerStartY = treeContainer.y; } }, { passive: true } ); // ===== WHEEL ZOOM ===== app.canvas.addEventListener('wheel', (e: WheelEvent) => { e.preventDefault(); const oldScale = treeContainer.scale.x; const zoomFactor = e.deltaY > 0 ? ZOOM.wheelOut : ZOOM.wheelIn; const newScale = Math.max(minZoom, Math.min(maxZoom, oldScale * zoomFactor)); // Zoom centered on origin node (0,0 in tree space): origin sits at // (treeContainer.x, treeContainer.y), so scaling keeps it anchored. treeContainer.scale.set(newScale); updateParallax(ctrl); }); return () => { window.removeEventListener('mousemove', onWindowMouseMove); window.removeEventListener('mouseup', onWindowMouseUp); }; } interface KeyboardOpts { onToggleStats: () => void; onCloseModal: () => void; isModalVisible: () => boolean; isStatsVisible: () => boolean; } /** Wire keyboard navigation (pan/zoom/reset/stats/escape). Returns cleanup. */ export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts): () => void { const { app, treeContainer } = ctrl; const minZoom = ZOOM.min; const maxZoom = ZOOM.max; function handleKeydown(e: KeyboardEvent) { // Toggle stats panel with 'C' key if (e.key === 'c' || e.key === 'C') { if (!opts.isModalVisible()) { opts.onToggleStats(); } } // Escape key priority: modal > stats panel if (e.key === 'Escape') { if (opts.isModalVisible()) { opts.onCloseModal(); } else if (opts.isStatsVisible()) { opts.onToggleStats(); } } const panSpeed = PAN_SPEED; const zoomSpeed = ZOOM.keyStep; // Arrow keys for panning if (e.key === 'ArrowUp') { e.preventDefault(); treeContainer.y += panSpeed; updateParallax(ctrl); } else if (e.key === 'ArrowDown') { e.preventDefault(); treeContainer.y -= panSpeed; updateParallax(ctrl); } else if (e.key === 'ArrowLeft') { e.preventDefault(); treeContainer.x += panSpeed; updateParallax(ctrl); } else if (e.key === 'ArrowRight') { e.preventDefault(); treeContainer.x -= panSpeed; updateParallax(ctrl); } // + and = for zoom in if (e.key === '+' || e.key === '=') { e.preventDefault(); const currentScale = treeContainer.scale.x; const newScale = Math.min(maxZoom, currentScale + zoomSpeed); treeContainer.scale.set(newScale); updateParallax(ctrl); } // - for zoom out if (e.key === '-') { e.preventDefault(); const currentScale = treeContainer.scale.x; const newScale = Math.max(minZoom, currentScale - zoomSpeed); treeContainer.scale.set(newScale); updateParallax(ctrl); } // R to reset view to origin if (e.key === 'r' || e.key === 'R') { e.preventDefault(); gsap.to(treeContainer, { x: app.screen.width / 2, y: app.screen.height / 2, duration: 0.5, ease: 'power2.out', onUpdate: () => updateParallax(ctrl), onComplete: () => { // Update parallax baseline after reset ctrl.initialTreeX = treeContainer.x; ctrl.initialTreeY = treeContainer.y; } }); gsap.to(treeContainer.scale, { x: ctrl.endScale, y: ctrl.endScale, duration: 0.5, ease: 'power2.out', onUpdate: () => updateParallax(ctrl) }); } } window.addEventListener('keydown', handleKeydown); return () => window.removeEventListener('keydown', handleKeydown); }