/* 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'; // --------------------------------------------------------------------------- // 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, _nodes: [], _edges: [], _offsetX: 0, _offsetY: 0, _zoom: 1, _dragging: false, _dragNode: null, _panning: false, _panStartX: 0, _panStartY: 0, _animFrame: null, _vault: null, _path: null, _nodePositions: {}, _width: 0, _height: 0, _scope: 'directory', _depth: 1, _searchTerm: '', _tagFilter: '', _hoveredNode: null, _infoPanel: null, _infoContent: null, _previewPanel: null, _previewContent: null, _previewCache: {}, _previewPending: null, _ctrlHoverNode: null, _navHistory: [], _navIndex: -1, async open(vault, path, type) { // Push current state to history before navigating (unless navigating via back/fwd) if (this._vault && !this._navNavigating) { this._pushHistory(this._vault, this._path || ''); } this._navNavigating = false; this._vault = vault; this._path = path; const modal = document.getElementById('graph-modal'); const title = document.getElementById('graph-title'); const statusEl = document.getElementById('graph-status'); const canvas = document.getElementById('graph-canvas'); const depthSlider = document.getElementById('graph-depth'); if (!modal || !canvas) return; this._infoPanel = document.getElementById('graph-info-panel'); this._infoContent = document.getElementById('graph-info-content'); this._previewPanel = document.getElementById('graph-preview-panel'); this._previewContent = document.getElementById('graph-preview-content'); this._depth = depthSlider ? parseInt(depthSlider.value) : 1; this._scope = 'directory'; title.textContent = `Vue Graphique — ${vault}${path ? '/' + path : ''}`; statusEl.textContent = 'Chargement...'; modal.classList.add('active'); this._canvas = canvas; this._ctx = canvas.getContext('2d'); this._resetView(); await this._fetchAndRender(); this._updateNavButtons(); safeCreateIcons(); }, _cache: {}, _cacheKey: '', _getCacheKey() { return `${this._vault}|${this._path}|${this._depth}|${this._scope}|${this._searchTerm}|${this._tagFilter}`; }, async _fetchAndRender() { const statusEl = document.getElementById('graph-status'); if (!statusEl) return; const cacheKey = this._getCacheKey(); // Use cache if same parameters if (this._cache[cacheKey]) { const cached = this._cache[cacheKey]; this._nodes = cached.nodes; this._edges = cached.edges; this._scope = cached.scope; const scopeLabel = this._scope === 'full' ? 'Vault complet' : 'Dossier'; statusEl.textContent = `${this._nodes.length} nœuds, ${this._edges.length} liens · ${scopeLabel} · prof=${this._depth} (cache)`; this._initLayout(); this._startRender(); 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._tagFilter) params.set('tag', this._tagFilter); 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'; // Cache the result this._cache[cacheKey] = { nodes: this._nodes, edges: this._edges, scope: this._scope, }; this._cacheKey = cacheKey; const scopeLabel = this._scope === 'full' ? 'Vault complet' : 'Dossier'; statusEl.textContent = `${this._nodes.length} nœuds, ${this._edges.length} liens · ${scopeLabel} · prof=${this._depth}`; this._initLayout(); this._startRender(); } catch (err) { statusEl.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; // Parse unified search: #tag → tag filter, otherwise → node search if (term.startsWith('#')) { const tag = term.substring(1).trim(); if (tag && tag !== this._tagFilter) { this._tagFilter = tag; this.reload(); } else if (!tag && this._tagFilter) { this._tagFilter = ''; this.reload(); } } else { if (this._tagFilter) { this._tagFilter = ''; this.reload(); return; } 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'); this._navHistory = []; this._navIndex = -1; if (this._animFrame) { cancelAnimationFrame(this._animFrame); this._animFrame = null; } this._hideTooltip(); }, _resetView() { this._offsetX = 0; this._offsetY = 0; this._zoom = 1; this._nodePositions = {}; }, _pushHistory(vault, path) { // Truncate forward history if we navigated back then took a new path if (this._navIndex < this._navHistory.length - 1) { this._navHistory = this._navHistory.slice(0, this._navIndex + 1); } this._navHistory.push({ vault, path }); this._navIndex = this._navHistory.length - 1; // Keep max 50 entries if (this._navHistory.length > 50) { this._navHistory.shift(); this._navIndex--; } this._updateNavButtons(); }, _updateNavButtons() { const backBtn = document.getElementById('graph-nav-back'); const fwdBtn = document.getElementById('graph-nav-fwd'); const upBtn = document.getElementById('graph-nav-up'); if (backBtn) { backBtn.disabled = this._navIndex <= 0; backBtn.style.opacity = this._navIndex > 0 ? '1' : '0.3'; } if (fwdBtn) { fwdBtn.disabled = this._navIndex >= this._navHistory.length - 1; fwdBtn.style.opacity = this._navIndex < this._navHistory.length - 1 ? '1' : '0.3'; } // Show "↑ Parent" if we have a path (subfolder) or history if (upBtn) { const hasParent = !!(this._path && this._path.includes('/')); const hasHistory = this._navIndex > 0; upBtn.style.display = (hasParent || hasHistory) ? '' : 'none'; } }, goBack() { if (this._navIndex <= 0) return; this._navIndex--; const entry = this._navHistory[this._navIndex]; this._navNavigating = true; this.open(entry.vault, entry.path); }, goForward() { if (this._navIndex >= this._navHistory.length - 1) return; this._navIndex++; const entry = this._navHistory[this._navIndex]; this._navNavigating = true; this.open(entry.vault, entry.path); }, goUp() { if (!this._path || !this._path.includes('/')) { // No subfolder — go to previous history entry this.goBack(); return; } // Go to parent directory const parentPath = this._path.split('/').slice(0, -1).join('/'); this.open(this._vault, parentPath); }, _initLayout() { const w = this._canvas.parentElement.clientWidth; 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._width = w; this._height = h; this._ctx.setTransform(1, 0, 0, 1, 0, 0); this._ctx.scale(devicePixelRatio, devicePixelRatio); 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) / Math.max(this._nodes.length, 1); this._nodePositions[node.id] = { x: cx + radius * Math.cos(angle), y: cy + radius * Math.sin(angle), vx: 0, vy: 0, }; }); }, _startRender() { const self = this; let lastTime = 0; const loop = (time) => { const dt = Math.min((time - lastTime) / 1000, 0.1); lastTime = time; self._simulate(dt); self._draw(); self._animFrame = requestAnimationFrame(loop); }; this._animFrame = requestAnimationFrame(loop); }, _simulate(dt) { if (this._dragging || this._dragNode) return; const positions = this._nodePositions; const cx = this._width / 2; const cy = this._height / 2; // Spring forces (edges) — unchanged for (const edge of this._edges) { const a = positions[edge.source]; const b = positions[edge.target]; if (!a || !b) continue; const dx = b.x - a.x; const dy = b.y - a.y; const dist = Math.sqrt(dx * dx + dy * dy) || 1; const targetLen = 80; const force = (dist - targetLen) * 0.01; const fx = (dx / dist) * force; const fy = (dy / dist) * force; a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy; } // Repulsion: Barnes-Hut for >200 nodes, naive O(n²) otherwise if (this._nodes.length > 200) { this._barnesHutRepulsion(positions); } else { this._naiveRepulsion(positions); } // Center gravity for (const node of this._nodes) { const p = positions[node.id]; if (!p) continue; p.vx += (cx - p.x) * 0.001; 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; p.vx *= 0.9; p.vy *= 0.9; p.x += p.vx * dt * 60; p.y += p.vy * dt * 60; } }, _naiveRepulsion(positions) { for (const n1 of this._nodes) { for (const n2 of this._nodes) { if (n1.id === n2.id) continue; const a = positions[n1.id]; const b = positions[n2.id]; if (!a || !b) continue; const dx = b.x - a.x; const dy = b.y - a.y; const dist = Math.sqrt(dx * dx + dy * dy) || 1; const force = 2000 / (dist * dist); const fx = (dx / dist) * force; const fy = (dy / dist) * force; a.vx -= fx; a.vy -= fy; } } }, // Barnes-Hut quadtree for O(n log n) repulsion _barnesHutRepulsion(positions) { // Build quadtree let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const node of this._nodes) { const p = positions[node.id]; if (!p) continue; if (p.x < minX) minX = p.x; if (p.y < minY) minY = p.y; if (p.x > maxX) maxX = p.x; if (p.y > maxY) maxY = p.y; } const root = this._buildQuadTree(positions, minX, minY, maxX, maxY); // Apply forces from quadtree const theta = 0.9; // Barnes-Hut opening angle for (const node of this._nodes) { const p = positions[node.id]; if (!p) continue; this._applyQuadTreeForce(root, p, theta); } }, _buildQuadTree(positions, x0, y0, x1, y1) { const cx = (x0 + x1) / 2; const cy = (y0 + y1) / 2; const cell = { x0, y0, x1, y1, cx, cy, mass: 0, mx: 0, my: 0, children: null }; // Collect nodes in this cell const contained = []; for (const node of this._nodes) { const p = positions[node.id]; if (p && p.x >= x0 && p.x < x1 && p.y >= y0 && p.y < y1) { contained.push(p); } } if (contained.length === 0) return cell; if (contained.length === 1) { cell.mass = 1; cell.mx = contained[0].x; cell.my = contained[0].y; return cell; } // Subdivide const midX = cx; const midY = cy; cell.children = [ this._buildQuadTree(positions, x0, y0, midX, midY), // NW this._buildQuadTree(positions, midX, y0, x1, midY), // NE this._buildQuadTree(positions, x0, midY, midX, y1), // SW this._buildQuadTree(positions, midX, midY, x1, y1), // SE ]; // Compute center of mass let totalMass = 0; let sumX = 0, sumY = 0; for (const child of cell.children) { if (child.mass > 0) { totalMass += child.mass; sumX += child.mx * child.mass; sumY += child.my * child.mass; } } cell.mass = totalMass; cell.mx = sumX / totalMass; cell.my = sumY / totalMass; return cell; }, _applyQuadTreeForce(cell, p, theta) { if (cell.mass === 0) return; const dx = cell.mx - p.x; const dy = cell.my - p.y; const dist = Math.sqrt(dx * dx + dy * dy) || 1; const size = cell.x1 - cell.x0; // If cell is far enough or is a leaf, apply force from center of mass if (!cell.children || size / dist < theta) { const force = 2000 * cell.mass / (dist * dist); const fx = (dx / dist) * force; const fy = (dy / dist) * force; p.vx -= fx; p.vy -= fy; return; } // Otherwise recurse into children for (const child of cell.children) { this._applyQuadTreeForce(child, p, theta); } }, _draw() { const ctx = this._ctx; const w = this._width; const h = this._height; ctx.save(); ctx.clearRect(0, 0, w, h); ctx.translate(this._offsetX, this._offsetY); ctx.scale(this._zoom, this._zoom); // Draw edges for (const edge of this._edges) { const a = this._nodePositions[edge.source]; const b = this._nodePositions[edge.target]; if (!a || !b) continue; ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); 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) { if (!this._isNodeVisible(node)) continue; const p = this._nodePositions[node.id]; if (!p) continue; 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)) ); ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI * 2); switch (node.type) { case 'directory': ctx.fillStyle = COLORS.dir; break; case 'file': ctx.fillStyle = (node.path || '').endsWith('.md') ? COLORS.md : COLORS.other; break; case 'vault': ctx.fillStyle = COLORS.vault; break; default: ctx.fillStyle = COLORS.other; } if (isHighlighted) { ctx.shadowColor = COLORS.highlight; ctx.shadowBlur = 12; } ctx.fill(); 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 = `${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); } ctx.restore(); }, _getNodeAt(screenX, screenY) { const x = (screenX - this._offsetX) / this._zoom; const y = (screenY - this._offsetY) / this._zoom; for (const node of this._nodes) { const p = this._nodePositions[node.id]; if (!p) continue; 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) { return { node, pos: p }; } } return null; }, _showTooltip(node, screenX, screenY, isPreview) { if (isPreview) return; // Handled by _showPreviewTooltip if (!this._infoContent) return; const cacheKey = `${this._vault}:${node.path}`; const cached = this._previewCache[cacheKey]; const meta = cached?.meta; // Build metadata summary let metaRows = ''; if (meta?.frontmatter && Object.keys(meta.frontmatter).length > 0) { const fm = meta.frontmatter; const rows = []; if (fm.auteur) rows.push(`
✍️ Auteur ${fm.auteur}
`); if (fm.date || fm.creation_date) rows.push(`
📅 Date ${fm.date || fm.creation_date}
`); if (fm.statut) rows.push(`
📌 Statut ${fm.statut}
`); if (fm.catégorie || fm.categorie) rows.push(`
📂 Catégorie ${fm.catégorie || fm.categorie}
`); if (fm.publish) rows.push('
Publié
'); if (fm.favoris) rows.push('
Favoris
'); if (rows.length > 0) metaRows = rows.join(''); } const tags = (meta?.tags || node.tags || []).slice(0, 6); const inc = node.incoming_count || 0; const out = node.outgoing_count || 0; const typeIcon = node.type === 'directory' ? '📁' : '📄'; const typeLabel = node.type === 'directory' ? 'Dossier' : (node.path || '').endsWith('.md') ? 'Markdown' : 'Fichier'; this._infoContent.innerHTML = `
${typeIcon}
${meta?.title || node.name}
${node.path}
${typeLabel} ${inc + out > 0 ? `🔗 ${out}→ · ${inc}←` : ''}
${metaRows ? `
${metaRows}
` : ''} ${tags.length > 0 ? `
${tags.map(t => `#${t}`).join('')}
` : ''} ${node.type === 'file' ? '
Ctrl+survol = aperçu complet
' : ''} `; this._infoPanel.style.display = 'block'; }, _hideTooltip() { // Panels are now sticky — only hide via close button }, _showPreviewTooltip(node, preview, screenX, screenY) { if (!this._previewContent || this._ctrlHoverNode !== node) return; const cacheKey = `${this._vault}:${node.path}`; const cached = this._previewCache[cacheKey]; const meta = cached?.meta; // Format frontmatter summary let fmSummary = ''; if (meta?.frontmatter && Object.keys(meta.frontmatter).length > 0) { const fm = meta.frontmatter; const items = []; if (fm.auteur) items.push(`✍️ ${fm.auteur}`); if (fm.date || fm.creation_date) items.push(`📅 ${fm.date || fm.creation_date}`); if (fm.statut) items.push(`📌 ${fm.statut}`); if (fm.publish) items.push('✅ publié'); if (fm.favoris) items.push('⭐ favoris'); if (items.length > 0) fmSummary = `
${items.join(' · ')}
`; } const tags = (meta?.tags || node.tags || []).slice(0, 8); this._previewContent.innerHTML = `
${meta?.title || node.name}
${node.path}
${fmSummary} ${tags.length > 0 ? `
${tags.map(t => `#${t}`).join('')}
` : ''}
${preview}
`; this._previewPanel.style.display = 'block'; }, // --- Advanced controls --- _isNodeVisible(node) { const showDir = document.getElementById('graph-filter-dir')?.checked ?? true; const showMd = document.getElementById('graph-filter-md')?.checked ?? true; const showOther = document.getElementById('graph-filter-other')?.checked ?? true; if (node.type === 'directory') return showDir; if (node.type === 'file' && (node.path || '').endsWith('.md')) return showMd; if (node.type === 'file') return showOther; return true; }, applyTypeFilter() { // Filters are applied during _draw() — nodes/edges not in visible set are skipped }, exportPNG() { const link = document.createElement('a'); link.download = `obsigate-graph-${this._vault}-${Date.now()}.png`; link.href = this._canvas.toDataURL('image/png'); link.click(); }, toggleFullscreen() { const container = this._canvas?.parentElement?.parentElement; // editor-container if (!container) return; if (document.fullscreenElement) { document.exitFullscreen(); } else { container.requestFullscreen(); } // Re-init layout after fullscreen change setTimeout(() => this._onResize(), 300); }, focusNode(nodeId) { const pos = this._nodePositions[nodeId]; if (!pos) return; this._offsetX = this._width / 2 - pos.x * this._zoom; this._offsetY = this._height / 2 - pos.y * this._zoom; }, _onMouseDown(e) { const rect = this._canvas.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; const hit = this._getNodeAt(mx, my); if (hit) { this._dragging = true; this._dragNode = hit; 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'; } }, _onMouseMove(e) { const rect = this._canvas.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; if (this._dragging && this._dragNode) { this._dragNode.pos.x = (mx - this._offsetX) / this._zoom; this._dragNode.pos.y = (my - this._offsetY) / this._zoom; this._dragNode.pos.vx = 0; this._dragNode.pos.vy = 0; } else if (this._panning) { this._offsetX = e.clientX - this._panStartX; this._offsetY = e.clientY - this._panStartY; } else { const hit = this._getNodeAt(mx, my); if (hit) { this._canvas.style.cursor = 'pointer'; this._canvas.title = hit.node.type === 'file' ? `📄 ${hit.node.name} (cliquer pour ouvrir${e.ctrlKey ? ', Ctrl+survol = aperçu' : ''})` : `📁 ${hit.node.name} (cliquer pour explorer)`; // Ctrl+hover: fetch content preview for file nodes if (e.ctrlKey && hit.node.type === 'file') { if (this._ctrlHoverNode !== hit.node) { this._ctrlHoverNode = hit.node; this._fetchPreview(hit.node, e.clientX, e.clientY); } this._showTooltip(hit.node, e.clientX, e.clientY, true); } else { this._ctrlHoverNode = null; this._showTooltip(hit.node, e.clientX, e.clientY, false); } } else { this._canvas.style.cursor = 'grab'; this._canvas.title = ''; this._ctrlHoverNode = null; this._hideTooltip(); } } }, async _fetchPreview(node, screenX, screenY) { const cacheKey = `${this._vault}:${node.path}`; if (this._previewCache[cacheKey] !== undefined) { const cached = this._previewCache[cacheKey]; this._showPreviewTooltip(node, cached.preview, screenX, screenY); return; } // Show loading indicator this._showPreviewTooltip(node, 'Chargement...', screenX, screenY); try { const data = await api(`/api/file/${encodeURIComponent(this._vault)}?path=${encodeURIComponent(node.path)}`); // Strip HTML tags, keep line breaks, limit to 600 chars const text = (data.html || '').replace(/<[^>]*>/g, '').replace(/\n+/g, '\n').trim(); const preview = text.substring(0, 600) + (text.length > 600 ? '…' : ''); // Cache both preview text and full metadata for tooltips this._previewCache[cacheKey] = { preview, meta: { title: data.title, frontmatter: data.frontmatter || {}, tags: data.tags || [], path: node.path, } }; this._showPreviewTooltip(node, preview, screenX, screenY); } catch { this._previewCache[cacheKey] = { preview: '(impossible de charger)', meta: null }; this._showPreviewTooltip(node, '(impossible de charger le contenu)', screenX, screenY); } }, _onMouseUp(e) { if (this._dragging && this._dragNode) { const node = this._dragNode.node; this._dragging = false; this._dragNode = null; this._canvas.style.cursor = 'grab'; if (node.type === 'file') { this.close(); openFile(this._vault, node.path); } else if (node.type === 'directory' || node.type === 'vault') { this.close(); this._path = node.path || ''; this.open(this._vault, this._path, node.type); } } this._panning = false; }, _onWheel(e) { e.preventDefault(); const rect = this._canvas.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; const zoomFactor = e.deltaY < 0 ? 1.1 : 0.9; const newZoom = Math.max(0.2, Math.min(3, this._zoom * zoomFactor)); this._offsetX = mx - (mx - this._offsetX) * (newZoom / this._zoom); this._offsetY = my - (my - this._offsetY) * (newZoom / this._zoom); this._zoom = newZoom; }, _onResize() { if (!this._canvas || !this._nodes.length) return; const w = this._canvas.parentElement.clientWidth; 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._width = w; this._height = h; this._ctx.setTransform(1, 0, 0, 1, 0, 0); this._ctx.scale(devicePixelRatio, devicePixelRatio); }, }; // --------------------------------------------------------------------------- // Init graph view event listeners // --------------------------------------------------------------------------- export function initGraphView() { 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', () => gm.close()); if (modal) modal.addEventListener('click', (e) => { if (e.target === modal) gm.close(); }); // Navigation buttons const navBack = document.getElementById('graph-nav-back'); if (navBack) navBack.addEventListener('click', () => gm.goBack()); const navFwd = document.getElementById('graph-nav-fwd'); if (navFwd) navFwd.addEventListener('click', () => gm.goForward()); const navUp = document.getElementById('graph-nav-up'); if (navUp) navUp.addEventListener('click', () => gm.goUp()); 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); }); } // Export PNG const exportBtn = document.getElementById('graph-export'); if (exportBtn) exportBtn.addEventListener('click', () => gm.exportPNG()); // Fullscreen const fullscreenBtn = document.getElementById('graph-fullscreen'); if (fullscreenBtn) fullscreenBtn.addEventListener('click', () => gm.toggleFullscreen()); // Type filters ['dir', 'md', 'other'].forEach(type => { const cb = document.getElementById(`graph-filter-${type}`); if (cb) cb.addEventListener('change', () => gm.applyTypeFilter()); }); 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'; // Panels stay visible until closed via button }); 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(); } }); // Close buttons for sticky panels const infoClose = document.getElementById('graph-info-close'); if (infoClose) infoClose.addEventListener('click', () => { if (gm._infoPanel) gm._infoPanel.style.display = 'none'; }); const previewClose = document.getElementById('graph-preview-close'); if (previewClose) previewClose.addEventListener('click', () => { if (gm._previewPanel) gm._previewPanel.style.display = 'none'; }); }