Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/website-content.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions src/components/collection-masonry.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
59 changes: 59 additions & 0 deletions src/components/collection-masonry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
export function masonryPositions(heights: number[], columns: number, gap: number) {
const bottoms = Array<number>(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;
}
3 changes: 3 additions & 0 deletions src/i18n/collection-client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { initialiseCollectionMasonry } from "../components/collection-masonry";
import { referenceCount } from "./messages";
import { matchesSearch } from "./search";
import { updateLanguageLinks } from "./switcher";
Expand All @@ -11,6 +12,7 @@ const cards = Array.from(document.querySelectorAll<HTMLElement>(".collection-car
const locale =
document.querySelector<HTMLElement>("[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;
Expand All @@ -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);
Expand Down
34 changes: 14 additions & 20 deletions src/page-templates/CollectionPage.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -128,27 +129,20 @@ 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;
}
}
@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) {
Expand Down
Loading