import { DAG_MIN_ATTRIBUTION_SHARE } from './genAttributeDagEdgeDisplay'; import { DAG_PROP_LOG_W, dagPropLogFmtNodeShareList, dagPropLogFmtToken, dagPropLogFmtWeight, dagPropLogPad, dagPropLogPadInt, dagPropLogPadWeight, logDagPropagationPlaybackLine, nodesAtNodeShareTotalForPlaybackLog, } from './genAttributeDagPropagationPlaybackLog'; import { batchPlaybackDelayMs, computePropagationGroupPacings, FORWARD_PROMPT_FRAME_DWELL_MS, type DagRecursiveEdgeReplayPacing, type DagReplayPacingMode, type PropagationGroupPrep, } from './genAttributeDagPropagationPlaybackPacing'; export type { DagRecursiveEdgeReplayPacing, DagReplayPacingMode } from './genAttributeDagPropagationPlaybackPacing'; export { batchPlaybackDelayMs, computePropagationGroupPacings, DAG_PROPAGATION_WEIGHT_RUNNING_MAX_LOOKAHEAD_MIN, DAG_PROPAGATION_WEIGHT_RUNNING_MAX_LOOKAHEAD_RATIO, FORWARD_PROMPT_FRAME_DWELL_MS, propagationRunningMaxLookaheadForGroupCount, } from './genAttributeDagPropagationPlaybackPacing'; export { DAG_PROPAGATION_PLAYBACK_LOG_LS_KEY, isDagPropagationPlaybackLogEnabled, setDagPropagationPlaybackLogEnabled, } from './genAttributeDagPropagationPlaybackLog'; export type DagRecursiveEdgeAnimationDirection = 'backward' | 'forward'; /** forward 专有第 0 帧:仅 prompt(稳态描边/归一),无传播链边。 */ export const FORWARD_PROMPT_BATCH_INDEX = -1; /** forward {@link FORWARD_PROMPT_BATCH_INDEX} 帧:仅展示 prompt,外观与稳态一致。 */ export function isForwardPromptOnlyBatchIndex( direction: DagRecursiveEdgeAnimationDirection, batchIndex: number, ): boolean { return direction === 'forward' && batchIndex === FORWARD_PROMPT_BATCH_INDEX; } /** 与 {@link genAttributeDagView} 内焦点归因快照同形;供动画 overlay 消费。 */ export type DagFocusAttributionState = { activeNodeIds: Set; incomingEdgeShareByKey: Map; downstreamEdgeStrengthByKey: Map; nodeShareById: Map; }; export type DagFocusAttributionComputeOptions = { maxIncomingDepth: number; includeDownstreamInfluence: boolean; allowedEdgeKeys?: ReadonlySet; }; export type DagFocusAttributionGraphContext = { nodesSortedByStepDesc: readonly { id: string; step: number }[]; incomingLinksByTarget: ReadonlyMap; }; type ComputeFocusStateFn = ( focusId: string, options: DagFocusAttributionComputeOptions, ctx: DagFocusAttributionGraphContext, ) => DagFocusAttributionState | null; type ComputeSteadyStateStayShareByIdFn = ( nodeShareById: Map, focusId: string, ) => Map; /** 无 {@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** * - 第 0 帧 {@link FORWARD_PROMPT_BATCH_INDEX}:无传播链边,仅 prompt 节点按稳态 stay 描边/归一;固定停留 {@link FORWARD_PROMPT_FRAME_DWELL_MS}ms。 * - 其后从最远 batch 递减;share 始终用全量焦点快照,动画只改「可见边集合」与归一分母(前沿内 max share)。 * - 部分帧内焦点不提前高亮/描边(render 延后至末帧 `batchIndex === 0` 稳态),与反向首帧才亮焦点对称。 * - 同一帧内,已可见边的相对强弱 = share 相对强弱;绝对 opacity 可因分母随新批次变大而变暗。 * - 末帧 `batchIndex === 0` 时前沿 = 全链、分母 = 全链 max、可见性全开,与无动画稳定态数值一致(收敛)。 * * **backward** * - 首帧 `batchIndex === 0`(焦点侧):固定停留 {@link FORWARD_PROMPT_FRAME_DWELL_MS}ms,焦点红色 slide;与 forward prompt 首帧对称,不参与权重分配。 * - 蓝线从焦点侧逐批显现:稳态 share + {@link backwardFrontierByBatchIndex} 门控(`batches[0..i]` 递增)。 * - 未滑过:live stay;已滑过 batch:稳态 stay;非播放链生成 token 不描边;prompt(`step === -1`)若在候选集中则用 live stay(可不在传播链上)。 * - 描边分母为稳态 `max(stay)`。 * - 当前帧 slide 节点(`--backward-slide`)的入边:红色,强度在本批指向 slide 的入边集合内 max 归一 × 焦点 MI。 * * **播放计划(见 {@link DagPropagationPlaybackPlan})** * - 一批 = 同一生成 offset 的入边组;`groupOffset` + `tgtId` 标识该组代表 token。 * - 播放间隔权重(准备阶段一遍):按**文字顺序**对非焦点 `groupShare/weightMax` 做 running max 归一化;向后看组数 = max({@link DAG_PROPAGATION_WEIGHT_RUNNING_MAX_LOOKAHEAD_MIN}, round(比例×组数));与播放方向无关。组内含焦点则无 `shareNorm`。 * - `backwardFrontierByBatchIndex` / `forwardFrontierByBatchIndex`:各方向蓝线可见边并集,render 热路径 O(1)。 * - forward / backward 共用同一 plan;不用 backward 部分快照的 nodeShare 定权重。 * * **播放停留(见 {@link batchPlaybackDelayMs})** * - 对 `propagationWeight` 连续;权重为 0 时停留恰为 0(`step` 下 0ms,不设最小间隔)。 * - `total` 模式:UI `totalS` 中预留 {@link FORWARD_PROMPT_FRAME_DWELL_MS} 给 forward prompt / backward 首帧,其余按权重分配。 */ /** 传播链动画的一批:同一生成 offset 的入边组 + 播放元数据。 */ export type DagRecursiveIncomingEdgeBatch = { /** 与 {@link buildPropagationPlaybackPlan} 分批键一致(`start(tgt)`)。 */ groupOffset: number; /** 本组代表 token(forward 高亮)。 */ tgtId: string; /** 本组传播链入边(`src->tgt`)。 */ edgeKeys: string[]; /** * 文字顺序局部归一化权重:share_norm ÷ runningMax(含向后 lookahead 窗口内的非焦点 share_norm)。 */ propagationWeight: number; /** * 非焦点 `groupShare / weightMax`(playback 日志 share_norm)。 * 组内含焦点时为 undefined。 */ shareNorm?: number; /** 准备阶段:截至本组(含 lookahead 窗口)的链序 running max。 */ runningMaxNorm: number; }; /** 点击焦点时生成的不可变播放计划(批次 + 预计算前沿 + 播放权重)。 */ export type DagPropagationPlaybackPlan = { focusId: string; batches: DagRecursiveIncomingEdgeBatch[]; /** 全链非焦点组 Total share 上限(日志 / 对照;量纲同 Total share)。 */ weightMax: number; /** Σ `batches[].propagationWeight`;total 模式分母。 */ weightTotal: number; /** 本计划 running max 前瞻组数(max(MIN, round(比例×组数)))。 */ runningMaxLookahead: number; /** backward:`batchIndex = i` 时可见边 = `batches[0..i]` 并集。 */ backwardFrontierByBatchIndex: ReadonlyArray>; /** forward:`batchIndex = i` 时可见边 = `batches[i..末]` 并集。 */ forwardFrontierByBatchIndex: ReadonlyArray>; }; /** 进行中的播放状态:仅 batchIndex 与 direction 可变。 */ export type DagEdgeBatchAnimationState = { plan: DagPropagationPlaybackPlan; direction: DagRecursiveEdgeAnimationDirection; batchIndex: number; }; 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 = new Set(); /** backward 当前批:指向 slide 节点的入边(与全图按 tgt 筛等价,仅扫本批 edgeKeys)。 */ export function backwardSlideIncomingEdgeKeysForBatch( plan: DagPropagationPlaybackPlan, batchIndex: number, focusId: string, ): ReadonlySet { const batch = plan.batches[batchIndex]; if (batch == null) return EMPTY_EDGE_KEY_SET; const slideTgtId = batchIndex === 0 ? focusId : batch.tgtId; const keys = new Set(); for (const key of batch.edgeKeys) { if (tgtIdFromEdgeKey(key) === slideTgtId) keys.add(key); } return keys; } /** 当前 batchIndex、方向下已启用的传播链入边(计划内预计算)。 */ function frontierEdgeKeysAtBatch( plan: DagPropagationPlaybackPlan, direction: DagRecursiveEdgeAnimationDirection, batchIndex: number, ): ReadonlySet { if (isForwardPromptOnlyBatchIndex(direction, batchIndex)) { 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, edgeKeys: ReadonlySet, ): 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): 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, incomingEdgeShareByKey: Map, focusId: string, ): Map { const propagatedByTarget = new Map(); 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(); 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 为稳态终帧;其余含 prompt(-1) 均属「动画未结束」(边/焦点行为由专帧 flag 区分) return animation.batchIndex !== 0; } function isBackwardRecursiveEdgeAnimationInProgress( animation: DagEdgeBatchAnimationState | null, focusId: string, ): boolean { return ( animation != null && animation.direction === 'backward' && isRecursiveEdgeAnimationFrontierPartial(animation, focusId) ); } function tgtIdsInBatch(batch: DagRecursiveIncomingEdgeBatch): Set { const ids = new Set(); for (const edgeKey of batch.edgeKeys) { const tgtId = tgtIdFromEdgeKey(edgeKey); if (tgtId != null) ids.add(tgtId); } return ids; } function batchesInTextOrder( batches: readonly DagRecursiveIncomingEdgeBatch[], ): DagRecursiveIncomingEdgeBatch[] { return [...batches].sort((a, b) => a.groupOffset - b.groupOffset); } /** 组内代表 tgt;并列时取 id 字典序最小。 */ function primaryTgtIdForGroup( tgtIds: Iterable, nodeShareById: ReadonlyMap, ): string { let bestId = ''; let bestShare = -1; for (const tgtId of tgtIds) { const share = nodeShareById.get(tgtId) ?? 0; if (share > bestShare || (share === bestShare && tgtId < bestId)) { bestShare = share; bestId = tgtId; } } return bestId; } function incomingEdgeBatchFromGroup( groupOffset: number, group: { edgeKeys: string[]; tgtIds: Set }, prep: PropagationGroupPrep, nodeShareById: ReadonlyMap, ): DagRecursiveIncomingEdgeBatch { group.edgeKeys.sort(); return { groupOffset, tgtId: primaryTgtIdForGroup(group.tgtIds, nodeShareById), edgeKeys: group.edgeKeys, propagationWeight: prep.propagationWeight, runningMaxNorm: prep.runningMaxNorm, ...(prep.shareNorm != null ? { shareNorm: prep.shareNorm } : {}), }; } function buildFrontierEdgeKeysByBatchIndex( batches: readonly DagRecursiveIncomingEdgeBatch[], ): Pick { const n = batches.length; const backward: Set[] = []; const forward: Set[] = []; 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 }; } /** * 传播链播放计划:入边按 `start(tgt)` 分批(offset 降序),并预计算双向前沿。 * backward 从 index 0 递增(蓝线递增),forward 从末批递减(蓝线递增)。 */ export function buildPropagationPlaybackPlan( incomingEdgeShareByKey: Map, offsetOf: (id: string) => number, nodeShareById: ReadonlyMap, focusId: string, ): DagPropagationPlaybackPlan | null { if (incomingEdgeShareByKey.size === 0) return null; const byOffset = new Map }>(); for (const edgeKey of incomingEdgeShareByKey.keys()) { const tgtId = tgtIdFromEdgeKey(edgeKey); if (tgtId == null) continue; const offset = offsetOf(tgtId); let group = byOffset.get(offset); if (group == null) { group = { edgeKeys: [], tgtIds: new Set() }; byOffset.set(offset, group); } group.edgeKeys.push(edgeKey); group.tgtIds.add(tgtId); } const sortedOffsetsAsc = [...byOffset.keys()].sort((a, b) => a - b); const sortedOffsetsDesc = [...sortedOffsetsAsc].reverse(); const { groupPreps, weightMax, weightTotal, runningMaxLookahead } = computePropagationGroupPacings( sortedOffsetsAsc.map((groupOffset) => byOffset.get(groupOffset)!), 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, 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, ); 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 { const ids = new Set(); for (let i = 0; i < batchIndex; i++) { for (const id of tgtIdsInBatch(batches[i]!)) ids.add(id); } return ids; } /** 播放计划传播链上的全部 tgt(含同 offset 非代表 token)。 */ function playbackChainNodeIds(batches: readonly DagRecursiveIncomingEdgeBatch[]): Set { const onChain = new Set(); for (const batch of batches) { for (const id of tgtIdsInBatch(batch)) onChain.add(id); } return onChain; } function promptNodeIdsFromCtx(ctx: DagFocusAttributionGraphContext): Set { const ids = new Set(); 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 { 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(); 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 | null; /** 与 focusState 同引用;入边 share 恒为全量,可见性由 frontier / edgeVisibility 裁切。 */ linkFocusState: DagFocusAttributionState | null; nodeStrokeShareById: Map | null; /** backward 部分帧:稳定态 stay 池 max,供描边归一分母;否则 undefined 用当前池 max。 */ nodeStrokeMaxForRender?: number; incomingShareForRender: Map; incomingMaxForRender: number; /** forward {@link FORWARD_PROMPT_BATCH_INDEX}:仅 prompt 稳态描边,无链边、无 slide 高亮。 */ forwardPromptOnlyFrame: boolean; /** 当前帧 slide token:forward 为 batch 代表;backward 首帧为焦点。不含 forward prompt 专帧。 */ 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 = 焦点;forward prompt 专帧和无动画 = null;其余 = 当前批代表 token。 */ function resolvePropagationSlideTgtId( anim: DagEdgeBatchAnimationState | null, animationFrontierPartial: boolean, forwardPromptOnlyFrame: boolean, focusId: string, ): string | null { if (anim == null || !animationFrontierPartial || forwardPromptOnlyFrame) return null; if (anim.direction === 'backward' && anim.batchIndex === 0) return focusId; return anim.plan.batches[anim.batchIndex]?.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; }): RecursiveEdgeAnimationRenderOverlay { const { effectiveFocusId: focusId, focusState, userAnimationFocusId, animation: anim, recursiveAttributionEnabled, computeFocusState, computeSteadyStateStayShareById, ctx, } = args; const emptyIncoming = new Map(); 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), forwardPromptOnlyFrame: false, propagationSlideTgtId: null, 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) : null; const nodeStrokeShareById = resolveEffectiveStayShareByIdForStroke( focusState, focusId, anim, computeFocusState, computeSteadyStateStayShareById, ctx, ); 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 forwardPromptOnlyFrame = anim != null && isForwardPromptOnlyBatchIndex(anim.direction, anim.batchIndex); const forwardPartial = animationFrontierPartial && anim?.direction === 'forward'; const propagationSlideTgtId = resolvePropagationSlideTgtId( anim, animationFrontierPartial, forwardPromptOnlyFrame, focusId, ); const deferFocusHighlightDuringAnim = forwardPartial; const suppressFocusSelectedStroke = animationFrontierPartial && focusId != null && (forwardPartial || propagationSlideTgtId === focusId); const edgeVisibility = (edgeKey: string, inPropagationChain: boolean): number => { if (!animationFrontierPartial || !inPropagationChain) { return 1; } return frontierEdgeKeys?.has(edgeKey) ? 1 : 0; }; return { animationFrontierPartial, anim, frontierEdgeKeys, linkFocusState: focusState, nodeStrokeShareById, nodeStrokeMaxForRender, incomingShareForRender, incomingMaxForRender, forwardPromptOnlyFrame, propagationSlideTgtId, deferFocusHighlightDuringAnim, suppressFocusSelectedStroke, edgeVisibility, }; } function logPropagationPlaybackPlanOnStart(args: { plan: DagPropagationPlaybackPlan; focusId: string; direction: DagRecursiveEdgeAnimationDirection; initialBatchIndex: number; pacing: DagRecursiveEdgeReplayPacing; nodeShareById: ReadonlyMap; 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 batchTgtIds = new Set(); for (const b of plan.batches) { for (const tgtId of tgtIdsInBatch(b)) batchTgtIds.add(tgtId); } 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.weightTotal)} | lookahead=${dagPropLogPadInt(plan.runningMaxLookahead, DAG_PROP_LOG_W.int3)} | nodes=${dagPropLogFmtNodeShareList(refNodes, tokenLabelOf)}`, ); const planTextOrder = batchesInTextOrder(plan.batches); for (let chainStep = 0; chainStep < planTextOrder.length; chainStep++) { const b = planTextOrder[chainStep]!; const token = dagPropLogFmtToken(tokenLabelOf(b.tgtId)); logDagPropagationPlaybackLine( `${dagPropLogPad(`plan[${chainStep}]`, 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; 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; }): 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 → 界面展示用 token 文案(如 `displayLabel`)。 */ tokenLabelOf: (id: string) => string | null; direction?: DagRecursiveEdgeAnimationDirection; /** 开始传播播放时读取;与 DAG 生成回放共用 UI 配置。 */ getReplayPacing?: () => DagRecursiveEdgeReplayPacing; }; 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 notifyPhaseChange = (): void => { options.onPlaybackPhaseChange?.(); }; let direction: DagRecursiveEdgeAnimationDirection = options.direction ?? 'forward'; let userAnimationFocusId: string | null = null; let animation: DagEdgeBatchAnimationState | null = null; let timer: ReturnType | null = null; let version = 0; let graphCtx: DagFocusAttributionGraphContext | null = null; let playbackPhase: DagPropagationPlaybackPhase = 'idle'; 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; 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, ) != 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 plan = buildPropagationPlaybackPlan( focusState.incomingEdgeShareByKey, options.offsetOf, focusState.nodeShareById, focusId, ); if (plan == null) { setPlaybackPhase('idle'); return; } const initialBatchIndex = direction === 'backward' ? 0 : FORWARD_PROMPT_BATCH_INDEX; userAnimationFocusId = focusId; animation = { plan, direction, batchIndex: initialBatchIndex, }; logPropagationPlaybackPlanOnStart({ plan, focusId, direction, initialBatchIndex, 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); } function delayMsForCurrentBatch(state: DagEdgeBatchAnimationState): number { if ( isForwardPromptOnlyBatchIndex(state.direction, state.batchIndex) || (state.direction === 'backward' && state.batchIndex === 0) ) { return FORWARD_PROMPT_FRAME_DWELL_MS; } const batch = state.plan.batches[state.batchIndex]; if (batch == null) return 0; return batchPlaybackDelayMs(batch, state.plan, getReplayPacing()); } /** * 展示当前帧后的停留(ms)。 * forward 稳态末帧(batchIndex 0)为 0;forward prompt / backward 首帧为固定值。 */ function dwellMsAfterCurrentFrame(state: DagEdgeBatchAnimationState): number { if (state.direction === 'forward' && state.batchIndex === 0) return 0; return delayMsForCurrentBatch(state); } /** batchIndex 时间线:backward `0 → last`;forward `-1(prompt) → last → … → 0(稳态)`。 */ function hasNextBatch(state: DagEdgeBatchAnimationState, lastBatch: number): boolean { if (state.direction === 'backward') { return state.batchIndex < lastBatch; } return ( state.batchIndex === FORWARD_PROMPT_BATCH_INDEX || state.batchIndex > 0 ); } function advanceBatchIndex(state: DagEdgeBatchAnimationState): void { if (state.direction === 'backward') { state.batchIndex += 1; return; } if (state.batchIndex === FORWARD_PROMPT_BATCH_INDEX) { state.batchIndex = state.plan.batches.length - 1; return; } state.batchIndex -= 1; } function logPropagationFrame(state: DagEdgeBatchAnimationState): void { const promptFrame = isForwardPromptOnlyBatchIndex(state.direction, state.batchIndex); const batch = promptFrame ? null : state.plan.batches[state.batchIndex]; const lastBatch = state.plan.batches.length - 1; const dwellMs = dwellMsAfterCurrentFrame(state); const token = promptFrame ? 'prompt' : dagPropLogFmtToken(batch?.tgtId != null ? options.tokenLabelOf(batch.tgtId) : null); const weight = promptFrame ? '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)} | dwellMs=${dagPropLogPadInt(dwellMs, 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; options.onTick(); logPropagationFrame(liveState); const dwellMs = dwellMsAfterCurrentFrame(liveState); timer = setTimeout(() => { if (version !== capturedVersion) return; const stateAfterDwell = animation; if (!stateAfterDwell || stateAfterDwell.plan.focusId !== focusId) return; if (!hasNextBatch(stateAfterDwell, lastBatch)) { timer = null; setPlaybackPhase('ended'); return; } advanceBatchIndex(stateAfterDwell); showFrameAndScheduleNext(); }, dwellMs); }; 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; }, 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(); }, }; }