// v3/map.jsx — MapLibre GL map: real tiles, animated tracks with heading, CSAR overlays

// ═══════════════════════════════════════════════════════════════════
// GLOBE PROJECTION NOTES (MapLibre 5.24.x)
// ═══════════════════════════════════════════════════════════════════
// Migrated from MapLibre 3.6.2 / Mercator to MapLibre 5.24.0 / globe
// (style.projection: { type: 'globe' }). The migration was driven by
// killbox/MGRS-grid mis-alignment at UTM zone boundaries, which globe
// handles correctly because it uses true 3D spherical projection
// instead of Mercator's flat-strip approximation. (Earlier audit
// notes referenced MapLibre 4.5+ but globe support actually shipped
// in 5.0 — 4.x had no real globe.)
//
// THE CARDINAL RULE
// ─────────────────
// Any time you write code that mixes pixel-space coords with lat/lng
// coords, ASK: would this still work on a globe? On Mercator, pixel
// space is a flat rectangle aligned to lat/lng axes. On a globe, pixel
// space is a perspective projection of a sphere — "north" rotates with
// camera pan, distance-per-pixel varies by location, and screen-aligned
// math that worked on Mercator silently breaks.
//
// SAFE PATTERNS (work on both projections)
// ────────────────────────────────────────
// • map.flyTo({ center, zoom, pitch, bearing, offset, padding })
//     `offset` is in pixels but applied at fly-time relative to the
//     current camera — works on globe as long as you compute it from
//     live state (rail widths, etc.) rather than caching across moves.
// • map.project([lng, lat]) → screen pixels
//     MapLibre handles the projection conversion internally. Use this
//     instead of computing pixel offsets from lat/lng deltas yourself.
// • map.unproject([x, y]) → [lng, lat]
//     Inverse of above. Same guarantee.
// • GeoJSON sources with line/fill layers
//     MapLibre re-projects vertices automatically. Polygon/line
//     geometry defined in lat/lng renders correctly on either proj.
// • DOM markers via new maplibregl.Marker() + setLngLat()
//     Position is computed by MapLibre. Globe-safe.
// • Flat-earth math for SHORT distances (<10 nm, <60 sec dead-reckon)
//     The `_projectLatLng` and `_kmBetween` helpers below are
//     projection-agnostic and accurate at the scales we use them.
//
// DANGER PATTERNS (will break on globe)
// ─────────────────────────────────────
// • Computing screen-aligned bearings as `atan2(dLng, dLat) * 180/π`
//     This gives geodesic bearing on Mercator (north == screen-up
//     everywhere) but NOT on a globe (north rotates with camera/lat).
//     If you need a screen-aligned heading for a marker icon, you'll
//     need: trueBearing + map.transform.bearing + lat-correction.
//     Today this affects: marker icon `transform: rotate(deg)` for
//     every track in the rAF animate loop. On globe that rotation
//     needs camera-bearing math added.
// • Caching pixel offsets across multiple flyTos
//     A pixel offset that lands the IP correctly at zoom 8.5 / pitch 0
//     won't land it correctly at zoom 8.5 / pitch 55 on globe.
//     Always compute pixel offsets at fly-time from live state.
// • Tile-based heatmaps or screen-aligned overlays
//     We don't have any today. If we add them, scope the design to
//     work in geo-space (GeoJSON sources) rather than tile/pixel-space.
// • Anti-meridian-aware logic
//     Mercator wraps at ±180°; globe doesn't (sphere is continuous).
//     Code like `if (lng > 180) lng -= 360` is Mercator-specific — on
//     globe it can produce the wrong wrap. We don't have any today;
//     keep it that way.
// • Pitch-zero-as-flat assumptions
//     `pitch: 0` on Mercator gives a 2D top-down view. On globe at
//     low zoom, even pitch:0 shows curvature. Don't assume "pitch 0
//     means everything renders flat."
//
// MIGRATION STATE
// ──────────────────────────────────────────
// All globe-readiness items have been addressed:
//
// ✓ MapLibre upgraded 3.6.2 → 5.24.0
// ✓ Marker icon rotation now applies a per-marker north-angle
//   correction (see _northAngleAt below). Mercator code path was
//   `rotate(heading)`; globe path is `rotate(heading + northAngle)`.
// ✓ CSAR-mode flyTo offset math validated on globe — pixel offset
//   computed at fly-time from live rail widths, globe-safe.
// ✓ Tilt view (pitch 55, zoom 10.4) works on globe.
// ✓ Initial COP zoom retuned 1.5 → 2.5 (hemisphere view).
// ✓ Marker back-side occlusion handled via setOpacity('1', '0').
// ✓ MGRS/GARS grids: globe re-projects per-zone correctly,
//   killbox/grid alignment is now automatic.
// ✓ NVG overlay, threat rings, cross-AOR routes — all validated.
//
// Items that COULD still be improved (not blocking):
// • circleGeoJSON: still flat-earth approximation. Slightly squashed
//   at high latitudes on globe. Could swap to Haversine if needed.
// • Marker DOM positioning perf is slightly higher on globe (~1.5×)
//   but well within budget after Phase 1 perf cleanup.
// ═══════════════════════════════════════════════════════════════════

// ── Motion helpers ────────────────────────────────────────────
// Approximate orbit tangential speed (degrees per t-unit) from orbit params.
// Used to drive transit times at the track's natural speed.
function orbitSpeed(track) {
  const a = track.olat || 0.3, b = track.olng || 0.5;
  const circumference = 2 * Math.PI * Math.sqrt((a*a + b*b) / 2);
  return circumference / (track.period || 20);
}
function degDistance(p1, p2) {
  const dLat = p2.lat - p1.lat, dLng = p2.lng - p1.lng;
  return Math.sqrt(dLat*dLat + dLng*dLng);
}
function bearingDeg(from, to) {
  return Math.atan2(to.lng - from.lng, to.lat - from.lat) * 180 / Math.PI;
}

// Per-marker globe rotation correction.
//
// On Mercator, "north" is always screen-up everywhere on the map, so
// `rotate(track.heading)` maps directly to compass heading.
//
// On globe, north rotates with both camera bearing AND the marker's
// position on the sphere. A track at (60°N, 30°E) viewed from a camera
// at (0°N, 0°E) has its "true north" pointing toward a different
// screen direction than a track at (60°N, -120°E) viewed from the
// same camera. So we have to compute, per marker per frame, the
// angle between "screen-up" and "the direction to true north from
// this marker's screen position."
//
// Implementation: project the marker's lat/lng to screen pixels,
// project a point slightly north (lat + dLat) of the marker, and
// measure the screen-pixel angle of the line connecting them. That
// angle is the per-marker correction added to track.heading.
//
// Cost: 2 map.project() calls per visible track per frame. Project
// is ~5-10μs on globe — negligible at our scale (~50 visible tracks,
// 60 fps → ~1ms/sec total).
//
// At high zoom (CSAR-mode tactical view, zoom 8.5+, narrow viewport
// near a single ground point), the correction is microscopic — the
// math still runs but the visible delta vs Mercator is < 1°. At low
// zoom (global COP), high latitudes, or with camera bearing != 0,
// the correction is meaningful and gets icons pointing the right way.
function _northAngleAt(map, lng, lat) {
  if (!map) return 0;
  try {
    // Fast path at high zoom: when the camera is close to the marker
    // (zoom ≥ 8 ≈ tactical view at <500km map width), screen-up is
    // dominated by the camera's bearing alone — projection-perspective
    // contributions from the marker's position vs camera center are
    // microscopic. Skip the per-marker project() math and just use
    // -bearing. This is also MUCH more stable per-frame at high pitch
    // (tilt view zoom 10.4 / pitch 55), where map.project() of a tiny
    // dLat step can produce frame-to-frame jittery dx/dy due to
    // perspective foreshortening and floating-point precision —
    // jitter that visibly manifested as orbiting tracks (e.g. DUKE 32)
    // appearing to spaz heading and speed in tilt view.
    const zoom = map.getZoom ? map.getZoom() : 0;
    if (zoom >= 8) {
      return -((map.getBearing && map.getBearing()) || 0);
    }
    const here = map.project([lng, lat]);
    // dLat step large enough that its screen-pixel projection is
    // well above floating-point noise even at high pitch. ~1.1km
    // is fine — the local north-angle doesn't change appreciably
    // over that distance, so this trades zero accuracy for stability.
    const dLat = 0.01;
    // Clamp at poles so we never project beyond ±90.
    const aheadLat = Math.min(89.9, lat + dLat);
    const ahead = map.project([lng, aheadLat]);
    const dx = ahead.x - here.x;
    const dy = ahead.y - here.y;  // screen y increases downward
    // Angle from screen-up (0,-1) to (dx,dy). atan2(dx, -dy) gives
    // the correct sign convention for our existing rotate(deg) where
    // 0 = up, 90 = right.
    return Math.atan2(dx, -dy) * 180 / Math.PI;
  } catch(e) {
    return 0;
  }
}

// Marker occlusion handling.
//
// MapLibre's `opacityWhenCovered` controls a marker's opacity when
// it's occluded by either:
//   (a) terrain in tilt view (an aircraft behind a ridge), OR
//   (b) the globe itself (a marker on the back side of the sphere)
//
// We've chosen `'0'` — markers occluded on the BACK SIDE of the
// globe are completely hidden. Without this, tracks at
// (lat=24, lng=120) would still render as DOM elements at their
// projected screen position even when the camera is looking at
// the opposite hemisphere — producing a ghost-tracks-through-Earth
// effect. With opacity 0 they cleanly disappear when behind the planet.
//
// Trade-off: tilt-view terrain occlusion (an HH-60 behind a ridge)
// also hides the marker. In practice this is rare — CSAR tilt is at
// zoom 10+ with near-top-down camera, where terrain occlusion is
// minimal. If it becomes an issue we'd need MapLibre 5.x's
// per-occlusion-source distinction (terrain vs globe), which is not
// currently exposed as a public API.
//
// On Mercator (no globe) markers were never occluded by the world,
// so the previous '1' value was effectively a no-op for back-side
// rendering. We were paying for terrain-occlusion full-show that
// MapLibre 3.x sometimes mis-applied — now we let it work naturally.
function fullOpacity(marker) {
  try { marker.setOpacity('1', '0'); } catch(e) {}
  return marker;
}

// Per-track exponentially-smoothed terrain elevation, used to damp the
// vertical bounce HH-60s pick up from MapLibre's per-frame terrain queries
// (terrain tiles load progressively and the underlying elevation snaps
// when new tiles arrive). Lives at module scope — fine for the prototype.
const _terrainSmooth = {};

// 2.5D altitude lift for DOM markers. Markers natively snap to terrain when
// setTerrain is enabled, so when the camera tilts a fixed-wing icon would
// "bounce" with whatever ridge it's flying over. We push the marker upward
// in screen space by (height-above-terrain × pixels-per-metre × sin(pitch))
// so it sits at its real flight altitude. At pitch 0 the offset collapses
// to zero, so this is invisible in flat ops view.
//
// Fixed-wing tracks (terrainFollowing=false) sit at constant MSL — the
// (altM - terrainEl) calc cancels out MapLibre's per-frame terrain
// projection, so they read smooth even on rugged terrain.
//
// HH-60s (terrainFollowing=true) sit at altM AGL above smoothed terrain
// — their visual altitude tracks the broad terrain trend without
// bouncing on every tile-load delta.
// ── Dead-reckoning helpers (used by ADS-B animation) ─────────
// Both use flat-earth approximations rather than full great-circle
// math. For projection windows < ~60 s and distances < ~5 nm the
// error is < 0.1% — negligible for tactical display.
//
// _projectLatLng(lat0, lng0, gsKnots, trackDeg, elapsedSec):
//   Move (lat0,lng0) forward by `gsKnots * elapsedSec / 3600` nm
//   along compass bearing `trackDeg` (0=N, 90=E, etc.).
function _projectLatLng(lat0, lng0, gsKnots, trackDeg, elapsedSec) {
  if (!gsKnots || elapsedSec <= 0) return { lat: lat0, lng: lng0 };
  const distNm = gsKnots * elapsedSec / 3600;
  const trackRad = trackDeg * Math.PI / 180;
  const dLat = distNm * Math.cos(trackRad) / 60;             // 1 nm = 1/60°
  const lngScale = Math.cos(lat0 * Math.PI / 180);
  const dLng = lngScale > 0 ? (distNm * Math.sin(trackRad) / (60 * lngScale)) : 0;
  return { lat: lat0 + dLat, lng: lng0 + dLng };
}
// _kmBetween(lat1, lng1, lat2, lng2):
//   Flat-earth distance in km between two close-by points.
function _kmBetween(lat1, lng1, lat2, lng2) {
  const dLatKm = (lat2 - lat1) * 111;
  const dLngKm = (lng2 - lng1) * 111 * Math.cos((lat1 * Math.PI) / 180);
  return Math.hypot(dLatKm, dLngKm);
}

// ── ACM geometry helpers ──────────────────────────────────────
// Build GeoJSON Features from raw ACM shape descriptors. Used by the
// ACO_ACMS rendering effect below. circleGeoJSON lives in data.js
// (window-scoped); polygon and corridor are defined here at module
// scope so they're available to map.jsx without round-tripping
// through window.
//
// polygonGeoJSON(verts):
//   verts is [[lng,lat],...]; auto-closes the ring.
function polygonGeoJSON(verts) {
  const ring = verts.map(v => [v[0], v[1]]);
  if (ring.length && (ring[0][0] !== ring[ring.length - 1][0] ||
                       ring[0][1] !== ring[ring.length - 1][1])) {
    ring.push([ring[0][0], ring[0][1]]);
  }
  return { type: 'Feature', geometry: { type: 'Polygon', coordinates: [ring] }, properties: {} };
}
// corridorGeoJSON(centerline, widthKm):
//   Buffer a polyline by widthKm/2 on each side. Cheap perpendicular-
//   offset approximation — good enough for visual ACMs at any zoom.
function corridorGeoJSON(centerline, widthKm) {
  const halfDeg = (widthKm / 111) / 2;        // km → ° approx
  const verts = [];
  // forward: centerline points offset +halfDeg perpendicular to local heading
  for (let i = 0; i < centerline.length; i++) {
    const [lng, lat] = centerline[i];
    const next = centerline[Math.min(i + 1, centerline.length - 1)];
    const prev = centerline[Math.max(i - 1, 0)];
    const dx = next[0] - prev[0];
    const dy = next[1] - prev[1];
    const len = Math.hypot(dx, dy) || 1;
    const lngScale = Math.cos(lat * Math.PI / 180) || 1;
    const perpLng = (-dy / len) * halfDeg / lngScale;
    const perpLat = ( dx / len) * halfDeg;
    verts.push([lng + perpLng, lat + perpLat]);
  }
  // reverse: same points offset the other direction, completing the ring
  for (let i = centerline.length - 1; i >= 0; i--) {
    const [lng, lat] = centerline[i];
    const next = centerline[Math.min(i + 1, centerline.length - 1)];
    const prev = centerline[Math.max(i - 1, 0)];
    const dx = next[0] - prev[0];
    const dy = next[1] - prev[1];
    const len = Math.hypot(dx, dy) || 1;
    const lngScale = Math.cos(lat * Math.PI / 180) || 1;
    const perpLng = (-dy / len) * halfDeg / lngScale;
    const perpLat = ( dx / len) * halfDeg;
    verts.push([lng - perpLng, lat - perpLat]);
  }
  return polygonGeoJSON(verts);
}
// racetrackGeoJSON(centerLng, centerLat, radiusNm, headingDeg, legNmOverride):
//   Builds a closed racetrack-shaped polygon: two parallel legs
//   (length = legNm) joined by two 180° half-circles (radius = radiusNm).
//   `headingDeg` is degrees true along the long axis (0 = N/S, 90 = E/W).
//   Default leg length is 6× radius — a 25 NM anchor → ~150 NM racetrack.
//   Output ring is closed (first vertex repeated at the end) and ready
//   for polygonGeoJSON / direct use as a Polygon feature.
function racetrackGeoJSON(centerLng, centerLat, radiusNm, headingDeg, legNmOverride) {
  const NM_PER_DEG = 60;          // ~60 NM per degree latitude
  const r = (radiusNm || 25);
  const leg = legNmOverride != null ? legNmOverride : (r * 6);
  const halfLeg = leg / 2;
  const headRad = ((headingDeg || 0) * Math.PI) / 180;
  // Local axis vectors. Heading 0 → axis points N (+lat), perp points E (+lng).
  const lngScale = Math.cos(centerLat * Math.PI / 180) || 1;
  // unit vector along heading direction (in deg-space)
  const ax_lng = Math.sin(headRad) / lngScale;   // east component
  const ax_lat = Math.cos(headRad);               // north component
  // perpendicular (90° clockwise from heading)
  const px_lng = Math.cos(headRad) / lngScale;
  const px_lat = -Math.sin(headRad);
  // Centers of the two end semicircles
  const cN = [
    centerLng + ax_lng * (halfLeg / NM_PER_DEG),
    centerLat + ax_lat * (halfLeg / NM_PER_DEG),
  ];
  const cS = [
    centerLng - ax_lng * (halfLeg / NM_PER_DEG),
    centerLat - ax_lat * (halfLeg / NM_PER_DEG),
  ];
  // Sample each semicircle. Angle is measured from the heading axis;
  // North end sweeps from -90° to +90° (around the leading cap);
  // South end sweeps from +90° to +270°.
  const STEPS = 24;  // 24 points per cap = smooth oval at any zoom
  const ring = [];
  // Right edge: from south-cap+90° to north-cap-90° along +perp leg
  ring.push([cS[0] + px_lng * (r / NM_PER_DEG), cS[1] + px_lat * (r / NM_PER_DEG)]);
  ring.push([cN[0] + px_lng * (r / NM_PER_DEG), cN[1] + px_lat * (r / NM_PER_DEG)]);
  // North-end semicircle: sweep from right edge over the top to left edge
  for (let i = 1; i <= STEPS; i++) {
    const t = (i / STEPS) * Math.PI;       // 0 → π
    // angle measured CCW from +perp toward -ax_lng axis
    const offsetLng = (Math.cos(t) * px_lng + Math.sin(t) * ax_lng) * (r / NM_PER_DEG);
    const offsetLat = (Math.cos(t) * px_lat + Math.sin(t) * ax_lat) * (r / NM_PER_DEG);
    ring.push([cN[0] + offsetLng, cN[1] + offsetLat]);
  }
  // Left edge runs from north-cap-180° back down to south-cap
  ring.push([cS[0] - px_lng * (r / NM_PER_DEG), cS[1] - px_lat * (r / NM_PER_DEG)]);
  // South-end semicircle: sweep from left edge under the bottom to right edge
  for (let i = 1; i <= STEPS; i++) {
    const t = (i / STEPS) * Math.PI;       // 0 → π
    // angle measured CCW from -perp toward +ax_lng axis (bottom sweep)
    const offsetLng = (-Math.cos(t) * px_lng - Math.sin(t) * ax_lng) * (r / NM_PER_DEG);
    const offsetLat = (-Math.cos(t) * px_lat - Math.sin(t) * ax_lat) * (r / NM_PER_DEG);
    ring.push([cS[0] + offsetLng, cS[1] + offsetLat]);
  }
  // Close the ring
  ring.push([ring[0][0], ring[0][1]]);
  return { type: 'Feature', geometry: { type: 'Polygon', coordinates: [ring] }, properties: {} };
}

function altitudeOffset(map, pos, track) {
  const altM = (track && track.altitudeM) || 0;
  if (!map || altM <= 0) return [0, 0];
  const pitch = map.getPitch ? map.getPitch() : 0;
  if (pitch < 0.5) return [0, 0];
  const terrainEl = (typeof map.queryTerrainElevation === 'function')
    ? (map.queryTerrainElevation([pos.lng, pos.lat]) || 0)
    : 0;

  // Per-track EMA smoothing of the terrain reading. Applied to ALL
  // tracks in tilt view, not just terrain-followers — was previously
  // gated on track.terrainFollowing, which left fixed-wing tracks
  // (DUKE 32 orbit, SANDY transit, etc.) using raw terrain queries.
  // Over rugged terrain (Taiwan ridges), raw terrainEl can vary by
  // 100s of meters between adjacent points on the orbit, producing
  // visible screen-pixel jitter that read as the aircraft "surging
  // and slowing."
  //
  // α = 0.03 (half-life ~23 frames ≈ 0.4s at 60fps) is aggressive
  // enough to flatten ridge-scale terrain variation (km-scale) while
  // still tracking the broad valley/mountain trend. Earlier α=0.10
  // wasn't aggressive enough for DUKE 32's tight 16-second orbit
  // over Taiwan terrain; the smoothed value still echoed each ridge
  // crossing.
  //
  // Cold-start fix: previous code initialized smoothed = raw on first
  // call, meaning the first ~30 frames after a track started moving
  // were essentially raw (EMA hadn't converged yet). For a 16-second
  // orbit period, the first orbit was almost entirely unsmoothed.
  // Now we prime the smoother with a one-shot 16-step warmup the
  // first time we see a track — gives us a converged baseline before
  // the marker is rendered for the first time.
  const k = (track.id || track.cs || 'unk') + '';
  let prev = _terrainSmooth[k];
  const ALPHA = 0.03;
  if (typeof prev !== 'number') {
    // Cold start — warm-up the EMA so the first frame is already
    // close to converged. Sixteen iterations of α=0.03 reach about
    // 1 - 0.97^16 ≈ 39% convergence, which is enough that the first
    // few frames don't show the raw spike.
    let warm = terrainEl;
    for (let i = 0; i < 16; i++) warm = warm * (1 - ALPHA) + terrainEl * ALPHA;
    prev = warm;
  }
  const smoothedTerrain = prev * (1 - ALPHA) + terrainEl * ALPHA;
  _terrainSmooth[k] = smoothedTerrain;

  let heightM;
  if (track.terrainFollowing) {
    // AGL helo: hover at altM above the smoothed terrain trend. The
    // (smoothed - raw) correction cancels MapLibre's per-frame
    // terrain projection, replacing it with the smoothed value;
    // + altM lifts to AGL.
    heightM = (smoothedTerrain - terrainEl) + altM;
  } else {
    // MSL fixed-wing: aircraft sits at altM above sea level. The
    // visual screen-pixel offset corresponds to (altM - terrain
    // beneath the marker), where the terrain reference must be
    // smoothed so per-frame raw-terrain jitter doesn't bounce the
    // pixel position. Use altM - smoothedTerrain directly. Earlier
    // formula `(smoothedTerrain - rawTerrain) + (altM - smoothedTerrain)`
    // algebraically reduced to `altM - rawTerrain`, which is exactly
    // the unsmoothed buggy case — the smoothing had no effect for
    // fixed-wing tracks. Fixed: use altM - smoothedTerrain directly.
    heightM = Math.max(0, altM - smoothedTerrain);
  }
  const zoom = map.getZoom ? map.getZoom() : 0;
  const metersPerPixel = 156543.03392 * Math.cos((pos.lat || 0) * Math.PI / 180) / Math.pow(2, zoom);
  if (!isFinite(metersPerPixel) || metersPerPixel <= 0) return [0, 0];
  const px = heightM * Math.sin(pitch * Math.PI / 180) / metersPerPixel;
  return [0, -px];
}

// ── Platform icon lookup ──────────────────────────────────────
// Maps track.type → SVG file basename in v3/icons/ (loaded lazily by
// loadIconPath at marker mount time). All silhouettes are pulled from
// the JERIC2O AIR-TRACKS Figma library.
// Falls back to null for types not in the icon library (we'll show a
// generic arrowhead).
function platformIconKey(track) {
  const t = track.type || '';
  // Fighters
  if (t === 'F-15E')  return 'f15e';
  if (t === 'F-15C')  return 'f15';   // air-superiority variant — uses non-strike F-15 silhouette
  if (t === 'F-16C')  return 'f16';
  if (t === 'F-22')   return 'fa22';
  if (t === 'F-18')   return 'fa18';
  // Bombers / strike support
  if (t === 'B-52H')  return 'b52';
  if (t === 'B-2A' || t === 'B-2')  return 'b2';
  // C2 / surveillance
  if (t === 'E-3C')   return 'e3';
  if (t === 'RC-135') return 'rc135';
  if (t === 'P-8')    return 'p8a';
  // CAS / SOF
  if (t === 'A-10C')  return 'a10';
  if (t === 'AC-130J')return 'ac130';
  // Helos
  if (t === 'HH-60W') return 'hh60';
  // C-130 family
  if (t === 'HC-130J')return 'c130';
  if (t === 'MC-130J')return 'c130'; // shares the C-130 silhouette
  if (t === 'KC-130J')return 'probe-and-drogue-tanker'; // hose-and-drogue tanker
  // Tankers (boom-equipped)
  if (t === 'KC-135') return 'kc135';
  if (t === 'KC-46')  return 'boom-tanker';  // generic boom-tanker silhouette
  // Drones
  if (t === 'MQ-9')   return 'mq9';
  return null;
}

// Cache of parsed icon SVGs — one fetch per platform, results reused for
// every marker. Each entry: { d, viewBox } extracted from v3/icons/<key>.svg.
// The marker factory renders these as inline <svg> with stroke-only paint
// (fill:none; stroke:color) so silhouettes read as outline-style on the COP.
// Pre-mount fallback: an arrowhead is drawn until the SVG arrives.
const _iconPathCache = {};
const _iconPathPending = {};
function loadIconPath(key, onReady) {
  if (_iconPathCache[key]) { onReady(_iconPathCache[key]); return; }
  if (!_iconPathPending[key]) {
    _iconPathPending[key] = [];
    fetch(`v3/icons/${key}.svg`)
      .then(r => r.text())
      .then(txt => {
        // Permissive: allow any attrs between `<path` and `d="..."` so
        // Figma exports like `<path id="vector" d="...">` parse cleanly.
        // Earlier strict regex required d to come immediately after path.
        const dMatch = txt.match(/<path\b[^>]*\sd="([^"]+)"/);
        const vbMatch = txt.match(/viewBox="([^"]+)"/);
        const entry = {
          d: dMatch ? dMatch[1] : null,
          viewBox: vbMatch ? vbMatch[1] : '0 0 24 24',
        };
        _iconPathCache[key] = entry;
        const queue = _iconPathPending[key];
        delete _iconPathPending[key];
        for (const cb of queue) cb(entry);
      })
      .catch(() => {
        _iconPathCache[key] = { d: null, viewBox: '0 0 24 24' };
        const queue = _iconPathPending[key] || [];
        delete _iconPathPending[key];
        for (const cb of queue) cb(_iconPathCache[key]);
      });
  }
  _iconPathPending[key].push(onReady);
}

// ── Marker element factories ──────────────────────────────────
function makeBFMarkerEl(track) {
  const el = document.createElement('div');
  el.className = 'track-marker';
  // 0×0 anchor at geo point — icon + label positioned absolutely from this center
  el.style.cssText = 'position:relative;width:0;height:0;';

  let color = C.accent;
  if (track.id === 'bf6') color = C.accent2;
  if (window.ASSET_IDS.has(track.id)) {
    color = track.sandy ? C.accent2 : track.status === 'advisory' ? C.advisory : C.accent;
  }
  // ISR (GHOST MQ-9s) take the orange `isr` comm category color so they read
  // distinctly from blue blue-air tracks. Pulled from COMM_ROLES so the legend
  // and the COP marker share a single source of truth.
  if (window.ISR_IDS && window.ISR_IDS.has(track.id)) {
    color = (window.COMM_ROLES && window.COMM_ROLES.isr && window.COMM_ROLES.isr.color) || '#ff8c42';
  }

  const iconKey = platformIconKey(track);
  const boxSz = 24; // Platform icons are designed at 24 (general) / 32 (COP variant)

  // Icon: centered on geo point
  const iconDiv = document.createElement('div');
  iconDiv.className = 'track-icon';
  if (iconKey) {
    // Inline <svg> with fill-only paint. Path comes from the cached
    // silhouette; we explicitly set fill=color and stroke=none so the airframe
    // reads as a clean filled silhouette with no outline. Switched from the
    // earlier CSS-mask approach because that rendered the underlying donut-
    // shaped path geometry as fill+halo rather than a clean filled body.
    iconDiv.style.cssText = `position:absolute;left:${-boxSz/2}px;top:${-boxSz/2}px;width:${boxSz}px;height:${boxSz}px;pointer-events:auto;cursor:default;`;
    const renderSvg = (entry) => {
      if (!entry || !entry.d) return; // load failed — fallback arrowhead drawn below
      iconDiv.innerHTML = `<svg width="${boxSz}" height="${boxSz}" viewBox="${entry.viewBox}" xmlns="http://www.w3.org/2000/svg" style="display:block;"><path d="${entry.d}" fill="${color}" stroke="none" fill-rule="nonzero"/></svg>`;
    };
    if (_iconPathCache[iconKey]) {
      renderSvg(_iconPathCache[iconKey]);
    } else {
      // Show a placeholder arrow until the SVG loads (one-shot per platform).
      const sz = 8;
      iconDiv.innerHTML = `<svg width="${boxSz}" height="${boxSz}" viewBox="-12 -12 24 24" style="display:block;"><polygon points="0,${-sz} ${sz*0.65},${sz*0.65} 0,${sz*0.3} ${-sz*0.65},${sz*0.65}" fill="${color}" stroke="none"/></svg>`;
      loadIconPath(iconKey, renderSvg);
    }
  } else {
    // Fallback arrowhead for unmapped platform types
    const sz = 10;
    iconDiv.style.cssText = `position:absolute;left:${-sz*1.5}px;top:${-sz*1.5}px;width:${sz*3}px;height:${sz*3}px;pointer-events:auto;cursor:default;`;
    iconDiv.innerHTML = `<svg width="${sz*3}" height="${sz*3}" viewBox="${-sz*1.5} ${-sz*1.5} ${sz*3} ${sz*3}" style="display:block;overflow:visible;"><polygon points="0,${-sz} ${sz*0.65},${sz*0.65} 0,${sz*0.3} ${-sz*0.65},${sz*0.65}" fill="${color}"/></svg>`;
  }

  // Label: to the right of icon, hidden until hover. The second line
  // (`type · mission`) is split into a dedicated child node so the
  // animate loop can refresh it from the platformOverrides ref when
  // the operator edits a platform's mission type from the Mission
  // Admin Platforms table — without rebuilding the marker.
  const labelDiv = document.createElement('div');
  labelDiv.className = 'track-label';
  labelDiv.style.cssText = `position:absolute;left:${boxSz/2 + 4}px;top:${-boxSz/2}px;white-space:nowrap;opacity:0;transition:opacity 0.15s;pointer-events:none;`;
  const _missionLabel = (window.getMissionTypeLabel
    ? window.getMissionTypeLabel(track.mission || track.role)
    : (track.role || ''));
  labelDiv.innerHTML = `<div style="background:rgba(15,15,17,0.9);border:1px solid ${color}55;border-radius:2px;padding:2px 6px;"><div style="font-family:'Roboto Mono',monospace;font-size:8.5px;font-weight:500;color:${color};letter-spacing:0.03em;">${track.cs}</div><div data-track-tooltip-mission="1" style="font-family:'Roboto Mono',monospace;font-size:7px;color:${C.fg4};">${track.type} · ${_missionLabel}</div></div>`;
  // Cache the mission-line child + last value so the animate loop
  // can diff and update without rebuilding markers.
  el._missionEl = labelDiv.querySelector('[data-track-tooltip-mission]');
  el._lastMissionLabel = _missionLabel;
  el._trackId = track.id;
  el._trackType = track.type;

  iconDiv.addEventListener('mouseenter', () => { labelDiv.style.opacity = '1'; });
  iconDiv.addEventListener('mouseleave', () => { labelDiv.style.opacity = '0'; });

  // Dot: shown only at low zoom (≥500mi map width). Carries the track's
  // assigned color so AOR composition reads at a glance even when icons
  // are hidden. CSS in the page-level <style> toggles which child is
  // visible based on data-low-zoom on the map root — default state hides
  // the dot, low-zoom shows it (and hides the icon).
  const dotDiv = document.createElement('div');
  dotDiv.className = 'track-dot';
  dotDiv.style.cssText = `position:absolute;left:-2px;top:-2px;width:4px;height:4px;border-radius:50%;background:${color};pointer-events:auto;cursor:default;`;
  dotDiv.addEventListener('mouseenter', () => { labelDiv.style.opacity = '1'; });
  dotDiv.addEventListener('mouseleave', () => { labelDiv.style.opacity = '0'; });

  // DIVERT chip — hidden by default; the animate loop flips display:block
  // on whichever HH-60 the pilot's divert call applies to. Surfaces the
  // pilot's autonomous decision (we don't get a vote on it from C2).
  const chipDiv = document.createElement('div');
  chipDiv.className = 'divert-chip';
  chipDiv.style.cssText = `position:absolute;left:-22px;top:${boxSz/2 + 2}px;display:none;pointer-events:none;`;
  chipDiv.innerHTML = `<div style="background:${C.advisory};color:#000;font-family:'Roboto Mono',monospace;font-size:7.5px;font-weight:700;letter-spacing:0.12em;padding:1.5px 5px;border-radius:2px;box-shadow:0 0 6px rgba(0,0,0,0.7);">DIVERT</div>`;

  el.appendChild(iconDiv);
  el.appendChild(dotDiv);
  el.appendChild(labelDiv);
  el.appendChild(chipDiv);
  return el;
}

function makeRedMarkerEl(rf) {
  const el = document.createElement('div');
  el.className = 'track-marker';
  el.style.cssText = 'position:relative;width:0;height:0;';

  // Symbology per track class:
  //   SAM   — NTDS Land/Hostile (horizontal + vertical line + diamond
  //            centered) — the design system's standard ground-IADS
  //            glyph. Source: figma node 34920:3078.
  //   AIR   — rotated-square diamond (the existing generic hostile
  //            air-track marker; BANDIT keeps this).
  //   other — diamond fallback (unknown ground threat / future use).
  const iconDiv = document.createElement('div');
  iconDiv.className = 'track-icon';
  if (rf.type === 'SAM' && window.NTDS_SYMBOLS && window.NTDS_SYMBOLS['land-hostile']) {
    // NTDS Land/Hostile is a 32-viewBox symbol; we render at 22px so
    // the cross extends ~11px in each direction from the geo point —
    // visually balanced against the 18px AIR diamond.
    const sz = 22;
    iconDiv.style.cssText = `position:absolute;left:${-sz/2}px;top:${-sz/2}px;width:${sz}px;height:${sz}px;pointer-events:auto;cursor:default;color:${C.critical};`;
    iconDiv.innerHTML = window.NTDS_SYMBOLS['land-hostile'].render(sz, C.critical);
  } else {
    iconDiv.style.cssText = 'position:absolute;left:-9px;top:-9px;width:18px;height:18px;pointer-events:auto;cursor:default;';
    iconDiv.innerHTML = `<svg width="18" height="18" viewBox="-9 -9 18 18" style="display:block;overflow:visible;"><rect x="-5" y="-5" width="10" height="10" fill="${C.critical}" transform="rotate(45)"/></svg>`;
  }

  const labelDiv = document.createElement('div');
  labelDiv.className = 'track-label';
  labelDiv.style.cssText = 'position:absolute;left:13px;top:-14px;white-space:nowrap;opacity:0;transition:opacity 0.15s;pointer-events:none;';
  labelDiv.innerHTML = `<div style="background:rgba(15,15,17,0.9);border:1px solid rgba(255,46,39,0.45);border-radius:2px;padding:2px 5px;"><div style="font-family:'Roboto Mono',monospace;font-size:8px;font-weight:500;color:${C.critical};">${rf.label}</div><div style="font-family:'Roboto Mono',monospace;font-size:7px;color:${C.fg3};">${rf.type==='SAM'?'SAM SITE':rf.type==='AIR'?'AIR TRACK':'GND THREAT'}</div></div>`;

  iconDiv.addEventListener('mouseenter', () => { labelDiv.style.opacity = '1'; });
  iconDiv.addEventListener('mouseleave', () => { labelDiv.style.opacity = '0'; });

  // Red dot — shown only at low zoom. Same critical-red so threat tracks
  // stay visually distinct from blue/cyan friendly tracks at any zoom.
  const dotDiv = document.createElement('div');
  dotDiv.className = 'track-dot';
  dotDiv.style.cssText = `position:absolute;left:-2px;top:-2px;width:4px;height:4px;border-radius:50%;background:${C.critical};pointer-events:auto;cursor:default;`;
  dotDiv.addEventListener('mouseenter', () => { labelDiv.style.opacity = '1'; });
  dotDiv.addEventListener('mouseleave', () => { labelDiv.style.opacity = '0'; });

  el.appendChild(iconDiv);
  el.appendChild(dotDiv);
  el.appendChild(labelDiv);
  return el;
}

// ── Hostile-track marker factory ──────────────────────────────
// HOSTILE_TRACKS are the non-SAM ground + naval threat picture
// (C2, EW, TEL, armor, naval, hardened, etc). Renders as a 14px
// glyph keyed off type. Tooltip surfaces label + type + confidence
// + lastSeen + source so the operator can read the intel quality
// inline. Distinct from MULTI_AOR_THREATS (SAM rings) so the IADS
// picture and the rest of the red picture have different visual
// vocabularies.
function _hostileGlyphSvg(type, color) {
  const c = color || '#ff6d68';
  // Glyph keyed off HOSTILE_TYPES.icon. Each is a 16-viewBox SVG so
  // the marker stays visually consistent across types — only the
  // shape changes.
  const tt = (window.HOSTILE_TYPES && window.HOSTILE_TYPES[type]) || {};
  const icon = tt.icon || 'square';
  switch (icon) {
    case 'square':
      return `<rect x="-5" y="-5" width="10" height="10" fill="${c}"/>`;
    case 'square-thin':
      return `<rect x="-5" y="-5" width="10" height="10" fill="none" stroke="${c}" stroke-width="1.6"/>`;
    case 'rect':
      return `<rect x="-6" y="-3.5" width="12" height="7" fill="${c}"/>`;
    case 'tri-up':
      return `<polygon points="0,-6 5,4 -5,4" fill="${c}"/>`;
    case 'tri-down':
      return `<polygon points="0,6 5,-4 -5,-4" fill="${c}"/>`;
    case 'arrow-up':
      return `<polygon points="0,-6 5,4 0,2 -5,4" fill="${c}"/>`;
    case 'arrow-out':
      return `<polygon points="0,-6 5,0 2,0 2,4 -2,4 -2,0 -5,0" fill="${c}"/>`;
    case 'cross':
      return `<rect x="-5" y="-1.2" width="10" height="2.4" fill="${c}"/><rect x="-1.2" y="-5" width="2.4" height="10" fill="${c}"/>`;
    case 'lightning':
      return `<polygon points="-2,-6 4,-1 1,-1 3,6 -4,1 -1,1" fill="${c}"/>`;
    case 'antenna':
      return `<rect x="-0.7" y="-6" width="1.4" height="9" fill="${c}"/><polygon points="-3,3 3,3 0,-2" fill="${c}"/>`;
    case 'hex':
      return `<polygon points="-5,2.5 -5,-2.5 0,-5.5 5,-2.5 5,2.5 0,5.5" fill="${c}"/>`;
    case 'hex-thin':
      return `<polygon points="-5,2.5 -5,-2.5 0,-5.5 5,-2.5 5,2.5 0,5.5" fill="none" stroke="${c}" stroke-width="1.6"/>`;
    case 'diamond':
      return `<rect x="-4" y="-4" width="8" height="8" fill="${c}" transform="rotate(45)"/>`;
    case 'plane-rect':
      return `<rect x="-6" y="-3" width="12" height="6" fill="${c}"/><rect x="-1" y="-5" width="2" height="10" fill="rgba(0,0,0,0.35)"/>`;
    case 'circle-thick':
      return `<circle cx="0" cy="0" r="5" fill="${c}"/><circle cx="0" cy="0" r="3" fill="rgba(0,0,0,0.5)"/>`;
    case 'chevron':
      return `<polygon points="0,-6 5,3 0,1 -5,3" fill="${c}"/>`;
    case 'chevron-thin':
      return `<polygon points="0,-6 5,3 0,1 -5,3" fill="none" stroke="${c}" stroke-width="1.6"/>`;
    case 'sub':
      return `<ellipse cx="0" cy="0" rx="6" ry="2.5" fill="${c}"/><rect x="-1.5" y="-4" width="3" height="2" fill="${c}"/>`;
    case 'anchor':
      return `<path d="M -4 3 L 0 -1 L 4 3 M 0 -1 L 0 -5 M -2 -3 L 2 -3" fill="none" stroke="${c}" stroke-width="1.6" stroke-linecap="round"/>`;
    default:
      return `<rect x="-5" y="-5" width="10" height="10" fill="${c}"/>`;
  }
}

// Pick the right glyph for a hostile-track render. Two modes:
//   • 'generic' — small filled diamond. The figma's ntds-target
//                 reticle was tested and read as too busy at marker
//                 scale; a simple rotated square at ~50% of the icon
//                 box gives a clean at-a-glance "target" silhouette
//                 without internal detail competing with the basemap.
//   • 'byType'  — per-type NTDS glyph from HOSTILE_TYPES[type].ntds
//                 (figma CORE Design System). Higher information
//                 density; activate when scanning for specific types.
// Returns the SVG string ready for innerHTML, sized to `size`.
function _hostileNtdsSvg(type, color, style, size) {
  if (style !== 'byType') {
    // Generic mode — small filled diamond. Side ≈ 50% of icon box,
    // centered on the geo point. Stroke is 1px in the same color
    // (renders crisp without anti-aliasing fuzz on dark basemaps).
    const half = Math.max(3, Math.round(size * 0.32));
    return `<svg width="${size}" height="${size}" viewBox="${-size/2} ${-size/2} ${size} ${size}" style="display:block;overflow:visible;"><rect x="${-half}" y="${-half}" width="${half*2}" height="${half*2}" fill="${color}" transform="rotate(45)"/></svg>`;
  }
  // By-type mode — figma NTDS glyph for the specific type, with a
  // fallback chain for any type that doesn't have an NTDS mapping.
  const symbols = window.NTDS_SYMBOLS || {};
  const tt = (window.HOSTILE_TYPES && window.HOSTILE_TYPES[type]) || {};
  let glyph = symbols[tt.ntds] || symbols['ntds-target'];
  if (glyph && typeof glyph.render === 'function') {
    return glyph.render(size, color);
  }
  // Final fallback — primitive glyph if NTDS isn't loaded yet.
  return `<svg width="${size}" height="${size}" viewBox="-7 -7 14 14" style="display:block;overflow:visible;">${_hostileGlyphSvg(type, color)}</svg>`;
}

function makeHostileMarkerEl(t, style) {
  const el = document.createElement('div');
  el.className = 'track-marker hostile-marker';
  el.style.cssText = 'position:relative;width:0;height:0;';
  // Confidence drives opacity: SUSPECTED faintest, CONFIRMED full.
  // (Confidence is intel-quality; classification is ROE — orthogonal axes.)
  const opacity = t.confidence === 'SUSPECTED' ? 0.55
                : t.confidence === 'PROBABLE'  ? 0.78
                :                                 1.0;
  const tt = (window.HOSTILE_TYPES && window.HOSTILE_TYPES[t.type]) || {};
  // Classification (HOSTILE / SUSPECT) drives color, mirroring the
  // figma's NTDS color convention:
  //   • foreground/ROE-hostile  = #FF0033 → C.critical (red)
  //   • foreground/ROE-suspect  = #FFAD38 → C.suspect  (yellow)
  // Tracks in CENTCOM/AFRICOM (active engagement) classify HOSTILE;
  // INDOPACOM/EUCOM (peer deterrence, ROE forbids engagement) classify
  // SUSPECT. SAM threat rings (MULTI_AOR_THREATS) stay red regardless
  // — those communicate "where it's dangerous to fly," not ROE intent.
  const isSuspect = t.classification === 'SUSPECT';
  const color = isSuspect
    ? ((window.C && window.C.suspect)  || '#ffad38')
    : ((window.C && window.C.critical) || '#ff2e27');

  // Prefer the design-toolkit NTDS hostile glyph (figma CORE Design
  // System). The 'generic' style draws ntds-target on every marker;
  // 'byType' draws the type-specific NTDS glyph. Markers stay 16px so
  // the figma glyphs read clearly at globe scale.
  const SIZE = 16;
  const iconDiv = document.createElement('div');
  iconDiv.className = 'track-icon';
  iconDiv.style.cssText = `position:absolute;left:${-SIZE/2}px;top:${-SIZE/2}px;width:${SIZE}px;height:${SIZE}px;pointer-events:auto;cursor:default;opacity:${opacity};`;
  iconDiv.innerHTML = _hostileNtdsSvg(t.type, color, style || 'generic', SIZE);
  // Stash type + size + color on the element so a later style-change
  // effect can re-render the icon without rebuilding the whole marker.
  el._hostileType = t.type;
  el._hostileColor = color;
  el._hostileSize  = SIZE;

  // Tooltip — distinct from MULTI_AOR_THREATS tooltip; surfaces the
  // intel quality fields the operator cares about. Border + label
  // color follow the classification color so the tooltip reads as
  // SUSPECT (yellow) or HOSTILE (red) at a glance.
  const labelDiv = document.createElement('div');
  labelDiv.className = 'track-label';
  labelDiv.style.cssText = 'position:absolute;left:11px;top:-12px;white-space:nowrap;opacity:0;transition:opacity 0.15s;pointer-events:none;z-index:5;';
  const typeLabel = (tt.label || t.type);
  const classLabel = isSuspect ? 'SUSPECT' : 'HOSTILE';
  labelDiv.innerHTML = `<div style="background:rgba(15,15,17,0.92);border:1px solid ${color}55;border-radius:2px;padding:3px 6px;">
    <div style="font-family:'Roboto Mono',monospace;font-size:9px;font-weight:600;color:${color};">${t.label}</div>
    <div style="font-family:'Roboto Mono',monospace;font-size:7.5px;color:${C.fg2};">${typeLabel} · ${classLabel}</div>
    <div style="font-family:'Roboto Mono',monospace;font-size:7.5px;color:${C.fg3};letter-spacing:0.04em;">${t.confidence} · ${t.source} · ${t.lastSeen}</div>
    ${t.notes ? `<div style="font-family:'Roboto Mono',monospace;font-size:7px;color:${C.fg3};max-width:240px;white-space:normal;margin-top:2px;">${t.notes}</div>` : ''}
  </div>`;

  iconDiv.addEventListener('mouseenter', () => { labelDiv.style.opacity = '1'; });
  iconDiv.addEventListener('mouseleave', () => { labelDiv.style.opacity = '0'; });

  // Low-zoom dot — same color, smaller. Matches the existing low-zoom
  // pattern used elsewhere on the COP.
  const dotDiv = document.createElement('div');
  dotDiv.className = 'track-dot';
  dotDiv.style.cssText = `position:absolute;left:-2px;top:-2px;width:4px;height:4px;border-radius:50%;background:${color};pointer-events:auto;cursor:default;opacity:${opacity};`;
  dotDiv.addEventListener('mouseenter', () => { labelDiv.style.opacity = '1'; });
  dotDiv.addEventListener('mouseleave', () => { labelDiv.style.opacity = '0'; });

  el.appendChild(iconDiv);
  el.appendChild(dotDiv);
  el.appendChild(labelDiv);
  return el;
}

function makeIPMarkerEl(crewPos, opts = {}) {
  const confirmed = crewPos?.confirmed !== false;
  const cs = crewPos?.callSign || 'DUKE 31 ALPHA';
  const mgrs = crewPos?.mgrs || '51RTH 8451 1234';
  const color = confirmed ? C.normal : C.advisory;
  const borderStyle = confirmed ? `1.5px solid ${color}` : `1.5px dashed ${color}`;
  // The pulsing outer halo represents an active beacon. Once the crewmember's
  // J3.5 is acknowledged (beacon confirmed by C2) the pulse stops — at that
  // point the survivor's signal is "claimed" and continued visual emphasis is
  // distracting. Caller passes `opts.pulse = false` to render a static halo.
  const pulse = opts.pulse !== false;
  const el = document.createElement('div');
  el.style.cssText = 'position:relative;width:0;height:0;pointer-events:none;';

  const iconDiv = document.createElement('div');
  iconDiv.style.cssText = 'position:absolute;left:-14px;top:-14px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;';
  iconDiv.innerHTML = `
    ${confirmed
      ? `<div style="position:absolute;width:28px;height:28px;border-radius:50%;border:${borderStyle};opacity:0.35;${pulse ? 'animation:pulseFade 0.9s ease-in-out infinite;' : ''}"></div>`
      : `<div style="position:absolute;width:36px;height:36px;border-radius:50%;border:${borderStyle};opacity:0.5;left:-4px;top:-4px;"></div>`
    }
    <div style="position:absolute;width:16px;height:16px;border-radius:50%;border:${borderStyle};opacity:0.75;"></div>
    <div style="width:8px;height:8px;border-radius:50%;background:${color};border:1.5px solid white;flex-shrink:0;${!confirmed?'opacity:0.6;':''}"></div>`;

  const labelDiv = document.createElement('div');
  labelDiv.className = 'ip-label';
  // Default: tooltip's top-left corner anchored 5px to the right of icon (icon radius 14 + 5 gap)
  labelDiv.style.cssText = 'position:absolute;left:19px;top:-14px;white-space:nowrap;transition:left 0.2s, transform 0.2s, opacity 0.15s;pointer-events:none;';
  labelDiv.innerHTML = `<div style="background:rgba(15,15,17,0.94);border:1px solid ${color}88;border-radius:2px;padding:3px 7px;"><div style="font-family:'Roboto Mono',monospace;font-size:9px;font-weight:500;color:${color};letter-spacing:0.04em;">${cs}</div><div style="font-family:'Roboto Mono',monospace;font-size:7.5px;color:#888a8f;">${confirmed ? 'CONFIRMED' : 'POS UNCONFIRMED'}</div><div style="font-family:'Roboto Mono',monospace;font-size:7px;color:#565759;">${mgrs}</div></div>`;

  // Confirmed IPs hide the tooltip by default — reveal on icon hover only.
  // Unconfirmed IPs keep the tooltip always visible so the POS UNCONFIRMED state is obvious.
  if (confirmed) {
    labelDiv.style.opacity = '0';
    iconDiv.style.pointerEvents = 'auto';
    iconDiv.style.cursor = 'help';
    iconDiv.addEventListener('mouseenter', () => { labelDiv.style.opacity = '1'; });
    iconDiv.addEventListener('mouseleave', () => { labelDiv.style.opacity = '0'; });
  }

  el.appendChild(iconDiv);
  el.appendChild(labelDiv);
  return { el, labelDiv };
}

// ── Mission target marker (PR 25.3) ───────────────────────────
// Drops a CORE target-reticle glyph at a mission's tasked DMPI. Only
// mounted when the operator is in a mission-type MM (oca, …) — these
// are the package's tasked aimpoints, distinct from threat/SAM glyphs.
// Reticle is red (C.critical) — doctrinal "target" color, reads as
// "where the bombs go" against the dimmed ambient picture.
function makeTargetMarkerEl(target, mission) {
  const el = document.createElement('div');
  el.className = 'track-marker tgt-marker';
  el.style.cssText = 'position:relative;width:0;height:0;';
  const color = C.critical;  // red — doctrinal target color
  const size = 20;
  // CORE target reticle inner SVG (from icons.js CORE_ICONS.target).
  // Single-path with fill-rule:evenodd carves the rings + cross out
  // of the outer fill — same recipe MODE_ICONS.crosshair uses.
  const reticlePath = 'M17.9653 10.75C17.6102 14.5697 14.5697 17.6102 10.75 17.9653V20H9.25V17.9653C5.43032 17.6102 2.38984 14.5697 2.03469 10.75H0V9.25H2.03469C2.38984 5.43032 5.43032 2.38984 9.25 2.03469V0H10.75V2.03469C14.5697 2.38984 17.6102 5.43032 17.9653 9.25H20V10.75H17.9653ZM9.25 3.5428V5.5H10.75V3.5428C13.7405 3.88638 16.1136 6.2595 16.4572 9.25H14.5V10.75H16.4572C16.1136 13.7405 13.7405 16.1136 10.75 16.4572V14.5H9.25V16.4572C6.2595 16.1136 3.88638 13.7405 3.5428 10.75H5.5V9.25H3.5428C3.88638 6.2595 6.2595 3.88638 9.25 3.5428Z';
  const iconDiv = document.createElement('div');
  iconDiv.style.cssText = `position:absolute;left:${-size/2}px;top:${-size/2}px;width:${size}px;height:${size}px;pointer-events:auto;cursor:help;`;
  iconDiv.innerHTML = `<svg width="${size}" height="${size}" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" style="display:block;"><path d="${reticlePath}" fill="${color}" fill-rule="evenodd" clip-rule="evenodd"/></svg>`;
  // Tooltip label — mission callsign + target name. Sits to the right
  // of the reticle, fades in on hover.
  const labelDiv = document.createElement('div');
  labelDiv.style.cssText = 'position:absolute;left:14px;top:-12px;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity 0.15s;';
  labelDiv.innerHTML = `<div style="background:rgba(15,15,17,0.94);border:1px solid ${color}66;border-radius:2px;padding:3px 7px;">
    <div style="font-family:'Roboto Mono',monospace;font-size:9px;font-weight:500;color:${color};letter-spacing:0.04em;">TGT · ${mission.callsign || mission.id}</div>
    <div style="font-family:'Roboto',sans-serif;font-size:10.5px;color:${C.fg1};margin-top:1px;">${target.name || '—'}</div>
    ${target.detail ? `<div style="font-family:'Roboto Mono',monospace;font-size:8.5px;color:#888a8f;margin-top:1px;">${target.detail}</div>` : ''}
    ${target.geo && target.geo.elevFt != null ? `<div style="font-family:'Roboto Mono',monospace;font-size:8.5px;color:#565759;">${target.geo.elevFt} ft MSL</div>` : ''}
  </div>`;
  iconDiv.addEventListener('mouseenter', () => { labelDiv.style.opacity = '1'; });
  iconDiv.addEventListener('mouseleave', () => { labelDiv.style.opacity = '0'; });
  el.appendChild(iconDiv);
  el.appendChild(labelDiv);
  el.title = `Tasked target — ${mission.callsign}`;
  return el;
}

// ── Blue base marker ──────────────────────────────────────────
// Static installation marker: small filled square (semi-transparent
// fill + accent border) with an inline control-tower SVG glyph and
// an always-visible ICAO label below. Distinct from track markers
// so the operator reads "fixed installation" not "moving asset" at
// a glance. Hover surfaces a name + service + country tooltip.
function makeBaseMarkerEl(b) {
  const el = document.createElement('div');
  el.className = 'track-marker base-marker';
  el.style.cssText = 'position:relative;width:0;height:0;';
  // Cyan accent matches the friendly-track color family — bases read
  // as "blue side" without competing with the track icons (which use
  // saturated cyan on their dot mode + pale text label).
  const color = (window.C && window.C.accent) || '#3d9eff';

  // Marker body — small rounded square with the control tower glyph
  // centered. 18px × 18px, slightly translucent fill so it doesn't
  // overpower clusters of track icons at low zoom.
  const SIZE = 16;
  const iconDiv = document.createElement('div');
  iconDiv.className = 'track-icon base-icon';
  iconDiv.style.cssText = `position:absolute;left:${-SIZE/2}px;top:${-SIZE/2}px;width:${SIZE}px;height:${SIZE}px;border:1px solid ${color};background:${color}22;border-radius:2px;display:flex;align-items:center;justify-content:center;pointer-events:auto;cursor:default;`;
  // Control-tower SVG: a tower shaft (vertical rect) topped with the
  // tower cab (small inverted trapezoid) and an antenna. Reads as a
  // tower at small sizes — common installation glyph idiom.
  iconDiv.innerHTML = `<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="${color}" stroke-width="0.9" stroke-linecap="round" stroke-linejoin="round">
    <path d="M5.5 1.0v1.0"/>
    <path d="M3.5 2.5h4l-0.5 1.5h-3z" fill="${color}" stroke="${color}"/>
    <path d="M4.0 4.0h3v1.0h-3z"/>
    <path d="M4.5 5.0v4.5"/>
    <path d="M6.5 5.0v4.5"/>
    <path d="M3.5 9.5h4"/>
  </svg>`;

  // (No always-visible label — base ICAO + name surface in the hover
  // tooltip below. Always-on labels at globe scale read as clutter
  // when there are 40 bases on screen at once.)

  // Hover tooltip — name + service + country, anchored to the right
  // of the icon so the always-visible ICAO label below stays readable.
  const tipDiv = document.createElement('div');
  tipDiv.className = 'base-tip';
  tipDiv.style.cssText = `position:absolute;left:${SIZE/2 + 5}px;top:${-SIZE/2}px;white-space:nowrap;opacity:0;transition:opacity 0.15s;pointer-events:none;z-index:5;`;
  tipDiv.innerHTML = `<div style="background:rgba(15,15,17,0.94);border:1px solid ${color}88;border-radius:2px;padding:4px 7px;">
    <div style="font-family:'Roboto Mono',monospace;font-size:9px;font-weight:600;color:${color};letter-spacing:0.04em;">${b.name}</div>
    <div style="font-family:'Roboto Mono',monospace;font-size:7.5px;color:${C.fg2};">${b.icao} · ${b.service}</div>
    <div style="font-family:'Roboto Mono',monospace;font-size:7px;color:${C.fg3};">${b.country}</div>
  </div>`;
  iconDiv.addEventListener('mouseenter', () => { tipDiv.style.opacity = '1'; });
  iconDiv.addEventListener('mouseleave', () => { tipDiv.style.opacity = '0'; });

  el.appendChild(iconDiv);
  el.appendChild(tipDiv);
  return el;
}

// ── Airspace toolbar ──────────────────────────────────────────
const TOOLS = [
  {id:'select',icon:'⊹',label:'Select'},
  {id:'corridor',icon:'⊸',label:'Corridor'},
  {id:'killbox',icon:'⬚',label:'Kill Box'},
  {id:'deception',icon:'⤳',label:'Deception Rte'},
  {id:'target',icon:'⊕',label:'Strike Target'},
  {id:'measure',icon:'⊷',label:'Measure'},
];

// ── Master-mode strip (the 44 px left rail) ──────────────────
// Closed/collapsed state of the master-mode drawer. Each row is a
// clickable mode icon — click switches mode directly via the same
// requestModeSwitch handler the drawer uses (so leaving an active CSAR
// case still surfaces the confirmation modal). Active mode shows accent
// fill + 3 px left rail bar, mirroring the drawer's active-row treatment.
// CSAR row gets a pulsing red dot + count when the operator is in
// another mode while a CSAR session is live.
function MasterModeStrip({ activeMode, csarUnreadCount, csarSessionActive, onSelectMode, modes }) {
  // Manage System's Config tab can hide Master Modes from the strip; the
  // parent passes a pre-filtered `modes` array. Fallback to the global
  // list when called without that prop (older callers).
  const _modes = modes || window.MASTER_MODES || [];
  return (
    <div style={{ position:'absolute',left:0,top:0,bottom:0,width:44,zIndex:10,
      background:C.s2,borderRight:`1px solid ${C.border}`,
      display:'flex',flexDirection:'column',alignItems:'stretch',paddingTop:6,gap:2 }}>
      {_modes.map(mode => {
        const Icon   = (window.MODE_ICONS || {})[mode.icon];
        const active = mode.id === activeMode;
        const stub   = !mode.implemented;
        const showBadge = mode.id === 'csar' && csarUnreadCount > 0 && activeMode !== 'csar' && csarSessionActive;
        return (
          <button
            key={mode.id}
            title={`${mode.label}${mode.desc ? '\n' + mode.desc : ''}${stub ? '\n(Not yet implemented)' : ''}`}
            onClick={() => onSelectMode(mode.id)}
            style={{
              position:'relative',
              height:38,
              border:'none',
              background: active ? C.accentM : 'transparent',
              borderLeft: `3px solid ${active ? C.accent : 'transparent'}`,
              color: active ? C.accent : (stub ? C.fg3 : C.fg2),
              cursor:'pointer',
              display:'flex', alignItems:'center', justifyContent:'center',
              transition:'all 0.15s',
              padding:0,
              opacity: stub ? 0.7 : 1,
            }}
            onMouseEnter={(e)=>{ if (!active) e.currentTarget.style.background = C.s3; }}
            onMouseLeave={(e)=>{ if (!active) e.currentTarget.style.background = 'transparent'; }}>
            {Icon ? <Icon size={16}/> : <span style={{ width:14, height:14, border:`1px solid ${C.fg3}`, borderRadius:2 }}/>}
            {showBadge && (
              <span style={{
                position:'absolute', top:5, right:5, minWidth:12, height:12, padding:'0 3px',
                borderRadius:6, background:C.critical, color:'#fff',
                fontFamily:'Roboto Mono', fontSize:7.5, fontWeight:600,
                display:'flex', alignItems:'center', justifyContent:'center',
                border:`1px solid ${C.s2}`, boxSizing:'border-box', lineHeight:1,
              }}>
                {csarUnreadCount > 9 ? '9+' : csarUnreadCount}
              </span>
            )}
          </button>
        );
      })}
    </div>
  );
}

// ── Map quick toggle pill ────────────────────────────────────
// Compact on/off button sized to slot under the mission-type MM corner
// label (top-left of the map). Active state echoes the C2 accent so the
// operator can scan toggle states without reading the label.
function MmQuickToggle({ label, on, onChange, title }) {
  return (
    <button
      onClick={onChange}
      title={title}
      style={{
        padding:'3px 7px', borderRadius:3, cursor:'pointer',
        background: on ? 'rgba(61,158,255,0.18)' : 'rgba(20,22,26,0.78)',
        border: `1px solid ${on ? 'rgba(61,158,255,0.55)' : 'rgba(120,135,150,0.30)'}`,
        color: on ? '#3D9EFF' : 'rgba(180,195,210,0.55)',
        fontFamily:'Roboto Mono', fontSize:8, fontWeight:500,
        letterSpacing:'0.10em', textTransform:'uppercase', lineHeight:1.1,
        backdropFilter:'blur(2px)',
        transition:'background 0.12s, border-color 0.12s, color 0.12s',
      }}>
      {label} · {on ? 'ON' : 'OFF'}
    </button>
  );
}

// ── Main map component ────────────────────────────────────────
function TacticalMap({ appState, dayProgress, bravoLocated=false, oscId=null, activeLateThreats=[], activeCoa=null, coaBreach=false, launched=false, missionProgress=0, missionPhase='idle', divertDecision=null, rozPublished=false, pickupDone=false, onTaskedGhostId=null, j35AlphaDone=false, j35BravoDone=false, personaMode='ops', onTogglePersona=()=>{}, queueExpanded=false, nvgMode=false, activeMode='home', csarUnreadCount=0, csarSessionActive=false, onSelectMode=()=>{}, mapLayers=null, basemap='carto-dark', projection='flat', platformOverrides=null, imageryDimming=0, pendingFlyTo=null, onFlyToConsumed=()=>{}, pendingFitBounds=null, onFitBoundsConsumed=()=>{}, activeAors=null, rail1Width=0, rail2Width=0, mmShowAcms=true, onMmShowAcmsChange=()=>{}, activeTool='select', onActiveToolChange=()=>{} }) {
  const containerRef   = React.useRef(null);
  const mapRef         = React.useRef(null);
  const markersRef     = React.useRef({});
  // Target markers — only mounted while in a mission-type MM (PR 25.3).
  // Separate from markersRef so the MM-emphasis dim doesn't apply to
  // them (they're always full-bright when present).
  const tgtMarkersRef  = React.useRef([]);
  // Pairing-line state (PR 25.4) — refs for the dedicated rAF that
  // updates the GeoJSON line source each frame so lines follow moving
  // tracks. Cleared on MM exit.
  const pairRafRef     = React.useRef(null);
  const pairConfigRef  = React.useRef(null);
  const ipMarkerRef    = React.useRef(null);
  // Once-per-session flag: set true the first time we fly the camera to
  // the IP after J3.1 fires; cleared when the CSAR session ends (reset).
  // Prevents re-entering CSAR mode (after browsing COP/Admin) from
  // re-yanking the camera back to the IP.
  const firedIpFlyToRef = React.useRef(false);
  const tRef           = React.useRef(0);
  const stateRef       = React.useRef(appState);
  // Mirror activeMode into a ref so the rAF animate loop can read it
  // (loop runs ~60Hz independent of React renders). Used by the
  // mission-type MM emphasis logic to dim non-package tracks.
  const activeModeRef  = React.useRef(activeMode);
  // Mirror the mapLayers prop into a ref so the rAF animate loop can
  // read live per-AOR visibility state without going through React's
  // re-render cycle. Without this, toggling an AOR layer off would set
  // the marker's opacity to 0 in the visibility effect, but then the
  // next rAF tick's wantOpacity calc would see only the dim/non-dim
  // logic and overwrite back to 1 the moment any other state changed.
  const mapLayersRef   = React.useRef(mapLayers);
  const rafRef         = React.useRef(null);
  // Transit state — per-track override for linear fly-to animations (OSC handoff)
  const transitionsRef = React.useRef({});
  const prevOscRef     = React.useRef(null);
  // Track which late threats are live on the map so we can diff + clean up on reset
  const lateIdsRef     = React.useRef(new Set());
  // NVG color override: when nvgMode flips on we remember each tactical
  // layer's pre-NVG fill-color / line-color so we can restore them when
  // NVG flips off. Keyed by layer id; values are { fill, line } strings.
  // Cleared on each STD→NVG transition so a fresh capture happens.
  const nvgColorBackupRef = React.useRef({});
  // Tracks the previous nvgMode value across renders so the override
  // effect can short-circuit when in STD mode without an NVG→STD
  // transition. Without this guard the effect would re-run + walk the
  // full style on every late-threat / COA / ROZ change in STD mode for
  // no work — getStyle() is expensive on a busy case.
  const prevNvgModeRef    = React.useRef(false);
  // Phase 3 mission motion refs — driven by missionProgress + divertDecision.
  const missionRef     = React.useRef({ launched:false, progress:0, phase:'idle', divert:null });
  // ISR retasking ref — `current` is the GHOST id being on-tasked (or null).
  // Per-GHOST transit state stored in ghostTransitsRef so we can run a fly-to
  // animation from the GHOST's last orbit position to the IP, then a synthetic
  // wide IP orbit at FL280, and a return transit when retasking clears.
  const onTaskedGhostRef = React.useRef(null);
  const ghostTransitsRef = React.useRef({}); // { [id]: { phase, fromPos, startTime, ...} }
  const activeCoaRef   = React.useRef(activeCoa); // mirrored so the rAF loop reads live COA
  const joyDivertFromRef = React.useRef(null); // JOLLY 62 divert-origin point
  // Escort SANDY orbit tuning — cached when escort first enters the IP orbit
  // ring so the orbit continues smoothly from the entry angle rather than
  // jumping once escort's corridor position reaches IP center.
  const escortOrbitRef = React.useRef(null); // { olat, olng, period, phaseOff }
  // Acoustic detection ring config — populated when the map initialises so
  // the animate loop knows which sources to update each frame.
  const acousticConfigRef = React.useRef({ bands:[], helos:[] });
  // Tracks the last shown/hidden state of acoustic rings so we only clear
  // the GeoJSON once on transition rather than every frame.
  const acousticActiveRef = React.useRef(false);
  // In-flight reroute: when a fresh COA is committed mid-ingress (e.g., after
  // a disruption SAM forces a re-pick), CSARTF can't restart from staging —
  // it has to fly the new geometry from wherever it is right now. We capture
  // a per-formation polyline from PEDRO's live position to the IP, plus the
  // mission progress at the moment of reroute, and remap u along this leg.
  // Cleared on reset / mission complete / new launch.
  //
  // Active in-flight route, mirrored from `activeCoa.ingressRoute` /
  // egressRoute so the rAF animate loop can read it without prop access. The
  // publish effect below assigns this on every activeCoa change — including
  // mid-flight reroutes where the operator re-picks the same COA letter but
  // with a fresh PEDRO-anchored geometry.
  const inflightRouteRef     = React.useRef(null); // { ingressRoute, egressRoute }
  // Keep the ref pointing at the current activeCoa on every render (no effect
  // lag between commit and next animation frame).
  activeCoaRef.current = activeCoa;
  // activeTool state lifted out — drawing tools moved to the top bar's
  // DrawingToolsCell (in app.jsx). The 44 px left strip now hosts the
  // master-mode icon switcher instead of the drawing toolbar.
  const [mapReady, setMapReady] = React.useState(false);
  // Darkness overlay is always solid black in both STD and NVG modes —
  // tint variants (BLK/NVY/NVG) were retired in favour of the new NVG
  // window-treatment overlay (DOM-layer mix-blend-mode multiply, applied
  // on top of the WebGL canvas). See the NVG overlay div in the JSX
  // return below and the day/night fade effect that drives intensity.

  React.useEffect(() => { stateRef.current = appState; }, [appState]);
  React.useEffect(() => { activeModeRef.current = activeMode; }, [activeMode]);

  // MM emphasis dim — when in a mission-type MM (oca, dca, …), dim every
  // non-package element on the COP so the package reads as the figure
  // and everything else becomes ground.
  //
  // MapLibre's internal marker opacity (setOpacity / globe back-side
  // fade) fights `el.style.opacity` and re-applies on every render. So
  // we go through the marker's setOpacity API instead, which MapLibre
  // respects. setOpacity(front, back) — same value for both keeps the
  // marker visible at the requested transparency regardless of globe
  // facing.
  //
  // Applied to: hostile-marker, base-marker, SAM-ring centers
  // (mat-*/rf-*). The BF/track rAF loop handles its own dim for the
  // ATO_TASKUNITS-derived tracks via mmMemberOf.
  React.useEffect(() => {
    const inMm = !!(window.isMmTypeMode && window.isMmTypeMode(activeMode));
    const refMap = markersRef.current || {};
    Object.keys(refMap).forEach(tid => {
      const entry = refMap[tid];
      if (!entry || !entry.el) return;
      const cls = entry.el.classList;
      if (!cls) return;
      const isAmbientClass = cls.contains('hostile-marker')
        || cls.contains('base-marker')
        || (typeof tid === 'string' && (tid.startsWith('mat-') || tid.startsWith('rf')));
      if (!isAmbientClass) return;
      const targetOpacity = inMm ? '0.15' : '1';
      if (entry.marker && typeof entry.marker.setOpacity === 'function') {
        try { entry.marker.setOpacity(targetOpacity, targetOpacity); } catch(err) {}
      } else {
        // Fallback for marker variants without setOpacity exposure
        entry.el.style.opacity = inMm ? '0.15' : '';
      }
      entry.el.style.pointerEvents = inMm ? 'none' : '';
    });
  }, [activeMode, mapReady]);

  // Target-marker lifecycle (PR 25.3) — on entry into a mission-type MM,
  // walk that MM's package and drop a target-reticle marker at every
  // tasked DMPI. On exit (or mode swap to another MM), tear them down.
  // Lives in tgtMarkersRef (array) so we don't trip the MM-emphasis dim
  // logic that walks markersRef.
  //
  // Same effect also manages pairing lines (PR 25.4) — a GeoJSON line
  // layer connecting each striker's current map position to its tasked
  // target. Lines update every frame via a dedicated rAF so they
  // follow the airframe as it moves.
  React.useEffect(() => {
    const map = mapRef.current;
    if (!map || !mapReady) return;
    // ── Tear down target markers ────────────────────────────────
    (tgtMarkersRef.current || []).forEach(m => { try { m.remove(); } catch(e) {} });
    tgtMarkersRef.current = [];
    // ── Tear down pairing line layer + source ───────────────────
    try {
      if (map.getLayer('mm-pair-line'))   map.removeLayer('mm-pair-line');
      if (map.getSource('mm-pair-lines')) map.removeSource('mm-pair-lines');
    } catch(err) {}
    // ── Stop any running pairing-line rAF ──────────────────────
    if (pairRafRef.current) {
      cancelAnimationFrame(pairRafRef.current);
      pairRafRef.current = null;
    }
    pairConfigRef.current = null;

    if (!window.isMmTypeMode || !window.isMmTypeMode(activeMode)) return;

    const pkgIds = (window.MM_PACKAGE_MEMBERS && window.MM_PACKAGE_MEMBERS[activeMode]) || [];
    if (!pkgIds.length) return;
    const idSet = new Set(pkgIds);
    const missions = (window.MISSIONS || []).filter(m => idSet.has(m.id));

    // ── Mount target reticles ──────────────────────────────────
    missions.forEach(m => {
      const tgt = m.target;
      if (!tgt || !tgt.geo || tgt.geo.lat == null || tgt.geo.lng == null) return;
      try {
        const el = makeTargetMarkerEl(tgt, m);
        const marker = fullOpacity(new maplibregl.Marker({ element: el, anchor: 'center' })
          .setLngLat([tgt.geo.lng, tgt.geo.lat])
          .addTo(map));
        tgtMarkersRef.current.push(marker);
      } catch(err) {}
    });

    // ── Build pairing-line config + register layer ─────────────
    // For each mission with a target, build one line per AIRFRAME in
    // the flight (lead + every wingman). At animate time each line's
    // start point reads from the airframe's live marker LngLat; the
    // end is the static target geo. Airframes without a live marker
    // (pre-step, post-RTB) fall back to mission.depGeo so the strike
    // intent is still visible.
    const _AIRFRAMES = window.AIRFRAMES || [];
    const _afById = new Map(_AIRFRAMES.map(af => [af.id, af]));
    const pairings = [];
    missions.forEach(m => {
      const tgt = m.target;
      if (!tgt || !tgt.geo || tgt.geo.lat == null || tgt.geo.lng == null) return;
      const afIds = m.airframeIds || (m.flightLead ? [m.flightLead] : []);
      afIds.forEach(afId => {
        const af = _afById.get(afId);
        const legacyId = af && af.legacyId;
        pairings.push({
          missionId: m.id,
          callsign:  m.callsign,
          airframeId: afId,
          trackId:   legacyId || null,
          fallbackLngLat: m.depGeo ? [m.depGeo.lng, m.depGeo.lat] : null,
          targetLngLat:   [tgt.geo.lng, tgt.geo.lat],
        });
      });
    });
    if (pairings.length === 0) return;

    const buildFeatureCollection = () => {
      const features = pairings.map(p => {
        let start = p.fallbackLngLat;
        const entry = markersRef.current[p.trackId];
        if (entry && entry.marker) {
          try {
            const ll = entry.marker.getLngLat();
            if (ll && Number.isFinite(ll.lng) && Number.isFinite(ll.lat)) {
              start = [ll.lng, ll.lat];
            }
          } catch(err) {}
        }
        if (!start) return null;
        return {
          type: 'Feature',
          properties: { missionId: p.missionId, callsign: p.callsign },
          geometry: {
            type: 'LineString',
            coordinates: [start, p.targetLngLat],
          },
        };
      }).filter(Boolean);
      return { type:'FeatureCollection', features };
    };

    try {
      map.addSource('mm-pair-lines', { type:'geojson', data: buildFeatureCollection() });
      map.addLayer({
        id: 'mm-pair-line',
        type: 'line',
        source: 'mm-pair-lines',
        // Round caps + zero-length dash + larger gap renders as a
        // string of actual dots (vs. dashes). Width-scaled gap so the
        // dots don't crowd at higher zooms.
        layout: { 'line-cap': 'round', 'line-join': 'round' },
        paint: {
          'line-color':     C.fg1,         // white
          'line-width':     2,
          'line-dasharray': [0, 2.4],      // dot + gap
          'line-opacity':   0.85,
        },
      });
    } catch(err) {}

    // ── Start the pairing-line rAF ─────────────────────────────
    pairConfigRef.current = { pairings, buildFeatureCollection };
    const tick = () => {
      const cfg = pairConfigRef.current;
      if (!cfg) return;
      const src = map.getSource('mm-pair-lines');
      if (src) {
        try { src.setData(cfg.buildFeatureCollection()); } catch(err) {}
      }
      pairRafRef.current = requestAnimationFrame(tick);
    };
    pairRafRef.current = requestAnimationFrame(tick);
  }, [activeMode, mapReady]);

  React.useEffect(() => { mapLayersRef.current = mapLayers; }, [mapLayers]);

  // Mirror platformOverrides into a ref so the rAF animate loop can
  // refresh per-marker tooltip text without re-creating markers.
  // Empty object means no overrides → tooltip falls back to the
  // canonical track.mission / track.role.
  const platformOverridesRef = React.useRef(platformOverrides || {});
  React.useEffect(() => {
    platformOverridesRef.current = platformOverrides || {};
    // Walk every existing marker and push the override down to its
    // mission-line node. Markers cache the last-rendered label and
    // diff before updating, so this is cheap on no-op churn.
    const markers = markersRef.current || {};
    const ov = platformOverridesRef.current || {};
    Object.keys(markers).forEach(id => {
      const m = markers[id];
      const el = m && m.getElement && m.getElement();
      if (!el || !el._missionEl) return;
      // Find the canonical track for fallback label.
      const track = (window.ATO_TASKUNITS || []).find(t => t.id === id);
      const sourceMission = (ov[id] && ov[id].mission != null)
        ? ov[id].mission
        : (track ? (track.mission || track.role) : '');
      const label = window.getMissionTypeLabel
        ? window.getMissionTypeLabel(sourceMission)
        : sourceMission;
      if (label === el._lastMissionLabel) return;
      el._lastMissionLabel = label;
      el._missionEl.textContent = `${el._trackType} · ${label}`;
    });
  }, [platformOverrides]);

  // Pending fly-to consumer — Mission Admin's Objectives table sets
  // `pendingFlyTo={lat,lng,zoom}` and switches the master mode to
  // 'home'. Once the map is mounted + visible, run flyTo and ack
  // back to App so the prop clears.
  React.useEffect(() => {
    if (!pendingFlyTo) return;
    const map = mapRef.current;
    if (!map || !mapReady) return;
    if (activeMode !== 'home') return;
    try {
      map.flyTo({
        center: [pendingFlyTo.lng, pendingFlyTo.lat],
        zoom:    pendingFlyTo.zoom || 8,
        duration: 1400,
        essential: true,
      });
    } catch(e) {}
    // Mission Admin → Objectives → COP fly-to is operator intent, so
    // pin the camera afterward (suppress AOR-fit auto-camera so the
    // chosen objective stays framed instead of getting pulled to the
    // active-AOR fit). manualCameraRef is read by the AOR-fit effect.
    manualCameraRef.current = true;
    onFlyToConsumed();
  }, [pendingFlyTo, mapReady, activeMode]);

  // Pending fitBounds consumer — mission-type MM workspaces
  // (MissionTypeWorkspace, PR 25) set `pendingFitBounds={bbox:[w,s,e,n]}`
  // when the workspace mounts so the COP frames the package's working
  // area instead of the whole world. Acks back via onFitBoundsConsumed
  // so the prop clears. Pins the camera so a later AOR-fit doesn't
  // pull it away. Honored on any active mode (gated by the caller).
  React.useEffect(() => {
    if (!pendingFitBounds || !pendingFitBounds.bbox) return;
    const map = mapRef.current;
    if (!map || !mapReady) return;
    const [w, s, e, n] = pendingFitBounds.bbox;
    if (![w, s, e, n].every(Number.isFinite)) {
      onFitBoundsConsumed();
      return;
    }
    try {
      map.fitBounds([[w, s], [e, n]], {
        padding: pendingFitBounds.padding || { top: 80, bottom: 320, left: 80, right: 80 },
        duration: pendingFitBounds.duration ?? 1200,
        essential: true,
      });
    } catch(err) {}
    manualCameraRef.current = true;
    onFitBoundsConsumed();
  }, [pendingFitBounds, mapReady]);

  // ── AOR-aware default camera ─────────────────────────────────
  // The Mission Admin AOR selector is the canonical "what AORs am I
  // operating in" toggle. We want the COP camera to frame those AORs
  // automatically:
  //   - Single AOR active   → flyTo that AOR's centerLat/Lng/zoomLevel
  //   - Multiple AORs       → fitBounds across the union of bboxes
  // Triggers (per locked decisions):
  //   1. Live: while on COP, any change to activeAors re-fits
  //   2. On entry: switching INTO 'home' master mode re-fits to current
  //      activeAors regardless of prior camera state (fresh entry =
  //      fresh fit; a clean reset of the manual-camera latch).
  // Suppressed once the operator manually pans/zooms — manualCameraRef
  // flips true on drag/wheel, blocking subsequent AOR-fits until the
  // operator either re-enters home or hits reset. CSAR mode owns its
  // own camera flow (IP fly-to, COA, mission progress) so AOR fit is
  // gated to non-CSAR modes only. The pendingFlyTo handler above also
  // sets manualCameraRef so an Objectives "fly to coords" survives
  // a subsequent AOR toggle.
  const manualCameraRef = React.useRef(false);
  const lastActiveModeRef = React.useRef(activeMode);
  // Latch: when activeMode transitions INTO 'home' from anywhere else,
  // clear the manual flag so the on-entry fit runs against the current
  // activeAors. Use a ref so we don't churn the AOR-fit effect's deps.
  React.useEffect(() => {
    const prev = lastActiveModeRef.current;
    // Clear the manual-camera latch on entry into 'home' OR into any
    // mission-type MM. PR 25's mission-type workspaces (oca, …) each
    // queue a fresh pendingFitBounds on entry, and that fit needs the
    // latch clear so prior manual pans don't bleed through.
    const _isMm = window.isMmTypeMode || (() => false);
    const isEnteringHome = activeMode === 'home' && prev !== 'home';
    const isEnteringMmType = _isMm(activeMode) && !_isMm(prev);
    if (isEnteringHome || isEnteringMmType) {
      manualCameraRef.current = false;
    }
    lastActiveModeRef.current = activeMode;
  }, [activeMode]);

  // Wire the manual-camera latch — any operator drag/wheel/zoom flips
  // it true. MapLibre fires `dragstart`, `zoomstart`, `pitchstart`,
  // `rotatestart` on user input. The flyTo / fitBounds calls *we*
  // make also fire these unfortunately, so we gate on `originalEvent`
  // being present (only set on user-driven events). Per MapLibre's
  // event docs that's the canonical way to distinguish.
  React.useEffect(() => {
    const map = mapRef.current;
    if (!map || !mapReady) return;
    const onUser = (e) => {
      if (e && e.originalEvent) manualCameraRef.current = true;
    };
    map.on('dragstart', onUser);
    map.on('zoomstart', onUser);
    map.on('pitchstart', onUser);
    map.on('rotatestart', onUser);
    return () => {
      try {
        map.off('dragstart', onUser);
        map.off('zoomstart', onUser);
        map.off('pitchstart', onUser);
        map.off('rotatestart', onUser);
      } catch(e) {}
    };
  }, [mapReady]);

  // Serialize activeAors for the dep array — useEffect compares Set
  // identity, not contents, so we hash the membership into a stable
  // string. Empty set → null so the effect bails cleanly.
  const _activeAorsKey = activeAors && activeAors.size
    ? Array.from(activeAors).sort().join(',')
    : null;
  React.useEffect(() => {
    if (activeMode !== 'home') return;
    if (manualCameraRef.current) return;
    if (!_activeAorsKey) return;
    const map = mapRef.current;
    if (!map || !mapReady) return;
    const aors = (window.AORS || []).filter(a => activeAors && activeAors.has(a.id));
    if (aors.length === 0) return;
    try {
      if (aors.length === 1) {
        const a = aors[0];
        map.flyTo({
          center: [a.centerLng, a.centerLat],
          zoom: a.zoomLevel,
          duration: 900,
          essential: true,
        });
      } else {
        // Compute the union bbox across every active AOR's bbox.
        // Each bbox is [west, south, east, north].
        let w = Infinity, s = Infinity, e = -Infinity, n = -Infinity;
        aors.forEach(a => {
          if (!a.bbox) return;
          const [aw, as, ae, an] = a.bbox;
          if (aw < w) w = aw; if (as < s) s = as;
          if (ae > e) e = ae; if (an > n) n = an;
        });
        if (Number.isFinite(w) && Number.isFinite(e)) {
          map.fitBounds([[w, s], [e, n]], {
            // Pad against the right-rail width and a fixed top/bottom
            // gutter so the fit doesn't tuck content under the rails.
            padding: { top: 60, bottom: 60, left: 80, right: 80 + (rail1Width || 0) + (rail2Width || 0) },
            duration: 900,
            essential: true,
          });
        }
      }
    } catch(err) {}
  }, [_activeAorsKey, activeMode, mapReady]);

  // Phase 3: keep mission state mirrored to a ref so the rAF loop can read it.
  // Stash the wall-clock time of each update so the rAF loop can interpolate
  // smoothly between 1 Hz state ticks (otherwise HH-60 motion looks stuttery).
  React.useEffect(() => {
    missionRef.current = {
      launched, progress:missionProgress, phase:missionPhase, divert:divertDecision, oscId,
      updateTime: (typeof performance !== 'undefined' ? performance.now() : Date.now()),
    };
    // Clear divert anim state whenever launch ends or the decision clears.
    if (!launched || !divertDecision) joyDivertFromRef.current = null;
    // Clear escort orbit cache when the mission is no longer running.
    if (!launched) escortOrbitRef.current = null;
  }, [launched, missionProgress, missionPhase, divertDecision, oscId]);

  // Sync the on-tasked GHOST id into the rAF-readable ref. When a GHOST is
  // newly on-tasked we capture its current screen position as the transit
  // origin; when it clears we kick a return transit back to its orbit centre.
  //
  // Transits start from `startT` (the orbit clock tRef.current at trigger
  // time) instead of wall-clock ms, so spacebar fast-forward (which advances
  // tRef 5× faster) speeds the transit up too. Without this, transits ran
  // off pure performance.now() and ignored FF entirely.
  React.useEffect(() => {
    const prev = onTaskedGhostRef.current;
    onTaskedGhostRef.current = onTaskedGhostId;
    const transits = ghostTransitsRef.current;
    if (onTaskedGhostId && onTaskedGhostId !== prev) {
      // Newly on-tasked — capture origin from the live marker if we have one,
      // else fall back to the orbit centre. The animate loop will then fly a
      // straight line to IP at MQ-9 transit speed and switch into wide-orbit.
      const ghost = (window.ISR_ASSETS || []).find(g => g.id === onTaskedGhostId);
      if (ghost) {
        const entry = markersRef.current[onTaskedGhostId];
        const start = entry ? entry.marker.getLngLat() : null;
        const fromPos = start ? { lat:start.lat, lng:start.lng } : { lat:ghost.base.lat, lng:ghost.base.lng };
        transits[onTaskedGhostId] = {
          phase:'transit-in',
          fromPos,
          startT: tRef.current,
        };
      }
    }
    if (!onTaskedGhostId && prev) {
      // Cleared — kick return transit for the previously on-tasked GHOST.
      const ghost = (window.ISR_ASSETS || []).find(g => g.id === prev);
      const entry = markersRef.current[prev];
      if (ghost && entry) {
        const ll = entry.marker.getLngLat();
        transits[prev] = {
          phase:'transit-out',
          fromPos:{ lat:ll.lat, lng:ll.lng },
          startT: tRef.current,
        };
      }
    }
  }, [onTaskedGhostId]);


  // ── Initialize MapLibre ──────────────────────────────────────
  React.useEffect(() => {
    if (!containerRef.current || typeof maplibregl === 'undefined') return;

    // Initial projection matches the prop. The operator can flip
    // between FLAT (Mercator strip) and GLOBE (3D sphere) via the
    // VIEW pill in the top bar — handled by a separate effect below
    // that calls map.setProjection() when the prop changes. At cold
    // load we use whichever the prop says (default 'flat' for AOR
    // SA — long lat strip frames an entire COCOM in one view).
    const initialProjType = (projection === 'globe') ? 'globe' : 'mercator';
    const map = new maplibregl.Map({
      container: containerRef.current,
      style: {
        version: 8,
        projection: { type: initialProjType },
        sources: {},
        layers: [{ id:'bg', type:'background', paint:{'background-color':'#001129'} }],
      },
      // Strategic COP default — prime meridian centered.
      // Zoom: globe at 2.5 (hemisphere view), flat at 1.5 (full strip
      // California → Australia). The two zoom values are tuned to
      // give similar geographic coverage in their respective
      // projections; we pick based on the initial projection at mount.
      // The CSAR-mode useEffect below flyTo's to the IP when the
      // scenario kicks off, overriding the boot zoom.
      center: [0, 25],
      zoom: initialProjType === 'globe' ? 2.5 : 1.5,
      attributionControl: false,
      pitchWithRotate: false,
    });
    mapRef.current = map;

    // Poll via rAF until canvas has dimensions — markers work as soon as that's true,
    // no need to wait for style/tile load events (which may be suppressed in sandbox).
    let initialized = false;
    const tryInit = () => {
      if (initialized) return;
      try {
        const canvas = map.getCanvas();
        if (!canvas || canvas.width === 0) { requestAnimationFrame(tryInit); return; }
      } catch(e) { requestAnimationFrame(tryInit); return; }
      initialized = true;

      // Add raster tile layers lazily — fail silently if CORS blocks them.
      //
      // Two basemap rasters stacked. Satellite (Esri World Imagery) is
      // the daytime view. Carto Dark Matter (no-labels variant) crossfades
      // in as `dayProgress` rises — by full dark the satellite is mostly
      // faded out and Carto's dark cartography (coastlines, rivers, road
      // skeletons) is doing the visual work. We skipped the labelled
      // Carto variant because road/township labels smear when the camera
      // tilts into 2.5D. Carto fade-in is suppressed entirely in NVG mode
      // (operator wants satellite-through-goggles, not schematic).
      // Layer stack (bottom → top):
      //   satellite-over    — Esri raster, always at full opacity. Saturation
      //                       drops from 0 → -1 (full greyscale) as
      //                       dayProgress climbs, so the satellite reads as
      //                       desaturated terrain by full dark. Contrast is
      //                       not touched — left at MapLibre's default 0.
      //   darkness-overlay  — solid black, ramps 0 → 0.75 with dayProgress in
      //                       both modes. Sits ABOVE satellite so it darkens
      //                       only the basemap, not the tactical GeoJSON
      //                       overlays (threats, ROZ, COA) painted after it.
      //   ...tactical layers (threats, ROZ, COA, mission tracks) follow
      //
      // Three basemap layers as peers — exactly one is visible at any
      // given time. CSAR mode forces satellite (it's the only basemap
      // CSAR was designed against — darkness fade + threat-ring colour
      // logic all assume satellite under them). Home/COP mode lets the
      // operator pick via the basemap dropdown (Carto Dark default,
      // Satellite or Carto Light optional). Visibility is driven by
      // the basemap-visibility effect below; default-visible ones are
      // overridden the first time the effect runs.
      try {
        map.addSource('esri-sat', { type:'raster', tiles:['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'], tileSize:256 });
        map.addLayer({ id:'satellite-over', type:'raster', source:'esri-sat',
          paint:{
            'raster-opacity': 1.0,
            'raster-saturation': 0,
            // Zero transitions: paint changes apply instantly. Prevents
            // the ~300ms default-transition flash when the operator
            // returns to CSAR mode after browsing COP/Admin — the
            // dayProgress-driven saturation jump from 0 (COP-clamped)
            // back to the held dark value would otherwise visibly
            // ramp through bright colors before settling at dark.
            'raster-saturation-transition': { duration: 0 },
            'raster-opacity-transition':    { duration: 0 },
          },
        });
      } catch(e) {}
      try {
        map.addSource('carto-dark', { type:'raster', tiles:[
          'https://cartodb-basemaps-a.global.ssl.fastly.net/dark_nolabels/{z}/{x}/{y}.png',
          'https://cartodb-basemaps-b.global.ssl.fastly.net/dark_nolabels/{z}/{x}/{y}.png',
          'https://cartodb-basemaps-c.global.ssl.fastly.net/dark_nolabels/{z}/{x}/{y}.png',
          'https://cartodb-basemaps-d.global.ssl.fastly.net/dark_nolabels/{z}/{x}/{y}.png',
        ], tileSize:256, attribution:'© OpenStreetMap contributors © CARTO' });
        map.addLayer({ id:'carto-dark', type:'raster', source:'carto-dark',
          paint:{ 'raster-opacity': 1.0 } });
      } catch(e) {}
      try {
        map.addSource('carto-light', { type:'raster', tiles:[
          'https://cartodb-basemaps-a.global.ssl.fastly.net/light_nolabels/{z}/{x}/{y}.png',
          'https://cartodb-basemaps-b.global.ssl.fastly.net/light_nolabels/{z}/{x}/{y}.png',
          'https://cartodb-basemaps-c.global.ssl.fastly.net/light_nolabels/{z}/{x}/{y}.png',
          'https://cartodb-basemaps-d.global.ssl.fastly.net/light_nolabels/{z}/{x}/{y}.png',
        ], tileSize:256, attribution:'© OpenStreetMap contributors © CARTO' });
        map.addLayer({ id:'carto-light', type:'raster', source:'carto-light',
          paint:{ 'raster-opacity': 1.0 } });
      } catch(e) {}
      try {
        map.addLayer({ id:'darkness-overlay', type:'background',
          paint:{
            'background-color': '#000000',
            'background-opacity': 0,
            // Zero transition: see satellite-over above. Prevents the
            // re-entry flash where the dim overlay would briefly show
            // at 0 opacity (COP-clamped) and then ramp back up to the
            // held dark value over MapLibre's default 300ms.
            'background-opacity-transition': { duration: 0 },
          },
        });
      } catch(e) {}
      // ── Terrain (AWS Open Data — Terrarium-encoded DEM) ──────
      // Source-only: we don't add a 2D hillshade layer because the satellite
      // imagery already does its own terrain shading on land, and a global
      // hillshade also bleeds across the ocean (ocean-floor bathymetry is
      // encoded in the DEM). setTerrain wires the DEM into MapLibre's 3D
      // pipeline — invisible at pitch=0 (current ops view) and revealed when
      // the camera tilts (IP persona view). Falls silent if AWS is unreachable.
      try {
        map.addSource('terrain-dem', {
          type:'raster-dem',
          tiles:['https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png'],
          encoding:'terrarium',
          tileSize:256,
          maxzoom:14,
        });
        map.setTerrain({ source:'terrain-dem', exaggeration:1.4 });
      } catch(e) {}

      // Inject hover-label CSS once
      if (!document.getElementById('track-hover-css')) {
        const s = document.createElement('style');
        s.id = 'track-hover-css';
        s.textContent = `.track-marker .track-label{opacity:0;transition:opacity 0.15s;pointer-events:none;} .track-marker:hover .track-label{opacity:1;}`;
        document.head.appendChild(s);
      }

      // ── Red force threat rings (GeoJSON — no tile dependency) ─
      window.RED_FORCES.forEach(rf => {
        const clng = rf.lng ?? rf.base?.lng ?? 0;
        const clat = rf.lat ?? rf.base?.lat ?? 0;
        if (rf.radiusKm > 0) {
          map.addSource(`thr-${rf.id}`, { type:'geojson', data: circleGeoJSON(clng, clat, rf.radiusKm) });
          map.addLayer({ id:`thr-fill-${rf.id}`, type:'fill',   source:`thr-${rf.id}`, paint:{'fill-color':C.critical,'fill-opacity':0.05} });
          map.addLayer({ id:`thr-line-${rf.id}`, type:'line',   source:`thr-${rf.id}`, paint:{'line-color':C.critical,'line-width':1.5,'line-dasharray':[4,3],'line-opacity':0.7} });
        }
        const el = makeRedMarkerEl(rf);
        const m = fullOpacity(new maplibregl.Marker({ element:el, anchor:'center' }).setLngLat([clng, clat]).addTo(map));
        markersRef.current[rf.id] = { marker:m, el };
      });

      // ── Multi-AOR threat rings (CENTCOM, EUCOM, AFRICOM ambient
      // IADS picture rendered globally in Home/COP mode). Same paint
      // shape as RED_FORCES — toggleable as one layer-group via the
      // map-layers panel. Use lower opacity than CSAR threats so the
      // active scenario stays visually dominant when both are visible.
      (window.MULTI_AOR_THREATS || []).forEach(rf => {
        const clng = rf.lng ?? 0, clat = rf.lat ?? 0;
        if (rf.radiusKm > 0) {
          try {
            map.addSource(`mat-${rf.id}`, { type:'geojson', data: circleGeoJSON(clng, clat, rf.radiusKm) });
            // Two render treatments based on range tier:
            //   • 'long'  — strategic SAMs (S-300/S-400/Bavar/HQ-9, ≥100 km).
            //               Faint dashed envelope so the airspace-watch
            //               picture doesn't drown out point-defense rings.
            //   • 'point' — short-range / point-defense (<50 km).
            //               Solid stronger fill — these are the rings the
            //               operator actively routes around.
            // Default (no range tier) inherits the legacy paint so older
            // entries still render correctly.
            // Threat-ring opacity values are tuned higher than the
            // original baseline so the SAM picture reads at low zoom
            // without the operator having to squint. Long-range rings
            // still render lighter than point-defense — they're an
            // airspace-watch overlay, not a "route around me" ring —
            // but both now have enough fill/stroke weight to register
            // against the dark basemap. Bumped 2025-05.
            const isLong = rf.range === 'long';
            const fillOpacity = isLong ? 0.05 : 0.12;
            const lineOpacity = isLong ? 0.65 : 0.90;
            const dashArray   = isLong ? [6,5] : [3,3];
            const lineWidth   = isLong ? 1.1  : 1.4;
            map.addLayer({ id:`mat-fill-${rf.id}`, type:'fill', source:`mat-${rf.id}`,
              paint:{'fill-color':C.critical,'fill-opacity':fillOpacity} });
            map.addLayer({ id:`mat-line-${rf.id}`, type:'line', source:`mat-${rf.id}`,
              paint:{'line-color':C.critical,'line-width':lineWidth,'line-dasharray':dashArray,'line-opacity':lineOpacity} });
          } catch(e) {}
        }
        // Spawn a center marker (NTDS Land/Hostile glyph for SAM-class)
        // so the operator can hover for label + has a visual anchor at
        // wide-zoom views where the ring may be too small to read.
        // makeRedMarkerEl branches on rf.type to pick the right symbol.
        const el = makeRedMarkerEl(rf);
        const m = fullOpacity(new maplibregl.Marker({ element:el, anchor:'center' }).setLngLat([clng, clat]).addTo(map));
        markersRef.current[rf.id] = { marker:m, el };
      });

      // ── Hostile-track non-SAM picture (HOSTILE_TRACKS) ────────
      // Point-only markers (no engagement envelope, unlike SAMs).
      // Rendered at slightly lower visual priority than SAM rings so
      // the IADS picture stays the dominant red signal. Toggleable
      // as a single layer-group via map-layers panel; styling
      // (generic ntds-target vs per-type NTDS glyph) is a sub-toggle
      // also driven from the map-layers panel.
      const initialHostileStyle = (mapLayers && mapLayers.hostileTrackStyle) || 'generic';
      (window.HOSTILE_TRACKS || []).forEach(t => {
        if (t.lat == null || t.lng == null) return;
        try {
          const el = makeHostileMarkerEl(t, initialHostileStyle);
          // fullOpacity → setOpacity('1', '0') so the marker is hidden
          // when on the back side of the globe in 3D projection. Without
          // this hostile diamonds painted as DOM elements at their
          // projected screen position even when behind the planet.
          const m = fullOpacity(new maplibregl.Marker({ element:el, anchor:'center' })
            .setLngLat([t.lng, t.lat]).addTo(map));
          markersRef.current[t.id] = { marker:m, el };
        } catch(e) {}
      });

      // ── Blue bases (US + coalition installations) ─────────────
      // Static markers at known forward-deployed locations. Same
      // back-globe occlusion fix as hostile tracks. Visibility gated
      // per-AOR via the map-layers panel; render path lives in the
      // visibility effect below (find by `base-marker` className).
      (window.BLUE_BASES || []).forEach(b => {
        if (b.lat == null || b.lng == null) return;
        try {
          const el = makeBaseMarkerEl(b);
          const m = fullOpacity(new maplibregl.Marker({ element:el, anchor:'center' })
            .setLngLat([b.lng, b.lat]).addTo(map));
          markersRef.current[b.id] = { marker:m, el };
        } catch(e) {}
      });

      // ── Trans-AOR mission planned route ───────────────────────
      // BONE 11 from Whiteman → Iran → Diego Garcia. Drawn as two
      // polylines: solid for the "flown" portion (start through current
      // position), dashed for the "planned" portion (current through
      // recovery). Toggleable via the map-layers panel.
      (window.TRANS_AOR_ROUTES || []).forEach(route => {
        const wps = route.waypoints || [];
        if (wps.length < 2) return;
        const flown   = wps.slice(0, route.currentIndex + 1).map(w => [w.lng, w.lat]);
        const planned = wps.slice(route.currentIndex     ).map(w => [w.lng, w.lat]);
        try {
          map.addSource(`trax-flown-${route.id}`, {
            type:'geojson', data:{ type:'Feature', geometry:{ type:'LineString', coordinates: flown } } });
          map.addLayer({ id:`trax-flown-line-${route.id}`, type:'line', source:`trax-flown-${route.id}`,
            paint:{'line-color':C.accent2,'line-width':2,'line-opacity':0.85} });
          map.addSource(`trax-planned-${route.id}`, {
            type:'geojson', data:{ type:'Feature', geometry:{ type:'LineString', coordinates: planned } } });
          map.addLayer({ id:`trax-planned-line-${route.id}`, type:'line', source:`trax-planned-${route.id}`,
            paint:{'line-color':C.accent2,'line-width':1.5,'line-dasharray':[4,4],'line-opacity':0.55} });
        } catch(e) {}
      });

      // ── Political airspace washes (Russian / Chinese / NATO) ──
      // Background colored fill + faint line per alignment. Single
      // LOD — Natural Earth 50m country shapes, accurate to ~1km,
      // looks fine at every zoom we use. Inserted BEFORE the AOR
      // boundary line so alignment washes sit underneath the dashed
      // COCOM overlays in z-order.
      const _airspaceBlocks = [
        { key:'russian', data:window.RUSSIAN_AIRSPACE, color:C.critical },  // red
        { key:'chinese', data:window.CHINESE_AIRSPACE, color:C.advisory },  // amber
        { key:'nato',    data:window.NATO_AIRSPACE,    color:C.accent2 },    // cyan
      ];
      _airspaceBlocks.forEach(block => {
        if (!block.data) return;
        try {
          map.addSource(`airspace-${block.key}`, {
            type:'geojson',
            data: block.data,
          });
          // Faint fill — the wash. Distinguishes alignment at a glance
          // without competing with tracks/threats for visual priority.
          // No outline — the wash alone reads cleanly and an outline
          // adds visual noise (especially where adjacent NATO countries
          // share borders and would draw an internal seam).
          map.addLayer({ id:`airspace-${block.key}-fill`, type:'fill', source:`airspace-${block.key}`,
            paint:{'fill-color':block.color,'fill-opacity':0.06} });
        } catch(e) {}
      });

      // ── AOR boundary lines (rough geographic COCOM polygons) ──
      // Approximations only — real AOR boundaries are complex and
      // include exclusions, sub-regions, etc. These trace the broad
      // strokes for orientation. Toggleable via map-layers panel.
      const AOR_BOUNDARIES = window.AOR_BOUNDARY_GEOJSON || null;
      if (AOR_BOUNDARIES) {
        try {
          map.addSource('aor-boundaries', { type:'geojson', data: AOR_BOUNDARIES });
          map.addLayer({ id:'aor-boundaries-line', type:'line', source:'aor-boundaries',
            paint:{'line-color':C.fg3,'line-width':1.2,'line-dasharray':[6,4],'line-opacity':0.55} });
        } catch(e) {}
      }

      // ── Blue force + CSARTF asset markers ──────────────────
      // Cache child element refs + classification flags on the entry so
      // the per-frame animate loop avoids:
      //   • el.querySelector('.track-icon' / '.track-dot' / '.divert-chip')
      //     repeated on every visible track every frame (~200 DOM
      //     queries/frame on a busy view — each ~2-5μs)
      //   • Set.has() lookups against ID classification (NON_CSARTF_IDS,
      //     ISR_IDS) per track per frame
      // All of these values are static for the lifetime of the marker
      // (DOM children don't move, classification doesn't change), so
      // computing them once at creation is a clean win.
      // Build a per-MM membership lookup once. Each entry maps a track
      // ID to the set of mission-type MM ids whose package includes it.
      // The animate loop dims tracks NOT in the current MM's package.
      // Resolution: ATO_TASKUNITS-derived tracks carry `_missionId` (see
      // _legacyTrackFromAirframe in data.js); we cross-reference that
      // against MM_PACKAGE_MEMBERS. Tracks without `_missionId` (the
      // ambient trans-AOR ones) are never package members.
      const _mmMemberByTrackId = (() => {
        const out = new Map();
        const pkgs = window.MM_PACKAGE_MEMBERS || {};
        Object.keys(pkgs).forEach(mmId => {
          const memberSet = new Set(pkgs[mmId] || []);
          (window.ALL_TRACKS || []).forEach(tr => {
            const mid = tr._missionId;
            if (!mid || !memberSet.has(mid)) return;
            if (!out.has(tr.id)) out.set(tr.id, new Set());
            out.get(tr.id).add(mmId);
          });
        });
        return out;
      })();
      window.ALL_TRACKS.forEach(track => {
        const el = makeBFMarkerEl(track);
        const pos = getPos(track, 0);
        const m = fullOpacity(new maplibregl.Marker({ element:el, anchor:'center' }).setLngLat([pos.lng, pos.lat]).addTo(map));
        markersRef.current[track.id] = {
          marker: m, el, track,
          // Cached child refs — null if not present in this marker variant.
          iconEl:  el.querySelector('.track-icon'),
          dotEl:   el.querySelector('.track-dot'),
          chipEl:  el.querySelector('.divert-chip'),
          labelEl: el.querySelector('.track-label'),
          // Cached classification flags (set-once-read-many).
          isIsr:        !!(window.ISR_IDS && window.ISR_IDS.has(track.id)),
          isNonCsartf:  !!(window.NON_CSARTF_IDS && window.NON_CSARTF_IDS.has(track.id)),
          isOtherAor:   !!(track.aor && track.aor !== 'indopacom'),
          // Set of MM ids whose package this track belongs to. Used by
          // the animate loop to decide whether to dim in MM emphasis mode.
          mmMemberOf:   _mmMemberByTrackId.get(track.id) || null,
          // Last-written style values so we can skip redundant DOM writes
          // when the value hasn't changed (avoids unnecessary style recalc).
          lastOpacity: '',
          lastDisplay: '',
          lastChipDisplay: '',
        };
      });

      // ── Acoustic detection rings ─────────────────────────────
      // Concentric circles around each HH-60 representing detection bands —
      // 3 km strong / 7 km probable / 12 km faint. Visible only during
      // ingress + terminal phases; once survivors are aboard the acoustic
      // signature matters less than getting clear of the threat envelope.
      // Rings are draped over terrain via setTerrain so they conform when
      // the camera pitches into the IP-persona view.
      const ACOUSTIC_BANDS = [
        { id:'far',  radiusKm:12, fillOpacity:0.030 },
        { id:'mid',  radiusKm:7,  fillOpacity:0.060 },
        { id:'near', radiusKm:3,  fillOpacity:0.105 },
      ];
      const ACOUSTIC_HELOS = ['a3', 'a4'];
      ACOUSTIC_HELOS.forEach(heloId => {
        ACOUSTIC_BANDS.forEach(band => {
          const sid = `acoustic-${heloId}-${band.id}`;
          try {
            map.addSource(sid, { type:'geojson', data: { type:'FeatureCollection', features:[] } });
            map.addLayer({
              id:`${sid}-fill`,
              type:'fill', source:sid,
              paint:{ 'fill-color':'#6ed1ff', 'fill-opacity':band.fillOpacity },
            });
          } catch(e) {}
        });
      });
      acousticConfigRef.current = { bands: ACOUSTIC_BANDS, helos: ACOUSTIC_HELOS };

      // Expose PEDRO 61's live position so app.jsx can use it as the start
      // anchor when regenerating COAs mid-flight. Returns null pre-mount;
      // app falls back to STAGING_COORD in that case.
      window.getPedroPos = () => {
        const e = markersRef.current && markersRef.current['a3'];
        if (!e || !e.marker) return null;
        const ll = e.marker.getLngLat();
        return { lat: ll.lat, lng: ll.lng };
      };

      setMapReady(true);
      // Expose the markers ref so the mission-type MM bbox helper
      // (see _mmPackageBbox in app.jsx) can read live track positions
      // when framing the COP camera on workspace entry.
      window._tacticalMapMarkers = markersRef.current;

      // ── Low-zoom dot mode ────────────────────────────────────
      // Past 500mi map width (≈ zoom < 6.5 at mid-latitudes) the
      // operator is looking at AOR-scale or wider. Track icons become
      // visual noise — swap to 4px dots that carry track color so AOR
      // composition still reads at a glance. The CSS swap in the page
      // <style> targets `.track-icon` / `.track-dot` based on the
      // data-low-zoom attribute on the map wrapper.
      const wrapper = containerRef.current && containerRef.current.parentElement;
      const LOW_ZOOM_THRESHOLD = 6.5;
      const updateLowZoomFlag = () => {
        if (!wrapper) return;
        const isLow = map.getZoom() < LOW_ZOOM_THRESHOLD;
        const cur = wrapper.getAttribute('data-low-zoom') === '1';
        if (isLow && !cur) wrapper.setAttribute('data-low-zoom', '1');
        else if (!isLow && cur) wrapper.removeAttribute('data-low-zoom');
      };
      map.on('zoom', updateLowZoomFlag);
      updateLowZoomFlag();

      // ── Animation loop ─────────────────────────────────────
      // Orbit clock advances by wall-clock time, not by frame count.
      // Was previously `tRef.current += 0.003 * ff` per rAF call —
      // gave constant per-FRAME advance, but if rAF intervals jittered
      // (browser busy, tab background, expensive paint frame), the
      // orbit visibly surged/slowed. This was most visible in tilt
      // view where the per-frame paint cost is higher (3D camera +
      // terrain) so frame intervals jitter more. SANDY/DUKE 32 etc.
      // appeared to surge and slow even though their orbit math
      // was deterministic.
      //
      // Now we compute t from `(now - tStart) * RATE + ffExtra` where
      // tStart is captured at first frame, RATE is the per-second
      // advance rate (0.18 t-units/sec, equivalent to the old 0.003 *
      // 60fps), and ffExtra accumulates the EXTRA advance time when
      // fast-forward is held (so a 1s FF hold adds 4s of extra
      // simulated time on top of the 1s of natural advance).
      const T_RATE_PER_SEC = 0.18; // t-units advanced per real second
      let tStartMs = null;
      let tLastFrameMs = null;
      let tFFExtraSec = 0;
      const animate = () => {
        const nowMs = (typeof performance !== 'undefined' ? performance.now() : Date.now());
        if (tStartMs === null) {
          tStartMs = nowMs;
          tLastFrameMs = nowMs;
        }
        const dtSec = Math.max(0, Math.min(1, (nowMs - tLastFrameMs) / 1000));
        tLastFrameMs = nowMs;
        // Fast-forward: accumulate the EXTRA time on top of natural
        // advance. ff=5 means 1 real second produces 5 t-second of
        // simulated advance — 1 from natural, 4 added to ffExtra.
        const ff = window.__fastForward ? 5 : 1;
        if (ff > 1) tFFExtraSec += dtSec * (ff - 1);
        tRef.current = ((nowMs - tStartMs) / 1000 + tFFExtraSec) * T_RATE_PER_SEC;
        const t = tRef.current;
        const state = stateRef.current;
        const inCsar = state === 'csar' || state === 'alert';

        // ── Viewport culling — compute bounds once per frame ─────
        // Per-frame work for tracks outside the visible viewport gets
        // skipped at the DOM-mutation step (setLngLat / setOffset / icon
        // rotate) which is the actual perf cost. The per-track math
        // (getPos trig, transit overrides) still runs because it advances
        // the underlying state — the savings come from not touching DOM
        // for ~80% of tracks at typical zoom levels.
        // Padded ~10% beyond the visible rect so markers don't pop in/out
        // when they cross the screen edge mid-orbit.
        let _viewN, _viewS, _viewE, _viewW;
        try {
          const b = map.getBounds();
          const padLat = (b.getNorth() - b.getSouth()) * 0.10;
          const padLng = (b.getEast() - b.getWest()) * 0.10;
          _viewN = b.getNorth() + padLat;
          _viewS = b.getSouth() - padLat;
          _viewE = b.getEast()  + padLng;
          _viewW = b.getWest()  - padLng;
        } catch(e) {
          // Fallback — cull nothing if bounds aren't available yet.
          _viewN = 90; _viewS = -90; _viewE = 180; _viewW = -180;
        }
        const _inView = (lat, lng) => {
          if (lat < _viewS || lat > _viewN) return false;
          // Lng wrap: if the bounds straddle anti-meridian, viewW > viewE.
          if (_viewW <= _viewE) return lng >= _viewW && lng <= _viewE;
          return lng >= _viewW || lng <= _viewE;
        };

        window.ALL_TRACKS.forEach(track => {
          const entry = markersRef.current[track.id];
          if (!entry) return;
          const { marker, el, iconEl, dotEl } = entry;

          // DUKE 31 vanishes when incident occurs. Hide both icon and
          // dot children since either could be visible at the time of
          // the incident depending on current zoom level.
          if (track.id === 'bf5' && inCsar) {
            if (entry.lastDisplay !== 'none') {
              if (iconEl) iconEl.style.display = 'none';
              if (dotEl)  dotEl.style.display  = 'none';
              entry.lastDisplay = 'none';
            }
            return;
          }
          // Restore display only if it was previously hidden — saves
          // a DOM write per visible track per frame in the steady state.
          if (entry.lastDisplay === 'none') {
            if (iconEl) iconEl.style.display = '';
            if (dotEl)  dotEl.style.display  = '';
            entry.lastDisplay = '';
          }

          // ── ISR override — GHOST 1X MQ-9s.
          //  Default: orbit at standing position, desaturated until on-tasked.
          //  On-tasked: transit toward IP at ~150 kn until the GHOST hits the
          //   orbit ring, then enter orbit at the entry angle (no jump to IP
          //   centre; the wide orbit picks up tangentially from where it
          //   crossed the ring).
          //  Terminal phase: orbit tightens (active overwatch read).
          //  Cleared: transit back to standing orbit centre, resume normal orbit.
          const isIsr = entry.isIsr;
          if (isIsr) {
            const onTaskedId = onTaskedGhostRef.current;
            const isOnTasked = (track.id === onTaskedId);
            const transit    = ghostTransitsRef.current[track.id];
            const ip         = window.IP_COORD;
            // MQ-9 wide overwatch orbit centred on IP — ~25 km radius @ FL280
            // (well above MANPADS, outside HQ-16 / HQ-22 lethal envelopes).
            // Tightens to ~15 km during terminal/pickup as a visual cue that
            // active overwatch has been called in close.
            const ORBIT_OLAT_WIDE  = 0.225;
            const ORBIT_OLNG_WIDE  = 0.245; // visual circle correction at ~24°N
            const ORBIT_OLAT_TIGHT = 0.135;
            const ORBIT_OLNG_TIGHT = 0.147;
            const ORBIT_PERIOD_WIDE  = 30;
            const ORBIT_PERIOD_TIGHT = 24;
            const mphase = missionRef.current.phase;
            const tight  = (mphase === 'terminal' || mphase === 'pickup');
            const orbitOlat   = tight ? ORBIT_OLAT_TIGHT   : ORBIT_OLAT_WIDE;
            const orbitOlng   = tight ? ORBIT_OLNG_TIGHT   : ORBIT_OLNG_WIDE;
            const orbitPeriod = tight ? ORBIT_PERIOD_TIGHT : ORBIT_PERIOD_WIDE;
            // MQ-9 transit speed: ~150 kn ≈ 278 km/h ≈ 4.63 km/min real-world.
            // At 10× demo tempo that's 46.3 km/min on the wall clock. Duration
            // is expressed in *t-units* (orbit clock units) rather than wall-
            // clock ms, so spacebar fast-forward — which advances tRef 5×
            // faster — speeds transit animations up in step with everything
            // else. Without this, transits stayed wall-clock and ignored FF.
            //
            // Conversion: tRef advances 0.003 t-units/frame × 60 fps =
            // 0.18 t-units/sec of real wall-clock time. So `realSec` of demo
            // time = realSec × 0.18 t-units.
            const MQ9_CRUISE_KMPMIN = 4.63;
            const DEMO_TEMPO        = 10;
            const T_PER_REAL_SEC    = 0.18;
            const transitT = (fromPos, toPos) => {
              const midLat = (fromPos.lat + toPos.lat) / 2;
              const lngKm = 111.32 * Math.cos(midLat * Math.PI / 180);
              const dLat = (toPos.lat - fromPos.lat) * 111.32;
              const dLng = (toPos.lng - fromPos.lng) * lngKm;
              const dKm = Math.hypot(dLat, dLng);
              const realMin    = dKm / MQ9_CRUISE_KMPMIN;
              const demoRealSec = (realMin / DEMO_TEMPO) * 60; // demo wall-clock seconds
              return demoRealSec * T_PER_REAL_SEC;             // → t-units
            };

            // Helper: where does the segment from `from` → ip cross the orbit
            // ring? Returns the entry point in lat/lng plus the fraction along
            // the segment at which the ring is reached (so we can scale our
            // linear interpolation to land exactly on the ring rather than at
            // the IP centre).
            const ringEntry = (from) => {
              // Convert to a normalised ellipse-distance metric: the ring is
              // {p : ((p.lat-ip.lat)/olat)^2 + ((p.lng-ip.lng)/olng)^2 = 1}.
              // Walk from `from` toward ip; find the largest u in [0,1] with
              // the parametric point still outside (or on) the ring, then
              // step from there. Closed-form: solve quadratic in u.
              const dLat = ip.lat - from.lat, dLng = ip.lng - from.lng;
              const fLat = (from.lat - ip.lat) / orbitOlat;
              const fLng = (from.lng - ip.lng) / orbitOlng;
              const sLat = dLat / orbitOlat;
              const sLng = dLng / orbitOlng;
              // Ring crossing: |f + u*s|^2 = 1 → quadratic au^2 + bu + c = 0
              const a = sLat*sLat + sLng*sLng;
              const b = 2 * (fLat*sLat + fLng*sLng);
              const c = fLat*fLat + fLng*fLng - 1;
              const disc = b*b - 4*a*c;
              if (a < 1e-9 || disc < 0) {
                // Already inside (or degenerate) — drop straight to IP centre
                // as a safe fallback. Shouldn't happen in practice given the
                // GHOSTs start far from the IP.
                return { lat:ip.lat, lng:ip.lng, u:1, theta:0 };
              }
              // Two roots; smaller positive is first ring crossing.
              const u1 = (-b - Math.sqrt(disc)) / (2*a);
              const u2 = (-b + Math.sqrt(disc)) / (2*a);
              const u  = (u1 > 0 && u1 < 1) ? u1 : (u2 > 0 && u2 < 1 ? u2 : 1);
              const lat = from.lat + u * dLat;
              const lng = from.lng + u * dLng;
              // Entry angle θ such that orbit position at t₀ matches the
              // entry point: lat - ip.lat = olat·sin(θ), lng - ip.lng = olng·cos(θ).
              const theta = Math.atan2((lat - ip.lat) / orbitOlat, (lng - ip.lng) / orbitOlng);
              return { lat, lng, u, theta };
            };

            let posIsr;
            if (transit && transit.phase === 'transit-in') {
              // Cache the ring entry on first frame so the linear fly-in
              // targets the ring crossing, not the IP centre, and we know the
              // exact angle to pick orbit up at when we arrive.
              if (transit.ringEntry == null) transit.ringEntry = ringEntry(transit.fromPos);
              const entry = transit.ringEntry;
              // Distance-based transit at MQ-9 cruise (150 kn) — closer
              // GHOSTs arrive sooner, distant ones take their full real-world
              // travel time at 10× demo tempo. Duration in t-units so FF
              // (spacebar 5×) speeds the animation up too.
              if (transit.durationT == null) transit.durationT = transitT(transit.fromPos, entry);
              const u = Math.min(1, (t - transit.startT) / transit.durationT);
              const lat = transit.fromPos.lat + u * (entry.lat - transit.fromPos.lat);
              const lng = transit.fromPos.lng + u * (entry.lng - transit.fromPos.lng);
              const heading = Math.atan2(entry.lng - transit.fromPos.lng, entry.lat - transit.fromPos.lat) * 180 / Math.PI;
              posIsr = { lat, lng, heading };
              if (u >= 1) {
                // Promote to orbit. Solve for `phase` so that getPos at the
                // current t equals the entry point — no positional jump.
                // θ_orbit(t) = 2π·t/period + phase  ⇒  phase = θ_entry − 2π·t/period.
                transit.phase = 'orbit-ip';
                transit.entryTheta = entry.theta;
                transit.entryT = t;
                transit.entryOlat = orbitOlat;
                transit.entryOlng = orbitOlng;
                transit.entryPeriod = orbitPeriod;
                transit.orbitPhase = entry.theta - 2 * Math.PI * t / orbitPeriod;
              }
            } else if (transit && transit.phase === 'orbit-ip') {
              // If the orbit shape changed (wide → tight on terminal entry),
              // re-derive `phase` so the orbit doesn't snap angle. We solve
              // for the angle that places the GHOST at its current position
              // under the new (olat,olng,period) and continue from there.
              if (transit.entryOlat !== orbitOlat || transit.entryPeriod !== orbitPeriod) {
                const prev = getPos({
                  base:ip, olat:transit.entryOlat, olng:transit.entryOlng,
                  period:transit.entryPeriod, phase:transit.orbitPhase,
                }, t);
                const newTheta = Math.atan2(
                  (prev.lat - ip.lat) / orbitOlat,
                  (prev.lng - ip.lng) / orbitOlng,
                );
                transit.entryOlat = orbitOlat;
                transit.entryOlng = orbitOlng;
                transit.entryPeriod = orbitPeriod;
                transit.orbitPhase = newTheta - 2 * Math.PI * t / orbitPeriod;
              }
              const synthTrack = {
                base:ip, olat:orbitOlat, olng:orbitOlng,
                period:orbitPeriod, phase:transit.orbitPhase,
                altitudeM:8500,
              };
              posIsr = getPos(synthTrack, t);
            } else if (transit && transit.phase === 'transit-out') {
              // Return to standing orbit centre — linear fly-to at MQ-9 cruise
              // (distance-scaled, like transit-in). Duration in t-units so FF
              // (spacebar 5×) speeds the animation up too.
              if (transit.durationT == null) transit.durationT = transitT(transit.fromPos, track.base);
              const u = Math.min(1, (t - transit.startT) / transit.durationT);
              const lat = transit.fromPos.lat + u * (track.base.lat - transit.fromPos.lat);
              const lng = transit.fromPos.lng + u * (track.base.lng - transit.fromPos.lng);
              const heading = Math.atan2(track.base.lng - transit.fromPos.lng, track.base.lat - transit.fromPos.lat) * 180 / Math.PI;
              posIsr = { lat, lng, heading };
              if (u >= 1) {
                // Done — drop the override so the default orbit machinery resumes.
                delete ghostTransitsRef.current[track.id];
              }
            } else {
              // Default standing orbit.
              posIsr = getPos(track, t);
            }
            // Apply altitude (FL280 when on-tasked at IP, FL250 otherwise).
            const altTrack = isOnTasked && transit && transit.phase === 'orbit-ip'
              ? { ...track, altitudeM:8500 }
              : track;
            // Cull off-screen markers — skip DOM updates if the GHOST is
            // outside the viewport. The transit/orbit math has already run
            // above so internal state stays correct; we just don't pay the
            // setLngLat / setOffset / transform cost.
            if (_inView(posIsr.lat, posIsr.lng)) {
              marker.setLngLat([posIsr.lng, posIsr.lat]);
              marker.setOffset(altitudeOffset(map, posIsr, altTrack));
              if (iconEl) {
                const northAngle = _northAngleAt(map, posIsr.lng, posIsr.lat);
                iconEl.style.transform = `rotate(${posIsr.heading + northAngle}deg)`;
              }
              // Visual: desaturated until on-tasked. We dim the wrapper opacity
              // for ambient state so on-tasked GHOST visibly pops vs the others.
              const ambient = !isOnTasked;
              const wantOpacity = ambient ? '0.55' : '1';
              if (entry.lastOpacity !== wantOpacity) {
                el.style.opacity = wantOpacity;
                entry.lastOpacity = wantOpacity;
              }
              if (el.style.display === 'none') el.style.display = '';
            } else {
              // Hide off-screen GHOST entirely so MapLibre doesn't paint it.
              if (el.style.display !== 'none') el.style.display = 'none';
            }
            return; // ISR tracks bypass the rest of the per-track block
          }

          // ── Phase 3 mission override — CSARTF formation flies the corridor.
          //  PEDRO 61 (a3) + JOLLY 62 (a4)       → full ingress/pickup/egress
          //  Escort SANDY (a1 or a2, non-OSC)    → full ingress/pickup/egress
          //  OSC SANDY (the one tied to oscId)   → joins on egress only
          //                                         (stays at IP via OSC orbit
          //                                          during ingress/pickup)
          const mission = missionRef.current;
          const coa = activeCoaRef.current;
          let pos;
          const isPed  = track.id === 'a3';
          const isJoly = track.id === 'a4';
          const isSandy = track.id === 'a1' || track.id === 'a2';
          // OSC = whoever currently holds it (DUKE 32 default, or an
          // appointed SANDY). They follow the recovery party out on egress
          // regardless of platform type.
          const isOsc        = !!mission.oscId && track.id === mission.oscId;
          const isEscortSandy = isSandy && !isOsc;
          // OSC joins the mission override starting with egress and stays in
          // it through 'complete' — otherwise the OSC's tight IP orbit (or
          // the SANDY-OSC handoff transit) would snap them back over the IP
          // after recovery.
          const oscInMission  = isOsc && (mission.phase === 'egress' || mission.phase === 'complete');
          const missionActive = mission.launched
            && coa && coa.ingressRoute && coa.egressRoute
            && (isPed || isJoly || isEscortSandy || oscInMission);
          if (missionActive) {
            // Spider routes: ingress route is flown 0→1 staging→IP; egress
            // route is its own polyline IP→staging (NOT a reverse). Pick the
            // active route by phase so positions and headings come from the
            // correct geometry. Divert/abort retraces happen during ingress
            // only, so they always operate against the ingress route.
            //
            // Routes are the COA's planned polylines verbatim. On a re-COA
            // mid-flight PEDRO snaps to the new ingress at the same progress
            // fraction — what the operator picked is what gets flown.
            const inflight = inflightRouteRef.current;
            const ingressCl = (inflight && inflight.ingressRoute) ? inflight.ingressRoute : coa.ingressRoute;
            const egressCl  = (inflight && inflight.egressRoute)  ? inflight.egressRoute  : coa.egressRoute;
            const cl = (mission.phase === 'egress') ? egressCl : ingressCl;
            // Interpolate progress smoothly between 1 Hz state ticks so the
            // HH-60 motion tracks in sync with the 60 fps rAF loop.
            const DEMO_TEMPO = 10;
            const ingressMin = coa.risk ? coa.risk.ingressTimeMin : 18;
            const perSec = 1 / (ingressMin * 60 / DEMO_TEMPO);
            const ffMult = window.__fastForward ? 5 : 1; // hold-space fast-forward
            const nowMs = (typeof performance !== 'undefined' ? performance.now() : Date.now());
            const elapsedSec = Math.max(0, (nowMs - (mission.updateTime || nowMs)) / 1000);
            let smoothProgress = mission.progress;
            if (mission.phase === 'ingress' || mission.phase === 'terminal') {
              smoothProgress = Math.min(1, mission.progress + perSec * ffMult * elapsedSec);
            } else if (mission.phase === 'egress') {
              smoothProgress = Math.max(0, mission.progress - perSec * ffMult * elapsedSec);
            }
            // Home-leg speed: ~4 km/min real (HH-60 cruise) * DEMO_TEMPO.
            // Fraction-per-second depends on the staging→base distance, so the
            // post-retrace hop flies at the same absolute speed as the ingress.
            const homeFracPerSec = (origin, target) => {
              const midLat = (origin.lat + target.lat) / 2;
              const lngKm = 111.32 * Math.cos(midLat * Math.PI / 180);
              const dLat = (target.lat - origin.lat) * 111.32;
              const dLng = (target.lng - origin.lng) * lngKm;
              const dKm = Math.max(1, Math.hypot(dLat, dLng));
              return (4 * DEMO_TEMPO / 60) / dKm; // u advances at cruise speed
            };

            // JOLLY 62 solo divert → reverse along corridor at ingress speed,
            // then short hop from staging to base (same cruise speed).
            // Capture JOLLY's formation-adjusted u (PEDRO's progress minus
            // the trailing offset) so the retrace starts exactly from where
            // JOLLY was visually, not where PEDRO is — otherwise the helo
            // snaps forward by the formation gap when divert kicks in.
            //
            // IMPORTANT: divert/abort retraces always operate against the
            // *ingress* polyline, never `cl`. `cl` flips to egress when PEDRO
            // acks pickup, but a diverted JOLLY that hasn't reached base yet
            // is still flying backward along the ingress route. If we read
            // from `cl`, JOLLY's u-coordinate gets re-interpreted against the
            // egress polyline (a totally different IP→staging path), snapping
            // the helo to a wrong position with a wrong heading.
            const retraceCl = ingressCl;
            if (isJoly && mission.divert === 'continue-solo') {
              if (!joyDivertFromRef.current || joyDivertFromRef.current._mode !== 'divert') {
                const baseU = (mission.phase === 'egress')
                  ? (1 - smoothProgress)
                  : smoothProgress;
                const jolyU = Math.max(0, baseU - 0.02);
                joyDivertFromRef.current = {
                  _mode:'divert', phase:'retrace',
                  startProgress: jolyU, startTime: nowMs,
                };
              }
              const s = joyDivertFromRef.current;
              if (s.phase === 'retrace') {
                const elapsed = (nowMs - s.startTime) / 1000;
                const u = s.startProgress - perSec * elapsed;
                if (u > 0) {
                  pos = window.positionAlongPolyline(retraceCl, u);
                  pos.heading = (pos.heading + 180) % 360; // nose-backward
                } else {
                  s.phase = 'home';
                  s.homeStart = nowMs;
                  s.homeOrigin = retraceCl[0];
                }
              }
              if (s.phase === 'home') {
                const origin = s.homeOrigin, target = track.base;
                const elapsed = (nowMs - s.homeStart) / 1000;
                const u = Math.min(1, homeFracPerSec(origin, target) * elapsed);
                pos = {
                  lat: origin.lat + u * (target.lat - origin.lat),
                  lng: origin.lng + u * (target.lng - origin.lng),
                  heading: Math.atan2(target.lng - origin.lng, target.lat - origin.lat) * 180 / Math.PI,
                };
                if (u >= 1) s.phase = 'arrived';
              }
              // Once home, treat the diverted helo as landed — sit at the
              // base coords with a fixed heading instead of resuming the
              // pre-mission orbit. A damaged HH-60W RTB doesn't go back
              // into a holding pattern.
              if (s.phase === 'arrived') {
                pos = { lat: track.base.lat, lng: track.base.lng, heading: 0 };
              }
            } else if (mission.divert === 'abort') {
              // Both HH-60s + escort SANDY reverse along the corridor at ingress
              // speed from the moment of abort, then short hop back to base.
              if (!joyDivertFromRef.current || joyDivertFromRef.current._mode !== 'abort') {
                joyDivertFromRef.current = {
                  _mode:'abort', startTime: nowMs,
                  startProgress: { aPed: smoothProgress, aJoly: smoothProgress, aEsc: smoothProgress },
                  phase: { aPed:'retrace', aJoly:'retrace', aEsc:'retrace' },
                  homeStart: { aPed:null, aJoly:null, aEsc:null },
                };
              }
              const s = joyDivertFromRef.current;
              const key = isPed ? 'aPed' : isJoly ? 'aJoly' : 'aEsc';
              if (s.phase[key] === 'retrace') {
                const elapsed = (nowMs - s.startTime) / 1000;
                const u = s.startProgress[key] - perSec * elapsed;
                if (u > 0) {
                  pos = window.positionAlongPolyline(retraceCl, u);
                  pos.heading = (pos.heading + 180) % 360;
                } else {
                  s.phase[key] = 'home';
                  s.homeStart[key] = nowMs;
                }
              }
              if (s.phase[key] === 'home') {
                const origin = retraceCl[0], target = track.base;
                const elapsed = (nowMs - s.homeStart[key]) / 1000;
                const u = Math.min(1, homeFracPerSec(origin, target) * elapsed);
                pos = {
                  lat: origin.lat + u * (target.lat - origin.lat),
                  lng: origin.lng + u * (target.lng - origin.lng),
                  heading: Math.atan2(target.lng - origin.lng, target.lat - origin.lat) * 180 / Math.PI,
                };
                if (u >= 1) s.phase[key] = 'arrived';
              }
              // Aborted HH-60s + escort SANDY: once home, treat as landed —
              // hold at base, no orbit pattern.
              if (s.phase[key] === 'arrived') {
                pos = { lat: track.base.lat, lng: track.base.lng, heading: 0 };
              }
            } else if (mission.phase === 'pickup') {
              if (isEscortSandy) {
                // Escort SARCAP orbits IP instead of hovering at the center.
                // Phase is tuned at first orbit entry so the ring is continuous.
                const orbit = escortOrbitRef.current || (()=> {
                  // Period 5 → ~330 kn at 10× demo (A-10C cruise). Matches the
                  // primary SANDY-OSC orbit definition above.
                  const olat = 0.055, olng = 0.088, period = 5;
                  // Default phase offset: opposite side from OSC so they split the ring.
                  const phaseOff = Math.PI;
                  return (escortOrbitRef.current = { olat, olng, period, phaseOff });
                })();
                const ang = 2 * Math.PI * t / orbit.period + orbit.phaseOff;
                pos = {
                  lat: window.IP_COORD.lat + Math.sin(ang) * orbit.olat,
                  lng: window.IP_COORD.lng + Math.cos(ang) * orbit.olng,
                  heading: 0,
                };
                const dtA = 0.05;
                const ang2 = 2 * Math.PI * (t + dtA) / orbit.period + orbit.phaseOff;
                const lat2 = window.IP_COORD.lat + Math.sin(ang2) * orbit.olat;
                const lng2 = window.IP_COORD.lng + Math.cos(ang2) * orbit.olng;
                pos.heading = Math.atan2(lng2 - pos.lng, lat2 - pos.lat) * 180 / Math.PI;
              } else {
                // PEDRO + JOLLY hold at IP center for pickup.
                pos = window.positionAlongPolyline(cl, 1);
                pos.heading = 0;
              }
            } else if (mission.phase === 'complete') {
              pos = getPos(track, t);
            } else {
              // Ingress / terminal / egress formation — escort leads, wingman
              // trails, OSC (on egress) is rear guard. Offsets are relative
              // to direction of travel along whichever route is active. The
              // route's polyline is already oriented in the direction the
              // helo is flying (ingress = staging→IP, egress = IP→staging),
              // so trailing = subtract offset and leading = add — no phase-
              // sign flip needed like the old single-corridor model required.
              let offsetInTravel = 0;
              if (isJoly)             offsetInTravel = -0.02; // wing, trails
              else if (isEscortSandy) offsetInTravel =  0.03; // SARCAP, leads
              // OSC trails the formation slightly on egress. Was -0.04
              // ("last out") but baseU starts at 0 when egress fires
              // (smoothProgress=1) — combined with -0.04 the OSC's u
              // clamped at 0 for the first ~7s of egress, pinning the
              // aircraft at the IP and producing the visual bug where
              // the OSC appeared to stay behind. -0.01 gives a barely-
              // visible trail without parking the OSC at the IP at egress
              // start; the formation starts moving together immediately.
              else if (isOsc && mission.phase === 'egress') offsetInTravel = -0.01;
              const baseU = (mission.phase === 'egress')
                ? (1 - smoothProgress)
                : smoothProgress;
              const u = Math.max(0, Math.min(1, baseU + offsetInTravel));
              const corridorPos = window.positionAlongPolyline(cl, u);

              // Escort SARCAP breaks off to orbit as soon as its corridor
              // position crosses the IP orbit ellipse — no more flying all the
              // way to the center and jumping to the orbit track.
              const orbitOlat = 0.055, orbitOlng = 0.088, orbitPeriod = 7;
              let inOrbitRing = false;
              if (isEscortSandy) {
                const nLat = (corridorPos.lat - window.IP_COORD.lat) / orbitOlat;
                const nLng = (corridorPos.lng - window.IP_COORD.lng) / orbitOlng;
                inOrbitRing = (nLat*nLat + nLng*nLng) < 1.0;
              }
              if (inOrbitRing) {
                // Cache orbit tuning on first entry so the angle is continuous.
                if (!escortOrbitRef.current) {
                  const nLat = (corridorPos.lat - window.IP_COORD.lat) / orbitOlat;
                  const nLng = (corridorPos.lng - window.IP_COORD.lng) / orbitOlng;
                  const theta = Math.atan2(nLat, nLng);
                  escortOrbitRef.current = {
                    olat: orbitOlat, olng: orbitOlng, period: orbitPeriod,
                    phaseOff: theta - 2 * Math.PI * t / orbitPeriod,
                  };
                }
                const o = escortOrbitRef.current;
                const ang = 2 * Math.PI * t / o.period + o.phaseOff;
                pos = {
                  lat: window.IP_COORD.lat + Math.sin(ang) * o.olat,
                  lng: window.IP_COORD.lng + Math.cos(ang) * o.olng,
                  heading: 0,
                };
                const dtA = 0.05;
                const ang2 = 2 * Math.PI * (t + dtA) / o.period + o.phaseOff;
                const lat2 = window.IP_COORD.lat + Math.sin(ang2) * o.olat;
                const lng2 = window.IP_COORD.lng + Math.cos(ang2) * o.olng;
                pos.heading = Math.atan2(lng2 - pos.lng, lat2 - pos.lat) * 180 / Math.PI;
              } else {
                // Outside orbit ring → follow corridor. Clear cache so next
                // entry picks a fresh tangent (e.g., approaching from the
                // opposite direction during egress).
                if (isEscortSandy) escortOrbitRef.current = null;
                pos = corridorPos;
                // Heading from positionAlongPolyline is already in the route's
                // direction of travel — egress route runs IP→staging, so no
                // 180° flip needed here (unlike the legacy single-corridor model).
              }
            }
          } else {
            // Per-track transition override — pre-transit (t<startT), in-transit, arrived
            const trans = transitionsRef.current[track.id];
            if (trans && t >= trans.startT) {
              if (t < trans.endT) {
                const u = (t - trans.startT) / (trans.endT - trans.startT);
                if (trans.waypoints && trans.waypoints.length >= 2) {
                  // Multi-leg threat-avoiding route — interpolate along the
                  // polyline so the track threads around SAM rings.
                  pos = window.positionAlongPolyline(trans.waypoints, u);
                } else {
                  pos = {
                    lat: trans.startPos.lat + u * (trans.targetPos.lat - trans.startPos.lat),
                    lng: trans.startPos.lng + u * (trans.targetPos.lng - trans.startPos.lng),
                    heading: trans.heading,
                  };
                }
              } else {
                pos = getPos(trans.onArriveTrack, t);
              }
            } else {
              // DUKE 32 tightens to IP orbit on incident (until relieved by a SANDY OSC).
              // Also applies while DUKE 32 is waiting for SANDY to arrive (trans pending but t<startT).
              // Period 3 calibrates the F-15E to ~510 kn at 10× demo tempo
              // (real-world F-15E cruise) on a ~6×9 km tight loop.
              const effectiveTrack = (track.id === 'bf6' && inCsar)
                ? { ...track, base: window.IP_COORD, olat:0.055, olng:0.088, period:3, pattern:null }
                : track;
              pos = getPos(effectiveTrack, t);
            }
          }
          // Cull off-screen markers — internal state (pos, transitions) is
          // already updated above. Only the DOM-mutation work is gated.
          if (!_inView(pos.lat, pos.lng)) {
            if (el.style.display !== 'none') el.style.display = 'none';
            return;
          }
          if (el.style.display === 'none') el.style.display = '';

          marker.setLngLat([pos.lng, pos.lat]);
          marker.setOffset(altitudeOffset(map, pos, track));

          // Rotate icon to heading (transform-origin defaults to 50% 50% = geo point).
          // Uses the cached iconEl ref (attached at marker creation) instead
          // of querySelector — saves ~2-5μs per visible track per frame.
          //
          // Globe rotation correction: heading is in compass degrees
          // (0 = true north). On globe, screen-up isn't always true
          // north — it varies with lat/lng position relative to
          // camera + camera bearing. _northAngleAt projects a tiny
          // step north and measures the screen angle, giving us the
          // per-marker correction. See helper at top of file.
          if (iconEl) {
            const northAngle = _northAngleAt(map, pos.lng, pos.lat);
            iconEl.style.transform = `rotate(${pos.heading + northAngle}deg)`;
          }

          // DIVERT chip: solo divert tags JOLLY only; abort tags both HH-60s.
          // Uses cached chipEl ref. Only writes display when value changes
          // (most frames the chip state is stable).
          if (entry.chipEl) {
            const isPed  = track.id === 'a3';
            const isJoly = track.id === 'a4';
            const show = (mission.divert === 'continue-solo' && isJoly)
                      || (mission.divert === 'abort' && (isPed || isJoly));
            const wantChipDisplay = show ? 'block' : 'none';
            if (entry.lastChipDisplay !== wantChipDisplay) {
              entry.chipEl.style.display = wantChipDisplay;
              entry.lastChipDisplay = wantChipDisplay;
            }
          }

          // Opacity: dim non-CSARTF in CSAR mode. We set it on the marker
          // wrapper directly so MapLibre's terrain-occlusion fade can't
          // sneak partial opacity onto our CSARTF tracks in tilt view —
          // fullOpacity() at creation time is meant to disable that fade,
          // but some MapLibre 3.x rendering paths still adjust it.
          //
          // CSAR mode also screens non-INDOPACOM tracks (other AOR ambient
          // ops shouldn't compete visually with the active CSAR scenario).
          //
          // Uses cached entry.isNonCsartf / entry.isOtherAor flags
          // (computed once at creation) — no per-frame Set.has() lookup.
          // Only writes opacity when the value changes, avoiding redundant
          // style recalc cascades on stable tracks.
          // Per-AOR visibility (Map Layers panel) — read live from the
          // mapLayersRef. If the track's home AOR has friendlyTracks
          // toggled off, hide the marker via display:none on EVERY
          // frame (cheap and bulletproof: no cache to fall stale, no
          // opacity inheritance edge cases, no pointerEvents to leak).
          // We use display rather than opacity here because some
          // MapLibre transform paths re-apply marker styles in ways
          // that fight pure opacity:0 — display:none is a hard hide.
          const _ml = mapLayersRef.current;
          const _perAor = (_ml && _ml.perAor) || null;
          const _trackAor = track.aor || null;
          const _hiddenByAor = !!(_perAor && _trackAor && _perAor[_trackAor]
            && _perAor[_trackAor].friendly === false);
          if (_hiddenByAor) {
            if (el.style.display !== 'none') {
              el.style.display = 'none';
              entry.lastDisplay = 'none';
              entry.lastOpacity = ''; // bust so we re-stamp on un-hide
            }
            return;  // skip remaining per-track work
          }
          // Coming back from AOR-hidden to visible — clear display:none
          // (the off-screen cull above has its own display:none/'' dance,
          // so we coordinate via entry.lastDisplay).
          // Per-mode emphasis: in CSAR mode dim non-CSARTF / other-AOR;
          // in mission-type MM modes (oca, dca, …) dim anything not in
          // the active MM's package set. Both can be true simultaneously
          // (rare) — we keep the lower opacity in that case.
          const _mmId = activeModeRef.current;
          const _inMmType = !!(window.isMmTypeMode && window.isMmTypeMode(_mmId));
          const _outOfMmPackage = _inMmType
            && !(entry.mmMemberOf && entry.mmMemberOf.has(_mmId));
          const dim = (state === 'csar' && (entry.isNonCsartf || entry.isOtherAor))
                   || _outOfMmPackage;
          let wantOpacity;
          if (_outOfMmPackage)              wantOpacity = '0.15';
          else if (dim && entry.isOtherAor) wantOpacity = '0.18';
          else if (dim)                     wantOpacity = '0.28';
          else                              wantOpacity = '1';
          if (entry.lastOpacity !== wantOpacity) {
            // Two paths: when dimming out of the MM package, go through
            // MapLibre's marker.setOpacity API so the value survives the
            // render loop (same fix as the hostile/base ambient dim).
            // For the CSAR / in-package cases we still write inline since
            // it's faster and works inside MapLibre's CSAR-honored ranges.
            if (_outOfMmPackage && entry.marker && typeof entry.marker.setOpacity === 'function') {
              try { entry.marker.setOpacity(wantOpacity, wantOpacity); } catch(err) {}
              // Clear any inline opacity from a prior CSAR-style dim so
              // it doesn't compound with the setOpacity value.
              el.style.opacity = '';
            } else {
              // Restore MapLibre opacity to 1 when coming back into the
              // package (or in any non-MM mode), then let the inline
              // value drive the visible result.
              if (entry.marker && typeof entry.marker.setOpacity === 'function') {
                try { entry.marker.setOpacity('1', '0'); } catch(err) {}
              }
              el.style.opacity = wantOpacity;
            }
            el.style.pointerEvents = _outOfMmPackage ? 'none' : '';
            entry.lastOpacity = wantOpacity;
            // The icon child's opacity is forced to 1 only when the wrapper's
            // opacity changes — the child stays at 1 except for the brief
            // window where MapLibre's terrain fade tried to dim it.
            if (iconEl) iconEl.style.opacity = '1';
          }
        });

        // Moving red force (BANDIT)
        window.RED_FORCES.filter(r => r.moving).forEach(rf => {
          const entry = markersRef.current[rf.id];
          if (!entry) return;
          const pos = getPos(rf, t);
          if (!_inView(pos.lat, pos.lng)) {
            if (entry.el.style.display !== 'none') entry.el.style.display = 'none';
            return;
          }
          if (entry.el.style.display === 'none') entry.el.style.display = '';
          entry.marker.setLngLat([pos.lng, pos.lat]);
          entry.marker.setOffset(altitudeOffset(map, pos, rf));
          const icon = entry.el.querySelector('div');
          if (icon) {
            const northAngle = _northAngleAt(map, pos.lng, pos.lat);
            icon.style.transform = `rotate(${pos.heading + northAngle}deg)`;
          }
        });

        // ── Acoustic detection rings ─────────────────────────────
        // Visible only during ingress + terminal so the rings disappear once
        // survivors are aboard and the helos start egress. We update ring
        // positions every frame while visible (cheap setData of 64-vertex
        // circles), and clear the GeoJSON exactly once on the show→hide
        // transition rather than re-clearing each frame.
        const mission2 = missionRef.current;
        const acoustic = acousticConfigRef.current;
        const showAcoustic = mission2.launched
          && (mission2.phase === 'ingress' || mission2.phase === 'terminal');
        if (showAcoustic) {
          acousticActiveRef.current = true;
          (acoustic.helos || []).forEach(heloId => {
            const heloEntry = markersRef.current[heloId];
            if (!heloEntry) return;
            const ll = heloEntry.marker.getLngLat();
            (acoustic.bands || []).forEach(band => {
              const src = map.getSource(`acoustic-${heloId}-${band.id}`);
              if (src) src.setData(circleGeoJSON(ll.lng, ll.lat, band.radiusKm));
            });
          });
        } else if (acousticActiveRef.current) {
          acousticActiveRef.current = false;
          (acoustic.helos || []).forEach(heloId => {
            (acoustic.bands || []).forEach(band => {
              const src = map.getSource(`acoustic-${heloId}-${band.id}`);
              if (src) src.setData({ type:'FeatureCollection', features:[] });
            });
          });
        }

          rafRef.current = requestAnimationFrame(animate);
      };
      animate();
    }; // end tryInit

    requestAnimationFrame(tryInit);

    return () => {
      if (rafRef.current) cancelAnimationFrame(rafRef.current);
      map.remove();
    };
  }, []);

  // ── Resize on layout change (toolbar/panel appear on CSAR entry,
  //    or the action queue's full-screen expand toggles back to dual rails). ──
  // When the queue expands we hide the map via display:none. When it
  // collapses we restore it, and MapLibre needs an explicit resize to
  // re-measure the container — without it the canvas keeps the old (now
  // hidden, zero-width) dimensions and renders black.
  React.useEffect(() => {
    const map = mapRef.current;
    if (!map) return;
    // Double-tap: once immediately, once after transitions settle
    try { map.resize(); } catch(e) {}
    const id = setTimeout(() => { try { map.resize(); } catch(e) {} }, 350);
    return () => clearTimeout(id);
  }, [appState, queueExpanded]);

  // ── Projection toggle (FLAT ↔ GLOBE) ──────────────────────────
  // Operator flips between Mercator (flat strip) and globe (3D
  // sphere) via the VIEW pill in the top bar. MapLibre 5.x animates
  // the projection morph internally over ~1s.
  //
  // Skip the first run — the initial projection is set in the map
  // constructor above (it reads the same prop), so this effect's
  // first fire would be a no-op call to setProjection that wastes a
  // tile reload. The ref tracks whether we've already set the
  // initial projection.
  const prevProjectionRef = React.useRef(projection);
  React.useEffect(() => {
    const map = mapRef.current;
    if (!map || !mapReady) return;
    if (prevProjectionRef.current === projection) return;
    prevProjectionRef.current = projection;
    const projType = (projection === 'globe') ? 'globe' : 'mercator';
    try {
      map.setProjection({ type: projType });
      // Zoom level that "looks right" differs by projection: at z=1.5
      // Mercator fills the canvas, but the globe at the same z is a
      // small ball in the middle of the screen. At z=2.5 the globe
      // takes up ~70% of viewport height which is the SA-friendly
      // sweet spot. Going the other way, z=2.5 in Mercator over-zooms
      // past the strip-fill point. Only bump zoom if the operator
      // hasn't already zoomed to a tactical level — at the COP-scale
      // zooms where the toggle is visible, we can safely override.
      const currentZoom = map.getZoom();
      if (projection === 'globe' && currentZoom < 2.5) {
        map.easeTo({ zoom: 2.5, duration: 600 });
      } else if (projection !== 'globe' && currentZoom < 2.0) {
        map.easeTo({ zoom: 1.5, duration: 600 });
      }
    } catch(e) {}
  }, [projection, mapReady]);

  // ── Basemap visibility ────────────────────────────────────────
  // Three basemap layers stacked as peers: satellite-over, carto-dark,
  // carto-light. CSAR mode forces satellite (its scenario was designed
  // around satellite imagery — darkness fade, threat-ring colour logic,
  // NVG composite all assume it). Outside CSAR, the operator's basemap
  // selection drives which layer is visible. Toggled via setLayoutProperty
  // (not opacity) so map.triggerRepaint isn't needed — visibility
  // changes are layout properties and MapLibre handles repaint
  // automatically.
  React.useEffect(() => {
    const map = mapRef.current;
    if (!map || !mapReady) return;
    const isCsar = appState === 'csar';
    const want = isCsar ? 'satellite-over'
               : basemap === 'satellite' ? 'satellite-over'
               : basemap === 'carto-light' ? 'carto-light'
               : 'carto-dark';   // default
    const setVis = (id, on) => {
      try { map.setLayoutProperty(id, 'visibility', on ? 'visible' : 'none'); } catch(e) {}
    };
    setVis('satellite-over', want === 'satellite-over');
    setVis('carto-dark',     want === 'carto-dark');
    setVis('carto-light',    want === 'carto-light');
  }, [basemap, appState, mapReady]);

  // ── Day/night fade ───────────────────────────────────────────
  // Two simultaneous moves driven by `dayProgress` (0 = noon, 1 = full
  // dark):
  //   1. Satellite saturation drops from 0 (full colour at noon) to
  //      -0.85 (mostly desaturated — keeps a faint hint of natural colour
  //      so the imagery doesn't feel completely lifeless at full dark).
  //      Curve is accelerated by 1.5× and clamped, so the satellite hits
  //      its -0.85 floor around dayProgress ≈ 0.67 (operational dark)
  //      rather than only at full dark. The remaining darkness ramps via
  //      the overlay below. Mostly-greyscale matters for NVG too —
  //      multiplying green over a near-greyscale image yields vivid
  //      green; over coloured imagery it gives muddy hue shifts.
  //   2. Darkness overlay opacity climbs 0 → 0.55 in both modes. Sits
  //      above the satellite (below tactical GeoJSON layers), so the
  //      basemap gets dark while threat rings / COA / ROZ stay punchy.
  //
  // GATED to CSAR master mode only. Outside CSAR (Home/COP, Mission
  // Admin, stub modes) the operator pans across multiple AORs, and a
  // global darkness wash makes the whole world dark at once — looks
  // wrong because real night only covers half the planet at any time.
  // For non-CSAR modes we hold dayProgress at 0 (noon) so the entire
  // basemap stays in full-colour daylight regardless of the scenario
  // clock. Spatial SA across AORs > simulated period of darkness.
  //
  // NVG green tint is handled separately — DOM-layer mix-blend-mode
  // overlay applied to the whole map area (see the JSX return below).
  React.useEffect(() => {
    const map = mapRef.current;
    if (!map) return;
    const isCsar = appState === 'csar';
    const dp = isCsar ? Math.max(0, Math.min(1, dayProgress)) : 0;
    const sat         = -Math.min(0.85, dp * 1.5 * 0.85);  // 0 → -0.85, capped by dp≈0.67
    const satOpacity  = 1;
    // COP imagery dim — when the operator picks the satellite basemap
    // outside CSAR mode, the raw Esri imagery is bright enough to wash
    // out track icons. The brightness slider drives `imageryDimming`
    // (0..0.85, higher = darker). We feed that into the *same*
    // `darkness-overlay` background layer the CSAR night ramp uses,
    // because that layer sits between the basemap rasters and every
    // tactical GeoJSON layer (threat rings, ACMs, ROZ, COA, hostile
    // tracks, AOR boundaries) — so dimming there only darkens the
    // basemap, not tactical content. HTML markers (icons, callouts,
    // dots) are above the canvas entirely, so they're untouched. Only
    // active for COP satellite; Carto Dark / Carto Light render as
    // authored, and CSAR mode keeps its dayProgress-driven dim.
    const dimSatCop = !isCsar && basemap === 'satellite'
      ? Math.max(0, Math.min(0.85, imageryDimming || 0))
      : 0;
    const darkOpacity = isCsar ? (dp * 0.55) : dimSatCop;
    try { map.setPaintProperty('satellite-over', 'raster-saturation', sat); } catch(e) {}
    try { map.setPaintProperty('satellite-over', 'raster-opacity',    satOpacity); } catch(e) {}
    try { map.setPaintProperty('darkness-overlay','background-opacity',darkOpacity); } catch(e) {}
    try { map.setPaintProperty('darkness-overlay','background-color', '#000000'); } catch(e) {}
    // Force an immediate repaint so the canvas reflects the new values
    // right away. Without this, MapLibre lazily defers re-rendering until
    // the next user interaction (pan/zoom/tilt) — which produced the
    // visible toggle latency on NVG flips before.
    try { map.triggerRepaint(); } catch(e) {}
  }, [dayProgress, nvgMode, mapReady, appState, basemap, imageryDimming]);

  // ── Map layer visibility toggles ───────────────────────────
  // Driven by the Map Layers panel (mapLayers prop). After the
  // Global+per-AOR refactor, the panel stores tactical layer toggles
  // under `mapLayers.perAor[aorId]` (friendly / hostile / threats /
  // acms). Global toggles (boundaries, washes, routes, grids,
  // commercial air, US restricted airspace) stay flat at the top
  // level. Each canvas layer or DOM marker gets gated against the
  // appropriate per-AOR or global flag depending on what it represents.
  React.useEffect(() => {
    const map = mapRef.current;
    if (!map || !mapReady || !mapLayers) return;
    let style; try { style = map.getStyle(); } catch(e) { return; }
    if (!style || !style.layers) return;
    const setVis = (id, on) => {
      try { map.setLayoutProperty(id, 'visibility', on ? 'visible' : 'none'); } catch(e) {}
    };
    // Helper — pull the per-AOR layer flag for a given AOR + layer key.
    // Returns true if the per-AOR record is missing (defensive default
    // matches the cold-load state: per-AOR layers default ON).
    const perAor = mapLayers.perAor || {};
    const aorOn = (aorId, key) => {
      if (!aorId) return true;
      const r = perAor[aorId];
      if (!r) return true;
      return r[key] !== false;
    };
    style.layers.forEach((layer) => {
      const id = layer.id;
      // SAM/threat rings — gated on the source threat's AOR. CSAR-mode
      // RED_FORCES rings (thr-*) also flow through, defaulting to true
      // when the lookup misses (CSAR doesn't have AOR-tagged threats).
      if (id.startsWith('thr-fill-') || id.startsWith('thr-line-')) {
        // thr-* layers are CSAR scenario threat rings — outside the
        // AOR threat picture. Show them whenever ANY AOR's threats
        // toggle is on, falling back to true if the panel state is
        // missing. (CSAR mode itself doesn't render the panel.)
        const anyThreatsOn = Object.keys(perAor).some(k => perAor[k] && perAor[k].threats);
        setVis(id, anyThreatsOn || Object.keys(perAor).length === 0);
      }
      else if (id.startsWith('mat-fill-') || id.startsWith('mat-line-')) {
        // mat-* layers carry their threat id in the suffix —
        // mat-fill-mat-cc-1 → look up mat-cc-1 → get aor.
        const threatId = id.replace(/^mat-(fill|line)-/, '');
        const aorId = (window.getThreatAor && window.getThreatAor(threatId)) || null;
        setVis(id, aorOn(aorId, 'threats'));
      }
      // ACMs — per-AOR (gated on `acms` for the ACM's home AOR).
      // MM behavior (PR 25.10 + 29):
      //   • In an MM-type mode, the ACM's home-AOR toggle is bypassed
      //     entirely (we always want the airspace picture visible while
      //     the operator's in a mission workspace).
      //   • In-package ACMs (tagged via supportingMmIds, or back-ref'd
      //     via the mission map) render at full opacity.
      //   • Out-of-package ACMs render dimmed (~18%) so they read as
      //     ambient context, not active workspace airspace.
      //   • Outside MM modes, ACMs follow the standard per-AOR toggle.
      else if (id.startsWith('acm-fill-') || id.startsWith('acm-line-')) {
        const acmId = id.replace(/^acm-(fill|line)-/, '');
        const aorId = (window.getAcmAor && window.getAcmAor(acmId)) || null;
        const mmId = activeModeRef.current;
        const inMmType = !!(window.isMmTypeMode && window.isMmTypeMode(mmId));
        const inMmPackage = inMmType && window.acmSupportsMm && window.acmSupportsMm(acmId, mmId);
        // Visibility: when in MM-mode, always show — unless the MM quick
        // toggle has been flipped off to declutter the view. Outside MM
        // modes, follow the per-AOR ACM toggle.
        const visible = inMmType ? mmShowAcms : aorOn(aorId, 'acms');
        setVis(id, visible);
        // Opacity dim: out-of-package ACMs in MM-mode drop to 0.18× of
        // their normal paint values. We apply that multiplier on the
        // fly via setPaintProperty so the dim is reversible when the
        // operator switches MMs.
        if (visible && map.getLayer(id)) {
          const types = window.ACM_TYPES || {};
          // Resolve the ACM type from window.ACO_ACMS so we know what
          // base alpha to multiply (avoids needing a separate cache).
          const acm = (window.ACO_ACMS || []).find(x => x.id === acmId);
          const t = acm && types[acm.type];
          if (t) {
            const mul = (inMmType && !inMmPackage) ? 0.18 : 1.0;
            try {
              if (id.startsWith('acm-fill-')) {
                map.setPaintProperty(id, 'fill-opacity', t.fillOp * mul);
              } else {
                map.setPaintProperty(id, 'line-opacity', 0.8 * mul);
              }
            } catch(e) {}
          }
        }
      }
      // CSAR-mode ACMs (ROZ, COA, bravo-search, spider, acoustic) —
      // not AOR-tagged. Show whenever any AOR has acms ON. Same
      // convention as thr-* above.
      else if (id === 'roz-fill' || id === 'roz-ring' ||
               id === 'coa-ingress-line' || id === 'coa-egress-line' ||
               id === 'coa-killbox-fill' || id === 'coa-killbox-line' ||
               id === 'bravo-search-fill' || id === 'bravo-search-ring' ||
               id === 'spider-lines' || id.startsWith('acoustic-')) {
        const anyAcmsOn = Object.keys(perAor).some(k => perAor[k] && perAor[k].acms);
        setVis(id, anyAcmsOn || Object.keys(perAor).length === 0);
      }
      // Trans-AOR mission routes — global.
      else if (id.startsWith('trax-flown-') || id.startsWith('trax-planned-')) {
        setVis(id, mapLayers.transAorRoutes);
      }
      // AOR boundary lines — global.
      else if (id === 'aor-boundaries-line') {
        setVis(id, mapLayers.aorBoundaries);
      }
      // Political airspace washes — global flat flags.
      else if (id === 'airspace-russian-fill') setVis(id, mapLayers.russianAirspace);
      else if (id === 'airspace-chinese-fill') setVis(id, mapLayers.chineseAirspace);
      else if (id === 'airspace-nato-fill')    setVis(id, mapLayers.natoAirspace);
      // satellite-over / carto-dark / darkness-overlay always visible
    });
    // DOM markers — friendly tracks, hostile tracks, SAM-ring centers.
    // Each gets gated against the AOR-aware flag map, falling back to
    // global behaviour for tracks without an AOR (CSAR-scenario tracks).
    const labelsOn        = mapLayers.callsignLabels !== false;
    const hostileStyle    = mapLayers.hostileTrackStyle || 'generic';
    const refMap = markersRef.current || {};
    Object.keys(refMap).forEach((trackId) => {
      const entry = refMap[trackId];
      if (!entry || !entry.el) return;
      // HOSTILE_TRACKS — ground/naval picture (non-SAM). Distinguished
      // by the `hostile-marker` className applied in makeHostileMarkerEl.
      // We hide via display:none rather than opacity:0 because MapLibre
      // re-applies its own opacity tracking (from setOpacity()) on every
      // render — pan/zoom would un-hide opacity:0 markers. display:none
      // is a hard hide that survives MapLibre's re-render.
      if (entry.el.classList && entry.el.classList.contains('hostile-marker')) {
        const aorId = (window.getHostileAor && window.getHostileAor(trackId)) || null;
        const visible = aorOn(aorId, 'hostile');
        entry.el.style.display = visible ? '' : 'none';
        entry.el.style.pointerEvents = visible ? '' : 'none';
        // If the operator changed the hostile-style picker, re-render
        // the iconDiv with the new glyph. Type/color/size are stashed
        // on the element at marker creation, so we don't need to
        // re-look-up the source HOSTILE_TRACK record.
        if (entry.el._hostileType && entry.el._lastStyle !== hostileStyle) {
          const iconDiv = entry.el.querySelector('.track-icon');
          if (iconDiv) {
            iconDiv.innerHTML = _hostileNtdsSvg(
              entry.el._hostileType,
              entry.el._hostileColor,
              hostileStyle,
              entry.el._hostileSize || 16,
            );
          }
          entry.el._lastStyle = hostileStyle;
        }
        return;
      }
      // BLUE_BASES — static installation markers. Distinguished by
      // the `base-marker` className applied in makeBaseMarkerEl.
      // Gate per-AOR via the `bases` flag. Same display:none idiom
      // as hostile/threat markers.
      if (entry.el.classList && entry.el.classList.contains('base-marker')) {
        const aorId = (window.getBaseAor && window.getBaseAor(trackId)) || null;
        const visible = aorOn(aorId, 'bases');
        entry.el.style.display = visible ? '' : 'none';
        entry.el.style.pointerEvents = visible ? '' : 'none';
        return;
      }
      // SAM-ring center markers — id begins with `mat-` (multi-AOR
      // threat) or `rf` (CSAR RED_FORCES). Gate mat-* by per-AOR
      // threats flag; fall back to "any AOR threats on" for rf-* /
      // CSAR scenario markers. Use display:none so MapLibre's render
      // pipeline can't restore the marker on pan/zoom.
      if (typeof trackId === 'string' && trackId.startsWith('mat-')) {
        const aorId = (window.getThreatAor && window.getThreatAor(trackId)) || null;
        const visible = aorOn(aorId, 'threats');
        entry.el.style.display = visible ? '' : 'none';
        entry.el.style.pointerEvents = visible ? '' : 'none';
        return;
      }
      // Friendly track markers carry `entry.track` (the source track
      // record) — set in the ALL_TRACKS render loop. SAM-ring + hostile
      // markers don't, so anything that doesn't have it falls through
      // here without a per-marker visibility decision (matches old
      // behaviour for CSAR threat-ring center markers, which we'd
      // already let through above when track.aor is null).
      if (!entry.track) return;
      // Per-AOR friendly visibility — track has an `aor` field via
      // ATO_TASKUNITS. Falls back to true when the lookup misses
      // (CSAR scenario track with no AOR field, or non-ATO track).
      // Use display:none rather than opacity:0 so MapLibre's render
      // pipeline can't restore the marker on pan/zoom (its setOpacity-
      // backed update path resets opacity each render). The rAF loop
      // also reads the per-AOR flag directly — coordinated via the
      // entry.lastDisplay cache so we don't fight here.
      const aorId = entry.track.aor || (window.getTrackAor && window.getTrackAor(trackId)) || null;
      const visible = aorOn(aorId, 'friendly');
      entry.el.style.display = visible ? '' : 'none';
      entry.el.style.pointerEvents = visible ? '' : 'none';
      entry.lastDisplay = visible ? '' : 'none';
      // Bust the rAF loop's `lastOpacity` cache so its next tick will
      // recompute and re-apply the right value (the rAF gates writes
      // on `entry.lastOpacity !== wantOpacity`).
      entry.lastOpacity = '';
      // Callsign labels — child element with class `track-label`. Only
      // matters when the track itself is visible, but we always honour
      // the labels-on flag so label visibility tracks the global toggle.
      const lbl = entry.el.querySelector('.track-label');
      if (lbl) lbl.style.display = (visible && labelsOn) ? '' : 'none';
    });
    try { map.triggerRepaint(); } catch(e) {}
  // activeMode in deps so the MM-package ACM-visibility override
  // re-evaluates on MM entry/exit (PR 25.10).
  }, [mapLayers, mapReady, activeMode, mmShowAcms]);

  // ── NVG colour override for tactical canvas layers ──────────
  // The NVG green multiply overlay sits above the canvas and tints
  // everything beneath it. Coloured tactical layers (red threat rings,
  // pink ROZ, orange COA, amber Bravo search, cyan acoustic bands)
  // multiply to muddy mixed hues that lose semantic meaning. Path 1 fix:
  // when NVG flips on, swap every tactical layer's fill/line colour to
  // white. White × green (multiply) = vivid green — every tactical
  // element reads as a uniform vivid green against the dimmer green
  // basemap, maximising contrast and legibility. STD → original colours
  // restored from the backup ref.
  //
  // Layer ID prefixes that count as "tactical" (matched by startsWith):
  //   thr-fill-, thr-line-       — threat rings (initial + late SAMs)
  //   acoustic-                  — acoustic detection bands
  //   bravo-search-              — Bravo survivor search circle
  //   spider-lines               — survivor-to-overhead correlation lines
  //   roz-                       — PR ROZ fill + ring
  //   coa-                       — COA ingress/egress + kill-box
  // Anything else (basemap rasters, darkness overlay) is left alone.
  const NVG_TACTICAL_PREFIXES = ['thr-fill-','thr-line-','acoustic-','bravo-search-','spider-lines','roz-','coa-','mgrs-grid-','gars-grid-'];
  const isTacticalLayerId = (id) => NVG_TACTICAL_PREFIXES.some(p => id.startsWith(p));
  React.useEffect(() => {
    const map = mapRef.current;
    if (!map || !mapReady) return;
    // NVG is CSAR-only — the green-tint overlay and marker grayscale
    // filter both gate on `nvgMode && isCsar`. The tactical-layer
    // color override needs to follow the same gate, otherwise leaving
    // CSAR with the toggle on leaves threat rings / ROZ / COA stamped
    // white on a non-NVG'd basemap (no green to tint them back).
    const isCsar = appState === 'csar';
    const nvgActive = nvgMode && isCsar;
    // Perf gate: only do work when there's actually something to do.
    //   - In NVG mode: always run (capture new layers, re-stamp white).
    //   - In STD mode: only run on the actual NVG→STD transition. Other
    //     dep-array fires (late-threat activation, COA breach, ROZ
    //     publish) in STD mode would otherwise call getStyle() and walk
    //     every layer for nothing.
    const prev = prevNvgModeRef.current;
    prevNvgModeRef.current = nvgActive;
    if (!nvgActive && !prev) return;  // STD with no transition → bail

    let style;
    try { style = map.getStyle(); } catch(e) { return; }
    if (!style || !style.layers) return;

    if (nvgActive) {
      // Capture pre-NVG colours into the backup ref, then stamp white.
      // CRITICAL: only capture layers we haven't seen before. New tactical
      // layers can spawn while NVG is on (late threats, COAs, ROZ), and
      // the effect re-runs to white-stamp them. Without this guard, the
      // re-run would overwrite the backup with already-stamped white
      // values, losing the originals — STD restore would then put white
      // back on the layer instead of the real colour.
      const backup = nvgColorBackupRef.current || {};
      style.layers.forEach((layer) => {
        if (!isTacticalLayerId(layer.id)) return;
        const paint = layer.paint || {};
        if (!backup[layer.id]) {
          const entry = {};
          if ('fill-color' in paint) entry.fill = paint['fill-color'];
          if ('line-color' in paint) entry.line = paint['line-color'];
          if (entry.fill !== undefined || entry.line !== undefined) backup[layer.id] = entry;
        }
        // Always stamp white — handles both newly-captured layers AND
        // layers whose paint may have drifted (e.g., COA breach state
        // toggling the line colour from accent2 to gold).
        if ('fill-color' in paint) { try { map.setPaintProperty(layer.id, 'fill-color', '#ffffff'); } catch(e) {} }
        if ('line-color' in paint) { try { map.setPaintProperty(layer.id, 'line-color', '#ffffff'); } catch(e) {} }
      });
      nvgColorBackupRef.current = backup;
    } else {
      // Restore from backup.
      const backup = nvgColorBackupRef.current || {};
      Object.keys(backup).forEach((id) => {
        const entry = backup[id];
        if (entry.fill !== undefined) {
          try { map.setPaintProperty(id, 'fill-color', entry.fill); } catch(e) {}
        }
        if (entry.line !== undefined) {
          try { map.setPaintProperty(id, 'line-color', entry.line); } catch(e) {}
        }
      });
      nvgColorBackupRef.current = {};
    }
    try { map.triggerRepaint(); } catch(e) {}
    // Dependency rationale: re-run whenever nvgMode flips, whenever
    // appState flips (CSAR ↔ COP/Admin etc), AND whenever upstream
    // state spawns new tactical layers (late threats, COAs, ROZ
    // publish, breach state). Without these, a layer added while
    // NVG is on would render in its source colour until NVG is toggled.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [nvgMode, appState, mapReady, activeLateThreats, activeCoa, coaBreach, rozPublished, bravoLocated]);

  // ── CSAR mode: fly to IP + add overlays ─────────────────────
  React.useEffect(() => {
    const map = mapRef.current;
    if (!map || appState !== 'csar') return;

    // Where to land the IP visually:
    //   The visible map area is the slice between the master-mode strip
    //   on the left and the two right rails. Iteration history on this
    //   centering math:
    //     v1: padding {right: rail1+rail2}            → IP left of center
    //     v2: padding {left:44, right: rail1+rail2}   → still left
    //     v3: no padding                              → IP right of center
    //   That oscillation says the map canvas is full-window-width and
    //   the rails OVERLAY rather than flex-clip it (despite the layout
    //   tree suggesting otherwise — likely an absolute-positioned canvas
    //   inside a flex parent, which MapLibre sometimes mis-measures
    //   against the parent's full content box rather than the post-flex
    //   layout box).
    //
    //   Most defensive math: read the actual rendered canvas width via
    //   getContainer().clientWidth AFTER resize, then use map.flyTo's
    //   `offset` option (pixels) to push the camera target left of
    //   canvas-center by exactly half the right-rail-occluded area. The
    //   master-mode strip lives outside the map container at App level,
    //   so it doesn't enter the canvas's geometry — only rail offset
    //   needed.
    //
    //     visible_center = (W − rails) / 2
    //     canvas_center  = W / 2
    //     offset.dx      = visible_center − canvas_center = −rails / 2
    //
    //   So offset.dx = −(rail1 + rail2) / 2. Negative shifts target left
    //   of canvas-center, landing the IP in the visible-slice center.
    //
    // Fly to the IP every time the operator enters CSAR mode while a
    // session is active. The intent: when the MC switches to the CSAR
    // master mode they should land on the active scenario, regardless
    // of what they were doing in COP / Mission Admin / etc. Their last
    // CSAR camera position may not be useful — better to re-anchor on
    // the IP and let them pan from there.
    //
    // If they want to study the broader context of an ongoing CSAR
    // mission, they can manually pan/zoom from the IP. The bigger UX
    // sin is leaving them lost: e.g. they tilted, exited tilt, panned
    // off, switched to COP, came back — without the re-fly they'd be
    // stuck on a half-remembered camera state.
    //
    // (Earlier build had a once-per-session gate via firedIpFlyToRef
    // so re-entry preserved the prior camera. Reverted: the spec is
    // now fly-every-entry. The flag still gets set/cleared but no
    // longer drives behavior; left in place harmlessly in case we
    // need to flip back.)
    let cancelled = false;
    firedIpFlyToRef.current = true;
    requestAnimationFrame(() => {
      if (cancelled) return;
      try { map.resize(); } catch(e) {}
      try {
        // Center the IP in the *visible* map slice, not the full
        // canvas. Even though the layout tree implies the rails are
        // flex siblings (and would clip the canvas), MapLibre 5
        // measures its canvas against the wrapper's pre-clip width
        // in the globe projection — rails effectively overlay the
        // right edge from the camera's perspective. Without this
        // correction the IP lands canvas-center, which sits behind
        // the open rails.
        //
        //   visible_center.x = (W − rail1 − rail2) / 2
        //   canvas_center.x  = W / 2
        //   offset.dx        = visible − canvas = −(rail1+rail2)/2
        //
        // Negative dx pushes the camera target LEFT of canvas-center,
        // landing the IP in the visible-slice center. We read the
        // live rail-width props that App threads in (rail1Width,
        // rail2Width) so collapsed-sliver rails (28 px) get the same
        // correction at a smaller offset.
        const dx = -((rail1Width || 0) + (rail2Width || 0)) / 2;
        map.flyTo({
          center: [window.IP_COORD.lng, window.IP_COORD.lat],
          zoom: 8.5,
          duration: 2500,
          essential: true,
          offset: [dx, 0],
        });
      } catch(e) {}
    });

    if (!ipMarkerRef.current) {
      // ALPHA — confirmed position
      const alpha = makeIPMarkerEl(window.IP_ALPHA);
      const alphaMarker = fullOpacity(new maplibregl.Marker({ element:alpha.el, anchor:'center' })
        .setLngLat([window.IP_ALPHA.lng, window.IP_ALPHA.lat]).addTo(map));

      // BRAVO — starts unconfirmed with fuzzy search circle; re-drawn once located
      const bravo = makeIPMarkerEl(window.IP_BRAVO);
      const bravoMarker = fullOpacity(new maplibregl.Marker({ element:bravo.el, anchor:'center' })
        .setLngLat([window.IP_BRAVO.lng, window.IP_BRAVO.lat]).addTo(map));

      ipMarkerRef.current = { alpha, alphaMarker, bravo, bravoMarker };

      // When ALPHA/BRAVO are close in screen space, flip ALPHA's tooltip to the
      // opposite horizontal side so its top-right corner anchors 5px from the icon.
      const updateIPLabels = () => {
        const aPx = map.project([window.IP_ALPHA.lng, window.IP_ALPHA.lat]);
        const bPx = map.project([window.IP_BRAVO.lng, window.IP_BRAVO.lat]);
        const dx = bPx.x - aPx.x;
        const dy = bPx.y - aPx.y;
        const overlap = Math.abs(dx) < 150 && Math.abs(dy) < 70;
        if (overlap && dx >= 0) {
          // BRAVO to the right → ALPHA tooltip goes left, top-right corner anchored
          alpha.labelDiv.style.left = '-19px';
          alpha.labelDiv.style.transform = 'translateX(-100%)';
        } else {
          alpha.labelDiv.style.left = '19px';
          alpha.labelDiv.style.transform = 'none';
        }
      };
      map.on('move', updateIPLabels);
      map.on('zoom', updateIPLabels);
      updateIPLabels();
    }

    // Add BRAVO search + spider overlays after fly (give style time). The
    // PR ROZ ring is gated separately by `rozPublished` — see the effect
    // below — so the operator's JTBD-15 transmit is what actually puts
    // the ring on the COP.
    const addOverlays = () => {
      try {
        if (!map.getSource('bravo-search-src')) {
          map.addSource('bravo-search-src', { type:'geojson', data: circleGeoJSON(window.IP_BRAVO.lng, window.IP_BRAVO.lat, 0.4) });
          map.addLayer({ id:'bravo-search-fill', type:'fill', source:'bravo-search-src', paint:{'fill-color':C.advisory,'fill-opacity':0.07} });
          map.addLayer({ id:'bravo-search-ring', type:'line', source:'bravo-search-src', paint:{'line-color':C.advisory,'line-width':1.5,'line-dasharray':[4,4],'line-opacity':0.6} });
        }
        if (!map.getSource('spider-src')) {
          map.addSource('spider-src', { type:'geojson', data: spiderGeoJSON(window.IP_COORD.lng, window.IP_COORD.lat, 18.52) });
          map.addLayer({ id:'spider-lines', type:'line', source:'spider-src', paint:{'line-color':C.isopPink,'line-width':0.9,'line-dasharray':[4,5],'line-opacity':0.5} });
        }
      } catch(e) {}
    };
    setTimeout(addOverlays, 900);
    return () => { cancelled = true; };
    // Intentionally NOT depending on rail1Width/rail2Width — we only
    // want the IP fly-to when ENTERING CSAR mode, not every time the
    // operator collapses or expands a rail mid-mission. The rAF below
    // already reads the latest rail widths from props at fire time
    // (the effect just ran on a fresh render, so the closure is fresh).
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [appState, mapReady]);

  // ── Session boundary: clear the once-per-CSAR-entry flag on reset ──
  // The CSAR-mode effect above flies the camera to the IP exactly once
  // per session (the first time appState flips to 'csar'). We need to
  // arm that flag again when a brand-new session begins — but we should
  // NOT arm it on every mode switch, otherwise the operator returning
  // to CSAR from COP would get yanked back to the IP.
  //
  // The session boundary is csarSessionActive going from true → false
  // (which only happens on reset). When it does, clear the fired flag
  // so the next J3.1 → CSAR transition flies to the IP again.
  //
  // Previous build also flew back to the strategic COP view (zoom 1.5,
  // prime meridian) every time the operator left CSAR. Removed: leaving
  // CSAR should respect wherever the operator's camera is, not snap to
  // a global view they may not want. Mode switching is now camera-neutral
  // except for the one-time IP fly on first CSAR entry.
  const prevCsarSessionRef = React.useRef(csarSessionActive);
  React.useEffect(() => {
    const wasActive = prevCsarSessionRef.current;
    prevCsarSessionRef.current = csarSessionActive;
    // Only act on the active → inactive transition (i.e. reset).
    if (wasActive && !csarSessionActive) {
      firedIpFlyToRef.current = false;
    }
  }, [csarSessionActive]);

  // ── ACO ACMs (gated by mapLayers.acms) ──────────────────────
  // Paint every published Airspace Coordination Measure from
  // window.ACO_ACMS. Single source of truth: same records that
  // generate the ACO USMTF body in getDocPreview() drive the visual
  // shapes here. So opening an ACO doc shows exactly what's on the
  // COP for that AOR — full parity with the ATO_TASKUNITS pattern.
  //
  // Layer ordering: this runs BEFORE the PR ROZ effect so the
  // mission-critical CSAR ROZ paints on top of the ambient ACM
  // wash. Each ACM gets its own source + fill/line layer pair so
  // we can hot-swap geometry later (dynamic ROZ adds, status
  // changes) by only touching the affected source.
  //
  // CSAR-mode dim: when the operator is running an active CSAR
  // scenario, all ambient ACMs fade to ~25% opacity (matches the
  // non-CSARTF track dim pattern) so the rescue scenario stands
  // out. The CSAR PR ROZ paints on its own layer in the next effect
  // and is NOT subject to this dim — it stays full-opacity. Same
  // visual principle as `el.style.opacity = '0.18'` for non-CSARTF
  // tracks: CSAR view = focused on CSAR things, ambient airspace
  // measures fade back.
  React.useEffect(() => {
    const map = mapRef.current;
    if (!map || !mapReady) return;
    // After the per-AOR refactor, ACM creation is gated on whether ANY
    // AOR has its acms layer toggled on. Per-AOR visibility is then
    // applied in the visibility effect above against each ACM's home
    // AOR — the gate here just decides whether sources/layers exist
    // at all (cheap to keep them and toggle visibility is much faster
    // than tearing down + recreating). When every AOR is OFF for
    // ACMs we tear them all down.
    const perAor = (mapLayers && mapLayers.perAor) || {};
    const wantAnyAor = Object.keys(perAor).some(k => perAor[k] && perAor[k].acms);
    // MM-package override (PR 25.10) — even with every AOR's ACM
    // toggle off, we still want package-bound ACMs (TARs, future
    // package ACMs) created and visible when the operator is in a
    // mission-type MM. So `want` is true if EITHER any AOR's acms
    // are on OR an MM-type mode is active.
    const wantForMm = !!(window.isMmTypeMode && window.isMmTypeMode(activeModeRef.current));
    const want = wantAnyAor || wantForMm;
    const acms = window.ACO_ACMS || [];
    const types = window.ACM_TYPES || {};
    const inCsar = appState === 'csar';
    const dimMul = inCsar ? 0.25 : 1.0;   // 25% in CSAR mode, full otherwise

    const removeAll = () => {
      acms.forEach(a => {
        try {
          if (map.getLayer(`acm-line-${a.id}`)) map.removeLayer(`acm-line-${a.id}`);
          if (map.getLayer(`acm-fill-${a.id}`)) map.removeLayer(`acm-fill-${a.id}`);
          // Remove the separate line source (added for polygon ACMs to
          // get LineString-tessellation alignment with grid lines on
          // globe). Safe no-op if the source wasn't created.
          if (map.getSource(`acm-line-src-${a.id}`)) map.removeSource(`acm-line-src-${a.id}`);
          if (map.getSource(`acm-src-${a.id}`)) map.removeSource(`acm-src-${a.id}`);
        } catch (e) {}
      });
    };

    if (!want) { removeAll(); return; }

    // If the layers are already added (effect re-firing because
    // appState changed), don't tear down + recreate — just adjust
    // the opacity in place. Cheaper and avoids the visible flash.
    //
    // MM-aware: when in a mission-type MM, out-of-package ACMs dim to
    // 0.18× of their normal alpha so the active MM's ACMs read as the
    // figure and everything else fades to context. Without this, the
    // creation effect would clobber the dim that the visibility effect
    // applied on the same commit.
    const _mmId = activeModeRef.current;
    const _inMmType = !!(window.isMmTypeMode && window.isMmTypeMode(_mmId));
    const firstAcm = acms[0];
    if (firstAcm && map.getSource(`acm-src-${firstAcm.id}`)) {
      acms.forEach(a => {
        const t = types[a.type]; if (!t) return;
        const inPkg = _inMmType && window.acmSupportsMm && window.acmSupportsMm(a.id, _mmId);
        const mmMul = (_inMmType && !inPkg) ? 0.18 : 1.0;
        try {
          if (map.getLayer(`acm-fill-${a.id}`)) {
            map.setPaintProperty(`acm-fill-${a.id}`, 'fill-opacity', t.fillOp * dimMul * mmMul);
          }
          if (map.getLayer(`acm-line-${a.id}`)) {
            map.setPaintProperty(`acm-line-${a.id}`, 'line-opacity', 0.8 * dimMul * mmMul);
          }
        } catch (e) {}
      });
      return removeAll;
    }

    // Per-AOR visibility on creation — the visibility effect above
    // would normally apply this, but it runs BEFORE this creation
    // effect on the same commit (effects fire in source order). So
    // if the operator goes from "all AORs OFF" → "INDOPACOM on",
    // visibility runs first (no layers exist yet, noop) then this
    // creates ALL ACMs with default visible. We resolve by stamping
    // the right initial visibility onto each layer at creation time.
    const acmVisibleForAor = (aorId) => {
      if (!aorId) return true;
      const r = perAor[aorId];
      if (!r) return true;
      return r.acms !== false;
    };
    // MM-package override — when in a mission-type MM (oca, dca, …),
    // any ACM bound to a mission in that package shows at creation
    // time regardless of the per-AOR ACM toggle.
    const _activeMmCreate = activeModeRef.current;
    const _inMmCreate = !!(window.isMmTypeMode && window.isMmTypeMode(_activeMmCreate));
    // In MM mode every ACM is visible (out-of-package will dim via the
    // runtime visibility branch). Outside MM, defer to the per-AOR
    // toggle handled by acmVisibleForAor above.
    const acmVisibleForMmPackage = (acmId) => _inMmCreate;
    acms.forEach(a => {
      const t = types[a.type];
      if (!t) return;
      if (map.getSource(`acm-src-${a.id}`)) return; // already added
      let geo;
      try {
        if (a.shape === 'circle') {
          geo = circleGeoJSON(a.center.lng, a.center.lat, a.radiusKm);
        } else if (a.shape === 'polygon') {
          geo = polygonGeoJSON(a.vertices);
        } else if (a.shape === 'corridor') {
          // ACM widths are in NM; corridorGeoJSON expects km. 1 NM ≈ 1.852 km.
          geo = corridorGeoJSON(a.centerline, a.width * 1.852);
        } else if (a.shape === 'racetrack') {
          // Tanker anchor (TAR) — oval polygon. Center + radius + heading
          // + legNm come from the ACM record (synthesized from the
          // mission's orbit ellipse in data.js).
          geo = racetrackGeoJSON(
            a.center.lng, a.center.lat,
            a.radiusNm || 10,
            a.headingDeg || 0,
            a.legNm,
          );
        } else if (a.shape === 'line') {
          // Coordination lines (FSCL, BCL, CCL, DCL) — open polyline,
          // no fill. Geometry is a LineString built from `centerline`.
          geo = {
            type:'Feature',
            geometry:{ type:'LineString', coordinates: a.centerline },
            properties:{},
          };
        } else {
          return;
        }
        const acmVisible = acmVisibleForAor(a.aor) || acmVisibleForMmPackage(a.id);
        // MM-aware initial opacity — out-of-package ACMs come up at the
        // dim value so there's no flicker between create + visibility
        // effect on cold-load.
        const _inPkgInit = _inMmCreate && window.acmSupportsMm && window.acmSupportsMm(a.id, _activeMmCreate);
        const _mmMulInit = (_inMmCreate && !_inPkgInit) ? 0.18 : 1.0;
        map.addSource(`acm-src-${a.id}`, { type:'geojson', data: geo });
        // Fill layer — skip for 'line' ACMs (open polylines have no fill).
        if (a.shape !== 'line') {
          map.addLayer({
            id: `acm-fill-${a.id}`, type: 'fill', source: `acm-src-${a.id}`,
            layout: { visibility: acmVisible ? 'visible' : 'none' },
            paint: {
              'fill-color': t.color,
              'fill-opacity': t.fillOp * dimMul * _mmMulInit,
              'fill-opacity-transition': { duration: 400 },
            },
          });
        }
        // Outline source. For Polygon-shape ACMs (killboxes, CAPs) we
        // build a SEPARATE LineString source from the ring vertices
        // and read the line layer from that, instead of having the
        // line layer trace the polygon's ring directly.
        //
        // Why: MapLibre 5.x globe tessellates polygon-source line
        // outlines and LineString-source lines on different code
        // paths with different subdivision densities. A killbox
        // outline read from a Polygon source can render with a
        // visible offset relative to the MGRS grid lines (which are
        // independent LineStrings) at the same UTM coordinates.
        // Building the outline as its own LineString puts it on the
        // SAME tessellation path as the grid, so they render
        // bit-identical and align perfectly. Fill stays as Polygon
        // (correct fill rendering depends on the polygon geometry).
        let lineSourceId = `acm-src-${a.id}`;
        // For polygonal shapes, build a separate LineString source from
        // the ring vertices (avoids globe tessellation mismatch with grid
        // lines). 'line' shapes already ARE LineStrings — reuse the source.
        if (a.shape === 'polygon' || a.shape === 'circle' || a.shape === 'corridor' || a.shape === 'racetrack') {
          // Get the ring coords (Polygon has nested arrays; LineString-style
          // open polyline doesn't apply here — we always have a ring).
          const ring = (geo.geometry.type === 'Polygon')
            ? geo.geometry.coordinates[0]
            : geo.geometry.coordinates;
          const lineGeo = {
            type: 'Feature',
            geometry: { type: 'LineString', coordinates: ring },
            properties: {},
          };
          lineSourceId = `acm-line-src-${a.id}`;
          map.addSource(lineSourceId, { type: 'geojson', data: lineGeo });
        }
        map.addLayer({
          id: `acm-line-${a.id}`, type: 'line', source: lineSourceId,
          layout: { visibility: acmVisible ? 'visible' : 'none' },
          paint: {
            'line-color': t.color,
            'line-width': t.width,
            'line-opacity': 0.8 * dimMul * _mmMulInit,
            'line-opacity-transition': { duration: 400 },
            ...(t.dash ? { 'line-dasharray': t.dash } : {}),
          },
        });
      } catch (e) {}
    });

    return removeAll;
  // activeMode in deps so the MM-package override fires on MM entry
  // (creation effect spins up package ACMs even when every AOR's
  // acms toggle is off).
  }, [mapReady, mapLayers && JSON.stringify(Object.keys(mapLayers.perAor || {}).map(k => (mapLayers.perAor[k] || {}).acms)), appState, activeMode]);

  // ── MGRS + GARS grid overlays (gated by mapLayers.{mgrsGrid,garsGrid}) ──
  // Both grids are zoom-adaptive: cell density increases as the operator
  // zooms in, regenerated whenever the viewport changes (debounced 150ms
  // after the last move/zoom). The math lives in v3/grids.js (window.GRIDS).
  //
  // Each grid uses two source/layer pairs — coarse + fine — so the
  // sub-cell lines can be styled with thinner strokes / lower opacity
  // than the parent cells. MGRS GZD lines stay distinctly bolder than
  // 100km/10km/1km cells; GARS 30-min cells stay bolder than 15-min /
  // 5-min subdivisions.
  //
  // Performance:
  //   • Recompute is debounced 150ms after the last viewport-change event
  //     so a continuous pan doesn't fire a recompute every frame
  //   • The generators self-cap feature counts (density guards in grids.js)
  //   • Lines are GeoJSON LineStrings, MapLibre handles repaint efficiently
  //   • setData on existing sources avoids the cost of remove+addLayer
  React.useEffect(() => {
    const map = mapRef.current;
    if (!map || !mapReady) return;
    const mgrsOn = !!(mapLayers && mapLayers.mgrsGrid);
    const garsOn = !!(mapLayers && mapLayers.garsGrid);

    // Layer/source ids
    const MGRS_SRC = 'mgrs-grid-src';
    const MGRS_LYR_BOLD = 'mgrs-grid-bold';      // GZD bands (thicker)
    const MGRS_LYR_FINE = 'mgrs-grid-fine';      // 100k/10k/1k cells
    const GARS_SRC = 'gars-grid-src';
    const GARS_LYR_BOLD = 'gars-grid-bold';      // 30min cells
    const GARS_LYR_FINE = 'gars-grid-fine';      // 15min/5min cells

    // Color palette — gray-blue MGRS, gray-warm GARS. Both faint enough
    // that overlaying on tracks/threats doesn't drown them out, but
    // present enough to read as a coordinate grid.
    const MGRS_COLOR = '#7a8a99';
    const GARS_COLOR = '#9a8c7a';

    const ensureSource = (id) => {
      if (!map.getSource(id)) {
        try {
          map.addSource(id, { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
        } catch (e) {}
      }
    };
    const ensureLayer = (id, sourceId, paint, filter) => {
      if (!map.getLayer(id)) {
        try {
          map.addLayer({ id, type: 'line', source: sourceId, paint, ...(filter ? { filter } : {}) });
        } catch (e) {}
      }
    };
    const removeLayerSafe = (id) => {
      try { if (map.getLayer(id)) map.removeLayer(id); } catch (e) {}
    };
    const removeSourceSafe = (id) => {
      try { if (map.getSource(id)) map.removeSource(id); } catch (e) {}
    };

    // Add layers on demand. We keep both source+layer pairs alive while
    // the toggle is on; only setData refreshes them.
    // Opacity tuning: both grids screened way back so the lines read
    // as faint reference scaffolding, not foreground content. Bold
    // (parent-cell) lines cap at 0.20; fine subdivisions sit at 0.15
    // so the cell hierarchy still reads but nothing competes with
    // tracks / threats / ACMs for visual attention.
    if (mgrsOn) {
      ensureSource(MGRS_SRC);
      ensureLayer(MGRS_LYR_FINE, MGRS_SRC,
        { 'line-color': MGRS_COLOR, 'line-width': 0.5, 'line-opacity': 0.15 },
        ['!=', ['get', 'kind'], 'gzd']);
      ensureLayer(MGRS_LYR_BOLD, MGRS_SRC,
        { 'line-color': MGRS_COLOR, 'line-width': 1.2, 'line-opacity': 0.20 },
        ['==', ['get', 'kind'], 'gzd']);
    } else {
      removeLayerSafe(MGRS_LYR_BOLD);
      removeLayerSafe(MGRS_LYR_FINE);
      removeSourceSafe(MGRS_SRC);
    }
    if (garsOn) {
      ensureSource(GARS_SRC);
      ensureLayer(GARS_LYR_FINE, GARS_SRC,
        { 'line-color': GARS_COLOR, 'line-width': 0.5, 'line-opacity': 0.15 },
        ['!=', ['get', 'kind'], '30min']);
      ensureLayer(GARS_LYR_BOLD, GARS_SRC,
        { 'line-color': GARS_COLOR, 'line-width': 1.0, 'line-opacity': 0.20 },
        ['==', ['get', 'kind'], '30min']);
    } else {
      removeLayerSafe(GARS_LYR_BOLD);
      removeLayerSafe(GARS_LYR_FINE);
      removeSourceSafe(GARS_SRC);
    }

    if (!mgrsOn && !garsOn) return;

    // Regenerate grid features for the current viewport.
    const regenerate = () => {
      const G = window.GRIDS;
      if (!G) return;
      let b;
      try { b = map.getBounds(); } catch (e) { return; }
      const viewN = b.getNorth();
      const viewS = b.getSouth();
      const viewE = b.getEast();
      const viewW = b.getWest();
      const zoom  = map.getZoom();
      if (mgrsOn) {
        try {
          const fc = G.mgrsGrid(viewN, viewS, viewE, viewW, zoom);
          const src = map.getSource(MGRS_SRC);
          if (src) src.setData(fc);
        } catch (e) {}
      }
      if (garsOn) {
        try {
          const fc = G.garsGrid(viewN, viewS, viewE, viewW, zoom);
          const src = map.getSource(GARS_SRC);
          if (src) src.setData(fc);
        } catch (e) {}
      }
    };

    // Debounce regen — 150ms after the last move/zoom event.
    let debounceId = null;
    const scheduleRegen = () => {
      if (debounceId) clearTimeout(debounceId);
      debounceId = setTimeout(regenerate, 150);
    };
    map.on('move', scheduleRegen);
    map.on('zoom', scheduleRegen);
    // Initial regen so the grid appears immediately after toggling on.
    regenerate();

    return () => {
      if (debounceId) clearTimeout(debounceId);
      try { map.off('move', scheduleRegen); } catch (e) {}
      try { map.off('zoom', scheduleRegen); } catch (e) {}
    };
  }, [mapReady, mapLayers && mapLayers.mgrsGrid, mapLayers && mapLayers.garsGrid]);

  // ── PR ROZ visibility (gated by rozPublished, collapses on pickupDone) ──
  // Adds the 5 NM ROZ ring around the IP when the operator transmits the
  // JTBD-15 ROZ Drop modal; removes it once survivors are aboard PEDRO 61
  // (the corresponding 15-Line Section A transmission in Phase 3).
  React.useEffect(() => {
    const map = mapRef.current;
    if (!map || !mapReady) return;
    const want = rozPublished && !pickupDone;
    const has = !!map.getSource('roz-src');
    if (want && !has) {
      try {
        map.addSource('roz-src', { type:'geojson', data: circleGeoJSON(window.IP_COORD.lng, window.IP_COORD.lat, 9.26) });
        // PR ROZ rendered in cyan (accent2 — "special friendly airspace
        // measure"). No doctrinal mandate for color, but cyan keeps the ring
        // readable against the red-family threat rings + the pink IP marker.
        map.addLayer({ id:'roz-fill', type:'fill', source:'roz-src', paint:{'fill-color':C.accent2,'fill-opacity':0.07,'fill-opacity-transition':{duration:600}} });
        map.addLayer({ id:'roz-ring', type:'line', source:'roz-src', paint:{'line-color':C.accent2,'line-width':2,'line-dasharray':[7,4],'line-opacity':0.9,'line-opacity-transition':{duration:600}} });
      } catch (e) {}
    } else if (!want && has) {
      try {
        // Fade then remove — if pickupDone toggled this, briefly fade for
        // a "collapse" feel, then drop layers.
        map.setPaintProperty('roz-fill','fill-opacity', 0);
        map.setPaintProperty('roz-ring','line-opacity', 0);
        setTimeout(() => {
          try {
            if (map.getLayer('roz-fill')) map.removeLayer('roz-fill');
            if (map.getLayer('roz-ring')) map.removeLayer('roz-ring');
            if (map.getSource('roz-src')) map.removeSource('roz-src');
          } catch (e) {}
        }, 700);
      } catch (e) {}
    }
  }, [rozPublished, pickupDone, mapReady]);

  // ── OSC handoff: when a SANDY is appointed, it flies to IP and takes over the tight orbit;
  // DUKE 32 breaks orbit and transits to KING 11 (tanker pattern).
  React.useEffect(() => {
    const map = mapRef.current;
    if (!map) return;
    const prev = prevOscRef.current;
    prevOscRef.current = oscId;
    if (!oscId || oscId === prev) return;
    if (oscId === 'bf6') return; // DUKE 32 self-appointed — no handoff needed

    const now = tRef.current;

    // Plan a smooth orbit-entry for a transit: find the closest point on the
    // target orbit ellipse to the starting position, and tune the orbit phase
    // so the orbit passes through that entry point at arrival. Result: track
    // flies to the circumference, then starts orbiting — no jump to center.
    const planOrbitEntry = (startPos, startT, speed, centerBase, olat, olng, period) => {
      const dLat = startPos.lat - centerBase.lat;
      const dLng = startPos.lng - centerBase.lng;
      const theta = Math.atan2(dLat / olat, dLng / olng);
      const entry = {
        lat: centerBase.lat + Math.sin(theta) * olat,
        lng: centerBase.lng + Math.cos(theta) * olng,
      };
      const dist = degDistance(startPos, entry);
      const arriveT = startT + dist / speed;
      const phase = theta - 2 * Math.PI * arriveT / period;
      return { entry, arriveT, phase };
    };

    // Threat list for solo-aircraft routing: every static SAM/threat with a
    // lethal radius the planner should treat as a no-go. Captured at handoff
    // time — we don't reactively re-route mid-transit if new threats pop.
    const knownThreats = [
      ...(window.RED_FORCES || []).filter(r => r && r.lat != null && r.radiusKm > 0),
      ...(activeLateThreats || []).filter(r => r && r.lat != null && r.radiusKm > 0),
    ];
    // Per-aircraft buffer around threat rings (km). Solo fighter/A-10 needs
    // less standoff than a fully-laden HH-60 corridor.
    const SOLO_BUFFER_KM = 3;

    // Build a threat-avoiding multi-leg path from start → end and the time
    // it'd take at the given degrees-per-second speed.
    const buildRoute = (startPos, endPos, speed) => {
      const router = window.routeAroundThreats;
      const path = router
        ? router(startPos, endPos, knownThreats, SOLO_BUFFER_KM)
        : [startPos, endPos];
      let pathLen = 0;
      for (let i = 1; i < path.length; i++) pathLen += degDistance(path[i-1], path[i]);
      return { path, durationSec: pathLen / Math.max(speed, 1e-6) };
    };

    // Selected SANDY → fly to orbit entry at IP, then tight orbit.
    const sandyTrack = window.ALL_TRACKS.find(t => t.id === oscId);
    let sandyArrivesT = now;
    if (sandyTrack) {
      const startPos = getPos(sandyTrack, now);
      const speed    = orbitSpeed(sandyTrack);
      // Period 5 calibrates the A-10's tight IP orbit to ~330 kn at 10x demo
      // tempo (real-world A-10C cruise). Was 7 — looked sluggish for an A-10.
      const olat=0.055, olng=0.088, period=5;
      const plan = planOrbitEntry(startPos, now, speed, window.IP_COORD, olat, olng, period);
      const { path, durationSec } = buildRoute(startPos, plan.entry, speed);
      sandyArrivesT = now + durationSec;
      // Phase needs re-tuning since arrival time shifted with the longer route,
      // otherwise the orbit jumps when the transition completes.
      const dLat = plan.entry.lat - window.IP_COORD.lat;
      const dLng = plan.entry.lng - window.IP_COORD.lng;
      const theta = Math.atan2(dLat / olat, dLng / olng);
      const phase = theta - 2 * Math.PI * sandyArrivesT / period;
      transitionsRef.current[oscId] = {
        startPos, targetPos: plan.entry,
        waypoints: path,
        startT: now, endT: sandyArrivesT,
        heading: bearingDeg(startPos, path[1] || plan.entry),
        onArriveTrack: { ...sandyTrack, base: window.IP_COORD, olat, olng, period, phase },
      };
    }

    // DUKE 32 → stay on station until SANDY arrives, THEN transit to KING 11
    // tanker pattern. Also enters that orbit at its circumference.
    const duke32 = window.ALL_TRACKS.find(t => t.id === 'bf6');
    const king11 = window.ALL_TRACKS.find(t => t.id === 'a5');
    if (duke32 && king11) {
      // Clear pattern — DUKE 32 normally flies a figure-8, but the tight IP
      // orbit + the KING 11 tanker arrival orbit are both ellipses.
      const duke32Orbit = { ...duke32, base: window.IP_COORD, olat:0.055, olng:0.088, period:3, pattern:null };
      const startPos = getPos(duke32Orbit, sandyArrivesT); // position when handoff happens
      // Use the CSAR-mode tight-orbit params for transit speed, not the
      // BLUE-mode original (huge olat/olng, period 15) which gave a slow
      // tangential velocity. F-15E heading to tanker shouldn't read slower
      // than his on-station orbit pace.
      const speed    = orbitSpeed(duke32Orbit);
      const olat = king11.olat * 0.55, olng = king11.olng * 0.55, period = king11.period;
      const plan = planOrbitEntry(startPos, sandyArrivesT, speed, king11.base, olat, olng, period);
      const { path, durationSec } = buildRoute(startPos, plan.entry, speed);
      const arriveT = sandyArrivesT + durationSec;
      const dLat = plan.entry.lat - king11.base.lat;
      const dLng = plan.entry.lng - king11.base.lng;
      const theta = Math.atan2(dLat / olat, dLng / olng);
      const phase = theta - 2 * Math.PI * arriveT / period;
      transitionsRef.current.bf6 = {
        startPos, targetPos: plan.entry,
        waypoints: path,
        startT: sandyArrivesT, endT: arriveT,
        heading: bearingDeg(startPos, path[1] || plan.entry),
        onArriveTrack: { ...duke32, base: king11.base, olat, olng, period, phase, pattern:null },
      };
    }
  // Snapshot threats at handoff time only — re-routing mid-transit on new
  // threats would be a separate (reactive) behaviour we haven't built yet.
  }, [oscId]);

  // Late SAM threats — add markers + rings as threats activate, remove on reset
  React.useEffect(() => {
    const map = mapRef.current;
    if (!map || !mapReady) return;
    const currentIds = new Set(activeLateThreats.map(t => t.id));
    const existing   = lateIdsRef.current;

    // Add new threats
    activeLateThreats.forEach(threat => {
      if (existing.has(threat.id)) return;
      try {
        map.addSource(`thr-${threat.id}`, { type:'geojson', data: circleGeoJSON(threat.lng, threat.lat, threat.radiusKm) });
        map.addLayer({ id:`thr-fill-${threat.id}`, type:'fill',   source:`thr-${threat.id}`, paint:{'fill-color':C.critical,'fill-opacity':0.05} });
        map.addLayer({ id:`thr-line-${threat.id}`, type:'line',   source:`thr-${threat.id}`, paint:{'line-color':C.critical,'line-width':1.5,'line-dasharray':[4,3],'line-opacity':0.7} });
      } catch(e) {}
      const el = makeRedMarkerEl(threat);
      const m  = fullOpacity(new maplibregl.Marker({ element:el, anchor:'center' }).setLngLat([threat.lng, threat.lat]).addTo(map));
      markersRef.current[threat.id] = { marker:m, el };
      existing.add(threat.id);
    });

    // Clean up threats that are no longer active (reset case)
    [...existing].forEach(id => {
      if (currentIds.has(id)) return;
      const entry = markersRef.current[id];
      if (entry) { entry.marker.remove(); delete markersRef.current[id]; }
      try {
        if (map.getLayer(`thr-line-${id}`)) map.removeLayer(`thr-line-${id}`);
        if (map.getLayer(`thr-fill-${id}`)) map.removeLayer(`thr-fill-${id}`);
        if (map.getSource(`thr-${id}`))     map.removeSource(`thr-${id}`);
      } catch(e) {}
      existing.delete(id);
    });
  }, [activeLateThreats, mapReady]);

  // Active COA spider routes (ingress + egress) + kill box — renders the
  // currently-selected COA. Corridor polygon was retired in favour of two
  // polylines so the routes look like flight paths, not airspace blocks.
  React.useEffect(() => {
    const map = mapRef.current;
    if (!map || !mapReady) return;
    // Remove any existing COA layers first
    try {
      if (map.getLayer('coa-ingress-line')) map.removeLayer('coa-ingress-line');
      if (map.getLayer('coa-egress-line'))  map.removeLayer('coa-egress-line');
      if (map.getSource('coa-ingress-src')) map.removeSource('coa-ingress-src');
      if (map.getSource('coa-egress-src'))  map.removeSource('coa-egress-src');
      if (map.getLayer('coa-killbox-fill')) map.removeLayer('coa-killbox-fill');
      if (map.getLayer('coa-killbox-line')) map.removeLayer('coa-killbox-line');
      if (map.getSource('coa-killbox-src')) map.removeSource('coa-killbox-src');
    } catch(e) {}

    if (!activeCoa) return;
    const ingress = activeCoa.ingressRoute;
    const egress  = activeCoa.egressRoute;
    if (!ingress || !egress) return;
    // Breached route uses gold rather than red so it stays visually distinct
    // from threat rings (red dashed). Tighter dash signals "stale but drawn".
    const lineColor = coaBreach ? '#e9c46a' : C.accent2;
    const breachDash = coaBreach ? [2, 3] : null;
    const lineString = (points) => ({
      type:'Feature',
      geometry:{ type:'LineString', coordinates: points.map(p => [p.lng, p.lat]) },
    });
    // Always create both ingress + egress sources/layers so the DEM-refinement
    // effect (below) can hot-swap their data once the AWS Terrarium tiles
    // load. Pre-refinement they hold the COA's synthetic geometry; once
    // refined they'll snake through real valleys.
    try {
      // Ingress — solid (or breached-dashed when active corridor breaches)
      map.addSource('coa-ingress-src', { type:'geojson', data: lineString(ingress) });
      const ingressPaint = { 'line-color': lineColor, 'line-width': 1.6, 'line-opacity': 0.5 };
      if (breachDash) ingressPaint['line-dasharray'] = breachDash;
      map.addLayer({ id:'coa-ingress-line', type:'line', source:'coa-ingress-src', paint:ingressPaint });
      // Egress — always dashed so the pair reads as ingress/egress, not
      // "two corridors".
      map.addSource('coa-egress-src', { type:'geojson', data: lineString(egress) });
      map.addLayer({
        id:'coa-egress-line', type:'line', source:'coa-egress-src',
        paint: {
          'line-color': lineColor,
          'line-width': 1.0,
          'line-dasharray': breachDash || [3, 4],
          'line-opacity': 0.5,
        },
      });
      if (activeCoa.killBox) {
        map.addSource('coa-killbox-src', { type:'geojson', data: activeCoa.killBox });
        map.addLayer({ id:'coa-killbox-fill', type:'fill', source:'coa-killbox-src', paint:{'fill-color':C.advisory,'fill-opacity':0.10} });
        map.addLayer({ id:'coa-killbox-line', type:'line', source:'coa-killbox-src', paint:{'line-color':C.advisory,'line-width':1.5,'line-opacity':0.85} });
      }
    } catch(e) {}
  }, [activeCoa, coaBreach, mapReady]);

  // Reset transitions when the simulation resets back to normal mode
  React.useEffect(() => {
    if (appState === 'normal') {
      transitionsRef.current = {};
      prevOscRef.current = null;
      inflightRouteRef.current = null;
      window.__activeFlightRoute = null;
    }
  }, [appState]);

  // ── Tilt view ───────────────────────────────────────────────
  // 2.5D camera toggle that pitches the view and zooms toward the IP so
  // terrain reads via setTerrain extrusion. Threats / COAs / tracks all
  // stay visible — this is just a camera change, not a separate persona.
  //
  // Tilt is CSAR-only — the toggle button is hidden outside CSAR mode.
  // The un-tilt cases:
  //   1. Operator hits the tilt button mid-CSAR — flyTo back to the
  //      CSAR overhead view (pan/zoom to a sensible default, pitch 0).
  //   2. Operator leaves CSAR (mode switch) while tilted — app.jsx
  //      auto-flips personaMode back to 'ops', which triggers this
  //      effect with appState !== 'csar'. Un-tilt by resetting pitch
  //      to 0 IN PLACE — no pan or zoom — so the operator's camera
  //      stays where they left it, just flat. Mode switches stay
  //      camera-neutral per the v=204 fix; tilt cleanup is the only
  //      camera change we make on CSAR exit.
  // Capture the operator's pre-tilt camera so exiting tilt restores
  // exactly what they were looking at. Earlier build flew back to a
  // hardcoded center/zoom which often pulled the camera wider than
  // their working zoom — losing context.
  const wasTiltedRef = React.useRef(false);
  const preTiltCameraRef = React.useRef(null);
  React.useEffect(() => {
    const map = mapRef.current;
    if (!map || !mapReady) return;
    const tilted = personaMode === 'ip';
    if (tilted) {
      // Snapshot the operator's current camera before flying into the
      // tilt-view preset. Saved center/zoom/bearing get restored on
      // exit (pitch zeroes regardless — we want flat overhead, not
      // a return-to-tilted state).
      try {
        const c = map.getCenter();
        preTiltCameraRef.current = {
          lng: c.lng, lat: c.lat,
          zoom: map.getZoom(),
          bearing: map.getBearing(),
        };
      } catch (e) {
        preTiltCameraRef.current = null;
      }
      wasTiltedRef.current = true;
      map.flyTo({
        center: [window.IP_COORD.lng, window.IP_COORD.lat],
        zoom: 10.4,
        pitch: 55,
        bearing: 0,
        duration: 2200,
        essential: true,
      });
    } else if (wasTiltedRef.current) {
      wasTiltedRef.current = false;
      if (appState === 'csar') {
        // Mid-CSAR un-tilt: restore the operator's pre-tilt camera
        // (center / zoom / bearing). pitch goes flat regardless.
        // Falls back to a sensible CSAR overhead if the snapshot
        // didn't capture (defensive — should always be set since
        // entering tilt always runs the snapshot above).
        const pre = preTiltCameraRef.current;
        preTiltCameraRef.current = null;
        if (pre) {
          map.flyTo({
            center: [pre.lng, pre.lat],
            zoom: pre.zoom,
            pitch: 0,
            bearing: pre.bearing || 0,
            duration: 2200,
            essential: true,
          });
        } else {
          map.flyTo({
            center: [121.20, 23.95],
            zoom: 7.0,
            pitch: 0,
            bearing: 0,
            duration: 2200,
            essential: true,
          });
        }
      } else {
        // Un-tilt because the operator left CSAR mode. Flatten the
        // camera in place — no pan/zoom — so they stay looking at
        // whatever they were looking at, just from above. The
        // pre-tilt snapshot is irrelevant here because the camera's
        // already flown around for tilt; easing pitch back to 0 from
        // the current position is the right interaction.
        preTiltCameraRef.current = null;
        map.easeTo({
          pitch: 0,
          bearing: 0,
          duration: 800,
          essential: true,
        });
      }
    }
  }, [personaMode, mapReady, appState]);

  // Clear inflight reroute when launch resets or mission completes — the next
  // launch should start fresh from staging on whichever COA is committed at GO.
  React.useEffect(() => {
    if (!launched || missionPhase === 'complete') {
      inflightRouteRef.current = null;
    }
  }, [launched, missionPhase]);

  // ── COP route source-of-truth ──────────────────────────────
  // The COP renders the COA's own ingress/egress polylines verbatim —
  // exactly what the operator saw in the COA selection modal. Previously
  // we ran a second DEM-backed Dijkstra here, which produced subtly
  // different geometry (different terrain model, different climb-cost
  // weight) — so the route on the COP didn't match what the operator
  // committed to. The COA generator is now the single source of truth
  // for route shape; this effect just publishes it.
  //
  // Mid-flight reroute (a new COA is committed while CSARTF is already
  // flying the corridor) is handled separately: we splice from PEDRO's
  // live position to IP using the synthetic planner, then animate from
  // there. The new COA's egress is taken as-is.
  React.useEffect(() => {
    const map = mapRef.current;
    if (!map || !mapReady) return;
    if (!activeCoa || !activeCoa.ingressRoute || !activeCoa.egressRoute) return;

    // Re-run on every activeCoa identity change. We previously cached on
    // `id::threatKey` to skip duplicate work, but that short-circuited the
    // mid-flight reroute case where the operator re-picks the same COA
    // letter (id stable) after a breach (threats unchanged) but the route
    // geometry is new because regenerateCoas anchored the ingress at PEDRO's
    // live position. Without this update, inflightRouteRef.current kept the
    // old route, so the helo flew the OLD geometry while the polyline showed
    // the NEW one — exactly the visual bug the user reported. The publish
    // work below is light (two setData calls + a ref assignment); no
    // optimisation needed.
    const ingressOut = activeCoa.ingressRoute;
    const egressOut  = activeCoa.egressRoute;
    const halfWidthKm = (activeCoa.corridor && activeCoa.corridor._halfWidthKm) || 6;

    inflightRouteRef.current = {
      ingressRoute: ingressOut,
      egressRoute:  egressOut,
    };
    // Publish the live route so app.jsx's breach detector tests new
    // threats against the actual flown geometry.
    window.__activeFlightRoute = {
      ingressRoute: ingressOut,
      egressRoute:  egressOut,
      halfWidthKm,
    };

    // Repaint the COP polyline sources.
    const lineString = (pts) => ({
      type:'Feature',
      geometry:{ type:'LineString', coordinates: pts.map(p => [p.lng, p.lat]) },
    });
    try {
      const ingressSrc = map.getSource('coa-ingress-src');
      if (ingressSrc && ingressOut) ingressSrc.setData(lineString(ingressOut));
      const egressSrc = map.getSource('coa-egress-src');
      if (egressSrc && egressOut) egressSrc.setData(lineString(egressOut));
    } catch (e) {}
  }, [activeCoa, mapReady, launched, missionPhase, missionProgress, activeLateThreats]);

  // ── BRAVO located: swap to confirmed marker + remove fuzzy search ring ──
  React.useEffect(() => {
    if (!bravoLocated) return;
    const map = mapRef.current;
    const refs = ipMarkerRef.current;
    if (!map || !refs || !refs.bravoMarker) return;
    // Rebuild BRAVO marker with confirmed=true. Carry through the current
    // J3.5 state so a BRAVO-located event after BRAVO's J3.5 was already
    // acknowledged keeps the pulse off (no state regression).
    refs.bravoMarker.remove();
    const bravoData = { ...window.IP_BRAVO, confirmed: true };
    const bravo = makeIPMarkerEl(bravoData, { pulse: !j35BravoDone });
    const bravoMarker = fullOpacity(new maplibregl.Marker({ element:bravo.el, anchor:'center' })
      .setLngLat([bravoData.lng, bravoData.lat]).addTo(map));
    ipMarkerRef.current = { ...refs, bravo, bravoMarker };
    // Drop the amber fuzzy search circle — position is fixed now
    try {
      if (map.getLayer('bravo-search-fill')) map.removeLayer('bravo-search-fill');
      if (map.getLayer('bravo-search-ring')) map.removeLayer('bravo-search-ring');
      if (map.getSource('bravo-search-src')) map.removeSource('bravo-search-src');
    } catch(e) {}
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [bravoLocated]);

  // ── J3.5 acknowledged: stop the survivor's beacon pulse ──
  // Beacon goes from "active, broadcasting" (pulsing halo) to "acknowledged
  // by C2" (static halo). The MGRS dot + inner ring stay so the position is
  // still legible; we just drop the attention-grabbing animation.
  React.useEffect(() => {
    const map = mapRef.current;
    const refs = ipMarkerRef.current;
    if (!map || !refs) return;
    if (j35AlphaDone && refs.alphaMarker) {
      refs.alphaMarker.remove();
      const alpha = makeIPMarkerEl(window.IP_ALPHA, { pulse: false });
      const alphaMarker = fullOpacity(new maplibregl.Marker({ element:alpha.el, anchor:'center' })
        .setLngLat([window.IP_ALPHA.lng, window.IP_ALPHA.lat]).addTo(map));
      ipMarkerRef.current = { ...ipMarkerRef.current, alpha, alphaMarker };
    }
    if (j35BravoDone && refs.bravoMarker) {
      const bravoData = { ...window.IP_BRAVO, confirmed: bravoLocated || window.IP_BRAVO.confirmed !== false };
      refs.bravoMarker.remove();
      const bravo = makeIPMarkerEl(bravoData, { pulse: false });
      const bravoMarker = fullOpacity(new maplibregl.Marker({ element:bravo.el, anchor:'center' })
        .setLngLat([bravoData.lng, bravoData.lat]).addTo(map));
      ipMarkerRef.current = { ...ipMarkerRef.current, bravo, bravoMarker };
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [j35AlphaDone, j35BravoDone]);

  // ── US Special Use Airspace (FAA SUA dataset) ────────────────
  // One-shot fetch from the FAA AIS OpenData feature service when
  // the operator toggles `restrictedAirspaceUS` on. Single CONUS-
  // wide query (~1k polygons, ~1MB GeoJSON) cached in a ref so a
  // toggle-off + toggle-on doesn't re-fetch. Renders three styled
  // fill+line layers grouped by SUA type:
  //   • Restricted (R)  — red dashed,  7% red fill
  //   • Prohibited (P)  — red solid,  12% red fill
  //   • MOA / Warning / Alert — amber dashed, 5–6% amber fill
  // Cleanup removes the source + all layers when toggled off.
  //
  // CORS: opendata.arcgis.com / services6.arcgis.com return
  // Access-Control-Allow-Origin:* on /query, so this is browser-
  // direct — no proxy needed (unlike OpenSky).
  const suaCacheRef = React.useRef(null);    // cached GeoJSON between toggles
  React.useEffect(() => {
    const map = mapRef.current;
    if (!map || !mapReady) return;
    const on = mapLayers && mapLayers.restrictedAirspaceUS;
    const SOURCE_ID = 'sua-us';
    const LAYER_IDS = ['sua-us-fill', 'sua-us-line', 'sua-us-fill-prohib', 'sua-us-line-prohib'];

    const removeLayers = () => {
      LAYER_IDS.forEach(id => { if (map.getLayer(id)) try { map.removeLayer(id); } catch(e) {} });
      if (map.getSource(SOURCE_ID)) try { map.removeSource(SOURCE_ID); } catch(e) {}
    };

    if (!on) {
      removeLayers();
      return;
    }

    let cancelled = false;
    const renderFromGeoJSON = (gj) => {
      if (cancelled) return;
      removeLayers();
      try {
        map.addSource(SOURCE_ID, { type:'geojson', data: gj });
        // Fill — type-aware via match expression on TYPE_CODE.
        map.addLayer({
          id: 'sua-us-fill', type:'fill', source: SOURCE_ID,
          filter: ['!=', ['get', 'TYPE_CODE'], 'P'],
          paint: {
            'fill-color': [
              'match', ['get', 'TYPE_CODE'],
              'R',   C.critical,
              'MOA', C.advisory,
              'W',   C.advisory,
              'A',   C.advisory,
              C.fg3,
            ],
            'fill-opacity': [
              'match', ['get', 'TYPE_CODE'],
              'R', 0.07, 'MOA', 0.05, 'W', 0.06, 'A', 0.04,
              0.04,
            ],
          },
        });
        // Outline — dashed, color-tinted by type.
        map.addLayer({
          id: 'sua-us-line', type:'line', source: SOURCE_ID,
          filter: ['!=', ['get', 'TYPE_CODE'], 'P'],
          paint: {
            'line-color': [
              'match', ['get', 'TYPE_CODE'],
              'R',   C.critical,
              'MOA', C.advisory,
              'W',   C.advisory,
              'A',   C.advisory,
              C.fg3,
            ],
            'line-opacity': 0.6,
            'line-width': 1.2,
            'line-dasharray': [4, 3],
          },
        });
        // Prohibited gets its own fill+line — solid, more opaque, distinct.
        map.addLayer({
          id: 'sua-us-fill-prohib', type:'fill', source: SOURCE_ID,
          filter: ['==', ['get', 'TYPE_CODE'], 'P'],
          paint: { 'fill-color': C.critical, 'fill-opacity': 0.12 },
        });
        map.addLayer({
          id: 'sua-us-line-prohib', type:'line', source: SOURCE_ID,
          filter: ['==', ['get', 'TYPE_CODE'], 'P'],
          paint: { 'line-color': C.critical, 'line-opacity': 0.85, 'line-width': 1.5 },
        });
      } catch(e) {}
    };

    if (suaCacheRef.current) {
      renderFromGeoJSON(suaCacheRef.current);
      return () => { cancelled = true; removeLayers(); };
    }

    // CONUS bbox: rough envelope minus AK/HI to keep payload small.
    // Includes Puerto Rico/USVI margin.
    const url = 'https://services6.arcgis.com/ssFJjBXIUyZDrSYZ/arcgis/rest/services/Special_Use_Airspace/FeatureServer/0/query'
      + '?where=1=1'
      + '&geometry=' + encodeURIComponent(JSON.stringify({
          xmin: -125, ymin: 24, xmax: -66, ymax: 50,
          spatialReference: { wkid: 4326 },
        }))
      + '&geometryType=esriGeometryEnvelope'
      + '&inSR=4326'
      + '&spatialRel=esriSpatialRelIntersects'
      + '&outFields=NAME,TYPE_CODE,LOWER_VAL,LOWER_UOM,LOWER_CODE,UPPER_VAL,UPPER_UOM,UPPER_CODE,TIMESOFUSE'
      + '&returnGeometry=true&outSR=4326&f=geojson';

    fetch(url).then(r => r.ok ? r.json() : null).then(gj => {
      if (cancelled || !gj || !Array.isArray(gj.features)) return;
      suaCacheRef.current = gj;
      renderFromGeoJSON(gj);
    }).catch(() => {});

    return () => { cancelled = true; removeLayers(); };
  }, [mapLayers, mapReady]);

  // ── Live ADS-B (OpenSky Network) ─────────────────────────────
  // Polls the OpenSky public API every 15 s when the operator has
  // the `commercialAir` map-layer toggle on. Uses the current map
  // viewport as a bounding-box query so we only fetch what's
  // visible — saves bandwidth + stays under OpenSky's anonymous
  // rate limits (which are stricter for global queries).
  //
  // Renders each aircraft as a DOM marker scaled by zoom:
  //   • zoom < 4   — entirely hidden (globe view, too cluttered)
  //   • zoom 4..8  — 3 px gray dot
  //   • zoom ≥ 8   — small airframe icon with heading rotation
  //
  // No animation between polls — markers jump to the new positions
  // every 15 s. Live data; freshness ≈ 5–15 s after each fetch.
  // Filters: drops on-ground, geo_altitude < 1500 m (~5000 ft), and
  // any aircraft missing lat/lng/heading.
  const adsbMarkersRef = React.useRef(new Map()); // icao24 -> { marker, el }
  React.useEffect(() => {
    const map = mapRef.current;
    if (!map || !mapReady) return;
    const on = mapLayers && mapLayers.commercialAir;
    if (!on) {
      // Toggle off — remove all ADSB markers and stop polling.
      adsbMarkersRef.current.forEach(entry => { try { entry.marker.remove(); } catch(e) {} });
      adsbMarkersRef.current.clear();
      return;
    }
    let cancelled = false;
    let pollTimer = null;

    // Inject CSS for the ADSB markers (once per session). Styles use
    // the data-zoom-tier attribute on the map wrapper to swap dot↔icon
    // appearances. data-zoom-tier set in updateAdsbZoomTier below.
    if (!document.getElementById('adsb-marker-css')) {
      const s = document.createElement('style');
      s.id = 'adsb-marker-css';
      s.textContent = `
        .adsb-marker { position:relative; width:0; height:0; }
        .adsb-marker .adsb-dot { position:absolute; left:-2px; top:-2px; width:4px; height:4px; border-radius:50%; background:#888a8f; pointer-events:auto; cursor:default; }
        .adsb-marker .adsb-icon { position:absolute; left:-7px; top:-7px; width:14px; height:14px; pointer-events:auto; cursor:default; color:#888a8f; display:none; }
        .adsb-marker .adsb-label { position:absolute; left:10px; top:-12px; white-space:nowrap; opacity:0; transition:opacity 0.15s; pointer-events:none; background:rgba(15,15,17,0.9); border:1px solid #38393a; border-radius:2px; padding:2px 5px; font-family:'Roboto Mono',monospace; font-size:8px; color:#c3c5c7; }
        .adsb-marker:hover .adsb-label { opacity:1; }
        /* Zoom tiers, set on map wrapper via data-adsb-tier:
           dot   — 3px gray dot (zoom < 8). Globe view falls in here
                   too — fetch-side decimation keeps dot count sane.
           icon  — airframe icon with heading (zoom >= 8) */
        [data-adsb-tier="dot"]  .adsb-icon   { display:none; }
        [data-adsb-tier="dot"]  .adsb-dot    { display:block; }
        [data-adsb-tier="icon"] .adsb-icon   { display:block; }
        [data-adsb-tier="icon"] .adsb-dot    { display:none; }
      `;
      document.head.appendChild(s);
    }

    const wrapper = containerRef.current && containerRef.current.parentElement;
    const updateAdsbZoomTier = () => {
      if (!wrapper) return;
      const z = map.getZoom();
      // ADS-B dot/icon tier flips at the SAME zoom threshold as our
      // friendly tracks (LOW_ZOOM_THRESHOLD = 6.5) so commercial-air
      // and tactical tracks transition together — operator doesn't
      // see one layer go to icons while the other is still on dots
      // at the same zoom. The old `low` tier (zoom < 4 hides
      // everything) is no longer needed — globe view now shows a
      // decimated dot subset, controlled in the fetch loop.
      const tier = z < 6.5 ? 'dot' : 'icon';
      if (wrapper.getAttribute('data-adsb-tier') !== tier) {
        wrapper.setAttribute('data-adsb-tier', tier);
      }
    };
    updateAdsbZoomTier();
    map.on('zoom', updateAdsbZoomTier);
    // Refresh-on-zoom: when the operator zooms enough to flip between
    // decimated globe view and full-detail theater view, the next
    // fetch result should reflect the new culling rules. Trigger an
    // immediate re-fetch on a 1.5s debounce after a zoom-end so we
    // don't burn credits scrubbing the wheel. Worst-case extra cost
    // is a handful of calls per session — negligible against the 4k
    // daily budget.
    let zoomDebounce = null;
    const onZoomEnd = () => {
      if (zoomDebounce) clearTimeout(zoomDebounce);
      zoomDebounce = setTimeout(() => {
        if (cancelled) return;
        // Re-cull the existing markers immediately by replaying the
        // viewport filter — cheaper than waiting 90 s for the next
        // poll. Then schedule a fresh fetch so the surviving subset
        // gets refreshed velocity/track data.
        if (!inFlight) tick();
      }, 1500);
    };
    map.on('zoomend', onZoomEnd);
    map.on('moveend', onZoomEnd);

    // Build a marker DOM for one aircraft. Both dot and icon children
    // are present; CSS shows whichever matches the current zoom tier.
    const makeAdsbEl = (callsign, typeDesc, altFt) => {
      const el = document.createElement('div');
      el.className = 'adsb-marker';
      const dot = document.createElement('div');
      dot.className = 'adsb-dot';
      el.appendChild(dot);
      const icon = document.createElement('div');
      icon.className = 'adsb-icon';
      // Generic airliner silhouette (top-down outline). Uses
      // currentColor so CSS can recolor without re-rendering.
      icon.innerHTML = `<svg width="14" height="14" viewBox="-7 -7 14 14" style="display:block;overflow:visible;"><polygon points="0,-6 1,-1 6,1 6,2 1,2 1,5 3,6 3,7 -3,7 -3,6 -1,5 -1,2 -6,2 -6,1 -1,-1" fill="currentColor"/></svg>`;
      el.appendChild(icon);
      const label = document.createElement('div');
      label.className = 'adsb-label';
      const cs = (callsign || '').trim();
      const altLbl = altFt ? `${Math.round(altFt / 100) * 100}ft` : '';
      label.textContent = `${cs || 'UNK'}  ${altLbl}  ${typeDesc || ''}`.trim();
      el.appendChild(label);
      return { el, icon };
    };

    // OpenSky Network /api/states/all — global state-vector feed.
    // Authenticated tier: HTTP Basic auth with the project credentials
    // gives 4000 credits/day, ~5 s freshness, 1 s position resolution.
    // A global query (no bbox) costs 4 credits per call → 90 s poll
    // ≈ 3,840 credits/day, leaving headroom for refresh-on-toggle and
    // a session left running overnight.
    //
    // CORS routing: OpenSky's `/api/states/all` only sets
    //   Access-Control-Allow-Origin: https://opensky-network.org
    // so a direct browser fetch from localhost (or anywhere else) is
    // blocked by the browser, even with valid Basic auth. The
    // `opensky-proxy.js` Node script in the prototype root sits on
    // localhost:8081, holds the credentials server-side, and adds the
    // CORS headers we need. Run it alongside `python -m http.server 8080`:
    //
    //   $ node opensky-proxy.js
    //
    // The auth header lives in the proxy script, not here — the
    // browser source no longer carries the credentials.
    //
    // For sharing the standalone HTML, swap the URL below for a
    // public proxy (Cloudflare Worker / Vercel Function with the same
    // shape). Until that's deployed, the commercial-air layer simply
    // shows nothing on standalone — graceful degradation, no errors.
    const OPENSKY_URL = 'http://localhost:8081/api/states/all';

    const fetchAdsb = async () => {
      // Single global fetch. ~10–15k aircraft, ~1.5 MB JSON. Modern
      // browsers handle this fine — the heavy lifting is in client-side
      // culling + DOM rendering downstream.
      const ctrl = new AbortController();
      const timeoutId = setTimeout(() => ctrl.abort(), 12000);
      let data = null;
      try {
        const res = await fetch(OPENSKY_URL, { signal: ctrl.signal });
        clearTimeout(timeoutId);
        if (!res.ok) {
          // 401 = bad creds (proxy misconfigured), 429 = over quota,
          // 502 = proxy can't reach OpenSky. Swallow and let cached
          // markers stay visible until next poll.
          return;
        }
        data = await res.json();
      } catch(e) {
        clearTimeout(timeoutId);
        return;
      }
      if (cancelled) return;
      // Cache-on-failure: a missing/empty response leaves the existing
      // markers in place. Far less jarring than "all aircraft vanished
      // for one tick."
      if (!data || !Array.isArray(data.states)) return;

      // OpenSky `states` array — each row is positional [16 fields]:
      //   0  icao24       string  (24-bit ICAO address, hex)
      //   1  callsign     string  (8 chars, trailing-space padded)
      //   2  origin_country string
      //   3  time_position int    (seconds since UTC epoch)
      //   4  last_contact int     (seconds since UTC epoch)
      //   5  longitude    float   (degrees)
      //   6  latitude     float   (degrees)
      //   7  baro_altitude float  (meters)
      //   8  on_ground    bool
      //   9  velocity     float   (m/s ground speed)
      //  10  true_track   float   (degrees, 0=north)
      //  11  vertical_rate float  (m/s)
      //  12  sensors      int[]
      //  13  geo_altitude float   (meters)
      //  14  squawk       string
      //  15  spi          bool
      //  16  position_source int
      //  17  category     int
      //
      // OpenSky doesn't return `desc` (full type) in /states/all —
      // tooltip falls back to the callsign + altitude only.

      // Dead-reckoning anchor: server timestamp for this snapshot,
      // mapped onto the local performance.now() clock so the rAF loop
      // reads consistent times.
      const nowSec    = (typeof data.time === 'number') ? data.time : (Date.now() / 1000);
      const nowPerfMs = performance.now();

      // Globe-view decimation: at zoom < 4 the operator can't tell
      // 1500 dots from 15000, but the DOM cost difference is real.
      // Render every 5th aircraft (deterministic by ICAO hash so the
      // surviving subset stays stable across polls — the same plane
      // doesn't pop in and out as the dataset shuffles).
      const zoom        = map.getZoom();
      const decimating  = zoom < 4;
      const decimateMod = 5;
      const _decHash = (s) => {
        let h = 5381;
        for (let i = 0; i < s.length; i++) h = ((h * 33) ^ s.charCodeAt(i)) >>> 0;
        return h;
      };

      // Viewport-bounds culling: at zoom ≥ 4 (theater scale and
      // tighter), drop aircraft outside the visible map. Avoids
      // pinning 15k DOM markers when only a few hundred can possibly
      // be on screen. Add a 10% pad so a brief pan doesn't expose
      // the cull boundary. Globe view (zoom < 4) skips the bounds
      // filter and relies on decimation alone.
      const bounds = map.getBounds();
      const padLat = (bounds.getNorth() - bounds.getSouth()) * 0.10;
      const padLng = (bounds.getEast()  - bounds.getWest())  * 0.10;
      const minLat = bounds.getSouth() - padLat;
      const maxLat = bounds.getNorth() + padLat;
      const minLng = bounds.getWest()  - padLng;
      const maxLng = bounds.getEast()  + padLng;
      const wrapLng = (maxLng - minLng) >= 360;  // bounds span the dateline
      const inViewport = (lat, lng) => {
        if (lat < minLat || lat > maxLat) return false;
        if (wrapLng) return true;
        // Standard case: lng range entirely inside [-180, 180].
        if (minLng <= lng && lng <= maxLng) return true;
        // Antimeridian wrap: if the visible bounds straddle ±180,
        // accept either side of the seam.
        if (minLng < -180 && lng >= minLng + 360) return true;
        if (maxLng >  180 && lng <= maxLng - 360) return true;
        return false;
      };

      const seen = new Set();
      data.states.forEach(a => {
        const icao  = a[0];
        if (!icao) return;
        if (decimating && (_decHash(icao) % decimateMod) !== 0) return;
        const cs     = a[1];
        const lng    = a[5];
        const lat    = a[6];
        const onGnd  = a[8];
        const gsMs   = a[9];     // m/s
        const trk    = a[10];    // degrees
        const altM   = (typeof a[13] === 'number') ? a[13] : a[7];  // geo > baro
        if (lat == null || lng == null) return;
        if (onGnd) return;
        if (altM == null || altM < 305) return;   // ~1000 ft floor (305 m)
        if (!decimating && !inViewport(lat, lng)) return;
        const altFt = altM * 3.28084;
        const gs    = (typeof gsMs === 'number') ? (gsMs * 1.94384) : 0;  // m/s → kt
        const track = (typeof trk  === 'number') ? trk : 0;
        seen.add(icao);
        // OpenSky doesn't expose `seen_pos` — use time_position when
        // available so the projection anchors on the actual report
        // time, not the response time.
        const posTimeSec = (typeof a[3] === 'number') ? a[3] : nowSec;
        const posAgeSec  = Math.max(0, nowSec - posTimeSec);
        const posPerfMs  = nowPerfMs - (posAgeSec * 1000);
        // Treat synthesized fields as if they came from the original
        // adsb.fi shape so the rendering path below stays unchanged.
        const a_flight = cs;
        const a_desc   = null;

        let entry = adsbMarkersRef.current.get(icao);
        if (!entry) {
          const built = makeAdsbEl(a_flight, a_desc, altFt);
          // fullOpacity → globe back-face occlusion. ADS-B is global
          // commercial-air, so you need this badly: without it, every
          // CONUS / Europe / Asia flight paints through the planet to
          // the opposite hemisphere on the globe projection.
          const m = fullOpacity(new maplibregl.Marker({ element: built.el, anchor:'center' })
            .setLngLat([lng, lat]).addTo(map));
          entry = {
            marker: m, el: built.el, icon: built.icon,
            // Origin of dead-reckoning — the *real* position that was
            // reported, with the timestamp it was reported at.
            originLat: lat, originLng: lng, originMs: posPerfMs,
            // Currently displayed position — what we last setLngLat'd.
            displayLat: lat, displayLng: lng,
            // Smooth-correction state. When a fresh poll lands a
            // position close to where we'd extrapolated, glide from
            // the displayed position to the new origin over ~250 ms.
            correctMs: 0, correctFromLat: lat, correctFromLng: lng,
            gs, track,
          };
          adsbMarkersRef.current.set(icao, entry);
        } else {
          // Decide: smooth-correct or snap?
          // Compute the projected position at this poll's posPerfMs,
          // using the previous entry's velocity. If the new origin is
          // within 500 m of where we'd predicted, glide. Otherwise
          // snap (the aircraft turned, slowed, or we missed updates
          // and the prediction is wildly off).
          const elapsedSec = (posPerfMs - entry.originMs) / 1000;
          const predicted = _projectLatLng(entry.originLat, entry.originLng, entry.gs, entry.track, elapsedSec);
          const errKm = _kmBetween(predicted.lat, predicted.lng, lat, lng);
          if (errKm < 0.5) {
            // Glide. Origin updates immediately so future projections
            // are anchored on the latest real position. Display stays
            // where it is until the rAF loop interpolates over 250 ms.
            entry.correctMs = nowPerfMs + 250;
            entry.correctFromLat = entry.displayLat;
            entry.correctFromLng = entry.displayLng;
          } else {
            // Snap. Set displayed = origin so the marker jumps to
            // the new position on the next rAF tick.
            entry.correctMs = 0;
            entry.displayLat = lat;
            entry.displayLng = lng;
          }
          entry.originLat = lat;
          entry.originLng = lng;
          entry.originMs  = posPerfMs;
          entry.gs    = gs;
          entry.track = track;
        }
        if (entry.icon) {
          // Globe-aware rotation: track is compass heading, _northAngleAt
          // gives the screen-up→true-north angle at the aircraft's
          // position. Sum is the screen-rotation that points the icon
          // along its compass heading. (See _northAngleAt at top of file.)
          const northAngle = _northAngleAt(map, lng, lat);
          entry.icon.style.transform = `rotate(${track + northAngle}deg)`;
        }
      });
      // Cull aircraft not in this response (left viewport, landed, etc).
      // Only runs when we got fresh data (the early-return on !data
      // above preserves stale markers when fetches fail).
      adsbMarkersRef.current.forEach((entry, icao) => {
        if (!seen.has(icao)) {
          try { entry.marker.remove(); } catch(e) {}
          adsbMarkersRef.current.delete(icao);
        }
      });
    };

    // Poll every 90 s. The credit math:
    //   global /states/all = 4 credits/call
    //   86,400 s/day ÷ 90 s = 960 calls/day × 4 = 3,840 credits/day
    //   authenticated daily quota = 4,000 → ~160 credits headroom for
    //   refresh-on-toggle and accidental session left running overnight.
    //
    // In-flight guard prevents an overlapping fetch when the response
    // is slow (12 s timeout above). Aircraft positions in the gap are
    // dead-reckoned forward by the rAF loop using the last reported
    // velocity — accurate to within a couple miles after 90 s for
    // typical airline cruise.
    let inFlight = false;
    const tick = async () => {
      if (inFlight) return;
      inFlight = true;
      try { await fetchAdsb(); }
      finally { inFlight = false; }
    };
    tick();
    pollTimer = setInterval(tick, 90000);

    // ── Dead-reckoning rAF loop ──────────────────────────────────
    // ADS-B positions update at uneven intervals per aircraft (some
    // every second, some 5–10 s gaps). Without interpolation between
    // polls the markers look jittery — step, freeze, jump. This loop
    // runs at the browser's frame rate and projects each marker
    // forward from its last *real* reported position using stored
    // ground speed + track. When a fresh poll arrives, fetchAdsb
    // updates the origin; if the new origin is close to the prediction
    // we glide to it over 250 ms instead of snapping.
    let rafId = 0;
    const animateAdsb = () => {
      const tMs = performance.now();
      adsbMarkersRef.current.forEach(entry => {
        const elapsedSec = (tMs - entry.originMs) / 1000;
        // Project forward along stored heading. _projectLatLng handles
        // gs=0 (parked / hovering helo) cleanly — returns the origin.
        const proj = _projectLatLng(entry.originLat, entry.originLng, entry.gs, entry.track, elapsedSec);
        let dispLat = proj.lat;
        let dispLng = proj.lng;
        // If a glide is in progress, blend from the previous displayed
        // position toward the projection over a 250 ms window.
        if (entry.correctMs > tMs) {
          const remainingMs = entry.correctMs - tMs;
          const blend = 1 - (remainingMs / 250);  // 0 → 1 over the window
          dispLat = entry.correctFromLat + (proj.lat - entry.correctFromLat) * blend;
          dispLng = entry.correctFromLng + (proj.lng - entry.correctFromLng) * blend;
        } else if (entry.correctMs !== 0) {
          // Glide finished — clear the flag so we stop interpolating.
          entry.correctMs = 0;
        }
        // Skip the DOM mutation if we're already at this position
        // (within ~1 m) — saves a few hundred setLngLat calls per
        // frame when the camera is at a tight zoom and aircraft
        // are at low ground speed.
        if (Math.abs(dispLat - entry.displayLat) > 1e-5 ||
            Math.abs(dispLng - entry.displayLng) > 1e-5) {
          entry.displayLat = dispLat;
          entry.displayLng = dispLng;
          try { entry.marker.setLngLat([dispLng, dispLat]); } catch(e) {}
        }
      });
      rafId = requestAnimationFrame(animateAdsb);
    };
    rafId = requestAnimationFrame(animateAdsb);

    return () => {
      cancelled = true;
      if (pollTimer) clearInterval(pollTimer);
      if (rafId) cancelAnimationFrame(rafId);
      if (zoomDebounce) clearTimeout(zoomDebounce);
      try { map.off('zoom',    updateAdsbZoomTier); } catch(e) {}
      try { map.off('zoomend', onZoomEnd); } catch(e) {}
      try { map.off('moveend', onZoomEnd); } catch(e) {}
      adsbMarkersRef.current.forEach(entry => { try { entry.marker.remove(); } catch(e) {} });
      adsbMarkersRef.current.clear();
    };
  }, [mapLayers, mapReady]);

  const isCsar = appState === 'csar';
  // The 44 px master-mode strip used to render here as a sibling to the
  // canvas. It's been lifted to App root so it persists across every
  // workspace (Home, CSAR, Mission Admin, stubs) — workspaces clear the
  // gutter via paddingLeft on their containers. From the map's POV the
  // canvas now fills its container all the way to the left edge.
  const toolbarW = 0;

  // NVG window-treatment overlay intensity.
  // Sibling <div> over the entire map area with mix-blend-mode multiply
  // and a solid-green background. Because the tint happens at the DOM
  // compositing layer it's agnostic to whatever's painted in the canvas
  // beneath — satellite, Carto Dark Matter, threat rings, COA centerlines,
  // ROZ polygons, mission tracks, AND the marker DOM elements all get
  // tinted together. That's the realistic NVG look: everything in the
  // operator's view shifts toward green, not just the basemap.
  //
  // 0.08 minimum at noon so toggling NVG in full daylight gives the
  // operator visible feedback that the toggle worked. Ramps to ~1.0 at
  // full dark for vivid green NVG immersion.
  // NVG overlay is also gated to CSAR mode — outside CSAR the operator
  // is on a multi-AOR global view and a green wash everywhere doesn't
  // make sense. Toggle UI is hidden in non-CSAR top bars too (see
  // hideMapChrome in app.jsx NormalTopBar).
  const _isCsar      = appState === 'csar';
  const _dpClamped   = _isCsar ? Math.max(0, Math.min(1, dayProgress)) : 0;
  const nvgIntensity = (nvgMode && _isCsar) ? (0.08 + (_dpClamped * 0.92)) : 0;

  return (
    <div
      // NVG mode is CSAR-only. Even if `nvgMode` toggle is on (operator
      // hasn't manually turned it off), we don't apply the data-nvg
      // attribute outside CSAR — the marker grayscale+brightness CSS
      // and the green-tint overlay would otherwise persist into COP /
      // Mission Admin / etc, leaving everything visually washed-out
      // when there's no green-multiply layer to complete the effect.
      // Returning to CSAR re-enables it (preserves the toggle state).
      data-nvg={(nvgMode && _isCsar) ? '1' : '0'}
      style={{
      position:'relative', flex:1, width:'100%', height:'100%',
      overflow:'hidden', minHeight:0,
    }}>
      <div ref={containerRef} style={{ position:'absolute', top:0, bottom:0, left:toolbarW, right:0 }} />
      {/* Imagery dim — applied via the in-canvas `darkness-overlay`
          MapLibre background layer (see effect that watches `basemap` +
          `imageryDimming` above). That layer sits between the basemap
          rasters and every tactical GeoJSON layer, so it darkens *only*
          the basemap — markers, threat rings, ROZs, hostile-track
          glyphs, etc. all stay at full punch. The previous DOM-overlay
          approach (above the entire canvas) also dimmed icons + tactical
          fills, which defeated the purpose. */}
      {/* NVG window treatment — two-layer composite over the map canvas.
          Both layers are pointer-events-none siblings sized to the same
          area, only present when nvgMode is on, with opacity ramping
          identically with dayProgress.

          Layer 1 (zIndex 5) — solid white with mix-blend-mode 'overlay'.
            Boosts contrast of the picture beneath: bright canvas pixels
            get pushed brighter, dark pixels get pushed darker. Approximates
            the over-exposed bright/dark relationship of real NVG before
            the green tint lands on top.

          Layer 2 (zIndex 6) — solid phosphor green with mix-blend-mode
            'multiply'. Tints the contrast-boosted picture: brights become
            vivid green, darks stay near-black. Sits above the white
            overlay so multiply applies to the already-contrast-boosted
            composite, producing a more vivid NVG look than multiply alone.

          zIndex range stays 5–6, above corner info (5 — same level but
          painted later in DOM order) and below airspace toolbar (10). */}
      {nvgMode && (
        <>
          <div style={{
            position:'absolute', top:0, bottom:0, left:toolbarW, right:0,
            pointerEvents:'none',
            background:'#ffffff',
            mixBlendMode:'overlay',
            opacity: nvgIntensity,
            transition:'opacity 0.4s ease',
            zIndex:5,
          }} />
          <div style={{
            position:'absolute', top:0, bottom:0, left:toolbarW, right:0,
            pointerEvents:'none',
            background:'#2EFF2E',  // bright phosphor green
            mixBlendMode:'multiply',
            opacity: nvgIntensity,
            transition:'opacity 0.4s ease',
            zIndex:6,
          }} />
        </>
      )}
      {/* Corner info — top-left, just inside the airspace toolbar (or at
          the edge in normal ops where the toolbar is hidden). When a
          mission-type MM is active the first line names the MM + its
          package mission count; otherwise it shows the standard
          NORMAL OPS / CSAR labels. */}
      {(() => {
        const _isMm = !!(window.isMmTypeMode && window.isMmTypeMode(activeMode));
        const meta = _isMm
          ? (window.MASTER_MODES || []).find(m => m.id === activeMode)
          : null;
        let line1;
        if (_isMm && meta) {
          const pkgIds = (window.MM_PACKAGE_MEMBERS && window.MM_PACKAGE_MEMBERS[activeMode]) || [];
          const count = pkgIds.length;
          // Match CSAR pattern: uppercase mode label + count + workspace flavor.
          line1 = `${meta.label.toUpperCase()} · ${count} MISSION${count === 1 ? '' : 'S'} IN PACKAGE`;
        } else {
          line1 = isCsar ? 'CSAR MASTER MODE · TACTICAL COP' : 'NORMAL OPS · MISSION COP';
        }
        return (
          <div style={{ position:'absolute', top:8, left: toolbarW + 10, zIndex:5, pointerEvents:'none', textAlign:'left', lineHeight:1.6 }}>
            <div style={{ fontFamily:'Roboto Mono', fontSize:8, color:'rgba(200,215,230,0.55)', letterSpacing:'0.07em' }}>
              {line1}
            </div>
            <div style={{ fontFamily:'Roboto Mono', fontSize:8, color:'rgba(160,180,200,0.35)' }}>
              {window.CASE.dtg}
            </div>
            {/* Mission-type MM quick toggles — pinned under the MM label
                so the operator can declutter the active workspace without
                opening the Map Layers panel. Only mounted when the
                workspace is a mission-type MM (the label above is the
                hint that map-based MMs share). */}
            {_isMm && (
              <div style={{ marginTop:6, display:'flex', gap:6, flexWrap:'wrap', pointerEvents:'auto' }}>
                <MmQuickToggle
                  label="ACMs"
                  on={mmShowAcms}
                  onChange={() => onMmShowAcmsChange(!mmShowAcms)}
                  title={mmShowAcms
                    ? 'Hide all Airspace Control Measures to declutter the active workspace. Tracks and threats stay visible.'
                    : 'Show Airspace Control Measures — restores the airspace picture (in-package full, out-of-package dimmed).'}
                />
              </div>
            )}
          </div>
        );
      })()}
      {/* Tilt-view toggle — flips the camera into 2.5D so terrain reads.
          Available in both normal ops and CSAR mode (no persona/phase gating). */}
      {/* Tilt-view toggle. When tilt is ON in NVG mode, the button's
          underlying fill is white so the NVG green multiply (and the
          white-overlay contrast boost above it) lands the displayed
          colour at vivid #00FF00 — multiply can only darken, so we
          start from white to end at green after the composite. STD
          mode keeps the original cyan-accent active fill. */}
      {/* Tilt is a CSAR-only tactical tool (read terrain near the IP).
          Hidden in COP/Home and other modes — at globe / multi-AOR
          zoom there's no terrain detail to extrude, and the previous
          behavior of tilting + flying to Taiwan in COP mode was a
          bug, not a feature. */}
      {appState === 'csar' && (() => {
        const tiltOn = personaMode === 'ip';
        const activeBg     = nvgMode ? '#ffffff' : C.accent;
        const activeBorder = nvgMode ? '#ffffff' : C.accent;
        const activeShadow = nvgMode ? '0 0 14px rgba(46,255,46,0.55)' : `0 0 14px ${C.accent}66`;
        return (
          <button
            onClick={onTogglePersona}
            title="TILT VIEW — Toggle the camera into the survivor-eye 3D tilted perspective. Useful for sanity-checking terrain masking and run-in geometry."
            style={{
              position:'absolute', top:38, left: toolbarW + 10, zIndex:5,
              background: tiltOn ? activeBg : C.s2,
              border: `1px solid ${tiltOn ? activeBorder : C.border2}`,
              color: tiltOn ? '#000' : C.fg2,
              fontFamily:'Roboto Mono', fontSize:9, fontWeight:500, letterSpacing:'0.10em',
              padding:'5px 10px', borderRadius:3, cursor:'pointer',
              boxShadow: tiltOn ? activeShadow : '0 2px 6px rgba(0,0,0,0.35)',
            }}>
            {tiltOn ? 'TILT VIEW · ON' : 'TILT VIEW'}
          </button>
        );
      })()}
      {/* Drawing tools — positioned at top-right corner of map */}
      {(() => {
        const tools = TOOLS || [];
        const toolTips = {
          select:    'Select — Default cursor. Pick / inspect tracks and shapes on the map.',
          corridor:  'Corridor — Draw an airspace corridor (linear lane with a width buffer).',
          killbox:   'Kill Box — Define a 3D kill-box airspace for fires deconfliction.',
          deception: 'Deception Route — Draw a feint or deception flight path.',
          target:    'Strike Target — Drop a strike aimpoint (DMPI) on the map.',
          measure:   'Measure — Range/bearing measurement tool. Click two points.',
        };
        return (
          <div style={{ position:'absolute', top:8, right:12, zIndex:5, display:'flex', gap:4,
            background:C.s2, border:`1px solid ${C.border}`, borderRadius:4, padding:4,
            boxShadow:'0 2px 8px rgba(0,0,0,0.4)' }}>
            {tools.map(t => {
              const isActive = t.id === activeTool;
              return (
                <button
                  key={t.id}
                  title={toolTips[t.id] || t.label}
                  onClick={() => onActiveToolChange(t.id)}
                  style={{
                    width:32, height:32, borderRadius:3, border:'none',
                    background: isActive ? C.accentM : 'transparent',
                    color: isActive ? C.accent : C.fg2,
                    cursor:'pointer', fontSize:15, fontWeight:500,
                    display:'flex', alignItems:'center', justifyContent:'center',
                    transition:'all 0.15s', padding:0,
                  }}
                  onMouseEnter={(e)=>{ if (!isActive) e.currentTarget.style.background = C.s3; }}
                  onMouseLeave={(e)=>{ if (!isActive) e.currentTarget.style.background = 'transparent'; }}>
                  {t.icon}
                </button>
              );
            })}
          </div>
        );
      })()}
    </div>
  );
}

// Share the platform icon loader + key resolver so other workspaces
// (TFC Gantt, Mission Admin, etc.) can render the same filled silhouettes
// without duplicating fetch / cache infrastructure.
Object.assign(window, {
  TacticalMap, MasterModeStrip, DRAWING_TOOLS: TOOLS,
  platformIconKey, loadIconPath,
  _iconPathCache,
});
