mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 06:16:10 +00:00
feat(ui/graph): migrate vault + KG graph to Sigma.js + Graphology
Replace react-force-graph-2d (Canvas 2D, 500-node ceiling) with Sigma.js (WebGL, 5k+ nodes at 60fps) for vault and knowledge graph visualization. Layout pipeline uses Louvain community detection + grid seeding + FA2 refinement + noverlap post-processing. Communities are placed in a grid (sorted by size) so related nodes cluster visually instead of collapsing into a ring shape. Shared graph infrastructure in ui/web/src/components/graph/: - sigma-graph-container: lifecycle, synchronous pre-computed layout, 2-hop BFS highlight on hover, opacity edge pulse, prefers-reduced-motion - sigma-graph-controls: reusable zoom/stats bar with camera tracking - sigma-graph-search: combobox with ARIA wiring and keyboard navigation - sigma-graph-filters: type filter dropdown (outside-click + Escape) - sigma-graph-minimap: canvas overlay with viewport rect + click-to-jump - sigma-graph-keyboard-help: help popover triggered by ? key - use-sigma-keyboard: +/- R F Esc / shortcuts - graph-utils: sizing, middle-truncation helpers Adapters rewritten to output Graphology Graph instances. Both views use curved arrow edges, focus ring for keyboard users, role="application" ARIA, responsive top bar, and node limits up to 5k. Removes react-force-graph-2d, d3-force, @types/d3-force. Adds sigma, @sigma/edge-curve, graphology, graphology-layout, graphology-layout-forceatlas2, graphology-layout-noverlap, graphology-communities-louvain, graphology-types.
This commit is contained in:
1 parent
2731f99ad5
commit
79b5dba5c7
16 files changed
+1626
-786
No files matched your search
+8
-3
@@ -14,14 +14,20 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@sigma/edge-curve": "^3.1.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/react-query": "^5.95.2",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"d3-force": "^3.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^12.38.0",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-communities-louvain": "^2.0.2",
|
||||
"graphology-layout": "^0.6.1",
|
||||
"graphology-layout-forceatlas2": "^0.10.1",
|
||||
"graphology-layout-noverlap": "^0.4.2",
|
||||
"graphology-types": "^0.24.8",
|
||||
"highlight.js": "^11.11.1",
|
||||
"i18next": "^26.0.1",
|
||||
"jszip": "^3.10.1",
|
||||
@@ -30,7 +36,6 @@
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-force-graph-2d": "^1.29.1",
|
||||
"react-hook-form": "^7.72.1",
|
||||
"react-i18next": "^17.0.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -40,6 +45,7 @@
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"sigma": "^3.0.2",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"unist-util-visit": "^5.1.0",
|
||||
"zod": "^4.3.6",
|
||||
@@ -47,7 +53,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/d3-force": "^3.0.10",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
||||
Generated
+145
-172
@@ -20,6 +20,9 @@ importers:
|
||||
'@hookform/resolvers':
|
||||
specifier: ^5.2.2
|
||||
version: 5.2.2(react-hook-form@7.72.1(react@19.2.4))
|
||||
'@sigma/edge-curve':
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.0(sigma@3.0.2(graphology-types@0.24.8))
|
||||
'@tailwindcss/typography':
|
||||
specifier: ^0.5.19
|
||||
version: 0.5.19(tailwindcss@4.2.2)
|
||||
@@ -35,15 +38,30 @@ importers:
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
d3-force:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
date-fns:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
framer-motion:
|
||||
specifier: ^12.38.0
|
||||
version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
graphology:
|
||||
specifier: ^0.26.0
|
||||
version: 0.26.0(graphology-types@0.24.8)
|
||||
graphology-communities-louvain:
|
||||
specifier: ^2.0.2
|
||||
version: 2.0.2(graphology-types@0.24.8)
|
||||
graphology-layout:
|
||||
specifier: ^0.6.1
|
||||
version: 0.6.1(graphology-types@0.24.8)
|
||||
graphology-layout-forceatlas2:
|
||||
specifier: ^0.10.1
|
||||
version: 0.10.1(graphology-types@0.24.8)
|
||||
graphology-layout-noverlap:
|
||||
specifier: ^0.4.2
|
||||
version: 0.4.2(graphology-types@0.24.8)
|
||||
graphology-types:
|
||||
specifier: ^0.24.8
|
||||
version: 0.24.8
|
||||
highlight.js:
|
||||
specifier: ^11.11.1
|
||||
version: 11.11.1
|
||||
@@ -68,9 +86,6 @@ 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-hook-form:
|
||||
specifier: ^7.72.1
|
||||
version: 7.72.1(react@19.2.4)
|
||||
@@ -98,6 +113,9 @@ importers:
|
||||
remark-math:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
sigma:
|
||||
specifier: ^3.0.2
|
||||
version: 3.0.2(graphology-types@0.24.8)
|
||||
tailwind-merge:
|
||||
specifier: ^3.5.0
|
||||
version: 3.5.0
|
||||
@@ -114,9 +132,6 @@ importers:
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.2.2
|
||||
version: 4.2.2(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(jiti@2.6.1))
|
||||
'@types/d3-force':
|
||||
specifier: ^3.0.10
|
||||
version: 3.0.10
|
||||
'@types/mdast':
|
||||
specifier: ^4.0.4
|
||||
version: 4.0.4
|
||||
@@ -1062,6 +1077,11 @@ packages:
|
||||
'@rolldown/pluginutils@1.0.0-rc.7':
|
||||
resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==}
|
||||
|
||||
'@sigma/edge-curve@3.1.0':
|
||||
resolution: {integrity: sha512-OFWkfAXEsm+X8l1K4K49cC0psB0gQ+gqxKA08HG5piNPdzrDZ5gG9Gza6htZ5AirOVwd/4/uq/gPpD5En+H+3Q==}
|
||||
peerDependencies:
|
||||
sigma: '>=3.0.0-beta.10'
|
||||
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
@@ -1186,9 +1206,6 @@ 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==}
|
||||
|
||||
@@ -1354,10 +1371,6 @@ packages:
|
||||
babel-plugin-react-compiler:
|
||||
optional: true
|
||||
|
||||
accessor-fn@1.5.3:
|
||||
resolution: {integrity: sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
acorn@8.16.0:
|
||||
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
@@ -1370,13 +1383,6 @@ 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==}
|
||||
|
||||
@@ -1468,9 +1474,6 @@ packages:
|
||||
resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-binarytree@1.0.2:
|
||||
resolution: {integrity: sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==}
|
||||
|
||||
d3-brush@3.0.0:
|
||||
resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -1512,10 +1515,6 @@ packages:
|
||||
resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==}
|
||||
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'}
|
||||
@@ -1536,9 +1535,6 @@ 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@1.0.9:
|
||||
resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==}
|
||||
|
||||
@@ -1671,6 +1667,10 @@ packages:
|
||||
eventemitter3@5.0.4:
|
||||
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
||||
|
||||
events@3.3.0:
|
||||
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
||||
engines: {node: '>=0.8.x'}
|
||||
|
||||
extend@3.0.2:
|
||||
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
|
||||
|
||||
@@ -1683,14 +1683,6 @@ 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:
|
||||
@@ -1717,6 +1709,44 @@ packages:
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
graphology-communities-louvain@2.0.2:
|
||||
resolution: {integrity: sha512-zt+2hHVPYxjEquyecxWXoUoIuN/UvYzsvI7boDdMNz0rRvpESQ7+e+Ejv6wK7AThycbZXuQ6DkG8NPMCq6XwoA==}
|
||||
peerDependencies:
|
||||
graphology-types: '>=0.19.0'
|
||||
|
||||
graphology-indices@0.17.0:
|
||||
resolution: {integrity: sha512-A7RXuKQvdqSWOpn7ZVQo4S33O0vCfPBnUSf7FwE0zNCasqwZVUaCXePuWo5HBpWw68KJcwObZDHpFk6HKH6MYQ==}
|
||||
peerDependencies:
|
||||
graphology-types: '>=0.20.0'
|
||||
|
||||
graphology-layout-forceatlas2@0.10.1:
|
||||
resolution: {integrity: sha512-ogzBeF1FvWzjkikrIFwxhlZXvD2+wlY54lqhsrWprcdPjopM2J9HoMweUmIgwaTvY4bUYVimpSsOdvDv1gPRFQ==}
|
||||
peerDependencies:
|
||||
graphology-types: '>=0.19.0'
|
||||
|
||||
graphology-layout-noverlap@0.4.2:
|
||||
resolution: {integrity: sha512-13WwZSx96zim6l1dfZONcqLh3oqyRcjIBsqz2c2iJ3ohgs3605IDWjldH41Gnhh462xGB1j6VGmuGhZ2FKISXA==}
|
||||
peerDependencies:
|
||||
graphology-types: '>=0.19.0'
|
||||
|
||||
graphology-layout@0.6.1:
|
||||
resolution: {integrity: sha512-m9aMvbd0uDPffUCFPng5ibRkb2pmfNvdKjQWeZrf71RS1aOoat5874+DcyNfMeCT4aQguKC7Lj9eCbqZj/h8Ag==}
|
||||
peerDependencies:
|
||||
graphology-types: '>=0.19.0'
|
||||
|
||||
graphology-types@0.24.8:
|
||||
resolution: {integrity: sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q==}
|
||||
|
||||
graphology-utils@2.5.2:
|
||||
resolution: {integrity: sha512-ckHg8MXrXJkOARk56ZaSCM1g1Wihe2d6iTmz1enGOz4W/l831MBCKSayeFQfowgF8wd+PQ4rlch/56Vs/VZLDQ==}
|
||||
peerDependencies:
|
||||
graphology-types: '>=0.23.0'
|
||||
|
||||
graphology@0.26.0:
|
||||
resolution: {integrity: sha512-8SSImzgUUYC89Z042s+0r/vMibY7GX/Emz4LDO5e7jYXhuoWfHISPFJYjpRLUSJGq6UQ6xlenvX1p/hJdfXuXg==}
|
||||
peerDependencies:
|
||||
graphology-types: '>=0.24.0'
|
||||
|
||||
hachure-fill@0.5.2:
|
||||
resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==}
|
||||
|
||||
@@ -1781,10 +1811,6 @@ 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==}
|
||||
|
||||
@@ -1817,24 +1843,13 @@ 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'}
|
||||
|
||||
katex@0.16.45:
|
||||
resolution: {integrity: sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==}
|
||||
hasBin: true
|
||||
@@ -1935,10 +1950,6 @@ packages:
|
||||
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==}
|
||||
|
||||
@@ -2099,6 +2110,9 @@ packages:
|
||||
mlly@1.8.2:
|
||||
resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==}
|
||||
|
||||
mnemonist@0.39.8:
|
||||
resolution: {integrity: sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==}
|
||||
|
||||
motion-dom@12.38.0:
|
||||
resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==}
|
||||
|
||||
@@ -2113,9 +2127,8 @@ 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'}
|
||||
obliterator@2.0.5:
|
||||
resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==}
|
||||
|
||||
package-manager-detector@1.6.0:
|
||||
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
|
||||
@@ -2123,6 +2136,9 @@ packages:
|
||||
pako@1.0.11:
|
||||
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
||||
|
||||
pandemonium@2.4.1:
|
||||
resolution: {integrity: sha512-wRqjisUyiUfXowgm7MFH2rwJzKIr20rca5FsHXCMNm1W5YPP1hCtrZfgmQ62kP7OZ7Xt+cR858aB28lu5NX55g==}
|
||||
|
||||
parse-entities@4.0.2:
|
||||
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
|
||||
|
||||
@@ -2159,15 +2175,9 @@ 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==}
|
||||
|
||||
@@ -2189,12 +2199,6 @@ 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-hook-form@7.72.1:
|
||||
resolution: {integrity: sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@@ -2217,18 +2221,9 @@ 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:
|
||||
@@ -2363,6 +2358,9 @@ packages:
|
||||
setimmediate@1.0.5:
|
||||
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
|
||||
|
||||
sigma@3.0.2:
|
||||
resolution: {integrity: sha512-/BUbeOwPGruiBOm0YQQ6ZMcLIZ6tf/W+Jcm7dxZyAX0tK3WP9/sq7/NAWBxPIxVahdGjCJoGwej0Gdrv0DxlQQ==}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -2398,9 +2396,6 @@ 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==}
|
||||
|
||||
tinyexec@1.1.1:
|
||||
resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -3549,6 +3544,10 @@ snapshots:
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-rc.7': {}
|
||||
|
||||
'@sigma/edge-curve@3.1.0(sigma@3.0.2(graphology-types@0.24.8))':
|
||||
dependencies:
|
||||
sigma: 3.0.2(graphology-types@0.24.8)
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@standard-schema/utils@0.3.0': {}
|
||||
@@ -3641,8 +3640,6 @@ snapshots:
|
||||
|
||||
'@tanstack/table-core@8.21.3': {}
|
||||
|
||||
'@tweenjs/tween.js@25.0.0': {}
|
||||
|
||||
'@tybys/wasm-util@0.10.1':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -3826,8 +3823,6 @@ snapshots:
|
||||
'@rolldown/pluginutils': 1.0.0-rc.7
|
||||
vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(jiti@2.6.1)
|
||||
|
||||
accessor-fn@1.5.3: {}
|
||||
|
||||
acorn@8.16.0: {}
|
||||
|
||||
aria-hidden@1.2.6:
|
||||
@@ -3836,12 +3831,6 @@ snapshots:
|
||||
|
||||
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: {}
|
||||
@@ -3917,8 +3906,6 @@ snapshots:
|
||||
|
||||
d3-axis@3.0.0: {}
|
||||
|
||||
d3-binarytree@1.0.2: {}
|
||||
|
||||
d3-brush@3.0.0:
|
||||
dependencies:
|
||||
d3-dispatch: 3.0.1
|
||||
@@ -3960,14 +3947,6 @@ snapshots:
|
||||
dependencies:
|
||||
d3-dsv: 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
|
||||
@@ -3986,8 +3965,6 @@ snapshots:
|
||||
dependencies:
|
||||
d3-color: 3.1.0
|
||||
|
||||
d3-octree@1.1.0: {}
|
||||
|
||||
d3-path@1.0.9: {}
|
||||
|
||||
d3-path@3.1.0: {}
|
||||
@@ -4138,36 +4115,14 @@ snapshots:
|
||||
|
||||
eventemitter3@5.0.4: {}
|
||||
|
||||
events@3.3.0: {}
|
||||
|
||||
extend@3.0.2: {}
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.4):
|
||||
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
|
||||
@@ -4184,6 +4139,47 @@ snapshots:
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
graphology-communities-louvain@2.0.2(graphology-types@0.24.8):
|
||||
dependencies:
|
||||
graphology-indices: 0.17.0(graphology-types@0.24.8)
|
||||
graphology-types: 0.24.8
|
||||
graphology-utils: 2.5.2(graphology-types@0.24.8)
|
||||
mnemonist: 0.39.8
|
||||
pandemonium: 2.4.1
|
||||
|
||||
graphology-indices@0.17.0(graphology-types@0.24.8):
|
||||
dependencies:
|
||||
graphology-types: 0.24.8
|
||||
graphology-utils: 2.5.2(graphology-types@0.24.8)
|
||||
mnemonist: 0.39.8
|
||||
|
||||
graphology-layout-forceatlas2@0.10.1(graphology-types@0.24.8):
|
||||
dependencies:
|
||||
graphology-types: 0.24.8
|
||||
graphology-utils: 2.5.2(graphology-types@0.24.8)
|
||||
|
||||
graphology-layout-noverlap@0.4.2(graphology-types@0.24.8):
|
||||
dependencies:
|
||||
graphology-types: 0.24.8
|
||||
graphology-utils: 2.5.2(graphology-types@0.24.8)
|
||||
|
||||
graphology-layout@0.6.1(graphology-types@0.24.8):
|
||||
dependencies:
|
||||
graphology-types: 0.24.8
|
||||
graphology-utils: 2.5.2(graphology-types@0.24.8)
|
||||
pandemonium: 2.4.1
|
||||
|
||||
graphology-types@0.24.8: {}
|
||||
|
||||
graphology-utils@2.5.2(graphology-types@0.24.8):
|
||||
dependencies:
|
||||
graphology-types: 0.24.8
|
||||
|
||||
graphology@0.26.0(graphology-types@0.24.8):
|
||||
dependencies:
|
||||
events: 3.3.0
|
||||
graphology-types: 0.24.8
|
||||
|
||||
hachure-fill@0.5.2: {}
|
||||
|
||||
hast-util-from-dom@5.0.1:
|
||||
@@ -4290,8 +4286,6 @@ snapshots:
|
||||
|
||||
immer@11.1.4: {}
|
||||
|
||||
index-array-by@1.4.2: {}
|
||||
|
||||
inherits@2.0.4: {}
|
||||
|
||||
inline-style-parser@0.2.7: {}
|
||||
@@ -4315,12 +4309,8 @@ 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
|
||||
@@ -4328,10 +4318,6 @@ snapshots:
|
||||
readable-stream: 2.3.8
|
||||
setimmediate: 1.0.5
|
||||
|
||||
kapsule@1.16.3:
|
||||
dependencies:
|
||||
lodash-es: 4.18.1
|
||||
|
||||
katex@0.16.45:
|
||||
dependencies:
|
||||
commander: 8.3.0
|
||||
@@ -4408,10 +4394,6 @@ snapshots:
|
||||
|
||||
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
|
||||
@@ -4827,6 +4809,10 @@ snapshots:
|
||||
pkg-types: 1.3.1
|
||||
ufo: 1.6.3
|
||||
|
||||
mnemonist@0.39.8:
|
||||
dependencies:
|
||||
obliterator: 2.0.5
|
||||
|
||||
motion-dom@12.38.0:
|
||||
dependencies:
|
||||
motion-utils: 12.36.0
|
||||
@@ -4837,12 +4823,16 @@ snapshots:
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
obliterator@2.0.5: {}
|
||||
|
||||
package-manager-detector@1.6.0: {}
|
||||
|
||||
pako@1.0.11: {}
|
||||
|
||||
pandemonium@2.4.1:
|
||||
dependencies:
|
||||
mnemonist: 0.39.8
|
||||
|
||||
parse-entities@4.0.2:
|
||||
dependencies:
|
||||
'@types/unist': 2.0.11
|
||||
@@ -4889,16 +4879,8 @@ 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):
|
||||
@@ -4969,13 +4951,6 @@ 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-hook-form@7.72.1(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
@@ -4991,15 +4966,8 @@ 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
|
||||
@@ -5208,6 +5176,13 @@ snapshots:
|
||||
|
||||
setimmediate@1.0.5: {}
|
||||
|
||||
sigma@3.0.2(graphology-types@0.24.8):
|
||||
dependencies:
|
||||
events: 3.3.0
|
||||
graphology-utils: 2.5.2(graphology-types@0.24.8)
|
||||
transitivePeerDependencies:
|
||||
- graphology-types
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
space-separated-tokens@2.0.2: {}
|
||||
@@ -5239,8 +5214,6 @@ snapshots:
|
||||
|
||||
tiny-invariant@1.3.3: {}
|
||||
|
||||
tinycolor2@1.6.0: {}
|
||||
|
||||
tinyexec@1.1.1: {}
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Graph from "graphology";
|
||||
import type { KGEntity, KGRelation } from "@/types/knowledge-graph";
|
||||
import { getNodeSize, truncateMiddle } from "@/components/graph/graph-utils";
|
||||
|
||||
// Solid colors per entity type — used for both circle fill and glow
|
||||
// Solid colors per entity type
|
||||
export const KG_TYPE_COLORS: Record<string, string> = {
|
||||
person: "#E85D24", organization: "#ef4444", project: "#22c55e",
|
||||
product: "#f97316", technology: "#3b82f6", task: "#f59e0b",
|
||||
@@ -8,31 +10,6 @@ export const KG_TYPE_COLORS: Record<string, string> = {
|
||||
};
|
||||
export const KG_DEFAULT_COLOR = "#9ca3af";
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export interface GraphLink {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface GraphData {
|
||||
nodes: GraphNode[];
|
||||
links: GraphLink[];
|
||||
}
|
||||
|
||||
/** Compute degree (edge count) for each entity id. */
|
||||
export function computeDegreeMap(entities: KGEntity[], relations: KGRelation[]): Map<string, number> {
|
||||
const deg = new Map<string, number>();
|
||||
@@ -44,47 +21,43 @@ export function computeDegreeMap(entities: KGEntity[], relations: KGRelation[]):
|
||||
return deg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform KGEntity[] + KGRelation[] into react-force-graph GraphData.
|
||||
* Builds nodes with color/degree, links with neighbor/linkId sets populated.
|
||||
*/
|
||||
export function buildGraphData(entities: KGEntity[], allRelations: KGRelation[]): GraphData {
|
||||
/** Build a Graphology graph from KG entities and relations. */
|
||||
export function buildKGGraph(entities: KGEntity[], allRelations: KGRelation[]): Graph {
|
||||
const graph = new Graph({ multi: false, type: "directed" });
|
||||
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: KG_TYPE_COLORS[e.entity_type] ?? KG_DEFAULT_COLOR,
|
||||
neighbors: new Set<string>(),
|
||||
linkIds: new Set<string>(),
|
||||
degree: degreeMap.get(e.id) ?? 0,
|
||||
}));
|
||||
// Add nodes (x/y assigned by container via circular layout before FA2)
|
||||
for (const e of entities) {
|
||||
if (!graph.hasNode(e.id)) {
|
||||
const degree = degreeMap.get(e.id) ?? 0;
|
||||
graph.addNode(e.id, {
|
||||
label: truncateMiddle(e.name, 28),
|
||||
x: 0,
|
||||
y: 0,
|
||||
size: getNodeSize(degree),
|
||||
color: KG_TYPE_COLORS[e.entity_type] ?? KG_DEFAULT_COLOR,
|
||||
entityType: e.entity_type,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
|
||||
// Add edges (straight arrows for KG)
|
||||
for (const r of allRelations) {
|
||||
if (entityIds.has(r.source_entity_id) && entityIds.has(r.target_entity_id)) {
|
||||
if (!graph.hasEdge(r.source_entity_id, r.target_entity_id)) {
|
||||
graph.addEdgeWithKey(r.id, r.source_entity_id, r.target_entity_id, {
|
||||
label: r.relation_type.replace(/_/g, " "),
|
||||
type: "curvedArrow",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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, " "),
|
||||
};
|
||||
});
|
||||
|
||||
return { nodes, links };
|
||||
return graph;
|
||||
}
|
||||
|
||||
/**
|
||||
* Limit entities to nodeLimit by degree centrality (highest-degree first).
|
||||
*/
|
||||
/** Limit entities to nodeLimit by degree centrality (highest-degree first). */
|
||||
export function limitEntitiesByDegree(
|
||||
allEntities: KGEntity[],
|
||||
allRelations: KGRelation[],
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import Graph from "graphology";
|
||||
import type { VaultDocument, VaultLink } from "@/types/vault";
|
||||
import { getNodeSize, truncateMiddle } from "@/components/graph/graph-utils";
|
||||
|
||||
// Colors per vault document type
|
||||
export const VAULT_TYPE_COLORS: Record<string, string> = {
|
||||
@@ -11,30 +13,6 @@ export const VAULT_TYPE_COLORS: Record<string, string> = {
|
||||
};
|
||||
const DEFAULT_COLOR = "#9ca3af";
|
||||
|
||||
export interface VaultGraphNode {
|
||||
id: string;
|
||||
title: string;
|
||||
docType: string;
|
||||
color: string;
|
||||
neighbors: Set<string>;
|
||||
linkIds: Set<string>;
|
||||
degree: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
}
|
||||
|
||||
export interface VaultGraphLink {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface VaultGraphData {
|
||||
nodes: VaultGraphNode[];
|
||||
links: VaultGraphLink[];
|
||||
}
|
||||
|
||||
/** Limit documents by degree centrality (highest-connected first). */
|
||||
export function limitVaultDocsByDegree(
|
||||
docs: VaultDocument[],
|
||||
@@ -51,53 +29,49 @@ export function limitVaultDocsByDegree(
|
||||
return [...docs].sort((a, b) => (deg.get(b.id) ?? 0) - (deg.get(a.id) ?? 0)).slice(0, nodeLimit);
|
||||
}
|
||||
|
||||
/** Build graph data from vault documents and their links. */
|
||||
export function buildVaultGraphData(
|
||||
/** Build a Graphology graph from vault documents and their links. */
|
||||
export function buildVaultGraph(
|
||||
documents: VaultDocument[],
|
||||
links: VaultLink[],
|
||||
): VaultGraphData {
|
||||
): Graph {
|
||||
const graph = new Graph({ multi: false, type: "directed" });
|
||||
const docIds = new Set(documents.map((d) => d.id));
|
||||
|
||||
// Build degree map
|
||||
// Pre-compute degree map
|
||||
const degreeMap = new Map<string, number>();
|
||||
for (const link of links) {
|
||||
if (docIds.has(link.from_doc_id)) {
|
||||
if (docIds.has(link.from_doc_id))
|
||||
degreeMap.set(link.from_doc_id, (degreeMap.get(link.from_doc_id) ?? 0) + 1);
|
||||
}
|
||||
if (docIds.has(link.to_doc_id)) {
|
||||
if (docIds.has(link.to_doc_id))
|
||||
degreeMap.set(link.to_doc_id, (degreeMap.get(link.to_doc_id) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const nodes: VaultGraphNode[] = documents.map((d) => ({
|
||||
id: d.id,
|
||||
title: d.title || d.path.split("/").pop() || d.id.slice(0, 8),
|
||||
docType: d.doc_type,
|
||||
color: VAULT_TYPE_COLORS[d.doc_type] ?? DEFAULT_COLOR,
|
||||
neighbors: new Set<string>(),
|
||||
linkIds: new Set<string>(),
|
||||
degree: degreeMap.get(d.id) ?? 0,
|
||||
}));
|
||||
|
||||
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
|
||||
|
||||
// Only include links where both endpoints exist in our document set
|
||||
const graphLinks: VaultGraphLink[] = [];
|
||||
for (const link of links) {
|
||||
const src = nodeMap.get(link.from_doc_id);
|
||||
const tgt = nodeMap.get(link.to_doc_id);
|
||||
if (!src || !tgt) continue;
|
||||
src.neighbors.add(tgt.id);
|
||||
tgt.neighbors.add(src.id);
|
||||
src.linkIds.add(link.id);
|
||||
tgt.linkIds.add(link.id);
|
||||
graphLinks.push({
|
||||
id: link.id,
|
||||
source: link.from_doc_id,
|
||||
target: link.to_doc_id,
|
||||
label: link.link_type,
|
||||
// Add nodes (x/y assigned by container via circular layout before FA2)
|
||||
for (const doc of documents) {
|
||||
const degree = degreeMap.get(doc.id) ?? 0;
|
||||
const rawLabel = doc.title || doc.path.split("/").pop() || doc.id.slice(0, 8);
|
||||
graph.addNode(doc.id, {
|
||||
label: truncateMiddle(rawLabel, 28),
|
||||
x: 0,
|
||||
y: 0,
|
||||
size: getNodeSize(degree),
|
||||
color: VAULT_TYPE_COLORS[doc.doc_type] ?? DEFAULT_COLOR,
|
||||
docType: doc.doc_type,
|
||||
});
|
||||
}
|
||||
|
||||
return { nodes, links: graphLinks };
|
||||
// Add edges (only where both endpoints exist)
|
||||
for (const link of links) {
|
||||
if (docIds.has(link.from_doc_id) && docIds.has(link.to_doc_id)) {
|
||||
// Avoid duplicate edges for same source→target
|
||||
if (!graph.hasEdge(link.from_doc_id, link.to_doc_id)) {
|
||||
graph.addEdgeWithKey(link.id, link.from_doc_id, link.to_doc_id, {
|
||||
label: link.link_type,
|
||||
type: "curvedArrow",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return graph;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/** Degree-based node sizing: 4-12px range. Logarithmic scale for dense hubs. */
|
||||
export function getNodeSize(degree: number): number {
|
||||
if (degree === 0) return 4;
|
||||
return 4 + Math.min(Math.log2(degree + 1) * 2, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a long string by removing the middle and inserting an ellipsis.
|
||||
* Example: truncateMiddle("Model Steering System Benchmark", 20) → "Model Ster…hmark"
|
||||
*/
|
||||
export function truncateMiddle(str: string, maxLength = 28): string {
|
||||
if (!str || str.length <= maxLength) return str;
|
||||
const keepStart = Math.ceil((maxLength - 1) * 0.6);
|
||||
const keepEnd = Math.floor((maxLength - 1) * 0.4);
|
||||
return `${str.slice(0, keepStart)}…${str.slice(-keepEnd)}`;
|
||||
}
|
||||
|
||||
/** Sigma settings constants for consistent look across both graph views */
|
||||
export const SIGMA_SETTINGS = {
|
||||
/** Labels hidden for nodes smaller than this on screen */
|
||||
labelRenderedSizeThreshold: 8,
|
||||
/** Lower = fewer labels shown (avoids overlap) */
|
||||
labelDensity: 0.12,
|
||||
/** Grid cell size for label collision avoidance */
|
||||
labelGridCellSize: 120,
|
||||
/** Default edge color (overridden per-theme) */
|
||||
defaultEdgeColor: "#334155",
|
||||
/** Minimum camera ratio (most zoomed in) */
|
||||
minCameraRatio: 0.02,
|
||||
/** Maximum camera ratio (most zoomed out) */
|
||||
maxCameraRatio: 8,
|
||||
} as const;
|
||||
@@ -0,0 +1,404 @@
|
||||
import { useRef, useEffect, useCallback, useState } from "react";
|
||||
import Sigma from "sigma";
|
||||
import { EdgeCurvedArrowProgram } from "@sigma/edge-curve";
|
||||
import { EdgeArrowProgram } from "sigma/rendering";
|
||||
import forceAtlas2 from "graphology-layout-forceatlas2";
|
||||
import noverlap from "graphology-layout-noverlap";
|
||||
import louvain from "graphology-communities-louvain";
|
||||
import type Graph from "graphology";
|
||||
import { useUiStore } from "@/stores/use-ui-store";
|
||||
import { SIGMA_SETTINGS } from "./graph-utils";
|
||||
|
||||
export type EdgeType = "curvedArrow" | "arrow";
|
||||
|
||||
export interface SigmaGraphContainerProps {
|
||||
graph: Graph;
|
||||
edgeType?: EdgeType;
|
||||
selectedNodeId?: string | null;
|
||||
onNodeSelect?: (nodeId: string | null) => void;
|
||||
onNodeDoubleClick?: (nodeId: string) => void;
|
||||
/** Called when Sigma instance is ready (or destroyed) */
|
||||
onSigmaReady?: (sigma: Sigma | null) => void;
|
||||
/** Compact mode for embedded mini-graphs (no layout, smaller labels) */
|
||||
compact?: boolean;
|
||||
/** Node types to hide (from filter component) */
|
||||
hiddenTypes?: Set<string>;
|
||||
}
|
||||
|
||||
/** Theme-aware colors */
|
||||
function useThemeColors() {
|
||||
const theme = useUiStore((s) => s.theme);
|
||||
const isDark =
|
||||
theme === "dark" ||
|
||||
(theme === "system" &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
return {
|
||||
isDark,
|
||||
labelColor: isDark ? "#e2e8f0" : "#1e293b",
|
||||
// Soft base edge color — lighter than borders
|
||||
edgeColor: isDark ? "#47556966" : "#d1d5db99",
|
||||
// Even lighter dim color
|
||||
dimEdgeColor: isDark ? "#47556933" : "#e5e7eb66",
|
||||
// Softer highlight (not too dark)
|
||||
highlightEdgeColor: isDark ? "#a1a1aa" : "#9ca3af",
|
||||
};
|
||||
}
|
||||
|
||||
export function SigmaGraphContainer({
|
||||
graph,
|
||||
edgeType = "arrow",
|
||||
selectedNodeId,
|
||||
onNodeSelect,
|
||||
onNodeDoubleClick,
|
||||
onSigmaReady,
|
||||
compact = false,
|
||||
hiddenTypes,
|
||||
}: SigmaGraphContainerProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const internalSigmaRef = useRef<Sigma | null>(null);
|
||||
const [hoveredNode, setHoveredNode] = useState<string | null>(null);
|
||||
// Pulse phase for animated highlighted edges (0..1, cycles)
|
||||
const [pulsePhase, setPulsePhase] = useState(0);
|
||||
const { labelColor, edgeColor, dimEdgeColor, highlightEdgeColor } = useThemeColors();
|
||||
|
||||
const setSigmaRef = useCallback(
|
||||
(instance: Sigma | null) => {
|
||||
internalSigmaRef.current = instance;
|
||||
onSigmaReady?.(instance);
|
||||
},
|
||||
[onSigmaReady],
|
||||
);
|
||||
|
||||
// --- Initialize Sigma with PRE-COMPUTED FA2 layout (no shake, no worker) ---
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || graph.order === 0) return;
|
||||
|
||||
// Step 1: Detect communities with Louvain, then seed nodes spatially per community.
|
||||
// This breaks the "ring" pattern that FA2 + circular init produces on sparse graphs.
|
||||
if (graph.order > 1) {
|
||||
// Detect communities — Louvain returns { nodeId: communityId }
|
||||
const communities = louvain(graph, { resolution: 1 });
|
||||
|
||||
// Group nodes by community
|
||||
const communityGroups = new Map<number, string[]>();
|
||||
for (const node of graph.nodes()) {
|
||||
const c = communities[node] ?? 0;
|
||||
if (!communityGroups.has(c)) communityGroups.set(c, []);
|
||||
communityGroups.get(c)!.push(node);
|
||||
}
|
||||
|
||||
// Sort communities by size (big ones placed first / more central)
|
||||
const communityIds = Array.from(communityGroups.keys())
|
||||
.sort((a, b) => communityGroups.get(b)!.length - communityGroups.get(a)!.length);
|
||||
const numCommunities = communityIds.length;
|
||||
|
||||
// Cell size derived from the LARGEST community — smaller clusters get same cell
|
||||
// but will pack tighter via noverlap post-processing.
|
||||
const maxCommunitySize = Math.max(
|
||||
...Array.from(communityGroups.values(), (nodes) => nodes.length),
|
||||
);
|
||||
// Each node contributes ~12 units of space (node size ~5-12 + margin)
|
||||
const cellSize = Math.max(Math.sqrt(maxCommunitySize) * 28, 140);
|
||||
|
||||
// Grid dimensions — aim for slightly wide aspect (4:3)
|
||||
const cols = Math.ceil(Math.sqrt(numCommunities * 1.4));
|
||||
const rows = Math.ceil(numCommunities / cols);
|
||||
const gridWidth = cols * cellSize;
|
||||
const gridHeight = rows * cellSize;
|
||||
|
||||
// Deterministic pseudo-random for jitter (breaks grid rigidity)
|
||||
const jitter = (seed: number) => {
|
||||
const x = Math.sin(seed * 9999) * 10000;
|
||||
return (x - Math.floor(x)) - 0.5;
|
||||
};
|
||||
|
||||
communityIds.forEach((cId, idx) => {
|
||||
const nodesInCommunity = communityGroups.get(cId)!;
|
||||
const col = idx % cols;
|
||||
const row = Math.floor(idx / cols);
|
||||
// Cell center with slight jitter
|
||||
const cx = col * cellSize - gridWidth / 2 + cellSize / 2 + jitter(idx) * cellSize * 0.2;
|
||||
const cy = row * cellSize - gridHeight / 2 + cellSize / 2 + jitter(idx + 1000) * cellSize * 0.2;
|
||||
// Local radius: scales with community size — bigger community = bigger circle
|
||||
// Uses sqrt so large clusters don't balloon out of proportion
|
||||
const localRadius = Math.max(Math.sqrt(nodesInCommunity.length) * 12, 25);
|
||||
|
||||
nodesInCommunity.forEach((nodeId, i) => {
|
||||
const localAngle = (i / nodesInCommunity.length) * Math.PI * 2;
|
||||
const jitterR = localRadius * (0.6 + Math.abs(jitter(i + idx * 100)) * 0.7);
|
||||
const x = cx + Math.cos(localAngle) * jitterR;
|
||||
const y = cy + Math.sin(localAngle) * jitterR;
|
||||
graph.setNodeAttribute(nodeId, "x", x);
|
||||
graph.setNodeAttribute(nodeId, "y", y);
|
||||
});
|
||||
});
|
||||
|
||||
// Step 2: Run FA2 with community-aware settings.
|
||||
// Since we have community seeds, FA2 just refines (doesn't completely re-layout).
|
||||
const iterations = graph.order < 100 ? 250 : graph.order < 500 ? 180 : 100;
|
||||
forceAtlas2.assign(graph, {
|
||||
iterations,
|
||||
settings: {
|
||||
// Standard FA2 (NOT linLogMode — causes ring collapse)
|
||||
linLogMode: false,
|
||||
outboundAttractionDistribution: false,
|
||||
// Moderate gravity — pulls clusters slightly closer (prevents drift)
|
||||
gravity: 0.15,
|
||||
// Moderate repulsion — less aggressive spacing
|
||||
scalingRatio: 8,
|
||||
adjustSizes: true,
|
||||
strongGravityMode: false,
|
||||
slowDown: 8,
|
||||
barnesHutOptimize: graph.order > 300,
|
||||
edgeWeightInfluence: 0,
|
||||
},
|
||||
});
|
||||
|
||||
// Step 3: Noverlap post-process — push apart any remaining overlaps (tight)
|
||||
noverlap.assign(graph, {
|
||||
maxIterations: 50,
|
||||
settings: {
|
||||
margin: 3,
|
||||
ratio: 1.02,
|
||||
speed: 3,
|
||||
gridSize: 20,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const edgePrograms: Record<string, typeof EdgeArrowProgram> = {
|
||||
arrow: EdgeArrowProgram,
|
||||
curvedArrow: EdgeCurvedArrowProgram as unknown as typeof EdgeArrowProgram,
|
||||
};
|
||||
|
||||
const sigma = new Sigma(graph, containerRef.current, {
|
||||
allowInvalidContainer: true,
|
||||
renderLabels: true,
|
||||
labelRenderedSizeThreshold: compact ? 14 : SIGMA_SETTINGS.labelRenderedSizeThreshold,
|
||||
labelDensity: compact ? 0.05 : SIGMA_SETTINGS.labelDensity,
|
||||
labelGridCellSize: SIGMA_SETTINGS.labelGridCellSize,
|
||||
labelColor: { color: labelColor },
|
||||
defaultEdgeColor: edgeColor,
|
||||
defaultEdgeType: edgeType,
|
||||
edgeProgramClasses: edgePrograms,
|
||||
minCameraRatio: SIGMA_SETTINGS.minCameraRatio,
|
||||
maxCameraRatio: SIGMA_SETTINGS.maxCameraRatio,
|
||||
labelFont: "Inter, system-ui, sans-serif",
|
||||
labelSize: compact ? 10 : 12,
|
||||
zoomingRatio: 1.3,
|
||||
// Enable z-index sorting — required for edge/node `zIndex` attr to affect render order
|
||||
zIndex: true,
|
||||
});
|
||||
|
||||
setSigmaRef(sigma);
|
||||
|
||||
// Fit viewport to graph on next frame (after initial render)
|
||||
requestAnimationFrame(() => {
|
||||
sigma.getCamera().animatedReset({ duration: 300 });
|
||||
});
|
||||
|
||||
return () => {
|
||||
sigma.kill();
|
||||
// Only clear external ref if it still points to THIS sigma (concurrent mode safety)
|
||||
if (internalSigmaRef.current === sigma) {
|
||||
internalSigmaRef.current = null;
|
||||
onSigmaReady?.(null);
|
||||
}
|
||||
};
|
||||
}, [graph, edgeType, compact]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// --- Update theme colors without re-init ---
|
||||
useEffect(() => {
|
||||
const sigma = internalSigmaRef.current;
|
||||
if (!sigma) return;
|
||||
sigma.setSetting("labelColor", { color: labelColor });
|
||||
sigma.setSetting("defaultEdgeColor", edgeColor);
|
||||
sigma.refresh();
|
||||
}, [labelColor, edgeColor]);
|
||||
|
||||
// Compute multi-hop neighborhood (BFS, 2 hops) for active node
|
||||
const neighborhoodRef = useRef<{ nodes: Set<string>; edges: Set<string> } | null>(null);
|
||||
useEffect(() => {
|
||||
const active = selectedNodeId || hoveredNode;
|
||||
if (!active || !graph.hasNode(active)) {
|
||||
neighborhoodRef.current = null;
|
||||
return;
|
||||
}
|
||||
const nodes = new Set<string>([active]);
|
||||
const edges = new Set<string>();
|
||||
const MAX_HOPS = 2;
|
||||
let frontier: string[] = [active];
|
||||
for (let hop = 0; hop < MAX_HOPS; hop++) {
|
||||
const next: string[] = [];
|
||||
for (const n of frontier) {
|
||||
graph.forEachEdge(n, (edge, _attrs, source, target) => {
|
||||
edges.add(edge);
|
||||
const other = source === n ? target : source;
|
||||
if (!nodes.has(other)) {
|
||||
nodes.add(other);
|
||||
next.push(other);
|
||||
}
|
||||
});
|
||||
}
|
||||
frontier = next;
|
||||
if (frontier.length === 0) break;
|
||||
}
|
||||
neighborhoodRef.current = { nodes, edges };
|
||||
}, [selectedNodeId, hoveredNode, graph]);
|
||||
|
||||
// --- Unified node/edge reducers: filter + subtle hover highlight (no dimming) ---
|
||||
useEffect(() => {
|
||||
const sigma = internalSigmaRef.current;
|
||||
if (!sigma) return;
|
||||
|
||||
const getNodeType = (attrs: Record<string, unknown>) =>
|
||||
(attrs.docType || attrs.entityType || "other") as string;
|
||||
|
||||
sigma.setSetting("nodeReducer", (node, data) => {
|
||||
// Filter: hide nodes of hidden types
|
||||
if (hiddenTypes?.size && hiddenTypes.has(getNodeType(data))) {
|
||||
return { ...data, hidden: true };
|
||||
}
|
||||
|
||||
const activeNode = selectedNodeId || hoveredNode;
|
||||
if (!activeNode) return data;
|
||||
|
||||
const hood = neighborhoodRef.current;
|
||||
if (!hood) return data;
|
||||
|
||||
if (node === activeNode) {
|
||||
// Active node: bring to top, show label
|
||||
return { ...data, zIndex: 3, forceLabel: true };
|
||||
}
|
||||
if (hood.nodes.has(node)) {
|
||||
// Within 2-hop neighborhood: show label, slightly raised z-index
|
||||
return { ...data, zIndex: 2, forceLabel: true };
|
||||
}
|
||||
// Outside neighborhood: keep color/size, no dimming — just lower z-index
|
||||
return { ...data, zIndex: 0 };
|
||||
});
|
||||
|
||||
sigma.setSetting("edgeReducer", (edge, data) => {
|
||||
// Filter: hide edges connected to hidden node types
|
||||
if (hiddenTypes?.size) {
|
||||
const srcAttrs = graph.getNodeAttributes(graph.source(edge));
|
||||
const tgtAttrs = graph.getNodeAttributes(graph.target(edge));
|
||||
if (hiddenTypes.has(getNodeType(srcAttrs)) || hiddenTypes.has(getNodeType(tgtAttrs))) {
|
||||
return { ...data, hidden: true };
|
||||
}
|
||||
}
|
||||
|
||||
const activeNode = selectedNodeId || hoveredNode;
|
||||
if (!activeNode) return data;
|
||||
|
||||
const hood = neighborhoodRef.current;
|
||||
if (!hood) return data;
|
||||
|
||||
if (hood.edges.has(edge)) {
|
||||
// Edges within neighborhood: thin + subtle OPACITY pulse (not size).
|
||||
const alpha = Math.round(200 + Math.sin(pulsePhase * Math.PI * 2) * 40);
|
||||
const alphaHex = Math.max(0, Math.min(255, alpha)).toString(16).padStart(2, "0");
|
||||
const pulsedColor = `${highlightEdgeColor}${alphaHex}`;
|
||||
return { ...data, color: pulsedColor, size: 1, zIndex: 2 };
|
||||
}
|
||||
// Non-related edges: HIDE entirely so active edges are clearly visible
|
||||
return { ...data, hidden: true };
|
||||
});
|
||||
|
||||
sigma.refresh();
|
||||
}, [selectedNodeId, hoveredNode, graph, highlightEdgeColor, dimEdgeColor, hiddenTypes]);
|
||||
|
||||
// --- Pulse animation for highlighted edges (only runs when a node is active) ---
|
||||
// Respects prefers-reduced-motion — skips animation entirely for accessibility
|
||||
useEffect(() => {
|
||||
const active = selectedNodeId || hoveredNode;
|
||||
if (!active) return;
|
||||
|
||||
// Honor user's reduced-motion preference
|
||||
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
if (mediaQuery.matches) return;
|
||||
|
||||
let rafId = 0;
|
||||
const start = performance.now();
|
||||
const PULSE_PERIOD_MS = 1800; // slower, gentler pulse
|
||||
const tick = () => {
|
||||
const elapsed = performance.now() - start;
|
||||
setPulsePhase((elapsed % PULSE_PERIOD_MS) / PULSE_PERIOD_MS);
|
||||
rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
rafId = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [selectedNodeId, hoveredNode]);
|
||||
|
||||
// Add pulsePhase to reducer deps so edges re-render on pulse
|
||||
useEffect(() => {
|
||||
const sigma = internalSigmaRef.current;
|
||||
if (!sigma) return;
|
||||
sigma.refresh({ skipIndexation: true });
|
||||
}, [pulsePhase]);
|
||||
|
||||
// --- Event handlers: use Sigma v3 native doubleClickNode (snappier than 280ms timer) ---
|
||||
useEffect(() => {
|
||||
const sigma = internalSigmaRef.current;
|
||||
if (!sigma) return;
|
||||
|
||||
const handleEnterNode = ({ node }: { node: string }) => {
|
||||
setHoveredNode(node);
|
||||
if (containerRef.current) containerRef.current.style.cursor = "pointer";
|
||||
};
|
||||
|
||||
const handleLeaveNode = () => {
|
||||
setHoveredNode(null);
|
||||
if (containerRef.current) containerRef.current.style.cursor = "default";
|
||||
};
|
||||
|
||||
const handleClickNode = ({ node }: { node: string }) => {
|
||||
onNodeSelect?.(node === selectedNodeId ? null : node);
|
||||
};
|
||||
|
||||
const handleDoubleClickNode = ({ node, event }: { node: string; event: { preventSigmaDefault?: () => void } }) => {
|
||||
// Prevent Sigma's default zoom-in behavior on double-click
|
||||
event.preventSigmaDefault?.();
|
||||
onNodeDoubleClick?.(node);
|
||||
};
|
||||
|
||||
const handleClickStage = () => {
|
||||
onNodeSelect?.(null);
|
||||
};
|
||||
|
||||
sigma.on("enterNode", handleEnterNode);
|
||||
sigma.on("leaveNode", handleLeaveNode);
|
||||
sigma.on("clickNode", handleClickNode);
|
||||
sigma.on("doubleClickNode", handleDoubleClickNode);
|
||||
sigma.on("clickStage", handleClickStage);
|
||||
|
||||
return () => {
|
||||
sigma.off("enterNode", handleEnterNode);
|
||||
sigma.off("leaveNode", handleLeaveNode);
|
||||
sigma.off("clickNode", handleClickNode);
|
||||
sigma.off("doubleClickNode", handleDoubleClickNode);
|
||||
sigma.off("clickStage", handleClickStage);
|
||||
};
|
||||
}, [selectedNodeId, onNodeSelect, onNodeDoubleClick]);
|
||||
|
||||
// NOTE: Click on node NO LONGER moves camera.
|
||||
// Camera only animates for explicit user actions (search, fit-to-view, keyboard F).
|
||||
// This matches the old force-graph behavior where clicking just highlights.
|
||||
|
||||
// No-data state
|
||||
if (graph.order === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
No data to display
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full"
|
||||
style={{ minHeight: compact ? 200 : 300 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type Sigma from "sigma";
|
||||
import { ZoomIn, ZoomOut, Maximize2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const NODE_LIMIT_OPTIONS = [100, 200, 500, 1000, 5000] as const;
|
||||
|
||||
interface SigmaGraphControlsProps {
|
||||
sigma: Sigma | null;
|
||||
nodeLimit: number;
|
||||
isLimited: boolean;
|
||||
onNodeLimitChange: (limit: number) => void;
|
||||
/** i18n labels — caller provides pre-formatted translated strings */
|
||||
labels: {
|
||||
nodes: string;
|
||||
edges: string;
|
||||
limitNote?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function SigmaGraphControls({
|
||||
sigma,
|
||||
nodeLimit,
|
||||
isLimited,
|
||||
onNodeLimitChange,
|
||||
labels,
|
||||
}: SigmaGraphControlsProps) {
|
||||
const [zoomPercent, setZoomPercent] = useState(100);
|
||||
|
||||
// Track zoom level from camera events
|
||||
useEffect(() => {
|
||||
if (!sigma) return;
|
||||
const camera = sigma.getCamera();
|
||||
const update = () => {
|
||||
// ratio < 1 = zoomed in, ratio > 1 = zoomed out. Invert for display.
|
||||
setZoomPercent(Math.round((1 / camera.ratio) * 100));
|
||||
};
|
||||
update();
|
||||
camera.on("updated", update);
|
||||
return () => { camera.off("updated", update); };
|
||||
}, [sigma]);
|
||||
|
||||
const handleZoomIn = useCallback(() => {
|
||||
sigma?.getCamera().animatedZoom({ duration: 200 });
|
||||
}, [sigma]);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
sigma?.getCamera().animatedUnzoom({ duration: 200 });
|
||||
}, [sigma]);
|
||||
|
||||
const handleFitToView = useCallback(() => {
|
||||
sigma?.getCamera().animatedReset({ duration: 300 });
|
||||
}, [sigma]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-3 py-1.5 border-t text-2xs text-muted-foreground shrink-0">
|
||||
<span>{labels.nodes}</span>
|
||||
<span>{labels.edges}</span>
|
||||
{isLimited && labels.limitNote && (
|
||||
<span>· {labels.limitNote}</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5" onClick={handleZoomOut} aria-label="Zoom out">
|
||||
<ZoomOut className="h-3 w-3" />
|
||||
</Button>
|
||||
<span className="w-9 text-center" aria-live="polite">{zoomPercent}%</span>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5" onClick={handleZoomIn} aria-label="Zoom in">
|
||||
<ZoomIn className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<select
|
||||
value={nodeLimit}
|
||||
onChange={(e) => onNodeLimitChange(Number(e.target.value))}
|
||||
className="h-5 rounded border bg-background px-1 text-base md:text-2xs"
|
||||
aria-label="Node limit"
|
||||
>
|
||||
{NODE_LIMIT_OPTIONS.map((n) => (
|
||||
<option key={n} value={n}>{n} nodes</option>
|
||||
))}
|
||||
</select>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5" onClick={handleFitToView} aria-label="Fit to view">
|
||||
<Maximize2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useCallback, useMemo, useEffect, useRef } from "react";
|
||||
import type Graph from "graphology";
|
||||
import { Filter, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface TypeCount {
|
||||
type: string;
|
||||
color: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface SigmaGraphFiltersProps {
|
||||
graph: Graph;
|
||||
/** Map of type → color for the legend dots */
|
||||
typeColors: Record<string, string>;
|
||||
defaultColor?: string;
|
||||
/** Currently hidden types (controlled state) */
|
||||
hiddenTypes: Set<string>;
|
||||
onHiddenTypesChange: (types: Set<string>) => void;
|
||||
collapsed: boolean;
|
||||
onCollapsedChange: (collapsed: boolean) => void;
|
||||
}
|
||||
|
||||
export function SigmaGraphFilters({
|
||||
graph, typeColors, defaultColor = "#9ca3af",
|
||||
hiddenTypes, onHiddenTypesChange,
|
||||
collapsed, onCollapsedChange,
|
||||
}: SigmaGraphFiltersProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Memoize type counts to avoid iterating all nodes on every render
|
||||
const typeCounts = useMemo<TypeCount[]>(() => {
|
||||
if (graph.order === 0) return [];
|
||||
const counts = new Map<string, number>();
|
||||
graph.forEachNode((_node, attrs) => {
|
||||
const t = (attrs.docType || attrs.entityType || "other") as string;
|
||||
counts.set(t, (counts.get(t) ?? 0) + 1);
|
||||
});
|
||||
const result: TypeCount[] = [];
|
||||
for (const [type, count] of counts) {
|
||||
result.push({ type, color: typeColors[type] ?? defaultColor, count });
|
||||
}
|
||||
return result.sort((a, b) => b.count - a.count);
|
||||
}, [graph, typeColors, defaultColor]);
|
||||
|
||||
const toggleType = useCallback((type: string) => {
|
||||
const next = new Set(hiddenTypes);
|
||||
if (next.has(type)) next.delete(type);
|
||||
else next.add(type);
|
||||
onHiddenTypesChange(next);
|
||||
}, [hiddenTypes, onHiddenTypesChange]);
|
||||
|
||||
// Close dropdown on outside click OR Escape key
|
||||
useEffect(() => {
|
||||
if (collapsed) return;
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
onCollapsedChange(true);
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onCollapsedChange(true);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClick);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [collapsed, onCollapsedChange]);
|
||||
|
||||
if (typeCounts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative flex flex-col">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 text-xs text-muted-foreground"
|
||||
onClick={() => onCollapsedChange(!collapsed)}
|
||||
aria-label={`Filter by type${hiddenTypes.size > 0 ? ` (${hiddenTypes.size} hidden)` : ""}`}
|
||||
aria-expanded={!collapsed}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<Filter className="h-3 w-3" />
|
||||
Filter
|
||||
{hiddenTypes.size > 0 && (
|
||||
<span className="ml-1 text-2xs rounded-full bg-primary/10 text-primary px-1.5">
|
||||
{hiddenTypes.size} hidden
|
||||
</span>
|
||||
)}
|
||||
{collapsed ? <ChevronDown className="h-3 w-3" /> : <ChevronUp className="h-3 w-3" />}
|
||||
</Button>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="absolute right-0 top-full z-50 mt-1 flex flex-col gap-0.5 rounded-md border bg-popover p-1 shadow-md min-w-[160px]">
|
||||
{typeCounts.map(({ type, color, count }) => {
|
||||
const hidden = hiddenTypes.has(type);
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => toggleType(type)}
|
||||
className={`flex items-center gap-2 px-2 py-1 rounded text-xs hover:bg-accent transition-opacity ${
|
||||
hidden ? "opacity-40" : ""
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 shrink-0 rounded-full transition-opacity"
|
||||
style={{ backgroundColor: color, opacity: hidden ? 0.3 : 1 }}
|
||||
/>
|
||||
<span className="flex-1 text-left truncate">{type}</span>
|
||||
<span className="text-2xs text-muted-foreground">{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Keyboard, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface Shortcut {
|
||||
keys: string[];
|
||||
description: string;
|
||||
}
|
||||
|
||||
const SHORTCUTS: Shortcut[] = [
|
||||
{ keys: ["+"], description: "Zoom in" },
|
||||
{ keys: ["-"], description: "Zoom out" },
|
||||
{ keys: ["R"], description: "Reset view (fit all)" },
|
||||
{ keys: ["F"], description: "Focus selected node" },
|
||||
{ keys: ["Esc"], description: "Deselect" },
|
||||
{ keys: ["/"], description: "Focus search" },
|
||||
{ keys: ["?"], description: "Show this help" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Keyboard shortcuts help popover.
|
||||
* Triggered by `?` key or by clicking the keyboard icon.
|
||||
*/
|
||||
export function SigmaGraphKeyboardHelp() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Global `?` key handler to open help (when not typing in input)
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
|
||||
if (e.key === "?") {
|
||||
e.preventDefault();
|
||||
setOpen((prev) => !prev);
|
||||
} else if (e.key === "Escape" && open) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [open]);
|
||||
|
||||
// Outside click to dismiss
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => setOpen((p) => !p)}
|
||||
aria-label="Keyboard shortcuts"
|
||||
title="Keyboard shortcuts (?)"
|
||||
>
|
||||
<Keyboard className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="Keyboard shortcuts"
|
||||
className="absolute right-0 top-full z-50 mt-1 min-w-[240px] rounded-md border bg-popover p-3 shadow-lg"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-xs font-medium">Keyboard shortcuts</h4>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="p-0.5 rounded hover:bg-accent"
|
||||
aria-label="Close shortcuts help"
|
||||
>
|
||||
<X className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
<dl className="space-y-1.5">
|
||||
{SHORTCUTS.map(({ keys, description }) => (
|
||||
<div key={keys.join("+")} className="flex items-center justify-between gap-3 text-xs">
|
||||
<dt className="text-muted-foreground">{description}</dt>
|
||||
<dd className="flex gap-1">
|
||||
{keys.map((k) => (
|
||||
<kbd
|
||||
key={k}
|
||||
className="inline-flex items-center justify-center min-w-[22px] h-5 px-1.5 rounded border bg-muted text-2xs font-mono"
|
||||
>
|
||||
{k}
|
||||
</kbd>
|
||||
))}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useRef, useEffect, useCallback } from "react";
|
||||
import type Sigma from "sigma";
|
||||
import type Graph from "graphology";
|
||||
|
||||
interface SigmaGraphMinimapProps {
|
||||
sigma: Sigma | null;
|
||||
graph: Graph;
|
||||
/** Minimap canvas size in pixels */
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight canvas-overlay minimap.
|
||||
* Draws all nodes as small dots + a viewport rectangle showing the current camera view.
|
||||
* Click to jump, no drag (KISS).
|
||||
*/
|
||||
export function SigmaGraphMinimap({ sigma, graph, size = 140 }: SigmaGraphMinimapProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const rafRef = useRef<number>(0);
|
||||
|
||||
// Compute graph bounding box from node positions
|
||||
const getBounds = useCallback(() => {
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
graph.forEachNode((_node, attrs) => {
|
||||
const x = attrs.x as number;
|
||||
const y = attrs.y as number;
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
});
|
||||
// Add padding
|
||||
const padX = (maxX - minX) * 0.1 || 50;
|
||||
const padY = (maxY - minY) * 0.1 || 50;
|
||||
return { minX: minX - padX, minY: minY - padY, maxX: maxX + padX, maxY: maxY + padY };
|
||||
}, [graph]);
|
||||
|
||||
// Draw minimap
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext("2d");
|
||||
if (!canvas || !ctx || !sigma || graph.order === 0) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = size * dpr;
|
||||
canvas.height = size * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const bounds = getBounds();
|
||||
const bw = bounds.maxX - bounds.minX;
|
||||
const bh = bounds.maxY - bounds.minY;
|
||||
const scale = Math.min(size / bw, size / bh);
|
||||
const offsetX = (size - bw * scale) / 2;
|
||||
const offsetY = (size - bh * scale) / 2;
|
||||
|
||||
// Clear
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = "rgba(0,0,0,0.03)";
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
|
||||
// Draw nodes as 1.5px dots
|
||||
graph.forEachNode((_node, attrs) => {
|
||||
const x = ((attrs.x as number) - bounds.minX) * scale + offsetX;
|
||||
const y = ((attrs.y as number) - bounds.minY) * scale + offsetY;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 1.5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = (attrs.color as string) || "#9ca3af";
|
||||
ctx.fill();
|
||||
});
|
||||
|
||||
// Draw viewport rectangle — use viewportToGraph to derive the 4 corners
|
||||
// in graph space, then map to minimap space
|
||||
const { width, height } = sigma.getDimensions();
|
||||
const topLeft = sigma.viewportToGraph({ x: 0, y: 0 });
|
||||
const bottomRight = sigma.viewportToGraph({ x: width, y: height });
|
||||
|
||||
const vx1 = (topLeft.x - bounds.minX) * scale + offsetX;
|
||||
const vy1 = (topLeft.y - bounds.minY) * scale + offsetY;
|
||||
const vx2 = (bottomRight.x - bounds.minX) * scale + offsetX;
|
||||
const vy2 = (bottomRight.y - bounds.minY) * scale + offsetY;
|
||||
const vw = vx2 - vx1;
|
||||
const vh = vy2 - vy1;
|
||||
|
||||
ctx.strokeStyle = "rgba(59, 130, 246, 0.7)";
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.strokeRect(vx1, vy1, vw, vh);
|
||||
ctx.fillStyle = "rgba(59, 130, 246, 0.08)";
|
||||
ctx.fillRect(vx1, vy1, vw, vh);
|
||||
}, [sigma, graph, size, getBounds]);
|
||||
|
||||
// Redraw on camera changes
|
||||
useEffect(() => {
|
||||
if (!sigma) return;
|
||||
const camera = sigma.getCamera();
|
||||
|
||||
// Throttled redraw — skip if already scheduled
|
||||
let scheduled = false;
|
||||
const scheduleRedraw = () => {
|
||||
if (scheduled) return;
|
||||
scheduled = true;
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
scheduled = false;
|
||||
draw();
|
||||
});
|
||||
};
|
||||
|
||||
// Initial draw
|
||||
scheduleRedraw();
|
||||
|
||||
// Redraw on camera updates (pan/zoom) AND after every sigma render
|
||||
// (so FA2 layout position updates are reflected)
|
||||
camera.on("updated", scheduleRedraw);
|
||||
sigma.on("afterRender", scheduleRedraw);
|
||||
|
||||
return () => {
|
||||
camera.off("updated", scheduleRedraw);
|
||||
sigma.off("afterRender", scheduleRedraw);
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, [sigma, draw]);
|
||||
|
||||
// Click to jump: find nearest node to click position and center camera on it
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!sigma || graph.order === 0) return;
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const bounds = getBounds();
|
||||
const bw = bounds.maxX - bounds.minX;
|
||||
const bh = bounds.maxY - bounds.minY;
|
||||
const scale = Math.min(size / bw, size / bh);
|
||||
const offsetX = (size - bw * scale) / 2;
|
||||
const offsetY = (size - bh * scale) / 2;
|
||||
|
||||
// Convert click position to graph coordinates
|
||||
const clickX = e.clientX - rect.left;
|
||||
const clickY = e.clientY - rect.top;
|
||||
const targetGraphX = (clickX - offsetX) / scale + bounds.minX;
|
||||
const targetGraphY = (clickY - offsetY) / scale + bounds.minY;
|
||||
|
||||
// Find nearest node (avoids coordinate-system issues with camera.animate)
|
||||
let nearestId: string | null = null;
|
||||
let minDist = Infinity;
|
||||
graph.forEachNode((id, attrs) => {
|
||||
const dx = (attrs.x as number) - targetGraphX;
|
||||
const dy = (attrs.y as number) - targetGraphY;
|
||||
const d = dx * dx + dy * dy;
|
||||
if (d < minDist) { minDist = d; nearestId = id; }
|
||||
});
|
||||
|
||||
if (nearestId) {
|
||||
const display = sigma.getNodeDisplayData(nearestId);
|
||||
if (display) {
|
||||
sigma.getCamera().animate(
|
||||
{ x: display.x, y: display.y },
|
||||
{ duration: 300 },
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[sigma, graph, size, getBounds],
|
||||
);
|
||||
|
||||
if (graph.order === 0) return null;
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onClick={handleClick}
|
||||
className="rounded border bg-background/80 backdrop-blur-sm cursor-crosshair"
|
||||
style={{ width: size, height: size }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import type Sigma from "sigma";
|
||||
import type Graph from "graphology";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface SigmaGraphSearchProps {
|
||||
sigma: Sigma | null;
|
||||
graph: Graph;
|
||||
onNodeSelect?: (nodeId: string | null) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export function SigmaGraphSearch({ sigma, graph, onNodeSelect, placeholder = "Search nodes..." }: SigmaGraphSearchProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Search graph nodes by label
|
||||
const handleSearch = useCallback(
|
||||
(q: string) => {
|
||||
setQuery(q);
|
||||
if (!q.trim() || graph.order === 0) {
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
const lower = q.toLowerCase();
|
||||
const matches: SearchResult[] = [];
|
||||
graph.forEachNode((node, attrs) => {
|
||||
if (matches.length >= 10) return;
|
||||
const label = (attrs.label as string) || "";
|
||||
if (label.toLowerCase().includes(lower)) {
|
||||
matches.push({ id: node, label, color: attrs.color as string });
|
||||
}
|
||||
});
|
||||
setResults(matches);
|
||||
setOpen(matches.length > 0);
|
||||
setActiveIndex(0);
|
||||
},
|
||||
[graph],
|
||||
);
|
||||
|
||||
// Select a result: highlight + camera zoom
|
||||
const selectResult = useCallback(
|
||||
(nodeId: string) => {
|
||||
onNodeSelect?.(nodeId);
|
||||
if (sigma && graph.hasNode(nodeId)) {
|
||||
// Use getNodeDisplayData for correct stage coords (not raw graph coords).
|
||||
// Only zoom in if currently zoomed out (don't force zoom level if already zoomed in).
|
||||
const nodeDisplay = sigma.getNodeDisplayData(nodeId);
|
||||
if (nodeDisplay) {
|
||||
const currentRatio = sigma.getCamera().ratio;
|
||||
sigma.getCamera().animate(
|
||||
{ x: nodeDisplay.x, y: nodeDisplay.y, ratio: Math.min(currentRatio, 0.5) },
|
||||
{ duration: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
},
|
||||
[sigma, graph, onNodeSelect],
|
||||
);
|
||||
|
||||
// Keyboard navigation in dropdown
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (!open || results.length === 0) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.min(i + 1, results.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const r = results[activeIndex];
|
||||
if (r) selectResult(r.id);
|
||||
} else if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
},
|
||||
[open, results, activeIndex, selectResult],
|
||||
);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-56">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => query && results.length > 0 && setOpen(true)}
|
||||
placeholder={placeholder}
|
||||
className="h-7 pl-7 pr-7 text-base md:text-xs"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-autocomplete="list"
|
||||
aria-controls="graph-search-listbox"
|
||||
aria-label={placeholder}
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
onClick={() => { setQuery(""); setResults([]); setOpen(false); }}
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-muted"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dropdown results */}
|
||||
{open && (
|
||||
<div
|
||||
id="graph-search-listbox"
|
||||
role="listbox"
|
||||
className="absolute top-full left-0 right-0 z-50 mt-1 rounded-md border bg-popover shadow-md max-h-60 overflow-y-auto"
|
||||
>
|
||||
{results.map((r, i) => (
|
||||
<button
|
||||
key={r.id}
|
||||
role="option"
|
||||
aria-selected={i === activeIndex}
|
||||
onClick={() => selectResult(r.id)}
|
||||
className={`flex items-center gap-2 w-full px-2.5 py-1.5 text-xs text-left hover:bg-accent ${
|
||||
i === activeIndex ? "bg-accent" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="inline-block h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: r.color }} />
|
||||
<span className="truncate">{r.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useEffect, type RefObject } from "react";
|
||||
import type Sigma from "sigma";
|
||||
import type Graph from "graphology";
|
||||
|
||||
interface UseSigmaKeyboardOptions {
|
||||
sigma: Sigma | null;
|
||||
graph: Graph;
|
||||
/** Container element — shortcuts only fire when this element or its children are focused */
|
||||
containerRef: RefObject<HTMLElement | null>;
|
||||
selectedNodeId?: string | null;
|
||||
onNodeSelect?: (nodeId: string | null) => void;
|
||||
/** Ref to search input — "/" focuses it */
|
||||
searchInputRef?: RefObject<HTMLInputElement | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyboard shortcuts for Sigma graph views.
|
||||
* Only active when the graph container (or a child) is focused.
|
||||
*
|
||||
* | Key | Action |
|
||||
* |---------|---------------------|
|
||||
* | + / = | Zoom in |
|
||||
* | - | Zoom out |
|
||||
* | R | Reset view (fit all)|
|
||||
* | F | Focus selected node |
|
||||
* | Escape | Deselect all |
|
||||
* | / | Focus search input |
|
||||
*/
|
||||
export function useSigmaKeyboard({
|
||||
sigma,
|
||||
graph,
|
||||
containerRef,
|
||||
selectedNodeId,
|
||||
onNodeSelect,
|
||||
searchInputRef,
|
||||
}: UseSigmaKeyboardOptions) {
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container || !sigma) return;
|
||||
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
// Ignore if typing in an input/textarea
|
||||
const tag = (e.target as HTMLElement).tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
|
||||
|
||||
switch (e.key) {
|
||||
case "+":
|
||||
case "=":
|
||||
e.preventDefault();
|
||||
sigma.getCamera().animatedZoom({ duration: 200 });
|
||||
break;
|
||||
case "-":
|
||||
e.preventDefault();
|
||||
sigma.getCamera().animatedUnzoom({ duration: 200 });
|
||||
break;
|
||||
case "r":
|
||||
case "R":
|
||||
e.preventDefault();
|
||||
sigma.getCamera().animatedReset({ duration: 300 });
|
||||
break;
|
||||
case "f":
|
||||
case "F":
|
||||
if (selectedNodeId && graph.hasNode(selectedNodeId)) {
|
||||
e.preventDefault();
|
||||
const nodeDisplay = sigma.getNodeDisplayData(selectedNodeId);
|
||||
if (nodeDisplay) {
|
||||
const currentRatio = sigma.getCamera().ratio;
|
||||
sigma.getCamera().animate(
|
||||
{ x: nodeDisplay.x, y: nodeDisplay.y, ratio: Math.min(currentRatio, 0.5) },
|
||||
{ duration: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
onNodeSelect?.(null);
|
||||
break;
|
||||
case "/":
|
||||
e.preventDefault();
|
||||
searchInputRef?.current?.focus();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
container.addEventListener("keydown", handler);
|
||||
return () => container.removeEventListener("keydown", handler);
|
||||
}, [sigma, graph, containerRef, selectedNodeId, onNodeSelect, searchInputRef]);
|
||||
}
|
||||
@@ -239,7 +239,7 @@ export function KGEntityDetailDialog({ open, onOpenChange, agentId, entity, getE
|
||||
</div>
|
||||
) : graphData ? (
|
||||
<div className="h-[400px]">
|
||||
<KGGraphView entities={graphData.entities} relations={graphData.relations} />
|
||||
<KGGraphView entities={graphData.entities} relations={graphData.relations} compact />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-[400px] text-sm text-muted-foreground">
|
||||
|
||||
@@ -1,286 +1,164 @@
|
||||
import { useRef, useMemo, useCallback, useState, useEffect, useLayoutEffect } from "react";
|
||||
import ForceGraph2D, { type ForceGraphMethods } from "react-force-graph-2d";
|
||||
import { Maximize2, ZoomIn, ZoomOut } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useRef, useMemo, useState, useCallback } from "react";
|
||||
import type Sigma from "sigma";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useUiStore } from "@/stores/use-ui-store";
|
||||
import type { KGEntity, KGRelation } from "@/types/knowledge-graph";
|
||||
import { buildGraphData, limitEntitiesByDegree } from "@/adapters/kg-graph.adapter";
|
||||
import type { GraphNode, GraphLink } from "@/adapters/kg-graph.adapter";
|
||||
import { buildKGGraph, limitEntitiesByDegree, KG_TYPE_COLORS } from "@/adapters/kg-graph.adapter";
|
||||
import { SigmaGraphContainer } from "@/components/graph/sigma-graph-container";
|
||||
import { SigmaGraphControls } from "@/components/graph/sigma-graph-controls";
|
||||
import { SigmaGraphSearch } from "@/components/graph/sigma-graph-search";
|
||||
import { SigmaGraphFilters } from "@/components/graph/sigma-graph-filters";
|
||||
import { SigmaGraphMinimap } from "@/components/graph/sigma-graph-minimap";
|
||||
import { SigmaGraphKeyboardHelp } from "@/components/graph/sigma-graph-keyboard-help";
|
||||
import { useSigmaKeyboard } from "@/components/graph/use-sigma-keyboard";
|
||||
|
||||
const NODE_LIMIT_OPTIONS = [100, 200, 300, 500] as const;
|
||||
const DEFAULT_NODE_LIMIT = 200;
|
||||
const NODE_R = 5;
|
||||
const DOUBLE_CLICK_MS = 280;
|
||||
|
||||
interface KGGraphViewProps {
|
||||
entities: KGEntity[];
|
||||
relations: KGRelation[];
|
||||
onEntityClick?: (entity: KGEntity) => void;
|
||||
/** Compact mode for embedded mini-graphs (entity detail dialog) */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function KGGraphView({ entities: allEntities, relations: allRelations, onEntityClick }: KGGraphViewProps) {
|
||||
export function KGGraphView({ entities: allEntities, relations: allRelations, onEntityClick, compact = false }: KGGraphViewProps) {
|
||||
const { t } = useTranslation("memory");
|
||||
const theme = useUiStore((s) => s.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 searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const [sigma, setSigma] = useState<Sigma | null>(null);
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const [nodeLimit, setNodeLimit] = useState(DEFAULT_NODE_LIMIT);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [zoomLevel, setZoomLevel] = useState(1);
|
||||
const [hiddenTypes, setHiddenTypes] = useState<Set<string>>(new Set());
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
|
||||
const ZOOM_STEP = 1.5;
|
||||
|
||||
const handleZoomIn = useCallback(() => {
|
||||
const fg = graphRef.current;
|
||||
if (!fg) return;
|
||||
fg.zoom(Math.min((fg.zoom() ?? 1) * ZOOM_STEP, 8), 300);
|
||||
}, []);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
const fg = graphRef.current;
|
||||
if (!fg) return;
|
||||
fg.zoom(Math.max((fg.zoom() ?? 1) / ZOOM_STEP, 0.1), 300);
|
||||
}, []);
|
||||
|
||||
// Double-click detection refs
|
||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastClickRef = useRef<string | null>(null);
|
||||
|
||||
// --- Container sizing ---
|
||||
// 1. Synchronous initial read before first paint to avoid wrong-size flash
|
||||
useLayoutEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (el && el.clientWidth > 0 && el.clientHeight > 0) {
|
||||
setDimensions({ width: el.clientWidth, height: el.clientHeight });
|
||||
setReady(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 2. ResizeObserver for ongoing size changes (window resize, sidebar toggle)
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
if (!entry) return;
|
||||
const { width, height } = entry.contentRect;
|
||||
if (width > 0 && height > 0) {
|
||||
setDimensions({ width, height });
|
||||
setReady(true);
|
||||
}
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// --- Limit entities by degree centrality ---
|
||||
const totalCount = allEntities.length;
|
||||
const isLimited = totalCount > nodeLimit;
|
||||
const entities = useMemo(
|
||||
() => limitEntitiesByDegree(allEntities, allRelations, nodeLimit),
|
||||
[allEntities, allRelations, nodeLimit],
|
||||
);
|
||||
|
||||
const entityMap = useMemo(() => new Map(entities.map((e) => [e.id, e])), [entities]);
|
||||
const graph = useMemo(() => buildKGGraph(entities, allRelations), [entities, allRelations]);
|
||||
|
||||
// --- Build graph data for react-force-graph ---
|
||||
const graphData = useMemo(
|
||||
() => buildGraphData(entities, allRelations),
|
||||
[entities, allRelations],
|
||||
);
|
||||
const handleNodeDoubleClick = useCallback((nodeId: string) => {
|
||||
const entity = entityMap.get(nodeId);
|
||||
if (entity) onEntityClick?.(entity);
|
||||
}, [entityMap, onEntityClick]);
|
||||
|
||||
// --- 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);
|
||||
}, []);
|
||||
|
||||
const handleEngineStop = useCallback(() => {
|
||||
// No auto zoom — initial view from warmupTicks is already good
|
||||
}, []);
|
||||
useSigmaKeyboard({
|
||||
sigma: compact ? null : sigma,
|
||||
graph,
|
||||
containerRef,
|
||||
selectedNodeId,
|
||||
onNodeSelect: setSelectedNodeId,
|
||||
searchInputRef,
|
||||
});
|
||||
|
||||
const hasEntities = allEntities.length > 0;
|
||||
|
||||
// Compact mode: simple graph only
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="flex h-full flex-col rounded-md border overflow-hidden bg-background">
|
||||
<div className="min-h-0 flex-1 relative">
|
||||
{!hasEntities ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{t("kg.graphView.empty")}
|
||||
</div>
|
||||
) : (
|
||||
<SigmaGraphContainer
|
||||
graph={graph}
|
||||
edgeType="curvedArrow"
|
||||
selectedNodeId={selectedNodeId}
|
||||
onNodeSelect={setSelectedNodeId}
|
||||
onNodeDoubleClick={handleNodeDoubleClick}
|
||||
onSigmaReady={setSigma}
|
||||
compact
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col rounded-md border overflow-hidden bg-background">
|
||||
<div ref={containerRef} className="min-h-0 flex-1 relative">
|
||||
<div
|
||||
ref={containerRef}
|
||||
tabIndex={0}
|
||||
role="application"
|
||||
aria-label={`Knowledge graph with ${totalCount} entities and ${allRelations.length} relations`}
|
||||
className="flex h-full flex-col rounded-md border overflow-hidden bg-background outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
||||
>
|
||||
{/* Top bar — responsive: legend stacks on mobile */}
|
||||
{hasEntities && (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 px-3 py-1 border-b shrink-0">
|
||||
{/* KG type legend */}
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-muted-foreground flex-1 min-w-0">
|
||||
{Object.entries(KG_TYPE_COLORS).map(([type, color]) => (
|
||||
<span key={type} className="flex items-center gap-1">
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-full" style={{ backgroundColor: color }} />
|
||||
{type}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0 relative">
|
||||
<SigmaGraphSearch
|
||||
sigma={sigma}
|
||||
graph={graph}
|
||||
onNodeSelect={setSelectedNodeId}
|
||||
placeholder={t("kg.graphView.search", { defaultValue: "Search entities..." })}
|
||||
/>
|
||||
<SigmaGraphFilters
|
||||
graph={graph}
|
||||
typeColors={KG_TYPE_COLORS}
|
||||
hiddenTypes={hiddenTypes}
|
||||
onHiddenTypesChange={setHiddenTypes}
|
||||
collapsed={!filtersOpen}
|
||||
onCollapsedChange={(c) => setFiltersOpen(!c)}
|
||||
/>
|
||||
<SigmaGraphKeyboardHelp />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Graph canvas + minimap overlay */}
|
||||
<div className="min-h-0 flex-1 relative">
|
||||
{!hasEntities ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">{t("kg.graphView.empty")}</div>
|
||||
) : ready ? <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}
|
||||
onZoom={(transform) => setZoomLevel(transform.k)}
|
||||
onEngineStop={handleEngineStop}
|
||||
backgroundColor="transparent"
|
||||
d3AlphaDecay={0.04}
|
||||
d3VelocityDecay={0.3}
|
||||
warmupTicks={40}
|
||||
cooldownTime={4000}
|
||||
enableNodeDrag={true}
|
||||
minZoom={0.1}
|
||||
maxZoom={8}
|
||||
/> : null}
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{t("kg.graphView.empty")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<SigmaGraphContainer
|
||||
graph={graph}
|
||||
edgeType="curvedArrow"
|
||||
selectedNodeId={selectedNodeId}
|
||||
onNodeSelect={setSelectedNodeId}
|
||||
onNodeDoubleClick={handleNodeDoubleClick}
|
||||
onSigmaReady={setSigma}
|
||||
hiddenTypes={hiddenTypes}
|
||||
/>
|
||||
<div className="absolute bottom-2 right-2 z-10 hidden sm:block">
|
||||
<SigmaGraphMinimap sigma={sigma} graph={graph} size={120} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats bar */}
|
||||
<div className="flex items-center gap-3 px-3 py-1.5 border-t text-2xs text-muted-foreground">
|
||||
<span>{t("kg.graphView.nodes", { count: totalCount })}</span>
|
||||
<span>{t("kg.graphView.edges", { count: allRelations.length })}</span>
|
||||
{isLimited && (
|
||||
<span>· {t("kg.graphView.limitNote", { limit: nodeLimit, total: totalCount })}</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5" onClick={handleZoomOut}>
|
||||
<ZoomOut className="h-3 w-3" />
|
||||
</Button>
|
||||
<span className="w-9 text-center">{Math.round(zoomLevel * 100)}%</span>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5" onClick={handleZoomIn}>
|
||||
<ZoomIn className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<select
|
||||
value={nodeLimit}
|
||||
onChange={(e) => setNodeLimit(Number(e.target.value))}
|
||||
className="h-5 rounded border bg-background px-1 text-2xs"
|
||||
>
|
||||
{NODE_LIMIT_OPTIONS.map((n) => (
|
||||
<option key={n} value={n}>{n} nodes</option>
|
||||
))}
|
||||
</select>
|
||||
<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>
|
||||
<SigmaGraphControls
|
||||
sigma={sigma}
|
||||
nodeLimit={nodeLimit}
|
||||
isLimited={isLimited}
|
||||
onNodeLimitChange={setNodeLimit}
|
||||
labels={{
|
||||
nodes: t("kg.graphView.nodes", { count: totalCount }),
|
||||
edges: t("kg.graphView.edges", { count: allRelations.length }),
|
||||
limitNote: isLimited
|
||||
? t("kg.graphView.limitNote", { limit: nodeLimit, total: totalCount })
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import type { RefObject } from "react";
|
||||
import { ZoomIn, ZoomOut, Maximize2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const NODE_LIMIT_OPTIONS = [100, 200, 300, 500] as const;
|
||||
|
||||
interface Props {
|
||||
docCount: number;
|
||||
linkCount: number;
|
||||
nodeLimit: number;
|
||||
isLimited: boolean;
|
||||
zoomDisplayRef: RefObject<HTMLSpanElement | null>;
|
||||
onNodeLimitChange: (limit: number) => void;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onFitToView: () => void;
|
||||
}
|
||||
|
||||
export function VaultGraphControls({
|
||||
docCount, linkCount, nodeLimit, isLimited, zoomDisplayRef,
|
||||
onNodeLimitChange, onZoomIn, onZoomOut, onFitToView,
|
||||
}: Props) {
|
||||
const { t } = useTranslation("vault");
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-3 py-1.5 border-t text-2xs text-muted-foreground">
|
||||
<span>{t("graphDocs", { count: docCount, defaultValue: "{{count}} docs" })}</span>
|
||||
<span>{t("graphLinks", { count: linkCount, defaultValue: "{{count}} links" })}</span>
|
||||
{isLimited && (
|
||||
<span>· {t("graphLimitNote", { limit: nodeLimit, total: docCount, defaultValue: "showing {{limit}} of {{total}}" })}</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5" onClick={onZoomOut}>
|
||||
<ZoomOut className="h-3 w-3" />
|
||||
</Button>
|
||||
<span ref={zoomDisplayRef} className="w-9 text-center">100%</span>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5" onClick={onZoomIn}>
|
||||
<ZoomIn className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<select
|
||||
value={nodeLimit}
|
||||
onChange={(e) => onNodeLimitChange(Number(e.target.value))}
|
||||
className="h-5 rounded border bg-background px-1 text-base md:text-2xs"
|
||||
>
|
||||
{NODE_LIMIT_OPTIONS.map((n) => (
|
||||
<option key={n} value={n}>{n} nodes</option>
|
||||
))}
|
||||
</select>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5" onClick={onFitToView}>
|
||||
<Maximize2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useRef, useMemo, useEffect, useLayoutEffect, useCallback, useState } from "react";
|
||||
import ForceGraph2D, { type ForceGraphMethods } from "react-force-graph-2d";
|
||||
import { useUiStore } from "@/stores/use-ui-store";
|
||||
import { buildVaultGraphData, limitVaultDocsByDegree, VAULT_TYPE_COLORS } from "@/adapters/vault-graph-adapter";
|
||||
import type { VaultGraphNode, VaultGraphLink } from "@/adapters/vault-graph-adapter";
|
||||
import { useRef, useMemo, useState, useCallback } from "react";
|
||||
import type Sigma from "sigma";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { buildVaultGraph, limitVaultDocsByDegree, VAULT_TYPE_COLORS } from "@/adapters/vault-graph-adapter";
|
||||
import { SigmaGraphContainer } from "@/components/graph/sigma-graph-container";
|
||||
import { SigmaGraphControls } from "@/components/graph/sigma-graph-controls";
|
||||
import { SigmaGraphSearch } from "@/components/graph/sigma-graph-search";
|
||||
import { SigmaGraphFilters } from "@/components/graph/sigma-graph-filters";
|
||||
import { SigmaGraphMinimap } from "@/components/graph/sigma-graph-minimap";
|
||||
import { SigmaGraphKeyboardHelp } from "@/components/graph/sigma-graph-keyboard-help";
|
||||
import { useSigmaKeyboard } from "@/components/graph/use-sigma-keyboard";
|
||||
import type { VaultDocument } from "@/types/vault";
|
||||
import { useVaultGraphData } from "./hooks/use-vault";
|
||||
import { VaultGraphControls } from "./vault-graph-controls";
|
||||
|
||||
const NODE_R = 5;
|
||||
const DOUBLE_CLICK_MS = 280;
|
||||
const DEFAULT_NODE_LIMIT = 200;
|
||||
|
||||
interface Props {
|
||||
@@ -20,201 +23,114 @@ interface Props {
|
||||
}
|
||||
|
||||
export function VaultGraphView({ agentId, teamId, selectedDocId, onNodeSelect, onNodeDoubleClick }: Props) {
|
||||
const theme = useUiStore((s) => s.theme);
|
||||
const isDark = theme === "dark" || (theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
const { t } = useTranslation("vault");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const [sigma, setSigma] = useState<Sigma | null>(null);
|
||||
const [nodeLimit, setNodeLimit] = useState(DEFAULT_NODE_LIMIT);
|
||||
const [hiddenTypes, setHiddenTypes] = useState<Set<string>>(new Set());
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
|
||||
const { documents: allDocs, links, loading } = useVaultGraphData(agentId, { teamId });
|
||||
|
||||
const graphRef = useRef<ForceGraphMethods<VaultGraphNode, VaultGraphLink>>(undefined);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dimensions, setDimensions] = useState({ width: 600, height: 400 });
|
||||
const [ready, setReady] = useState(false);
|
||||
const [nodeLimit, setNodeLimit] = useState(DEFAULT_NODE_LIMIT);
|
||||
const zoomRef = useRef(1);
|
||||
const zoomDisplayRef = useRef<HTMLSpanElement>(null);
|
||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastClickRef = useRef<string | null>(null);
|
||||
|
||||
// Container sizing
|
||||
useLayoutEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (el && el.clientWidth > 0 && el.clientHeight > 0) {
|
||||
setDimensions({ width: el.clientWidth, height: el.clientHeight });
|
||||
setReady(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
if (!entry) return;
|
||||
const { width, height } = entry.contentRect;
|
||||
if (width > 0 && height > 0) { setDimensions({ width, height }); setReady(true); }
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Pan camera to externally-selected node
|
||||
useEffect(() => {
|
||||
if (!selectedDocId || !graphRef.current) return;
|
||||
const node = graphData.nodes.find((n) => n.id === selectedDocId);
|
||||
if (node?.x != null && node?.y != null) {
|
||||
graphRef.current.centerAt(node.x, node.y, 300);
|
||||
}
|
||||
}, [selectedDocId]);
|
||||
|
||||
const totalCount = allDocs.length;
|
||||
const isLimited = totalCount > nodeLimit;
|
||||
const documents = useMemo(() => limitVaultDocsByDegree(allDocs, links, nodeLimit), [allDocs, links, nodeLimit]);
|
||||
const docMap = useMemo(() => new Map(documents.map((d) => [d.id, d])), [documents]);
|
||||
const graphData = useMemo(() => buildVaultGraphData(documents, links), [documents, links]);
|
||||
const graph = useMemo(() => buildVaultGraph(documents, links), [documents, links]);
|
||||
|
||||
// Highlight sets from selectedDocId (controlled by parent)
|
||||
const { highlightNodes, highlightLinks } = useMemo(() => {
|
||||
if (!selectedDocId) return { highlightNodes: new Set<string>(), highlightLinks: new Set<string>() };
|
||||
const node = graphData.nodes.find((n) => n.id === selectedDocId);
|
||||
if (!node) return { highlightNodes: new Set<string>(), highlightLinks: new Set<string>() };
|
||||
return { highlightNodes: new Set([selectedDocId, ...node.neighbors]), highlightLinks: new Set(node.linkIds) };
|
||||
}, [selectedDocId, graphData.nodes]);
|
||||
const handleNodeDoubleClick = useCallback((nodeId: string) => {
|
||||
const doc = docMap.get(nodeId);
|
||||
if (doc) onNodeDoubleClick?.(doc);
|
||||
}, [docMap, onNodeDoubleClick]);
|
||||
|
||||
const nodeCanvasObject = useCallback(
|
||||
(node: VaultGraphNode, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
const x = node.x ?? 0, y = node.y ?? 0;
|
||||
const isSelected = node.id === selectedDocId;
|
||||
const isNeighbor = highlightNodes.has(node.id);
|
||||
const isDimmed = !!selectedDocId && !isSelected && !isNeighbor;
|
||||
const r = NODE_R + Math.min(node.degree, 10);
|
||||
useSigmaKeyboard({
|
||||
sigma,
|
||||
graph,
|
||||
containerRef,
|
||||
selectedNodeId: selectedDocId,
|
||||
onNodeSelect,
|
||||
searchInputRef,
|
||||
});
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, r, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = isDimmed ? `${node.color}30` : node.color;
|
||||
ctx.fill();
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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");
|
||||
// Adaptive truncation: short labels when zoomed out, full when zoomed in
|
||||
const maxChars = isSelected ? node.title.length
|
||||
: globalScale < 0.8 ? 10
|
||||
: globalScale < 1.5 ? 18
|
||||
: globalScale < 3 ? 30
|
||||
: node.title.length;
|
||||
const label = node.title.length > maxChars ? node.title.slice(0, maxChars) + "…" : node.title;
|
||||
ctx.fillText(label, x, y + r + 2 / globalScale);
|
||||
}
|
||||
},
|
||||
[selectedDocId, highlightNodes, isDark],
|
||||
);
|
||||
|
||||
const linkCanvasObject = useCallback(
|
||||
(link: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
if (!highlightLinks.has(link.id)) return;
|
||||
const src = link.source, tgt = link.target;
|
||||
if (!src?.x || !tgt?.x) return;
|
||||
// Only show link labels when zoomed in enough to read them
|
||||
if (globalScale < 1.5) return;
|
||||
const midX = (src.x + tgt.x) / 2, midY = (src.y + tgt.y) / 2;
|
||||
const fontSize = Math.max(9 / globalScale, 2);
|
||||
const text = link.label || "";
|
||||
ctx.font = `${fontSize}px Inter, system-ui, sans-serif`;
|
||||
ctx.textAlign = "center"; ctx.textBaseline = "middle";
|
||||
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);
|
||||
ctx.fillStyle = isDark ? "#94a3b8" : "#64748b";
|
||||
ctx.fillText(text, midX, midY);
|
||||
},
|
||||
[highlightLinks, isDark],
|
||||
);
|
||||
|
||||
// Single-click = highlight (notify parent), double-click = open detail
|
||||
const handleNodeClick = useCallback(
|
||||
(node: VaultGraphNode) => {
|
||||
if (clickTimerRef.current && lastClickRef.current === node.id) {
|
||||
clearTimeout(clickTimerRef.current); clickTimerRef.current = null; lastClickRef.current = null;
|
||||
const doc = docMap.get(node.id);
|
||||
if (doc) onNodeDoubleClick?.(doc);
|
||||
return;
|
||||
}
|
||||
if (clickTimerRef.current) clearTimeout(clickTimerRef.current);
|
||||
lastClickRef.current = node.id;
|
||||
clickTimerRef.current = setTimeout(() => {
|
||||
onNodeSelect?.(node.id === selectedDocId ? null : node.id);
|
||||
clickTimerRef.current = null;
|
||||
}, DOUBLE_CLICK_MS);
|
||||
},
|
||||
[docMap, selectedDocId, onNodeSelect, onNodeDoubleClick],
|
||||
);
|
||||
|
||||
const handleZoomIn = useCallback(() => { const fg = graphRef.current; if (fg) fg.zoom(Math.min((fg.zoom() ?? 1) * 1.5, 8), 300); }, []);
|
||||
const handleZoomOut = useCallback(() => { const fg = graphRef.current; if (fg) fg.zoom(Math.max((fg.zoom() ?? 1) / 1.5, 0.1), 300); }, []);
|
||||
const handleFitToView = useCallback(() => graphRef.current?.zoomToFit(300, 20), []);
|
||||
const hasData = allDocs.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background">
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground px-3 py-1 border-b shrink-0">
|
||||
{Object.entries(VAULT_TYPE_COLORS).map(([type, color]) => (
|
||||
<span key={type} className="flex items-center gap-1">
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-full" style={{ backgroundColor: color }} />
|
||||
{type}
|
||||
</span>
|
||||
))}
|
||||
<div
|
||||
ref={containerRef}
|
||||
tabIndex={0}
|
||||
role="application"
|
||||
aria-label={`Vault knowledge graph with ${totalCount} documents and ${links.length} links`}
|
||||
className="flex h-full flex-col overflow-hidden bg-background outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
||||
>
|
||||
{/* Top bar — responsive: stacks on narrow screens */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 px-3 py-1 border-b shrink-0">
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-muted-foreground flex-1 min-w-0">
|
||||
{Object.entries(VAULT_TYPE_COLORS).map(([type, color]) => (
|
||||
<span key={type} className="flex items-center gap-1">
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-full" style={{ backgroundColor: color }} />
|
||||
{type}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{hasData && (
|
||||
<div className="flex items-center gap-1 shrink-0 relative">
|
||||
<SigmaGraphSearch
|
||||
sigma={sigma}
|
||||
graph={graph}
|
||||
onNodeSelect={onNodeSelect}
|
||||
placeholder={t("graphSearch", { defaultValue: "Search docs..." })}
|
||||
/>
|
||||
<SigmaGraphFilters
|
||||
graph={graph}
|
||||
typeColors={VAULT_TYPE_COLORS}
|
||||
hiddenTypes={hiddenTypes}
|
||||
onHiddenTypesChange={setHiddenTypes}
|
||||
collapsed={!filtersOpen}
|
||||
onCollapsedChange={(c) => setFiltersOpen(!c)}
|
||||
/>
|
||||
<SigmaGraphKeyboardHelp />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Graph canvas */}
|
||||
<div ref={containerRef} className="min-h-0 flex-1 relative">
|
||||
{/* Graph canvas + minimap overlay */}
|
||||
<div className="min-h-0 flex-1 relative">
|
||||
{loading && allDocs.length === 0 ? (
|
||||
<div className="h-full animate-pulse rounded-md bg-muted" />
|
||||
) : allDocs.length === 0 ? (
|
||||
) : !hasData ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">No documents</div>
|
||||
) : ready ? (
|
||||
<ForceGraph2D
|
||||
ref={graphRef} graphData={graphData} width={dimensions.width} height={dimensions.height}
|
||||
nodeCanvasObject={nodeCanvasObject} nodeCanvasObjectMode={() => "replace"}
|
||||
nodePointerAreaPaint={(node, color, ctx) => {
|
||||
const r = NODE_R + Math.min((node as VaultGraphNode).degree, 10) + 3;
|
||||
ctx.beginPath(); ctx.arc(node.x ?? 0, node.y ?? 0, r, 0, 2 * Math.PI); ctx.fillStyle = color; ctx.fill();
|
||||
}}
|
||||
onNodeClick={handleNodeClick}
|
||||
onBackgroundClick={() => onNodeSelect?.(null)}
|
||||
onZoom={(transform) => {
|
||||
zoomRef.current = transform.k;
|
||||
if (zoomDisplayRef.current) zoomDisplayRef.current.textContent = `${Math.round(transform.k * 100)}%`;
|
||||
}}
|
||||
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}
|
||||
linkDirectionalArrowLength={4} linkDirectionalArrowRelPos={1}
|
||||
backgroundColor="transparent" d3AlphaDecay={0.04} d3VelocityDecay={0.3}
|
||||
warmupTicks={40} cooldownTime={4000} enableNodeDrag minZoom={0.1} maxZoom={8}
|
||||
/>
|
||||
) : null}
|
||||
) : (
|
||||
<>
|
||||
<SigmaGraphContainer
|
||||
graph={graph}
|
||||
edgeType="curvedArrow"
|
||||
selectedNodeId={selectedDocId}
|
||||
onNodeSelect={onNodeSelect}
|
||||
onNodeDoubleClick={handleNodeDoubleClick}
|
||||
onSigmaReady={setSigma}
|
||||
hiddenTypes={hiddenTypes}
|
||||
/>
|
||||
<div className="absolute bottom-2 right-2 z-10 hidden sm:block">
|
||||
<SigmaGraphMinimap sigma={sigma} graph={graph} size={120} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats bar */}
|
||||
<VaultGraphControls
|
||||
docCount={totalCount} linkCount={links.length} nodeLimit={nodeLimit} isLimited={isLimited}
|
||||
zoomDisplayRef={zoomDisplayRef} onNodeLimitChange={setNodeLimit}
|
||||
onZoomIn={handleZoomIn} onZoomOut={handleZoomOut} onFitToView={handleFitToView}
|
||||
<SigmaGraphControls
|
||||
sigma={sigma}
|
||||
nodeLimit={nodeLimit}
|
||||
isLimited={isLimited}
|
||||
onNodeLimitChange={setNodeLimit}
|
||||
labels={{
|
||||
nodes: t("graphDocs", { count: totalCount, defaultValue: "{{count}} docs" }),
|
||||
edges: t("graphLinks", { count: links.length, defaultValue: "{{count}} links" }),
|
||||
limitNote: isLimited
|
||||
? t("graphLimitNote", { limit: nodeLimit, total: totalCount, defaultValue: "showing {{limit}} of {{total}}" })
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user