/** * GraphView - Full-screen knowledge graph visualization. * * Aesthetic: "Scholarly Observatory" - A constellation map of knowledge * where documents are celestial bodies connected by gossamer threads. * The dark canvas evokes a planetarium, with nodes glowing like stars * and edges as faint stellar connections. */ import { AlertTriangleIcon, FilterIcon, HomeIcon, SparklesIcon, XIcon, ZoomInIcon, ZoomOutIcon, } from "lucide-react"; import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { Button } from "../components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "../components/ui/select"; import { apiFetch } from "../hooks/use-api"; import { cn } from "../lib/utils"; // Lazy load the heavy graph library const ForceGraph2D = lazy(() => import("react-force-graph-2d")); // ───────────────────────────────────────────────────────────────────────────── // Types // ───────────────────────────────────────────────────────────────────────────── interface GraphNode { id: string; uri: string; title: string | null; collection: string; relPath: string; degree: number; communityId?: string; } interface GraphReportNode { id: string; uri: string; title: string | null; collection: string; relPath: string; degree: number; communityId?: string; } interface GraphCommunity { id: string; label: string; size: number; edgeCount: number; density: number; topNodes: GraphReportNode[]; } interface GraphLink { source: string; target: string; type: "wiki" | "markdown" | "similar"; weight: number; confidence: "explicit" | "inferred" | "ambiguous" | "similarity"; audit: { resolution: | "exact-title" | "exact-path" | "path-fallback" | "ambiguous-fallback" | "similarity"; matchCount?: number; score?: number; }; } interface GraphMeta { collection: string | null; nodeLimit: number; edgeLimit: number; totalNodes: number; totalEdges: number; totalEdgesUnresolved: number; returnedNodes: number; returnedEdges: number; truncated: boolean; linkedOnly: boolean; includedSimilar: boolean; similarAvailable: boolean; similarTopK: number; similarTruncatedByComputeBudget: boolean; warnings: string[]; } interface GraphReport { hubs: GraphReportNode[]; bridgeCandidates: GraphReportNode[]; isolated: { total: number; examples: GraphReportNode[]; }; unresolvedLinks: { total: number; byType: { wiki: number; markdown: number; }; }; edgeTypes: { wiki: number; markdown: number; similar: number; }; edgeConfidence: { explicit: number; inferred: number; ambiguous: number; similarity: number; }; audit: { inferredEdges: number; ambiguousEdges: number; similarityEdges: number; }; communities: { total: number; algorithm: "deterministic-label-propagation"; skipped: boolean; assignments: Record; top: GraphCommunity[]; }; } interface GraphResponse { nodes: GraphNode[]; links: GraphLink[]; report: GraphReport; meta: GraphMeta; } interface CollectionInfo { name: string; } interface StatusResponse { collections: CollectionInfo[]; } interface PageProps { navigate: (to: string | number) => void; } // ───────────────────────────────────────────────────────────────────────────── // Theme colors (Scholarly Dusk palette) // ───────────────────────────────────────────────────────────────────────────── const COLORS = { // Node colors by collection - celestial palette nodeDefault: "#4db8a8", // Primary teal - main constellation nodeSimilar: "#d4a053", // Gold - similarity connections nodeHover: "#5ee3ce", // Bright teal - active star nodeSelected: "#f5d78e", // Warm gold - selected // Edge colors edgeWiki: "rgba(77, 184, 168, 0.4)", // Teal threads edgeMarkdown: "rgba(77, 184, 168, 0.25)", // Fainter teal edgeSimilar: "rgba(212, 160, 83, 0.3)", // Gold similarity edgeInferred: "rgba(124, 158, 178, 0.35)", edgeAmbiguous: "rgba(245, 215, 142, 0.55)", // Background canvas: "#050505", // Deep black - observatory darkness }; // Generate consistent colors for collections function getCollectionColor(collection: string): string { // Simple hash to pick from a curated palette const palette = [ "#4db8a8", // Teal "#d4a053", // Gold "#7c9eb2", // Slate blue "#a8c686", // Sage green "#c9a7c7", // Dusty lavender "#e2a76f", // Copper "#6ba3d6", // Sky blue "#b8a090", // Taupe ]; let hash = 0; for (let i = 0; i < collection.length; i++) { hash = ((hash << 5) - hash + collection.charCodeAt(i)) | 0; } return palette[Math.abs(hash) % palette.length]!; } function getCommunityColor(communityId: string | undefined): string | null { if (!communityId) return null; const palette = [ "#4db8a8", "#d4a053", "#6ba3d6", "#a8c686", "#c9a7c7", "#e2a76f", "#f5d78e", "#7c9eb2", ]; const index = Math.max(0, Number.parseInt(communityId.slice(1), 10) - 1); return palette[index % palette.length] ?? null; } // ───────────────────────────────────────────────────────────────────────────── // Loading & Empty States // ───────────────────────────────────────────────────────────────────────────── function GraphLoading() { return (
{/* Orbital animation */}

Mapping knowledge constellation...

Loading document connections

); } function GraphEmpty() { return (

No connections found

Documents haven't been linked yet. Add wiki links [[like this]]
or markdown links [like this](path.md) to see the graph.

); } // ───────────────────────────────────────────────────────────────────────────── // Truncation Warning Banner // ───────────────────────────────────────────────────────────────────────────── function TruncationBanner({ meta, onDismiss, }: { meta: GraphMeta; onDismiss: () => void; }) { if (!meta.truncated) return null; return (

Graph truncated

Showing {meta.returnedNodes.toLocaleString()} of{" "} {meta.totalNodes.toLocaleString()} nodes and{" "} {meta.returnedEdges.toLocaleString()} of{" "} {meta.totalEdges.toLocaleString()} edges. {meta.totalEdgesUnresolved > 0 && ( <> ({meta.totalEdgesUnresolved} unresolved links) )}

); } // ───────────────────────────────────────────────────────────────────────────── // Main Component // ───────────────────────────────────────────────────────────────────────────── export default function GraphView({ navigate }: PageProps) { // Graph data state const [graphData, setGraphData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // Collections for filter const [collections, setCollections] = useState([]); // Filter state const [selectedCollection, setSelectedCollection] = useState("_all"); const [includeSimilar, setIncludeSimilar] = useState(false); const [selectedCommunity, setSelectedCommunity] = useState("_all"); // UI state const [showTruncationBanner, setShowTruncationBanner] = useState(true); const [hoveredNode, setHoveredNode] = useState(null); // Graph ref for zoom controls - using any for dynamic import compatibility // biome-ignore lint: dynamic import typing const graphRef = useRef(null); const pointerScaleRef = useRef(1); const zoomTimeoutRef = useRef(null); const pointerTimeoutRef = useRef(null); const fetchSeqRef = useRef(0); const mountedRef = useRef(true); const clearZoomTimeouts = useCallback(() => { if (zoomTimeoutRef.current !== null) { clearTimeout(zoomTimeoutRef.current); zoomTimeoutRef.current = null; } if (pointerTimeoutRef.current !== null) { clearTimeout(pointerTimeoutRef.current); pointerTimeoutRef.current = null; } }, []); // Fetch collections on mount useEffect(() => { const fetchCollections = async () => { const { data } = await apiFetch("/api/status"); if (data?.collections) { setCollections(data.collections.map((c) => c.name)); } }; void fetchCollections(); }, []); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); // Fetch graph data const fetchGraph = useCallback(async () => { const fetchSeq = ++fetchSeqRef.current; setLoading(true); setError(null); setShowTruncationBanner(true); setSelectedCommunity("_all"); clearZoomTimeouts(); const params = new URLSearchParams(); if (selectedCollection !== "_all") { params.set("collection", selectedCollection); } if (includeSimilar) { params.set("includeSimilar", "true"); } params.set("limit", "600"); params.set("edgeLimit", "2200"); const url = `/api/graph?${params.toString()}`; const { data, error: fetchError } = await apiFetch(url); if (!mountedRef.current || fetchSeq !== fetchSeqRef.current) return; if (fetchError || !data) { setError(fetchError ?? "Failed to load graph"); setLoading(false); return; } setGraphData(data); setLoading(false); // Fit to view after data loads zoomTimeoutRef.current = window.setTimeout(() => { if (fetchSeq !== fetchSeqRef.current) return; const fg = graphRef.current; if (!fg) return; fg.zoomToFit(400); pointerTimeoutRef.current = window.setTimeout(() => { if (fetchSeq !== fetchSeqRef.current) return; const zoom = fg.zoom?.(); if (typeof zoom === "number") { pointerScaleRef.current = zoom; } }, 450); }, 100); }, [clearZoomTimeouts, selectedCollection, includeSimilar]); useEffect(() => { void fetchGraph(); }, [fetchGraph]); useEffect(() => () => clearZoomTimeouts(), [clearZoomTimeouts]); // Handle node click - navigate to document // biome-ignore lint: dynamic import typing const handleNodeClick = useCallback( (node: any) => { if (node?.uri) { navigate(`/doc?uri=${encodeURIComponent(node.uri)}`); } }, [navigate] ); // Zoom controls const handleZoomIn = () => { const fg = graphRef.current; if (fg) { const currentZoom = fg.zoom(); fg.zoom(currentZoom * 1.3, 200); } }; const handleZoomOut = () => { const fg = graphRef.current; if (fg) { const currentZoom = fg.zoom(); fg.zoom(currentZoom / 1.3, 200); } }; // Memoized graph data for force-graph (needs objects with x/y) const forceGraphData = useMemo(() => { if (!graphData) return { nodes: [], links: [] }; const visibleNodeIds = new Set( graphData.nodes .filter( (node) => selectedCommunity === "_all" || node.communityId === selectedCommunity ) .map((node) => node.id) ); return { nodes: graphData.nodes .filter((node) => visibleNodeIds.has(node.id)) .map((n) => ({ ...n, // Add computed properties for rendering color: getCommunityColor(n.communityId) ?? getCollectionColor(n.collection), size: Math.max(4, Math.min(20, 4 + Math.sqrt(n.degree) * 2)), })), links: graphData.links .filter( (link) => visibleNodeIds.has(link.source) && visibleNodeIds.has(link.target) ) .map((l) => ({ ...l, color: l.confidence === "ambiguous" ? COLORS.edgeAmbiguous : l.confidence === "inferred" ? COLORS.edgeInferred : l.type === "similar" ? COLORS.edgeSimilar : l.type === "wiki" ? COLORS.edgeWiki : COLORS.edgeMarkdown, })), }; }, [graphData, selectedCommunity]); // Node canvas rendering for custom aesthetics // biome-ignore lint: dynamic import typing const paintNode = useCallback( (node: any, ctx: CanvasRenderingContext2D, globalScale: number) => { if (node.x === undefined || node.y === undefined) return; const isHovered = hoveredNode?.id === node.id; const size = node.size / globalScale; const glowSize = size * 1.5; // Outer glow - subtle aurora effect if (isHovered) { ctx.beginPath(); const gradient = ctx.createRadialGradient( node.x, node.y, size * 0.5, node.x, node.y, glowSize * 2 ); gradient.addColorStop(0, "rgba(94, 227, 206, 0.4)"); gradient.addColorStop(1, "rgba(94, 227, 206, 0)"); ctx.fillStyle = gradient; ctx.arc(node.x, node.y, glowSize * 2, 0, 2 * Math.PI); ctx.fill(); } // Main node - celestial body ctx.beginPath(); ctx.arc(node.x, node.y, size, 0, 2 * Math.PI); ctx.fillStyle = isHovered ? COLORS.nodeHover : node.color; ctx.fill(); // Inner highlight - gives depth like a planet ctx.beginPath(); ctx.arc( node.x - size * 0.25, node.y - size * 0.25, size * 0.3, 0, 2 * Math.PI ); ctx.fillStyle = "rgba(255, 255, 255, 0.15)"; ctx.fill(); // Label on hover if (isHovered && node.title) { const label = node.title.length > 30 ? node.title.slice(0, 30) + "…" : node.title; const fontSize = 12 / globalScale; ctx.font = `${fontSize}px Georgia, serif`; ctx.textAlign = "center"; ctx.textBaseline = "top"; // Label background const labelWidth = ctx.measureText(label).width + 8 / globalScale; const labelHeight = fontSize * 1.5; ctx.fillStyle = "rgba(15, 17, 21, 0.9)"; ctx.fillRect( node.x - labelWidth / 2, node.y + size + 4 / globalScale, labelWidth, labelHeight ); // Label text ctx.fillStyle = "#ededed"; ctx.fillText(label, node.x, node.y + size + 6 / globalScale); } }, [hoveredNode] ); return (
{/* Subtle star field background */}
{/* Header toolbar - glass panel */}
{/* Left: Navigation */}
{graphData && !graphData.report.communities.skipped && graphData.report.communities.top.length > 0 && ( )}

Knowledge Graph

{/* Center: Filters */}
{/* Collection filter */}
{/* Similarity toggle - only show if available */} {graphData?.meta.similarAvailable && ( )}
{/* Right: Stats & zoom */}
{/* Stats badge */} {graphData && !loading && (
{graphData.nodes.length.toLocaleString()} {" "} nodes • {graphData.links.length.toLocaleString()} {" "} edges • {graphData.report.hubs[0]?.degree ?? 0} {" "} top degree • {graphData.report.unresolvedLinks.total.toLocaleString()} {" "} unresolved • {graphData.report.isolated.total.toLocaleString()} {" "} isolated • {graphData.report.audit.inferredEdges.toLocaleString()} {" "} inferred • {graphData.report.audit.ambiguousEdges.toLocaleString()} {" "} ambiguous • {graphData.report.communities.total.toLocaleString()} {" "} communities
)} {/* Zoom controls */}
{/* Truncation warning */} {graphData?.meta && showTruncationBanner && ( setShowTruncationBanner(false)} /> )} {graphData && !loading && !graphData.report.communities.skipped && graphData.report.communities.top.length > 0 && (

Communities

{graphData.report.communities.top.slice(0, 6).map((community) => ( ))}
)} {/* Graph canvas */}
{loading ? ( ) : error ? (

{error}

) : graphData && graphData.nodes.length === 0 ? ( ) : ( }> link.color} linkCurvature={0.1} linkDirectionalParticleColor={() => COLORS.nodeDefault} linkDirectionalParticles={ // Disable particles on large graphs for performance forceGraphData.links.length > 250 ? 0 : 1 } linkDirectionalParticleWidth={1.5} linkWidth={(link: any) => Math.max(0.5, Math.min(3, Math.sqrt(link.weight))) } nodeCanvasObject={paintNode} nodeLabel="" nodePointerAreaPaint={( node: any, color: string, ctx: CanvasRenderingContext2D ) => { if (node.x === undefined || node.y === undefined) return; const scale = pointerScaleRef.current || 1; ctx.fillStyle = color; ctx.beginPath(); ctx.arc( node.x, node.y, (node.size / scale) * 1.5, 0, 2 * Math.PI ); ctx.fill(); }} onNodeClick={handleNodeClick} onNodeHover={(node: any) => setHoveredNode(node)} onZoom={(transform: { k: number }) => { pointerScaleRef.current = transform.k; }} warmupTicks={20} ref={graphRef} /> )}
{/* Hovered node info panel */} {hoveredNode && (

{hoveredNode.title || hoveredNode.relPath}

{hoveredNode.collection}/{hoveredNode.relPath}

{hoveredNode.degree} connection{hoveredNode.degree !== 1 && "s"}

)} {/* Legend */}
Wiki links
Markdown links
{includeSimilar && (
Similarity
)}
Inferred
Ambiguous
); }