feat(graph): Phase 1+2 — full-vault, tag filter, backlinks, tooltips, depth slider
Backend (main.py): - GraphNode: added tags, incoming_count, outgoing_count - GraphEdge: added 'backlink' relation - GraphResponse: added 'scope' field - api_graph: scope=full|directory, tag= filter, backlinks - Full-vault tree walk with configurable depth 0-3 - Tag index from in-memory file index for fast filtering - Incoming/outgoing link count per node Frontend (graph.js + index.html): - Theme-adaptive colors via CSS custom properties - Depth slider (0-3) with live reload - Full-vault toggle button (🌐 Tout / 📁 Dossier) - Search input with tag filtering + visual highlighting - Tooltip on hover (name, path, tags, link counts) - Backlink edges rendered in red dashed - Node size proportional to link count - Larger modal (1000px, 85vh)
This commit is contained in:
parent
c8e74bd39b
commit
a373279b08
@ -229,19 +229,23 @@ class GraphNode(BaseModel):
|
||||
type: str = Field(description="'vault', 'directory', or 'file'")
|
||||
path: str = Field(description="Relative path within vault")
|
||||
size: int = Field(default=0, description="File size in bytes")
|
||||
tags: List[str] = Field(default_factory=list, description="Tags from frontmatter")
|
||||
incoming_count: int = Field(default=0, description="Number of incoming wikilinks")
|
||||
outgoing_count: int = Field(default=0, description="Number of outgoing wikilinks")
|
||||
|
||||
|
||||
class GraphEdge(BaseModel):
|
||||
"""An edge between two nodes in the graph view."""
|
||||
source: str = Field(description="Source node ID")
|
||||
target: str = Field(description="Target node ID")
|
||||
relation: str = Field(description="'parent' or 'wikilink'")
|
||||
relation: str = Field(description="'parent', 'wikilink', or 'backlink'")
|
||||
|
||||
|
||||
class GraphResponse(BaseModel):
|
||||
"""Graph data for a vault or directory."""
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Root path for the graph")
|
||||
scope: str = Field(default="directory", description="'directory' or 'full'")
|
||||
nodes: List[GraphNode] = Field(description="Graph nodes (files and directories)")
|
||||
edges: List[GraphEdge] = Field(description="Graph edges (parent and wikilink relations)")
|
||||
|
||||
@ -2165,6 +2169,8 @@ async def api_graph(
|
||||
vault_name: str,
|
||||
path: str = Query("", description="Relative path to focus on"),
|
||||
depth: int = Query(1, ge=0, le=3, description="How many levels deep to expand"),
|
||||
scope: str = Query("directory", description="'directory' (default) or 'full' for entire vault"),
|
||||
tag: str = Query("", description="Filter: only show files with this tag"),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Return graph data (nodes and edges) for a vault or directory.
|
||||
@ -2176,6 +2182,8 @@ async def api_graph(
|
||||
vault_name: Name of the vault.
|
||||
path: Relative directory path to focus on (empty = root).
|
||||
depth: Expansion depth (0 = only direct children, 1-3 = deeper).
|
||||
scope: 'directory' for subtree, 'full' for entire vault.
|
||||
tag: Optional tag filter (only files with this tag appear).
|
||||
|
||||
Returns:
|
||||
``GraphResponse`` with nodes and edges.
|
||||
@ -2197,11 +2205,16 @@ async def api_graph(
|
||||
edges: List[dict] = []
|
||||
node_ids: set = set()
|
||||
|
||||
def _add_node(name: str, ntype: str, npath: str, size: int = 0) -> str:
|
||||
def _add_node(name: str, ntype: str, npath: str, size: int = 0,
|
||||
tags: list[str] | None = None, incoming: int = 0, outgoing: int = 0) -> str:
|
||||
nid = f"{vault_name}:{npath}"
|
||||
if nid not in node_ids:
|
||||
node_ids.add(nid)
|
||||
nodes.append({"id": nid, "name": name, "type": ntype, "path": npath, "size": size})
|
||||
nodes.append({
|
||||
"id": nid, "name": name, "type": ntype, "path": npath,
|
||||
"size": size, "tags": tags or [],
|
||||
"incoming_count": incoming, "outgoing_count": outgoing,
|
||||
})
|
||||
return nid
|
||||
|
||||
def _add_edge(source: str, target: str, relation: str):
|
||||
@ -2212,6 +2225,26 @@ async def api_graph(
|
||||
settings = get_vault_setting(vault_name) or {}
|
||||
hide_hidden = settings.get("hideHiddenFiles", False)
|
||||
|
||||
# Build tag index from the in-memory index for fast lookups
|
||||
_tag_index: dict[str, list[str]] = {}
|
||||
for doc_key, info in index.items():
|
||||
vn, fp = doc_key.split("::", 1) if "::" in doc_key else ("", "")
|
||||
if vn == vault_name:
|
||||
for t in info.get("tags", []):
|
||||
_tag_index.setdefault(t.lower(), []).append(fp)
|
||||
|
||||
# Determine scope
|
||||
if scope == "full":
|
||||
# Full vault — walk entire vault root, ignore path param
|
||||
target = vault_root.resolve()
|
||||
effective_depth = depth if depth > 0 else 2 # minimum depth 2 for full view
|
||||
else:
|
||||
target = _resolve_safe_path(vault_root, path) if path else vault_root.resolve()
|
||||
effective_depth = depth
|
||||
|
||||
if not target.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Path not found: {path}")
|
||||
|
||||
# Add the focus node
|
||||
focus_name = path.split("/")[-1] if path else vault_name
|
||||
focus_type = "directory" if path else "vault"
|
||||
@ -2219,20 +2252,28 @@ async def api_graph(
|
||||
|
||||
# Walk directory tree up to depth levels
|
||||
def _walk_dir(dir_path: Path, parent_id: str, current_depth: int):
|
||||
if current_depth > depth:
|
||||
if current_depth > effective_depth:
|
||||
return
|
||||
try:
|
||||
for entry in sorted(dir_path.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())):
|
||||
if hide_hidden and entry.name.startswith("."):
|
||||
continue
|
||||
rel = str(entry.relative_to(vault_root)).replace("\\", "/")
|
||||
|
||||
# Tag filter: skip files that don't have the requested tag
|
||||
if tag and entry.is_file():
|
||||
file_tags = [t.lower() for t in _tag_index.get(rel, [])]
|
||||
if tag.lower() not in file_tags:
|
||||
continue
|
||||
|
||||
if entry.is_dir():
|
||||
did = _add_node(entry.name, "directory", rel)
|
||||
_add_edge(parent_id, did, "parent")
|
||||
if current_depth < depth:
|
||||
if current_depth < effective_depth:
|
||||
_walk_dir(entry, did, current_depth + 1)
|
||||
elif entry.suffix.lower() in SUPPORTED_EXTENSIONS or entry.name.lower() in ("dockerfile", "makefile"):
|
||||
fid = _add_node(entry.name, "file", rel, entry.stat().st_size)
|
||||
file_tags = _tag_index.get(rel, [])
|
||||
fid = _add_node(entry.name, "file", rel, entry.stat().st_size, tags=file_tags)
|
||||
_add_edge(parent_id, fid, "parent")
|
||||
except PermissionError:
|
||||
pass
|
||||
@ -2240,13 +2281,30 @@ async def api_graph(
|
||||
if target.is_dir():
|
||||
_walk_dir(target, focus_id, 0)
|
||||
elif target.is_file():
|
||||
# For a single file, show siblings in same directory
|
||||
_walk_dir(target.parent, focus_id, 0)
|
||||
|
||||
# Add wikilink edges between markdown files in the current scope
|
||||
_add_wikilink_edges(nodes, edges, node_ids, vault_name)
|
||||
|
||||
return {"vault": vault_name, "path": path, "nodes": nodes, "edges": edges}
|
||||
# Compute incoming/outgoing counts from edges
|
||||
edge_counts: dict[str, dict[str, int]] = {}
|
||||
for node in nodes:
|
||||
edge_counts[node["id"]] = {"incoming": 0, "outgoing": 0}
|
||||
for edge in edges:
|
||||
if edge["relation"] in ("wikilink", "backlink"):
|
||||
src = edge["source"]
|
||||
tgt = edge["target"]
|
||||
if src in edge_counts:
|
||||
edge_counts[src]["outgoing"] += 1
|
||||
if tgt in edge_counts:
|
||||
edge_counts[tgt]["incoming"] += 1
|
||||
for node in nodes:
|
||||
counts = edge_counts.get(node["id"], {"incoming": 0, "outgoing": 0})
|
||||
node["incoming_count"] = counts["incoming"]
|
||||
node["outgoing_count"] = counts["outgoing"]
|
||||
|
||||
return {"vault": vault_name, "path": path, "scope": scope,
|
||||
"nodes": nodes, "edges": edges}
|
||||
|
||||
|
||||
def _add_wikilink_edges(nodes: list, edges: list, node_ids: set, vault_name: str):
|
||||
|
||||
@ -699,11 +699,15 @@
|
||||
|
||||
<!-- Graph View Modal -->
|
||||
<div class="editor-modal" id="graph-modal">
|
||||
<div class="editor-container" style="max-width:90vw;width:900px;height:80vh;display:flex;flex-direction:column;">
|
||||
<div class="editor-container" style="max-width:95vw;width:1000px;height:85vh;display:flex;flex-direction:column;">
|
||||
<div class="editor-header">
|
||||
<div class="editor-title" id="graph-title">Vue Graphique</div>
|
||||
<div class="editor-actions">
|
||||
<span id="graph-info" style="font-size:0.75rem;color:var(--text-muted);margin-right:12px"></span>
|
||||
<span id="graph-info" style="font-size:0.75rem;color:var(--text-muted);margin-right:8px"></span>
|
||||
<label style="font-size:0.7rem;color:var(--text-muted);margin-right:4px">Profondeur:</label>
|
||||
<input type="range" id="graph-depth" min="0" max="3" value="1" style="width:60px;margin-right:8px" title="Profondeur d'exploration">
|
||||
<button class="editor-btn" id="graph-full-vault" title="Vue complète du vault" aria-label="Vault complet" style="font-size:0.7rem;margin-right:4px">🌐 Tout</button>
|
||||
<input type="text" id="graph-search" placeholder="Rechercher..." style="width:100px;font-size:0.75rem;padding:2px 6px;border-radius:4px;border:1px solid var(--border-color);background:var(--bg-secondary);color:var(--text-primary);margin-right:8px">
|
||||
<button class="editor-btn" id="graph-zoom-in" title="Zoom avant" aria-label="Zoom avant">
|
||||
<i data-lucide="zoom-in" style="width:16px;height:16px"></i>
|
||||
</button>
|
||||
@ -720,12 +724,14 @@
|
||||
</div>
|
||||
<div class="editor-body" style="flex:1;overflow:hidden;position:relative;padding:0">
|
||||
<canvas id="graph-canvas" style="width:100%;height:100%;cursor:grab"></canvas>
|
||||
<div id="graph-tooltip" style="display:none;position:absolute;pointer-events:none;background:var(--bg-primary);border:1px solid var(--border-color);border-radius:6px;padding:6px 10px;font-size:0.75rem;color:var(--text-primary);max-width:250px;z-index:10;box-shadow:0 2px 8px rgba(0,0,0,0.3)"></div>
|
||||
<div id="graph-legend" style="position:absolute;bottom:12px;left:12px;display:flex;gap:16px;font-size:0.7rem;color:var(--text-muted);background:var(--bg-primary);padding:6px 12px;border-radius:6px;border:1px solid var(--border-color)">
|
||||
<span>🟦 Dossier</span>
|
||||
<span>🟩 Fichier .md</span>
|
||||
<span>⬜ Autre fichier</span>
|
||||
<span style="color:var(--text-muted)">── Parent</span>
|
||||
<span style="color:var(--accent-color,#2563eb)">┅┅ Wikilink</span>
|
||||
<span style="color:#e74c3c">← Backlink</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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 = `
|
||||
<strong>${node.name}</strong>
|
||||
${node.type === 'file' ? `<br><span style="color:${COLORS.muted};font-size:0.7rem">${node.path}</span>` : ''}
|
||||
${tags ? `<br>🏷️ ${tags}` : ''}
|
||||
${inc + out > 0 ? `<br>🔗 ${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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user