Graph: label hubs cluster notes; unlinked notes float by default
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:
+48
-12
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user