feat: audit phase 6 (T1) — persistent HUD, skip intro, faster entrance
The audit's headline UX finding, measured on production: cold load showed a black screen for ~5s, then a wordless constellation. At 9 seconds the number of words on screen was zero — no name, no title, no hint that anything was clickable. A recruiter could not learn one fact without clicking. - New Hud.svelte: name, market-legible title and a one-line hook, always on screen, sourced from the origin node. Readable at ~1s instead of never. Added `tagline` to OriginNode so it lives with the rest of the content. - New CoachHint.svelte: first-run "drag / scroll / click" hint. The icons gave no affordance that the tree was interactive at all. - Skip intro control. runEntranceAnimations now returns a handle whose skip() enumerates the entrance's own nested GSAP context and calls progress(1) on each finite tween — which fires their onComplete, so node interactivity and the ambient pulses come up exactly as if the intro had played through. Verified: a node is clickable immediately after skipping. - Returning visitors land on the settled tree (localStorage), reusing the same code path as reduced motion. The flag is set once the intro has actually been seen, not at mount — otherwise someone who bounced after a second would be marked as having seen it and never get it. - Entrance timings cut: title 2.5s -> 1.6s, zoom 4.5s -> 2s, stagger 0.08 -> 0.045 (that last one was 4.5s of cascade on its own across 56 nodes). Two bugs found while building this: - animation.ts hardcoded its timings as literals instead of reading config.ts TIMINGS, so editing config silently did nothing. Now derived. - Skip and the coach hint were timed off a magic 3200ms, so Skip vanished while the intro was still running and "Click a node" appeared before the nodes did. Both now derive from the entrance's real computed length, which the handle exposes as durationMs. - Skip sat bottom-right underneath the service-worker update toast (same z token, later in DOM order). Moved to bottom-centre; the toast got its own layer token. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
c4f1fd9f84
commit
d8297cf903
8 files changed
+500
-204
No files matched your search
@@ -59,6 +59,7 @@
|
|||||||
--z-panel-toggle: 950;
|
--z-panel-toggle: 950;
|
||||||
--z-modal: 1000;
|
--z-modal: 1000;
|
||||||
--z-toast: 1100;
|
--z-toast: 1100;
|
||||||
|
--z-update-toast: 1150;
|
||||||
--z-tree-nav: 1200;
|
--z-tree-nav: 1200;
|
||||||
--z-skip-link: 2000;
|
--z-skip-link: 2000;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// First-run interaction hint. The tree gives no affordance that it can be
|
||||||
|
// dragged, zoomed or clicked — the icons look decorative. Shown once, then
|
||||||
|
// remembered, so it never nags a returning visitor.
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
import { prefersReducedMotion } from '$lib/config';
|
||||||
|
|
||||||
|
const { visible = false, onDismiss }: { visible?: boolean; onDismiss?: () => void } = $props();
|
||||||
|
|
||||||
|
const duration = prefersReducedMotion() ? 0 : 400;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if visible}
|
||||||
|
<div class="coach" transition:fade={{ duration }} role="status">
|
||||||
|
<span>Drag to explore</span>
|
||||||
|
<span class="sep" aria-hidden="true">·</span>
|
||||||
|
<span>Scroll to zoom</span>
|
||||||
|
<span class="sep" aria-hidden="true">·</span>
|
||||||
|
<span>Click a node for details</span>
|
||||||
|
<button onclick={() => onDismiss?.()} aria-label="Dismiss hint">✕</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.coach {
|
||||||
|
position: fixed;
|
||||||
|
bottom: var(--space-5);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: var(--z-toast);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border: 1px solid rgba(255, 215, 0, 0.35);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: rgba(10, 10, 20, 0.88);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sep {
|
||||||
|
color: var(--color-gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.coach button {
|
||||||
|
margin-left: var(--space-1);
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-subtle);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coach button:hover {
|
||||||
|
color: var(--color-gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.coach {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: var(--space-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Always-visible identity.
|
||||||
|
//
|
||||||
|
// The measured problem this solves: on a cold load the site showed a black
|
||||||
|
// screen for ~5s, then a wordless constellation of icons. At 9 seconds the
|
||||||
|
// number of words on screen was zero — no name, no title, no indication of
|
||||||
|
// what any of it was. A recruiter could not learn a single fact without
|
||||||
|
// clicking. This puts the 5-second pitch on screen immediately and keeps it
|
||||||
|
// there, sourced from the same origin node as everything else.
|
||||||
|
import { treeNodes, type OriginNode } from '$lib/data';
|
||||||
|
|
||||||
|
const {
|
||||||
|
showSkip = false,
|
||||||
|
onSkip
|
||||||
|
}: {
|
||||||
|
/** True while the intro is still playing. */
|
||||||
|
showSkip?: boolean;
|
||||||
|
onSkip?: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const origin = treeNodes.find((n): n is OriginNode => n.category === 'origin');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if origin}
|
||||||
|
<div class="hud">
|
||||||
|
<p class="hud-name">{origin.fullName}</p>
|
||||||
|
<p class="hud-title">{origin.jobTitle}</p>
|
||||||
|
{#if origin.tagline}
|
||||||
|
<p class="hud-tagline">{origin.tagline}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showSkip}
|
||||||
|
<button class="skip-intro" onclick={() => onSkip?.()}>Skip intro</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hud {
|
||||||
|
position: fixed;
|
||||||
|
top: var(--space-5);
|
||||||
|
left: calc(var(--space-5) + 68px); /* clears the stats toggle */
|
||||||
|
z-index: var(--z-welcome);
|
||||||
|
max-width: min(30rem, calc(100vw - 8rem));
|
||||||
|
pointer-events: none;
|
||||||
|
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud-name {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
line-height: 1.1;
|
||||||
|
color: var(--color-gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud-title {
|
||||||
|
margin: 0.15rem 0 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud-tagline {
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-intro {
|
||||||
|
position: fixed;
|
||||||
|
bottom: var(--space-5);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: var(--z-toast);
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border: 1px solid var(--color-gold);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: rgba(10, 10, 20, 0.85);
|
||||||
|
color: var(--color-gold);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--duration-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-intro:hover {
|
||||||
|
background: rgba(255, 215, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.hud {
|
||||||
|
top: auto;
|
||||||
|
bottom: var(--space-5);
|
||||||
|
left: var(--space-4);
|
||||||
|
right: var(--space-4);
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud-name {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud-tagline {
|
||||||
|
display: none; /* too little room to be worth the noise */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -10,7 +10,9 @@
|
|||||||
import NodeModal from './NodeModal.svelte';
|
import NodeModal from './NodeModal.svelte';
|
||||||
import StatsPanel from './StatsPanel.svelte';
|
import StatsPanel from './StatsPanel.svelte';
|
||||||
import TreeNavigator from './TreeNavigator.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 {
|
import {
|
||||||
BRANCH_IDS,
|
BRANCH_IDS,
|
||||||
calculatePlayerLevel,
|
calculatePlayerLevel,
|
||||||
@@ -34,6 +36,35 @@
|
|||||||
let selectedNode = $state<TreeNode | null>(null);
|
let selectedNode = $state<TreeNode | null>(null);
|
||||||
let modalVisible = $state(false);
|
let modalVisible = $state(false);
|
||||||
let showWelcomeTitle = $state(true);
|
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
|
// Tooltip state
|
||||||
let hoveredNode = $state<TreeNode | null>(null);
|
let hoveredNode = $state<TreeNode | null>(null);
|
||||||
@@ -275,14 +306,36 @@
|
|||||||
if (resizeFrame !== -1) cancelAnimationFrame(resizeFrame);
|
if (resizeFrame !== -1) cancelAnimationFrame(resizeFrame);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sequenced entrance animation
|
// Sequenced entrance animation. Returning visitors skip straight to the
|
||||||
runEntranceAnimations(controller, {
|
// settled tree; first-timers get the intro plus a Skip control.
|
||||||
|
const returning = hasVisitedBefore();
|
||||||
|
introPlaying = !returning && !prefersReducedMotion();
|
||||||
|
|
||||||
|
const entrance = runEntranceAnimations(controller, {
|
||||||
isMobile,
|
isMobile,
|
||||||
life,
|
life,
|
||||||
|
skipIntro: returning,
|
||||||
onTitleDone: () => {
|
onTitleDone: () => {
|
||||||
showWelcomeTitle = false;
|
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) {
|
} catch (err) {
|
||||||
// No WebGL, or a texture failed to load. Don't strand the user on a
|
// No WebGL, or a texture failed to load. Don't strand the user on a
|
||||||
// spinner that never resolves — reveal the text version instead.
|
// spinner that never resolves — reveal the text version instead.
|
||||||
@@ -351,6 +404,16 @@
|
|||||||
<LoadingIndicator />
|
<LoadingIndicator />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Persistent identity: name, title and the one-line hook, always on screen -->
|
||||||
|
<Hud showSkip={introPlaying} onSkip={skipIntro} />
|
||||||
|
|
||||||
|
<CoachHint
|
||||||
|
visible={showCoachHint}
|
||||||
|
onDismiss={() => {
|
||||||
|
showCoachHint = false;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Welcome Title Card -->
|
<!-- Welcome Title Card -->
|
||||||
{#if showWelcomeTitle}
|
{#if showWelcomeTitle}
|
||||||
<WelcomeTitle />
|
<WelcomeTitle />
|
||||||
|
|||||||
+6
-3
@@ -51,9 +51,12 @@ export const PARTICLE_COUNTS = {
|
|||||||
|
|
||||||
/** Entrance-animation timings (seconds), split by device. */
|
/** Entrance-animation timings (seconds), split by device. */
|
||||||
export const TIMINGS = {
|
export const TIMINGS = {
|
||||||
titleDuration: { desktop: 2.5, mobile: 1.5 },
|
titleDuration: { desktop: 1.6, mobile: 1.1 },
|
||||||
zoomDuration: { desktop: 4.5, mobile: 2.5 },
|
// Was 4.5s desktop. Nothing is readable until this finishes, and it is the
|
||||||
nodeStagger: { desktop: 0.08, mobile: 0.05 }
|
// 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;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export interface OriginNode extends BaseNode {
|
|||||||
category: 'origin';
|
category: 'origin';
|
||||||
fullName?: string;
|
fullName?: string;
|
||||||
jobTitle?: string;
|
jobTitle?: string;
|
||||||
|
/** One-line hook for the persistent HUD — the 5-second pitch. */
|
||||||
|
tagline?: string;
|
||||||
aboutMe?: string;
|
aboutMe?: string;
|
||||||
location?: string;
|
location?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
@@ -89,6 +91,7 @@ export const treeNodes: TreeNode[] = [
|
|||||||
icon: '',
|
icon: '',
|
||||||
fullName: 'Kevin Riehl',
|
fullName: 'Kevin Riehl',
|
||||||
jobTitle: 'Lead Software Engineer',
|
jobTitle: 'Lead Software Engineer',
|
||||||
|
tagline: 'WebRTC telehealth · MediaPipe accessibility · air-gapped CI',
|
||||||
aboutMe:
|
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.",
|
"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',
|
location: 'Kent, WA',
|
||||||
|
|||||||
+244
-197
@@ -4,25 +4,37 @@
|
|||||||
import { Container } from 'pixi.js';
|
import { Container } from 'pixi.js';
|
||||||
import { gsap } from 'gsap';
|
import { gsap } from 'gsap';
|
||||||
import { getDistanceFromOrigin } from '$lib/utils/tree';
|
import { getDistanceFromOrigin } from '$lib/utils/tree';
|
||||||
import { prefersReducedMotion } from '$lib/config';
|
import { prefersReducedMotion, TIMINGS, byDevice } from '$lib/config';
|
||||||
import { updateParallax } from './interaction';
|
import { updateParallax } from './interaction';
|
||||||
import type { SkillTreeController } from './controller';
|
import type { SkillTreeController } from './controller';
|
||||||
import type { Lifecycle } from './lifecycle';
|
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 {
|
interface EntranceOpts {
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
life: Lifecycle;
|
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). */
|
/** Called when the welcome title should be removed (matches its CSS fade). */
|
||||||
onTitleDone: () => void;
|
onTitleDone: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runEntranceAnimations(ctrl: SkillTreeController, opts: EntranceOpts) {
|
export function runEntranceAnimations(
|
||||||
|
ctrl: SkillTreeController,
|
||||||
|
opts: EntranceOpts
|
||||||
|
): EntranceHandle {
|
||||||
const { isMobile } = opts;
|
const { isMobile } = opts;
|
||||||
const { treeContainer, nodeObjects, lineObjects, endScale } = ctrl;
|
const { treeContainer, nodeObjects, lineObjects, endScale } = ctrl;
|
||||||
|
|
||||||
// Reduced motion: show the finished tree immediately. No zoom, no stagger,
|
/** Put the tree straight into its settled, interactive state. */
|
||||||
// and critically no `repeat: -1` pulses — a static scene, not a faster one.
|
function showFinishedTree() {
|
||||||
if (prefersReducedMotion()) {
|
|
||||||
treeContainer.scale.set(endScale);
|
treeContainer.scale.set(endScale);
|
||||||
|
|
||||||
for (const obj of nodeObjects) {
|
for (const obj of nodeObjects) {
|
||||||
@@ -42,216 +54,251 @@ export function runEntranceAnimations(ctrl: SkillTreeController, opts: EntranceO
|
|||||||
if (toggle) toggle.style.opacity = '1';
|
if (toggle) toggle.style.opacity = '1';
|
||||||
|
|
||||||
opts.onTitleDone();
|
opts.onTitleDone();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Responsive animation timing
|
// Reduced motion, or a returning visitor who has already seen the intro:
|
||||||
const titleDuration = isMobile ? 1.5 : 2.5;
|
// no zoom, no stagger, and no `repeat: -1` pulses.
|
||||||
const zoomDuration = isMobile ? 2.5 : 4.5;
|
if (prefersReducedMotion() || opts.skipIntro) {
|
||||||
const nodeStagger = isMobile ? 0.05 : 0.08;
|
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,
|
// The entrance allocates ~340 finite tweens plus ~170 `repeat: -1` pulses,
|
||||||
// some created inside an onComplete. GSAP restores an animation's context
|
// some created inside an onComplete. GSAP restores an animation's context
|
||||||
// while running its callbacks, so wrapping the whole sequence here makes
|
// while running its callbacks, so wrapping the whole sequence here makes
|
||||||
// even those late-created pulses killable via the lifecycle.
|
// even those late-created pulses killable via the lifecycle.
|
||||||
opts.life.animate(() => {
|
opts.life.animate(() => {
|
||||||
// Hide all lines and plasma beams initially
|
entranceCtx = gsap.context(() => {
|
||||||
lineObjects.forEach((line) => {
|
// Hide all lines and plasma beams initially
|
||||||
line.graphics.alpha = 0;
|
lineObjects.forEach((line) => {
|
||||||
line.glowGraphics.alpha = 0;
|
line.graphics.alpha = 0;
|
||||||
line.plasmaBeam.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'
|
|
||||||
});
|
});
|
||||||
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)
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -226,7 +226,7 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
right: 1rem;
|
right: 1rem;
|
||||||
bottom: 1rem;
|
bottom: 1rem;
|
||||||
z-index: var(--z-toast);
|
z-index: var(--z-update-toast);
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in new issue
Block a user