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
439 lines
13 KiB
Vue
439 lines
13 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
|
import { useRouter } from "vue-router";
|
|
import { api } from "../api/client";
|
|
import { useNotesStore, type Note } from "../stores/notes";
|
|
import { NOTE_NODE_FILL, type NoteColor } from "../notes/colors";
|
|
import NoteEditor from "../components/NoteEditor.vue";
|
|
|
|
type NodeKind = "note" | "label";
|
|
|
|
interface GNode {
|
|
id: string;
|
|
title: string;
|
|
color: string;
|
|
kind: NodeKind;
|
|
labelId?: string;
|
|
x: number;
|
|
y: number;
|
|
vx: number;
|
|
vy: number;
|
|
}
|
|
interface GEdge {
|
|
source: string;
|
|
target: string;
|
|
kind?: string;
|
|
}
|
|
|
|
const WIDTH = 1000;
|
|
const HEIGHT = 700;
|
|
|
|
const router = useRouter();
|
|
const notes = useNotesStore();
|
|
const allNodes = ref<GNode[]>([]);
|
|
const edges = ref<GEdge[]>([]);
|
|
const loading = ref(true);
|
|
const error = ref("");
|
|
const editing = ref<Note | null>(null);
|
|
// 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 gRef = ref<SVGGElement | null>(null);
|
|
|
|
// Pan/zoom applied to the inner <g>.
|
|
const scale = ref(1);
|
|
const tx = ref(0);
|
|
const ty = ref(0);
|
|
|
|
let frame = 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 s = new Set<string>();
|
|
for (const e of visibleEdges.value) {
|
|
s.add(e.source);
|
|
s.add(e.target);
|
|
}
|
|
return s;
|
|
});
|
|
|
|
// 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(() =>
|
|
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 edgeLines = computed(() => {
|
|
const byId = new Map(allNodes.value.map((n) => [n.id, n]));
|
|
const out: { x1: number; y1: number; x2: number; y2: number; label: boolean }[] = [];
|
|
for (const e of visibleEdges.value) {
|
|
if (!activeIds.value.has(e.source) || !activeIds.value.has(e.target)) continue;
|
|
const s = byId.get(e.source);
|
|
const t = byId.get(e.target);
|
|
if (s && t) out.push({ x1: s.x, y1: s.y, x2: t.x, y2: t.y, label: e.kind === "label" });
|
|
}
|
|
return out;
|
|
});
|
|
|
|
function fill(color: string): string {
|
|
return NOTE_NODE_FILL[color as NoteColor] ?? NOTE_NODE_FILL.default;
|
|
}
|
|
|
|
async function loadGraph() {
|
|
loading.value = true;
|
|
error.value = "";
|
|
try {
|
|
const res = await api.get<{
|
|
nodes: { id: string; title: string; color: string; kind: NodeKind; label_id?: string }[];
|
|
edges: GEdge[];
|
|
}>("/api/graph");
|
|
const cx = WIDTH / 2;
|
|
const cy = HEIGHT / 2;
|
|
const count = Math.max(res.nodes.length, 1);
|
|
allNodes.value = res.nodes.map((n, i) => {
|
|
const angle = (i / count) * Math.PI * 2;
|
|
return {
|
|
id: n.id,
|
|
title: n.title,
|
|
color: n.color,
|
|
kind: n.kind,
|
|
labelId: n.label_id,
|
|
x: cx + Math.cos(angle) * 220 + (Math.random() - 0.5) * 40,
|
|
y: cy + Math.sin(angle) * 220 + (Math.random() - 0.5) * 40,
|
|
vx: 0,
|
|
vy: 0,
|
|
};
|
|
});
|
|
edges.value = res.edges;
|
|
} catch (e) {
|
|
error.value = (e as { error?: string }).error ?? "Couldn't load the graph.";
|
|
allNodes.value = [];
|
|
edges.value = [];
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
reheat();
|
|
}
|
|
|
|
function reheat() {
|
|
frame = 0;
|
|
cancelAnimationFrame(raf);
|
|
raf = 0;
|
|
if (activeNodes.value.length) simulate();
|
|
}
|
|
|
|
function simulate() {
|
|
const list = activeNodes.value;
|
|
const cx = WIDTH / 2;
|
|
const cy = HEIGHT / 2;
|
|
const byId = new Map(list.map((n) => [n.id, n]));
|
|
|
|
for (let i = 0; i < list.length; i++) {
|
|
for (let j = i + 1; j < list.length; j++) {
|
|
const a = list[i];
|
|
const b = list[j];
|
|
let dx = a.x - b.x;
|
|
let dy = a.y - b.y;
|
|
let d2 = dx * dx + dy * dy;
|
|
if (d2 < 0.01) {
|
|
d2 = 0.01;
|
|
dx = Math.random();
|
|
dy = Math.random();
|
|
}
|
|
const d = Math.sqrt(d2);
|
|
const force = 6000 / d2;
|
|
const fx = (dx / d) * force;
|
|
const fy = (dy / d) * force;
|
|
a.vx += fx;
|
|
a.vy += fy;
|
|
b.vx -= fx;
|
|
b.vy -= fy;
|
|
}
|
|
}
|
|
|
|
for (const e of visibleEdges.value) {
|
|
const s = byId.get(e.source);
|
|
const t = byId.get(e.target);
|
|
if (!s || !t) continue;
|
|
const dx = t.x - s.x;
|
|
const dy = t.y - s.y;
|
|
const d = Math.sqrt(dx * dx + dy * dy) || 0.01;
|
|
// 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 fy = (dy / d) * diff;
|
|
s.vx += fx;
|
|
s.vy += fy;
|
|
t.vx -= fx;
|
|
t.vy -= fy;
|
|
}
|
|
|
|
for (const n of list) {
|
|
if (n === dragNode) {
|
|
n.vx = 0;
|
|
n.vy = 0;
|
|
continue; // pinned to the cursor while dragging
|
|
}
|
|
n.vx += (cx - n.x) * 0.002;
|
|
n.vy += (cy - n.y) * 0.002;
|
|
n.vx *= 0.85;
|
|
n.vy *= 0.85;
|
|
n.x += n.vx;
|
|
n.y += n.vy;
|
|
}
|
|
|
|
frame++;
|
|
// Keep running while cooling, or indefinitely while a node is being dragged.
|
|
raf = frame < 400 || dragNode ? requestAnimationFrame(simulate) : 0;
|
|
}
|
|
|
|
// --- pointer interaction: drag a node, pan the background, wheel-zoom ---
|
|
let dragNode: GNode | null = null;
|
|
let dragMoved = false;
|
|
let downPos = { x: 0, y: 0 };
|
|
let panning = false;
|
|
let panLast = { x: 0, y: 0 };
|
|
|
|
function toLocal(el: SVGGraphicsElement | null, e: MouseEvent) {
|
|
const ctm = el?.getScreenCTM();
|
|
if (!ctm) return { x: 0, y: 0 };
|
|
const p = new DOMPoint(e.clientX, e.clientY).matrixTransform(ctm.inverse());
|
|
return { x: p.x, y: p.y };
|
|
}
|
|
|
|
function onNodeDown(n: GNode, e: MouseEvent) {
|
|
e.stopPropagation();
|
|
dragNode = n;
|
|
dragMoved = false;
|
|
downPos = { x: e.clientX, y: e.clientY };
|
|
reheat();
|
|
window.addEventListener("mousemove", onMove);
|
|
window.addEventListener("mouseup", onUp);
|
|
}
|
|
|
|
function onBgDown(e: MouseEvent) {
|
|
panning = true;
|
|
panLast = toLocal(svgRef.value, e);
|
|
window.addEventListener("mousemove", onMove);
|
|
window.addEventListener("mouseup", onUp);
|
|
}
|
|
|
|
function onMove(e: MouseEvent) {
|
|
if (dragNode) {
|
|
if (Math.hypot(e.clientX - downPos.x, e.clientY - downPos.y) > 3) dragMoved = true;
|
|
const p = toLocal(gRef.value, e);
|
|
dragNode.x = p.x;
|
|
dragNode.y = p.y;
|
|
} else if (panning) {
|
|
const p = toLocal(svgRef.value, e);
|
|
tx.value += p.x - panLast.x;
|
|
ty.value += p.y - panLast.y;
|
|
panLast = p;
|
|
}
|
|
}
|
|
|
|
function onUp() {
|
|
window.removeEventListener("mousemove", onMove);
|
|
window.removeEventListener("mouseup", onUp);
|
|
const node = dragNode;
|
|
const moved = dragMoved;
|
|
dragNode = null;
|
|
panning = false;
|
|
// A press without a drag is a click.
|
|
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) {
|
|
e.preventDefault();
|
|
const vb = toLocal(svgRef.value, e);
|
|
const gx = (vb.x - tx.value) / scale.value;
|
|
const gy = (vb.y - ty.value) / scale.value;
|
|
const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
|
|
scale.value = Math.min(Math.max(scale.value * factor, 0.3), 3);
|
|
tx.value = vb.x - gx * scale.value;
|
|
ty.value = vb.y - gy * scale.value;
|
|
}
|
|
|
|
function resetView() {
|
|
scale.value = 1;
|
|
tx.value = 0;
|
|
ty.value = 0;
|
|
}
|
|
|
|
function toggleAll() {
|
|
showAll.value = !showAll.value;
|
|
reheat();
|
|
}
|
|
|
|
function toggleLabels() {
|
|
showLabels.value = !showLabels.value;
|
|
reheat();
|
|
}
|
|
|
|
async function openNode(n: GNode) {
|
|
const found = notes.items.find((x) => x.id === n.id);
|
|
editing.value = found ?? (await notes.fetchOne(n.id));
|
|
}
|
|
function closeEditor() {
|
|
editing.value = null;
|
|
}
|
|
async function onNavigate(id: string) {
|
|
const found = notes.items.find((x) => x.id === id);
|
|
editing.value = found ?? (await notes.fetchOne(id));
|
|
}
|
|
|
|
onMounted(loadGraph);
|
|
onBeforeUnmount(() => {
|
|
cancelAnimationFrame(raf);
|
|
window.removeEventListener("mousemove", onMove);
|
|
window.removeEventListener("mouseup", onUp);
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="flex h-full flex-col p-4">
|
|
<div class="mb-3 flex flex-wrap items-center justify-between gap-3">
|
|
<h1 class="text-lg font-semibold">Graph</h1>
|
|
<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">
|
|
<input type="checkbox" class="accent-brand" :checked="showAll" @change="toggleAll" />
|
|
Show unlinked notes
|
|
</label>
|
|
<button
|
|
type="button"
|
|
class="rounded-md border border-neutral-300 px-2 py-1 text-xs hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:hover:bg-neutral-800"
|
|
@click="resetView"
|
|
>
|
|
Reset view
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="loading" class="py-24 text-center text-sm text-neutral-400">Loading graph…</div>
|
|
|
|
<div v-else-if="error" class="py-24 text-center">
|
|
<h2 class="text-lg font-semibold text-neutral-700 dark:text-neutral-200">Couldn't load the graph</h2>
|
|
<p class="mt-1 text-sm text-neutral-400">{{ error }}</p>
|
|
<button
|
|
type="button"
|
|
class="mt-3 rounded-md border border-neutral-300 px-3 py-1.5 text-sm hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:hover:bg-neutral-800"
|
|
@click="loadGraph"
|
|
>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
|
|
<div v-else-if="allNodes.length === 0" class="py-24 text-center">
|
|
<h2 class="text-lg font-semibold text-neutral-700 dark:text-neutral-200">No notes yet</h2>
|
|
<p class="mt-1 text-sm text-neutral-400">
|
|
Create notes and link them with
|
|
<span class="font-mono text-brand-700 dark:text-brand">[[Note title]]</span> to see them here.
|
|
</p>
|
|
</div>
|
|
|
|
<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">Nothing connected yet</h2>
|
|
<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>, 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">
|
|
show all notes
|
|
</button>.
|
|
</p>
|
|
</div>
|
|
|
|
<div
|
|
v-else
|
|
class="min-h-[500px] flex-1 overflow-hidden rounded-xl border border-neutral-200 bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-950"
|
|
>
|
|
<svg
|
|
ref="svgRef"
|
|
:viewBox="`0 0 ${WIDTH} ${HEIGHT}`"
|
|
class="h-full w-full cursor-grab select-none touch-none"
|
|
preserveAspectRatio="xMidYMid meet"
|
|
@mousedown="onBgDown"
|
|
@wheel="onWheel"
|
|
>
|
|
<g ref="gRef" :transform="`translate(${tx} ${ty}) scale(${scale})`">
|
|
<line
|
|
v-for="(l, i) in edgeLines"
|
|
:key="`e${i}`"
|
|
:x1="l.x1"
|
|
:y1="l.y1"
|
|
:x2="l.x2"
|
|
:y2="l.y2"
|
|
class="stroke-neutral-300 dark:stroke-neutral-700"
|
|
: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)">
|
|
<!-- Label hubs read as a larger ringed node so tags stand out from notes. -->
|
|
<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"
|
|
:cy="n.y"
|
|
r="8"
|
|
:fill="fill(n.color)"
|
|
class="stroke-neutral-50 dark:stroke-neutral-950"
|
|
stroke-width="1.5"
|
|
/>
|
|
<text
|
|
:x="n.x"
|
|
:y="n.kind === 'label' ? n.y - 17 : n.y - 13"
|
|
text-anchor="middle"
|
|
: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 }}
|
|
</text>
|
|
</g>
|
|
</g>
|
|
</svg>
|
|
</div>
|
|
|
|
<template v-if="editing">
|
|
<NoteEditor :note="editing" @close="closeEditor" @navigate="onNavigate" />
|
|
</template>
|
|
</div>
|
|
</template>
|