/ 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(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
139      .replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&apos;/g, "'")
140      .replace(/&nbsp;/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>