diff --git a/.gitignore b/.gitignore index 5ef6a52..b43744c 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ # misc .DS_Store *.pem +.claude # debug npm-debug.log* diff --git a/package-lock.json b/package-lock.json index cfd8a24..fbaa396 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,9 @@ "version": "0.1.0", "dependencies": { "@base-ui/react": "^1.3.0", + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.5.0", + "@react-three/postprocessing": "^3.0.4", "@types/dagre": "^0.7.54", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -22,6 +25,7 @@ "shadcn": "^4.1.2", "sphinx-bridge": "^0.2.67", "tailwind-merge": "^3.5.0", + "three": "^0.183.2", "tw-animate-css": "^1.4.0", "webln": "^0.3.2", "zustand": "^5.0.12" @@ -33,6 +37,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/three": "^0.183.1", "d3-zoom": "^3.0.0", "eslint": "^9", "eslint-config-next": "16.2.2", @@ -520,6 +525,12 @@ } } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "license": "Apache-2.0" + }, "node_modules/@dotenvx/dotenvx": { "version": "1.60.1", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.60.1.tgz", @@ -1591,6 +1602,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mediapipe/tasks-vision": { + "version": "0.10.17", + "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz", + "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==", + "license": "Apache-2.0" + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", @@ -1653,6 +1670,18 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/@monogrid/gainmap-js": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz", + "integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==", + "license": "MIT", + "dependencies": { + "promise-worker-transferable": "^1.0.4" + }, + "peerDependencies": { + "three": ">= 0.159.0" + } + }, "node_modules/@mswjs/interceptors": { "version": "0.41.3", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", @@ -1933,6 +1962,120 @@ "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", "license": "MIT" }, + "node_modules/@react-three/drei": { + "version": "10.7.7", + "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.7.tgz", + "integrity": "sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mediapipe/tasks-vision": "0.10.17", + "@monogrid/gainmap-js": "^3.0.6", + "@use-gesture/react": "^10.3.1", + "camera-controls": "^3.1.0", + "cross-env": "^7.0.3", + "detect-gpu": "^5.0.56", + "glsl-noise": "^0.0.0", + "hls.js": "^1.5.17", + "maath": "^0.10.8", + "meshline": "^3.3.1", + "stats-gl": "^2.2.8", + "stats.js": "^0.17.0", + "suspend-react": "^0.1.3", + "three-mesh-bvh": "^0.8.3", + "three-stdlib": "^2.35.6", + "troika-three-text": "^0.52.4", + "tunnel-rat": "^0.1.2", + "use-sync-external-store": "^1.4.0", + "utility-types": "^3.11.0", + "zustand": "^5.0.1" + }, + "peerDependencies": { + "@react-three/fiber": "^9.0.0", + "react": "^19", + "react-dom": "^19", + "three": ">=0.159" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/@react-three/fiber": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.5.0.tgz", + "integrity": "sha512-FiUzfYW4wB1+PpmsE47UM+mCads7j2+giRBltfwH7SNhah95rqJs3ltEs9V3pP8rYdS0QlNne+9Aj8dS/SiaIA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8", + "@types/webxr": "*", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "its-fine": "^2.0.0", + "react-use-measure": "^2.1.7", + "scheduler": "^0.27.0", + "suspend-react": "^0.1.3", + "use-sync-external-store": "^1.4.0", + "zustand": "^5.0.3" + }, + "peerDependencies": { + "expo": ">=43.0", + "expo-asset": ">=8.4", + "expo-file-system": ">=11.0", + "expo-gl": ">=11.0", + "react": ">=19 <19.3", + "react-dom": ">=19 <19.3", + "react-native": ">=0.78", + "three": ">=0.156" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "expo-asset": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "expo-gl": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@react-three/postprocessing": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@react-three/postprocessing/-/postprocessing-3.0.4.tgz", + "integrity": "sha512-e4+F5xtudDYvhxx3y0NtWXpZbwvQ0x1zdOXWTbXMK6fFLVDd4qucN90YaaStanZGS4Bd5siQm0lGL/5ogf8iDQ==", + "license": "MIT", + "dependencies": { + "maath": "^0.6.0", + "n8ao": "^1.9.4", + "postprocessing": "^6.36.6" + }, + "peerDependencies": { + "@react-three/fiber": "^9.0.0", + "react": "^19.0", + "three": ">= 0.156.0" + } + }, + "node_modules/@react-three/postprocessing/node_modules/maath": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/maath/-/maath-0.6.0.tgz", + "integrity": "sha512-dSb2xQuP7vDnaYqfoKzlApeRcR2xtN8/f7WV/TMAkBC8552TwTLtOO0JTcSygkYMjNDPoo6V01jTw/aPi4JrMw==", + "license": "MIT", + "peerDependencies": { + "@types/three": ">=0.144.0", + "three": ">=0.144.0" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -2319,6 +2462,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "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", @@ -2399,6 +2548,12 @@ "integrity": "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==", "license": "MIT" }, + "node_modules/@types/draco3d": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", + "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2444,11 +2599,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -2464,18 +2624,48 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@types/sjcl": { "version": "1.0.34", "resolved": "https://registry.npmjs.org/@types/sjcl/-/sjcl-1.0.34.tgz", "integrity": "sha512-bQHEeK5DTQRunIfQeUMgtpPsNNCcZyQ9MJuAfW1I7iN0LDunTc78Fu17STbLMd7KiEY/g2zHVApippa70h6HoQ==", "license": "MIT" }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, "node_modules/@types/statuses": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", "license": "MIT" }, + "node_modules/@types/three": { + "version": "0.183.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.183.1.tgz", + "integrity": "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==", + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~1.0.1" + } + }, "node_modules/@types/uuid": { "version": "3.4.13", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-3.4.13.tgz", @@ -2488,6 +2678,12 @@ "integrity": "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==", "license": "MIT" }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.58.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", @@ -3052,6 +3248,30 @@ "win32" ] }, + "node_modules/@use-gesture/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", + "license": "MIT" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "license": "MIT", + "dependencies": { + "@use-gesture/core": "10.3.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, + "node_modules/@webgpu/types": { + "version": "0.1.69", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz", + "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==", + "license": "BSD-3-Clause" + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -3436,6 +3656,26 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.16", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.16.tgz", @@ -3454,6 +3694,15 @@ "integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==", "license": "MIT" }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/bip174": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bip174/-/bip174-2.1.1.tgz", @@ -3649,6 +3898,30 @@ "node": ">=8.0.0" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/bufio": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/bufio/-/bufio-1.2.3.tgz", @@ -3738,6 +4011,19 @@ "node": ">=6" } }, + "node_modules/camera-controls": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.2.tgz", + "integrity": "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==", + "license": "MIT", + "engines": { + "node": ">=22.0.0", + "npm": ">=10.5.1" + }, + "peerDependencies": { + "three": ">=0.126.1" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001786", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001786.tgz", @@ -4072,6 +4358,24 @@ "sha.js": "^2.4.0" } }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4102,7 +4406,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, "node_modules/d3-color": { @@ -4430,6 +4733,15 @@ "node": ">= 0.8" } }, + "node_modules/detect-gpu": { + "version": "5.0.70", + "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz", + "integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==", + "license": "MIT", + "dependencies": { + "webgl-constants": "^1.1.1" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -4474,6 +4786,12 @@ "url": "https://dotenvx.com" } }, + "node_modules/draco3d": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", + "license": "Apache-2.0" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -5423,6 +5741,12 @@ "node": "^12.20 || >= 14.13" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/figures": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", @@ -5806,6 +6130,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/glsl-noise": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz", + "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==", + "license": "MIT" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -5980,6 +6310,12 @@ "hermes-estree": "0.25.1" } }, + "node_modules/hls.js": { + "version": "1.6.15", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz", + "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==", + "license": "Apache-2.0" + }, "node_modules/hmac-drbg": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", @@ -6058,6 +6394,26 @@ "url": "https://opencollective.com/express" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -6067,6 +6423,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -6748,6 +7110,18 @@ "node": ">= 0.4" } }, + "node_modules/its-fine": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz", + "integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.9" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -6923,6 +7297,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -7313,6 +7696,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/maath": { + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz", + "integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==", + "license": "MIT", + "peerDependencies": { + "@types/three": ">=0.134.0", + "three": ">=0.134.0" + } + }, "node_modules/macaroon": { "version": "3.1.0", "resolved": "git+ssh://git@github.com/tierion/js-macaroon.git#f3654fd314f8fa778299b2286b87f78366a941e1", @@ -7389,6 +7782,21 @@ "node": ">= 8" } }, + "node_modules/meshline": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz", + "integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/meshoptimizer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz", + "integrity": "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==", + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -7554,6 +7962,16 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/n8ao": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/n8ao/-/n8ao-1.10.1.tgz", + "integrity": "sha512-hhI1pC+BfOZBV1KMwynBrVlIm8wqLxj/abAWhF2nZ0qQKyzTSQa1QtLVS2veRiuoBQXojxobcnp0oe+PUoxf/w==", + "license": "ISC", + "peerDependencies": { + "postprocessing": ">=6.30.0", + "three": ">=0.137" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -8254,6 +8672,21 @@ "node": ">=4" } }, + "node_modules/postprocessing": { + "version": "6.39.0", + "resolved": "https://registry.npmjs.org/postprocessing/-/postprocessing-6.39.0.tgz", + "integrity": "sha512-/G6JY8hs426lcto/pBZlnFSkyEo1fHsh4gy7FPJtq1SaSUOzJgDW6f6f1K/+aMOYzK/eQEefyOb3++jPPIUeDA==", + "license": "Zlib", + "peerDependencies": { + "three": ">= 0.168.0 < 0.184.0" + } + }, + "node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC" + }, "node_modules/powershell-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", @@ -8297,6 +8730,22 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/promise-worker-transferable": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz", + "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==", + "license": "Apache-2.0", + "dependencies": { + "is-promise": "^2.1.0", + "lie": "^3.0.2" + } + }, + "node_modules/promise-worker-transferable/node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", + "license": "MIT" + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -8450,6 +8899,21 @@ "dev": true, "license": "MIT" }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", @@ -9248,6 +9712,32 @@ "dev": true, "license": "MIT" }, + "node_modules/stats-gl": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", + "integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==", + "license": "MIT", + "dependencies": { + "@types/three": "*", + "three": "^0.170.0" + }, + "peerDependencies": { + "@types/three": "*", + "three": "*" + } + }, + "node_modules/stats-gl/node_modules/three": { + "version": "0.170.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", + "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==", + "license": "MIT" + }, + "node_modules/stats.js": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz", + "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -9555,6 +10045,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/suspend-react": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", + "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=17.0" + } + }, "node_modules/tabbable": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", @@ -9604,6 +10103,44 @@ "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-mesh-bvh": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.8.3.tgz", + "integrity": "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==", + "license": "MIT", + "peerDependencies": { + "three": ">= 0.159.0" + } + }, + "node_modules/three-stdlib": { + "version": "2.36.1", + "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz", + "integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==", + "license": "MIT", + "dependencies": { + "@types/draco3d": "^1.4.0", + "@types/offscreencanvas": "^2019.6.4", + "@types/webxr": "^0.5.2", + "draco3d": "^1.4.1", + "fflate": "^0.6.9", + "potpack": "^1.0.1" + }, + "peerDependencies": { + "three": ">=0.128.0" + } + }, + "node_modules/three-stdlib/node_modules/fflate": { + "version": "0.6.10", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz", + "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==", + "license": "MIT" + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -9735,6 +10272,36 @@ "node": ">=16" } }, + "node_modules/troika-three-text": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz", + "integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==", + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.2", + "troika-three-utils": "^0.52.4", + "troika-worker-utils": "^0.52.0", + "webgl-sdf-generator": "1.1.1" + }, + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-three-utils": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz", + "integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-worker-utils": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz", + "integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -9790,6 +10357,43 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tunnel-rat": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", + "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", + "license": "MIT", + "dependencies": { + "zustand": "^4.3.2" + } + }, + "node_modules/tunnel-rat/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" + }, + "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/tw-animate-css": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", @@ -10137,6 +10741,15 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/uuid": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", @@ -10183,6 +10796,17 @@ "node": ">= 8" } }, + "node_modules/webgl-constants": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz", + "integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==" + }, + "node_modules/webgl-sdf-generator": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz", + "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==", + "license": "MIT" + }, "node_modules/webln": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/webln/-/webln-0.3.2.tgz", diff --git a/package.json b/package.json index e452df2..f93fcda 100644 --- a/package.json +++ b/package.json @@ -3,13 +3,16 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", - "build": "NODE_ENV=production next build", + "dev": "NODE_TLS_REJECT_UNAUTHORIZED=0 next dev", + "build": "next build", "start": "next start", "lint": "eslint" }, "dependencies": { "@base-ui/react": "^1.3.0", + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.5.0", + "@react-three/postprocessing": "^3.0.4", "@types/dagre": "^0.7.54", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -23,6 +26,7 @@ "shadcn": "^4.1.2", "sphinx-bridge": "^0.2.67", "tailwind-merge": "^3.5.0", + "three": "^0.183.2", "tw-animate-css": "^1.4.0", "webln": "^0.3.2", "zustand": "^5.0.12" @@ -34,6 +38,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/three": "^0.183.1", "d3-zoom": "^3.0.0", "eslint": "^9", "eslint-config-next": "16.2.2", diff --git a/src/app/ontology/ontology-graph-3d.tsx b/src/app/ontology/ontology-graph-3d.tsx new file mode 100644 index 0000000..ad71f0e --- /dev/null +++ b/src/app/ontology/ontology-graph-3d.tsx @@ -0,0 +1,422 @@ +"use client" + +import { useState, useMemo, useCallback, useRef, useEffect } from "react" +import { Canvas } from "@react-three/fiber" +import { CameraControls, Html } from "@react-three/drei" +import { EffectComposer, Bloom } from "@react-three/postprocessing" +import type CameraControlsImpl from "camera-controls" +import { + buildGraph, + computeRadialLayout, + extractInitialSubgraph, + extractSubgraph, + VIRTUAL_CENTER, + GraphView, + OffscreenIndicators, + PrevNodeIndicator, +} from "@/graph-viz-kit" +import type { Graph, ViewState, RawNode, RawEdge } from "@/graph-viz-kit" +import type { SchemaNode, SchemaEdge } from "./page" + +interface Props { + schemas: SchemaNode[] + edges: SchemaEdge[] + selectedId: string | null + onSelect: (id: string) => void +} + +function schemasToGraph( + schemas: SchemaNode[], + edges: SchemaEdge[] +): { graph: Graph; indexMap: Map } { + const rawNodes: RawNode[] = schemas.map((s) => ({ + id: s.ref_id, + label: s.type, + })) + + const rawEdges: RawEdge[] = [] + const edgeSet = new Set() + + for (const e of edges) { + const key = `${e.source}-${e.target}` + if (!edgeSet.has(key)) { + edgeSet.add(key) + rawEdges.push({ + source: e.edge_type === "CHILD_OF" ? e.target : e.source, + target: e.edge_type === "CHILD_OF" ? e.source : e.target, + label: e.edge_type, + }) + } + } + + const hasChildOf = edges.some((e) => e.edge_type === "CHILD_OF") + if (!hasChildOf) { + for (const s of schemas) { + if (s.parent) { + const parent = schemas.find((p) => p.type === s.parent) + if (parent) { + const key = `${parent.ref_id}-${s.ref_id}` + if (!edgeSet.has(key)) { + edgeSet.add(key) + rawEdges.push({ source: parent.ref_id, target: s.ref_id }) + } + } + } + } + } + + const graph = buildGraph(rawNodes, rawEdges) + + const indexMap = new Map() + for (let i = 0; i < schemas.length; i++) { + indexMap.set(i, schemas[i].ref_id) + } + + return { graph, indexMap } +} + +function applyInitialLayout(graph: Graph) { + const sub = extractInitialSubgraph(graph) + const { positions, treeEdgeSet, childrenOf } = computeRadialLayout( + sub.centerId, + sub.neighborsByDepth, + graph.edges, + { parentId: sub.parentId } + ) + + for (const [id, pos] of positions) { + if (id !== VIRTUAL_CENTER && id < graph.nodes.length) { + graph.nodes[id].position = pos + } + } + + graph.initialDepthMap = sub.depthMap + graph.treeEdgeSet = treeEdgeSet + graph.childrenOf = childrenOf +} + +function moveCameraToNode( + cam: CameraControlsImpl, + graph: Graph, + nodeId: number +) { + const p = graph.nodes[nodeId].position + const treeKids = graph.childrenOf?.get(nodeId) ?? [] + const allPts = [p, ...treeKids.map((nid) => graph.nodes[nid]?.position).filter(Boolean)] + const avgX = allPts.reduce((s, pt) => s + pt.x, 0) / allPts.length + const avgZ = allPts.reduce((s, pt) => s + pt.z, 0) / allPts.length + let maxRadius = 0 + for (const pt of allPts) { + const dx = pt.x - avgX + const dz = pt.z - avgZ + maxRadius = Math.max(maxRadius, Math.sqrt(dx * dx + dz * dz)) + } + const fovRad = (50 / 2) * (Math.PI / 180) + const cameraHeight = Math.max(5, (maxRadius * 1.05) / Math.tan(fovRad)) + cam.setLookAt(avgX, p.y + cameraHeight, avgZ + 0.1, avgX, p.y, avgZ, true) +} + +export function OntologyGraph3D({ schemas, edges, selectedId, onSelect }: Props) { + const cameraRef = useRef(null) + + const { graph: baseGraph, indexMap } = useMemo(() => { + const result = schemasToGraph(schemas, edges) + applyInitialLayout(result.graph) + return result + }, [schemas, edges]) + + const [viewState, setViewState] = useState({ mode: "overview" }) + const [pinStack, setPinStack] = useState([]) + const pinnedNodeId = pinStack.length > 0 ? pinStack[pinStack.length - 1] : null + + // Pinned view: build a chain of radial layouts + const pinnedGraph = useMemo(() => { + if (pinStack.length === 0) return null + + let result = baseGraph + + for (const pid of pinStack) { + const node = result.nodes[pid] + if (!node) return null + + const sub = extractSubgraph(result, pid, 4, { useAdj: "undirected" }) + + if (sub.neighborsByDepth[0]) { + sub.neighborsByDepth[0].sort((a, b) => { + const ta = result.nodes[a]?.label || "" + const tb = result.nodes[b]?.label || "" + return ta.localeCompare(tb) + }) + } + + const layoutEdges = sub.edges.map((e) => ({ src: e.src, dst: e.dst })) + const layout = computeRadialLayout(pid, sub.neighborsByDepth, layoutEdges, { + parentId: sub.parentId, + }) + + const cx = node.position.x + const cy = node.position.y + const cz = node.position.z + + const clonedNodes = result.nodes.map((n, i) => { + const layoutPos = layout.positions.get(i) + if (layoutPos) { + return { ...n, position: { x: cx + layoutPos.x, y: cy + layoutPos.y, z: cz + layoutPos.z } } + } + return n + }) + + const subNodeSet = new Set(sub.nodeIds) + const filteredAdj = result.adj.map((neighbors, i) => + subNodeSet.has(i) ? neighbors.filter((n) => subNodeSet.has(n)) : [] + ) + const filteredEdges = result.edges.filter( + (e) => subNodeSet.has(e.src) && subNodeSet.has(e.dst) + ) + + result = { + ...result, + nodes: clonedNodes, + adj: filteredAdj, + edges: filteredEdges, + childrenOf: layout.childrenOf, + treeEdgeSet: layout.treeEdgeSet, + initialDepthMap: sub.depthMap, + } + } + + return result + }, [pinStack, baseGraph]) + + // When a node is pinned, set viewState for the pinned graph + useEffect(() => { + if (pinnedNodeId === null || !pinnedGraph) return + const sub = extractSubgraph(pinnedGraph, pinnedNodeId, 30, { useAdj: "undirected" }) + setViewState({ + mode: "subgraph", + selectedNodeId: pinnedNodeId, + navigationHistory: [pinnedNodeId], + depthMap: sub.depthMap, + neighborsByDepth: sub.neighborsByDepth, + parentId: sub.parentId, + visibleNodeIds: sub.nodeIds, + }) + + const cam = cameraRef.current + if (cam) moveCameraToNode(cam, pinnedGraph, pinnedNodeId) + }, [pinStack, pinnedGraph, pinnedNodeId]) + + const graph = pinnedGraph ?? baseGraph + + const handleNodeClick = useCallback( + (nodeId: number) => { + const refId = indexMap.get(nodeId) + if (refId) onSelect(refId) + + if (viewState.mode === "subgraph" && viewState.selectedNodeId === nodeId) return + + const sub = extractSubgraph(graph, nodeId, 30, { useAdj: "undirected" }) + + setViewState((prev) => { + const prevVisible = prev.mode === "subgraph" ? prev.visibleNodeIds : [] + const prevSet = new Set(prevVisible) + const newNodes = sub.nodeIds.filter((n) => !prevSet.has(n)) + const prevHistory = prev.mode === "subgraph" ? prev.navigationHistory : [] + const existingIdx = prevHistory.indexOf(nodeId) + const newHistory = + existingIdx !== -1 + ? prevHistory.slice(0, existingIdx + 1) + : [...prevHistory, nodeId] + + // Ensure all nodes in navigation history stay visible + const allVisible = [...prevVisible, ...newNodes] + const visibleSet = new Set(allVisible) + for (const hid of newHistory) { + if (!visibleSet.has(hid)) { + allVisible.push(hid) + visibleSet.add(hid) + } + } + + // Ensure previous node has a depth entry so it's not invisible + const depthMap = new Map(sub.depthMap) + const prevNodeId = newHistory.length >= 2 ? newHistory[newHistory.length - 2] : null + if (prevNodeId !== null && !depthMap.has(prevNodeId)) { + depthMap.set(prevNodeId, -1) // depth -1 = parent-like visibility + } + + return { + mode: "subgraph" as const, + selectedNodeId: nodeId, + navigationHistory: newHistory, + depthMap, + neighborsByDepth: sub.neighborsByDepth, + parentId: sub.parentId, + visibleNodeIds: allVisible, + } + }) + + const cam = cameraRef.current + if (cam) moveCameraToNode(cam, graph, nodeId) + }, + [graph, indexMap, onSelect, viewState] + ) + + const handlePin = useCallback( + (nodeId: number) => { + if (nodeId === pinnedNodeId) return + setPinStack((prev) => [...prev, nodeId]) + }, + [pinnedNodeId] + ) + + const handleReset = useCallback(() => { + setPinStack([]) + setViewState({ mode: "overview" }) + const cam = cameraRef.current + if (cam) cam.setLookAt(0, 80, 0.1, 0, 0, 0, true) + }, []) + + // Escape key to reset + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== "Escape") return + if (pinStack.length > 0) { + // Unpin last + setPinStack((prev) => prev.slice(0, -1)) + } else if (viewState.mode === "subgraph") { + handleReset() + } + } + window.addEventListener("keydown", onKeyDown) + return () => window.removeEventListener("keydown", onKeyDown) + }, [viewState.mode, pinStack.length, handleReset]) + + if (schemas.length === 0) { + return ( +
+

No schema data

+
+ ) + } + + // Selected node for pin button + const selectedNodeId = viewState.mode === "subgraph" ? viewState.selectedNodeId : null + + return ( +
+ + + + + + + {/* Pin button on selected node */} + {selectedNodeId !== null && graph.nodes[selectedNodeId] && ( + + + + )} + + + + + + + + {/* Controls overlay */} + {(viewState.mode === "subgraph" || pinStack.length > 0) && ( + + )} + + {pinStack.length > 0 && ( + + )} +
+ ) +} diff --git a/src/app/ontology/page.tsx b/src/app/ontology/page.tsx index 0f1707b..dc2703f 100644 --- a/src/app/ontology/page.tsx +++ b/src/app/ontology/page.tsx @@ -1,10 +1,16 @@ "use client" import { useCallback, useEffect, useState } from "react" +import dynamic from "next/dynamic" import { useRouter } from "next/navigation" import { OntologyGraph } from "./ontology-graph" import { TypeEditor } from "./type-editor" -import { Plus, ArrowLeft } from "lucide-react" +import { Plus, ArrowLeft, Box, Grid2x2 } from "lucide-react" + +const OntologyGraph3D = dynamic( + () => import("./ontology-graph-3d").then((m) => ({ default: m.OntologyGraph3D })), + { ssr: false, loading: () =>

Loading 3D...

} +) import { Button } from "@/components/ui/button" import { useSchemaStore } from "@/stores/schema-store" import { useMocks } from "@/lib/mock-data" @@ -36,6 +42,7 @@ export default function OntologyPage() { const router = useRouter() const store = useSchemaStore() const [selectedId, setSelectedId] = useState(null) + const [view3D, setView3D] = useState(false) useEffect(() => { if (useMocks()) { @@ -99,6 +106,15 @@ export default function OntologyPage() {

Node Types

+ + + + {/* Metadata grid */} +
+ + + + +
+ + {/* Content */} + {node.content && ( +
+
+ {node.content} +
+
+ )} + + {/* Neighbors */} + {neighbors.length > 0 && ( +
+
+ Neighbors +
+
+ {neighbors.map((n) => ( + + ))} +
+
+ )} + + {/* Footer hint */} +
+ zoom out or press Esc to exit +
+ + ); +} + +function MetaItem({ + label, + value, + color, +}: { + label: string; + value: string; + color?: string; +}) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} diff --git a/src/graph-viz-kit/OffscreenIndicators.tsx b/src/graph-viz-kit/OffscreenIndicators.tsx new file mode 100644 index 0000000..9a9aa8b --- /dev/null +++ b/src/graph-viz-kit/OffscreenIndicators.tsx @@ -0,0 +1,264 @@ +import { useRef, useEffect } from "react"; +import { useFrame, useThree } from "@react-three/fiber"; +import * as THREE from "three"; +import type { Graph, ViewState } from "./types"; + +const MARGIN = 40; +const MAX_INDICATORS = 30; + +const _v3 = new THREE.Vector3(); + +interface Props { + graph: Graph; + viewState: ViewState; + onNodeClick: (id: number) => void; +} + +export function OffscreenIndicators({ graph, viewState, onNodeClick }: Props) { + const containerRef = useRef(null); + const indicatorsRef = useRef([]); + const onNodeClickRef = useRef(onNodeClick); + onNodeClickRef.current = onNodeClick; + const { camera, size, gl } = useThree(); + + useEffect(() => { + // Mount on the Canvas's parent so indicators stay within the graph area + const canvasParent = gl.domElement.parentElement; + const container = document.createElement("div"); + Object.assign(container.style, { + position: "absolute", + inset: "0", + pointerEvents: "none", + zIndex: "10", + overflow: "hidden", + }); + (canvasParent ?? document.body).appendChild(container); + containerRef.current = container; + + const indicators: HTMLDivElement[] = []; + for (let i = 0; i < MAX_INDICATORS; i++) { + const el = document.createElement("div"); + Object.assign(el.style, { + position: "absolute", + display: "none", + pointerEvents: "auto", + cursor: "pointer", + }); + el.addEventListener("click", () => { + const nid = (el as any).__nodeId as number | undefined; + if (nid !== undefined) onNodeClickRef.current(nid); + }); + el.addEventListener("mouseenter", () => { + const pip = el.children[0] as HTMLElement; + const diamond = pip?.children[0] as HTMLElement; + const label = el.children[2] as HTMLElement; + if (diamond) { + diamond.style.background = "rgba(255, 200, 100, 0.95)"; + diamond.style.boxShadow = "0 0 8px rgba(255, 200, 100, 0.8), 0 0 16px rgba(255, 200, 100, 0.3)"; + } + if (label) label.style.color = "rgba(255, 200, 100, 0.95)"; + }); + el.addEventListener("mouseleave", () => { + const pip = el.children[0] as HTMLElement; + const diamond = pip?.children[0] as HTMLElement; + const label = el.children[2] as HTMLElement; + if (diamond) { + diamond.style.background = "rgba(77, 217, 232, 0.9)"; + diamond.style.boxShadow = "0 0 6px rgba(77, 217, 232, 0.6), 0 0 12px rgba(77, 217, 232, 0.2)"; + } + if (label) label.style.color = "rgba(77, 217, 232, 0.85)"; + }); + + // Diamond pip + const pip = document.createElement("div"); + Object.assign(pip.style, { + position: "absolute", + width: "8px", + height: "8px", + left: "-4px", + top: "-4px", + }); + + const diamond = document.createElement("div"); + Object.assign(diamond.style, { + position: "absolute", + inset: "0", + background: "rgba(77, 217, 232, 0.9)", + transform: "rotate(45deg)", + borderRadius: "1px", + boxShadow: "0 0 6px rgba(77, 217, 232, 0.6), 0 0 12px rgba(77, 217, 232, 0.2)", + }); + pip.appendChild(diamond); + + const ring = document.createElement("div"); + Object.assign(ring.style, { + position: "absolute", + inset: "-4px", + border: "1px solid rgba(77, 217, 232, 0.25)", + borderRadius: "50%", + }); + pip.appendChild(ring); + + el.appendChild(pip); + + // Trail line (rotated independently) + const trail = document.createElement("div"); + Object.assign(trail.style, { + position: "absolute", + height: "1px", + background: "linear-gradient(90deg, rgba(77, 217, 232, 0.4), transparent)", + transformOrigin: "left center", + width: "20px", + left: "0", + top: "0", + }); + el.appendChild(trail); + + // Label (positioned independently, always readable) + const label = document.createElement("div"); + Object.assign(label.style, { + position: "absolute", + fontFamily: "'JetBrains Mono', monospace", + fontSize: "11px", + fontWeight: "500", + letterSpacing: "0.5px", + color: "rgba(77, 217, 232, 0.85)", + whiteSpace: "nowrap", + maxWidth: "90px", + overflow: "hidden", + textOverflow: "ellipsis", + textShadow: "0 0 8px rgba(0,0,0,0.9), 0 0 4px rgba(0,0,0,1)", + }); + el.appendChild(label); + + container.appendChild(el); + indicators.push(el); + } + indicatorsRef.current = indicators; + + return () => { + container.parentElement?.removeChild(container); + containerRef.current = null; + indicatorsRef.current = []; + }; + }, []); + + useFrame(() => { + const indicators = indicatorsRef.current; + if (!indicators.length) return; + + for (let i = 0; i < MAX_INDICATORS; i++) { + indicators[i].style.display = "none"; + } + + if (viewState.mode !== "subgraph") return; + + const w = size.width; + const h = size.height; + const selectedId = viewState.selectedNodeId; + const depthMap = viewState.depthMap; + + let count = 0; + + for (const [nodeId, depth] of depthMap) { + if (count >= MAX_INDICATORS) break; + if (depth !== 1 || nodeId === selectedId) continue; + + const node = graph.nodes[nodeId]; + if (!node) continue; + + _v3.set(node.position.x, node.position.y, node.position.z); + _v3.project(camera); + + const sx = ((_v3.x + 1) / 2) * w; + const sy = ((-_v3.y + 1) / 2) * h; + + if (_v3.z > 1) continue; + + const onScreen = + sx >= MARGIN && sx <= w - MARGIN && + sy >= MARGIN && sy <= h - MARGIN; + if (onScreen) continue; + + const cx = w / 2; + const cy = h / 2; + const dx = sx - cx; + const dy = sy - cy; + const angle = Math.atan2(dy, dx); + + const edgeX = w / 2 - MARGIN; + const edgeY = h / 2 - MARGIN; + const absCos = Math.abs(Math.cos(angle)); + const absSin = Math.abs(Math.sin(angle)); + + let clampX: number, clampY: number; + if (edgeX * absSin <= edgeY * absCos) { + clampX = cx + Math.sign(dx) * edgeX; + clampY = cy + Math.tan(angle) * Math.sign(dx) * edgeX; + } else { + clampX = cx + (Math.sign(dy) * edgeY) / Math.tan(angle); + clampY = cy + Math.sign(dy) * edgeY; + } + + clampX = Math.max(MARGIN, Math.min(w - MARGIN, clampX)); + clampY = Math.max(MARGIN, Math.min(h - MARGIN, clampY)); + + const el = indicators[count]; + (el as any).__nodeId = nodeId; + el.style.display = "block"; + el.style.left = `${clampX}px`; + el.style.top = `${clampY}px`; + + // Rotate trail to point outward (toward the off-screen node) + const trailEl = el.children[1] as HTMLElement; + const rotDeg = (angle * 180) / Math.PI; + trailEl.style.transform = `rotate(${rotDeg}deg)`; + + // Position label on the inward side (toward screen center) + const labelEl = el.children[2] as HTMLElement; + labelEl.textContent = node.label; + + // Determine which edge we're on and offset label inward + const onRight = clampX > w - MARGIN - 5; + const onLeft = clampX < MARGIN + 5; + const onBottom = clampY > h - MARGIN - 5; + const onTop = clampY < MARGIN + 5; + + if (onRight) { + labelEl.style.right = "14px"; + labelEl.style.left = "auto"; + labelEl.style.textAlign = "right"; + } else if (onLeft) { + labelEl.style.left = "14px"; + labelEl.style.right = "auto"; + labelEl.style.textAlign = "left"; + } else { + // Horizontal center — offset based on angle + if (dx > 0) { + labelEl.style.right = "14px"; + labelEl.style.left = "auto"; + labelEl.style.textAlign = "right"; + } else { + labelEl.style.left = "14px"; + labelEl.style.right = "auto"; + labelEl.style.textAlign = "left"; + } + } + + if (onTop) { + labelEl.style.top = "10px"; + labelEl.style.bottom = "auto"; + } else if (onBottom) { + labelEl.style.bottom = "10px"; + labelEl.style.top = "auto"; + } else { + labelEl.style.top = "-5px"; + labelEl.style.bottom = "auto"; + } + + count++; + } + }); + + return null; +} diff --git a/src/graph-viz-kit/PrevNodeIndicator.tsx b/src/graph-viz-kit/PrevNodeIndicator.tsx new file mode 100644 index 0000000..772cdb6 --- /dev/null +++ b/src/graph-viz-kit/PrevNodeIndicator.tsx @@ -0,0 +1,194 @@ +import { useRef, useEffect } from "react"; +import { useFrame, useThree } from "@react-three/fiber"; +import * as THREE from "three"; +import type { Graph, ViewState } from "./types"; + +const _v3 = new THREE.Vector3(); + +interface Props { + graph: Graph; + viewState: ViewState; + onNodeClick: (id: number) => void; +} + +export function PrevNodeIndicator({ graph, viewState, onNodeClick }: Props) { + const containerRef = useRef(null); + const bracketRef = useRef(null); + const chevronRef = useRef(null); + const onClickRef = useRef(onNodeClick); + onClickRef.current = onNodeClick; + const { camera, size, gl } = useThree(); + + const navigationHistory = viewState.mode === "subgraph" ? viewState.navigationHistory : []; + const prevNodeId = navigationHistory.length >= 2 ? navigationHistory[navigationHistory.length - 2] : null; + const prevNodeIdRef = useRef(prevNodeId); + prevNodeIdRef.current = prevNodeId; + + useEffect(() => { + const canvasParent = gl.domElement.parentElement; + const container = document.createElement("div"); + Object.assign(container.style, { + position: "absolute", + inset: "0", + pointerEvents: "none", + zIndex: "11", + overflow: "hidden", + }); + (canvasParent ?? document.body).appendChild(container); + containerRef.current = container; + + // --- Bracket (4 corner marks) --- + const bracket = document.createElement("div"); + Object.assign(bracket.style, { + position: "absolute", + width: "42px", + height: "42px", + pointerEvents: "auto", + cursor: "pointer", + display: "none", + }); + + const cornerStyle = { + position: "absolute", + width: "10px", + height: "10px", + borderColor: "rgba(255, 128, 38, 0.55)", + borderStyle: "solid", + borderWidth: "0", + }; + const corners = [ + { top: "0", left: "0", borderTopWidth: "2px", borderLeftWidth: "2px" }, + { top: "0", right: "0", borderTopWidth: "2px", borderRightWidth: "2px" }, + { bottom: "0", left: "0", borderBottomWidth: "2px", borderLeftWidth: "2px" }, + { bottom: "0", right: "0", borderBottomWidth: "2px", borderRightWidth: "2px" }, + ]; + for (const c of corners) { + const el = document.createElement("div"); + Object.assign(el.style, cornerStyle, c); + bracket.appendChild(el); + } + + bracket.addEventListener("click", () => { if (prevNodeIdRef.current !== null) onClickRef.current(prevNodeIdRef.current); }); + bracket.addEventListener("mouseenter", () => { + for (const child of bracket.children) { + (child as HTMLElement).style.borderColor = "rgba(255, 200, 100, 0.8)"; + } + }); + bracket.addEventListener("mouseleave", () => { + for (const child of bracket.children) { + (child as HTMLElement).style.borderColor = "rgba(255, 128, 38, 0.35)"; + } + }); + + container.appendChild(bracket); + bracketRef.current = bracket; + + // --- Chevron (arrow pointing toward prev) --- + const chevron = document.createElement("div"); + Object.assign(chevron.style, { + position: "absolute", + pointerEvents: "auto", + cursor: "pointer", + display: "none", + width: "30px", + height: "30px", + }); + + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("viewBox", "0 0 20 20"); + svg.setAttribute("width", "30"); + svg.setAttribute("height", "30"); + svg.style.overflow = "visible"; + + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + // Chevron pointing right: two lines meeting at a point + path.setAttribute("d", "M6 4 L14 10 L6 16"); + path.setAttribute("fill", "none"); + path.setAttribute("stroke", "rgba(255, 128, 38, 0.5)"); + path.setAttribute("stroke-width", "2"); + path.setAttribute("stroke-linecap", "round"); + path.setAttribute("stroke-linejoin", "round"); + svg.appendChild(path); + chevron.appendChild(svg); + + chevron.addEventListener("click", () => { if (prevNodeIdRef.current !== null) onClickRef.current(prevNodeIdRef.current); }); + chevron.addEventListener("mouseenter", () => { + path.setAttribute("stroke", "rgba(255, 200, 100, 1.0)"); + }); + chevron.addEventListener("mouseleave", () => { + path.setAttribute("stroke", "rgba(255, 128, 38, 0.5)"); + }); + + container.appendChild(chevron); + chevronRef.current = chevron; + + return () => { + container.parentElement?.removeChild(container); + containerRef.current = null; + bracketRef.current = null; + chevronRef.current = null; + }; + }, []); + + useFrame(() => { + const bracket = bracketRef.current; + const chevron = chevronRef.current; + if (!bracket || !chevron) return; + + if (viewState.mode !== "subgraph" || prevNodeId === null) { + bracket.style.display = "none"; + chevron.style.display = "none"; + return; + } + + const selectedId = viewState.selectedNodeId; + const prevNode = graph.nodes[prevNodeId]; + const selNode = graph.nodes[selectedId]; + if (!prevNode || !selNode) return; + + const w = size.width; + const h = size.height; + + // Project prev node + _v3.set(prevNode.position.x, prevNode.position.y, prevNode.position.z).project(camera); + if (_v3.z > 1) { + bracket.style.display = "none"; + chevron.style.display = "none"; + return; + } + const prevSx = ((_v3.x + 1) / 2) * w; + const prevSy = ((-_v3.y + 1) / 2) * h; + + // Project selected node + _v3.set(selNode.position.x, selNode.position.y, selNode.position.z).project(camera); + const selSx = ((_v3.x + 1) / 2) * w; + const selSy = ((-_v3.y + 1) / 2) * h; + + // --- Position bracket centered on prev node --- + bracket.style.display = "block"; + bracket.style.left = `${prevSx - 21}px`; + bracket.style.top = `${prevSy - 21}px`; + + // --- Position chevron near selected node, pointing toward prev --- + const dx = prevSx - selSx; + const dy = prevSy - selSy; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist < 1) { + chevron.style.display = "none"; + return; + } + + const angle = Math.atan2(dy, dx); + const offset = 35; // px from selected node center + const chevX = selSx + (dx / dist) * offset; + const chevY = selSy + (dy / dist) * offset; + + chevron.style.display = "block"; + chevron.style.left = `${chevX - 15}px`; + chevron.style.top = `${chevY - 15}px`; + const rotDeg = (angle * 180) / Math.PI; + chevron.style.transform = `rotate(${rotDeg}deg)`; + }); + + return null; +} diff --git a/src/graph-viz-kit/PulseLayer.tsx b/src/graph-viz-kit/PulseLayer.tsx new file mode 100644 index 0000000..d48c7d7 --- /dev/null +++ b/src/graph-viz-kit/PulseLayer.tsx @@ -0,0 +1,160 @@ +import { useRef } from "react"; +import { useFrame } from "@react-three/fiber"; +import * as THREE from "three"; +import type { Pulse } from "./GraphView"; + +const MAX_PULSES = 32; + +// Traveling dot: billboard that always faces camera +const dotVertexShader = /* glsl */ ` + varying vec2 vUv; + void main() { + vUv = uv; + vec3 instancePos = vec3(instanceMatrix[3]); + float s = length(vec3(instanceMatrix[0])); + vec4 mvPosition = modelViewMatrix * vec4(instancePos, 1.0); + float screenScale = -mvPosition.z / projectionMatrix[1][1]; + mvPosition.xy += (position.xy * s) * screenScale * 0.08; + gl_Position = projectionMatrix * mvPosition; + } +`; + +const dotFragmentShader = /* glsl */ ` + varying vec2 vUv; + void main() { + vec2 coord = (vUv - 0.5) * 2.0; + float r = length(coord); + // Bright core + soft glow + float core = 1.0 - smoothstep(0.0, 0.2, r); + float glow = exp(-r * r * 4.0) * 0.8; + float a = core + glow; + if (a < 0.01) discard; + vec3 color = vec3(0.6, 0.95, 1.0) * (core * 2.0 + glow); + gl_FragColor = vec4(color, a); + } +`; + +const _tmpObj = new THREE.Object3D(); + +interface PulseLayerProps { + pulses: Pulse[]; + positionsRef: React.RefObject; +} + +export function PulseLayer({ pulses, positionsRef }: PulseLayerProps) { + const lineRef = useRef(null); + const dotRef = useRef(null); + + useFrame(() => { + const positions = positionsRef.current; + if (!positions) return; + + const activePulses = pulses.slice(0, MAX_PULSES); + const count = activePulses.length; + + // Update traveling dots + const dots = dotRef.current; + if (dots) { + for (let i = 0; i < MAX_PULSES; i++) { + if (i < count) { + const p = activePulses[i]; + const s3 = p.src * 3; + const d3 = p.dst * 3; + // Lerp position along edge + const t = p.progress; + const x = positions[s3] + (positions[d3] - positions[s3]) * t; + const y = positions[s3 + 1] + (positions[d3 + 1] - positions[s3 + 1]) * t; + const z = positions[s3 + 2] + (positions[d3 + 2] - positions[s3 + 2]) * t; + _tmpObj.position.set(x, y, z); + // Scale: peaks in middle of travel, fades at ends + const envelope = Math.sin(t * Math.PI); + _tmpObj.scale.setScalar(0.4 + 0.6 * envelope); + } else { + _tmpObj.position.set(0, -1000, 0); + _tmpObj.scale.setScalar(0); + } + _tmpObj.updateMatrix(); + dots.setMatrixAt(i, _tmpObj.matrix); + } + dots.instanceMatrix.needsUpdate = true; + } + + // Update highlight edge lines + const line = lineRef.current; + if (line) { + const posArr = new Float32Array(count * 6); + const alphaArr = new Float32Array(count * 2); + + for (let i = 0; i < count; i++) { + const p = activePulses[i]; + const s3 = p.src * 3; + const d3 = p.dst * 3; + const base = i * 6; + + // Edge from source to current dot position (lit portion) + posArr[base] = positions[s3]; + posArr[base + 1] = positions[s3 + 1]; + posArr[base + 2] = positions[s3 + 2]; + // End at the traveling dot + const t = p.progress; + posArr[base + 3] = positions[s3] + (positions[d3] - positions[s3]) * t; + posArr[base + 4] = positions[s3 + 1] + (positions[d3 + 1] - positions[s3 + 1]) * t; + posArr[base + 5] = positions[s3 + 2] + (positions[d3 + 2] - positions[s3 + 2]) * t; + + // Bright at source, bright at dot + const envelope = Math.sin(t * Math.PI); + alphaArr[i * 2] = 0.3 * envelope; + alphaArr[i * 2 + 1] = 1.0 * envelope; + } + + const geom = line.geometry as THREE.BufferGeometry; + geom.setAttribute("position", new THREE.BufferAttribute(posArr, 3)); + geom.setAttribute("alpha", new THREE.BufferAttribute(alphaArr, 1)); + geom.attributes.position.needsUpdate = true; + geom.attributes.alpha.needsUpdate = true; + geom.setDrawRange(0, count * 2); + } + }); + + return ( + <> + {/* Bright edge trail */} + + + + + + {/* Traveling dots */} + + + + + + ); +} diff --git a/src/graph-viz-kit/buildGraph.ts b/src/graph-viz-kit/buildGraph.ts new file mode 100644 index 0000000..eadf424 --- /dev/null +++ b/src/graph-viz-kit/buildGraph.ts @@ -0,0 +1,56 @@ +import type { Graph, GraphEdge } from "./types"; + +export interface RawNode { + id: string; + label: string; + link?: string; + icon?: string; + status?: "executing" | "done" | "idle"; + progress?: number; + content?: string; +} + +export interface RawEdge { + source: string; + target: string; + label?: string; +} + +export function buildGraph(nodes: RawNode[], edges: RawEdge[]): Graph { + const idToIndex = new Map(); + for (let i = 0; i < nodes.length; i++) { + idToIndex.set(nodes[i].id, i); + } + + const n = nodes.length; + const adj: number[][] = Array.from({ length: n }, () => []); + const outAdj: number[][] = Array.from({ length: n }, () => []); + const inAdj: number[][] = Array.from({ length: n }, () => []); + const graphEdges: GraphEdge[] = []; + + for (const e of edges) { + const src = idToIndex.get(e.source); + const dst = idToIndex.get(e.target); + if (src === undefined || dst === undefined) continue; + + graphEdges.push({ src, dst, label: e.label }); + adj[src].push(dst); + adj[dst].push(src); + outAdj[src].push(dst); + inAdj[dst].push(src); + } + + const graphNodes = nodes.map((node, i) => ({ + id: i, + label: node.label, + position: { x: 0, y: 0, z: 0 }, + degree: adj[i].length, + ...(node.link != null && { link: node.link }), + ...(node.icon != null && { icon: node.icon }), + ...(node.status != null && { status: node.status }), + ...(node.progress != null && { progress: node.progress }), + ...(node.content != null && { content: node.content }), + })); + + return { nodes: graphNodes, edges: graphEdges, adj, outAdj, inAdj }; +} diff --git a/src/graph-viz-kit/extract.ts b/src/graph-viz-kit/extract.ts new file mode 100644 index 0000000..b4f9379 --- /dev/null +++ b/src/graph-viz-kit/extract.ts @@ -0,0 +1,136 @@ +import type { Graph, GraphEdge } from "./types"; + +/** Virtual center ID used when there are multiple root nodes. */ +export const VIRTUAL_CENTER = -1; + +/** Returns the best single root for initial layout (highest undirected degree). */ +export function findBestRoot(graph: Graph): number { + return graph.nodes.reduce((best, node) => + node.degree > graph.nodes[best].degree ? node.id : best, 0); +} + +/** + * Build the initial subgraph for layout. + * - If multiple source nodes (zero in-degree) exist, they all go on ring 1 + * around a virtual center (VIRTUAL_CENTER = -1). + * - If one source node exists, it becomes the center. + * - If no source nodes exist, falls back to highest-degree node with undirected BFS. + */ +export function extractInitialSubgraph(graph: Graph, maxDepth = 30): Subgraph { + const roots = graph.nodes + .filter((_, i) => graph.inAdj[i].length === 0) + .map((n) => n.id); + + // Single root or no roots — use existing logic + if (roots.length <= 1) { + const center = roots.length === 1 ? roots[0] : findBestRoot(graph); + return extractSubgraph(graph, center, maxDepth, { useAdj: "undirected" }); + } + + // Multiple roots: virtual center, all roots on first ring + const depthMap = new Map(); + depthMap.set(VIRTUAL_CENTER, 0); + + const neighborsByDepth: number[][] = []; + + const queue: number[] = []; + for (const r of roots) { + depthMap.set(r, 1); + if (!neighborsByDepth[0]) neighborsByDepth.push([]); + neighborsByDepth[0].push(r); + queue.push(r); + } + + // BFS from all roots via outAdj + let qi = 0; + while (qi < queue.length) { + const node = queue[qi++]; + const d = depthMap.get(node)!; + if (d >= maxDepth) continue; + + for (const child of graph.adj[node]) { + if (!depthMap.has(child)) { + depthMap.set(child, d + 1); + while (neighborsByDepth.length <= d) neighborsByDepth.push([]); + neighborsByDepth[d].push(child); + queue.push(child); + } + } + } + + const nodeIds = neighborsByDepth.flat(); + const nodeSet = new Set(nodeIds); + const edges = graph.edges.filter( + (e) => nodeSet.has(e.src) && nodeSet.has(e.dst) + ); + + return { centerId: VIRTUAL_CENTER, depthMap, neighborsByDepth, nodeIds, edges }; +} + +export interface Subgraph { + centerId: number; + parentId?: number; // parent node (via inAdj) + depthMap: Map; // nodeId → depth + neighborsByDepth: number[][]; // [hop1, hop2, ..., hopN] + nodeIds: number[]; + edges: GraphEdge[]; +} + +/** + * Extract the directed subgraph around a center node. + * - BFS through outAdj (children only) up to maxDepth + * - Identifies parent via inAdj[centerId] + * - Parent is included in nodeIds but NOT in neighborsByDepth + */ +export function extractSubgraph( + graph: Graph, centerId: number, maxDepth = 30, + opts?: { useAdj?: "directed" | "undirected" } +): Subgraph { + const adjList = opts?.useAdj === "undirected" ? graph.adj : graph.outAdj; + const depthMap = new Map(); + depthMap.set(centerId, 0); + + const neighborsByDepth: number[][] = []; + + const queue: number[] = [centerId]; + + // BFS through outAdj (children only — directed) + let qi = 0; + while (qi < queue.length) { + const node = queue[qi++]; + const d = depthMap.get(node)!; + if (d >= maxDepth) continue; + + for (const child of adjList[node]) { + if (!depthMap.has(child)) { + depthMap.set(child, d + 1); + while (neighborsByDepth.length <= d) neighborsByDepth.push([]); + neighborsByDepth[d].push(child); + queue.push(child); + } + } + } + + // Identify parent (first neighbor not in the subgraph, or at lower depth) + let parentId: number | undefined; + for (const p of graph.adj[centerId]) { + if (!depthMap.has(p)) { + parentId = p; + break; + } + } + + const nodeIds = [centerId, ...neighborsByDepth.flat()]; + if (parentId !== undefined) { + depthMap.set(parentId, -1); // special depth for parent + nodeIds.push(parentId); + } + + const nodeSet = new Set(nodeIds); + + const edges = graph.edges.filter( + (e) => nodeSet.has(e.src) && nodeSet.has(e.dst) + ); + + return { centerId, parentId, depthMap, neighborsByDepth, nodeIds, edges }; +} diff --git a/src/graph-viz-kit/index.ts b/src/graph-viz-kit/index.ts new file mode 100644 index 0000000..7d8038a --- /dev/null +++ b/src/graph-viz-kit/index.ts @@ -0,0 +1,65 @@ +// ============================================ +// graph-viz-kit — Self-contained 3D graph visualization +// +// Dependencies (add to your project): +// npm install three @react-three/fiber @react-three/drei @react-three/postprocessing +// npm install -D @types/three +// +// Usage: +// import { buildGraph, computeRadialLayout, extractInitialSubgraph, extractSubgraph, VIRTUAL_CENTER } from "./graph-viz-kit"; +// import { GraphView, type Pulse } from "./graph-viz-kit"; +// import { OffscreenIndicators } from "./graph-viz-kit"; +// import { PrevNodeIndicator } from "./graph-viz-kit"; +// import type { Graph, ViewState, RawNode, RawEdge } from "./graph-viz-kit"; +// +// Quick start: +// +// const nodes: RawNode[] = [ +// { id: "a", label: "Node A", icon: "★" }, +// { id: "b", label: "Node B" }, +// { id: "c", label: "Node C" }, +// ]; +// const edges: RawEdge[] = [ +// { source: "a", target: "b" }, +// { source: "a", target: "c" }, +// ]; +// +// const graph = buildGraph(nodes, edges); +// const sub = extractInitialSubgraph(graph); +// const { positions, treeEdgeSet, childrenOf } = computeRadialLayout( +// sub.centerId, sub.neighborsByDepth, graph.edges, { parentId: sub.parentId } +// ); +// for (const [id, pos] of positions) { +// if (id !== VIRTUAL_CENTER) graph.nodes[id].position = pos; +// } +// graph.initialDepthMap = sub.depthMap; +// graph.treeEdgeSet = treeEdgeSet; +// graph.childrenOf = childrenOf; +// +// // Then render inside a : +// +// ============================================ + +// Graph data types +export type { Graph, GraphNode, GraphEdge, Vec3, ViewState } from "./types"; +export { edgeKey } from "./types"; + +// Build graph from raw data +export type { RawNode, RawEdge } from "./buildGraph"; +export { buildGraph } from "./buildGraph"; + +// Layout algorithm +export { computeRadialLayout, adaptiveRadius } from "./layout"; +export type { RadialLayoutResult } from "./layout"; + +// Subgraph extraction +export { extractSubgraph, extractInitialSubgraph, findBestRoot, VIRTUAL_CENTER } from "./extract"; +export type { Subgraph } from "./extract"; + +// 3D components (use inside @react-three/fiber ) +export { GraphView } from "./GraphView"; +export type { Pulse } from "./GraphView"; +export { PulseLayer } from "./PulseLayer"; +export { NodeDetailPanel } from "./NodeDetailPanel"; +export { OffscreenIndicators } from "./OffscreenIndicators"; +export { PrevNodeIndicator } from "./PrevNodeIndicator"; diff --git a/src/graph-viz-kit/layout.ts b/src/graph-viz-kit/layout.ts new file mode 100644 index 0000000..73afbf2 --- /dev/null +++ b/src/graph-viz-kit/layout.ts @@ -0,0 +1,284 @@ +export type Vec3 = { x: number; y: number; z: number }; +export type Edge = { src: number; dst: number }; + +const TWO_PI = Math.PI * 2; + +const MIN_R1 = 22; +const MIN_ARC_LENGTH = 10; + +export function adaptiveRadius( + count: number, + minArc = MIN_ARC_LENGTH, + minR = MIN_R1 +): number { + if (count <= 1) return minR; + return Math.max(minR, (minArc * count) / (Math.PI * 2)); +} + +function buildAdj(edges: Edge[]): Map { + const m = new Map(); + for (const e of edges) { + if (!m.has(e.src)) m.set(e.src, []); + if (!m.has(e.dst)) m.set(e.dst, []); + m.get(e.src)!.push(e.dst); + m.get(e.dst)!.push(e.src); + } + return m; +} + +function normAngle(a: number) { + a %= TWO_PI; + if (a < 0) a += TWO_PI; + return a; +} + +export interface DepthVisual { + scale: number; + r: number; + g: number; + b: number; + showLabel: boolean; +} + +export function depthVisuals(depth: number, degreeRatio: number): DepthVisual { + const base = + depth === 0 ? 1.0 : + depth === 1 ? 0.62 : + depth === 2 ? 0.26 : + depth === 3 ? 0.13 : 0.07; + + const scale = base * (0.85 + 0.45 * degreeRatio); + + switch (depth) { + case 0: + return { scale, r: 0.20, g: 0.90, b: 1.00, showLabel: true }; + case 1: + return { scale, r: 0.15 * 0.85, g: 0.50 * 0.85, b: 0.90 * 0.85, showLabel: true }; + case 2: + return { scale, r: 0.10 * 0.55, g: 0.35 * 0.55, b: 0.65 * 0.55, showLabel: false }; + case 3: + return { scale, r: 0.08 * 0.28, g: 0.20 * 0.28, b: 0.40 * 0.28, showLabel: false }; + default: + return { scale: base, r: 0.05 * 0.14, g: 0.10 * 0.14, b: 0.20 * 0.14, showLabel: false }; + } +} + +export interface RadialLayoutResult { + positions: Map; + treeEdgeSet: Set; + childrenOf: Map; +} + +function edgeKey(a: number, b: number): string { + return a < b ? `${a}-${b}` : `${b}-${a}`; +} + +export function computeRadialLayout( + centerId: number, + neighborsByDepth: number[][], + edges: Edge[], + opts?: { + r1?: number | "auto"; + y1?: number; + localMinSpacing?: number; + startAngle?: number; + wedgePad?: number; + parentId?: number; + } +): RadialLayoutResult { + const { + r1: r1Opt = "auto", + startAngle = -Math.PI / 2, + parentId, + } = opts ?? {}; + + const positions = new Map(); + const treeEdgeSet = new Set(); + positions.set(centerId, { x: 0, y: 0, z: 0 }); + + if (neighborsByDepth.length === 0 || neighborsByDepth[0].length === 0) { + return { positions, treeEdgeSet, childrenOf: new Map() }; + } + + const adj = buildAdj(edges); + const maxDepth = neighborsByDepth.length; + + // ---- 1. Build BFS tree (assign each node exactly one parent) ---- + const childrenOf = new Map(); + childrenOf.set(centerId, []); + + for (let d = 0; d < maxDepth; d++) { + if (d === 0) { + for (const node of neighborsByDepth[0]) { + if (!childrenOf.has(node)) childrenOf.set(node, []); + childrenOf.get(centerId)!.push(node); + } + continue; + } + + const prevSet = new Set(neighborsByDepth[d - 1]); + for (const node of neighborsByDepth[d]) { + if (!childrenOf.has(node)) childrenOf.set(node, []); + const ns = adj.get(node) ?? []; + let best = -1; + let bestDeg = -1; + + for (const x of ns) { + if (!prevSet.has(x)) continue; + const deg = adj.get(x)?.length ?? 0; + if (deg > bestDeg) { + bestDeg = deg; + best = x; + } + } + + if (best !== -1) childrenOf.get(best)!.push(node); + } + } + + for (const [, kids] of childrenOf) kids.sort((a, b) => a - b); + + // ---- 2b. Populate treeEdgeSet from childrenOf ---- + for (const [parent, kids] of childrenOf) { + for (const kid of kids) { + treeEdgeSet.add(edgeKey(parent, kid)); + } + } + + // ---- 2c. Cross-edge-aware angular ordering of hop-1 children ---- + const hop1Raw = childrenOf.get(centerId)!; + if (hop1Raw.length > 2) { + const subtreeOf = new Map(); + + for (const h of hop1Raw) { + subtreeOf.set(h, h); + const stack = [h]; + while (stack.length > 0) { + const cur = stack.pop()!; + for (const kid of childrenOf.get(cur) ?? []) { + subtreeOf.set(kid, h); + stack.push(kid); + } + } + } + + const crossCount = new Map(); + for (const e of edges) { + const sa = subtreeOf.get(e.src); + const sb = subtreeOf.get(e.dst); + if (sa === undefined || sb === undefined || sa === sb) continue; + if (treeEdgeSet.has(edgeKey(e.src, e.dst))) continue; + const key = edgeKey(sa, sb); + crossCount.set(key, (crossCount.get(key) ?? 0) + 1); + } + + if (crossCount.size > 0) { + const remaining = new Set(hop1Raw); + const ordered: number[] = []; + let cur = hop1Raw[0]; + ordered.push(cur); + remaining.delete(cur); + + while (remaining.size > 0) { + let best = -1; + let bestScore = -1; + + for (const cand of remaining) { + const score = crossCount.get(edgeKey(cur, cand)) ?? 0; + if (score > bestScore) { + bestScore = score; + best = cand; + } + } + + if (best === -1) best = remaining.values().next().value!; + ordered.push(best); + remaining.delete(best); + cur = best; + } + + childrenOf.set(centerId, ordered); + } + } + + // ---- 3. Ring radii & y-offsets ---- + const hop1 = childrenOf.get(centerId)!; + const n1 = hop1.length; + const r1 = r1Opt === "auto" ? adaptiveRadius(n1) : r1Opt; + + const yOff: number[] = [0]; + yOff[1] = -r1 * 0.35; + + // ---- 4. Placement ---- + if (n1 === 0) return { positions, treeEdgeSet, childrenOf: new Map() }; + + const R1 = r1; + + // Fixed ring radii per depth — geometric series + const DEPTH_SHRINK = 0.45; + const orbitR: number[] = [0, R1]; + for (let d = 2; d <= maxDepth + 1; d++) { + orbitR[d] = orbitR[d - 1] * DEPTH_SHRINK; + } + + // Y-offset proportional to ring radius + const Y_RATIO = 0.35; + for (let d = 2; d <= maxDepth + 1; d++) { + yOff[d] = yOff[d - 1] - orbitR[d] * Y_RATIO; + } + + // Hop-1: EVEN angular spacing (not weighted) + const sectorSpan = TWO_PI / n1; + for (let i = 0; i < n1; i++) { + const angle = startAngle + i * sectorSpan; + positions.set(hop1[i], { + x: Math.cos(angle) * R1, + y: yOff[1], + z: Math.sin(angle) * R1, + }); + } + + // Deeper layers: children on a ring of radius orbitR[childDepth] + // around their parent. Angular step = 2π/nChildren. + for (let d = 0; d < maxDepth; d++) { + for (const node of neighborsByDepth[d]) { + const kids = childrenOf.get(node) ?? []; + if (!kids.length) continue; + + const parentPos = positions.get(node); + if (!parentPos) continue; + + const childDepth = d + 2; + if (childDepth > maxDepth) continue; + + const outward = Math.atan2(parentPos.z, parentPos.x); + const localR = orbitR[childDepth]; + const step = TWO_PI / kids.length; + + for (let j = 0; j < kids.length; j++) { + const phi = outward + j * step; + positions.set(kids[j], { + x: parentPos.x + Math.cos(phi) * localR, + y: yOff[childDepth], + z: parentPos.z + Math.sin(phi) * localR, + }); + } + } + } + + // ---- 5. Parent node ---- + if (parentId !== undefined) { + const parentDist = r1 * 3.5; + const parentAngle = normAngle(startAngle + Math.PI); + + positions.set(parentId, { + x: Math.cos(parentAngle) * parentDist, + y: yOff[1], + z: Math.sin(parentAngle) * parentDist, + }); + } + + return { positions, treeEdgeSet, childrenOf }; +} + +// diff --git a/src/graph-viz-kit/types.ts b/src/graph-viz-kit/types.ts new file mode 100644 index 0000000..adfae41 --- /dev/null +++ b/src/graph-viz-kit/types.ts @@ -0,0 +1,73 @@ +export interface Vec3 { + x: number; + y: number; + z: number; +} + +export interface GraphNode { + id: number; + label: string; + position: Vec3; + degree: number; + link?: string; + icon?: string; + status?: "executing" | "done" | "idle"; + progress?: number; // 0–1 for executing nodes + content?: string; // descriptive text for detail view + loaderId?: string; + nodeType?: string; +} + +export type LayoutStrategyName = "radial" | "force" | "auto"; + +export interface LayoutResult { + positions: Map; + treeEdgeSet: Set; + childrenOf: Map; +} + +export interface GraphEdge { + src: number; + dst: number; + label?: string; + type?: string; +} + +export const UNSTRUCTURED_EDGE_TYPES = new Set(["references", "mentions", "relates"]); + +export function isStructuralEdge(edge: GraphEdge): boolean { + return edge.type === undefined || !UNSTRUCTURED_EDGE_TYPES.has(edge.type); +} + +export interface Graph { + nodes: GraphNode[]; + edges: GraphEdge[]; + adj: number[][]; + outAdj: number[][]; // children: source→target (directed) + inAdj: number[][]; // parents: target→source (directed) + structuralAdj?: number[][]; + structuralOutAdj?: number[][]; + structuralInAdj?: number[][]; + unstructuredNodeIds?: Set; + unstructuredRegions?: { id: number; proxyNodeId: number; memberIds: number[]; expanded: boolean; radius: number; center: Vec3 }[]; + initialDepthMap?: Map; + treeEdgeSet?: Set; + childrenOf?: Map; +} + +/** Undirected edge key (order-independent) */ +export function edgeKey(a: number, b: number): string { + return a < b ? `${a}-${b}` : `${b}-${a}`; +} + +export type ViewState = + | { mode: "overview" } + | { + mode: "subgraph"; + selectedNodeId: number; + navigationHistory: number[]; + depthMap: Map; + neighborsByDepth: number[][]; + parentId?: number; + visibleNodeIds: number[]; + };