"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { Canvas } from "@react-three/fiber"; import { OrbitControls, Html, Stars } from "@react-three/drei"; import { getConstellation, searchCandidates } from "@/lib/api"; import { Loader2, Boxes, Users, RotateCw, Search, X } from "lucide-react"; import type { Mesh } from "three"; // Industry/project domain → color + label (matches legend) const FIELD_COLORS: Record = { web3_blockchain: "#c084fc", ai_ml: "#22d3ee", computer_vision: "#60a5fa", hardware_iot: "#fb923c", sustainability: "#34d399", web_development: "#f472b6", data_science: "#fbbf24", healthcare: "#f87171", social_impact: "#d6b370", }; const FIELD_LABELS: Record = { web3_blockchain: "Web3 & Blockchain", ai_ml: "AI & Machine Learning", computer_vision: "Computer Vision", hardware_iot: "Hardware & IoT", sustainability: "Sustainability", web_development: "Web Development", data_science: "Data Science", healthcare: "Healthcare", social_impact: "Social Impact", }; const fieldColor = (f: string) => FIELD_COLORS[f] || "#94a3b8"; const fieldLabel = (f: string) => FIELD_LABELS[f] || f.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); type Point = { id: number; name: string; field: string; field_label: string; score: number; match_score: number; x: number; y: number; z: number; top_skills: string[]; applied_job?: string | null; github_username?: string | null; }; function Node({ p, dimmed, onHover, onClick, }: { p: Point; dimmed: boolean; onHover: (p: Point | null) => void; onClick: (p: Point) => void; }) { const ref = useRef(null); const [hovered, setHovered] = useState(false); const radius = 0.28 + (p.score / 100) * 0.55; const color = fieldColor(p.field); return ( { e.stopPropagation(); setHovered(true); onHover(p); document.body.style.cursor = "pointer"; }} onPointerOut={() => { setHovered(false); onHover(null); document.body.style.cursor = "auto"; }} onClick={(e) => { e.stopPropagation(); onClick(p); }} scale={hovered ? 1.35 : 1} > {hovered && (
{p.name} · {p.score}
)}
); } export function VectorGraph() { const router = useRouter(); const searchParams = useSearchParams(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [hover, setHover] = useState(null); const [activeFields, setActiveFields] = useState>(new Set()); const [minScore, setMinScore] = useState(0); // Search (#5) + gap-bridge highlight (#3) const [query, setQuery] = useState(""); const [highlightIds, setHighlightIds] = useState | null>(null); const [highlightLabel, setHighlightLabel] = useState(null); const load = async () => { setLoading(true); setError(null); try { const res = await getConstellation(); setData(res); setActiveFields(new Set(res.fields || [])); } catch (err: any) { setError(err.message || "Failed to load talent map"); } finally { setLoading(false); } }; useEffect(() => { load(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const points: Point[] = data?.points || []; const fields: string[] = data?.fields || []; // Gap-bridge: ?skills=kubernetes,terraform highlights matching candidates useEffect(() => { const skillsParam = searchParams.get("skills"); if (skillsParam && points.length) { const wanted = skillsParam.toLowerCase().split(",").map((s) => s.trim()).filter(Boolean); const ids = new Set( points .filter((p) => (p.top_skills || []).some((s) => wanted.includes(s.toLowerCase()))) .map((p) => p.id) ); setHighlightIds(ids); setHighlightLabel(`Gap match: ${wanted.join(", ")}`); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchParams, data]); const runSearch = async () => { if (!query.trim()) { clearHighlight(); return; } try { const res = await searchCandidates(query); setHighlightIds(new Set((res.results || []).map((r: any) => r.id))); setHighlightLabel(`Search: "${query}"`); } catch { /* ignore */ } }; const clearHighlight = () => { setHighlightIds(null); setHighlightLabel(null); setQuery(""); }; const isVisible = (p: Point) => activeFields.has(p.field) && p.score >= minScore; const isActive = (p: Point) => isVisible(p) && (highlightIds === null || highlightIds.has(p.id)); const toggleField = (f: string) => { setActiveFields((prev) => { const next = new Set(prev); if (next.has(f)) next.delete(f); else next.add(f); return next; }); }; const visibleCount = useMemo( () => points.filter(isVisible).length, // eslint-disable-next-line react-hooks/exhaustive-deps [points, activeFields, minScore] ); if (loading) { return (
Projecting candidates into vector space...
); } if (error) { return
{error}
; } if (!points.length) { return (

No candidates yet

Once candidates apply through the applicant portal, they'll appear here as a 3D semantic map you can explore.

); } return (

Talent Map

{data.layout === "semantic" ? "Candidates positioned by skill-embedding similarity (PCA). Outliers are the most unique profiles." : "Candidates grouped by field, height by overall score."}{" "} · {visibleCount} of {points.length} shown

setQuery(e.target.value)} onKeyDown={(e) => e.key === "Enter" && runSearch()} placeholder="Search e.g. react dev strong dsa" className="h-9 w-56 rounded-lg border bg-background pl-8 pr-3 text-sm" />
{points.map((p) => ( router.push(`/candidates/${pt.id}`)} /> ))} {/* Highlight banner (search / gap bridge) */} {highlightLabel && (
{highlightLabel} · {points.filter((p) => highlightIds?.has(p.id)).length} match
)} {/* Hover detail card */} {hover && (
{hover.name} {hover.field_label || fieldLabel(hover.field)}
{hover.score}
overall
{hover.match_score}
JD match
{hover.top_skills?.length > 0 && (
{hover.top_skills.slice(0, 5).map((s, i) => ( {s} ))}
)}

Click node to open full evaluation →

)} {/* Score filter */}
setMinScore(Number(e.target.value))} className="w-full mt-1 accent-primary" />
{/* Legend / field filters */}
Field — click to filter
{fields.map((f) => { const active = activeFields.has(f); return ( ); })}
{/* Count badge */}
{points.length} candidates
); }