{ "name": "note-graph", "type": "registry:ui", "title": "Note Graph", "description": "A vault of linked notes laid out by force — the Obsidian-shaped view. Nodes are sized by how many links touch them, hovering dims everything outside the neighbourhood, and orphans are drawn hollow rather than hidden.", "category": "Knowledge", "dependencies": [], "registryDependencies": [ "lib-motion", "lib-styles", "lib-use-force-graph", "lib-utils" ], "files": [ { "path": "components/ui/note-graph.tsx", "type": "registry:ui", "content": "'use client'\n\nimport {\n useMemo,\n useRef,\n useState,\n type ComponentProps,\n type PointerEvent as ReactPointerEvent,\n} from 'react'\nimport { useForceGraph, type ForceLink } from '@/lib/use-force-graph'\nimport { enterFade } from '@/lib/motion'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A vault of linked notes, laid out by force.\n *\n * The Obsidian-shaped view: notes as circles, links as lines, and the thing you\n * are looking for found by shape rather than by name.\n *\n * **Size is degree, not recency or length.** A hub with thirty backlinks is the\n * note the vault is organised around, and it should be the one your eye lands\n * on. Sizing by word count or edit date produces a picture of your typing\n * habits instead of your structure.\n *\n * **Orphans are drawn, and drawn differently.** A note nothing links to is the\n * most actionable thing this view can surface — it is either miscategorised or\n * forgotten — so hiding unconnected nodes to tidy the picture removes its main\n * use. They sit in a ring at the edge, hollow.\n *\n * **Hovering dims everything except the neighbourhood.** At a few hundred nodes\n * the hairball is unreadable at rest; focus is what makes it legible, and it\n * costs one CSS class rather than a second layout.\n *\n * The layout stops once it settles — see `useForceGraph`. Dragging a note pins\n * it, which is how you pull a cluster apart to read it.\n */\nexport type Note = {\n id: string\n title: string\n /** Colours the node. A folder, a tag, whatever you group by. */\n group?: string\n /** Overrides the degree-derived size. */\n size?: number\n}\n\ntype NoteGraphProps = Omit, 'onSelect'> & {\n notes: Note[]\n links: ForceLink[]\n onSelect?: (note: Note) => void\n selectedId?: string\n height?: number | string\n /** Show every title, rather than only hubs and the hovered neighbourhood. */\n showAllLabels?: boolean\n /** Degree at or above which a title is always drawn. */\n labelFrom?: number\n /** Map a group to a CSS colour. */\n colorFor?: (group: string | undefined) => string\n orphanLabel?: string\n emptyLabel?: string\n label?: string\n}\n\nconst GROUP_COLORS = [\n 'var(--blue-soft-foreground)',\n 'var(--violet-soft-foreground)',\n 'var(--cyan-soft-foreground)',\n 'var(--amber-soft-foreground)',\n 'var(--green-soft-foreground)',\n]\n\nfunction NoteGraph({\n notes,\n links,\n onSelect,\n selectedId,\n height = 460,\n showAllLabels = false,\n labelFrom = 4,\n colorFor,\n orphanLabel = 'no links',\n emptyLabel = 'This vault has no notes.',\n label = 'Note graph',\n className,\n ...props\n}: NoteGraphProps) {\n const boxRef = useRef(null)\n const [size, setSize] = useState({ width: 720, height: 460 })\n const [hovered, setHovered] = useState(null)\n const dragging = useRef(null)\n\n const { positions, drag, release } = useForceGraph(notes, links, size)\n\n /** How many links touch each note, and who its neighbours are. */\n const { degree, neighbours } = useMemo(() => {\n const degree = new Map()\n const neighbours = new Map>()\n\n for (const note of notes) {\n degree.set(note.id, 0)\n neighbours.set(note.id, new Set())\n }\n for (const link of links) {\n degree.set(link.source, (degree.get(link.source) ?? 0) + 1)\n degree.set(link.target, (degree.get(link.target) ?? 0) + 1)\n neighbours.get(link.source)?.add(link.target)\n neighbours.get(link.target)?.add(link.source)\n }\n return { degree, neighbours }\n }, [notes, links])\n\n const groups = useMemo(\n () => [...new Set(notes.map((note) => note.group).filter(Boolean))] as string[],\n [notes],\n )\n\n const colour = (group: string | undefined) =>\n colorFor?.(group) ??\n (group ? GROUP_COLORS[groups.indexOf(group) % GROUP_COLORS.length] : 'var(--muted-foreground)')\n\n const at = useMemo(\n () => new Map(positions.map((position) => [position.id, position])),\n [positions],\n )\n\n /** Dimmed unless it is the hovered note or one of its neighbours. */\n const lit = (id: string) =>\n !hovered || hovered === id || neighbours.get(hovered)?.has(id) === true\n\n function pointerToBox(event: ReactPointerEvent) {\n const box = boxRef.current?.getBoundingClientRect()\n if (!box) return { x: 0, y: 0 }\n return { x: event.clientX - box.left, y: event.clientY - box.top }\n }\n\n return (\n {\n boxRef.current = node\n if (node) {\n const box = node.getBoundingClientRect()\n // Only on a real change, or this loops.\n if (Math.abs(box.width - size.width) > 1 || Math.abs(box.height - size.height) > 1) {\n setSize({ width: box.width, height: box.height })\n }\n }\n }}\n className={cn(enterFade, 'relative overflow-hidden', surface, radius.surface, className)}\n style={{ height }}\n onPointerMove={(event) => {\n if (!dragging.current) return\n const point = pointerToBox(event)\n drag(dragging.current, point.x, point.y)\n }}\n onPointerUp={() => {\n if (dragging.current) release(dragging.current)\n dragging.current = null\n }}\n onPointerLeave={() => {\n if (dragging.current) release(dragging.current)\n dragging.current = null\n setHovered(null)\n }}\n {...props}\n >\n {notes.length === 0 ? (\n

{emptyLabel}

\n ) : (\n \n \n {links.map((link, index) => {\n const a = at.get(link.source)\n const b = at.get(link.target)\n if (!a || !b) return null\n const shown = lit(link.source) && lit(link.target)\n\n return (\n \n )\n })}\n \n\n \n {notes.map((note) => {\n const position = at.get(note.id)\n if (!position) return null\n\n const links = degree.get(note.id) ?? 0\n // Degree, damped: a hub with thirty backlinks should read as\n // bigger than one with three without swamping the canvas.\n const r = note.size ?? Math.min(18, 4 + Math.sqrt(links) * 2.6)\n const orphan = links === 0\n const shown = lit(note.id)\n const selected = note.id === selectedId\n const labelled =\n showAllLabels || selected || hovered === note.id || links >= labelFrom\n\n return (\n {\n event.stopPropagation()\n dragging.current = note.id\n ;(event.target as Element).releasePointerCapture?.(event.pointerId)\n }}\n onPointerEnter={() => setHovered(note.id)}\n onClick={() => onSelect?.(note)}\n >\n \n {labelled && (\n \n {note.title}\n \n )}\n {/* One string: React cannot join multiple children into a . */}\n {`${note.title}${orphan ? ` — ${orphanLabel}` : ` — ${links}`}`}\n \n )\n })}\n \n \n )}\n\n {groups.length > 0 && (\n
    \n {groups.map((group) => (\n
  • \n \n {group}\n
  • \n ))}\n
\n )}\n \n )\n}\n\nexport { NoteGraph }\nexport type { NoteGraphProps }\n" } ] }