Files
second-brain/templates/dashboard.html

1637 lines
66 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>🧠 Second Brain</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="app">
<!-- Stats Header -->
<header class="stats-bar" id="statsBar">
<div class="stat"><span class="stat-num" id="statTotal">-</span><span class="stat-label">Total</span></div>
<div class="stat"><span class="stat-num" id="statConfirmed">-</span><span class="stat-label">OK</span></div>
<div class="stat"><span class="stat-num" id="statRejected">-</span><span class="stat-label">Rej</span></div>
<div class="stat"><span class="stat-num" id="statPending">-</span><span class="stat-label">Pending</span></div>
<div class="stat"><span class="stat-num" id="statErrors">-</span><span class="stat-label">Err</span></div>
<div class="stat"><span class="stat-num" id="statAvgConf">-</span><span class="stat-label">Avg</span></div>
</header>
<div class="tabs-bar">
<button class="tab-btn active" id="tabCards" onclick="setView('cards')">Cards</button>
<button class="tab-btn" id="tabGraph" onclick="setView('graph')">Graph</button>
<button class="tab-btn" id="tabStatus" onclick="setView('status')">Status</button>
</div>
<!-- Search -->
<div class="search-box">
<div class="search-row">
<input type="text" id="searchInput" placeholder="🔍 Suche..." />
</div>
<div class="search-row">
<select id="filterSelect">
<option value="all">Alle</option>
<option value="pending">Pending</option>
<option value="confirmed">Confirmed</option>
<option value="rejected">Rejected</option>
<option value="errors">Errors</option>
</select>
<select id="exportFormat" title="Export format">
<option value="jsonl">JSONL</option>
<option value="csv">CSV</option>
</select>
<button class="btn-export" onclick="exportCurrent()">Export</button>
</div>
</div>
<!-- New Engram -->
<div class="new-engram">
<textarea id="newContent" placeholder="Neues Engramm..."></textarea>
<input type="text" id="newTags" placeholder="Tags (comma sep)" />
<button onclick="createEngram()"> Speichern</button>
</div>
<!-- Cards -->
<div class="cards" id="cards"></div>
<!-- Graph -->
<div class="graph" id="graph" style="display:none;">
<div class="graph-controls">
<button class="btn" onclick="resetGraphView()">Reset view</button>
<button class="btn" onclick="fitGraphView()">Fit</button>
<select class="btn" id="graphLimit" onchange="reloadGraph()" title="Wie viele Knoten laden? 0=all">
<option value="0">Nodes: all</option>
<option value="200">Nodes: 200</option>
<option value="1000">Nodes: 1000</option>
<option value="5000">Nodes: 5000</option>
</select>
<button class="btn" onclick="reloadGraph()">Reload</button>
<span class="graph-mode">Live physics on</span>
</div>
<canvas id="graphCanvas" width="440" height="520"></canvas>
<div class="graph-hint muted small" id="graphHint">Lade Graph…</div>
<div class="graph-live" id="graphLive">
<div class="graph-live-title">Live</div>
<div id="graphLiveFeed" class="graph-live-feed"></div>
</div>
<div class="graph-legend">
<div><strong>Graph</strong>: Live Cluster wie Obsidian. Zoom per Pinch, Pan per Drag. Tap auf Engram öffnet Details, Tap auf Tag setzt Suche.</div>
<div class="legend-row"><span class="legend-dot engram"></span> Engram</div>
<div class="legend-row"><span class="legend-dot tag"></span> Tag</div>
<div class="legend-row"><span class="legend-dot source"></span> Quelle</div>
<div class="legend-row"><span class="legend-dot match"></span> Match (Suche)</div>
</div>
</div>
<!-- Status -->
<div class="status" id="status" style="display:none;"></div>
<!-- Pagination -->
<div class="pagination" id="pagination">
<button id="btnPrev" onclick="prevPage()"></button>
<span id="pageNum">1</span>
<button id="btnNext" onclick="nextPage()"></button>
</div>
<div class="footer">
<span id="lastUpdate">--:--</span>
<button onclick="manualRefresh()" class="refresh-btn"></button>
</div>
</div>
<!-- Detail Modal -->
<div class="modal" id="detailModal">
<div class="modal-content">
<button class="close-btn" onclick="closeModal()">×</button>
<div id="modalBody"></div>
</div>
</div>
<script>
// ─── State ──────────────────────────────────────────────────────────────────
let state = {
items: [],
offset: 0,
limit: 10,
filter: 'all',
search: '',
autoRefresh: true,
view: 'cards',
lastEvent: null,
selectedId: null,
};
// ─── Fetch ──────────────────────────────────────────────────────────────────
async function api(path, opts = {}) {
const r = await fetch(path, opts);
if (!r.ok) {
let msg = r.statusText;
try {
const j = await r.json();
msg = j.error || j.detail || msg;
if (Array.isArray(msg)) msg = JSON.stringify(msg);
if (typeof msg !== 'string') msg = JSON.stringify(msg);
} catch (e) {
// ignore JSON parse errors
}
throw new Error(msg);
}
return r.json();
}
async function loadStats() {
const s = await api('/api/stats');
document.getElementById('statTotal').textContent = s.total;
document.getElementById('statConfirmed').textContent = s.confirmed;
document.getElementById('statRejected').textContent = (s.rejected ?? '-');
document.getElementById('statPending').textContent = s.pending;
document.getElementById('statErrors').textContent = s.errors;
document.getElementById('statAvgConf').textContent = (typeof s.avg_confidence === 'number') ? `${Math.round(s.avg_confidence * 100)}%` : '-';
}
function updateStatsFromEvent(ev) {
if (!ev || !ev.stats) return;
const s = ev.stats;
document.getElementById('statTotal').textContent = s.total;
document.getElementById('statConfirmed').textContent = s.confirmed;
if (document.getElementById('statRejected')) document.getElementById('statRejected').textContent = (s.rejected ?? '-');
document.getElementById('statPending').textContent = s.pending;
document.getElementById('statErrors').textContent = s.errors;
if (document.getElementById('statAvgConf')) document.getElementById('statAvgConf').textContent = (typeof s.avg_confidence === 'number') ? `${Math.round(s.avg_confidence * 100)}%` : '-';
}
function setView(view) {
state.view = view;
document.getElementById('tabCards').classList.toggle('active', view === 'cards');
document.getElementById('tabGraph').classList.toggle('active', view === 'graph');
document.getElementById('tabStatus').classList.toggle('active', view === 'status');
document.getElementById('cards').style.display = view === 'cards' ? '' : 'none';
document.getElementById('pagination').style.display = view === 'cards' ? '' : 'none';
document.getElementById('graph').style.display = view === 'graph' ? '' : 'none';
document.getElementById('status').style.display = view === 'status' ? '' : 'none';
if (view === 'graph') {
loadGraph();
} else if (graphState && graphState.raf) {
cancelAnimationFrame(graphState.raf);
graphState.raf = null;
}
if (view === 'status') loadStatus();
}
async function loadCards() {
const data = await api(buildEngramsUrl(state.limit, state.offset));
state.items = data.items;
renderCardsWithSuggestions();
document.getElementById('pageNum').textContent = Math.floor(state.offset / state.limit) + 1;
document.getElementById('btnPrev').disabled = state.offset === 0;
document.getElementById('btnNext').disabled = data.items.length < state.limit;
}
function buildEngramsUrl(limit, offset) {
let url = `/api/engrams?limit=${limit}&offset=${offset}`;
if (state.search) url += `&search=${encodeURIComponent(state.search)}`;
if (state.filter === 'confirmed') url += '&confirmed=1';
if (state.filter === 'pending') url += '&confirmed=0';
if (state.filter === 'rejected') url += '&verdict=confirmed_false';
if (state.filter === 'errors') url += '&tag=error';
return url;
}
async function exportCurrent() {
const fmt = (document.getElementById('exportFormat')?.value || 'jsonl').toLowerCase();
const limit = 100;
const max = 5000;
let offset = 0;
let all = [];
while (all.length < max) {
const data = await api(buildEngramsUrl(limit, offset));
const items = data.items || [];
all = all.concat(items);
if (items.length < limit) break;
offset += limit;
}
const safe = (s) => String(s ?? '').replace(/[\\r\\n]+/g, ' ').trim();
let payload = '';
let mime = 'text/plain';
if (fmt === 'csv') {
mime = 'text/csv';
const esc = (v) => '\"' + String(v ?? '').replace(/\"/g, '\"\"') + '\"';
payload += ['id','created','source','confidence','verdict','tags','content'].join(',') + '\\n';
for (const it of all) {
payload += [
esc(it.id),
esc(it.created),
esc(it.source),
esc(it.confidence),
esc(it.verdict),
esc((it.tags || []).join('|')),
esc((it.content || '').replace(/\\r?\\n/g, '\\\\n')),
].join(',') + '\\n';
}
} else {
mime = 'application/x-ndjson';
payload = all.map(x => JSON.stringify(x)).join('\\n') + (all.length ? '\\n' : '');
}
const now = new Date();
const ymd = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`;
const filename = `second-brain_${ymd}_${safe(state.filter || 'all')}_${fmt}.${fmt}`;
const blob = new Blob([payload], {type: mime});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
async function loadStatus() {
const reqs = await Promise.allSettled([
api('/api/config'),
api('/api/db_info'),
api('/api/jobs'),
api('/api/insights?limit=8'),
api('/api/storage_stats'),
api('/api/pending?limit=20&offset=0'),
]);
const pick = (i, fallback) => (reqs[i].status === 'fulfilled' ? reqs[i].value : fallback);
const err = (i) => (reqs[i].status === 'rejected' ? (reqs[i].reason && reqs[i].reason.message ? reqs[i].reason.message : String(reqs[i].reason)) : null);
const cfg = pick(0, { workspace: '-', db_path: '-' });
const db = pick(1, { db_path: '-', mtime: null });
const jobs = pick(2, { items: [], error: err(2) });
const ins = pick(3, { pending: '-', top_tags: [], top_hosts: [], error: err(3) });
const stor = pick(4, { sql: { total_engrams: '-', confirmed: '-', pending: '-', by_source: {} }, vector: { chroma_size_bytes: 0, embedding_cache_files: 0 }, obsidian: { configured: false } });
const pend = pick(5, { items: [], error: err(5) });
const el = document.getElementById('status');
const jobsHtml = (jobs.items || []).map(j => `
<div class="kv-row">
<div class="kv-key">${j.unit}</div>
<div class="kv-val">${j.error ? ('ERR: ' + j.error) : (j.active + '/' + j.sub)}</div>
</div>
`).join('');
const topTags = (ins.top_tags || []).map(t => `<span class="pill">${t.key} (${t.count})</span>`).join(' ');
const topHosts = (ins.top_hosts || []).map(t => `<span class="pill">${t.key} (${t.count})</span>`).join(' ');
const bySource = Object.entries((stor.sql && stor.sql.by_source) ? stor.sql.by_source : {})
.slice(0, 8)
.map(([k,v]) => `<span class="pill">${k}: ${v}</span>`)
.join(' ');
const pendItems = (pend.items || []);
const pendHtml = pendItems.map(p => `
<div class="kv-row" onclick="showDetail('${p.id}')">
<div class="kv-key">${(p.source||'').slice(0,12)}</div>
<div class="kv-val">
<span class="pill">${p.id.substring(0,8)}</span>
${escapeHtml((p.content||'').substring(0,120))}${(p.content||'').length>120?'…':''}
<div class="actions" style="margin-top:6px" onclick="event.stopPropagation()">
<button class="btn-ok" onclick="confirm('${p.id}', event)">✅</button>
<button class="btn-no" onclick="reject('${p.id}', event)">❌</button>
</div>
</div>
</div>
`).join('');
el.innerHTML = `
<div class="panel">
<div class="panel-title">Config</div>
<div class="kv-row"><div class="kv-key">workspace</div><div class="kv-val">${cfg.workspace}</div></div>
<div class="kv-row"><div class="kv-key">db</div><div class="kv-val">${db.db_path}</div></div>
<div class="kv-row"><div class="kv-key">db mtime</div><div class="kv-val">${new Date(db.mtime).toLocaleString()}</div></div>
</div>
<div class="panel">
<div class="panel-title">Storage</div>
<div class="kv-row"><div class="kv-key">SQL</div><div class="kv-val">${stor.sql.total_engrams} engrams (ok ${stor.sql.confirmed}, pending ${stor.sql.pending})</div></div>
<div class="kv-row"><div class="kv-key">Vector</div><div class="kv-val">chroma ${(stor.vector.chroma_size_bytes/1024/1024).toFixed(1)} MB, cache ${stor.vector.embedding_cache_files} files</div></div>
<div class="kv-row"><div class="kv-key">Obsidian</div><div class="kv-val">${stor.obsidian.configured ? 'configured' : 'not configured'}</div></div>
<div class="kv-row"><div class="kv-key">By source</div><div class="kv-val">${bySource || '-'}</div></div>
</div>
<div class="panel">
<div class="panel-title">Jobs</div>
${jobs.error ? `<div class="muted">Fehler: ${escapeHtml(jobs.error)}</div>` : (jobsHtml || '<div class="muted">Keine Daten</div>')}
</div>
<div class="panel">
<div class="panel-title">Insights</div>
${ins.error ? `<div class="muted">Fehler: ${escapeHtml(ins.error)}</div>` : ''}
<div class="kv-row"><div class="kv-key">pending</div><div class="kv-val">${ins.pending}</div></div>
<div class="kv-row"><div class="kv-key">top tags</div><div class="kv-val">${topTags || '-'}</div></div>
<div class="kv-row"><div class="kv-key">top hosts</div><div class="kv-val">${topHosts || '-'}</div></div>
</div>
<div class="panel">
<div class="panel-title">Pending Queue (latest)</div>
${pend.error ? `<div class="muted">Fehler: ${escapeHtml(pend.error)}</div>` : (pendHtml || '<div class="muted">Keine Pendings</div>')}
</div>
`;
}
async function loadGraph() {
const hint = document.getElementById('graphHint');
const sel = document.getElementById('graphLimit');
const selected = sel ? parseInt(sel.value || '0', 10) : 0;
const maxEngrams = Number.isNaN(selected) ? 0 : selected;
if (sel) localStorage.setItem('graphLimit', String(maxEngrams));
graphState.loadingToken = (graphState.loadingToken || 0) + 1;
const token = graphState.loadingToken;
_graphResetData();
_graphResizeCanvas();
_graphInitInteractions();
if (hint) hint.textContent = 'Graph startet...';
_graphDraw();
try {
let offset = 0;
const chunkSize = maxEngrams && maxEngrams <= 1000 ? 400 : 900;
while (token === graphState.loadingToken) {
const limit = maxEngrams ? Math.min(chunkSize, Math.max(0, maxEngrams - offset)) : chunkSize;
if (limit <= 0) break;
const g = await api(`/api/graph_chunk?offset=${offset}&limit=${limit}`);
_graphMergePayload(g, {progressive: true});
graphState.loadedEngrams = Math.min(g.next_offset || offset, g.total_engrams || 0);
graphState.totalEngrams = g.total_engrams || graphState.totalEngrams || 0;
graphState.lastModified = g.max_modified || graphState.lastModified;
_graphDraw();
if (offset === 0 || (graphState.loadedEngrams && graphState.loadedEngrams % 9000 === 0)) {
fitGraphView({silent: true});
}
if (g.done || (maxEngrams && graphState.loadedEngrams >= maxEngrams)) break;
offset = g.next_offset || (offset + limit);
await new Promise(resolve => setTimeout(resolve, 16));
}
fitGraphView();
} catch (e) {
if (hint) hint.textContent = `Graph-Fehler: ${e && e.message ? e.message : String(e)}`;
const canvas = _graphCanvas();
const ctx = _graphCtx();
if (canvas && ctx) ctx.clearRect(0,0,canvas.width,canvas.height);
}
}
function reloadGraph() { loadGraph(); }
async function loadGraphChanges() {
if (!graphState.lastModified) return;
try {
const g = await api(`/api/graph_changes?since=${encodeURIComponent(graphState.lastModified)}&limit=600`);
if ((g.nodes || []).length || (g.edges || []).length) {
_graphMergePayload(g, {progressive: true});
_graphPushLive(`Delta +${(g.nodes || []).filter(n => n.kind === 'engram').length} Einträge, +${(g.edges || []).length} Kanten`);
_graphDraw();
} else {
graphState.lastModified = g.max_modified || graphState.lastModified;
}
} catch (e) {
_graphPushLive(`Delta-Fehler: ${e && e.message ? e.message : String(e)}`);
}
}
// ─── Graph Renderer (Canvas) ────────────────────────────────────────────────
let graphState = {
nodes: [],
edges: [],
sim: [],
links: [],
nodeById: new Map(),
simById: new Map(),
degree: new Map(),
physicsOn: true,
draggingId: null,
selectedId: null,
panning: false,
lastX: 0,
lastY: 0,
panX: 0,
panY: 0,
zoom: 1,
raf: null,
search: '',
pointers: new Map(),
pinchStartDist: null,
pinchStartZoom: null,
pinchStartPan: null,
down: null, // {pointerId, cx, cy, t}
edgeKeys: new Set(),
sourceIndex: new Map(),
sourceCounts: new Map(),
nextSourceIndex: 0,
loadingToken: 0,
totalEngrams: 0,
loadedEngrams: 0,
lastModified: null,
liveFeed: [],
lastFrameAt: 0,
};
function _graphCanvas() { return document.getElementById('graphCanvas'); }
function _graphCtx() { return _graphCanvas().getContext('2d'); }
function _graphResizeCanvas() {
const canvas = _graphCanvas();
if (!canvas) return;
const w = canvas.parentElement.clientWidth - 24;
canvas.width = Math.max(320, w);
canvas.height = Math.max(560, Math.min(980, (window.innerHeight || 900) - 250));
}
function _graphResetData() {
if (graphState.raf) cancelAnimationFrame(graphState.raf);
graphState.nodes = [];
graphState.edges = [];
graphState.sim = [];
graphState.links = [];
graphState.nodeById = new Map();
graphState.simById = new Map();
graphState.degree = new Map();
graphState.edgeKeys = new Set();
graphState.sourceIndex = new Map();
graphState.sourceCounts = new Map();
graphState.nextSourceIndex = 0;
graphState.totalEngrams = 0;
graphState.loadedEngrams = 0;
graphState.lastModified = null;
graphState.physicsOn = true;
graphState.panX = 0;
graphState.panY = 0;
graphState.zoom = 1;
graphState.lastFrameAt = 0;
}
function _graphHashUnit(s) {
let h = 2166136261;
const str = String(s || '');
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return ((h >>> 0) / 4294967295);
}
function _graphPalette(seed) {
const palette = [
[34, 211, 238], // cyan
[244, 114, 182], // pink
[168, 85, 247], // purple
[74, 222, 128], // green
[248, 113, 113], // red
[96, 165, 250], // blue
[251, 191, 36], // amber
[45, 212, 191], // teal
];
return palette[Math.floor(_graphHashUnit(seed) * palette.length) % palette.length];
}
function _graphSourceCenter(source) {
const key = source || 'unknown';
if (!graphState.sourceIndex.has(key)) {
graphState.sourceIndex.set(key, graphState.nextSourceIndex++);
}
const i = graphState.sourceIndex.get(key);
const angle = i * 2.399963;
const ring = i < 1 ? 0 : 260 + Math.sqrt(i) * 95;
return {
x: Math.cos(angle) * ring,
y: Math.sin(angle) * ring * 0.82,
};
}
function _graphPlaceNode(n) {
if (n.kind === 'source') {
return _graphSourceCenter((n.label || n.id || '').replace(/^source:/, ''));
}
if (n.kind === 'tag') {
const angle = _graphHashUnit(n.id) * Math.PI * 2;
const ring = 520 + (_graphHashUnit(n.id + ':r') * 420);
return {x: Math.cos(angle) * ring, y: Math.sin(angle) * ring};
}
if (n.kind === 'host') {
const angle = _graphHashUnit(n.id) * Math.PI * 2;
const ring = 760 + (_graphHashUnit(n.id + ':r') * 260);
return {x: Math.cos(angle) * ring, y: Math.sin(angle) * ring};
}
// Obsidian-like "brain": each source gets a lobe, entries fill it as a
// golden-angle cloud so 50k+ nodes appear immediately without edge layout.
const source = n.source || 'unknown';
const local = (graphState.sourceCounts.get(source) || 0) + 1;
graphState.sourceCounts.set(source, local);
const center = _graphSourceCenter(source);
const angle = local * 2.399963 + _graphHashUnit(n.id) * 0.7;
const radius = 18 + Math.sqrt(local) * 9 + _graphHashUnit(n.id + ':r') * 38;
const squash = 0.72 + _graphHashUnit(source) * 0.46;
return {
x: center.x + Math.cos(angle) * radius * squash,
y: center.y + Math.sin(angle) * radius * (1.55 - squash),
};
}
function _graphEnsureSimNode(n) {
const existing = graphState.simById.get(n.id);
if (existing) {
Object.assign(existing, {
kind: n.kind || existing.kind,
label: n.label || existing.label,
weight: n.weight ?? existing.weight,
verdict: n.verdict ?? existing.verdict,
confidence: n.confidence ?? existing.confidence,
created: n.created ?? existing.created,
modified: n.modified ?? existing.modified,
last_accessed: n.last_accessed ?? existing.last_accessed,
source: n.source ?? existing.source,
predict_locked: n.predict_locked ?? existing.predict_locked,
createdMs: Date.parse(n.created || existing.created || '') || existing.createdMs || 0,
modifiedMs: Date.parse(n.modified || existing.modified || '') || existing.modifiedMs || 0,
});
graphState.nodeById.set(n.id, {...(graphState.nodeById.get(n.id) || {}), ...n});
return existing;
}
const p = _graphPlaceNode(n);
const sim = {
id: n.id,
kind: n.kind,
label: n.label || n.id,
weight: n.weight,
verdict: n.verdict,
confidence: n.confidence,
created: n.created,
modified: n.modified,
createdMs: Date.parse(n.created || '') || 0,
modifiedMs: Date.parse(n.modified || '') || 0,
last_accessed: n.last_accessed,
source: n.source,
predict_locked: n.predict_locked,
x: p.x,
y: p.y,
vx: 0, vy: 0,
};
graphState.nodes.push(n);
graphState.nodeById.set(n.id, n);
graphState.sim.push(sim);
graphState.simById.set(n.id, sim);
return sim;
}
function _graphMergePayload(payload, opts = {}) {
const incomingNodes = payload.nodes || [];
const incomingEdges = payload.edges || [];
for (const n of incomingNodes) _graphEnsureSimNode(n);
for (const e of incomingEdges) {
const a = graphState.simById.get(e.from);
const b = graphState.simById.get(e.to);
if (!a || !b) continue;
const key = `${e.from}\u0000${e.to}\u0000${e.kind || ''}`;
if (graphState.edgeKeys.has(key)) continue;
graphState.edgeKeys.add(key);
graphState.edges.push(e);
graphState.links.push({a, b, kind: e.kind, weight: e.weight || 1.0});
graphState.degree.set(e.from, (graphState.degree.get(e.from) || 0) + 1);
graphState.degree.set(e.to, (graphState.degree.get(e.to) || 0) + 1);
}
graphState.lastModified = payload.max_modified || graphState.lastModified;
graphState.search = state.search || graphState.search || '';
if (opts.progressive && graphState.sim.length < 2500) {
const iters = graphState.sim.length < 900 ? 12 : 3;
for (let i = 0; i < iters; i++) _graphStepPhysics(0.28);
}
if (state.view === 'graph' && graphState.physicsOn && !graphState.raf) {
graphState.raf = requestAnimationFrame(_graphLoop);
}
}
function _graphPushLive(text) {
const feed = document.getElementById('graphLiveFeed');
if (!feed || !text) return;
const ts = new Date().toLocaleTimeString('de-DE', {hour: '2-digit', minute: '2-digit', second: '2-digit'});
graphState.liveFeed.unshift(`${ts} ${text}`);
graphState.liveFeed = graphState.liveFeed.slice(0, 8);
feed.innerHTML = graphState.liveFeed.map(x => `<div>${escapeHtml(x)}</div>`).join('');
}
function _graphNodeRadius(n) {
const d = graphState.degree.get(n.id) || 0;
const huge = graphState.sim.length > 20000;
const base = n.kind === 'tag' ? (huge ? 3 : 4) : (n.kind === 'host' ? 5 : (n.kind === 'source' ? 13 : (huge ? 1.9 : 5.5)));
const w = (n.weight || 0);
const bonus = Math.min(huge ? 2.5 : 6, Math.sqrt(Math.max(0, w)) * 0.8);
return Math.max(huge ? 1.4 : 3, Math.min(huge ? 9 : 18, base + Math.sqrt(d) * (huge ? 0.35 : 1) + bonus));
}
function _graphVisualRadius(n) {
// Fit can zoom out to show the full 58k+ cloud. Keep dots readable in
// screen space instead of shrinking them into invisible sub-pixels.
return _graphNodeRadius(n) / Math.max(0.08, graphState.zoom || 1);
}
function _graphNodeFill(n) {
const d = graphState.degree.get(n.id) || 0;
const t = Math.max(0, Math.min(0.55, d / 18)); // higher degree -> brighter
const mix = (rgb) => rgb.map(c => Math.round(c + (255 - c) * t));
if (n.kind === 'tag') {
const [r,g,b] = mix([167, 139, 250]);
return `rgb(${r},${g},${b})`;
}
if (n.kind === 'host') {
const [r,g,b] = mix([245, 158, 11]);
return `rgb(${r},${g},${b})`;
}
if (n.kind === 'source') {
const [r,g,b] = mix(_graphPalette(n.label || n.id));
return `rgb(${r},${g},${b})`;
}
if (n.kind === 'engram') {
let base = _graphPalette(n.source || n.id);
const verdict = (n.verdict || '').toString();
if (verdict === 'confirmed_false') base = [248, 113, 113];
else if (verdict === 'confirmed_true') {
const src = _graphPalette(n.source || n.id);
base = [Math.round((src[0] + 74) / 2), Math.round((src[1] + 222) / 2), Math.round((src[2] + 128) / 2)];
}
const now = graphState.drawNow || Date.now();
const created = n.createdMs || 0;
const ageMin = created ? (now - created) / 60000 : 999999;
const rec = Math.max(0, Math.min(0.45, (30 - ageMin) / 30 * 0.45));
const bump = (c) => Math.round(c + (255 - c) * rec);
const [r,g,b] = mix(base).map(bump);
return `rgb(${r},${g},${b})`;
}
const verdict = (n.verdict || '').toString();
let base = [96, 165, 250]; // pending/unknown = blue
if (verdict === 'confirmed_true') base = [34, 197, 94]; // green
else if (verdict === 'confirmed_false') base = [239, 68, 68]; // red
else if (verdict === 'probable_true') base = [52, 211, 153]; // teal
else if (verdict === 'probable_false') base = [251, 191, 36]; // amber
// Recency brightening (brand new pops)
const now = Date.now();
const created = Date.parse(n.created || '') || 0;
const ageMin = created ? (now - created) / 60000 : 999999;
const rec = Math.max(0, Math.min(0.35, (30 - ageMin) / 30 * 0.35));
const bump = (c) => Math.round(c + (255 - c) * rec);
const [r,g,b] = mix(base).map(bump);
return `rgb(${r},${g},${b})`;
}
function _graphMatches(n, term) {
const t = (term || '').trim().toLowerCase();
if (!t) return false;
const id = (n.id || '').toLowerCase();
const label = (n.label || '').toLowerCase();
return id.includes(t) || label.includes(t);
}
function _graphWorldFromScreen(cx, cy) {
return {
x: (cx - graphState.panX) / graphState.zoom,
y: (cy - graphState.panY) / graphState.zoom,
};
}
function _graphHitTest(wx, wy) {
for (let i = graphState.sim.length - 1; i >= 0; i--) {
const n = graphState.sim[i];
const r = _graphVisualRadius(n) + 2 / Math.max(0.08, graphState.zoom);
const dx = wx - n.x;
const dy = wy - n.y;
if ((dx*dx + dy*dy) <= r*r) return n;
}
return null;
}
function _graphInitInteractions() {
const canvas = _graphCanvas();
if (canvas._graphBound) return;
canvas._graphBound = true;
const toCanvasXY = (ev) => {
const rect = canvas.getBoundingClientRect();
return { cx: ev.clientX - rect.left, cy: ev.clientY - rect.top };
};
const pinchMidpoint = () => {
const pts = Array.from(graphState.pointers.values());
if (pts.length < 2) return null;
return { cx: (pts[0].cx + pts[1].cx) / 2, cy: (pts[0].cy + pts[1].cy) / 2 };
};
const pinchDistance = () => {
const pts = Array.from(graphState.pointers.values());
if (pts.length < 2) return null;
const dx = pts[0].cx - pts[1].cx;
const dy = pts[0].cy - pts[1].cy;
return Math.sqrt(dx*dx + dy*dy);
};
canvas.addEventListener('pointerdown', (ev) => {
canvas.setPointerCapture(ev.pointerId);
const pos = toCanvasXY(ev);
graphState.pointers.set(ev.pointerId, pos);
graphState.down = { pointerId: ev.pointerId, cx: pos.cx, cy: pos.cy, t: Date.now() };
// When 2 pointers: start pinch
if (graphState.pointers.size === 2) {
graphState.panning = false;
graphState.draggingId = null;
graphState.down = null;
graphState.pinchStartDist = pinchDistance();
graphState.pinchStartZoom = graphState.zoom;
graphState.pinchStartPan = { panX: graphState.panX, panY: graphState.panY };
_graphDraw();
return;
}
// Single pointer: pan or drag node
const { cx, cy } = pos;
const w = _graphWorldFromScreen(cx, cy);
const hit = _graphHitTest(w.x, w.y);
graphState.lastX = cx; graphState.lastY = cy;
if (hit) {
graphState.draggingId = hit.id;
graphState.selectedId = hit.id;
} else {
graphState.panning = true;
graphState.selectedId = null;
}
_graphDraw();
});
canvas.addEventListener('pointermove', (ev) => {
if (!graphState.pointers.has(ev.pointerId)) return;
const pos = toCanvasXY(ev);
graphState.pointers.set(ev.pointerId, pos);
// Pinch zoom (2 pointers)
if (graphState.pointers.size >= 2 && graphState.pinchStartDist) {
const dist = pinchDistance();
const mid = pinchMidpoint();
if (!dist || !mid) return;
const zoomOld = graphState.zoom;
const wx = (mid.cx - graphState.panX) / zoomOld;
const wy = (mid.cy - graphState.panY) / zoomOld;
const factor = dist / graphState.pinchStartDist;
const next = Math.max(0.35, Math.min(3.0, graphState.pinchStartZoom * factor));
graphState.zoom = next;
graphState.panX = mid.cx - wx * graphState.zoom;
graphState.panY = mid.cy - wy * graphState.zoom;
_graphDraw();
return;
}
// Single pointer
const { cx, cy } = pos;
const dx = cx - graphState.lastX;
const dy = cy - graphState.lastY;
graphState.lastX = cx; graphState.lastY = cy;
if (graphState.draggingId) {
const w = _graphWorldFromScreen(cx, cy);
const n = graphState.simById.get(graphState.draggingId);
if (n) { n.x = w.x; n.y = w.y; n.vx = 0; n.vy = 0; }
_graphDraw();
return;
}
if (graphState.panning) {
graphState.panX += dx;
graphState.panY += dy;
_graphDraw();
}
});
const endPointer = (ev) => {
if (graphState.pointers.has(ev.pointerId)) graphState.pointers.delete(ev.pointerId);
try { canvas.releasePointerCapture(ev.pointerId); } catch {}
if (graphState.pointers.size < 2) {
graphState.pinchStartDist = null;
graphState.pinchStartZoom = null;
graphState.pinchStartPan = null;
}
const pos = toCanvasXY(ev);
const down = graphState.down && graphState.down.pointerId === ev.pointerId ? graphState.down : null;
graphState.down = null;
const moved = down ? Math.hypot(pos.cx - down.cx, pos.cy - down.cy) : 999;
const isTap = !!down && moved <= 8 && (Date.now() - down.t) <= 500;
const w = _graphWorldFromScreen(pos.cx, pos.cy);
const hit = _graphHitTest(w.x, w.y);
graphState.draggingId = null;
graphState.panning = false;
if (isTap && hit) {
graphState.selectedId = hit.id;
_graphDraw();
if (hit.kind === 'engram') {
showDetail(hit.id);
} else if (hit.kind === 'tag') {
const label = (hit.label || '').replace(/^tag:/, '');
const inp = document.getElementById('searchInput');
inp.value = label;
inp.dispatchEvent(new Event('input'));
}
return;
}
_graphDraw();
};
canvas.addEventListener('pointerup', endPointer);
canvas.addEventListener('pointercancel', endPointer);
}
function renderGraph(nodes, edges) {
const canvas = _graphCanvas();
const hint = document.getElementById('graphHint');
const ctx = _graphCtx();
const w = canvas.parentElement.clientWidth - 24;
canvas.width = Math.max(320, w);
canvas.height = Math.max(520, Math.min(900, (window.innerHeight || 900) - 260));
graphState.nodes = nodes || [];
graphState.edges = edges || [];
graphState.nodeById = new Map(graphState.nodes.map(n => [n.id, n]));
graphState.degree = new Map();
for (const e of graphState.edges) {
graphState.degree.set(e.from, (graphState.degree.get(e.from) || 0) + 1);
graphState.degree.set(e.to, (graphState.degree.get(e.to) || 0) + 1);
}
if (!graphState.nodes.length) {
hint.textContent = 'Graph: keine Nodes.';
ctx.clearRect(0,0,canvas.width,canvas.height);
return;
}
hint.textContent = `nodes=${graphState.nodes.length} edges=${graphState.edges.length}`;
// Build adjacency for deterministic layout (O(n+e))
const adj = new Map();
const addAdj = (a, b) => {
if (!adj.has(a)) adj.set(a, []);
adj.get(a).push(b);
};
for (const e of graphState.edges) {
addAdj(e.from, e.to);
addAdj(e.to, e.from);
}
const degree = graphState.degree;
const visited = new Set();
const comps = [];
for (const n of graphState.nodes) {
if (visited.has(n.id)) continue;
const q = [n.id];
visited.add(n.id);
const comp = [];
while (q.length) {
const cur = q.pop();
comp.push(cur);
const neigh = adj.get(cur) || [];
for (const nb of neigh) {
if (!visited.has(nb)) {
visited.add(nb);
q.push(nb);
}
}
}
comps.push(comp);
}
comps.sort((a, b) => b.length - a.length);
// component centers in a loose grid/spiral
const centers = [];
const gap = 240;
const cols = Math.max(1, Math.floor(canvas.width / gap));
for (let i = 0; i < comps.length; i++) {
const cx = (i % cols) * gap - ((cols - 1) * gap) / 2;
const cy = Math.floor(i / cols) * gap - (Math.floor((comps.length - 1) / cols) * gap) / 2;
centers.push({ cx, cy });
}
const pos = new Map();
const R = 46;
for (let ci = 0; ci < comps.length; ci++) {
const comp = comps[ci];
const center = centers[ci] || { cx: 0, cy: 0 };
// hub: highest degree engram preferred
let hub = comp[0];
let hubScore = -1;
for (const id of comp) {
const n = graphState.nodeById.get(id) || {};
const score = (degree.get(id) || 0) + (n.kind === 'engram' ? 3 : 0);
if (score > hubScore) { hubScore = score; hub = id; }
}
// BFS layers from hub
const layer = new Map();
layer.set(hub, 0);
const qq = [hub];
while (qq.length) {
const cur = qq.shift();
const l = layer.get(cur) || 0;
const neigh = adj.get(cur) || [];
for (const nb of neigh) {
if (!layer.has(nb)) {
layer.set(nb, l + 1);
qq.push(nb);
}
}
}
const buckets = new Map();
for (const id of comp) {
const l = layer.get(id);
const ll = (typeof l === 'number') ? l : 99;
if (!buckets.has(ll)) buckets.set(ll, []);
buckets.get(ll).push(id);
}
// Place hubs first, then rings
pos.set(hub, { x: center.cx, y: center.cy });
const maxLayer = Math.max(...Array.from(buckets.keys()));
for (let l = 1; l <= maxLayer; l++) {
const ids = buckets.get(l) || [];
if (!ids.length) continue;
// Stable order: engrams first, then by degree desc
ids.sort((a, b) => {
const na = graphState.nodeById.get(a) || {};
const nb = graphState.nodeById.get(b) || {};
const ka = na.kind === 'engram' ? 0 : (na.kind === 'tag' ? 1 : 2);
const kb = nb.kind === 'engram' ? 0 : (nb.kind === 'tag' ? 1 : 2);
if (ka !== kb) return ka - kb;
return (degree.get(b) || 0) - (degree.get(a) || 0);
});
const rad = l * R;
for (let i = 0; i < ids.length; i++) {
const ang = (i / ids.length) * Math.PI * 2;
pos.set(ids[i], {
x: center.cx + Math.cos(ang) * rad,
y: center.cy + Math.sin(ang) * rad,
});
}
}
}
graphState.sim = graphState.nodes.map(n => {
const p = pos.get(n.id) || { x: (Math.random() - 0.5) * canvas.width, y: (Math.random() - 0.5) * canvas.height };
return {
id: n.id,
kind: n.kind,
label: n.label || n.id,
weight: n.weight,
verdict: n.verdict,
confidence: n.confidence,
created: n.created,
modified: n.modified,
last_accessed: n.last_accessed,
predict_locked: n.predict_locked,
x: p.x,
y: p.y,
vx: 0, vy: 0,
};
});
graphState.simById = new Map(graphState.sim.map(n => [n.id, n]));
graphState.links = graphState.edges
.map(e => ({a: graphState.simById.get(e.from), b: graphState.simById.get(e.to), kind: e.kind, weight: e.weight || 1.0}))
.filter(l => l.a && l.b);
graphState.panX = canvas.width / 2;
graphState.panY = canvas.height / 2;
graphState.zoom = 1;
graphState.search = state.search || '';
graphState.selectedId = null;
_graphInitInteractions();
// Physics is expensive; pre-relax a little but keep it optional for big graphs.
const relaxIters = graphState.sim.length <= 700 ? 70 : 12;
for (let i = 0; i < relaxIters; i++) _graphStepPhysics(0.85);
_graphDraw();
}
function _graphStepPhysics(alpha = 1.0) {
const canvas = _graphCanvas();
if (graphState.sim.length > 6000) {
_graphStepAmbient(alpha);
return;
}
const repulsion = (graphState.sim.length > 700) ? 120 : 180;
const damping = 0.86;
const target = 80;
const springK = 0.018;
const sim = graphState.sim;
if (sim.length > 700) {
// Fast approximate repulsion via spatial hashing (checks only local neighbor cells).
const cell = 120;
const grid = new Map();
const key = (cx, cy) => `${cx},${cy}`;
for (let i = 0; i < sim.length; i++) {
const n = sim[i];
const cx = Math.floor(n.x / cell);
const cy = Math.floor(n.y / cell);
const k = key(cx, cy);
if (!grid.has(k)) grid.set(k, []);
grid.get(k).push(i);
}
for (let i = 0; i < sim.length; i++) {
const a = sim[i];
const cx = Math.floor(a.x / cell);
const cy = Math.floor(a.y / cell);
for (let ox = -1; ox <= 1; ox++) {
for (let oy = -1; oy <= 1; oy++) {
const idxs = grid.get(key(cx + ox, cy + oy));
if (!idxs) continue;
for (const j of idxs) {
if (j === i) continue;
const b = sim[j];
const dx = a.x - b.x, dy = a.y - b.y;
const d2 = dx*dx + dy*dy + 0.12;
const f = (repulsion / d2) * alpha * 0.55;
a.vx += dx*f; a.vy += dy*f;
}
}
}
}
} else {
for (let i = 0; i < sim.length; i++) {
for (let j = i + 1; j < sim.length; j++) {
const a = sim[i], b = sim[j];
const dx = a.x - b.x, dy = a.y - b.y;
const d2 = dx*dx + dy*dy + 0.02;
const f = (repulsion / d2) * alpha;
a.vx += dx*f; a.vy += dy*f;
b.vx -= dx*f; b.vy -= dy*f;
}
}
}
for (const l of graphState.links) {
const a = l.a, b = l.b;
const dx = b.x - a.x, dy = b.y - a.y;
const dist = Math.sqrt(dx*dx + dy*dy) || 1;
const f = (dist - target) * springK * alpha;
const fx = (dx/dist) * f, fy = (dy/dist) * f;
a.vx += fx; a.vy += fy;
b.vx -= fx; b.vy -= fy;
}
for (const n of sim) {
n.vx *= damping; n.vy *= damping;
n.x += n.vx; n.y += n.vy;
const pad = 10;
const bx = canvas.width / graphState.zoom;
const by = canvas.height / graphState.zoom;
n.x = Math.max(-bx/2 + pad, Math.min(bx/2 - pad, n.x));
n.y = Math.max(-by/2 + pad, Math.min(by/2 - pad, n.y));
}
}
function _graphStepAmbient(alpha = 1.0) {
const now = Date.now();
const t = now / 1000;
const sim = graphState.sim;
const count = sim.length;
if (!count) return;
// Keep the full 50k+ graph alive without doing an expensive all-node force
// solve: tiny deterministic drift plus stronger movement on fresh/hub nodes.
const sample = Math.min(count, 900);
const start = Math.floor((t * 97) % count);
for (let k = 0; k < sample; k++) {
const n = sim[(start + k * 13) % count];
const amp = (n.kind === 'engram') ? 0.018 : 0.05;
n.x += Math.sin(t * 0.7 + _graphHashUnit(n.id) * 6.283) * amp * alpha;
n.y += Math.cos(t * 0.6 + _graphHashUnit(n.id + ':y') * 6.283) * amp * alpha;
}
const recentCutoff = now - 12 * 60 * 1000;
for (const n of sim) {
if (n.kind === 'source' || n.kind === 'tag' || (n.modifiedMs || n.createdMs || 0) > recentCutoff) {
const home = n.kind === 'engram' ? _graphSourceCenter(n.source || 'unknown') : null;
if (home) {
n.vx += (home.x - n.x) * 0.00003 * alpha;
n.vy += (home.y - n.y) * 0.00003 * alpha;
}
n.vx *= 0.92; n.vy *= 0.92;
n.x += n.vx; n.y += n.vy;
}
}
}
function _graphEdgeColor(kind) {
const k = (kind || '').toString().toLowerCase();
if (k.includes('tag')) return '#7c3aed';
if (k.includes('host')) return '#f59e0b';
if (k.includes('ref')) return '#10b981';
return '#3a3a55';
}
function _graphDraw() {
const canvas = _graphCanvas();
const ctx = _graphCtx();
const hint = document.getElementById('graphHint');
ctx.clearRect(0,0,canvas.width,canvas.height);
const bg = ctx.createRadialGradient(canvas.width * 0.52, canvas.height * 0.48, 10, canvas.width * 0.5, canvas.height * 0.5, Math.max(canvas.width, canvas.height) * 0.72);
bg.addColorStop(0, '#121a2b');
bg.addColorStop(0.55, '#070b16');
bg.addColorStop(1, '#02040a');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, canvas.width, canvas.height);
graphState.drawNow = Date.now();
ctx.save();
ctx.translate(graphState.panX, graphState.panY);
ctx.scale(graphState.zoom, graphState.zoom);
const term = (graphState.search || '').trim();
const dense = graphState.sim.length > 12000;
const drawEdges = !dense || term || graphState.selectedId;
if (drawEdges) for (const l of graphState.links) {
const isMatchEdge = term && (_graphMatches(l.a, term) || _graphMatches(l.b, term));
const selectedEdge = graphState.selectedId && (l.a.id === graphState.selectedId || l.b.id === graphState.selectedId);
if (dense && !isMatchEdge && !selectedEdge && l.kind !== 'link') continue;
const w = Math.max(0.2, Math.min(3.0, (l.weight || 1.0)));
ctx.lineWidth = (0.6 + w) / graphState.zoom;
ctx.globalAlpha = isMatchEdge || selectedEdge ? 0.9 : (dense ? 0.12 : (0.20 + Math.min(0.35, w * 0.18)));
ctx.strokeStyle = isMatchEdge ? '#f7d154' : _graphEdgeColor(l.kind);
ctx.beginPath();
ctx.moveTo(l.a.x, l.a.y);
ctx.lineTo(l.b.x, l.b.y);
ctx.stroke();
}
ctx.globalAlpha = 1.0;
let matches = 0;
for (const n of graphState.sim) {
const r = _graphVisualRadius(n);
const isMatch = _graphMatches(n, term);
if (isMatch) matches++;
if (isMatch) {
ctx.beginPath();
ctx.strokeStyle = '#f7d154';
ctx.lineWidth = 3 / graphState.zoom;
ctx.arc(n.x, n.y, r + 3, 0, Math.PI*2);
ctx.stroke();
}
if (graphState.selectedId === n.id) {
ctx.beginPath();
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2 / graphState.zoom;
ctx.arc(n.x, n.y, r + 2, 0, Math.PI*2);
ctx.stroke();
}
const fill = _graphNodeFill(n);
const important = n.kind !== 'engram' || isMatch || graphState.selectedId === n.id || ((graphState.drawNow - (n.modifiedMs || n.createdMs || 0)) < 10 * 60 * 1000);
if (important) {
ctx.save();
ctx.shadowColor = fill;
ctx.shadowBlur = (n.kind === 'source' ? 14 : 8) / graphState.zoom;
ctx.beginPath();
ctx.fillStyle = fill;
ctx.arc(n.x, n.y, r, 0, Math.PI*2);
ctx.fill();
ctx.restore();
} else {
ctx.beginPath();
ctx.fillStyle = fill;
ctx.arc(n.x, n.y, r, 0, Math.PI*2);
ctx.fill();
}
// status/recency/lock border
let stroke = null;
const v = (n.verdict || '').toString();
if (v === 'confirmed_true') stroke = '#bbf7d0';
else if (v === 'confirmed_false') stroke = '#fecaca';
else if (v) stroke = '#c7d2fe';
const now = graphState.drawNow;
const created = n.createdMs || 0;
const modified = n.modifiedMs || 0;
const isNew = created && (now - created) < (30 * 60 * 1000);
const isHot = modified && (now - modified) < (10 * 60 * 1000);
if (isNew) stroke = '#f7d154';
if (isHot) stroke = '#ffffff';
if (n.predict_locked) stroke = '#a78bfa';
if (stroke) {
ctx.beginPath();
ctx.strokeStyle = stroke;
ctx.lineWidth = (n.predict_locked ? 3 : 1.5) / graphState.zoom;
ctx.arc(n.x, n.y, r + 0.8, 0, Math.PI*2);
ctx.stroke();
}
}
ctx.restore();
const loaded = graphState.totalEngrams ? ` | engrams=${graphState.loadedEngrams}/${graphState.totalEngrams}` : '';
hint.textContent = `nodes=${graphState.nodes.length} edges=${graphState.edges.length}${loaded}` + (term ? ` | match=${matches}` : '');
}
function _graphLoop() {
if (!graphState.physicsOn) return;
const now = performance.now();
const fps = graphState.sim.length > 25000 ? 8 : (graphState.sim.length > 8000 ? 14 : 30);
if (!graphState.lastFrameAt || now - graphState.lastFrameAt >= (1000 / fps)) {
graphState.lastFrameAt = now;
_graphStepPhysics(0.75);
_graphDraw();
}
graphState.raf = requestAnimationFrame(_graphLoop);
}
function setPhysicsStrength(v) {
// Kept for older cached pages; physics is now always live.
}
function toggleGraphPhysics() {
graphState.physicsOn = true;
if (!graphState.raf) graphState.raf = requestAnimationFrame(_graphLoop);
}
function resetGraphView() {
const canvas = _graphCanvas();
graphState.panX = canvas.width / 2;
graphState.panY = canvas.height / 2;
graphState.zoom = 1;
_graphDraw();
}
function fitGraphView(opts = {}) {
const canvas = _graphCanvas();
if (!graphState.sim.length) return;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const n of graphState.sim) {
minX = Math.min(minX, n.x); minY = Math.min(minY, n.y);
maxX = Math.max(maxX, n.x); maxY = Math.max(maxY, n.y);
}
const w = Math.max(1, maxX - minX);
const h = Math.max(1, maxY - minY);
const pad = graphState.sim.length > 20000 ? 96 : 54;
const zx = (canvas.width - pad) / w;
const zy = (canvas.height - pad) / h;
graphState.zoom = Math.max(0.04, Math.min(2.5, Math.min(zx, zy)));
graphState.panX = canvas.width / 2 - ((minX + maxX) / 2) * graphState.zoom;
graphState.panY = canvas.height / 2 - ((minY + maxY) / 2) * graphState.zoom;
if (!opts.silent) _graphDraw();
}
function graphApplySearch(term) {
graphState.search = term || '';
_graphDraw();
}
// Real-time updates via SSE
function startEvents() {
try {
const es = new EventSource('/api/events');
es.onmessage = (msg) => {
try {
state.lastEvent = JSON.parse(msg.data);
updateStatsFromEvent(state.lastEvent);
if (state.view === 'status') {
// refresh status panels without heavy re-render: just rerun loadStatus occasionally
loadStatus();
}
if (state.view === 'graph') {
const t = Date.now();
const stats = state.lastEvent.stats || {};
const jobs = state.lastEvent.jobs || {};
_graphPushLive(`Stats total=${stats.total ?? '-'} pending=${stats.pending ?? '-'} jobs=${Array.isArray(jobs.units) ? jobs.units.length : '-'}`);
if (!state._lastGraphDelta || (t - state._lastGraphDelta) > 5000) {
state._lastGraphDelta = t;
loadGraphChanges();
}
}
} catch (e) {}
};
es.onerror = () => {
// keep UI usable even if SSE drops
};
} catch (e) {}
}
startEvents();
function renderCards() {
const el = document.getElementById('cards');
el.innerHTML = state.items.map(item => `
<div class="card ${item.confirmed ? 'confirmed' : ''} ${item.rejections > 0 ? 'rejected' : ''}" data-id="${item.id}">
<div class="card-header">
<span class="conf-badge" style="background:hsl(${item.confidence*120},70%,40%)">${Math.round(item.confidence*100)}%</span>
${renderVerdictPill(item)}
<span class="tags">${item.tags.map(t => '<span class="tag">'+t+'</span>').join('')}</span>
<span class="date">${fmtDate(item.created)}</span>
</div>
<div class="card-body" onclick="showDetail('${item.id}')">
${escapeHtml(item.content.substring(0, 200))}${item.content.length>200?'...':''}
</div>
<div class="card-footer">
<input type="text" class="reason-input" placeholder="Grund (optional)" id="reason-${item.id}"/>
<div class="actions">
<button class="btn-ok" onclick="confirm('${item.id}', event)">✅</button>
<button class="btn-no" onclick="reject('${item.id}', event)">❌</button>
<button class="btn-archive" onclick="refresh('${item.id}', event)">🔄</button>
</div>
</div>
</div>
`).join('');
}
function renderVerdictPill(item) {
const v = (item.verdict || '').toString();
if (!v) return '';
let cls = 'v-unknown';
let label = v;
if (v === 'confirmed_true') { cls = 'v-true'; label = 'TRUE'; }
else if (v === 'confirmed_false') { cls = 'v-false'; label = 'FALSE'; }
else if (v === 'probable_true') { cls = 'v-prob-true'; label = 'LIKELY'; }
else if (v === 'probable_false') { cls = 'v-prob-false'; label = 'UNLIKELY'; }
else if (v === 'unknown') { cls = 'v-unknown'; label = 'UNKNOWN'; }
return `<span class="verdict-pill ${cls}">${label}</span>`;
}
function fmtDate(iso) {
const d = new Date(iso);
return `${d.getDate().toString().padStart(2,'0')}.${(d.getMonth()+1).toString().padStart(2,'0')} ${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}`;
}
function escapeHtml(t) {
const d = document.createElement('div');
d.textContent = t;
return d.innerHTML;
}
// ─── Actions ────────────────────────────────────────────────────────────────
async function confirm(id, ev, ctx = 'card') {
ev.stopPropagation();
const reasonElId = (ctx === 'modal') ? ('reason-modal-' + id) : ('reason-' + id);
const reasonEl = document.getElementById(reasonElId);
const reason = reasonEl ? reasonEl.value : '';
await api(`/api/engrams/${id}/confirm`, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({reason})
});
await loadCards(); await loadStats();
if (state.view === 'graph') loadGraph();
if (state.view === 'status') loadStatus();
}
async function reject(id, ev, ctx = 'card') {
ev.stopPropagation();
const reasonElId = (ctx === 'modal') ? ('reason-modal-' + id) : ('reason-' + id);
const reasonEl = document.getElementById(reasonElId);
const reason = reasonEl ? reasonEl.value : '';
await api(`/api/engrams/${id}/reject`, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({reason})
});
await loadCards(); await loadStats();
if (state.view === 'graph') loadGraph();
if (state.view === 'status') loadStatus();
}
async function refresh(id, ev) {
ev.stopPropagation();
await api(`/api/engrams/${id}/refresh`, {method: 'POST'});
await loadCards();
}
async function createEngram() {
const content = document.getElementById('newContent').value;
const tags = document.getElementById('newTags').value;
if (!content.trim()) return;
await api('/api/engrams', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({content, tags})
});
document.getElementById('newContent').value = '';
document.getElementById('newTags').value = '';
await loadCards(); await loadStats();
if (state.view === 'graph') loadGraph();
if (state.view === 'status') loadStatus();
}
async function showDetail(id) {
const item = await api(`/api/engrams/${id}`);
const body = document.getElementById('modalBody');
const links = (item.links || []);
const suggestions = (item.link_suggestions || []).concat(item.predictive_links || []);
const suggHtml = suggestions.length ? suggestions.map(s => `
<div class="suggestion">
<span class="sugg-id">${s.engram_id.substring(0,8)}</span>
<span class="sugg-preview">${escapeHtml(s.preview || s.content_preview || '')}</span>
<button class="btn-link" onclick="acceptLink('${item.id}', '${s.engram_id}', event)">🔗</button>
</div>
`).join('') : '<span class="muted">Keine Vorschläge</span>';
body.innerHTML = `
<h3>Engramm <span class="pill">${item.id.substring(0,8)}</span></h3>
<div class="kv-row"><div class="kv-key">verdict</div><div class="kv-val">${renderVerdictPill(item)} <span class="muted small">${Math.round(item.confidence*100)}%</span></div></div>
<div class="kv-row"><div class="kv-key">source</div><div class="kv-val">${escapeHtml(item.source || '-')}</div></div>
<div class="kv-row"><div class="kv-key">created</div><div class="kv-val">${fmtDate(item.created)}</div></div>
<div class="kv-row"><div class="kv-key">modified</div><div class="kv-val">${fmtDate(item.modified)}</div></div>
<div class="kv-row"><div class="kv-key">access</div><div class="kv-val">${item.access_count ?? 0} • grounding ${item.grounding ?? 0}</div></div>
<div class="kv-row"><div class="kv-key">tags</div><div class="kv-val">${(item.tags || []).map(t => '<span class="tag">'+escapeHtml(t)+'</span>').join(' ') || '-'}</div></div>
<div style="margin-top:10px"><b>Content</b></div>
<div class="detail-content">${escapeHtml(item.content || '')}</div>
<div class="panel" style="margin:10px 0 0; padding:10px 12px;">
<div class="panel-title">Vorschläge</div>
<div class="suggestions">${suggHtml}</div>
</div>
<div class="panel" style="margin:10px 0 0; padding:10px 12px;">
<div class="panel-title">Links</div>
<div>
${links.length ? links.map(l => `<span class="pill" style="cursor:pointer" onclick="showDetail('${l}')">${l.substring(0,8)}</span>`).join(' ') : '<span class="muted">none</span>'}
</div>
</div>
${Array.isArray(item.evidence) && item.evidence.length ? `
<div class="panel" style="margin:10px 0 0; padding:10px 12px;">
<div class="panel-title">Evidence</div>
<ul class="history">
${item.evidence.map(e => `<li>${escapeHtml(typeof e === 'string' ? e : JSON.stringify(e))}</li>`).join('')}
</ul>
</div>` : ''}
<div class="panel" style="margin:10px 0 0; padding:10px 12px;">
<div class="panel-title">History</div>
<ul class="history">
${(item.review_history || []).map(h => `<li>${fmtDate(h.at)}${escapeHtml(h.action)} ${h.note ? ('(' + escapeHtml(h.note) + ')') : ''}</li>`).join('') || '<li class=\"muted\">-</li>'}
</ul>
</div>
<div class="card-footer" style="margin-top:10px">
<input type="text" class="reason-input" placeholder="Grund (optional)" id="reason-modal-${item.id}"/>
<div class="actions">
<button class="btn-ok" onclick="confirm('${item.id}', event, 'modal')">✅</button>
<button class="btn-no" onclick="reject('${item.id}', event, 'modal')">❌</button>
<button class="btn-archive" onclick="refresh('${item.id}', event)">🔄</button>
</div>
</div>
`;
document.getElementById('detailModal').classList.add('open');
}
function closeModal() {
document.getElementById('detailModal').classList.remove('open');
}
function selectCard(id) {
state.selectedId = id;
renderCardsWithSuggestions();
setTimeout(() => {
const el = document.querySelector(`.card[data-id=\"${id}\"]`);
if (el) el.scrollIntoView({block: 'nearest', behavior: 'smooth'});
}, 0);
}
// ─── Pagination ─────────────────────────────────────────────────────────────
function nextPage() {
state.offset += state.limit;
loadCards();
}
function prevPage() {
state.offset = Math.max(0, state.offset - state.limit);
loadCards();
}
function manualRefresh() {
loadCards(); loadStats();
}
// ─── Search ─────────────────────────────────────────────────────────────────
document.getElementById('searchInput').addEventListener('input', (e) => {
state.search = e.target.value;
state.offset = 0;
loadCards();
if (state.view === 'graph') graphApplySearch(state.search);
});
document.getElementById('filterSelect').addEventListener('change', (e) => {
state.filter = e.target.value;
state.offset = 0;
loadCards();
});
// ─── Auto Refresh ───────────────────────────────────────────────────────────
setInterval(() => {
if (!state.autoRefresh) return;
loadStats();
loadCards();
const now = new Date();
document.getElementById('lastUpdate').textContent =
`${now.getHours().toString().padStart(2,'0')}:${now.getMinutes().toString().padStart(2,'0')}`;
}, 5000);
// ─── Init ───────────────────────────────────────────────────────────────────
loadStats();
loadCards();
document.getElementById('detailModal').addEventListener('click', (e) => {
if (e.target && e.target.id === 'detailModal') closeModal();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeModal();
if (e.key === '/' && !(e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT'))) {
e.preventDefault();
document.getElementById('searchInput')?.focus();
}
if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT')) return;
if (document.getElementById('detailModal')?.classList.contains('open')) return;
if (e.key === 'g') setView('graph');
if (e.key === 's') setView('status');
if (e.key === '1') setView('cards');
if (state.view !== 'cards') return;
if (!state.items || !state.items.length) return;
const idxOf = (id) => state.items.findIndex(x => x.id === id);
let idx = state.selectedId ? idxOf(state.selectedId) : -1;
if (idx < 0) idx = 0;
if (e.key === 'j') {
idx = Math.min(state.items.length - 1, idx + 1);
selectCard(state.items[idx].id);
} else if (e.key === 'k') {
idx = Math.max(0, idx - 1);
selectCard(state.items[idx].id);
} else if (e.key === 'Enter') {
showDetail(state.items[idx].id);
} else if (e.key === 'c') {
confirm(state.items[idx].id, e);
} else if (e.key === 'r') {
reject(state.items[idx].id, e);
}
});
function renderCardsWithSuggestions() {
const el = document.getElementById('cards');
el.innerHTML = state.items.map(item => {
const suggestions = (item.link_suggestions || []).concat(item.predictive_links || []);
const suggHtml = suggestions.length ? suggestions.map(s => `
<div class="suggestion">
<span class="sugg-id">${s.engram_id.substring(0,8)}</span>
<span class="sugg-preview">${escapeHtml(s.preview || s.content_preview || '')}</span>
<button class="btn-link" onclick="acceptLink('${item.id}', '${s.engram_id}', event)">🔗</button>
</div>
`).join('') : '<span class="muted">Keine Vorschläge</span>';
return `
<div class="card ${item.id === state.selectedId ? 'selected' : ''} ${item.confirmed ? 'confirmed' : ''} ${item.rejections > 0 ? 'rejected' : ''}" data-id="${item.id}" onclick="selectCard('${item.id}')">
<div class="card-header">
<span class="conf-badge" style="background:hsl(${item.confidence*120},70%,40%)">${Math.round(item.confidence*100)}%</span>
${renderVerdictPill(item)}
<span class="tags">${item.tags.map(t => '<span class="tag">'+t+'</span>').join('')}</span>
<span class="date">${fmtDate(item.created)}</span>
</div>
<div class="card-body" onclick="showDetail('${item.id}')">
${escapeHtml(item.content.substring(0, 200))}${item.content.length>200?'...':''}
</div>
<div class="suggestions">
<strong>Vorschläge:</strong> ${suggHtml}
</div>
<div class="card-footer">
<input type="text" class="reason-input" placeholder="Grund (optional)" id="reason-${item.id}"/>
<div class="actions">
<button class="btn-ok" onclick="confirm('${item.id}', event)">✅</button>
<button class="btn-no" onclick="reject('${item.id}', event)">❌</button>
<button class="btn-archive" onclick="refresh('${item.id}', event)">🔄</button>
</div>
</div>
</div>
`;
}).join('');
}
async function acceptLink(fromId, toId, ev) {
ev.stopPropagation();
await api('/api/links/accept', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({from_id: fromId, to_id: toId})
});
alert('Link erstellt');
await loadCards();
}
</script>
</body>
</html>