// ===== demos/data/gauge-readout-moves/NeedleSweepSelftest.tsx ===== // needle-sweep-selftest —— 满弧扫针自检 // 卡片内三个并排 270° 表盘。f12 "点火"后指针错峰 4f 依次从 0 甩满全弧 // (去程 12f ease-out),再回落到各自真实值(回程 20f:先落过头 8° 再回摆), // 落定同帧盘下数值文字弹出。三表错峰形成波浪。f60 后真静止 80f。 // 帧确定性:纯 interpolate 分段,全部 clamp,settle 后每帧输出常数。 import React from 'react'; import { useCurrentFrame, interpolate, Easing } from 'remotion'; import { G, TitleBlock } from '../../_fixtures/Fixtures'; const AMBER = '#b45309'; const RED = '#7c2d12'; const CARD_W = 1500; const CARD_H = 640; const CARD_X = (1920 - CARD_W) / 2; const CARD_Y = 300; const GA_W = CARD_W / 3; // 每表盘占位宽 const R = 148; const CX = GA_W / 2; const CY = 240; // 表盘角 d∈[0,270] → SVG 角 a = 135 + d(0=右,顺时针,y 向下) const polar = (a: number, r: number): [number, number] => [ CX + r * Math.cos((a * Math.PI) / 180), CY + r * Math.sin((a * Math.PI) / 180), ]; const arcPath = (d0: number, d1: number, r: number): string => { const [x0, y0] = polar(135 + d0, r); const [x1, y1] = polar(135 + d1, r); const large = d1 - d0 > 180 ? 1 : 0; return `M ${x0.toFixed(2)} ${y0.toFixed(2)} A ${r} ${r} 0 ${large} 1 ${x1.toFixed(2)} ${y1.toFixed(2)}`; }; // 三表:点火 f12,错峰 4f const GAUGES = [ { start: 12, target: 190 }, { start: 16, target: 120 }, { start: 20, target: 235 }, ]; const needleAngle = (frame: number, s: number, target: number): number => { if (frame <= s) return 0; if (frame <= s + 12) { // 去程:甩满全弧,ease-out(起步猛) return interpolate(frame, [s, s + 12], [0, 270], { easing: Easing.out(Easing.cubic), }); } if (frame <= s + 25) { // 回程主段:落过真实值 8° return interpolate(frame, [s + 12, s + 25], [270, target - 8], { easing: Easing.inOut(Easing.cubic), }); } // 回摆:从过冲位弹回真实值,之后 clamp 为常数 return interpolate(frame, [s + 25, s + 32], [target - 8, target], { easing: Easing.out(Easing.cubic), extrapolateRight: 'clamp', }); }; const Gauge: React.FC<{ start: number; target: number }> = ({ start, target }) => { const frame = useCurrentFrame(); const d = needleAngle(frame, start, target); const settle = start + 32; const value = Math.round((target / 270) * 100); const popScale = interpolate(frame, [settle, settle + 4, settle + 8], [0.3, 1.18, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', }); const popOp = interpolate(frame, [settle, settle + 3], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', }); const [tipX, tipY] = polar(135, R - 26); const [tailX, tailY] = polar(315, 36); // 大刻度 11 根(每 27°),小刻度每 9° const ticks: React.ReactNode[] = []; for (let k = 0; k <= 30; k++) { const dd = k * 9; const major = k % 3 === 0; const a = 135 + dd; const [x0, y0] = polar(a, R - 8); const [x1, y1] = polar(a, major ? R - 30 : R - 19); ticks.push( = 225 ? RED : G.mid} strokeWidth={major ? 4 : 2} /> ); } return (
{/* 弧形轨道 + 红区 */} {ticks} {/* 指针:琥珀刚体,rotate 全量程往返 */} {/* 落定同帧弹出的数值 */}
{value} %
); }; export const NeedleSweepSelftest: React.FC = () => { return (
{GAUGES.map((g, i) => ( ))}
); }; // ===== demos/data/gauge-readout-moves/TapeScrollFixedPointer.tsx ===== // tape-scroll-fixed-pointer —— 滚带定针 // 取景窗+琥珀三角指针钉死在画面正中,竖向长刻度带(0–500,每 50 一大格) // 从窗后滚过:"世界动、针不动"。先慢速爬(f12–55 读数 60→140 缓涨), // 然后一次大跳变:刻度带冲刺 ~23f(峰值 ~50px/f)冲过 420 到 442, // spring 刹车回摆一次(442→415→420)落定。窗内读数同步。f96 后真静止 44f。 // 帧确定性:value(frame) 纯分段 interpolate 全 clamp,带偏移 = value 线性映射。 import React from 'react'; import { useCurrentFrame, interpolate, Easing } from 'remotion'; import { G, TitleBlock } from '../../_fixtures/Fixtures'; const AMBER = '#b45309'; const PXU = 3; // px per unit:500 量程 → 1500px 长带 const CENTER_Y = 590; // 取景窗中线(屏幕坐标) const TAPE_X = 830; // 刻度带左缘 const TAPE_W = 260; // 读数时间线:静置→慢爬→冲刺(过冲到442)→回摆(415)→落定420→真静止 const valueAt = (frame: number): number => { if (frame <= 12) return 60; if (frame <= 55) { return interpolate(frame, [12, 55], [60, 140]); // 慢爬 ~5.6px/f } if (frame <= 78) { return interpolate(frame, [55, 78], [140, 442], { easing: Easing.inOut(Easing.cubic), // 冲刺段,峰值 ~50px/f }); } if (frame <= 88) { return interpolate(frame, [78, 88], [442, 415], { easing: Easing.out(Easing.cubic), // 刹车回摆:甩过头再拉回 }); } return interpolate(frame, [88, 96], [415, 420], { easing: Easing.out(Easing.cubic), extrapolateRight: 'clamp', }); }; export const TapeScrollFixedPointer: React.FC = () => { const frame = useCurrentFrame(); const v = valueAt(frame); // 刻度带:数值大在上。单位 u 的屏幕 y = CENTER_Y + (v - u) * PXU const yOf = (u: number): number => CENTER_Y + (v - u) * PXU; const ticks: React.ReactNode[] = []; for (let u = 0; u <= 500; u += 10) { const y = yOf(u); if (y < 180 || y > 1010) continue; // 视野外裁剪 const major = u % 50 === 0; ticks.push(
); if (major) { ticks.push(
{u}
); } } return (
{/* 刻度带容器(世界层:整体在动) */}
{ // 内层坐标 = 屏幕 y - 180
{ticks}
}
{/* 固定层:取景窗 + 琥珀三角指针 + 读数(针不动) */}
{/* 同步读数窗 */}
{Math.round(v)}
); };