sadar / frontend /src /pages /Monitor.tsx
Ruperth's picture
feat: polish mobile responsive layout across all dashboard screens
96bba34
Raw
History Blame Contribute Delete
22.5 kB
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<Scene | null>(null);
const [error, setError] = useState<string | null>(null);
const [simTime, setSimTime] = useState(120);
const [speed, setSpeed] = useState(1);
const [running, setRunning] = useState(true);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [flights, setFlights] = useState<SceneFlight[]>([]);
const [alerts, setAlerts] = useState<ActiveAlert[]>([]);
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<number | null>(null);
const lastTickRef = useRef<number>(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<SceneFlight | null>(null);
const audioPlayedRef = useRef<number | null>(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 <div className="status-alert">{t.monitor.offline}</div>;
}
if (!scene) {
return <div className="label">LOADING SCENE...</div>;
}
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 (
<div className="monitor-layout">
<div style={{ position: "relative", borderRight: "1px solid var(--panel-edge)", minHeight: 0 }}>
<RadarScope
flights={allFlights}
stepThreshold={stepThreshold}
stepSeconds={stepSeconds}
simTime={simTime}
selectedId={selectedId}
onSelect={setSelectedId}
center={center}
onAlerts={handleAlerts}
/>
{alerts.length > 0 && (
<div
style={{
position: "absolute",
top: isMobile ? "auto" : 14,
bottom: isMobile ? 60 : "auto",
left: 14,
right: 14,
maxWidth: 280,
display: "flex",
flexDirection: "column",
gap: 4,
zIndex: 5,
}}
>
{(isMobile ? alerts.slice(0, MOBILE_ALERT_LIMIT) : alerts).map((alert) => (
<div
key={alert.id}
onClick={() => 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)",
}}
>
<div style={{ display: "flex", justifyContent: "space-between", gap: 8 }}>
<span style={{ fontWeight: 700 }}>{alert.callsign}</span>
<span>{alert.classification.short}</span>
</div>
<div style={{ marginTop: 2, color: "var(--label)", fontSize: 10 }}>
{alert.classification.label}
</div>
<div style={{ color: "var(--muted)", fontSize: 10 }}>
{alert.classification.reason}
</div>
</div>
))}
</div>
)}
<div
style={{
position: "absolute",
bottom: 12,
left: 12,
right: isMobile ? 12 : "auto",
display: "flex",
gap: 6,
alignItems: "center",
flexWrap: isMobile ? "nowrap" : "wrap",
overflowX: isMobile ? "auto" : "visible",
maxWidth: isMobile ? "calc(100% - 24px)" : "calc(100% - 24px)",
background: "rgba(14, 26, 31, 0.85)",
border: "1px solid var(--panel-edge)",
padding: isMobile ? "6px 8px" : "6px 10px",
fontSize: isMobile ? 12 : 11,
letterSpacing: "0.08em",
textTransform: "uppercase",
}}
>
<span style={{ color: "var(--info)", flexShrink: 0 }}>{clock}</span>
<span style={{ color: "var(--panel-edge)", flexShrink: 0 }}>|</span>
<button
onClick={() => setRunning((value) => !value)}
style={{
padding: isMobile ? "8px 12px" : "2px 8px",
fontSize: isMobile ? 12 : undefined,
flexShrink: 0,
}}
>
{isMobile ? (running ? "P" : "▶") : running ? "PAUSE" : "PLAY"}
</button>
{SPEEDS.map((value) => (
<button
key={value}
onClick={() => setSpeed(value)}
style={{
padding: isMobile ? "8px 12px" : "2px 8px",
fontSize: isMobile ? 12 : undefined,
flexShrink: 0,
borderColor: speed === value ? "var(--info)" : "var(--panel-edge)",
color: speed === value ? "var(--info)" : "var(--text)",
}}
>
{value}X
</button>
))}
<button
onClick={() => setSimTime(120)}
style={{
padding: isMobile ? "8px 12px" : "2px 8px",
fontSize: isMobile ? 12 : undefined,
flexShrink: 0,
}}
>
RESET
</button>
</div>
{injectedFlight && (
<div
style={{
position: "absolute",
bottom: 12,
right: 12,
display: "flex",
gap: 8,
alignItems: "center",
background: "rgba(14, 26, 31, 0.92)",
border: "1px solid #d96bd9",
padding: "6px 10px",
fontSize: 11,
letterSpacing: "0.08em",
textTransform: "uppercase",
color: "#d96bd9",
}}
>
<span>SIM INJECTED · {injectedFlight.callsign}</span>
<button
onClick={() => {
setInjectedFlight(null);
onClearInjected();
}}
style={{ padding: "2px 8px", borderColor: "#d96bd9", color: "#d96bd9" }}
>
CLEAR
</button>
</div>
)}
</div>
<div className="monitor-strip-panel">
<div
style={{
padding: "8px 12px",
borderBottom: "1px solid var(--panel-edge)",
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
}}
>
<span className="label">FLIGHT STRIPS</span>
<span className="label" style={{ color: "var(--info)" }}>{activeIds.size} ACTIVE</span>
</div>
<div style={{ overflowY: "auto", flex: 1, minHeight: 0 }}>
{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 (
<div
key={flight.id}
className={`scope-row${flight.id === selectedId ? " scope-row--selected" : ""}`}
style={{ gridTemplateColumns: "1fr 56px 56px", color: stateColor }}
onClick={() => setSelectedId(flight.id)}
>
<span>{flight.callsign}</span>
<span style={{ textAlign: "right", color: "var(--muted)" }}>WIN {String(flight.id).padStart(4, "0")}</span>
<span style={{ textAlign: "right" }}>{stateLabel}</span>
</div>
);
})}
</div>
{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 = (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 3 }}>
<span style={{ color: "var(--muted)" }}>CALLSIGN</span>
<span style={{ textAlign: "right" }}>{selected.callsign}</span>
<span style={{ color: "var(--muted)" }}>WINDOW</span>
<span style={{ textAlign: "right" }}>{String(selected.id).padStart(5, "0")}</span>
<span style={{ color: "var(--muted)" }}>ALTITUDE</span>
<span style={{ textAlign: "right" }}>{onScope ? `${fl} (${altFt} FT)` : "---"}</span>
<span style={{ color: "var(--muted)" }}>GROUND SPD</span>
<span style={{ textAlign: "right" }}>{onScope ? `${Math.round(speedKt)} KT` : "---"}</span>
<span style={{ color: "var(--muted)" }}>HEADING</span>
<span style={{ textAlign: "right" }}>{onScope ? `${String(Math.round(headingDeg)).padStart(3, "0")}°` : "---"}</span>
<span style={{ color: "var(--muted)" }}>DIST LEMD</span>
<span style={{ textAlign: "right" }}>{onScope ? `${distNm.toFixed(1)} NM` : "---"}</span>
<span style={{ color: "var(--muted)" }}>SCORE (NOW)</span>
<span style={{ textAlign: "right", color: alerting ? "var(--alert)" : "var(--text)" }}>
{onScope ? currentScore.toFixed(3) : "---"}
</span>
<span style={{ color: "var(--muted)" }}>PEAK</span>
<span style={{ textAlign: "right" }}>{peakScore.toFixed(3)}</span>
<span style={{ color: "var(--muted)" }}>THRESHOLD</span>
<span style={{ textAlign: "right" }}>{stepThreshold.toFixed(3)}</span>
{alertClass && (
<>
<span style={{ color: "var(--muted)" }}>ALERT TYPE</span>
<span
style={{
textAlign: "right",
color: alertClass.critical ? "var(--emergency)" : "var(--alert)",
}}
>
{alertClass.short}
</span>
<span style={{ color: "var(--muted)" }}>SIGNATURE</span>
<span style={{ textAlign: "right", fontSize: 10 }}>{alertClass.label}</span>
<span style={{ color: "var(--muted)" }}>REASON</span>
<span style={{ textAlign: "right", fontSize: 10, color: "var(--label)" }}>
{alertClass.reason} (heuristic)
</span>
</>
)}
</div>
);
const summaryTable = (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 3 }}>
<span style={{ color: "var(--muted)" }}>CALLSIGN</span>
<span style={{ textAlign: "right" }}>{selected.callsign}</span>
<span style={{ color: "var(--muted)" }}>ALTITUDE</span>
<span style={{ textAlign: "right" }}>{onScope ? `${fl} (${altFt} FT)` : "---"}</span>
<span style={{ color: "var(--muted)" }}>SPEED</span>
<span style={{ textAlign: "right" }}>{onScope ? `${Math.round(speedKt)} KT` : "---"}</span>
<span style={{ color: "var(--muted)" }}>SCORE</span>
<span style={{ textAlign: "right", color: alerting ? "var(--alert)" : "var(--text)" }}>
{onScope ? currentScore.toFixed(3) : "---"}
</span>
</div>
);
return (
<div style={{ padding: "10px 12px", borderTop: "1px solid var(--panel-edge)", fontSize: 11 }}>
<div className="label" style={{ marginBottom: 8, display: "flex", justifyContent: "space-between" }}>
<span>SELECTED</span>
<span style={{ color: alerting ? "var(--alert)" : onScope ? "var(--info)" : "var(--muted)" }}>
{onScope ? (alerting ? "ALERT" : "TRACK") : localT < 0 ? "INBOUND" : "CLEARED"}
</span>
</div>
{isMobile ? (
<>
{summaryTable}
<details style={{ marginTop: 8 }}>
<summary
className="label"
style={{ cursor: "pointer", color: "var(--info)", padding: "4px 0" }}
>
DETAILS
</summary>
<div style={{ marginTop: 6 }}>{fullTable}</div>
</details>
</>
) : (
<>
{fullTable}
<div className="label" style={{ marginTop: 10, marginBottom: 4 }}>SCORE / 10 MIN</div>
<svg
viewBox={`0 0 ${sparkW} ${sparkH}`}
preserveAspectRatio="none"
style={{ display: "block", width: "100%", height: sparkH, background: "var(--bg-deep)", border: "1px solid var(--panel-edge)" }}
>
<line x1={0} y1={thrY} x2={sparkW} y2={thrY} stroke="var(--alert)" strokeDasharray="3 3" strokeWidth={1} />
<path d={sparkPath} fill="none" stroke="var(--info)" strokeWidth={1.4} vectorEffect="non-scaling-stroke" />
{cursorIdx >= 0 && (
<line x1={cursorX} y1={0} x2={cursorX} y2={sparkH} stroke="var(--track-selected)" strokeWidth={1} vectorEffect="non-scaling-stroke" />
)}
</svg>
</>
)}
</div>
);
})()}
</div>
</div>
);
}