CodingNic

Watch Regions & History

Prompting Free-Pan and Aircraft Trails

Watch Regions & History 30 min read

Prompting Free-Pan and Aircraft Trails

Objectives

By the end of this chapter, you should be able to:

  • Prompt a stateless, on-demand /api/live/:lat/:lon/:radius endpoint that looks up live traffic anywhere, independent of the continuously-watched regions
  • Direct an AI assistant to distinguish a programmatic map move from a real user pan, and switch into a “free-pan” mode without leaving the map stuck there after a reload
  • Prompt a fading DB-backed trail behind the currently-selected aircraft, and catch an AI assistant writing a trail query with no bound on how much it can return

💡 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 is also the chapter where an AI assistant’s instincts about “state” and “query limits” get tested the hardest, because the wrong instinct in both cases still runs fine in your first five minutes of testing.

Two Features, One Trap Each

Free-pan and trails don’t share any 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 they share the same shape of risk: both introduce state or a query that behaves correctly in a quick test and wrong over time. Free-pan’s active flag is the kind of thing an AI assistant reflexively wants to persist, because that’s the default instinct for “state that should survive a reload.” It shouldn’t survive a reload here, the operator panning off the watch region five minutes ago has nothing to do with what they want to see when they open the app tomorrow. Trails, meanwhile, are a query over a table that’s being written to every 15 seconds by design, forever. A query with no time bound and no row cap doesn’t error today. It just gets slower every week you keep the app running against a busy region.

The Prompt

code
Two features for SKYWATCH's map, apps/server and apps/web: 1. A stateless "look anywhere" endpoint. apps/server/src/routes/live.ts: GET /api/live/:lat/:lon/:radius, validated with zod (lat -90..90, lon -180..180, radius 1..250), calls the existing fetchPoint() from apps/server/src/poller/adsbClient.js (the same function the poller already uses per watch region) and returns its result directly. No DB write, no repo function, this is a pure pass-through, there's nothing to persist. 2. Free-pan on the frontend. When the operator drags or zooms the map themselves (not when our own code calls setView/panTo), stop relying on the WS-pushed feed and poll /api/live for whatever's in view instead, refreshed every 15s and on further moves (debounced ~400ms). Requirements: - Add a freePan slice to the Zustand store: { active, aircraft, lastUpdate }. This must be in-memory, session-only state, not persisted to localStorage or any persist middleware. A user panning away from the watch region five minutes ago should have zero effect on where the map opens next time they load the app. - You need a way to tell "the code moved the map" apart from "the user moved the map" via Leaflet's moveend event, since both fire it. Every single call site anywhere in the codebase that calls map.setView or map.panTo programmatically (the initial view setter, jumping to a region from the watch-regions panel, setting a new default, and the "return to watch region" button we're about to add) must tag the move before making it, or that action will incorrectly be read as a user pan and flip the map into free-pan mode. - Add a "RETURN TO WATCH REGION" button, visible only while free-pan is active, that deactivates free-pan and pans back to the current default region (read live from the store, not a stale prop). 3. Aircraft trails. New repo function getTrail(hex, minutes) in apps/server/src/db/repos/positionSnapshotsRepo.ts: query position_snapshots for one hex, ordered oldest to newest, bounded to the last `minutes` minutes AND capped at a fixed max row count as a backstop, since this table grows forever and a hardcoded LIMIT is the only thing standing between "one popular aircraft's trail" and "an unbounded table scan." Expose it as GET /api/trails/:hex?minutes=N (default 10, max 180). Frontend: a useTrail hook that refetches every 15s for the selected aircraft, and a TrailLayer component rendering a dashed, faded polyline under the aircraft markers. Show me the full route, store additions, the moveend detection mechanism, and the trail repo function, hook, and component.

What It Built

ts
// 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 });
    }
  });
}
ts
// apps/server/src/routes/index.ts
import { registerLiveRoutes } from "./live.js";
// ...
registerLiveRoutes(api);
ts
// 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)));
}
ts
// 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);
}

Note what’s absent here: no persist middleware wrapping the store, no localStorage read on init for freePan. That’s not an oversight to fix, it’s the point, this state needs to reset to { active: false, ... } on every fresh load.

tsx
// 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.

Wire both into MapContainer alongside AircraftMarkers, and switch AircraftMarkers itself from selectPrimaryAircraft to selectViewAircraft so markers render whichever feed is actually active:

tsx
// 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} />}
ts
// 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() }));
}
ts
// 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 };
  });
}
ts
// 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;
}
tsx
// 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.

Review This

Did freePan end up in a persist middleware, or plain in-memory state? Zustand’s persist wrapper is a common, reasonable default an AI assistant reaches for whenever a field is named something like active, because “state the app should remember” is exactly what persist is for in most other contexts. Here it’s wrong, and it’s wrong in a way that only shows up on your second session, not your first. If it lands in persist, everything works perfectly the first time you pan the map, and the bug is invisible until you close the tab and reopen the app, at which point it loads straight into the last free-pan view instead of the default watch region, silently ignoring the entire “opens on the operator’s chosen default” feature from the previous chapter. Check the store definition for a persist(...) wrapper or any localStorage read touching freePan. Follow-up: “freePan needs to be plain in-memory Zustand state, not wrapped in persist or read from localStorage, it should always start as { active: false } on a fresh load.”

Does every programmatic map move actually call markProgrammaticMove, or did one call site get missed? This is the sharpest edge in the whole mechanism, because it’s a convention, not a type-enforced contract, nothing stops a new map.setView() call from skipping the tag, and TypeScript will not warn you. The most likely place to check first is RecenterControl’s own button: if its onClick calls map.setView(target.center, target.zoom) without first calling markProgrammaticMove(map), clicking “RETURN TO WATCH REGION” will trigger moveend, which ViewportFeed reads as a real user pan since the tag was never set, which calls activateFreePan() again immediately. The visible symptom is the button appearing to do nothing, or worse, flickering, the map snaps back for a frame and then free-pan silently reactivates for the exact same view you just tried to leave. Check markProgrammaticMove(map) is called immediately before every setView/panTo in RecenterControl, WatchRegionsControl, and InitialViewSetter. Follow-up: “RecenterControl’s button needs to call markProgrammaticMove(map) immediately before map.setView, the same as every other place that moves the map programmatically, otherwise returning to the watch region immediately re-triggers free-pan.”

Does getTrail actually have both a time bound and a row cap, or just one? The prompt asked for both because they guard against different things, gte(recordedAt, since) bounds how far back the query looks, .limit(500) bounds how many rows come back even within that window. An AI assistant reading “trail” as “recent points for one aircraft” might reasonably implement only the time filter and skip the .limit(), reasoning that 15 minutes of one aircraft’s data can’t be that many rows, that’s plausible, and it’s true for a typical aircraft. It’s false for one broadcasting more frequently than the poller’s own cadence assumes, or for a bug elsewhere that causes duplicate snapshot writes, either of which turns “a fading line behind one plane” into a query that returns thousands of rows and a polyline component trying to render all of them. Check both the gte clause and the .limit(500) are present in the db.select() chain. Follow-up: “getTrail is missing a hard row cap, add .limit(500) after the orderBy so one aircraft with unusually dense position data can’t return an unbounded result.”

Try It

  1. 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.
  2. Click ⌂ RETURN TO WATCH REGION and confirm the map snaps back and stays back, watch for it flickering into free-pan again for the same view, that’s the missed-tag bug from Review This. Confirm the button disappears and the live WS feed resumes (check the connection indicator and that positions keep refreshing every ~15s again).
  3. Pan away from the watch region, then close the tab entirely and reopen the app fresh. Confirm it opens on the default watch region, not the free-pan view you left it in, that’s the persisted-state check.
  4. 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, and open your network tab to confirm the /api/trails/:hex response stays a sane size rather than growing unbounded the longer you leave it selected.
  5. 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

  • markProgrammaticMove plus the moveend tag-check is the whole mechanism that tells “the code moved the map” apart from “the user moved the map.” Every call site that pans or zooms the map has to remember to tag itself, and an AI assistant adding a new one later (like RecenterControl) is exactly where that convention is most likely to get dropped.
  • Free-pan state has to reset on every load by design, an AI assistant’s instinct to persist “active” state is right most of the time and wrong here specifically, because this is about what the map is showing right now, not a preference to remember.
  • selectViewAircraft is now the one selector the map, list, and detail panel should all read from, selectPrimaryAircraft alone no longer reflects what’s actually on screen once free-pan can override it.
  • A trail query needs both a time bound and a row cap, not one or the other. They guard against different failure shapes, and a missing .limit() is invisible until a specific aircraft’s data density exposes it.

Next lesson: historical playback, reconstructing any recent moment from position_snapshots and a scrubber to step through it.