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>
This commit is contained in:
1 parent
8874b35998
commit
746d81b151
14 files changed
+1127
-692
No files matched your search
+40
@@ -47,3 +47,43 @@ body {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Un-hides the text version when the canvas can't run — no JS, or no WebGL.
|
||||||
|
* Applied by <noscript> (see +layout.svelte) and by the render-failure path in
|
||||||
|
* SkillTree.svelte. Also restores scrolling, which is disabled globally above
|
||||||
|
* for the canvas view.
|
||||||
|
*/
|
||||||
|
.sr-only.revealed {
|
||||||
|
position: static;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
margin: 0;
|
||||||
|
overflow: visible;
|
||||||
|
clip: auto;
|
||||||
|
white-space: normal;
|
||||||
|
max-width: 60rem;
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
color: #e8e8f0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
html:has(.sr-only.revealed),
|
||||||
|
html:has(.sr-only.revealed) body {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only.revealed a {
|
||||||
|
color: var(--color-gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only.revealed h1,
|
||||||
|
.sr-only.revealed h2,
|
||||||
|
.sr-only.revealed h3 {
|
||||||
|
color: var(--color-gold);
|
||||||
|
margin: 1.25rem 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only.revealed ul {
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
}
|
||||||
@@ -14,6 +14,10 @@
|
|||||||
} from '$lib/data';
|
} from '$lib/data';
|
||||||
import { aggregateSkills, BRANCH_IDS } from '$lib/utils/stats';
|
import { aggregateSkills, BRANCH_IDS } from '$lib/utils/stats';
|
||||||
|
|
||||||
|
// Set when the canvas can't render, so this becomes the visible page rather
|
||||||
|
// than a screen-reader-only mirror of it.
|
||||||
|
const { revealed = false }: { revealed?: boolean } = $props();
|
||||||
|
|
||||||
const origin = treeNodes.find((n): n is OriginNode => n.category === 'origin');
|
const origin = treeNodes.find((n): n is OriginNode => n.category === 'origin');
|
||||||
|
|
||||||
// Leaf nodes only — container/branch nodes lack these category fields.
|
// Leaf nodes only — container/branch nodes lack these category fields.
|
||||||
@@ -34,7 +38,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if origin}
|
{#if origin}
|
||||||
<section class="sr-only" aria-label="Portfolio (text version)">
|
<section class="sr-only" class:revealed aria-label="Portfolio (text version)">
|
||||||
<h1>{origin.fullName} — {origin.jobTitle}</h1>
|
<h1>{origin.fullName} — {origin.jobTitle}</h1>
|
||||||
{#if origin.aboutMe}<p>{origin.aboutMe}</p>{/if}
|
{#if origin.aboutMe}<p>{origin.aboutMe}</p>{/if}
|
||||||
{#if origin.location}<p>Location: {origin.location}</p>{/if}
|
{#if origin.location}<p>Location: {origin.location}</p>{/if}
|
||||||
|
|||||||
@@ -21,8 +21,11 @@
|
|||||||
import { createController } from '$lib/pixi/controller';
|
import { createController } from '$lib/pixi/controller';
|
||||||
import { createLines } from '$lib/pixi/lines';
|
import { createLines } from '$lib/pixi/lines';
|
||||||
import { createNodes } from '$lib/pixi/nodes';
|
import { createNodes } from '$lib/pixi/nodes';
|
||||||
import { attachPanZoom, attachKeyboard } from '$lib/pixi/interaction';
|
import { attachPanZoom, attachKeyboard, updateParallax } from '$lib/pixi/interaction';
|
||||||
import { runEntranceAnimations } from '$lib/pixi/animation';
|
import { runEntranceAnimations } from '$lib/pixi/animation';
|
||||||
|
import { createLifecycle } from '$lib/pixi/lifecycle';
|
||||||
|
|
||||||
|
const { onRenderFail }: { onRenderFail?: () => void } = $props();
|
||||||
|
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
let selectedNode = $state<TreeNode | null>(null);
|
let selectedNode = $state<TreeNode | null>(null);
|
||||||
@@ -48,7 +51,7 @@
|
|||||||
let statsPanelVisible = $state(false);
|
let statsPanelVisible = $state(false);
|
||||||
// SvelteSet, not $state(new Set()) — Svelte 5 does not proxy a plain Set, so
|
// SvelteSet, not $state(new Set()) — Svelte 5 does not proxy a plain Set, so
|
||||||
// .add()/.delete() would be untracked and the panel would never re-render.
|
// .add()/.delete() would be untracked and the panel would never re-render.
|
||||||
let expandedBranches = new SvelteSet<string>(BRANCH_IDS); // All expanded by default
|
const expandedBranches = new SvelteSet<string>(BRANCH_IDS); // All expanded by default
|
||||||
|
|
||||||
function openModal(node: TreeNode) {
|
function openModal(node: TreeNode) {
|
||||||
selectedNode = node;
|
selectedNode = node;
|
||||||
@@ -100,118 +103,212 @@
|
|||||||
isMobile = windowWidth < MOBILE_BREAKPOINT;
|
isMobile = windowWidth < MOBILE_BREAKPOINT;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanupFunctions: Array<() => void> = [];
|
// One owner for every Pixi/GSAP/DOM resource this component allocates.
|
||||||
|
// Registered with the component's teardown BEFORE any await, so unmounting
|
||||||
|
// mid-load still releases everything (this used to be the last statement of
|
||||||
|
// the async onMount, so an early unmount cleaned up nothing at all).
|
||||||
|
const life = createLifecycle();
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
handleResize();
|
handleResize();
|
||||||
window.addEventListener('resize', handleResize);
|
life.listen(window, 'resize', handleResize);
|
||||||
|
|
||||||
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
// matchMedia('(hover: hover)') rather than 'ontouchstart': the latter is
|
||||||
|
// true on touch-capable laptops, which permanently killed mouse hover.
|
||||||
|
const isTouchDevice = !window.matchMedia('(hover: hover)').matches;
|
||||||
|
|
||||||
const app = new Application();
|
const app = new Application();
|
||||||
await app.init({
|
|
||||||
background: '#05050a',
|
|
||||||
resizeTo: container,
|
|
||||||
// The scene is soft glow circles — MSAA costs fill rate and buys almost
|
|
||||||
// nothing here. Sharpness comes from rendering at device resolution instead.
|
|
||||||
antialias: false,
|
|
||||||
resolution: Math.min(window.devicePixelRatio || 1, 2),
|
|
||||||
autoDensity: true,
|
|
||||||
powerPreference: 'high-performance'
|
|
||||||
});
|
|
||||||
|
|
||||||
// PixiJS owns this canvas, not Svelte — mounting it directly is intentional.
|
try {
|
||||||
// eslint-disable-next-line svelte/no-dom-manipulating
|
await app.init({
|
||||||
container.appendChild(app.canvas);
|
background: '#05050a',
|
||||||
|
resizeTo: container,
|
||||||
|
// The scene is soft glow circles — MSAA costs fill rate and buys almost
|
||||||
|
// nothing here. Sharpness comes from rendering at device resolution instead.
|
||||||
|
antialias: false,
|
||||||
|
resolution: Math.min(window.devicePixelRatio || 1, 2),
|
||||||
|
autoDensity: true,
|
||||||
|
powerPreference: 'high-performance'
|
||||||
|
});
|
||||||
|
|
||||||
// Background layers (gradient → nebula → stars → dust → vignette)
|
// Unmounted while the renderer was initialising — release and stop.
|
||||||
const bg = createBackground(app, isMobile);
|
if (life.disposed) {
|
||||||
|
app.destroy(true, { children: true, texture: true });
|
||||||
// Tree container — pan/zoom transform is applied here
|
return;
|
||||||
const treeContainer = new Container();
|
|
||||||
app.stage.addChild(treeContainer);
|
|
||||||
treeContainer.x = app.screen.width / 2;
|
|
||||||
treeContainer.y = app.screen.height / 2;
|
|
||||||
|
|
||||||
// Start zoomed in; the entrance animation zooms out to endScale.
|
|
||||||
const startScale = isMobile ? 3 : 4;
|
|
||||||
const endScale = isMobile ? 0.6 : 1;
|
|
||||||
treeContainer.scale.set(startScale);
|
|
||||||
treeContainer.alpha = 1;
|
|
||||||
|
|
||||||
const controller = createController(app, treeContainer, bg, endScale);
|
|
||||||
|
|
||||||
// Build the visualisation (connections first, then nodes on top)
|
|
||||||
controller.lineObjects = createLines(treeContainer);
|
|
||||||
controller.nodeObjects = await createNodes(treeContainer, {
|
|
||||||
isMobile,
|
|
||||||
isTouchDevice,
|
|
||||||
controller,
|
|
||||||
onOpen: openModal,
|
|
||||||
onHover: (node) => {
|
|
||||||
hoveredNode = node;
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Textures are loaded and the scene graph is built — only now is the canvas
|
// Tearing down the renderer has to happen last, so register it first
|
||||||
// genuinely ready. (This used to fire right after app.init(), so the spinner
|
// (dispose runs newest-first).
|
||||||
// resolved onto an empty screen.)
|
life.add(() => app.destroy(true, { children: true, texture: true }));
|
||||||
isLoading = false;
|
|
||||||
|
|
||||||
// Pan / zoom / parallax + keyboard navigation
|
// PixiJS owns this canvas, not Svelte — mounting it directly is intentional.
|
||||||
const detachPanZoom = attachPanZoom(controller, {
|
// eslint-disable-next-line svelte/no-dom-manipulating
|
||||||
onTooltipMove: (x, y) => {
|
container.appendChild(app.canvas);
|
||||||
tooltipX = x;
|
|
||||||
tooltipY = y;
|
// Background layers (gradient → nebula → stars → dust → vignette).
|
||||||
|
// Its own sub-lifecycle so a resize rebuild can kill just its ~315
|
||||||
|
// ambient tweens and the dust ticker, without touching the tree's.
|
||||||
|
let bgLife = createLifecycle();
|
||||||
|
let bg = createBackground(app, isMobile, bgLife);
|
||||||
|
life.add(() => bgLife.dispose());
|
||||||
|
|
||||||
|
// Tree container — pan/zoom transform is applied here
|
||||||
|
const treeContainer = new Container();
|
||||||
|
app.stage.addChild(treeContainer);
|
||||||
|
treeContainer.x = app.screen.width / 2;
|
||||||
|
treeContainer.y = app.screen.height / 2;
|
||||||
|
|
||||||
|
// Start zoomed in; the entrance animation zooms out to endScale.
|
||||||
|
const startScale = isMobile ? 3 : 4;
|
||||||
|
const endScale = isMobile ? 0.6 : 1;
|
||||||
|
treeContainer.scale.set(startScale);
|
||||||
|
treeContainer.alpha = 1;
|
||||||
|
|
||||||
|
const controller = createController(app, treeContainer, bg, endScale);
|
||||||
|
|
||||||
|
// Build the visualisation (connections first, then nodes on top)
|
||||||
|
controller.lineObjects = createLines(treeContainer);
|
||||||
|
controller.nodeObjects = await createNodes(treeContainer, {
|
||||||
|
isMobile,
|
||||||
|
isTouchDevice,
|
||||||
|
controller,
|
||||||
|
life,
|
||||||
|
onOpen: openModal,
|
||||||
|
onHover: (node) => {
|
||||||
|
hoveredNode = node;
|
||||||
|
// Seed the tooltip at the current pointer before it first paints.
|
||||||
|
if (node) panZoom?.flushTooltip();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unmounted while textures were loading.
|
||||||
|
if (life.disposed) return;
|
||||||
|
|
||||||
|
// Textures are loaded and the scene graph is built — only now is the canvas
|
||||||
|
// genuinely ready. (This used to fire right after app.init(), so the spinner
|
||||||
|
// resolved onto an empty screen.)
|
||||||
|
isLoading = false;
|
||||||
|
|
||||||
|
// Pan / zoom / parallax + keyboard navigation
|
||||||
|
const panZoom = attachPanZoom(
|
||||||
|
controller,
|
||||||
|
{
|
||||||
|
onTooltipMove: (x, y) => {
|
||||||
|
tooltipX = x;
|
||||||
|
tooltipY = y;
|
||||||
|
},
|
||||||
|
hasHover: () => hoveredNode !== null
|
||||||
|
},
|
||||||
|
life
|
||||||
|
);
|
||||||
|
attachKeyboard(
|
||||||
|
controller,
|
||||||
|
{
|
||||||
|
onToggleStats: toggleStatsPanel,
|
||||||
|
onCloseModal: closeModal,
|
||||||
|
isModalVisible: () => modalVisible,
|
||||||
|
isStatsVisible: () => statsPanelVisible
|
||||||
|
},
|
||||||
|
life
|
||||||
|
);
|
||||||
|
|
||||||
|
// Every star position, the gradient radius, the vignette size and the
|
||||||
|
// dust wrap bounds are baked from app.screen at build time. Without this
|
||||||
|
// a resized window leaves an unlit, starless band.
|
||||||
|
let lastWidth = app.screen.width;
|
||||||
|
let lastHeight = app.screen.height;
|
||||||
|
let resizeFrame = -1;
|
||||||
|
|
||||||
|
function rebuildBackground() {
|
||||||
|
bgLife.dispose();
|
||||||
|
bg.deepBackgroundContainer.destroy({ children: true });
|
||||||
|
bg.nebulaContainer.destroy({ children: true });
|
||||||
|
bg.starContainer.destroy({ children: true });
|
||||||
|
bg.vignette.destroy();
|
||||||
|
|
||||||
|
bgLife = createLifecycle();
|
||||||
|
bg = createBackground(app, isMobile, bgLife);
|
||||||
|
controller.bg = bg;
|
||||||
|
|
||||||
|
// createBackground appends to the stage, so the tree would end up
|
||||||
|
// behind the new layers. Restore it to the top.
|
||||||
|
app.stage.setChildIndex(treeContainer, app.stage.children.length - 1);
|
||||||
|
updateParallax(controller);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
const detachKeyboard = attachKeyboard(controller, {
|
|
||||||
onToggleStats: toggleStatsPanel,
|
|
||||||
onCloseModal: closeModal,
|
|
||||||
isModalVisible: () => modalVisible,
|
|
||||||
isStatsVisible: () => statsPanelVisible
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sequenced entrance animation
|
function handleCanvasResize() {
|
||||||
runEntranceAnimations(controller, {
|
resizeFrame = -1;
|
||||||
isMobile,
|
const width = app.screen.width;
|
||||||
onTitleDone: () => {
|
const height = app.screen.height;
|
||||||
showWelcomeTitle = false;
|
if (width === lastWidth && height === lastHeight) return;
|
||||||
|
|
||||||
|
// Keep whatever the user was looking at anchored: shift by half the
|
||||||
|
// delta rather than snapping back to centre and yanking their view.
|
||||||
|
const dx = (width - lastWidth) / 2;
|
||||||
|
const dy = (height - lastHeight) / 2;
|
||||||
|
treeContainer.x += dx;
|
||||||
|
treeContainer.y += dy;
|
||||||
|
controller.initialTreeX += dx;
|
||||||
|
controller.initialTreeY += dy;
|
||||||
|
|
||||||
|
lastWidth = width;
|
||||||
|
lastHeight = height;
|
||||||
|
rebuildBackground();
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Store cleanup functions
|
// Coalesce to one rebuild per frame — resize fires continuously while
|
||||||
cleanupFunctions.push(() => {
|
// dragging a window edge, and a rebuild allocates ~300 Graphics.
|
||||||
window.removeEventListener('resize', handleResize);
|
life.listen(window, 'resize', () => {
|
||||||
detachPanZoom();
|
if (resizeFrame === -1) resizeFrame = requestAnimationFrame(handleCanvasResize);
|
||||||
detachKeyboard();
|
});
|
||||||
});
|
life.add(() => {
|
||||||
|
if (resizeFrame !== -1) cancelAnimationFrame(resizeFrame);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sequenced entrance animation
|
||||||
|
runEntranceAnimations(controller, {
|
||||||
|
isMobile,
|
||||||
|
life,
|
||||||
|
onTitleDone: () => {
|
||||||
|
showWelcomeTitle = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// No WebGL, or a texture failed to load. Don't strand the user on a
|
||||||
|
// spinner that never resolves — reveal the text version instead.
|
||||||
|
console.error('Skill tree failed to initialise:', err);
|
||||||
|
isLoading = false;
|
||||||
|
// Hands off to +page.svelte, which reveals the text version.
|
||||||
|
onRenderFail?.();
|
||||||
|
life.dispose();
|
||||||
|
app.destroy(true, { children: true, texture: true });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cleanup on component unmount
|
// Cleanup on component unmount
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
return () => {
|
return () => life.dispose();
|
||||||
cleanupFunctions.forEach((fn) => fn());
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate tooltip position to keep it on screen
|
// Live tooltip element, so flip decisions use its real measured size rather
|
||||||
|
// than a hardcoded 280x120 guess (descriptions vary in length, so the old
|
||||||
|
// height constant mis-flipped tall tooltips near the viewport bottom).
|
||||||
|
let tooltipEl = $state<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const tooltipStyle = $derived.by(() => {
|
const tooltipStyle = $derived.by(() => {
|
||||||
let x = tooltipX + 15;
|
const gap = 15;
|
||||||
let y = tooltipY + 15;
|
const rect = tooltipEl?.getBoundingClientRect();
|
||||||
|
const width = rect?.width || 280;
|
||||||
|
const height = rect?.height || 120;
|
||||||
|
|
||||||
// Keep tooltip on screen
|
let x = tooltipX + gap;
|
||||||
const tooltipWidth = 280;
|
let y = tooltipY + gap;
|
||||||
const tooltipHeight = 120;
|
|
||||||
|
|
||||||
if (x + tooltipWidth > windowWidth) {
|
// Flip to the other side of the cursor rather than overflow the viewport.
|
||||||
x = tooltipX - tooltipWidth - 15;
|
if (x + width > windowWidth) x = Math.max(0, tooltipX - width - gap);
|
||||||
}
|
if (y + height > windowHeight) y = Math.max(0, tooltipY - height - gap);
|
||||||
if (y + tooltipHeight > windowHeight) {
|
|
||||||
y = tooltipY - tooltipHeight - 15;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `left: ${x}px; top: ${y}px;`;
|
return `transform: translate3d(${x}px, ${y}px, 0);`;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -239,6 +336,7 @@
|
|||||||
<!-- Hover Tooltip (hidden on mobile) -->
|
<!-- Hover Tooltip (hidden on mobile) -->
|
||||||
{#if hoveredNode && !modalVisible && !isMobile}
|
{#if hoveredNode && !modalVisible && !isMobile}
|
||||||
<Tooltip
|
<Tooltip
|
||||||
|
bind:element={tooltipEl}
|
||||||
node={hoveredNode}
|
node={hoveredNode}
|
||||||
style={tooltipStyle}
|
style={tooltipStyle}
|
||||||
colorHex={categoryColorHex[hoveredNode.category]}
|
colorHex={categoryColorHex[hoveredNode.category]}
|
||||||
|
|||||||
@@ -1,10 +1,34 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { TreeNode } from '$lib/data';
|
import type { TreeNode } from '$lib/data';
|
||||||
|
|
||||||
const { node, style, colorHex }: { node: TreeNode; style: string; colorHex: string } = $props();
|
// `let`, not `const`: bind:this assigns to `element`, and a bindable prop is
|
||||||
|
// only writable when destructured with let.
|
||||||
|
let {
|
||||||
|
node,
|
||||||
|
style,
|
||||||
|
colorHex,
|
||||||
|
element = $bindable(null)
|
||||||
|
}: {
|
||||||
|
node: TreeNode;
|
||||||
|
style: string;
|
||||||
|
colorHex: string;
|
||||||
|
element?: HTMLDivElement | null;
|
||||||
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="tooltip" role="tooltip" aria-live="polite" style="{style} --node-color: {colorHex};">
|
<!--
|
||||||
|
aria-hidden: this is a mouse-only visual duplicate of canvas content. It used
|
||||||
|
to carry role="tooltip" + aria-live="polite", which announced the full label,
|
||||||
|
description and "Click for details" on every single hover. Nothing references
|
||||||
|
it via aria-describedby either — the trigger is a WebGL sprite with no DOM
|
||||||
|
node — so the role was inert anyway. The accessible path is the text version.
|
||||||
|
-->
|
||||||
|
<div
|
||||||
|
bind:this={element}
|
||||||
|
class="tooltip"
|
||||||
|
aria-hidden="true"
|
||||||
|
style="{style} --node-color: {colorHex};"
|
||||||
|
>
|
||||||
<h3>
|
<h3>
|
||||||
{#if node.icon}
|
{#if node.icon}
|
||||||
<img src={node.icon} alt="" class="tooltip-icon" />
|
<img src={node.icon} alt="" class="tooltip-icon" />
|
||||||
@@ -18,6 +42,12 @@
|
|||||||
<style>
|
<style>
|
||||||
.tooltip {
|
.tooltip {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
/* Positioned via translate3d, not left/top: moving on the compositor
|
||||||
|
avoids a layout+paint per frame on an element with two color-mix
|
||||||
|
shadows. The offsets live in the transform, so these stay at 0. */
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
will-change: transform;
|
||||||
background: linear-gradient(145deg, var(--panel-from) 0%, #12121a 100%);
|
background: linear-gradient(145deg, var(--panel-from) 0%, #12121a 100%);
|
||||||
border: 1px solid var(--node-color);
|
border: 1px solid var(--node-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|||||||
+195
-186
@@ -6,9 +6,11 @@ import { gsap } from 'gsap';
|
|||||||
import { getDistanceFromOrigin } from '$lib/utils/tree';
|
import { getDistanceFromOrigin } from '$lib/utils/tree';
|
||||||
import { updateParallax } from './interaction';
|
import { updateParallax } from './interaction';
|
||||||
import type { SkillTreeController } from './controller';
|
import type { SkillTreeController } from './controller';
|
||||||
|
import type { Lifecycle } from './lifecycle';
|
||||||
|
|
||||||
interface EntranceOpts {
|
interface EntranceOpts {
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
|
life: Lifecycle;
|
||||||
/** Called when the welcome title should be removed (matches its CSS fade). */
|
/** Called when the welcome title should be removed (matches its CSS fade). */
|
||||||
onTitleDone: () => void;
|
onTitleDone: () => void;
|
||||||
}
|
}
|
||||||
@@ -22,201 +24,208 @@ export function runEntranceAnimations(ctrl: SkillTreeController, opts: EntranceO
|
|||||||
const zoomDuration = isMobile ? 2.5 : 4.5;
|
const zoomDuration = isMobile ? 2.5 : 4.5;
|
||||||
const nodeStagger = isMobile ? 0.05 : 0.08;
|
const nodeStagger = isMobile ? 0.05 : 0.08;
|
||||||
|
|
||||||
// Hide all lines and plasma beams initially
|
// The entrance allocates ~340 finite tweens plus ~170 `repeat: -1` pulses,
|
||||||
lineObjects.forEach((line) => {
|
// some created inside an onComplete. GSAP restores an animation's context
|
||||||
line.graphics.alpha = 0;
|
// while running its callbacks, so wrapping the whole sequence here makes
|
||||||
line.glowGraphics.alpha = 0;
|
// even those late-created pulses killable via the lifecycle.
|
||||||
line.plasmaBeam.alpha = 0;
|
opts.life.animate(() => {
|
||||||
});
|
// Hide all lines and plasma beams initially
|
||||||
|
lineObjects.forEach((line) => {
|
||||||
// Hide all nodes initially
|
line.graphics.alpha = 0;
|
||||||
nodeObjects.forEach((obj) => {
|
line.glowGraphics.alpha = 0;
|
||||||
obj.graphics.alpha = 0;
|
line.plasmaBeam.alpha = 0;
|
||||||
obj.graphics.scale.set(0.5);
|
|
||||||
obj.glowGraphics.alpha = 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort by distance from origin
|
|
||||||
const sortedNodes = [...nodeObjects].sort(
|
|
||||||
(a, b) => getDistanceFromOrigin(a.node) - getDistanceFromOrigin(b.node)
|
|
||||||
);
|
|
||||||
const sortedLines = [...lineObjects].sort(
|
|
||||||
(a, b) => getDistanceFromOrigin(a.node) - getDistanceFromOrigin(b.node)
|
|
||||||
);
|
|
||||||
|
|
||||||
// STEP 1: After title fades, show origin node first (overlap on mobile)
|
|
||||||
const originNode = sortedNodes.find((obj) => obj.node.id === 'origin');
|
|
||||||
const originDelay = isMobile ? titleDuration - 0.5 : titleDuration;
|
|
||||||
const originDuration = isMobile ? 0.3 : 0.6;
|
|
||||||
|
|
||||||
if (originNode && isMobile) {
|
|
||||||
originNode.graphics.scale.set(0.8); // Start slightly larger
|
|
||||||
}
|
|
||||||
|
|
||||||
if (originNode) {
|
|
||||||
gsap.to(originNode.graphics, {
|
|
||||||
alpha: 1,
|
|
||||||
duration: originDuration,
|
|
||||||
delay: originDelay,
|
|
||||||
ease: 'power2.out'
|
|
||||||
});
|
});
|
||||||
gsap.to(originNode.graphics.scale, {
|
|
||||||
x: 1,
|
// Hide all nodes initially
|
||||||
y: 1,
|
nodeObjects.forEach((obj) => {
|
||||||
duration: originDuration,
|
obj.graphics.alpha = 0;
|
||||||
delay: originDelay,
|
obj.graphics.scale.set(0.5);
|
||||||
ease: 'back.out(1.7)',
|
obj.glowGraphics.alpha = 0;
|
||||||
onComplete: () => {
|
});
|
||||||
if (originNode.graphics instanceof Container) {
|
|
||||||
originNode.graphics.eventMode = 'static';
|
// Sort by distance from origin
|
||||||
|
const sortedNodes = [...nodeObjects].sort(
|
||||||
|
(a, b) => getDistanceFromOrigin(a.node) - getDistanceFromOrigin(b.node)
|
||||||
|
);
|
||||||
|
const sortedLines = [...lineObjects].sort(
|
||||||
|
(a, b) => getDistanceFromOrigin(a.node) - getDistanceFromOrigin(b.node)
|
||||||
|
);
|
||||||
|
|
||||||
|
// STEP 1: After title fades, show origin node first (overlap on mobile)
|
||||||
|
const originNode = sortedNodes.find((obj) => obj.node.id === 'origin');
|
||||||
|
const originDelay = isMobile ? titleDuration - 0.5 : titleDuration;
|
||||||
|
const originDuration = isMobile ? 0.3 : 0.6;
|
||||||
|
|
||||||
|
if (originNode && isMobile) {
|
||||||
|
originNode.graphics.scale.set(0.8); // Start slightly larger
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originNode) {
|
||||||
|
gsap.to(originNode.graphics, {
|
||||||
|
alpha: 1,
|
||||||
|
duration: originDuration,
|
||||||
|
delay: originDelay,
|
||||||
|
ease: 'power2.out'
|
||||||
|
});
|
||||||
|
gsap.to(originNode.graphics.scale, {
|
||||||
|
x: 1,
|
||||||
|
y: 1,
|
||||||
|
duration: originDuration,
|
||||||
|
delay: originDelay,
|
||||||
|
ease: 'back.out(1.7)',
|
||||||
|
onComplete: () => {
|
||||||
|
if (originNode.graphics instanceof Container) {
|
||||||
|
originNode.graphics.eventMode = 'static';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
gsap.to(originNode.glowGraphics, {
|
||||||
|
alpha: 1,
|
||||||
|
duration: originDuration,
|
||||||
|
delay: originDelay,
|
||||||
|
ease: 'power2.out'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// STEP 2: Start zoom out after origin appears
|
||||||
|
const zoomDelay = isMobile ? titleDuration - 0.3 : titleDuration + 0.3;
|
||||||
|
|
||||||
|
gsap.to(treeContainer.scale, {
|
||||||
|
x: endScale,
|
||||||
|
y: endScale,
|
||||||
|
duration: zoomDuration,
|
||||||
|
delay: zoomDelay,
|
||||||
|
ease: 'power1.out',
|
||||||
|
onUpdate: () => updateParallax(ctrl)
|
||||||
});
|
});
|
||||||
gsap.to(originNode.glowGraphics, {
|
|
||||||
alpha: 1,
|
|
||||||
duration: originDuration,
|
|
||||||
delay: originDelay,
|
|
||||||
ease: 'power2.out'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// STEP 2: Start zoom out after origin appears
|
// STEP 3: Animate in the rest of the nodes (excluding origin)
|
||||||
const zoomDelay = isMobile ? titleDuration - 0.3 : titleDuration + 0.3;
|
const otherNodes = sortedNodes.filter((obj) => obj.node.id !== 'origin');
|
||||||
|
const nodeStartDelay = isMobile ? titleDuration - 0.2 : titleDuration + 0.5;
|
||||||
|
|
||||||
gsap.to(treeContainer.scale, {
|
otherNodes.forEach((obj, index) => {
|
||||||
x: endScale,
|
const delay = nodeStartDelay + index * nodeStagger;
|
||||||
y: endScale,
|
|
||||||
duration: zoomDuration,
|
|
||||||
delay: zoomDelay,
|
|
||||||
ease: 'power1.out',
|
|
||||||
onUpdate: () => updateParallax(ctrl)
|
|
||||||
});
|
|
||||||
|
|
||||||
// STEP 3: Animate in the rest of the nodes (excluding origin)
|
gsap.to(obj.graphics, {
|
||||||
const otherNodes = sortedNodes.filter((obj) => obj.node.id !== 'origin');
|
alpha: 1,
|
||||||
const nodeStartDelay = isMobile ? titleDuration - 0.2 : titleDuration + 0.5;
|
duration: 0.5,
|
||||||
|
delay,
|
||||||
otherNodes.forEach((obj, index) => {
|
ease: 'power2.out'
|
||||||
const delay = nodeStartDelay + index * nodeStagger;
|
});
|
||||||
|
gsap.to(obj.graphics.scale, {
|
||||||
gsap.to(obj.graphics, {
|
x: 1,
|
||||||
alpha: 1,
|
y: 1,
|
||||||
duration: 0.5,
|
duration: 0.5,
|
||||||
delay,
|
delay,
|
||||||
ease: 'power2.out'
|
ease: 'back.out(1.7)',
|
||||||
});
|
onComplete: () => {
|
||||||
gsap.to(obj.graphics.scale, {
|
if (obj.graphics instanceof Container) {
|
||||||
x: 1,
|
obj.graphics.eventMode = 'static';
|
||||||
y: 1,
|
}
|
||||||
duration: 0.5,
|
|
||||||
delay,
|
|
||||||
ease: 'back.out(1.7)',
|
|
||||||
onComplete: () => {
|
|
||||||
if (obj.graphics instanceof Container) {
|
|
||||||
obj.graphics.eventMode = 'static';
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
gsap.to(obj.glowGraphics, {
|
||||||
gsap.to(obj.glowGraphics, {
|
alpha: 1,
|
||||||
alpha: 1,
|
duration: 0.5,
|
||||||
duration: 0.5,
|
delay,
|
||||||
delay,
|
ease: 'power2.out'
|
||||||
ease: 'power2.out'
|
});
|
||||||
|
|
||||||
|
// Start ambient pulse after node appears
|
||||||
|
gsap.to(obj.glowGraphics.scale, {
|
||||||
|
x: 1.4,
|
||||||
|
y: 1.4,
|
||||||
|
duration: 2 + Math.random() * 1,
|
||||||
|
delay: delay + 0.5,
|
||||||
|
ease: 'sine.inOut',
|
||||||
|
yoyo: true,
|
||||||
|
repeat: -1
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start ambient pulse after node appears
|
// Start origin pulse too
|
||||||
gsap.to(obj.glowGraphics.scale, {
|
if (originNode) {
|
||||||
x: 1.4,
|
gsap.to(originNode.glowGraphics.scale, {
|
||||||
y: 1.4,
|
x: 1.4,
|
||||||
duration: 2 + Math.random() * 1,
|
y: 1.4,
|
||||||
delay: delay + 0.5,
|
duration: 2 + Math.random() * 1,
|
||||||
ease: 'sine.inOut',
|
delay: titleDuration + 1,
|
||||||
yoyo: true,
|
ease: 'sine.inOut',
|
||||||
repeat: -1
|
yoyo: true,
|
||||||
|
repeat: -1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// STEP 4: Animate lines and plasma beams as nodes appear
|
||||||
|
const lineStartDelay = isMobile ? titleDuration - 0.3 : titleDuration + 0.4;
|
||||||
|
|
||||||
|
sortedLines.forEach((line, index) => {
|
||||||
|
const delay = lineStartDelay + index * nodeStagger;
|
||||||
|
|
||||||
|
// Core line appears first
|
||||||
|
gsap.to(line.graphics, {
|
||||||
|
alpha: 1,
|
||||||
|
duration: 0.4,
|
||||||
|
delay,
|
||||||
|
ease: 'power2.out'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Base glow appears with line
|
||||||
|
gsap.to(line.glowGraphics, {
|
||||||
|
alpha: 1,
|
||||||
|
duration: 0.6,
|
||||||
|
delay: delay + 0.1,
|
||||||
|
ease: 'power2.out'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Plasma beam fades in, then breathes continuously
|
||||||
|
gsap.to(line.plasmaBeam, {
|
||||||
|
alpha: 1,
|
||||||
|
duration: 0.8,
|
||||||
|
delay: delay + 0.2,
|
||||||
|
ease: 'power2.out',
|
||||||
|
onComplete: () => {
|
||||||
|
const pulseDuration = 2.0 + Math.random() * 1.0; // Vary pulse speed
|
||||||
|
const minAlpha = 0.6 * line.intensity; // Minimum brightness - stays visible
|
||||||
|
|
||||||
|
gsap.to(line.plasmaBeam, {
|
||||||
|
alpha: minAlpha,
|
||||||
|
duration: pulseDuration,
|
||||||
|
ease: 'sine.inOut',
|
||||||
|
yoyo: true,
|
||||||
|
repeat: -1
|
||||||
|
});
|
||||||
|
|
||||||
|
const glowMinAlpha = 0.12 * line.intensity;
|
||||||
|
gsap.to(line.glowGraphics, {
|
||||||
|
alpha: glowMinAlpha,
|
||||||
|
duration: pulseDuration * 1.2,
|
||||||
|
ease: 'sine.inOut',
|
||||||
|
yoyo: true,
|
||||||
|
repeat: -1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Clean up title card after animation (matches titleDuration)
|
||||||
|
const titleTimer = setTimeout(
|
||||||
|
() => {
|
||||||
|
opts.onTitleDone();
|
||||||
|
},
|
||||||
|
isMobile ? 1500 : 2500
|
||||||
|
);
|
||||||
|
opts.life.add(() => clearTimeout(titleTimer));
|
||||||
|
|
||||||
|
// Fade in stats panel toggle button with the tree
|
||||||
|
const toggleButton = document.querySelector('.stats-panel-toggle');
|
||||||
|
if (toggleButton) {
|
||||||
|
gsap.to(toggleButton, {
|
||||||
|
opacity: 1,
|
||||||
|
duration: 0.5,
|
||||||
|
delay: nodeStartDelay + otherNodes.length * nodeStagger * 0.5, // midway through node animation
|
||||||
|
ease: 'power2.out'
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start origin pulse too
|
|
||||||
if (originNode) {
|
|
||||||
gsap.to(originNode.glowGraphics.scale, {
|
|
||||||
x: 1.4,
|
|
||||||
y: 1.4,
|
|
||||||
duration: 2 + Math.random() * 1,
|
|
||||||
delay: titleDuration + 1,
|
|
||||||
ease: 'sine.inOut',
|
|
||||||
yoyo: true,
|
|
||||||
repeat: -1
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// STEP 4: Animate lines and plasma beams as nodes appear
|
|
||||||
const lineStartDelay = isMobile ? titleDuration - 0.3 : titleDuration + 0.4;
|
|
||||||
|
|
||||||
sortedLines.forEach((line, index) => {
|
|
||||||
const delay = lineStartDelay + index * nodeStagger;
|
|
||||||
|
|
||||||
// Core line appears first
|
|
||||||
gsap.to(line.graphics, {
|
|
||||||
alpha: 1,
|
|
||||||
duration: 0.4,
|
|
||||||
delay,
|
|
||||||
ease: 'power2.out'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Base glow appears with line
|
|
||||||
gsap.to(line.glowGraphics, {
|
|
||||||
alpha: 1,
|
|
||||||
duration: 0.6,
|
|
||||||
delay: delay + 0.1,
|
|
||||||
ease: 'power2.out'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Plasma beam fades in, then breathes continuously
|
|
||||||
gsap.to(line.plasmaBeam, {
|
|
||||||
alpha: 1,
|
|
||||||
duration: 0.8,
|
|
||||||
delay: delay + 0.2,
|
|
||||||
ease: 'power2.out',
|
|
||||||
onComplete: () => {
|
|
||||||
const pulseDuration = 2.0 + Math.random() * 1.0; // Vary pulse speed
|
|
||||||
const minAlpha = 0.6 * line.intensity; // Minimum brightness - stays visible
|
|
||||||
|
|
||||||
gsap.to(line.plasmaBeam, {
|
|
||||||
alpha: minAlpha,
|
|
||||||
duration: pulseDuration,
|
|
||||||
ease: 'sine.inOut',
|
|
||||||
yoyo: true,
|
|
||||||
repeat: -1
|
|
||||||
});
|
|
||||||
|
|
||||||
const glowMinAlpha = 0.12 * line.intensity;
|
|
||||||
gsap.to(line.glowGraphics, {
|
|
||||||
alpha: glowMinAlpha,
|
|
||||||
duration: pulseDuration * 1.2,
|
|
||||||
ease: 'sine.inOut',
|
|
||||||
yoyo: true,
|
|
||||||
repeat: -1
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clean up title card after animation (matches titleDuration)
|
|
||||||
setTimeout(
|
|
||||||
() => {
|
|
||||||
opts.onTitleDone();
|
|
||||||
},
|
|
||||||
isMobile ? 1500 : 2500
|
|
||||||
);
|
|
||||||
|
|
||||||
// Fade in stats panel toggle button with the tree
|
|
||||||
const toggleButton = document.querySelector('.stats-panel-toggle');
|
|
||||||
if (toggleButton) {
|
|
||||||
gsap.to(toggleButton, {
|
|
||||||
opacity: 1,
|
|
||||||
duration: 0.5,
|
|
||||||
delay: nodeStartDelay + otherNodes.length * nodeStagger * 0.5, // midway through node animation
|
|
||||||
ease: 'power2.out'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+228
-217
@@ -6,9 +6,15 @@ import { gsap } from 'gsap';
|
|||||||
import { nebulaPalette } from '$lib/theme';
|
import { nebulaPalette } from '$lib/theme';
|
||||||
import { PARTICLE_COUNTS, byDevice } from '$lib/config';
|
import { PARTICLE_COUNTS, byDevice } from '$lib/config';
|
||||||
import type { BackgroundLayers } from './types';
|
import type { BackgroundLayers } from './types';
|
||||||
|
import type { Lifecycle } from './lifecycle';
|
||||||
|
|
||||||
export function createBackground(app: Application, isMobile: boolean): BackgroundLayers {
|
export function createBackground(
|
||||||
|
app: Application,
|
||||||
|
isMobile: boolean,
|
||||||
|
life: Lifecycle
|
||||||
|
): BackgroundLayers {
|
||||||
const deepBackgroundContainer = new Container();
|
const deepBackgroundContainer = new Container();
|
||||||
|
let vignette!: Graphics;
|
||||||
const nebulaContainer = new Container();
|
const nebulaContainer = new Container();
|
||||||
const starContainer = new Container();
|
const starContainer = new Container();
|
||||||
|
|
||||||
@@ -16,238 +22,243 @@ export function createBackground(app: Application, isMobile: boolean): Backgroun
|
|||||||
app.stage.addChild(nebulaContainer);
|
app.stage.addChild(nebulaContainer);
|
||||||
app.stage.addChild(starContainer);
|
app.stage.addChild(starContainer);
|
||||||
|
|
||||||
const screenWidth = app.screen.width;
|
// Everything below allocates `repeat: -1` ambient tweens; building it inside
|
||||||
const screenHeight = app.screen.height;
|
// the lifecycle's GSAP context makes all of them killable on teardown.
|
||||||
const centerX = screenWidth / 2;
|
life.animate(() => {
|
||||||
const centerY = screenHeight / 2;
|
const screenWidth = app.screen.width;
|
||||||
|
const screenHeight = app.screen.height;
|
||||||
|
const centerX = screenWidth / 2;
|
||||||
|
const centerY = screenHeight / 2;
|
||||||
|
|
||||||
// ========== DEEP SPACE GRADIENT ==========
|
// ========== DEEP SPACE GRADIENT ==========
|
||||||
const spaceGradient = new Graphics();
|
const spaceGradient = new Graphics();
|
||||||
const gradientRadius = Math.max(screenWidth, screenHeight);
|
const gradientRadius = Math.max(screenWidth, screenHeight);
|
||||||
|
|
||||||
const gradientSteps = 15;
|
const gradientSteps = 15;
|
||||||
for (let i = gradientSteps; i >= 0; i--) {
|
for (let i = gradientSteps; i >= 0; i--) {
|
||||||
const ratio = i / gradientSteps;
|
const ratio = i / gradientSteps;
|
||||||
const radius = gradientRadius * (0.3 + ratio * 0.7);
|
const radius = gradientRadius * (0.3 + ratio * 0.7);
|
||||||
|
|
||||||
const r = Math.floor(8 * (1 - ratio));
|
const r = Math.floor(8 * (1 - ratio));
|
||||||
const g = Math.floor(6 * (1 - ratio));
|
const g = Math.floor(6 * (1 - ratio));
|
||||||
const b = Math.floor(20 * (1 - ratio));
|
const b = Math.floor(20 * (1 - ratio));
|
||||||
const color = (r << 16) + (g << 8) + b;
|
const color = (r << 16) + (g << 8) + b;
|
||||||
|
|
||||||
spaceGradient.circle(centerX, centerY, radius).fill({ color, alpha: 0.8 });
|
spaceGradient.circle(centerX, centerY, radius).fill({ color, alpha: 0.8 });
|
||||||
}
|
}
|
||||||
deepBackgroundContainer.addChild(spaceGradient);
|
deepBackgroundContainer.addChild(spaceGradient);
|
||||||
|
|
||||||
// ========== NEBULA CLOUDS ==========
|
// ========== NEBULA CLOUDS ==========
|
||||||
const nebulaColors = nebulaPalette;
|
const nebulaColors = nebulaPalette;
|
||||||
|
|
||||||
nebulaColors.forEach((nebula, index) => {
|
nebulaColors.forEach((nebula, index) => {
|
||||||
const angle = (index / nebulaColors.length) * Math.PI * 2 + Math.PI / 4;
|
const angle = (index / nebulaColors.length) * Math.PI * 2 + Math.PI / 4;
|
||||||
const distance = Math.min(screenWidth, screenHeight) * 0.35;
|
const distance = Math.min(screenWidth, screenHeight) * 0.35;
|
||||||
const baseX = centerX + Math.cos(angle) * distance;
|
const baseX = centerX + Math.cos(angle) * distance;
|
||||||
const baseY = centerY + Math.sin(angle) * distance;
|
const baseY = centerY + Math.sin(angle) * distance;
|
||||||
|
|
||||||
const nebulaCloud = new Graphics();
|
const nebulaCloud = new Graphics();
|
||||||
|
|
||||||
// Outer very faint glow
|
// Outer very faint glow
|
||||||
const outerSize = 200 + Math.random() * 150;
|
const outerSize = 200 + Math.random() * 150;
|
||||||
for (let layer = 5; layer >= 0; layer--) {
|
for (let layer = 5; layer >= 0; layer--) {
|
||||||
const layerRatio = layer / 5;
|
const layerRatio = layer / 5;
|
||||||
const size = outerSize * (0.4 + layerRatio * 0.6);
|
const size = outerSize * (0.4 + layerRatio * 0.6);
|
||||||
const alpha = 0.02 * (1 - layerRatio);
|
const alpha = 0.02 * (1 - layerRatio);
|
||||||
nebulaCloud.circle(0, 0, size).fill({ color: nebula.color, alpha });
|
nebulaCloud.circle(0, 0, size).fill({ color: nebula.color, alpha });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Irregular blobs for a more organic look
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const offsetX = (Math.random() - 0.5) * 100;
|
||||||
|
const offsetY = (Math.random() - 0.5) * 100;
|
||||||
|
const blobSize = 60 + Math.random() * 80;
|
||||||
|
|
||||||
|
for (let layer = 3; layer >= 0; layer--) {
|
||||||
|
const layerRatio = layer / 3;
|
||||||
|
const size = blobSize * (0.5 + layerRatio * 0.5);
|
||||||
|
const alpha = 0.03 * (1 - layerRatio);
|
||||||
|
nebulaCloud.circle(offsetX, offsetY, size).fill({ color: nebula.color, alpha });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nebulaCloud.x = baseX;
|
||||||
|
nebulaCloud.y = baseY;
|
||||||
|
nebulaContainer.addChild(nebulaCloud);
|
||||||
|
|
||||||
|
// Animate nebula pulsing
|
||||||
|
gsap.to(nebulaCloud, {
|
||||||
|
alpha: 0.6,
|
||||||
|
duration: 3 + Math.random() * 2,
|
||||||
|
ease: 'sine.inOut',
|
||||||
|
yoyo: true,
|
||||||
|
repeat: -1,
|
||||||
|
delay: Math.random() * 2
|
||||||
|
});
|
||||||
|
|
||||||
|
gsap.to(nebulaCloud.scale, {
|
||||||
|
x: 1.1,
|
||||||
|
y: 1.1,
|
||||||
|
duration: 4 + Math.random() * 2,
|
||||||
|
ease: 'sine.inOut',
|
||||||
|
yoyo: true,
|
||||||
|
repeat: -1,
|
||||||
|
delay: Math.random() * 2
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== DISTANT STAR FIELD ==========
|
||||||
|
// Layer 1: Tiny distant stars (many, dim)
|
||||||
|
const distantStarCount = byDevice(PARTICLE_COUNTS.distantStars, isMobile);
|
||||||
|
for (let i = 0; i < distantStarCount; i++) {
|
||||||
|
const star = new Graphics();
|
||||||
|
const size = Math.random() * 1 + 0.5;
|
||||||
|
const alpha = Math.random() * 0.4 + 0.1;
|
||||||
|
|
||||||
|
star.circle(0, 0, size).fill({ color: 0xffffff, alpha });
|
||||||
|
|
||||||
|
star.x = Math.random() * screenWidth;
|
||||||
|
star.y = Math.random() * screenHeight;
|
||||||
|
starContainer.addChild(star);
|
||||||
|
|
||||||
|
gsap.to(star, {
|
||||||
|
alpha: alpha * 0.3,
|
||||||
|
duration: 1 + Math.random() * 2,
|
||||||
|
ease: 'sine.inOut',
|
||||||
|
yoyo: true,
|
||||||
|
repeat: -1,
|
||||||
|
delay: Math.random() * 3
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Irregular blobs for a more organic look
|
// Layer 2: Medium stars with colour tints
|
||||||
for (let i = 0; i < 3; i++) {
|
const mediumStarCount = byDevice(PARTICLE_COUNTS.mediumStars, isMobile);
|
||||||
const offsetX = (Math.random() - 0.5) * 100;
|
const starTints = [0xffffff, 0xfff8e7, 0xe7f0ff, 0xffe7e7, 0xe7ffe7];
|
||||||
const offsetY = (Math.random() - 0.5) * 100;
|
|
||||||
const blobSize = 60 + Math.random() * 80;
|
|
||||||
|
|
||||||
for (let layer = 3; layer >= 0; layer--) {
|
for (let i = 0; i < mediumStarCount; i++) {
|
||||||
const layerRatio = layer / 3;
|
const star = new Graphics();
|
||||||
const size = blobSize * (0.5 + layerRatio * 0.5);
|
const size = Math.random() * 1.5 + 1;
|
||||||
const alpha = 0.03 * (1 - layerRatio);
|
const alpha = Math.random() * 0.5 + 0.3;
|
||||||
nebulaCloud.circle(offsetX, offsetY, size).fill({ color: nebula.color, alpha });
|
const tint = starTints[Math.floor(Math.random() * starTints.length)];
|
||||||
|
|
||||||
|
star.circle(0, 0, size + 2).fill({ color: tint, alpha: alpha * 0.2 });
|
||||||
|
star.circle(0, 0, size).fill({ color: tint, alpha });
|
||||||
|
|
||||||
|
star.x = Math.random() * screenWidth;
|
||||||
|
star.y = Math.random() * screenHeight;
|
||||||
|
starContainer.addChild(star);
|
||||||
|
|
||||||
|
gsap.to(star, {
|
||||||
|
alpha: alpha * 0.4,
|
||||||
|
duration: 0.5 + Math.random() * 1.5,
|
||||||
|
ease: 'sine.inOut',
|
||||||
|
yoyo: true,
|
||||||
|
repeat: -1,
|
||||||
|
delay: Math.random() * 2
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer 3: Bright feature stars (few, prominent)
|
||||||
|
const brightStarCount = byDevice(PARTICLE_COUNTS.brightStars, isMobile);
|
||||||
|
for (let i = 0; i < brightStarCount; i++) {
|
||||||
|
const star = new Graphics();
|
||||||
|
const size = Math.random() * 2 + 1.5;
|
||||||
|
const tint = starTints[Math.floor(Math.random() * starTints.length)];
|
||||||
|
|
||||||
|
star.circle(0, 0, size + 6).fill({ color: tint, alpha: 0.05 });
|
||||||
|
star.circle(0, 0, size + 3).fill({ color: tint, alpha: 0.1 });
|
||||||
|
star.circle(0, 0, size).fill({ color: 0xffffff, alpha: 0.9 });
|
||||||
|
|
||||||
|
star.x = Math.random() * screenWidth;
|
||||||
|
star.y = Math.random() * screenHeight;
|
||||||
|
starContainer.addChild(star);
|
||||||
|
|
||||||
|
gsap.to(star.scale, {
|
||||||
|
x: 1.3,
|
||||||
|
y: 1.3,
|
||||||
|
duration: 2 + Math.random() * 2,
|
||||||
|
ease: 'sine.inOut',
|
||||||
|
yoyo: true,
|
||||||
|
repeat: -1,
|
||||||
|
delay: Math.random() * 2
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== FLOATING DUST PARTICLES ==========
|
||||||
|
const dustParticles: Array<{ graphics: Graphics; vx: number; vy: number }> = [];
|
||||||
|
const dustCount = byDevice(PARTICLE_COUNTS.dust, isMobile);
|
||||||
|
|
||||||
|
for (let i = 0; i < dustCount; i++) {
|
||||||
|
const dust = new Graphics();
|
||||||
|
const size = Math.random() * 2 + 1;
|
||||||
|
const colorIndex = Math.floor(Math.random() * nebulaColors.length);
|
||||||
|
const color = nebulaColors[colorIndex].color;
|
||||||
|
|
||||||
|
dust.circle(0, 0, size + 4).fill({ color, alpha: 0.05 });
|
||||||
|
dust.circle(0, 0, size + 2).fill({ color, alpha: 0.08 });
|
||||||
|
dust.circle(0, 0, size).fill({ color, alpha: 0.15 });
|
||||||
|
|
||||||
|
dust.x = Math.random() * screenWidth;
|
||||||
|
dust.y = Math.random() * screenHeight;
|
||||||
|
starContainer.addChild(dust);
|
||||||
|
|
||||||
|
dustParticles.push({
|
||||||
|
graphics: dust,
|
||||||
|
vx: (Math.random() - 0.5) * 0.2,
|
||||||
|
vy: (Math.random() - 0.5) * 0.2
|
||||||
|
});
|
||||||
|
|
||||||
|
gsap.to(dust, {
|
||||||
|
alpha: 0.3,
|
||||||
|
duration: 3 + Math.random() * 2,
|
||||||
|
ease: 'sine.inOut',
|
||||||
|
yoyo: true,
|
||||||
|
repeat: -1,
|
||||||
|
delay: Math.random() * 2
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animate dust particles drifting. Named function + indexed loop: `forEach`
|
||||||
|
// with an inline arrow allocated a fresh closure on every frame.
|
||||||
|
function driftDust() {
|
||||||
|
for (let i = 0; i < dustParticles.length; i++) {
|
||||||
|
const p = dustParticles[i];
|
||||||
|
const g = p.graphics;
|
||||||
|
g.x += p.vx;
|
||||||
|
g.y += p.vy;
|
||||||
|
|
||||||
|
if (g.x < -20) g.x = screenWidth + 20;
|
||||||
|
else if (g.x > screenWidth + 20) g.x = -20;
|
||||||
|
if (g.y < -20) g.y = screenHeight + 20;
|
||||||
|
else if (g.y > screenHeight + 20) g.y = -20;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
app.ticker.add(driftDust);
|
||||||
|
life.add(() => app.ticker.remove(driftDust));
|
||||||
|
|
||||||
nebulaCloud.x = baseX;
|
// ========== VIGNETTE OVERLAY ==========
|
||||||
nebulaCloud.y = baseY;
|
vignette = new Graphics();
|
||||||
nebulaContainer.addChild(nebulaCloud);
|
const vignetteSize = Math.max(screenWidth, screenHeight) * 1.2;
|
||||||
|
|
||||||
// Animate nebula pulsing
|
for (let i = 12; i >= 0; i--) {
|
||||||
gsap.to(nebulaCloud, {
|
const ratio = i / 12;
|
||||||
alpha: 0.6,
|
const radius = vignetteSize * (0.4 + ratio * 0.6);
|
||||||
duration: 3 + Math.random() * 2,
|
const alpha = ratio * ratio * 0.4;
|
||||||
ease: 'sine.inOut',
|
vignette.circle(centerX, centerY, radius).fill({ color: 0x000000, alpha });
|
||||||
yoyo: true,
|
}
|
||||||
repeat: -1,
|
app.stage.addChild(vignette);
|
||||||
delay: Math.random() * 2
|
|
||||||
});
|
|
||||||
|
|
||||||
gsap.to(nebulaCloud.scale, {
|
// The gradient (16 fills) and vignette (13 fills) are near-full-screen and
|
||||||
x: 1.1,
|
// never change — bake them to a texture instead of recompositing every frame.
|
||||||
y: 1.1,
|
deepBackgroundContainer.cacheAsTexture(true);
|
||||||
duration: 4 + Math.random() * 2,
|
vignette.cacheAsTexture(true);
|
||||||
ease: 'sine.inOut',
|
|
||||||
yoyo: true,
|
// Nothing in the background is clickable; skip it during hit-testing.
|
||||||
repeat: -1,
|
deepBackgroundContainer.interactiveChildren = false;
|
||||||
delay: Math.random() * 2
|
nebulaContainer.interactiveChildren = false;
|
||||||
});
|
starContainer.interactiveChildren = false;
|
||||||
|
vignette.eventMode = 'none';
|
||||||
});
|
});
|
||||||
|
|
||||||
// ========== DISTANT STAR FIELD ==========
|
return { deepBackgroundContainer, nebulaContainer, starContainer, vignette };
|
||||||
// Layer 1: Tiny distant stars (many, dim)
|
|
||||||
const distantStarCount = byDevice(PARTICLE_COUNTS.distantStars, isMobile);
|
|
||||||
for (let i = 0; i < distantStarCount; i++) {
|
|
||||||
const star = new Graphics();
|
|
||||||
const size = Math.random() * 1 + 0.5;
|
|
||||||
const alpha = Math.random() * 0.4 + 0.1;
|
|
||||||
|
|
||||||
star.circle(0, 0, size).fill({ color: 0xffffff, alpha });
|
|
||||||
|
|
||||||
star.x = Math.random() * screenWidth;
|
|
||||||
star.y = Math.random() * screenHeight;
|
|
||||||
starContainer.addChild(star);
|
|
||||||
|
|
||||||
gsap.to(star, {
|
|
||||||
alpha: alpha * 0.3,
|
|
||||||
duration: 1 + Math.random() * 2,
|
|
||||||
ease: 'sine.inOut',
|
|
||||||
yoyo: true,
|
|
||||||
repeat: -1,
|
|
||||||
delay: Math.random() * 3
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Layer 2: Medium stars with colour tints
|
|
||||||
const mediumStarCount = byDevice(PARTICLE_COUNTS.mediumStars, isMobile);
|
|
||||||
const starTints = [0xffffff, 0xfff8e7, 0xe7f0ff, 0xffe7e7, 0xe7ffe7];
|
|
||||||
|
|
||||||
for (let i = 0; i < mediumStarCount; i++) {
|
|
||||||
const star = new Graphics();
|
|
||||||
const size = Math.random() * 1.5 + 1;
|
|
||||||
const alpha = Math.random() * 0.5 + 0.3;
|
|
||||||
const tint = starTints[Math.floor(Math.random() * starTints.length)];
|
|
||||||
|
|
||||||
star.circle(0, 0, size + 2).fill({ color: tint, alpha: alpha * 0.2 });
|
|
||||||
star.circle(0, 0, size).fill({ color: tint, alpha });
|
|
||||||
|
|
||||||
star.x = Math.random() * screenWidth;
|
|
||||||
star.y = Math.random() * screenHeight;
|
|
||||||
starContainer.addChild(star);
|
|
||||||
|
|
||||||
gsap.to(star, {
|
|
||||||
alpha: alpha * 0.4,
|
|
||||||
duration: 0.5 + Math.random() * 1.5,
|
|
||||||
ease: 'sine.inOut',
|
|
||||||
yoyo: true,
|
|
||||||
repeat: -1,
|
|
||||||
delay: Math.random() * 2
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Layer 3: Bright feature stars (few, prominent)
|
|
||||||
const brightStarCount = byDevice(PARTICLE_COUNTS.brightStars, isMobile);
|
|
||||||
for (let i = 0; i < brightStarCount; i++) {
|
|
||||||
const star = new Graphics();
|
|
||||||
const size = Math.random() * 2 + 1.5;
|
|
||||||
const tint = starTints[Math.floor(Math.random() * starTints.length)];
|
|
||||||
|
|
||||||
star.circle(0, 0, size + 6).fill({ color: tint, alpha: 0.05 });
|
|
||||||
star.circle(0, 0, size + 3).fill({ color: tint, alpha: 0.1 });
|
|
||||||
star.circle(0, 0, size).fill({ color: 0xffffff, alpha: 0.9 });
|
|
||||||
|
|
||||||
star.x = Math.random() * screenWidth;
|
|
||||||
star.y = Math.random() * screenHeight;
|
|
||||||
starContainer.addChild(star);
|
|
||||||
|
|
||||||
gsap.to(star.scale, {
|
|
||||||
x: 1.3,
|
|
||||||
y: 1.3,
|
|
||||||
duration: 2 + Math.random() * 2,
|
|
||||||
ease: 'sine.inOut',
|
|
||||||
yoyo: true,
|
|
||||||
repeat: -1,
|
|
||||||
delay: Math.random() * 2
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== FLOATING DUST PARTICLES ==========
|
|
||||||
const dustParticles: Array<{ graphics: Graphics; vx: number; vy: number }> = [];
|
|
||||||
const dustCount = byDevice(PARTICLE_COUNTS.dust, isMobile);
|
|
||||||
|
|
||||||
for (let i = 0; i < dustCount; i++) {
|
|
||||||
const dust = new Graphics();
|
|
||||||
const size = Math.random() * 2 + 1;
|
|
||||||
const colorIndex = Math.floor(Math.random() * nebulaColors.length);
|
|
||||||
const color = nebulaColors[colorIndex].color;
|
|
||||||
|
|
||||||
dust.circle(0, 0, size + 4).fill({ color, alpha: 0.05 });
|
|
||||||
dust.circle(0, 0, size + 2).fill({ color, alpha: 0.08 });
|
|
||||||
dust.circle(0, 0, size).fill({ color, alpha: 0.15 });
|
|
||||||
|
|
||||||
dust.x = Math.random() * screenWidth;
|
|
||||||
dust.y = Math.random() * screenHeight;
|
|
||||||
starContainer.addChild(dust);
|
|
||||||
|
|
||||||
dustParticles.push({
|
|
||||||
graphics: dust,
|
|
||||||
vx: (Math.random() - 0.5) * 0.2,
|
|
||||||
vy: (Math.random() - 0.5) * 0.2
|
|
||||||
});
|
|
||||||
|
|
||||||
gsap.to(dust, {
|
|
||||||
alpha: 0.3,
|
|
||||||
duration: 3 + Math.random() * 2,
|
|
||||||
ease: 'sine.inOut',
|
|
||||||
yoyo: true,
|
|
||||||
repeat: -1,
|
|
||||||
delay: Math.random() * 2
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Animate dust particles drifting. Named function + indexed loop: `forEach`
|
|
||||||
// with an inline arrow allocated a fresh closure on every frame.
|
|
||||||
function driftDust() {
|
|
||||||
for (let i = 0; i < dustParticles.length; i++) {
|
|
||||||
const p = dustParticles[i];
|
|
||||||
const g = p.graphics;
|
|
||||||
g.x += p.vx;
|
|
||||||
g.y += p.vy;
|
|
||||||
|
|
||||||
if (g.x < -20) g.x = screenWidth + 20;
|
|
||||||
else if (g.x > screenWidth + 20) g.x = -20;
|
|
||||||
if (g.y < -20) g.y = screenHeight + 20;
|
|
||||||
else if (g.y > screenHeight + 20) g.y = -20;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
app.ticker.add(driftDust);
|
|
||||||
|
|
||||||
// ========== VIGNETTE OVERLAY ==========
|
|
||||||
const vignette = new Graphics();
|
|
||||||
const vignetteSize = Math.max(screenWidth, screenHeight) * 1.2;
|
|
||||||
|
|
||||||
for (let i = 12; i >= 0; i--) {
|
|
||||||
const ratio = i / 12;
|
|
||||||
const radius = vignetteSize * (0.4 + ratio * 0.6);
|
|
||||||
const alpha = ratio * ratio * 0.4;
|
|
||||||
vignette.circle(centerX, centerY, radius).fill({ color: 0x000000, alpha });
|
|
||||||
}
|
|
||||||
app.stage.addChild(vignette);
|
|
||||||
|
|
||||||
// The gradient (16 fills) and vignette (13 fills) are near-full-screen and
|
|
||||||
// never change — bake them to a texture instead of recompositing every frame.
|
|
||||||
deepBackgroundContainer.cacheAsTexture(true);
|
|
||||||
vignette.cacheAsTexture(true);
|
|
||||||
|
|
||||||
// Nothing in the background is clickable; skip it during hit-testing.
|
|
||||||
deepBackgroundContainer.interactiveChildren = false;
|
|
||||||
nebulaContainer.interactiveChildren = false;
|
|
||||||
starContainer.interactiveChildren = false;
|
|
||||||
vignette.eventMode = 'none';
|
|
||||||
|
|
||||||
return { deepBackgroundContainer, nebulaContainer, starContainer };
|
|
||||||
}
|
}
|
||||||
+97
-47
@@ -6,6 +6,7 @@
|
|||||||
import { gsap } from 'gsap';
|
import { gsap } from 'gsap';
|
||||||
import { ZOOM, PARALLAX, PAN_SPEED } from '$lib/config';
|
import { ZOOM, PARALLAX, PAN_SPEED } from '$lib/config';
|
||||||
import type { SkillTreeController } from './controller';
|
import type { SkillTreeController } from './controller';
|
||||||
|
import type { Lifecycle } from './lifecycle';
|
||||||
|
|
||||||
/** Offset the background layers behind the tree to create parallax depth. */
|
/** Offset the background layers behind the tree to create parallax depth. */
|
||||||
export function updateParallax(ctrl: SkillTreeController) {
|
export function updateParallax(ctrl: SkillTreeController) {
|
||||||
@@ -25,16 +26,31 @@ export function updateParallax(ctrl: SkillTreeController) {
|
|||||||
|
|
||||||
interface PanZoomOpts {
|
interface PanZoomOpts {
|
||||||
onTooltipMove: (x: number, y: number) => void;
|
onTooltipMove: (x: number, y: number) => void;
|
||||||
|
/** Tooltip coords are only worth computing while a node is actually hovered. */
|
||||||
|
hasHover: () => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wire mouse/touch/wheel pan + zoom. Returns a cleanup for window listeners. */
|
export interface PanZoomHandle {
|
||||||
export function attachPanZoom(ctrl: SkillTreeController, opts: PanZoomOpts): () => void {
|
/**
|
||||||
|
* 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 { app, treeContainer } = ctrl;
|
||||||
const minZoom = ZOOM.min;
|
const minZoom = ZOOM.min;
|
||||||
const maxZoom = ZOOM.max;
|
const maxZoom = ZOOM.max;
|
||||||
|
|
||||||
// ===== MOUSE PANNING =====
|
// ===== MOUSE PANNING =====
|
||||||
app.canvas.addEventListener('mousedown', (e: MouseEvent) => {
|
life.listen(app.canvas, 'mousedown', (e: MouseEvent) => {
|
||||||
ctrl.isDragging = true;
|
ctrl.isDragging = true;
|
||||||
ctrl.hasMoved = false;
|
ctrl.hasMoved = false;
|
||||||
ctrl.dragStartX = e.clientX;
|
ctrl.dragStartX = e.clientX;
|
||||||
@@ -55,23 +71,47 @@ export function attachPanZoom(ctrl: SkillTreeController, opts: PanZoomOpts): ()
|
|||||||
treeContainer.y = ctrl.containerStartY + dy;
|
treeContainer.y = ctrl.containerStartY + dy;
|
||||||
updateParallax(ctrl);
|
updateParallax(ctrl);
|
||||||
};
|
};
|
||||||
window.addEventListener('mousemove', onWindowMouseMove);
|
life.listen(window, 'mousemove', onWindowMouseMove);
|
||||||
|
|
||||||
const onWindowMouseUp = () => {
|
const onWindowMouseUp = () => {
|
||||||
ctrl.isDragging = false;
|
ctrl.isDragging = false;
|
||||||
app.canvas.style.cursor = 'grab';
|
app.canvas.style.cursor = 'grab';
|
||||||
};
|
};
|
||||||
window.addEventListener('mouseup', onWindowMouseUp);
|
life.listen(window, 'mouseup', onWindowMouseUp);
|
||||||
|
|
||||||
app.canvas.style.cursor = 'grab';
|
app.canvas.style.cursor = 'grab';
|
||||||
|
|
||||||
// Track mouse position for tooltip
|
// Track mouse position for the tooltip.
|
||||||
app.canvas.addEventListener('mousemove', (e: MouseEvent) => {
|
//
|
||||||
opts.onTooltipMove(e.clientX, e.clientY);
|
// 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 =====
|
// ===== TOUCH PANNING & PINCH ZOOM =====
|
||||||
app.canvas.addEventListener(
|
life.listen(
|
||||||
|
app.canvas,
|
||||||
'touchstart',
|
'touchstart',
|
||||||
(e: TouchEvent) => {
|
(e: TouchEvent) => {
|
||||||
if (e.touches.length === 1) {
|
if (e.touches.length === 1) {
|
||||||
@@ -94,7 +134,8 @@ export function attachPanZoom(ctrl: SkillTreeController, opts: PanZoomOpts): ()
|
|||||||
{ passive: true }
|
{ passive: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
app.canvas.addEventListener(
|
life.listen(
|
||||||
|
app.canvas,
|
||||||
'touchmove',
|
'touchmove',
|
||||||
(e: TouchEvent) => {
|
(e: TouchEvent) => {
|
||||||
if (e.touches.length === 1 && ctrl.isDragging && !ctrl.isTouchZooming) {
|
if (e.touches.length === 1 && ctrl.isDragging && !ctrl.isTouchZooming) {
|
||||||
@@ -129,7 +170,8 @@ export function attachPanZoom(ctrl: SkillTreeController, opts: PanZoomOpts): ()
|
|||||||
{ passive: true }
|
{ passive: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
app.canvas.addEventListener(
|
life.listen(
|
||||||
|
app.canvas,
|
||||||
'touchend',
|
'touchend',
|
||||||
(e: TouchEvent) => {
|
(e: TouchEvent) => {
|
||||||
if (e.touches.length === 0) {
|
if (e.touches.length === 0) {
|
||||||
@@ -150,22 +192,26 @@ export function attachPanZoom(ctrl: SkillTreeController, opts: PanZoomOpts): ()
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ===== WHEEL ZOOM =====
|
// ===== WHEEL ZOOM =====
|
||||||
app.canvas.addEventListener('wheel', (e: WheelEvent) => {
|
// Explicitly non-passive: this calls preventDefault(). It works by default on
|
||||||
e.preventDefault();
|
// a non-root element, but relying on that is an implicit dependency.
|
||||||
const oldScale = treeContainer.scale.x;
|
life.listen(
|
||||||
const zoomFactor = e.deltaY > 0 ? ZOOM.wheelOut : ZOOM.wheelIn;
|
app.canvas,
|
||||||
const newScale = Math.max(minZoom, Math.min(maxZoom, oldScale * zoomFactor));
|
'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
|
// Zoom centered on origin node (0,0 in tree space): origin sits at
|
||||||
// (treeContainer.x, treeContainer.y), so scaling keeps it anchored.
|
// (treeContainer.x, treeContainer.y), so scaling keeps it anchored.
|
||||||
treeContainer.scale.set(newScale);
|
treeContainer.scale.set(newScale);
|
||||||
updateParallax(ctrl);
|
updateParallax(ctrl);
|
||||||
});
|
},
|
||||||
|
{ passive: false }
|
||||||
|
);
|
||||||
|
|
||||||
return () => {
|
return { flushTooltip };
|
||||||
window.removeEventListener('mousemove', onWindowMouseMove);
|
|
||||||
window.removeEventListener('mouseup', onWindowMouseUp);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface KeyboardOpts {
|
interface KeyboardOpts {
|
||||||
@@ -175,8 +221,8 @@ interface KeyboardOpts {
|
|||||||
isStatsVisible: () => boolean;
|
isStatsVisible: () => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wire keyboard navigation (pan/zoom/reset/stats/escape). Returns cleanup. */
|
/** Wire keyboard navigation (pan/zoom/reset/stats/escape). Owned by `life`. */
|
||||||
export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts): () => void {
|
export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts, life: Lifecycle) {
|
||||||
const { app, treeContainer } = ctrl;
|
const { app, treeContainer } = ctrl;
|
||||||
const minZoom = ZOOM.min;
|
const minZoom = ZOOM.min;
|
||||||
const maxZoom = ZOOM.max;
|
const maxZoom = ZOOM.max;
|
||||||
@@ -241,28 +287,32 @@ export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts): (
|
|||||||
// R to reset view to origin
|
// R to reset view to origin
|
||||||
if (e.key === 'r' || e.key === 'R') {
|
if (e.key === 'r' || e.key === 'R') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
gsap.to(treeContainer, {
|
// Re-enter the GSAP context: these are created per keypress.
|
||||||
x: app.screen.width / 2,
|
life.animate(() => {
|
||||||
y: app.screen.height / 2,
|
gsap.to(treeContainer, {
|
||||||
duration: 0.5,
|
x: app.screen.width / 2,
|
||||||
ease: 'power2.out',
|
y: app.screen.height / 2,
|
||||||
onUpdate: () => updateParallax(ctrl),
|
duration: 0.5,
|
||||||
onComplete: () => {
|
ease: 'power2.out',
|
||||||
// Update parallax baseline after reset
|
overwrite: 'auto',
|
||||||
ctrl.initialTreeX = treeContainer.x;
|
onUpdate: () => updateParallax(ctrl),
|
||||||
ctrl.initialTreeY = treeContainer.y;
|
onComplete: () => {
|
||||||
}
|
// Update parallax baseline after reset
|
||||||
});
|
ctrl.initialTreeX = treeContainer.x;
|
||||||
gsap.to(treeContainer.scale, {
|
ctrl.initialTreeY = treeContainer.y;
|
||||||
x: ctrl.endScale,
|
}
|
||||||
y: ctrl.endScale,
|
});
|
||||||
duration: 0.5,
|
gsap.to(treeContainer.scale, {
|
||||||
ease: 'power2.out',
|
x: ctrl.endScale,
|
||||||
onUpdate: () => updateParallax(ctrl)
|
y: ctrl.endScale,
|
||||||
|
duration: 0.5,
|
||||||
|
ease: 'power2.out',
|
||||||
|
overwrite: 'auto',
|
||||||
|
onUpdate: () => updateParallax(ctrl)
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeydown);
|
life.listen(window, 'keydown', handleKeydown);
|
||||||
return () => window.removeEventListener('keydown', handleKeydown);
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// Single owner for every resource the tree allocates, so one dispose() call
|
||||||
|
// releases all of it.
|
||||||
|
//
|
||||||
|
// Why this exists: the PixiJS layer creates ~480 infinite GSAP tweens, a ticker
|
||||||
|
// callback, and a dozen DOM listeners. None of them were ever released, and 112
|
||||||
|
// of the tweens are created inside an `onComplete` — so they don't exist yet at
|
||||||
|
// the moment a naive `killTweensOf` sweep would run.
|
||||||
|
//
|
||||||
|
// `gsap.context()` solves exactly that: GSAP records each animation's context on
|
||||||
|
// creation, and restores that context while invoking the animation's callbacks
|
||||||
|
// (see `_callback` in gsap-core). So a tween created inside a tracked tween's
|
||||||
|
// onComplete is tracked by the same context, and ctx.kill() gets all of them.
|
||||||
|
import { gsap } from 'gsap';
|
||||||
|
|
||||||
|
export function createLifecycle() {
|
||||||
|
const disposers: Array<() => void> = [];
|
||||||
|
const gsapCtx = gsap.context(() => {});
|
||||||
|
let isDisposed = false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
/** True once dispose() has run — guards async work that resolves late. */
|
||||||
|
get disposed() {
|
||||||
|
return isDisposed;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `fn` with GSAP's active context set, so every tween it creates is
|
||||||
|
* tracked for teardown. Returns whatever `fn` returns.
|
||||||
|
*/
|
||||||
|
animate<T>(fn: () => T): T {
|
||||||
|
return gsapCtx.add(fn) as T;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Register an arbitrary teardown step. */
|
||||||
|
add(fn: () => void) {
|
||||||
|
disposers.push(fn);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Add a DOM listener and register its removal in one call. */
|
||||||
|
listen<T extends EventTarget>(
|
||||||
|
target: T,
|
||||||
|
type: string,
|
||||||
|
handler: (event: never) => void,
|
||||||
|
options?: AddEventListenerOptions
|
||||||
|
) {
|
||||||
|
target.addEventListener(type, handler as EventListener, options);
|
||||||
|
disposers.push(() => target.removeEventListener(type, handler as EventListener, options));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release everything, newest first. `kill()` rather than `revert()`: we do
|
||||||
|
* not want GSAP writing properties back onto Pixi objects that are about
|
||||||
|
* to be destroyed.
|
||||||
|
*/
|
||||||
|
dispose() {
|
||||||
|
if (isDisposed) return;
|
||||||
|
isDisposed = true;
|
||||||
|
|
||||||
|
gsapCtx.kill();
|
||||||
|
|
||||||
|
for (let i = disposers.length - 1; i >= 0; i--) {
|
||||||
|
try {
|
||||||
|
disposers[i]();
|
||||||
|
} catch (err) {
|
||||||
|
// One failing teardown step must not strand the rest.
|
||||||
|
console.warn('Teardown step failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
disposers.length = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Lifecycle = ReturnType<typeof createLifecycle>;
|
||||||
+16
-5
@@ -8,11 +8,13 @@ import { categoryColors } from '$lib/theme';
|
|||||||
import { getNodeRadius } from '$lib/utils/tree';
|
import { getNodeRadius } from '$lib/utils/tree';
|
||||||
import type { NodeObject } from './types';
|
import type { NodeObject } from './types';
|
||||||
import type { SkillTreeController } from './controller';
|
import type { SkillTreeController } from './controller';
|
||||||
|
import type { Lifecycle } from './lifecycle';
|
||||||
|
|
||||||
interface NodeOpts {
|
interface NodeOpts {
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
isTouchDevice: boolean;
|
isTouchDevice: boolean;
|
||||||
controller: SkillTreeController;
|
controller: SkillTreeController;
|
||||||
|
life: Lifecycle;
|
||||||
onOpen: (node: TreeNode) => void;
|
onOpen: (node: TreeNode) => void;
|
||||||
onHover: (node: TreeNode | null) => void;
|
onHover: (node: TreeNode | null) => void;
|
||||||
}
|
}
|
||||||
@@ -34,15 +36,22 @@ function attachNodeInteraction(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Hover tweens are created per-event, long after setup, so each creation has
|
||||||
|
// to re-enter the GSAP context to stay killable. `overwrite: 'auto'` stops
|
||||||
|
// rapid in/out sweeps from stacking conflicting tweens on the same property.
|
||||||
target.on('pointerover', () => {
|
target.on('pointerover', () => {
|
||||||
gsap.to(target.scale, { x: 1.2, y: 1.2, duration: 0.2 });
|
opts.life.animate(() => {
|
||||||
gsap.to(glowGraphics, { alpha: 1.5, duration: 0.2 });
|
gsap.to(target.scale, { x: 1.2, y: 1.2, duration: 0.2, overwrite: 'auto' });
|
||||||
|
gsap.to(glowGraphics, { alpha: 1.5, duration: 0.2, overwrite: 'auto' });
|
||||||
|
});
|
||||||
if (!opts.isTouchDevice) opts.onHover(node);
|
if (!opts.isTouchDevice) opts.onHover(node);
|
||||||
});
|
});
|
||||||
|
|
||||||
target.on('pointerout', () => {
|
target.on('pointerout', () => {
|
||||||
gsap.to(target.scale, { x: 1, y: 1, duration: 0.2 });
|
opts.life.animate(() => {
|
||||||
gsap.to(glowGraphics, { alpha: 1, duration: 0.2 });
|
gsap.to(target.scale, { x: 1, y: 1, duration: 0.2, overwrite: 'auto' });
|
||||||
|
gsap.to(glowGraphics, { alpha: 1, duration: 0.2, overwrite: 'auto' });
|
||||||
|
});
|
||||||
if (!opts.isTouchDevice) opts.onHover(null);
|
if (!opts.isTouchDevice) opts.onHover(null);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -59,7 +68,9 @@ export async function createNodes(treeContainer: Container, opts: NodeOpts): Pro
|
|||||||
const [profileTexture, iconTextures] = await Promise.all([
|
const [profileTexture, iconTextures] = await Promise.all([
|
||||||
Assets.load<Texture>('/headshot.jpg'),
|
Assets.load<Texture>('/headshot.jpg'),
|
||||||
// Array form resolves to a url -> Texture map and fetches concurrently.
|
// Array form resolves to a url -> Texture map and fetches concurrently.
|
||||||
iconUrls.length ? Assets.load<Texture>(iconUrls) : Promise.resolve({} as Record<string, Texture>)
|
iconUrls.length
|
||||||
|
? Assets.load<Texture>(iconUrls)
|
||||||
|
: Promise.resolve({} as Record<string, Texture>)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
for (const node of treeNodes) {
|
for (const node of treeNodes) {
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ export interface NodeObject {
|
|||||||
glowGraphics: Graphics;
|
glowGraphics: Graphics;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The three parallax background layers (vignette lives on the stage). */
|
/** The parallax background layers plus the vignette overlay. */
|
||||||
export interface BackgroundLayers {
|
export interface BackgroundLayers {
|
||||||
deepBackgroundContainer: Container;
|
deepBackgroundContainer: Container;
|
||||||
nebulaContainer: Container;
|
nebulaContainer: Container;
|
||||||
starContainer: Container;
|
starContainer: Container;
|
||||||
|
/** Static overlay on the stage; returned so a resize rebuild can destroy it. */
|
||||||
|
vignette: Graphics;
|
||||||
}
|
}
|
||||||
+119
-25
@@ -42,38 +42,97 @@
|
|||||||
// terminate this block early or trip the Svelte ESLint parser.
|
// terminate this block early or trip the Svelte ESLint parser.
|
||||||
const personLdScript = '<script type="application/ld+json">' + personJsonLd + '</' + 'script>';
|
const personLdScript = '<script type="application/ld+json">' + personJsonLd + '</' + 'script>';
|
||||||
|
|
||||||
onMount(() => {
|
// Set when a new worker is installed and waiting; drives the refresh prompt.
|
||||||
// Register service worker for PWA support
|
let updateReady = $state<(() => void) | null>(null);
|
||||||
if ('serviceWorker' in navigator) {
|
|
||||||
navigator.serviceWorker
|
|
||||||
.register('/service-worker.js')
|
|
||||||
.then((registration) => {
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log('✨ Service Worker registered successfully:', registration.scope);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for updates periodically
|
onMount(() => {
|
||||||
registration.addEventListener('updatefound', () => {
|
if (!('serviceWorker' in navigator)) return;
|
||||||
const newWorker = registration.installing;
|
|
||||||
if (newWorker) {
|
let reloading = false;
|
||||||
newWorker.addEventListener('statechange', () => {
|
// The worker no longer calls skipWaiting() on install, so a new version
|
||||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
// waits instead of taking over a page running the previous deploy's JS.
|
||||||
if (import.meta.env.DEV) {
|
// When it does take over, reload once so page and worker agree.
|
||||||
console.log('🔄 New service worker available. Refresh to update.');
|
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||||
}
|
if (reloading) return;
|
||||||
}
|
reloading = true;
|
||||||
});
|
location.reload();
|
||||||
}
|
});
|
||||||
|
|
||||||
|
navigator.serviceWorker
|
||||||
|
.register('/service-worker.js', { type: 'module' })
|
||||||
|
.then((registration) => {
|
||||||
|
// The old code's comment claimed updates were checked periodically, but
|
||||||
|
// nothing ever called update(). Without this a long-lived tab only
|
||||||
|
// re-checks on navigation.
|
||||||
|
const hourly = setInterval(
|
||||||
|
() => {
|
||||||
|
registration.update().catch(() => {});
|
||||||
|
},
|
||||||
|
60 * 60 * 1000
|
||||||
|
);
|
||||||
|
window.addEventListener('pagehide', () => clearInterval(hourly));
|
||||||
|
|
||||||
|
registration.addEventListener('updatefound', () => {
|
||||||
|
const newWorker = registration.installing;
|
||||||
|
if (!newWorker) return;
|
||||||
|
|
||||||
|
newWorker.addEventListener('statechange', () => {
|
||||||
|
if (newWorker.state !== 'installed' || !navigator.serviceWorker.controller) return;
|
||||||
|
// A new version is ready and waiting. Previously this only logged,
|
||||||
|
// and in DEV only, so the SKIP_WAITING handler was dead code.
|
||||||
|
updateReady = () => newWorker.postMessage({ type: 'SKIP_WAITING' });
|
||||||
});
|
});
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.warn('Service Worker registration failed:', error);
|
|
||||||
});
|
});
|
||||||
}
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.warn('Service Worker registration failed:', error);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
<!--
|
||||||
|
With JS disabled the canvas never mounts and SeoContent stays clipped to
|
||||||
|
1x1px, so the page renders pure black. This reveals the text version with
|
||||||
|
no JS involved.
|
||||||
|
-->
|
||||||
|
<noscript>
|
||||||
|
<style>
|
||||||
|
.sr-only {
|
||||||
|
position: static !important;
|
||||||
|
width: auto !important;
|
||||||
|
height: auto !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
clip: auto !important;
|
||||||
|
white-space: normal !important;
|
||||||
|
max-width: 60rem;
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
color: #e8e8f0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
overflow: auto !important;
|
||||||
|
}
|
||||||
|
.sr-only a {
|
||||||
|
color: #ffd700;
|
||||||
|
}
|
||||||
|
.sr-only h1,
|
||||||
|
.sr-only h2,
|
||||||
|
.sr-only h3 {
|
||||||
|
color: #ffd700;
|
||||||
|
margin: 1.25rem 0 0.5rem;
|
||||||
|
}
|
||||||
|
.sr-only ul {
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</noscript>
|
||||||
|
|
||||||
<!-- Favicon -->
|
<!-- Favicon -->
|
||||||
<link rel="icon" href={favicon} />
|
<link rel="icon" href={favicon} />
|
||||||
|
|
||||||
@@ -150,3 +209,38 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|
||||||
|
{#if updateReady}
|
||||||
|
<div class="update-toast" role="status">
|
||||||
|
<span>A new version is available.</span>
|
||||||
|
<button onclick={() => updateReady?.()}>Refresh</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.update-toast {
|
||||||
|
position: fixed;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: 1rem;
|
||||||
|
z-index: 1100;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid var(--color-gold);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(145deg, var(--panel-from) 0%, var(--panel-to) 100%);
|
||||||
|
color: #e8e8f0;
|
||||||
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-toast button {
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--color-gold);
|
||||||
|
color: #12121a;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+11
-3
@@ -1,13 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import SkillTree from '$lib/components/SkillTree.svelte';
|
import SkillTree from '$lib/components/SkillTree.svelte';
|
||||||
import SeoContent from '$lib/components/SeoContent.svelte';
|
import SeoContent from '$lib/components/SeoContent.svelte';
|
||||||
|
|
||||||
|
// If the canvas can't come up (no WebGL, asset failure), the text version
|
||||||
|
// stops being a hidden mirror and becomes the page.
|
||||||
|
let renderFailed = $state(false);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Crawlable / screen-reader text version (visually hidden, prerendered). -->
|
<!-- Crawlable / screen-reader text version (visually hidden, prerendered). -->
|
||||||
<SeoContent />
|
<SeoContent revealed={renderFailed} />
|
||||||
|
|
||||||
<main>
|
<main class:hidden={renderFailed}>
|
||||||
<SkillTree />
|
<SkillTree onRenderFail={() => (renderFailed = true)} />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -18,4 +22,8 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
main.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
/// <reference types="@sveltejs/kit" />
|
||||||
|
|
||||||
|
// Lives in src/, not static/, so SvelteKit builds it and injects the manifest
|
||||||
|
// below. That matters: `build` and `files` are generated from the actual output,
|
||||||
|
// so the precache list can't drift, and `version` changes every deploy, so cache
|
||||||
|
// names rotate automatically instead of needing a hand-edited version string.
|
||||||
|
import { build, files, version } from '$service-worker';
|
||||||
|
|
||||||
|
const CACHE = `atlas-of-skills-${version}`;
|
||||||
|
|
||||||
|
// Content-hashed build output + everything in static/. Hashed assets are
|
||||||
|
// immutable, so once cached they never need revalidating.
|
||||||
|
const PRECACHE = [...build, ...files];
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const cache = await caches.open(CACHE);
|
||||||
|
// Individually, not cache.addAll(): addAll is atomic, so a single 404
|
||||||
|
// failed the whole install and left the site with no offline support at
|
||||||
|
// all — silently.
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
PRECACHE.map((asset) => cache.add(new Request(asset, { cache: 'reload' })))
|
||||||
|
);
|
||||||
|
const failed = results.filter((r) => r.status === 'rejected').length;
|
||||||
|
if (failed) {
|
||||||
|
console.warn(`[sw] ${failed}/${PRECACHE.length} assets failed to precache`);
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
// Drop every previous version's cache. Because the name embeds `version`,
|
||||||
|
// this now actually prunes on each deploy instead of accumulating stale
|
||||||
|
// hashed chunks forever.
|
||||||
|
const keys = await caches.keys();
|
||||||
|
await Promise.all(keys.filter((key) => key !== CACHE).map((key) => caches.delete(key)));
|
||||||
|
await self.clients.claim();
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Assets emitted by the build are content-hashed, so cache-first is safe. */
|
||||||
|
const PRECACHED = new Set(PRECACHE);
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const { request } = event;
|
||||||
|
|
||||||
|
// Never touch non-GET (cache.put throws on them) or cross-origin.
|
||||||
|
if (request.method !== 'GET') return;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.origin !== self.location.origin) return;
|
||||||
|
|
||||||
|
event.respondWith(handle(event, url));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handle(event, url) {
|
||||||
|
const { request } = event;
|
||||||
|
const cache = await caches.open(CACHE);
|
||||||
|
|
||||||
|
// Content-hashed build output: cache-first, it can never go stale.
|
||||||
|
if (PRECACHED.has(url.pathname) && build.includes(url.pathname)) {
|
||||||
|
const hit = await cache.match(url.pathname);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything else — navigations and unhashed static files (icons, headshot,
|
||||||
|
// the résumé PDF) — is network-first. The old worker served these cache-first
|
||||||
|
// with no expiry, which meant replacing the résumé at the same URL never
|
||||||
|
// reached anyone who had already visited.
|
||||||
|
try {
|
||||||
|
const response = await fetch(request);
|
||||||
|
|
||||||
|
// Only cache real successes. A 404/500 body used to get written and then
|
||||||
|
// served as the offline fallback. Opaque/redirected responses throw on
|
||||||
|
// cache.put, so they're excluded too.
|
||||||
|
if (response.status === 200 && response.type === 'basic' && !response.redirected) {
|
||||||
|
// waitUntil so the write survives worker termination.
|
||||||
|
event.waitUntil(cache.put(request, response.clone()));
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} catch {
|
||||||
|
const hit = await cache.match(request);
|
||||||
|
if (hit) return hit;
|
||||||
|
|
||||||
|
// Navigations fall back to the cached shell.
|
||||||
|
if (request.mode === 'navigate') {
|
||||||
|
const shell = await cache.match('/');
|
||||||
|
if (shell) return shell;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every other failure must still resolve to a Response. Returning
|
||||||
|
// undefined here (the previous behaviour for non-image assets) makes
|
||||||
|
// respondWith throw a TypeError and turns a soft failure into a hard one.
|
||||||
|
if (request.destination === 'image') {
|
||||||
|
return new Response(
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120">' +
|
||||||
|
'<rect width="120" height="120" fill="#05050a"/>' +
|
||||||
|
'<text x="60" y="64" fill="#ffd700" font-family="sans-serif" font-size="12" text-anchor="middle">Offline</text>' +
|
||||||
|
'</svg>',
|
||||||
|
{ status: 503, headers: { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'no-store' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('Offline', {
|
||||||
|
status: 503,
|
||||||
|
headers: { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The page posts this after the user accepts an update prompt. Deliberately no
|
||||||
|
// skipWaiting() in install: taking over a page whose JS came from the previous
|
||||||
|
// deploy is how you get version skew.
|
||||||
|
self.addEventListener('message', (event) => {
|
||||||
|
if (event.data?.type === 'SKIP_WAITING') self.skipWaiting();
|
||||||
|
});
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
// Service Worker for PWA offline support
|
|
||||||
const CACHE_NAME = 'atlas-of-skills-v2';
|
|
||||||
const RUNTIME_CACHE = 'atlas-runtime-v2';
|
|
||||||
|
|
||||||
// Assets to cache on install
|
|
||||||
const PRECACHE_ASSETS = [
|
|
||||||
'/',
|
|
||||||
'/headshot.jpg',
|
|
||||||
'/kevin-riehl-resume.pdf',
|
|
||||||
'/robots.txt',
|
|
||||||
// Add icon paths when they're created
|
|
||||||
'/icons/pwa.svg'
|
|
||||||
];
|
|
||||||
|
|
||||||
// Install event - precache critical assets
|
|
||||||
self.addEventListener('install', (event) => {
|
|
||||||
event.waitUntil(
|
|
||||||
caches.open(CACHE_NAME)
|
|
||||||
.then((cache) => cache.addAll(PRECACHE_ASSETS))
|
|
||||||
.then(() => self.skipWaiting())
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Activate event - clean up old caches
|
|
||||||
self.addEventListener('activate', (event) => {
|
|
||||||
event.waitUntil(
|
|
||||||
caches.keys()
|
|
||||||
.then((cacheNames) => {
|
|
||||||
return Promise.all(
|
|
||||||
cacheNames
|
|
||||||
.filter((name) => name !== CACHE_NAME && name !== RUNTIME_CACHE)
|
|
||||||
.map((name) => caches.delete(name))
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.then(() => self.clients.claim())
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch event - network first, fallback to cache
|
|
||||||
self.addEventListener('fetch', (event) => {
|
|
||||||
// Skip cross-origin requests
|
|
||||||
if (!event.request.url.startsWith(self.location.origin)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Network first strategy for HTML
|
|
||||||
if (event.request.mode === 'navigate') {
|
|
||||||
event.respondWith(
|
|
||||||
fetch(event.request)
|
|
||||||
.then((response) => {
|
|
||||||
// Clone response to store in cache
|
|
||||||
const responseClone = response.clone();
|
|
||||||
caches.open(RUNTIME_CACHE)
|
|
||||||
.then((cache) => cache.put(event.request, responseClone));
|
|
||||||
return response;
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// Fallback to cache if network fails
|
|
||||||
return caches.match(event.request)
|
|
||||||
.then((cachedResponse) => {
|
|
||||||
if (cachedResponse) {
|
|
||||||
return cachedResponse;
|
|
||||||
}
|
|
||||||
// Return offline page if available
|
|
||||||
return caches.match('/');
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache first strategy for assets (CSS, JS, images, icons)
|
|
||||||
event.respondWith(
|
|
||||||
caches.match(event.request)
|
|
||||||
.then((cachedResponse) => {
|
|
||||||
if (cachedResponse) {
|
|
||||||
return cachedResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not in cache, fetch from network
|
|
||||||
return fetch(event.request)
|
|
||||||
.then((response) => {
|
|
||||||
// Don't cache if not a success response
|
|
||||||
if (!response || response.status !== 200 || response.type === 'error') {
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clone response to store in cache
|
|
||||||
const responseClone = response.clone();
|
|
||||||
|
|
||||||
caches.open(RUNTIME_CACHE)
|
|
||||||
.then((cache) => {
|
|
||||||
cache.put(event.request, responseClone);
|
|
||||||
});
|
|
||||||
|
|
||||||
return response;
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// Return fallback for images if network and cache fail
|
|
||||||
if (event.request.destination === 'image') {
|
|
||||||
return new Response(
|
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><rect fill="#05050a"/><text x="50%" y="50%" text-anchor="middle" fill="#ffd700">Offline</text></svg>',
|
|
||||||
{ headers: { 'Content-Type': 'image/svg+xml' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Message event - allow clients to skip waiting
|
|
||||||
self.addEventListener('message', (event) => {
|
|
||||||
if (event.data && event.data.type === 'SKIP_WAITING') {
|
|
||||||
self.skipWaiting();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Reference in new issue
Block a user