Radar Scope
A playable navigation game built from a RadialLineChart — steer with a dash-gauge joystick while world-fixed contacts drift across the scope and new ones stream in ahead of you.
---
.. llms_copy::Radar Scope
.. toc::
A radar you can pilot
This is an experiment turned into a small navigation game, assembled from nothing but a RadialLineChart, a DashRCJoystick (from [dash-gauge](https://pypi.org/project/dash-gauge/)), a dcc.Interval, and a clientside callback. Grab the green stick on the NAV CONSOLE and steer: the contacts hold their position in the world while *you* move, so they slide across the scope as you travel, and fresh contacts stream in from ahead. The readout gives you a live heading, speed, position, and the bearing + range of every contact in range.
.. admonition::Premium (preview) :color: yellow
RadialLineChart is MUI X Premium (preview) — set MUI_X_LICENSE_KEY to remove the watermark.
.. exec::docs.radar.radar :code: false
How to play
- Push the joystick toward where you want to go — up travels north, right
east, and so on. Push harder (further from center) to move faster.
- The scope is always you-centered: every contact's bearing and range is
recomputed from your current position each frame, so steering toward a blip brings it closer and steering away lets it fall behind and disappear.
- New contacts spawn in a cone ahead of you while you travel and keep their
fixed world coordinates, so the world feels endless — chase them down or weave between them.
How it works
Contacts live at fixed world coordinates (cE, cN). Your ship has a world position (pE, pN) that the joystick integrates each frame. Everything else is derived and drawn as polar series, entirely in the browser (the dcc.Interval feeds a clientside callback that rewrites the chart's series, updates a dcc.Store with the game state, and refreshes the readout — the server is never in the loop):
- Steering — the joystick's read-only
angle/distanceprops become a
world velocity (East = -cos(angle), North = sin(angle) for this widget's convention) and advance (pE, pN); the readout shows a compass heading.
- Projection — each contact's offset
(cE-pE, cN-pN)is turned into a
bearing (atan2(dE, dN), 0° = north, clockwise) and a range (hypot). In-range contacts are plotted as a single cross mark at that bearing/radius.
- The scope — the chart's
grid(range rings + bearing spokes), restyled
green through the sx prop targeting the radial grid/axis classes.
- The sweep & pulse — an
areawedge that rotates with the bearing and a
growing closePath ring give the scope its living, scanning feel.
- Hover —
slotProps={"tooltip": {"trigger": "item"}}makes the tooltip
follow the individual contact mark, so hovering a blip reports its bearing and range.
.. admonition::Why per-frame works smoothly :color: blue
The chart is set to skipAnimation=True and the series prop is updated by a clientside callback, so each frame is a cheap local re-render with no network round-trip — the scope stays smooth as you steer.
Source
```python
File: docs/radar/radar.py
import os
from dash import dcc, html, clientside_callback, Input, Output, State import dash_mantine_components as dmc from dash_gauge import DashRCJoystick import dash_mui_scheduler as dms
An interactive radar *navigation game* built from a RadialLineChart:
- contacts live at fixed world coordinates (random placement),
- the DashRCJoystick moves YOUR position, so contacts drift across the scope
as you travel (their bearing + range are recomputed every frame),
- new contacts spawn ahead of you as you move and keep their world location,
- the sweep beam and an expanding pulse keep the scope alive, and each
contact reports its range in the readout and in the hover tooltip.
The whole game loop runs client-side (a dcc.Interval + a clientside callback
over a dcc.Store), so the server is never in the loop.
NA = 120 ANGLES = [i * 360 / NA for i in range(NA + 1)]
RADAR_SX = { "& .MuiChartsRadialGrid-line": {"stroke": "rgba(57, 255, 153, 0.16)"}, "& .MuiChartsRadialAxis-line": {"stroke": "rgba(57, 255, 153, 0.30)"}, "& .MuiChartsRadialAxis-tick": {"stroke": "rgba(57, 255, 153, 0.30)"}, "& text": {"fill": "rgba(140, 255, 180, 0.75)", "fontFamily": "monospace", "fontSize": "10px"}, }
_scope = dmc.Paper( [ dmc.Group( [ dmc.Text("RADAR SCOPE", fw=700, c="#39ff99", style={"letterSpacing": "3px", "fontFamily": "monospace"}), dmc.Badge("● NAVIGATING", color="teal", variant="light", styles={"root": {"fontFamily": "monospace"}}), ], justify="space-between", mb="xs", ), dms.RadialLineChart( id="radar-chart", height=440, licenseKey=os.environ.get("MUI_X_LICENSE_KEY", ""), series=[], rotationAxis=[{"data": ANGLES, "min": 0, "max": 360, "tickInterval": [0, 45, 90, 135, 180, 225, 270, 315]}], radiusAxis=[{"min": 0, "max": 100, "position": "none"}], grid={"rotation": True, "radius": True}, hideLegend=True, skipAnimation=True, margin=12, sx=RADAR_SX, # Hover a contact mark to read its bearing + range. slotProps={"tooltip": {"trigger": "item"}}, ), dcc.Interval(id="radar-tick", interval=80, n_intervals=0), dcc.Store(id="radar-state"), ], p="md", radius="md", withBorder=True, style={"background": "radial-gradient(circle at center, #07260f 0%, #04140a 65%, #020b06 100%)", "borderColor": "rgba(57,255,153,0.25)", "flex": "1 1 460px", "maxWidth": 560}, )
_console = dmc.Stack( [ dmc.Text("NAV CONSOLE", fw=700, c="#39ff99", style={"letterSpacing": "2px", "fontFamily": "monospace"}), DashRCJoystick( id="radar-joystick", baseRadius=72, controllerRadius=32, throttle=60, controllerClassName="radar-joystick-ctrl", style={"background": "radial-gradient(circle at center, #0b3a1c, #03110a)", "border": "1px solid rgba(57,255,153,0.4)", "borderRadius": "50%"}, ), dmc.Text("Drag to steer · contacts hold position as you travel", size="xs", c="dimmed", ta="center", maw=180), html.Pre(id="radar-readout", style={"fontFamily": "monospace", "fontSize": "11px", "color": "#8effb4", "background": "#03110a", "border": "1px solid rgba(57,255,153,0.25)", "borderRadius": "8px", "padding": "10px", "margin": 0, "width": 220, "minHeight": 170, "whiteSpace": "pre-wrap"}), ], align="center", gap="sm", )
component = dmc.Group([_scope, _console], align="flex-start", gap="xl", wrap="wrap")
Entire game loop runs in the browser; the server never sees a frame.
clientside_callback( """ function(n, jAngle, jDist, st) { const NA = 120, R = 100, BASE = 72, MAXSPEED = 1.6; const angles = []; for (let i = 0; i <= NA; i++) { angles.push(i * 360 / NA); }
st = st || {pE: 0, pN: 0, contacts: [], nextId: 1, heading: 0}; let pE = st.pE, pN = st.pN, contacts = st.contacts || [], nextId = st.nextId || 1; let heading = st.heading || 0, speed = 0;
// --- movement ----------------------------------------------------- // rc-joystick reports angle as LEFT=0°, UP=90°, RIGHT=±180°, DOWN=-90° // (CCW from the left axis). Map the push into a world velocity: // East = -cos(th) North = sin(th) // so UP travels north, RIGHT east, etc. if (jDist && jAngle != null) { const f = Math.min(1, jDist / BASE); const th = jAngle * Math.PI / 180; speed = MAXSPEED * f; const vE = -Math.cos(th), vN = Math.sin(th); pE += vE * speed; pN += vN * speed; heading = (Math.atan2(vE, vN) * 180 / Math.PI + 360) % 360; // compass 0=N, CW } const moving = (jDist || 0) > 6;
// --- spawn contacts (random world positions); bias ahead while moving -- function spawnAt(brgDeg, rmin, rmax) { const brg = brgDeg * Math.PI / 180; // 0=N, CW const rng = rmin + Math.random() * (rmax - rmin); contacts.push({id: nextId++, cE: pE + Math.sin(brg) * rng, cN: pN + Math.cos(brg) * rng}); } // travelling: new contacts stream in from ahead (a forward cone) if (moving && Math.random() < 0.18 && contacts.length < 18) { spawnAt(heading + (Math.random() * 2 - 1) * 70, R * 1.0, R * 1.7); } // always keep a minimum population scattered all around while (contacts.length < 5) { spawnAt(Math.random() * 360, R * 0.3, R * 0.95); } // forget contacts that fall far behind us (keeps the world bounded) contacts = contacts.filter(c => Math.hypot(c.cE - pE, c.cN - pN) < R * 2.3);
// --- sweep + pulse ---------------------------------------------------- const sweep = (n * 3.5) % 360; const beam = 30; const pulseR = (n * 1.5) % 116; const pAlpha = Math.max(0.04, 0.5 * (1 - pulseR / 116)); const pulse = angles.map(() => Math.max(1, pulseR)); const beamData = angles.map(a => { const d = ((sweep - a) % 360 + 360) % 360; return d <= beam ? 100 : 0; }); const series = [ {data: pulse, label: 'pulse', closePath: true, showMark: false, color: 'rgba(57,255,153,' + pAlpha.toFixed(2) + ')', curve: 'linear'}, {data: beamData, label: 'sweep', area: true, showMark: false, color: 'rgba(57,255,153,0.8)', curve: 'linear'}, ];
// --- project each contact to bearing + range; plot the in-range ones -- const visible = []; contacts.forEach(c => { const dE = c.cE - pE, dN = c.cN - pN; const dist = Math.hypot(dE, dN); if (dist > R) { return; } let brg = (Math.atan2(dE, dN) * 180 / Math.PI + 360) % 360; // 0=N, CW visible.push({id: c.id, brg: brg, dist: dist}); const since = ((sweep - brg) % 360 + 360) % 360; // deg since the sweep passed const alpha = Math.min(1, 0.4 + (1 - since / 360) * 0.8); // always visible, flares on scan const arr = angles.map(() => null); arr[Math.round(brg / 360 * NA) % (NA + 1)] = dist; series.push({ data: arr, showMark: true, shape: 'cross', curve: 'linear', color: 'rgba(160,255,180,' + alpha.toFixed(2) + ')', label: 'BRG ' + (('00' + Math.round(brg)).slice(-3)) + '° RNG ' + Math.round(dist), }); });
// --- readout ---------------------------------------------------------- visible.sort((a, b) => a.dist - b.dist); const pad3 = v => ('00' + Math.round(v)).slice(-3); const sgn = v => (v >= 0 ? '+' : '-') + ('000' + Math.abs(Math.round(v))).slice(-3); let out = 'HEADING ' + pad3(heading) + '° SPD ' + speed.toFixed(1) + '\\n'; out += 'POSITION E' + sgn(pE) + ' N' + sgn(pN) + '\\n'; out += '-------------------------\\n'; out += 'CONTACTS IN RANGE: ' + visible.length + '\\n'; visible.slice(0, 8).forEach(c => { out += ' BRG ' + pad3(c.brg) + '° RNG ' + ('00' + Math.round(c.dist)).slice(-3) + '\\n'; }); if (visible.length === 0) { out += ' -- no contacts --'; }
return [series, {pE: pE, pN: pN, contacts: contacts, nextId: nextId, heading: heading}, out]; } """, Output("radar-chart", "series"), Output("radar-state", "data"), Output("radar-readout", "children"), Input("radar-tick", "n_intervals"), State("radar-joystick", "angle"), State("radar-joystick", "distance"), State("radar-state", "data"), ) ```
:defaultExpanded: false :withExpandedButton: true
---
*Source: /radar*
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:
- /radar/llms.txt — LLM-friendly documentation
- /sitemap.xml
- /robots.txt