From eaef11e1e3776cbee7930874b1ea703280f2e01a Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Mon, 1 Jun 2026 16:43:09 +0300 Subject: [PATCH 01/16] refactor: update dependencies and improve graph handling - Changed the dependency for @falkordb/canvas to a local file reference. - Introduced graphDataToData function to standardize graph data handling across components. - Removed cooldownTicks state management and replaced it with animation state in multiple components. - Enhanced the Chat component to utilize createMessage for message creation, ensuring unique IDs. - Updated the Toolbar component to include animation controls and layout options. - Modified the Vite configuration to point to the production API endpoint. --- app/package-lock.json | 205 ++++-------------------------- app/package.json | 2 +- app/src/App.tsx | 25 ++-- app/src/components/ForceGraph.tsx | 34 ++--- app/src/components/chat.tsx | 98 +++++++------- app/src/components/code-graph.tsx | 31 ++--- app/src/components/graphView.tsx | 118 +++++++++++------ app/src/components/toolbar.tsx | 183 ++++++++++++++++++++++++-- app/src/lib/utils.ts | 6 + 9 files changed, 380 insertions(+), 322 deletions(-) diff --git a/app/package-lock.json b/app/package-lock.json index d22f1851..76d82ccc 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -8,7 +8,7 @@ "name": "code-graph", "version": "0.3.0", "dependencies": { - "@falkordb/canvas": "^0.0.44", + "@falkordb/canvas": "file:../../falkordb-canvas", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", @@ -52,6 +52,28 @@ "vite": "^6.3.5" } }, + "../../falkordb-canvas": { + "name": "@falkordb/canvas", + "version": "0.0.0-dev", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "force-graph": "^1.44.4", + "react": "^19.2.3" + }, + "devDependencies": { + "@types/d3": "^7.4.3", + "@types/react": "^19.2.7", + "@typescript-eslint/eslint-plugin": "^8.18.2", + "@typescript-eslint/parser": "^8.18.2", + "eslint": "^10.0.2", + "jsdom": "^29.0.2", + "promise-retry": "^2.0.1", + "tsup": "^8.5.1", + "typescript": "^6.0.3", + "vitest": "^4.1.4" + } + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -516,24 +538,8 @@ } }, "node_modules/@falkordb/canvas": { - "version": "0.0.44", - "resolved": "https://registry.npmjs.org/@falkordb/canvas/-/canvas-0.0.44.tgz", - "integrity": "sha512-ljVc6h+4kkTJw7cBSWth5soRjnV1LtURWa3x9DFXqbLXmVtItBy5EdnSvmhp6GCE43ZBmdVY8o12QDHxU2/vqQ==", - "license": "MIT", - "dependencies": { - "d3": "^7.9.0", - "force-graph": "^1.44.4", - "react": "^19.2.3" - } - }, - "node_modules/@falkordb/canvas/node_modules/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } + "resolved": "../../falkordb-canvas", + "link": true }, "node_modules/@floating-ui/core": { "version": "1.7.5", @@ -2302,12 +2308,6 @@ "@swc/counter": "^0.1.3" } }, - "node_modules/@tweenjs/tween.js": { - "version": "25.0.0", - "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", - "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==", - "license": "MIT" - }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2336,7 +2336,6 @@ "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2352,7 +2351,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2364,7 +2362,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2406,15 +2403,6 @@ "vite": "^4 || ^5 || ^6 || ^7" } }, - "node_modules/accessor-fn": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz", - "integrity": "sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==", - "license": "MIT", - "engines": { - "node": ">=12" - } - }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -2511,16 +2499,6 @@ "node": ">=6.0.0" } }, - "node_modules/bezier-js": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/bezier-js/-/bezier-js-6.1.4.tgz", - "integrity": "sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==", - "license": "MIT", - "funding": { - "type": "individual", - "url": "https://github.com/Pomax/bezierjs/blob/master/FUNDING.md" - } - }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -2565,7 +2543,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2610,18 +2587,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/canvas-color-tracker": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/canvas-color-tracker/-/canvas-color-tracker-1.3.2.tgz", - "integrity": "sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg==", - "license": "MIT", - "dependencies": { - "tinycolor2": "^1.6.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/canvas2svg": { "version": "1.0.16", "resolved": "https://registry.npmjs.org/canvas2svg/-/canvas2svg-1.0.16.tgz", @@ -2864,12 +2829,6 @@ "node": ">=12" } }, - "node_modules/d3-binarytree": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz", - "integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==", - "license": "MIT" - }, "node_modules/d3-brush": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", @@ -3013,22 +2972,6 @@ "node": ">=12" } }, - "node_modules/d3-force-3d": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz", - "integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==", - "license": "MIT", - "dependencies": { - "d3-binarytree": "1", - "d3-dispatch": "1 - 3", - "d3-octree": "1", - "d3-quadtree": "1 - 3", - "d3-timer": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/d3-format": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", @@ -3071,12 +3014,6 @@ "node": ">=12" } }, - "node_modules/d3-octree": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz", - "integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==", - "license": "MIT" - }, "node_modules/d3-path": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", @@ -3147,7 +3084,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -3270,8 +3206,7 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/embla-carousel-react": { "version": "8.6.0", @@ -3409,46 +3344,6 @@ "node": ">=8" } }, - "node_modules/float-tooltip": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/float-tooltip/-/float-tooltip-1.7.5.tgz", - "integrity": "sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==", - "license": "MIT", - "dependencies": { - "d3-selection": "2 - 3", - "kapsule": "^1.16", - "preact": "10" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/force-graph": { - "version": "1.51.1", - "resolved": "https://registry.npmjs.org/force-graph/-/force-graph-1.51.1.tgz", - "integrity": "sha512-uEEX8iRzgq1IKRISOw6RrB2RLMhcI25xznQYrCTVvxZHZZ+A2jH6qIolYuwavVxAMi64pFp2yZm4KFVdD993cg==", - "license": "MIT", - "dependencies": { - "@tweenjs/tween.js": "18 - 25", - "accessor-fn": "1", - "bezier-js": "3 - 6", - "canvas-color-tracker": "^1.3", - "d3-array": "1 - 3", - "d3-drag": "2 - 3", - "d3-force-3d": "2 - 3", - "d3-scale": "1 - 4", - "d3-scale-chromatic": "1 - 3", - "d3-selection": "2 - 3", - "d3-zoom": "2 - 3", - "float-tooltip": "^1.7", - "index-array-by": "1", - "kapsule": "^1.16", - "lodash-es": "4" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/format": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", @@ -3594,15 +3489,6 @@ "node": ">=0.10.0" } }, - "node_modules/index-array-by": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.2.tgz", - "integrity": "sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==", - "license": "MIT", - "engines": { - "node": ">=12" - } - }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -3724,7 +3610,6 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -3735,18 +3620,6 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, - "node_modules/kapsule": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/kapsule/-/kapsule-1.16.3.tgz", - "integrity": "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==", - "license": "MIT", - "dependencies": { - "lodash-es": "4" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -3970,7 +3843,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -4108,16 +3980,6 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, - "node_modules/preact": { - "version": "10.28.4", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.4.tgz", - "integrity": "sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, "node_modules/prismjs": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", @@ -4177,7 +4039,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -4202,7 +4063,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -4221,7 +4081,8 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-json-tree": { "version": "0.19.0", @@ -4591,7 +4452,6 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -4663,12 +4523,6 @@ "node": ">=0.8" } }, - "node_modules/tinycolor2": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", - "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", - "license": "MIT" - }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -4707,7 +4561,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4868,7 +4721,6 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -4962,7 +4814,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/app/package.json b/app/package.json index a32bbefb..0d7c2e83 100644 --- a/app/package.json +++ b/app/package.json @@ -11,7 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "@falkordb/canvas": "^0.0.44", + "@falkordb/canvas": "file:../../falkordb-canvas", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", diff --git a/app/src/App.tsx b/app/src/App.tsx index 156b1313..0ad845e8 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -15,6 +15,7 @@ import { Labels } from './components/labels'; import { Toolbar } from './components/toolbar'; import { cn, GraphRef, Message, Path, PathData, PathNode } from '@/lib/utils'; import type { GraphNode } from '@falkordb/canvas'; +import { graphDataToData } from '@falkordb/canvas'; import { Toaster } from '@/components/ui/toaster'; import GTM from './GTM'; import { Button } from '@/components/ui/button'; @@ -79,7 +80,8 @@ export default function App() { const [menuOpen, setMenuOpen] = useState(false) const [chatOpen, setChatOpen] = useState(false) const [searchNode, setSearchNode] = useState({}); - const [cooldownTicks, setCooldownTicks] = useState(0) + const [cooldownTicks, setCooldownTicks] = useState(undefined) + const [animation, setAnimation] = useState(false) const [optionsOpen, setOptionsOpen] = useState(false) const [messages, setMessages] = useState([]); const [query, setQuery] = useState(''); @@ -174,8 +176,6 @@ export default function App() { const g = Graph.create(json.entities, graphName) setGraph(g) - if (cooldownTicks === 0) setCooldownTicks(-1) - setIsPathResponse(false) chatPanel.current?.expand() // @ts-ignore @@ -225,8 +225,6 @@ export default function App() { if (!chartNode) { chartNode = graph.extend({ nodes: [node], edges: [] }).nodes[0] - if (cooldownTicks === 0) setCooldownTicks(-1) - setZoomedNodes([chartNode]) graph.visibleLinks(true, [chartNode!.id]) @@ -251,7 +249,7 @@ export default function App() { currentData.nodes.push(graphNode) } - canvas.setGraphData(currentData) + canvas.setGraphData(graphDataToData(currentData)) setTimeout(() => { @@ -280,7 +278,7 @@ export default function App() { } }) - canvas.setGraphData(currentData) + canvas.setGraphData(graphDataToData(currentData)) } setTimeout(() => { @@ -324,9 +322,8 @@ export default function App() { } }); - canvas.setGraphData(currentData); + canvas.setGraphData(graphDataToData(currentData)); - setCooldownTicks(cooldownTicks === undefined ? undefined : -1); setHasHiddenElements(graph.getElements().some(element => !element.visible)); } @@ -540,6 +537,8 @@ export default function App() { setSearchNode={setSearchNode} cooldownTicks={cooldownTicks} setCooldownTicks={setCooldownTicks} + animation={animation} + setAnimation={setAnimation} onCategoryClick={(name, show) => onCategoryClick(name, show, desktopChartRef)} handleDownloadImage={handleDownloadImage} zoomedNodes={zoomedNodes} @@ -574,7 +573,6 @@ export default function App() { setIsPathResponse={setIsPathResponse} paths={paths} setPaths={setPaths} - setCooldownTicks={setCooldownTicks} /> @@ -667,6 +665,8 @@ export default function App() { searchNode={searchNode} cooldownTicks={cooldownTicks} setCooldownTicks={setCooldownTicks} + animation={animation} + setAnimation={setAnimation} onCategoryClick={(name, show) => onCategoryClick(name, show, mobileChartRef)} handleDownloadImage={handleDownloadImage} zoomedNodes={zoomedNodes} @@ -705,7 +705,6 @@ export default function App() { setChatOpen={setChatOpen} paths={paths} setPaths={setPaths} - setCooldownTicks={setCooldownTicks} /> @@ -723,8 +722,8 @@ export default function App() { number[] onZoom: () => void onEngineStop: () => void - cooldownTicks: number | undefined + animation: boolean backgroundColor?: string foregroundColor?: string } + const convertToCanvasData = (graphData: GraphData): Data => ({ nodes: graphData.nodes.filter(n => n.visible).map(({ id, category, color, visible, isPath, isPathSelected, data }) => ({ id, @@ -66,7 +67,7 @@ export default function ForceGraph({ nodeCanvasObject, nodePointerAreaPaint, linkLineDash, - cooldownTicks, + animation, backgroundColor = "#FFFFFF", foregroundColor = "#000000" }: Props) { @@ -94,12 +95,12 @@ export default function ForceGraph({ canvasRef.current.setForegroundColor(foregroundColor) }, [canvasRef, backgroundColor, foregroundColor, canvasLoaded]) - // Update cooldown ticks + // Update animation state on canvas useEffect(() => { if (!canvasRef.current || !canvasLoaded) return - canvasRef.current.setCooldownTicks(cooldownTicks === -1 ? undefined : cooldownTicks) - }, [canvasRef, cooldownTicks, canvasLoaded]) + canvasRef.current.setAnimation(animation) + }, [canvasRef, animation, canvasLoaded]) // Map node click handler const handleNodeClick = useCallback((node: GraphNode, event: MouseEvent) => { @@ -158,23 +159,24 @@ export default function ForceGraph({ useEffect(() => { if (!canvasRef.current || !canvasLoaded) return canvasRef.current.setConfig({ - autoStopOnSettle: false, // nodes will display node.data.captionsKeys in the canvas captionsKeys: ["name", "title"], - onNodeClick: handleNodeClick, - onNodeRightClick: handleNodeRightClick, - onNodeHover: handleNodeHover, isNodeSelected: isNodeSelected, - onLinkClick: handleLinkClick, - onLinkRightClick: handleLinkRightClick, - onLinkHover: handleLinkHover, isLinkSelected: isLinkSelected, - onBackgroundClick, - onBackgroundRightClick, - onEngineStop: handleEngineStop, node: { nodeCanvasObject, nodePointerAreaPaint }, linkLineDash, - onZoom + eventHandlers: { + onNodeClick: handleNodeClick, + onNodeRightClick: handleNodeRightClick, + onNodeHover: handleNodeHover, + onLinkClick: handleLinkClick, + onLinkRightClick: handleLinkRightClick, + onLinkHover: handleLinkHover, + onBackgroundClick, + onBackgroundRightClick, + onEngineStop: handleEngineStop, + onZoom + } }) }, [ handleNodeClick, diff --git a/app/src/components/chat.tsx b/app/src/components/chat.tsx index 0ecc49c2..f5057c70 100644 --- a/app/src/components/chat.tsx +++ b/app/src/components/chat.tsx @@ -1,7 +1,7 @@ import { toast } from "@/components/ui/use-toast"; import { Dispatch, FormEvent, SetStateAction, useEffect, useRef, useState } from "react"; import { AlignLeft, ArrowRight, ChevronDown, Lightbulb, Loader2, Undo2 } from "lucide-react"; -import { Message, MessageTypes, Path, PathData, PATH_COLOR } from "@/lib/utils"; +import { Message, MessageTypes, Path, PathData, PATH_COLOR, createMessage } from "@/lib/utils"; import Input from "./Input"; import { Graph, GraphData, Node } from "./model"; import { cn, GraphRef } from "@/lib/utils"; @@ -10,9 +10,9 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@/compon import { Button } from "@/components/ui/button"; const AUTH_HEADERS: HeadersInit = import.meta.env.VITE_SECRET_TOKEN - ? { 'Authorization': `Bearer ${import.meta.env.VITE_SECRET_TOKEN}` } - : {}; -import { dataToGraphData, GraphLink, GraphNode } from "@falkordb/canvas"; + ? { 'Authorization': `Bearer ${import.meta.env.VITE_SECRET_TOKEN}` } + : {}; +import { dataToGraphData, graphDataToData, GraphLink, GraphNode } from "@falkordb/canvas"; interface Props { repo: string @@ -29,7 +29,6 @@ interface Props { setQuery: Dispatch> selectedPath: PathData | undefined setSelectedPath: Dispatch> - setCooldownTicks: Dispatch> setChatOpen?: Dispatch> paths: PathData[] setPaths: Dispatch> @@ -48,29 +47,30 @@ type RemoveLastPathResult = { insertIndex: number } -const RemoveLastPath = (messages: Message[]): RemoveLastPathResult => { - // Find the last Path message so we know where the user was in the conversation +const RemoveLastPath = (messages: Message[], includeQuery = false): RemoveLastPathResult => { + // Find the last Path marker (the pending "select start/end" state) const index = messages.findLastIndex((m) => m.type === MessageTypes.Path) if (index === -1) { return { messages, insertIndex: messages.length } } - const groupStart = Math.max(0, index - 2) - const hasMessagesAfter = index < messages.length - 1 - const cleaned = [...messages.slice(0, groupStart), ...messages.slice(index + 1)] - - // Recurse to strip any remaining Path groups - const { messages: finalMessages } = RemoveLastPath(cleaned) + // The Path marker is always preceded by Response("Please select..."). + // Optionally also remove the Query("Create a path") before it. + let groupStart = index; + if (index > 0 && messages[index - 1].type === MessageTypes.Response) { + groupStart = index - 1; + if (includeQuery && groupStart > 0 && messages[groupStart - 1].type === MessageTypes.Query) { + groupStart = groupStart - 1; + } + } - // If there were messages after the Path group, inject the answer there; - // otherwise just append at the end - const insertIndex = hasMessagesAfter ? groupStart : finalMessages.length + const cleaned = [...messages.slice(0, groupStart), ...messages.slice(index + 1)] - return { messages: finalMessages, insertIndex } + return { messages: cleaned, insertIndex: cleaned.length } } -export function Chat({ messages, setMessages, query, setQuery, selectedPath, setSelectedPath, setChatOpen, repo, path, setPath, graph, selectedPathId, isPathResponse, setIsPathResponse, setCooldownTicks, canvasRef, paths, setPaths }: Props) { +export function Chat({ messages, setMessages, query, setQuery, selectedPath, setSelectedPath, setChatOpen, repo, path, setPath, graph, selectedPathId, isPathResponse, setIsPathResponse, canvasRef, paths, setPaths }: Props) { const [sugOpen, setSugOpen] = useState(false); @@ -225,11 +225,14 @@ export function Chat({ messages, setMessages, query, setQuery, selectedPath, set } }); - canvas.setGraphData(currentData) + canvas.setGraphData(graphDataToData(currentData)) setTimeout(() => { + const filteredNodes = currentData.nodes.filter((n: any) => pNodeIds.has(n.id)); + console.log('[zoomToFit] filtered nodes:', filteredNodes.map((n: any) => ({ id: n.id, x: n.x, y: n.y }))); + console.log('[zoomToFit] pNodeIds:', [...pNodeIds]); canvas.zoomToFit(2, (n: GraphNode) => pNodeIds.has(n.id)); - }, 0) + }, 300) setChatOpen && setChatOpen(false) } @@ -263,7 +266,7 @@ export function Chat({ messages, setMessages, query, setQuery, selectedPath, set setQuery("") - setMessages((messages) => [...messages, { text: q, type: MessageTypes.Query }, { type: MessageTypes.Pending }]); + setMessages((messages) => [...messages, createMessage({ text: q, type: MessageTypes.Query }), createMessage({ type: MessageTypes.Pending })]); const result = await fetch(`/api/chat`, { method: 'POST', @@ -277,7 +280,7 @@ export function Chat({ messages, setMessages, query, setQuery, selectedPath, set if (!result.ok) { setMessages((prev) => { prev = [...prev.slice(0, -1)]; - return [...prev, { type: MessageTypes.Response, text: "Sorry but I couldn't answer your question, please try rephrasing." }]; + return [...prev, createMessage({ type: MessageTypes.Response, text: "Sorry but I couldn't answer your question, please try rephrasing." })]; }); return } @@ -286,7 +289,7 @@ export function Chat({ messages, setMessages, query, setQuery, selectedPath, set setMessages((prev) => { prev = prev.slice(0, -1); - return [...prev, { text: json.response, type: MessageTypes.Response }]; + return [...prev, createMessage({ text: json.response, type: MessageTypes.Response })]; }); } @@ -300,17 +303,17 @@ export function Chat({ messages, setMessages, query, setQuery, selectedPath, set if (!path?.start?.id || !path.end?.id) return - const pathMessage = [{ + const pathMessage = [createMessage({ type: MessageTypes.Response, text: "Please select a starting point and the end point. Select or press relevant item on the graph" - }, { type: MessageTypes.Path }] + }), createMessage({ type: MessageTypes.Path })] setPath(undefined) let insertIndex = 0 setMessages((prev) => { const { messages, insertIndex: idx } = RemoveLastPath(prev) insertIndex = idx - const pending: Message = { type: MessageTypes.Pending } + const pending: Message = createMessage({ type: MessageTypes.Pending }) return [...messages.slice(0, insertIndex), pending, ...messages.slice(insertIndex)] }) @@ -367,7 +370,7 @@ export function Chat({ messages, setMessages, query, setQuery, selectedPath, set setPaths(formattedPaths) setMessages((prev) => [ ...prev.slice(0, insertIndex), - { type: MessageTypes.PathResponse, paths: formattedPaths, graphName: graph.Id }, + createMessage({ type: MessageTypes.PathResponse, paths: formattedPaths, graphName: graph.Id }), ...prev.slice(insertIndex + 1), ]); setIsPathResponse(true) @@ -430,14 +433,10 @@ export function Chat({ messages, setMessages, query, setQuery, selectedPath, set ) // Merge with existing data - canvasRef.current?.setGraphData({ + canvasRef.current?.setGraphData(graphDataToData({ nodes: [...currentData.nodes, ...newGraphData.nodes], links: [...currentData.links, ...newGraphData.links] - }) - - if (elements.nodes.length !== 0 || elements.links.length !== 0) { - setCooldownTicks(-1) - } + })) setTimeout(() => { const nodesMap = new Map(formattedPaths.flatMap(p => p.nodes.map((n: Node) => [n.id, n]))) @@ -457,9 +456,9 @@ export function Chat({ messages, setMessages, query, setQuery, selectedPath, set setSugOpen(false) setMessages(prev => { - const { messages, insertIndex } = RemoveLastPath(prev) - const queryMsg: Message = { type: MessageTypes.Query, text: "Create a path" } - return [...messages.slice(0, insertIndex), queryMsg, ...messages.slice(insertIndex)] + const { messages } = RemoveLastPath(prev, true) + const queryMsg: Message = createMessage({ type: MessageTypes.Query, text: "Create a path" }) + return [...messages, queryMsg] }) if (isPathResponse) { @@ -480,13 +479,13 @@ export function Chat({ messages, setMessages, query, setQuery, selectedPath, set } }) - canvas.setGraphData(currentData) + canvas.setGraphData(graphDataToData(currentData)) } - setMessages(prev => [...prev, { + setMessages(prev => [...prev, createMessage({ type: MessageTypes.Response, text: "Please select a starting point and the end point. Select or press relevant item on the graph" - }, { type: MessageTypes.Path }]) + }), createMessage({ type: MessageTypes.Path })]) setPath({}) }} > @@ -513,7 +512,7 @@ export function Chat({ messages, setMessages, query, setQuery, selectedPath, set const getMessage = (message: Message, index?: number) => { switch (message.type) { case MessageTypes.Query: return ( -
+

You

@@ -522,13 +521,12 @@ export function Chat({ messages, setMessages, query, setQuery, selectedPath, set
) case MessageTypes.Response: return ( -
+

Answer

) case MessageTypes.Text: return ( -

{message.text}

+

{message.text}

) case MessageTypes.Path: { return ( -
+
+
+
+ +

Answer

+
{ message.paths && message.paths.map((p, i: number) => ( @@ -577,7 +579,7 @@ export function Chat({ messages, setMessages, query, setQuery, selectedPath, set className={cn( "flex text-wrap border p-2 gap-2 rounded-md", p.nodes.length === selectedPath?.nodes.length && - selectedPath?.nodes.every(node => p?.nodes.some((n) => n.id === node.id)) && + selectedPath?.nodes.every((node, i) => p?.nodes[i]?.id === node.id) && "border-[#ffde21] bg-[#ffde2133]", message.graphName !== graph.Id && "opacity-50 bg-secondary" )} @@ -592,7 +594,7 @@ export function Chat({ messages, setMessages, query, setQuery, selectedPath, set return; } - if (selectedPath?.nodes.every(node => p?.nodes.some((n) => n.id === node.id)) && selectedPath.nodes.length === p.nodes.length) return + if (selectedPath?.nodes.every((node, i) => p?.nodes[i]?.id === node.id) && selectedPath.nodes.length === p.nodes.length) return if (!isPathResponse) { setIsPathResponse(undefined) @@ -615,7 +617,7 @@ export function Chat({ messages, setMessages, query, setQuery, selectedPath, set
) default: return ( -
+
Thinking... diff --git a/app/src/components/code-graph.tsx b/app/src/components/code-graph.tsx index c0b2a083..4dc3b38a 100644 --- a/app/src/components/code-graph.tsx +++ b/app/src/components/code-graph.tsx @@ -14,7 +14,7 @@ import { Checkbox } from '@/components/ui/checkbox'; import type { Position } from "./graphView"; import { prepareArg } from '../utils'; import { GraphRef } from "@/lib/utils"; -import { dataToGraphData } from "@falkordb/canvas"; +import { dataToGraphData, graphDataToData } from "@falkordb/canvas"; import type { Node as CanvasNode, Link as CanvasLink, GraphData as CanvasData } from "@falkordb/canvas"; import GraphView from "./graphView"; @@ -44,6 +44,8 @@ interface Props { setSearchNode: Dispatch> cooldownTicks: number | undefined setCooldownTicks: Dispatch> + animation: boolean + setAnimation: (animation: boolean) => void onCategoryClick: (name: string, show: boolean) => void handleDownloadImage: () => void zoomedNodes: Node[] @@ -74,6 +76,8 @@ export function CodeGraph({ setSearchNode, cooldownTicks, setCooldownTicks, + animation, + setAnimation, onCategoryClick, handleDownloadImage, zoomedNodes, @@ -280,12 +284,10 @@ export function CodeGraph({ ) // Merge with existing data - canvasRef.current?.setGraphData({ + canvasRef.current?.setGraphData(graphDataToData({ nodes: [...currentData.nodes, ...newGraphData.nodes], links: [...currentData.links, ...newGraphData.links] - }) - - setCooldownTicks(-1) + })) } else { const deleteNodes = nodes.filter(n => n.expand) if (deleteNodes.length > 0) { @@ -299,8 +301,7 @@ export function CodeGraph({ currentData.nodes = currentData.nodes.filter(node => !deleteIdsMap.has(Number(node.id))) currentData.links = currentData.links.filter(link => !deleteIdsMap.has(Number(link.source.id)) && !deleteIdsMap.has(Number(link.target.id))) - canvasRef.current?.setGraphData(currentData) - setCooldownTicks(-1) + canvasRef.current?.setGraphData(graphDataToData(currentData)) } } } @@ -337,13 +338,12 @@ export function CodeGraph({ }) } - canvas.setGraphData(currentData) + canvas.setGraphData(graphDataToData(currentData)) graph.visibleLinks(false, ids) setHasHiddenElements(true) setSelectedObj(undefined) setSelectedObjects([]) - setCooldownTicks(-1) } return ( @@ -403,9 +403,8 @@ export function CodeGraph({ } }) - canvas.setGraphData(currentData) + canvas.setGraphData(graphDataToData(currentData)) setIsPathResponse(false) - setCooldownTicks(-1) }} > @@ -434,9 +433,8 @@ export function CodeGraph({ element.visible = true }); - canvas.setGraphData(currentData); + canvas.setGraphData(graphDataToData(currentData)); setHasHiddenElements(false); - setCooldownTicks(-1); }} > @@ -475,8 +473,7 @@ export function CodeGraph({ isPathResponse={isPathResponse} selectedPathId={selectedPathId} setSelectedPathId={setSelectedPathId} - cooldownTicks={cooldownTicks} - setCooldownTicks={setCooldownTicks} + animation={animation} setZoomedNodes={setZoomedNodes} zoomedNodes={zoomedNodes} /> @@ -510,8 +507,8 @@ export function CodeGraph({ className="gap-4" canvasRef={canvasRef} handleDownloadImage={handleDownloadImage} - setCooldownTicks={setCooldownTicks} - cooldownTicks={cooldownTicks} + animation={animation} + setAnimation={setAnimation} />
diff --git a/app/src/components/graphView.tsx b/app/src/components/graphView.tsx index 3a08e27f..38fcad64 100644 --- a/app/src/components/graphView.tsx +++ b/app/src/components/graphView.tsx @@ -5,7 +5,7 @@ import { Path, PATH_COLOR } from '@/lib/utils'; import { Fullscreen } from 'lucide-react'; import { GraphRef } from '@/lib/utils'; import ForceGraph from './ForceGraph'; -import { GraphLink, GraphNode } from '@falkordb/canvas'; +import { GraphLink, GraphNode, NODE_SIZE, getContrastTextColor, wrapTextForCircularNode } from '@falkordb/canvas'; import { useTheme } from './theme-provider'; export interface Position { @@ -30,14 +30,18 @@ interface Props { isPathResponse: boolean | undefined selectedPathId: number | undefined setSelectedPathId: (selectedPathId: number) => void - cooldownTicks: number | undefined - setCooldownTicks: Dispatch> + animation: boolean setZoomedNodes: Dispatch> zoomedNodes: Node[] } -const NODE_SIZE = 6; const PADDING = 2; +const FONT_FAMILY = 'SofiaSans, Arial, sans-serif'; +const FONT_WEIGHT_NORMAL = 400; +const FONT_WEIGHT_SELECTED = 700; +const TEXT_FILL_RATIO = 0.85; +const STROKE_WIDTH_SELECTED = 1.5; +const STROKE_WIDTH_UNSELECTED = 0.5; const LIGHT_CANVAS_BACKGROUND = '#FFFFFF'; const DARK_CANVAS_BACKGROUND = '#1A1A1A'; const LIGHT_CANVAS_FOREGROUND = '#000000'; @@ -63,8 +67,7 @@ export default function GraphView({ isPathResponse, selectedPathId, setSelectedPathId, - cooldownTicks, - setCooldownTicks, + animation, zoomedNodes, setZoomedNodes }: Props) { @@ -151,10 +154,12 @@ export default function GraphView({ const isDoubleClick = now.getTime() - date.getTime() < 1000 && name === node.data.name lastClick.current = { date: now, name: node.data.name } - + if (isDoubleClick) { + lastClick.current = { date: now, name: "" } handleExpand([node], !node.expand) } else if (isShowPath) { + lastClick.current = { date: now, name: "" } setPath(prev => { if (!prev?.start?.name || (prev.end?.name && prev.end?.name !== "")) { return ({ start: { id: Number(node.id), name: node.data.name } }) @@ -171,11 +176,7 @@ export default function GraphView({ canvasRef.current?.zoomToFit(zoomedNodes.length === 1 ? 4 : 1, (n: GraphNode) => zoomedNodes.some(node => node.id === n.id)) setZoomedNodes([]) } - - if (cooldownTicks !== -1) return - - setCooldownTicks(0) - }, [zoomedNodes, cooldownTicks, canvasRef]) + }, [zoomedNodes, canvasRef]) const nodeCanvasObject = useCallback((node: GraphNode, ctx: CanvasRenderingContext2D) => { if (node.x === undefined || node.y === undefined) { @@ -185,66 +186,111 @@ export default function GraphView({ const isHovered = !!hoverElement && !('source' in hoverElement) && hoverElement.id === node.id const isSelected = selectedObjects.some(obj => obj.id === node.id) || selectedObj?.id === node.id + const nodeSelected = isSelected || isHovered + // --- Determine colors based on path state --- if (isPathResponse) { if (node.data.isPathSelected) { ctx.fillStyle = node.color; ctx.strokeStyle = PATH_COLOR; - ctx.lineWidth = 1.5 + ctx.lineWidth = STROKE_WIDTH_SELECTED; } else if (node.data.isPath) { ctx.fillStyle = node.color; ctx.strokeStyle = PATH_COLOR; - ctx.lineWidth = 1 + ctx.lineWidth = STROKE_WIDTH_UNSELECTED; } else { ctx.fillStyle = dimmedNodeFillColor; ctx.strokeStyle = dimmedNodeStrokeColor; - ctx.lineWidth = 1 + ctx.lineWidth = STROKE_WIDTH_UNSELECTED; } } else if (isPathResponse === undefined) { if (node.data.isPathSelected) { ctx.fillStyle = node.color; ctx.strokeStyle = PATH_COLOR; - ctx.lineWidth = 1.5 + ctx.lineWidth = STROKE_WIDTH_SELECTED; } else if (node.data.isPath) { ctx.fillStyle = node.color; ctx.strokeStyle = PATH_COLOR; - ctx.lineWidth = 1 + ctx.lineWidth = STROKE_WIDTH_UNSELECTED; } else { ctx.fillStyle = node.color; ctx.strokeStyle = canvasForegroundColor; - ctx.lineWidth = isSelected || isHovered ? 1.5 : 1 + ctx.lineWidth = nodeSelected ? STROKE_WIDTH_SELECTED : STROKE_WIDTH_UNSELECTED; } } else { ctx.fillStyle = node.color; ctx.strokeStyle = canvasForegroundColor; - ctx.lineWidth = isSelected || isHovered ? 1.5 : 1 + ctx.lineWidth = nodeSelected ? STROKE_WIDTH_SELECTED : STROKE_WIDTH_UNSELECTED; } + const radius = NODE_SIZE + ctx.lineWidth / 2; + + // Draw stroke circle ctx.beginPath(); - ctx.arc(node.x, node.y, NODE_SIZE + ctx.lineWidth / 2, 0, 2 * Math.PI, false); + ctx.arc(node.x, node.y, radius, 0, 2 * Math.PI, false); ctx.stroke(); + + // Draw fill circle + ctx.beginPath(); + ctx.arc(node.x, node.y, NODE_SIZE, 0, 2 * Math.PI, false); ctx.fill(); - ctx.fillStyle = canvasForegroundColor; + // Skip labels when zoomed out (large graph optimisation) + const zoom = ctx.getTransform().a; + if (zoom < 1) return; + + // --- Draw text (matching canvas logic) --- + const fillColor = ctx.fillStyle as string; + ctx.fillStyle = getContrastTextColor(fillColor); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - ctx.font = '2px Arial'; - let name = node.data.name || ""; - const textWidth = ctx.measureText(name).width; - const ellipsis = '...'; - const ellipsisWidth = ctx.measureText(ellipsis).width; - const nodeSize = (NODE_SIZE + ctx.lineWidth / 2) * 2 - PADDING; - - // truncate text if it's too long - if (textWidth > nodeSize) { - while (name.length > 0 && ctx.measureText(name).width + ellipsisWidth > nodeSize) { - name = name.slice(0, -1); + + const textRadius = NODE_SIZE - PADDING / 2; + const name = node.data.name || node.data.title || String(node.id); + const nodeFontWeight = nodeSelected ? FONT_WEIGHT_SELECTED : FONT_WEIGHT_NORMAL; + const baseFontSize = 4; + + // Measure at the base size for line-wrapping decisions + ctx.font = `${nodeFontWeight} ${baseFontSize}px ${FONT_FAMILY}`; + const [line1, line2] = wrapTextForCircularNode(ctx, name, textRadius); + + let chosenSize = baseFontSize; + + if (TEXT_FILL_RATIO > 0 && !line2) { + // Auto-size mode: scale text to fill textFillRatio × nodeRadius + const REF = 20; + ctx.font = `${nodeFontWeight} ${REF}px ${FONT_FAMILY}`; + const refMetrics = ctx.measureText(line1); + const visualWidth = (refMetrics.actualBoundingBoxLeft ?? 0) + + (refMetrics.actualBoundingBoxRight ?? 0); + const refWidth = Math.max(visualWidth, refMetrics.width); + const refHeight = (refMetrics.actualBoundingBoxAscent ?? 0) + + (refMetrics.actualBoundingBoxDescent ?? 0); + + const r = TEXT_FILL_RATIO * textRadius; + if (refWidth > 0 && refHeight > 0) { + const diagonal = Math.sqrt(refWidth * refWidth + refHeight * refHeight); + chosenSize = REF * (2 * r / diagonal); + } else if (refWidth > 0) { + chosenSize = REF * (2 * r / refWidth); } - name += ellipsis; } - // add label - ctx.fillText(name, node.x, node.y); + ctx.font = `${nodeFontWeight} ${chosenSize}px ${FONT_FAMILY}`; + + const textMetrics = ctx.measureText(line1); + const textHeight = textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent; + const halfTextHeight = (textHeight / 2) * 1.5; + + if (line1) { + const yCorrection = line2 + ? 0 + : (textMetrics.actualBoundingBoxAscent - textMetrics.actualBoundingBoxDescent) / 2; + ctx.fillText(line1, node.x, line2 ? node.y - halfTextHeight : node.y + yCorrection); + } + if (line2) { + ctx.fillText(line2, node.x, node.y + halfTextHeight); + } }, [ selectedObj, selectedObjects, @@ -302,7 +348,7 @@ export default function GraphView({ nodeCanvasObject={nodeCanvasObject} nodePointerAreaPaint={nodePointerAreaPaint} linkLineDash={linkLineDash} - cooldownTicks={cooldownTicks} + animation={animation} backgroundColor={canvasBackgroundColor} foregroundColor={canvasForegroundColor} /> diff --git a/app/src/components/toolbar.tsx b/app/src/components/toolbar.tsx index b4d11258..5553c1c7 100644 --- a/app/src/components/toolbar.tsx +++ b/app/src/components/toolbar.tsx @@ -1,17 +1,42 @@ -import { Download, Fullscreen, ZoomIn, ZoomOut } from "lucide-react"; +import { useState } from "react"; +import { ChevronDown, Circle, Download, Fullscreen, Pause, Pin, PinOff, Play, ZoomIn, ZoomOut } from "lucide-react"; +import type { LayoutMode, HierarchyDirection, RadialDirection } from "@falkordb/canvas"; import { cn } from "@/lib/utils" import { GraphRef } from "@/lib/utils"; import { Switch } from "@/components/ui/switch"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; + +const LAYOUTS: { value: LayoutMode; label: string }[] = [ + { value: 'force', label: 'Force' }, + { value: 'tree', label: 'Tree' }, + { value: 'radial', label: 'Radial' }, +]; + +const HIERARCHY_DIRECTIONS: { value: HierarchyDirection; label: string }[] = [ + { value: 'td', label: 'Top → Down' }, + { value: 'bu', label: 'Bottom → Up' }, + { value: 'lr', label: 'Left → Right' }, + { value: 'rl', label: 'Right → Left' }, +]; + +const RADIAL_DIRECTIONS: { value: RadialDirection; label: string }[] = [ + { value: 'out', label: 'Outward' }, + { value: 'in', label: 'Inward' }, +]; interface Props { canvasRef: GraphRef className?: string handleDownloadImage?: () => void - setCooldownTicks: (ticks?: 0) => void - cooldownTicks: number | undefined + animation: boolean + setAnimation: (animation: boolean) => void } -export function Toolbar({ canvasRef, className, handleDownloadImage, setCooldownTicks, cooldownTicks }: Props) { +export function Toolbar({ canvasRef, className, handleDownloadImage, animation, setAnimation }: Props) { + + const [layout, setLayout] = useState('force'); + const [direction, setDirection] = useState(''); + const [pinned, setPinned] = useState(false); const handleZoomClick = (changefactor: number) => { const canvas = canvasRef.current @@ -29,42 +54,172 @@ export function Toolbar({ canvasRef, className, handleDownloadImage, setCooldown } } + const handleAnimationToggle = () => { + setAnimation(!animation) + } + + const handlePinToggle = () => { + const next = !pinned; + setPinned(next); + canvasRef.current?.setPinOnDragEnd(next); + } + + const handleLayoutChange = (value: string) => { + const mode = value as LayoutMode; + setLayout(mode); + + if (mode === 'tree') { + const dir = direction || 'td'; + setDirection(dir); + canvasRef.current?.setLayoutOptions({ tree: { direction: dir as HierarchyDirection } }); + } else if (mode === 'radial') { + const dir = direction || 'out'; + setDirection(dir); + canvasRef.current?.setLayoutOptions({ radial: { direction: dir as RadialDirection } }); + } else { + setDirection(''); + } + + canvasRef.current?.setLayout(mode); + + // Non-force layouts auto-pin + const nextPinned = mode !== 'force'; + setPinned(nextPinned); + canvasRef.current?.setPinOnDragEnd(nextPinned); + } + + const handleDirectionChange = (value: string, targetLayout?: string) => { + const effectiveLayout = targetLayout || layout; + setDirection(value); + + if (effectiveLayout === 'tree') { + canvasRef.current?.setLayoutOptions({ tree: { direction: value as HierarchyDirection } }); + } else if (effectiveLayout === 'radial') { + canvasRef.current?.setLayoutOptions({ radial: { direction: value as RadialDirection } }); + } + } + + const animationDisabled = pinned || layout !== 'force'; + return ( -
+
+ {animation ? : } { - setCooldownTicks(cooldownTicks !== 0 ? 0 : undefined) - }} + className="pointer-events-auto data-[state=unchecked]:bg-border" + checked={animation} + disabled={animationDisabled} + onCheckedChange={handleAnimationToggle} /> + +
+ + + + + + + Force + + + + {layout === 'tree' && ( + + + + )} + Tree + + + {HIERARCHY_DIRECTIONS.map(d => ( + { + if (layout !== 'tree') handleLayoutChange('tree'); + handleDirectionChange(d.value, 'tree'); + }} + > + {layout === 'tree' && direction === d.value && ( + + + + )} + {d.label} + + ))} + + + + + {layout === 'radial' && ( + + + + )} + Radial + + + {RADIAL_DIRECTIONS.map(d => ( + { + if (layout !== 'radial') handleLayoutChange('radial'); + handleDirectionChange(d.value, 'radial'); + }} + > + {layout === 'radial' && direction === d.value && ( + + + + )} + {d.label} + + ))} + + + + +
) diff --git a/app/src/lib/utils.ts b/app/src/lib/utils.ts index 73cf2c9c..a67780c7 100644 --- a/app/src/lib/utils.ts +++ b/app/src/lib/utils.ts @@ -27,7 +27,13 @@ export enum MessageTypes { Text, } +let messageIdCounter = 0; +export function createMessage(msg: Omit): Message { + return { ...msg, id: ++messageIdCounter }; +} + export interface Message { + id: number; type: MessageTypes; text?: string; paths?: { nodes: any[], links: any[] }[]; From 98fc391457a9bc80708988c97b28dfe46a0fd8a8 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Wed, 8 Jul 2026 13:08:32 +0300 Subject: [PATCH 02/16] feat: enhance combobox type safety and element menu positioning - Updated Combobox component to enforce string array type for options. - Added container height state to ElementMenu for improved positioning. - Refactored ElementMenu positioning logic to ensure it remains within parent boundaries. - Improved GraphView component by removing unused props and optimizing node/link selection logic. - Introduced manual dimming feature for nodes and links in GraphView based on selection. - Enhanced Toolbar with zoom controls and manual dimming toggle. - Updated Graph model to include collapsed state for links and modified link removal logic. - Added e2e tests to validate node visibility and position preservation after hiding/unhiding. --- app/package-lock.json | 186 ++++++++++++--- app/package.json | 2 +- app/src/App.tsx | 106 +++------ app/src/components/ForceGraph.tsx | 66 +++--- app/src/components/chat.tsx | 132 ++--------- app/src/components/code-graph.tsx | 201 ++++++----------- app/src/components/combobox.tsx | 2 +- app/src/components/elementMenu.tsx | 31 ++- app/src/components/graphView.tsx | 349 +++++++++++++++-------------- app/src/components/model.ts | 17 +- app/src/components/toolbar.tsx | 120 +++++++--- e2e/logic/POM/codeGraph.ts | 2 +- e2e/tests/canvas.spec.ts | 88 +++++++- 13 files changed, 726 insertions(+), 576 deletions(-) diff --git a/app/package-lock.json b/app/package-lock.json index 76d82ccc..4c55da26 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -8,7 +8,7 @@ "name": "code-graph", "version": "0.3.0", "dependencies": { - "@falkordb/canvas": "file:../../falkordb-canvas", + "@falkordb/canvas": "v0.2.2", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", @@ -52,28 +52,6 @@ "vite": "^6.3.5" } }, - "../../falkordb-canvas": { - "name": "@falkordb/canvas", - "version": "0.0.0-dev", - "license": "MIT", - "dependencies": { - "d3": "^7.9.0", - "force-graph": "^1.44.4", - "react": "^19.2.3" - }, - "devDependencies": { - "@types/d3": "^7.4.3", - "@types/react": "^19.2.7", - "@typescript-eslint/eslint-plugin": "^8.18.2", - "@typescript-eslint/parser": "^8.18.2", - "eslint": "^10.0.2", - "jsdom": "^29.0.2", - "promise-retry": "^2.0.1", - "tsup": "^8.5.1", - "typescript": "^6.0.3", - "vitest": "^4.1.4" - } - }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -538,8 +516,24 @@ } }, "node_modules/@falkordb/canvas": { - "resolved": "../../falkordb-canvas", - "link": true + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@falkordb/canvas/-/canvas-0.2.2.tgz", + "integrity": "sha512-oGo4/tXrLqUzzf39wjpcZ2lfoSo0InTsHqORGBpLVyQtgyNRSLgNdgKUrAbO3vjVnjbfNFzKIwHrUJFf2R6mHA==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "force-graph": "^1.44.4", + "react": "^19.2.3" + } + }, + "node_modules/@falkordb/canvas/node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } }, "node_modules/@floating-ui/core": { "version": "1.7.5", @@ -2308,6 +2302,12 @@ "@swc/counter": "^0.1.3" } }, + "node_modules/@tweenjs/tween.js": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", + "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2403,6 +2403,15 @@ "vite": "^4 || ^5 || ^6 || ^7" } }, + "node_modules/accessor-fn": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz", + "integrity": "sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -2499,6 +2508,16 @@ "node": ">=6.0.0" } }, + "node_modules/bezier-js": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/bezier-js/-/bezier-js-6.1.4.tgz", + "integrity": "sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==", + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://github.com/Pomax/bezierjs/blob/master/FUNDING.md" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -2587,6 +2606,18 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas-color-tracker": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/canvas-color-tracker/-/canvas-color-tracker-1.3.2.tgz", + "integrity": "sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg==", + "license": "MIT", + "dependencies": { + "tinycolor2": "^1.6.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/canvas2svg": { "version": "1.0.16", "resolved": "https://registry.npmjs.org/canvas2svg/-/canvas2svg-1.0.16.tgz", @@ -2829,6 +2860,12 @@ "node": ">=12" } }, + "node_modules/d3-binarytree": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz", + "integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==", + "license": "MIT" + }, "node_modules/d3-brush": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", @@ -2972,6 +3009,22 @@ "node": ">=12" } }, + "node_modules/d3-force-3d": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz", + "integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==", + "license": "MIT", + "dependencies": { + "d3-binarytree": "1", + "d3-dispatch": "1 - 3", + "d3-octree": "1", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-format": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", @@ -3014,6 +3067,12 @@ "node": ">=12" } }, + "node_modules/d3-octree": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz", + "integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==", + "license": "MIT" + }, "node_modules/d3-path": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", @@ -3344,6 +3403,46 @@ "node": ">=8" } }, + "node_modules/float-tooltip": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/float-tooltip/-/float-tooltip-1.7.5.tgz", + "integrity": "sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==", + "license": "MIT", + "dependencies": { + "d3-selection": "2 - 3", + "kapsule": "^1.16", + "preact": "10" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/force-graph": { + "version": "1.51.4", + "resolved": "https://registry.npmjs.org/force-graph/-/force-graph-1.51.4.tgz", + "integrity": "sha512-TdJ2KbkoiDQ7NIRx8IPGD0mAXXpLhamS7c+b7W98b0MHG7lphnda1VOQX/98UDTsttIAdH4TcP0l0MauSnLK8w==", + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "18 - 25", + "accessor-fn": "1", + "bezier-js": "3 - 6", + "canvas-color-tracker": "^1.3", + "d3-array": "1 - 3", + "d3-drag": "2 - 3", + "d3-force-3d": "2 - 3", + "d3-scale": "1 - 4", + "d3-scale-chromatic": "1 - 3", + "d3-selection": "2 - 3", + "d3-zoom": "2 - 3", + "float-tooltip": "^1.7", + "index-array-by": "1", + "kapsule": "^1.16", + "lodash-es": "4" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/format": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", @@ -3489,6 +3588,15 @@ "node": ">=0.10.0" } }, + "node_modules/index-array-by": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.2.tgz", + "integrity": "sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -3620,6 +3728,18 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/kapsule": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/kapsule/-/kapsule-1.16.3.tgz", + "integrity": "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==", + "license": "MIT", + "dependencies": { + "lodash-es": "4" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -3980,6 +4100,16 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/preact": { + "version": "10.29.5", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.5.tgz", + "integrity": "sha512-zIai7HLEIz9c4GNfqpiuu5K9I1szXNtx0Ykr8f1Vbm1NasSVIvXGRxc8R8alLC+G+zonAt0TiUUBMn4+CiTgaA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/prismjs": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", @@ -4523,6 +4653,12 @@ "node": ">=0.8" } }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", diff --git a/app/package.json b/app/package.json index 0d7c2e83..6a5e1fe4 100644 --- a/app/package.json +++ b/app/package.json @@ -11,7 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "@falkordb/canvas": "file:../../falkordb-canvas", + "@falkordb/canvas": "v0.2.2", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", diff --git a/app/src/App.tsx b/app/src/App.tsx index 0ad845e8..fe70471d 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -12,10 +12,10 @@ import { Carousel, CarouselApi, CarouselContent, CarouselItem, CarouselNext, Car import { Drawer, DrawerContent, DrawerDescription, DrawerTitle, DrawerTrigger } from '@/components/ui/drawer'; import Input from './components/Input'; import { Labels } from './components/labels'; -import { Toolbar } from './components/toolbar'; +import { Toolbar, ZoomControls } from './components/toolbar'; import { cn, GraphRef, Message, Path, PathData, PathNode } from '@/lib/utils'; import type { GraphNode } from '@falkordb/canvas'; -import { graphDataToData } from '@falkordb/canvas'; +import { convertToCanvasData } from './components/ForceGraph'; import { Toaster } from '@/components/ui/toaster'; import GTM from './GTM'; import { Button } from '@/components/ui/button'; @@ -82,6 +82,7 @@ export default function App() { const [searchNode, setSearchNode] = useState({}); const [cooldownTicks, setCooldownTicks] = useState(undefined) const [animation, setAnimation] = useState(false) + const [manualDimmed, setManualDimmed] = useState(true) const [optionsOpen, setOptionsOpen] = useState(false) const [messages, setMessages] = useState([]); const [query, setQuery] = useState(''); @@ -228,29 +229,7 @@ export default function App() { setZoomedNodes([chartNode]) graph.visibleLinks(true, [chartNode!.id]) - const currentData = canvas.getGraphData() - - const { dataToGraphData } = await import('@falkordb/canvas') - const graphNode = dataToGraphData({ - nodes: [{ - color: chartNode.color, - id: chartNode.id, - labels: [chartNode.category], - visible: chartNode.visible, - data: { - ...chartNode.data, - isPath: chartNode.isPath, - isPathSelected: chartNode.isPathSelected, - } - }], links: [] - }).nodes[0] - - if (graphNode) { - currentData.nodes.push(graphNode) - } - - canvas.setGraphData(graphDataToData(currentData)) - + canvas.setGraphData(convertToCanvasData(graph.Elements)) setTimeout(() => { canvas.zoomToFit(4, (n: GraphNode) => n.id === chartNode!.id) @@ -263,22 +242,7 @@ export default function App() { chartNode.visible = true graph.visibleLinks(true, [chartNode!.id]) - const currentData = canvas.getGraphData() - - const graphNode = currentData.nodes.find(n => n.id === chartNode!.id) - if (graphNode) { - graphNode.visible = true - } - - currentData.links.forEach(canvasLink => { - const appLink = graph.LinksMap.get(canvasLink.id) - - if (appLink) { - canvasLink.visible = appLink.visible - } - }) - - canvas.setGraphData(graphDataToData(currentData)) + canvas.setGraphData(convertToCanvasData(graph.Elements)) } setTimeout(() => { @@ -304,25 +268,17 @@ export default function App() { graph.visibleLinks(show) - const currentData = canvas.getGraphData(); - - currentData.nodes.forEach(canvasNode => { - const appNode = graph.NodesMap.get(canvasNode.id); - - if (appNode) { - canvasNode.visible = appNode.visible; - } - }); - - currentData.links.forEach(canvasLink => { - const appLink = graph.LinksMap.get(canvasLink.id); - - if (appLink) { - canvasLink.visible = appLink.visible; - } - }); - - canvas.setGraphData(graphDataToData(currentData)); + // setGraphData doesn't update visible for existing nodes — mutate canvas nodes directly + const canvasData = canvas.getGraphData() + canvasData.nodes.forEach((canvasNode: { id: number, visible: boolean }) => { + const appNode = graph.NodesMap.get(canvasNode.id) + if (appNode) canvasNode.visible = appNode.visible + }) + canvasData.links.forEach((canvasLink: { id: number, visible: boolean }) => { + const appLink = graph.LinksMap.get(canvasLink.id) + if (appLink) canvasLink.visible = appLink.visible + }) + canvas.refresh() setHasHiddenElements(graph.getElements().some(element => !element.visible)); } @@ -539,6 +495,8 @@ export default function App() { setCooldownTicks={setCooldownTicks} animation={animation} setAnimation={setAnimation} + manualDimmed={manualDimmed} + setManualDimmed={setManualDimmed} onCategoryClick={(name, show) => onCategoryClick(name, show, desktopChartRef)} handleDownloadImage={handleDownloadImage} zoomedNodes={zoomedNodes} @@ -667,6 +625,8 @@ export default function App() { setCooldownTicks={setCooldownTicks} animation={animation} setAnimation={setAnimation} + manualDimmed={manualDimmed} + setManualDimmed={setManualDimmed} onCategoryClick={(name, show) => onCategoryClick(name, show, mobileChartRef)} handleDownloadImage={handleDownloadImage} zoomedNodes={zoomedNodes} @@ -719,11 +679,10 @@ export default function App() { - onCategoryClick(name, show, mobileChartRef)} /> -
- -

Take Screenshot

-
+ {/* Options controls inside the drawer, below search */} +
diff --git a/app/src/components/ForceGraph.tsx b/app/src/components/ForceGraph.tsx index 110b3902..dd915a00 100644 --- a/app/src/components/ForceGraph.tsx +++ b/app/src/components/ForceGraph.tsx @@ -16,31 +16,34 @@ interface Props { onLinkHover: (link: Link | null) => void onLinkRightClick: (link: Link, event: MouseEvent) => void isLinkSelected: (link: GraphLink) => boolean + isNodeDimmed: (node: GraphNode) => boolean + isLinkDimmed: (link: GraphLink) => boolean + dimmed: boolean + linkLineDash: (link: any) => number[] onBackgroundClick: (event: MouseEvent) => void onBackgroundRightClick: (event: MouseEvent) => void - nodeCanvasObject: (node: GraphNode, ctx: CanvasRenderingContext2D) => void - nodePointerAreaPaint: (node: GraphNode, color: string, ctx: CanvasRenderingContext2D) => void - linkLineDash: (link: any) => number[] onZoom: () => void onEngineStop: () => void - animation: boolean + onNodeDragEnd?: () => void backgroundColor?: string foregroundColor?: string } -const convertToCanvasData = (graphData: GraphData): Data => ({ - nodes: graphData.nodes.filter(n => n.visible).map(({ id, category, color, visible, isPath, isPathSelected, data }) => ({ +export const convertToCanvasData = (graphData: GraphData): Data => ({ + nodes: graphData.nodes.map(({ id, category, color, visible, expand, isPath, isPathSelected, data }) => ({ id, labels: [category], color, + borderColor: (isPath || isPathSelected) ? PATH_COLOR : undefined, visible, + expand, data: { ...data, isPath, isPathSelected } })), - links: graphData.links.filter(l => l.visible).map(({ id, label, color, visible, source, target, isPath, isPathSelected, data }) => ({ + links: graphData.links.map(({ id, label, color, visible, source, target, isPath, isPathSelected, data }) => ({ id, relationship: label, - color: isPath ? PATH_COLOR : color, + color: (isPath || isPathSelected) ? PATH_COLOR : color, visible, source, target, @@ -60,14 +63,15 @@ export default function ForceGraph({ onLinkHover, onLinkRightClick, isLinkSelected, + isNodeDimmed, + isLinkDimmed, + dimmed, + linkLineDash, onBackgroundClick, onBackgroundRightClick, onZoom, onEngineStop, - nodeCanvasObject, - nodePointerAreaPaint, - linkLineDash, - animation, + onNodeDragEnd, backgroundColor = "#FFFFFF", foregroundColor = "#000000" }: Props) { @@ -83,10 +87,10 @@ export default function ForceGraph({ useEffect(() => { const canvas = canvasRef.current - if (!canvas) return + if (!canvas || !canvasLoaded) return (window as any)[id === "desktop" ? "graphDesktop" : "graphMobile"] = () => canvas.getGraphData(); - }, [canvasRef, id]) + }, [canvasRef, id, canvasLoaded]) // Update canvas colors useEffect(() => { @@ -95,13 +99,6 @@ export default function ForceGraph({ canvasRef.current.setForegroundColor(foregroundColor) }, [canvasRef, backgroundColor, foregroundColor, canvasLoaded]) - // Update animation state on canvas - useEffect(() => { - if (!canvasRef.current || !canvasLoaded) return - - canvasRef.current.setAnimation(animation) - }, [canvasRef, animation, canvasLoaded]) - // Map node click handler const handleNodeClick = useCallback((node: GraphNode, event: MouseEvent) => { const originalNode = data.nodes.find(n => n.id === node.id) @@ -114,9 +111,9 @@ export default function ForceGraph({ onNodeHover(null) return } - + const originalNode = data.nodes.find(n => n.id === node.id) - + if (originalNode) onNodeHover(originalNode) }, [onNodeHover, data.nodes]) @@ -138,9 +135,9 @@ export default function ForceGraph({ onLinkHover(null) return } - + const originalLink = data.links.find(l => l.id === link.id) - + if (originalLink) onLinkHover(originalLink) }, [onLinkHover, data.links]) @@ -159,11 +156,11 @@ export default function ForceGraph({ useEffect(() => { if (!canvasRef.current || !canvasLoaded) return canvasRef.current.setConfig({ - // nodes will display node.data.captionsKeys in the canvas - captionsKeys: ["name", "title"], + captionsKeys: [["name", true], ["title", true]], isNodeSelected: isNodeSelected, isLinkSelected: isLinkSelected, - node: { nodeCanvasObject, nodePointerAreaPaint }, + isNodeDimmed: isNodeDimmed, + isLinkDimmed: isLinkDimmed, linkLineDash, eventHandlers: { onNodeClick: handleNodeClick, @@ -175,6 +172,7 @@ export default function ForceGraph({ onBackgroundClick, onBackgroundRightClick, onEngineStop: handleEngineStop, + onNodeDragEnd, onZoom } }) @@ -187,17 +185,25 @@ export default function ForceGraph({ handleLinkHover, isNodeSelected, isLinkSelected, + isNodeDimmed, + isLinkDimmed, + dimmed, onBackgroundClick, onBackgroundRightClick, handleEngineStop, - nodeCanvasObject, - nodePointerAreaPaint, + onNodeDragEnd, linkLineDash, onZoom, canvasRef, canvasLoaded ]) + // Sync dimmed state to canvas (like browser: separate effect, not via setConfig) + useEffect(() => { + if (!canvasRef.current || !canvasLoaded) return + canvasRef.current.setDimmed(dimmed) + }, [dimmed, canvasRef, canvasLoaded]) + // Update canvas data useEffect(() => { const canvas = canvasRef.current diff --git a/app/src/components/chat.tsx b/app/src/components/chat.tsx index f5057c70..f68cdd01 100644 --- a/app/src/components/chat.tsx +++ b/app/src/components/chat.tsx @@ -12,7 +12,8 @@ import { Button } from "@/components/ui/button"; const AUTH_HEADERS: HeadersInit = import.meta.env.VITE_SECRET_TOKEN ? { 'Authorization': `Bearer ${import.meta.env.VITE_SECRET_TOKEN}` } : {}; -import { dataToGraphData, graphDataToData, GraphLink, GraphNode } from "@falkordb/canvas"; +import { GraphNode } from "@falkordb/canvas"; +import { convertToCanvasData } from "./ForceGraph"; interface Props { repo: string @@ -114,7 +115,7 @@ export function Chat({ messages, setMessages, query, setQuery, selectedPath, set if (!canvas) return - // Sets for the new path + setIsPathResponse(true) const pNodeIds = new Set(p.nodes.map((n: Node) => n.id)) const pLinkIds = new Set(p.links.map((l: any) => l.id)) const pIds = new Set([...pNodeIds, ...pLinkIds]) @@ -180,57 +181,9 @@ export function Chat({ messages, setMessages, query, setQuery, selectedPath, set }) } - const currentData = canvas.getGraphData(); - - // --- Unset canvas data for previous path elements no longer in the new path --- - if (selectedPath) { - currentData.nodes.forEach((n: any) => { - if (prevNodeIds.has(n.id) && !pNodeIds.has(n.id)) { - if (isPathResponse) { - // keep isPath + PATH_COLOR border, just deselect - n.data.isPathSelected = false - } else { - n.data.isPath = false - n.data.isPathSelected = false - } - } - }) - currentData.links.forEach((l: any) => { - if (prevLinkIds.has(l.id) && !pLinkIds.has(l.id)) { - if (isPathResponse) { - // keep color + dashed path line, just deselect - l.data.isPathSelected = false - } else { - l.data.isPathSelected = false - l.color = "#999999" - } - } - }) - } - - // --- Set canvas data for new path elements --- - currentData.nodes.forEach((n: any) => { - if (pNodeIds.has(n.id)) { - if (n.id === firstNodeId || n.id === lastNodeId) { - n.data.isPathSelected = true; - } else { - n.data.isPath = true; - } - } - }); - currentData.links.forEach((l: any) => { - if (pLinkIds.has(l.id)) { - l.data.isPathSelected = true; - l.color = PATH_COLOR; - } - }); - - canvas.setGraphData(graphDataToData(currentData)) + canvas.setGraphData(convertToCanvasData(graph.Elements)) setTimeout(() => { - const filteredNodes = currentData.nodes.filter((n: any) => pNodeIds.has(n.id)); - console.log('[zoomToFit] filtered nodes:', filteredNodes.map((n: any) => ({ id: n.id, x: n.x, y: n.y }))); - console.log('[zoomToFit] pNodeIds:', [...pNodeIds]); canvas.zoomToFit(2, (n: GraphNode) => pNodeIds.has(n.id)); }, 300) setChatOpen && setChatOpen(false) @@ -375,69 +328,30 @@ export function Chat({ messages, setMessages, query, setQuery, selectedPath, set ]); setIsPathResponse(true) - const currentData = canvas.getGraphData(); - - const nodesMap = new Map(currentData.nodes.map(n => [n.id, n])) - const linksMap = new Map(currentData.links.map(l => [l.id, l])) - + // Mark path elements on the model formattedPaths.flatMap(p => p.nodes).forEach(n => { - const node = nodesMap.get(n.id); + const node = graph.Elements.nodes.find(gn => gn.id === n.id); if (node) { - node.data.isPath = true; + node.isPath = true; } }); formattedPaths.flatMap(p => p.links).forEach(l => { - const link = linksMap.get(l.id); - + const link = graph.Elements.links.find(gl => gl.id === l.id); if (link) { - link.data.isPath = true; + link.isPath = true; link.color = PATH_COLOR; } }); - // Filter for only new elements - const newDataElements = { - nodes: elements.nodes.filter(n => !nodesMap.has(n.id)) - .map(({ category, color, data, id, isPath, isPathSelected, visible }) => ({ - color, - id, - labels: [category], - data: { - ...data, - isPath, - isPathSelected - }, - visible, - })), - links: elements.links.filter(l => !linksMap.has(l.id)) - .map(({ color, id, source, target, data, isPath, isPathSelected, visible, label }) => ({ - color: isPath ? PATH_COLOR : color, - id, - source, - target, - data: { - ...data, - isPath, - isPathSelected - }, - visible, - relationship: label, - })) + // Update the canvas from the model + canvasRef.current?.setGraphData(convertToCanvasData(graph.Elements)) + + // Zoom to fit all path nodes after coloring + const pathNodeIds = new Set(formattedPaths.flatMap(p => p.nodes).map(n => n.id)) + if (pathNodeIds.size > 0 && canvasRef.current) { + canvasRef.current.zoomToFit(1, (n: any) => pathNodeIds.has(n.id)) } - // Convert only new data to GraphData format - const newGraphData = dataToGraphData( - newDataElements, - undefined, - new Map(currentData.nodes.map(n => [n.id, n])) - ) - - // Merge with existing data - canvasRef.current?.setGraphData(graphDataToData({ - nodes: [...currentData.nodes, ...newGraphData.nodes], - links: [...currentData.links, ...newGraphData.links] - })) - setTimeout(() => { const nodesMap = new Map(formattedPaths.flatMap(p => p.nodes.map((n: Node) => [n.id, n]))) canvas.zoomToFit(2, (n: GraphNode) => formattedPaths.some(p => nodesMap.has(n.id))); @@ -468,18 +382,12 @@ export function Chat({ messages, setMessages, query, setQuery, selectedPath, set e.isPathSelected = false }) - const currentData = canvas.getGraphData(); - - [...currentData.nodes, ...currentData.links].forEach(element => { - element.data.isPath = false - element.data.isPathSelected = false - - if ("source" in element) { - element.color = "#999999" - } + // Reset link colors on the model + graph.Elements.links.forEach(link => { + link.color = "#999999" }) - canvas.setGraphData(graphDataToData(currentData)) + canvas.setGraphData(convertToCanvasData(graph.Elements)) } setMessages(prev => [...prev, createMessage({ diff --git a/app/src/components/code-graph.tsx b/app/src/components/code-graph.tsx index 4dc3b38a..a872ba24 100644 --- a/app/src/components/code-graph.tsx +++ b/app/src/components/code-graph.tsx @@ -1,5 +1,5 @@ import { Dispatch, SetStateAction, useEffect, useRef, useState } from "react"; -import { Graph, GraphData, Node, Link } from "./model"; +import { Graph, GraphData, Node, Link, getCategoryColorValue } from "./model"; import { Toolbar } from "./toolbar"; import { Labels } from "./labels"; import { Download, GitFork, Search, X } from "lucide-react"; @@ -7,20 +7,18 @@ import { Button } from "@/components/ui/button"; import ElementMenu from "./elementMenu"; import Combobox from "./combobox"; import { toast } from '@/components/ui/use-toast'; -import { Path, PATH_COLOR } from "@/lib/utils"; +import { Path } from "@/lib/utils"; import Input from './Input'; // import CommitList from './commitList'; import { Checkbox } from '@/components/ui/checkbox'; import type { Position } from "./graphView"; -import { prepareArg } from '../utils'; import { GraphRef } from "@/lib/utils"; -import { dataToGraphData, graphDataToData } from "@falkordb/canvas"; -import type { Node as CanvasNode, Link as CanvasLink, GraphData as CanvasData } from "@falkordb/canvas"; import GraphView from "./graphView"; +import { convertToCanvasData } from "./ForceGraph"; const AUTH_HEADERS: HeadersInit = import.meta.env.VITE_SECRET_TOKEN - ? { 'Authorization': `Bearer ${import.meta.env.VITE_SECRET_TOKEN}` } - : {}; + ? { 'Authorization': `Bearer ${import.meta.env.VITE_SECRET_TOKEN}` } + : {}; interface Props { id: "desktop" | "mobile" @@ -46,6 +44,8 @@ interface Props { setCooldownTicks: Dispatch> animation: boolean setAnimation: (animation: boolean) => void + manualDimmed: boolean + setManualDimmed: (dimmed: boolean) => void onCategoryClick: (name: string, show: boolean) => void handleDownloadImage: () => void zoomedNodes: Node[] @@ -78,6 +78,8 @@ export function CodeGraph({ setCooldownTicks, animation, setAnimation, + manualDimmed, + setManualDimmed, onCategoryClick, handleDownloadImage, zoomedNodes, @@ -87,7 +89,6 @@ export function CodeGraph({ }: Props) { const [url, setURL] = useState(""); - const [selectedObj, setSelectedObj] = useState(); const [selectedObjects, setSelectedObjects] = useState([]); const [position, setPosition] = useState(); const [graphName, setGraphName] = useState(""); @@ -114,9 +115,9 @@ export function CodeGraph({ useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Delete') { - if (selectedObjects.length === 0 && (!selectedObj || "source" in selectedObj)) return - - handleRemove([...selectedObjects.map(obj => obj.id), selectedObj?.id].filter(id => id !== undefined), "nodes"); + if (selectedObjects.length === 0) return + + handleRemove(selectedObjects.map(obj => obj.id), "nodes"); } }; @@ -125,7 +126,7 @@ export function CodeGraph({ return () => { document.removeEventListener('keydown', handleKeyDown); }; - }, [selectedObjects, selectedObj]); + }, [selectedObjects]); async function fetchCount() { const result = await fetch(`/api/repo_info`, { @@ -197,20 +198,16 @@ export function CodeGraph({ if (nodes.length === 0) return; const expandedNodes: Node[] = [] - const deleteIdsMap = new Set() graph.Elements = { nodes: graph.Elements.nodes.filter(node => { if (!node.collapsed) return true - const isTarget = graph.Elements.links.some(link => link.target === node.id && nodes.some(n => n.id === link.source)); - - if (!isTarget) return true + const isConnected = graph.Elements.links.some(link => (link.target === node.id || nodes.some(n => n.id === link.target)) && (link.source === node.id || nodes.some(n => n.id === link.source))); - deleteIdsMap.add(node.id) - const deleted = graph.NodesMap.delete(Number(node.id)) + if (!isConnected) return true - if (deleted && node.expand) { + if (graph.NodesMap.delete(Number(node.id)) && node.expand) { expandedNodes.push(node) } @@ -219,15 +216,14 @@ export function CodeGraph({ links: graph.Elements.links } - deleteNeighbors(expandedNodes)?.forEach(id => deleteIdsMap.add(id)) + deleteNeighbors(expandedNodes) - graph.removeLinks() - - return deleteIdsMap + graph.removeLinks(nodes.map(n => n.id)) } const handleExpand = async (nodes: Node[], expand: boolean) => { if (expand) { + // DON'T set expand state until after successful fetch const elements = await onFetchNode(nodes.map(n => n.id)) if (elements.nodes.length === 0) { @@ -235,82 +231,34 @@ export function CodeGraph({ title: `No neighbors found`, description: `No neighbors found`, }) + // Don't set expand if no neighbors found - no glow effect return } - const currentData = canvasRef.current?.getGraphData() - - if (!currentData) return - - // Get existing IDs - const existingNodeIds = new Set(currentData.nodes.map(n => n.id)) - const existingLinkIds = new Set(currentData.links.map(l => l.id)) - - // Filter for only new elements - const newDataElements = { - nodes: elements.nodes.filter(n => !existingNodeIds.has(n.id)) - .map(({ category, color, data, id, isPath, isPathSelected, visible }) => ({ - color: isPath ? PATH_COLOR : color, - id, - labels: [category], - data: { - ...data, - isPath, - isPathSelected - }, - visible, - } as CanvasNode)), - links: elements.links.filter(l => !existingLinkIds.has(l.id)) - .map(({ color, id, source, target, data, isPath, isPathSelected, visible, label }) => ({ - color: isPath ? PATH_COLOR : color, - id, - source, - target, - data: { - ...data, - isPath, - isPathSelected - }, - visible, - relationship: label, - } as CanvasLink)) - } + // Only NOW set expand state after successful fetch - glow animation triggers here + nodes.forEach((node) => { + node.expand = true + }) - // Convert only new data to GraphData format - const newGraphData = dataToGraphData( - newDataElements, - undefined, - new Map(currentData.nodes.map(n => [n.id, n])) - ) - - // Merge with existing data - canvasRef.current?.setGraphData(graphDataToData({ - nodes: [...currentData.nodes, ...newGraphData.nodes], - links: [...currentData.links, ...newGraphData.links] - })) + // Update the model with new elements (graph.Elements is already updated by onFetchNode) + // Convert the full model to canvas data and update + canvasRef.current?.setGraphData(convertToCanvasData(graph.Elements)) } else { const deleteNodes = nodes.filter(n => n.expand) if (deleteNodes.length > 0) { - const deleteIdsMap = deleteNeighbors(deleteNodes); - - if (!deleteIdsMap || deleteIdsMap.size === 0) return - - const currentData = canvasRef.current?.getGraphData() + deleteNeighbors(deleteNodes) + } - if (currentData) { - currentData.nodes = currentData.nodes.filter(node => !deleteIdsMap.has(Number(node.id))) - currentData.links = currentData.links.filter(link => !deleteIdsMap.has(Number(link.source.id)) && !deleteIdsMap.has(Number(link.target.id))) + // Set expand state AFTER deleteNeighbors so the filter above works + nodes.forEach((node) => { + node.expand = false + }) - canvasRef.current?.setGraphData(graphDataToData(currentData)) - } - } + // Convert the updated model to canvas data + canvasRef.current?.setGraphData(convertToCanvasData(graph.Elements)) } - nodes.forEach((node) => { - node.expand = expand - }) - - setSelectedObj(undefined) + setSelectedObjects([]) } const handleRemove = (ids: number[], type: "nodes" | "links") => { @@ -323,26 +271,24 @@ export function CodeGraph({ element.visible = false }) - const currentData = canvas.getGraphData() - - currentData[type].forEach(element => { - if (!ids.includes(Number(element.id))) return - element.visible = false - }) - if (type === "nodes") { - currentData.links.forEach((link) => { - if (ids.includes(link.source.id) || ids.includes(link.target.id)) { - link.visible = false - } - }) + graph.visibleLinks(false, ids) } - canvas.setGraphData(graphDataToData(currentData)) - graph.visibleLinks(false, ids) + // setGraphData doesn't update visible for existing nodes — mutate canvas nodes directly + const canvasData = canvas.getGraphData() + canvasData.nodes.forEach(canvasNode => { + const appNode = graph.NodesMap.get(canvasNode.id) + if (appNode) canvasNode.visible = appNode.visible + }) + canvasData.links.forEach(canvasLink => { + const appLink = graph.LinksMap.get(canvasLink.id) + if (appLink) canvasLink.visible = appLink.visible + }) + canvas.refresh() + setHasHiddenElements(true) - setSelectedObj(undefined) setSelectedObjects([]) } @@ -387,23 +333,24 @@ export function CodeGraph({ if (!canvas) return - graph.getElements().forEach((element) => { - element.isPath = false - element.isPathSelected = false + // Clear path flags from graph model + graph.Elements.nodes.forEach((node) => { + node.isPath = false + node.isPathSelected = false }) - const currentData = canvas.getGraphData(); - - [...currentData.nodes, ...currentData.links].forEach(element => { - element.data.isPath = false - element.data.isPathSelected = false - - if ("source" in element) { - element.color = "#999999" - } + graph.Elements.links.forEach((link) => { + link.isPath = false + link.isPathSelected = false + link.color = "#999999" // Reset link color }) - canvas.setGraphData(graphDataToData(currentData)) + // Update React state to trigger re-render + setData({ ...graph.Elements }) + + // Update canvas + canvas.setGraphData(convertToCanvasData(graph.Elements)) + canvas.zoomToFit() setIsPathResponse(false) }} > @@ -427,13 +374,10 @@ export function CodeGraph({ element.visible = true }); - const currentData = canvas.getGraphData(); - - [...currentData.nodes, ...currentData.links].forEach(element => { - element.visible = true - }); - - canvas.setGraphData(graphDataToData(currentData)); + const canvasData = canvas.getGraphData(); + canvasData.nodes.forEach((n: { visible: boolean }) => { n.visible = true; }); + canvasData.links.forEach((l: { visible: boolean }) => { l.visible = true; }); + canvas.refresh(); setHasHiddenElements(false); }} > @@ -444,11 +388,11 @@ export function CodeGraph({
{ setPath(path) - setSelectedObj(undefined) + setSelectedObjects([]) }} handleRemove={handleRemove} position={position} @@ -462,9 +406,7 @@ export function CodeGraph({ setData={setData} graph={graph} chartRef={canvasRef} - selectedObj={selectedObj} selectedObjects={selectedObjects} - setSelectedObj={setSelectedObj} setSelectedObjects={setSelectedObjects} setPosition={setPosition} handleExpand={handleExpand} @@ -473,9 +415,9 @@ export function CodeGraph({ isPathResponse={isPathResponse} selectedPathId={selectedPathId} setSelectedPathId={setSelectedPathId} - animation={animation} setZoomedNodes={setZoomedNodes} zoomedNodes={zoomedNodes} + manualDimmed={manualDimmed} />
@@ -509,6 +451,9 @@ export function CodeGraph({ handleDownloadImage={handleDownloadImage} animation={animation} setAnimation={setAnimation} + manualDimmed={manualDimmed} + setManualDimmed={setManualDimmed} + selectedObjects={selectedObjects} />
diff --git a/app/src/components/combobox.tsx b/app/src/components/combobox.tsx index 93c6720c..ec1b500a 100644 --- a/app/src/components/combobox.tsx +++ b/app/src/components/combobox.tsx @@ -37,7 +37,7 @@ export default function Combobox({ options, setOptions, selectedValue, onSelecte } const json = await result.json() - setOptions(json.repositories) + setOptions(json.repositories as string[]) } useEffect(() => { diff --git a/app/src/components/elementMenu.tsx b/app/src/components/elementMenu.tsx index e7d5764c..1c7d9a63 100644 --- a/app/src/components/elementMenu.tsx +++ b/app/src/components/elementMenu.tsx @@ -22,6 +22,7 @@ interface Props { export default function ElementMenu({ obj, objects, setPath, handleRemove, position, url, handleExpand, parentRef }: Props) { const [currentObj, setCurrentObj] = useState(); const [containerWidth, setContainerWidth] = useState(0); + const [containerHeight, setContainerHeight] = useState(0); useEffect(() => { setCurrentObj(undefined) @@ -33,19 +34,41 @@ export default function ElementMenu({ obj, objects, setPath, handleRemove, posit ? `${url}/tree/master/${obj.data.path}/${obj.data.name}` : `${url}/tree/master/${obj.data.path}#L${obj.data.src_start}-L${obj.data.src_end + 1}` + const parentRect = parentRef?.current?.getBoundingClientRect() + const parentW = parentRef?.current?.clientWidth || 0 + const parentH = parentRef?.current?.clientHeight || 0 + + const EDGE_MARGIN = 8 // fixed margin from container edges + const GAP = 12 // fixed gap between node edge and menu — constant at every zoom level + + const zoom = position.zoom ?? 1 + const nodeRadius = 9 * zoom // node visual radius in screen px (NODE_SIZE=9 world units) + + // Convert viewport coordinates to parent-relative coordinates + const relX = position.x - (parentRect?.left || 0) + const relY = position.y - (parentRect?.top || 0) + + // X axis: center on click, clamp to container edges + const rawLeft = relX - containerWidth / 2 + const left = Math.max(EDGE_MARGIN, Math.min(rawLeft, parentW - containerWidth - EDGE_MARGIN)) + + // Y axis: always place below at (node_center + node_radius + fixed_gap). + // Clamp to canvas edges — no flipping. If the node is larger than the canvas + // the menu stays at the bottom edge, which may overlap the node. + const rawTop = relY + nodeRadius + GAP + const top = Math.max(EDGE_MARGIN, Math.min(rawTop, parentH - containerHeight - EDGE_MARGIN)) + return ( <>
{ if (!ref) return setContainerWidth(ref.clientWidth) + setContainerHeight(ref.clientHeight) }} className="absolute z-10 bg-popover text-popover-foreground rounded-lg shadow-lg flex divide-x divide-border" id="elementMenu" - style={{ - left: Math.max(8, Math.min(position.x - containerWidth / 2, (parentRef?.current?.clientWidth || 0) - containerWidth - 8)), - top: Math.max(8, Math.min(position.y - 153, (parentRef?.current?.clientHeight || 0) - containerWidth - 8)), - }} + style={{ left, top }} > { objects.some(o => o.id === obj.id) && objects.length > 1 ? diff --git a/app/src/components/graphView.tsx b/app/src/components/graphView.tsx index 38fcad64..d0ac4051 100644 --- a/app/src/components/graphView.tsx +++ b/app/src/components/graphView.tsx @@ -1,16 +1,17 @@ import { Graph, GraphData, Link, Node } from './model'; import { Dispatch, SetStateAction, useCallback, useEffect, useRef, useState } from 'react'; -import { Path, PATH_COLOR } from '@/lib/utils'; +import { Path } from '@/lib/utils'; import { Fullscreen } from 'lucide-react'; import { GraphRef } from '@/lib/utils'; import ForceGraph from './ForceGraph'; -import { GraphLink, GraphNode, NODE_SIZE, getContrastTextColor, wrapTextForCircularNode } from '@falkordb/canvas'; +import { GraphLink, GraphNode } from '@falkordb/canvas'; import { useTheme } from './theme-provider'; export interface Position { x: number, y: number, + zoom?: number, } interface Props { @@ -19,8 +20,6 @@ interface Props { graph: Graph chartRef: GraphRef id: "desktop" | "mobile" - selectedObj: Node | Link | undefined - setSelectedObj: Dispatch> selectedObjects: Node[] setSelectedObjects: Dispatch> setPosition: Dispatch> @@ -30,34 +29,22 @@ interface Props { isPathResponse: boolean | undefined selectedPathId: number | undefined setSelectedPathId: (selectedPathId: number) => void - animation: boolean setZoomedNodes: Dispatch> zoomedNodes: Node[] + manualDimmed: boolean } -const PADDING = 2; -const FONT_FAMILY = 'SofiaSans, Arial, sans-serif'; -const FONT_WEIGHT_NORMAL = 400; -const FONT_WEIGHT_SELECTED = 700; -const TEXT_FILL_RATIO = 0.85; -const STROKE_WIDTH_SELECTED = 1.5; -const STROKE_WIDTH_UNSELECTED = 0.5; const LIGHT_CANVAS_BACKGROUND = '#FFFFFF'; const DARK_CANVAS_BACKGROUND = '#1A1A1A'; const LIGHT_CANVAS_FOREGROUND = '#000000'; const DARK_CANVAS_FOREGROUND = '#F5F5F5'; -const LIGHT_DIMMED_NODE_FILL = '#E5E5E5'; -const DARK_DIMMED_NODE_FILL = '#525252'; -const LIGHT_DIMMED_NODE_STROKE = 'gray'; -const DARK_DIMMED_NODE_STROKE = '#A3A3A3'; + +const DOUBLE_CLICK_MS = 300; export default function GraphView({ data, - graph, chartRef: canvasRef, id, - selectedObj, - setSelectedObj, selectedObjects, setSelectedObjects, setPosition, @@ -67,20 +54,19 @@ export default function GraphView({ isPathResponse, selectedPathId, setSelectedPathId, - animation, zoomedNodes, - setZoomedNodes + setZoomedNodes, + manualDimmed }: Props) { - const lastClick = useRef<{ date: Date, name: string }>({ date: new Date(), name: "" }) + const lastClick = useRef<{ date: number, id: number }>({ date: 0, id: 0 }) + const isCenteringRef = useRef(false) const [screenSize, setScreenSize] = useState(0) const [hoverElement, setHoverElement] = useState() const { resolvedTheme } = useTheme() const isDark = resolvedTheme === 'dark' const canvasBackgroundColor = isDark ? DARK_CANVAS_BACKGROUND : LIGHT_CANVAS_BACKGROUND const canvasForegroundColor = isDark ? DARK_CANVAS_FOREGROUND : LIGHT_CANVAS_FOREGROUND - const dimmedNodeFillColor = isDark ? DARK_DIMMED_NODE_FILL : LIGHT_DIMMED_NODE_FILL - const dimmedNodeStrokeColor = isDark ? DARK_DIMMED_NODE_STROKE : LIGHT_DIMMED_NODE_STROKE useEffect(() => { const handleResize = () => { @@ -97,10 +83,9 @@ export default function GraphView({ }, []) const unsetSelectedObjects = useCallback((evt?: MouseEvent) => { - if (evt?.ctrlKey || (!selectedObj && selectedObjects.length === 0)) return - setSelectedObj(undefined) + if (evt?.ctrlKey || selectedObjects.length === 0) return setSelectedObjects([]) - }, [selectedObj, selectedObjects, setSelectedObj, setSelectedObjects]) + }, [selectedObjects, setSelectedObjects]) const handleRightClick = useCallback((element: Node | Link, evt: MouseEvent) => { if (evt.ctrlKey && "category" in element) { @@ -111,17 +96,84 @@ export default function GraphView({ setSelectedObjects([...selectedObjects, element as Node]) } } else { - setSelectedObjects([]) + setSelectedObjects([element as Node]) } - setSelectedObj(element) - setPosition({ x: evt.clientX, y: evt.clientY }) - }, [selectedObjects, setSelectedObjects, setSelectedObj, setPosition]) + // Center on node or link midpoint when focus mode is ON, then show menu + if ((manualDimmed || isPathResponse) && canvasRef.current) { + // Clear any existing menu immediately so it doesn't flash at old position + setPosition(undefined) + + const graphData = canvasRef.current.getGraphData() + let cx: number | undefined + let cy: number | undefined + + if ("category" in element) { + const graphNode = graphData?.nodes.find(n => n.id === element.id) + cx = graphNode?.x + cy = graphNode?.y + } else { + const src = graphData?.nodes.find(n => n.id === (element as Link).source) + const tgt = graphData?.nodes.find(n => n.id === (element as Link).target) + if (src?.x !== undefined && tgt?.x !== undefined) { + cx = ((src.x ?? 0) + (tgt.x ?? 0)) / 2 + cy = ((src.y ?? 0) + (tgt.y ?? 0)) / 2 + } + } + + if (cx !== undefined && cy !== undefined) { + isCenteringRef.current = true + canvasRef.current.centerAt(cx, cy, 300) + // Show menu after animation fully settles (400ms > 300ms animation) + setTimeout(() => { + isCenteringRef.current = false + const canvasBounds = canvasRef.current?.getBoundingClientRect() + if (canvasBounds) { + setPosition({ + x: canvasBounds.left + canvasBounds.width / 2, + y: canvasBounds.top + canvasBounds.height / 2, + zoom: canvasRef.current?.getZoom() ?? 1 + }) + } + }, 400) + return + } + } + // Focus mode OFF: use the node's actual screen-center so the gap in + // elementMenu is always the same fixed value regardless of where on the + // node the user clicked. + if ("category" in element && canvasRef.current) { + const gd = canvasRef.current.getGraphData() + const gn = gd?.nodes.find(n => n.id === element.id) + const vp = canvasRef.current.getViewport() + const cb = canvasRef.current.getBoundingClientRect() + if (gn?.x !== undefined && gn?.y !== undefined && vp && cb) { + const z = vp.zoom + const sx = (gn.x - vp.centerX) * z + cb.width / 2 + const sy = (gn.y - vp.centerY) * z + cb.height / 2 + setPosition({ x: cb.left + sx, y: cb.top + sy, zoom: z }) + return + } + } + setPosition({ x: evt.clientX, y: evt.clientY, zoom: canvasRef.current?.getZoom() ?? 1 }) + }, [selectedObjects, setSelectedObjects, setPosition, canvasRef, manualDimmed, isPathResponse]) const handleLinkClick = (link: Link, evt: MouseEvent) => { unsetSelectedObjects(evt) if (!isPathResponse || link.id === selectedPathId) return setSelectedPathId(link.id) + + // Zoom to fit selected path nodes with 2x multiplier + if (canvasRef.current) { + const graphData = canvasRef.current.getGraphData() + const selectedPathNodeIds = new Set(graphData?.nodes + .filter(n => n.data?.isPathSelected) + .map(n => n.id) ?? []) + + if (selectedPathNodeIds.size > 0) { + canvasRef.current.zoomToFit(2, (n: GraphNode) => selectedPathNodeIds.has(n.id)) + } + } } const handleNodeHover = useCallback((node: Node | null) => { @@ -135,31 +187,29 @@ export default function GraphView({ const isNodeSelected = useCallback((node: GraphNode) => { if (isPathResponse) { return node.data.isPathSelected - } else { - return selectedObjects.some(obj => "category" in obj && obj.id === node.id) || (selectedObj && "category" in selectedObj && selectedObj?.id === node.id) || (hoverElement && ('category' in hoverElement) && hoverElement.id === node.id) } - }, [isPathResponse, selectedObjects, selectedObj, hoverElement]) + // Highlight selected and hovered nodes - dimming handled separately by isNodeDimmed + return selectedObjects.some(obj => "category" in obj && obj.id === node.id) || (hoverElement && ('category' in hoverElement) && hoverElement.id === node.id) + }, [isPathResponse, selectedObjects, hoverElement]) const isLinkSelected = useCallback((link: GraphLink) => { if (isPathResponse) { return link.data.isPathSelected - } else { - return selectedObjects.some(obj => "source" in obj && obj.id === link.id) || (selectedObj && "source" in selectedObj && selectedObj?.id === link.id) || (hoverElement && 'source' in hoverElement && hoverElement.id === link.id) } - }, [isPathResponse, selectedObjects, selectedObj, hoverElement]) + // Highlight selected and hovered links - dimming handled separately by isLinkDimmed + return selectedObjects.some(obj => "source" in obj && obj.id === link.id) || (hoverElement && 'source' in hoverElement && hoverElement.id === link.id) + }, [isPathResponse, selectedObjects, hoverElement]) const handleNodeClick = useCallback(async (node: Node) => { - const now = new Date() - const { date, name } = lastClick.current + const now = Date.now() + const { date, id } = lastClick.current - const isDoubleClick = now.getTime() - date.getTime() < 1000 && name === node.data.name - lastClick.current = { date: now, name: node.data.name } + const isDoubleClick = now - date < DOUBLE_CLICK_MS && id === node.id + lastClick.current = isDoubleClick ? { date: 0, id: 0 } : { date: now, id: node.id } if (isDoubleClick) { - lastClick.current = { date: now, name: "" } handleExpand([node], !node.expand) } else if (isShowPath) { - lastClick.current = { date: now, name: "" } setPath(prev => { if (!prev?.start?.name || (prev.end?.name && prev.end?.name !== "")) { return ({ start: { id: Number(node.id), name: node.data.name } }) @@ -178,146 +228,102 @@ export default function GraphView({ } }, [zoomedNodes, canvasRef]) - const nodeCanvasObject = useCallback((node: GraphNode, ctx: CanvasRenderingContext2D) => { - if (node.x === undefined || node.y === undefined) { - node.x = 0; - node.y = 0; - } - - const isHovered = !!hoverElement && !('source' in hoverElement) && hoverElement.id === node.id - const isSelected = selectedObjects.some(obj => obj.id === node.id) || selectedObj?.id === node.id - const nodeSelected = isSelected || isHovered - - // --- Determine colors based on path state --- + const isNodeDimmed = useCallback((node: GraphNode) => { if (isPathResponse) { - if (node.data.isPathSelected) { - ctx.fillStyle = node.color; - ctx.strokeStyle = PATH_COLOR; - ctx.lineWidth = STROKE_WIDTH_SELECTED; - } else if (node.data.isPath) { - ctx.fillStyle = node.color; - ctx.strokeStyle = PATH_COLOR; - ctx.lineWidth = STROKE_WIDTH_UNSELECTED; - } else { - ctx.fillStyle = dimmedNodeFillColor; - ctx.strokeStyle = dimmedNodeStrokeColor; - ctx.lineWidth = STROKE_WIDTH_UNSELECTED; - } - } else if (isPathResponse === undefined) { - if (node.data.isPathSelected) { - ctx.fillStyle = node.color; - ctx.strokeStyle = PATH_COLOR; - ctx.lineWidth = STROKE_WIDTH_SELECTED; - } else if (node.data.isPath) { - ctx.fillStyle = node.color; - ctx.strokeStyle = PATH_COLOR; - ctx.lineWidth = STROKE_WIDTH_UNSELECTED; + return !node.data.isPath && !node.data.isPathSelected + } + // Only apply dimming when focus mode is ON (manualDimmed=true) + // When manualDimmed=false, nothing should be dimmed regardless of selection + if (!manualDimmed) { + return false + } + + if (selectedObjects.length === 0 && !hoverElement) return false + + // Collect all active elements (selected + hovered) + const activeElements: (Node | Link)[] = [...selectedObjects] + if (hoverElement && !activeElements.includes(hoverElement)) { + activeElements.push(hoverElement) + } + + // Build selected node IDs and link endpoint IDs + const selectedNodeIds = new Set() + const linkEndpointIds = new Set() + + for (const el of activeElements) { + if (!('source' in el)) { + selectedNodeIds.add((el as any).id) } else { - ctx.fillStyle = node.color; - ctx.strokeStyle = canvasForegroundColor; - ctx.lineWidth = nodeSelected ? STROKE_WIDTH_SELECTED : STROKE_WIDTH_UNSELECTED; + linkEndpointIds.add((el as any).source as number) + linkEndpointIds.add((el as any).target as number) } - } else { - ctx.fillStyle = node.color; - ctx.strokeStyle = canvasForegroundColor; - ctx.lineWidth = nodeSelected ? STROKE_WIDTH_SELECTED : STROKE_WIDTH_UNSELECTED; } - - const radius = NODE_SIZE + ctx.lineWidth / 2; - - // Draw stroke circle - ctx.beginPath(); - ctx.arc(node.x, node.y, radius, 0, 2 * Math.PI, false); - ctx.stroke(); - - // Draw fill circle - ctx.beginPath(); - ctx.arc(node.x, node.y, NODE_SIZE, 0, 2 * Math.PI, false); - ctx.fill(); - - // Skip labels when zoomed out (large graph optimisation) - const zoom = ctx.getTransform().a; - if (zoom < 1) return; - - // --- Draw text (matching canvas logic) --- - const fillColor = ctx.fillStyle as string; - ctx.fillStyle = getContrastTextColor(fillColor); - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - - const textRadius = NODE_SIZE - PADDING / 2; - const name = node.data.name || node.data.title || String(node.id); - const nodeFontWeight = nodeSelected ? FONT_WEIGHT_SELECTED : FONT_WEIGHT_NORMAL; - const baseFontSize = 4; - - // Measure at the base size for line-wrapping decisions - ctx.font = `${nodeFontWeight} ${baseFontSize}px ${FONT_FAMILY}`; - const [line1, line2] = wrapTextForCircularNode(ctx, name, textRadius); - - let chosenSize = baseFontSize; - - if (TEXT_FILL_RATIO > 0 && !line2) { - // Auto-size mode: scale text to fill textFillRatio × nodeRadius - const REF = 20; - ctx.font = `${nodeFontWeight} ${REF}px ${FONT_FAMILY}`; - const refMetrics = ctx.measureText(line1); - const visualWidth = (refMetrics.actualBoundingBoxLeft ?? 0) - + (refMetrics.actualBoundingBoxRight ?? 0); - const refWidth = Math.max(visualWidth, refMetrics.width); - const refHeight = (refMetrics.actualBoundingBoxAscent ?? 0) - + (refMetrics.actualBoundingBoxDescent ?? 0); - - const r = TEXT_FILL_RATIO * textRadius; - if (refWidth > 0 && refHeight > 0) { - const diagonal = Math.sqrt(refWidth * refWidth + refHeight * refHeight); - chosenSize = REF * (2 * r / diagonal); - } else if (refWidth > 0) { - chosenSize = REF * (2 * r / refWidth); + + const allActiveIds = new Set([...selectedNodeIds, ...linkEndpointIds]) + if (allActiveIds.size === 0) return false + if (allActiveIds.has(node.id)) return false + + // Expand neighbourhood only for directly selected nodes + for (const link of data.links) { + if ((selectedNodeIds.has(link.source) && link.target === node.id) || + (selectedNodeIds.has(link.target) && link.source === node.id)) { + return false } } + + return true + }, [isPathResponse, manualDimmed, selectedObjects, hoverElement, data.links]) - ctx.font = `${nodeFontWeight} ${chosenSize}px ${FONT_FAMILY}`; - - const textMetrics = ctx.measureText(line1); - const textHeight = textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent; - const halfTextHeight = (textHeight / 2) * 1.5; - - if (line1) { - const yCorrection = line2 - ? 0 - : (textMetrics.actualBoundingBoxAscent - textMetrics.actualBoundingBoxDescent) / 2; - ctx.fillText(line1, node.x, line2 ? node.y - halfTextHeight : node.y + yCorrection); + const isLinkDimmed = useCallback((link: GraphLink) => { + if (isPathResponse) { + return !link.data.isPath && !link.data.isPathSelected } - if (line2) { - ctx.fillText(line2, node.x, node.y + halfTextHeight); + // Only apply dimming when focus mode is ON (manualDimmed=true) + // When manualDimmed=false, nothing should be dimmed regardless of selection + if (!manualDimmed) { + return false } - }, [ - selectedObj, - selectedObjects, - isPathResponse, - hoverElement, - dimmedNodeFillColor, - dimmedNodeStrokeColor, - canvasForegroundColor, - ]) - - const nodePointerAreaPaint = useCallback((node: GraphNode, color: string, ctx: CanvasRenderingContext2D) => { - if (node.x === undefined || node.y === undefined) { - node.x = 0; - node.y = 0; + + if (selectedObjects.length === 0 && !hoverElement) return false + + // Don't dim the link itself if it's selected/hovered + if (hoverElement && 'source' in hoverElement && hoverElement.id === link.id) return false + if (selectedObjects.some(obj => 'source' in obj && obj.id === link.id)) return false + + // Collect all active elements (selected + hovered) + const activeElements: (Node | Link)[] = [...selectedObjects] + if (hoverElement && !activeElements.includes(hoverElement)) { + activeElements.push(hoverElement) + } + + // Build selected node IDs only (not link endpoints) + const selectedNodeIds = new Set() + for (const el of activeElements) { + if (!('source' in el)) { + selectedNodeIds.add((el as any).id) + } } + + if (selectedNodeIds.size === 0) return true + + // Link is undimmed if one endpoint is a directly selected node + const srcId = typeof link.source === 'object' ? (link.source as any).id : link.source + const tgtId = typeof link.target === 'object' ? (link.target as any).id : link.target + + if (selectedNodeIds.has(srcId) || selectedNodeIds.has(tgtId)) return false - ctx.fillStyle = color; - ctx.beginPath(); - ctx.arc(node.x, node.y, NODE_SIZE + 2 + ctx.lineWidth / 2, 0, 2 * Math.PI, false); - ctx.fill(); - }, []) + return true + }, [isPathResponse, manualDimmed, selectedObjects, hoverElement, data.links]) const linkLineDash = useCallback((link: GraphLink) => { if (link.data.isPath && !link.data.isPathSelected) return [5, 5] return [] }, []) + const handleNodeDragEnd = useCallback(() => { + setPosition(undefined) + }, [setPosition]) + const mobileBreakpointRaw = Number(import.meta.env.VITE_MOBILE_BREAKPOINT) const mobileBreakpoint = Number.isFinite(mobileBreakpointRaw) ? mobileBreakpointRaw : 0 const isDesktop = screenSize > mobileBreakpoint @@ -341,14 +347,15 @@ export default function GraphView({ onLinkHover={handleLinkHover} onLinkRightClick={handleRightClick} isLinkSelected={isLinkSelected} + isNodeDimmed={isNodeDimmed} + isLinkDimmed={isLinkDimmed} + dimmed={isPathResponse === true || manualDimmed} + linkLineDash={linkLineDash} onBackgroundClick={unsetSelectedObjects} onBackgroundRightClick={unsetSelectedObjects} - onZoom={() => unsetSelectedObjects()} + onZoom={() => { if (!isCenteringRef.current) unsetSelectedObjects() }} onEngineStop={handleEngineStop} - nodeCanvasObject={nodeCanvasObject} - nodePointerAreaPaint={nodePointerAreaPaint} - linkLineDash={linkLineDash} - animation={animation} + onNodeDragEnd={handleNodeDragEnd} backgroundColor={canvasBackgroundColor} foregroundColor={canvasForegroundColor} /> diff --git a/app/src/components/model.ts b/app/src/components/model.ts index 2fe40608..b40cc365 100644 --- a/app/src/components/model.ts +++ b/app/src/components/model.ts @@ -34,6 +34,7 @@ export interface Link { target: number, label: string, visible: boolean, + collapsed: boolean, isPathSelected: boolean, isPath: boolean, color: string, @@ -246,6 +247,7 @@ export class Graph { target: edgeData.dest_node, label: edgeData.relation, visible: true, + collapsed, color: "#999999", isPathSelected: false, isPath: !!path, @@ -260,12 +262,21 @@ export class Graph { return newElements } - public removeLinks() { + public removeLinks(ids: number[] = []) { + const connectedLinks = this.elements.links.filter(link => ids.includes(link.source) || ids.includes(link.target)) + this.elements = { nodes: this.elements.nodes, links: this.elements.links.map(link => { - if (this.nodesMap.get(link.source) && this.nodesMap.get(link.target)) { - return link + if ( + (ids.length !== 0 && !connectedLinks.includes(link)) || + (this.nodesMap.get(link.source) && this.nodesMap.get(link.target)) + ) { + if (link.collapsed && connectedLinks.includes(link)) { + // Remove collapsed links connected to the given ids even if both endpoints exist + } else { + return link + } } this.linksMap.delete(link.id) }).filter(link => link !== undefined) diff --git a/app/src/components/toolbar.tsx b/app/src/components/toolbar.tsx index 5553c1c7..052bb8a3 100644 --- a/app/src/components/toolbar.tsx +++ b/app/src/components/toolbar.tsx @@ -1,9 +1,10 @@ import { useState } from "react"; -import { ChevronDown, Circle, Download, Fullscreen, Pause, Pin, PinOff, Play, ZoomIn, ZoomOut } from "lucide-react"; +import { ChevronDown, Circle, Download, Fullscreen, Pause, Pin, PinOff, Play, Telescope, ZoomIn, ZoomOut } from "lucide-react"; import type { LayoutMode, HierarchyDirection, RadialDirection } from "@falkordb/canvas"; import { cn } from "@/lib/utils" import { GraphRef } from "@/lib/utils"; import { Switch } from "@/components/ui/switch"; +import type { Node, Link } from "./model"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; const LAYOUTS: { value: LayoutMode; label: string }[] = [ @@ -24,15 +25,66 @@ const RADIAL_DIRECTIONS: { value: RadialDirection; label: string }[] = [ { value: 'in', label: 'Inward' }, ]; +interface ZoomControlsProps { + canvasRef: GraphRef + className?: string + selectedObjects?: (Node | Link)[] +} + +export function ZoomControls({ canvasRef, className, selectedObjects }: ZoomControlsProps) { + const handleZoomClick = (changefactor: number) => { + const canvas = canvasRef.current + if (!canvas) return + if (selectedObjects && selectedObjects.length > 0) { + const graphData = canvas.getGraphData() + const selectedNodeIds = new Set() + for (const el of selectedObjects) { + if ('source' in el) { + selectedNodeIds.add(el.source as number) + selectedNodeIds.add(el.target as number) + } else { + selectedNodeIds.add(el.id) + } + } + const focusedNodes = graphData?.nodes.filter(n => selectedNodeIds.has(n.id)) ?? [] + if (focusedNodes.length > 0) { + const cx = focusedNodes.reduce((s, n) => s + (n.x ?? 0), 0) / focusedNodes.length + const cy = focusedNodes.reduce((s, n) => s + (n.y ?? 0), 0) / focusedNodes.length + canvas.centerAt(cx, cy, 300) + } + } + canvas.zoom(canvas.getZoom() * changefactor) + } + + return ( +
+ + + +
+ ) +} + interface Props { canvasRef: GraphRef className?: string handleDownloadImage?: () => void animation: boolean setAnimation: (animation: boolean) => void + manualDimmed: boolean + setManualDimmed: (dimmed: boolean) => void + selectedObjects?: (Node | Link)[] + /** Hide the three zoom buttons and download, show everything else. */ + hideZoom?: boolean } -export function Toolbar({ canvasRef, className, handleDownloadImage, animation, setAnimation }: Props) { +export function Toolbar({ canvasRef, className, handleDownloadImage, animation, setAnimation, manualDimmed, setManualDimmed, selectedObjects, hideZoom: hideZoom }: Props) { const [layout, setLayout] = useState('force'); const [direction, setDirection] = useState(''); @@ -41,9 +93,32 @@ export function Toolbar({ canvasRef, className, handleDownloadImage, animation, const handleZoomClick = (changefactor: number) => { const canvas = canvasRef.current - if (canvas) { - canvas.zoom(canvas.getZoom() * changefactor) + if (!canvas) return + + if (selectedObjects && selectedObjects.length > 0) { + const graphData = canvas.getGraphData() + const selectedNodeIds = new Set() + + for (const el of selectedObjects) { + if ('source' in el) { + // It's a link - center on both endpoints + selectedNodeIds.add(el.source as number) + selectedNodeIds.add(el.target as number) + } else { + // It's a node + selectedNodeIds.add(el.id) + } + } + + const focusedNodes = graphData?.nodes.filter(n => selectedNodeIds.has(n.id)) ?? [] + if (focusedNodes.length > 0) { + const cx = focusedNodes.reduce((s, n) => s + (n.x ?? 0), 0) / focusedNodes.length + const cy = focusedNodes.reduce((s, n) => s + (n.y ?? 0), 0) / focusedNodes.length + canvas.centerAt(cx, cy, 300) + } } + + canvas.zoom(canvas.getZoom() * changefactor) } const handleCenterClick = () => { @@ -55,7 +130,14 @@ export function Toolbar({ canvasRef, className, handleDownloadImage, animation, } const handleAnimationToggle = () => { - setAnimation(!animation) + const next = !animation; + setAnimation(next); + canvasRef.current?.setAnimation(next); + } + + const handleDimToggle = (checked: boolean) => { + setManualDimmed(checked); + canvasRef.current?.setDimmed(checked); } const handlePinToggle = () => { @@ -110,6 +192,12 @@ export function Toolbar({ canvasRef, className, handleDownloadImage, animation, disabled={animationDisabled} onCheckedChange={handleAnimationToggle} /> + + - - - - -
@@ -146,6 +146,8 @@ export function Toolbar({ canvasRef, className, handleDownloadImage, animation, canvasRef.current?.setPinOnDragEnd(next); } + const isAnimationDisabled = layout !== 'force' || pinned + const handleLayoutChange = (value: string) => { const mode = value as LayoutMode; setLayout(mode); @@ -164,10 +166,16 @@ export function Toolbar({ canvasRef, className, handleDownloadImage, animation, canvasRef.current?.setLayout(mode); - // Non-force layouts auto-pin + // Non-force layouts auto-pin and auto-disable animation const nextPinned = mode !== 'force'; setPinned(nextPinned); canvasRef.current?.setPinOnDragEnd(nextPinned); + + // If switching to non-force layout, disable animation + if (mode !== 'force' && animation) { + setAnimation(false); + canvasRef.current?.setAnimation(false); + } } const handleDirectionChange = (value: string, targetLayout?: string) => { diff --git a/app/src/lib/utils.ts b/app/src/lib/utils.ts index a67780c7..1a8d6af3 100644 --- a/app/src/lib/utils.ts +++ b/app/src/lib/utils.ts @@ -27,13 +27,12 @@ export enum MessageTypes { Text, } -let messageIdCounter = 0; export function createMessage(msg: Omit): Message { - return { ...msg, id: ++messageIdCounter }; + return { ...msg, id: crypto.randomUUID() }; } export interface Message { - id: number; + id: string; type: MessageTypes; text?: string; paths?: { nodes: any[], links: any[] }[]; diff --git a/e2e/tests/toolbar.spec.ts b/e2e/tests/toolbar.spec.ts index d7a4faf1..2b1f4405 100644 --- a/e2e/tests/toolbar.spec.ts +++ b/e2e/tests/toolbar.spec.ts @@ -46,7 +46,7 @@ test.describe("Toolbar and Zoom Controls tests", () => { // Zoom in first await codeGraph.clickZoomIn(); await codeGraph.clickZoomIn(); - const zoomedScale = await codeGraph.getCanvasScaling(); + await codeGraph.getCanvasScaling(); // Click center to reset await codeGraph.clickCenter(); From 7b0cde03279be125a0f384789a7bea120ad794d6 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Wed, 8 Jul 2026 14:15:20 +0300 Subject: [PATCH 05/16] fix: resolve build and compilation errors - Fix missing closing brace in graphView.tsx setTimeout handler - Remove unused cooldownTicks state from App.tsx and CodeGraph - Fix chat.tsx path markings to iterate nodes and links separately - Remove stale EDGE_MARGIN references in elementMenu.tsx --- app/src/App.tsx | 4 ---- app/src/components/chat.tsx | 14 ++++++++------ app/src/components/code-graph.tsx | 4 ---- app/src/components/elementMenu.tsx | 2 +- app/src/components/graphView.tsx | 3 ++- 5 files changed, 11 insertions(+), 16 deletions(-) diff --git a/app/src/App.tsx b/app/src/App.tsx index 91da2ac6..4a2e731a 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -490,8 +490,6 @@ export default function App() { handleSearchSubmit={(node) => handleSearchSubmit(node, desktopChartRef)} searchNode={searchNode} setSearchNode={setSearchNode} - cooldownTicks={cooldownTicks} - setCooldownTicks={setCooldownTicks} animation={animation} setAnimation={setAnimation} manualDimmed={manualDimmed} @@ -620,8 +618,6 @@ export default function App() { handleSearchSubmit={(node) => handleSearchSubmit(node, mobileChartRef)} setSearchNode={setSearchNode} searchNode={searchNode} - cooldownTicks={cooldownTicks} - setCooldownTicks={setCooldownTicks} animation={animation} setAnimation={setAnimation} manualDimmed={manualDimmed} diff --git a/app/src/components/chat.tsx b/app/src/components/chat.tsx index 564527d7..80dce2d3 100644 --- a/app/src/components/chat.tsx +++ b/app/src/components/chat.tsx @@ -329,12 +329,14 @@ export function Chat({ messages, setMessages, query, setQuery, selectedPath, set setIsPathResponse(true) // Clear previous path markings before applying new ones - graph.Elements.forEach((element: any) => { - element.isPath = false; - element.isPathSelected = false; - if ("source" in element) { - element.color = "#999999"; - } + graph.Elements.nodes.forEach((node: any) => { + node.isPath = false; + node.isPathSelected = false; + }); + graph.Elements.links.forEach((link: any) => { + link.isPath = false; + link.isPathSelected = false; + link.color = "#999999"; }); // Mark path elements on the model diff --git a/app/src/components/code-graph.tsx b/app/src/components/code-graph.tsx index 9cc69d33..1666c3c2 100644 --- a/app/src/components/code-graph.tsx +++ b/app/src/components/code-graph.tsx @@ -40,8 +40,6 @@ interface Props { handleSearchSubmit: (node: any) => void searchNode: any setSearchNode: Dispatch> - cooldownTicks: number | undefined - setCooldownTicks: Dispatch> animation: boolean setAnimation: (animation: boolean) => void manualDimmed: boolean @@ -74,8 +72,6 @@ export function CodeGraph({ handleSearchSubmit, searchNode, setSearchNode, - cooldownTicks, - setCooldownTicks, animation, setAnimation, manualDimmed, diff --git a/app/src/components/elementMenu.tsx b/app/src/components/elementMenu.tsx index 4d5ca618..b532e7a6 100644 --- a/app/src/components/elementMenu.tsx +++ b/app/src/components/elementMenu.tsx @@ -50,7 +50,7 @@ export default function ElementMenu({ obj, objects, setPath, handleRemove, posit // X axis: center on click, clamp to container edges const rawLeft = relX - containerWidth / 2 - const left = Math.max(EDGE_MARGIN, Math.min(rawLeft, parentW - containerWidth - EDGE_MARGIN)) + const left = Math.max(edgeMargin, Math.min(rawLeft, parentW - containerWidth - edgeMargin)) // Y axis: always place below at (node_center + node_radius + fixed_gap). // Clamp to canvas edges — no flipping. If the node is larger than the canvas diff --git a/app/src/components/graphView.tsx b/app/src/components/graphView.tsx index bb37f00d..8082bebb 100644 --- a/app/src/components/graphView.tsx +++ b/app/src/components/graphView.tsx @@ -125,7 +125,7 @@ export default function GraphView({ isCenteringRef.current = true canvasRef.current.centerAt(cx, cy, 300) // Show menu after animation fully settles (400ms > 300ms animation) - const timeoutId = setTimeout(() => { + setTimeout(() => { isCenteringRef.current = false const canvasBounds = canvasRef.current?.getBoundingClientRect() if (canvasBounds) { @@ -137,6 +137,7 @@ export default function GraphView({ } }, 400) return + } } // Focus mode OFF: use the node's actual screen-center so the gap in // elementMenu is always the same fixed value regardless of where on the From da01b496fa946169ab1d93aca42d22c05a2af23a Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Wed, 8 Jul 2026 14:36:49 +0300 Subject: [PATCH 06/16] Fix remaining PR #697 code quality issues - Add aria-labels to animation, dimming, and pin toggle switches for accessibility - Add setData() calls after graph mutations in handleExpand and handleRemove to keep React state synced - Remove unused dimmed dependency from ForceGraph setConfig effect - All changes maintain existing code style and pass TypeScript compilation --- app/src/components/ForceGraph.tsx | 1 - app/src/components/code-graph.tsx | 4 ++++ app/src/components/graphView.tsx | 4 ++-- app/src/components/toolbar.tsx | 3 +++ 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/app/src/components/ForceGraph.tsx b/app/src/components/ForceGraph.tsx index dd915a00..71819a68 100644 --- a/app/src/components/ForceGraph.tsx +++ b/app/src/components/ForceGraph.tsx @@ -187,7 +187,6 @@ export default function ForceGraph({ isLinkSelected, isNodeDimmed, isLinkDimmed, - dimmed, onBackgroundClick, onBackgroundRightClick, handleEngineStop, diff --git a/app/src/components/code-graph.tsx b/app/src/components/code-graph.tsx index 1666c3c2..d30d492a 100644 --- a/app/src/components/code-graph.tsx +++ b/app/src/components/code-graph.tsx @@ -243,6 +243,7 @@ export function CodeGraph({ // Update the model with new elements (graph.Elements is already updated by onFetchNode) // Convert the full model to canvas data and update canvasRef.current?.setGraphData(convertToCanvasData(graph.Elements)) + setData({ ...graph.Elements }) } else { const deleteNodes = nodes.filter(n => n.expand) if (deleteNodes.length > 0) { @@ -256,6 +257,7 @@ export function CodeGraph({ // Convert the updated model to canvas data canvasRef.current?.setGraphData(convertToCanvasData(graph.Elements)) + setData({ ...graph.Elements }) } setSelectedObjects([]) @@ -289,6 +291,8 @@ export function CodeGraph({ setHasHiddenElements(true) + setData({ ...graph.Elements }) + setSelectedObjects([]) } diff --git a/app/src/components/graphView.tsx b/app/src/components/graphView.tsx index 8082bebb..f49fc28b 100644 --- a/app/src/components/graphView.tsx +++ b/app/src/components/graphView.tsx @@ -176,7 +176,7 @@ export default function GraphView({ canvasRef.current.zoomToFit(2, (n: GraphNode) => selectedPathNodeIds.has(n.id)) } } - }, [selectedPathId, isPathResponse]) + }, [selectedPathId, isPathResponse, canvasRef]) const handleNodeHover = useCallback((node: Node | null) => { setHoverElement(node) @@ -345,7 +345,7 @@ export default function GraphView({ onNodeHover={handleNodeHover} onNodeRightClick={handleRightClick} isNodeSelected={isNodeSelected} - onLinkClick={isDesktop && isPathResponse ? handleLinkClick : handleRightClick} + onLinkClick={isDesktop ? handleLinkClick : handleRightClick} onLinkHover={handleLinkHover} onLinkRightClick={handleRightClick} isLinkSelected={isLinkSelected} diff --git a/app/src/components/toolbar.tsx b/app/src/components/toolbar.tsx index 23abfe51..d95d53f2 100644 --- a/app/src/components/toolbar.tsx +++ b/app/src/components/toolbar.tsx @@ -199,17 +199,20 @@ export function Toolbar({ canvasRef, className, handleDownloadImage, animation, checked={animation} disabled={animationDisabled} onCheckedChange={handleAnimationToggle} + aria-label={animation ? "Pause animation" : "Play animation"} /> From b86d5f966868b00b547b76b28661dedfe2c66fea Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Wed, 8 Jul 2026 14:40:46 +0300 Subject: [PATCH 07/16] Improve path zoom handling and remove unnecessary state updates - Move path zoom logic from graphView useEffect to chat.tsx for better stability - Remove unnecessary setData() calls - setGraphData() is sufficient for canvas updates - Cap zoom multiplier for path links (1.3x) since they take less visual space than nodes - All zoom handling now happens in chat.tsx where path responses originate --- app/src/components/chat.tsx | 5 ++++- app/src/components/code-graph.tsx | 4 ---- app/src/components/graphView.tsx | 14 -------------- 3 files changed, 4 insertions(+), 19 deletions(-) diff --git a/app/src/components/chat.tsx b/app/src/components/chat.tsx index 80dce2d3..7c4932f6 100644 --- a/app/src/components/chat.tsx +++ b/app/src/components/chat.tsx @@ -365,7 +365,10 @@ export function Chat({ messages, setMessages, query, setQuery, selectedPath, set setTimeout(() => { const nodesMap = new Map(formattedPaths.flatMap(p => p.nodes.map((n: Node) => [n.id, n]))) - canvas.zoomToFit(2, (n: GraphNode) => formattedPaths.some(p => nodesMap.has(n.id))); + // Cap zoom multiplier for links (1.3x) since links take less visual space than nodes (2x) + const hasLinks = formattedPaths.some(p => p.links.length > 0) + const multiplier = hasLinks ? 1.3 : 2 + canvas.zoomToFit(multiplier, (n: GraphNode) => formattedPaths.some(p => nodesMap.has(n.id))); }, 0) } diff --git a/app/src/components/code-graph.tsx b/app/src/components/code-graph.tsx index d30d492a..1666c3c2 100644 --- a/app/src/components/code-graph.tsx +++ b/app/src/components/code-graph.tsx @@ -243,7 +243,6 @@ export function CodeGraph({ // Update the model with new elements (graph.Elements is already updated by onFetchNode) // Convert the full model to canvas data and update canvasRef.current?.setGraphData(convertToCanvasData(graph.Elements)) - setData({ ...graph.Elements }) } else { const deleteNodes = nodes.filter(n => n.expand) if (deleteNodes.length > 0) { @@ -257,7 +256,6 @@ export function CodeGraph({ // Convert the updated model to canvas data canvasRef.current?.setGraphData(convertToCanvasData(graph.Elements)) - setData({ ...graph.Elements }) } setSelectedObjects([]) @@ -291,8 +289,6 @@ export function CodeGraph({ setHasHiddenElements(true) - setData({ ...graph.Elements }) - setSelectedObjects([]) } diff --git a/app/src/components/graphView.tsx b/app/src/components/graphView.tsx index f49fc28b..886987d5 100644 --- a/app/src/components/graphView.tsx +++ b/app/src/components/graphView.tsx @@ -164,20 +164,6 @@ export default function GraphView({ setSelectedPathId(link.id) } - // Zoom to fit selected path nodes when selectedPathId changes - useEffect(() => { - if (selectedPathId && canvasRef.current && isPathResponse) { - const graphData = canvasRef.current.getGraphData() - const selectedPathNodeIds = new Set(graphData?.nodes - .filter(n => n.data?.isPathSelected) - .map(n => n.id) ?? []) - - if (selectedPathNodeIds.size > 0) { - canvasRef.current.zoomToFit(2, (n: GraphNode) => selectedPathNodeIds.has(n.id)) - } - } - }, [selectedPathId, isPathResponse, canvasRef]) - const handleNodeHover = useCallback((node: Node | null) => { setHoverElement(node) }, []) From bcf5e7ad47cf6d4f611e2095dac08cf353c2254a Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Wed, 8 Jul 2026 14:42:42 +0300 Subject: [PATCH 08/16] Reduce link zoom multiplier to 1.1x for better spacing --- app/src/components/chat.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/components/chat.tsx b/app/src/components/chat.tsx index 7c4932f6..a8de3864 100644 --- a/app/src/components/chat.tsx +++ b/app/src/components/chat.tsx @@ -365,9 +365,9 @@ export function Chat({ messages, setMessages, query, setQuery, selectedPath, set setTimeout(() => { const nodesMap = new Map(formattedPaths.flatMap(p => p.nodes.map((n: Node) => [n.id, n]))) - // Cap zoom multiplier for links (1.3x) since links take less visual space than nodes (2x) + // Cap zoom multiplier for links (1.1x) since links take very little visual space const hasLinks = formattedPaths.some(p => p.links.length > 0) - const multiplier = hasLinks ? 1.3 : 2 + const multiplier = hasLinks ? 1.1 : 2 canvas.zoomToFit(multiplier, (n: GraphNode) => formattedPaths.some(p => nodesMap.has(n.id))); }, 0) } From bf30fd76e80605f1b71837d75fd6ce234069c7d0 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Wed, 8 Jul 2026 14:45:31 +0300 Subject: [PATCH 09/16] Remove automatic path zoom - just highlight paths without forcing zoom --- app/src/components/chat.tsx | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/app/src/components/chat.tsx b/app/src/components/chat.tsx index a8de3864..fd19c981 100644 --- a/app/src/components/chat.tsx +++ b/app/src/components/chat.tsx @@ -356,20 +356,6 @@ export function Chat({ messages, setMessages, query, setQuery, selectedPath, set // Update the canvas from the model canvasRef.current?.setGraphData(convertToCanvasData(graph.Elements)) - - // Zoom to fit all path nodes after coloring - const pathNodeIds = new Set(formattedPaths.flatMap(p => p.nodes).map(n => n.id)) - if (pathNodeIds.size > 0 && canvasRef.current) { - canvasRef.current.zoomToFit(1, (n: any) => pathNodeIds.has(n.id)) - } - - setTimeout(() => { - const nodesMap = new Map(formattedPaths.flatMap(p => p.nodes.map((n: Node) => [n.id, n]))) - // Cap zoom multiplier for links (1.1x) since links take very little visual space - const hasLinks = formattedPaths.some(p => p.links.length > 0) - const multiplier = hasLinks ? 1.1 : 2 - canvas.zoomToFit(multiplier, (n: GraphNode) => formattedPaths.some(p => nodesMap.has(n.id))); - }, 0) } const getTip = (className?: string) => From d64403ea415376b0a614f4ed998d4ed05a2bf955 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Wed, 8 Jul 2026 14:55:27 +0300 Subject: [PATCH 10/16] fix: improve path zoom behavior - zoom all paths on response, then zoom selected path - Remove default path selection on response - Add zoom to fit all paths (multiplier 1.2) when paths arrive - Add zoom to fit selected path nodes (multiplier 1.5) when path is clicked - Use setTimeout to ensure canvas is ready before zooming --- app/src/components/chat.tsx | 46 ++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/app/src/components/chat.tsx b/app/src/components/chat.tsx index fd19c981..8c38243b 100644 --- a/app/src/components/chat.tsx +++ b/app/src/components/chat.tsx @@ -110,6 +110,44 @@ export function Chat({ messages, setMessages, query, setQuery, selectedPath, set setPaths([]) }, [isPathResponse]) + // Update selected path highlighting when selectedPathId changes + useEffect(() => { + if (!selectedPathId || !isPathResponse || paths.length === 0) return + + const canvas = canvasRef.current + if (!canvas) return + + // Find which path contains the selected link + const selectedPath = paths.find(p => p.links.some(l => l.id === selectedPathId)) + if (!selectedPath) return + + // Clear all path selections first + graph.Elements.nodes.forEach((node: any) => { + if (node.isPathSelected) node.isPathSelected = false + }) + graph.Elements.links.forEach((link: any) => { + if (link.isPathSelected) link.isPathSelected = false + }) + + // Mark selected path elements + selectedPath.nodes.forEach(n => { + const node = graph.Elements.nodes.find(gn => gn.id === n.id) + if (node) node.isPathSelected = true + }) + selectedPath.links.forEach(l => { + const link = graph.Elements.links.find(gl => gl.id === l.id) + if (link) link.isPathSelected = true + }) + + canvas.setGraphData(convertToCanvasData(graph.Elements)) + + // Zoom to fit selected path nodes + const selectedNodeIds = new Set(selectedPath.nodes.map((n: Node) => n.id)) + setTimeout(() => { + canvas.zoomToFit(1.5, (n: GraphNode) => selectedNodeIds.has(n.id)) + }, 100) + }, [selectedPathId, isPathResponse, paths]) + const handleSetSelectedPath = (p: PathData) => { const canvas = canvasRef.current @@ -355,7 +393,13 @@ export function Chat({ messages, setMessages, query, setQuery, selectedPath, set }); // Update the canvas from the model - canvasRef.current?.setGraphData(convertToCanvasData(graph.Elements)) + const pathCanvas = canvasRef.current + pathCanvas?.setGraphData(convertToCanvasData(graph.Elements)) + + // Zoom to fit all paths + setTimeout(() => { + pathCanvas?.zoomToFit(1.2) + }, 100) } const getTip = (className?: string) => From babe0ba1b519f8abf49d24e31e9237bdf02cb87a Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Wed, 8 Jul 2026 16:48:08 +0300 Subject: [PATCH 11/16] fix: add graphId to ForceGraph for incremental data updates - Use canvas.setData() only when graph identity changes (full reset) - Use canvas.setGraphData() for same-graph data updates (preserves positions) - Pass graphId from graph.Id through graphView to ForceGraph - Prevents unnecessary canvas remounting when data changes - Fixes z-index for element menu (z-[15] above toolbar, below data panel) --- app/package-lock.json | 189 +++++------------------------ app/package.json | 2 +- app/src/components/ForceGraph.tsx | 19 ++- app/src/components/chat.tsx | 9 +- app/src/components/elementMenu.tsx | 2 +- app/src/components/graphView.tsx | 2 + 6 files changed, 53 insertions(+), 170 deletions(-) diff --git a/app/package-lock.json b/app/package-lock.json index 4c55da26..7f8a9b78 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -8,7 +8,7 @@ "name": "code-graph", "version": "0.3.0", "dependencies": { - "@falkordb/canvas": "v0.2.2", + "@falkordb/canvas": "file:../../falkordb-canvas", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", @@ -52,6 +52,31 @@ "vite": "^6.3.5" } }, + "../../falkordb-canvas": { + "name": "@falkordb/canvas", + "version": "0.0.0-dev", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "force-graph": "^1.44.4", + "react": "^19.2.3" + }, + "devDependencies": { + "@types/d3": "^7.4.3", + "@types/react": "^19.2.7", + "@typescript-eslint/eslint-plugin": "^8.18.2", + "@typescript-eslint/parser": "^8.18.2", + "eslint": "^10.0.2", + "jsdom": "^29.0.2", + "promise-retry": "^2.0.1", + "tsup": "^8.5.1", + "typescript": "^6.0.3", + "vitest": "^4.1.4" + } + }, + "../falkordb-canvas": { + "extraneous": true + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -516,24 +541,8 @@ } }, "node_modules/@falkordb/canvas": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@falkordb/canvas/-/canvas-0.2.2.tgz", - "integrity": "sha512-oGo4/tXrLqUzzf39wjpcZ2lfoSo0InTsHqORGBpLVyQtgyNRSLgNdgKUrAbO3vjVnjbfNFzKIwHrUJFf2R6mHA==", - "license": "MIT", - "dependencies": { - "d3": "^7.9.0", - "force-graph": "^1.44.4", - "react": "^19.2.3" - } - }, - "node_modules/@falkordb/canvas/node_modules/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", - "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } + "resolved": "../../falkordb-canvas", + "link": true }, "node_modules/@floating-ui/core": { "version": "1.7.5", @@ -2302,12 +2311,6 @@ "@swc/counter": "^0.1.3" } }, - "node_modules/@tweenjs/tween.js": { - "version": "25.0.0", - "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", - "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==", - "license": "MIT" - }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2403,15 +2406,6 @@ "vite": "^4 || ^5 || ^6 || ^7" } }, - "node_modules/accessor-fn": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz", - "integrity": "sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==", - "license": "MIT", - "engines": { - "node": ">=12" - } - }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -2508,16 +2502,6 @@ "node": ">=6.0.0" } }, - "node_modules/bezier-js": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/bezier-js/-/bezier-js-6.1.4.tgz", - "integrity": "sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==", - "license": "MIT", - "funding": { - "type": "individual", - "url": "https://github.com/Pomax/bezierjs/blob/master/FUNDING.md" - } - }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -2606,18 +2590,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/canvas-color-tracker": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/canvas-color-tracker/-/canvas-color-tracker-1.3.2.tgz", - "integrity": "sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg==", - "license": "MIT", - "dependencies": { - "tinycolor2": "^1.6.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/canvas2svg": { "version": "1.0.16", "resolved": "https://registry.npmjs.org/canvas2svg/-/canvas2svg-1.0.16.tgz", @@ -2860,12 +2832,6 @@ "node": ">=12" } }, - "node_modules/d3-binarytree": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz", - "integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==", - "license": "MIT" - }, "node_modules/d3-brush": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", @@ -3009,22 +2975,6 @@ "node": ">=12" } }, - "node_modules/d3-force-3d": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz", - "integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==", - "license": "MIT", - "dependencies": { - "d3-binarytree": "1", - "d3-dispatch": "1 - 3", - "d3-octree": "1", - "d3-quadtree": "1 - 3", - "d3-timer": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/d3-format": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", @@ -3067,12 +3017,6 @@ "node": ">=12" } }, - "node_modules/d3-octree": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz", - "integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==", - "license": "MIT" - }, "node_modules/d3-path": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", @@ -3403,46 +3347,6 @@ "node": ">=8" } }, - "node_modules/float-tooltip": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/float-tooltip/-/float-tooltip-1.7.5.tgz", - "integrity": "sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==", - "license": "MIT", - "dependencies": { - "d3-selection": "2 - 3", - "kapsule": "^1.16", - "preact": "10" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/force-graph": { - "version": "1.51.4", - "resolved": "https://registry.npmjs.org/force-graph/-/force-graph-1.51.4.tgz", - "integrity": "sha512-TdJ2KbkoiDQ7NIRx8IPGD0mAXXpLhamS7c+b7W98b0MHG7lphnda1VOQX/98UDTsttIAdH4TcP0l0MauSnLK8w==", - "license": "MIT", - "dependencies": { - "@tweenjs/tween.js": "18 - 25", - "accessor-fn": "1", - "bezier-js": "3 - 6", - "canvas-color-tracker": "^1.3", - "d3-array": "1 - 3", - "d3-drag": "2 - 3", - "d3-force-3d": "2 - 3", - "d3-scale": "1 - 4", - "d3-scale-chromatic": "1 - 3", - "d3-selection": "2 - 3", - "d3-zoom": "2 - 3", - "float-tooltip": "^1.7", - "index-array-by": "1", - "kapsule": "^1.16", - "lodash-es": "4" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/format": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", @@ -3588,15 +3492,6 @@ "node": ">=0.10.0" } }, - "node_modules/index-array-by": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.2.tgz", - "integrity": "sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==", - "license": "MIT", - "engines": { - "node": ">=12" - } - }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -3728,18 +3623,6 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, - "node_modules/kapsule": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/kapsule/-/kapsule-1.16.3.tgz", - "integrity": "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==", - "license": "MIT", - "dependencies": { - "lodash-es": "4" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -4100,16 +3983,6 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, - "node_modules/preact": { - "version": "10.29.5", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.5.tgz", - "integrity": "sha512-zIai7HLEIz9c4GNfqpiuu5K9I1szXNtx0Ykr8f1Vbm1NasSVIvXGRxc8R8alLC+G+zonAt0TiUUBMn4+CiTgaA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, "node_modules/prismjs": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", @@ -4653,12 +4526,6 @@ "node": ">=0.8" } }, - "node_modules/tinycolor2": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", - "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", - "license": "MIT" - }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", diff --git a/app/package.json b/app/package.json index 6a5e1fe4..0d7c2e83 100644 --- a/app/package.json +++ b/app/package.json @@ -11,7 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "@falkordb/canvas": "v0.2.2", + "@falkordb/canvas": "file:../../falkordb-canvas", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", diff --git a/app/src/components/ForceGraph.tsx b/app/src/components/ForceGraph.tsx index 71819a68..41549f86 100644 --- a/app/src/components/ForceGraph.tsx +++ b/app/src/components/ForceGraph.tsx @@ -1,11 +1,12 @@ -import { useCallback, useEffect, useState } from "react" +import { useCallback, useEffect, useRef, useState } from "react" import type { Data, GraphLink, GraphNode } from "@falkordb/canvas" import { GraphRef, PATH_COLOR } from "@/lib/utils" import { GraphData, Link, Node } from "./model" interface Props { id: "desktop" | "mobile" + graphId: string data: GraphData canvasRef: GraphRef onNodeClick: (node: Node, event: MouseEvent) => void @@ -53,6 +54,7 @@ export const convertToCanvasData = (graphData: GraphData): Data => ({ export default function ForceGraph({ id, + graphId, data, canvasRef, onNodeClick, @@ -203,14 +205,25 @@ export default function ForceGraph({ canvasRef.current.setDimmed(dimmed) }, [dimmed, canvasRef, canvasLoaded]) + // Track the last graphId that triggered a full setData + const lastGraphIdRef = useRef(undefined) + // Update canvas data useEffect(() => { const canvas = canvasRef.current if (!canvas || !canvasLoaded) return const canvasData = convertToCanvasData(data) - canvas.setData(canvasData) - }, [canvasRef, data, canvasLoaded]) + + if (graphId !== lastGraphIdRef.current) { + // New graph identity: full replacement (resets positions, runs simulation) + lastGraphIdRef.current = graphId + canvas.setData(canvasData) + } else { + // Same graph: incremental update (preserves node positions) + canvas.setGraphData(canvasData) + } + }, [canvasRef, graphId, data, canvasLoaded]) return ( diff --git a/app/src/components/chat.tsx b/app/src/components/chat.tsx index 8c38243b..e1409fbb 100644 --- a/app/src/components/chat.tsx +++ b/app/src/components/chat.tsx @@ -144,7 +144,7 @@ export function Chat({ messages, setMessages, query, setQuery, selectedPath, set // Zoom to fit selected path nodes const selectedNodeIds = new Set(selectedPath.nodes.map((n: Node) => n.id)) setTimeout(() => { - canvas.zoomToFit(1.5, (n: GraphNode) => selectedNodeIds.has(n.id)) + canvas.zoomToFit(1, (n: GraphNode) => selectedNodeIds.has(n.id)) }, 100) }, [selectedPathId, isPathResponse, paths]) @@ -222,7 +222,7 @@ export function Chat({ messages, setMessages, query, setQuery, selectedPath, set canvas.setGraphData(convertToCanvasData(graph.Elements)) setTimeout(() => { - canvas.zoomToFit(2, (n: GraphNode) => pNodeIds.has(n.id)); + canvas.zoomToFit(1, (n: GraphNode) => pNodeIds.has(n.id)); }, 300) setChatOpen && setChatOpen(false) } @@ -396,9 +396,10 @@ export function Chat({ messages, setMessages, query, setQuery, selectedPath, set const pathCanvas = canvasRef.current pathCanvas?.setGraphData(convertToCanvasData(graph.Elements)) - // Zoom to fit all paths + // Zoom to fit all path nodes (same as selected path zoom but for all paths) + const allPathNodeIds = new Set(formattedPaths.flatMap(p => p.nodes).map((n: Node) => n.id)) setTimeout(() => { - pathCanvas?.zoomToFit(1.2) + pathCanvas?.zoomToFit(1, (n: GraphNode) => allPathNodeIds.has(n.id)) }, 100) } diff --git a/app/src/components/elementMenu.tsx b/app/src/components/elementMenu.tsx index b532e7a6..fdc57c16 100644 --- a/app/src/components/elementMenu.tsx +++ b/app/src/components/elementMenu.tsx @@ -66,7 +66,7 @@ export default function ElementMenu({ obj, objects, setPath, handleRemove, posit setContainerWidth(ref.clientWidth) setContainerHeight(ref.clientHeight) }} - className="absolute z-10 bg-popover text-popover-foreground rounded-lg shadow-lg flex divide-x divide-border" + className="absolute z-[15] bg-popover text-popover-foreground rounded-lg shadow-lg flex divide-x divide-border" id="elementMenu" style={{ left, top }} > diff --git a/app/src/components/graphView.tsx b/app/src/components/graphView.tsx index 886987d5..951b2f4c 100644 --- a/app/src/components/graphView.tsx +++ b/app/src/components/graphView.tsx @@ -43,6 +43,7 @@ const DOUBLE_CLICK_MS = 300; export default function GraphView({ data, + graph, chartRef: canvasRef, id, selectedObjects, @@ -325,6 +326,7 @@ export default function GraphView({
handleNodeClick(node) : (node: Node, evt: MouseEvent) => handleRightClick(node, evt)} From 6745207577f0db616406fcf22cbe62cb47d5e0f3 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Thu, 9 Jul 2026 12:21:52 +0300 Subject: [PATCH 12/16] fix: update @falkordb/canvas dependency to version 0.2.5 --- app/package-lock.json | 194 ++++++++++++++++++++++++++++++++++++------ app/package.json | 2 +- 2 files changed, 170 insertions(+), 26 deletions(-) diff --git a/app/package-lock.json b/app/package-lock.json index 7f8a9b78..29ebc16e 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -8,7 +8,7 @@ "name": "code-graph", "version": "0.3.0", "dependencies": { - "@falkordb/canvas": "file:../../falkordb-canvas", + "@falkordb/canvas": "^0.2.5", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", @@ -52,28 +52,6 @@ "vite": "^6.3.5" } }, - "../../falkordb-canvas": { - "name": "@falkordb/canvas", - "version": "0.0.0-dev", - "license": "MIT", - "dependencies": { - "d3": "^7.9.0", - "force-graph": "^1.44.4", - "react": "^19.2.3" - }, - "devDependencies": { - "@types/d3": "^7.4.3", - "@types/react": "^19.2.7", - "@typescript-eslint/eslint-plugin": "^8.18.2", - "@typescript-eslint/parser": "^8.18.2", - "eslint": "^10.0.2", - "jsdom": "^29.0.2", - "promise-retry": "^2.0.1", - "tsup": "^8.5.1", - "typescript": "^6.0.3", - "vitest": "^4.1.4" - } - }, "../falkordb-canvas": { "extraneous": true }, @@ -541,8 +519,24 @@ } }, "node_modules/@falkordb/canvas": { - "resolved": "../../falkordb-canvas", - "link": true + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@falkordb/canvas/-/canvas-0.2.5.tgz", + "integrity": "sha512-BSVDHYC9WeuGF9iPUkGmh11KgyV3whVAjmLWDaai29GKXyJrG49LPiwxRAaKY2bA9ha7bU8ZWMJ77EqfUPXhmA==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "force-graph": "^1.44.4", + "react": "^19.2.3" + } + }, + "node_modules/@falkordb/canvas/node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } }, "node_modules/@floating-ui/core": { "version": "1.7.5", @@ -2311,6 +2305,12 @@ "@swc/counter": "^0.1.3" } }, + "node_modules/@tweenjs/tween.js": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", + "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2406,6 +2406,15 @@ "vite": "^4 || ^5 || ^6 || ^7" } }, + "node_modules/accessor-fn": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz", + "integrity": "sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -2502,6 +2511,16 @@ "node": ">=6.0.0" } }, + "node_modules/bezier-js": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/bezier-js/-/bezier-js-6.1.4.tgz", + "integrity": "sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==", + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://github.com/Pomax/bezierjs/blob/master/FUNDING.md" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -2590,6 +2609,18 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas-color-tracker": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/canvas-color-tracker/-/canvas-color-tracker-1.3.2.tgz", + "integrity": "sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg==", + "license": "MIT", + "dependencies": { + "tinycolor2": "^1.6.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/canvas2svg": { "version": "1.0.16", "resolved": "https://registry.npmjs.org/canvas2svg/-/canvas2svg-1.0.16.tgz", @@ -2832,6 +2863,12 @@ "node": ">=12" } }, + "node_modules/d3-binarytree": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz", + "integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==", + "license": "MIT" + }, "node_modules/d3-brush": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", @@ -2975,6 +3012,22 @@ "node": ">=12" } }, + "node_modules/d3-force-3d": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz", + "integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==", + "license": "MIT", + "dependencies": { + "d3-binarytree": "1", + "d3-dispatch": "1 - 3", + "d3-octree": "1", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-format": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", @@ -3017,6 +3070,12 @@ "node": ">=12" } }, + "node_modules/d3-octree": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz", + "integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==", + "license": "MIT" + }, "node_modules/d3-path": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", @@ -3347,6 +3406,46 @@ "node": ">=8" } }, + "node_modules/float-tooltip": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/float-tooltip/-/float-tooltip-1.7.5.tgz", + "integrity": "sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==", + "license": "MIT", + "dependencies": { + "d3-selection": "2 - 3", + "kapsule": "^1.16", + "preact": "10" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/force-graph": { + "version": "1.51.4", + "resolved": "https://registry.npmjs.org/force-graph/-/force-graph-1.51.4.tgz", + "integrity": "sha512-TdJ2KbkoiDQ7NIRx8IPGD0mAXXpLhamS7c+b7W98b0MHG7lphnda1VOQX/98UDTsttIAdH4TcP0l0MauSnLK8w==", + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "18 - 25", + "accessor-fn": "1", + "bezier-js": "3 - 6", + "canvas-color-tracker": "^1.3", + "d3-array": "1 - 3", + "d3-drag": "2 - 3", + "d3-force-3d": "2 - 3", + "d3-scale": "1 - 4", + "d3-scale-chromatic": "1 - 3", + "d3-selection": "2 - 3", + "d3-zoom": "2 - 3", + "float-tooltip": "^1.7", + "index-array-by": "1", + "kapsule": "^1.16", + "lodash-es": "4" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/format": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", @@ -3492,6 +3591,15 @@ "node": ">=0.10.0" } }, + "node_modules/index-array-by": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.2.tgz", + "integrity": "sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -3623,6 +3731,18 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/kapsule": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/kapsule/-/kapsule-1.16.3.tgz", + "integrity": "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==", + "license": "MIT", + "dependencies": { + "lodash-es": "4" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -3983,6 +4103,24 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/preact": { + "version": "10.29.7", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.7.tgz", + "integrity": "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } + } + }, "node_modules/prismjs": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", @@ -4526,6 +4664,12 @@ "node": ">=0.8" } }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", diff --git a/app/package.json b/app/package.json index 0d7c2e83..8a6fd379 100644 --- a/app/package.json +++ b/app/package.json @@ -11,7 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "@falkordb/canvas": "file:../../falkordb-canvas", + "@falkordb/canvas": "^0.2.5", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", From 4807a2963c000b9f3da38d3110de4a398c1880f1 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Thu, 9 Jul 2026 13:14:22 +0300 Subject: [PATCH 13/16] fix: improve element menu removal test for deterministic assertions --- e2e/tests/elementMenu.spec.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/e2e/tests/elementMenu.spec.ts b/e2e/tests/elementMenu.spec.ts index 0a8f67e8..37452e53 100644 --- a/e2e/tests/elementMenu.spec.ts +++ b/e2e/tests/elementMenu.spec.ts @@ -78,19 +78,21 @@ test.describe("Element Menu and Right-click Menu tests", () => { const codeGraph = await browser.createNewPage(CodeGraph, urls.baseUrl); await browser.setPageToFullScreen(); await codeGraph.selectGraph(GRAPHRAG_SDK); - - // Get initial node count + + // Remove a specific node to keep the assertion deterministic. const initialGraphData = await codeGraph.getGraphNodes(); - const initialNodeCount = initialGraphData.length; - - // Right-click at canvas center and remove - await codeGraph.rightClickAtCanvasCenter(); + const targetNode = findNodeByName(initialGraphData, nodes[0].nodeName); + expect(targetNode).toBeDefined(); + + await codeGraph.rightClickAtNode(targetNode.screenX, targetNode.screenY); await codeGraph.clickOnRemoveNodeViaElementMenu(); - - // Wait for animation and verify node count decreased + + // Hidden nodes remain in graph data; verify visibility rather than length. await codeGraph.waitForCanvasAnimationToEnd(); const updatedGraphData = await codeGraph.getGraphNodes(); - expect(updatedGraphData.length).toBeLessThan(initialNodeCount); + const updatedTargetNode = findNodeByName(updatedGraphData, nodes[0].nodeName); + expect(updatedTargetNode).toBeDefined(); + expect(updatedTargetNode.visible).toBe(false); }); test("Verify element menu positioning respects canvas boundaries", async () => { From 0bb7392fe3738640f179f872d0168804b4eff893 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Thu, 9 Jul 2026 13:56:07 +0300 Subject: [PATCH 14/16] fix: update API proxy target to local development server --- app/vite.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/vite.config.ts b/app/vite.config.ts index 7ed134cd..791b8dd7 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ port: 3000, proxy: { '/api': { - target: 'https://code-graph.falkordb.com', + target: 'http://127.0.0.1:5000', changeOrigin: true, secure: false, }, From d063625f686b018b539207edd53c61c5df715d8e Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Thu, 9 Jul 2026 17:44:07 +0300 Subject: [PATCH 15/16] fix: set viewport in browser context to ensure desktop layout on page load The issue was that the page was loading with a default small viewport before setPageToFullScreen() was called. This caused the CSS media query for md:flex to not match, so the #desktop container was hidden or not rendered properly. By setting the viewport in the BrowserContext options (1920x1080), every page created will have the correct viewport from the start, before any page load happens. This ensures the CSS media queries match correctly and the #desktop container renders with the ComboBox button visible. Fixes: ComboBox button is not visible error in all Playwright E2E tests --- e2e/infra/ui/browserWrapper.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/e2e/infra/ui/browserWrapper.ts b/e2e/infra/ui/browserWrapper.ts index 77fa5d07..0108092d 100644 --- a/e2e/infra/ui/browserWrapper.ts +++ b/e2e/infra/ui/browserWrapper.ts @@ -15,6 +15,7 @@ export default class BrowserWrapper { } this.context = await this.browser.newContext({ permissions: ['clipboard-read', 'clipboard-write'], + viewport: { width: 1920, height: 1080 }, }); if (!this.page) { this.page = await this.context.newPage(); From e39f528fe827ad4c43f348c2c5d692e8cb70dd37 Mon Sep 17 00:00:00 2001 From: Anchel135 Date: Sun, 12 Jul 2026 16:42:35 +0300 Subject: [PATCH 16/16] fix: resolve combobox crash from repo objects and legacy graph name lookup - api/list_repos returns {project, branch, graph} dicts, but combobox.tsx cast the payload to string[] and rendered the object directly, crashing React (minified error #31) on every page load. This blocked virtually all e2e tests (111/112 failing on both CI shards). - Add a RepoOption type and repoLabel() helper; use option.graph as the select value and a friendly project/branch label for display. - Fix Graph/AsyncGraphQuery construction to prefer an existing legacy (pre-T17) bare-named graph over always recomposing to code:{project}:_default, which silently queried a different (empty) graph and broke node/edge counts and path lookups for legacy repos. - Add missing setData() calls after handleExpand/handleRemove/Unhide Nodes so React state stays in sync with canvas mutations (CodeRabbit). - Remove redundant double right-click in elementMenu.spec.ts (CodeRabbit nit). --- api/graph.py | 34 ++++++++++++++++++++++++++++--- app/src/App.tsx | 6 +++--- app/src/components/code-graph.tsx | 10 ++++++--- app/src/components/combobox.tsx | 11 +++++----- app/src/lib/utils.ts | 14 +++++++++++++ e2e/tests/elementMenu.spec.ts | 1 - 6 files changed, 61 insertions(+), 15 deletions(-) diff --git a/api/graph.py b/api/graph.py index ffa208fb..2b182b21 100644 --- a/api/graph.py +++ b/api/graph.py @@ -131,9 +131,17 @@ def __init__(self, name: str, branch: Optional[str] = None) -> None: # Normalize empty / None to DEFAULT_BRANCH so the stored # branch matches the key actually used by compose_graph_name. self.branch = branch or DEFAULT_BRANCH - self.name = compose_graph_name(self.project, self.branch) + self.db = create_falkordb() + # Legacy (pre-T17) graphs are stored under the bare project + # name. Prefer that existing graph over composing a new, + # empty ``code:{project}:{branch}`` key out from under it. + if branch in (None, "", DEFAULT_BRANCH) and name in self.db.list_graphs(): + self.name = name + else: + self.name = compose_graph_name(self.project, self.branch) - self.db = create_falkordb() + if not hasattr(self, "db"): + self.db = create_falkordb() self.g = self.db.select_graph(self.name) # Initialize the backlog as disabled by default @@ -832,12 +840,32 @@ def __init__(self, name: str, branch: Optional[str] = None) -> None: else: self.project = name self.branch = branch or DEFAULT_BRANCH - self.name = compose_graph_name(self.project, self.branch) + # Composition is deferred until ``graph_exists``/first query so + # we can prefer an existing legacy (pre-T17) bare graph over + # composing a new, empty ``code:{project}:{branch}`` key; see + # ``_resolve_name``. + self.name = name + self._needs_resolution = branch in (None, "", DEFAULT_BRANCH) self.db = _async_db() self.g = self.db.select_graph(self.name) + async def _resolve_name(self) -> None: + """Resolve a bare project name to its actual FalkorDB graph key. + + Prefers an existing legacy graph (stored under the bare project + name) over composing a new ``code:{project}:{branch}`` key. + """ + if not getattr(self, "_needs_resolution", False): + return + self._needs_resolution = False + graphs = await self.db.list_graphs() + if self.name not in graphs: + self.name = compose_graph_name(self.project, self.branch) + self.g = self.db.select_graph(self.name) + async def graph_exists(self) -> bool: """Check if this graph exists, reusing the current connection.""" + await self._resolve_name() graphs = await self.db.list_graphs() return self.name in graphs diff --git a/app/src/App.tsx b/app/src/App.tsx index 4a2e731a..252e81cd 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -13,7 +13,7 @@ import { Drawer, DrawerContent, DrawerDescription, DrawerTitle, DrawerTrigger } import Input from './components/Input'; import { Labels } from './components/labels'; import { Toolbar, ZoomControls } from './components/toolbar'; -import { cn, GraphRef, Message, Path, PathData, PathNode } from '@/lib/utils'; +import { cn, DEFAULT_BRANCH, GraphRef, Message, Path, PathData, PathNode, RepoOption } from '@/lib/utils'; import type { GraphNode } from '@falkordb/canvas'; import { convertToCanvasData } from './components/ForceGraph'; import { Toaster } from '@/components/ui/toaster'; @@ -72,7 +72,7 @@ export default function App() { const [createURL, setCreateURL] = useState("") const [createOpen, setCreateOpen] = useState(false) const [tipOpen, setTipOpen] = useState(false) - const [options, setOptions] = useState([]); + const [options, setOptions] = useState([]); const [path, setPath] = useState(); const [isSubmit, setIsSubmit] = useState(false); const desktopChartRef = useRef(null) @@ -142,7 +142,7 @@ export default function App() { const graphName = createURL.split('/').pop()! - setOptions(prev => [...prev, graphName]) + setOptions(prev => [...prev, { project: graphName, branch: DEFAULT_BRANCH, graph: graphName }]) setSelectedValue(graphName) setCreateURL("") setCreateOpen(false) diff --git a/app/src/components/code-graph.tsx b/app/src/components/code-graph.tsx index 1666c3c2..7c8c1fe1 100644 --- a/app/src/components/code-graph.tsx +++ b/app/src/components/code-graph.tsx @@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button"; import ElementMenu from "./elementMenu"; import Combobox from "./combobox"; import { toast } from '@/components/ui/use-toast'; -import { Path } from "@/lib/utils"; +import { Path, RepoOption } from "@/lib/utils"; import Input from './Input'; // import CommitList from './commitList'; import { Checkbox } from '@/components/ui/checkbox'; @@ -27,8 +27,8 @@ interface Props { setData: Dispatch>, onFetchGraph: (graphName: string) => Promise, onFetchNode: (nodeIds: number[]) => Promise, - options: string[] - setOptions: Dispatch> + options: RepoOption[] + setOptions: Dispatch> isShowPath: boolean setPath: Dispatch> canvasRef: GraphRef @@ -243,6 +243,7 @@ export function CodeGraph({ // Update the model with new elements (graph.Elements is already updated by onFetchNode) // Convert the full model to canvas data and update canvasRef.current?.setGraphData(convertToCanvasData(graph.Elements)) + setData({ ...graph.Elements }) } else { const deleteNodes = nodes.filter(n => n.expand) if (deleteNodes.length > 0) { @@ -256,6 +257,7 @@ export function CodeGraph({ // Convert the updated model to canvas data canvasRef.current?.setGraphData(convertToCanvasData(graph.Elements)) + setData({ ...graph.Elements }) } setSelectedObjects([]) @@ -287,6 +289,7 @@ export function CodeGraph({ }) canvas.refresh() + setData({ ...graph.Elements }) setHasHiddenElements(true) setSelectedObjects([]) @@ -378,6 +381,7 @@ export function CodeGraph({ canvasData.nodes.forEach((n: { visible: boolean }) => { n.visible = true; }); canvasData.links.forEach((l: { visible: boolean }) => { l.visible = true; }); canvas.refresh(); + setData({ ...graph.Elements }); setHasHiddenElements(false); }} > diff --git a/app/src/components/combobox.tsx b/app/src/components/combobox.tsx index ec1b500a..d3d64362 100644 --- a/app/src/components/combobox.tsx +++ b/app/src/components/combobox.tsx @@ -1,14 +1,15 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { toast } from "@/components/ui/use-toast"; import { useEffect, useState } from "react"; +import { RepoOption, repoLabel } from "@/lib/utils"; const AUTH_HEADERS: HeadersInit = import.meta.env.VITE_SECRET_TOKEN ? { 'Authorization': `Bearer ${import.meta.env.VITE_SECRET_TOKEN}` } : {}; interface Props { - options: string[] - setOptions: (options: string[]) => void + options: RepoOption[] + setOptions: (options: RepoOption[]) => void selectedValue: string onSelectedValue: (value: string) => Promise @@ -37,7 +38,7 @@ export default function Combobox({ options, setOptions, selectedValue, onSelecte } const json = await result.json() - setOptions(json.repositories as string[]) + setOptions(json.repositories as RepoOption[]) } useEffect(() => { @@ -65,8 +66,8 @@ export default function Combobox({ options, setOptions, selectedValue, onSelecte { options.length !== 0 && options.map((option) => ( - - {option} + + {repoLabel(option)} )) } diff --git a/app/src/lib/utils.ts b/app/src/lib/utils.ts index 1a8d6af3..59c5434d 100644 --- a/app/src/lib/utils.ts +++ b/app/src/lib/utils.ts @@ -8,6 +8,20 @@ export type PathData = { links: any[] } +export const DEFAULT_BRANCH = "_default" + +// A single entry returned by /api/list_repos: `graph` is the underlying +// FalkorDB graph name to query, `project`/`branch` are used for display. +export type RepoOption = { + project: string + branch: string + graph: string +} + +export function repoLabel(repo: RepoOption): string { + return repo.branch === DEFAULT_BRANCH ? repo.project : `${repo.project} (${repo.branch})` +} + export type PathNode = { id?: number name?: string diff --git a/e2e/tests/elementMenu.spec.ts b/e2e/tests/elementMenu.spec.ts index 37452e53..c6a89f76 100644 --- a/e2e/tests/elementMenu.spec.ts +++ b/e2e/tests/elementMenu.spec.ts @@ -36,7 +36,6 @@ test.describe("Element Menu and Right-click Menu tests", () => { expect(targetNode).toBeDefined(); // Right-click on the node to trigger element menu - await codeGraph.nodeClick(targetNode.screenX, targetNode.screenY); await codeGraph.rightClickAtNode(targetNode.screenX, targetNode.screenY); // Verify menu is visible