From 61f9401fdeff4731833e3f54154d45dc41779256 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 15 May 2026 21:10:21 -0400 Subject: [PATCH] feat(fc2c-i): usePolyMasonry shortest-column distribution --- frontend/src/composables/usePolyMasonry.js | 57 ++++++++++++++++++++++ frontend/test/usePolyMasonry.spec.js | 39 +++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 frontend/src/composables/usePolyMasonry.js create mode 100644 frontend/test/usePolyMasonry.spec.js diff --git a/frontend/src/composables/usePolyMasonry.js b/frontend/src/composables/usePolyMasonry.js new file mode 100644 index 0000000..210fc94 --- /dev/null +++ b/frontend/src/composables/usePolyMasonry.js @@ -0,0 +1,57 @@ +// Shortest-column masonry distribution (ported from ImageRepo). +// +// Predictive relative height = height / width. Using the aspect ratio means +// columns are balanced before thumbnails load, so there is no reflow when +// images arrive. Missing or zero dimensions fall back to a square (1.0). + +import { ref, onMounted, onUnmounted } from 'vue' + +function relativeHeight(item) { + const w = Number(item.width) + const h = Number(item.height) + if (!w || !h) return 1 + return h / w +} + +export function distributeIntoColumns(items, columnCount) { + const n = Math.max(1, columnCount | 0) + const columns = Array.from({ length: n }, () => []) + const heights = new Array(n).fill(0) + for (const item of items) { + let shortest = 0 + for (let c = 1; c < n; c++) { + if (heights[c] < heights[shortest]) shortest = c + } + columns[shortest].push(item) + heights[shortest] += relativeHeight(item) + } + return columns +} + +// Reactive column count from container width. Breakpoints mirror the +// gallery grid intent: ~260px target column width, min 1 column. +export function columnCountForWidth(width) { + if (!width || width < 0) return 1 + return Math.max(1, Math.floor(width / 260)) +} + +export function usePolyMasonry(containerRef) { + const columnCount = ref(1) + let ro = null + + function measure() { + const el = containerRef.value + if (el) columnCount.value = columnCountForWidth(el.clientWidth) + } + + onMounted(() => { + measure() + if (typeof ResizeObserver !== 'undefined' && containerRef.value) { + ro = new ResizeObserver(measure) + ro.observe(containerRef.value) + } + }) + onUnmounted(() => ro && ro.disconnect()) + + return { columnCount, distribute: distributeIntoColumns } +} diff --git a/frontend/test/usePolyMasonry.spec.js b/frontend/test/usePolyMasonry.spec.js new file mode 100644 index 0000000..f71d1d7 --- /dev/null +++ b/frontend/test/usePolyMasonry.spec.js @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest' +import { distributeIntoColumns } from '../src/composables/usePolyMasonry.js' + +describe('distributeIntoColumns', () => { + it('returns the requested number of columns', () => { + const cols = distributeIntoColumns([], 3) + expect(cols.length).toBe(3) + expect(cols.every(c => c.length === 0)).toBe(true) + }) + + it('places each item in the then-shortest column', () => { + // 3 square items (relative height 1 each), 3 columns -> one per column. + const items = [ + { id: 1, width: 100, height: 100 }, + { id: 2, width: 100, height: 100 }, + { id: 3, width: 100, height: 100 } + ] + const cols = distributeIntoColumns(items, 3) + expect(cols.map(c => c.map(i => i.id))).toEqual([[1], [2], [3]]) + }) + + it('routes a 4th item to a balanced column', () => { + const items = [ + { id: 1, width: 100, height: 300 }, // tall -> col0 height 3 + { id: 2, width: 100, height: 100 }, // col1 height 1 + { id: 3, width: 100, height: 100 }, // col2 height 1 + { id: 4, width: 100, height: 100 } // shortest is col1 (1) -> col1 + ] + const cols = distributeIntoColumns(items, 3) + expect(cols[0].map(i => i.id)).toEqual([1]) + expect(cols[1].map(i => i.id)).toEqual([2, 4]) + expect(cols[2].map(i => i.id)).toEqual([3]) + }) + + it('treats missing/zero dimensions as a square (height 1)', () => { + const cols = distributeIntoColumns([{ id: 9 }], 1) + expect(cols[0]).toEqual([{ id: 9 }]) + }) +})