From fa2ba11b661a5a9f5ca395d206b739d4afdaec8d Mon Sep 17 00:00:00 2001 From: Otto Date: Fri, 29 May 2026 11:54:00 +0200 Subject: [PATCH] fix: improve graph touch controls and legend --- static/style.css | 38 ++++ templates/dashboard.html | 401 +++++++++++++++++++++++++++++++++------ 2 files changed, 378 insertions(+), 61 deletions(-) diff --git a/static/style.css b/static/style.css index f2efe55..157f383 100644 --- a/static/style.css +++ b/static/style.css @@ -156,7 +156,45 @@ body { background:#12121a; border:1px solid #252533; border-radius: 14px; + touch-action: none; } + +.graph-controls{ + display:flex; + gap:8px; + padding: 10px 12px 0; + align-items:center; + flex-wrap: wrap; +} +.graph-controls .btn{ + background:#1e1e28; + border:1px solid #2a2a3a; + border-radius: 10px; + padding: 8px 10px; + color:#cfd3ff; + font-weight:700; + font-size:0.82rem; +} +.graph-controls .btn.primary{ + border-color:#6c8af5; + box-shadow:0 0 0 1px rgba(108,138,245,0.18) inset; +} +.graph-legend{ + margin: 8px 12px 0; + padding: 10px 12px; + background:#1a1a24; + border:1px solid #252533; + border-radius: 14px; + color:#b9b9c9; + font-size:0.8rem; + line-height:1.4; +} +.legend-row{ display:flex; align-items:center; gap:8px; margin-top:6px; } +.legend-dot{ width:10px; height:10px; border-radius:50%; display:inline-block; } +.legend-dot.engram{ background:#6c8af5; } +.legend-dot.tag{ background:#8a9aff; } +.legend-dot.match{ background:#f7d154; } +.graph-hint{ padding: 4px 12px 10px; } #searchInput { flex: 1; background: #1e1e28; diff --git a/templates/dashboard.html b/templates/dashboard.html index 4c3bd0e..b30fe16 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -46,8 +46,20 @@ @@ -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 - for (let i=0; i { state.search = e.target.value; state.offset = 0; loadCards(); + if (state.view === 'graph') graphApplySearch(state.search); }); document.getElementById('filterSelect').addEventListener('change', (e) => { -- 2.47.3