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:
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user