diff --git a/ui/web/package.json b/ui/web/package.json index e78ae991..e77ea01a 100644 --- a/ui/web/package.json +++ b/ui/web/package.json @@ -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", diff --git a/ui/web/pnpm-lock.yaml b/ui/web/pnpm-lock.yaml index 6668d561..1ae17066 100644 --- a/ui/web/pnpm-lock.yaml +++ b/ui/web/pnpm-lock.yaml @@ -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: diff --git a/ui/web/src/adapters/kg-graph.adapter.ts b/ui/web/src/adapters/kg-graph.adapter.ts index 947f3afa..d33bd5bf 100644 --- a/ui/web/src/adapters/kg-graph.adapter.ts +++ b/ui/web/src/adapters/kg-graph.adapter.ts @@ -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 = { person: "#E85D24", organization: "#ef4444", project: "#22c55e", product: "#f97316", technology: "#3b82f6", task: "#f59e0b", @@ -8,31 +10,6 @@ export const KG_TYPE_COLORS: Record = { }; export const KG_DEFAULT_COLOR = "#9ca3af"; -export interface GraphNode { - id: string; - name: string; - entityType: string; - color: string; - neighbors: Set; - linkIds: Set; - 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 { const deg = new Map(); @@ -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(), - linkIds: new Set(), - 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[], diff --git a/ui/web/src/adapters/vault-graph-adapter.ts b/ui/web/src/adapters/vault-graph-adapter.ts index 5acd027c..334e25a2 100644 --- a/ui/web/src/adapters/vault-graph-adapter.ts +++ b/ui/web/src/adapters/vault-graph-adapter.ts @@ -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 = { @@ -11,30 +13,6 @@ export const VAULT_TYPE_COLORS: Record = { }; const DEFAULT_COLOR = "#9ca3af"; -export interface VaultGraphNode { - id: string; - title: string; - docType: string; - color: string; - neighbors: Set; - linkIds: Set; - 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(); 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(), - linkIds: new Set(), - 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; } diff --git a/ui/web/src/components/graph/graph-utils.ts b/ui/web/src/components/graph/graph-utils.ts new file mode 100644 index 00000000..a562580c --- /dev/null +++ b/ui/web/src/components/graph/graph-utils.ts @@ -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; diff --git a/ui/web/src/components/graph/sigma-graph-container.tsx b/ui/web/src/components/graph/sigma-graph-container.tsx new file mode 100644 index 00000000..3aa73128 --- /dev/null +++ b/ui/web/src/components/graph/sigma-graph-container.tsx @@ -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; +} + +/** 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(null); + const internalSigmaRef = useRef(null); + const [hoveredNode, setHoveredNode] = useState(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(); + 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 = { + 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; edges: Set } | null>(null); + useEffect(() => { + const active = selectedNodeId || hoveredNode; + if (!active || !graph.hasNode(active)) { + neighborhoodRef.current = null; + return; + } + const nodes = new Set([active]); + const edges = new Set(); + 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) => + (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 ( +
+ No data to display +
+ ); + } + + return ( +
+ ); +} diff --git a/ui/web/src/components/graph/sigma-graph-controls.tsx b/ui/web/src/components/graph/sigma-graph-controls.tsx new file mode 100644 index 00000000..2276e51a --- /dev/null +++ b/ui/web/src/components/graph/sigma-graph-controls.tsx @@ -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 ( +
+ {labels.nodes} + {labels.edges} + {isLimited && labels.limitNote && ( + · {labels.limitNote} + )} +
+
+ + {zoomPercent}% + +
+ + +
+ ); +} diff --git a/ui/web/src/components/graph/sigma-graph-filters.tsx b/ui/web/src/components/graph/sigma-graph-filters.tsx new file mode 100644 index 00000000..1f949c3b --- /dev/null +++ b/ui/web/src/components/graph/sigma-graph-filters.tsx @@ -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; + defaultColor?: string; + /** Currently hidden types (controlled state) */ + hiddenTypes: Set; + onHiddenTypesChange: (types: Set) => void; + collapsed: boolean; + onCollapsedChange: (collapsed: boolean) => void; +} + +export function SigmaGraphFilters({ + graph, typeColors, defaultColor = "#9ca3af", + hiddenTypes, onHiddenTypesChange, + collapsed, onCollapsedChange, +}: SigmaGraphFiltersProps) { + const containerRef = useRef(null); + + // Memoize type counts to avoid iterating all nodes on every render + const typeCounts = useMemo(() => { + if (graph.order === 0) return []; + const counts = new Map(); + 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 ( +
+ + + {!collapsed && ( +
+ {typeCounts.map(({ type, color, count }) => { + const hidden = hiddenTypes.has(type); + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/ui/web/src/components/graph/sigma-graph-keyboard-help.tsx b/ui/web/src/components/graph/sigma-graph-keyboard-help.tsx new file mode 100644 index 00000000..c86a693a --- /dev/null +++ b/ui/web/src/components/graph/sigma-graph-keyboard-help.tsx @@ -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(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 ( +
+ + + {open && ( +
+
+

Keyboard shortcuts

+ +
+
+ {SHORTCUTS.map(({ keys, description }) => ( +
+
{description}
+
+ {keys.map((k) => ( + + {k} + + ))} +
+
+ ))} +
+
+ )} +
+ ); +} diff --git a/ui/web/src/components/graph/sigma-graph-minimap.tsx b/ui/web/src/components/graph/sigma-graph-minimap.tsx new file mode 100644 index 00000000..b53a2555 --- /dev/null +++ b/ui/web/src/components/graph/sigma-graph-minimap.tsx @@ -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(null); + const rafRef = useRef(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) => { + 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 ( + + ); +} diff --git a/ui/web/src/components/graph/sigma-graph-search.tsx b/ui/web/src/components/graph/sigma-graph-search.tsx new file mode 100644 index 00000000..f6260645 --- /dev/null +++ b/ui/web/src/components/graph/sigma-graph-search.tsx @@ -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([]); + const [open, setOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(0); + const inputRef = useRef(null); + const containerRef = useRef(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 ( +
+
+ + 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 && ( + + )} +
+ + {/* Dropdown results */} + {open && ( +
+ {results.map((r, i) => ( + + ))} +
+ )} +
+ ); +} diff --git a/ui/web/src/components/graph/use-sigma-keyboard.ts b/ui/web/src/components/graph/use-sigma-keyboard.ts new file mode 100644 index 00000000..c1f8c589 --- /dev/null +++ b/ui/web/src/components/graph/use-sigma-keyboard.ts @@ -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; + selectedNodeId?: string | null; + onNodeSelect?: (nodeId: string | null) => void; + /** Ref to search input — "/" focuses it */ + searchInputRef?: RefObject; +} + +/** + * 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]); +} diff --git a/ui/web/src/pages/memory/knowledge-graph/kg-entity-detail-dialog.tsx b/ui/web/src/pages/memory/knowledge-graph/kg-entity-detail-dialog.tsx index ad7d70e1..8bf85358 100644 --- a/ui/web/src/pages/memory/knowledge-graph/kg-entity-detail-dialog.tsx +++ b/ui/web/src/pages/memory/knowledge-graph/kg-entity-detail-dialog.tsx @@ -239,7 +239,7 @@ export function KGEntityDetailDialog({ open, onOpenChange, agentId, entity, getE
) : graphData ? (
- +
) : (
diff --git a/ui/web/src/pages/memory/knowledge-graph/kg-graph-view.tsx b/ui/web/src/pages/memory/knowledge-graph/kg-graph-view.tsx index 8b4e1204..283725a7 100644 --- a/ui/web/src/pages/memory/knowledge-graph/kg-graph-view.tsx +++ b/ui/web/src/pages/memory/knowledge-graph/kg-graph-view.tsx @@ -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>(undefined); const containerRef = useRef(null); - const [dimensions, setDimensions] = useState({ width: 600, height: 400 }); + const searchInputRef = useRef(null); + const [sigma, setSigma] = useState(null); const [selectedNodeId, setSelectedNodeId] = useState(null); const [nodeLimit, setNodeLimit] = useState(DEFAULT_NODE_LIMIT); - const [ready, setReady] = useState(false); - const [zoomLevel, setZoomLevel] = useState(1); + const [hiddenTypes, setHiddenTypes] = useState>(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 | null>(null); - const lastClickRef = useRef(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(), highlightLinks: new Set() }; - const node = graphData.nodes.find((n) => n.id === selectedNodeId); - if (!node) return { highlightNodes: new Set(), highlightLinks: new Set() }; - 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 ( +
+
+ {!hasEntities ? ( +
+ {t("kg.graphView.empty")} +
+ ) : ( + + )} +
+
+ ); + } + return ( -
-
+
+ {/* Top bar — responsive: legend stacks on mobile */} + {hasEntities && ( +
+ {/* KG type legend */} +
+ {Object.entries(KG_TYPE_COLORS).map(([type, color]) => ( + + + {type} + + ))} +
+
+ + setFiltersOpen(!c)} + /> + +
+
+ )} + + {/* Graph canvas + minimap overlay */} +
{!hasEntities ? ( -
{t("kg.graphView.empty")}
- ) : ready ? "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} +
+ {t("kg.graphView.empty")} +
+ ) : ( + <> + +
+ +
+ + )}
{/* Stats bar */} -
- {t("kg.graphView.nodes", { count: totalCount })} - {t("kg.graphView.edges", { count: allRelations.length })} - {isLimited && ( - · {t("kg.graphView.limitNote", { limit: nodeLimit, total: totalCount })} - )} -
-
- - {Math.round(zoomLevel * 100)}% - -
- - -
+
); } diff --git a/ui/web/src/pages/vault/vault-graph-controls.tsx b/ui/web/src/pages/vault/vault-graph-controls.tsx deleted file mode 100644 index cd7994db..00000000 --- a/ui/web/src/pages/vault/vault-graph-controls.tsx +++ /dev/null @@ -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; - 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 ( -
- {t("graphDocs", { count: docCount, defaultValue: "{{count}} docs" })} - {t("graphLinks", { count: linkCount, defaultValue: "{{count}} links" })} - {isLimited && ( - · {t("graphLimitNote", { limit: nodeLimit, total: docCount, defaultValue: "showing {{limit}} of {{total}}" })} - )} -
-
- - 100% - -
- - -
- ); -} diff --git a/ui/web/src/pages/vault/vault-graph-view.tsx b/ui/web/src/pages/vault/vault-graph-view.tsx index 123d1f6b..585aeebb 100644 --- a/ui/web/src/pages/vault/vault-graph-view.tsx +++ b/ui/web/src/pages/vault/vault-graph-view.tsx @@ -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(null); + const searchInputRef = useRef(null); + const [sigma, setSigma] = useState(null); + const [nodeLimit, setNodeLimit] = useState(DEFAULT_NODE_LIMIT); + const [hiddenTypes, setHiddenTypes] = useState>(new Set()); + const [filtersOpen, setFiltersOpen] = useState(false); const { documents: allDocs, links, loading } = useVaultGraphData(agentId, { teamId }); - const graphRef = useRef>(undefined); - const containerRef = useRef(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(null); - const clickTimerRef = useRef | null>(null); - const lastClickRef = useRef(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(), highlightLinks: new Set() }; - const node = graphData.nodes.find((n) => n.id === selectedDocId); - if (!node) return { highlightNodes: new Set(), highlightLinks: new Set() }; - 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 ( -
- {/* Legend */} -
- {Object.entries(VAULT_TYPE_COLORS).map(([type, color]) => ( - - - {type} - - ))} +
+ {/* Top bar — responsive: stacks on narrow screens */} +
+
+ {Object.entries(VAULT_TYPE_COLORS).map(([type, color]) => ( + + + {type} + + ))} +
+ {hasData && ( +
+ + setFiltersOpen(!c)} + /> + +
+ )}
- {/* Graph canvas */} -
+ {/* Graph canvas + minimap overlay */} +
{loading && allDocs.length === 0 ? (
- ) : allDocs.length === 0 ? ( + ) : !hasData ? (
No documents
- ) : ready ? ( - "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} + ) : ( + <> + +
+ +
+ + )}
{/* Stats bar */} -
);