PR-C1: adaptive video grid sizing (foundation) #2
2 changed files with 30 additions and 28 deletions
|
|
@ -85,6 +85,8 @@ export type AdaptiveTileGrid = {
|
||||||
type LayoutScore = {
|
type LayoutScore = {
|
||||||
/** Penalty for vertical overflow: 0 if none, else huge */
|
/** Penalty for vertical overflow: 0 if none, else huge */
|
||||||
verticalOverflowPenalty: number
|
verticalOverflowPenalty: number
|
||||||
|
/** Penalty for horizontal overflow: 0 if none, else huge */
|
||||||
|
horizontalOverflowPenalty: number
|
||||||
/** Penalty when tiles fall below MIN_TILE_HEIGHT even while fitting */
|
/** Penalty when tiles fall below MIN_TILE_HEIGHT even while fitting */
|
||||||
minTileHeightPenalty: number
|
minTileHeightPenalty: number
|
||||||
/** Negative tile area (we want to maximize area, so negate) */
|
/** Negative tile area (we want to maximize area, so negate) */
|
||||||
|
|
@ -98,6 +100,7 @@ type LayoutScore = {
|
||||||
const compareScores = (a: LayoutScore, b: LayoutScore): number => {
|
const compareScores = (a: LayoutScore, b: LayoutScore): number => {
|
||||||
const fields: (keyof LayoutScore)[] = [
|
const fields: (keyof LayoutScore)[] = [
|
||||||
"verticalOverflowPenalty",
|
"verticalOverflowPenalty",
|
||||||
|
"horizontalOverflowPenalty",
|
||||||
"minTileHeightPenalty",
|
"minTileHeightPenalty",
|
||||||
"negativeArea",
|
"negativeArea",
|
||||||
"whitespace",
|
"whitespace",
|
||||||
|
|
@ -125,8 +128,8 @@ const buildUniformRow = (
|
||||||
const rowHeight = availableHeight / rowCount
|
const rowHeight = availableHeight / rowCount
|
||||||
const tileWidth = (containerWidth - (columnCount - 1) * gap) / columnCount
|
const tileWidth = (containerWidth - (columnCount - 1) * gap) / columnCount
|
||||||
const fillAspect = tileWidth / rowHeight
|
const fillAspect = tileWidth / rowHeight
|
||||||
// No upper bound — allow wider than 16:9 to avoid overflow
|
// Cap at 16:9 to avoid horizontally stretching video (review feedback)
|
||||||
const aspectRatio = Math.max(TILE_ASPECT_PORTRAIT, fillAspect)
|
const aspectRatio = Math.max(TILE_ASPECT_PORTRAIT, Math.min(TILE_ASPECT_LANDSCAPE, fillAspect))
|
||||||
const tileHeight = tileWidth / aspectRatio
|
const tileHeight = tileWidth / aspectRatio
|
||||||
return {columnCount, tileWidth, tileHeight, aspectRatio}
|
return {columnCount, tileWidth, tileHeight, aspectRatio}
|
||||||
}
|
}
|
||||||
|
|
@ -160,31 +163,21 @@ const buildMixedCandidate = (
|
||||||
const tileWidthLast = (containerWidth - (remainder - 1) * gap) / remainder
|
const tileWidthLast = (containerWidth - (remainder - 1) * gap) / remainder
|
||||||
if (tileWidthLast <= 0) return undefined
|
if (tileWidthLast <= 0) return undefined
|
||||||
|
|
||||||
// Last row at 16:9 (widest aspect, per review: "single centered 16:9 tile")
|
// Find a common height that fits both row types and container
|
||||||
const lastRowAspect = TILE_ASPECT_LANDSCAPE
|
const maxPossibleHeight = Math.min(
|
||||||
const commonHeight = tileWidthLast / lastRowAspect
|
tileWidthFull / TILE_ASPECT_PORTRAIT,
|
||||||
|
tileWidthLast / TILE_ASPECT_PORTRAIT,
|
||||||
|
(containerHeight - (totalRowCount - 1) * gap) / totalRowCount,
|
||||||
|
)
|
||||||
|
|
||||||
// Full-row tile that matches the common height
|
// Target 16:9 for the last row if possible
|
||||||
const fullRowAspect = tileWidthFull / commonHeight
|
const targetHeight = tileWidthLast / TILE_ASPECT_LANDSCAPE
|
||||||
|
const commonHeight = Math.min(maxPossibleHeight, targetHeight)
|
||||||
|
|
||||||
let tileHeightFull: number
|
const tileHeightFull = commonHeight
|
||||||
let tileHeightLast: number
|
const tileHeightLast = commonHeight
|
||||||
let fullRowAspectRatio: number
|
const fullRowAspectRatio = tileWidthFull / commonHeight
|
||||||
let lastRowAspectRatio: number
|
const lastRowAspectRatio = tileWidthLast / commonHeight
|
||||||
|
|
||||||
if (fullRowAspect >= TILE_ASPECT_PORTRAIT) {
|
|
||||||
// Full-row aspect is within bounds — match heights cleanly
|
|
||||||
tileHeightFull = commonHeight
|
|
||||||
tileHeightLast = commonHeight
|
|
||||||
fullRowAspectRatio = fullRowAspect
|
|
||||||
lastRowAspectRatio = lastRowAspect
|
|
||||||
} else {
|
|
||||||
// Full rows would be too portrait — clamp to portrait bound
|
|
||||||
fullRowAspectRatio = TILE_ASPECT_PORTRAIT
|
|
||||||
tileHeightFull = tileWidthFull / fullRowAspectRatio
|
|
||||||
tileHeightLast = commonHeight
|
|
||||||
lastRowAspectRatio = lastRowAspect
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalHeight = fullRowCount * tileHeightFull + tileHeightLast + (totalRowCount - 1) * gap
|
const totalHeight = fullRowCount * tileHeightFull + tileHeightLast + (totalRowCount - 1) * gap
|
||||||
const totalWidth = Math.max(
|
const totalWidth = Math.max(
|
||||||
|
|
@ -293,11 +286,16 @@ export const computeAdaptiveGrid = (
|
||||||
// unacceptable — heavily penalize. If tiles are below MIN_TILE_HEIGHT,
|
// unacceptable — heavily penalize. If tiles are below MIN_TILE_HEIGHT,
|
||||||
// overflow is acceptable (tiles are already too small to shrink further).
|
// overflow is acceptable (tiles are already too small to shrink further).
|
||||||
const minTileHeightInCandidate = Math.min(...candidate.rows.map(r => r.tileHeight))
|
const minTileHeightInCandidate = Math.min(...candidate.rows.map(r => r.tileHeight))
|
||||||
const overflowPenalty =
|
const verticalOverflowPenalty =
|
||||||
verticalOverflow > 0 && minTileHeightInCandidate >= MIN_TILE_HEIGHT
|
verticalOverflow > 0 && minTileHeightInCandidate >= MIN_TILE_HEIGHT
|
||||||
? verticalOverflow * 1_000_000
|
? verticalOverflow * 1_000_000
|
||||||
: verticalOverflow
|
: verticalOverflow
|
||||||
|
|
||||||
|
const horizontalOverflowPenalty =
|
||||||
|
horizontalOverflow > 0 && minTileHeightInCandidate >= MIN_TILE_HEIGHT
|
||||||
|
? horizontalOverflow * 1_000_000
|
||||||
|
: horizontalOverflow
|
||||||
|
|
||||||
// Min-tile-height penalty: even when fitting, discourage layouts
|
// Min-tile-height penalty: even when fitting, discourage layouts
|
||||||
// where tiles are below the minimum acceptable size.
|
// where tiles are below the minimum acceptable size.
|
||||||
const minTileHeightPenalty =
|
const minTileHeightPenalty =
|
||||||
|
|
@ -306,7 +304,8 @@ export const computeAdaptiveGrid = (
|
||||||
: 0
|
: 0
|
||||||
|
|
||||||
const score: LayoutScore = {
|
const score: LayoutScore = {
|
||||||
verticalOverflowPenalty: overflowPenalty,
|
verticalOverflowPenalty,
|
||||||
|
horizontalOverflowPenalty,
|
||||||
minTileHeightPenalty,
|
minTileHeightPenalty,
|
||||||
negativeArea: -totalArea,
|
negativeArea: -totalArea,
|
||||||
whitespace,
|
whitespace,
|
||||||
|
|
|
||||||
|
|
@ -296,10 +296,13 @@
|
||||||
{#if tileGrid}
|
{#if tileGrid}
|
||||||
<div class="flex flex-col items-center gap-2">
|
<div class="flex flex-col items-center gap-2">
|
||||||
{#each tileGrid.rows as row, rowIndex}
|
{#each tileGrid.rows as row, rowIndex}
|
||||||
|
{@const offset = tileGrid.rows
|
||||||
|
.slice(0, rowIndex)
|
||||||
|
.reduce((sum, r) => sum + r.columnCount, 0)}
|
||||||
<div
|
<div
|
||||||
class="flex flex-wrap justify-center gap-2"
|
class="flex flex-wrap justify-center gap-2"
|
||||||
style={`max-width: ${row.columnCount * row.tileWidth + (row.columnCount - 1) * 8}px`}>
|
style={`max-width: ${row.columnCount * row.tileWidth + (row.columnCount - 1) * 8}px`}>
|
||||||
{#each videoTiles.slice(rowIndex * row.columnCount, rowIndex * row.columnCount + row.columnCount) as tile (tileKey(tile))}
|
{#each videoTiles.slice(offset, offset + row.columnCount) as tile (tileKey(tile))}
|
||||||
<div
|
<div
|
||||||
class="overflow-hidden rounded-2xl shrink-0"
|
class="overflow-hidden rounded-2xl shrink-0"
|
||||||
style={`width: ${row.tileWidth}px; height: ${row.tileHeight}px`}>
|
style={`width: ${row.tileWidth}px; height: ${row.tileHeight}px`}>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue