collage fixes
Some checks failed
Build & Push Docker (latest) / verify (push) Successful in 9m27s
Build & Push Docker (latest) / build (push) Has been cancelled

This commit is contained in:
Aiden Wilson
2026-05-29 23:45:52 +10:00
parent 4b488913e4
commit 30cd5c7b13
7 changed files with 372 additions and 55 deletions

279
public/collage.js Normal file
View File

@@ -0,0 +1,279 @@
const dataElement = document.getElementById("collage-data");
const stage = document.querySelector(".collage-stage");
const track = document.querySelector(".collage-track");
const config = JSON.parse(dataElement.textContent);
let lastFrame = performance.now();
let itemCursor = 0;
const columns = [];
const deviceScale = window.devicePixelRatio || 1;
const hydrationQueue = [];
const hydrateDelay = Math.max(Number(config.hydrateDelay) || 0, 0);
const placeholderHeight = 160;
const cardColumns = new WeakMap();
let trackTop = 0;
let hydrationTimer;
const cardObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const card = entry.target;
const column = cardColumns.get(card);
const previousExtent = cardExtent(card);
card.dataset.height = String(Math.max(entry.contentRect.height, placeholderHeight));
if (column) {
column.contentHeight += cardExtent(card) - previousExtent;
}
}
});
function snapPixel(value) {
return Math.round(value * deviceScale) / deviceScale;
}
function slugify(value = "") {
return String(value)
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "") || "unknown";
}
function executeScripts(root) {
for (const script of root.querySelectorAll("script")) {
const replacement = document.createElement("script");
for (const attribute of script.attributes) {
replacement.setAttribute(attribute.name, attribute.value);
}
replacement.textContent = script.textContent;
script.replaceWith(replacement);
}
}
function scheduleHydration(card, item) {
if (!hydrateDelay) {
hydrateCard(card, item);
return;
}
hydrationQueue.push({ card, item });
if (!hydrationTimer) {
hydrationTimer = setInterval(hydrateNextCard, hydrateDelay);
}
}
function hydrateNextCard() {
let next = hydrationQueue.shift();
while (next && !next.card.isConnected) {
next = hydrationQueue.shift();
}
if (!next) {
clearInterval(hydrationTimer);
hydrationTimer = undefined;
return;
}
hydrateCard(next.card, next.item);
}
function nudgeProviderWidgets(root) {
window.twttr?.widgets?.load?.(root);
window.instgrm?.Embeds?.process?.();
window.bluesky?.scan?.(root);
}
function hydrateCard(card, item) {
if (item.type === "photo" && item.url) {
const image = document.createElement("img");
image.src = item.url;
image.alt = item.title || "";
card.replaceChildren(image);
} else {
card.innerHTML = item.html || "";
}
executeScripts(card);
nudgeProviderWidgets(card);
card.classList.add("is-hydrated");
}
function createCard(column, item) {
const card = document.createElement("section");
card.className = `embed collage-card provider-${slugify(item.providerName)}`;
card.dataset.source = item.targetUrl;
card.dataset.height = String(placeholderHeight);
card.style.setProperty("--embed-width", `${config.cardWidth}px`);
cardColumns.set(card, column);
column.contentHeight += cardExtent(card);
cardObserver.observe(card);
scheduleHydration(card, item);
return card;
}
function createColumn(index) {
const element = document.createElement("div");
element.className = "collage-column";
element.dataset.column = String(index);
track.append(element);
return {
element,
index,
offset: 0,
contentHeight: 0,
};
}
function cardExtent(card) {
return cardHeight(card) + config.spacing;
}
function cardHeight(card) {
return Math.max(Number(card.dataset.height) || 0, placeholderHeight);
}
function candidateY(column) {
return column.contentHeight - column.offset;
}
function isTooCloseToSamePost(item, column) {
if (!config.repeatDistance || config.items.length < 2) {
return false;
}
const minDistanceSquared = config.repeatDistance * config.repeatDistance;
const nextY = candidateY(column);
for (const otherColumn of columns) {
const columnDistance = Math.abs(column.index - otherColumn.index) * (config.cardWidth + config.spacing);
for (const card of otherColumn.element.children) {
if (card.dataset.source !== item.targetUrl) {
continue;
}
const existingY = card.offsetTop - otherColumn.offset;
const yDistance = nextY - existingY;
const distanceSquared = (columnDistance * columnDistance) + (yDistance * yDistance);
if (distanceSquared < minDistanceSquared) {
return true;
}
}
}
return false;
}
function randomCandidate() {
return config.items[Math.floor(Math.random() * config.items.length)];
}
function sequentialCandidate(attempt) {
return config.items[(itemCursor + attempt) % config.items.length];
}
function nextItem(column) {
const maxAttempts = Math.max(config.items.length * 2, 8);
let fallback = config.items[0];
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
const item = config.shuffle ? randomCandidate() : sequentialCandidate(attempt);
fallback = item;
if (!isTooCloseToSamePost(item, column)) {
if (!config.shuffle) {
itemCursor += attempt + 1;
}
return item;
}
}
if (!config.shuffle) {
itemCursor += 1;
}
return fallback;
}
function fillColumn(column) {
const targetHeight = stage.clientHeight + Math.max(config.cardWidth, 720) + config.spacing;
let guard = 0;
while (column.contentHeight - column.offset < targetHeight && guard < 24) {
column.element.append(createCard(column, nextItem(column)));
guard += 1;
}
}
function fillColumns() {
for (const column of columns) {
fillColumn(column);
}
}
function recycleColumn(column) {
let firstCard = column.element.firstElementChild;
while (firstCard && isFullyAboveStage(firstCard, column)) {
column.contentHeight -= cardExtent(firstCard);
column.offset -= cardExtent(firstCard);
cardObserver.unobserve(firstCard);
cardColumns.delete(firstCard);
firstCard.remove();
column.element.append(createCard(column, nextItem(column)));
firstCard = column.element.firstElementChild;
}
}
function isFullyAboveStage(card, column) {
const cardBottom = trackTop + cardHeight(card) - column.offset;
return cardBottom <= 0;
}
function scrollSpeed() {
const baseDistance = Math.max(stage.clientHeight, config.cardWidth * config.repeat);
return baseDistance / config.duration;
}
function frame(now) {
const elapsedSeconds = Math.min((now - lastFrame) / 1000, 0.1);
lastFrame = now;
for (const column of columns) {
column.offset += scrollSpeed() * elapsedSeconds;
recycleColumn(column);
fillColumn(column);
column.element.style.transform = `translate3d(0, ${snapPixel(-column.offset)}px, 0)`;
}
requestAnimationFrame(frame);
}
for (let index = 0; index < config.columns; index += 1) {
columns.push(createColumn(index));
}
fillColumns();
trackTop = track.offsetTop;
for (const [index, column] of columns.entries()) {
const firstCard = column.element.firstElementChild;
const stagger = firstCard ? Math.min(index * config.spacing * 0.75, cardExtent(firstCard) * 0.4) : 0;
column.offset = stagger;
column.element.style.transform = `translate3d(0, ${snapPixel(-column.offset)}px, 0)`;
}
const observer = new ResizeObserver(() => {
trackTop = track.offsetTop;
fillColumns();
});
observer.observe(stage);
requestAnimationFrame(frame);

View File

@@ -215,6 +215,8 @@ code {
display: grid;
justify-items: center;
padding: var(--collage-spacing);
contain: layout paint;
pointer-events: none;
-webkit-mask-image: linear-gradient(
to bottom,
transparent 0,
@@ -232,19 +234,23 @@ code {
}
.collage-track {
display: flex;
flex-direction: column;
width: max-content;
align-items: stretch;
gap: var(--collage-spacing);
animation: collage-scroll var(--collage-duration) linear infinite;
will-change: transform;
}
.collage-group {
display: grid;
grid-template-columns: repeat(var(--collage-columns), minmax(0, var(--collage-card-width)));
gap: var(--collage-spacing);
width: max-content;
backface-visibility: hidden;
contain: layout style;
}
.collage-column {
display: flex;
flex-direction: column;
gap: var(--collage-spacing);
width: var(--collage-card-width);
backface-visibility: hidden;
contain: layout style;
transform: translate3d(0, 0, 0);
will-change: transform;
}
.collage-card {
@@ -252,10 +258,16 @@ code {
width: 100%;
height: auto;
max-width: var(--collage-card-width);
overflow: hidden;
max-height: none;
contain: layout style;
overflow: visible;
transform: none;
}
.collage-card:not(.is-hydrated) {
min-height: 160px;
}
.collage-card > iframe,
.collage-card > blockquote,
.collage-card > img,
@@ -274,16 +286,11 @@ code {
width: 100% !important;
max-width: 100% !important;
box-sizing: border-box;
margin: 0 !important;
}
@keyframes collage-scroll {
from {
transform: translateY(0);
}
to {
transform: translateY(calc(-50% - (var(--collage-spacing) / 2)));
}
.collage-card > * {
margin-block: 0 !important;
}
.error {