InfoLens / client /src /shared /prediction_attribution /causal_flow /genAttributeDagRecursiveEdgeAnimation.ts
dqy08's picture
Causal Flow 下对称支持递归下游红边
973dfd2
Raw
History Blame Contribute Delete
51.8 kB
import { DAG_MIN_ATTRIBUTION_SHARE } from './genAttributeDagEdgeDisplay';
import { DAG_LIGHTNING_SLOW_MO_DEFAULT, DAG_LIGHTNING_THRESHOLD_TAU_DEFAULT, lightningBoundaryFrameDwellMs } from './genAttributeDagEdgeRenderStrength';
import { parseDagLinkEndpointKey } from './genAttributeDagNodeDim';
import {
DAG_PROP_LOG_W,
dagPropLogFmtNodeShareList,
dagPropLogFmtToken,
dagPropLogFmtWeight,
dagPropLogPad,
dagPropLogPadInt,
dagPropLogPadWeight,
logDagPropagationPlaybackLine,
nodesAtNodeShareTotalForPlaybackLog,
} from './genAttributeDagPropagationPlaybackLog';
import {
batchAppearanceCostMs,
computePropagationGroupPacings,
effectivePropagationWeightTotal,
DAG_PROPAGATION_BOUNDARY_FRAME_DWELL_MS,
type DagRecursiveEdgeReplayPacing,
type DagReplayPacingMode,
type PropagationGroupPrep,
type PropagationPlaybackWeightScope,
} from './genAttributeDagPropagationPlaybackPacing';
export type { DagRecursiveEdgeReplayPacing, DagReplayPacingMode, PropagationPlaybackWeightScope } from './genAttributeDagPropagationPlaybackPacing';
export {
batchAppearanceCostMs,
computePropagationGroupPacings,
effectivePropagationWeightTotal,
DAG_PROPAGATION_WEIGHT_RUNNING_MAX_LOOKAHEAD_MIN,
DAG_PROPAGATION_WEIGHT_RUNNING_MAX_LOOKAHEAD_RATIO,
DAG_PROPAGATION_BOUNDARY_FRAME_DWELL_MS,
propagationRunningMaxLookaheadForGroupCount,
} from './genAttributeDagPropagationPlaybackPacing';
export {
DAG_PROPAGATION_PLAYBACK_LOG_LS_KEY,
isDagPropagationPlaybackLogEnabled,
setDagPropagationPlaybackLogEnabled,
} from './genAttributeDagPropagationPlaybackLog';
export type DagRecursiveEdgeAnimationDirection = 'backward' | 'forward';
export type DagPropagationPlaybackOptions = {
/** forward:prompt 等有 share 节点纳入 batch slide;默认 false → 500ms prompt 首帧后跳过 prompt 批。 */
forwardSlideSharedNodes: boolean;
/** forward 边界帧 dwell:传播蓝边峰值闪后线性衰减至稳态蓝边(accent 混白 × τ → 常色 × renderStrength)。 */
lightningEffect: boolean;
/**
* 闪电线性增强分母 τ(0,1]:`opacity = min(1, renderStrength / τ)`。
* τ 越小越多边顶满;τ=1 等价于仅最强边可达 1(在 renderStrength 已归一前提下)。
*/
lightningThresholdTau: number;
/** 闪电效果慢放倍数 [1,10]:dwell 与节奏按此比例拉长。 */
lightningSlowMo: number;
/** 闪电效果伴随雷声(雷击 + 背景闷雷)。 */
lightningSound: boolean;
};
/** 与 {@link genAttributeDagView} 内焦点归因快照同形;供动画 overlay 消费。 */
export type DagFocusAttributionState = {
activeNodeIds: Set<string>;
incomingEdgeShareByKey: Map<string, number>;
downstreamEdgeStrengthByKey: Map<string, number>;
/** 下游影响传播的节点到达量(焦点为 1);供红边渲染将每源最强出边刻度钉在 arrive。 */
downstreamArriveById: Map<string, number>;
nodeShareById: Map<string, number>;
};
export type DagFocusAttributionComputeOptions = {
maxIncomingDepth: number;
includeDownstreamInfluence: boolean;
allowedEdgeKeys?: ReadonlySet<string>;
};
export type DagFocusAttributionGraphContext = {
nodesSortedByStepDesc: readonly { id: string; step: number }[];
incomingLinksByTarget: ReadonlyMap<string, readonly unknown[]>;
};
type ComputeFocusStateFn = (
focusId: string,
options: DagFocusAttributionComputeOptions,
ctx: DagFocusAttributionGraphContext,
) => DagFocusAttributionState | null;
type ComputeSteadyStateStayShareByIdFn = (
nodeShareById: Map<string, number>,
focusId: string,
) => Map<string, number>;
/** 无 {@link CreateDagRecursiveEdgeAnimationControllerOptions.getReplayPacing} 时的兜底 step 间隔(ms)。 */
const DAG_RECURSIVE_EDGE_BATCH_STEP_MS_FALLBACK = 500;
/**
* 仅用于「Propagated attribution mode」焦点入边的分批显示状态。
* 两方向均按 `start(tgt)` 分批;backward 从高 tgt 向低 tgt 播放(贴合向上追溯),forward 反向。
*
* **传播蓝边强度(设计理念,render 见 {@link genAttributeDagView} `refreshNodeLinkHighlight`)**
* - 语义值 propagated share 在递推时已乘各 hop 的传导 MI;render 不再 per-edge 乘 target MI。
* - 蓝边 opacity:帧内 max 归一 × 焦点 MI 上限 × floor;tooltip Link strength 用原始 share。
*
* **forward**
* - 计划始终含全部有 share 的 token(含 prompt);勾选 slide prompt 时从 `batches[末]` 递减,各批按 {@link batchAppearanceCostMs}。
* - 未勾选时:先 {@link DAG_PROPAGATION_BOUNDARY_FRAME_DWELL_MS}ms prompt 稳态首帧(跳过 prompt-only 批),再从首个 gen 批递减至 `batchIndex === 0`。
* - share 始终用全量焦点快照,动画只改「可见边集合」与归一分母(前沿内 max share)。
* - 部分帧内焦点不提前高亮/描边(render 延后至末帧 `batchIndex === 0` 稳态),与反向首帧才亮焦点对称。
* - 同一帧内,已可见边的相对强弱 = share 相对强弱;绝对 opacity 可因分母随新批次变大而变暗。
* - 末帧 `batchIndex === 0` 时前沿 = 全链、分母 = 全链 max、可见性全开,与无动画稳定态数值一致(收敛)。
*
* **backward**
* - 首帧 `batchIndex === 0`(焦点侧):固定模拟开销 {@link DAG_PROPAGATION_BOUNDARY_FRAME_DWELL_MS}ms,焦点红色 slide;与 forward prompt 首帧对称,不参与权重分配。
* - 蓝线从焦点侧逐批显现:稳态 share + {@link backwardFrontierByBatchIndex} 门控(`batches[0..i]` 递增)。
* - 未滑过:live stay;已滑过 batch:稳态 stay;非播放链生成 token 不描边;prompt(`step === -1`)若在候选集中则用 live stay(可不在传播链上)。
* - 首个 prompt 区不参与 slide(无红框);滑完 gen 链后一跳至末批稳态(全链蓝边),与 forward 跳过 prompt 批对称。
* - 描边分母为稳态 `max(stay)`。
* - 当前帧 slide 节点(`--backward-slide`)的入边:红色,强度在本批指向 slide 的入边集合内 max 归一 × 焦点 MI。
*
* **播放计划(见 {@link DagPropagationPlaybackPlan})**
* - 一批 = 同一 `start` offset 上的节点组 + 该组入边;节点 = 有 Total share 的 token ∪ 入边 tgt(去重)。
* - 仅作 src 的 prompt 等无入边时仍成批,供 slide / 权重节拍;蓝边仍只来自 `edgeKeys`。
* - 播放间隔权重(准备阶段一遍):按**文字顺序**对非焦点 `groupShare/weightMax` 做 running max 归一化;向后看组数 = max({@link DAG_PROPAGATION_WEIGHT_RUNNING_MAX_LOOKAHEAD_MIN}, round(比例×组数));与播放方向无关。组内含焦点则无 `shareNorm`。
* - 文序**首部连续** prompt-only 组(首个 prompt 区):prompt 区内 max 归一;不参与 gen `weightMax` / running max。
* - `backwardFrontierByBatchIndex` / `forwardFrontierByBatchIndex`:各方向蓝线可见边并集,render 热路径 O(1)。
* - forward / backward 共用同一 plan;不用 backward 部分快照的 nodeShare 定权重。
*
* **播放间隔(见 {@link batchAppearanceCostMs})**
* - 间隔 = 当前帧的模拟开销;由本帧 `propagationWeight` 或固定帧类型决定。
* - 调度:展示本帧 → 等待其开销 → 下一帧(与步进回放「展示前等待」在相邻帧时刻上等价)。
* - 权重为 0 时开销恰为 0(`step` 下 0ms,不设最小间隔)。
* - `total` 模式:UI `totalS` 中预留 {@link DAG_PROPAGATION_BOUNDARY_FRAME_DWELL_MS} 给 backward 首帧;forward 末帧另计同长度固定收尾,其余按权重分配。
*/
/** 传播链动画的一批:同一 offset 上有份额的节点 + 该组传播入边 + 播放元数据。 */
export type DagRecursiveIncomingEdgeBatch = {
/** 与 {@link buildPropagationPlaybackPlan} 分批键一致(组内节点 `start`)。 */
groupOffset: number;
/** 本组代表 token(slide / tooltip)。 */
tgtId: string;
/** 本组全部有份额节点(含仅作 src、无入边的 prompt 等)。 */
nodeIds: readonly string[];
/** 本组传播链入边(`src->tgt`)。 */
edgeKeys: string[];
/**
* 文字顺序局部归一化权重:share_norm ÷ runningMax(含向后 lookahead 窗口内的非焦点 share_norm)。
*/
propagationWeight: number;
/**
* playback 日志 `share_norm`:prompt 区为 share/promptRegionMax;gen/input 为 share/weightMax。
* 组内含焦点时为 undefined。
*/
shareNorm?: number;
/** 准备阶段:截至本组(含 lookahead 窗口)的链序 running max;prompt 区不适用。 */
runningMaxNorm?: number;
/** 组内非焦点 max Total share(prompt 区 playback 日志 `share=`)。 */
groupShare?: number;
/** 文序首部连续 prompt-only 组(首个 prompt 区);playback 日志 `running_max` 打印为 `-`。 */
isFirstPromptRegion?: boolean;
};
/** 点击焦点时生成的不可变播放计划(批次 + 预计算前沿 + 播放权重)。 */
export type DagPropagationPlaybackPlan = {
focusId: string;
batches: DagRecursiveIncomingEdgeBatch[];
/** gen / input 区非焦点组 Total share 上限(不含 prompt 区;日志 / 对照)。 */
weightMax: number;
/** Σ 全部 `batches[].propagationWeight`;total 模式分母。 */
weightTotal: number;
/** prompt 区组内 max Total share;无 prompt 区时为 0。 */
promptRegionMax: number;
/** Σ prompt 区 propagationWeight(日志)。 */
promptWeightTotal: number;
/** Σ gen / input 区 propagationWeight(日志)。 */
chainWeightTotal: number;
/** 本计划 running max 前瞻组数(max(MIN, round(比例×组数)))。 */
runningMaxLookahead: number;
/** backward:`batchIndex = i` 时可见边 = `batches[0..i]` 并集。 */
backwardFrontierByBatchIndex: ReadonlyArray<ReadonlySet<string>>;
/** forward:`batchIndex = i` 时可见边 = `batches[i..末]` 并集。 */
forwardFrontierByBatchIndex: ReadonlyArray<ReadonlySet<string>>;
};
/** 进行中的播放状态:batchIndex / direction / forwardPromptPreamblePending 可变。 */
export type DagEdgeBatchAnimationState = {
plan: DagPropagationPlaybackPlan;
direction: DagRecursiveEdgeAnimationDirection;
batchIndex: number;
/** forward 未 slide prompt:首播 500ms 稳态 prompt,再从此 index 递减(已跳过 prompt-only 批)。 */
forwardPromptPreamblePending: boolean;
/** 开播时固化;`total` 模式加权分母见 {@link effectivePropagationWeightTotal}。 */
weightScope: PropagationPlaybackWeightScope;
};
export function tgtIdFromEdgeKey(edgeKey: string): string | null {
const i = edgeKey.indexOf('->');
if (i <= 0 || i >= edgeKey.length - 2) return null;
return edgeKey.slice(i + 2);
}
const EMPTY_EDGE_KEY_SET: ReadonlySet<string> = new Set();
/** backward 当前批:指向 slide 节点的入边(与全图按 tgt 筛等价,仅扫本批 edgeKeys)。 */
export function backwardSlideIncomingEdgeKeysForBatch(
plan: DagPropagationPlaybackPlan,
batchIndex: number,
focusId: string,
): ReadonlySet<string> {
const batch = plan.batches[batchIndex];
if (batch == null) return EMPTY_EDGE_KEY_SET;
const slideTgtId = batchIndex === 0 ? focusId : batch.tgtId;
const keys = new Set<string>();
for (const key of batch.edgeKeys) {
if (tgtIdFromEdgeKey(key) === slideTgtId) keys.add(key);
}
return keys;
}
/** 当前 batchIndex、方向下已启用的传播链入边(计划内预计算)。 */
function frontierEdgeKeysAtBatch(
plan: DagPropagationPlaybackPlan,
direction: DagRecursiveEdgeAnimationDirection,
batchIndex: number,
forwardPromptPreamblePending: boolean,
): ReadonlySet<string> {
if (direction === 'forward' && forwardPromptPreamblePending) {
return EMPTY_EDGE_KEY_SET;
}
const table =
direction === 'backward' ? plan.backwardFrontierByBatchIndex : plan.forwardFrontierByBatchIndex;
return table[batchIndex] ?? EMPTY_EDGE_KEY_SET;
}
export function maxShareInEdgeKeySet(
incomingEdgeShareByKey: Map<string, number>,
edgeKeys: ReadonlySet<string>,
): number {
let max = 0;
for (const key of edgeKeys) {
const share = incomingEdgeShareByKey.get(key);
if (share != null && share > max) max = share;
}
return max;
}
export function maxHighlightEdgeShare(sharesByKey: Map<string, number>): number {
let max = 0;
for (const share of sharesByKey.values()) {
if (share > max) max = share;
}
return max;
}
/**
* backward 动画进行中的节点 stay(描边/提亮用)。
*
* 刻意不用稳定态公式 `nodeShare × (1 − propagationMiRatio)`:动画只亮出部分入边,
* 稳定态 stay 描述的是链全亮后的终态。此处用「当前前沿快照里的 nodeShare,减去该快照
* 已计入入边的份额之和」,在播放过程中实时反映**当前可见归因**下各节点仍留在链上的量;
* 动画结束或关动画时改走稳定态 stay(`computeSteadyStateStayShareById`)。
*/
function computeLivePartialStayShareById(
nodeShareById: Map<string, number>,
incomingEdgeShareByKey: Map<string, number>,
focusId: string,
): Map<string, number> {
const propagatedByTarget = new Map<string, number>();
for (const [edgeKey, edgeShare] of incomingEdgeShareByKey) {
const tgtId = tgtIdFromEdgeKey(edgeKey);
if (tgtId == null) continue;
propagatedByTarget.set(tgtId, (propagatedByTarget.get(tgtId) ?? 0) + edgeShare);
}
const byNodeId = new Map<string, number>();
for (const [nodeId, nodeShare] of nodeShareById) {
if (nodeId === focusId) continue;
const stay = Math.max(0, nodeShare - (propagatedByTarget.get(nodeId) ?? 0));
if (stay >= DAG_MIN_ATTRIBUTION_SHARE) byNodeId.set(nodeId, stay);
}
return byNodeId;
}
export function isRecursiveEdgeAnimationFrontierPartial(
animation: DagEdgeBatchAnimationState | null,
focusId: string,
): boolean {
if (animation == null || animation.plan.focusId !== focusId) return false;
if (animation.direction === 'backward') {
const lastBatch = animation.plan.batches.length - 1;
if (lastBatch <= 0) return false;
return animation.batchIndex < lastBatch;
}
// forward:batchIndex===0 为稳态终帧;其余均属「动画未结束」
return animation.batchIndex !== 0;
}
function isBackwardRecursiveEdgeAnimationInProgress(
animation: DagEdgeBatchAnimationState | null,
focusId: string,
): boolean {
return (
animation != null &&
animation.direction === 'backward' &&
isRecursiveEdgeAnimationFrontierPartial(animation, focusId)
);
}
function nodeIdsInBatch(batch: DagRecursiveIncomingEdgeBatch): Set<string> {
return new Set(batch.nodeIds);
}
function batchesInTextOrder(
batches: readonly DagRecursiveIncomingEdgeBatch[],
): DagRecursiveIncomingEdgeBatch[] {
return [...batches].sort((a, b) => a.groupOffset - b.groupOffset);
}
/** 文序 prompt-only 组:无传播入边且组内全是 prompt。 */
export function isPromptOnlyPropagationGroup(
nodeIds: Iterable<string>,
edgeKeyCount: number,
isPromptNode: (id: string) => boolean,
): boolean {
if (edgeKeyCount > 0) return false;
let hasNode = false;
for (const id of nodeIds) {
hasNode = true;
if (!isPromptNode(id)) return false;
}
return hasNode;
}
/**
* 文序 ASC:从首部起连续 prompt-only 组标记为首个 prompt 区;遇首个非 prompt-only 即结束。
*/
export function markFirstPromptRegionGroupsInTextOrder(
sortedOffsetsAsc: readonly number[],
groupAt: (offset: number) => { edgeKeys: string[]; nodeIds: Set<string> },
isPromptNode: (id: string) => boolean,
): boolean[] {
let inPrefix = true;
return sortedOffsetsAsc.map((groupOffset) => {
const group = groupAt(groupOffset);
const promptOnly = isPromptOnlyPropagationGroup(
group.nodeIds,
group.edgeKeys.length,
isPromptNode,
);
const isFirstPromptRegion = inPrefix && promptOnly;
if (inPrefix && !promptOnly) inPrefix = false;
return isFirstPromptRegion;
});
}
/** 播放序(DESC)下首个 prompt 区之前的最后一批 index;无 prompt 区时为末批。 */
export function lastNonFirstPromptRegionBatchIndex(
plan: { batches: readonly { isFirstPromptRegion?: boolean }[] },
): number {
for (let i = plan.batches.length - 1; i >= 0; i--) {
if (!plan.batches[i]!.isFirstPromptRegion) return i;
}
return 0;
}
/** forward 播放序:从末批向 0 递减,跳过文序首部 prompt 区后的首个 gen 批 index。 */
function firstNonPromptRegionBatchIndexFromEnd(plan: DagPropagationPlaybackPlan): number {
return lastNonFirstPromptRegionBatchIndex(plan);
}
function planHasFirstPromptRegionBatch(plan: DagPropagationPlaybackPlan): boolean {
return plan.batches.some((batch) => batch.isFirstPromptRegion === true);
}
/** 组内代表 token;并列时取 share 最大,同 share 取 id 字典序最小。 */
function primaryNodeIdForGroup(
nodeIds: Iterable<string>,
nodeShareById: ReadonlyMap<string, number>,
): string {
let bestId = '';
let bestShare = -1;
for (const nodeId of nodeIds) {
const share = nodeShareById.get(nodeId) ?? 0;
if (share > bestShare || (share === bestShare && nodeId < bestId)) {
bestShare = share;
bestId = nodeId;
}
}
return bestId;
}
function incomingEdgeBatchFromGroup(
groupOffset: number,
group: { edgeKeys: string[]; nodeIds: Set<string> },
prep: PropagationGroupPrep,
nodeShareById: ReadonlyMap<string, number>,
): DagRecursiveIncomingEdgeBatch {
group.edgeKeys.sort();
const nodeIds = [...group.nodeIds].sort();
return {
groupOffset,
tgtId: primaryNodeIdForGroup(nodeIds, nodeShareById),
nodeIds,
edgeKeys: group.edgeKeys,
propagationWeight: prep.propagationWeight,
...(prep.runningMaxNorm != null ? { runningMaxNorm: prep.runningMaxNorm } : {}),
...(prep.shareNorm != null ? { shareNorm: prep.shareNorm } : {}),
...(prep.groupShare != null ? { groupShare: prep.groupShare } : {}),
...(prep.isFirstPromptRegion ? { isFirstPromptRegion: true } : {}),
};
}
function buildFrontierEdgeKeysByBatchIndex(
batches: readonly DagRecursiveIncomingEdgeBatch[],
): Pick<DagPropagationPlaybackPlan, 'backwardFrontierByBatchIndex' | 'forwardFrontierByBatchIndex'> {
const n = batches.length;
const backward: Set<string>[] = [];
const forward: Set<string>[] = [];
for (let i = 0; i < n; i++) {
const prev = backward[i - 1];
backward.push(new Set(prev));
for (const key of batches[i]!.edgeKeys) backward[i]!.add(key);
}
for (let i = n - 1; i >= 0; i--) {
const next = forward[i + 1];
forward[i] = new Set(next);
for (const key of batches[i]!.edgeKeys) forward[i]!.add(key);
}
return { backwardFrontierByBatchIndex: backward, forwardFrontierByBatchIndex: forward };
}
/**
* 传播链播放计划:按 offset 分组;组内节点 = 有 Total share 的 token(非焦点),并并入入边 tgt。
* backward 从 index 0 递增(蓝线递增),forward 从末批递减(蓝线递增)。
*/
export function buildPropagationPlaybackPlan(
incomingEdgeShareByKey: Map<string, number>,
offsetOf: (id: string) => number,
nodeShareById: ReadonlyMap<string, number>,
focusId: string,
isPromptNode: (id: string) => boolean,
): DagPropagationPlaybackPlan | null {
if (incomingEdgeShareByKey.size === 0) return null;
const byOffset = new Map<number, { edgeKeys: string[]; nodeIds: Set<string> }>();
const ensureGroup = (offset: number) => {
let group = byOffset.get(offset);
if (group == null) {
group = { edgeKeys: [], nodeIds: new Set() };
byOffset.set(offset, group);
}
return group;
};
for (const edgeKey of incomingEdgeShareByKey.keys()) {
const tgtId = tgtIdFromEdgeKey(edgeKey);
if (tgtId == null) continue;
const group = ensureGroup(offsetOf(tgtId));
group.edgeKeys.push(edgeKey);
group.nodeIds.add(tgtId);
}
for (const [nodeId, share] of nodeShareById) {
if (nodeId === focusId) continue;
if (share < DAG_MIN_ATTRIBUTION_SHARE) continue;
ensureGroup(offsetOf(nodeId)).nodeIds.add(nodeId);
}
if (byOffset.size === 0) return null;
const sortedOffsetsAsc = [...byOffset.keys()].sort((a, b) => a - b);
const sortedOffsetsDesc = [...sortedOffsetsAsc].reverse();
const firstPromptRegionFlags = markFirstPromptRegionGroupsInTextOrder(
sortedOffsetsAsc,
(offset) => byOffset.get(offset)!,
isPromptNode,
);
const weightGroups = sortedOffsetsAsc.map((groupOffset, i) => ({
tgtIds: byOffset.get(groupOffset)!.nodeIds,
isFirstPromptRegion: firstPromptRegionFlags[i],
}));
const {
groupPreps,
weightMax,
weightTotal,
runningMaxLookahead,
promptRegionMax,
promptWeightTotal,
chainWeightTotal,
} = computePropagationGroupPacings(weightGroups, nodeShareById, focusId);
// groupPreps 按文序 ASC;batches 按播放序 DESC(远→近),故播放序下标 j 对应文序下标 n-1-j
const batches: DagRecursiveIncomingEdgeBatch[] = sortedOffsetsDesc.map((groupOffset, j) => {
const prep = groupPreps[sortedOffsetsAsc.length - 1 - j]!;
return incomingEdgeBatchFromGroup(groupOffset, byOffset.get(groupOffset)!, prep, nodeShareById);
});
return {
focusId,
batches,
weightMax,
weightTotal,
promptRegionMax,
promptWeightTotal,
chainWeightTotal,
runningMaxLookahead,
...buildFrontierEdgeKeysByBatchIndex(batches),
};
}
/**
* backward 节点 stay 用:沿 {@link backwardFrontierByBatchIndex}(与蓝线同前沿)重算部分快照。
* 蓝边不经过此函数(与 forward 一样用全量 share + 前沿门控)。
*/
function resolveFocusAttributionAtFrontier(
focusId: string,
fullState: DagFocusAttributionState,
animation: DagEdgeBatchAnimationState | null,
computeFocusState: ComputeFocusStateFn,
ctx: DagFocusAttributionGraphContext,
): DagFocusAttributionState {
if (
animation == null ||
animation.plan.focusId !== focusId ||
!isRecursiveEdgeAnimationFrontierPartial(animation, focusId) ||
animation.direction !== 'backward'
) {
return fullState;
}
const allowedEdgeKeys = frontierEdgeKeysAtBatch(
animation.plan,
animation.direction,
animation.batchIndex,
animation.forwardPromptPreamblePending,
);
if (allowedEdgeKeys.size >= fullState.incomingEdgeShareByKey.size) {
return fullState;
}
const partial = computeFocusState(
focusId,
{
maxIncomingDepth: Number.POSITIVE_INFINITY,
includeDownstreamInfluence: false,
allowedEdgeKeys,
},
ctx,
);
return partial ?? fullState;
}
function passedBatchTgtIdsBeforeIndex(
batches: readonly DagRecursiveIncomingEdgeBatch[],
batchIndex: number,
): Set<string> {
const ids = new Set<string>();
for (let i = 0; i < batchIndex; i++) {
for (const id of nodeIdsInBatch(batches[i]!)) ids.add(id);
}
return ids;
}
/** 播放计划传播链上的全部 tgt(含同 offset 非代表 token)。 */
function playbackChainNodeIds(batches: readonly DagRecursiveIncomingEdgeBatch[]): Set<string> {
const onChain = new Set<string>();
for (const batch of batches) {
for (const id of nodeIdsInBatch(batch)) onChain.add(id);
}
return onChain;
}
function promptNodeIdsFromCtx(ctx: DagFocusAttributionGraphContext): Set<string> {
const ids = new Set<string>();
for (const n of ctx.nodesSortedByStepDesc) {
if (n.step === -1) ids.add(n.id);
}
return ids;
}
/**
* 传播模式描边:backward 动画进行中,未滑过 batch 用 live stay;
* 已滑过 batch 用稳态 stay;候选中的 prompt 用 live(可不在链上);其余链外生成 token 不描边。
*/
function resolveEffectiveStayShareByIdForStroke(
focusState: DagFocusAttributionState,
focusId: string,
animation: DagEdgeBatchAnimationState | null,
computeFocusState: ComputeFocusStateFn,
computeSteadyStateStayShareById: ComputeSteadyStateStayShareByIdFn,
ctx: DagFocusAttributionGraphContext,
): Map<string, number> {
if (!isBackwardRecursiveEdgeAnimationInProgress(animation, focusId)) {
return computeSteadyStateStayShareById(focusState.nodeShareById, focusId);
}
// ① 与蓝线同前沿的部分快照 → live stay 池
const atFrontier = resolveFocusAttributionAtFrontier(
focusId,
focusState,
animation,
computeFocusState,
ctx,
);
const liveById = computeLivePartialStayShareById(
atFrontier.nodeShareById,
atFrontier.incomingEdgeShareByKey,
focusId,
);
// ② 全链稳态 stay 池(已滑过 batch 用)
const steadyById = computeSteadyStateStayShareById(focusState.nodeShareById, focusId);
const batches = animation!.plan.batches;
const batchIndex = animation!.batchIndex;
const passedTgtIds = passedBatchTgtIdsBeforeIndex(batches, batchIndex);
const onChain = playbackChainNodeIds(batches);
const promptIds = promptNodeIdsFromCtx(ctx);
// ③ 合并候选:未滑过→live,已滑过→steady;仅链上或 prompt
const strokeCandidates = new Set([...liveById.keys(), ...passedTgtIds]);
const byNodeId = new Map<string, number>();
for (const nodeId of strokeCandidates) {
if (!onChain.has(nodeId) && !promptIds.has(nodeId)) continue;
const stay = passedTgtIds.has(nodeId) ? steadyById.get(nodeId) : liveById.get(nodeId);
if (stay != null && stay >= DAG_MIN_ATTRIBUTION_SHARE) byNodeId.set(nodeId, stay);
}
return byNodeId;
}
export type RecursiveEdgeAnimationRenderOverlay = {
animationFrontierPartial: boolean;
anim: DagEdgeBatchAnimationState | null;
frontierEdgeKeys: ReadonlySet<string> | null;
/** 与 focusState 同引用;入边 share 恒为全量,可见性由 frontier / edgeVisibility 裁切。 */
linkFocusState: DagFocusAttributionState | null;
nodeStrokeShareById: Map<string, number> | null;
/** backward 部分帧:稳定态 stay 池 max,供描边归一分母;否则 undefined 用当前池 max。 */
nodeStrokeMaxForRender?: number;
incomingShareForRender: Map<string, number>;
incomingMaxForRender: number;
/** forward 首开帧(未 slide 共享节点):仅 prompt 稳态描边,无链边、无 slide。 */
forwardPromptPreambleFrame: boolean;
/** 当前帧 slide token:forward 为 batch 代表;backward 首帧为焦点。 */
propagationSlideTgtId: string | null;
/** 正向传播部分帧:焦点不提前全亮(末帧 partial 结束即恢复)。 */
deferFocusHighlightDuringAnim: boolean;
/** 传播部分帧:不为焦点挂 `--selected` 描边(正向全程;反向仅 slide=焦点时)。 */
suppressFocusSelectedStroke: boolean;
edgeVisibility(edgeKey: string, inPropagationChain: boolean): number;
};
const INACTIVE_EDGE_VISIBILITY = (_edgeKey: string, _inPropagationChain: boolean): number => 1;
/** backward 首帧 slide = 焦点;无动画 = null;forward 与其余 backward 批 = 当前批代表 token。 */
function resolvePropagationSlideTgtId(
anim: DagEdgeBatchAnimationState | null,
animationFrontierPartial: boolean,
focusId: string,
): string | null {
if (anim == null || !animationFrontierPartial || anim.forwardPromptPreamblePending) {
return null;
}
if (anim.direction === 'backward' && anim.batchIndex === 0) return focusId;
const batch = anim.plan.batches[anim.batchIndex];
if (anim.direction === 'backward' && batch?.isFirstPromptRegion) return null;
return batch?.tgtId ?? null;
}
export function resolveRecursiveEdgeAnimationRenderOverlay(args: {
effectiveFocusId: string | null;
focusState: DagFocusAttributionState | null;
userAnimationFocusId: string | null;
animation: DagEdgeBatchAnimationState | null;
recursiveAttributionEnabled: boolean;
computeFocusState: ComputeFocusStateFn;
computeSteadyStateStayShareById: ComputeSteadyStateStayShareByIdFn;
ctx: DagFocusAttributionGraphContext;
/** 传播描边/蓝边/动画不展示涉及该节点的部分(如 inactive)。 */
isPropagationNodeSuppressed?: (nodeId: string) => boolean;
}): RecursiveEdgeAnimationRenderOverlay {
const {
effectiveFocusId: focusId,
focusState,
userAnimationFocusId,
animation: anim,
recursiveAttributionEnabled,
computeFocusState,
computeSteadyStateStayShareById,
ctx,
isPropagationNodeSuppressed,
} = args;
const edgeTouchesSuppressedNode = (edgeKey: string): boolean => {
if (!isPropagationNodeSuppressed) return false;
const ends = parseDagLinkEndpointKey(edgeKey);
if (ends == null) return false;
return (
isPropagationNodeSuppressed(ends.srcId) || isPropagationNodeSuppressed(ends.tgtId)
);
};
const emptyIncoming = new Map<string, number>();
if (
focusId == null ||
focusState == null ||
!recursiveAttributionEnabled ||
userAnimationFocusId == null ||
userAnimationFocusId !== focusId ||
anim == null
) {
const nodeStrokeShareById =
focusId != null && focusState != null && recursiveAttributionEnabled
? computeSteadyStateStayShareById(focusState.nodeShareById, focusId)
: null;
return {
animationFrontierPartial: false,
anim: null,
frontierEdgeKeys: null,
linkFocusState: focusState,
nodeStrokeShareById,
incomingShareForRender: focusState?.incomingEdgeShareByKey ?? emptyIncoming,
incomingMaxForRender: maxHighlightEdgeShare(focusState?.incomingEdgeShareByKey ?? emptyIncoming),
propagationSlideTgtId: null,
forwardPromptPreambleFrame: false,
deferFocusHighlightDuringAnim: false,
suppressFocusSelectedStroke: false,
edgeVisibility: INACTIVE_EDGE_VISIBILITY,
};
}
const animationFrontierPartial =
anim != null &&
anim.plan.focusId === userAnimationFocusId &&
isRecursiveEdgeAnimationFrontierPartial(anim, userAnimationFocusId);
const frontierEdgeKeys =
animationFrontierPartial && anim != null
? frontierEdgeKeysAtBatch(
anim.plan,
anim.direction,
anim.batchIndex,
anim.forwardPromptPreamblePending,
)
: null;
let nodeStrokeShareById = resolveEffectiveStayShareByIdForStroke(
focusState,
focusId,
anim,
computeFocusState,
computeSteadyStateStayShareById,
ctx,
);
if (isPropagationNodeSuppressed && nodeStrokeShareById != null) {
const filtered = new Map<string, number>();
for (const [nodeId, stay] of nodeStrokeShareById) {
if (!isPropagationNodeSuppressed(nodeId)) filtered.set(nodeId, stay);
}
nodeStrokeShareById = filtered;
}
const nodeStrokeMaxForRender =
animationFrontierPartial && anim?.direction === 'backward'
? maxHighlightEdgeShare(computeSteadyStateStayShareById(focusState.nodeShareById, focusId))
: undefined;
const incomingShareForRender = focusState.incomingEdgeShareByKey;
const incomingMaxForRender =
animationFrontierPartial && frontierEdgeKeys != null
? maxShareInEdgeKeySet(incomingShareForRender, frontierEdgeKeys)
: maxHighlightEdgeShare(incomingShareForRender);
const forwardPromptPreambleFrame = anim != null && anim.forwardPromptPreamblePending;
const forwardPartial = animationFrontierPartial && anim?.direction === 'forward';
const propagationSlideTgtId = resolvePropagationSlideTgtId(
anim,
animationFrontierPartial,
focusId,
);
const deferFocusHighlightDuringAnim = forwardPartial;
const suppressFocusSelectedStroke =
animationFrontierPartial &&
focusId != null &&
(forwardPartial || propagationSlideTgtId === focusId);
const edgeVisibility = (edgeKey: string, inPropagationChain: boolean): number => {
if (edgeTouchesSuppressedNode(edgeKey)) return 0;
if (!animationFrontierPartial || !inPropagationChain) {
return 1;
}
return frontierEdgeKeys?.has(edgeKey) ? 1 : 0;
};
return {
animationFrontierPartial,
anim,
frontierEdgeKeys,
linkFocusState: focusState,
nodeStrokeShareById,
nodeStrokeMaxForRender,
incomingShareForRender,
incomingMaxForRender,
forwardPromptPreambleFrame,
propagationSlideTgtId,
deferFocusHighlightDuringAnim,
suppressFocusSelectedStroke,
edgeVisibility,
};
}
function logPropagationPlaybackPlanOnStart(args: {
plan: DagPropagationPlaybackPlan;
focusId: string;
direction: DagRecursiveEdgeAnimationDirection;
initialBatchIndex: number;
pacing: DagRecursiveEdgeReplayPacing;
nodeShareById: ReadonlyMap<string, number>;
tokenLabelOf: (id: string) => string | null;
}): void {
const { plan, focusId, direction, initialBatchIndex, pacing, nodeShareById, tokenLabelOf } = args;
const pacingLine =
pacing.mode === 'step'
? `pacing=step stepMs=${pacing.stepMs}`
: `pacing=total totalS=${pacing.totalS}`;
logDagPropagationPlaybackLine(
`${dagPropLogPad('start', DAG_PROP_LOG_W.event)} | focus=${dagPropLogPad(dagPropLogFmtToken(tokenLabelOf(focusId)), DAG_PROP_LOG_W.focus)} | direction=${dagPropLogPad(direction, DAG_PROP_LOG_W.direction)} | batches=${dagPropLogPadInt(plan.batches.length, DAG_PROP_LOG_W.int3)} | initial=${dagPropLogPadInt(initialBatchIndex, DAG_PROP_LOG_W.int3)} | ${pacingLine}`,
);
const planTextOrder = batchesInTextOrder(plan.batches);
const promptBatches = planTextOrder.filter((b) => b.isFirstPromptRegion);
const chainBatches = planTextOrder.filter((b) => !b.isFirstPromptRegion);
if (promptBatches.length > 0) {
logDagPropagationPlaybackLine(
`${dagPropLogPad('pacing:prompt', DAG_PROP_LOG_W.event)} | regionMax=${dagPropLogPadWeight(plan.promptRegionMax)} | weightTotal=${dagPropLogPadWeight(plan.promptWeightTotal)} | batches=${dagPropLogPadInt(promptBatches.length, DAG_PROP_LOG_W.int3)}`,
);
for (let i = 0; i < promptBatches.length; i++) {
const b = promptBatches[i]!;
const token = dagPropLogFmtToken(tokenLabelOf(b.tgtId));
logDagPropagationPlaybackLine(
`${dagPropLogPad(`plan:prompt[${i}]`, DAG_PROP_LOG_W.event)} | token=${dagPropLogPad(token, DAG_PROP_LOG_W.token)} | share=${dagPropLogPadWeight(b.groupShare)} | weight=${dagPropLogPadWeight(b.propagationWeight)}`,
);
}
}
const batchTgtIds = new Set<string>();
for (const b of chainBatches) {
for (const nodeId of b.nodeIds) batchTgtIds.add(nodeId);
}
const refNodes = nodesAtNodeShareTotalForPlaybackLog(nodeShareById, plan.weightMax, {
excludeFocusId: focusId,
onlyNodeIds: batchTgtIds,
});
logDagPropagationPlaybackLine(
`${dagPropLogPad('pacing', DAG_PROP_LOG_W.event)} | weightMax=${dagPropLogPadWeight(plan.weightMax)} | weightTotal=${dagPropLogPadWeight(plan.chainWeightTotal)} | lookahead=${dagPropLogPadInt(plan.runningMaxLookahead, DAG_PROP_LOG_W.int3)} | nodes=${dagPropLogFmtNodeShareList(refNodes, tokenLabelOf)}`,
);
for (let i = 0; i < chainBatches.length; i++) {
const b = chainBatches[i]!;
const token = dagPropLogFmtToken(tokenLabelOf(b.tgtId));
logDagPropagationPlaybackLine(
`${dagPropLogPad(`plan[${i}]`, DAG_PROP_LOG_W.event)} | token=${dagPropLogPad(token, DAG_PROP_LOG_W.token)} | share_norm=${dagPropLogPadWeight(b.shareNorm)} | running_max=${dagPropLogPadWeight(b.runningMaxNorm)} | weight=${dagPropLogPadWeight(b.propagationWeight)}`,
);
}
}
export type DagPropagationPlaybackPhase = 'idle' | 'playing' | 'paused' | 'ended';
export type DagRecursiveEdgeAnimationController = {
onClear(): void;
setDirection(direction: DagRecursiveEdgeAnimationDirection): void;
getDirection(): DagRecursiveEdgeAnimationDirection;
getUserAnimationFocusId(): string | null;
getPlaybackPhase(): DagPropagationPlaybackPhase;
/** 当前帧自展示以来的时长(ms);无活跃帧时为 0。 */
getCurrentFrameElapsedMs(): number;
canStartPlayback(focusId: string, ctx: DagFocusAttributionGraphContext): boolean;
startPlayback(focusId: string, ctx: DagFocusAttributionGraphContext): void;
pausePlayback(): void;
resumePlayback(): void;
stopPlayback(): void;
isPlaybackActive(): boolean;
resolveRenderOverlay(args: {
effectiveFocusId: string | null;
focusState: DagFocusAttributionState | null;
recursiveAttributionEnabled: boolean;
ctx: DagFocusAttributionGraphContext;
isPropagationNodeSuppressed?: (nodeId: string) => boolean;
}): RecursiveEdgeAnimationRenderOverlay;
dispose(): void;
};
export type CreateDagRecursiveEdgeAnimationControllerOptions = {
onTick: () => void;
onPlaybackPhaseChange?: () => void;
computeFocusState: ComputeFocusStateFn;
computeSteadyStateStayShareById: ComputeSteadyStateStayShareByIdFn;
isRecursiveAttributionEnabled: () => boolean;
hasNode: (id: string) => boolean;
offsetOf: (id: string) => number;
/** 节点 id → 是否 prompt(`step === -1`)。 */
isPromptNode: (id: string) => boolean;
/** 节点 id → 界面展示用 token 文案(如 `displayLabel`)。 */
tokenLabelOf: (id: string) => string | null;
direction?: DagRecursiveEdgeAnimationDirection;
/** 开始传播播放时读取;与 DAG 生成回放共用 UI 配置。 */
getReplayPacing?: () => DagRecursiveEdgeReplayPacing;
getPropagationPlaybackOptions?: () => DagPropagationPlaybackOptions;
};
export function createDagRecursiveEdgeAnimationController(
options: CreateDagRecursiveEdgeAnimationControllerOptions,
): DagRecursiveEdgeAnimationController {
const defaultPacing = (): DagRecursiveEdgeReplayPacing => ({
mode: 'step',
stepMs: DAG_RECURSIVE_EDGE_BATCH_STEP_MS_FALLBACK,
totalS: 7,
});
const getReplayPacing = options.getReplayPacing ?? defaultPacing;
const getPropagationPlaybackOptions =
options.getPropagationPlaybackOptions ??
((): DagPropagationPlaybackOptions => ({
forwardSlideSharedNodes: false,
lightningEffect: false,
lightningThresholdTau: DAG_LIGHTNING_THRESHOLD_TAU_DEFAULT,
lightningSlowMo: DAG_LIGHTNING_SLOW_MO_DEFAULT,
lightningSound: false,
}));
const notifyPhaseChange = (): void => {
options.onPlaybackPhaseChange?.();
};
let direction: DagRecursiveEdgeAnimationDirection = options.direction ?? 'forward';
let userAnimationFocusId: string | null = null;
let animation: DagEdgeBatchAnimationState | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
let version = 0;
let graphCtx: DagFocusAttributionGraphContext | null = null;
let playbackPhase: DagPropagationPlaybackPhase = 'idle';
let currentFrameShownAt: number | null = null;
function setPlaybackPhase(next: DagPropagationPlaybackPhase): void {
if (playbackPhase === next) return;
playbackPhase = next;
notifyPhaseChange();
}
function stopPlayback(): void {
if (animation != null) {
const s = animation;
const batch = s.plan.batches[s.batchIndex];
const lastBatch = s.plan.batches.length - 1;
logDagPropagationPlaybackLine(
`${dagPropLogPad('stop', DAG_PROP_LOG_W.event)} | focus=${dagPropLogPad(dagPropLogFmtToken(options.tokenLabelOf(s.plan.focusId)), DAG_PROP_LOG_W.focus)} | frame=${dagPropLogPad(`${s.batchIndex}/${lastBatch}`, DAG_PROP_LOG_W.frame)} | token=${dagPropLogPad(dagPropLogFmtToken(batch != null ? options.tokenLabelOf(batch.tgtId) : null), DAG_PROP_LOG_W.token)}`,
);
}
version++;
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
animation = null;
userAnimationFocusId = null;
currentFrameShownAt = null;
setPlaybackPhase('idle');
}
function onClear(): void {
graphCtx = null;
stopPlayback();
}
function canStartPlayback(focusId: string, ctx: DagFocusAttributionGraphContext): boolean {
if (!options.isRecursiveAttributionEnabled() || !options.hasNode(focusId)) {
return false;
}
const focusState = options.computeFocusState(
focusId,
{
maxIncomingDepth: Number.POSITIVE_INFINITY,
includeDownstreamInfluence: false,
},
ctx,
);
if (focusState == null || focusState.incomingEdgeShareByKey.size === 0) {
return false;
}
return (
buildPropagationPlaybackPlan(
focusState.incomingEdgeShareByKey,
options.offsetOf,
focusState.nodeShareById,
focusId,
options.isPromptNode,
) != null
);
}
function startPlayback(focusId: string, ctx: DagFocusAttributionGraphContext): void {
graphCtx = ctx;
version++;
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
animation = null;
if (!canStartPlayback(focusId, ctx)) {
userAnimationFocusId = null;
setPlaybackPhase('idle');
return;
}
const focusState = options.computeFocusState(
focusId,
{
maxIncomingDepth: Number.POSITIVE_INFINITY,
includeDownstreamInfluence: false,
},
ctx,
);
if (focusState == null) {
setPlaybackPhase('idle');
return;
}
const forwardSlideSharedNodes = getPropagationPlaybackOptions().forwardSlideSharedNodes;
const plan = buildPropagationPlaybackPlan(
focusState.incomingEdgeShareByKey,
options.offsetOf,
focusState.nodeShareById,
focusId,
options.isPromptNode,
);
if (plan == null) {
setPlaybackPhase('idle');
return;
}
const lastBatch = plan.batches.length - 1;
let batchIndex = direction === 'backward' ? 0 : Math.max(0, lastBatch);
let forwardPromptPreamblePending = false;
if (
direction === 'forward' &&
!forwardSlideSharedNodes &&
planHasFirstPromptRegionBatch(plan)
) {
forwardPromptPreamblePending = true;
batchIndex = firstNonPromptRegionBatchIndexFromEnd(plan);
}
userAnimationFocusId = focusId;
animation = {
plan,
direction,
batchIndex,
forwardPromptPreamblePending,
weightScope: { direction, forwardSlideSharedNodes },
};
logPropagationPlaybackPlanOnStart({
plan,
focusId,
direction,
initialBatchIndex: batchIndex,
pacing: getReplayPacing(),
nodeShareById: focusState.nodeShareById,
tokenLabelOf: options.tokenLabelOf,
});
setPlaybackPhase('playing');
scheduleAnimationStep(focusId);
}
function pausePlayback(): void {
if (playbackPhase !== 'playing') return;
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
setPlaybackPhase('paused');
}
function resumePlayback(): void {
if (playbackPhase !== 'paused' && playbackPhase !== 'ended') return;
const focusId = animation?.plan.focusId;
if (focusId == null || graphCtx == null) return;
setPlaybackPhase('playing');
scheduleAnimationStep(focusId);
}
/** 当前帧的模拟开销(ms);固定帧或本帧 `propagationWeight` 决定。 */
function appearanceCostMsForCurrentFrame(state: DagEdgeBatchAnimationState): number {
const lastBatch = state.plan.batches.length - 1;
const backwardSkippedPromptSteadyEnd =
state.direction === 'backward' &&
planHasFirstPromptRegionBatch(state.plan) &&
lastNonFirstPromptRegionBatchIndex(state.plan) < lastBatch &&
state.batchIndex === lastBatch;
if (
state.forwardPromptPreamblePending ||
(state.direction === 'backward' && state.batchIndex === 0) ||
(state.direction === 'forward' && state.batchIndex === 0) ||
backwardSkippedPromptSteadyEnd
) {
if (state.direction === 'forward' && state.batchIndex === 0) {
return lightningBoundaryFrameDwellMs(
DAG_PROPAGATION_BOUNDARY_FRAME_DWELL_MS,
getPropagationPlaybackOptions().lightningEffect,
getPropagationPlaybackOptions().lightningSlowMo,
);
}
return DAG_PROPAGATION_BOUNDARY_FRAME_DWELL_MS;
}
const batch = state.plan.batches[state.batchIndex];
if (batch == null) return 0;
return batchAppearanceCostMs(batch, state.plan, getReplayPacing(), state.weightScope);
}
/** batchIndex 时间线:backward `0 → lastAnim → last(稳态,跳过 prompt 区)`;forward `preamble? → last → … → 0(稳态)`。 */
function hasNextBatch(state: DagEdgeBatchAnimationState, lastBatch: number): boolean {
if (state.direction === 'backward') {
return state.batchIndex < lastBatch;
}
return state.forwardPromptPreamblePending || state.batchIndex > 0;
}
function advanceBatchIndex(state: DagEdgeBatchAnimationState): void {
if (state.direction === 'backward') {
const lastBatch = state.plan.batches.length - 1;
const lastAnim = lastNonFirstPromptRegionBatchIndex(state.plan);
if (state.batchIndex < lastAnim) {
state.batchIndex += 1;
return;
}
if (state.batchIndex === lastAnim && lastAnim < lastBatch) {
state.batchIndex = lastBatch;
return;
}
state.batchIndex += 1;
return;
}
if (state.forwardPromptPreamblePending) {
state.forwardPromptPreamblePending = false;
return;
}
state.batchIndex -= 1;
}
function logPropagationFrame(state: DagEdgeBatchAnimationState): void {
const preamble = state.forwardPromptPreamblePending;
const batch = preamble ? null : state.plan.batches[state.batchIndex];
const lastBatch = state.plan.batches.length - 1;
const appearanceCostMs = appearanceCostMsForCurrentFrame(state);
const token = preamble
? 'prompt'
: dagPropLogFmtToken(batch?.tgtId != null ? options.tokenLabelOf(batch.tgtId) : null);
const weight = preamble ? 'fixed' : dagPropLogFmtWeight(batch?.propagationWeight);
logDagPropagationPlaybackLine(
`${dagPropLogPad('frame', DAG_PROP_LOG_W.event)} ${dagPropLogPad(`${state.batchIndex}/${lastBatch}`, DAG_PROP_LOG_W.frame)} | token=${dagPropLogPad(token, DAG_PROP_LOG_W.token)} | weight=${dagPropLogPad(weight, DAG_PROP_LOG_W.weight)} | costMs=${dagPropLogPadInt(appearanceCostMs, DAG_PROP_LOG_W.dwell)}`,
);
}
function scheduleAnimationStep(focusId: string): void {
const state = animation;
if (!state || state.plan.focusId !== focusId) return;
const lastBatch = state.plan.batches.length - 1;
if (state.plan.batches.length === 0) {
timer = null;
return;
}
if (state.direction === 'backward' && lastBatch <= 0) {
timer = null;
return;
}
const capturedVersion = ++version;
const showFrameAndScheduleNext = (): void => {
if (version !== capturedVersion) return;
const liveState = animation;
if (!liveState || liveState.plan.focusId !== focusId) return;
currentFrameShownAt = performance.now();
options.onTick();
logPropagationFrame(liveState);
const appearanceCostMs = appearanceCostMsForCurrentFrame(liveState);
timer = setTimeout(() => {
if (version !== capturedVersion) return;
const stateAfterCost = animation;
if (!stateAfterCost || stateAfterCost.plan.focusId !== focusId) return;
if (!hasNextBatch(stateAfterCost, lastBatch)) {
timer = null;
setPlaybackPhase('ended');
return;
}
advanceBatchIndex(stateAfterCost);
showFrameAndScheduleNext();
}, appearanceCostMs);
};
showFrameAndScheduleNext();
}
return {
onClear,
setDirection(next: DagRecursiveEdgeAnimationDirection): void {
if (direction === next) return;
direction = next;
stopPlayback();
},
getDirection(): DagRecursiveEdgeAnimationDirection {
return direction;
},
getUserAnimationFocusId(): string | null {
return userAnimationFocusId;
},
getPlaybackPhase(): DagPropagationPlaybackPhase {
return playbackPhase;
},
getCurrentFrameElapsedMs(): number {
if (currentFrameShownAt == null) return 0;
return Math.max(0, performance.now() - currentFrameShownAt);
},
canStartPlayback,
startPlayback,
pausePlayback,
resumePlayback,
stopPlayback,
isPlaybackActive(): boolean {
return timer !== null;
},
resolveRenderOverlay(args): RecursiveEdgeAnimationRenderOverlay {
return resolveRecursiveEdgeAnimationRenderOverlay({
...args,
userAnimationFocusId,
animation,
computeFocusState: options.computeFocusState,
computeSteadyStateStayShareById: options.computeSteadyStateStayShareById,
});
},
dispose(): void {
onClear();
},
};
}