+
diff --git a/frontend/js/graph.js b/frontend/js/graph.js
index 70d47c1..6b8b956 100644
--- a/frontend/js/graph.js
+++ b/frontend/js/graph.js
@@ -1,12 +1,33 @@
-/* ObsiGate β Graph View: interactive file/folder relationship visualization */
+/* ObsiGate β Graph View: interactive file/folder relationship visualization.
+ Phase 2: theme colors, tooltips, depth slider, full-vault, search, backlinks. */
import { api } from './auth.js';
import { safeCreateIcons } from './utils.js';
import { openFile } from './viewer.js';
// ---------------------------------------------------------------------------
-// Graph View Manager β Interactive file/folder relationship visualization
+// Theme-aware color helpers
// ---------------------------------------------------------------------------
+function _cssVar(name, fallback) {
+ return getComputedStyle(document.body).getPropertyValue(name).trim() || fallback;
+}
+const COLORS = {
+ get bg() { return _cssVar('--bg-primary', '#1e1e1e'); },
+ get text() { return _cssVar('--text-primary', '#ddd'); },
+ get accent() { return _cssVar('--accent-color', '#2563eb'); },
+ get muted() { return _cssVar('--text-muted', '#888'); },
+ get border() { return _cssVar('--border-color', '#333'); },
+ dir: '#5b9bd5',
+ md: '#70ad47',
+ other: '#999',
+ vault: '#ffc000',
+ backlink: '#e74c3c',
+ highlight: '#ff6b6b',
+};
+
+// ---------------------------------------------------------------------------
+// GraphViewManager
+// ---------------------------------------------------------------------------
export const GraphViewManager = {
_canvas: null,
_ctx: null,
@@ -26,49 +47,105 @@ export const GraphViewManager = {
_nodePositions: {},
_width: 0,
_height: 0,
+ _scope: 'directory',
+ _depth: 1,
+ _searchTerm: '',
+ _hoveredNode: null,
+ _tooltipEl: null,
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");
+ const modal = document.getElementById('graph-modal');
+ const title = document.getElementById('graph-title');
+ const info = document.getElementById('graph-info');
+ const canvas = document.getElementById('graph-canvas');
+ const depthSlider = document.getElementById('graph-depth');
if (!modal || !canvas) return;
- title.textContent = `Vue Graphique β ${vault}${path ? "/" + path : ""}`;
- info.textContent = "Chargement...";
- modal.classList.add("active");
+ this._tooltipEl = document.getElementById('graph-tooltip');
+ this._depth = depthSlider ? parseInt(depthSlider.value) : 1;
+ this._scope = 'directory';
+
+ title.textContent = `Vue Graphique β ${vault}${path ? '/' + path : ''}`;
+ info.textContent = 'Chargement...';
+ modal.classList.add('active');
this._canvas = canvas;
- this._ctx = canvas.getContext("2d");
+ 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);
- }
-
+ await this._fetchAndRender();
safeCreateIcons();
},
+ async _fetchAndRender() {
+ const info = document.getElementById('graph-info');
+ if (!info) return;
+
+ const params = new URLSearchParams();
+ if (this._path) params.set('path', this._path);
+ params.set('depth', String(this._depth));
+ params.set('scope', this._scope);
+ if (this._searchTerm) params.set('tag', this._searchTerm);
+
+ try {
+ const data = await api(
+ `/api/graph/${encodeURIComponent(this._vault)}?${params.toString()}`
+ );
+ this._nodes = data.nodes || [];
+ this._edges = data.edges || [];
+ this._scope = data.scope || 'directory';
+
+ 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}`;
+ this._initLayout();
+ this._startRender();
+ } catch (err) {
+ info.textContent = 'Erreur de chargement';
+ console.error('Graph error:', err);
+ }
+ },
+
+ reload() {
+ // Called when depth slider or full-vault button changes
+ this._resetView();
+ this._fetchAndRender();
+ },
+
+ setDepth(depth) {
+ this._depth = depth;
+ this.reload();
+ },
+
+ toggleScope() {
+ this._scope = this._scope === 'full' ? 'directory' : 'full';
+ const btn = document.getElementById('graph-full-vault');
+ if (btn) btn.textContent = this._scope === 'full' ? 'π Dossier' : 'π Tout';
+ this.reload();
+ },
+
+ setSearch(term) {
+ this._searchTerm = term;
+ // For now, use tag filter on backend; client-side highlighting on draw
+ if (term && term.length >= 2) {
+ this.reload();
+ } else if (!term && this._searchTerm !== term) {
+ this.reload();
+ }
+ },
+
close() {
- const modal = document.getElementById("graph-modal");
- if (modal) modal.classList.remove("active");
+ const modal = document.getElementById('graph-modal');
+ if (modal) modal.classList.remove('active');
if (this._animFrame) {
cancelAnimationFrame(this._animFrame);
this._animFrame = null;
}
+ this._hideTooltip();
},
_resetView() {
@@ -83,19 +160,19 @@ export const GraphViewManager = {
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._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);
- // 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;
+ const angle = (2 * Math.PI * i) / Math.max(this._nodes.length, 1);
this._nodePositions[node.id] = {
x: cx + radius * Math.cos(angle),
y: cy + radius * Math.sin(angle),
@@ -127,7 +204,6 @@ export const GraphViewManager = {
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];
@@ -147,7 +223,6 @@ export const GraphViewManager = {
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;
@@ -167,7 +242,6 @@ export const GraphViewManager = {
}
}
- // Center gravity
for (const node of this._nodes) {
const p = positions[node.id];
if (!p) continue;
@@ -175,7 +249,6 @@ 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;
@@ -194,7 +267,6 @@ export const GraphViewManager = {
ctx.save();
ctx.clearRect(0, 0, w, h);
- // Apply transform (pan + zoom)
ctx.translate(this._offsetX, this._offsetY);
ctx.scale(this._zoom, this._zoom);
@@ -207,45 +279,78 @@ export const GraphViewManager = {
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] : []);
+
+ if (edge.relation === 'backlink') {
+ ctx.strokeStyle = COLORS.backlink;
+ ctx.lineWidth = 1.5;
+ ctx.setLineDash([3, 3]);
+ } else if (edge.relation === 'wikilink') {
+ ctx.strokeStyle = COLORS.accent;
+ ctx.lineWidth = 2;
+ ctx.setLineDash([4, 4]);
+ } else {
+ ctx.strokeStyle = COLORS.muted;
+ ctx.lineWidth = 1;
+ ctx.setLineDash([]);
+ }
ctx.stroke();
}
ctx.setLineDash([]);
// Draw nodes
+ const searchLower = this._searchTerm.toLowerCase();
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 links = (node.incoming_count || 0) + (node.outgoing_count || 0);
+ const r = Math.max(5, Math.min(22, 6 + Math.sqrt(Math.max(node.size || 100, links * 200)) / 100));
+
+ // Highlight if search match
+ const isHighlighted = searchLower && (
+ node.name.toLowerCase().includes(searchLower) ||
+ (node.tags || []).some(t => t.toLowerCase().includes(searchLower))
+ );
- // Node circle
ctx.beginPath();
ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
switch (node.type) {
- case "directory":
- ctx.fillStyle = "#5b9bd5";
+ case 'directory':
+ ctx.fillStyle = COLORS.dir;
break;
- case "file":
- ctx.fillStyle = (node.path || "").endsWith(".md") ? "#70ad47" : "#c0c0c0";
+ case 'file':
+ ctx.fillStyle = (node.path || '').endsWith('.md') ? COLORS.md : COLORS.other;
+ break;
+ case 'vault':
+ ctx.fillStyle = COLORS.vault;
break;
default:
- ctx.fillStyle = "#ffc000";
- break;
+ ctx.fillStyle = COLORS.other;
}
+
+ if (isHighlighted) {
+ ctx.shadowColor = COLORS.highlight;
+ ctx.shadowBlur = 12;
+ }
+
ctx.fill();
- ctx.strokeStyle = "var(--bg-primary, #1e1e1e)";
- ctx.lineWidth = 1.5;
+
+ if (isHighlighted) {
+ ctx.shadowBlur = 0;
+ ctx.strokeStyle = COLORS.highlight;
+ ctx.lineWidth = 2.5;
+ } else {
+ ctx.strokeStyle = COLORS.bg;
+ 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";
+ const label = node.name.length > 20 ? node.name.slice(0, 18) + '...' : node.name;
+ ctx.font = `${isHighlighted ? 12 : 11} / ${this._zoom}px -apple-system, sans-serif`;
+ ctx.fillStyle = isHighlighted ? COLORS.highlight : COLORS.text;
+ ctx.textAlign = 'center';
ctx.fillText(label, p.x, p.y + r + 12 / this._zoom);
}
@@ -259,7 +364,8 @@ export const GraphViewManager = {
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 links = (node.incoming_count || 0) + (node.outgoing_count || 0);
+ const r = Math.max(5, Math.min(22, 6 + Math.sqrt(Math.max(node.size || 100, links * 200)) / 100));
const dx = x - p.x;
const dy = y - p.y;
if (dx * dx + dy * dy <= r * r + 100) {
@@ -269,6 +375,26 @@ export const GraphViewManager = {
return null;
},
+ _showTooltip(node, screenX, screenY) {
+ if (!this._tooltipEl) return;
+ const tags = (node.tags || []).slice(0, 5).join(', ');
+ const inc = node.incoming_count || 0;
+ const out = node.outgoing_count || 0;
+ this._tooltipEl.innerHTML = `
+ ${node.name}
+ ${node.type === 'file' ? `
${node.path}` : ''}
+ ${tags ? `
π·οΈ ${tags}` : ''}
+ ${inc + out > 0 ? `
π ${out} sortants Β· ${inc} entrants` : ''}
+ `;
+ this._tooltipEl.style.display = 'block';
+ this._tooltipEl.style.left = (screenX + 15) + 'px';
+ this._tooltipEl.style.top = (screenY - 10) + 'px';
+ },
+
+ _hideTooltip() {
+ if (this._tooltipEl) this._tooltipEl.style.display = 'none';
+ },
+
_onMouseDown(e) {
const rect = this._canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
@@ -278,12 +404,12 @@ export const GraphViewManager = {
if (hit) {
this._dragging = true;
this._dragNode = hit;
- this._canvas.style.cursor = "grabbing";
+ 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";
+ this._canvas.style.cursor = 'grabbing';
}
},
@@ -302,28 +428,34 @@ export const GraphViewManager = {
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}` : "";
+ if (hit) {
+ this._canvas.style.cursor = 'pointer';
+ this._canvas.title = hit.node.type === 'file'
+ ? `π ${hit.node.name} (cliquer pour ouvrir)`
+ : `π ${hit.node.name} (cliquer pour explorer)`;
+ this._showTooltip(hit.node, e.clientX, e.clientY);
+ } else {
+ this._canvas.style.cursor = 'grab';
+ this._canvas.title = '';
+ this._hideTooltip();
+ }
}
},
_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";
+ this._canvas.style.cursor = 'grab';
- // If it's a file, open it on click
- if (node.type === "file") {
+ if (node.type === 'file') {
this.close();
openFile(this._vault, node.path);
- } else if (node.type === "directory" || node.type === "vault") {
- // Expand into this directory
+ } else if (node.type === 'directory' || node.type === 'vault') {
this.close();
- this.open(this._vault, node.path, node.type);
+ this._path = node.path || '';
+ this.open(this._vault, this._path, node.type);
}
}
this._panning = false;
@@ -349,8 +481,8 @@ export const GraphViewManager = {
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._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);
@@ -359,43 +491,53 @@ export const GraphViewManager = {
};
// ---------------------------------------------------------------------------
-// Init graph view event listeners after DOM ready
+// Init graph view event listeners
// ---------------------------------------------------------------------------
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");
+ const gm = GraphViewManager;
+ 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 fullVault = document.getElementById('graph-full-vault');
+ const depthSlider = document.getElementById('graph-depth');
+ const searchInput = document.getElementById('graph-search');
+ 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";
+ if (closeBtn) closeBtn.addEventListener('click', () => gm.close());
+ if (modal) modal.addEventListener('click', (e) => { if (e.target === modal) gm.close(); });
+ if (zoomIn) zoomIn.addEventListener('click', () => { gm._zoom = Math.min(3, gm._zoom * 1.2); });
+ if (zoomOut) zoomOut.addEventListener('click', () => { gm._zoom = Math.max(0.2, gm._zoom * 0.8); });
+ if (reset) reset.addEventListener('click', () => { gm._offsetX = 0; gm._offsetY = 0; gm._zoom = 1; });
+ if (fullVault) fullVault.addEventListener('click', () => gm.toggleScope());
+ if (depthSlider) depthSlider.addEventListener('input', () => gm.setDepth(parseInt(depthSlider.value)));
+ if (searchInput) {
+ let debounce;
+ searchInput.addEventListener('input', () => {
+ clearTimeout(debounce);
+ debounce = setTimeout(() => gm.setSearch(searchInput.value.trim()), 300);
});
- 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();
+ if (canvas) {
+ canvas.addEventListener('mousedown', (e) => gm._onMouseDown(e));
+ canvas.addEventListener('mousemove', (e) => gm._onMouseMove(e));
+ canvas.addEventListener('mouseup', (e) => gm._onMouseUp(e));
+ canvas.addEventListener('mouseleave', () => {
+ gm._dragging = false;
+ gm._dragNode = null;
+ gm._panning = false;
+ canvas.style.cursor = 'grab';
+ gm._hideTooltip();
+ });
+ canvas.addEventListener('wheel', (e) => gm._onWheel(e), { passive: false });
+ window.addEventListener('resize', () => gm._onResize());
+ }
+
+ document.addEventListener('keydown', (e) => {
+ if (modal && modal.classList.contains('active') && e.key === 'Escape') {
+ gm.close();
}
});
}