diff --git a/apps/registry/app/similarity/page.js b/apps/registry/app/similarity/page.js new file mode 100644 index 0000000..590c9c2 --- /dev/null +++ b/apps/registry/app/similarity/page.js @@ -0,0 +1,228 @@ +'use client'; + +import React, { useEffect, useState, useCallback } from 'react'; +import dynamic from 'next/dynamic'; + +// Import ForceGraph dynamically to avoid SSR issues +const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), { + ssr: false, +}); + +// Helper function to compute cosine similarity +function cosineSimilarity(a, b) { + let dotProduct = 0; + let normA = 0; + let normB = 0; + for (let i = 0; i < a.length; i++) { + dotProduct += a[i] * b[i]; + normA += a[i] * a[i]; + normB += b[i] * b[i]; + } + return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)); +} + +export default function SimilarityPage() { + const [graphData, setGraphData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [highlightNodes, setHighlightNodes] = useState(new Set()); + const [highlightLinks, setHighlightLinks] = useState(new Set()); + const [hoverNode, setHoverNode] = useState(null); + + useEffect(() => { + async function fetchData() { + try { + const response = await fetch('/api/similarity'); + if (!response.ok) { + throw new Error('Failed to fetch data'); + } + const jsonData = await response.json(); + + // Group similar positions + const positionGroups = {}; + jsonData.forEach((item) => { + const position = item.position; + if (!positionGroups[position]) { + positionGroups[position] = []; + } + positionGroups[position].push(item); + }); + + // Create nodes and links + const nodes = []; + const links = []; + const similarityThreshold = 0.7; + + // Create nodes for each unique position + Object.entries(positionGroups).forEach(([position, items], index) => { + nodes.push({ + id: position, + group: index, + size: Math.log(items.length + 1) * 3, + count: items.length, + usernames: items.map((item) => item.username), + embeddings: items.map((item) => item.embedding), + color: `hsl(${Math.random() * 360}, 70%, 50%)`, + }); + }); + + // Create links between similar positions + for (let i = 0; i < nodes.length; i++) { + for (let j = i + 1; j < nodes.length; j++) { + // Calculate average similarity between groups + let totalSimilarity = 0; + let comparisons = 0; + + nodes[i].embeddings.forEach((emb1) => { + nodes[j].embeddings.forEach((emb2) => { + totalSimilarity += cosineSimilarity(emb1, emb2); + comparisons++; + }); + }); + + const avgSimilarity = totalSimilarity / comparisons; + + if (avgSimilarity > similarityThreshold) { + links.push({ + source: nodes[i].id, + target: nodes[j].id, + value: avgSimilarity, + }); + } + } + } + + setGraphData({ nodes, links }); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + } + + fetchData(); + }, []); + + const handleNodeHover = useCallback( + (node) => { + setHighlightNodes(new Set(node ? [node] : [])); + setHighlightLinks( + new Set( + graphData?.links.filter( + (link) => link.source.id === node?.id || link.target.id === node?.id + ) || [] + ) + ); + setHoverNode(node || null); + }, + [graphData] + ); + + if (loading) { + return ( +
Loading...
+Error: {error}
++ Explore similar positions in an interactive network. Each node + represents a position, with size indicating the number of resumes. + Connected positions are similar based on resume content. Hover to + highlight connections, click to view resumes. +
+{hoverNode.count} resumes
++ Click to view a sample resume +
+