返回 html-video
DataRollup.tsx
根目录 / templates / frame-data-rollup / source / DataRollup.tsx
1 // frame-data-rollup — the first NATIVE Remotion template (RFC-08 Phase 2).
2 //
3 // This is what the HTML bridge can't do: bars GROW from real data via spring()
4 // and numbers ROLL from 0 to their target via interpolate(), every value driven
5 // by the actual data array fed through inputProps — not a static chart screenshot.
6 // That visible "the numbers are alive" difference is the whole point of letting a
7 // user opt a data frame into Remotion enhancement.
8 //
9 // Offline-deterministic by construction: system font stack only, no external
10 // fonts / assets / network (the same all-black-frame trap the bridge's
11 // neutralizeBlockingResources() guards against — here we just never introduce it).
12 import React from 'react';
13 import {
14 AbsoluteFill,
15 interpolate,
16 spring,
17 useCurrentFrame,
18 useVideoConfig,
19 } from 'remotion';
20
21 export interface DataRollupItem {
22 label: string;
23 value: number;
24 }
25
26 export interface DataRollupProps {
27 data: {
28 title?: string;
29 /** Optional unit suffix shown after each rolled number, e.g. "K", "%". */
30 unit?: string;
31 items: DataRollupItem[];
32 };
33 /** Accent color for the bars + rolled numbers. */
34 accent?: string;
35 background?: string;
36 foreground?: string;
37 }
38
39 const SYSTEM_SANS =
40 '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif';
41 const SYSTEM_MONO =
42 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace';
43
44 const DEFAULTS: Required<Omit<DataRollupProps, 'data'>> = {
45 accent: '#FF5A2C',
46 background: '#0E0E10',
47 foreground: '#F5F5F2',
48 };
49
50 /** Format a rolled number: thousands separators, no decimals while counting. */
51 function fmt(n: number): string {
52 return Math.round(n).toLocaleString('en-US');
53 }
54
55 export const DataRollup: React.FC<DataRollupProps> = (props) => {
56 const frame = useCurrentFrame();
57 const { fps, width, height } = useVideoConfig();
58
59 const data = props.data ?? { items: [] };
60 const items = Array.isArray(data.items) ? data.items : [];
61 const accent = props.accent ?? DEFAULTS.accent;
62 const background = props.background ?? DEFAULTS.background;
63 const foreground = props.foreground ?? DEFAULTS.foreground;
64 const unit = data.unit ?? '';
65
66 // Bar-height scaling. Normally each bar is linear against the largest value.
67 // But when one value dwarfs the rest (e.g. 61,059 stars next to 142 systems),
68 // linear scaling crushes the small bars to a 2px sliver. So if the spread is
69 // extreme (max ≥ 50× the smallest positive value), switch to a log scale so
70 // every bar stays legible. The rolled NUMBERS always show the true value —
71 // only the bar HEIGHT is remapped.
72 const values = items.map((it) => (Number.isFinite(it.value) ? it.value : 0));
73 const maxValue = Math.max(1, ...values);
74 const positives = values.filter((v) => v > 0);
75 const minPositive = positives.length > 0 ? Math.min(...positives) : maxValue;
76 const useLog = minPositive > 0 && maxValue / minPositive >= 50;
77 // Map a raw value → 0..1 fraction of the tallest bar.
78 const heightFrac = (value: number): number => {
79 if (value <= 0) return 0;
80 if (!useLog) return value / maxValue;
81 // Log scale: smallest positive bar reads ~25% tall, the tallest 100%.
82 const logMin = Math.log(minPositive);
83 const logMax = Math.log(maxValue);
84 const t = (Math.log(value) - logMin) / (logMax - logMin || 1);
85 return 0.25 + t * 0.75;
86 };
87
88 // Title fades + slides up over the first ~0.5s.
89 const titleProgress = spring({ frame, fps, config: { damping: 200 } });
90 const titleY = interpolate(titleProgress, [0, 1], [24, 0]);
91
92 // Layout maths (all in px, scaled off the real canvas so 9:16 / 1:1 work too).
93 const padX = Math.round(width * 0.08);
94 const chartTop = Math.round(height * (data.title ? 0.26 : 0.16));
95 const chartBottom = Math.round(height * 0.82);
96 const chartHeight = chartBottom - chartTop;
97 const slotW = items.length > 0 ? (width - padX * 2) / items.length : 0;
98 const barW = Math.min(slotW * 0.52, Math.round(width * 0.12));
99
100 return (
101 <AbsoluteFill style={{ backgroundColor: background, fontFamily: SYSTEM_SANS }}>
102 {data.title ? (
103 <div
104 style={{
105 position: 'absolute',
106 top: Math.round(height * 0.1),
107 left: padX,
108 right: padX,
109 color: foreground,
110 fontSize: Math.round(height * 0.058),
111 fontWeight: 800,
112 letterSpacing: '-0.02em',
113 opacity: titleProgress,
114 transform: `translateY(${titleY}px)`,
115 }}
116 >
117 {data.title}
118 </div>
119 ) : null}
120
121 {items.map((it, i) => {
122 const value = Number.isFinite(it.value) ? it.value : 0;
123
124 // Each bar starts growing on a staggered delay so they cascade in.
125 const delay = i * Math.round(fps * 0.12);
126 const grow = spring({
127 frame: frame - delay,
128 fps,
129 config: { damping: 14, mass: 0.7, stiffness: 90 },
130 });
131 const barHeight = Math.max(2, heightFrac(value) * chartHeight * grow);
132
133 // The number rolls from 0 → value tracking the same growth curve, so the
134 // figure and the bar finish together.
135 const rolled = value * grow;
136
137 const cx = padX + slotW * i + slotW / 2;
138 const labelColor = foreground;
139
140 return (
141 <React.Fragment key={`${it.label}-${i}`}>
142 {/* rolled number, sits just above the bar top */}
143 <div
144 style={{
145 position: 'absolute',
146 left: cx - slotW / 2,
147 width: slotW,
148 top: chartBottom - barHeight - Math.round(height * 0.07),
149 textAlign: 'center',
150 color: accent,
151 fontFamily: SYSTEM_MONO,
152 fontSize: Math.round(height * 0.04),
153 fontWeight: 700,
154 opacity: grow,
155 }}
156 >
157 {fmt(rolled)}
158 {unit ? ` ${unit}` : ''}
159 </div>
160
161 {/* the bar */}
162 <div
163 style={{
164 position: 'absolute',
165 left: cx - barW / 2,
166 width: barW,
167 bottom: height - chartBottom,
168 height: barHeight,
169 backgroundColor: accent,
170 borderRadius: `${Math.round(barW * 0.12)}px ${Math.round(barW * 0.12)}px 0 0`,
171 }}
172 />
173
174 {/* label under the baseline */}
175 <div
176 style={{
177 position: 'absolute',
178 left: cx - slotW / 2,
179 width: slotW,
180 top: chartBottom + Math.round(height * 0.025),
181 textAlign: 'center',
182 color: labelColor,
183 fontSize: Math.round(height * 0.028),
184 fontWeight: 500,
185 opacity: interpolate(grow, [0, 0.4], [0, 0.85], { extrapolateRight: 'clamp' }),
186 }}
187 >
188 {it.label}
189 </div>
190 </React.Fragment>
191 );
192 })}
193
194 {/* baseline rule */}
195 <div
196 style={{
197 position: 'absolute',
198 left: padX,
199 right: padX,
200 top: chartBottom,
201 height: 2,
202 backgroundColor: foreground,
203 opacity: 0.18,
204 }}
205 />
206 </AbsoluteFill>
207 );
208 };
209
209 lines Plain Text