From 3eec7f316e4b8be3c7d93deada68169a8a6e1144 Mon Sep 17 00:00:00 2001 From: dtoro Date: Sat, 4 Apr 2026 23:24:44 +0200 Subject: [PATCH] feat: final graph --- frontend/package-lock.json | 157 +++ frontend/package.json | 2 + frontend/src/assets/browser.min.svg | 1 + .../src/components/editor/EditorPointer.tsx | 35 +- frontend/src/components/shared/AppShell.tsx | 69 +- frontend/src/components/shared/LazyEyes.tsx | 79 ++ frontend/src/hooks/useLazyEyes.ts | 90 ++ frontend/src/routes/BrowseView.tsx | 965 +++++++++--------- frontend/vite.config.ts | 1 + 9 files changed, 896 insertions(+), 503 deletions(-) create mode 100644 frontend/src/assets/browser.min.svg create mode 100644 frontend/src/components/shared/LazyEyes.tsx create mode 100644 frontend/src/hooks/useLazyEyes.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d004b70..14636b0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,6 +16,8 @@ "@codemirror/search": "^6.6.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.40.0", + "@cosmograph/cosmos": "^1.6.1", + "@cosmos.gl/graph": "^2.6.4", "@dagrejs/dagre": "^3.0.0", "@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8", @@ -667,6 +669,55 @@ "w3c-keyname": "^2.2.4" } }, + "node_modules/@cosmograph/cosmos": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@cosmograph/cosmos/-/cosmos-1.6.1.tgz", + "integrity": "sha512-A91YabqDxCRqYZXmlOs5ykqkDw1pui0TnuXA65sYFRHwskOJ+BNy7z2IFb/vVIBlsnl3Jdyzm9G93A/xipbHIA==", + "deprecated": "This package has been moved to @cosmos.gl/graph. If you're using version >2, please update", + "license": "CC-BY-NC-4.0", + "dependencies": { + "d3-array": "^3.2.0", + "d3-color": "^3.1.0", + "d3-ease": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1", + "d3-zoom": "^3.0.0", + "gl-bench": "^1.0.42", + "gl-matrix": "^3.4.3", + "random": "^4.1.0", + "regl": "^2.1.0" + }, + "engines": { + "node": ">=12.2.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@cosmos.gl/graph": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/@cosmos.gl/graph/-/graph-2.6.4.tgz", + "integrity": "sha512-i+N9lSpAjGLTUPelo/bKNbQnKPDqt3k2UnRlfIWe2Lrambc4J3QFgOfpR8AalQ/1tgLRoeNtVBZ1GPpsNqae5w==", + "license": "MIT", + "dependencies": { + "d3-array": "^3.2.0", + "d3-color": "^3.1.0", + "d3-drag": "^3.0.0", + "d3-ease": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1", + "d3-zoom": "^3.0.0", + "dompurify": "^3.2.6", + "gl-bench": "^1.0.42", + "gl-matrix": "^3.4.3", + "random": "^4.1.0", + "regl": "^2.1.0" + }, + "engines": { + "node": ">=12.2.0", + "npm": ">=7.0.0" + } + }, "node_modules/@dagrejs/dagre": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-3.0.0.tgz", @@ -3303,6 +3354,18 @@ "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-color": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", @@ -3343,6 +3406,15 @@ "node": ">=12" } }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-interpolate": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", @@ -3355,6 +3427,22 @@ "node": ">=12" } }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-selection": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", @@ -3364,6 +3452,30 @@ "node": ">=12" } }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-timer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", @@ -4411,6 +4523,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gl-bench": { + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/gl-bench/-/gl-bench-1.0.42.tgz", + "integrity": "sha512-zuMsA/NCPmI8dPy6q3zTUH8OUM5cqKg7uVWwqzrtXJPBqoypM0XeFWEc8iFOqbf/1qtXieWOrbmgFEByKTQt4Q==", + "license": "MIT" + }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -4669,6 +4793,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", @@ -6131,6 +6264,18 @@ ], "license": "MIT" }, + "node_modules/random": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/random/-/random-4.1.0.tgz", + "integrity": "sha512-6Ajb7XmMSE9EFAMGC3kg9mvE7fGlBip25mYYuSMzw/uUSrmGilvZo2qwX3RnTRjwXkwkS+4swse9otZ92VjAtQ==", + "license": "MIT", + "dependencies": { + "seedrandom": "^3.0.5" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -6240,6 +6385,12 @@ "node": ">= 4" } }, + "node_modules/regl": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/regl/-/regl-2.1.1.tgz", + "integrity": "sha512-+IOGrxl3FZ8ZM9ixCWQZzFRiRn7Rzn9bu3iFHwg/yz4tlOUQgbO4PHLgG+1ZT60zcIV8tief6Qrmyl8qcoJP0g==", + "license": "MIT" + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -6417,6 +6568,12 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/seedrandom": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", + "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==", + "license": "MIT" + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 3030f00..63a78a5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,8 @@ "@codemirror/search": "^6.6.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.40.0", + "@cosmograph/cosmos": "^1.6.1", + "@cosmos.gl/graph": "^2.6.4", "@dagrejs/dagre": "^3.0.0", "@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8", diff --git a/frontend/src/assets/browser.min.svg b/frontend/src/assets/browser.min.svg new file mode 100644 index 0000000..bd98ffd --- /dev/null +++ b/frontend/src/assets/browser.min.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/editor/EditorPointer.tsx b/frontend/src/components/editor/EditorPointer.tsx index 576d085..2e6afb6 100644 --- a/frontend/src/components/editor/EditorPointer.tsx +++ b/frontend/src/components/editor/EditorPointer.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from "react"; import pointerSvg from "@/assets/pointer.min.svg"; +import { useLazyEyes } from "@/hooks/useLazyEyes"; /** * Floating pointer that tracks the CodeMirror cursor with smooth lerp animation. @@ -16,13 +17,13 @@ export default function EditorPointer() { const [clickKey, setClickKey] = useState(0); const clickTimerRef = useRef>(undefined); const cmRef = useRef(null); - const [eyeOffset, setEyeOffset] = useState({ x: 0, y: 0 }); - const eyeTargetRef = useRef({ x: 0, y: 0 }); - const eyeCurrentRef = useRef({ x: 0, y: 0 }); + + // Eye anchor tracks the pointer's viewport position + const eyeAnchorRef = useRef<{ x: number; y: number } | null>(null); + const eyeOffset = useLazyEyes({ anchorRef: eyeAnchorRef }); useEffect(() => { const ease = 0.09; - const eyeEase = 0.06; const updateLeft = () => { if (cmRef.current) { @@ -30,21 +31,6 @@ export default function EditorPointer() { } }; - const onMouseMove = (e: MouseEvent) => { - const pointerX = (cmRef.current?.getBoundingClientRect().left ?? 0) - 100; - const pointerY = currentRef.current; - const dx = e.clientX - pointerX; - const dy = e.clientY - pointerY; - const dist = Math.sqrt(dx * dx + dy * dy) || 1; - const maxShift = 1.5; - eyeTargetRef.current = { - x: (dx / dist) * maxShift, - y: (dy / dist) * maxShift, - }; - }; - - window.addEventListener("mousemove", onMouseMove); - const onEditorClick = () => { clearTimeout(clickTimerRef.current); setClickKey((k) => k + 1); @@ -82,13 +68,11 @@ export default function EditorPointer() { currentRef.current += diff * ease; } setY(currentRef.current); + + // Update eye anchor to match pointer position + const left = cmRef.current?.getBoundingClientRect().left ?? 0; + eyeAnchorRef.current = { x: left - 100, y: currentRef.current }; } - // Lerp eyes toward target - const ec = eyeCurrentRef.current; - const et = eyeTargetRef.current; - ec.x += (et.x - ec.x) * eyeEase; - ec.y += (et.y - ec.y) * eyeEase; - setEyeOffset({ x: ec.x, y: ec.y }); rafRef.current = requestAnimationFrame(tick); }; @@ -106,7 +90,6 @@ export default function EditorPointer() { return () => { window.removeEventListener("cm-cursor-move", onCursorMove); - window.removeEventListener("mousemove", onMouseMove); window.removeEventListener("resize", updateLeft); cmRef.current?.removeEventListener("mousedown", onEditorClick); cancelAnimationFrame(rafRef.current); diff --git a/frontend/src/components/shared/AppShell.tsx b/frontend/src/components/shared/AppShell.tsx index ea60be2..97b49c2 100644 --- a/frontend/src/components/shared/AppShell.tsx +++ b/frontend/src/components/shared/AppShell.tsx @@ -3,7 +3,9 @@ import { useNavigate, useLocation } from "react-router-dom"; import { TooltipProvider } from "@/components/ui/tooltip"; import { Toaster } from "@/components/ui/sonner"; import frameSvg from "@/assets/frame.themed.svg"; +import browserSvg from "@/assets/browser.min.svg"; import NavMenu from "./NavMenu"; +import LazyEyes from "./LazyEyes"; const TITLE = ` @@ -80,11 +82,47 @@ function RomanClock() { ); } +// --------------------------------------------------------------------------- +// Per-frame layout configs +// --------------------------------------------------------------------------- + +interface FrameLayout { + svg: string; + frameHeight: number; + containerMaxW: string; + containerMinW: string; + title: { paddingTop: number; height: number; paddingLeft: number; paddingRight: number; paddingBottom: number }; + content: { marginLeft: number; marginTop: number; marginRight: number; width: number; height: number; paddingTop: number; paddingBottom: number }; + nav: { top: number; left: number }; +} + +const defaultLayout: FrameLayout = { + svg: frameSvg, + frameHeight: 1315, + containerMaxW: "max-w-5xl", + containerMinW: "min-w-5xl", + title: { paddingTop: 65, height: 185, paddingLeft: 60, paddingRight: 500, paddingBottom: 25 }, + content: { marginLeft: 12, marginRight: 68, width: 843, height: 1030, paddingTop: 8, paddingBottom: 20 }, + nav: { top: 150, left: -210 }, +}; + +const browseLayout: FrameLayout = { + svg: browserSvg, + frameHeight: 884, + containerMaxW: "max-w-5xl", + containerMinW: "min-w-5xl", + title: { paddingTop: 65, height: 185, paddingLeft: 60, paddingRight: 500, paddingBottom: 25 }, + content: { marginLeft: 268, marginTop: 63, width: 476, height: 377, paddingTop: 0, paddingBottom: 0 }, + nav: { top: 150, left: -210 }, +}; + export default function AppShell({ children }: { children: ReactNode }) { const navigate = useNavigate(); const location = useLocation(); const [theme, setTheme] = useState(getStoredTheme); const isEditor = location.pathname.startsWith("/editor"); + const isBrowse = location.pathname === "/browse"; + const layout = isBrowse ? browseLayout : defaultLayout; useEffect(() => { const root = document.documentElement; @@ -104,17 +142,17 @@ export default function AppShell({ children }: { children: ReactNode }) {
-
+
{/* Frame SVG — background */}
+ {/* Lazy eyes on the browse frame figure */} + {isBrowse && ( + + )} + {/* Top hole — ASCII title */}
navigate("/")} + style={layout.title} >
 navigate("/")}
               >
                 {TITLE}
               
@@ -141,7 +190,7 @@ export default function AppShell({ children }: { children: ReactNode }) { {/* Bottom hole — main content */}
{children}
@@ -150,7 +199,7 @@ export default function AppShell({ children }: { children: ReactNode }) { {!isEditor && (
@@ -159,7 +208,7 @@ export default function AppShell({ children }: { children: ReactNode }) {
- Becoming with hubris · MMXXVI + Created with hubris · MMXXVI
diff --git a/frontend/src/components/shared/LazyEyes.tsx b/frontend/src/components/shared/LazyEyes.tsx new file mode 100644 index 0000000..32161d6 --- /dev/null +++ b/frontend/src/components/shared/LazyEyes.tsx @@ -0,0 +1,79 @@ +import { useRef, useEffect } from "react"; +import { useLazyEyes } from "@/hooks/useLazyEyes"; + +interface EyeSpec { + top: number; + left: number; + size?: number; + irisSize?: number; +} + +interface LazyEyesProps { + eyes: EyeSpec[]; + /** Viewport-relative anchor the eyes "live" at. If omitted, uses the component's own position. */ + anchor?: { x: number; y: number }; + maxShift?: number; + ease?: number; + className?: string; +} + +export default function LazyEyes({ eyes, anchor, maxShift, ease, className }: LazyEyesProps) { + const containerRef = useRef(null); + const anchorRef = useRef<{ x: number; y: number } | null>(anchor ?? null); + + // Keep anchorRef in sync with prop or auto-compute from DOM + useEffect(() => { + if (anchor) { + anchorRef.current = anchor; + return; + } + const update = () => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + anchorRef.current = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; + } + }; + update(); + window.addEventListener("scroll", update, true); + window.addEventListener("resize", update); + return () => { + window.removeEventListener("scroll", update, true); + window.removeEventListener("resize", update); + }; + }, [anchor]); + + const offset = useLazyEyes({ anchorRef, maxShift, ease }); + + return ( +
+ {eyes.map((eye, i) => { + const size = eye.size ?? 5; + const irisSize = eye.irisSize ?? 2; + // Clamp iris within eye circle + const maxR = (size - irisSize) / 2; + const dist = Math.sqrt(offset.x * offset.x + offset.y * offset.y) || 0; + const scale = dist > maxR && dist > 0 ? maxR / dist : 1; + const cx = offset.x * scale; + const cy = offset.y * scale; + return ( +
+
+
+ ); + })} +
+ ); +} diff --git a/frontend/src/hooks/useLazyEyes.ts b/frontend/src/hooks/useLazyEyes.ts new file mode 100644 index 0000000..3cc303c --- /dev/null +++ b/frontend/src/hooks/useLazyEyes.ts @@ -0,0 +1,90 @@ +import { useEffect, useRef, useState } from "react"; + +interface LazyEyesOptions { + /** Reference point the eyes "live" at (viewport coords). Eyes look away from this toward the mouse. */ + anchorRef: React.RefObject<{ x: number; y: number } | null>; + /** Max pixel shift for the iris (default 1.5) */ + maxShift?: number; + /** Lerp ease factor 0–1 for slow drift (default 0.04) */ + ease?: number; + /** Saccade threshold — when target jumps more than this, snap fast (default 0.8) */ + saccadeThreshold?: number; + /** Fast ease for saccade snap (default 0.35) */ + saccadeEase?: number; +} + +/** + * Returns a smoothly-interpolated { x, y } offset for positioning irises + * that lazily track the mouse cursor relative to an anchor point. + * + * Movement model: + * - Small mouse moves → slow, lazy drift (ease) + * - Large jumps → quick saccade snap (saccadeEase), then settle + * - Tiny random micro-drift to avoid perfectly still eyes + */ +export function useLazyEyes({ + anchorRef, + maxShift = 1.5, + ease = 0.04, + saccadeThreshold = 0.8, + saccadeEase = 0.35, +}: LazyEyesOptions) { + const [offset, setOffset] = useState({ x: 0, y: 0 }); + const targetRef = useRef({ x: 0, y: 0 }); + const currentRef = useRef({ x: 0, y: 0 }); + const velocityRef = useRef({ x: 0, y: 0 }); + + useEffect(() => { + const onMouseMove = (e: MouseEvent) => { + const anchor = anchorRef.current; + if (!anchor) return; + const dx = e.clientX - anchor.x; + const dy = e.clientY - anchor.y; + const dist = Math.sqrt(dx * dx + dy * dy) || 1; + targetRef.current = { + x: (dx / dist) * maxShift, + y: (dy / dist) * maxShift, + }; + }; + + window.addEventListener("mousemove", onMouseMove); + + let raf = 0; + const tick = () => { + const ec = currentRef.current; + const et = targetRef.current; + const vel = velocityRef.current; + + // Distance to target + const dx = et.x - ec.x; + const dy = et.y - ec.y; + const dist = Math.sqrt(dx * dx + dy * dy); + + // Saccade: snap fast when target jumps significantly + const e_ = dist > saccadeThreshold ? saccadeEase : ease; + + // Apply eased movement + vel.x = vel.x * 0.6 + dx * e_ * 0.4; + vel.y = vel.y * 0.6 + dy * e_ * 0.4; + ec.x += vel.x; + ec.y += vel.y; + + // Micro-drift: tiny organic tremor when nearly still + if (dist < 0.1) { + ec.x += (Math.random() - 0.5) * 0.02; + ec.y += (Math.random() - 0.5) * 0.02; + } + + setOffset({ x: ec.x, y: ec.y }); + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + + return () => { + window.removeEventListener("mousemove", onMouseMove); + cancelAnimationFrame(raf); + }; + }, [anchorRef, maxShift, ease, saccadeThreshold, saccadeEase]); + + return offset; +} diff --git a/frontend/src/routes/BrowseView.tsx b/frontend/src/routes/BrowseView.tsx index 3362638..810e326 100644 --- a/frontend/src/routes/BrowseView.tsx +++ b/frontend/src/routes/BrowseView.tsx @@ -1,69 +1,91 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import Graph from "graphology"; -import Sigma from "sigma"; -import type { NodeDisplayData, EdgeDisplayData } from "sigma/types"; -import { NodeSquareProgram } from "@sigma/node-square"; -import ForceSupervisor from "graphology-layout-force/worker"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { Graph } from "@cosmos.gl/graph"; import { subscribeBrowseNodes, fetchRemotePage, type NetworkNode } from "@/api/client"; import { renderMicron } from "@/components/editor/micronRenderer"; -import axisMundiUrl from "@/assets/axis-mundi.min.svg"; // --------------------------------------------------------------------------- -// Theme — read from CSS custom properties +// Constants — matching cosmos.gl clusters-with-labels example // --------------------------------------------------------------------------- -function cssVarToHex(varName: string): string { +const SPACE_SIZE = 4096; +const CENTER = SPACE_SIZE / 2; + +// --------------------------------------------------------------------------- +// Theme-aware status colors — reads CSS variables, returns 0–1 RGBA +// --------------------------------------------------------------------------- + +function cssVarToRGBA(varName: string): [number, number, number, number] { const raw = getComputedStyle(document.documentElement).getPropertyValue(varName).trim(); - if (!raw) return "#808080"; + if (!raw) return [0.5, 0.5, 0.5, 1]; const ctx = document.createElement("canvas").getContext("2d")!; ctx.fillStyle = raw; - return ctx.fillStyle; + // ctx.fillStyle normalizes to #rrggbb + const hex = ctx.fillStyle; + 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; + return [r, g, b, 1]; } -function getThemeColors() { +function lerpRGBA( + a: [number, number, number, number], + b: [number, number, number, number], + t: number, +): [number, number, number, number] { + return [ + a[0] + (b[0] - a[0]) * t, + a[1] + (b[1] - a[1]) * t, + a[2] + (b[2] - a[2]) * t, + a[3] + (b[3] - a[3]) * t, + ]; +} + +function brighten(c: [number, number, number, number], amount: number): [number, number, number, number] { + return [ + Math.min(1, c[0] + amount), + Math.min(1, c[1] + amount), + Math.min(1, c[2] + amount), + c[3], + ]; +} + +function getThemeStatusColors() { + const primary = brighten(cssVarToRGBA("--primary"), 0.15); + const muted = cssVarToRGBA("--muted-foreground"); return { - primary: cssVarToHex("--primary"), - muted: cssVarToHex("--muted-foreground"), - border: cssVarToHex("--border"), - foreground: cssVarToHex("--foreground"), - bg: cssVarToHex("--background"), + online: primary, // --primary brightened + stale: lerpRGBA(primary, muted, 0.4), // blend, closer to primary + offline: muted, // --muted-foreground }; } -function lerpHex(a: string, b: string, t: number): string { - const parse = (h: string) => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)]; - const ca = parse(a), cb = parse(b); - const r = Math.round(ca[0] + (cb[0] - ca[0]) * t); - const g = Math.round(ca[1] + (cb[1] - ca[1]) * t); - const bl = Math.round(ca[2] + (cb[2] - ca[2]) * t); - return "#" + [r, g, bl].map(c => c.toString(16).padStart(2, "0")).join(""); -} - -type ThemeColors = ReturnType; - -// Dimmed color for non-matching nodes -function dimColor(bg: string): string { - return lerpHex(bg, "#808080", 0.15); +function statusRGBA(entry: NetworkNode, theme: ReturnType): [number, number, number, number] { + const age = Date.now() / 1000 - (entry.last_seen ?? 0); + if (age < 300) return theme.online; + if (age < 3600) return theme.stale; + return theme.offline; } // --------------------------------------------------------------------------- -// Incremental graph sync — adds/removes/updates nodes without clearing +// Build flat arrays for cosmos — positions around spaceSize/2 // --------------------------------------------------------------------------- -function nodeAttrs(entry: NetworkNode, colors: ThemeColors, ifaceColor: string) { - if (entry.is_self) { - return { label: entry.name, size: 12, color: colors.primary, type: "circle" }; - } - if (entry.type === "interface") { - return { label: `⬡ ${entry.name}`, size: 8, color: ifaceColor, type: "square" }; - } - return { label: entry.name, size: 5, color: colors.foreground, type: "circle" }; +interface BuiltGraph { + entries: NetworkNode[]; + positions: Float32Array; + colors: Float32Array; + sizes: Float32Array; + clusterIndices: (number | undefined)[]; + clusterPositions: (number | undefined)[]; + clusterStrength: Float32Array; + clusterNames: string[]; + hashToIndex: Map; } -function findParent( +function findParentIface( entry: NetworkNode, interfaces: NetworkNode[], - selfHash: string | undefined, peerIndex: number, ): string | undefined { if (entry.interface) { @@ -71,508 +93,517 @@ function findParent( if (iface) return iface.hash; } if (interfaces.length > 0) return interfaces[peerIndex % interfaces.length].hash; - return selfHash; + return undefined; } -function syncGraph( - graph: Graph, - entries: NetworkNode[], - colors: ThemeColors, -) { - const ifaceColor = lerpHex(colors.primary, colors.muted, 0.4); - const selfEntry = entries.find(e => e.is_self); - const interfaces = entries.filter(e => e.type === "interface"); - const peers = entries.filter(e => !e.is_self && e.type !== "interface"); +function buildGraphArrays( + rawNodes: NetworkNode[], + prevHashToIndex: Map, + prevPositions: number[], + theme: ReturnType, +): BuiltGraph { + const interfaces = rawNodes.filter(e => e.type === "interface").sort((a, b) => a.name.localeCompare(b.name)); + const peers = rawNodes.filter(e => !e.is_self && e.type !== "interface"); - const desiredNodes = new Set(entries.map(e => e.hash)); + // Only peers are points — interfaces are clusters, not nodes + const entries = peers; + const n = entries.length; + const nClusters = interfaces.length; + const hashToIndex = new Map(); + 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); - // --- Remove nodes no longer present --- - const toRemove = graph.nodes().filter(n => !desiredNodes.has(n)); - for (const n of toRemove) graph.dropNode(n); + // Cluster map: interface name → cluster index + const clusterMap = new Map(); + interfaces.forEach((iface, i) => clusterMap.set(iface.name, i)); - // --- Add or update self --- - if (selfEntry) { - const attrs = nodeAttrs(selfEntry, colors, ifaceColor); - if (graph.hasNode(selfEntry.hash)) { - graph.mergeNodeAttributes(selfEntry.hash, attrs); + // Cluster names for labels + const clusterNames = interfaces.map(i => i.name); + + // No explicit cluster positions — let cosmos use centermass + const clusterPositions: (number | undefined)[] = []; + + entries.forEach((entry, i) => { + hashToIndex.set(entry.hash, i); + + // Position: preserve existing, 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 { - graph.addNode(selfEntry.hash, { x: 0, y: 0, fixed: true, ...attrs }); + positions[i * 2] = CENTER + (Math.random() - 0.5) * SPACE_SIZE * 0.5; + positions[i * 2 + 1] = CENTER + (Math.random() - 0.5) * SPACE_SIZE * 0.5; } - } - // --- Add or update interfaces --- - interfaces.forEach((iface, ci) => { - const attrs = nodeAttrs(iface, colors, ifaceColor); - if (graph.hasNode(iface.hash)) { - graph.mergeNodeAttributes(iface.hash, attrs); - } else { - const angle = (ci / Math.max(1, interfaces.length)) * Math.PI * 2 - Math.PI / 2; - const radius = 3; - graph.addNode(iface.hash, { - x: Math.cos(angle) * radius, - y: Math.sin(angle) * radius, - fixed: true, - ...attrs, - }); - } - // Ensure edge self → interface - if (selfEntry && !graph.hasEdge(selfEntry.hash, iface.hash)) { - graph.addEdge(selfEntry.hash, iface.hash, { color: colors.border, size: 2 }); - } + // Colors — by status + const rgba = 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]; + + sizes[i] = 3; + + // Cluster assignment — map to parent interface + 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); + + // Cluster strength + clusterStrength[i] = nClusters > 1 ? (nClusters - (i % nClusters)) / nClusters : 1; }); - // --- Add or update peers --- - peers.forEach((peer, pi) => { - const attrs = nodeAttrs(peer, colors, ifaceColor); - const parentHash = findParent(peer, interfaces, selfEntry?.hash, pi); - - if (graph.hasNode(peer.hash)) { - graph.mergeNodeAttributes(peer.hash, attrs); - } else { - // Position near parent - let px = 0, py = 0; - if (parentHash && graph.hasNode(parentHash)) { - const parent = graph.getNodeAttributes(parentHash); - const angle = Math.random() * Math.PI * 2; - const dist = 1.5 + Math.random() * 2; - px = (parent.x ?? 0) + Math.cos(angle) * dist; - py = (parent.y ?? 0) + Math.sin(angle) * dist; - } - graph.addNode(peer.hash, { x: px, y: py, ...attrs }); - } - - // Ensure edge parent → peer - if (parentHash && graph.hasNode(parentHash) && !graph.hasEdge(parentHash, peer.hash)) { - graph.addEdge(parentHash, peer.hash, { color: colors.border, size: 1 }); - } - }); - - // --- Clean stale edges (both endpoints must still exist) --- - graph.forEachEdge((edge, _attrs, source, target) => { - if (!desiredNodes.has(source) || !desiredNodes.has(target)) { - graph.dropEdge(edge); - } - }); + return { entries, positions, colors, sizes, clusterIndices, clusterPositions, clusterStrength, clusterNames, hashToIndex }; } // --------------------------------------------------------------------------- -// Reducer state — mutable, drives nodeReducer / edgeReducer +// Per-window state & BrowserWindow // --------------------------------------------------------------------------- -interface ReducerState { - hoveredNode?: string; - hoveredNeighbors?: Set; - searchQuery: string; - selectedNode?: string; - suggestions?: Set; +interface BrowserWin { + id: string; node: NetworkNode; + pageHtml: string | null; pageLoading: boolean; pageError: string | null; + x: number; y: number; w: number; h: number; zIndex: number; +} + +let nextWinZ = 1; + +const DITHERED_SHADOW = ` + 3px 3px 0 0 var(--border), 5px 3px 0 0 transparent, 7px 3px 0 0 var(--border), + 4px 4px 0 0 transparent, 6px 4px 0 0 var(--border), + 3px 5px 0 0 var(--border), 5px 5px 0 0 transparent, 7px 5px 0 0 var(--border), + 4px 6px 0 0 var(--border), 6px 6px 0 0 transparent +`; + +function BrowserWindow({ + win, focused, onUpdate, onClose, onFocus, +}: { + win: BrowserWin; focused: boolean; + onUpdate: (id: string, patch: Partial) => void; + onClose: (id: string) => void; onFocus: (id: string) => void; +}) { + const ref = useRef(null); + const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null); + const resizeRef = useRef<{ startX: number; startY: number; origW: number; origH: number } | null>(null); + useEffect(() => { if (focused) ref.current?.focus(); }, [focused]); + + const onDragStart = useCallback((e: React.MouseEvent) => { + if ((e.target as HTMLElement).closest("button")) return; + e.preventDefault(); onFocus(win.id); + dragRef.current = { startX: e.clientX, startY: e.clientY, origX: win.x, origY: win.y }; + const onMove = (ev: MouseEvent) => { if (!dragRef.current) return; onUpdate(win.id, { x: dragRef.current.origX + (ev.clientX - dragRef.current.startX), y: Math.max(0, dragRef.current.origY + (ev.clientY - dragRef.current.startY)) }); }; + const onUp = () => { dragRef.current = null; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; + document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); + }, [win.id, win.x, win.y, onUpdate, onFocus]); + + const onResizeStart = useCallback((e: React.MouseEvent) => { + e.preventDefault(); e.stopPropagation(); onFocus(win.id); + resizeRef.current = { startX: e.clientX, startY: e.clientY, origW: win.w, origH: win.h }; + const onMove = (ev: MouseEvent) => { if (!resizeRef.current) return; onUpdate(win.id, { w: Math.max(320, resizeRef.current.origW + (ev.clientX - resizeRef.current.startX)), h: Math.max(200, resizeRef.current.origH + (ev.clientY - resizeRef.current.startY)) }); }; + const onUp = () => { resizeRef.current = null; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; + document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); + }, [win.id, win.w, win.h, onUpdate, onFocus]); + + return createPortal( +
{ if (e.key === "Escape") onClose(win.id); }} onMouseDown={() => onFocus(win.id)} + className="fixed z-999 flex flex-col bg-popover text-popover-foreground border-2 rounded-lg outline-none transition-[border-color,opacity] duration-150" + style={{ left: win.x, top: win.y, width: win.w, height: win.h, zIndex: 999 + win.zIndex, borderColor: focused ? "var(--primary)" : "var(--border)", opacity: focused ? 1 : 0.85, boxShadow: DITHERED_SHADOW }}> +
+
+
+ {win.node.name} +
+
+ addr +
{win.node.hash}
+ + {win.pageLoading ? loading + : win.pageError ? <>error + : win.pageHtml ? <>ok : null} + +
+
+ {win.pageLoading && Requesting page...} + {win.pageError && {win.pageError}} + {win.pageHtml &&
} +
+
+ {win.node.type ?? "peer"} + {win.node.interface && via {win.node.interface}} +
+
+ +
+
, document.body); } // --------------------------------------------------------------------------- -// Component +// Main component // --------------------------------------------------------------------------- export default function BrowseView() { const [nodes, setNodes] = useState([]); const [filter, setFilter] = useState(""); - const [selectedNode, setSelectedNode] = useState(null); - const [pageHtml, setPageHtml] = useState(null); - const [pageLoading, setPageLoading] = useState(false); - const [pageError, setPageError] = useState(null); + const [windows, setWindows] = useState([]); + const [focusedWinId, setFocusedWinId] = useState(null); + const [hoveredLabel, setHoveredLabel] = useState<{ text: string; x: number; y: number } | null>(null); + const [themeRev, setThemeRev] = useState(0); - const containerRef = useRef(null); - const sigmaRef = useRef(null); - const graphRef = useRef(null); - const layoutRef = useRef(null); - const nodesMapRef = useRef>(new Map()); - const stateRef = useRef({ searchQuery: "" }); - - // ── Watch for theme changes ── + // Watch for theme changes (class on ) useEffect(() => { - const observer = new MutationObserver(() => setThemeRev((r) => r + 1)); + const observer = new MutationObserver(() => setThemeRev(r => r + 1)); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] }); return () => observer.disconnect(); }, []); - // ── Initialize Sigma with reducers ── + const containerRef = useRef(null); + const searchInputRef = useRef(null); + const graphRef = useRef(null); + const entriesRef = useRef([]); + const nodesMapRef = useRef>(new Map()); + const hashToIndexRef = useRef>(new Map()); + const clusterNamesRef = useRef([]); + + // 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(); + 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 + 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]); + + // ── Cluster labels — direct DOM like the example's create-cluster-labels.ts ── + const labelDivsRef = useRef([]); + 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 = "2px 8px"; + div.style.borderRadius = "4px"; + div.style.background = "var(--popover)"; + div.style.border = "1px solid var(--border)"; + div.style.color = "var(--foreground)"; + div.style.fontFamily = "JetBrains Mono, monospace"; + div.style.fontSize = "11px"; + div.style.boxShadow = "0 2px 8px rgba(0,0,0,0.3)"; + div.textContent = names[i] ?? ""; + container.appendChild(div); + labelDivsRef.current.push(div); + } + } + + // Update positions + 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`; + } + }, []); + + // ── Init Cosmos — matching the example's create-cosmos.ts ── useEffect(() => { if (!containerRef.current) return; - const graph = new Graph(); + 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, + // Simulation defaults — dynamically adjusted by node count in data update + simulationGravity: 0.5, + simulationRepulsion: 1, + simulationCluster: 0.5, + simulationDecay: 5000, + simulationFriction: 0.85, + simulationLinkSpring: 0, + simulationLinkDistance: 1, + // Events + 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(), + onSimulationEnd: () => { graphRef.current?.fitView(300, 0.2); updateClusterLabels(); }, + onZoom: () => updateClusterLabels(), + }); + graphRef.current = graph; - const colors = getThemeColors(); - const dim = dimColor(colors.bg); - - const renderer = new Sigma(graph, containerRef.current, { - allowInvalidContainer: true, - nodeProgramClasses: { square: NodeSquareProgram }, - defaultNodeColor: colors.primary, - defaultEdgeColor: colors.border, - labelColor: { color: colors.foreground }, - labelFont: "JetBrains Mono, monospace", - labelSize: 10, - labelRenderedSizeThreshold: 0, - renderEdgeLabels: false, - enableEdgeEvents: false, - // ── Node reducer: search highlighting + hover dimming ── - nodeReducer: (node, data) => { - const res: Partial = { ...data }; - const s = stateRef.current; - - // Hover: dim non-neighbors - if (s.hoveredNeighbors && !s.hoveredNeighbors.has(node) && s.hoveredNode !== node) { - res.label = ""; - res.color = dim; - } - - // Search: highlight matches, dim the rest - if (s.selectedNode === node) { - res.highlighted = true; - } else if (s.suggestions) { - if (s.suggestions.has(node)) { - res.forceLabel = true; - } else { - res.label = ""; - res.color = dim; - } - } - - return res; - }, - // ── Edge reducer: hide irrelevant edges ── - edgeReducer: (edge, data) => { - const res: Partial = { ...data }; - const s = stateRef.current; - const g = graphRef.current!; - - // Hover: hide edges not connected to hovered node - if (s.hoveredNode && !g.extremities(edge).includes(s.hoveredNode)) { - res.hidden = true; - } - - // Search: hide edges not connecting two suggestions - if (s.suggestions && - (!s.suggestions.has(g.source(edge)) || !s.suggestions.has(g.target(edge)))) { - res.hidden = true; - } - - return res; - }, - }); - - // Hover events - renderer.on("enterNode", ({ node }) => { - stateRef.current.hoveredNode = node; - stateRef.current.hoveredNeighbors = new Set(graph.neighbors(node)); - renderer.refresh({ skipIndexation: true }); - }); - renderer.on("leaveNode", () => { - stateRef.current.hoveredNode = undefined; - stateRef.current.hoveredNeighbors = undefined; - renderer.refresh({ skipIndexation: true }); - }); - - // Click events - renderer.on("clickNode", ({ node }) => { - const entry = nodesMapRef.current.get(node); - if (entry && entry.type !== "interface") { - handleNodeClick(entry); - } - }); - renderer.on("clickStage", () => { - setSelectedNode(null); - setPageHtml(null); - setPageError(null); - }); - - // Force layout — runs continuously, pins fixed nodes - const layout = new ForceSupervisor(graph, { - isNodeFixed: (_, attr) => attr.fixed, - settings: { gravity: 0.0005, repulsion: 0.5, attraction: 0.01, inertia: 0.6 }, - }); - layout.start(); - layoutRef.current = layout; - - sigmaRef.current = renderer; - return () => { - layout.kill(); - renderer.kill(); - layoutRef.current = null; - sigmaRef.current = null; + labelDivsRef.current.forEach(d => d.remove()); + labelDivsRef.current = []; + graph.destroy(); graphRef.current = null; }; }, []); - // ── Search: update reducer state when filter changes ── + // ── Update data — following the example's exact call order ── + const isFirstLoadRef = useRef(true); useEffect(() => { - const renderer = sigmaRef.current; const graph = graphRef.current; - if (!renderer || !graph) return; + if (!graph || graphData.entries.length === 0) return; - const s = stateRef.current; - const query = filter.trim(); - s.searchQuery = query; + // Scale simulation params by node count + // Reference: 10k nodes → repulsion 10, cluster 0.25, gravity 2 + // Scale logarithmically so it works from 10 to 10k nodes + const n = graphData.entries.length; + const scale = Math.log10(Math.max(10, n)) / Math.log10(10000); // 0..1 + graph.setConfig({ + simulationRepulsion: 0.5 + scale * 9.5, // 0.5 → 10 + simulationCluster: 1.0 - scale * 0.75, // 1.0 → 0.25 + simulationGravity: 0.25 + scale * 1.75, // 0.25 → 2 + }); - if (query) { - const lcQuery = query.toLowerCase(); - const matches = graph - .nodes() - .map((n) => ({ id: n, label: (graph.getNodeAttribute(n, "label") as string) || "" })) - .filter(({ label }) => label.toLowerCase().includes(lcQuery)); + 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)); - // Exact single match → select and zoom - if (matches.length === 1 && matches[0].label.toLowerCase() === lcQuery) { - s.selectedNode = matches[0].id; - s.suggestions = undefined; - - const nodePosition = renderer.getNodeDisplayData(s.selectedNode); - if (nodePosition) { - renderer.getCamera().animate(nodePosition, { duration: 500 }); - } - } else { - s.selectedNode = undefined; - s.suggestions = new Set(matches.map(({ id }) => id)); - } + if (isFirstLoadRef.current) { + graph.render(1); + isFirstLoadRef.current = false; } else { - s.selectedNode = undefined; - s.suggestions = undefined; + graph.render(0.1); + } + updateClusterLabels(); + }, [graphData]); + + // ── Search highlighting + labels ── + const searchLabelDivsRef = useRef([]); + const searchTimerRef = useRef | null>(null); + + const clearSearchLabels = useCallback(() => { + if (searchTimerRef.current) { clearTimeout(searchTimerRef.current); searchTimerRef.current = null; } + searchLabelDivsRef.current.forEach(d => d.remove()); + searchLabelDivsRef.current = []; + }, []); + + useEffect(() => { + const graph = graphRef.current; + const container = containerRef.current; + if (!graph || !container) return; + + clearSearchLabels(); + + if (searchMatchIndices) { + graph.selectPointsByIndices(searchMatchIndices); + if (searchMatchIndices.length <= 10) { + graph.fitViewByPointIndices(searchMatchIndices, 500); + } + + searchTimerRef.current = setTimeout(() => { + searchTimerRef.current = null; + if (!graphRef.current) return; + const positions = graphRef.current.getPointPositions(); + const entries = entriesRef.current; + for (const idx of searchMatchIndices) { + const x = positions[idx * 2]; + const y = positions[idx * 2 + 1]; + if (x === undefined || y === undefined) continue; + const screen = graphRef.current.spaceToScreenPosition([x, y]); + const div = document.createElement("div"); + div.style.position = "absolute"; + div.style.pointerEvents = "none"; + div.style.left = `${screen[0] + 10}px`; + div.style.top = `${screen[1] - 8}px`; + div.style.padding = "2px 8px"; + div.style.borderRadius = "4px"; + div.style.background = "var(--popover)"; + div.style.border = "1px solid var(--border)"; + div.style.color = "var(--foreground)"; + div.style.fontFamily = "JetBrains Mono, monospace"; + div.style.fontSize = "11px"; + div.style.boxShadow = "0 2px 8px rgba(0,0,0,0.3)"; + div.style.whiteSpace = "nowrap"; + div.textContent = entries[idx]?.name ?? ""; + container.appendChild(div); + searchLabelDivsRef.current.push(div); + } + }, 600); + } else { + graph.unselectPoints(); + graph.fitView(300, 0.2); } - renderer.refresh({ skipIndexation: true }); - }, [filter]); + return () => clearSearchLabels(); + }, [searchMatchIndices, clearSearchLabels]); - // ── Incrementally sync graph when nodes arrive or theme changes ── - useEffect(() => { - const renderer = sigmaRef.current; - const graph = graphRef.current; - if (!renderer || !graph) return; - - const colors = getThemeColors(); - const dim = dimColor(colors.bg); - - // Build node map for click lookups - const map = new Map(); - for (const n of nodes) map.set(n.hash, n); - nodesMapRef.current = map; - - // Incremental add/remove/update — no graph.clear() - syncGraph(graph, nodes, colors); - - // Update theme settings + dim color in reducers - renderer.setSetting("defaultNodeColor", colors.primary); - renderer.setSetting("defaultEdgeColor", colors.border); - renderer.setSetting("labelColor", { color: colors.foreground }); - - renderer.setSetting("nodeReducer", (node, data) => { - const res: Partial = { ...data }; - const s = stateRef.current; - - if (s.hoveredNeighbors && !s.hoveredNeighbors.has(node) && s.hoveredNode !== node) { - res.label = ""; - res.color = dim; - } - if (s.selectedNode === node) { - res.highlighted = true; - } else if (s.suggestions) { - if (s.suggestions.has(node)) { - res.forceLabel = true; - } else { - res.label = ""; - res.color = dim; - } - } - return res; - }); - - renderer.setSetting("edgeReducer", (edge, data) => { - const res: Partial = { ...data }; - const s = stateRef.current; - - if (s.hoveredNode && !graph.extremities(edge).includes(s.hoveredNode)) { - res.hidden = true; - } - if (s.suggestions && - (!s.suggestions.has(graph.source(edge)) || !s.suggestions.has(graph.target(edge)))) { - res.hidden = true; - } - return res; - }); - - renderer.refresh(); - }, [nodes, themeRev]); - - // ── Live SSE stream (batched) ── + // ── SSE stream ── useEffect(() => { let pending: NetworkNode[] = []; let batchTimer: ReturnType | null = null; - const flush = () => { batchTimer = null; if (pending.length === 0) return; - const batch = pending; - pending = []; + 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, 100); - }); - - return () => { - unsub(); - if (batchTimer) clearTimeout(batchTimer); - flush(); - }; + const unsub = subscribeBrowseNodes((node) => { pending.push(node); if (!batchTimer) batchTimer = setTimeout(flush, 200); }); + return () => { unsub(); if (batchTimer) clearTimeout(batchTimer); flush(); }; }, []); - // ── Node click → fetch page ── + // ── Node click ── const handleNodeClick = useCallback((node: NetworkNode) => { - setSelectedNode(node); - setPageHtml(null); - setPageError(null); - setPageLoading(true); - - fetchRemotePage(node.hash) - .then((res) => { - if (res.content) setPageHtml(renderMicron(res.content, true)); - else setPageError(res.error ?? "No content"); - }) - .catch((e) => setPageError(String(e))) - .finally(() => setPageLoading(false)); + setWindows((prev) => { + const existing = prev.find((w) => w.node.hash === node.hash); + if (existing) { const z = ++nextWinZ; setFocusedWinId(existing.id); return prev.map((w) => w.id === existing.id ? { ...w, zIndex: z } : w); } + const winW = 480, winH = 400, margin = 20, z = ++nextWinZ; + const win: BrowserWin = { + id: `${node.hash}-${Date.now()}`, node, pageHtml: null, pageLoading: true, pageError: null, + x: Math.round(margin + Math.random() * (Math.max(margin, window.innerWidth - winW - margin) - margin)), + y: Math.round(margin + Math.random() * (Math.max(margin, window.innerHeight - winH - margin) - margin)), + w: winW, h: winH, zIndex: z, + }; + setFocusedWinId(win.id); + fetchRemotePage(node.hash) + .then((res) => setWindows((ws) => ws.map((w) => w.id === win.id ? { ...w, pageHtml: res.content ? renderMicron(res.content, true) : null, pageError: res.content ? null : (res.error ?? "No content"), pageLoading: false } : w))) + .catch((e) => setWindows((ws) => ws.map((w) => w.id === win.id ? { ...w, pageError: String(e), pageLoading: false } : w))); + return [...prev, win]; + }); }, []); - // ── Resizable split ── - const [graphHeight, setGraphHeight] = useState(500); - const draggingRef = useRef(false); - const wrapperRef = useRef(null); + const updateWindow = useCallback((id: string, patch: Partial) => setWindows((prev) => prev.map((w) => (w.id === id ? { ...w, ...patch } : w))), []); + const closeWindowById = useCallback((id: string) => { setWindows((prev) => prev.filter((w) => w.id !== id)); setFocusedWinId((cur) => cur === id ? null : cur); }, []); + const focusWindow = useCallback((id: string) => { setFocusedWinId((cur) => { if (cur === id) return cur; const z = ++nextWinZ; setWindows((prev) => prev.map((w) => (w.id === id ? { ...w, zIndex: z } : w))); return id; }); }, []); - const onResizeStart = useCallback((e: React.MouseEvent) => { - e.preventDefault(); - draggingRef.current = true; - - const startY = e.clientY; - const startH = graphHeight; - - const onMove = (ev: MouseEvent) => { - if (!draggingRef.current || !wrapperRef.current) return; - const wrapperH = wrapperRef.current.getBoundingClientRect().height; - const headerH = 41; // header bar height approx - const minGraph = 150; - const minPage = 100; - const maxGraph = wrapperH - headerH - minPage; - const newH = Math.min(maxGraph, Math.max(minGraph, startH + (ev.clientY - startY))); - setGraphHeight(newH); - }; - - const onUp = () => { - draggingRef.current = false; - document.removeEventListener("mousemove", onMove); - document.removeEventListener("mouseup", onUp); - // Refresh sigma after resize - sigmaRef.current?.refresh(); - }; - - document.addEventListener("mousemove", onMove); - document.addEventListener("mouseup", onUp); - }, [graphHeight]); + const clearSearch = useCallback(() => { + setFilter(""); + graphRef.current?.unselectPoints(); + graphRef.current?.fitView(300, 0.2); + updateClusterLabels(); + }, [updateClusterLabels]); const nodeCount = nodes.filter(n => n.type !== "interface").length; const ifaceCount = nodes.filter(n => n.type === "interface").length; - const colors = getThemeColors(); + + // ── Draggable search bar ── + const [searchPos, setSearchPos] = useState<{ x: number; y: number } | null>(null); + const searchDragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null); + useEffect(() => { setSearchPos({ x: Math.round(window.innerWidth / 2 - 200), y: window.innerHeight - 130 }); }, []); + + const onSearchDragStart = useCallback((e: React.MouseEvent) => { + if ((e.target as HTMLElement).tagName === "INPUT") return; + e.preventDefault(); + const pos = searchPos ?? { x: 0, y: 0 }; + searchDragRef.current = { startX: e.clientX, startY: e.clientY, origX: pos.x, origY: pos.y }; + const onMove = (ev: MouseEvent) => { if (!searchDragRef.current) return; setSearchPos({ x: searchDragRef.current.origX + (ev.clientX - searchDragRef.current.startX), y: Math.max(0, searchDragRef.current.origY + (ev.clientY - searchDragRef.current.startY)) }); }; + const onUp = () => { searchDragRef.current = null; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; + document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); + }, [searchPos]); return ( -
- {/* Header */} -
-

Browse

- setFilter(e.target.value)} - placeholder="Search nodes..." - className="flex-1 h-8 px-2 text-xs bg-muted/50 border border-border rounded placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary" - /> - - {nodeCount} node{nodeCount !== 1 && "s"} - {" · "} - {ifaceCount} iface{ifaceCount !== 1 && "s"} - -
+
+
- {/* Graph */} -
- {/* Sigma container — must have no React children */} -
- {nodes.length === 0 && ( -
- Listening for nodes on the Reticulum network... -
- )} -
+ {/* Hover label */} + {hoveredLabel && ( +
+ {hoveredLabel.text} +
+ )} - {/* Resize handle */} -
- - {/* Page viewer */} -
-
- - {selectedNode ? ( - <> - {selectedNode.name} - - {selectedNode.hash.slice(0, 12)} - - - ) : ( - Page - )} + {/* Search bar */} + {searchPos && createPortal( +
+ setFilter(e.target.value)} + onKeyDown={(e) => { if (e.key === "Escape") { clearSearch(); e.currentTarget.blur(); } }} + 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 && ( + + )} + + {nodeCount} node{nodeCount !== 1 && "s"} · {ifaceCount} iface{ifaceCount !== 1 && "s"} -
+
, document.body)} -
- {!selectedNode && !pageLoading && ( -
-
- Click a node to view its page -
- )} - {pageLoading && ( - - Requesting page... - - )} - {pageError && ( - {pageError} - )} - {pageHtml && ( -
- )} + {nodes.length === 0 && ( +
+ Listening for nodes on the Reticulum network...
-
+ )} + + {windows.map((win) => ( + + ))}
); } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 0c5a7ed..bef4b31 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ resolve: { alias: { "@": path.resolve(__dirname, "./src"), + "gl-bench": path.resolve(__dirname, "node_modules/gl-bench/dist/gl-bench.module.js"), }, }, server: {