Skip to content

Commit 1d3a78f

Browse files
feat(flight-simulator): interactive free-flight camera over terrain and 3D layers (#1455)
* feat(flight-simulator): interactive free-flight camera over terrain (#1454) Every camera path in GeoLibre was scripted and discrete — `flyTo` from Set View, story-map chapters, the keyframe Camera Tour recorder. This adds the continuous one: an aircraft state integrated each animation frame, with the MapLibre camera placed to match, so the user steers instead of declaring a destination. MapLibre has no `setFreeCameraOptions` (a Mapbox API added after the fork). Its equivalent is `calculateCameraOptionsFromCameraLngLatAltRotation()`, which converts camera lng/lat/altitude plus orientation into the `CameraOptions` that `jumpTo` accepts. That conversion divides by `cos(pitch)`, so a pitch of exactly 90° (level with the horizon) sends the derived center and zoom to infinity — the camera is capped just short of it and level flight looks slightly downward. - `flight-simulator-physics.ts` is map-free: a coordinated-turn model (bank drives turn rate, nose attitude drives climb rate, throttle drives airspeed), unit-tested without a MapLibre instance or a GPU. - `flight-simulator.ts` owns the map while flying: the rAF loop, held-key tracking, terrain collision via `queryTerrainElevation`, and restoring the pitch ceiling, center clamping, and all eight interaction handlers on exit. MapLibre's own `keyboard` handler must be suspended or it double-handles the arrow keys. - Camera jumps carry a `flightCameraToken` so the viewport history, the store's view sync, and collaboration presence skip them — following the story presenter's existing `storyCameraToken` precedent. Without it a flight buries the back/forward stack under ~60 entries a second and overwrites the project's saved view. - HUD with airspeed, altitude MSL/AGL, heading, an artificial horizon, and a terrain warning; imperial or metric. Settings persist with the project, but never in a flying state — seizing the camera and keyboard is an explicit user action. Verified against real terrain over Mount Rainier: the DEM reads 4,379 m at the summit against a true 4,392 m (0.3%), airspeed settles at exactly the modelled 162 m/s at 60% throttle, bank pins at the 70° limit and self-levels at 40°/s, and the terrain floor holds the aircraft at the configured 25 m AGL. Translated into all 15 non-English catalogs. * Address CodeRabbit review feedback - flight-simulator: bail out of `tick()` on `!running` and re-check before re-arming the rAF loop. `publishHud` notifies subscribers synchronously, so a `stop()` re-entered from a listener cancels a `rafId` that `tick()` has already nulled — the cancel was a no-op and the loop kept driving the camera after the map had been handed back. - flight-simulator: reset the throttle to the cruise default in `start()`. Every other per-flight field was re-seeded, so a flight that ended at full power restarted showing full throttle while the airspeed had been re-seeded to idle. - flight-simulator: `clampNumber` now coerces only numbers and non-empty numeric strings. `Number(null)`, `Number("")` and `Number([])` are all 0 — finite, so those persisted values clamped to the range minimum instead of falling back to the documented default. - flight-simulator: `reattachFlightSimulator` is a no-op when the map instance is unchanged. Rebinding tears the engine down and hands the map back, ending a live flight; reattach exists to bind to a *new* map, so keying on map identity is the precise condition. (CodeRabbit's premise that remote collaboration drives this was wrong — collab snapshots call `applyProjectToStore`, which does not bump `projectGeneration`; only `newProject`/`loadProject` do. The guard is still worth having for those.) - useViewportHistory: clear the pending restore count when a flight `moveend` arrives, so a restore ease that a flight frame interrupted cannot leave a counter behind to swallow the next ordinary `moveend`. Adds 5 tests. The three engine cases were each checked against a reverted fix to confirm they actually fail without it. Plugin line coverage 91.9% -> 97.3%. * fix(flight-simulator): improve terrain handling and stability * style: auto-format (ruff + oxfmt) [pre-commit.ci] * test(flight-simulator): cover missing map reattachment * feat(flight-simulator): enable terrain during flight * style: auto-format (ruff + oxfmt) [pre-commit.ci] * Address CodeRabbit review feedback - Compare invalid-terrain output by value to catch input mutation. - Assert replacement-map teardown begins from an active flight. * Address CodeRabbit review feedback - preserve pre-existing custom terrain during flight mode - add regression coverage for custom terrain sources * Align flight controls with Google Earth - make Page Up and Page Down respond to taps and holds - match Google Earth arrow-key pitch direction - add regression coverage for throttle and pitch controls --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 9257b8a commit 1d3a78f

32 files changed

Lines changed: 3244 additions & 4 deletions

apps/geolibre-desktop/src/components/layout/DesktopShell.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
restorePlanetaryComputerLayers,
2323
reattachSun,
2424
reattachRouteAnimation,
25+
reattachFlightSimulator,
2526
restoreRasterLayers,
2627
restoreThreeDTilesLayers,
2728
restoreVectorLayers,
@@ -125,6 +126,7 @@ import { LayerPanel } from "../panels/LayerPanel";
125126
import { FloatingPanels } from "../panels/FloatingPanels";
126127
import { SunPanel } from "../panels/SunPanel";
127128
import { RouteAnimationPanel } from "../panels/RouteAnimationPanel";
129+
import { FlightSimulatorPanel } from "../panels/FlightSimulatorPanel";
128130
import {
129131
PluginRightPanel,
130132
PLUGIN_PANEL_DEFAULT_WIDTH,
@@ -1056,6 +1058,9 @@ export function DesktopShell({
10561058
// to the (possibly new) map after a re-init/basemap swap without deriving
10571059
// open/closed state (project loads handle that via applyProjectState).
10581060
reattachRouteAnimation(appAPI);
1061+
// The flight simulator holds a reference to the live map (and suspends its
1062+
// interaction handlers while flying), so rebind it after a map re-init too.
1063+
reattachFlightSimulator(appAPI);
10591064
// Rebind the directions tool to the (possibly new) map instance after a
10601065
// map re-init, since restoreProjectState skips an already-active plugin.
10611066
restoreDirections(appAPI, pluginManager.isActive(DIRECTIONS_PLUGIN_ID));
@@ -2046,6 +2051,12 @@ export function DesktopShell({
20462051
>
20472052
<RouteAnimationPanel mapControllerRef={mapControllerRef} />
20482053
</SectionErrorBoundary>
2054+
<SectionErrorBoundary
2055+
label="Flight simulator panel"
2056+
displayName={t("shell.section.flightSimulatorPanel")}
2057+
>
2058+
<FlightSimulatorPanel />
2059+
</SectionErrorBoundary>
20492060
<KnowledgeCardConsentDialog
20502061
open={knowledgeNoticeOpen}
20512062
onOpenChange={(open) => {

apps/geolibre-desktop/src/components/layout/toolbar/ControlsMenu.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ export function ControlsMenu({
145145
show("controls.graticule") ||
146146
show("controls.sun") ||
147147
show("controls.routeAnimation") ||
148+
show("controls.flightSimulator") ||
148149
show("controls.directions") ||
149150
show("controls.reverseGeocode");
150151
// Whether the middle group (panels) has any visible item. The separator that
@@ -227,6 +228,15 @@ export function ControlsMenu({
227228
{panels.routeAnimation.visible ? " ✓" : ""}
228229
</DropdownMenuItem>
229230
)}
231+
{show("controls.flightSimulator") && (
232+
<DropdownMenuItem
233+
title={t("toolbar.item.flightSimulatorTooltip")}
234+
onSelect={panels.flightSimulator.toggle}
235+
>
236+
{t("toolbar.item.flightSimulator")}
237+
{panels.flightSimulator.visible ? " ✓" : ""}
238+
</DropdownMenuItem>
239+
)}
230240
{show("controls.spinGlobe") && (
231241
<DropdownMenuItem onSelect={handleSpinGlobe}>
232242
{t("toolbar.item.spinGlobe")}
Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
import {
2+
FEET_PER_METER,
3+
FLIGHT_MAX_SPEED_MAX,
4+
FLIGHT_MAX_SPEED_MIN,
5+
FLIGHT_MIN_AGL_MAX,
6+
FLIGHT_MIN_AGL_MIN,
7+
KNOTS_PER_MPS,
8+
closeFlightSimulatorPanel,
9+
compassPoint,
10+
getFlightHudSnapshot,
11+
getFlightSimulatorSnapshot,
12+
isFlightSimulatorPanelVisible,
13+
setFlightSimulatorSettings,
14+
subscribeFlightHud,
15+
subscribeFlightSimulatorPanel,
16+
toggleFlying,
17+
} from "@geolibre/plugins";
18+
import { Button, Select, Slider } from "@geolibre/ui";
19+
import { ChevronDown, ChevronUp, Pause, Plane, Play, TriangleAlert, X } from "lucide-react";
20+
import { type PointerEvent as ReactPointerEvent, useState, useSyncExternalStore } from "react";
21+
import { useTranslation } from "react-i18next";
22+
import { clamp } from "../../lib/clamp";
23+
24+
const PANEL_WIDTH = 320;
25+
const EDGE_MARGIN = 12;
26+
27+
/**
28+
* Flight Simulator control panel and heads-up display (Controls → Flight
29+
* Simulator).
30+
*
31+
* The plugin engine owns every piece of map work — the animation loop, the
32+
* keyboard, and the camera — so this component only renders its published HUD
33+
* state and writes settings back. It subscribes to two separate stores because
34+
* they change at very different rates: the instrument readout refreshes ten
35+
* times a second while flying, the settings only when the user edits them.
36+
*/
37+
export function FlightSimulatorPanel() {
38+
const visible = useSyncExternalStore(
39+
subscribeFlightSimulatorPanel,
40+
isFlightSimulatorPanelVisible,
41+
isFlightSimulatorPanelVisible,
42+
);
43+
if (!visible) return null;
44+
return <FlightSimulatorCard />;
45+
}
46+
47+
function FlightSimulatorCard() {
48+
const { t } = useTranslation();
49+
const settings = useSyncExternalStore(
50+
subscribeFlightSimulatorPanel,
51+
getFlightSimulatorSnapshot,
52+
getFlightSimulatorSnapshot,
53+
);
54+
const hud = useSyncExternalStore(subscribeFlightHud, getFlightHudSnapshot, getFlightHudSnapshot);
55+
const [collapsed, setCollapsed] = useState(false);
56+
const [position, setPosition] = useState(() => ({ x: EDGE_MARGIN, y: EDGE_MARGIN }));
57+
58+
const imperial = settings.units === "imperial";
59+
const speed = imperial ? hud.airspeedMps * KNOTS_PER_MPS : hud.airspeedMps * 3.6;
60+
const speedUnit = imperial ? t("toolbar.flightSim.knots") : t("toolbar.flightSim.kph");
61+
const altitude = imperial ? hud.altitudeMeters * FEET_PER_METER : hud.altitudeMeters;
62+
const agl = imperial ? hud.aglMeters * FEET_PER_METER : hud.aglMeters;
63+
const altitudeUnit = imperial ? t("toolbar.flightSim.feet") : t("toolbar.flightSim.meters");
64+
65+
const handleDragStart = (event: ReactPointerEvent<HTMLDivElement>) => {
66+
if ((event.target as HTMLElement).closest("button,input,select")) return;
67+
event.preventDefault();
68+
const handle = event.currentTarget;
69+
handle.setPointerCapture(event.pointerId);
70+
const startX = event.clientX;
71+
const startY = event.clientY;
72+
const origin = position;
73+
const handleMove = (move: PointerEvent) => {
74+
const card = handle.parentElement;
75+
const bounds = card?.parentElement?.getBoundingClientRect();
76+
const cardHeight = card?.getBoundingClientRect().height ?? 80;
77+
const maxX = Math.max(
78+
EDGE_MARGIN,
79+
(bounds?.width ?? window.innerWidth) - PANEL_WIDTH - EDGE_MARGIN,
80+
);
81+
const maxY = Math.max(
82+
EDGE_MARGIN,
83+
(bounds?.height ?? window.innerHeight) - cardHeight - EDGE_MARGIN,
84+
);
85+
setPosition({
86+
x: clamp(origin.x + (move.clientX - startX), EDGE_MARGIN, maxX),
87+
y: clamp(origin.y + (move.clientY - startY), EDGE_MARGIN, maxY),
88+
});
89+
};
90+
const handleUp = () => {
91+
handle.releasePointerCapture(event.pointerId);
92+
handle.removeEventListener("pointermove", handleMove);
93+
handle.removeEventListener("pointerup", handleUp);
94+
handle.removeEventListener("pointercancel", handleUp);
95+
};
96+
handle.addEventListener("pointermove", handleMove);
97+
handle.addEventListener("pointerup", handleUp);
98+
handle.addEventListener("pointercancel", handleUp);
99+
};
100+
101+
return (
102+
<div
103+
className="absolute z-30 rounded-lg border border-border bg-background/95 shadow-lg backdrop-blur"
104+
style={{ left: position.x, top: position.y, width: PANEL_WIDTH }}
105+
role="dialog"
106+
aria-label={t("toolbar.flightSim.title")}
107+
>
108+
<div
109+
className="flex cursor-grab items-center gap-2 rounded-t-lg border-b border-border bg-muted/40 px-3 py-2 active:cursor-grabbing"
110+
onPointerDown={handleDragStart}
111+
>
112+
<Plane className="h-4 w-4 text-sky-500" />
113+
<span className="text-sm font-medium">{t("toolbar.flightSim.title")}</span>
114+
<Button
115+
variant={hud.flying ? "default" : "outline"}
116+
size="icon"
117+
className="ms-auto h-6 w-6"
118+
aria-label={hud.flying ? t("toolbar.flightSim.stop") : t("toolbar.flightSim.start")}
119+
title={hud.flying ? t("toolbar.flightSim.stop") : t("toolbar.flightSim.start")}
120+
onClick={() => toggleFlying()}
121+
>
122+
{hud.flying ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5" />}
123+
</Button>
124+
<Button
125+
variant="ghost"
126+
size="icon"
127+
className="h-6 w-6"
128+
aria-expanded={!collapsed}
129+
aria-label={collapsed ? t("toolbar.flightSim.expand") : t("toolbar.flightSim.collapse")}
130+
title={collapsed ? t("toolbar.flightSim.expand") : t("toolbar.flightSim.collapse")}
131+
onClick={() => setCollapsed((value) => !value)}
132+
>
133+
{collapsed ? (
134+
<ChevronDown className="h-3.5 w-3.5" />
135+
) : (
136+
<ChevronUp className="h-3.5 w-3.5" />
137+
)}
138+
</Button>
139+
<Button
140+
variant="ghost"
141+
size="icon"
142+
className="h-6 w-6"
143+
aria-label={t("toolbar.flightSim.close")}
144+
onClick={() => closeFlightSimulatorPanel()}
145+
>
146+
<X className="h-3.5 w-3.5" />
147+
</Button>
148+
</div>
149+
150+
{!collapsed && (
151+
<div className="space-y-3 p-3">
152+
{/* Instruments. Values are held at their last reading when stopped so
153+
the layout does not jump between flights. */}
154+
<div className="grid grid-cols-3 gap-2">
155+
<Instrument
156+
label={t("toolbar.flightSim.speed")}
157+
value={Math.round(speed).toString()}
158+
unit={speedUnit}
159+
/>
160+
<Instrument
161+
label={t("toolbar.flightSim.altitude")}
162+
value={Math.round(altitude).toLocaleString()}
163+
unit={altitudeUnit}
164+
/>
165+
<Instrument
166+
label={t("toolbar.flightSim.heading")}
167+
value={`${Math.round(hud.headingDeg)}°`}
168+
unit={compassPoint(hud.headingDeg)}
169+
/>
170+
</div>
171+
172+
<div className="flex items-center justify-between text-xs">
173+
<span className="text-muted-foreground">{t("toolbar.flightSim.agl")}</span>
174+
<span className="tabular-nums text-foreground">
175+
{Math.round(agl).toLocaleString()} {altitudeUnit}
176+
</span>
177+
</div>
178+
179+
{/* Artificial horizon: the bar tilts with the bank angle and rises or
180+
falls with the nose attitude, so attitude is readable at a glance
181+
without watching the terrain. */}
182+
<div className="relative h-16 overflow-hidden rounded border border-border bg-gradient-to-b from-sky-400/30 to-emerald-700/30">
183+
<div
184+
className="absolute inset-x-[-25%] top-1/2 h-0.5 bg-foreground/70"
185+
style={{
186+
transform: `translateY(${clamp(hud.pitchDeg, -45, 45) * 0.6}px) rotate(${-hud.rollDeg}deg)`,
187+
}}
188+
/>
189+
<div className="absolute left-1/2 top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-400 ring-1 ring-background" />
190+
</div>
191+
192+
{hud.grounded && hud.flying && (
193+
<div className="flex items-center gap-1.5 rounded border border-amber-500/40 bg-amber-500/10 px-2 py-1 text-xs text-amber-600 dark:text-amber-400">
194+
<TriangleAlert className="h-3.5 w-3.5 shrink-0" />
195+
{t("toolbar.flightSim.terrainWarning")}
196+
</div>
197+
)}
198+
199+
<div className="flex items-center justify-between text-xs">
200+
<span className="text-muted-foreground">{t("toolbar.flightSim.throttle")}</span>
201+
<span className="tabular-nums text-foreground">{Math.round(hud.throttle * 100)}%</span>
202+
</div>
203+
204+
<p className="rounded bg-muted/50 px-2 py-1.5 text-[11px] leading-relaxed text-muted-foreground">
205+
{t("toolbar.flightSim.help")}
206+
</p>
207+
208+
<SliderRow
209+
label={t("toolbar.flightSim.maxSpeed")}
210+
min={FLIGHT_MAX_SPEED_MIN}
211+
max={FLIGHT_MAX_SPEED_MAX}
212+
step={5}
213+
value={settings.maxSpeedMps}
214+
format={(value) =>
215+
imperial
216+
? `${Math.round(value * KNOTS_PER_MPS)} ${speedUnit}`
217+
: `${Math.round(value * 3.6)} ${speedUnit}`
218+
}
219+
onChange={(value) => setFlightSimulatorSettings({ maxSpeedMps: value })}
220+
/>
221+
222+
<SliderRow
223+
label={t("toolbar.flightSim.minAgl")}
224+
min={FLIGHT_MIN_AGL_MIN}
225+
max={FLIGHT_MIN_AGL_MAX}
226+
step={5}
227+
value={settings.minAltitudeAglMeters}
228+
format={(value) =>
229+
imperial
230+
? `${Math.round(value * FEET_PER_METER)} ${altitudeUnit}`
231+
: `${Math.round(value)} ${altitudeUnit}`
232+
}
233+
onChange={(value) => setFlightSimulatorSettings({ minAltitudeAglMeters: value })}
234+
/>
235+
236+
<label className="flex items-center justify-between gap-2 text-xs">
237+
<span className="text-muted-foreground">{t("toolbar.flightSim.units")}</span>
238+
<Select
239+
className="h-7 w-28"
240+
value={settings.units}
241+
onChange={(event) =>
242+
setFlightSimulatorSettings({
243+
units: event.target.value === "metric" ? "metric" : "imperial",
244+
})
245+
}
246+
>
247+
<option value="imperial">{t("toolbar.flightSim.imperial")}</option>
248+
<option value="metric">{t("toolbar.flightSim.metric")}</option>
249+
</Select>
250+
</label>
251+
252+
<label className="flex items-center gap-2 text-xs">
253+
<input
254+
type="checkbox"
255+
className="h-3.5 w-3.5 accent-sky-500"
256+
checked={settings.bankCamera}
257+
onChange={(event) => setFlightSimulatorSettings({ bankCamera: event.target.checked })}
258+
/>
259+
<span className="text-muted-foreground">{t("toolbar.flightSim.bankCamera")}</span>
260+
</label>
261+
262+
<label className="flex items-center gap-2 text-xs">
263+
<input
264+
type="checkbox"
265+
className="h-3.5 w-3.5 accent-sky-500"
266+
checked={settings.invertPitch}
267+
onChange={(event) =>
268+
setFlightSimulatorSettings({ invertPitch: event.target.checked })
269+
}
270+
/>
271+
<span className="text-muted-foreground">{t("toolbar.flightSim.invertPitch")}</span>
272+
</label>
273+
</div>
274+
)}
275+
</div>
276+
);
277+
}
278+
279+
interface InstrumentProps {
280+
label: string;
281+
value: string;
282+
unit: string;
283+
}
284+
285+
/** One boxed instrument readout: a big number with its label and unit. */
286+
function Instrument({ label, value, unit }: InstrumentProps) {
287+
return (
288+
<div className="rounded border border-border bg-muted/30 px-2 py-1.5">
289+
<div className="truncate text-[10px] uppercase tracking-wide text-muted-foreground">
290+
{label}
291+
</div>
292+
<div className="truncate text-base font-semibold tabular-nums leading-tight">{value}</div>
293+
<div className="truncate text-[10px] text-muted-foreground">{unit}</div>
294+
</div>
295+
);
296+
}
297+
298+
interface SliderRowProps {
299+
label: string;
300+
min: number;
301+
max: number;
302+
step: number;
303+
value: number;
304+
format: (value: number) => string;
305+
onChange: (value: number) => void;
306+
}
307+
308+
function SliderRow({ label, min, max, step, value, format, onChange }: SliderRowProps) {
309+
return (
310+
<div className="space-y-1">
311+
<div className="flex items-center justify-between text-xs">
312+
<span className="text-muted-foreground">{label}</span>
313+
<span className="tabular-nums text-foreground">{format(value)}</span>
314+
</div>
315+
<Slider
316+
aria-label={label}
317+
min={min}
318+
max={max}
319+
step={step}
320+
value={[value]}
321+
onValueChange={([next]: number[]) => onChange(next ?? value)}
322+
/>
323+
</div>
324+
);
325+
}

0 commit comments

Comments
 (0)