feat(graph): 3D mode toggle — three.js + WebGL fallback

This commit is contained in:
Александр Зимин
2026-05-11 22:11:41 +00:00
parent fc9db66c7c
commit fe18befb2a
5 changed files with 687 additions and 39 deletions
+3
View File
@@ -39,12 +39,14 @@
"oidc-client-ts": "^3.5.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-force-graph-3d": "^1.29.1",
"react-hook-form": "^7.54.2",
"react-i18next": "^17.0.2",
"react-leaflet": "^5.0.0",
"react-oidc-context": "^3.3.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"three": "^0.184.0",
"topojson-client": "^3.1.0",
"tw-animate-css": "^1.4.0",
"world-atlas": "^2.0.2"
@@ -67,6 +69,7 @@
"@types/leaflet": "^1.9.21",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/three": "^0.184.1",
"@types/topojson-client": "^3.1.5",
"@vitejs/plugin-react": "^4.3.4",
"conventional-changelog-conventionalcommits": "^9.3.1",
+326
View File
@@ -92,6 +92,9 @@ importers:
react-dom:
specifier: ^19.0.0
version: 19.2.5(react@19.2.5)
react-force-graph-3d:
specifier: ^1.29.1
version: 1.29.1(react@19.2.5)
react-hook-form:
specifier: ^7.54.2
version: 7.75.0(react@19.2.5)
@@ -110,6 +113,9 @@ importers:
tailwind-merge:
specifier: ^3.6.0
version: 3.6.0
three:
specifier: ^0.184.0
version: 0.184.0
topojson-client:
specifier: ^3.1.0
version: 3.1.0
@@ -171,6 +177,9 @@ importers:
'@types/react-dom':
specifier: ^19.0.0
version: 19.2.3(@types/react@19.2.14)
'@types/three':
specifier: ^0.184.1
version: 0.184.1
'@types/topojson-client':
specifier: ^3.1.5
version: 3.1.5
@@ -201,6 +210,10 @@ importers:
packages:
3d-force-graph@1.80.0:
resolution: {integrity: sha512-tzI353gW1nXPpnC7VTa3JjMg+3cp77qOLUFO0vucPTfF+q5R6sQsNsIqVTbRIb7RSypn14nBa4yfkOe9ThxASw==}
engines: {node: '>=12'}
'@adobe/css-tools@4.4.4':
resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==}
@@ -362,6 +375,9 @@ packages:
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
engines: {node: '>=20.19.0'}
'@dimforge/rapier3d-compat@0.12.0':
resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
'@esbuild/aix-ppc64@0.21.5':
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
engines: {node: '>=12'}
@@ -1619,6 +1635,12 @@ packages:
peerDependencies:
'@testing-library/dom': '>=7.21.4'
'@tweenjs/tween.js@23.1.3':
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
'@tweenjs/tween.js@25.0.0':
resolution: {integrity: sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==}
'@types/aria-query@5.0.4':
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
@@ -1663,12 +1685,21 @@ packages:
'@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
'@types/stats.js@0.17.4':
resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==}
'@types/three@0.184.1':
resolution: {integrity: sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==}
'@types/topojson-client@3.1.5':
resolution: {integrity: sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==}
'@types/topojson-specification@1.0.5':
resolution: {integrity: sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==}
'@types/webxr@0.5.24':
resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==}
'@vitejs/plugin-react@4.7.0':
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
engines: {node: ^14.18.0 || >=16.0.0}
@@ -1704,6 +1735,10 @@ packages:
'@vitest/utils@2.1.9':
resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==}
accessor-fn@1.5.3:
resolution: {integrity: sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==}
engines: {node: '>=12'}
agent-base@7.1.4:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
@@ -2000,26 +2035,72 @@ packages:
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
engines: {node: '>=12'}
d3-binarytree@1.0.2:
resolution: {integrity: sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-dispatch@3.0.1:
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
engines: {node: '>=12'}
d3-force-3d@3.0.6:
resolution: {integrity: sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==}
engines: {node: '>=12'}
d3-force@3.0.0:
resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==}
engines: {node: '>=12'}
d3-format@3.1.2:
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
engines: {node: '>=12'}
d3-geo@3.1.1:
resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-octree@1.1.0:
resolution: {integrity: sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==}
d3-quadtree@3.0.1:
resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==}
engines: {node: '>=12'}
d3-scale-chromatic@3.1.0:
resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==}
engines: {node: '>=12'}
d3-scale@4.0.2:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
d3-selection@3.0.0:
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
engines: {node: '>=12'}
d3-time-format@4.1.0:
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
engines: {node: '>=12'}
d3-time@3.1.0:
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
data-bind-mapper@1.0.3:
resolution: {integrity: sha512-QmU3lyEnbENQPo0M1F9BMu4s6cqNNp8iJA+b/HP2sSb7pf3dxwF3+EP1eO69rwBfH9kFJ1apmzrtogAmVt2/Xw==}
engines: {node: '>=12'}
data-urls@7.0.0:
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@@ -2205,6 +2286,9 @@ packages:
picomatch:
optional: true
fflate@0.8.2:
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
figures@2.0.0:
resolution: {integrity: sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==}
engines: {node: '>=4'}
@@ -2229,6 +2313,10 @@ packages:
resolution: {integrity: sha512-2kCCtc+JvcZ86IGAz3Z2Y0A1baIz9fL31pH/0S1IqZr9Iwnjq8izfPtrCyQKO6TLMPELLsQMre7VDqeIKCsHkA==}
engines: {node: '>=18'}
float-tooltip@1.7.5:
resolution: {integrity: sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==}
engines: {node: '>=12'}
follow-redirects@1.16.0:
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
engines: {node: '>=4.0'}
@@ -2517,6 +2605,10 @@ packages:
resolution: {integrity: sha512-qjdpeo2yKlYTH7nFdK0vbZWuTCesk4o63v5iVOlhMQPfuIZQfW/HI35SjfhA+4qpg36rnFSvUK5b1m+ckIblQQ==}
engines: {node: '>= 0.6.0'}
jerrypick@1.1.2:
resolution: {integrity: sha512-YKnxXEekXKzhpf7CLYA0A+oDP8V0OhICNCr5lv96FvSsDEmrb0GKM776JgQvHTMjr7DTTPEVv/1Ciaw0uEWzBA==}
engines: {node: '>=12'}
jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
@@ -2566,6 +2658,10 @@ packages:
resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
engines: {node: '>=18'}
kapsule@1.16.3:
resolution: {integrity: sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==}
engines: {node: '>=12'}
keyv@5.6.0:
resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==}
@@ -2678,6 +2774,10 @@ packages:
lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
@@ -2740,6 +2840,9 @@ packages:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
meshoptimizer@1.1.1:
resolution: {integrity: sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==}
micromatch@4.0.8:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
@@ -2793,6 +2896,21 @@ packages:
nerf-dart@1.0.0:
resolution: {integrity: sha512-EZSPZB70jiVsivaBLYDCyntd5eH8NTSMOn3rB+HxwdmKThGELLdYv8qVIMWvZEFy9w8ZZpW9h9OB32l1rGtj7g==}
ngraph.events@1.4.0:
resolution: {integrity: sha512-NeDGI4DSyjBNBRtA86222JoYietsmCXbs8CEB0dZ51Xeh4lhVl1y3wpWLumczvnha8sFQIW4E0vvVWwgmX2mGw==}
ngraph.forcelayout@3.3.1:
resolution: {integrity: sha512-MKBuEh1wujyQHFTW57y5vd/uuEOK0XfXYxm3lC7kktjJLRdt/KEKEknyOlc6tjXflqBKEuYBBcu7Ax5VY+S6aw==}
ngraph.graph@20.1.2:
resolution: {integrity: sha512-W/G3GBR3Y5UxMLHTUCPP9v+pbtpzwuAEIqP5oZV+9IwgxAIEZwh+Foc60iPc1idlnK7Zxu0p3puxAyNmDvBd0Q==}
ngraph.merge@1.0.0:
resolution: {integrity: sha512-5J8YjGITUJeapsomtTALYsw7rFveYkM+lBj3QiYZ79EymQcuri65Nw3knQtFxQBU1r5iOaVRXrSwMENUPK62Vg==}
ngraph.random@1.2.0:
resolution: {integrity: sha512-4EUeAGbB2HWX9njd6bP6tciN6ByJfoaAvmVL9QTaZSeXrW46eNGA9GajiXiPBbvFqxUWFkEbyo6x5qsACUuVfA==}
node-emoji@2.2.0:
resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==}
engines: {node: '>=18'}
@@ -3046,10 +3164,17 @@ packages:
resolution: {integrity: sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g==}
engines: {node: '>=4'}
polished@4.3.1:
resolution: {integrity: sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==}
engines: {node: '>=10'}
postcss@8.5.13:
resolution: {integrity: sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==}
engines: {node: ^10 || ^12 || >=14}
preact@10.29.1:
resolution: {integrity: sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==}
prettier@3.8.3:
resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==}
engines: {node: '>=14'}
@@ -3066,6 +3191,9 @@ packages:
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
proto-list@1.2.4:
resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==}
@@ -3096,6 +3224,12 @@ packages:
peerDependencies:
react: ^19.2.5
react-force-graph-3d@1.29.1:
resolution: {integrity: sha512-5Vp+PGpYnO+zLwgK2NvNqdXHvsWLrFzpDfJW1vUA1twjo9SPvXqfUYQrnRmAbD+K2tOxkZw1BkbH31l5b4TWHg==}
engines: {node: '>=12'}
peerDependencies:
react: '*'
react-hook-form@7.75.0:
resolution: {integrity: sha512-Ovv94H+0p3sJ7B9B5QxPuCP1u8V/cHuVGyH55cSwodYDtoJwK+fqk3vjfIgSX59I2U/bU4z0nRJ9HMLpNiWEmw==}
engines: {node: '>=18.0.0'}
@@ -3118,9 +3252,18 @@ packages:
typescript:
optional: true
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
react-kapsule@2.5.7:
resolution: {integrity: sha512-kifAF4ZPD77qZKc4CKLmozq6GY1sBzPEJTIJb0wWFK6HsePJatK3jXplZn2eeAt3x67CDozgi7/rO8fNQ/AL7A==}
engines: {node: '>=12'}
peerDependencies:
react: '>=16.13.1'
react-leaflet@5.0.0:
resolution: {integrity: sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==}
peerDependencies:
@@ -3424,6 +3567,21 @@ packages:
thenify@3.3.1:
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
three-forcegraph@1.43.4:
resolution: {integrity: sha512-FtmiZP/T16ZQaHza3JDaDn0YTXFtg9e7pGnTeU8nzu0NNkx7MpWbF/GvmpbQsWHx3rukHtkRv1fTorLPB3FDEA==}
engines: {node: '>=12'}
peerDependencies:
three: '>=0.118.3'
three-render-objects@1.41.1:
resolution: {integrity: sha512-0H7l7yREPVKfO3HL7RjPQ67T0phHgnyMeEc4ww/OCEfK6jbsm7psEcrR0SGFqGDyS/pDQTPi4DyPbS/xlHRJKw==}
engines: {node: '>=12'}
peerDependencies:
three: '>=0.179'
three@0.184.0:
resolution: {integrity: sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==}
through2@2.0.5:
resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==}
@@ -3434,6 +3592,9 @@ packages:
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinycolor2@1.6.0:
resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==}
tinyexec@0.3.2:
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
@@ -3773,6 +3934,14 @@ packages:
snapshots:
3d-force-graph@1.80.0:
dependencies:
accessor-fn: 1.5.3
kapsule: 1.16.3
three: 0.184.0
three-forcegraph: 1.43.4(three@0.184.0)
three-render-objects: 1.41.1(three@0.184.0)
'@adobe/css-tools@4.4.4': {}
'@asamuzakjp/css-color@5.1.11':
@@ -3950,6 +4119,8 @@ snapshots:
'@csstools/css-tokenizer@4.0.0': {}
'@dimforge/rapier3d-compat@0.12.0': {}
'@esbuild/aix-ppc64@0.21.5':
optional: true
@@ -5041,6 +5212,10 @@ snapshots:
dependencies:
'@testing-library/dom': 10.4.1
'@tweenjs/tween.js@23.1.3': {}
'@tweenjs/tween.js@25.0.0': {}
'@types/aria-query@5.0.4': {}
'@types/babel__core@7.20.5':
@@ -5090,6 +5265,17 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/stats.js@0.17.4': {}
'@types/three@0.184.1':
dependencies:
'@dimforge/rapier3d-compat': 0.12.0
'@tweenjs/tween.js': 23.1.3
'@types/stats.js': 0.17.4
'@types/webxr': 0.5.24
fflate: 0.8.2
meshoptimizer: 1.1.1
'@types/topojson-client@3.1.5':
dependencies:
'@types/geojson': 7946.0.16
@@ -5099,6 +5285,8 @@ snapshots:
dependencies:
'@types/geojson': 7946.0.16
'@types/webxr@0.5.24': {}
'@vitejs/plugin-react@4.7.0(vite@6.4.2(jiti@2.6.1)(lightningcss@1.32.0))':
dependencies:
'@babel/core': 7.29.0
@@ -5151,6 +5339,8 @@ snapshots:
loupe: 3.2.1
tinyrainbow: 1.2.0
accessor-fn@1.5.3: {}
agent-base@7.1.4: {}
aggregate-error@3.1.0:
@@ -5454,22 +5644,69 @@ snapshots:
dependencies:
internmap: 2.0.3
d3-binarytree@1.0.2: {}
d3-color@3.1.0: {}
d3-dispatch@3.0.1: {}
d3-force-3d@3.0.6:
dependencies:
d3-binarytree: 1.0.2
d3-dispatch: 3.0.1
d3-octree: 1.1.0
d3-quadtree: 3.0.1
d3-timer: 3.0.1
d3-force@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-quadtree: 3.0.1
d3-timer: 3.0.1
d3-format@3.1.2: {}
d3-geo@3.1.1:
dependencies:
d3-array: 3.2.4
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-octree@1.1.0: {}
d3-quadtree@3.0.1: {}
d3-scale-chromatic@3.1.0:
dependencies:
d3-color: 3.1.0
d3-interpolate: 3.0.1
d3-scale@4.0.2:
dependencies:
d3-array: 3.2.4
d3-format: 3.1.2
d3-interpolate: 3.0.1
d3-time: 3.1.0
d3-time-format: 4.1.0
d3-selection@3.0.0: {}
d3-time-format@4.1.0:
dependencies:
d3-time: 3.1.0
d3-time@3.1.0:
dependencies:
d3-array: 3.2.4
d3-timer@3.0.1: {}
data-bind-mapper@1.0.3:
dependencies:
accessor-fn: 1.5.3
data-urls@7.0.0:
dependencies:
whatwg-mimetype: 5.0.0
@@ -5694,6 +5931,8 @@ snapshots:
optionalDependencies:
picomatch: 4.0.4
fflate@0.8.2: {}
figures@2.0.0:
dependencies:
escape-string-regexp: 1.0.5
@@ -5717,6 +5956,12 @@ snapshots:
semver-regex: 4.0.5
super-regex: 1.1.0
float-tooltip@1.7.5:
dependencies:
d3-selection: 3.0.0
kapsule: 1.16.3
preact: 10.29.1
follow-redirects@1.16.0: {}
form-data-encoder@4.1.0: {}
@@ -5976,6 +6221,8 @@ snapshots:
java-properties@1.0.2: {}
jerrypick@1.1.2: {}
jiti@2.6.1: {}
js-tokens@4.0.0: {}
@@ -6030,6 +6277,10 @@ snapshots:
jwt-decode@4.0.0: {}
kapsule@1.16.3:
dependencies:
lodash-es: 4.18.1
keyv@5.6.0:
dependencies:
'@keyv/serialize': 1.1.1
@@ -6113,6 +6364,10 @@ snapshots:
lodash@4.18.1: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
loupe@3.2.1: {}
lowercase-keys@3.0.0: {}
@@ -6164,6 +6419,8 @@ snapshots:
merge2@1.4.1: {}
meshoptimizer@1.1.1: {}
micromatch@4.0.8:
dependencies:
braces: 3.0.3
@@ -6201,6 +6458,22 @@ snapshots:
nerf-dart@1.0.0: {}
ngraph.events@1.4.0: {}
ngraph.forcelayout@3.3.1:
dependencies:
ngraph.events: 1.4.0
ngraph.merge: 1.0.0
ngraph.random: 1.2.0
ngraph.graph@20.1.2:
dependencies:
ngraph.events: 1.4.0
ngraph.merge@1.0.0: {}
ngraph.random@1.2.0: {}
node-emoji@2.2.0:
dependencies:
'@sindresorhus/is': 4.6.0
@@ -6352,12 +6625,18 @@ snapshots:
find-up: 2.1.0
load-json-file: 4.0.0
polished@4.3.1:
dependencies:
'@babel/runtime': 7.29.2
postcss@8.5.13:
dependencies:
nanoid: 3.3.12
picocolors: 1.1.1
source-map-js: 1.2.1
preact@10.29.1: {}
prettier@3.8.3: {}
pretty-format@27.5.1:
@@ -6372,6 +6651,12 @@ snapshots:
process-nextick-args@2.0.1: {}
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
object-assign: 4.1.1
react-is: 16.13.1
proto-list@1.2.4: {}
protocols@2.0.2: {}
@@ -6396,6 +6681,13 @@ snapshots:
react: 19.2.5
scheduler: 0.27.0
react-force-graph-3d@1.29.1(react@19.2.5):
dependencies:
3d-force-graph: 1.80.0
prop-types: 15.8.1
react: 19.2.5
react-kapsule: 2.5.7(react@19.2.5)
react-hook-form@7.75.0(react@19.2.5):
dependencies:
react: 19.2.5
@@ -6411,8 +6703,15 @@ snapshots:
react-dom: 19.2.5(react@19.2.5)
typescript: 5.9.3
react-is@16.13.1: {}
react-is@17.0.2: {}
react-kapsule@2.5.7(react@19.2.5):
dependencies:
jerrypick: 1.1.2
react: 19.2.5
react-leaflet@5.0.0(leaflet@1.9.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
'@react-leaflet/core': 3.0.0(leaflet@1.9.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@@ -6737,6 +7036,31 @@ snapshots:
dependencies:
any-promise: 1.3.0
three-forcegraph@1.43.4(three@0.184.0):
dependencies:
accessor-fn: 1.5.3
d3-array: 3.2.4
d3-force-3d: 3.0.6
d3-scale: 4.0.2
d3-scale-chromatic: 3.1.0
data-bind-mapper: 1.0.3
kapsule: 1.16.3
ngraph.forcelayout: 3.3.1
ngraph.graph: 20.1.2
three: 0.184.0
tinycolor2: 1.6.0
three-render-objects@1.41.1(three@0.184.0):
dependencies:
'@tweenjs/tween.js': 25.0.0
accessor-fn: 1.5.3
float-tooltip: 1.7.5
kapsule: 1.16.3
polished: 4.3.1
three: 0.184.0
three@0.184.0: {}
through2@2.0.5:
dependencies:
readable-stream: 2.3.8
@@ -6748,6 +7072,8 @@ snapshots:
tinybench@2.9.0: {}
tinycolor2@1.6.0: {}
tinyexec@0.3.2: {}
tinyglobby@0.2.16:
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Component, Suspense, lazy, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useQueries } from '@tanstack/react-query'
import {
@@ -16,6 +16,50 @@ import type { DictionaryDefinition, SchemaDependent } from '@/api/client'
import { LoadingBlock } from '@/ui'
import { cn } from '@/lib/utils'
// 3D-режим лениво грузится — three.js (~650kb) и react-force-graph-3d не
// попадают в default chunk. Vite автоматически создаёт отдельный chunk для
// dynamic import. Suspense fallback показывается во время загрузки модуля.
const DictionaryGraph3D = lazy(() => import('./DictionaryGraph3D'))
type GraphMode = '2d' | '3d'
/**
* Scoped error boundary для 3D Suspense three.js'у нужен WebGL, и если
* браузер его не поддерживает (corp VM, headless без --use-gl=*, hardware
* accel выключен) `WebGLRenderer` бросает ошибку в render-цикле. Без
* этого ErrorBoundary fallback'a ошибка bubble'ит до AppErrorBoundary и
* вся page становится "Что-то пошло не так". Здесь мы её ловим локально,
* показываем friendly fallback + предлагаем вернуться в 2D режим.
*
* `onError` callback позволяет parent auto-reset'ить mode → '2d', чтобы
* пользователь не залипал на ошибочном состоянии. Recovery через
* componentDidUpdate (resetKey changes state.hasError = false).
*/
class Graph3DErrorBoundary extends Component<
{ children: ReactNode; onError: () => void; resetKey: number; fallback: ReactNode },
{ hasError: boolean }
> {
state = { hasError: false }
static getDerivedStateFromError() {
return { hasError: true }
}
componentDidCatch() {
// Auto-notify parent (will switch mode → '2d') чтобы next render не
// пытался монтировать 3D снова. Defer через setTimeout — синхронный
// setState из catch ломает React reconciliation.
setTimeout(() => this.props.onError(), 0)
}
componentDidUpdate(prev: { resetKey: number }) {
if (prev.resetKey !== this.props.resetKey && this.state.hasError) {
this.setState({ hasError: false })
}
}
render() {
if (this.state.hasError) return this.props.fallback
return this.props.children
}
}
/**
* Граф связей справочников d3-force network diagram.
*
@@ -65,6 +109,16 @@ function colorForBundle(bundle: string): string {
export function DictionaryGraph() {
const dictsQuery = useDictionaries()
const dicts = dictsQuery.data ?? []
// localStorage persist чтобы preference сохранился между переходами.
// SSR-safe guard на window — vitest setup использует jsdom где window есть,
// но defensive чтобы не упасть на SSR в будущем.
const [mode, setMode] = useState<GraphMode>(() => {
if (typeof window === 'undefined') return '2d'
return (localStorage.getItem('graph.mode') as GraphMode) === '3d' ? '3d' : '2d'
})
useEffect(() => {
if (typeof window !== 'undefined') localStorage.setItem('graph.mode', mode)
}, [mode])
// Fetch dependents для всех dicts параллельно (cached между mounts).
const dependentsResults = useQueries({
@@ -101,6 +155,8 @@ export function DictionaryGraph() {
dicts={dicts}
dependentsByDict={dependentsByDict}
depsLoading={allDependentsLoading}
mode={mode}
onModeChange={setMode}
/>
)
}
@@ -109,11 +165,18 @@ function GraphCanvas({
dicts,
dependentsByDict,
depsLoading,
mode,
onModeChange,
}: {
dicts: DictionaryDefinition[]
dependentsByDict: Map<string, SchemaDependent[]>
depsLoading: boolean
mode: GraphMode
onModeChange: (m: GraphMode) => void
}) {
// WebGL-fallback banner — показывается несколько секунд после авто-возврата
// в 2D из-за ошибки 3D renderer'а (нет WebGL / hardware accel выключено).
const [webglFallbackNotice, setWebglFallbackNotice] = useState(false)
const navigate = useNavigate()
const wrapperRef = useRef<HTMLDivElement>(null)
const svgRef = useRef<SVGSVGElement>(null)
@@ -230,8 +293,43 @@ function GraphCanvas({
Загрузка связей
</div>
)}
{/* Zoom controls overlay top-right. */}
<div className="absolute top-2 right-2 z-10 inline-flex items-center gap-1 rounded-md border border-line bg-surface/95 backdrop-blur p-0.5 shadow-sm">
{/* Controls overlay top-right. Stack из 2D/3D toggle + zoom (только в 2D). */}
<div className="absolute top-2 right-2 z-10 flex flex-col items-end gap-1">
{/* 2D / 3D mode toggle */}
<div className="inline-flex items-center rounded-md border border-line bg-surface/95 backdrop-blur p-0.5 shadow-sm">
<button
type="button"
onClick={() => onModeChange('2d')}
aria-pressed={mode === '2d'}
title="2D режим — SVG force-directed layout"
className={cn(
'h-7 px-2.5 inline-flex items-center justify-center text-cap font-mono uppercase tracking-wider rounded-sm transition-colors',
mode === '2d'
? 'bg-accent text-white'
: 'text-mute hover:text-ink hover:bg-surface-2',
)}
>
2D
</button>
<button
type="button"
onClick={() => onModeChange('3d')}
aria-pressed={mode === '3d'}
title="3D режим — WebGL three.js (camera orbit)"
className={cn(
'h-7 px-2.5 inline-flex items-center justify-center text-cap font-mono uppercase tracking-wider rounded-sm transition-colors',
mode === '3d'
? 'bg-accent text-white'
: 'text-mute hover:text-ink hover:bg-surface-2',
)}
>
3D
</button>
</div>
{/* Zoom controls — только в 2D, в 3D camera orbit имеет свой wheel-zoom. */}
{mode === '2d' && (
<div className="inline-flex items-center gap-1 rounded-md border border-line bg-surface/95 backdrop-blur p-0.5 shadow-sm">
<button
type="button"
onClick={() =>
@@ -267,8 +365,56 @@ function GraphCanvas({
{Math.round(view.zoom * 100)}%
</span>
</div>
)}
</div>
<svg
{mode === '3d' && (
<Graph3DErrorBoundary
resetKey={size.width + size.height}
onError={() => {
// WebGL undefined / hardware accel off → switch back в 2D.
// Banner показывается ~5s, потом сам исчезает.
onModeChange('2d')
setWebglFallbackNotice(true)
setTimeout(() => setWebglFallbackNotice(false), 5000)
}}
fallback={
<div className="absolute inset-0 flex items-center justify-center p-6">
<div className="text-center max-w-md">
<p className="text-title-md text-ink mb-2">3D-режим недоступен</p>
<p className="text-cell text-mute">
Браузер не смог инициализировать WebGL. Возможно отключено
аппаратное ускорение (chrome://gpu) или используется среда без
поддержки 3D. Возврат к 2D-режиму
</p>
</div>
</div>
}
>
<Suspense
fallback={
<div className="absolute inset-0 flex items-center justify-center">
<LoadingBlock size="lg" label="Загрузка 3D-движка…" />
</div>
}
>
<DictionaryGraph3D
dicts={dicts}
dependentsByDict={dependentsByDict}
width={size.width}
height={size.height}
/>
</Suspense>
</Graph3DErrorBoundary>
)}
{webglFallbackNotice && (
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-20 max-w-md bg-warn/10 border border-warn text-ink-2 text-cell rounded-md px-3 py-2 shadow-sm backdrop-blur">
3D недоступен в этом браузере переключено на 2D.
</div>
)}
{mode === '2d' && <svg
ref={svgRef}
width={size.width}
height={size.height}
@@ -412,7 +558,7 @@ function GraphCanvas({
})}
</g>
{/* === end zoom/pan transform === */}
</svg>
</svg>}
{/* Legend */}
<div className="absolute bottom-3 left-3 flex flex-col gap-1 bg-surface/90 backdrop-blur px-3 py-2 rounded-md border border-line text-cell">
<div className="font-display uppercase tracking-wider text-mute mb-0.5">Bundle</div>
@@ -0,0 +1,159 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import ForceGraph3D, { type ForceGraphMethods } from 'react-force-graph-3d'
import type { DictionaryDefinition, SchemaDependent } from '@/api/client'
/**
* 3D-режим графа справочников через react-force-graph-3d (three.js + d3-force-3d).
*
* Этот компонент динамически импортируется из DictionaryGraph, чтобы three.js
* (~650kb minified) не попадал в default chunk. Lazy-loaded только когда
* пользователь явно переключает в 3D режим.
*
* Поведение:
* - Те же nodes/links что в 2D-моде (общая data shape)
* - Camera orbit controls (drag = rotate, wheel = zoom)
* - Hover tooltip с displayName + record count
* - Click navigate /dictionaries/$name
* - Цвета и размеры узлов идентичны 2D (single source of truth)
* - Directional particles на edges лёгкая визуализация направления FK
*
* Performance: 3D-граф handles 200+ nodes легко (GPU rendering), но default
* configuration уже good enough для current ~37 dicts.
*/
type Node3D = {
id: string
name: string
displayName: string
bundle: string
scope: string
recordCount: number
// Internal three coords — populated by force engine
x?: number
y?: number
z?: number
}
type Link3D = {
source: string | Node3D
target: string | Node3D
sourceField: string
targetField: string
}
const BUNDLE_COLORS: Record<string, string> = {
cuod: '#b05a2e',
geo: '#4f7038',
comms: '#a8762a',
delivery: '#3a2818',
default: '#52483d',
}
const colorForBundle = (bundle: string): string =>
BUNDLE_COLORS[bundle] ?? BUNDLE_COLORS.default
type Props = {
dicts: DictionaryDefinition[]
dependentsByDict: Map<string, SchemaDependent[]>
width: number
height: number
}
export default function DictionaryGraph3D({
dicts,
dependentsByDict,
width,
height,
}: Props) {
const navigate = useNavigate()
const graphRef = useRef<ForceGraphMethods<Node3D, Link3D> | undefined>(undefined)
const [hoverId, setHoverId] = useState<string | null>(null)
// Build graph data — same logic as 2D mode, без simulation refs (three
// worker handles physics).
const graphData = useMemo(() => {
const nodes: Node3D[] = dicts.map((d) => ({
id: d.name,
name: d.name,
displayName: d.displayName ?? d.name,
bundle: d.bundle,
scope: d.scope,
recordCount: d.recordCount ?? 0,
}))
const nodeIds = new Set(nodes.map((n) => n.id))
const links: Link3D[] = []
dicts.forEach((target) => {
const deps = dependentsByDict.get(target.name) ?? []
deps.forEach((dep) => {
if (nodeIds.has(dep.sourceDict)) {
links.push({
source: dep.sourceDict,
target: target.name,
sourceField: dep.sourceField,
targetField: dep.targetField,
})
}
})
})
return { nodes, links }
}, [dicts, dependentsByDict])
// Tune camera + force layout once after mount.
useEffect(() => {
const fg = graphRef.current
if (!fg) return
// Подальше отвести камеру для просторного 3D-обзора.
fg.cameraPosition({ x: 0, y: 0, z: 400 })
}, [])
// CSS variable resolution — three не понимает var(--color-*),
// grab raw hex values from document root для accent + ink.
const accentHex = '#b05a2e'
const lineHex = '#e6e1d4'
return (
<div className="relative w-full h-full">
<ForceGraph3D<Node3D, Link3D>
ref={graphRef}
graphData={graphData}
width={width}
height={height}
backgroundColor="rgba(0,0,0,0)"
nodeId="id"
nodeLabel={(n) =>
`<div style="background:#fff;color:#1a1714;padding:6px 10px;border-radius:4px;border:1px solid ${lineHex};font:13px ui-sans-serif"><strong>${n.displayName}</strong><br/><span style="color:#666;font-size:11px">${n.bundle} · ${n.recordCount} записей</span></div>`
}
nodeColor={(n) => (hoverId === n.id ? accentHex : colorForBundle(n.bundle))}
nodeVal={(n) => 1 + Math.log10((n.recordCount ?? 1) + 1) * 2}
nodeOpacity={hoverId ? 0.85 : 0.95}
nodeResolution={16}
linkColor={(l) => {
const sid = typeof l.source === 'string' ? l.source : (l.source as Node3D).id
const tid = typeof l.target === 'string' ? l.target : (l.target as Node3D).id
return hoverId && (sid === hoverId || tid === hoverId) ? accentHex : lineHex
}}
linkOpacity={hoverId ? 0.4 : 0.5}
linkWidth={(l) => {
const sid = typeof l.source === 'string' ? l.source : (l.source as Node3D).id
const tid = typeof l.target === 'string' ? l.target : (l.target as Node3D).id
return hoverId && (sid === hoverId || tid === hoverId) ? 1.5 : 0.5
}}
linkDirectionalArrowLength={3}
linkDirectionalArrowRelPos={1}
linkDirectionalArrowColor={() => accentHex}
linkDirectionalParticles={(l) => {
const sid = typeof l.source === 'string' ? l.source : (l.source as Node3D).id
const tid = typeof l.target === 'string' ? l.target : (l.target as Node3D).id
return hoverId && (sid === hoverId || tid === hoverId) ? 2 : 0
}}
linkDirectionalParticleSpeed={0.006}
linkDirectionalParticleWidth={2}
onNodeHover={(n) => setHoverId(n?.id ?? null)}
onNodeClick={(n) => navigate({ to: '/dictionaries/$name', params: { name: n.name } })}
// Slight cooldown extension чтобы layout успел осесть.
cooldownTicks={120}
/>
</div>
)
}
+14
View File
@@ -129,6 +129,20 @@ export default defineConfig({
// Axios + парсеры
if (id.match(/node_modules\/(axios|jose|crypto-js)\//)) return 'vendor-net'
// 3D-graph (three.js + react-force-graph-3d + transitive deps).
// Изолирован в свой chunk, чтобы НЕ попасть в vendor-misc, который
// загружается eagerly. DictionaryGraph3D импортируется через
// React.lazy() — Rollup auto-defers этот chunk до первого
// дин-импорта. ~500kb gzipped (~600kb three.js + force graph
// wrappers), грузится только при переключении в 3D режим.
if (
id.match(
/node_modules\/(three|3d-force-graph|d3-force-3d|kapsule|react-kapsule|three-forcegraph|three-render-objects|react-force-graph-3d|@tweenjs)/,
)
) {
return 'vendor-graph3d'
}
// Остальное — в общий vendor (не должно быть много)
return 'vendor-misc'
},