458 lines
18 KiB
TypeScript
458 lines
18 KiB
TypeScript
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<NetworkNode[]>([]);
|
|
const [filter, setFilter] = useState("");
|
|
const { windows, focusedId: focusedWinId, open: openWindow, update: updateWindow, close: closeWindowById, focus: focusWindow } = useWindowManager<BrowseWinData>();
|
|
const [hoveredLabel, setHoveredLabel] = useState<{ text: string; x: number; y: number } | null>(null);
|
|
|
|
const [themeRev, setThemeRev] = useState(0);
|
|
|
|
// Watch for theme changes (class on <html>)
|
|
useEffect(() => {
|
|
const observer = new MutationObserver(() => setThemeRev(r => r + 1));
|
|
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const graphRef = useRef<Graph | null>(null);
|
|
const entriesRef = useRef<NetworkNode[]>([]);
|
|
const nodesMapRef = useRef<Map<string, NetworkNode>>(new Map());
|
|
const hashToIndexRef = useRef<Map<string, number>>(new Map());
|
|
const clusterNamesRef = useRef<string[]>([]);
|
|
|
|
// 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<string, NetworkNode>();
|
|
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<HTMLDivElement[]>([]);
|
|
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<Map<number, HTMLDivElement>>(new Map());
|
|
const searchIndicesRef = useRef<number[] | null>(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<typeof setTimeout> | 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: <hash>/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 (
|
|
<div className="relative overflow-hidden" style={{ height: "100%" }}>
|
|
<div ref={containerRef} className="absolute inset-0" />
|
|
|
|
{/* Hover label */}
|
|
{hoveredLabel && (
|
|
<div className="absolute pointer-events-none px-2 py-0.5 bg-popover border border-border rounded text-xs font-mono text-foreground whitespace-nowrap"
|
|
style={{ left: hoveredLabel.x + 12, top: hoveredLabel.y - 10, boxShadow: "0 2px 8px rgba(0,0,0,0.3)" }}>
|
|
{hoveredLabel.text}
|
|
</div>
|
|
)}
|
|
|
|
<BrowseSearchBar
|
|
filter={filter}
|
|
onFilterChange={setFilter}
|
|
onClear={clearSearch}
|
|
suggestions={suggestions}
|
|
onSelectNode={handleNodeClick}
|
|
focusedWinId={focusedWinId}
|
|
windowCount={windows.length}
|
|
/>
|
|
|
|
{nodes.length === 0 && (
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-muted-foreground text-sm pointer-events-none">
|
|
<Loader />
|
|
Connecting...
|
|
</div>
|
|
)}
|
|
|
|
{windows.map((win) => (
|
|
<BrowseNodeWindow
|
|
key={win.id}
|
|
win={win}
|
|
focused={focusedWinId === win.id}
|
|
onUpdate={updateWindow}
|
|
onClose={closeWindowById}
|
|
onFocus={focusWindow}
|
|
onNavBack={navBack}
|
|
onNavForward={navForward}
|
|
onNavReload={navReload}
|
|
onContentClick={handleContentClick}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|