All files / app/features/picture/services picture-row-builder.ts

100% Statements 58/58
96.87% Branches 31/32
100% Functions 7/7
100% Lines 54/54

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),
            };
        }),
    };
}