Skip to content

Commit

Permalink
Merge pull request #170 from whereby/thomas/grid-layout
Browse files Browse the repository at this point in the history
Grid component
  • Loading branch information
thyal authored Jan 30, 2024
2 parents 53919af + 43c2808 commit 1667b52
Show file tree
Hide file tree
Showing 16 changed files with 1,580 additions and 27 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@whereby.com/browser-sdk",
"version": "2.0.0",
"version": "2.1.0-beta1",
"description": "Modules for integration Whereby video in web apps",
"author": "Whereby AS",
"license": "MIT",
Expand Down
48 changes: 48 additions & 0 deletions src/lib/react/Grid/helpers/__tests__/centerGridLayout.unit.ts
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);
}
);
});
36 changes: 36 additions & 0 deletions src/lib/react/Grid/helpers/__tests__/gridUtils.unit.ts
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);
}
);
});
40 changes: 40 additions & 0 deletions src/lib/react/Grid/helpers/__tests__/stageLayout.unit.ts
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);
}
);
});
29 changes: 29 additions & 0 deletions src/lib/react/Grid/helpers/cellView.ts
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",
};
}
221 changes: 221 additions & 0 deletions src/lib/react/Grid/helpers/centerGridLayout.ts
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,
};
}
Loading

0 comments on commit 1667b52

Please sign in to comment.