topfans/横向瀑布流.html
2026-04-27 18:20:38 +08:00

420 lines
18 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>横向瀑布流 · 完美填满与呼吸间隙</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { background:#0f0f14; overflow:hidden; height:100vh; height:-webkit-fill-available; font-family:system-ui, -apple-system, sans-serif; }
.scroll-container {
position:fixed; top:0; left:0; width:100vw; height:100vh; height:-webkit-fill-available;
overflow-x:auto; overflow-y:hidden; -webkit-overflow-scrolling:touch; cursor:grab;
}
.scroll-container:active { cursor:grabbing; }
.scroll-container.dragging { cursor:grabbing; }
.waterfall-inner { position:relative; height:100%; min-height:100%; }
.card {
position:absolute; border-radius:14px; overflow:hidden; cursor:pointer;
transition: transform 0.25s, box-shadow 0.3s; will-change:transform;
box-shadow:0 4px 18px rgba(0,0,0,0.35); border:1px solid rgba(255,255,255,0.06);
background-size:cover; background-position:center;
display:flex; flex-direction:column; justify-content:flex-end; align-items:center;
}
.card:active { transform:scale(0.96); }
.card .pattern-layer { position:absolute; inset:0; opacity:0.25; pointer-events:none; }
.card .likes {
position:relative; z-index:1; color:white; font-weight:700; font-size:1rem;
text-shadow:0 2px 6px rgba(0,0,0,0.7); margin-bottom:12px; user-select:none;
}
.header {
position:fixed; top:0; left:0; right:0; z-index:100; padding:12px 18px;
display:flex; align-items:center; justify-content:space-between;
background:linear-gradient(180deg, rgba(15,15,20,0.95) 50%, rgba(15,15,20,0) 100%);
pointer-events:none;
}
.header > * { pointer-events:auto; }
.header-title { font-size:1.05rem; font-weight:700; color:#fff; display:flex; align-items:center; gap:8px; }
.header-dot { width:9px; height:9px; border-radius:50%; background:#ff6b6b; animation:pulse 2s infinite; }
@keyframes pulse { 0%,100%{box-shadow:0 0 0 0 rgba(255,107,107,0.6)} 50%{box-shadow:0 0 0 12px rgba(255,107,107,0)} }
.status { color:#ccc; font-size:0.8rem; }
</style>
</head>
<body>
<div class="header">
<div class="header-title"><div class="header-dot"></div>灵感瀑布</div>
<div class="status" id="statusText">自动滚动中</div>
</div>
<div class="scroll-container" id="scrollContainer">
<div class="waterfall-inner" id="waterfallInner"></div>
</div>
<script>
(() => {
const container = document.getElementById('scrollContainer');
const inner = document.getElementById('waterfallInner');
const statusText = document.getElementById('statusText');
// ---------- 布局引擎(带列间隙填充)----------
class WaterfallLayout {
constructor(containerHeight, unitHeight = 35, minGap = 10, jitter = 0.4) {
this.containerHeight = containerHeight;
this.unitHeight = unitHeight;
this.minGap = minGap;
this.jitter = jitter;
this.recalcUnits();
}
recalcUnits() {
this.unitCount = Math.max(4, Math.round(this.containerHeight / this.unitHeight));
this.actualUnitHeight = this.containerHeight / this.unitCount;
this.rowCurrentX = new Array(this.unitCount).fill(0);
}
// 分段高度
computeHeights(allCards) {
const n = allCards.length;
if (n === 0) return new Map();
const sortedLikes = [...allCards.map(c => c.likes)].sort((a,b)=>a-b);
const heightMap = new Map();
const perturbation = 0.08;
for (const card of allCards) {
let less = 0, equal = 0;
for (const v of sortedLikes) {
if (v < card.likes) less++;
else if (v === card.likes) equal++;
}
const percentile = (less + equal * 0.5) / n;
let ratio;
if (percentile < 0.60) ratio = 0.25;
else if (percentile < 0.85) ratio = 0.333;
else if (percentile < 0.96) ratio = 0.5;
else ratio = 1.0;
const noise = 1 + (Math.random() - 0.5) * 2 * perturbation;
let rawHeight = this.containerHeight * ratio * noise;
rawHeight = Math.max(this.containerHeight * 0.22, Math.min(this.containerHeight, rawHeight));
const units = Math.round(rawHeight / this.actualUnitHeight);
const clampedUnits = Math.max(1, Math.min(units, this.unitCount));
const finalHeight = clampedUnits * this.actualUnitHeight;
heightMap.set(card.id, finalHeight);
}
return heightMap;
}
// 全量计算
compute(cardsData) {
this.rowCurrentX = new Array(this.unitCount).fill(0);
const heightMap = this.computeHeights(cardsData);
const rawCards = [];
for (const card of cardsData) {
const height = heightMap.get(card.id);
const width = (height * 9) / 16;
const units = Math.round(height / this.actualUnitHeight);
const span = Math.max(1, Math.min(units, this.unitCount));
let bestRow = 0, bestX = Infinity;
for (let r = 0; r <= this.unitCount - span; r++) {
let maxX = 0;
for (let s = 0; s < span; s++) maxX = Math.max(maxX, this.rowCurrentX[r + s]);
if (maxX < bestX) { bestX = maxX; bestRow = r; }
}
const left = bestX;
const top = bestRow * this.actualUnitHeight;
const gap = this.minGap + (Math.random() - 0.5) * this.minGap * this.jitter * 2;
const newX = left + width + gap;
for (let s = 0; s < span; s++) this.rowCurrentX[bestRow + s] = newX;
rawCards.push({ ...card, left, top, width, height, spanUnits: span });
}
// 应用列间隙分布
return this.applyColumnGaps(rawCards);
}
// 追加卡片并应用间隙
addCards(newCards, allCardsSoFar) {
const heightMap = this.computeHeights(allCardsSoFar);
const rawCards = [];
for (const card of newCards) {
const height = heightMap.get(card.id);
const width = (height * 9) / 16;
const units = Math.round(height / this.actualUnitHeight);
const span = Math.max(1, Math.min(units, this.unitCount));
let bestRow = 0, bestX = Infinity;
for (let r = 0; r <= this.unitCount - span; r++) {
let maxX = 0;
for (let s = 0; s < span; s++) maxX = Math.max(maxX, this.rowCurrentX[r + s]);
if (maxX < bestX) { bestX = maxX; bestRow = r; }
}
const left = bestX;
const top = bestRow * this.actualUnitHeight;
const gap = this.minGap + (Math.random() - 0.5) * this.minGap * this.jitter * 2;
const newX = left + width + gap;
for (let s = 0; s < span; s++) this.rowCurrentX[bestRow + s] = newX;
rawCards.push({ ...card, left, top, width, height, spanUnits: span });
}
return this.applyColumnGaps(rawCards);
}
// 核心:将每一列的剩余垂直空间随机分配为卡片间隙
applyColumnGaps(cards) {
if (cards.length === 0) return cards;
// 按 left 聚类成列(同一列的卡片 left 几乎相同)
const sorted = [...cards].sort((a,b) => a.left - b.left);
const columns = [];
let currentCol = [sorted[0]];
for (let i = 1; i < sorted.length; i++) {
const prev = currentCol[currentCol.length - 1];
if (Math.abs(sorted[i].left - prev.left) < this.minGap * 2) {
currentCol.push(sorted[i]);
} else {
columns.push(currentCol);
currentCol = [sorted[i]];
}
}
columns.push(currentCol);
// 处理每一列
for (const col of columns) {
// 按 top 排序
col.sort((a,b) => a.top - b.top);
const sumHeight = col.reduce((s, c) => s + c.height, 0);
const slack = this.containerHeight - sumHeight;
if (slack <= 0) continue; // 已满或超出,保持原样
const n = col.length;
// 生成 n-1 个间隙(如果只有一张卡片,则将 slack 分配为顶部间隙?不,单卡片直接全高即可,无需间隙)
if (n === 1) {
// 单卡片:可稍微增加高度到容器高度,或者保持原高度在顶部,底部留白就无法避免。
// 为了视觉一致,不改变单卡高度,但可以居中?为了简单,不做变动。
continue;
}
// 生成 n-1 个随机权重,归一化使总和为 slack
const rawWeights = Array.from({ length: n - 1 }, () => Math.random());
const weightSum = rawWeights.reduce((a,b) => a + b, 0) || 1;
const gaps = rawWeights.map(w => (w / weightSum) * slack);
// 调整每张卡片的 top
let currentTop = col[0].top;
col[0].finalTop = currentTop;
for (let i = 1; i < n; i++) {
currentTop += col[i-1].height + gaps[i-1];
col[i].finalTop = currentTop;
}
// 最后一张卡片底部currentTop + lastCard.height 应等于 containerHeight
// 为精确,可微调最后一个间隙
}
// 合并 finalTop 到卡片对象
const updatedCards = cards.map(card => {
const colCard = columns.flat().find(c => c.id === card.id);
return {
...card,
top: colCard?.finalTop !== undefined ? colCard.finalTop : card.top,
// 高度不变
};
});
return updatedCards;
}
getTotalWidth() {
return Math.max(...this.rowCurrentX) + this.minGap * 2;
}
}
// ---------- 颜色/图案 ----------
const palettes = [
{ grad: 'linear-gradient(135deg, #2d1b69 0%, #4c1d95 50%, #5b21b6 100%)', acc: '#a78bfa' },
{ grad: 'linear-gradient(135deg, #0c4a6e 0%, #0369a1 50%, #0284c7 100%)', acc: '#38bdf8' },
{ grad: 'linear-gradient(135deg, #450a0a 0%, #991b1b 50%, #b91c1c 100%)', acc: '#f87171' },
{ grad: 'linear-gradient(135deg, #064e3b 0%, #047857 50%, #059669 100%)', acc: '#4ade80' },
{ grad: 'linear-gradient(135deg, #3b0764 0%, #6d28d9 50%, #7c3aed 100%)', acc: '#c084fc' },
{ grad: 'linear-gradient(135deg, #78350f 0%, #b45309 50%, #d97706 100%)', acc: '#fbbf24' },
{ grad: 'linear-gradient(135deg, #1e3a5f 0%, #1d4ed8 50%, #2563eb 100%)', acc: '#60a5fa' },
{ grad: 'linear-gradient(135deg, #4a1942 0%, #9d174d 50%, #be185d 100%)', acc: '#f472b6' },
];
function randomPalette() { return palettes[Math.floor(Math.random() * palettes.length)]; }
function patternSVG(acc, seed) {
const op = '0.15';
const shapes = ['circles','triangles','dots','waves'];
const s = shapes[seed % shapes.length];
if(s==='circles') return `<svg width="100%" height="100%" viewBox="0 0 200 150" preserveAspectRatio="none"><circle cx="30" cy="30" r="25" fill="none" stroke="${acc}" stroke-width="2" opacity="${op}"/><circle cx="160" cy="50" r="35" fill="none" stroke="${acc}" stroke-width="1.5" opacity="${op}"/><circle cx="80" cy="110" r="20" fill="none" stroke="${acc}" stroke-width="2" opacity="${op}"/></svg>`;
if(s==='triangles') return `<svg width="100%" height="100%" viewBox="0 0 200 150" preserveAspectRatio="none"><polygon points="40,20 70,70 10,70" fill="none" stroke="${acc}" stroke-width="1.5" opacity="${op}"/><polygon points="150,30 180,80 120,80" fill="none" stroke="${acc}" stroke-width="1" opacity="${op}"/></svg>`;
if(s==='dots') return `<svg width="100%" height="100%" viewBox="0 0 200 150" preserveAspectRatio="none"><circle cx="25" cy="25" r="3" fill="${acc}" opacity="${op}"/><circle cx="60" cy="40" r="2" fill="${acc}" opacity="${op}"/><circle cx="100" cy="20" r="4" fill="${acc}" opacity="${op}"/><circle cx="150" cy="55" r="2.5" fill="${acc}" opacity="${op}"/></svg>`;
if(s==='waves') return `<svg width="100%" height="100%" viewBox="0 0 200 150" preserveAspectRatio="none"><path d="M0,60 Q50,30 100,60 Q150,90 200,60" fill="none" stroke="${acc}" stroke-width="2" opacity="${op}"/><path d="M0,90 Q50,70 100,90 Q150,110 200,90" fill="none" stroke="${acc}" stroke-width="1.5" opacity="${op}"/></svg>`;
return '';
}
// ---------- 全局状态 ----------
let allCards = [];
let idCounter = 0;
const layout = new WaterfallLayout(window.innerHeight - 80, 35, 10, 0.4);
let autoScrollActive = true;
const autoScrollSpeed = 1.2;
let userInteracting = false;
let interactionTimeout = null;
const AUTO_RESUME_DELAY = 2500;
function createCardElement(cardData) {
const div = document.createElement('div');
div.className = 'card';
div.dataset.id = cardData.id;
div.style.left = cardData.left + 'px';
div.style.top = cardData.top + 'px';
div.style.width = cardData.width + 'px';
div.style.height = cardData.height + 'px';
div.style.background = cardData.palette.grad;
const pattern = document.createElement('div');
pattern.className = 'pattern-layer';
pattern.innerHTML = patternSVG(cardData.palette.acc, cardData.id);
div.appendChild(pattern);
const likesSpan = document.createElement('div');
likesSpan.className = 'likes';
likesSpan.textContent = '❤️ ' + cardData.likes;
div.appendChild(likesSpan);
return div;
}
function renderAllCards() {
inner.innerHTML = '';
const frag = document.createDocumentFragment();
allCards.forEach(c => frag.appendChild(createCardElement(c)));
inner.appendChild(frag);
inner.style.width = layout.getTotalWidth() + 'px';
}
function updateCardDOM(cardData) {
const el = document.querySelector(`.card[data-id="${cardData.id}"]`);
if (!el) return;
el.style.left = cardData.left + 'px';
el.style.top = cardData.top + 'px';
el.style.width = cardData.width + 'px';
el.style.height = cardData.height + 'px';
const likesEl = el.querySelector('.likes');
if (likesEl) likesEl.textContent = '❤️ ' + cardData.likes;
}
function generateBatch(count) {
const batch = [];
const minLikes = 20;
const maxLikes = 1800;
const alpha = 0.7;
for (let i = 0; i < count; i++) {
const u = Math.random();
const likes = Math.floor(minLikes + (maxLikes - minLikes) * Math.pow(1 - u, 1/alpha));
batch.push({ id: idCounter++, likes, palette: randomPalette() });
}
return batch;
}
function initLoad() {
const initialCards = generateBatch(50);
allCards = layout.compute(initialCards);
renderAllCards();
}
function appendMore(count = 35) {
const newCards = generateBatch(count);
const placed = layout.addCards(newCards, allCards);
allCards.push(...placed);
const frag = document.createDocumentFragment();
placed.forEach(c => frag.appendChild(createCardElement(c)));
inner.appendChild(frag);
inner.style.width = layout.getTotalWidth() + 'px';
const leftEdge = container.scrollLeft - 600;
allCards.forEach(c => {
if (c.left + c.width < leftEdge) {
const el = document.querySelector(`.card[data-id="${c.id}"]`);
if (el) el.remove();
}
});
if (allCards.length > 200) allCards = allCards.slice(-150);
}
function handleLike(cardId) {
const idx = allCards.findIndex(c => c.id == cardId);
if (idx === -1) return;
allCards[idx].likes += Math.floor(20 + Math.random() * 80);
const plainData = allCards.map(c => ({ id: c.id, likes: c.likes, palette: c.palette }));
const newLayout = layout.compute(plainData);
allCards = newLayout.map((c, i) => ({ ...c, palette: allCards[i].palette }));
allCards.forEach(c => updateCardDOM(c));
inner.style.width = layout.getTotalWidth() + 'px';
}
function autoScroll() {
if (!autoScrollActive || userInteracting) return;
container.scrollLeft += autoScrollSpeed;
const maxScroll = inner.scrollWidth - container.clientWidth;
if (container.scrollLeft >= maxScroll - 600) {
appendMore(35);
}
requestAnimationFrame(() => autoScroll());
}
function pauseAutoScroll() {
userInteracting = true;
statusText.textContent = '手动模式';
clearTimeout(interactionTimeout);
interactionTimeout = setTimeout(() => {
userInteracting = false;
autoScrollActive = true;
statusText.textContent = '自动滚动中';
}, AUTO_RESUME_DELAY);
}
container.addEventListener('wheel', (e) => {
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return;
e.preventDefault();
container.scrollLeft += e.deltaY;
pauseAutoScroll();
}, { passive: false });
let dragging = false, startX, startScroll;
container.addEventListener('pointerdown', (e) => {
dragging = true;
startX = e.clientX;
startScroll = container.scrollLeft;
container.classList.add('dragging');
pauseAutoScroll();
});
window.addEventListener('pointermove', (e) => {
if (!dragging) return;
container.scrollLeft = startScroll + (startX - e.clientX);
});
window.addEventListener('pointerup', () => {
dragging = false;
container.classList.remove('dragging');
});
inner.addEventListener('click', (e) => {
const cardEl = e.target.closest('.card');
if (!cardEl) return;
const id = Number(cardEl.dataset.id);
handleLike(id);
pauseAutoScroll();
});
container.addEventListener('touchstart', () => pauseAutoScroll(), { passive: true });
window.addEventListener('resize', () => {
layout.containerHeight = window.innerHeight - 80;
layout.recalcUnits();
const plainData = allCards.map(c => ({ id: c.id, likes: c.likes, palette: c.palette }));
allCards = layout.compute(plainData);
renderAllCards();
});
initLoad();
autoScroll();
console.log('✨ 完美填满:随机垂直间隙分布,零底部留白');
})();
</script>
</body>
</html>