# 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` / `distance` props 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 `area` wedge 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*