Graph: label hubs cluster notes; unlinked notes float by default
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Build & push image (push) Successful in 35s

Add labels as first-class nodes in the graph so tags act as visual
clustering hubs, and let unlinked notes float in the space by default —
per operator request (the graph is a light auxiliary lens, not a focal
surface).

Backend (graph.py):
- Emit a label-hub node (id "label:<uuid>", kind "label", #name, label
  color) for every label attached to a live note.
- Emit note -> label membership edges (kind "label") alongside the
  existing wiki-link edges (now kind "link").

Frontend (GraphView.vue):
- Render hubs as larger ringed nodes; membership edges dashed with a
  slightly longer spring rest so notes ring their hub.
- Default showAll (unlinked notes float) to true; add a Show labels
  toggle (default on) that hides hubs + membership edges.
- Click a label hub -> that label's board lens (/label/<id>); note
  clicks still open the editor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
2026-07-22 21:16:16 -04:00
co-authored by Claude Opus 4.8
parent cc828559ff
commit 1417479729
2 changed files with 128 additions and 31 deletions
+48 -12
View File
@@ -16,10 +16,21 @@ bp = Blueprint("graph", __name__, url_prefix="/api/graph")
@bp.get("")
@login_required
async def get_graph():
"""Wiki-link graph. Nodes are ALL of the owner's non-trashed notes (the frontend
toggles whether to show unlinked ones); each carries its first label's color for
clustering. Edges are resolved [[links]] (note_links.target_norm matched to a
note's normalized display_title — its explicit title or first body line)."""
"""Spatial view of the owner's non-trashed notes.
Nodes are two kinds:
- notes (kind="note") — every non-trashed note; each carries its first
label's color for tinting.
- labels (kind="label", id "label:<uuid>") — every label actually attached
to a live note, acting as a clustering HUB so tagged notes gravitate
together even without wiki-links between them.
Edges are two kinds:
- wiki-links (kind="link") — resolved [[links]] (note_links.target_norm
matched to a note's normalized display_title).
- membership (kind="label") — each note → each of its label hubs.
The frontend toggles labels + unlinked notes; the graph is a light auxiliary
lens, not a focal surface.
"""
source = aliased(Note)
target = aliased(Note)
edge_stmt = (
@@ -44,26 +55,51 @@ async def get_graph():
if key in seen:
continue
seen.add(key)
edges.append({"source": str(src_id), "target": str(tgt_id)})
edges.append({"source": str(src_id), "target": str(tgt_id), "kind": "link"})
# First label color per note (labels ordered by name) → node color.
color_rows = (
# Note ↔ label membership: one row per (note, label) for the owner's
# non-trashed notes. Drives both the note-color tint (first label by name)
# and the label-hub nodes + membership edges.
label_rows = (
await db.execute(
select(NoteLabel.note_id, Label.color)
select(NoteLabel.note_id, Label.id, Label.name, Label.color)
.join(Label, Label.id == NoteLabel.label_id)
.where(Label.owner_id == g.user_id)
.join(Note, Note.id == NoteLabel.note_id)
.where(
Label.owner_id == g.user_id,
Note.owner_id == g.user_id,
Note.deleted_at.is_(None),
)
.order_by(Label.name)
)
).all()
first_color: dict = {}
for note_id, color in color_rows:
first_color.setdefault(note_id, color)
label_nodes: dict = {}
for note_id, label_id, label_name, label_color in label_rows:
first_color.setdefault(note_id, label_color)
hub_id = f"label:{label_id}"
if hub_id not in label_nodes:
label_nodes[hub_id] = {
"id": hub_id,
"title": f"#{label_name}",
"color": label_color or "default",
"kind": "label",
"label_id": str(label_id),
}
edges.append({"source": str(note_id), "target": hub_id, "kind": "label"})
note_rows = (
await db.scalars(select(Note).where(Note.owner_id == g.user_id, Note.deleted_at.is_(None)))
).all()
nodes = [
{"id": str(n.id), "title": n.display_title or "Untitled", "color": first_color.get(n.id, "default")}
{
"id": str(n.id),
"title": n.display_title or "Untitled",
"color": first_color.get(n.id, "default"),
"kind": "note",
}
for n in note_rows
]
nodes.extend(label_nodes.values())
return jsonify({"nodes": nodes, "edges": edges})