const dataElement = document.getElementById("collage-data") as HTMLScriptElement; const stage = document.querySelector(".collage-stage"); const track = document.querySelector(".collage-track"); const config = JSON.parse(dataElement.textContent); if (!stage || !track) { throw new Error("Collage stage could not be initialized."); } 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 as HTMLElement; 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);