From e9e954f36b387e2361c245186298bf18603431fa Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 28 May 2026 14:52:31 -0400 Subject: [PATCH] =?UTF-8?q?feat(graph):=20Phase=204=20=E2=80=94=20Barnes-H?= =?UTF-8?q?ut,=20cache,=20lazy=20loading?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Performance optimizations for large vaults: - Barnes-Hut quadtree repulsion (O(n log n) for >200 nodes) - Naive O(n²) preserved for small graphs (<200 nodes) - Graph cache: reuse data when same (vault, path, depth, scope, tag) - Cache key displayed in info bar: '(cache)' label --- frontend/js/graph.js | 173 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 154 insertions(+), 19 deletions(-) diff --git a/frontend/js/graph.js b/frontend/js/graph.js index 18c4488..01bdd4d 100644 --- a/frontend/js/graph.js +++ b/frontend/js/graph.js @@ -81,10 +81,32 @@ export const GraphViewManager = { safeCreateIcons(); }, + _cache: {}, + _cacheKey: '', + + _getCacheKey() { + return `${this._vault}|${this._path}|${this._depth}|${this._scope}|${this._searchTerm}`; + }, + async _fetchAndRender() { const info = document.getElementById('graph-info'); if (!info) 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'; + info.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)); @@ -99,9 +121,16 @@ export const GraphViewManager = { 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'; - const depthLabel = `prof=${this._depth}`; - info.textContent = `${this._nodes.length} nœuds, ${this._edges.length} liens · ${scopeLabel} · ${depthLabel}`; + info.textContent = `${this._nodes.length} nœuds, ${this._edges.length} liens · ${scopeLabel} · prof=${this._depth}`; this._initLayout(); this._startRender(); } catch (err) { @@ -204,6 +233,7 @@ export const GraphViewManager = { 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]; @@ -223,25 +253,14 @@ export const GraphViewManager = { b.vy -= fy; } - 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; - } + // 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; @@ -249,6 +268,7 @@ 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; @@ -259,6 +279,121 @@ export const GraphViewManager = { } }, + _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;