diff --git a/e2e/example.spec.ts b/e2e/example.spec.ts index 54a906a4..083e3ba5 100644 --- a/e2e/example.spec.ts +++ b/e2e/example.spec.ts @@ -5,6 +5,8 @@ test('has title', async ({ page }) => { // Expect a title "to contain" a substring. await expect(page).toHaveTitle(/Playwright/); + + await page.close(); }); test('get started link', async ({ page }) => { @@ -15,4 +17,6 @@ test('get started link', async ({ page }) => { // Expects page to have a heading with the name of Installation. await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible(); + + await page.close(); }); diff --git a/e2e/findEdgesContainedByNodes.spec.ts b/e2e/findEdgesContainedByNodes.spec.ts new file mode 100644 index 00000000..32c1797c --- /dev/null +++ b/e2e/findEdgesContainedByNodes.spec.ts @@ -0,0 +1,95 @@ +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); + + await page.close(); +}); + +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, '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); + + await page.close(); +}); diff --git a/e2e/undo system/undoAfterFixAll.spec.ts b/e2e/undo system/undoAfterFixAll.spec.ts new file mode 100644 index 00000000..b7adaf9f --- /dev/null +++ b/e2e/undo system/undoAfterFixAll.spec.ts @@ -0,0 +1,102 @@ +import { test, expect } from '@playwright/test'; +import { TestHelpers } from '../TestHelpers'; + +// Regression test for: fix all issues, do other actions, undo — previously fixed +// errors/warnings must NOT reappear. +// https://github.com/ICRAR/EAGLE/issues/1667 +// +// Reproduction steps: +// 1. Open a graph that has fixable warnings/errors +// 2. Fix all issues with 'f' +// 3. Delete two nodes +// 4. Undo (z) once to restore one deleted node +// 5. Verify warning/error count has NOT reverted to pre-fix levels + +const GRAPH_URL = + 'https://raw.githubusercontent.com/ICRAR/EAGLE-graph-repo/refs/heads/master/examples/HelloWorld-Universe-function.graph'; + +test('Undo after fixAll does not reintroduce fixed errors', async ({ page }) => { + // load the graph via URL query param + await page.goto('http://localhost:8888/?tutorial=none&service=Url&url=' + GRAPH_URL); + + // wait for the graph-load notification + await page.locator('div[data-notify="container"]').waitFor({ state: 'attached' }); + await page.locator('button[data-notify="dismiss"]').click(); + await page.locator('div[data-notify="container"]').waitFor({ state: 'detached' }); + + // set UI mode so editing is allowed + await TestHelpers.setUIMode(page, 'Expert'); + + // capture the initial warning/error count before fix + const initialCount = await TestHelpers.getNumWarningsErrors(page); + console.log('Initial warnings+errors:', initialCount); + + // press 'f' to run fixAll + await page.press('body', 'f'); + + // dismiss any notification from fixAll + await page.locator('div[data-notify="container"]').waitFor({ state: 'attached' }); + await page.locator('button[data-notify="dismiss"]').click(); + await page.locator('div[data-notify="container"]').waitFor({ state: 'detached' }); + + // Wait until graph state settles after applying fixes. + await expect.poll(async () => await TestHelpers.getNumWarningsErrors(page)).toBeLessThanOrEqual(initialCount); + + // capture the post-fix count — this is the count that must be preserved after undo + const postFixCount = await TestHelpers.getNumWarningsErrors(page); + console.log('Post-fix warnings+errors:', postFixCount); + + // get the node count so we know how many to select for deletion + const nodeCount = await TestHelpers.getNodeCount(page); + expect(nodeCount).toBeGreaterThan(1); + + // select all nodes and delete them one at a time using the evaluate API to avoid + // needing to click on canvas nodes (positions may vary) + const nodeIds: string[] = await page.evaluate(() => { + const eagle = (window as any).eagle; + return Array.from(eagle.logicalGraph().nodes().keys()) as string[]; + }); + + // delete first node via the eagle API directly + await page.evaluate(async (id: string) => { + const eagle = (window as any).eagle; + const node = eagle.logicalGraph().getNodeById(id); + if (node) { + eagle.setSelection(node, (window as any).Eagle.FileType.Graph); + await eagle.deleteSelection(false, true, false); + } + }, nodeIds[0]); + await expect.poll(async () => await TestHelpers.getNodeCount(page)).toBe(nodeCount - 1); + + // delete second node + await page.evaluate(async (id: string) => { + const eagle = (window as any).eagle; + const node = eagle.logicalGraph().getNodeById(id); + if (node) { + eagle.setSelection(node, (window as any).Eagle.FileType.Graph); + await eagle.deleteSelection(false, true, false); + } + }, nodeIds[1]); + await expect.poll(async () => await TestHelpers.getNodeCount(page)).toBe(nodeCount - 2); + + // verify two nodes were deleted + const nodeCountAfterDelete = await TestHelpers.getNodeCount(page); + expect(nodeCountAfterDelete).toBe(nodeCount - 2); + + // undo once (restores one deletion) + await TestHelpers.undo(page); + await expect.poll(async () => await TestHelpers.getNodeCount(page)).toBe(nodeCount - 1); + + // one node should be back + const nodeCountAfterUndo = await TestHelpers.getNodeCount(page); + expect(nodeCountAfterUndo).toBe(nodeCount - 1); + + // THE CRITICAL ASSERTION: warning/error count after undo must equal the post-fix + // count, NOT the original pre-fix count. + const countAfterUndo = await TestHelpers.getNumWarningsErrors(page); + console.log('Warnings+errors after undo:', countAfterUndo); + + expect(countAfterUndo).toBe(postFixCount); + + await page.close(); +}); diff --git a/e2e/undo system/undoBoundary.spec.ts b/e2e/undo system/undoBoundary.spec.ts new file mode 100644 index 00000000..1afc70eb --- /dev/null +++ b/e2e/undo system/undoBoundary.spec.ts @@ -0,0 +1,128 @@ +import { test, expect } from '@playwright/test'; +import { TestHelpers } from '../TestHelpers'; + +test('Undo exhausted history warns on first boundary keypress', async ({ page }) => { + await page.goto('http://localhost:8888/?tutorial=none'); + + // set 'Expert' UI mode + await TestHelpers.setUIMode(page, 'Expert'); + + // start from an empty graph + await expect(await TestHelpers.getNodeCount(page)).toBe(0); + + // expand the first palette and add 4 nodes one-by-one + await TestHelpers.expandPalette(page, 0); + + await page.locator('#palette_0_HelloWorldApp').scrollIntoViewIfNeeded(); + await page.locator('#addPaletteNodeHelloWorldApp').click(); + + // agree to create a new graph with auto-generated name + await page.waitForTimeout(500); + await page.getByRole('button', { name: 'OK' }).click(); + await page.waitForTimeout(500); + await expect.poll(async () => await TestHelpers.getNodeCount(page)).toBe(1); + + for (let expectedCount = 2; expectedCount <= 4; expectedCount++) { + await page.locator('#palette_0_File').scrollIntoViewIfNeeded(); + await page.locator('#addPaletteNodeFile').click(); + await expect.poll(async () => await TestHelpers.getNodeCount(page)).toBe(expectedCount); + } + + // undo exactly 4 actions to get back to empty graph + for (let expectedCount = 3; expectedCount >= 0; expectedCount--) { + await TestHelpers.undo(page); + await expect.poll(async () => await TestHelpers.getNodeCount(page)).toBe(expectedCount); + } + + // clear any previous notifications so we only observe the boundary attempt + await page.evaluate(() => { + const $ = (window as any).$; + $('[data-notify="container"]').remove(); + }); + + // the first extra undo is still valid on a fresh graph because graph + // initialization metadata is recorded in undo history. + await TestHelpers.undo(page); + + const notification = page.locator('div[data-notify="container"]').first(); + await notification.waitFor({ state: 'attached' }); + await expect(notification.locator('[data-notify="title"]')).toContainText('Undo'); + await expect(notification.locator('span[data-notify="message"]')).toContainText('Added a new graph configuration'); + + // This metadata undo should not alter visible graph state. + await expect.poll(async () => await TestHelpers.getNodeCount(page)).toBe(0); + + await page.locator('button[data-notify="dismiss"]').first().click(); + + // The next undo is the real boundary. + await TestHelpers.undo(page); + + const boundaryNotification = page.locator('div[data-notify="container"]').first(); + await boundaryNotification.waitFor({ state: 'attached' }); + await expect(boundaryNotification.locator('[data-notify="title"]')).toContainText('Unable to Undo'); + await expect(boundaryNotification.locator('span[data-notify="message"]')).toContainText('No further history available'); + await expect.poll(async () => await TestHelpers.getNodeCount(page)).toBe(0); + + await page.locator('button[data-notify="dismiss"]').first().click(); + + await page.close(); +}); + +test('Undo still works after add undo add branch', async ({ page }) => { + await page.goto('http://localhost:8888/?tutorial=none'); + + await TestHelpers.setUIMode(page, 'Expert'); + await expect(await TestHelpers.getNodeCount(page)).toBe(0); + + await TestHelpers.expandPalette(page, 0); + + // First add creates the graph. + await page.locator('#palette_0_HelloWorldApp').scrollIntoViewIfNeeded(); + await page.locator('#addPaletteNodeHelloWorldApp').click(); + await page.waitForTimeout(500); + await page.getByRole('button', { name: 'OK' }).click(); + await page.waitForTimeout(500); + await expect.poll(async () => await TestHelpers.getNodeCount(page)).toBe(1); + + // Undo back to empty. + await TestHelpers.undo(page); + await expect.poll(async () => await TestHelpers.getNodeCount(page)).toBe(0); + + // Branch history by adding a different node. + await page.locator('#palette_0_File').scrollIntoViewIfNeeded(); + await page.locator('#addPaletteNodeFile').click(); + await expect.poll(async () => await TestHelpers.getNodeCount(page)).toBe(1); + + // Undo should still work immediately on the new branch. + await TestHelpers.undo(page); + await expect.poll(async () => await TestHelpers.getNodeCount(page)).toBe(0); + + // One more undo should still be valid because graph initialization metadata + // is part of the undo history. + await page.evaluate(() => { + const $ = (window as any).$; + $('[data-notify="container"]').remove(); + }); + + await TestHelpers.undo(page); + + const notification = page.locator('div[data-notify="container"]').first(); + await notification.waitFor({ state: 'attached' }); + await expect(notification.locator('[data-notify="title"]')).toContainText('Undo'); + await expect(notification.locator('span[data-notify="message"]')).toContainText('Added a new graph configuration'); + await expect.poll(async () => await TestHelpers.getNodeCount(page)).toBe(0); + + await page.locator('button[data-notify="dismiss"]').first().click(); + + await TestHelpers.undo(page); + + const boundaryNotification = page.locator('div[data-notify="container"]').first(); + await boundaryNotification.waitFor({ state: 'attached' }); + await expect(boundaryNotification.locator('[data-notify="title"]')).toContainText('Unable to Undo'); + await expect(boundaryNotification.locator('span[data-notify="message"]')).toContainText('No further history available'); + await expect.poll(async () => await TestHelpers.getNodeCount(page)).toBe(0); + + await page.locator('button[data-notify="dismiss"]').first().click(); + + await page.close(); +}); \ No newline at end of file diff --git a/e2e/undo system/undoDuplicateDetection.spec.ts b/e2e/undo system/undoDuplicateDetection.spec.ts new file mode 100644 index 00000000..7156b1c7 --- /dev/null +++ b/e2e/undo system/undoDuplicateDetection.spec.ts @@ -0,0 +1,52 @@ +import { test, expect } from '@playwright/test'; +import { TestHelpers } from '../TestHelpers'; + +test('Undo duplicate snapshot detection', async ({ page }) => { + await page.goto('http://localhost:8888/?tutorial=none'); + await expect(page).toHaveTitle(/EAGLE/); + + // set 'Expert' UI mode + await TestHelpers.setUIMode(page, 'Expert'); + + // expand the 'Builtin Components' palette and add a HelloWorldApp node + await TestHelpers.expandPalette(page, 0); + await page.locator('#palette_0_HelloWorldApp').scrollIntoViewIfNeeded(); + await page.locator('#addPaletteNodeHelloWorldApp').click(); + + // agree to create a new graph + await page.waitForTimeout(500); + await page.getByRole('button', { name: 'OK' }).click(); + await page.waitForTimeout(500); + + // record the undo front pointer after adding the node + const frontAfterAdd = await page.evaluate(() => { + return (window as any).eagle.undo().front(); + }); + + // push a snapshot with no graph change — should be a duplicate and aborted + await page.evaluate(() => { + const eagle = (window as any).eagle; + eagle.undo().pushSnapshot(eagle, 'duplicate push attempt'); + }); + + const frontAfterDuplicatePush = await page.evaluate(() => { + return (window as any).eagle.undo().front(); + }); + + // front pointer must not have advanced — duplicate was detected + await expect(frontAfterDuplicatePush).toBe(frontAfterAdd); + + // now add a File node to genuinely change the graph + await page.locator('#palette_0_File').scrollIntoViewIfNeeded(); + await page.locator('#addPaletteNodeFile').click(); + await page.waitForTimeout(500); + + const frontAfterFileAdd = await page.evaluate(() => { + return (window as any).eagle.undo().front(); + }); + + // front pointer must have advanced — the change was real + await expect(frontAfterFileAdd).not.toBe(frontAfterAdd); + + await page.close(); +}); diff --git a/e2e/undo system/undoRedo.spec.ts b/e2e/undo system/undoRedo.spec.ts new file mode 100644 index 00000000..443c6ee9 --- /dev/null +++ b/e2e/undo system/undoRedo.spec.ts @@ -0,0 +1,102 @@ +import { test, expect } from '@playwright/test'; +import { TestHelpers } from '../TestHelpers'; + +test('Undo', async ({ page }) => { + await page.goto('http://localhost:8888/?tutorial=none'); + + // set 'Expert' UI mode + await TestHelpers.setUIMode(page, 'Expert'); + + // check that number of nodes is 0 + const nodeCount = await TestHelpers.getNodeCount(page); + await expect(nodeCount).toBe(0); + + // expand the 'Builtin Components' palette + await TestHelpers.expandPalette(page, 0); + + // add a helloworld app to the graph by clicking it's icon + await page.locator('#palette_0_HelloWorldApp').scrollIntoViewIfNeeded(); + await page.locator('#addPaletteNodeHelloWorldApp').click(); + + // agree to create a new graph with it's auto-generated name + await page.waitForTimeout(500); + await page.getByRole('button', { name: 'OK' }).click(); + await page.waitForTimeout(500); + + // check that number of nodes is 1 + const nodeCount2 = await TestHelpers.getNodeCount(page); + await expect(nodeCount2).toBe(1); + + // scroll the file node into view in the palette + await page.locator('#palette_0_File').scrollIntoViewIfNeeded() + await page.locator('#addPaletteNodeFile').click(); + + // check that number of nodes is 2 + const nodeCount3 = await TestHelpers.getNodeCount(page); + await expect(nodeCount3).toBe(2); + + // undo the last action (adding the file node) + await TestHelpers.undo(page); + + // check that number of nodes is 1 + const nodeCount4 = await TestHelpers.getNodeCount(page); + await expect(nodeCount4).toBe(1); + + // redo the last action (adding the file node) + await TestHelpers.redo(page); + + // check that number of nodes is 2 + const nodeCount5 = await TestHelpers.getNodeCount(page); + await expect(nodeCount5).toBe(2); + + // undo the last action (adding the file node) + await TestHelpers.undo(page); + + // check that number of nodes is 1 + const nodeCount6 = await TestHelpers.getNodeCount(page); + await expect(nodeCount6).toBe(1); + + // undo the last action (adding the hello world node) + await TestHelpers.undo(page); + + // check that number of nodes is 0 + const nodeCount7 = await TestHelpers.getNodeCount(page); + await expect(nodeCount7).toBe(0); + + // redo the last action (adding the hello world node) + await TestHelpers.redo(page); + + // check that number of nodes is 1 + const nodeCount8 = await TestHelpers.getNodeCount(page); + await expect(nodeCount8).toBe(1); + + // close the browser + await page.close(); +}); + +test('Undo recomputes navbar graph issues state', async ({ page }) => { + await page.goto('http://localhost:8888/?tutorial=none'); + + await TestHelpers.setUIMode(page, 'Expert'); + + await TestHelpers.createNewGraph(page); + await TestHelpers.setShortDescription(page, 'Undo regression graph'); + await TestHelpers.setDetailedDescription(page, 'Graph used to verify navbar issue recomputation after undo.'); + + await expect.poll(async () => await TestHelpers.getNumWarningsErrors(page)).toBe(0); + await expect(page.locator('#checkEagleDone')).toBeVisible(); + + await TestHelpers.setShortDescription(page, ''); + const warningCountAfterMutation = await TestHelpers.getNumWarningsErrors(page); + + expect(warningCountAfterMutation).toBeGreaterThan(0); + await expect(page.locator('#checkEagleWarnings')).toBeVisible(); + + await TestHelpers.undo(page); + + await expect.poll(async () => await TestHelpers.getNumWarningsErrors(page)).toBe(0); + await expect(page.locator('#checkEagleDone')).toBeVisible(); + await expect(page.locator('#checkEagleWarnings')).toBeHidden(); + + await page.close(); +}); \ No newline at end of file diff --git a/e2e/undoBoundary.spec.ts b/e2e/undoBoundary.spec.ts index 5f98a745..84f15fc4 100644 --- a/e2e/undoBoundary.spec.ts +++ b/e2e/undoBoundary.spec.ts @@ -64,6 +64,7 @@ test('Undo exhausted history warns on first boundary keypress', async ({ page }) await expect.poll(async () => await TestHelpers.getNodeCount(page)).toBe(0); await page.locator('button[data-notify="dismiss"]').first().click(); + await page.close(); }); @@ -122,5 +123,6 @@ test('Undo still works after add undo add branch', async ({ page }) => { await expect.poll(async () => await TestHelpers.getNodeCount(page)).toBe(0); await page.locator('button[data-notify="dismiss"]').first().click(); + await page.close(); }); \ No newline at end of file diff --git a/src/GraphRenderer.ts b/src/GraphRenderer.ts index 2ecb7b32..9e27e83b 100644 --- a/src/GraphRenderer.ts +++ b/src/GraphRenderer.ts @@ -1070,12 +1070,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) @@ -1134,6 +1131,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){ @@ -1147,7 +1145,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){ @@ -1214,7 +1211,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') @@ -1269,34 +1266,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, EagleFileType.Graph); + }else{ + GraphRenderer.selectInRegion(nodes); } - eagle.editSelection(object,EagleFileType.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 @@ -1375,7 +1368,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 @@ -1611,34 +1604,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); } } @@ -1803,36 +1789,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 @@ -2263,12 +2243,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}); } @@ -2280,7 +2261,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; @@ -2289,7 +2270,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); @@ -2299,28 +2279,23 @@ export class GraphRenderer { let depth : number = 0; 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()"); + while (node.getParent() !== null){ + 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){ - 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"); @@ -2424,7 +2399,7 @@ export class GraphRenderer { } static findNearestMatchingPort(positionX: number, positionY: number, _sourceNode: Node, _sourcePort: Field, sourcePortIsInput: boolean) : {node: Node | null, field: Field | null, validity: 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: Validity = Validity.Unknown; @@ -2449,22 +2424,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