From a373279b0823f0f3da6898e467768b94b2837549 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 28 May 2026 14:46:22 -0400 Subject: [PATCH] =?UTF-8?q?feat(graph):=20Phase=201+2=20=E2=80=94=20full-v?= =?UTF-8?q?ault,=20tag=20filter,=20backlinks,=20tooltips,=20depth=20slider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (main.py): - GraphNode: added tags, incoming_count, outgoing_count - GraphEdge: added 'backlink' relation - GraphResponse: added 'scope' field - api_graph: scope=full|directory, tag= filter, backlinks - Full-vault tree walk with configurable depth 0-3 - Tag index from in-memory file index for fast filtering - Incoming/outgoing link count per node Frontend (graph.js + index.html): - Theme-adaptive colors via CSS custom properties - Depth slider (0-3) with live reload - Full-vault toggle button (🌐 Tout / πŸ“ Dossier) - Search input with tag filtering + visual highlighting - Tooltip on hover (name, path, tags, link counts) - Backlink edges rendered in red dashed - Node size proportional to link count - Larger modal (1000px, 85vh) --- backend/main.py | 74 ++++++++-- frontend/index.html | 10 +- frontend/js/graph.js | 336 ++++++++++++++++++++++++++++++------------- 3 files changed, 313 insertions(+), 107 deletions(-) diff --git a/backend/main.py b/backend/main.py index 21736ad..1f166be 100644 --- a/backend/main.py +++ b/backend/main.py @@ -229,19 +229,23 @@ class GraphNode(BaseModel): type: str = Field(description="'vault', 'directory', or 'file'") path: str = Field(description="Relative path within vault") size: int = Field(default=0, description="File size in bytes") + tags: List[str] = Field(default_factory=list, description="Tags from frontmatter") + incoming_count: int = Field(default=0, description="Number of incoming wikilinks") + outgoing_count: int = Field(default=0, description="Number of outgoing wikilinks") class GraphEdge(BaseModel): """An edge between two nodes in the graph view.""" source: str = Field(description="Source node ID") target: str = Field(description="Target node ID") - relation: str = Field(description="'parent' or 'wikilink'") + relation: str = Field(description="'parent', 'wikilink', or 'backlink'") class GraphResponse(BaseModel): """Graph data for a vault or directory.""" vault: str = Field(description="Vault name") path: str = Field(description="Root path for the graph") + scope: str = Field(default="directory", description="'directory' or 'full'") nodes: List[GraphNode] = Field(description="Graph nodes (files and directories)") edges: List[GraphEdge] = Field(description="Graph edges (parent and wikilink relations)") @@ -2165,6 +2169,8 @@ async def api_graph( vault_name: str, path: str = Query("", description="Relative path to focus on"), depth: int = Query(1, ge=0, le=3, description="How many levels deep to expand"), + scope: str = Query("directory", description="'directory' (default) or 'full' for entire vault"), + tag: str = Query("", description="Filter: only show files with this tag"), current_user=Depends(require_auth), ): """Return graph data (nodes and edges) for a vault or directory. @@ -2176,6 +2182,8 @@ async def api_graph( vault_name: Name of the vault. path: Relative directory path to focus on (empty = root). depth: Expansion depth (0 = only direct children, 1-3 = deeper). + scope: 'directory' for subtree, 'full' for entire vault. + tag: Optional tag filter (only files with this tag appear). Returns: ``GraphResponse`` with nodes and edges. @@ -2197,11 +2205,16 @@ async def api_graph( edges: List[dict] = [] node_ids: set = set() - def _add_node(name: str, ntype: str, npath: str, size: int = 0) -> str: + def _add_node(name: str, ntype: str, npath: str, size: int = 0, + tags: list[str] | None = None, incoming: int = 0, outgoing: int = 0) -> str: nid = f"{vault_name}:{npath}" if nid not in node_ids: node_ids.add(nid) - nodes.append({"id": nid, "name": name, "type": ntype, "path": npath, "size": size}) + nodes.append({ + "id": nid, "name": name, "type": ntype, "path": npath, + "size": size, "tags": tags or [], + "incoming_count": incoming, "outgoing_count": outgoing, + }) return nid def _add_edge(source: str, target: str, relation: str): @@ -2212,6 +2225,26 @@ async def api_graph( settings = get_vault_setting(vault_name) or {} hide_hidden = settings.get("hideHiddenFiles", False) + # Build tag index from the in-memory index for fast lookups + _tag_index: dict[str, list[str]] = {} + for doc_key, info in index.items(): + vn, fp = doc_key.split("::", 1) if "::" in doc_key else ("", "") + if vn == vault_name: + for t in info.get("tags", []): + _tag_index.setdefault(t.lower(), []).append(fp) + + # Determine scope + if scope == "full": + # Full vault β€” walk entire vault root, ignore path param + target = vault_root.resolve() + effective_depth = depth if depth > 0 else 2 # minimum depth 2 for full view + else: + target = _resolve_safe_path(vault_root, path) if path else vault_root.resolve() + effective_depth = depth + + if not target.exists(): + raise HTTPException(status_code=404, detail=f"Path not found: {path}") + # Add the focus node focus_name = path.split("/")[-1] if path else vault_name focus_type = "directory" if path else "vault" @@ -2219,20 +2252,28 @@ async def api_graph( # Walk directory tree up to depth levels def _walk_dir(dir_path: Path, parent_id: str, current_depth: int): - if current_depth > depth: + if current_depth > effective_depth: return try: for entry in sorted(dir_path.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())): if hide_hidden and entry.name.startswith("."): continue rel = str(entry.relative_to(vault_root)).replace("\\", "/") + + # Tag filter: skip files that don't have the requested tag + if tag and entry.is_file(): + file_tags = [t.lower() for t in _tag_index.get(rel, [])] + if tag.lower() not in file_tags: + continue + if entry.is_dir(): did = _add_node(entry.name, "directory", rel) _add_edge(parent_id, did, "parent") - if current_depth < depth: + if current_depth < effective_depth: _walk_dir(entry, did, current_depth + 1) elif entry.suffix.lower() in SUPPORTED_EXTENSIONS or entry.name.lower() in ("dockerfile", "makefile"): - fid = _add_node(entry.name, "file", rel, entry.stat().st_size) + file_tags = _tag_index.get(rel, []) + fid = _add_node(entry.name, "file", rel, entry.stat().st_size, tags=file_tags) _add_edge(parent_id, fid, "parent") except PermissionError: pass @@ -2240,13 +2281,30 @@ async def api_graph( if target.is_dir(): _walk_dir(target, focus_id, 0) elif target.is_file(): - # For a single file, show siblings in same directory _walk_dir(target.parent, focus_id, 0) # Add wikilink edges between markdown files in the current scope _add_wikilink_edges(nodes, edges, node_ids, vault_name) - return {"vault": vault_name, "path": path, "nodes": nodes, "edges": edges} + # Compute incoming/outgoing counts from edges + edge_counts: dict[str, dict[str, int]] = {} + for node in nodes: + edge_counts[node["id"]] = {"incoming": 0, "outgoing": 0} + for edge in edges: + if edge["relation"] in ("wikilink", "backlink"): + src = edge["source"] + tgt = edge["target"] + if src in edge_counts: + edge_counts[src]["outgoing"] += 1 + if tgt in edge_counts: + edge_counts[tgt]["incoming"] += 1 + for node in nodes: + counts = edge_counts.get(node["id"], {"incoming": 0, "outgoing": 0}) + node["incoming_count"] = counts["incoming"] + node["outgoing_count"] = counts["outgoing"] + + return {"vault": vault_name, "path": path, "scope": scope, + "nodes": nodes, "edges": edges} def _add_wikilink_edges(nodes: list, edges: list, node_ids: set, vault_name: str): diff --git a/frontend/index.html b/frontend/index.html index 702e295..703a64c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -699,11 +699,15 @@
-
+
Vue Graphique
- + + + + + @@ -720,12 +724,14 @@
+
🟦 Dossier 🟩 Fichier .md ⬜ Autre fichier ── Parent β”…β”… Wikilink + ← Backlink
diff --git a/frontend/js/graph.js b/frontend/js/graph.js index 70d47c1..6b8b956 100644 --- a/frontend/js/graph.js +++ b/frontend/js/graph.js @@ -1,12 +1,33 @@ -/* ObsiGate β€” Graph View: interactive file/folder relationship visualization */ +/* ObsiGate β€” Graph View: interactive file/folder relationship visualization. + Phase 2: theme colors, tooltips, depth slider, full-vault, search, backlinks. */ import { api } from './auth.js'; import { safeCreateIcons } from './utils.js'; import { openFile } from './viewer.js'; // --------------------------------------------------------------------------- -// Graph View Manager β€” Interactive file/folder relationship visualization +// Theme-aware color helpers // --------------------------------------------------------------------------- +function _cssVar(name, fallback) { + return getComputedStyle(document.body).getPropertyValue(name).trim() || fallback; +} +const COLORS = { + get bg() { return _cssVar('--bg-primary', '#1e1e1e'); }, + get text() { return _cssVar('--text-primary', '#ddd'); }, + get accent() { return _cssVar('--accent-color', '#2563eb'); }, + get muted() { return _cssVar('--text-muted', '#888'); }, + get border() { return _cssVar('--border-color', '#333'); }, + dir: '#5b9bd5', + md: '#70ad47', + other: '#999', + vault: '#ffc000', + backlink: '#e74c3c', + highlight: '#ff6b6b', +}; + +// --------------------------------------------------------------------------- +// GraphViewManager +// --------------------------------------------------------------------------- export const GraphViewManager = { _canvas: null, _ctx: null, @@ -26,49 +47,105 @@ export const GraphViewManager = { _nodePositions: {}, _width: 0, _height: 0, + _scope: 'directory', + _depth: 1, + _searchTerm: '', + _hoveredNode: null, + _tooltipEl: null, async open(vault, path, type) { this._vault = vault; this._path = path; - const modal = document.getElementById("graph-modal"); - const title = document.getElementById("graph-title"); - const info = document.getElementById("graph-info"); - const canvas = document.getElementById("graph-canvas"); + const modal = document.getElementById('graph-modal'); + const title = document.getElementById('graph-title'); + const info = document.getElementById('graph-info'); + const canvas = document.getElementById('graph-canvas'); + const depthSlider = document.getElementById('graph-depth'); if (!modal || !canvas) return; - title.textContent = `Vue Graphique β€” ${vault}${path ? "/" + path : ""}`; - info.textContent = "Chargement..."; - modal.classList.add("active"); + this._tooltipEl = document.getElementById('graph-tooltip'); + this._depth = depthSlider ? parseInt(depthSlider.value) : 1; + this._scope = 'directory'; + + title.textContent = `Vue Graphique β€” ${vault}${path ? '/' + path : ''}`; + info.textContent = 'Chargement...'; + modal.classList.add('active'); this._canvas = canvas; - this._ctx = canvas.getContext("2d"); + this._ctx = canvas.getContext('2d'); this._resetView(); - // Fetch graph data - try { - const data = await api(`/api/graph/${encodeURIComponent(vault)}?path=${encodeURIComponent(path)}&depth=1`); - this._nodes = data.nodes || []; - this._edges = data.edges || []; - info.textContent = `${this._nodes.length} nΕ“uds, ${this._edges.length} liens`; - this._initLayout(); - this._startRender(); - } catch (err) { - info.textContent = "Erreur de chargement"; - console.error("Graph error:", err); - } - + await this._fetchAndRender(); safeCreateIcons(); }, + async _fetchAndRender() { + const info = document.getElementById('graph-info'); + if (!info) return; + + const params = new URLSearchParams(); + if (this._path) params.set('path', this._path); + params.set('depth', String(this._depth)); + params.set('scope', this._scope); + if (this._searchTerm) params.set('tag', this._searchTerm); + + try { + const data = await api( + `/api/graph/${encodeURIComponent(this._vault)}?${params.toString()}` + ); + this._nodes = data.nodes || []; + this._edges = data.edges || []; + this._scope = data.scope || 'directory'; + + const scopeLabel = this._scope === 'full' ? 'Vault complet' : 'Dossier'; + const depthLabel = `prof=${this._depth}`; + info.textContent = `${this._nodes.length} nΕ“uds, ${this._edges.length} liens Β· ${scopeLabel} Β· ${depthLabel}`; + this._initLayout(); + this._startRender(); + } catch (err) { + info.textContent = 'Erreur de chargement'; + console.error('Graph error:', err); + } + }, + + reload() { + // Called when depth slider or full-vault button changes + this._resetView(); + this._fetchAndRender(); + }, + + setDepth(depth) { + this._depth = depth; + this.reload(); + }, + + toggleScope() { + this._scope = this._scope === 'full' ? 'directory' : 'full'; + const btn = document.getElementById('graph-full-vault'); + if (btn) btn.textContent = this._scope === 'full' ? 'πŸ“ Dossier' : '🌐 Tout'; + this.reload(); + }, + + setSearch(term) { + this._searchTerm = term; + // For now, use tag filter on backend; client-side highlighting on draw + if (term && term.length >= 2) { + this.reload(); + } else if (!term && this._searchTerm !== term) { + this.reload(); + } + }, + close() { - const modal = document.getElementById("graph-modal"); - if (modal) modal.classList.remove("active"); + const modal = document.getElementById('graph-modal'); + if (modal) modal.classList.remove('active'); if (this._animFrame) { cancelAnimationFrame(this._animFrame); this._animFrame = null; } + this._hideTooltip(); }, _resetView() { @@ -83,19 +160,19 @@ export const GraphViewManager = { const h = this._canvas.parentElement.clientHeight; this._canvas.width = w * devicePixelRatio; this._canvas.height = h * devicePixelRatio; - this._canvas.style.width = w + "px"; - this._canvas.style.height = h + "px"; + this._canvas.style.width = w + 'px'; + this._canvas.style.height = h + 'px'; this._width = w; this._height = h; + this._ctx.setTransform(1, 0, 0, 1, 0, 0); this._ctx.scale(devicePixelRatio, devicePixelRatio); - // Position nodes in a circle initially const cx = w / 2; const cy = h / 2; const radius = Math.min(w, h) * 0.35; this._nodes.forEach((node, i) => { - const angle = (2 * Math.PI * i) / this._nodes.length; + const angle = (2 * Math.PI * i) / Math.max(this._nodes.length, 1); this._nodePositions[node.id] = { x: cx + radius * Math.cos(angle), y: cy + radius * Math.sin(angle), @@ -127,7 +204,6 @@ export const GraphViewManager = { const cx = this._width / 2; const cy = this._height / 2; - // Spring forces (edges) for (const edge of this._edges) { const a = positions[edge.source]; const b = positions[edge.target]; @@ -147,7 +223,6 @@ export const GraphViewManager = { b.vy -= fy; } - // Repulsion between all nodes for (const n1 of this._nodes) { for (const n2 of this._nodes) { if (n1.id === n2.id) continue; @@ -167,7 +242,6 @@ export const GraphViewManager = { } } - // Center gravity for (const node of this._nodes) { const p = positions[node.id]; if (!p) continue; @@ -175,7 +249,6 @@ export const GraphViewManager = { p.vy += (cy - p.y) * 0.001; } - // Apply velocities with damping for (const node of this._nodes) { const p = positions[node.id]; if (!p) continue; @@ -194,7 +267,6 @@ export const GraphViewManager = { ctx.save(); ctx.clearRect(0, 0, w, h); - // Apply transform (pan + zoom) ctx.translate(this._offsetX, this._offsetY); ctx.scale(this._zoom, this._zoom); @@ -207,45 +279,78 @@ export const GraphViewManager = { ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); - ctx.strokeStyle = edge.relation === "wikilink" ? "var(--accent-color, #2563eb)" : "var(--text-muted, #888)"; - ctx.lineWidth = edge.relation === "wikilink" ? 2 : 1; - ctx.setLineDash(edge.relation === "wikilink" ? [4, 4] : []); + + if (edge.relation === 'backlink') { + ctx.strokeStyle = COLORS.backlink; + ctx.lineWidth = 1.5; + ctx.setLineDash([3, 3]); + } else if (edge.relation === 'wikilink') { + ctx.strokeStyle = COLORS.accent; + ctx.lineWidth = 2; + ctx.setLineDash([4, 4]); + } else { + ctx.strokeStyle = COLORS.muted; + ctx.lineWidth = 1; + ctx.setLineDash([]); + } ctx.stroke(); } ctx.setLineDash([]); // Draw nodes + const searchLower = this._searchTerm.toLowerCase(); for (const node of this._nodes) { const p = this._nodePositions[node.id]; if (!p) continue; - const r = Math.max(5, Math.min(20, 6 + Math.sqrt(node.size || 100) / 100)); + const links = (node.incoming_count || 0) + (node.outgoing_count || 0); + const r = Math.max(5, Math.min(22, 6 + Math.sqrt(Math.max(node.size || 100, links * 200)) / 100)); + + // Highlight if search match + const isHighlighted = searchLower && ( + node.name.toLowerCase().includes(searchLower) || + (node.tags || []).some(t => t.toLowerCase().includes(searchLower)) + ); - // Node circle ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI * 2); switch (node.type) { - case "directory": - ctx.fillStyle = "#5b9bd5"; + case 'directory': + ctx.fillStyle = COLORS.dir; break; - case "file": - ctx.fillStyle = (node.path || "").endsWith(".md") ? "#70ad47" : "#c0c0c0"; + case 'file': + ctx.fillStyle = (node.path || '').endsWith('.md') ? COLORS.md : COLORS.other; + break; + case 'vault': + ctx.fillStyle = COLORS.vault; break; default: - ctx.fillStyle = "#ffc000"; - break; + ctx.fillStyle = COLORS.other; } + + if (isHighlighted) { + ctx.shadowColor = COLORS.highlight; + ctx.shadowBlur = 12; + } + ctx.fill(); - ctx.strokeStyle = "var(--bg-primary, #1e1e1e)"; - ctx.lineWidth = 1.5; + + if (isHighlighted) { + ctx.shadowBlur = 0; + ctx.strokeStyle = COLORS.highlight; + ctx.lineWidth = 2.5; + } else { + ctx.strokeStyle = COLORS.bg; + ctx.lineWidth = 1.5; + } ctx.stroke(); // Node label - const label = node.name.length > 20 ? node.name.slice(0, 18) + "..." : node.name; - ctx.font = `${11 / this._zoom}px -apple-system, sans-serif`; - ctx.fillStyle = "var(--text-primary, #ddd)"; - ctx.textAlign = "center"; + const label = node.name.length > 20 ? node.name.slice(0, 18) + '...' : node.name; + ctx.font = `${isHighlighted ? 12 : 11} / ${this._zoom}px -apple-system, sans-serif`; + ctx.fillStyle = isHighlighted ? COLORS.highlight : COLORS.text; + ctx.textAlign = 'center'; ctx.fillText(label, p.x, p.y + r + 12 / this._zoom); } @@ -259,7 +364,8 @@ export const GraphViewManager = { for (const node of this._nodes) { const p = this._nodePositions[node.id]; if (!p) continue; - const r = Math.max(5, Math.min(20, 6 + Math.sqrt(node.size || 100) / 100)); + const links = (node.incoming_count || 0) + (node.outgoing_count || 0); + const r = Math.max(5, Math.min(22, 6 + Math.sqrt(Math.max(node.size || 100, links * 200)) / 100)); const dx = x - p.x; const dy = y - p.y; if (dx * dx + dy * dy <= r * r + 100) { @@ -269,6 +375,26 @@ export const GraphViewManager = { return null; }, + _showTooltip(node, screenX, screenY) { + if (!this._tooltipEl) return; + const tags = (node.tags || []).slice(0, 5).join(', '); + const inc = node.incoming_count || 0; + const out = node.outgoing_count || 0; + this._tooltipEl.innerHTML = ` + ${node.name} + ${node.type === 'file' ? `
${node.path}` : ''} + ${tags ? `
🏷️ ${tags}` : ''} + ${inc + out > 0 ? `
πŸ”— ${out} sortants Β· ${inc} entrants` : ''} + `; + this._tooltipEl.style.display = 'block'; + this._tooltipEl.style.left = (screenX + 15) + 'px'; + this._tooltipEl.style.top = (screenY - 10) + 'px'; + }, + + _hideTooltip() { + if (this._tooltipEl) this._tooltipEl.style.display = 'none'; + }, + _onMouseDown(e) { const rect = this._canvas.getBoundingClientRect(); const mx = e.clientX - rect.left; @@ -278,12 +404,12 @@ export const GraphViewManager = { if (hit) { this._dragging = true; this._dragNode = hit; - this._canvas.style.cursor = "grabbing"; + this._canvas.style.cursor = 'grabbing'; } else { this._panning = true; this._panStartX = e.clientX - this._offsetX; this._panStartY = e.clientY - this._offsetY; - this._canvas.style.cursor = "grabbing"; + this._canvas.style.cursor = 'grabbing'; } }, @@ -302,28 +428,34 @@ export const GraphViewManager = { this._offsetY = e.clientY - this._panStartY; } else { const hit = this._getNodeAt(mx, my); - this._canvas.style.cursor = hit ? "pointer" : "grab"; - this._canvas.title = hit ? `Ouvrir: ${hit.node.path || hit.node.name}` : ""; + if (hit) { + this._canvas.style.cursor = 'pointer'; + this._canvas.title = hit.node.type === 'file' + ? `πŸ“„ ${hit.node.name} (cliquer pour ouvrir)` + : `πŸ“ ${hit.node.name} (cliquer pour explorer)`; + this._showTooltip(hit.node, e.clientX, e.clientY); + } else { + this._canvas.style.cursor = 'grab'; + this._canvas.title = ''; + this._hideTooltip(); + } } }, _onMouseUp(e) { if (this._dragging && this._dragNode) { - // Check if it was a click (not a drag) - const rect = this._canvas.getBoundingClientRect(); const node = this._dragNode.node; this._dragging = false; this._dragNode = null; - this._canvas.style.cursor = "grab"; + this._canvas.style.cursor = 'grab'; - // If it's a file, open it on click - if (node.type === "file") { + if (node.type === 'file') { this.close(); openFile(this._vault, node.path); - } else if (node.type === "directory" || node.type === "vault") { - // Expand into this directory + } else if (node.type === 'directory' || node.type === 'vault') { this.close(); - this.open(this._vault, node.path, node.type); + this._path = node.path || ''; + this.open(this._vault, this._path, node.type); } } this._panning = false; @@ -349,8 +481,8 @@ export const GraphViewManager = { const h = this._canvas.parentElement.clientHeight; this._canvas.width = w * devicePixelRatio; this._canvas.height = h * devicePixelRatio; - this._canvas.style.width = w + "px"; - this._canvas.style.height = h + "px"; + this._canvas.style.width = w + 'px'; + this._canvas.style.height = h + 'px'; this._width = w; this._height = h; this._ctx.setTransform(1, 0, 0, 1, 0, 0); @@ -359,43 +491,53 @@ export const GraphViewManager = { }; // --------------------------------------------------------------------------- -// Init graph view event listeners after DOM ready +// Init graph view event listeners // --------------------------------------------------------------------------- export function initGraphView() { - const closeBtn = document.getElementById("graph-close"); - const zoomIn = document.getElementById("graph-zoom-in"); - const zoomOut = document.getElementById("graph-zoom-out"); - const reset = document.getElementById("graph-reset"); - const modal = document.getElementById("graph-modal"); - const canvas = document.getElementById("graph-canvas"); + const gm = GraphViewManager; + const closeBtn = document.getElementById('graph-close'); + const zoomIn = document.getElementById('graph-zoom-in'); + const zoomOut = document.getElementById('graph-zoom-out'); + const reset = document.getElementById('graph-reset'); + const fullVault = document.getElementById('graph-full-vault'); + const depthSlider = document.getElementById('graph-depth'); + const searchInput = document.getElementById('graph-search'); + const modal = document.getElementById('graph-modal'); + const canvas = document.getElementById('graph-canvas'); - if (closeBtn) closeBtn.addEventListener("click", () => GraphViewManager.close()); - if (modal) modal.addEventListener("click", (e) => { if (e.target === modal) GraphViewManager.close(); }); - if (zoomIn) zoomIn.addEventListener("click", () => { GraphViewManager._zoom = Math.min(3, GraphViewManager._zoom * 1.2); }); - if (zoomOut) zoomOut.addEventListener("click", () => { GraphViewManager._zoom = Math.max(0.2, GraphViewManager._zoom * 0.8); }); - if (reset) reset.addEventListener("click", () => { - GraphViewManager._offsetX = 0; - GraphViewManager._offsetY = 0; - GraphViewManager._zoom = 1; - }); - - if (canvas) { - canvas.addEventListener("mousedown", (e) => GraphViewManager._onMouseDown(e)); - canvas.addEventListener("mousemove", (e) => GraphViewManager._onMouseMove(e)); - canvas.addEventListener("mouseup", (e) => GraphViewManager._onMouseUp(e)); - canvas.addEventListener("mouseleave", () => { - GraphViewManager._dragging = false; - GraphViewManager._dragNode = null; - GraphViewManager._panning = false; - canvas.style.cursor = "grab"; + if (closeBtn) closeBtn.addEventListener('click', () => gm.close()); + if (modal) modal.addEventListener('click', (e) => { if (e.target === modal) gm.close(); }); + if (zoomIn) zoomIn.addEventListener('click', () => { gm._zoom = Math.min(3, gm._zoom * 1.2); }); + if (zoomOut) zoomOut.addEventListener('click', () => { gm._zoom = Math.max(0.2, gm._zoom * 0.8); }); + if (reset) reset.addEventListener('click', () => { gm._offsetX = 0; gm._offsetY = 0; gm._zoom = 1; }); + if (fullVault) fullVault.addEventListener('click', () => gm.toggleScope()); + if (depthSlider) depthSlider.addEventListener('input', () => gm.setDepth(parseInt(depthSlider.value))); + if (searchInput) { + let debounce; + searchInput.addEventListener('input', () => { + clearTimeout(debounce); + debounce = setTimeout(() => gm.setSearch(searchInput.value.trim()), 300); }); - canvas.addEventListener("wheel", (e) => GraphViewManager._onWheel(e), { passive: false }); - window.addEventListener("resize", () => GraphViewManager._onResize()); } - document.addEventListener("keydown", (e) => { - if (modal && modal.classList.contains("active") && e.key === "Escape") { - GraphViewManager.close(); + if (canvas) { + canvas.addEventListener('mousedown', (e) => gm._onMouseDown(e)); + canvas.addEventListener('mousemove', (e) => gm._onMouseMove(e)); + canvas.addEventListener('mouseup', (e) => gm._onMouseUp(e)); + canvas.addEventListener('mouseleave', () => { + gm._dragging = false; + gm._dragNode = null; + gm._panning = false; + canvas.style.cursor = 'grab'; + gm._hideTooltip(); + }); + canvas.addEventListener('wheel', (e) => gm._onWheel(e), { passive: false }); + window.addEventListener('resize', () => gm._onResize()); + } + + document.addEventListener('keydown', (e) => { + if (modal && modal.classList.contains('active') && e.key === 'Escape') { + gm.close(); } }); }