414 lines
13 KiB
TypeScript
414 lines
13 KiB
TypeScript
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<string, ThemeColors> = {
|
|
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<NetworkNode[]>([]);
|
|
const [filter, setFilter] = useState("");
|
|
const [selectedNode, setSelectedNode] = useState<NetworkNode | null>(null);
|
|
const [pageHtml, setPageHtml] = useState<string | null>(null);
|
|
const [pageLoading, setPageLoading] = useState(false);
|
|
const [pageError, setPageError] = useState<string | null>(null);
|
|
const [labelPositions, setLabelPositions] = useState<{ x: number; y: number }[]>([]);
|
|
const [themeId, setThemeId] = useState(getThemeId);
|
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const graphRef = useRef<Graph | null>(null);
|
|
const nodesRef = useRef<NetworkNode[]>([]);
|
|
const pollRef = useRef<ReturnType<typeof setInterval> | 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 (
|
|
<div className="flex flex-col" style={{ height: "100%" }}>
|
|
{/* Header */}
|
|
<div className="flex items-center gap-3 px-4 py-2 border-b-2 border-border shrink-0">
|
|
<h1 className="text-sm font-semibold">Browse</h1>
|
|
<input
|
|
type="text"
|
|
value={filter}
|
|
onChange={(e) => 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"
|
|
/>
|
|
<span className="text-[10px] text-muted-foreground uppercase tracking-wider whitespace-nowrap">
|
|
{filteredNodes.length}/{nodes.length} node{nodes.length !== 1 && "s"}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Graph + labels */}
|
|
<div
|
|
ref={containerRef}
|
|
className="relative shrink-0 bg-background overflow-hidden"
|
|
style={{ height: 500 }}
|
|
>
|
|
{nodesRef.current.map((node, i) => {
|
|
const lp = labelPositions[i];
|
|
if (!lp) return null;
|
|
return (
|
|
<span
|
|
key={node.hash}
|
|
className="absolute text-[10px] font-mono pointer-events-none select-none whitespace-nowrap"
|
|
style={{
|
|
left: lp.x,
|
|
top: lp.y - (node.is_self ? 24 : 12),
|
|
transform: "translate(-50%, -100%)",
|
|
color: node.is_self ? colors.primary : colors.muted,
|
|
}}
|
|
>
|
|
{node.name}
|
|
</span>
|
|
);
|
|
})}
|
|
|
|
{nodes.length === 0 && (
|
|
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground text-sm">
|
|
Listening for nodes on the Reticulum network...
|
|
</div>
|
|
)}
|
|
{nodes.length > 0 && filteredNodes.length === 0 && (
|
|
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground text-sm">
|
|
No nodes match "{filter}"
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Page viewer */}
|
|
<div className="flex-1 min-h-0 border-t-2 border-border flex flex-col">
|
|
<div className="flex items-center px-4 py-2 border-b border-border bg-background shrink-0">
|
|
<span className="text-xs font-semibold flex-1 truncate">
|
|
{selectedNode ? (
|
|
<>
|
|
{selectedNode.name}
|
|
<span className="ml-2 text-[10px] text-muted-foreground font-normal">
|
|
{selectedNode.hash.slice(0, 12)}
|
|
</span>
|
|
</>
|
|
) : (
|
|
<span className="text-muted-foreground font-normal">Page</span>
|
|
)}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex-1 min-h-0 overflow-auto p-3">
|
|
{!selectedNode && !pageLoading && (
|
|
<div className="flex flex-col items-center justify-center h-full gap-2 text-muted-foreground">
|
|
<div
|
|
className="w-80 h-64"
|
|
style={{
|
|
backgroundColor: colors.primary,
|
|
mask: `url(${axisMundiUrl}) center/contain no-repeat`,
|
|
WebkitMask: `url(${axisMundiUrl}) center/contain no-repeat`,
|
|
}}
|
|
/>
|
|
<span className="text-xs">Click a node to view its page</span>
|
|
</div>
|
|
)}
|
|
{pageLoading && (
|
|
<span className="text-muted-foreground text-xs animate-pulse">
|
|
Requesting page...
|
|
</span>
|
|
)}
|
|
{pageError && (
|
|
<span className="text-destructive text-xs">{pageError}</span>
|
|
)}
|
|
{pageHtml && (
|
|
<div
|
|
className="font-mono text-[11px] leading-tight"
|
|
dangerouslySetInnerHTML={{ __html: pageHtml }}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|