feat: new day new graph
This commit is contained in:
@@ -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)">
|
||||
×
|
||||
</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)">
|
||||
×
|
||||
</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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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")}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user