diff --git a/frontend/src/notes/colors.ts b/frontend/src/notes/colors.ts index 0fcdd47..95765f5 100644 --- a/frontend/src/notes/colors.ts +++ b/frontend/src/notes/colors.ts @@ -57,6 +57,21 @@ export const LABEL_CHIP_CLASSES: Record = { gray: "bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-200", }; +// Solid fills for graph nodes (SVG needs concrete colors, not Tailwind bg classes). +// Mid-tone hues read on both the light and dark graph background. +export const NOTE_NODE_FILL: Record = { + default: "#9ca3af", + red: "#ef4444", + orange: "#f97316", + yellow: "#f59e0b", + green: "#22c55e", + teal: "#14b8a6", + blue: "#3b82f6", + purple: "#a855f7", + pink: "#ec4899", + gray: "#6b7280", +}; + export const NOTE_COLOR_LABELS: Record = { default: "Default", red: "Red", diff --git a/frontend/src/views/GraphView.vue b/frontend/src/views/GraphView.vue index ffa6b6d..cf4ce55 100644 --- a/frontend/src/views/GraphView.vue +++ b/frontend/src/views/GraphView.vue @@ -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([]); +const allNodes = ref([]); const edges = ref([]); const loading = ref(true); const editing = ref(null); +const showAll = ref(false); + +const svgRef = ref(null); +const gRef = ref(null); + +// Pan/zoom applied to the inner . +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(); + 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); +});