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:
@@ -1,14 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import { useNotesStore, type Note } from "../stores/notes";
|
import { useNotesStore, type Note } from "../stores/notes";
|
||||||
import { NOTE_NODE_FILL, type NoteColor } from "../notes/colors";
|
import { NOTE_NODE_FILL, type NoteColor } from "../notes/colors";
|
||||||
import NoteEditor from "../components/NoteEditor.vue";
|
import NoteEditor from "../components/NoteEditor.vue";
|
||||||
|
|
||||||
|
type NodeKind = "note" | "label";
|
||||||
|
|
||||||
interface GNode {
|
interface GNode {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
kind: NodeKind;
|
||||||
|
labelId?: string;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
vx: number;
|
vx: number;
|
||||||
@@ -17,18 +22,23 @@ interface GNode {
|
|||||||
interface GEdge {
|
interface GEdge {
|
||||||
source: string;
|
source: string;
|
||||||
target: string;
|
target: string;
|
||||||
|
kind?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WIDTH = 1000;
|
const WIDTH = 1000;
|
||||||
const HEIGHT = 700;
|
const HEIGHT = 700;
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
const notes = useNotesStore();
|
const notes = useNotesStore();
|
||||||
const allNodes = ref<GNode[]>([]);
|
const allNodes = ref<GNode[]>([]);
|
||||||
const edges = ref<GEdge[]>([]);
|
const edges = ref<GEdge[]>([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const error = ref("");
|
const error = ref("");
|
||||||
const editing = ref<Note | null>(null);
|
const editing = ref<Note | null>(null);
|
||||||
const showAll = ref(false);
|
// Unlinked notes float in the space by default — the graph is a gentle overview,
|
||||||
|
// not a links-only surface. Label hubs are on by default so tags cluster notes.
|
||||||
|
const showAll = ref(true);
|
||||||
|
const showLabels = ref(true);
|
||||||
|
|
||||||
const svgRef = ref<SVGSVGElement | null>(null);
|
const svgRef = ref<SVGSVGElement | null>(null);
|
||||||
const gRef = ref<SVGGElement | null>(null);
|
const gRef = ref<SVGGElement | null>(null);
|
||||||
@@ -41,29 +51,39 @@ const ty = ref(0);
|
|||||||
let frame = 0;
|
let frame = 0;
|
||||||
let raf = 0;
|
let raf = 0;
|
||||||
|
|
||||||
|
// Label membership edges drop out when the label hubs are hidden.
|
||||||
|
const visibleEdges = computed(() =>
|
||||||
|
showLabels.value ? edges.value : edges.value.filter((e) => e.kind !== "label"),
|
||||||
|
);
|
||||||
|
|
||||||
const connectedIds = computed(() => {
|
const connectedIds = computed(() => {
|
||||||
const s = new Set<string>();
|
const s = new Set<string>();
|
||||||
for (const e of edges.value) {
|
for (const e of visibleEdges.value) {
|
||||||
s.add(e.source);
|
s.add(e.source);
|
||||||
s.add(e.target);
|
s.add(e.target);
|
||||||
}
|
}
|
||||||
return s;
|
return s;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Connected notes only by default; the toggle adds unlinked ones.
|
// Hide label hubs when toggled off; otherwise show everything (unlinked notes
|
||||||
|
// float too) unless "show unlinked" is off, in which case keep only connected nodes.
|
||||||
const activeNodes = computed(() =>
|
const activeNodes = computed(() =>
|
||||||
showAll.value ? allNodes.value : allNodes.value.filter((n) => connectedIds.value.has(n.id)),
|
allNodes.value.filter((n) => {
|
||||||
|
if (n.kind === "label" && !showLabels.value) return false;
|
||||||
|
if (showAll.value) return true;
|
||||||
|
return connectedIds.value.has(n.id);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
const activeIds = computed(() => new Set(activeNodes.value.map((n) => n.id)));
|
const activeIds = computed(() => new Set(activeNodes.value.map((n) => n.id)));
|
||||||
|
|
||||||
const edgeLines = computed(() => {
|
const edgeLines = computed(() => {
|
||||||
const byId = new Map(allNodes.value.map((n) => [n.id, n]));
|
const byId = new Map(allNodes.value.map((n) => [n.id, n]));
|
||||||
const out: { x1: number; y1: number; x2: number; y2: number }[] = [];
|
const out: { x1: number; y1: number; x2: number; y2: number; label: boolean }[] = [];
|
||||||
for (const e of edges.value) {
|
for (const e of visibleEdges.value) {
|
||||||
if (!activeIds.value.has(e.source) || !activeIds.value.has(e.target)) continue;
|
if (!activeIds.value.has(e.source) || !activeIds.value.has(e.target)) continue;
|
||||||
const s = byId.get(e.source);
|
const s = byId.get(e.source);
|
||||||
const t = byId.get(e.target);
|
const t = byId.get(e.target);
|
||||||
if (s && t) out.push({ x1: s.x, y1: s.y, x2: t.x, y2: t.y });
|
if (s && t) out.push({ x1: s.x, y1: s.y, x2: t.x, y2: t.y, label: e.kind === "label" });
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
@@ -76,9 +96,10 @@ async function loadGraph() {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
try {
|
try {
|
||||||
const res = await api.get<{ nodes: { id: string; title: string; color: string }[]; edges: GEdge[] }>(
|
const res = await api.get<{
|
||||||
"/api/graph",
|
nodes: { id: string; title: string; color: string; kind: NodeKind; label_id?: string }[];
|
||||||
);
|
edges: GEdge[];
|
||||||
|
}>("/api/graph");
|
||||||
const cx = WIDTH / 2;
|
const cx = WIDTH / 2;
|
||||||
const cy = HEIGHT / 2;
|
const cy = HEIGHT / 2;
|
||||||
const count = Math.max(res.nodes.length, 1);
|
const count = Math.max(res.nodes.length, 1);
|
||||||
@@ -88,6 +109,8 @@ async function loadGraph() {
|
|||||||
id: n.id,
|
id: n.id,
|
||||||
title: n.title,
|
title: n.title,
|
||||||
color: n.color,
|
color: n.color,
|
||||||
|
kind: n.kind,
|
||||||
|
labelId: n.label_id,
|
||||||
x: cx + Math.cos(angle) * 220 + (Math.random() - 0.5) * 40,
|
x: cx + Math.cos(angle) * 220 + (Math.random() - 0.5) * 40,
|
||||||
y: cy + Math.sin(angle) * 220 + (Math.random() - 0.5) * 40,
|
y: cy + Math.sin(angle) * 220 + (Math.random() - 0.5) * 40,
|
||||||
vx: 0,
|
vx: 0,
|
||||||
@@ -141,14 +164,16 @@ function simulate() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const e of edges.value) {
|
for (const e of visibleEdges.value) {
|
||||||
const s = byId.get(e.source);
|
const s = byId.get(e.source);
|
||||||
const t = byId.get(e.target);
|
const t = byId.get(e.target);
|
||||||
if (!s || !t) continue;
|
if (!s || !t) continue;
|
||||||
const dx = t.x - s.x;
|
const dx = t.x - s.x;
|
||||||
const dy = t.y - s.y;
|
const dy = t.y - s.y;
|
||||||
const d = Math.sqrt(dx * dx + dy * dy) || 0.01;
|
const d = Math.sqrt(dx * dx + dy * dy) || 0.01;
|
||||||
const diff = (d - 130) * 0.02;
|
// Label-membership springs sit a touch longer so hubs ring their notes.
|
||||||
|
const rest = e.kind === "label" ? 150 : 130;
|
||||||
|
const diff = (d - rest) * 0.02;
|
||||||
const fx = (dx / d) * diff;
|
const fx = (dx / d) * diff;
|
||||||
const fy = (dy / d) * diff;
|
const fy = (dy / d) * diff;
|
||||||
s.vx += fx;
|
s.vx += fx;
|
||||||
@@ -228,8 +253,17 @@ function onUp() {
|
|||||||
const moved = dragMoved;
|
const moved = dragMoved;
|
||||||
dragNode = null;
|
dragNode = null;
|
||||||
panning = false;
|
panning = false;
|
||||||
// A press without a drag is a click → open the note.
|
// A press without a drag is a click.
|
||||||
if (node && !moved) void openNode(node);
|
if (node && !moved) clickNode(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clickNode(n: GNode) {
|
||||||
|
// Label hub → jump to that label's board lens (one space, many lenses).
|
||||||
|
if (n.kind === "label" && n.labelId) {
|
||||||
|
void router.push(`/label/${n.labelId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void openNode(n);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onWheel(e: WheelEvent) {
|
function onWheel(e: WheelEvent) {
|
||||||
@@ -254,6 +288,11 @@ function toggleAll() {
|
|||||||
reheat();
|
reheat();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleLabels() {
|
||||||
|
showLabels.value = !showLabels.value;
|
||||||
|
reheat();
|
||||||
|
}
|
||||||
|
|
||||||
async function openNode(n: GNode) {
|
async function openNode(n: GNode) {
|
||||||
const found = notes.items.find((x) => x.id === n.id);
|
const found = notes.items.find((x) => x.id === n.id);
|
||||||
editing.value = found ?? (await notes.fetchOne(n.id));
|
editing.value = found ?? (await notes.fetchOne(n.id));
|
||||||
@@ -279,6 +318,10 @@ onBeforeUnmount(() => {
|
|||||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-3">
|
<div class="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||||
<h1 class="text-lg font-semibold">Graph</h1>
|
<h1 class="text-lg font-semibold">Graph</h1>
|
||||||
<div class="flex items-center gap-3 text-sm">
|
<div class="flex items-center gap-3 text-sm">
|
||||||
|
<label class="flex cursor-pointer items-center gap-1.5 text-neutral-600 dark:text-neutral-300">
|
||||||
|
<input type="checkbox" class="accent-brand" :checked="showLabels" @change="toggleLabels" />
|
||||||
|
Show labels
|
||||||
|
</label>
|
||||||
<label class="flex cursor-pointer items-center gap-1.5 text-neutral-600 dark:text-neutral-300">
|
<label class="flex cursor-pointer items-center gap-1.5 text-neutral-600 dark:text-neutral-300">
|
||||||
<input type="checkbox" class="accent-brand" :checked="showAll" @change="toggleAll" />
|
<input type="checkbox" class="accent-brand" :checked="showAll" @change="toggleAll" />
|
||||||
Show unlinked notes
|
Show unlinked notes
|
||||||
@@ -316,9 +359,10 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="activeNodes.length === 0" class="py-24 text-center">
|
<div v-else-if="activeNodes.length === 0" class="py-24 text-center">
|
||||||
<h2 class="text-lg font-semibold text-neutral-700 dark:text-neutral-200">No links yet</h2>
|
<h2 class="text-lg font-semibold text-neutral-700 dark:text-neutral-200">Nothing connected yet</h2>
|
||||||
<p class="mt-1 text-sm text-neutral-400">
|
<p class="mt-1 text-sm text-neutral-400">
|
||||||
Link notes with <span class="font-mono text-brand-700 dark:text-brand">[[Note title]]</span>, or
|
Link notes with <span class="font-mono text-brand-700 dark:text-brand">[[Note title]]</span>, add
|
||||||
|
<span class="font-mono text-brand-700 dark:text-brand">#tags</span>, or
|
||||||
<button type="button" class="text-brand-700 underline dark:text-brand" @click="toggleAll">
|
<button type="button" class="text-brand-700 underline dark:text-brand" @click="toggleAll">
|
||||||
show all notes
|
show all notes
|
||||||
</button>.
|
</button>.
|
||||||
@@ -346,10 +390,23 @@ onBeforeUnmount(() => {
|
|||||||
:x2="l.x2"
|
:x2="l.x2"
|
||||||
:y2="l.y2"
|
:y2="l.y2"
|
||||||
class="stroke-neutral-300 dark:stroke-neutral-700"
|
class="stroke-neutral-300 dark:stroke-neutral-700"
|
||||||
stroke-width="1.5"
|
:stroke-width="l.label ? 1 : 1.5"
|
||||||
|
:stroke-dasharray="l.label ? '3 3' : undefined"
|
||||||
/>
|
/>
|
||||||
<g v-for="n in activeNodes" :key="n.id" class="cursor-pointer" @mousedown="onNodeDown(n, $event)">
|
<g v-for="n in activeNodes" :key="n.id" class="cursor-pointer" @mousedown="onNodeDown(n, $event)">
|
||||||
|
<!-- Label hubs read as a larger ringed node so tags stand out from notes. -->
|
||||||
<circle
|
<circle
|
||||||
|
v-if="n.kind === 'label'"
|
||||||
|
:cx="n.x"
|
||||||
|
:cy="n.y"
|
||||||
|
r="12"
|
||||||
|
:fill="fill(n.color)"
|
||||||
|
fill-opacity="0.9"
|
||||||
|
class="stroke-neutral-50 dark:stroke-neutral-950"
|
||||||
|
stroke-width="3"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
v-else
|
||||||
:cx="n.x"
|
:cx="n.x"
|
||||||
:cy="n.y"
|
:cy="n.y"
|
||||||
r="8"
|
r="8"
|
||||||
@@ -359,9 +416,13 @@ onBeforeUnmount(() => {
|
|||||||
/>
|
/>
|
||||||
<text
|
<text
|
||||||
:x="n.x"
|
:x="n.x"
|
||||||
:y="n.y - 13"
|
:y="n.kind === 'label' ? n.y - 17 : n.y - 13"
|
||||||
text-anchor="middle"
|
text-anchor="middle"
|
||||||
class="fill-neutral-600 text-[12px] dark:fill-neutral-300"
|
:class="
|
||||||
|
n.kind === 'label'
|
||||||
|
? 'fill-neutral-700 text-[12px] font-semibold dark:fill-neutral-100'
|
||||||
|
: 'fill-neutral-600 text-[12px] dark:fill-neutral-300'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
{{ n.title }}
|
{{ n.title }}
|
||||||
</text>
|
</text>
|
||||||
|
|||||||
+48
-12
@@ -16,10 +16,21 @@ bp = Blueprint("graph", __name__, url_prefix="/api/graph")
|
|||||||
@bp.get("")
|
@bp.get("")
|
||||||
@login_required
|
@login_required
|
||||||
async def get_graph():
|
async def get_graph():
|
||||||
"""Wiki-link graph. Nodes are ALL of the owner's non-trashed notes (the frontend
|
"""Spatial view of the owner's non-trashed notes.
|
||||||
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
|
Nodes are two kinds:
|
||||||
note's normalized display_title — its explicit title or first body line)."""
|
- 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)
|
source = aliased(Note)
|
||||||
target = aliased(Note)
|
target = aliased(Note)
|
||||||
edge_stmt = (
|
edge_stmt = (
|
||||||
@@ -44,26 +55,51 @@ async def get_graph():
|
|||||||
if key in seen:
|
if key in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(key)
|
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.
|
# Note ↔ label membership: one row per (note, label) for the owner's
|
||||||
color_rows = (
|
# 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(
|
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)
|
.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)
|
.order_by(Label.name)
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
first_color: dict = {}
|
first_color: dict = {}
|
||||||
for note_id, color in color_rows:
|
label_nodes: dict = {}
|
||||||
first_color.setdefault(note_id, color)
|
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 = (
|
note_rows = (
|
||||||
await db.scalars(select(Note).where(Note.owner_id == g.user_id, Note.deleted_at.is_(None)))
|
await db.scalars(select(Note).where(Note.owner_id == g.user_id, Note.deleted_at.is_(None)))
|
||||||
).all()
|
).all()
|
||||||
nodes = [
|
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
|
for n in note_rows
|
||||||
]
|
]
|
||||||
|
nodes.extend(label_nodes.values())
|
||||||
return jsonify({"nodes": nodes, "edges": edges})
|
return jsonify({"nodes": nodes, "edges": edges})
|
||||||
|
|||||||
Reference in New Issue
Block a user