ai-research-survey

Systematic scan of agentic development research. What's signal, what's noise.
git clone https://git.shiptheloop.com/ai-research-survey.git
Log | Files | Refs

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 }

Impressum · Datenschutz