From 0379742b9e8f07d08d6d43ab71620a326b329f88 Mon Sep 17 00:00:00 2001 From: james-strauss-uwa Date: Tue, 1 Sep 2026 11:24:32 +0800 Subject: [PATCH 1/9] Fix graph drag state handling --- src/GraphRenderer.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/GraphRenderer.ts b/src/GraphRenderer.ts index 31e2d0e03..18c2ecf67 100644 --- a/src/GraphRenderer.ts +++ b/src/GraphRenderer.ts @@ -1133,6 +1133,7 @@ export class GraphRenderer { // these two are needed to keep track of these modifiers for the mouse move and release event GraphRenderer.altSelect = event.altKey GraphRenderer.shiftSelect = event.shiftKey + GraphRenderer.dragCurrentPosition = {x:event.pageX,y:event.pageY} // if no node is selected, or we are dragging using middle mouse, then we are dragging the background if(object === null || event.button === 1){ @@ -1146,7 +1147,6 @@ export class GraphRenderer { GraphRenderer.draggingObject(object); GraphRenderer.nodeDragElement = event.target GraphRenderer.dragStartPosition = {x:event.pageX,y:event.pageY} - GraphRenderer.dragCurrentPosition = {x:event.pageX,y:event.pageY} //checking if the node is inside of a construct, if so, fetching it's parent if(object instanceof Node && object.getParent() !== null){ @@ -1213,7 +1213,7 @@ export class GraphRenderer { const dragStartPos = GraphRenderer.dragStartPosition ? GraphRenderer.dragStartPosition : {x:0,y:0} //check and note if the mouse has moved - GraphRenderer.simpleSelect = dragStartPos.x - moveDistance.x < 5 && dragStartPos.y - moveDistance.y < 5 + GraphRenderer.simpleSelect = Math.abs(e.pageX - dragStartPos.x) < 5 && Math.abs(e.pageY - dragStartPos.y) < 5 //this is to prevent the de-parent transition effect, which we don't want in this case $('.node.transition').removeClass('transition') From 200a07257586181a86b01618e214ce4f1c96982a Mon Sep 17 00:00:00 2001 From: james-strauss-uwa Date: Tue, 1 Sep 2026 11:31:52 +0800 Subject: [PATCH 2/9] Prevent zero graph zoom scale --- src/GraphRenderer.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/GraphRenderer.ts b/src/GraphRenderer.ts index 18c2ecf67..0456fb8b0 100644 --- a/src/GraphRenderer.ts +++ b/src/GraphRenderer.ts @@ -1069,12 +1069,9 @@ export class GraphRenderer { const xsb = GraphRenderer.SCREEN_TO_GRAPH_POSITION_X(null) const ysb = GraphRenderer.SCREEN_TO_GRAPH_POSITION_Y(null) - eagle.globalScale(eagle.globalScale()*(1-(wheelDelta/zoomDivisor))); - - if(eagle.globalScale()<0){ - //prevent negative scale which results in an inverted graph - eagle.globalScale(Math.abs(eagle.globalScale())) - } + const MIN_GRAPH_SCALE = 0.01; + const newScale = eagle.globalScale()*(1-(wheelDelta/zoomDivisor)); + eagle.globalScale(Math.max(MIN_GRAPH_SCALE, Math.abs(newScale))); const xsa = GraphRenderer.SCREEN_TO_GRAPH_POSITION_X(null) const ysa = GraphRenderer.SCREEN_TO_GRAPH_POSITION_Y(null) From 69c80ca0fc5fcefbaa7c6a29d4f7ef5f7215a3fd Mon Sep 17 00:00:00 2001 From: james-strauss-uwa Date: Tue, 1 Sep 2026 11:40:17 +0800 Subject: [PATCH 3/9] Detect cycles in node parent traversal --- src/GraphRenderer.ts | 37 +++++++++++++++---------------------- 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/src/GraphRenderer.ts b/src/GraphRenderer.ts index 0456fb8b0..58e1fe26c 100644 --- a/src/GraphRenderer.ts +++ b/src/GraphRenderer.ts @@ -1799,36 +1799,30 @@ export class GraphRenderer { static isAncestor(node : Node | null, possibleAncestor : Node) : boolean { let n : Node | null = node; - let iterations = 0; - const MAX_ITERATIONS = 32; + const visitedIds = new Set(); // keep a set of visited node IDs to detect cycles and avoid infinite loops if (n === null){ return false; } - while (true){ - if (iterations > MAX_ITERATIONS){ - console.error("too many iterations in isDescendent()"); + while (n !== null){ + const nodeId = n.getId(); + if (visitedIds.has(nodeId)){ + console.error("cycle detected in isAncestor()"); return false; } - - iterations += 1; + visitedIds.add(nodeId); // check if found - if (n.getId() === possibleAncestor.getId()){ + if (nodeId === possibleAncestor.getId()){ return true; } // otherwise keep traversing upwards - const newParent = n.getParent(); - - // if we reach a null parent, we are done looking - if (newParent === null){ - return false; - } - - n = newParent; + n = n.getParent(); } + + return false; } // update the parent of the given node @@ -2285,7 +2279,6 @@ export class GraphRenderer { // TODO: maybe replace the nodes parameter here with graph: LogicalGraph static findDepthOfNode(index: number, nodes : Node[]) : number { const eagle = Eagle.getInstance(); - const MAX_ITERATIONS = 10; if (index >= nodes.length){ console.warn("findDepthOfNode() with node index outside range of nodes. index:", index, "nodes.length", nodes.length); @@ -2296,19 +2289,19 @@ export class GraphRenderer { let node : Node | undefined = nodes[index]; let nodeId: NodeId; let nodeParent: Node | null = node.getParent(); - let iterations = 0; + const visitedIds = new Set(); // keep a set of visited node IDs to detect cycles and avoid infinite loops // follow the chain of parents while (nodeParent != null){ - if (iterations > MAX_ITERATIONS){ - console.error("too many iterations in findDepthOfNode()"); + nodeId = node.getId(); + if (visitedIds.has(nodeId)){ + console.error("cycle detected in findDepthOfNode()"); break; } - iterations += 1; + visitedIds.add(nodeId); depth += 1; depth += node.getDrawOrderHint() / 10; - nodeId = node.getId(); nodeParent = node.getParent(); if (nodeParent === null){ From 1ee2e34258dd13e00589d654a0f1bb5ab057b156 Mon Sep 17 00:00:00 2001 From: james-strauss-uwa Date: Tue, 1 Sep 2026 11:53:09 +0800 Subject: [PATCH 4/9] Ensure selection drag cleanup --- src/GraphRenderer.ts | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/src/GraphRenderer.ts b/src/GraphRenderer.ts index 58e1fe26c..77e121c9d 100644 --- a/src/GraphRenderer.ts +++ b/src/GraphRenderer.ts @@ -1265,34 +1265,30 @@ export class GraphRenderer { if (GraphRenderer.selectionRegionStart === null || GraphRenderer.selectionRegionEnd === null){ console.warn("endDrag called with null selection region points"); - return; - } + } else { + const nodes: (Node|Visual)[] = GraphRenderer.findNodesInRegion(GraphRenderer.selectionRegionStart.x, GraphRenderer.selectionRegionEnd.x, GraphRenderer.selectionRegionStart.y, GraphRenderer.selectionRegionEnd.y); - const nodes: (Node|Visual)[] = GraphRenderer.findNodesInRegion(GraphRenderer.selectionRegionStart.x, GraphRenderer.selectionRegionEnd.x, GraphRenderer.selectionRegionStart.y, GraphRenderer.selectionRegionEnd.y); - - //checking if there was no drag distance, if so we are clicking a single object and we will toggle its selection - if(Math.abs(GraphRenderer.selectionRegionStart.x-GraphRenderer.selectionRegionEnd.x)+Math.abs(GraphRenderer.selectionRegionStart.y - GraphRenderer.selectionRegionEnd.y)<3){ - if(!GraphRenderer.altSelect && object instanceof Node){ - GraphRenderer.selectNodeAndChildren(object,GraphRenderer.shiftSelect) + // checking if there was no drag distance, if so we are clicking a single object and we will toggle its selection + if(Math.abs(GraphRenderer.selectionRegionStart.x-GraphRenderer.selectionRegionEnd.x)+Math.abs(GraphRenderer.selectionRegionStart.y - GraphRenderer.selectionRegionEnd.y)<3){ + if(!GraphRenderer.altSelect && object instanceof Node){ + GraphRenderer.selectNodeAndChildren(object,GraphRenderer.shiftSelect) + } + eagle.editSelection(object,Eagle.FileType.Graph); + }else{ + GraphRenderer.selectInRegion(nodes); } - eagle.editSelection(object,Eagle.FileType.Graph); - }else{ - GraphRenderer.selectInRegion(nodes); + + // necessary to make un-collapsed nodes show up + eagle.logicalGraph.valueHasMutated(); } - //resetting some helper variables GraphRenderer.ctrlDrag = false; - GraphRenderer.selectionRegionStart = {x: 0, y: 0}; GraphRenderer.selectionRegionEnd = {x: 0, y: 0}; - GraphRenderer.isDraggingSelectionRegion = false; - //hide the selection rectangle + // hide the selection rectangle $('#selectionRectangle').hide() - - // necessary to make un-collapsed nodes show up - eagle.logicalGraph.valueHasMutated(); } // if we aren't multi selecting and the node has moved by a larger amount From a1490045f9247c138930e5804a6f2bbf27882910 Mon Sep 17 00:00:00 2001 From: james-strauss-uwa Date: Tue, 1 Sep 2026 12:18:28 +0800 Subject: [PATCH 5/9] Cache graph nodes during depth traversal --- src/GraphRenderer.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/GraphRenderer.ts b/src/GraphRenderer.ts index 77e121c9d..d35a88082 100644 --- a/src/GraphRenderer.ts +++ b/src/GraphRenderer.ts @@ -2249,12 +2249,13 @@ export class GraphRenderer { static depthFirstTraversalOfNodes(graph: LogicalGraph) : Node[] { // TODO: think about changing this to idPlusDepths (as above, re-use possible?) + const nodes = Array.from(graph.getNodes()); const indexPlusDepths : {index:number, depth:number}[] = []; const result : Node[] = []; // populate key plus depths - for (let i = 0 ; i < graph.getNumNodes() ; i++){ - const depth = GraphRenderer.findDepthOfNode(i, Array.from(graph.getNodes())); + for (let i = 0 ; i < nodes.length ; i++){ + const depth = GraphRenderer.findDepthOfNode(i, nodes); indexPlusDepths.push({index:i, depth:depth}); } @@ -2266,7 +2267,7 @@ export class GraphRenderer { // write nodes to result in sorted order for (const indexPlusDepth of indexPlusDepths){ - result.push(Array.from(graph.getNodes())[indexPlusDepth.index]); + result.push(nodes[indexPlusDepth.index]); } return result; From e57ac49806c052772eaa70c63f66d038eb3877ab Mon Sep 17 00:00:00 2001 From: james-strauss-uwa Date: Tue, 1 Sep 2026 14:29:14 +0800 Subject: [PATCH 6/9] Avoid square roots in port matching --- src/GraphRenderer.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/GraphRenderer.ts b/src/GraphRenderer.ts index d35a88082..a42f3e043 100644 --- a/src/GraphRenderer.ts +++ b/src/GraphRenderer.ts @@ -2410,7 +2410,7 @@ export class GraphRenderer { } static findNearestMatchingPort(positionX: number, positionY: number, _sourceNode: Node, _sourcePort: Field, sourcePortIsInput: boolean) : {node: Node | null, field: Field | null, validity: Errors.Validity} { - let minDistance: number = Number.MAX_SAFE_INTEGER; + let minDistanceSquared: number = Number.MAX_SAFE_INTEGER; let minNode: Node | null = null; let minPort: Field | null = null; let minValidity: Errors.Validity = Errors.Validity.Unknown; @@ -2435,22 +2435,23 @@ export class GraphRenderer { portX = node.getPosition().x - node.getRadius() + portX portY = node.getPosition().y - node.getRadius() + portY - // get distance to port - const distance = Math.sqrt( Math.pow(portX - positionX, 2) + Math.pow(portY - positionY, 2) ); + const deltaX = portX - positionX; + const deltaY = portY - positionY; + const distanceSquared = deltaX * deltaX + deltaY * deltaY; - if(distance > EagleConfig.NODE_SUGGESTION_RADIUS){ + if(distanceSquared > EagleConfig.NODE_SUGGESTION_RADIUS * EagleConfig.NODE_SUGGESTION_RADIUS){ continue } // remember this port if it the best so far - if (distance < minDistance){ + if (distanceSquared < minDistanceSquared){ minPort = port; minNode = node; - minDistance = distance; + minDistanceSquared = distanceSquared; minValidity = validity; } } - if (minDistance Date: Wed, 2 Sep 2026 17:07:30 +0800 Subject: [PATCH 7/9] Optimize edge containment lookup --- e2e/findEdgesContainedByNodes.spec.ts | 46 +++++++++++++++++++++++++++ src/GraphRenderer.ts | 41 ++++++++++-------------- 2 files changed, 63 insertions(+), 24 deletions(-) create mode 100644 e2e/findEdgesContainedByNodes.spec.ts diff --git a/e2e/findEdgesContainedByNodes.spec.ts b/e2e/findEdgesContainedByNodes.spec.ts new file mode 100644 index 000000000..58aec713d --- /dev/null +++ b/e2e/findEdgesContainedByNodes.spec.ts @@ -0,0 +1,46 @@ +import { test, expect } from '@playwright/test'; +import { TestHelpers } from './TestHelpers'; + +test('findEdgesContainedByNodes handles graph iterators and partial selections', async ({ page }) => { + await page.goto('http://localhost:8888/?tutorial=none'); + await expect(page).toHaveTitle(/EAGLE/); + + await TestHelpers.setUIMode(page, 'Expert'); + await TestHelpers.expandPalette(page, 0); + + await page.locator('#addPaletteNodeHelloWorldApp').click(); + await page.waitForTimeout(500); + await page.getByRole('button', { name: 'OK' }).click(); + + await page.locator('#palette_0_File').scrollIntoViewIfNeeded(); + await page.locator('#addPaletteNodeFile').click(); + await page.waitForTimeout(500); + await TestHelpers.dragEdge(page, 'HelloWorldApp', 'File'); + + const result = await page.evaluate(() => { + const eagle = (window as any).eagle; + const graph = eagle.logicalGraph(); + const nodes = Array.from(graph.getNodes()); + const edges = Array.from(graph.getEdges()); + const graphRenderer = (window as any).GraphRenderer; + + const allSelected = graphRenderer.findEdgesContainedByNodes( + graph.getEdges(), + graph.getNodes(), + ); + const oneSelected = graphRenderer.findEdgesContainedByNodes( + graph.getEdges(), + [nodes[0]], + ); + + return { + graphEdgeCount: edges.length, + allSelectedIds: allSelected.map((edge: any) => edge.getId()), + oneSelectedCount: oneSelected.length, + }; + }); + + expect(result.graphEdgeCount).toBeGreaterThan(0); + expect(result.allSelectedIds).toHaveLength(result.graphEdgeCount); + expect(result.oneSelectedCount).toBe(0); +}); diff --git a/src/GraphRenderer.ts b/src/GraphRenderer.ts index a42f3e043..7b080b214 100644 --- a/src/GraphRenderer.ts +++ b/src/GraphRenderer.ts @@ -1367,7 +1367,7 @@ export class GraphRenderer { const eagle = Eagle.getInstance() //filter passed selected objects to only nodes, so we can use this to find edges const nodes = selectObjects.filter(item => item instanceof Node) as Node[]; - const edges: Edge[] = GraphRenderer.findEdgesContainedByNodes(Array.from(eagle.logicalGraph().getEdges()), nodes); + const edges: Edge[] = GraphRenderer.findEdgesContainedByNodes(eagle.logicalGraph().getEdges(), nodes); const objects: (Node | Edge | Visual)[] = []; // depending on if its shift+ctrl or just shift we are either only adding or only removing nodes @@ -1603,34 +1603,27 @@ export class GraphRenderer { } } - // TODO: change input parameters to iterators - static findEdgesContainedByNodes(edges: Edge[], nodes: Node[]): Edge[]{ + // Accept iterables so callers can provide arrays or graph collection iterators. + static findEdgesContainedByNodes(edges: Iterable, nodes: Iterable): Edge[]{ const result: Edge[] = []; + const nodeIds = new Set(); - for (const edge of edges){ - const srcId = edge.getSrcNode().getId(); - const destId = edge.getDestNode().getId(); - let srcFound = false; - let destFound = false; - - for (const node of nodes){ - const inputApplication = node.getInputApplication(); - const outputApplication = node.getOutputApplication(); - - if ((node.getId() === srcId) || - (inputApplication !== null && inputApplication.getId() === srcId) || - (outputApplication !== null && outputApplication.getId() === srcId)){ - srcFound = true; - } + for (const node of nodes){ + nodeIds.add(node.getId()); - if ((node.getId() === destId) || - (inputApplication !== null && inputApplication.getId() === destId) || - (outputApplication !== null && outputApplication.getId() === destId)){ - destFound = true; - } + const inputApplication = node.getInputApplication(); + if (inputApplication !== null){ + nodeIds.add(inputApplication.getId()); } - if (srcFound && destFound){ + const outputApplication = node.getOutputApplication(); + if (outputApplication !== null){ + nodeIds.add(outputApplication.getId()); + } + } + + for (const edge of edges){ + if (nodeIds.has(edge.getSrcNode().getId()) && nodeIds.has(edge.getDestNode().getId())){ result.push(edge); } } From 4bed6ae33e1e9491ce218e58cd5ef2e2efd25a63 Mon Sep 17 00:00:00 2001 From: james-strauss-uwa Date: Wed, 2 Sep 2026 18:09:20 +0800 Subject: [PATCH 8/9] Fix nested parent depth traversal --- e2e/findEdgesContainedByNodes.spec.ts | 45 +++++++++++++++++++++++++++ src/GraphRenderer.ts | 11 ++----- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/e2e/findEdgesContainedByNodes.spec.ts b/e2e/findEdgesContainedByNodes.spec.ts index 58aec713d..498a2f27c 100644 --- a/e2e/findEdgesContainedByNodes.spec.ts +++ b/e2e/findEdgesContainedByNodes.spec.ts @@ -44,3 +44,48 @@ test('findEdgesContainedByNodes handles graph iterators and partial selections', expect(result.allSelectedIds).toHaveLength(result.graphEdgeCount); expect(result.oneSelectedCount).toBe(0); }); + +test('findDepthOfNode follows nested parents', async ({ page }) => { + await page.goto('http://localhost:8888/?tutorial=none'); + await expect(page).toHaveTitle(/EAGLE/); + + await TestHelpers.setUIMode(page, 'Expert'); + await TestHelpers.expandPalette(page, 0); + + await page.locator('#addPaletteNodeHelloWorldApp').click(); + await page.waitForTimeout(500); + await page.getByRole('button', { name: 'OK' }).click(); + + await page.locator('#palette_0_File').scrollIntoViewIfNeeded(); + await page.locator('#addPaletteNodeFile').click(); + await page.waitForTimeout(500); + await page.locator('#palette_0_File').scrollIntoViewIfNeeded(); + await page.locator('#addPaletteNodeFile').click(); + await page.waitForTimeout(500); + + const depth = await page.evaluate(() => { + const eagle = (window as any).eagle; + const graph = eagle.logicalGraph(); + eagle.setSelection(null, (window as any).Eagle.FileType.Graph); + const nodes = Array.from(graph.getNodes()); + const child = nodes[0]; + const parent = nodes[1]; + const grandparent = nodes[2]; + + child.setParent(parent); + parent.setParent(grandparent); + + const expectedDepth = 2 + ( + child.getDrawOrderHint() + + parent.getDrawOrderHint() + + grandparent.getDrawOrderHint() + ) / 10; + + return { + actual: (window as any).GraphRenderer.findDepthOfNode(0, nodes), + expected: expectedDepth, + }; + }); + + expect(depth.actual).toBe(depth.expected); +}); diff --git a/src/GraphRenderer.ts b/src/GraphRenderer.ts index 7b080b214..15c2ecdfe 100644 --- a/src/GraphRenderer.ts +++ b/src/GraphRenderer.ts @@ -2278,11 +2278,10 @@ export class GraphRenderer { let depth : number = 0; let node : Node | undefined = nodes[index]; let nodeId: NodeId; - let nodeParent: Node | null = node.getParent(); const visitedIds = new Set(); // keep a set of visited node IDs to detect cycles and avoid infinite loops // follow the chain of parents - while (nodeParent != null){ + while (node.getParent() !== null){ nodeId = node.getId(); if (visitedIds.has(nodeId)){ console.error("cycle detected in findDepthOfNode()"); @@ -2292,14 +2291,10 @@ export class GraphRenderer { visitedIds.add(nodeId); depth += 1; depth += node.getDrawOrderHint() / 10; - nodeParent = node.getParent(); - - if (nodeParent === null){ - return depth; - } + const nodeParent = node.getParent(); // TODO: could we use something else here? - node = GraphRenderer.findNodeWithId(nodeParent.getId(), nodes); + node = GraphRenderer.findNodeWithId(nodeParent!.getId(), nodes); if (typeof node === "undefined"){ console.error("Node", nodeId, "has parent", nodeParent ? nodeParent.getName() : null, "but call to findNodeWithId(", nodeParent.getId(), ") returned null"); From 9621ea2644c345abfe08d727939ec415c6ea5742 Mon Sep 17 00:00:00 2001 From: james-strauss-uwa Date: Wed, 9 Sep 2026 17:06:33 +0800 Subject: [PATCH 9/9] Fixed bug with undefined enum --- e2e/findEdgesContainedByNodes.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/findEdgesContainedByNodes.spec.ts b/e2e/findEdgesContainedByNodes.spec.ts index 498a2f27c..47e761358 100644 --- a/e2e/findEdgesContainedByNodes.spec.ts +++ b/e2e/findEdgesContainedByNodes.spec.ts @@ -66,7 +66,7 @@ test('findDepthOfNode follows nested parents', async ({ page }) => { const depth = await page.evaluate(() => { const eagle = (window as any).eagle; const graph = eagle.logicalGraph(); - eagle.setSelection(null, (window as any).Eagle.FileType.Graph); + eagle.setSelection(null, 'Graph'); const nodes = Array.from(graph.getNodes()); const child = nodes[0]; const parent = nodes[1];