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:
viettranx committed 2026-04-10 12:56:00 +07:00
1 parent 2731f99ad5
commit 79b5dba5c7
16 files changed
+1626 -786

No files matched your search

+8 -3
View File
@@ -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",
+145 -172
View File
@@ -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:
+33 -60
View File
@@ -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[],
+34 -60
View File
@@ -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>
);
}
+99 -183
View File
@@ -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>
);