reszta poprawek
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
# MapTiler maps in Remotion
|
||||
|
||||
MapTiler is a good solution for map animations where geographics features should be drawn as annotations on top of the map: Country borders, rivers, labels for POIs.
|
||||
|
||||
## MapTiler SDK (`@maptiler/sdk`)
|
||||
|
||||
Draw the basemap plus MapTiler Planet vector layers and custom GeoJSON into a WebGL canvas. Default styled-vector starting point: `MapStyle.BASIC`; satellite is an equally valid choice.
|
||||
|
||||
## Remotion
|
||||
|
||||
Imperatively update `setData`/`setPaintProperty`.
|
||||
|
||||
Use `jumpTo` only for a static shot, or a fixed map plate for any pan/zoom.
|
||||
Gate with [`delayRender`](https://www.remotion.dev/docs/delay-render.md) until `map.once('idle')`.
|
||||
|
||||
Use `preserveDrawingBuffer:true`.
|
||||
|
||||
Render labels as positioned [`<Interactive.Div>`](https://www.remotion.dev/docs/interactive.md) elements.
|
||||
|
||||
Env `REMOTION_MAPTILER_KEY` (unrestricted). Init the map once (ref guard); update imperatively per frame.
|
||||
|
||||
When constructing MapLibre/MapTiler layer objects, omit optional properties that are absent.
|
||||
In particular, use `...(layer.filter ? {filter: layer.filter} : {})`; do not pass `filter: undefined`. An undefined filter can suppress the layer while separately created halo or border layers continue rendering, producing missing country fills and dark marker halos with no coloured cores.
|
||||
|
||||
Drive animation from `useCurrentFrame()` rather than CSS transitions or browser timers.
|
||||
|
||||
## Choose the source for each map element
|
||||
|
||||
Do not begin by manufacturing GeoJSON. First check whether MapTiler Planet already exposes the element as filtered vector data.
|
||||
|
||||
### MapTiler vector
|
||||
|
||||
Use it when the feature exists in a provider `source-layer`, its attributes support an exact filter, and provider geometry is editorially acceptable.
|
||||
|
||||
### Hybrid
|
||||
|
||||
Use it when ordinary geographic context can come from MapTiler while the claim depends on custom
|
||||
evidence.
|
||||
|
||||
Animate each layer according to its source and meaning.
|
||||
|
||||
MapTiler vector features remain split across tiles. Do not use them for a semantic start-to-end line draw; extract, verify, order, and bake that element to GeoJSON first. Read **`references/map-data-sources.md`** and reuse **`assets/MapTilerVectorElement.ts`** for provider-layer setup and per-frame paint updates.
|
||||
|
||||
## Motion stability
|
||||
|
||||
**Do not call `map.jumpTo()` on every Remotion frame when the camera moves.** In headless capture it can make both MapTiler hillshade **and satellite imagery** shimmer/jitter, even when the source tiles load correctly. This is renderer resampling, not a data, network, or label problem.
|
||||
|
||||
For the implementation, read **`references/render-stability.md`** before building or debugging any moving map. It contains the fixed-map-plate recipe, diagnostics, and render checks.
|
||||
|
||||
- Use the live MapTiler camera only for a static shot.
|
||||
- Keep pitch and bearing constant for a fixed plate. This technique does not implement a genuine changing 3D camera.
|
||||
- Verify the moving preview and a short rendered MP4 before approving a beat. If any basemap detail wavers, switch to the fixed-plate pattern; do not try to solve it with tile retries or camera easing.
|
||||
|
||||
## Drawing rivers
|
||||
|
||||
Use `turf.lineSliceAlong(line, 0, lineKm*reveal)` to draw rivers.
|
||||
|
||||
## Source selection
|
||||
|
||||
Use MapTiler vector layers for suitable provider features and custom GeoJSON for story-specific or ordered geometry. If the beat needs country-entry triggers or a progressive line draw, run `scripts/prep-geo.mjs` to bake `country-meta.json`, `borders.geojson`, and the ordered line. Details → `references/map-data-sources.md` and `references/map-geo-prep.md`.
|
||||
|
||||
## Keep it minmal
|
||||
|
||||
Strip clutter on `load`: remove `symbol` layers (place labels) and `/other border/i` (admin-1 inner borders); hide the logo via CSS. Keep country + disputed borders.
|
||||
|
||||
## Files
|
||||
|
||||
Use as reference:
|
||||
|
||||
- `assets/RiverReveal.tsx` — the main component.
|
||||
- `assets/MapTilerVectorElement.ts` — filtered MapTiler Planet elements.
|
||||
- `assets/CountryLabel.tsx` — reusable example label.
|
||||
- `assets/tokens.ts` — example palette and durations.
|
||||
- `assets/example-Root.tsx` — minimal composition scaffold.
|
||||
- `assets/sample-data/` — example route and generated country metadata.
|
||||
- `scripts/prep-geo.mjs` — geo pipeline.
|
||||
- `references/map-explainer-architecture.md` — timing model and implementation.
|
||||
- `references/map-data-sources.md` — provider vector versus custom GeoJSON selection.
|
||||
- `references/map-geo-prep.md` — basemap stripping and geo preparation.
|
||||
- `references/render-stability.md` — camera motion and stable headless renders.
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import {Easing, interpolate} from 'remotion';
|
||||
|
||||
// Reusable country label. Supply typography and final values from the consuming project; the CSS custom
|
||||
// properties below provide neutral fallbacks. Positioned by its centre (x,y in screen px).
|
||||
export const CountryLabel: React.FC<{
|
||||
name: string;
|
||||
color: string;
|
||||
reveal: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}> = ({name, color, reveal, x, y}) => {
|
||||
const e = interpolate(reveal, [0, 1], [0, 1], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
easing: Easing.bezier(0.3333333333333333, 1, 0.6666666666666666, 1),
|
||||
});
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: x,
|
||||
top: y,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
pointerEvents: 'none',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
opacity: e,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
transform: `translateY(${(1 - e) * 16}px)`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{/* accent rule — draws out from the centre in the country's colour */}
|
||||
<div
|
||||
style={{
|
||||
width: 64,
|
||||
height: 3,
|
||||
borderRadius: 2,
|
||||
background: color,
|
||||
transform: `scaleX(${e})`,
|
||||
boxShadow: `0 0 10px ${color}`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--map-label-font, system-ui, sans-serif)',
|
||||
fontWeight: 'var(--map-label-weight, 600)',
|
||||
fontSize: 'var(--map-label-size, 34px)',
|
||||
letterSpacing: 'var(--map-label-tracking, 0.16em)',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--map-label-color, #ffffff)',
|
||||
textShadow: 'var(--map-label-shadow, 0 2px 18px rgba(0,0,0,0.9))',
|
||||
marginTop: 13,
|
||||
paddingLeft: 'var(--map-label-tracking, 0.16em)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
// Surface an existing MapTiler Planet feature without maintaining duplicate GeoJSON.
|
||||
// Paint animation is deterministic; geometry slicing is not. For a source-to-end line draw,
|
||||
// bake the selected feature to ordered GeoJSON and use RiverReveal.tsx instead.
|
||||
|
||||
type VectorLayerType = 'fill' | 'line' | 'circle' | 'symbol';
|
||||
|
||||
export type MapTilerVectorElement = {
|
||||
id: string;
|
||||
sourceLayer: string;
|
||||
type: VectorLayerType;
|
||||
filter?: unknown[];
|
||||
minzoom?: number;
|
||||
maxzoom?: number;
|
||||
layout?: Record<string, unknown>;
|
||||
paint: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const SOURCE_ID = 'maptiler-planet';
|
||||
|
||||
export const addMapTilerVectorElement = (
|
||||
map: any,
|
||||
apiKey: string,
|
||||
element: MapTilerVectorElement,
|
||||
beforeId?: string,
|
||||
) => {
|
||||
if (!map.getSource(SOURCE_ID)) {
|
||||
map.addSource(SOURCE_ID, {
|
||||
type: 'vector',
|
||||
url: `https://api.maptiler.com/tiles/v3/tiles.json?key=${apiKey}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (map.getLayer(element.id)) return;
|
||||
|
||||
map.addLayer(
|
||||
{
|
||||
id: element.id,
|
||||
type: element.type,
|
||||
source: SOURCE_ID,
|
||||
'source-layer': element.sourceLayer,
|
||||
...(element.filter ? {filter: element.filter} : {}),
|
||||
...(element.minzoom === undefined ? {} : {minzoom: element.minzoom}),
|
||||
...(element.maxzoom === undefined ? {} : {maxzoom: element.maxzoom}),
|
||||
...(element.layout ? {layout: element.layout} : {}),
|
||||
paint: element.paint,
|
||||
},
|
||||
beforeId,
|
||||
);
|
||||
};
|
||||
|
||||
export const setVectorElementPaint = (
|
||||
map: any,
|
||||
layerId: string,
|
||||
paint: Record<string, unknown>,
|
||||
) => {
|
||||
for (const [property, value] of Object.entries(paint)) {
|
||||
map.setPaintProperty(layerId, property, value);
|
||||
}
|
||||
};
|
||||
|
||||
// Example:
|
||||
//
|
||||
// addMapTilerVectorElement(map, process.env.REMOTION_MAPTILER_KEY!, {
|
||||
// id: "story-river",
|
||||
// sourceLayer: "waterway",
|
||||
// type: "line",
|
||||
// filter: [
|
||||
// "all",
|
||||
// ["==", ["get", "class"], "river"],
|
||||
// ["==", ["coalesce", ["get", "name_en"], ["get", "name"]], "Yarlung Tsangpo"],
|
||||
// ],
|
||||
// layout: {"line-cap": "round", "line-join": "round"},
|
||||
// paint: {"line-color": "#E8F7FF", "line-width": 3, "line-opacity": 0},
|
||||
// });
|
||||
//
|
||||
// Per Remotion frame:
|
||||
// setVectorElementPaint(map, "story-river", {
|
||||
// "line-opacity": reveal,
|
||||
// "line-width": 2 + reveal * 2,
|
||||
// });
|
||||
+363
@@ -0,0 +1,363 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
// Minimal Remotion scaffold for the map explainer. In a Remotion project (`bunx create-video@latest`,
|
||||
// blank template), this is `src/Root.tsx`; `src/index.ts` is just `registerRoot(RemotionRoot)`.
|
||||
//
|
||||
// The Composition sets DURATION (the beat) and dimensions. The component reads durationInFrames / width /
|
||||
// height from useVideoConfig(). The beat must be long enough for the last country's full sequence after
|
||||
// the river reaches it. See references/map-explainer-architecture.md §2.
|
||||
// Render with: bunx remotion render src/index.ts MapExplainer out.mp4 --gl=angle --concurrency=1 --timeout=120000
|
||||
|
||||
import React from 'react';
|
||||
import {Composition} from 'remotion';
|
||||
import {RiverReveal} from './RiverReveal'; // → src/components/RiverReveal.tsx in your project
|
||||
|
||||
export const RemotionRoot: React.FC = () => (
|
||||
<Composition
|
||||
id="MapExplainer"
|
||||
component={RiverReveal}
|
||||
durationInFrames={12 * 30} // 12 s @ 30 fps — raise if a later country needs more room after the river arrives
|
||||
fps={30}
|
||||
width={1920}
|
||||
height={1080}
|
||||
/>
|
||||
);
|
||||
+7535
File diff suppressed because it is too large
Load Diff
+605
@@ -0,0 +1,605 @@
|
||||
[
|
||||
[84.144, 29.565],
|
||||
[84.171, 29.577],
|
||||
[84.22500000000001, 29.559],
|
||||
[84.246, 29.541],
|
||||
[84.297, 29.544],
|
||||
[84.321, 29.532],
|
||||
[84.411, 29.544],
|
||||
[84.426, 29.529],
|
||||
[84.45, 29.535],
|
||||
[84.468, 29.523],
|
||||
[84.492, 29.523],
|
||||
[84.531, 29.490000000000002],
|
||||
[84.507, 29.466],
|
||||
[84.501, 29.445],
|
||||
[84.513, 29.385],
|
||||
[84.495, 29.379],
|
||||
[84.483, 29.361],
|
||||
[84.486, 29.34],
|
||||
[84.519, 29.316],
|
||||
[84.51, 29.292],
|
||||
[84.522, 29.286],
|
||||
[84.525, 29.265],
|
||||
[84.57600000000001, 29.253],
|
||||
[84.58800000000001, 29.259],
|
||||
[84.60300000000001, 29.247],
|
||||
[84.615, 29.265],
|
||||
[84.63, 29.268],
|
||||
[84.624, 29.283],
|
||||
[84.651, 29.286],
|
||||
[84.669, 29.259],
|
||||
[84.732, 29.25],
|
||||
[84.741, 29.235],
|
||||
[84.771, 29.25],
|
||||
[84.777, 29.241],
|
||||
[84.843, 29.223],
|
||||
[84.864, 29.232],
|
||||
[84.879, 29.202],
|
||||
[84.9, 29.199],
|
||||
[84.909, 29.181],
|
||||
[84.933, 29.184],
|
||||
[84.96900000000001, 29.163],
|
||||
[85.023, 29.187],
|
||||
[85.071, 29.259],
|
||||
[85.122, 29.274],
|
||||
[85.167, 29.322],
|
||||
[85.185, 29.310000000000002],
|
||||
[85.26, 29.319],
|
||||
[85.284, 29.301000000000002],
|
||||
[85.34400000000001, 29.289],
|
||||
[85.35600000000001, 29.265],
|
||||
[85.413, 29.262],
|
||||
[85.431, 29.271],
|
||||
[85.449, 29.247],
|
||||
[85.464, 29.244],
|
||||
[85.521, 29.25],
|
||||
[85.551, 29.265],
|
||||
[85.611, 29.235],
|
||||
[85.617, 29.193],
|
||||
[85.662, 29.172],
|
||||
[85.686, 29.172],
|
||||
[85.69500000000001, 29.184],
|
||||
[85.71600000000001, 29.172],
|
||||
[85.72800000000001, 29.187],
|
||||
[85.764, 29.181],
|
||||
[85.761, 29.193],
|
||||
[85.776, 29.202],
|
||||
[85.788, 29.196],
|
||||
[85.797, 29.22],
|
||||
[85.809, 29.211000000000002],
|
||||
[85.848, 29.211000000000002],
|
||||
[85.863, 29.193],
|
||||
[85.881, 29.193],
|
||||
[85.917, 29.172],
|
||||
[85.968, 29.175],
|
||||
[86.004, 29.163],
|
||||
[86.09400000000001, 29.175],
|
||||
[86.10000000000001, 29.166],
|
||||
[86.133, 29.172],
|
||||
[86.148, 29.166],
|
||||
[86.193, 29.178],
|
||||
[86.223, 29.202],
|
||||
[86.268, 29.193],
|
||||
[86.295, 29.208000000000002],
|
||||
[86.34, 29.205000000000002],
|
||||
[86.412, 29.217000000000002],
|
||||
[86.427, 29.211000000000002],
|
||||
[86.43, 29.199],
|
||||
[86.47800000000001, 29.196],
|
||||
[86.496, 29.22],
|
||||
[86.514, 29.211000000000002],
|
||||
[86.52, 29.235],
|
||||
[86.529, 29.238],
|
||||
[86.535, 29.217000000000002],
|
||||
[86.559, 29.196],
|
||||
[86.607, 29.202],
|
||||
[86.616, 29.184],
|
||||
[86.658, 29.193],
|
||||
[86.67, 29.205000000000002],
|
||||
[86.682, 29.199],
|
||||
[86.7, 29.208000000000002],
|
||||
[86.736, 29.205000000000002],
|
||||
[86.778, 29.187],
|
||||
[86.805, 29.196],
|
||||
[86.82300000000001, 29.184],
|
||||
[86.84100000000001, 29.19],
|
||||
[86.901, 29.181],
|
||||
[86.949, 29.163],
|
||||
[87.027, 29.172],
|
||||
[87.039, 29.148],
|
||||
[87.063, 29.136],
|
||||
[87.084, 29.142],
|
||||
[87.12, 29.175],
|
||||
[87.162, 29.142],
|
||||
[87.21000000000001, 29.142],
|
||||
[87.237, 29.157],
|
||||
[87.249, 29.136],
|
||||
[87.297, 29.127],
|
||||
[87.303, 29.115000000000002],
|
||||
[87.366, 29.121000000000002],
|
||||
[87.459, 29.097],
|
||||
[87.486, 29.115000000000002],
|
||||
[87.546, 29.109],
|
||||
[87.56700000000001, 29.121000000000002],
|
||||
[87.59400000000001, 29.121000000000002],
|
||||
[87.60600000000001, 29.133],
|
||||
[87.654, 29.139],
|
||||
[87.666, 29.13],
|
||||
[87.684, 29.133],
|
||||
[87.687, 29.157],
|
||||
[87.669, 29.181],
|
||||
[87.675, 29.205000000000002],
|
||||
[87.705, 29.217000000000002],
|
||||
[87.714, 29.235],
|
||||
[87.732, 29.238],
|
||||
[87.732, 29.253],
|
||||
[87.756, 29.283],
|
||||
[87.753, 29.301000000000002],
|
||||
[87.789, 29.304000000000002],
|
||||
[87.795, 29.328],
|
||||
[87.81, 29.337],
|
||||
[87.849, 29.331],
|
||||
[87.87, 29.349],
|
||||
[87.906, 29.343],
|
||||
[87.924, 29.349],
|
||||
[87.97800000000001, 29.388],
|
||||
[87.996, 29.376],
|
||||
[88.014, 29.379],
|
||||
[88.017, 29.37],
|
||||
[88.125, 29.367],
|
||||
[88.137, 29.337],
|
||||
[88.161, 29.331],
|
||||
[88.176, 29.337],
|
||||
[88.188, 29.325],
|
||||
[88.2, 29.328],
|
||||
[88.221, 29.367],
|
||||
[88.245, 29.367],
|
||||
[88.287, 29.349],
|
||||
[88.299, 29.358],
|
||||
[88.365, 29.316],
|
||||
[88.41, 29.322],
|
||||
[88.422, 29.316],
|
||||
[88.443, 29.325],
|
||||
[88.458, 29.349],
|
||||
[88.503, 29.361],
|
||||
[88.536, 29.334],
|
||||
[88.563, 29.328],
|
||||
[88.596, 29.349],
|
||||
[88.629, 29.346],
|
||||
[88.641, 29.334],
|
||||
[88.671, 29.34],
|
||||
[88.71000000000001, 29.331],
|
||||
[88.72800000000001, 29.343],
|
||||
[88.791, 29.337],
|
||||
[88.818, 29.352],
|
||||
[88.86, 29.319],
|
||||
[88.884, 29.334],
|
||||
[88.908, 29.316],
|
||||
[88.923, 29.331],
|
||||
[88.962, 29.328],
|
||||
[88.971, 29.349],
|
||||
[89.007, 29.361],
|
||||
[89.019, 29.352],
|
||||
[89.034, 29.358],
|
||||
[89.08500000000001, 29.322],
|
||||
[89.136, 29.316],
|
||||
[89.16, 29.343],
|
||||
[89.181, 29.337],
|
||||
[89.211, 29.349],
|
||||
[89.235, 29.379],
|
||||
[89.253, 29.373],
|
||||
[89.265, 29.385],
|
||||
[89.289, 29.385],
|
||||
[89.307, 29.376],
|
||||
[89.349, 29.379],
|
||||
[89.397, 29.358],
|
||||
[89.45100000000001, 29.355],
|
||||
[89.46000000000001, 29.334],
|
||||
[89.58, 29.358],
|
||||
[89.595, 29.346],
|
||||
[89.61, 29.355],
|
||||
[89.631, 29.346],
|
||||
[89.661, 29.349],
|
||||
[89.679, 29.364],
|
||||
[89.757, 29.295],
|
||||
[89.787, 29.292],
|
||||
[89.808, 29.310000000000002],
|
||||
[89.85300000000001, 29.322],
|
||||
[89.931, 29.319],
|
||||
[89.985, 29.343],
|
||||
[90.015, 29.337],
|
||||
[90.072, 29.352],
|
||||
[90.156, 29.355],
|
||||
[90.168, 29.346],
|
||||
[90.20100000000001, 29.349],
|
||||
[90.22500000000001, 29.331],
|
||||
[90.273, 29.337],
|
||||
[90.276, 29.328],
|
||||
[90.342, 29.313],
|
||||
[90.378, 29.295],
|
||||
[90.435, 29.241],
|
||||
[90.477, 29.256],
|
||||
[90.492, 29.25],
|
||||
[90.51, 29.256],
|
||||
[90.522, 29.247],
|
||||
[90.54, 29.262],
|
||||
[90.621, 29.277],
|
||||
[90.642, 29.301000000000002],
|
||||
[90.663, 29.298000000000002],
|
||||
[90.681, 29.313],
|
||||
[90.684, 29.328],
|
||||
[90.705, 29.34],
|
||||
[90.729, 29.328],
|
||||
[90.747, 29.337],
|
||||
[90.765, 29.328],
|
||||
[90.768, 29.310000000000002],
|
||||
[90.756, 29.295],
|
||||
[90.771, 29.277],
|
||||
[90.855, 29.283],
|
||||
[90.897, 29.319],
|
||||
[90.933, 29.310000000000002],
|
||||
[90.95100000000001, 29.295],
|
||||
[90.993, 29.322],
|
||||
[91.035, 29.295],
|
||||
[91.065, 29.316],
|
||||
[91.116, 29.325],
|
||||
[91.131, 29.313],
|
||||
[91.173, 29.325],
|
||||
[91.194, 29.286],
|
||||
[91.233, 29.274],
|
||||
[91.296, 29.289],
|
||||
[91.308, 29.277],
|
||||
[91.34700000000001, 29.28],
|
||||
[91.374, 29.292],
|
||||
[91.395, 29.28],
|
||||
[91.449, 29.286],
|
||||
[91.479, 29.268],
|
||||
[91.512, 29.28],
|
||||
[91.533, 29.271],
|
||||
[91.554, 29.289],
|
||||
[91.587, 29.292],
|
||||
[91.602, 29.271],
|
||||
[91.62, 29.265],
|
||||
[91.659, 29.271],
|
||||
[91.674, 29.259],
|
||||
[91.71000000000001, 29.268],
|
||||
[91.74, 29.259],
|
||||
[91.782, 29.274],
|
||||
[91.839, 29.268],
|
||||
[91.869, 29.283],
|
||||
[91.98, 29.262],
|
||||
[92.007, 29.232],
|
||||
[92.07000000000001, 29.289],
|
||||
[92.115, 29.283],
|
||||
[92.154, 29.289],
|
||||
[92.196, 29.244],
|
||||
[92.22, 29.253],
|
||||
[92.235, 29.244],
|
||||
[92.277, 29.244],
|
||||
[92.295, 29.229],
|
||||
[92.319, 29.226],
|
||||
[92.376, 29.226],
|
||||
[92.397, 29.241],
|
||||
[92.406, 29.262],
|
||||
[92.433, 29.25],
|
||||
[92.46600000000001, 29.253],
|
||||
[92.529, 29.172],
|
||||
[92.529, 29.133],
|
||||
[92.553, 29.148],
|
||||
[92.598, 29.145],
|
||||
[92.589, 29.121000000000002],
|
||||
[92.61, 29.097],
|
||||
[92.619, 29.115000000000002],
|
||||
[92.658, 29.121000000000002],
|
||||
[92.685, 29.139],
|
||||
[92.697, 29.127],
|
||||
[92.676, 29.112000000000002],
|
||||
[92.679, 29.103],
|
||||
[92.709, 29.109],
|
||||
[92.739, 29.067],
|
||||
[92.772, 29.091],
|
||||
[92.775, 29.073],
|
||||
[92.796, 29.082],
|
||||
[92.808, 29.061],
|
||||
[92.82000000000001, 29.067],
|
||||
[92.82300000000001, 29.082],
|
||||
[92.82900000000001, 29.076],
|
||||
[92.85300000000001, 29.082],
|
||||
[92.85600000000001, 29.073],
|
||||
[92.88, 29.073],
|
||||
[92.904, 29.058],
|
||||
[92.934, 29.067],
|
||||
[92.952, 29.049],
|
||||
[92.973, 29.061],
|
||||
[92.988, 29.043],
|
||||
[92.997, 29.049],
|
||||
[93.066, 29.043],
|
||||
[93.081, 29.094],
|
||||
[93.117, 29.115000000000002],
|
||||
[93.123, 29.136],
|
||||
[93.138, 29.139],
|
||||
[93.15, 29.127],
|
||||
[93.147, 29.094],
|
||||
[93.165, 29.064],
|
||||
[93.165, 29.043],
|
||||
[93.153, 29.037],
|
||||
[93.153, 29.025000000000002],
|
||||
[93.162, 29.016000000000002],
|
||||
[93.21300000000001, 29.025000000000002],
|
||||
[93.22800000000001, 29.019000000000002],
|
||||
[93.23100000000001, 29.001],
|
||||
[93.261, 28.992],
|
||||
[93.285, 29.001],
|
||||
[93.312, 28.998],
|
||||
[93.315, 29.016000000000002],
|
||||
[93.348, 29.046],
|
||||
[93.375, 29.052],
|
||||
[93.393, 29.094],
|
||||
[93.429, 29.109],
|
||||
[93.447, 29.106],
|
||||
[93.438, 29.13],
|
||||
[93.48, 29.175],
|
||||
[93.492, 29.163],
|
||||
[93.54, 29.178],
|
||||
[93.57000000000001, 29.166],
|
||||
[93.627, 29.172],
|
||||
[93.63, 29.151],
|
||||
[93.645, 29.145],
|
||||
[93.675, 29.151],
|
||||
[93.681, 29.163],
|
||||
[93.699, 29.16],
|
||||
[93.702, 29.142],
|
||||
[93.735, 29.127],
|
||||
[93.75, 29.136],
|
||||
[93.75, 29.148],
|
||||
[93.78, 29.154],
|
||||
[93.789, 29.124000000000002],
|
||||
[93.825, 29.118000000000002],
|
||||
[93.834, 29.124000000000002],
|
||||
[93.831, 29.142],
|
||||
[93.894, 29.13],
|
||||
[93.912, 29.145],
|
||||
[93.903, 29.166],
|
||||
[93.909, 29.178],
|
||||
[93.94500000000001, 29.184],
|
||||
[93.95400000000001, 29.196],
|
||||
[94.017, 29.193],
|
||||
[94.035, 29.205000000000002],
|
||||
[94.176, 29.196],
|
||||
[94.251, 29.262],
|
||||
[94.305, 29.271],
|
||||
[94.302, 29.292],
|
||||
[94.33200000000001, 29.316],
|
||||
[94.35000000000001, 29.316],
|
||||
[94.389, 29.337],
|
||||
[94.401, 29.361],
|
||||
[94.419, 29.37],
|
||||
[94.434, 29.406000000000002],
|
||||
[94.542, 29.448],
|
||||
[94.581, 29.481],
|
||||
[94.656, 29.490000000000002],
|
||||
[94.70700000000001, 29.463],
|
||||
[94.818, 29.493000000000002],
|
||||
[94.875, 29.541],
|
||||
[94.923, 29.61],
|
||||
[94.926, 29.622],
|
||||
[94.887, 29.634],
|
||||
[94.917, 29.67],
|
||||
[94.893, 29.697],
|
||||
[94.905, 29.715],
|
||||
[94.956, 29.757],
|
||||
[95.124, 29.763],
|
||||
[95.124, 29.781000000000002],
|
||||
[95.09100000000001, 29.814],
|
||||
[95.11200000000001, 29.868000000000002],
|
||||
[95.13, 29.88],
|
||||
[95.175, 29.895],
|
||||
[95.196, 29.892],
|
||||
[95.223, 29.868000000000002],
|
||||
[95.286, 29.868000000000002],
|
||||
[95.304, 29.856],
|
||||
[95.307, 29.838],
|
||||
[95.283, 29.82],
|
||||
[95.289, 29.811],
|
||||
[95.385, 29.772000000000002],
|
||||
[95.379, 29.718],
|
||||
[95.397, 29.688000000000002],
|
||||
[95.388, 29.595],
|
||||
[95.403, 29.562],
|
||||
[95.427, 29.538],
|
||||
[95.43900000000001, 29.478],
|
||||
[95.43, 29.451],
|
||||
[95.313, 29.331],
|
||||
[95.256, 29.289],
|
||||
[95.202, 29.28],
|
||||
[95.04, 29.175],
|
||||
[95.001, 29.169],
|
||||
[95.004, 29.139],
|
||||
[94.908, 29.052],
|
||||
[94.902, 29.016000000000002],
|
||||
[94.869, 28.998],
|
||||
[94.839, 28.956],
|
||||
[94.788, 28.935000000000002],
|
||||
[94.773, 28.854],
|
||||
[94.794, 28.824],
|
||||
[94.914, 28.821],
|
||||
[94.923, 28.812],
|
||||
[94.911, 28.794],
|
||||
[94.92, 28.752],
|
||||
[94.977, 28.713],
|
||||
[94.983, 28.674],
|
||||
[94.998, 28.683],
|
||||
[95.031, 28.617],
|
||||
[95.09700000000001, 28.539],
|
||||
[95.10300000000001, 28.506],
|
||||
[95.09100000000001, 28.455000000000002],
|
||||
[95.09700000000001, 28.419],
|
||||
[95.013, 28.326],
|
||||
[94.992, 28.287],
|
||||
[94.992, 28.242],
|
||||
[95.019, 28.215],
|
||||
[95.031, 28.173000000000002],
|
||||
[95.06700000000001, 28.155],
|
||||
[95.145, 28.149],
|
||||
[95.211, 28.179000000000002],
|
||||
[95.277, 28.155],
|
||||
[95.292, 28.116],
|
||||
[95.316, 28.089000000000002],
|
||||
[95.355, 28.071],
|
||||
[95.379, 28.071],
|
||||
[95.385, 28.059],
|
||||
[95.382, 27.951],
|
||||
[95.4, 27.936],
|
||||
[95.412, 27.882],
|
||||
[95.379, 27.843],
|
||||
[95.349, 27.837],
|
||||
[95.325, 27.810000000000002],
|
||||
[95.325, 27.78],
|
||||
[95.298, 27.762],
|
||||
[95.289, 27.717000000000002],
|
||||
[95.253, 27.657],
|
||||
[95.211, 27.666],
|
||||
[95.151, 27.624000000000002],
|
||||
[95.139, 27.63],
|
||||
[95.10300000000001, 27.609],
|
||||
[94.992, 27.6],
|
||||
[94.944, 27.57],
|
||||
[94.917, 27.573],
|
||||
[94.869, 27.504],
|
||||
[94.803, 27.495],
|
||||
[94.803, 27.48],
|
||||
[94.785, 27.471],
|
||||
[94.794, 27.456],
|
||||
[94.767, 27.435000000000002],
|
||||
[94.767, 27.414],
|
||||
[94.73100000000001, 27.402],
|
||||
[94.71900000000001, 27.372],
|
||||
[94.69800000000001, 27.36],
|
||||
[94.69800000000001, 27.333000000000002],
|
||||
[94.683, 27.324],
|
||||
[94.677, 27.294],
|
||||
[94.635, 27.291],
|
||||
[94.629, 27.273],
|
||||
[94.602, 27.255],
|
||||
[94.596, 27.231],
|
||||
[94.584, 27.228],
|
||||
[94.587, 27.177],
|
||||
[94.596, 27.162],
|
||||
[94.584, 27.147000000000002],
|
||||
[94.566, 27.150000000000002],
|
||||
[94.554, 27.102],
|
||||
[94.518, 27.105],
|
||||
[94.449, 27.060000000000002],
|
||||
[94.44, 27.018],
|
||||
[94.407, 26.985],
|
||||
[94.35900000000001, 26.97],
|
||||
[94.287, 26.919],
|
||||
[94.197, 26.925],
|
||||
[94.164, 26.898],
|
||||
[94.146, 26.865000000000002],
|
||||
[94.125, 26.856],
|
||||
[94.113, 26.868000000000002],
|
||||
[94.134, 26.907],
|
||||
[94.113, 26.913],
|
||||
[94.077, 26.886],
|
||||
[93.927, 26.832],
|
||||
[93.864, 26.766000000000002],
|
||||
[93.753, 26.733],
|
||||
[93.657, 26.715],
|
||||
[93.618, 26.724],
|
||||
[93.56700000000001, 26.751],
|
||||
[93.441, 26.769000000000002],
|
||||
[93.411, 26.76],
|
||||
[93.369, 26.718],
|
||||
[93.288, 26.742],
|
||||
[93.249, 26.724],
|
||||
[93.183, 26.673000000000002],
|
||||
[93.12, 26.646],
|
||||
[93.087, 26.655],
|
||||
[93.018, 26.646],
|
||||
[92.898, 26.655],
|
||||
[92.883, 26.652],
|
||||
[92.874, 26.625],
|
||||
[92.83500000000001, 26.607],
|
||||
[92.733, 26.613],
|
||||
[92.676, 26.601],
|
||||
[92.658, 26.592000000000002],
|
||||
[92.616, 26.523],
|
||||
[92.595, 26.517],
|
||||
[92.508, 26.517],
|
||||
[92.43, 26.535],
|
||||
[92.391, 26.532],
|
||||
[92.244, 26.472],
|
||||
[92.229, 26.442],
|
||||
[92.202, 26.454],
|
||||
[92.172, 26.442],
|
||||
[92.124, 26.403000000000002],
|
||||
[92.06700000000001, 26.373],
|
||||
[92.07000000000001, 26.325],
|
||||
[92.058, 26.304000000000002],
|
||||
[91.971, 26.271],
|
||||
[91.932, 26.271],
|
||||
[91.899, 26.241],
|
||||
[91.878, 26.235],
|
||||
[91.854, 26.241],
|
||||
[91.665, 26.166],
|
||||
[91.608, 26.172],
|
||||
[91.542, 26.145],
|
||||
[91.497, 26.139],
|
||||
[91.377, 26.172],
|
||||
[91.287, 26.166],
|
||||
[91.197, 26.202],
|
||||
[91.134, 26.214000000000002],
|
||||
[91.062, 26.187],
|
||||
[90.96300000000001, 26.175],
|
||||
[90.888, 26.136],
|
||||
[90.849, 26.13],
|
||||
[90.807, 26.154],
|
||||
[90.741, 26.163],
|
||||
[90.681, 26.19],
|
||||
[90.58200000000001, 26.205000000000002],
|
||||
[90.507, 26.229],
|
||||
[90.474, 26.226],
|
||||
[90.435, 26.178],
|
||||
[90.402, 26.16],
|
||||
[90.345, 26.148],
|
||||
[90.309, 26.124],
|
||||
[90.22500000000001, 26.106],
|
||||
[90.177, 26.076],
|
||||
[89.955, 26.01],
|
||||
[89.904, 25.941],
|
||||
[89.84700000000001, 25.89],
|
||||
[89.81400000000001, 25.815],
|
||||
[89.736, 25.692],
|
||||
[89.724, 25.632],
|
||||
[89.697, 25.572],
|
||||
[89.709, 25.482],
|
||||
[89.697, 25.398],
|
||||
[89.67, 25.323],
|
||||
[89.7, 25.266000000000002],
|
||||
[89.703, 25.242],
|
||||
[89.685, 25.2],
|
||||
[89.661, 25.173000000000002],
|
||||
[89.625, 25.074],
|
||||
[89.613, 24.96],
|
||||
[89.613, 24.936],
|
||||
[89.658, 24.87],
|
||||
[89.673, 24.792],
|
||||
[89.706, 24.75],
|
||||
[89.769, 24.549],
|
||||
[89.748, 24.372],
|
||||
[89.754, 24.282],
|
||||
[89.733, 24.225],
|
||||
[89.742, 24.201],
|
||||
[89.739, 24.123],
|
||||
[89.697, 24.015],
|
||||
[89.7, 23.958000000000002],
|
||||
[89.727, 23.883],
|
||||
[89.787, 23.796],
|
||||
[89.85600000000001, 23.748],
|
||||
[89.919, 23.664],
|
||||
[89.985, 23.643],
|
||||
[90.144, 23.544],
|
||||
[90.249, 23.463]
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
// Example tokens only. Replace every visual value with production-local tokens.
|
||||
export const COLORS = {
|
||||
bg: '#101315',
|
||||
// Electric water — a near-white icy core with a blue glow and a white-hot draw-head (the "electricity"
|
||||
// travels along the river as it draws on). No dark casing.
|
||||
river: '#E8F7FF', // bright icy core
|
||||
riverGlow: 'rgba(73,198,255,0.5)', // electric-blue glow
|
||||
riverHead: '#FFFFFF', // white-hot leading head
|
||||
riverHeadGlow: 'rgba(120,225,255,0.95)',
|
||||
border: '#f5f2ed', // neutral cream country borders/labels over the colored fills
|
||||
cream: '#f5f0eb',
|
||||
} as const;
|
||||
|
||||
// Example progressive fill tokens. Rename these keys and replace values for each production.
|
||||
export const COUNTRY = {
|
||||
china: '#D4A853',
|
||||
india: '#5B8A8A',
|
||||
bangladesh: '#C07B57',
|
||||
} as const;
|
||||
// Darker shade of each country colour — the settled border line (the bright COUNTRY colour is the
|
||||
// glowing draw-head that leads the animation).
|
||||
export const COUNTRY_DARK = {
|
||||
china: '#9A7530',
|
||||
india: '#3C5C5C',
|
||||
bangladesh: '#855239',
|
||||
} as const;
|
||||
export const FILL_OPACITY = 0.5;
|
||||
|
||||
export const VIDEO = {width: 1920, height: 1080, fps: 30} as const;
|
||||
|
||||
// Beat durations (seconds → frames at VIDEO.fps)
|
||||
export const DUR = {
|
||||
mapExplainer: 12 * VIDEO.fps,
|
||||
} as const;
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
# Map element data sources
|
||||
|
||||
Choose the source independently for every story element. A single map can—and often should—mix
|
||||
provider vectors with custom geodata.
|
||||
|
||||
## Decision rule
|
||||
|
||||
Use a MapTiler Planet vector layer when the feature already exists there, its attributes support an
|
||||
editorially precise filter, and provider geometry is acceptable for the claim. Use custom GeoJSON when
|
||||
the feature is absent, proposed, historical, disputed, corrected, privately sourced, or needs ordered
|
||||
geometry for a deterministic draw.
|
||||
|
||||
| Requirement | MapTiler vector layer | Custom GeoJSON |
|
||||
| --------------------------------------------------------------------------- | --------------------- | ------------------------------------------- |
|
||||
| Roads, waterways, water, boundaries, land cover, or other standard context | Prefer | Use only when provider data is insufficient |
|
||||
| Basemap-consistent geometry without a duplicate local dataset | Prefer | No |
|
||||
| Proposed, historical, classified, corrected, or production-specific element | No | Prefer |
|
||||
| Fade, colour, width, radius, blur, or fill-opacity animation | Yes | Yes |
|
||||
| Feature-state highlight when a stable feature ID exists | Yes | Yes |
|
||||
| Deterministic source-to-end line draw or perimeter draw | Bake first | Prefer |
|
||||
| Geometry editing, morphing, clipping, or exact sequencing | No | Prefer |
|
||||
|
||||
Provider alignment is not proof of correctness. Inspect the attributes and geometry against the
|
||||
editorial source before presenting a provider feature as evidence.
|
||||
|
||||
## MapTiler vector mode
|
||||
|
||||
MapTiler Planet is a vector tile source. Add it once, then build story layers with a
|
||||
`source-layer` and an exact attribute filter. Read the current MapTiler Planet schema before choosing
|
||||
layer names or fields.
|
||||
|
||||
Common layer categories include `waterway`, `water`, `transportation`, `boundary`, `landcover`, and
|
||||
`poi`; availability, fields, and zoom ranges vary by schema version.
|
||||
|
||||
```ts
|
||||
import {addMapTilerVectorElement, setVectorElementPaint} from "./MapTilerVectorElement";
|
||||
|
||||
addMapTilerVectorElement(map, process.env.REMOTION_MAPTILER_KEY!, {
|
||||
id: "story-river",
|
||||
sourceLayer: "waterway",
|
||||
type: "line",
|
||||
filter: [
|
||||
"all",
|
||||
["==", ["get", "class"], "river"],
|
||||
["==", ["coalesce", ["get", "name_en"], ["get", "name"]], "Yarlung Tsangpo"],
|
||||
],
|
||||
layout: {"line-cap": "round", "line-join": "round"},
|
||||
paint: {
|
||||
"line-color": "#E8F7FF",
|
||||
"line-width": 3,
|
||||
"line-opacity": 0,
|
||||
},
|
||||
});
|
||||
|
||||
setVectorElementPaint(map, "story-river", {
|
||||
"line-opacity": reveal,
|
||||
"line-width": 2 + reveal * 2,
|
||||
});
|
||||
```
|
||||
|
||||
Animate provider features by changing paint properties from the Remotion frame: opacity, colour,
|
||||
width, blur, fill opacity, circle radius, or symbol opacity. Use feature state only when the source
|
||||
provides stable IDs and the selection remains deterministic across tiles.
|
||||
|
||||
Do not treat a tiled line as one ordered path. Vector tiles split features at tile boundaries, so a
|
||||
source-to-mouth or start-to-end draw has no reliable global order. If that motion carries meaning,
|
||||
extract and verify the complete feature, order it once, save it as GeoJSON, and use the custom mode.
|
||||
|
||||
## Custom geodata mode
|
||||
|
||||
Use the bundled `../assets/RiverReveal.tsx` and `../scripts/prep-geo.mjs` pattern for custom GeoJSON. This mode owns
|
||||
the exact geometry and can slice it by distance, calculate entry triggers, draw complete borders, and
|
||||
produce deterministic sequences.
|
||||
|
||||
Custom mode is mandatory when:
|
||||
|
||||
- the feature is not in the provider dataset;
|
||||
- the story uses a proposed route, planned tunnel, historical boundary, disputed interpretation, or
|
||||
non-public dataset;
|
||||
- provider geometry was editorially corrected;
|
||||
- motion must travel through the geometry in a verified order;
|
||||
- a complete off-screen boundary matters and a viewport query would silently crop it.
|
||||
|
||||
## Hybrid mode
|
||||
|
||||
Use provider vectors for ordinary contextual features and custom GeoJSON for the specific claim. For
|
||||
example: MapTiler waterways and roads as aligned context; a custom proposed tunnel, dam site, disputed
|
||||
boundary, or verified evacuation area as the highlighted evidence.
|
||||
|
||||
Keep provider and custom layers visually distinct when they carry different evidentiary weight. Record
|
||||
the source and effective date of every custom layer in the production notes.
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
# Map Explainer — architecture reference
|
||||
|
||||
Deep detail behind `TECHNIQUE.md`: the timing model, the river reveal + electric head, the per-country
|
||||
sequence, and label projection. The supplied values are examples, not a production style system.
|
||||
The custom-geometry example is `../assets/RiverReveal.tsx` +
|
||||
`../assets/CountryLabel.tsx` +
|
||||
`../assets/tokens.ts`. Provider-vector setup is in
|
||||
`../assets/MapTilerVectorElement.ts`; choose between
|
||||
the two modes with `data-sources.md`.
|
||||
|
||||
## 1. The render harness (per frame)
|
||||
|
||||
Init the MapTiler map once (ref guard). On `load`: strip clutter (see `geo-prep.md`), add sources/layers,
|
||||
wait for `once('idle') → continueRender`. Per frame:
|
||||
|
||||
```
|
||||
delayRender → setData/setPaintProperty → map.once('idle', continueRender) → triggerRepaint
|
||||
```
|
||||
|
||||
`preserveDrawingBuffer:true` so Remotion's screenshot captures the canvas. Render `--gl=angle`.
|
||||
For an animated camera, read `render-stability.md`: the MapTiler renderer remains static and a CSS plate
|
||||
transform supplies the camera choreography.
|
||||
|
||||
## 2. Timing model — time-based; beat length derived from the sequences
|
||||
|
||||
Everything keys off **seconds** (`t = frame / fps`), not reveal-units. The river draws over a window;
|
||||
each country **triggers when the river reaches it** and runs a fixed sequence. The beat is exactly as
|
||||
long as the sequences need.
|
||||
|
||||
```ts
|
||||
const RIVER_START = 0.3, RIVER_END = 8.0; // river draws over this window
|
||||
const BORDER_S = 2.5, FILL_S = 1.0, LABEL_S = 0.7; // per-country sequence (constant durations)
|
||||
const trigger = (c) => RIVER_START + META[c].stop * (RIVER_END - RIVER_START); // river-arrival time
|
||||
// beat length = max over c of (trigger(c) + BORDER_S + FILL_S + LABEL_S) + tail
|
||||
const reveal = interpolate(t, [RIVER_START, RIVER_END], [0,1], { ...clamp, easing: Easing.bezier(0.645, 0.045, 0.355, 1) });
|
||||
```
|
||||
|
||||
**Constant durations matter:** drive the border draw by _time since trigger_, not a slice of the reveal —
|
||||
otherwise complex or long borders flash by in a fraction of a second.
|
||||
|
||||
## 3. Provider-vector animation
|
||||
|
||||
MapTiler Planet elements can be animated directly in place. Filter an exact `source-layer` feature,
|
||||
initialize its paint in the hidden or neutral state, then update paint properties from the Remotion frame.
|
||||
This works for line, fill, circle, and symbol layers without copying provider geometry into the project.
|
||||
|
||||
Do not assume tiled geometry has a global order. Provider features are split at tile boundaries: opacity,
|
||||
colour, width, blur, radius, fill, and feature-state changes are reliable; semantic start-to-end line
|
||||
draws are not. Bake ordered GeoJSON when the direction of the draw carries meaning.
|
||||
|
||||
## 4. Custom line animation — reveal + electric draw-head
|
||||
|
||||
The "electricity" is a **white-hot head** leading the draw — the last few % of the drawn line in its own
|
||||
bright + glow layers, faded out once the river completes.
|
||||
|
||||
```ts
|
||||
const riverDrawnKm = lineKm * reveal;
|
||||
map.getSource("river").setData(turf.lineSliceAlong(line, 0, Math.max(0.001, riverDrawnKm)));
|
||||
const headKm = lineKm * 0.03;
|
||||
map.getSource("river-head").setData(turf.lineSliceAlong(line, Math.max(0, riverDrawnKm - headKm), Math.max(0.001, riverDrawnKm)));
|
||||
let headFade = 0;
|
||||
if (reveal > 0.002 && reveal < 0.999) headFade = 1;
|
||||
else if (reveal >= 0.999) headFade = 1 - clamp01((t - RIVER_END) / 0.5); // fade out at the mouth
|
||||
map.setPaintProperty("river-headglow", "line-opacity", 0.85 * headFade);
|
||||
map.setPaintProperty("river-head", "line-opacity", headFade);
|
||||
```
|
||||
|
||||
Layers, bottom→top: `river-glow` (electric blue `#49C6FF`, w11, op0.32, blur6) → `river-line`
|
||||
(icy core `#E8F7FF`, w3) → `river-headglow` (`rgba(120,225,255,.95)`, w16, blur9) → `river-head`
|
||||
(white `#FFFFFF`, w4.5). **No dark casing** — the bright icy core reads over every fill on its own.
|
||||
|
||||
## 5. Country animation — border draws → fill blooms → label rises
|
||||
|
||||
Triggered by river arrival, each country runs three sequential phases. The border is a **darker shade**
|
||||
of the country colour (the electricity is on the river, not here).
|
||||
|
||||
```ts
|
||||
const lt = t - trigger(c); // local seconds since trigger
|
||||
// 1) complete source border draws on over a constant BORDER_S, multi-segment-safe
|
||||
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}`).setData(sliceBorder(DRAW[c], 0, DRAW[c].total * bp)); // COUNTRY_DARK line
|
||||
// 2) fill blooms in (opacity overshoots, then settles) 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], { ...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);
|
||||
```
|
||||
|
||||
`sliceBorder(d, fromKm, toKm)` reveals a portion of a complete (possibly multi-segment) border as a
|
||||
MultiLineString, slicing each segment by cumulative length — no joins across gaps and no viewport crop:
|
||||
|
||||
```ts
|
||||
const sliceBorder = (d, fromKm, toKm) => {
|
||||
const out = [];
|
||||
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", properties:{}, geometry:{ type:"MultiLineString", coordinates: out } };
|
||||
};
|
||||
```
|
||||
|
||||
Choose fill, border, and river colours in the production's local token file. The bundled token values are
|
||||
examples only; do not carry a source project's palette into another production.
|
||||
|
||||
## 6. Labels — HTML overlay, projected each frame
|
||||
|
||||
Labels are React, not map symbols (full typography control). `CountryLabel` is an example accent-rule,
|
||||
rise-and-fade treatment; select the typeface and final values in the production.
|
||||
Positioned by projecting the anchor to screen pixels **every frame**, stored in state:
|
||||
|
||||
```ts
|
||||
const p = map.project(META[c].anchor); // lngLat → screen px (respects the live camera)
|
||||
pos[c] = { x: p.x, y: p.y, reveal: lp };
|
||||
setLabels(pos); // re-render the overlay; effect deps exclude `labels`
|
||||
```
|
||||
|
||||
`CountryLabel` shows the mechanics: uppercase region name, short accent divider, rise/fade entrance,
|
||||
and `pointerEvents:none`. Select font, weight, size, spacing, contrast, and colour from the production's
|
||||
own type and palette system.
|
||||
|
||||
## 7. Camera — fixed map plate for any movement
|
||||
|
||||
Read `render-stability.md`. Do not use per-frame `map.jumpTo()` for a moving 2D shot; it can shimmer in
|
||||
headless renders even on satellite imagery. Interpolate the intended camera for the CSS plate transform,
|
||||
while keeping the MapTiler renderer static.
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
# Map Explainer — basemap & geo prep
|
||||
|
||||
How the basemap is cleaned and how `../scripts/prep-geo.mjs` bakes the per-country data the component reads.
|
||||
|
||||
## Basemap styling — strip the clutter
|
||||
|
||||
On `load`, remove the basemap's labels and inner admin borders so only your geography reads:
|
||||
|
||||
```ts
|
||||
for (const l of m.getStyle().layers as any[])
|
||||
if (l.type === "symbol" || /other border/i.test(l.id)) m.removeLayer(l.id);
|
||||
```
|
||||
|
||||
- `type === "symbol"` → every place/water/road **label** (the "MapTiler labels"). Gone.
|
||||
- Inner admin-border layer IDs vary by style. Inspect the loaded style, remove state/province/district
|
||||
layers as needed, and retain only the context borders the production requires.
|
||||
- Logo/attribution: `maptilerLogo:false` + `attributionControl:false` aren't always enough — also hide
|
||||
via CSS in the component:
|
||||
```tsx
|
||||
<style>{`.maplibregl-ctrl-bottom-left,.maplibregl-ctrl-bottom-right,.maplibregl-ctrl-attrib,.maptiler-logo{display:none!important}`}</style>
|
||||
```
|
||||
|
||||
## `../scripts/prep-geo.mjs` → outputs
|
||||
|
||||
Reads a routed river GeoJSON + country polygon GeoJSONs; writes:
|
||||
|
||||
- **River line** — simplified for a smooth draw. For a braided river, route one source→mouth path through
|
||||
the network first: greedy endpoint-chaining bounces between parallel channels. → `src/geo/river-flow.json`.
|
||||
- **`public/geo/borders.geojson`** — each country's polygon tagged `{country: name}` (one source,
|
||||
filtered per country for the fills).
|
||||
- **`src/geo/country-meta.json`** — per country `{ stop, anchor, border }`.
|
||||
|
||||
### `stop` — when a country lights up
|
||||
|
||||
Walk the river points; first point inside a country (`turf.booleanPointInPolygon`) = the arc-length
|
||||
fraction where the river **enters** it. Drives the trigger time. The headwaters country = 0.
|
||||
|
||||
### `anchor` — label centre via pole of inaccessibility
|
||||
|
||||
The most-interior point of the country (clipped to a per-country **story bbox** so a big country
|
||||
centres in the relevant region, not its far bulge), then a small operator **nudge**. Pole = grid-sample
|
||||
inside the polygon, keep the point with max distance to the boundary. **Centroids get pulled to edges —
|
||||
don't use them.**
|
||||
|
||||
```js
|
||||
const pole = (poly) => {
|
||||
const bb = turf.bbox(poly), edge = turf.polygonToLine(poly), N = 46;
|
||||
let best = null, bestD = -1;
|
||||
for (let i = 0; i <= N; i++) for (let j = 0; j <= N; j++) {
|
||||
const p = turf.point([bb[0]+(bb[2]-bb[0])*i/N, bb[1]+(bb[3]-bb[1])*j/N]);
|
||||
if (!turf.booleanPointInPolygon(p, poly)) continue;
|
||||
const d = turf.pointToLineDistance(p, edge);
|
||||
if (d > bestD) { bestD = d; best = p.geometry.coordinates; }
|
||||
}
|
||||
return best;
|
||||
};
|
||||
const ANCHOR_BBOX = { china:[82,27,96,32], india:[76,14,99,31], bangladesh:[86,20,93,27] }; // story regions
|
||||
const NUDGE = { china:[0,0.6], india:[-1.0,0], bangladesh:[0,-0.6] }; // operator-directed
|
||||
```
|
||||
|
||||
### `border` — complete source geometry
|
||||
|
||||
Preserve every exterior ring from the named country source. Never clip a country or bilateral border to
|
||||
the framed bbox and never discard an off-screen segment: the geometry may leave the frame naturally.
|
||||
The renderer handles a MultiLineString by cumulative length, so it remains one timed reveal without
|
||||
inventing joins across gaps.
|
||||
|
||||
## Tuning the geo prep for a new scenario
|
||||
|
||||
| Want | Knob |
|
||||
| --------------------------------- | -------------------------------------------------------------------- |
|
||||
| Which countries | the country list in `prep-geo.mjs` (+ supply their polygon GeoJSONs) |
|
||||
| Label centred in the right region | `ANCHOR_BBOX[country]` (the story bbox) |
|
||||
| Nudge a label | `NUDGE[country]` (lng, lat offset) |
|
||||
| What border is drawn | the complete named source geometry; never the visible extent |
|
||||
| When each lights up | derived from `stop` — depends on the river geometry |
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
# Moving Map Render Stability
|
||||
|
||||
Read this reference before building a moving 2D MapTiler scene or diagnosing a wavering Remotion render.
|
||||
|
||||
## Symptom and cause
|
||||
|
||||
If basemap detail shimmers or jitters during a pan/zoom, the likely cause is per-frame `map.jumpTo()`.
|
||||
Headless MapTiler capture can resample both vector hillshade and satellite imagery differently frame to
|
||||
frame. Tile retries, easing changes, and label changes do not solve that renderer effect.
|
||||
|
||||
## Required pattern: fixed map plate
|
||||
|
||||
For any 2D pan/zoom:
|
||||
|
||||
1. Render the MapTiler canvas once at the largest required zoom in an oversized container. Size it from the camera route and keep each dimension below the browser's reliable WebGL render-buffer limit (commonly 4096 px). Do **not** blindly use 3×: a 1920×1080 composition becomes 5760 px wide and Chromium may silently downsample it, causing visible pixelation during the CSS zoom.
|
||||
2. Keep the map's camera static.
|
||||
3. For each frame, calculate the approved target centre/zoom, then move the canvas with CSS `translate` + `scale`.
|
||||
4. Apply the same transform to every projected HTML overlay.
|
||||
5. Continue to animate GeoJSON data and paint properties imperatively; only the renderer camera is frozen.
|
||||
|
||||
Keep pitch and bearing constant. Use a 3D engine such as Cesium for genuine changing pitch/bearing or a terrain flythrough.
|
||||
|
||||
```ts
|
||||
const baseZoom = Math.max(start.zoom, end.zoom);
|
||||
const map = new maptilersdk.Map({
|
||||
container,
|
||||
style,
|
||||
center: end.center,
|
||||
zoom: baseZoom,
|
||||
pitch: end.pitch ?? 0,
|
||||
bearing: end.bearing ?? 0,
|
||||
interactive: false,
|
||||
fadeDuration: 0,
|
||||
canvasContextAttributes: {preserveDrawingBuffer: true},
|
||||
});
|
||||
|
||||
// Per Remotion frame. `camera` is the approved centre/zoom interpolation.
|
||||
const projected = map.project(camera.center);
|
||||
const scale = 2 ** (camera.zoom - baseZoom);
|
||||
const plate = {
|
||||
transform: `translate(${width / 2 - projected.x * scale}px, ${height / 2 - projected.y * scale}px) scale(${scale})`,
|
||||
transformOrigin: "0 0",
|
||||
};
|
||||
|
||||
// Convert label projection with exactly the same plate transform.
|
||||
const labelX = labelPoint.x * scale + width / 2 - projected.x * scale;
|
||||
const labelY = labelPoint.y * scale + height / 2 - projected.y * scale;
|
||||
```
|
||||
|
||||
### Plate sizing and sharpness
|
||||
|
||||
- Render at the maximum zoom reached by **any** camera waypoint, including intermediate or hold cameras. The CSS scale should never exceed `1`; otherwise the plate is being enlarged.
|
||||
- Centre the frozen map on the midpoint of the camera route's geographic extent, not automatically on the final camera. This minimizes required overscan.
|
||||
- Keep the largest canvas dimension at or below 4096 px unless the actual render environment has been tested with a larger `MAX_RENDERBUFFER_SIZE`.
|
||||
- For 1920×1080, a 3840×2160 plate is a safe default. For 1080×1920, use approximately 2700×3840 when the route needs extra horizontal pan room.
|
||||
- If the route cannot fit within that plate at the required zoom, split the shot into two fixed plates with a deliberate editorial transition. Do not trade sharpness for one enormous canvas.
|
||||
- Distinguish failure modes: repeating shimmer means the live renderer is moving; steadily soft tiles during a CSS push means the fixed plate is underspecified, internally downsampled, or being scaled above `1`.
|
||||
|
||||
## Verification
|
||||
|
||||
- Render a short MP4, not only a Studio preview.
|
||||
- Inspect static terrain texture and satellite detail while the camera moves.
|
||||
- If any underlying map detail wavers, use the fixed map plate. Do not approve it as a minor preview artefact.
|
||||
- Render WebGL with `--gl=angle`, `preserveDrawingBuffer:true`, and conservative concurrency (`1`) while validating.
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
// Geo prep for the map-explainer. Bakes the data the component reads:
|
||||
// out/river-flow.json -> the river draw-on line (→ copy to your Remotion project's src/geo/)
|
||||
// out/country-meta.json -> per-country { stop, anchor, border } (→ src/geo/)
|
||||
// out/borders.geojson -> country polygons, each tagged {country} (→ public/geo/ — loaded via staticFile)
|
||||
//
|
||||
// RUN: node prep-geo.mjs (needs YOUR geodata — see CONFIG; the source polygons are too large to ship,
|
||||
// so the skill ships the OUTPUTS in assets/sample-data/ instead.)
|
||||
//
|
||||
// RIVER INPUT MUST BE ONE CLEAN LINESTRING, source → mouth (features[0].geometry is a LineString). If your
|
||||
// OSM river comes as many ways / braided channels, ROUTE it into a single line FIRST — a graph
|
||||
// shortest-path from source node to mouth node. Do NOT greedily chain by nearest endpoint: it bounces
|
||||
// between parallel channels. (That routing step is a prerequisite, not part of this script.)
|
||||
|
||||
import {readFileSync, writeFileSync, mkdirSync} from 'fs';
|
||||
import {dirname, resolve} from 'path';
|
||||
import {fileURLToPath} from 'url';
|
||||
|
||||
if (process.argv.includes('--help')) {
|
||||
console.log(
|
||||
'Configure COUNTRIES, RIVER, BORDER, label bounds, and output paths in this script, then run: bun scripts/prep-geo.mjs',
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const turf = await import('@turf/turf');
|
||||
|
||||
const __dir = dirname(fileURLToPath(import.meta.url));
|
||||
const root = resolve(__dir, '..');
|
||||
const geo = resolve(root, '../geodata'); // ADAPT: where your input GeoJSON lives
|
||||
const read = (p) => JSON.parse(readFileSync(p, 'utf8'));
|
||||
|
||||
// ===== CONFIG — edit for your river + countries =====
|
||||
const COUNTRIES = ['china', 'india', 'bangladesh']; // ORDERED headwaters → mouth; first = source (stop 0)
|
||||
const RIVER = resolve(geo, 'focus-rivers/yarlung-brahmaputra-full-osm.geojson'); // a single clean source→mouth LineString
|
||||
const BORDER = (name) => resolve(geo, `project-borders/${name}.geojson`); // one polygon file per country, named <country>.geojson
|
||||
const FRAME_BBOX = [76, 14, 104, 33.5]; // [W,S,E,N] visible extent — fallback for label anchoring only
|
||||
const ANCHOR_BBOX = {
|
||||
china: [82, 27, 96, 32],
|
||||
india: [76, 14, 99, 31],
|
||||
bangladesh: [86, 20, 93, 27],
|
||||
}; // [W,S,E,N] per-country label "story region"
|
||||
const NUDGE = {china: [0, 0.6], india: [-1.0, 0], bangladesh: [0, -0.6]}; // [lng,lat] label nudge
|
||||
const RIVER_SIMPLIFY_TOL = 0.006; // degrees — smooths the draw-on (bigger = simpler)
|
||||
const OUT_RIVER = resolve(root, 'out/river-flow.json');
|
||||
const OUT_META = resolve(root, 'out/country-meta.json');
|
||||
const OUT_BORDERS = resolve(root, 'out/borders.geojson');
|
||||
// =====================================================
|
||||
|
||||
const havKm = (a, b) => {
|
||||
const R = 6371,
|
||||
r = Math.PI / 180;
|
||||
const dLat = (b[1] - a[1]) * r,
|
||||
dLng = (b[0] - a[0]) * r;
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(a[1] * r) * Math.cos(b[1] * r) * Math.sin(dLng / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.sqrt(h));
|
||||
};
|
||||
|
||||
// --- River draw-on line: take the clean routed LineString, strip a dangling final hop, simplify so the
|
||||
// wide-zoom draw-on reads as one smooth thread (no bezier — meander overshoot on a long river). ---
|
||||
const routed = read(RIVER).features[0].geometry.coordinates;
|
||||
let end = routed.length;
|
||||
while (end > 2 && havKm(routed[end - 2], routed[end - 1]) > 15) end--; // drop a final cross-braid jump if present
|
||||
const flow = turf.simplify(turf.lineString(routed.slice(0, end)), {
|
||||
tolerance: RIVER_SIMPLIFY_TOL,
|
||||
highQuality: true,
|
||||
}).geometry.coordinates;
|
||||
|
||||
// --- Borders + country fills (one source, filtered per country in the component) ---
|
||||
const borders = {type: 'FeatureCollection', features: []};
|
||||
const polys = {};
|
||||
for (const name of COUNTRIES) {
|
||||
const fc = read(BORDER(name));
|
||||
polys[name] = fc;
|
||||
for (const f of fc.features) {
|
||||
f.properties = {...(f.properties || {}), country: name};
|
||||
borders.features.push(f);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Reveal stops: arc-length fraction of `flow` where the river first ENTERS each country. The first
|
||||
// country (headwaters) is the source, so its stop is 0; the rest are computed. ---
|
||||
const flowKm = turf.length(turf.lineString(flow));
|
||||
const insideCountry = (pt, name) =>
|
||||
polys[name].features.some((f) => turf.booleanPointInPolygon(pt, f));
|
||||
const stops = {};
|
||||
COUNTRIES.forEach((c, i) => {
|
||||
stops[c] = i === 0 ? 0 : 1;
|
||||
}); // 0 = headwaters; 1 = sentinel until entered
|
||||
let acc = 0;
|
||||
for (let i = 0; i < flow.length; i++) {
|
||||
if (i > 0) acc += havKm(flow[i - 1], flow[i]);
|
||||
const frac = acc / (flowKm || 1);
|
||||
const pt = turf.point(flow[i]);
|
||||
for (const c of COUNTRIES)
|
||||
if (stops[c] === 1 && insideCountry(pt, c)) stops[c] = frac;
|
||||
}
|
||||
|
||||
// --- Per-country meta: anchor = pole of inaccessibility of the visible landmass (centred, away from
|
||||
// borders/edges) within the country's story region + a NUDGE; border = every exterior ring from the
|
||||
// complete named source. Never crop a country or bilateral boundary to the viewport. ---
|
||||
const biggestPoly = (geom) => {
|
||||
const rings =
|
||||
geom.type === 'MultiPolygon' ? geom.coordinates : [geom.coordinates];
|
||||
let best = null,
|
||||
bestA = -1;
|
||||
for (const c of rings) {
|
||||
const p = turf.polygon(c);
|
||||
const a = turf.area(p);
|
||||
if (a > bestA) {
|
||||
bestA = a;
|
||||
best = p;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
const largestPolygon = (fc) => {
|
||||
let best = null,
|
||||
bestA = -1;
|
||||
for (const f of fc.features) {
|
||||
const p = biggestPoly(f.geometry),
|
||||
a = turf.area(p);
|
||||
if (a > bestA) {
|
||||
bestA = a;
|
||||
best = p;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
const completeExteriorSegments = (fc) => {
|
||||
const segments = [];
|
||||
for (const feature of fc.features) {
|
||||
const polygons =
|
||||
feature.geometry.type === 'MultiPolygon'
|
||||
? feature.geometry.coordinates
|
||||
: [feature.geometry.coordinates];
|
||||
for (const polygon of polygons)
|
||||
if (polygon[0]?.length > 1) segments.push(polygon[0]);
|
||||
}
|
||||
return segments;
|
||||
};
|
||||
const poleOfInaccessibility = (poly) => {
|
||||
const bb = turf.bbox(poly),
|
||||
boundary = turf.polygonToLine(poly),
|
||||
N = 46;
|
||||
let best = null,
|
||||
bestD = -1;
|
||||
for (let i = 0; i <= N; i++)
|
||||
for (let j = 0; j <= N; j++) {
|
||||
const lng = bb[0] + ((bb[2] - bb[0]) * i) / N,
|
||||
lat = bb[1] + ((bb[3] - bb[1]) * j) / N;
|
||||
const pt = turf.point([lng, lat]);
|
||||
if (!turf.booleanPointInPolygon(pt, poly)) continue;
|
||||
const d = turf.pointToLineDistance(pt, boundary);
|
||||
if (d > bestD) {
|
||||
bestD = d;
|
||||
best = [lng, lat];
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
const countryMeta = {};
|
||||
for (const name of COUNTRIES) {
|
||||
const poly = largestPolygon(polys[name]);
|
||||
const storyRegion = biggestPoly(
|
||||
turf.bboxClip(poly, ANCHOR_BBOX[name] || FRAME_BBOX).geometry,
|
||||
);
|
||||
const pole = poleOfInaccessibility(storyRegion);
|
||||
const segs = completeExteriorSegments(polys[name]);
|
||||
const nudge = NUDGE[name] || [0, 0];
|
||||
countryMeta[name] = {
|
||||
stop: stops[name],
|
||||
anchor: [pole[0] + nudge[0], pole[1] + nudge[1]],
|
||||
border: segs,
|
||||
};
|
||||
}
|
||||
|
||||
mkdirSync(dirname(OUT_RIVER), {recursive: true});
|
||||
writeFileSync(OUT_RIVER, JSON.stringify(flow));
|
||||
writeFileSync(OUT_META, JSON.stringify(countryMeta));
|
||||
writeFileSync(OUT_BORDERS, JSON.stringify(borders));
|
||||
console.log(
|
||||
'river:',
|
||||
flow.length,
|
||||
'pts ·',
|
||||
flowKm.toFixed(0),
|
||||
'km · entry stops',
|
||||
JSON.stringify(stops),
|
||||
);
|
||||
for (const n of COUNTRIES) {
|
||||
const km = countryMeta[n].border.reduce(
|
||||
(s, seg) => s + turf.length(turf.lineString(seg)),
|
||||
0,
|
||||
);
|
||||
console.log(
|
||||
` ${n}: anchor ${countryMeta[n].anchor.map((v) => v.toFixed(2)).join(',')} · border ${km.toFixed(0)} km`,
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
"\nNext: copy out/river-flow.json + out/country-meta.json → your project's src/geo/ ; out/borders.geojson → public/geo/",
|
||||
);
|
||||
Reference in New Issue
Block a user