// diagram-cascade-build —— miro-promo 104–116s // prompt 行打字 → 图表节点自上而下逐层级联弹出(根→2 子→4 孙), // 连线跟随节点生长(SVG path 描线),成树后整体呼吸一拍。 import React from 'react'; import { AbsoluteFill, useCurrentFrame, interpolate, spring, useVideoConfig, Easing } from 'remotion'; import { G } from '../../_fixtures/Fixtures'; const PROMPT = 'Generate an entity-relationship diagram'; const TYPE_START = 6; const TYPE_CPS = 1.1; // 字符/帧 // 树布局(画布坐标,节点中心) const NODE_W = 300; const NODE_H = 110; const NODES = [ // level 0 { id: 0, x: 960, y: 330, level: 0, parent: -1 }, // level 1 { id: 1, x: 620, y: 570, level: 1, parent: 0 }, { id: 2, x: 1300, y: 570, level: 1, parent: 0 }, // level 2 { id: 3, x: 400, y: 820, level: 2, parent: 1 }, { id: 4, x: 810, y: 820, level: 2, parent: 1 }, { id: 5, x: 1110, y: 820, level: 2, parent: 2 }, { id: 6, x: 1520, y: 820, level: 2, parent: 2 }, ]; const CASCADE_START = 52; // 根节点弹出时刻 const LEVEL_GAP = 20; // 层间隔 const SIBLING_STAGGER = 6; const nodeStart = (n: (typeof NODES)[number]) => { if (n.level === 0) return CASCADE_START; const siblingIdx = NODES.filter((m) => m.level === n.level && m.id < n.id).length; return CASCADE_START + n.level * LEVEL_GAP + siblingIdx * SIBLING_STAGGER; }; // 折角连线 path:父底边中点 → 垂直下探 → 水平 → 垂直进子顶边 const edgePath = (p: (typeof NODES)[number], c: (typeof NODES)[number]) => { const x1 = p.x; const y1 = p.y + NODE_H / 2; const x2 = c.x; const y2 = c.y - NODE_H / 2; const my = (y1 + y2) / 2; return `M ${x1} ${y1} L ${x1} ${my} L ${x2} ${my} L ${x2} ${y2}`; }; const edgeLen = (p: (typeof NODES)[number], c: (typeof NODES)[number]) => { const y1 = p.y + NODE_H / 2; const y2 = c.y - NODE_H / 2; return Math.abs(y2 - y1) + Math.abs(c.x - p.x); }; export const DiagramCascadeBuild: React.FC = () => { const frame = useCurrentFrame(); const { fps } = useVideoConfig(); const typed = Math.min(PROMPT.length, Math.max(0, Math.floor((frame - TYPE_START) * TYPE_CPS))); const caretOn = Math.floor(frame / 8) % 2 === 0; const promptDone = typed >= PROMPT.length; // 成树后整体呼吸一拍 const lastStart = nodeStart(NODES[NODES.length - 1]); const breathe = interpolate(frame, [lastStart + 22, lastStart + 34, lastStart + 50], [1, 1.035, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.inOut(Easing.sin), }); return ( {/* prompt 条 */}
{PROMPT.slice(0, typed)} |
{/* 树(呼吸缩放以树心为原点) */}
{/* 连线层 */} {NODES.filter((n) => n.parent >= 0).map((n) => { const p = NODES[n.parent]; const start = nodeStart(n) - 8; // 线比子节点先长 const len = edgeLen(p, n); const grow = interpolate(frame, [start, start + 16], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.out(Easing.cubic), }); if (grow <= 0) return null; return ( ); })} {/* 节点层 */} {NODES.map((n) => { const start = nodeStart(n); const pop = spring({ frame: frame - start, fps, config: { damping: 11, stiffness: 170 } }); if (frame < start) return null; return (
); })}
); };