// ===== demos/interaction/canvas-materialize-moves/DiagramCascadeBuild.tsx =====
// 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.map((n) => {
const start = nodeStart(n);
const pop = spring({ frame: frame - start, fps, config: { damping: 11, stiffness: 170 } });
if (frame < start) return null;
return (
);
})}
);
};
// ===== demos/interaction/canvas-materialize-moves/PanelToCanvasMaterialize.tsx =====
// panel-to-canvas-materialize —— miro-promo 84–92s
// 侧面板表格行复选框自动逐个打勾 → 按钮按下 → 三行内容飞出面板、
// 物化成画布上三张独立卡片落位(行→卡跨容器形态迁移,尺寸/形状插值)。
import React from 'react';
import { AbsoluteFill, useCurrentFrame, interpolate, spring, useVideoConfig, Easing } from 'remotion';
import { G } from '../../_fixtures/Fixtures';
const PANEL_X = 1210;
const PANEL_Y = 90;
const PANEL_W = 620;
const ROW_H = 92;
const ROWS_TOP = 210; // 面板内第一行的画面 y
// 行 → 卡的目标位(画布左侧区域)
const TARGETS = [
{ x: 150, y: 150, rot: -2 },
{ x: 480, y: 420, rot: 1.5 },
{ x: 180, y: 660, rot: 2 },
];
const CARD_W = 480;
const CARD_H = 240;
const CHECK_FRAMES = [12, 22, 32]; // 三个复选框打勾时刻
const BUTTON_FRAME = 46; // 按钮按下
const FLY_START = [54, 60, 66]; // 三行错峰起飞
export const PanelToCanvasMaterialize: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const btnPress = interpolate(frame, [BUTTON_FRAME, BUTTON_FRAME + 3, BUTTON_FRAME + 9], [0, 1, 0], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
return (
{/* 画布点阵底 */}
{/* 侧面板 */}
{/* 面板标题 */}
{/* 表头 */}
{/* 行槽位(行飞走后留白) */}
{[0, 1, 2].map((i) => (
))}
{/* 面板底部按钮 */}
0 ? G.ink : G.side,
transform: `scale(${1 - btnPress * 0.06})`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
Add all to canvas
{/* 飞行中/落位的三张卡(行→卡形态插值) */}
{[0, 1, 2].map((i) => (
))}
{/* 光标 */}
);
};
// 面板内的一行:复选框自动打勾;起飞后槽位塌陷成虚线留白
const RowSlot: React.FC<{ idx: number; frame: number; fps: number }> = ({ idx, frame, fps }) => {
const checkF = CHECK_FRAMES[idx];
const flyF = FLY_START[idx];
const checked = frame >= checkF;
const checkPop = spring({ frame: frame - checkF, fps, config: { damping: 10, stiffness: 260 } });
const flown = frame >= flyF;
return (
{!flown && (
<>
{/* 复选框 */}
>
)}
);
};
// 行→卡:位置沿贝塞尔弧线飞、尺寸/圆角/内容布局同步插值
const FlyingCard: React.FC<{ idx: number; frame: number; fps: number }> = ({ idx, frame, fps }) => {
const flyF = FLY_START[idx];
if (frame < flyF) return null;
const t = spring({ frame: frame - flyF, fps, config: { damping: 16, stiffness: 60 }, durationInFrames: 34 });
// 起点:面板内该行的屏幕位置/尺寸
const sx = PANEL_X + 30;
const sy = ROWS_TOP + idx * ROW_H;
const sw = PANEL_W - 60;
const sh = ROW_H - 12;
const tgt = TARGETS[idx];
// 弧线:中点向上抬,像被"倒"出来
const mx = (sx + tgt.x) / 2;
const my = Math.min(sy, tgt.y) - 170;
const u = t;
const x = (1 - u) * (1 - u) * sx + 2 * (1 - u) * u * mx + u * u * tgt.x;
const y = (1 - u) * (1 - u) * sy + 2 * (1 - u) * u * my + u * u * tgt.y;
const w = sw + (CARD_W - sw) * u;
const h = sh + (CARD_H - sh) * u;
const rot = tgt.rot * u;
const radius = 10 + 8 * u;
const shadow = interpolate(u, [0, 1], [0.08, 0.16]);
// 行内容(单行水平) → 卡内容(标题+行+footer) 交叉淡化
const rowOp = Math.max(0, 1 - u * 2.2);
const cardOp = Math.max(0, (u - 0.45) / 0.55);
return (
{/* 行形态内容 */}
{/* 卡形态内容 */}
);
};
const Cursor: React.FC<{ frame: number }> = ({ frame }) => {
// 光标:从画面中部移到按钮上并停留按下
const bx = PANEL_X + PANEL_W / 2;
const by = PANEL_Y + 900 - 60;
const x = interpolate(frame, [8, BUTTON_FRAME - 4], [900, bx], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
easing: Easing.inOut(Easing.quad),
});
const y = interpolate(frame, [8, BUTTON_FRAME - 4], [560, by], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
easing: Easing.inOut(Easing.quad),
});
const press = interpolate(frame, [BUTTON_FRAME, BUTTON_FRAME + 3, BUTTON_FRAME + 8], [1, 0.78, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
return (
);
};