import * as maptilersdk from '@maptiler/sdk'; import * as turf from '@turf/turf'; import React, {useEffect, useRef, useState} from 'react'; import '@maptiler/sdk/dist/maptiler-sdk.css'; import { AbsoluteFill, Easing, continueRender, delayRender, interpolate, useCurrentFrame, useVideoConfig, } from 'remotion'; import {CountryLabel} from './CountryLabel'; import countryMeta from './sample-data/country-meta.json'; import flowCoords from './sample-data/yarlung-flow.json'; import {COLORS, COUNTRY, COUNTRY_DARK, FILL_OPACITY} from './tokens'; // Sample route reveal. Replace the imported sample geometry, names, timing, and visual tokens in the // consuming production. The renderer stays static; approved centre/zoom motion is a CSS plate transform. maptilersdk.config.apiKey = process.env.REMOTION_MAPTILER_KEY as string; const line = turf.lineString(flowCoords as [number, number][]); const lineKm = turf.length(line); const START = { center: [89.6, 27.7] as [number, number], zoom: 4.75, pitch: 0, bearing: 0, }; const END = { center: [90.2, 27.0] as [number, number], zoom: 5.05, pitch: 0, bearing: 0, }; const lerp = (a: number, b: number, t: number) => a + (b - a) * t; const clamp01 = (v: number) => Math.max(0, Math.min(1, v)); const ORDER = ['china', 'india', 'bangladesh'] as const; type Country = (typeof ORDER)[number]; const META = countryMeta as Record< Country, {stop: number; anchor: [number, number]; border: [number, number][][]} >; const countryPolygons = { type: 'FeatureCollection' as const, features: ORDER.map((country) => ({ type: 'Feature' as const, properties: {country}, geometry: { type: 'MultiPolygon' as const, coordinates: META[country].border.map((ring) => [ring]), }, })), }; // Pre-build each country's border as ordered segments with cumulative lengths (for the multi-segment draw). const DRAW = Object.fromEntries( ORDER.map((c) => { const segLines = META[c].border.map((s) => turf.lineString(s)); const segLen = segLines.map((l) => turf.length(l)); const cum: number[] = []; let acc = 0; for (const L of segLen) { cum.push(acc); acc += L; } return [c, {segLines, segLen, cum, total: acc}]; }), ) as Record< Country, {segLines: any[]; segLen: number[]; cum: number[]; total: number} >; // Reveal the portion of the border between fromKm and toKm as a MultiLineString (no joins across gaps). const sliceBorder = ( d: (typeof DRAW)[Country], fromKm: number, toKm: number, ) => { const out: number[][][] = []; for (let i = 0; i < d.segLines.length; i++) { const start = d.cum[i], end = start + d.segLen[i]; const a = Math.max(fromKm, start), b = Math.min(toKm, end); if (b - a <= 0.0008) continue; out.push( turf.lineSliceAlong(d.segLines[i], a - start, b - start).geometry .coordinates, ); } return { type: 'Feature' as const, properties: {}, geometry: {type: 'MultiLineString' as const, coordinates: out}, }; }; const EMPTY = { type: 'Feature' as const, properties: {}, geometry: {type: 'MultiLineString' as const, coordinates: [] as number[][][]}, }; // --- Timing (seconds). River draws over [RIVER_START, RIVER_END]; each country triggers when the river // reaches it (stop · span), then runs border → fill → label. Beat length is derived from these. --- const RIVER_START = 0.3; const RIVER_END = 8.0; const BORDER_S = 2.5; const FILL_S = 1.0; const LABEL_S = 0.7; const trigger = (c: Country) => RIVER_START + META[c].stop * (RIVER_END - RIVER_START); export const RiverReveal: React.FC = () => { const ref = useRef(null); const started = useRef(false); const frame = useCurrentFrame(); const {fps, durationInFrames, width, height} = useVideoConfig(); const [map, setMap] = useState(null); const [labels, setLabels] = useState< Record >({}); const [plate, setPlate] = useState({x: 0, y: 0, scale: 1}); const [handle] = useState(() => delayRender('maptiler init A')); useEffect(() => { if (!ref.current || started.current) return; started.current = true; const m = new maptilersdk.Map({ container: ref.current, style: maptilersdk.MapStyle.BASIC, center: END.center, zoom: Math.max(START.zoom, END.zoom), pitch: END.pitch, bearing: END.bearing, interactive: false, attributionControl: true, navigationControl: false, geolocateControl: false, maptilerLogo: true, fadeDuration: 0, canvasContextAttributes: {preserveDrawingBuffer: true}, } as any); m.on('load', () => { // Strip basemap labels (symbols) AND the inner admin-1 borders ('Other border[ dash]', // admin_level 3–10) to cut basemap clutter. Keep country + disputed borders. for (const l of m.getStyle().layers as any[]) if (l.type === 'symbol' || /other border/i.test(l.id)) m.removeLayer(l.id); m.addSource('countries', {type: 'geojson', data: countryPolygons}); for (const c of ORDER) { m.addLayer({ id: `fill-${c}`, type: 'fill', source: 'countries', filter: ['==', ['get', 'country'], c], paint: {'fill-color': COUNTRY[c], 'fill-opacity': 0}, }); } // Per country: just the border that draws on, settled to a darker shade of the country colour // (the electricity now lives on the river, not the borders). for (const c of ORDER) { m.addSource(`trail-${c}`, {type: 'geojson', data: EMPTY}); m.addLayer({ id: `trail-${c}`, type: 'line', source: `trail-${c}`, layout: {'line-cap': 'round', 'line-join': 'round'}, paint: { 'line-color': COUNTRY_DARK[c], 'line-width': 2, 'line-opacity': 0.95, }, }); } const seed = turf.lineSliceAlong( line, 0, Math.max(0.001, lineKm * 0.001), ); m.addSource('river', {type: 'geojson', data: seed}); m.addSource('river-head', {type: 'geojson', data: seed}); // Electric water: soft blue glow → icy core → white-hot leading head with its own glow. m.addLayer({ id: 'river-glow', type: 'line', source: 'river', layout: {'line-cap': 'round', 'line-join': 'round'}, paint: { 'line-color': '#49C6FF', 'line-width': 11, 'line-opacity': 0.32, 'line-blur': 6, }, }); m.addLayer({ id: 'river-line', type: 'line', source: 'river', layout: {'line-cap': 'round', 'line-join': 'round'}, paint: {'line-color': COLORS.river, 'line-width': 3}, }); m.addLayer({ id: 'river-headglow', type: 'line', source: 'river-head', layout: {'line-cap': 'round', 'line-join': 'round'}, paint: { 'line-color': COLORS.riverHeadGlow, 'line-width': 16, 'line-opacity': 0, 'line-blur': 9, }, }); m.addLayer({ id: 'river-head', type: 'line', source: 'river-head', layout: {'line-cap': 'round', 'line-join': 'round'}, paint: { 'line-color': COLORS.riverHead, 'line-width': 4.5, 'line-opacity': 0, }, }); m.once('idle', () => { setMap(m); continueRender(handle); }); }); }, [handle]); useEffect(() => { if (!map) return; const h = delayRender(`frame A ${frame}`); const t = frame / fps; // seconds const tt = interpolate(frame, [0, durationInFrames - 1], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', }); // River draw const reveal = interpolate(t, [RIVER_START, RIVER_END], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.bezier(0.645, 0.045, 0.355, 1), }); const riverDrawnKm = lineKm * reveal; (map.getSource('river') as any)?.setData( turf.lineSliceAlong(line, 0, Math.max(0.001, riverDrawnKm)), ); // Electric draw-head leading the river: white-hot core + glow, fading out once the river completes. const riverHeadKm = lineKm * 0.03; (map.getSource('river-head') as any)?.setData( turf.lineSliceAlong( line, Math.max(0, riverDrawnKm - riverHeadKm), Math.max(0.001, riverDrawnKm), ), ); let riverHeadFade = 0; if (reveal > 0.002 && reveal < 0.999) riverHeadFade = 1; else if (reveal >= 0.999) riverHeadFade = 1 - clamp01((t - RIVER_END) / 0.5); map.setPaintProperty( 'river-headglow', 'line-opacity', 0.85 * riverHeadFade, ); map.setPaintProperty('river-head', 'line-opacity', riverHeadFade); const camera = { center: [ lerp(START.center[0], END.center[0], tt), lerp(START.center[1], END.center[1], tt), ] as [number, number], zoom: lerp(START.zoom, END.zoom, tt), }; const cameraPoint = map.project(camera.center); const plateScale = 2 ** (camera.zoom - Math.max(START.zoom, END.zoom)); const plateX = width / 2 - cameraPoint.x * plateScale; const plateY = height / 2 - cameraPoint.y * plateScale; const pos: Record = {}; for (const c of ORDER) { const d = DRAW[c]; const lt = t - trigger(c); // local seconds since this country triggered // 1) border draws on (constant duration), settling to a darker shade — no electric head const bp = interpolate(clamp01(lt / BORDER_S), [0, 1], [0, 1], { easing: Easing.bezier(0.645, 0.045, 0.355, 1), }); (map.getSource(`trail-${c}`) as any)?.setData( bp <= 0 ? EMPTY : sliceBorder(d, 0, d.total * bp), ); // 2) fill blooms in (overshoot then settle) after the border completes const fp = clamp01((lt - BORDER_S) / FILL_S); const fo = interpolate( fp, [0, 0.6, 1], [0, FILL_OPACITY * 1.25, FILL_OPACITY], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.bezier(0.3333333333333333, 1, 0.6666666666666666, 1), }, ); map.setPaintProperty(`fill-${c}`, 'fill-opacity', fp <= 0 ? 0 : fo); // 3) label rises in after the fill const lp = clamp01((lt - BORDER_S - FILL_S) / LABEL_S); const p = map.project(META[c].anchor); pos[c] = { x: p.x * plateScale + plateX, y: p.y * plateScale + plateY, reveal: lp, }; } setLabels(pos); setPlate({x: plateX, y: plateY, scale: plateScale}); map.once('idle', () => continueRender(h)); map.triggerRepaint(); }, [map, frame, fps, durationInFrames, width, height]); return (
{ORDER.map((c) => labels[c] ? ( ) : null, )} ); };