Upload folder using huggingface_hub
Browse files- frontend/dist/assets/api.js +44 -11
- frontend/dist/assets/boot.js +306 -148
- frontend/dist/assets/canvas.js +144 -72
- frontend/dist/assets/council.js +380 -260
- frontend/dist/assets/elysium.css +40 -34
- frontend/dist/assets/nodes.js +112 -119
- frontend/dist/index.html +1 -1
frontend/dist/assets/api.js
CHANGED
|
@@ -1,20 +1,53 @@
|
|
| 1 |
-
/*
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
window.ElysiumAPI = {
|
| 4 |
async turn(text, files) {
|
| 5 |
const fd = new FormData();
|
| 6 |
fd.append('user_text', text || '');
|
| 7 |
-
(files || []).slice(0, 2).forEach(f => fd.append('attachments', f));
|
| 8 |
const r = await fetch('/api/turn', { method: 'POST', body: fd });
|
| 9 |
-
if (!r.ok)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
return r.json();
|
| 11 |
},
|
| 12 |
-
|
| 13 |
-
async
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
},
|
| 19 |
-
async reset() { return (await fetch('/api/reset',{method:'POST'})).json(); },
|
| 20 |
};
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
Elysium API client — talks to FastAPI routes on gr.Server.
|
| 3 |
+
- Sends multipart/form-data with optional attachments
|
| 4 |
+
- Hard-caps at 2 files (server also enforces)
|
| 5 |
+
- Surfaces friendly errors so boot.js can toast them
|
| 6 |
+
============================================================ */
|
| 7 |
window.ElysiumAPI = {
|
| 8 |
async turn(text, files) {
|
| 9 |
const fd = new FormData();
|
| 10 |
fd.append('user_text', text || '');
|
| 11 |
+
(files || []).slice(0, 2).forEach(f => fd.append('attachments', f, f.name));
|
| 12 |
const r = await fetch('/api/turn', { method: 'POST', body: fd });
|
| 13 |
+
if (!r.ok) {
|
| 14 |
+
let detail = '';
|
| 15 |
+
try { detail = (await r.text()).slice(0, 200); } catch {}
|
| 16 |
+
throw new Error(`HTTP ${r.status}${detail ? ': ' + detail : ''}`);
|
| 17 |
+
}
|
| 18 |
return r.json();
|
| 19 |
},
|
| 20 |
+
|
| 21 |
+
async health() {
|
| 22 |
+
try {
|
| 23 |
+
const r = await fetch('/api/health');
|
| 24 |
+
if (!r.ok) return { status: 'error' };
|
| 25 |
+
return r.json();
|
| 26 |
+
} catch { return { status: 'offline' }; }
|
| 27 |
+
},
|
| 28 |
+
|
| 29 |
+
async hypergraph() {
|
| 30 |
+
try {
|
| 31 |
+
const r = await fetch('/api/hypergraph');
|
| 32 |
+
if (!r.ok) return { nodes: [], edges: [], node_count: 0, edge_count: 0 };
|
| 33 |
+
return r.json();
|
| 34 |
+
} catch {
|
| 35 |
+
return { nodes: [], edges: [], node_count: 0, edge_count: 0 };
|
| 36 |
+
}
|
| 37 |
+
},
|
| 38 |
+
|
| 39 |
+
async nodeDetail(id) {
|
| 40 |
+
try {
|
| 41 |
+
const r = await fetch('/api/node/' + encodeURIComponent(id));
|
| 42 |
+
if (!r.ok) return null;
|
| 43 |
+
return r.json();
|
| 44 |
+
} catch { return null; }
|
| 45 |
+
},
|
| 46 |
+
|
| 47 |
+
async reset() {
|
| 48 |
+
try {
|
| 49 |
+
const r = await fetch('/api/reset', { method: 'POST' });
|
| 50 |
+
return r.ok ? r.json() : { status: 'error' };
|
| 51 |
+
} catch { return { status: 'offline' }; }
|
| 52 |
},
|
|
|
|
| 53 |
};
|
frontend/dist/assets/boot.js
CHANGED
|
@@ -1,45 +1,83 @@
|
|
| 1 |
-
/*
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
(() => {
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
const
|
| 19 |
-
const
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
window.toast = function (msg, kind = '') {
|
|
|
|
|
|
|
| 24 |
const t = document.createElement('div');
|
| 25 |
-
t.className = 'toast ' + kind;
|
| 26 |
-
t.textContent = msg;
|
| 27 |
-
|
| 28 |
-
setTimeout(() => t.remove(), 5200);
|
| 29 |
};
|
| 30 |
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
const MAX_FILES = 2;
|
| 33 |
-
|
|
|
|
| 34 |
|
| 35 |
function renderStrip() {
|
|
|
|
| 36 |
if (attachments.length === 0) {
|
| 37 |
-
|
| 38 |
strip.innerHTML = '';
|
| 39 |
return;
|
| 40 |
}
|
| 41 |
-
|
| 42 |
-
const
|
| 43 |
? `<span class="attach-hint">${MAX_FILES - attachments.length} more allowed</span>`
|
| 44 |
: `<span class="attach-hint">max ${MAX_FILES} reached</span>`;
|
| 45 |
strip.innerHTML = attachments.map((a, i) => {
|
|
@@ -48,23 +86,30 @@
|
|
| 48 |
: `<div class="pdf-ico">PDF</div>`;
|
| 49 |
const sizeKb = (a.file.size / 1024).toFixed(0);
|
| 50 |
return `
|
| 51 |
-
<div class="preview-tile">
|
| 52 |
${preview}
|
| 53 |
<span class="nm" title="${escapeHtml(a.name)}">${escapeHtml(a.name)} · ${sizeKb}KB</span>
|
| 54 |
-
<button class="x" data-i="${i}" title="Remove">×</button>
|
| 55 |
</div>`;
|
| 56 |
-
}).join('') +
|
|
|
|
|
|
|
| 57 |
strip.querySelectorAll('.x').forEach(b => {
|
| 58 |
-
b.onclick = () => {
|
|
|
|
|
|
|
| 59 |
const i = +b.dataset.i;
|
| 60 |
const removed = attachments.splice(i, 1)[0];
|
| 61 |
-
if (removed
|
|
|
|
|
|
|
| 62 |
renderStrip();
|
| 63 |
};
|
| 64 |
});
|
| 65 |
}
|
| 66 |
|
| 67 |
function addFiles(files) {
|
|
|
|
| 68 |
const list = Array.from(files);
|
| 69 |
for (const f of list) {
|
| 70 |
if (attachments.length >= MAX_FILES) {
|
|
@@ -75,10 +120,10 @@
|
|
| 75 |
const isImg = mime.startsWith('image/');
|
| 76 |
const isPdf = mime === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf');
|
| 77 |
if (!isImg && !isPdf) {
|
| 78 |
-
window.toast(`Unsupported
|
| 79 |
continue;
|
| 80 |
}
|
| 81 |
-
if (f.size >
|
| 82 |
window.toast(`${f.name} is too large (>12MB)`, 'warn');
|
| 83 |
continue;
|
| 84 |
}
|
|
@@ -90,183 +135,292 @@
|
|
| 90 |
});
|
| 91 |
}
|
| 92 |
renderStrip();
|
| 93 |
-
|
| 94 |
-
fileEl.value = '';
|
| 95 |
}
|
| 96 |
|
| 97 |
-
|
|
|
|
|
|
|
| 98 |
|
| 99 |
-
//
|
| 100 |
['dragenter', 'dragover'].forEach(ev =>
|
| 101 |
window.addEventListener(ev, e => { e.preventDefault(); }));
|
| 102 |
window.addEventListener('drop', e => {
|
| 103 |
-
if (!e.dataTransfer || !e.dataTransfer.files.length) return;
|
| 104 |
e.preventDefault();
|
| 105 |
addFiles(e.dataTransfer.files);
|
| 106 |
});
|
| 107 |
|
| 108 |
-
/
|
|
|
|
|
|
|
| 109 |
function setBusy(b) {
|
| 110 |
document.body.dataset.busy = b ? '1' : '0';
|
| 111 |
-
input.disabled = b;
|
| 112 |
-
send.disabled = b;
|
| 113 |
if (b) {
|
| 114 |
-
send.classList.add('loading');
|
| 115 |
-
upBtn.classList.add('disabled');
|
| 116 |
-
fileEl.disabled = true;
|
| 117 |
-
|
| 118 |
-
|
|
|
|
|
|
|
| 119 |
} else {
|
| 120 |
-
send.classList.remove('loading');
|
| 121 |
-
upBtn.classList.remove('disabled');
|
| 122 |
-
fileEl.disabled = false;
|
| 123 |
-
|
|
|
|
|
|
|
| 124 |
}
|
| 125 |
}
|
| 126 |
|
| 127 |
-
/
|
|
|
|
|
|
|
| 128 |
async function restore() {
|
| 129 |
try {
|
| 130 |
const h = await ElysiumAPI.hypergraph();
|
| 131 |
(h.nodes || []).forEach(n => {
|
| 132 |
-
if (n.node_id !== 'CORE'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
});
|
| 134 |
-
(h.
|
| 135 |
-
if ((h.nodes || []).length > 0) seedHint.classList.add('hidden');
|
| 136 |
updateLegend();
|
| 137 |
updateMetrics({
|
| 138 |
-
nodes: h.node_count
|
| 139 |
-
|
|
|
|
|
|
|
| 140 |
civilization_age_min: 0,
|
| 141 |
-
mycelium_density_pct: h.node_count
|
|
|
|
|
|
|
| 142 |
coherence_pct: 70,
|
| 143 |
});
|
| 144 |
} catch (e) {
|
| 145 |
-
|
|
|
|
| 146 |
}
|
| 147 |
}
|
| 148 |
restore();
|
| 149 |
|
| 150 |
-
/
|
|
|
|
|
|
|
| 151 |
async function submit() {
|
| 152 |
if (document.body.dataset.busy === '1') return;
|
| 153 |
-
const text = input.value.trim();
|
| 154 |
if (!text && attachments.length === 0) return;
|
| 155 |
|
| 156 |
setBusy(true);
|
| 157 |
-
|
| 158 |
-
input.value = '';
|
| 159 |
|
| 160 |
const files = attachments.map(a => a.file);
|
| 161 |
-
//
|
| 162 |
-
attachments.forEach(a =>
|
|
|
|
|
|
|
| 163 |
attachments = [];
|
| 164 |
renderStrip();
|
| 165 |
|
| 166 |
try {
|
| 167 |
const data = await ElysiumAPI.turn(text, files);
|
| 168 |
-
handleResponse(data);
|
| 169 |
} catch (e) {
|
| 170 |
-
|
|
|
|
| 171 |
} finally {
|
| 172 |
setBusy(false);
|
| 173 |
}
|
| 174 |
}
|
| 175 |
|
| 176 |
-
send.onclick = submit;
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
e.
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
|
|
|
|
|
|
| 183 |
|
| 184 |
-
/
|
| 185 |
-
|
| 186 |
-
|
|
|
|
|
|
|
| 187 |
function handleResponse(payload) {
|
| 188 |
-
|
| 189 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
|
| 191 |
-
// 0. Attachment errors → toast
|
| 192 |
-
(rt.attachment_errors
|
| 193 |
window.toast(`📎 ${e.name}: ${e.error}`, 'warn'));
|
| 194 |
|
| 195 |
-
// 1. Hypergraph delta → canvas
|
| 196 |
const delta = resp.hypergraph_delta || {};
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
}
|
| 204 |
-
window.elysiumAddNode(n, parent);
|
| 205 |
});
|
| 206 |
-
(delta.edges_added || []).forEach(e => window.elysiumAddEdge(e));
|
| 207 |
|
| 208 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
const ui = resp.ui_directives || {};
|
| 210 |
-
(ui.bioluminescence_pulse_nodes
|
|
|
|
|
|
|
| 211 |
document.body.dataset.alert = ui.alert_level || 'CALM';
|
| 212 |
-
if (ui.camera_focus_node_id) {
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
setTimeout(() =>
|
|
|
|
|
|
|
| 216 |
}
|
| 217 |
|
| 218 |
// 3. Council overlay + TTS
|
| 219 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
|
| 221 |
-
// 4. Metrics bar (REAL civilization metrics
|
| 222 |
updateMetrics(rt.metrics || {});
|
| 223 |
|
| 224 |
// 5. Agent count badge
|
| 225 |
-
const ag = (resp.council_deliberation
|
| 226 |
-
|
|
|
|
| 227 |
|
| 228 |
-
// 6. Legend live update
|
| 229 |
updateLegend();
|
| 230 |
|
| 231 |
// 7. Tool toasts
|
| 232 |
-
(rt.tool_results
|
|
|
|
| 233 |
const ok = tr.result && !tr.result.error;
|
| 234 |
-
window.toast(
|
| 235 |
-
|
|
|
|
|
|
|
| 236 |
});
|
| 237 |
|
| 238 |
-
// 8. Direct answer toast
|
| 239 |
-
|
| 240 |
-
|
|
|
|
| 241 |
}
|
| 242 |
|
| 243 |
-
// 9.
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
|
|
|
| 247 |
}
|
| 248 |
}
|
| 249 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
function updateMetrics(m) {
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
}
|
| 259 |
|
| 260 |
function updateLegend() {
|
|
|
|
|
|
|
| 261 |
const counts = {};
|
| 262 |
-
window.ELYSIUM.nodes.forEach(n =>
|
|
|
|
|
|
|
|
|
|
| 263 |
const order = ['CORE','CIVILIZATION','DOMAIN','AGENT','TOOL','PROJECT',
|
| 264 |
'LIFE_EVENT','EMOTION','PERSON','VALUE','MEMORY','FACT','CONCEPT','QUERY'];
|
| 265 |
const seen = new Set();
|
| 266 |
const parts = [];
|
| 267 |
-
|
|
|
|
|
|
|
| 268 |
seen.add(t);
|
| 269 |
-
const c =
|
| 270 |
parts.push(`
|
| 271 |
<div class="legend-item" data-type="${t}">
|
| 272 |
<span class="dot" style="background:${c};color:${c}"></span>
|
|
@@ -274,42 +428,46 @@
|
|
| 274 |
<span class="lbl">${t.replace(/_/g, ' ').toLowerCase()}</span>
|
| 275 |
</div>`);
|
| 276 |
});
|
| 277 |
-
// any types not in canonical order, append
|
| 278 |
Object.keys(counts).forEach(t => {
|
| 279 |
if (seen.has(t)) return;
|
| 280 |
-
const c =
|
| 281 |
-
parts.push(`
|
|
|
|
| 282 |
<span class="dot" style="background:${c};color:${c}"></span>
|
| 283 |
<span class="badge">${counts[t]}</span>
|
| 284 |
<span class="lbl">${t.replace(/_/g, ' ').toLowerCase()}</span>
|
| 285 |
</div>`);
|
| 286 |
});
|
| 287 |
-
|
| 288 |
-
//
|
| 289 |
-
|
| 290 |
el.onclick = () => {
|
| 291 |
const t = el.dataset.type;
|
| 292 |
-
window.elysiumFilterType
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
x.style.opacity =
|
| 296 |
-
? (x.dataset.type ===
|
| 297 |
-
: '1'
|
|
|
|
| 298 |
};
|
| 299 |
});
|
| 300 |
}
|
| 301 |
|
| 302 |
-
/
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
}[c]));
|
| 314 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
})();
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
ELYSIUM — boot.js
|
| 3 |
+
------------------------------------------------------------
|
| 4 |
+
Wires UI ↔ /api/turn. Critical guarantees:
|
| 5 |
+
|
| 6 |
+
• The JSON envelope returned by the model is NEVER rendered
|
| 7 |
+
as raw text anywhere on screen. It is parsed and ROUTED to:
|
| 8 |
+
hypergraph_delta → canvas nodes / edges
|
| 9 |
+
council_deliberation → council overlay + TTS audio
|
| 10 |
+
ui_directives → bioluminescence pulses, focus, alert
|
| 11 |
+
direct_answer → optional small toast
|
| 12 |
+
metrics → bottom stats bar + legend densities
|
| 13 |
+
|
| 14 |
+
• File uploads are previewed above the textbox with X removal
|
| 15 |
+
and only sent when the user presses Send.
|
| 16 |
+
|
| 17 |
+
• Every DOM lookup is defensive — a single missing element
|
| 18 |
+
never throws an unhandled "Cannot read properties of null".
|
| 19 |
+
This was the root cause of the previous "classList of null"
|
| 20 |
+
toast that appeared on top.
|
| 21 |
+
============================================================ */
|
| 22 |
(() => {
|
| 23 |
+
'use strict';
|
| 24 |
+
|
| 25 |
+
// ── Safe element accessor (logs once if missing) ──
|
| 26 |
+
const _missing = new Set();
|
| 27 |
+
const $ = (id) => {
|
| 28 |
+
const el = document.getElementById(id);
|
| 29 |
+
if (!el && !_missing.has(id)) {
|
| 30 |
+
_missing.add(id);
|
| 31 |
+
console.warn('[elysium] missing element:', id);
|
| 32 |
+
}
|
| 33 |
+
return el;
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
// Safe wrappers — no-op if element missing
|
| 37 |
+
const addCls = (el, c) => { if (el && el.classList) el.classList.add(c); };
|
| 38 |
+
const rmCls = (el, c) => { if (el && el.classList) el.classList.remove(c); };
|
| 39 |
+
|
| 40 |
+
// ── Toast helper (always returns; never throws) ──
|
| 41 |
window.toast = function (msg, kind = '') {
|
| 42 |
+
const host = $('toasts');
|
| 43 |
+
if (!host) { console.log('[toast]', msg); return; }
|
| 44 |
const t = document.createElement('div');
|
| 45 |
+
t.className = 'toast ' + (kind || '');
|
| 46 |
+
t.textContent = String(msg).slice(0, 280);
|
| 47 |
+
host.appendChild(t);
|
| 48 |
+
setTimeout(() => { try { t.remove(); } catch {} }, 5200);
|
| 49 |
};
|
| 50 |
|
| 51 |
+
function escapeHtml(s) {
|
| 52 |
+
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
|
| 53 |
+
'&':'&','<':'<','>':'>','"':'"',"'":'''
|
| 54 |
+
}[c]));
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
// ── DOM references (all may be null — handled defensively) ──
|
| 58 |
+
const input = $('q-input');
|
| 59 |
+
const send = $('q-send');
|
| 60 |
+
const fileEl = $('q-file');
|
| 61 |
+
const upBtn = $('q-upload');
|
| 62 |
+
const strip = $('attach-strip');
|
| 63 |
+
const seedHint = $('seed-hint');
|
| 64 |
+
|
| 65 |
+
/* =========================================================
|
| 66 |
+
PART A — FILE ATTACHMENTS (preview above textbox, max 2)
|
| 67 |
+
========================================================= */
|
| 68 |
const MAX_FILES = 2;
|
| 69 |
+
const MAX_BYTES = 12 * 1024 * 1024;
|
| 70 |
+
let attachments = []; // [{file, name, kind:'image'|'pdf', previewUrl}]
|
| 71 |
|
| 72 |
function renderStrip() {
|
| 73 |
+
if (!strip) return;
|
| 74 |
if (attachments.length === 0) {
|
| 75 |
+
addCls(strip, 'hidden');
|
| 76 |
strip.innerHTML = '';
|
| 77 |
return;
|
| 78 |
}
|
| 79 |
+
rmCls(strip, 'hidden');
|
| 80 |
+
const hint = attachments.length < MAX_FILES
|
| 81 |
? `<span class="attach-hint">${MAX_FILES - attachments.length} more allowed</span>`
|
| 82 |
: `<span class="attach-hint">max ${MAX_FILES} reached</span>`;
|
| 83 |
strip.innerHTML = attachments.map((a, i) => {
|
|
|
|
| 86 |
: `<div class="pdf-ico">PDF</div>`;
|
| 87 |
const sizeKb = (a.file.size / 1024).toFixed(0);
|
| 88 |
return `
|
| 89 |
+
<div class="preview-tile" data-i="${i}">
|
| 90 |
${preview}
|
| 91 |
<span class="nm" title="${escapeHtml(a.name)}">${escapeHtml(a.name)} · ${sizeKb}KB</span>
|
| 92 |
+
<button class="x" data-i="${i}" title="Remove attachment" aria-label="Remove">×</button>
|
| 93 |
</div>`;
|
| 94 |
+
}).join('') + hint;
|
| 95 |
+
|
| 96 |
+
// Wire remove buttons
|
| 97 |
strip.querySelectorAll('.x').forEach(b => {
|
| 98 |
+
b.onclick = (ev) => {
|
| 99 |
+
ev.preventDefault();
|
| 100 |
+
ev.stopPropagation();
|
| 101 |
const i = +b.dataset.i;
|
| 102 |
const removed = attachments.splice(i, 1)[0];
|
| 103 |
+
if (removed && removed.previewUrl) {
|
| 104 |
+
try { URL.revokeObjectURL(removed.previewUrl); } catch {}
|
| 105 |
+
}
|
| 106 |
renderStrip();
|
| 107 |
};
|
| 108 |
});
|
| 109 |
}
|
| 110 |
|
| 111 |
function addFiles(files) {
|
| 112 |
+
if (!files) return;
|
| 113 |
const list = Array.from(files);
|
| 114 |
for (const f of list) {
|
| 115 |
if (attachments.length >= MAX_FILES) {
|
|
|
|
| 120 |
const isImg = mime.startsWith('image/');
|
| 121 |
const isPdf = mime === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf');
|
| 122 |
if (!isImg && !isPdf) {
|
| 123 |
+
window.toast(`Unsupported: ${f.name} (only images & PDFs)`, 'warn');
|
| 124 |
continue;
|
| 125 |
}
|
| 126 |
+
if (f.size > MAX_BYTES) {
|
| 127 |
window.toast(`${f.name} is too large (>12MB)`, 'warn');
|
| 128 |
continue;
|
| 129 |
}
|
|
|
|
| 135 |
});
|
| 136 |
}
|
| 137 |
renderStrip();
|
| 138 |
+
if (fileEl) fileEl.value = ''; // allow re-selecting same file after removal
|
|
|
|
| 139 |
}
|
| 140 |
|
| 141 |
+
if (fileEl) {
|
| 142 |
+
fileEl.addEventListener('change', e => addFiles(e.target.files));
|
| 143 |
+
}
|
| 144 |
|
| 145 |
+
// Optional polish: drag-and-drop files anywhere on the window
|
| 146 |
['dragenter', 'dragover'].forEach(ev =>
|
| 147 |
window.addEventListener(ev, e => { e.preventDefault(); }));
|
| 148 |
window.addEventListener('drop', e => {
|
| 149 |
+
if (!e.dataTransfer || !e.dataTransfer.files || !e.dataTransfer.files.length) return;
|
| 150 |
e.preventDefault();
|
| 151 |
addFiles(e.dataTransfer.files);
|
| 152 |
});
|
| 153 |
|
| 154 |
+
/* =========================================================
|
| 155 |
+
PART B — BUSY LOCK (disables input while model thinks)
|
| 156 |
+
========================================================= */
|
| 157 |
function setBusy(b) {
|
| 158 |
document.body.dataset.busy = b ? '1' : '0';
|
| 159 |
+
if (input) input.disabled = b;
|
| 160 |
+
if (send) send.disabled = b;
|
| 161 |
if (b) {
|
| 162 |
+
if (send) send.classList.add('loading');
|
| 163 |
+
if (upBtn) upBtn.classList.add('disabled');
|
| 164 |
+
if (fileEl) fileEl.disabled = true;
|
| 165 |
+
if (input) {
|
| 166 |
+
input.dataset.prev = input.placeholder || '';
|
| 167 |
+
input.placeholder = 'Council deliberating…';
|
| 168 |
+
}
|
| 169 |
} else {
|
| 170 |
+
if (send) send.classList.remove('loading');
|
| 171 |
+
if (upBtn) upBtn.classList.remove('disabled');
|
| 172 |
+
if (fileEl) fileEl.disabled = false;
|
| 173 |
+
if (input) {
|
| 174 |
+
input.placeholder = input.dataset.prev || 'Speak to your civilization seed…';
|
| 175 |
+
}
|
| 176 |
}
|
| 177 |
}
|
| 178 |
|
| 179 |
+
/* =========================================================
|
| 180 |
+
PART C — RESTORE existing civilization on page load
|
| 181 |
+
========================================================= */
|
| 182 |
async function restore() {
|
| 183 |
try {
|
| 184 |
const h = await ElysiumAPI.hypergraph();
|
| 185 |
(h.nodes || []).forEach(n => {
|
| 186 |
+
if (n.node_id !== 'CORE' && typeof window.elysiumAddNode === 'function') {
|
| 187 |
+
window.elysiumAddNode(n);
|
| 188 |
+
}
|
| 189 |
+
});
|
| 190 |
+
(h.edges || []).forEach(e => {
|
| 191 |
+
if (typeof window.elysiumAddEdge === 'function') window.elysiumAddEdge(e);
|
| 192 |
});
|
| 193 |
+
if ((h.nodes || []).length > 1) addCls(seedHint, 'hidden');
|
|
|
|
| 194 |
updateLegend();
|
| 195 |
updateMetrics({
|
| 196 |
+
nodes: h.node_count || (h.nodes || []).length || 1,
|
| 197 |
+
edges: h.edge_count || (h.edges || []).length || 0,
|
| 198 |
+
council_active: 0,
|
| 199 |
+
knowledge_growth: 0,
|
| 200 |
civilization_age_min: 0,
|
| 201 |
+
mycelium_density_pct: h.node_count
|
| 202 |
+
? Math.round(Math.min(1, (h.edge_count || 0) / Math.max(1, h.node_count * 1.4)) * 100)
|
| 203 |
+
: 0,
|
| 204 |
coherence_pct: 70,
|
| 205 |
});
|
| 206 |
} catch (e) {
|
| 207 |
+
console.warn('[restore] failed', e);
|
| 208 |
+
updateLegend();
|
| 209 |
}
|
| 210 |
}
|
| 211 |
restore();
|
| 212 |
|
| 213 |
+
/* =========================================================
|
| 214 |
+
PART D — SUBMIT a turn
|
| 215 |
+
========================================================= */
|
| 216 |
async function submit() {
|
| 217 |
if (document.body.dataset.busy === '1') return;
|
| 218 |
+
const text = input ? input.value.trim() : '';
|
| 219 |
if (!text && attachments.length === 0) return;
|
| 220 |
|
| 221 |
setBusy(true);
|
| 222 |
+
addCls(seedHint, 'hidden');
|
| 223 |
+
if (input) input.value = '';
|
| 224 |
|
| 225 |
const files = attachments.map(a => a.file);
|
| 226 |
+
// Clear preview strip (response will show separate "Analyzed:" toast)
|
| 227 |
+
attachments.forEach(a => {
|
| 228 |
+
if (a.previewUrl) { try { URL.revokeObjectURL(a.previewUrl); } catch {} }
|
| 229 |
+
});
|
| 230 |
attachments = [];
|
| 231 |
renderStrip();
|
| 232 |
|
| 233 |
try {
|
| 234 |
const data = await ElysiumAPI.turn(text, files);
|
| 235 |
+
handleResponse(data || {});
|
| 236 |
} catch (e) {
|
| 237 |
+
console.error('[submit] failed', e);
|
| 238 |
+
window.toast('Inference failed: ' + (e.message || e), 'error');
|
| 239 |
} finally {
|
| 240 |
setBusy(false);
|
| 241 |
}
|
| 242 |
}
|
| 243 |
|
| 244 |
+
if (send) send.onclick = submit;
|
| 245 |
+
if (input) {
|
| 246 |
+
input.addEventListener('keydown', e => {
|
| 247 |
+
if (e.key === 'Enter' && !e.shiftKey) {
|
| 248 |
+
e.preventDefault();
|
| 249 |
+
submit();
|
| 250 |
+
}
|
| 251 |
+
});
|
| 252 |
+
}
|
| 253 |
|
| 254 |
+
/* =========================================================
|
| 255 |
+
PART E — HANDLE RESPONSE
|
| 256 |
+
Parses the JSON envelope and routes it. NEVER renders raw
|
| 257 |
+
JSON to the canvas or any visible element.
|
| 258 |
+
========================================================= */
|
| 259 |
function handleResponse(payload) {
|
| 260 |
+
// payload schema: { user_msg, elysium_response, _runtime }
|
| 261 |
+
let resp = {};
|
| 262 |
+
let rt = {};
|
| 263 |
+
try {
|
| 264 |
+
resp = payload.elysium_response || {};
|
| 265 |
+
rt = payload._runtime || {};
|
| 266 |
+
} catch (e) {
|
| 267 |
+
console.error('[handleResponse] malformed payload', e);
|
| 268 |
+
window.toast('Malformed response from model', 'error');
|
| 269 |
+
return;
|
| 270 |
+
}
|
| 271 |
|
| 272 |
+
// 0. Attachment errors (server-side validation failures) → toast
|
| 273 |
+
safeArr(rt.attachment_errors).forEach(e =>
|
| 274 |
window.toast(`📎 ${e.name}: ${e.error}`, 'warn'));
|
| 275 |
|
| 276 |
+
// 1. Hypergraph delta → canvas (NODES then EDGES; build parent map)
|
| 277 |
const delta = resp.hypergraph_delta || {};
|
| 278 |
+
const edgesAdded = safeArr(delta.edges_added);
|
| 279 |
+
const nodesAdded = safeArr(delta.nodes_added);
|
| 280 |
+
|
| 281 |
+
// Build a parent-hint map: for each new node, what's its closest source
|
| 282 |
+
// (used so spawn animation grows OUT FROM the parent, not the centre).
|
| 283 |
+
const parentHint = {};
|
| 284 |
+
edgesAdded.forEach(e => {
|
| 285 |
+
if (!e) return;
|
| 286 |
+
if (e.target_node_id && !parentHint[e.target_node_id]) {
|
| 287 |
+
parentHint[e.target_node_id] = e.source_node_id;
|
| 288 |
+
}
|
| 289 |
+
if (e.source_node_id && !parentHint[e.source_node_id]) {
|
| 290 |
+
// weaker hint (reverse direction) — only if not set
|
| 291 |
+
if (!parentHint[e.source_node_id]) {
|
| 292 |
+
parentHint[e.source_node_id] = e.target_node_id;
|
| 293 |
+
}
|
| 294 |
+
}
|
| 295 |
+
});
|
| 296 |
+
|
| 297 |
+
nodesAdded.forEach(n => {
|
| 298 |
+
if (!n || !n.node_id) return;
|
| 299 |
+
try {
|
| 300 |
+
if (typeof window.elysiumAddNode === 'function') {
|
| 301 |
+
window.elysiumAddNode(n, parentHint[n.node_id]);
|
| 302 |
+
}
|
| 303 |
+
} catch (err) {
|
| 304 |
+
console.warn('[handleResponse] addNode failed', n, err);
|
| 305 |
}
|
|
|
|
| 306 |
});
|
|
|
|
| 307 |
|
| 308 |
+
edgesAdded.forEach(e => {
|
| 309 |
+
if (!e || !e.source_node_id || !e.target_node_id) return;
|
| 310 |
+
try {
|
| 311 |
+
if (typeof window.elysiumAddEdge === 'function') window.elysiumAddEdge(e);
|
| 312 |
+
} catch (err) {
|
| 313 |
+
console.warn('[handleResponse] addEdge failed', e, err);
|
| 314 |
+
}
|
| 315 |
+
});
|
| 316 |
+
|
| 317 |
+
// Apply node updates (fields_changed)
|
| 318 |
+
safeArr(delta.nodes_updated).forEach(u => {
|
| 319 |
+
if (!u || !u.node_id) return;
|
| 320 |
+
const node = window.elysiumGetNode && window.elysiumGetNode(u.node_id);
|
| 321 |
+
if (node && u.fields_changed) {
|
| 322 |
+
Object.assign(node.payload || (node.payload = {}), u.fields_changed);
|
| 323 |
+
}
|
| 324 |
+
});
|
| 325 |
+
|
| 326 |
+
// 2. UI directives — pulses, focus, alert
|
| 327 |
const ui = resp.ui_directives || {};
|
| 328 |
+
safeArr(ui.bioluminescence_pulse_nodes).forEach(id => {
|
| 329 |
+
if (typeof window.elysiumPulse === 'function') window.elysiumPulse(id, 1600);
|
| 330 |
+
});
|
| 331 |
document.body.dataset.alert = ui.alert_level || 'CALM';
|
| 332 |
+
if (ui.camera_focus_node_id && typeof window.elysiumFocus === 'function') {
|
| 333 |
+
if (typeof window.elysiumPulse === 'function')
|
| 334 |
+
window.elysiumPulse(ui.camera_focus_node_id, 1800);
|
| 335 |
+
setTimeout(() => {
|
| 336 |
+
try { window.elysiumFocus(ui.camera_focus_node_id); } catch {}
|
| 337 |
+
}, 380);
|
| 338 |
}
|
| 339 |
|
| 340 |
// 3. Council overlay + TTS
|
| 341 |
+
try {
|
| 342 |
+
if (typeof window.renderCouncil === 'function') {
|
| 343 |
+
window.renderCouncil(resp, rt);
|
| 344 |
+
}
|
| 345 |
+
} catch (e) {
|
| 346 |
+
console.error('[council] render failed', e);
|
| 347 |
+
}
|
| 348 |
|
| 349 |
+
// 4. Metrics bar (REAL civilization metrics)
|
| 350 |
updateMetrics(rt.metrics || {});
|
| 351 |
|
| 352 |
// 5. Agent count badge
|
| 353 |
+
const ag = safeArr(resp.council_deliberation && resp.council_deliberation.agent_outputs).length;
|
| 354 |
+
const badge = $('agent-count');
|
| 355 |
+
if (badge) badge.textContent = `+${ag}`;
|
| 356 |
|
| 357 |
+
// 6. Legend live update (counts per type)
|
| 358 |
updateLegend();
|
| 359 |
|
| 360 |
// 7. Tool toasts
|
| 361 |
+
safeArr(rt.tool_results).forEach(tr => {
|
| 362 |
+
if (!tr) return;
|
| 363 |
const ok = tr.result && !tr.result.error;
|
| 364 |
+
window.toast(
|
| 365 |
+
`🔧 ${tr.tool_name || 'tool'}: ${ok ? 'ok' : (tr.result?.error || 'offline')}`,
|
| 366 |
+
ok ? 'info' : 'warn'
|
| 367 |
+
);
|
| 368 |
});
|
| 369 |
|
| 370 |
+
// 8. Direct answer toast — ONLY when no council (otherwise it's already
|
| 371 |
+
// shown in the overlay synthesis). NEVER dumps raw JSON.
|
| 372 |
+
if (!ag && resp.direct_answer && typeof resp.direct_answer === 'string') {
|
| 373 |
+
window.toast(resp.direct_answer.slice(0, 240));
|
| 374 |
}
|
| 375 |
|
| 376 |
+
// 9. Confirm attachment analysis
|
| 377 |
+
const processed = safeArr(rt.attachments_processed);
|
| 378 |
+
if (processed.length) {
|
| 379 |
+
const names = processed.map(a => a && a.name).filter(Boolean).join(', ');
|
| 380 |
+
if (names) window.toast(`📎 Analyzed: ${names}`, 'info');
|
| 381 |
}
|
| 382 |
}
|
| 383 |
|
| 384 |
+
function safeArr(x) { return Array.isArray(x) ? x : []; }
|
| 385 |
+
|
| 386 |
+
/* =========================================================
|
| 387 |
+
PART F — Metrics & Legend updates
|
| 388 |
+
========================================================= */
|
| 389 |
function updateMetrics(m) {
|
| 390 |
+
m = m || {};
|
| 391 |
+
const nodes = m.nodes ?? (window.ELYSIUM ? window.ELYSIUM.nodes.size : 1);
|
| 392 |
+
const edges = m.edges ?? (window.ELYSIUM ? window.ELYSIUM.edges.length : 0);
|
| 393 |
+
setText('s-nodes', nodes);
|
| 394 |
+
setText('s-edges', edges);
|
| 395 |
+
setText('s-council', m.council_active ?? 0);
|
| 396 |
+
setText('s-growth', m.knowledge_growth ?? 0);
|
| 397 |
+
setText('s-age', m.civilization_age_min ?? 0);
|
| 398 |
+
setText('m-density', (m.mycelium_density_pct ?? 0) + '%');
|
| 399 |
+
setText('m-coherence', (m.coherence_pct ?? 70) + '%');
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
function setText(id, value) {
|
| 403 |
+
const el = $(id);
|
| 404 |
+
if (el) el.textContent = value;
|
| 405 |
}
|
| 406 |
|
| 407 |
function updateLegend() {
|
| 408 |
+
const host = $('legend-items');
|
| 409 |
+
if (!host || !window.ELYSIUM) return;
|
| 410 |
const counts = {};
|
| 411 |
+
window.ELYSIUM.nodes.forEach(n => {
|
| 412 |
+
const t = n.type || 'CONCEPT';
|
| 413 |
+
counts[t] = (counts[t] || 0) + 1;
|
| 414 |
+
});
|
| 415 |
const order = ['CORE','CIVILIZATION','DOMAIN','AGENT','TOOL','PROJECT',
|
| 416 |
'LIFE_EVENT','EMOTION','PERSON','VALUE','MEMORY','FACT','CONCEPT','QUERY'];
|
| 417 |
const seen = new Set();
|
| 418 |
const parts = [];
|
| 419 |
+
const colorFn = window.colorFor || (() => '#a76bff');
|
| 420 |
+
order.forEach(t => {
|
| 421 |
+
if (!counts[t]) return;
|
| 422 |
seen.add(t);
|
| 423 |
+
const c = colorFn(t);
|
| 424 |
parts.push(`
|
| 425 |
<div class="legend-item" data-type="${t}">
|
| 426 |
<span class="dot" style="background:${c};color:${c}"></span>
|
|
|
|
| 428 |
<span class="lbl">${t.replace(/_/g, ' ').toLowerCase()}</span>
|
| 429 |
</div>`);
|
| 430 |
});
|
|
|
|
| 431 |
Object.keys(counts).forEach(t => {
|
| 432 |
if (seen.has(t)) return;
|
| 433 |
+
const c = colorFn(t);
|
| 434 |
+
parts.push(`
|
| 435 |
+
<div class="legend-item" data-type="${t}">
|
| 436 |
<span class="dot" style="background:${c};color:${c}"></span>
|
| 437 |
<span class="badge">${counts[t]}</span>
|
| 438 |
<span class="lbl">${t.replace(/_/g, ' ').toLowerCase()}</span>
|
| 439 |
</div>`);
|
| 440 |
});
|
| 441 |
+
host.innerHTML = parts.join('');
|
| 442 |
+
// Wire legend filter clicks
|
| 443 |
+
host.querySelectorAll('.legend-item').forEach(el => {
|
| 444 |
el.onclick = () => {
|
| 445 |
const t = el.dataset.type;
|
| 446 |
+
if (typeof window.elysiumFilterType === 'function') window.elysiumFilterType(t);
|
| 447 |
+
const active = window.ELYSIUM && window.ELYSIUM.filterType;
|
| 448 |
+
host.querySelectorAll('.legend-item').forEach(x => {
|
| 449 |
+
x.style.opacity = active
|
| 450 |
+
? (x.dataset.type === active ? '1' : '.45')
|
| 451 |
+
: '1';
|
| 452 |
+
});
|
| 453 |
};
|
| 454 |
});
|
| 455 |
}
|
| 456 |
|
| 457 |
+
/* =========================================================
|
| 458 |
+
PART G — RI Analysis button (fit + summary)
|
| 459 |
+
========================================================= */
|
| 460 |
+
const ri = $('ri-analysis');
|
| 461 |
+
if (ri) {
|
| 462 |
+
ri.onclick = () => {
|
| 463 |
+
if (typeof window.elysiumFitAll === 'function') window.elysiumFitAll();
|
| 464 |
+
const n = window.ELYSIUM ? window.ELYSIUM.nodes.size : 0;
|
| 465 |
+
const e = window.ELYSIUM ? window.ELYSIUM.edges.length : 0;
|
| 466 |
+
window.toast(`🔍 Civilization snapshot: ${n} nodes · ${e} threads`, 'info');
|
| 467 |
+
};
|
|
|
|
| 468 |
}
|
| 469 |
+
|
| 470 |
+
// Expose for debugging (read-only-ish)
|
| 471 |
+
window.elysiumUpdateLegend = updateLegend;
|
| 472 |
+
window.elysiumUpdateMetrics = updateMetrics;
|
| 473 |
})();
|
frontend/dist/assets/canvas.js
CHANGED
|
@@ -1,7 +1,22 @@
|
|
| 1 |
-
/*
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
(() => {
|
|
|
|
|
|
|
| 4 |
const canvas = document.getElementById('elysium-canvas');
|
|
|
|
| 5 |
const ctx = canvas.getContext('2d', { alpha: false });
|
| 6 |
|
| 7 |
const DPR = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
|
|
@@ -21,16 +36,17 @@
|
|
| 21 |
edges: [],
|
| 22 |
hover: null,
|
| 23 |
selected: null,
|
| 24 |
-
filterType: null,
|
| 25 |
};
|
| 26 |
|
| 27 |
-
// Seed CORE
|
| 28 |
E.nodes.set('CORE', {
|
| 29 |
node_id: 'CORE', x: 0, y: 0,
|
| 30 |
type: 'CORE', label: 'ELYSIUM',
|
| 31 |
radius: 36, color: '#ffb840',
|
| 32 |
phase: 0, born: performance.now() - 1000,
|
| 33 |
payload: { description: 'The seed of your civilization. Ask anything to grow new nodes.' },
|
|
|
|
| 34 |
});
|
| 35 |
|
| 36 |
// ──────── COORDS ────────
|
|
@@ -46,19 +62,28 @@
|
|
| 46 |
let best = null, bestD = Infinity;
|
| 47 |
E.nodes.forEach(n => {
|
| 48 |
const d = Math.hypot(n.x - w.x, n.y - w.y);
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
| 50 |
});
|
| 51 |
return best;
|
| 52 |
}
|
| 53 |
|
| 54 |
// ──────── PAN / TAP ────────
|
| 55 |
-
let lastX = 0, lastY = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
canvas.addEventListener('pointerdown', e => {
|
| 57 |
downX = e.clientX; downY = e.clientY; downT = performance.now();
|
|
|
|
|
|
|
| 58 |
E.cam.drag = true;
|
| 59 |
canvas.classList.add('dragging');
|
| 60 |
lastX = e.clientX; lastY = e.clientY;
|
| 61 |
-
canvas.setPointerCapture(e.pointerId);
|
| 62 |
});
|
| 63 |
|
| 64 |
canvas.addEventListener('pointermove', e => {
|
|
@@ -68,8 +93,11 @@
|
|
| 68 |
canvas.style.cursor = n ? 'pointer' : '';
|
| 69 |
return;
|
| 70 |
}
|
| 71 |
-
const
|
| 72 |
-
const
|
|
|
|
|
|
|
|
|
|
| 73 |
E.cam.x -= dx; E.cam.y -= dy;
|
| 74 |
E.cam.vx = dx * 0.85; E.cam.vy = dy * 0.85;
|
| 75 |
E.cam.tx = E.cam.x; E.cam.ty = E.cam.y;
|
|
@@ -77,25 +105,31 @@
|
|
| 77 |
});
|
| 78 |
|
| 79 |
function endPan(e) {
|
| 80 |
-
const
|
| 81 |
-
const dt = performance.now() - downT;
|
| 82 |
E.cam.drag = false;
|
| 83 |
canvas.classList.remove('dragging');
|
| 84 |
try { canvas.releasePointerCapture(e.pointerId); } catch {}
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
} else {
|
| 91 |
-
if (window.hideNodeDetail) window.hideNodeDetail();
|
| 92 |
}
|
| 93 |
}
|
|
|
|
| 94 |
}
|
| 95 |
canvas.addEventListener('pointerup', endPan);
|
| 96 |
canvas.addEventListener('pointercancel', endPan);
|
| 97 |
|
| 98 |
-
//
|
| 99 |
canvas.addEventListener('wheel', e => {
|
| 100 |
e.preventDefault();
|
| 101 |
const factor = e.deltaY > 0 ? 0.9 : 1.1;
|
|
@@ -111,7 +145,7 @@
|
|
| 111 |
E.cam.tx = w.x; E.cam.ty = w.y;
|
| 112 |
});
|
| 113 |
|
| 114 |
-
//
|
| 115 |
let touchDist = 0, touchMid = null;
|
| 116 |
canvas.addEventListener('touchstart', e => {
|
| 117 |
if (e.touches.length === 2) {
|
|
@@ -135,10 +169,11 @@
|
|
| 135 |
}, { passive: false });
|
| 136 |
canvas.addEventListener('touchend', () => { touchDist = 0; });
|
| 137 |
|
| 138 |
-
//
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
|
|
|
| 142 |
|
| 143 |
function fitAll() {
|
| 144 |
if (E.nodes.size === 0) return;
|
|
@@ -147,7 +182,7 @@
|
|
| 147 |
mnX = Math.min(mnX, n.x); mnY = Math.min(mnY, n.y);
|
| 148 |
mxX = Math.max(mxX, n.x); mxY = Math.max(mxY, n.y);
|
| 149 |
});
|
| 150 |
-
const pad =
|
| 151 |
E.cam.tx = (mnX + mxX) / 2;
|
| 152 |
E.cam.ty = (mnY + mxY) / 2;
|
| 153 |
if (mxX - mnX < 1 && mxY - mnY < 1) {
|
|
@@ -160,16 +195,17 @@
|
|
| 160 |
}
|
| 161 |
window.elysiumFitAll = fitAll;
|
| 162 |
|
| 163 |
-
/
|
| 164 |
const mini = document.getElementById('minimap');
|
| 165 |
-
const mctx = mini.getContext('2d');
|
| 166 |
|
| 167 |
-
function computeBounds(pad =
|
| 168 |
let mnX = Infinity, mnY = Infinity, mxX = -Infinity, mxY = -Infinity;
|
| 169 |
E.nodes.forEach(n => {
|
| 170 |
mnX = Math.min(mnX, n.x); mnY = Math.min(mnY, n.y);
|
| 171 |
mxX = Math.max(mxX, n.x); mxY = Math.max(mxY, n.y);
|
| 172 |
});
|
|
|
|
| 173 |
if (mxX - mnX < 100) { mnX -= 200; mxX += 200; }
|
| 174 |
if (mxY - mnY < 100) { mnY -= 200; mxY += 200; }
|
| 175 |
return { mnX: mnX - pad, mnY: mnY - pad,
|
|
@@ -177,26 +213,31 @@
|
|
| 177 |
}
|
| 178 |
|
| 179 |
function drawMinimap() {
|
| 180 |
-
|
|
|
|
|
|
|
| 181 |
if (mini.width !== w * DPR || mini.height !== h * DPR) {
|
| 182 |
mini.width = w * DPR; mini.height = h * DPR;
|
| 183 |
}
|
| 184 |
mctx.save();
|
| 185 |
mctx.scale(DPR, DPR);
|
| 186 |
-
// bg
|
| 187 |
mctx.fillStyle = 'rgba(2,16,22,1)';
|
| 188 |
mctx.fillRect(0, 0, w, h);
|
| 189 |
-
|
|
|
|
| 190 |
mctx.strokeStyle = 'rgba(0,229,200,.08)';
|
| 191 |
mctx.lineWidth = .5;
|
| 192 |
-
for (let i = 0; i < w; i += 12) {
|
| 193 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
|
| 195 |
-
if (E.nodes.size === 0) { mctx.restore(); return; }
|
| 196 |
const { mnX, mnY, mxX, mxY } = computeBounds();
|
| 197 |
const sx = w / (mxX - mnX), sy = h / (mxY - mnY);
|
| 198 |
|
| 199 |
-
//
|
| 200 |
mctx.lineWidth = .6;
|
| 201 |
E.edges.forEach(ed => {
|
| 202 |
const s = E.nodes.get(ed.src), t = E.nodes.get(ed.dst);
|
|
@@ -208,62 +249,93 @@
|
|
| 208 |
mctx.stroke();
|
| 209 |
});
|
| 210 |
|
| 211 |
-
//
|
| 212 |
E.nodes.forEach(n => {
|
| 213 |
const px = (n.x - mnX) * sx, py = (n.y - mnY) * sy;
|
| 214 |
mctx.shadowColor = n.color;
|
| 215 |
mctx.shadowBlur = 6;
|
| 216 |
mctx.fillStyle = n.color;
|
|
|
|
|
|
|
| 217 |
mctx.beginPath();
|
| 218 |
-
mctx.arc(px, py,
|
| 219 |
mctx.fill();
|
| 220 |
});
|
| 221 |
mctx.shadowBlur = 0;
|
| 222 |
|
| 223 |
-
//
|
| 224 |
const vx = (E.cam.x - innerWidth / 2 / E.cam.z - mnX) * sx;
|
| 225 |
const vy = (E.cam.y - innerHeight / 2 / E.cam.z - mnY) * sy;
|
| 226 |
const vw = innerWidth / E.cam.z * sx;
|
| 227 |
const vh = innerHeight / E.cam.z * sy;
|
| 228 |
-
mctx.strokeStyle = 'rgba(25,214,255,.
|
| 229 |
mctx.lineWidth = 1.2;
|
| 230 |
mctx.setLineDash([3, 3]);
|
| 231 |
mctx.strokeRect(vx, vy, vw, vh);
|
| 232 |
mctx.setLineDash([]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
mctx.restore();
|
| 234 |
}
|
| 235 |
|
| 236 |
-
//
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
const
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
}
|
| 246 |
-
mini.addEventListener('pointerdown', e => { miniDrag = true; miniPan(e); mini.setPointerCapture(e.pointerId); });
|
| 247 |
-
mini.addEventListener('pointermove', e => { if (miniDrag) miniPan(e); });
|
| 248 |
-
mini.addEventListener('pointerup', e => { miniDrag = false; try { mini.releasePointerCapture(e.pointerId); } catch{} });
|
| 249 |
-
mini.addEventListener('pointercancel', () => { miniDrag = false; });
|
| 250 |
|
| 251 |
-
/
|
| 252 |
const SPAWN_RADIUS = 280;
|
| 253 |
|
| 254 |
window.elysiumAddNode = function (node, parentHint) {
|
| 255 |
-
if (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
const parentId = parentHint || 'CORE';
|
| 257 |
const parent = E.nodes.get(parentId) || E.nodes.get('CORE') || { x: 0, y: 0 };
|
| 258 |
const idx = E.nodes.size;
|
| 259 |
-
// golden-angle spiral around parent for organic feel
|
| 260 |
const golden = 2.39996;
|
| 261 |
const angle = (idx * golden) % (Math.PI * 2);
|
| 262 |
const dist = SPAWN_RADIUS + ((idx * 17) % 220);
|
| 263 |
const tx = parent.x + Math.cos(angle) * dist;
|
| 264 |
const ty = parent.y + Math.sin(angle) * dist;
|
| 265 |
const type = node.node_type || node.type || 'DOMAIN';
|
| 266 |
-
const radius = type === 'CORE' || type === 'CIVILIZATION' ? 36
|
| 267 |
: type === 'AGENT' ? 22
|
| 268 |
: type === 'TOOL' ? 14
|
| 269 |
: 18;
|
|
@@ -273,21 +345,21 @@
|
|
| 273 |
type,
|
| 274 |
label: node.label || node.node_id,
|
| 275 |
radius,
|
| 276 |
-
color: window.colorFor(type),
|
| 277 |
phase: Math.random() * Math.PI * 2,
|
| 278 |
born: performance.now(),
|
| 279 |
payload: node.payload || {},
|
| 280 |
embedding_hint: node.embedding_hint || '',
|
| 281 |
};
|
| 282 |
E.nodes.set(node.node_id, n);
|
| 283 |
-
//
|
| 284 |
E.cam.tx = E.cam.x * 0.86 + tx * 0.14;
|
| 285 |
E.cam.ty = E.cam.y * 0.86 + ty * 0.14;
|
| 286 |
return n;
|
| 287 |
};
|
| 288 |
|
| 289 |
window.elysiumAddEdge = function (edge) {
|
| 290 |
-
|
| 291 |
if (E.edges.some(e =>
|
| 292 |
e.src === edge.source_node_id &&
|
| 293 |
e.dst === edge.target_node_id &&
|
|
@@ -295,12 +367,12 @@
|
|
| 295 |
E.edges.push({
|
| 296 |
src: edge.source_node_id,
|
| 297 |
dst: edge.target_node_id,
|
| 298 |
-
type: edge.edge_type,
|
| 299 |
weight: edge.weight ?? 0.5,
|
| 300 |
-
color: edge.edge_type === 'CONFLICT' ? 'rgba(255,84,105,.6)'
|
| 301 |
edge.edge_type === 'COALITION' ? 'rgba(167,107,255,.65)' :
|
| 302 |
-
edge.edge_type === 'CAUSAL' ? 'rgba(25,214,255,.55)'
|
| 303 |
-
edge.edge_type === 'SUPPORTS' ? 'rgba(92,255,174,.55)'
|
| 304 |
'rgba(255,184,64,.55)',
|
| 305 |
born: performance.now(),
|
| 306 |
});
|
|
@@ -326,16 +398,15 @@
|
|
| 326 |
|
| 327 |
window.elysiumGetNode = (id) => E.nodes.get(id);
|
| 328 |
|
| 329 |
-
// legend filter
|
| 330 |
window.elysiumFilterType = function (type) {
|
| 331 |
E.filterType = (E.filterType === type) ? null : type;
|
| 332 |
};
|
| 333 |
|
| 334 |
-
/
|
| 335 |
function lerp(a, b, t) { return a + (b - a) * t; }
|
| 336 |
|
| 337 |
function loop(t) {
|
| 338 |
-
//
|
| 339 |
E.nodes.forEach(n => {
|
| 340 |
if (n.tx != null) {
|
| 341 |
n.x = lerp(n.x, n.tx, 0.12);
|
|
@@ -346,7 +417,7 @@
|
|
| 346 |
}
|
| 347 |
});
|
| 348 |
|
| 349 |
-
//
|
| 350 |
E.cam.z = lerp(E.cam.z, E.cam.tz, 0.10);
|
| 351 |
if (!E.cam.drag) {
|
| 352 |
E.cam.x = lerp(E.cam.x, E.cam.tx, 0.12);
|
|
@@ -357,7 +428,7 @@
|
|
| 357 |
if (Math.abs(E.cam.vy) < 0.05) E.cam.vy = 0;
|
| 358 |
}
|
| 359 |
|
| 360 |
-
//
|
| 361 |
ctx.fillStyle = '#02080c';
|
| 362 |
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
| 363 |
const grad = ctx.createRadialGradient(
|
|
@@ -370,14 +441,14 @@
|
|
| 370 |
|
| 371 |
drawAmbient(ctx, t);
|
| 372 |
|
| 373 |
-
//
|
| 374 |
ctx.save();
|
| 375 |
ctx.scale(DPR, DPR);
|
| 376 |
ctx.translate(innerWidth / 2, innerHeight / 2);
|
| 377 |
ctx.scale(E.cam.z, E.cam.z);
|
| 378 |
ctx.translate(-E.cam.x, -E.cam.y);
|
| 379 |
|
| 380 |
-
//
|
| 381 |
E.edges.forEach(e => {
|
| 382 |
const s = E.nodes.get(e.src), d = E.nodes.get(e.dst);
|
| 383 |
if (!s || !d) return;
|
|
@@ -402,11 +473,12 @@
|
|
| 402 |
});
|
| 403 |
ctx.globalAlpha = 1;
|
| 404 |
|
| 405 |
-
//
|
| 406 |
E.nodes.forEach(n => {
|
| 407 |
const dim = E.filterType && n.type !== E.filterType ? 0.22 : 1;
|
| 408 |
ctx.globalAlpha = dim;
|
| 409 |
-
window.drawNode(ctx, n, t, E.cam.z);
|
|
|
|
| 410 |
});
|
| 411 |
ctx.globalAlpha = 1;
|
| 412 |
ctx.restore();
|
|
@@ -415,7 +487,7 @@
|
|
| 415 |
requestAnimationFrame(loop);
|
| 416 |
}
|
| 417 |
|
| 418 |
-
//
|
| 419 |
const PARTICLES = Array.from({ length: 60 }, () => ({
|
| 420 |
x: Math.random() * innerWidth,
|
| 421 |
y: Math.random() * innerHeight,
|
|
|
|
| 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));
|
|
|
|
| 36 |
edges: [],
|
| 37 |
hover: null,
|
| 38 |
selected: null,
|
| 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 ────────
|
|
|
|
| 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 |
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 |
});
|
| 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 |
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 |
}, { 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 |
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 |
}
|
| 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);
|
| 206 |
mxX = Math.max(mxX, n.x); mxY = Math.max(mxY, n.y);
|
| 207 |
});
|
| 208 |
+
if (!isFinite(mnX)) { mnX = -200; mnY = -200; mxX = 200; mxY = 200; }
|
| 209 |
if (mxX - mnX < 100) { mnX -= 200; mxX += 200; }
|
| 210 |
if (mxY - mnY < 100) { mnY -= 200; mxY += 200; }
|
| 211 |
return { mnX: mnX - pad, mnY: mnY - pad,
|
|
|
|
| 213 |
}
|
| 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 |
}
|
| 222 |
mctx.save();
|
| 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 |
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;
|
|
|
|
| 332 |
const golden = 2.39996;
|
| 333 |
const angle = (idx * golden) % (Math.PI * 2);
|
| 334 |
const dist = SPAWN_RADIUS + ((idx * 17) % 220);
|
| 335 |
const tx = parent.x + Math.cos(angle) * dist;
|
| 336 |
const ty = parent.y + Math.sin(angle) * dist;
|
| 337 |
const type = node.node_type || node.type || 'DOMAIN';
|
| 338 |
+
const radius = (type === 'CORE' || type === 'CIVILIZATION') ? 36
|
| 339 |
: type === 'AGENT' ? 22
|
| 340 |
: type === 'TOOL' ? 14
|
| 341 |
: 18;
|
|
|
|
| 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;
|
| 359 |
};
|
| 360 |
|
| 361 |
window.elysiumAddEdge = function (edge) {
|
| 362 |
+
if (!edge || !edge.source_node_id || !edge.target_node_id) return;
|
| 363 |
if (E.edges.some(e =>
|
| 364 |
e.src === edge.source_node_id &&
|
| 365 |
e.dst === edge.target_node_id &&
|
|
|
|
| 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 |
});
|
|
|
|
| 398 |
|
| 399 |
window.elysiumGetNode = (id) => E.nodes.get(id);
|
| 400 |
|
|
|
|
| 401 |
window.elysiumFilterType = function (type) {
|
| 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 |
}
|
| 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 |
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 |
|
| 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 |
});
|
| 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 |
requestAnimationFrame(loop);
|
| 488 |
}
|
| 489 |
|
| 490 |
+
// Ambient particles
|
| 491 |
const PARTICLES = Array.from({ length: 60 }, () => ({
|
| 492 |
x: Math.random() * innerWidth,
|
| 493 |
y: Math.random() * innerHeight,
|
frontend/dist/assets/council.js
CHANGED
|
@@ -1,255 +1,368 @@
|
|
| 1 |
-
/*
|
|
|
|
|
|
|
|
|
|
| 2 |
Implements:
|
| 3 |
-
• Council slide-in panel with per-agent
|
| 4 |
-
•
|
| 5 |
-
• Per-agent mini play buttons
|
| 6 |
-
• Speaking-agent highlight
|
| 7 |
-
• Node-detail popover
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
window.renderCouncil = function (resp, runtime) {
|
| 32 |
-
const ov = overlay();
|
| 33 |
-
const body = document.getElementById('co-body');
|
| 34 |
-
const synth = document.getElementById('co-synth');
|
| 35 |
-
const count = document.getElementById('co-count');
|
| 36 |
-
const audio = document.getElementById('debate-audio');
|
| 37 |
-
_audio = audio;
|
| 38 |
-
|
| 39 |
-
const cd = resp.council_deliberation || {};
|
| 40 |
-
const agents = cd.agent_outputs || [];
|
| 41 |
-
_currentAgents = agents;
|
| 42 |
-
_agentAudios = (runtime && runtime.per_agent_audio) || [];
|
| 43 |
-
|
| 44 |
-
// No agents → hide overlay + pill
|
| 45 |
-
if (!agents.length) {
|
| 46 |
-
ov.classList.remove('show');
|
| 47 |
-
setTimeout(() => ov.classList.add('hidden'), 400);
|
| 48 |
-
pill().classList.add('hidden');
|
| 49 |
-
return;
|
| 50 |
}
|
| 51 |
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
const
|
| 61 |
-
const
|
| 62 |
-
const
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
});
|
| 99 |
-
|
| 100 |
-
audio
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
});
|
| 110 |
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
}).catch(() => {});
|
|
|
|
| 123 |
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
}
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
} else {
|
| 143 |
-
audio.style.display = 'none';
|
| 144 |
-
audio.removeAttribute('src');
|
| 145 |
-
document.getElementById('co-play').disabled = true;
|
| 146 |
-
document.getElementById('co-pause').disabled = true;
|
| 147 |
-
}
|
| 148 |
-
};
|
| 149 |
-
|
| 150 |
-
function highlightAgent(idx) {
|
| 151 |
-
const cards = document.querySelectorAll('#co-body .agent-card');
|
| 152 |
-
cards.forEach(c => c.classList.remove('speaking'));
|
| 153 |
-
cards[idx]?.classList.add('speaking');
|
| 154 |
-
}
|
| 155 |
-
function clearHighlight() {
|
| 156 |
-
document.querySelectorAll('#co-body .agent-card').forEach(c => c.classList.remove('speaking'));
|
| 157 |
-
}
|
| 158 |
-
|
| 159 |
-
/* ── Controls ── */
|
| 160 |
-
document.getElementById('co-close').onclick = () => {
|
| 161 |
-
overlay().classList.remove('show');
|
| 162 |
-
setTimeout(() => overlay().classList.add('hidden'), 400);
|
| 163 |
-
pill().classList.add('hidden');
|
| 164 |
-
if (_audio) { _audio.pause(); _audio.currentTime = 0; }
|
| 165 |
-
};
|
| 166 |
-
|
| 167 |
-
document.getElementById('co-min').onclick = () => {
|
| 168 |
-
overlay().classList.add('minimized');
|
| 169 |
-
if (_currentAgents.length) pill().classList.remove('hidden');
|
| 170 |
-
};
|
| 171 |
-
|
| 172 |
-
document.getElementById('council-pill').onclick = () => {
|
| 173 |
-
overlay().classList.remove('minimized');
|
| 174 |
-
pill().classList.add('hidden');
|
| 175 |
-
};
|
| 176 |
-
|
| 177 |
-
document.getElementById('co-play').onclick = () => {
|
| 178 |
-
if (!_audio || !_audio.src) return;
|
| 179 |
-
_audio.dataset.combined = '1';
|
| 180 |
-
_audio.play().then(() => {
|
| 181 |
-
document.getElementById('co-play').disabled = true;
|
| 182 |
-
document.getElementById('co-pause').disabled = false;
|
| 183 |
-
}).catch(() => {});
|
| 184 |
-
};
|
| 185 |
-
document.getElementById('co-pause').onclick = () => {
|
| 186 |
-
if (!_audio) return;
|
| 187 |
-
_audio.pause();
|
| 188 |
-
document.getElementById('co-play').disabled = false;
|
| 189 |
-
document.getElementById('co-pause').disabled = true;
|
| 190 |
-
};
|
| 191 |
-
|
| 192 |
-
/* ── My-Agent button toggles the overlay ── */
|
| 193 |
-
document.getElementById('my-agent').onclick = () => {
|
| 194 |
-
const ov = overlay();
|
| 195 |
-
if (ov.classList.contains('hidden') || ov.classList.contains('minimized')) {
|
| 196 |
-
if (!_currentAgents.length) {
|
| 197 |
-
window.toast?.('No agents yet — ask a complex question to summon the council', 'info');
|
| 198 |
-
return;
|
| 199 |
}
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
<div class="nd-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
const connsHtml = conns.length
|
| 244 |
-
? conns.slice(0,
|
| 245 |
<div class="nd-conn">
|
| 246 |
-
<span class="ct">${escapeHtml(c.from_label || c.to_label || c.from || c.to)}</span>
|
| 247 |
-
<span class="cw">${(c.weight ?
|
| 248 |
</div>`).join('')
|
| 249 |
: '<div class="nd-conn"><span class="ct">No connections yet</span></div>';
|
| 250 |
|
| 251 |
const payload = d.payload && Object.keys(d.payload).length
|
| 252 |
-
? Object.entries(d.payload).slice(0,
|
| 253 |
`${escapeHtml(k)}: ${escapeHtml(typeof v === 'object' ? JSON.stringify(v) : String(v))}`
|
| 254 |
).join('\n')
|
| 255 |
: (d.embedding_hint || 'No payload data');
|
|
@@ -257,13 +370,14 @@ window.showNodeDetail = async function (n, sx, sy) {
|
|
| 257 |
el.innerHTML = `
|
| 258 |
<div class="nd-head">
|
| 259 |
<div class="nd-title">
|
| 260 |
-
<span class="nd-dot" style="background:${
|
| 261 |
-
${escapeHtml(d.label)}
|
| 262 |
-
<
|
|
|
|
| 263 |
</div>
|
| 264 |
-
<div class="nd-type">${escapeHtml(d.node_type)}</div>
|
| 265 |
<div class="nd-stats">
|
| 266 |
-
<div class="nd-pill"><b>${d.degree}</b>links</div>
|
| 267 |
<div class="nd-pill"><b>${(d.incoming || []).length}</b>in</div>
|
| 268 |
<div class="nd-pill"><b>${(d.outgoing || []).length}</b>out</div>
|
| 269 |
</div>
|
|
@@ -272,24 +386,30 @@ window.showNodeDetail = async function (n, sx, sy) {
|
|
| 272 |
<div class="nd-section">CONNECTIONS</div>
|
| 273 |
<div class="nd-conns">${connsHtml}</div>
|
| 274 |
`;
|
| 275 |
-
|
| 276 |
-
|
| 277 |
}
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
//
|
| 292 |
-
if (e.target.
|
|
|
|
| 293 |
window.hideNodeDetail();
|
| 294 |
-
}
|
| 295 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
ELYSIUM — council.js
|
| 3 |
+
Council overlay + Node-detail popover.
|
| 4 |
+
|
| 5 |
Implements:
|
| 6 |
+
• Council slide-in panel with per-agent cards
|
| 7 |
+
• Combined-track playback (▶ / ⏸)
|
| 8 |
+
• Per-agent mini play buttons
|
| 9 |
+
• Speaking-agent highlight synced to audio time
|
| 10 |
+
• Node-detail popover (Task 3) — fetches /api/node/:id and
|
| 11 |
+
falls back to local node payload if backend not reachable
|
| 12 |
+
• All DOM access is defensive — no "classList of null" crashes
|
| 13 |
+
============================================================ */
|
| 14 |
+
(() => {
|
| 15 |
+
'use strict';
|
| 16 |
+
|
| 17 |
+
const AGENT_COLORS = {
|
| 18 |
+
THE_BUILDER: '#ff4fa3',
|
| 19 |
+
THE_GUARDIAN: '#a76bff',
|
| 20 |
+
THE_ORACLE: '#19d6ff',
|
| 21 |
+
THE_WEAVER: '#ff80c4',
|
| 22 |
+
THE_WILDCARD: '#5cffae',
|
| 23 |
+
DYNAMIC: '#ffb840',
|
| 24 |
+
};
|
| 25 |
+
window.agentColor = (a) => AGENT_COLORS[a] || '#a76bff';
|
| 26 |
+
|
| 27 |
+
const $ = (id) => document.getElementById(id);
|
| 28 |
+
const safe = (fn) => { try { return fn(); } catch (e) { console.warn(e); } };
|
| 29 |
+
|
| 30 |
+
function escapeHtml(s) {
|
| 31 |
+
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
|
| 32 |
+
'&':'&','<':'<','>':'>','"':'"',"'":'''
|
| 33 |
+
}[c]));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
}
|
| 35 |
|
| 36 |
+
let _audio = null;
|
| 37 |
+
let _agentAudios = [];
|
| 38 |
+
let _currentAgents = [];
|
| 39 |
+
|
| 40 |
+
/* =========================================================
|
| 41 |
+
RENDER COUNCIL (called from boot.js handleResponse)
|
| 42 |
+
========================================================= */
|
| 43 |
+
window.renderCouncil = function (resp, runtime) {
|
| 44 |
+
const ov = $('council-overlay');
|
| 45 |
+
const body = $('co-body');
|
| 46 |
+
const synth = $('co-synth');
|
| 47 |
+
const count = $('co-count');
|
| 48 |
+
const audio = $('debate-audio');
|
| 49 |
+
if (!ov || !body) return;
|
| 50 |
+
_audio = audio;
|
| 51 |
+
|
| 52 |
+
const cd = (resp && resp.council_deliberation) || {};
|
| 53 |
+
const agents = Array.isArray(cd.agent_outputs) ? cd.agent_outputs : [];
|
| 54 |
+
_currentAgents = agents;
|
| 55 |
+
_agentAudios = (runtime && Array.isArray(runtime.per_agent_audio))
|
| 56 |
+
? runtime.per_agent_audio : [];
|
| 57 |
+
|
| 58 |
+
// No agents → hide overlay + pill
|
| 59 |
+
if (!agents.length) {
|
| 60 |
+
ov.classList.remove('show');
|
| 61 |
+
setTimeout(() => ov.classList.add('hidden'), 400);
|
| 62 |
+
const pill = $('council-pill'); if (pill) pill.classList.add('hidden');
|
| 63 |
+
if (synth) synth.textContent = '';
|
| 64 |
+
if (audio) { try { audio.pause(); } catch {} audio.removeAttribute('src'); audio.style.display='none'; }
|
| 65 |
+
if (count) count.textContent = '+0';
|
| 66 |
+
return;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
ov.classList.remove('hidden');
|
| 70 |
+
requestAnimationFrame(() => ov.classList.add('show'));
|
| 71 |
+
|
| 72 |
+
if (count) count.textContent = `+${agents.length}`;
|
| 73 |
+
const pillCount = $('pill-count');
|
| 74 |
+
if (pillCount) pillCount.textContent = agents.length;
|
| 75 |
+
|
| 76 |
+
body.innerHTML = agents.map((a, i) => {
|
| 77 |
+
if (!a) return '';
|
| 78 |
+
const c = window.agentColor(a.archetype);
|
| 79 |
+
const audioInfo = _agentAudios[i] || {};
|
| 80 |
+
const hasAudio = !!audioInfo.audio_url;
|
| 81 |
+
const archetype = (a.archetype || 'DYNAMIC').replace(/^THE_/, '');
|
| 82 |
+
return `
|
| 83 |
+
<div class="agent-card" style="border-color:${c}; --card-glow:${c}33; animation-delay:${i * 80}ms"
|
| 84 |
+
data-aid="${escapeHtml(a.agent_id)}" data-idx="${i}">
|
| 85 |
+
<div class="row1">
|
| 86 |
+
<span class="ad-dot" style="background:${c};color:${c}"></span>
|
| 87 |
+
<span class="name" style="color:${c}">${escapeHtml(a.agent_name || 'Agent')}</span>
|
| 88 |
+
<span class="archetype">${escapeHtml(archetype)}</span>
|
| 89 |
+
${a.veto_triggered ? '<span class="veto">VETO</span>' : ''}
|
| 90 |
+
</div>
|
| 91 |
+
<div class="thinking">${escapeHtml(a.thinking || '')}</div>
|
| 92 |
+
<div class="stance">${escapeHtml(a.tts_speech_text || a.stance || '')}</div>
|
| 93 |
+
<div class="footer-row">
|
| 94 |
+
<div class="conf">confidence ${(typeof a.confidence === 'number' ? a.confidence : 0.8).toFixed(2)}</div>
|
| 95 |
+
${hasAudio ? `<button class="play-mini" data-aurl="${audioInfo.audio_url}" data-idx="${i}" title="Play this agent's voice">▶</button>` : ''}
|
| 96 |
+
</div>
|
| 97 |
+
</div>`;
|
| 98 |
+
}).join('');
|
| 99 |
+
|
| 100 |
+
if (synth) synth.textContent = cd.final_synthesis || '';
|
| 101 |
+
|
| 102 |
+
// Wire per-agent mini-play buttons
|
| 103 |
+
if (audio) {
|
| 104 |
+
body.querySelectorAll('.play-mini').forEach(btn => {
|
| 105 |
+
btn.onclick = (ev) => {
|
| 106 |
+
ev.stopPropagation();
|
| 107 |
+
const url = btn.dataset.aurl;
|
| 108 |
+
const idx = +btn.dataset.idx;
|
| 109 |
+
if (audio.src && audio.src.endsWith(url) && !audio.paused) {
|
| 110 |
+
audio.pause();
|
| 111 |
+
btn.classList.remove('playing');
|
| 112 |
+
btn.textContent = '▶';
|
| 113 |
+
return;
|
| 114 |
+
}
|
| 115 |
+
body.querySelectorAll('.play-mini.playing').forEach(b => {
|
| 116 |
+
b.classList.remove('playing'); b.textContent = '▶';
|
| 117 |
+
});
|
| 118 |
+
audio.dataset.combined = '0';
|
| 119 |
+
audio.src = url;
|
| 120 |
+
audio.play().catch(() => {});
|
| 121 |
+
btn.classList.add('playing');
|
| 122 |
+
btn.textContent = '⏸';
|
| 123 |
+
highlightAgent(idx);
|
| 124 |
+
audio.onended = () => {
|
| 125 |
+
btn.classList.remove('playing'); btn.textContent = '▶';
|
| 126 |
+
clearHighlight();
|
| 127 |
+
};
|
| 128 |
+
};
|
| 129 |
});
|
| 130 |
+
|
| 131 |
+
// Combined audio drama (if backend produced one)
|
| 132 |
+
if (runtime && runtime.audio_url) {
|
| 133 |
+
audio.style.display = 'block';
|
| 134 |
+
audio.src = runtime.audio_url;
|
| 135 |
+
audio.dataset.combined = '1';
|
| 136 |
+
const playBtn = $('co-play');
|
| 137 |
+
const pauseBtn = $('co-pause');
|
| 138 |
+
if (playBtn) playBtn.disabled = false;
|
| 139 |
+
if (pauseBtn) pauseBtn.disabled = true;
|
| 140 |
+
audio.play().then(() => {
|
| 141 |
+
if (playBtn) playBtn.disabled = true;
|
| 142 |
+
if (pauseBtn) pauseBtn.disabled = false;
|
| 143 |
+
}).catch(() => {});
|
| 144 |
+
|
| 145 |
+
const cards = body.querySelectorAll('.agent-card');
|
| 146 |
+
let idx = 0;
|
| 147 |
+
if (cards[0]) cards[0].classList.add('speaking');
|
| 148 |
+
audio.ontimeupdate = () => {
|
| 149 |
+
if (!audio.duration || audio.dataset.combined !== '1') return;
|
| 150 |
+
const target = Math.min(cards.length - 1,
|
| 151 |
+
Math.floor((audio.currentTime / audio.duration) * cards.length));
|
| 152 |
+
if (target !== idx) {
|
| 153 |
+
cards[idx]?.classList.remove('speaking');
|
| 154 |
+
cards[target]?.classList.add('speaking');
|
| 155 |
+
idx = target;
|
| 156 |
+
}
|
| 157 |
+
};
|
| 158 |
+
audio.onended = () => {
|
| 159 |
+
cards.forEach(c => c.classList.remove('speaking'));
|
| 160 |
+
if (playBtn) playBtn.disabled = false;
|
| 161 |
+
if (pauseBtn) pauseBtn.disabled = true;
|
| 162 |
+
};
|
| 163 |
+
} else {
|
| 164 |
+
audio.style.display = 'none';
|
| 165 |
+
audio.removeAttribute('src');
|
| 166 |
+
const playBtn = $('co-play');
|
| 167 |
+
const pauseBtn = $('co-pause');
|
| 168 |
+
if (playBtn) playBtn.disabled = true;
|
| 169 |
+
if (pauseBtn) pauseBtn.disabled = true;
|
| 170 |
+
}
|
| 171 |
+
}
|
| 172 |
+
};
|
| 173 |
+
|
| 174 |
+
function highlightAgent(idx) {
|
| 175 |
+
const cards = document.querySelectorAll('#co-body .agent-card');
|
| 176 |
+
cards.forEach(c => c.classList.remove('speaking'));
|
| 177 |
+
if (cards[idx]) cards[idx].classList.add('speaking');
|
| 178 |
+
}
|
| 179 |
+
function clearHighlight() {
|
| 180 |
+
document.querySelectorAll('#co-body .agent-card').forEach(c => c.classList.remove('speaking'));
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
/* =========================================================
|
| 184 |
+
OVERLAY CONTROLS (defensive — only wire if button exists)
|
| 185 |
+
========================================================= */
|
| 186 |
+
const wire = (id, fn) => { const el = $(id); if (el) el.onclick = fn; };
|
| 187 |
+
|
| 188 |
+
wire('co-close', () => {
|
| 189 |
+
const ov = $('council-overlay');
|
| 190 |
+
if (ov) {
|
| 191 |
+
ov.classList.remove('show');
|
| 192 |
+
setTimeout(() => ov.classList.add('hidden'), 400);
|
| 193 |
+
}
|
| 194 |
+
const pill = $('council-pill'); if (pill) pill.classList.add('hidden');
|
| 195 |
+
if (_audio) { try { _audio.pause(); _audio.currentTime = 0; } catch {} }
|
| 196 |
});
|
| 197 |
|
| 198 |
+
wire('co-min', () => {
|
| 199 |
+
const ov = $('council-overlay'); if (ov) ov.classList.add('minimized');
|
| 200 |
+
if (_currentAgents.length) { const p = $('council-pill'); if (p) p.classList.remove('hidden'); }
|
| 201 |
+
});
|
| 202 |
+
|
| 203 |
+
wire('council-pill', () => {
|
| 204 |
+
const ov = $('council-overlay');
|
| 205 |
+
if (ov) ov.classList.remove('minimized');
|
| 206 |
+
const p = $('council-pill'); if (p) p.classList.add('hidden');
|
| 207 |
+
});
|
| 208 |
+
|
| 209 |
+
wire('co-play', () => {
|
| 210 |
+
if (!_audio || !_audio.src) return;
|
| 211 |
+
_audio.dataset.combined = '1';
|
| 212 |
+
_audio.play().then(() => {
|
| 213 |
+
const playBtn = $('co-play');
|
| 214 |
+
const pauseBtn = $('co-pause');
|
| 215 |
+
if (playBtn) playBtn.disabled = true;
|
| 216 |
+
if (pauseBtn) pauseBtn.disabled = false;
|
| 217 |
}).catch(() => {});
|
| 218 |
+
});
|
| 219 |
|
| 220 |
+
wire('co-pause', () => {
|
| 221 |
+
if (!_audio) return;
|
| 222 |
+
try { _audio.pause(); } catch {}
|
| 223 |
+
const playBtn = $('co-play');
|
| 224 |
+
const pauseBtn = $('co-pause');
|
| 225 |
+
if (playBtn) playBtn.disabled = false;
|
| 226 |
+
if (pauseBtn) pauseBtn.disabled = true;
|
| 227 |
+
});
|
| 228 |
+
|
| 229 |
+
// My-Agent button: toggle overlay (and warn if empty)
|
| 230 |
+
wire('my-agent', () => {
|
| 231 |
+
const ov = $('council-overlay');
|
| 232 |
+
if (!ov) return;
|
| 233 |
+
if (ov.classList.contains('hidden') || ov.classList.contains('minimized')) {
|
| 234 |
+
if (!_currentAgents.length) {
|
| 235 |
+
if (window.toast) window.toast('No agents yet — ask a complex question to summon the council', 'info');
|
| 236 |
+
return;
|
| 237 |
}
|
| 238 |
+
ov.classList.remove('hidden', 'minimized');
|
| 239 |
+
requestAnimationFrame(() => ov.classList.add('show'));
|
| 240 |
+
const p = $('council-pill'); if (p) p.classList.add('hidden');
|
| 241 |
+
} else {
|
| 242 |
+
ov.classList.remove('show');
|
| 243 |
+
setTimeout(() => ov.classList.add('hidden'), 400);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
}
|
| 245 |
+
});
|
| 246 |
+
|
| 247 |
+
/* =========================================================
|
| 248 |
+
NODE DETAIL POPOVER (Task 3)
|
| 249 |
+
Click any node → fetch enriched detail from /api/node/:id,
|
| 250 |
+
fall back to local data if offline.
|
| 251 |
+
========================================================= */
|
| 252 |
+
window.showNodeDetail = async function (n, sx, sy) {
|
| 253 |
+
const el = $('node-detail');
|
| 254 |
+
if (!el || !n) return;
|
| 255 |
+
|
| 256 |
+
if (typeof window.elysiumSelect === 'function') {
|
| 257 |
+
try { window.elysiumSelect(n.node_id); } catch {}
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
// Position popover near click but keep on-screen
|
| 261 |
+
const W = window.innerWidth, H = window.innerHeight;
|
| 262 |
+
const left = Math.min(W - 340, Math.max(10, (sx || W/2) + 14));
|
| 263 |
+
const top = Math.min(H - 360, Math.max(10, (sy || H/2) - 40));
|
| 264 |
+
el.style.left = left + 'px';
|
| 265 |
+
el.style.top = top + 'px';
|
| 266 |
+
|
| 267 |
+
// Initial skeleton with locally-known data (instant render)
|
| 268 |
+
const localPayload = n.payload && Object.keys(n.payload).length
|
| 269 |
+
? Object.entries(n.payload).slice(0, 6).map(([k, v]) =>
|
| 270 |
+
`${escapeHtml(k)}: ${escapeHtml(typeof v === 'object' ? JSON.stringify(v) : String(v))}`
|
| 271 |
+
).join('\n')
|
| 272 |
+
: (n.embedding_hint || 'Loading details…');
|
| 273 |
+
|
| 274 |
+
el.innerHTML = `
|
| 275 |
+
<div class="nd-head">
|
| 276 |
+
<div class="nd-title">
|
| 277 |
+
<span class="nd-dot" style="background:${n.color};color:${n.color}"></span>
|
| 278 |
+
${escapeHtml(n.label)}
|
| 279 |
+
</div>
|
| 280 |
+
<button class="nd-close" aria-label="Close">×</button>
|
| 281 |
+
</div>
|
| 282 |
+
<div class="nd-type">${escapeHtml(n.type)}</div>
|
| 283 |
+
<div class="nd-stats">
|
| 284 |
+
<div class="nd-pill"><b>…</b>links</div>
|
| 285 |
+
<div class="nd-pill"><b>…</b>in</div>
|
| 286 |
+
<div class="nd-pill"><b>…</b>out</div>
|
| 287 |
+
</div>
|
| 288 |
+
<div class="nd-section">PAYLOAD</div>
|
| 289 |
+
<div class="nd-payload">${escapeHtml(localPayload)}</div>
|
| 290 |
+
<div class="nd-section">CONNECTIONS</div>
|
| 291 |
+
<div class="nd-conns"><div class="nd-conn"><span class="ct">Loading…</span></div></div>
|
| 292 |
+
`;
|
| 293 |
+
el.classList.remove('hidden');
|
| 294 |
+
requestAnimationFrame(() => el.classList.add('show'));
|
| 295 |
+
|
| 296 |
+
// Wire close
|
| 297 |
+
const closeBtn = el.querySelector('.nd-close');
|
| 298 |
+
if (closeBtn) closeBtn.onclick = (ev) => { ev.stopPropagation(); window.hideNodeDetail(); };
|
| 299 |
+
|
| 300 |
+
// Compute local connections immediately (works even offline)
|
| 301 |
+
const localConns = [];
|
| 302 |
+
if (window.ELYSIUM) {
|
| 303 |
+
window.ELYSIUM.edges.forEach(e => {
|
| 304 |
+
if (e.src === n.node_id) {
|
| 305 |
+
const target = window.ELYSIUM.nodes.get(e.dst);
|
| 306 |
+
localConns.push({
|
| 307 |
+
ct: target ? target.label : e.dst,
|
| 308 |
+
type: e.type,
|
| 309 |
+
weight: e.weight,
|
| 310 |
+
dir: 'out',
|
| 311 |
+
});
|
| 312 |
+
} else if (e.dst === n.node_id) {
|
| 313 |
+
const source = window.ELYSIUM.nodes.get(e.src);
|
| 314 |
+
localConns.push({
|
| 315 |
+
ct: source ? source.label : e.src,
|
| 316 |
+
type: e.type,
|
| 317 |
+
weight: e.weight,
|
| 318 |
+
dir: 'in',
|
| 319 |
+
});
|
| 320 |
+
}
|
| 321 |
+
});
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
// Render with local data first
|
| 325 |
+
renderDetail(el, {
|
| 326 |
+
node_id: n.node_id,
|
| 327 |
+
label: n.label,
|
| 328 |
+
node_type: n.type,
|
| 329 |
+
payload: n.payload || {},
|
| 330 |
+
embedding_hint: n.embedding_hint || '',
|
| 331 |
+
incoming: localConns.filter(c => c.dir === 'in').map(c => ({
|
| 332 |
+
from_label: c.ct, edge_type: c.type, weight: c.weight,
|
| 333 |
+
})),
|
| 334 |
+
outgoing: localConns.filter(c => c.dir === 'out').map(c => ({
|
| 335 |
+
to_label: c.ct, edge_type: c.type, weight: c.weight,
|
| 336 |
+
})),
|
| 337 |
+
degree: localConns.length,
|
| 338 |
+
});
|
| 339 |
+
|
| 340 |
+
// Then try to enrich from backend (silent fail)
|
| 341 |
+
if (window.ElysiumAPI && window.ElysiumAPI.nodeDetail) {
|
| 342 |
+
try {
|
| 343 |
+
const d = await window.ElysiumAPI.nodeDetail(n.node_id);
|
| 344 |
+
if (d) renderDetail(el, d);
|
| 345 |
+
} catch (e) { /* keep local data */ }
|
| 346 |
+
}
|
| 347 |
+
};
|
| 348 |
+
|
| 349 |
+
function renderDetail(el, d) {
|
| 350 |
+
if (!el || !d) return;
|
| 351 |
+
const n = window.ELYSIUM ? window.ELYSIUM.nodes.get(d.node_id) : null;
|
| 352 |
+
const color = (n && n.color) || (window.colorFor ? window.colorFor(d.node_type) : '#a76bff');
|
| 353 |
+
|
| 354 |
+
const conns = (Array.isArray(d.incoming) ? d.incoming : [])
|
| 355 |
+
.concat(Array.isArray(d.outgoing) ? d.outgoing : []);
|
| 356 |
const connsHtml = conns.length
|
| 357 |
+
? conns.slice(0, 8).map(c => `
|
| 358 |
<div class="nd-conn">
|
| 359 |
+
<span class="ct">${escapeHtml(c.from_label || c.to_label || c.from || c.to || '?')}</span>
|
| 360 |
+
<span class="cw">${(typeof c.weight === 'number' ? c.weight : 0.5).toFixed(2)}</span>
|
| 361 |
</div>`).join('')
|
| 362 |
: '<div class="nd-conn"><span class="ct">No connections yet</span></div>';
|
| 363 |
|
| 364 |
const payload = d.payload && Object.keys(d.payload).length
|
| 365 |
+
? Object.entries(d.payload).slice(0, 8).map(([k, v]) =>
|
| 366 |
`${escapeHtml(k)}: ${escapeHtml(typeof v === 'object' ? JSON.stringify(v) : String(v))}`
|
| 367 |
).join('\n')
|
| 368 |
: (d.embedding_hint || 'No payload data');
|
|
|
|
| 370 |
el.innerHTML = `
|
| 371 |
<div class="nd-head">
|
| 372 |
<div class="nd-title">
|
| 373 |
+
<span class="nd-dot" style="background:${color};color:${color}"></span>
|
| 374 |
+
${escapeHtml(d.label || d.node_id)}
|
| 375 |
+
</div>
|
| 376 |
+
<button class="nd-close" aria-label="Close">×</button>
|
| 377 |
</div>
|
| 378 |
+
<div class="nd-type">${escapeHtml(d.node_type || '')}</div>
|
| 379 |
<div class="nd-stats">
|
| 380 |
+
<div class="nd-pill"><b>${d.degree ?? conns.length}</b>links</div>
|
| 381 |
<div class="nd-pill"><b>${(d.incoming || []).length}</b>in</div>
|
| 382 |
<div class="nd-pill"><b>${(d.outgoing || []).length}</b>out</div>
|
| 383 |
</div>
|
|
|
|
| 386 |
<div class="nd-section">CONNECTIONS</div>
|
| 387 |
<div class="nd-conns">${connsHtml}</div>
|
| 388 |
`;
|
| 389 |
+
const closeBtn = el.querySelector('.nd-close');
|
| 390 |
+
if (closeBtn) closeBtn.onclick = (ev) => { ev.stopPropagation(); window.hideNodeDetail(); };
|
| 391 |
}
|
| 392 |
+
|
| 393 |
+
window.hideNodeDetail = function () {
|
| 394 |
+
const el = $('node-detail');
|
| 395 |
+
if (!el) return;
|
| 396 |
+
el.classList.remove('show');
|
| 397 |
+
setTimeout(() => el.classList.add('hidden'), 250);
|
| 398 |
+
if (window.ELYSIUM) window.ELYSIUM.nodes.forEach(n => n.selected = false);
|
| 399 |
+
};
|
| 400 |
+
|
| 401 |
+
// Click outside popover (and outside canvas) → close
|
| 402 |
+
document.addEventListener('pointerdown', e => {
|
| 403 |
+
const nd = $('node-detail');
|
| 404 |
+
if (!nd || nd.classList.contains('hidden')) return;
|
| 405 |
+
if (nd.contains(e.target)) return; // click inside popover
|
| 406 |
+
if (e.target.id === 'elysium-canvas') return; // canvas handles its own
|
| 407 |
+
if (e.target.closest && e.target.closest('#minimap-wrap')) return;
|
| 408 |
window.hideNodeDetail();
|
| 409 |
+
});
|
| 410 |
+
|
| 411 |
+
// Escape closes popover
|
| 412 |
+
document.addEventListener('keydown', e => {
|
| 413 |
+
if (e.key === 'Escape') window.hideNodeDetail();
|
| 414 |
+
});
|
| 415 |
+
})();
|
frontend/dist/assets/elysium.css
CHANGED
|
@@ -1,8 +1,5 @@
|
|
| 1 |
/* ============================================================
|
| 2 |
ELYSIUM — Dark Neon Bioluminescent Theme
|
| 3 |
-
Heavily inspired by the reference images:
|
| 4 |
-
deep teal-black backdrop, magenta/cyan/violet/gold neon nodes,
|
| 5 |
-
glassy panels with neon hairline borders.
|
| 6 |
============================================================ */
|
| 7 |
:root{
|
| 8 |
--bg-0:#02080c;
|
|
@@ -45,8 +42,7 @@
|
|
| 45 |
|
| 46 |
*,*::before,*::after{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
|
| 47 |
html,body{margin:0;padding:0;height:100%;width:100%;overflow:hidden;
|
| 48 |
-
background:
|
| 49 |
-
radial-gradient(ellipse 90% 70% at 50% 40%, #0a2434 0%, #03101a 55%, #01060b 100%);
|
| 50 |
color:var(--text);font-family:var(--font-sans);font-size:14px;
|
| 51 |
user-select:none;-webkit-user-select:none}
|
| 52 |
button{font-family:inherit;color:inherit;cursor:pointer}
|
|
@@ -56,7 +52,7 @@ button{font-family:inherit;color:inherit;cursor:pointer}
|
|
| 56 |
touch-action:none;z-index:0}
|
| 57 |
#elysium-canvas.dragging{cursor:grabbing}
|
| 58 |
|
| 59 |
-
/* ── GLASS
|
| 60 |
.glass{
|
| 61 |
background:linear-gradient(180deg, var(--panel), var(--panel-2));
|
| 62 |
border:1px solid var(--border);
|
|
@@ -111,16 +107,16 @@ button{font-family:inherit;color:inherit;cursor:pointer}
|
|
| 111 |
box-shadow:inset 0 0 8px rgba(0,229,200,.08)}
|
| 112 |
|
| 113 |
/* ── MINIMAP ──────────────────────────────────────────── */
|
| 114 |
-
#minimap-wrap{position:fixed;left:74px;bottom:
|
| 115 |
background:rgba(2,12,18,.85);border:1px solid var(--border);border-radius:12px;
|
| 116 |
padding:6px 6px 4px;box-shadow:var(--shadow);backdrop-filter:blur(10px)}
|
| 117 |
.minimap-label{font-size:9px;letter-spacing:2.2px;color:var(--muted);
|
| 118 |
text-align:center;margin-bottom:4px;font-weight:600}
|
| 119 |
#minimap{display:block;width:170px;height:108px;border-radius:8px;cursor:crosshair;
|
| 120 |
-
background:rgba(2,16,22,.95)}
|
| 121 |
|
| 122 |
/* ── ZOOM CTRL ────────────────────────────────────────── */
|
| 123 |
-
#zoom-ctrl{position:fixed;right:24px;bottom:
|
| 124 |
gap:6px;z-index:15}
|
| 125 |
#zoom-ctrl button{width:38px;height:38px;background:rgba(6,18,26,.85);
|
| 126 |
border:1px solid var(--border);border-radius:10px;color:var(--teal);
|
|
@@ -140,7 +136,7 @@ body[data-alert="CRISIS"] #alert-scrim{background:radial-gradient(ellipse at cen
|
|
| 140 |
@keyframes crisisPulse{0%,100%{opacity:.6}50%{opacity:1}}
|
| 141 |
|
| 142 |
/* ── STATS BAR ────────────────────────────────────────── */
|
| 143 |
-
#stats{position:fixed;left:58px;right:0;bottom:
|
| 144 |
display:flex;align-items:center;padding:0 28px;gap:32px;z-index:18;
|
| 145 |
border-radius:0;border-left:none;border-right:none;border-bottom:none;
|
| 146 |
background:linear-gradient(180deg,rgba(2,12,18,.72),rgba(2,8,12,.92))}
|
|
@@ -152,28 +148,36 @@ body[data-alert="CRISIS"] #alert-scrim{background:radial-gradient(ellipse at cen
|
|
| 152 |
.stat-value{font-size:22px;font-family:var(--font-disp);color:var(--cyan);
|
| 153 |
font-weight:700;line-height:1;text-shadow:0 0 8px rgba(25,214,255,.45)}
|
| 154 |
|
| 155 |
-
/* ── QUERY AREA
|
| 156 |
#query-area{position:fixed;left:58px;right:0;bottom:0;z-index:20;
|
| 157 |
-
display:flex;flex-direction:column;gap:0}
|
| 158 |
|
|
|
|
| 159 |
#attach-strip{margin:0 22px 6px;padding:8px 10px;display:flex;gap:8px;
|
| 160 |
-
flex-wrap:wrap;align-items:center;border-radius:14px
|
| 161 |
-
|
|
|
|
|
|
|
| 162 |
.preview-tile{display:flex;align-items:center;gap:8px;
|
| 163 |
background:rgba(0,229,200,.06);border:1px solid var(--border-strong);
|
| 164 |
-
border-radius:10px;padding:4px 8px 4px 4px;font-size:11px;color:var(--text)
|
|
|
|
|
|
|
| 165 |
.preview-tile img{width:38px;height:38px;border-radius:6px;object-fit:cover;
|
| 166 |
box-shadow:0 0 8px rgba(0,229,200,.3)}
|
| 167 |
.preview-tile .pdf-ico{width:38px;height:38px;border-radius:6px;display:grid;
|
| 168 |
place-items:center;background:linear-gradient(135deg,#3a1a2a,#1a0a14);
|
| 169 |
color:var(--magenta);font-family:var(--font-mono);font-size:11px;font-weight:700;
|
| 170 |
box-shadow:var(--neon-magenta)}
|
| 171 |
-
.preview-tile .nm{max-width:
|
| 172 |
white-space:nowrap;font-family:var(--font-mono)}
|
| 173 |
-
.preview-tile .x{background:
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
|
|
|
|
|
|
|
|
|
| 177 |
|
| 178 |
#query-bar{margin:0 22px 8px;height:54px;
|
| 179 |
display:flex;align-items:center;padding:0 12px;gap:10px;
|
|
@@ -289,7 +293,7 @@ body[data-alert="CRISIS"] #alert-scrim{background:radial-gradient(ellipse at cen
|
|
| 289 |
#co-synth:empty{display:none}
|
| 290 |
#debate-audio{width:100%;margin-top:10px;height:32px;display:none}
|
| 291 |
|
| 292 |
-
/* COUNCIL PILL (minimized
|
| 293 |
#council-pill{position:fixed;left:22px;top:22px;z-index:24;
|
| 294 |
padding:8px 16px;border-radius:20px;background:rgba(6,18,26,.92);
|
| 295 |
border:1px solid var(--border-strong);color:var(--text);font-size:12px;
|
|
@@ -300,12 +304,13 @@ body[data-alert="CRISIS"] #alert-scrim{background:radial-gradient(ellipse at cen
|
|
| 300 |
#council-pill .dot-mini{width:8px;height:8px;border-radius:50%;background:var(--magenta);
|
| 301 |
box-shadow:var(--neon-magenta)}
|
| 302 |
|
| 303 |
-
/* ── NODE DETAIL POPOVER ───────────────────────
|
| 304 |
#node-detail{position:fixed;width:320px;padding:18px;z-index:30;
|
| 305 |
opacity:0;transform:translateY(8px);pointer-events:none;
|
| 306 |
transition:opacity .25s,transform .25s;
|
| 307 |
border:1px solid var(--border-strong);box-shadow:var(--neon-teal),var(--shadow)}
|
| 308 |
#node-detail.show{opacity:1;transform:translateY(0);pointer-events:auto}
|
|
|
|
| 309 |
.nd-head{display:flex;justify-content:space-between;align-items:flex-start;
|
| 310 |
margin-bottom:8px;gap:8px}
|
| 311 |
.nd-title{font-size:14px;font-weight:700;display:flex;align-items:center;gap:8px;
|
|
@@ -313,7 +318,7 @@ body[data-alert="CRISIS"] #alert-scrim{background:radial-gradient(ellipse at cen
|
|
| 313 |
.nd-title .nd-dot{width:16px;height:16px;border-radius:50%;flex-shrink:0;
|
| 314 |
box-shadow:0 0 12px currentColor}
|
| 315 |
.nd-close{background:none;border:none;color:var(--muted);font-size:20px;
|
| 316 |
-
cursor:pointer;padding:0;line-height:1}
|
| 317 |
.nd-close:hover{color:var(--text)}
|
| 318 |
.nd-type{font-size:10px;letter-spacing:1.6px;color:var(--cyan);
|
| 319 |
text-transform:uppercase;margin-bottom:12px;font-family:var(--font-mono);
|
|
@@ -329,7 +334,7 @@ body[data-alert="CRISIS"] #alert-scrim{background:radial-gradient(ellipse at cen
|
|
| 329 |
.nd-payload{font-size:11px;color:var(--text);background:rgba(0,0,0,.25);
|
| 330 |
padding:8px 10px;border-radius:8px;font-family:var(--font-mono);
|
| 331 |
max-height:90px;overflow:auto;line-height:1.5;
|
| 332 |
-
border-left:2px solid var(--border-strong)}
|
| 333 |
.nd-conns{font-size:11px;color:var(--muted);max-height:100px;overflow:auto}
|
| 334 |
.nd-conn{display:flex;justify-content:space-between;padding:3px 0;
|
| 335 |
border-bottom:1px solid rgba(0,229,200,.07)}
|
|
@@ -343,7 +348,7 @@ body[data-alert="CRISIS"] #alert-scrim{background:radial-gradient(ellipse at cen
|
|
| 343 |
border-left:3px solid var(--teal);padding:10px 18px;border-radius:10px;
|
| 344 |
font-size:12px;color:var(--text);box-shadow:var(--neon-teal),var(--shadow);
|
| 345 |
animation:toastIn .35s ease both,toastOut .4s ease 4.5s both;
|
| 346 |
-
backdrop-filter:blur(10px)}
|
| 347 |
.toast.warn{border-left-color:var(--gold);box-shadow:var(--neon-gold),var(--shadow)}
|
| 348 |
.toast.error{border-left-color:var(--red);box-shadow:0 0 14px rgba(255,84,105,.5)}
|
| 349 |
.toast.info{border-left-color:var(--violet);box-shadow:var(--neon-violet),var(--shadow)}
|
|
@@ -362,7 +367,7 @@ body[data-alert="CRISIS"] #alert-scrim{background:radial-gradient(ellipse at cen
|
|
| 362 |
@keyframes hintFloat{0%,100%{opacity:.7;transform:translate(-50%,-50%)}
|
| 363 |
50%{opacity:1;transform:translate(-50%,calc(-50% - 6px))}}
|
| 364 |
|
| 365 |
-
/* ── BUSY OVERLAY
|
| 366 |
#busy-overlay{position:fixed;inset:0;z-index:35;pointer-events:none;
|
| 367 |
display:none;place-items:center;background:rgba(2,12,18,0);
|
| 368 |
transition:background .3s}
|
|
@@ -378,10 +383,10 @@ body[data-busy="1"] #busy-overlay{display:grid;background:rgba(2,12,18,.18)}
|
|
| 378 |
/* ── RESPONSIVE ───────────────────────────────────────── */
|
| 379 |
@media (max-width:980px){
|
| 380 |
#legend{width:200px;padding:14px;top:14px;right:14px}
|
| 381 |
-
#minimap-wrap{padding:5px}
|
| 382 |
#minimap{width:140px;height:90px}
|
| 383 |
-
#zoom-ctrl{right:14px;bottom:
|
| 384 |
-
#stats{gap:20px;padding:0 16px}
|
| 385 |
.stat-value{font-size:18px}
|
| 386 |
.stat-label{font-size:9px}
|
| 387 |
#council-overlay{width:300px}
|
|
@@ -391,19 +396,20 @@ body[data-busy="1"] #busy-overlay{display:grid;background:rgba(2,12,18,.18)}
|
|
| 391 |
#legend{width:170px;padding:12px;top:10px;right:10px;font-size:11px}
|
| 392 |
#legend .lbl{display:none}
|
| 393 |
.meta-row{font-size:10px}
|
| 394 |
-
#minimap-wrap{left:54px;bottom:
|
| 395 |
#minimap{width:120px;height:80px}
|
| 396 |
-
#stats{gap:14px;padding:0 12px;height:48px;bottom:
|
| 397 |
.stat-value{font-size:15px}
|
| 398 |
.stat-icon{font-size:14px}
|
| 399 |
#query-area{left:44px}
|
| 400 |
.mode-row{padding-right:14px}
|
| 401 |
#council-overlay{left:8px;right:8px;width:auto;top:8px;
|
| 402 |
-
max-height:calc(100vh -
|
| 403 |
-
#zoom-ctrl{bottom:
|
|
|
|
| 404 |
}
|
| 405 |
|
| 406 |
-
/*
|
| 407 |
.gradio-container,footer,.built-with,gradio-app>.main>.contain{display:none!important}
|
| 408 |
|
| 409 |
.hidden{display:none!important}
|
|
|
|
| 1 |
/* ============================================================
|
| 2 |
ELYSIUM — Dark Neon Bioluminescent Theme
|
|
|
|
|
|
|
|
|
|
| 3 |
============================================================ */
|
| 4 |
:root{
|
| 5 |
--bg-0:#02080c;
|
|
|
|
| 42 |
|
| 43 |
*,*::before,*::after{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
|
| 44 |
html,body{margin:0;padding:0;height:100%;width:100%;overflow:hidden;
|
| 45 |
+
background:radial-gradient(ellipse 90% 70% at 50% 40%, #0a2434 0%, #03101a 55%, #01060b 100%);
|
|
|
|
| 46 |
color:var(--text);font-family:var(--font-sans);font-size:14px;
|
| 47 |
user-select:none;-webkit-user-select:none}
|
| 48 |
button{font-family:inherit;color:inherit;cursor:pointer}
|
|
|
|
| 52 |
touch-action:none;z-index:0}
|
| 53 |
#elysium-canvas.dragging{cursor:grabbing}
|
| 54 |
|
| 55 |
+
/* ── GLASS ────────────────────────────────────────────── */
|
| 56 |
.glass{
|
| 57 |
background:linear-gradient(180deg, var(--panel), var(--panel-2));
|
| 58 |
border:1px solid var(--border);
|
|
|
|
| 107 |
box-shadow:inset 0 0 8px rgba(0,229,200,.08)}
|
| 108 |
|
| 109 |
/* ── MINIMAP ──────────────────────────────────────────── */
|
| 110 |
+
#minimap-wrap{position:fixed;left:74px;bottom:170px;z-index:15;
|
| 111 |
background:rgba(2,12,18,.85);border:1px solid var(--border);border-radius:12px;
|
| 112 |
padding:6px 6px 4px;box-shadow:var(--shadow);backdrop-filter:blur(10px)}
|
| 113 |
.minimap-label{font-size:9px;letter-spacing:2.2px;color:var(--muted);
|
| 114 |
text-align:center;margin-bottom:4px;font-weight:600}
|
| 115 |
#minimap{display:block;width:170px;height:108px;border-radius:8px;cursor:crosshair;
|
| 116 |
+
background:rgba(2,16,22,.95);touch-action:none}
|
| 117 |
|
| 118 |
/* ── ZOOM CTRL ────────────────────────────────────────── */
|
| 119 |
+
#zoom-ctrl{position:fixed;right:24px;bottom:170px;display:flex;flex-direction:column;
|
| 120 |
gap:6px;z-index:15}
|
| 121 |
#zoom-ctrl button{width:38px;height:38px;background:rgba(6,18,26,.85);
|
| 122 |
border:1px solid var(--border);border-radius:10px;color:var(--teal);
|
|
|
|
| 136 |
@keyframes crisisPulse{0%,100%{opacity:.6}50%{opacity:1}}
|
| 137 |
|
| 138 |
/* ── STATS BAR ────────────────────────────────────────── */
|
| 139 |
+
#stats{position:fixed;left:58px;right:0;bottom:108px;height:56px;
|
| 140 |
display:flex;align-items:center;padding:0 28px;gap:32px;z-index:18;
|
| 141 |
border-radius:0;border-left:none;border-right:none;border-bottom:none;
|
| 142 |
background:linear-gradient(180deg,rgba(2,12,18,.72),rgba(2,8,12,.92))}
|
|
|
|
| 148 |
.stat-value{font-size:22px;font-family:var(--font-disp);color:var(--cyan);
|
| 149 |
font-weight:700;line-height:1;text-shadow:0 0 8px rgba(25,214,255,.45)}
|
| 150 |
|
| 151 |
+
/* ── QUERY AREA ───────────────────────────────────────── */
|
| 152 |
#query-area{position:fixed;left:58px;right:0;bottom:0;z-index:20;
|
| 153 |
+
display:flex;flex-direction:column;gap:0;padding-bottom:8px}
|
| 154 |
|
| 155 |
+
/* File preview strip — appears ABOVE input when files attached */
|
| 156 |
#attach-strip{margin:0 22px 6px;padding:8px 10px;display:flex;gap:8px;
|
| 157 |
+
flex-wrap:wrap;align-items:center;border-radius:14px;
|
| 158 |
+
max-height:84px;overflow:auto;
|
| 159 |
+
animation:stripIn .25s ease-out}
|
| 160 |
+
@keyframes stripIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
|
| 161 |
.preview-tile{display:flex;align-items:center;gap:8px;
|
| 162 |
background:rgba(0,229,200,.06);border:1px solid var(--border-strong);
|
| 163 |
+
border-radius:10px;padding:4px 8px 4px 4px;font-size:11px;color:var(--text);
|
| 164 |
+
animation:tileIn .3s ease}
|
| 165 |
+
@keyframes tileIn{from{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}
|
| 166 |
.preview-tile img{width:38px;height:38px;border-radius:6px;object-fit:cover;
|
| 167 |
box-shadow:0 0 8px rgba(0,229,200,.3)}
|
| 168 |
.preview-tile .pdf-ico{width:38px;height:38px;border-radius:6px;display:grid;
|
| 169 |
place-items:center;background:linear-gradient(135deg,#3a1a2a,#1a0a14);
|
| 170 |
color:var(--magenta);font-family:var(--font-mono);font-size:11px;font-weight:700;
|
| 171 |
box-shadow:var(--neon-magenta)}
|
| 172 |
+
.preview-tile .nm{max-width:140px;overflow:hidden;text-overflow:ellipsis;
|
| 173 |
white-space:nowrap;font-family:var(--font-mono)}
|
| 174 |
+
.preview-tile .x{background:rgba(255,84,105,.12);border:1px solid rgba(255,84,105,.25);
|
| 175 |
+
color:#ffb0b0;font-size:14px;font-weight:bold;
|
| 176 |
+
cursor:pointer;padding:0;width:20px;height:20px;border-radius:50%;
|
| 177 |
+
display:grid;place-items:center;transition:all .15s;line-height:1}
|
| 178 |
+
.preview-tile .x:hover{background:var(--red);color:#fff;transform:scale(1.1);
|
| 179 |
+
box-shadow:0 0 8px rgba(255,84,105,.5)}
|
| 180 |
+
.attach-hint{font-size:11px;color:var(--muted-2);margin-left:6px;font-style:italic}
|
| 181 |
|
| 182 |
#query-bar{margin:0 22px 8px;height:54px;
|
| 183 |
display:flex;align-items:center;padding:0 12px;gap:10px;
|
|
|
|
| 293 |
#co-synth:empty{display:none}
|
| 294 |
#debate-audio{width:100%;margin-top:10px;height:32px;display:none}
|
| 295 |
|
| 296 |
+
/* COUNCIL PILL (minimized) */
|
| 297 |
#council-pill{position:fixed;left:22px;top:22px;z-index:24;
|
| 298 |
padding:8px 16px;border-radius:20px;background:rgba(6,18,26,.92);
|
| 299 |
border:1px solid var(--border-strong);color:var(--text);font-size:12px;
|
|
|
|
| 304 |
#council-pill .dot-mini{width:8px;height:8px;border-radius:50%;background:var(--magenta);
|
| 305 |
box-shadow:var(--neon-magenta)}
|
| 306 |
|
| 307 |
+
/* ── NODE DETAIL POPOVER (Task 3) ─────────────────────── */
|
| 308 |
#node-detail{position:fixed;width:320px;padding:18px;z-index:30;
|
| 309 |
opacity:0;transform:translateY(8px);pointer-events:none;
|
| 310 |
transition:opacity .25s,transform .25s;
|
| 311 |
border:1px solid var(--border-strong);box-shadow:var(--neon-teal),var(--shadow)}
|
| 312 |
#node-detail.show{opacity:1;transform:translateY(0);pointer-events:auto}
|
| 313 |
+
#node-detail.hidden{display:none}
|
| 314 |
.nd-head{display:flex;justify-content:space-between;align-items:flex-start;
|
| 315 |
margin-bottom:8px;gap:8px}
|
| 316 |
.nd-title{font-size:14px;font-weight:700;display:flex;align-items:center;gap:8px;
|
|
|
|
| 318 |
.nd-title .nd-dot{width:16px;height:16px;border-radius:50%;flex-shrink:0;
|
| 319 |
box-shadow:0 0 12px currentColor}
|
| 320 |
.nd-close{background:none;border:none;color:var(--muted);font-size:20px;
|
| 321 |
+
cursor:pointer;padding:0;line-height:1;width:24px;height:24px}
|
| 322 |
.nd-close:hover{color:var(--text)}
|
| 323 |
.nd-type{font-size:10px;letter-spacing:1.6px;color:var(--cyan);
|
| 324 |
text-transform:uppercase;margin-bottom:12px;font-family:var(--font-mono);
|
|
|
|
| 334 |
.nd-payload{font-size:11px;color:var(--text);background:rgba(0,0,0,.25);
|
| 335 |
padding:8px 10px;border-radius:8px;font-family:var(--font-mono);
|
| 336 |
max-height:90px;overflow:auto;line-height:1.5;
|
| 337 |
+
border-left:2px solid var(--border-strong);white-space:pre-wrap;word-break:break-word}
|
| 338 |
.nd-conns{font-size:11px;color:var(--muted);max-height:100px;overflow:auto}
|
| 339 |
.nd-conn{display:flex;justify-content:space-between;padding:3px 0;
|
| 340 |
border-bottom:1px solid rgba(0,229,200,.07)}
|
|
|
|
| 348 |
border-left:3px solid var(--teal);padding:10px 18px;border-radius:10px;
|
| 349 |
font-size:12px;color:var(--text);box-shadow:var(--neon-teal),var(--shadow);
|
| 350 |
animation:toastIn .35s ease both,toastOut .4s ease 4.5s both;
|
| 351 |
+
backdrop-filter:blur(10px);max-width:480px}
|
| 352 |
.toast.warn{border-left-color:var(--gold);box-shadow:var(--neon-gold),var(--shadow)}
|
| 353 |
.toast.error{border-left-color:var(--red);box-shadow:0 0 14px rgba(255,84,105,.5)}
|
| 354 |
.toast.info{border-left-color:var(--violet);box-shadow:var(--neon-violet),var(--shadow)}
|
|
|
|
| 367 |
@keyframes hintFloat{0%,100%{opacity:.7;transform:translate(-50%,-50%)}
|
| 368 |
50%{opacity:1;transform:translate(-50%,calc(-50% - 6px))}}
|
| 369 |
|
| 370 |
+
/* ── BUSY OVERLAY ─────────────────────────────────────── */
|
| 371 |
#busy-overlay{position:fixed;inset:0;z-index:35;pointer-events:none;
|
| 372 |
display:none;place-items:center;background:rgba(2,12,18,0);
|
| 373 |
transition:background .3s}
|
|
|
|
| 383 |
/* ── RESPONSIVE ───────────────────────────────────────── */
|
| 384 |
@media (max-width:980px){
|
| 385 |
#legend{width:200px;padding:14px;top:14px;right:14px}
|
| 386 |
+
#minimap-wrap{padding:5px;bottom:160px}
|
| 387 |
#minimap{width:140px;height:90px}
|
| 388 |
+
#zoom-ctrl{right:14px;bottom:160px}
|
| 389 |
+
#stats{gap:20px;padding:0 16px;bottom:100px}
|
| 390 |
.stat-value{font-size:18px}
|
| 391 |
.stat-label{font-size:9px}
|
| 392 |
#council-overlay{width:300px}
|
|
|
|
| 396 |
#legend{width:170px;padding:12px;top:10px;right:10px;font-size:11px}
|
| 397 |
#legend .lbl{display:none}
|
| 398 |
.meta-row{font-size:10px}
|
| 399 |
+
#minimap-wrap{left:54px;bottom:140px}
|
| 400 |
#minimap{width:120px;height:80px}
|
| 401 |
+
#stats{gap:14px;padding:0 12px;height:48px;bottom:96px}
|
| 402 |
.stat-value{font-size:15px}
|
| 403 |
.stat-icon{font-size:14px}
|
| 404 |
#query-area{left:44px}
|
| 405 |
.mode-row{padding-right:14px}
|
| 406 |
#council-overlay{left:8px;right:8px;width:auto;top:8px;
|
| 407 |
+
max-height:calc(100vh - 240px)}
|
| 408 |
+
#zoom-ctrl{bottom:96px;right:10px}
|
| 409 |
+
#node-detail{width:280px}
|
| 410 |
}
|
| 411 |
|
| 412 |
+
/* Hide any default Gradio surface */
|
| 413 |
.gradio-container,footer,.built-with,gradio-app>.main>.contain{display:none!important}
|
| 414 |
|
| 415 |
.hidden{display:none!important}
|
frontend/dist/assets/nodes.js
CHANGED
|
@@ -1,133 +1,126 @@
|
|
| 1 |
-
/*
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
PROJECT: '#19d6ff', // cyan
|
| 9 |
-
LIFE_EVENT: '#5cffae', // green
|
| 10 |
-
EMOTION: '#ff80c4', // pink
|
| 11 |
-
PERSON: '#ff70b8', // pink
|
| 12 |
-
VALUE: '#7e5cff', // violet
|
| 13 |
-
MEMORY: '#00e5c8', // teal
|
| 14 |
-
FACT: '#19d6ff', // cyan
|
| 15 |
-
CONCEPT: '#a76bff',
|
| 16 |
-
QUERY: '#5cffae',
|
| 17 |
-
};
|
| 18 |
-
const DEFAULT_COLOR = '#a76bff';
|
| 19 |
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
const pulse = 1 + 0.06 * Math.sin((t / 1000) * 0.55 + (n.phase || 0));
|
| 41 |
-
const r = n.radius * pulse * overshoot;
|
| 42 |
-
const x = n.x, y = n.y;
|
| 43 |
|
| 44 |
-
|
|
|
|
| 45 |
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
-
|
| 55 |
-
if (n.type === 'CORE' || n.type === 'CIVILIZATION') {
|
| 56 |
-
ctx.save();
|
| 57 |
-
ctx.translate(x, y);
|
| 58 |
-
ctx.rotate(t * 0.0004);
|
| 59 |
-
ctx.strokeStyle = n.color + '88';
|
| 60 |
-
ctx.lineWidth = 1.5 / zoom;
|
| 61 |
-
ctx.setLineDash([6 / zoom, 6 / zoom]);
|
| 62 |
-
ctx.beginPath(); ctx.arc(0, 0, r * 1.85, 0, Math.PI * 2); ctx.stroke();
|
| 63 |
-
ctx.setLineDash([]);
|
| 64 |
-
ctx.restore();
|
| 65 |
-
}
|
| 66 |
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
|
|
|
|
|
|
| 72 |
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
ctx.
|
| 88 |
-
ctx.
|
| 89 |
-
ctx.
|
| 90 |
-
ctx.beginPath(); ctx.arc(x, y, r + 8 / zoom, 0, Math.PI * 2); ctx.stroke();
|
| 91 |
-
ctx.setLineDash([]);
|
| 92 |
-
}
|
| 93 |
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
ctx.beginPath();
|
| 102 |
-
ctx.roundRect(bx - 12, by - 8, 22, 16, 8);
|
| 103 |
-
ctx.fill(); ctx.stroke();
|
| 104 |
}
|
| 105 |
-
ctx.fillStyle = '#fff';
|
| 106 |
-
ctx.font = `bold ${10}px Space Mono`;
|
| 107 |
-
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
| 108 |
-
ctx.fillText(String(n.count), bx - 1, by);
|
| 109 |
-
}
|
| 110 |
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
}
|
| 123 |
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
|
| 132 |
-
|
| 133 |
-
};
|
|
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
ELYSIUM — nodes.js
|
| 3 |
+
Bioluminescent node renderer (dark-neon theme).
|
| 4 |
+
Pure draw helpers; safe to call from animation loop.
|
| 5 |
+
============================================================ */
|
| 6 |
+
(() => {
|
| 7 |
+
'use strict';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
+
const TYPE_COLOR = {
|
| 10 |
+
CORE: '#ffb840', // golden seed
|
| 11 |
+
CIVILIZATION: '#ffb840',
|
| 12 |
+
DOMAIN: '#a76bff', // violet
|
| 13 |
+
AGENT: '#ff4fa3', // magenta
|
| 14 |
+
TOOL: '#ff9b3c', // warm orange
|
| 15 |
+
PROJECT: '#19d6ff', // cyan
|
| 16 |
+
LIFE_EVENT: '#5cffae', // green
|
| 17 |
+
EMOTION: '#ff80c4', // pink
|
| 18 |
+
PERSON: '#ff70b8',
|
| 19 |
+
VALUE: '#7e5cff',
|
| 20 |
+
MEMORY: '#00e5c8', // teal
|
| 21 |
+
FACT: '#19d6ff',
|
| 22 |
+
CONCEPT: '#a76bff',
|
| 23 |
+
QUERY: '#5cffae',
|
| 24 |
+
};
|
| 25 |
+
const DEFAULT_COLOR = '#a76bff';
|
| 26 |
|
| 27 |
+
function shade(hex, amt) {
|
| 28 |
+
if (typeof hex !== 'string' || hex.length < 7) return hex || DEFAULT_COLOR;
|
| 29 |
+
const c = parseInt(hex.slice(1), 16);
|
| 30 |
+
let r = (c >> 16) + amt;
|
| 31 |
+
let g = ((c >> 8) & 255) + amt;
|
| 32 |
+
let b = (c & 255) + amt;
|
| 33 |
+
r = Math.max(0, Math.min(255, r));
|
| 34 |
+
g = Math.max(0, Math.min(255, g));
|
| 35 |
+
b = Math.max(0, Math.min(255, b));
|
| 36 |
+
return '#' + ((r << 16) | (g << 8) | b).toString(16).padStart(6, '0');
|
| 37 |
+
}
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
+
window.colorFor = (type) => TYPE_COLOR[type] || DEFAULT_COLOR;
|
| 40 |
+
window.TYPE_COLOR = TYPE_COLOR;
|
| 41 |
|
| 42 |
+
window.drawNode = function (ctx, n, t, zoom) {
|
| 43 |
+
if (!ctx || !n) return;
|
| 44 |
+
const ageMs = t - (n.born || t);
|
| 45 |
+
const age = Math.min(1, ageMs / 700);
|
| 46 |
+
const k = age;
|
| 47 |
+
const overshoot = age < 1
|
| 48 |
+
? 1 + 1.7 * (k - 1) ** 3 + 0.7 * (k - 1) ** 2
|
| 49 |
+
: 1;
|
| 50 |
+
const pulse = 1 + 0.06 * Math.sin((t / 1000) * 0.55 + (n.phase || 0));
|
| 51 |
+
const r = Math.max(2, n.radius * pulse * overshoot);
|
| 52 |
+
const x = n.x, y = n.y;
|
| 53 |
+
const color = n.color || DEFAULT_COLOR;
|
| 54 |
|
| 55 |
+
ctx.globalAlpha = age;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
+
// Outer halo
|
| 58 |
+
const halo = ctx.createRadialGradient(x, y, r * 0.9, x, y, r * 2.6);
|
| 59 |
+
halo.addColorStop(0, color + 'aa');
|
| 60 |
+
halo.addColorStop(0.5, color + '33');
|
| 61 |
+
halo.addColorStop(1, color + '00');
|
| 62 |
+
ctx.fillStyle = halo;
|
| 63 |
+
ctx.beginPath(); ctx.arc(x, y, r * 2.6, 0, Math.PI * 2); ctx.fill();
|
| 64 |
|
| 65 |
+
// Rotating dashed orbital ring (CORE only)
|
| 66 |
+
if (n.type === 'CORE' || n.type === 'CIVILIZATION') {
|
| 67 |
+
ctx.save();
|
| 68 |
+
ctx.translate(x, y);
|
| 69 |
+
ctx.rotate(t * 0.0004);
|
| 70 |
+
ctx.strokeStyle = color + '88';
|
| 71 |
+
ctx.lineWidth = 1.5 / zoom;
|
| 72 |
+
ctx.setLineDash([6 / zoom, 6 / zoom]);
|
| 73 |
+
ctx.beginPath(); ctx.arc(0, 0, r * 1.85, 0, Math.PI * 2); ctx.stroke();
|
| 74 |
+
ctx.setLineDash([]);
|
| 75 |
+
ctx.restore();
|
| 76 |
+
}
|
| 77 |
|
| 78 |
+
// Main sphere
|
| 79 |
+
const grad = ctx.createRadialGradient(x - r * 0.35, y - r * 0.35, r * 0.1, x, y, r);
|
| 80 |
+
grad.addColorStop(0, shade(color, 60));
|
| 81 |
+
grad.addColorStop(0.5, color);
|
| 82 |
+
grad.addColorStop(1, shade(color, -70));
|
| 83 |
+
ctx.shadowColor = color;
|
| 84 |
+
ctx.shadowBlur = 28 * pulse;
|
| 85 |
+
ctx.fillStyle = grad;
|
| 86 |
+
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
|
| 87 |
+
ctx.shadowBlur = 0;
|
| 88 |
|
| 89 |
+
// Specular highlight
|
| 90 |
+
ctx.fillStyle = 'rgba(255,255,255,.5)';
|
| 91 |
+
ctx.beginPath();
|
| 92 |
+
ctx.ellipse(x - r * 0.32, y - r * 0.32, r * 0.28, r * 0.17, -0.5, 0, Math.PI * 2);
|
| 93 |
+
ctx.fill();
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
+
// Selection ring
|
| 96 |
+
if (n.selected) {
|
| 97 |
+
ctx.strokeStyle = '#19d6ff';
|
| 98 |
+
ctx.lineWidth = 2.5 / zoom;
|
| 99 |
+
ctx.setLineDash([4 / zoom, 4 / zoom]);
|
| 100 |
+
ctx.beginPath(); ctx.arc(x, y, r + 8 / zoom, 0, Math.PI * 2); ctx.stroke();
|
| 101 |
+
ctx.setLineDash([]);
|
|
|
|
|
|
|
|
|
|
| 102 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
|
| 104 |
+
// Label
|
| 105 |
+
if (zoom > 0.4 && n.label) {
|
| 106 |
+
const fs = Math.min(13, 11 * Math.max(1, zoom * 0.85));
|
| 107 |
+
ctx.fillStyle = 'rgba(0,0,0,.6)';
|
| 108 |
+
ctx.font = `500 ${fs / zoom}px Inter, sans-serif`;
|
| 109 |
+
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
|
| 110 |
+
const lbl = n.label.length > 24 ? n.label.slice(0, 22) + '…' : n.label;
|
| 111 |
+
ctx.fillText(lbl, x + 1 / zoom, y + r + 7 / zoom);
|
| 112 |
+
ctx.fillStyle = '#d8f6fa';
|
| 113 |
+
ctx.fillText(lbl, x, y + r + 6 / zoom);
|
| 114 |
+
}
|
|
|
|
| 115 |
|
| 116 |
+
// Pulse highlight ring (from UI directive)
|
| 117 |
+
if (n.pulseUntil && t < n.pulseUntil) {
|
| 118 |
+
const p = (n.pulseUntil - t) / 1400;
|
| 119 |
+
ctx.strokeStyle = `rgba(25,214,255,${p})`;
|
| 120 |
+
ctx.lineWidth = 2.5 / zoom;
|
| 121 |
+
ctx.beginPath(); ctx.arc(x, y, r + (1 - p) * 36, 0, Math.PI * 2); ctx.stroke();
|
| 122 |
+
}
|
| 123 |
|
| 124 |
+
ctx.globalAlpha = 1;
|
| 125 |
+
};
|
| 126 |
+
})();
|
frontend/dist/index.html
CHANGED
|
@@ -59,7 +59,7 @@
|
|
| 59 |
<!-- ALERT SCRIM (overlay tint for alert levels) -->
|
| 60 |
<div id="alert-scrim"></div>
|
| 61 |
|
| 62 |
-
<!-- STATS BAR —
|
| 63 |
<section id="stats" class="glass">
|
| 64 |
<div class="stat" title="Total nodes in your civilization mycelium">
|
| 65 |
<div class="stat-icon">🌐</div>
|
|
|
|
| 59 |
<!-- ALERT SCRIM (overlay tint for alert levels) -->
|
| 60 |
<div id="alert-scrim"></div>
|
| 61 |
|
| 62 |
+
<!-- STATS BAR — meaningful civilization metrics -->
|
| 63 |
<section id="stats" class="glass">
|
| 64 |
<div class="stat" title="Total nodes in your civilization mycelium">
|
| 65 |
<div class="stat-icon">🌐</div>
|