Improve second brain live graph
This commit is contained in:
@@ -76,10 +76,15 @@
|
||||
</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>: Zoom per Pinch (2 Finger), Pan per Drag (1 Finger). 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>
|
||||
@@ -329,17 +334,35 @@ async function loadStatus() {
|
||||
}
|
||||
|
||||
async function loadGraph() {
|
||||
const sel = document.getElementById('graphLimit');
|
||||
const fromSel = sel ? parseInt(sel.value || '0', 10) : NaN;
|
||||
const fromStore = parseInt(localStorage.getItem('graphLimit') || '0', 10);
|
||||
const q = (!Number.isNaN(fromSel)) ? fromSel : (Number.isNaN(fromStore) ? 0 : fromStore);
|
||||
if (sel) sel.value = String(q);
|
||||
if (sel) localStorage.setItem('graphLimit', String(q));
|
||||
const hint = document.getElementById('graphHint');
|
||||
if (hint) hint.textContent = 'Lade Graph…';
|
||||
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 {
|
||||
const g = await api(`/api/graph?limit_nodes=${q}`);
|
||||
renderGraph(g.nodes || [], g.edges || []);
|
||||
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 (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();
|
||||
@@ -350,6 +373,22 @@ async function loadGraph() {
|
||||
|
||||
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: [],
|
||||
@@ -376,14 +415,167 @@ let graphState = {
|
||||
pinchStartPan: null,
|
||||
down: null, // {pointerId, cx, cy, t}
|
||||
physicsStrength: parseInt(localStorage.getItem('physicsStrength') || '60', 10),
|
||||
edgeKeys: new Set(),
|
||||
loadingToken: 0,
|
||||
totalEngrams: 0,
|
||||
loadedEngrams: 0,
|
||||
lastModified: null,
|
||||
liveFeed: [],
|
||||
};
|
||||
|
||||
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.totalEngrams = 0;
|
||||
graphState.loadedEngrams = 0;
|
||||
graphState.lastModified = null;
|
||||
graphState.physicsOn = false;
|
||||
graphState.panX = 0;
|
||||
graphState.panY = 0;
|
||||
graphState.zoom = 1;
|
||||
const b = document.getElementById('btnGraphPhysics');
|
||||
if (b) {
|
||||
b.textContent = 'Physics: off';
|
||||
b.classList.remove('primary');
|
||||
}
|
||||
}
|
||||
|
||||
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 _graphPlaceNode(n) {
|
||||
const canvas = _graphCanvas();
|
||||
const idx = graphState.sim.length + 1;
|
||||
if (n.kind === 'source') {
|
||||
const angle = _graphHashUnit(n.id) * Math.PI * 2;
|
||||
const ring = 70 + (_graphHashUnit(n.id + ':r') * 45);
|
||||
return {x: Math.cos(angle) * ring, y: Math.sin(angle) * ring};
|
||||
}
|
||||
if (n.kind === 'tag') {
|
||||
const angle = _graphHashUnit(n.id) * Math.PI * 2;
|
||||
const ring = 210 + (_graphHashUnit(n.id + ':r') * 170);
|
||||
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 = 360 + (_graphHashUnit(n.id + ':r') * 160);
|
||||
return {x: Math.cos(angle) * ring, y: Math.sin(angle) * ring};
|
||||
}
|
||||
|
||||
// Obsidian-like "brain": a golden-angle cloud with source-aware lobes.
|
||||
const sourceSeed = _graphHashUnit(n.source || 'unknown') * Math.PI * 2;
|
||||
const angle = idx * 2.399963 + sourceSeed * 0.45;
|
||||
const radius = 120 + Math.sqrt(idx) * 15 + _graphHashUnit(n.id) * 70;
|
||||
const lobe = 1 + (_graphHashUnit(n.source || 'unknown') - 0.5) * 0.26;
|
||||
return {
|
||||
x: Math.cos(angle) * radius * lobe,
|
||||
y: Math.sin(angle) * radius * (2 - lobe),
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
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,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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 base = n.kind === 'tag' ? 4 : (n.kind === 'host' ? 5 : 7);
|
||||
const base = n.kind === 'tag' ? 4 : (n.kind === 'host' ? 5 : (n.kind === 'source' ? 11 : 7));
|
||||
const w = (n.weight || 0);
|
||||
const bonus = Math.min(6, Math.sqrt(Math.max(0, w)) * 0.8);
|
||||
return Math.max(3, Math.min(18, base + Math.sqrt(d) + bonus));
|
||||
@@ -402,6 +594,10 @@ function _graphNodeFill(n) {
|
||||
const [r,g,b] = mix([245, 158, 11]);
|
||||
return `rgb(${r},${g},${b})`;
|
||||
}
|
||||
if (n.kind === 'source') {
|
||||
const [r,g,b] = mix([20, 184, 166]);
|
||||
return `rgb(${r},${g},${b})`;
|
||||
}
|
||||
|
||||
const verdict = (n.verdict || '').toString();
|
||||
let base = [96, 165, 250]; // pending/unknown = blue
|
||||
@@ -926,7 +1122,8 @@ function _graphDraw() {
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
hint.textContent = `nodes=${graphState.nodes.length} edges=${graphState.edges.length}` + (term ? ` | match=${matches}` : '');
|
||||
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() {
|
||||
@@ -982,8 +1179,8 @@ function fitGraphView() {
|
||||
const zx = (canvas.width - 40) / w;
|
||||
const zy = (canvas.height - 40) / h;
|
||||
graphState.zoom = Math.max(0.35, Math.min(2.5, Math.min(zx, zy)));
|
||||
graphState.panX = canvas.width / 2;
|
||||
graphState.panY = canvas.height / 2;
|
||||
graphState.panX = canvas.width / 2 - ((minX + maxX) / 2) * graphState.zoom;
|
||||
graphState.panY = canvas.height / 2 - ((minY + maxY) / 2) * graphState.zoom;
|
||||
_graphDraw();
|
||||
}
|
||||
|
||||
@@ -1005,11 +1202,13 @@ function startEvents() {
|
||||
loadStatus();
|
||||
}
|
||||
if (state.view === 'graph') {
|
||||
// fetch graph less often (every ~15s)
|
||||
const t = Date.now();
|
||||
if (!state._lastGraphFetch || (t - state._lastGraphFetch) > 15000) {
|
||||
state._lastGraphFetch = t;
|
||||
loadGraph();
|
||||
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) {}
|
||||
|
||||
Reference in New Issue
Block a user