From 9c6c6a0cb40f8d71913f1c4d64a6c9ca20d0d3ef Mon Sep 17 00:00:00 2001 From: Emilio Balda Date: Thu, 3 Sep 2026 11:42:58 +0300 Subject: [PATCH] Migrate concept graph from Cytoscape to react-force-graph-3d Switches src/Graph.jsx from a 2D Cytoscape view to a 3D force-directed graph so it scales to real-sized bundles: uniform node sizing, SpriteText labels, and a camera that recenters on selection without changing zoom. Co-authored-by: Cursor --- package-lock.json | 477 +++++++++++++++++++++++++++++++++++++++++++++- package.json | 4 +- src/Graph.jsx | 295 ++++++++++++++++------------ 3 files changed, 643 insertions(+), 133 deletions(-) diff --git a/package-lock.json b/package-lock.json index 41df02e..3cb60a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,13 @@ "name": "second-brain-template-viewer", "version": "0.1.0", "dependencies": { - "cytoscape": "^3.34.2", "lucide-react": "0.542.0", "markdown-to-jsx": "^7.7.17", "react": "^19.2.8", "react-dom": "^19.2.8", + "react-force-graph-3d": "^1.29.1", + "three": "^0.185.1", + "three-spritetext": "^1.10.0", "yaml": "^2.9.0" }, "devDependencies": { @@ -24,6 +26,15 @@ "node": ">=20.19.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", @@ -310,6 +321,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@tweenjs/tween.js": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", + "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==", + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -478,6 +495,31 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/3d-force-graph": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/3d-force-graph/-/3d-force-graph-1.80.0.tgz", + "integrity": "sha512-tzI353gW1nXPpnC7VTa3JjMg+3cp77qOLUFO0vucPTfF+q5R6sQsNsIqVTbRIb7RSypn14nBa4yfkOe9ThxASw==", + "license": "MIT", + "dependencies": { + "accessor-fn": "1", + "kapsule": "^1.16", + "three": ">=0.179 <1", + "three-forcegraph": "1", + "three-render-objects": "^1.41" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/accessor-fn": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz", + "integrity": "sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -505,13 +547,175 @@ "dev": true, "license": "MIT" }, - "node_modules/cytoscape": { - "version": "3.34.2", - "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.2.tgz", - "integrity": "sha512-Cm2jaj1X/PBNlzV9yH8zcfGOxO7U+CJ/+mxSBVPSchLaugdp4jtlGx5qaHtPRZ6tgiZ5P+o1XoRfJA+ba6KM3g==", + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-binarytree": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz", + "integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==", + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force-3d": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz", + "integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==", "license": "MIT", + "dependencies": { + "d3-binarytree": "1", + "d3-dispatch": "1 - 3", + "d3-octree": "1", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, "engines": { - "node": ">=0.10" + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-octree": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz", + "integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==", + "license": "MIT" + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-bind-mapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/data-bind-mapper/-/data-bind-mapper-1.0.3.tgz", + "integrity": "sha512-QmU3lyEnbENQPo0M1F9BMu4s6cqNNp8iJA+b/HP2sSb7pf3dxwF3+EP1eO69rwBfH9kFJ1apmzrtogAmVt2/Xw==", + "license": "MIT", + "dependencies": { + "accessor-fn": "1" + }, + "engines": { + "node": ">=12" } }, "node_modules/detect-libc": { @@ -569,6 +773,20 @@ } } }, + "node_modules/float-tooltip": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/float-tooltip/-/float-tooltip-1.7.5.tgz", + "integrity": "sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==", + "license": "MIT", + "dependencies": { + "d3-selection": "2 - 3", + "kapsule": "^1.16", + "preact": "10" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -584,6 +802,42 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/jerrypick": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/jerrypick/-/jerrypick-1.1.2.tgz", + "integrity": "sha512-YKnxXEekXKzhpf7CLYA0A+oDP8V0OhICNCr5lv96FvSsDEmrb0GKM776JgQvHTMjr7DTTPEVv/1Ciaw0uEWzBA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/kapsule": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/kapsule/-/kapsule-1.16.3.tgz", + "integrity": "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==", + "license": "MIT", + "dependencies": { + "lodash-es": "4" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -845,6 +1099,24 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lucide-react": { "version": "0.542.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.542.0.tgz", @@ -900,6 +1172,53 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/ngraph.events": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/ngraph.events/-/ngraph.events-1.4.0.tgz", + "integrity": "sha512-NeDGI4DSyjBNBRtA86222JoYietsmCXbs8CEB0dZ51Xeh4lhVl1y3wpWLumczvnha8sFQIW4E0vvVWwgmX2mGw==", + "license": "BSD-3-Clause" + }, + "node_modules/ngraph.forcelayout": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/ngraph.forcelayout/-/ngraph.forcelayout-3.3.1.tgz", + "integrity": "sha512-MKBuEh1wujyQHFTW57y5vd/uuEOK0XfXYxm3lC7kktjJLRdt/KEKEknyOlc6tjXflqBKEuYBBcu7Ax5VY+S6aw==", + "license": "BSD-3-Clause", + "dependencies": { + "ngraph.events": "^1.0.0", + "ngraph.merge": "^1.0.0", + "ngraph.random": "^1.0.0" + } + }, + "node_modules/ngraph.graph": { + "version": "20.1.2", + "resolved": "https://registry.npmjs.org/ngraph.graph/-/ngraph.graph-20.1.2.tgz", + "integrity": "sha512-W/G3GBR3Y5UxMLHTUCPP9v+pbtpzwuAEIqP5oZV+9IwgxAIEZwh+Foc60iPc1idlnK7Zxu0p3puxAyNmDvBd0Q==", + "license": "BSD-3-Clause", + "dependencies": { + "ngraph.events": "^1.4.0" + } + }, + "node_modules/ngraph.merge": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ngraph.merge/-/ngraph.merge-1.0.0.tgz", + "integrity": "sha512-5J8YjGITUJeapsomtTALYsw7rFveYkM+lBj3QiYZ79EymQcuri65Nw3knQtFxQBU1r5iOaVRXrSwMENUPK62Vg==", + "license": "MIT" + }, + "node_modules/ngraph.random": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/ngraph.random/-/ngraph.random-1.2.0.tgz", + "integrity": "sha512-4EUeAGbB2HWX9njd6bP6tciN6ByJfoaAvmVL9QTaZSeXrW46eNGA9GajiXiPBbvFqxUWFkEbyo6x5qsACUuVfA==", + "license": "BSD-3-Clause" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/obug": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", @@ -941,6 +1260,18 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/polished": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz", + "integrity": "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/postcss": { "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", @@ -970,6 +1301,35 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/preact": { + "version": "10.29.8", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", + "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -991,6 +1351,44 @@ "react": "^19.2.8" } }, + "node_modules/react-force-graph-3d": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/react-force-graph-3d/-/react-force-graph-3d-1.29.1.tgz", + "integrity": "sha512-5Vp+PGpYnO+zLwgK2NvNqdXHvsWLrFzpDfJW1vUA1twjo9SPvXqfUYQrnRmAbD+K2tOxkZw1BkbH31l5b4TWHg==", + "license": "MIT", + "dependencies": { + "3d-force-graph": "^1.79", + "prop-types": "15", + "react-kapsule": "^2.5" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-kapsule": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-kapsule/-/react-kapsule-2.6.0.tgz", + "integrity": "sha512-HzLJoYb1n1kfwjXbqFFcRR0EA6oPsJ64tNdDmCSaL/bz2o9wUZRSb0cMe//grLFeF9EVoL4CD/e6ozLyzEv+PQ==", + "license": "MIT", + "dependencies": { + "jerrypick": "^1.1.2" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/rolldown": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz", @@ -1062,6 +1460,67 @@ "dev": true, "license": "MIT" }, + "node_modules/three": { + "version": "0.185.1", + "resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz", + "integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==", + "license": "MIT" + }, + "node_modules/three-forcegraph": { + "version": "1.43.4", + "resolved": "https://registry.npmjs.org/three-forcegraph/-/three-forcegraph-1.43.4.tgz", + "integrity": "sha512-FtmiZP/T16ZQaHza3JDaDn0YTXFtg9e7pGnTeU8nzu0NNkx7MpWbF/GvmpbQsWHx3rukHtkRv1fTorLPB3FDEA==", + "license": "MIT", + "dependencies": { + "accessor-fn": "1", + "d3-array": "1 - 3", + "d3-force-3d": "2 - 3", + "d3-scale": "1 - 4", + "d3-scale-chromatic": "1 - 3", + "data-bind-mapper": "1", + "kapsule": "^1.16", + "ngraph.forcelayout": "3", + "ngraph.graph": "20", + "tinycolor2": "1" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "three": ">=0.118.3" + } + }, + "node_modules/three-render-objects": { + "version": "1.42.0", + "resolved": "https://registry.npmjs.org/three-render-objects/-/three-render-objects-1.42.0.tgz", + "integrity": "sha512-KYfkPrYGEbIK8ChFocWqOF1aAN80FBUBWVYB8mB2oBpVuVN+52FvvngVYB5ieFANQu7Rt21rPYZ/xKaAgVWWRQ==", + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "18 - 25", + "accessor-fn": "1", + "float-tooltip": "^1.7", + "kapsule": "^1.16", + "polished": "4" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "three": ">=0.179" + } + }, + "node_modules/three-spritetext": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/three-spritetext/-/three-spritetext-1.10.0.tgz", + "integrity": "sha512-t08iP1FCU1lQh8T5MmCpdijKgas8GDHJE0LqMGBuVu3xqMMpFnEZhTlih7FlxLPQizHIGoumUSpfOlY1GO/Tgg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "three": ">=0.86.0" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -1069,6 +1528,12 @@ "dev": true, "license": "MIT" }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", diff --git a/package.json b/package.json index c2b22e1..eeb6efb 100644 --- a/package.json +++ b/package.json @@ -13,11 +13,13 @@ "test": "vitest run" }, "dependencies": { - "cytoscape": "^3.34.2", "lucide-react": "0.542.0", "markdown-to-jsx": "^7.7.17", "react": "^19.2.8", "react-dom": "^19.2.8", + "react-force-graph-3d": "^1.29.1", + "three": "^0.185.1", + "three-spritetext": "^1.10.0", "yaml": "^2.9.0" }, "devDependencies": { diff --git a/src/Graph.jsx b/src/Graph.jsx index af99992..43246a5 100644 --- a/src/Graph.jsx +++ b/src/Graph.jsx @@ -12,168 +12,193 @@ // See the License for the specific language governing permissions and // limitations under the License. -import cytoscape from "cytoscape"; -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import ForceGraph3D from "react-force-graph-3d"; +import { Group, Mesh, MeshBasicMaterial, SphereGeometry } from "three"; +import SpriteText from "three-spritetext"; import { buildUndirectedEdges } from "./brain.js"; const GRAPH_THEMES = { light: { text: "#191c22", - border: "#596170", edge: "#b8c0cc", accent: "#1a56d0", + bg: "#edeff3", }, dark: { text: "#e6e9ee", - border: "#9ea7b3", edge: "#3b434f", accent: "#74a0ff", + bg: "#0e1116", }, }; -function graphStyle(theme) { - const colors = GRAPH_THEMES[theme] || GRAPH_THEMES.light; - return [ - { - selector: "node", - style: { - "background-color": "data(color)", - label: "data(label)", - color: colors.text, - "font-size": 11, - "text-valign": "bottom", - "text-margin-y": 4, - "text-wrap": "wrap", - "text-max-width": 120, - width: "data(size)", - height: "data(size)", - "border-width": 1, - "border-color": colors.border, - }, - }, - { - selector: "node[?stale]", - style: { - "border-width": 2, - "border-color": "#b91c1c", - "border-style": "dashed", - }, - }, - { - selector: 'node[status = "deprecated"]', - style: { opacity: 0.55 }, - }, - { - selector: "node:selected", - style: { - "border-width": 3, - "border-color": colors.accent, - }, - }, - { - selector: "edge", - style: { - width: 1.5, - "line-color": colors.edge, - "curve-style": "bezier", - }, - }, - { - selector: "edge:selected", - style: { - "line-color": colors.accent, - width: 2.5, - }, - }, - { - selector: ".dim", - style: { opacity: 0.15 }, - }, - ]; +function hexToRgb(hex) { + const value = hex.replace("#", ""); + const parsed = Number.parseInt(value, 16); + return { r: (parsed >> 16) & 255, g: (parsed >> 8) & 255, b: parsed & 255 }; +} + +function rgbToHex(r, g, b) { + return `#${[r, g, b].map((channel) => channel.toString(16).padStart(2, "0")).join("")}`; +} + +function mixHex(from, to, ratio) { + const a = hexToRgb(from); + const b = hexToRgb(to); + const mix = (start, end) => Math.round(start + (end - start) * ratio); + return rgbToHex(mix(a.r, b.r), mix(a.g, b.g), mix(a.b, b.b)); } export function Graph({ bundle, matchingIds, selectedId, resetSignal, theme, onSelect }) { const containerRef = useRef(null); const graphRef = useRef(null); + const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); + const colors = GRAPH_THEMES[theme] || GRAPH_THEMES.light; + + const nodes = useMemo(() => bundle.nodes.map((node) => node.data), [bundle]); + const links = useMemo( + () => buildUndirectedEdges(bundle.edges).map((edge) => edge.data), + [bundle], + ); + const graphData = useMemo(() => ({ nodes, links }), [nodes, links]); useEffect(() => { - const graph = cytoscape({ - container: containerRef.current, - elements: [...bundle.nodes, ...buildUndirectedEdges(bundle.edges)], - style: graphStyle(theme), - layout: { name: "cose", animate: false, padding: 30 }, - minZoom: 0.2, - maxZoom: 2, + const element = containerRef.current; + if (!element) { + return undefined; + } + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) { + return; + } + const { width, height } = entry.contentRect; + setDimensions({ width, height }); }); - graphRef.current = graph; + observer.observe(element); + return () => observer.disconnect(); + }, []); + + const nodeColor = useCallback( + (node) => { + if (!matchingIds.has(node.id)) { + return mixHex(node.color, colors.bg, 0.85); + } + if (node.status === "deprecated") { + return mixHex(node.color, colors.bg, 0.45); + } + return node.color; + }, + [matchingIds, colors], + ); - graph.on("tap", "node", (event) => onSelect(event.target.id())); - graph.on("tap", (event) => { - if (event.target === graph) { - onSelect(null); + const linkColor = useCallback( + (link) => { + const sourceId = typeof link.source === "object" ? link.source.id : link.source; + const targetId = typeof link.target === "object" ? link.target.id : link.target; + if (!matchingIds.has(sourceId) || !matchingIds.has(targetId)) { + return mixHex(colors.edge, colors.bg, 0.85); } - }); + if (selectedId && (sourceId === selectedId || targetId === selectedId)) { + return colors.accent; + } + return colors.edge; + }, + [matchingIds, selectedId, colors], + ); - return () => { - graphRef.current = null; - graph.destroy(); - }; - }, [bundle, onSelect]); + const linkWidth = useCallback( + (link) => { + const sourceId = typeof link.source === "object" ? link.source.id : link.source; + const targetId = typeof link.target === "object" ? link.target.id : link.target; + if (selectedId && (sourceId === selectedId || targetId === selectedId)) { + return 1.4; // thicker highlight for edges touching the selected node + } + return 0.8; // base edge thickness; try 0 for the library's default hairline + }, + [selectedId], + ); - useEffect(() => { - const graph = graphRef.current; - if (graph) { - graph.style(graphStyle(theme)); - } - }, [theme]); + const nodeThreeObject = useCallback( + (node) => { + const dimmed = !matchingIds.has(node.id); - useEffect(() => { - const graph = graphRef.current; - if (!graph) { - return; - } - graph.layout({ name: "cose", animate: false, padding: 30 }).run(); - }, [bundle]); + const sprite = new SpriteText(node.label); + sprite.color = dimmed ? mixHex(colors.text, colors.bg, 0.7) : colors.text; + sprite.textHeight = 3; + sprite.center.y = -1.5; // shift above node, matching the react-force-graph text-nodes example + + if (!node.stale && node.id !== selectedId) { + return sprite; + } + + const group = new Group(); + group.add(sprite); + + if (node.stale) { + const halo = new Mesh( + new SphereGeometry(2, 8, 8), + new MeshBasicMaterial({ + color: "#b91c1c", + wireframe: true, + transparent: true, + opacity: dimmed ? 0.25 : 0.9, + }), + ); + group.add(halo); + } + + if (node.id === selectedId) { + const ring = new Mesh( + new SphereGeometry(node.stale ? 3 : 2, 12, 12), + new MeshBasicMaterial({ color: colors.accent, wireframe: true }), + ); + group.add(ring); + } + + return group; + }, + [matchingIds, selectedId, colors], + ); useEffect(() => { const graph = graphRef.current; - if (!graph) { - return; + if (!graph || !dimensions.width || !dimensions.height) { + return undefined; } - graph.nodes().forEach((node) => { - node.toggleClass("dim", !matchingIds.has(node.id())); - }); - graph.edges().forEach((edge) => { - edge.toggleClass("dim", edge.source().hasClass("dim") || edge.target().hasClass("dim")); - }); - }, [bundle, matchingIds]); + const timeoutId = setTimeout(() => graph.zoomToFit(400, 30), 250); + return () => clearTimeout(timeoutId); + }, [graphData, dimensions.width, dimensions.height]); useEffect(() => { const graph = graphRef.current; - if (!graph) { + if (!graph || !selectedId) { return; } - graph.elements().unselect(); - if (!selectedId) { + const node = nodes.find((candidate) => candidate.id === selectedId); + if (!node || !Number.isFinite(node.x)) { return; } - const node = graph.getElementById(selectedId); - if (!node.nonempty()) { - return; - } - node.select(); - graph.animate( - { center: { eles: node }, zoom: Math.max(graph.zoom(), 1) }, - { duration: 200 }, + // Keep the camera's current distance/angle relative to its target (i.e. don't + // change zoom), just shift that same offset so it now looks at the selected node. + const { x: camX, y: camY, z: camZ } = graph.cameraPosition(); + const target = graph.controls()?.target; + const offset = { + x: camX - (target?.x ?? 0), + y: camY - (target?.y ?? 0), + z: camZ - (target?.z ?? 0), + }; + graph.cameraPosition( + { x: node.x + offset.x, y: node.y + offset.y, z: node.z + offset.z }, + node, + 600, ); - }, [bundle, selectedId]); + }, [nodes, selectedId]); useEffect(() => { - const graph = graphRef.current; - if (graph) { - graph.fit(undefined, 30); - } + graphRef.current?.zoomToFit(400, 30); }, [resetSignal]); function changeZoom(factor) { @@ -181,20 +206,38 @@ export function Graph({ bundle, matchingIds, selectedId, resetSignal, theme, onS if (!graph) { return; } - const level = Math.min(graph.maxZoom(), Math.max(graph.minZoom(), graph.zoom() * factor)); - graph.zoom({ - level, - renderedPosition: { x: graph.width() / 2, y: graph.height() / 2 }, - }); + const { x, y, z } = graph.camera().position; + const scale = 1 / factor; + graph.cameraPosition({ x: x * scale, y: y * scale, z: z * scale }, undefined, 200); } function fitGraph() { - graphRef.current?.fit(undefined, 30); + graphRef.current?.zoomToFit(400, 30); } return (
-
+
+ {dimensions.width > 0 && dimensions.height > 0 ? ( + ""} + nodeColor={nodeColor} + nodeThreeObjectExtend={true} + nodeThreeObject={nodeThreeObject} + linkColor={linkColor} + linkWidth={linkWidth} + linkResolution={6} + onNodeClick={(node) => onSelect(node.id)} + onBackgroundClick={() => onSelect(null)} + /> + ) : null} +