Files
ObsiGate/frontend/js/graph.js
T
bruno 4836d6f1d0
CI / lint (push) Successful in 10s
CI / security (push) Successful in 8s
CI / build (push) Has been cancelled
CI / test (push) Has been cancelled
refactor: split app.js (8875 lines) into 12 ES modules
frontend/js/ structure:
  state.js      (55 lines)  — Shared mutable state, constants
  utils.js      (510 lines) — EXT_ICONS, getFileIcon, escapeHtml, safeCreateIcons
  auth.js       (547 lines) — api(), AuthManager, initLoginForm, AdminPanel
  search.js     (1106 lines)— SearchHistory, QueryParser, Autocomplete, performSearch
  sidebar.js    (1091 lines)— Vault tree, sidebar filter, TagFilterService, loadTags
  viewer.js     (1554 lines)— openFile, Outline, ScrollSpy, Frontmatter, Editor
  ui.js         (2250 lines)— Theme, Toast, Sidebar, Dropdowns, Tabs, ContextMenu
  dashboard.js  (461 lines) — Dashboard widgets (Recent, Stats, Bookmarks)
  config.js     (999 lines) — Config panel, Hidden files, About, Sidebar tabs
  sync.js       (436 lines) — SSE/IndexUpdateManager, PWA registration
  graph.js      (401 lines) — GraphViewManager (force-directed canvas graph)
  legacy.js     (550 lines) — Remaining bridge functions (goHome, showWelcome, initSearch)
  app.js        (80 lines)  — Thin orchestrator: imports all modules, calls init()

index.html: switched from <script src="app.js"> to <script type="module" src="js/app.js">
Original app.js preserved for backward compatibility.
All 14 modules pass node --check syntax validation.
2026-05-28 14:04:50 -04:00

402 lines
12 KiB
JavaScript

/* 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();
}
});
}