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 ( +
+

Resume Position Network

+

Loading...

+
+ ); + } + + if (error) { + return ( +
+

Resume Position Network

+

Error: {error}

+
+ ); + } + + return ( +
+

Resume Position Network

+

+ 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. +

+
+ {graphData && ( + + highlightNodes.has(node) ? '#ff0000' : node.color + } + nodeCanvasObject={(node, ctx, globalScale) => { + // Draw node + const size = node.size * (4 / Math.max(1, globalScale)); + ctx.beginPath(); + ctx.arc(node.x, node.y, size, 0, 2 * Math.PI); + ctx.fillStyle = highlightNodes.has(node) ? '#ff0000' : node.color; + ctx.fill(); + + // Only draw label if node is highlighted + if (highlightNodes.has(node)) { + const label = node.id; + const fontSize = Math.max(14, size * 1.5); + ctx.font = `${fontSize}px Sans-Serif`; + const textWidth = ctx.measureText(label).width; + const bckgDimensions = [textWidth, fontSize].map( + (n) => n + fontSize * 0.2 + ); + + // Draw background for label + ctx.fillStyle = 'rgba(255, 255, 255, 0.9)'; + ctx.fillRect( + node.x - bckgDimensions[0] / 2, + node.y - bckgDimensions[1] * 2, + bckgDimensions[0], + bckgDimensions[1] + ); + + // Draw label + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = '#000'; + ctx.fillText(label, node.x, node.y - bckgDimensions[1] * 1.5); + + // Draw count + const countLabel = `(${node.count})`; + const smallerFont = fontSize * 0.7; + ctx.font = `${smallerFont}px Sans-Serif`; + ctx.fillText(countLabel, node.x, node.y - bckgDimensions[1]); + } + }} + nodeRelSize={6} + linkWidth={(link) => (highlightLinks.has(link) ? 2 : 1)} + linkColor={(link) => + highlightLinks.has(link) ? '#ff0000' : '#cccccc' + } + linkOpacity={0.3} + linkDirectionalParticles={4} + linkDirectionalParticleWidth={2} + onNodeHover={handleNodeHover} + onNodeClick={(node) => { + if (node.usernames && node.usernames.length > 0) { + window.open(`/${node.usernames[0]}`, '_blank'); + } + }} + enableNodeDrag={true} + cooldownTicks={100} + d3AlphaDecay={0.02} + d3VelocityDecay={0.3} + warmupTicks={100} + /> + )} + {hoverNode && ( +
+

{hoverNode.id}

+

{hoverNode.count} resumes

+

+ Click to view a sample resume +

+
+ )} +
+
+ ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79798b0..2fa5999 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22896,7 +22896,6 @@ packages: engines: {node: '>=0.6.0', teleport: '>=0.2.0'} deprecated: |- You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other. - (For a CapTP with native promises, see @endo/eventual-send and @endo/captp) /qr-image@3.2.0: @@ -27806,7 +27805,3 @@ packages: nice-color-palettes: 3.0.0 quad-indices: 2.0.1 dev: false - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false