Files
micronomicon/frontend/src/routes/BrowseView.tsx
2026-04-05 11:01:48 +02:00

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>
);
}