-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #170 from whereby/thomas/grid-layout
Grid component
- Loading branch information
Showing
16 changed files
with
1,580 additions
and
27 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
48 changes: 48 additions & 0 deletions
48
src/lib/react/Grid/helpers/__tests__/centerGridLayout.unit.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
import * as grid from "../centerGridLayout"; | ||
|
||
const sanityLayout = { | ||
cellWidth: 600, | ||
cellHeight: 450, | ||
rows: 1, | ||
cols: 1, | ||
gridGap: 0, | ||
extraHorizontalPadding: 0, | ||
extraVerticalPadding: 25, | ||
cellCount: 1, | ||
paddings: { top: 0, left: 0, bottom: 0, right: 0 }, | ||
}; | ||
|
||
const sanityLayout2 = { | ||
cellWidth: 400, | ||
cellHeight: 300, | ||
rows: 2, | ||
cols: 1, | ||
gridGap: 0, | ||
extraHorizontalPadding: 50, | ||
extraVerticalPadding: 0, | ||
cellCount: 2, | ||
paddings: { top: 0, left: 0, bottom: 0, right: 0 }, | ||
}; | ||
|
||
const NORMAL_AR = 4 / 3; | ||
|
||
describe("calculateLayout", () => { | ||
it.each` | ||
cellCount | width | height | gridGap | cellAspectRatios | expectedResult | ||
${1} | ${600} | ${500} | ${0} | ${[NORMAL_AR]} | ${sanityLayout} | ||
${2} | ${500} | ${600} | ${0} | ${[NORMAL_AR]} | ${sanityLayout2} | ||
`( | ||
"returns $expectedResult layout for $cellCount cell(s) in a (w: $width, h: $height) container", | ||
({ cellCount, width, height, gridGap, cellAspectRatios, expectedResult }) => { | ||
expect( | ||
grid.calculateLayout({ | ||
cellCount, | ||
width, | ||
height, | ||
gridGap, | ||
cellAspectRatios, | ||
}) | ||
).toEqual(expectedResult); | ||
} | ||
); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
import { getGridSizeForCount, fitToBounds } from "../gridUtils"; | ||
|
||
describe("getGridSizeForCount", () => { | ||
it.each` | ||
count | width | height | expectedResult | ||
${1} | ${500} | ${500} | ${{ rows: 1, cols: 1 }} | ||
${2} | ${500} | ${500} | ${{ rows: 2, cols: 1 }} | ||
${2} | ${1000} | ${500} | ${{ rows: 1, cols: 2 }} | ||
${3} | ${500} | ${500} | ${{ rows: 2, cols: 2 }} | ||
${3} | ${1000} | ${500} | ${{ rows: 1, cols: 3 }} | ||
${6} | ${500} | ${1600} | ${{ rows: 6, cols: 1 }} | ||
${6} | ${1600} | ${500} | ${{ rows: 2, cols: 3 }} | ||
${12} | ${500} | ${1000} | ${{ rows: 6, cols: 2 }} | ||
${12} | ${1600} | ${500} | ${{ rows: 2, cols: 6 }} | ||
${12} | ${500} | ${500} | ${{ rows: 4, cols: 3 }} | ||
`( | ||
"returns $expectedResult grid size for container bounds $width $height", | ||
({ count, width, height, expectedResult }) => { | ||
expect(getGridSizeForCount({ count, width, height, aspectRatio: 4 / 3 })).toEqual(expectedResult); | ||
} | ||
); | ||
}); | ||
|
||
describe("fitToBounds", () => { | ||
it.each` | ||
aspectRatio | width | height | expectedResult | ||
${0.5} | ${100} | ${100} | ${{ width: 50, height: 100 }} | ||
${1} | ${100} | ${100} | ${{ width: 100, height: 100 }} | ||
${2} | ${100} | ${100} | ${{ width: 100, height: 50 }} | ||
`( | ||
"returns $expectedResult grid size for container bounds $width $height", | ||
({ aspectRatio, width, height, expectedResult }) => { | ||
expect(fitToBounds(aspectRatio, { width, height })).toEqual(expectedResult); | ||
} | ||
); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
import { calculateStageLayout } from "../stageLayout"; | ||
|
||
const GRID_GAP_PX = 10; | ||
|
||
const wideScreenNoSubgrid = { | ||
hasOverflow: false, | ||
isPortrait: false, | ||
videosContainer: { | ||
bounds: { | ||
width: 600, | ||
height: 400, | ||
}, | ||
origin: { | ||
top: 0, | ||
left: 0, | ||
}, | ||
}, | ||
}; | ||
|
||
describe("calculateStageLayout", () => { | ||
it.each` | ||
width | height | isConstrained | isPortrait | expectedResult | ||
${600} | ${400} | ${true} | ${false} | ${wideScreenNoSubgrid} | ||
`( | ||
"returns expected stage layout in a (w: $width, h: $height) container isPortrait:$isPortrait", | ||
({ width, height, isPortrait, expectedResult }) => { | ||
expect( | ||
calculateStageLayout({ | ||
containerBounds: { width, height }, | ||
containerOrigin: { top: 0, left: 0 }, | ||
isPortrait, | ||
gridGap: GRID_GAP_PX, | ||
hasConstrainedOverflow: false, | ||
hasPresentationContent: true, | ||
hasVideoContent: true, | ||
}) | ||
).toEqual(expectedResult); | ||
} | ||
); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
export function makeVideoCellView({ | ||
aspectRatio, | ||
avatarSize, | ||
cellPaddings, | ||
client = undefined, | ||
isDraggable = true, | ||
isPlaceholder = false, | ||
isSubgrid = false, | ||
}: { | ||
aspectRatio?: number; | ||
avatarSize?: number; | ||
cellPaddings?: number; | ||
client?: { id: string }; | ||
isDraggable?: boolean; | ||
isPlaceholder?: boolean; | ||
isSubgrid?: boolean; | ||
}) { | ||
return { | ||
aspectRatio: aspectRatio || 16 / 9, | ||
avatarSize, | ||
cellPaddings, | ||
client, | ||
clientId: client?.id || "", | ||
isDraggable, | ||
isPlaceholder, | ||
isSubgrid, | ||
type: "video", | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,221 @@ | ||
import { getGridSizeForCount } from "./gridUtils"; | ||
|
||
import { Box, makeBox } from "./layout"; | ||
|
||
const WIDE_AR = 16 / 9; | ||
const NORMAL_AR = 4 / 3; | ||
|
||
const clamp = ({ value, min, max }: { value: number; min: number; max: number }) => Math.min(Math.max(value, min), max); | ||
|
||
function hasDuplicates<T>(...array: T[]) { | ||
return new Set(array).size !== array.length; | ||
} | ||
|
||
function findMostCommon<T>(arr: T[]) { | ||
return arr.sort((a, b) => arr.filter((v) => v === a).length - arr.filter((v) => v === b).length).pop(); | ||
} | ||
|
||
// Grid cells are all the same aspect ratio (not to be confused with the video cells) | ||
// Pick the best ratio given a list of the video cell ratios: | ||
export function pickCellAspectRatio({ choices = [] }: { choices: number[] }) { | ||
// If all cells are the same aspect ratio use that: | ||
const minAr = Math.min(...choices); | ||
const maxAr = Math.max(...choices); | ||
let chosenAr = null; | ||
if (minAr === maxAr) { | ||
chosenAr = minAr; | ||
} else { | ||
// Otherwise we're in a mixed grid. | ||
// We ideally want to make the majority ratio look nice. Pick the most common | ||
// ratio but limit it to wide cells. If we don't have a majority choice | ||
// just go with the widest: | ||
const dominantAr = hasDuplicates(choices) ? findMostCommon(choices) : maxAr; | ||
chosenAr = clamp({ value: dominantAr || maxAr, min: NORMAL_AR, max: WIDE_AR }); | ||
} | ||
return { | ||
minAr, | ||
maxAr, | ||
chosenAr, | ||
}; | ||
} | ||
|
||
// Calculate how much we need to move the last row horizontally so it | ||
// becomes centered: | ||
function getCenterPadding({ | ||
rows, | ||
cols, | ||
cellWidth, | ||
index, | ||
cellCount, | ||
gridGap, | ||
}: { | ||
rows: number; | ||
cols: number; | ||
cellWidth: number; | ||
index: number; | ||
cellCount: number; | ||
gridGap: number; | ||
}) { | ||
const max = rows * cols; | ||
|
||
const leftOver = max - cellCount; | ||
|
||
if (!leftOver) { | ||
return 0; | ||
} | ||
|
||
const lastIndex = max - leftOver - 1; | ||
const firstIndex = lastIndex - (cols - leftOver) + 1; | ||
|
||
const lastRowPadding = (leftOver * cellWidth) / 2 + gridGap; | ||
|
||
return index >= firstIndex && index <= lastIndex ? lastRowPadding : 0; | ||
} | ||
|
||
function getCellBounds({ | ||
width, | ||
height, | ||
rows, | ||
cols, | ||
gridGap, | ||
aspectRatio, | ||
}: { | ||
width: number; | ||
height: number; | ||
rows: number; | ||
cols: number; | ||
gridGap: number; | ||
aspectRatio: number; | ||
}) { | ||
// Naively calculate the cell size based on grid and container size: | ||
const cellWidth = (width - (cols - 1) * gridGap) / cols; | ||
const cellHeight = (height - (rows - 1) * gridGap) / rows; | ||
const ar = cellWidth / cellHeight; | ||
|
||
// Knowing the target cell aspect ratio, pull any extra space | ||
// into the grid padding: | ||
let horizontalCorrection = 0; | ||
let verticalCorrection = 0; | ||
|
||
if (aspectRatio < ar) { | ||
horizontalCorrection = cellWidth - cellHeight * aspectRatio; | ||
} else if (aspectRatio > ar) { | ||
verticalCorrection = cellHeight - cellWidth / aspectRatio; | ||
} | ||
|
||
const totalHorizontalCorrection = horizontalCorrection * cols; | ||
const totalVerticalCorrection = verticalCorrection * rows; | ||
|
||
return { | ||
cellWidth: cellWidth - horizontalCorrection, | ||
cellHeight: cellHeight - verticalCorrection, | ||
extraHorizontalPadding: totalHorizontalCorrection / 2, | ||
extraVerticalPadding: totalVerticalCorrection / 2, | ||
}; | ||
} | ||
|
||
export function calculateLayout({ | ||
width, | ||
height, | ||
cellCount, | ||
gridGap, | ||
cellAspectRatios = [NORMAL_AR], | ||
paddings = makeBox(), | ||
}: { | ||
width: number; | ||
height: number; | ||
cellCount: number; | ||
gridGap: number; | ||
cellAspectRatios?: number[]; | ||
paddings?: Box; | ||
}) { | ||
// Handle empty grid: | ||
if (!cellCount) { | ||
return { | ||
cellCount, | ||
cellHeight: 0, | ||
cellWidth: 0, | ||
cols: 0, | ||
rows: 0, | ||
extraHorizontalPadding: 0, | ||
extraVerticalPadding: 0, | ||
gridGap, | ||
paddings, | ||
}; | ||
} | ||
|
||
const contentWidth = width - (paddings.left + paddings.right); | ||
const contentHeight = height - (paddings.top + paddings.bottom); | ||
|
||
const cellAspectRatioTuple = pickCellAspectRatio({ | ||
choices: cellAspectRatios, | ||
}); | ||
let cellAspectRatio = cellAspectRatioTuple.chosenAr; | ||
|
||
const { rows, cols } = getGridSizeForCount({ | ||
count: cellCount, | ||
width: contentWidth, | ||
height: contentHeight, | ||
aspectRatio: cellAspectRatio, | ||
}); | ||
|
||
// Special case 1 col / row: | ||
// Divvy up available all space (within reason) | ||
if (rows === 1) { | ||
cellAspectRatio = clamp({ | ||
value: contentWidth / cols / contentHeight, | ||
min: Math.min(cellAspectRatioTuple.chosenAr, cellAspectRatioTuple.maxAr), | ||
max: Math.max(cellAspectRatioTuple.chosenAr, cellAspectRatioTuple.maxAr), | ||
}); | ||
} else if (cols === 1) { | ||
cellAspectRatio = clamp({ | ||
value: contentWidth / (contentHeight / rows), | ||
min: Math.min(cellAspectRatioTuple.chosenAr, cellAspectRatioTuple.maxAr), | ||
max: Math.max(cellAspectRatioTuple.chosenAr, cellAspectRatioTuple.maxAr), | ||
}); | ||
} | ||
|
||
const { cellWidth, cellHeight, extraHorizontalPadding, extraVerticalPadding } = getCellBounds({ | ||
width: contentWidth, | ||
height: contentHeight, | ||
rows, | ||
cols, | ||
gridGap, | ||
aspectRatio: cellAspectRatio, | ||
}); | ||
|
||
return { | ||
cellCount, | ||
cellHeight, | ||
cellWidth, | ||
cols, | ||
rows, | ||
extraHorizontalPadding, | ||
extraVerticalPadding, | ||
// pass through | ||
gridGap, | ||
paddings, | ||
}; | ||
} | ||
|
||
export function getCellPropsAtIndexForLayout({ | ||
index, | ||
layout, | ||
}: { | ||
index: number; | ||
layout: ReturnType<typeof calculateLayout>; | ||
}) { | ||
const { cellWidth, cellHeight, rows, cols, cellCount, gridGap } = layout; | ||
|
||
const top = Math.floor(index / cols); | ||
const left = Math.floor(index % cols); | ||
|
||
const leftPadding = getCenterPadding({ rows, cols, cellWidth, index, cellCount, gridGap }); | ||
|
||
return { | ||
top: top * cellHeight + top * gridGap, | ||
left: left * cellWidth + left * gridGap + leftPadding, | ||
width: cellWidth, | ||
height: cellHeight, | ||
}; | ||
} |
Oops, something went wrong.