import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Graph } from "@cosmos.gl/graph"; import { subscribeBrowseNodes, fetchRemotePage, type NetworkNode } from "@/api/client"; import { renderMicron } from "@/components/editor/micronRenderer"; import { useWindowManager } from "@/hooks/useWindowManager"; import { getThemeStatusColors } from "@/components/browse/graphColors"; import { buildGraphArrays, SPACE_SIZE } from "@/components/browse/buildGraph"; import type { BrowseWinData, HistoryEntry } from "@/components/browse/types"; import BrowseNodeWindow from "@/components/browse/BrowseNodeWindow"; import BrowseSearchBar from "@/components/browse/BrowseSearchBar"; import Loader from "@/components/shared/Loader"; // --------------------------------------------------------------------------- // Main component // --------------------------------------------------------------------------- export default function BrowseView() { const [nodes, setNodes] = useState([]); const [filter, setFilter] = useState(""); const { windows, focusedId: focusedWinId, open: openWindow, update: updateWindow, close: closeWindowById, focus: focusWindow } = useWindowManager(); const [hoveredLabel, setHoveredLabel] = useState<{ text: string; x: number; y: number } | null>(null); const [themeRev, setThemeRev] = useState(0); // Watch for theme changes (class on ) useEffect(() => { const observer = new MutationObserver(() => setThemeRev(r => r + 1)); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] }); return () => observer.disconnect(); }, []); const containerRef = useRef(null); const graphRef = useRef(null); const entriesRef = useRef([]); const nodesMapRef = useRef>(new Map()); const hashToIndexRef = useRef>(new Map()); const clusterNamesRef = useRef([]); // Build graph data, preserving existing positions const graphData = useMemo(() => { const graph = graphRef.current; const prevPositions = graph ? graph.getPointPositions() : []; return buildGraphArrays(nodes, hashToIndexRef.current, prevPositions, getThemeStatusColors()); // eslint-disable-next-line react-hooks/exhaustive-deps }, [nodes, themeRev]); useEffect(() => { const map = new Map(); for (const n of nodes) map.set(n.hash, n); nodesMapRef.current = map; entriesRef.current = graphData.entries; hashToIndexRef.current = graphData.hashToIndex; clusterNamesRef.current = graphData.clusterNames; }, [nodes, graphData]); // Search — matches + autocomplete suggestions const searchMatchIndices = useMemo(() => { if (!filter.trim()) return null; const q = filter.trim().toLowerCase(); const indices: number[] = []; graphData.entries.forEach((entry, i) => { if (entry.name.toLowerCase().includes(q)) indices.push(i); }); return indices.length > 0 ? indices : null; }, [filter, graphData]); const suggestions = useMemo(() => { if (!filter.trim()) return []; const q = filter.trim().toLowerCase(); return graphData.entries .filter(e => e.name.toLowerCase().includes(q)) .slice(0, 8); }, [filter, graphData]); // ── Cluster labels — direct DOM like the example's create-cluster-labels.ts ── const labelDivsRef = useRef([]); const updateClusterLabels = useCallback(() => { const graph = graphRef.current; const container = containerRef.current; if (!graph || !container) return; const positions = graph.getClusterPositions(); const names = clusterNamesRef.current; const nClusters = Math.min(names.length, positions.length / 2); // Rebuild label divs if count changed if (labelDivsRef.current.length !== nClusters) { labelDivsRef.current.forEach(d => d.remove()); labelDivsRef.current = []; for (let i = 0; i < nClusters; i++) { const div = document.createElement("div"); div.style.position = "absolute"; div.style.pointerEvents = "none"; div.style.whiteSpace = "nowrap"; div.style.transform = "translate(-50%, -100%)"; div.style.padding = "0"; div.style.background = "none"; div.style.border = "none"; div.style.color = "var(--foreground)"; div.style.fontFamily = "JetBrains Mono, monospace"; div.style.fontSize = "14px"; div.style.fontWeight = "700"; div.style.letterSpacing = "0.05em"; div.style.textTransform = "uppercase"; div.style.opacity = "0.85"; div.textContent = names[i] ?? ""; container.appendChild(div); labelDivsRef.current.push(div); } } for (let i = 0; i < nClusters; i++) { const x = positions[i * 2]; const y = positions[i * 2 + 1]; if (x === undefined || y === undefined) continue; const screen = graph.spaceToScreenPosition([x, y]); const div = labelDivsRef.current[i]!; div.style.left = `${screen[0]}px`; div.style.top = `${screen[1]}px`; } }, []); // ── Search highlighting labels ── const searchLabelDivsRef = useRef>(new Map()); const searchIndicesRef = useRef(null); const clearSearchLabels = useCallback(() => { searchLabelDivsRef.current.forEach(d => d.remove()); searchLabelDivsRef.current.clear(); searchIndicesRef.current = null; }, []); const updateSearchLabels = useCallback(() => { const graph = graphRef.current; const indices = searchIndicesRef.current; if (!graph || !indices || indices.length === 0) return; const positions = graph.getPointPositions(); const entries = entriesRef.current; const containerEl = containerRef.current; if (!containerEl) return; const rect = containerEl.getBoundingClientRect(); for (const idx of indices) { const x = positions[idx * 2]; const y = positions[idx * 2 + 1]; if (x === undefined || y === undefined) continue; const screen = graph.spaceToScreenPosition([x, y]); let div = searchLabelDivsRef.current.get(idx); if (!div) { div = document.createElement("div"); div.style.position = "fixed"; div.style.pointerEvents = "none"; div.style.padding = "2px 8px"; div.style.borderRadius = "4px"; div.style.background = "var(--popover)"; div.style.border = "1px solid var(--primary)"; div.style.color = "var(--primary)"; div.style.fontFamily = "JetBrains Mono, monospace"; div.style.fontWeight = "bold"; div.style.fontSize = "11px"; div.style.boxShadow = "0 2px 8px rgba(0,0,0,0.4)"; div.style.whiteSpace = "nowrap"; div.style.zIndex = "998"; div.textContent = entries[idx]?.name ?? ""; document.body.appendChild(div); searchLabelDivsRef.current.set(idx, div); } div.style.left = `${rect.left + screen[0] + 10}px`; div.style.top = `${rect.top + screen[1] - 8}px`; } }, []); // ── Init Cosmos ── useEffect(() => { if (!containerRef.current) return; const graph = new Graph(containerRef.current, { spaceSize: SPACE_SIZE, backgroundColor: "transparent", pointDefaultColor: "#888888", pointDefaultSize: 10, renderLinks: false, fitViewOnInit: true, fitViewDelay: 1500, fitViewPadding: 0.2, renderHoveredPointRing: true, hoveredPointRingColor: "#ffffff", scalePointsOnZoom: true, pointGreyoutOpacity: 0.1, simulationGravity: 0.5, simulationRepulsion: 1, simulationCluster: 0.5, simulationDecay: 5000, simulationFriction: 0.85, simulationLinkSpring: 0, simulationLinkDistance: 1, onClick: (index, _pos, _event) => { if (index === undefined) return; const entry = entriesRef.current[index]; if (entry && entry.type !== "interface") handleNodeClick(entry); }, onMouseMove: (index, pointPosition) => { if (index === undefined || !pointPosition || !graphRef.current) { setHoveredLabel(null); return; } const entry = entriesRef.current[index]; if (!entry) return; const screen = graphRef.current.spaceToScreenPosition(pointPosition); setHoveredLabel({ text: entry.name, x: screen[0], y: screen[1] }); }, onSimulationTick: () => { updateClusterLabels(); updateSearchLabels(); }, onSimulationEnd: () => { graphRef.current?.fitView(300, 0.2); updateClusterLabels(); updateSearchLabels(); }, onZoom: () => { updateClusterLabels(); updateSearchLabels(); }, }); graphRef.current = graph; return () => { labelDivsRef.current.forEach(d => d.remove()); labelDivsRef.current = []; graph.destroy(); graphRef.current = null; }; }, []); // ── Update graph data ── const isFirstLoadRef = useRef(true); useEffect(() => { const graph = graphRef.current; if (!graph || graphData.entries.length === 0) return; const n = graphData.entries.length; const scale = Math.log10(Math.max(10, n)) / Math.log10(10000); graph.setConfig({ simulationRepulsion: 0.5 + scale * 9.5, simulationCluster: 1.0 - scale * 0.75, simulationGravity: 0.25 + scale * 1.75, }); graph.setPointPositions(graphData.positions); graph.setPointColors(graphData.colors); graph.setPointSizes(graphData.sizes); graph.setPointClusters(graphData.clusterIndices); graph.setClusterPositions(graphData.clusterPositions); graph.setPointClusterStrength(graphData.clusterStrength); graph.setLinks(new Float32Array(0)); if (isFirstLoadRef.current) { graph.render(1); isFirstLoadRef.current = false; } else { graph.render(0.1); } updateClusterLabels(); }, [graphData]); // ── Search highlighting ── useEffect(() => { const graph = graphRef.current; if (!graph) return; clearSearchLabels(); if (searchMatchIndices) { searchIndicesRef.current = searchMatchIndices; graph.selectPointsByIndices(searchMatchIndices); if (searchMatchIndices.length <= 10) { graph.fitViewByPointIndices(searchMatchIndices, 500); } updateSearchLabels(); } else { graph.unselectPoints(); graph.fitView(300, 0.2); } return () => clearSearchLabels(); }, [searchMatchIndices, clearSearchLabels, updateSearchLabels]); // ── SSE stream ── useEffect(() => { let pending: NetworkNode[] = []; let batchTimer: ReturnType | null = null; const flush = () => { batchTimer = null; if (pending.length === 0) return; const batch = pending; pending = []; setNodes((prev) => { const map = new Map(prev.map((n) => [n.hash, n])); for (const node of batch) map.set(node.hash, node); return Array.from(map.values()); }); }; const unsub = subscribeBrowseNodes((node) => { pending.push(node); if (!batchTimer) batchTimer = setTimeout(flush, 200); }); return () => { unsub(); if (batchTimer) clearTimeout(batchTimer); flush(); }; }, []); // ── Navigation helpers ── const navigateTo = useCallback((winId: string, node: NetworkNode, path: string, prevData?: BrowseWinData) => { const loading: BrowseWinData = { node, pageHtml: null, pageLoading: true, pageError: null, currentPath: path, history: prevData?.history ?? [], historyIndex: prevData?.historyIndex ?? -1, }; updateWindow(winId, { data: loading }); fetchRemotePage(node.hash, path) .then((res) => { const html = res.content ? renderMicron(res.content, true) : null; const error = res.content ? null : (res.error ?? "No content"); const entry: HistoryEntry = { path, html, error }; const prevHistory = loading.history.slice(0, loading.historyIndex + 1); const newHistory = [...prevHistory, entry]; const newIndex = newHistory.length - 1; updateWindow(winId, { data: { node, pageHtml: html, pageError: error, pageLoading: false, currentPath: path, history: newHistory, historyIndex: newIndex } }); }) .catch((e) => { const error = String(e); const entry: HistoryEntry = { path, html: null, error }; const prevHistory = loading.history.slice(0, loading.historyIndex + 1); const newHistory = [...prevHistory, entry]; const newIndex = newHistory.length - 1; updateWindow(winId, { data: { node, pageError: error, pageLoading: false, pageHtml: null, currentPath: path, history: newHistory, historyIndex: newIndex } }); }); }, [updateWindow]); const navBack = useCallback((winId: string, data: BrowseWinData) => { const newIndex = data.historyIndex - 1; if (newIndex < 0) return; const entry = data.history[newIndex]!; updateWindow(winId, { data: { ...data, pageHtml: entry.html, pageError: entry.error, pageLoading: false, currentPath: entry.path, historyIndex: newIndex } }); }, [updateWindow]); const navForward = useCallback((winId: string, data: BrowseWinData) => { const newIndex = data.historyIndex + 1; if (newIndex >= data.history.length) return; const entry = data.history[newIndex]!; updateWindow(winId, { data: { ...data, pageHtml: entry.html, pageError: entry.error, pageLoading: false, currentPath: entry.path, historyIndex: newIndex } }); }, [updateWindow]); const navReload = useCallback((winId: string, data: BrowseWinData) => { navigateTo(winId, data.node, data.currentPath, { ...data, historyIndex: data.historyIndex - 1 }); }, [navigateTo]); // ── Node click ── const handleNodeClick = useCallback((node: NetworkNode) => { const id = node.hash; const initData: BrowseWinData = { node, pageHtml: null, pageLoading: true, pageError: null, currentPath: "index.mu", history: [], historyIndex: -1 }; openWindow(id, initData); navigateTo(id, node, "index.mu"); }, [openWindow, navigateTo]); // ── Handle micron link clicks via event delegation ── // // Micron link destinations come in several forms: // /page.mu — same-node, absolute path // page.mu — same-node, relative // :/page/page.mu — NomadNet "request" link (: prefix + /page/ segment) // <32-hex-hash>/page.mu — cross-node link // nomadnetwork://... — already stripped by micron-parser's data-destination const handleContentClick = useCallback((e: React.MouseEvent, winId: string, data: BrowseWinData) => { const anchor = (e.target as HTMLElement).closest("a"); if (!anchor) return; e.preventDefault(); const dest = anchor.getAttribute("data-destination") ?? anchor.getAttribute("href") ?? ""; if (!dest) return; let raw = dest .replace(/^nomadnetwork:\/\//, "") // strip scheme if present .replace(/^:/, "") // strip NomadNet request prefix .replace(/^\/page\//, "") // strip /page/ path segment .replace(/^\/+/, ""); // strip remaining leading slashes // A bare 32-char hex hash with no path — nothing to navigate to if (/^[0-9a-f]{32}$/i.test(raw)) return; // If the destination starts with a 32-char hex hash followed by "/", // it's a cross-node link: /page.mu → use that node's hash let targetNode = data.node; let path = raw; const crossNodeMatch = raw.match(/^([0-9a-f]{32})\/(.+)$/i); if (crossNodeMatch) { const targetHash = crossNodeMatch[1]!; path = crossNodeMatch[2]!; // Strip /page/ from the path portion too path = path.replace(/^\/page\//, "").replace(/^\/+/, ""); const known = nodesMapRef.current.get(targetHash); if (known) { targetNode = known; } } if (!path || path === "/") return; if (!path.endsWith(".mu")) path += ".mu"; navigateTo(winId, targetNode, path, data); }, [navigateTo]); const clearSearch = useCallback(() => { setFilter(""); graphRef.current?.unselectPoints(); graphRef.current?.fitView(300, 0.2); updateClusterLabels(); }, [updateClusterLabels]); return (
{/* Hover label */} {hoveredLabel && (
{hoveredLabel.text}
)} {nodes.length === 0 && (
Connecting...
)} {windows.map((win) => ( ))}
); }