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:
2026-03-06 20:50:43 -05:00
parent b33aa25fb7
commit a8bb687349
7 changed files with 775 additions and 0 deletions
+13
View File
@@ -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)