/* 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';
// ---------------------------------------------------------------------------
// 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,
_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,
_scope: 'directory',
_depth: 1,
_searchTerm: '',
_tagFilter: '',
_hoveredNode: null,
_tooltipEl: null,
_previewCache: {},
_previewPending: null,
_ctrlHoverNode: 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 statusEl = document.getElementById('graph-status');
const canvas = document.getElementById('graph-canvas');
const depthSlider = document.getElementById('graph-depth');
if (!modal || !canvas) return;
this._tooltipEl = document.getElementById('graph-tooltip');
this._depth = depthSlider ? parseInt(depthSlider.value) : 1;
this._scope = 'directory';
title.textContent = `Vue Graphique — ${vault}${path ? '/' + path : ''}`;
statusEl.textContent = 'Chargement...';
modal.classList.add('active');
this._canvas = canvas;
this._ctx = canvas.getContext('2d');
this._resetView();
await this._fetchAndRender();
safeCreateIcons();
},
_cache: {},
_cacheKey: '',
_getCacheKey() {
return `${this._vault}|${this._path}|${this._depth}|${this._scope}|${this._searchTerm}|${this._tagFilter}`;
},
async _fetchAndRender() {
const statusEl = document.getElementById('graph-status');
if (!statusEl) 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';
statusEl.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));
params.set('scope', this._scope);
if (this._tagFilter) params.set('tag', this._tagFilter);
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';
// 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';
statusEl.textContent = `${this._nodes.length} nœuds, ${this._edges.length} liens · ${scopeLabel} · prof=${this._depth}`;
this._initLayout();
this._startRender();
} catch (err) {
statusEl.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;
// Parse unified search: #tag → tag filter, otherwise → node search
if (term.startsWith('#')) {
const tag = term.substring(1).trim();
if (tag && tag !== this._tagFilter) {
this._tagFilter = tag;
this.reload();
} else if (!tag && this._tagFilter) {
this._tagFilter = '';
this.reload();
}
} else {
if (this._tagFilter) {
this._tagFilter = '';
this.reload();
return;
}
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');
if (this._animFrame) {
cancelAnimationFrame(this._animFrame);
this._animFrame = null;
}
this._hideTooltip();
},
_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.setTransform(1, 0, 0, 1, 0, 0);
this._ctx.scale(devicePixelRatio, devicePixelRatio);
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) / Math.max(this._nodes.length, 1);
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) — unchanged
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: 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;
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;
}
},
_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;
const h = this._height;
ctx.save();
ctx.clearRect(0, 0, w, h);
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);
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) {
if (!this._isNodeVisible(node)) continue;
const p = this._nodePositions[node.id];
if (!p) continue;
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))
);
ctx.beginPath();
ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
switch (node.type) {
case 'directory':
ctx.fillStyle = COLORS.dir;
break;
case 'file':
ctx.fillStyle = (node.path || '').endsWith('.md') ? COLORS.md : COLORS.other;
break;
case 'vault':
ctx.fillStyle = COLORS.vault;
break;
default:
ctx.fillStyle = COLORS.other;
}
if (isHighlighted) {
ctx.shadowColor = COLORS.highlight;
ctx.shadowBlur = 12;
}
ctx.fill();
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 = `${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);
}
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 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) {
return { node, pos: p };
}
}
return null;
},
_showTooltip(node, screenX, screenY, isPreview) {
if (!this._tooltipEl) return;
if (isPreview) return; // Preview tooltip handled by _showPreviewTooltip
const cacheKey = `${this._vault}:${node.path}`;
const cached = this._previewCache[cacheKey];
const meta = cached?.meta;
// Build metadata summary if available
let metaHtml = '';
if (meta?.frontmatter && Object.keys(meta.frontmatter).length > 0) {
const fm = meta.frontmatter;
const parts = [];
if (fm.auteur) parts.push(`✍️ ${fm.auteur}`);
if (fm.date || fm.creation_date) parts.push(`📅 ${fm.date || fm.creation_date}`);
if (fm.statut) parts.push(`📌 ${fm.statut}`);
if (fm.publish) parts.push('✅ publié');
if (fm.favoris) parts.push('⭐ favoris');
if (parts.length > 0) metaHtml = `
${parts.join(' · ')}`;
}
const tags = (meta?.tags || node.tags || []).slice(0, 5).join(', ');
const inc = node.incoming_count || 0;
const out = node.outgoing_count || 0;
this._tooltipEl.innerHTML = `
${meta?.title || node.name}
${node.type === 'file' ? `
${node.path}` : ''}
${metaHtml}
${tags ? `
🏷️ ${tags}` : ''}
${inc + out > 0 ? `
🔗 ${out} sortants · ${inc} entrants` : ''}
${node.type === 'file' ? `
Ctrl+survol pour aperçu` : ''}
`;
this._tooltipEl.style.display = 'block';
this._tooltipEl.style.maxWidth = '280px';
this._tooltipEl.style.left = (screenX + 15) + 'px';
this._tooltipEl.style.top = (screenY - 10) + 'px';
},
_hideTooltip() {
if (this._tooltipEl) this._tooltipEl.style.display = 'none';
},
// --- Advanced controls ---
_isNodeVisible(node) {
const showDir = document.getElementById('graph-filter-dir')?.checked ?? true;
const showMd = document.getElementById('graph-filter-md')?.checked ?? true;
const showOther = document.getElementById('graph-filter-other')?.checked ?? true;
if (node.type === 'directory') return showDir;
if (node.type === 'file' && (node.path || '').endsWith('.md')) return showMd;
if (node.type === 'file') return showOther;
return true;
},
applyTypeFilter() {
// Filters are applied during _draw() — nodes/edges not in visible set are skipped
},
exportPNG() {
const link = document.createElement('a');
link.download = `obsigate-graph-${this._vault}-${Date.now()}.png`;
link.href = this._canvas.toDataURL('image/png');
link.click();
},
toggleFullscreen() {
const container = this._canvas?.parentElement?.parentElement; // editor-container
if (!container) return;
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
container.requestFullscreen();
}
// Re-init layout after fullscreen change
setTimeout(() => this._onResize(), 300);
},
focusNode(nodeId) {
const pos = this._nodePositions[nodeId];
if (!pos) return;
this._offsetX = this._width / 2 - pos.x * this._zoom;
this._offsetY = this._height / 2 - pos.y * this._zoom;
},
_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);
if (hit) {
this._canvas.style.cursor = 'pointer';
this._canvas.title = hit.node.type === 'file'
? `📄 ${hit.node.name} (cliquer pour ouvrir${e.ctrlKey ? ', Ctrl+survol = aperçu' : ''})`
: `📁 ${hit.node.name} (cliquer pour explorer)`;
// Ctrl+hover: fetch content preview for file nodes
if (e.ctrlKey && hit.node.type === 'file') {
if (this._ctrlHoverNode !== hit.node) {
this._ctrlHoverNode = hit.node;
this._fetchPreview(hit.node, e.clientX, e.clientY);
}
this._showTooltip(hit.node, e.clientX, e.clientY, true);
} else {
this._ctrlHoverNode = null;
this._showTooltip(hit.node, e.clientX, e.clientY, false);
}
} else {
this._canvas.style.cursor = 'grab';
this._canvas.title = '';
this._ctrlHoverNode = null;
this._hideTooltip();
}
}
},
async _fetchPreview(node, screenX, screenY) {
const cacheKey = `${this._vault}:${node.path}`;
if (this._previewCache[cacheKey] !== undefined) {
const cached = this._previewCache[cacheKey];
this._showPreviewTooltip(node, cached.preview, screenX, screenY);
return;
}
// Show loading indicator
this._showPreviewTooltip(node, 'Chargement...', screenX, screenY);
try {
const data = await api(`/api/file/${encodeURIComponent(this._vault)}?path=${encodeURIComponent(node.path)}`);
// Strip HTML tags, keep line breaks, limit to 600 chars
const text = (data.html || '').replace(/<[^>]*>/g, '').replace(/\n+/g, '\n').trim();
const preview = text.substring(0, 600) + (text.length > 600 ? '…' : '');
// Cache both preview text and full metadata for tooltips
this._previewCache[cacheKey] = {
preview,
meta: {
title: data.title,
frontmatter: data.frontmatter || {},
tags: data.tags || [],
path: node.path,
}
};
this._showPreviewTooltip(node, preview, screenX, screenY);
} catch {
this._previewCache[cacheKey] = { preview: '(impossible de charger)', meta: null };
this._showPreviewTooltip(node, '(impossible de charger le contenu)', screenX, screenY);
}
},
_showPreviewTooltip(node, preview, screenX, screenY) {
if (!this._tooltipEl || this._ctrlHoverNode !== node) return;
const cacheKey = `${this._vault}:${node.path}`;
const cached = this._previewCache[cacheKey];
const meta = cached?.meta;
// Format frontmatter summary
let fmSummary = '';
if (meta?.frontmatter && Object.keys(meta.frontmatter).length > 0) {
const fm = meta.frontmatter;
const items = [];
if (fm.auteur) items.push(`✍️ ${fm.auteur}`);
if (fm.date || fm.creation_date) items.push(`📅 ${fm.date || fm.creation_date}`);
if (fm.statut) items.push(`📌 ${fm.statut}`);
if (fm.catégorie || fm.categorie) items.push(`📂 ${fm.catégorie || fm.categorie}`);
if (fm.publish) items.push('✅ publié');
if (fm.favoris) items.push('⭐ favoris');
if (items.length > 0) fmSummary = `