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:
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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})`;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
|
||||
@ -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 };
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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),
|
||||
|
||||
55
src/components/map/map-state-tracker.tsx
Normal file
55
src/components/map/map-state-tracker.tsx
Normal 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;
|
||||
}
|
||||
@ -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);
|
||||
|
||||
176
src/components/map/weather-radar-layer.tsx
Normal file
176
src/components/map/weather-radar-layer.tsx
Normal 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;
|
||||
}
|
||||
11
src/components/theme-provider.tsx
Normal file
11
src/components/theme-provider.tsx
Normal 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>;
|
||||
}
|
||||
@ -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>
|
||||
|
||||
626
src/components/ui/airport-board.tsx
Normal file
626
src/components/ui/airport-board.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
415
src/components/ui/airport-info-card.tsx
Normal file
415
src/components/ui/airport-info-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@ -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)"
|
||||
>
|
||||
|
||||
430
src/components/ui/atc-spectrum.tsx
Normal file
430
src/components/ui/atc-spectrum.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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"
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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 "JFK", a city name, or a flight
|
||||
callsign like "UAL123"
|
||||
</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 "{query.trim()}"
|
||||
</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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 & 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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
}`}
|
||||
>
|
||||
|
||||
@ -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)`,
|
||||
|
||||
@ -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";
|
||||
|
||||
340
src/components/ui/vertical-profile.tsx
Normal file
340
src/components/ui/vertical-profile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user