2026-09-22 12:50:28 +08:00
|
|
|
|
// apps/viewer/src/embed/stream.ts —— v2 垂直照片流纯函数集(UI-04 §2.3/§3.2)。
|
|
|
|
|
|
// 高度比例防御、环形队列窗口数学、锚定计算全部收敛为纯函数(vitest 直接覆盖);
|
|
|
|
|
|
// 组件/编排层(EmbedStream.vue / useVerticalPhotoStream.ts)只做装配。
|
|
|
|
|
|
|
|
|
|
|
|
/** 元数据异常时的高度比例回退(4:3,§2.3)。 */
|
|
|
|
|
|
export const FALLBACK_RATIO = 4 / 3;
|
|
|
|
|
|
|
|
|
|
|
|
/** 超长图安全钳(高度/宽度上限;防无穷高度破坏循环,§2.3)。 */
|
|
|
|
|
|
export const MAX_RATIO = 8;
|
|
|
|
|
|
|
|
|
|
|
|
/** 照片缝宽度(px,§2.2 硬性 3px)。 */
|
|
|
|
|
|
export const PHOTO_GAP_PX = 3;
|
|
|
|
|
|
|
|
|
|
|
|
export interface StreamPhotoSize {
|
|
|
|
|
|
width: number;
|
|
|
|
|
|
height: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 元数据 → 高度比例(height/width);宽高须 >0 且有限,否则回退 4:3。 */
|
|
|
|
|
|
export function photoRatio(size: StreamPhotoSize): number {
|
|
|
|
|
|
if (
|
|
|
|
|
|
Number.isFinite(size.width) &&
|
|
|
|
|
|
Number.isFinite(size.height) &&
|
|
|
|
|
|
size.width > 0 &&
|
|
|
|
|
|
size.height > 0
|
|
|
|
|
|
) {
|
|
|
|
|
|
return Math.min(size.height / size.width, MAX_RATIO);
|
|
|
|
|
|
}
|
|
|
|
|
|
return FALLBACK_RATIO;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 容器内宽 → 照片展示高度(按比例;内宽 = 窗宽 - 边框)。 */
|
|
|
|
|
|
export function photoDisplayHeight(size: StreamPhotoSize, innerWidth: number): number {
|
|
|
|
|
|
return photoRatio(size) * innerWidth;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 环形队列窗口(§3.2 步骤 1):给定队列、窗口高度与预载余量,计算从
|
|
|
|
|
|
* startIndex 起需要装载的条目数(高度和 ≥ 窗口高 + 上下余量)。返回条目
|
|
|
|
|
|
* 数量(至少 1,队列空返回 0)。纯计算——实际 DOM 复用由编排层负责。
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function windowCount(
|
|
|
|
|
|
sizes: StreamPhotoSize[],
|
|
|
|
|
|
startIndex: number,
|
|
|
|
|
|
innerWidth: number,
|
|
|
|
|
|
viewportHeight: number,
|
|
|
|
|
|
bufferPx: number,
|
|
|
|
|
|
): number {
|
|
|
|
|
|
if (sizes.length === 0) return 0;
|
|
|
|
|
|
const target = viewportHeight + bufferPx;
|
|
|
|
|
|
let acc = 0;
|
|
|
|
|
|
let count = 0;
|
|
|
|
|
|
for (let i = 0; i < sizes.length; i++) {
|
|
|
|
|
|
const item = sizes[(startIndex + i) % sizes.length];
|
|
|
|
|
|
acc += photoDisplayHeight(item, innerWidth) + PHOTO_GAP_PX;
|
|
|
|
|
|
count += 1;
|
|
|
|
|
|
if (acc >= target) return count;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 一轮高度不足以覆盖窗口(极矮照片):返回整队(§3.2.7 少量可见循环
|
|
|
|
|
|
// 副本由编排层处理,本函数不放大队列)。
|
|
|
|
|
|
return count;
|
|
|
|
|
|
}
|
2026-09-22 13:19:21 +08:00
|
|
|
|
|
|
|
|
|
|
// ── E03 连续滚动引擎的纯状态机(§3.1/§3.2)──
|
|
|
|
|
|
// 引擎状态与推进/锚定全部收敛为纯函数:rAF 编排层(useVerticalPhotoStream)
|
|
|
|
|
|
// 只做「每帧积分 → DOM transform」「退役/补尾 → Vue 渲染列表」两件事。
|
|
|
|
|
|
|
|
|
|
|
|
/** 渲染实例:有限窗口内的一条照片引用(key 含循环代次防同照重复 key)。 */
|
|
|
|
|
|
export interface StreamRenderItem {
|
|
|
|
|
|
/** Vue :key 与 DOM 复用标识:`{photoId}#{generation}`。 */
|
|
|
|
|
|
key: string;
|
|
|
|
|
|
photoId: string;
|
|
|
|
|
|
/** 数据队列下标(循环取模)。 */
|
|
|
|
|
|
queueIdx: number;
|
|
|
|
|
|
/** 该实例的循环代次(每绕队列一周 +1)。 */
|
|
|
|
|
|
gen: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 引擎可变状态(advance/anchor 纯函数的输入输出载体)。 */
|
|
|
|
|
|
export interface StreamEngineState {
|
|
|
|
|
|
/** 队列(photoId 序,最新在前——与 feed 合并结果同序)。 */
|
|
|
|
|
|
queue: string[];
|
|
|
|
|
|
/** 各队列条目展示尺寸(与 queue 同长同序)。 */
|
|
|
|
|
|
sizes: StreamPhotoSize[];
|
|
|
|
|
|
/** 顶部渲染实例的队列下标。 */
|
|
|
|
|
|
headIdx: number;
|
|
|
|
|
|
/** 已滚过头条目顶部的像素(0 ≤ offset < 头条目高+缝)。 */
|
|
|
|
|
|
offset: number;
|
|
|
|
|
|
/** 当前循环代次(headIdx 回绕 0 时 +1,作新尾实例 gen)。 */
|
|
|
|
|
|
gen: number;
|
|
|
|
|
|
/** 有限窗口渲染实例(head 起,向前覆盖窗口+缓冲)。 */
|
|
|
|
|
|
items: StreamRenderItem[];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** DOM 实例上限(§3.2.6:最多 16 个图片实例)。 */
|
|
|
|
|
|
export const MAX_DOM_INSTANCES = 16;
|
|
|
|
|
|
|
|
|
|
|
|
/** 数据队列上限(§3.2.6;feed 单次 ≤20,合并后截尾保序)。 */
|
|
|
|
|
|
export const MAX_QUEUE = 30;
|
|
|
|
|
|
|
|
|
|
|
|
/** 窗口上下缓冲(px):预加载余量(§3.3 当前+接下来 2 张的覆盖带)。 */
|
|
|
|
|
|
export const WINDOW_BUFFER_PX = 480;
|
|
|
|
|
|
|
|
|
|
|
|
/** 条目展示高(含缝):比例 × 内宽。 */
|
|
|
|
|
|
function entryHeight(size: StreamPhotoSize, innerWidth: number): number {
|
|
|
|
|
|
return photoDisplayHeight(size, innerWidth) + PHOTO_GAP_PX;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 覆盖窗口+缓冲所需的实例数(自 headIdx 起,循环计数;含极矮一轮不足的
|
|
|
|
|
|
* 整队情形——窗口副本语义由 items 天然形成:count 可 > queue.length)。 */
|
|
|
|
|
|
function coverageCount(
|
|
|
|
|
|
state: Pick<StreamEngineState, 'queue' | 'sizes' | 'headIdx'>,
|
|
|
|
|
|
innerWidth: number,
|
|
|
|
|
|
viewportHeight: number,
|
|
|
|
|
|
): number {
|
|
|
|
|
|
const queueLen = state.queue.length;
|
|
|
|
|
|
if (queueLen === 0) return 0;
|
|
|
|
|
|
const target = viewportHeight + WINDOW_BUFFER_PX;
|
|
|
|
|
|
let acc = 0;
|
|
|
|
|
|
for (let i = 0; i < MAX_DOM_INSTANCES; i++) {
|
|
|
|
|
|
acc += entryHeight(state.sizes[(state.headIdx + i) % queueLen], innerWidth);
|
|
|
|
|
|
if (acc >= target) return i + 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
return MAX_DOM_INSTANCES;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 重建有限窗口(headIdx/gen 基准;覆盖不足时循环副本,副本 key 含代次)。
|
|
|
|
|
|
* 单张静态(§3.2.8):不复制刷屏,恒一个实例。 */
|
|
|
|
|
|
function rebuildItems(
|
|
|
|
|
|
state: StreamEngineState,
|
|
|
|
|
|
innerWidth: number,
|
|
|
|
|
|
viewportHeight: number,
|
|
|
|
|
|
): void {
|
|
|
|
|
|
const queueLen = state.queue.length;
|
|
|
|
|
|
if (queueLen === 0) {
|
|
|
|
|
|
state.items = [];
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (queueLen === 1) {
|
|
|
|
|
|
state.items = [
|
|
|
|
|
|
{
|
|
|
|
|
|
key: `${state.queue[0]}#${state.gen}`,
|
|
|
|
|
|
photoId: state.queue[0],
|
|
|
|
|
|
queueIdx: 0,
|
|
|
|
|
|
gen: state.gen,
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const count = Math.max(1, coverageCount(state, innerWidth, viewportHeight));
|
|
|
|
|
|
const items: StreamRenderItem[] = [];
|
|
|
|
|
|
let gen = state.gen;
|
|
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
|
|
|
|
const queueIdx = (state.headIdx + i) % queueLen;
|
|
|
|
|
|
// 实例的 gen:从 head 起向前,每回绕到更小下标(一圈)+1。
|
|
|
|
|
|
if (i > 0 && queueIdx === 0) gen += 1;
|
|
|
|
|
|
items.push({
|
|
|
|
|
|
key: `${state.queue[queueIdx]}#${gen}`,
|
|
|
|
|
|
photoId: state.queue[queueIdx],
|
|
|
|
|
|
queueIdx,
|
|
|
|
|
|
gen,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
state.items = items;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface CreateStreamStateInput {
|
|
|
|
|
|
photos: Array<{ photoId: string } & StreamPhotoSize>;
|
|
|
|
|
|
innerWidth: number;
|
|
|
|
|
|
viewportHeight: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 由 feed 照片序列创建引擎状态(截尾 MAX_QUEUE;head=最新)。 */
|
|
|
|
|
|
export function createStreamState(input: CreateStreamStateInput): StreamEngineState {
|
|
|
|
|
|
const queue = input.photos.map((p) => p.photoId);
|
|
|
|
|
|
const sizes = input.photos.map((p) => ({ width: p.width, height: p.height }));
|
|
|
|
|
|
const state: StreamEngineState = {
|
|
|
|
|
|
queue,
|
|
|
|
|
|
sizes,
|
|
|
|
|
|
headIdx: 0,
|
|
|
|
|
|
offset: 0,
|
|
|
|
|
|
gen: 0,
|
|
|
|
|
|
items: [],
|
|
|
|
|
|
};
|
|
|
|
|
|
rebuildItems(state, input.innerWidth, input.viewportHeight);
|
|
|
|
|
|
return state;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface AdvanceResult {
|
|
|
|
|
|
/** 头条目是否退役(调用方据此重算渲染列表——低频,不逐帧)。 */
|
|
|
|
|
|
retired: boolean;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 单帧时间上限(ms):后台长停顿恢复不补跑(§3.1 deltaTime)。 */
|
|
|
|
|
|
export const MAX_FRAME_MS = 50;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 单帧推进(§3.1):offset += speed×dt(dt 上限 50ms,长停顿不补跑);
|
|
|
|
|
|
* 头条目完整滚出(offset ≥ 头条高+缝)即退役:offset 回扣、headIdx 前进、
|
|
|
|
|
|
* 跨界过程屏幕内容不变(不瞬移)。返回是否发生退役。
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function advanceStream(
|
|
|
|
|
|
state: StreamEngineState,
|
|
|
|
|
|
dtMs: number,
|
|
|
|
|
|
speedPxPerSec: number,
|
|
|
|
|
|
innerWidth: number,
|
|
|
|
|
|
viewportHeight: number,
|
|
|
|
|
|
): AdvanceResult {
|
|
|
|
|
|
const queueLen = state.queue.length;
|
|
|
|
|
|
if (queueLen === 0) return { retired: false };
|
|
|
|
|
|
const dt = Math.min(dtMs, MAX_FRAME_MS);
|
|
|
|
|
|
state.offset += (speedPxPerSec * dt) / 1000;
|
|
|
|
|
|
let retired = false;
|
|
|
|
|
|
// 一帧内可能跨多条极矮条目:循环退役直至 offset 落回头条目内。
|
|
|
|
|
|
for (let guard = 0; guard < MAX_DOM_INSTANCES; guard++) {
|
|
|
|
|
|
const headH = entryHeight(state.sizes[state.headIdx], innerWidth);
|
|
|
|
|
|
if (state.offset < headH) break;
|
|
|
|
|
|
state.offset -= headH;
|
|
|
|
|
|
state.headIdx = (state.headIdx + 1) % queueLen;
|
|
|
|
|
|
if (state.headIdx === 0) state.gen += 1;
|
|
|
|
|
|
retired = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (retired) rebuildItems(state, innerWidth, viewportHeight);
|
|
|
|
|
|
return { retired };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 多帧受控推进(测试/校准用):按 ≤50ms 帧分块累计 durationMs(与运行时
|
|
|
|
|
|
* rAF 逐帧语义一致;大 dt 不经单帧捷径绕过退役边界)。 */
|
|
|
|
|
|
export function advanceStreamFor(
|
|
|
|
|
|
state: StreamEngineState,
|
|
|
|
|
|
durationMs: number,
|
|
|
|
|
|
speedPxPerSec: number,
|
|
|
|
|
|
innerWidth: number,
|
|
|
|
|
|
viewportHeight: number,
|
|
|
|
|
|
): void {
|
|
|
|
|
|
let remaining = durationMs;
|
|
|
|
|
|
while (remaining > 0) {
|
|
|
|
|
|
const dt = Math.min(remaining, 50);
|
|
|
|
|
|
advanceStream(state, dt, speedPxPerSec, innerWidth, viewportHeight);
|
|
|
|
|
|
remaining -= dt;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 数据更新锚定(§6.2):以「首个与视口相交的 photoId + 图内偏移」为锚,
|
|
|
|
|
|
* 队列合并(幸存保序、新图入未显示区)后重建状态,保持锚照片屏幕位置不变
|
|
|
|
|
|
* (±2px 内,允许新尺寸重算)。锚照片被移除时回退到最近幸存条目。
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function anchorRebuild(
|
|
|
|
|
|
state: StreamEngineState,
|
|
|
|
|
|
nextPhotos: Array<{ photoId: string } & StreamPhotoSize>,
|
|
|
|
|
|
innerWidth: number,
|
|
|
|
|
|
viewportHeight: number,
|
|
|
|
|
|
): void {
|
|
|
|
|
|
const queueLen = state.queue.length;
|
|
|
|
|
|
// ① 找锚:自 head 起首个「底边超过 offset」的实例(即视口顶可见者)。
|
|
|
|
|
|
let anchorPhotoId: string | null = null;
|
|
|
|
|
|
let anchorInPhoto = 0;
|
|
|
|
|
|
if (queueLen > 0) {
|
|
|
|
|
|
let acc = -state.offset;
|
|
|
|
|
|
for (let i = 0; i < state.items.length; i++) {
|
|
|
|
|
|
const item = state.items[i];
|
|
|
|
|
|
const h = entryHeight(state.sizes[item.queueIdx], innerWidth);
|
|
|
|
|
|
if (acc + h > 0) {
|
|
|
|
|
|
anchorPhotoId = item.photoId;
|
|
|
|
|
|
// 照片顶部在视口上方 acc(负值)→ 图内偏移 = -acc(已滚入部分)。
|
|
|
|
|
|
anchorInPhoto = Math.max(0, -acc);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
acc += h;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// ② 合并到新队列(幸存保序 + 新图追加——与 feed.mergePhotoQueue 同语义,
|
|
|
|
|
|
// 此处直接按 nextPhotos 服务端序重建,锚定保位置、新增不入显示区)。
|
|
|
|
|
|
const nextQueue = nextPhotos.map((p) => p.photoId);
|
|
|
|
|
|
const nextSizes = nextPhotos.map((p) => ({ width: p.width, height: p.height }));
|
|
|
|
|
|
state.queue = nextQueue;
|
|
|
|
|
|
state.sizes = nextSizes;
|
|
|
|
|
|
// ③ 重定位 head:锚照片仍存在 → head=锚(图内偏移保留,按新尺寸夹取);
|
|
|
|
|
|
// 已移除 → 保持原 headIdx 相对位置(夹取到新队列长度内)。
|
|
|
|
|
|
let headIdx = 0;
|
|
|
|
|
|
if (anchorPhotoId !== null) {
|
|
|
|
|
|
const idx = nextQueue.indexOf(anchorPhotoId);
|
|
|
|
|
|
if (idx >= 0) headIdx = idx;
|
|
|
|
|
|
else headIdx = Math.min(state.headIdx, Math.max(0, nextQueue.length - 1));
|
|
|
|
|
|
}
|
|
|
|
|
|
state.headIdx = headIdx;
|
|
|
|
|
|
const newHeadH =
|
|
|
|
|
|
nextQueue.length > 0 ? entryHeight(nextSizes[headIdx], innerWidth) - PHOTO_GAP_PX : 0;
|
|
|
|
|
|
state.offset = Math.max(0, Math.min(anchorInPhoto, Math.max(0, newHeadH - 1)));
|
|
|
|
|
|
rebuildItems(state, innerWidth, viewportHeight);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 移除指定照片(隐藏/删除事件):重定位到下一有效项,保锚不跳屏(§6.3)。
|
|
|
|
|
|
* 锚点在旧队列上解析(anchorRebuild 内部先找锚后换队列)。 */
|
|
|
|
|
|
export function removePhoto(
|
|
|
|
|
|
state: StreamEngineState,
|
|
|
|
|
|
photoId: string,
|
|
|
|
|
|
innerWidth: number,
|
|
|
|
|
|
viewportHeight: number,
|
|
|
|
|
|
): void {
|
|
|
|
|
|
const idx = state.queue.indexOf(photoId);
|
|
|
|
|
|
if (idx < 0) return;
|
|
|
|
|
|
const queue = state.queue.filter((_, i) => i !== idx);
|
|
|
|
|
|
const sizes = state.sizes.filter((_, i) => i !== idx);
|
|
|
|
|
|
const nextPhotos = queue.map((id, i) => ({
|
|
|
|
|
|
photoId: id,
|
|
|
|
|
|
width: sizes[i].width,
|
|
|
|
|
|
height: sizes[i].height,
|
|
|
|
|
|
}));
|
|
|
|
|
|
anchorRebuild(state, nextPhotos, innerWidth, viewportHeight);
|
|
|
|
|
|
}
|