Flight Sim
single-player, keyboard + touch-joystick, rAF physics loop.
---
.. llms_copy::Flight Sim
.. toc::
Overview
Rotation model (revised, matching DashEcommerce/pages/map/fly.py):
* The MAP STAYS NORTH-UP — bearing is left at 0 on this page. The previous iteration rotated the camera with the heading; the user wanted the fly.py pattern instead, where the player gets the orientation cue from the SPRITE. * The MARKER ROTATES — rotateWithMap=False plus rotationAngle = heading drives the airplane sprite to face the direction of travel. The sprite is the top-down airplane PNG, intrinsically pointing UP (north) — so a rotationAngle of 90° = nose pointing east, 180° = south, etc.
* The dl2.Map(bearing=…) machinery still EXISTS and the dl2-rotation-wrapper is still there — we just don't use it from this page. Rotation-basic still demonstrates the camera-rotation capability.
Controls
* Keyboard (desktop): ArrowLeft/Right turn the plane, ArrowUp throttles, ArrowDown brakes, Space hard-stops, Cmd/Ctrl+Arrow pans the camera. * Touch joystick (mobile, auto-shown via @media (hover: none) and (pointer: coarse)): pushing horizontally turns, pushing vertically throttles/brakes — same semantic as the arrow keys but as a continuous analog signal. Joystick state is read by the rAF tick alongside keyboard state, so both work simultaneously and either input alone is enough.
The rAF physics loop stays unchanged — frame-rate-independent integration, self-throttling to display refresh, paused when the tab is hidden.
Live demo
.. exec::docs.flight-sim.example :code: false
Source
```python
File: docs/flight-sim/example.py
""" Flight Sim — single-player, keyboard + touch-joystick, rAF physics loop.
Rotation model (revised, matching DashEcommerce/pages/map/fly.py):
* The MAP STAYS NORTH-UP — bearing is left at 0 on this page. The previous iteration rotated the camera with the heading; the user wanted the fly.py pattern instead, where the player gets the orientation cue from the SPRITE. * The MARKER ROTATES — rotateWithMap=False plus rotationAngle = heading drives the airplane sprite to face the direction of travel. The sprite is the top-down airplane PNG, intrinsically pointing UP (north) — so a rotationAngle of 90° = nose pointing east, 180° = south, etc.
* The dl2.Map(bearing=…) machinery still EXISTS and the dl2-rotation-wrapper is still there — we just don't use it from this page. Rotation-basic still demonstrates the camera-rotation capability.
Controls
* Keyboard (desktop): ArrowLeft/Right turn the plane, ArrowUp throttles, ArrowDown brakes, Space hard-stops, Cmd/Ctrl+Arrow pans the camera. * Touch joystick (mobile, auto-shown via @media (hover: none) and (pointer: coarse)): pushing horizontally turns, pushing vertically throttles/brakes — same semantic as the arrow keys but as a continuous analog signal. Joystick state is read by the rAF tick alongside keyboard state, so both work simultaneously and either input alone is enough.
The rAF physics loop stays unchanged — frame-rate-independent integration, self-throttling to display refresh, paused when the tab is hidden. """
import dash_leaflet2 as dl2 import dash_mantine_components as dmc from dash import Input, Output, State, callback, clientside_callback, dcc, html from dash_iconify import DashIconify from dl2_tiles import ESRI_STREET, register_theme_swap from dl2_locations import MIAMI from dl2_shared import code_panel, header, info_panel
Basemap pair for this page. dl2_tiles owns the light/dark wiring so
every example themes the same way — see register_theme_swap below.
TILES = ESRI_STREET TILE_URL = TILES.url("light") SAT = ( "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/" "MapServer/tile/{z}/{y}/{x}" ) ATTR = TILES.attribution()
START = MIAMI.center START_ZOOM = 15
Physics — deg/s for the integrator (the rAF tick multiplies by dt in seconds).
MIN_SPEED = 0.0 MAX_SPEED = 0.0006 ACCEL = 0.0006 # ≈ 1 sec to top speed BRAKE = 0.0012 TURN_RATE = 90.0 # deg/sec of heading change while turning
Top-down green bomber sprite with built-in drop shadow. The image is ~512px,
square, with the nose pointing UP — matches our north-up convention so the
rotationAngle = heading mapping in the rAF loop reads naturally (heading 90°
→ sprite rotated 90° CW → nose pointing east).
AIRPLANE_SRC = "/assets/sprites/airplane_with_shadow.webp" AIRPLANE_SIZE = 68 # bumped from 56 to give the propellers + stars room to read
CODE = """# Map stays north-up. The aircraft sprite rotates to face the heading. dl2.Map(id="fs-map", center=START, zoom=15, children=[ dl2.TileLayer(...), dl2.Marker(id="fs-aircraft", position=START, icon={"iconUrl": AIRPLANE_SRC, "iconSize": [56, 56], "iconAnchor": [28, 28]}, rotateWithMap=False, # decoupled from map.bearing rotationAngle=0), # rAF loop drives this to heading ])
A single clientside callback installs the rAF loop. It:
- reads keyboard state (ArrowLeft/Right/Up/Down + Space) and the joystick
(window._dl2_joystick = {x, y})
- integrates position, heading, speed
- sets fs-aircraft.position via set_props
- sets fs-aircraft.rotationAngle via set_props ← the marker rotates
- sets fs-map.center via set_props (camera follows)
- updates the HUD displays directly (set_props on the badges)"""
def _joystick_div(prefix: str): """Render the joystick base + controller. CSS in style.css hides this on non-touch displays. The base/controller IDs are wired up in the rAF loop's setup JS using a unique class prefix.""" return html.Div( className="dl2-joystick", children=[ html.Div( className="dl2-joystick-base", id=f"{prefix}-joystick-base", children=html.Div( className="dl2-joystick-controller", id=f"{prefix}-joystick-controller", ), ), html.Div( className="dl2-joystick-hint", children=[ DashIconify(icon="mdi:gesture-tap", width=14), html.Span(" Drag to fly / brake"), ], ), ], )
component = dmc.Stack( [ header( "Flight Sim", "Single-player flight on top of dl2.Map. The map stays north-up; the " "aircraft sprite rotates to face the direction of travel (matches " "fly.py's pattern). Physics runs entirely client-side on " "requestAnimationFrame — frame-rate-independent, pauses when the tab " "is hidden. Keyboard: ArrowLeft/Right turn, Up throttles, Down brakes, " "Space stops, Cmd/Ctrl+Arrow pans the camera. Mobile / touch devices " "automatically get a draggable joystick at the bottom of the screen.", badge="dl2 · rAF physics · touch joystick", ), dmc.Grid( [ # base 12 (stacks under map) on mobile, 8 on md+ (side-by-side). dmc.GridCol( html.Div( style={"position": "relative"}, children=[ # Height is set by the .dl2-sim-map-paper CSS class (70vh # on desktop, 55vh on mobile) so the responsive height # doesn't require an inline style dict. dmc.Paper( dl2.Map( id="fs-map", center=START, zoom=START_ZOOM, bearing=0, style={"height": "100%"}, children=[ dl2.TileLayer( id="fs-sat", url=SAT, attribution=ATTR, opacity=0.55, ), dl2.TileLayer( id="fs-tile", url=TILE_URL, opacity=0.7 ), dl2.Marker( id="fs-aircraft", position=START, icon={ "iconUrl": AIRPLANE_SRC, "iconSize": [ AIRPLANE_SIZE, AIRPLANE_SIZE, ], "iconAnchor": [ AIRPLANE_SIZE // 2, AIRPLANE_SIZE // 2, ], }, rotateWithMap=False, rotationAngle=0, ), dl2.Polyline( id="fs-trail", positions=[START], color="#2f9e44", weight=2, opacity=0.6, ), ], ), className="dl2-sim-map-paper", shadow="sm", radius="md", withBorder=True, style={"overflow": "hidden"}, ), # Joystick overlay (mobile only — hidden by CSS otherwise). _joystick_div("fs"), ], ), span={"base": 12, "md": 8}, ), dmc.GridCol( dmc.Stack( [ info_panel( "HUD", dmc.Stack( [ dmc.Group( [ dmc.Stack( [ dmc.Text( "HEADING", size="xs", c="dimmed", ), dmc.Badge( id="fs-heading", color="green", variant="light", size="lg", children="0°", ), ], gap=2, ), dmc.Stack( [ dmc.Text( "THROTTLE", size="xs", c="dimmed", ), dmc.Badge( id="fs-throttle", color="orange", variant="light", size="lg", children="0%", ), ], gap=2, ), ], justify="space-between", ), dmc.Progress( id="fs-throttle-bar", value=0, color="orange", size="sm", striped=True, animated=True, ), dmc.Group( [ dmc.Text( "POSITION", size="xs", c="dimmed" ), dmc.Code( id="fs-position", children="...", style={"fontSize": "11px"}, ), ], justify="space-between", ), ], gap="sm", ), ), info_panel( "Controls", dmc.Stack( [ dmc.Group( [ dmc.Kbd("←"), dmc.Kbd("→"), dmc.Text("turn aircraft", size="sm"), ], gap="xs", ), dmc.Group( [ dmc.Kbd("↑"), dmc.Text("throttle up", size="sm"), ], gap="xs", ), dmc.Group( [ dmc.Kbd("↓"), dmc.Text("brake", size="sm"), ], gap="xs", ), dmc.Group( [ dmc.Kbd("Space"), dmc.Text("hard stop", size="sm"), ], gap="xs", ), dmc.Divider(), dmc.Group( [ dmc.Kbd("⌘"), dmc.Text("+", size="sm"), dmc.Kbd("←/→/↑/↓"), dmc.Text("pan camera", size="sm"), ], gap="xs", ), dmc.Divider(), dmc.Group( [ DashIconify( icon="mdi:gesture-tap", width=16 ), dmc.Text( "touch joystick auto-shows on mobile", size="sm", c="dimmed", ), ], gap="xs", ), ], gap=6, ), ), info_panel( "State", dmc.Code( id="fs-state-readout", block=True, style={"fontSize": "11px", "minHeight": "100px"}, ), ), ], gap="md", ), span={"base": 12, "md": 4}, ), ] ), code_panel("Pattern", CODE), dcc.Store(id="fs-tick", data=0), ], gap="md", )
---- rAF physics loop (installed once on page mount) -----------------------
clientside_callback( f""" (mapId) => {{ const root = document.getElementById('fs-map'); if (!root || root.dataset.fsLoopRunning) {{ return window.dash_clientside.no_update; }} root.dataset.fsLoopRunning = '1';
const MIN_SPEED = {MIN_SPEED}; const MAX_SPEED = {MAX_SPEED}; const ACCEL = {ACCEL}; const BRAKE = {BRAKE}; const TURN_RATE = {TURN_RATE};
const state = {{ lat: {START[0]}, lng: {START[1]}, heading: 0, speed: 0, trail: [[{START[0]}, {START[1]}]], keys: new Set(), lastFrame: performance.now(), }}; // Shared joystick state — written by the touch handlers below, read by // the rAF tick. Magnitude on each axis is normalized to [-1, +1]. window._dl2_joystick = window._dl2_joystick || {{ x: 0, y: 0, active: false }}; const joy = window._dl2_joystick;
// --- keyboard --- // CRITICAL: disable Leaflet's built-in keyboard handler. Without this, // once the user clicks the map (giving it focus), Leaflet intercepts // ArrowLeft/Right/Up/Down via its own keyboard module and calls // stopPropagation — meaning the window-level listener we install // below never sees the event. Result: arrow keys appear to do nothing // on desktop (the map briefly pans, then snaps back via the rAF // re-centering). Disabling Leaflet's keyboard module lets the events // propagate up to window where our listener handles them. // // dl2.Map sets root.__dl2_map inside its mount effect (children run // first in React); the property may not exist yet when this setup // callback fires. Poll until it appears. const disableLeafletKeyboard = () => {{ const m = root.__dl2_map; if (m && m.keyboard && typeof m.keyboard.disable === 'function') {{ try {{ m.keyboard.disable(); }} catch (e) {{}} return; }} setTimeout(disableLeafletKeyboard, 80); }}; disableLeafletKeyboard();
const PAN_STEP_PX = 80; const panBy = (dx, dy) => {{ const m = root.__dl2_map; if (m && typeof m.panBy === 'function') m.panBy([dx, dy]); }}; const onDown = (e) => {{ const t = e.target; if (t && /input|textarea|select/i.test(t.tagName)) return; if (e.metaKey || e.ctrlKey) {{ if (e.key === 'ArrowLeft') {{ panBy(-PAN_STEP_PX, 0); e.preventDefault(); }} if (e.key === 'ArrowRight') {{ panBy(PAN_STEP_PX, 0); e.preventDefault(); }} if (e.key === 'ArrowUp') {{ panBy(0, -PAN_STEP_PX); e.preventDefault(); }} if (e.key === 'ArrowDown') {{ panBy(0, PAN_STEP_PX); e.preventDefault(); }} return; }} if (['ArrowLeft','ArrowRight','ArrowUp','ArrowDown',' ','Space'].includes(e.key)) {{ state.keys.add(e.key); e.preventDefault(); }} }}; const onUp = (e) => state.keys.delete(e.key); window.addEventListener('keydown', onDown); window.addEventListener('keyup', onUp);
// --- touch joystick --- // Hand-rolled (no dash_gauge dep). Base is a circle anchored bottom-center; // dragging the controller updates joy.x/joy.y in [-1, +1]. Released => // controller springs back to center, joy values zero out. const base = document.getElementById('fs-joystick-base'); const ctrl = document.getElementById('fs-joystick-controller'); if (base && ctrl && !base.dataset.wired) {{ base.dataset.wired = '1'; const reset = () => {{ ctrl.style.transform = 'translate(-50%, -50%)'; joy.x = 0; joy.y = 0; joy.active = false; }}; reset(); let activePtr = null; const onPtrDown = (e) => {{ if (activePtr !== null) return; activePtr = e.pointerId; base.setPointerCapture(e.pointerId); joy.active = true; e.preventDefault(); }}; const onPtrMove = (e) => {{ if (e.pointerId !== activePtr) return; const r = base.getBoundingClientRect(); const radius = r.width / 2; const dx = e.clientX - (r.left + radius); const dy = e.clientY - (r.top + radius); const mag = Math.sqrt(dx * dx + dy * dy); // Clamp the visual controller offset to the base radius. const k = mag > radius ? radius / mag : 1; const cx = dx * k, cy = dy * k; ctrl.style.transform = translate(calc(-50% + ${{cx}}px), calc(-50% + ${{cy}}px)); joy.x = cx / radius; joy.y = cy / radius; }}; const onPtrUp = (e) => {{ if (e.pointerId !== activePtr) return; try {{ base.releasePointerCapture(e.pointerId); }} catch (err) {{}} activePtr = null; reset(); }}; base.addEventListener('pointerdown', onPtrDown); base.addEventListener('pointermove', onPtrMove); base.addEventListener('pointerup', onPtrUp); base.addEventListener('pointercancel', onPtrUp); }}
// --- rAF tick --- const tick = (now) => {{ const dt = Math.min(0.1, (now - state.lastFrame) / 1000); state.lastFrame = now;
const k = state.keys; // Turn input: keyboard arrows give ±1, joystick gives [-1, +1]. // Sum them and clamp so holding the keyboard AND pushing joystick // doesn't double-spin. let turn = 0; if (k.has('ArrowLeft')) turn -= 1; if (k.has('ArrowRight')) turn += 1; if (joy.active) turn += joy.x; turn = Math.max(-1, Math.min(1, turn)); state.heading = (state.heading + turn * TURN_RATE * dt + 360) % 360;
// Throttle / brake input. let thr = 0; if (k.has('ArrowUp')) thr += 1; if (k.has('ArrowDown')) thr -= 1; if (joy.active) thr += -joy.y; // joystick UP (-y) = throttle thr = Math.max(-1, Math.min(1, thr)); if (thr > 0) state.speed = Math.min(MAX_SPEED, state.speed + thr * ACCEL * dt * 60); else if (thr < 0) state.speed = Math.max(MIN_SPEED, state.speed + thr * BRAKE * dt * 60);
if (k.has(' ') || k.has('Space')) state.speed = 0;
if (state.speed > 0) {{ const hd = state.heading * Math.PI / 180; state.lat += state.speed * Math.cos(hd) * (dt * 60); state.lng += state.speed * Math.sin(hd) * (dt * 60); if (state.trail.length === 0 || Math.hypot(state.lat - state.trail[state.trail.length-1][0], state.lng - state.trail[state.trail.length-1][1]) > 0.0008) {{ state.trail.push([state.lat, state.lng]); if (state.trail.length > 300) state.trail.shift(); }} }}
const pos = [state.lat, state.lng]; dash_clientside.set_props('fs-aircraft', {{ position: pos, rotationAngle: state.heading, // ← marker rotates to face heading }}); dash_clientside.set_props('fs-map', {{ center: pos }}); dash_clientside.set_props('fs-trail', {{ positions: state.trail }});
const pct = Math.round((state.speed / MAX_SPEED) * 100); dash_clientside.set_props('fs-heading', {{ children: Math.round(state.heading) + '°' }}); dash_clientside.set_props('fs-throttle', {{ children: pct + '%' }}); dash_clientside.set_props('fs-throttle-bar', {{ value: pct }}); dash_clientside.set_props('fs-position', {{ children: pos[0].toFixed(4) + ', ' + pos[1].toFixed(4) }});
if (!document.getElementById('fs-map')) return; requestAnimationFrame(tick); }}; requestAnimationFrame(tick);
return window.dash_clientside.no_update; }} """, Output("fs-tick", "data"), Input("fs-map", "id"), )
@callback(Output("fs-state-readout", "children"), Input("fs-map", "viewport")) def state_readout(vp): if not vp: return "—" return ( "center: [{:.4f}, {:.4f}]\nzoom: {}\nbearing: {}°\n" "bounds: N {:.3f} S {:.3f} E {:.3f} W {:.3f}" ).format( vp["center"][0], vp["center"][1], vp["zoom"], round(vp.get("bearing") or 0), vp["bounds"]["north"], vp["bounds"]["south"], vp["bounds"]["east"], vp["bounds"]["west"], )
register_theme_swap("fs-tile", TILES) ```
:defaultExpanded: false :withExpandedButton: true
---
*Source: /flight-sim*
Note for AI agents: This is the static, prerendered view of an interactive Dash application served because we detected a non-JS user agent. Full prose docs:
- /flight-sim/llms.txt — LLM-friendly documentation
- /sitemap.xml
- /robots.txt