Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 | 3x 3x 3x 3x 3x 15x 2x 13x 13x 13x 13x 13x 76x 76x 76x 76x 76x 76x 13x 13x 13x 76x 76x 13x 13x 13x 76x 3x 73x 73x 26x 26x 26x 13x 13x 13x 56x 13x 14x 14x 14x 58x 2x 2x 2x 2x 2x 14x 13x 2x 13x 26x 76x 76x | import { Picture } from '@drevo-web/shared';
const DEFAULT_ASPECT_RATIO = 3 / 4;
// Keep in sync with $picture-gap in picture-row.component.scss
const GAP = 8;
// Keep in sync with legacy ImageHelper.php resize(400, 400)
const THUMB_MAX_WIDTH = 400;
const THUMB_MAX_HEIGHT = 400;
export interface PictureRowItem {
readonly picture: Picture;
readonly width: number;
readonly height: number;
}
export interface PictureRow {
readonly items: readonly PictureRowItem[];
readonly height: number;
}
interface RowEntry {
readonly picture: Picture;
readonly aspectRatio: number;
readonly maxDisplayHeight: number;
}
/**
* Build justified rows of pictures (like Google Photos).
*
* Algorithm:
* 1. For each picture, compute aspect ratio and max display height (thumbnail constraint)
* 2. Add pictures to current row using effective width (capped by thumbnail size)
* 3. When row is full, compute row height so uncapped items fill remaining space exactly
* 4. Capped items keep their max height and get vertical padding via align-items:center
* 5. Last row uses target height (not stretched to fill)
*/
export function buildRows(
pictures: readonly Picture[],
containerWidth: number,
targetRowHeight: number,
): readonly PictureRow[] {
if (pictures.length === 0 || containerWidth <= 0) {
return [];
}
// Reserve right margin so pictures don't touch the scrollbar
containerWidth -= GAP;
const rows: PictureRow[] = [];
let currentItems: RowEntry[] = [];
let currentRowWidth = 0;
for (const picture of pictures) {
const aspectRatio =
picture.width !== undefined && picture.height !== undefined && picture.height > 0
? picture.width / picture.height
: DEFAULT_ASPECT_RATIO;
const maxDisplayHeight = getMaxDisplayHeight(picture);
const effectiveHeight = Math.min(targetRowHeight, maxDisplayHeight);
const scaledWidth = aspectRatio * effectiveHeight;
const gapSpace = currentItems.length * GAP;
if (currentItems.length > 0 && currentRowWidth + gapSpace + scaledWidth > containerWidth) {
rows.push(finalizeRow(currentItems, containerWidth));
currentItems = [];
currentRowWidth = 0;
}
currentItems.push({ picture, aspectRatio, maxDisplayHeight });
currentRowWidth += scaledWidth;
}
// Last row — use target height (don't stretch)
Eif (currentItems.length > 0) {
rows.push(finalizeRow(currentItems, containerWidth, targetRowHeight));
}
return rows;
}
/**
* Max display height for a picture without exceeding its thumbnail resolution.
* Thumbnails are generated at max THUMB_MAX_WIDTH × THUMB_MAX_HEIGHT (legacy ImageHelper).
* Returns Infinity if dimensions are unknown (no limit).
*/
function getMaxDisplayHeight(picture: Picture): number {
if (picture.width === undefined || picture.height === undefined || picture.height === 0) {
return Infinity;
}
const scale = Math.min(THUMB_MAX_WIDTH / picture.width, THUMB_MAX_HEIGHT / picture.height, 1);
return picture.height * scale;
}
/**
* Compute row height so the row fills containerWidth exactly.
* Capped items (thumbnail-limited) contribute fixed width; uncapped items stretch to fill the rest.
* Iterates because raising height for uncapped items may cause new items to hit their cap.
*/
function finalizeRow(
items: readonly RowEntry[],
containerWidth: number,
fixedHeight?: number,
): PictureRow {
const totalGap = (items.length - 1) * GAP;
const availableWidth = containerWidth - totalGap;
if (fixedHeight !== undefined) {
// Last row — fixed height, cap individual items
return buildRowResult(items, fixedHeight);
}
// Full row — iteratively solve for row height with thumbnail caps
const capped = new Array<boolean>(items.length).fill(false);
let cappedWidthSum = 0;
let uncappedAspectSum = items.reduce((sum, item) => sum + item.aspectRatio, 0);
for (let iter = 0; iter < items.length; iter++) {
const rowHeight = uncappedAspectSum > 0 ? (availableWidth - cappedWidthSum) / uncappedAspectSum : 0;
let changed = false;
for (let i = 0; i < items.length; i++) {
if (!capped[i] && rowHeight > items[i].maxDisplayHeight) {
capped[i] = true;
const fixedWidth = items[i].aspectRatio * items[i].maxDisplayHeight;
cappedWidthSum += fixedWidth;
uncappedAspectSum -= items[i].aspectRatio;
changed = true;
}
}
if (!changed) break;
}
const rowHeight =
uncappedAspectSum > 0
? (availableWidth - cappedWidthSum) / uncappedAspectSum
: Math.min(...items.map(item => item.maxDisplayHeight));
return buildRowResult(items, rowHeight);
}
function buildRowResult(items: readonly RowEntry[], rowHeight: number): PictureRow {
return {
height: rowHeight,
items: items.map(item => {
const itemHeight = Math.min(rowHeight, item.maxDisplayHeight);
return {
picture: item.picture,
width: Math.round(item.aspectRatio * itemHeight),
height: Math.round(itemHeight),
};
}),
};
}
|