diff --git a/src/app.css b/src/app.css index d8a4bcf..907d867 100644 --- a/src/app.css +++ b/src/app.css @@ -59,6 +59,7 @@ --z-panel-toggle: 950; --z-modal: 1000; --z-toast: 1100; + --z-update-toast: 1150; --z-tree-nav: 1200; --z-skip-link: 2000; diff --git a/src/lib/components/CoachHint.svelte b/src/lib/components/CoachHint.svelte new file mode 100644 index 0000000..d5bf79c --- /dev/null +++ b/src/lib/components/CoachHint.svelte @@ -0,0 +1,68 @@ + + +{#if visible} +
+ Drag to explore + + Scroll to zoom + + Click a node for details + +
+{/if} + + diff --git a/src/lib/components/Hud.svelte b/src/lib/components/Hud.svelte new file mode 100644 index 0000000..1a52542 --- /dev/null +++ b/src/lib/components/Hud.svelte @@ -0,0 +1,111 @@ + + +{#if origin} +
+

{origin.fullName}

+

{origin.jobTitle}

+ {#if origin.tagline} +

{origin.tagline}

+ {/if} +
+{/if} + +{#if showSkip} + +{/if} + + diff --git a/src/lib/components/SkillTree.svelte b/src/lib/components/SkillTree.svelte index 9fc6dc9..d98193f 100644 --- a/src/lib/components/SkillTree.svelte +++ b/src/lib/components/SkillTree.svelte @@ -10,7 +10,9 @@ import NodeModal from './NodeModal.svelte'; import StatsPanel from './StatsPanel.svelte'; import TreeNavigator from './TreeNavigator.svelte'; - import { MOBILE_BREAKPOINT } from '$lib/config'; + import Hud from './Hud.svelte'; + import CoachHint from './CoachHint.svelte'; + import { MOBILE_BREAKPOINT, prefersReducedMotion } from '$lib/config'; import { BRANCH_IDS, calculatePlayerLevel, @@ -34,6 +36,35 @@ let selectedNode = $state(null); let modalVisible = $state(false); let showWelcomeTitle = $state(true); + // Intro state. A returning visitor should land on the settled tree rather + // than sit through the entrance again. + const VISITED_KEY = 'atlas:visited'; + let introPlaying = $state(false); + let showCoachHint = $state(false); + let skipEntrance: (() => void) | null = null; + + function hasVisitedBefore() { + try { + return localStorage.getItem(VISITED_KEY) === '1'; + } catch { + return false; // private mode / storage disabled + } + } + + function markVisited() { + try { + localStorage.setItem(VISITED_KEY, '1'); + } catch { + /* not important enough to surface */ + } + } + + function skipIntro() { + skipEntrance?.(); + introPlaying = false; + showWelcomeTitle = false; + markVisited(); + } // Tooltip state let hoveredNode = $state(null); @@ -275,14 +306,36 @@ if (resizeFrame !== -1) cancelAnimationFrame(resizeFrame); }); - // Sequenced entrance animation - runEntranceAnimations(controller, { + // Sequenced entrance animation. Returning visitors skip straight to the + // settled tree; first-timers get the intro plus a Skip control. + const returning = hasVisitedBefore(); + introPlaying = !returning && !prefersReducedMotion(); + + const entrance = runEntranceAnimations(controller, { isMobile, life, + skipIntro: returning, onTitleDone: () => { showWelcomeTitle = false; } }); + skipEntrance = entrance.skip; + + // Show the interaction hint once the tree is actually there to interact + // with — timed off the real entrance length, not a guess, so "Click a + // node" never appears before the nodes do and Skip never disappears + // while the intro is still running. + if (!returning) { + const hintTimer = setTimeout(() => { + introPlaying = false; + showCoachHint = true; + // Only now has the visitor actually seen the intro. Marking at + // mount instead would flag someone who bounced after a second as + // having seen it, and they'd never get it. + markVisited(); + }, entrance.durationMs + 300); + life.add(() => clearTimeout(hintTimer)); + } } 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. @@ -351,6 +404,16 @@ {/if} + + + + { + showCoachHint = false; + }} +/> + {#if showWelcomeTitle} diff --git a/src/lib/config.ts b/src/lib/config.ts index a7158da..bb79799 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -51,9 +51,12 @@ export const PARTICLE_COUNTS = { /** Entrance-animation timings (seconds), split by device. */ export const TIMINGS = { - titleDuration: { desktop: 2.5, mobile: 1.5 }, - zoomDuration: { desktop: 4.5, mobile: 2.5 }, - nodeStagger: { desktop: 0.08, mobile: 0.05 } + titleDuration: { desktop: 1.6, mobile: 1.1 }, + // Was 4.5s desktop. Nothing is readable until this finishes, and it is the + // dominant term in time-to-first-information. + zoomDuration: { desktop: 2, mobile: 1.5 }, + // 0.08 x 56 nodes was 4.5s of cascade on its own, dominating the entrance. + nodeStagger: { desktop: 0.045, mobile: 0.03 } } as const; /** diff --git a/src/lib/data.ts b/src/lib/data.ts index 9d50553..55816e5 100644 --- a/src/lib/data.ts +++ b/src/lib/data.ts @@ -15,6 +15,8 @@ export interface OriginNode extends BaseNode { category: 'origin'; fullName?: string; jobTitle?: string; + /** One-line hook for the persistent HUD — the 5-second pitch. */ + tagline?: string; aboutMe?: string; location?: string; email?: string; @@ -89,6 +91,7 @@ export const treeNodes: TreeNode[] = [ icon: '', fullName: 'Kevin Riehl', jobTitle: 'Lead Software Engineer', + tagline: 'WebRTC telehealth · MediaPipe accessibility · air-gapped CI', aboutMe: "Software engineer with 5+ years building full-stack applications for Defense Health Agency programs. I've architected a WebRTC telehealth platform, shipped real-time gesture recognition that made chat usable for speech-impaired patients, and built the deployment tooling that unblocked a team after an air-gapped infrastructure migration. React, Node.js, NestJS, TypeScript, SQL.", location: 'Kent, WA', diff --git a/src/lib/pixi/animation.ts b/src/lib/pixi/animation.ts index ca01f14..a1c7506 100644 --- a/src/lib/pixi/animation.ts +++ b/src/lib/pixi/animation.ts @@ -4,25 +4,37 @@ import { Container } from 'pixi.js'; import { gsap } from 'gsap'; import { getDistanceFromOrigin } from '$lib/utils/tree'; -import { prefersReducedMotion } from '$lib/config'; +import { prefersReducedMotion, TIMINGS, byDevice } from '$lib/config'; import { updateParallax } from './interaction'; import type { SkillTreeController } from './controller'; import type { Lifecycle } from './lifecycle'; +/** Handle returned by runEntranceAnimations. */ +export interface EntranceHandle { + /** Fast-forward the intro to its finished state. */ + skip: () => void; + /** Total intro length in ms, so the UI can time Skip and the hint off it. */ + durationMs: number; +} + interface EntranceOpts { isMobile: boolean; life: Lifecycle; + /** Returning visitor — land on the settled tree instead of replaying it. */ + skipIntro?: boolean; /** Called when the welcome title should be removed (matches its CSS fade). */ onTitleDone: () => void; } -export function runEntranceAnimations(ctrl: SkillTreeController, opts: EntranceOpts) { +export function runEntranceAnimations( + ctrl: SkillTreeController, + opts: EntranceOpts +): EntranceHandle { const { isMobile } = opts; const { treeContainer, nodeObjects, lineObjects, endScale } = ctrl; - // Reduced motion: show the finished tree immediately. No zoom, no stagger, - // and critically no `repeat: -1` pulses — a static scene, not a faster one. - if (prefersReducedMotion()) { + /** Put the tree straight into its settled, interactive state. */ + function showFinishedTree() { treeContainer.scale.set(endScale); for (const obj of nodeObjects) { @@ -42,216 +54,251 @@ export function runEntranceAnimations(ctrl: SkillTreeController, opts: EntranceO if (toggle) toggle.style.opacity = '1'; opts.onTitleDone(); - return; } - // Responsive animation timing - const titleDuration = isMobile ? 1.5 : 2.5; - const zoomDuration = isMobile ? 2.5 : 4.5; - const nodeStagger = isMobile ? 0.05 : 0.08; + // Reduced motion, or a returning visitor who has already seen the intro: + // no zoom, no stagger, and no `repeat: -1` pulses. + if (prefersReducedMotion() || opts.skipIntro) { + showFinishedTree(); + return { skip: () => {}, durationMs: 0 }; + } + + // Timings come from config so there is a single place to tune them. They used + // to be duplicated here as literals, so editing config.ts did nothing. + const titleDuration = byDevice(TIMINGS.titleDuration, isMobile); + const zoomDuration = byDevice(TIMINGS.zoomDuration, isMobile); + const nodeStagger = byDevice(TIMINGS.nodeStagger, isMobile); + + // The entrance gets its own nested GSAP context so Skip can enumerate exactly + // its tweens. Nested contexts register with the parent, so the lifecycle + // still tears everything down in one call. + let entranceCtx: gsap.Context | null = null; // The entrance allocates ~340 finite tweens plus ~170 `repeat: -1` pulses, // some created inside an onComplete. GSAP restores an animation's context // while running its callbacks, so wrapping the whole sequence here makes // even those late-created pulses killable via the lifecycle. opts.life.animate(() => { - // Hide all lines and plasma beams initially - lineObjects.forEach((line) => { - line.graphics.alpha = 0; - line.glowGraphics.alpha = 0; - line.plasmaBeam.alpha = 0; - }); - - // Hide all nodes initially - nodeObjects.forEach((obj) => { - obj.graphics.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' + entranceCtx = gsap.context(() => { + // Hide all lines and plasma beams initially + lineObjects.forEach((line) => { + line.graphics.alpha = 0; + line.glowGraphics.alpha = 0; + line.plasmaBeam.alpha = 0; }); - 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'; + + // Hide all nodes initially + nodeObjects.forEach((obj) => { + obj.graphics.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, + 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 - const zoomDelay = isMobile ? titleDuration - 0.3 : titleDuration + 0.3; + // STEP 3: Animate in the rest of the nodes (excluding origin) + const otherNodes = sortedNodes.filter((obj) => obj.node.id !== 'origin'); + const nodeStartDelay = isMobile ? titleDuration - 0.2 : titleDuration + 0.5; - gsap.to(treeContainer.scale, { - x: endScale, - y: endScale, - duration: zoomDuration, - delay: zoomDelay, - ease: 'power1.out', - onUpdate: () => updateParallax(ctrl) - }); + otherNodes.forEach((obj, index) => { + const delay = nodeStartDelay + index * nodeStagger; - // STEP 3: Animate in the rest of the nodes (excluding origin) - const otherNodes = sortedNodes.filter((obj) => obj.node.id !== 'origin'); - const nodeStartDelay = isMobile ? titleDuration - 0.2 : titleDuration + 0.5; - - otherNodes.forEach((obj, index) => { - const delay = nodeStartDelay + index * nodeStagger; - - gsap.to(obj.graphics, { - alpha: 1, - duration: 0.5, - delay, - ease: 'power2.out' - }); - gsap.to(obj.graphics.scale, { - x: 1, - y: 1, - duration: 0.5, - delay, - ease: 'back.out(1.7)', - onComplete: () => { - if (obj.graphics instanceof Container) { - obj.graphics.eventMode = 'static'; + gsap.to(obj.graphics, { + alpha: 1, + duration: 0.5, + delay, + ease: 'power2.out' + }); + gsap.to(obj.graphics.scale, { + x: 1, + 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, { - alpha: 1, - duration: 0.5, - delay, - ease: 'power2.out' + }); + gsap.to(obj.glowGraphics, { + alpha: 1, + duration: 0.5, + delay, + 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 - 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 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) + 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) - 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' - }); - } }); + + // When the last node/line finishes appearing. Derived rather than guessed, so + // the Skip control and the coach hint stay in step with the real animation. + const lastIndex = Math.max(nodeObjects.length, lineObjects.length) - 1; + const durationMs = Math.round( + (byDevice(TIMINGS.titleDuration, isMobile) + 0.5 + lastIndex * nodeStagger + 0.5) * 1000 + ); + + return { + durationMs, + skip() { + // progress(1) completes each finite tween AND fires its onComplete — + // which is what re-enables node interactivity and starts the ambient + // pulses. So skipping lands in the same state as watching it through. + // Infinite pulses are left alone; fast-forwarding them is meaningless. + for (const tween of entranceCtx?.getTweens() ?? []) { + if (tween.repeat() === -1) continue; + tween.progress(1); + } + } + }; } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index cd554ec..5f25dcd 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -226,7 +226,7 @@ position: fixed; right: 1rem; bottom: 1rem; - z-index: var(--z-toast); + z-index: var(--z-update-toast); display: flex; gap: 0.75rem; align-items: center;