feat(viewer): waterfall feed with lazy load, filter and new-photo hint

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-18 10:23:27 +08:00
parent adc45b1563
commit d0b79dd0c9
15 changed files with 1305 additions and 27 deletions
+102
View File
@@ -0,0 +1,102 @@
// apps/viewer/src/feed/layout.ts —— 瀑布流纯函数层(Task 7):双列绝对定位布局
// 与列表纯数据操作。抽出为无 DOM 依赖的模块以便 vitest 直接覆盖放置/去重逻辑。
//
// 布局模型(选型见计划 Task 7 Step 1:绝对定位而非 CSS columns——滚动位置可
// 恢复、条目高按 width/height 比例预留):
// · 列宽 = (容器宽 - (列数-1)*列间距) / 列数,向下取整;
// · 条目高 = round(列宽 * height/width)(宽高来自 browse 变体,服务端必返);
// 宽高非正的脏数据回退正方形容高(不产生 NaN/0 塌陷);
// · 放置规则:总是放入当前「最矮」列,同高取索引小者(确定性输出);
// · 容器高 = 最高列去掉末尾间距(间距只在条目之间生效)。
/** 布局输入:仅需宽高(PublicPhoto 满足)。 */
export interface WaterfallSource {
width: number;
height: number;
}
/** 单条放置结果:index 对应输入数组下标,left/top/height 为像素值。 */
export interface WaterfallPlacement {
index: number;
column: number;
left: number;
top: number;
height: number;
}
export interface WaterfallLayout {
placements: WaterfallPlacement[];
/** 单列宽度(所有列等宽)。 */
columnWidth: number;
/** 容器内容总高(px)。 */
height: number;
}
export const WATERFALL_COLUMNS = 2;
export const WATERFALL_GAP = 8;
/**
* 双列(可推广 N 列)瀑布流布局:逐条放入最矮列。O(n) 全量重算——分页追加
* 也整体重算(n 为已加载条数,P1 量级下开销可忽略),换来 resize/刷新的确定性。
*/
export function layoutWaterfall(
items: readonly WaterfallSource[],
containerWidth: number,
options: { columns?: number; gap?: number } = {},
): WaterfallLayout {
const columns = Math.max(1, options.columns ?? WATERFALL_COLUMNS);
const gap = Math.max(0, options.gap ?? WATERFALL_GAP);
const columnWidth =
containerWidth > 0
? Math.floor((containerWidth - gap * (columns - 1)) / columns)
: 0;
const columnHeights = new Array<number>(columns).fill(0);
const placements: WaterfallPlacement[] = [];
items.forEach((item, index) => {
const ratio =
item.width > 0 && item.height > 0 ? item.height / item.width : 1;
const height = Math.max(1, Math.round(columnWidth * ratio));
let column = 0;
for (let c = 1; c < columns; c += 1) {
if (columnHeights[c] < columnHeights[column]) column = c;
}
placements.push({
index,
column,
left: column * (columnWidth + gap),
top: columnHeights[column],
height,
});
columnHeights[column] += height + gap;
});
const height =
placements.length > 0 ? Math.max(...columnHeights) - gap : 0;
return { placements, columnWidth, height };
}
/** 追加分页结果时按 photoId 去重(防御:游标边界漂移、轮询与分页竞争)。 */
export function mergeUniqueByPhotoId<T extends { photoId: string }>(
existing: readonly T[],
incoming: readonly T[],
): T[] {
const seen = new Set(existing.map((item) => item.photoId));
const merged = [...existing];
for (const item of incoming) {
if (seen.has(item.photoId)) continue;
seen.add(item.photoId);
merged.push(item);
}
return merged;
}
/** 轮询判定:incoming 中是否存在 known 集合之外的新照片。 */
export function containsNewPhotoId(
knownIds: Iterable<string>,
incoming: readonly { photoId: string }[],
): boolean {
const known = new Set(knownIds);
return incoming.some((item) => !known.has(item.photoId));
}