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>
269 lines
8.1 KiB
TypeScript
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);
|
|
}
|