Refactor UI components for improved theming, better flight trace logic and weather data (RainViewer radar tiles and METAR reports) (#17)

* Refactor UI components for improved theming and accessibility

- Updated color schemes in `fpv-hud.tsx`, `hero-banner.tsx`, `keyboard-shortcuts-help.tsx`, `mobile-flight-toast.tsx`, `provider-panel.tsx`, `scroll-area.tsx`, and `slider.tsx` to utilize foreground and background variables for better dark mode support.
- Enhanced visual consistency by replacing hardcoded colors with theme variables across various components.
- Adjusted text and background colors for improved readability and accessibility.
- Fixed minor issues with key bindings in `keyboard-shortcuts-help.tsx`.
- Optimized flight data handling in `use-trail-history.ts` and `trail-cleanup.ts` for better performance and accuracy.
- Implemented outlier filtering in trail history to reduce GPS glitches.

* feat: enhance aircraft appearance and flight trail rendering with improved safety checks and visual effects

* feat: implement last flight leg trimming and nearest airport search functionality

* feat: Enhance flight data parsing and handling

- Added optionalFinite helper function to ensure only finite numbers are processed in flight data.
- Extended FlightState type to include avionics data (ias, tas, mach, roll, trackRate, magHeading) and navigation intent (navAltitudeMcp, navAltitudeFms, navHeading, navQnh, navModes).
- Updated parseRawAircraft function to utilize optionalFinite for avionics and navigation data.
- Adjusted removeSpikePoints function to increase cosThreshold from -0.17 to -0.05 for better spike removal.
- Increased MAX_WINDOW in removePathLoops function from 120 to 300 to allow for larger path loops.
- Integrated loop cleaning in stitchHistoricalTrail function to ensure cleaner paths and altitudes.

* feat: add AtcSpectrum component for audio visualization and useAirportBoard hook for flight data management
This commit is contained in:
kew
2026-03-30 00:21:36 +05:30
committed by GitHub
parent 0e2ba9fc13
commit 498504b73b
177 changed files with 5676 additions and 1165 deletions

View File

@ -23,15 +23,15 @@ export class ErrorBoundary extends Component<Props, State> {
return (
<div
role="alert"
className="flex h-screen w-screen flex-col items-center justify-center gap-4 bg-black text-white"
className="flex h-screen w-screen flex-col items-center justify-center gap-4 bg-background text-foreground"
>
<p className="text-lg font-semibold">Something went wrong</p>
<p className="max-w-md text-center text-sm text-white/50">
<p className="max-w-md text-center text-sm text-foreground/50">
{this.state.error.message}
</p>
<button
onClick={() => this.setState({ error: null })}
className="rounded-lg bg-white/10 px-4 py-2 text-sm font-medium transition-colors hover:bg-white/20"
className="rounded-lg bg-foreground/10 px-4 py-2 text-sm font-medium transition-colors hover:bg-foreground/20"
>
Try again
</button>

View File

@ -6,13 +6,9 @@ import {
formatStarCount,
} from "@/components/flight-tracker-utils";
export function Brand({ isDark }: { isDark: boolean }) {
export function Brand({ isDark: _isDark }: { isDark: boolean }) {
return (
<span
className={`text-sm font-semibold tracking-wide ${
isDark ? "text-white/70" : "text-black/70"
}`}
>
<span className="text-sm font-semibold tracking-wide text-foreground/70">
aeris
</span>
);

View File

@ -8,14 +8,20 @@ import {
useRef,
useSyncExternalStore,
} from "react";
import { AnimatePresence } from "motion/react";
import { AnimatePresence, motion } from "motion/react";
import dynamic from "next/dynamic";
import { useTheme } from "next-themes";
import { ErrorBoundary } from "@/components/error-boundary";
import { Map as MapView } from "@/components/map/map";
import { CameraController } from "@/components/map/camera-controller";
import { AirportLayer } from "@/components/map/airport-layer";
import { AirspaceLayer } from "@/components/map/airspace-layer";
import { WeatherRadarLayer } from "@/components/map/weather-radar-layer";
import { FlightLayers } from "@/components/map/flight-layers";
import {
MapStateTracker,
type MapViewState,
} from "@/components/map/map-state-tracker";
const FlightCard = dynamic(() =>
import("@/components/ui/flight-card").then((mod) => mod.FlightCard),
);
@ -28,6 +34,9 @@ import { CameraControls } from "@/components/ui/camera-controls";
import { StatusBar } from "@/components/ui/status-bar";
import { MapAttribution } from "@/components/ui/map-attribution";
import { AtcPlayerBar } from "@/components/ui/atc-panel";
const AirportBoard = dynamic(() =>
import("@/components/ui/airport-board").then((mod) => mod.AirportBoard),
);
import { Brand, GitHubBadge } from "@/components/flight-tracker-brand";
import { SettingsProvider, useSettings } from "@/hooks/use-settings";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
@ -38,11 +47,12 @@ import { useMergedTrails } from "@/hooks/use-merged-trails";
import { useFlightMonitors } from "@/hooks/use-flight-monitors";
import { useAtcStream } from "@/hooks/use-atc-stream";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { useAirportBoard } from "@/hooks/use-airport-board";
import { MobileFlightToast } from "@/components/ui/mobile-flight-toast";
import { toast } from "sonner";
import type { MapStyle } from "@/lib/map-styles";
import type { City } from "@/lib/cities";
import type { FlightState } from "@/lib/opensky";
import { fetchFlightByHex, fetchFlightByCallsign } from "@/lib/flight-api";
import { formatCallsign } from "@/lib/flight-utils";
import type { PickingInfo } from "@deck.gl/core";
@ -103,6 +113,12 @@ function FlightTrackerInner() {
const activeCity = cityOverride ?? hydratedCity;
const mapStyle = styleOverride ?? hydratedStyle;
const { settings, update } = useSettings();
const { setTheme } = useTheme();
// Sync document theme with current map style (dark/light)
useEffect(() => {
setTheme(mapStyle.dark ? "dark" : "light");
}, [mapStyle.dark, setTheme]);
const setActiveCity = useCallback((city: City) => {
setCityOverride(city);
@ -112,6 +128,16 @@ function FlightTrackerInner() {
syncCityToUrl(city);
}, []);
/** Called when user clicks an airport dot on the map — navigates AND opens the board. */
const handleAirportDotClick = useCallback((city: City) => {
setCityOverride(city);
setSelectedIcao24(null);
setFpvIcao24(null);
setFollowIcao24(null);
syncCityToUrl(city);
setSelectedAirportIata(city.iata);
}, []);
const setMapStyle = useCallback((style: MapStyle) => {
setStyleOverride(style);
saveMapStyle(style);
@ -133,15 +159,7 @@ function FlightTrackerInner() {
return m;
}, [displayFlights]);
const selectedFlightForTrack = useMemo(() => {
if (!selectedIcao24) return null;
return displayFlightMap.get(selectedIcao24) ?? null;
}, [selectedIcao24, displayFlightMap]);
const shouldFetchSelectedTrack =
!!selectedIcao24 &&
!fpvIcao24 &&
!(selectedFlightForTrack?.onGround ?? false);
const shouldFetchSelectedTrack = !!selectedIcao24 && !fpvIcao24;
const { track: selectedTrack, fetchedAtMs: selectedTrackFetchedAtMs } =
useFlightTrack(selectedIcao24, {
@ -161,6 +179,11 @@ function FlightTrackerInner() {
return displayFlightMap.get(selectedIcao24) ?? null;
}, [selectedIcao24, displayFlightMap]);
const selectedTrail = useMemo(() => {
if (!selectedIcao24) return null;
return mergedTrails.find((t) => t.icao24 === selectedIcao24) ?? null;
}, [selectedIcao24, mergedTrails]);
const followFlight = useMemo(() => {
if (!followIcao24) return null;
return displayFlightMap.get(followIcao24) ?? null;
@ -198,6 +221,39 @@ function FlightTrackerInner() {
const fpvFlightOrCached = fpvFlight;
const displayFlight = selectedFlight;
// ── Airport Board state ──────────────────────────────────────────────
const mapStateRef = useRef<MapViewState>({
zoom: 9.2,
center: { lat: 0, lng: 0 },
});
const [mapViewState, setMapViewState] = useState<MapViewState>({
zoom: 9.2,
center: { lat: 0, lng: 0 },
});
const [selectedAirportIata, setSelectedAirportIata] = useState<string | null>(
null,
);
const handleMapStateChange = useCallback((state: MapViewState) => {
setMapViewState(state);
}, []);
const airportBoard = useAirportBoard(
displayFlights,
mapViewState.center,
mapViewState.zoom,
activeCity.iata,
selectedAirportIata,
);
const handleAirportBoardSelect = useCallback((icao24: string) => {
setSelectedIcao24((prev) => (prev === icao24 ? null : icao24));
}, []);
const handleAirportBoardClose = useCallback(() => {
setSelectedAirportIata(null);
}, []);
const [atcToggle, setAtcToggle] = useState(0);
const handleToggleAtc = useCallback(() => {
setAtcToggle((c) => c + 1);
@ -370,74 +426,12 @@ function FlightTrackerInner() {
});
const isMobile = useIsMobile();
const mobileToastIdRef = useRef<string | number | null>(null);
// Stable close handler that both dismisses the toast and deselects the flight
const handleMobileToastClose = useCallback(() => {
if (mobileToastIdRef.current !== null) {
toast.dismiss(mobileToastIdRef.current);
mobileToastIdRef.current = null;
}
handleDeselectFlight();
}, [handleDeselectFlight]);
// Show/dismiss mobile flight toast
useEffect(() => {
// Dismiss when not applicable
if (!isMobile || fpvIcao24 || !displayFlight) {
if (mobileToastIdRef.current !== null) {
toast.dismiss(mobileToastIdRef.current);
mobileToastIdRef.current = null;
}
return;
}
// Use a stable ID based on the selected flight
const stableId = `mobile-flight-${displayFlight.icao24}`;
// If switching to a different flight, dismiss the old toast first
if (
mobileToastIdRef.current !== null &&
mobileToastIdRef.current !== stableId
) {
toast.dismiss(mobileToastIdRef.current);
}
toast.custom(
() => (
<MobileFlightToast
flight={displayFlight}
onClose={handleMobileToastClose}
onToggleFpv={handleToggleFpv}
isFpvActive={fpvIcao24 === displayFlight.icao24}
/>
),
{
id: stableId,
duration: Infinity,
dismissible: false,
},
);
mobileToastIdRef.current = stableId;
}, [
isMobile,
displayFlight,
fpvIcao24,
handleMobileToastClose,
handleToggleFpv,
]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (mobileToastIdRef.current !== null) {
toast.dismiss(mobileToastIdRef.current);
}
};
}, []);
// Whether to show the mobile bottom sheet flight card
const showMobileFlightCard = isMobile && !fpvIcao24 && !!displayFlight;
return (
<main className="relative h-dvh w-screen overflow-hidden bg-black">
<main className="relative h-dvh w-screen overflow-hidden bg-background">
<MapView
mapStyle={mapStyle.style}
terrainProfile={mapStyle.terrainProfile}
@ -450,9 +444,13 @@ function FlightTrackerInner() {
fpvFlight={fpvFlightOrCached}
fpvPositionRef={fpvPositionRef}
/>
<MapStateTracker
stateRef={mapStateRef}
onChange={handleMapStateChange}
/>
<AirportLayer
activeCity={activeCity}
onSelectAirport={setActiveCity}
onSelectAirport={handleAirportDotClick}
isDark={mapStyle.dark}
/>
<AirspaceLayer
@ -460,6 +458,10 @@ function FlightTrackerInner() {
opacity={settings.airspaceOpacity}
showHotspots={settings.showAirspaceHotspots}
/>
<WeatherRadarLayer
visible={settings.showWeatherRadar}
opacity={settings.weatherRadarOpacity}
/>
<FlightLayers
flights={displayFlights}
trails={mergedTrails}
@ -476,10 +478,7 @@ function FlightTrackerInner() {
/>
</MapView>
<div
data-map-theme={mapStyle.dark ? "dark" : "light"}
className="pointer-events-none absolute inset-0 z-10"
>
<div className="pointer-events-none absolute inset-0 z-10">
{!fpvIcao24 && (
<div className="pointer-events-auto absolute left-3 top-3 flex items-center gap-3 sm:left-4 sm:top-4">
<Brand isDark={mapStyle.dark} />
@ -490,6 +489,7 @@ function FlightTrackerInner() {
<div className="pointer-events-auto absolute left-3 top-14 sm:left-4 sm:top-16">
<FlightCard
flight={displayFlight}
trail={selectedTrail}
onClose={handleDeselectFlight}
onToggleFpv={handleToggleFpv}
isFpvActive={
@ -561,6 +561,56 @@ function FlightTrackerInner() {
</div>
</div>
)}
{/* Airport Departure/Arrival Board — hide on mobile when flight card is open */}
{!fpvIcao24 && !showMobileFlightCard && (
<AnimatePresence>
{airportBoard.isActive && (
<div className="pointer-events-auto absolute bottom-[env(safe-area-inset-bottom,0px)] left-1/2 mb-14 -translate-x-1/2 sm:mb-16">
<AirportBoard
data={airportBoard}
onSelectFlight={handleAirportBoardSelect}
selectedIcao24={selectedIcao24}
onClose={handleAirportBoardClose}
/>
</div>
)}
</AnimatePresence>
)}
{/* Mobile flight card — native bottom sheet with drag-to-dismiss */}
<AnimatePresence>
{showMobileFlightCard && displayFlight && (
<motion.div
key={displayFlight.icao24}
className="pointer-events-auto fixed inset-x-0 bottom-0 z-50 px-2 pb-[calc(0.5rem+env(safe-area-inset-bottom,0px))]"
initial={{ y: "100%", opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: "100%", opacity: 0 }}
transition={{
type: "spring",
stiffness: 400,
damping: 35,
mass: 0.8,
}}
drag="y"
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0, bottom: 0.6 }}
onDragEnd={(_, info) => {
if (info.offset.y > 80 || info.velocity.y > 300) {
handleDeselectFlight();
}
}}
>
<MobileFlightToast
flight={displayFlight}
onClose={handleDeselectFlight}
onToggleFpv={handleToggleFpv}
isFpvActive={fpvIcao24 === displayFlight.icao24}
/>
</motion.div>
)}
</AnimatePresence>
</div>
<AnimatePresence>

View File

@ -58,10 +58,35 @@ export function tintAircraftColor(
];
}
/** Apply military (amber) or emergency (red) tint on top of normal color. */
export function applySpecialTint(
color: [number, number, number, number],
dbFlags?: number | null,
emergencyStatus?: string | null,
): [number, number, number, number] {
// Emergency overrides military
if (emergencyStatus && emergencyStatus !== "none") {
return [
Math.round(color[0] * 0.3 + 255 * 0.7),
Math.round(color[1] * 0.3 + 60 * 0.7),
Math.round(color[2] * 0.3 + 60 * 0.7),
color[3],
];
}
if (((dbFlags ?? 0) & 1) !== 0) {
return [
Math.round(color[0] * 0.4 + 255 * 0.6),
Math.round(color[1] * 0.4 + 190 * 0.6),
Math.round(color[2] * 0.4 + 80 * 0.6),
color[3],
];
}
return color;
}
// ── Selection pulse timing ─────────────────────────────────────────────
export const PULSE_PERIOD_MS = 7000;
export const RING_PERIOD_MS = 5500;
export const PULSE_PERIOD_MS = 9000;
// ── Canvas Atlas Generators ────────────────────────────────────────────
@ -76,16 +101,18 @@ export function createHaloAtlas(): HTMLCanvasElement {
for (let r = 0; r < c; r++) {
const norm = r / c;
let alpha = 0;
if (norm < 0.18) {
if (norm < 0.4) {
// Large clear center — no glow within ~40% of radius so it never
// overlaps the aircraft icon even at the largest category size.
alpha = 0;
} else if (norm < 0.35) {
const t = (norm - 0.18) / 0.17;
alpha = t * t * 0.7;
} else if (norm < 0.55) {
alpha = 0.7 - ((norm - 0.35) / 0.2) * 0.3;
const t = (norm - 0.4) / 0.15;
alpha = t * t * 0.4;
} else if (norm < 0.72) {
alpha = 0.4 - ((norm - 0.55) / 0.17) * 0.15;
} else {
const t = (norm - 0.55) / 0.45;
alpha = 0.4 * (1 - t) * (1 - t);
const t = (norm - 0.72) / 0.28;
alpha = 0.25 * (1 - t) * (1 - t);
}
if (alpha < 0.003) continue;
ctx.strokeStyle = `rgba(255,255,255,${alpha})`;

View File

@ -18,10 +18,7 @@
import { ScenegraphLayer } from "@deck.gl/mesh-layers";
import type { FlightState } from "@/lib/opensky";
import { altitudeToColor, altitudeToElevation } from "@/lib/flight-utils";
import {
categorySizeMultiplier,
tintAircraftColor,
} from "./aircraft-appearance";
import { tintAircraftColor, applySpecialTint } from "./aircraft-appearance";
import { type PickingInfo } from "@deck.gl/core";
import {
AIRCRAFT_MIN_PIXELS,
@ -77,7 +74,7 @@ export interface AircraftLayerParams {
* between animation frames). Accessors look up interpolated positions from
* the `interpolatedMap`. `updateTriggers` selectively recompute:
* - getPosition / getOrientation: every frame (via frameCounter)
* - getColor / getScale: only on new data (via dataVersion)
* - getColor: only on new data (via dataVersion)
*
* This eliminates per-frame color/scale attribute recomputation for all
* 14 layers and massively reduces GC pressure from array allocations.
@ -155,20 +152,18 @@ export function buildAircraftModelLayers(
},
getColor: (d) => {
const base = altColors ? altitudeToColor(d.baroAltitude) : defaultColor;
return tintAircraftColor(base, d.category);
const catColor = tintAircraftColor(base, d.category);
return applySpecialTint(catColor, d.dbFlags, d.emergencyStatus);
},
scenegraph: modelUrl(modelKey),
getScale: (d) => {
const catScale = categorySizeMultiplier(d.category);
const s = catScale * normScale;
return [s, s, s];
getScale: () => {
return [normScale, normScale, normScale];
},
sizeScale: BASE_AIRCRAFT_SIZE,
updateTriggers: {
getPosition: [frameCounter, elevScale],
getOrientation: frameCounter,
getColor: [dataVersion, altColors],
getScale: dataVersion,
},
sizeMinPixels: AIRCRAFT_MIN_PIXELS,
sizeMaxPixels: AIRCRAFT_MAX_PIXELS,

View File

@ -82,23 +82,43 @@ export function modelUrl(key: AircraftModelKey): string {
// ── Per-Model Size Normalization ───────────────────────────────────────
//
// Factors normalize all models to a consistent visual base (~40 units).
// categorySizeMultiplier in aircraft-appearance.ts adds per-category scaling.
// Each factor combines TWO concerns:
//
// 1. **Mesh equalisation** — raw GLBs have wildly different coordinate
// scales. The base factor brings every silhouette to a common
// reference size (~40 internal units).
//
// 2. **Realistic wingspan proportion** — a √(wingspan / 36 m) multiplier
// derived from real ICAO Doc 8643 representative wingspans. Square-
// root compression keeps the visual range manageable (~4×) while
// preserving clear differentiation between light GA, business jets,
// narrowbodies, widebodies, and the A380.
//
// Representative wingspans used (metres):
// light-prop 11 (C172) fighter 11 (F-16 / Eurofighter)
// helicopter 11 (H145 rotor) glider 18 (ASG 29)
// bizjet 24 (avg G550/CX)regional 25 (CRJ-900 / E175)
// turboprop 27 (ATR 72) drone 5 (small UAV)
// narrowbody 36 (A320) B737 36 (B737-800)
// widebody-2 65 (B777-300ER) widebody-4 64 (B747-400)
// A380 80 (A380-800)
//
// Formula per key: meshEqualise × √(wingspan / 36)
const MODEL_NORMALIZE: Readonly<Record<AircraftModelKey, number>> = {
a380: 0.42,
a380: 0.46,
b737: 0.55,
narrowbody: 1.0,
"widebody-2eng": 0.85,
"widebody-4eng": 0.42,
"regional-jet": 1.0,
"light-prop": 2.8,
turboprop: 0.9,
helicopter: 2.2,
bizjet: 2.2,
glider: 2.0,
fighter: 2.8,
drone: 2.8,
"widebody-2eng": 0.89,
"widebody-4eng": 0.44,
"regional-jet": 0.85,
"light-prop": 1.86,
turboprop: 0.8,
helicopter: 1.44,
bizjet: 1.73,
glider: 1.56,
fighter: 1.86,
drone: 1.43,
generic: 1.0,
};

View File

@ -15,7 +15,7 @@ const DEFAULT_PITCH = 49;
const DEFAULT_BEARING = 27.4;
const FOLLOW_ZOOM = 10.5;
const FOLLOW_PITCH = 55;
const FOLLOW_EASE_MS = 1200;
const FOLLOW_EASE_MS = 2000;
type FpvPosition = { lng: number; lat: number; alt: number; track: number };

View File

@ -3,8 +3,10 @@ import type { TrailEntry } from "@/hooks/use-trail-history";
import { snapLngToReference, unwrapLngPath } from "@/lib/geo";
import {
removeSpikePoints,
removeDistanceOutliers,
roundSharpCorners3D,
catmullRomSpline3D,
removePathLoops,
} from "@/lib/trail-smoothing";
import type { ElevatedPoint, Snapshot } from "./flight-layer-constants";
import {
@ -19,10 +21,11 @@ import {
export function buildStartupFallbackTrail(f: FlightState): [number, number][] {
if (f.longitude == null || f.latitude == null) return [];
const heading =
((Number.isFinite(f.trueTrack) ? f.trueTrack! : 0) * Math.PI) / 180;
const speed =
Number.isFinite(f.velocity) && f.velocity! > 0 ? f.velocity! : 200;
if (f.trueTrack == null || !Number.isFinite(f.trueTrack)) return [];
if (f.velocity == null || !Number.isFinite(f.velocity) || f.velocity <= 0)
return [];
const heading = (f.trueTrack * Math.PI) / 180;
const speed = f.velocity;
const degPerSecond = speed / 111_320;
const path: [number, number][] = [];
@ -168,19 +171,43 @@ export function smoothElevatedPath(
// ── Altitude Smoothing ─────────────────────────────────────────────────
/**
* Multi-pass altitude smoothing with a wider kernel to prevent
* near-vertical "wall" artifacts on climb/descent trails.
* The wider kernel (0.3/0.4/0.3) and multiple passes spread steep
* altitude transitions over more trail points, producing a gradual
* climb/descent gradient that looks natural with elevation exaggeration.
* Multi-pass altitude smoothing with outlier pre-filtering and a wider
* kernel to prevent near-vertical "wall" artifacts on climb/descent trails.
*/
export function smoothAnimationAltitudes(
values: number[],
passes: number = 3,
): number[] {
if (values.length < 3 || passes <= 0) return values;
if (values.length < 2 || passes <= 0) return values;
let result = values;
// For 2 points, apply a gentle blend toward the mean to reduce the
// visual snap when the 3rd point arrives and full smoothing kicks in.
if (values.length === 2) {
const mean = (values[0] + values[1]) * 0.5;
return [values[0] * 0.85 + mean * 0.15, values[1] * 0.85 + mean * 0.15];
}
// Pre-pass: reject altitude spikes (>800m from local median).
const SPIKE_THRESHOLD = 800;
let result = [...values];
if (result.length >= 5) {
for (let i = 2; i < result.length - 2; i++) {
const window = [
result[i - 2],
result[i - 1],
result[i],
result[i + 1],
result[i + 2],
];
const sorted = [...window].sort((a, b) => a - b);
const med = sorted[2];
if (Math.abs(result[i] - med) > SPIKE_THRESHOLD) {
result[i] = (result[i - 1] + result[i + 1]) / 2;
}
}
}
// Main smoothing passes
for (let p = 0; p < passes; p++) {
const next = [...result];
for (let i = 1; i < result.length - 1; i++) {
@ -204,7 +231,12 @@ export function trimPathAheadOfAircraft(
let bestIndex = points.length - 2;
let bestDistanceSq = Number.POSITIVE_INFINITY;
const searchStart = Math.max(0, Math.floor(points.length * 0.9));
// Search only the last 15% (min 12) to prevent clip-point jump-backs.
const searchStart = Math.max(
0,
points.length - Math.max(12, Math.ceil(points.length * 0.15)),
);
for (let i = searchStart; i < points.length - 1; i++) {
const a = points[i];
@ -249,7 +281,8 @@ export function trimPathAheadOfAircraft(
const dot = hLen > 1e-10 ? (hdx * dx + hdy * dy) / (hLen * dist) : 0;
// Scale lever by alignment: 0 when perpendicular/behind (no loop),
// up to 0.4 when heading straight at the aircraft (smooth arc).
const lever = Math.max(0, dot) * 0.4;
const lever =
Math.max(0, dot) * Math.min(0.3, 0.4 * Math.min(1, dist / 0.01));
const ux = hLen > 1e-10 ? hdx / hLen : 0;
const uy = hLen > 1e-10 ? hdy / hLen : 0;
const cx = lastPt[0] + ux * dist * lever;
@ -373,18 +406,28 @@ export function buildTrailBasePath(
Math.max(0, altitudeMeters[i] ?? trail.baroAltitude ?? 0),
] as ElevatedPoint,
);
return elevated.length >= 3 ? roundSharpCorners3D(elevated, 15) : elevated;
if (elevated.length >= 3) {
const rounded = roundSharpCorners3D(elevated, 15);
return removePathLoops(rounded);
}
return elevated;
}
// Active trails: remove GPS glitches (V-spikes), smooth positions to
// reduce measurement noise, smooth altitudes, then apply Catmull-Rom
// spline for consistent visual smoothness with historical trails.
const spikeResult = removeSpikePoints(pathSlice, altitudeSlice);
// Active trails: remove GPS glitches (distance outliers + V-spikes),
// smooth positions to reduce measurement noise, smooth altitudes, then
// apply Catmull-Rom spline for consistent visual smoothness.
// Pre-smooth 2D positions: 5 passes of a 0.25/0.5/0.25 kernel removes
// GPS measurement jitter (~10-20m noise) while preserving the overall
// path shape. Without this, the interpolating Catmull-Rom spline would
// amplify noise into visible oscillations between control points.
// Step 1: Remove distance outliers — catches random GPS/MLAT points
// that deviate far from the local path trend.
const outlierResult = removeDistanceOutliers(pathSlice, altitudeSlice, 3.0);
// Step 2: Remove V-shaped direction-reversal spikes.
const spikeResult = removeSpikePoints(
outlierResult.path,
outlierResult.altitudes,
);
// Pre-smooth 2D positions to reduce GPS jitter before spline interpolation.
let smoothedPath = spikeResult.path;
if (smoothedPath.length >= 3) {
for (let pass = 0; pass < 5; pass++) {
@ -416,12 +459,10 @@ export function buildTrailBasePath(
]);
if (elevated.length >= 2) {
// Round sharp corners (>15° heading change) before spline to remove
// GPS-noise kinks and tight arcs at genuine turns.
const rounded = roundSharpCorners3D(elevated, 15);
// Moderate density (5-14 pts/seg) produces smooth curves without
// the point bloat that higher density would cause across 200+ trails.
return catmullRomSpline3D(rounded, 5, 14);
const splined = catmullRomSpline3D(rounded, 5, 14);
// Remove self-intersecting loops from spline overshoot.
return removePathLoops(splined);
}
return elevated;
}

View File

@ -10,7 +10,6 @@ import {
} from "./flight-layer-constants";
import {
PULSE_PERIOD_MS,
RING_PERIOD_MS,
HALO_MAPPING,
RING_MAPPING,
} from "./aircraft-appearance";
@ -71,7 +70,8 @@ function limitTrailSlope(
return pts.map((p, i) => {
// Preserve endpoints so trail connects to aircraft and origin
if (i === 0 || i === n - 1) return p;
return [p[0], p[1], Math.max(0, (fwd[i] + bwd[i]) / 2)];
const avg = (fwd[i] + bwd[i]) / 2;
return [p[0], p[1], Math.max(0, Number.isFinite(avg) ? avg : p[2])];
});
}
@ -266,7 +266,15 @@ export function buildTrailLayers(params: TrailLayerParams) {
),
] as [number, number, number],
);
const result = limitTrailSlope(raw);
// Final NaN defense: filter out any invalid coordinates before
// passing to PathLayer to prevent WebGL rendering errors.
const clean = raw.filter(
(p) =>
Number.isFinite(p[0]) &&
Number.isFinite(p[1]) &&
Number.isFinite(p[2]),
);
const result = limitTrailSlope(clean);
trailPathCache?.set(d.icao24, { key: pathKey, result });
return result;
},
@ -275,7 +283,9 @@ export function buildTrailLayers(params: TrailLayerParams) {
const visiblePoints = getVisibleTrailPoints(d, animFlight);
const len = visiblePoints.length;
const colorKey = `${len}_${altColors}_${d.fullHistory ?? false}_${d.baroAltitude != null ? Math.round(d.baroAltitude / 200) : "n"}`;
// Use floor with a 500m bucket to avoid cache key flicker at
// round-number boundaries (Math.round toggles at exact midpoints).
const colorKey = `${len}_${altColors}_${d.fullHistory ?? false}_${d.baroAltitude != null ? Math.floor(d.baroAltitude / 500) : "n"}`;
if (trailColorCache) {
const cached = trailColorCache.get(d.icao24);
if (cached && cached.key === colorKey) return cached.result;
@ -361,7 +371,7 @@ export function buildSelectionPulseLayers(
}
// Build stable layers for both "sel" and "prev" prefixes.
// Always emit all 8 IDs; use `visible` to toggle rather than omitting layers.
// Always emit all 4 IDs; use `visible` to toggle rather than omitting layers.
const prefixes = ["sel", "prev"] as const;
for (const prefix of prefixes) {
const isSelected = prefix === "sel";
@ -387,8 +397,11 @@ export function buildSelectionPulseLayers(
const breath = Math.sin(breathT * Math.PI * 2);
const softBreath = smoothStep(smoothStep((breath + 1) / 2)) * 2 - 1;
const haloSize = 90 + 10 * softBreath;
const haloAlpha = Math.round((22 + 10 * softBreath) * op);
// Subtle background glow — barely visible, provides soft ambient light.
// At 86px with 40% clear center: clear zone = 17px radius, well outside
// the largest aircraft icon (~12px radius).
const haloSize = 86 + 3 * softBreath;
const haloAlpha = Math.round((10 + 4 * softBreath) * op);
layers.push(
new IconLayer({
@ -409,34 +422,34 @@ export function buildSelectionPulseLayers(
}),
);
const ringOffsets = [0, RING_PERIOD_MS / 3, (RING_PERIOD_MS * 2) / 3];
ringOffsets.forEach((offset, i) => {
const t = ((elapsed + offset) % RING_PERIOD_MS) / RING_PERIOD_MS;
const eased = 1 - (1 - t) ** 5;
const ringSize = 35 + 70 * eased;
const fade = 1 - t;
const ringAlpha = Math.round(80 * fade * fade * fade * fade * op);
// Single clean ring that gently breathes in size and opacity.
// No expansion animation — just a calm, static indicator.
// At 68px, ring inner edge = 0.57 * 34 = 19px — clears the aircraft.
const ringBreathT =
((elapsed + PULSE_PERIOD_MS * 0.25) % PULSE_PERIOD_MS) / PULSE_PERIOD_MS;
const ringBreath = Math.sin(ringBreathT * Math.PI * 2);
const softRingBreath = smoothStep(smoothStep((ringBreath + 1) / 2)) * 2 - 1;
const ringSize = 68 + 3 * softRingBreath;
const ringAlpha = Math.round((30 + 10 * softRingBreath) * op);
layers.push(
new IconLayer({
id: `${prefix}-ring-${i}`,
pickable: false,
visible: active && ringAlpha >= 2,
data,
opacity: globeFade,
getPosition: (d: { position: [number, number, number] }) =>
d.position,
getIcon: () => "ring",
getSize: ringSize,
getColor: [70, 165, 235, ringAlpha],
iconAtlas: ringUrl,
iconMapping: RING_MAPPING,
billboard: true,
sizeUnits: "pixels",
sizeScale: 1,
}),
);
});
layers.push(
new IconLayer({
id: `${prefix}-ring-0`,
pickable: false,
visible: active && ringAlpha >= 2,
data,
opacity: globeFade,
getPosition: (d: { position: [number, number, number] }) => d.position,
getIcon: () => "ring",
getSize: ringSize,
getColor: [70, 165, 235, ringAlpha],
iconAtlas: ringUrl,
iconMapping: RING_MAPPING,
billboard: true,
sizeUnits: "pixels",
sizeScale: 1,
}),
);
}
return { layers, shouldClearPrev };

View File

@ -32,6 +32,7 @@ import {
import {
categorySizeMultiplier,
tintAircraftColor,
applySpecialTint,
AIRCRAFT_ICON_MAPPING,
getHaloUrl,
getRingUrl,
@ -691,7 +692,7 @@ export function FlightLayers({
// Selection pulse layers (halo + rings) — skip entirely when
// nothing is selected and no fade-out is in progress. Saves
// constructing 8 IconLayer objects + deck.gl diffing per frame.
// constructing 4 IconLayer objects + deck.gl diffing per frame.
if (selectedIcao24Ref.current || prevSelectedRef.current) {
const pulseResult = buildSelectionPulseLayers({
selectionChangeTime: selectionChangeTimeRef.current,
@ -763,7 +764,8 @@ export function FlightLayers({
const base = altColors
? altitudeToColor(d.baroAltitude)
: DEFAULT_COLOR;
return tintAircraftColor(base, d.category);
const catColor = tintAircraftColor(base, d.category);
return applySpecialTint(catColor, d.dbFlags, d.emergencyStatus);
},
getAngle: (d) =>
360 - (Number.isFinite(d.trueTrack) ? d.trueTrack! : 0),

View File

@ -0,0 +1,55 @@
"use client";
import { useEffect, useRef } from "react";
import { useMap } from "@/components/map/map";
export type MapViewState = {
zoom: number;
center: { lat: number; lng: number };
};
type MapStateTrackerProps = {
/** Mutable ref updated on every moveend — avoids re-renders. */
stateRef: React.MutableRefObject<MapViewState>;
/** Optional callback on state change (throttled internally). */
onChange?: (state: MapViewState) => void;
};
/**
* Invisible component that sits inside <MapView> and tracks zoom + center.
* Updates a parent-owned ref (zero re-renders) and optionally calls onChange.
*/
export function MapStateTracker({ stateRef, onChange }: MapStateTrackerProps) {
const { map, isLoaded } = useMap();
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
useEffect(() => {
if (!map || !isLoaded) return;
function update() {
if (!map) return;
const center = map.getCenter();
const zoom = map.getZoom();
const next: MapViewState = {
zoom,
center: { lat: center.lat, lng: center.lng },
};
stateRef.current = next;
onChangeRef.current?.(next);
}
// Seed initial state
update();
map.on("moveend", update);
map.on("zoomend", update);
return () => {
map.off("moveend", update);
map.off("zoomend", update);
};
}, [map, isLoaded, stateRef]);
return null;
}

View File

@ -20,11 +20,11 @@ const DEFAULT_PITCH = 49;
const DEFAULT_BEARING = 27.4;
const FPV_FLY_DURATION = 1600;
const FPV_PITCH = 65;
const FPV_CENTER_ALPHA = 0.16;
const FPV_BEARING_ALPHA = 0.1;
const FPV_ZOOM_ALPHA = 0.06;
const FPV_CENTER_ALPHA = 0.09;
const FPV_BEARING_ALPHA = 0.06;
const FPV_ZOOM_ALPHA = 0.03;
const FPV_IDLE_RECENTER_MS = 1200;
const FPV_EASE_IN_MS = 600;
const FPV_EASE_IN_MS = 1000;
type FpvPosition = { lng: number; lat: number; alt: number; track: number };
@ -201,8 +201,10 @@ export function useFpvCamera(
const liveBearing =
posTrack !== null && Number.isFinite(posTrack) ? posTrack : prevBearing;
// Update prevBearing to track live heading (used as fallback when
// tracking strength is zero and for tab-resume reset).
const bearingDelta = ((liveBearing - prevBearing + 540) % 360) - 180;
prevBearing = prevBearing + bearingDelta * FPV_BEARING_ALPHA;
prevBearing = prevBearing + bearingDelta * 0.15;
if (trackingStrength > 0.001) {
const safeAlt = Number.isFinite(posAlt) ? posAlt : 5000;
@ -232,26 +234,28 @@ export function useFpvCamera(
if (deltaPx) {
const desiredX = fpvOffsetX - deltaPx.dx;
const desiredY = fpvOffsetY - deltaPx.dy;
const offsetAlpha = 0.08 * trackingStrength;
const offsetAlpha = 0.05 * trackingStrength;
fpvOffsetX = lerp(fpvOffsetX, desiredX, offsetAlpha);
fpvOffsetY = lerp(fpvOffsetY, desiredY, offsetAlpha);
} else {
const decayAlpha = 0.1 * trackingStrength;
const decayAlpha = 0.06 * trackingStrength;
fpvOffsetX = lerp(fpvOffsetX, 0, decayAlpha);
fpvOffsetY = lerp(fpvOffsetY, 0, decayAlpha);
}
const maxScale = Math.min(1.5, Math.max(1, elevationMeters / 15_000));
const maxOffset = 0.45 * maxScale * Math.min(canvasW, canvasH);
const maxOffset = 0.25 * maxScale * Math.min(canvasW, canvasH);
fpvOffsetX = Math.max(-maxOffset, Math.min(maxOffset, fpvOffsetX));
fpvOffsetY = Math.max(-maxOffset, Math.min(maxOffset, fpvOffsetY));
// Single-level bearing interpolation — lerp map bearing directly
// toward the live heading. Avoids the double-smoothing oscillation
// that occurred when prevBearing was intermediated separately.
const currentBearing = map.getBearing();
const bearingToCurrent =
((prevBearing - currentBearing + 540) % 360) - 180;
const bearingToLive =
((liveBearing - currentBearing + 540) % 360) - 180;
const newMapBearing =
currentBearing +
bearingToCurrent * FPV_BEARING_ALPHA * trackingStrength;
currentBearing + bearingToLive * FPV_BEARING_ALPHA * trackingStrength;
const pitchAlpha = 0.05 * trackingStrength;
const newPitch = lerp(currentPitch, FPV_PITCH, pitchAlpha);

View File

@ -0,0 +1,176 @@
"use client";
import { useEffect, useRef, useCallback } from "react";
import { useMap } from "./map";
const RAINVIEWER_API = "https://api.rainviewer.com/public/weather-maps.json";
const REFRESH_INTERVAL_MS = 10 * 60_000; // 10 minutes
const SOURCE_ID = "rainviewer-radar";
const LAYER_ID = "rainviewer-radar-layer";
// RainViewer tiles are only available up to zoom level 7.
// MapLibre will over-zoom level 7 tiles for higher zoom levels.
const RAINVIEWER_MAX_ZOOM = 7;
/** Build tile URL via our server proxy (avoids CORS issues with RainViewer). */
function proxyTileUrl(timestamp: number): string {
return `/api/weather-tiles?ts=${timestamp}&z={z}&x={x}&y={y}`;
}
type RainViewerFrame = { time: number; path: string };
type RainViewerResponse = {
host: string;
radar: { past: RainViewerFrame[] };
};
type WeatherRadarLayerProps = {
visible: boolean;
opacity: number;
};
export function WeatherRadarLayer({
visible,
opacity,
}: WeatherRadarLayerProps) {
const { map, isLoaded } = useMap();
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const currentTimeRef = useRef<number | null>(null);
const visibleRef = useRef(visible);
const opacityRef = useRef(opacity);
// Keep refs current without recreating callbacks
visibleRef.current = visible;
opacityRef.current = opacity;
const updateRadarTiles = useCallback(async () => {
if (!map) return;
try {
const res = await fetch(RAINVIEWER_API);
if (!res.ok) return;
const data: RainViewerResponse = await res.json();
const frames = data.radar?.past;
if (!frames || frames.length === 0) return;
const latest = frames[frames.length - 1];
// Skip if same frame already loaded AND the source still exists on the map
const sourceExists = !!map.getSource(SOURCE_ID);
if (currentTimeRef.current === latest.time && sourceExists) return;
currentTimeRef.current = latest.time;
const tileUrl = proxyTileUrl(latest.time);
const source = map.getSource(SOURCE_ID);
if (source && "setTiles" in source) {
(source as { setTiles: (tiles: string[]) => void }).setTiles([tileUrl]);
} else if (!source) {
map.addSource(SOURCE_ID, {
type: "raster",
tiles: [tileUrl],
tileSize: 256,
maxzoom: RAINVIEWER_MAX_ZOOM,
attribution: '© <a href="https://www.rainviewer.com/">RainViewer</a>',
});
// Insert below the first symbol layer so labels remain readable
const layers = map.getStyle()?.layers ?? [];
const firstSymbol = layers.find((l) => l.type === "symbol");
map.addLayer(
{
id: LAYER_ID,
type: "raster",
source: SOURCE_ID,
paint: {
"raster-opacity": visibleRef.current ? opacityRef.current : 0,
"raster-fade-duration": 300,
},
},
firstSymbol?.id,
);
}
} catch {
// Network failure — silently ignore, will retry next interval
}
}, [map]);
// Initial fetch + periodic refresh
useEffect(() => {
if (!map || !isLoaded || !visible) return;
updateRadarTiles();
intervalRef.current = setInterval(updateRadarTiles, REFRESH_INTERVAL_MS);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [map, isLoaded, visible, updateRadarTiles]);
// Toggle visibility and opacity
useEffect(() => {
if (!map || !isLoaded) return;
if (!map.getLayer(LAYER_ID)) return;
map.setPaintProperty(LAYER_ID, "raster-opacity", visible ? opacity : 0);
}, [map, isLoaded, visible, opacity]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (!map) return;
try {
if (map.getLayer(LAYER_ID)) map.removeLayer(LAYER_ID);
if (map.getSource(SOURCE_ID)) map.removeSource(SOURCE_ID);
} catch {
/* map may already be removed */
}
};
}, [map]);
// Re-add source/layer after style change (MapLibre removes custom layers on style swap)
useEffect(() => {
if (!map || !isLoaded) return;
const onStyleLoad = () => {
// Only re-add if we had a valid timestamp and source was removed by style swap
if (
currentTimeRef.current &&
!map.getSource(SOURCE_ID) &&
visibleRef.current
) {
const tileUrl = proxyTileUrl(currentTimeRef.current);
map.addSource(SOURCE_ID, {
type: "raster",
tiles: [tileUrl],
tileSize: 256,
maxzoom: RAINVIEWER_MAX_ZOOM,
attribution: '© <a href="https://www.rainviewer.com/">RainViewer</a>',
});
const layers = map.getStyle()?.layers ?? [];
const firstSymbol = layers.find((l) => l.type === "symbol");
map.addLayer(
{
id: LAYER_ID,
type: "raster",
source: SOURCE_ID,
paint: {
"raster-opacity": opacityRef.current,
"raster-fade-duration": 300,
},
},
firstSymbol?.id,
);
}
};
map.on("style.load", onStyleLoad);
return () => {
map.off("style.load", onStyleLoad);
};
}, [map, isLoaded]);
return null;
}

View File

@ -0,0 +1,11 @@
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View File

@ -54,13 +54,13 @@ const Thumbnail = memo(function Thumbnail({
ref={ref}
type="button"
onClick={() => onClick(index)}
className="group relative h-20 w-32 shrink-0 cursor-pointer overflow-hidden rounded-lg border border-white/8 bg-white/5 transition-all hover:border-white/20 hover:brightness-110 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-white/30"
className="group relative h-20 w-32 shrink-0 cursor-pointer overflow-hidden rounded-lg border border-foreground/8 bg-foreground/5 transition-all hover:border-foreground/20 hover:brightness-110 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30"
aria-label={`View photo ${index + 1}${photo.photographer ? ` by ${photo.photographer}` : ""}`}
>
{!loaded && (
<span
aria-hidden
className="absolute inset-0 animate-pulse bg-linear-to-br from-white/5 via-white/8 to-white/5"
className="absolute inset-0 animate-pulse bg-linear-to-br from-foreground/5 via-foreground/8 to-foreground/5"
/>
)}
{visible && (
@ -74,7 +74,7 @@ const Thumbnail = memo(function Thumbnail({
className={`h-full w-full object-cover transition-opacity duration-300 ${loaded ? "opacity-100" : "opacity-0"}`}
/>
)}
<span className="pointer-events-none absolute inset-0 rounded-lg ring-1 ring-inset ring-white/5 group-hover:ring-white/15" />
<span className="pointer-events-none absolute inset-0 rounded-lg ring-1 ring-inset ring-foreground/5 group-hover:ring-foreground/15" />
</button>
);
});
@ -129,7 +129,7 @@ export function Lightbox({
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="fixed inset-0 z-9999 flex items-center justify-center bg-black/92 backdrop-blur-2xl"
className="fixed inset-0 z-9999 flex items-center justify-center bg-background/92 backdrop-blur-2xl"
onClick={onClose}
role="dialog"
aria-modal="true"
@ -138,13 +138,13 @@ export function Lightbox({
<button
type="button"
onClick={onClose}
className="absolute right-3 top-3 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-white/80 backdrop-blur-sm transition-all duration-200 hover:bg-white/20 hover:text-white sm:right-6 sm:top-6 sm:h-12 sm:w-12"
className="absolute right-3 top-3 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-foreground/10 text-foreground/80 backdrop-blur-sm transition-all duration-200 hover:bg-foreground/20 hover:text-foreground sm:right-6 sm:top-6 sm:h-12 sm:w-12"
aria-label="Close photo viewer"
>
<X className="h-5 w-5 sm:h-6 sm:w-6" />
</button>
<span className="absolute left-3 top-3 z-10 rounded-full bg-white/10 px-4 py-2 text-sm font-semibold tabular-nums text-white/80 backdrop-blur-sm sm:left-6 sm:top-6 sm:px-5 sm:text-base">
<span className="absolute left-3 top-3 z-10 rounded-full bg-foreground/10 px-4 py-2 text-sm font-semibold tabular-nums text-foreground/80 backdrop-blur-sm sm:left-6 sm:top-6 sm:px-5 sm:text-base">
{index + 1} / {photos.length}
</span>
@ -158,14 +158,14 @@ export function Lightbox({
>
{!loaded && !imgError && (
<div className="flex h-48 w-72 items-center justify-center sm:h-64 sm:w-96">
<div className="h-9 w-9 animate-spin rounded-full border-2 border-white/20 border-t-white/60" />
<div className="h-9 w-9 animate-spin rounded-full border-2 border-foreground/20 border-t-foreground/60" />
</div>
)}
{imgError ? (
<div className="flex h-48 w-72 flex-col items-center justify-center gap-3 rounded-2xl border border-white/10 bg-white/5 sm:h-64 sm:w-96">
<Camera className="h-8 w-8 text-white/20" />
<p className="text-sm text-white/40">Failed to load image</p>
<div className="flex h-48 w-72 flex-col items-center justify-center gap-3 rounded-2xl border border-foreground/10 bg-foreground/5 sm:h-64 sm:w-96">
<Camera className="h-8 w-8 text-foreground/20" />
<p className="text-sm text-foreground/40">Failed to load image</p>
</div>
) : (
<img
@ -187,7 +187,7 @@ export function Lightbox({
e.stopPropagation();
goPrev();
}}
className="absolute left-2 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white/80 backdrop-blur-sm transition-all duration-200 hover:bg-white/25 hover:text-white sm:left-6 sm:h-14 sm:w-14"
className="absolute left-2 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-foreground/10 text-foreground/80 backdrop-blur-sm transition-all duration-200 hover:bg-foreground/25 hover:text-foreground sm:left-6 sm:h-14 sm:w-14"
aria-label="Previous photo"
>
<ChevronLeft className="h-6 w-6 sm:h-7 sm:w-7" />
@ -198,7 +198,7 @@ export function Lightbox({
e.stopPropagation();
goNext();
}}
className="absolute right-2 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white/80 backdrop-blur-sm transition-all duration-200 hover:bg-white/25 hover:text-white sm:right-6 sm:h-14 sm:w-14"
className="absolute right-2 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-foreground/10 text-foreground/80 backdrop-blur-sm transition-all duration-200 hover:bg-foreground/25 hover:text-foreground sm:right-6 sm:h-14 sm:w-14"
aria-label="Next photo"
>
<ChevronRight className="h-6 w-6 sm:h-7 sm:w-7" />
@ -216,34 +216,34 @@ export function Lightbox({
transition={{ duration: 0.3, delay: 0.15 }}
className="absolute bottom-3 left-1/2 z-10 w-[92vw] max-w-lg -translate-x-1/2 sm:bottom-8"
>
<span className="flex flex-wrap items-center justify-center gap-x-3 gap-y-1 rounded-xl bg-black/60 px-5 py-3 text-sm text-white/70 backdrop-blur-sm sm:text-base">
<span className="flex flex-wrap items-center justify-center gap-x-3 gap-y-1 rounded-xl bg-background/60 px-5 py-3 text-sm text-foreground/70 backdrop-blur-sm sm:text-base">
{photo.photographer && (
<span className="font-medium text-white/85">
<span className="font-medium text-foreground/85">
{photo.photographer}
</span>
)}
{photo.photographer && photo.location && (
<span className="text-white/25">|</span>
<span className="text-foreground/25">|</span>
)}
{photo.location && (
<span className="text-white/55">{photo.location}</span>
<span className="text-foreground/55">{photo.location}</span>
)}
{(photo.photographer || photo.location) && photo.dateTaken && (
<span className="text-white/25">|</span>
<span className="text-foreground/25">|</span>
)}
{photo.dateTaken && (
<span className="text-white/45">{photo.dateTaken}</span>
<span className="text-foreground/45">{photo.dateTaken}</span>
)}
{photo.link && (
<>
{(photo.photographer || photo.location || photo.dateTaken) && (
<span className="text-white/25">|</span>
<span className="text-foreground/25">|</span>
)}
<a
href={photo.link}
target="_blank"
rel="noopener noreferrer"
className="text-white/40 underline decoration-white/20 underline-offset-2 transition-colors hover:text-white/60"
className="text-foreground/40 underline decoration-foreground/20 underline-offset-2 transition-colors hover:text-foreground/60"
onClick={(e) => e.stopPropagation()}
>
Source
@ -323,12 +323,12 @@ export function AircraftPhotos({
if (aircraft?.airline && !detailParts.includes(aircraft.airline)) {
detailParts.push(aircraft.airline);
}
const detailLine = detailParts.join(" · ");
const detailLine = detailParts.join(" · ");
return (
<>
<div className="mt-3">
<div className="h-px bg-linear-to-r from-transparent via-white/6 to-transparent" />
<div className="h-px bg-linear-to-r from-transparent via-foreground/6 to-transparent" />
<button
type="button"
@ -337,22 +337,22 @@ export function AircraftPhotos({
aria-expanded={expanded}
aria-controls="aircraft-photo-strip"
>
<Camera className="h-3 w-3 text-white/25" />
<span className="text-[10px] font-medium tracking-wider text-white/30 uppercase">
{loading ? "Loading" : hasPhotos ? "Photos" : "Aircraft"}
<Camera className="h-3 w-3 text-foreground/25" />
<span className="text-[10px] font-medium tracking-wider text-foreground/30 uppercase">
{loading ? "Loading…" : hasPhotos ? "Photos" : "Aircraft"}
</span>
{hasPhotos && (
<span className="text-[10px] tabular-nums text-white/20">
<span className="text-[10px] tabular-nums text-foreground/20">
({photos.length})
</span>
)}
{aircraft?.registration && (
<span className="ml-auto text-[10px] font-mono tracking-wider text-white/20">
<span className="ml-auto text-[10px] font-mono tracking-wider text-foreground/20">
{aircraft.registration}
</span>
)}
<ChevronRight
className={`h-2.5 w-2.5 text-white/20 transition-transform duration-200 ${expanded ? "rotate-90" : ""}`}
className={`h-2.5 w-2.5 text-foreground/20 transition-transform duration-200 ${expanded ? "rotate-90" : ""}`}
/>
</button>
@ -371,7 +371,7 @@ export function AircraftPhotos({
{[0, 1, 2].map((i) => (
<div
key={i}
className="h-20 w-32 shrink-0 animate-pulse rounded-lg bg-white/5"
className="h-20 w-32 shrink-0 animate-pulse rounded-lg bg-foreground/5"
/>
))}
</div>
@ -395,7 +395,7 @@ export function AircraftPhotos({
<button
type="button"
onClick={() => setShowAllPhotos(true)}
className="flex h-20 w-20 shrink-0 flex-col items-center justify-center gap-0.5 rounded-lg border border-white/8 bg-white/5 text-white/40 transition-all hover:border-white/20 hover:bg-white/8 hover:text-white/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-white/30"
className="flex h-20 w-20 shrink-0 flex-col items-center justify-center gap-0.5 rounded-lg border border-foreground/8 bg-foreground/5 text-foreground/40 transition-all hover:border-foreground/20 hover:bg-foreground/8 hover:text-foreground/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30"
aria-label={`Show ${hiddenCount} more photo${hiddenCount === 1 ? "" : "s"}`}
>
<Plus className="h-3.5 w-3.5" />
@ -408,15 +408,15 @@ export function AircraftPhotos({
)}
{!loading && !hasPhotos && hasAircraft && (
<div className="mt-2 flex items-center gap-2 rounded-lg border border-white/6 bg-white/2 px-3 py-2.5">
<Plane className="h-3.5 w-3.5 shrink-0 text-white/20" />
<div className="mt-2 flex items-center gap-2 rounded-lg border border-foreground/6 bg-foreground/2 px-3 py-2.5">
<Plane className="h-3.5 w-3.5 shrink-0 text-foreground/20" />
<div className="min-w-0 flex-1">
{detailLine && (
<p className="truncate text-[11px] font-medium text-white/45">
<p className="truncate text-[11px] font-medium text-foreground/45">
{detailLine}
</p>
)}
<p className="mt-0.5 flex items-center gap-1 text-[10px] text-white/25">
<p className="mt-0.5 flex items-center gap-1 text-[10px] text-foreground/25">
<ImageOff className="h-2.5 w-2.5" />
No photos available
</p>
@ -426,8 +426,8 @@ export function AircraftPhotos({
{!loading && !hasPhotos && !hasAircraft && error && (
<div className="mt-2 flex items-center gap-2 px-1 py-1.5">
<ImageOff className="h-3 w-3 text-white/15" />
<p className="text-[10px] text-white/25">
<ImageOff className="h-3 w-3 text-foreground/15" />
<p className="text-[10px] text-foreground/25">
Could not load aircraft data
</p>
</div>

View File

@ -0,0 +1,626 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { motion, AnimatePresence } from "motion/react";
import {
PlaneLanding,
PlaneTakeoff,
X,
ChevronDown,
ArrowDown,
ArrowUp,
} from "lucide-react";
import type { BoardFlight, AirportBoardData } from "@/hooks/use-airport-board";
// ── Types ──────────────────────────────────────────────────────────────
type AirportBoardProps = {
data: AirportBoardData;
onSelectFlight: (icao24: string) => void;
selectedIcao24: string | null;
onClose: () => void;
};
type Tab = "arrivals" | "departures";
// ── Shared spring config ───────────────────────────────────────────────
const SPRING = {
type: "spring" as const,
stiffness: 500,
damping: 35,
mass: 0.7,
};
const SPRING_GENTLE = {
type: "spring" as const,
stiffness: 300,
damping: 28,
mass: 0.8,
};
// ── Status styling ─────────────────────────────────────────────────────
function statusStyle(status: string): {
text: string;
dot: string;
glow: string;
} {
switch (status) {
case "Final":
return {
text: "text-emerald-400",
dot: "bg-emerald-400",
glow: "shadow-emerald-400/20",
};
case "Approach":
return {
text: "text-emerald-400/80",
dot: "bg-emerald-400/80",
glow: "",
};
case "Inbound":
return { text: "text-teal-400/70", dot: "bg-teal-400/70", glow: "" };
case "Descending":
return {
text: "text-emerald-400/60",
dot: "bg-emerald-400/60",
glow: "",
};
case "Departure":
return {
text: "text-amber-400",
dot: "bg-amber-400",
glow: "shadow-amber-400/20",
};
case "Climbing":
return {
text: "text-amber-400/80",
dot: "bg-amber-400/80",
glow: "",
};
case "Outbound":
return {
text: "text-orange-400/70",
dot: "bg-orange-400/70",
glow: "",
};
default:
return { text: "text-foreground/30", dot: "bg-foreground/20", glow: "" };
}
}
// ── Vertical rate ──────────────────────────────────────────────────────
function VRate({ rate }: { rate: number | null }) {
if (rate === null || !Number.isFinite(rate)) {
return <span className="text-xs text-foreground/15"></span>;
}
const fpm = Math.round(rate * 196.85);
if (Math.abs(fpm) < 100) {
return (
<span className="inline-flex items-center gap-0.5 text-xs text-foreground/20">
<span className="inline-block h-px w-3 bg-foreground/15" />
</span>
);
}
const isDown = fpm < 0;
return (
<span
className={`inline-flex items-center gap-0.5 font-mono text-xs tabular-nums ${
isDown ? "text-emerald-400/60" : "text-amber-400/60"
}`}
>
{isDown ? (
<ArrowDown className="h-3 w-3" />
) : (
<ArrowUp className="h-3 w-3" />
)}
<span>{Math.abs(fpm).toLocaleString()}</span>
</span>
);
}
// ── Flight row ─────────────────────────────────────────────────────────
function FlightRow({
flight,
isSelected,
onSelect,
index,
}: {
flight: BoardFlight;
isSelected: boolean;
onSelect: () => void;
index: number;
}) {
const style = statusStyle(flight.status);
const isUrgent = flight.status === "Final" || flight.status === "Departure";
return (
<motion.button
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97 }}
transition={{
duration: 0.2,
ease: [0.25, 0.1, 0.25, 1],
delay: Math.min(index * 0.02, 0.15),
}}
onClick={onSelect}
className={`group relative flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left transition-all duration-200 ${
isSelected
? "bg-foreground/8 shadow-sm shadow-foreground/5"
: "hover:bg-foreground/4 active:bg-foreground/6"
}`}
aria-label={`${flight.callsign}${flight.status}, ${flight.altitude}, ${flight.distanceFormatted}`}
>
{/* Selected indicator bar */}
{isSelected && (
<motion.div
initial={{ scaleY: 0 }}
animate={{ scaleY: 1 }}
className="absolute left-0.5 top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-full bg-foreground/50"
transition={{ duration: 0.2, ease: [0.25, 0.1, 0.25, 1] }}
/>
)}
{/* Status dot */}
<div className="flex w-3 shrink-0 items-center justify-center">
<span
className={`block h-1.5 w-1.5 rounded-full ${style.dot} ${
isUrgent ? "animate-pulse" : ""
} ${style.glow ? `shadow-md ${style.glow}` : ""}`}
/>
</div>
{/* Callsign — primary info, largest text */}
<span className="w-16 shrink-0 truncate font-mono text-[13px] font-semibold tracking-wide text-foreground/90">
{flight.callsign}
</span>
{/* Status badge */}
<span
className={`w-16 shrink-0 text-[11px] font-semibold uppercase tracking-wide ${style.text}`}
>
{flight.status}
</span>
{/* Altitude */}
<span className="w-16 shrink-0 text-right font-mono text-[12px] tabular-nums text-foreground/50 transition-colors group-hover:text-foreground/70">
{flight.altitude}
</span>
{/* V/S — hidden on small screens */}
<span className="hidden w-14 shrink-0 justify-end sm:flex">
<VRate rate={flight.verticalRate} />
</span>
{/* Distance */}
<span className="ml-auto w-14 shrink-0 text-right font-mono text-[12px] tabular-nums text-foreground/40 transition-colors group-hover:text-foreground/60">
{flight.distanceFormatted}
</span>
</motion.button>
);
}
// ── Table header ───────────────────────────────────────────────────────
function TableHead() {
return (
<div className="flex items-center gap-3 px-3 pb-1.5 pt-1">
{/* dot spacer */}
<span className="w-3 shrink-0" />
<span className="w-16 shrink-0 text-[10px] font-medium uppercase tracking-widest text-foreground/20">
Flight
</span>
<span className="w-16 shrink-0 text-[10px] font-medium uppercase tracking-widest text-foreground/20">
Status
</span>
<span className="w-16 shrink-0 text-right text-[10px] font-medium uppercase tracking-widest text-foreground/20">
Alt
</span>
<span className="hidden w-14 shrink-0 text-right text-[10px] font-medium uppercase tracking-widest text-foreground/20 sm:block">
V/S
</span>
<span className="ml-auto w-14 shrink-0 text-right text-[10px] font-medium uppercase tracking-widest text-foreground/20">
Dist
</span>
</div>
);
}
// ── Flight list ────────────────────────────────────────────────────────
function FlightList({
flights,
selectedIcao24,
onSelectFlight,
emptyMessage,
}: {
flights: BoardFlight[];
selectedIcao24: string | null;
onSelectFlight: (icao24: string) => void;
emptyMessage?: string;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const prevCountRef = useRef(flights.length);
// Auto-scroll to top when new flights appear at the top (closer distance)
useEffect(() => {
if (flights.length > prevCountRef.current && scrollRef.current) {
scrollRef.current.scrollTo({ top: 0, behavior: "smooth" });
}
prevCountRef.current = flights.length;
}, [flights.length]);
if (flights.length === 0) {
return (
<div className="flex h-24 items-center justify-center">
<span className="text-[12px] font-medium text-foreground/20">
{emptyMessage ?? "No flights"}
</span>
</div>
);
}
return (
<>
<TableHead />
<div
ref={scrollRef}
className="scrollbar-none max-h-72 overflow-y-auto overscroll-contain"
>
<AnimatePresence initial={false}>
{flights.map((f, i) => (
<FlightRow
key={f.icao24}
flight={f}
isSelected={f.icao24 === selectedIcao24}
onSelect={() => onSelectFlight(f.icao24)}
index={i}
/>
))}
</AnimatePresence>
</div>
{/* Fade bottom edge for scroll overflow */}
{flights.length > 5 && (
<div className="pointer-events-none h-4 bg-linear-to-t from-black/40 to-transparent" />
)}
</>
);
}
// ── Empty state ────────────────────────────────────────────────────────
function EmptyState() {
return (
<motion.div
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={SPRING_GENTLE}
className="flex h-32 flex-col items-center justify-center gap-3"
>
<div className="flex items-center gap-4 text-foreground/8">
<PlaneLanding className="h-5 w-5" />
<div className="h-4 w-px bg-foreground/6" />
<PlaneTakeoff className="h-5 w-5" />
</div>
<span className="text-[11px] font-medium tracking-wide text-foreground/18">
No air traffic nearby
</span>
</motion.div>
);
}
// ── Segmented Control ──────────────────────────────────────────────────
function SegmentedControl({
activeTab,
onTabChange,
arrivalsCount,
departuresCount,
}: {
activeTab: Tab;
onTabChange: (tab: Tab) => void;
arrivalsCount: number;
departuresCount: number;
}) {
return (
<div className="mx-3.5 mt-3 mb-1.5">
<div className="relative flex h-9 rounded-xl bg-foreground/4 p-0.5">
{/* Animated pill background */}
<motion.div
className="absolute top-0.5 bottom-0.5 rounded-[10px]"
animate={{
left: activeTab === "arrivals" ? "2px" : "50%",
right: activeTab === "arrivals" ? "50%" : "2px",
}}
transition={SPRING}
style={{
background:
activeTab === "arrivals"
? "rgba(52, 211, 153, 0.10)"
: "rgba(251, 191, 36, 0.10)",
border:
activeTab === "arrivals"
? "1px solid rgba(52, 211, 153, 0.12)"
: "1px solid rgba(251, 191, 36, 0.12)",
}}
/>
<button
onClick={() => onTabChange("arrivals")}
className={`relative z-10 flex flex-1 items-center justify-center gap-1.5 rounded-[10px] text-[12px] font-semibold tracking-wide transition-colors duration-200 ${
activeTab === "arrivals"
? "text-emerald-400/90"
: "text-foreground/30 hover:text-foreground/45"
}`}
>
<PlaneLanding className="h-3.5 w-3.5" />
<span>Arrivals</span>
<span
className={`ml-0.5 rounded-full px-1.5 py-px font-mono text-[10px] tabular-nums ${
activeTab === "arrivals"
? "bg-emerald-400/10 text-emerald-400/70"
: "bg-foreground/4 text-foreground/20"
}`}
>
{arrivalsCount}
</span>
</button>
<button
onClick={() => onTabChange("departures")}
className={`relative z-10 flex flex-1 items-center justify-center gap-1.5 rounded-[10px] text-[12px] font-semibold tracking-wide transition-colors duration-200 ${
activeTab === "departures"
? "text-amber-400/90"
: "text-foreground/30 hover:text-foreground/45"
}`}
>
<PlaneTakeoff className="h-3.5 w-3.5" />
<span>Departures</span>
<span
className={`ml-0.5 rounded-full px-1.5 py-px font-mono text-[10px] tabular-nums ${
activeTab === "departures"
? "bg-amber-400/10 text-amber-400/70"
: "bg-foreground/4 text-foreground/20"
}`}
>
{departuresCount}
</span>
</button>
</div>
</div>
);
}
// ── Main component ─────────────────────────────────────────────────────
export function AirportBoard({
data,
onSelectFlight,
selectedIcao24,
onClose,
}: AirportBoardProps) {
const [collapsed, setCollapsed] = useState(false);
const [activeTab, setActiveTab] = useState<Tab>("arrivals");
const prevAirportRef = useRef(data.airport?.iata);
const { arrivals, departures, airport, totalFlights } = data;
// Reset collapsed state when switching airports
useEffect(() => {
if (airport?.iata !== prevAirportRef.current) {
prevAirportRef.current = airport?.iata;
setCollapsed(false);
setActiveTab("arrivals");
}
}, [airport?.iata]);
// Smart tab management: auto-switch only when current tab empties
useEffect(() => {
if (
activeTab === "arrivals" &&
arrivals.length === 0 &&
departures.length > 0
) {
setActiveTab("departures");
} else if (
activeTab === "departures" &&
departures.length === 0 &&
arrivals.length > 0
) {
setActiveTab("arrivals");
}
}, [arrivals.length, departures.length, activeTab]);
const handleToggleCollapse = useCallback(() => {
setCollapsed((c) => !c);
}, []);
const handleHeaderKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleToggleCollapse();
}
},
[handleToggleCollapse],
);
if (!airport) return null;
const currentFlights = activeTab === "arrivals" ? arrivals : departures;
return (
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 12, scale: 0.98 }}
transition={SPRING_GENTLE}
className="w-[min(calc(100vw-20px),440px)] sm:w-[min(calc(100vw-32px),580px)]"
>
<div
className="overflow-hidden rounded-2xl border border-foreground/6 shadow-2xl shadow-black/60 backdrop-blur-2xl"
style={{ backgroundColor: "rgb(var(--ui-bg) / 0.7)" }}
>
{/* ── Header ── */}
<div
role="button"
tabIndex={0}
onClick={handleToggleCollapse}
onKeyDown={handleHeaderKeyDown}
className="flex w-full cursor-pointer select-none items-center justify-between px-4 py-3 transition-colors duration-150 hover:bg-foreground/2 active:bg-foreground/4"
>
<div className="flex items-center gap-3 overflow-hidden">
{/* Live pulse */}
<div className="relative flex h-4 w-4 shrink-0 items-center justify-center">
<span className="absolute inline-flex h-2.5 w-2.5 animate-ping rounded-full bg-emerald-400/20 duration-[3s]" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-400/80 shadow-sm shadow-emerald-400/25" />
</div>
{/* Airport IATA + name */}
<div className="flex min-w-0 items-baseline gap-2.5">
<span className="shrink-0 font-mono text-[15px] font-bold tracking-wide text-foreground/90">
{airport.iata}
</span>
<span className="hidden min-w-0 max-w-44 truncate text-[12px] font-medium text-foreground/30 sm:inline">
{airport.name}
</span>
</div>
{/* Flight count badge */}
<span className="shrink-0 rounded-full bg-foreground/4 px-2.5 py-0.5 font-mono text-[10px] tabular-nums text-foreground/30">
{totalFlights} {totalFlights === 1 ? "flight" : "flights"}
</span>
</div>
<div className="flex shrink-0 items-center gap-1.5">
{/* Collapse indicator */}
<motion.div
animate={{ rotate: collapsed ? 0 : 180 }}
transition={{ duration: 0.2, ease: [0.25, 0.1, 0.25, 1] }}
className="flex h-5 w-5 items-center justify-center"
>
<ChevronDown className="h-3.5 w-3.5 text-foreground/20" />
</motion.div>
{/* Close button — outside of the role="button" div via stopPropagation */}
<button
onClick={(e) => {
e.stopPropagation();
onClose();
}}
onKeyDown={(e) => e.stopPropagation()}
className="flex h-6 w-6 items-center justify-center rounded-lg text-foreground/20 transition-all duration-150 hover:bg-foreground/6 hover:text-foreground/45 active:scale-95"
aria-label="Close airport board"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
{/* ── Body ── */}
<AnimatePresence initial={false}>
{!collapsed && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.28, ease: [0.25, 0.1, 0.25, 1] }}
className="overflow-hidden"
>
{/* Gradient divider */}
<div className="h-px bg-linear-to-r from-transparent via-foreground/6 to-transparent" />
{totalFlights === 0 ? (
<EmptyState />
) : (
<>
{/* ── Desktop: side-by-side columns ── */}
<div className="hidden sm:block">
<SegmentedControl
activeTab={activeTab}
onTabChange={setActiveTab}
arrivalsCount={arrivals.length}
departuresCount={departures.length}
/>
<div className="px-1 pb-2.5 pt-1">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={activeTab}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{
duration: 0.15,
ease: [0.25, 0.1, 0.25, 1],
}}
>
<FlightList
flights={currentFlights}
selectedIcao24={selectedIcao24}
onSelectFlight={onSelectFlight}
emptyMessage={
activeTab === "arrivals"
? "No arriving flights"
: "No departing flights"
}
/>
</motion.div>
</AnimatePresence>
</div>
</div>
{/* ── Mobile: segmented control + single list ── */}
<div className="sm:hidden">
<SegmentedControl
activeTab={activeTab}
onTabChange={setActiveTab}
arrivalsCount={arrivals.length}
departuresCount={departures.length}
/>
<div className="px-1 pb-2.5 pt-1">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={activeTab}
initial={{
opacity: 0,
x: activeTab === "arrivals" ? -8 : 8,
}}
animate={{ opacity: 1, x: 0 }}
exit={{
opacity: 0,
x: activeTab === "arrivals" ? 8 : -8,
}}
transition={{
duration: 0.18,
ease: [0.25, 0.1, 0.25, 1],
}}
>
<FlightList
flights={currentFlights}
selectedIcao24={selectedIcao24}
onSelectFlight={onSelectFlight}
emptyMessage={
activeTab === "arrivals"
? "No arriving flights"
: "No departing flights"
}
/>
</motion.div>
</AnimatePresence>
</div>
</div>
</>
)}
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
);
}

View File

@ -0,0 +1,415 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { motion, AnimatePresence } from "motion/react";
import {
X,
Wind,
Eye,
Thermometer,
Gauge,
Cloud,
Radio,
MapPin,
Loader2,
} from "lucide-react";
import type { Airport } from "@/lib/airports";
import { findNearbyAtcFeeds, iataToIcao } from "@/lib/atc-lookup";
import { ScrollArea } from "@/components/ui/scroll-area";
type MetarData = {
rawOb?: string;
temp?: number;
dewp?: number;
wdir?: number | string;
wspd?: number;
wgst?: number;
visib?: number | string;
altim?: number;
clouds?: { cover: string; base?: number }[];
fltcat?: string;
name?: string;
};
type AirportInfoCardProps = {
airport: Airport | null;
onClose: () => void;
};
function decodeFltCat(cat: string | undefined): {
label: string;
color: string;
dotColor: string;
} {
switch (cat?.toUpperCase()) {
case "VFR":
return {
label: "VFR",
color: "text-emerald-400",
dotColor: "bg-emerald-400",
};
case "MVFR":
return { label: "MVFR", color: "text-blue-400", dotColor: "bg-blue-400" };
case "IFR":
return { label: "IFR", color: "text-red-400", dotColor: "bg-red-400" };
case "LIFR":
return {
label: "LIFR",
color: "text-purple-400",
dotColor: "bg-purple-400",
};
default:
return {
label: "—",
color: "text-foreground/40",
dotColor: "bg-foreground/20",
};
}
}
function formatVisibility(vis: number | string | undefined): string {
if (vis === undefined || vis === null) return "—";
if (typeof vis === "string") return vis;
if (vis >= 9999) return "10+ SM";
return `${vis} SM`;
}
function cloudCoverLabel(cover: string): string {
switch (cover.toUpperCase()) {
case "SKC":
case "CLR":
case "NCD":
return "Clear";
case "FEW":
return "Few";
case "SCT":
return "Scattered";
case "BKN":
return "Broken";
case "OVC":
return "Overcast";
case "OVX":
return "Obscured";
default:
return cover;
}
}
// ── Client-side METAR cache (10 min TTL) ───────────────────────────────
const METAR_CACHE_TTL_MS = 10 * 60 * 1000;
const metarCache = new Map<string, { data: MetarData; fetchedAt: number }>();
export function AirportInfoCard({ airport, onClose }: AirportInfoCardProps) {
const [metar, setMetar] = useState<MetarData | null>(null);
const [metarLoading, setMetarLoading] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const fetchMetar = useCallback(async (icao: string) => {
// Check client-side cache first
const cached = metarCache.get(icao);
if (cached && Date.now() - cached.fetchedAt < METAR_CACHE_TTL_MS) {
setMetar(cached.data);
setMetarLoading(false);
return;
}
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setMetarLoading(true);
// Show stale cached data while re-fetching instead of blank
if (cached) setMetar(cached.data);
else setMetar(null);
try {
const res = await fetch(
`/api/weather/metar?icao=${encodeURIComponent(icao)}`,
{ signal: controller.signal },
);
if (!res.ok) {
return;
}
const data = await res.json();
if (controller.signal.aborted) return;
// NOAA returns an array of METAR observations
const obs = Array.isArray(data) ? data[0] : data;
if (obs) {
metarCache.set(icao, { data: obs, fetchedAt: Date.now() });
}
setMetar(obs ?? null);
} catch (err) {
if (err instanceof Error && err.name === "AbortError") return;
} finally {
if (!controller.signal.aborted) setMetarLoading(false);
}
}, []);
useEffect(() => {
if (!airport) {
setMetar(null);
return;
}
const icao = iataToIcao(airport.iata);
if (icao) fetchMetar(icao);
else setMetar(null);
return () => {
abortRef.current?.abort();
};
}, [airport, fetchMetar]);
const icao = airport ? iataToIcao(airport.iata) : null;
const nearbyAtc =
airport && icao ? findNearbyAtcFeeds(airport.lat, airport.lng, 30, 6) : [];
// Group feeds for this airport only
const airportFeeds = nearbyAtc.find((r) => r.icao === icao);
const fltCat = decodeFltCat(metar?.fltcat);
return (
<AnimatePresence mode="wait">
{airport && (
<motion.div
key={airport.iata}
initial={{ opacity: 0, y: 12, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 12, scale: 0.96 }}
transition={{
type: "spring",
stiffness: 400,
damping: 28,
mass: 0.8,
}}
className="w-72 sm:w-80"
role="complementary"
aria-label="Airport information"
>
<div className="overflow-hidden rounded-2xl border border-foreground/8 bg-background/60 shadow-2xl shadow-background/40 backdrop-blur-2xl">
<div className="p-4">
{/* Header */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
{metar ? (
<span
className={`h-2.5 w-2.5 shrink-0 rounded-full ${fltCat.dotColor} shadow-sm`}
style={{ boxShadow: `0 0 6px 1px currentColor` }}
/>
) : (
<MapPin className="h-4 w-4 shrink-0 text-foreground/30" />
)}
<p className="truncate text-base font-bold text-foreground">
{airport.iata}
</p>
{icao && (
<span className="text-[10px] font-medium tracking-widest text-foreground/30 uppercase">
{icao}
</span>
)}
{metar && (
<span
className={`rounded-md bg-foreground/5 px-1.5 py-0.5 text-[9px] font-bold tracking-wider ring-1 ring-foreground/6 ${fltCat.color}`}
>
{fltCat.label}
</span>
)}
</div>
<p className="mt-0.5 truncate text-[11px] font-medium text-foreground/40">
{airport.name}
</p>
<p className="text-[10px] text-foreground/25">
{airport.city}, {airport.country}
</p>
</div>
<button
type="button"
onClick={onClose}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg bg-foreground/5 transition-colors hover:bg-foreground/10"
aria-label="Close airport info"
>
<X className="h-3 w-3 text-foreground/40" />
</button>
</div>
{/* Weather Section */}
<div className="mt-3 h-px bg-linear-to-r from-transparent via-foreground/6 to-transparent" />
{metarLoading && (
<div className="mt-2.5 flex items-center gap-2">
<Loader2 className="h-3 w-3 animate-spin text-foreground/20" />
<span className="text-[10px] text-foreground/25">
Loading weather...
</span>
</div>
)}
{metar && !metarLoading && (
<div className="mt-2.5">
<p className="text-[10px] font-medium tracking-widest text-foreground/25 uppercase">
Current Weather
</p>
<div className="mt-2 grid grid-cols-2 gap-1.5">
{/* Wind */}
<WeatherMetric
icon={<Wind className="h-3 w-3" />}
label="Wind"
value={
metar.wspd !== undefined
? `${metar.wdir ?? "VRB"}° ${metar.wspd}kt${metar.wgst ? ` G${metar.wgst}` : ""}`
: "Calm"
}
/>
{/* Visibility */}
<WeatherMetric
icon={<Eye className="h-3 w-3" />}
label="Visibility"
value={formatVisibility(metar.visib)}
/>
{/* Temperature */}
<WeatherMetric
icon={<Thermometer className="h-3 w-3" />}
label="Temp / Dew"
value={
metar.temp !== undefined
? `${metar.temp}°C / ${metar.dewp ?? "—"}°C`
: "—"
}
/>
{/* QNH */}
<WeatherMetric
icon={<Gauge className="h-3 w-3" />}
label="QNH"
value={
metar.altim !== undefined
? `${metar.altim.toFixed(0)} hPa`
: "—"
}
/>
</div>
{/* Clouds */}
{metar.clouds && metar.clouds.length > 0 && (
<div className="mt-2 flex items-start gap-1.5 rounded-lg bg-foreground/3 px-2.5 py-2 ring-1 ring-foreground/4">
<Cloud className="mt-0.5 h-3 w-3 shrink-0 text-foreground/25" />
<div className="flex flex-col gap-0.5">
<span className="text-[9px] font-medium tracking-widest text-foreground/20 uppercase">
Cloud Layers
</span>
<p className="text-[11px] leading-snug text-foreground/45">
{metar.clouds
.map(
(c) =>
`${cloudCoverLabel(c.cover)}${c.base != null ? ` ${(c.base * 100).toLocaleString()}ft` : ""}`,
)
.join(" · ")}
</p>
</div>
</div>
)}
{/* Raw METAR */}
{metar.rawOb && (
<div className="mt-2 rounded-lg bg-foreground/3 px-2.5 py-2 ring-1 ring-foreground/4">
<p className="font-mono text-[9px] leading-relaxed text-foreground/25 break-all select-all">
{metar.rawOb}
</p>
</div>
)}
</div>
)}
{!metar && !metarLoading && icao && (
<div className="mt-2.5">
<p className="text-[10px] text-foreground/25">
No weather data available
</p>
</div>
)}
{/* ATC Frequencies */}
{airportFeeds && airportFeeds.feeds.length > 0 && (
<>
<div className="mt-3 h-px bg-linear-to-r from-transparent via-foreground/6 to-transparent" />
<div className="mt-2.5">
<div className="flex items-center gap-1.5">
<Radio className="h-3 w-3 text-emerald-400/50" />
<p className="text-[10px] font-medium tracking-widest text-foreground/25 uppercase">
ATC Frequencies
</p>
<span className="ml-auto rounded-full bg-foreground/5 px-1.5 py-px text-[9px] font-medium tabular-nums text-foreground/20">
{airportFeeds.feeds.length}
</span>
</div>
<ScrollArea className="mt-1.5 max-h-28">
<div className="space-y-0.5">
{airportFeeds.feeds.map((feed) => (
<div
key={feed.id}
className="flex items-center justify-between gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-foreground/3"
>
<div className="flex items-center gap-1.5 min-w-0">
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-emerald-400/40" />
<span className="truncate text-[11px] text-foreground/45">
{feed.name}
</span>
</div>
<span className="shrink-0 font-mono text-[10px] tabular-nums text-foreground/35">
{feed.frequency}
</span>
</div>
))}
</div>
</ScrollArea>
</div>
</>
)}
{/* Coordinates */}
<div className="mt-3 h-px bg-linear-to-r from-transparent via-foreground/6 to-transparent" />
<div className="mt-2 flex items-center gap-1.5">
<MapPin className="h-3 w-3 text-foreground/20" />
<p className="font-mono text-[10px] tabular-nums text-foreground/25">
{Math.abs(airport.lat).toFixed(4)}°
{airport.lat >= 0 ? "N" : "S"},{" "}
{Math.abs(airport.lng).toFixed(4)}°
{airport.lng >= 0 ? "E" : "W"}
</p>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
function WeatherMetric({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="flex flex-col gap-0.5 rounded-lg bg-foreground/3 px-2.5 py-2 ring-1 ring-foreground/4">
<div className="flex items-center gap-1 text-foreground/25">
{icon}
<span className="text-[9px] font-medium tracking-widest uppercase">
{label}
</span>
</div>
<p className="text-[12px] font-semibold tabular-nums text-foreground/80">
{value}
</p>
</div>
);
}

View File

@ -108,16 +108,16 @@ export function AirportSearchInput({
setIsOpen(true);
requestAnimationFrame(() => inputRef.current?.focus());
}}
className="flex w-full items-center gap-2 rounded-xl border border-white/8 bg-white/4 px-3 py-2.5 text-left transition-colors hover:bg-white/6"
className="flex w-full items-center gap-2 rounded-xl border border-foreground/8 bg-foreground/4 px-3 py-2.5 text-left transition-colors hover:bg-foreground/6"
>
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-white/8">
<MapPin className="h-3 w-3 text-white/50" />
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-foreground/8">
<MapPin className="h-3 w-3 text-foreground/50" />
</div>
<div className="flex-1 min-w-0">
<span className="text-[13px] font-semibold text-white/80">
<span className="text-[13px] font-semibold text-foreground/80">
{selected.iata}
</span>
<span className="ml-1.5 text-[11px] text-white/30">
<span className="ml-1.5 text-[11px] text-foreground/30">
{selected.city}
</span>
</div>
@ -127,7 +127,7 @@ export function AirportSearchInput({
e.stopPropagation();
handleClear();
}}
className="shrink-0 text-white/20 hover:text-white/40 transition-colors"
className="shrink-0 text-foreground/20 hover:text-foreground/40 transition-colors"
aria-label="Clear selection"
>
<X className="h-3 w-3" />
@ -135,8 +135,8 @@ export function AirportSearchInput({
)}
</button>
) : (
<div className="flex items-center gap-2 rounded-xl border border-white/8 bg-white/4 px-3 py-2">
<Search className="h-3.5 w-3.5 shrink-0 text-white/25" />
<div className="flex items-center gap-2 rounded-xl border border-foreground/8 bg-foreground/4 px-3 py-2">
<Search className="h-3.5 w-3.5 shrink-0 text-foreground/25" />
<input
ref={inputRef}
value={query}
@ -147,12 +147,12 @@ export function AirportSearchInput({
onFocus={() => setIsOpen(true)}
placeholder={placeholder}
aria-label={label}
className="flex-1 bg-transparent text-[13px] font-medium text-white/90 placeholder:text-white/20 outline-none"
className="flex-1 bg-transparent text-[13px] font-medium text-foreground/90 placeholder:text-foreground/20 outline-none"
/>
{query && (
<button
onClick={() => setQuery("")}
className="shrink-0 text-white/20 hover:text-white/40 transition-colors"
className="shrink-0 text-foreground/20 hover:text-foreground/40 transition-colors"
aria-label="Clear search"
>
<X className="h-3 w-3" />
@ -168,12 +168,12 @@ export function AirportSearchInput({
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -4, scale: 0.98 }}
transition={{ duration: 0.15 }}
className="absolute left-0 right-0 top-full z-50 mt-1 overflow-hidden rounded-xl border border-white/8 bg-[#0c0c0e]/95 shadow-[0_20px_60px_rgba(0,0,0,.7)] backdrop-blur-2xl"
className="absolute left-0 right-0 top-full z-50 mt-1 overflow-hidden rounded-xl border border-foreground/8 bg-popover/95 shadow-[0_20px_60px_rgba(0,0,0,0.15)] dark:shadow-[0_20px_60px_rgba(0,0,0,0.7)] backdrop-blur-2xl"
>
<ScrollArea className="max-h-56">
<div className="p-1.5">
{!hasResults && (
<p className="py-6 text-center text-[11px] text-white/25">
<p className="py-6 text-center text-[11px] text-foreground/25">
No airports found
</p>
)}
@ -181,7 +181,7 @@ export function AirportSearchInput({
{featured.length > 0 && (
<>
{query && (
<p className="px-2.5 pt-1.5 pb-1 text-[9px] font-semibold uppercase tracking-widest text-white/15">
<p className="px-2.5 pt-1.5 pb-1 text-[9px] font-semibold uppercase tracking-widest text-foreground/15">
Featured
</p>
)}
@ -200,7 +200,7 @@ export function AirportSearchInput({
{airports.length > 0 && (
<>
<p
className={`px-2.5 pb-1 text-[9px] font-semibold uppercase tracking-widest text-white/15 ${
className={`px-2.5 pb-1 text-[9px] font-semibold uppercase tracking-widest text-foreground/15 ${
featured.length > 0 ? "pt-2" : "pt-1.5"
}`}
>
@ -240,18 +240,18 @@ const DropdownRow = memo(function DropdownRow({
return (
<button
onClick={onClick}
className={`group flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left transition-colors hover:bg-white/5 ${
isActive ? "bg-white/6" : ""
className={`group flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left transition-colors hover:bg-foreground/5 ${
isActive ? "bg-foreground/6" : ""
}`}
>
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-white/4">
<MapPin className="h-3 w-3 text-white/35" />
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-foreground/4">
<MapPin className="h-3 w-3 text-foreground/35" />
</div>
<div className="flex-1 min-w-0">
<p className="truncate text-[12px] font-medium text-white/75">{name}</p>
<p className="text-[10px] text-white/25">{detail}</p>
<p className="truncate text-[12px] font-medium text-foreground/75">{name}</p>
<p className="text-[10px] text-foreground/25">{detail}</p>
</div>
<ChevronRight className="h-3 w-3 shrink-0 text-white/10 group-hover:text-white/20" />
<ChevronRight className="h-3 w-3 shrink-0 text-foreground/10 group-hover:text-foreground/20" />
</button>
);
});

View File

@ -1,6 +1,6 @@
"use client";
import { useMemo, useCallback, useRef } from "react";
import { useMemo, useCallback, useRef, useState, useEffect } from "react";
import { motion, AnimatePresence } from "motion/react";
import {
Radio,
@ -11,13 +11,16 @@ import {
AlertTriangle,
Server,
ChevronUp,
AudioLines,
} from "lucide-react";
import { AtcSpectrum } from "@/components/ui/atc-spectrum";
import type { AtcFeed, AtcFeedType } from "@/lib/atc-types";
import { FEED_TYPE_PRIORITY } from "@/lib/atc-types";
import { lookupAtcFeeds, findNearbyAtcFeeds } from "@/lib/atc-lookup";
import { AtcWaveform } from "@/components/ui/atc-waveform";
import type { UseAtcStreamReturn } from "@/hooks/use-atc-stream";
import { useDropdownDismiss } from "@/hooks/use-dropdown-dismiss";
import { ScrollArea } from "@/components/ui/scroll-area";
// ── Feed helpers ───────────────────────────────────────────────────────
@ -122,16 +125,18 @@ export function AtcFeedDropdown({
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.97 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="absolute bottom-full left-0 z-50 mb-2 w-[calc(100vw-2rem)] max-w-70 overflow-hidden rounded-xl border shadow-2xl shadow-black/60 backdrop-blur-2xl sm:w-70 sm:max-w-none"
className="absolute bottom-full left-0 z-50 mb-2 w-[calc(100vw-2rem)] max-w-70 overflow-hidden rounded-[18px] backdrop-blur-3xl sm:w-70 sm:max-w-none"
style={{
borderColor: "rgb(var(--ui-fg) / 0.08)",
backgroundColor: "rgb(var(--ui-bg) / 0.75)",
border: "0.5px solid rgb(var(--ui-fg) / 0.08)",
backgroundColor: "rgb(var(--ui-bg) / 0.7)",
boxShadow:
"0 12px 40px rgb(0 0 0 / 0.4), inset 0 0.5px 0 rgb(var(--ui-fg) / 0.04)",
}}
>
{/* Header */}
<div
className="flex items-center justify-between px-4 py-2.5"
style={{ borderBottom: "1px solid rgb(var(--ui-fg) / 0.06)" }}
style={{ borderBottom: "0.5px solid rgb(var(--ui-fg) / 0.06)" }}
>
<div className="flex items-center gap-2">
<Radio className="h-3 w-3 text-emerald-400/70" />
@ -145,7 +150,7 @@ export function AtcFeedDropdown({
<button
type="button"
onClick={onClose}
className="flex h-5 w-5 items-center justify-center rounded-md transition-colors hover:bg-white/5 active:bg-white/10"
className="flex h-5 w-5 items-center justify-center rounded-md transition-colors hover:bg-foreground/5 active:bg-foreground/10"
aria-label="Close feed selector"
>
<X
@ -166,90 +171,92 @@ export function AtcFeedDropdown({
</span>
</div>
) : (
<div className="max-h-65 overflow-y-auto py-1">
{groupedFeeds.map((group) => (
<div key={group.type}>
{group.feeds.map((feed) => {
const isPlaying =
atc.feed?.id === feed.id && atc.status === "playing";
const isLoading =
atc.feed?.id === feed.id && atc.status === "loading";
const isFeedError =
atc.feed?.id === feed.id &&
(atc.status === "error" || atc.status === "blocked");
const isSelected = atc.feed?.id === feed.id;
<ScrollArea className="max-h-65">
<div className="py-1">
{groupedFeeds.map((group) => (
<div key={group.type}>
{group.feeds.map((feed) => {
const isPlaying =
atc.feed?.id === feed.id && atc.status === "playing";
const isLoading =
atc.feed?.id === feed.id && atc.status === "loading";
const isFeedError =
atc.feed?.id === feed.id &&
(atc.status === "error" || atc.status === "blocked");
const isSelected = atc.feed?.id === feed.id;
return (
<button
key={feed.id}
type="button"
onClick={() => handleSelectFeed(feed)}
className={`group flex w-full items-center gap-2.5 px-3.5 py-2 transition-colors ${
isSelected
? "bg-white/6"
: "hover:bg-white/3 active:bg-white/6"
}`}
>
{/* Inline icon */}
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
{isLoading ? (
<Loader2 className="h-3 w-3 animate-spin text-emerald-400/70" />
) : isFeedError ? (
<AlertTriangle className="h-3 w-3 text-amber-400/70" />
) : isPlaying ? (
<Square className="h-2.5 w-2.5 text-emerald-400" />
) : (
<Play
className="h-3 w-3 opacity-40 transition-opacity group-hover:opacity-80"
style={{ color: "rgb(var(--ui-fg) / 0.5)" }}
/>
)}
</div>
return (
<button
key={feed.id}
type="button"
onClick={() => handleSelectFeed(feed)}
className={`group flex w-full items-center gap-2.5 px-3.5 py-2 transition-all duration-150 ${
isSelected
? "bg-foreground/6"
: "hover:bg-foreground/3 active:bg-foreground/6 active:scale-[0.99]"
}`}
>
{/* Inline icon */}
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
{isLoading ? (
<Loader2 className="h-3 w-3 animate-spin text-emerald-400/70" />
) : isFeedError ? (
<AlertTriangle className="h-3 w-3 text-amber-400/70" />
) : isPlaying ? (
<Square className="h-2.5 w-2.5 text-emerald-400" />
) : (
<Play
className="h-3 w-3 opacity-40 transition-opacity group-hover:opacity-80"
style={{ color: "rgb(var(--ui-fg) / 0.5)" }}
/>
)}
</div>
{/* Feed name + frequency */}
<div className="flex min-w-0 flex-1 flex-col gap-0 text-left">
{/* Feed name + frequency */}
<div className="flex min-w-0 flex-1 flex-col gap-0 text-left">
<span
className="truncate text-[11px] font-medium leading-snug"
style={{
color: isPlaying
? "rgb(var(--ui-fg) / 0.85)"
: isFeedError
? "rgb(251 191 36 / 0.7)"
: "rgb(var(--ui-fg) / 0.55)",
}}
>
{feed.name}
</span>
{isFeedError && atc.error ? (
<span className="truncate text-[9px] text-amber-300/50">
{atc.error}
</span>
) : (
<span
className="font-mono text-[9px] tabular-nums leading-snug"
style={{ color: "rgb(var(--ui-fg) / 0.25)" }}
>
{feed.frequency}
</span>
)}
</div>
{/* Type badge */}
<span
className="truncate text-[11px] font-medium leading-snug"
className="shrink-0 rounded px-1.5 py-px text-[8px] font-bold tracking-wider"
style={{
color: isPlaying
? "rgb(var(--ui-fg) / 0.85)"
: isFeedError
? "rgb(251 191 36 / 0.7)"
: "rgb(var(--ui-fg) / 0.55)",
backgroundColor: `${TYPE_COLORS[feed.type]}12`,
color: `${TYPE_COLORS[feed.type]}`,
}}
>
{feed.name}
{TYPE_LABELS[feed.type]}
</span>
{isFeedError && atc.error ? (
<span className="truncate text-[9px] text-amber-300/50">
{atc.error}
</span>
) : (
<span
className="font-mono text-[9px] tabular-nums leading-snug"
style={{ color: "rgb(var(--ui-fg) / 0.25)" }}
>
{feed.frequency}
</span>
)}
</div>
{/* Type badge */}
<span
className="shrink-0 rounded px-1.5 py-px text-[8px] font-bold tracking-wider"
style={{
backgroundColor: `${TYPE_COLORS[feed.type]}12`,
color: `${TYPE_COLORS[feed.type]}`,
}}
>
{TYPE_LABELS[feed.type]}
</span>
</button>
);
})}
</div>
))}
</div>
</button>
);
})}
</div>
))}
</div>
</ScrollArea>
)}
</motion.div>
)}
@ -268,103 +275,153 @@ export function AtcPlayerBar({ atc, onOpenFeedSelector }: AtcPlayerBarProps) {
const isStreaming = atc.status === "playing" || atc.status === "loading";
const isError = atc.status === "error" || atc.status === "blocked";
const isBlocked = atc.status === "blocked";
const [spectrumOpen, setSpectrumOpen] = useState(false);
// Close spectrum on Escape key
useEffect(() => {
if (!spectrumOpen) return;
function handleKey(e: KeyboardEvent) {
if (e.key === "Escape") setSpectrumOpen(false);
}
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [spectrumOpen]);
if (!atc.feed) return null;
return (
<motion.div
initial={{ opacity: 0, y: -12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
transition={{ type: "spring", stiffness: 400, damping: 28 }}
className="flex w-[calc(100vw-2rem)] max-w-sm items-center gap-3 rounded-2xl border px-3.5 py-3 backdrop-blur-2xl sm:w-auto sm:max-w-none sm:gap-3.5 sm:px-4"
style={{
borderColor: isError
? "rgb(251 191 36 / 0.12)"
: "rgb(var(--ui-fg) / 0.06)",
backgroundColor: "rgb(var(--ui-bg) / 0.5)",
}}
>
{/* Waveform or blocked play icon (left) */}
{isBlocked ? (
<div className="flex flex-col items-center gap-2">
{/* Expanded Spectrum Visualizer */}
<AnimatePresence>
{spectrumOpen && (
<div className="w-[calc(100vw-2rem)] max-w-sm sm:w-96 sm:max-w-none">
<AtcSpectrum
audioElement={atc.audioElement}
active={atc.status === "playing"}
feedName={atc.feed.name}
feedFrequency={atc.feed.frequency}
/>
</div>
)}
</AnimatePresence>
{/* Player Bar */}
<motion.div
initial={{ opacity: 0, y: -12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
transition={{ type: "spring", stiffness: 400, damping: 28 }}
className="flex w-[calc(100vw-2rem)] max-w-sm items-center gap-3 rounded-[20px] px-4 py-3 backdrop-blur-3xl sm:w-auto sm:max-w-none sm:gap-3.5 sm:px-4"
style={{
border: isError
? "0.5px solid rgb(251 191 36 / 0.15)"
: "0.5px solid rgb(var(--ui-fg) / 0.06)",
backgroundColor: "rgb(var(--ui-bg) / 0.5)",
boxShadow:
"0 8px 32px rgb(0 0 0 / 0.25), inset 0 0.5px 0 rgb(var(--ui-fg) / 0.04)",
}}
>
{/* Waveform or blocked play icon (left) */}
{isBlocked ? (
<button
type="button"
onClick={() => atc.resume()}
className="flex h-7 w-13 shrink-0 items-center justify-center rounded-lg transition-colors hover:bg-foreground/5 active:bg-foreground/10"
aria-label="Tap to start"
>
<Play className="h-4 w-4 text-emerald-400/80" />
</button>
) : (
<AtcWaveform
audioElement={atc.audioElement}
active={atc.status === "playing"}
/>
)}
{/* Feed name + frequency (stacked, center) — clickable to open selector */}
<button
type="button"
onClick={() => atc.resume()}
className="flex h-7 w-13 shrink-0 items-center justify-center rounded-lg transition-colors hover:bg-white/5 active:bg-white/10"
aria-label="Tap to start"
onClick={isBlocked ? () => atc.resume() : onOpenFeedSelector}
className="flex min-w-0 flex-1 flex-col gap-0.5 text-left"
>
<Play className="h-4 w-4 text-emerald-400/80" />
</button>
) : (
<AtcWaveform
audioElement={atc.audioElement}
active={atc.status === "playing"}
/>
)}
{/* Feed name + frequency (stacked, center) — clickable to open selector */}
<button
type="button"
onClick={isBlocked ? () => atc.resume() : onOpenFeedSelector}
className="flex min-w-0 flex-1 flex-col gap-0.5 text-left"
>
<div className="flex items-center gap-1.5">
{atc.status === "loading" ? (
<Loader2 className="h-3 w-3 shrink-0 animate-spin text-emerald-400/70" />
) : isError ? (
<AlertTriangle className="h-3 w-3 shrink-0 text-amber-400/70" />
) : null}
<span
className="truncate text-[12px] font-medium leading-tight"
style={{
color: isBlocked
? "rgb(var(--ui-fg) / 0.55)"
: isError
? "rgb(251 191 36 / 0.7)"
: isStreaming
? "rgb(var(--ui-fg) / 0.75)"
: "rgb(var(--ui-fg) / 0.45)",
}}
>
{isBlocked
? "Tap to listen"
: isError && atc.error
? atc.error
: atc.feed.name}
</span>
</div>
<div className="flex items-center gap-1.5">
<span
className="font-mono text-[9px] tabular-nums"
style={{ color: "rgb(var(--ui-fg) / 0.25)" }}
>
{atc.feed.frequency}
</span>
{atc.usingProxy && atc.status === "playing" && (
<div className="flex items-center gap-1.5">
{atc.status === "loading" ? (
<Loader2 className="h-3 w-3 shrink-0 animate-spin text-emerald-400/70" />
) : isError ? (
<AlertTriangle className="h-3 w-3 shrink-0 text-amber-400/70" />
) : null}
<span
className="flex items-center gap-0.5 text-[9px]"
style={{ color: "rgb(var(--ui-fg) / 0.2)" }}
className="truncate text-[12px] font-medium leading-tight"
style={{
color: isBlocked
? "rgb(var(--ui-fg) / 0.55)"
: isError
? "rgb(251 191 36 / 0.7)"
: isStreaming
? "rgb(var(--ui-fg) / 0.75)"
: "rgb(var(--ui-fg) / 0.45)",
}}
>
<Server className="h-1.5 w-1.5" />
proxy
{isBlocked
? "Tap to listen"
: isError && atc.error
? atc.error
: atc.feed.name}
</span>
)}
</div>
</button>
</div>
<div className="flex items-center gap-1.5">
<span
className="font-mono text-[9px] tabular-nums"
style={{ color: "rgb(var(--ui-fg) / 0.25)" }}
>
{atc.feed.frequency}
</span>
{atc.usingProxy && atc.status === "playing" && (
<span
className="flex items-center gap-0.5 text-[9px]"
style={{ color: "rgb(var(--ui-fg) / 0.2)" }}
>
<Server className="h-1.5 w-1.5" />
proxy
</span>
)}
</div>
</button>
{/* Close / Stop (right) */}
<button
type="button"
onClick={() => atc.stop()}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg transition-colors hover:bg-white/5 active:bg-white/10"
aria-label="Stop and close"
>
<X
className="h-3.5 w-3.5"
style={{ color: "rgb(var(--ui-fg) / 0.25)" }}
/>
</button>
</motion.div>
{/* Spectrum toggle (right of center) */}
{!isBlocked && (
<button
type="button"
onClick={() => setSpectrumOpen((prev) => !prev)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg transition-all hover:bg-foreground/5 active:bg-foreground/8 active:scale-[0.92]"
aria-label={spectrumOpen ? "Hide spectrum" : "Show spectrum"}
title={spectrumOpen ? "Hide spectrum (Esc)" : "Show audio spectrum"}
>
<AudioLines
className="h-3.5 w-3.5 transition-colors"
style={{
color: spectrumOpen
? "rgb(52, 211, 153)"
: "rgb(var(--ui-fg) / 0.25)",
}}
/>
</button>
)}
{/* Close / Stop (right) */}
<button
type="button"
onClick={() => atc.stop()}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg transition-all hover:bg-foreground/5 active:bg-foreground/8 active:scale-[0.92]"
aria-label="Stop and close"
>
<X
className="h-3.5 w-3.5"
style={{ color: "rgb(var(--ui-fg) / 0.25)" }}
/>
</button>
</motion.div>
</div>
);
}
@ -389,7 +446,7 @@ export function AtcTrigger({
<button
type="button"
onClick={onClick}
className="inline-flex items-center rounded p-1 transition-colors hover:bg-white/5 active:bg-white/10 sm:p-0.5"
className="inline-flex items-center rounded p-1 transition-colors hover:bg-foreground/5 active:bg-foreground/10 sm:p-0.5"
aria-label="Live ATC (A)"
title="Live ATC (A)"
>

View File

@ -0,0 +1,430 @@
"use client";
import { useRef, useEffect, useState } from "react";
import { motion } from "motion/react";
import { getOrCreateConnection } from "@/components/ui/atc-waveform";
// ── Constants ──────────────────────────────────────────────────────────
const BAR_COUNT = 64;
const CANVAS_PADDING = 20;
const LERP_UP = 0.24; // Quick attack
const LERP_DOWN = 0.07; // Slow decay — silky smooth
type VisualizationMode = "spectrum" | "waveform" | "combined";
const MODES: { key: VisualizationMode; label: string }[] = [
{ key: "spectrum", label: "Spectrum" },
{ key: "waveform", label: "Waveform" },
{ key: "combined", label: "Combined" },
];
// ── Voice-range bin mapping (logarithmic spread) ───────────────────────
function buildBinRanges(
binCount: number,
barCount: number,
): [number, number][] {
const maxBin = Math.min(Math.ceil(binCount * 0.35), binCount);
const ranges: [number, number][] = [];
for (let i = 0; i < barCount; i++) {
const t0 = i / barCount;
const t1 = (i + 1) / barCount;
const start = 1 + Math.floor(t0 * t0 * (maxBin - 1));
const end = 1 + Math.floor(t1 * t1 * (maxBin - 1));
ranges.push([start, Math.max(end, start + 1)]);
}
return ranges;
}
// ── Accent color helper ────────────────────────────────────────────────
function accent(intensity: number, alpha: number): string {
const v = Math.min(intensity, 1);
// Refined emerald/mint — clean and cohesive
const r = Math.round(48 + v * 40);
const g = Math.round(205 + v * 35);
const b = Math.round(148 + v * 32);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
// ── Segmented Control (Apple-style) ────────────────────────────────────
function SegmentedControl({
mode,
onChange,
}: {
mode: VisualizationMode;
onChange: (m: VisualizationMode) => void;
}) {
const activeIndex = MODES.findIndex((m) => m.key === mode);
const segW = 100 / MODES.length;
return (
<div
role="tablist"
className="relative flex h-6.5 shrink-0 items-center rounded-lg"
style={{ backgroundColor: "rgb(var(--ui-fg) / 0.06)" }}
>
{/* Sliding capsule indicator */}
<motion.div
className="absolute top-0.5 bottom-0.5 rounded-md"
style={{
width: `calc(${segW}% - 4px)`,
backgroundColor: "rgb(var(--ui-fg) / 0.1)",
boxShadow:
"0 1px 2px rgb(0 0 0 / 0.25), inset 0 0.5px 0 rgb(var(--ui-fg) / 0.05)",
}}
animate={{ left: `calc(${activeIndex * segW}% + 2px)` }}
transition={{ type: "spring", stiffness: 420, damping: 28 }}
/>
{MODES.map((m) => (
<button
key={m.key}
role="tab"
type="button"
aria-selected={mode === m.key}
onClick={() => onChange(m.key)}
className="relative z-10 flex h-full flex-1 items-center justify-center px-2.5 text-[10px] font-semibold tracking-wide transition-colors duration-200"
style={{
color:
mode === m.key
? "rgb(var(--ui-fg) / 0.9)"
: "rgb(var(--ui-fg) / 0.3)",
}}
>
{m.label}
</button>
))}
</div>
);
}
// ── Component ──────────────────────────────────────────────────────────
export function AtcSpectrum({
audioElement,
active,
feedName,
feedFrequency,
}: {
audioElement: HTMLAudioElement | null;
active: boolean;
feedName?: string;
feedFrequency?: string;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const rafRef = useRef<number>(0);
const barsRef = useRef<number[]>(new Array(BAR_COUNT).fill(0));
const [mode, setMode] = useState<VisualizationMode>("combined");
// ── Connect to Web Audio API ────────────────────────────────────────
useEffect(() => {
if (!active || !audioElement) {
barsRef.current = new Array(BAR_COUNT).fill(0);
analyserRef.current = null;
return;
}
analyserRef.current = getOrCreateConnection(audioElement);
}, [active, audioElement]);
// ── Resize observer for responsive canvas ───────────────────────────
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const container = canvas.parentElement;
if (!container) return;
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
}
});
ro.observe(container);
return () => ro.disconnect();
}, []);
// ── Main render loop ────────────────────────────────────────────────
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let freqData: Uint8Array<ArrayBuffer> | null = null;
let timeData: Uint8Array<ArrayBuffer> | null = null;
let binRanges: [number, number][] | null = null;
let lastBinCount = 0;
function draw() {
rafRef.current = requestAnimationFrame(draw);
const dpr = window.devicePixelRatio || 1;
const W = canvas!.width / dpr;
const H = canvas!.height / dpr;
if (W === 0 || H === 0) return;
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx!.clearRect(0, 0, W, H);
const analyser = analyserRef.current;
const binCount = analyser?.frequencyBinCount ?? 128;
// Re-allocate if bin count changed
if (binCount !== lastBinCount) {
freqData = new Uint8Array(binCount) as Uint8Array<ArrayBuffer>;
timeData = new Uint8Array(binCount) as Uint8Array<ArrayBuffer>;
binRanges = buildBinRanges(binCount, BAR_COUNT);
lastBinCount = binCount;
}
if (analyser && freqData && timeData) {
analyser.getByteFrequencyData(freqData);
analyser.getByteTimeDomainData(timeData);
}
const now = performance.now();
const drawW = W - CANVAS_PADDING * 2;
const baseY = H - CANVAS_PADDING;
const maxBarH = H - CANVAS_PADDING * 2;
const currentMode = mode;
// ── Pre-compute bar values ────────────────────────────────────
let hasSignal = false;
let peakVal = 0;
for (let i = 0; i < BAR_COUNT; i++) {
const [startBin, endBin] = binRanges![i];
let sum = 0;
const count = endBin - startBin;
for (let b = startBin; b < endBin; b++) {
sum += freqData ? freqData[b] : 0;
}
const raw = analyser && count > 0 ? sum / count / 255 : 0;
// Breathing: barely perceptible, organic phase per bar
const breathPeriod = 2600 + (i % 5) * 280;
const breathPhase =
((now / breathPeriod) + i * 0.15) % (Math.PI * 2);
const breathVal = 0.02 + Math.sin(breathPhase) * 0.008;
const target = raw > 0.02 ? raw : breathVal;
const lerp = target > barsRef.current[i] ? LERP_UP : LERP_DOWN;
barsRef.current[i] += (target - barsRef.current[i]) * lerp;
if (raw > 0.02) hasSignal = true;
if (barsRef.current[i] > peakVal) peakVal = barsRef.current[i];
}
// ── Ambient glow from bottom ─────────────────────────────────
if (hasSignal && peakVal > 0.12) {
const glowAlpha = Math.min(peakVal * 0.05, 0.035);
const glow = ctx!.createRadialGradient(
W / 2, H + 20, 0,
W / 2, H + 20, W * 0.55,
);
glow.addColorStop(0, accent(0.5, glowAlpha));
glow.addColorStop(1, "transparent");
ctx!.fillStyle = glow;
ctx!.fillRect(0, 0, W, H);
}
// ── Spectrum bars ─────────────────────────────────────────────
if (currentMode === "spectrum" || currentMode === "combined") {
const totalBarW = drawW / BAR_COUNT;
const barW = Math.max(2, totalBarW * 0.55);
const radius = Math.min(barW * 0.45, 3.5);
for (let i = 0; i < BAR_COUNT; i++) {
const val = barsRef.current[i];
const isActive = val > 0.03;
const barH = Math.max(2, val * maxBarH * 0.88);
const x =
CANVAS_PADDING + i * totalBarW + (totalBarW - barW) / 2;
const y = baseY - barH;
const alpha = isActive ? 0.45 + val * 0.55 : 0.04;
// Gradient fill for active bars, flat tint for idle
if (isActive && barH > 5) {
const grad = ctx!.createLinearGradient(0, y, 0, y + barH);
grad.addColorStop(0, accent(val, alpha));
grad.addColorStop(0.7, accent(val * 0.65, alpha * 0.8));
grad.addColorStop(1, accent(val * 0.2, alpha * 0.4));
ctx!.fillStyle = grad;
} else {
ctx!.fillStyle = isActive
? accent(val, alpha)
: `rgba(255, 255, 255, ${alpha})`;
}
ctx!.beginPath();
ctx!.roundRect(x, y, barW, barH, radius);
ctx!.fill();
// Soft top glow on loud bars
if (val > 0.45 && isActive) {
ctx!.save();
ctx!.shadowColor = accent(val, 0.35);
ctx!.shadowBlur = 6 + val * 8;
ctx!.fillStyle = accent(val, 0.05);
ctx!.beginPath();
ctx!.roundRect(x, y, barW, Math.min(barH, 8), radius);
ctx!.fill();
ctx!.restore();
}
}
}
// ── Waveform / Oscilloscope ───────────────────────────────────
if (
(currentMode === "waveform" || currentMode === "combined") &&
timeData
) {
const waveH = currentMode === "waveform" ? H * 0.5 : H * 0.14;
const waveMid = currentMode === "waveform" ? H * 0.5 : H * 0.5;
const waveAlpha = currentMode === "combined" ? 0.12 : 0.45;
const step = Math.max(1, Math.floor(timeData.length / 128));
const pts: { x: number; y: number }[] = [];
let waveSignal = false;
for (let i = 0; i < timeData.length; i += step) {
const v = (timeData[i] - 128) / 128;
if (Math.abs(v) > 0.02) waveSignal = true;
pts.push({
x: CANVAS_PADDING + (i / (timeData.length - 1)) * drawW,
y: waveMid + v * waveH,
});
}
// Catmull-Rom spline renderer
function spline(lw: number, style: string) {
if (pts.length < 2) return;
ctx!.beginPath();
ctx!.strokeStyle = style;
ctx!.lineWidth = lw;
ctx!.lineJoin = "round";
ctx!.lineCap = "round";
ctx!.moveTo(pts[0].x, pts[0].y);
for (let j = 0; j < pts.length - 1; j++) {
const p0 = pts[Math.max(0, j - 1)];
const p1 = pts[j];
const p2 = pts[Math.min(pts.length - 1, j + 1)];
const p3 = pts[Math.min(pts.length - 1, j + 2)];
ctx!.bezierCurveTo(
p1.x + (p2.x - p0.x) / 6,
p1.y + (p2.y - p0.y) / 6,
p2.x - (p3.x - p1.x) / 6,
p2.y - (p3.y - p1.y) / 6,
p2.x,
p2.y,
);
}
ctx!.stroke();
}
// Outer glow
if (waveSignal) {
ctx!.save();
ctx!.shadowColor = accent(0.5, waveAlpha * 0.15);
ctx!.shadowBlur = 12;
spline(3, accent(0.5, waveAlpha * 0.06));
ctx!.restore();
}
// Main trace
spline(1.5, accent(0.6, waveAlpha));
}
}
draw();
return () => cancelAnimationFrame(rafRef.current);
}, [mode]);
return (
<motion.div
initial={{ opacity: 0, y: 8, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.98 }}
transition={{ type: "spring", stiffness: 380, damping: 30 }}
className="overflow-hidden rounded-[20px] backdrop-blur-3xl"
style={{
border: "0.5px solid rgb(var(--ui-fg) / 0.08)",
backgroundColor: "rgb(var(--ui-bg) / 0.55)",
boxShadow:
"0 8px 32px rgb(0 0 0 / 0.3), inset 0 0.5px 0 rgb(var(--ui-fg) / 0.04)",
}}
>
{/* Header */}
<div
className="flex items-center justify-between gap-3 px-4 py-2.5"
style={{ borderBottom: "0.5px solid rgb(var(--ui-fg) / 0.06)" }}
>
{/* Feed info */}
<div className="flex items-center gap-2.5 min-w-0">
{/* Live indicator */}
<div className="relative flex h-1.5 w-1.5 shrink-0">
{active && (
<span
className="absolute inline-flex h-full w-full animate-ping rounded-full opacity-30"
style={{ backgroundColor: "rgb(52, 211, 153)" }}
/>
)}
<span
className="relative inline-flex h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: active
? "rgb(52, 211, 153)"
: "rgb(var(--ui-fg) / 0.15)",
}}
/>
</div>
<div className="flex items-center gap-2 min-w-0">
<span
className="truncate text-[11px] font-medium"
style={{
color: "rgb(var(--ui-fg) / 0.55)",
letterSpacing: "-0.01em",
}}
>
{feedName ?? "ATC Audio"}
</span>
{feedFrequency && (
<span
className="font-mono text-[9px] tabular-nums shrink-0"
style={{ color: "rgb(var(--ui-fg) / 0.22)" }}
>
{feedFrequency}
</span>
)}
</div>
</div>
{/* Mode selector */}
<SegmentedControl mode={mode} onChange={setMode} />
</div>
{/* Visualization canvas */}
<div className="relative h-40 w-full sm:h-48">
<canvas
ref={canvasRef}
className="absolute inset-0 h-full w-full"
aria-hidden="true"
/>
</div>
</motion.div>
);
}

View File

@ -2,11 +2,9 @@
import { useRef, useEffect } from "react";
const BAR_COUNT = 12;
const DEFAULT_BAR_COUNT = 12;
const BAR_WIDTH = 2.5;
const BAR_GAP = 2;
const CANVAS_W = BAR_COUNT * BAR_WIDTH + (BAR_COUNT - 1) * BAR_GAP;
const CANVAS_H = 28;
const MIN_BAR_H = 2.5;
const LERP = 0.22;
@ -22,7 +20,7 @@ const capturedElements = new WeakMap<
{ source: MediaElementAudioSourceNode; analyser: AnalyserNode }
>();
function getOrCreateConnection(
export function getOrCreateConnection(
audioElement: HTMLAudioElement,
): AnalyserNode | null {
if (!sharedCtx || sharedCtx.state === "closed") {
@ -93,12 +91,18 @@ export function AtcWaveform({
const canvasRef = useRef<HTMLCanvasElement>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const rafRef = useRef<number>(0);
const barsRef = useRef<number[]>(new Array(BAR_COUNT).fill(0));
const barsRef = useRef<number[]>(new Array(DEFAULT_BAR_COUNT).fill(0));
/** Tracks canvas CSS size → derive bar count dynamically */
const layoutRef = useRef({
w: DEFAULT_BAR_COUNT * BAR_WIDTH + (DEFAULT_BAR_COUNT - 1) * BAR_GAP,
h: 28,
barCount: DEFAULT_BAR_COUNT,
});
// ── Connect to Web Audio API ──────────────────────────────────────
useEffect(() => {
if (!active || !audioElement) {
barsRef.current = new Array(BAR_COUNT).fill(0);
barsRef.current = new Array(layoutRef.current.barCount).fill(0);
analyserRef.current = null;
return;
}
@ -128,15 +132,41 @@ export function AtcWaveform({
const draw2d = canvas.getContext("2d");
if (!draw2d) return;
const dpr = window.devicePixelRatio || 1;
canvas.width = CANVAS_W * dpr;
canvas.height = CANVAS_H * dpr;
draw2d.scale(dpr, dpr);
/** Recompute canvas backing-store size from CSS dimensions. */
function syncCanvasSize() {
if (!canvas || !draw2d) return;
const rect = canvas.getBoundingClientRect();
const w = rect.width;
const h = rect.height;
if (w < 1 || h < 1) return;
const barCount = Math.max(
4,
Math.floor((w + BAR_GAP) / (BAR_WIDTH + BAR_GAP)),
);
layoutRef.current = { w, h, barCount };
// Resize bars array when count changes
if (barsRef.current.length !== barCount) {
barsRef.current = new Array(barCount).fill(0);
}
const dpr = window.devicePixelRatio || 1;
canvas.width = w * dpr;
canvas.height = h * dpr;
draw2d.setTransform(dpr, 0, 0, dpr, 0, 0);
}
syncCanvasSize();
const ro = new ResizeObserver(() => syncCanvasSize());
ro.observe(canvas);
// Hoist allocations out of draw loop — only reallocate when binCount changes
let dataArray: Uint8Array<ArrayBuffer> | null = null;
let binRanges: [number, number][] | null = null;
let lastBinCount = 0;
let lastBarCount = 0;
function draw() {
rafRef.current = requestAnimationFrame(draw);
@ -144,17 +174,25 @@ export function AtcWaveform({
const now = performance.now();
const analyser = analyserRef.current;
const binCount = analyser?.frequencyBinCount ?? 128;
const { w: cW, h: cH, barCount } = layoutRef.current;
if (binCount !== lastBinCount) {
if (binCount !== lastBinCount || barCount !== lastBarCount) {
dataArray = new Uint8Array(binCount) as Uint8Array<ArrayBuffer>;
binRanges = buildBinRanges(binCount, BAR_COUNT);
binRanges = buildBinRanges(binCount, barCount);
lastBinCount = binCount;
lastBarCount = barCount;
}
if (analyser && dataArray) analyser.getByteFrequencyData(dataArray);
draw2d!.clearRect(0, 0, CANVAS_W, CANVAS_H);
draw2d!.clearRect(0, 0, cW, cH);
for (let i = 0; i < BAR_COUNT; i++) {
// Compute theme once per frame (not per bar)
const isDark = document.documentElement.classList.contains("dark");
const idleFill = isDark
? "rgba(255, 255, 255, 0.1)"
: "rgba(0, 0, 0, 0.1)";
for (let i = 0; i < barCount; i++) {
// Average frequency bins in this bar's range
const [startBin, endBin] = binRanges![i];
let sum = 0;
@ -172,16 +210,16 @@ export function AtcWaveform({
barsRef.current[i] += (target - barsRef.current[i]) * LERP;
const val = barsRef.current[i];
const barH = Math.max(MIN_BAR_H, val * (CANVAS_H - 2));
const barH = Math.max(MIN_BAR_H, val * (cH - 2));
const x = i * (BAR_WIDTH + BAR_GAP);
const y = CANVAS_H - barH;
const y = cH - barH;
// Emerald when signal, dim white breathing when idle
// Emerald when signal, dim fill when idle
if (raw > 0.04) {
const intensity = Math.min(val * 1.6, 1);
draw2d!.fillStyle = `rgba(52, 211, 153, ${0.5 + intensity * 0.5})`;
} else {
draw2d!.fillStyle = "rgba(255, 255, 255, 0.1)";
draw2d!.fillStyle = idleFill;
}
draw2d!.beginPath();
draw2d!.roundRect(x, y, BAR_WIDTH, barH, 1);
@ -190,14 +228,16 @@ export function AtcWaveform({
}
draw();
return () => cancelAnimationFrame(rafRef.current);
return () => {
cancelAnimationFrame(rafRef.current);
ro.disconnect();
};
}, []);
return (
<canvas
ref={canvasRef}
className="h-7 shrink-0"
style={{ width: `${CANVAS_W}px`, imageRendering: "auto" }}
className="h-6 w-10 shrink-0 sm:h-7 sm:w-13"
aria-hidden="true"
/>
);

View File

@ -119,7 +119,7 @@ function HighlightMatch({ text, query }: { text: string; query: string }) {
return (
<>
{text.slice(0, idx)}
<span className="text-white/95 font-semibold">
<span className="text-foreground/95 font-semibold">
{text.slice(idx, idx + q.length)}
</span>
{text.slice(idx + q.length)}
@ -393,8 +393,8 @@ export function SearchContent({
label="Search airports, flights, and cities"
>
{/* ── Search input ──────────────────────────────────────────── */}
<div className="flex items-center gap-2.5 border-b border-white/6 mx-3 sm:mx-5 pb-3">
<Search className="h-3.5 w-3.5 shrink-0 text-white/25" />
<div className="flex items-center gap-2.5 border-b border-foreground/6 mx-3 sm:mx-5 pb-3">
<Search className="h-3.5 w-3.5 shrink-0 text-foreground/25" />
<Command.Input
ref={inputRef}
value={query}
@ -410,12 +410,12 @@ export function SearchContent({
}}
placeholder="Search airports, flights, ICAO24…"
aria-label="Search airports, flights, and cities"
className="flex-1 bg-transparent text-[14px] font-medium text-white/90 placeholder:text-white/20 outline-none"
className="flex-1 bg-transparent text-[14px] font-medium text-foreground/90 placeholder:text-foreground/20 outline-none"
/>
{query && (
<button
onClick={() => setQuery("")}
className="shrink-0 text-white/20 hover:text-white/40 transition-colors"
className="shrink-0 text-foreground/20 hover:text-foreground/40 transition-colors"
aria-label="Clear search"
>
<X className="h-3.5 w-3.5" />
@ -438,14 +438,14 @@ export function SearchContent({
className="flex-1 overflow-y-auto overflow-x-hidden scrollbar-none p-2"
>
<Command.Empty className="flex flex-col items-center justify-center py-10 gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-white/4">
<Globe2 className="h-5 w-5 text-white/15" />
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-foreground/4">
<Globe2 className="h-5 w-5 text-foreground/15" />
</div>
<div className="text-center space-y-1">
<p className="text-[13px] font-medium text-white/30">
<p className="text-[13px] font-medium text-foreground/30">
No results found
</p>
<p className="text-[11px] text-white/15 max-w-55 leading-relaxed">
<p className="text-[11px] text-foreground/15 max-w-55 leading-relaxed">
Try an airport code like &quot;JFK&quot;, a city name, or a flight
callsign like &quot;UAL123&quot;
</p>
@ -460,7 +460,7 @@ export function SearchContent({
<span>Recent</span>
<button
onClick={handleClearRecents}
className="text-[9px] font-medium text-white/20 hover:text-white/40 transition-colors normal-case tracking-normal"
className="text-[9px] font-medium text-foreground/20 hover:text-foreground/40 transition-colors normal-case tracking-normal"
>
Clear all
</button>
@ -478,10 +478,10 @@ export function SearchContent({
}}
className="search-item"
>
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-white/3">
<Clock className="h-3 w-3 text-white/25" />
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-foreground/3">
<Clock className="h-3 w-3 text-foreground/25" />
</div>
<span className="flex-1 truncate text-[13px] font-medium text-white/50">
<span className="flex-1 truncate text-[13px] font-medium text-foreground/50">
{r}
</span>
<button
@ -489,7 +489,7 @@ export function SearchContent({
e.stopPropagation();
handleRemoveRecent(r);
}}
className="shrink-0 opacity-0 group-data-[selected=true]/item:opacity-100 text-white/20 hover:text-white/40 transition-all"
className="shrink-0 opacity-0 group-data-[selected=true]/item:opacity-100 text-foreground/20 hover:text-foreground/40 transition-all"
aria-label={`Remove ${r} from recent searches`}
>
<Trash2 className="h-3 w-3" />
@ -509,24 +509,24 @@ export function SearchContent({
disabled={lookupBusy}
className="search-item"
>
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-white/4">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-foreground/4">
{lookupBusy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-white/40" />
<Loader2 className="h-3.5 w-3.5 animate-spin text-foreground/40" />
) : (
<Search className="h-3.5 w-3.5 text-white/40" />
<Search className="h-3.5 w-3.5 text-foreground/40" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="truncate text-[13px] font-medium text-white/70">
<p className="truncate text-[13px] font-medium text-foreground/70">
Search worldwide for &quot;{query.trim()}&quot;
</p>
<p className="text-[10px] text-white/25">
<p className="text-[10px] text-foreground/25">
{isIcao24Query
? "ICAO24 hex lookup"
: "Callsign / flight number lookup"}
</p>
</div>
<kbd className="hidden sm:inline-flex h-5 items-center rounded border border-white/8 bg-white/4 px-1.5 text-[9px] font-semibold text-white/25">
<kbd className="hidden sm:inline-flex h-5 items-center rounded border border-foreground/8 bg-foreground/4 px-1.5 text-[9px] font-semibold text-foreground/25">
</kbd>
</Command.Item>
@ -552,7 +552,7 @@ export function SearchContent({
Follow camera view
</p>
</div>
<kbd className="hidden sm:inline-flex h-5 items-center gap-0.5 rounded border border-white/8 bg-white/4 px-1.5 text-[9px] font-semibold text-white/25">
<kbd className="hidden sm:inline-flex h-5 items-center gap-0.5 rounded border border-foreground/8 bg-foreground/4 px-1.5 text-[9px] font-semibold text-foreground/25">
<span className="text-[8px]"></span>
</kbd>
</Command.Item>
@ -573,12 +573,12 @@ export function SearchContent({
onSelect={() => void openFlight(flight.icao24, false)}
className="search-item"
>
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-white/4">
<Plane className="h-3.5 w-3.5 text-white/40" />
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-foreground/4">
<Plane className="h-3.5 w-3.5 text-foreground/40" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<p className="truncate text-[13px] font-semibold text-white/80">
<p className="truncate text-[13px] font-semibold text-foreground/80">
<HighlightMatch text={cs} query={query} />
</p>
{activeFlightIcao24 === flight.icao24 && (
@ -587,14 +587,14 @@ export function SearchContent({
</span>
)}
</div>
<div className="flex items-center gap-1.5 text-[10px] text-white/25">
<div className="flex items-center gap-1.5 text-[10px] text-foreground/25">
<span className="font-mono">
<HighlightMatch
text={flight.icao24.toUpperCase()}
query={query}
/>
</span>
<span className="text-white/10">·</span>
<span className="text-foreground/10">·</span>
{flag && <span className="text-[10px]">{flag}</span>}
<span>{flight.originCountry}</span>
</div>
@ -603,21 +603,21 @@ export function SearchContent({
{/* Flight info chips */}
<div className="flex items-center gap-1.5 shrink-0">
{flight.baroAltitude != null && (
<span className="inline-flex items-center gap-1 rounded-md bg-white/3 px-1.5 py-0.5 text-[9px] font-medium text-white/30">
<span className="inline-flex items-center gap-1 rounded-md bg-foreground/3 px-1.5 py-0.5 text-[9px] font-medium text-foreground/30">
<AltitudeDot altitude={flight.baroAltitude} />
{metersToFeet(flight.baroAltitude)}
</span>
)}
{flight.velocity != null && (
<span className="inline-flex items-center gap-1 rounded-md bg-white/3 px-1.5 py-0.5 text-[9px] font-medium text-white/30">
<Gauge className="h-2.5 w-2.5 text-white/20" />
<span className="inline-flex items-center gap-1 rounded-md bg-foreground/3 px-1.5 py-0.5 text-[9px] font-medium text-foreground/30">
<Gauge className="h-2.5 w-2.5 text-foreground/20" />
{msToKnots(flight.velocity)}
</span>
)}
{flight.trueTrack != null && (
<span className="inline-flex items-center gap-1 rounded-md bg-white/3 px-1.5 py-0.5 text-[9px] font-medium text-white/30">
<span className="inline-flex items-center gap-1 rounded-md bg-foreground/3 px-1.5 py-0.5 text-[9px] font-medium text-foreground/30">
<ArrowUpRight
className="h-2.5 w-2.5 text-white/20"
className="h-2.5 w-2.5 text-foreground/20"
style={{
transform: `rotate(${flight.trueTrack - 45}deg)`,
}}
@ -663,29 +663,29 @@ export function SearchContent({
>
<div
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-lg ${
activeCity?.id === city.id ? "bg-white/8" : "bg-white/4"
activeCity?.id === city.id ? "bg-foreground/8" : "bg-foreground/4"
}`}
>
<MapPin
className={`h-3.5 w-3.5 ${
activeCity?.id === city.id
? "text-white/60"
: "text-white/35"
? "text-foreground/60"
: "text-foreground/35"
}`}
/>
</div>
<div className="flex-1 min-w-0">
<p className="truncate text-[13px] font-medium text-white/80">
<p className="truncate text-[13px] font-medium text-foreground/80">
<HighlightMatch text={city.name} query={query} />
</p>
<p className="text-[10px] font-medium text-white/25">
<p className="text-[10px] font-medium text-foreground/25">
<HighlightMatch text={city.iata} query={query} />
<span className="text-white/10"> · </span>
<span className="text-foreground/10"> · </span>
{city.country}
</p>
</div>
{activeCity?.id === city.id && (
<span className="shrink-0 rounded-full bg-white/6 px-1.5 py-px text-[8px] font-bold uppercase tracking-wider text-white/30">
<span className="shrink-0 rounded-full bg-foreground/6 px-1.5 py-px text-[8px] font-bold uppercase tracking-wider text-foreground/30">
Current
</span>
)}
@ -713,31 +713,31 @@ export function SearchContent({
<div
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-lg ${
activeCity?.iata === airport.iata
? "bg-white/8"
: "bg-white/4"
? "bg-foreground/8"
: "bg-foreground/4"
}`}
>
<MapPin
className={`h-3.5 w-3.5 ${
activeCity?.iata === airport.iata
? "text-white/60"
: "text-white/35"
? "text-foreground/60"
: "text-foreground/35"
}`}
/>
</div>
<div className="flex-1 min-w-0">
<p className="truncate text-[13px] font-medium text-white/80">
<p className="truncate text-[13px] font-medium text-foreground/80">
<HighlightMatch text={airport.name} query={query} />
</p>
<p className="text-[10px] font-medium text-white/25">
<p className="text-[10px] font-medium text-foreground/25">
<HighlightMatch text={airport.iata} query={query} />
<span className="text-white/10"> · </span>
<span className="text-foreground/10"> · </span>
<HighlightMatch text={airport.city} query={query} />,{" "}
{airport.country}
</p>
</div>
{activeCity?.iata === airport.iata && (
<span className="shrink-0 rounded-full bg-white/6 px-1.5 py-px text-[8px] font-bold uppercase tracking-wider text-white/30">
<span className="shrink-0 rounded-full bg-foreground/6 px-1.5 py-px text-[8px] font-bold uppercase tracking-wider text-foreground/30">
Current
</span>
)}
@ -756,7 +756,7 @@ export function SearchContent({
{/* ── Footer hint ───────────────────────────────────────── */}
{!query && !showRecents && (
<div className="flex items-center justify-center gap-2 py-4">
<p className="text-[10px] text-white/12 font-medium">
<p className="text-[10px] text-foreground/12 font-medium">
Search 9,000+ airports worldwide
</p>
</div>

View File

@ -12,11 +12,14 @@ import {
Shield,
Flame,
Eye,
CloudRain,
} from "lucide-react";
import {
useSettings,
AIRSPACE_OPACITY_MIN,
AIRSPACE_OPACITY_MAX,
WEATHER_RADAR_OPACITY_MIN,
WEATHER_RADAR_OPACITY_MAX,
type OrbitDirection,
} from "@/hooks/use-settings";
import { ScrollArea } from "@/components/ui/scroll-area";
@ -139,6 +142,24 @@ export function SettingsContent() {
</>
)}
{/* ── Weather ── */}
<SectionHeader title="Weather" />
<SettingRow
icon={<CloudRain className="h-4 w-4" />}
title="Weather radar"
description="Live precipitation radar overlay (RainViewer)"
checked={settings.showWeatherRadar}
onChange={(v) => update("showWeatherRadar", v)}
/>
{settings.showWeatherRadar && (
<WeatherRadarOpacitySlider
value={settings.weatherRadarOpacity}
onChange={(v) => update("weatherRadarOpacity", v)}
/>
)}
{/* ── Performance ── */}
<SectionHeader title="Performance" />
@ -151,19 +172,19 @@ export function SettingsContent() {
badge="BETA"
/>
<div className="mx-3 my-2 h-px bg-white/4" />
<div className="mx-3 my-2 h-px bg-foreground/5" />
<div className="px-3 pt-2">
<button
type="button"
onClick={reset}
className="inline-flex h-8 items-center justify-center rounded-lg px-3 text-[12px] font-medium text-white/65 ring-1 ring-white/10 transition-colors hover:bg-white/5 hover:text-white/85"
className="inline-flex h-8 items-center justify-center rounded-lg px-3 text-[12px] font-medium text-foreground/65 ring-1 ring-foreground/10 transition-colors hover:bg-foreground/5 hover:text-foreground/85"
>
Reset to defaults
</button>
</div>
<div className="mx-3 my-2 h-px bg-white/4" />
<div className="mx-3 my-2 h-px bg-foreground/5" />
</div>
</ScrollArea>
);
@ -177,12 +198,12 @@ export function ShortcutsContent() {
{SHORTCUTS.map(({ key, description }) => (
<div
key={key}
className="flex items-center justify-between gap-3 rounded-xl px-3 py-2.5 transition-colors hover:bg-white/4"
className="flex items-center justify-between gap-3 rounded-xl px-3 py-2.5 transition-colors hover:bg-foreground/4"
>
<span className="text-[13px] font-medium text-white/68">
<span className="text-[13px] font-medium text-foreground/68">
{description}
</span>
<kbd className="flex h-7 min-w-7 items-center justify-center rounded-md bg-white/6 px-2 font-mono text-[11px] font-semibold text-white/74 ring-1 ring-white/8">
<kbd className="flex h-7 min-w-7 items-center justify-center rounded-md bg-foreground/6 px-2 font-mono text-[11px] font-semibold text-foreground/74 ring-1 ring-foreground/8">
{key}
</kbd>
</div>
@ -218,13 +239,15 @@ function OrbitSpeedSlider({
return (
<div className="flex w-full items-center gap-3.5 rounded-xl px-3 py-2.5 text-left">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white/5 text-white/35 ring-1 ring-white/6">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-foreground/5 text-foreground/35 ring-1 ring-foreground/6">
<RotateCw className="h-4 w-4" />
</div>
<div className="flex flex-1 min-w-0 flex-col gap-2">
<div className="flex items-center justify-between">
<p className="text-[13px] font-medium text-white/80">Orbit speed</p>
<span className="text-[11px] font-semibold text-white/40 tabular-nums">
<p className="text-[13px] font-medium text-foreground/80">
Orbit speed
</p>
<span className="text-[11px] font-semibold text-foreground/40 tabular-nums">
{activeLabel}
</span>
</div>
@ -249,7 +272,7 @@ function OrbitSpeedSlider({
<span
key={preset.label}
className={`absolute h-1.5 w-1.5 rounded-full -translate-x-1/2 -translate-y-1/2 transition-colors ${
isActive ? "bg-white/50" : "bg-white/15"
isActive ? "bg-foreground/50" : "bg-foreground/15"
}`}
style={{ left: `${pct}%` }}
/>
@ -271,15 +294,15 @@ function TrailThicknessSlider({
}) {
return (
<div className="flex w-full items-center gap-3.5 rounded-xl px-3 py-2.5 text-left">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white/5 text-white/35 ring-1 ring-white/6">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-foreground/5 text-foreground/35 ring-1 ring-foreground/6">
<Layers className="h-4 w-4" />
</div>
<div className="flex flex-1 min-w-0 flex-col gap-2">
<div className="flex items-center justify-between">
<p className="text-[13px] font-medium text-white/80">
<p className="text-[13px] font-medium text-foreground/80">
Trail thickness
</p>
<span className="text-[11px] font-semibold text-white/40 tabular-nums">
<span className="text-[11px] font-semibold text-foreground/40 tabular-nums">
{value.toFixed(1)} px
</span>
</div>
@ -305,15 +328,15 @@ function TrailDistanceSlider({
}) {
return (
<div className="flex w-full items-center gap-3.5 rounded-xl px-3 py-2.5 text-left">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white/5 text-white/35 ring-1 ring-white/6">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-foreground/5 text-foreground/35 ring-1 ring-foreground/6">
<Route className="h-4 w-4" />
</div>
<div className="flex flex-1 min-w-0 flex-col gap-2">
<div className="flex items-center justify-between">
<p className="text-[13px] font-medium text-white/80">
<p className="text-[13px] font-medium text-foreground/80">
Trail distance
</p>
<span className="text-[11px] font-semibold text-white/40 tabular-nums">
<span className="text-[11px] font-semibold text-foreground/40 tabular-nums">
{value} pts
</span>
</div>
@ -339,15 +362,15 @@ function AirspaceOpacitySlider({
}) {
return (
<div className="flex w-full items-center gap-3.5 rounded-xl px-3 py-2.5 text-left">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white/5 text-white/35 ring-1 ring-white/6">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-foreground/5 text-foreground/35 ring-1 ring-foreground/6">
<Eye className="h-4 w-4" />
</div>
<div className="flex flex-1 min-w-0 flex-col gap-2">
<div className="flex items-center justify-between">
<p className="text-[13px] font-medium text-white/80">
<p className="text-[13px] font-medium text-foreground/80">
Airspace opacity
</p>
<span className="text-[11px] font-semibold text-white/40 tabular-nums">
<span className="text-[11px] font-semibold text-foreground/40 tabular-nums">
{Math.round(value * 100)}%
</span>
</div>
@ -364,13 +387,47 @@ function AirspaceOpacitySlider({
);
}
function WeatherRadarOpacitySlider({
value,
onChange,
}: {
value: number;
onChange: (v: number) => void;
}) {
return (
<div className="flex w-full items-center gap-3.5 rounded-xl px-3 py-2.5 text-left">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-foreground/5 text-foreground/35 ring-1 ring-foreground/6">
<CloudRain className="h-4 w-4" />
</div>
<div className="flex flex-1 min-w-0 flex-col gap-2">
<div className="flex items-center justify-between">
<p className="text-[13px] font-medium text-foreground/80">
Radar opacity
</p>
<span className="text-[11px] font-semibold text-foreground/40 tabular-nums">
{Math.round(value * 100)}%
</span>
</div>
<Slider
min={WEATHER_RADAR_OPACITY_MIN}
max={WEATHER_RADAR_OPACITY_MAX}
step={0.05}
value={[value]}
onValueChange={(vals) => onChange(vals[0])}
aria-label="Weather radar opacity"
/>
</div>
</div>
);
}
function SectionHeader({ title }: { title: string }) {
return (
<div className="flex items-center gap-2 px-3 pt-3 pb-1">
<span className="text-[10px] font-bold tracking-widest text-white/25 uppercase">
<span className="text-[10px] font-bold tracking-widest text-foreground/25 uppercase">
{title}
</span>
<div className="h-px flex-1 bg-white/4" />
<div className="h-px flex-1 bg-foreground/4" />
</div>
);
}
@ -395,21 +452,21 @@ function SettingRow({
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className="flex w-full items-center gap-3.5 rounded-xl px-3 py-3 text-left transition-colors hover:bg-white/4 active:bg-white/6"
className="flex w-full items-center gap-3.5 rounded-xl px-3 py-3 text-left transition-colors hover:bg-foreground/4 active:bg-foreground/6"
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white/5 text-white/35 ring-1 ring-white/6">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-foreground/5 text-foreground/35 ring-1 ring-foreground/6">
{icon}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<p className="text-[13px] font-medium text-white/80">{title}</p>
<p className="text-[13px] font-medium text-foreground/80">{title}</p>
{badge && (
<span className="inline-flex items-center rounded-md bg-indigo-500/15 px-1.5 py-0.5 text-[9px] font-bold tracking-wider text-indigo-300 ring-1 ring-indigo-400/20">
{badge}
</span>
)}
</div>
<p className="mt-0.5 text-[11px] font-medium leading-relaxed text-white/22">
<p className="mt-0.5 text-[11px] font-medium leading-relaxed text-foreground/22">
{description}
</p>
</div>
@ -433,16 +490,16 @@ function SegmentRow<T extends string | number>({
}) {
return (
<div className="flex w-full items-center gap-3.5 rounded-xl px-3 py-2.5 text-left">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white/5 text-white/35 ring-1 ring-white/6">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-foreground/5 text-foreground/35 ring-1 ring-foreground/6">
{icon}
</div>
<p className="flex-1 min-w-0 text-[13px] font-medium text-white/80">
<p className="flex-1 min-w-0 text-[13px] font-medium text-foreground/80">
{title}
</p>
<div
role="radiogroup"
aria-label={title}
className="flex shrink-0 rounded-md bg-white/4 p-0.5 ring-1 ring-white/6"
className="flex shrink-0 rounded-md bg-foreground/4 p-0.5 ring-1 ring-foreground/6"
>
{options.map((opt) => {
const isActive = opt.value === value;
@ -453,13 +510,15 @@ function SegmentRow<T extends string | number>({
aria-checked={isActive}
onClick={() => onChange(opt.value)}
className={`relative rounded-md px-2 py-1 text-[11px] font-semibold transition-colors ${
isActive ? "text-white/90" : "text-white/30 hover:text-white/50"
isActive
? "text-foreground/90"
: "text-foreground/30 hover:text-foreground/50"
}`}
>
{isActive && (
<motion.div
layoutId={`seg-${title}`}
className="absolute inset-0 rounded-md bg-white/10"
className="absolute inset-0 rounded-md bg-foreground/10"
transition={{
type: "spring",
stiffness: 500,
@ -480,14 +539,14 @@ function Toggle({ checked }: { checked: boolean }) {
return (
<div
className={`relative h-5 w-9 shrink-0 rounded-full transition-colors duration-200 ${
checked ? "bg-white/20" : "bg-white/6"
checked ? "bg-foreground/20" : "bg-foreground/6"
}`}
>
<motion.div
animate={{ x: checked ? 17 : 2 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className={`absolute top-0.75 h-3.5 w-3.5 rounded-full shadow-sm transition-colors duration-200 ${
checked ? "bg-white" : "bg-white/25"
checked ? "bg-foreground" : "bg-foreground/25"
}`}
/>
</div>
@ -573,11 +632,11 @@ export function AboutContent() {
return (
<ScrollArea className="h-full">
<div className="flex flex-col gap-5 p-5 pt-3">
<h3 className="text-[20px] font-bold tracking-tight text-white/90">
<h3 className="text-[20px] font-bold tracking-tight text-foreground/90">
Aeris
</h3>
<div className="space-y-3 text-[13px] leading-relaxed text-white/40">
<div className="space-y-3 text-[13px] leading-relaxed text-foreground/40">
<p>
Live flight tracking in 3D. The planes you see are real position
data comes from ADS-B Exchange, adsb.lol, and OpenSky Network,
@ -592,40 +651,49 @@ export function AboutContent() {
</p>
</div>
<div className="h-px w-full bg-white/6" />
<div className="h-px w-full bg-foreground/6" />
<p className="text-[12px] leading-relaxed text-white/30">
Built by{" "}
<p className="text-[12px] leading-relaxed text-foreground/30">
Built by a human, not just LLMs.{" "}
<a
href="https://github.com/kewonit"
target="_blank"
rel="noopener noreferrer"
className="text-white/55 underline decoration-white/15 underline-offset-2 hover:text-white/70 transition-colors"
className="text-foreground/55 underline decoration-foreground/15 underline-offset-2 hover:text-foreground/70 transition-colors"
>
kewonit
</a>
{" · "}
<a
href="https://x.com/kewonit"
target="_blank"
rel="noopener noreferrer"
className="text-foreground/55 underline decoration-foreground/15 underline-offset-2 hover:text-foreground/70 transition-colors"
>
@kewonit
</a>
. Open to internships {" "}
<a
href="mailto:kew@edbn.me"
className="text-white/55 underline decoration-white/15 underline-offset-2 hover:text-white/70 transition-colors"
className="text-foreground/55 underline decoration-foreground/15 underline-offset-2 hover:text-foreground/70 transition-colors"
>
kew@edbn.me
</a>
</p>
<p className="text-[12px] leading-relaxed text-white/30">
<p className="text-[12px] leading-relaxed text-foreground/30">
Source is on{" "}
<a
href="https://github.com/kewonit/aeris"
target="_blank"
rel="noopener noreferrer"
className="text-white/55 underline decoration-white/15 underline-offset-2 hover:text-white/70 transition-colors"
className="text-foreground/55 underline decoration-foreground/15 underline-offset-2 hover:text-foreground/70 transition-colors"
>
GitHub
</a>
. Got a question or just wanna say hi?{" "}
<a
href="mailto:aeris@edbn.me"
className="text-white/55 underline decoration-white/15 underline-offset-2 hover:text-white/70 transition-colors"
className="text-foreground/55 underline decoration-foreground/15 underline-offset-2 hover:text-foreground/70 transition-colors"
>
aeris@edbn.me
</a>
@ -639,16 +707,16 @@ export function ChangelogContent() {
return (
<ScrollArea className="h-full">
<div className="flex flex-col gap-4 p-5 pt-3">
{CHANGELOG.map((entry) => (
<div key={entry.date} className="flex gap-3">
<span className="shrink-0 pt-0.5 text-[11px] tabular-nums text-white/20 w-11">
{CHANGELOG.map((entry, i) => (
<div key={`${entry.date}-${i}`} className="flex gap-3">
<span className="shrink-0 pt-0.5 text-[11px] tabular-nums text-foreground/20 w-11">
{entry.date}
</span>
<div className="min-w-0">
<p className="text-[13px] font-medium text-white/55">
<p className="text-[13px] font-medium text-foreground/55">
{entry.title}
</p>
<p className="mt-0.5 text-[12px] leading-relaxed text-white/30">
<p className="mt-0.5 text-[12px] leading-relaxed text-foreground/30">
{entry.description}
</p>
</div>

View File

@ -27,8 +27,8 @@ export function StyleContent({
/>
))}
</div>
<div className="border-t border-white/4 px-5 py-3">
<p className="text-[11px] font-medium text-white/12">
<div className="border-t border-foreground/5 px-5 py-3">
<p className="text-[11px] font-medium text-foreground/15">
Satellite © Esri · Terrain © AWS/Mapzen Terrain Tiles · Base maps ©
CARTO
</p>
@ -63,8 +63,8 @@ function StyleTile({
<div
className={`relative aspect-16/10 w-full overflow-hidden rounded-xl transition-all duration-200 ${
isActive
? "ring-2 ring-white/50 ring-offset-2 ring-offset-black/80 shadow-[0_0_20px_rgba(255,255,255,0.06)]"
: "ring-1 ring-white/8 group-hover:ring-white/18"
? "ring-2 ring-primary/50 ring-offset-2 ring-offset-background/80 shadow-[0_0_20px_rgba(0,0,0,0.06)] dark:shadow-[0_0_20px_rgba(255,255,255,0.06)]"
: "ring-1 ring-foreground/10 group-hover:ring-foreground/20"
}`}
>
<div
@ -96,9 +96,9 @@ function StyleTile({
stiffness: 500,
damping: 28,
}}
className="absolute right-1.5 top-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-white shadow-md shadow-black/30"
className="absolute right-1.5 top-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-foreground shadow-md shadow-background/30"
>
<Check className="h-3 w-3 text-black" strokeWidth={3} />
<Check className="h-3 w-3 text-background" strokeWidth={3} />
</motion.div>
)}
</AnimatePresence>
@ -108,14 +108,14 @@ function StyleTile({
<span
className={`text-[12px] font-semibold tracking-tight transition-colors ${
isActive
? "text-white/90"
: "text-white/40 group-hover:text-white/60"
? "text-foreground/90"
: "text-foreground/40 group-hover:text-foreground/60"
}`}
>
{style.name}
</span>
{style.dark && (
<span className="h-0.5 w-0.5 rounded-full bg-white/20" />
<span className="h-0.5 w-0.5 rounded-full bg-foreground/20" />
)}
</div>
</motion.button>

View File

@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useRef, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { motion, AnimatePresence } from "motion/react";
import {
Search,
@ -69,6 +70,11 @@ export function ControlPanel({
onLookupFlight,
}: ControlPanelProps) {
const [openTab, setOpenTab] = useState<TabId | null>(null);
const [portalMounted, setPortalMounted] = useState(false);
useEffect(() => {
setPortalMounted(true);
}, []);
useEffect(() => {
function handleOpenSearch() {
@ -125,25 +131,29 @@ export function ControlPanel({
<Settings className="h-4 w-4" />
</motion.button>
<AnimatePresence>
{openTab && (
<PanelDialog
activeTab={openTab}
onTabChange={setOpenTab}
onClose={close}
activeCity={activeCity}
onSelectCity={(c) => {
onSelectCity(c);
close();
}}
activeStyle={activeStyle}
onSelectStyle={onSelectStyle}
flights={flights}
activeFlightIcao24={activeFlightIcao24}
onLookupFlight={onLookupFlight}
/>
{portalMounted &&
createPortal(
<AnimatePresence>
{openTab && (
<PanelDialog
activeTab={openTab}
onTabChange={setOpenTab}
onClose={close}
activeCity={activeCity}
onSelectCity={(c) => {
onSelectCity(c);
close();
}}
activeStyle={activeStyle}
onSelectStyle={onSelectStyle}
flights={flights}
activeFlightIcao24={activeFlightIcao24}
onLookupFlight={onLookupFlight}
/>
)}
</AnimatePresence>,
document.body,
)}
</AnimatePresence>
</>
);
}
@ -225,7 +235,7 @@ function PanelDialog({
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-80 bg-black/70"
className="fixed inset-0 z-80 bg-background/70"
onClick={onClose}
/>
@ -245,10 +255,10 @@ function PanelDialog({
aria-modal="true"
aria-labelledby="panel-dialog-title"
>
<div className="flex flex-col sm:flex-row overflow-hidden rounded-2xl sm:rounded-3xl border border-white/8 bg-[#0c0c0e] shadow-[0_40px_100px_rgba(0,0,0,0.8),0_0_0_1px_rgba(255,255,255,0.04)_inset] h-[75vh] sm:h-auto sm:max-h-[85vh]">
<div className="flex flex-col sm:flex-row overflow-hidden rounded-2xl sm:rounded-3xl border border-border bg-popover shadow-[0_40px_100px_rgba(0,0,0,0.25)] dark:shadow-[0_40px_100px_rgba(0,0,0,0.8),0_0_0_1px_rgba(255,255,255,0.04)_inset] h-[75vh] sm:h-auto sm:max-h-[85vh]">
{/* Desktop sidebar (hidden on mobile) */}
<div className="hidden sm:flex w-52 shrink-0 flex-col border-r border-white/6 py-5 px-3">
<p className="mb-3 px-2 text-[11px] font-semibold uppercase tracking-widest text-white/20">
<div className="hidden sm:flex w-52 shrink-0 flex-col border-r border-border py-5 px-3">
<p className="mb-3 px-2 text-[11px] font-semibold uppercase tracking-widest text-muted-foreground/40">
Controls
</p>
<nav className="flex flex-col gap-0.5">
@ -260,14 +270,14 @@ function PanelDialog({
onClick={() => onTabChange(id)}
className={`group relative flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-left transition-colors ${
active
? "text-white/90"
: "text-white/35 hover:text-white/55 hover:bg-white/4"
? "text-foreground/90"
: "text-foreground/35 hover:text-foreground/55 hover:bg-foreground/4"
}`}
>
{active && (
<motion.div
layoutId="panel-tab-bg"
className="absolute inset-0 rounded-xl bg-white/8"
className="absolute inset-0 rounded-xl bg-foreground/8"
transition={{
type: "spring",
stiffness: 400,
@ -290,7 +300,7 @@ function PanelDialog({
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub (opens in new tab)"
className="group relative flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-left transition-colors text-white/35 hover:text-white/55 hover:bg-white/4"
className="group relative flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-left transition-colors text-foreground/35 hover:text-foreground/55 hover:bg-foreground/4"
>
<Github
className="relative h-4 w-4 shrink-0"
@ -298,8 +308,8 @@ function PanelDialog({
/>
<span className="relative text-[14px] font-medium">GitHub</span>
</a>
<div className="border-t border-white/3 pt-2 px-2.5">
<p className="text-[10px] font-medium text-white/10 tracking-wide">
<div className="border-t border-foreground/5 pt-2 px-2.5">
<p className="text-[10px] font-medium text-foreground/15 tracking-wide">
Data from ADS-B Exchange, adsb.lol &amp; OpenSky
</p>
</div>
@ -311,7 +321,7 @@ function PanelDialog({
<div className="flex sm:hidden items-center justify-between px-4 pt-4 pb-2">
<h2
id="panel-dialog-title"
className="text-[14px] font-semibold tracking-tight text-white/90"
className="text-[14px] font-semibold tracking-tight text-foreground/90"
>
{PANEL_TABS.find((t) => t.id === activeTab)?.label}
</h2>
@ -320,18 +330,18 @@ function PanelDialog({
<div className="hidden sm:flex items-center justify-between px-5 pt-5 pb-2">
<h2
id="panel-dialog-title"
className="text-[15px] font-semibold tracking-tight text-white/90"
className="text-[15px] font-semibold tracking-tight text-foreground/90"
>
{PANEL_TABS.find((t) => t.id === activeTab)?.label}
</h2>
<motion.button
onClick={onClose}
className="flex h-7 w-7 items-center justify-center rounded-full bg-white/6 transition-colors hover:bg-white/12"
className="flex h-7 w-7 items-center justify-center rounded-full bg-foreground/6 transition-colors hover:bg-foreground/12"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
aria-label="Close"
>
<X className="h-3.5 w-3.5 text-white/40" />
<X className="h-3.5 w-3.5 text-foreground/40" />
</motion.button>
</div>
@ -385,7 +395,7 @@ function PanelDialog({
</div>
{/* Mobile tab bar */}
<div className="flex sm:hidden items-center gap-0.5 border-t border-white/6 px-2 pt-2 pb-3">
<div className="flex sm:hidden items-center gap-0.5 border-t border-border px-2 pt-2 pb-3">
<nav className="flex flex-1 gap-0.5">
{PANEL_TABS.map(({ id, icon: Icon, label }) => {
const active = id === activeTab;
@ -395,15 +405,15 @@ function PanelDialog({
onClick={() => onTabChange(id)}
className={`relative flex flex-1 items-center justify-center rounded-lg py-2.5 transition-colors ${
active
? "text-white/90"
: "text-white/35 active:bg-white/6"
? "text-foreground/90"
: "text-foreground/35 active:bg-foreground/6"
}`}
aria-label={label}
>
{active && (
<motion.div
layoutId="panel-tab-bg-mobile"
className="absolute inset-0 rounded-lg bg-white/8"
className="absolute inset-0 rounded-lg bg-foreground/8"
transition={{
type: "spring",
stiffness: 400,
@ -418,11 +428,11 @@ function PanelDialog({
</nav>
<motion.button
onClick={onClose}
className="ml-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white/6 transition-colors active:bg-white/12"
className="ml-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-foreground/6 transition-colors active:bg-foreground/12"
whileTap={{ scale: 0.9 }}
aria-label="Close"
>
<X className="h-3.5 w-3.5 text-white/40" />
<X className="h-3.5 w-3.5 text-foreground/40" />
</motion.button>
</div>
</div>

View File

@ -14,11 +14,15 @@ import {
Building2,
Eye,
ChevronRight,
Shield,
AlertTriangle,
} from "lucide-react";
import { useAircraftPhotos } from "@/hooks/use-aircraft-photos";
import { AircraftPhotos } from "@/components/ui/aircraft-photos";
import { HeroBanner } from "@/components/ui/hero-banner";
import type { FlightState } from "@/lib/opensky";
import { VerticalProfile } from "@/components/ui/vertical-profile";
import type { TrailEntry } from "@/hooks/use-trail-history";
import {
metersToFeet,
msToKnots,
@ -37,6 +41,7 @@ import {
type FlightCardProps = {
flight: FlightState | null;
trail?: TrailEntry | null;
onClose: () => void;
onToggleFpv?: (icao24: string) => void;
isFpvActive?: boolean;
@ -44,6 +49,7 @@ type FlightCardProps = {
export function FlightCard({
flight,
trail,
onClose,
onToggleFpv,
isFpvActive = false,
@ -53,7 +59,7 @@ export function FlightCard({
const company =
airline ?? (flight ? `${flight.originCountry} operator` : null);
const model = flight ? aircraftTypeHint(flight.category) : null;
const logoCandidates = airlineLogoCandidates(airline);
const logoCandidates = airlineLogoCandidates(airline, flight?.callsign);
const heading = flight?.trueTrack ?? null;
const cardinal = heading !== null ? headingToCardinal(heading) : null;
const canEnterFpv =
@ -116,14 +122,14 @@ export function FlightCard({
aria-label="Selected flight details"
aria-live="polite"
>
<div className="overflow-hidden rounded-2xl border border-white/8 bg-black/60 shadow-2xl shadow-black/40 backdrop-blur-2xl">
<div className="overflow-hidden rounded-2xl border border-foreground/8 bg-background/60 shadow-2xl shadow-background/40 backdrop-blur-2xl">
<HeroBanner photo={heroPhoto} loading={photosLoading} />
<div className="p-4">
<div className="flex items-center gap-3.5">
<div className="relative flex h-20 w-20 items-center justify-center rounded-2xl border border-white/14 bg-white/10 shadow-lg shadow-black/25">
<div className="relative flex h-20 w-20 items-center justify-center rounded-2xl border border-foreground/14 bg-foreground/10 shadow-lg shadow-background/25">
{showLogo ? (
<span className="relative flex h-18 w-18 items-center justify-center overflow-hidden rounded-xl border border-black/10 bg-white/95 p-3.5 shadow-sm">
<span className="relative flex h-18 w-18 items-center justify-center overflow-hidden rounded-xl border border-background/10 bg-white/95 p-3.5 shadow-sm">
{!logoLoaded && (
<span
aria-hidden="true"
@ -163,9 +169,9 @@ export function FlightCard({
/>
</span>
) : (
<span className="relative flex h-18 w-18 items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-white/95 p-3.5 shadow-sm">
<span className="relative flex h-18 w-18 items-center justify-center overflow-hidden rounded-xl border border-foreground/10 bg-white/95 p-3.5 shadow-sm">
{genericLogoFailed ? (
<span className="text-[22px] font-semibold text-black/25">
<span className="text-[22px] font-semibold text-background/25">
</span>
) : (
@ -183,10 +189,10 @@ export function FlightCard({
)}
</div>
<div>
<p className="text-base font-bold leading-tight text-white">
<p className="text-base font-bold leading-tight text-foreground">
{formatCallsign(flight.callsign)}
</p>
<p className="mt-0.5 text-[11px] font-medium tracking-widest text-white/35 uppercase">
<p className="mt-0.5 text-[11px] font-medium tracking-widest text-foreground/35 uppercase">
{flight.icao24}
{flightNum ? ` · #${flightNum}` : ""}
</p>
@ -195,17 +201,36 @@ export function FlightCard({
{company && (
<div className="mt-2.5 flex items-center gap-1.5">
<Building2 className="h-3 w-3 text-white/25" />
<p className="text-xs font-medium text-white/50">
<Building2 className="h-3 w-3 text-foreground/25" />
<p className="text-xs font-medium text-foreground/50">
{company}
{model ? (
<span className="text-white/30"> · {model}</span>
<span className="text-foreground/30"> · {model}</span>
) : null}
</p>
</div>
)}
<div className="mt-3 h-px bg-linear-to-r from-transparent via-white/6 to-transparent" />
{/* Military / Emergency badges */}
{(isMilitary(flight.dbFlags) ||
isEmergencyStatus(flight.emergencyStatus)) && (
<div className="mt-2 flex items-center gap-1.5">
{isMilitary(flight.dbFlags) && (
<span className="inline-flex items-center gap-1 rounded bg-amber-500/15 px-1.5 py-0.5 text-[9px] font-bold tracking-wider text-amber-400 uppercase ring-1 ring-amber-400/20">
<Shield className="h-2.5 w-2.5" />
MIL
</span>
)}
{isEmergencyStatus(flight.emergencyStatus) && (
<span className="inline-flex animate-pulse items-center gap-1 rounded bg-red-500/15 px-1.5 py-0.5 text-[9px] font-bold tracking-wider text-red-400 uppercase ring-1 ring-red-400/25">
<AlertTriangle className="h-2.5 w-2.5" />
{flight.emergencyStatus?.toUpperCase()}
</span>
)}
</div>
)}
<div className="mt-3 h-px bg-linear-to-r from-transparent via-foreground/6 to-transparent" />
<div className="mt-3 grid grid-cols-2 gap-3">
<Metric
@ -239,19 +264,19 @@ export function FlightCard({
/>
</div>
<div className="mt-3 h-px bg-linear-to-r from-transparent via-white/6 to-transparent" />
<div className="mt-3 h-px bg-linear-to-r from-transparent via-foreground/6 to-transparent" />
<div className="mt-2.5 flex flex-col gap-1.5">
<div className="flex items-center gap-1.5">
<Globe className="h-3 w-3 text-white/25" />
<p className="text-[11px] text-white/40">
<Globe className="h-3 w-3 text-foreground/25" />
<p className="text-[11px] text-foreground/40">
{flight.originCountry}
</p>
</div>
{cardinal && (
<div className="flex items-center gap-1.5">
<Navigation
className="h-3 w-3 text-white/25"
className="h-3 w-3 text-foreground/25"
style={{
transform:
heading !== null && Number.isFinite(heading)
@ -259,13 +284,13 @@ export function FlightCard({
: undefined,
}}
/>
<p className="text-[11px] text-white/40">
<p className="text-[11px] text-foreground/40">
Heading {cardinal}
{flight.latitude !== null &&
flight.longitude !== null &&
Number.isFinite(flight.latitude) &&
Number.isFinite(flight.longitude) && (
<span className="text-white/20">
<span className="text-foreground/20">
{" "}
· {Math.abs(flight.latitude).toFixed(2)}°
{flight.latitude >= 0 ? "N" : "S"},{" "}
@ -282,7 +307,7 @@ export function FlightCard({
className={`h-3 w-3 text-center text-[8px] font-bold leading-3 ${
isEmergencySquawk(flight.squawk)
? "text-red-400"
: "text-white/25"
: "text-foreground/25"
}`}
>
SQ
@ -291,7 +316,7 @@ export function FlightCard({
className={`font-mono text-[11px] tabular-nums ${
isEmergencySquawk(flight.squawk)
? "text-red-400"
: "text-white/40"
: "text-foreground/40"
}`}
>
{flight.squawk}
@ -307,7 +332,7 @@ export function FlightCard({
{onToggleFpv && (
<div className="mt-3">
<div className="h-px bg-linear-to-r from-transparent via-white/6 to-transparent" />
<div className="h-px bg-linear-to-r from-transparent via-foreground/6 to-transparent" />
<button
type="button"
onClick={() =>
@ -339,17 +364,17 @@ export function FlightCard({
}
>
<Eye
className={`h-3 w-3 ${isFpvActive ? "text-emerald-400" : "text-white/25"}`}
className={`h-3 w-3 ${isFpvActive ? "text-emerald-400" : "text-foreground/25"}`}
/>
<span
className={`text-[11px] font-medium tracking-wide uppercase ${isFpvActive ? "text-emerald-400/70" : "text-white/30"}`}
className={`text-[11px] font-medium tracking-wide uppercase ${isFpvActive ? "text-emerald-400/70" : "text-foreground/30"}`}
>
{isFpvActive
? "Exit First Person View"
: "First Person View"}
</span>
<ChevronRight
className={`ml-auto h-2.5 w-2.5 ${isFpvActive ? "text-emerald-400/40" : "text-white/20"}`}
className={`ml-auto h-2.5 w-2.5 ${isFpvActive ? "text-emerald-400/40" : "text-foreground/20"}`}
/>
</button>
</div>
@ -362,16 +387,23 @@ export function FlightCard({
error={photosError}
/>
{trail && trail.path.length >= 3 && (
<VerticalProfile
trail={trail}
navAltitudeMcp={flight.navAltitudeMcp}
/>
)}
<div className="mt-3">
<div className="h-px bg-linear-to-r from-transparent via-white/6 to-transparent" />
<div className="h-px bg-linear-to-r from-transparent via-foreground/6 to-transparent" />
<button
type="button"
onClick={onClose}
className="mt-2 flex w-full items-center gap-1.5 text-left transition-colors hover:opacity-70"
aria-label="Deselect flight"
>
<X className="h-3 w-3 text-white/25" />
<span className="text-[11px] font-medium tracking-wide text-white/30 uppercase">
<X className="h-3 w-3 text-foreground/25" />
<span className="text-[11px] font-medium tracking-wide text-foreground/30 uppercase">
Close
</span>
</button>
@ -404,6 +436,14 @@ function squawkLabel(squawk: string): string {
}
}
function isMilitary(dbFlags?: number | null): boolean {
return ((dbFlags ?? 0) & 1) !== 0;
}
function isEmergencyStatus(status?: string | null): boolean {
return !!status && status !== "none";
}
function Metric({
icon,
label,
@ -415,13 +455,13 @@ function Metric({
}) {
return (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1.5 text-white/25">
<div className="flex items-center gap-1.5 text-foreground/25">
{icon}
<span className="text-[10px] font-medium tracking-widest uppercase">
{label}
</span>
</div>
<p className="text-sm font-semibold tabular-nums text-white/90">
<p className="text-sm font-semibold tabular-nums text-foreground/90">
{value}
</p>
</div>

View File

@ -3,7 +3,18 @@
import { useRef, useEffect, useMemo, useState } from "react";
import Image from "next/image";
import { motion } from "motion/react";
import { X, ArrowUp, ArrowDown, Minus, Gauge } from "lucide-react";
import {
X,
ArrowUp,
ArrowDown,
Minus,
Gauge,
Wind,
Thermometer,
Shield,
AlertTriangle,
Target,
} from "lucide-react";
import type { FlightState } from "@/lib/opensky";
import { formatCallsign, headingToCardinal } from "@/lib/flight-utils";
import { lookupAirline } from "@/lib/airlines";
@ -40,6 +51,9 @@ function CompassRibbon({ heading }: { heading: number | null }) {
const ctx = canvas.getContext("2d");
if (!ctx) return;
const isDark = document.documentElement.classList.contains("dark");
const fg = isDark ? "255,255,255" : "0,0,0";
const dpr = window.devicePixelRatio ?? 1;
const w = 260;
const h = 32;
@ -66,7 +80,7 @@ function CompassRibbon({ heading }: { heading: number | null }) {
const isTiny = normDeg % 5 === 0;
if (isMajor) {
ctx.strokeStyle = "rgba(255,255,255,0.45)";
ctx.strokeStyle = `rgba(${fg},0.45)`;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(x, h - 1);
@ -74,19 +88,19 @@ function CompassRibbon({ heading }: { heading: number | null }) {
ctx.stroke();
const label = COMPASS_LABELS[normDeg] ?? `${normDeg}`;
ctx.fillStyle = "rgba(255,255,255,0.55)";
ctx.fillStyle = `rgba(${fg},0.55)`;
ctx.font = "bold 9px Inter, system-ui, sans-serif";
ctx.textAlign = "center";
ctx.fillText(label, x, h - 14);
} else if (isMinor) {
ctx.strokeStyle = "rgba(255,255,255,0.22)";
ctx.strokeStyle = `rgba(${fg},0.22)`;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x, h - 1);
ctx.lineTo(x, h - 7);
ctx.stroke();
} else if (isTiny) {
ctx.strokeStyle = "rgba(255,255,255,0.10)";
ctx.strokeStyle = `rgba(${fg},0.10)`;
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(x, h - 1);
@ -141,13 +155,34 @@ export function FpvHud({ flight, onExit }: FpvHudProps) {
const vsFpm =
vs !== null && Number.isFinite(vs) ? Math.round(vs * 196.85) : null;
const vsDisplay = vsFpm !== null ? `${vsFpm > 0 ? "+" : ""}${vsFpm}` : null;
// ── Avionics data (readsb only) ────────────────────────────────────
const iasKts = flight.ias ?? null;
const machNum = flight.mach ?? null;
const windDir = flight.windDirection ?? null;
const windSpd = flight.windSpeed ?? null;
const oatC = flight.oat ?? null;
const selAlt = flight.navAltitudeMcp ?? null;
const navModes = flight.navModes ?? null;
const rollDeg = flight.roll ?? null;
const isMilitary = (flight.dbFlags ?? 0) & 1;
const emergencyStatus = flight.emergencyStatus ?? null;
const hasAvionicsRow =
iasKts !== null ||
machNum !== null ||
(windDir !== null && windSpd !== null) ||
oatC !== null;
const hasAutopilotRow =
(navModes !== null && navModes.length > 0) || selAlt !== null;
const airline = useMemo(
() => lookupAirline(flight.callsign),
[flight.callsign],
);
const logoCandidates = useMemo(
() => airlineLogoCandidates(airline),
[airline],
() => airlineLogoCandidates(airline, flight.callsign),
[airline, flight.callsign],
);
const airlineKey = airline ?? "__none__";
const [logoIndexByAirline, setLogoIndexByAirline] = useState<
@ -198,12 +233,12 @@ export function FpvHud({ flight, onExit }: FpvHudProps) {
className="pointer-events-auto fixed bottom-[calc(1.5rem+env(safe-area-inset-bottom))] left-1/2 z-50 -translate-x-1/2 sm:bottom-[calc(1.5rem+env(safe-area-inset-bottom))]"
>
<div
className="flex w-[min(92vw,460px)] flex-col items-center gap-0 overflow-hidden rounded-xl border border-white/8 bg-black/70 pb-1 shadow-[0_8px_32px_rgba(0,0,0,0.6)] backdrop-blur-3xl md:w-max"
className="flex w-[min(92vw,460px)] flex-col items-center gap-0 overflow-hidden rounded-xl border border-foreground/8 bg-background/70 pb-1 shadow-[0_8px_32px_rgba(0,0,0,0.3)] dark:shadow-[0_8px_32px_rgba(0,0,0,0.6)] backdrop-blur-3xl md:w-max"
role="status"
aria-live="polite"
aria-label="First person view flight instruments"
>
<div className="w-full border-b border-white/6 px-2 pt-1.5 pb-0.5 sm:px-2.5">
<div className="w-full border-b border-foreground/6 px-2 pt-1.5 pb-0.5 sm:px-2.5">
<div
className="mx-auto w-fit overflow-hidden rounded-md"
style={{ width: 260 }}
@ -216,9 +251,9 @@ export function FpvHud({ flight, onExit }: FpvHudProps) {
</div>
<div className="flex w-full items-stretch">
<div className="flex min-w-0 flex-1 items-center gap-2 border-r border-white/6 px-2 py-1.5 sm:px-3 sm:py-2">
<div className="flex min-w-0 flex-1 items-center gap-2 border-r border-foreground/6 px-2 py-1.5 sm:px-3 sm:py-2">
{logoUrl ? (
<span className="relative flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full bg-white/95 shadow-sm ring-1 ring-white/20">
<span className="relative flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full bg-white/95 shadow-sm ring-1 ring-foreground/20">
{!logoLoaded && (
<span
aria-hidden="true"
@ -256,10 +291,10 @@ export function FpvHud({ flight, onExit }: FpvHudProps) {
/>
</span>
) : (
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white/5 ring-1 ring-white/10">
<span className="relative flex h-7 w-7 items-center justify-center overflow-hidden rounded-full bg-white/95 ring-1 ring-white/15">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-foreground/5 ring-1 ring-foreground/10">
<span className="relative flex h-7 w-7 items-center justify-center overflow-hidden rounded-full bg-white/95 ring-1 ring-foreground/15">
{genericLogoFailed ? (
<span className="text-[12px] font-semibold text-black/25">
<span className="text-[12px] font-semibold text-background/25">
</span>
) : (
@ -282,43 +317,57 @@ export function FpvHud({ flight, onExit }: FpvHudProps) {
</span>
)}
<div className="min-w-0">
<p className="truncate text-[12px] font-bold tracking-wide text-white/90 sm:text-[13px]">
{formatCallsign(flight.callsign)}
</p>
<p className="truncate text-[9px] font-medium uppercase tracking-widest text-white/30">
<div className="flex items-center gap-1">
<p className="truncate text-[12px] font-bold tracking-wide text-foreground/90 sm:text-[13px]">
{formatCallsign(flight.callsign)}
</p>
{isMilitary ? (
<span className="flex shrink-0 items-center gap-0.5 rounded border border-amber-500/30 bg-amber-500/10 px-1 py-px text-[7px] font-bold uppercase tracking-wider text-amber-400">
<Shield className="h-2 w-2" />
MIL
</span>
) : null}
{emergencyStatus ? (
<span className="flex shrink-0 items-center gap-0.5 rounded border border-red-500/40 bg-red-500/15 px-1 py-px text-[7px] font-bold uppercase tracking-wider text-red-400 animate-pulse">
<AlertTriangle className="h-2 w-2" />
{emergencyStatus}
</span>
) : null}
</div>
<p className="truncate text-[9px] font-medium uppercase tracking-widest text-foreground/30">
{airline ?? flight.originCountry}
</p>
</div>
</div>
<div className="flex min-w-12 flex-col items-center justify-center border-r border-white/6 px-2.5 py-1.5 sm:min-w-16 sm:px-2.5">
<div className="flex items-center gap-0.5 text-white/30">
<div className="flex min-w-12 flex-col items-center justify-center border-r border-foreground/6 px-2.5 py-1.5 sm:min-w-16 sm:px-2.5">
<div className="flex items-center gap-0.5 text-foreground/30">
<ArrowUp className="h-2 w-2" />
<span className="text-[8px] font-semibold uppercase tracking-wider">
ALT
</span>
</div>
<p className="text-[13px] font-bold tabular-nums text-white/90">
<p className="text-[13px] font-bold tabular-nums text-foreground/90">
{altFeet !== null ? altFeet.toLocaleString() : "—"}
</p>
<p className="text-[8px] font-medium text-white/25">ft</p>
<p className="text-[8px] font-medium text-foreground/25">ft</p>
</div>
<div className="flex min-w-11 flex-col items-center justify-center border-r border-white/6 px-2.5 py-1.5 sm:min-w-14 sm:px-2.5">
<div className="flex items-center gap-0.5 text-white/30">
<div className="flex min-w-11 flex-col items-center justify-center border-r border-foreground/6 px-2.5 py-1.5 sm:min-w-14 sm:px-2.5">
<div className="flex items-center gap-0.5 text-foreground/30">
<Gauge className="h-2 w-2" />
<span className="text-[8px] font-semibold uppercase tracking-wider">
SPD
</span>
</div>
<p className="text-[13px] font-bold tabular-nums text-white/90">
<p className="text-[13px] font-bold tabular-nums text-foreground/90">
{speedKts ?? "—"}
</p>
<p className="text-[8px] font-medium text-white/25">kts</p>
<p className="text-[8px] font-medium text-foreground/25">kts</p>
</div>
<div className="flex min-w-12 flex-col items-center justify-center border-r border-white/6 px-2.5 py-1.5 sm:min-w-16 sm:px-2.5">
<div className="flex items-center gap-0.5 text-white/30">
<div className="flex min-w-12 flex-col items-center justify-center border-r border-foreground/6 px-2.5 py-1.5 sm:min-w-16 sm:px-2.5">
<div className="flex items-center gap-0.5 text-foreground/30">
{vsIcon ?? <Minus className="h-2 w-2" />}
<span className="text-[8px] font-semibold uppercase tracking-wider">
V/S
@ -330,23 +379,117 @@ export function FpvHud({ flight, onExit }: FpvHudProps) {
? "text-emerald-400/80"
: vs !== null && vs < -0.5
? "text-amber-400/80"
: "text-white/90"
: "text-foreground/90"
}`}
>
{vsDisplay ?? "—"}
</p>
<p className="text-[8px] font-medium text-white/25">fpm</p>
<p className="text-[8px] font-medium text-foreground/25">fpm</p>
</div>
<button
onClick={onExit}
className="flex items-center gap-1 px-2.5 py-1.5 text-white/40 transition-colors hover:bg-white/5 hover:text-white/60 sm:px-2.5"
className="flex items-center gap-1 px-2.5 py-1.5 text-foreground/40 transition-colors hover:bg-foreground/5 hover:text-foreground/60 sm:px-2.5"
aria-label="Exit first person view"
title="Exit FPV (Esc)"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
{/* ── Avionics strip (IAS, Mach, Wind, OAT) ──────────────── */}
{hasAvionicsRow && (
<div className="flex w-full items-center justify-center gap-3 border-t border-foreground/6 px-3 py-1 sm:gap-4">
{iasKts !== null && (
<span className="flex items-center gap-1 text-[10px] tabular-nums text-foreground/50">
<span className="text-[8px] font-semibold uppercase tracking-wider text-foreground/30">
IAS
</span>
<span className="font-bold text-foreground/70">
{Math.round(iasKts)}
</span>
<span className="text-[8px] text-foreground/25">kts</span>
</span>
)}
{machNum !== null && (
<span className="flex items-center gap-0.5 text-[10px] tabular-nums text-foreground/50">
<span className="text-[8px] font-semibold uppercase tracking-wider text-foreground/30">
M
</span>
<span className="font-bold text-foreground/70">
{machNum.toFixed(2)}
</span>
</span>
)}
{windDir !== null && windSpd !== null && (
<span className="flex items-center gap-1 text-[10px] tabular-nums text-foreground/50">
<Wind className="h-2.5 w-2.5 text-foreground/30" />
<span className="font-bold text-foreground/70">
{Math.round(windDir)}°
</span>
<span className="text-foreground/60">/</span>
<span className="font-bold text-foreground/70">
{Math.round(windSpd)}
</span>
<span className="text-[8px] text-foreground/25">kts</span>
</span>
)}
{oatC !== null && (
<span className="flex items-center gap-0.5 text-[10px] tabular-nums text-foreground/50">
<Thermometer className="h-2.5 w-2.5 text-foreground/30" />
<span className="font-bold text-foreground/70">
{Math.round(oatC)}°C
</span>
</span>
)}
{rollDeg !== null && Math.abs(rollDeg) > 1 && (
<span className="flex items-center gap-0.5 text-[10px] tabular-nums text-foreground/50">
<span className="text-[8px] font-semibold uppercase tracking-wider text-foreground/30">
BANK
</span>
<span className="font-bold text-foreground/70">
{rollDeg > 0 ? "R" : "L"}
{Math.round(Math.abs(rollDeg))}°
</span>
</span>
)}
</div>
)}
{/* ── Autopilot / selected altitude strip ────────────────── */}
{hasAutopilotRow && (
<div className="flex w-full items-center justify-center gap-2 border-t border-foreground/6 px-3 py-1">
{navModes !== null &&
navModes.map((mode) => (
<span
key={mode}
className={`rounded px-1.5 py-px text-[8px] font-bold uppercase tracking-wider ${
mode === "tcas"
? "border border-amber-500/30 bg-amber-500/10 text-amber-400"
: "border border-emerald-500/25 bg-emerald-500/10 text-emerald-400/90"
}`}
>
{mode === "autopilot"
? "AP"
: mode === "althold"
? "ALT HLD"
: mode}
</span>
))}
{selAlt !== null && (
<span className="flex items-center gap-0.5 text-[10px] tabular-nums text-foreground/50">
<Target className="h-2.5 w-2.5 text-cyan-400/50" />
<span className="text-[8px] font-semibold uppercase tracking-wider text-foreground/30">
SEL
</span>
<span className="font-bold text-cyan-400/70">
{selAlt.toLocaleString()}
</span>
<span className="text-[8px] text-foreground/25">ft</span>
</span>
)}
</div>
)}
</div>
</motion.div>
);

View File

@ -25,18 +25,18 @@ export function HeroBanner({ photo, loading }: HeroBannerProps) {
const hasPhoto = photo != null && !failed;
return (
<div className="relative h-36 w-full overflow-hidden bg-white/5">
<div className="relative h-36 w-full overflow-hidden bg-foreground/5">
{/* Skeleton while loading */}
{loading && !hasPhoto && (
<span
aria-hidden
className="absolute inset-0 animate-pulse bg-linear-to-br from-white/5 via-white/8 to-white/5"
className="absolute inset-0 animate-pulse bg-linear-to-br from-foreground/5 via-foreground/8 to-white/5"
/>
)}
{/* No image placeholder */}
{!loading && !hasPhoto && (
<div className="flex h-full w-full flex-col items-center justify-center gap-1.5 text-white/20">
<div className="flex h-full w-full flex-col items-center justify-center gap-1.5 text-foreground/20">
<ImageOff className="h-6 w-6" />
<span className="text-[10px] font-medium">No photo available</span>
</div>
@ -48,7 +48,7 @@ export function HeroBanner({ photo, loading }: HeroBannerProps) {
{!loaded && (
<span
aria-hidden
className="absolute inset-0 animate-pulse bg-linear-to-br from-white/5 via-white/8 to-white/5"
className="absolute inset-0 animate-pulse bg-linear-to-br from-foreground/5 via-foreground/8 to-white/5"
/>
)}
<img
@ -59,9 +59,9 @@ export function HeroBanner({ photo, loading }: HeroBannerProps) {
className={`h-full w-full object-cover transition-opacity duration-300 ${loaded ? "opacity-100" : "opacity-0"}`}
draggable={false}
/>
<span className="pointer-events-none absolute inset-0 bg-linear-to-t from-black/40 via-black/5 to-transparent" />
<span className="pointer-events-none absolute inset-0 bg-linear-to-t from-background/40 via-background/5 to-transparent" />
{photo.photographer && loaded && (
<span className="absolute bottom-2 right-2.5 flex items-center gap-1 rounded-full bg-black/40 px-2 py-0.5 text-[9px] font-medium text-white/60 backdrop-blur-sm">
<span className="absolute bottom-2 right-2.5 flex items-center gap-1 rounded-full bg-background/40 px-2 py-0.5 text-[9px] font-medium text-foreground/60 backdrop-blur-sm">
<Camera className="h-2.5 w-2.5" />
{photo.photographer}
</span>

View File

@ -83,7 +83,7 @@ export function KeyboardShortcutsHelp({
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-80 bg-black/60 backdrop-blur-md"
className="fixed inset-0 z-80 bg-background/60 backdrop-blur-md"
onClick={onClose}
/>
<motion.div
@ -102,24 +102,24 @@ export function KeyboardShortcutsHelp({
aria-modal="true"
aria-label="Keyboard shortcuts"
>
<div className="overflow-hidden rounded-2xl border border-white/8 bg-[#0c0c0e]/95 shadow-[0_40px_100px_rgba(0,0,0,0.8)] backdrop-blur-3xl">
<div className="overflow-hidden rounded-2xl border border-foreground/8 bg-popover/95 shadow-[0_40px_100px_rgba(0,0,0,0.8)] dark:shadow-[0_40px_100px_rgba(0,0,0,0.8)] backdrop-blur-3xl">
<div className="flex items-center justify-between px-5 pt-5 pb-3">
<div className="flex items-center gap-2.5">
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-white/6">
<Keyboard className="h-3.5 w-3.5 text-white/50" />
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-foreground/6">
<Keyboard className="h-3.5 w-3.5 text-foreground/50" />
</div>
<h2 className="text-[14px] font-semibold tracking-tight text-white/90">
<h2 className="text-[14px] font-semibold tracking-tight text-foreground/90">
Keyboard Shortcuts
</h2>
</div>
<motion.button
onClick={onClose}
className="flex h-7 w-7 items-center justify-center rounded-full bg-white/6 transition-colors hover:bg-white/12"
className="flex h-7 w-7 items-center justify-center rounded-full bg-foreground/6 transition-colors hover:bg-foreground/12"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
aria-label="Close"
>
<X className="h-3.5 w-3.5 text-white/40" />
<X className="h-3.5 w-3.5 text-foreground/40" />
</motion.button>
</div>
@ -130,10 +130,10 @@ export function KeyboardShortcutsHelp({
key={key}
className="flex items-center justify-between py-1.5"
>
<span className="text-[13px] font-medium text-white/50">
<span className="text-[13px] font-medium text-foreground/50">
{description}
</span>
<kbd className="flex h-6 min-w-6 items-center justify-center rounded-md bg-white/6 px-2 font-mono text-[11px] font-semibold text-white/70 ring-1 ring-white/8">
<kbd className="flex h-6 min-w-6 items-center justify-center rounded-md bg-foreground/6 px-2 font-mono text-[11px] font-semibold text-foreground/70 ring-1 ring-foreground/8">
{key}
</kbd>
</div>

View File

@ -15,6 +15,8 @@ import {
Camera,
ImageOff,
Plane,
Shield,
AlertTriangle,
} from "lucide-react";
import { useAircraftPhotos } from "@/hooks/use-aircraft-photos";
import type { FlightState } from "@/lib/opensky";
@ -61,6 +63,14 @@ function squawkLabel(squawk: string): string {
}
}
function isMilitary(dbFlags?: number | null): boolean {
return ((dbFlags ?? 0) & 1) !== 0;
}
function isEmergencyStatus(status?: string | null): boolean {
return !!status && status !== "none";
}
export function MobileFlightToast({
flight,
onClose,
@ -76,8 +86,8 @@ export function MobileFlightToast({
const canEnterFpv =
flight.longitude != null && flight.latitude != null && !flight.onGround;
// ── Airline logo with fallback chain ──────────────────────────────
const logoCandidates = airlineLogoCandidates(airline);
// ── Airline logo with fallback chain ──────────────────────────────
const logoCandidates = airlineLogoCandidates(airline, flight.callsign);
const [logoIndexByAirline, setLogoIndexByAirline] = useState<
Record<string, number>
>({});
@ -107,14 +117,14 @@ export function MobileFlightToast({
const showLogo = Boolean(logoUrl);
const genericLogoUrl = "/airline-logos/envoy-air.png";
// ── Aircraft photos & details ──────────────────────────────────────
// ── Aircraft photos & details ──────────────────────────────────────
const {
photos,
aircraft: aircraftDetails,
loading: photosLoading,
} = useAircraftPhotos(flight.icao24, flight.registration);
// ── Photo carousel state ───────────────────────────────────────────
// ── Photo carousel state ───────────────────────────────────────────
const scrollRef = useRef<HTMLDivElement>(null);
const [activeSlide, setActiveSlide] = useState(0);
const [slideLoadState, setSlideLoadState] = useState<
@ -163,20 +173,20 @@ export function MobileFlightToast({
const showPhotos = !photosLoading && hasPhotos;
return (
<div className="w-full overflow-hidden rounded-2xl border border-white/8 bg-black/80 shadow-2xl shadow-black/50 backdrop-blur-2xl">
<div className="w-full overflow-hidden rounded-2xl border border-foreground/8 bg-background/80 shadow-2xl shadow-background/50 backdrop-blur-2xl">
{/* Photo carousel / hero banner */}
<div className="relative h-36 w-full overflow-hidden bg-white/5">
<div className="relative h-36 w-full overflow-hidden bg-foreground/5">
{/* Skeleton while loading */}
{photosLoading && !hasPhotos && (
<span
aria-hidden
className="absolute inset-0 animate-pulse bg-linear-to-br from-white/5 via-white/8 to-white/5"
className="absolute inset-0 animate-pulse bg-linear-to-br from-foreground/5 via-foreground/8 to-foreground/5"
/>
)}
{/* No image placeholder */}
{!photosLoading && !hasPhotos && (
<div className="flex h-full w-full flex-col items-center justify-center gap-1 text-white/15">
<div className="flex h-full w-full flex-col items-center justify-center gap-1 text-foreground/15">
<ImageOff className="h-4 w-4" />
<span className="text-[9px] font-medium">No photo</span>
</div>
@ -200,11 +210,11 @@ export function MobileFlightToast({
slideLoadState[i] !== "error" && (
<span
aria-hidden
className="absolute inset-0 animate-pulse bg-linear-to-br from-white/5 via-white/8 to-white/5"
className="absolute inset-0 animate-pulse bg-linear-to-br from-foreground/5 via-foreground/8 to-foreground/5"
/>
)}
{slideLoadState[i] === "error" ? (
<div className="flex h-full w-full items-center justify-center text-white/15">
<div className="flex h-full w-full items-center justify-center text-foreground/15">
<ImageOff className="h-5 w-5" />
</div>
) : mountedSlides.has(i) ? (
@ -234,7 +244,7 @@ export function MobileFlightToast({
{/* Photographer attribution */}
{showPhotos && photos[activeSlide]?.photographer && (
<span className="absolute bottom-1.5 right-2 z-10 flex items-center gap-0.5 rounded-full bg-black/45 px-1.5 py-0.5 text-[8px] font-medium text-white/55 backdrop-blur-sm">
<span className="absolute bottom-1.5 right-2 z-10 flex items-center gap-0.5 rounded-full bg-background/45 px-1.5 py-0.5 text-[8px] font-medium text-foreground/55 backdrop-blur-sm">
<Camera className="h-2 w-2" />
{photos[activeSlide].photographer}
</span>
@ -247,12 +257,12 @@ export function MobileFlightToast({
<span
key={i}
className={`h-1 w-1 rounded-full transition-colors duration-200 ${
i === activeSlide ? "bg-white/80" : "bg-white/30"
i === activeSlide ? "bg-foreground/80" : "bg-foreground/30"
}`}
/>
))}
{photos.length > 10 && (
<span className="text-[7px] leading-none text-white/30">
<span className="text-[7px] leading-none text-foreground/30">
+{photos.length - 10}
</span>
)}
@ -261,7 +271,7 @@ export function MobileFlightToast({
{/* Slide counter */}
{showPhotos && photos.length > 1 && (
<span className="absolute top-1.5 right-2 z-10 rounded-full bg-black/45 px-1.5 py-0.5 text-[8px] font-semibold tabular-nums text-white/60 backdrop-blur-sm">
<span className="absolute top-1.5 right-2 z-10 rounded-full bg-background/45 px-1.5 py-0.5 text-[8px] font-semibold tabular-nums text-foreground/60 backdrop-blur-sm">
{activeSlide + 1}/{photos.length}
</span>
)}
@ -271,9 +281,9 @@ export function MobileFlightToast({
{/* Header row: logo + callsign + close */}
<div className="flex items-center gap-3">
{/* Airline logo */}
<div className="relative flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border border-white/14 bg-white/10 shadow-md shadow-black/25">
<div className="relative flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border border-foreground/14 bg-foreground/10 shadow-md shadow-background/25">
{showLogo ? (
<span className="relative flex h-10 w-10 items-center justify-center overflow-hidden rounded-lg border border-black/10 bg-white/95 p-2 shadow-sm">
<span className="relative flex h-10 w-10 items-center justify-center overflow-hidden rounded-lg border border-foreground/10 bg-white/95 p-2 shadow-sm">
{!logoLoaded && (
<span
aria-hidden="true"
@ -313,10 +323,10 @@ export function MobileFlightToast({
/>
</span>
) : (
<span className="relative flex h-10 w-10 items-center justify-center overflow-hidden rounded-lg border border-white/10 bg-white/95 p-2 shadow-sm">
<span className="relative flex h-10 w-10 items-center justify-center overflow-hidden rounded-lg border border-foreground/10 bg-white/95 p-2 shadow-sm">
{genericLogoFailed ? (
<span className="text-[16px] font-semibold text-black/25">
<span className="text-[16px] font-semibold text-background/25">
â
</span>
) : (
<Image
@ -335,12 +345,12 @@ export function MobileFlightToast({
{/* Callsign + identifiers */}
<div className="min-w-0 flex-1">
<p className="truncate text-[15px] font-bold leading-tight text-white">
<p className="truncate text-[15px] font-bold leading-tight text-foreground">
{formatCallsign(flight.callsign)}
</p>
<p className="mt-0.5 truncate text-[10px] font-medium tracking-widest text-white/30 uppercase">
<p className="mt-0.5 truncate text-[10px] font-medium tracking-widest text-foreground/30 uppercase">
{flight.icao24}
{flightNum ? ` · #${flightNum}` : ""}
{flightNum ? ` · #${flightNum}` : ""}
</p>
</div>
@ -348,24 +358,24 @@ export function MobileFlightToast({
<button
type="button"
onClick={onClose}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-white/5 transition-colors active:bg-white/10"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-foreground/5 transition-colors active:bg-foreground/10"
aria-label="Close flight details"
>
<X className="h-3.5 w-3.5 text-white/40" />
<X className="h-3.5 w-3.5 text-foreground/40" />
</button>
</div>
{/* Airline / model */}
{company && (
<div className="mt-2 flex items-center gap-1.5">
<Building2 className="h-3 w-3 shrink-0 text-white/20" />
<p className="truncate text-[11px] font-medium text-white/45">
<Building2 className="h-3 w-3 shrink-0 text-foreground/20" />
<p className="truncate text-[11px] font-medium text-foreground/45">
{company}
{model ? <span className="text-white/25"> · {model}</span> : null}
{model ? (
<span className="text-foreground/25"> · {model}</span>
) : null}
</p>
</div>
)}
{/* Aircraft details (registration, type, owner) */}
{aircraftDetails &&
(aircraftDetails.registration ||
@ -373,22 +383,40 @@ export function MobileFlightToast({
aircraftDetails.typeCode ||
aircraftDetails.owner) && (
<div className="mt-1.5 flex items-center gap-1.5">
<Plane className="h-3 w-3 shrink-0 text-white/20" />
<p className="truncate text-[11px] text-white/35">
<Plane className="h-3 w-3 shrink-0 text-foreground/20" />
<p className="truncate text-[11px] text-foreground/35">
{[
aircraftDetails.registration,
aircraftDetails.type ?? aircraftDetails.typeCode,
aircraftDetails.owner,
]
.filter(Boolean)
.join(" · ")}
.join(" · ")}
</p>
</div>
)}
{/* Military / Emergency badges */}
{(isMilitary(flight.dbFlags) ||
isEmergencyStatus(flight.emergencyStatus)) && (
<div className="mt-2 flex items-center gap-1.5 px-0">
{isMilitary(flight.dbFlags) && (
<span className="inline-flex items-center gap-1 rounded bg-amber-500/15 px-1.5 py-0.5 text-[9px] font-bold tracking-wider text-amber-400 uppercase ring-1 ring-amber-400/20">
<Shield className="h-2.5 w-2.5" />
MIL
</span>
)}
{isEmergencyStatus(flight.emergencyStatus) && (
<span className="inline-flex animate-pulse items-center gap-1 rounded bg-red-500/15 px-1.5 py-0.5 text-[9px] font-bold tracking-wider text-red-400 uppercase ring-1 ring-red-400/25">
<AlertTriangle className="h-2.5 w-2.5" />
{flight.emergencyStatus?.toUpperCase()}
</span>
)}
</div>
)}{" "}
</div>
{/* Metrics 4-column grid */}
<div className="grid grid-cols-4 gap-px border-t border-white/5 bg-white/[0.02]">
<div className="grid grid-cols-4 gap-px border-t border-foreground/5 bg-foreground/2">
<MiniMetric
icon={<ArrowUp className="h-2.5 w-2.5" />}
label="ALT"
@ -404,8 +432,8 @@ export function MobileFlightToast({
label="HDG"
value={
heading !== null && Number.isFinite(heading)
? `${Math.round(heading)}° ${cardinal}`
: ""
? `${Math.round(heading)}° ${cardinal}`
: "—"
}
/>
<MiniMetric
@ -414,24 +442,26 @@ export function MobileFlightToast({
value={
flight.verticalRate !== null && Number.isFinite(flight.verticalRate)
? `${flight.verticalRate > 0 ? "+" : ""}${Math.round(flight.verticalRate)}`
: ""
: "—"
}
/>
</div>
{/* Info section: origin, heading + coords, squawk */}
<div className="flex flex-col gap-1.5 border-t border-white/5 px-3.5 py-2.5">
<div className="flex flex-col gap-1.5 border-t border-foreground/5 px-3.5 py-2.5">
{/* Origin country */}
<div className="flex items-center gap-1.5">
<Globe className="h-3 w-3 text-white/25" />
<p className="text-[11px] text-white/40">{flight.originCountry}</p>
<Globe className="h-3 w-3 text-foreground/25" />
<p className="text-[11px] text-foreground/40">
{flight.originCountry}
</p>
</div>
{/* Heading direction + coordinates */}
{cardinal && (
<div className="flex items-center gap-1.5">
<Navigation
className="h-3 w-3 text-white/25"
className="h-3 w-3 text-foreground/25"
style={{
transform:
heading !== null && Number.isFinite(heading)
@ -439,17 +469,17 @@ export function MobileFlightToast({
: undefined,
}}
/>
<p className="text-[11px] text-white/40">
<p className="text-[11px] text-foreground/40">
Heading {cardinal}
{flight.latitude !== null &&
flight.longitude !== null &&
Number.isFinite(flight.latitude) &&
Number.isFinite(flight.longitude) && (
<span className="text-white/20">
<span className="text-foreground/20">
{" "}
· {Math.abs(flight.latitude).toFixed(2)}°
· {Math.abs(flight.latitude).toFixed(2)}°
{flight.latitude >= 0 ? "N" : "S"},{" "}
{Math.abs(flight.longitude).toFixed(2)}°
{Math.abs(flight.longitude).toFixed(2)}°
{flight.longitude >= 0 ? "E" : "W"}
</span>
)}
@ -464,7 +494,7 @@ export function MobileFlightToast({
className={`h-3 w-3 text-center text-[8px] font-bold leading-3 ${
isEmergencySquawk(flight.squawk)
? "text-red-400"
: "text-white/25"
: "text-foreground/25"
}`}
>
SQ
@ -473,7 +503,7 @@ export function MobileFlightToast({
className={`font-mono text-[11px] tabular-nums ${
isEmergencySquawk(flight.squawk)
? "text-red-400"
: "text-white/40"
: "text-foreground/40"
}`}
>
{flight.squawk}
@ -489,14 +519,14 @@ export function MobileFlightToast({
{/* FPV button */}
{onToggleFpv && (
<div className="border-t border-white/5">
<div className="border-t border-foreground/5">
<button
type="button"
onClick={() =>
(isFpvActive || canEnterFpv) && onToggleFpv(flight.icao24)
}
disabled={!isFpvActive && !canEnterFpv}
className={`flex w-full items-center justify-center gap-1.5 py-2.5 transition-colors active:bg-white/5 ${
className={`flex w-full items-center justify-center gap-1.5 py-2.5 transition-colors active:bg-foreground/5 ${
!isFpvActive && !canEnterFpv
? "cursor-not-allowed opacity-30"
: ""
@ -506,11 +536,11 @@ export function MobileFlightToast({
}
>
<Eye
className={`h-3 w-3 ${isFpvActive ? "text-emerald-400" : "text-white/30"}`}
className={`h-3 w-3 ${isFpvActive ? "text-emerald-400" : "text-foreground/30"}`}
/>
<span
className={`text-[10px] font-semibold tracking-wider uppercase ${
isFpvActive ? "text-emerald-400/70" : "text-white/35"
isFpvActive ? "text-emerald-400/70" : "text-foreground/35"
}`}
>
{isFpvActive ? "Exit FPV" : "First Person View"}
@ -533,13 +563,13 @@ function MiniMetric({
}) {
return (
<div className="flex flex-col items-center gap-0.5 py-2.5">
<div className="flex items-center gap-1 text-white/20">
<div className="flex items-center gap-1 text-foreground/20">
{icon}
<span className="text-[8px] font-bold tracking-widest uppercase">
{label}
</span>
</div>
<p className="text-[12px] font-semibold tabular-nums text-white/85">
<p className="text-[12px] font-semibold tabular-nums text-foreground/85">
{value}
</p>
</div>

View File

@ -115,7 +115,7 @@ export function ProviderDropdown({
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.97 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="absolute bottom-full left-0 z-50 mb-2 w-[calc(100vw-2rem)] max-w-70 overflow-hidden rounded-xl border shadow-2xl shadow-black/60 backdrop-blur-2xl sm:w-70 sm:max-w-none"
className="absolute bottom-full left-0 z-50 mb-2 w-[calc(100vw-2rem)] max-w-70 overflow-hidden rounded-xl border shadow-2xl shadow-background/60 backdrop-blur-2xl sm:w-70 sm:max-w-none"
style={{
borderColor: "rgb(var(--ui-fg) / 0.08)",
backgroundColor: "rgb(var(--ui-bg) / 0.75)",
@ -138,7 +138,7 @@ export function ProviderDropdown({
<button
type="button"
onClick={onClose}
className="flex h-5 w-5 items-center justify-center rounded-md transition-colors hover:bg-white/5 active:bg-white/10"
className="flex h-5 w-5 items-center justify-center rounded-md transition-colors hover:bg-foreground/5 active:bg-foreground/10"
aria-label="Close provider selector"
>
<X
@ -155,7 +155,7 @@ export function ProviderDropdown({
type="button"
onClick={() => handleSelect("auto")}
className={`group flex w-full items-center gap-2.5 px-3.5 py-2 transition-colors ${
isAutoMode ? "bg-white/6" : "hover:bg-white/3 active:bg-white/6"
isAutoMode ? "bg-foreground/6" : "hover:bg-foreground/3 active:bg-foreground/6"
}`}
>
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
@ -217,9 +217,9 @@ export function ProviderDropdown({
disabled={!isAvailable}
className={`group flex w-full items-center gap-2.5 px-3.5 py-2 transition-colors ${
isSelected
? "bg-white/6"
? "bg-foreground/6"
: isAvailable
? "hover:bg-white/3 active:bg-white/6"
? "hover:bg-foreground/3 active:bg-foreground/6"
: "cursor-not-allowed opacity-40"
}`}
>

View File

@ -81,7 +81,7 @@ export const ScrollArea = forwardRef<HTMLDivElement, ScrollAreaProps>(
>
<div
ref={thumbRef}
className="absolute w-full rounded-full bg-white/15 transition-[background-color] duration-150 hover:bg-white/25"
className="absolute w-full rounded-full bg-foreground/15 transition-[background-color] duration-150 hover:bg-foreground/25"
style={{
height: thumbHeight,
transform: `translateY(${thumbTop}px)`,

View File

@ -14,10 +14,10 @@ const Slider = React.forwardRef<
className={`relative flex w-full touch-none select-none items-center ${className}`}
{...props}
>
<SliderPrimitive.Track className="relative h-1 w-full grow overflow-hidden rounded-full bg-white/8">
<SliderPrimitive.Range className="absolute h-full bg-white/30" />
<SliderPrimitive.Track className="relative h-1 w-full grow overflow-hidden rounded-full bg-foreground/8">
<SliderPrimitive.Range className="absolute h-full bg-foreground/30" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-3.5 w-3.5 rounded-full bg-white shadow-sm shadow-black/40 ring-1 ring-white/20 transition-colors hover:bg-white/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40" />
<SliderPrimitive.Thumb className="block h-3.5 w-3.5 rounded-full bg-foreground shadow-sm shadow-background/40 ring-1 ring-foreground/20 transition-colors hover:bg-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground/40" />
</SliderPrimitive.Root>
));
Slider.displayName = "Slider";

View File

@ -0,0 +1,340 @@
"use client";
import { useMemo } from "react";
import type { TrailEntry } from "@/hooks/use-trail-history";
const PROFILE_HEIGHT = 100;
const PROFILE_PADDING_TOP = 14;
const PROFILE_PADDING_BOTTOM = 20;
const PROFILE_PADDING_X = 6;
const MIN_POINTS_TO_RENDER = 3;
const FEET_PER_METER = 3.28084;
const MAX_ALTITUDE_METERS = 13_000;
const NM_PER_DEG = 60; // 1 degree latitude ≈ 60 nm
type RGB = [number, number, number];
const ALTITUDE_STOPS: { t: number; color: RGB }[] = [
{ t: 0.0, color: [72, 210, 160] },
{ t: 0.1, color: [100, 200, 120] },
{ t: 0.2, color: [160, 195, 80] },
{ t: 0.3, color: [210, 180, 60] },
{ t: 0.4, color: [235, 150, 60] },
{ t: 0.52, color: [240, 110, 80] },
{ t: 0.64, color: [220, 85, 130] },
{ t: 0.76, color: [180, 90, 190] },
{ t: 0.88, color: [120, 110, 220] },
{ t: 1.0, color: [100, 170, 240] },
];
function altColor(altMeters: number): string {
const t = Math.max(0, Math.min(1, altMeters / MAX_ALTITUDE_METERS));
let i = 0;
while (i < ALTITUDE_STOPS.length - 1 && ALTITUDE_STOPS[i + 1].t <= t) i++;
if (i >= ALTITUDE_STOPS.length - 1) {
const c = ALTITUDE_STOPS[ALTITUDE_STOPS.length - 1].color;
return `rgb(${c[0]},${c[1]},${c[2]})`;
}
const a = ALTITUDE_STOPS[i];
const b = ALTITUDE_STOPS[i + 1];
const lt = (t - a.t) / (b.t - a.t);
const r = Math.round(a.color[0] + (b.color[0] - a.color[0]) * lt);
const g = Math.round(a.color[1] + (b.color[1] - a.color[1]) * lt);
const bl = Math.round(a.color[2] + (b.color[2] - a.color[2]) * lt);
return `rgb(${r},${g},${bl})`;
}
function haversineNm(
lat1: number,
lon1: number,
lat2: number,
lon2: number,
): number {
const R_NM = 3440.065; // earth radius in nautical miles
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLon = ((lon2 - lon1) * Math.PI) / 180;
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLon / 2) ** 2;
return 2 * R_NM * Math.asin(Math.sqrt(a));
}
type ProfilePoint = {
distNm: number;
altFt: number;
altMeters: number;
};
type VerticalProfileProps = {
trail: TrailEntry | null;
/** Selected altitude on MCP/FCU in feet (drawn as dashed target line) */
navAltitudeMcp?: number | null;
};
export function VerticalProfile({
trail,
navAltitudeMcp,
}: VerticalProfileProps) {
const points = useMemo<ProfilePoint[]>(() => {
if (!trail || trail.path.length < MIN_POINTS_TO_RENDER) return [];
const result: ProfilePoint[] = [];
let cumDist = 0;
const len = Math.min(trail.path.length, trail.altitudes.length);
for (let i = 0; i < len; i++) {
const [lng, lat] = trail.path[i];
// Always accumulate distance even for null-altitude points
if (i > 0) {
const [pLng, pLat] = trail.path[i - 1];
cumDist += haversineNm(pLat, pLng, lat, lng);
}
const alt = trail.altitudes[i];
if (alt === null || !Number.isFinite(alt)) continue;
result.push({
distNm: cumDist,
altFt: Math.round(alt * FEET_PER_METER),
altMeters: alt,
});
}
return result;
}, [trail]);
if (points.length < MIN_POINTS_TO_RENDER) return null;
const maxDist = points[points.length - 1].distNm || 1;
const maxAlt = Math.max(
...points.map((p) => p.altFt),
navAltitudeMcp ?? 0,
1000,
);
// Round up to nearest 5000ft for clean axis
const ceilAlt = Math.ceil(maxAlt / 5000) * 5000;
const drawW = 200 - PROFILE_PADDING_X * 2;
const drawH = PROFILE_HEIGHT - PROFILE_PADDING_TOP - PROFILE_PADDING_BOTTOM;
const toX = (d: number) => PROFILE_PADDING_X + (d / maxDist) * drawW;
const toY = (alt: number) =>
PROFILE_PADDING_TOP + drawH - (alt / ceilAlt) * drawH;
// Build SVG polyline points
const polyPoints = points
.map((p) => `${toX(p.distNm).toFixed(1)},${toY(p.altFt).toFixed(1)}`)
.join(" ");
// Build colored line segments
const segments: {
x1: number;
y1: number;
x2: number;
y2: number;
color: string;
}[] = [];
for (let i = 0; i < points.length - 1; i++) {
const p = points[i];
const q = points[i + 1];
const avgAlt = (p.altMeters + q.altMeters) / 2;
segments.push({
x1: toX(p.distNm),
y1: toY(p.altFt),
x2: toX(q.distNm),
y2: toY(q.altFt),
color: altColor(avgAlt),
});
}
// Altitude Y-axis tick labels
const ticks: number[] = [];
const tickStep = ceilAlt <= 10000 ? 5000 : 10000;
for (let a = 0; a <= ceilAlt; a += tickStep) {
ticks.push(a);
}
return (
<div className="mt-3">
<div className="h-px bg-linear-to-r from-transparent via-foreground/6 to-transparent" />
<div className="mt-2">
<div className="flex items-center justify-between">
<p className="text-[11px] font-semibold tracking-widest text-foreground/30 uppercase">
Vertical Profile
</p>
<p className="font-mono text-[10px] tabular-nums text-foreground/25">
FL
{Math.round(points[points.length - 1].altFt / 100)
.toString()
.padStart(3, "0")}
</p>
</div>
<svg
viewBox={`0 0 200 ${PROFILE_HEIGHT}`}
className="mt-1.5 w-full"
aria-label="Altitude profile chart"
role="img"
>
<defs>
{/* Gradient fill under the altitude line */}
<linearGradient id="profile-fill-grad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="currentColor" stopOpacity={0.08} />
<stop offset="100%" stopColor="currentColor" stopOpacity={0.01} />
</linearGradient>
{/* Glow filter for the current position dot */}
<filter
id="profile-glow"
x="-50%"
y="-50%"
width="200%"
height="200%"
>
<feGaussianBlur stdDeviation="1.5" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* Grid lines */}
{ticks.map((a) => (
<g key={a}>
<line
x1={PROFILE_PADDING_X}
y1={toY(a)}
x2={200 - PROFILE_PADDING_X}
y2={toY(a)}
stroke="currentColor"
strokeOpacity={0.05}
strokeWidth={0.4}
strokeDasharray={a > 0 ? "2 3" : undefined}
/>
<text
x={PROFILE_PADDING_X + 1}
y={toY(a) - 2.5}
fill="currentColor"
fillOpacity={0.22}
fontSize={6}
fontFamily="monospace"
>
{a >= 1000
? `FL${Math.round(a / 100)
.toString()
.padStart(3, "0")}`
: a}
</text>
</g>
))}
{/* Gradient fill under the altitude line */}
<polygon
points={`${toX(0).toFixed(1)},${toY(0).toFixed(1)} ${polyPoints} ${toX(maxDist).toFixed(1)},${toY(0).toFixed(1)}`}
fill="url(#profile-fill-grad)"
/>
{/* Colored altitude segments */}
{segments.map((s, i) => (
<line
key={i}
x1={s.x1}
y1={s.y1}
x2={s.x2}
y2={s.y2}
stroke={s.color}
strokeWidth={2}
strokeLinecap="round"
strokeOpacity={0.9}
/>
))}
{/* MCP selected altitude target line */}
{navAltitudeMcp != null &&
Number.isFinite(navAltitudeMcp) &&
navAltitudeMcp > 0 &&
navAltitudeMcp <= ceilAlt && (
<>
<line
x1={PROFILE_PADDING_X}
y1={toY(navAltitudeMcp)}
x2={200 - PROFILE_PADDING_X}
y2={toY(navAltitudeMcp)}
stroke="#34d399"
strokeWidth={0.5}
strokeDasharray="2 2.5"
strokeOpacity={0.45}
/>
<text
x={200 - PROFILE_PADDING_X}
y={toY(navAltitudeMcp) - 2.5}
fill="#34d399"
fillOpacity={0.55}
fontSize={5.5}
fontFamily="monospace"
textAnchor="end"
>
SEL FL
{Math.round(navAltitudeMcp / 100)
.toString()
.padStart(3, "0")}
</text>
</>
)}
{/* Current position dot (last point) */}
{points.length > 0 &&
(() => {
const last = points[points.length - 1];
const cx = toX(last.distNm);
const cy = toY(last.altFt);
const dotColor = altColor(last.altMeters);
return (
<g filter="url(#profile-glow)">
<circle
cx={cx}
cy={cy}
r={3}
fill={dotColor}
fillOpacity={0.9}
/>
<circle
cx={cx}
cy={cy}
r={1.5}
fill="white"
fillOpacity={0.8}
/>
</g>
);
})()}
{/* Distance labels */}
<text
x={PROFILE_PADDING_X}
y={PROFILE_HEIGHT - 4}
fill="currentColor"
fillOpacity={0.22}
fontSize={6}
fontFamily="monospace"
>
0 nm
</text>
<text
x={200 - PROFILE_PADDING_X}
y={PROFILE_HEIGHT - 4}
fill="currentColor"
fillOpacity={0.22}
fontSize={6}
fontFamily="monospace"
textAnchor="end"
>
{maxDist.toFixed(0)} nm
</text>
</svg>
</div>
</div>
);
}