From 91d9efc5b321ef34749e04d5f88ee69ff12b53ba Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Thu, 2 Jan 2025 16:04:20 -0700 Subject: [PATCH] [Dashboard][Collapsable Panels] New collision resolution algorithm (#204134) Closes https://github.com/elastic/kibana/issues/191306 ## Summary This PR updates the collision resolution algorithm to handle the edge case described in the linked issue. In the old algorithm, we checked each panel for collisions a **single time** - however, this caused a bug where panels could get re-ordered depending on the order that we pushed panels down + how much distance we were pushing them down by. While this algorithm was extremely efficient (approximately $`O \left({ { n^2 + n } \over 2 }\right)`$ before compaction where `n` is the number of panels), it was not as simple as "fixing" the bug because, no matter what, there was always an edge case that could not be handled without adding some sort of backtracking - instead, we had to come up with a new algorithm. ### Example of Edge Case To better understand how the old and new algorithms compare (and how they work), consider a scenario where the grid layout has 8 columns and the panels are laid out as follows: ![image](https://github.com/user-attachments/assets/94fbb8f6-b6b8-4f9e-af91-0128717cdffc) Now, what happens when panel 4 (yellow) is dragged to the left so that it overlaps both panel 3 (red) and panel 4 (blue)?
Old Algorithm With the old algorithm, we get the following behaviour: ![Dec-16-2024 17-04-58](https://github.com/user-attachments/assets/9a920046-cf53-4dff-bfbe-6ed0aed05f77) This is because we are **only** pushing the collided panels down **once**. So, to describe what happens in detail, we start by gathering up the panels **in order** (top-bottom, left-right, where dragged panels take priority when rows are equal) - this results in an order of `[panel4, panel3, panel1, panel2]`. We start checking for collisions in this order - starting with panel 4 (yellow), we determine that it is colliding with panel 3 (red) and panel 2 (blue). We start with panel 3 (since we are once again doing this "in order"), and we push it down by 5 rows (since this is the overlap of the panel 3 and panel 4). Then, to "resolve" the second collision with panel 4, we push down panel 2 by 3 rows. This leaves the layout in the following state: ![image](https://github.com/user-attachments/assets/47aa981e-9e4f-4c30-8570-abf6ba4a20ba) Now, since panel 4 (yellow) is "resolved", we move on to the next panel in the `[panel4, panel3, panel1, panel2]` order - panel 3 (red). Panel 3 now collides with panel 2, so we push panel 2 down by two rows. This is the only collision with panel 3, so we check panel 1 and panel 2, neither of which report collisions at this point. So the layout ends in the following state: ![image](https://github.com/user-attachments/assets/4c5e3aca-399c-4075-9897-a5d2d73e9284) So, what's the problem here? Well, panel 1 (green) is now "out of sync" with the old panel ordering because it got "left behind" in the collision resolution. Instead, we should have moved panel 1 by the same amount as panel 2.
New Algorithm With the new algorithm, we get the expected behaviour: ![Dec-16-2024 17-30-28](https://github.com/user-attachments/assets/4cd7d49d-138b-4b59-ac78-0ea2cbc86c3c) If we run through the algorithm, we identify that the **first** collision in the layout (based on left to right, top to bottom ordering, with priority being the panel being dragged) is panel 4 (yellow) - so, we enter the `while` loop and recurse to resolve all collisions with panel 4 via `resolvePanelCollisions`. The recursion within `resolvePanelCollisions` works as follows - get **all** collisions with panel 4 (which, at this point, returns panel 3 (red) and panel 2 (blue)) and push them down by a single row, then recurse on **those** panels to resolve any of the resulting collisions. It's difficult to describe recursion, but let me try my best :) During the collision resolution on panel 4, we start by pushing panel 3 down and then recursively resolving the collisions on panel 3. Since pushing panel 3 causes collisions with panel 1 and 2 (in order), we push them both down by one row - starting with panel 1. We recurse to resolve the collisions on panel 1, which has no collisions - then we do the same for panel 2, which also reports no collisions. The collisions with panel 3 are "resolved" (i.e. we can "back out" of the recursion on panels 1 and 2), so we go back to resolving panel 4, push panel **2** down, and then resolve the collisions on panel 2 - which reports no collisions. In summary: ![image](https://github.com/user-attachments/assets/ca82ca05-673e-4bf1-b22f-e2aa1c7b6ba4) Therefore, after the first iteration of the `while` loop, this results in the following layout, where panel 3 (red) has been pushed down by one row, panel 1 (green) has been pushed down by one row, and panel 2 (blue) has been pushed down by two rows: ![image](https://github.com/user-attachments/assets/ec7c08a5-d0e9-46f7-bf3c-2eede7cef6fd) We then return to the `while` loop and check if the layout has any other collisions - and panel 4 is still our "first" collision! So we repeat the process above and push both panel 4 and panel 2 down and recurse to resolve the resulting collisions on those panels. This continues as follows: | Steps | Resulting Layout | |--------|--------| | | | | | | | | | | | | And so, after our fifth iteration of the `while` loop, the layout is **no longer** reporting collisions - so, our layout is now fully resolved!
### Performance Comparison This algorithm is more-or-less the same as the algorithm used in `react-grid-layout` - however, instead of running on **every frame**, our layout engine only resolves collisions **as they happen** (hence why the while loop is necessary). This results in much better rendering performance. To test this, I opened the Logs sample dashboard in both the example app (which is obviously using `kbn-grid-layout`) and Dashboard (which is still using `react-grid-layout`) and performed various drag actions on both while recording their performance. For example, consider the following capture of a drag that doesn't cause **any** collisions: | `kbn-grid-layout` | `react-grid-layout` | |--------|--------| | ![image](https://github.com/user-attachments/assets/7fa1e9bb-e3dd-4f99-b011-0a46e53f0e88) | ![image](https://github.com/user-attachments/assets/05e86a48-769d-4bae-ba48-d2fb3bdde90e) | You can see that, even with no collisions, the performance is slightly better with `kbn-grid-layout` because we don't **ever** recurse on collisions in this scenario. But it gets even better when actual collisions happen - for example, consider the performance when the dragged panel only causes a **single** collision: | `kbn-grid-layout` | `react-grid-layout` | |--------|--------| | ![image](https://github.com/user-attachments/assets/5e39a616-aa96-43be-a316-cb8fc4258b88) | ![image](https://github.com/user-attachments/assets/520ab5b6-7049-4028-9d8c-63972aff9e92) | Versus when the dragged panel causes multiple collisions at the top of the dashboard that causes a chain reaction of panels needing to be pushed down: | `kbn-grid-layout` | `react-grid-layout` | |--------|--------| | ![image](https://github.com/user-attachments/assets/4d6b15c6-181a-4ea0-a179-09702b50411a) | ![image](https://github.com/user-attachments/assets/363ef360-3c4f-4327-8031-18aca3833bce) | In all of the above scenarios, performance is improved overall **and** there are fewer red "spikes" / bottlenecks due to [forced reflow](https://web.dev/articles/avoid-large-complex-layouts-and-layout-thrashing?utm_source=devtools#avoid-forced-synchronous-layouts) along the way. Most of the time saved is caused by significant reductions to both `rendering` and `painting` time in `kbn-grid-layout` when compared to `react-grid-layout`. While some of this improvement may be explained by differences in the example grid plugin versus the Dashboard plugin, this is enough proof that performance of `kbn-grid-layout` is **at least as good as** `react-grid-layout` and so swapping out the layout engine should not cause any performance regressions. ### Other Notable Algorithm Attempts I tried a few **drag/resize event specific** algorithms that **only work** when an interaction event is happening - i.e. they rely on information from the panel that is being interacted with in order to function. Note that, if we decided to go this route, we would still need some generic collision resolution algorithm (such as the one proposed in this PR) that we could use to resolve a layout when **no** interaction event is happening. After all, we cannot assume that the grid layout that is sent as a prop is valid (i.e. it **could** have overlapping panels **at the start**, before any dragging and/or resizing has occurred) and we need a way to verify + resolve it. Unfortunately, both of the "interaction-event necessary" collision resolution algorithms that I tried suffered from bugs so, while it might be **possible** to make a more efficient algorithm for this, I ultimately threw them away in favour of using the "generic" collision resolution algorithm proposed in this PR. That being said, I figured it was worth documenting the things that I tried and why they failed.
Algorithm 1 I tried a modification of the **original** collision resolution algorithm where, instead of **just** moving the colliding panels down by the `rowOverlap` amount, we move **all** panels in or below the targeted grid row down by the **height** of the panel being dragged. However, due to the compaction algorithm ignoring **all** panels that have collisions, you can end up in an infinite "push panels down" loop like so: ![Dec-16-2024 15-31-22](https://github.com/user-attachments/assets/29364168-6bd5-4b62-839e-a63636af71f5) This is because panel 3 is **always** colliding with panel 1 and 4, so they never get pushed up to "close" the gaps. To try and get around this, I modified the collision detection used for panel compaction so that collisions only "stop" compaction if the collision occurs on a panel **above** the given panel we are trying to compact. However, this caused a **different** bug: ![Dec-16-2024 15-12-22](https://github.com/user-attachments/assets/34f23bfd-6a5d-4ff5-8421-10b7bab89551) In the above GIF, consider what happens when panel 3 (red) targets row 3. As expected, we move all other panels (1, 2, and 4) down by a single row. Then, when we reach the compaction step, we start by trying to push panel 1 up (since it is now the top-left-most panel) - and, since we are filtering out the collision with panel 3 (since it occurs "below" panel 1), it gets pushed all the way to the top. Then, we push panel 4 all the way to the top (because it is also ignoring the collision with panel 3), followed by pushing panel 2 up until it would collide with panel 3. Unfortunately though, we didn't account for the full height of panel 4 - so panel 3 is now stuck colliding with panel 4. I modified this algorithm **further** to move the panels down by the "row overlap" with the dragged panel rather than the dragged panel's height - but this also suffers from a bug: ![Dec-16-2024 16-29-12](https://github.com/user-attachments/assets/950cb573-0caf-4c44-8f63-2fc465c2c497) At which point, I abandoned this approach becase it felt like there were too many edge cases that could not all be accounted for. It's possible we could explore this further but, given the limitations of this algorithm **only working** for resolving collisions given an interaction event, I opted to move on.
Algorithm 2 I tried an algorithm where, similar to the algorithm proposed in this PR, I recorded the panel IDs in each cell that a given panel occupied - however, instead of moving things down row by row and handling the resulting collisions, we moved **every** panel that was **below** the targetted row down by the **height** of the panel being dragged. In theory, the compaction algorithm would then take control of closing all the resulting gaps. Unfortunately, this didn't quite work in practice. Consider the following: ![Dec-16-2024 10-17-21](https://github.com/user-attachments/assets/8157dabd-bb52-4089-9493-74cbdcf49e5e) According to the algorithm defined above, panel 2 (blue) and panel 4 (yellow) both need to move down when panel 3 (red) is dragged to target row 4 (remember that row **zero** is the first row), since they both occupy this row - so we push both of them down by 1 (the height of panel 3). However, once we start compacting the layout in order to finish our collision resolution, we hit a snag with panel 4 (yellow) - when we start trying to compact panel 4, `getAllCollisionsWithPanel` returns that **panel 3** is colliding with panel 4 and so we **break out** of the compaction - hence why panel 4 continues to float. This then has a snowball effect on all other panels as they report collisions and therefore do not get pushed up.
Algorithm 3 This was the algorithm we **almost** went with, because it works! But it suffered from two things - one, the code was complicated and difficult to maintain and two, the collision resolution behaviour felt less natural because it **always** favoured the dragged panel. This made it too sensitive to pushing items down; so even if a panel was targeting the bottom row of a different panel, it would push that panel down to make room for the dragged panel. In the simplest terms, this algorithm worked as follows: after creating a 3D array representing the current grid (a 2D array of panel ID arrays), for each row, while there are collisions, push panels down in **reverse order** (bottom right to top left); continue until all rows have no collisions. This algorithm hd an efficiency of approximately $`O\left(r * c \right)`$ to set up the 3D array and $`O \left( c * h * n \right)`$ to resolve the conflicts, where `r` is the number of rows, `c` is the number of columns (minus 1), `h` is the height of the dragged panel, and `n` is the number of panels. This results in an overall speed of about $`O \left( (r * c) + (c * h * n) \right)`$ To better understand how this algorithm works, consider how it handles the edge case described above:
Algorithm 3 - Edge Case Example Rather than checking every **panel** for collisions a single time, we check and resolve each **row** for collisions and resolve them. This gives the following behaviour: ![Dec-16-2024 17-30-28](https://github.com/user-attachments/assets/60cf3347-707c-4bc2-99e5-0e042c4cbed3) We start by creating a 3D array representing the grid - at the **first** point of collision, it looks like: | | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | |--------|--------|--------|--------|--------|--------|--------|--------|--------| | **0** | `[panel3]` | `[panel3]` | `[panel3]` | `[panel3, panel4]` | `[panel4]` | `[panel4]` | `[panel4]` | `[]` | | **1** | `[panel3]` | `[panel3]` | `[panel3]` | `[panel3, panel4]` | `[panel4]` | `[panel4]` | `[panel4]` | `[]` | | **2** | `[panel1]` | `[panel1]` | `[panel2]` | `[panel2, panel4]` | `[panel4]` | `[panel4]` | `[panel4]` | `[]` | | **3** | `[panel1]` | `[panel1]` | `[panel2]` | `[panel2, panel4]` | `[panel4]` | `[panel4]` | `[panel4]` | `[]` | | **4** | `[panel1]` | `[panel1]` | `[panel2]` | `[panel2, panel4]` | `[panel4]` | `[panel4]` | `[panel4]` | `[]` | Then, we start checking and resolving collisions, row by row. Starting with row 0, we see that column 3 has a collision between panel 3 and panel 4 - we **ignore** the panel being dragged (panel 4), so we push down panel 3 by one row. Row 0 has no more collisions, so we move on to row 1 and, following the same logic as above, we push down panel 3 again. This leaves the grid in the following state: ![image](https://github.com/user-attachments/assets/47ee1f8c-5107-41eb-8309-527e6fd773be) | | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | |--------|--------|--------|--------|--------|--------|--------|--------|--------| | **0** | `[]` | `[]` | `[]` | `[panel4]` | `[panel4]` | `[panel4]` | `[panel4]` | `[]` | | **1** | `[]` | `[]` | `[]` | `[panel4]` | `[panel4]` | `[panel4]` | `[panel4]` | `[]` | | **2** | `[panel1, panel3]` | `[panel1, panel3]` | `[panel2, panel3]` | `[panel3, panel2, panel4]` | `[panel4]` | `[panel4]` | `[panel4]` | `[]` | | **3** | `[panel1, panel3]` | `[panel1, panel3]` | `[panel2, panel3]` | `[panel3, panel2, panel4]` | `[panel4]` | `[panel4]` | `[panel4]` | `[]` | | **4** | `[panel1]` | `[panel1]` | `[panel2]` | `[panel2, panel4]` | `[panel4]` | `[panel4]` | `[panel4]` | `[]` | Now, on row 2, we have collisions in columns zero through three, so we push the panels in **reverse order** (i.e from the bottom right to the top left) - this results in pushing panel 2 down, then panel 1, and finally panel 3. We have to add a row in order to accomplish this because otherwise we "lose" the bottom of panel 2, so our layout now looks like this and row 2 has no more collisions: ![image](https://github.com/user-attachments/assets/b5697592-cb0e-4fac-90c9-6f3ade5e9db9) | | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | |--------|--------|--------|--------|--------|--------|--------|--------|--------| | **0** | `[]` | `[]` | `[]` | `[panel4]` | `[panel4]` | `[panel4]` | `[panel4]` | `[]` | | **1** | `[]` | `[]` | `[]` | `[panel4]` | `[panel4]` | `[panel4]` | `[panel4]` | `[]` | | **2** | `[]` | `[]` | `[]` | `[panel4]` | `[panel4]` | `[panel4]` | `[panel4]` | `[]` | | **3** | `[panel1, panel3]` | `[panel1, panel3]` | `[panel2, panel3]` | `[panel3, panel2, panel4]` | `[panel4]` | `[panel4]` | `[panel4]` | `[]` | | **4** | `[panel1, panel3]` | `[panel1, panel3]` | `[panel2, panel3]` | `[panel3, panel2, panel4]` | `[panel4]` | `[panel4]` | `[panel4]` | `[]` | | **5** | `[panel1]` | `[panel1]` | `[panel2]` | `[panel2]` | `[]` | `[]` | `[]` | `[]` | Continuing this behaviour, we push panel 2, panel 1, and panel 3 down in row 3; then again in row 4. Once we reach and resolve row 5 (i.e. get row 5 to the point where there are no collisions with panel 3), our layout looks like so: ![image](https://github.com/user-attachments/assets/e59de766-a547-499a-a181-7841179032c0) | | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | |--------|--------|--------|--------|--------|--------|--------|--------|--------| | **0** | `[]` | `[]` | `[]` | `[panel4]` | `[panel4]` | `[panel4]` | `[panel4]` | `[]` | | **1** | `[]` | `[]` | `[]` | `[panel4]` | `[panel4]` | `[panel4]` | `[panel4]` | `[]` | | **2** | `[]` | `[]` | `[]` | `[panel4]` | `[panel4]` | `[panel4]` | `[panel4]` | `[]` | | **3** | `[]` | `[]` | `[]` | `[panel4]` | `[panel4]` | `[panel4]` | `[panel4]` | `[]` | | **4** | `[]` | `[]` | `[]` | `[panel4]` | `[panel4]` | `[panel4]` | `[panel4]` | `[]` | | **5** | `[panel3]` | `[panel3]` | `[panel3]` | `[panel3]` | `[]` | `[]` | `[]` | `[]` | | **6** | `[panel3, panel1]` | `[panel3, panel1]` | `[panel2, panel3]` | `[panel2, panel3]` | `[]` | `[]` | `[]` | `[]` | | **7** | `[panel1]` | `[panel1]` | `[panel2]` | `[panel2]` | `[]` | `[]` | `[]` | `[]` | | **8** | `[panel1]` | `[panel1]` | `[panel2]` | `[panel2]` | `[]` | `[]` | `[]` | `[]` | At this point, all collisions on panel 4 have been resolved. So, in row 6, we see that panels 2 and 1 are now colliding with panel 3 - so, we push both of them down. At that point, row 6 does not have collisions so we **don't** push panel 3 down any further - and our layout is resolved! ![image](https://github.com/user-attachments/assets/71869176-473f-4a48-8d0f-863b855628c4) | | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | |--------|--------|--------|--------|--------|--------|--------|--------|--------| | **0** | `[]` | `[]` | `[]` | `[panel4]` | `[panel4]` | `[panel4]` | `[panel4]` | `[]` | | **1** | `[]` | `[]` | `[]` | `[panel4]` | `[panel4]` | `[panel4]` | `[panel4]` | `[]` | | **2** | `[]` | `[]` | `[]` | `[panel4]` | `[panel4]` | `[panel4]` | `[panel4]` | `[]` | | **3** | `[]` | `[]` | `[]` | `[panel4]` | `[panel4]` | `[panel4]` | `[panel4]` | `[]` | | **4** | `[]` | `[]` | `[]` | `[panel4]` | `[panel4]` | `[panel4]` | `[panel4]` | `[]` | | **5** | `[panel3]` | `[panel3]` | `[panel3]` | `[panel3]` | `[]` | `[]` | `[]` | `[]` | | **6** | `[panel3]` | `[panel3]` | `[panel3]` | `[panel3]` | `[]` | `[]` | `[]` | `[]` | | **7** | `[panel1]` | `[panel1]` | `[panel2]` | `[panel2]` | `[]` | `[]` | `[]` | `[]` | | **8** | `[panel1]` | `[panel1]` | `[panel2]` | `[panel2]` | `[]` | `[]` | `[]` | `[]` | | **9** | `[panel1]` | `[panel1]` | `[panel2]` | `[panel2]` | `[]` | `[]` | `[]` | `[]` |
### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) ### Identify risks There are no risks to this PR, since all work is contained in the `examples` plugin. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- examples/grid_example/public/app.tsx | 22 ++- .../public/use_mock_dashboard_api.tsx | 2 + .../kbn-grid-layout/grid/grid_layout.test.tsx | 5 + .../grid/utils/resolve_grid_row.test.ts | 155 ++++++++++++++++++ .../grid/utils/resolve_grid_row.ts | 65 ++++++-- 5 files changed, 222 insertions(+), 27 deletions(-) create mode 100644 packages/kbn-grid-layout/grid/utils/resolve_grid_row.test.ts 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 })); }); 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/grid_layout.test.tsx b/packages/kbn-grid-layout/grid/grid_layout.test.tsx index 33b1bad784618..f28703f748bf7 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); }; @@ -123,6 +126,7 @@ describe('GridLayout', () => { 'panel10', ]); }); + it('after removing a panel', async () => { const { rerender } = renderGridLayout(); const sampleLayoutWithoutPanel1 = cloneDeep(getSampleLayout()); @@ -141,6 +145,7 @@ describe('GridLayout', () => { 'panel10', ]); }); + it('after replacing a panel id', async () => { const { rerender } = renderGridLayout(); const modifiedLayout = cloneDeep(getSampleLayout()); 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..b194e89c3241e --- /dev/null +++ b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.test.ts @@ -0,0 +1,155 @@ +/* + * 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); + 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 }, + }, + }); + 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 }, + }, + }, + { 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 }, + }, + }, + { 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 }, + }, + }, + { 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 }, + }, + }, + { 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 38b778b5d0571..d41e3216ec1fb 100644 --- a/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts +++ b/packages/kbn-grid-layout/grid/utils/resolve_grid_row.ts @@ -34,6 +34,18 @@ const getAllCollisionsWithPanel = ( 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) => { @@ -81,28 +93,45 @@ export const resolveGridRow = ( originalRowData: GridRowData, dragRequest?: GridPanelData ): GridRowData => { - const nextRowData = { ...originalRowData, panels: { ...originalRowData.panels } }; - - // Apply drag request + let nextRowData = { ...originalRowData, panels: { ...originalRowData.panels } }; + // apply drag request if (dragRequest) { nextRowData.panels[dragRequest.id] = dragRequest; } - // return nextRowData; - - // push all panels down if they collide with another panel + // 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); - for (const key of sortedKeys) { - const panel = nextRowData.panels[key]; - const collisions = getAllCollisionsWithPanel(panel, nextRowData, sortedKeys); - - for (const collision of collisions) { - const rowOverlap = panel.row + panel.height - collision.row; - if (rowOverlap > 0) { - collision.row += rowOverlap; - } - } + // 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); } - 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` to prevent an infinite loop + */ + keysInOrder.filter((key) => key !== panelToResolve.id) + ); + } + return rowData; +}