|
|
|
|
@@ -46,8 +46,20 @@
|
|
|
|
|
|
|
|
|
|
<!-- 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>
|
|
|
|
|
<button class="btn" onclick="reloadGraph()">Reload</button>
|
|
|
|
|
</div>
|
|
|
|
|
<canvas id="graphCanvas" width="440" height="520"></canvas>
|
|
|
|
|
<div class="muted small" id="graphHint">Lade Graph…</div>
|
|
|
|
|
<div class="graph-hint muted small" id="graphHint">Lade Graph…</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 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 match"></span> Match (Suche)</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Status -->
|
|
|
|
|
@@ -218,79 +230,282 @@ async function loadGraph() {
|
|
|
|
|
renderGraph(g.nodes || [], g.edges || []);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderGraph(nodes, edges) {
|
|
|
|
|
const canvas = document.getElementById('graphCanvas');
|
|
|
|
|
const hint = document.getElementById('graphHint');
|
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
|
function reloadGraph() { loadGraph(); }
|
|
|
|
|
|
|
|
|
|
// ─── Graph Renderer (Canvas) ────────────────────────────────────────────────
|
|
|
|
|
let graphState = {
|
|
|
|
|
nodes: [],
|
|
|
|
|
edges: [],
|
|
|
|
|
sim: [],
|
|
|
|
|
links: [],
|
|
|
|
|
nodeById: new Map(),
|
|
|
|
|
simById: new Map(),
|
|
|
|
|
degree: new Map(),
|
|
|
|
|
physicsOn: false,
|
|
|
|
|
draggingId: null,
|
|
|
|
|
panning: false,
|
|
|
|
|
lastX: 0,
|
|
|
|
|
lastY: 0,
|
|
|
|
|
panX: 0,
|
|
|
|
|
panY: 0,
|
|
|
|
|
zoom: 1,
|
|
|
|
|
raf: null,
|
|
|
|
|
search: '',
|
|
|
|
|
pointers: new Map(),
|
|
|
|
|
pinchStartDist: null,
|
|
|
|
|
pinchStartZoom: null,
|
|
|
|
|
pinchStartPan: null,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function _graphCanvas() { return document.getElementById('graphCanvas'); }
|
|
|
|
|
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)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function _graphNodeFill(n) {
|
|
|
|
|
if (n.kind === 'tag') return '#8a9aff';
|
|
|
|
|
return '#6c8af5';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function _graphMatches(n, term) {
|
|
|
|
|
const t = (term || '').trim().toLowerCase();
|
|
|
|
|
if (!t) return false;
|
|
|
|
|
const id = (n.id || '').toLowerCase();
|
|
|
|
|
const label = (n.label || '').toLowerCase();
|
|
|
|
|
return id.includes(t) || label.includes(t);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function _graphWorldFromScreen(cx, cy) {
|
|
|
|
|
return {
|
|
|
|
|
x: (cx - graphState.panX) / graphState.zoom,
|
|
|
|
|
y: (cy - graphState.panY) / graphState.zoom,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function _graphHitTest(wx, wy) {
|
|
|
|
|
for (let i = graphState.sim.length - 1; i >= 0; i--) {
|
|
|
|
|
const n = graphState.sim[i];
|
|
|
|
|
const r = _graphNodeRadius(n) + 2;
|
|
|
|
|
const dx = wx - n.x;
|
|
|
|
|
const dy = wy - n.y;
|
|
|
|
|
if ((dx*dx + dy*dy) <= r*r) return n;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function _graphInitInteractions() {
|
|
|
|
|
const canvas = _graphCanvas();
|
|
|
|
|
if (canvas._graphBound) return;
|
|
|
|
|
canvas._graphBound = true;
|
|
|
|
|
|
|
|
|
|
const toCanvasXY = (ev) => {
|
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
|
|
|
return { cx: ev.clientX - rect.left, cy: ev.clientY - rect.top };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const pinchMidpoint = () => {
|
|
|
|
|
const pts = Array.from(graphState.pointers.values());
|
|
|
|
|
if (pts.length < 2) return null;
|
|
|
|
|
return { cx: (pts[0].cx + pts[1].cx) / 2, cy: (pts[0].cy + pts[1].cy) / 2 };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const pinchDistance = () => {
|
|
|
|
|
const pts = Array.from(graphState.pointers.values());
|
|
|
|
|
if (pts.length < 2) return null;
|
|
|
|
|
const dx = pts[0].cx - pts[1].cx;
|
|
|
|
|
const dy = pts[0].cy - pts[1].cy;
|
|
|
|
|
return Math.sqrt(dx*dx + dy*dy);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
canvas.addEventListener('pointerdown', (ev) => {
|
|
|
|
|
canvas.setPointerCapture(ev.pointerId);
|
|
|
|
|
const pos = toCanvasXY(ev);
|
|
|
|
|
graphState.pointers.set(ev.pointerId, pos);
|
|
|
|
|
|
|
|
|
|
// When 2 pointers: start pinch
|
|
|
|
|
if (graphState.pointers.size === 2) {
|
|
|
|
|
graphState.panning = false;
|
|
|
|
|
graphState.draggingId = null;
|
|
|
|
|
graphState.pinchStartDist = pinchDistance();
|
|
|
|
|
graphState.pinchStartZoom = graphState.zoom;
|
|
|
|
|
graphState.pinchStartPan = { panX: graphState.panX, panY: graphState.panY };
|
|
|
|
|
_graphDraw();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Single pointer: pan or drag node
|
|
|
|
|
const { cx, cy } = pos;
|
|
|
|
|
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;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
canvas.addEventListener('pointermove', (ev) => {
|
|
|
|
|
if (!graphState.pointers.has(ev.pointerId)) return;
|
|
|
|
|
const pos = toCanvasXY(ev);
|
|
|
|
|
graphState.pointers.set(ev.pointerId, pos);
|
|
|
|
|
|
|
|
|
|
// Pinch zoom (2 pointers)
|
|
|
|
|
if (graphState.pointers.size >= 2 && graphState.pinchStartDist) {
|
|
|
|
|
const dist = pinchDistance();
|
|
|
|
|
const mid = pinchMidpoint();
|
|
|
|
|
if (!dist || !mid) return;
|
|
|
|
|
|
|
|
|
|
const zoomOld = graphState.zoom;
|
|
|
|
|
const wx = (mid.cx - graphState.panX) / zoomOld;
|
|
|
|
|
const wy = (mid.cy - graphState.panY) / zoomOld;
|
|
|
|
|
|
|
|
|
|
const factor = dist / graphState.pinchStartDist;
|
|
|
|
|
const next = Math.max(0.35, Math.min(3.0, graphState.pinchStartZoom * factor));
|
|
|
|
|
graphState.zoom = next;
|
|
|
|
|
graphState.panX = mid.cx - wx * graphState.zoom;
|
|
|
|
|
graphState.panY = mid.cy - wy * graphState.zoom;
|
|
|
|
|
_graphDraw();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Single pointer
|
|
|
|
|
const { cx, cy } = pos;
|
|
|
|
|
const dx = cx - graphState.lastX;
|
|
|
|
|
const dy = cy - graphState.lastY;
|
|
|
|
|
graphState.lastX = cx; graphState.lastY = cy;
|
|
|
|
|
|
|
|
|
|
if (graphState.draggingId) {
|
|
|
|
|
const w = _graphWorldFromScreen(cx, cy);
|
|
|
|
|
const n = graphState.simById.get(graphState.draggingId);
|
|
|
|
|
if (n) { n.x = w.x; n.y = w.y; n.vx = 0; n.vy = 0; }
|
|
|
|
|
_graphDraw();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (graphState.panning) {
|
|
|
|
|
graphState.panX += dx;
|
|
|
|
|
graphState.panY += dy;
|
|
|
|
|
_graphDraw();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const endPointer = (ev) => {
|
|
|
|
|
if (graphState.pointers.has(ev.pointerId)) graphState.pointers.delete(ev.pointerId);
|
|
|
|
|
try { canvas.releasePointerCapture(ev.pointerId); } catch {}
|
|
|
|
|
|
|
|
|
|
if (graphState.pointers.size < 2) {
|
|
|
|
|
graphState.pinchStartDist = null;
|
|
|
|
|
graphState.pinchStartZoom = null;
|
|
|
|
|
graphState.pinchStartPan = null;
|
|
|
|
|
}
|
|
|
|
|
graphState.draggingId = null;
|
|
|
|
|
graphState.panning = false;
|
|
|
|
|
};
|
|
|
|
|
canvas.addEventListener('pointerup', endPointer);
|
|
|
|
|
canvas.addEventListener('pointercancel', endPointer);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderGraph(nodes, edges) {
|
|
|
|
|
const canvas = _graphCanvas();
|
|
|
|
|
const hint = document.getElementById('graphHint');
|
|
|
|
|
const ctx = _graphCtx();
|
|
|
|
|
|
|
|
|
|
// Fit canvas to container width (mobile)
|
|
|
|
|
const w = canvas.parentElement.clientWidth - 24;
|
|
|
|
|
canvas.width = Math.max(320, Math.min(520, w));
|
|
|
|
|
canvas.height = 520;
|
|
|
|
|
|
|
|
|
|
if (!nodes.length || !edges.length) {
|
|
|
|
|
hint.textContent = 'Graph: keine Kanten (noch keine Links/Tags/Hosts im Sample).';
|
|
|
|
|
graphState.nodes = nodes || [];
|
|
|
|
|
graphState.edges = edges || [];
|
|
|
|
|
graphState.nodeById = new Map(graphState.nodes.map(n => [n.id, n]));
|
|
|
|
|
graphState.degree = new Map();
|
|
|
|
|
for (const e of graphState.edges) {
|
|
|
|
|
graphState.degree.set(e.from, (graphState.degree.get(e.from) || 0) + 1);
|
|
|
|
|
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).';
|
|
|
|
|
ctx.clearRect(0,0,canvas.width,canvas.height);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
hint.textContent = `nodes=${nodes.length} edges=${edges.length}`;
|
|
|
|
|
|
|
|
|
|
const nodeById = new Map(nodes.map(n => [n.id, n]));
|
|
|
|
|
const sim = nodes.map(n => ({
|
|
|
|
|
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()*canvas.width,
|
|
|
|
|
y: Math.random()*canvas.height,
|
|
|
|
|
x: (Math.random() - 0.5) * canvas.width,
|
|
|
|
|
y: (Math.random() - 0.5) * canvas.height,
|
|
|
|
|
vx: 0, vy: 0,
|
|
|
|
|
}));
|
|
|
|
|
const simById = new Map(sim.map(n => [n.id, n]));
|
|
|
|
|
|
|
|
|
|
const links = edges
|
|
|
|
|
.map(e => ({a: simById.get(e.from), b: simById.get(e.to), kind: e.kind}))
|
|
|
|
|
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}))
|
|
|
|
|
.filter(l => l.a && l.b);
|
|
|
|
|
|
|
|
|
|
// Simple force layout (few iterations)
|
|
|
|
|
for (let iter=0; iter<180; iter++) {
|
|
|
|
|
// repulsion
|
|
|
|
|
graphState.panX = canvas.width / 2;
|
|
|
|
|
graphState.panY = canvas.height / 2;
|
|
|
|
|
graphState.zoom = 1;
|
|
|
|
|
graphState.search = state.search || '';
|
|
|
|
|
|
|
|
|
|
_graphInitInteractions();
|
|
|
|
|
for (let i = 0; i < 120; i++) _graphStepPhysics(0.9);
|
|
|
|
|
_graphDraw();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function _graphStepPhysics(alpha = 1.0) {
|
|
|
|
|
const canvas = _graphCanvas();
|
|
|
|
|
const repulsion = 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.01;
|
|
|
|
|
const f = 120 / d2;
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// springs
|
|
|
|
|
for (const l of links) {
|
|
|
|
|
for (const l of graphState.links) {
|
|
|
|
|
const a = l.a, b = l.b;
|
|
|
|
|
const dx = b.x - a.x, dy = b.y - a.y;
|
|
|
|
|
const dist = Math.sqrt(dx*dx + dy*dy) || 1;
|
|
|
|
|
const target = 60;
|
|
|
|
|
const k = 0.02;
|
|
|
|
|
const f = (dist - target) * k;
|
|
|
|
|
const f = (dist - target) * springK * alpha;
|
|
|
|
|
const fx = (dx/dist) * f, fy = (dy/dist) * f;
|
|
|
|
|
a.vx += fx; a.vy += fy;
|
|
|
|
|
b.vx -= fx; b.vy -= fy;
|
|
|
|
|
}
|
|
|
|
|
// integrate + bounds
|
|
|
|
|
for (const n of sim) {
|
|
|
|
|
n.vx *= 0.85; n.vy *= 0.85;
|
|
|
|
|
n.vx *= damping; n.vy *= damping;
|
|
|
|
|
n.x += n.vx; n.y += n.vy;
|
|
|
|
|
n.x = Math.max(10, Math.min(canvas.width-10, n.x));
|
|
|
|
|
n.y = Math.max(10, Math.min(canvas.height-10, n.y));
|
|
|
|
|
const pad = 10;
|
|
|
|
|
const bx = canvas.width / graphState.zoom;
|
|
|
|
|
const by = canvas.height / graphState.zoom;
|
|
|
|
|
n.x = Math.max(-bx/2 + pad, Math.min(bx/2 - pad, n.x));
|
|
|
|
|
n.y = Math.max(-by/2 + pad, Math.min(by/2 - pad, n.y));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function _graphDraw() {
|
|
|
|
|
const canvas = _graphCanvas();
|
|
|
|
|
const ctx = _graphCtx();
|
|
|
|
|
const hint = document.getElementById('graphHint');
|
|
|
|
|
|
|
|
|
|
ctx.clearRect(0,0,canvas.width,canvas.height);
|
|
|
|
|
// edges
|
|
|
|
|
ctx.globalAlpha = 0.5;
|
|
|
|
|
ctx.save();
|
|
|
|
|
ctx.translate(graphState.panX, graphState.panY);
|
|
|
|
|
ctx.scale(graphState.zoom, graphState.zoom);
|
|
|
|
|
|
|
|
|
|
ctx.globalAlpha = 0.45;
|
|
|
|
|
ctx.strokeStyle = '#3a3a55';
|
|
|
|
|
ctx.lineWidth = 1;
|
|
|
|
|
for (const l of links) {
|
|
|
|
|
ctx.lineWidth = 1 / graphState.zoom;
|
|
|
|
|
for (const l of graphState.links) {
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
ctx.moveTo(l.a.x, l.a.y);
|
|
|
|
|
ctx.lineTo(l.b.x, l.b.y);
|
|
|
|
|
@@ -298,19 +513,82 @@ function renderGraph(nodes, edges) {
|
|
|
|
|
}
|
|
|
|
|
ctx.globalAlpha = 1.0;
|
|
|
|
|
|
|
|
|
|
// nodes
|
|
|
|
|
for (const n of sim) {
|
|
|
|
|
let r = 5;
|
|
|
|
|
let fill = '#6c8af5';
|
|
|
|
|
if (n.kind === 'tag') { fill = '#8a9aff'; r = 4; }
|
|
|
|
|
if (n.kind === 'host') { fill = '#f5b46c'; r = 4; }
|
|
|
|
|
if (n.kind === 'engram') { fill = '#6c8af5'; r = 5; }
|
|
|
|
|
const term = (graphState.search || '').trim();
|
|
|
|
|
let matches = 0;
|
|
|
|
|
for (const n of graphState.sim) {
|
|
|
|
|
const r = _graphNodeRadius(n);
|
|
|
|
|
const isMatch = _graphMatches(n, term);
|
|
|
|
|
if (isMatch) matches++;
|
|
|
|
|
|
|
|
|
|
if (isMatch) {
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
ctx.strokeStyle = '#f7d154';
|
|
|
|
|
ctx.lineWidth = 3 / graphState.zoom;
|
|
|
|
|
ctx.arc(n.x, n.y, r + 3, 0, Math.PI*2);
|
|
|
|
|
ctx.stroke();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
ctx.fillStyle = fill;
|
|
|
|
|
ctx.fillStyle = _graphNodeFill(n);
|
|
|
|
|
ctx.arc(n.x, n.y, r, 0, Math.PI*2);
|
|
|
|
|
ctx.fill();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ctx.restore();
|
|
|
|
|
hint.textContent = `nodes=${graphState.nodes.length} edges=${graphState.edges.length}` + (term ? ` | match=${matches}` : '');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function _graphLoop() {
|
|
|
|
|
if (!graphState.physicsOn) return;
|
|
|
|
|
_graphStepPhysics(1.0);
|
|
|
|
|
_graphDraw();
|
|
|
|
|
graphState.raf = requestAnimationFrame(_graphLoop);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toggleGraphPhysics() {
|
|
|
|
|
graphState.physicsOn = !graphState.physicsOn;
|
|
|
|
|
const b = document.getElementById('btnGraphPhysics');
|
|
|
|
|
b.textContent = `Physics: ${graphState.physicsOn ? 'on' : '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();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resetGraphView() {
|
|
|
|
|
const canvas = _graphCanvas();
|
|
|
|
|
graphState.panX = canvas.width / 2;
|
|
|
|
|
graphState.panY = canvas.height / 2;
|
|
|
|
|
graphState.zoom = 1;
|
|
|
|
|
_graphDraw();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function fitGraphView() {
|
|
|
|
|
const canvas = _graphCanvas();
|
|
|
|
|
if (!graphState.sim.length) return;
|
|
|
|
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
|
|
|
for (const n of graphState.sim) {
|
|
|
|
|
minX = Math.min(minX, n.x); minY = Math.min(minY, n.y);
|
|
|
|
|
maxX = Math.max(maxX, n.x); maxY = Math.max(maxY, n.y);
|
|
|
|
|
}
|
|
|
|
|
const w = Math.max(1, maxX - minX);
|
|
|
|
|
const h = Math.max(1, maxY - minY);
|
|
|
|
|
const zx = (canvas.width - 40) / w;
|
|
|
|
|
const zy = (canvas.height - 40) / h;
|
|
|
|
|
graphState.zoom = Math.max(0.35, Math.min(2.5, Math.min(zx, zy)));
|
|
|
|
|
graphState.panX = canvas.width / 2;
|
|
|
|
|
graphState.panY = canvas.height / 2;
|
|
|
|
|
_graphDraw();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function graphApplySearch(term) {
|
|
|
|
|
graphState.search = term || '';
|
|
|
|
|
_graphDraw();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Real-time updates via SSE
|
|
|
|
|
@@ -478,6 +756,7 @@ document.getElementById('searchInput').addEventListener('input', (e) => {
|
|
|
|
|
state.search = e.target.value;
|
|
|
|
|
state.offset = 0;
|
|
|
|
|
loadCards();
|
|
|
|
|
if (state.view === 'graph') graphApplySearch(state.search);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
document.getElementById('filterSelect').addEventListener('change', (e) => {
|
|
|
|
|
|