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[] }[];