reszta poprawek
This commit is contained in:
+89
@@ -0,0 +1,89 @@
|
||||
# CesiumJS — 3D flyovers in Remotion
|
||||
|
||||
Instructions for achieving map animations with "flight-simulator" perspective in Remotion.
|
||||
|
||||
## Modes
|
||||
|
||||
| Mode | Data | Use for |
|
||||
| ----------- | ----------------------------------------------------- | ------------------------------------------------------ |
|
||||
| `landscape` | MapTiler `terrain-quantized-mesh-v2` + `satellite-v2` | Mountains, gorges, rivers, coastlines and rural routes |
|
||||
| `city` | Google Photorealistic 3D Tiles | Cities, architecture and recognizable landmarks |
|
||||
|
||||
Do not use footprint extrusions for city flyovers. They produce crude building blocks rather than
|
||||
textured architecture.
|
||||
|
||||
## Credentials
|
||||
|
||||
For `landscape`, set:
|
||||
|
||||
```text
|
||||
REMOTION_MAPTILER_KEY=...
|
||||
```
|
||||
|
||||
Create a MapTiler key at https://cloud.maptiler.com/account/keys/.
|
||||
|
||||
For `city`, set:
|
||||
|
||||
```text
|
||||
REMOTION_GOOGLE_MAPS_API_KEY=...
|
||||
```
|
||||
|
||||
Create a billing-enabled Google Map Tiles API key by following
|
||||
https://developers.google.com/maps/documentation/tile/get-api-key. Enable the **Map Tiles API** and
|
||||
restrict the key to that API. Ensure its application restriction permits local headless Remotion
|
||||
requests.
|
||||
|
||||
## Build the flight
|
||||
|
||||
1. Copy `assets/CesiumFlythrough.tsx`, a path JSON and `assets/example-Root.tsx` into the Remotion
|
||||
project, or import the component directly.
|
||||
2. Supply the camera route as `[longitude, latitude][]`. Use only meaningful control points; do not hand-author dozens of tiny corrections.
|
||||
3. Leave `pathSmoothingPasses={3}` initially. The component applies repeated Chaikin corner cutting, turning straight-then-corner input into a continuous swerve. Increase to `4` for a softer route or reduce to `2` when the camera must follow a tight corridor.
|
||||
4. Set absolute camera altitudes for the location. City cameras normally fly lower than landscape
|
||||
cameras.
|
||||
5. Render a middle-frame still before rendering the full video.
|
||||
|
||||
```tsx
|
||||
<CesiumFlythrough
|
||||
mode="city"
|
||||
path={cameraPath}
|
||||
pathSmoothingPasses={3}
|
||||
altitudeStart={700}
|
||||
altitudeEnd={500}
|
||||
lookAheadKm={0.7}
|
||||
travelKm={4.5}
|
||||
/>
|
||||
```
|
||||
|
||||
## Camera behavior
|
||||
|
||||
Walk the smoothed curve by arc length for constant ground speed. Aim at a real point farther along
|
||||
the curve rather than its next vertex. Derive roll from the change in look-ahead bearing so the
|
||||
camera banks into a turn instead of twitching left and right.
|
||||
|
||||
For landscape routes, `scripts/prep-cesium-path.mjs` also clips, resamples, smooths and dampens a
|
||||
GeoJSON centerline before the component applies its final curve smoothing.
|
||||
|
||||
|
||||
## Mechanics
|
||||
|
||||
- Set `viewer.useDefaultRenderLoop = false`.
|
||||
- Call `viewer.render()`, never `scene.render()`, while settling.
|
||||
- Use `preserveDrawingBuffer: true`.
|
||||
- Gate initialization and every frame with `delayRender`.
|
||||
- Drive camera animation from `useCurrentFrame()`; do not use CSS transitions or browser-timed animation.
|
||||
- Settle landscapes on `globe.tilesLoaded` and cities on `tileset.tilesLoaded`.
|
||||
- Keep all provider attribution visible.
|
||||
- Record the source and effective date of custom or disputed geography.
|
||||
- Inspect rendered pixels, not only Studio playback, at every required aspect ratio.
|
||||
- Read `references/3d-flyover-architecture.md` for camera math, `references/3d-data-sources.md` for provider details, and `references/3d-troubleshooting.md` for blank, coarse, unauthorized or timed-out renders.
|
||||
|
||||
## Files
|
||||
|
||||
- `assets/CesiumFlythrough.tsx` — reusable two-mode component.
|
||||
- `assets/flight-path.ts` — dependency-free Chaikin route smoothing.
|
||||
- `assets/example-Root.tsx` — landscape and city compositions.
|
||||
- `assets/cesium-path.json` — sample landscape route.
|
||||
- `assets/city-path.json` — sample city route.
|
||||
- `assets/sample-river.geojson` — sample path-preparation input.
|
||||
- `scripts/prep-cesium-path.mjs` — dependency-free landscape route preparation.
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
import React, {useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {
|
||||
AbsoluteFill,
|
||||
cancelRender,
|
||||
continueRender,
|
||||
delayRender,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
} from 'remotion';
|
||||
import terrainPath from './cesium-path.json';
|
||||
import {smoothFlightPath, type LngLat} from './flight-path';
|
||||
|
||||
export type FlyoverMode = 'landscape' | 'city';
|
||||
export type {LngLat} from './flight-path';
|
||||
|
||||
export type CesiumFlythroughProps = {
|
||||
mode?: FlyoverMode;
|
||||
path?: LngLat[];
|
||||
pathSmoothingPasses?: number;
|
||||
altitudeStart?: number;
|
||||
altitudeEnd?: number;
|
||||
lookAheadKm?: number;
|
||||
travelKm?: number;
|
||||
pitchFromNadir?: number;
|
||||
verticalExaggeration?: number;
|
||||
maximumScreenSpaceError?: number;
|
||||
};
|
||||
|
||||
const MAPTILER_KEY = process.env.REMOTION_MAPTILER_KEY;
|
||||
const GOOGLE_MAPS_API_KEY = process.env.REMOTION_GOOGLE_MAPS_API_KEY;
|
||||
const CESIUM_VER = '1.143';
|
||||
const CDN = `https://cesium.com/downloads/cesiumjs/releases/${CESIUM_VER}/Build/Cesium/`;
|
||||
const R = 6371;
|
||||
const MAX_BANK = 0.13;
|
||||
const BANK_GAIN = 0.6;
|
||||
|
||||
const havKm = (a: number[], b: number[]) => {
|
||||
const r = Math.PI / 180;
|
||||
const dLat = (b[1] - a[1]) * r;
|
||||
const 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));
|
||||
};
|
||||
|
||||
const makePathWalker = (path: LngLat[]) => {
|
||||
if (path.length < 2)
|
||||
throw new Error('Flyover path needs at least two points');
|
||||
const cumulative = [0];
|
||||
for (let i = 1; i < path.length; i++) {
|
||||
cumulative.push(cumulative[i - 1] + havKm(path[i - 1], path[i]));
|
||||
}
|
||||
const lengthKm = cumulative[cumulative.length - 1];
|
||||
const along = (km: number): LngLat => {
|
||||
const d = Math.max(0, Math.min(lengthKm, km));
|
||||
let i = 1;
|
||||
while (i < cumulative.length && cumulative[i] < d) i++;
|
||||
if (i >= cumulative.length) return path[path.length - 1];
|
||||
const segmentLength = cumulative[i] - cumulative[i - 1] || 1;
|
||||
const t = (d - cumulative[i - 1]) / segmentLength;
|
||||
return [
|
||||
path[i - 1][0] + (path[i][0] - path[i - 1][0]) * t,
|
||||
path[i - 1][1] + (path[i][1] - path[i - 1][1]) * t,
|
||||
];
|
||||
};
|
||||
return {along, lengthKm};
|
||||
};
|
||||
|
||||
const bearing = (a: number[], b: number[]) => {
|
||||
const r = Math.PI / 180;
|
||||
const y = Math.sin((b[0] - a[0]) * r) * Math.cos(b[1] * r);
|
||||
const x =
|
||||
Math.cos(a[1] * r) * Math.sin(b[1] * r) -
|
||||
Math.sin(a[1] * r) * Math.cos(b[1] * r) * Math.cos((b[0] - a[0]) * r);
|
||||
return Math.atan2(y, x);
|
||||
};
|
||||
|
||||
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
||||
const clamp = (v: number, lo: number, hi: number) =>
|
||||
Math.max(lo, Math.min(hi, v));
|
||||
|
||||
const loadCesium = () =>
|
||||
new Promise<any>((resolve, reject) => {
|
||||
if ((window as any).Cesium) return resolve((window as any).Cesium);
|
||||
(window as any).CESIUM_BASE_URL = CDN;
|
||||
const css = document.createElement('link');
|
||||
css.rel = 'stylesheet';
|
||||
css.href = `${CDN}Widgets/widgets.css`;
|
||||
document.head.appendChild(css);
|
||||
const script = document.createElement('script');
|
||||
script.src = `${CDN}Cesium.js`;
|
||||
script.onload = () => resolve((window as any).Cesium);
|
||||
script.onerror = () =>
|
||||
reject(new Error(`Failed to load CesiumJS ${CESIUM_VER}`));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
export const CesiumFlythrough: React.FC<CesiumFlythroughProps> = ({
|
||||
mode = 'landscape',
|
||||
path = terrainPath as LngLat[],
|
||||
pathSmoothingPasses = 3,
|
||||
altitudeStart = 4600,
|
||||
altitudeEnd = 4300,
|
||||
lookAheadKm = 1.5,
|
||||
travelKm = 13,
|
||||
pitchFromNadir = 76,
|
||||
verticalExaggeration = 1.1,
|
||||
maximumScreenSpaceError = 8,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const started = useRef(false);
|
||||
const viewerRef = useRef<any>(null);
|
||||
const tilesetRef = useRef<any>(null);
|
||||
const frame = useCurrentFrame();
|
||||
const {durationInFrames, fps, width, height} = useVideoConfig();
|
||||
const [ready, setReady] = useState(false);
|
||||
const [handle] = useState(() =>
|
||||
delayRender(`cesium init: ${mode}`, {timeoutInMilliseconds: 120000}),
|
||||
);
|
||||
const walker = useMemo(
|
||||
() => makePathWalker(smoothFlightPath(path, pathSmoothingPasses)),
|
||||
[path, pathSmoothingPasses],
|
||||
);
|
||||
|
||||
const setCamera = (C: any, viewer: any, progress: number) => {
|
||||
const maxTravel = Math.max(0, walker.lengthKm - lookAheadKm * 2);
|
||||
const cameraDistance = Math.min(travelKm, maxTravel) * progress;
|
||||
const camera = walker.along(cameraDistance);
|
||||
const aim = walker.along(cameraDistance + lookAheadKm);
|
||||
const aim2 = walker.along(cameraDistance + lookAheadKm * 2);
|
||||
const heading = bearing(camera, aim);
|
||||
let headingDelta = bearing(aim, aim2) - heading;
|
||||
while (headingDelta > Math.PI) headingDelta -= 2 * Math.PI;
|
||||
while (headingDelta < -Math.PI) headingDelta += 2 * Math.PI;
|
||||
viewer.camera.setView({
|
||||
destination: C.Cartesian3.fromDegrees(
|
||||
camera[0],
|
||||
camera[1],
|
||||
lerp(altitudeStart, altitudeEnd, progress),
|
||||
),
|
||||
orientation: {
|
||||
heading,
|
||||
pitch: C.Math.toRadians(-(90 - pitchFromNadir)),
|
||||
roll: clamp(headingDelta * BANK_GAIN, -MAX_BANK, MAX_BANK),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const tilesAreLoaded = (viewer: any) => {
|
||||
if (mode === 'landscape') return viewer.scene.globe.tilesLoaded;
|
||||
return Boolean(tilesetRef.current?.tilesLoaded);
|
||||
};
|
||||
|
||||
const settle = (viewer: any) =>
|
||||
new Promise<void>((resolve) => {
|
||||
let stable = 0;
|
||||
let ticks = 0;
|
||||
const tick = () => {
|
||||
viewer.render();
|
||||
ticks++;
|
||||
stable = tilesAreLoaded(viewer) ? stable + 1 : 0;
|
||||
if (stable > 8 || ticks > 600) {
|
||||
viewer.render();
|
||||
resolve();
|
||||
} else {
|
||||
setTimeout(tick, 8);
|
||||
}
|
||||
};
|
||||
tick();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (started.current) return;
|
||||
started.current = true;
|
||||
(async () => {
|
||||
if (mode === 'landscape' && !MAPTILER_KEY) {
|
||||
throw new Error(
|
||||
'Set REMOTION_MAPTILER_KEY. Create a key at https://cloud.maptiler.com/account/keys/',
|
||||
);
|
||||
}
|
||||
if (mode === 'city' && !GOOGLE_MAPS_API_KEY) {
|
||||
throw new Error(
|
||||
'Set REMOTION_GOOGLE_MAPS_API_KEY. Create a Map Tiles API key at https://developers.google.com/maps/documentation/tile/get-api-key',
|
||||
);
|
||||
}
|
||||
if (mode === 'city' && durationInFrames / fps > 30) {
|
||||
throw new Error(
|
||||
'Google Photorealistic 3D Tiles compositions must not exceed 30 seconds',
|
||||
);
|
||||
}
|
||||
|
||||
const C = await loadCesium();
|
||||
const viewer = new C.Viewer(containerRef.current, {
|
||||
baseLayer: false,
|
||||
baseLayerPicker: false,
|
||||
geocoder: false,
|
||||
homeButton: false,
|
||||
sceneModePicker: false,
|
||||
navigationHelpButton: false,
|
||||
animation: false,
|
||||
timeline: false,
|
||||
fullscreenButton: false,
|
||||
infoBox: false,
|
||||
selectionIndicator: false,
|
||||
contextOptions: {webgl: {preserveDrawingBuffer: true}},
|
||||
});
|
||||
if (mode === 'landscape') {
|
||||
viewer.imageryLayers.addImageryProvider(
|
||||
new C.UrlTemplateImageryProvider({
|
||||
url: `https://api.maptiler.com/tiles/satellite-v2/{z}/{x}/{y}.jpg?key=${MAPTILER_KEY}`,
|
||||
maximumLevel: 20,
|
||||
}),
|
||||
);
|
||||
viewer.terrainProvider = await C.CesiumTerrainProvider.fromUrl(
|
||||
`https://api.maptiler.com/tiles/terrain-quantized-mesh-v2/?key=${MAPTILER_KEY}`,
|
||||
{requestVertexNormals: true},
|
||||
);
|
||||
viewer.creditDisplay.addStaticCredit(
|
||||
new C.Credit(
|
||||
'<a href="https://www.maptiler.com/copyright/" target="_blank">© MapTiler</a>',
|
||||
true,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (mode === 'city') {
|
||||
viewer.scene.globe.show = false;
|
||||
const tileset = await C.Cesium3DTileset.fromUrl(
|
||||
`https://tile.googleapis.com/v1/3dtiles/root.json?key=${GOOGLE_MAPS_API_KEY}`,
|
||||
{
|
||||
showCreditsOnScreen: true,
|
||||
maximumScreenSpaceError,
|
||||
},
|
||||
);
|
||||
viewer.scene.primitives.add(tileset);
|
||||
tilesetRef.current = tileset;
|
||||
}
|
||||
|
||||
viewer.useDefaultRenderLoop = false;
|
||||
viewer.scene.skyAtmosphere.show = true;
|
||||
viewer.scene.fog.enabled = true;
|
||||
viewer.scene.globe.enableLighting = false;
|
||||
viewer.scene.verticalExaggeration = verticalExaggeration;
|
||||
(window as any).__CESIUM_FLYOVER__ = {C, mode};
|
||||
viewerRef.current = viewer;
|
||||
setCamera(C, viewer, 0);
|
||||
await settle(viewer);
|
||||
setReady(true);
|
||||
continueRender(handle);
|
||||
})().catch((error) => cancelRender(error));
|
||||
}, [durationInFrames, fps, handle, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
const frameHandle = delayRender(`cesium ${mode} frame ${frame}`, {
|
||||
timeoutInMilliseconds: 60000,
|
||||
});
|
||||
const C = (window as any).__CESIUM_FLYOVER__.C;
|
||||
const viewer = viewerRef.current;
|
||||
const progress = durationInFrames <= 1 ? 0 : frame / (durationInFrames - 1);
|
||||
setCamera(C, viewer, progress);
|
||||
settle(viewer).then(() => continueRender(frameHandle));
|
||||
}, [ready, frame, durationInFrames, mode]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{backgroundColor: '#000'}}>
|
||||
<div ref={containerRef} style={{width, height, position: 'absolute'}} />
|
||||
{mode === 'city' ? (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 20,
|
||||
right: 24,
|
||||
color: 'white',
|
||||
font: '500 18px/1.2 sans-serif',
|
||||
textShadow: '0 1px 4px black',
|
||||
}}
|
||||
>
|
||||
For promotional purposes only
|
||||
</div>
|
||||
) : null}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
[
|
||||
[94.986139893537, 29.76417312959953],
|
||||
[94.98637054463002, 29.764163024004816],
|
||||
[94.98661427422437, 29.764153025681516],
|
||||
[94.9868698103708, 29.764142908388347],
|
||||
[94.98713600736019, 29.764132461562934],
|
||||
[94.98741179569139, 29.76412145291623],
|
||||
[94.9876962466277, 29.764109663422126],
|
||||
[94.98798853462426, 29.764096898390772],
|
||||
[94.98828790834106, 29.764082995679242],
|
||||
[94.98859364523021, 29.764067827511347],
|
||||
[94.98890509685035, 29.764051279216236],
|
||||
[94.98922167960325, 29.76403324759024],
|
||||
[94.98954294512464, 29.76401367774424],
|
||||
[94.9898685550894, 29.76399255290906],
|
||||
[94.99019820264337, 29.76396985787546],
|
||||
[94.9905316178847, 29.763945606334815],
|
||||
[94.9908685732739, 29.763919866576575],
|
||||
[94.99120886105722, 29.763892700951896],
|
||||
[94.99155229116181, 29.7638641665054],
|
||||
[94.99189872300363, 29.76383429811986],
|
||||
[94.99224811692223, 29.763803085651237],
|
||||
[94.99260053337049, 29.763770509641105],
|
||||
[94.99295601118384, 29.76373658086857],
|
||||
[94.99331456742908, 29.763701322339223],
|
||||
[94.99367598745215, 29.763664830647013],
|
||||
[94.99404000156176, 29.763627219082974],
|
||||
[94.99440635968021, 29.763588592698778],
|
||||
[94.99477481502787, 29.76354905236459],
|
||||
[94.9951451006731, 29.763508748648892],
|
||||
[94.99578766204738, 29.763462125646818],
|
||||
[94.99643710580968, 29.763412119743368],
|
||||
[94.99709296623192, 29.763358988682928],
|
||||
[94.99775479298779, 29.763302979031952],
|
||||
[94.99842214521965, 29.763244327404465],
|
||||
[94.99909470569231, 29.763183258547627],
|
||||
[94.9997722961385, 29.76312010454948],
|
||||
[95.00045474013422, 29.763055184243758],
|
||||
[95.00114189582168, 29.762988795623112],
|
||||
[95.00183368970049, 29.762921207778405],
|
||||
[95.0025300799095, 29.76285270023514],
|
||||
[95.00323103443773, 29.762783549665084],
|
||||
[95.00393658318124, 29.76271400155497],
|
||||
[95.00464681949704, 29.76264432424487],
|
||||
[95.00536183331896, 29.76257477740286],
|
||||
[95.00608171146906, 29.762505612813378],
|
||||
[95.00680653793428, 29.762437075077955],
|
||||
[95.00753639864557, 29.762369398844733],
|
||||
[95.00827137837749, 29.762302811854063],
|
||||
[95.00901153006761, 29.762237519590354],
|
||||
[95.00975680776318, 29.76217368790719],
|
||||
[95.01050716875933, 29.762111478518566],
|
||||
[95.0112626067996, 29.76205102042169],
|
||||
[95.01202312290016, 29.761992432502932],
|
||||
[95.01278871942189, 29.76193583156792],
|
||||
[95.01355940108247, 29.761881333165324],
|
||||
[95.01433525277555, 29.761829093407865],
|
||||
[95.01511636205572, 29.761779224166457],
|
||||
[95.01590278872715, 29.76173180995877],
|
||||
[95.0166868810408, 29.76168612055982],
|
||||
[95.01746871725406, 29.761642332250204],
|
||||
[95.0182483756254, 29.76160062131038],
|
||||
[95.01902596675926, 29.761561177619345],
|
||||
[95.019801682618, 29.761524238306453],
|
||||
[95.02057570378254, 29.76149004865798],
|
||||
[95.02134818878243, 29.761458838563573],
|
||||
[95.02211921065114, 29.761430763930672],
|
||||
[95.02288888930805, 29.761405973069504],
|
||||
[95.02365734467304, 29.761384614290225],
|
||||
[95.02442469666667, 29.761366835902948],
|
||||
[95.02519095257038, 29.761352731197437],
|
||||
[95.02595602043016, 29.761342344990513],
|
||||
[95.02671980829304, 29.761335722099044],
|
||||
[95.02748221022355, 29.76133286485609],
|
||||
[95.02824308111781, 29.761333684556867],
|
||||
[95.02900227020879, 29.761338090875398],
|
||||
[95.02975960629327, 29.76134599002589],
|
||||
[95.03051480114966, 29.76135728786741],
|
||||
[95.03126749207338, 29.761371921751238],
|
||||
[95.03201723576402, 29.761389829130785],
|
||||
[95.03276363398774, 29.761410893146387],
|
||||
[95.03350632277824, 29.761434973117545],
|
||||
[95.03424521675927, 29.76146183394706],
|
||||
[95.03498037607913, 29.761491215377145],
|
||||
[95.035711917426, 29.761522866578012],
|
||||
[95.03643998610582, 29.761556530192124],
|
||||
[95.03716475930274, 29.761591886465347],
|
||||
[95.03788653744915, 29.761628566185358],
|
||||
[95.03860571883365, 29.76166618469001],
|
||||
[95.03932258585421, 29.76170436456548],
|
||||
[95.04003738892985, 29.76174273062808],
|
||||
[95.04075025083816, 29.76178094997509],
|
||||
[95.0414610559133, 29.76181870813213],
|
||||
[95.04216961904287, 29.761855638407084],
|
||||
[95.04287591713418, 29.761891331889718],
|
||||
[95.04357986124926, 29.761925397709096],
|
||||
[95.04428137757625, 29.761957488479126],
|
||||
[95.04498041854592, 29.76198725048925],
|
||||
[95.04567682817188, 29.762014317811357],
|
||||
[95.04637032968634, 29.762038376323694],
|
||||
[95.04706059416637, 29.762059099770216],
|
||||
[95.04774726432963, 29.762076162373777],
|
||||
[95.0484300050609, 29.762089241174117],
|
||||
[95.04910871445378, 29.762098042845082],
|
||||
[95.04978328153793, 29.762102280852183],
|
||||
[95.05045359964508, 29.76210167552205],
|
||||
[95.05111966253706, 29.76209602004179],
|
||||
[95.05178153406797, 29.762085106891018],
|
||||
[95.05243927503516, 29.762068731547185],
|
||||
[95.05309287649075, 29.762046750046103],
|
||||
[95.0537422946664, 29.762019099359485],
|
||||
[95.05438748396233, 29.761985721609257],
|
||||
[95.05502839823626, 29.76194655859307],
|
||||
[95.05566488201883, 29.761901557942874],
|
||||
[95.05629656919591, 29.761850805510885],
|
||||
[95.05692313234233, 29.761794428680094],
|
||||
[95.0575442743842, 29.761732580860688],
|
||||
[95.0581596433054, 29.761665348138973],
|
||||
[95.05876888708963, 29.761592816601354],
|
||||
[95.05937164047754, 29.761515065256333],
|
||||
[95.05996759228947, 29.761432144038746],
|
||||
[95.06055643134583, 29.761344102883623],
|
||||
[95.06113793491645, 29.761251010034922],
|
||||
[95.06171214230999, 29.761153046649387],
|
||||
[95.06227907948092, 29.761050389948267],
|
||||
[95.06283877238438, 29.760943217152814],
|
||||
[95.06339127381165, 29.760831691663462],
|
||||
[95.06393669678933, 29.760716002374487],
|
||||
[95.06447520396281, 29.76059636241658],
|
||||
[95.06500695797821, 29.760472984920362],
|
||||
[95.06553212847378, 29.760346104258282],
|
||||
[95.0660508986937, 29.760216015040477],
|
||||
[95.06656344194985, 29.760083025494016],
|
||||
[95.06707002648291, 29.75994741412334],
|
||||
[95.06757112978441, 29.759809440866206],
|
||||
[95.06806722979658, 29.759669344181514],
|
||||
[95.06855878142073, 29.759527354008487],
|
||||
[95.06904617625263, 29.759383697903765],
|
||||
[95.06952978875404, 29.75923861533451],
|
||||
[95.07000993080214, 29.759092371156385],
|
||||
[95.07048679175114, 29.758945227682446],
|
||||
[95.07096044787545, 29.75879742836973],
|
||||
[95.07143096114058, 29.758649219939148],
|
||||
[95.0718984144853, 29.758500858117706],
|
||||
[95.07236278188816, 29.7583525823887],
|
||||
[95.07282412506945, 29.758204644682806],
|
||||
[95.07328275575983, 29.75805728963777],
|
||||
[95.07373905641253, 29.757910760813612],
|
||||
[95.07419366476313, 29.757765217208473],
|
||||
[95.07464735762416, 29.757620800921966],
|
||||
[95.07510085345471, 29.757477677507868],
|
||||
[95.07555467766437, 29.757336168750566],
|
||||
[95.07600940811365, 29.757196598118206],
|
||||
[95.07646537399586, 29.75705931280275],
|
||||
[95.0769228207089, 29.756924677394206],
|
||||
[95.07738220221682, 29.756793075496777],
|
||||
[95.07784401968648, 29.75666487927172],
|
||||
[95.07830874492939, 29.756540496044202],
|
||||
[95.07877690647402, 29.756420332181715],
|
||||
[95.07924898851259, 29.75630478841808],
|
||||
[95.07972503722137, 29.756194196891926],
|
||||
[95.08020511490892, 29.756088882533362],
|
||||
[95.0806891403115, 29.75598918720403],
|
||||
[95.08117690920173, 29.75589531641115],
|
||||
[95.08166835685006, 29.755807414283098],
|
||||
[95.08216335868525, 29.75572566824862],
|
||||
[95.08266177736347, 29.755650250535062],
|
||||
[95.08316349491537, 29.75558120546187],
|
||||
[95.08366839703267, 29.755518567048284],
|
||||
[95.08417639041532, 29.75546236367944],
|
||||
[95.08468755179344, 29.755412571989652],
|
||||
[95.08520236790308, 29.755369015997044],
|
||||
[95.08572134650697, 29.75533148313236],
|
||||
[95.0862450098908, 29.755299740305322],
|
||||
[95.08677390781156, 29.75527358808779],
|
||||
[95.08730861289804, 29.755252808551163],
|
||||
[95.08784970300886, 29.755237175354623],
|
||||
[95.08839755885471, 29.755226476280296],
|
||||
[95.08895256438073, 29.755220494902016],
|
||||
[95.089514993805, 29.755219019351184],
|
||||
[95.09008489086116, 29.7552217729881],
|
||||
[95.09066229928165, 29.75522847917293],
|
||||
[95.09124726342738, 29.755238870403907],
|
||||
[95.09183976966378, 29.755252726598844],
|
||||
[95.09243978867215, 29.75526985018834],
|
||||
[95.09304723439128, 29.755290065097345],
|
||||
[95.09366184648091, 29.75531326126932],
|
||||
[95.09428337414857, 29.755339326723966],
|
||||
[95.09491171647382, 29.755368102171502],
|
||||
[95.09554680939114, 29.75539940595205],
|
||||
[95.09618846028675, 29.755433126230766],
|
||||
[95.09683632409143, 29.75546923335011],
|
||||
[95.09749004228215, 29.75550775219342],
|
||||
[95.09814924669702, 29.755548754655436],
|
||||
[95.0988136358514, 29.755592374644042],
|
||||
[95.09948290826172, 29.755638746066982],
|
||||
[95.1001568080277, 29.75568798494813],
|
||||
[95.10083517891061, 29.75574017449467],
|
||||
[95.10151797539464, 29.75579531974171],
|
||||
[95.1022051519645, 29.755853425724286],
|
||||
[95.10289664375364, 29.755914488290983],
|
||||
[95.10359258928374, 29.755978570204803],
|
||||
[95.10429306658138, 29.756045742645284],
|
||||
[95.10499798654989, 29.756116067070348],
|
||||
[95.10570717321444, 29.75618958016383],
|
||||
[95.10642032295875, 29.75626636089044],
|
||||
[95.10713711848761, 29.756346492746122],
|
||||
[95.10785729025027, 29.75643004436662],
|
||||
[95.10858043622838, 29.75651688160893],
|
||||
[95.10930611534633, 29.75660684892344],
|
||||
[95.11003407093642, 29.756699686157475],
|
||||
[95.11076414467685, 29.756795123665622],
|
||||
[95.1114960914754, 29.756892893838618],
|
||||
[95.11222971288655, 29.756992700238396],
|
||||
[95.1129649452222, 29.75709420093414],
|
||||
[95.11370170201873, 29.75719709582746],
|
||||
[95.11443986012442, 29.757301125099488],
|
||||
[95.11517935555177, 29.7574061249826],
|
||||
[95.11592009761476, 29.757511940391662],
|
||||
[95.11666232015585, 29.75761835330566],
|
||||
[95.11740624233691, 29.757725222810585],
|
||||
[95.11815181076378, 29.75783253698326],
|
||||
[95.11889913308254, 29.757940178669305],
|
||||
[95.11964841222917, 29.758048000558695],
|
||||
[95.12039993276164, 29.758155854652614],
|
||||
[95.12115399312397, 29.75826357999786],
|
||||
[95.1219108497459, 29.75837102690515],
|
||||
[95.1226706293697, 29.758478081829693],
|
||||
[95.12343325561484, 29.758584686897258],
|
||||
[95.12419857627997, 29.758690818430438],
|
||||
[95.12496638982559, 29.75879646083105],
|
||||
[95.12573649209446, 29.7589015818837],
|
||||
[95.12650863318528, 29.75900618637391],
|
||||
[95.12728253192327, 29.759110282431784],
|
||||
[95.12805798566751, 29.75921386162033],
|
||||
[95.12883477913662, 29.759316919371038],
|
||||
[95.12961273528985, 29.759419445656786],
|
||||
[95.13039166301432, 29.759521458216526],
|
||||
[95.13117134363408, 29.759622963323043],
|
||||
[95.13195155721434, 29.759723948972997],
|
||||
[95.13273211930314, 29.759824349786275],
|
||||
[95.13351286507051, 29.759924061406032],
|
||||
[95.13428648274362, 29.76002351015658],
|
||||
[95.1350533298064, 29.760122544307915],
|
||||
[95.1358137532818, 29.760221010686855],
|
||||
[95.13656784419986, 29.760318809952974],
|
||||
[95.13731567503761, 29.76041584848236],
|
||||
[95.1380573752464, 29.760511990985425],
|
||||
[95.13879310931623, 29.76060704815151],
|
||||
[95.13952306473344, 29.760700794123938],
|
||||
[95.14024751710419, 29.7607929520901],
|
||||
[95.14096671686433, 29.760883206230545],
|
||||
[95.14168092332761, 29.76097123202995],
|
||||
[95.14239029599311, 29.761056732470486],
|
||||
[95.14309486625548, 29.761139484506582],
|
||||
[95.14379454089128, 29.761219462160696],
|
||||
[95.14448922306659, 29.76129664046317],
|
||||
[95.14517882147311, 29.76137100941381],
|
||||
[95.14586312132259, 29.761442532574584],
|
||||
[95.14654188135431, 29.761511168945386],
|
||||
[95.14721489938523, 29.761576901934955],
|
||||
[95.14788204807155, 29.761639772673718],
|
||||
[95.14854319868547, 29.76169982923195],
|
||||
[95.14919822095698, 29.761757127412878],
|
||||
[95.14984699116548, 29.76181173598209],
|
||||
[95.15048960377052, 29.761863851723042],
|
||||
[95.15112617637942, 29.761913696049017],
|
||||
[95.15175681293137, 29.761961561816054],
|
||||
[95.15238148064795, 29.762007691856205],
|
||||
[95.15300009096235, 29.762052309028526],
|
||||
[95.1533539374323, 29.762086082676035],
|
||||
[95.15370650809008, 29.762118928860186],
|
||||
[95.15405759583123, 29.762150875711065],
|
||||
[95.15440704121897, 29.76218191319173],
|
||||
[95.1547548321985, 29.762211967913583],
|
||||
[95.15510097264206, 29.762240955288657],
|
||||
[95.15544525415498, 29.762268829619945],
|
||||
[95.15578751039571, 29.76229553455492],
|
||||
[95.15612769998111, 29.762320941845203],
|
||||
[95.15646565712565, 29.762344985615734],
|
||||
[95.156801142589, 29.76236761294652],
|
||||
[95.1571338046714, 29.76238883654028],
|
||||
[95.15746323934037, 29.76240869370805],
|
||||
[95.15778903282519, 29.762427217766984],
|
||||
[95.15811077257146, 29.762444433666733],
|
||||
[95.15842800577592, 29.762460368791256],
|
||||
[95.15874028837707, 29.76247502889767],
|
||||
[95.15904716834646, 29.76248842410003],
|
||||
[95.1593481499996, 29.762500590502704],
|
||||
[95.15964271930007, 29.76251153961251],
|
||||
[95.1599303416933, 29.76252129267978],
|
||||
[95.16021045104225, 29.762529892932115],
|
||||
[95.16048243037355, 29.762537389252305],
|
||||
[95.16074559672008, 29.762543832974302],
|
||||
[95.1609993202866, 29.762549265122786],
|
||||
[95.16124294069552, 29.762553753099137],
|
||||
[95.16147571071544, 29.762557390653928],
|
||||
[95.16169677155058, 29.762560323342807]
|
||||
]
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
[
|
||||
[-74.0135, 40.7047],
|
||||
[-74.0102, 40.7104],
|
||||
[-74.0074, 40.7167],
|
||||
[-74.0047, 40.7232],
|
||||
[-74.0017, 40.7297],
|
||||
[-73.9988, 40.7362],
|
||||
[-73.9954, 40.7429],
|
||||
[-73.9917, 40.7496],
|
||||
[-73.9882, 40.7563],
|
||||
[-73.9848, 40.7631]
|
||||
]
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import {Composition} from 'remotion';
|
||||
import {CesiumFlythrough, type CesiumFlythroughProps} from './CesiumFlythrough';
|
||||
import cityPath from './city-path.json';
|
||||
|
||||
export const RemotionRoot: React.FC = () => (
|
||||
<>
|
||||
<Composition
|
||||
id="LandscapeFlyover"
|
||||
component={CesiumFlythrough}
|
||||
defaultProps={{mode: 'landscape'} satisfies CesiumFlythroughProps}
|
||||
durationInFrames={24 * 30}
|
||||
fps={30}
|
||||
width={1920}
|
||||
height={1080}
|
||||
/>
|
||||
<Composition
|
||||
id="CityFlyover"
|
||||
component={CesiumFlythrough}
|
||||
defaultProps={
|
||||
{
|
||||
mode: 'city',
|
||||
path: cityPath as [number, number][],
|
||||
altitudeStart: 700,
|
||||
altitudeEnd: 500,
|
||||
lookAheadKm: 0.7,
|
||||
travelKm: 4.5,
|
||||
pitchFromNadir: 72,
|
||||
verticalExaggeration: 1,
|
||||
maximumScreenSpaceError: 6,
|
||||
} satisfies CesiumFlythroughProps
|
||||
}
|
||||
durationInFrames={18 * 30}
|
||||
fps={30}
|
||||
width={1920}
|
||||
height={1080}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
export type LngLat = [number, number];
|
||||
|
||||
// Chaikin corner cutting turns a sparse route into a continuous curve. Repeated passes round
|
||||
// direction changes into deliberate swerves instead of left-right heading bumps.
|
||||
export const smoothFlightPath = (source: LngLat[], passes = 3): LngLat[] => {
|
||||
if (source.length < 2)
|
||||
throw new Error('Flyover path needs at least two points');
|
||||
|
||||
// Keep adjacent longitudes continuous for routes that cross the antimeridian.
|
||||
const unwrapped: LngLat[] = [source[0]];
|
||||
for (let index = 1; index < source.length; index++) {
|
||||
const [lng, lat] = source[index];
|
||||
const previous = unwrapped[index - 1][0];
|
||||
let adjusted = lng;
|
||||
while (adjusted - previous > 180) adjusted -= 360;
|
||||
while (adjusted - previous < -180) adjusted += 360;
|
||||
unwrapped.push([adjusted, lat]);
|
||||
}
|
||||
|
||||
let curve = unwrapped;
|
||||
for (let pass = 0; pass < Math.max(0, passes); pass++) {
|
||||
const next: LngLat[] = [curve[0]];
|
||||
for (let i = 0; i < curve.length - 1; i++) {
|
||||
const a = curve[i];
|
||||
const b = curve[i + 1];
|
||||
next.push(
|
||||
[a[0] * 0.75 + b[0] * 0.25, a[1] * 0.75 + b[1] * 0.25],
|
||||
[a[0] * 0.25 + b[0] * 0.75, a[1] * 0.25 + b[1] * 0.75],
|
||||
);
|
||||
}
|
||||
next.push(curve[curve.length - 1]);
|
||||
curve = next;
|
||||
}
|
||||
return curve;
|
||||
};
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
{
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {"name": "Yarlung Tsangpo (OSM, gorge way)"},
|
||||
"geometry": {
|
||||
"type": "LineString",
|
||||
"coordinates": [
|
||||
[94.8794306, 29.5453548],
|
||||
[94.880058, 29.5500259],
|
||||
[94.8804545, 29.5529773],
|
||||
[94.8872806, 29.559007],
|
||||
[94.8902386, 29.5645817],
|
||||
[94.8945618, 29.5651505],
|
||||
[94.8985437, 29.5703839],
|
||||
[94.8978611, 29.5758448],
|
||||
[94.9077237, 29.5843086],
|
||||
[94.9107706, 29.5872382],
|
||||
[94.9136749, 29.5879042],
|
||||
[94.9208424, 29.5864253],
|
||||
[94.9243692, 29.5890419],
|
||||
[94.9243692, 29.5945028],
|
||||
[94.9268508, 29.597544],
|
||||
[94.9331942, 29.6000111],
|
||||
[94.9350913, 29.6024165],
|
||||
[94.9359736, 29.6042869],
|
||||
[94.9354367, 29.6064452],
|
||||
[94.9350634, 29.608155],
|
||||
[94.9343462, 29.6094434],
|
||||
[94.9295695, 29.6113703],
|
||||
[94.9288061, 29.6124782],
|
||||
[94.9268721, 29.6194181],
|
||||
[94.9273809, 29.6276608],
|
||||
[94.9274409, 29.6286334],
|
||||
[94.9263783, 29.6303683],
|
||||
[94.9250518, 29.6304537],
|
||||
[94.922744, 29.6299103],
|
||||
[94.9170292, 29.6256472],
|
||||
[94.9144757, 29.6243553],
|
||||
[94.9115439, 29.6243318],
|
||||
[94.9008302, 29.6273853],
|
||||
[94.8972905, 29.6276085],
|
||||
[94.8897112, 29.6252949],
|
||||
[94.8827398, 29.6253184],
|
||||
[94.8806998, 29.6268686],
|
||||
[94.8777241, 29.6317051],
|
||||
[94.8768493, 29.6342436],
|
||||
[94.8768641, 29.6346894],
|
||||
[94.8769277, 29.6365972],
|
||||
[94.8775677, 29.6391137],
|
||||
[94.8804943, 29.6418187],
|
||||
[94.8821969, 29.6438716],
|
||||
[94.8877263, 29.6456718],
|
||||
[94.8903366, 29.6468971],
|
||||
[94.8959561, 29.6481151],
|
||||
[94.9032857, 29.6528729],
|
||||
[94.9113869, 29.6568592],
|
||||
[94.9162734, 29.6587881],
|
||||
[94.9182022, 29.6625172],
|
||||
[94.9203882, 29.670104],
|
||||
[94.9197453, 29.6744761],
|
||||
[94.9192644, 29.6754058],
|
||||
[94.9178164, 29.6782052],
|
||||
[94.915116, 29.6816771],
|
||||
[94.9094581, 29.6810342],
|
||||
[94.9043145, 29.6796197],
|
||||
[94.9018712, 29.6806484],
|
||||
[94.8976278, 29.6812914],
|
||||
[94.8909411, 29.6856634],
|
||||
[94.8896552, 29.6890068],
|
||||
[94.892227, 29.6990368],
|
||||
[94.8978849, 29.704952],
|
||||
[94.9017426, 29.7100956],
|
||||
[94.9061206, 29.7112788],
|
||||
[94.9065005, 29.7113815],
|
||||
[94.9225743, 29.7192255],
|
||||
[94.9264023, 29.7212652],
|
||||
[94.9267629, 29.7213678],
|
||||
[94.9341474, 29.723469],
|
||||
[94.9360823, 29.7245705],
|
||||
[94.9357152, 29.7273333],
|
||||
[94.9344915, 29.7289804],
|
||||
[94.9323502, 29.7303263],
|
||||
[94.9298621, 29.73339],
|
||||
[94.9294542, 29.734665],
|
||||
[94.9298213, 29.7382244],
|
||||
[94.9306755, 29.739157],
|
||||
[94.9340021, 29.7439794],
|
||||
[94.9355331, 29.7464083],
|
||||
[94.9364901, 29.7488664],
|
||||
[94.9372115, 29.749423],
|
||||
[94.937775, 29.7498579],
|
||||
[94.939019, 29.7500527],
|
||||
[94.9407117, 29.7501944],
|
||||
[94.9429346, 29.7513629],
|
||||
[94.947778, 29.7543307],
|
||||
[94.9541921, 29.755984],
|
||||
[94.9574551, 29.7555414],
|
||||
[94.9603715, 29.7553644],
|
||||
[94.9629516, 29.755488],
|
||||
[94.9676317, 29.757489],
|
||||
[94.9709355, 29.7587814],
|
||||
[94.9737019, 29.7603278],
|
||||
[94.9738572, 29.7603796],
|
||||
[94.9844009, 29.7638941],
|
||||
[94.9852725, 29.7649069],
|
||||
[94.9861359, 29.7675568],
|
||||
[94.9898232, 29.7694497],
|
||||
[94.9917263, 29.7691954],
|
||||
[94.9966421, 29.7688099],
|
||||
[95.0005707, 29.7670075],
|
||||
[95.0007566, 29.7668999],
|
||||
[95.0028203, 29.7657056],
|
||||
[95.0080406, 29.7614995],
|
||||
[95.0092963, 29.7609572],
|
||||
[95.0138378, 29.7589957],
|
||||
[95.0160159, 29.7574362],
|
||||
[95.0194618, 29.7553902],
|
||||
[95.0222306, 29.7524106],
|
||||
[95.024336, 29.7498815],
|
||||
[95.0273932, 29.7487547],
|
||||
[95.0324981, 29.7493557],
|
||||
[95.0339552, 29.7496724],
|
||||
[95.0346257, 29.7499916],
|
||||
[95.035882, 29.7507411],
|
||||
[95.0375964, 29.7530966],
|
||||
[95.0386203, 29.754818],
|
||||
[95.0383895, 29.7567272],
|
||||
[95.038166, 29.7590432],
|
||||
[95.0395792, 29.7618349],
|
||||
[95.0411334, 29.7631575],
|
||||
[95.0420532, 29.7639402],
|
||||
[95.0467825, 29.7661735],
|
||||
[95.0495101, 29.7673453],
|
||||
[95.0510063, 29.7681933],
|
||||
[95.0516577, 29.7683919],
|
||||
[95.0560621, 29.7692331],
|
||||
[95.0577259, 29.7690047],
|
||||
[95.0588453, 29.7684695],
|
||||
[95.0650773, 29.767807],
|
||||
[95.0705737, 29.761782],
|
||||
[95.0720258, 29.7599696],
|
||||
[95.0736121, 29.7540856],
|
||||
[95.0754147, 29.751782],
|
||||
[95.0758224, 29.751042],
|
||||
[95.0760619, 29.750314],
|
||||
[95.0759483, 29.7495284],
|
||||
[95.0751984, 29.7480135],
|
||||
[95.0728911, 29.7466988],
|
||||
[95.0724585, 29.7445327],
|
||||
[95.0732804, 29.7429802],
|
||||
[95.0734102, 29.740789],
|
||||
[95.0731362, 29.7386729],
|
||||
[95.0744382, 29.7374902],
|
||||
[95.075294, 29.7367129],
|
||||
[95.079535, 29.7361346],
|
||||
[95.081745, 29.736994],
|
||||
[95.083005, 29.737484],
|
||||
[95.0833905, 29.7430745],
|
||||
[95.0844421, 29.7452715],
|
||||
[95.0871532, 29.7473749],
|
||||
[95.0918361, 29.7499864],
|
||||
[95.0931923, 29.7511573],
|
||||
[95.0943202, 29.7514189],
|
||||
[95.0965266, 29.7510558],
|
||||
[95.0991656, 29.7494408],
|
||||
[95.1029871, 29.7472998],
|
||||
[95.1038668, 29.7453591],
|
||||
[95.1063039, 29.7417406],
|
||||
[95.1084814, 29.741365],
|
||||
[95.111726, 29.7426671],
|
||||
[95.1138489, 29.7452914],
|
||||
[95.1152135, 29.7465013],
|
||||
[95.1154175, 29.7473556],
|
||||
[95.1158661, 29.7480506],
|
||||
[95.1167533, 29.7482586],
|
||||
[95.1179004, 29.7491572],
|
||||
[95.119434, 29.7515181],
|
||||
[95.1195997, 29.7517319],
|
||||
[95.1214023, 29.7535598],
|
||||
[95.1239692, 29.754987],
|
||||
[95.1260602, 29.7556005],
|
||||
[95.1284973, 29.7561388],
|
||||
[95.1311663, 29.7559002],
|
||||
[95.1323477, 29.7559385],
|
||||
[95.1344757, 29.7564723],
|
||||
[95.1350155, 29.7583672],
|
||||
[95.1382746, 29.7617848],
|
||||
[95.1400377, 29.7646815],
|
||||
[95.1410982, 29.7654339],
|
||||
[95.1437689, 29.7649144],
|
||||
[95.1469991, 29.7640131],
|
||||
[95.1503617, 29.7622343],
|
||||
[95.1506908, 29.7620602],
|
||||
[95.1532721, 29.7600322],
|
||||
[95.1565312, 29.7590432],
|
||||
[95.1584059, 29.7594063],
|
||||
[95.1589059, 29.7599317],
|
||||
[95.1595043, 29.7612399],
|
||||
[95.1611602, 29.7632119],
|
||||
[95.1635565, 29.7646136],
|
||||
[95.1700434, 29.7649144],
|
||||
[95.1730828, 29.7644136],
|
||||
[95.1748094, 29.7636814],
|
||||
[95.1765005, 29.7628339],
|
||||
[95.1794885, 29.7589784],
|
||||
[95.1830673, 29.7576787],
|
||||
[95.1848212, 29.7580682],
|
||||
[95.1869103, 29.7594603]
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# Flyover data sources
|
||||
|
||||
## Landscape
|
||||
|
||||
Use MapTiler for both layers:
|
||||
|
||||
- `terrain-quantized-mesh-v2`: elevation encoded as Cesium quantized-mesh terrain.
|
||||
- `satellite-v2`: raster satellite imagery draped on that mesh.
|
||||
|
||||
CesiumJS renders the layers; it does not supply the data. Set `REMOTION_MAPTILER_KEY`.
|
||||
|
||||
Create a key:
|
||||
|
||||
- https://cloud.maptiler.com/account/keys/
|
||||
|
||||
Official documentation:
|
||||
|
||||
- https://docs.maptiler.com/cesium/
|
||||
- https://docs.maptiler.com/schema-raster/terrain-3d/
|
||||
|
||||
## City
|
||||
|
||||
Use Google Photorealistic 3D Tiles. Google supplies one high-resolution 3D mesh already textured
|
||||
with imagery. Disable the Cesium globe and do not add MapTiler terrain or satellite beneath it. Set
|
||||
`REMOTION_GOOGLE_MAPS_API_KEY`.
|
||||
|
||||
Create and configure a key:
|
||||
|
||||
- https://developers.google.com/maps/documentation/tile/get-api-key
|
||||
|
||||
Enable the Map Tiles API in a billing-enabled Google Cloud project and restrict the key to that API.
|
||||
The application restriction must permit the local headless Remotion request.
|
||||
|
||||
Official documentation:
|
||||
|
||||
- https://developers.google.com/maps/documentation/tile/3d-tiles
|
||||
- https://developers.google.com/maps/documentation/tile/policies
|
||||
|
||||
## Why not extruded buildings
|
||||
|
||||
OSM-, Overture- and vector-tile building products primarily provide footprints, approximate heights
|
||||
and optional roof attributes. They are useful for analytical or stylized maps, but they do not
|
||||
provide the textured architecture required for a cinematic city flyover.
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
# 3D Flyover — architecture reference
|
||||
|
||||
Deep detail behind `TECHNIQUE.md`: provider loading, the camera-path pipeline, per-frame camera math, and
|
||||
the proven terrain values. Both landscape and city modes have been forward-tested through Remotion.
|
||||
|
||||
## 1. Provider initialization
|
||||
|
||||
Create the Viewer with `baseLayer: false`, UI widgets disabled, and
|
||||
`contextOptions.webgl.preserveDrawingBuffer: true`. Never hide the credit display.
|
||||
|
||||
### Landscape
|
||||
|
||||
Add MapTiler `satellite-v2` with `UrlTemplateImageryProvider`, then load
|
||||
`terrain-quantized-mesh-v2` with `CesiumTerrainProvider.fromUrl({requestVertexNormals: true})`.
|
||||
MapTiler supplies both datasets; no Cesium ion token is required.
|
||||
|
||||
### City
|
||||
|
||||
Do not add MapTiler. Hide the globe, then add:
|
||||
|
||||
```ts
|
||||
viewer.scene.globe.show = false;
|
||||
const tileset = await Cesium.Cesium3DTileset.fromUrl(
|
||||
`https://tile.googleapis.com/v1/3dtiles/root.json?key=${GOOGLE_KEY}`,
|
||||
{showCreditsOnScreen: true, maximumScreenSpaceError: 4},
|
||||
);
|
||||
viewer.scene.primitives.add(tileset);
|
||||
```
|
||||
|
||||
Lower `maximumScreenSpaceError` improves refinement at substantial download/render cost. Start at
|
||||
`4` for a hero landmark and `6–8` for wider urban shots. Enforce the Google 30-second promotional
|
||||
video ceiling in the component.
|
||||
|
||||
Load Cesium from the CDN after setting `window.CESIUM_BASE_URL`; the tested version is `1.143`.
|
||||
|
||||
## 2. The camera path — structure & generation
|
||||
|
||||
Four properties matter, in order:
|
||||
|
||||
1. **Continuous curvature** — the camera must flow through curves, never "fly straight, snap to a new
|
||||
heading, fly straight." The component applies three passes of **Chaikin corner cutting** to every
|
||||
supplied path. Each pass replaces a segment with quarter and three-quarter points, rounding a
|
||||
corner into a curve. Three passes are the default; four is softer, two is tighter.
|
||||
2. **Constant ground speed** — precompute cumulative distance along the rounded curve and interpolate
|
||||
by arc length. Do not animate by source-point index; unequal source spacing creates speed bumps.
|
||||
3. **Minimized amplitude** — for detailed landscape centerlines, dampen the prepared path toward its
|
||||
straight start→end chord by a fixed fraction `DAMP` (0 = dead straight, 1 = full river). This is
|
||||
the single swerve-amplitude knob.
|
||||
4. **Enough length** — `PATHKM ≥ TRAVEL_KM + 2·LOOK_AHEAD_KM` so the look-ahead aim never clamps.
|
||||
|
||||
`../scripts/prep-cesium-path.mjs` does: clip a window of the source centerline → resample to even
|
||||
arc-length spacing (0.1 km) → moving-average smooth (±2.8 km window, 2 passes) → dampen toward the chord
|
||||
(`DAMP=0.45`). Validate with the heading-delta probe it prints — deltas should be small and change
|
||||
_gradually_ (water-wars: `3,0,-1,-3,-4,1,7,9,5,-5,-12,-7,…`). Big jumps = corners = bad.
|
||||
|
||||
The component then applies Chaikin smoothing to this prepared route, or directly to a short
|
||||
hand-authored city route. Keep city control points sparse and intentional; smoothing cannot rescue a
|
||||
zig-zagging route that crosses the subject repeatedly.
|
||||
|
||||
**Source data:** OSM via Overpass (~0.3 km vertex spacing) — Natural Earth is too coarse for inner
|
||||
gorges. overpass-api.de is often busy → mirror `overpass.kumi.systems`.
|
||||
|
||||
## 3. Camera animation per frame (position, heading, pitch, bank)
|
||||
|
||||
Walk the path by **arc length** (precompute cumulative distances once). Every frame:
|
||||
|
||||
- **Position** = the point at `dCam` km along the path, altitude `lerp(ALT_START, ALT_END, prog)`.
|
||||
- **Heading** = bearing from the camera point to a **real point `LOOK_AHEAD_KM` further along the same
|
||||
path**. A far aim averages wiggle → smooth heading; on a curved path it leads into the bend, so the
|
||||
heading turns gently with the path. (A local-tangent aim spins the camera at every kink — don't.)
|
||||
- **Pitch** = constant. We keep a MapLibre-style param `PITCH_FROM_NADIR` (90 = horizon), then convert:
|
||||
**Cesium pitch = `-(90 - PITCH_FROM_NADIR)`** (Cesium: 0 = horizon, -90 = straight down). 76° → -14°.
|
||||
- **Bank (roll)** = lean _into_ the turn — the helicopter tell. Measure turn rate as the bearing change
|
||||
between `aim` and a point `2·LOOK_AHEAD_KM` ahead; `roll = clamp(dH · BANK_GAIN, ±MAX_BANK)`. Because
|
||||
the path is smooth, `dH` changes gradually → the bank eases in and out, never jerks.
|
||||
|
||||
```ts
|
||||
const setCamera = (C, viewer, prog) => {
|
||||
const dCam = Math.min(TRAVEL_KM, PATHKM - LOOK_AHEAD_KM * 2) * prog;
|
||||
const cam = alongPath(dCam); // arc-length point
|
||||
const aim = alongPath(dCam + LOOK_AHEAD_KM); // heading target (real point on the path)
|
||||
const aim2 = alongPath(dCam + LOOK_AHEAD_KM * 2); // turn-rate probe → bank
|
||||
const heading = bearing(cam, aim);
|
||||
let dH = bearing(aim, aim2) - heading; while (dH > Math.PI) dH -= 2*Math.PI; while (dH < -Math.PI) dH += 2*Math.PI;
|
||||
viewer.camera.setView({
|
||||
destination: C.Cartesian3.fromDegrees(cam[0], cam[1], lerp(ALT_START, ALT_END, prog)),
|
||||
orientation: { heading, pitch: C.Math.toRadians(-(90 - PITCH_FROM_NADIR)), roll: clamp(dH * BANK_GAIN, -MAX_BANK, MAX_BANK) },
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
> **Cesium vs MapLibre conventions (gotcha):** Cesium heading is radians, 0 = north, clockwise. Pitch
|
||||
> 0 = horizon, negative = down (MapLibre is the inverse). Roll positive = bank right; tune the sign by eye.
|
||||
|
||||
## 4. The feel — proven water-wars values
|
||||
|
||||
| Param | Value | Meaning |
|
||||
| ------------------------ | ---------------------- | ------------------------------------------------------------------------------------ |
|
||||
| `TRAVEL_KM` | 13 | How far the camera travels. **Speed = `TRAVEL_KM / durationSeconds`.** |
|
||||
| duration | 24 s (720 f @30) | 13 km / 24 s ≈ **0.54 km/s** — a slow, peaceful glide. 8 s felt "extremely rushed". |
|
||||
| `ALT_START → ALT_END` | 4600 → 4300 m ASL | Absolute (terrain-independent). Inside the corridor walls → fly _through_, not over. |
|
||||
| `LOOK_AHEAD_KM` | 1.5 | Heading smoothness vs responsiveness. |
|
||||
| `PITCH_FROM_NADIR` | 76° | Stare-ahead down the corridor (90 = level). → Cesium -14°. |
|
||||
| `MAX_BANK` / `BANK_GAIN` | 0.13 rad (~7.5°) / 0.6 | Helicopter lean into turns. |
|
||||
| `verticalExaggeration` | 1.1 | Subtle terrain drama. |
|
||||
| `DAMP` (prep) | 0.45 | Swerve amount: higher = weavier, lower = straighter. |
|
||||
|
||||
**A slow camera renders fast.** At 0.54 km/s the camera moves ~18 m/frame, so tiles stay cached and each
|
||||
`settle()` returns almost immediately; the 720-frame render completed in one pass (no chunk-rendering).
|
||||
|
||||
## 5. The complete component
|
||||
|
||||
The full, runnable component is `../assets/CesiumFlythrough.tsx` — read it directly. Its shape:
|
||||
|
||||
- `loadCesium()` — inject `CESIUM_BASE_URL` + the CDN `Cesium.js`, resolve when loaded.
|
||||
- init effect — build the Viewer (§1), `setCamera(…, 0)`, `await settle(viewer)`, `continueRender`.
|
||||
- `settle(viewer)` — loop `viewer.render()` until `globe.tilesLoaded` for landscapes or
|
||||
`tileset.tilesLoaded` for cities is stable for ~8 ticks (cap ~600).
|
||||
- per-frame effect — `delayRender({timeoutInMilliseconds: 60000})` → `setCamera(prog)` → `settle()` → `continueRender`.
|
||||
|
||||
## 6. Render
|
||||
|
||||
```bash
|
||||
bunx remotion still src/index.ts <Comp> out.png --frame=N --gl=angle --timeout=180000 # validate framing/bank first
|
||||
bunx remotion render src/index.ts <Comp> out.mp4 --gl=angle --concurrency=1 --timeout=180000
|
||||
```
|
||||
|
||||
`--gl=angle` is mandatory. Use `--concurrency=1`; `settle()` already serializes tile loading.
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
# 3D Flyover — troubleshooting
|
||||
|
||||
## The headless dead-end (why we render through Remotion)
|
||||
|
||||
Cesium's globe **will not draw in a standalone headless Playwright/Chromium** harness. Verified on
|
||||
Apple M4 (ANGLE Metal active, WebGL working): the skybox/stars render, but the globe surface produces
|
||||
**zero draw commands** (`scene.frameState.commandList.length === 0`), `globe.tilesLoaded` never goes
|
||||
true, and frames come back as the black starfield. No network failures; `sampleTerrainMostDetailed`
|
||||
succeeds (terrain data is reachable). Dead-ends tried, all failed:
|
||||
|
||||
- default render loop, manual `scene.render()`, manual `viewer.render()`, headed mode (context-destroyed).
|
||||
|
||||
**What works:** render Cesium **through Remotion** — same headless Chrome, but driven by Remotion's frame
|
||||
loop with these four non-negotiables:
|
||||
|
||||
1. `useDefaultRenderLoop = false` — drive frames by hand.
|
||||
2. Per frame call **`viewer.render()`**, NOT `scene.render()`. `viewer.render()` does the full frame
|
||||
(`initializeFrame` → tile streaming → render); `scene.render()` skips frame-init, so tiles never
|
||||
advance and the globe never appears. **This is the single most important line.**
|
||||
3. `contextOptions: { webgl: { preserveDrawingBuffer: true } }` so Remotion's screenshot captures pixels.
|
||||
4. Gate init + every frame with `delayRender(…, {timeoutInMilliseconds})` — tile loading can exceed Remotion's
|
||||
default; use 60–120 s.
|
||||
|
||||
The standalone `flythrough.html` / `render.mjs` / `probe.mjs` from the original spike are kept only as the
|
||||
record of this dead-end. The canonical render path is the Remotion component
|
||||
(`../assets/CesiumFlythrough.tsx`).
|
||||
|
||||
## Symptom → fix
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| Frames are black with stars | Globe not drawing (headless Playwright, or `scene.render()` used) | Render through Remotion; use `viewer.render()`; `useDefaultRenderLoop=false`. |
|
||||
| Screenshots blank/transparent | No `preserveDrawingBuffer` | `contextOptions:{ webgl:{ preserveDrawingBuffer:true } }`. |
|
||||
| "delayRender timed out" | Cold tiles exceed the default | `delayRender(…, {timeoutInMilliseconds: 120000})` + `--timeout=180000`. |
|
||||
| Globe is a dark/navy sphere | Imagery layer didn't attach | `baseLayer:false` then `viewer.imageryLayers.addImageryProvider(...)`. |
|
||||
| High-pitch frame shows a void/starfield above the horizon | No atmosphere | `viewer.scene.skyAtmosphere.show = true`. |
|
||||
| 403 on tiles in headless | Domain-locked MapTiler key | Use an **unrestricted** key. |
|
||||
| Google root tileset returns 403 | Map Tiles API disabled, billing absent, wrong key, or application restriction blocks local headless rendering | Enable Map Tiles API and billing; restrict the key to that API while allowing the Remotion request. |
|
||||
| Google scene shows a duplicate/competing surface | MapTiler or the Cesium globe is still enabled | Do not add MapTiler; set `viewer.scene.globe.show=false`. |
|
||||
| Google mesh remains coarse | Screen-space error is too high or the capture starts before refinement | Lower `maximumScreenSpaceError`; settle on `tileset.tilesLoaded`. |
|
||||
| WebGL unavailable / software renderer | Missing GL flag | Render with `--gl=angle`. |
|
||||
| Camera looks at sky / ground, not terrain | Pitch sign / convention | Cesium pitch 0 = horizon, negative = down (inverse of MapLibre); `-(90 - PITCH_FROM_NADIR)`. |
|
||||
| Aim/turn-probe clamps near the end | Path too short | `PATHKM ≥ TRAVEL_KM + 2·LOOK_AHEAD_KM`; raise `WINDOW_KM` in prep. |
|
||||
| Path feels like straight-then-corner | Douglas-Peucker simplification | Use resample → moving-average smooth (see architecture §2), not `turf.simplify`. |
|
||||
| Camera bumps left and right instead of swerving | Sparse route vertices are still being followed as straight segments | Keep `pathSmoothingPasses={3}`; use sparse intentional control points and arc-length movement. |
|
||||
|
||||
## Gotchas checklist
|
||||
|
||||
- **`viewer.render()`, never `scene.render()`** per frame. The single biggest trap.
|
||||
- Cesium loads from **CDN**; set `window.CESIUM_BASE_URL` _before_ injecting the script.
|
||||
- `preserveDrawingBuffer: true` or screenshots are blank.
|
||||
- `delayRender` uses `timeoutInMilliseconds`; set it to at least 60,000.
|
||||
- Terrain: `baseLayer:false`, then add MapTiler imagery.
|
||||
- Google: no MapTiler, hide the globe, retain `showCreditsOnScreen:true`.
|
||||
- Always `skyAtmosphere.show = true`.
|
||||
- Validate the path's heading-delta probe and render one **still** (framing + bank) before the full mp4.
|
||||
- Cesium pitch/roll conventions are inverted vs MapLibre.
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
// Camera-path generator for the Cesium flythrough. Turns a river/route centerline GeoJSON into a LONG,
|
||||
// CONTINUOUSLY-curving camera path so the camera banks through smooth flowing curves (no
|
||||
// straight-then-corner). Method: clip → resample to even spacing → moving-average smooth (inherently
|
||||
// continuous curvature) → dampen lateral deviation toward the straight chord (dials swerve amplitude).
|
||||
// No Douglas-Peucker (that concentrates curvature at sparse control points → corners).
|
||||
//
|
||||
// RUN (out of the box, against the shipped sample):
|
||||
// node prep-cesium-path.mjs
|
||||
// → reads assets/sample-river.geojson (override: node prep-cesium-path.mjs <input.geojson> <output.json>)
|
||||
// → writes assets/cesium-path.json (then import that JSON in CesiumFlythrough.tsx, or copy it
|
||||
// into your Remotion project's src/geo/ and adjust the import)
|
||||
//
|
||||
// ADAPT for a new location: change START (a point ON your centerline where the corridor opens),
|
||||
// WINDOW_KM, and DAMP/SMOOTH below. Input must be a single LineString feature (features[0].geometry).
|
||||
|
||||
import {readFileSync, writeFileSync, mkdirSync} from 'fs';
|
||||
import {dirname, resolve} from 'path';
|
||||
import {fileURLToPath} from 'url';
|
||||
|
||||
const __dir = dirname(fileURLToPath(import.meta.url));
|
||||
const IN = process.argv[2] || resolve(__dir, '../assets/sample-river.geojson');
|
||||
const OUT = process.argv[3] || resolve(__dir, '../assets/cesium-path.json');
|
||||
const havKm = (a, b) => {
|
||||
const R = 6371,
|
||||
r = Math.PI / 180,
|
||||
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));
|
||||
};
|
||||
|
||||
const gorge = JSON.parse(readFileSync(IN, 'utf8')).features[0].geometry
|
||||
.coordinates;
|
||||
|
||||
// ADAPT: clip ~24 km of river from the reach where the flythrough opens. START must be a point ON the
|
||||
// centerline (the script snaps to the nearest vertex). The sample's opening is the Yarlung gorge:
|
||||
const START = [94.968, 29.757];
|
||||
let s0 = 0,
|
||||
best = Infinity;
|
||||
gorge.forEach((p, i) => {
|
||||
const d = havKm(p, START);
|
||||
if (d < best) {
|
||||
best = d;
|
||||
s0 = i;
|
||||
}
|
||||
});
|
||||
const WINDOW_KM = 30; // clip to ~end of gorge data; smoothing+dampening shrink it to the usable corridor
|
||||
const clip = [];
|
||||
for (let i = s0, acc = 0; i < gorge.length; i++) {
|
||||
if (i > s0) acc += havKm(gorge[i - 1], gorge[i]);
|
||||
if (acc > WINDOW_KM) break;
|
||||
clip.push(gorge[i]);
|
||||
}
|
||||
|
||||
// Resample to even arc-length spacing so curvature is distributed evenly along the path.
|
||||
const STEP_KM = 0.1;
|
||||
const resample = (coords) => {
|
||||
const out = [coords[0].slice()];
|
||||
let carry = 0,
|
||||
from = coords[0];
|
||||
for (let i = 1; i < coords.length; i++) {
|
||||
let segLen = havKm(from, coords[i]);
|
||||
while (carry + segLen >= STEP_KM) {
|
||||
const t = (STEP_KM - carry) / segLen;
|
||||
const np = [
|
||||
from[0] + (coords[i][0] - from[0]) * t,
|
||||
from[1] + (coords[i][1] - from[1]) * t,
|
||||
];
|
||||
out.push(np);
|
||||
from = np;
|
||||
segLen = havKm(from, coords[i]);
|
||||
carry = 0;
|
||||
}
|
||||
carry += segLen;
|
||||
from = coords[i];
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// Moving-average smoothing — inherently continuous (no kinks). Window in points; repeat for extra glass.
|
||||
const smoothMA = (coords, w, passes) => {
|
||||
let c = coords;
|
||||
for (let p = 0; p < passes; p++) {
|
||||
c = c.map((_, i) => {
|
||||
let sx = 0,
|
||||
sy = 0,
|
||||
n = 0;
|
||||
for (
|
||||
let j = Math.max(0, i - w);
|
||||
j <= Math.min(c.length - 1, i + w);
|
||||
j++
|
||||
) {
|
||||
sx += c[j][0];
|
||||
sy += c[j][1];
|
||||
n++;
|
||||
}
|
||||
return [sx / n, sy / n];
|
||||
});
|
||||
}
|
||||
return c;
|
||||
};
|
||||
|
||||
const SMOOTH_W = 28; // ±2.8 km window — turns meanders into smooth flowing curves
|
||||
const SMOOTH_PASSES = 2;
|
||||
const DAMP = 0.45; // keep 45% of the (already-smooth) deviation → gentle, continuous swerve
|
||||
|
||||
const even = resample(clip);
|
||||
const sm = smoothMA(even, SMOOTH_W, SMOOTH_PASSES);
|
||||
|
||||
const lat0 = (sm[0][1] * Math.PI) / 180;
|
||||
const kx = 111.32 * Math.cos(lat0),
|
||||
ky = 110.57;
|
||||
const toXY = (p) => [(p[0] - sm[0][0]) * kx, (p[1] - sm[0][1]) * ky];
|
||||
const toLL = (xy) => [sm[0][0] + xy[0] / kx, sm[0][1] + xy[1] / ky];
|
||||
const A = toXY(sm[0]),
|
||||
B = toXY(sm[sm.length - 1]);
|
||||
const AB = [B[0] - A[0], B[1] - A[1]],
|
||||
len2 = AB[0] ** 2 + AB[1] ** 2;
|
||||
const path = sm.map((p) => {
|
||||
const P = toXY(p);
|
||||
const t = ((P[0] - A[0]) * AB[0] + (P[1] - A[1]) * AB[1]) / len2;
|
||||
const proj = [A[0] + t * AB[0], A[1] + t * AB[1]];
|
||||
return toLL([
|
||||
proj[0] + (P[0] - proj[0]) * DAMP,
|
||||
proj[1] + (P[1] - proj[1]) * DAMP,
|
||||
]);
|
||||
});
|
||||
|
||||
mkdirSync(dirname(OUT), {recursive: true});
|
||||
writeFileSync(OUT, JSON.stringify(path));
|
||||
|
||||
let len = 0;
|
||||
for (let i = 1; i < path.length; i++) len += havKm(path[i - 1], path[i]);
|
||||
console.log(
|
||||
`cesium-path: clip ${clip.length} → resample ${even.length} → smooth → ${path.length} pts · ${len.toFixed(1)} km`,
|
||||
);
|
||||
const bear = (a, b) => {
|
||||
const r = Math.PI / 180;
|
||||
const y = Math.sin((b[0] - a[0]) * r) * Math.cos(b[1] * r);
|
||||
const x =
|
||||
Math.cos(a[1] * r) * Math.sin(b[1] * r) -
|
||||
Math.sin(a[1] * r) * Math.cos(b[1] * r) * Math.cos((b[0] - a[0]) * r);
|
||||
return (Math.atan2(y, x) * 180) / Math.PI;
|
||||
};
|
||||
// heading sampled every ~1.5 km — should change gradually & continuously (no big jumps = no corners)
|
||||
const stepPts = Math.round(1.5 / STEP_KM);
|
||||
let prev = null,
|
||||
hs = [];
|
||||
for (let i = 0; i + stepPts < path.length; i += stepPts) {
|
||||
const h = bear(path[i], path[i + stepPts]);
|
||||
if (prev !== null) {
|
||||
let d = h - prev;
|
||||
while (d > 180) d -= 360;
|
||||
while (d < -180) d += 360;
|
||||
hs.push(d.toFixed(0));
|
||||
}
|
||||
prev = h;
|
||||
}
|
||||
console.log(` heading deltas every 1.5km (deg): ${hs.join(', ')}`);
|
||||
Reference in New Issue
Block a user