diff --git a/app/package-lock.json b/app/package-lock.json index d22f1851..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": "^0.0.44", + "@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,6 +52,9 @@ "vite": "^6.3.5" } }, + "../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,9 +519,9 @@ } }, "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==", + "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", @@ -527,9 +530,9 @@ } }, "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==", + "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" @@ -2336,7 +2339,6 @@ "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2352,7 +2354,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 +2365,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2565,7 +2565,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3147,7 +3146,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 +3268,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", @@ -3424,9 +3421,9 @@ } }, "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==", + "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", @@ -3724,7 +3721,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" } @@ -3970,7 +3966,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -4109,13 +4104,21 @@ "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==", + "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": { @@ -4177,7 +4180,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 +4204,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 +4222,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 +4593,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", @@ -4707,7 +4708,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 +4868,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 +4961,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..8a6fd379 100644 --- a/app/package.json +++ b/app/package.json @@ -11,7 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "@falkordb/canvas": "^0.0.44", + "@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", diff --git a/app/src/App.tsx b/app/src/App.tsx index 156b1313..4a2e731a 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -12,9 +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 { convertToCanvasData } from './components/ForceGraph'; 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 [animation, setAnimation] = useState(false) + const [manualDimmed, setManualDimmed] = useState(true) 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,34 +225,10 @@ 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]) - 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(currentData) - + canvas.setGraphData(convertToCanvasData(graph.Elements)) setTimeout(() => { canvas.zoomToFit(4, (n: GraphNode) => n.id === chartNode!.id) @@ -265,22 +241,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(currentData) + canvas.setGraphData(convertToCanvasData(graph.Elements)) } setTimeout(() => { @@ -306,27 +267,18 @@ 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(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() - setCooldownTicks(cooldownTicks === undefined ? undefined : -1); setHasHiddenElements(graph.getElements().some(element => !element.visible)); } @@ -538,8 +490,10 @@ export default function App() { handleSearchSubmit={(node) => handleSearchSubmit(node, desktopChartRef)} searchNode={searchNode} setSearchNode={setSearchNode} - cooldownTicks={cooldownTicks} - setCooldownTicks={setCooldownTicks} + animation={animation} + setAnimation={setAnimation} + manualDimmed={manualDimmed} + setManualDimmed={setManualDimmed} onCategoryClick={(name, show) => onCategoryClick(name, show, desktopChartRef)} handleDownloadImage={handleDownloadImage} zoomedNodes={zoomedNodes} @@ -574,7 +528,6 @@ export default function App() { setIsPathResponse={setIsPathResponse} paths={paths} setPaths={setPaths} - setCooldownTicks={setCooldownTicks} /> @@ -665,8 +618,10 @@ export default function App() { handleSearchSubmit={(node) => handleSearchSubmit(node, mobileChartRef)} setSearchNode={setSearchNode} searchNode={searchNode} - cooldownTicks={cooldownTicks} - setCooldownTicks={setCooldownTicks} + animation={animation} + setAnimation={setAnimation} + manualDimmed={manualDimmed} + setManualDimmed={setManualDimmed} onCategoryClick={(name, show) => onCategoryClick(name, show, mobileChartRef)} handleDownloadImage={handleDownloadImage} zoomedNodes={zoomedNodes} @@ -705,7 +660,6 @@ export default function App() { setChatOpen={setChatOpen} paths={paths} setPaths={setPaths} - setCooldownTicks={setCooldownTicks} /> @@ -720,11 +674,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 e7a52e54..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 @@ -16,30 +17,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 - cooldownTicks: number | undefined + 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, @@ -49,6 +54,7 @@ const convertToCanvasData = (graphData: GraphData): Data => ({ export default function ForceGraph({ id, + graphId, data, canvasRef, onNodeClick, @@ -59,14 +65,15 @@ export default function ForceGraph({ onLinkHover, onLinkRightClick, isLinkSelected, + isNodeDimmed, + isLinkDimmed, + dimmed, + linkLineDash, onBackgroundClick, onBackgroundRightClick, onZoom, onEngineStop, - nodeCanvasObject, - nodePointerAreaPaint, - linkLineDash, - cooldownTicks, + onNodeDragEnd, backgroundColor = "#FFFFFF", foregroundColor = "#000000" }: Props) { @@ -82,10 +89,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(() => { @@ -94,13 +101,6 @@ export default function ForceGraph({ canvasRef.current.setForegroundColor(foregroundColor) }, [canvasRef, backgroundColor, foregroundColor, canvasLoaded]) - // Update cooldown ticks - useEffect(() => { - if (!canvasRef.current || !canvasLoaded) return - - canvasRef.current.setCooldownTicks(cooldownTicks === -1 ? undefined : cooldownTicks) - }, [canvasRef, cooldownTicks, canvasLoaded]) - // Map node click handler const handleNodeClick = useCallback((node: GraphNode, event: MouseEvent) => { const originalNode = data.nodes.find(n => n.id === node.id) @@ -113,9 +113,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]) @@ -137,9 +137,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]) @@ -158,23 +158,25 @@ 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, + captionsKeys: [["name", true], ["title", true]], isNodeSelected: isNodeSelected, - onLinkClick: handleLinkClick, - onLinkRightClick: handleLinkRightClick, - onLinkHover: handleLinkHover, isLinkSelected: isLinkSelected, - onBackgroundClick, - onBackgroundRightClick, - onEngineStop: handleEngineStop, - node: { nodeCanvasObject, nodePointerAreaPaint }, + isNodeDimmed: isNodeDimmed, + isLinkDimmed: isLinkDimmed, linkLineDash, - onZoom + eventHandlers: { + onNodeClick: handleNodeClick, + onNodeRightClick: handleNodeRightClick, + onNodeHover: handleNodeHover, + onLinkClick: handleLinkClick, + onLinkRightClick: handleLinkRightClick, + onLinkHover: handleLinkHover, + onBackgroundClick, + onBackgroundRightClick, + onEngineStop: handleEngineStop, + onNodeDragEnd, + onZoom + } }) }, [ handleNodeClick, @@ -185,25 +187,43 @@ export default function ForceGraph({ handleLinkHover, isNodeSelected, isLinkSelected, + isNodeDimmed, + isLinkDimmed, 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]) + + // 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 0ecc49c2..e1409fbb 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,10 @@ 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 { GraphNode } from "@falkordb/canvas"; +import { convertToCanvasData } from "./ForceGraph"; interface Props { repo: string @@ -29,7 +30,6 @@ interface Props { setQuery: Dispatch> selectedPath: PathData | undefined setSelectedPath: Dispatch> - setCooldownTicks: Dispatch> setChatOpen?: Dispatch> paths: PathData[] setPaths: Dispatch> @@ -48,29 +48,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); @@ -109,12 +110,50 @@ 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, (n: GraphNode) => selectedNodeIds.has(n.id)) + }, 100) + }, [selectedPathId, isPathResponse, paths]) + const handleSetSelectedPath = (p: PathData) => { const canvas = canvasRef.current 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,56 +219,11 @@ 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(currentData) + canvas.setGraphData(convertToCanvasData(graph.Elements)) setTimeout(() => { - canvas.zoomToFit(2, (n: GraphNode) => pNodeIds.has(n.id)); - }, 0) + canvas.zoomToFit(1, (n: GraphNode) => pNodeIds.has(n.id)); + }, 300) setChatOpen && setChatOpen(false) } @@ -263,7 +257,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 +271,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 +280,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 +294,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,82 +361,46 @@ 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) - 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])) + // Clear previous path markings before applying new ones + 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 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, - })) - } - - // 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({ - nodes: [...currentData.nodes, ...newGraphData.nodes], - links: [...currentData.links, ...newGraphData.links] - }) - - if (elements.nodes.length !== 0 || elements.links.length !== 0) { - setCooldownTicks(-1) - } + // Update the canvas from the model + const pathCanvas = canvasRef.current + pathCanvas?.setGraphData(convertToCanvasData(graph.Elements)) + // 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(() => { - 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))); - }, 0) + pathCanvas?.zoomToFit(1, (n: GraphNode) => allPathNodeIds.has(n.id)) + }, 100) } const getTip = (className?: string) => @@ -457,9 +415,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) { @@ -469,24 +427,18 @@ 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(currentData) + canvas.setGraphData(convertToCanvasData(graph.Elements)) } - 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 +465,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 +474,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 +532,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 +547,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 +570,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..1666c3c2 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 } 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 } 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" @@ -42,8 +40,10 @@ interface Props { handleSearchSubmit: (node: any) => void searchNode: any setSearchNode: Dispatch> - cooldownTicks: number | undefined - setCooldownTicks: Dispatch> + animation: boolean + setAnimation: (animation: boolean) => void + manualDimmed: boolean + setManualDimmed: (dimmed: boolean) => void onCategoryClick: (name: string, show: boolean) => void handleDownloadImage: () => void zoomedNodes: Node[] @@ -72,8 +72,10 @@ export function CodeGraph({ handleSearchSubmit, searchNode, setSearchNode, - cooldownTicks, - setCooldownTicks, + animation, + setAnimation, + manualDimmed, + setManualDimmed, onCategoryClick, handleDownloadImage, zoomedNodes, @@ -83,7 +85,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(""); @@ -110,9 +111,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"); } }; @@ -121,7 +122,7 @@ export function CodeGraph({ return () => { document.removeEventListener('keydown', handleKeyDown); }; - }, [selectedObjects, selectedObj]); + }, [selectedObjects]); async function fetchCount() { const result = await fetch(`/api/repo_info`, { @@ -193,20 +194,20 @@ 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 + // Check if this collapsed node is directly part of any link from deleted nodes + const isConnected = graph.Elements.links.some(link => + (link.target === node.id && nodes.some(n => n.id === link.source)) || + (link.source === node.id && nodes.some(n => n.id === link.target)) + ); - 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) } @@ -215,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) { @@ -231,85 +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)) - } - - // 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({ - nodes: [...currentData.nodes, ...newGraphData.nodes], - links: [...currentData.links, ...newGraphData.links] + // Only NOW set expand state after successful fetch - glow animation triggers here + nodes.forEach((node) => { + node.expand = true }) - setCooldownTicks(-1) + // 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(currentData) - setCooldownTicks(-1) - } - } + // 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") => { @@ -322,28 +271,25 @@ 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(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([]) - setCooldownTicks(-1) } return ( @@ -387,25 +333,25 @@ 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(currentData) + // Update React state to trigger re-render + setData({ ...graph.Elements }) + + // Update canvas + canvas.setGraphData(convertToCanvasData(graph.Elements)) + canvas.zoomToFit() setIsPathResponse(false) - setCooldownTicks(-1) }} > @@ -428,15 +374,11 @@ export function CodeGraph({ element.visible = true }); - const currentData = canvas.getGraphData(); - - [...currentData.nodes, ...currentData.links].forEach(element => { - element.visible = true - }); - - canvas.setGraphData(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); - setCooldownTicks(-1); }} > @@ -446,11 +388,11 @@ export function CodeGraph({
{ setPath(path) - setSelectedObj(undefined) + setSelectedObjects([]) }} handleRemove={handleRemove} position={position} @@ -464,9 +406,7 @@ export function CodeGraph({ setData={setData} graph={graph} chartRef={canvasRef} - selectedObj={selectedObj} selectedObjects={selectedObjects} - setSelectedObj={setSelectedObj} setSelectedObjects={setSelectedObjects} setPosition={setPosition} handleExpand={handleExpand} @@ -475,10 +415,9 @@ export function CodeGraph({ isPathResponse={isPathResponse} selectedPathId={selectedPathId} setSelectedPathId={setSelectedPathId} - cooldownTicks={cooldownTicks} - setCooldownTicks={setCooldownTicks} setZoomedNodes={setZoomedNodes} zoomedNodes={zoomedNodes} + manualDimmed={manualDimmed} />
@@ -510,8 +449,11 @@ export function CodeGraph({ className="gap-4" canvasRef={canvasRef} handleDownloadImage={handleDownloadImage} - setCooldownTicks={setCooldownTicks} - cooldownTicks={cooldownTicks} + 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..fdc57c16 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 edgeMargin = 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(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 + // the menu stays at the bottom edge, which may overlap the node. + const rawTop = relY + nodeRadius + gap + const top = Math.max(edgeMargin, Math.min(rawTop, parentH - containerHeight - edgeMargin)) + 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" + className="absolute z-[15] 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 3a08e27f..951b2f4c 100644 --- a/app/src/components/graphView.tsx +++ b/app/src/components/graphView.tsx @@ -1,7 +1,7 @@ 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'; @@ -11,6 +11,7 @@ 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,30 +29,23 @@ interface Props { isPathResponse: boolean | undefined selectedPathId: number | undefined setSelectedPathId: (selectedPathId: number) => void - cooldownTicks: number | undefined - setCooldownTicks: Dispatch> setZoomedNodes: Dispatch> zoomedNodes: Node[] + manualDimmed: boolean } -const NODE_SIZE = 6; -const PADDING = 2; 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, @@ -63,21 +55,19 @@ export default function GraphView({ isPathResponse, selectedPathId, setSelectedPathId, - cooldownTicks, - setCooldownTicks, 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 = () => { @@ -94,10 +84,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) { @@ -108,12 +97,67 @@ 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) @@ -132,26 +176,26 @@ 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 isDoubleClick = now.getTime() - date.getTime() < 1000 && name === node.data.name - lastClick.current = { date: now, name: node.data.name } + const now = Date.now() + const { date, id } = lastClick.current + const isDoubleClick = now - date < DOUBLE_CLICK_MS && id === node.id + lastClick.current = isDoubleClick ? { date: 0, id: 0 } : { date: now, id: node.id } + if (isDoubleClick) { handleExpand([node], !node.expand) } else if (isShowPath) { @@ -171,107 +215,104 @@ export default function GraphView({ canvasRef.current?.zoomToFit(zoomedNodes.length === 1 ? 4 : 1, (n: GraphNode) => zoomedNodes.some(node => node.id === n.id)) setZoomedNodes([]) } + }, [zoomedNodes, canvasRef]) - if (cooldownTicks !== -1) return - - setCooldownTicks(0) - }, [zoomedNodes, cooldownTicks, 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 isNodeDimmed = useCallback((node: GraphNode) => { if (isPathResponse) { - if (node.data.isPathSelected) { - ctx.fillStyle = node.color; - ctx.strokeStyle = PATH_COLOR; - ctx.lineWidth = 1.5 - } else if (node.data.isPath) { - ctx.fillStyle = node.color; - ctx.strokeStyle = PATH_COLOR; - ctx.lineWidth = 1 - } else { - ctx.fillStyle = dimmedNodeFillColor; - ctx.strokeStyle = dimmedNodeStrokeColor; - ctx.lineWidth = 1 - } - } else if (isPathResponse === undefined) { - if (node.data.isPathSelected) { - ctx.fillStyle = node.color; - ctx.strokeStyle = PATH_COLOR; - ctx.lineWidth = 1.5 - } else if (node.data.isPath) { - ctx.fillStyle = node.color; - ctx.strokeStyle = PATH_COLOR; - ctx.lineWidth = 1 + 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 = isSelected || isHovered ? 1.5 : 1 + 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 = isSelected || isHovered ? 1.5 : 1 } - - ctx.beginPath(); - ctx.arc(node.x, node.y, NODE_SIZE + ctx.lineWidth / 2, 0, 2 * Math.PI, false); - ctx.stroke(); - ctx.fill(); - - ctx.fillStyle = canvasForegroundColor; - 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 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 } - name += ellipsis; } + + return true + }, [isPathResponse, manualDimmed, selectedObjects, hoverElement, data.links]) - // add label - ctx.fillText(name, node.x, node.y); - }, [ - 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; + const isLinkDimmed = useCallback((link: GraphLink) => { + if (isPathResponse) { + return !link.data.isPath && !link.data.isPathSelected } - - ctx.fillStyle = color; - ctx.beginPath(); - ctx.arc(node.x, node.y, NODE_SIZE + 2 + ctx.lineWidth / 2, 0, 2 * Math.PI, false); - ctx.fill(); - }, []) + // 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 + + // 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 + + 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 @@ -285,24 +326,26 @@ export default function GraphView({
handleNodeClick(node) : (node: Node, evt: MouseEvent) => handleRightClick(node, evt)} onNodeHover={handleNodeHover} onNodeRightClick={handleRightClick} isNodeSelected={isNodeSelected} - onLinkClick={isDesktop && isPathResponse ? handleLinkClick : handleRightClick} + onLinkClick={isDesktop ? handleLinkClick : handleRightClick} 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} - cooldownTicks={cooldownTicks} + onNodeDragEnd={handleNodeDragEnd} backgroundColor={canvasBackgroundColor} foregroundColor={canvasForegroundColor} /> diff --git a/app/src/components/model.ts b/app/src/components/model.ts index 2fe40608..be91b880 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,16 +262,25 @@ export class Graph { return newElements } - public removeLinks() { - 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 + public removeLinks(ids: number[] = []) { + const connectedLinks = this.elements.links.filter(link => ids.includes(link.source) || ids.includes(link.target)) + + this.elements.links = this.elements.links.filter(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 + this.linksMap.delete(link.id) + return false + } else { + return true } - this.linksMap.delete(link.id) - }).filter(link => link !== undefined) - } + } + this.linksMap.delete(link.id) + return false + }) } public visibleLinks(visible: boolean, ids?: number[]) { diff --git a/app/src/components/toolbar.tsx b/app/src/components/toolbar.tsx index b4d11258..d95d53f2 100644 --- a/app/src/components/toolbar.tsx +++ b/app/src/components/toolbar.tsx @@ -1,24 +1,124 @@ -import { Download, Fullscreen, ZoomIn, ZoomOut } from "lucide-react"; +import { useState } from "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 }[] = [ + { 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 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 - setCooldownTicks: (ticks?: 0) => void - cooldownTicks: number | undefined + 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, setCooldownTicks, cooldownTicks }: Props) { +export function Toolbar({ canvasRef, className, handleDownloadImage, animation, setAnimation, manualDimmed, setManualDimmed, selectedObjects, hideZoom: hideZoom }: Props) { + + const [layout, setLayout] = useState('force'); + const [direction, setDirection] = useState(''); + const [pinned, setPinned] = useState(false); 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 = () => { @@ -29,42 +129,176 @@ export function Toolbar({ canvasRef, className, handleDownloadImage, setCooldown } } + const handleAnimationToggle = () => { + const next = !animation; + setAnimation(next); + canvasRef.current?.setAnimation(next); + } + + const handleDimToggle = (checked: boolean) => { + setManualDimmed(checked); + canvasRef.current?.setDimmed(checked); + } + + const handlePinToggle = () => { + const next = !pinned; + setPinned(next); + canvasRef.current?.setPinOnDragEnd(next); + } + + const isAnimationDisabled = layout !== 'force' || pinned + + 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 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) => { + 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} + aria-label={animation ? "Pause animation" : "Play animation"} + /> + + - +
+ + + + + + + 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} + + ))} + + + + +
+ {!hideZoom && } -
) diff --git a/app/src/lib/utils.ts b/app/src/lib/utils.ts index 73cf2c9c..1a8d6af3 100644 --- a/app/src/lib/utils.ts +++ b/app/src/lib/utils.ts @@ -27,7 +27,12 @@ export enum MessageTypes { Text, } +export function createMessage(msg: Omit): Message { + return { ...msg, id: crypto.randomUUID() }; +} + export interface Message { + id: string; type: MessageTypes; text?: string; paths?: { nodes: any[], links: any[] }[]; 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(); diff --git a/e2e/logic/POM/codeGraph.ts b/e2e/logic/POM/codeGraph.ts index bd2f4249..4c6ef3fd 100644 --- a/e2e/logic/POM/codeGraph.ts +++ b/e2e/logic/POM/codeGraph.ts @@ -71,7 +71,7 @@ export default class CodeGraph extends BasePage { } private get selectGraphInComboBoxByName(): (graph: string) => Locator { - return (graph: string) => this.page.locator(`//div[@role='presentation']//div//span[contains(text(), '${graph}')]`); + return (graph: string) => this.page.locator(`//div[@role='presentation']//div//span[contains(text(), '${graph}')]`).first(); } private get selectGraphInComboBoxById(): (graph: string) => Locator { @@ -714,7 +714,7 @@ export default class CodeGraph extends BasePage { async getCanvasScaling(): Promise<{ scaleX: number; scaleY: number }> { await this.waitForCanvasAnimationToEnd(); - const zoom = await this.canvasHost.evaluate((canvas: { getZoom?: () => number }) => { + const zoom = await this.canvasHost.evaluate((canvas: any) => { return typeof canvas.getZoom === "function" ? canvas.getZoom() : 1; }); return { scaleX: zoom, scaleY: zoom }; @@ -804,10 +804,7 @@ export default class CodeGraph extends BasePage { const startTime = Date.now(); while (Date.now() - startTime < timeout) { - const { cooldown, zoom } = await this.canvasHost.evaluate((canvas: { - getGraph?: () => { cooldownTicks?: () => number } | undefined; - getZoom?: () => number; - }) => { + const { cooldown, zoom } = await this.canvasHost.evaluate((canvas: any) => { const graph = typeof canvas.getGraph === "function" ? canvas.getGraph() : undefined; return { cooldown: typeof graph?.cooldownTicks === "function" ? graph.cooldownTicks() : null, @@ -857,4 +854,44 @@ export default class CodeGraph extends BasePage { }; }, this.activeGraphGetterName); } + + /* Element Menu specific tests */ + async isElementMenuVisible(): Promise { + await this.page.waitForTimeout(500); + return await this.elementMenu.isVisible(); + } + + async getElementMenuBoundingBox(): Promise { + const isVisible = await this.isElementMenuVisible(); + if (!isVisible) throw new Error("Element menu is not visible!"); + return await this.elementMenu.boundingBox(); + } + + async hasElementMenuButton(buttonTitle: string): Promise { + const isVisible = await this.isElementMenuVisible(); + if (!isVisible) throw new Error("Element menu is not visible!"); + const button = this.elementMenuButton(buttonTitle); + return await button.isVisible(); + } + + async rightClickAtNode(x: number, y: number): Promise { + await this.waitForCanvasAnimationToEnd(); + const boundingBox = await this.canvasElement.boundingBox(); + if (!boundingBox) throw new Error("Canvas bounding box not found"); + + // Move to node position and right-click + await this.page.mouse.move(x, y); + await this.page.waitForTimeout(300); + await this.page.mouse.click(x, y, { button: 'right' }); + + // Wait for element menu to appear + const isMenuVisible = await this.elementMenu.isVisible({ timeout: 3000 }).catch(() => false); + if (!isMenuVisible) { + throw new Error(`Element menu not visible after right-clicking at (${x}, ${y})`); + } + } + + async getCanvasBoundingBox(): Promise { + return await this.canvasElement.boundingBox(); + } } diff --git a/e2e/tests/canvas.spec.ts b/e2e/tests/canvas.spec.ts index ef86597c..946f77ac 100644 --- a/e2e/tests/canvas.spec.ts +++ b/e2e/tests/canvas.spec.ts @@ -249,5 +249,91 @@ test.describe("Canvas tests", () => { expect(await codeGraph.isNodeToolTipVisible(node.nodeName)).toBe(true); }) }) - + + // --- Tests covering the convertToCanvasData visibility fix --- + // Previously, invisible nodes were filtered out before being sent to the canvas, + // which caused them to be fully removed (losing their positions). Now all nodes + // are sent with their `visible` property so the canvas preserves their positions. + + test(`Validate hidden node remains in canvas data with visible=false after remove`, async () => { + const codeGraph = await browser.createNewPage(CodeGraph, urls.baseUrl); + await browser.setPageToFullScreen(); + await codeGraph.selectGraph(GRAPHRAG_SDK); + await codeGraph.fillSearchBar(nodes[0].nodeName); + await codeGraph.selectSearchBarOptionBtn("1"); + await codeGraph.waitForCanvasAnimationToEnd(); + const initialGraph = await codeGraph.getGraphNodes(); + const targetNode = findNodeByName(initialGraph, nodes[0].nodeName); + expect(targetNode).toBeDefined(); + + // Hide the node via the element menu + await codeGraph.nodeClick(targetNode.screenX, targetNode.screenY); + await codeGraph.clickOnRemoveNodeViaElementMenu(); + + const updatedGraph = await codeGraph.getGraphNodes(); + const hiddenNode = findNodeByName(updatedGraph, nodes[0].nodeName); + + // Node must still be present in canvas data (not removed), just invisible + expect(hiddenNode).toBeDefined(); + expect(hiddenNode.visible).toBe(false); + }); + + test(`Validate node position is preserved after hide and unhide for ${nodes[0].nodeName}`, async () => { + const codeGraph = await browser.createNewPage(CodeGraph, urls.baseUrl); + await browser.setPageToFullScreen(); + await codeGraph.selectGraph(GRAPHRAG_SDK); + await codeGraph.fillSearchBar(nodes[0].nodeName); + await codeGraph.selectSearchBarOptionBtn("1"); + await codeGraph.waitForCanvasAnimationToEnd(); + + // Capture position before hiding + const initialGraph = await codeGraph.getGraphNodes(); + const initialNode = findNodeByName(initialGraph, nodes[0].nodeName); + expect(initialNode).toBeDefined(); + const { x: xBefore, y: yBefore } = initialNode; + + // Hide the node + await codeGraph.nodeClick(initialNode.screenX, initialNode.screenY); + await codeGraph.clickOnRemoveNodeViaElementMenu(); + + // Unhide all nodes — no simulation reruns, so positions must be preserved + await codeGraph.clickOnUnhideNodesBtn(); + + const updatedGraph = await codeGraph.getGraphNodes(); + const restoredNode = findNodeByName(updatedGraph, nodes[0].nodeName); + expect(restoredNode).toBeDefined(); + expect(restoredNode.visible).toBe(true); + + // Position tolerance of 1 unit accounts for floating-point serialisation + expect(Math.abs(restoredNode.x - xBefore)).toBeLessThan(1); + expect(Math.abs(restoredNode.y - yBefore)).toBeLessThan(1); + }); + + test(`Validate category node positions are preserved after toggling visibility for ${categories[0]}`, async () => { + const codeGraph = await browser.createNewPage(CodeGraph, urls.baseUrl); + await codeGraph.selectGraph(GRAPHRAG_SDK); + await codeGraph.waitForCanvasAnimationToEnd(); + + // Capture positions of the first visible category node before hiding + const initialGraph = await codeGraph.getGraphNodes(); + const categoryNodesBefore = initialGraph.filter((n: any) => n.category === categories[0]); + expect(categoryNodesBefore.length).toBeGreaterThan(0); + const sampleNode = categoryNodesBefore[0]; + const { x: xBefore, y: yBefore } = sampleNode; + + // Hide the category (checkbox 1 = categories[0]) + await codeGraph.selectCodeGraphCheckbox("1"); + + // Show the category again — canvas must not re-simulate; positions are kept + await codeGraph.selectCodeGraphCheckbox("1"); + + const updatedGraph = await codeGraph.getGraphNodes(); + const restoredNode = updatedGraph.find((n: any) => n.id === sampleNode.id); + expect(restoredNode).toBeDefined(); + expect(restoredNode.visible).toBe(true); + + expect(Math.abs(restoredNode.x - xBefore)).toBeLessThan(1); + expect(Math.abs(restoredNode.y - yBefore)).toBeLessThan(1); + }); + }); diff --git a/e2e/tests/elementMenu.spec.ts b/e2e/tests/elementMenu.spec.ts new file mode 100644 index 00000000..37452e53 --- /dev/null +++ b/e2e/tests/elementMenu.spec.ts @@ -0,0 +1,114 @@ +import { test, expect } from "@playwright/test"; +import BrowserWrapper from "../infra/ui/browserWrapper"; +import CodeGraph from "../logic/POM/codeGraph"; +import urls from "../config/urls.json"; +import { GRAPHRAG_SDK } from "../config/constants"; +import { findNodeByName } from "../logic/utils"; +import { nodes } from "../config/testData"; + +test.describe("Element Menu and Right-click Menu tests", () => { + let browser: BrowserWrapper; + + test.beforeAll(async () => { + browser = new BrowserWrapper(); + }); + + test.afterAll(async () => { + await browser.closeBrowser(); + }); + + test("Verify element menu appears on right-click at canvas center", async () => { + const codeGraph = await browser.createNewPage(CodeGraph, urls.baseUrl); + await codeGraph.selectGraph(GRAPHRAG_SDK); + await codeGraph.rightClickAtCanvasCenter(); + const isMenuVisible = await codeGraph.isElementMenuVisible(); + expect(isMenuVisible).toBe(true); + }); + + test("Verify element menu has consistent positioning gap from node", async () => { + const codeGraph = await browser.createNewPage(CodeGraph, urls.baseUrl); + await browser.setPageToFullScreen(); + await codeGraph.selectGraph(GRAPHRAG_SDK); + + // Get a node's position + const graphData = await codeGraph.getGraphNodes(); + const targetNode = findNodeByName(graphData, nodes[0].nodeName); + 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 + const isMenuVisible = await codeGraph.isElementMenuVisible(); + expect(isMenuVisible).toBe(true); + + // Get menu position and verify it appears below the node (not overlapping) + const menuBox = await codeGraph.getElementMenuBoundingBox(); + expect(menuBox).toBeDefined(); + expect(menuBox.y).toBeGreaterThan(targetNode.screenY); + }); + + test("Verify element menu contains 'View Node' button", async () => { + const codeGraph = await browser.createNewPage(CodeGraph, urls.baseUrl); + await codeGraph.selectGraph(GRAPHRAG_SDK); + await codeGraph.rightClickAtCanvasCenter(); + + const hasViewNodeBtn = await codeGraph.hasElementMenuButton("View Node"); + expect(hasViewNodeBtn).toBe(true); + }); + + test("Verify element menu 'View Node' button opens node details panel", async () => { + const codeGraph = await browser.createNewPage(CodeGraph, urls.baseUrl); + await browser.setPageToFullScreen(); + await codeGraph.selectGraph(GRAPHRAG_SDK); + + // Get a node and right-click on it + const graphData = await codeGraph.getGraphNodes(); + const targetNode = findNodeByName(graphData, nodes[0].nodeName); + expect(targetNode).toBeDefined(); + + await codeGraph.rightClickAtNode(targetNode.screenX, targetNode.screenY); + await codeGraph.clickOnViewNode(); + + expect(await codeGraph.isNodeDetailsPanel()).toBe(true); + }); + + test("Verify element menu 'Remove' button removes node/link from canvas", async () => { + const codeGraph = await browser.createNewPage(CodeGraph, urls.baseUrl); + await browser.setPageToFullScreen(); + await codeGraph.selectGraph(GRAPHRAG_SDK); + + // Remove a specific node to keep the assertion deterministic. + const initialGraphData = await codeGraph.getGraphNodes(); + const targetNode = findNodeByName(initialGraphData, nodes[0].nodeName); + expect(targetNode).toBeDefined(); + + await codeGraph.rightClickAtNode(targetNode.screenX, targetNode.screenY); + await codeGraph.clickOnRemoveNodeViaElementMenu(); + + // Hidden nodes remain in graph data; verify visibility rather than length. + await codeGraph.waitForCanvasAnimationToEnd(); + const updatedGraphData = await codeGraph.getGraphNodes(); + const updatedTargetNode = findNodeByName(updatedGraphData, nodes[0].nodeName); + expect(updatedTargetNode).toBeDefined(); + expect(updatedTargetNode.visible).toBe(false); + }); + + test("Verify element menu positioning respects canvas boundaries", async () => { + const codeGraph = await browser.createNewPage(CodeGraph, urls.baseUrl); + await browser.setPageToFullScreen(); + await codeGraph.selectGraph(GRAPHRAG_SDK); + + // Right-click at canvas center + await codeGraph.rightClickAtCanvasCenter(); + + // Get menu bounding box and canvas bounds + const menuBox = await codeGraph.getElementMenuBoundingBox(); + const canvasBox = await codeGraph.getCanvasBoundingBox(); + + // Verify menu stays within canvas bounds + expect(menuBox.x).toBeGreaterThanOrEqual(canvasBox.x); + expect(menuBox.y + menuBox.height).toBeLessThanOrEqual(canvasBox.y + canvasBox.height); + }); +}); diff --git a/e2e/tests/toolbar.spec.ts b/e2e/tests/toolbar.spec.ts new file mode 100644 index 00000000..2b1f4405 --- /dev/null +++ b/e2e/tests/toolbar.spec.ts @@ -0,0 +1,107 @@ +import { test, expect } from "@playwright/test"; +import BrowserWrapper from "../infra/ui/browserWrapper"; +import CodeGraph from "../logic/POM/codeGraph"; +import urls from "../config/urls.json"; +import { GRAPHRAG_SDK } from "../config/constants"; + +test.describe("Toolbar and Zoom Controls tests", () => { + let browser: BrowserWrapper; + + test.beforeAll(async () => { + browser = new BrowserWrapper(); + }); + + test.afterAll(async () => { + await browser.closeBrowser(); + }); + + test("Verify zoom in increases canvas scale", async () => { + const codeGraph = await browser.createNewPage(CodeGraph, urls.baseUrl); + await codeGraph.selectGraph(GRAPHRAG_SDK); + + const initialScale = await codeGraph.getCanvasScaling(); + await codeGraph.clickZoomIn(); + const newScale = await codeGraph.getCanvasScaling(); + + expect(newScale.scaleX).toBeGreaterThan(initialScale.scaleX); + expect(newScale.scaleY).toBeGreaterThan(initialScale.scaleY); + }); + + test("Verify zoom out decreases canvas scale", async () => { + const codeGraph = await browser.createNewPage(CodeGraph, urls.baseUrl); + await codeGraph.selectGraph(GRAPHRAG_SDK); + + const initialScale = await codeGraph.getCanvasScaling(); + await codeGraph.clickZoomOut(); + const newScale = await codeGraph.getCanvasScaling(); + + expect(newScale.scaleX).toBeLessThan(initialScale.scaleX); + expect(newScale.scaleY).toBeLessThan(initialScale.scaleY); + }); + + test("Verify center button resets zoom to fit", async () => { + const codeGraph = await browser.createNewPage(CodeGraph, urls.baseUrl); + await codeGraph.selectGraph(GRAPHRAG_SDK); + + // Zoom in first + await codeGraph.clickZoomIn(); + await codeGraph.clickZoomIn(); + await codeGraph.getCanvasScaling(); + + // Click center to reset + await codeGraph.clickCenter(); + const centeredScale = await codeGraph.getCanvasScaling(); + + // Scale should have changed (either increased or decreased depending on implementation) + expect(centeredScale.scaleX).toBeDefined(); + expect(centeredScale.scaleY).toBeDefined(); + }); + + test("Verify zoom controls remain functional after multiple interactions", async () => { + const codeGraph = await browser.createNewPage(CodeGraph, urls.baseUrl); + await codeGraph.selectGraph(GRAPHRAG_SDK); + + const scales: number[] = []; + + // Perform multiple zoom operations + for (let i = 0; i < 3; i++) { + await codeGraph.clickZoomIn(); + const scale = await codeGraph.getCanvasScaling(); + scales.push(scale.scaleX); + } + + // Verify each zoom in increased the scale + for (let i = 1; i < scales.length; i++) { + expect(scales[i]).toBeGreaterThan(scales[i - 1]); + } + }); + + test("Verify zoom controls work after graph change", async () => { + const codeGraph = await browser.createNewPage(CodeGraph, urls.baseUrl); + await codeGraph.selectGraph(GRAPHRAG_SDK); + + const initialScale = await codeGraph.getCanvasScaling(); + await codeGraph.clickZoomIn(); + const zoomedScale = await codeGraph.getCanvasScaling(); + + expect(zoomedScale.scaleX).toBeGreaterThan(initialScale.scaleX); + }); + + test("Verify continuous zoom maintains functionality", async () => { + const codeGraph = await browser.createNewPage(CodeGraph, urls.baseUrl); + await codeGraph.selectGraph(GRAPHRAG_SDK); + + // Zoom in, then out, should return to similar scale + const initialScale = await codeGraph.getCanvasScaling(); + + await codeGraph.clickZoomIn(); + await codeGraph.clickZoomIn(); + await codeGraph.clickZoomOut(); + await codeGraph.clickZoomOut(); + + const finalScale = await codeGraph.getCanvasScaling(); + + // Should be approximately the same (within 5% tolerance for floating point operations) + expect(Math.abs(finalScale.scaleX - initialScale.scaleX) / initialScale.scaleX).toBeLessThan(0.05); + }); +});