From 46185705d7735094c8e8fb4d92bc4775acb70330 Mon Sep 17 00:00:00 2001 From: dtoro Date: Mon, 6 Apr 2026 22:27:02 +0200 Subject: [PATCH] feat: new day new graph --- frontend/package-lock.json | 634 +++++++------- frontend/package.json | 10 +- .../src/components/browse/BrowseSearchBar.tsx | 102 ++- frontend/src/components/browse/buildGraph.ts | 149 ++-- frontend/src/components/browse/graphColors.ts | 7 + frontend/src/index.css | 11 - frontend/src/routes/BrowseView.tsx | 803 +++++++++++------- frontend/src/types/three-shims.d.ts | 40 + package-lock.json | 31 + package.json | 5 + 10 files changed, 1018 insertions(+), 774 deletions(-) create mode 100644 frontend/src/types/three-shims.d.ts create mode 100644 package-lock.json create mode 100644 package.json diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 14636b0..b428e91 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,31 +16,25 @@ "@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", - "@sigma/node-square": "^3.0.0", "@tailwindcss/vite": "^4.2.2", - "@xyflow/react": "^12.10.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dompurify": "^3.3.3", - "graphology": "^0.26.0", - "graphology-layout-force": "^0.2.4", "lucide-react": "^1.7.0", "micron-parser": "^1.0.3", "next-themes": "^0.4.6", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-force-graph-3d": "^1.29.1", "react-resizable-panels": "^4.8.0", "react-router-dom": "^7.13.2", "shadcn": "^4.1.1", - "sigma": "^3.0.2", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.2", + "three-spritetext": "^1.10.0", "tw-animate-css": "^1.4.0", "zustand": "^5.0.12" }, @@ -669,70 +663,6 @@ "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", - "integrity": "sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==", - "license": "MIT", - "dependencies": { - "@dagrejs/graphlib": "4.0.1" - } - }, - "node_modules/@dagrejs/graphlib": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-4.0.1.tgz", - "integrity": "sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==", - "license": "MIT" - }, "node_modules/@dotenvx/dotenvx": { "version": "1.59.1", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.59.1.tgz", @@ -1865,15 +1795,6 @@ "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", "license": "MIT" }, - "node_modules/@sigma/node-square": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@sigma/node-square/-/node-square-3.0.0.tgz", - "integrity": "sha512-hPX2oWo7WeaSe6M3D56AXsrLyg3F+7N/YsodaJh4Sw3KTce0GAFVWWPZZklu9CITz0xi3kEmlCGulqAH0cVG2w==", - "license": "MIT", - "peerDependencies": { - "sigma": ">=3.0.0-beta.17" - } - }, "node_modules/@sindresorhus/merge-streams": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", @@ -2190,6 +2111,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@tweenjs/tween.js": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", + "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==", + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -2200,55 +2127,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/d3-color": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", - "license": "MIT" - }, - "node_modules/@types/d3-drag": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", - "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", - "license": "MIT", - "dependencies": { - "@types/d3-selection": "*" - } - }, - "node_modules/@types/d3-interpolate": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", - "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "license": "MIT", - "dependencies": { - "@types/d3-color": "*" - } - }, - "node_modules/@types/d3-selection": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", - "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", - "license": "MIT" - }, - "node_modules/@types/d3-transition": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", - "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", - "license": "MIT", - "dependencies": { - "@types/d3-selection": "*" - } - }, - "node_modules/@types/d3-zoom": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", - "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", - "license": "MIT", - "dependencies": { - "@types/d3-interpolate": "*", - "@types/d3-selection": "*" - } - }, "node_modules/@types/dompurify": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", @@ -2643,64 +2521,20 @@ } } }, - "node_modules/@xyflow/react": { - "version": "12.10.2", - "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz", - "integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==", + "node_modules/3d-force-graph": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/3d-force-graph/-/3d-force-graph-1.80.0.tgz", + "integrity": "sha512-tzI353gW1nXPpnC7VTa3JjMg+3cp77qOLUFO0vucPTfF+q5R6sQsNsIqVTbRIb7RSypn14nBa4yfkOe9ThxASw==", "license": "MIT", "dependencies": { - "@xyflow/system": "0.0.76", - "classcat": "^5.0.3", - "zustand": "^4.4.0" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, - "node_modules/@xyflow/react/node_modules/zustand": { - "version": "4.5.7", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", - "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", - "license": "MIT", - "dependencies": { - "use-sync-external-store": "^1.2.2" + "accessor-fn": "1", + "kapsule": "^1.16", + "three": ">=0.179 <1", + "three-forcegraph": "1", + "three-render-objects": "^1.41" }, "engines": { - "node": ">=12.7.0" - }, - "peerDependencies": { - "@types/react": ">=16.8", - "immer": ">=9.0.6", - "react": ">=16.8" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - } - } - }, - "node_modules/@xyflow/system": { - "version": "0.0.76", - "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.76.tgz", - "integrity": "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==", - "license": "MIT", - "dependencies": { - "@types/d3-drag": "^3.0.7", - "@types/d3-interpolate": "^3.0.4", - "@types/d3-selection": "^3.0.10", - "@types/d3-transition": "^3.0.8", - "@types/d3-zoom": "^3.0.8", - "d3-drag": "^3.0.0", - "d3-interpolate": "^3.0.1", - "d3-selection": "^3.0.0", - "d3-zoom": "^3.0.0" + "node": ">=12" } }, "node_modules/accepts": { @@ -2716,6 +2550,15 @@ "node": ">= 0.6" } }, + "node_modules/accessor-fn": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz", + "integrity": "sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -3059,12 +2902,6 @@ "url": "https://polar.sh/cva" } }, - "node_modules/classcat": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", - "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", - "license": "MIT" - }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -3366,6 +3203,12 @@ "node": ">=12" } }, + "node_modules/d3-binarytree": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz", + "integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==", + "license": "MIT" + }, "node_modules/d3-color": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", @@ -3384,28 +3227,22 @@ "node": ">=12" } }, - "node_modules/d3-drag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", - "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", - "license": "ISC", + "node_modules/d3-force-3d": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz", + "integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==", + "license": "MIT", "dependencies": { + "d3-binarytree": "1", "d3-dispatch": "1 - 3", - "d3-selection": "3" + "d3-octree": "1", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" }, "engines": { "node": ">=12" } }, - "node_modules/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12" - } - }, "node_modules/d3-format": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", @@ -3427,6 +3264,21 @@ "node": ">=12" } }, + "node_modules/d3-octree": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz", + "integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==", + "license": "MIT" + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-scale": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", @@ -3443,6 +3295,19 @@ "node": ">=12" } }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-selection": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", @@ -3485,36 +3350,13 @@ "node": ">=12" } }, - "node_modules/d3-transition": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", - "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", - "license": "ISC", + "node_modules/data-bind-mapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/data-bind-mapper/-/data-bind-mapper-1.0.3.tgz", + "integrity": "sha512-QmU3lyEnbENQPo0M1F9BMu4s6cqNNp8iJA+b/HP2sSb7pf3dxwF3+EP1eO69rwBfH9kFJ1apmzrtogAmVt2/Xw==", + "license": "MIT", "dependencies": { - "d3-color": "1 - 3", - "d3-dispatch": "1 - 3", - "d3-ease": "1 - 3", - "d3-interpolate": "1 - 3", - "d3-timer": "1 - 3" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "d3-selection": "2 - 3" - } - }, - "node_modules/d3-zoom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", - "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", - "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-drag": "2 - 3", - "d3-interpolate": "1 - 3", - "d3-selection": "2 - 3", - "d3-transition": "2 - 3" + "accessor-fn": "1" }, "engines": { "node": ">=12" @@ -4017,15 +3859,6 @@ "node": ">= 0.6" } }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "license": "MIT", - "engines": { - "node": ">=0.8.x" - } - }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -4355,6 +4188,20 @@ "dev": true, "license": "ISC" }, + "node_modules/float-tooltip": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/float-tooltip/-/float-tooltip-1.7.5.tgz", + "integrity": "sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==", + "license": "MIT", + "dependencies": { + "d3-selection": "2 - 3", + "kapsule": "^1.16", + "preact": "10" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -4523,18 +4370,6 @@ "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", @@ -4579,46 +4414,6 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, - "node_modules/graphology": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/graphology/-/graphology-0.26.0.tgz", - "integrity": "sha512-8SSImzgUUYC89Z042s+0r/vMibY7GX/Emz4LDO5e7jYXhuoWfHISPFJYjpRLUSJGq6UQ6xlenvX1p/hJdfXuXg==", - "license": "MIT", - "dependencies": { - "events": "^3.3.0" - }, - "peerDependencies": { - "graphology-types": ">=0.24.0" - } - }, - "node_modules/graphology-layout-force": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/graphology-layout-force/-/graphology-layout-force-0.2.4.tgz", - "integrity": "sha512-NYZz0YAnDkn5pkm30cvB0IScFoWGtbzJMrqaiH070dYlYJiag12Oc89dbVfaMaVR/w8DMIKxn/ix9Bqj+Umm9Q==", - "license": "MIT", - "dependencies": { - "graphology-utils": "^2.4.2" - }, - "peerDependencies": { - "graphology-types": ">=0.19.0" - } - }, - "node_modules/graphology-types": { - "version": "0.24.8", - "resolved": "https://registry.npmjs.org/graphology-types/-/graphology-types-0.24.8.tgz", - "integrity": "sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q==", - "license": "MIT", - "peer": true - }, - "node_modules/graphology-utils": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/graphology-utils/-/graphology-utils-2.5.2.tgz", - "integrity": "sha512-ckHg8MXrXJkOARk56ZaSCM1g1Wihe2d6iTmz1enGOz4W/l831MBCKSayeFQfowgF8wd+PQ4rlch/56Vs/VZLDQ==", - "license": "MIT", - "peerDependencies": { - "graphology-types": ">=0.23.0" - } - }, "node_modules/graphql": { "version": "16.13.2", "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", @@ -5015,6 +4810,15 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/jerrypick": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/jerrypick/-/jerrypick-1.1.2.tgz", + "integrity": "sha512-YKnxXEekXKzhpf7CLYA0A+oDP8V0OhICNCr5lv96FvSsDEmrb0GKM776JgQvHTMjr7DTTPEVv/1Ciaw0uEWzBA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -5120,6 +4924,18 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/kapsule": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/kapsule/-/kapsule-1.16.3.tgz", + "integrity": "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==", + "license": "MIT", + "dependencies": { + "lodash-es": "4" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5424,6 +5240,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5471,6 +5293,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -5748,6 +5582,44 @@ "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, + "node_modules/ngraph.events": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/ngraph.events/-/ngraph.events-1.4.0.tgz", + "integrity": "sha512-NeDGI4DSyjBNBRtA86222JoYietsmCXbs8CEB0dZ51Xeh4lhVl1y3wpWLumczvnha8sFQIW4E0vvVWwgmX2mGw==", + "license": "BSD-3-Clause" + }, + "node_modules/ngraph.forcelayout": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/ngraph.forcelayout/-/ngraph.forcelayout-3.3.1.tgz", + "integrity": "sha512-MKBuEh1wujyQHFTW57y5vd/uuEOK0XfXYxm3lC7kktjJLRdt/KEKEknyOlc6tjXflqBKEuYBBcu7Ax5VY+S6aw==", + "license": "BSD-3-Clause", + "dependencies": { + "ngraph.events": "^1.0.0", + "ngraph.merge": "^1.0.0", + "ngraph.random": "^1.0.0" + } + }, + "node_modules/ngraph.graph": { + "version": "20.1.2", + "resolved": "https://registry.npmjs.org/ngraph.graph/-/ngraph.graph-20.1.2.tgz", + "integrity": "sha512-W/G3GBR3Y5UxMLHTUCPP9v+pbtpzwuAEIqP5oZV+9IwgxAIEZwh+Foc60iPc1idlnK7Zxu0p3puxAyNmDvBd0Q==", + "license": "BSD-3-Clause", + "dependencies": { + "ngraph.events": "^1.4.0" + } + }, + "node_modules/ngraph.merge": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ngraph.merge/-/ngraph.merge-1.0.0.tgz", + "integrity": "sha512-5J8YjGITUJeapsomtTALYsw7rFveYkM+lBj3QiYZ79EymQcuri65Nw3knQtFxQBU1r5iOaVRXrSwMENUPK62Vg==", + "license": "MIT" + }, + "node_modules/ngraph.random": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/ngraph.random/-/ngraph.random-1.2.0.tgz", + "integrity": "sha512-4EUeAGbB2HWX9njd6bP6tciN6ByJfoaAvmVL9QTaZSeXrW46eNGA9GajiXiPBbvFqxUWFkEbyo6x5qsACUuVfA==", + "license": "BSD-3-Clause" + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -6106,6 +5978,18 @@ "node": ">=16.20.0" } }, + "node_modules/polished": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz", + "integrity": "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", @@ -6159,6 +6043,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/preact": { + "version": "10.29.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz", + "integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6206,6 +6100,17 @@ "node": ">=6" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -6264,18 +6169,6 @@ ], "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", @@ -6321,6 +6214,44 @@ "react": "^19.2.4" } }, + "node_modules/react-force-graph-3d": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/react-force-graph-3d/-/react-force-graph-3d-1.29.1.tgz", + "integrity": "sha512-5Vp+PGpYnO+zLwgK2NvNqdXHvsWLrFzpDfJW1vUA1twjo9SPvXqfUYQrnRmAbD+K2tOxkZw1BkbH31l5b4TWHg==", + "license": "MIT", + "dependencies": { + "3d-force-graph": "^1.79", + "prop-types": "15", + "react-kapsule": "^2.5" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-kapsule": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/react-kapsule/-/react-kapsule-2.5.7.tgz", + "integrity": "sha512-kifAF4ZPD77qZKc4CKLmozq6GY1sBzPEJTIJb0wWFK6HsePJatK3jXplZn2eeAt3x67CDozgi7/rO8fNQ/AL7A==", + "license": "MIT", + "dependencies": { + "jerrypick": "^1.1.1" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-resizable-panels": { "version": "4.8.0", "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.8.0.tgz", @@ -6385,12 +6316,6 @@ "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", @@ -6568,12 +6493,6 @@ "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", @@ -6787,16 +6706,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/sigma": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/sigma/-/sigma-3.0.2.tgz", - "integrity": "sha512-/BUbeOwPGruiBOm0YQQ6ZMcLIZ6tf/W+Jcm7dxZyAX0tK3WP9/sq7/NAWBxPIxVahdGjCJoGwej0Gdrv0DxlQQ==", - "license": "MIT", - "dependencies": { - "events": "^3.3.0", - "graphology-utils": "^2.5.2" - } - }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -7019,12 +6928,79 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/three": { + "version": "0.183.2", + "resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz", + "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==", + "license": "MIT" + }, + "node_modules/three-forcegraph": { + "version": "1.43.2", + "resolved": "https://registry.npmjs.org/three-forcegraph/-/three-forcegraph-1.43.2.tgz", + "integrity": "sha512-KUlqDaWVsrYtKx0NVVi5M3NR46K5JQIiPEzZnTMvBq7EHVF2tJpWtgGiAT1mhaerrlJ7F4UGNS2rIVYHmVrzYw==", + "license": "MIT", + "dependencies": { + "accessor-fn": "1", + "d3-array": "1 - 3", + "d3-force-3d": "2 - 3", + "d3-scale": "1 - 4", + "d3-scale-chromatic": "1 - 3", + "data-bind-mapper": "1", + "kapsule": "^1.16", + "ngraph.forcelayout": "3", + "ngraph.graph": "20", + "tinycolor2": "1" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "three": ">=0.118.3" + } + }, + "node_modules/three-render-objects": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/three-render-objects/-/three-render-objects-1.41.1.tgz", + "integrity": "sha512-0H7l7yREPVKfO3HL7RjPQ67T0phHgnyMeEc4ww/OCEfK6jbsm7psEcrR0SGFqGDyS/pDQTPi4DyPbS/xlHRJKw==", + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "18 - 25", + "accessor-fn": "1", + "float-tooltip": "^1.7", + "kapsule": "^1.16", + "polished": "4" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "three": ">=0.179" + } + }, + "node_modules/three-spritetext": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/three-spritetext/-/three-spritetext-1.10.0.tgz", + "integrity": "sha512-t08iP1FCU1lQh8T5MmCpdijKgas8GDHJE0LqMGBuVu3xqMMpFnEZhTlih7FlxLPQizHIGoumUSpfOlY1GO/Tgg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "three": ">=0.86.0" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", diff --git a/frontend/package.json b/frontend/package.json index 63a78a5..cc5ead4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,31 +18,25 @@ "@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", - "@sigma/node-square": "^3.0.0", "@tailwindcss/vite": "^4.2.2", - "@xyflow/react": "^12.10.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dompurify": "^3.3.3", - "graphology": "^0.26.0", - "graphology-layout-force": "^0.2.4", "lucide-react": "^1.7.0", "micron-parser": "^1.0.3", "next-themes": "^0.4.6", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-force-graph-3d": "^1.29.1", "react-resizable-panels": "^4.8.0", "react-router-dom": "^7.13.2", "shadcn": "^4.1.1", - "sigma": "^3.0.2", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.2", + "three-spritetext": "^1.10.0", "tw-animate-css": "^1.4.0", "zustand": "^5.0.12" }, diff --git a/frontend/src/components/browse/BrowseSearchBar.tsx b/frontend/src/components/browse/BrowseSearchBar.tsx index 8f5f966..f40c39a 100644 --- a/frontend/src/components/browse/BrowseSearchBar.tsx +++ b/frontend/src/components/browse/BrowseSearchBar.tsx @@ -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(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(
- 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 && ( - - )} - {suggestions.length > 0 && ( -
- {suggestions.map((entry, i) => ( +
+ 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 && ( + + )} + {!filter && focused && ( + + {allNodes.length} nodes + + )} +
+ {showDropdown && ( +
+ {displayList.map((entry, i) => ( ))} + {hasMore && ( +
+ {visibleList.length - maxVisible} more — type to narrow +
+ )}
)}
, document.body); diff --git a/frontend/src/components/browse/buildGraph.ts b/frontend/src/components/browse/buildGraph.ts index 28bdc6c..8ea9d45 100644 --- a/frontend/src/components/browse/buildGraph.ts +++ b/frontend/src/components/browse/buildGraph.ts @@ -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; } 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, - prevPositions: number[], + prevPositions: Map, 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(); - 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(); - 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 }; } diff --git a/frontend/src/components/browse/graphColors.ts b/frontend/src/components/browse/graphColors.ts index ad88704..4ae57c6 100644 --- a/frontend/src/components/browse/graphColors.ts +++ b/frontend/src/components/browse/graphColors.ts @@ -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")}`; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index a004a11..e73516a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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 { diff --git a/frontend/src/routes/BrowseView.tsx b/frontend/src/routes/BrowseView.tsx index 34c547c..2bf00f1 100644 --- a/frontend/src/routes/BrowseView.tsx +++ b/frontend/src/routes/BrowseView.tsx @@ -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; + +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 | null; + themeRev: number; + width: number; + height: number; + onNodeClick: (node: NetworkNode) => void; + fgRef: React.MutableRefObject | undefined>; + flyToRef: React.MutableRefObject; + containerRef: React.RefObject; +} + +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 | null>(null); + const flyTimerRef = useRef | 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(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 ( + + ); +}); + // --------------------------------------------------------------------------- // Main component // --------------------------------------------------------------------------- @@ -26,7 +363,6 @@ export default function BrowseView() { const [nodes, setNodes] = useState([]); const [filter, setFilter] = useState(""); const { windows, focusedId: focusedWinId, open: openWindow, update: updateWindow, close: closeWindowById, focus: focusWindow } = useWindowManager(); - 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(null); - const graphRef = useRef(null); - const entriesRef = useRef([]); - const nodesMapRef = useRef>(new Map()); - const hashToIndexRef = useRef>(new Map()); - const clusterNamesRef = useRef([]); - const hasUserInteractedRef = useRef(false); - const initialFitDoneRef = useRef(false); + const fgRef = useRef>(undefined); + const flyToRef = useRef(undefined); + const prevPositionsRef = useRef>(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(); + 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(); - 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(); + 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([]); - 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>(new Map()); - const searchIndicesRef = useRef(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>(new Map()); useEffect(() => { let pending: NetworkNode[] = []; let batchTimer: ReturnType | 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: /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 ( -
-
- - {/* Hover label */} - {hoveredLabel && ( -
- {hoveredLabel.text} -
- )} +
+ @@ -482,20 +628,25 @@ export default function BrowseView() {
)} - {windows.map((win) => ( - - ))} + {/* 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 */} +
e.stopPropagation()} onPointerDown={e => e.stopPropagation()}> + {windows.map((win) => ( + + ))} +
); } diff --git a/frontend/src/types/three-shims.d.ts b/frontend/src/types/three-shims.d.ts new file mode 100644 index 0000000..d76e8de --- /dev/null +++ b/frontend/src/types/three-shims.d.ts @@ -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; + } +} + +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 }; + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..cd459c3 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,31 @@ +{ + "name": "micronomicon", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "three-spritetext": "^1.10.0" + } + }, + "node_modules/three": { + "version": "0.183.2", + "resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz", + "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==", + "license": "MIT", + "peer": true + }, + "node_modules/three-spritetext": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/three-spritetext/-/three-spritetext-1.10.0.tgz", + "integrity": "sha512-t08iP1FCU1lQh8T5MmCpdijKgas8GDHJE0LqMGBuVu3xqMMpFnEZhTlih7FlxLPQizHIGoumUSpfOlY1GO/Tgg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "three": ">=0.86.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..638555f --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "three-spritetext": "^1.10.0" + } +}