diff --git a/src/lib/components/Hud.svelte b/src/lib/components/Hud.svelte index 1a52542..032e2a7 100644 --- a/src/lib/components/Hud.svelte +++ b/src/lib/components/Hud.svelte @@ -11,11 +11,13 @@ const { showSkip = false, - onSkip + onSkip, + onStartJourney }: { /** True while the intro is still playing. */ showSkip?: boolean; onSkip?: () => void; + onStartJourney?: () => void; } = $props(); const origin = treeNodes.find((n): n is OriginNode => n.category === 'origin'); @@ -28,6 +30,13 @@ {#if origin.tagline}

{origin.tagline}

{/if} + + {#if onStartJourney && !showSkip} + + + {/if} {/if} @@ -36,6 +45,24 @@ {/if} diff --git a/src/lib/components/SkillTree.svelte b/src/lib/components/SkillTree.svelte index e2c2121..a0d6d59 100644 --- a/src/lib/components/SkillTree.svelte +++ b/src/lib/components/SkillTree.svelte @@ -13,6 +13,8 @@ import Hud from './Hud.svelte'; import CoachHint from './CoachHint.svelte'; import SearchPalette from './SearchPalette.svelte'; + import JourneyMode from './JourneyMode.svelte'; + import { getJourneyStops, findStopIndex } from '$lib/utils/journey'; import { MOBILE_BREAKPOINT, prefersReducedMotion } from '$lib/config'; import { BRANCH_IDS, @@ -49,6 +51,17 @@ let introPlaying = $state(false); let showCoachHint = $state(false); let searchOpen = $state(false); + // Guided career narrative. Deep-linkable via ?tour=peraton. + let journeyActive = $state(false); + let journeyStartIndex = $state(0); + + function startJourney(index = 0) { + journeyStartIndex = index; + journeyActive = true; + searchOpen = false; + showCoachHint = false; + closeModal(); + } let skipEntrance: (() => void) | null = null; function hasVisitedBefore() { @@ -258,7 +271,9 @@ isModalVisible: () => modalVisible, isStatsVisible: () => statsPanelVisible, onOpenSearch: () => (searchOpen = true), - isSearchOpen: () => searchOpen + isSearchOpen: () => searchOpen, + onStartJourney: () => startJourney(0), + isJourneyActive: () => journeyActive }, life ); @@ -347,6 +362,15 @@ // mount instead would flag someone who bounced after a second as // having seen it, and they'd never get it. markVisited(); + + // ?tour=peraton deep-links straight into that stop of the journey. + const tourParam = new URLSearchParams(window.location.search).get('tour'); + if (tourParam !== null) { + const stops = getJourneyStops(); + const found = findStopIndex(stops, tourParam); + // A bare ?tour starts from the beginning. + startJourney(found === -1 ? 0 : found); + } }, entrance.durationMs + 300); life.add(() => clearTimeout(hintTimer)); } @@ -419,7 +443,21 @@ {/if} - + startJourney(0)} /> + + (journeyActive = false)} + onStop={(stop) => { + if (controllerRef) { + panToNode(controllerRef, stop.node.x, stop.node.y, life, { + scale: stop.scale, + duration: 1.1 + }); + } + }} +/> + { showCoachHint = false; }} diff --git a/src/lib/pixi/interaction.ts b/src/lib/pixi/interaction.ts index 29ee8f0..1692af8 100644 --- a/src/lib/pixi/interaction.ts +++ b/src/lib/pixi/interaction.ts @@ -325,24 +325,50 @@ export function attachPanZoom( * Centre the view on a node in tree-space. Used by the accessible tree * navigator so keyboard focus and the canvas stay in sync. */ -export function panToNode(ctrl: SkillTreeController, x: number, y: number, life: Lifecycle) { +export function panToNode( + ctrl: SkillTreeController, + x: number, + y: number, + life: Lifecycle, + options: { scale?: number; duration?: number } = {} +) { const { app, treeContainer } = ctrl; - const scale = treeContainer.scale.x; - const targetX = app.screen.width / 2 - x * scale; - const targetY = app.screen.height / 2 - y * scale; + const targetScale = options.scale ?? treeContainer.scale.x; + const targetX = app.screen.width / 2 - x * targetScale; + const targetY = app.screen.height / 2 - y * targetScale; + + // Reduced motion jumps instead of flying — the objection is to the camera + // movement, not to being taken somewhere. + const duration = prefersReducedMotion() ? 0 : (options.duration ?? 0.4); life.animate(() => { + const sync = () => { + updateParallax(ctrl); + updateLabels(ctrl); + }; + gsap.to(treeContainer, { x: targetX, y: targetY, - duration: prefersReducedMotion() ? 0 : 0.4, - ease: 'power2.out', + duration, + ease: 'power2.inOut', overwrite: 'auto', - onUpdate: () => { - updateParallax(ctrl); - updateLabels(ctrl); + onUpdate: sync, + onComplete: () => { + // Re-baseline parallax so later panning stays consistent. + ctrl.initialTreeX = treeContainer.x; + ctrl.initialTreeY = treeContainer.y; } }); + + gsap.to(treeContainer.scale, { + x: targetScale, + y: targetScale, + duration, + ease: 'power2.inOut', + overwrite: 'auto', + onUpdate: sync + }); }); } @@ -351,6 +377,9 @@ interface KeyboardOpts { /** Cmd/Ctrl-K — open the search palette. */ onOpenSearch: () => void; isSearchOpen: () => boolean; + /** 'J' — start the guided career journey. */ + onStartJourney: () => void; + isJourneyActive: () => boolean; onCloseModal: () => void; isModalVisible: () => boolean; isStatsVisible: () => boolean; @@ -384,8 +413,8 @@ export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts, li return; } - // While the palette is open it owns the keyboard entirely. - if (opts.isSearchOpen()) return; + // While the palette or the guided journey is open, it owns the keyboard. + if (opts.isSearchOpen() || opts.isJourneyActive()) return; if (e.ctrlKey || e.metaKey || e.altKey) return; @@ -472,6 +501,13 @@ export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts, li updateLabels(ctrl); } + // J starts the guided career journey. + if (e.key === 'j' || e.key === 'J') { + e.preventDefault(); + opts.onStartJourney(); + return; + } + // L forces labels on/off regardless of zoom. if (e.key === 'l' || e.key === 'L') { e.preventDefault(); diff --git a/src/lib/utils/journey.test.ts b/src/lib/utils/journey.test.ts new file mode 100644 index 0000000..16b7eac --- /dev/null +++ b/src/lib/utils/journey.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest'; +import { getJourneyStops, findStopIndex, slugify } from './journey'; + +const stops = getJourneyStops(); + +describe('slugify', () => { + it('normalises company names', () => { + expect(slugify('Bluewater Federal Solutions')).toBe('bluewater-federal-solutions'); + expect(slugify('Insight Global')).toBe('insight-global'); + }); + + it('trims leading and trailing separators', () => { + expect(slugify(' Peraton! ')).toBe('peraton'); + }); +}); + +describe('getJourneyStops', () => { + it('opens on the origin', () => { + expect(stops[0].slug).toBe('start'); + expect(stops[0].node.category).toBe('origin'); + }); + + it('includes every real job and no container nodes', () => { + const jobs = stops.slice(1); + expect(jobs.length).toBeGreaterThanOrEqual(5); + expect(jobs.every((s) => s.node.category === 'experience')).toBe(true); + expect(jobs.every((s) => !!s.title)).toBe(true); + }); + + it('runs oldest-first so it reads as progression', () => { + const years = stops.slice(1).map((s) => Number(s.kicker.match(/\d{4}/)?.[0])); + const sorted = [...years].sort((a, b) => a - b); + expect(years).toEqual(sorted); + }); + + it('ends on the current role', () => { + expect(stops[stops.length - 1].kicker).toMatch(/Present$/); + }); + + it('caps each stop at two points so the card stays readable', () => { + expect(stops.every((s) => s.points.length <= 2)).toBe(true); + }); + + it('gives every stop a unique slug', () => { + const slugs = stops.map((s) => s.slug); + expect(new Set(slugs).size).toBe(slugs.length); + }); +}); + +describe('findStopIndex', () => { + it('finds a stop by slug', () => { + expect(findStopIndex(stops, 'peraton')).toBeGreaterThan(0); + expect(stops[findStopIndex(stops, 'peraton')].title).toBe('Peraton'); + }); + + it('is forgiving about case and punctuation', () => { + expect(findStopIndex(stops, 'Peraton')).toBe(findStopIndex(stops, 'peraton')); + }); + + it('returns -1 for an unknown or missing slug', () => { + expect(findStopIndex(stops, 'nope')).toBe(-1); + expect(findStopIndex(stops, null)).toBe(-1); + }); +}); diff --git a/src/lib/utils/journey.ts b/src/lib/utils/journey.ts new file mode 100644 index 0000000..1bc45ae --- /dev/null +++ b/src/lib/utils/journey.ts @@ -0,0 +1,73 @@ +// ========================================================================== +// JOURNEY — the guided career narrative. Pure; derived from data.ts so the +// tour can never drift from the tree. +// ========================================================================== + +import { treeNodes, type TreeNode, type ExperienceNode, type OriginNode } from '$lib/data'; + +export interface JourneyStop { + /** Stable slug for deep links (?tour=peraton). */ + slug: string; + node: TreeNode; + kicker: string; + title: string; + subtitle?: string; + points: string[]; + /** Zoom to settle at for this stop. */ + scale: number; +} + +/** "Bluewater Federal Solutions" -> "bluewater-federal-solutions" */ +export function slugify(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +/** + * The tour: an intro on the origin, then every real job oldest-first, so the + * story reads as progression rather than a résumé's reverse-chronology. + */ +export function getJourneyStops(nodes: TreeNode[] = treeNodes): JourneyStop[] { + const stops: JourneyStop[] = []; + + const origin = nodes.find((n): n is OriginNode => n.category === 'origin'); + if (origin) { + stops.push({ + slug: 'start', + node: origin, + kicker: 'The short version', + title: origin.fullName ?? origin.label, + subtitle: origin.jobTitle, + points: origin.tagline ? [origin.tagline] : [], + scale: 1.1 + }); + } + + const jobs = nodes.filter( + (n): n is ExperienceNode => n.category === 'experience' && !!n.company && !!n.startDate + ); + + for (const job of jobs) { + stops.push({ + slug: slugify(job.company as string), + node: job, + kicker: `${job.startDate} – ${job.endDate || 'Present'}`, + title: job.company as string, + subtitle: job.jobTitle, + // Two at most: the card has to stay readable at a glance. + points: (job.highlights ?? []).slice(0, 2), + scale: 1.35 + }); + } + + return stops; +} + +/** Resolve a ?tour= value to a stop index. -1 when it matches nothing. */ +export function findStopIndex(stops: JourneyStop[], slug: string | null): number { + if (!slug) return -1; + const wanted = slugify(slug); + return stops.findIndex((s) => s.slug === wanted || s.slug.startsWith(wanted)); +}