diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json b/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json index 350e3898a39ee..b9ebf68986e7c 100644 --- a/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json +++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json @@ -92,6 +92,7 @@ "downloadImage": "Download graph image", "downloadImageError": "Failed to download graph image.", "downloadImageErrorTitle": "Download Failed", + "manualLayout": "Rearrange", "otherDagRuns": "+Other Dag Runs", "taskCount_one": "{{count}} Task", "taskCount_other": "{{count}} Tasks", diff --git a/airflow-core/src/airflow/ui/src/components/Graph/Edge.test.tsx b/airflow-core/src/airflow/ui/src/components/Graph/Edge.test.tsx new file mode 100644 index 0000000000000..3ff80baae4349 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/components/Graph/Edge.test.tsx @@ -0,0 +1,138 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { render } from "@testing-library/react"; +import { Position } from "@xyflow/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import CustomEdge from "./Edge"; +import type { EdgeData } from "./reactflowUtils"; + +const chakraMocks = vi.hoisted(() => ({ + useToken: vi.fn(() => ["#111111", "#222222", "#333333", "#444444", "#555555", "#666666"]), +})); +const xyFlowMocks = vi.hoisted(() => ({ + BaseEdge: vi.fn(() => null), + getSmoothStepPath: vi.fn(() => ["M 0 0"]), + useNodesData: vi.fn(() => [{ data: { isSelected: false } }, { data: { isSelected: false } }]), + useStore: vi.fn(), +})); + +vi.mock("@chakra-ui/react", async () => { + const actual = await vi.importActual("@chakra-ui/react"); + + return { ...actual, useToken: chakraMocks.useToken }; +}); + +vi.mock("@xyflow/react", async () => { + const actual = await vi.importActual("@xyflow/react"); + + return { + ...actual, + BaseEdge: xyFlowMocks.BaseEdge, + getSmoothStepPath: xyFlowMocks.getSmoothStepPath, + useNodesData: xyFlowMocks.useNodesData, + useStore: xyFlowMocks.useStore, + }; +}); + +type MockNodeLookupEntry = { + internals: { userNode: { dragging?: boolean } }; +}; + +type MockStoreState = { + nodeLookup: Map; +}; + +let nodeLookup = new Map(); + +const buildEdgeProps = ({ + source, + target, +}: { + source: string; + target: string; +}): { + data: EdgeData; + id: string; + source: string; + sourcePosition: Position.Right; + sourceX: number; + sourceY: number; + target: string; + targetPosition: Position.Left; + targetX: number; + targetY: number; + type: "custom"; +} => ({ + data: { + isManualLayout: true, + rest: { + id: `edge-${source}-${target}`, + sources: [source], + targets: [target], + }, + }, + id: `edge-${source}-${target}`, + source, + sourcePosition: Position.Right, + sourceX: 0, + sourceY: 0, + target, + targetPosition: Position.Left, + targetX: 100, + targetY: 100, + type: "custom", +}); + +const getLastBaseEdgeStroke = () => { + const baseEdgeCalls = vi.mocked(xyFlowMocks.BaseEdge).mock.calls as unknown as Array< + [{ style?: { stroke?: string | undefined } | undefined }] + >; + + return baseEdgeCalls.at(-1)?.[0]?.style?.stroke; +}; + +describe("CustomEdge", () => { + beforeEach(() => { + vi.clearAllMocks(); + nodeLookup = new Map([ + ["task_1", { internals: { userNode: { dragging: true } } }], + ["task_2", { internals: { userNode: { dragging: false } } }], + ["task_3", { internals: { userNode: { dragging: false } } }], + ["task_4", { internals: { userNode: { dragging: false } } }], + ]); + + vi.mocked(xyFlowMocks.useStore).mockImplementation((selector: (state: MockStoreState) => boolean) => + selector({ nodeLookup }), + ); + }); + + it("uses drag preview colors only for edges connected to dragged nodes", () => { + render(); + + const connectedStroke = getLastBaseEdgeStroke(); + + render(); + + const unconnectedStroke = getLastBaseEdgeStroke(); + + expect(connectedStroke).toBe("#444444"); + expect(unconnectedStroke).toBe("#111111"); + }); +}); diff --git a/airflow-core/src/airflow/ui/src/components/Graph/Edge.tsx b/airflow-core/src/airflow/ui/src/components/Graph/Edge.tsx index a4afa95c168bf..e350ab3758e8c 100644 --- a/airflow-core/src/airflow/ui/src/components/Graph/Edge.tsx +++ b/airflow-core/src/airflow/ui/src/components/Graph/Edge.tsx @@ -19,38 +19,95 @@ import { Text, useToken } from "@chakra-ui/react"; import { Group } from "@visx/group"; import { LinePath } from "@visx/shape"; -import type { Edge as EdgeType } from "@xyflow/react"; -import { useNodesData } from "@xyflow/react"; +import { BaseEdge, getSmoothStepPath, useNodesData, useStore } from "@xyflow/react"; +import type { Edge as EdgeType, EdgeProps } from "@xyflow/react"; import type { ElkPoint } from "elkjs"; import { opacityStyle } from "./graphTypes"; import type { EdgeData } from "./reactflowUtils"; -type Props = EdgeType; +type Props = EdgeProps>; -const CustomEdge = ({ data, source, target }: Props) => { - const [strokeColor, blueColor, dataEdgeColor] = useToken("colors", [ - "border.inverted", - "blue.500", - "purple.500", - ]); +const CustomEdge = ({ + data, + id, + markerEnd, + markerStart, + source, + sourcePosition, + sourceX, + sourceY, + target, + targetPosition, + targetX, + targetY, +}: Props) => { + const [ + strokeColor, + blueColor, + dataEdgeColor, + draggingStrokeColor, + draggingBlueColor, + draggingDataEdgeColor, + ] = useToken("colors", ["border.inverted", "blue.500", "purple.500", "border", "blue.400", "purple.400"]); // Read isSelected directly from the node store so that selection changes // don't require the parent to rebuild and pass down a new edges array. // useNodesData subscribes to data changes for these specific node IDs only. const nodesData = useNodesData([source, target]); const isSelected = nodesData.some((node) => Boolean(node.data.isSelected)); + const isConnectedNodeDragging = useStore((state) => { + const sourceDragging = state.nodeLookup.get(source)?.internals.userNode.dragging ?? false; + const targetDragging = state.nodeLookup.get(target)?.internals.userNode.dragging ?? false; + + return sourceDragging || targetDragging; + }); if (data === undefined) { return undefined; } - const { rest } = data; + const { isManualLayout = false, rest } = data; + const isDragPreview = isManualLayout && isConnectedNodeDragging; + const selectedEdgeStrokeColor = rest.edgeType === "data" ? dataEdgeColor : blueColor; + const selectedDraggingEdgeStrokeColor = + rest.edgeType === "data" ? draggingDataEdgeColor : draggingBlueColor; + let edgeStrokeColor = (isSelected ? selectedEdgeStrokeColor : strokeColor) ?? "currentColor"; + + if (isDragPreview) { + edgeStrokeColor = (isSelected ? selectedDraggingEdgeStrokeColor : draggingStrokeColor) ?? "currentColor"; + } - const edgeStrokeColor = isSelected ? (rest.edgeType === "data" ? dataEdgeColor : blueColor) : strokeColor; + if (isManualLayout) { + const [path] = getSmoothStepPath({ + sourcePosition, + sourceX, + sourceY, + targetPosition, + targetX, + targetY, + }); + + return ( + + + + ); + } return ( - - {rest.labels?.map(({ height, id, text, width, x, y }) => { + + {rest.labels?.map(({ height, id: labelId, text, width, x, y }) => { if (y === undefined || x === undefined) { return undefined; } @@ -59,7 +116,7 @@ const CustomEdge = ({ data, source, target }: Props) => { + first.position.x < second.position.x + (second.width ?? 0) && + first.position.x + (first.width ?? 0) > second.position.x && + first.position.y < second.position.y + (second.height ?? 0) && + first.position.y + (first.height ?? 0) > second.position.y; + +const getGraphControlsProps = () => { + const children = vi.mocked(ReactFlow).mock.lastCall?.[0]?.children; + let graphControlsProps: ComponentProps | undefined; + + Children.forEach(children, (child) => { + if (isValidElement(child) && child.type === GraphControls) { + graphControlsProps = child.props as ComponentProps; + } + }); + + return graphControlsProps; +}; + +const getRenderedNodes = () => vi.mocked(ReactFlow).mock.lastCall?.[0]?.nodes ?? []; + +const getRenderedNodeById = (nodeId: string) => getRenderedNodes().find((node) => node.id === nodeId); describe("Graph", () => { + beforeEach(() => { + mockParams = { dagId: "test_dag" }; + localStorage.clear(); + vi.mocked(ReactFlow).mockClear(); + vi.mocked(useGraphLayout).mockReturnValue({ + data: { edges: [mockGraphEdge], nodes: [mockGraphNode, mockGraphNodeTwo] }, + isPending: false, + } as ReturnType); + }); + it("passes states to useGridTiSummariesStream when a runId is present", () => { mockParams = { dagId: "test_dag", runId: "run_1" }; vi.mocked(useDagRunServiceGetDagRun).mockReturnValue({ @@ -148,4 +236,227 @@ describe("Graph", () => { }), ); }); + + it("sets manual layout edge mode only while manual mode is active", () => { + render(, { wrapper: Wrapper }); + + expect(vi.mocked(ReactFlow).mock.lastCall?.[0]?.edges?.[0]?.data?.isManualLayout).toBe(false); + + act(() => { + getGraphControlsProps()?.onToggleManualLayout(); + }); + + expect(vi.mocked(ReactFlow).mock.lastCall?.[0]?.edges?.[0]?.data?.isManualLayout).toBe(true); + + act(() => { + getGraphControlsProps()?.onToggleManualLayout(); + }); + + expect(vi.mocked(ReactFlow).mock.lastCall?.[0]?.edges?.[0]?.data?.isManualLayout).toBe(false); + }); + + it("restores ELK positions when switching manual layout off", () => { + render(, { wrapper: Wrapper }); + + act(() => { + getGraphControlsProps()?.onToggleManualLayout(); + }); + + const onNodesChange = vi.mocked(ReactFlow).mock.lastCall?.[0]?.onNodesChange; + + act(() => { + onNodesChange?.([{ dragging: false, id: "task_1", position: changedNodePosition, type: "position" }]); + }); + + expect(vi.mocked(ReactFlow).mock.lastCall?.[0]?.nodes).toEqual( + expect.arrayContaining([expect.objectContaining({ id: "task_1", position: changedNodePosition })]), + ); + + act(() => { + getGraphControlsProps()?.onToggleManualLayout(); + }); + + expect(vi.mocked(ReactFlow).mock.lastCall?.[0]?.nodesDraggable).toBe(false); + expect(vi.mocked(ReactFlow).mock.lastCall?.[0]?.nodes).toEqual( + expect.arrayContaining([expect.objectContaining({ id: "task_1", position: { x: 10, y: 20 } })]), + ); + }); + + it("keeps the dropped position when releasing with no overlap", () => { + render(, { wrapper: Wrapper }); + + act(() => { + getGraphControlsProps()?.onToggleManualLayout(); + }); + + const onNodesChange = vi.mocked(ReactFlow).mock.lastCall?.[0]?.onNodesChange; + + act(() => { + onNodesChange?.([{ dragging: true, id: "task_1", position: freeNodePosition, type: "position" }]); + }); + + expect(getRenderedNodeById("task_1")?.position).toEqual(freeNodePosition); + + act(() => { + onNodesChange?.([{ dragging: false, id: "task_1", position: freeNodePosition, type: "position" }]); + }); + + expect(getRenderedNodeById("task_1")?.position).toEqual(freeNodePosition); + }); + + it("resolves simultaneous drop changes deterministically", () => { + render(, { wrapper: Wrapper }); + + act(() => { + getGraphControlsProps()?.onToggleManualLayout(); + }); + + const onNodesChange = vi.mocked(ReactFlow).mock.lastCall?.[0]?.onNodesChange; + + act(() => { + onNodesChange?.([ + { dragging: false, id: "task_1", position: simultaneousDropPosition, type: "position" }, + { dragging: false, id: "task_2", position: simultaneousDropPosition, type: "position" }, + ]); + }); + + let firstNode = getRenderedNodeById("task_1"); + let secondNode = getRenderedNodeById("task_2"); + const firstPassHasOverlap = + firstNode !== undefined && secondNode !== undefined + ? nodesOverlap({ first: firstNode, second: secondNode }) + : true; + + expect(firstPassHasOverlap).toBe(false); + + const firstPassPositions = { + task1: firstNode?.position, + task2: secondNode?.position, + }; + + act(() => { + getGraphControlsProps()?.onToggleManualLayout(); + getGraphControlsProps()?.onToggleManualLayout(); + }); + + const rerunOnNodesChange = vi.mocked(ReactFlow).mock.lastCall?.[0]?.onNodesChange; + + act(() => { + rerunOnNodesChange?.([ + { dragging: false, id: "task_1", position: simultaneousDropPosition, type: "position" }, + { dragging: false, id: "task_2", position: simultaneousDropPosition, type: "position" }, + ]); + }); + + firstNode = getRenderedNodeById("task_1"); + secondNode = getRenderedNodeById("task_2"); + const secondPassPositions = { + task1: firstNode?.position, + task2: secondNode?.position, + }; + + expect(secondPassPositions).toEqual(firstPassPositions); + }); + + it("keeps the dropped position when no free slot is found within the search radius", () => { + const blockedDropPosition = { x: 100, y: 100 }; + const blockedPositions = [blockedDropPosition]; + + for (let radius = 1; radius <= testMaxSearchRadius; radius += 1) { + for (const direction of testRadialDirections) { + blockedPositions.push({ + x: blockedDropPosition.x + direction.x * radius * testSearchGridSize, + y: blockedDropPosition.y + direction.y * radius * testSearchGridSize, + }); + } + } + + const blockingNodes = blockedPositions.map((position, index) => ({ + data: { id: `task_blocker_${index}`, label: `Task Blocker ${index}`, type: "task" }, + height: 80, + id: `task_blocker_${index}`, + position, + type: "task", + width: 100, + })); + + vi.mocked(useGraphLayout).mockReturnValue({ + data: { edges: [mockGraphEdge], nodes: [mockGraphNode, ...blockingNodes] }, + isPending: false, + } as ReturnType); + + render(, { wrapper: Wrapper }); + + act(() => { + getGraphControlsProps()?.onToggleManualLayout(); + }); + + const onNodesChange = vi.mocked(ReactFlow).mock.lastCall?.[0]?.onNodesChange; + + act(() => { + onNodesChange?.([{ dragging: false, id: "task_1", position: blockedDropPosition, type: "position" }]); + }); + + expect(getRenderedNodeById("task_1")?.position).toEqual(blockedDropPosition); + }); + + it("keeps overlap during drag preview and snaps to a nearby open position on release", () => { + render(, { wrapper: Wrapper }); + + act(() => { + getGraphControlsProps()?.onToggleManualLayout(); + }); + + const onNodesChange = vi.mocked(ReactFlow).mock.lastCall?.[0]?.onNodesChange; + + act(() => { + onNodesChange?.([ + { dragging: true, id: "task_1", position: overlappingNodePosition, type: "position" }, + ]); + }); + + let renderedNodes = vi.mocked(ReactFlow).mock.lastCall?.[0]?.nodes ?? []; + let firstNode = renderedNodes.find((node) => node.id === "task_1"); + let secondNode = renderedNodes.find((node) => node.id === "task_2"); + let hasOverlap = + firstNode !== undefined && secondNode !== undefined + ? nodesOverlap({ first: firstNode, second: secondNode }) + : true; + + expect(firstNode?.position).toEqual(overlappingNodePosition); + expect(hasOverlap).toBe(true); + + act(() => { + onNodesChange?.([ + { dragging: false, id: "task_1", position: overlappingNodePosition, type: "position" }, + ]); + }); + + renderedNodes = vi.mocked(ReactFlow).mock.lastCall?.[0]?.nodes ?? []; + firstNode = renderedNodes.find((node) => node.id === "task_1"); + secondNode = renderedNodes.find((node) => node.id === "task_2"); + hasOverlap = + firstNode !== undefined && secondNode !== undefined + ? nodesOverlap({ first: firstNode, second: secondNode }) + : true; + + expect(firstNode?.position).not.toEqual(overlappingNodePosition); + expect(hasOverlap).toBe(false); + }); + + it("forces manual layout mode back to auto after component re-mount", () => { + const { unmount } = render(, { wrapper: Wrapper }); + + act(() => { + getGraphControlsProps()?.onToggleManualLayout(); + }); + + expect(vi.mocked(ReactFlow).mock.lastCall?.[0]?.nodesDraggable).toBe(true); + unmount(); + + render(, { wrapper: Wrapper }); + + expect(vi.mocked(ReactFlow).mock.lastCall?.[0]?.nodesDraggable).toBe(false); + expect(getGraphControlsProps()?.isManualLayout).toBe(false); + }); }); diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Graph/Graph.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/Graph/Graph.tsx index 8c2ff6574530d..c47c71c60e444 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/Graph/Graph.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Details/Graph/Graph.tsx @@ -17,9 +17,16 @@ * under the License. */ import { Box, Spinner, useToken } from "@chakra-ui/react"; -import { ReactFlow, Background, MiniMap, type Node as ReactFlowNode } from "@xyflow/react"; +import { + applyNodeChanges, + Background, + MiniMap, + ReactFlow, + type Node as ReactFlowNode, + type NodeChange, +} from "@xyflow/react"; import "@xyflow/react/dist/style.css"; -import { useEffect } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useParams } from "react-router-dom"; import { useLocalStorage } from "usehooks-ts"; @@ -47,7 +54,123 @@ import { nodeColor } from "./utils/nodeColor"; // Hoisted to module scope so ReactFlow receives a stable reference and skips // its internal shallow-equality check on every render. -const defaultEdgeOptions = { zIndex: 1 }; +const defaultEdgeOptions = { + interactionWidth: 0, + zIndex: -1, +}; + +const collisionPadding = 12; +const searchGridSize = 24; +const maxSearchRadius = 24; +const fallbackNodeWidth = 100; +const fallbackNodeHeight = 64; +const radialDirections = [ + { x: 1, y: 0 }, + { x: -1, y: 0 }, + { x: 0, y: 1 }, + { x: 0, y: -1 }, + { x: 1, y: 1 }, + { x: 1, y: -1 }, + { x: -1, y: 1 }, + { x: -1, y: -1 }, +]; + +const getNodeDimensions = (node: ReactFlowNode) => ({ + height: node.height ?? node.data.height ?? fallbackNodeHeight, + width: node.width ?? node.data.width ?? fallbackNodeWidth, +}); + +const nodesOverlap = ( + firstNode: ReactFlowNode, + secondNode: ReactFlowNode, + firstPosition = firstNode.position, +) => { + const firstDimensions = getNodeDimensions(firstNode); + const secondDimensions = getNodeDimensions(secondNode); + + return ( + firstPosition.x < secondNode.position.x + secondDimensions.width + collisionPadding && + firstPosition.x + firstDimensions.width + collisionPadding > secondNode.position.x && + firstPosition.y < secondNode.position.y + secondDimensions.height + collisionPadding && + firstPosition.y + firstDimensions.height + collisionPadding > secondNode.position.y + ); +}; + +const findNearestOpenPosition = ({ + movedNode, + nodes, +}: { + movedNode: ReactFlowNode; + nodes: Array>; +}) => { + const otherNodes = nodes.filter((node) => node.id !== movedNode.id); + + const isPositionFree = (position: { x: number; y: number }) => + otherNodes.every((node) => !nodesOverlap(movedNode, node, position)); + + if (isPositionFree(movedNode.position)) { + return movedNode.position; + } + + for (let radius = 1; radius <= maxSearchRadius; radius += 1) { + for (const direction of radialDirections) { + const position = { + x: movedNode.position.x + direction.x * radius * searchGridSize, + y: movedNode.position.y + direction.y * radius * searchGridSize, + }; + + if (isPositionFree(position)) { + return position; + } + } + } + + return movedNode.position; +}; + +const avoidNodeOverlap = ({ + changes, + nodes, +}: { + changes: Array>>; + nodes: Array>; +}) => { + const movedNodeIds = new Set( + changes + .filter( + ( + change, + ): change is { + dragging: false; + id: string; + type: "position"; + } & NodeChange> => + change.type === "position" && change.dragging === false, + ) + .map((change) => change.id), + ); + + if (movedNodeIds.size === 0) { + return nodes; + } + + const resolvedNodes = [...nodes]; + + for (const movedNodeId of movedNodeIds) { + const movedNodeIndex = resolvedNodes.findIndex((node) => node.id === movedNodeId); + const movedNode = movedNodeIndex === -1 ? undefined : resolvedNodes[movedNodeIndex]; + + if (movedNode !== undefined) { + const position = findNearestOpenPosition({ movedNode, nodes: resolvedNodes }); + + if (position !== movedNode.position) { + resolvedNodes[movedNodeIndex] = { ...movedNode, position }; + } + } + } + + return resolvedNodes; +}; export const Graph = () => { const { colorMode = "light" } = useColorMode(); @@ -72,6 +195,8 @@ export const Graph = () => { const [dependencies] = useLocalStorage<"all" | "immediate" | "tasks">(dependenciesKey(dagId), "tasks"); const [direction] = useLocalStorage(directionKey(dagId), "RIGHT"); + const [isManualLayout, setIsManualLayout] = useState(false); + const [manualNodes, setManualNodes] = useState>>([]); const selectedColor = colorMode === "dark" ? selectedDarkColor : selectedLightColor; const { data: graphData = { edges: [], nodes: [] } } = useStructureServiceStructureData( @@ -131,19 +256,25 @@ export const Graph = () => { }); const gridTISummaries = runId ? summariesByRunId.get(runId) : undefined; - // Add task instances to the node data but without having to recalculate how the graph is laid out - const nodesWithTI = data?.nodes.map((node) => { - const taskInstance = gridTISummaries?.task_instances.find((ti) => ti.task_id === node.id); - - return { - ...node, - data: { - ...node.data, - isSelected: node.id === taskId || node.id === groupId || node.id === `dag:${dagId}`, - taskInstance, - }, - }; - }); + // Add task instances to the node data but without having to recalculate how the graph is laid out. + // Keep the mapped array stable while inputs are unchanged so manual-layout state sync does not + // retrigger itself in a render loop. + const nodesWithTI = useMemo( + () => + data?.nodes.map((node) => { + const taskInstance = gridTISummaries?.task_instances.find((ti) => ti.task_id === node.id); + + return { + ...node, + data: { + ...node.data, + isSelected: node.id === taskId || node.id === groupId || node.id === `dag:${dagId}`, + taskInstance, + }, + }; + }), + [dagId, data?.nodes, gridTISummaries, groupId, taskId], + ); const baseFilteredNodes = useGraphFilteredNodes(nodesWithTI, graphFilters); @@ -155,6 +286,64 @@ export const Graph = () => { taskId, }); + useEffect(() => { + if (!isManualLayout) { + return; + } + + setManualNodes((currentNodes) => { + if (nodes === undefined) { + return []; + } + + if (currentNodes.length === 0) { + return nodes; + } + + const currentPositionsById = new Map(currentNodes.map((node) => [node.id, node.position])); + + return nodes.map((node) => { + const position = currentPositionsById.get(node.id); + + return position === undefined ? node : { ...node, position }; + }); + }); + }, [isManualLayout, nodes]); + + const onNodesChange = (changes: Array>>) => { + if (!isManualLayout) { + return; + } + + setManualNodes((currentNodes) => { + const changedNodes = applyNodeChanges(changes, currentNodes); + + return avoidNodeOverlap({ changes, nodes: changedNodes }); + }); + }; + + const toggleManualLayout = () => { + setIsManualLayout((currentValue) => { + const nextValue = !currentValue; + + setManualNodes(nextValue ? (nodes ?? []) : []); + + return nextValue; + }); + }; + + const nodesToRender = isManualLayout ? manualNodes : (nodes ?? []); + const edgesToRender = useMemo( + () => + edges.map((edge) => ({ + ...edge, + data: { + ...edge.data, + isManualLayout, + }, + })), + [edges, isManualLayout], + ); const selectedNodeId = taskId ?? groupId; return ( @@ -177,25 +366,30 @@ export const Graph = () => { {/* Fit the viewport after each new ELK layout instead of using the fitView prop, which re-fires on every re-mount even when nodes are served from the React Query cache. */} - - + + {/* Hide the MiniMap for large graphs — it processes all nodes even when onlyRenderVisibleElements is set, adding meaningful paint cost with little benefit at 500+ nodes where the map is a near-solid blob. */} diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Graph/components/GraphControls.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/Graph/components/GraphControls.tsx index 84230df372080..72fbf96bc93f5 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/Graph/components/GraphControls.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Details/Graph/components/GraphControls.tsx @@ -16,27 +16,50 @@ * specific language governing permissions and limitations * under the License. */ -import { ControlButton, Controls, useReactFlow } from "@xyflow/react"; +import { Button } from "@chakra-ui/react"; +import { ControlButton, Controls, Panel, useReactFlow } from "@xyflow/react"; import { useTranslation } from "react-i18next"; import { MdCenterFocusStrong } from "react-icons/md"; -export const GraphControls = ({ selectedNodeId }: { readonly selectedNodeId?: string }) => { +type Props = { + readonly isManualLayout: boolean; + readonly onToggleManualLayout: () => void; + readonly selectedNodeId?: string; +}; + +export const GraphControls = ({ isManualLayout, onToggleManualLayout, selectedNodeId }: Props) => { const { t: translate } = useTranslation("components"); const { fitView } = useReactFlow(); + const manualLayoutLabel = translate("graph.manualLayout"); return ( - - {selectedNodeId === undefined ? undefined : ( - { - void fitView({ duration: 500, nodes: [{ id: selectedNodeId }], padding: 0.5 }); - }} - title={translate("graph.zoomToTask")} + <> + + {selectedNodeId === undefined ? undefined : ( + { + void fitView({ duration: 500, nodes: [{ id: selectedNodeId }], padding: 0.5 }); + }} + title={translate("graph.zoomToTask")} + > + + + )} + + + + + ); };