import { useEffect, useRef, useState } from "react"; import { getFlight, getFlights, simulate, type FlightDetail, type FlightSummary, type InjectedFlight, type SimulationResult, } from "../api"; import AlertBanner from "../components/AlertBanner"; import RadarPlot from "../components/RadarPlot"; import ScoreTimeline from "../components/ScoreTimeline"; import { useT } from "../i18n"; interface SimulatorProps { onInject: (flight: InjectedFlight) => void; hasInjected: boolean; onClearInjected: () => void; } interface KindConfig { id: string; min: number; max: number; step: number; def: number; unit: string; } const KINDS: KindConfig[] = [ { id: "route_deviation", min: 0, max: 80000, step: 2000, def: 40000, unit: "m" }, { id: "altitude", min: 0, max: 2500, step: 100, def: 1200, unit: "m" }, { id: "speed", min: 0.3, max: 2.6, step: 0.1, def: 2.2, unit: "x" }, { id: "holding", min: 60, max: 300, step: 20, def: 160, unit: "s/turn" }, { id: "freeze", min: 0, max: 0, step: 1, def: 0, unit: "" }, ]; const MOBILE_BREAKPOINT_QUERY = "(max-width: 900px)"; const SLIDER_TRACK_HEIGHT = 26; const INJECT_BUTTON_PADDING_MOBILE = "14px"; const INJECT_BUTTON_PADDING_DESKTOP = "10px"; const INJECT_BUTTON_FONT_MOBILE = 13; function useIsMobile() { const [isMobile, setIsMobile] = useState(() => { if (typeof window === "undefined") return false; return window.matchMedia(MOBILE_BREAKPOINT_QUERY).matches; }); useEffect(() => { if (typeof window === "undefined") return; const mql = window.matchMedia(MOBILE_BREAKPOINT_QUERY); const handler = (event: MediaQueryListEvent) => setIsMobile(event.matches); setIsMobile(mql.matches); mql.addEventListener("change", handler); return () => mql.removeEventListener("change", handler); }, []); return isMobile; } export default function Simulator({ onInject, hasInjected, onClearInjected }: SimulatorProps) { const t = useT(); const isMobile = useIsMobile(); const [bases, setBases] = useState([]); const [baseId, setBaseId] = useState(null); const [baseDetail, setBaseDetail] = useState(null); const [kind, setKind] = useState("speed"); const [magnitude, setMagnitude] = useState(2.2); const [onset, setOnset] = useState(0.5); const [result, setResult] = useState(null); const [error, setError] = useState(null); const simSeedRef = useRef(1); useEffect(() => { getFlights(20, "typical") .then((rows) => { setBases(rows); if (rows.length) setBaseId(rows[0].id); }) .catch((reason) => setError(String(reason))); }, []); useEffect(() => { if (baseId == null) return; getFlight(baseId) .then(setBaseDetail) .catch((reason) => setError(String(reason))); }, [baseId]); useEffect(() => { if (baseId == null) return; simulate({ id: baseId, kind, magnitude, onset }) .then(setResult) .catch((reason) => setError(String(reason))); }, [baseId, kind, magnitude, onset]); if (error) { return
{t.monitor.offline}
; } const config = KINDS.find((entry) => entry.id === kind)!; function changeKind(id: string) { setKind(id); setMagnitude(KINDS.find((entry) => entry.id === id)!.def); } const controlsPanel = (
{t.simulator.baseFlight}
{t.simulator.injectedAnomaly}
{KINDS.map((entry) => ( ))}
{config.max > config.min && (
{t.simulator.intensity}: {magnitude} {config.unit}
setMagnitude(Number(event.target.value))} style={{ width: "100%", marginTop: 8, accentColor: "var(--alert)", height: SLIDER_TRACK_HEIGHT }} />
)}
{t.simulator.onset}: {(onset * 100).toFixed(0)} {t.simulator.onsetSuffix}
setOnset(Number(event.target.value))} style={{ width: "100%", marginTop: 8, accentColor: "var(--warn)", height: SLIDER_TRACK_HEIGHT }} />
{hasInjected && ( )}
Appears in the Monitor as SDR001 within 5 s. Replaces any previous injection.
); const radarColumn = (
{result && ( )} {result && baseDetail && (
)}
); const timelineColumn = (
{t.monitor.scoreTitle}
{result && (
)}
); if (isMobile) { return (
{radarColumn} {controlsPanel} {timelineColumn}
); } return (
{controlsPanel} {radarColumn} {timelineColumn}
); }