Force-directed graph view with tag nodes, project clustering, physics tuning
- New /graph route with D3 force simulation (GraphView.vue) - Tag nodes as first-class graph nodes (string IDs "tag:name") — clicking navigates to /notes?tags=name; tags shown by default - Invisible project hub nodes attract project members into clusters - Physics panel with live sliders: repulsion, link distance, link strength, project pull, gravity (forceX/forceY, not forceCenter) - Wikilink edges retain directed arrowheads; tag edges are thin, no arrowhead - Graph nav link in AppHeader; `g` shortcut in App.vue - Backend: build_note_graph() emits tag nodes + note→tag edges instead of O(n²) note→note shared-tag mesh Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ from fabledassistant.services.generation_buffer import (
|
||||
)
|
||||
from fabledassistant.services.generation_task import run_assist_generation
|
||||
from fabledassistant.services.notes import (
|
||||
build_note_graph,
|
||||
convert_note_to_task,
|
||||
convert_task_to_note,
|
||||
create_note,
|
||||
@@ -510,3 +511,15 @@ async def get_version_route(note_id: int, version_id: int):
|
||||
if version is None:
|
||||
return not_found("Version")
|
||||
return jsonify(version.to_dict(include_body=True))
|
||||
|
||||
|
||||
# ── Graph route ────────────────────────────────────────────────────────────────
|
||||
|
||||
@notes_bp.route("/graph", methods=["GET"])
|
||||
@login_required
|
||||
async def graph_route():
|
||||
uid = get_current_user_id()
|
||||
project_id = request.args.get("project_id", type=int)
|
||||
shared_tags = request.args.get("shared_tags", "false").lower() == "true"
|
||||
graph = await build_note_graph(uid, project_id=project_id, include_shared_tags=shared_tags)
|
||||
return jsonify(graph)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import re
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from sqlalchemy import func, or_, select, text
|
||||
@@ -356,3 +357,82 @@ async def get_backlinks(user_id: int, note_id: int) -> list[dict]:
|
||||
backlinks.append({"type": link_type, "id": row[0], "title": row[1]})
|
||||
|
||||
return backlinks
|
||||
|
||||
|
||||
_WIKILINK_RE = re.compile(r'\[\[([^\]|]+)(?:\|[^\]]+)?\]\]')
|
||||
|
||||
|
||||
async def build_note_graph(
|
||||
user_id: int,
|
||||
project_id: int | None = None,
|
||||
include_shared_tags: bool = False,
|
||||
) -> dict:
|
||||
notes, _ = await list_notes(user_id, project_id=project_id, limit=1000)
|
||||
if not notes:
|
||||
return {"nodes": [], "edges": []}
|
||||
|
||||
# Fetch project colours
|
||||
project_ids = {n.project_id for n in notes if n.project_id is not None}
|
||||
project_colors: dict[int, str] = {}
|
||||
if project_ids:
|
||||
from fabledassistant.models.project import Project
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Project.id, Project.color).where(Project.id.in_(project_ids))
|
||||
)
|
||||
for pid, color in result.fetchall():
|
||||
project_colors[pid] = color or "#888888"
|
||||
|
||||
# Build title lookup (lowercase → id)
|
||||
title_map: dict[str, int] = {}
|
||||
for n in notes:
|
||||
if n.title:
|
||||
title_map[n.title.lower()] = n.id
|
||||
|
||||
# Build nodes
|
||||
nodes = []
|
||||
for n in notes:
|
||||
nodes.append({
|
||||
"id": n.id,
|
||||
"title": n.title or "(untitled)",
|
||||
"type": "task" if n.is_task else "note",
|
||||
"tags": n.tags or [],
|
||||
"project_id": n.project_id,
|
||||
"project_color": project_colors.get(n.project_id) if n.project_id else None,
|
||||
})
|
||||
|
||||
# Build wikilink edges
|
||||
edge_set: set[tuple[int, int]] = set()
|
||||
edges = []
|
||||
for n in notes:
|
||||
if not n.body:
|
||||
continue
|
||||
for match in _WIKILINK_RE.findall(n.body):
|
||||
target_id = title_map.get(match.strip().lower())
|
||||
if target_id is not None and target_id != n.id:
|
||||
pair = (n.id, target_id)
|
||||
if pair not in edge_set:
|
||||
edge_set.add(pair)
|
||||
edges.append({"source": n.id, "target": target_id, "type": "wikilink"})
|
||||
|
||||
# Build tag nodes + note→tag edges
|
||||
if include_shared_tags:
|
||||
tag_to_ids: dict[str, list[int]] = {}
|
||||
for n in notes:
|
||||
for tag in (n.tags or []):
|
||||
tag_to_ids.setdefault(tag, []).append(n.id)
|
||||
|
||||
for tag, note_ids in tag_to_ids.items():
|
||||
tag_node_id = f"tag:{tag}"
|
||||
nodes.append({
|
||||
"id": tag_node_id,
|
||||
"title": tag,
|
||||
"type": "tag",
|
||||
"tags": [],
|
||||
"project_id": None,
|
||||
"project_color": None,
|
||||
})
|
||||
for nid in note_ids:
|
||||
edges.append({"source": nid, "target": tag_node_id, "type": "tag"})
|
||||
|
||||
return {"nodes": nodes, "edges": edges}
|
||||
|
||||
Reference in New Issue
Block a user