diff --git a/src/video-grid/NewVideoGrid.tsx b/src/video-grid/NewVideoGrid.tsx index d22ad6e..2c25f62 100644 --- a/src/video-grid/NewVideoGrid.tsx +++ b/src/video-grid/NewVideoGrid.tsx @@ -1,3 +1,19 @@ +/* +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. +*/ + import { SpringRef, TransitionFn, useTransition } from "@react-spring/web"; import { EventTypes, Handler, useScroll } from "@use-gesture/react"; import React, { @@ -13,35 +29,17 @@ import styles from "./NewVideoGrid.module.css"; import { TileDescriptor } from "./TileDescriptor"; import { VideoGridProps as Props } from "./VideoGrid"; import { useReactiveState } from "../useReactiveState"; -import TinyQueue from "tinyqueue"; import { zipWith } from "lodash"; import { useMergedRefs } from "../useMergedRefs"; - -interface Cell { - /** - * The item held by the slot containing this cell. - */ - item: TileDescriptor; - /** - * Whether this cell is the first cell of the containing slot. - */ - // TODO: Rename to 'start'? - slot: boolean; - /** - * The width, in columns, of the containing slot. - */ - columns: number; - /** - * The height, in rows, of the containing slot. - */ - rows: number; -} - -interface Grid { - generation: number; - columns: number; - cells: (Cell | undefined)[]; -} +import { + Grid, + Cell, + row, + column, + fillGaps, + forEachCellInArea, + cycleTileSize, +} from "./model"; interface Rect { x: number; @@ -73,311 +71,6 @@ interface DragState { cursorY: number; } -const dijkstra = (g: Grid): number[] => { - const end = findLast1By1Index(g) ?? 0; - const endRow = row(end, g); - const endColumn = column(end, g); - - const distances = new Array(end + 1).fill(Infinity); - distances[end] = 0; - const edges = new Array(end).fill(undefined); - const heap = new TinyQueue([end], (i) => distances[i]); - - const visit = (curr: number, via: number) => { - const viaCell = g.cells[via]; - const viaLargeSlot = - viaCell !== undefined && (viaCell.rows > 1 || viaCell.columns > 1); - const distanceVia = distances[via] + (viaLargeSlot ? 4 : 1); - - if (distanceVia < distances[curr]) { - distances[curr] = distanceVia; - edges[curr] = via; - heap.push(curr); - } - }; - - while (heap.length > 0) { - const via = heap.pop()!; - const viaRow = row(via, g); - const viaColumn = column(via, g); - - if (viaRow > 0) visit(via - g.columns, via); - if (viaColumn > 0) visit(via - 1, via); - if (viaColumn < (viaRow === endRow ? endColumn : g.columns - 1)) - visit(via + 1, via); - if ( - viaRow < endRow - 1 || - (viaRow === endRow - 1 && viaColumn <= endColumn) - ) - visit(via + g.columns, via); - } - - return edges as number[]; -}; - -const 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); - -const row = (index: number, g: Grid): number => Math.floor(index / g.columns); -const column = (index: number, g: Grid): number => - ((index % g.columns) + g.columns) % g.columns; - -const inArea = ( - index: number, - start: number, - end: number, - g: Grid -): boolean => { - const indexColumn = column(index, g); - const indexRow = row(index, g); - return ( - indexRow >= row(start, g) && - indexRow <= row(end, g) && - indexColumn >= column(start, g) && - indexColumn <= column(end, g) - ); -}; - -function* cellsInArea( - start: number, - end: number, - g: Grid -): Generator { - const startColumn = column(start, g); - const endColumn = column(end, g); - for ( - let i = start; - i <= end; - i = - column(i, g) === endColumn - ? i + g.columns + startColumn - endColumn - : i + 1 - ) - yield i; -} - -const forEachCellInArea = ( - start: number, - end: number, - g: Grid, - fn: (c: Cell | undefined, i: number) => void -) => { - for (const i of cellsInArea(start, end, g)) fn(g.cells[i], i); -}; - -const allCellsInArea = ( - start: number, - end: number, - g: Grid, - fn: (c: Cell | undefined, i: number) => boolean -) => { - for (const i of cellsInArea(start, end, g)) { - if (!fn(g.cells[i], i)) return false; - } - - return true; -}; - -const areaEnd = ( - start: number, - columns: number, - rows: number, - g: Grid -): number => start + columns - 1 + g.columns * (rows - 1); - -/** - * Gets the index of the next gap in the grid that should be backfilled by 1×1 - * tiles. - */ -const getNextGap = (g: Grid): number | null => { - const last1By1Index = findLast1By1Index(g); - if (last1By1Index === null) return null; - - for (let i = 0; i < last1By1Index; i++) { - // To make the backfilling process look natural when there are multiple - // gaps, we actually scan each row from right to left - const j = - (row(i, g) === row(last1By1Index, g) - ? last1By1Index - : (row(i, g) + 1) * g.columns) - - 1 - - column(i, g); - - if (g.cells[j] === undefined) return j; - } - - return null; -}; - -const fillGaps = (g: Grid): Grid => { - const result: Grid = { ...g, cells: [...g.cells] }; - let gap = getNextGap(result); - - if (gap !== null) { - const pathToEnd = dijkstra(result); - - do { - let filled = false; - let to = gap; - let from: number | undefined = pathToEnd[gap]; - - // First, attempt to fill the gap by moving 1×1 tiles backwards from the - // end of the grid along a set path - while (from !== undefined) { - const toCell = result.cells[to]; - const fromCell = result.cells[from]; - - // Skip over large tiles - if (toCell !== undefined) { - to = pathToEnd[to]; - // Skip over large tiles. Also, we might run into gaps along the path - // created during the filling of previous gaps. Skip over those too; - // they'll be picked up on the next iteration of the outer loop. - } else if ( - fromCell === undefined || - fromCell.rows > 1 || - fromCell.columns > 1 - ) { - from = pathToEnd[from]; - } else { - result.cells[to] = result.cells[from]; - result.cells[from] = undefined; - filled = true; - to = pathToEnd[to]; - from = pathToEnd[from]; - } - } - - // In case the path approach failed, fall back to taking the very last 1×1 - // tile, and just dropping it into place - if (!filled) { - const last1By1Index = findLast1By1Index(result)!; - result.cells[gap] = result.cells[last1By1Index]; - result.cells[last1By1Index] = undefined; - } - - gap = getNextGap(result); - } 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; - if (finalLength < result.cells.length) - result.cells = result.cells.slice(0, finalLength); - - return result; -}; - -const cycleTileSize = (tileId: string, g: Grid): Grid => { - const from = g.cells.findIndex((c) => c?.item.id === tileId); - if (from === -1) return g; // Tile removed, no change - const fromWidth = g.cells[from]!.columns; - const fromHeight = g.cells[from]!.rows; - const fromEnd = areaEnd(from, fromWidth, fromHeight, g); - - const [toWidth, toHeight] = - fromWidth === 1 && fromHeight === 1 - ? [Math.min(3, Math.max(2, g.columns - 1)), 2] - : [1, 1]; - const newRows = Math.max( - 0, - Math.ceil((toWidth * toHeight - fromWidth * fromHeight) / g.columns) - ); - - const candidateWidth = toWidth; - const candidateHeight = toHeight - newRows; - - const gappyGrid: Grid = { - ...g, - generation: g.generation + 1, - cells: new Array(g.cells.length + newRows * g.columns), - }; - - const nextScanLocations = new Set([from]); - const scanColumnOffset = Math.floor((toWidth - 1) / 2); - const scanRowOffset = Math.floor((toHeight - 1) / 2); - const rows = row(g.cells.length - 1, g) + 1; - let to: number | null = null; - - const displaceable = (c: Cell | undefined, i: number): boolean => - c === undefined || - (c.columns === 1 && c.rows === 1) || - inArea(i, from, fromEnd, g); - - for (const scanLocation of nextScanLocations) { - const start = scanLocation - scanColumnOffset - g.columns * scanRowOffset; - const end = areaEnd(start, candidateWidth, candidateHeight, g); - const startColumn = column(start, g); - const startRow = row(start, g); - const endColumn = column(end, g); - - if ( - start >= 0 && - end < gappyGrid.cells.length && - endColumn - startColumn + 1 === candidateWidth - ) { - if (allCellsInArea(start, end, g, displaceable)) { - to = start; - break; - } - } - - if (startColumn > 0) nextScanLocations.add(scanLocation - 1); - if (endColumn < g.columns - 1) nextScanLocations.add(scanLocation + 1); - if (startRow > 0) nextScanLocations.add(scanLocation - g.columns); - if (startRow <= rows) nextScanLocations.add(scanLocation + g.columns); - } - - // TODO: Don't give up on placing the tile yet - if (to === null) return g; - - const toRow = row(to, g); - - g.cells.forEach((c, src) => { - if (c?.slot && 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; - }); - } - }); - - const displacedTiles: Cell[] = []; - const toEnd = areaEnd(to, toWidth, toHeight, g); - forEachCellInArea(to, toEnd, gappyGrid, (c, i) => { - if (c !== undefined) displacedTiles.push(c); - gappyGrid.cells[i] = { - item: g.cells[from]!.item, - slot: i === to, - columns: toWidth, - rows: toHeight, - }; - }); - - for (let i = 0; displacedTiles.length > 0; i++) { - if (gappyGrid.cells[i] === undefined) - gappyGrid.cells[i] = displacedTiles.shift(); - } - - return fillGaps(gappyGrid); -}; - export const NewVideoGrid: FC = ({ items, disableAnimations, diff --git a/src/video-grid/model.ts b/src/video-grid/model.ts new file mode 100644 index 0000000..0a4136c --- /dev/null +++ b/src/video-grid/model.ts @@ -0,0 +1,348 @@ +/* +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. +*/ + +import TinyQueue from "tinyqueue"; +import { TileDescriptor } from "./TileDescriptor"; + +export interface Cell { + /** + * The item held by the slot containing this cell. + */ + item: TileDescriptor; + /** + * Whether this cell is the first cell of the containing slot. + */ + // TODO: Rename to 'start'? + slot: boolean; + /** + * The width, in columns, of the containing slot. + */ + columns: number; + /** + * The height, in rows, of the containing slot. + */ + rows: number; +} + +export interface Grid { + generation: number; + columns: number; + cells: (Cell | undefined)[]; +} + +export function dijkstra(g: Grid): number[] { + const end = findLast1By1Index(g) ?? 0; + const endRow = row(end, g); + const endColumn = column(end, g); + + const distances = new Array(end + 1).fill(Infinity); + distances[end] = 0; + const edges = new Array(end).fill(undefined); + const heap = new TinyQueue([end], (i) => distances[i]); + + const visit = (curr: number, via: number) => { + const viaCell = g.cells[via]; + const viaLargeSlot = + viaCell !== undefined && (viaCell.rows > 1 || viaCell.columns > 1); + const distanceVia = distances[via] + (viaLargeSlot ? 4 : 1); + + if (distanceVia < distances[curr]) { + distances[curr] = distanceVia; + edges[curr] = via; + heap.push(curr); + } + }; + + while (heap.length > 0) { + const via = heap.pop()!; + const viaRow = row(via, g); + const viaColumn = column(via, g); + + if (viaRow > 0) visit(via - g.columns, via); + if (viaColumn > 0) visit(via - 1, via); + if (viaColumn < (viaRow === endRow ? endColumn : g.columns - 1)) + visit(via + 1, via); + if ( + viaRow < endRow - 1 || + (viaRow === endRow - 1 && viaColumn <= endColumn) + ) + visit(via + g.columns, via); + } + + return edges as number[]; +} + +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); + +export function row(index: number, g: Grid): number { + return Math.floor(index / g.columns); +} + +export function column(index: number, g: Grid): number { + return ((index % g.columns) + g.columns) % g.columns; +} + +function inArea(index: number, start: number, end: number, g: Grid): boolean { + const indexColumn = column(index, g); + const indexRow = row(index, g); + return ( + indexRow >= row(start, g) && + indexRow <= row(end, g) && + indexColumn >= column(start, g) && + indexColumn <= column(end, g) + ); +} + +function* cellsInArea( + start: number, + end: number, + g: Grid +): Generator { + const startColumn = column(start, g); + const endColumn = column(end, g); + for ( + let i = start; + i <= end; + i = + column(i, g) === endColumn + ? i + g.columns + startColumn - endColumn + : i + 1 + ) + yield i; +} + +export function forEachCellInArea( + start: number, + end: number, + g: Grid, + fn: (c: Cell | undefined, i: number) => void +): void { + for (const i of cellsInArea(start, end, g)) fn(g.cells[i], i); +} + +function allCellsInArea( + start: number, + end: number, + g: Grid, + fn: (c: Cell | undefined, i: number) => boolean +): boolean { + for (const i of cellsInArea(start, end, g)) { + if (!fn(g.cells[i], i)) return false; + } + + return true; +} + +const areaEnd = ( + start: number, + columns: number, + rows: number, + g: Grid +): number => start + columns - 1 + g.columns * (rows - 1); + +/** + * Gets the index of the next gap in the grid that should be backfilled by 1×1 + * tiles. + */ +function getNextGap(g: Grid): number | null { + const last1By1Index = findLast1By1Index(g); + if (last1By1Index === null) return null; + + for (let i = 0; i < last1By1Index; i++) { + // To make the backfilling process look natural when there are multiple + // gaps, we actually scan each row from right to left + const j = + (row(i, g) === row(last1By1Index, g) + ? last1By1Index + : (row(i, g) + 1) * g.columns) - + 1 - + column(i, g); + + if (g.cells[j] === undefined) return j; + } + + return null; +} + +export function fillGaps(g: Grid): Grid { + const result: Grid = { ...g, cells: [...g.cells] }; + let gap = getNextGap(result); + + if (gap !== null) { + const pathToEnd = dijkstra(result); + + do { + let filled = false; + let to = gap; + let from: number | undefined = pathToEnd[gap]; + + // First, attempt to fill the gap by moving 1×1 tiles backwards from the + // end of the grid along a set path + while (from !== undefined) { + const toCell = result.cells[to]; + const fromCell = result.cells[from]; + + // Skip over large tiles + if (toCell !== undefined) { + to = pathToEnd[to]; + // Skip over large tiles. Also, we might run into gaps along the path + // created during the filling of previous gaps. Skip over those too; + // they'll be picked up on the next iteration of the outer loop. + } else if ( + fromCell === undefined || + fromCell.rows > 1 || + fromCell.columns > 1 + ) { + from = pathToEnd[from]; + } else { + result.cells[to] = result.cells[from]; + result.cells[from] = undefined; + filled = true; + to = pathToEnd[to]; + from = pathToEnd[from]; + } + } + + // In case the path approach failed, fall back to taking the very last 1×1 + // tile, and just dropping it into place + if (!filled) { + const last1By1Index = findLast1By1Index(result)!; + result.cells[gap] = result.cells[last1By1Index]; + result.cells[last1By1Index] = undefined; + } + + gap = getNextGap(result); + } 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; + if (finalLength < result.cells.length) + result.cells = result.cells.slice(0, finalLength); + + return result; +} + +export function cycleTileSize(tileId: string, g: Grid): Grid { + const from = g.cells.findIndex((c) => c?.item.id === tileId); + if (from === -1) return g; // Tile removed, no change + const fromWidth = g.cells[from]!.columns; + const fromHeight = g.cells[from]!.rows; + const fromEnd = areaEnd(from, fromWidth, fromHeight, g); + + const [toWidth, toHeight] = + fromWidth === 1 && fromHeight === 1 + ? [Math.min(3, Math.max(2, g.columns - 1)), 2] + : [1, 1]; + const newRows = Math.max( + 0, + Math.ceil((toWidth * toHeight - fromWidth * fromHeight) / g.columns) + ); + + const candidateWidth = toWidth; + const candidateHeight = toHeight - newRows; + + const gappyGrid: Grid = { + ...g, + generation: g.generation + 1, + cells: new Array(g.cells.length + newRows * g.columns), + }; + + const nextScanLocations = new Set([from]); + const scanColumnOffset = Math.floor((toWidth - 1) / 2); + const scanRowOffset = Math.floor((toHeight - 1) / 2); + const rows = row(g.cells.length - 1, g) + 1; + let to: number | null = null; + + const displaceable = (c: Cell | undefined, i: number): boolean => + c === undefined || + (c.columns === 1 && c.rows === 1) || + inArea(i, from, fromEnd, g); + + for (const scanLocation of nextScanLocations) { + const start = scanLocation - scanColumnOffset - g.columns * scanRowOffset; + const end = areaEnd(start, candidateWidth, candidateHeight, g); + const startColumn = column(start, g); + const startRow = row(start, g); + const endColumn = column(end, g); + + if ( + start >= 0 && + end < gappyGrid.cells.length && + endColumn - startColumn + 1 === candidateWidth + ) { + if (allCellsInArea(start, end, g, displaceable)) { + to = start; + break; + } + } + + if (startColumn > 0) nextScanLocations.add(scanLocation - 1); + if (endColumn < g.columns - 1) nextScanLocations.add(scanLocation + 1); + if (startRow > 0) nextScanLocations.add(scanLocation - g.columns); + if (startRow <= rows) nextScanLocations.add(scanLocation + g.columns); + } + + // TODO: Don't give up on placing the tile yet + if (to === null) return g; + + const toRow = row(to, g); + + g.cells.forEach((c, src) => { + if (c?.slot && 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; + }); + } + }); + + const displacedTiles: Cell[] = []; + const toEnd = areaEnd(to, toWidth, toHeight, g); + forEachCellInArea(to, toEnd, gappyGrid, (c, i) => { + if (c !== undefined) displacedTiles.push(c); + gappyGrid.cells[i] = { + item: g.cells[from]!.item, + slot: i === to, + columns: toWidth, + rows: toHeight, + }; + }); + + for (let i = 0; displacedTiles.length > 0; i++) { + if (gappyGrid.cells[i] === undefined) + gappyGrid.cells[i] = displacedTiles.shift(); + } + + return fillGaps(gappyGrid); +}