| name | map-layer-add |
| description | Add a new toggleable overlay to the Leaflet map (layers control checkbox on the top-right). Handles the proxy-overlay pattern (dispatching to a setter that manages data loading + rendering), wires persistence of the checkbox state, and plugs into the existing layers-control registry. Use when the user asks to add a map overlay — weather radar, flight paths, wind barbs, surface winds, METAR stations, MLAT coverage, custom GeoJSON. For changes to base tiles (OpenStreetMap / Satellite / Topographic) see the `baseLayers` block in map_setup.js directly. |
Adding a map overlay
Leaflet overlays are driven by a small registry in map_setup.js —
adding a new toggle is one entry in PROXY_OVERLAYS plus a setter
function that manages the actual rendering. This skill documents the
pattern so new overlays integrate cleanly with the layers control,
the keyboard shortcuts, and the "which overlays are on" persistence
without copy-pasting boilerplate into unrelated files.
Which pattern to use
Two overlay styles are supported:
| Style | When | Example |
|---|
| Proxy overlay | Overlay is rendered by a dedicated JS module that fetches + draws data (blackspots.js, openaip.js) and owns its own L.layerGroup. The layers-control checkbox dispatches to a setX(on) handler. | Airports, Navaids, Blackspots, Coverage, Airspaces |
| Tile overlay | Overlay is just a L.tileLayer pointed at a raster source — Leaflet's native add/remove handles the lifecycle. | IFR Low (US), IFR High (US) |
Pick proxy for anything data-driven (GeoJSON, canvas markers, line
work). Pick tile for raster map tiles from an external service.
Touchpoints (proxy overlay)
app/static/<name>_layer.js (new file) — the module that
owns the overlay: layer group, data fetch, canvas rendering,
setFoo(on) enable/disable function.
app/static/map_setup.js — create the proxy layerGroup, add
one row to PROXY_OVERLAYS.
app/static/app.js — import setFoo and pass it into
initMap({ …, setFoo }).
app/static/shortcuts.js (optional) — bind a keyboard
shortcut (F = follow, L = labels, T = trails, etc.) if the
overlay earns one. Skip for niche overlays.
- Backend — if the overlay needs server data, add an endpoint
like
/api/<name>?bbox=… and a record type in Program.cs +
the matching FlightJar.Clients/… client if the source is
external.
- Tests —
tests/e2e/smoke.spec.js has a template test for
overlay wiring (Terrain blackspots overlay is wired into the layers control). Duplicate it for the new overlay.
- README — add to the "Map layers" bullet under "Using the map".
Step 1 — Write the layer module
Create app/static/<name>_layer.js. The module owns one
L.layerGroup that sits in the registry, a fetch function that pulls
data from the backend, and a setFoo(on) entry point:
import { state } from './state.js';
const layer = L.layerGroup();
let loaded = false;
let inflight = null;
async function load() {
if (inflight) return inflight;
inflight = (async () => {
const r = await fetch('/api/foo');
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data = await r.json();
layer.clearLayers();
for (const item of data.items || []) {
L.circleMarker([item.lat, item.lon], { radius: 4, color: '#4da6ff' })
.bindTooltip(item.label).addTo(layer);
}
loaded = true;
})();
try { await inflight; } finally { inflight = null; }
}
export function setFoo(on) {
if (on) {
state.map.addLayer(layer);
if (!loaded) load().catch((e) => console.warn('foo layer load', e));
} else {
state.map.removeLayer(layer);
}
}
Guidelines:
- Use
state.map (the shared Leaflet instance) rather than re-importing.
- For dense point sets (> 500), use the shared
state.airportsCanvas renderer passed via L.canvas({ padding })
— thousands of markers need canvas, not SVG.
- Debounce
moveend fetches (bbox-driven overlays) to 250-300 ms.
- Persist "is the overlay on" state via
localStorage keyed by
flightjar.<name>_on so checkbox state survives reloads. The
layers-control wiring in map_setup.js does this automatically for
tile overlays via storageKey; proxy overlays need to save it
inside setFoo.
Step 2 — Register in map_setup.js
Add the proxy + row inside initMap:
const fooProxy = L.layerGroup();
const PROXY_OVERLAYS = [
{ label: 'Foo radar', proxy: fooProxy, handler: 'setFoo' },
];
The proxy isn't drawn — it exists as a Leaflet-visible handle so the
layers-control checkbox has something to add/remove. The real
add/remove runs inside setFoo via the dispatcher at the bottom of
initMap.
If the overlay should default to on for new users, add it to the
initiallyOn set near the bottom of initMap.
Step 3 — Wire the handler in app.js
Import the setter and pass it to initMap:
import { setFoo } from './foo_layer.js';
initMap({
config,
setFoo,
});
initMap spreads everything after config into overlayHandlers,
so the name you use here must match the handler: string in
PROXY_OVERLAYS.
Step 4 — Backend endpoint (if needed)
If the overlay reads server data, add a minimal-API endpoint in
Program.cs. Keep it bbox-aware so the frontend can re-fetch on
pan/zoom without transferring the whole dataset each time:
app.MapGet("/api/foo", (double? minLat, double? maxLat, double? minLon, double? maxLon) =>
{
if (minLat is null || maxLat is null || minLon is null || maxLon is null
|| minLat < -90 || maxLat > 90 || minLon < -180 || maxLon > 180)
{
return Results.BadRequest();
}
return Results.Ok(new FooPayload(Items: ));
});
For external data sources (not bundled), add a client via the
external-client-add skill before wiring this endpoint.
Step 5 — Tests
Smoke test: assert the overlay appears in the layers-control DOM
with the right label. Good template in tests/e2e/smoke.spec.js:
test('Foo overlay is wired into the layers control', async ({ page }) => {
await page.hover('.leaflet-control-layers');
await expect(
page.locator('.leaflet-control-layers-overlays label', { hasText: 'Foo radar' })
).toBeVisible();
});
If the backend endpoint is load-bearing, add an API test in
FlightJar.Api.Tests asserting 200 with a valid bbox + 400 on bad
input.
Step 6 — README
Extend the "Map layers" bullet in the "Using the map" section of
README.md with a short description of what the overlay shows and
how it behaves (zoom-gate? requires an env var? expensive to toggle?).
Do not
- Do not add SVG markers for dense point sets (> 500 points). Leaflet
renders each as a separate DOM node; scroll perf dies. Use a
canvas renderer — the shared
airportsCanvas works for most cases.
- Do not fetch data on every pan. Debounce
moveend (~300 ms) and
use bbox-bounded endpoints.
- Do not block map interaction during a fetch.
setFoo(true) should
add the empty layer group immediately and async-load the content
in the background; the user sees markers appear rather than a
janky pause.
- Do not persist overlay state via a global variable. Use
localStorage.setItem('flightjar.<name>_on', '1'|'0') so a reload
preserves what the user was looking at.
- Do not forget the zoom-gate check. If your overlay is dense at
zoom < 5 (continental views), hide markers below a minimum zoom or
serve a decimated response from the backend. See how airspaces
(z5+), reporting points (z7+), and obstacles (z9+) do it in
openaip.js.
- Do not add a toggleable overlay for something that's always on (a
base tile layer, a single receiver dot). Those live outside the
layers control.