// ========================================================================== // 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); }