| 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 |