diff --git a/docs/website-content.md b/docs/website-content.md index 0f43433..43d8c9f 100644 --- a/docs/website-content.md +++ b/docs/website-content.md @@ -63,6 +63,11 @@ pages with `reproduces` and to depicted objects with `depicts`. The collection contains objects and sources. Its subnavigation uses the label “Documents and images” / “Documentos e imágenes” for sources. The site publishes object and source indexes and detail pages. +The object index uses masonry placement in the shortest available column, with +four columns on wider screens and two on narrow screens. Images retain their +natural proportions without cropping or height limits. A small client script +repacks the cards after search filtering and size changes; document and keyboard +order remain unchanged. Without JavaScript, the same cards use a regular grid. Sources are listed at `/en/sources/` and `/es/fuentes/`; articles are listed at `/en/articles/` and `/es/articulos/`, labelled “Articles” / “Artículos”. Articles are authored MoSA publications rather than collection records. Their diff --git a/src/components/collection-masonry.test.ts b/src/components/collection-masonry.test.ts new file mode 100644 index 0000000..7a894a5 --- /dev/null +++ b/src/components/collection-masonry.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { masonryPositions } from "./collection-masonry"; + +describe("collection masonry", () => { + it("packs short cards alongside an uncapped tall image in document order", () => { + expect(masonryPositions([2000, 100, 100, 50], 2, 20)).toEqual({ + positions: [ + { column: 0, top: 0 }, + { column: 1, top: 0 }, + { column: 1, top: 120 }, + { column: 1, top: 240 }, + ], + height: 2000, + }); + }); + + it("starts again with the first column for a filtered result", () => { + expect(masonryPositions([50], 4, 20)).toEqual({ + positions: [{ column: 0, top: 0 }], + height: 50, + }); + expect(masonryPositions([], 4, 20)).toEqual({ positions: [], height: 0 }); + }); + + it("breaks equal-height ties from left to right and omits the final gap", () => { + expect(masonryPositions([100, 100, 50, 60], 2, 20)).toEqual({ + positions: [ + { column: 0, top: 0 }, + { column: 1, top: 0 }, + { column: 0, top: 120 }, + { column: 1, top: 120 }, + ], + height: 180, + }); + }); +}); diff --git a/src/components/collection-masonry.ts b/src/components/collection-masonry.ts new file mode 100644 index 0000000..9c11e97 --- /dev/null +++ b/src/components/collection-masonry.ts @@ -0,0 +1,59 @@ +export function masonryPositions(heights: number[], columns: number, gap: number) { + const bottoms = Array(columns).fill(0); + const positions = heights.map((height) => { + const top = Math.min(...bottoms); + const column = bottoms.indexOf(top); + bottoms[column] = top + height + gap; + return { column, top }; + }); + return { positions, height: Math.max(0, ...bottoms) - (heights.length ? gap : 0) }; +} + +export function initialiseCollectionMasonry(container: HTMLElement, cards: HTMLElement[]) { + container.dataset.masonry = "true"; + + const layout = () => { + if (container.hidden) return; + const style = getComputedStyle(container); + const columns = Number(style.getPropertyValue("--collection-columns")); + const columnGap = Number.parseFloat(style.columnGap); + const rowGap = Number.parseFloat(style.rowGap); + const width = container.getBoundingClientRect().width; + const columnWidth = (width - columnGap * (columns - 1)) / columns; + const visible = cards.filter((card) => !card.hidden); + // Read all sizes before writing positions to avoid repeated layout work. + const { positions, height } = masonryPositions( + visible.map((card) => card.getBoundingClientRect().height), + columns, + rowGap, + ); + visible.forEach((card, index) => { + const { column, top } = positions[index]; + card.style.left = `${column * (columnWidth + columnGap)}px`; + card.style.top = `${top}px`; + }); + container.style.height = `${height}px`; + }; + + let frame = 0; + const scheduleLayout = () => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(layout); + }; + let previousWidth = 0; + const observer = new ResizeObserver((entries) => { + const changed = entries.some((entry) => { + if (entry.target !== container) return true; + // Our own height updates must not trigger another layout indefinitely. + const width = entry.contentRect.width; + if (width === previousWidth) return false; + previousWidth = width; + return true; + }); + if (changed) scheduleLayout(); + }); + observer.observe(container); + for (const card of cards) observer.observe(card); + layout(); + return layout; +} diff --git a/src/i18n/collection-client.ts b/src/i18n/collection-client.ts index 7c2d015..86aa22b 100644 --- a/src/i18n/collection-client.ts +++ b/src/i18n/collection-client.ts @@ -1,3 +1,4 @@ +import { initialiseCollectionMasonry } from "../components/collection-masonry"; import { referenceCount } from "./messages"; import { matchesSearch } from "./search"; import { updateLanguageLinks } from "./switcher"; @@ -11,6 +12,7 @@ const cards = Array.from(document.querySelectorAll(".collection-car const locale = document.querySelector("[data-locale]")?.dataset.locale === "en" ? "en" : "es"; if (controls && search && results && count && empty && cards.length) { + const layout = initialiseCollectionMasonry(results, cards); controls.hidden = false; const filter = () => { let visible = 0; @@ -26,6 +28,7 @@ if (controls && search && results && count && empty && cards.length) { count.textContent = referenceCount(locale, { count: visible }); empty.hidden = visible > 0; results.hidden = visible === 0; + layout(); const url = new URL(location.href); for (const key of ["concept", "type", "view"]) url.searchParams.delete(key); if (search.value) url.searchParams.set("q", search.value); diff --git a/src/page-templates/CollectionPage.astro b/src/page-templates/CollectionPage.astro index 825496f..35b2d4d 100644 --- a/src/page-templates/CollectionPage.astro +++ b/src/page-templates/CollectionPage.astro @@ -86,16 +86,17 @@ const t = messages(locale); margin-bottom: 2rem; } .collection-grid { + --collection-columns: 4; + --collection-column-gap: 1rem; display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 3.5rem 1rem; + grid-template-columns: repeat(var(--collection-columns), minmax(0, 1fr)); + align-items: start; + gap: 3.5rem var(--collection-column-gap); margin-top: 2rem; } -.collection-card:nth-child(3) { - grid-column: 4; -} -.collection-card:nth-child(5) { - grid-column: 3; +.collection-grid[data-masonry] { + display: block; + position: relative; } .collection-caption { margin-top: 1.25rem; @@ -128,15 +129,11 @@ const t = messages(locale); margin-right: 0.5rem; text-decoration: underline; } - -@media (min-width: 801px) { - .collection-grid:has(.collection-card[hidden]) { - grid-template-columns: repeat(4, minmax(0, 1fr)); - } - .collection-grid:has(.collection-card[hidden]) .collection-card { - grid-column: auto; - } +.collection-grid[data-masonry] .collection-card { + position: absolute; + width: calc((100% - (var(--collection-columns) - 1) * var(--collection-column-gap)) / var(--collection-columns)); } + @media (max-width: 1100px) { .info-card { padding: 1.5rem; @@ -144,11 +141,8 @@ const t = messages(locale); } @media (max-width: 800px) { .collection-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 2.5rem 1rem; - } - .collection-card:nth-child(n) { - grid-column: auto; + --collection-columns: 2; + row-gap: 2.5rem; } } @media (max-width: 520px) {