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 157 | 3x 3x 3x 3x 3x 3x 18x 2x 16x 16x 16x 16x 16x 131x 131x 131x 131x 131x 131x 16x 16x 16x 131x 131x 16x 16x 16x 131x 3x 128x 128x 32x 32x 32x 16x 16x 106x 90x 16x 106x 16x 16x 63x 63x 63x 13x 13x 50x 50x 16x 49x 16x 32x 131x 131x | 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;
const MIN_ROW_HEIGHT = 200;
// 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 via sorted single-pass capping
* 4. If all items are capped, use MIN_ROW_HEIGHT fallback; gaps remain fixed at GAP,
* so any remaining space stays at the row end
* 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 visually fills containerWidth (gaps remain fixed at GAP).
*
* Sorted single-pass capping: process items by maxDisplayHeight ascending.
* Mathematically guaranteed: if rowHeight <= maxH[i], then rowHeight <= maxH[j] for all j > i
* (sorted ascending). One pass, no iterations.
*
* If all items are capped, rowHeight falls back to max(MIN_ROW_HEIGHT, max(maxDisplayHeights)).
*/
function finalizeRow(items: readonly RowEntry[], containerWidth: number, fixedHeight?: number): PictureRow {
const totalGap = (items.length - 1) * GAP;
const availableWidth = containerWidth - totalGap;
if (fixedHeight !== undefined) {
return buildRowResult(items, fixedHeight);
}
const sortedIndices = items
.map((_, i) => i)
.sort((a, b) => items[a].maxDisplayHeight - items[b].maxDisplayHeight);
let cappedWidthSum = 0;
let uncappedAspectSum = items.reduce((sum, item) => sum + item.aspectRatio, 0);
let allCapped = true;
for (const idx of sortedIndices) {
const item = items[idx];
const rowHeight = uncappedAspectSum > 0
? (availableWidth - cappedWidthSum) / uncappedAspectSum
: 0;
if (rowHeight <= item.maxDisplayHeight) {
allCapped = false;
break;
}
cappedWidthSum += item.aspectRatio * item.maxDisplayHeight;
uncappedAspectSum -= item.aspectRatio;
}
const rowHeight = allCapped
? Math.max(MIN_ROW_HEIGHT, Math.max(...items.map(it => it.maxDisplayHeight)))
: (availableWidth - cappedWidthSum) / uncappedAspectSum;
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),
};
}),
};
}
|