diff --git a/src/array-utils.ts b/src/array-utils.ts new file mode 100644 index 0000000..cf37e4c --- /dev/null +++ b/src/array-utils.ts @@ -0,0 +1,38 @@ +/* +Copyright 2023 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Gets the index of the last element in the array to satsify the given + * predicate. + */ +// TODO: remove this once TypeScript recognizes the existence of +// Array.prototype.findLastIndex +export function findLastIndex( + array: T[], + predicate: (item: T) => boolean +): number | null { + for (let i = array.length - 1; i >= 0; i--) { + if (predicate(array[i])) return i; + } + + return null; +} + +/** + * Counts the number of elements in an array that satsify the given predicate. + */ +export const count = (array: T[], predicate: (item: T) => boolean): number => + array.reduce((acc, item) => (predicate(item) ? acc + 1 : acc), 0); diff --git a/src/video-grid/model.ts b/src/video-grid/model.ts index 48f19b3..14bd87e 100644 --- a/src/video-grid/model.ts +++ b/src/video-grid/model.ts @@ -17,6 +17,7 @@ limitations under the License. import TinyQueue from "tinyqueue"; import { TileDescriptor } from "./TileDescriptor"; +import { count, findLastIndex } from "../array-utils"; /** * A 1×1 cell in a grid which belongs to a tile. @@ -105,17 +106,6 @@ export function getPaths(dest: number, g: Grid): (number | null)[] { return edges as (number | null)[]; } -function findLastIndex( - array: T[], - predicate: (item: T) => boolean -): number | null { - for (let i = array.length - 1; i >= 0; i--) { - if (predicate(array[i])) return i; - } - - return null; -} - const findLast1By1Index = (g: Grid): number | null => findLastIndex(g.cells, (c) => c?.rows === 1 && c?.columns === 1); @@ -209,11 +199,117 @@ function getNextGap(g: Grid): number | null { return null; } +/** + * Gets the index of the origin of the tile to which the given cell belongs. + */ +function getOrigin(g: Grid, index: number): number { + const initialColumn = column(index, g); + + for ( + let i = index; + i >= 0; + i = column(i, g) === 0 ? i - g.columns + initialColumn : i - 1 + ) { + const cell = g.cells[i]; + if ( + cell !== undefined && + cell.origin && + inArea(index, i, areaEnd(i, cell.columns, cell.rows, g), g) + ) + return i; + } + + throw new Error("Tile is broken"); +} + +/** + * Moves the tile at index "from" over to index "to", displacing other tiles + * along the way. + * Precondition: the destination area must consist of only 1×1 tiles. + */ +function moveTile(g: Grid, from: number, to: number) { + const tile = g.cells[from]!; + const fromEnd = areaEnd(from, tile.columns, tile.rows, g); + const toEnd = areaEnd(to, tile.columns, tile.rows, g); + + const displacedTiles: Cell[] = []; + forEachCellInArea(to, toEnd, g, (c, i) => { + if (c !== undefined && !inArea(i, from, fromEnd, g)) displacedTiles.push(c); + }); + + const movingCells: Cell[] = []; + forEachCellInArea(from, fromEnd, g, (c, i) => { + movingCells.push(c!); + g.cells[i] = undefined; + }); + + forEachCellInArea( + to, + toEnd, + g, + (_c, i) => (g.cells[i] = movingCells.shift()) + ); + forEachCellInArea( + from, + fromEnd, + g, + (_c, i) => (g.cells[i] ??= displacedTiles.shift()) + ); +} + +/** + * Attempts to push a tile upwards by one row, displacing 1×1 tiles and shifting + * enlarged tiles around when necessary. + * @returns Whether the tile was actually pushed + */ +function pushTileUp(g: Grid, from: number): boolean { + const tile = g.cells[from]!; + + // TODO: pushing large tiles sideways might be more successful in some + // situations + const cellsAboveAreDisplacable = + from - g.columns >= 0 && + allCellsInArea( + from - g.columns, + from - g.columns + tile.columns - 1, + g, + (c, i) => + c === undefined || + (c.columns === 1 && c.rows === 1) || + pushTileUp(g, getOrigin(g, i)) + ); + + if (cellsAboveAreDisplacable) { + moveTile(g, from, from - g.columns); + return true; + } else { + return false; + } +} + /** * Backfill any gaps in the grid. */ export function fillGaps(g: Grid): Grid { const result: Grid = { ...g, cells: [...g.cells] }; + + // This will hopefully be the size of the grid after we're done here, assuming + // that we can pack the large tiles tightly enough + const idealLength = count(result.cells, (c) => c !== undefined); + + // Step 1: Take any large tiles hanging off the bottom of the grid, and push + // them upwards + for (let i = result.cells.length - 1; i >= idealLength; i--) { + const cell = result.cells[i]; + if (cell !== undefined && (cell.columns > 1 || cell.rows > 1)) { + const originIndex = + i - (cell.columns - 1) - result.columns * (cell.rows - 1); + // If it's not possible to pack the large tiles any tighter, give up + if (!pushTileUp(result, originIndex)) break; + } + } + + // Step 2: Fill all 1×1 gaps let gap = getNextGap(result); if (gap !== null) { @@ -263,9 +359,6 @@ export function fillGaps(g: Grid): Grid { } while (gap !== null); } - // TODO: If there are any large tiles on the last row, shuffle them back - // upwards into a full row - // Shrink the array to remove trailing gaps const finalLength = (findLastIndex(result.cells, (c) => c !== undefined) ?? -1) + 1; @@ -381,13 +474,18 @@ export function cycleTileSize(tileId: string, g: Grid): Grid { // Copy tiles from the original grid to the new one, with the new rows // inserted at the target location - g.cells.forEach((c, src) => { + g.cells.forEach((c, from) => { if (c?.origin && c.item.id !== tileId) { const offset = - row(src, g) > toRow + candidateHeight - 1 ? g.columns * newRows : 0; - forEachCellInArea(src, areaEnd(src, c.columns, c.rows, g), g, (c, i) => { - gappyGrid.cells[i + offset] = c; - }); + row(from, g) > toRow + candidateHeight - 1 ? g.columns * newRows : 0; + forEachCellInArea( + from, + areaEnd(from, c.columns, c.rows, g), + g, + (c, i) => { + gappyGrid.cells[i + offset] = c; + } + ); } }); diff --git a/test/video-grid/model-test.ts b/test/video-grid/model-test.ts index cc7741d..d8cfdd8 100644 --- a/test/video-grid/model-test.ts +++ b/test/video-grid/model-test.ts @@ -169,6 +169,50 @@ dddd iegh` ); +testFillGaps( + "keeps a large tile from hanging off the bottom", + ` +abcd +efgh + +ii +ii`, + ` +abcd +iigh +iief` +); + +testFillGaps( + "pushes a chain of large tiles upwards", + ` +abcd +e fg +hh +hh + ii + ii`, + ` +hhcd +hhfg +aiib +eii` +); + +testFillGaps( + "gives up on pushing large tiles upwards when not possible", + ` +aabb +aabb +cc +cc`, + ` +aabb +aabb +cc +cc` +); + function testCycleTileSize( title: string, tileId: string,