Style second brain graph like live cluster map

This commit is contained in:
2026-06-05 09:02:39 +02:00
parent 2f61e900b8
commit 51762611c5
2 changed files with 181 additions and 77 deletions

View File

@@ -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;

View File

@@ -58,14 +58,8 @@
<!-- Graph -->
<div class="graph" id="graph" style="display:none;">
<div class="graph-controls">
<button class="btn primary" id="btnGraphPhysics" onclick="toggleGraphPhysics()">Physics: off</button>
<button class="btn" onclick="resetGraphView()">Reset view</button>
<button class="btn" onclick="fitGraphView()">Fit</button>
<label class="muted small" style="display:flex;align-items:center;gap:8px">
<span>Physics</span>
<input id="physicsStrength" type="range" min="0" max="100" value="60" oninput="setPhysicsStrength(this.value)" style="width:140px">
<span id="physicsStrengthVal">60</span>
</label>
<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>
@@ -73,6 +67,7 @@
<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>
@@ -81,7 +76,7 @@
<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><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>
@@ -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 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();
}
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 = _graphNodeFill(n);
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);
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() {