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.

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

east, and so on. Push harder (further from center) to move faster.

recomputed from your current position each frame, so steering toward a blip brings it closer and steering away lets it fall behind and disappear.

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):

world velocity (East = -cos(angle), North = sin(angle) for this widget's convention) and advance (pE, pN); the readout shows a compass heading.

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.

green through the sx prop targeting the radial grid/axis classes.

growing closePath ring give the scope its living, scanning feel.

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: