network.ts (17922B)
1 import { loadNetwork, type NetNode } from '../data'; 2 import { navigate } from '../router'; 3 4 interface SimNode extends NetNode { 5 x: number; 6 y: number; 7 vx: number; 8 vy: number; 9 } 10 11 type EdgeMode = 'none' | 'quality'; 12 13 export async function renderNetwork(app: HTMLElement) { 14 app.innerHTML = '<div class="spinner"></div>'; 15 const { nodes, edges } = await loadNetwork(); 16 17 app.innerHTML = ` 18 <div class="filters" style="margin-bottom:1rem"> 19 <label style="font-size:0.8rem;color:var(--text-dim)">Min connections: <input type="number" id="net-min-conn" value="1" min="0" max="50" style="width:50px"></label> 20 <label style="font-size:0.8rem;color:var(--text-dim)">Scanned only: <input type="checkbox" id="net-scanned-only"></label> 21 <label style="font-size:0.8rem;color:var(--text-dim)">Edge color: 22 <select id="net-edge-mode"> 23 <option value="none">Default</option> 24 <option value="quality">Quality flow</option> 25 </select> 26 </label> 27 <span class="filter-count" id="net-count"></span> 28 <button id="net-reset-ego" style="display:none;font-size:0.75rem;padding:0.2rem 0.6rem;border:1px solid var(--border);border-radius:3px;background:none;color:var(--accent);cursor:pointer">Show all (Esc)</button> 29 </div> 30 <canvas id="network-canvas" width="1200" height="700"></canvas> 31 <div class="network-tooltip" id="net-tooltip" style="display:none"></div> 32 <div id="ego-panel" class="ego-panel" style="display:none"></div> 33 `; 34 35 const canvas = document.getElementById('network-canvas') as HTMLCanvasElement; 36 const ctx = canvas.getContext('2d')!; 37 const tooltip = document.getElementById('net-tooltip')!; 38 const egoPanel = document.getElementById('ego-panel')!; 39 const resetBtn = document.getElementById('net-reset-ego')!; 40 41 const adjCount = new Map<string, number>(); 42 for (const [s, t] of edges) { 43 adjCount.set(s, (adjCount.get(s) || 0) + 1); 44 adjCount.set(t, (adjCount.get(t) || 0) + 1); 45 } 46 47 let minConn = 1; 48 let scannedOnly = false; 49 let edgeMode: EdgeMode = 'none'; 50 let selectedNode: string | null = null; 51 52 function getFilteredGraph() { 53 const filteredNodes = nodes.filter(n => { 54 const conn = adjCount.get(n.id) || 0; 55 if (conn < minConn) return false; 56 if (scannedOnly && !n.has_scan) return false; 57 return true; 58 }); 59 const nodeSet = new Set(filteredNodes.map(n => n.id)); 60 const filteredEdges = edges.filter(([s, t]) => nodeSet.has(s) && nodeSet.has(t)); 61 return { nodes: filteredNodes, edges: filteredEdges }; 62 } 63 64 let simNodes: SimNode[] = []; 65 let simEdges: [number, number][] = []; 66 // Start zoomed out to see the full graph; center on the simulation origin (600,350) 67 let transform = { x: 200, y: 50, k: 0.5 }; 68 let animId = 0; 69 let hoveredNode: SimNode | null = null; 70 71 // Adjacency for ego mode 72 let simIncoming = new Map<number, number[]>(); // target_idx -> [source_idxs] 73 let simOutgoing = new Map<number, number[]>(); // source_idx -> [target_idxs] 74 let nodeIdxMap = new Map<string, number>(); // id -> simNodes index 75 76 function initSim() { 77 const { nodes: fNodes, edges: fEdges } = getFilteredGraph(); 78 79 const countEl = document.getElementById('net-count'); 80 if (countEl) countEl.textContent = `${fNodes.length} nodes, ${fEdges.length} edges`; 81 82 simNodes = fNodes.map(n => ({ 83 ...n, 84 x: (Math.random() - 0.5) * 800 + 600, 85 y: (Math.random() - 0.5) * 500 + 350, 86 vx: 0, vy: 0, 87 })); 88 89 nodeIdxMap = new Map(simNodes.map((n, i) => [n.id, i])); 90 simEdges = []; 91 simIncoming = new Map(); 92 simOutgoing = new Map(); 93 94 for (const [s, t] of fEdges) { 95 const si = nodeIdxMap.get(s), ti = nodeIdxMap.get(t); 96 if (si !== undefined && ti !== undefined) { 97 simEdges.push([si, ti]); 98 if (!simIncoming.has(ti)) simIncoming.set(ti, []); 99 simIncoming.get(ti)!.push(si); 100 if (!simOutgoing.has(si)) simOutgoing.set(si, []); 101 simOutgoing.get(si)!.push(ti); 102 } 103 } 104 105 selectedNode = null; 106 egoPanel.style.display = 'none'; 107 resetBtn.style.display = 'none'; 108 109 let alpha = 1; 110 cancelAnimationFrame(animId); 111 112 function tick() { 113 alpha *= 0.995; 114 if (alpha < 0.001) { draw(); return; } 115 116 for (let i = 0; i < simNodes.length; i++) { 117 let fx = 0, fy = 0; 118 for (let j = 0; j < simNodes.length; j++) { 119 if (i === j) continue; 120 const dx = simNodes[i].x - simNodes[j].x; 121 const dy = simNodes[i].y - simNodes[j].y; 122 const d2 = dx * dx + dy * dy + 1; 123 const f = 500 / d2; 124 fx += dx * f; 125 fy += dy * f; 126 } 127 fx += (600 - simNodes[i].x) * 0.01; 128 fy += (350 - simNodes[i].y) * 0.01; 129 simNodes[i].vx = (simNodes[i].vx + fx * alpha) * 0.6; 130 simNodes[i].vy = (simNodes[i].vy + fy * alpha) * 0.6; 131 } 132 133 for (const [si, ti] of simEdges) { 134 const dx = simNodes[ti].x - simNodes[si].x; 135 const dy = simNodes[ti].y - simNodes[si].y; 136 const d = Math.sqrt(dx * dx + dy * dy) || 1; 137 const f = (d - 80) * 0.005 * alpha; 138 const fx = dx / d * f; 139 const fy = dy / d * f; 140 simNodes[si].vx += fx; 141 simNodes[si].vy += fy; 142 simNodes[ti].vx -= fx; 143 simNodes[ti].vy -= fy; 144 } 145 146 for (const n of simNodes) { 147 n.x += n.vx; 148 n.y += n.vy; 149 } 150 151 draw(); 152 animId = requestAnimationFrame(tick); 153 } 154 155 tick(); 156 } 157 158 function scoreToColor(score: number | null): string { 159 if (score === null) return '#888'; 160 if (score < 30) return '#f06565'; 161 if (score < 50) return '#f0c050'; 162 if (score < 70) return '#6c8cff'; 163 return '#3dd68c'; 164 } 165 166 function getEdgeColor(): string { 167 return getComputedStyle(document.documentElement).getPropertyValue('--net-edge').trim(); 168 } 169 170 function qualityEdgeColor(si: number, ti: number): string { 171 const ss = simNodes[si].score; 172 const ts = simNodes[ti].score; 173 if (ss === null || ts === null) return 'rgba(100,100,100,0.2)'; 174 if (ss >= 50 && ts >= 50) return 'rgba(61, 214, 140, 0.5)'; // green: good cites good 175 if (ss < 50 && ts >= 50) return 'rgba(240, 101, 101, 0.5)'; // red: weak cites good (free-riding) 176 if (ts < 50) return 'rgba(240, 192, 80, 0.3)'; // yellow: citing weak work 177 return 'rgba(100,100,100,0.2)'; 178 } 179 180 function drawArrow(x1: number, y1: number, x2: number, y2: number, nodeR: number) { 181 const dx = x2 - x1, dy = y2 - y1; 182 const d = Math.sqrt(dx * dx + dy * dy) || 1; 183 const ux = dx / d, uy = dy / d; 184 // Arrow tip at edge of target node 185 const tipX = x2 - ux * nodeR; 186 const tipY = y2 - uy * nodeR; 187 const size = 4; 188 ctx.beginPath(); 189 ctx.moveTo(tipX, tipY); 190 ctx.lineTo(tipX - ux * size - uy * size * 0.5, tipY - uy * size + ux * size * 0.5); 191 ctx.lineTo(tipX - ux * size + uy * size * 0.5, tipY - uy * size - ux * size * 0.5); 192 ctx.closePath(); 193 ctx.fill(); 194 } 195 196 const selectedIdx = () => selectedNode ? nodeIdxMap.get(selectedNode) : undefined; 197 198 function isEgoVisible(ni: number): boolean { 199 const sel = selectedIdx(); 200 if (sel === undefined) return true; 201 if (ni === sel) return true; 202 const incoming = simIncoming.get(sel) || []; 203 const outgoing = simOutgoing.get(sel) || []; 204 return incoming.includes(ni) || outgoing.includes(ni); 205 } 206 207 function isEgoEdge(si: number, ti: number): boolean { 208 const sel = selectedIdx(); 209 if (sel === undefined) return true; 210 return si === sel || ti === sel; 211 } 212 213 function draw() { 214 const w = canvas.width, h = canvas.height; 215 ctx.clearRect(0, 0, w, h); 216 217 const bgColor = getComputedStyle(document.documentElement).getPropertyValue('--net-bg').trim(); 218 ctx.fillStyle = bgColor; 219 ctx.fillRect(0, 0, w, h); 220 221 ctx.save(); 222 ctx.translate(transform.x, transform.y); 223 ctx.scale(transform.k, transform.k); 224 225 const sel = selectedIdx(); 226 const hoverIdx = hoveredNode ? nodeIdxMap.get(hoveredNode.id) : undefined; 227 const showArrows = transform.k > 0.8; 228 229 // Edges 230 const defaultEdgeColor = getEdgeColor(); 231 for (const [si, ti] of simEdges) { 232 const egoVis = isEgoEdge(si, ti); 233 if (sel !== undefined && !egoVis) continue; // hide non-ego edges entirely 234 235 let color: string; 236 let width = 1.5; 237 238 if (edgeMode === 'quality') { 239 color = qualityEdgeColor(si, ti); 240 width = 1.8; 241 } else if (hoverIdx !== undefined && (si === hoverIdx || ti === hoverIdx)) { 242 // Directional highlight on hover 243 color = si === hoverIdx ? 'rgba(240, 160, 50, 0.8)' : 'rgba(100, 160, 255, 0.8)'; 244 width = 2.5; 245 } else { 246 color = sel !== undefined ? defaultEdgeColor.replace(/[\d.]+\)$/, '0.6)') : defaultEdgeColor; 247 } 248 249 ctx.strokeStyle = color; 250 ctx.lineWidth = width; 251 ctx.beginPath(); 252 ctx.moveTo(simNodes[si].x, simNodes[si].y); 253 ctx.lineTo(simNodes[ti].x, simNodes[ti].y); 254 ctx.stroke(); 255 256 // Arrowheads when zoomed in or in ego mode 257 if (showArrows || sel !== undefined) { 258 const tr = Math.max(3, Math.min(10, 3 + simNodes[ti].in_degree * 0.6)); 259 ctx.fillStyle = color; 260 drawArrow(simNodes[si].x, simNodes[si].y, simNodes[ti].x, simNodes[ti].y, tr); 261 } 262 } 263 264 // Nodes 265 for (let i = 0; i < simNodes.length; i++) { 266 const n = simNodes[i]; 267 const visible = isEgoVisible(i); 268 if (sel !== undefined && !visible) { 269 // Ghost dimmed node 270 ctx.beginPath(); 271 ctx.arc(n.x, n.y, 2, 0, Math.PI * 2); 272 ctx.fillStyle = 'rgba(100,100,100,0.1)'; 273 ctx.fill(); 274 continue; 275 } 276 277 const r = Math.max(3, Math.min(10, 3 + n.in_degree * 0.6)); 278 const isSelected = sel !== undefined && i === sel; 279 280 ctx.beginPath(); 281 ctx.arc(n.x, n.y, isSelected ? r + 3 : r, 0, Math.PI * 2); 282 ctx.fillStyle = scoreToColor(n.score); 283 ctx.globalAlpha = (sel !== undefined && !isSelected) ? 0.85 : (visible ? 0.8 : 0.15); 284 ctx.fill(); 285 ctx.globalAlpha = 1; 286 287 if (isSelected) { 288 ctx.strokeStyle = '#fff'; 289 ctx.lineWidth = 2; 290 ctx.stroke(); 291 // Label 292 ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--text').trim(); 293 ctx.font = '11px sans-serif'; 294 ctx.textAlign = 'center'; 295 ctx.fillText(n.title.length > 40 ? n.title.slice(0, 37) + '...' : n.title, n.x, n.y - r - 8); 296 } else { 297 ctx.strokeStyle = 'rgba(0,0,0,0.2)'; 298 ctx.lineWidth = 0.5; 299 ctx.stroke(); 300 } 301 } 302 303 ctx.restore(); 304 } 305 306 // --- Mouse interaction --- 307 function canvasCoords(e: MouseEvent): { cx: number; cy: number } { 308 const rect = canvas.getBoundingClientRect(); 309 const scaleX = canvas.width / rect.width; 310 const scaleY = canvas.height / rect.height; 311 return { 312 cx: (e.clientX - rect.left) * scaleX, 313 cy: (e.clientY - rect.top) * scaleY, 314 }; 315 } 316 317 function simCoords(e: MouseEvent): { mx: number; my: number } { 318 const { cx, cy } = canvasCoords(e); 319 return { 320 mx: (cx - transform.x) / transform.k, 321 my: (cy - transform.y) / transform.k, 322 }; 323 } 324 325 function findNearest(e: MouseEvent, maxDist = 25): SimNode | null { 326 const { mx, my } = simCoords(e); 327 let closest: SimNode | null = null; 328 let closestDist = maxDist; 329 for (const n of simNodes) { 330 const d = Math.sqrt((n.x - mx) ** 2 + (n.y - my) ** 2); 331 if (d < closestDist) { closest = n; closestDist = d; } 332 } 333 return closest; 334 } 335 336 let dragging = false; 337 let dragMoved = false; 338 let lastX = 0, lastY = 0; 339 340 canvas.addEventListener('mousedown', e => { 341 dragging = true; 342 dragMoved = false; 343 lastX = e.clientX; lastY = e.clientY; 344 }); 345 346 canvas.addEventListener('mousemove', e => { 347 if (dragging) { 348 const dx = e.clientX - lastX, dy = e.clientY - lastY; 349 if (Math.abs(dx) > 2 || Math.abs(dy) > 2) dragMoved = true; 350 const rect = canvas.getBoundingClientRect(); 351 const scaleX = canvas.width / rect.width; 352 const scaleY = canvas.height / rect.height; 353 transform.x += dx * scaleX; 354 transform.y += dy * scaleY; 355 lastX = e.clientX; lastY = e.clientY; 356 draw(); 357 } 358 359 const nearest = findNearest(e); 360 hoveredNode = nearest; 361 draw(); // redraw for hover highlight 362 363 if (nearest) { 364 canvas.style.cursor = 'pointer'; 365 tooltip.style.display = 'block'; 366 tooltip.style.left = e.clientX + 14 + 'px'; 367 tooltip.style.top = e.clientY + 14 + 'px'; 368 tooltip.innerHTML = `<strong>${nearest.title}</strong><br> 369 ${nearest.score != null ? `Score: ${nearest.score}%` : 'Not scanned'}<br> 370 Cites: ${nearest.out_degree} · Cited by: ${nearest.in_degree} 371 ${nearest.has_scan ? '<br><em style="color:var(--text-dim)">Click to explore · Dbl-click for detail</em>' : ''}`; 372 } else { 373 canvas.style.cursor = dragging ? 'grabbing' : 'grab'; 374 tooltip.style.display = 'none'; 375 } 376 }); 377 378 canvas.addEventListener('mouseup', () => dragging = false); 379 canvas.addEventListener('mouseleave', () => { 380 dragging = false; 381 tooltip.style.display = 'none'; 382 hoveredNode = null; 383 draw(); 384 }); 385 386 canvas.addEventListener('wheel', e => { 387 e.preventDefault(); 388 const factor = e.deltaY > 0 ? 0.9 : 1.1; 389 const { cx, cy } = canvasCoords(e); 390 transform.x = cx - (cx - transform.x) * factor; 391 transform.y = cy - (cy - transform.y) * factor; 392 transform.k *= factor; 393 draw(); 394 }, { passive: false }); 395 396 // Single click = ego mode 397 canvas.addEventListener('click', e => { 398 if (dragMoved) return; 399 const nearest = findNearest(e); 400 if (nearest) { 401 enterEgoMode(nearest.id); 402 } else { 403 exitEgoMode(); 404 } 405 }); 406 407 // Double click = navigate to detail 408 canvas.addEventListener('dblclick', e => { 409 const nearest = findNearest(e); 410 if (nearest && nearest.has_scan) { 411 navigate(`/paper/${nearest.id}`); 412 } 413 }); 414 415 function enterEgoMode(nodeId: string) { 416 selectedNode = nodeId; 417 resetBtn.style.display = 'inline-block'; 418 419 const idx = nodeIdxMap.get(nodeId); 420 if (idx === undefined) return; 421 422 const node = simNodes[idx]; 423 const incoming = (simIncoming.get(idx) || []).map(i => simNodes[i]); 424 const outgoing = (simOutgoing.get(idx) || []).map(i => simNodes[i]); 425 426 const inScores = incoming.filter(n => n.score !== null).map(n => n.score!); 427 const outScores = outgoing.filter(n => n.score !== null).map(n => n.score!); 428 const inMean = inScores.length ? (inScores.reduce((a, b) => a + b, 0) / inScores.length).toFixed(1) : '?'; 429 const outMean = outScores.length ? (outScores.reduce((a, b) => a + b, 0) / outScores.length).toFixed(1) : '?'; 430 431 egoPanel.style.display = 'block'; 432 egoPanel.innerHTML = ` 433 <div class="ego-header"> 434 <strong>${node.title}</strong> 435 ${node.score != null ? ` <span style="color:${scoreToColor(node.score)};font-family:var(--font)">${node.score}%</span>` : ' <span style="color:var(--gray)">not scanned</span>'} 436 ${node.has_scan ? ` <a href="#/paper/${node.id}" style="color:var(--accent);font-size:0.8rem">view detail</a>` : ''} 437 </div> 438 <div class="ego-stats"> 439 <span style="color:rgba(100,160,255,0.9)">Cited by ${incoming.length} (mean ${inMean}%)</span> · 440 <span style="color:rgba(240,160,50,0.9)">Cites ${outgoing.length} (mean ${outMean}%)</span> 441 </div> 442 ${incoming.length ? `<div class="ego-list"><div class="ego-list-label" style="color:rgba(100,160,255,0.9)">Cited by</div> 443 ${incoming.sort((a, b) => (b.score ?? -1) - (a.score ?? -1)).slice(0, 15).map(n => 444 `<div class="ego-list-item" data-id="${n.id}"><span style="color:${scoreToColor(n.score)};font-family:var(--font);font-size:0.75rem;width:35px;display:inline-block">${n.score != null ? n.score + '%' : '--'}</span> ${n.title.length > 50 ? n.title.slice(0, 47) + '...' : n.title}</div>` 445 ).join('')}${incoming.length > 15 ? `<div style="color:var(--text-dim);font-size:0.75rem">+ ${incoming.length - 15} more</div>` : ''} 446 </div>` : ''} 447 ${outgoing.length ? `<div class="ego-list"><div class="ego-list-label" style="color:rgba(240,160,50,0.9)">Cites</div> 448 ${outgoing.sort((a, b) => (b.score ?? -1) - (a.score ?? -1)).slice(0, 15).map(n => 449 `<div class="ego-list-item" data-id="${n.id}"><span style="color:${scoreToColor(n.score)};font-family:var(--font);font-size:0.75rem;width:35px;display:inline-block">${n.score != null ? n.score + '%' : '--'}</span> ${n.title.length > 50 ? n.title.slice(0, 47) + '...' : n.title}</div>` 450 ).join('')}${outgoing.length > 15 ? `<div style="color:var(--text-dim);font-size:0.75rem">+ ${outgoing.length - 15} more</div>` : ''} 451 </div>` : ''} 452 `; 453 454 // Click on ego list items to switch ego focus 455 egoPanel.querySelectorAll('.ego-list-item').forEach(el => { 456 el.addEventListener('click', () => { 457 const id = (el as HTMLElement).dataset.id; 458 if (id) enterEgoMode(id); 459 }); 460 }); 461 462 draw(); 463 } 464 465 function exitEgoMode() { 466 selectedNode = null; 467 egoPanel.style.display = 'none'; 468 resetBtn.style.display = 'none'; 469 draw(); 470 } 471 472 resetBtn.addEventListener('click', exitEgoMode); 473 document.addEventListener('keydown', e => { 474 if (e.key === 'Escape' && selectedNode) exitEgoMode(); 475 }); 476 477 // Filter controls 478 document.getElementById('net-min-conn')?.addEventListener('input', e => { 479 minConn = parseInt((e.target as HTMLInputElement).value) || 0; 480 initSim(); 481 }); 482 document.getElementById('net-scanned-only')?.addEventListener('change', e => { 483 scannedOnly = (e.target as HTMLInputElement).checked; 484 initSim(); 485 }); 486 document.getElementById('net-edge-mode')?.addEventListener('change', e => { 487 edgeMode = (e.target as HTMLSelectElement).value as EdgeMode; 488 draw(); 489 }); 490 491 initSim(); 492 }