feat(graph): Phase 4 — Barnes-Hut, cache, lazy loading
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
This commit is contained in:
+154
-19
@@ -81,10 +81,32 @@ export const GraphViewManager = {
|
|||||||
safeCreateIcons();
|
safeCreateIcons();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
_cache: {},
|
||||||
|
_cacheKey: '',
|
||||||
|
|
||||||
|
_getCacheKey() {
|
||||||
|
return `${this._vault}|${this._path}|${this._depth}|${this._scope}|${this._searchTerm}`;
|
||||||
|
},
|
||||||
|
|
||||||
async _fetchAndRender() {
|
async _fetchAndRender() {
|
||||||
const info = document.getElementById('graph-info');
|
const info = document.getElementById('graph-info');
|
||||||
if (!info) return;
|
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();
|
const params = new URLSearchParams();
|
||||||
if (this._path) params.set('path', this._path);
|
if (this._path) params.set('path', this._path);
|
||||||
params.set('depth', String(this._depth));
|
params.set('depth', String(this._depth));
|
||||||
@@ -99,9 +121,16 @@ export const GraphViewManager = {
|
|||||||
this._edges = data.edges || [];
|
this._edges = data.edges || [];
|
||||||
this._scope = data.scope || 'directory';
|
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 scopeLabel = this._scope === 'full' ? 'Vault complet' : 'Dossier';
|
||||||
const depthLabel = `prof=${this._depth}`;
|
info.textContent = `${this._nodes.length} nœuds, ${this._edges.length} liens · ${scopeLabel} · prof=${this._depth}`;
|
||||||
info.textContent = `${this._nodes.length} nœuds, ${this._edges.length} liens · ${scopeLabel} · ${depthLabel}`;
|
|
||||||
this._initLayout();
|
this._initLayout();
|
||||||
this._startRender();
|
this._startRender();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -204,6 +233,7 @@ export const GraphViewManager = {
|
|||||||
const cx = this._width / 2;
|
const cx = this._width / 2;
|
||||||
const cy = this._height / 2;
|
const cy = this._height / 2;
|
||||||
|
|
||||||
|
// Spring forces (edges) — unchanged
|
||||||
for (const edge of this._edges) {
|
for (const edge of this._edges) {
|
||||||
const a = positions[edge.source];
|
const a = positions[edge.source];
|
||||||
const b = positions[edge.target];
|
const b = positions[edge.target];
|
||||||
@@ -223,25 +253,14 @@ export const GraphViewManager = {
|
|||||||
b.vy -= fy;
|
b.vy -= fy;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const n1 of this._nodes) {
|
// Repulsion: Barnes-Hut for >200 nodes, naive O(n²) otherwise
|
||||||
for (const n2 of this._nodes) {
|
if (this._nodes.length > 200) {
|
||||||
if (n1.id === n2.id) continue;
|
this._barnesHutRepulsion(positions);
|
||||||
const a = positions[n1.id];
|
} else {
|
||||||
const b = positions[n2.id];
|
this._naiveRepulsion(positions);
|
||||||
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) {
|
for (const node of this._nodes) {
|
||||||
const p = positions[node.id];
|
const p = positions[node.id];
|
||||||
if (!p) continue;
|
if (!p) continue;
|
||||||
@@ -249,6 +268,7 @@ export const GraphViewManager = {
|
|||||||
p.vy += (cy - p.y) * 0.001;
|
p.vy += (cy - p.y) * 0.001;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply velocities with damping
|
||||||
for (const node of this._nodes) {
|
for (const node of this._nodes) {
|
||||||
const p = positions[node.id];
|
const p = positions[node.id];
|
||||||
if (!p) continue;
|
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() {
|
_draw() {
|
||||||
const ctx = this._ctx;
|
const ctx = this._ctx;
|
||||||
const w = this._width;
|
const w = this._width;
|
||||||
|
|||||||
Reference in New Issue
Block a user