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:
parent
8c95899456
commit
e9e954f36b
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user