import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { getScene, type InjectedFlight, type Scene, type SceneFlight } from "../api"; import RadarScope, { type ActiveAlert } from "../components/RadarScope"; import { classify } from "../components/classify"; import { useT } from "../i18n"; const SPEEDS = [1, 10, 30, 60]; const AIRLINES = ["IBE", "AEA", "RYR", "VLG", "AFR", "BAW", "DLH", "KLM", "EZY", "UAE", "QTR", "AAL"]; const MOBILE_BREAKPOINT = 900; const MOBILE_ALERT_LIMIT = 3; function newCallsign(seed: number): string { const airline = AIRLINES[seed % AIRLINES.length]; const number = 100 + (seed * 37) % 8900; return `${airline}${number}`; } function useIsMobile(): boolean { const [isMobile, setIsMobile] = useState(() => { if (typeof window === "undefined") return false; return window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`).matches; }); useEffect(() => { if (typeof window === "undefined") return; const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`); const handler = (event: MediaQueryListEvent) => setIsMobile(event.matches); mql.addEventListener("change", handler); return () => mql.removeEventListener("change", handler); }, []); return isMobile; } interface MonitorProps { injected: InjectedFlight | null; onClearInjected: () => void; } export default function Monitor({ injected, onClearInjected }: MonitorProps) { const t = useT(); const isMobile = useIsMobile(); const [scene, setScene] = useState(null); const [error, setError] = useState(null); const [simTime, setSimTime] = useState(120); const [speed, setSpeed] = useState(1); const [running, setRunning] = useState(true); const [selectedId, setSelectedId] = useState(null); const [flights, setFlights] = useState([]); const [alerts, setAlerts] = useState([]); const respawnSeedRef = useRef(1000); const handleAlerts = useCallback((next: ActiveAlert[]) => { setAlerts((prev) => { if (prev.length !== next.length) return next; for (let i = 0; i < prev.length; i++) { if (prev[i].id !== next[i].id || prev[i].classification.kind !== next[i].classification.kind) return next; } return prev; }); }, []); const rafRef = useRef(null); const lastTickRef = useRef(0); useEffect(() => { getScene(25) .then((data) => { setScene(data); setFlights(data.flights); if (data.flights.length) setSelectedId(data.flights[0].id); }) .catch((reason) => setError(String(reason))); }, []); useEffect(() => { if (!running) return; lastTickRef.current = performance.now(); const step = (now: number) => { const dt = (now - lastTickRef.current) / 1000; lastTickRef.current = now; setSimTime((value) => value + dt * speed); rafRef.current = requestAnimationFrame(step); }; rafRef.current = requestAnimationFrame(step); return () => { if (rafRef.current != null) cancelAnimationFrame(rafRef.current); }; }, [running, speed]); useEffect(() => { if (!flights.length) return; const updated = flights.map((flight) => { const endT = flight.start_offset + flight.path[flight.path.length - 1].t; if (simTime <= endT + 5) return flight; const delay = 30 + Math.random() * 60; const seed = respawnSeedRef.current++; return { ...flight, start_offset: simTime + delay, callsign: newCallsign(seed), }; }); if (updated.some((flight, i) => flight !== flights[i])) { setFlights(updated); } }, [simTime, flights]); const [injectedFlight, setInjectedFlight] = useState(null); const audioPlayedRef = useRef(null); useEffect(() => { if (injected) { setInjectedFlight({ ...injected, start_offset: simTime + 5 }); audioPlayedRef.current = null; } else { setInjectedFlight(null); } }, [injected]); useEffect(() => { if (!injectedFlight) return; if (audioPlayedRef.current === injectedFlight.id) return; const localT = simTime - injectedFlight.start_offset; if (localT < 0) return; audioPlayedRef.current = injectedFlight.id; const sequence = ["/atc/tower1.mp3", "/atc/plane.mp3", "/atc/tower2.mp3"]; let idx = 0; const playNext = () => { if (idx >= sequence.length) return; const audio = new Audio(sequence[idx]); audio.volume = 0.85; audio.addEventListener("ended", () => { idx++; setTimeout(playNext, 600); }); audio.play().catch(() => {}); }; playNext(); }, [simTime, injectedFlight]); useEffect(() => { if (!injectedFlight) return; const endT = injectedFlight.start_offset + injectedFlight.path[injectedFlight.path.length - 1].t; if (simTime > endT + 10) { setInjectedFlight(null); onClearInjected(); } }, [simTime, injectedFlight, onClearInjected]); const allFlights = useMemo( () => (injectedFlight ? [...flights, injectedFlight] : flights), [flights, injectedFlight], ); const stepThreshold = scene?.step_threshold ?? 0; const stepSeconds = scene?.step_seconds ?? 10; const center = useMemo(() => scene?.center ?? { lat: 40.4936, lon: -3.5668 }, [scene]); if (error) { return
{t.monitor.offline}
; } if (!scene) { return
LOADING SCENE...
; } const activeIds = new Set( allFlights .filter((flight) => { const localT = simTime - flight.start_offset; return localT >= 0 && localT <= flight.path[flight.path.length - 1].t; }) .map((flight) => flight.id), ); const selected = allFlights.find((flight) => flight.id === selectedId); const minutes = Math.floor(simTime / 60); const seconds = Math.floor(simTime % 60); const clock = `T+${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; return (
{alerts.length > 0 && (
{(isMobile ? alerts.slice(0, MOBILE_ALERT_LIMIT) : alerts).map((alert) => (
setSelectedId(alert.id)} className={alert.classification.critical ? "atc-blink" : ""} style={{ cursor: "pointer", background: "rgba(14, 26, 31, 0.92)", border: `1px solid ${alert.classification.critical ? "var(--emergency)" : "var(--alert)"}`, padding: "6px 8px", fontFamily: "var(--mono)", fontSize: 11, letterSpacing: "0.06em", textTransform: "uppercase", color: alert.classification.critical ? "var(--emergency)" : "var(--alert)", }} >
{alert.callsign} {alert.classification.short}
{alert.classification.label}
{alert.classification.reason}
))}
)}
{clock} | {SPEEDS.map((value) => ( ))}
{injectedFlight && (
SIM INJECTED · {injectedFlight.callsign}
)}
FLIGHT STRIPS {activeIds.size} ACTIVE
{allFlights.map((flight) => { const localT = simTime - flight.start_offset; const onScope = localT >= 0 && localT <= flight.path[flight.path.length - 1].t; const stepIndex = Math.max( 0, Math.min(flight.scores.length - 1, Math.floor(localT / stepSeconds)), ); const score = onScope ? flight.scores[stepIndex] : 0; const alerting = onScope && score >= stepThreshold; const stateLabel = !onScope ? localT < 0 ? "INBOUND" : "CLEARED" : alerting ? "ALERT" : "TRACK"; const stateColor = !onScope ? "var(--muted)" : alerting ? "var(--alert)" : "var(--track-normal)"; return (
setSelectedId(flight.id)} > {flight.callsign} WIN {String(flight.id).padStart(4, "0")} {stateLabel}
); })}
{selected && (() => { const localT = simTime - selected.start_offset; const onScope = localT >= 0 && localT <= selected.path[selected.path.length - 1].t; let alt = 0; let speedKt = 0; let headingDeg = 0; let lat = 0; let lon = 0; let currentScore = 0; if (onScope) { const path = selected.path; let i = 0; for (; i < path.length - 1; i++) { if (localT >= path[i].t && localT < path[i + 1].t) break; } const a = path[i]; const b = path[Math.min(i + 1, path.length - 1)]; const span = b.t - a.t || 1; const f = (localT - a.t) / span; lat = a.lat + (b.lat - a.lat) * f; lon = a.lon + (b.lon - a.lon) * f; alt = a.alt + (b.alt - a.alt) * f; const lat1 = (a.lat * Math.PI) / 180; const lat2 = (b.lat * Math.PI) / 180; const dLon = ((b.lon - a.lon) * Math.PI) / 180; headingDeg = ( (Math.atan2( Math.sin(dLon) * Math.cos(lat2), Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon), ) * 180) / Math.PI + 360 ) % 360; const R = 6371; const dLatHav = ((b.lat - a.lat) * Math.PI) / 180; const h = Math.sin(dLatHav / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2; const kmStep = 2 * R * Math.asin(Math.sqrt(h)); speedKt = (kmStep / 1.852) * (3600 / stepSeconds); const stepIndex = Math.max(0, Math.min(selected.scores.length - 1, Math.floor(localT / stepSeconds))); currentScore = selected.scores[stepIndex]; } const lemdLat = (center.lat * Math.PI) / 180; const acLat = (lat * Math.PI) / 180; const dLonNm = ((lon - center.lon) * Math.PI) / 180; const dLatNm = ((lat - center.lat) * Math.PI) / 180; const hNm = Math.sin(dLatNm / 2) ** 2 + Math.cos(lemdLat) * Math.cos(acLat) * Math.sin(dLonNm / 2) ** 2; const distKm = onScope ? 2 * 6371 * Math.asin(Math.sqrt(hNm)) : 0; const distNm = distKm / 1.852; const peakScore = Math.max(...selected.scores); const altFt = Math.round((alt * 3.281) / 100) * 100; const fl = `FL${String(Math.round((alt * 3.281) / 1000) * 10 / 10).padStart(3, "0")}`; const sparkW = 240; const sparkH = 36; const maxS = Math.max(stepThreshold, ...selected.scores) * 1.1 || 1; const sparkPath = selected.scores .map((s, i) => { const sx = (i / (selected.scores.length - 1)) * sparkW; const sy = sparkH - (s / maxS) * sparkH; return `${i === 0 ? "M" : "L"}${sx.toFixed(1)},${sy.toFixed(1)}`; }) .join(" "); const cursorIdx = onScope ? Math.max(0, Math.min(selected.scores.length - 1, Math.floor(localT / stepSeconds))) : -1; const cursorX = cursorIdx >= 0 ? (cursorIdx / (selected.scores.length - 1)) * sparkW : 0; const thrY = sparkH - (stepThreshold / maxS) * sparkH; const alerting = onScope && currentScore >= stepThreshold; const alertClass = alerting && cursorIdx >= 0 ? classify(selected.path, cursorIdx) : null; const fullTable = (
CALLSIGN {selected.callsign} WINDOW {String(selected.id).padStart(5, "0")} ALTITUDE {onScope ? `${fl} (${altFt} FT)` : "---"} GROUND SPD {onScope ? `${Math.round(speedKt)} KT` : "---"} HEADING {onScope ? `${String(Math.round(headingDeg)).padStart(3, "0")}°` : "---"} DIST LEMD {onScope ? `${distNm.toFixed(1)} NM` : "---"} SCORE (NOW) {onScope ? currentScore.toFixed(3) : "---"} PEAK {peakScore.toFixed(3)} THRESHOLD {stepThreshold.toFixed(3)} {alertClass && ( <> ALERT TYPE {alertClass.short} SIGNATURE {alertClass.label} REASON {alertClass.reason} (heuristic) )}
); const summaryTable = (
CALLSIGN {selected.callsign} ALTITUDE {onScope ? `${fl} (${altFt} FT)` : "---"} SPEED {onScope ? `${Math.round(speedKt)} KT` : "---"} SCORE {onScope ? currentScore.toFixed(3) : "---"}
); return (
SELECTED {onScope ? (alerting ? "ALERT" : "TRACK") : localT < 0 ? "INBOUND" : "CLEARED"}
{isMobile ? ( <> {summaryTable}
DETAILS
{fullTable}
) : ( <> {fullTable}
SCORE / 10 MIN
{cursorIdx >= 0 && ( )} )}
); })()}
); }