diff --git a/.github/workflows/nextjs.yml b/.github/workflows/nextjs.yml index 9b87e673..2dc9fde6 100644 --- a/.github/workflows/nextjs.yml +++ b/.github/workflows/nextjs.yml @@ -53,7 +53,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "24" cache: ${{ steps.detect-package-manager.outputs.manager }} - name: Restore cache uses: actions/cache@v3 diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 06b8b283..7de5dea7 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -8,6 +8,10 @@ jobs: test: timeout-minutes: 60 runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shard: [1, 2] services: falkordb: image: falkordb/falkordb:latest @@ -17,7 +21,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: lts/* + node-version: 24 - name: Install dependencies run: npm ci - name: Install Playwright Browsers @@ -40,7 +44,7 @@ jobs: - uses: actions/upload-artifact@v4 if: always() with: - name: playwright-report + name: playwright-report-shard-${{ matrix.shard }} path: playwright-report/ retention-days: 30 - name: Upload failed test screenshots diff --git a/Dockerfile b/Dockerfile index d338bda6..8a21a2ac 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Use a Node.js base image -FROM node:22 +FROM node:24 # Set working directory WORKDIR /app diff --git a/README.md b/README.md index 3d30f415..7126fd6c 100644 --- a/README.md +++ b/README.md @@ -92,8 +92,9 @@ curl -X POST http://127.0.0.1:5000/analyze_folder \ -d '{"path": "", "ignore": ["./.github", "./sbin", "./.git", "./deps", "./bin", "./build"]}' ``` -**Note:** Currently, Code-Graph supports analyzing C and Python source files. -Support for additional languages (e.g., JavaScript, Go, Java) is planned. +Note: At the moment code-graph can analyze both the Java & Python source files. +Support for additional languages e.g. C, JavaScript, Go is planned to be added +in the future. ### 5. Access the Web Interface diff --git a/app/api/repo/route.ts b/app/api/repo/route.ts index 421be274..3a004d6c 100644 --- a/app/api/repo/route.ts +++ b/app/api/repo/route.ts @@ -22,7 +22,7 @@ export async function GET() { return NextResponse.json({ result: repositories }, { status: 200 }) } catch (err) { console.error(err) - return NextResponse.json((err as Error).message, { status: 400 }) + return NextResponse.json(err instanceof Error ? err.message : String(err), { status: 400 }) } } @@ -57,6 +57,6 @@ export async function POST(request: NextRequest) { return NextResponse.json({ message: "success" }, { status: 200 }); } catch (err) { console.error(err) - return NextResponse.json((err as Error).message, { status: 400 }); + return NextResponse.json(err instanceof Error ? err.message : String(err), { status: 400 }); } } \ No newline at end of file diff --git a/app/components/ForceGraph.tsx b/app/components/ForceGraph.tsx new file mode 100644 index 00000000..80c7f2fa --- /dev/null +++ b/app/components/ForceGraph.tsx @@ -0,0 +1,173 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import type { Data, GraphNode } from "@falkordb/canvas" +import { GraphRef, PATH_COLOR } from "@/lib/utils" +import { GraphData, Link, Node } from "./model" + +interface Props { + id: "desktop" | "mobile" + data: GraphData + canvasRef: GraphRef + onNodeClick: (node: Node, event: MouseEvent) => void + onNodeRightClick: (node: Node, event: MouseEvent) => void + onLinkClick: (link: Link, event: MouseEvent) => void + onLinkRightClick: (link: Link, event: MouseEvent) => void + onBackgroundClick: (event: MouseEvent) => void + onBackgroundRightClick: (event: MouseEvent) => void + nodeCanvasObject: (node: GraphNode, ctx: CanvasRenderingContext2D) => void + nodePointerAreaPaint: (node: GraphNode, color: string, ctx: CanvasRenderingContext2D) => void + linkCanvasObject: (link: any, ctx: CanvasRenderingContext2D) => void + linkPointerAreaPaint: (link: any, color: string, ctx: CanvasRenderingContext2D) => void + linkLineDash: (link: any) => number[] | null + onZoom: () => void + onEngineStop: () => void + cooldownTicks: number | undefined + 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, + labels: [category], + color, + visible, + data: { ...data, isPath, isPathSelected } + })), + links: graphData.links.filter(l => l.visible).map(({ id, label, color, visible, source, target, isPath, isPathSelected, data }) => ({ + id, + relationship: label, + color: isPath ? PATH_COLOR : color, + visible, + source, + target, + data: { ...data, isPath, isPathSelected } + })) +}); + +export default function ForceGraph({ + id, + data, + canvasRef, + onNodeClick, + onNodeRightClick, + onLinkClick, + onLinkRightClick, + onBackgroundClick, + onBackgroundRightClick, + onZoom, + onEngineStop, + nodeCanvasObject, + nodePointerAreaPaint, + linkCanvasObject, + linkPointerAreaPaint, + linkLineDash, + cooldownTicks, + backgroundColor = "#FFFFFF", + foregroundColor = "#000000" +}: Props) { + const [canvasLoaded, setCanvasLoaded] = useState(false) + + // Load falkordb-canvas dynamically (client-only) + useEffect(() => { + import('@falkordb/canvas').then(() => { + setCanvasLoaded(true) + }) + }, []) + + useEffect(() => { + const canvas = canvasRef.current + + if (!canvas) return + + (window as any)[id === "desktop" ? "graphDesktop" : "graphMobile"] = () => canvas.getGraphData(); + }, [canvasRef, id]) + + // Update canvas colors + useEffect(() => { + if (!canvasRef.current || !canvasLoaded) return + canvasRef.current.setBackgroundColor(backgroundColor) + 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: any, event: MouseEvent) => { + const originalNode = data.nodes.find(n => n.id === node.id) + if (originalNode) onNodeClick(originalNode, event) + }, [onNodeClick, data.nodes]) + + // Map node right click handler + const handleNodeRightClick = useCallback((node: any, event: MouseEvent) => { + const originalNode = data.nodes.find(n => n.id === node.id) + if (originalNode) onNodeRightClick(originalNode, event) + }, [onNodeRightClick, data.nodes]) + + // Map link click handler + const handleLinkClick = useCallback((link: any, event: MouseEvent) => { + const originalLink = data.links.find(l => l.id === link.id) + if (originalLink) onLinkClick(originalLink, event) + }, [onLinkClick, data.links]) + + // Map link right click handler + const handleLinkRightClick = useCallback((link: any, event: MouseEvent) => { + const originalLink = data.links.find(l => l.id === link.id) + if (originalLink) onLinkRightClick(originalLink, event) + }, [onLinkRightClick, data.links]) + + // Handle engine stop and set window.graph + const handleEngineStop = useCallback(() => { + onEngineStop() + }, [canvasRef, onEngineStop]) + + // Update event handlers + useEffect(() => { + if (!canvasRef.current || !canvasLoaded) return + canvasRef.current.setConfig({ + autoStopOnSettle: false, + captionsKeys: ["name", "title"], + onNodeClick: handleNodeClick, + onNodeRightClick: handleNodeRightClick, + onLinkClick: handleLinkClick, + onLinkRightClick: handleLinkRightClick, + onBackgroundClick, + onBackgroundRightClick, + onEngineStop: handleEngineStop, + node: { nodeCanvasObject, nodePointerAreaPaint }, + link: { linkCanvasObject, linkPointerAreaPaint }, + linkLineDash, + onZoom + }) + }, [ + handleNodeClick, + handleNodeRightClick, + handleLinkClick, + handleLinkRightClick, + onBackgroundClick, + onBackgroundRightClick, + handleEngineStop, + onZoom, + canvasRef, + canvasLoaded + ]) + + // Update canvas data + useEffect(() => { + const canvas = canvasRef.current + if (!canvas || !canvasLoaded) return + + const canvasData = convertToCanvasData(data) + canvas.setData(canvasData) + }, [canvasRef, data, canvasLoaded]) + + return ( + + ) +} diff --git a/app/components/chat.tsx b/app/components/chat.tsx index 3b2ba447..ff99e169 100644 --- a/app/components/chat.tsx +++ b/app/components/chat.tsx @@ -4,12 +4,12 @@ import Image from "next/image"; import { AlignLeft, ArrowRight, ChevronDown, Lightbulb, Undo2 } from "lucide-react"; import { Message, MessageTypes, Path, PathData } from "@/lib/utils"; import Input from "./Input"; -import { Graph, GraphData, Link, Node } from "./model"; +import { Graph, GraphData, Node } from "./model"; import { cn, GraphRef } from "@/lib/utils"; import { TypeAnimation } from "react-type-animation"; import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { prepareArg } from "../utils"; -import { ForceGraphMethods, NodeObject } from "react-force-graph-2d"; +import { dataToGraphData, GraphLink, GraphNode } from "@falkordb/canvas"; interface Props { repo: string @@ -19,19 +19,20 @@ interface Props { selectedPathId: number | undefined isPathResponse: boolean | undefined setIsPathResponse: (isPathResponse: boolean | undefined) => void - setData: Dispatch> - chartRef: GraphRef + canvasRef: GraphRef messages: Message[] setMessages: Dispatch> query: string setQuery: Dispatch> selectedPath: PathData | undefined setSelectedPath: Dispatch> + setCooldownTicks: Dispatch> setChatOpen?: Dispatch> paths: PathData[] setPaths: Dispatch> } +const PATH_COLOR = "#ffde21"; const SUGGESTIONS = [ "List a few recursive functions", "What is the name of the most used method?", @@ -51,7 +52,7 @@ const RemoveLastPath = (messages: Message[]) => { return messages } -export function Chat({ messages, setMessages, query, setQuery, selectedPath, setSelectedPath, setChatOpen, repo, path, setPath, graph, selectedPathId, isPathResponse, setIsPathResponse, setData, chartRef, paths, setPaths }: Props) { +export function Chat({ messages, setMessages, query, setQuery, selectedPath, setSelectedPath, setChatOpen, repo, path, setPath, graph, selectedPathId, isPathResponse, setIsPathResponse, setCooldownTicks, canvasRef, paths, setPaths }: Props) { const [sugOpen, setSugOpen] = useState(false); @@ -91,9 +92,9 @@ export function Chat({ messages, setMessages, query, setQuery, selectedPath, set }, [isPathResponse]) const handleSetSelectedPath = (p: PathData) => { - const chart = chartRef.current + const canvas = canvasRef.current - if (!chart) return + if (!canvas) return setSelectedPath(prev => { if (prev) { if (isPathResponse && paths.some((path) => [...path.nodes, ...path.links].every((e: any) => [...prev.nodes, ...prev.links].some((e: any) => e.id === e.id)))) { @@ -138,16 +139,39 @@ export function Chat({ messages, setMessages, query, setQuery, selectedPath, set }) graph.extend(elements, true, { start: p.nodes[0], end: p.nodes[p.nodes.length - 1] }) graph.getElements().filter(e => "source" in e ? p.links.some(l => l.id === e.id) : p.nodes.some(n => n.id === e.id)).forEach(e => { - if ((e.id === p.nodes[0].id || e.id === p.nodes[p.nodes.length - 1].id) || "source" in e) { + if (e.id === p.nodes[0].id || e.id === p.nodes[p.nodes.length - 1].id || "source" in e) { e.isPathSelected = true } else { e.isPath = true } }); } - setData({ ...graph.Elements }) + + const currentData = canvas.getGraphData(); + + const nodesSet = new Set(p.nodes.map((n: Node) => n.id)); + const linksSet = new Set(p.links.map((l: any) => l.id)); + + currentData.nodes.forEach(n => { + if (nodesSet.has(n.id)) { + if (n.id === p.nodes[0].id || n.id === p.nodes[p.nodes.length - 1].id) { + n.data.isPathSelected = true; + } else { + n.data.isPath = true; + } + } + }); + currentData.links.forEach(l => { + if (linksSet.has(l.id)) { + l.data.isPathSelected = true; + l.color = PATH_COLOR; + } + }); + + canvas.setGraphData(currentData) + setTimeout(() => { - chart.zoomToFit(1000, 150, (n: NodeObject) => p.nodes.some(node => node.id === n.id)); + canvas.zoomToFit(2, (n: GraphNode) => p.nodes.some(node => node.id === n.id)); }, 0) setChatOpen && setChatOpen(false) } @@ -206,21 +230,29 @@ export function Chat({ messages, setMessages, query, setQuery, selectedPath, set } const handleSubmit = async () => { - const chart = chartRef.current + const canvas = canvasRef.current - if (!chart) return + if (!canvas) return setSelectedPath(undefined) if (!path?.start?.id || !path.end?.id) return + const pathMessage = [{ + type: MessageTypes.Response, + text: "Please select a starting point and the end point. Select or press relevant item on the graph" + }, { type: MessageTypes.Path }] + setPath(undefined) + setMessages((prev) => [...RemoveLastPath(prev), { type: MessageTypes.Pending }]) const result = await fetch(`/api/repo/${prepareArg(repo)}/${prepareArg(String(path.start.id))}/?targetId=${prepareArg(String(path.end.id))}`, { method: 'POST' }) if (!result.ok) { + setMessages((prev) => [...prev.slice(0, -1), ...pathMessage]) + setPath({}) toast({ variant: "destructive", title: "Uh oh! Something went wrong.", @@ -232,6 +264,8 @@ export function Chat({ messages, setMessages, query, setQuery, selectedPath, set const json = await result.json() if (json.result.paths.length === 0) { + setMessages((prev) => [...prev.slice(0, -1), ...pathMessage]) + setPath({}) toast({ title: `No path found`, description: `no path found between node ${path.start.name} - ${path.end.name}`, @@ -239,15 +273,90 @@ export function Chat({ messages, setMessages, query, setQuery, selectedPath, set return } - const formattedPaths: PathData[] = json.result.paths.map((p: any) => ({ nodes: p.filter((n: any, i: number) => i % 2 === 0), links: p.filter((l: any, i: number) => i % 2 !== 0) })) - formattedPaths.forEach((p: any) => graph.extend(p, false, path)) - + const formattedPaths: PathData[] = json.result.paths.map((p: any) => ({ nodes: p.filter((_n: any, i: number) => i % 2 === 0), links: p.filter((l: any, i: number) => i % 2 !== 0) })) + const elements = formattedPaths.reduce( + (acc, p) => { + const el = graph.extend(p, false, path) + acc.nodes.push(...el.nodes) + acc.links.push(...el.links) + return acc + }, + { nodes: [], links: [] } + ) setPaths(formattedPaths) - setMessages((prev) => [...RemoveLastPath(prev), { type: MessageTypes.PathResponse, paths: formattedPaths, graphName: graph.Id }]); + setMessages((prev) => [...prev.slice(0, -1), { type: MessageTypes.PathResponse, paths: formattedPaths, graphName: graph.Id }]); setIsPathResponse(true) - setData({ ...graph.Elements }) + + 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])) + + formattedPaths.flatMap(p => p.nodes).forEach(n => { + const node = nodesMap.get(n.id); + if (node) { + node.data.isPath = true; + } + }); + formattedPaths.flatMap(p => p.links).forEach(l => { + const link = linksMap.get(l.id); + + if (link) { + link.data.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) + } + setTimeout(() => { - chart.zoomToFit(1000, 150, (n: NodeObject) => formattedPaths.some(p => p.nodes.some(node => node.id === n.id))); + 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) } @@ -257,6 +366,10 @@ export function Chat({ messages, setMessages, query, setQuery, selectedPath, set disabled={isSendMessage} className={cn("Tip", className)} onClick={() => { + const canvas = canvasRef.current + + if (!canvas) return + setSugOpen(false) setMessages(prev => [ ...RemoveLastPath(prev), @@ -269,16 +382,26 @@ export function Chat({ messages, setMessages, query, setQuery, selectedPath, set e.isPath = false 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" + } + }) + + canvas.setGraphData(currentData) } - setTimeout(() => setMessages(prev => [...prev, { + setMessages(prev => [...prev, { type: MessageTypes.Response, text: "Please select a starting point and the end point. Select or press relevant item on the graph" - }]), 300) - setTimeout(() => { - setMessages(prev => [...prev, { type: MessageTypes.Path }]) - setPath({}) - }, 4000) + }, { type: MessageTypes.Path }]) + setPath({}) }} >

Show the path

diff --git a/app/components/code-graph.tsx b/app/components/code-graph.tsx index fdf31e5f..b75f9ef1 100644 --- a/app/components/code-graph.tsx +++ b/app/components/code-graph.tsx @@ -6,18 +6,19 @@ import { Download, GitFork, Search, X } from "lucide-react"; import ElementMenu from "./elementMenu"; import Combobox from "./combobox"; import { toast } from '@/components/ui/use-toast'; -import { Path } from "@/lib/utils"; +import { Path, PATH_COLOR } from "@/lib/utils"; import Input from './Input'; // import CommitList from './commitList'; import { Checkbox } from '@/components/ui/checkbox'; -import dynamic from 'next/dynamic'; -import { Position } from "./graphView"; +import type { Position } from "./graphView"; import { prepareArg } from '../utils'; import { GraphRef } from "@/lib/utils"; - -const GraphView = dynamic(() => import('./graphView')); +import { dataToGraphData } from "@falkordb/canvas"; +import type { Node as CanvasNode, Link as CanvasLink, GraphData as CanvasData } from "@falkordb/canvas"; +import GraphView from "./graphView"; interface Props { + id: "desktop" | "mobile" graph: Graph, data: GraphData, setData: Dispatch>, @@ -27,7 +28,7 @@ interface Props { setOptions: Dispatch> isShowPath: boolean setPath: Dispatch> - chartRef: GraphRef + canvasRef: GraphRef selectedValue: string selectedPathId: number | undefined setSelectedPathId: (selectedPathId: number) => void @@ -42,9 +43,12 @@ interface Props { handleDownloadImage: () => void zoomedNodes: Node[] setZoomedNodes: Dispatch> + hasHiddenElements: boolean + setHasHiddenElements: Dispatch> } export function CodeGraph({ + id, graph, data, setData, @@ -54,7 +58,7 @@ export function CodeGraph({ setOptions, isShowPath, setPath, - chartRef, + canvasRef, selectedValue, setSelectedPathId, isPathResponse, @@ -68,7 +72,9 @@ export function CodeGraph({ onCategoryClick, handleDownloadImage, zoomedNodes, - setZoomedNodes + setZoomedNodes, + hasHiddenElements, + setHasHiddenElements }: Props) { const [url, setURL] = useState(""); @@ -92,6 +98,10 @@ export function CodeGraph({ handleSelectedValue(selectedValue) }, [selectedValue]) + useEffect(() => { + setHasHiddenElements(graph.getElements().some(element => !element.visible)) + }, [data, graph.Id]) + useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Delete') { @@ -172,15 +182,17 @@ 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.id === node.id && nodes.some(n => n.id === link.source.id)); + const isTarget = graph.Elements.links.some(link => link.target === node.id && nodes.some(n => n.id === link.source)); if (!isTarget) return true + deleteIdsMap.add(node.id) const deleted = graph.NodesMap.delete(Number(node.id)) if (deleted && node.expand) { @@ -192,9 +204,11 @@ export function CodeGraph({ links: graph.Elements.links } - deleteNeighbors(expandedNodes) + deleteNeighbors(expandedNodes)?.forEach(id => deleteIdsMap.add(id)) graph.removeLinks() + + return deleteIdsMap } const handleExpand = async (nodes: Node[], expand: boolean) => { @@ -208,10 +222,75 @@ export function CodeGraph({ }) 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] + }) + + setCooldownTicks(-1) } else { const deleteNodes = nodes.filter(n => n.expand) if (deleteNodes.length > 0) { - deleteNeighbors(deleteNodes); + const deleteIdsMap = deleteNeighbors(deleteNodes); + + if (!deleteIdsMap || deleteIdsMap.size === 0) return + + const currentData = canvasRef.current?.getGraphData() + + 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))) + + canvasRef.current?.setGraphData(currentData) + setCooldownTicks(-1) + } } } @@ -220,21 +299,40 @@ export function CodeGraph({ }) setSelectedObj(undefined) - setData({ ...graph.Elements }) } const handleRemove = (ids: number[], type: "nodes" | "links") => { + const canvas = canvasRef.current + + if (!canvas) return + graph.Elements[type].forEach(element => { if (!ids.includes(element.id)) return 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 + } + }) + } + + canvas.setGraphData(currentData) graph.visibleLinks(false, ids) + setHasHiddenElements(true) setSelectedObj(undefined) setSelectedObjects([]) - - setData({ ...graph.Elements }) + setCooldownTicks(-1) } return ( @@ -272,11 +370,29 @@ export function CodeGraph({ } { - (graph.getElements().some(e => !e.visible)) && + hasHiddenElements && @@ -89,11 +90,11 @@ export default function ElementMenu({ obj, objects, setPath, handleRemove, posit title="Copy src to clipboard" onClick={async () => { try { - await navigator.clipboard.writeText(obj.src || ""); + await navigator.clipboard.writeText(obj.data.src || ""); } catch (err) { // Fallback for older browsers const textArea = document.createElement('textarea'); - textArea.value = obj.src || ""; + textArea.value = obj.data.src || ""; textArea.style.position = 'fixed'; textArea.style.left = '-999999px'; document.body.appendChild(textArea); diff --git a/app/components/graphView.tsx b/app/components/graphView.tsx index c221e0ac..63606835 100644 --- a/app/components/graphView.tsx +++ b/app/components/graphView.tsx @@ -1,11 +1,12 @@ 'use client' -import ForceGraph2D, { NodeObject } from 'react-force-graph-2d'; import { Graph, GraphData, Link, Node } from './model'; -import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react'; -import { Path } from '@/lib/utils'; +import { Dispatch, SetStateAction, useCallback, useEffect, useRef, useState } from 'react'; +import { Path, PATH_COLOR } from '@/lib/utils'; import { Fullscreen } from 'lucide-react'; -import { GraphRef, handleZoomToFit } from '@/lib/utils'; +import { GraphRef } from '@/lib/utils'; +import ForceGraph from './ForceGraph'; +import { GraphLink, GraphNode } from '@falkordb/canvas'; export interface Position { x: number, @@ -17,6 +18,7 @@ interface Props { setData: Dispatch> graph: Graph chartRef: GraphRef + id: "desktop" | "mobile" selectedObj: Node | Link | undefined setSelectedObj: Dispatch> selectedObjects: Node[] @@ -34,14 +36,14 @@ interface Props { zoomedNodes: Node[] } -const PATH_COLOR = "#ffde21" const NODE_SIZE = 6; const PADDING = 2; export default function GraphView({ data, graph, - chartRef, + chartRef: canvasRef, + id, selectedObj, setSelectedObj, selectedObjects, @@ -59,10 +61,7 @@ export default function GraphView({ setZoomedNodes }: Props) { - const parentRef = useRef(null) const lastClick = useRef<{ date: Date, name: string }>({ date: new Date(), name: "" }) - const [parentWidth, setParentWidth] = useState(0) - const [parentHeight, setParentHeight] = useState(0) const [screenSize, setScreenSize] = useState(0) useEffect(() => { @@ -79,38 +78,13 @@ export default function GraphView({ } }, []) - useEffect(() => { - const handleResize = () => { - if (!parentRef.current) return - setParentWidth(parentRef.current.clientWidth) - setParentHeight(parentRef.current.clientHeight) - } - - window.addEventListener('resize', handleResize) - - const observer = new ResizeObserver(handleResize) - - if (parentRef.current) { - observer.observe(parentRef.current) - } - - return () => { - window.removeEventListener('resize', handleResize) - observer.disconnect() - } - }, [parentRef]) - - useEffect(() => { - setCooldownTicks(undefined) - }, [graph.Id, graph.getElements().length]) - - const unsetSelectedObjects = (evt?: MouseEvent) => { + const unsetSelectedObjects = useCallback((evt?: MouseEvent) => { if (evt?.ctrlKey || (!selectedObj && selectedObjects.length === 0)) return setSelectedObj(undefined) setSelectedObjects([]) - } + }, [selectedObj, selectedObjects, setSelectedObj, setSelectedObjects]) - const handleRightClick = (element: Node | Link, evt: MouseEvent) => { + const handleRightClick = useCallback((element: Node | Link, evt: MouseEvent) => { if (evt.ctrlKey && "category" in element) { if (selectedObjects.some(obj => obj.id === element.id)) { setSelectedObjects(selectedObjects.filter(obj => obj.id !== element.id)) @@ -124,7 +98,7 @@ export default function GraphView({ setSelectedObj(element) setPosition({ x: evt.clientX, y: evt.clientY }) - } + }, [selectedObjects, setSelectedObjects, setSelectedObj, setPosition]) const handleLinkClick = (link: Link, evt: MouseEvent) => { unsetSelectedObjects(evt) @@ -132,232 +106,280 @@ export default function GraphView({ setSelectedPathId(link.id) } - const handleNodeClick = async (node: Node) => { + const handleNodeClick = useCallback(async (node: Node) => { const now = new Date() const { date, name } = lastClick.current - const isDoubleClick = now.getTime() - date.getTime() < 1000 && name === node.name - lastClick.current = { date: now, name: node.name } + const isDoubleClick = now.getTime() - date.getTime() < 1000 && name === node.data.name + lastClick.current = { date: now, name: node.data.name } if (isDoubleClick) { handleExpand([node], !node.expand) } else if (isShowPath) { setPath(prev => { if (!prev?.start?.name || (prev.end?.name && prev.end?.name !== "")) { - return ({ start: { id: Number(node.id), name: node.name } }) + return ({ start: { id: Number(node.id), name: node.data.name } }) } else { - return ({ end: { id: Number(node.id), name: node.name }, start: prev.start }) + return ({ end: { id: Number(node.id), name: node.data.name }, start: prev.start }) } }) return } - } + }, [handleExpand, isShowPath, setPath]) - const avoidOverlap = (nodes: Position[]) => { - const spacing = NODE_SIZE * 2.5; - nodes.forEach((nodeA, i) => { - nodes.forEach((nodeB, j) => { - if (i !== j) { - const dx = nodeA.x - nodeB.x; - const dy = nodeA.y - nodeB.y; - const distance = Math.sqrt(dx * dx + dy * dy) || 1; - - if (distance < spacing) { - const pushStrength = (spacing - distance) / distance * 0.5; - nodeA.x += dx * pushStrength; - nodeA.y += dy * pushStrength; - nodeB.x -= dx * pushStrength; - nodeB.y -= dy * pushStrength; - } - } - }); - }); - }; + const handleEngineStop = useCallback(() => { + if (zoomedNodes.length > 0) { + 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]) + + const nodeCanvasObject = useCallback((node: GraphNode, ctx: CanvasRenderingContext2D) => { + if (!node.x || !node.y) return + + 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 = '#E5E5E5'; + ctx.strokeStyle = 'gray'; + 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 + } else { + ctx.fillStyle = node.color; + ctx.strokeStyle = 'black'; + ctx.lineWidth = selectedObjects.some(obj => obj.id === node.id) || selectedObj?.id === node.id ? 1.5 : 1 + } + } else { + ctx.fillStyle = node.color; + ctx.strokeStyle = 'black'; + ctx.lineWidth = selectedObjects.some(obj => obj.id === node.id) || selectedObj?.id === node.id ? 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 = 'black'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.font = '4px 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); + } + name += ellipsis; + } + + // add label + ctx.fillText(name, node.x, node.y); + }, [selectedObj, selectedObjects, isPathResponse]) + + const nodePointerAreaPaint = useCallback((node: GraphNode, color: string, ctx: CanvasRenderingContext2D) => { + if (!node.x || !node.y) return + + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(node.x, node.y, NODE_SIZE + 2 + ctx.lineWidth / 2, 0, 2 * Math.PI, false); + ctx.fill(); + }, []) + + const linkCanvasObject = useCallback((link: GraphLink, ctx: CanvasRenderingContext2D) => { + const start = link.source; + const end = link.target; + + if (!start.x || !start.y || !end.x || !end.y) return + + const sameNodesLinks = graph.Elements.links.filter(l => (l.source === start.id && l.target === end.id) || (l.target === start.id && l.source === end.id)) + const index = sameNodesLinks.findIndex(l => l.id === link.id) || 0 + const even = index % 2 === 0 + let curve + + ctx.strokeStyle = '#999999'; + ctx.lineWidth = 0.1; + + if (start.id === end.id) { + if (even) { + curve = Math.floor(-(index / 2)) - 3 + } else { + curve = Math.floor((index + 1) / 2) + 2 + } + + link.curve = curve * 0.1 + + const d = link.curve * NODE_SIZE * 11.67; + + ctx.beginPath(); + ctx.moveTo(start.x, start.y); + ctx.bezierCurveTo(start.x, start.y - d, start.x + d, start.y, start.x, start.y); + ctx.stroke(); + + // Midpoint of cubic bezier: P0=(sx,sy), P1=(sx,sy-d), P2=(sx+d,sy), P3=(sx,sy) + const textX = start.x + 0.375 * d; + const textY = start.y - 0.375 * d; + + // Tangent at midpoint is (0.75d, 0.75d), angle always resolves to PI/4 + let textAngle = Math.atan2(0.75 * d, 0.75 * d); + if (textAngle > Math.PI / 2) textAngle = -(Math.PI - textAngle); + if (textAngle < -Math.PI / 2) textAngle = -(-Math.PI - textAngle); + + ctx.save(); + ctx.translate(textX, textY); + ctx.rotate(textAngle); + } else { + if (even) { + curve = Math.floor(-(index / 2)) + } else { + curve = Math.floor((index + 1) / 2) + } + + link.curve = curve * 0.1 + + const dx = end.x - start.x; + const dy = end.y - start.y; + const dist = Math.sqrt(dx * dx + dy * dy); + const curvature = link.curve; + const cpX = (start.x + end.x) / 2 + (dy / dist) * curvature * dist; + const cpY = (start.y + end.y) / 2 + (-dx / dist) * curvature * dist; + + // Draw the quadratic bezier curve + ctx.beginPath(); + ctx.moveTo(start.x, start.y); + ctx.quadraticCurveTo(cpX, cpY, end.x, end.y); + ctx.stroke(); + + // Midpoint of quadratic bezier at t=0.5 + const t = 0.5; + const midX = (1 - t) * (1 - t) * start.x + 2 * (1 - t) * t * cpX + t * t * end.x; + const midY = (1 - t) * (1 - t) * start.y + 2 * (1 - t) * t * cpY + t * t * end.y; + + let textAngle = Math.atan2(end.y - start.y, end.x - start.x) + + // maintain label vertical orientation for legibility + if (textAngle > Math.PI / 2) textAngle = -(Math.PI - textAngle); + if (textAngle < -Math.PI / 2) textAngle = -(-Math.PI - textAngle); + + ctx.save(); + ctx.translate(midX, midY); + ctx.rotate(textAngle); + } + + // add label + ctx.globalAlpha = 1; + ctx.fillStyle = 'black'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.font = '2px Arial'; + ctx.fillText(link.relationship, 0, 0); + ctx.restore() + }, [graph.Elements.links]) + + const linkLineDash = useCallback((link: GraphLink) => { + if (link.data.isPath && !link.data.isPathSelected) return [5, 5] + return null + }, []) + + const linkPointerAreaPaint = useCallback((link: GraphLink, color: string, ctx: CanvasRenderingContext2D) => { + const start = link.source; + const end = link.target; + + if (!start.x || !start.y || !end.x || !end.y) return + + ctx.strokeStyle = color; + ctx.lineWidth = 1; + + const sameNodesLinks = graph.Elements.links.filter(l => (l.source === start.id && l.target === end.id) || (l.target === start.id && l.source === end.id)) + const index = sameNodesLinks.findIndex(l => l.id === link.id) || 0 + const even = index % 2 === 0 + let curve + + if (start.id === end.id) { + if (even) { + curve = Math.floor(-(index / 2)) - 3 + } else { + curve = Math.floor((index + 1) / 2) + 2 + } + + const curvature = curve * 0.1 + const d = curvature * NODE_SIZE * 11.67; + + ctx.beginPath(); + ctx.moveTo(start.x, start.y); + ctx.bezierCurveTo(start.x, start.y - d, start.x + d, start.y, start.x, start.y); + ctx.stroke(); + } else { + if (even) { + curve = Math.floor(-(index / 2)) + } else { + curve = Math.floor((index + 1) / 2) + } + + const curvature = curve * 0.1 + const dx = end.x - start.x; + const dy = end.y - start.y; + const dist = Math.sqrt(dx * dx + dy * dy); + const cpX = (start.x + end.x) / 2 + (dy / dist) * curvature * dist; + const cpY = (start.y + end.y) / 2 + (-dx / dist) * curvature * dist; + + ctx.beginPath(); + ctx.moveTo(start.x, start.y); + ctx.quadraticCurveTo(cpX, cpY, end.x, end.y); + ctx.stroke(); + } + }, [graph.Elements.links]) return ( -
+
-
- avoidOverlap(data.nodes as Position[])} - nodeVisibility="visible" - linkVisibility="visible" - linkCurvature="curve" - linkDirectionalArrowRelPos={1} - linkDirectionalArrowColor={(link) => (link.isPath || link.isPathSelected) ? PATH_COLOR : link.color} - linkDirectionalArrowLength={(link) => link.source.id === link.target.id ? 0 : (link.id === selectedObj?.id || link.isPathSelected) ? 3 : 2} - nodeRelSize={NODE_SIZE} - linkLineDash={(link) => (link.isPath && !link.isPathSelected) ? [5, 5] : []} - linkColor={(link) => (link.isPath || link.isPathSelected) ? PATH_COLOR : link.color} - linkWidth={(link) => (link.id === selectedObj?.id || link.isPathSelected) ? 2 : 1} - nodeCanvasObjectMode={() => 'after'} - linkCanvasObjectMode={() => 'after'} - nodeCanvasObject={(node, ctx) => { - if (!node.x || !node.y) return - - if (isPathResponse) { - if (node.isPathSelected) { - ctx.fillStyle = node.color; - ctx.strokeStyle = PATH_COLOR; - ctx.lineWidth = 1 - } else if (node.isPath) { - ctx.fillStyle = node.color; - ctx.strokeStyle = PATH_COLOR; - ctx.lineWidth = 0.5 - } else { - ctx.fillStyle = '#E5E5E5'; - ctx.strokeStyle = 'gray'; - ctx.lineWidth = 0.5 - } - } else if (isPathResponse === undefined) { - if (node.isPathSelected) { - ctx.fillStyle = node.color; - ctx.strokeStyle = PATH_COLOR; - ctx.lineWidth = 1 - } else if (node.isPath) { - ctx.fillStyle = node.color; - ctx.strokeStyle = PATH_COLOR; - ctx.lineWidth = 0.5 - } else { - ctx.fillStyle = node.color; - ctx.strokeStyle = 'black'; - ctx.lineWidth = selectedObjects.some(obj => obj.id === node.id) || selectedObj?.id === node.id ? 1 : 0.5 - } - } else { - ctx.fillStyle = node.color; - ctx.strokeStyle = 'black'; - ctx.lineWidth = selectedObjects.some(obj => obj.id === node.id) || selectedObj?.id === node.id ? 1 : 0.5 - } - - ctx.beginPath(); - ctx.arc(node.x, node.y, NODE_SIZE, 0, 2 * Math.PI, false); - ctx.stroke(); - ctx.fill(); - - ctx.fillStyle = 'black'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.font = '2px Arial'; - const textWidth = ctx.measureText(node.name).width; - const ellipsis = '...'; - const ellipsisWidth = ctx.measureText(ellipsis).width; - const nodeSize = NODE_SIZE * 2 - PADDING; - let { name } = { ...node } - - // 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); - } - name += ellipsis; - } - - // add label - ctx.fillText(name, node.x, node.y); - }} - linkCanvasObject={(link, ctx) => { - const start = link.source; - const end = link.target; - - if (!start.x || !start.y || !end.x || !end.y) return - - let textX, textY, angle; - - if (start.id === end.id) { - const radius = NODE_SIZE * link.curve * 6.2; - const angleOffset = -Math.PI / 4; // 45 degrees offset for text alignment - textX = start.x + radius * Math.cos(angleOffset); - textY = start.y + radius * Math.sin(angleOffset); - angle = -angleOffset; - } else { - const midX = (start.x + end.x) / 2; - const midY = (start.y + end.y) / 2; - const offset = link.curve / 2; - - angle = Math.atan2(end.y - start.y, end.x - start.x); - - // maintain label vertical orientation for legibility - if (angle > Math.PI / 2) angle = -(Math.PI - angle); - if (angle < -Math.PI / 2) angle = -(-Math.PI - angle); - - // Calculate perpendicular offset - const perpX = -Math.sin(angle) * offset; - const perpY = Math.cos(angle) * offset; - - // Adjust position to compensate for rotation around origin - const cos = Math.cos(angle); - const sin = Math.sin(angle); - textX = midX + perpX; - textY = midY + perpY; - const rotatedX = textX * cos + textY * sin; - const rotatedY = -textX * sin + textY * cos; - textX = rotatedX; - textY = rotatedY; - } - - // Setup text properties to measure background size - ctx.font = '2px Arial'; - const padding = 0.5; - // Get text width and height - const label = graph.LabelsMap.get(link.label)! - let { textWidth, textHeight } = label - - if (!textWidth || !textHeight) { - const { width, actualBoundingBoxAscent, actualBoundingBoxDescent } = ctx.measureText(link.label) - textWidth = width - textHeight = actualBoundingBoxAscent + actualBoundingBoxDescent - graph.LabelsMap.set(link.label, { ...label, textWidth, textHeight }) - } - - // Save the current context state - ctx.save(); - - // add label with background and rotation - ctx.rotate(angle); - - // Draw background - ctx.fillStyle = 'white'; - ctx.fillRect( - textX - textWidth / 2 - padding, - textY - textHeight / 2 - padding, - textWidth + padding * 2, - textHeight + padding * 2 - ); - - // Draw text - ctx.globalAlpha = 1; - ctx.fillStyle = 'black'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(link.label, textX, textY); - - ctx.restore(); // reset rotation - }} - onNodeClick={screenSize > Number(process.env.NEXT_PUBLIC_MOBILE_BREAKPOINT) || isShowPath ? handleNodeClick : handleRightClick} + Number(process.env.NEXT_PUBLIC_MOBILE_BREAKPOINT) || isShowPath ? (node: Node, _evt: MouseEvent) => handleNodeClick(node) : (node: Node, evt: MouseEvent) => handleRightClick(node, evt)} onNodeRightClick={handleRightClick} - onNodeDragEnd={(n, translate) => setPosition(prev => { - return prev && { x: prev.x + translate.x * (chartRef.current?.zoom() ?? 1), y: prev.y + translate.y * (chartRef.current?.zoom() ?? 1) } - })} onLinkClick={screenSize > Number(process.env.NEXT_PUBLIC_MOBILE_BREAKPOINT) && isPathResponse ? handleLinkClick : handleRightClick} onLinkRightClick={handleRightClick} - onBackgroundRightClick={unsetSelectedObjects} onBackgroundClick={unsetSelectedObjects} + onBackgroundRightClick={unsetSelectedObjects} onZoom={() => unsetSelectedObjects()} - onEngineStop={() => { - setCooldownTicks(0) - debugger - handleZoomToFit(chartRef, zoomedNodes.length === 1 ? 4 : 1, (n: NodeObject) => zoomedNodes.some(node => node.id === n.id)) - setZoomedNodes([]) - }} + onEngineStop={handleEngineStop} + nodeCanvasObject={nodeCanvasObject} + nodePointerAreaPaint={nodePointerAreaPaint} + linkCanvasObject={linkCanvasObject} + linkPointerAreaPaint={linkPointerAreaPaint} + linkLineDash={linkLineDash} cooldownTicks={cooldownTicks} - cooldownTime={6000} />
) diff --git a/app/components/model.ts b/app/components/model.ts index 130e60a7..61626443 100644 --- a/app/components/model.ts +++ b/app/components/model.ts @@ -1,4 +1,3 @@ -import { LinkObject, NodeObject } from 'react-force-graph-2d' import { Path } from '@/lib/utils' export interface GraphData { @@ -13,34 +12,35 @@ export interface Category { export interface Label { name: string, - textWidth: number, - textHeight: number, } -export type Node = NodeObject<{ +export interface Node { id: number, - name: string, category: string, color: string, + visible: boolean, collapsed: boolean, expand: boolean, - visible: boolean, isPathSelected: boolean, isPath: boolean, - [key: string]: any, -}> - -export type Link = LinkObject + color: string, + data: { + [key: string]: any, + }, +} const COLORS_ORDER_NAME = [ "blue", @@ -115,7 +115,7 @@ export class Graph { this.elements = elements; } - get EdgesMap(): Map { + get LinksMap(): Map { return this.linksMap; } @@ -163,18 +163,18 @@ export class Graph { node = { id: nodeData.id, - name: nodeData.name, color: getCategoryColorValue(category.index), category: category.name, expand: false, visible: true, collapsed, isPath: !!path, - isPathSelected: path?.start?.id === nodeData.id || path?.end?.id === nodeData.id + isPathSelected: path?.start?.id === nodeData.id || path?.end?.id === nodeData.id, + data: { + ...nodeData.properties, + } } - Object.entries(nodeData.properties).forEach(([key, value]) => { - node[key] = value - }) + this.nodesMap.set(nodeData.id, node) this.elements.nodes.push(node) newElements.nodes.push(node) @@ -197,14 +197,17 @@ export class Graph { if (!source) { source = { id: edgeData.src_node, - name: edgeData.src_node, color: getCategoryColorValue(), category: "", expand: false, visible: true, collapsed, isPath: !!path, - isPathSelected: path?.start?.id === edgeData.src_node || path?.end?.id === edgeData.src_node + isPathSelected: path?.start?.id === edgeData.src_node || path?.end?.id === edgeData.src_node, + data: { + name: edgeData.src_node + } + } this.nodesMap.set(edgeData.src_node, source) } @@ -212,69 +215,44 @@ export class Graph { if (!target) { target = { id: edgeData.dest_node, - name: edgeData.dest_node, color: getCategoryColorValue(), category: "", expand: false, visible: true, collapsed, isPath: !!path, - isPathSelected: path?.start?.id === edgeData.dest_node || path?.end?.id === edgeData.dest_node + isPathSelected: path?.start?.id === edgeData.dest_node || path?.end?.id === edgeData.dest_node, + data: { + name: edgeData.dest_node + } } this.nodesMap.set(edgeData.dest_node, target) } let label = this.labelsMap.get(edgeData.relation) if (!label) { - label = { name: edgeData.relation, textWidth: 0, textHeight: 0 } + label = { name: edgeData.relation } this.labelsMap.set(edgeData.relation, label) this.labels.push(label) } link = { id: edgeData.id, - source, - target, + source: edgeData.src_node, + target: edgeData.dest_node, label: edgeData.relation, visible: true, - expand: false, - collapsed, + color: "#999999", isPathSelected: false, isPath: !!path, - curve: 0 + data: { ...edgeData.properties } } + this.linksMap.set(edgeData.id, link) this.elements.links.push(link) newElements.links.push(link) }) - newElements.links.forEach(link => { - const start = link.source - const end = link.target - const sameNodesLinks = this.Elements.links.filter(l => (l.source.id === start.id && l.target.id === end.id) || (l.target.id === start.id && l.source.id === end.id)) - const index = sameNodesLinks.findIndex(l => l.id === link.id) ?? 0 - const even = index % 2 === 0 - let curve - - if (start.id === end.id) { - if (even) { - curve = Math.floor(-(index / 2)) - 3 - } else { - curve = Math.floor((index + 1) / 2) + 2 - } - } else { - if (even) { - curve = Math.floor(-(index / 2)) - } else { - curve = Math.floor((index + 1) / 2) - } - - } - - link.curve = curve * 0.1 - }) - - return newElements } @@ -282,7 +260,7 @@ export class Graph { this.elements = { nodes: this.elements.nodes, links: this.elements.links.map(link => { - if (this.elements.nodes.map(n => n.id).includes(link.source.id) && this.elements.nodes.map(n => n.id).includes(link.target.id)) { + if (this.nodesMap.get(link.source) && this.nodesMap.get(link.target)) { return link } this.linksMap.delete(link.id) @@ -291,15 +269,15 @@ export class Graph { } public visibleLinks(visible: boolean, ids?: number[]) { - const elements = ids ? this.elements.links.filter(link => ids.includes(link.source.id) || ids.includes(link.target.id)) : this.elements.links + const elements = ids ? this.elements.links.filter(link => ids.includes(link.source) || ids.includes(link.target)) : this.elements.links elements.forEach(link => { - if (visible && this.elements.nodes.map(n => n.id).includes(link.source.id) && link.source.visible && this.elements.nodes.map(n => n.id).includes(link.target.id) && link.target.visible) { + if (visible && this.nodesMap.get(link.source)?.visible && this.nodesMap.get(link.target)?.visible) { // eslint-disable-next-line no-param-reassign link.visible = true } - if (!visible && ((this.elements.nodes.map(n => n.id).includes(link.source.id) && !link.source.visible) || (this.elements.nodes.map(n => n.id).includes(link.target.id) && !link.target.visible))) { + if (!visible && (this.nodesMap.get(link.source)?.visible === false || this.nodesMap.get(link.target)?.visible === false)) { // eslint-disable-next-line no-param-reassign link.visible = false } diff --git a/app/components/toolbar.tsx b/app/components/toolbar.tsx index 9769f25e..b4d11258 100644 --- a/app/components/toolbar.tsx +++ b/app/components/toolbar.tsx @@ -1,31 +1,43 @@ import { Download, Fullscreen, ZoomIn, ZoomOut } from "lucide-react"; import { cn } from "@/lib/utils" import { GraphRef } from "@/lib/utils"; +import { Switch } from "@/components/ui/switch"; interface Props { - chartRef: GraphRef + canvasRef: GraphRef className?: string handleDownloadImage?: () => void + setCooldownTicks: (ticks?: 0) => void + cooldownTicks: number | undefined } -export function Toolbar({ chartRef, className, handleDownloadImage }: Props) { +export function Toolbar({ canvasRef, className, handleDownloadImage, setCooldownTicks, cooldownTicks }: Props) { const handleZoomClick = (changefactor: number) => { - const chart = chartRef.current - if (chart) { - chart.zoom(chart.zoom() * changefactor) + const canvas = canvasRef.current + + if (canvas) { + canvas.zoom(canvas.getZoom() * changefactor) } } const handleCenterClick = () => { - const chart = chartRef.current - if (chart) { - chart.zoomToFit(1000, 40) + const canvas = canvasRef.current + + if (canvas) { + canvas.zoomToFit() } } return ( -
+
+ { + setCooldownTicks(cooldownTicks !== 0 ? 0 : undefined) + }} + />