import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Graph } from "@cosmos.gl/graph"; import { fetchBrowseNodes, fetchRemotePage, type NetworkNode } from "@/api/client"; import { renderMicron } from "@/components/editor/micronRenderer"; import axisMundiUrl from "@/assets/axis-mundi.min.svg"; // --------------------------------------------------------------------------- // Theme color maps — hex values matching index.css OKLCH definitions // --------------------------------------------------------------------------- interface ThemeColors { primary: string; muted: string; border: string; } const THEME_COLORS: Record = { dark: { // .dark (terra) primary: "#c47a32", muted: "#8a7560", border: "#6b5a42", }, azure: { // .theme-azure primary: "#5aa0d4", muted: "#6d8a9e", border: "#4a6e88", }, }; function getThemeId(): string { const cl = document.documentElement.classList; if (cl.contains("theme-azure")) return "azure"; return "dark"; } function hexToRgba255(hex: string): [number, number, number, number] { return [ parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16), 255, ]; } // --------------------------------------------------------------------------- // Data builders — convert NetworkNode[] to Float32Arrays for cosmos.gl // --------------------------------------------------------------------------- function buildBuffers(nodes: NetworkNode[], colors: ThemeColors) { const sorted = [...nodes].sort((a, b) => (a.is_self ? -1 : b.is_self ? 1 : 0)); const n = sorted.length; const positions = new Float32Array(n * 2); const pointColors = new Float32Array(n * 4); const sizes = new Float32Array(n); const primaryRgba = hexToRgba255(colors.primary); const mutedRgba = hexToRgba255(colors.muted); for (let i = 0; i < n; i++) { if (sorted[i].is_self) { positions[i * 2] = 0; positions[i * 2 + 1] = 0; } else { const angle = ((i - 1) / Math.max(1, n - 1)) * Math.PI * 2; positions[i * 2] = Math.cos(angle) * 100; positions[i * 2 + 1] = Math.sin(angle) * 100; } const rgba = sorted[i].is_self ? primaryRgba : mutedRgba; pointColors[i * 4] = rgba[0]; pointColors[i * 4 + 1] = rgba[1]; pointColors[i * 4 + 2] = rgba[2]; pointColors[i * 4 + 3] = rgba[3]; sizes[i] = sorted[i].is_self ? 14 : 7; } const linkCount = Math.max(0, n - 1); const links = new Float32Array(linkCount * 2); const borderRgba = hexToRgba255(colors.border); const linkColors = new Float32Array(linkCount * 4); for (let i = 0; i < linkCount; i++) { links[i * 2] = 0; links[i * 2 + 1] = i + 1; linkColors[i * 4] = borderRgba[0]; linkColors[i * 4 + 1] = borderRgba[1]; linkColors[i * 4 + 2] = borderRgba[2]; linkColors[i * 4 + 3] = 180; } return { sorted, positions, pointColors, sizes, links, linkColors }; } /** Re-apply theme colors to an existing graph instance */ function applyThemeToGraph(graph: Graph, nodes: NetworkNode[], colors: ThemeColors) { graph.setConfig({ pointDefaultColor: colors.primary, linkDefaultColor: colors.border, hoveredPointRingColor: colors.primary, }); const n = nodes.length; if (n === 0) return; const pointColors = new Float32Array(n * 4); const primaryRgba = hexToRgba255(colors.primary); const mutedRgba = hexToRgba255(colors.muted); for (let i = 0; i < n; i++) { const rgba = nodes[i].is_self ? primaryRgba : mutedRgba; pointColors[i * 4] = rgba[0]; pointColors[i * 4 + 1] = rgba[1]; pointColors[i * 4 + 2] = rgba[2]; pointColors[i * 4 + 3] = rgba[3]; } graph.setPointColors(pointColors); const linkCount = Math.max(0, n - 1); if (linkCount > 0) { const borderRgba = hexToRgba255(colors.border); const linkColors = new Float32Array(linkCount * 4); for (let i = 0; i < linkCount; i++) { linkColors[i * 4] = borderRgba[0]; linkColors[i * 4 + 1] = borderRgba[1]; linkColors[i * 4 + 2] = borderRgba[2]; linkColors[i * 4 + 3] = 180; } graph.setLinkColors(linkColors); } graph.render(); } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export default function BrowseView() { const [nodes, setNodes] = useState([]); const [filter, setFilter] = useState(""); const [selectedNode, setSelectedNode] = useState(null); const [pageHtml, setPageHtml] = useState(null); const [pageLoading, setPageLoading] = useState(false); const [pageError, setPageError] = useState(null); const [labelPositions, setLabelPositions] = useState<{ x: number; y: number }[]>([]); const [themeId, setThemeId] = useState(getThemeId); const containerRef = useRef(null); const graphRef = useRef(null); const nodesRef = useRef([]); const pollRef = useRef | null>(null); const rafRef = useRef(0); const colors = THEME_COLORS[themeId] ?? THEME_COLORS.dark; const filteredNodes = useMemo(() => { if (!filter) return nodes; const q = filter.toLowerCase(); return nodes.filter( (n) => n.name.toLowerCase().includes(q) || n.hash.toLowerCase().includes(q), ); }, [nodes, filter]); // ── Watch for theme changes ── useEffect(() => { const observer = new MutationObserver(() => { const id = getThemeId(); setThemeId(id); const graph = graphRef.current; if (graph) { const c = THEME_COLORS[id] ?? THEME_COLORS.dark; applyThemeToGraph(graph, nodesRef.current, c); } }); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] }); return () => observer.disconnect(); }, []); // ── Initialize cosmos.gl graph ── useEffect(() => { if (!containerRef.current) return; const graph = new Graph(containerRef.current, { backgroundColor: [0, 0, 0, 0], pointDefaultColor: colors.primary, pointDefaultSize: 12, linkDefaultColor: colors.border, linkDefaultWidth: 1, linkOpacity: 0.5, enableSimulation: true, enableDrag: true, enableZoom: true, fitViewOnInit: false, spaceSize: 1024, simulationGravity: 0.15, simulationRepulsion: 0.6, simulationLinkSpring: 0.3, simulationLinkDistance: 60, simulationFriction: 0.85, simulationDecay: 8000, renderHoveredPointRing: true, hoveredPointRingColor: colors.primary, hoveredPointCursor: "pointer", onPointClick: (index: number) => { const node = nodesRef.current[index]; if (node) handleNodeClick(node); }, onClick: () => { setSelectedNode(null); setPageHtml(null); setPageError(null); }, onSimulationTick: () => updateLabels(), onZoom: () => updateLabels(), }); graphRef.current = graph; return () => { cancelAnimationFrame(rafRef.current); graph.destroy(); graphRef.current = null; }; }, []); // ── Update labels from graph positions ── const updateLabels = useCallback(() => { const graph = graphRef.current; if (!graph || nodesRef.current.length === 0) return; const positions = graph.getPointPositions(); const next: { x: number; y: number }[] = []; for (let i = 0; i < nodesRef.current.length; i++) { const sx = positions[i * 2]; const sy = positions[i * 2 + 1]; if (sx === undefined) break; const [px, py] = graph.spaceToScreenPosition([sx, sy]); next.push({ x: px, y: py }); } setLabelPositions(next); }, []); // ── Feed node data into graph when nodes change ── useEffect(() => { const graph = graphRef.current; if (!graph) return; if (filteredNodes.length === 0) { nodesRef.current = []; setLabelPositions([]); graph.setPointPositions(new Float32Array(0)); graph.setPointColors(new Float32Array(0)); graph.setPointSizes(new Float32Array(0)); graph.setLinks(new Float32Array(0)); graph.setLinkColors(new Float32Array(0)); graph.render(); return; } const c = THEME_COLORS[getThemeId()] ?? THEME_COLORS.dark; const { sorted, positions, pointColors, sizes, links, linkColors } = buildBuffers(filteredNodes, c); nodesRef.current = sorted; graph.setPointPositions(positions); graph.setPointColors(pointColors); graph.setPointSizes(sizes); if (links.length > 0) { graph.setLinks(links); graph.setLinkColors(linkColors); } graph.setPinnedPoints([0]); graph.render(); graph.start(); setTimeout(() => { graph.fitView(400, 0.4); updateLabels(); }, 200); }, [filteredNodes, updateLabels]); // ── Poll for nodes ── useEffect(() => { const load = () => { fetchBrowseNodes().then(setNodes).catch(() => { }); }; load(); pollRef.current = setInterval(load, 30_000); return () => { if (pollRef.current) clearInterval(pollRef.current); }; }, []); // ── Node click → fetch page ── const handleNodeClick = useCallback((node: NetworkNode) => { setSelectedNode(node); setPageHtml(null); setPageError(null); setPageLoading(true); fetchRemotePage(node.hash) .then((res) => { if (res.content) { setPageHtml(renderMicron(res.content, true)); } else { setPageError(res.error ?? "No content"); } }) .catch((e) => setPageError(String(e))) .finally(() => setPageLoading(false)); }, []); return (
{/* Header */}

Browse

setFilter(e.target.value)} placeholder="Filter nodes..." className="flex-1 h-8 px-2 text-xs bg-muted/50 border border-border rounded placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary" /> {filteredNodes.length}/{nodes.length} node{nodes.length !== 1 && "s"}
{/* Graph + labels */}
{nodesRef.current.map((node, i) => { const lp = labelPositions[i]; if (!lp) return null; return ( {node.name} ); })} {nodes.length === 0 && (
Listening for nodes on the Reticulum network...
)} {nodes.length > 0 && filteredNodes.length === 0 && (
No nodes match "{filter}"
)}
{/* Page viewer */}
{selectedNode ? ( <> {selectedNode.name} {selectedNode.hash.slice(0, 12)} ) : ( Page )}
{!selectedNode && !pageLoading && (
Click a node to view its page
)} {pageLoading && ( Requesting page... )} {pageError && ( {pageError} )} {pageHtml && (
)}
); }