← Files RemotionARCHIVED FILE

skills/remotion-markup/remotion-maps/techniques/maptiler/scripts/prep-geo.mjs

7.38 KB · Oct 5, 2026 · 18:37 UTC

↓ Download file

// 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/",
);

SHA-256: 8265f56bd7c27a7d1a5fa528a77bbe1b29c6acc0e6730233583ca53e2632d490