diff --git a/src/lib/components/SearchPalette.svelte b/src/lib/components/SearchPalette.svelte new file mode 100644 index 0000000..d05793d --- /dev/null +++ b/src/lib/components/SearchPalette.svelte @@ -0,0 +1,266 @@ + + +{#if open} + + + + +{/if} + + diff --git a/src/lib/components/SkillTree.svelte b/src/lib/components/SkillTree.svelte index d98193f..e2c2121 100644 --- a/src/lib/components/SkillTree.svelte +++ b/src/lib/components/SkillTree.svelte @@ -12,6 +12,7 @@ import TreeNavigator from './TreeNavigator.svelte'; import Hud from './Hud.svelte'; import CoachHint from './CoachHint.svelte'; + import SearchPalette from './SearchPalette.svelte'; import { MOBILE_BREAKPOINT, prefersReducedMotion } from '$lib/config'; import { BRANCH_IDS, @@ -24,7 +25,13 @@ import { createController, type SkillTreeController } from '$lib/pixi/controller'; import { createLines } from '$lib/pixi/lines'; import { createNodes } from '$lib/pixi/nodes'; - import { attachPanZoom, attachKeyboard, updateParallax, panToNode } from '$lib/pixi/interaction'; + import { + attachPanZoom, + attachKeyboard, + updateParallax, + panToNode, + updateLabels + } from '$lib/pixi/interaction'; import { runEntranceAnimations } from '$lib/pixi/animation'; import { createLifecycle } from '$lib/pixi/lifecycle'; @@ -41,6 +48,7 @@ const VISITED_KEY = 'atlas:visited'; let introPlaying = $state(false); let showCoachHint = $state(false); + let searchOpen = $state(false); let skipEntrance: (() => void) | null = null; function hasVisitedBefore() { @@ -248,7 +256,9 @@ onToggleStats: toggleStatsPanel, onCloseModal: closeModal, isModalVisible: () => modalVisible, - isStatsVisible: () => statsPanelVisible + isStatsVisible: () => statsPanelVisible, + onOpenSearch: () => (searchOpen = true), + isSearchOpen: () => searchOpen }, life ); @@ -306,6 +316,10 @@ if (resizeFrame !== -1) cancelAnimationFrame(resizeFrame); }); + // Labels start hidden below the zoom threshold; set their initial state + // so they're correct before the first zoom interaction. + updateLabels(controller); + // Sequenced entrance animation. Returning visitors skip straight to the // settled tree; first-timers get the intro plus a Skip control. const returning = hasVisitedBefore(); @@ -407,6 +421,15 @@ + (searchOpen = false)} + onSelect={(node) => { + if (controllerRef) panToNode(controllerRef, node.x, node.y, life); + openModal(node); + }} +/> + { diff --git a/src/lib/config.ts b/src/lib/config.ts index bb79799..85ce805 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -38,6 +38,16 @@ export const NODE_RADII: Record = { }; export const MOBILE_RADIUS_FACTOR = 0.8; +/** + * Zoom at which on-canvas node labels appear. + * + * Deliberately just above the settled desktop zoom (endScale = 1), so the + * default full-tree view stays a clean constellation — 57 constant-size labels + * at that zoom overlap into noise. Zooming in at all reveals them, and 'L' + * forces them on at any zoom. + */ +export const LABEL_ZOOM_THRESHOLD = 1.05; + /** Keyboard pan distance per arrow-key press (px). */ export const PAN_SPEED = 50; diff --git a/src/lib/pixi/animation.ts b/src/lib/pixi/animation.ts index a1c7506..c42ab19 100644 --- a/src/lib/pixi/animation.ts +++ b/src/lib/pixi/animation.ts @@ -5,7 +5,7 @@ import { Container } from 'pixi.js'; import { gsap } from 'gsap'; import { getDistanceFromOrigin } from '$lib/utils/tree'; import { prefersReducedMotion, TIMINGS, byDevice } from '$lib/config'; -import { updateParallax } from './interaction'; +import { updateParallax, updateLabels } from './interaction'; import type { SkillTreeController } from './controller'; import type { Lifecycle } from './lifecycle'; @@ -36,11 +36,13 @@ export function runEntranceAnimations( /** Put the tree straight into its settled, interactive state. */ function showFinishedTree() { treeContainer.scale.set(endScale); + updateLabels(ctrl); for (const obj of nodeObjects) { obj.graphics.alpha = 1; obj.graphics.scale.set(1); obj.glowGraphics.alpha = 1; + if (obj.label) obj.label.alpha = 1; if (obj.graphics instanceof Container) obj.graphics.eventMode = 'static'; } @@ -136,6 +138,14 @@ export function runEntranceAnimations( delay: originDelay, ease: 'power2.out' }); + if (originNode.label) { + gsap.to(originNode.label, { + alpha: 1, + duration: originDuration, + delay: originDelay, + ease: 'power2.out' + }); + } } // STEP 2: Start zoom out after origin appears @@ -147,7 +157,10 @@ export function runEntranceAnimations( duration: zoomDuration, delay: zoomDelay, ease: 'power1.out', - onUpdate: () => updateParallax(ctrl) + onUpdate: () => { + updateParallax(ctrl); + updateLabels(ctrl); + } }); // STEP 3: Animate in the rest of the nodes (excluding origin) @@ -182,6 +195,12 @@ export function runEntranceAnimations( ease: 'power2.out' }); + // Labels arrive with their node, never before it — otherwise they show + // over invisible nodes during the splash. + if (obj.label) { + gsap.to(obj.label, { alpha: 1, duration: 0.5, delay, ease: 'power2.out' }); + } + // Start ambient pulse after node appears gsap.to(obj.glowGraphics.scale, { x: 1.4, diff --git a/src/lib/pixi/controller.ts b/src/lib/pixi/controller.ts index fe3d611..818dfbb 100644 --- a/src/lib/pixi/controller.ts +++ b/src/lib/pixi/controller.ts @@ -29,6 +29,9 @@ export interface SkillTreeController { initialTreeX: number; initialTreeY: number; endScale: number; + + /** Labels pinned on by the user ('L'), overriding the zoom threshold. */ + labelsForced: boolean; } export function createController( @@ -53,6 +56,7 @@ export function createController( isTouchZooming: false, initialTreeX: treeContainer.x, initialTreeY: treeContainer.y, - endScale + endScale, + labelsForced: false }; } diff --git a/src/lib/pixi/interaction.ts b/src/lib/pixi/interaction.ts index d10345c..29ee8f0 100644 --- a/src/lib/pixi/interaction.ts +++ b/src/lib/pixi/interaction.ts @@ -4,10 +4,111 @@ // handlers read/write it by reference. attach* functions return a cleanup that // removes the window-level listeners they added. import { gsap } from 'gsap'; -import { ZOOM, PARALLAX, PAN_SPEED, prefersReducedMotion } from '$lib/config'; +import { ZOOM, PARALLAX, PAN_SPEED, prefersReducedMotion, LABEL_ZOOM_THRESHOLD } from '$lib/config'; import type { SkillTreeController } from './controller'; import type { Lifecycle } from './lifecycle'; +/** + * Fade node labels in once the tree is zoomed in far enough to have room for + * them, and counter-scale so text stays a constant size on screen rather than + * ballooning with the zoom. Called on every zoom change (not per frame). + */ +/** Screen-space rectangle, used for label collision tests. */ +interface Box { + left: number; + top: number; + right: number; + bottom: number; +} + +function overlaps(a: Box, b: Box): boolean { + return !(a.right < b.left || a.left > b.right || a.bottom < b.top || a.top > b.bottom); +} + +/** Bigger nodes win a contested label. */ +const SIZE_PRIORITY: Record = { + 'x-large': 0, + large: 1, + medium: 2, + small: 3 +}; + +export function updateLabels(ctrl: SkillTreeController) { + const { treeContainer, nodeObjects } = ctrl; + const scale = treeContainer.scale.x; + const visible = ctrl.labelsForced || scale >= LABEL_ZOOM_THRESHOLD; + const inverse = 1 / scale; + + if (!visible) { + for (const obj of nodeObjects) if (obj.label) obj.label.visible = false; + return; + } + + // Every node's footprint on screen. A label may never cover a node other + // than its own. + const nodeBoxes = new Map(); + for (const obj of nodeObjects) { + const g = obj.graphics; + const w = (g.width || 0) / 2; + const h = (g.height || 0) / 2; + const cx = treeContainer.x + obj.node.x * scale; + const cy = treeContainer.y + obj.node.y * scale; + nodeBoxes.set(obj.node.id, { + left: cx - w, + top: cy - h, + right: cx + w, + bottom: cy + h + }); + } + + // Draw labels biggest-node-first, and drop any that would collide with a + // label already placed or with someone else's node. In dense clusters this + // simply shows fewer labels until the user zooms further in. + const ordered = [...nodeObjects].sort( + (a, b) => (SIZE_PRIORITY[a.node.size] ?? 9) - (SIZE_PRIORITY[b.node.size] ?? 9) + ); + + const placed: Box[] = []; + const PAD = 2; + + for (const obj of ordered) { + const label = obj.label; + if (!label) continue; + + // Counter-scale so the text stays a constant size on screen instead of + // growing with the zoom. Alpha is deliberately NOT set here — it is owned + // by the entrance animation, so a label can't appear over a node that has + // not faded in yet. + label.scale.set(inverse); + + const screenW = label.width * scale; + const screenH = label.height * scale; + const cx = treeContainer.x + label.x * scale; + const top = treeContainer.y + label.y * scale; + + const box: Box = { + left: cx - screenW / 2 - PAD, + top: top - PAD, + right: cx + screenW / 2 + PAD, + bottom: top + screenH + PAD + }; + + let blocked = placed.some((other) => overlaps(box, other)); + if (!blocked) { + for (const [id, nodeBox] of nodeBoxes) { + if (id === obj.node.id) continue; + if (overlaps(box, nodeBox)) { + blocked = true; + break; + } + } + } + + label.visible = !blocked; + if (!blocked) placed.push(box); + } +} + /** Offset the background layers behind the tree to create parallax depth. */ export function updateParallax(ctrl: SkillTreeController) { const treeDeltaX = ctrl.treeContainer.x - ctrl.initialTreeX; @@ -161,6 +262,7 @@ export function attachPanZoom( // Zoom centered on origin node (same as mouse wheel zoom) treeContainer.scale.set(newScale); updateParallax(ctrl); + updateLabels(ctrl); } ctrl.lastTouchDistance = distance; @@ -210,6 +312,8 @@ export function attachPanZoom( // (treeContainer.x, treeContainer.y), so scaling keeps it anchored. treeContainer.scale.set(newScale); updateParallax(ctrl); + updateLabels(ctrl); + updateLabels(ctrl); }, { passive: false } ); @@ -234,13 +338,19 @@ export function panToNode(ctrl: SkillTreeController, x: number, y: number, life: duration: prefersReducedMotion() ? 0 : 0.4, ease: 'power2.out', overwrite: 'auto', - onUpdate: () => updateParallax(ctrl) + onUpdate: () => { + updateParallax(ctrl); + updateLabels(ctrl); + } }); }); } interface KeyboardOpts { onToggleStats: () => void; + /** Cmd/Ctrl-K — open the search palette. */ + onOpenSearch: () => void; + isSearchOpen: () => boolean; onCloseModal: () => void; isModalVisible: () => boolean; isStatsVisible: () => boolean; @@ -266,6 +376,17 @@ export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts, li // modal body) must scroll it, not pan the tree behind it. Previously // every arrow press called preventDefault() unconditionally, so those // regions could not be scrolled by keyboard at all. + // Cmd/Ctrl-K opens search. Checked before the modifier bail-out below, + // since it is itself a modifier combo. + if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) { + e.preventDefault(); + opts.onOpenSearch(); + return; + } + + // While the palette is open it owns the keyboard entirely. + if (opts.isSearchOpen()) return; + if (e.ctrlKey || e.metaKey || e.altKey) return; // `instanceof Element`, not a truthiness check: a keydown dispatched on @@ -336,6 +457,8 @@ export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts, li const newScale = Math.min(maxZoom, currentScale + zoomSpeed); treeContainer.scale.set(newScale); updateParallax(ctrl); + updateLabels(ctrl); + updateLabels(ctrl); } // - for zoom out @@ -345,6 +468,15 @@ export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts, li const newScale = Math.max(minZoom, currentScale - zoomSpeed); treeContainer.scale.set(newScale); updateParallax(ctrl); + updateLabels(ctrl); + updateLabels(ctrl); + } + + // L forces labels on/off regardless of zoom. + if (e.key === 'l' || e.key === 'L') { + e.preventDefault(); + ctrl.labelsForced = !ctrl.labelsForced; + updateLabels(ctrl); } // R to reset view to origin @@ -358,7 +490,10 @@ export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts, li duration: 0.5, ease: 'power2.out', overwrite: 'auto', - onUpdate: () => updateParallax(ctrl), + onUpdate: () => { + updateParallax(ctrl); + updateLabels(ctrl); + }, onComplete: () => { // Update parallax baseline after reset ctrl.initialTreeX = treeContainer.x; @@ -371,7 +506,10 @@ export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts, li duration: 0.5, ease: 'power2.out', overwrite: 'auto', - onUpdate: () => updateParallax(ctrl) + onUpdate: () => { + updateParallax(ctrl); + updateLabels(ctrl); + } }); }); } diff --git a/src/lib/pixi/nodes.ts b/src/lib/pixi/nodes.ts index 9544936..9aec53f 100644 --- a/src/lib/pixi/nodes.ts +++ b/src/lib/pixi/nodes.ts @@ -1,10 +1,10 @@ // Tree node rendering: the origin profile node and regular icon nodes. // Nodes start non-interactive (eventMode 'none') until the entrance animation // enables them; see animation.ts. -import { Graphics, Container, Assets, Sprite, type Texture } from 'pixi.js'; +import { Graphics, Container, Assets, Sprite, Text, TextStyle, type Texture } from 'pixi.js'; import { gsap } from 'gsap'; import { treeNodes, type TreeNode } from '$lib/data'; -import { categoryColors } from '$lib/theme'; +import { categoryColors, categoryColorHex } from '$lib/theme'; import { getNodeRadius } from '$lib/utils/tree'; import type { NodeObject } from './types'; import type { SkillTreeController } from './controller'; @@ -56,6 +56,40 @@ function attachNodeInteraction( }); } +/** + * One shared TextStyle per category. Pixi rasterises Text to a texture, so + * reusing styles keeps that cheap; labels also start invisible and are only + * revealed above the zoom threshold. + */ +function makeLabel(node: TreeNode, radius: number, isMobile: boolean): Text { + const style = new TextStyle({ + fontFamily: 'system-ui, sans-serif', + fontSize: isMobile ? 11 : 12, + fontWeight: '600', + fill: categoryColorHex[node.category], + align: 'center', + stroke: { color: '#05050a', width: 3, join: 'round' } + }); + + // Long labels are the main source of collisions ("Data Structures & + // Algorithms" is nearly 3x a node's diameter). The full text is always + // available in the tooltip and modal. + const MAX_LABEL_CHARS = 18; + const text = + node.label.length > MAX_LABEL_CHARS + ? `${node.label.slice(0, MAX_LABEL_CHARS - 1)}…` + : node.label; + + const label = new Text({ text, style }); + label.anchor.set(0.5, 0); + label.x = node.x; + label.y = node.y + radius + 6; + label.alpha = 0; + label.eventMode = 'none'; + label.resolution = Math.min(window.devicePixelRatio || 1, 2); + return label; +} + export async function createNodes(treeContainer: Container, opts: NodeOpts): Promise { const { isMobile } = opts; const nodeObjects: NodeObject[] = []; @@ -116,7 +150,9 @@ export async function createNodes(treeContainer: Container, opts: NodeOpts): Pro attachNodeInteraction(profileContainer, glowGraphics, node, opts); treeContainer.addChild(profileContainer); - nodeObjects.push({ graphics: profileContainer, node, glowGraphics }); + const originLabel = makeLabel(node, radius, isMobile); + treeContainer.addChild(originLabel); + nodeObjects.push({ graphics: profileContainer, node, glowGraphics, label: originLabel }); } else { const nodeContainer = new Container(); nodeContainer.x = node.x; @@ -153,7 +189,9 @@ export async function createNodes(treeContainer: Container, opts: NodeOpts): Pro attachNodeInteraction(nodeContainer, glowGraphics, node, opts); treeContainer.addChild(nodeContainer); - nodeObjects.push({ graphics: nodeContainer, node, glowGraphics }); + const nodeLabel = makeLabel(node, radius, isMobile); + treeContainer.addChild(nodeLabel); + nodeObjects.push({ graphics: nodeContainer, node, glowGraphics, label: nodeLabel }); } } diff --git a/src/lib/pixi/types.ts b/src/lib/pixi/types.ts index e7ab746..892b9b8 100644 --- a/src/lib/pixi/types.ts +++ b/src/lib/pixi/types.ts @@ -1,5 +1,5 @@ // Shared structural types for the PixiJS skill-tree rendering modules. -import type { Graphics, Container } from 'pixi.js'; +import type { Graphics, Container, Text } from 'pixi.js'; import type { TreeNode } from '$lib/data'; /** A parent→child connection: glow + core line + animated plasma beam. */ @@ -17,6 +17,8 @@ export interface NodeObject { graphics: Graphics | Container; node: TreeNode; glowGraphics: Graphics; + /** On-canvas label, shown above the zoom threshold. */ + label?: Text; } /** The parallax background layers plus the vignette overlay. */ diff --git a/src/lib/utils/search.test.ts b/src/lib/utils/search.test.ts new file mode 100644 index 0000000..1858efb --- /dev/null +++ b/src/lib/utils/search.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { searchNodes } from './search'; + +describe('searchNodes', () => { + it('returns nothing for an empty query', () => { + expect(searchNodes('')).toEqual([]); + expect(searchNodes(' ')).toEqual([]); + }); + + it('ranks an exact label match first', () => { + expect(searchNodes('React')[0].node.label).toBe('React'); + expect(searchNodes('Docker')[0].node.label).toBe('Docker'); + }); + + it('is case-insensitive', () => { + expect(searchNodes('react')[0].node.id).toBe(searchNodes('REACT')[0].node.id); + }); + + it('matches a subsequence, not just a substring', () => { + const ids = searchNodes('wbrtc').map((r) => r.node.id); + expect(ids).toContain('be-webrtc'); + }); + + it('prefers the shorter label when both match a prefix', () => { + expect(searchNodes('git')[0].node.label).toBe('Git'); + }); + + it('finds projects by their tech stack', () => { + const results = searchNodes('Prisma'); + expect(results.length).toBeGreaterThan(0); + expect(results.every((r) => r.node.category === 'projects')).toBe(true); + expect(results[0].context).toMatch(/^tech: /); + }); + + it('finds experience by company name', () => { + const results = searchNodes('Peraton'); + expect(results[0].node.category).toBe('experience'); + expect(results[0].context).toBe('Peraton'); + }); + + it('returns nothing for a query that matches nothing', () => { + expect(searchNodes('zzzzqqqxyw')).toEqual([]); + }); + + it('never returns duplicate nodes', () => { + const ids = searchNodes('e').map((r) => r.node.id); + expect(new Set(ids).size).toBe(ids.length); + }); +}); diff --git a/src/lib/utils/search.ts b/src/lib/utils/search.ts new file mode 100644 index 0000000..4a17fe1 --- /dev/null +++ b/src/lib/utils/search.ts @@ -0,0 +1,93 @@ +// ========================================================================== +// SEARCH — subsequence matching over the tree data. Pure, no DOM, no PixiJS. +// ========================================================================== + +import { treeNodes, type TreeNode } from '$lib/data'; + +export interface SearchResult { + node: TreeNode; + score: number; + /** Why it matched, for display ("React" vs "tech: React"). */ + context?: string; +} + +/** + * Case-insensitive subsequence match: "wbrtc" matches "WebRTC". + * + * Returns a score where lower is better, or null for no match. Consecutive + * runs and an early first match are rewarded, so "react" ranks the React node + * above a project that merely lists React in its stack. + */ +function subsequenceScore(haystack: string, needle: string): number | null { + if (!needle) return 0; + const h = haystack.toLowerCase(); + const n = needle.toLowerCase(); + + // Exact and prefix matches short-circuit to the best scores. + if (h === n) return -1000; + if (h.startsWith(n)) return -500 + h.length; + + let score = 0; + let hi = 0; + let lastHit = -1; + for (let ni = 0; ni < n.length; ni++) { + const found = h.indexOf(n[ni], hi); + if (found === -1) return null; + // Penalise gaps; reward adjacency. + score += found === lastHit + 1 ? 0 : found - hi + 1; + lastHit = found; + hi = found + 1; + } + // Prefer shorter haystacks — "Git" should beat "GitHub Actions" for "git". + return score + h.length * 0.1; +} + +/** Search every node by label, then category, then description/tech stack. */ +export function searchNodes(query: string, nodes: TreeNode[] = treeNodes): SearchResult[] { + const q = query.trim(); + if (!q) return []; + + const results: SearchResult[] = []; + + for (const node of nodes) { + const labelScore = subsequenceScore(node.label, q); + if (labelScore !== null) { + results.push({ node, score: labelScore }); + continue; + } + + // Tech stack is a strong signal — searching "prisma" should surface the + // projects that use it even though no node is named Prisma. + if (node.category === 'projects' && node.techStack) { + const tech = node.techStack.find((t) => subsequenceScore(t, q) !== null); + if (tech) { + results.push({ node, score: 200, context: `tech: ${tech}` }); + continue; + } + } + + if (node.category === 'experience' && node.company) { + const companyScore = subsequenceScore(node.company, q); + if (companyScore !== null) { + results.push({ node, score: companyScore + 100, context: node.company }); + continue; + } + } + + // Descriptions use plain substring matching, not subsequence. Over long + // prose a subsequence match is almost always spurious — "Prisma" found a + // p...r...i...s...m...a spread across an unrelated sentence. + const haystack = node.description.toLowerCase(); + const needle = q.toLowerCase(); + const at = haystack.indexOf(needle); + if (at !== -1) { + results.push({ + node, + score: 400 + at, + context: node.description.slice(Math.max(0, at - 20), at + 45).trim() + }); + } + } + + return results.sort((a, b) => a.score - b.score); +}