/* 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(`