radarscope
v0.2.2
Published
Aviation-themed SVG primitives: top-down radar scopes, aircraft blips, runways, waypoints. Framework-agnostic core + Svelte 5 adapter.
Maintainers
Readme
radarscope
Aviation-themed SVG primitives for top-down radar / ATC views: scopes, aircraft blips, runways, waypoints, wind tags, plus the geometry math you'd otherwise re-derive every project.
Purpose. This library exists primarily to back the flight-deck and ATC games at https://miro.build/games/flight-deck. It's published as a standalone package because the underlying primitives are general-purpose and someone else might find them useful, but the roadmap is driven by what those games need. If you're considering using it elsewhere, that's welcome — just know the priorities.
Where third-party code, schemas, or visual techniques have been borrowed, they are credited in
ATTRIBUTIONS.mdwith the corresponding licenses preserved underlicenses/.
- Framework-agnostic core — zero dependencies, just TypeScript. Geometry helpers (
headingToVector,findConflicts,interceptAngle, …), instrument math (clampDots,vsiNeedleDeg,inHgToHpa, …), and a scene-graph that renders to a static SVG string. - Svelte 5 adapter — ergonomic reactive components (
<RadarScope>,<AircraftBlip>,<RunwayMarker>,<Waypoint>,<Route>,<WindTag>) that consume the same geometry. Subpath export, opt-in. - IFR cockpit instruments — composable PFD widgets (
<AttitudeIndicator>,<SpeedTape>,<AltitudeTape>,<VSI>,<HeadingTape>,<LocalizerScale>,<GlideslopeScale>,<FMAStrip>,<RadioAltimeter>, plus a layout-only<PFD>). Each widget is a self-contained SVG with its ownviewBox— drop one in anywhere, theme via CSS variables. Subpath export, opt-in. - Real-world data — bundled airport + runway data from the public-domain OurAirports dataset (~1100 large/medium airports), plus a CSV parser if you want to load the full dataset yourself, plus a starter set of well-known approaches. Subpath export, opt-in.
- Themable — every visual is driven by CSS custom properties (
--scope-bg,--scope-blip,--scope-conflict, …) so it slots into your existing palette.
Install
npm install radarscope
# Svelte adapter is opt-in — bring your own Svelte 5For local development against a sibling project:
// games/package.json
{
"dependencies": {
"radarscope": "file:../radarscope"
}
}Quickstart — Svelte
<script lang="ts">
import { RadarScope, AircraftBlip, RunwayMarker } from 'radarscope/svelte';
import { findConflicts, type Scenario } from 'radarscope';
const scenario: Scenario = {
aircraft: [
{ id: '1', callsign: 'AAL123', pos: { x: 5, y: -10 }, heading: 240, altitude: 18000, speed: 280 },
{ id: '2', callsign: 'DAL456', pos: { x: -8, y: -2 }, heading: 90, altitude: 18000, speed: 260 },
],
runway: { threshold: { x: 0, y: 0 }, heading: 270 },
rangeNm: 30,
};
const conflictIds = new Set(findConflicts(scenario, 180, 5).flatMap((c) => [c.a.id, c.b.id]));
</script>
<RadarScope {scenario} size={520}>
<RunwayMarker runway={scenario.runway!} showFinal />
{#each scenario.aircraft as ac (ac.id)}
<AircraftBlip
aircraft={ac}
conflict={conflictIds.has(ac.id)}
onclick={(a) => console.log('clicked', a.callsign)}
/>
{/each}
</RadarScope>Quickstart — vanilla / SSR
import { buildScopeScene, renderToString, type Scenario } from 'radarscope';
const scenario: Scenario = {
aircraft: [
{ id: '1', callsign: 'BAW100', pos: { x: 0, y: -8 }, heading: 180, altitude: 12000, speed: 240 },
],
rangeNm: 30,
};
const svg = renderToString(buildScopeScene(scenario, { size: 600 }));
document.getElementById('mount')!.innerHTML = svg;Coordinates
- Positions are in nautical miles, relative to the scope center
(0, 0). xincreases east,yincreases south. Y-down so values map directly into SVG screen coords without flipping.- Headings are degrees true (0–360), with
0= north.
API surface
Core (radarscope)
// types
type Heading = number;
interface Position { x: number; y: number; }
interface Aircraft { id, callsign, pos, heading, altitude /* ft */, speed /* kt GS */ }
interface Runway { threshold, heading, lengthNm? }
interface Wind { from /* deg true */, kt }
interface Waypoint { id, pos, label }
interface Scenario { aircraft, runway?, waypoints?, wind?, rangeNm? }
// geometry
headingToVector(h, len): Position
projectAircraft(a, seconds): Position
bearingFromTo(a, b): Heading
distanceBetween(a, b): number // nm
interceptAngle(current, target): number // signed, (-180, 180]
findConflicts(scenario, horizonSec, separationNm, verticalFt?): ConflictPair[]
windToVector(fromHeading, kt): Position
// scene → SVG
buildScopeScene(scenario, opts?): SvgNode
buildAircraftBlip / buildRunway / buildWaypoint / buildWindTag (also exported individually)
renderToString(node): stringReal-world data (radarscope/data)
Bundled subset of OurAirports (CC0 public domain), filtered to ~1100 airports: every large_airport, plus medium_airports with scheduled service, an IATA code, and a paved runway ≥5000 ft. Each airport carries its full runway data (heading, length, threshold lat/lon).
import {
// Bundled lookups
allAirports,
findAirportByIcao,
findAirportByIata,
airportsByCountry,
// Geographic ↔ scope projection
geoToScope, scopeToGeo, distNmGeo, bearingGeo,
// CSV parser for OurAirports' full dataset (load it yourself if you need
// every airport — the bundled subset covers every commercially-served field).
parseCsv,
parseOurAirportsAirports,
parseOurAirportsRunways,
attachRunways,
// Starter approach set (~18 well-known ILS approaches; minimums/FAF
// intentionally undefined — fill those in against authoritative AIPs).
allApproaches, approachesByIcao, approachesByRunway,
} from 'radarscope/data';
// Build a scope centered on a real airport with real runways:
import { headingToVector } from 'radarscope';
const ksfo = findAirportByIcao('KSFO')!;
const center = { lat: ksfo.lat, lon: ksfo.lon };
const scenario = {
rangeNm: 30,
runway: {
threshold: geoToScope(center, ksfo.runways[2].le), // 28R landing threshold
heading: ksfo.runways[2].he.headingDegT, // arriving from the east
},
aircraft: [/* … */],
};The radarscope/data subpath bundles ~150 KB gzipped of airport JSON. It's a separate entry point, so consumers who don't import it pay nothing.
To refresh the bundled data against the latest OurAirports release:
node scripts/fetch-airports.mjsSvelte adapter (radarscope/svelte)
<RadarScope scenario size={520} rangeRings={[10, 20, 30]}>
<RunwayMarker runway={…} showFinal finalNm={12} />
<Waypoint waypoint={…} onclick={(w) => …} selected />
<Route waypoints={…} from={ac.pos} />
<AircraftBlip aircraft={…} selected conflict vectorNm={2} onclick={(a) => …} />
<WindTag wind={…} position={{ x: -27, y: -27 }} />
</RadarScope>All children render inside the parent <RadarScope>'s coordinate system (nm-units), so positions on a Waypoint or AircraftBlip are passed in nm, not pixels.
Instruments adapter (radarscope/instruments)
Cockpit PFD widgets, framework-agnostic in their math (in core) and rendered via Svelte 5. Each widget is independently mountable — there's no shared SVG context, so you can use just the localizer scale, or the whole composite PFD, or anything in between.
<script lang="ts">
import {
PFD,
AttitudeIndicator, SpeedTape, AltitudeTape, VSI, HeadingTape,
LocalizerScale, GlideslopeScale, FMAStrip, RadioAltimeter,
} from 'radarscope/instruments';
</script>
<!-- Drop a single instrument anywhere -->
<LocalizerScale deviation={0.4} />
<!-- Or the whole PFD -->
<PFD
pitch={-2} roll={0} fd={{ pitch: -2.5, roll: 0 }} slip={0}
ias={140} vref={135} selectedSpeed={140} mach={0.32} accelKt={0}
alt={1200} selectedAlt={800} baro={29.92} fpm={-700}
hdg={265} hdgBug={265} track={267}
locDots={0.3} gsDots={-0.4}
fma={{ at: 'SPEED', lat: 'LOC', vert: 'G/S', app: 'LAND 3' }}
ra={420} da={200}
/>Widgets:
| Component | Required props | Notable optional |
|---|---|---|
| AttitudeIndicator | pitch, roll | fd (V-bars), slip (g) |
| SpeedTape | ias | vref, bugs[], selected, mach, accelKt (trend), vmo / vstall / vmin / vfe (color bands), gs |
| AltitudeTape | alt, baro | selectedAlt, baroUnit, fpm (trend), da / daSource (DA tick + alt alert when within 1000 ft) |
| VSI | fpm | selectedFpm (V/S autopilot reference) |
| HeadingTape | hdg | bug, track, course (DTK) |
| LocalizerScale | deviation (dots) | — |
| GlideslopeScale | deviation (dots) | — |
| FMAStrip | at, lat, vert, app | boxMs (mode-change outline duration) |
| RadioAltimeter | ra (ft AGL) | da (turns amber + "MINIMUMS" annunciator below DA) |
| PFD | all of the above | composite, layout only |
Conventions:
- Localizer/glideslope follow CDI convention: positive deviation moves the diamond to where the course is (opposite the aircraft offset). Past ±2 dots the diamond pegs amber.
- FMA columns turn amber on
LAND 2/NO AUTOLAND, green onLAND 3/FLARE. Any value change draws a white outline for 10 s — the CAT IIIb autoland watch cue. - Radio altimeter blanks above 2500 ft AGL and turns amber when RA ≤ DA, with a "MINIMUMS" annunciator.
- Speed tape shows Mach below the IAS box only at M ≥ 0.40.
Math helpers (in radarscope core, useful for converting physical state into widget props):
import {
clampDots, dotsToOffset,
inHgToHpa, hpaToInHg,
vsiNeedleDeg,
LOC_DEG_PER_DOT, GS_DEG_PER_DOT, RA_VISIBLE_BELOW_FT,
} from 'radarscope';Theming
The lib reads CSS custom properties with sensible dark-mode defaults. Override any of:
:root {
--scope-bg: #0c1116;
--scope-stroke: #3a4750;
--scope-blip: #a3cef1;
--scope-conflict: #ef4444;
--scope-selected: #facc15;
--scope-tag: #cdd9e2;
--scope-tag-dim: #97a4ab;
--scope-runway: #cdd9e2;
--scope-final: #6b7480;
--scope-waypoint: #6096ba;
--scope-route: #6096ba;
/* Instruments (PFD widgets) */
--pfd-bg: #0a0c10;
--pfd-fg: #ffffff;
--pfd-sky: #2b6cb0;
--pfd-ground: #6b4423;
--pfd-magenta: #d946ef; /* selected speed/alt, FD V-bars, CDI diamonds */
--pfd-amber: #ffb000; /* aircraft reference, heading box, MINIMUMS, alt alert */
--pfd-vref: #16a34a; /* VREF reference, trend vectors, LAND 3 */
--pfd-cyan: #22d3ee; /* DA / MDA tick on altitude tape */
--pfd-fma: #1a1d23; /* FMA strip background */
--pfd-bezel: #2a2f38; /* PFD outer border */
}For a light theme, override --scope-bg / --pfd-bg and the contrast colors; everything else flows.
Demo
npm install
npm run devOpens a tabbed sandbox:
- Radar scope — aircraft count slider, range rings, wind controls, click-to-select, conflict highlighting from the geometry helpers.
- Instruments — every PFD widget with sliders for each prop, plus the composite
<PFD>driven from the same state.
Tests
npm run testUnit tests cover the geometry primitives (the load-bearing math) and the SVG-string renderer.
Data attribution
Bundled airport + runway data is derived from OurAirports, released into the public domain (CC0). Re-distributing the bundled JSON is permitted; attributing OurAirports is encouraged.
License
MIT (the library code). The bundled airport data is CC0 from OurAirports.
