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));
+}