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.
402 lines
12 KiB
JavaScript
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();
|
|
}
|
|
});
|
|
}
|