feat: new day new graph

This commit is contained in:
2026-04-06 22:27:02 +02:00
parent d8abe311cd
commit 46185705d7
10 changed files with 1018 additions and 774 deletions

View File

@@ -7,22 +7,39 @@ interface BrowseSearchBarProps {
filter: string;
onFilterChange: (value: string) => void;
onClear: () => void;
allNodes: NetworkNode[];
suggestions: NetworkNode[];
onSelectNode: (node: NetworkNode) => void;
onHighlightNode: (node: NetworkNode | null) => void;
onSearchFocusChange: (focused: boolean) => void;
focusedWinId: string | null;
windowCount: number;
}
export default function BrowseSearchBar({
filter, onFilterChange, onClear,
suggestions, onSelectNode,
allNodes, suggestions, onSelectNode, onHighlightNode, onSearchFocusChange,
focusedWinId, windowCount,
}: BrowseSearchBarProps) {
const searchInputRef = useRef<HTMLInputElement>(null);
const [selectedSuggestion, setSelectedSuggestion] = useState(-1);
const [focused, setFocused] = useState(false);
// Reset selection when suggestions change
useEffect(() => { setSelectedSuggestion(-1); }, [suggestions]);
// The visible list: when focused with no filter, show all nodes; otherwise filtered suggestions
const visibleList = focused && !filter.trim() ? allNodes : suggestions;
const maxVisible = 12;
const displayList = visibleList.slice(0, maxVisible);
const hasMore = visibleList.length > maxVisible;
// Reset selection when list changes
useEffect(() => { setSelectedSuggestion(-1); }, [visibleList.length, filter]);
// Notify parent of highlight changes for camera fly-to
useEffect(() => {
const entry = selectedSuggestion >= 0 ? displayList[selectedSuggestion] : null;
onHighlightNode(entry ?? null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedSuggestion, onHighlightNode]);
// Auto-focus search when no windows open
useEffect(() => { searchInputRef.current?.focus(); }, []);
@@ -43,6 +60,20 @@ export default function BrowseSearchBar({
return () => document.removeEventListener("keydown", onKeyDown);
}, [focusedWinId]);
const handleFocus = useCallback(() => {
setFocused(true);
onSearchFocusChange(true);
}, [onSearchFocusChange]);
const handleBlur = useCallback(() => {
// Delay to allow click on suggestion to fire before closing
setTimeout(() => {
setFocused(false);
onSearchFocusChange(false);
setSelectedSuggestion(-1);
}, 150);
}, [onSearchFocusChange]);
// Draggable position
const [searchPos, setSearchPos] = useState<{ x: number; y: number } | null>(null);
const searchDragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
@@ -60,34 +91,44 @@ export default function BrowseSearchBar({
if (!searchPos) return null;
const showDropdown = focused && displayList.length > 0;
return createPortal(
<div onMouseDown={onSearchDragStart}
className="fixed z-999 flex items-center gap-3 px-3 py-1.5 bg-popover border-2 border-border focus-within:border-primary rounded-lg cursor-grab active:cursor-grabbing transition-[border-color] duration-150"
className="fixed z-999 flex flex-col bg-popover border-2 border-border focus-within:border-primary rounded-lg cursor-grab active:cursor-grabbing transition-[border-color] duration-150"
style={{ left: searchPos.x, top: searchPos.y, width: 400, boxShadow: DITHERED_SHADOW }}>
<input ref={searchInputRef} type="text" value={filter}
onChange={(e) => onFilterChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") { onClear(); e.currentTarget.blur(); return; }
if (e.key === "ArrowDown") { e.preventDefault(); setSelectedSuggestion(i => Math.min(i + 1, suggestions.length - 1)); return; }
if (e.key === "ArrowUp") { e.preventDefault(); setSelectedSuggestion(i => Math.max(i - 1, -1)); return; }
if (e.key === "Enter") {
e.preventDefault();
const entry = selectedSuggestion >= 0 ? suggestions[selectedSuggestion] : suggestions[0];
if (entry && entry.type !== "interface") { onSelectNode(entry); onClear(); }
return;
}
}}
placeholder="Search nodes..."
className="flex-1 h-7 px-2 text-xs bg-background/60 border border-border rounded placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary cursor-text" />
{filter && (
<button onClick={onClear} className="text-muted-foreground hover:text-foreground transition-colors text-xs leading-none px-1" title="Clear search (Esc)">
&times;
</button>
)}
{suggestions.length > 0 && (
<div className="absolute left-0 right-0 top-full mt-1 bg-popover border border-border rounded-lg overflow-hidden"
style={{ boxShadow: "0 4px 12px rgba(0,0,0,0.4)" }}>
{suggestions.map((entry, i) => (
<div className="flex items-center gap-3 px-3 py-1.5">
<input ref={searchInputRef} type="text" value={filter}
onChange={(e) => onFilterChange(e.target.value)}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={(e) => {
if (e.key === "Escape") { onClear(); e.currentTarget.blur(); return; }
if (e.key === "ArrowDown") { e.preventDefault(); setSelectedSuggestion(i => Math.min(i + 1, displayList.length - 1)); return; }
if (e.key === "ArrowUp") { e.preventDefault(); setSelectedSuggestion(i => Math.max(i - 1, -1)); return; }
if (e.key === "Enter") {
e.preventDefault();
const entry = selectedSuggestion >= 0 ? displayList[selectedSuggestion] : displayList[0];
if (entry && entry.type !== "interface") { onSelectNode(entry); onClear(); }
return;
}
}}
placeholder="Search nodes..."
className="flex-1 h-7 px-2 text-xs bg-background/60 border border-border rounded placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary cursor-text" />
{filter && (
<button onClick={onClear} className="text-muted-foreground hover:text-foreground transition-colors text-xs leading-none px-1" title="Clear search (Esc)">
&times;
</button>
)}
{!filter && focused && (
<span className="text-[9px] text-muted-foreground uppercase tracking-wider shrink-0">
{allNodes.length} nodes
</span>
)}
</div>
{showDropdown && (
<div className="border-t border-border max-h-[360px] overflow-y-auto">
{displayList.map((entry, i) => (
<button
key={entry.hash}
onMouseDown={(e) => { e.preventDefault(); if (entry.type !== "interface") { onSelectNode(entry); onClear(); } }}
@@ -109,6 +150,11 @@ export default function BrowseSearchBar({
</span>
</button>
))}
{hasMore && (
<div className="px-3 py-1 text-[9px] text-muted-foreground text-center border-t border-border/50">
{visibleList.length - maxVisible} more type to narrow
</div>
)}
</div>
)}
</div>, document.body);

View File

@@ -1,101 +1,106 @@
import type { NetworkNode } from "@/api/client";
import { statusRGBA, type RGBA, type StatusColors } from "./graphColors";
import { statusRGBA, rgbaToHex, type StatusColors } from "./graphColors";
export const SPACE_SIZE = 4096;
const CENTER = SPACE_SIZE / 2;
export interface GraphNode {
id: string;
name: string;
entry: NetworkNode;
type: "self" | "peer" | "interface";
color: string;
size: number;
cluster?: string;
x?: number;
y?: number;
z?: number;
}
export interface BuiltGraph {
entries: NetworkNode[];
positions: Float32Array;
colors: Float32Array;
sizes: Float32Array;
clusterIndices: (number | undefined)[];
clusterPositions: (number | undefined)[];
clusterStrength: Float32Array;
export interface GraphLink {
source: string;
target: string;
}
export interface GraphData {
nodes: GraphNode[];
links: GraphLink[];
clusterNames: string[];
hashToIndex: Map<string, number>;
}
function findParentIface(
entry: NetworkNode,
interfaces: NetworkNode[],
peerIndex: number,
): string | undefined {
): NetworkNode | undefined {
if (entry.interface) {
const iface = interfaces.find(i => i.name === entry.interface);
if (iface) return iface.hash;
if (iface) return iface;
}
if (interfaces.length > 0) return interfaces[peerIndex % interfaces.length].hash;
if (interfaces.length > 0) return interfaces[peerIndex % interfaces.length];
return undefined;
}
export function buildGraphArrays(
export function buildGraphData(
rawNodes: NetworkNode[],
prevHashToIndex: Map<string, number>,
prevPositions: number[],
prevPositions: Map<string, { x: number; y: number; z: number }>,
theme: StatusColors,
): BuiltGraph {
const interfaces = rawNodes.filter(e => e.type === "interface").sort((a, b) => a.name.localeCompare(b.name));
): GraphData {
const interfaces = rawNodes
.filter(e => e.type === "interface")
.sort((a, b) => a.name.localeCompare(b.name));
const selfNode = rawNodes.find(e => e.is_self && e.type !== "interface");
const peers = rawNodes.filter(e => !e.is_self && e.type !== "interface");
const entries = selfNode ? [selfNode, ...peers] : peers;
const selfIndex = selfNode ? 0 : -1;
const n = entries.length;
const nClusters = interfaces.length;
const hashToIndex = new Map<string, number>();
const positions = new Float32Array(n * 2);
const colors = new Float32Array(n * 4);
const sizes = new Float32Array(n);
const clusterIndices: (number | undefined)[] = [];
const clusterStrength = new Float32Array(n);
const nodes: GraphNode[] = [];
const links: GraphLink[] = [];
const clusterMap = new Map<string, number>();
interfaces.forEach((iface, i) => clusterMap.set(iface.name, i));
const selfClusterIndex = interfaces.length;
// Add interface nodes
for (const iface of interfaces) {
const prev = prevPositions.get(iface.hash);
nodes.push({
id: iface.hash,
name: iface.name,
entry: iface,
type: "interface",
color: rgbaToHex(theme.stale),
size: 2,
...(prev ? { x: prev.x, y: prev.y, z: prev.z } : {}),
});
}
const clusterNames = [...interfaces.map(i => i.name), ...(selfNode ? [selfNode.name] : [])];
const clusterPositions: (number | undefined)[] = [];
// Add self node
if (selfNode) {
const prev = prevPositions.get(selfNode.hash);
nodes.push({
id: selfNode.hash,
name: selfNode.name,
entry: selfNode,
type: "self",
color: "#ffffff",
size: 2,
...(prev ? { x: prev.x, y: prev.y, z: prev.z } : {}),
});
}
entries.forEach((entry, i) => {
hashToIndex.set(entry.hash, i);
// Add peer nodes + links to parent interface
peers.forEach((peer, i) => {
const prev = prevPositions.get(peer.hash);
const parentIface = findParentIface(peer, interfaces, i);
nodes.push({
id: peer.hash,
name: peer.name,
entry: peer,
type: "peer",
color: rgbaToHex(statusRGBA(peer, theme)),
size: 2,
cluster: parentIface?.name,
...(prev ? { x: prev.x, y: prev.y, z: prev.z } : {}),
});
// Preserve existing position, or jitter near center for new points
const prevIdx = prevHashToIndex.get(entry.hash);
if (prevIdx !== undefined && prevPositions.length >= (prevIdx + 1) * 2) {
positions[i * 2] = prevPositions[prevIdx * 2]!;
positions[i * 2 + 1] = prevPositions[prevIdx * 2 + 1]!;
} else {
// Tighter initial spread for small graphs so they don't scatter
const spread = n <= 20 ? 0.1 : n <= 100 ? 0.25 : 0.5;
positions[i * 2] = CENTER + (Math.random() - 0.5) * SPACE_SIZE * spread;
positions[i * 2 + 1] = CENTER + (Math.random() - 0.5) * SPACE_SIZE * spread;
if (parentIface) {
links.push({ source: parentIface.hash, target: peer.hash });
}
// Self node = bright white, others by status
const rgba: RGBA = i === selfIndex
? [1, 1, 1, 1]
: statusRGBA(entry, theme);
colors[i * 4 + 0] = rgba[0];
colors[i * 4 + 1] = rgba[1];
colors[i * 4 + 2] = rgba[2];
colors[i * 4 + 3] = rgba[3];
// Scale node size inversely with count: bigger dots when fewer nodes
sizes[i] = n <= 10 ? 8 : n <= 50 ? 5 : n <= 200 ? 4 : 3;
// Cluster assignment
if (i === selfIndex) {
clusterIndices.push(selfClusterIndex);
} else {
const pi = peers.indexOf(entry);
const parentHash = findParentIface(entry, interfaces, pi);
const parentName = parentHash ? interfaces.find(f => f.hash === parentHash)?.name : undefined;
clusterIndices.push(parentName !== undefined ? clusterMap.get(parentName) : undefined);
}
clusterStrength[i] = nClusters > 1 ? (nClusters - (i % nClusters)) / nClusters : 1;
});
return { entries, positions, colors, sizes, clusterIndices, clusterPositions, clusterStrength, clusterNames, hashToIndex };
const clusterNames = interfaces.map(i => i.name);
return { nodes, links, clusterNames };
}

View File

@@ -55,3 +55,10 @@ export function statusRGBA(entry: NetworkNode, theme: StatusColors): RGBA {
if (age < 3600) return theme.stale;
return theme.offline;
}
export function rgbaToHex(c: RGBA): string {
const r = Math.round(c[0] * 255);
const g = Math.round(c[1] * 255);
const b = Math.round(c[2] * 255);
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
}

View File

@@ -426,17 +426,6 @@
cursor: nwse-resize !important;
}
/* ── Graph cursors ── */
.graph-canvas canvas {
cursor: grab !important;
}
.graph-canvas canvas:active {
cursor: grabbing !important;
}
.graph-canvas.graph-node-hovered canvas {
cursor: pointer !important;
}
/* ── Editor Pointer ── */
.editor-pointer {

View File

@@ -1,23 +1,360 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Graph } from "@cosmos.gl/graph";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import ForceGraph3D, { type ForceGraphMethods, type NodeObject } from "react-force-graph-3d";
import SpriteText from "three-spritetext";
import { UnrealBloomPass } from "three/examples/jsm/postprocessing/UnrealBloomPass.js";
import { ShaderPass } from "three/examples/jsm/postprocessing/ShaderPass.js";
import { Vector2 } from "three";
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 { buildGraphData, type GraphNode, type GraphData } 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";
function cssVarToHex(varName: string): string {
const raw = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
type FGNode = NodeObject<GraphNode>;
function cssVar(name: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
/** Resolve a CSS variable to a normalised hex string (#rrggbb) */
function cssVarToHex(name: string): string {
const raw = cssVar(name);
if (!raw) return "#888888";
const ctx = document.createElement("canvas").getContext("2d")!;
ctx.fillStyle = raw;
return ctx.fillStyle;
return ctx.fillStyle; // always "#rrggbb"
}
const DIM_COLOR = "rgba(60,60,60,0.15)";
const DIM_LINK = "rgba(60,60,60,0.03)";
// ---------------------------------------------------------------------------
// Retro post-processing shader: pixelation + posterize + scanlines + vignette
// ---------------------------------------------------------------------------
const RetroShader = {
uniforms: {
tDiffuse: { value: null },
resolution: { value: new Vector2(800, 600) },
pixelSize: { value: 2.0 },
colorLevels: { value: 48.0 },
scanlineIntensity: { value: 0.03 },
scanlineDensity: { value: 1.0 },
vignetteIntensity: { value: 0.15 },
tintColor: { value: [1.0, 0.95, 0.85] },
},
vertexShader: /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: /* glsl */ `
uniform sampler2D tDiffuse;
uniform vec2 resolution;
uniform float pixelSize;
uniform float colorLevels;
uniform float scanlineIntensity;
uniform float scanlineDensity;
uniform float vignetteIntensity;
uniform vec3 tintColor;
varying vec2 vUv;
void main() {
// Pixelation
vec2 dxy = pixelSize / resolution;
vec2 coord = dxy * floor(vUv / dxy) + dxy * 0.5;
vec4 color = texture2D(tDiffuse, coord);
// Posterize (reduce color depth)
color.rgb = floor(color.rgb * colorLevels + 0.5) / colorLevels;
// Subtle tint towards theme color
color.rgb *= tintColor;
// Scanlines
float scanline = sin(vUv.y * resolution.y * scanlineDensity) * 0.5 + 0.5;
color.rgb -= scanlineIntensity * (1.0 - scanline);
// Vignette
vec2 vig = vUv * (1.0 - vUv);
float vigFactor = pow(vig.x * vig.y * 15.0, vignetteIntensity);
color.rgb *= vigFactor;
gl_FragColor = color;
}
`,
};
// ---------------------------------------------------------------------------
// Memoized 3D graph — isolated from window/UI state changes
// ---------------------------------------------------------------------------
type FlyToFn = (pos: { x: number; y: number; z: number }, lookAt: any, durationMs: number) => void;
interface Graph3DProps {
graphData: GraphData;
searchMatchIds: Set<string> | null;
themeRev: number;
width: number;
height: number;
onNodeClick: (node: NetworkNode) => void;
fgRef: React.MutableRefObject<ForceGraphMethods<FGNode> | undefined>;
flyToRef: React.MutableRefObject<FlyToFn | undefined>;
containerRef: React.RefObject<HTMLDivElement | null>;
}
const Graph3D = memo(function Graph3D({ graphData, searchMatchIds, themeRev, width, height, onNodeClick, fgRef, flyToRef, containerRef }: Graph3DProps) {
// react-kapsule diffs props during every render — graphData triggers a full
// simulation restart (alpha=1). Stabilise the reference.
const stableDataRef = useRef(graphData);
const prevNodeIds = useRef("");
const nodeIds = graphData.nodes.map(n => n.id).join(",");
if (nodeIds !== prevNodeIds.current) {
stableDataRef.current = graphData;
prevNodeIds.current = nodeIds;
}
// Dim non-matching nodes/links during search
const nodeColor = useCallback((node: FGNode) => {
if (!searchMatchIds) return (node as GraphNode).color;
return searchMatchIds.has(node.id as string) ? (node as GraphNode).color : DIM_COLOR;
}, [searchMatchIds]);
const linkColor = useCallback((link: any) => {
if (!searchMatchIds) return "rgba(100,100,100,0.15)";
const srcId = typeof link.source === "object" ? (link.source.id as string) : link.source;
const tgtId = typeof link.target === "object" ? (link.target.id as string) : link.target;
if (searchMatchIds.has(srcId) || searchMatchIds.has(tgtId)) return "rgba(100,100,100,0.15)";
return DIM_LINK;
}, [searchMatchIds]);
// Fly camera to search matches
useEffect(() => {
const fg = fgRef.current;
if (!fg || !searchMatchIds) return;
if (searchMatchIds.size <= 10) {
fg.zoomToFit(600, 80, (n: FGNode) => searchMatchIds.has(n.id as string));
}
}, [searchMatchIds, fgRef]);
// ── Camera auto-orbit with smooth ease-in / ease-out ──
const orbitAngleRef = useRef(0);
const orbitTargetSpeed = useRef(1.0); // 1 = full speed, 0 = stopped
const orbitCurrentSpeed = useRef(0.0); // smoothed value
const hoveringNodeRef = useRef(false);
const flyingRef = useRef(false); // true while cameraPosition transition is active — hard-blocks orbit
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const flyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const IDLE_RESUME_MS = 3000;
const BASE_SPEED = Math.PI / 600;
const HOVER_FACTOR = 0.1; // 10% speed when hovering
const EASE_RATE = 0.02; // lerp factor per tick — smaller = smoother
/** Fly camera to a position. Completely blocks orbit during the transition. */
const flyTo = useCallback((pos: { x: number; y: number; z: number }, lookAt: any, durationMs: number) => {
const fg = fgRef.current;
if (!fg) return;
flyingRef.current = true;
orbitCurrentSpeed.current = 0;
orbitTargetSpeed.current = 0;
if (flyTimerRef.current) clearTimeout(flyTimerRef.current);
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
fg.cameraPosition(pos, lookAt, durationMs);
flyTimerRef.current = setTimeout(() => {
flyingRef.current = false;
const cam = fg.camera();
orbitAngleRef.current = Math.atan2(cam.position.x, cam.position.z);
idleTimerRef.current = setTimeout(() => { orbitTargetSpeed.current = 1; }, IDLE_RESUME_MS);
}, durationMs);
}, [fgRef]);
// Expose flyTo to parent
flyToRef.current = flyTo;
const pauseOrbit = useCallback(() => {
orbitTargetSpeed.current = 0;
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
idleTimerRef.current = setTimeout(() => { orbitTargetSpeed.current = 1; }, IDLE_RESUME_MS);
}, []);
const onNodeHover = useCallback((node: FGNode | null) => {
hoveringNodeRef.current = !!node;
}, []);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const events = ["mousedown", "wheel", "touchstart"] as const;
for (const evt of events) el.addEventListener(evt, pauseOrbit, { passive: true });
return () => { for (const evt of events) el.removeEventListener(evt, pauseOrbit); };
}, [pauseOrbit, containerRef]);
useEffect(() => {
const interval = setInterval(() => {
const fg = fgRef.current;
if (!fg || flyingRef.current) return;
// Smooth target: full speed or hover-reduced
const target = orbitTargetSpeed.current * (hoveringNodeRef.current ? HOVER_FACTOR : 1);
// Ease towards target
orbitCurrentSpeed.current += (target - orbitCurrentSpeed.current) * EASE_RATE;
// Skip negligible movement
if (Math.abs(orbitCurrentSpeed.current) < 0.001) return;
const cam = fg.camera();
const distance = Math.sqrt(cam.position.x ** 2 + cam.position.z ** 2) || 400;
orbitAngleRef.current += BASE_SPEED * orbitCurrentSpeed.current;
fg.cameraPosition({
x: distance * Math.sin(orbitAngleRef.current),
z: distance * Math.cos(orbitAngleRef.current),
});
}, 20);
return () => { clearInterval(interval); if (idleTimerRef.current) clearTimeout(idleTimerRef.current); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fgRef]);
// ── Post-processing: bloom + retro ──
const postProcInitRef = useRef(false);
const retroPassRef = useRef<ShaderPass | null>(null);
useEffect(() => {
const fg = fgRef.current;
if (!fg || postProcInitRef.current) return;
// Wait a tick for the renderer to be ready
const timer = setTimeout(() => {
try {
const composer = fg.postProcessingComposer();
// Bloom — subtle glow
const bloom = new UnrealBloomPass(new Vector2(width, height), 0.3, 0.3, 0.9);
composer.addPass(bloom);
// Retro shader
const retro = new ShaderPass(RetroShader);
retro.uniforms.resolution.value.set(width, height);
// Tint towards theme primary
const hex = cssVarToHex("--primary");
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
// Blend towards white so the tint is subtle
retro.uniforms.tintColor.value = [0.7 + r * 0.3, 0.7 + g * 0.3, 0.7 + b * 0.3];
composer.addPass(retro);
retroPassRef.current = retro;
postProcInitRef.current = true;
} catch { /* renderer not ready yet, will retry */ }
}, 500);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fgRef, width, height]);
// Update retro tint when theme changes
useEffect(() => {
const retro = retroPassRef.current;
if (!retro) return;
const hex = cssVarToHex("--primary");
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
retro.uniforms.tintColor.value = [0.7 + r * 0.3, 0.7 + g * 0.3, 0.7 + b * 0.3];
}, [themeRev]);
// Update resolution uniform on resize
useEffect(() => {
const retro = retroPassRef.current;
if (retro) retro.uniforms.resolution.value.set(width, height);
}, [width, height]);
// ── Custom node objects: text labels above every node ──
const nodeThreeObject = useCallback((node: FGNode) => {
const gn = node as GraphNode;
const sprite = new SpriteText(gn.name);
(sprite as any).material.depthWrite = false;
(sprite as any).renderOrder = 999;
sprite.color = gn.type === "interface"
? (cssVar("--foreground") || "#888")
: gn.color;
sprite.textHeight = gn.type === "interface" ? 4 : 3;
sprite.fontFace = "JetBrains Mono, monospace";
sprite.fontWeight = gn.type === "interface" ? "700" : "400";
sprite.backgroundColor = "transparent";
if (gn.type === "interface") {
(sprite as any).center.set(0.5, 0.5);
} else {
(sprite as any).center.set(0.5, 2.5);
}
return sprite;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [themeRev]);
const nodeVisibility = useCallback((node: FGNode) => {
return (node as GraphNode).type !== "interface";
}, []);
const nodeThreeObjectExtend = useCallback((node: FGNode) => {
return (node as GraphNode).type !== "interface";
}, []);
const showPointerCursor = useCallback((obj: any) => {
if (!obj || !("type" in obj)) return false;
return (obj as GraphNode).type !== "interface";
}, []);
const handleClick = useCallback((node: FGNode) => {
const gn = node as GraphNode;
if (gn.type === "interface") return;
if (node.x !== undefined && node.y !== undefined && node.z !== undefined) {
const distance = 40;
const dist = Math.hypot(node.x, node.y, node.z);
const newPos = dist > 0
? { x: node.x * (1 + distance / dist), y: node.y * (1 + distance / dist), z: node.z * (1 + distance / dist) }
: { x: 0, y: 0, z: distance };
flyTo(newPos, node as any, 1500);
}
onNodeClick(gn.entry);
}, [onNodeClick, flyTo]);
return (
<ForceGraph3D
ref={fgRef}
graphData={stableDataRef.current}
width={width}
height={height}
backgroundColor="rgba(0,0,0,0)"
nodeId="id"
nodeVal="size"
nodeColor={nodeColor}
nodeLabel=""
nodeOpacity={0.9}
nodeResolution={12}
nodeVisibility={nodeVisibility}
nodeThreeObject={nodeThreeObject}
nodeThreeObjectExtend={nodeThreeObjectExtend}
onNodeClick={handleClick}
onNodeHover={onNodeHover}
showPointerCursor={showPointerCursor}
linkColor={linkColor}
linkWidth={0.3}
linkOpacity={0.12}
cooldownTicks={150}
warmupTicks={0}
enableNodeDrag={true}
enableNavigationControls={true}
showNavInfo={false}
/>
);
});
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
@@ -26,7 +363,6 @@ 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);
@@ -38,290 +374,108 @@ export default function BrowseView() {
}, []);
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[]>([]);
const hasUserInteractedRef = useRef(false);
const initialFitDoneRef = useRef(false);
const fgRef = useRef<ForceGraphMethods<FGNode>>(undefined);
const flyToRef = useRef<FlyToFn>(undefined);
const prevPositionsRef = useRef<Map<string, { x: number; y: number; z: number }>>(new Map());
// Container sizing
const [dims, setDims] = useState<{ w: number; h: number }>({ w: 800, h: 600 });
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) => {
if (!entry) return;
setDims({ w: entry.contentRect.width, h: entry.contentRect.height });
});
ro.observe(el);
setDims({ w: el.clientWidth, h: el.clientHeight });
return () => ro.disconnect();
}, []);
// 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());
const fg = fgRef.current;
if (fg) {
try {
// @ts-expect-error — graphData() is on the underlying instance
const live = fg.graphData?.() as { nodes: FGNode[] } | undefined;
if (live?.nodes) {
const map = new Map<string, { x: number; y: number; z: number }>();
for (const n of live.nodes) {
if (n.x !== undefined && n.y !== undefined && n.z !== undefined) {
map.set(n.id as string, { x: n.x, y: n.y, z: n.z });
}
}
prevPositionsRef.current = map;
}
} catch { /* ignore */ }
}
return buildGraphData(nodes, prevPositionsRef.current, 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(() => {
const searchMatchIds = 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;
const ids = new Set<string>();
for (const n of graphData.nodes) {
if (n.name.toLowerCase().includes(q)) ids.add(n.id);
}
return ids.size > 0 ? ids : null;
}, [filter, graphData]);
const allPeerNodes = useMemo(() => {
return graphData.nodes
.filter(e => e.type !== "interface")
.map(e => e.entry);
}, [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]);
return allPeerNodes.filter(e => e.name.toLowerCase().includes(q));
}, [filter, allPeerNodes]);
// ── 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);
}
}
const rect = container.getBoundingClientRect();
const pad = 8;
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]!;
// Measure label so we can clamp within container
const lw = div.offsetWidth;
const lh = div.offsetHeight;
// Default transform is translate(-50%, -100%), so anchor is center-bottom
let left = screen[0] - lw / 2;
let top = screen[1] - lh;
// Clamp to container bounds
left = Math.max(pad, Math.min(left, rect.width - lw - pad));
top = Math.max(pad, Math.min(top, rect.height - lh - pad));
div.style.transform = "none";
div.style.left = `${left}px`;
div.style.top = `${top}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: cssVarToHex("--primary"),
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);
containerRef.current?.classList.remove("graph-node-hovered");
return;
}
const entry = entriesRef.current[index];
if (!entry) return;
if (entry.type !== "interface") {
containerRef.current?.classList.add("graph-node-hovered");
} else {
containerRef.current?.classList.remove("graph-node-hovered");
}
const screen = graphRef.current.spaceToScreenPosition(pointPosition);
setHoveredLabel({ text: entry.name, x: screen[0], y: screen[1] });
},
onSimulationTick: () => { updateClusterLabels(); updateSearchLabels(); },
onSimulationEnd: () => {
// Only auto-fit on initial load; after that, respect user's viewport
if (!initialFitDoneRef.current) {
graphRef.current?.fitView(300, 0.2);
initialFitDoneRef.current = true;
}
updateClusterLabels(); updateSearchLabels();
},
onZoom: () => { hasUserInteractedRef.current = true; 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);
// Stronger gravity + lower repulsion for small graphs keeps them compact and centered
const isSmall = n <= 20;
graph.setConfig({
simulationRepulsion: isSmall ? 0.3 : 0.5 + scale * 9.5,
simulationCluster: 1.0 - scale * 0.75,
simulationGravity: isSmall ? 1.0 : 0.25 + scale * 1.75,
simulationDecay: isSmall ? 3000 : 5000,
hoveredPointRingColor: cssVarToHex("--primary"),
});
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();
// Fly camera to a highlighted search suggestion.
// ForceGraph3D mutates graphData.nodes in-place with x/y/z after simulation,
// so we read positions directly from the graph data nodes.
const onHighlightNode = useCallback((node: NetworkNode | null) => {
if (!node) return;
const fly = flyToRef.current;
if (!fly) return;
const match = graphData.nodes.find(n => n.id === node.hash) as FGNode | undefined;
if (!match || match.x === undefined || match.y === undefined || match.z === undefined) return;
const distance = 60;
const dist = Math.hypot(match.x, match.y, match.z);
const newPos = dist > 0
? { x: match.x * (1 + distance / dist), y: match.y * (1 + distance / dist), z: match.z * (1 + distance / dist) }
: { x: 0, y: 0, z: distance };
fly(newPos, match as any, 800);
}, [graphData]);
// ── Search highlighting ──
const onSearchFocusChange = useCallback((_focused: boolean) => {
// Could be used to dim graph when search is active
}, []);
// ── Fly camera to focused window's node ──
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]);
if (!focusedWinId) return;
const fly = flyToRef.current;
if (!fly) return;
const match = graphData.nodes.find(n => n.id === focusedWinId) as FGNode | undefined;
if (!match || match.x === undefined || match.y === undefined || match.z === undefined) return;
const distance = 60;
const dist = Math.hypot(match.x, match.y, match.z);
const newPos = dist > 0
? { x: match.x * (1 + distance / dist), y: match.y * (1 + distance / dist), z: match.z * (1 + distance / dist) }
: { x: 0, y: 0, z: distance };
fly(newPos, match as any, 1000);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [focusedWinId]);
// ── SSE stream ──
const nodeMapRef = useRef<Map<string, NetworkNode>>(new Map());
useEffect(() => {
let pending: NetworkNode[] = [];
let batchTimer: ReturnType<typeof setTimeout> | null = null;
@@ -329,11 +483,15 @@ export default function BrowseView() {
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 map = nodeMapRef.current;
let changed = false;
for (const node of batch) {
if (!map.has(node.hash)) changed = true;
map.set(node.hash, node);
}
if (changed) {
setNodes(Array.from(map.values()));
}
};
const unsub = subscribeBrowseNodes((node) => { pending.push(node); if (!batchTimer) batchTimer = setTimeout(flush, 200); });
return () => { unsub(); if (batchTimer) clearTimeout(batchTimer); flush(); };
@@ -398,13 +556,6 @@ export default function BrowseView() {
}, [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;
@@ -414,63 +565,58 @@ export default function BrowseView() {
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
.replace(/^nomadnetwork:\/\//, "")
.replace(/^:/, "")
.replace(/^\/page\//, "")
.replace(/^\/+/, "");
// 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;
}
const known = nodes.find(n => n.hash === targetHash);
if (known) targetNode = known;
}
if (!path || path === "/") return;
if (!path.endsWith(".mu")) path += ".mu";
navigateTo(winId, targetNode, path, data);
}, [navigateTo]);
}, [navigateTo, nodes]);
const clearSearch = useCallback(() => {
setFilter("");
graphRef.current?.unselectPoints();
// Re-center when explicitly clearing search
graphRef.current?.fitView(300, 0.2);
hasUserInteractedRef.current = false;
updateClusterLabels();
}, [updateClusterLabels]);
fgRef.current?.zoomToFit(400, 60);
}, []);
return (
<div className="relative overflow-hidden" style={{ height: "100%" }}>
<div ref={containerRef} className="absolute inset-0 graph-canvas" />
{/* 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: Math.min(hoveredLabel.x + 12, (containerRef.current?.offsetWidth ?? 9999) - 160), top: Math.max(8, hoveredLabel.y - 10), boxShadow: "0 2px 8px rgba(0,0,0,0.3)" }}>
{hoveredLabel.text}
</div>
)}
<div ref={containerRef} className="relative overflow-hidden" style={{ height: "100%" }}>
<Graph3D
graphData={graphData}
searchMatchIds={searchMatchIds}
themeRev={themeRev}
width={dims.w}
height={dims.h}
onNodeClick={handleNodeClick}
fgRef={fgRef}
flyToRef={flyToRef}
containerRef={containerRef}
/>
<BrowseSearchBar
filter={filter}
onFilterChange={setFilter}
onClear={clearSearch}
allNodes={allPeerNodes}
suggestions={suggestions}
onSelectNode={handleNodeClick}
onHighlightNode={onHighlightNode}
onSearchFocusChange={onSearchFocusChange}
focusedWinId={focusedWinId}
windowCount={windows.length}
/>
@@ -482,20 +628,25 @@ export default function BrowseView() {
</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}
/>
))}
{/* Stop React synthetic events from portaled windows bubbling into the
graph — portals bubble through the React tree, not the DOM tree. */}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div onMouseDown={e => e.stopPropagation()} onPointerDown={e => e.stopPropagation()}>
{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>
</div>
);
}

40
frontend/src/types/three-shims.d.ts vendored Normal file
View File

@@ -0,0 +1,40 @@
declare module "three" {
export class Vector2 {
constructor(x?: number, y?: number);
set(x: number, y: number): this;
x: number;
y: number;
}
}
declare module "three/examples/jsm/postprocessing/UnrealBloomPass.js" {
import { Vector2 } from "three";
export class UnrealBloomPass {
constructor(resolution: Vector2, strength: number, radius: number, threshold: number);
strength: number;
radius: number;
threshold: number;
}
}
declare module "three/examples/jsm/postprocessing/ShaderPass.js" {
export class ShaderPass {
constructor(shader: any);
uniforms: Record<string, { value: any }>;
}
}
declare module "three/examples/jsm/renderers/CSS2DRenderer.js" {
export class CSS2DRenderer {
constructor();
setSize(width: number, height: number): void;
domElement: HTMLElement;
render(scene: any, camera: any): void;
}
export class CSS2DObject {
constructor(element: HTMLElement);
position: { set(x: number, y: number, z: number): void };
center: { set(x: number, y: number): void };
layers: { set(n: number): void };
}
}