/** * Obsidian-style force-directed graph view for the memory tree. * * Two modes: * - `tree` — sealed summary nodes connected by parent→child * - `contacts` — raw chunks linked to person entities they mention * * Layout: a tiny barycentric force simulation * - parent → child links pull connected nodes together * - all-pairs Coulomb repulsion pushes overlapping nodes apart * - centring force keeps the cloud anchored in the viewport * * Colour: in tree mode each level lights up in its own hue (mirroring the * Obsidian `path:L{n}` groups) with a soft glow on summary nodes; leaves * stay a quiet slate. * * Interaction: drag a node to reposition it, drag the background to pan, * scroll to zoom, and "Reset view" recentres. Click a summary node → * opens the matching `.md` file through the shared workspace path * command (skipped when the pointer was dragging). This keeps Memory * graph file actions on the same guarded contract as chat workspace links. * * Rendering: where WebGL is available we use a Pixi.js + d3-force canvas * ({@link PixiGraph}) — the same stack Obsidian's graph runs on, smooth * well past the 1000-node cap. Without WebGL (e.g. jsdom under test) it * falls back to a deterministic pure-SVG renderer with the same colours, * interactions and click/preview behaviour. */ import { type PointerEvent as ReactPointerEvent, type WheelEvent as ReactWheelEvent, useCallback, useEffect, useMemo, useReducer, useRef, useState, } from 'react'; import { useT } from '../../lib/i18n/I18nContext'; import { useAppSelector } from '../../store/hooks'; import { resolveTheme, type ThemeMode } from '../../store/themeSlice'; import { type GraphEdge, type GraphMode, type GraphNode } from '../../utils/tauriCommands'; import { openWorkspacePath, previewWorkspaceText } from '../../utils/tauriCommands/workspacePaths'; import Button from '../ui/Button'; import { CONTACT_COLOR, LEAF_COLOR, levelColor, nodeColor, nodeRadius, type SimTuning, SOURCE_COLOR, VIEWPORT_H, VIEWPORT_W, ZOOM_MAX, ZOOM_MIN, } from './memoryGraphLayout'; import { summaryWorkspacePath } from './memoryWorkspacePaths'; import { PixiGraph } from './PixiGraph'; import { seedSvgLayout } from './seedSvgLayout'; import { useSvgForceLayout, WORKER_SUPPORTED } from './useSvgForceLayout'; /** Use WebGL (Pixi) in production; fall back to SVG in test (jsdom). */ const HAS_WEBGL = typeof document !== 'undefined' && typeof document.createElement === 'function' && (() => { try { const c = document.createElement('canvas'); return !!(c.getContext('webgl2') || c.getContext('webgl')); } catch { return false; } })(); interface SimNode extends GraphNode { x: number; y: number; vx: number; vy: number; } interface SimState { sim: SimNode[]; edges: Array<[number, number]>; radii: number[]; alpha: number; } // Stable empties so the worker-layout effect's deps don't change every render // when there's no graph yet. const NO_NODES: SimNode[] = []; const NO_RADII: number[] = []; const NO_EDGES: Array<[number, number]> = []; // Stable centre the SVG worker layout settles around (matches the viewBox). const SVG_CENTER: readonly [number, number] = [VIEWPORT_W / 2, VIEWPORT_H / 2]; interface MemoryGraphProps { /** Pre-fetched summary / chunk / contact nodes. */ nodes: GraphNode[]; /** Explicit edges (only used in contacts mode). */ edges: GraphEdge[]; /** Which graph this is — drives colour palette + click behaviour. */ mode: GraphMode; /** Optional override for the empty-state message. */ emptyHint?: string; /** Optional label for the central hub node (WebGL path). Defaults to "Memory". */ rootLabel?: string; /** Fill the parent's height (full-screen) instead of the fixed 640px card. */ fill?: boolean; /** Initial auto-fit zoom. Lower = more zoomed out. Defaults to 0.17. */ fitScale?: number; /** Fit the whole graph tightly to the viewport instead of a fixed zoom. */ fitToBounds?: boolean; /** Draw an always-on text label under each node. */ showLabels?: boolean; /** * Optional force-simulation tuning applied on the WebGL (Pixi) path. Every * field defaults to identity, so omitting it preserves the standard layout; * the orchestration scale showcase passes higher repulsion / link length for * a wide fan-out. */ tuning?: SimTuning; /** * Fired exactly once when the graph's force layout first settles (SVG * worker `end`, the synchronous relax fallback, or the Pixi sim cooling). * A loading overlay (e.g. the Brain page) waits on this to reveal the graph. */ onReady?: () => void; } interface SummaryPreviewState { path: string; contents: string; truncated: boolean; error: string | null; } /** * Map a pointer's client coords into the SVG's viewBox coordinate space * (SVG fallback only). Returns null without a live CTM (e.g. jsdom) so the * pan/zoom handlers degrade to no-ops under test. */ function clientToViewBox( svg: SVGSVGElement | null, clientX: number, clientY: number ): { x: number; y: number } | null { if (!svg || typeof svg.getScreenCTM !== 'function') return null; const ctm = svg.getScreenCTM(); if (!ctm) return null; const inv = ctm.inverse(); return { x: inv.a * clientX + inv.c * clientY + inv.e, y: inv.b * clientX + inv.d * clientY + inv.f, }; } /** * Run the force simulation for `iterations` ticks. Mutates positions in * place so we can re-use the same buffer across renders. */ function relaxLayout(nodes: SimNode[], edges: Array<[number, number]>, iterations = 220): void { const REPULSION = 1800; const SPRING_K = 0.04; const SPRING_LEN = 60; const CENTER_K = 0.0025; const FRICTION = 0.85; const cx = VIEWPORT_W / 2; const cy = VIEWPORT_H / 2; for (let iter = 0; iter < iterations; iter++) { for (let i = 0; i < nodes.length; i++) { for (let j = i + 1; j < nodes.length; j++) { const a = nodes[i]; const b = nodes[j]; const dx = a.x - b.x; const dy = a.y - b.y; const dist2 = dx * dx + dy * dy + 0.01; const force = REPULSION / dist2; const dist = Math.sqrt(dist2); const fx = (dx / dist) * force; const fy = (dy / dist) * force; a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy; } } for (const [ai, bi] of edges) { const a = nodes[ai]; const b = nodes[bi]; const dx = b.x - a.x; const dy = b.y - a.y; const dist = Math.sqrt(dx * dx + dy * dy) + 0.01; const delta = dist - SPRING_LEN; const fx = (dx / dist) * delta * SPRING_K; const fy = (dy / dist) * delta * SPRING_K; a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy; } for (const n of nodes) { n.vx += (cx - n.x) * CENTER_K; n.vy += (cy - n.y) * CENTER_K; n.vx *= FRICTION; n.vy *= FRICTION; n.x += n.vx; n.y += n.vy; } } } export function MemoryGraph({ nodes, edges, mode, emptyHint, rootLabel, fill, fitScale, fitToBounds, showLabels, tuning, onReady, }: MemoryGraphProps) { const { t } = useT(); const themeMode = useAppSelector(state => state.theme?.mode ?? 'system') as ThemeMode; const isDark = resolveTheme(themeMode) === 'dark'; const [hovered, setHovered] = useState(null); // Fire `onReady` at most once across this component's lifetime. The latest // callback is held in a ref so `fireReady` stays stable (the SVG layout hook // depends on a stable `onSettled`, and the guard prevents refires on reheat). const onReadyRef = useRef(onReady); onReadyRef.current = onReady; const readyFiredRef = useRef(false); const fireReady = useCallback(() => { if (readyFiredRef.current) return; readyFiredRef.current = true; console.debug('[memory-graph] layout settled → onReady'); onReadyRef.current?.(); }, []); const [preview, setPreview] = useState(null); const [previewingPath, setPreviewingPath] = useState(null); const svgRef = useRef(null); // Pan / zoom transform applied to the graph group, plus the live drag // state. Node positions live in the memoised `sim` buffer and are // mutated in place during a node drag; `bumpTick` forces a re-render so // the moved node repaints without re-running the physics. const [view, setView] = useState({ tx: 0, ty: 0, scale: 1 }); const [, bumpTick] = useReducer((c: number) => c + 1, 0); const [grabbing, setGrabbing] = useState(false); // Bumped by "Reset view" — the Pixi renderer watches it to recentre. const [resetSignal, bumpReset] = useReducer((c: number) => c + 1, 0); // Flips true if Pixi fails to init at runtime → fall back to SVG even // though supportsWebGL() was true at module load. const [pixiFailed, setPixiFailed] = useState(false); const useWebGL = HAS_WEBGL && !pixiFailed; const dragRef = useRef< | { kind: 'node'; node: SimNode; dx: number; dy: number } | { kind: 'pan'; vbStartX: number; vbStartY: number; tx0: number; ty0: number } | null >(null); // True once the pointer moved during the current gesture — guards the // node click so a drag doesn't also open the summary file. const movedRef = useRef(false); // Halts the SVG layout worker once the user grabs a node/background, so its // streamed positions stop fighting the manual drag. Set after the hook below. const stopLayoutRef = useRef<() => void>(() => {}); // Set once the user grabs the camera, so the settle-time auto-fit doesn't // yank the view out from under them. const userInteractedRef = useRef(false); // Re-frame the SVG graph from "Reset view" (set after fitToView below). const fitRef = useRef<() => void>(() => {}); // Holds the current sim across renders; during the next build it still points // at the OUTGOING sim, whose nodes carry the latest live coordinates (the // worker / a drag mutate them in place) — read for position carry-over. const liveSimRef = useRef(null); const clientToGraph = useCallback( (clientX: number, clientY: number) => { const vb = clientToViewBox(svgRef.current, clientX, clientY); if (!vb) return null; return { x: (vb.x - view.tx) / view.scale, y: (vb.y - view.ty) / view.scale }; }, [view] ); const onNodePointerDown = useCallback( (e: ReactPointerEvent, n: SimNode) => { // Stop the background pan from also starting on this pointer down. e.stopPropagation(); movedRef.current = false; const g = clientToGraph(e.clientX, e.clientY); if (!g) return; (e.currentTarget as Element).setPointerCapture?.(e.pointerId); dragRef.current = { kind: 'node', node: n, dx: g.x - n.x, dy: g.y - n.y }; setGrabbing(true); }, [clientToGraph] ); const onBackgroundPointerDown = useCallback( (e: ReactPointerEvent) => { movedRef.current = false; const vb = clientToViewBox(svgRef.current, e.clientX, e.clientY); if (!vb) return; (e.currentTarget as Element).setPointerCapture?.(e.pointerId); dragRef.current = { kind: 'pan', vbStartX: vb.x, vbStartY: vb.y, tx0: view.tx, ty0: view.ty }; setGrabbing(true); }, [view] ); const onPointerMove = useCallback( (e: ReactPointerEvent) => { const d = dragRef.current; if (!d) return; // On the first real movement (not a plain click), hand the camera to the // user: freeze the worker layout and suppress the settle-time auto-fit. if (!movedRef.current) { stopLayoutRef.current(); userInteractedRef.current = true; } if (d.kind === 'node') { const g = clientToGraph(e.clientX, e.clientY); if (!g) return; d.node.x = g.x - d.dx; d.node.y = g.y - d.dy; movedRef.current = true; bumpTick(); } else { const vb = clientToViewBox(svgRef.current, e.clientX, e.clientY); if (!vb) return; movedRef.current = true; setView(v => ({ ...v, tx: d.tx0 + (vb.x - d.vbStartX), ty: d.ty0 + (vb.y - d.vbStartY) })); } }, [clientToGraph] ); const endDrag = useCallback(() => { dragRef.current = null; setGrabbing(false); }, []); const onWheelZoom = useCallback((e: ReactWheelEvent) => { const vb = clientToViewBox(svgRef.current, e.clientX, e.clientY); if (!vb) return; userInteractedRef.current = true; setView(v => { const factor = Math.exp(-e.deltaY * 0.0015); const scale = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, v.scale * factor)); // Keep the graph point under the cursor fixed while zooming. const gx = (vb.x - v.tx) / v.scale; const gy = (vb.y - v.ty) / v.scale; return { scale, tx: vb.x - gx * scale, ty: vb.y - gy * scale }; }); }, []); const resetView = useCallback(() => { // SVG re-frames the whole graph; the Pixi canvas listens on the reset // signal. Both are triggered so the button works in either path. userInteractedRef.current = false; fitRef.current(); bumpReset(); }, []); const openSummary = useCallback(async (node: GraphNode) => { const path = summaryWorkspacePath(node); if (!path) return; console.debug('[memory-graph] open workspace path=%s', path); try { await openWorkspacePath(path); } catch (err) { console.error('[memory-graph] openWorkspacePath failed', err); } }, []); const previewSummary = useCallback(async (node: GraphNode) => { const path = summaryWorkspacePath(node); if (!path) return; setPreviewingPath(path); try { const next = await previewWorkspaceText(path); setPreview({ path, contents: next.contents, truncated: next.truncated, error: null }); } catch (err) { console.error('[memory-graph] previewWorkspaceText failed', err); setPreview({ path, contents: '', truncated: false, error: err instanceof Error ? err.message : String(err), }); } finally { setPreviewingPath(null); } }, []); // Build edges + seed positions, carrying over the last positions of any // surviving node so a live data update doesn't reshuffle the whole graph // (seedSvgLayout). The O(n²) relax only runs as the no-worker fallback (test // env); the worker settles otherwise; the Pixi path runs its own sim. const sim = useMemo(() => { if (!nodes || nodes.length === 0) return null; // Snapshot the OUTGOING graph's live coordinates so survivors carry over // from where they actually are now — not a stale init/settle snapshot — // even mid-settle or after a drag. const prev = liveSimRef.current; const prevPos = new Map(); if (prev) for (const n of prev.sim) prevPos.set(n.id, { x: n.x, y: n.y }); const seed = seedSvgLayout(nodes, edges, mode, prevPos); const sim: SimNode[] = nodes.map((n, i) => ({ ...n, x: seed.positions[i].x, y: seed.positions[i].y, vx: 0, vy: 0, })); const radii = sim.map(n => nodeRadius(n)); if (!useWebGL && !WORKER_SUPPORTED) relaxLayout(sim, seed.edges); return { sim, edges: seed.edges, radii, alpha: seed.reheatAlpha }; }, [nodes, edges, mode, useWebGL]); // Becomes the "previous" sim on the next build (above). liveSimRef.current = sim; // Element refs for imperative position updates: while the worker streams // positions we write cx/cy (and line endpoints) straight to the DOM instead // of re-rendering up to 10k elements through React every frame. const circleEls = useRef<(SVGCircleElement | null)[]>([]); const lineEls = useRef<(SVGLineElement | null)[]>([]); const textEls = useRef<(SVGTextElement | null)[]>([]); // Progressive DOM mount for the SVG path: reveal nodes in per-frame batches // so a large graph never blocks building thousands of elements in one commit. // WebGL draws to one canvas, so it shows everything at once. const FIRST_BATCH = 800; const [svgVisible, setSvgVisible] = useState(() => sim ? Math.min(sim.sim.length, FIRST_BATCH) : 0 ); // Reset the reveal window + element refs during render when the graph data // changes (the recommended alternative to setState-in-effect). const simIdRef = useRef(sim); if (simIdRef.current !== sim) { simIdRef.current = sim; circleEls.current = []; lineEls.current = []; textEls.current = []; setSvgVisible(sim ? Math.min(sim.sim.length, FIRST_BATCH) : 0); } // Latest visible count read by the stable imperative applier without // re-subscribing the worker every render. const latestVisibleRef = useRef(svgVisible); latestVisibleRef.current = svgVisible; // Write current positions straight to the mounted SVG elements. const applyPositions = useCallback(() => { const s = liveSimRef.current; if (!s) return; const vis = latestVisibleRef.current; const ns = s.sim; for (let i = 0; i < vis && i < ns.length; i++) { const el = circleEls.current[i]; if (el) { el.setAttribute('cx', String(ns[i].x)); el.setAttribute('cy', String(ns[i].y)); } const tel = textEls.current[i]; if (tel) { tel.setAttribute('x', String(ns[i].x)); tel.setAttribute('y', String(ns[i].y + nodeRadius(ns[i]) + 12)); } } for (let e = 0; e < s.edges.length; e++) { const [ai, bi] = s.edges[e]; if (ai >= vis || bi >= vis) continue; const el = lineEls.current[e]; if (el) { el.setAttribute('x1', String(ns[ai].x)); el.setAttribute('y1', String(ns[ai].y)); el.setAttribute('x2', String(ns[bi].x)); el.setAttribute('y2', String(ns[bi].y)); } } }, []); // Frame the whole cloud in the viewport. d3-force spreads a large graph far // past the viewBox, so without this most nodes sit off-screen. Committed to // `view` state (single source) so pan/zoom keep working; called once the // worker settles, unless the user already grabbed the camera. const fitToView = useCallback(() => { const s = liveSimRef.current; if (!s || userInteractedRef.current) return; const ns = s.sim; if (ns.length === 0) return; // Center on the root node at a fixed comfortable zoom. if (fitToBounds) { let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; for (const n of ns) { const r = nodeRadius(n) + 8; minX = Math.min(minX, n.x - r); minY = Math.min(minY, n.y - r); maxX = Math.max(maxX, n.x + r); maxY = Math.max(maxY, n.y + r); } const w = Math.max(1, maxX - minX); const h = Math.max(1, maxY - minY); const scale = Math.min( ZOOM_MAX, Math.max(ZOOM_MIN, Math.min(VIEWPORT_W / w, VIEWPORT_H / h) * 0.92) ); const bx = (minX + maxX) / 2; const by = (minY + maxY) / 2; setView({ scale, tx: VIEWPORT_W / 2 - bx * scale, ty: VIEWPORT_H / 2 - by * scale }); return; } const root = ns.find(n => n.kind === 'root'); const cx = root?.x ?? 0; const cy = root?.y ?? 0; const scale = fitScale ?? 0.17; setView({ scale, tx: VIEWPORT_W / 2 - cx * scale, ty: VIEWPORT_H / 2 - cy * scale }); }, [fitScale, fitToBounds]); fitRef.current = fitToView; // Coalesce worker ticks to one DOM write per frame. const applyPendingRef = useRef(false); const scheduleApply = useCallback(() => { if (applyPendingRef.current) return; applyPendingRef.current = true; const run = () => { applyPendingRef.current = false; applyPositions(); }; if (typeof window.requestAnimationFrame === 'function') window.requestAnimationFrame(run); else run(); }, [applyPositions]); // Frame the graph then signal readiness once the worker layout cools. const onSvgSettled = useCallback(() => { fitToView(); fireReady(); }, [fitToView, fireReady]); // SVG fallback layout runs in a worker (off the main thread); positions // stream back and are applied imperatively. No-op on WebGL and where workers // are unavailable (the synchronous relaxLayout above covers that case). const svgLayout = useSvgForceLayout( !useWebGL && !!sim, sim?.sim ?? NO_NODES, sim?.radii ?? NO_RADII, sim?.edges ?? NO_EDGES, SVG_CENTER, sim?.alpha ?? 1, scheduleApply, onSvgSettled ); stopLayoutRef.current = svgLayout.stop; // Synchronous-layout path (no WebGL, no Worker — e.g. jsdom under test): // `relaxLayout` already ran inside the `sim` memo, so the graph is laid out // as soon as `sim` exists. Signal readiness on the next tick. useEffect(() => { if (useWebGL || WORKER_SUPPORTED || !sim) return; fireReady(); }, [useWebGL, sim, fireReady]); // Ramp the rest in per-frame batches (setState only inside the rAF callback). useEffect(() => { if (useWebGL || !sim) return; const total = sim.sim.length; if (total <= FIRST_BATCH || typeof window.requestAnimationFrame !== 'function') return; let raf = 0; const step = () => { setSvgVisible(c => { const next = Math.min(total, c + 1200); if (next < total) raf = window.requestAnimationFrame(step); return next; }); }; raf = window.requestAnimationFrame(step); return () => window.cancelAnimationFrame(raf); }, [useWebGL, sim]); if (nodes.length === 0) { return (
{emptyHint ?? (mode === 'contacts' ? t('graph.noContactMentions') : t('graph.noMemory'))}
); } if (!sim) return null; // Distinct legend rows for the active mode. Tree mode lists the levels // actually present (each lit in its own colour) plus a leaf row when // chunks are shown. const legend = mode === 'tree' ? [ ...(nodes.some(n => n.kind === 'source') ? [{ label: t('graph.source', 'Source'), color: SOURCE_COLOR }] : []), ...Array.from(new Set(nodes.filter(n => n.kind === 'summary').map(n => n.level ?? 0))) .sort((a, b) => a - b) .map(lvl => ({ label: `L${lvl}`, color: levelColor(lvl) })), ...(nodes.some(n => n.kind === 'chunk') ? [{ label: t('graph.document'), color: LEAF_COLOR }] : []), ] : [ { label: t('graph.document'), color: LEAF_COLOR }, { label: t('graph.contact'), color: CONTACT_COLOR }, ]; const hoveredSummaryPath = hovered?.kind === 'summary' ? summaryWorkspacePath(hovered) : null; return (
setHovered(null)}>
{nodes.length} {t('graph.nodes')} · {sim.edges.length}{' '} {mode === 'tree' ? t('graph.parentChild') : t('graph.documentContact')}{' '} {sim.edges.length === 1 ? t('graph.link') : t('graph.links')}
{legend.map(item => ( {item.label} ))}
{useWebGL ? ( { if (n.kind === 'summary') void openSummary(n); }} onError={() => setPixiFailed(true)} onReady={fireReady} /> ) : ( {/* Pan / zoom group — drag the background to pan, scroll to zoom. */} {sim.edges.map(([ai, bi], idx) => { // Only draw edges whose endpoints are both mounted yet. if (ai >= svgVisible || bi >= svgVisible) return null; const a = sim.sim[ai]; const b = sim.sim[bi]; return ( { lineEls.current[idx] = el; }} x1={a.x} y1={a.y} x2={b.x} y2={b.y} /> ); })} {sim.sim.slice(0, svgVisible).map((n, i) => { const r = nodeRadius(n); const fill = nodeColor(n); const isHover = hovered?.id === n.id; // Leaves stay flat; summary / contact nodes glow in their // own colour so the tree levels "light up". const glow = n.kind === 'chunk' ? undefined : `drop-shadow(0 0 ${isHover ? 7 : 4}px ${fill})`; return ( { circleEls.current[i] = el; }} cx={n.x} cy={n.y} r={isHover ? r + 2 : r} fill={fill} stroke={ isHover ? (isDark ? '#0f172a' : '#1e293b') : isDark ? '#ffffff' : '#e2e8f0' } strokeWidth={isHover ? 1.4 : 0.8} style={{ cursor: grabbing ? 'grabbing' : 'pointer', filter: glow }} onPointerDown={e => onNodePointerDown(e, n)} onMouseEnter={() => setHovered(n)} onClick={() => { // A drag ends with a click event too — skip the open // when the pointer actually moved. if (movedRef.current) return; if (n.kind === 'summary') void openSummary(n); }} data-testid={`memory-graph-node-${n.id}`}> {tooltipFor(n, t)} ); })} {showLabels && ( {sim.sim.slice(0, svgVisible).map((n, i) => { const label = (n.label ?? '').trim(); const text = label.length > 22 ? `${label.slice(0, 21)}…` : label; return ( { textEls.current[i] = el; }} x={n.x} y={n.y + nodeRadius(n) + 12} textAnchor="middle" fontSize={13} fill={isDark ? '#e2e8f0' : '#334155'} style={{ pointerEvents: 'none', userSelect: 'none' }}> {text} ); })} )} )} {hovered && (
{hovered.kind === 'root' ? ( {hovered.label} ) : hovered.kind === 'source' ? ( {hovered.label} ) : hovered.kind === 'summary' ? ( <> L{hovered.level ?? '?'} · {hovered.tree_kind} · {hovered.tree_scope} · {hovered.child_count ?? 0} {t('graph.children')} {hoveredSummaryPath && ( <> workspace:{hoveredSummaryPath} )} ) : hovered.kind === 'contact' ? ( <> {hovered.label} {t('graph.person')} · canonical id {hovered.id.slice(0, 12)}… ) : ( <> {hovered.label || 'chunk'} {t('graph.document')} )}
)} {preview && (
workspace:{preview.path}
            {preview.error || preview.contents}
            {preview.truncated ? '\n…' : ''}
          
)}
); } function tooltipFor(n: GraphNode, t: (key: string, fallback?: string) => string): string { if (n.kind === 'root') return n.label; if (n.kind === 'summary') return t('graph.tooltip.summary'); if (n.kind === 'contact') return t('graph.tooltip.contact'); return n.label || t('graph.document'); }