Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ CIFP file facts (verified against live FAA data, June 2026):

**Collapsible pane + mobile bottom sheet.** `usePaneStore` is a plain (non-domain) Zustand store: `collapsed` (desktop/tablet rail-collapse, persisted) and `sheetOpen` (phone bottom-sheet expanded vs. peeking, session-only). `deriveMode(widthPx)` is a pure function (no DOM read) mapping viewport width to `'push'` (desktop — the sidebar reflows `.mapArea` on collapse, and `AppMap` exposes `resize()` fired on the rail's `transitionend` since mapbox-gl doesn't notice a reflowed container on its own) or `'overlay'` (phones ≤640px, `PANE_OVERLAY_BREAKPOINT_PX` — the sidebar becomes a fixed-position bottom sheet with a 44px handle bar, and the map canvas never resizes). `Cmd/Ctrl+B` toggles the desktop collapse from anywhere outside a text input.

**Anisotropic (per-axis) zoom.** The map can be zoomed further along one screen axis than the other (e.g. zoom in horizontally to separate parallel-runway traffic while staying zoomed out vertically to see the whole approach). Mapbox has one zoom, so `useMapStore.axisRatio` (zoomY − zoomX, session-only, clamped ±`AXIS_ZOOM_MAX_RATIO`) is realized by `AxisStretchFrame`: the mapbox zoom is always the LESS zoomed axis and the frame is laid out smaller along the more-zoomed axis, then CSS-scaled up to fill the viewport (origin top-left, so layout↔visual conversion is a pure divide — pure math in `src/utils/axisZoom.ts`, unit-tested). Because the stretch is independent of the mapbox zoom, every standard zoom mechanism (wheel, pinch, double-click, NavigationControl) changes both axes together and preserves the ratio; the `AxisZoomControls` cluster (bottom-right stack) does the per-axis ± and 1:1 reset. The transform breaks mapbox's own pointer math, so while stretched the frame disables mapbox's drag/scroll/box/rotate/pitch handlers and substitutes pointer/wheel handlers that convert visual→layout deltas, re-dispatches point-consuming mouse events (click/dblclick/mousemove/contextmenu) with corrected coordinates in the capture phase, and forces bearing/pitch to 0; at 1:1 the feature is fully inert (no transform, no listeners, all native handlers). DOM content inside the stretched frame is counter-scaled to stay crisp — waypoint markers/segment labels via CSS keyed on `[data-axis-stretch]` + `--axis-inv-sx/sy` vars, the rotated course/barb/hold labels inline (their angle is re-derived with `stretchRotationDeg` to stay parallel to the stretched leg), the DataBlock popup and mapbox ctrl corners per-anchor; `AircraftOverlay` (already outside the frame) instead multiplies `map.project()` output by the stretch scales and re-derives icon headings with `stretchTrackDeg`, and ProfilePanel's obstacle-avoidance projections do the same. GL-rendered content (basemap labels, procedure lines) stretches with the map by design.

**All-US airport index, built and validated offline.** The CIFP worker already parses every US airport with published procedures, but the curated 89-airport `airports.json` gates search reachability. `scripts/buildAirportIndex.ts` (`npm run build-airport-index`, flags `--force` to bypass the download cache, `--cifp <path>` to use a local CIFP zip, `--help`) joins the CIFP's enumerated airports against OurAirports metadata to emit `public/data/airport-index.json` (one compact row per airport with ≥1 published approach, ~2–3k rows — search corpus for `useAirportSearch`) and `public/data/airports/{key}.json` shards (metadata + runway geometry, fetched on selection like a map tile). **Sandbox caveat:** building the index requires unrestricted egress to `aeronav.faa.gov`, which some sandboxes block — until it's run somewhere unrestricted, the generated files don't exist and the app transparently falls back to the bundled 89-airport set. `scripts/validateStaticData.ts` (`npm run validate-static-data`, `--live` to also sample live upstream APIs with a seeded stratified selection) checks schema/coordinate sanity and CIFP↔index cross-checks; its posture is advisory — failures below a 2% threshold per category exit 0 and are appended to `TODO-data-issues.md` (one entry per distinct failure class, each meant to become its own follow-up fix) rather than failing the run. `scripts/lib/` holds the shared join/validation helpers (`csv.ts`, `cache.ts`, `runways.ts`, `airportIndex.ts`, `validate.ts`) used by both build scripts.

**Dual procedure visibility model.** `src/store/useProcedureStore.ts` keeps `userToggles` (explicit user action) and `autoVisible` (detection engine) separate. `isVisible(id) = userToggles[id] ?? autoVisible[id] ?? false`. "Revert to auto" clears `userToggles[id]`. The store also tracks `lastDetectedAt`, `detectedHexes` (confirmed aircraft per procedure — powers hover highlighting and the profile panel; array identity is stable when contents don't change), `aircraftAssignments` (hex → the one approach that aircraft is assigned to), and `autoShownIds`.
Expand Down Expand Up @@ -179,7 +181,7 @@ render budgets (`MAX_RENDERED_PROCEDURE_LINES`, `MAX_ONSCREEN_WAYPOINT_SYMBOLS`)
ADS-B poll clustering (`POLL_CLUSTER_MAX_RADIUS_NM`), detection-machine gates and
hysteresis (`DETECT_*`, including the bbox prefilter pad `DETECT_BBOX_PAD_NM`),
glideslope math, route-cache TTLs/backoff (`ROUTE_*`), auto-hide delay,
extended-centerline length, map styles, and AIRAC/NASR cycle constants. The
extended-centerline length, map styles, per-axis zoom step/limit (`AXIS_ZOOM_STEP`, `AXIS_ZOOM_MAX_RATIO`), and AIRAC/NASR cycle constants. The
path-prediction engine adds its own groups: `TRACKLOG_*` (ring-buffer capacity,
gap-break), `PREDICT_*` (step/horizon, turn-rate clamps and hold/decay timing,
profile-capture tolerance), `HOLD_ENTRY_*` (trigger bearing/ETA/pass-distance
Expand Down
18 changes: 17 additions & 1 deletion src/components/map/AircraftOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { useAircraftStore } from '../../store/useAircraftStore'
import { useSelectionStore, selectedHexOf } from '../../store/useSelectionStore'
import { useSettingsStore } from '../../store/useSettingsStore'
import { usePathStore } from '../../store/usePathStore'
import { useMapStore } from '../../store/useMapStore'
import { stretchScales, stretchTrackDeg } from '../../utils/axisZoom'
import type { AircraftAlert } from '../../types/path'
import { formatAltitude, formatSpeed, formatHeading } from '../../utils/formatters'
import { altitudeColor } from '../../utils/colorScheme'
Expand Down Expand Up @@ -90,6 +92,10 @@ export function AircraftOverlay({ mapRef }: Props) {
// from getState per frame for the z-order/visibility side.
const showTerrainAlerts = useSettingsStore((s) => s.showTerrainAlerts)
const showTrafficAlerts = useSettingsStore((s) => s.showTrafficAlerts)
// Anisotropic zoom: icons are drawn crisp (unstretched), so their rotation
// is adjusted to match the visually stretched trajectory below.
const axisRatio = useMapStore((s) => s.axisRatio)
const { sx: stretchSx, sy: stretchSy } = stretchScales(axisRatio)

// Snapshot the airborne aircraft set; only changes on a poll.
const aircraft = useMemo(
Expand Down Expand Up @@ -117,6 +123,10 @@ export function AircraftOverlay({ mapRef }: Props) {
const { alerts, forcedVisibleHexes } = usePathStore.getState()
const minFt = positionToMinFt(altFilterMin)
const maxFt = positionToMaxFt(altFilterMax)
// Anisotropic zoom: this overlay sits OUTSIDE the stretched map frame
// (so aircraft stay crisp), which means map.project()'s layout-space
// pixels must be scaled to visual pixels here. (1, 1) at normal zoom.
const { sx, sy } = stretchScales(useMapStore.getState().axisRatio)

for (const [hex, node] of nodes.current) {
const ac = store.aircraftMap.get(hex)
Expand All @@ -143,6 +153,8 @@ export function AircraftOverlay({ mapRef }: Props) {
node.style.display = ''

const p = map.project([ac.interpLon, ac.interpLat])
p.x *= sx
p.y *= sy
node.style.transform = `translate(${p.x}px, ${p.y}px)`
node.style.color = altitudeColor(ac.altBaro)

Expand All @@ -167,6 +179,8 @@ export function AircraftOverlay({ mapRef }: Props) {
const other = otherHex ? store.aircraftMap.get(otherHex) : undefined
if (other) {
const op = map.project([other.interpLon, other.interpLat])
op.x *= sx
op.y *= sy
let dx = p.x - op.x
let dy = p.y - op.y
const dist = Math.hypot(dx, dy)
Expand Down Expand Up @@ -303,7 +317,9 @@ export function AircraftOverlay({ mapRef }: Props) {
>
<div
className={styles.iconWrap}
style={{ transform: `translate(-50%, -50%) rotate(${ac.track}deg)` }}
style={{
transform: `translate(-50%, -50%) rotate(${stretchTrackDeg(ac.track, stretchSx, stretchSy)}deg)`,
}}
>
<AircraftIcon />
</div>
Expand Down
96 changes: 53 additions & 43 deletions src/components/map/AppMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import { TrackLogLayer } from './TrackLogLayer'
import { HoldEntryLayer } from './HoldEntryLayer'
import { PredictionLayer } from './PredictionLayer'
import { PathControls } from './PathControls'
import { AxisStretchFrame } from './AxisStretchFrame'
import { AxisZoomControls } from './AxisZoomControls'
import { RenderBudgetHint } from './RenderBudgetHint'
import { ActiveProceduresOverlay } from '../layout/ActiveProceduresOverlay'
import { AltitudeFilter } from './AltitudeFilter'
Expand Down Expand Up @@ -139,66 +141,73 @@ export function AppMap() {
const handleMouseLeave = useCallback(() => setHoverCursor(false), [])

return (
<div style={{ width: '100%', height: '100%', position: 'relative' }}>
<Map
ref={mapRef}
longitude={viewport.longitude}
latitude={viewport.latitude}
zoom={viewport.zoom}
onMove={handleMove}
mapStyle={getMapStyle()}
mapboxAccessToken={import.meta.env.VITE_MAPBOX_TOKEN}
style={{ width: '100%', height: '100%' }}
attributionControl={false}
interactiveLayerIds={interactiveLayerIds}
cursor={hoverCursor ? 'pointer' : undefined}
onClick={handleMapClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<NavigationControl position="bottom-right" />
<div style={{ width: '100%', height: '100%', position: 'relative' }} data-map-root="">
{/* AxisStretchFrame implements the anisotropic (per-axis) zoom: at 1:1
it's a pass-through; otherwise it CSS-stretches the map frame along
the more-zoomed axis and takes over pointer handling. The DOM
overlays below (AircraftOverlay, control stack, ProfilePanel) stay
OUTSIDE the frame so they render unstretched. */}
<AxisStretchFrame mapRef={mapRef}>
<Map
ref={mapRef}
longitude={viewport.longitude}
latitude={viewport.latitude}
zoom={viewport.zoom}
onMove={handleMove}
mapStyle={getMapStyle()}
mapboxAccessToken={import.meta.env.VITE_MAPBOX_TOKEN}
style={{ width: '100%', height: '100%' }}
attributionControl={false}
interactiveLayerIds={interactiveLayerIds}
cursor={hoverCursor ? 'pointer' : undefined}
onClick={handleMapClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<NavigationControl position="bottom-right" />

{/* Terrain and safe-altitude overlays are always mounted (visibility
toggled via layout.visibility) rather than conditionally rendered,
so their runtime GL layers keep a stable position in the render
order across toggles — see the z-order rationale comment atop
TerrainLayer.tsx. */}
<TerrainLayer />
{/* Terrain and safe-altitude overlays are always mounted (visibility
toggled via layout.visibility) rather than conditionally rendered,
so their runtime GL layers keep a stable position in the render
order across toggles — see the z-order rationale comment atop
TerrainLayer.tsx. */}
<TerrainLayer />

<SafeAltitudeLayer items={safeAltItems} />
<SafeAltitudeLayer items={safeAltItems} />

<MvaLayer />
<MvaLayer />

<AirspaceLayer />
<AirspaceLayer />

<LocFeatherLayer />
<LocFeatherLayer />

<RunwayLayer runways={runways} />
<RunwayLayer runways={runways} />

<AirportLabelsLayer />
<AirportLabelsLayer />

{showCenterlines && <ExtendedCenterlineLayer runways={runways} />}
{showCenterlines && <ExtendedCenterlineLayer runways={runways} />}

{visibleProcedures.map((p) => (
<ProcedureLayer key={p.id} procedure={p} />
))}
{visibleProcedures.map((p) => (
<ProcedureLayer key={p.id} procedure={p} />
))}

<AutoActiveSegmentsLayer procedures={visibleProcedures} />
<AutoActiveSegmentsLayer procedures={visibleProcedures} />

<FlownSegmentLayer procedures={visibleProcedures} />
<FlownSegmentLayer procedures={visibleProcedures} />

<RangeRingsLayer />
<RangeRingsLayer />

<TrackLogLayer />
<TrackLogLayer />

<HoldEntryLayer />
<HoldEntryLayer />

<PredictionLayer />
<PredictionLayer />

<WaypointMarkers procedures={visibleProcedures} />
<WaypointMarkers procedures={visibleProcedures} />

<SelectedAircraftDataBlock />
</Map>
<SelectedAircraftDataBlock />
</Map>
</AxisStretchFrame>

{/* DOM overlay so aircraft render above the waypoint markers and stay crisp */}
<AircraftOverlay mapRef={mapRef} />
Expand All @@ -220,6 +229,7 @@ export function AppMap() {
pointerEvents: 'none',
}}
>
<AxisZoomControls />
<PathControls />
<TrafficFilter />
<AltitudeFilter />
Expand Down
21 changes: 21 additions & 0 deletions src/components/map/AxisStretchFrame.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/* ── Anisotropic-zoom frame ──────────────────────────────────────────────── */
/* Outer fills the map area and clips the (sub)pixel overhang of the scaled */
/* inner frame; inner is laid out smaller along the more-zoomed axis and */
/* scaled back up (origin top left so layout↔visual is a pure divide). */

.outer {
position: absolute;
inset: 0;
overflow: hidden;
/* Our own pointer pan/pinch handlers run while stretched. */
touch-action: none;
}

.inner {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
transform-origin: top left;
}
Loading
Loading