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>
319 lines
9.6 KiB
TypeScript
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);
|
|
}
|