From 51762611c56e46fecd7d9f5f8b1fe4f47c700be3 Mon Sep 17 00:00:00 2001 From: Otto Date: Fri, 5 Jun 2026 09:02:39 +0200 Subject: [PATCH] Style second brain graph like live cluster map --- static/style.css | 16 ++- templates/dashboard.html | 242 +++++++++++++++++++++++++++------------ 2 files changed, 181 insertions(+), 77 deletions(-) diff --git a/static/style.css b/static/style.css index 07c60b2..34a2356 100644 --- a/static/style.css +++ b/static/style.css @@ -166,9 +166,10 @@ body { #graphCanvas{ display:block; margin: 8px auto 0; - background:#12121a; - border:1px solid #252533; - border-radius: 14px; + background:#02040a; + border:1px solid #172033; + border-radius: 8px; + box-shadow: 0 0 28px rgba(34,211,238,0.08), inset 0 0 42px rgba(124,58,237,0.08); touch-action: none; } @@ -192,6 +193,15 @@ body { border-color:#6c8af5; box-shadow:0 0 0 1px rgba(108,138,245,0.18) inset; } +.graph-mode{ + color:#8ef6e4; + font-size:0.78rem; + font-weight:700; + padding: 6px 8px; + border:1px solid #173c42; + background:#07151b; + border-radius: 8px; +} .graph-legend{ margin: 8px 12px 0; padding: 10px 12px; diff --git a/templates/dashboard.html b/templates/dashboard.html index 0a90705..9836de0 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -58,14 +58,8 @@
-
Graph: Zoom per Pinch (2 Finger), Pan per Drag (1 Finger). Tap auf Engram öffnet Details, Tap auf Tag setzt Suche.
+
Graph: Live Cluster wie Obsidian. Zoom per Pinch, Pan per Drag. Tap auf Engram öffnet Details, Tap auf Tag setzt Suche.
Engram
Tag
Quelle
@@ -177,7 +172,12 @@ function setView(view) { document.getElementById('graph').style.display = view === 'graph' ? '' : 'none'; document.getElementById('status').style.display = view === 'status' ? '' : 'none'; - if (view === 'graph') loadGraph(); + if (view === 'graph') { + loadGraph(); + } else if (graphState && graphState.raf) { + cancelAnimationFrame(graphState.raf); + graphState.raf = null; + } if (view === 'status') loadStatus(); } @@ -398,7 +398,7 @@ let graphState = { nodeById: new Map(), simById: new Map(), degree: new Map(), - physicsOn: false, + physicsOn: true, draggingId: null, selectedId: null, panning: false, @@ -414,13 +414,16 @@ let graphState = { pinchStartZoom: null, pinchStartPan: null, down: null, // {pointerId, cx, cy, t} - physicsStrength: parseInt(localStorage.getItem('physicsStrength') || '60', 10), 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'); } @@ -444,18 +447,17 @@ function _graphResetData() { 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 = false; + graphState.physicsOn = true; graphState.panX = 0; graphState.panY = 0; graphState.zoom = 1; - const b = document.getElementById('btnGraphPhysics'); - if (b) { - b.textContent = 'Physics: off'; - b.classList.remove('primary'); - } + graphState.lastFrameAt = 0; } function _graphHashUnit(s) { @@ -468,33 +470,61 @@ function _graphHashUnit(s) { 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) { - 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}; + return _graphSourceCenter((n.label || n.id || '').replace(/^source:/, '')); } if (n.kind === 'tag') { const angle = _graphHashUnit(n.id) * Math.PI * 2; - const ring = 210 + (_graphHashUnit(n.id + ':r') * 170); + 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 = 360 + (_graphHashUnit(n.id + ':r') * 160); + const ring = 760 + (_graphHashUnit(n.id + ':r') * 260); 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; + // 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: Math.cos(angle) * radius * lobe, - y: Math.sin(angle) * radius * (2 - lobe), + x: center.x + Math.cos(angle) * radius * squash, + y: center.y + Math.sin(angle) * radius * (1.55 - squash), }; } @@ -512,6 +542,8 @@ function _graphEnsureSimNode(n) { 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; @@ -526,6 +558,8 @@ function _graphEnsureSimNode(n) { 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, @@ -562,6 +596,9 @@ function _graphMergePayload(payload, opts = {}) { 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) { @@ -575,10 +612,11 @@ function _graphPushLive(text) { function _graphNodeRadius(n) { const d = graphState.degree.get(n.id) || 0; - const base = n.kind === 'tag' ? 4 : (n.kind === 'host' ? 5 : (n.kind === 'source' ? 11 : 7)); + 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(6, Math.sqrt(Math.max(0, w)) * 0.8); - return Math.max(3, Math.min(18, base + Math.sqrt(d) + bonus)); + 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 _graphNodeFill(n) { @@ -595,7 +633,25 @@ function _graphNodeFill(n) { return `rgb(${r},${g},${b})`; } if (n.kind === 'source') { - const [r,g,b] = mix([20, 184, 166]); + 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})`; } @@ -790,14 +846,6 @@ function renderGraph(nodes, edges) { const hint = document.getElementById('graphHint'); const ctx = _graphCtx(); - // sync physics slider - const slider = document.getElementById('physicsStrength'); - const sliderVal = document.getElementById('physicsStrengthVal'); - const s = Math.max(0, Math.min(100, parseInt(graphState.physicsStrength || 60, 10))); - graphState.physicsStrength = s; - if (slider) slider.value = String(s); - if (sliderVal) sliderVal.textContent = String(s); - const w = canvas.parentElement.clientWidth - 24; canvas.width = Math.max(320, w); canvas.height = Math.max(520, Math.min(900, (window.innerHeight || 900) - 260)); @@ -966,6 +1014,10 @@ function renderGraph(nodes, edges) { 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; @@ -1036,6 +1088,36 @@ function _graphStepPhysics(alpha = 1.0) { } } +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'; @@ -1050,16 +1132,27 @@ function _graphDraw() { 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); - for (const l of graphState.links) { - const term = (graphState.search || '').trim(); + 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 ? 0.85 : (0.25 + Math.min(0.35, w * 0.18)); + 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); @@ -1068,7 +1161,6 @@ function _graphDraw() { } ctx.globalAlpha = 1.0; - const term = (graphState.search || '').trim(); let matches = 0; for (const n of graphState.sim) { const r = _graphNodeRadius(n); @@ -1091,10 +1183,23 @@ function _graphDraw() { ctx.stroke(); } - ctx.beginPath(); - ctx.fillStyle = _graphNodeFill(n); - ctx.arc(n.x, n.y, r, 0, Math.PI*2); - ctx.fill(); + 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; @@ -1103,9 +1208,9 @@ function _graphDraw() { else if (v === 'confirmed_false') stroke = '#fecaca'; else if (v) stroke = '#c7d2fe'; - const now = Date.now(); - const created = Date.parse(n.created || '') || 0; - const modified = Date.parse(n.modified || '') || 0; + 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'; @@ -1128,34 +1233,23 @@ function _graphDraw() { function _graphLoop() { if (!graphState.physicsOn) return; - const speed = 0.25 + (Math.max(0, Math.min(100, graphState.physicsStrength || 60)) / 100) * 0.95; - _graphStepPhysics(speed); - _graphDraw(); + 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) { - const n = Math.max(0, Math.min(100, parseInt(v || '0', 10))); - graphState.physicsStrength = n; - localStorage.setItem('physicsStrength', String(n)); - const el = document.getElementById('physicsStrengthVal'); - if (el) el.textContent = String(n); + // Kept for older cached pages; physics is now always live. } function toggleGraphPhysics() { - graphState.physicsOn = !graphState.physicsOn; - const b = document.getElementById('btnGraphPhysics'); - const fast = (graphState.sim || []).length > 700; - b.textContent = `Physics: ${graphState.physicsOn ? ('on' + (fast ? ' (fast)' : '')) : 'off'}`; - b.classList.toggle('primary', graphState.physicsOn); - if (graphState.physicsOn) { - if (graphState.raf) cancelAnimationFrame(graphState.raf); - graphState.raf = requestAnimationFrame(_graphLoop); - } else { - if (graphState.raf) cancelAnimationFrame(graphState.raf); - graphState.raf = null; - _graphDraw(); - } + graphState.physicsOn = true; + if (!graphState.raf) graphState.raf = requestAnimationFrame(_graphLoop); } function resetGraphView() {