/* ObsiGate — Graph View: interactive file/folder relationship visualization */ import { api } from './auth.js'; import { safeCreateIcons } from './utils.js'; import { openFile } from './viewer.js'; // --------------------------------------------------------------------------- // Graph View Manager — Interactive file/folder relationship visualization // --------------------------------------------------------------------------- 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, 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"); if (!modal || !canvas) return; title.textContent = `Vue Graphique — ${vault}${path ? "/" + path : ""}`; info.textContent = "Chargement..."; modal.classList.add("active"); this._canvas = canvas; 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); } safeCreateIcons(); }, close() { const modal = document.getElementById("graph-modal"); if (modal) modal.classList.remove("active"); if (this._animFrame) { cancelAnimationFrame(this._animFrame); this._animFrame = null; } }, _resetView() { this._offsetX = 0; this._offsetY = 0; this._zoom = 1; this._nodePositions = {}; }, _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.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; 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) 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 between all nodes 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; } } // 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; } }, _draw() { const ctx = this._ctx; const w = this._width; const h = this._height; ctx.save(); ctx.clearRect(0, 0, w, h); // Apply transform (pan + zoom) 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); 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] : []); ctx.stroke(); } ctx.setLineDash([]); // Draw nodes 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)); // Node circle ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI * 2); switch (node.type) { case "directory": ctx.fillStyle = "#5b9bd5"; break; case "file": ctx.fillStyle = (node.path || "").endsWith(".md") ? "#70ad47" : "#c0c0c0"; break; default: ctx.fillStyle = "#ffc000"; break; } ctx.fill(); ctx.strokeStyle = "var(--bg-primary, #1e1e1e)"; 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"; 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 r = Math.max(5, Math.min(20, 6 + Math.sqrt(node.size || 100) / 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; }, _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); this._canvas.style.cursor = hit ? "pointer" : "grab"; this._canvas.title = hit ? `Ouvrir: ${hit.node.path || hit.node.name}` : ""; } }, _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"; // If it's a file, open it on click if (node.type === "file") { this.close(); openFile(this._vault, node.path); } else if (node.type === "directory" || node.type === "vault") { // Expand into this directory this.close(); this.open(this._vault, node.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 after DOM ready // --------------------------------------------------------------------------- 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"); 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"; }); 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(); } }); }