Free-Pan and Aircraft Trails
Objectives
By the end of this chapter, you should be able to:
- Add a stateless, on-demand
/api/live/:lat/:lon/:radiusendpoint that looks up live traffic anywhere, independent of the continuously-watched regions - Detect the difference between a programmatic map move and a real user pan, and switch the map into a “free-pan” mode that polls that endpoint for whatever’s currently in view
- Give the operator a one-click way back to the watch region, and add a fading DB-backed trail behind the currently-selected aircraft
๐ก Why this matters: A watch region is deliberately narrow โ the poller only records what’s inside one, because recording the whole world every 15 seconds isn’t a real option. But an operator dragging the map around still expects to see something wherever they look, not a blank ocean the moment they pan off the watched area. This chapter adds that “look anywhere” fallback, plus the trail feature that only makes sense once an aircraft has some recorded history behind it.
A Stateless Lookup Endpoint
fetchPoint โ the same function the poller already uses every cycle for each watch region โ is general enough to answer “what’s near this arbitrary point right now” too. The route just needs to validate the input and hand it off.
// apps/server/src/routes/live.ts
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { fetchPoint } from "../poller/adsbClient.js";
import { UpstreamError } from "../lib/http.js";
const paramsSchema = z.object({
lat: z.coerce.number().min(-90).max(90),
lon: z.coerce.number().min(-180).max(180),
radius: z.coerce.number().min(1).max(250),
});
/**
* Stateless, on-demand proxy for live positions anywhere โ not just the
* continuously-watched regions. No DB write, no trails, no alerts, just a
* live look. Lets the frontend pan anywhere on top of the persistent
* watch-region features.
*/
export function registerLiveRoutes(app: FastifyInstance): void {
app.get("/live/:lat/:lon/:radius", async (req, reply) => {
const parsed = paramsSchema.safeParse(req.params);
if (!parsed.success) {
return reply.status(400).send({ error: "invalid lat/lon/radius", issues: parsed.error.issues });
}
try {
const data = await fetchPoint(parsed.data.lat, parsed.data.lon, parsed.data.radius);
return data;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const status = err instanceof UpstreamError ? 502 : 500;
return reply.status(status).send({ error: message });
}
});
}
// apps/server/src/routes/index.ts
import { registerLiveRoutes } from "./live.js";
// ...
registerLiveRoutes(api);
One new import, one new call โ the same registry pattern from Module 2. No repo function backs this route because there’s nothing to persist; it’s a direct pass-through to fetchPoint, same as the poller’s own use of it.
Turning a Viewport Into a Radius
The frontend needs to turn “however much of the map is currently visible” into a single radius number fetchPoint can use. distanceNm already exists; this is the second geo helper geo.ts was left waiting for back in Module 2.
// packages/shared/src/geo.ts (add to the Module 2 version)
/** Radius (nm) derived from a Leaflet map viewport (center-to-corner meters), capped at adsb.lol's 250nm max. */
export function viewRadiusNm(centerToNeMeters: number): number {
const nm = centerToNeMeters / 1852;
return Math.max(10, Math.min(250, Math.round(nm)));
}
(bearingDeg still isn’t needed yet โ it joins this file when a later module adds distance/bearing readouts.)
The Free-Pan Store Slice
Free-pan needs its own place to live in the store: whether it’s active, what it last fetched, and when. It also changes what “the aircraft the map should show” means โ that’s selectViewAircraft, replacing direct reads of selectPrimaryAircraft everywhere the map, list, and detail panel render aircraft.
// apps/web/src/store/useAppStore.ts (additions to lesson 1's version)
/**
* Live positions for whatever area the map is currently showing, fetched
* on-demand from /api/live when the user pans/zooms away from the primary
* watch region โ mirrors the original app's "look anywhere" behavior on
* top of the watch-region-anchored WS push.
*/
interface FreePanState {
active: boolean;
aircraft: AircraftState[];
lastUpdate: Date | null;
}
interface AppState {
// ...existing fields from lesson 1...
freePan: FreePanState;
activateFreePan: () => void;
deactivateFreePan: () => void;
setFreePanAircraft: (aircraft: AircraftState[], ts: number) => void;
}
export const useAppStore = create<AppState>((set) => ({
// ...existing fields...
freePan: { active: false, aircraft: EMPTY_AIRCRAFT, lastUpdate: null },
activateFreePan: () =>
set((state) => (state.freePan.active ? state : { freePan: { ...state.freePan, active: true } })),
deactivateFreePan: () =>
set((state) => ({ freePan: { active: false, aircraft: EMPTY_AIRCRAFT, lastUpdate: state.freePan.lastUpdate } })),
setFreePanAircraft: (aircraft, ts) =>
set((state) => ({ freePan: { ...state.freePan, active: true, aircraft, lastUpdate: new Date(ts) } })),
}));
/**
* Aircraft for whatever the map is currently showing: the on-demand
* /api/live result once the user has panned/zoomed away from the primary
* region, or the primary watch region's WS-pushed feed otherwise. This is
* what the map markers, flight list, and detail panel should all read
* from, so they stay in sync with what's actually visible.
*/
export function selectViewAircraft(state: AppState): AircraftState[] {
if (state.freePan.active) return state.freePan.aircraft;
return selectPrimaryAircraft(state);
}
activateFreePan’s no-op guard (state.freePan.active ? state : ...) matters more than it looks โ moveend can fire in bursts during a drag, and returning the exact same state object when nothing actually changed keeps Zustand from notifying subscribers on every one of those redundant calls.
Distinguishing Real Pans From Programmatic Ones
Every place the code moves the map on its own โ the initial view, jumping to a region, setting a default โ needs to avoid tripping the free-pan detector built next.
// apps/web/src/components/MapPanel.tsx (additions)
import { useCallback, useRef } from "react";
import { useMapEvents } from "react-leaflet";
import type { AdsbPointResponse } from "@skywatch/shared";
import { viewRadiusNm } from "@skywatch/shared";
import { apiUrl } from "@/lib/serverUrl";
const FREE_PAN_POLL_MS = 15_000;
const FREE_PAN_DEBOUNCE_MS = 400;
/**
* Mirrors the original app's "look anywhere" behavior: once the user pans
* or zooms the map themselves, stop relying on the primary watch region's
* WS-pushed feed (which only ever covers that region) and instead poll the
* stateless /api/live/:lat/:lon/:radius endpoint for whatever's currently
* in view, refreshing on an interval and after further moves.
*/
function ViewportFeed() {
const map = useMap();
const freePanActive = useAppStore((s) => s.freePan.active);
const activateFreePan = useAppStore((s) => s.activateFreePan);
const setFreePanAircraft = useAppStore((s) => s.setFreePanAircraft);
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const pollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const fetchForCurrentView = useCallback(() => {
const center = map.getCenter();
const bounds = map.getBounds();
const radiusNm = viewRadiusNm(center.distanceTo(bounds.getNorthEast()));
fetch(apiUrl(`/api/live/${center.lat}/${center.lng}/${radiusNm}`))
.then((r) => r.json())
.then((data: AdsbPointResponse) => setFreePanAircraft(data.ac ?? [], Date.now()))
.catch((err) => console.error("[free-pan] fetch failed:", err));
}, [map, setFreePanAircraft]);
useMapEvents({
moveend: () => {
const tagged = map as unknown as { __skywatchProgrammatic?: boolean };
if (tagged.__skywatchProgrammatic) {
tagged.__skywatchProgrammatic = false;
return;
}
activateFreePan();
if (debounceTimer.current) clearTimeout(debounceTimer.current);
debounceTimer.current = setTimeout(fetchForCurrentView, FREE_PAN_DEBOUNCE_MS);
},
});
useEffect(() => {
if (!freePanActive) {
if (pollTimer.current) {
clearInterval(pollTimer.current);
pollTimer.current = null;
}
return;
}
fetchForCurrentView();
pollTimer.current = setInterval(fetchForCurrentView, FREE_PAN_POLL_MS);
return () => {
if (pollTimer.current) clearInterval(pollTimer.current);
};
}, [freePanActive, fetchForCurrentView]);
useEffect(() => {
return () => {
if (debounceTimer.current) clearTimeout(debounceTimer.current);
};
}, []);
return null;
}
/**
* Jumps back to the primary watch region's live view and resumes the
* WS-pushed feed. Reads the target from the store's defaultRegionView
* (falling back to `center`/`zoom`, the view computed once at mount)
* rather than solely from those fallback props โ WatchRegionsControl
* overwrites defaultRegionView the instant the user picks a new default,
* so "return" goes to whatever is default *now*, not whatever was default
* when this component mounted.
*/
function RecenterControl({ center, zoom }: { center: [number, number]; zoom: number }) {
const map = useMap();
const freePanActive = useAppStore((s) => s.freePan.active);
const deactivateFreePan = useAppStore((s) => s.deactivateFreePan);
const defaultRegionView = useAppStore((s) => s.defaultRegionView);
if (!freePanActive) return null;
const target = defaultRegionView ?? { center, zoom };
return (
<button
type="button"
onClick={() => {
deactivateFreePan();
markProgrammaticMove(map);
map.setView(target.center, target.zoom);
}}
className="absolute right-3.5 top-3.5 z-[500] rounded-sm border border-line bg-surface/90 px-3 py-2 text-[11px] tracking-[0.08em] text-phosphor transition-colors hover:border-phosphor-dim"
>
โ RETURN TO WATCH REGION
</button>
);
}
Every call site that moves the map programmatically โ InitialViewSetter from lesson 1, jumpTo/setDefault in WatchRegionsControl, and RecenterControl’s own button here โ calls markProgrammaticMove(map) right before setView/panTo. ViewportFeed’s moveend handler checks that tag first and clears it, so only a real drag or scroll-zoom ever flips the map into free-pan mode. Miss one call site and that one action would look, to ViewportFeed, indistinguishable from the user grabbing the map themselves.
Wire both into MapContainer alongside AircraftMarkers, and switch AircraftMarkers itself from selectPrimaryAircraft to selectViewAircraft so markers render whichever feed is actually active:
// apps/web/src/components/MapPanel.tsx (inside MapContainer)
<AircraftMarkers /> {/* now reads selectViewAircraft */}
<WatchRegionsControl />
<ViewportFeed />
{loaded && <InitialViewSetter center={initialView.center} zoom={initialView.zoom} />}
{loaded && <RecenterControl center={initialView.center} zoom={initialView.zoom} />}
Aircraft Trails
Trails read the same position_snapshots table Module 1 modeled and Module 2’s poller has been writing to every cycle since โ this is the first lesson to actually query it back out.
// apps/server/src/db/repos/positionSnapshotsRepo.ts
import { and, asc, eq, gte } from "drizzle-orm";
import { db } from "../client.js";
import { positionSnapshots } from "../schema.js";
export interface TrailPoint {
lat: number;
lon: number;
altBaroFt: number | null;
recordedAt: string;
}
/** Recent points for a single aircraft, oldest first โ draws the fading trail polyline. */
export async function getTrail(hex: string, minutes: number): Promise<TrailPoint[]> {
const since = new Date(Date.now() - minutes * 60_000);
const rows = await db
.select({
lat: positionSnapshots.lat,
lon: positionSnapshots.lon,
altBaroFt: positionSnapshots.altBaroFt,
recordedAt: positionSnapshots.recordedAt,
})
.from(positionSnapshots)
.where(and(eq(positionSnapshots.hex, hex), gte(positionSnapshots.recordedAt, since)))
.orderBy(asc(positionSnapshots.recordedAt))
.limit(500);
return rows.map((r) => ({ ...r, recordedAt: r.recordedAt.toISOString() }));
}
// apps/server/src/routes/trails.ts
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { getTrail } from "../db/repos/positionSnapshotsRepo.js";
const paramsSchema = z.object({ hex: z.string().regex(/^~?[0-9a-fA-F]{1,6}$/) });
const querySchema = z.object({ minutes: z.coerce.number().min(1).max(180).default(10) });
export function registerTrailRoutes(app: FastifyInstance): void {
app.get("/trails/:hex", async (req, reply) => {
const params = paramsSchema.safeParse(req.params);
const query = querySchema.safeParse(req.query);
if (!params.success || !query.success) return reply.status(400).send({ error: "invalid request" });
const points = await getTrail(params.data.hex.toLowerCase(), query.data.minutes);
return { hex: params.data.hex.toLowerCase(), points };
});
}
Register it the same way as live.ts above: import registerTrailRoutes in routes/index.ts, call it inside registerApiRoutes.
// apps/web/src/hooks/useTrail.ts
"use client";
import { useEffect, useState } from "react";
import { apiUrl } from "@/lib/serverUrl";
export interface TrailPoint {
lat: number;
lon: number;
altBaroFt: number | null;
recordedAt: string;
}
const TRAIL_MINUTES = 15;
const REFRESH_MS = 15_000;
/**
* Recent DB-backed track history for the selected aircraft, drawn as a
* fading trail on the map. Only ever populated for aircraft the poller has
* actually been recording (i.e. inside a watch region) โ selecting an
* aircraft found via free-pan simply yields an empty trail, which is
* correct: there's no history for it.
*/
export function useTrail(hex: string | null): TrailPoint[] {
const [points, setPoints] = useState<TrailPoint[]>([]);
useEffect(() => {
if (!hex) {
setPoints([]);
return;
}
let cancelled = false;
const load = () => {
fetch(apiUrl(`/api/trails/${hex}?minutes=${TRAIL_MINUTES}`))
.then((r) => (r.ok ? r.json() : null))
.then((data: { points: TrailPoint[] } | null) => {
if (!cancelled && data) setPoints(data.points);
})
.catch((err) => console.error("[trail] fetch failed:", err));
};
load();
const timer = setInterval(load, REFRESH_MS);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [hex]);
return points;
}
// apps/web/src/components/TrailLayer.tsx
"use client";
import { useMemo } from "react";
import { Polyline } from "react-leaflet";
import { useAppStore } from "@/store/useAppStore";
import { useTrail } from "@/hooks/useTrail";
/** Fading DB-backed track history for whichever aircraft is currently selected. */
export function TrailLayer() {
const selectedIcao = useAppStore((s) => s.selectedIcao);
const points = useTrail(selectedIcao);
const positions = useMemo(() => points.map((p) => [p.lat, p.lon] as [number, number]), [points]);
if (positions.length < 2) return null;
return <Polyline positions={positions} pathOptions={{ color: "var(--amber)", weight: 2.5, opacity: 0.65, dashArray: "1 6" }} />;
}
Add <TrailLayer /> inside MapContainer, ahead of <AircraftMarkers /> so the line renders under the markers rather than over them. The points.length < 2 guard matters for a fresh selection: a single recorded point has nowhere to draw a line to yet, and returning null is simpler than a polyline component trying to render a zero-length path.
Try It
- Restart the frontend, then click and drag the map away from the watch region. Confirm the โ RETURN TO WATCH REGION button appears and aircraft markers keep updating for whatever’s now in view.
- Click โ RETURN TO WATCH REGION and confirm the map snaps back, the button disappears, and the live WS feed resumes (check the connection indicator and that positions keep refreshing every ~15s again).
- Select an aircraft inside a watch region and leave it selected across two or three poll cycles (roughly 30-45s). Confirm a dashed amber line starts extending behind it.
- Pan away into free-pan mode, select an aircraft found there, and confirm its trail is empty โ there’s no history for anything the poller hasn’t been recording.
Recap
markProgrammaticMoveplus themoveendtag-check is the whole mechanism that tells “the code moved the map” apart from “the user moved the map” โ every new call site that pans or zooms the map going forward has to remember to tag itself, or it’ll be misread as a user pan.selectViewAircraftis now the one selector the map, list, and detail panel should all read from โselectPrimaryAircraftalone no longer reflects what’s actually on screen once free-pan can override it.- Trails and free-pan share nothing at the data layer โ free-pan is a stateless proxy to adsb.lol for wherever you’re looking, trails are a DB query scoped to one aircraft’s watch-region history โ but both exist because “a watch region is narrow by design” has consequences the UI needs to account for honestly.
Next lesson: historical playback โ reconstructing any recent moment from position_snapshots and a scrubber to step through it.