diff --git a/fastapi_app.py b/fastapi_app.py index 2d0c37c..3e8005f 100644 --- a/fastapi_app.py +++ b/fastapi_app.py @@ -630,10 +630,14 @@ def _graph_payload_from_rows(rows: list[sqlite3.Row], link_rows: list[sqlite3.Ro content = (r["content"] or "").strip() source = str(meta.get("source", "unknown") or "unknown") + tags = [t for t in _safe_json_extract_tags(r["metadata_json"]) if t.strip()] + primary_cluster = tags[0] if tags else source add_node(eid, "engram", label=(content[:54] or eid[:8]), weight=float(meta.get("access_count", 0) or 0)) nodes[eid].update( { "source": source, + "cluster": primary_cluster, + "tags": tags[:8], "confidence": float(meta.get("confidence", 0.0) or 0.0), "created": meta.get("created", r["created_at"]), "modified": meta.get("modified", r["modified_at"]), @@ -646,17 +650,20 @@ def _graph_payload_from_rows(rows: list[sqlite3.Row], link_rows: list[sqlite3.Ro sid = f"source:{source}" add_node(sid, "source", label=source, weight=5) + nodes[sid]["cluster"] = source add_edge(eid, sid, "from_source", 0.45) - for t in _safe_json_extract_tags(r["metadata_json"]): + for t in tags: tid = f"tag:{t}" add_node(tid, "tag", label=t, weight=2) + nodes[tid]["cluster"] = t add_edge(eid, tid, "has_tag", 0.35) host = _host_from_meta(r["metadata_json"]) if host: hid = f"host:{host}" add_node(hid, "host", label=host, weight=3) + nodes[hid]["cluster"] = source add_edge(eid, hid, "grounded_at", 0.25) for lr in link_rows: diff --git a/static/style.css b/static/style.css index efdb15a..7e1ceef 100644 --- a/static/style.css +++ b/static/style.css @@ -10,10 +10,10 @@ body { } .app { - max-width: 480px; + max-width: 1180px; margin: 0 auto; min-height: 100vh; - background: #141419; + background: radial-gradient(circle at 50% 12%, #162238 0%, #11131d 42%, #0b0d13 100%); width: 100%; } @@ -165,11 +165,14 @@ body { /* Graph canvas */ #graphCanvas{ display:block; - margin: 8px auto 0; - background:#02040a; - border:1px solid #172033; + margin: 10px auto 0; + background:#01030a; + border:0; border-radius: 50%; - box-shadow: 0 0 28px rgba(34,211,238,0.08), inset 0 0 42px rgba(124,58,237,0.08); + box-shadow: + 0 0 48px rgba(34,211,238,0.14), + 0 0 120px rgba(236,72,153,0.08), + inset 0 0 56px rgba(124,58,237,0.10); touch-action: none; } @@ -181,8 +184,8 @@ body { flex-wrap: wrap; } .graph-controls .btn{ - background:#1e1e28; - border:1px solid #2a2a3a; + background:rgba(12,18,31,0.88); + border:1px solid rgba(74,94,130,0.55); border-radius: 10px; padding: 8px 10px; color:#cfd3ff; @@ -194,19 +197,19 @@ body { box-shadow:0 0 0 1px rgba(108,138,245,0.18) inset; } .graph-mode{ - color:#8ef6e4; + color:#a7f3d0; font-size:0.78rem; font-weight:700; padding: 6px 8px; - border:1px solid #173c42; - background:#07151b; + border:1px solid rgba(52,211,153,0.35); + background:rgba(4,18,20,0.82); border-radius: 8px; } .graph-legend{ margin: 8px 12px 0; padding: 10px 12px; - background:#1a1a24; - border:1px solid #252533; + background:rgba(11,15,25,0.72); + border:1px solid rgba(65,78,112,0.4); border-radius: 14px; color:#b9b9c9; font-size:0.8rem; @@ -222,12 +225,45 @@ body { .graph-live{ margin: 8px 12px 0; padding: 10px 12px; - background:#101820; - border:1px solid #22303d; + background:rgba(5,12,20,0.78); + border:1px solid rgba(56,189,248,0.22); border-radius: 10px; color:#bfe8df; font-size:0.78rem; } +.graph-insights{ + display:grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 8px; + margin: 8px 12px 0; +} +.graph-chip{ + min-height: 44px; + padding: 8px 10px; + border: 1px solid rgba(80,96,130,0.42); + border-radius: 8px; + background: rgba(10,15,26,0.72); + color: #dbeafe; + overflow: hidden; +} +.graph-chip b{ + display:block; + font-size: 0.78rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.graph-chip span{ + display:block; + color:#8aa0c7; + font-size:0.72rem; + margin-top:2px; +} + +@media (max-width: 560px) { + .app { max-width: 100%; } + .graph-controls .btn { padding: 8px 9px; } +} .graph-live-title{ color:#e8fffb; font-weight:700; diff --git a/templates/dashboard.html b/templates/dashboard.html index 3d631f9..5301098 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -67,16 +67,17 @@ - Live physics on + Graph 2.0 live
Lade Graph…
+
Live
-
Graph: Live Cluster wie Obsidian. Zoom per Pinch, Pan per Drag. Tap auf Engram öffnet Details, Tap auf Tag setzt Suche.
+
Graph 2.0: Topic-Cluster, Brücken und Live-Deltas. Zoom per Pinch, Pan per Drag. Tap auf Engram öffnet Details, Tap auf Tag setzt Suche.
Engram
Tag
Quelle
@@ -348,7 +349,7 @@ async function loadGraph() { _graphDraw(); try { let offset = 0; - const chunkSize = maxEngrams && maxEngrams <= 1000 ? 400 : 900; + const chunkSize = maxEngrams && maxEngrams <= 1000 ? 500 : 1800; while (token === graphState.loadingToken) { const limit = maxEngrams ? Math.min(chunkSize, Math.max(0, maxEngrams - offset)) : chunkSize; if (limit <= 0) break; @@ -420,7 +421,10 @@ let graphState = { edgeKeys: new Set(), sourceIndex: new Map(), sourceCounts: new Map(), + clusterIndex: new Map(), + clusterCounts: new Map(), nextSourceIndex: 0, + nextClusterIndex: 0, loadingToken: 0, totalEngrams: 0, loadedEngrams: 0, @@ -454,7 +458,10 @@ function _graphResetData() { graphState.edgeKeys = new Set(); graphState.sourceIndex = new Map(); graphState.sourceCounts = new Map(); + graphState.clusterIndex = new Map(); + graphState.clusterCounts = new Map(); graphState.nextSourceIndex = 0; + graphState.nextClusterIndex = 0; graphState.totalEngrams = 0; graphState.loadedEngrams = 0; graphState.lastModified = null; @@ -477,18 +484,32 @@ function _graphHashUnit(s) { 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 + [34, 211, 238], [236, 72, 153], [168, 85, 247], + [74, 222, 128], [248, 113, 113], [96, 165, 250], + [251, 191, 36], [45, 212, 191], [250, 204, 21], + [129, 140, 248], [251, 113, 133], [52, 211, 153], ]; return palette[Math.floor(_graphHashUnit(seed) * palette.length) % palette.length]; } +function _graphClusterKey(n) { + if (!n) return 'unknown'; + const raw = n.cluster || (Array.isArray(n.tags) && n.tags[0]) || n.source || n.label || n.id || 'unknown'; + return String(raw || 'unknown').slice(0, 80); +} + +function _graphClusterCenter(cluster) { + const key = cluster || 'unknown'; + if (!graphState.clusterIndex.has(key)) { + graphState.clusterIndex.set(key, graphState.nextClusterIndex++); + } + const i = graphState.clusterIndex.get(key); + if (i === 0) return {x: 0, y: 0}; + const angle = i * 2.399963 + _graphHashUnit(key) * 0.85; + const ring = 190 + Math.sqrt(i) * 78; + return {x: Math.cos(angle) * ring, y: Math.sin(angle) * ring}; +} + function _graphSourceCenter(source) { const key = source || 'unknown'; if (!graphState.sourceIndex.has(key)) { @@ -505,28 +526,30 @@ function _graphSourceCenter(source) { function _graphPlaceNode(n) { if (n.kind === 'source') { - return _graphSourceCenter((n.label || n.id || '').replace(/^source:/, '')); + const p = _graphSourceCenter((n.label || n.id || '').replace(/^source:/, '')); + return {x: p.x * 0.55, y: p.y * 0.55}; } if (n.kind === 'tag') { + const p = _graphClusterCenter(n.label || n.id); const angle = _graphHashUnit(n.id) * Math.PI * 2; - const ring = 470 + (_graphHashUnit(n.id + ':r') * 260); - return {x: Math.cos(angle) * ring, y: Math.sin(angle) * ring}; + const ring = 24 + (_graphHashUnit(n.id + ':r') * 54); + return {x: p.x + Math.cos(angle) * ring, y: p.y + Math.sin(angle) * ring}; } if (n.kind === 'host') { const angle = _graphHashUnit(n.id) * Math.PI * 2; - const ring = 640 + (_graphHashUnit(n.id + ':r') * 220); + const ring = 540 + (_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) * 7.5 + _graphHashUnit(n.id + ':r') * 28; - const squash = 0.94 + (_graphHashUnit(source) - 0.5) * 0.16; + // Graph 2.0: nodes live in topic lobes. This gives an InfraNodus-like + // overview immediately, before expensive edge physics has any work to do. + const cluster = _graphClusterKey(n); + const local = (graphState.clusterCounts.get(cluster) || 0) + 1; + graphState.clusterCounts.set(cluster, local); + const center = _graphClusterCenter(cluster); + const angle = local * 2.399963 + _graphHashUnit(n.id) * 0.9; + const radius = 14 + Math.sqrt(local) * 6.6 + _graphHashUnit(n.id + ':r') * 30; + const squash = 0.9 + (_graphHashUnit(cluster) - 0.5) * 0.24; return { x: center.x + Math.cos(angle) * radius * squash, y: center.y + Math.sin(angle) * radius / squash, @@ -546,6 +569,8 @@ function _graphEnsureSimNode(n) { modified: n.modified ?? existing.modified, last_accessed: n.last_accessed ?? existing.last_accessed, source: n.source ?? existing.source, + cluster: n.cluster ?? existing.cluster, + tags: n.tags ?? existing.tags, 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, @@ -567,6 +592,8 @@ function _graphEnsureSimNode(n) { modifiedMs: Date.parse(n.modified || '') || 0, last_accessed: n.last_accessed, source: n.source, + cluster: n.cluster, + tags: n.tags, predict_locked: n.predict_locked, x: p.x, y: p.y, @@ -649,11 +676,11 @@ function _graphNodeFill(n) { } if (n.kind === 'engram') { - let base = _graphPalette(n.source || n.id); + let base = _graphPalette(_graphClusterKey(n)); 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); + const src = _graphPalette(_graphClusterKey(n)); base = [Math.round((src[0] + 74) / 2), Math.round((src[1] + 222) / 2), Math.round((src[2] + 128) / 2)]; } @@ -1107,21 +1134,21 @@ function _graphStepAmbient(alpha = 1.0) { 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 sample = Math.min(count, 1400); 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; + const amp = (n.kind === 'engram') ? 0.026 : 0.07; 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; + const home = n.kind === 'engram' ? _graphClusterCenter(_graphClusterKey(n)) : (n.kind === 'tag' ? _graphClusterCenter(n.label || n.id) : null); if (home) { - n.vx += (home.x - n.x) * 0.00003 * alpha; - n.vy += (home.y - n.y) * 0.00003 * alpha; + n.vx += (home.x - n.x) * 0.00004 * alpha; + n.vy += (home.y - n.y) * 0.00004 * alpha; } n.vx *= 0.92; n.vy *= 0.92; n.x += n.vx; n.y += n.vy; @@ -1133,8 +1160,38 @@ function _graphEdgeColor(kind) { const k = (kind || '').toString().toLowerCase(); if (k.includes('tag')) return '#7c3aed'; if (k.includes('host')) return '#f59e0b'; + if (k.includes('source')) return '#22d3ee'; if (k.includes('ref')) return '#10b981'; - return '#3a3a55'; + return '#64748b'; +} + +function _graphDenseEdgeVisible(l) { + if (!l || !l.a || !l.b) return false; + if (l.kind === 'link') return true; + const da = graphState.degree.get(l.a.id) || 0; + const db = graphState.degree.get(l.b.id) || 0; + if (l.a.kind !== 'engram' || l.b.kind !== 'engram') return Math.max(da, db) >= 28; + const h = _graphHashUnit(`${l.a.id}:${l.b.id}:${l.kind}`); + return h > 0.982; +} + +function _graphRenderInsights() { + const el = document.getElementById('graphInsights'); + if (!el) return; + const clusters = Array.from(graphState.clusterCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5); + const live = graphState.sim.filter(n => { + const ms = n.modifiedMs || n.createdMs || 0; + return ms && (graphState.drawNow - ms) < 15 * 60 * 1000; + }).length; + const html = [ + `
${graphState.sim.length}Knoten live
`, + `
${graphState.links.length}Beziehungen
`, + `
${live}aktiv / neu
`, + ...clusters.map(([name, count]) => `
${escapeHtml(name)}${count} Einträge
`), + ].join(''); + el.innerHTML = html; } function _graphDraw() { @@ -1163,14 +1220,17 @@ function _graphDraw() { 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) { + let drawnEdges = 0; + const maxDenseEdges = graphState.selectedId || term ? 5000 : 2400; + 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; + if (dense && !isMatchEdge && !selectedEdge && !_graphDenseEdgeVisible(l)) continue; + if (dense && !isMatchEdge && !selectedEdge && drawnEdges >= maxDenseEdges) continue; + drawnEdges++; 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.globalAlpha = isMatchEdge || selectedEdge ? 0.9 : (dense ? 0.16 : (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); @@ -1249,12 +1309,16 @@ function _graphDraw() { ctx.save(); ctx.beginPath(); ctx.arc(cx, cy, viewR, 0, Math.PI * 2); - ctx.strokeStyle = 'rgba(34, 211, 238, 0.18)'; - ctx.lineWidth = 1.2; + ctx.strokeStyle = 'rgba(34, 211, 238, 0.05)'; + ctx.lineWidth = 0.8; 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}` : ''); + if (!graphState._lastInsightsAt || graphState.drawNow - graphState._lastInsightsAt > 1500) { + graphState._lastInsightsAt = graphState.drawNow; + _graphRenderInsights(); + } } function _graphLoop() {