Files
skill-tree-portfolio/src/lib/pixi/interaction.ts
T
TehRiehlDealandClaude Opus 5 746d81b151 fix: audit phase 2 — teardown, failure paths, service worker, tooltip, resize
Correctness phase of the frontend audit. Nothing here is visible on a happy
path; it is what the app does when something goes wrong, when the window
changes, or when the component goes away.

Teardown (new src/lib/pixi/lifecycle.ts):
- Nothing was ever released: no app.destroy(), no ticker.remove(), no
  killTweensOf, no onDestroy. ~480 GSAP tweens (most repeat:-1), a ticker
  callback, and a dozen DOM listeners lived forever.
- 112 of those tweens are created inside an onComplete, so they don't exist
  when a call-site sweep would run. gsap.context() handles exactly this:
  GSAP records each animation's context on creation and restores it while
  invoking that animation's callbacks (see _callback in gsap-core), so
  late-created pulses land in the same context. One ctx.kill() gets all of
  them. kill(), not revert(), so GSAP doesn't write properties back onto
  objects about to be destroyed.
- All DOM listeners now go through life.listen(), which registers removal at
  the same time. Previously 6 canvas listeners were never removed, each
  closing over the whole ~713-object scene graph.
- The unmount race: cleanup used to be registered on the LAST line of an
  async onMount, after the asset waterfall, so unmounting mid-load cleaned up
  nothing. The lifecycle is now created before any await, with life.disposed
  guards after each one.
- Hover tweens re-enter the context per event and use overwrite:'auto' so
  rapid sweeps don't stack conflicting tweens.

Failure paths:
- onMount had no try/catch and no WebGL detection. Any init or asset failure
  left the spinner up forever, the stats toggle at opacity:0, and Escape dead
  (attachKeyboard never ran). Now caught, and it hands off to +page.svelte to
  reveal the text version.
- There was no <noscript> anywhere, and SeoContent is clipped to 1x1px with
  html/body overflow:hidden — so JS-disabled visitors got a pure black page.
  A <noscript><style> block now un-hides it with no JS involved.

Service worker (moved static/ -> src/, so SvelteKit builds it):
- It can now use $service-worker, so precache comes from the real build
  manifest instead of a hand-maintained 5-entry list that omitted all JS/CSS,
  and the cache name embeds `version` so old caches actually get pruned.
- cache.addAll was atomic: one 404 failed the whole install silently. Now
  per-asset with a warning on partial failure.
- The catch returned undefined for non-image assets, which makes
  respondWith throw a TypeError — a soft offline failure became a hard one.
  Every path now resolves to a Response.
- Unhashed assets (icons, headshot, resume PDF) were cache-first with no
  expiry, so replacing the resume at the same URL never reached a returning
  visitor. Those are network-first now; only hashed build output is
  cache-first.
- Navigation responses were cached without a status check, so a 404/500 body
  could become the offline fallback.
- Dropped skipWaiting() from install and added a real update prompt +
  controllerchange reload. The SKIP_WAITING handler was previously dead code
  and the "check for updates periodically" comment described an interval that
  did not exist.

Tooltip:
- Wrote Svelte state on every canvas mousemove even with nothing hovered.
  Now gated on hover and coalesced to one write per frame; raw coords are
  still tracked cheaply so the first hover isn't positioned from stale data.
- Animated left/top (layout + paint per frame on an element with two
  color-mix shadows); now translate3d.
- Listener moved to window, so coordinates no longer freeze when a drag
  leaves the canvas.
- Flip logic measured a hardcoded 280x120; now measures the real element
  (actual height is 113, so tall tooltips mis-flipped near the bottom edge).
- Dropped role="tooltip" + aria-live="polite", which announced the full
  label and description on every hover. Nothing referenced it via
  aria-describedby, so the role was inert. Now aria-hidden.

Resize:
- Star positions, gradient radius, vignette size and dust wrap bounds were
  all baked at first paint, so enlarging the window left an unlit band. The
  background is now rebuilt on a rAF-coalesced resize, under its own
  sub-lifecycle. The view is shifted by half the delta rather than re-centred,
  so the user's current view stays anchored.
- isTouchDevice used 'ontouchstart', true on touch-capable laptops and thus
  permanently killing mouse hover. Now matchMedia('(hover: hover)').

Known gap: node radii still don't change when the 768px breakpoint is
crossed mid-session; re-creating all 57 nodes would reset interaction state.

Verified in the production build: resize 1278x1220 -> 1734x821 rebuilds with
zero errors, tooltip positions via transform and stays on screen, fallback
renders as a readable resume page. svelte-check clean, lint clean, 14/14.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 00:27:30 -07:00

319 lines
9.6 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';
import type { Lifecycle } from './lifecycle';
/** 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;
/** Tooltip coords are only worth computing while a node is actually hovered. */
hasHover: () => boolean;
}
export interface PanZoomHandle {
/**
* Push the current pointer position to the tooltip immediately. Needed on
* hover-start: the move handler below only writes state while a node is
* hovered, so without this the first hover would render at stale coords.
*/
flushTooltip: () => void;
}
/** Wire mouse/touch/wheel pan + zoom. Every listener is owned by `life`. */
export function attachPanZoom(
ctrl: SkillTreeController,
opts: PanZoomOpts,
life: Lifecycle
): PanZoomHandle {
const { app, treeContainer } = ctrl;
const minZoom = ZOOM.min;
const maxZoom = ZOOM.max;
// ===== MOUSE PANNING =====
life.listen(app.canvas, '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);
};
life.listen(window, 'mousemove', onWindowMouseMove);
const onWindowMouseUp = () => {
ctrl.isDragging = false;
app.canvas.style.cursor = 'grab';
};
life.listen(window, 'mouseup', onWindowMouseUp);
app.canvas.style.cursor = 'grab';
// Track mouse position for the tooltip.
//
// Three things matter here. It is gated on `hasHover()` so moving the mouse
// across empty canvas costs nothing (this previously wrote Svelte state on
// every single mousemove, hovered or not). It coalesces to one write per
// frame instead of one per pointer event. And it listens on `window`, not the
// canvas, so coordinates keep updating when a drag leaves the canvas bounds.
let tooltipFrame = -1;
let tooltipPointerX = 0;
let tooltipPointerY = 0;
const flushTooltip = () => {
tooltipFrame = -1;
opts.onTooltipMove(tooltipPointerX, tooltipPointerY);
};
life.listen(window, 'mousemove', (e: MouseEvent) => {
// Raw coords are always tracked — two plain number writes, negligible.
// What was expensive, and is now gated, is writing Svelte state.
tooltipPointerX = e.clientX;
tooltipPointerY = e.clientY;
if (!opts.hasHover()) return;
if (tooltipFrame === -1) tooltipFrame = requestAnimationFrame(flushTooltip);
});
life.add(() => {
if (tooltipFrame !== -1) cancelAnimationFrame(tooltipFrame);
});
// ===== TOUCH PANNING & PINCH ZOOM =====
life.listen(
app.canvas,
'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 }
);
life.listen(
app.canvas,
'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 }
);
life.listen(
app.canvas,
'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 =====
// Explicitly non-passive: this calls preventDefault(). It works by default on
// a non-root element, but relying on that is an implicit dependency.
life.listen(
app.canvas,
'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);
},
{ passive: false }
);
return { flushTooltip };
}
interface KeyboardOpts {
onToggleStats: () => void;
onCloseModal: () => void;
isModalVisible: () => boolean;
isStatsVisible: () => boolean;
}
/** Wire keyboard navigation (pan/zoom/reset/stats/escape). Owned by `life`. */
export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts, life: Lifecycle) {
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();
// Re-enter the GSAP context: these are created per keypress.
life.animate(() => {
gsap.to(treeContainer, {
x: app.screen.width / 2,
y: app.screen.height / 2,
duration: 0.5,
ease: 'power2.out',
overwrite: 'auto',
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',
overwrite: 'auto',
onUpdate: () => updateParallax(ctrl)
});
});
}
}
life.listen(window, 'keydown', handleKeydown);
}