Update frontend/dist/assets/canvas.js

#3
by pmrinal2005 - opened
Files changed (1) hide show
  1. frontend/dist/assets/canvas.js +66 -136
frontend/dist/assets/canvas.js CHANGED
@@ -1,22 +1,15 @@
1
- /* ============================================================
2
- ELYSIUM β€” canvas.js
3
- Google-Maps-style infinite pan/zoom canvas with bioluminescent
4
- hypergraph rendering, particles, and a synced minimap.
5
-
6
- Critical behaviours fixed in this version:
7
- β€’ Click vs pan is detected with BOTH movement-distance AND
8
- elapsed-time thresholds AND a final hit-test on the up
9
- location (so even a slight finger jitter still opens
10
- the node detail).
11
- β€’ The minimap is fully interactive (click + drag to pan).
12
- β€’ Every public function is wrapped to be no-op if called
13
- before the canvas exists.
14
- ============================================================ */
15
  (() => {
16
- 'use strict';
17
-
18
  const canvas = document.getElementById('elysium-canvas');
19
- if (!canvas) { console.warn('[canvas] #elysium-canvas missing'); return; }
20
  const ctx = canvas.getContext('2d', { alpha: false });
21
 
22
  const DPR = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
@@ -39,17 +32,16 @@
39
  filterType: null,
40
  };
41
 
42
- // Seed CORE (always present)
43
  E.nodes.set('CORE', {
44
  node_id: 'CORE', x: 0, y: 0,
45
  type: 'CORE', label: 'ELYSIUM',
46
  radius: 36, color: '#ffb840',
47
  phase: 0, born: performance.now() - 1000,
48
  payload: { description: 'The seed of your civilization. Ask anything to grow new nodes.' },
49
- embedding_hint: 'The civilization core. All knowledge branches out from here.',
50
  });
51
 
52
- // ──────── COORDS ────────
53
  function clientToWorld(cx, cy) {
54
  return {
55
  x: (cx - innerWidth / 2) / E.cam.z + E.cam.x,
@@ -62,28 +54,19 @@
62
  let best = null, bestD = Infinity;
63
  E.nodes.forEach(n => {
64
  const d = Math.hypot(n.x - w.x, n.y - w.y);
65
- // Use a generous hit radius β€” at least 18 world-units to make
66
- // small nodes tappable on touch devices.
67
- const hitR = Math.max(18, n.radius * 1.35);
68
- if (d < hitR && d < bestD) { best = n; bestD = d; }
69
  });
70
  return best;
71
  }
72
 
73
  // ──────── PAN / TAP ────────
74
- let lastX = 0, lastY = 0;
75
- let downX = 0, downY = 0, downT = 0;
76
- let totalMove = 0;
77
- let downOnNode = null;
78
-
79
  canvas.addEventListener('pointerdown', e => {
80
  downX = e.clientX; downY = e.clientY; downT = performance.now();
81
- totalMove = 0;
82
- downOnNode = hitTest(e.clientX, e.clientY);
83
  E.cam.drag = true;
84
  canvas.classList.add('dragging');
85
  lastX = e.clientX; lastY = e.clientY;
86
- try { canvas.setPointerCapture(e.pointerId); } catch {}
87
  });
88
 
89
  canvas.addEventListener('pointermove', e => {
@@ -93,11 +76,8 @@
93
  canvas.style.cursor = n ? 'pointer' : '';
94
  return;
95
  }
96
- const dxRaw = e.clientX - lastX;
97
- const dyRaw = e.clientY - lastY;
98
- totalMove += Math.hypot(dxRaw, dyRaw);
99
- const dx = dxRaw / E.cam.z;
100
- const dy = dyRaw / E.cam.z;
101
  E.cam.x -= dx; E.cam.y -= dy;
102
  E.cam.vx = dx * 0.85; E.cam.vy = dy * 0.85;
103
  E.cam.tx = E.cam.x; E.cam.ty = E.cam.y;
@@ -105,31 +85,24 @@
105
  });
106
 
107
  function endPan(e) {
108
- const dt = performance.now() - downT;
 
109
  E.cam.drag = false;
110
  canvas.classList.remove('dragging');
111
- try { canvas.releasePointerCapture(e.pointerId); } catch {}
112
-
113
- // Click detection: total movement < 8px AND duration < 500ms.
114
- // Either down-or-up location may hit a node (handles touch jitter).
115
- const moved = totalMove < 8 && Math.hypot(e.clientX - downX, e.clientY - downY) < 8;
116
- if (moved && dt < 500) {
117
- const upNode = hitTest(e.clientX, e.clientY);
118
- const node = upNode || downOnNode;
119
- if (node) {
120
- if (typeof window.showNodeDetail === 'function') {
121
- window.showNodeDetail(node, e.clientX, e.clientY);
122
- }
123
  } else {
124
- if (typeof window.hideNodeDetail === 'function') window.hideNodeDetail();
125
  }
126
  }
127
- downOnNode = null;
128
  }
129
  canvas.addEventListener('pointerup', endPan);
130
  canvas.addEventListener('pointercancel', endPan);
131
 
132
- // Wheel zoom (zoom to cursor)
133
  canvas.addEventListener('wheel', e => {
134
  e.preventDefault();
135
  const factor = e.deltaY > 0 ? 0.9 : 1.1;
@@ -145,7 +118,7 @@
145
  E.cam.tx = w.x; E.cam.ty = w.y;
146
  });
147
 
148
- // Pinch zoom (touch)
149
  let touchDist = 0, touchMid = null;
150
  canvas.addEventListener('touchstart', e => {
151
  if (e.touches.length === 2) {
@@ -169,11 +142,10 @@
169
  }, { passive: false });
170
  canvas.addEventListener('touchend', () => { touchDist = 0; });
171
 
172
- // Zoom buttons (defensive)
173
- const _btn = (id, fn) => { const el = document.getElementById(id); if (el) el.onclick = fn; };
174
- _btn('z-in', () => E.cam.tz = Math.min(4, E.cam.z * 1.35));
175
- _btn('z-out', () => E.cam.tz = Math.max(0.15, E.cam.z / 1.35));
176
- _btn('z-fit', () => fitAll());
177
 
178
  function fitAll() {
179
  if (E.nodes.size === 0) return;
@@ -182,7 +154,7 @@
182
  mnX = Math.min(mnX, n.x); mnY = Math.min(mnY, n.y);
183
  mxX = Math.max(mxX, n.x); mxY = Math.max(mxY, n.y);
184
  });
185
- const pad = 240;
186
  E.cam.tx = (mnX + mxX) / 2;
187
  E.cam.ty = (mnY + mxY) / 2;
188
  if (mxX - mnX < 1 && mxY - mnY < 1) {
@@ -195,11 +167,11 @@
195
  }
196
  window.elysiumFitAll = fitAll;
197
 
198
- /* ──────── MINIMAP (interactive β€” click & drag to pan) ──────── */
199
  const mini = document.getElementById('minimap');
200
  const mctx = mini ? mini.getContext('2d') : null;
201
 
202
- function computeBounds(pad = 140) {
203
  let mnX = Infinity, mnY = Infinity, mxX = -Infinity, mxY = -Infinity;
204
  E.nodes.forEach(n => {
205
  mnX = Math.min(mnX, n.x); mnY = Math.min(mnY, n.y);
@@ -214,8 +186,7 @@
214
 
215
  function drawMinimap() {
216
  if (!mini || !mctx) return;
217
- const w = mini.clientWidth || 170;
218
- const h = mini.clientHeight || 108;
219
  if (mini.width !== w * DPR || mini.height !== h * DPR) {
220
  mini.width = w * DPR; mini.height = h * DPR;
221
  }
@@ -223,21 +194,16 @@
223
  mctx.scale(DPR, DPR);
224
  mctx.fillStyle = 'rgba(2,16,22,1)';
225
  mctx.fillRect(0, 0, w, h);
226
-
227
- // Grid
228
  mctx.strokeStyle = 'rgba(0,229,200,.08)';
229
  mctx.lineWidth = .5;
230
- for (let i = 0; i < w; i += 12) {
231
- mctx.beginPath(); mctx.moveTo(i, 0); mctx.lineTo(i, h); mctx.stroke();
232
- }
233
- for (let j = 0; j < h; j += 12) {
234
- mctx.beginPath(); mctx.moveTo(0, j); mctx.lineTo(w, j); mctx.stroke();
235
- }
236
 
 
237
  const { mnX, mnY, mxX, mxY } = computeBounds();
238
  const sx = w / (mxX - mnX), sy = h / (mxY - mnY);
239
 
240
- // Edges (faint)
241
  mctx.lineWidth = .6;
242
  E.edges.forEach(ed => {
243
  const s = E.nodes.get(ed.src), t = E.nodes.get(ed.dst);
@@ -249,83 +215,55 @@
249
  mctx.stroke();
250
  });
251
 
252
- // Nodes (glowing dots)
253
  E.nodes.forEach(n => {
254
  const px = (n.x - mnX) * sx, py = (n.y - mnY) * sy;
255
  mctx.shadowColor = n.color;
256
  mctx.shadowBlur = 6;
257
  mctx.fillStyle = n.color;
258
- const r = (n.type === 'CORE' || n.type === 'CIVILIZATION') ? 3.5
259
- : n.type === 'AGENT' ? 2.6 : 2.2;
260
  mctx.beginPath();
261
- mctx.arc(px, py, r, 0, Math.PI * 2);
262
  mctx.fill();
263
  });
264
  mctx.shadowBlur = 0;
265
 
266
- // Viewport rect
267
  const vx = (E.cam.x - innerWidth / 2 / E.cam.z - mnX) * sx;
268
  const vy = (E.cam.y - innerHeight / 2 / E.cam.z - mnY) * sy;
269
  const vw = innerWidth / E.cam.z * sx;
270
  const vh = innerHeight / E.cam.z * sy;
271
- mctx.strokeStyle = 'rgba(25,214,255,.95)';
272
  mctx.lineWidth = 1.2;
273
  mctx.setLineDash([3, 3]);
274
  mctx.strokeRect(vx, vy, vw, vh);
275
  mctx.setLineDash([]);
276
-
277
- // Selected node marker on minimap
278
- if (E.selected) {
279
- const px = (E.selected.x - mnX) * sx, py = (E.selected.y - mnY) * sy;
280
- mctx.strokeStyle = '#19d6ff';
281
- mctx.lineWidth = 1.5;
282
- mctx.beginPath(); mctx.arc(px, py, 5, 0, Math.PI * 2); mctx.stroke();
283
- }
284
  mctx.restore();
285
  }
286
 
287
- // Minimap drag / click pan
 
 
 
 
 
 
 
 
 
 
288
  if (mini) {
289
- let miniDrag = false;
290
- const miniPan = (e) => {
291
- const rect = mini.getBoundingClientRect();
292
- const fx = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
293
- const fy = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
294
- const { mnX, mnY, mxX, mxY } = computeBounds();
295
- E.cam.tx = mnX + fx * (mxX - mnX);
296
- E.cam.ty = mnY + fy * (mxY - mnY);
297
- };
298
- mini.addEventListener('pointerdown', e => {
299
- miniDrag = true; miniPan(e);
300
- try { mini.setPointerCapture(e.pointerId); } catch {}
301
- });
302
  mini.addEventListener('pointermove', e => { if (miniDrag) miniPan(e); });
303
- mini.addEventListener('pointerup', e => {
304
- miniDrag = false;
305
- try { mini.releasePointerCapture(e.pointerId); } catch {}
306
- });
307
  mini.addEventListener('pointercancel', () => { miniDrag = false; });
308
- // Mouse wheel on minimap β†’ zoom canvas around centre
309
- mini.addEventListener('wheel', e => {
310
- e.preventDefault();
311
- const factor = e.deltaY > 0 ? 0.9 : 1.1;
312
- E.cam.tz = Math.max(0.15, Math.min(4, E.cam.z * factor));
313
- }, { passive: false });
314
  }
315
 
316
- /* ──────── PUBLIC API ──────── */
317
  const SPAWN_RADIUS = 280;
318
 
319
  window.elysiumAddNode = function (node, parentHint) {
320
  if (!node || !node.node_id) return null;
321
- if (E.nodes.has(node.node_id)) {
322
- // Update label / payload if changed
323
- const existing = E.nodes.get(node.node_id);
324
- if (node.label) existing.label = node.label;
325
- if (node.payload) existing.payload = node.payload;
326
- if (node.embedding_hint) existing.embedding_hint = node.embedding_hint;
327
- return existing;
328
- }
329
  const parentId = parentHint || 'CORE';
330
  const parent = E.nodes.get(parentId) || E.nodes.get('CORE') || { x: 0, y: 0 };
331
  const idx = E.nodes.size;
@@ -345,14 +283,14 @@
345
  type,
346
  label: node.label || node.node_id,
347
  radius,
348
- color: (window.colorFor || (() => '#a76bff'))(type),
349
  phase: Math.random() * Math.PI * 2,
350
  born: performance.now(),
351
  payload: node.payload || {},
352
  embedding_hint: node.embedding_hint || '',
353
  };
354
  E.nodes.set(node.node_id, n);
355
- // Gentle auto-pan toward new node
356
  E.cam.tx = E.cam.x * 0.86 + tx * 0.14;
357
  E.cam.ty = E.cam.y * 0.86 + ty * 0.14;
358
  return n;
@@ -367,12 +305,12 @@
367
  E.edges.push({
368
  src: edge.source_node_id,
369
  dst: edge.target_node_id,
370
- type: edge.edge_type || 'GENERIC',
371
  weight: edge.weight ?? 0.5,
372
- color: edge.edge_type === 'CONFLICT' ? 'rgba(255,84,105,.6)' :
373
  edge.edge_type === 'COALITION' ? 'rgba(167,107,255,.65)' :
374
- edge.edge_type === 'CAUSAL' ? 'rgba(25,214,255,.55)' :
375
- edge.edge_type === 'SUPPORTS' ? 'rgba(92,255,174,.55)' :
376
  'rgba(255,184,64,.55)',
377
  born: performance.now(),
378
  });
@@ -402,11 +340,10 @@
402
  E.filterType = (E.filterType === type) ? null : type;
403
  };
404
 
405
- /* ──────── RENDER LOOP ──────── */
406
  function lerp(a, b, t) { return a + (b - a) * t; }
407
 
408
  function loop(t) {
409
- // Animate spawn-to-target
410
  E.nodes.forEach(n => {
411
  if (n.tx != null) {
412
  n.x = lerp(n.x, n.tx, 0.12);
@@ -417,7 +354,6 @@
417
  }
418
  });
419
 
420
- // Smooth camera
421
  E.cam.z = lerp(E.cam.z, E.cam.tz, 0.10);
422
  if (!E.cam.drag) {
423
  E.cam.x = lerp(E.cam.x, E.cam.tx, 0.12);
@@ -428,7 +364,6 @@
428
  if (Math.abs(E.cam.vy) < 0.05) E.cam.vy = 0;
429
  }
430
 
431
- // Background
432
  ctx.fillStyle = '#02080c';
433
  ctx.fillRect(0, 0, canvas.width, canvas.height);
434
  const grad = ctx.createRadialGradient(
@@ -441,14 +376,12 @@
441
 
442
  drawAmbient(ctx, t);
443
 
444
- // World transform
445
  ctx.save();
446
  ctx.scale(DPR, DPR);
447
  ctx.translate(innerWidth / 2, innerHeight / 2);
448
  ctx.scale(E.cam.z, E.cam.z);
449
  ctx.translate(-E.cam.x, -E.cam.y);
450
 
451
- // Edges
452
  E.edges.forEach(e => {
453
  const s = E.nodes.get(e.src), d = E.nodes.get(e.dst);
454
  if (!s || !d) return;
@@ -473,12 +406,10 @@
473
  });
474
  ctx.globalAlpha = 1;
475
 
476
- // Nodes
477
  E.nodes.forEach(n => {
478
  const dim = E.filterType && n.type !== E.filterType ? 0.22 : 1;
479
  ctx.globalAlpha = dim;
480
- try { window.drawNode(ctx, n, t, E.cam.z); }
481
- catch (err) { /* swallow per-node draw errors */ }
482
  });
483
  ctx.globalAlpha = 1;
484
  ctx.restore();
@@ -487,7 +418,6 @@
487
  requestAnimationFrame(loop);
488
  }
489
 
490
- // Ambient particles
491
  const PARTICLES = Array.from({ length: 60 }, () => ({
492
  x: Math.random() * innerWidth,
493
  y: Math.random() * innerHeight,
 
1
+ /* Google-Maps-style infinite-pan canvas with bioluminescent hypergraph.
2
+
3
+ FIXES vs previous build:
4
+ β€’ Minimap (TASK 4): fully interactive, drag-to-pan, click-to-pan,
5
+ viewport rectangle, redraws every frame so it stays in sync.
6
+ β€’ Node click (TASK 3): clicking ANY node opens the node-detail popover.
7
+ β€’ Hard null safety throughout. Defensive against missing DOM elements
8
+ so initialization failures cannot crash the loop.
9
+ */
 
 
 
 
 
10
  (() => {
 
 
11
  const canvas = document.getElementById('elysium-canvas');
12
+ if (!canvas) { console.error('elysium-canvas not found'); return; }
13
  const ctx = canvas.getContext('2d', { alpha: false });
14
 
15
  const DPR = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
 
32
  filterType: null,
33
  };
34
 
35
+ // Seed CORE
36
  E.nodes.set('CORE', {
37
  node_id: 'CORE', x: 0, y: 0,
38
  type: 'CORE', label: 'ELYSIUM',
39
  radius: 36, color: '#ffb840',
40
  phase: 0, born: performance.now() - 1000,
41
  payload: { description: 'The seed of your civilization. Ask anything to grow new nodes.' },
42
+ embedding_hint: 'The seed of your civilization. Ask anything to grow new nodes.',
43
  });
44
 
 
45
  function clientToWorld(cx, cy) {
46
  return {
47
  x: (cx - innerWidth / 2) / E.cam.z + E.cam.x,
 
54
  let best = null, bestD = Infinity;
55
  E.nodes.forEach(n => {
56
  const d = Math.hypot(n.x - w.x, n.y - w.y);
57
+ if (d < n.radius * 1.4 && d < bestD) { best = n; bestD = d; }
 
 
 
58
  });
59
  return best;
60
  }
61
 
62
  // ──────── PAN / TAP ────────
63
+ let lastX = 0, lastY = 0, downX = 0, downY = 0, downT = 0;
 
 
 
 
64
  canvas.addEventListener('pointerdown', e => {
65
  downX = e.clientX; downY = e.clientY; downT = performance.now();
 
 
66
  E.cam.drag = true;
67
  canvas.classList.add('dragging');
68
  lastX = e.clientX; lastY = e.clientY;
69
+ try { canvas.setPointerCapture(e.pointerId); } catch (_) {}
70
  });
71
 
72
  canvas.addEventListener('pointermove', e => {
 
76
  canvas.style.cursor = n ? 'pointer' : '';
77
  return;
78
  }
79
+ const dx = (e.clientX - lastX) / E.cam.z;
80
+ const dy = (e.clientY - lastY) / E.cam.z;
 
 
 
81
  E.cam.x -= dx; E.cam.y -= dy;
82
  E.cam.vx = dx * 0.85; E.cam.vy = dy * 0.85;
83
  E.cam.tx = E.cam.x; E.cam.ty = E.cam.y;
 
85
  });
86
 
87
  function endPan(e) {
88
+ const moved = Math.hypot(e.clientX - downX, e.clientY - downY);
89
+ const dt = performance.now() - downT;
90
  E.cam.drag = false;
91
  canvas.classList.remove('dragging');
92
+ try { canvas.releasePointerCapture(e.pointerId); } catch (_) {}
93
+ if (moved < 6 && dt < 350) {
94
+ const n = hitTest(e.clientX, e.clientY);
95
+ if (n) {
96
+ // TASK 3: open detail popover
97
+ if (window.showNodeDetail) window.showNodeDetail(n, e.clientX, e.clientY);
 
 
 
 
 
 
98
  } else {
99
+ if (window.hideNodeDetail) window.hideNodeDetail();
100
  }
101
  }
 
102
  }
103
  canvas.addEventListener('pointerup', endPan);
104
  canvas.addEventListener('pointercancel', endPan);
105
 
 
106
  canvas.addEventListener('wheel', e => {
107
  e.preventDefault();
108
  const factor = e.deltaY > 0 ? 0.9 : 1.1;
 
118
  E.cam.tx = w.x; E.cam.ty = w.y;
119
  });
120
 
121
+ // ──────── PINCH ZOOM ────────
122
  let touchDist = 0, touchMid = null;
123
  canvas.addEventListener('touchstart', e => {
124
  if (e.touches.length === 2) {
 
142
  }, { passive: false });
143
  canvas.addEventListener('touchend', () => { touchDist = 0; });
144
 
145
+ // ──────── ZOOM BTNS ────────
146
+ document.getElementById('z-in') ?.addEventListener('click', () => E.cam.tz = Math.min(4, E.cam.z * 1.35));
147
+ document.getElementById('z-out')?.addEventListener('click', () => E.cam.tz = Math.max(0.15, E.cam.z / 1.35));
148
+ document.getElementById('z-fit')?.addEventListener('click', fitAll);
 
149
 
150
  function fitAll() {
151
  if (E.nodes.size === 0) return;
 
154
  mnX = Math.min(mnX, n.x); mnY = Math.min(mnY, n.y);
155
  mxX = Math.max(mxX, n.x); mxY = Math.max(mxY, n.y);
156
  });
157
+ const pad = 220;
158
  E.cam.tx = (mnX + mxX) / 2;
159
  E.cam.ty = (mnY + mxY) / 2;
160
  if (mxX - mnX < 1 && mxY - mnY < 1) {
 
167
  }
168
  window.elysiumFitAll = fitAll;
169
 
170
+ // ──────── MINIMAP (TASK 4: civilization map) ────────
171
  const mini = document.getElementById('minimap');
172
  const mctx = mini ? mini.getContext('2d') : null;
173
 
174
+ function computeBounds(pad = 100) {
175
  let mnX = Infinity, mnY = Infinity, mxX = -Infinity, mxY = -Infinity;
176
  E.nodes.forEach(n => {
177
  mnX = Math.min(mnX, n.x); mnY = Math.min(mnY, n.y);
 
186
 
187
  function drawMinimap() {
188
  if (!mini || !mctx) return;
189
+ const w = mini.clientWidth, h = mini.clientHeight;
 
190
  if (mini.width !== w * DPR || mini.height !== h * DPR) {
191
  mini.width = w * DPR; mini.height = h * DPR;
192
  }
 
194
  mctx.scale(DPR, DPR);
195
  mctx.fillStyle = 'rgba(2,16,22,1)';
196
  mctx.fillRect(0, 0, w, h);
 
 
197
  mctx.strokeStyle = 'rgba(0,229,200,.08)';
198
  mctx.lineWidth = .5;
199
+ for (let i = 0; i < w; i += 12) { mctx.beginPath(); mctx.moveTo(i, 0); mctx.lineTo(i, h); mctx.stroke(); }
200
+ for (let j = 0; j < h; j += 12) { mctx.beginPath(); mctx.moveTo(0, j); mctx.lineTo(w, j); mctx.stroke(); }
 
 
 
 
201
 
202
+ if (E.nodes.size === 0) { mctx.restore(); return; }
203
  const { mnX, mnY, mxX, mxY } = computeBounds();
204
  const sx = w / (mxX - mnX), sy = h / (mxY - mnY);
205
 
206
+ // edges
207
  mctx.lineWidth = .6;
208
  E.edges.forEach(ed => {
209
  const s = E.nodes.get(ed.src), t = E.nodes.get(ed.dst);
 
215
  mctx.stroke();
216
  });
217
 
218
+ // nodes
219
  E.nodes.forEach(n => {
220
  const px = (n.x - mnX) * sx, py = (n.y - mnY) * sy;
221
  mctx.shadowColor = n.color;
222
  mctx.shadowBlur = 6;
223
  mctx.fillStyle = n.color;
 
 
224
  mctx.beginPath();
225
+ mctx.arc(px, py, n.type === 'CORE' ? 3.5 : 2.2, 0, Math.PI * 2);
226
  mctx.fill();
227
  });
228
  mctx.shadowBlur = 0;
229
 
230
+ // viewport rect
231
  const vx = (E.cam.x - innerWidth / 2 / E.cam.z - mnX) * sx;
232
  const vy = (E.cam.y - innerHeight / 2 / E.cam.z - mnY) * sy;
233
  const vw = innerWidth / E.cam.z * sx;
234
  const vh = innerHeight / E.cam.z * sy;
235
+ mctx.strokeStyle = 'rgba(25,214,255,.9)';
236
  mctx.lineWidth = 1.2;
237
  mctx.setLineDash([3, 3]);
238
  mctx.strokeRect(vx, vy, vw, vh);
239
  mctx.setLineDash([]);
 
 
 
 
 
 
 
 
240
  mctx.restore();
241
  }
242
 
243
+ // Minimap drag / click to pan
244
+ let miniDrag = false;
245
+ function miniPan(e) {
246
+ if (!mini) return;
247
+ const rect = mini.getBoundingClientRect();
248
+ const fx = (e.clientX - rect.left) / rect.width;
249
+ const fy = (e.clientY - rect.top) / rect.height;
250
+ const { mnX, mnY, mxX, mxY } = computeBounds();
251
+ E.cam.tx = mnX + fx * (mxX - mnX);
252
+ E.cam.ty = mnY + fy * (mxY - mnY);
253
+ }
254
  if (mini) {
255
+ mini.addEventListener('pointerdown', e => { miniDrag = true; miniPan(e); try { mini.setPointerCapture(e.pointerId); } catch(_){} });
 
 
 
 
 
 
 
 
 
 
 
 
256
  mini.addEventListener('pointermove', e => { if (miniDrag) miniPan(e); });
257
+ mini.addEventListener('pointerup', e => { miniDrag = false; try { mini.releasePointerCapture(e.pointerId); } catch(_){} });
 
 
 
258
  mini.addEventListener('pointercancel', () => { miniDrag = false; });
 
 
 
 
 
 
259
  }
260
 
261
+ // ──────── PUBLIC API ────────
262
  const SPAWN_RADIUS = 280;
263
 
264
  window.elysiumAddNode = function (node, parentHint) {
265
  if (!node || !node.node_id) return null;
266
+ if (E.nodes.has(node.node_id)) return E.nodes.get(node.node_id);
 
 
 
 
 
 
 
267
  const parentId = parentHint || 'CORE';
268
  const parent = E.nodes.get(parentId) || E.nodes.get('CORE') || { x: 0, y: 0 };
269
  const idx = E.nodes.size;
 
283
  type,
284
  label: node.label || node.node_id,
285
  radius,
286
+ color: window.colorFor(type),
287
  phase: Math.random() * Math.PI * 2,
288
  born: performance.now(),
289
  payload: node.payload || {},
290
  embedding_hint: node.embedding_hint || '',
291
  };
292
  E.nodes.set(node.node_id, n);
293
+ // gentle auto-pan toward new node
294
  E.cam.tx = E.cam.x * 0.86 + tx * 0.14;
295
  E.cam.ty = E.cam.y * 0.86 + ty * 0.14;
296
  return n;
 
305
  E.edges.push({
306
  src: edge.source_node_id,
307
  dst: edge.target_node_id,
308
+ type: edge.edge_type,
309
  weight: edge.weight ?? 0.5,
310
+ color: edge.edge_type === 'CONFLICT' ? 'rgba(255,84,105,.6)' :
311
  edge.edge_type === 'COALITION' ? 'rgba(167,107,255,.65)' :
312
+ edge.edge_type === 'CAUSAL' ? 'rgba(25,214,255,.55)' :
313
+ edge.edge_type === 'SUPPORTS' ? 'rgba(92,255,174,.55)' :
314
  'rgba(255,184,64,.55)',
315
  born: performance.now(),
316
  });
 
340
  E.filterType = (E.filterType === type) ? null : type;
341
  };
342
 
343
+ // ──────── RENDER LOOP ────────
344
  function lerp(a, b, t) { return a + (b - a) * t; }
345
 
346
  function loop(t) {
 
347
  E.nodes.forEach(n => {
348
  if (n.tx != null) {
349
  n.x = lerp(n.x, n.tx, 0.12);
 
354
  }
355
  });
356
 
 
357
  E.cam.z = lerp(E.cam.z, E.cam.tz, 0.10);
358
  if (!E.cam.drag) {
359
  E.cam.x = lerp(E.cam.x, E.cam.tx, 0.12);
 
364
  if (Math.abs(E.cam.vy) < 0.05) E.cam.vy = 0;
365
  }
366
 
 
367
  ctx.fillStyle = '#02080c';
368
  ctx.fillRect(0, 0, canvas.width, canvas.height);
369
  const grad = ctx.createRadialGradient(
 
376
 
377
  drawAmbient(ctx, t);
378
 
 
379
  ctx.save();
380
  ctx.scale(DPR, DPR);
381
  ctx.translate(innerWidth / 2, innerHeight / 2);
382
  ctx.scale(E.cam.z, E.cam.z);
383
  ctx.translate(-E.cam.x, -E.cam.y);
384
 
 
385
  E.edges.forEach(e => {
386
  const s = E.nodes.get(e.src), d = E.nodes.get(e.dst);
387
  if (!s || !d) return;
 
406
  });
407
  ctx.globalAlpha = 1;
408
 
 
409
  E.nodes.forEach(n => {
410
  const dim = E.filterType && n.type !== E.filterType ? 0.22 : 1;
411
  ctx.globalAlpha = dim;
412
+ window.drawNode(ctx, n, t, E.cam.z);
 
413
  });
414
  ctx.globalAlpha = 1;
415
  ctx.restore();
 
418
  requestAnimationFrame(loop);
419
  }
420
 
 
421
  const PARTICLES = Array.from({ length: 60 }, () => ({
422
  x: Math.random() * innerWidth,
423
  y: Math.random() * innerHeight,