/ index.html
index.html
1 <!DOCTYPE html> 2 <html lang="en"> 3 <head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>PodCrawl</title> 7 <style> 8 * { margin: 0; padding: 0; box-sizing: border-box; } 9 body { 10 background: #000; color: #e0e0e0; 11 font-family: 'TeX Gyre Termes', Georgia, serif; 12 overflow: hidden; width: 100vw; height: 100vh; 13 } 14 canvas { display: block; width: 100%; height: 100%; } 15 #url-input { 16 position: fixed; top: -100px; left: 0; opacity: 0; 17 width: 1px; height: 1px; 18 } 19 </style> 20 </head> 21 <body> 22 <canvas id="c"></canvas> 23 <input type="text" id="url-input" /> 24 <script> 25 // ============================================================ 26 // 1. CONSTANTS 27 // ============================================================ 28 const BLUE = '#00A2FF'; 29 const RED = '#FF644E'; 30 const WHITE = '#FFFFFF'; 31 const BLACK = '#000000'; 32 const FONT = "'TeX Gyre Termes', Georgia, serif"; 33 const NODE_RADIUS = 22; 34 const SPRING_LENGTH = 1000; 35 const REPULSION = 200; 36 const CENTER_GRAVITY = 0.01; 37 const HOST_GRAVITY_MULT = 3; 38 const DAMPING = 0.92; 39 const SETTLE_THRESHOLD = 0.01; 40 const NODE_ANIM_MS = 300; 41 const EDGE_ANIM_MS = 200; 42 const ZOOM_ANIM_MS = 600; 43 const BATCH_SIZE = 50; 44 const VESICA_MAX_BULGE_RATIO = 0.35; 45 const VESICA_MAX_BULGE_PX = 80; 46 47 // ============================================================ 48 // 2. DATA MODEL 49 // ============================================================ 50 const graph = { nodes: [], edges: [] }; 51 let nodeMap = {}; // name → node index 52 let edgeMap = {}; // "a|b" sorted → edge index 53 54 const camera = { x: 0, y: 0, zoom: 1 }; 55 const cameraTarget = { x: 0, y: 0, zoom: 1 }; 56 let cameraAnimStart = 0; 57 let cameraAnimDuration = 0; 58 let cameraAnimFrom = { x: 0, y: 0, zoom: 1 }; 59 let isCameraAnimating = false; 60 61 let viewMode = 'global'; // 'global' | 'edge-detail' 62 let selectedEdge = null; // edge index when in edge-detail 63 let podcastData = null; 64 let crawlState = 'idle'; // 'idle' | 'fetching' | 'parsing' | 'extracting' | 'done' 65 let hudText = ''; 66 let hudSubtext = ''; 67 let simRunning = false; 68 let showPrompt = true; 69 70 // Pan state 71 let isPanning = false; 72 let panStart = { x: 0, y: 0 }; 73 let panCameraStart = { x: 0, y: 0 }; 74 75 // ============================================================ 76 // 3. AI BRIDGE (unchanged protocol) 77 // ============================================================ 78 let aiBridgeReady = false; 79 80 window.addEventListener('message', (e) => { 81 if (e.data?.type === 'ai-bridge-ready') { 82 aiBridgeReady = true; 83 console.log('[PodCrawl] AI bridge ready (v' + e.data.version + ')'); 84 } 85 }); 86 87 window.parent.postMessage({ type: 'ai-bridge-probe' }, '*'); 88 setTimeout(() => { 89 if (!aiBridgeReady) { 90 console.log('[PodCrawl] No AI bridge — running in standalone mode'); 91 } 92 }, 300); 93 94 function requestAI(messages, complexity = 'trivial') { 95 return new Promise((resolve, reject) => { 96 if (!aiBridgeReady) { 97 reject(new Error('AI bridge not available')); 98 return; 99 } 100 const requestId = crypto.randomUUID(); 101 const handler = (e) => { 102 if (e.data?.requestId !== requestId) return; 103 window.removeEventListener('message', handler); 104 if (e.data.type === 'ai-inference-response') { 105 resolve(e.data); 106 } else if (e.data.type === 'ai-inference-error') { 107 reject(new Error(e.data.error)); 108 } 109 }; 110 window.addEventListener('message', handler); 111 window.parent.postMessage({ 112 type: 'ai-inference-request', requestId, messages, complexity 113 }, '*'); 114 setTimeout(() => { 115 window.removeEventListener('message', handler); 116 reject(new Error('AI inference timeout')); 117 }, 30000); 118 }); 119 } 120 121 // ============================================================ 122 // 4. RSS PARSING (unchanged) 123 // ============================================================ 124 function extractText(xml, tag) { 125 const re = new RegExp('<' + tag + '[^>]*>(?:<!\\[CDATA\\[)?([\\s\\S]*?)(?:\\]\\]>)?</' + tag + '>', 'i'); 126 const m = xml.match(re); 127 return m ? m[1].trim() : ''; 128 } 129 130 function extractAttr(xml, tag, attr) { 131 const re = new RegExp('<' + tag + '[^>]*' + attr + '="([^"]*)"', 'i'); 132 const m = xml.match(re); 133 return m ? m[1] : ''; 134 } 135 136 function decodeEntities(text) { 137 return text 138 .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') 139 .replace(/"/g, '"').replace(/'/g, "'").replace(/'/g, "'") 140 .replace(/ /g, ' ').replace(/&#\d+;/g, m => String.fromCharCode(parseInt(m.slice(2, -1)))); 141 } 142 143 function stripHtml(html) { 144 return decodeEntities( 145 html.replace(/<br\s*\/?>/gi, '\n').replace(/<\/p>/gi, '\n\n').replace(/<[^>]+>/g, '') 146 ).replace(/\n{3,}/g, '\n\n').trim(); 147 } 148 149 // ============================================================ 150 // 5. GRAPH BUILDER 151 // ============================================================ 152 function getOrCreateNode(name, isHost = false) { 153 if (name in nodeMap) { 154 const node = graph.nodes[nodeMap[name]]; 155 if (isHost) node.isHost = true; 156 return nodeMap[name]; 157 } 158 const idx = graph.nodes.length; 159 const angle = Math.random() * Math.PI * 2; 160 const dist = isHost ? 0 : 80 + Math.random() * 120; 161 graph.nodes.push({ 162 name, 163 isHost, 164 x: isHost ? 0 : Math.cos(angle) * dist, 165 y: isHost ? 0 : Math.sin(angle) * dist, 166 vx: 0, vy: 0, 167 radius: 0, 168 targetRadius: NODE_RADIUS, 169 birthTime: performance.now(), 170 episodes: [] 171 }); 172 nodeMap[name] = idx; 173 restartSim(); 174 return idx; 175 } 176 177 function edgeKey(a, b) { 178 return a < b ? a + '|' + b : b + '|' + a; 179 } 180 181 function getOrCreateEdge(aIdx, bIdx) { 182 const key = edgeKey(aIdx, bIdx); 183 if (key in edgeMap) return edgeMap[key]; 184 const idx = graph.edges.length; 185 graph.edges.push({ 186 a: Math.min(aIdx, bIdx), 187 b: Math.max(aIdx, bIdx), 188 episodes: [], // { index, title, from, to } — directed 189 birthTime: performance.now(), 190 opacity: 0 191 }); 192 edgeMap[key] = idx; 193 return idx; 194 } 195 196 function addPodcastToGraph(hostName, guestMap) { 197 // guestMap: { "Guest Name": [episodeIndex, ...], ... } 198 const hostIdx = getOrCreateNode(hostName, true); 199 200 const episodeGuests = {}; // episodeIndex → [guestIdx, ...] 201 202 for (const [guestName, indices] of Object.entries(guestMap)) { 203 const guestIdx = getOrCreateNode(guestName); 204 const edgeIdx = getOrCreateEdge(hostIdx, guestIdx); 205 const edge = graph.edges[edgeIdx]; 206 207 for (const epIdx of indices) { 208 const ep = podcastData.episodes[epIdx]; 209 const title = ep ? ep.title : 'Episode ' + (epIdx + 1); 210 edge.episodes.push({ 211 index: epIdx, 212 title, 213 from: hostIdx, 214 to: guestIdx 215 }); 216 if (!episodeGuests[epIdx]) episodeGuests[epIdx] = []; 217 episodeGuests[epIdx].push(guestIdx); 218 } 219 } 220 221 // Co-guest edges (undirected, shared episode) 222 for (const [epIdx, guests] of Object.entries(episodeGuests)) { 223 for (let i = 0; i < guests.length; i++) { 224 for (let j = i + 1; j < guests.length; j++) { 225 const edgeIdx = getOrCreateEdge(guests[i], guests[j]); 226 const ep = podcastData.episodes[parseInt(epIdx)]; 227 const title = ep ? ep.title : 'Episode ' + (parseInt(epIdx) + 1); 228 graph.edges[edgeIdx].episodes.push({ 229 index: parseInt(epIdx), 230 title, 231 from: guests[i], 232 to: guests[j] 233 }); 234 } 235 } 236 } 237 238 restartSim(); 239 } 240 241 // ============================================================ 242 // 6. FORCE LAYOUT 243 // ============================================================ 244 function restartSim() { 245 simRunning = true; 246 } 247 248 function stepForces() { 249 if (!simRunning || graph.nodes.length < 2) return; 250 251 const N = graph.nodes.length; 252 let totalKE = 0; 253 254 // Repulsion (N-body) 255 for (let i = 0; i < N; i++) { 256 for (let j = i + 1; j < N; j++) { 257 const a = graph.nodes[i], b = graph.nodes[j]; 258 let dx = b.x - a.x, dy = b.y - a.y; 259 let dist = Math.sqrt(dx * dx + dy * dy) || 1; 260 const force = REPULSION / (dist * dist); 261 const fx = (dx / dist) * force; 262 const fy = (dy / dist) * force; 263 a.vx -= fx; a.vy -= fy; 264 b.vx += fx; b.vy += fy; 265 } 266 } 267 268 // Edge springs 269 for (const edge of graph.edges) { 270 const a = graph.nodes[edge.a], b = graph.nodes[edge.b]; 271 let dx = b.x - a.x, dy = b.y - a.y; 272 let dist = Math.sqrt(dx * dx + dy * dy) || 1; 273 const displacement = dist - SPRING_LENGTH; 274 const force = displacement * 0.005; 275 const fx = (dx / dist) * force; 276 const fy = (dy / dist) * force; 277 a.vx += fx; a.vy += fy; 278 b.vx -= fx; b.vy -= fy; 279 } 280 281 // Center gravity 282 for (const node of graph.nodes) { 283 const grav = node.isHost ? CENTER_GRAVITY * HOST_GRAVITY_MULT : CENTER_GRAVITY; 284 node.vx -= node.x * grav; 285 node.vy -= node.y * grav; 286 } 287 288 // Integrate + damp 289 for (const node of graph.nodes) { 290 node.vx *= DAMPING; 291 node.vy *= DAMPING; 292 node.x += node.vx; 293 node.y += node.vy; 294 totalKE += node.vx * node.vx + node.vy * node.vy; 295 } 296 297 if (totalKE < SETTLE_THRESHOLD * N) { 298 simRunning = false; 299 } 300 } 301 302 // ============================================================ 303 // 7. CAMERA 304 // ============================================================ 305 function worldToScreen(wx, wy) { 306 const cx = canvas.width / 2; 307 const cy = canvas.height / 2; 308 return { 309 x: (wx - camera.x) * camera.zoom + cx, 310 y: (wy - camera.y) * camera.zoom + cy 311 }; 312 } 313 314 function screenToWorld(sx, sy) { 315 const cx = canvas.width / 2; 316 const cy = canvas.height / 2; 317 return { 318 x: (sx - cx) / camera.zoom + camera.x, 319 y: (sy - cy) / camera.zoom + camera.y 320 }; 321 } 322 323 function animateCamera(targetX, targetY, targetZoom, duration = ZOOM_ANIM_MS) { 324 cameraAnimFrom = { x: camera.x, y: camera.y, zoom: camera.zoom }; 325 cameraTarget.x = targetX; 326 cameraTarget.y = targetY; 327 cameraTarget.zoom = targetZoom; 328 cameraAnimStart = performance.now(); 329 cameraAnimDuration = duration; 330 isCameraAnimating = true; 331 } 332 333 function easeInOut(t) { 334 return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; 335 } 336 337 function updateCamera(now) { 338 if (!isCameraAnimating) return; 339 let t = (now - cameraAnimStart) / cameraAnimDuration; 340 if (t >= 1) { t = 1; isCameraAnimating = false; } 341 const e = easeInOut(t); 342 camera.x = cameraAnimFrom.x + (cameraTarget.x - cameraAnimFrom.x) * e; 343 camera.y = cameraAnimFrom.y + (cameraTarget.y - cameraAnimFrom.y) * e; 344 camera.zoom = cameraAnimFrom.zoom + (cameraTarget.zoom - cameraAnimFrom.zoom) * e; 345 } 346 347 // ============================================================ 348 // 8. VESICA PISCIS RENDERER 349 // ============================================================ 350 function computeVesicaCurves(p1, p2, n) { 351 if (n === 0) return []; 352 const mx = (p1.x + p2.x) / 2; 353 const my = (p1.y + p2.y) / 2; 354 const dx = p2.x - p1.x; 355 const dy = p2.y - p1.y; 356 const dist = Math.sqrt(dx * dx + dy * dy) || 1; 357 // Normal perpendicular 358 const nx = -dy / dist; 359 const ny = dx / dist; 360 const maxBulge = Math.min(VESICA_MAX_BULGE_RATIO * dist, VESICA_MAX_BULGE_PX); 361 362 const curves = []; 363 if (n === 1) { 364 curves.push({ cp: { x: mx, y: my } }); // straight through midpoint 365 } else { 366 for (let i = 0; i < n; i++) { 367 const t = n === 1 ? 0 : (i / (n - 1)) * 2 - 1; // -1 to 1 368 const bulge = t * maxBulge; 369 curves.push({ 370 cp: { x: mx + nx * bulge, y: my + ny * bulge } 371 }); 372 } 373 } 374 return curves; 375 } 376 377 // ============================================================ 378 // 9. CANVAS RENDERER 379 // ============================================================ 380 const canvas = document.getElementById('c'); 381 const ctx = canvas.getContext('2d'); 382 383 function resize() { 384 canvas.width = window.innerWidth * devicePixelRatio; 385 canvas.height = window.innerHeight * devicePixelRatio; 386 ctx.scale(devicePixelRatio, devicePixelRatio); 387 } 388 window.addEventListener('resize', resize); 389 resize(); 390 391 function render(now) { 392 const W = window.innerWidth; 393 const H = window.innerHeight; 394 395 ctx.clearRect(0, 0, W, H); 396 ctx.fillStyle = BLACK; 397 ctx.fillRect(0, 0, W, H); 398 399 // Prompt text when empty 400 if (showPrompt && graph.nodes.length === 0 && crawlState === 'idle') { 401 ctx.fillStyle = '#333'; 402 ctx.font = '16px ' + FONT; 403 ctx.textAlign = 'center'; 404 ctx.textBaseline = 'middle'; 405 ctx.fillText('Paste a podcast RSS URL', W / 2, H / 2 - 12); 406 ctx.font = '12px ' + FONT; 407 ctx.fillStyle = '#222'; 408 ctx.fillText('or drag and drop', W / 2, H / 2 + 14); 409 return; 410 } 411 412 // --- EDGES --- 413 if (viewMode === 'global') { 414 for (const edge of graph.edges) { 415 const a = graph.nodes[edge.a]; 416 const b = graph.nodes[edge.b]; 417 const sa = worldToScreen(a.x, a.y); 418 const sb = worldToScreen(b.x, b.y); 419 420 // Fade in 421 const age = now - edge.birthTime; 422 const opacity = Math.min(age / EDGE_ANIM_MS, 1); 423 edge.opacity = opacity; 424 425 // Thickness hints at episode count 426 const thickness = Math.min(1 + edge.episodes.length * 0.5, 5); 427 428 ctx.strokeStyle = BLUE; 429 ctx.globalAlpha = opacity * 0.7; 430 ctx.lineWidth = thickness; 431 ctx.beginPath(); 432 ctx.moveTo(sa.x, sa.y); 433 ctx.lineTo(sb.x, sb.y); 434 ctx.stroke(); 435 ctx.globalAlpha = 1; 436 } 437 } else if (viewMode === 'edge-detail' && selectedEdge !== null) { 438 const edge = graph.edges[selectedEdge]; 439 const a = graph.nodes[edge.a]; 440 const b = graph.nodes[edge.b]; 441 const sa = worldToScreen(a.x, a.y); 442 const sb = worldToScreen(b.x, b.y); 443 444 const curves = computeVesicaCurves(sa, sb, edge.episodes.length); 445 446 for (let i = 0; i < curves.length; i++) { 447 const ep = edge.episodes[i]; 448 ctx.strokeStyle = RED; 449 ctx.globalAlpha = 0.8; 450 ctx.lineWidth = 1.5; 451 ctx.beginPath(); 452 ctx.moveTo(sa.x, sa.y); 453 ctx.quadraticCurveTo(curves[i].cp.x, curves[i].cp.y, sb.x, sb.y); 454 ctx.stroke(); 455 456 // Episode title along curve 457 if (edge.episodes.length <= 12) { 458 const t = 0.5; 459 const labelX = (1 - t) * (1 - t) * sa.x + 2 * (1 - t) * t * curves[i].cp.x + t * t * sb.x; 460 const labelY = (1 - t) * (1 - t) * sa.y + 2 * (1 - t) * t * curves[i].cp.y + t * t * sb.y; 461 // Offset label away from curve 462 const midX = (sa.x + sb.x) / 2; 463 const midY = (sa.y + sb.y) / 2; 464 const offsetX = curves[i].cp.x - midX; 465 const offsetY = curves[i].cp.y - midY; 466 const offsetDist = Math.sqrt(offsetX * offsetX + offsetY * offsetY) || 1; 467 const labelOffX = (offsetX / offsetDist) * 12; 468 const labelOffY = (offsetY / offsetDist) * 12; 469 470 ctx.fillStyle = RED; 471 ctx.globalAlpha = 0.6; 472 ctx.font = '10px ' + FONT; 473 ctx.textAlign = 'center'; 474 ctx.textBaseline = 'middle'; 475 const maxLen = 40; 476 const title = ep.title.length > maxLen ? ep.title.slice(0, maxLen) + '...' : ep.title; 477 ctx.fillText(title, labelX + labelOffX, labelY + labelOffY); 478 } 479 ctx.globalAlpha = 1; 480 } 481 482 // Draw dim global edges for context 483 for (let i = 0; i < graph.edges.length; i++) { 484 if (i === selectedEdge) continue; 485 const e = graph.edges[i]; 486 const ea = graph.nodes[e.a]; 487 const eb = graph.nodes[e.b]; 488 const sea = worldToScreen(ea.x, ea.y); 489 const seb = worldToScreen(eb.x, eb.y); 490 ctx.strokeStyle = BLUE; 491 ctx.globalAlpha = 0.08; 492 ctx.lineWidth = 1; 493 ctx.beginPath(); 494 ctx.moveTo(sea.x, sea.y); 495 ctx.lineTo(seb.x, seb.y); 496 ctx.stroke(); 497 } 498 ctx.globalAlpha = 1; 499 } 500 501 // --- NODES --- 502 for (const node of graph.nodes) { 503 // Animate radius 504 const age = now - node.birthTime; 505 const animT = Math.min(age / NODE_ANIM_MS, 1); 506 node.radius = node.targetRadius * easeInOut(animT); 507 508 const s = worldToScreen(node.x, node.y); 509 const r = node.radius * camera.zoom; 510 if (r < 0.5) continue; 511 512 // Fill 513 ctx.fillStyle = BLACK; 514 ctx.beginPath(); 515 ctx.arc(s.x, s.y, r, 0, Math.PI * 2); 516 ctx.fill(); 517 518 // Border 519 ctx.strokeStyle = WHITE; 520 ctx.lineWidth = node.isHost ? 2.5 : 1.5; 521 ctx.beginPath(); 522 ctx.arc(s.x, s.y, r, 0, Math.PI * 2); 523 ctx.stroke(); 524 525 // Name 526 const fontSize = Math.max(10, Math.min(13, 13 * camera.zoom)); 527 ctx.fillStyle = WHITE; 528 ctx.font = fontSize + 'px ' + FONT; 529 ctx.textAlign = 'center'; 530 ctx.textBaseline = 'top'; 531 ctx.globalAlpha = animT; 532 ctx.fillText(node.name, s.x, s.y + r + 4); 533 ctx.globalAlpha = 1; 534 } 535 536 // --- HUD --- 537 if (hudText) { 538 ctx.fillStyle = WHITE; 539 ctx.font = '14px ' + FONT; 540 ctx.textAlign = 'left'; 541 ctx.textBaseline = 'top'; 542 ctx.fillText(hudText, 16, 16); 543 if (hudSubtext) { 544 ctx.fillStyle = '#666'; 545 ctx.font = '12px ' + FONT; 546 ctx.fillText(hudSubtext, 16, 36); 547 } 548 } 549 550 // Edge-detail mode hint 551 if (viewMode === 'edge-detail') { 552 ctx.fillStyle = '#444'; 553 ctx.font = '11px ' + FONT; 554 ctx.textAlign = 'center'; 555 ctx.textBaseline = 'bottom'; 556 ctx.fillText('click anywhere to return', W / 2, H - 16); 557 } 558 559 // Crawl status 560 if (crawlState !== 'idle' && crawlState !== 'done') { 561 ctx.fillStyle = BLUE; 562 ctx.font = '12px ' + FONT; 563 ctx.textAlign = 'right'; 564 ctx.textBaseline = 'top'; 565 const statusText = crawlState === 'fetching' ? 'Fetching RSS...' 566 : crawlState === 'parsing' ? 'Parsing episodes...' 567 : crawlState === 'extracting' ? 'Extracting guests...' 568 : ''; 569 ctx.fillText(statusText, W - 16, 16); 570 } 571 } 572 573 // ============================================================ 574 // 10. INTERACTION 575 // ============================================================ 576 function hitTestNode(sx, sy) { 577 for (let i = graph.nodes.length - 1; i >= 0; i--) { 578 const node = graph.nodes[i]; 579 const s = worldToScreen(node.x, node.y); 580 const r = node.radius * camera.zoom + 5; // generous hit area 581 const dx = sx - s.x, dy = sy - s.y; 582 if (dx * dx + dy * dy <= r * r) return i; 583 } 584 return -1; 585 } 586 587 function hitTestEdge(sx, sy) { 588 let bestDist = 15; // pixel threshold 589 let bestIdx = -1; 590 for (let i = 0; i < graph.edges.length; i++) { 591 const edge = graph.edges[i]; 592 const a = graph.nodes[edge.a]; 593 const b = graph.nodes[edge.b]; 594 const sa = worldToScreen(a.x, a.y); 595 const sb = worldToScreen(b.x, b.y); 596 // Point-to-segment distance 597 const dx = sb.x - sa.x, dy = sb.y - sa.y; 598 const lenSq = dx * dx + dy * dy; 599 if (lenSq === 0) continue; 600 let t = ((sx - sa.x) * dx + (sy - sa.y) * dy) / lenSq; 601 t = Math.max(0, Math.min(1, t)); 602 const px = sa.x + t * dx, py = sa.y + t * dy; 603 const dist = Math.sqrt((sx - px) * (sx - px) + (sy - py) * (sy - py)); 604 if (dist < bestDist) { 605 bestDist = dist; 606 bestIdx = i; 607 } 608 } 609 return bestIdx; 610 } 611 612 canvas.addEventListener('mousedown', (e) => { 613 if (e.button !== 0) return; 614 isPanning = true; 615 panStart = { x: e.clientX, y: e.clientY }; 616 panCameraStart = { x: camera.x, y: camera.y }; 617 }); 618 619 canvas.addEventListener('mousemove', (e) => { 620 if (!isPanning) return; 621 const dx = e.clientX - panStart.x; 622 const dy = e.clientY - panStart.y; 623 camera.x = panCameraStart.x - dx / camera.zoom; 624 camera.y = panCameraStart.y - dy / camera.zoom; 625 // Kill any camera animation when manually panning 626 isCameraAnimating = false; 627 }); 628 629 canvas.addEventListener('mouseup', (e) => { 630 if (!isPanning) return; 631 const dx = e.clientX - panStart.x; 632 const dy = e.clientY - panStart.y; 633 const wasDrag = Math.abs(dx) > 4 || Math.abs(dy) > 4; 634 isPanning = false; 635 636 if (wasDrag) return; // was a pan, not a click 637 638 // Click handling 639 if (viewMode === 'edge-detail') { 640 // Return to global view 641 viewMode = 'global'; 642 selectedEdge = null; 643 // Zoom out to fit graph 644 zoomToFitGraph(); 645 return; 646 } 647 648 // Check edge click first (so nodes take priority after) 649 const nodeIdx = hitTestNode(e.clientX, e.clientY); 650 if (nodeIdx >= 0) { 651 // Could do something on node click later 652 return; 653 } 654 655 const edgeIdx = hitTestEdge(e.clientX, e.clientY); 656 if (edgeIdx >= 0) { 657 zoomToEdge(edgeIdx); 658 return; 659 } 660 }); 661 662 canvas.addEventListener('wheel', (e) => { 663 e.preventDefault(); 664 const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; 665 const newZoom = Math.max(0.1, Math.min(10, camera.zoom * zoomFactor)); 666 667 // Zoom toward cursor 668 const wx = (e.clientX - window.innerWidth / 2) / camera.zoom + camera.x; 669 const wy = (e.clientY - window.innerHeight / 2) / camera.zoom + camera.y; 670 671 camera.zoom = newZoom; 672 camera.x = wx - (e.clientX - window.innerWidth / 2) / newZoom; 673 camera.y = wy - (e.clientY - window.innerHeight / 2) / newZoom; 674 675 isCameraAnimating = false; 676 }, { passive: false }); 677 678 function zoomToEdge(edgeIdx) { 679 const edge = graph.edges[edgeIdx]; 680 const a = graph.nodes[edge.a]; 681 const b = graph.nodes[edge.b]; 682 const midX = (a.x + b.x) / 2; 683 const midY = (a.y + b.y) / 2; 684 const dx = b.x - a.x, dy = b.y - a.y; 685 const dist = Math.sqrt(dx * dx + dy * dy) || 100; 686 687 // Zoom so edge fills ~60% of screen 688 const screenMin = Math.min(window.innerWidth, window.innerHeight); 689 const targetZoom = (screenMin * 0.6) / (dist + NODE_RADIUS * 4); 690 691 viewMode = 'edge-detail'; 692 selectedEdge = edgeIdx; 693 animateCamera(midX, midY, Math.min(targetZoom, 4)); 694 } 695 696 function zoomToFitGraph() { 697 if (graph.nodes.length === 0) return; 698 let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; 699 for (const node of graph.nodes) { 700 minX = Math.min(minX, node.x); 701 maxX = Math.max(maxX, node.x); 702 minY = Math.min(minY, node.y); 703 maxY = Math.max(maxY, node.y); 704 } 705 const cx = (minX + maxX) / 2; 706 const cy = (minY + maxY) / 2; 707 const spanX = (maxX - minX) + NODE_RADIUS * 4; 708 const spanY = (maxY - minY) + NODE_RADIUS * 4; 709 const zoomX = window.innerWidth / (spanX || 100); 710 const zoomY = window.innerHeight / (spanY || 100); 711 const zoom = Math.min(zoomX, zoomY, 2) * 0.85; 712 animateCamera(cx, cy, zoom); 713 } 714 715 // --- Paste / Drop URL input --- 716 const urlInput = document.getElementById('url-input'); 717 718 document.addEventListener('paste', (e) => { 719 if (crawlState !== 'idle') return; 720 const text = (e.clipboardData || window.clipboardData).getData('text').trim(); 721 if (text && (text.startsWith('http://') || text.startsWith('https://'))) { 722 e.preventDefault(); 723 crawl(text); 724 } 725 }); 726 727 document.addEventListener('dragover', (e) => { e.preventDefault(); }); 728 document.addEventListener('drop', (e) => { 729 e.preventDefault(); 730 if (crawlState !== 'idle') return; 731 const text = e.dataTransfer.getData('text/plain').trim(); 732 if (text && (text.startsWith('http://') || text.startsWith('https://'))) { 733 crawl(text); 734 } 735 }); 736 737 // Also allow keyboard focus and typing a URL 738 document.addEventListener('keydown', (e) => { 739 if (crawlState !== 'idle' || graph.nodes.length > 0) return; 740 if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) { 741 urlInput.style.position = 'fixed'; 742 urlInput.style.top = '50%'; 743 urlInput.style.left = '50%'; 744 urlInput.style.transform = 'translate(-50%, -50%)'; 745 urlInput.style.opacity = '1'; 746 urlInput.style.width = '400px'; 747 urlInput.style.height = 'auto'; 748 urlInput.style.background = '#111'; 749 urlInput.style.border = '1px solid #333'; 750 urlInput.style.borderRadius = '4px'; 751 urlInput.style.color = '#fff'; 752 urlInput.style.padding = '12px 16px'; 753 urlInput.style.fontSize = '14px'; 754 urlInput.style.fontFamily = 'monospace'; 755 urlInput.style.outline = 'none'; 756 urlInput.style.zIndex = '10'; 757 urlInput.placeholder = 'Paste podcast RSS URL...'; 758 urlInput.value = ''; 759 urlInput.focus(); 760 showPrompt = false; 761 } 762 }); 763 764 urlInput.addEventListener('keydown', (e) => { 765 if (e.key === 'Enter') { 766 const url = urlInput.value.trim(); 767 if (url) { 768 hideUrlInput(); 769 crawl(url); 770 } 771 } 772 if (e.key === 'Escape') { 773 hideUrlInput(); 774 showPrompt = true; 775 } 776 }); 777 778 urlInput.addEventListener('blur', () => { 779 if (!urlInput.value.trim()) { 780 hideUrlInput(); 781 showPrompt = true; 782 } 783 }); 784 785 function hideUrlInput() { 786 urlInput.style.position = 'fixed'; 787 urlInput.style.top = '-100px'; 788 urlInput.style.left = '0'; 789 urlInput.style.opacity = '0'; 790 urlInput.style.width = '1px'; 791 urlInput.style.height = '1px'; 792 urlInput.value = ''; 793 } 794 795 // ============================================================ 796 // 11. ANIMATION LOOP 797 // ============================================================ 798 function frame(now) { 799 stepForces(); 800 updateCamera(now); 801 render(now); 802 requestAnimationFrame(frame); 803 } 804 requestAnimationFrame(frame); 805 806 // ============================================================ 807 // 12. CRAWL FLOW 808 // ============================================================ 809 async function crawl(url) { 810 showPrompt = false; 811 crawlState = 'fetching'; 812 hudText = ''; 813 hudSubtext = ''; 814 815 // Reset graph 816 graph.nodes.length = 0; 817 graph.edges.length = 0; 818 nodeMap = {}; 819 edgeMap = {}; 820 821 // Step 1: Fetch RSS 822 let xml; 823 try { 824 const res = await fetch(url); 825 if (!res.ok) throw new Error('HTTP ' + res.status); 826 xml = await res.text(); 827 } catch (err) { 828 hudText = 'Failed to fetch RSS'; 829 hudSubtext = err.message; 830 crawlState = 'idle'; 831 showPrompt = true; 832 return; 833 } 834 835 // Step 2: Parse 836 crawlState = 'parsing'; 837 try { 838 const channelTitle = decodeEntities(extractText(xml, 'title')); 839 const channelAuthor = decodeEntities(extractText(xml, 'itunes:author')); 840 841 const items = xml.split('<item>').slice(1).map(chunk => '<item>' + chunk.split('</item>')[0] + '</item>'); 842 const episodes = items.map(item => ({ 843 title: decodeEntities(extractText(item, 'title')), 844 description: stripHtml(extractText(item, 'description') || extractText(item, 'itunes:summary')), 845 date: extractText(item, 'pubDate'), 846 duration: parseInt(extractText(item, 'itunes:duration')) || 0, 847 audioUrl: extractAttr(item, 'enclosure', 'url'), 848 })); 849 850 podcastData = { 851 title: channelTitle, 852 author: channelAuthor, 853 feedUrl: url, 854 episodeCount: episodes.length, 855 episodes, 856 }; 857 858 hudText = channelTitle; 859 hudSubtext = episodes.length + ' episodes'; 860 861 // Create host node 862 const hostName = channelAuthor || channelTitle; 863 getOrCreateNode(hostName, true); 864 865 } catch (err) { 866 hudText = 'Parse error'; 867 hudSubtext = err.message; 868 crawlState = 'idle'; 869 return; 870 } 871 872 // Step 3: AI Guest Extraction 873 if (aiBridgeReady) { 874 crawlState = 'extracting'; 875 try { 876 const allTitles = podcastData.episodes.map((ep, i) => i + '. ' + ep.title); 877 const hostName = podcastData.author || podcastData.title; 878 879 for (let i = 0; i < allTitles.length; i += BATCH_SIZE) { 880 const batch = allTitles.slice(i, i + BATCH_SIZE).join('\n'); 881 hudSubtext = 'Extracting guests... batch ' + (Math.floor(i / BATCH_SIZE) + 1) + '/' + Math.ceil(allTitles.length / BATCH_SIZE); 882 883 try { 884 const result = await requestAI([ 885 { 886 role: 'system', 887 content: 'Extract guest names from podcast episode titles. Return ONLY a JSON object mapping each unique guest name to an array of episode indices (the numbers before the dots). Format: {"Guest Name": [0, 3, 7], "Another Guest": [2]}. If an episode has no guest (solo episode, trailer, etc), skip it. Do not include the host. Return valid JSON only, no markdown.' 888 }, 889 { role: 'user', content: 'Host: ' + hostName + '\n\nEpisodes:\n' + batch } 890 ], 'trivial'); 891 892 let guestMap = {}; 893 try { 894 guestMap = JSON.parse(result.content.replace(/```json\n?|\n?```/g, '').trim()); 895 } catch { 896 // Fallback: try to extract as old format (flat array) 897 try { 898 const arr = JSON.parse(result.content.replace(/```json\n?|\n?```/g, '').trim()); 899 if (Array.isArray(arr)) { 900 // Can't map to episodes, create dummy indices 901 arr.forEach((name, idx) => { guestMap[name] = [i + idx]; }); 902 } 903 } catch { /* skip this batch */ } 904 } 905 906 if (typeof guestMap === 'object' && !Array.isArray(guestMap)) { 907 addPodcastToGraph(hostName, guestMap); 908 } 909 } catch (batchErr) { 910 console.warn('[PodCrawl] Batch error:', batchErr.message); 911 } 912 } 913 } catch (err) { 914 console.warn('[PodCrawl] Guest extraction failed:', err.message); 915 } 916 } else { 917 hudSubtext = podcastData.episodeCount + ' episodes (no AI bridge — guests not extracted)'; 918 } 919 920 crawlState = 'done'; 921 922 // Fit graph after a moment for forces to settle 923 setTimeout(() => zoomToFitGraph(), 500); 924 } 925 926 </script> 927 </body> 928 </html>