Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Grid component #170

Merged
merged 16 commits into from
Jan 30, 2024
Merged
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
Loading