chore: sync local workspace state
This commit is contained in:
@@ -45,11 +45,22 @@
|
||||
<div class="cards" id="cards"></div>
|
||||
|
||||
<!-- 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>
|
||||
<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>
|
||||
<option value="1000">Nodes: 1000</option>
|
||||
<option value="5000">Nodes: 5000</option>
|
||||
</select>
|
||||
<button class="btn" onclick="reloadGraph()">Reload</button>
|
||||
</div>
|
||||
<canvas id="graphCanvas" width="440" height="520"></canvas>
|
||||
@@ -102,7 +113,18 @@ let state = {
|
||||
// ─── Fetch ──────────────────────────────────────────────────────────────────
|
||||
async function api(path, opts = {}) {
|
||||
const r = await fetch(path, opts);
|
||||
if (!r.ok) throw new Error((await r.json()).error || r.statusText);
|
||||
if (!r.ok) {
|
||||
let msg = r.statusText;
|
||||
try {
|
||||
const j = await r.json();
|
||||
msg = j.error || j.detail || msg;
|
||||
if (Array.isArray(msg)) msg = JSON.stringify(msg);
|
||||
if (typeof msg !== 'string') msg = JSON.stringify(msg);
|
||||
} catch (e) {
|
||||
// ignore JSON parse errors
|
||||
}
|
||||
throw new Error(msg);
|
||||
}
|
||||
return r.json();
|
||||
}
|
||||
|
||||
@@ -155,14 +177,23 @@ async function loadCards() {
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
const [cfg, db, jobs, ins, stor] = await Promise.all([
|
||||
const reqs = await Promise.allSettled([
|
||||
api('/api/config'),
|
||||
api('/api/db_info'),
|
||||
api('/api/jobs'),
|
||||
api('/api/insights?limit=8'),
|
||||
api('/api/storage_stats'),
|
||||
api('/api/pending?limit=20&offset=0'),
|
||||
]);
|
||||
const pend = await api('/api/pending?limit=20&offset=0');
|
||||
const pick = (i, fallback) => (reqs[i].status === 'fulfilled' ? reqs[i].value : fallback);
|
||||
const err = (i) => (reqs[i].status === 'rejected' ? (reqs[i].reason && reqs[i].reason.message ? reqs[i].reason.message : String(reqs[i].reason)) : null);
|
||||
|
||||
const cfg = pick(0, { workspace: '-', db_path: '-' });
|
||||
const db = pick(1, { db_path: '-', mtime: null });
|
||||
const jobs = pick(2, { items: [], error: err(2) });
|
||||
const ins = pick(3, { pending: '-', top_tags: [], top_hosts: [], error: err(3) });
|
||||
const stor = pick(4, { sql: { total_engrams: '-', confirmed: '-', pending: '-', by_source: {} }, vector: { chroma_size_bytes: 0, embedding_cache_files: 0 }, obsidian: { configured: false } });
|
||||
const pend = pick(5, { items: [], error: err(5) });
|
||||
|
||||
const el = document.getElementById('status');
|
||||
const jobsHtml = (jobs.items || []).map(j => `
|
||||
@@ -210,24 +241,40 @@ async function loadStatus() {
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">Jobs</div>
|
||||
${jobsHtml || '<div class="muted">Keine Daten</div>'}
|
||||
${jobs.error ? `<div class="muted">Fehler: ${escapeHtml(jobs.error)}</div>` : (jobsHtml || '<div class="muted">Keine Daten</div>')}
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">Insights</div>
|
||||
${ins.error ? `<div class="muted">Fehler: ${escapeHtml(ins.error)}</div>` : ''}
|
||||
<div class="kv-row"><div class="kv-key">pending</div><div class="kv-val">${ins.pending}</div></div>
|
||||
<div class="kv-row"><div class="kv-key">top tags</div><div class="kv-val">${topTags || '-'}</div></div>
|
||||
<div class="kv-row"><div class="kv-key">top hosts</div><div class="kv-val">${topHosts || '-'}</div></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">Pending Queue (latest)</div>
|
||||
${pendHtml || '<div class="muted">Keine Pendings</div>'}
|
||||
${pend.error ? `<div class="muted">Fehler: ${escapeHtml(pend.error)}</div>` : (pendHtml || '<div class="muted">Keine Pendings</div>')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadGraph() {
|
||||
const g = await api('/api/graph?limit_nodes=200');
|
||||
renderGraph(g.nodes || [], g.edges || []);
|
||||
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…';
|
||||
try {
|
||||
const g = await api(`/api/graph?limit_nodes=${q}`);
|
||||
renderGraph(g.nodes || [], g.edges || []);
|
||||
} catch (e) {
|
||||
if (hint) hint.textContent = `Graph-Fehler: ${e && e.message ? e.message : String(e)}`;
|
||||
const canvas = _graphCanvas();
|
||||
const ctx = _graphCtx();
|
||||
if (canvas && ctx) ctx.clearRect(0,0,canvas.width,canvas.height);
|
||||
}
|
||||
}
|
||||
|
||||
function reloadGraph() { loadGraph(); }
|
||||
@@ -243,6 +290,7 @@ let graphState = {
|
||||
degree: new Map(),
|
||||
physicsOn: false,
|
||||
draggingId: null,
|
||||
selectedId: null,
|
||||
panning: false,
|
||||
lastX: 0,
|
||||
lastY: 0,
|
||||
@@ -255,6 +303,8 @@ let graphState = {
|
||||
pinchStartDist: null,
|
||||
pinchStartZoom: null,
|
||||
pinchStartPan: null,
|
||||
down: null, // {pointerId, cx, cy, t}
|
||||
physicsStrength: parseInt(localStorage.getItem('physicsStrength') || '60', 10),
|
||||
};
|
||||
|
||||
function _graphCanvas() { return document.getElementById('graphCanvas'); }
|
||||
@@ -262,13 +312,41 @@ function _graphCtx() { return _graphCanvas().getContext('2d'); }
|
||||
|
||||
function _graphNodeRadius(n) {
|
||||
const d = graphState.degree.get(n.id) || 0;
|
||||
const base = n.kind === 'tag' ? 4 : 6;
|
||||
return Math.max(3, Math.min(14, base + Math.sqrt(d)));
|
||||
const base = n.kind === 'tag' ? 4 : (n.kind === 'host' ? 5 : 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));
|
||||
}
|
||||
|
||||
function _graphNodeFill(n) {
|
||||
if (n.kind === 'tag') return '#8a9aff';
|
||||
return '#6c8af5';
|
||||
const d = graphState.degree.get(n.id) || 0;
|
||||
const t = Math.max(0, Math.min(0.55, d / 18)); // higher degree -> brighter
|
||||
const mix = (rgb) => rgb.map(c => Math.round(c + (255 - c) * t));
|
||||
|
||||
if (n.kind === 'tag') {
|
||||
const [r,g,b] = mix([167, 139, 250]);
|
||||
return `rgb(${r},${g},${b})`;
|
||||
}
|
||||
if (n.kind === 'host') {
|
||||
const [r,g,b] = mix([245, 158, 11]);
|
||||
return `rgb(${r},${g},${b})`;
|
||||
}
|
||||
|
||||
const verdict = (n.verdict || '').toString();
|
||||
let base = [96, 165, 250]; // pending/unknown = blue
|
||||
if (verdict === 'confirmed_true') base = [34, 197, 94]; // green
|
||||
else if (verdict === 'confirmed_false') base = [239, 68, 68]; // red
|
||||
else if (verdict === 'probable_true') base = [52, 211, 153]; // teal
|
||||
else if (verdict === 'probable_false') base = [251, 191, 36]; // amber
|
||||
|
||||
// Recency brightening (brand new pops)
|
||||
const now = Date.now();
|
||||
const created = Date.parse(n.created || '') || 0;
|
||||
const ageMin = created ? (now - created) / 60000 : 999999;
|
||||
const rec = Math.max(0, Math.min(0.35, (30 - ageMin) / 30 * 0.35));
|
||||
const bump = (c) => Math.round(c + (255 - c) * rec);
|
||||
const [r,g,b] = mix(base).map(bump);
|
||||
return `rgb(${r},${g},${b})`;
|
||||
}
|
||||
|
||||
function _graphMatches(n, term) {
|
||||
@@ -325,11 +403,13 @@ function _graphInitInteractions() {
|
||||
canvas.setPointerCapture(ev.pointerId);
|
||||
const pos = toCanvasXY(ev);
|
||||
graphState.pointers.set(ev.pointerId, pos);
|
||||
graphState.down = { pointerId: ev.pointerId, cx: pos.cx, cy: pos.cy, t: Date.now() };
|
||||
|
||||
// When 2 pointers: start pinch
|
||||
if (graphState.pointers.size === 2) {
|
||||
graphState.panning = false;
|
||||
graphState.draggingId = null;
|
||||
graphState.down = null;
|
||||
graphState.pinchStartDist = pinchDistance();
|
||||
graphState.pinchStartZoom = graphState.zoom;
|
||||
graphState.pinchStartPan = { panX: graphState.panX, panY: graphState.panY };
|
||||
@@ -342,8 +422,14 @@ function _graphInitInteractions() {
|
||||
const w = _graphWorldFromScreen(cx, cy);
|
||||
const hit = _graphHitTest(w.x, w.y);
|
||||
graphState.lastX = cx; graphState.lastY = cy;
|
||||
if (hit) graphState.draggingId = hit.id;
|
||||
else graphState.panning = true;
|
||||
if (hit) {
|
||||
graphState.draggingId = hit.id;
|
||||
graphState.selectedId = hit.id;
|
||||
} else {
|
||||
graphState.panning = true;
|
||||
graphState.selectedId = null;
|
||||
}
|
||||
_graphDraw();
|
||||
});
|
||||
|
||||
canvas.addEventListener('pointermove', (ev) => {
|
||||
@@ -399,8 +485,34 @@ function _graphInitInteractions() {
|
||||
graphState.pinchStartZoom = null;
|
||||
graphState.pinchStartPan = null;
|
||||
}
|
||||
|
||||
const pos = toCanvasXY(ev);
|
||||
const down = graphState.down && graphState.down.pointerId === ev.pointerId ? graphState.down : null;
|
||||
graphState.down = null;
|
||||
|
||||
const moved = down ? Math.hypot(pos.cx - down.cx, pos.cy - down.cy) : 999;
|
||||
const isTap = !!down && moved <= 8 && (Date.now() - down.t) <= 500;
|
||||
|
||||
const w = _graphWorldFromScreen(pos.cx, pos.cy);
|
||||
const hit = _graphHitTest(w.x, w.y);
|
||||
|
||||
graphState.draggingId = null;
|
||||
graphState.panning = false;
|
||||
|
||||
if (isTap && hit) {
|
||||
graphState.selectedId = hit.id;
|
||||
_graphDraw();
|
||||
if (hit.kind === 'engram') {
|
||||
showDetail(hit.id);
|
||||
} else if (hit.kind === 'tag') {
|
||||
const label = (hit.label || '').replace(/^tag:/, '');
|
||||
const inp = document.getElementById('searchInput');
|
||||
inp.value = label;
|
||||
inp.dispatchEvent(new Event('input'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
_graphDraw();
|
||||
};
|
||||
canvas.addEventListener('pointerup', endPointer);
|
||||
canvas.addEventListener('pointercancel', endPointer);
|
||||
@@ -411,9 +523,17 @@ 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, Math.min(520, w));
|
||||
canvas.height = 520;
|
||||
canvas.width = Math.max(320, w);
|
||||
canvas.height = Math.max(520, Math.min(900, (window.innerHeight || 900) - 260));
|
||||
|
||||
graphState.nodes = nodes || [];
|
||||
graphState.edges = edges || [];
|
||||
@@ -424,52 +544,209 @@ function renderGraph(nodes, edges) {
|
||||
graphState.degree.set(e.to, (graphState.degree.get(e.to) || 0) + 1);
|
||||
}
|
||||
|
||||
if (!graphState.nodes.length || !graphState.edges.length) {
|
||||
hint.textContent = 'Graph: keine Kanten (noch keine Links/Tags im Sample).';
|
||||
if (!graphState.nodes.length) {
|
||||
hint.textContent = 'Graph: keine Nodes.';
|
||||
ctx.clearRect(0,0,canvas.width,canvas.height);
|
||||
return;
|
||||
}
|
||||
|
||||
hint.textContent = `nodes=${graphState.nodes.length} edges=${graphState.edges.length}`;
|
||||
graphState.sim = graphState.nodes.map(n => ({
|
||||
id: n.id,
|
||||
kind: n.kind,
|
||||
label: n.label || n.id,
|
||||
x: (Math.random() - 0.5) * canvas.width,
|
||||
y: (Math.random() - 0.5) * canvas.height,
|
||||
vx: 0, vy: 0,
|
||||
}));
|
||||
|
||||
// Build adjacency for deterministic layout (O(n+e))
|
||||
const adj = new Map();
|
||||
const addAdj = (a, b) => {
|
||||
if (!adj.has(a)) adj.set(a, []);
|
||||
adj.get(a).push(b);
|
||||
};
|
||||
for (const e of graphState.edges) {
|
||||
addAdj(e.from, e.to);
|
||||
addAdj(e.to, e.from);
|
||||
}
|
||||
|
||||
const degree = graphState.degree;
|
||||
const visited = new Set();
|
||||
const comps = [];
|
||||
for (const n of graphState.nodes) {
|
||||
if (visited.has(n.id)) continue;
|
||||
const q = [n.id];
|
||||
visited.add(n.id);
|
||||
const comp = [];
|
||||
while (q.length) {
|
||||
const cur = q.pop();
|
||||
comp.push(cur);
|
||||
const neigh = adj.get(cur) || [];
|
||||
for (const nb of neigh) {
|
||||
if (!visited.has(nb)) {
|
||||
visited.add(nb);
|
||||
q.push(nb);
|
||||
}
|
||||
}
|
||||
}
|
||||
comps.push(comp);
|
||||
}
|
||||
comps.sort((a, b) => b.length - a.length);
|
||||
|
||||
// component centers in a loose grid/spiral
|
||||
const centers = [];
|
||||
const gap = 240;
|
||||
const cols = Math.max(1, Math.floor(canvas.width / gap));
|
||||
for (let i = 0; i < comps.length; i++) {
|
||||
const cx = (i % cols) * gap - ((cols - 1) * gap) / 2;
|
||||
const cy = Math.floor(i / cols) * gap - (Math.floor((comps.length - 1) / cols) * gap) / 2;
|
||||
centers.push({ cx, cy });
|
||||
}
|
||||
|
||||
const pos = new Map();
|
||||
const R = 46;
|
||||
for (let ci = 0; ci < comps.length; ci++) {
|
||||
const comp = comps[ci];
|
||||
const center = centers[ci] || { cx: 0, cy: 0 };
|
||||
|
||||
// hub: highest degree engram preferred
|
||||
let hub = comp[0];
|
||||
let hubScore = -1;
|
||||
for (const id of comp) {
|
||||
const n = graphState.nodeById.get(id) || {};
|
||||
const score = (degree.get(id) || 0) + (n.kind === 'engram' ? 3 : 0);
|
||||
if (score > hubScore) { hubScore = score; hub = id; }
|
||||
}
|
||||
|
||||
// BFS layers from hub
|
||||
const layer = new Map();
|
||||
layer.set(hub, 0);
|
||||
const qq = [hub];
|
||||
while (qq.length) {
|
||||
const cur = qq.shift();
|
||||
const l = layer.get(cur) || 0;
|
||||
const neigh = adj.get(cur) || [];
|
||||
for (const nb of neigh) {
|
||||
if (!layer.has(nb)) {
|
||||
layer.set(nb, l + 1);
|
||||
qq.push(nb);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const buckets = new Map();
|
||||
for (const id of comp) {
|
||||
const l = layer.get(id);
|
||||
const ll = (typeof l === 'number') ? l : 99;
|
||||
if (!buckets.has(ll)) buckets.set(ll, []);
|
||||
buckets.get(ll).push(id);
|
||||
}
|
||||
|
||||
// Place hubs first, then rings
|
||||
pos.set(hub, { x: center.cx, y: center.cy });
|
||||
const maxLayer = Math.max(...Array.from(buckets.keys()));
|
||||
for (let l = 1; l <= maxLayer; l++) {
|
||||
const ids = buckets.get(l) || [];
|
||||
if (!ids.length) continue;
|
||||
// Stable order: engrams first, then by degree desc
|
||||
ids.sort((a, b) => {
|
||||
const na = graphState.nodeById.get(a) || {};
|
||||
const nb = graphState.nodeById.get(b) || {};
|
||||
const ka = na.kind === 'engram' ? 0 : (na.kind === 'tag' ? 1 : 2);
|
||||
const kb = nb.kind === 'engram' ? 0 : (nb.kind === 'tag' ? 1 : 2);
|
||||
if (ka !== kb) return ka - kb;
|
||||
return (degree.get(b) || 0) - (degree.get(a) || 0);
|
||||
});
|
||||
const rad = l * R;
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
const ang = (i / ids.length) * Math.PI * 2;
|
||||
pos.set(ids[i], {
|
||||
x: center.cx + Math.cos(ang) * rad,
|
||||
y: center.cy + Math.sin(ang) * rad,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
graphState.sim = graphState.nodes.map(n => {
|
||||
const p = pos.get(n.id) || { x: (Math.random() - 0.5) * canvas.width, y: (Math.random() - 0.5) * canvas.height };
|
||||
return {
|
||||
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,
|
||||
predict_locked: n.predict_locked,
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
vx: 0, vy: 0,
|
||||
};
|
||||
});
|
||||
graphState.simById = new Map(graphState.sim.map(n => [n.id, n]));
|
||||
graphState.links = graphState.edges
|
||||
.map(e => ({a: graphState.simById.get(e.from), b: graphState.simById.get(e.to), kind: e.kind}))
|
||||
.map(e => ({a: graphState.simById.get(e.from), b: graphState.simById.get(e.to), kind: e.kind, weight: e.weight || 1.0}))
|
||||
.filter(l => l.a && l.b);
|
||||
|
||||
graphState.panX = canvas.width / 2;
|
||||
graphState.panY = canvas.height / 2;
|
||||
graphState.zoom = 1;
|
||||
graphState.search = state.search || '';
|
||||
graphState.selectedId = null;
|
||||
|
||||
_graphInitInteractions();
|
||||
for (let i = 0; i < 120; i++) _graphStepPhysics(0.9);
|
||||
// Physics is expensive; pre-relax a little but keep it optional for big graphs.
|
||||
const relaxIters = graphState.sim.length <= 700 ? 70 : 12;
|
||||
for (let i = 0; i < relaxIters; i++) _graphStepPhysics(0.85);
|
||||
_graphDraw();
|
||||
}
|
||||
|
||||
function _graphStepPhysics(alpha = 1.0) {
|
||||
const canvas = _graphCanvas();
|
||||
const repulsion = 180;
|
||||
const repulsion = (graphState.sim.length > 700) ? 120 : 180;
|
||||
const damping = 0.86;
|
||||
const target = 80;
|
||||
const springK = 0.018;
|
||||
|
||||
const sim = graphState.sim;
|
||||
for (let i = 0; i < sim.length; i++) {
|
||||
for (let j = i + 1; j < sim.length; j++) {
|
||||
const a = sim[i], b = sim[j];
|
||||
const dx = a.x - b.x, dy = a.y - b.y;
|
||||
const d2 = dx*dx + dy*dy + 0.02;
|
||||
const f = (repulsion / d2) * alpha;
|
||||
a.vx += dx*f; a.vy += dy*f;
|
||||
b.vx -= dx*f; b.vy -= dy*f;
|
||||
if (sim.length > 700) {
|
||||
// Fast approximate repulsion via spatial hashing (checks only local neighbor cells).
|
||||
const cell = 120;
|
||||
const grid = new Map();
|
||||
const key = (cx, cy) => `${cx},${cy}`;
|
||||
for (let i = 0; i < sim.length; i++) {
|
||||
const n = sim[i];
|
||||
const cx = Math.floor(n.x / cell);
|
||||
const cy = Math.floor(n.y / cell);
|
||||
const k = key(cx, cy);
|
||||
if (!grid.has(k)) grid.set(k, []);
|
||||
grid.get(k).push(i);
|
||||
}
|
||||
for (let i = 0; i < sim.length; i++) {
|
||||
const a = sim[i];
|
||||
const cx = Math.floor(a.x / cell);
|
||||
const cy = Math.floor(a.y / cell);
|
||||
for (let ox = -1; ox <= 1; ox++) {
|
||||
for (let oy = -1; oy <= 1; oy++) {
|
||||
const idxs = grid.get(key(cx + ox, cy + oy));
|
||||
if (!idxs) continue;
|
||||
for (const j of idxs) {
|
||||
if (j === i) continue;
|
||||
const b = sim[j];
|
||||
const dx = a.x - b.x, dy = a.y - b.y;
|
||||
const d2 = dx*dx + dy*dy + 0.12;
|
||||
const f = (repulsion / d2) * alpha * 0.55;
|
||||
a.vx += dx*f; a.vy += dy*f;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < sim.length; i++) {
|
||||
for (let j = i + 1; j < sim.length; j++) {
|
||||
const a = sim[i], b = sim[j];
|
||||
const dx = a.x - b.x, dy = a.y - b.y;
|
||||
const d2 = dx*dx + dy*dy + 0.02;
|
||||
const f = (repulsion / d2) * alpha;
|
||||
a.vx += dx*f; a.vy += dy*f;
|
||||
b.vx -= dx*f; b.vy -= dy*f;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const l of graphState.links) {
|
||||
@@ -492,6 +769,14 @@ function _graphStepPhysics(alpha = 1.0) {
|
||||
}
|
||||
}
|
||||
|
||||
function _graphEdgeColor(kind) {
|
||||
const k = (kind || '').toString().toLowerCase();
|
||||
if (k.includes('tag')) return '#7c3aed';
|
||||
if (k.includes('host')) return '#f59e0b';
|
||||
if (k.includes('ref')) return '#10b981';
|
||||
return '#3a3a55';
|
||||
}
|
||||
|
||||
function _graphDraw() {
|
||||
const canvas = _graphCanvas();
|
||||
const ctx = _graphCtx();
|
||||
@@ -502,10 +787,13 @@ function _graphDraw() {
|
||||
ctx.translate(graphState.panX, graphState.panY);
|
||||
ctx.scale(graphState.zoom, graphState.zoom);
|
||||
|
||||
ctx.globalAlpha = 0.45;
|
||||
ctx.strokeStyle = '#3a3a55';
|
||||
ctx.lineWidth = 1 / graphState.zoom;
|
||||
for (const l of graphState.links) {
|
||||
const term = (graphState.search || '').trim();
|
||||
const isMatchEdge = term && (_graphMatches(l.a, term) || _graphMatches(l.b, term));
|
||||
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.strokeStyle = isMatchEdge ? '#f7d154' : _graphEdgeColor(l.kind);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(l.a.x, l.a.y);
|
||||
ctx.lineTo(l.b.x, l.b.y);
|
||||
@@ -528,10 +816,42 @@ function _graphDraw() {
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
if (graphState.selectedId === n.id) {
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
ctx.lineWidth = 2 / graphState.zoom;
|
||||
ctx.arc(n.x, n.y, r + 2, 0, Math.PI*2);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = _graphNodeFill(n);
|
||||
ctx.arc(n.x, n.y, r, 0, Math.PI*2);
|
||||
ctx.fill();
|
||||
|
||||
// status/recency/lock border
|
||||
let stroke = null;
|
||||
const v = (n.verdict || '').toString();
|
||||
if (v === 'confirmed_true') stroke = '#bbf7d0';
|
||||
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 isNew = created && (now - created) < (30 * 60 * 1000);
|
||||
const isHot = modified && (now - modified) < (10 * 60 * 1000);
|
||||
if (isNew) stroke = '#f7d154';
|
||||
if (isHot) stroke = '#ffffff';
|
||||
if (n.predict_locked) stroke = '#a78bfa';
|
||||
|
||||
if (stroke) {
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = stroke;
|
||||
ctx.lineWidth = (n.predict_locked ? 3 : 1.5) / graphState.zoom;
|
||||
ctx.arc(n.x, n.y, r + 0.8, 0, Math.PI*2);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
@@ -540,15 +860,25 @@ function _graphDraw() {
|
||||
|
||||
function _graphLoop() {
|
||||
if (!graphState.physicsOn) return;
|
||||
_graphStepPhysics(1.0);
|
||||
const speed = 0.25 + (Math.max(0, Math.min(100, graphState.physicsStrength || 60)) / 100) * 0.95;
|
||||
_graphStepPhysics(speed);
|
||||
_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);
|
||||
}
|
||||
|
||||
function toggleGraphPhysics() {
|
||||
graphState.physicsOn = !graphState.physicsOn;
|
||||
const b = document.getElementById('btnGraphPhysics');
|
||||
b.textContent = `Physics: ${graphState.physicsOn ? 'on' : 'off'}`;
|
||||
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);
|
||||
@@ -680,6 +1010,8 @@ async function confirm(id, ev) {
|
||||
body: new URLSearchParams({reason})
|
||||
});
|
||||
await loadCards(); await loadStats();
|
||||
if (state.view === 'graph') loadGraph();
|
||||
if (state.view === 'status') loadStatus();
|
||||
}
|
||||
|
||||
async function reject(id, ev) {
|
||||
@@ -691,6 +1023,8 @@ async function reject(id, ev) {
|
||||
body: new URLSearchParams({reason})
|
||||
});
|
||||
await loadCards(); await loadStats();
|
||||
if (state.view === 'graph') loadGraph();
|
||||
if (state.view === 'status') loadStatus();
|
||||
}
|
||||
|
||||
async function refresh(id, ev) {
|
||||
@@ -711,6 +1045,8 @@ async function createEngram() {
|
||||
document.getElementById('newContent').value = '';
|
||||
document.getElementById('newTags').value = '';
|
||||
await loadCards(); await loadStats();
|
||||
if (state.view === 'graph') loadGraph();
|
||||
if (state.view === 'status') loadStatus();
|
||||
}
|
||||
|
||||
async function showDetail(id) {
|
||||
|
||||
Reference in New Issue
Block a user