graph: interactive liveliness (drag/pan/zoom, unlinked toggle, label colors)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 35s

Backend /api/graph now returns ALL non-trashed notes as nodes, each with its
first label's color; edges unchanged. GraphView reworked: drag a node to
reposition it (pins to cursor + reheats the sim), pan the background, wheel-
zoom toward the cursor, a "Show unlinked notes" toggle (connected-only by
default), a Reset view button, and nodes filled by label color. Click (a
press without a drag) still opens the note; re-fetches on view open.

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-20 22:40:28 -04:00
co-authored by Claude Opus 4.8
parent 27b09e18e3
commit 894dd4ac42
3 changed files with 253 additions and 53 deletions
+210 -42
View File
@@ -2,11 +2,13 @@
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
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";
interface GNode {
id: string;
title: string;
color: string;
x: number;
y: number;
vx: number;
@@ -21,26 +23,69 @@ const WIDTH = 1000;
const HEIGHT = 700;
const notes = useNotesStore();
const nodes = ref<GNode[]>([]);
const allNodes = ref<GNode[]>([]);
const edges = ref<GEdge[]>([]);
const loading = ref(true);
const editing = ref<Note | null>(null);
const showAll = ref(false);
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;
const connectedIds = computed(() => {
const s = new Set<string>();
for (const e of edges.value) {
s.add(e.source);
s.add(e.target);
}
return s;
});
// Connected notes only by default; the toggle adds unlinked ones.
const activeNodes = computed(() =>
showAll.value ? allNodes.value : allNodes.value.filter((n) => 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 }[] = [];
for (const e of edges.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 });
}
return out;
});
function fill(color: string): string {
return NOTE_NODE_FILL[color as NoteColor] ?? NOTE_NODE_FILL.default;
}
async function loadGraph() {
loading.value = true;
try {
const res = await api.get<{ nodes: { id: string; title: string }[]; edges: GEdge[] }>("/api/graph");
const res = await api.get<{ nodes: { id: string; title: string; color: string }[]; edges: GEdge[] }>(
"/api/graph",
);
const cx = WIDTH / 2;
const cy = HEIGHT / 2;
const count = Math.max(res.nodes.length, 1);
nodes.value = res.nodes.map((n, i) => {
allNodes.value = res.nodes.map((n, i) => {
const angle = (i / count) * Math.PI * 2;
return {
id: n.id,
title: n.title,
color: n.color,
x: cx + Math.cos(angle) * 220 + (Math.random() - 0.5) * 40,
y: cy + Math.sin(angle) * 220 + (Math.random() - 0.5) * 40,
vx: 0,
@@ -51,13 +96,18 @@ async function loadGraph() {
} finally {
loading.value = false;
}
reheat();
}
function reheat() {
frame = 0;
cancelAnimationFrame(raf);
if (nodes.value.length) simulate();
raf = 0;
if (activeNodes.value.length) simulate();
}
function simulate() {
const list = nodes.value;
const list = activeNodes.value;
const cx = WIDTH / 2;
const cy = HEIGHT / 2;
const byId = new Map(list.map((n) => [n.id, n]));
@@ -102,6 +152,11 @@ function simulate() {
}
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;
@@ -111,19 +166,87 @@ function simulate() {
}
frame++;
if (frame < 400) raf = requestAnimationFrame(simulate);
// Keep running while cooling, or indefinitely while a node is being dragged.
raf = frame < 400 || dragNode ? requestAnimationFrame(simulate) : 0;
}
const edgeLines = computed(() => {
const byId = new Map(nodes.value.map((n) => [n.id, n]));
const out: { x1: number; y1: number; x2: number; y2: number }[] = [];
for (const e of edges.value) {
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 });
// --- 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;
}
return out;
});
}
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 → open the note.
if (node && !moved) void openNode(node);
}
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();
}
async function openNode(n: GNode) {
const found = notes.items.find((x) => x.id === n.id);
@@ -138,20 +261,49 @@ async function onNavigate(id: string) {
}
onMounted(loadGraph);
onBeforeUnmount(() => cancelAnimationFrame(raf));
onBeforeUnmount(() => {
cancelAnimationFrame(raf);
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
});
</script>
<template>
<div class="flex h-full flex-col p-4">
<h1 class="mb-3 text-lg font-semibold">Graph</h1>
<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="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="nodes.length === 0" class="py-24 text-center">
<h2 class="text-lg font-semibold text-neutral-700 dark:text-neutral-200">No connections yet</h2>
<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">
Link notes with <span class="font-mono text-brand-700 dark:text-brand">[[Note title]]</span> to see them
connected here.
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">No links 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>, or
<button type="button" class="text-brand-700 underline dark:text-brand" @click="toggleAll">
show all notes
</button>.
</p>
</div>
@@ -159,27 +311,43 @@ onBeforeUnmount(() => cancelAnimationFrame(raf));
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 :viewBox="`0 0 ${WIDTH} ${HEIGHT}`" class="h-full w-full" preserveAspectRatio="xMidYMid meet">
<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="1.5"
/>
<g v-for="n in nodes" :key="n.id" class="cursor-pointer" @click="openNode(n)">
<circle :cx="n.x" :cy="n.y" r="8" class="fill-brand" />
<text
:x="n.x"
:y="n.y - 13"
text-anchor="middle"
class="fill-neutral-600 text-[12px] dark:fill-neutral-300"
>
{{ n.title }}
</text>
<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="1.5"
/>
<g v-for="n in activeNodes" :key="n.id" class="cursor-pointer" @mousedown="onNodeDown(n, $event)">
<circle
: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.y - 13"
text-anchor="middle"
class="fill-neutral-600 text-[12px] dark:fill-neutral-300"
>
{{ n.title }}
</text>
</g>
</g>
</svg>
</div>