From 8fbdd94bfeafd68897b8c9828d608c7c5565834a Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Thu, 12 Dec 2024 14:15:22 -0700 Subject: [PATCH 01/14] Try new algorithm --- examples/grid_example/public/app.tsx | 4 +- .../public/serialized_grid_layout.ts | 20 +--- .../public/use_mock_dashboard_api.tsx | 4 +- packages/kbn-grid-layout/grid/grid_layout.tsx | 3 +- .../grid/use_grid_layout_events.ts | 8 +- .../grid/utils/resolve_grid_row.ts | 96 ++++++++++++++++--- 6 files changed, 98 insertions(+), 37 deletions(-) diff --git a/examples/grid_example/public/app.tsx b/examples/grid_example/public/app.tsx index 1a44d2cb4f8c1..1f8e11a0eba2f 100644 --- a/examples/grid_example/public/app.tsx +++ b/examples/grid_example/public/app.tsx @@ -250,8 +250,8 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => { layout={currentLayout} gridSettings={{ gutterSize: DASHBOARD_MARGIN_SIZE, - rowHeight: DASHBOARD_GRID_HEIGHT, - columnCount: DASHBOARD_GRID_COLUMN_COUNT, + rowHeight: 100, + columnCount: 8, }} renderPanelContents={renderBasicPanel} onLayoutChange={(newLayout) => { diff --git a/examples/grid_example/public/serialized_grid_layout.ts b/examples/grid_example/public/serialized_grid_layout.ts index 3e40380d91ac3..cf53dce49a88d 100644 --- a/examples/grid_example/public/serialized_grid_layout.ts +++ b/examples/grid_example/public/serialized_grid_layout.ts @@ -26,20 +26,10 @@ export function setSerializedGridLayout(state: MockSerializedDashboardState) { const initialState: MockSerializedDashboardState = { panels: { - panel1: { id: 'panel1', gridData: { i: 'panel1', x: 0, y: 0, w: 12, h: 6, row: 0 } }, - panel2: { id: 'panel2', gridData: { i: 'panel2', x: 0, y: 6, w: 8, h: 4, row: 0 } }, - panel3: { id: 'panel3', gridData: { i: 'panel3', x: 8, y: 6, w: 12, h: 4, row: 0 } }, - panel4: { id: 'panel4', gridData: { i: 'panel4', x: 0, y: 10, w: 48, h: 4, row: 0 } }, - panel5: { id: 'panel5', gridData: { i: 'panel5', x: 12, y: 0, w: 36, h: 6, row: 0 } }, - panel6: { id: 'panel6', gridData: { i: 'panel6', x: 24, y: 6, w: 24, h: 4, row: 0 } }, - panel7: { id: 'panel7', gridData: { i: 'panel7', x: 20, y: 6, w: 4, h: 2, row: 0 } }, - panel8: { id: 'panel8', gridData: { i: 'panel8', x: 20, y: 8, w: 4, h: 2, row: 0 } }, - panel9: { id: 'panel9', gridData: { i: 'panel9', x: 0, y: 0, w: 12, h: 16, row: 1 } }, - panel10: { id: 'panel10', gridData: { i: 'panel10', x: 24, y: 0, w: 12, h: 6, row: 2 } }, + panel1: { id: 'panel1', gridData: { i: 'panel1', x: 0, y: 0, w: 4, h: 3, row: 0 } }, + panel2: { id: 'panel2', gridData: { i: 'panel2', x: 0, y: 3, w: 4, h: 2, row: 0 } }, + panel3: { id: 'panel3', gridData: { i: 'panel3', x: 0, y: 5, w: 8, h: 1, row: 0 } }, + panel4: { id: 'panel4', gridData: { i: 'panel4', x: 4, y: 0, w: 4, h: 5, row: 0 } }, }, - rows: [ - { title: 'Large section', collapsed: false }, - { title: 'Small section', collapsed: false }, - { title: 'Another small section', collapsed: false }, - ], + rows: [{ title: 'Large section', collapsed: false }], }; diff --git a/examples/grid_example/public/use_mock_dashboard_api.tsx b/examples/grid_example/public/use_mock_dashboard_api.tsx index 8388bd83f2645..70a0c3bde5f9e 100644 --- a/examples/grid_example/public/use_mock_dashboard_api.tsx +++ b/examples/grid_example/public/use_mock_dashboard_api.tsx @@ -62,8 +62,8 @@ export const useMockDashboardApi = ({ row: 0, x: 0, y: 0, - w: DEFAULT_PANEL_WIDTH, - h: DEFAULT_PANEL_HEIGHT, + w: 4, + h: 4, }, }, }); diff --git a/packages/kbn-grid-layout/grid/grid_layout.tsx b/packages/kbn-grid-layout/grid/grid_layout.tsx index 1406d4b6eb55d..fc38971b63da1 100644 --- a/packages/kbn-grid-layout/grid/grid_layout.tsx +++ b/packages/kbn-grid-layout/grid/grid_layout.tsx @@ -60,8 +60,9 @@ export const GridLayout = ({ * the layout sent in as a prop is not guaranteed to be valid (i.e it may have floating panels) - * so, we need to loop through each row and ensure it is compacted */ + const { columnCount } = gridLayoutStateManager.runtimeSettings$.getValue(); newLayout.forEach((row, rowIndex) => { - newLayout[rowIndex] = resolveGridRow(row); + newLayout[rowIndex] = resolveGridRow(row, columnCount); }); gridLayoutStateManager.gridLayout$.next(newLayout); } diff --git a/packages/kbn-grid-layout/grid/use_grid_layout_events.ts b/packages/kbn-grid-layout/grid/use_grid_layout_events.ts index 64cc8f482838e..31027f80e5d39 100644 --- a/packages/kbn-grid-layout/grid/use_grid_layout_events.ts +++ b/packages/kbn-grid-layout/grid/use_grid_layout_events.ts @@ -180,13 +180,17 @@ export const useGridLayoutEvents = ({ // resolve destination grid const destinationGrid = nextLayout[targetRowIndex]; - const resolvedDestinationGrid = resolveGridRow(destinationGrid, requestedGridData); + const resolvedDestinationGrid = resolveGridRow( + destinationGrid, + columnCount, + requestedGridData + ); nextLayout[targetRowIndex] = resolvedDestinationGrid; // resolve origin grid if (hasChangedGridRow) { const originGrid = nextLayout[lastRowIndex]; - const resolvedOriginGrid = resolveGridRow(originGrid); + const resolvedOriginGrid = resolveGridRow(originGrid, columnCount); nextLayout[lastRowIndex] = resolvedOriginGrid; } if (!deepEqual(currentLayout, nextLayout)) { diff --git a/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts index 38b778b5d0571..14c7b0e537970 100644 --- a/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts +++ b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts @@ -7,6 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import { without } from 'lodash'; import { GridPanelData, GridRowData } from '../types'; const collides = (panelA: GridPanelData, panelB: GridPanelData) => { @@ -40,14 +41,14 @@ export const getKeysInOrder = (panels: GridRowData['panels'], draggedId?: string const panelA = panels[panelKeyA]; const panelB = panels[panelKeyB]; - // sort by row first - if (panelA.row > panelB.row) return 1; - if (panelA.row < panelB.row) return -1; - // if rows are the same. Is either panel being dragged? if (panelA.id === draggedId) return -1; if (panelB.id === draggedId) return 1; + // sort by row first + if (panelA.row > panelB.row) return 1; + if (panelA.row < panelB.row) return -1; + // if rows are the same and neither panel is being dragged, sort by column if (panelA.column > panelB.column) return 1; if (panelA.column < panelB.column) return -1; @@ -79,30 +80,95 @@ const compactGridRow = (originalLayout: GridRowData) => { export const resolveGridRow = ( originalRowData: GridRowData, + columnCount: number, dragRequest?: GridPanelData ): GridRowData => { const nextRowData = { ...originalRowData, panels: { ...originalRowData.panels } }; - - // Apply drag request + // apply drag request if (dragRequest) { nextRowData.panels[dragRequest.id] = dragRequest; } - // return nextRowData; - // push all panels down if they collide with another panel + // calculate the total height of the grid + const panelRows = Object.values(nextRowData.panels).map( + ({ row: panelRow, height: panelHeight }) => panelRow + panelHeight + ); + const rowCount = Math.max(...panelRows); + + // build an empty 2D array representing the current grid + const collisionGrid: string[][][] = new Array(rowCount) + .fill(null) + .map(() => new Array(columnCount).fill(null).map(() => new Array(0))); + + // for each panel, push its ID into the grid cells that it occupies const sortedKeys = getKeysInOrder(nextRowData.panels, dragRequest?.id); + let orderToMove = sortedKeys.reverse(); + if (dragRequest) { + orderToMove = orderToMove.filter((key) => key !== dragRequest.id); + } + sortedKeys.forEach((panelKey) => { + const panel = nextRowData.panels[panelKey]; + for (let row = panel.row; row < panel.row + panel.height; row++) { + for (let column = panel.column; column < panel.column + panel.width; column++) { + collisionGrid[row][column].push(panelKey); + } + } + }); - for (const key of sortedKeys) { - const panel = nextRowData.panels[key]; - const collisions = getAllCollisionsWithPanel(panel, nextRowData, sortedKeys); + // handle all collisions, row by row + const getCollisionsInOrder = (row: number) => { + let collisions: string[] = []; + collisionGrid[row].forEach((collisionArray) => { + if (collisionArray.length > 1) { + collisions = collisions.concat( + dragRequest ? collisionArray.filter((key) => key !== dragRequest.id) : collisionArray + ); + } + }); + const collisionsInOrder: string[] = []; + orderToMove.forEach((panelKey) => { + if (collisions.includes(panelKey)) { + collisionsInOrder.push(panelKey); + } + }); + return collisionsInOrder; + }; + + for (let row = dragRequest?.row ?? 0; row < collisionGrid.length; row++) { + let collisions = getCollisionsInOrder(row); + while (collisions.length > 0) { + // don't move on to the next row until the current row has no more collisions + for (const panelKey of collisions) { + // move the panel down + const panel = nextRowData.panels[panelKey]; + nextRowData.panels[panelKey].row += 1; - for (const collision of collisions) { - const rowOverlap = panel.row + panel.height - collision.row; - if (rowOverlap > 0) { - collision.row += rowOverlap; + // update the collision grid to keep it in sync + const currentGridHeight = collisionGrid.length; + if (row + panel.height >= currentGridHeight) { + collisionGrid.push(new Array(columnCount).fill(null).map(() => new Array(0))); + } + for ( + let panelColumn = panel.column; + panelColumn < panel.column + panel.width; + panelColumn++ + ) { + collisionGrid[panel.row - 1][panelColumn] = without( + collisionGrid[panel.row - 1][panelColumn], + panelKey + ); + collisionGrid[panel.row + panel.height - 1][panelColumn].push(panelKey); + } + + collisions = getCollisionsInOrder(row); + if (collisions.length <= 0) { + // no more collisions; break out early of "push panel down" loop + break; + } } } } + const compactedGrid = compactGridRow(nextRowData); return compactedGrid; }; From c17699267710228a885bb901b410c263f4355d49 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Thu, 12 Dec 2024 14:33:45 -0700 Subject: [PATCH 02/14] Undo grid changes --- examples/grid_example/public/app.tsx | 4 ++-- .../public/serialized_grid_layout.ts | 20 ++++++++++++++----- .../public/use_mock_dashboard_api.tsx | 4 ++-- .../grid/utils/resolve_grid_row.ts | 8 ++++---- 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/examples/grid_example/public/app.tsx b/examples/grid_example/public/app.tsx index 1f8e11a0eba2f..1a44d2cb4f8c1 100644 --- a/examples/grid_example/public/app.tsx +++ b/examples/grid_example/public/app.tsx @@ -250,8 +250,8 @@ export const GridExample = ({ coreStart }: { coreStart: CoreStart }) => { layout={currentLayout} gridSettings={{ gutterSize: DASHBOARD_MARGIN_SIZE, - rowHeight: 100, - columnCount: 8, + rowHeight: DASHBOARD_GRID_HEIGHT, + columnCount: DASHBOARD_GRID_COLUMN_COUNT, }} renderPanelContents={renderBasicPanel} onLayoutChange={(newLayout) => { diff --git a/examples/grid_example/public/serialized_grid_layout.ts b/examples/grid_example/public/serialized_grid_layout.ts index cf53dce49a88d..3e40380d91ac3 100644 --- a/examples/grid_example/public/serialized_grid_layout.ts +++ b/examples/grid_example/public/serialized_grid_layout.ts @@ -26,10 +26,20 @@ export function setSerializedGridLayout(state: MockSerializedDashboardState) { const initialState: MockSerializedDashboardState = { panels: { - panel1: { id: 'panel1', gridData: { i: 'panel1', x: 0, y: 0, w: 4, h: 3, row: 0 } }, - panel2: { id: 'panel2', gridData: { i: 'panel2', x: 0, y: 3, w: 4, h: 2, row: 0 } }, - panel3: { id: 'panel3', gridData: { i: 'panel3', x: 0, y: 5, w: 8, h: 1, row: 0 } }, - panel4: { id: 'panel4', gridData: { i: 'panel4', x: 4, y: 0, w: 4, h: 5, row: 0 } }, + panel1: { id: 'panel1', gridData: { i: 'panel1', x: 0, y: 0, w: 12, h: 6, row: 0 } }, + panel2: { id: 'panel2', gridData: { i: 'panel2', x: 0, y: 6, w: 8, h: 4, row: 0 } }, + panel3: { id: 'panel3', gridData: { i: 'panel3', x: 8, y: 6, w: 12, h: 4, row: 0 } }, + panel4: { id: 'panel4', gridData: { i: 'panel4', x: 0, y: 10, w: 48, h: 4, row: 0 } }, + panel5: { id: 'panel5', gridData: { i: 'panel5', x: 12, y: 0, w: 36, h: 6, row: 0 } }, + panel6: { id: 'panel6', gridData: { i: 'panel6', x: 24, y: 6, w: 24, h: 4, row: 0 } }, + panel7: { id: 'panel7', gridData: { i: 'panel7', x: 20, y: 6, w: 4, h: 2, row: 0 } }, + panel8: { id: 'panel8', gridData: { i: 'panel8', x: 20, y: 8, w: 4, h: 2, row: 0 } }, + panel9: { id: 'panel9', gridData: { i: 'panel9', x: 0, y: 0, w: 12, h: 16, row: 1 } }, + panel10: { id: 'panel10', gridData: { i: 'panel10', x: 24, y: 0, w: 12, h: 6, row: 2 } }, }, - rows: [{ title: 'Large section', collapsed: false }], + rows: [ + { title: 'Large section', collapsed: false }, + { title: 'Small section', collapsed: false }, + { title: 'Another small section', collapsed: false }, + ], }; diff --git a/examples/grid_example/public/use_mock_dashboard_api.tsx b/examples/grid_example/public/use_mock_dashboard_api.tsx index 70a0c3bde5f9e..8388bd83f2645 100644 --- a/examples/grid_example/public/use_mock_dashboard_api.tsx +++ b/examples/grid_example/public/use_mock_dashboard_api.tsx @@ -62,8 +62,8 @@ export const useMockDashboardApi = ({ row: 0, x: 0, y: 0, - w: 4, - h: 4, + w: DEFAULT_PANEL_WIDTH, + h: DEFAULT_PANEL_HEIGHT, }, }, }); diff --git a/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts index 14c7b0e537970..63ee33c5f90d6 100644 --- a/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts +++ b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts @@ -41,14 +41,14 @@ export const getKeysInOrder = (panels: GridRowData['panels'], draggedId?: string const panelA = panels[panelKeyA]; const panelB = panels[panelKeyB]; - // if rows are the same. Is either panel being dragged? - if (panelA.id === draggedId) return -1; - if (panelB.id === draggedId) return 1; - // sort by row first if (panelA.row > panelB.row) return 1; if (panelA.row < panelB.row) return -1; + // if rows are the same. Is either panel being dragged? + if (panelA.id === draggedId) return -1; + if (panelB.id === draggedId) return 1; + // if rows are the same and neither panel is being dragged, sort by column if (panelA.column > panelB.column) return 1; if (panelA.column < panelB.column) return -1; From 07ba89b5c65cd820e9c37ef48b4e5d5a41dccc2e Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Thu, 12 Dec 2024 14:43:40 -0700 Subject: [PATCH 03/14] Fix empty row bug --- packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts index 63ee33c5f90d6..cf03be91c0254 100644 --- a/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts +++ b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts @@ -93,6 +93,7 @@ export const resolveGridRow = ( const panelRows = Object.values(nextRowData.panels).map( ({ row: panelRow, height: panelHeight }) => panelRow + panelHeight ); + if (panelRows.length === 0) return nextRowData; const rowCount = Math.max(...panelRows); // build an empty 2D array representing the current grid From f5457233b30b96390ae1c6f1ac9f6306f584dfc0 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Thu, 12 Dec 2024 15:23:13 -0700 Subject: [PATCH 04/14] Make Lens embeddables work again --- examples/grid_example/public/use_mock_dashboard_api.tsx | 2 ++ packages/kbn-grid-layout/grid/use_grid_layout_state.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/grid_example/public/use_mock_dashboard_api.tsx b/examples/grid_example/public/use_mock_dashboard_api.tsx index 51933f3a038e4..5b26b6c7eca02 100644 --- a/examples/grid_example/public/use_mock_dashboard_api.tsx +++ b/examples/grid_example/public/use_mock_dashboard_api.tsx @@ -46,6 +46,8 @@ export const useMockDashboardApi = ({ from: 'now-24h', to: 'now', }), + filters$: new BehaviorSubject([]), + query$: new BehaviorSubject(''), viewMode: new BehaviorSubject('edit'), panels$, rows$: new BehaviorSubject(savedState.rows), diff --git a/packages/kbn-grid-layout/grid/use_grid_layout_state.ts b/packages/kbn-grid-layout/grid/use_grid_layout_state.ts index 3e76687436bcb..26773931b6be3 100644 --- a/packages/kbn-grid-layout/grid/use_grid_layout_state.ts +++ b/packages/kbn-grid-layout/grid/use_grid_layout_state.ts @@ -63,7 +63,7 @@ export const useGridLayoutState = ({ const gridLayoutStateManager = useMemo(() => { const resolvedLayout = cloneDeep(layout); resolvedLayout.forEach((row, rowIndex) => { - resolvedLayout[rowIndex] = resolveGridRow(row); + resolvedLayout[rowIndex] = resolveGridRow(row, gridSettings.columnCount); }); const gridLayout$ = new BehaviorSubject(resolvedLayout); From 3b9ee929bbff45026f377e01355b6e33d6d8b106 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Thu, 12 Dec 2024 16:17:44 -0700 Subject: [PATCH 05/14] Add some comments --- .../grid/utils/resolve_grid_row.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts index cf03be91c0254..a850860c7f971 100644 --- a/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts +++ b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts @@ -78,6 +78,21 @@ const compactGridRow = (originalLayout: GridRowData) => { return nextRowData; }; +/** + * Our collision resolution algorithm works as follows: + * - Start by creating a 2D grid that contains arrays of panel IDs (i.e. a 3D array) + * - If a panel occupies a cell, then its panel ID is pushed into the cell array + * - Once you have this representation, row by row, resolve the collisions; i.e. + * - for each row, + * - while there are collisions + * - for each colliding panel, in reverse order, + * - move the panel down by a single row + * - if no collisions, break; otherwise, proceed to next panel + * Notes: + * - We know there is a collision if the panel ID array of a cell contains more than one ID + * - Pushing panels down is done in reverse order in order to maintain the original order of panels + * - i.e. the bottom-right-most panel is pushed down **first** + */ export const resolveGridRow = ( originalRowData: GridRowData, columnCount: number, @@ -89,14 +104,12 @@ export const resolveGridRow = ( nextRowData.panels[dragRequest.id] = dragRequest; } - // calculate the total height of the grid + // build an empty 2D array representing the current grid const panelRows = Object.values(nextRowData.panels).map( ({ row: panelRow, height: panelHeight }) => panelRow + panelHeight ); if (panelRows.length === 0) return nextRowData; const rowCount = Math.max(...panelRows); - - // build an empty 2D array representing the current grid const collisionGrid: string[][][] = new Array(rowCount) .fill(null) .map(() => new Array(columnCount).fill(null).map(() => new Array(0))); From 8280887003d33ff7dcd0229453091436bf092190 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Thu, 12 Dec 2024 18:00:37 -0700 Subject: [PATCH 06/14] Small cleanup --- .../grid/utils/resolve_grid_row.ts | 88 +++++++++---------- 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts index a850860c7f971..4604f83f9fbc5 100644 --- a/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts +++ b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { without } from 'lodash'; +import { without, pick } from 'lodash'; import { GridPanelData, GridRowData } from '../types'; const collides = (panelA: GridPanelData, panelB: GridPanelData) => { @@ -116,10 +116,6 @@ export const resolveGridRow = ( // for each panel, push its ID into the grid cells that it occupies const sortedKeys = getKeysInOrder(nextRowData.panels, dragRequest?.id); - let orderToMove = sortedKeys.reverse(); - if (dragRequest) { - orderToMove = orderToMove.filter((key) => key !== dragRequest.id); - } sortedKeys.forEach((panelKey) => { const panel = nextRowData.panels[panelKey]; for (let row = panel.row; row < panel.row + panel.height; row++) { @@ -129,56 +125,60 @@ export const resolveGridRow = ( } }); - // handle all collisions, row by row + // move panels in reverse order to maintain the original order of panels + const orderToMove: { [panelKey: string]: number } = sortedKeys + .reverse() + .reduce((prev, panelKey, index) => { + return { ...prev, [panelKey]: index }; + }, {}); + const getCollisionsInOrder = (row: number) => { - let collisions: string[] = []; + const collisions: string[] = []; collisionGrid[row].forEach((collisionArray) => { if (collisionArray.length > 1) { - collisions = collisions.concat( - dragRequest ? collisionArray.filter((key) => key !== dragRequest.id) : collisionArray - ); - } - }); - const collisionsInOrder: string[] = []; - orderToMove.forEach((panelKey) => { - if (collisions.includes(panelKey)) { - collisionsInOrder.push(panelKey); + for (const panelKey of collisionArray) { + if (collisions.indexOf(panelKey) === -1 && panelKey !== dragRequest?.id) { + collisions.push(panelKey); + } + } } }); - return collisionsInOrder; + return collisions.sort((key1, key2) => orderToMove[key1] - orderToMove[key2]); }; + // handle all collisions, row by row for (let row = dragRequest?.row ?? 0; row < collisionGrid.length; row++) { let collisions = getCollisionsInOrder(row); + // don't move on to sthe next row until the current row has no more collisions while (collisions.length > 0) { - // don't move on to the next row until the current row has no more collisions - for (const panelKey of collisions) { - // move the panel down - const panel = nextRowData.panels[panelKey]; - nextRowData.panels[panelKey].row += 1; - - // update the collision grid to keep it in sync - const currentGridHeight = collisionGrid.length; - if (row + panel.height >= currentGridHeight) { - collisionGrid.push(new Array(columnCount).fill(null).map(() => new Array(0))); - } - for ( - let panelColumn = panel.column; - panelColumn < panel.column + panel.width; - panelColumn++ - ) { - collisionGrid[panel.row - 1][panelColumn] = without( - collisionGrid[panel.row - 1][panelColumn], - panelKey - ); - collisionGrid[panel.row + panel.height - 1][panelColumn].push(panelKey); - } + const panelKey = collisions.shift(); + if (!panelKey) break; - collisions = getCollisionsInOrder(row); - if (collisions.length <= 0) { - // no more collisions; break out early of "push panel down" loop - break; - } + // move the panel down + const panel = nextRowData.panels[panelKey]; + nextRowData.panels[panelKey].row += 1; + + // update the collision grid to keep it in sync + const currentGridHeight = collisionGrid.length; + if (row + panel.height >= currentGridHeight) { + collisionGrid.push(new Array(columnCount).fill(null).map(() => new Array(0))); + } + for ( + let panelColumn = panel.column; + panelColumn < panel.column + panel.width; + panelColumn++ + ) { + collisionGrid[panel.row - 1][panelColumn] = without( + collisionGrid[panel.row - 1][panelColumn], + panelKey + ); + collisionGrid[panel.row + panel.height - 1][panelColumn].push(panelKey); + } + + collisions = getCollisionsInOrder(row); + if (collisions.length <= 0) { + // no more collisions; break out early of "push panel down" loop + break; } } } From 7bf2bd015c7773d1226ddd903ecc590226ecb111 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Tue, 17 Dec 2024 11:43:08 -0700 Subject: [PATCH 07/14] Another small cleanup --- packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts index 4604f83f9fbc5..b9d1d8bbac3a4 100644 --- a/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts +++ b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts @@ -149,12 +149,12 @@ export const resolveGridRow = ( // handle all collisions, row by row for (let row = dragRequest?.row ?? 0; row < collisionGrid.length; row++) { let collisions = getCollisionsInOrder(row); - // don't move on to sthe next row until the current row has no more collisions + // continue pushing panels down until all collisions in this row have been resolved while (collisions.length > 0) { const panelKey = collisions.shift(); if (!panelKey) break; - // move the panel down + // move the current colliding panel down const panel = nextRowData.panels[panelKey]; nextRowData.panels[panelKey].row += 1; @@ -175,11 +175,8 @@ export const resolveGridRow = ( collisionGrid[panel.row + panel.height - 1][panelColumn].push(panelKey); } + // re-check if the current row has collisions now that the panel has been moved down collisions = getCollisionsInOrder(row); - if (collisions.length <= 0) { - // no more collisions; break out early of "push panel down" loop - break; - } } } From c811e5c4aa1e8f2ebb206c40c9cae7edc8367898 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Tue, 17 Dec 2024 16:45:04 -0700 Subject: [PATCH 08/14] Add tests --- .../grid/utils/resolve_grid_row.test.ts | 162 ++++++++++++++++++ .../grid/utils/resolve_grid_row.ts | 1 + 2 files changed, 163 insertions(+) create mode 100644 packages/kbn-grid-layout/grid/utils/resolve_grid_row.test.ts diff --git a/packages/kbn-grid-layout/grid/utils/resolve_grid_row.test.ts b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.test.ts new file mode 100644 index 0000000000000..b07fc7b80a845 --- /dev/null +++ b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.test.ts @@ -0,0 +1,162 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { resolveGridRow } from './resolve_grid_row'; + +describe('resolve grid row', () => { + test('does nothing if grid row has no collisions', () => { + const gridRow = { + title: 'Test', + isCollapsed: false, + panels: { + panel1: { id: 'panel1', row: 3, column: 0, height: 1, width: 7 }, + panel2: { id: 'panel2', row: 4, column: 0, height: 1, width: 7 }, + panel3: { id: 'panel3', row: 5, column: 0, height: 1, width: 7 }, + panel4: { id: 'panel4', row: 0, column: 6, height: 3, width: 1 }, + }, + }; + const result = resolveGridRow(gridRow, 8); + expect(result).toEqual(gridRow); + }); + + test('resolves grid row if it has collisions without drag event', () => { + const result = resolveGridRow( + { + title: 'Test', + isCollapsed: false, + panels: { + panel1: { id: 'panel1', row: 0, column: 0, height: 3, width: 4 }, + panel2: { id: 'panel2', row: 3, column: 0, height: 2, width: 2 }, + panel3: { id: 'panel3', row: 3, column: 2, height: 2, width: 2 }, + panel4: { id: 'panel4', row: 0, column: 3, height: 5, width: 4 }, + }, + }, + 8 + ); + expect(result).toEqual({ + title: 'Test', + isCollapsed: false, + panels: { + panel1: { id: 'panel1', row: 0, column: 0, height: 3, width: 4 }, + panel2: { id: 'panel2', row: 3, column: 0, height: 2, width: 2 }, + panel3: { id: 'panel3', row: 8, column: 2, height: 2, width: 2 }, // pushed down + panel4: { id: 'panel4', row: 3, column: 3, height: 5, width: 4 }, // pushed down + }, + }); + }); + + test('drag causes no collision', () => { + const result = resolveGridRow( + { + title: 'Test', + isCollapsed: false, + panels: { + panel1: { id: 'panel1', row: 0, column: 0, height: 1, width: 7 }, + panel2: { id: 'panel2', row: 1, column: 0, height: 1, width: 7 }, + panel3: { id: 'panel3', row: 2, column: 0, height: 1, width: 7 }, + }, + }, + 8, + { id: 'panel4', row: 0, column: 7, height: 3, width: 1 } + ); + + expect(result).toEqual({ + title: 'Test', + isCollapsed: false, + panels: { + panel1: { id: 'panel1', row: 0, column: 0, height: 1, width: 7 }, + panel2: { id: 'panel2', row: 1, column: 0, height: 1, width: 7 }, + panel3: { id: 'panel3', row: 2, column: 0, height: 1, width: 7 }, + panel4: { id: 'panel4', row: 0, column: 7, height: 3, width: 1 }, + }, + }); + }); + + test('drag causes collision with one panel that pushes down others', () => { + const result = resolveGridRow( + { + title: 'Test', + isCollapsed: false, + panels: { + panel1: { id: 'panel1', row: 0, column: 0, height: 1, width: 7 }, + panel2: { id: 'panel2', row: 1, column: 0, height: 1, width: 7 }, + panel3: { id: 'panel3', row: 2, column: 0, height: 1, width: 8 }, + panel4: { id: 'panel4', row: 3, column: 4, height: 3, width: 4 }, + }, + }, + 8, + { id: 'panel5', row: 2, column: 0, height: 3, width: 3 } + ); + + expect(result).toEqual({ + title: 'Test', + isCollapsed: false, + panels: { + panel1: { id: 'panel1', row: 0, column: 0, height: 1, width: 7 }, + panel2: { id: 'panel2', row: 1, column: 0, height: 1, width: 7 }, + panel3: { id: 'panel3', row: 5, column: 0, height: 1, width: 8 }, // pushed down + panel4: { id: 'panel4', row: 6, column: 4, height: 3, width: 4 }, // pushed down + panel5: { id: 'panel5', row: 2, column: 0, height: 3, width: 3 }, + }, + }); + }); + + test('drag causes collision with multiple panels', () => { + const result = resolveGridRow( + { + title: 'Test', + isCollapsed: false, + panels: { + panel1: { id: 'panel1', row: 0, column: 0, height: 3, width: 4 }, + panel2: { id: 'panel2', row: 3, column: 0, height: 2, width: 2 }, + panel3: { id: 'panel3', row: 3, column: 2, height: 2, width: 2 }, + }, + }, + 8, + { id: 'panel4', row: 0, column: 3, height: 5, width: 4 } + ); + expect(result).toEqual({ + title: 'Test', + isCollapsed: false, + panels: { + panel1: { id: 'panel1', row: 5, column: 0, height: 3, width: 4 }, // pushed down + panel2: { id: 'panel2', row: 8, column: 0, height: 2, width: 2 }, // pushed down + panel3: { id: 'panel3', row: 8, column: 2, height: 2, width: 2 }, // pushed down + panel4: { id: 'panel4', row: 0, column: 3, height: 5, width: 4 }, + }, + }); + }); + + test('drag causes collision with every panel', () => { + const result = resolveGridRow( + { + title: 'Test', + isCollapsed: false, + panels: { + panel1: { id: 'panel1', row: 0, column: 0, height: 1, width: 7 }, + panel2: { id: 'panel2', row: 1, column: 0, height: 1, width: 7 }, + panel3: { id: 'panel3', row: 2, column: 0, height: 1, width: 7 }, + }, + }, + 8, + { id: 'panel4', row: 0, column: 6, height: 3, width: 1 } + ); + + expect(result).toEqual({ + title: 'Test', + isCollapsed: false, + panels: { + panel1: { id: 'panel1', row: 3, column: 0, height: 1, width: 7 }, + panel2: { id: 'panel2', row: 4, column: 0, height: 1, width: 7 }, + panel3: { id: 'panel3', row: 5, column: 0, height: 1, width: 7 }, + panel4: { id: 'panel4', row: 0, column: 6, height: 3, width: 1 }, + }, + }); + }); +}); diff --git a/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts index b9d1d8bbac3a4..732a9faf1d606 100644 --- a/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts +++ b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts @@ -149,6 +149,7 @@ export const resolveGridRow = ( // handle all collisions, row by row for (let row = dragRequest?.row ?? 0; row < collisionGrid.length; row++) { let collisions = getCollisionsInOrder(row); + // continue pushing panels down until all collisions in this row have been resolved while (collisions.length > 0) { const panelKey = collisions.shift(); From 0cc0081f731b6292620ee3084f67c53bd54e48f0 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Wed, 18 Dec 2024 00:06:21 +0000 Subject: [PATCH 09/14] [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' --- packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts index 732a9faf1d606..57d822d95382e 100644 --- a/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts +++ b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { without, pick } from 'lodash'; +import { without } from 'lodash'; import { GridPanelData, GridRowData } from '../types'; const collides = (panelA: GridPanelData, panelB: GridPanelData) => { From 903b774b543afa22d913c38c208d780ed8669c8f Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Wed, 18 Dec 2024 10:18:35 -0700 Subject: [PATCH 10/14] Fix failing test due to reordering --- packages/kbn-grid-layout/grid/grid_layout.test.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/kbn-grid-layout/grid/grid_layout.test.tsx b/packages/kbn-grid-layout/grid/grid_layout.test.tsx index 33b1bad784618..76c703ed4a6b6 100644 --- a/packages/kbn-grid-layout/grid/grid_layout.test.tsx +++ b/packages/kbn-grid-layout/grid/grid_layout.test.tsx @@ -32,6 +32,7 @@ describe('GridLayout', () => { rerender(), }; }; + const getAllThePanelIds = () => screen .getAllByRole('button', { name: /panelId:panel/i }) @@ -40,9 +41,11 @@ describe('GridLayout', () => { const startDragging = (handle: HTMLElement, options = { clientX: 0, clientY: 0 }) => { fireEvent.mouseDown(handle, options); }; + const moveTo = (options = { clientX: 256, clientY: 128 }) => { fireEvent.mouseMove(document, options); }; + const drop = (handle: HTMLElement) => { fireEvent.mouseUp(handle); }; @@ -112,17 +115,18 @@ describe('GridLayout', () => { drop(panel1DragHandle); expect(getAllThePanelIds()).toEqual([ 'panel2', + 'panel1', 'panel5', 'panel3', 'panel7', - 'panel1', - 'panel8', 'panel6', + 'panel8', 'panel4', 'panel9', 'panel10', ]); }); + it('after removing a panel', async () => { const { rerender } = renderGridLayout(); const sampleLayoutWithoutPanel1 = cloneDeep(getSampleLayout()); From d8ce89ad97cae5341a03208446c296774c48611c Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Thu, 19 Dec 2024 15:10:55 -0700 Subject: [PATCH 11/14] Switch to recursive algorithm --- packages/kbn-grid-layout/grid/grid_layout.tsx | 3 +- .../grid/use_grid_layout_events.ts | 8 +- .../grid/use_grid_layout_state.ts | 2 +- .../grid/utils/resolve_grid_row.test.ts | 27 ++-- .../grid/utils/resolve_grid_row.ts | 147 ++++++------------ 5 files changed, 63 insertions(+), 124 deletions(-) diff --git a/packages/kbn-grid-layout/grid/grid_layout.tsx b/packages/kbn-grid-layout/grid/grid_layout.tsx index 8b19f72415ca0..b11fd8cc7dd9f 100644 --- a/packages/kbn-grid-layout/grid/grid_layout.tsx +++ b/packages/kbn-grid-layout/grid/grid_layout.tsx @@ -63,9 +63,8 @@ export const GridLayout = ({ * the layout sent in as a prop is not guaranteed to be valid (i.e it may have floating panels) - * so, we need to loop through each row and ensure it is compacted */ - const { columnCount } = gridLayoutStateManager.runtimeSettings$.getValue(); newLayout.forEach((row, rowIndex) => { - newLayout[rowIndex] = resolveGridRow(row, columnCount); + newLayout[rowIndex] = resolveGridRow(row); }); gridLayoutStateManager.gridLayout$.next(newLayout); } diff --git a/packages/kbn-grid-layout/grid/use_grid_layout_events.ts b/packages/kbn-grid-layout/grid/use_grid_layout_events.ts index 67017334534f0..09f12d13d93d8 100644 --- a/packages/kbn-grid-layout/grid/use_grid_layout_events.ts +++ b/packages/kbn-grid-layout/grid/use_grid_layout_events.ts @@ -206,17 +206,13 @@ export const useGridLayoutEvents = ({ // resolve destination grid const destinationGrid = nextLayout[targetRowIndex]; - const resolvedDestinationGrid = resolveGridRow( - destinationGrid, - columnCount, - requestedGridData - ); + const resolvedDestinationGrid = resolveGridRow(destinationGrid, requestedGridData); nextLayout[targetRowIndex] = resolvedDestinationGrid; // resolve origin grid if (hasChangedGridRow) { const originGrid = nextLayout[lastRowIndex]; - const resolvedOriginGrid = resolveGridRow(originGrid, columnCount); + const resolvedOriginGrid = resolveGridRow(originGrid); nextLayout[lastRowIndex] = resolvedOriginGrid; } if (!deepEqual(currentLayout, nextLayout)) { diff --git a/packages/kbn-grid-layout/grid/use_grid_layout_state.ts b/packages/kbn-grid-layout/grid/use_grid_layout_state.ts index 26773931b6be3..3e76687436bcb 100644 --- a/packages/kbn-grid-layout/grid/use_grid_layout_state.ts +++ b/packages/kbn-grid-layout/grid/use_grid_layout_state.ts @@ -63,7 +63,7 @@ export const useGridLayoutState = ({ const gridLayoutStateManager = useMemo(() => { const resolvedLayout = cloneDeep(layout); resolvedLayout.forEach((row, rowIndex) => { - resolvedLayout[rowIndex] = resolveGridRow(row, gridSettings.columnCount); + resolvedLayout[rowIndex] = resolveGridRow(row); }); const gridLayout$ = new BehaviorSubject(resolvedLayout); diff --git a/packages/kbn-grid-layout/grid/utils/resolve_grid_row.test.ts b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.test.ts index b07fc7b80a845..b194e89c3241e 100644 --- a/packages/kbn-grid-layout/grid/utils/resolve_grid_row.test.ts +++ b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.test.ts @@ -21,24 +21,21 @@ describe('resolve grid row', () => { panel4: { id: 'panel4', row: 0, column: 6, height: 3, width: 1 }, }, }; - const result = resolveGridRow(gridRow, 8); + const result = resolveGridRow(gridRow); expect(result).toEqual(gridRow); }); test('resolves grid row if it has collisions without drag event', () => { - const result = resolveGridRow( - { - title: 'Test', - isCollapsed: false, - panels: { - panel1: { id: 'panel1', row: 0, column: 0, height: 3, width: 4 }, - panel2: { id: 'panel2', row: 3, column: 0, height: 2, width: 2 }, - panel3: { id: 'panel3', row: 3, column: 2, height: 2, width: 2 }, - panel4: { id: 'panel4', row: 0, column: 3, height: 5, width: 4 }, - }, + const result = resolveGridRow({ + title: 'Test', + isCollapsed: false, + panels: { + panel1: { id: 'panel1', row: 0, column: 0, height: 3, width: 4 }, + panel2: { id: 'panel2', row: 3, column: 0, height: 2, width: 2 }, + panel3: { id: 'panel3', row: 3, column: 2, height: 2, width: 2 }, + panel4: { id: 'panel4', row: 0, column: 3, height: 5, width: 4 }, }, - 8 - ); + }); expect(result).toEqual({ title: 'Test', isCollapsed: false, @@ -62,7 +59,6 @@ describe('resolve grid row', () => { panel3: { id: 'panel3', row: 2, column: 0, height: 1, width: 7 }, }, }, - 8, { id: 'panel4', row: 0, column: 7, height: 3, width: 1 } ); @@ -90,7 +86,6 @@ describe('resolve grid row', () => { panel4: { id: 'panel4', row: 3, column: 4, height: 3, width: 4 }, }, }, - 8, { id: 'panel5', row: 2, column: 0, height: 3, width: 3 } ); @@ -118,7 +113,6 @@ describe('resolve grid row', () => { panel3: { id: 'panel3', row: 3, column: 2, height: 2, width: 2 }, }, }, - 8, { id: 'panel4', row: 0, column: 3, height: 5, width: 4 } ); expect(result).toEqual({ @@ -144,7 +138,6 @@ describe('resolve grid row', () => { panel3: { id: 'panel3', row: 2, column: 0, height: 1, width: 7 }, }, }, - 8, { id: 'panel4', row: 0, column: 6, height: 3, width: 1 } ); diff --git a/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts index 57d822d95382e..60bfdc4f19022 100644 --- a/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts +++ b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts @@ -7,7 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { without } from 'lodash'; import { GridPanelData, GridRowData } from '../types'; const collides = (panelA: GridPanelData, panelB: GridPanelData) => { @@ -23,18 +22,30 @@ const getAllCollisionsWithPanel = ( panelToCheck: GridPanelData, gridLayout: GridRowData, keysInOrder: string[] -): GridPanelData[] => { - const collidingPanels: GridPanelData[] = []; +): Array => { + const collidingPanels: Array = []; for (const key of keysInOrder) { const comparePanel = gridLayout.panels[key]; if (comparePanel.id === panelToCheck.id) continue; if (collides(panelToCheck, comparePanel)) { - collidingPanels.push(comparePanel); + collidingPanels.push({ ...comparePanel, moved: false }); } } return collidingPanels; }; +const getFirstCollision = (gridLayout: GridRowData, keysInOrder: string[]): string | undefined => { + for (const panelA of keysInOrder) { + for (const panelB of keysInOrder) { + if (panelA === panelB) continue; + if (collides(gridLayout.panels[panelA], gridLayout.panels[panelB])) { + return panelA; + } + } + } + return undefined; +}; + export const getKeysInOrder = (panels: GridRowData['panels'], draggedId?: string): string[] => { const panelKeys = Object.keys(panels); return panelKeys.sort((panelKeyA, panelKeyB) => { @@ -78,109 +89,49 @@ const compactGridRow = (originalLayout: GridRowData) => { return nextRowData; }; -/** - * Our collision resolution algorithm works as follows: - * - Start by creating a 2D grid that contains arrays of panel IDs (i.e. a 3D array) - * - If a panel occupies a cell, then its panel ID is pushed into the cell array - * - Once you have this representation, row by row, resolve the collisions; i.e. - * - for each row, - * - while there are collisions - * - for each colliding panel, in reverse order, - * - move the panel down by a single row - * - if no collisions, break; otherwise, proceed to next panel - * Notes: - * - We know there is a collision if the panel ID array of a cell contains more than one ID - * - Pushing panels down is done in reverse order in order to maintain the original order of panels - * - i.e. the bottom-right-most panel is pushed down **first** - */ export const resolveGridRow = ( originalRowData: GridRowData, - columnCount: number, dragRequest?: GridPanelData ): GridRowData => { - const nextRowData = { ...originalRowData, panels: { ...originalRowData.panels } }; + let nextRowData = { ...originalRowData, panels: { ...originalRowData.panels } }; // apply drag request if (dragRequest) { nextRowData.panels[dragRequest.id] = dragRequest; } - - // build an empty 2D array representing the current grid - const panelRows = Object.values(nextRowData.panels).map( - ({ row: panelRow, height: panelHeight }) => panelRow + panelHeight - ); - if (panelRows.length === 0) return nextRowData; - const rowCount = Math.max(...panelRows); - const collisionGrid: string[][][] = new Array(rowCount) - .fill(null) - .map(() => new Array(columnCount).fill(null).map(() => new Array(0))); - - // for each panel, push its ID into the grid cells that it occupies + // get keys in order from top to bottom, left to right const sortedKeys = getKeysInOrder(nextRowData.panels, dragRequest?.id); - sortedKeys.forEach((panelKey) => { - const panel = nextRowData.panels[panelKey]; - for (let row = panel.row; row < panel.row + panel.height; row++) { - for (let column = panel.column; column < panel.column + panel.width; column++) { - collisionGrid[row][column].push(panelKey); - } - } - }); - - // move panels in reverse order to maintain the original order of panels - const orderToMove: { [panelKey: string]: number } = sortedKeys - .reverse() - .reduce((prev, panelKey, index) => { - return { ...prev, [panelKey]: index }; - }, {}); - const getCollisionsInOrder = (row: number) => { - const collisions: string[] = []; - collisionGrid[row].forEach((collisionArray) => { - if (collisionArray.length > 1) { - for (const panelKey of collisionArray) { - if (collisions.indexOf(panelKey) === -1 && panelKey !== dragRequest?.id) { - collisions.push(panelKey); - } - } - } - }); - return collisions.sort((key1, key2) => orderToMove[key1] - orderToMove[key2]); - }; - - // handle all collisions, row by row - for (let row = dragRequest?.row ?? 0; row < collisionGrid.length; row++) { - let collisions = getCollisionsInOrder(row); - - // continue pushing panels down until all collisions in this row have been resolved - while (collisions.length > 0) { - const panelKey = collisions.shift(); - if (!panelKey) break; - - // move the current colliding panel down - const panel = nextRowData.panels[panelKey]; - nextRowData.panels[panelKey].row += 1; - - // update the collision grid to keep it in sync - const currentGridHeight = collisionGrid.length; - if (row + panel.height >= currentGridHeight) { - collisionGrid.push(new Array(columnCount).fill(null).map(() => new Array(0))); - } - for ( - let panelColumn = panel.column; - panelColumn < panel.column + panel.width; - panelColumn++ - ) { - collisionGrid[panel.row - 1][panelColumn] = without( - collisionGrid[panel.row - 1][panelColumn], - panelKey - ); - collisionGrid[panel.row + panel.height - 1][panelColumn].push(panelKey); - } - - // re-check if the current row has collisions now that the panel has been moved down - collisions = getCollisionsInOrder(row); - } + // while the layout has at least one collision, try to resolve them + let collision = getFirstCollision(nextRowData, sortedKeys); // top-left most collision + while (collision !== undefined) { + nextRowData = resolvePanelCollisions(nextRowData, nextRowData.panels[collision], sortedKeys); + collision = getFirstCollision(nextRowData, sortedKeys); } - - const compactedGrid = compactGridRow(nextRowData); - return compactedGrid; + return compactGridRow(nextRowData); // compact the grid to close any gaps; }; + +/** + * for each panel that collides with `panelToResolve`, push the colliding panel down by a single row and + * recursively handle any collisions that result from that move + */ +function resolvePanelCollisions( + rowData: GridRowData, + panelToResolve: GridPanelData, + keysInOrder: string[] +): GridRowData { + const collisions = getAllCollisionsWithPanel(panelToResolve, rowData, keysInOrder); + for (const collision of collisions) { + if (collision.id === panelToResolve.id) continue; + rowData.panels[collision.id].row++; + rowData = resolvePanelCollisions( + rowData, + rowData.panels[collision.id], + /** + * when recursively resolving any collisions that result from moving this colliding panel down, + * ignore if `collision` is still colliding with `panelToResolve`t o prevent an infinite loop + */ + keysInOrder.filter((key) => key !== panelToResolve.id) + ); + } + return rowData; +} From 7c70bc7ae7885d46580c8a9d3dc5301a89a94e24 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Thu, 19 Dec 2024 15:31:04 -0700 Subject: [PATCH 12/14] Fix state comparison --- examples/grid_example/public/app.tsx | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/examples/grid_example/public/app.tsx b/examples/grid_example/public/app.tsx index 9519fd2af43a9..4f0a6c4a1dfa7 100644 --- a/examples/grid_example/public/app.tsx +++ b/examples/grid_example/public/app.tsx @@ -69,15 +69,19 @@ export const GridExample = ({ combineLatest([mockDashboardApi.panels$, mockDashboardApi.rows$]) .pipe(debounceTime(0)) // debounce to avoid subscribe being called twice when both panels$ and rows$ publish .subscribe(([panels, rows]) => { - const hasChanges = !( - deepEqual( - Object.values(panels).map(({ gridData }) => ({ row: 0, ...gridData })), - Object.values(savedState.current.panels).map(({ gridData }) => ({ - row: 0, // if row is undefined, then default to 0 - ...gridData, - })) - ) && deepEqual(rows, savedState.current.rows) - ); + const panelIds = Object.keys(panels); + let panelsAreEqual = true; + for (const panelId of panelIds) { + if (!panelsAreEqual) break; + const currentPanel = panels[panelId]; + const savedPanel = savedState.current.panels[panelId]; + panelsAreEqual = deepEqual( + { row: 0, ...currentPanel.gridData }, + { row: 0, ...savedPanel.gridData } + ); + } + + const hasChanges = !(panelsAreEqual && deepEqual(rows, savedState.current.rows)); setHasUnsavedChanges(hasChanges); setCurrentLayout(dashboardInputToGridLayout({ panels, rows })); }); From a5cf12bffd58331cf2215bd94c360b0cdfae3fa4 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Mon, 30 Dec 2024 09:37:46 -0700 Subject: [PATCH 13/14] Undo test changes --- packages/kbn-grid-layout/grid/grid_layout.test.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/kbn-grid-layout/grid/grid_layout.test.tsx b/packages/kbn-grid-layout/grid/grid_layout.test.tsx index 76c703ed4a6b6..f28703f748bf7 100644 --- a/packages/kbn-grid-layout/grid/grid_layout.test.tsx +++ b/packages/kbn-grid-layout/grid/grid_layout.test.tsx @@ -115,12 +115,12 @@ describe('GridLayout', () => { drop(panel1DragHandle); expect(getAllThePanelIds()).toEqual([ 'panel2', - 'panel1', 'panel5', 'panel3', 'panel7', - 'panel6', + 'panel1', 'panel8', + 'panel6', 'panel4', 'panel9', 'panel10', @@ -145,6 +145,7 @@ describe('GridLayout', () => { 'panel10', ]); }); + it('after replacing a panel id', async () => { const { rerender } = renderGridLayout(); const modifiedLayout = cloneDeep(getSampleLayout()); From 043dac506dfa23359dfe17c3d98f5f9b3c68ebdb Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Mon, 30 Dec 2024 12:46:28 -0700 Subject: [PATCH 14/14] Small cleanup --- .../grid/utils/resolve_grid_row.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts index 60bfdc4f19022..d41e3216ec1fb 100644 --- a/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts +++ b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts @@ -22,13 +22,13 @@ const getAllCollisionsWithPanel = ( panelToCheck: GridPanelData, gridLayout: GridRowData, keysInOrder: string[] -): Array => { - const collidingPanels: Array = []; +): GridPanelData[] => { + const collidingPanels: GridPanelData[] = []; for (const key of keysInOrder) { const comparePanel = gridLayout.panels[key]; if (comparePanel.id === panelToCheck.id) continue; if (collides(panelToCheck, comparePanel)) { - collidingPanels.push({ ...comparePanel, moved: false }); + collidingPanels.push(comparePanel); } } return collidingPanels; @@ -98,16 +98,16 @@ export const resolveGridRow = ( if (dragRequest) { nextRowData.panels[dragRequest.id] = dragRequest; } - // get keys in order from top to bottom, left to right + // get keys in order from top to bottom, left to right, with priority on the dragged item if it exists const sortedKeys = getKeysInOrder(nextRowData.panels, dragRequest?.id); - // while the layout has at least one collision, try to resolve them - let collision = getFirstCollision(nextRowData, sortedKeys); // top-left most collision + // while the layout has at least one collision, try to resolve them in order + let collision = getFirstCollision(nextRowData, sortedKeys); while (collision !== undefined) { nextRowData = resolvePanelCollisions(nextRowData, nextRowData.panels[collision], sortedKeys); collision = getFirstCollision(nextRowData, sortedKeys); } - return compactGridRow(nextRowData); // compact the grid to close any gaps; + return compactGridRow(nextRowData); // compact the grid to close any gaps }; /** @@ -128,7 +128,7 @@ function resolvePanelCollisions( rowData.panels[collision.id], /** * when recursively resolving any collisions that result from moving this colliding panel down, - * ignore if `collision` is still colliding with `panelToResolve`t o prevent an infinite loop + * ignore if `collision` is still colliding with `panelToResolve` to prevent an infinite loop */ keysInOrder.filter((key) => key !== panelToResolve.id) );