mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 16:17:25 +00:00
feat(kg): replace ReactFlow SVG with react-force-graph-2d Canvas renderer
- Replace ReactFlow (SVG, lags at 80+ nodes) with react-force-graph-2d (Canvas/WebGL, handles 500+ nodes at 60fps) - Custom nodeCanvasObject: colored circle by entity type + name label - Single click: highlight node + neighbors + connected edges with directional particles; dim unconnected nodes to 20% opacity - Double click (timer-based 280ms): open entity detail modal - Edge labels shown only for selected node's connections (background pill) - Increase GRAPH_LIMIT from 80 to 150 (Canvas renderer handles it) - ResizeObserver for responsive container sizing - Zoom-to-fit button in stats bar - Auto zoom-to-fit on engine stop - Dark/light theme support via isDark conditional colors - Remove ReactFlow + D3 force imports (packages kept in package.json)
This commit is contained in:
1 parent
cfc69f6f70
commit
7850ccea01
4 files changed
+420
-241
No files matched your search
@@ -29,6 +29,7 @@
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-force-graph-2d": "^1.29.1",
|
||||
"react-i18next": "^17.0.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.13.2",
|
||||
|
||||
Generated
+182
@@ -65,6 +65,9 @@ importers:
|
||||
react-dom:
|
||||
specifier: ^19.2.4
|
||||
version: 19.2.4(react@19.2.4)
|
||||
react-force-graph-2d:
|
||||
specifier: ^1.29.1
|
||||
version: 1.29.1(react@19.2.4)
|
||||
react-i18next:
|
||||
specifier: ^17.0.1
|
||||
version: 17.0.1(i18next@26.0.1(typescript@6.0.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.2)
|
||||
@@ -1124,6 +1127,9 @@ packages:
|
||||
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@tweenjs/tween.js@25.0.0':
|
||||
resolution: {integrity: sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==}
|
||||
|
||||
'@tybys/wasm-util@0.10.1':
|
||||
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
||||
|
||||
@@ -1235,6 +1241,10 @@ packages:
|
||||
'@xyflow/system@0.0.76':
|
||||
resolution: {integrity: sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==}
|
||||
|
||||
accessor-fn@1.5.3:
|
||||
resolution: {integrity: sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
aria-hidden@1.2.6:
|
||||
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1242,6 +1252,13 @@ packages:
|
||||
bail@2.0.2:
|
||||
resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
|
||||
|
||||
bezier-js@6.1.4:
|
||||
resolution: {integrity: sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==}
|
||||
|
||||
canvas-color-tracker@1.3.2:
|
||||
resolution: {integrity: sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ccount@2.0.1:
|
||||
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
||||
|
||||
@@ -1289,6 +1306,9 @@ 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'}
|
||||
@@ -1305,6 +1325,10 @@ packages:
|
||||
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||
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'}
|
||||
@@ -1317,6 +1341,9 @@ packages:
|
||||
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-path@3.1.0:
|
||||
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -1325,6 +1352,10 @@ packages:
|
||||
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'}
|
||||
@@ -1420,6 +1451,14 @@ packages:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
float-tooltip@1.7.5:
|
||||
resolution: {integrity: sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
force-graph@1.51.2:
|
||||
resolution: {integrity: sha512-zZNdMqx8qIQGurgnbgYIUsdXxSfvhfRSIdncsKGv/twUOZpwCsk9hPHmdjdcme1+epATgb41G0rkIGHJ0Wydng==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
framer-motion@12.38.0:
|
||||
resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==}
|
||||
peerDependencies:
|
||||
@@ -1485,6 +1524,10 @@ packages:
|
||||
immer@11.1.4:
|
||||
resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==}
|
||||
|
||||
index-array-by@1.4.2:
|
||||
resolution: {integrity: sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
@@ -1514,13 +1557,24 @@ packages:
|
||||
isarray@1.0.0:
|
||||
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
||||
|
||||
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
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
jszip@3.10.1:
|
||||
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
||||
|
||||
kapsule@1.16.3:
|
||||
resolution: {integrity: sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
lie@3.3.0:
|
||||
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
|
||||
|
||||
@@ -1598,9 +1652,16 @@ packages:
|
||||
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
|
||||
lodash-es@4.18.1:
|
||||
resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
|
||||
|
||||
longest-streak@3.1.0:
|
||||
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||
hasBin: true
|
||||
|
||||
lowlight@3.3.0:
|
||||
resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==}
|
||||
|
||||
@@ -1758,6 +1819,10 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
pako@1.0.11:
|
||||
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
||||
|
||||
@@ -1779,9 +1844,15 @@ packages:
|
||||
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
preact@10.29.1:
|
||||
resolution: {integrity: sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==}
|
||||
|
||||
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==}
|
||||
|
||||
property-information@7.1.0:
|
||||
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
|
||||
|
||||
@@ -1803,6 +1874,12 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^19.2.4
|
||||
|
||||
react-force-graph-2d@1.29.1:
|
||||
resolution: {integrity: sha512-1Rl/1Z3xy2iTHKj6a0jRXGyiI86xUti81K+jBQZ+Oe46csaMikp47L5AjrzA9hY9fNGD63X8ffrqnvaORukCuQ==}
|
||||
engines: {node: '>=12'}
|
||||
peerDependencies:
|
||||
react: '*'
|
||||
|
||||
react-i18next@17.0.1:
|
||||
resolution: {integrity: sha512-iG65FGnFHcYyHNuT01ukffYWCOBFTWSdVD8EZd/dCVWgtjFPObcSsvYYNwcsokO/rDcTb5d6D8Acv8MrOdm6Hw==}
|
||||
peerDependencies:
|
||||
@@ -1819,9 +1896,18 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
react-is@16.13.1:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
|
||||
react-is@19.2.4:
|
||||
resolution: {integrity: sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==}
|
||||
|
||||
react-kapsule@2.5.7:
|
||||
resolution: {integrity: sha512-kifAF4ZPD77qZKc4CKLmozq6GY1sBzPEJTIJb0wWFK6HsePJatK3jXplZn2eeAt3x67CDozgi7/rO8fNQ/AL7A==}
|
||||
engines: {node: '>=12'}
|
||||
peerDependencies:
|
||||
react: '>=16.13.1'
|
||||
|
||||
react-markdown@10.1.0:
|
||||
resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
|
||||
peerDependencies:
|
||||
@@ -1970,6 +2056,9 @@ packages:
|
||||
tiny-invariant@1.3.3:
|
||||
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||
|
||||
tinycolor2@1.6.0:
|
||||
resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==}
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -3139,6 +3228,8 @@ snapshots:
|
||||
|
||||
'@tanstack/table-core@8.21.3': {}
|
||||
|
||||
'@tweenjs/tween.js@25.0.0': {}
|
||||
|
||||
'@tybys/wasm-util@0.10.1':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -3257,12 +3348,20 @@ snapshots:
|
||||
d3-selection: 3.0.0
|
||||
d3-zoom: 3.0.0
|
||||
|
||||
accessor-fn@1.5.3: {}
|
||||
|
||||
aria-hidden@1.2.6:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
bail@2.0.2: {}
|
||||
|
||||
bezier-js@6.1.4: {}
|
||||
|
||||
canvas-color-tracker@1.3.2:
|
||||
dependencies:
|
||||
tinycolor2: 1.6.0
|
||||
|
||||
ccount@2.0.1: {}
|
||||
|
||||
character-entities-html4@2.1.0: {}
|
||||
@@ -3295,6 +3394,8 @@ snapshots:
|
||||
dependencies:
|
||||
internmap: 2.0.3
|
||||
|
||||
d3-binarytree@1.0.2: {}
|
||||
|
||||
d3-color@3.1.0: {}
|
||||
|
||||
d3-dispatch@3.0.1: {}
|
||||
@@ -3306,6 +3407,14 @@ snapshots:
|
||||
|
||||
d3-ease@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
|
||||
@@ -3318,10 +3427,17 @@ snapshots:
|
||||
dependencies:
|
||||
d3-color: 3.1.0
|
||||
|
||||
d3-octree@1.1.0: {}
|
||||
|
||||
d3-path@3.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
|
||||
@@ -3404,6 +3520,30 @@ snapshots:
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.4
|
||||
|
||||
float-tooltip@1.7.5:
|
||||
dependencies:
|
||||
d3-selection: 3.0.0
|
||||
kapsule: 1.16.3
|
||||
preact: 10.29.1
|
||||
|
||||
force-graph@1.51.2:
|
||||
dependencies:
|
||||
'@tweenjs/tween.js': 25.0.0
|
||||
accessor-fn: 1.5.3
|
||||
bezier-js: 6.1.4
|
||||
canvas-color-tracker: 1.3.2
|
||||
d3-array: 3.2.4
|
||||
d3-drag: 3.0.0
|
||||
d3-force-3d: 3.0.6
|
||||
d3-scale: 4.0.2
|
||||
d3-scale-chromatic: 3.1.0
|
||||
d3-selection: 3.0.0
|
||||
d3-zoom: 3.0.0
|
||||
float-tooltip: 1.7.5
|
||||
index-array-by: 1.4.2
|
||||
kapsule: 1.16.3
|
||||
lodash-es: 4.18.1
|
||||
|
||||
framer-motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
motion-dom: 12.38.0
|
||||
@@ -3475,6 +3615,8 @@ snapshots:
|
||||
|
||||
immer@11.1.4: {}
|
||||
|
||||
index-array-by@1.4.2: {}
|
||||
|
||||
inherits@2.0.4: {}
|
||||
|
||||
inline-style-parser@0.2.7: {}
|
||||
@@ -3496,8 +3638,12 @@ snapshots:
|
||||
|
||||
isarray@1.0.0: {}
|
||||
|
||||
jerrypick@1.1.2: {}
|
||||
|
||||
jiti@2.6.1: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
jszip@3.10.1:
|
||||
dependencies:
|
||||
lie: 3.3.0
|
||||
@@ -3505,6 +3651,10 @@ snapshots:
|
||||
readable-stream: 2.3.8
|
||||
setimmediate: 1.0.5
|
||||
|
||||
kapsule@1.16.3:
|
||||
dependencies:
|
||||
lodash-es: 4.18.1
|
||||
|
||||
lie@3.3.0:
|
||||
dependencies:
|
||||
immediate: 3.0.6
|
||||
@@ -3558,8 +3708,14 @@ snapshots:
|
||||
lightningcss-win32-arm64-msvc: 1.32.0
|
||||
lightningcss-win32-x64-msvc: 1.32.0
|
||||
|
||||
lodash-es@4.18.1: {}
|
||||
|
||||
longest-streak@3.1.0: {}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
dependencies:
|
||||
js-tokens: 4.0.0
|
||||
|
||||
lowlight@3.3.0:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
@@ -3930,6 +4086,8 @@ snapshots:
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
pako@1.0.11: {}
|
||||
|
||||
parse-entities@4.0.2:
|
||||
@@ -3957,8 +4115,16 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
preact@10.29.1: {}
|
||||
|
||||
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
|
||||
|
||||
property-information@7.1.0: {}
|
||||
|
||||
radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
@@ -4029,6 +4195,13 @@ snapshots:
|
||||
react: 19.2.4
|
||||
scheduler: 0.27.0
|
||||
|
||||
react-force-graph-2d@1.29.1(react@19.2.4):
|
||||
dependencies:
|
||||
force-graph: 1.51.2
|
||||
prop-types: 15.8.1
|
||||
react: 19.2.4
|
||||
react-kapsule: 2.5.7(react@19.2.4)
|
||||
|
||||
react-i18next@17.0.1(i18next@26.0.1(typescript@6.0.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.2):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
@@ -4040,8 +4213,15 @@ snapshots:
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
typescript: 6.0.2
|
||||
|
||||
react-is@16.13.1: {}
|
||||
|
||||
react-is@19.2.4: {}
|
||||
|
||||
react-kapsule@2.5.7(react@19.2.4):
|
||||
dependencies:
|
||||
jerrypick: 1.1.2
|
||||
react: 19.2.4
|
||||
|
||||
react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4):
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
@@ -4247,6 +4427,8 @@ snapshots:
|
||||
|
||||
tiny-invariant@1.3.3: {}
|
||||
|
||||
tinycolor2@1.6.0: {}
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
|
||||
@@ -140,7 +140,7 @@ export function useKGGraph(agentId: string, userId?: string) {
|
||||
queryKey: queryKeys.kg.graph(agentId, userId),
|
||||
queryFn: async () => {
|
||||
if (!agentId) return { entities: [], relations: [] };
|
||||
const params: Record<string, string> = { limit: "80" };
|
||||
const params: Record<string, string> = { limit: "150" };
|
||||
if (userId) params.user_id = userId;
|
||||
return http.get<KGGraphData>(`/v1/agents/${agentId}/kg/graph`, params);
|
||||
},
|
||||
|
||||
@@ -1,50 +1,22 @@
|
||||
import { useMemo, useEffect, useCallback, useState, useTransition, memo } from "react";
|
||||
import {
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
useReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
type Node,
|
||||
type Edge,
|
||||
type ColorMode,
|
||||
Handle,
|
||||
Position,
|
||||
} from "@xyflow/react";
|
||||
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, forceX, forceY, type SimulationNodeDatum } from "d3-force";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { useRef, useMemo, useCallback, useState, useEffect } from "react";
|
||||
import ForceGraph2D, { type ForceGraphMethods } from "react-force-graph-2d";
|
||||
import { Maximize2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useUiStore } from "@/stores/use-ui-store";
|
||||
import type { KGEntity, KGRelation } from "@/types/knowledge-graph";
|
||||
|
||||
const GRAPH_LIMIT = 80;
|
||||
const GRAPH_LIMIT = 150;
|
||||
const NODE_R = 5;
|
||||
const DOUBLE_CLICK_MS = 280;
|
||||
|
||||
// Dual-theme palette — separate dark/light values for readability on both backgrounds
|
||||
interface TypeColor {
|
||||
border: string;
|
||||
dark: { bg: string; text: string };
|
||||
light: { bg: string; text: string };
|
||||
}
|
||||
const TYPE_COLORS: Record<string, TypeColor> = {
|
||||
person: { border: "#E85D24", dark: { bg: "rgba(232,93,36,0.15)", text: "#f4a574" }, light: { bg: "#fde8d8", text: "#7a2610" } },
|
||||
organization: { border: "#ef4444", dark: { bg: "rgba(239,68,68,0.15)", text: "#fca5a5" }, light: { bg: "#fee2e2", text: "#991b1b" } },
|
||||
project: { border: "#22c55e", dark: { bg: "rgba(34,197,94,0.15)", text: "#86efac" }, light: { bg: "#dcfce7", text: "#166534" } },
|
||||
product: { border: "#f97316", dark: { bg: "rgba(249,115,22,0.15)", text: "#fdba74" }, light: { bg: "#ffedd5", text: "#9a3412" } },
|
||||
technology: { border: "#3b82f6", dark: { bg: "rgba(59,130,246,0.15)", text: "#93c5fd" }, light: { bg: "#dbeafe", text: "#1e40af" } },
|
||||
task: { border: "#f59e0b", dark: { bg: "rgba(245,158,11,0.15)", text: "#fcd34d" }, light: { bg: "#fef3c7", text: "#92400e" } },
|
||||
event: { border: "#ec4899", dark: { bg: "rgba(236,72,153,0.15)", text: "#f9a8d4" }, light: { bg: "#fce7f3", text: "#9d174d" } },
|
||||
document: { border: "#8b5cf6", dark: { bg: "rgba(139,92,246,0.15)", text: "#c4b5fd" }, light: { bg: "#ede9fe", text: "#6d28d9" } },
|
||||
concept: { border: "#a78bfa", dark: { bg: "rgba(167,139,250,0.12)", text: "#c4b5fd" }, light: { bg: "#ede9fe", text: "#5b21b6" } },
|
||||
location: { border: "#14b8a6", dark: { bg: "rgba(20,184,166,0.15)", text: "#5eead4" }, light: { bg: "#ccfbf1", text: "#115e59" } },
|
||||
// Solid colors per entity type — used for both circle fill and glow
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
person: "#E85D24", organization: "#ef4444", project: "#22c55e",
|
||||
product: "#f97316", technology: "#3b82f6", task: "#f59e0b",
|
||||
event: "#ec4899", document: "#8b5cf6", concept: "#a78bfa", location: "#14b8a6",
|
||||
};
|
||||
const DEFAULT_TC: TypeColor = { border: "#9ca3af", dark: { bg: "rgba(156,163,175,0.12)", text: "#d1d5db" }, light: { bg: "#f3f4f6", text: "#374151" } };
|
||||
|
||||
const TYPE_MASS: Record<string, number> = {
|
||||
organization: 8, project: 6, product: 5, person: 4, technology: 3.5, concept: 3, location: 3, document: 2.5, event: 2, task: 1.5,
|
||||
};
|
||||
const DEFAULT_MASS = 2;
|
||||
const DEFAULT_COLOR = "#9ca3af";
|
||||
|
||||
function computeDegreeMap(entities: KGEntity[], relations: KGRelation[]): Map<string, number> {
|
||||
const deg = new Map<string, number>();
|
||||
@@ -56,89 +28,24 @@ function computeDegreeMap(entities: KGEntity[], relations: KGRelation[]): Map<st
|
||||
return deg;
|
||||
}
|
||||
|
||||
const HANDLE_STYLE = { opacity: 0, width: 0, height: 0, pointerEvents: "none" as const };
|
||||
|
||||
// memo prevents re-render during pan/zoom — only re-renders when data changes
|
||||
const EntityNode = memo(function EntityNode({ data }: { data: { label: string; type: string; degree: number; isDark: boolean } }) {
|
||||
const tc = TYPE_COLORS[data.type] || DEFAULT_TC;
|
||||
const t = data.isDark ? tc.dark : tc.light;
|
||||
const isHub = data.degree >= 4;
|
||||
return (
|
||||
<div
|
||||
className="px-4 py-1.5 cursor-grab"
|
||||
style={{
|
||||
background: t.bg,
|
||||
border: `2px solid ${tc.border}`,
|
||||
borderRadius: 20,
|
||||
boxShadow: isHub ? `0 0 8px ${tc.border}40` : undefined,
|
||||
}}
|
||||
>
|
||||
<Handle type="target" position={Position.Top} style={HANDLE_STYLE} />
|
||||
<Handle type="source" position={Position.Bottom} style={HANDLE_STYLE} />
|
||||
<div className="text-[11px] font-semibold whitespace-nowrap" style={{ color: t.text }}>
|
||||
{data.label}
|
||||
</div>
|
||||
<div className="text-[9px] text-center" style={{ color: t.text, opacity: 0.6 }}>
|
||||
{data.type}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const nodeTypes = { entity: EntityNode };
|
||||
|
||||
interface SimNode extends SimulationNodeDatum { id: string; mass: number }
|
||||
|
||||
function buildGraph(entities: KGEntity[], relations: KGRelation[], isDark: boolean) {
|
||||
const entityIds = new Set(entities.map((e) => e.id));
|
||||
const degreeMap = computeDegreeMap(entities, relations);
|
||||
const nodes: Node[] = entities.map((e) => ({
|
||||
id: e.id, type: "entity", position: { x: 0, y: 0 },
|
||||
data: { label: e.name, type: e.entity_type, degree: degreeMap.get(e.id) ?? 0, isDark },
|
||||
}));
|
||||
// Edge color handled by ReactFlow colorMode — use neutral gray
|
||||
const edgeColor = "#64748b";
|
||||
const edges: Edge[] = relations
|
||||
.filter((r) => entityIds.has(r.source_entity_id) && entityIds.has(r.target_entity_id))
|
||||
.map((r) => ({
|
||||
id: r.id, source: r.source_entity_id, target: r.target_entity_id,
|
||||
label: undefined, data: { relationLabel: r.relation_type.replace(/_/g, " ") },
|
||||
animated: false, type: "default",
|
||||
style: { stroke: edgeColor, strokeWidth: 2, opacity: 0.6 },
|
||||
}));
|
||||
return { nodes, edges };
|
||||
// --- Graph data types for react-force-graph ---
|
||||
interface GraphNode {
|
||||
id: string;
|
||||
name: string;
|
||||
entityType: string;
|
||||
color: string;
|
||||
neighbors: Set<string>;
|
||||
linkIds: Set<string>;
|
||||
degree: number;
|
||||
// Force-graph adds these at runtime
|
||||
x?: number;
|
||||
y?: number;
|
||||
}
|
||||
|
||||
function computeForceLayout(nodes: Node[], edges: Edge[], entities: KGEntity[]): Node[] {
|
||||
if (nodes.length === 0) return nodes;
|
||||
const entityTypeMap = new Map(entities.map((e) => [e.id, e.entity_type]));
|
||||
const simNodes: SimNode[] = nodes.map((n) => ({
|
||||
id: n.id, x: n.position.x, y: n.position.y,
|
||||
mass: TYPE_MASS[entityTypeMap.get(n.id) ?? ""] ?? DEFAULT_MASS,
|
||||
}));
|
||||
const simLinks = edges.map((e) => ({ source: e.source, target: e.target }));
|
||||
const w = 600, h = 400;
|
||||
// Scale forces by node count — tighter for small graphs, spread for large
|
||||
const n = nodes.length;
|
||||
const linkDist = n > 150 ? 280 : n > 100 ? 240 : n > 60 ? 200 : n > 30 ? 160 : 120;
|
||||
const chargeMul = n > 150 ? -350 : n > 100 ? -300 : n > 60 ? -250 : n > 30 ? -180 : -120;
|
||||
const centerPull = n > 150 ? 0.015 : n > 100 ? 0.02 : n > 60 ? 0.03 : n > 30 ? 0.05 : 0.08;
|
||||
const collideBase = n > 150 ? 60 : n > 100 ? 55 : n > 60 ? 50 : n > 30 ? 40 : 35;
|
||||
// Barnes-Hut theta: higher = faster approximation, slightly less accurate
|
||||
const theta = n > 60 ? 1.2 : 0.9;
|
||||
const charge = forceManyBody().strength((d: any) => chargeMul * (d.mass ?? 1)).theta(theta);
|
||||
const simulation = forceSimulation(simNodes)
|
||||
.force("link", forceLink(simLinks).id((d: any) => d.id).distance(linkDist).strength(0.5))
|
||||
.force("charge", charge)
|
||||
.force("center", forceCenter(w / 2, h / 2))
|
||||
.force("x", forceX(w / 2).strength(centerPull))
|
||||
.force("y", forceY(h / 2).strength(centerPull))
|
||||
.force("collide", forceCollide().radius((d: any) => collideBase + (d.mass ?? 1) * 3).strength(0.7))
|
||||
.alphaDecay(n > 60 ? 0.05 : 0.0228) // faster convergence for large graphs
|
||||
.stop();
|
||||
const ticks = Math.ceil(Math.log(simulation.alphaMin()) / Math.log(1 - simulation.alphaDecay()));
|
||||
for (let i = 0; i < ticks; i++) simulation.tick();
|
||||
return nodes.map((n, i) => ({ ...n, position: { x: simNodes[i]!.x ?? 0, y: simNodes[i]!.y ?? 0 } }));
|
||||
interface GraphLink {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface KGGraphViewProps {
|
||||
@@ -147,151 +54,236 @@ interface KGGraphViewProps {
|
||||
onEntityClick?: (entity: KGEntity) => void;
|
||||
}
|
||||
|
||||
export function KGGraphView(props: KGGraphViewProps) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<KGGraphViewInner {...props} />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function KGGraphViewInner({ entities: allEntities, relations: allRelations, onEntityClick }: KGGraphViewProps) {
|
||||
export function KGGraphView({ entities: allEntities, relations: allRelations, onEntityClick }: KGGraphViewProps) {
|
||||
const { t } = useTranslation("memory");
|
||||
const { fitView } = useReactFlow();
|
||||
const theme = useUiStore((s) => s.theme);
|
||||
const colorMode: ColorMode = theme === "system" ? "system" : theme;
|
||||
const isDark = theme === "dark" || (theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
|
||||
const graphRef = useRef<ForceGraphMethods<GraphNode, GraphLink>>(undefined);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dimensions, setDimensions] = useState({ width: 600, height: 400 });
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const [layoutReady, setLayoutReady] = useState(false);
|
||||
|
||||
// Limit entities to GRAPH_LIMIT, filter relations accordingly
|
||||
// Double-click detection refs
|
||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastClickRef = useRef<string | null>(null);
|
||||
|
||||
// --- Container sizing via ResizeObserver ---
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
if (!entry) return;
|
||||
setDimensions({ width: entry.contentRect.width, height: entry.contentRect.height });
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// --- Limit entities by degree centrality ---
|
||||
const totalCount = allEntities.length;
|
||||
const isLimited = totalCount > GRAPH_LIMIT;
|
||||
// Rank by degree centrality — hub nodes (most connections) always included in graph
|
||||
const entities = useMemo(() => {
|
||||
if (!isLimited) return allEntities;
|
||||
const deg = computeDegreeMap(allEntities, allRelations);
|
||||
return [...allEntities].sort((a, b) => (deg.get(b.id) ?? 0) - (deg.get(a.id) ?? 0)).slice(0, GRAPH_LIMIT);
|
||||
}, [allEntities, allRelations, isLimited]);
|
||||
const entityIds = useMemo(() => new Set(entities.map((e) => e.id)), [entities]);
|
||||
const relations = useMemo(
|
||||
() => allRelations.filter((r) => entityIds.has(r.source_entity_id) && entityIds.has(r.target_entity_id)),
|
||||
[allRelations, entityIds],
|
||||
);
|
||||
|
||||
// Build graph — isDark only affects node data colors, not layout
|
||||
const { rawNodes, rawEdges } = useMemo(() => {
|
||||
const { nodes, edges } = buildGraph(entities, relations, isDark);
|
||||
return { rawNodes: nodes, rawEdges: edges };
|
||||
}, [entities, relations, isDark]);
|
||||
|
||||
const [layoutNodes, setLayoutNodes] = useState<Node[]>([]);
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(layoutNodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(rawEdges);
|
||||
|
||||
// Track which entity set was laid out — only re-layout when data changes, not theme
|
||||
const [layoutKey, setLayoutKey] = useState("");
|
||||
const dataKey = useMemo(() => entities.map((e) => e.id).join(","), [entities]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dataKey === layoutKey) {
|
||||
// Theme change only — update node colors without re-layout
|
||||
const freshMap = new Map(rawNodes.map((r) => [r.id, r.data]));
|
||||
setNodes((prev) => prev.map((n) => {
|
||||
const data = freshMap.get(n.id);
|
||||
return data ? { ...n, data } : n;
|
||||
}));
|
||||
return;
|
||||
}
|
||||
// Data changed — full re-layout
|
||||
setLayoutReady(false);
|
||||
const timer = setTimeout(() => {
|
||||
const positioned = computeForceLayout(rawNodes, rawEdges, entities);
|
||||
setLayoutNodes(positioned);
|
||||
setNodes(positioned);
|
||||
setLayoutKey(dataKey);
|
||||
setLayoutReady(true);
|
||||
requestAnimationFrame(() => fitView({ padding: 0.15, duration: 300 }));
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}, [rawNodes, rawEdges, entities, dataKey, layoutKey, setNodes, fitView]);
|
||||
|
||||
useEffect(() => { setEdges(rawEdges); }, [rawEdges, setEdges]);
|
||||
|
||||
// Selection: highlight connected edges, dim others
|
||||
const entityMap = useMemo(() => new Map(entities.map((e) => [e.id, e])), [entities]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedNodeId) {
|
||||
setEdges((eds) => eds.map((e) => ({
|
||||
...e, label: undefined, animated: false,
|
||||
style: { stroke: "#64748b", strokeWidth: 2, opacity: 0.6 },
|
||||
labelStyle: undefined, labelBgStyle: undefined, labelBgPadding: undefined, labelShowBg: undefined,
|
||||
})));
|
||||
return;
|
||||
}
|
||||
const ent = entityMap.get(selectedNodeId);
|
||||
const tc = TYPE_COLORS[ent?.entity_type ?? ""] || DEFAULT_TC;
|
||||
const labelColor = isDark ? tc.dark.text : tc.light.text;
|
||||
const fadedEdge = isDark ? "#1e293b" : "#e2e8f0";
|
||||
const labelBg = isDark ? "rgba(15,23,42,0.85)" : "rgba(255,255,255,0.9)";
|
||||
setEdges((eds) => eds.map((e) => {
|
||||
const connected = e.source === selectedNodeId || e.target === selectedNodeId;
|
||||
if (!connected) return { ...e, label: undefined, animated: false, style: { stroke: fadedEdge, strokeWidth: 0.5, opacity: 0.25 } };
|
||||
return {
|
||||
...e,
|
||||
label: (e.data as { relationLabel?: string })?.relationLabel,
|
||||
animated: true,
|
||||
style: { stroke: tc.border, strokeWidth: 3, opacity: 0.9 },
|
||||
labelStyle: { fontSize: 9, fill: labelColor, fontWeight: 500 },
|
||||
labelBgStyle: { fill: labelBg, stroke: tc.border, rx: 4 },
|
||||
labelBgPadding: [4, 2] as [number, number],
|
||||
labelShowBg: true,
|
||||
};
|
||||
// --- Build graph data for react-force-graph ---
|
||||
const graphData = useMemo(() => {
|
||||
const entityIds = new Set(entities.map((e) => e.id));
|
||||
const degreeMap = computeDegreeMap(entities, allRelations);
|
||||
|
||||
const nodes: GraphNode[] = entities.map((e) => ({
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
entityType: e.entity_type,
|
||||
color: TYPE_COLORS[e.entity_type] ?? DEFAULT_COLOR,
|
||||
neighbors: new Set<string>(),
|
||||
linkIds: new Set<string>(),
|
||||
degree: degreeMap.get(e.id) ?? 0,
|
||||
}));
|
||||
}, [selectedNodeId, entityMap, isDark, setEdges]);
|
||||
|
||||
const [, startTransition] = useTransition();
|
||||
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
|
||||
|
||||
// Single click: highlight connected edges only
|
||||
const handleNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
|
||||
startTransition(() => setSelectedNodeId((prev) => (prev === node.id ? null : node.id)));
|
||||
}, [startTransition]);
|
||||
const links: GraphLink[] = allRelations
|
||||
.filter((r) => entityIds.has(r.source_entity_id) && entityIds.has(r.target_entity_id))
|
||||
.map((r) => {
|
||||
nodeMap.get(r.source_entity_id)?.neighbors.add(r.target_entity_id);
|
||||
nodeMap.get(r.target_entity_id)?.neighbors.add(r.source_entity_id);
|
||||
nodeMap.get(r.source_entity_id)?.linkIds.add(r.id);
|
||||
nodeMap.get(r.target_entity_id)?.linkIds.add(r.id);
|
||||
return {
|
||||
id: r.id,
|
||||
source: r.source_entity_id,
|
||||
target: r.target_entity_id,
|
||||
label: r.relation_type.replace(/_/g, " "),
|
||||
};
|
||||
});
|
||||
|
||||
// Double click: open entity detail modal
|
||||
const handleNodeDoubleClick = useCallback((_: React.MouseEvent, node: Node) => {
|
||||
const entity = entityMap.get(node.id);
|
||||
if (entity && onEntityClick) onEntityClick(entity);
|
||||
}, [entityMap, onEntityClick]);
|
||||
return { nodes, links };
|
||||
}, [entities, allRelations]);
|
||||
|
||||
const handlePaneClick = useCallback(() => {
|
||||
startTransition(() => setSelectedNodeId(null));
|
||||
}, [startTransition]);
|
||||
// --- Highlight sets derived from selected node ---
|
||||
const { highlightNodes, highlightLinks } = useMemo(() => {
|
||||
if (!selectedNodeId) return { highlightNodes: new Set<string>(), highlightLinks: new Set<string>() };
|
||||
const node = graphData.nodes.find((n) => n.id === selectedNodeId);
|
||||
if (!node) return { highlightNodes: new Set<string>(), highlightLinks: new Set<string>() };
|
||||
return {
|
||||
highlightNodes: new Set([selectedNodeId, ...node.neighbors]),
|
||||
highlightLinks: new Set(node.linkIds),
|
||||
};
|
||||
}, [selectedNodeId, graphData.nodes]);
|
||||
|
||||
// --- Node Canvas rendering: colored circle + name label ---
|
||||
const nodeCanvasObject = useCallback(
|
||||
(node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
const x = node.x ?? 0;
|
||||
const y = node.y ?? 0;
|
||||
const isSelected = node.id === selectedNodeId;
|
||||
const isNeighbor = highlightNodes.has(node.id);
|
||||
const isDimmed = !!selectedNodeId && !isSelected && !isNeighbor;
|
||||
const r = NODE_R + (node.degree >= 4 ? 2 : 0);
|
||||
|
||||
// Circle fill
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, r, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = isDimmed ? `${node.color}30` : node.color;
|
||||
ctx.fill();
|
||||
|
||||
// Selection glow ring
|
||||
if (isSelected) {
|
||||
ctx.strokeStyle = node.color;
|
||||
ctx.lineWidth = 2.5 / globalScale;
|
||||
ctx.shadowColor = node.color;
|
||||
ctx.shadowBlur = 8 / globalScale;
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
} else if (isNeighbor) {
|
||||
ctx.strokeStyle = node.color;
|
||||
ctx.lineWidth = 1.5 / globalScale;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Name label — show when zoomed enough or highlighted
|
||||
if (globalScale > 0.5 || isSelected || isNeighbor) {
|
||||
const fontSize = Math.max(11 / globalScale, 2);
|
||||
ctx.font = `${isSelected ? "bold " : ""}${fontSize}px Inter, system-ui, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "top";
|
||||
ctx.fillStyle = isDimmed
|
||||
? (isDark ? "rgba(255,255,255,0.12)" : "rgba(0,0,0,0.12)")
|
||||
: (isDark ? "#e2e8f0" : "#1e293b");
|
||||
ctx.fillText(node.name, x, y + r + 2 / globalScale);
|
||||
}
|
||||
},
|
||||
[selectedNodeId, highlightNodes, isDark],
|
||||
);
|
||||
|
||||
// --- Link canvas overlay: draw label text only for highlighted links ---
|
||||
const linkCanvasObject = useCallback(
|
||||
(link: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
if (!highlightLinks.has(link.id)) return;
|
||||
const src = link.source;
|
||||
const tgt = link.target;
|
||||
if (!src?.x || !tgt?.x) return;
|
||||
const midX = (src.x + tgt.x) / 2;
|
||||
const midY = (src.y + tgt.y) / 2;
|
||||
const fontSize = Math.max(9 / globalScale, 2);
|
||||
ctx.font = `${fontSize}px Inter, system-ui, sans-serif`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
// Background pill for readability
|
||||
const text = link.label || "";
|
||||
const metrics = ctx.measureText(text);
|
||||
const pad = 2 / globalScale;
|
||||
ctx.fillStyle = isDark ? "rgba(15,23,42,0.85)" : "rgba(255,255,255,0.9)";
|
||||
ctx.fillRect(midX - metrics.width / 2 - pad, midY - fontSize / 2 - pad, metrics.width + pad * 2, fontSize + pad * 2);
|
||||
// Text
|
||||
ctx.fillStyle = isDark ? "#94a3b8" : "#64748b";
|
||||
ctx.fillText(text, midX, midY);
|
||||
},
|
||||
[highlightLinks, isDark],
|
||||
);
|
||||
|
||||
// --- Click handlers: single (highlight) vs double (open detail) ---
|
||||
const handleNodeClick = useCallback(
|
||||
(node: GraphNode) => {
|
||||
if (clickTimerRef.current && lastClickRef.current === node.id) {
|
||||
// Double click → open detail modal
|
||||
clearTimeout(clickTimerRef.current);
|
||||
clickTimerRef.current = null;
|
||||
lastClickRef.current = null;
|
||||
const entity = entityMap.get(node.id);
|
||||
if (entity && onEntityClick) onEntityClick(entity);
|
||||
return;
|
||||
}
|
||||
// Single click → highlight (after delay to distinguish from double)
|
||||
if (clickTimerRef.current) clearTimeout(clickTimerRef.current);
|
||||
lastClickRef.current = node.id;
|
||||
clickTimerRef.current = setTimeout(() => {
|
||||
setSelectedNodeId((prev) => (prev === node.id ? null : node.id));
|
||||
clickTimerRef.current = null;
|
||||
}, DOUBLE_CLICK_MS);
|
||||
},
|
||||
[entityMap, onEntityClick],
|
||||
);
|
||||
|
||||
const handleBackgroundClick = useCallback(() => {
|
||||
setSelectedNodeId(null);
|
||||
}, []);
|
||||
|
||||
// --- Zoom to fit after engine stops ---
|
||||
const handleEngineStop = useCallback(() => {
|
||||
graphRef.current?.zoomToFit(300, 30);
|
||||
}, []);
|
||||
|
||||
// --- Empty state ---
|
||||
if (allEntities.length === 0) {
|
||||
return <div className="flex h-full items-center justify-center text-sm text-muted-foreground">{t("kg.graphView.empty")}</div>;
|
||||
}
|
||||
|
||||
if (!layoutReady) {
|
||||
return <div className="flex h-full items-center justify-center bg-background"><div className="text-sm text-muted-foreground">{t("kg.graphView.loading")}</div></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col rounded-md border overflow-hidden bg-background">
|
||||
<div className="min-h-0 flex-1">
|
||||
<ReactFlow
|
||||
nodes={nodes} edges={edges}
|
||||
onNodesChange={onNodesChange} onEdgesChange={onEdgesChange}
|
||||
onNodeClick={handleNodeClick} onNodeDoubleClick={handleNodeDoubleClick} onPaneClick={handlePaneClick}
|
||||
nodeTypes={nodeTypes} colorMode={colorMode}
|
||||
minZoom={0.1} maxZoom={3}
|
||||
nodesConnectable={false} nodesDraggable={true} elementsSelectable={true}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background gap={20} size={1} />
|
||||
<Controls showInteractive={false} />
|
||||
</ReactFlow>
|
||||
<div ref={containerRef} className="min-h-0 flex-1 relative">
|
||||
<ForceGraph2D
|
||||
ref={graphRef}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
graphData={graphData}
|
||||
nodeId="id"
|
||||
nodeCanvasObject={nodeCanvasObject}
|
||||
nodeCanvasObjectMode={() => "replace"}
|
||||
nodePointerAreaPaint={(node: GraphNode, color, ctx) => {
|
||||
const r = NODE_R + (node.degree >= 4 ? 2 : 0) + 3;
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x ?? 0, node.y ?? 0, r, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
}}
|
||||
linkColor={(link: any) =>
|
||||
highlightLinks.has(link.id) ? "#64748b" : (isDark ? "#334155" : "#cbd5e1")
|
||||
}
|
||||
linkWidth={(link: any) => (highlightLinks.has(link.id) ? 2 : 0.5)}
|
||||
linkCanvasObject={linkCanvasObject}
|
||||
linkCanvasObjectMode={(link: any) => (highlightLinks.has(link.id) ? "after" : undefined)}
|
||||
linkDirectionalParticles={(link: any) => (highlightLinks.has(link.id) ? 2 : 0)}
|
||||
linkDirectionalParticleWidth={3}
|
||||
linkDirectionalParticleSpeed={0.004}
|
||||
onNodeClick={handleNodeClick}
|
||||
onBackgroundClick={handleBackgroundClick}
|
||||
onEngineStop={handleEngineStop}
|
||||
backgroundColor="transparent"
|
||||
d3AlphaDecay={0.04}
|
||||
d3VelocityDecay={0.3}
|
||||
warmupTicks={40}
|
||||
cooldownTime={4000}
|
||||
enableNodeDrag={true}
|
||||
minZoom={0.1}
|
||||
maxZoom={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stats bar */}
|
||||
@@ -303,6 +295,10 @@ function KGGraphViewInner({ entities: allEntities, relations: allRelations, onEn
|
||||
· {t("kg.graphView.limitNote", { limit: GRAPH_LIMIT, total: totalCount })}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5" onClick={() => graphRef.current?.zoomToFit(300, 20)}>
|
||||
<Maximize2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user