feat: audit phase 6 (T2) — on-canvas labels and Cmd/Ctrl-K search
Even with the HUD from T1, the tree itself was 57 wordless icons: no way to
answer "does he know NestJS?" without clicking around.
Search palette (Cmd/Ctrl-K):
- New utils/search.ts — pure, 9 unit tests. Subsequence matching on short
fields ("wbrtc" finds WebRTC), plus tech-stack and company matching so
"Prisma" surfaces the projects using it and "Peraton" finds the role.
- Descriptions deliberately use substring, NOT subsequence matching. Over
long prose a subsequence match is almost always spurious — the first
version matched "Prisma" as p...r...i...s...m...a spread across an
unrelated sentence, which a test caught.
- Selecting a result flies the camera to that node and opens its modal.
- Built to the ARIA combobox pattern: input keeps focus and owns
aria-activedescendant, results are a listbox. Options intentionally have no
keyboard handler — making them focusable would break the pattern — so the
svelte-ignore there is documented rather than incidental.
On-canvas labels:
- A Text per node, hidden below LABEL_ZOOM_THRESHOLD and counter-scaled by
1/zoom so they stay a constant size on screen rather than ballooning.
- Threshold sits just above the settled desktop zoom, so the default view
stays a clean constellation and zooming in reveals labels. 'L' forces them
on at any zoom.
- Long labels truncate at 18 chars; full text is still in the tooltip/modal.
- Collision culling: labels are placed biggest-node-first, and any whose box
would overlap an already-placed label or someone else's node is dropped.
Dense clusters simply show fewer labels until you zoom further in. This is
purely presentational — no node coordinates changed.
Three bugs found during review:
- The entrance zoom tween only called updateParallax, so the label
counter-scale stayed frozen at its pre-zoom value (labels ended up at 1/4
size and effectively vanished). It now updates labels too, as does the
skip/reduced-motion path that sets scale directly.
- updateLabels forced alpha = 1, so labels appeared over nodes that had not
faded in yet — visible during the splash screen. Alpha now belongs to the
entrance and labels arrive with their node.
- animation.ts imported updateParallax only; it needed updateLabels as well.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d8297cf903
commit
ea721ae650
10 files changed
+656
-14
No files matched your search
@@ -0,0 +1,266 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Cmd/Ctrl-K search over the whole tree.
|
||||||
|
//
|
||||||
|
// The canvas is 57 wordless icons, so there was no way to answer "does he
|
||||||
|
// know NestJS?" short of clicking around. This makes the data directly
|
||||||
|
// queryable and flies the camera to whatever you pick.
|
||||||
|
//
|
||||||
|
// Built to the ARIA combobox pattern: the input keeps focus and owns
|
||||||
|
// aria-activedescendant, the list is a listbox, and nothing else steals
|
||||||
|
// focus — so arrow keys read correctly to a screen reader.
|
||||||
|
import { searchNodes, type SearchResult } from '$lib/utils/search';
|
||||||
|
import { categoryColorHex } from '$lib/theme';
|
||||||
|
import type { TreeNode } from '$lib/data';
|
||||||
|
import { prefersReducedMotion } from '$lib/config';
|
||||||
|
|
||||||
|
const {
|
||||||
|
open = false,
|
||||||
|
onClose,
|
||||||
|
onSelect
|
||||||
|
}: {
|
||||||
|
open?: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSelect: (node: TreeNode) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let query = $state('');
|
||||||
|
let activeIndex = $state(0);
|
||||||
|
let inputEl = $state<HTMLInputElement | null>(null);
|
||||||
|
let listEl = $state<HTMLUListElement | null>(null);
|
||||||
|
|
||||||
|
const results = $derived<SearchResult[]>(searchNodes(query).slice(0, 8));
|
||||||
|
|
||||||
|
// Reset and focus each time it opens.
|
||||||
|
$effect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
query = '';
|
||||||
|
activeIndex = 0;
|
||||||
|
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||||
|
inputEl?.focus();
|
||||||
|
return () => previouslyFocused?.focus?.({ preventScroll: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Keep the active option scrolled into view as arrows move it.
|
||||||
|
if (!open || !listEl) return;
|
||||||
|
const el = listEl.children[activeIndex] as HTMLElement | undefined;
|
||||||
|
el?.scrollIntoView({ block: 'nearest', behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
|
||||||
|
});
|
||||||
|
|
||||||
|
function choose(index: number) {
|
||||||
|
const hit = results[index];
|
||||||
|
if (!hit) return;
|
||||||
|
onSelect(hit.node);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
activeIndex = results.length ? (activeIndex + 1) % results.length : 0;
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
activeIndex = results.length ? (activeIndex - 1 + results.length) % results.length : 0;
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault();
|
||||||
|
choose(activeIndex);
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
break;
|
||||||
|
case 'Tab':
|
||||||
|
// Nothing else in here is focusable; keep focus on the input.
|
||||||
|
e.preventDefault();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<!-- Presentational scrim; Escape and the input own all keyboard handling. -->
|
||||||
|
<div class="palette-backdrop" onclick={onClose} role="presentation"></div>
|
||||||
|
|
||||||
|
<div class="palette" role="dialog" aria-modal="true" aria-label="Search the skill tree">
|
||||||
|
<input
|
||||||
|
bind:this={inputEl}
|
||||||
|
bind:value={query}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
oninput={() => (activeIndex = 0)}
|
||||||
|
type="text"
|
||||||
|
class="palette-input"
|
||||||
|
placeholder="Search skills, projects, companies…"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={results.length > 0}
|
||||||
|
aria-controls="palette-results"
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-activedescendant={results.length ? `palette-opt-${activeIndex}` : undefined}
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if query && !results.length}
|
||||||
|
<p class="palette-empty">No matches for “{query}”.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<ul bind:this={listEl} id="palette-results" role="listbox" aria-label="Search results">
|
||||||
|
{#each results as result, i (result.node.id)}
|
||||||
|
<!--
|
||||||
|
No keyboard handler here on purpose. In the ARIA combobox pattern the
|
||||||
|
input keeps focus and drives selection via aria-activedescendant;
|
||||||
|
making options focusable would break that for screen readers. Mouse
|
||||||
|
users get click, keyboard users get the arrow keys on the input.
|
||||||
|
-->
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<li
|
||||||
|
id="palette-opt-{i}"
|
||||||
|
role="option"
|
||||||
|
aria-selected={i === activeIndex}
|
||||||
|
class:active={i === activeIndex}
|
||||||
|
style="--node-color: {categoryColorHex[result.node.category]}"
|
||||||
|
onclick={() => choose(i)}
|
||||||
|
onmousemove={() => (activeIndex = i)}
|
||||||
|
>
|
||||||
|
<span class="dot" aria-hidden="true"></span>
|
||||||
|
<span class="label">{result.node.label}</span>
|
||||||
|
{#if result.context}
|
||||||
|
<span class="context">{result.context}</span>
|
||||||
|
{/if}
|
||||||
|
<span class="category">{result.node.category}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p class="palette-hint">
|
||||||
|
<kbd>↑</kbd><kbd>↓</kbd> to navigate · <kbd>Enter</kbd> to open · <kbd>Esc</kbd> to close
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.palette-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: var(--z-modal);
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.palette {
|
||||||
|
position: fixed;
|
||||||
|
top: 12vh;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: var(--z-modal);
|
||||||
|
width: min(36rem, 92vw);
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid var(--color-gold);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: linear-gradient(145deg, var(--panel-from) 0%, var(--panel-to) 100%);
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.palette-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.palette-input::placeholder {
|
||||||
|
color: var(--text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.palette-empty {
|
||||||
|
margin: var(--space-3) var(--space-2) 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
max-height: 46vh;
|
||||||
|
margin: var(--space-2) 0 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
li.active {
|
||||||
|
background: rgba(255, 215, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-self: center;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--node-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--text-subtle);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.palette-hint {
|
||||||
|
margin: var(--space-3) var(--space-2) var(--space-1);
|
||||||
|
color: var(--text-subtle);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.palette {
|
||||||
|
top: 4vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
import TreeNavigator from './TreeNavigator.svelte';
|
import TreeNavigator from './TreeNavigator.svelte';
|
||||||
import Hud from './Hud.svelte';
|
import Hud from './Hud.svelte';
|
||||||
import CoachHint from './CoachHint.svelte';
|
import CoachHint from './CoachHint.svelte';
|
||||||
|
import SearchPalette from './SearchPalette.svelte';
|
||||||
import { MOBILE_BREAKPOINT, prefersReducedMotion } from '$lib/config';
|
import { MOBILE_BREAKPOINT, prefersReducedMotion } from '$lib/config';
|
||||||
import {
|
import {
|
||||||
BRANCH_IDS,
|
BRANCH_IDS,
|
||||||
@@ -24,7 +25,13 @@
|
|||||||
import { createController, type SkillTreeController } from '$lib/pixi/controller';
|
import { createController, type SkillTreeController } from '$lib/pixi/controller';
|
||||||
import { createLines } from '$lib/pixi/lines';
|
import { createLines } from '$lib/pixi/lines';
|
||||||
import { createNodes } from '$lib/pixi/nodes';
|
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 { runEntranceAnimations } from '$lib/pixi/animation';
|
||||||
import { createLifecycle } from '$lib/pixi/lifecycle';
|
import { createLifecycle } from '$lib/pixi/lifecycle';
|
||||||
|
|
||||||
@@ -41,6 +48,7 @@
|
|||||||
const VISITED_KEY = 'atlas:visited';
|
const VISITED_KEY = 'atlas:visited';
|
||||||
let introPlaying = $state(false);
|
let introPlaying = $state(false);
|
||||||
let showCoachHint = $state(false);
|
let showCoachHint = $state(false);
|
||||||
|
let searchOpen = $state(false);
|
||||||
let skipEntrance: (() => void) | null = null;
|
let skipEntrance: (() => void) | null = null;
|
||||||
|
|
||||||
function hasVisitedBefore() {
|
function hasVisitedBefore() {
|
||||||
@@ -248,7 +256,9 @@
|
|||||||
onToggleStats: toggleStatsPanel,
|
onToggleStats: toggleStatsPanel,
|
||||||
onCloseModal: closeModal,
|
onCloseModal: closeModal,
|
||||||
isModalVisible: () => modalVisible,
|
isModalVisible: () => modalVisible,
|
||||||
isStatsVisible: () => statsPanelVisible
|
isStatsVisible: () => statsPanelVisible,
|
||||||
|
onOpenSearch: () => (searchOpen = true),
|
||||||
|
isSearchOpen: () => searchOpen
|
||||||
},
|
},
|
||||||
life
|
life
|
||||||
);
|
);
|
||||||
@@ -306,6 +316,10 @@
|
|||||||
if (resizeFrame !== -1) cancelAnimationFrame(resizeFrame);
|
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
|
// Sequenced entrance animation. Returning visitors skip straight to the
|
||||||
// settled tree; first-timers get the intro plus a Skip control.
|
// settled tree; first-timers get the intro plus a Skip control.
|
||||||
const returning = hasVisitedBefore();
|
const returning = hasVisitedBefore();
|
||||||
@@ -407,6 +421,15 @@
|
|||||||
<!-- Persistent identity: name, title and the one-line hook, always on screen -->
|
<!-- Persistent identity: name, title and the one-line hook, always on screen -->
|
||||||
<Hud showSkip={introPlaying} onSkip={skipIntro} />
|
<Hud showSkip={introPlaying} onSkip={skipIntro} />
|
||||||
|
|
||||||
|
<SearchPalette
|
||||||
|
open={searchOpen}
|
||||||
|
onClose={() => (searchOpen = false)}
|
||||||
|
onSelect={(node) => {
|
||||||
|
if (controllerRef) panToNode(controllerRef, node.x, node.y, life);
|
||||||
|
openModal(node);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<CoachHint
|
<CoachHint
|
||||||
visible={showCoachHint}
|
visible={showCoachHint}
|
||||||
onDismiss={() => {
|
onDismiss={() => {
|
||||||
|
|||||||
@@ -38,6 +38,16 @@ export const NODE_RADII: Record<string, number> = {
|
|||||||
};
|
};
|
||||||
export const MOBILE_RADIUS_FACTOR = 0.8;
|
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). */
|
/** Keyboard pan distance per arrow-key press (px). */
|
||||||
export const PAN_SPEED = 50;
|
export const PAN_SPEED = 50;
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ 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, TIMINGS, byDevice } from '$lib/config';
|
import { prefersReducedMotion, TIMINGS, byDevice } from '$lib/config';
|
||||||
import { updateParallax } from './interaction';
|
import { updateParallax, updateLabels } from './interaction';
|
||||||
import type { SkillTreeController } from './controller';
|
import type { SkillTreeController } from './controller';
|
||||||
import type { Lifecycle } from './lifecycle';
|
import type { Lifecycle } from './lifecycle';
|
||||||
|
|
||||||
@@ -36,11 +36,13 @@ export function runEntranceAnimations(
|
|||||||
/** Put the tree straight into its settled, interactive state. */
|
/** Put the tree straight into its settled, interactive state. */
|
||||||
function showFinishedTree() {
|
function showFinishedTree() {
|
||||||
treeContainer.scale.set(endScale);
|
treeContainer.scale.set(endScale);
|
||||||
|
updateLabels(ctrl);
|
||||||
|
|
||||||
for (const obj of nodeObjects) {
|
for (const obj of nodeObjects) {
|
||||||
obj.graphics.alpha = 1;
|
obj.graphics.alpha = 1;
|
||||||
obj.graphics.scale.set(1);
|
obj.graphics.scale.set(1);
|
||||||
obj.glowGraphics.alpha = 1;
|
obj.glowGraphics.alpha = 1;
|
||||||
|
if (obj.label) obj.label.alpha = 1;
|
||||||
if (obj.graphics instanceof Container) obj.graphics.eventMode = 'static';
|
if (obj.graphics instanceof Container) obj.graphics.eventMode = 'static';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +138,14 @@ export function runEntranceAnimations(
|
|||||||
delay: originDelay,
|
delay: originDelay,
|
||||||
ease: 'power2.out'
|
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
|
// STEP 2: Start zoom out after origin appears
|
||||||
@@ -147,7 +157,10 @@ export function runEntranceAnimations(
|
|||||||
duration: zoomDuration,
|
duration: zoomDuration,
|
||||||
delay: zoomDelay,
|
delay: zoomDelay,
|
||||||
ease: 'power1.out',
|
ease: 'power1.out',
|
||||||
onUpdate: () => updateParallax(ctrl)
|
onUpdate: () => {
|
||||||
|
updateParallax(ctrl);
|
||||||
|
updateLabels(ctrl);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// STEP 3: Animate in the rest of the nodes (excluding origin)
|
// STEP 3: Animate in the rest of the nodes (excluding origin)
|
||||||
@@ -182,6 +195,12 @@ export function runEntranceAnimations(
|
|||||||
ease: 'power2.out'
|
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
|
// Start ambient pulse after node appears
|
||||||
gsap.to(obj.glowGraphics.scale, {
|
gsap.to(obj.glowGraphics.scale, {
|
||||||
x: 1.4,
|
x: 1.4,
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ export interface SkillTreeController {
|
|||||||
initialTreeX: number;
|
initialTreeX: number;
|
||||||
initialTreeY: number;
|
initialTreeY: number;
|
||||||
endScale: number;
|
endScale: number;
|
||||||
|
|
||||||
|
/** Labels pinned on by the user ('L'), overriding the zoom threshold. */
|
||||||
|
labelsForced: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createController(
|
export function createController(
|
||||||
@@ -53,6 +56,7 @@ export function createController(
|
|||||||
isTouchZooming: false,
|
isTouchZooming: false,
|
||||||
initialTreeX: treeContainer.x,
|
initialTreeX: treeContainer.x,
|
||||||
initialTreeY: treeContainer.y,
|
initialTreeY: treeContainer.y,
|
||||||
endScale
|
endScale,
|
||||||
|
labelsForced: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
+142
-4
@@ -4,10 +4,111 @@
|
|||||||
// handlers read/write it by reference. attach* functions return a cleanup that
|
// handlers read/write it by reference. attach* functions return a cleanup that
|
||||||
// removes the window-level listeners they added.
|
// removes the window-level listeners they added.
|
||||||
import { gsap } from 'gsap';
|
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 { SkillTreeController } from './controller';
|
||||||
import type { Lifecycle } from './lifecycle';
|
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<string, number> = {
|
||||||
|
'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<string, Box>();
|
||||||
|
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. */
|
/** Offset the background layers behind the tree to create parallax depth. */
|
||||||
export function updateParallax(ctrl: SkillTreeController) {
|
export function updateParallax(ctrl: SkillTreeController) {
|
||||||
const treeDeltaX = ctrl.treeContainer.x - ctrl.initialTreeX;
|
const treeDeltaX = ctrl.treeContainer.x - ctrl.initialTreeX;
|
||||||
@@ -161,6 +262,7 @@ export function attachPanZoom(
|
|||||||
// Zoom centered on origin node (same as mouse wheel zoom)
|
// Zoom centered on origin node (same as mouse wheel zoom)
|
||||||
treeContainer.scale.set(newScale);
|
treeContainer.scale.set(newScale);
|
||||||
updateParallax(ctrl);
|
updateParallax(ctrl);
|
||||||
|
updateLabels(ctrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
ctrl.lastTouchDistance = distance;
|
ctrl.lastTouchDistance = distance;
|
||||||
@@ -210,6 +312,8 @@ export function attachPanZoom(
|
|||||||
// (treeContainer.x, treeContainer.y), so scaling keeps it anchored.
|
// (treeContainer.x, treeContainer.y), so scaling keeps it anchored.
|
||||||
treeContainer.scale.set(newScale);
|
treeContainer.scale.set(newScale);
|
||||||
updateParallax(ctrl);
|
updateParallax(ctrl);
|
||||||
|
updateLabels(ctrl);
|
||||||
|
updateLabels(ctrl);
|
||||||
},
|
},
|
||||||
{ passive: false }
|
{ passive: false }
|
||||||
);
|
);
|
||||||
@@ -234,13 +338,19 @@ export function panToNode(ctrl: SkillTreeController, x: number, y: number, life:
|
|||||||
duration: prefersReducedMotion() ? 0 : 0.4,
|
duration: prefersReducedMotion() ? 0 : 0.4,
|
||||||
ease: 'power2.out',
|
ease: 'power2.out',
|
||||||
overwrite: 'auto',
|
overwrite: 'auto',
|
||||||
onUpdate: () => updateParallax(ctrl)
|
onUpdate: () => {
|
||||||
|
updateParallax(ctrl);
|
||||||
|
updateLabels(ctrl);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
interface KeyboardOpts {
|
interface KeyboardOpts {
|
||||||
onToggleStats: () => void;
|
onToggleStats: () => void;
|
||||||
|
/** Cmd/Ctrl-K — open the search palette. */
|
||||||
|
onOpenSearch: () => void;
|
||||||
|
isSearchOpen: () => boolean;
|
||||||
onCloseModal: () => void;
|
onCloseModal: () => void;
|
||||||
isModalVisible: () => boolean;
|
isModalVisible: () => boolean;
|
||||||
isStatsVisible: () => 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
|
// modal body) must scroll it, not pan the tree behind it. Previously
|
||||||
// every arrow press called preventDefault() unconditionally, so those
|
// every arrow press called preventDefault() unconditionally, so those
|
||||||
// regions could not be scrolled by keyboard at all.
|
// 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;
|
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||||
|
|
||||||
// `instanceof Element`, not a truthiness check: a keydown dispatched on
|
// `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);
|
const newScale = Math.min(maxZoom, currentScale + zoomSpeed);
|
||||||
treeContainer.scale.set(newScale);
|
treeContainer.scale.set(newScale);
|
||||||
updateParallax(ctrl);
|
updateParallax(ctrl);
|
||||||
|
updateLabels(ctrl);
|
||||||
|
updateLabels(ctrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// - for zoom out
|
// - for zoom out
|
||||||
@@ -345,6 +468,15 @@ export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts, li
|
|||||||
const newScale = Math.max(minZoom, currentScale - zoomSpeed);
|
const newScale = Math.max(minZoom, currentScale - zoomSpeed);
|
||||||
treeContainer.scale.set(newScale);
|
treeContainer.scale.set(newScale);
|
||||||
updateParallax(ctrl);
|
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
|
// R to reset view to origin
|
||||||
@@ -358,7 +490,10 @@ export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts, li
|
|||||||
duration: 0.5,
|
duration: 0.5,
|
||||||
ease: 'power2.out',
|
ease: 'power2.out',
|
||||||
overwrite: 'auto',
|
overwrite: 'auto',
|
||||||
onUpdate: () => updateParallax(ctrl),
|
onUpdate: () => {
|
||||||
|
updateParallax(ctrl);
|
||||||
|
updateLabels(ctrl);
|
||||||
|
},
|
||||||
onComplete: () => {
|
onComplete: () => {
|
||||||
// Update parallax baseline after reset
|
// Update parallax baseline after reset
|
||||||
ctrl.initialTreeX = treeContainer.x;
|
ctrl.initialTreeX = treeContainer.x;
|
||||||
@@ -371,7 +506,10 @@ export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts, li
|
|||||||
duration: 0.5,
|
duration: 0.5,
|
||||||
ease: 'power2.out',
|
ease: 'power2.out',
|
||||||
overwrite: 'auto',
|
overwrite: 'auto',
|
||||||
onUpdate: () => updateParallax(ctrl)
|
onUpdate: () => {
|
||||||
|
updateParallax(ctrl);
|
||||||
|
updateLabels(ctrl);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-4
@@ -1,10 +1,10 @@
|
|||||||
// Tree node rendering: the origin profile node and regular icon nodes.
|
// Tree node rendering: the origin profile node and regular icon nodes.
|
||||||
// Nodes start non-interactive (eventMode 'none') until the entrance animation
|
// Nodes start non-interactive (eventMode 'none') until the entrance animation
|
||||||
// enables them; see animation.ts.
|
// 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 { gsap } from 'gsap';
|
||||||
import { treeNodes, type TreeNode } from '$lib/data';
|
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 { getNodeRadius } from '$lib/utils/tree';
|
||||||
import type { NodeObject } from './types';
|
import type { NodeObject } from './types';
|
||||||
import type { SkillTreeController } from './controller';
|
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<NodeObject[]> {
|
export async function createNodes(treeContainer: Container, opts: NodeOpts): Promise<NodeObject[]> {
|
||||||
const { isMobile } = opts;
|
const { isMobile } = opts;
|
||||||
const nodeObjects: NodeObject[] = [];
|
const nodeObjects: NodeObject[] = [];
|
||||||
@@ -116,7 +150,9 @@ export async function createNodes(treeContainer: Container, opts: NodeOpts): Pro
|
|||||||
attachNodeInteraction(profileContainer, glowGraphics, node, opts);
|
attachNodeInteraction(profileContainer, glowGraphics, node, opts);
|
||||||
|
|
||||||
treeContainer.addChild(profileContainer);
|
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 {
|
} else {
|
||||||
const nodeContainer = new Container();
|
const nodeContainer = new Container();
|
||||||
nodeContainer.x = node.x;
|
nodeContainer.x = node.x;
|
||||||
@@ -153,7 +189,9 @@ export async function createNodes(treeContainer: Container, opts: NodeOpts): Pro
|
|||||||
attachNodeInteraction(nodeContainer, glowGraphics, node, opts);
|
attachNodeInteraction(nodeContainer, glowGraphics, node, opts);
|
||||||
|
|
||||||
treeContainer.addChild(nodeContainer);
|
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Shared structural types for the PixiJS skill-tree rendering modules.
|
// 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';
|
import type { TreeNode } from '$lib/data';
|
||||||
|
|
||||||
/** A parent→child connection: glow + core line + animated plasma beam. */
|
/** A parent→child connection: glow + core line + animated plasma beam. */
|
||||||
@@ -17,6 +17,8 @@ export interface NodeObject {
|
|||||||
graphics: Graphics | Container;
|
graphics: Graphics | Container;
|
||||||
node: TreeNode;
|
node: TreeNode;
|
||||||
glowGraphics: Graphics;
|
glowGraphics: Graphics;
|
||||||
|
/** On-canvas label, shown above the zoom threshold. */
|
||||||
|
label?: Text;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The parallax background layers plus the vignette overlay. */
|
/** The parallax background layers plus the vignette overlay. */
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user