Files
skill-tree-portfolio/src/lib/pixi/interaction.ts
T
TehRiehlDealandClaude Opus 4.8 84112d8e42 refactor: extract PixiJS rendering into pixi/ modules
Phase 5 (behavior-preserving): decompose the ~1000-line onMount into focused
modules coordinated by a plain (non-reactive) controller object:
- pixi/types.ts        LineObject / NodeObject / BackgroundLayers
- pixi/controller.ts   shared mutable pan/zoom state + Pixi refs (NOT $state,
                       which would break PixiJS object identity)
- pixi/background.ts   gradient/nebula/stars/dust/vignette + ambient loops
- pixi/lines.ts        plasma-beam connections
- pixi/nodes.ts        origin + regular nodes; collapses the duplicated pointer
                       handlers into one attachNodeInteraction helper
- pixi/interaction.ts  pan/zoom/parallax + keyboard nav (return cleanups)
- pixi/animation.ts    sequenced entrance animation

onMount is now a thin orchestrator. SkillTree.svelte: 1177 -> 274 lines
(2453 at the start of the refactor). Listener passivity, the hasMoved click
guard, eventMode-until-animated ordering, and the R-reset parallax rebaseline
are all preserved.

svelte-check: 0 errors/0 warnings. lint/test/build clean. Verified onMount runs
with no runtime exceptions (background + stats panel render). NOTE: pan/zoom/
node-click/keyboard need a manual real-browser check (headless can't paint nodes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:23:54 -07:00

269 lines
8.1 KiB
TypeScript

// 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);
}