Setting the file. One moment.
River Reveal · Remotion Markup · remotion-dev/skills · Skills Docs
ContentsBack to the top of the page ⋯
remotion-maps/techniques/maptiler/assets/ RiverReveal.tsx
TypeScript · 363 lines · 11 KB
10
interpolate,
11 useCurrentFrame,
12 useVideoConfig,
13 } from 'remotion' ;
14 import {CountryLabel} from './CountryLabel' ;
15 import countryMeta from './sample-data/country-meta.json' ;
16 import flowCoords from './sample-data/yarlung-flow.json' ;
17 import {COLORS, COUNTRY, COUNTRY_DARK, FILL_OPACITY} from './tokens' ;
18
19 // Sample route reveal. Replace the imported sample geometry, names, timing, and visual tokens in the
20 // consuming production. The renderer stays static; approved centre/zoom motion is a CSS plate transform.
21
22 maptilersdk.config.apiKey = process.env. REMOTION_MAPTILER_KEY as string ;
23
24 const line = turf. lineString (flowCoords as [ number , number ][]);
25 const lineKm = turf. length (line);
26
27 const START = {
28 center: [ 89.6 , 27.7 ] as [ number , number ],
29 zoom: 4.75 ,
30 pitch: 0 ,
31 bearing: 0 ,
32 };
33 const END = {
34 center: [ 90.2 , 27.0 ] as [ number , number ],
35 zoom: 5.05 ,
36 pitch: 0 ,
37 bearing: 0 ,
38 };
39 const lerp = ( a : number , b : number , t : number ) => a + (b - a) * t;
40 const clamp01 = ( v : number ) => Math. max ( 0 , Math. min ( 1 , v));
41
42 const ORDER = [ 'china' , 'india' , 'bangladesh' ] as const ;
43 type Country = ( typeof ORDER )[ number ];
44 const META = countryMeta as Record <
45 Country ,
46 { stop : number ; anchor : [ number , number ]; border : [ number , number ][][]}
47 >;
48 const countryPolygons = {
49 type: 'FeatureCollection' as const ,
50 features: ORDER . map (( country ) => ({
51 type: 'Feature' as const ,
52 properties: {country},
53 geometry: {
54 type: 'MultiPolygon' as const ,
55 coordinates: META [country].border. map (( ring ) => [ring]),
56 },
57 })),
58 };
59
60 // Pre-build each country's border as ordered segments with cumulative lengths (for the multi-segment draw).
61 const DRAW = Object. fromEntries (
62 ORDER . map (( c ) => {
63 const segLines = META [c].border. map (( s ) => turf. lineString (s));
64 const segLen = segLines. map (( l ) => turf. length (l));
65 const cum : number [] = [];
66 let acc = 0 ;
67 for ( const L of segLen) {
68 cum. push (acc);
69 acc += L ;
70 }
71 return [c, {segLines, segLen, cum, total: acc}];
72 }),
73 ) as Record <
74 Country ,
75 { segLines : any []; segLen : number []; cum : number []; total : number }
76 >;
77
78 // Reveal the portion of the border between fromKm and toKm as a MultiLineString (no joins across gaps).
79 const sliceBorder = (
80 d : ( typeof DRAW )[ Country ],
81 fromKm : number ,
82 toKm : number ,
83 ) => {
84 const out : number [][][] = [];
85 for ( let i = 0 ; i < d.segLines. length ; i ++ ) {
86 const start = d.cum[i],
87 end = start + d.segLen[i];
88 const a = Math. max (fromKm, start),
89 b = Math. min (toKm, end);
90 if (b - a <= 0.0008 ) continue ;
91 out. push (
92 turf. lineSliceAlong (d.segLines[i], a - start, b - start).geometry
93 .coordinates,
94 );
95 }
96 return {
97 type: 'Feature' as const ,
98 properties: {},
99 geometry: {type: 'MultiLineString' as const , coordinates: out},
100 };
101 };
102 const EMPTY = {
103 type: 'Feature' as const ,
104 properties: {},
105 geometry: {type: 'MultiLineString' as const , coordinates: [] as number [][][]},
106 };
107
108 // --- Timing (seconds). River draws over [RIVER_START, RIVER_END]; each country triggers when the river
109 // reaches it (stop · span), then runs border → fill → label. Beat length is derived from these. ---
110 const RIVER_START = 0.3 ;
111 const RIVER_END = 8.0 ;
112 const BORDER_S = 2.5 ;
113 const FILL_S = 1.0 ;
114 const LABEL_S = 0.7 ;
115 const trigger = ( c : Country ) =>
116 RIVER_START + META [c].stop * ( RIVER_END - RIVER_START );
117
118 export const RiverReveal : React . FC = () => {
119 const ref = useRef < HTMLDivElement >( null );
120 const started = useRef ( false );
121 const frame = useCurrentFrame ();
122 const { fps , durationInFrames , width , height } = useVideoConfig ();
123 const [ map , setMap ] = useState < any >( null );
124 const [ labels , setLabels ] = useState <
125 Record < string, {x: number; y: number; reveal: number} >
126 > ({});
127 const [ plate , setPlate ] = useState ({x: 0 , y: 0 , scale: 1 });
128 const [ handle ] = useState (() => delayRender ( 'maptiler init A' ));
129
130 useEffect (() => {
131 if ( ! ref.current || started.current) return ;
132 started.current = true ;
133 const m = new maptilersdk. Map ({
134 container: ref.current,
135 style: maptilersdk.MapStyle. BASIC ,
136 center: END .center,
137 zoom: Math. max ( START .zoom, END .zoom),
138 pitch: END .pitch,
139 bearing: END .bearing,
140 interactive: false ,
141 attributionControl: true ,
142 navigationControl: false ,
143 geolocateControl: false ,
144 maptilerLogo: true ,
145 fadeDuration: 0 ,
146 canvasContextAttributes: {preserveDrawingBuffer: true },
147 } as any );
148
149 m. on ( 'load' , () => {
150 // Strip basemap labels (symbols) AND the inner admin-1 borders ('Other border[ dash]',
151 // admin_level 3–10) to cut basemap clutter. Keep country + disputed borders.
152 for ( const l of m. getStyle ().layers as any [])
153 if (l.type === 'symbol' || /other border/ i . test (l.id))
154 m. removeLayer (l.id);
155
156 m. addSource ( 'countries' , {type: 'geojson' , data: countryPolygons});
157 for ( const c of ORDER ) {
158 m. addLayer ({
159 id: `fill-${ c }` ,
160 type: 'fill' ,
161 source: 'countries' ,
162 filter: [ '==' , [ 'get' , 'country' ], c],
163 paint: { 'fill-color' : COUNTRY [c], 'fill-opacity' : 0 },
164 });
165 }
166 // Per country: just the border that draws on, settled to a darker shade of the country colour
167 // (the electricity now lives on the river, not the borders).
168 for ( const c of ORDER ) {
169 m. addSource ( `trail-${ c }` , {type: 'geojson' , data: EMPTY });
170 m. addLayer ({
171 id: `trail-${ c }` ,
172 type: 'line' ,
173 source: `trail-${ c }` ,
174 layout: { 'line-cap' : 'round' , 'line-join' : 'round' },
175 paint: {
176 'line-color' : COUNTRY_DARK [c],
177 'line-width' : 2 ,
178 'line-opacity' : 0.95 ,
179 },
180 });
181 }
182
183 const seed = turf. lineSliceAlong (
184 line,
185 0 ,
186 Math. max ( 0.001 , lineKm * 0.001 ),
187 );
188 m. addSource ( 'river' , {type: 'geojson' , data: seed});
189 m. addSource ( 'river-head' , {type: 'geojson' , data: seed});
190 // Electric water: soft blue glow → icy core → white-hot leading head with its own glow.
191 m. addLayer ({
192 id: 'river-glow' ,
193 type: 'line' ,
194 source: 'river' ,
195 layout: { 'line-cap' : 'round' , 'line-join' : 'round' },
196 paint: {
197 'line-color' : '#49C6FF' ,
198 'line-width' : 11 ,
199 'line-opacity' : 0.32 ,
200 'line-blur' : 6 ,
201 },
202 });
203 m. addLayer ({
204 id: 'river-line' ,
205 type: 'line' ,
206 source: 'river' ,
207 layout: { 'line-cap' : 'round' , 'line-join' : 'round' },
208 paint: { 'line-color' : COLORS .river, 'line-width' : 3 },
209 });
210 m. addLayer ({
211 id: 'river-headglow' ,
212 type: 'line' ,
213 source: 'river-head' ,
214 layout: { 'line-cap' : 'round' , 'line-join' : 'round' },
215 paint: {
216 'line-color' : COLORS .riverHeadGlow,
217 'line-width' : 16 ,
218 'line-opacity' : 0 ,
219 'line-blur' : 9 ,
220 },
221 });
222 m. addLayer ({
223 id: 'river-head' ,
224 type: 'line' ,
225 source: 'river-head' ,
226 layout: { 'line-cap' : 'round' , 'line-join' : 'round' },
227 paint: {
228 'line-color' : COLORS .riverHead,
229 'line-width' : 4.5 ,
230 'line-opacity' : 0 ,
231 },
232 });
233
234 m. once ( 'idle' , () => {
235 setMap (m);
236 continueRender (handle);
237 });
238 });
239 }, [handle]);
240
241 useEffect (() => {
242 if ( ! map) return ;
243 const h = delayRender ( `frame A ${ frame }` );
244 const t = frame / fps; // seconds
245 const tt = interpolate (frame, [ 0 , durationInFrames - 1 ], [ 0 , 1 ], {
246 extrapolateLeft: 'clamp' ,
247 extrapolateRight: 'clamp' ,
248 });
249
250 // River draw
251 const reveal = interpolate (t, [ RIVER_START , RIVER_END ], [ 0 , 1 ], {
252 extrapolateLeft: 'clamp' ,
253 extrapolateRight: 'clamp' ,
254 easing: Easing. bezier ( 0.645 , 0.045 , 0.355 , 1 ),
255 });
256 const riverDrawnKm = lineKm * reveal;
257 (map. getSource ( 'river' ) as any )?. setData (
258 turf. lineSliceAlong (line, 0 , Math. max ( 0.001 , riverDrawnKm)),
259 );
260 // Electric draw-head leading the river: white-hot core + glow, fading out once the river completes.
261 const riverHeadKm = lineKm * 0.03 ;
262 (map. getSource ( 'river-head' ) as any )?. setData (
263 turf. lineSliceAlong (
264 line,
265 Math. max ( 0 , riverDrawnKm - riverHeadKm),
266 Math. max ( 0.001 , riverDrawnKm),
267 ),
268 );
269 let riverHeadFade = 0 ;
270 if (reveal > 0.002 && reveal < 0.999 ) riverHeadFade = 1 ;
271 else if (reveal >= 0.999 )
272 riverHeadFade = 1 - clamp01 ((t - RIVER_END ) / 0.5 );
273 map. setPaintProperty (
274 'river-headglow' ,
275 'line-opacity' ,
276 0.85 * riverHeadFade,
277 );
278 map. setPaintProperty ( 'river-head' , 'line-opacity' , riverHeadFade);
279
280 const camera = {
281 center: [
282 lerp ( START .center[ 0 ], END .center[ 0 ], tt),
283 lerp ( START .center[ 1 ], END .center[ 1 ], tt),
284 ] as [ number , number ],
285 zoom: lerp ( START .zoom, END .zoom, tt),
286 };
287 const cameraPoint = map. project (camera.center);
288 const plateScale = 2 ** (camera.zoom - Math. max ( START .zoom, END .zoom));
289 const plateX = width / 2 - cameraPoint.x * plateScale;
290 const plateY = height / 2 - cameraPoint.y * plateScale;
291
292 const pos : Record < string , { x : number ; y : number ; reveal : number }> = {};
293 for ( const c of ORDER ) {
294 const d = DRAW [c];
295 const lt = t - trigger (c); // local seconds since this country triggered
296
297 // 1) border draws on (constant duration), settling to a darker shade — no electric head
298 const bp = interpolate ( clamp01 (lt / BORDER_S ), [ 0 , 1 ], [ 0 , 1 ], {
299 easing: Easing. bezier ( 0.645 , 0.045 , 0.355 , 1 ),
300 });
301 (map. getSource ( `trail-${ c }` ) as any )?. setData (
302 bp <= 0 ? EMPTY : sliceBorder (d, 0 , d.total * bp),
303 );
304
305 // 2) fill blooms in (overshoot then settle) after the border completes
306 const fp = clamp01 ((lt - BORDER_S ) / FILL_S );
307 const fo = interpolate (
308 fp,
309 [ 0 , 0.6 , 1 ],
310 [ 0 , FILL_OPACITY * 1.25 , FILL_OPACITY ],
311 {
312 extrapolateLeft: 'clamp' ,
313 extrapolateRight: 'clamp' ,
314 easing: Easing. bezier ( 0.3333333333333333 , 1 , 0.6666666666666666 , 1 ),
315 },
316 );
317 map. setPaintProperty ( `fill-${ c }` , 'fill-opacity' , fp <= 0 ? 0 : fo);
318
319 // 3) label rises in after the fill
320 const lp = clamp01 ((lt - BORDER_S - FILL_S ) / LABEL_S );
321 const p = map. project ( META [c].anchor);
322 pos[c] = {
323 x: p.x * plateScale + plateX,
324 y: p.y * plateScale + plateY,
325 reveal: lp,
326 };
327 }
328 setLabels (pos);
329
330 setPlate ({x: plateX, y: plateY, scale: plateScale});
331 map. once ( 'idle' , () => continueRender (h));
332 map. triggerRepaint ();
333 }, [map, frame, fps, durationInFrames, width, height]);
334
335 return (
336 < AbsoluteFill style ={ {backgroundColor: COLORS .bg} } >
337 < div
338 ref ={ ref }
339 style ={ {
340 width: width * 2 ,
341 height: height * 2 ,
342 position: 'absolute' ,
343 transform: `translate(${ plate . x }px, ${ plate . y }px) scale(${ plate . scale })` ,
344 transformOrigin: '0 0' ,
345 } }
346 />
347 < AbsoluteFill style ={ {pointerEvents: 'none' } } >
348 { ORDER . map (( c ) =>
349 labels[c] ? (
350 < CountryLabel
351 key ={ c }
352 name ={ c. toUpperCase () }
353 color ={ COUNTRY [c] }
354 reveal ={ labels[c].reveal }
355 x ={ labels[c].x }
356 y ={ labels[c].y }
357 />
358 ) : null ,
359 ) }
360 </ AbsoluteFill >
361 </ AbsoluteFill >
362 );
363 };