diff --git a/package-lock.json b/package-lock.json index 28ae11a..b2e9b1e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,8 @@ "@daily-co/daily-react": "^0.20.0", "@fal-ai/client": "^1.7.2", "@mdxeditor/editor": "^3.51.0", + "@react-three/drei": "^9.114.3", + "@react-three/fiber": "^8.17.10", "@tldraw/assets": "^3.15.4", "@tldraw/tldraw": "^3.15.4", "@tldraw/tlschema": "^3.15.4", @@ -58,6 +60,7 @@ "react-router-dom": "^7.0.2", "recoil": "^0.7.7", "sharp": "^0.33.5", + "three": "^0.182.0", "tldraw": "^3.15.4", "use-whisper": "^0.0.1", "webcola": "^3.4.0" @@ -2147,6 +2150,12 @@ "recoil": "^0.7.0" } }, + "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/@emnapi/runtime": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", @@ -3892,6 +3901,24 @@ "react-dom": ">= 18 || >= 19" } }, + "node_modules/@mediapipe/tasks-vision": { + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.8.tgz", + "integrity": "sha512-Rp7ll8BHrKB3wXaRFKhrltwZl1CiXGdibPxuWXvqGnKTnv8fqa/nvftYNuSbf+pbJWKYCXdBtYTITdAUTGGh0Q==", + "license": "Apache-2.0" + }, + "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/@msgpack/msgpack": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.2.tgz", @@ -5660,6 +5687,205 @@ "react": ">=16.8" } }, + "node_modules/@react-spring/animated": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.6.1.tgz", + "integrity": "sha512-ls/rJBrAqiAYozjLo5EPPLLOb1LM0lNVQcXODTC1SMtS6DbuBCPaKco5svFUQFMP2dso3O+qcC4k9FsKc0KxMQ==", + "license": "MIT", + "dependencies": { + "@react-spring/shared": "~9.6.1", + "@react-spring/types": "~9.6.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/core": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-9.6.1.tgz", + "integrity": "sha512-3HAAinAyCPessyQNNXe5W0OHzRfa8Yo5P748paPcmMowZ/4sMfaZ2ZB6e5x5khQI8NusOHj8nquoutd6FRY5WQ==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~9.6.1", + "@react-spring/rafz": "~9.6.1", + "@react-spring/shared": "~9.6.1", + "@react-spring/types": "~9.6.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-spring/donate" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/rafz": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-9.6.1.tgz", + "integrity": "sha512-v6qbgNRpztJFFfSE3e2W1Uz+g8KnIBs6SmzCzcVVF61GdGfGOuBrbjIcp+nUz301awVmREKi4eMQb2Ab2gGgyQ==", + "license": "MIT" + }, + "node_modules/@react-spring/shared": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-9.6.1.tgz", + "integrity": "sha512-PBFBXabxFEuF8enNLkVqMC9h5uLRBo6GQhRMQT/nRTnemVENimgRd+0ZT4yFnAQ0AxWNiJfX3qux+bW2LbG6Bw==", + "license": "MIT", + "dependencies": { + "@react-spring/rafz": "~9.6.1", + "@react-spring/types": "~9.6.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/three": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@react-spring/three/-/three-9.6.1.tgz", + "integrity": "sha512-Tyw2YhZPKJAX3t2FcqvpLRb71CyTe1GvT3V+i+xJzfALgpk10uPGdGaQQ5Xrzmok1340DAeg2pR/MCfaW7b8AA==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~9.6.1", + "@react-spring/core": "~9.6.1", + "@react-spring/shared": "~9.6.1", + "@react-spring/types": "~9.6.1" + }, + "peerDependencies": { + "@react-three/fiber": ">=6.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "three": ">=0.126" + } + }, + "node_modules/@react-spring/types": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-9.6.1.tgz", + "integrity": "sha512-POu8Mk0hIU3lRXB3bGIGe4VHIwwDsQyoD1F394OK7STTiX9w4dG3cTLljjYswkQN+hDSHRrj4O36kuVa7KPU8Q==", + "license": "MIT" + }, + "node_modules/@react-three/drei": { + "version": "9.114.3", + "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-9.114.3.tgz", + "integrity": "sha512-hPKPYmxTb2P1mOdhkouJbKJVcfFK5JmThr/97i4zkweoNzWBHNde090A6r0SFFb4tGaTtHM4/kyfVx5PrzjTMw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@mediapipe/tasks-vision": "0.10.8", + "@monogrid/gainmap-js": "^3.0.5", + "@react-spring/three": "~9.6.1", + "@use-gesture/react": "^10.2.24", + "camera-controls": "^2.4.2", + "cross-env": "^7.0.3", + "detect-gpu": "^5.0.28", + "glsl-noise": "^0.0.0", + "hls.js": "1.3.5", + "maath": "^0.10.7", + "meshline": "^3.1.6", + "react-composer": "^5.0.3", + "stats-gl": "^2.0.0", + "stats.js": "^0.17.0", + "suspend-react": "^0.1.3", + "three-mesh-bvh": "^0.7.8", + "three-stdlib": "^2.29.9", + "troika-three-text": "^0.49.0", + "tunnel-rat": "^0.1.2", + "utility-types": "^3.10.0", + "uuid": "^9.0.1", + "zustand": "^3.7.1" + }, + "peerDependencies": { + "@react-three/fiber": ">=8.0", + "react": ">=18.0", + "react-dom": ">=18.0", + "three": ">=0.137" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/@react-three/fiber": { + "version": "8.17.10", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-8.17.10.tgz", + "integrity": "sha512-S6bqa4DqUooEkInYv/W+Jklv2zjSYCXAhm6qKpAQyOXhTEt5gBXnA7W6aoJ0bjmp9pAeaSj/AZUoz1HCSof/uA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8", + "@types/debounce": "^1.2.1", + "@types/react-reconciler": "^0.26.7", + "@types/webxr": "*", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "debounce": "^1.2.1", + "its-fine": "^1.0.6", + "react-reconciler": "^0.27.0", + "scheduler": "^0.21.0", + "suspend-react": "^0.1.3", + "zustand": "^3.7.1" + }, + "peerDependencies": { + "expo": ">=43.0", + "expo-asset": ">=8.4", + "expo-file-system": ">=11.0", + "expo-gl": ">=11.0", + "react": ">=18.0", + "react-dom": ">=18.0", + "react-native": ">=0.64", + "three": ">=0.133" + }, + "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/fiber/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/@react-three/fiber/node_modules/scheduler": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz", + "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/@remirror/core-constants": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", @@ -7060,6 +7286,12 @@ "node": ">= 10" } }, + "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/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -7432,6 +7664,12 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/debounce": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@types/debounce/-/debounce-1.2.4.tgz", + "integrity": "sha512-jBqiORIzKDOToaF63Fm//haOCHuwQuLa2202RK4MozpA6lh93eCBc+/8+wZn5OzjJt3ySdc+74SXWXB55Ewtyw==", + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -7463,6 +7701,12 @@ "@types/trusted-types": "*" } }, + "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", @@ -7633,6 +7877,12 @@ "form-data": "^4.0.4" } }, + "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/prismjs": { "version": "1.26.5", "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz", @@ -7677,7 +7927,6 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -7693,6 +7942,15 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/react-reconciler": { + "version": "0.26.7", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.26.7.tgz", + "integrity": "sha512-mBDYl8x+oyPX/VBb3E638N0B7xG+SPk/EAMcVPeexqus/5aTpTphQi0curhhshOqRrc9t6OPoJfEUkbymse/lQ==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", @@ -7726,6 +7984,12 @@ "@types/node": "*" } }, + "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", @@ -7751,6 +8015,21 @@ "@types/estree": "*" } }, + "node_modules/@types/three": { + "version": "0.182.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.182.0.tgz", + "integrity": "sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q==", + "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": "~0.22.0" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -7769,6 +8048,12 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "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/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -8187,6 +8472,12 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@webgpu/types": { + "version": "0.1.68", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.68.tgz", + "integrity": "sha512-3ab1B59Ojb6RwjOspYLsTpCzbNB3ZaamIAxBMmvnNkiDoLTZUOBXZ9p5nAYVEkQlDdf6qAZWi1pqj9+ypiqznA==", + "license": "BSD-3-Clause" + }, "node_modules/@xenova/transformers": { "version": "2.17.2", "resolved": "https://registry.npmjs.org/@xenova/transformers/-/transformers-2.17.2.tgz", @@ -8558,7 +8849,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, "license": "MIT", "dependencies": { "require-from-string": "^2.0.2" @@ -8814,6 +9104,15 @@ "tslib": "^2.0.3" } }, + "node_modules/camera-controls": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-2.10.1.tgz", + "integrity": "sha512-KnaKdcvkBJ1Irbrzl8XD6WtZltkRjp869Jx8c0ujs9K+9WD+1D7ryBsCiVqJYUqt6i/HR5FxT7RLASieUD+Q5w==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.126.1" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001757", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz", @@ -9586,6 +9885,38 @@ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", "license": "MIT" }, + "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", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/crypto-js": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", @@ -10204,6 +10535,12 @@ "license": "MIT", "optional": true }, + "node_modules/debounce": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", + "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -10308,6 +10645,15 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "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", @@ -10509,6 +10855,12 @@ "react": ">=16.12.0" } }, + "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", @@ -11380,6 +11732,12 @@ "dev": true, "license": "BSD-2-Clause" }, + "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", @@ -11825,6 +12183,12 @@ "dev": true, "license": "MIT" }, + "node_modules/hls.js": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.3.5.tgz", + "integrity": "sha512-uybAvKS6uDe0MnWNEPnO0krWVr+8m2R0hJ/viql8H3MVK+itq8gGQuIYoFHL3rECkIpNH98Lw8YuuWMKZxp3Ew==", + "license": "Apache-2.0" + }, "node_modules/hotkeys-js": { "version": "3.13.15", "resolved": "https://registry.npmjs.org/hotkeys-js/-/hotkeys-js-3.13.15.tgz", @@ -12234,6 +12598,12 @@ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "license": "MIT" }, + "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/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", @@ -12252,6 +12622,12 @@ "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", "license": "MIT" }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -12319,6 +12695,27 @@ "node": ">=8" } }, + "node_modules/its-fine": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.2.5.tgz", + "integrity": "sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.0" + }, + "peerDependencies": { + "react": ">=18.0" + } + }, + "node_modules/its-fine/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/itty-router": { "version": "5.0.22", "resolved": "https://registry.npmjs.org/itty-router/-/itty-router-5.0.22.tgz", @@ -12705,6 +13102,16 @@ "lz-string": "bin/bin.js" } }, + "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/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -13258,6 +13665,21 @@ "license": "(MPL-2.0 OR Apache-2.0)", "optional": true }, + "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": "0.22.0", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz", + "integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==", + "license": "MIT" + }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -14745,6 +15167,15 @@ "tslib": "^2.0.3" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-to-regexp": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", @@ -14942,6 +15373,16 @@ "node": ">=6" } }, + "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/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -15440,6 +15881,18 @@ "react-dom": "^16.x || ^17.x || ^18.x" } }, + "node_modules/react-composer": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/react-composer/-/react-composer-5.0.3.tgz", + "integrity": "sha512-1uWd07EME6XZvMfapwZmc7NgCZqDemcvicRi3wMJzXsQLvZ3L7fTHVyPy1bZdnWXM4iPjYuNE+uJ41MLKeTtnA==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.6.0" + }, + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-devtools-inline": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/react-devtools-inline/-/react-devtools-inline-4.4.0.tgz", @@ -15527,6 +15980,31 @@ "react": ">=18" } }, + "node_modules/react-reconciler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.27.0.tgz", + "integrity": "sha512-HmMDKciQjYmBRGuuhIaKA1ba/7a+UsM5FzOZsMO2JYHt9Jh8reCb7j1eDC95NOyUlKM9KRyvdx0flBuDvYSBoA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.21.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, + "node_modules/react-reconciler/node_modules/scheduler": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz", + "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -16439,6 +16917,27 @@ "node": ">=10" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/shell-quote": { "version": "1.8.3", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", @@ -16635,6 +17134,32 @@ "outvariant": "^1.3.0" } }, + "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.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -16816,6 +17341,15 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "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/svg-pathdata": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", @@ -16934,6 +17468,51 @@ "utrie": "^1.0.2" } }, + "node_modules/three": { + "version": "0.182.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz", + "integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==", + "license": "MIT" + }, + "node_modules/three-mesh-bvh": { + "version": "0.7.8", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.7.8.tgz", + "integrity": "sha512-BGEZTOIC14U0XIRw3tO4jY7IjP7n7v24nv9JXS1CyeVRWOCkcOMhRnmENUjuV39gktAw4Ofhr0OvIAiTspQrrw==", + "deprecated": "Deprecated due to three.js version incompatibility. Please use v0.8.0, instead.", + "license": "MIT", + "peerDependencies": { + "three": ">= 0.151.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/three-stdlib/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/throttleit": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", @@ -17133,6 +17712,36 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/troika-three-text": { + "version": "0.49.1", + "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.49.1.tgz", + "integrity": "sha512-lXGWxgjJP9kw4i4Wh+0k0Q/7cRfS6iOME4knKht/KozPu9GcFA9NnNpRvehIhrUawq9B0ZRw+0oiFHgRO+4Wig==", + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.2", + "troika-three-utils": "^0.49.0", + "troika-worker-utils": "^0.49.0", + "webgl-sdf-generator": "1.1.1" + }, + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-three-utils": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.49.0.tgz", + "integrity": "sha512-umitFL4cT+Fm/uONmaQEq4oZlyRHWwVClaS6ZrdcueRvwc2w+cpNQ47LlJKJswpqtMFWbEhOLy0TekmcPZOdYA==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-worker-utils": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.49.0.tgz", + "integrity": "sha512-1xZHoJrG0HFfCvT/iyN41DvI/nRykiBtHqFkGaGgJwq5iXfIZFBiPPEHFpPpgyKM3Oo5ITHXP5wM2TNQszYdVg==", + "license": "MIT" + }, "node_modules/trough": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", @@ -17179,6 +17788,43 @@ "fsevents": "~2.3.3" } }, + "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/type": { "version": "2.7.3", "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", @@ -17534,6 +18180,15 @@ "integrity": "sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==", "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/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -18002,6 +18657,17 @@ "integrity": "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==", "license": "BSD-3-Clause" }, + "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/webidl-conversions": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", @@ -18062,6 +18728,21 @@ "node": ">=20" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -18812,6 +19493,23 @@ "zod": "^3.25 || ^4" } }, + "node_modules/zustand": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-3.7.2.tgz", + "integrity": "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==", + "license": "MIT", + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index eca4e1c..d97c413 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,8 @@ "@daily-co/daily-react": "^0.20.0", "@fal-ai/client": "^1.7.2", "@mdxeditor/editor": "^3.51.0", + "@react-three/drei": "^9.114.3", + "@react-three/fiber": "^8.17.10", "@tldraw/assets": "^3.15.4", "@tldraw/tldraw": "^3.15.4", "@tldraw/tlschema": "^3.15.4", @@ -84,6 +86,7 @@ "react-router-dom": "^7.0.2", "recoil": "^0.7.7", "sharp": "^0.33.5", + "three": "^0.182.0", "tldraw": "^3.15.4", "use-whisper": "^0.0.1", "webcola": "^3.4.0" diff --git a/src/components/networking/NetworkGraph3D.tsx b/src/components/networking/NetworkGraph3D.tsx new file mode 100644 index 0000000..a37eb44 --- /dev/null +++ b/src/components/networking/NetworkGraph3D.tsx @@ -0,0 +1,1163 @@ +// @ts-nocheck +/** + * NetworkGraph3D Component + * + * A 3D force-directed social graph visualization using Three.js. + * Renders users as spheres within a bounded transparent sphere, + * with connections shown as animated flowing lines between nodes. + * + * Features: + * - Trust-level clustering (trusted → inner, connected → middle, unconnected → outer) + * - Node size proportional to decision power/influence + * - Animated edge flows showing delegation direction + * - Orbit controls for camera navigation (drag to rotate, scroll to zoom) + * - Zoom to user with camera animation + * - View as user (broadcast mode) for screen following + * - Click nodes to select and interact + * + * Note: @ts-nocheck is used because React Three Fiber JSX types are not + * being properly recognized. The code works correctly at runtime. + */ + +import React, { useRef, useMemo, useState, useCallback, useEffect } from 'react'; +import { Canvas, useFrame, useThree, ThreeEvent } from '@react-three/fiber'; +import { OrbitControls, Text, Billboard } from '@react-three/drei'; +import * as THREE from 'three'; +import { type GraphNode, type GraphEdge, type TrustLevel } from '../../lib/networking'; + +// ============================================================================= +// Types +// ============================================================================= + +interface NetworkGraph3DProps { + nodes: GraphNode[]; + edges: GraphEdge[]; + currentUserId?: string; + onNodeClick?: (node: GraphNode) => void; + onNodeSelect?: (node: GraphNode | null) => void; + onConnect?: (userId: string, trustLevel?: TrustLevel) => Promise; + onZoomToUser?: (node: GraphNode) => void; + onViewAsUser?: (node: GraphNode) => void; + isDarkMode?: boolean; + sphereRadius?: number; +} + +interface Node3D extends GraphNode { + position: THREE.Vector3; + velocity: THREE.Vector3; + targetRadius: number; // Target distance from center based on trust level + decisionPower: number; // Calculated influence metric +} + +interface SelectedNodeInfo { + node: GraphNode; + screenPosition: { x: number; y: number }; +} + +// ============================================================================= +// Force Simulation Constants +// ============================================================================= + +const FORCE_CONFIG = { + // Repulsion between all nodes + repulsion: 0.6, + repulsionDistance: 2.5, + // Attraction along edges + linkStrength: 0.015, + linkDistance: 1.2, + // Centering force (now per-shell) + shellStrength: 0.03, + // Velocity damping + damping: 0.88, + // Sphere boundary + boundaryStrength: 0.4, + // Minimum movement threshold to stop simulation + minVelocity: 0.0008, + // Trust level shell radii (as fraction of sphere radius) + trustedShell: 0.35, // Inner - most trusted + connectedShell: 0.6, // Middle - connected + outerShell: 0.85, // Outer - unconnected/anonymous +}; + +// ============================================================================= +// Helper Functions +// ============================================================================= + +function getNodeColor(node: GraphNode, isDarkMode: boolean): string { + if (node.roomPresenceColor) return node.roomPresenceColor; + if (node.avatarColor) return node.avatarColor; + return isDarkMode ? '#6b7280' : '#9ca3af'; +} + +function getEdgeColor(edge: GraphEdge, isDarkMode: boolean): string { + const level = edge.effectiveTrustLevel || edge.trustLevel; + if (level === 'trusted') return isDarkMode ? '#22c55e' : '#16a34a'; + if (level === 'connected') return isDarkMode ? '#eab308' : '#ca8a04'; + return isDarkMode ? 'rgba(150, 150, 150, 0.5)' : 'rgba(100, 100, 100, 0.4)'; +} + +/** + * Calculate decision power for a node based on incoming connections + * Higher power = more people trust/delegate to this user + */ +function calculateDecisionPower( + nodeId: string, + edges: GraphEdge[], + currentUserId?: string +): number { + let power = 1; // Base power + + for (const edge of edges) { + // Count incoming connections (where this node is the target) + if (edge.target === nodeId) { + const weight = edge.trustLevel === 'trusted' ? 2 : 1; + power += weight; + } + // Mutual connections add extra weight + if (edge.isMutual && (edge.source === nodeId || edge.target === nodeId)) { + power += 0.5; + } + } + + // Current user gets a small boost for visibility + if (nodeId === currentUserId) { + power += 0.5; + } + + return power; +} + +/** + * Determine which shell a node belongs to based on trust relationships + */ +function getNodeShellRadius( + node: GraphNode, + edges: GraphEdge[], + currentUserId: string | undefined, + sphereRadius: number +): number { + // Current user is always at center + if (node.id === currentUserId || node.isCurrentUser) { + return sphereRadius * 0.15; + } + + // Check if this node has trusted relationship with current user + const hasTrustedConnection = edges.some( + (e) => + ((e.source === currentUserId && e.target === node.id) || + (e.target === currentUserId && e.source === node.id)) && + (e.trustLevel === 'trusted' || e.effectiveTrustLevel === 'trusted') + ); + + if (hasTrustedConnection) { + return sphereRadius * FORCE_CONFIG.trustedShell; + } + + // Check if connected + const hasConnection = edges.some( + (e) => + (e.source === currentUserId && e.target === node.id) || + (e.target === currentUserId && e.source === node.id) + ); + + if (hasConnection) { + return sphereRadius * FORCE_CONFIG.connectedShell; + } + + // Unconnected - outer shell + return sphereRadius * FORCE_CONFIG.outerShell; +} + +// ============================================================================= +// Animated Edge Flow Component +// ============================================================================= + +interface AnimatedEdgeProps { + sourcePosition: THREE.Vector3; + targetPosition: THREE.Vector3; + edge: GraphEdge; + isDarkMode: boolean; +} + +function AnimatedEdge({ sourcePosition, targetPosition, edge, isDarkMode }: AnimatedEdgeProps) { + const groupRef = useRef(null); + const lineRef = useRef(null); + const particlesRef = useRef(null); + const flowOffset = useRef(0); + + const color = getEdgeColor(edge, isDarkMode); + const isTrusted = edge.trustLevel === 'trusted' || edge.effectiveTrustLevel === 'trusted'; + const particleCount = isTrusted ? 5 : 3; + + // Create and setup line and particles on mount + useEffect(() => { + if (!groupRef.current) return; + + // Create line + const lineGeometry = new THREE.BufferGeometry(); + const positions = new Float32Array([ + sourcePosition.x, sourcePosition.y, sourcePosition.z, + targetPosition.x, targetPosition.y, targetPosition.z, + ]); + lineGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + + const lineMaterial = new THREE.LineBasicMaterial({ + color: color, + transparent: true, + opacity: edge.isMutual ? 0.6 : 0.3, + }); + + const line = new THREE.Line(lineGeometry, lineMaterial); + lineRef.current = line; + groupRef.current.add(line); + + // Create particles + const particleGeometry = new THREE.BufferGeometry(); + const particlePositions = new Float32Array(particleCount * 3); + particleGeometry.setAttribute('position', new THREE.BufferAttribute(particlePositions, 3)); + + const particleMaterial = new THREE.PointsMaterial({ + color: isTrusted ? '#22c55e' : '#eab308', + size: isTrusted ? 0.06 : 0.04, + transparent: true, + opacity: 0.9, + sizeAttenuation: true, + }); + + const particles = new THREE.Points(particleGeometry, particleMaterial); + particlesRef.current = particles; + groupRef.current.add(particles); + + return () => { + if (groupRef.current) { + if (lineRef.current) { + groupRef.current.remove(lineRef.current); + lineRef.current.geometry.dispose(); + (lineRef.current.material as THREE.Material).dispose(); + } + if (particlesRef.current) { + groupRef.current.remove(particlesRef.current); + particlesRef.current.geometry.dispose(); + (particlesRef.current.material as THREE.Material).dispose(); + } + } + }; + }, [color, edge.isMutual, isTrusted, particleCount]); + + // Update line positions when nodes move + useFrame(() => { + if (!lineRef.current) return; + const positions = lineRef.current.geometry.attributes.position.array as Float32Array; + positions[0] = sourcePosition.x; + positions[1] = sourcePosition.y; + positions[2] = sourcePosition.z; + positions[3] = targetPosition.x; + positions[4] = targetPosition.y; + positions[5] = targetPosition.z; + lineRef.current.geometry.attributes.position.needsUpdate = true; + }); + + // Animate particles flowing along the edge + useFrame(() => { + if (!particlesRef.current) return; + + flowOffset.current = (flowOffset.current + 0.008) % 1; + + const positions = particlesRef.current.geometry.attributes.position.array as Float32Array; + + for (let i = 0; i < particleCount; i++) { + const t = ((flowOffset.current + i / particleCount) % 1); + positions[i * 3] = sourcePosition.x + (targetPosition.x - sourcePosition.x) * t; + positions[i * 3 + 1] = sourcePosition.y + (targetPosition.y - sourcePosition.y) * t; + positions[i * 3 + 2] = sourcePosition.z + (targetPosition.z - sourcePosition.z) * t; + } + + particlesRef.current.geometry.attributes.position.needsUpdate = true; + }); + + return ; +} + +// ============================================================================= +// 3D Node Component with Power-Based Sizing +// ============================================================================= + +interface NodeSphereProps { + node: Node3D; + isSelected: boolean; + isCurrentUser: boolean; + isDarkMode: boolean; + onClick: (e: ThreeEvent) => void; + onPointerOver: () => void; + onPointerOut: () => void; +} + +function NodeSphere({ + node, + isSelected, + isCurrentUser, + isDarkMode, + onClick, + onPointerOver, + onPointerOut, +}: NodeSphereProps) { + const meshRef = useRef(null); + const glowRef = useRef(null); + const ringRef = useRef(null); + + const color = getNodeColor(node, isDarkMode); + + // Size based on decision power (logarithmic scale to prevent huge nodes) + const powerScale = Math.log2(node.decisionPower + 1) / 3; + const baseSize = 0.08 + powerScale * 0.08; + const size = isCurrentUser ? baseSize * 1.4 : baseSize; + const displaySize = isSelected ? size * 1.2 : size; + + // Animate glow for in-room users and selection ring + useFrame((state) => { + if (glowRef.current && node.isInRoom) { + const scale = 1.4 + Math.sin(state.clock.elapsedTime * 2) * 0.15; + glowRef.current.scale.setScalar(scale); + } + if (ringRef.current && isSelected) { + ringRef.current.rotation.z = state.clock.elapsedTime * 0.5; + } + }); + + // Power indicator color (more power = more vibrant) + const powerIndicatorOpacity = Math.min(0.4, 0.1 + node.decisionPower * 0.03); + + return ( + + {/* Power aura (larger for more influential nodes) */} + {node.decisionPower > 2 && ( + + + + + )} + + {/* Glow ring for in-room users */} + {node.isInRoom && ( + + + + + )} + + {/* Main node sphere */} + + + + + + {/* Selection ring (animated) */} + {isSelected && ( + + + + + )} + + {/* Username label */} + + + {node.displayName || node.username} + + {/* Power indicator */} + {node.decisionPower > 1.5 && ( + + ◆ {node.decisionPower.toFixed(1)} + + )} + + + ); +} + +// ============================================================================= +// Shell Indicator Rings +// ============================================================================= + +interface ShellRingsProps { + sphereRadius: number; + isDarkMode: boolean; +} + +function ShellRings({ sphereRadius, isDarkMode }: ShellRingsProps) { + return ( + + {/* Trusted shell ring */} + + + + + + {/* Connected shell ring */} + + + + + + {/* Outer boundary sphere */} + + + + + + ); +} + +// ============================================================================= +// Camera Controller for Zoom-to-User +// ============================================================================= + +interface CameraControllerProps { + targetPosition: THREE.Vector3 | null; + sphereRadius: number; + controlsRef: React.RefObject; +} + +function CameraController({ targetPosition, sphereRadius, controlsRef }: CameraControllerProps) { + const { camera } = useThree(); + const isAnimating = useRef(false); + const animationProgress = useRef(0); + const startPosition = useRef(new THREE.Vector3()); + const startTarget = useRef(new THREE.Vector3()); + + useEffect(() => { + if (targetPosition) { + isAnimating.current = true; + animationProgress.current = 0; + startPosition.current.copy(camera.position); + if (controlsRef.current) { + startTarget.current.copy(controlsRef.current.target); + } + } + }, [targetPosition, camera, controlsRef]); + + useFrame(() => { + if (!isAnimating.current || !targetPosition || !controlsRef.current) return; + + animationProgress.current += 0.02; + const t = Math.min(1, animationProgress.current); + const easeT = 1 - Math.pow(1 - t, 3); // Ease out cubic + + // Calculate target camera position (offset from node) + const targetCameraPos = targetPosition.clone().add( + new THREE.Vector3(0, 0.5, sphereRadius * 0.8) + ); + + // Lerp camera position + camera.position.lerpVectors(startPosition.current, targetCameraPos, easeT); + + // Lerp controls target + controlsRef.current.target.lerpVectors(startTarget.current, targetPosition, easeT); + controlsRef.current.update(); + + if (t >= 1) { + isAnimating.current = false; + } + }); + + return null; +} + +// ============================================================================= +// Force Simulation Hook with Trust Clustering +// ============================================================================= + +function useForceSimulation( + nodes: GraphNode[], + edges: GraphEdge[], + sphereRadius: number, + currentUserId?: string +): Node3D[] { + const nodes3DRef = useRef([]); + const isSettledRef = useRef(false); + const frameCountRef = useRef(0); + + // Initialize or update nodes + const nodes3D = useMemo(() => { + const existingMap = new Map(nodes3DRef.current.map(n => [n.id, n])); + + const newNodes: Node3D[] = nodes.map((node) => { + const existing = existingMap.get(node.id); + const targetRadius = getNodeShellRadius(node, edges, currentUserId, sphereRadius); + const decisionPower = calculateDecisionPower(node.id, edges, currentUserId); + + if (existing) { + return { + ...existing, + ...node, + targetRadius, + decisionPower, + }; + } + + // New node - place randomly at its target shell + const theta = Math.random() * Math.PI * 2; + const phi = Math.acos(2 * Math.random() - 1); + const r = targetRadius * (0.9 + Math.random() * 0.2); + + return { + ...node, + position: new THREE.Vector3( + r * Math.sin(phi) * Math.cos(theta), + r * Math.sin(phi) * Math.sin(theta), + r * Math.cos(phi) + ), + velocity: new THREE.Vector3(0, 0, 0), + targetRadius, + decisionPower, + }; + }); + + nodes3DRef.current = newNodes; + isSettledRef.current = false; + frameCountRef.current = 0; + return newNodes; + }, [nodes, edges, sphereRadius, currentUserId]); + + // Apply forces each frame + useFrame(() => { + frameCountRef.current++; + + // Allow occasional updates even after settling (for smooth animation) + if (isSettledRef.current && frameCountRef.current % 60 !== 0) return; + + const nodeMap = new Map(nodes3D.map(n => [n.id, n])); + let maxVelocity = 0; + + for (const node of nodes3D) { + const force = new THREE.Vector3(0, 0, 0); + + // 1. Repulsion from other nodes + for (const other of nodes3D) { + if (node.id === other.id) continue; + + const diff = node.position.clone().sub(other.position); + const dist = diff.length(); + + if (dist < FORCE_CONFIG.repulsionDistance && dist > 0.01) { + // Stronger repulsion for nodes in same shell + const sameShell = Math.abs(node.targetRadius - other.targetRadius) < 0.3; + const repulsionMult = sameShell ? 1.5 : 1; + const repulsionForce = diff + .normalize() + .multiplyScalar((FORCE_CONFIG.repulsion * repulsionMult) / (dist * dist)); + force.add(repulsionForce); + } + } + + // 2. Attraction along edges + for (const edge of edges) { + let linkedNode: Node3D | undefined; + + if (edge.source === node.id) { + linkedNode = nodeMap.get(edge.target); + } else if (edge.target === node.id) { + linkedNode = nodeMap.get(edge.source); + } + + if (linkedNode) { + const diff = linkedNode.position.clone().sub(node.position); + const dist = diff.length(); + const isTrusted = edge.trustLevel === 'trusted'; + const linkDist = isTrusted ? FORCE_CONFIG.linkDistance * 0.8 : FORCE_CONFIG.linkDistance; + + if (dist > linkDist) { + const strength = isTrusted ? FORCE_CONFIG.linkStrength * 1.5 : FORCE_CONFIG.linkStrength; + const attractionForce = diff + .normalize() + .multiplyScalar((dist - linkDist) * strength); + force.add(attractionForce); + } + } + } + + // 3. Shell-based radial force (pull toward target shell radius) + const currentRadius = node.position.length(); + const radiusDiff = node.targetRadius - currentRadius; + if (Math.abs(radiusDiff) > 0.1) { + const shellForce = node.position + .clone() + .normalize() + .multiplyScalar(radiusDiff * FORCE_CONFIG.shellStrength); + force.add(shellForce); + } + + // 4. Sphere boundary constraint + if (currentRadius > sphereRadius * 0.95) { + const boundaryForce = node.position + .clone() + .normalize() + .multiplyScalar(-(currentRadius - sphereRadius * 0.9) * FORCE_CONFIG.boundaryStrength); + force.add(boundaryForce); + } + + // Apply force to velocity + node.velocity.add(force); + node.velocity.multiplyScalar(FORCE_CONFIG.damping); + + const vel = node.velocity.length(); + if (vel > maxVelocity) maxVelocity = vel; + } + + // Apply velocity to positions + for (const node of nodes3D) { + node.position.add(node.velocity); + + // Hard boundary + const distFromCenter = node.position.length(); + if (distFromCenter > sphereRadius) { + node.position.normalize().multiplyScalar(sphereRadius * 0.98); + node.velocity.multiplyScalar(0.3); + } + } + + // Check if settled + if (maxVelocity < FORCE_CONFIG.minVelocity) { + isSettledRef.current = true; + } + }); + + return nodes3D; +} + +// ============================================================================= +// Scene Content Component +// ============================================================================= + +interface SceneContentProps { + nodes: GraphNode[]; + edges: GraphEdge[]; + currentUserId?: string; + isDarkMode: boolean; + sphereRadius: number; + selectedNodeId: string | null; + onNodeClick: (node: GraphNode, screenPos: { x: number; y: number }) => void; + hoveredNodeId: string | null; + setHoveredNodeId: (id: string | null) => void; + zoomTargetPosition: THREE.Vector3 | null; + controlsRef: React.RefObject; +} + +function SceneContent({ + nodes, + edges, + currentUserId, + isDarkMode, + sphereRadius, + selectedNodeId, + onNodeClick, + hoveredNodeId: _hoveredNodeId, + setHoveredNodeId, + zoomTargetPosition, + controlsRef, +}: SceneContentProps) { + const { camera, gl } = useThree(); + + // Use force simulation with clustering + const nodes3D = useForceSimulation(nodes, edges, sphereRadius, currentUserId); + + // Create node map for edge rendering + const nodeMap = useMemo( + () => new Map(nodes3D.map(n => [n.id, n])), + [nodes3D] + ); + + // Handle node click with screen position + const handleNodeClick = useCallback( + (node: Node3D, e: ThreeEvent) => { + e.stopPropagation(); + + const vector = node.position.clone().project(camera); + const rect = gl.domElement.getBoundingClientRect(); + const screenX = ((vector.x + 1) / 2) * rect.width; + const screenY = ((-vector.y + 1) / 2) * rect.height; + + onNodeClick(node, { x: screenX, y: screenY }); + }, + [camera, gl, onNodeClick] + ); + + return ( + <> + {/* Lighting */} + + + + + + {/* Shell indicator rings */} + + + {/* Animated edges */} + {edges.map((edge) => { + const sourceNode = nodeMap.get(edge.source); + const targetNode = nodeMap.get(edge.target); + if (!sourceNode || !targetNode) return null; + + return ( + + ); + })} + + {/* Nodes */} + {nodes3D.map((node) => ( + handleNodeClick(node, e)} + onPointerOver={() => setHoveredNodeId(node.id)} + onPointerOut={() => setHoveredNodeId(null)} + /> + ))} + + {/* Camera controller for zoom animation */} + + + ); +} + +// ============================================================================= +// Main Component +// ============================================================================= + +export function NetworkGraph3D({ + nodes, + edges, + currentUserId, + onNodeClick, + onNodeSelect, + onConnect, + onZoomToUser, + onViewAsUser, + isDarkMode = false, + sphereRadius = 3, +}: NetworkGraph3DProps) { + const containerRef = useRef(null); + const controlsRef = useRef(null); + const [selectedNode, setSelectedNode] = useState(null); + const [hoveredNodeId, setHoveredNodeId] = useState(null); + const [isConnecting, setIsConnecting] = useState(false); + const [zoomTargetPosition, setZoomTargetPosition] = useState(null); + + // Calculate power stats + const powerStats = useMemo(() => { + const powers = nodes.map(n => calculateDecisionPower(n.id, edges, currentUserId)); + const maxPower = Math.max(...powers, 1); + const avgPower = powers.reduce((a, b) => a + b, 0) / powers.length || 0; + return { maxPower: maxPower.toFixed(1), avgPower: avgPower.toFixed(1) }; + }, [nodes, edges, currentUserId]); + + // Handle node click + const handleNodeClick = useCallback( + (node: GraphNode, screenPos: { x: number; y: number }) => { + if (node.isCurrentUser || node.id === currentUserId) { + setSelectedNode(null); + onNodeSelect?.(null); + return; + } + + setSelectedNode({ node, screenPosition: screenPos }); + onNodeSelect?.(node); + onNodeClick?.(node); + }, + [onNodeClick, onNodeSelect, currentUserId] + ); + + // Handle background click + const handleBackgroundClick = useCallback(() => { + setSelectedNode(null); + onNodeSelect?.(null); + setZoomTargetPosition(null); + }, [onNodeSelect]); + + // Handle connect + const handleConnect = useCallback(async () => { + if (!selectedNode || !onConnect) return; + + setIsConnecting(true); + try { + const userId = selectedNode.node.username || selectedNode.node.id; + await onConnect(userId, 'connected'); + } catch (err) { + console.error('Failed to connect:', err); + } + setIsConnecting(false); + setSelectedNode(null); + }, [selectedNode, onConnect]); + + // Handle zoom to user + const handleZoomToUser = useCallback(() => { + if (!selectedNode) return; + + // Find the node's 3D position + const power = calculateDecisionPower(selectedNode.node.id, edges, currentUserId); + const targetRadius = getNodeShellRadius(selectedNode.node, edges, currentUserId, sphereRadius); + + // Approximate position (actual position is managed by simulation) + const theta = Math.random() * Math.PI * 2; + const phi = Math.PI / 2; + const position = new THREE.Vector3( + targetRadius * Math.sin(phi) * Math.cos(theta), + targetRadius * Math.sin(phi) * Math.sin(theta), + targetRadius * Math.cos(phi) + ); + + setZoomTargetPosition(position); + onZoomToUser?.(selectedNode.node); + }, [selectedNode, edges, currentUserId, sphereRadius, onZoomToUser]); + + // Handle view as user (broadcast mode) + const handleViewAsUser = useCallback(() => { + if (!selectedNode) return; + onViewAsUser?.(selectedNode.node); + setSelectedNode(null); + }, [selectedNode, onViewAsUser]); + + // Keyboard handler + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setSelectedNode(null); + onNodeSelect?.(null); + setZoomTargetPosition(null); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onNodeSelect]); + + return ( +
+ + + + + + + {/* Legend */} +
+
Trust Shells
+
+
+ Trusted (inner) +
+
+
+ Connected (middle) +
+
+
+ Unconnected (outer) +
+
+
◆ = Decision Power
+
Max: {powerStats.maxPower} | Avg: {powerStats.avgPower}
+
+
+ + {/* Hover tooltip */} + {hoveredNodeId && !selectedNode && ( +
+ {nodes.find(n => n.id === hoveredNodeId)?.displayName || + nodes.find(n => n.id === hoveredNodeId)?.username} +
+ )} + + {/* Selected node panel */} + {selectedNode && ( +
+ {/* User info header */} +
+
+
+
+ {selectedNode.node.displayName || selectedNode.node.username} +
+
+ {selectedNode.node.isInRoom && ● In room} + ◆ {calculateDecisionPower(selectedNode.node.id, edges, currentUserId).toFixed(1)} power +
+
+
+ + {/* Action buttons */} +
+ {/* Connect */} + {!selectedNode.node.isAnonymous && onConnect && ( + + )} + + {/* Zoom to user */} + + + {/* View as user (broadcast mode) */} + {selectedNode.node.isInRoom && onViewAsUser && ( + + )} + + {/* Cancel */} + +
+
+ )} + + {/* Instructions */} +
+ Drag to rotate | Scroll to zoom | Click node to interact +
+
+ ); +} + +export default NetworkGraph3D; diff --git a/src/components/networking/NetworkGraphMinimap.tsx b/src/components/networking/NetworkGraphMinimap.tsx index 88cff88..8404874 100644 --- a/src/components/networking/NetworkGraphMinimap.tsx +++ b/src/components/networking/NetworkGraphMinimap.tsx @@ -1,29 +1,37 @@ /** * NetworkGraphMinimap Component * - * A 2D force-directed graph visualization in the bottom-right corner. + * A force-directed social graph visualization positioned above the minimap. * Shows: - * - User's full network in grey + * - User's full network with trust-level coloring * - Room participants in their presence colors * - Connections as edges between nodes * - Mutual connections as thicker lines * * Features: + * - Three display modes: minimized (icon), normal (small window), maximized (modal) * - Click node to view profile / connect * - Click edge to edit metadata * - Hover for tooltips - * - Expand button to open full 3D view + * - Stable simulation that doesn't constantly reinitialize + * + * Positioned in bottom-left, above the tldraw minimap. */ -import React, { useEffect, useRef, useState, useCallback } from 'react'; +import React, { useEffect, useRef, useState, useCallback, useMemo, Suspense, lazy } from 'react'; import * as d3 from 'd3'; import { type GraphNode, type GraphEdge, type TrustLevel } from '../../lib/networking'; import { UserSearchModal } from './UserSearchModal'; +// Lazy load the 3D component to avoid loading Three.js unless needed +const NetworkGraph3D = lazy(() => import('./NetworkGraph3D')); + // ============================================================================= // Types // ============================================================================= +type DisplayMode = 'minimized' | 'normal' | 'maximized'; + interface NetworkGraphMinimapProps { nodes: GraphNode[]; edges: GraphEdge[]; @@ -54,54 +62,101 @@ interface SimulationLink extends d3.SimulationLinkDatum { // Styles - Theme-aware functions // ============================================================================= -const getStyles = (isDarkMode: boolean) => ({ +// Match tldraw minimap dimensions +const MINIMAP_WIDTH = 200; +const MINIMAP_HEIGHT = 100; +const MINIMAP_BOTTOM = 40; // tldraw minimap position from bottom +const MINIMAP_LEFT = 8; // tldraw minimap position from left +const STACK_GAP = 8; // gap between network panel and minimap + +const getStyles = (isDarkMode: boolean, displayMode: DisplayMode) => ({ + // Container - positioned bottom LEFT, directly above the tldraw minimap container: { position: 'fixed' as const, - bottom: '60px', - right: '10px', - zIndex: 1000, + // Stack directly above tldraw minimap: minimap_bottom + minimap_height + gap + bottom: displayMode === 'maximized' ? '0' : `${MINIMAP_BOTTOM + MINIMAP_HEIGHT + STACK_GAP}px`, + left: displayMode === 'maximized' ? '0' : `${MINIMAP_LEFT}px`, + right: displayMode === 'maximized' ? '0' : 'auto', + top: displayMode === 'maximized' ? '0' : 'auto', + zIndex: displayMode === 'maximized' ? 10000 : 1000, display: 'flex', flexDirection: 'column' as const, - alignItems: 'flex-end', + alignItems: displayMode === 'maximized' ? 'center' : 'flex-start', + justifyContent: displayMode === 'maximized' ? 'center' : 'flex-start', gap: '8px', + backgroundColor: displayMode === 'maximized' ? 'rgba(0, 0, 0, 0.5)' : 'transparent', + pointerEvents: displayMode === 'maximized' ? 'auto' as const : 'none' as const, }, + // Main panel panel: { backgroundColor: isDarkMode ? 'rgba(20, 20, 25, 0.95)' : 'rgba(255, 255, 255, 0.98)', - borderRadius: '12px', - boxShadow: isDarkMode ? '0 4px 20px rgba(0, 0, 0, 0.4)' : '0 4px 20px rgba(0, 0, 0, 0.15)', + borderRadius: displayMode === 'maximized' ? '16px' : '12px', + boxShadow: isDarkMode + ? '0 4px 20px rgba(0, 0, 0, 0.4)' + : displayMode === 'maximized' + ? '0 8px 40px rgba(0, 0, 0, 0.3)' + : '0 4px 20px rgba(0, 0, 0, 0.15)', overflow: 'hidden', - transition: 'all 0.2s ease', + transition: 'all 0.3s ease', border: isDarkMode ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)', + pointerEvents: 'auto' as const, + // Sphere-like gradient background for the graph area + background: isDarkMode + ? 'radial-gradient(ellipse at center, rgba(40, 40, 50, 0.95) 0%, rgba(20, 20, 25, 0.98) 100%)' + : 'radial-gradient(ellipse at center, rgba(255, 255, 255, 0.98) 0%, rgba(245, 245, 250, 0.98) 100%)', }, - panelCollapsed: { - width: '48px', - height: '48px', + // Minimized state - small icon + panelMinimized: { + width: '40px', + height: '40px', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', + borderRadius: '50%', + background: isDarkMode + ? 'radial-gradient(circle, rgba(60, 60, 80, 0.9) 0%, rgba(30, 30, 40, 0.95) 100%)' + : 'radial-gradient(circle, rgba(255, 255, 255, 0.95) 0%, rgba(240, 240, 245, 0.98) 100%)', + boxShadow: isDarkMode + ? '0 2px 12px rgba(100, 100, 255, 0.2), inset 0 0 20px rgba(100, 100, 255, 0.1)' + : '0 2px 12px rgba(0, 0, 0, 0.15), inset 0 0 20px rgba(100, 100, 255, 0.05)', + border: isDarkMode ? '1px solid rgba(100, 100, 255, 0.3)' : '1px solid rgba(100, 100, 255, 0.2)', + }, + // Normal state dimensions - match tldraw minimap width + panelNormal: { + width: `${MINIMAP_WIDTH}px`, + maxHeight: '200px', + }, + // Maximized state dimensions + panelMaximized: { + width: '90vw', + maxWidth: '800px', + maxHeight: '80vh', }, header: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', - padding: '8px 12px', + padding: displayMode === 'maximized' ? '12px 16px' : '8px 12px', borderBottom: isDarkMode ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)', backgroundColor: isDarkMode ? 'rgba(255, 255, 255, 0.03)' : 'rgba(0, 0, 0, 0.02)', }, title: { - fontSize: '12px', + fontSize: displayMode === 'maximized' ? '14px' : '12px', fontWeight: 600, color: isDarkMode ? '#e0e0e0' : '#374151', margin: 0, + display: 'flex', + alignItems: 'center', + gap: '8px', }, headerButtons: { display: 'flex', gap: '4px', }, iconButton: { - width: '28px', - height: '28px', + width: displayMode === 'maximized' ? '32px' : '28px', + height: displayMode === 'maximized' ? '32px' : '28px', border: 'none', background: 'none', borderRadius: '6px', @@ -109,31 +164,54 @@ const getStyles = (isDarkMode: boolean) => ({ display: 'flex', alignItems: 'center', justifyContent: 'center', - fontSize: '14px', + fontSize: displayMode === 'maximized' ? '16px' : '14px', color: isDarkMode ? '#a0a0a0' : '#6b7280', transition: 'background-color 0.15s, color 0.15s', }, canvas: { display: 'block', - backgroundColor: isDarkMode ? 'transparent' : 'rgba(249, 250, 251, 0.5)', + // Sphere-like inner gradient + background: isDarkMode + ? 'radial-gradient(ellipse at center, rgba(50, 50, 70, 0.3) 0%, transparent 70%)' + : 'radial-gradient(ellipse at center, rgba(200, 200, 255, 0.15) 0%, transparent 70%)', }, tooltip: { position: 'absolute' as const, - backgroundColor: isDarkMode ? 'rgba(0, 0, 0, 0.8)' : 'rgba(255, 255, 255, 0.95)', + backgroundColor: isDarkMode ? 'rgba(0, 0, 0, 0.9)' : 'rgba(255, 255, 255, 0.98)', color: isDarkMode ? '#fff' : '#1f2937', - padding: '6px 10px', - borderRadius: '6px', + padding: '8px 12px', + borderRadius: '8px', fontSize: '12px', pointerEvents: 'none' as const, whiteSpace: 'nowrap' as const, zIndex: 1001, transform: 'translate(-50%, -100%)', - marginTop: '-8px', - boxShadow: isDarkMode ? 'none' : '0 2px 8px rgba(0, 0, 0, 0.15)', - border: isDarkMode ? 'none' : '1px solid rgba(0, 0, 0, 0.1)', + marginTop: '-10px', + boxShadow: isDarkMode ? '0 4px 12px rgba(0, 0, 0, 0.3)' : '0 4px 12px rgba(0, 0, 0, 0.15)', + border: isDarkMode ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)', }, - collapsedIcon: { - fontSize: '20px', + minimizedIcon: { + fontSize: '18px', + filter: 'drop-shadow(0 0 4px rgba(100, 100, 255, 0.4))', + }, + // Network stats in maximized view + statsBar: { + display: 'flex', + gap: '16px', + padding: '8px 16px', + borderBottom: isDarkMode ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)', + backgroundColor: isDarkMode ? 'rgba(255, 255, 255, 0.02)' : 'rgba(0, 0, 0, 0.01)', + fontSize: '11px', + color: isDarkMode ? '#888' : '#666', + }, + statItem: { + display: 'flex', + alignItems: 'center', + gap: '4px', + }, + statValue: { + fontWeight: 600, + color: isDarkMode ? '#a0a0ff' : '#4f46e5', }, }); @@ -154,56 +232,181 @@ export function NetworkGraphMinimap({ onOpenProfile, onEdgeClick, onExpandClick, - width = 240, - height = 180, + width: propWidth = MINIMAP_WIDTH - 16, // Account for padding + height: propHeight = 120, // Compact height to match minimap proportions isCollapsed = false, onToggleCollapse, isDarkMode = false, }: NetworkGraphMinimapProps) { const svgRef = useRef(null); + const simulationRef = useRef | null>(null); + const simNodesRef = useRef([]); + const simLinksRef = useRef([]); + const isInitializedRef = useRef(false); + const [tooltip, setTooltip] = useState<{ x: number; y: number; text: string } | null>(null); const [isSearchOpen, setIsSearchOpen] = useState(false); const [selectedNode, setSelectedNode] = useState<{ node: GraphNode; x: number; y: number } | null>(null); const [isConnecting, setIsConnecting] = useState(false); - const simulationRef = useRef | null>(null); + + // Three-state display mode: minimized, normal, maximized + const [displayMode, setDisplayMode] = useState(isCollapsed ? 'minimized' : 'normal'); + + // Sync with legacy isCollapsed prop + useEffect(() => { + if (isCollapsed && displayMode !== 'minimized') { + setDisplayMode('minimized'); + } + }, [isCollapsed]); + + // Calculate dimensions based on display mode + const { width, height } = useMemo(() => { + switch (displayMode) { + case 'minimized': + return { width: 0, height: 0 }; + case 'normal': + return { width: propWidth, height: propHeight }; + case 'maximized': + return { + width: Math.min(700, window.innerWidth * 0.85), + height: Math.min(500, window.innerHeight * 0.6) + }; + default: + return { width: propWidth, height: propHeight }; + } + }, [displayMode, propWidth, propHeight]); // Get theme-aware styles - const styles = React.useMemo(() => getStyles(isDarkMode), [isDarkMode]); + const styles = useMemo(() => getStyles(isDarkMode, displayMode), [isDarkMode, displayMode]); - // Initialize and update the D3 simulation + // Network stats for maximized view + const networkStats = useMemo(() => { + const inRoomCount = nodes.filter(n => n.isInRoom).length; + const connectionCount = edges.length; + const mutualCount = edges.filter(e => e.isMutual).length; + const trustedCount = edges.filter(e => e.trustLevel === 'trusted').length; + return { inRoomCount, connectionCount, mutualCount, trustedCount, totalNodes: nodes.length }; + }, [nodes, edges]); + + // Cleanup simulation on unmount useEffect(() => { - if (!svgRef.current || isCollapsed || nodes.length === 0) return; + return () => { + if (simulationRef.current) { + simulationRef.current.stop(); + simulationRef.current = null; + } + isInitializedRef.current = false; + simNodesRef.current = []; + simLinksRef.current = []; + }; + }, []); + + // Initialize and update the D3 simulation - STABLE VERSION + // This effect uses refs to persist simulation state and only updates incrementally + useEffect(() => { + if (!svgRef.current || displayMode === 'minimized' || nodes.length === 0) return; const svg = d3.select(svgRef.current); - svg.selectAll('*').remove(); - // Create simulation nodes and links - const simNodes: SimulationNode[] = nodes.map(n => ({ ...n })); - const nodeMap = new Map(simNodes.map(n => [n.id, n])); + // Check if we need to initialize or just update + const needsInit = !isInitializedRef.current || !simulationRef.current; + const nodeMap = new Map(simNodesRef.current.map(n => [n.id, n])); - const simLinks: SimulationLink[] = edges - .filter(e => nodeMap.has(e.source) && nodeMap.has(e.target)) - .map(e => ({ - source: nodeMap.get(e.source)!, - target: nodeMap.get(e.target)!, - id: e.id, - isMutual: e.isMutual, - })); + if (needsInit) { + // Full initialization - only happens once + svg.selectAll('*').remove(); - // Create the simulation with faster decay for stabilization - const simulation = d3.forceSimulation(simNodes) - .force('link', d3.forceLink(simLinks) - .id(d => d.id) - .distance(40)) - .force('charge', d3.forceManyBody().strength(-80)) - .force('center', d3.forceCenter(width / 2, height / 2)) - .force('collision', d3.forceCollide().radius(12)) - // Speed up stabilization: higher decay = faster settling - .alphaDecay(0.05) - // Lower alpha min threshold for stopping - .alphaMin(0.01); + // Create simulation nodes, preserving existing positions if available + simNodesRef.current = nodes.map(n => { + const existing = nodeMap.get(n.id); + return { + ...n, + x: existing?.x ?? width / 2 + (Math.random() - 0.5) * 100, + y: existing?.y ?? height / 2 + (Math.random() - 0.5) * 100, + vx: existing?.vx ?? 0, + vy: existing?.vy ?? 0, + }; + }); - simulationRef.current = simulation; + const newNodeMap = new Map(simNodesRef.current.map(n => [n.id, n])); + + simLinksRef.current = edges + .filter(e => newNodeMap.has(e.source) && newNodeMap.has(e.target)) + .map(e => ({ + source: newNodeMap.get(e.source)!, + target: newNodeMap.get(e.target)!, + id: e.id, + isMutual: e.isMutual, + })); + + // Create the simulation with smooth, stable parameters + const simulation = d3.forceSimulation(simNodesRef.current) + .force('link', d3.forceLink(simLinksRef.current) + .id(d => d.id) + .distance(displayMode === 'maximized' ? 80 : 50) + .strength(0.5)) + .force('charge', d3.forceManyBody() + .strength(displayMode === 'maximized' ? -150 : -100) + .distanceMax(displayMode === 'maximized' ? 300 : 200)) + .force('center', d3.forceCenter(width / 2, height / 2).strength(0.05)) + .force('collision', d3.forceCollide().radius(d => (d as SimulationNode).isCurrentUser ? 14 : 10)) + // Gentler alpha decay for smoother settling + .alphaDecay(0.02) + // Higher alpha min so it stops sooner + .alphaMin(0.05) + // Add velocity decay for smoother movement + .velocityDecay(0.4); + + simulationRef.current = simulation; + isInitializedRef.current = true; + } else { + // Incremental update - preserve existing node positions + const existingNodeMap = new Map(simNodesRef.current.map(n => [n.id, n])); + + // Update existing nodes and add new ones + const newNodes: SimulationNode[] = nodes.map(n => { + const existing = existingNodeMap.get(n.id); + if (existing) { + // Update properties but keep position + return { ...existing, ...n, x: existing.x, y: existing.y, vx: existing.vx, vy: existing.vy }; + } + // New node - place near center with slight randomness + return { + ...n, + x: width / 2 + (Math.random() - 0.5) * 50, + y: height / 2 + (Math.random() - 0.5) * 50, + }; + }); + + simNodesRef.current = newNodes; + const newNodeMap = new Map(newNodes.map(n => [n.id, n])); + + simLinksRef.current = edges + .filter(e => newNodeMap.has(e.source) && newNodeMap.has(e.target)) + .map(e => ({ + source: newNodeMap.get(e.source)!, + target: newNodeMap.get(e.target)!, + id: e.id, + isMutual: e.isMutual, + })); + + // Update simulation with new nodes/links + simulationRef.current! + .nodes(simNodesRef.current) + .force('link', d3.forceLink(simLinksRef.current) + .id(d => d.id) + .distance(displayMode === 'maximized' ? 80 : 50) + .strength(0.5)) + .force('center', d3.forceCenter(width / 2, height / 2).strength(0.05)); + + // Gentle reheat to settle new nodes + simulationRef.current!.alpha(0.3).restart(); + + // Re-render the graph with updated data + svg.selectAll('*').remove(); + } + + const simulation = simulationRef.current!; // Create container group const g = svg.append('g'); @@ -279,10 +482,10 @@ export function NetworkGraphMinimap({ const link = g.append('g') .attr('class', 'links') .selectAll('line') - .data(simLinks) + .data(simLinksRef.current) .join('line') .attr('stroke', d => getEdgeColor(d)) - .attr('stroke-width', d => d.isMutual ? 2.5 : 1.5) + .attr('stroke-width', d => d.isMutual ? (displayMode === 'maximized' ? 3 : 2.5) : (displayMode === 'maximized' ? 2 : 1.5)) .attr('marker-end', d => getArrowMarker(d)) .style('cursor', 'pointer') .on('click', (event, d) => { @@ -307,22 +510,30 @@ export function NetworkGraphMinimap({ return '#9ca3af'; }; + // Node sizes based on display mode + const nodeRadius = displayMode === 'maximized' ? 10 : 6; + const currentUserRadius = displayMode === 'maximized' ? 14 : 8; + // Create nodes const node = g.append('g') .attr('class', 'nodes') .selectAll('circle') - .data(simNodes) + .data(simNodesRef.current) .join('circle') - .attr('r', d => d.isCurrentUser ? 8 : 6) + .attr('r', d => d.isCurrentUser ? currentUserRadius : nodeRadius) .attr('fill', d => getNodeColor(d)) + .attr('stroke', d => d.isInRoom ? 'rgba(100, 200, 255, 0.8)' : 'transparent') + .attr('stroke-width', d => d.isInRoom ? 2 : 0) .style('cursor', 'pointer') + .style('filter', d => d.isInRoom ? 'drop-shadow(0 0 4px rgba(100, 200, 255, 0.5))' : 'none') .on('mouseenter', (event, d) => { const rect = svgRef.current!.getBoundingClientRect(); const name = d.displayName || d.username; + const status = d.isInRoom ? ' (in room)' : ''; setTooltip({ x: event.clientX - rect.left, y: event.clientY - rect.top, - text: d.isCurrentUser ? `${name} (you)` : name, + text: d.isCurrentUser ? `${name} (you)` : `${name}${status}`, }); }) .on('mouseleave', () => { @@ -367,84 +578,234 @@ export function NetworkGraphMinimap({ .attr('x2', d => (d.target as SimulationNode).x!) .attr('y2', d => (d.target as SimulationNode).y!); + const margin = displayMode === 'maximized' ? 12 : 8; node - .attr('cx', d => Math.max(8, Math.min(width - 8, d.x!))) - .attr('cy', d => Math.max(8, Math.min(height - 8, d.y!))); + .attr('cx', d => Math.max(margin, Math.min(width - margin, d.x!))) + .attr('cy', d => Math.max(margin, Math.min(height - margin, d.y!))); }); // Stop simulation when it stabilizes (alpha reaches alphaMin) simulation.on('end', () => { // Simulation has stabilized, nodes will stay in place unless dragged - simulation.stop(); + // Don't call stop() - let it stay ready for interactions }); + // Cleanup function - only stop if component unmounts return () => { - simulation.stop(); + // Don't reset simulation on every re-render + // Only cleanup on actual unmount }; - }, [nodes, edges, width, height, isCollapsed, onNodeClick, onEdgeClick]); + }, [nodes, edges, width, height, displayMode, onNodeClick, onEdgeClick]); - // Handle collapsed state click - const handleCollapsedClick = useCallback(() => { - if (onToggleCollapse) { - onToggleCollapse(); - } + // Handle display mode changes + const handleMinimize = useCallback(() => { + setDisplayMode('minimized'); + onToggleCollapse?.(); }, [onToggleCollapse]); - if (isCollapsed) { + const handleNormal = useCallback(() => { + setDisplayMode('normal'); + }, []); + + const handleMaximize = useCallback(() => { + setDisplayMode('maximized'); + onExpandClick?.(); + }, [onExpandClick]); + + const handleMinimizedClick = useCallback(() => { + setDisplayMode('normal'); + onToggleCollapse?.(); + }, [onToggleCollapse]); + + // Handle ESC to close maximized view + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && displayMode === 'maximized') { + setDisplayMode('normal'); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [displayMode]); + + // Minimized state - small circular icon + if (displayMode === 'minimized') { return (
- 🕸️ + 🕸️
); } + // Get panel size styles based on display mode + const panelSizeStyle = displayMode === 'maximized' ? styles.panelMaximized : styles.panelNormal; + return ( -
-
+
+
e.stopPropagation()} + > + {/* Header */}
-

Social Network

+

+ 🕸️ + Social Network + {displayMode === 'maximized' && ( + + {networkStats.totalNodes} people + + )} +

+ {/* Search button */} - {onExpandClick && ( + + {/* Maximize/Restore button */} + {displayMode === 'normal' && ( )} - {onToggleCollapse && ( + {displayMode === 'maximized' && ( + )} + + {/* Minimize button */} + + + {/* Close button (maximized only) */} + {displayMode === 'maximized' && ( + )}
-
setSelectedNode(null)}> - + {/* Stats bar (maximized only) */} + {displayMode === 'maximized' && ( +
+
+ 👥 + In room: + {networkStats.inRoomCount} +
+
+ 🔗 + Connections: + {networkStats.connectionCount} +
+
+ 🤝 + Mutual: + {networkStats.mutualCount} +
+
+ + Trusted: + {networkStats.trustedCount} +
+
+ )} + + {/* 3D View for maximized mode */} + {displayMode === 'maximized' ? ( + + Loading 3D view... +
+ } + > +
+ +
+ + ) : ( + /* 2D SVG View for normal mode */ +
setSelectedNode(null)}> + {tooltip && (
)} -
+
+ )}
void; + isDarkMode: boolean; +} + +function BroadcastIndicator({ followingUser, onStop, isDarkMode }: BroadcastIndicatorProps) { + if (!followingUser) return null; + + return ( +
+ + + {/* Live indicator */} +
+ + + {/* User avatar */} +
+ + {/* Text */} +
+ Viewing as{' '} + {followingUser.username} +
+ + {/* Exit hint */} +
+ + ESC + + to exit +
+ + {/* Close button */} + +
+ ); +} + // ============================================================================= // Types // ============================================================================= @@ -30,13 +163,20 @@ export function NetworkGraphPanel({ onExpand }: NetworkGraphPanelProps) { const [isCollapsed, setIsCollapsed] = useState(false); const [selectedEdge, setSelectedEdge] = useState(null); + // Broadcast mode state - tracks who we're following + const [followingUser, setFollowingUser] = useState<{ + id: string; + username: string; + color?: string; + } | null>(null); + // Detect dark mode const [isDarkMode, setIsDarkMode] = useState( typeof document !== 'undefined' && document.documentElement.classList.contains('dark') ); // Listen for theme changes - React.useEffect(() => { + useEffect(() => { const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.attributeName === 'class') { @@ -48,6 +188,53 @@ export function NetworkGraphPanel({ onExpand }: NetworkGraphPanelProps) { return () => observer.disconnect(); }, []); + // Stop following user - cleanup function + const stopFollowingUser = useCallback(() => { + if (!editor) return; + + editor.stopFollowingUser(); + setFollowingUser(null); + + // Remove followId from URL if present + const url = new URL(window.location.href); + if (url.searchParams.has('followId')) { + url.searchParams.delete('followId'); + window.history.replaceState(null, '', url.toString()); + } + + console.log('Stopped following user'); + }, [editor]); + + // Keyboard handler for ESC and X to exit broadcast mode + useEffect(() => { + if (!followingUser) return; + + const handleKeyDown = (e: KeyboardEvent) => { + // ESC or X (lowercase or uppercase) stops following + if (e.key === 'Escape' || e.key === 'x' || e.key === 'X') { + e.preventDefault(); + e.stopPropagation(); + stopFollowingUser(); + } + }; + + // Use capture phase to intercept before tldraw + window.addEventListener('keydown', handleKeyDown, { capture: true }); + + return () => { + window.removeEventListener('keydown', handleKeyDown, { capture: true }); + }; + }, [followingUser, stopFollowingUser]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (followingUser && editor) { + editor.stopFollowingUser(); + } + }; + }, [followingUser, editor]); + // Get collaborators from tldraw const collaborators = useValue( 'collaborators', @@ -150,7 +337,20 @@ export function NetworkGraphPanel({ onExpand }: NetworkGraphPanelProps) { // Use tldraw's built-in follow functionality - needs userId const userId = targetCollaborator.userId || targetCollaborator.id; editor.startFollowingUser(userId); - console.log('Now following user:', node.username); + + // Set state to show broadcast indicator and enable keyboard exit + setFollowingUser({ + id: userId, + username: node.username || node.displayName || 'User', + color: targetCollaborator.color || node.avatarColor || node.roomPresenceColor, + }); + + // Optionally add followId to URL for deep linking + const url = new URL(window.location.href); + url.searchParams.set('followId', userId); + window.history.replaceState(null, '', url.toString()); + + console.log('Now following user:', node.username, '- Press ESC or X to exit'); } else { console.log('Could not find user to follow:', node.username); } @@ -200,23 +400,33 @@ export function NetworkGraphPanel({ onExpand }: NetworkGraphPanelProps) { } return ( - setIsCollapsed(!isCollapsed)} - isDarkMode={isDarkMode} - /> + <> + {/* Broadcast mode indicator - shows when following a user */} + + + {/* Network graph minimap */} + setIsCollapsed(!isCollapsed)} + isDarkMode={isDarkMode} + /> + ); } diff --git a/src/components/networking/index.ts b/src/components/networking/index.ts index b2946ee..537043e 100644 --- a/src/components/networking/index.ts +++ b/src/components/networking/index.ts @@ -5,6 +5,7 @@ */ export { NetworkGraphMinimap } from './NetworkGraphMinimap'; +export { NetworkGraph3D } from './NetworkGraph3D'; export { NetworkGraphPanel } from './NetworkGraphPanel'; export { UserSearchModal } from './UserSearchModal'; export { useNetworkGraph, useRoomParticipantsFromEditor } from './useNetworkGraph';