PageSourceSearch

https://odysseus-lxcq.onrender.com/static/js/researchSynapse.js

js odysseus-lxcq.onrender.com collected 2026-10-03 11:44:07 UTC 9,281 bytes, 225 lines download raw bytes

1// static/js/researchSynapse.js
2//
3// Live SVG visualization of a deep-research run: central query node with
4// sub-question branches and source leaves that pop in as rounds progress.
5// Driven imperatively by chat.js when SSE research_progress events arrive.
6
7const SVG_NS = 'http://www.w3.org/2000/svg';
8
9const PHASE_LABEL = {
10  probing:   'verifying model',
11  planning:  'planning strategy',
12  searching: 'searching',
13  reading:   'reading sources',
14  analyzing: 'analyzing findings',
15  writing:   'writing report',
16  error:     'error',
17  done:      'complete',
18};
19
20function rand(a, b) { return Math.random() * (b - a) + a; }
21function pick(arr)  { return arr[Math.floor(Math.random() * arr.length)]; }
22
23export default function createResearchSynapse(container, opts = {}) {
24  const W = 520, H = 220;
25  const cx = W / 2, cy = H / 2;
26
27  const wrap = document.createElement('div');
28  wrap.className = 'research-synapse' + (opts.compact ? ' research-synapse-compact' : '');
29  wrap.innerHTML = `
30    <div class="rs-stage">
31      <svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet">
32        <g class="rs-edges"></g>
33        <g class="rs-nodes"></g>
34        <circle class="rs-pulse" cx="${cx}" cy="${cy}" r="6"></circle>
35      </svg>
36    </div>
37    <div class="rs-meta">
38      <span class="rs-status">starting…</span>
39      <span class="rs-sep">·</span>
40      <span class="rs-round">round <b>0</b></span>
41      <span class="rs-sep">·</span>
42      <span class="rs-sources"><b>0</b> sources</span>
43      <span class="rs-sep">·</span>
44      <span class="rs-timer">00:00</span>
45    </div>
46  `;
47  container.appendChild(wrap);
48
49  const svg     = wrap.querySelector('svg');
50  const edgesG  = wrap.querySelector('.rs-edges');
51  const nodesG  = wrap.querySelector('.rs-nodes');
52  const statusE = wrap.querySelector('.rs-status');
53  const roundE  = wrap.querySelector('.rs-round b');
54  const srcE    = wrap.querySelector('.rs-sources b');
55  const timerE  = wrap.querySelector('.rs-timer');
56
57  // ── root (query) ───────────────────────────────────────────────
58  const root = document.createElementNS(SVG_NS, 'circle');
59  root.setAttribute('cx', cx); root.setAttribute('cy', cy);
60  root.setAttribute('r', 11);
61  root.setAttribute('class', 'rs-node rs-node-root');
62  nodesG.appendChild(root);
63  const rootLabel = document.createElementNS(SVG_NS, 'text');
64  rootLabel.setAttribute('x', cx);
65  rootLabel.setAttribute('y', cy + 28);
66  rootLabel.setAttribute('text-anchor', 'middle');
67  rootLabel.setAttribute('class', 'rs-label');
68  rootLabel.textContent = _trunc(opts.query || 'query', 28);
69  nodesG.appendChild(rootLabel);
70
71  const subs = []; // { x, y, count }
72  let sourceCount = 0;
73  let lastRound = 0;
74  let completed = false;
75
76  // ── timer ──────────────────────────────────────────────────────
77  const startedAt = opts.startedAt || Date.now();
78  let timerInterval = setInterval(() => {
79    const elapsed = Math.floor((Date.now() - startedAt) / 1000);
80    timerE.textContent =
81      String(Math.floor(elapsed / 60)).padStart(2, '0') + ':' +
82      String(elapsed % 60).padStart(2, '0');
83  }, 1000);
84
85  // ── helpers ────────────────────────────────────────────────────
86  function _trunc(s, n) {
87    if (!s) return '';
88    s = String(s).replace(/\s+/g, ' ').trim();
89    return s.length > n ? s.slice(0, n - 1) + '…' : s;
90  }
91
92  function _addSub(label) {
93    if (subs.length >= 10) return; // cap visual clutter
94    // Spread subs around a circle; reserve slight offset so first sub doesn't
95    // sit directly above the root label.
96    const slot = subs.length;
97    const totalSlots = Math.max(6, subs.length + 1);
98    const angle = (slot / totalSlots) * Math.PI * 2 - Math.PI / 2;
99    const r = 78;
100    const x = cx + Math.cos(angle) * r;
101    const y = cy + Math.sin(angle) * r;
102
103    const edge = document.createElementNS(SVG_NS, 'line');
104    edge.setAttribute('x1', cx); edge.setAttribute('y1', cy);
105    edge.setAttribute('x2', x);  edge.setAttribute('y2', y);
106    edge.setAttribute('class', 'rs-edge rs-edge-firing');
107    edgesG.appendChild(edge);
108    setTimeout(() => edge.classList.remove('rs-edge-firing'), 1100);
109
110    const n = document.createElementNS(SVG_NS, 'circle');
111    n.setAttribute('cx', x); n.setAttribute('cy', y); n.setAttribute('r', 7);
112    n.setAttribute('class', 'rs-node rs-node-sub rs-node-new');
113    nodesG.appendChild(n);
114
115    if (label) {
116      const t = document.createElementNS(SVG_NS, 'text');
117      // Position label outside the circle on the same angle
118      const lx = cx + Math.cos(angle) * (r + 14);
119      const ly = cy + Math.sin(angle) * (r + 14);
120      t.setAttribute('x', lx); t.setAttribute('y', ly + 3);
121      t.setAttribute('text-anchor', Math.cos(angle) > 0.15 ? 'start' :
122                                    Math.cos(angle) < -0.15 ? 'end' : 'middle');
123      t.setAttribute('class', 'rs-label rs-label-sub');
124      t.textContent = _trunc(label, 14);
125      nodesG.appendChild(t);
126    }
127
128    subs.push({ x, y, count: 0 });
129  }
130
131  function _addLeaf() {
132    if (!subs.length) _addSub('');
133    // Always attach the new source to the CURRENT round's sub (i.e. the
134    // most-recently-added one). That gives a clean per-round attribution
135    // — 10 sources across 3 rounds ends up as 10/10/10 across the three
136    // sub-nodes, not a random scatter.
137    const sub = subs[subs.length - 1];
138    sub.count++;
139    // Lay leaves out in concentric arcs around the sub: 6 per ring fanned
140    // across ~140°, then a second ring further out for the next 6, etc.
141    // Keeps things readable past 10+ leaves per sub.
142    const baseAngle = Math.atan2(sub.y - cy, sub.x - cx);
143    const idx = sub.count - 1;
144    const perRing = 6;
145    const ring = Math.floor(idx / perRing);
146    const slot = idx % perRing;
147    const arcSpan = 2.4;
148    const angle = baseAngle + (slot - (perRing - 1) / 2) * (arcSpan / perRing) + rand(-0.05, 0.05);
149    const r = 26 + ring * 14 + rand(-1.5, 1.5);
150    const lx = sub.x + Math.cos(angle) * r;
151    const ly = sub.y + Math.sin(angle) * r;
152
153    const edge = document.createElementNS(SVG_NS, 'line');
154    edge.setAttribute('x1', sub.x); edge.setAttribute('y1', sub.y);
155    edge.setAttribute('x2', lx);    edge.setAttribute('y2', ly);
156    edge.setAttribute('class', 'rs-edge rs-edge-firing');
157    edgesG.appendChild(edge);
158    setTimeout(() => edge.classList.remove('rs-edge-firing'), 1100);
159
160    const leaf = document.createElementNS(SVG_NS, 'circle');
161    leaf.setAttribute('cx', lx); leaf.setAttribute('cy', ly);
162    leaf.setAttribute('r', 4);
163    leaf.setAttribute('class', 'rs-node rs-node-leaf rs-node-new');
164    nodesG.appendChild(leaf);
165  }
166
167  // ── public API ─────────────────────────────────────────────────
168  return {
169    element: wrap,
170
171    /** Reflect a phase change in the status text + side effects. */
172    setPhase(phase, extra = {}) {
173      if (completed) return;
174      const label = PHASE_LABEL[phase] || phase || '';
175      let txt = label;
176      if (phase === 'searching' && extra.queries) txt += ` · ${extra.queries} queries`;
177      else if (phase === 'reading' && extra.title) txt = `reading: ${_trunc(extra.title, 32)}`;
178      else if (phase === 'analyzing' && extra.total_findings) txt += ` · ${extra.total_findings} findings`;
179      statusE.textContent = txt;
180      // Visual cue per phase
181      if (phase === 'error') wrap.classList.add('rs-error');
182    },
183
184    /** Bump the round counter — adds a sub-question node when round grows. */
185    setRound(round, opts = {}) {
186      if (completed) return;
187      if (typeof round !== 'number' || round < 1) return;
188      if (round > lastRound) {
189        // Add one sub-question node per new round we see
190        for (let i = lastRound; i < round && subs.length < 10; i++) {
191          _addSub(opts.label || `R${i + 1}`);
192        }
193        lastRound = round;
194        roundE.textContent = round;
195      }
196    },
197
198    /** Update the total source count — adds leaf nodes for any new sources. */
199    setSourceCount(total) {
200      if (completed) return;
201      if (typeof total !== 'number' || total <= sourceCount) return;
202      const delta = Math.min(total - sourceCount, 6); // animate at most 6 at a time
203      for (let i = 0; i < delta; i++) {
204        // Stagger leaves slightly so they don't all pop on the same frame
205        setTimeout(_addLeaf, i * 110);
206      }
207      sourceCount = total;
208      srcE.textContent = total;
209    },
210
211    /** Mark the run as done — freezes the pulse and tints the graph green. */
212    complete() {
213      if (completed) return;
214      completed = true;
215      wrap.classList.add('rs-complete');
216      statusE.textContent = 'complete';
217      if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
218    },
219
220    destroy() {
221      if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
222      if (wrap.parentNode) wrap.parentNode.removeChild(wrap);
223    },
224  };
225}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.