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,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