feat: new day new graph

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

View File

@@ -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",

View File

@@ -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"
},

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,23 +1,360 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Graph } from "@cosmos.gl/graph";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import ForceGraph3D, { type ForceGraphMethods, type NodeObject } from "react-force-graph-3d";
import SpriteText from "three-spritetext";
import { UnrealBloomPass } from "three/examples/jsm/postprocessing/UnrealBloomPass.js";
import { ShaderPass } from "three/examples/jsm/postprocessing/ShaderPass.js";
import { Vector2 } from "three";
import { subscribeBrowseNodes, fetchRemotePage, type NetworkNode } from "@/api/client";
import { renderMicron } from "@/components/editor/micronRenderer";
import { useWindowManager } from "@/hooks/useWindowManager";
import { getThemeStatusColors } from "@/components/browse/graphColors";
import { buildGraphArrays, SPACE_SIZE } from "@/components/browse/buildGraph";
import { buildGraphData, type GraphNode, type GraphData } from "@/components/browse/buildGraph";
import type { BrowseWinData, HistoryEntry } from "@/components/browse/types";
import BrowseNodeWindow from "@/components/browse/BrowseNodeWindow";
import BrowseSearchBar from "@/components/browse/BrowseSearchBar";
import Loader from "@/components/shared/Loader";
function cssVarToHex(varName: string): string {
const raw = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
type FGNode = NodeObject<GraphNode>;
function cssVar(name: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
/** Resolve a CSS variable to a normalised hex string (#rrggbb) */
function cssVarToHex(name: string): string {
const raw = cssVar(name);
if (!raw) return "#888888";
const ctx = document.createElement("canvas").getContext("2d")!;
ctx.fillStyle = raw;
return ctx.fillStyle;
return ctx.fillStyle; // always "#rrggbb"
}
const DIM_COLOR = "rgba(60,60,60,0.15)";
const DIM_LINK = "rgba(60,60,60,0.03)";
// ---------------------------------------------------------------------------
// Retro post-processing shader: pixelation + posterize + scanlines + vignette
// ---------------------------------------------------------------------------
const RetroShader = {
uniforms: {
tDiffuse: { value: null },
resolution: { value: new Vector2(800, 600) },
pixelSize: { value: 2.0 },
colorLevels: { value: 48.0 },
scanlineIntensity: { value: 0.03 },
scanlineDensity: { value: 1.0 },
vignetteIntensity: { value: 0.15 },
tintColor: { value: [1.0, 0.95, 0.85] },
},
vertexShader: /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: /* glsl */ `
uniform sampler2D tDiffuse;
uniform vec2 resolution;
uniform float pixelSize;
uniform float colorLevels;
uniform float scanlineIntensity;
uniform float scanlineDensity;
uniform float vignetteIntensity;
uniform vec3 tintColor;
varying vec2 vUv;
void main() {
// Pixelation
vec2 dxy = pixelSize / resolution;
vec2 coord = dxy * floor(vUv / dxy) + dxy * 0.5;
vec4 color = texture2D(tDiffuse, coord);
// Posterize (reduce color depth)
color.rgb = floor(color.rgb * colorLevels + 0.5) / colorLevels;
// Subtle tint towards theme color
color.rgb *= tintColor;
// Scanlines
float scanline = sin(vUv.y * resolution.y * scanlineDensity) * 0.5 + 0.5;
color.rgb -= scanlineIntensity * (1.0 - scanline);
// Vignette
vec2 vig = vUv * (1.0 - vUv);
float vigFactor = pow(vig.x * vig.y * 15.0, vignetteIntensity);
color.rgb *= vigFactor;
gl_FragColor = color;
}
`,
};
// ---------------------------------------------------------------------------
// Memoized 3D graph — isolated from window/UI state changes
// ---------------------------------------------------------------------------
type FlyToFn = (pos: { x: number; y: number; z: number }, lookAt: any, durationMs: number) => void;
interface Graph3DProps {
graphData: GraphData;
searchMatchIds: Set<string> | null;
themeRev: number;
width: number;
height: number;
onNodeClick: (node: NetworkNode) => void;
fgRef: React.MutableRefObject<ForceGraphMethods<FGNode> | undefined>;
flyToRef: React.MutableRefObject<FlyToFn | undefined>;
containerRef: React.RefObject<HTMLDivElement | null>;
}
const Graph3D = memo(function Graph3D({ graphData, searchMatchIds, themeRev, width, height, onNodeClick, fgRef, flyToRef, containerRef }: Graph3DProps) {
// react-kapsule diffs props during every render — graphData triggers a full
// simulation restart (alpha=1). Stabilise the reference.
const stableDataRef = useRef(graphData);
const prevNodeIds = useRef("");
const nodeIds = graphData.nodes.map(n => n.id).join(",");
if (nodeIds !== prevNodeIds.current) {
stableDataRef.current = graphData;
prevNodeIds.current = nodeIds;
}
// Dim non-matching nodes/links during search
const nodeColor = useCallback((node: FGNode) => {
if (!searchMatchIds) return (node as GraphNode).color;
return searchMatchIds.has(node.id as string) ? (node as GraphNode).color : DIM_COLOR;
}, [searchMatchIds]);
const linkColor = useCallback((link: any) => {
if (!searchMatchIds) return "rgba(100,100,100,0.15)";
const srcId = typeof link.source === "object" ? (link.source.id as string) : link.source;
const tgtId = typeof link.target === "object" ? (link.target.id as string) : link.target;
if (searchMatchIds.has(srcId) || searchMatchIds.has(tgtId)) return "rgba(100,100,100,0.15)";
return DIM_LINK;
}, [searchMatchIds]);
// Fly camera to search matches
useEffect(() => {
const fg = fgRef.current;
if (!fg || !searchMatchIds) return;
if (searchMatchIds.size <= 10) {
fg.zoomToFit(600, 80, (n: FGNode) => searchMatchIds.has(n.id as string));
}
}, [searchMatchIds, fgRef]);
// ── Camera auto-orbit with smooth ease-in / ease-out ──
const orbitAngleRef = useRef(0);
const orbitTargetSpeed = useRef(1.0); // 1 = full speed, 0 = stopped
const orbitCurrentSpeed = useRef(0.0); // smoothed value
const hoveringNodeRef = useRef(false);
const flyingRef = useRef(false); // true while cameraPosition transition is active — hard-blocks orbit
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const flyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const IDLE_RESUME_MS = 3000;
const BASE_SPEED = Math.PI / 600;
const HOVER_FACTOR = 0.1; // 10% speed when hovering
const EASE_RATE = 0.02; // lerp factor per tick — smaller = smoother
/** Fly camera to a position. Completely blocks orbit during the transition. */
const flyTo = useCallback((pos: { x: number; y: number; z: number }, lookAt: any, durationMs: number) => {
const fg = fgRef.current;
if (!fg) return;
flyingRef.current = true;
orbitCurrentSpeed.current = 0;
orbitTargetSpeed.current = 0;
if (flyTimerRef.current) clearTimeout(flyTimerRef.current);
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
fg.cameraPosition(pos, lookAt, durationMs);
flyTimerRef.current = setTimeout(() => {
flyingRef.current = false;
const cam = fg.camera();
orbitAngleRef.current = Math.atan2(cam.position.x, cam.position.z);
idleTimerRef.current = setTimeout(() => { orbitTargetSpeed.current = 1; }, IDLE_RESUME_MS);
}, durationMs);
}, [fgRef]);
// Expose flyTo to parent
flyToRef.current = flyTo;
const pauseOrbit = useCallback(() => {
orbitTargetSpeed.current = 0;
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
idleTimerRef.current = setTimeout(() => { orbitTargetSpeed.current = 1; }, IDLE_RESUME_MS);
}, []);
const onNodeHover = useCallback((node: FGNode | null) => {
hoveringNodeRef.current = !!node;
}, []);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const events = ["mousedown", "wheel", "touchstart"] as const;
for (const evt of events) el.addEventListener(evt, pauseOrbit, { passive: true });
return () => { for (const evt of events) el.removeEventListener(evt, pauseOrbit); };
}, [pauseOrbit, containerRef]);
useEffect(() => {
const interval = setInterval(() => {
const fg = fgRef.current;
if (!fg || flyingRef.current) return;
// Smooth target: full speed or hover-reduced
const target = orbitTargetSpeed.current * (hoveringNodeRef.current ? HOVER_FACTOR : 1);
// Ease towards target
orbitCurrentSpeed.current += (target - orbitCurrentSpeed.current) * EASE_RATE;
// Skip negligible movement
if (Math.abs(orbitCurrentSpeed.current) < 0.001) return;
const cam = fg.camera();
const distance = Math.sqrt(cam.position.x ** 2 + cam.position.z ** 2) || 400;
orbitAngleRef.current += BASE_SPEED * orbitCurrentSpeed.current;
fg.cameraPosition({
x: distance * Math.sin(orbitAngleRef.current),
z: distance * Math.cos(orbitAngleRef.current),
});
}, 20);
return () => { clearInterval(interval); if (idleTimerRef.current) clearTimeout(idleTimerRef.current); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fgRef]);
// ── Post-processing: bloom + retro ──
const postProcInitRef = useRef(false);
const retroPassRef = useRef<ShaderPass | null>(null);
useEffect(() => {
const fg = fgRef.current;
if (!fg || postProcInitRef.current) return;
// Wait a tick for the renderer to be ready
const timer = setTimeout(() => {
try {
const composer = fg.postProcessingComposer();
// Bloom — subtle glow
const bloom = new UnrealBloomPass(new Vector2(width, height), 0.3, 0.3, 0.9);
composer.addPass(bloom);
// Retro shader
const retro = new ShaderPass(RetroShader);
retro.uniforms.resolution.value.set(width, height);
// Tint towards theme primary
const hex = cssVarToHex("--primary");
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
// Blend towards white so the tint is subtle
retro.uniforms.tintColor.value = [0.7 + r * 0.3, 0.7 + g * 0.3, 0.7 + b * 0.3];
composer.addPass(retro);
retroPassRef.current = retro;
postProcInitRef.current = true;
} catch { /* renderer not ready yet, will retry */ }
}, 500);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fgRef, width, height]);
// Update retro tint when theme changes
useEffect(() => {
const retro = retroPassRef.current;
if (!retro) return;
const hex = cssVarToHex("--primary");
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
retro.uniforms.tintColor.value = [0.7 + r * 0.3, 0.7 + g * 0.3, 0.7 + b * 0.3];
}, [themeRev]);
// Update resolution uniform on resize
useEffect(() => {
const retro = retroPassRef.current;
if (retro) retro.uniforms.resolution.value.set(width, height);
}, [width, height]);
// ── Custom node objects: text labels above every node ──
const nodeThreeObject = useCallback((node: FGNode) => {
const gn = node as GraphNode;
const sprite = new SpriteText(gn.name);
(sprite as any).material.depthWrite = false;
(sprite as any).renderOrder = 999;
sprite.color = gn.type === "interface"
? (cssVar("--foreground") || "#888")
: gn.color;
sprite.textHeight = gn.type === "interface" ? 4 : 3;
sprite.fontFace = "JetBrains Mono, monospace";
sprite.fontWeight = gn.type === "interface" ? "700" : "400";
sprite.backgroundColor = "transparent";
if (gn.type === "interface") {
(sprite as any).center.set(0.5, 0.5);
} else {
(sprite as any).center.set(0.5, 2.5);
}
return sprite;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [themeRev]);
const nodeVisibility = useCallback((node: FGNode) => {
return (node as GraphNode).type !== "interface";
}, []);
const nodeThreeObjectExtend = useCallback((node: FGNode) => {
return (node as GraphNode).type !== "interface";
}, []);
const showPointerCursor = useCallback((obj: any) => {
if (!obj || !("type" in obj)) return false;
return (obj as GraphNode).type !== "interface";
}, []);
const handleClick = useCallback((node: FGNode) => {
const gn = node as GraphNode;
if (gn.type === "interface") return;
if (node.x !== undefined && node.y !== undefined && node.z !== undefined) {
const distance = 40;
const dist = Math.hypot(node.x, node.y, node.z);
const newPos = dist > 0
? { x: node.x * (1 + distance / dist), y: node.y * (1 + distance / dist), z: node.z * (1 + distance / dist) }
: { x: 0, y: 0, z: distance };
flyTo(newPos, node as any, 1500);
}
onNodeClick(gn.entry);
}, [onNodeClick, flyTo]);
return (
<ForceGraph3D
ref={fgRef}
graphData={stableDataRef.current}
width={width}
height={height}
backgroundColor="rgba(0,0,0,0)"
nodeId="id"
nodeVal="size"
nodeColor={nodeColor}
nodeLabel=""
nodeOpacity={0.9}
nodeResolution={12}
nodeVisibility={nodeVisibility}
nodeThreeObject={nodeThreeObject}
nodeThreeObjectExtend={nodeThreeObjectExtend}
onNodeClick={handleClick}
onNodeHover={onNodeHover}
showPointerCursor={showPointerCursor}
linkColor={linkColor}
linkWidth={0.3}
linkOpacity={0.12}
cooldownTicks={150}
warmupTicks={0}
enableNodeDrag={true}
enableNavigationControls={true}
showNavInfo={false}
/>
);
});
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
@@ -26,7 +363,6 @@ export default function BrowseView() {
const [nodes, setNodes] = useState<NetworkNode[]>([]);
const [filter, setFilter] = useState("");
const { windows, focusedId: focusedWinId, open: openWindow, update: updateWindow, close: closeWindowById, focus: focusWindow } = useWindowManager<BrowseWinData>();
const [hoveredLabel, setHoveredLabel] = useState<{ text: string; x: number; y: number } | null>(null);
const [themeRev, setThemeRev] = useState(0);
@@ -38,290 +374,108 @@ export default function BrowseView() {
}, []);
const containerRef = useRef<HTMLDivElement>(null);
const graphRef = useRef<Graph | null>(null);
const entriesRef = useRef<NetworkNode[]>([]);
const nodesMapRef = useRef<Map<string, NetworkNode>>(new Map());
const hashToIndexRef = useRef<Map<string, number>>(new Map());
const clusterNamesRef = useRef<string[]>([]);
const hasUserInteractedRef = useRef(false);
const initialFitDoneRef = useRef(false);
const fgRef = useRef<ForceGraphMethods<FGNode>>(undefined);
const flyToRef = useRef<FlyToFn>(undefined);
const prevPositionsRef = useRef<Map<string, { x: number; y: number; z: number }>>(new Map());
// Container sizing
const [dims, setDims] = useState<{ w: number; h: number }>({ w: 800, h: 600 });
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) => {
if (!entry) return;
setDims({ w: entry.contentRect.width, h: entry.contentRect.height });
});
ro.observe(el);
setDims({ w: el.clientWidth, h: el.clientHeight });
return () => ro.disconnect();
}, []);
// Build graph data, preserving existing positions
const graphData = useMemo(() => {
const graph = graphRef.current;
const prevPositions = graph ? graph.getPointPositions() : [];
return buildGraphArrays(nodes, hashToIndexRef.current, prevPositions, getThemeStatusColors());
const fg = fgRef.current;
if (fg) {
try {
// @ts-expect-error — graphData() is on the underlying instance
const live = fg.graphData?.() as { nodes: FGNode[] } | undefined;
if (live?.nodes) {
const map = new Map<string, { x: number; y: number; z: number }>();
for (const n of live.nodes) {
if (n.x !== undefined && n.y !== undefined && n.z !== undefined) {
map.set(n.id as string, { x: n.x, y: n.y, z: n.z });
}
}
prevPositionsRef.current = map;
}
} catch { /* ignore */ }
}
return buildGraphData(nodes, prevPositionsRef.current, getThemeStatusColors());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nodes, themeRev]);
useEffect(() => {
const map = new Map<string, NetworkNode>();
for (const n of nodes) map.set(n.hash, n);
nodesMapRef.current = map;
entriesRef.current = graphData.entries;
hashToIndexRef.current = graphData.hashToIndex;
clusterNamesRef.current = graphData.clusterNames;
}, [nodes, graphData]);
// Search — matches + autocomplete suggestions
const searchMatchIndices = useMemo(() => {
const searchMatchIds = useMemo(() => {
if (!filter.trim()) return null;
const q = filter.trim().toLowerCase();
const indices: number[] = [];
graphData.entries.forEach((entry, i) => {
if (entry.name.toLowerCase().includes(q)) indices.push(i);
});
return indices.length > 0 ? indices : null;
const ids = new Set<string>();
for (const n of graphData.nodes) {
if (n.name.toLowerCase().includes(q)) ids.add(n.id);
}
return ids.size > 0 ? ids : null;
}, [filter, graphData]);
const allPeerNodes = useMemo(() => {
return graphData.nodes
.filter(e => e.type !== "interface")
.map(e => e.entry);
}, [graphData]);
const suggestions = useMemo(() => {
if (!filter.trim()) return [];
const q = filter.trim().toLowerCase();
return graphData.entries
.filter(e => e.name.toLowerCase().includes(q))
.slice(0, 8);
}, [filter, graphData]);
return allPeerNodes.filter(e => e.name.toLowerCase().includes(q));
}, [filter, allPeerNodes]);
// ── Cluster labels — direct DOM like the example's create-cluster-labels.ts ──
const labelDivsRef = useRef<HTMLDivElement[]>([]);
const updateClusterLabels = useCallback(() => {
const graph = graphRef.current;
const container = containerRef.current;
if (!graph || !container) return;
const positions = graph.getClusterPositions();
const names = clusterNamesRef.current;
const nClusters = Math.min(names.length, positions.length / 2);
// Rebuild label divs if count changed
if (labelDivsRef.current.length !== nClusters) {
labelDivsRef.current.forEach(d => d.remove());
labelDivsRef.current = [];
for (let i = 0; i < nClusters; i++) {
const div = document.createElement("div");
div.style.position = "absolute";
div.style.pointerEvents = "none";
div.style.whiteSpace = "nowrap";
div.style.transform = "translate(-50%, -100%)";
div.style.padding = "0";
div.style.background = "none";
div.style.border = "none";
div.style.color = "var(--foreground)";
div.style.fontFamily = "JetBrains Mono, monospace";
div.style.fontSize = "14px";
div.style.fontWeight = "700";
div.style.letterSpacing = "0.05em";
div.style.textTransform = "uppercase";
div.style.opacity = "0.85";
div.textContent = names[i] ?? "";
container.appendChild(div);
labelDivsRef.current.push(div);
}
}
const rect = container.getBoundingClientRect();
const pad = 8;
for (let i = 0; i < nClusters; i++) {
const x = positions[i * 2];
const y = positions[i * 2 + 1];
if (x === undefined || y === undefined) continue;
const screen = graph.spaceToScreenPosition([x, y]);
const div = labelDivsRef.current[i]!;
// Measure label so we can clamp within container
const lw = div.offsetWidth;
const lh = div.offsetHeight;
// Default transform is translate(-50%, -100%), so anchor is center-bottom
let left = screen[0] - lw / 2;
let top = screen[1] - lh;
// Clamp to container bounds
left = Math.max(pad, Math.min(left, rect.width - lw - pad));
top = Math.max(pad, Math.min(top, rect.height - lh - pad));
div.style.transform = "none";
div.style.left = `${left}px`;
div.style.top = `${top}px`;
}
}, []);
// ── Search highlighting labels ──
const searchLabelDivsRef = useRef<Map<number, HTMLDivElement>>(new Map());
const searchIndicesRef = useRef<number[] | null>(null);
const clearSearchLabels = useCallback(() => {
searchLabelDivsRef.current.forEach(d => d.remove());
searchLabelDivsRef.current.clear();
searchIndicesRef.current = null;
}, []);
const updateSearchLabels = useCallback(() => {
const graph = graphRef.current;
const indices = searchIndicesRef.current;
if (!graph || !indices || indices.length === 0) return;
const positions = graph.getPointPositions();
const entries = entriesRef.current;
const containerEl = containerRef.current;
if (!containerEl) return;
const rect = containerEl.getBoundingClientRect();
for (const idx of indices) {
const x = positions[idx * 2];
const y = positions[idx * 2 + 1];
if (x === undefined || y === undefined) continue;
const screen = graph.spaceToScreenPosition([x, y]);
let div = searchLabelDivsRef.current.get(idx);
if (!div) {
div = document.createElement("div");
div.style.position = "fixed";
div.style.pointerEvents = "none";
div.style.padding = "2px 8px";
div.style.borderRadius = "4px";
div.style.background = "var(--popover)";
div.style.border = "1px solid var(--primary)";
div.style.color = "var(--primary)";
div.style.fontFamily = "JetBrains Mono, monospace";
div.style.fontWeight = "bold";
div.style.fontSize = "11px";
div.style.boxShadow = "0 2px 8px rgba(0,0,0,0.4)";
div.style.whiteSpace = "nowrap";
div.style.zIndex = "998";
div.textContent = entries[idx]?.name ?? "";
document.body.appendChild(div);
searchLabelDivsRef.current.set(idx, div);
}
div.style.left = `${rect.left + screen[0] + 10}px`;
div.style.top = `${rect.top + screen[1] - 8}px`;
}
}, []);
// ── Init Cosmos ──
useEffect(() => {
if (!containerRef.current) return;
const graph = new Graph(containerRef.current, {
spaceSize: SPACE_SIZE,
backgroundColor: "transparent",
pointDefaultColor: "#888888",
pointDefaultSize: 10,
renderLinks: false,
fitViewOnInit: true,
fitViewDelay: 1500,
fitViewPadding: 0.2,
renderHoveredPointRing: true,
hoveredPointRingColor: cssVarToHex("--primary"),
scalePointsOnZoom: true,
pointGreyoutOpacity: 0.1,
simulationGravity: 0.5,
simulationRepulsion: 1,
simulationCluster: 0.5,
simulationDecay: 5000,
simulationFriction: 0.85,
simulationLinkSpring: 0,
simulationLinkDistance: 1,
onClick: (index, _pos, _event) => {
if (index === undefined) return;
const entry = entriesRef.current[index];
if (entry && entry.type !== "interface") handleNodeClick(entry);
},
onMouseMove: (index, pointPosition) => {
if (index === undefined || !pointPosition || !graphRef.current) {
setHoveredLabel(null);
containerRef.current?.classList.remove("graph-node-hovered");
return;
}
const entry = entriesRef.current[index];
if (!entry) return;
if (entry.type !== "interface") {
containerRef.current?.classList.add("graph-node-hovered");
} else {
containerRef.current?.classList.remove("graph-node-hovered");
}
const screen = graphRef.current.spaceToScreenPosition(pointPosition);
setHoveredLabel({ text: entry.name, x: screen[0], y: screen[1] });
},
onSimulationTick: () => { updateClusterLabels(); updateSearchLabels(); },
onSimulationEnd: () => {
// Only auto-fit on initial load; after that, respect user's viewport
if (!initialFitDoneRef.current) {
graphRef.current?.fitView(300, 0.2);
initialFitDoneRef.current = true;
}
updateClusterLabels(); updateSearchLabels();
},
onZoom: () => { hasUserInteractedRef.current = true; updateClusterLabels(); updateSearchLabels(); },
});
graphRef.current = graph;
return () => {
labelDivsRef.current.forEach(d => d.remove());
labelDivsRef.current = [];
graph.destroy();
graphRef.current = null;
};
}, []);
// ── Update graph data ──
const isFirstLoadRef = useRef(true);
useEffect(() => {
const graph = graphRef.current;
if (!graph || graphData.entries.length === 0) return;
const n = graphData.entries.length;
const scale = Math.log10(Math.max(10, n)) / Math.log10(10000);
// Stronger gravity + lower repulsion for small graphs keeps them compact and centered
const isSmall = n <= 20;
graph.setConfig({
simulationRepulsion: isSmall ? 0.3 : 0.5 + scale * 9.5,
simulationCluster: 1.0 - scale * 0.75,
simulationGravity: isSmall ? 1.0 : 0.25 + scale * 1.75,
simulationDecay: isSmall ? 3000 : 5000,
hoveredPointRingColor: cssVarToHex("--primary"),
});
graph.setPointPositions(graphData.positions);
graph.setPointColors(graphData.colors);
graph.setPointSizes(graphData.sizes);
graph.setPointClusters(graphData.clusterIndices);
graph.setClusterPositions(graphData.clusterPositions);
graph.setPointClusterStrength(graphData.clusterStrength);
graph.setLinks(new Float32Array(0));
if (isFirstLoadRef.current) {
graph.render(1);
isFirstLoadRef.current = false;
} else {
graph.render(0.1);
}
updateClusterLabels();
// Fly camera to a highlighted search suggestion.
// ForceGraph3D mutates graphData.nodes in-place with x/y/z after simulation,
// so we read positions directly from the graph data nodes.
const onHighlightNode = useCallback((node: NetworkNode | null) => {
if (!node) return;
const fly = flyToRef.current;
if (!fly) return;
const match = graphData.nodes.find(n => n.id === node.hash) as FGNode | undefined;
if (!match || match.x === undefined || match.y === undefined || match.z === undefined) return;
const distance = 60;
const dist = Math.hypot(match.x, match.y, match.z);
const newPos = dist > 0
? { x: match.x * (1 + distance / dist), y: match.y * (1 + distance / dist), z: match.z * (1 + distance / dist) }
: { x: 0, y: 0, z: distance };
fly(newPos, match as any, 800);
}, [graphData]);
// ── Search highlighting ──
const onSearchFocusChange = useCallback((_focused: boolean) => {
// Could be used to dim graph when search is active
}, []);
// ── Fly camera to focused window's node ──
useEffect(() => {
const graph = graphRef.current;
if (!graph) return;
clearSearchLabels();
if (searchMatchIndices) {
searchIndicesRef.current = searchMatchIndices;
graph.selectPointsByIndices(searchMatchIndices);
if (searchMatchIndices.length <= 10) {
graph.fitViewByPointIndices(searchMatchIndices, 500);
}
updateSearchLabels();
} else {
graph.unselectPoints();
graph.fitView(300, 0.2);
}
return () => clearSearchLabels();
}, [searchMatchIndices, clearSearchLabels, updateSearchLabels]);
if (!focusedWinId) return;
const fly = flyToRef.current;
if (!fly) return;
const match = graphData.nodes.find(n => n.id === focusedWinId) as FGNode | undefined;
if (!match || match.x === undefined || match.y === undefined || match.z === undefined) return;
const distance = 60;
const dist = Math.hypot(match.x, match.y, match.z);
const newPos = dist > 0
? { x: match.x * (1 + distance / dist), y: match.y * (1 + distance / dist), z: match.z * (1 + distance / dist) }
: { x: 0, y: 0, z: distance };
fly(newPos, match as any, 1000);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [focusedWinId]);
// ── SSE stream ──
const nodeMapRef = useRef<Map<string, NetworkNode>>(new Map());
useEffect(() => {
let pending: NetworkNode[] = [];
let batchTimer: ReturnType<typeof setTimeout> | null = null;
@@ -329,11 +483,15 @@ export default function BrowseView() {
batchTimer = null;
if (pending.length === 0) return;
const batch = pending; pending = [];
setNodes((prev) => {
const map = new Map(prev.map((n) => [n.hash, n]));
for (const node of batch) map.set(node.hash, node);
return Array.from(map.values());
});
const map = nodeMapRef.current;
let changed = false;
for (const node of batch) {
if (!map.has(node.hash)) changed = true;
map.set(node.hash, node);
}
if (changed) {
setNodes(Array.from(map.values()));
}
};
const unsub = subscribeBrowseNodes((node) => { pending.push(node); if (!batchTimer) batchTimer = setTimeout(flush, 200); });
return () => { unsub(); if (batchTimer) clearTimeout(batchTimer); flush(); };
@@ -398,13 +556,6 @@ export default function BrowseView() {
}, [openWindow, navigateTo]);
// ── Handle micron link clicks via event delegation ──
//
// Micron link destinations come in several forms:
// /page.mu — same-node, absolute path
// page.mu — same-node, relative
// :/page/page.mu — NomadNet "request" link (: prefix + /page/ segment)
// <32-hex-hash>/page.mu — cross-node link
// nomadnetwork://... — already stripped by micron-parser's data-destination
const handleContentClick = useCallback((e: React.MouseEvent, winId: string, data: BrowseWinData) => {
const anchor = (e.target as HTMLElement).closest("a");
if (!anchor) return;
@@ -414,63 +565,58 @@ export default function BrowseView() {
if (!dest) return;
let raw = dest
.replace(/^nomadnetwork:\/\//, "") // strip scheme if present
.replace(/^:/, "") // strip NomadNet request prefix
.replace(/^\/page\//, "") // strip /page/ path segment
.replace(/^\/+/, ""); // strip remaining leading slashes
.replace(/^nomadnetwork:\/\//, "")
.replace(/^:/, "")
.replace(/^\/page\//, "")
.replace(/^\/+/, "");
// A bare 32-char hex hash with no path — nothing to navigate to
if (/^[0-9a-f]{32}$/i.test(raw)) return;
// If the destination starts with a 32-char hex hash followed by "/",
// it's a cross-node link: <hash>/page.mu → use that node's hash
let targetNode = data.node;
let path = raw;
const crossNodeMatch = raw.match(/^([0-9a-f]{32})\/(.+)$/i);
if (crossNodeMatch) {
const targetHash = crossNodeMatch[1]!;
path = crossNodeMatch[2]!;
// Strip /page/ from the path portion too
path = path.replace(/^\/page\//, "").replace(/^\/+/, "");
const known = nodesMapRef.current.get(targetHash);
if (known) {
targetNode = known;
}
const known = nodes.find(n => n.hash === targetHash);
if (known) targetNode = known;
}
if (!path || path === "/") return;
if (!path.endsWith(".mu")) path += ".mu";
navigateTo(winId, targetNode, path, data);
}, [navigateTo]);
}, [navigateTo, nodes]);
const clearSearch = useCallback(() => {
setFilter("");
graphRef.current?.unselectPoints();
// Re-center when explicitly clearing search
graphRef.current?.fitView(300, 0.2);
hasUserInteractedRef.current = false;
updateClusterLabels();
}, [updateClusterLabels]);
fgRef.current?.zoomToFit(400, 60);
}, []);
return (
<div className="relative overflow-hidden" style={{ height: "100%" }}>
<div ref={containerRef} className="absolute inset-0 graph-canvas" />
{/* Hover label */}
{hoveredLabel && (
<div className="absolute pointer-events-none px-2 py-0.5 bg-popover border border-border rounded text-xs font-mono text-foreground whitespace-nowrap"
style={{ left: Math.min(hoveredLabel.x + 12, (containerRef.current?.offsetWidth ?? 9999) - 160), top: Math.max(8, hoveredLabel.y - 10), boxShadow: "0 2px 8px rgba(0,0,0,0.3)" }}>
{hoveredLabel.text}
</div>
)}
<div ref={containerRef} className="relative overflow-hidden" style={{ height: "100%" }}>
<Graph3D
graphData={graphData}
searchMatchIds={searchMatchIds}
themeRev={themeRev}
width={dims.w}
height={dims.h}
onNodeClick={handleNodeClick}
fgRef={fgRef}
flyToRef={flyToRef}
containerRef={containerRef}
/>
<BrowseSearchBar
filter={filter}
onFilterChange={setFilter}
onClear={clearSearch}
allNodes={allPeerNodes}
suggestions={suggestions}
onSelectNode={handleNodeClick}
onHighlightNode={onHighlightNode}
onSearchFocusChange={onSearchFocusChange}
focusedWinId={focusedWinId}
windowCount={windows.length}
/>
@@ -482,20 +628,25 @@ export default function BrowseView() {
</div>
)}
{windows.map((win) => (
<BrowseNodeWindow
key={win.id}
win={win}
focused={focusedWinId === win.id}
onUpdate={updateWindow}
onClose={closeWindowById}
onFocus={focusWindow}
onNavBack={navBack}
onNavForward={navForward}
onNavReload={navReload}
onContentClick={handleContentClick}
/>
))}
{/* Stop React synthetic events from portaled windows bubbling into the
graph — portals bubble through the React tree, not the DOM tree. */}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div onMouseDown={e => e.stopPropagation()} onPointerDown={e => e.stopPropagation()}>
{windows.map((win) => (
<BrowseNodeWindow
key={win.id}
win={win}
focused={focusedWinId === win.id}
onUpdate={updateWindow}
onClose={closeWindowById}
onFocus={focusWindow}
onNavBack={navBack}
onNavForward={navForward}
onNavReload={navReload}
onContentClick={handleContentClick}
/>
))}
</div>
</div>
);
}

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

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

31
package-lock.json generated Normal file
View File

@@ -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"
}
}
}
}

5
package.json Normal file
View File

@@ -0,0 +1,5 @@
{
"dependencies": {
"three-spritetext": "^1.10.0"
}
}