PocketScope renders the radar-style view using a layered canvas abstraction. Rendering code lives under src/pocketscope/render/ and src/pocketscope/ui/.
render/canvas.py defines drawing primitives (lines, circles, polygons, text) and colour handling that every backend implements. This decouples scene composition from the actual display technology.
render/layers/*.py– Each overlay (airports, sectors, aircraft, trails) lives in a dedicated layer class. Layers subscribe to bus topics or data services and render onto the canvas when asked.render/view_ppi.py– The primary north-up plan position indicator. It draws range rings, ownship, aircraft glyphs, and data blocks using geometry helpers fromcore/geo.py. Aircraft glyph fill was simplified to always use the neutralac.level.fillcolour; vertical speed is now communicated (in "simple label" mode) via a small ▲ / ▼ arrow appended to the one-line label when climb/descent exceeds ±200 fpm (arrow coloured withac.climb.fill/ac.desc.fill).render/labels.py– Handles ATC-style label layout, collision avoidance, and typography controls (font size, gaps, and alignment).
ui/controller.py coordinates focus management, gesture handling, and layer composition. Input events arrive from the active backend (mouse, touch, soft keys) and are translated into actions such as range changes or aircraft pinning.
- Pygame – The default desktop backend. Supports windowed or headless rendering, PNG snapshots, and keyboard shortcuts.
- ILI9341 SPI TFT – Targets the Raspberry Pi handheld hardware. Includes RGB565 encoding, hardware watchdog integration, and rotation options.
- Web View – Streams canvas updates over WebSocket to a minimal browser-based client for remote monitoring.
All backends share the same controller API. Switching between them is done through CLI flags (--backend pygame, --backend tft, --backend web).
- Deterministic Frames – Rendering tests under
tests/render/andtests/ui/compare generated frames against golden PNGs using the simulated clock. - Screenshot Hooks – The UI exposes screenshot triggers that can be activated by keyboard shortcuts, soft keys, Unix signals, or dropping marker files. See Screenshot automation for workflows.
- Theme Reloading – Palette changes saved to the configuration file propagate to the active controller so you can tune colours while the app is running.
When adding a new overlay or backend, focus on implementing the canvas protocol and keeping stateful logic inside the appropriate layer or service.
The Pi handheld (ILI9341 SPI TFT) is the performance-critical target. A few techniques keep the PPI frame within budget; understand them before optimising.
PillowCanvas.text() does not re-rasterise glyphs every frame. FontCache
holds a color-independent coverage mask (L-mode) per (text, size_px),
bounded by an LRU. text() pastes a solid fill through the cached mask, which
is pixel-identical to ImageDraw.text (same default anchor and textbbox
metrics, same 8-bit coverage blend). Semi-transparent fills and multi-line
strings fall back to the direct draw path so that identity guarantee holds.
This cut repeated label cost (sector names, cardinals, airport tags, ATC data
blocks) by 4–7× on-device. Pixel-equivalence is locked by
tests/platform/test_pillow_text_cache.py.
render/geom_np.py holds the shared NumPy fast paths used by both the
state-boundary (view_ppi.py) and sector (sectors_layer.py) overlays:
_ring_visible_np– view-radius cull (vertex / edge / enclosure test)._rdp_keep_mask_np– iterative Ramer–Douglas–Peucker simplification._simplify_ring_np– adaptive-tolerance RDP with ENU projection._any_within_nm– vectorised haversine cull (matchescore.geo.haversine_nm).
Each is locked to its scalar original by equivalence tests
(tests/render/test_states_vectorized.py, tests/render/test_sectors_cull.py)
so a future change can't silently alter what gets culled or simplified. State
geometry is also rebuilt only when range, rotation, or center change (cached in
render/geo_cache.py); a static view reuses projected screen vertices.
The ui.perf log emits view_detail= per-stage millisecond figures. These
are wall-clock, not CPU, and can mislead. The SPI transmit of the framebuffer
(240×320×2 bytes) runs in a background thread; the render stages call into
Pillow C routines that release the GIL, so the SPI push overlaps whichever stage
is executing. The first heavy stage (sectors) absorbs that wait and can read as
~180 ms even though SectorsLayer.draw() is only ~5–6 ms of actual CPU. Always
confirm a stage is CPU-bound with internal timers before optimising it. The
genuine remaining bottleneck is the SPI transmit itself (bandwidth-bound);
levers there are the SPI clock rate and dirty-rectangle / partial updates.