364 lines
11 KiB
TypeScript
364 lines
11 KiB
TypeScript
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<HTMLDivElement>(null);
|
||
const started = useRef(false);
|
||
const frame = useCurrentFrame();
|
||
const {fps, durationInFrames, width, height} = useVideoConfig();
|
||
const [map, setMap] = useState<any>(null);
|
||
const [labels, setLabels] = useState<
|
||
Record<string, {x: number; y: number; reveal: number}>
|
||
>({});
|
||
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<string, {x: number; y: number; reveal: number}> = {};
|
||
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 (
|
||
<AbsoluteFill style={{backgroundColor: COLORS.bg}}>
|
||
<div
|
||
ref={ref}
|
||
style={{
|
||
width: width * 2,
|
||
height: height * 2,
|
||
position: 'absolute',
|
||
transform: `translate(${plate.x}px, ${plate.y}px) scale(${plate.scale})`,
|
||
transformOrigin: '0 0',
|
||
}}
|
||
/>
|
||
<AbsoluteFill style={{pointerEvents: 'none'}}>
|
||
{ORDER.map((c) =>
|
||
labels[c] ? (
|
||
<CountryLabel
|
||
key={c}
|
||
name={c.toUpperCase()}
|
||
color={COUNTRY[c]}
|
||
reveal={labels[c].reveal}
|
||
x={labels[c].x}
|
||
y={labels[c].y}
|
||
/>
|
||
) : null,
|
||
)}
|
||
</AbsoluteFill>
|
||
</AbsoluteFill>
|
||
);
|
||
};
|