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() {