* Refactor aircraft photo and hero banner components to reset loading state on photo change - Updated Lightbox component to reset image loading state when navigating between photos. - Modified HeroBanner component to reset loading state when the photo changes. Clean up control panel search logic - Removed unnecessary hasResults variable in SearchContent component. Implement flight API client with fallback mechanism - Added flight-api-client to handle fetching flight data from multiple sources (airplanes.live, adsb.lol, OpenSky). - Introduced flight-api-parsing module to convert raw API responses into standardized FlightState objects. - Created flight-api-types for shared types between API responses. Refactor useFlights hook to utilize new flight API client - Updated useFlights hook to fetch flights using the new flight API client. - Removed credit management logic as it is no longer applicable with the new API structure. Fix useFlightMonitors to fetch flight data by hex address - Changed useFlightMonitors to use fetchFlightByHex instead of fetchFlightByIcao24. Update geo utility function for better readability - Refactored splitAtAntimeridian function to improve variable naming and clarity. Enhance OpenSky types with additional fields - Added typeCode and registration fields to FlightState type for better integration with readsb data. * fix: correct 6 files that diverged during rebase (iata code, globe mode ref, terrain attribution, cache eviction, opensky parsing) * fix: improve keyboard shortcuts help focus trapping feat: add showAirspace option to MapAttribution component fix: clear hideTimer on ScrollArea cleanup refactor: change pendingFpvRef to MutableRefObject in useFlightMonitors fix: handle sessionStorage availability in useFlightTrack refactor: increase POLL_INTERVAL_MS in useFlights for better performance fix: optimize keyboard shortcuts dialog check refactor: optimize useMergedTrails by caching selected flight position feat: extend Settings type with airspace options refactor: improve airline logo normalization functions refactor: enhance flight API client with serialized rate limiting refactor: optimize registration country lookup with pre-built maps refactor: enhance logo cache management with size limits feat: update map attribution to include airspace option fix: validate rawState in parseStateRow function refactor: improve utility functions with clamp implementation * feat: add ATC lookup functionality and GPU memory monitoring - Implemented ATC lookup functions in `atc-lookup.ts` for converting IATA to ICAO codes, finding nearby ATC feeds, and looking up ATC feeds by code. - Introduced `atc-types.ts` to define types and priorities for ATC feeds. - Added GPU memory monitoring in `gpu-memory-monitor.ts` to track WebGL resource allocations and provide memory reports. - Enhanced trail stitching logic in `trail-stitching.ts` by adding a function to clear the splined track cache and optimizing altitude checks. * feat: enhance flight data handling and improve API resilience - Implemented a maximum empty response streak guard in useFlights to prevent data loss during transient API failures. - Added immediate fetch on network reconnect in useFlights to ensure timely data retrieval. - Updated useMergedTrails to include timestamps for trail points. - Removed smoothAnimations setting from useSettings as it is no longer needed. - Enhanced useTrailHistory to preserve last-known trails during empty flight responses and added dynamic jump detection for tab resume scenarios. - Improved flight API client with a circuit breaker mechanism to handle provider failures and prevent excessive retries. - Updated flight API parsing to reject non-JSON responses from OpenSky and other providers. - Enhanced trail smoothing and stitching logic to ensure better continuity at junctions between historical and live data. * feat: migrate aircraft models to Cloudinary CDN and update mapping logic * fix: adjust UI component styles and improve trail smoothing parameters * fix: adjust base aircraft size for improved rendering * feat: update changelog with recent enhancements and modify data source attribution * fix: update model optimization details and remove Draco compression dependency * feat: update changelog with recent code review fixes and fallback provider adjustments
This commit is contained in:
59
src/components/flight-tracker-brand.tsx
Normal file
59
src/components/flight-tracker-brand.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { Github, Star } from "lucide-react";
|
||||
import {
|
||||
GITHUB_REPO_URL,
|
||||
formatStarCount,
|
||||
} from "@/components/flight-tracker-utils";
|
||||
|
||||
export function Brand({ isDark }: { isDark: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={`text-sm font-semibold tracking-wide ${
|
||||
isDark ? "text-white/70" : "text-black/70"
|
||||
}`}
|
||||
>
|
||||
aeris
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function GitHubBadge({ stars }: { stars: number | null }) {
|
||||
return (
|
||||
<a
|
||||
href={GITHUB_REPO_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Open GitHub repository"
|
||||
className="relative inline-flex h-9 w-9 items-center justify-center rounded-xl backdrop-blur-2xl transition-colors"
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: "rgb(var(--ui-fg) / 0.06)",
|
||||
backgroundColor: "rgb(var(--ui-fg) / 0.03)",
|
||||
color: "rgb(var(--ui-fg) / 0.5)",
|
||||
}}
|
||||
title={
|
||||
stars != null
|
||||
? `GitHub · ${formatStarCount(stars)} stars`
|
||||
: "Open GitHub repository"
|
||||
}
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
{stars != null && (
|
||||
<span
|
||||
className="pointer-events-none absolute -bottom-1 -right-1 rounded-full px-1.5 py-0.5 text-[9px] font-semibold tabular-nums"
|
||||
style={{
|
||||
backgroundColor: "rgb(var(--ui-bg) / 0.95)",
|
||||
border: "1px solid rgb(var(--ui-fg) / 0.1)",
|
||||
color: "rgb(var(--ui-fg) / 0.55)",
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Star className="h-2 w-2" />
|
||||
{formatStarCount(stars)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@ -65,7 +65,7 @@ export function cityFromFlight(flight: FlightState): City | null {
|
||||
id: `trk-${flight.icao24}`,
|
||||
name: `Flight ${code}`,
|
||||
country: flight.originCountry || "Unknown",
|
||||
iata: code.slice(0, 3),
|
||||
iata: code,
|
||||
coordinates: [flight.longitude, flight.latitude],
|
||||
radius: 2,
|
||||
};
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { CITIES, type City } from "@/lib/cities";
|
||||
import { findByIata, airportToCity } from "@/lib/airports";
|
||||
import { MAP_STYLES, DEFAULT_STYLE, type MapStyle } from "@/lib/map-styles";
|
||||
import { ICAO24_REGEX } from "@/lib/flight-api-types";
|
||||
|
||||
export { DEFAULT_STYLE };
|
||||
export { DEFAULT_STYLE, ICAO24_REGEX };
|
||||
|
||||
export const DEFAULT_CITY_ID = "sfo";
|
||||
export const STYLE_STORAGE_KEY = "aeris:mapStyle";
|
||||
@ -10,7 +11,6 @@ export const DEFAULT_CITY =
|
||||
CITIES.find((c) => c.id === DEFAULT_CITY_ID) ?? CITIES[0];
|
||||
export const GITHUB_REPO_URL = "https://github.com/kewonit/aeris";
|
||||
export const GITHUB_REPO_API = "https://api.github.com/repos/kewonit/aeris";
|
||||
export const ICAO24_REGEX = /^[0-9a-f]{6}$/i;
|
||||
|
||||
export const subscribeNoop = () => () => {};
|
||||
|
||||
@ -43,6 +43,7 @@ export function resolveInitialCity(): City {
|
||||
_cachedInitialCity = DEFAULT_CITY;
|
||||
return DEFAULT_CITY;
|
||||
} catch {
|
||||
// Not in a browser environment (SSR) — fall back to default city
|
||||
_cachedInitialCity = DEFAULT_CITY;
|
||||
return DEFAULT_CITY;
|
||||
}
|
||||
@ -58,7 +59,7 @@ export function syncCityToUrl(city: City): void {
|
||||
url.searchParams.delete("fpv");
|
||||
window.history.replaceState(null, "", url.toString());
|
||||
} catch {
|
||||
/* ignore */
|
||||
// URL parsing or history API may fail in non-browser environments
|
||||
}
|
||||
}
|
||||
|
||||
@ -79,7 +80,7 @@ export function syncFpvToUrl(icao24: string | null, activeCity?: City): void {
|
||||
}
|
||||
window.history.replaceState(null, "", url.toString());
|
||||
} catch {
|
||||
/* ignore */
|
||||
// URL parsing or history API may fail in non-browser environments
|
||||
}
|
||||
}
|
||||
|
||||
@ -89,6 +90,7 @@ export function resolveInitialFpv(): string | null {
|
||||
const raw = params.get("fpv")?.trim().toLowerCase();
|
||||
return raw && /^[0-9a-f]{6}$/.test(raw) ? raw : null;
|
||||
} catch {
|
||||
// Not in a browser environment (SSR)
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -99,6 +101,7 @@ export function loadMapStyle(): MapStyle {
|
||||
if (!id) return DEFAULT_STYLE;
|
||||
return MAP_STYLES.find((s) => s.id === id) ?? DEFAULT_STYLE;
|
||||
} catch {
|
||||
// localStorage unavailable (SSR, private browsing, or quota exceeded)
|
||||
return DEFAULT_STYLE;
|
||||
}
|
||||
}
|
||||
@ -108,7 +111,7 @@ export function saveMapStyle(style: MapStyle): void {
|
||||
try {
|
||||
localStorage.setItem(STYLE_STORAGE_KEY, style.id);
|
||||
} catch {
|
||||
/* blocked */
|
||||
// localStorage unavailable (private browsing or quota exceeded)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -9,18 +9,26 @@ import {
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { AnimatePresence } from "motion/react";
|
||||
import dynamic from "next/dynamic";
|
||||
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 { FlightLayers } from "@/components/map/flight-layers";
|
||||
import { FlightCard } from "@/components/ui/flight-card";
|
||||
const FlightCard = dynamic(() =>
|
||||
import("@/components/ui/flight-card").then((mod) => mod.FlightCard),
|
||||
);
|
||||
import { FpvHud } from "@/components/ui/fpv-hud";
|
||||
import { ControlPanel } from "@/components/ui/control-panel";
|
||||
const ControlPanel = dynamic(() =>
|
||||
import("@/components/ui/control-panel").then((mod) => mod.ControlPanel),
|
||||
);
|
||||
import { AltitudeLegend } from "@/components/ui/altitude-legend";
|
||||
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";
|
||||
import { Brand, GitHubBadge } from "@/components/flight-tracker-brand";
|
||||
import { SettingsProvider, useSettings } from "@/hooks/use-settings";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { useFlights } from "@/hooks/use-flights";
|
||||
@ -28,20 +36,19 @@ import { useTrailHistory } from "@/hooks/use-trail-history";
|
||||
import { useFlightTrack } from "@/hooks/use-flight-track";
|
||||
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 { 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 {
|
||||
fetchFlightByIcao24,
|
||||
fetchFlightByCallsign,
|
||||
type FlightState,
|
||||
} from "@/lib/opensky";
|
||||
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";
|
||||
import { Github, Star } from "lucide-react";
|
||||
import {
|
||||
DEFAULT_CITY,
|
||||
DEFAULT_STYLE,
|
||||
GITHUB_REPO_URL,
|
||||
ICAO24_REGEX,
|
||||
subscribeNoop,
|
||||
resolveInitialCity,
|
||||
@ -50,7 +57,6 @@ import {
|
||||
resolveInitialFpv,
|
||||
loadMapStyle,
|
||||
saveMapStyle,
|
||||
formatStarCount,
|
||||
} from "@/components/flight-tracker-utils";
|
||||
import {
|
||||
pickRandomAirportCity,
|
||||
@ -58,6 +64,9 @@ import {
|
||||
} from "@/components/flight-tracker-random";
|
||||
|
||||
function FlightTrackerInner() {
|
||||
// useSyncExternalStore with a no-op subscriber reads localStorage once
|
||||
// on the client while returning DEFAULT_CITY on the server — SSR-safe
|
||||
// hydration without useEffect flicker.
|
||||
const hydratedCity = useSyncExternalStore(
|
||||
subscribeNoop,
|
||||
resolveInitialCity,
|
||||
@ -89,6 +98,8 @@ function FlightTrackerInner() {
|
||||
lat: number;
|
||||
} | null>(null);
|
||||
|
||||
const lookupAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const activeCity = cityOverride ?? hydratedCity;
|
||||
const mapStyle = styleOverride ?? hydratedStyle;
|
||||
const { settings, update } = useSettings();
|
||||
@ -106,7 +117,7 @@ function FlightTrackerInner() {
|
||||
saveMapStyle(style);
|
||||
}, []);
|
||||
|
||||
const { flights, loading, rateLimited, retryIn } = useFlights(
|
||||
const { flights, loading, rateLimited, retryIn, source } = useFlights(
|
||||
activeCity,
|
||||
fpvIcao24,
|
||||
fpvSeedCenter,
|
||||
@ -115,10 +126,17 @@ function FlightTrackerInner() {
|
||||
const displayFlights = flights;
|
||||
const displayTrails = useTrailHistory(displayFlights);
|
||||
|
||||
// Single Map for O(1) flight lookups — replaces 4× O(n) find() calls per poll
|
||||
const displayFlightMap = useMemo(() => {
|
||||
const m = new Map<string, FlightState>();
|
||||
for (const f of displayFlights) m.set(f.icao24, f);
|
||||
return m;
|
||||
}, [displayFlights]);
|
||||
|
||||
const selectedFlightForTrack = useMemo(() => {
|
||||
if (!selectedIcao24) return null;
|
||||
return displayFlights.find((f) => f.icao24 === selectedIcao24) ?? null;
|
||||
}, [selectedIcao24, displayFlights]);
|
||||
return displayFlightMap.get(selectedIcao24) ?? null;
|
||||
}, [selectedIcao24, displayFlightMap]);
|
||||
|
||||
const shouldFetchSelectedTrack =
|
||||
!!selectedIcao24 &&
|
||||
@ -140,26 +158,18 @@ function FlightTrackerInner() {
|
||||
|
||||
const selectedFlight = useMemo(() => {
|
||||
if (!selectedIcao24) return null;
|
||||
return (
|
||||
displayFlights.find((f) => f.icao24.toLowerCase() === selectedIcao24) ??
|
||||
null
|
||||
);
|
||||
}, [selectedIcao24, displayFlights]);
|
||||
return displayFlightMap.get(selectedIcao24) ?? null;
|
||||
}, [selectedIcao24, displayFlightMap]);
|
||||
|
||||
const followFlight = useMemo(() => {
|
||||
if (!followIcao24) return null;
|
||||
return (
|
||||
displayFlights.find((f) => f.icao24.toLowerCase() === followIcao24) ??
|
||||
null
|
||||
);
|
||||
}, [followIcao24, displayFlights]);
|
||||
return displayFlightMap.get(followIcao24) ?? null;
|
||||
}, [followIcao24, displayFlightMap]);
|
||||
|
||||
const fpvFlight = useMemo(() => {
|
||||
if (!fpvIcao24) return null;
|
||||
return (
|
||||
displayFlights.find((f) => f.icao24.toLowerCase() === fpvIcao24) ?? null
|
||||
);
|
||||
}, [fpvIcao24, displayFlights]);
|
||||
return displayFlightMap.get(fpvIcao24) ?? null;
|
||||
}, [fpvIcao24, displayFlightMap]);
|
||||
|
||||
useEffect(() => {
|
||||
syncFpvToUrl(fpvIcao24, activeCity);
|
||||
@ -183,12 +193,21 @@ function FlightTrackerInner() {
|
||||
setFpvSeedCenter,
|
||||
});
|
||||
|
||||
const atc = useAtcStream();
|
||||
|
||||
const fpvFlightOrCached = fpvFlight;
|
||||
const displayFlight = selectedFlight;
|
||||
|
||||
const [atcToggle, setAtcToggle] = useState(0);
|
||||
const handleToggleAtc = useCallback(() => {
|
||||
setAtcToggle((c) => c + 1);
|
||||
}, []);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(info: PickingInfo<FlightState> | null) => {
|
||||
if (fpvIcao24) return;
|
||||
lookupAbortRef.current?.abort();
|
||||
lookupAbortRef.current = null;
|
||||
if (info?.object) {
|
||||
const icao24 = info.object.icao24.toLowerCase();
|
||||
setSelectedIcao24((prev) => (prev === icao24 ? null : icao24));
|
||||
@ -212,8 +231,8 @@ function FlightTrackerInner() {
|
||||
(icao24: string) => {
|
||||
const targetIcao24 = icao24.toLowerCase();
|
||||
const flight =
|
||||
displayFlights.find((f) => f.icao24.toLowerCase() === targetIcao24) ??
|
||||
flights.find((f) => f.icao24.toLowerCase() === targetIcao24);
|
||||
displayFlightMap.get(targetIcao24) ??
|
||||
flights.find((f) => f.icao24 === targetIcao24);
|
||||
if (!flight) return;
|
||||
if (flight.longitude == null || flight.latitude == null) return;
|
||||
if (flight.onGround) return;
|
||||
@ -228,7 +247,7 @@ function FlightTrackerInner() {
|
||||
});
|
||||
setFollowIcao24(null);
|
||||
},
|
||||
[displayFlights, flights],
|
||||
[displayFlightMap, flights],
|
||||
);
|
||||
|
||||
const handleExitFpv = useCallback(() => {
|
||||
@ -280,7 +299,7 @@ function FlightTrackerInner() {
|
||||
if (!compactQuery) return false;
|
||||
|
||||
const localMatch =
|
||||
displayFlights.find((f) => f.icao24.toLowerCase() === compactQuery) ??
|
||||
displayFlightMap.get(compactQuery) ??
|
||||
displayFlights.find((f) =>
|
||||
formatCallsign(f.callsign)
|
||||
.toLowerCase()
|
||||
@ -289,53 +308,53 @@ function FlightTrackerInner() {
|
||||
) ??
|
||||
null;
|
||||
|
||||
if (localMatch) {
|
||||
setSelectedIcao24(localMatch.icao24);
|
||||
// Helper: select flight and optionally enter FPV
|
||||
const selectFlight = (f: FlightState) => {
|
||||
setSelectedIcao24(f.icao24);
|
||||
setFollowIcao24(null);
|
||||
if (
|
||||
enterFpv &&
|
||||
!localMatch.onGround &&
|
||||
localMatch.longitude != null &&
|
||||
localMatch.latitude != null
|
||||
!f.onGround &&
|
||||
f.longitude != null &&
|
||||
f.latitude != null
|
||||
) {
|
||||
setFpvSeedCenter({
|
||||
lng: localMatch.longitude,
|
||||
lat: localMatch.latitude,
|
||||
});
|
||||
setFpvIcao24(localMatch.icao24);
|
||||
setFpvSeedCenter({ lng: f.longitude, lat: f.latitude });
|
||||
setFpvIcao24(f.icao24);
|
||||
}
|
||||
};
|
||||
|
||||
if (localMatch) {
|
||||
selectFlight(localMatch);
|
||||
return true;
|
||||
}
|
||||
|
||||
const result = ICAO24_REGEX.test(compactQuery)
|
||||
? await fetchFlightByIcao24(compactQuery)
|
||||
: await fetchFlightByCallsign(compactQuery);
|
||||
// Cancel any previous pending lookup
|
||||
lookupAbortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
lookupAbortRef.current = controller;
|
||||
|
||||
if (!result.flight) return false;
|
||||
try {
|
||||
const result = ICAO24_REGEX.test(compactQuery)
|
||||
? await fetchFlightByHex(compactQuery, controller.signal)
|
||||
: await fetchFlightByCallsign(compactQuery, controller.signal);
|
||||
|
||||
const focusCity = cityFromFlight(result.flight);
|
||||
if (focusCity) {
|
||||
setCityOverride(focusCity);
|
||||
syncCityToUrl(focusCity);
|
||||
if (controller.signal.aborted) return false;
|
||||
if (!result.flight) return false;
|
||||
|
||||
const focusCity = cityFromFlight(result.flight);
|
||||
if (focusCity) {
|
||||
setCityOverride(focusCity);
|
||||
syncCityToUrl(focusCity);
|
||||
}
|
||||
|
||||
selectFlight(result.flight);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") return false;
|
||||
return false;
|
||||
}
|
||||
|
||||
setSelectedIcao24(result.flight.icao24);
|
||||
setFollowIcao24(null);
|
||||
if (
|
||||
enterFpv &&
|
||||
!result.flight.onGround &&
|
||||
result.flight.longitude != null &&
|
||||
result.flight.latitude != null
|
||||
) {
|
||||
setFpvSeedCenter({
|
||||
lng: result.flight.longitude,
|
||||
lat: result.flight.latitude,
|
||||
});
|
||||
setFpvIcao24(result.flight.icao24);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[displayFlights],
|
||||
[displayFlights, displayFlightMap],
|
||||
);
|
||||
|
||||
useKeyboardShortcuts({
|
||||
@ -346,9 +365,77 @@ function FlightTrackerInner() {
|
||||
onToggleHelp: handleToggleHelp,
|
||||
onDeselect: handleDeselectFlight,
|
||||
onToggleFpv: handleToggleFpvKey,
|
||||
onToggleAtc: handleToggleAtc,
|
||||
isFpv: fpvIcao24 !== null,
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="relative h-dvh w-screen overflow-hidden bg-black">
|
||||
<MapView
|
||||
@ -368,6 +455,11 @@ function FlightTrackerInner() {
|
||||
onSelectAirport={setActiveCity}
|
||||
isDark={mapStyle.dark}
|
||||
/>
|
||||
<AirspaceLayer
|
||||
visible={settings.showAirspace}
|
||||
opacity={settings.airspaceOpacity}
|
||||
showHotspots={settings.showAirspaceHotspots}
|
||||
/>
|
||||
<FlightLayers
|
||||
flights={displayFlights}
|
||||
trails={mergedTrails}
|
||||
@ -394,7 +486,7 @@ function FlightTrackerInner() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!fpvIcao24 && (
|
||||
{!fpvIcao24 && !isMobile && (
|
||||
<div className="pointer-events-auto absolute left-3 top-14 sm:left-4 sm:top-16">
|
||||
<FlightCard
|
||||
flight={displayFlight}
|
||||
@ -409,41 +501,7 @@ function FlightTrackerInner() {
|
||||
|
||||
{!fpvIcao24 && (
|
||||
<div className="pointer-events-auto absolute right-3 top-3 flex items-center gap-1.5 sm:right-4 sm:top-4 sm:gap-2">
|
||||
<a
|
||||
href={GITHUB_REPO_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="Open GitHub repository"
|
||||
className="relative inline-flex h-9 w-9 items-center justify-center rounded-xl backdrop-blur-2xl transition-colors"
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: "rgb(var(--ui-fg) / 0.06)",
|
||||
backgroundColor: "rgb(var(--ui-fg) / 0.03)",
|
||||
color: "rgb(var(--ui-fg) / 0.5)",
|
||||
}}
|
||||
title={
|
||||
repoStars != null
|
||||
? `GitHub · ${formatStarCount(repoStars)} stars`
|
||||
: "Open GitHub repository"
|
||||
}
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
{repoStars != null && (
|
||||
<span
|
||||
className="pointer-events-none absolute -bottom-1 -right-1 rounded-full px-1.5 py-0.5 text-[9px] font-semibold tabular-nums"
|
||||
style={{
|
||||
backgroundColor: "rgb(var(--ui-bg) / 0.95)",
|
||||
border: "1px solid rgb(var(--ui-fg) / 0.1)",
|
||||
color: "rgb(var(--ui-fg) / 0.55)",
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Star className="h-2 w-2" />
|
||||
{formatStarCount(repoStars)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
<GitHubBadge stars={repoStars} />
|
||||
<ControlPanel
|
||||
activeCity={activeCity}
|
||||
onSelectCity={setActiveCity}
|
||||
@ -461,16 +519,32 @@ function FlightTrackerInner() {
|
||||
<StatusBar
|
||||
flightCount={flights.length}
|
||||
cityName={activeCity.name}
|
||||
cityIata={activeCity.iata}
|
||||
cityCoordinates={activeCity.coordinates}
|
||||
loading={loading}
|
||||
rateLimited={rateLimited}
|
||||
retryIn={retryIn}
|
||||
onNorthUp={handleNorthUp}
|
||||
onResetView={handleResetView}
|
||||
onRandomAirport={handleRandomAirport}
|
||||
atc={atc}
|
||||
atcToggle={atcToggle}
|
||||
source={source}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ATC Player Bar — top-center on mobile, bottom-center on desktop */}
|
||||
{!fpvIcao24 && (
|
||||
<AnimatePresence>
|
||||
{atc.feed && (
|
||||
<div className="pointer-events-auto absolute left-1/2 top-14 -translate-x-1/2 sm:top-auto sm:bottom-18">
|
||||
<AtcPlayerBar atc={atc} onOpenFeedSelector={handleToggleAtc} />
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
|
||||
{!fpvIcao24 && (
|
||||
<div className="pointer-events-none absolute bottom-[env(safe-area-inset-bottom,0px)] right-3 mb-3 flex flex-col items-end gap-2 sm:bottom-4 sm:right-4 sm:mb-0">
|
||||
<div className="pointer-events-auto">
|
||||
@ -480,7 +554,10 @@ function FlightTrackerInner() {
|
||||
<AltitudeLegend />
|
||||
</div>
|
||||
<div className="pointer-events-auto">
|
||||
<MapAttribution styleId={mapStyle.id} />
|
||||
<MapAttribution
|
||||
styleId={mapStyle.id}
|
||||
showAirspace={settings.showAirspace}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -504,15 +581,3 @@ export function FlightTracker() {
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function Brand({ isDark }: { isDark: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={`text-sm font-semibold tracking-wide ${
|
||||
isDark ? "text-white/70" : "text-black/70"
|
||||
}`}
|
||||
>
|
||||
aeris
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@ -18,26 +18,26 @@ export const CATEGORY_TINT: Record<number, [number, number, number]> = {
|
||||
export function categorySizeMultiplier(category: number | null): number {
|
||||
switch (category) {
|
||||
case 2:
|
||||
return 0.88;
|
||||
return 0.92;
|
||||
case 3:
|
||||
return 0.96;
|
||||
case 4:
|
||||
return 1.08;
|
||||
case 5:
|
||||
return 1.18;
|
||||
case 6:
|
||||
return 1.28;
|
||||
case 7:
|
||||
return 1.04;
|
||||
case 5:
|
||||
return 1.08;
|
||||
case 6:
|
||||
return 1.12;
|
||||
case 7:
|
||||
return 1.0;
|
||||
case 8:
|
||||
return 0.86;
|
||||
return 0.9;
|
||||
case 9:
|
||||
case 12:
|
||||
return 0.8;
|
||||
return 0.86;
|
||||
case 10:
|
||||
return 1.15;
|
||||
return 1.06;
|
||||
case 14:
|
||||
return 0.72;
|
||||
return 0.82;
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
|
||||
183
src/components/map/aircraft-model-layers.ts
Normal file
183
src/components/map/aircraft-model-layers.ts
Normal file
@ -0,0 +1,183 @@
|
||||
// ── Aircraft Model Layers ──────────────────────────────────────────────
|
||||
//
|
||||
// Builds one ScenegraphLayer per model type from bucketised flights.
|
||||
// This keeps flight-layers.tsx slim and model logic self-contained.
|
||||
//
|
||||
// Performance strategy:
|
||||
// 1. Bucket raw flights by model key (cached between polls via
|
||||
// bucketFlightsByModel — only recomputes when flightsRef changes).
|
||||
// 2. Pass STABLE data arrays to each ScenegraphLayer (same reference
|
||||
// between animation frames) so deck.gl skips full attribute rebuild.
|
||||
// 3. Use updateTriggers to selectively recompute only position & orientation
|
||||
// each frame. Color and scale are recomputed only on new data.
|
||||
// 4. Layers are created for model keys that have active flights or were
|
||||
// recently active (within MODEL_DEACTIVATE_MS grace period). Truly
|
||||
// inactive models are omitted entirely to reduce overhead.
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
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 { type PickingInfo } from "@deck.gl/core";
|
||||
import {
|
||||
AIRCRAFT_MIN_PIXELS,
|
||||
AIRCRAFT_MAX_PIXELS,
|
||||
BASE_AIRCRAFT_SIZE,
|
||||
} from "./flight-layer-constants";
|
||||
import {
|
||||
ALL_MODEL_KEYS,
|
||||
bucketFlightsByModel,
|
||||
modelNormScale,
|
||||
modelUrl,
|
||||
modelYawOffset,
|
||||
} from "./aircraft-model-mapping";
|
||||
|
||||
// Stable empty array — same reference every frame so deck.gl skips buffer work
|
||||
const EMPTY_DATA: FlightState[] = [];
|
||||
|
||||
// Track when each model type was last seen in flight data.
|
||||
// Models not seen for MODEL_DEACTIVATE_MS are omitted from the layer array
|
||||
// entirely, avoiding ScenegraphLayer constructor and deck.gl diffing overhead.
|
||||
const modelLastUsed = new Map<string, number>();
|
||||
const MODEL_DEACTIVATE_MS = 5_000; // 5 second grace period (covers 1 poll cycle)
|
||||
const MODEL_LAST_USED_MAX = 50; // bound the Map to prevent unbounded growth
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AircraftLayerParams {
|
||||
/** Raw flights (flightsRef.current) — stable between polls. Used for bucketing. */
|
||||
rawFlights: FlightState[];
|
||||
/** Interpolated flight map (icao24 → interpolated FlightState). Updated every frame. */
|
||||
interpolatedMap: Map<string, FlightState>;
|
||||
/** Animation frame counter — increments every rAF. Drives position/orientation updates. */
|
||||
frameCounter: number;
|
||||
/** Data version — increments on new poll data. Triggers color/scale recomputation. */
|
||||
dataVersion: number;
|
||||
layersVisible: boolean;
|
||||
globeFade: number;
|
||||
elevScale: number;
|
||||
altColors: boolean;
|
||||
defaultColor: [number, number, number, number];
|
||||
pitchByIcao: Map<string, number>;
|
||||
bankByIcao: Map<string, number>;
|
||||
handleHover: (info: PickingInfo<FlightState>) => void;
|
||||
handleClick: (info: PickingInfo<FlightState>) => void;
|
||||
}
|
||||
|
||||
// ── Builder ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns an array of ScenegraphLayers — one per model key.
|
||||
*
|
||||
* Key optimization: `data` uses the CACHED bucket arrays (stable reference
|
||||
* 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)
|
||||
*
|
||||
* This eliminates per-frame color/scale attribute recomputation for all
|
||||
* 14 layers and massively reduces GC pressure from array allocations.
|
||||
*/
|
||||
export function buildAircraftModelLayers(
|
||||
params: AircraftLayerParams,
|
||||
): ScenegraphLayer<FlightState>[] {
|
||||
const {
|
||||
rawFlights,
|
||||
interpolatedMap,
|
||||
frameCounter,
|
||||
dataVersion,
|
||||
layersVisible,
|
||||
globeFade,
|
||||
elevScale,
|
||||
altColors,
|
||||
defaultColor,
|
||||
pitchByIcao,
|
||||
bankByIcao,
|
||||
handleHover,
|
||||
handleClick,
|
||||
} = params;
|
||||
|
||||
// Cached bucketing — only recomputes when rawFlights reference changes
|
||||
const buckets = bucketFlightsByModel(rawFlights);
|
||||
const now = performance.now();
|
||||
|
||||
// Only build layers for models that have data or are within the grace
|
||||
// period. Truly inactive models (no data AND expired) are skipped entirely,
|
||||
// avoiding ScenegraphLayer constructor + deck.gl diffing overhead.
|
||||
// Evict stale entries to bound memory growth over long sessions
|
||||
if (modelLastUsed.size > MODEL_LAST_USED_MAX) {
|
||||
for (const [k, ts] of modelLastUsed) {
|
||||
if (now - ts > MODEL_DEACTIVATE_MS) modelLastUsed.delete(k);
|
||||
}
|
||||
}
|
||||
|
||||
return ALL_MODEL_KEYS.filter((key) => {
|
||||
const hasData = (buckets.get(key)?.length ?? 0) > 0;
|
||||
if (hasData) {
|
||||
modelLastUsed.set(key, now);
|
||||
return true;
|
||||
}
|
||||
return now - (modelLastUsed.get(key) ?? 0) < MODEL_DEACTIVATE_MS;
|
||||
}).map((modelKey) => {
|
||||
const flights = buckets.get(modelKey) ?? EMPTY_DATA;
|
||||
const hasData = flights.length > 0;
|
||||
|
||||
// Pre-compute the yaw offset once per layer (not per-flight per-frame)
|
||||
const yawOff = modelYawOffset(modelKey);
|
||||
const normScale = modelNormScale(modelKey);
|
||||
|
||||
return new ScenegraphLayer<FlightState>({
|
||||
id: `flight-aircraft-${modelKey}`,
|
||||
visible: hasData && layersVisible,
|
||||
data: flights,
|
||||
opacity: globeFade,
|
||||
getPosition: (d) => {
|
||||
const interp = interpolatedMap.get(d.icao24);
|
||||
const src = interp ?? d;
|
||||
return [
|
||||
src.longitude ?? 0,
|
||||
src.latitude ?? 0,
|
||||
altitudeToElevation(src.baroAltitude) * elevScale,
|
||||
];
|
||||
},
|
||||
getOrientation: (d) => {
|
||||
const interp = interpolatedMap.get(d.icao24);
|
||||
const src = interp ?? d;
|
||||
const pitch = pitchByIcao.get(d.icao24) ?? 0;
|
||||
const bank = bankByIcao.get(d.icao24) ?? 0;
|
||||
const yaw =
|
||||
yawOff - (Number.isFinite(src.trueTrack) ? src.trueTrack! : 0);
|
||||
return [pitch, yaw, 90 + bank];
|
||||
},
|
||||
getColor: (d) => {
|
||||
const base = altColors ? altitudeToColor(d.baroAltitude) : defaultColor;
|
||||
return tintAircraftColor(base, d.category);
|
||||
},
|
||||
scenegraph: modelUrl(modelKey),
|
||||
getScale: (d) => {
|
||||
const catScale = categorySizeMultiplier(d.category);
|
||||
const s = catScale * normScale;
|
||||
return [s, s, s];
|
||||
},
|
||||
sizeScale: BASE_AIRCRAFT_SIZE,
|
||||
updateTriggers: {
|
||||
getPosition: [frameCounter, elevScale],
|
||||
getOrientation: frameCounter,
|
||||
getColor: [dataVersion, altColors],
|
||||
getScale: dataVersion,
|
||||
},
|
||||
sizeMinPixels: AIRCRAFT_MIN_PIXELS,
|
||||
sizeMaxPixels: AIRCRAFT_MAX_PIXELS,
|
||||
_lighting: "pbr",
|
||||
pickable: hasData,
|
||||
onHover: handleHover,
|
||||
onClick: handleClick,
|
||||
autoHighlight: true,
|
||||
highlightColor: [255, 255, 255, 80],
|
||||
});
|
||||
});
|
||||
}
|
||||
380
src/components/map/aircraft-model-mapping.ts
Normal file
380
src/components/map/aircraft-model-mapping.ts
Normal file
@ -0,0 +1,380 @@
|
||||
// ── Aircraft Model Mapping ─────────────────────────────────────────────
|
||||
//
|
||||
// Maps ADS-B category + ICAO typeCode → 3D model silhouette.
|
||||
// Models are Draco-compressed GLB files served from Cloudinary CDN.
|
||||
// Local backups remain in public/models/aircraft/.
|
||||
//
|
||||
// Category-based fallback assigns generic silhouettes (narrowbody, etc.).
|
||||
// TypeCode-based matching routes iconic types (A380, B737) to dedicated models.
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import type { FlightState } from "@/lib/opensky";
|
||||
|
||||
// ── Model Keys ─────────────────────────────────────────────────────────
|
||||
|
||||
export type AircraftModelKey =
|
||||
| "a380"
|
||||
| "b737"
|
||||
| "narrowbody"
|
||||
| "widebody-2eng"
|
||||
| "widebody-4eng"
|
||||
| "regional-jet"
|
||||
| "light-prop"
|
||||
| "turboprop"
|
||||
| "helicopter"
|
||||
| "bizjet"
|
||||
| "glider"
|
||||
| "fighter"
|
||||
| "drone"
|
||||
| "generic";
|
||||
|
||||
export const ALL_MODEL_KEYS: readonly AircraftModelKey[] = [
|
||||
"a380",
|
||||
"b737",
|
||||
"narrowbody",
|
||||
"widebody-2eng",
|
||||
"widebody-4eng",
|
||||
"regional-jet",
|
||||
"light-prop",
|
||||
"turboprop",
|
||||
"helicopter",
|
||||
"bizjet",
|
||||
"glider",
|
||||
"fighter",
|
||||
"drone",
|
||||
"generic",
|
||||
] as const;
|
||||
|
||||
// ── URL Resolution ─────────────────────────────────────────────────────
|
||||
|
||||
const CLOUDINARY_CLOUD = "dfyrk32ua";
|
||||
const CLOUDINARY_FOLDER = "aeris/models/aircraft";
|
||||
|
||||
// Per-model Cloudinary versions from upload response — ensures optimal
|
||||
// CDN cache (long-lived Cache-Control) and instant busting on re-upload.
|
||||
const MODEL_CDN_VERSIONS: Readonly<Record<string, number>> = {
|
||||
b737: 1774203409,
|
||||
bizjet: 1774203410,
|
||||
fighter: 1774203411,
|
||||
glider: 1774203411,
|
||||
helicopter: 1774203412,
|
||||
"light-prop": 1774203413,
|
||||
narrowbody: 1774203413,
|
||||
"regional-jet": 1774203414,
|
||||
turboprop: 1774203415,
|
||||
"widebody-2eng": 1774203416,
|
||||
"widebody-4eng": 1774203418,
|
||||
};
|
||||
|
||||
// A380 reuses the widebody-4eng mesh (it IS the A380 from FlightAirMap).
|
||||
// generic.glb and narrowbody.glb are identical files; drone.glb and light-prop.glb likewise.
|
||||
const MODEL_FILE_OVERRIDES: Partial<Record<AircraftModelKey, string>> = {
|
||||
a380: "widebody-4eng",
|
||||
generic: "narrowbody",
|
||||
drone: "light-prop",
|
||||
};
|
||||
|
||||
export function modelUrl(key: AircraftModelKey): string {
|
||||
const file = MODEL_FILE_OVERRIDES[key] ?? key;
|
||||
const version = MODEL_CDN_VERSIONS[file] ?? 1;
|
||||
return `https://res.cloudinary.com/${CLOUDINARY_CLOUD}/raw/upload/v${version}/${CLOUDINARY_FOLDER}/${file}.glb`;
|
||||
}
|
||||
|
||||
// ── Per-Model Size Normalization ───────────────────────────────────────
|
||||
//
|
||||
// Factors normalize all models to a consistent visual base (~40 units).
|
||||
// categorySizeMultiplier in aircraft-appearance.ts adds per-category scaling.
|
||||
|
||||
const MODEL_NORMALIZE: Readonly<Record<AircraftModelKey, number>> = {
|
||||
a380: 0.42,
|
||||
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,
|
||||
generic: 1.0,
|
||||
};
|
||||
|
||||
/** Returns the size normalization factor for a model type */
|
||||
export function modelNormScale(key: AircraftModelKey): number {
|
||||
return MODEL_NORMALIZE[key];
|
||||
}
|
||||
|
||||
// ── Per-Model Yaw Offset ───────────────────────────────────────────────
|
||||
//
|
||||
// Each GLB was authored/exported with a different nose direction in model space.
|
||||
// These offsets rotate each model so that at yaw=0 the nose faces North.
|
||||
// Combined formula: yaw = MODEL_YAW_OFFSET[key] - trueTrack
|
||||
//
|
||||
// Determined by analysing each model's node rotations and nose-indicator
|
||||
// node translations (CockpitWindows, pilot_tubes, windscreen, etc.).
|
||||
|
||||
const MODEL_YAW_OFFSET: Readonly<Record<AircraftModelKey, number>> = {
|
||||
b737: 0, // no node rotation, nose at -Z → already faces North
|
||||
narrowbody: 90, // 180° Y rotation, nose raw +X → model +X → East at yaw=0
|
||||
generic: 90, // identical mesh to narrowbody
|
||||
"widebody-2eng": 180, // 90° Y rotation, nose raw +Z → model -X → South
|
||||
"widebody-4eng": 180, // same rotation family
|
||||
a380: 180, // uses widebody-4eng mesh
|
||||
"regional-jet": 180, // 90° Y rotation, nose indicators at +Z
|
||||
bizjet: 180, // 90° Y rotation, Glass.inside near +Z
|
||||
helicopter: 180, // 90° Y rotation, body extends +Z
|
||||
glider: 180, // 90° Y rotation, windowR near +Z
|
||||
fighter: 180, // 90° Y rotation
|
||||
turboprop: 180, // 120° diagonal rotation, cylinder at +Z
|
||||
"light-prop": 180, // 120° diagonal rotation
|
||||
drone: 180, // identical mesh to light-prop
|
||||
};
|
||||
|
||||
/** Returns the yaw offset in degrees to orient the model's nose North */
|
||||
export function modelYawOffset(key: AircraftModelKey): number {
|
||||
return MODEL_YAW_OFFSET[key];
|
||||
}
|
||||
|
||||
// ── Category → Model Key (DO-260B emitter categories) ──────────────────
|
||||
export function categoryToModelKey(category: number | null): AircraftModelKey {
|
||||
switch (category) {
|
||||
case 2:
|
||||
return "light-prop";
|
||||
case 3:
|
||||
return "narrowbody";
|
||||
case 4:
|
||||
return "narrowbody";
|
||||
case 5:
|
||||
return "narrowbody";
|
||||
case 6:
|
||||
return "widebody-2eng";
|
||||
case 7:
|
||||
return "fighter";
|
||||
case 8:
|
||||
return "helicopter";
|
||||
case 9:
|
||||
return "glider";
|
||||
case 12:
|
||||
return "light-prop";
|
||||
case 14:
|
||||
return "drone";
|
||||
default:
|
||||
return "generic";
|
||||
}
|
||||
}
|
||||
|
||||
// ── TypeCode → Model Key ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Maps ICAO type designator to a model key. Returns null for unrecognized types.
|
||||
*
|
||||
* Patterns checked in priority order — first match wins. This ordering
|
||||
* prevents false positives (e.g. C919 matching bizjet C[5-9]xx, or
|
||||
* Fokker F28 matching the fighter F-series pattern).
|
||||
*
|
||||
* Sources: ICAO Doc 8643 Aircraft Type Designators.
|
||||
*/
|
||||
export function typeCodeToModelKey(
|
||||
typeCode: string | null | undefined,
|
||||
): AircraftModelKey | null {
|
||||
if (!typeCode) return null;
|
||||
const tc = typeCode.toUpperCase();
|
||||
|
||||
// ── Narrowbody airliners ─────────────────────────────────────────
|
||||
// Airbus A318/A319/A320/A321, neo variants (A19N/A20N/A21N),
|
||||
// Airbus A220 (BCS1/BCS3), Boeing 717, COMAC C919
|
||||
if (/^A31[89]$|^A32\d$|^A(?:19|20|21)N$|^BCS[13]$|^B712$|^C919$/.test(tc))
|
||||
return "narrowbody";
|
||||
|
||||
// ── Widebody twins ───────────────────────────────────────────────
|
||||
// A300/A310, A330, A350 (incl. A35K = A350-1000)
|
||||
if (/^A30[0-9B]$|^A310$|^A33\d$|^A35[0-9K]$/.test(tc)) return "widebody-2eng";
|
||||
|
||||
// Airbus A380
|
||||
if (/^A38\d$/.test(tc)) return "a380";
|
||||
|
||||
// Airbus A340 (four-engine widebody)
|
||||
if (/^A34\d$/.test(tc)) return "widebody-4eng";
|
||||
|
||||
// Boeing 737 family (incl. MAX 7/8/9/10: B37M/B38M/B39M/B3XM)
|
||||
if (/^B73\d$|^B3[789X]M$/.test(tc)) return "b737";
|
||||
|
||||
// Boeing 757
|
||||
if (/^B75\d$/.test(tc)) return "narrowbody";
|
||||
|
||||
// Boeing 767
|
||||
if (/^B76\d$/.test(tc)) return "widebody-2eng";
|
||||
|
||||
// Boeing 777/787
|
||||
if (/^B77\d$|^B77[LW]$|^B78\d$|^B78X$/.test(tc)) return "widebody-2eng";
|
||||
|
||||
// Boeing 747 (incl. SP/SR letter-suffix variants)
|
||||
if (/^B74[0-9FRSP]$/.test(tc)) return "widebody-4eng";
|
||||
|
||||
// ── Regional jets ────────────────────────────────────────────────
|
||||
// CRJ (incl. CRJX = CRJ-1000), Embraer E-Jets (E170/E175/E190/E195,
|
||||
// E2: E275/E290/E295, + E75L/E75S), Fokker F28/F70/F100,
|
||||
// BAe 146 (B461-B463), Antonov An-148/158, Sukhoi Superjet, ARJ21
|
||||
if (
|
||||
/^CRJ[0-9X]?$|^E1[79]\d$|^E[27][79]\d$|^E75[0-9LS]$|^F(?:28|70|10\d)$|^B46[1-3]$|^A148$|^A158$|^SU95$|^AJ27$/.test(
|
||||
tc,
|
||||
)
|
||||
)
|
||||
return "regional-jet";
|
||||
|
||||
// ── Turboprops ───────────────────────────────────────────────────
|
||||
// ATR, Dash-8, Saab 340/2000, Jetstream, Fokker F27/F50,
|
||||
// Beechcraft 1900, Embraer EMB 110/120
|
||||
if (
|
||||
/^AT[47]\d$|^DH8[A-D]?$|^SF34$|^SB20$|^JS[34]\d$|^F(?:27|50)$|^B190$|^E1[12]0$/.test(
|
||||
tc,
|
||||
)
|
||||
)
|
||||
return "turboprop";
|
||||
|
||||
// ── Business jets ────────────────────────────────────────────────
|
||||
// Gulfstream (GLF/G-series), Bombardier Global (GLEX/GL5T/GL7T),
|
||||
// Challenger, Dassault Falcon (FA-series, F2TH, F900),
|
||||
// Learjet, Cessna Citation (C5xx-C9xx + C25A-C), Hawker (H25x),
|
||||
// Embraer Phenom/Legacy (E55P/E550/E545), Pilatus PC-24,
|
||||
// HondaJet, Beechjet (BE40)
|
||||
if (
|
||||
/^GLF\d$|^GL[5-7][T0-9]$|^GLEX$|^G[2-7]\d{2}$|^CL[3-6]\d$|^FA\d[0-9X]$|^F2TH$|^F900$|^LJ\d{2}$|^C[5-9]\d{2}$|^C25[A-C]$|^GA\d[0-9C]$|^H25[0-9A-Z]?$|^E[35]5[0-9P]$|^E545$|^PC24$|^HDJT$|^BE40$/.test(
|
||||
tc,
|
||||
)
|
||||
)
|
||||
return "bizjet";
|
||||
|
||||
// ── Light GA ─────────────────────────────────────────────────────
|
||||
// Cessna single/twin, Piper, Cirrus, Diamond, SOCATA, Mooney, Beechcraft
|
||||
if (
|
||||
/^C[12]\d{2}$|^PA\d{2}$|^SR2\d$|^DA[24]\d$|^TB\d{2}$|^M20\d?$|^BE[3-9]\d$/.test(
|
||||
tc,
|
||||
)
|
||||
) {
|
||||
// Exclude military/utility types that happen to match the Cessna pattern
|
||||
if (/^C130$|^C212$|^C295$/.test(tc)) return null;
|
||||
return "light-prop";
|
||||
}
|
||||
|
||||
// ── Helicopters ──────────────────────────────────────────────────
|
||||
// Airbus/Eurocopter, Sikorsky, Robinson, Aérospatiale, MBB
|
||||
if (/^H[16]\d{2}$|^EC\d{2}$|^S[67]\d$|^R[24]\d$|^AS\d{2}$|^BK\d{2}$/.test(tc))
|
||||
return "helicopter";
|
||||
// Bell: B0xx-B4xx (B190 and B46x already handled in turboprop/regional)
|
||||
if (/^B[0-4]\d{2}$/.test(tc) && !/^B19\d$|^B46\d$/.test(tc))
|
||||
return "helicopter";
|
||||
// AgustaWestland: A10x-A19x (A148/A158 already handled in regional)
|
||||
if (/^A1[0-9]\d$/.test(tc) && tc !== "A148" && tc !== "A158")
|
||||
return "helicopter";
|
||||
|
||||
// ── Military fighters ────────────────────────────────────────────
|
||||
// F-series (Fokker F27/F28/F50 already handled in turboprop/regional)
|
||||
if (/^F\d{1,2}[A-Z]?$/.test(tc)) return "fighter";
|
||||
// Eurofighter, Tornado, Mikoyan
|
||||
if (/^EF\d/.test(tc) || tc === "TOR" || /^MIG\d/.test(tc)) return "fighter";
|
||||
// Sukhoi fighters (SU95 Superjet already handled in regional)
|
||||
if (/^SU\d/.test(tc) && tc !== "SU95") return "fighter";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Combined Resolver ──────────────────────────────────────────────────
|
||||
|
||||
/** Resolves model key: typeCode match first, then category fallback. */
|
||||
export function resolveModelKey(
|
||||
category: number | null,
|
||||
typeCode?: string | null,
|
||||
): AircraftModelKey {
|
||||
if (typeCode) {
|
||||
const fromType = typeCodeToModelKey(typeCode);
|
||||
if (fromType) return fromType;
|
||||
}
|
||||
return categoryToModelKey(category);
|
||||
}
|
||||
|
||||
// ── Per-Aircraft Model Key Cache ───────────────────────────────────────
|
||||
//
|
||||
// Avoids re-running up to 20 regex tests per flight per frame.
|
||||
// Key = icao24, value = resolved model key.
|
||||
// Cache is wiped when the flight data array changes (new poll).
|
||||
|
||||
const modelKeyCache = new Map<string, AircraftModelKey>();
|
||||
|
||||
/** Resolves model key with per-icao24 caching. */
|
||||
export function resolveModelKeyCached(flight: FlightState): AircraftModelKey {
|
||||
const cached = modelKeyCache.get(flight.icao24);
|
||||
if (cached !== undefined) return cached;
|
||||
const key = resolveModelKey(flight.category, flight.typeCode);
|
||||
modelKeyCache.set(flight.icao24, key);
|
||||
return key;
|
||||
}
|
||||
|
||||
/** Clear the model key cache when flight data changes. */
|
||||
export function invalidateModelKeyCache(): void {
|
||||
modelKeyCache.clear();
|
||||
}
|
||||
|
||||
// ── Flight Bucketing ───────────────────────────────────────────────────
|
||||
//
|
||||
// Cached bucketing: only recomputes when the flights array reference changes.
|
||||
// This prevents 60fps re-bucketing + new array allocations that cause
|
||||
// deck.gl to regenerate GPU buffers every frame.
|
||||
|
||||
let cachedBucketInput: FlightState[] | null = null;
|
||||
let cachedBuckets: Map<AircraftModelKey, FlightState[]> | null = null;
|
||||
|
||||
export function bucketFlightsByModel(
|
||||
flights: FlightState[],
|
||||
): Map<AircraftModelKey, FlightState[]> {
|
||||
// Return cached result if the flights array reference hasn't changed
|
||||
if (flights === cachedBucketInput && cachedBuckets) {
|
||||
return cachedBuckets;
|
||||
}
|
||||
|
||||
// Invalidate model key cache on new data (new aircraft may appear)
|
||||
invalidateModelKeyCache();
|
||||
|
||||
const buckets = new Map<AircraftModelKey, FlightState[]>();
|
||||
|
||||
for (const flight of flights) {
|
||||
const key = resolveModelKeyCached(flight);
|
||||
const bucket = buckets.get(key);
|
||||
if (bucket) {
|
||||
bucket.push(flight);
|
||||
} else {
|
||||
buckets.set(key, [flight]);
|
||||
}
|
||||
}
|
||||
|
||||
cachedBucketInput = flights;
|
||||
cachedBuckets = buckets;
|
||||
return buckets;
|
||||
}
|
||||
|
||||
// ── Preloading ─────────────────────────────────────────────────────────
|
||||
|
||||
let preloaded = false;
|
||||
|
||||
const PREFETCH_KEYS: AircraftModelKey[] = [
|
||||
"narrowbody",
|
||||
"b737",
|
||||
"widebody-2eng",
|
||||
];
|
||||
|
||||
export function preloadAllModels(): void {
|
||||
if (preloaded || typeof document === "undefined") return;
|
||||
preloaded = true;
|
||||
|
||||
for (const key of PREFETCH_KEYS) {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "prefetch";
|
||||
link.href = modelUrl(key);
|
||||
link.as = "fetch";
|
||||
link.crossOrigin = "anonymous";
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
}
|
||||
209
src/components/map/airspace-layer.tsx
Normal file
209
src/components/map/airspace-layer.tsx
Normal file
@ -0,0 +1,209 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { useMap } from "./map";
|
||||
|
||||
// ── OpenAIP Airspace Tile Overlay ──────────────────────────────────────
|
||||
//
|
||||
// Adds OpenAIP's pre-styled airspace raster tiles as a MapLibre layer.
|
||||
// Tiles are fetched through /api/airspace-tiles to keep the API key
|
||||
// server-side. The layer is inserted below symbol layers so labels
|
||||
// remain readable.
|
||||
//
|
||||
// MEMORY OPTIMISATION: When airspace is hidden the entire source is
|
||||
// removed (not just set to `visibility: none`). This releases all
|
||||
// decoded tile ArrayBuffers from GPU memory — each 256×256 PNG tile
|
||||
// occupies ~262 KB decoded, and 100+ cached tiles can easily add
|
||||
// 26+ MB per source. The proxy sets Cache-Control: immutable so
|
||||
// the browser disk-cache serves tiles instantly when re-enabled.
|
||||
//
|
||||
// Data: openaip.net (CC BY-NC 4.0)
|
||||
// Tiles update daily; cached 24h by the proxy.
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const SOURCE_ID = "openaip-airspace-tiles";
|
||||
const LAYER_ID = "openaip-airspace-layer";
|
||||
|
||||
const AIRSPACE_CONTRAST = 0.3;
|
||||
const AIRSPACE_SATURATION = 0.2;
|
||||
const AIRSPACE_BRIGHTNESS_MIN = 0.08;
|
||||
const AIRSPACE_MIN_ZOOM = 4;
|
||||
const AIRSPACE_MAX_ZOOM = 14;
|
||||
|
||||
const HOTSPOT_SOURCE_ID = "openaip-hotspot-tiles";
|
||||
const HOTSPOT_LAYER_ID = "openaip-hotspot-layer";
|
||||
const HOTSPOT_OPACITY = 0.7;
|
||||
|
||||
type AirspaceLayerProps = {
|
||||
visible: boolean;
|
||||
opacity: number;
|
||||
showHotspots: boolean;
|
||||
};
|
||||
|
||||
export function AirspaceLayer({
|
||||
visible,
|
||||
opacity,
|
||||
showHotspots,
|
||||
}: AirspaceLayerProps) {
|
||||
const { map, isLoaded } = useMap();
|
||||
|
||||
// Refs to let style.load callback read latest prop values
|
||||
const visibleRef = useRef(visible);
|
||||
visibleRef.current = visible;
|
||||
const opacityRef = useRef(opacity);
|
||||
opacityRef.current = opacity;
|
||||
const hotspotsRef = useRef(showHotspots);
|
||||
hotspotsRef.current = showHotspots;
|
||||
|
||||
const mountedRef = useRef(true);
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/** Remove airspace + hotspot layers/sources if they exist. */
|
||||
const removeSources = useCallback(() => {
|
||||
if (!map) return;
|
||||
try {
|
||||
if (map.getLayer(HOTSPOT_LAYER_ID)) map.removeLayer(HOTSPOT_LAYER_ID);
|
||||
if (map.getSource(HOTSPOT_SOURCE_ID)) map.removeSource(HOTSPOT_SOURCE_ID);
|
||||
if (map.getLayer(LAYER_ID)) map.removeLayer(LAYER_ID);
|
||||
if (map.getSource(SOURCE_ID)) map.removeSource(SOURCE_ID);
|
||||
} catch {
|
||||
/* map may already be destroyed */
|
||||
}
|
||||
}, [map]);
|
||||
|
||||
/** Add airspace + hotspot sources and layers. */
|
||||
const addSources = useCallback(() => {
|
||||
if (!map || !mountedRef.current) return;
|
||||
if (map.getSource(SOURCE_ID)) return; // already added
|
||||
|
||||
map.addSource(SOURCE_ID, {
|
||||
type: "raster",
|
||||
tiles: ["/api/airspace-tiles?z={z}&x={x}&y={y}"],
|
||||
tileSize: 256,
|
||||
minzoom: AIRSPACE_MIN_ZOOM,
|
||||
maxzoom: AIRSPACE_MAX_ZOOM,
|
||||
attribution:
|
||||
'© <a href="https://www.openaip.net" target="_blank">OpenAIP</a>',
|
||||
});
|
||||
|
||||
// Insert below the first symbol layer so airspace doesn't occlude
|
||||
// map labels, airport markers, or other overlay text.
|
||||
const layers = map.getStyle()?.layers ?? [];
|
||||
let beforeId: string | undefined;
|
||||
for (const layer of layers) {
|
||||
if (layer.type === "symbol") {
|
||||
beforeId = layer.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
map.addLayer(
|
||||
{
|
||||
id: LAYER_ID,
|
||||
type: "raster",
|
||||
source: SOURCE_ID,
|
||||
minzoom: AIRSPACE_MIN_ZOOM,
|
||||
paint: {
|
||||
"raster-opacity": opacityRef.current,
|
||||
"raster-contrast": AIRSPACE_CONTRAST,
|
||||
"raster-saturation": AIRSPACE_SATURATION,
|
||||
"raster-brightness-min": AIRSPACE_BRIGHTNESS_MIN,
|
||||
"raster-fade-duration": 200,
|
||||
},
|
||||
},
|
||||
beforeId,
|
||||
);
|
||||
|
||||
// ── Hotspots layer (thermal/glider activity) ────────────────────
|
||||
if (!map.getSource(HOTSPOT_SOURCE_ID)) {
|
||||
map.addSource(HOTSPOT_SOURCE_ID, {
|
||||
type: "raster",
|
||||
tiles: ["/api/airspace-tiles?layer=hotspots&z={z}&x={x}&y={y}"],
|
||||
tileSize: 256,
|
||||
minzoom: AIRSPACE_MIN_ZOOM,
|
||||
maxzoom: AIRSPACE_MAX_ZOOM,
|
||||
attribution:
|
||||
'© <a href="https://www.openaip.net" target="_blank">OpenAIP</a>',
|
||||
});
|
||||
}
|
||||
|
||||
map.addLayer(
|
||||
{
|
||||
id: HOTSPOT_LAYER_ID,
|
||||
type: "raster",
|
||||
source: HOTSPOT_SOURCE_ID,
|
||||
minzoom: AIRSPACE_MIN_ZOOM,
|
||||
paint: {
|
||||
"raster-opacity": HOTSPOT_OPACITY,
|
||||
"raster-fade-duration": 200,
|
||||
},
|
||||
layout: {
|
||||
visibility: hotspotsRef.current ? "visible" : "none",
|
||||
},
|
||||
},
|
||||
beforeId,
|
||||
);
|
||||
}, [map, removeSources]);
|
||||
|
||||
// ── Add/remove sources based on visibility ─────────────────────────
|
||||
// When hidden → remove sources entirely to free tile ArrayBuffers.
|
||||
// When shown → re-add (browser HTTP cache makes this instant).
|
||||
useEffect(() => {
|
||||
if (!map || !isLoaded) return;
|
||||
|
||||
const onStyleLoad = () => {
|
||||
// After style swap, re-add only if currently visible
|
||||
if (visibleRef.current) addSources();
|
||||
};
|
||||
map.on("style.load", onStyleLoad);
|
||||
|
||||
// Initial add (if visible and style already loaded)
|
||||
if (visible && map.isStyleLoaded()) {
|
||||
addSources();
|
||||
} else if (!visible) {
|
||||
removeSources();
|
||||
}
|
||||
|
||||
return () => {
|
||||
map.off("style.load", onStyleLoad);
|
||||
removeSources();
|
||||
};
|
||||
}, [map, isLoaded, addSources, removeSources, visible]);
|
||||
|
||||
// ── Toggle hotspot layer visibility ────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!map || !isLoaded || !visible) return;
|
||||
try {
|
||||
if (map.getLayer(HOTSPOT_LAYER_ID)) {
|
||||
map.setLayoutProperty(
|
||||
HOTSPOT_LAYER_ID,
|
||||
"visibility",
|
||||
showHotspots ? "visible" : "none",
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* layer may not exist yet after style swap */
|
||||
}
|
||||
}, [map, isLoaded, visible, showHotspots]);
|
||||
|
||||
// ── Dynamic opacity ───────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!map || !isLoaded || !visible) return;
|
||||
try {
|
||||
if (map.getLayer(LAYER_ID)) {
|
||||
map.setPaintProperty(LAYER_ID, "raster-opacity", opacity);
|
||||
}
|
||||
} catch {
|
||||
/* layer may not exist yet */
|
||||
}
|
||||
}, [map, isLoaded, visible, opacity]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@ -39,6 +39,10 @@ export function CameraController({
|
||||
const orbitFrameRef = useRef<number | null>(null);
|
||||
const isInteractingRef = useRef(false);
|
||||
const isFollowingRef = useRef(false);
|
||||
const followFlyToActiveRef = useRef(false);
|
||||
const followFlyToTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
const isFpvActiveRef = useRef(false);
|
||||
const fpvFlightRef = useRef<FlightState | null>(fpvFlight);
|
||||
const fpvPosRef = useRef(fpvPositionRef);
|
||||
@ -75,6 +79,12 @@ export function CameraController({
|
||||
if (followKey === prevFollowRef.current) return;
|
||||
prevFollowRef.current = followKey;
|
||||
|
||||
if (followFlyToTimerRef.current) {
|
||||
clearTimeout(followFlyToTimerRef.current);
|
||||
followFlyToTimerRef.current = null;
|
||||
}
|
||||
followFlyToActiveRef.current = false;
|
||||
|
||||
if (
|
||||
!followFlight ||
|
||||
followFlight.longitude == null ||
|
||||
@ -85,18 +95,25 @@ export function CameraController({
|
||||
}
|
||||
|
||||
isFollowingRef.current = true;
|
||||
followFlyToActiveRef.current = true;
|
||||
const bearing = Number.isFinite(followFlight.trueTrack)
|
||||
? followFlight.trueTrack!
|
||||
: map.getBearing();
|
||||
|
||||
const FOLLOW_FLYTO_MS = 2200;
|
||||
map.flyTo({
|
||||
center: [followFlight.longitude, followFlight.latitude],
|
||||
zoom: FOLLOW_ZOOM,
|
||||
pitch: FOLLOW_PITCH,
|
||||
bearing,
|
||||
duration: 2200,
|
||||
duration: FOLLOW_FLYTO_MS,
|
||||
essential: true,
|
||||
});
|
||||
|
||||
followFlyToTimerRef.current = setTimeout(() => {
|
||||
followFlyToActiveRef.current = false;
|
||||
followFlyToTimerRef.current = null;
|
||||
}, FOLLOW_FLYTO_MS);
|
||||
}, [map, isLoaded, followFlight]);
|
||||
|
||||
// Follow flight continuous update
|
||||
@ -105,6 +122,7 @@ export function CameraController({
|
||||
if (followFlight.longitude == null || followFlight.latitude == null) return;
|
||||
|
||||
if (!isFollowingRef.current) return;
|
||||
if (followFlyToActiveRef.current) return;
|
||||
|
||||
map.easeTo({
|
||||
center: [followFlight.longitude, followFlight.latitude],
|
||||
@ -144,10 +162,18 @@ export function CameraController({
|
||||
const onNorthUp = () => {
|
||||
if (isFpvActiveRef.current) return;
|
||||
if (northUpRafId != null) cancelAnimationFrame(northUpRafId);
|
||||
const startBearing = map.getBearing();
|
||||
if (!map) return;
|
||||
const m = map;
|
||||
|
||||
// Stop any in-progress flyTo/easeTo (e.g. city transition, follow
|
||||
// init) so this RAF setBearing() loop won't fight a parallel
|
||||
// camera animation — which causes visible oscillation.
|
||||
m.stop();
|
||||
|
||||
const startBearing = m.getBearing();
|
||||
const delta = ((0 - startBearing + 540) % 360) - 180;
|
||||
if (Math.abs(delta) < 0.5) {
|
||||
map.setBearing(0);
|
||||
m.setBearing(0);
|
||||
return;
|
||||
}
|
||||
const duration = 650;
|
||||
@ -155,7 +181,7 @@ export function CameraController({
|
||||
function animateBearing() {
|
||||
const t = Math.min((performance.now() - start) / duration, 1);
|
||||
const eased = smoothstep(t);
|
||||
map!.setBearing(startBearing + delta * eased);
|
||||
m.setBearing(startBearing + delta * eased);
|
||||
if (t < 1) {
|
||||
northUpRafId = requestAnimationFrame(animateBearing);
|
||||
} else {
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
import type { FlightState } from "@/lib/opensky";
|
||||
import type { TrailEntry } from "@/hooks/use-trail-history";
|
||||
import { snapLngToReference, unwrapLngPath } from "@/lib/geo";
|
||||
import {
|
||||
snapLngToReference,
|
||||
unwrapLngPath,
|
||||
greatCircleIntermediate,
|
||||
gcDistanceDeg,
|
||||
} from "@/lib/geo";
|
||||
import { roundSharpCorners2D } from "@/lib/trail-smoothing";
|
||||
removeSpikePoints,
|
||||
roundSharpCorners3D,
|
||||
catmullRomSpline3D,
|
||||
} from "@/lib/trail-smoothing";
|
||||
import type { ElevatedPoint, Snapshot } from "./flight-layer-constants";
|
||||
import {
|
||||
STARTUP_TRAIL_POLLS,
|
||||
@ -22,7 +21,8 @@ export function buildStartupFallbackTrail(f: FlightState): [number, number][] {
|
||||
|
||||
const heading =
|
||||
((Number.isFinite(f.trueTrack) ? f.trueTrack! : 0) * Math.PI) / 180;
|
||||
const speed = Number.isFinite(f.velocity) ? f.velocity! : 200;
|
||||
const speed =
|
||||
Number.isFinite(f.velocity) && f.velocity! > 0 ? f.velocity! : 200;
|
||||
const degPerSecond = speed / 111_320;
|
||||
|
||||
const path: [number, number][] = [];
|
||||
@ -117,9 +117,18 @@ export function smoothElevatedPath(
|
||||
): ElevatedPoint[] {
|
||||
if (points.length < 3 || iterations <= 0) return points;
|
||||
|
||||
const effectiveIters =
|
||||
points.length > 4000
|
||||
? 0
|
||||
: points.length > 2000
|
||||
? Math.min(iterations, 1)
|
||||
: points.length > 500
|
||||
? Math.min(iterations, 2)
|
||||
: iterations;
|
||||
|
||||
let current = points;
|
||||
for (let iter = 0; iter < iterations; iter++) {
|
||||
if (current.length < 3) break;
|
||||
for (let iter = 0; iter < effectiveIters; iter++) {
|
||||
if (current.length < 3 || current.length > 6000) break;
|
||||
|
||||
const next: ElevatedPoint[] = [current[0]];
|
||||
for (let i = 0; i < current.length - 1; i++) {
|
||||
@ -140,62 +149,23 @@ export function smoothElevatedPath(
|
||||
current = next;
|
||||
}
|
||||
|
||||
// Absolute output cap — prevents downstream per-point processing
|
||||
// (color mapping, altitude effects) from becoming a bottleneck.
|
||||
const MAX_SMOOTH_OUTPUT = 6000;
|
||||
if (current.length > MAX_SMOOTH_OUTPUT) {
|
||||
const stride = (current.length - 1) / (MAX_SMOOTH_OUTPUT - 1);
|
||||
const capped: ElevatedPoint[] = [];
|
||||
for (let i = 0; i < MAX_SMOOTH_OUTPUT - 1; i++) {
|
||||
capped.push(current[Math.round(i * stride)]);
|
||||
}
|
||||
capped.push(current[current.length - 1]);
|
||||
current = capped;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
export function densifyElevatedPath(
|
||||
points: ElevatedPoint[],
|
||||
subdivisions: number = 2,
|
||||
): ElevatedPoint[] {
|
||||
if (points.length < 2 || subdivisions <= 1) return points;
|
||||
|
||||
// Threshold in degrees above which we use great-circle interpolation
|
||||
// instead of linear. ~0.5° ≈ 55 km at the equator.
|
||||
const GC_THRESHOLD_DEG = 0.4;
|
||||
|
||||
const out: ElevatedPoint[] = [];
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const a = points[i];
|
||||
const b = points[i + 1];
|
||||
out.push(a);
|
||||
|
||||
const dist = gcDistanceDeg(a[0], a[1], b[0], b[1]);
|
||||
const useGC = dist > GC_THRESHOLD_DEG;
|
||||
|
||||
// For longer segments, add extra subdivisions proportional to distance
|
||||
const effectiveSubs = useGC
|
||||
? Math.max(subdivisions, Math.min(16, Math.ceil(dist / 0.3)))
|
||||
: subdivisions;
|
||||
|
||||
for (let j = 1; j < effectiveSubs; j++) {
|
||||
const t = j / effectiveSubs;
|
||||
if (useGC) {
|
||||
const [lng, lat] = greatCircleIntermediate(a[0], a[1], b[0], b[1], t);
|
||||
const alt = a[2] + (b[2] - a[2]) * t;
|
||||
out.push([lng, lat, alt]);
|
||||
} else {
|
||||
out.push([
|
||||
a[0] + (b[0] - a[0]) * t,
|
||||
a[1] + (b[1] - a[1]) * t,
|
||||
a[2] + (b[2] - a[2]) * t,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(points[points.length - 1]);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Numeric & Planar Smoothing ─────────────────────────────────────────
|
||||
|
||||
export function smoothNumericSeries(values: number[]): number[] {
|
||||
if (values.length < 3) return values;
|
||||
const out = [...values];
|
||||
for (let i = 1; i < values.length - 1; i++) {
|
||||
out[i] = values[i - 1] * 0.2 + values[i] * 0.6 + values[i + 1] * 0.2;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// ── Altitude Smoothing ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Multi-pass altitude smoothing with a wider kernel to prevent
|
||||
@ -221,69 +191,6 @@ export function smoothAnimationAltitudes(
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Remove points that create sharp reversals (V-spikes) in a 2D path. */
|
||||
export function removePlanarSpikes(
|
||||
points: [number, number][],
|
||||
): [number, number][] {
|
||||
if (points.length < 3) return points;
|
||||
|
||||
const keep: boolean[] = new Array(points.length).fill(true);
|
||||
const COS_THRESHOLD = -0.5; // reject turns sharper than 120°
|
||||
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
let changed = false;
|
||||
for (let i = 1; i < points.length - 1; i++) {
|
||||
if (!keep[i]) continue;
|
||||
let prevIdx = i - 1;
|
||||
while (prevIdx >= 0 && !keep[prevIdx]) prevIdx--;
|
||||
if (prevIdx < 0) continue;
|
||||
let nextIdx = i + 1;
|
||||
while (nextIdx < points.length && !keep[nextIdx]) nextIdx++;
|
||||
if (nextIdx >= points.length) continue;
|
||||
|
||||
const dx1 = points[i][0] - points[prevIdx][0];
|
||||
const dy1 = points[i][1] - points[prevIdx][1];
|
||||
const dx2 = points[nextIdx][0] - points[i][0];
|
||||
const dy2 = points[nextIdx][1] - points[i][1];
|
||||
const len1 = Math.sqrt(dx1 * dx1 + dy1 * dy1);
|
||||
const len2 = Math.sqrt(dx2 * dx2 + dy2 * dy2);
|
||||
if (len1 < 1e-10 || len2 < 1e-10) continue;
|
||||
|
||||
const cos = (dx1 * dx2 + dy1 * dy2) / (len1 * len2);
|
||||
if (cos < COS_THRESHOLD) {
|
||||
keep[i] = false;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!changed) break;
|
||||
}
|
||||
|
||||
if (keep.every(Boolean)) return points;
|
||||
return points.filter((_, i) => keep[i]);
|
||||
}
|
||||
|
||||
export function smoothPlanarPath(
|
||||
points: [number, number][],
|
||||
): [number, number][] {
|
||||
if (points.length < 3) return points;
|
||||
|
||||
let current: [number, number][] = removePlanarSpikes(points);
|
||||
current = roundSharpCorners2D(current, 15);
|
||||
|
||||
for (let pass = 0; pass < 6; pass++) {
|
||||
const next = [...current];
|
||||
for (let i = 1; i < current.length - 1; i++) {
|
||||
next[i] = [
|
||||
current[i - 1][0] * 0.2 + current[i][0] * 0.6 + current[i + 1][0] * 0.2,
|
||||
current[i - 1][1] * 0.2 + current[i][1] * 0.6 + current[i + 1][1] * 0.2,
|
||||
];
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
// ── Trail Ahead Trimming ───────────────────────────────────────────────
|
||||
|
||||
export function trimPathAheadOfAircraft(
|
||||
@ -297,7 +204,7 @@ export function trimPathAheadOfAircraft(
|
||||
|
||||
let bestIndex = points.length - 2;
|
||||
let bestDistanceSq = Number.POSITIVE_INFINITY;
|
||||
const searchStart = Math.max(0, points.length - 40);
|
||||
const searchStart = Math.max(0, Math.floor(points.length * 0.9));
|
||||
|
||||
for (let i = searchStart; i < points.length - 1; i++) {
|
||||
const a = points[i];
|
||||
@ -323,19 +230,80 @@ export function trimPathAheadOfAircraft(
|
||||
}
|
||||
|
||||
const trimmed = points.slice(0, bestIndex + 1);
|
||||
|
||||
// Smooth transition: insert a quadratic Bézier arc between the trail's
|
||||
// clip point and the aircraft. The control-point lever is scaled by
|
||||
// heading alignment (dot product) so turning aircraft never create loops.
|
||||
const lastPt = trimmed[trimmed.length - 1];
|
||||
if (lastPt && trimmed.length >= 2) {
|
||||
const prevPt = trimmed[trimmed.length - 2];
|
||||
const hdx = lastPt[0] - prevPt[0];
|
||||
const hdy = lastPt[1] - prevPt[1];
|
||||
const hLen = Math.sqrt(hdx * hdx + hdy * hdy);
|
||||
const dx = px - lastPt[0];
|
||||
const dy = py - lastPt[1];
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (dist > 1e-7) {
|
||||
// How aligned is trail heading → aircraft direction? [-1, 1]
|
||||
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 ux = hLen > 1e-10 ? hdx / hLen : 0;
|
||||
const uy = hLen > 1e-10 ? hdy / hLen : 0;
|
||||
const cx = lastPt[0] + ux * dist * lever;
|
||||
const cy = lastPt[1] + uy * dist * lever;
|
||||
|
||||
// Insert 3 Bézier arc points between trail end and aircraft
|
||||
for (let j = 1; j <= 3; j++) {
|
||||
const t = j / 4;
|
||||
const b0 = (1 - t) * (1 - t);
|
||||
const b1 = 2 * (1 - t) * t;
|
||||
const b2 = t * t;
|
||||
trimmed.push([
|
||||
b0 * lastPt[0] + b1 * cx + b2 * px,
|
||||
b0 * lastPt[1] + b1 * cy + b2 * py,
|
||||
lastPt[2] + (aircraft[2] - lastPt[2]) * t,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trimmed.push([px, py, aircraft[2]]);
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// ── Visible Trail Point Builder (extracted from component) ─────────────
|
||||
// ── Trail Base Path Cache ──────────────────────────────────────────────
|
||||
|
||||
export function buildVisibleTrailPoints(
|
||||
/**
|
||||
* Generates a cache key for trail base path computation.
|
||||
* The base path only changes when trail data grows, trail distance changes,
|
||||
* or fullHistory mode toggles. Keyed on the last point so appends invalidate.
|
||||
*/
|
||||
export function trailBasePathCacheKey(
|
||||
trail: TrailEntry,
|
||||
trailDistance: number,
|
||||
): string {
|
||||
const n = trail.path.length;
|
||||
const last = n > 0 ? trail.path[n - 1] : null;
|
||||
const lastAlt =
|
||||
trail.altitudes.length > 0
|
||||
? trail.altitudes[trail.altitudes.length - 1]
|
||||
: null;
|
||||
return `${n}|${trailDistance}|${trail.fullHistory ? 1 : 0}|${last?.[0]}|${last?.[1]}|${lastAlt}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the expensive base path (smoothing + densification) for a trail.
|
||||
* This result is cacheable across animation frames — it only depends on
|
||||
* trail.path, trail.altitudes, trailDistance, and fullHistory.
|
||||
* The per-frame head attachment (trimPathAheadOfAircraft) is NOT included.
|
||||
*/
|
||||
export function buildTrailBasePath(
|
||||
trail: TrailEntry,
|
||||
animFlight: FlightState | undefined,
|
||||
trailDistance: number,
|
||||
smoothingIterations: number,
|
||||
denseSubdivisions: number,
|
||||
): ElevatedPoint[] {
|
||||
const isFullHistory = trail.fullHistory === true;
|
||||
const historyPoints = isFullHistory
|
||||
@ -389,26 +357,97 @@ export function buildVisibleTrailPoints(
|
||||
pathSlice = trimmed.path;
|
||||
altitudeSlice = trimmed.altitudes;
|
||||
|
||||
const smoothPathSlice = isFullHistory
|
||||
? pathSlice
|
||||
: smoothPlanarPath(pathSlice);
|
||||
if (isFullHistory) {
|
||||
// The historical portion is already smooth from the Catmull-Rom
|
||||
// spline in trail-stitching.ts, but the stitched live-tail portion
|
||||
// is raw GPS. Apply roundSharpCorners3D to catch remaining tight
|
||||
// turns (approach patterns, live-tail heading kinks) without
|
||||
// re-running the full kernel pre-smoothing or re-spline.
|
||||
const rawAltitudes = altitudeSlice.map((a) => a ?? trail.baroAltitude ?? 0);
|
||||
const altitudeMeters = smoothAnimationAltitudes(rawAltitudes, 3);
|
||||
const elevated = pathSlice.map(
|
||||
(p, i) =>
|
||||
[
|
||||
p[0],
|
||||
p[1],
|
||||
Math.max(0, altitudeMeters[i] ?? trail.baroAltitude ?? 0),
|
||||
] as ElevatedPoint,
|
||||
);
|
||||
return elevated.length >= 3 ? roundSharpCorners3D(elevated, 15) : elevated;
|
||||
}
|
||||
|
||||
const rawAltitudes = altitudeSlice.map(
|
||||
(a) => a ?? trail.baroAltitude ?? animFlight?.baroAltitude ?? 0,
|
||||
// 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);
|
||||
|
||||
// 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.
|
||||
let smoothedPath = spikeResult.path;
|
||||
if (smoothedPath.length >= 3) {
|
||||
for (let pass = 0; pass < 5; pass++) {
|
||||
const next: [number, number][] = [smoothedPath[0]];
|
||||
for (let i = 1; i < smoothedPath.length - 1; i++) {
|
||||
next.push([
|
||||
smoothedPath[i - 1][0] * 0.25 +
|
||||
smoothedPath[i][0] * 0.5 +
|
||||
smoothedPath[i + 1][0] * 0.25,
|
||||
smoothedPath[i - 1][1] * 0.25 +
|
||||
smoothedPath[i][1] * 0.5 +
|
||||
smoothedPath[i + 1][1] * 0.25,
|
||||
]);
|
||||
}
|
||||
next.push(smoothedPath[smoothedPath.length - 1]);
|
||||
smoothedPath = next;
|
||||
}
|
||||
}
|
||||
|
||||
const rawAltitudes = spikeResult.altitudes.map(
|
||||
(a) => a ?? trail.baroAltitude ?? 0,
|
||||
);
|
||||
const altitudeMeters = isFullHistory
|
||||
? rawAltitudes
|
||||
: smoothAnimationAltitudes(rawAltitudes, 3);
|
||||
const altitudeMeters = smoothAnimationAltitudes(rawAltitudes, 3);
|
||||
|
||||
const basePath = smoothPathSlice.map((p, i) => [
|
||||
const elevated: ElevatedPoint[] = smoothedPath.map((p, i) => [
|
||||
p[0],
|
||||
p[1],
|
||||
Math.max(0, altitudeMeters[i] ?? trail.baroAltitude ?? 0),
|
||||
]) as ElevatedPoint[];
|
||||
const denseBasePath = densifyElevatedPath(
|
||||
basePath,
|
||||
isFullHistory ? 1 : denseSubdivisions,
|
||||
);
|
||||
]);
|
||||
|
||||
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);
|
||||
}
|
||||
return elevated;
|
||||
}
|
||||
|
||||
// ── Visible Trail Point Builder (extracted from component) ─────────────
|
||||
|
||||
/**
|
||||
* Builds the final visible trail points for rendering.
|
||||
* When cachedBasePath is provided, skips the expensive smoothing/densification
|
||||
* and only performs the cheap per-frame head attachment + final smoothing.
|
||||
*/
|
||||
export function buildVisibleTrailPoints(
|
||||
trail: TrailEntry,
|
||||
animFlight: FlightState | undefined,
|
||||
trailDistance: number,
|
||||
smoothingIterations: number,
|
||||
cachedBasePath?: ElevatedPoint[],
|
||||
): ElevatedPoint[] {
|
||||
const denseBasePath =
|
||||
cachedBasePath ?? buildTrailBasePath(trail, trailDistance);
|
||||
|
||||
// Skip Chaikin subdivision — the Catmull-Rom spline, roundSharpCorners3D,
|
||||
// and Bézier head-arc already produce smooth, dense output. Running
|
||||
// Chaikin on top would bloat ~200 pts → ~1600 per trail per frame,
|
||||
// causing severe lag during orbit with 100+ aircraft.
|
||||
const skipChaikin = true;
|
||||
|
||||
if (
|
||||
animFlight &&
|
||||
@ -425,20 +464,17 @@ export function buildVisibleTrailPoints(
|
||||
]);
|
||||
|
||||
const smoothed =
|
||||
clipped.length < 4
|
||||
skipChaikin || clipped.length < 4
|
||||
? clipped
|
||||
: smoothElevatedPath(clipped, isFullHistory ? 1 : smoothingIterations);
|
||||
: smoothElevatedPath(clipped, smoothingIterations);
|
||||
|
||||
return smoothed.map((p) => [p[0], p[1], Math.max(0, p[2])]);
|
||||
}
|
||||
|
||||
const smoothed =
|
||||
denseBasePath.length < 4
|
||||
skipChaikin || denseBasePath.length < 4
|
||||
? denseBasePath
|
||||
: smoothElevatedPath(
|
||||
denseBasePath,
|
||||
isFullHistory ? 1 : smoothingIterations,
|
||||
);
|
||||
: smoothElevatedPath(denseBasePath, smoothingIterations);
|
||||
|
||||
return smoothed.map((p) => [p[0], p[1], Math.max(0, p[2])]);
|
||||
}
|
||||
@ -450,8 +486,10 @@ export function computePitchByIcao(
|
||||
trailByIcao: Map<string, TrailEntry>,
|
||||
currSnapshots: Map<string, Snapshot>,
|
||||
prevSnapshots: Map<string, Snapshot>,
|
||||
out?: Map<string, number>,
|
||||
): Map<string, number> {
|
||||
const pitchByIcao = new Map<string, number>();
|
||||
const pitchByIcao = out ?? new Map<string, number>();
|
||||
pitchByIcao.clear();
|
||||
|
||||
for (const f of interpolated) {
|
||||
const curr = currSnapshots.get(f.icao24);
|
||||
@ -493,7 +531,8 @@ export function computePitchByIcao(
|
||||
})()
|
||||
: 0;
|
||||
|
||||
const speed = Number.isFinite(f.velocity) ? f.velocity! : 0;
|
||||
const speed =
|
||||
Number.isFinite(f.velocity) && f.velocity! > 0 ? f.velocity! : 0;
|
||||
const verticalRate = Number.isFinite(f.verticalRate) ? f.verticalRate! : 0;
|
||||
const kinematicPitch =
|
||||
speed > 0 ? (-Math.atan2(verticalRate, speed) * 180) / Math.PI : 0;
|
||||
@ -508,6 +547,46 @@ export function computePitchByIcao(
|
||||
return pitchByIcao;
|
||||
}
|
||||
|
||||
// ── Bank (Roll) Calculation ────────────────────────────────────────────
|
||||
|
||||
const MAX_BANK_DEG = 25;
|
||||
|
||||
/**
|
||||
* Compute a turn-coupled bank angle for each aircraft.
|
||||
* The bank follows a sine-bell curve over the animation cycle so it
|
||||
* peaks mid-turn and eases to zero at the start/end — mimicking how
|
||||
* real aircraft roll into and out of turns.
|
||||
*/
|
||||
export function computeBankByIcao(
|
||||
interpolated: FlightState[],
|
||||
prevSnapshots: Map<string, Snapshot>,
|
||||
currSnapshots: Map<string, Snapshot>,
|
||||
tAngle: number,
|
||||
out?: Map<string, number>,
|
||||
): Map<string, number> {
|
||||
const bankByIcao = out ?? new Map<string, number>();
|
||||
bankByIcao.clear();
|
||||
for (const f of interpolated) {
|
||||
const prev = prevSnapshots.get(f.icao24);
|
||||
const curr = currSnapshots.get(f.icao24);
|
||||
if (!prev || !curr) continue;
|
||||
|
||||
// Shortest-path heading delta: positive = turning right
|
||||
const headingDelta = ((curr.track - prev.track + 540) % 360) - 180;
|
||||
|
||||
// Bank proportional to turn magnitude, clamped
|
||||
const bankTarget = Math.max(
|
||||
-MAX_BANK_DEG,
|
||||
Math.min(MAX_BANK_DEG, headingDelta * 0.8),
|
||||
);
|
||||
|
||||
// Sine bell curve: 0 → 1 → 0 over the animation cycle
|
||||
const bankEase = Math.sin(tAngle * Math.PI);
|
||||
bankByIcao.set(f.icao24, bankTarget * bankEase);
|
||||
}
|
||||
return bankByIcao;
|
||||
}
|
||||
|
||||
// ── Flight Interpolation (extracted from RAF loop) ─────────────────────
|
||||
|
||||
export function computeInterpolatedFlights(
|
||||
@ -554,17 +633,97 @@ export function computeInterpolatedFlights(
|
||||
}
|
||||
|
||||
const heading = (curr.track * Math.PI) / 180;
|
||||
const speed = Number.isFinite(f.velocity) ? f.velocity! : 200;
|
||||
const speed =
|
||||
Number.isFinite(f.velocity) && f.velocity! > 0 ? f.velocity! : 200;
|
||||
const extraSec = ((rawT - 1) * animDuration) / 1000;
|
||||
const extraDeg = Math.min((speed * extraSec) / 111_320, 0.03);
|
||||
const moveDx = Math.sin(heading) * extraDeg;
|
||||
const moveDy = Math.cos(heading) * extraDeg;
|
||||
// Continue climb/descent using vertical rate, capped at ±500m
|
||||
const vr = Number.isFinite(f.verticalRate) ? f.verticalRate! : 0;
|
||||
const extraAlt = Math.max(-500, Math.min(500, vr * extraSec));
|
||||
return {
|
||||
...f,
|
||||
longitude: curr.lng + moveDx,
|
||||
latitude: curr.lat + moveDy,
|
||||
baroAltitude: curr.alt,
|
||||
baroAltitude: curr.alt + extraAlt,
|
||||
trueTrack: trackFromDelta(moveDx, moveDy, curr.track),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* In-place position update for an existing interpolated array.
|
||||
*
|
||||
* Called on animation frames between data polls. Instead of creating new
|
||||
* FlightState objects with `{...f}`, this mutates the existing objects'
|
||||
* position fields directly. Combined with a stable array reference this
|
||||
* eliminates ~18K object allocations/sec and ~360K property copies/sec.
|
||||
*
|
||||
* `rawFlights` must be the SAME array that was used to create `out` via
|
||||
* `computeInterpolatedFlights` (i.e. `flightsRef.current` hasn't changed).
|
||||
* Elements where `out[i] === rawFlights[i]` are raw references (no
|
||||
* interpolation was needed) and are left untouched.
|
||||
*/
|
||||
export function updateInterpolatedInPlace(
|
||||
out: FlightState[],
|
||||
rawFlights: FlightState[],
|
||||
prevSnapshots: Map<string, Snapshot>,
|
||||
currSnapshots: Map<string, Snapshot>,
|
||||
tPos: number,
|
||||
tAngle: number,
|
||||
rawT: number,
|
||||
animDuration: number,
|
||||
): void {
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
const o = out[i];
|
||||
const f = rawFlights[i];
|
||||
if (!o || !f) continue;
|
||||
|
||||
// Skip raw references — these flights had no position or snapshot,
|
||||
// so computeInterpolatedFlights returned the raw object directly.
|
||||
// Mutating them would corrupt the source data.
|
||||
if (o === f) continue;
|
||||
|
||||
const curr = currSnapshots.get(f.icao24);
|
||||
if (!curr) continue;
|
||||
|
||||
const prev = prevSnapshots.get(f.icao24);
|
||||
if (!prev) {
|
||||
o.longitude = curr.lng;
|
||||
o.latitude = curr.lat;
|
||||
o.baroAltitude = curr.alt;
|
||||
o.trueTrack = Number.isFinite(f.trueTrack) ? f.trueTrack! : curr.track;
|
||||
continue;
|
||||
}
|
||||
|
||||
const dx = curr.lng - prev.lng;
|
||||
const dy = curr.lat - prev.lat;
|
||||
if (dx * dx + dy * dy > TELEPORT_THRESHOLD * TELEPORT_THRESHOLD) continue;
|
||||
|
||||
if (rawT <= 1) {
|
||||
o.longitude = prev.lng + dx * tPos;
|
||||
o.latitude = prev.lat + dy * tPos;
|
||||
o.baroAltitude = prev.alt + (curr.alt - prev.alt) * tPos;
|
||||
o.trueTrack = trackFromDelta(
|
||||
dx,
|
||||
dy,
|
||||
lerpAngle(prev.track, curr.track, tAngle),
|
||||
);
|
||||
} else {
|
||||
const heading = (curr.track * Math.PI) / 180;
|
||||
const speed =
|
||||
Number.isFinite(f.velocity) && f.velocity! > 0 ? f.velocity! : 200;
|
||||
const extraSec = ((rawT - 1) * animDuration) / 1000;
|
||||
const extraDeg = Math.min((speed * extraSec) / 111_320, 0.03);
|
||||
const moveDx = Math.sin(heading) * extraDeg;
|
||||
const moveDy = Math.cos(heading) * extraDeg;
|
||||
const vr = Number.isFinite(f.verticalRate) ? f.verticalRate! : 0;
|
||||
const extraAlt = Math.max(-500, Math.min(500, vr * extraSec));
|
||||
o.longitude = curr.lng + moveDx;
|
||||
o.latitude = curr.lat + moveDy;
|
||||
o.baroAltitude = curr.alt + extraAlt;
|
||||
o.trueTrack = trackFromDelta(moveDx, moveDy, curr.track);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,6 +17,8 @@ import {
|
||||
import {
|
||||
buildStartupFallbackTrail,
|
||||
buildVisibleTrailPoints,
|
||||
buildTrailBasePath,
|
||||
trailBasePathCacheKey,
|
||||
smoothStep,
|
||||
} from "./flight-animation-helpers";
|
||||
|
||||
@ -79,14 +81,36 @@ export interface TrailLayerParams {
|
||||
interpolated: FlightState[];
|
||||
interpolatedMap: Map<string, FlightState>;
|
||||
currentTrails: TrailEntry[];
|
||||
/** Pre-built trail-by-icao24 Map — passed from parent to avoid per-frame allocation */
|
||||
trailMap: Map<string, TrailEntry>;
|
||||
trailDistance: number;
|
||||
trailThickness: number;
|
||||
altColors: boolean;
|
||||
defaultColor: [number, number, number, number];
|
||||
elapsed: number;
|
||||
/** Visual frame counter — throttled counter that only increments on rendered frames */
|
||||
visualFrame: number;
|
||||
globeFade: number;
|
||||
currentZoom: number;
|
||||
/** Pre-computed zoom-dependent elevation scale — avoids recomputing per accessor call */
|
||||
elevScale: number;
|
||||
visible?: boolean;
|
||||
/** Persistent cache for expensive base path computations across frames */
|
||||
trailBasePathCache?: Map<string, { key: string; basePath: ElevatedPoint[] }>;
|
||||
/** Persistent cache for slope-limited trail paths across frames */
|
||||
trailPathCache?: Map<
|
||||
string,
|
||||
{ key: string; result: [number, number, number][] }
|
||||
>;
|
||||
/** Persistent cache for trail colors across frames */
|
||||
trailColorCache?: Map<
|
||||
string,
|
||||
{ key: string; result: [number, number, number, number][] }
|
||||
>;
|
||||
/** Reusable containers — cleared and reused each frame to avoid per-frame allocations */
|
||||
handledIdsSet?: Set<string>;
|
||||
visibleTrailCacheMap?: Map<string, ElevatedPoint[]>;
|
||||
activeIcaosSet?: Set<string>;
|
||||
}
|
||||
|
||||
export function buildTrailLayers(params: TrailLayerParams) {
|
||||
@ -94,36 +118,64 @@ export function buildTrailLayers(params: TrailLayerParams) {
|
||||
interpolated,
|
||||
interpolatedMap,
|
||||
currentTrails,
|
||||
trailMap,
|
||||
trailDistance,
|
||||
trailThickness,
|
||||
altColors,
|
||||
defaultColor,
|
||||
elapsed,
|
||||
visualFrame,
|
||||
globeFade,
|
||||
currentZoom,
|
||||
elevScale,
|
||||
visible = true,
|
||||
trailBasePathCache,
|
||||
trailPathCache,
|
||||
trailColorCache,
|
||||
handledIdsSet,
|
||||
visibleTrailCacheMap,
|
||||
activeIcaosSet,
|
||||
} = params;
|
||||
|
||||
const trailMap = new Map(currentTrails.map((t) => [t.icao24, t]));
|
||||
const handledIds = new Set<string>();
|
||||
const handledIds = handledIdsSet ?? new Set<string>();
|
||||
handledIds.clear();
|
||||
const trailData: TrailEntry[] = [];
|
||||
const denseSubdivisions = 2;
|
||||
const smoothingIters =
|
||||
interpolated.length > 220 ? 2 : TRAIL_SMOOTHING_ITERATIONS;
|
||||
|
||||
const visibleTrailCache = new Map<string, ElevatedPoint[]>();
|
||||
const visibleTrailCache =
|
||||
visibleTrailCacheMap ?? new Map<string, ElevatedPoint[]>();
|
||||
visibleTrailCache.clear();
|
||||
const activeIcaos = trailBasePathCache
|
||||
? (activeIcaosSet ?? new Set<string>())
|
||||
: null;
|
||||
activeIcaos?.clear();
|
||||
|
||||
const getVisibleTrailPoints = (
|
||||
trail: TrailEntry,
|
||||
animFlight: FlightState | undefined,
|
||||
): ElevatedPoint[] => {
|
||||
const cached = visibleTrailCache.get(trail.icao24);
|
||||
if (cached) return cached;
|
||||
|
||||
// Try to use cached base path (expensive smoothing/densification)
|
||||
let basePath: ElevatedPoint[] | undefined;
|
||||
if (trailBasePathCache) {
|
||||
const key = trailBasePathCacheKey(trail, trailDistance);
|
||||
const entry = trailBasePathCache.get(trail.icao24);
|
||||
if (entry && entry.key === key) {
|
||||
basePath = entry.basePath;
|
||||
} else {
|
||||
basePath = buildTrailBasePath(trail, trailDistance);
|
||||
trailBasePathCache.set(trail.icao24, { key, basePath });
|
||||
}
|
||||
activeIcaos?.add(trail.icao24);
|
||||
}
|
||||
|
||||
const computed = buildVisibleTrailPoints(
|
||||
trail,
|
||||
animFlight,
|
||||
trailDistance,
|
||||
smoothingIters,
|
||||
denseSubdivisions,
|
||||
basePath,
|
||||
);
|
||||
visibleTrailCache.set(trail.icao24, computed);
|
||||
return computed;
|
||||
@ -133,6 +185,7 @@ export function buildTrailLayers(params: TrailLayerParams) {
|
||||
if (f.longitude == null || f.latitude == null) continue;
|
||||
const existing = trailMap.get(f.icao24);
|
||||
handledIds.add(f.icao24);
|
||||
activeIcaos?.add(f.icao24);
|
||||
if (existing && existing.path.length >= 2) {
|
||||
trailData.push(existing);
|
||||
continue;
|
||||
@ -144,34 +197,63 @@ export function buildTrailLayers(params: TrailLayerParams) {
|
||||
altitudes: startupPath.map(
|
||||
() => existing?.baroAltitude ?? f.baroAltitude,
|
||||
),
|
||||
timestamps: startupPath.map(() => 0),
|
||||
baroAltitude: existing?.baroAltitude ?? f.baroAltitude,
|
||||
});
|
||||
}
|
||||
|
||||
for (const d of currentTrails) {
|
||||
if (!handledIds.has(d.icao24)) trailData.push(d);
|
||||
if (!handledIds.has(d.icao24)) {
|
||||
trailData.push(d);
|
||||
activeIcaos?.add(d.icao24);
|
||||
}
|
||||
}
|
||||
|
||||
// Sweep stale entries from persistent caches
|
||||
if (trailBasePathCache && activeIcaos) {
|
||||
for (const icao of trailBasePathCache.keys()) {
|
||||
if (!activeIcaos.has(icao)) {
|
||||
trailBasePathCache.delete(icao);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (trailPathCache && activeIcaos) {
|
||||
for (const icao of trailPathCache.keys()) {
|
||||
if (!activeIcaos.has(icao)) trailPathCache.delete(icao);
|
||||
}
|
||||
}
|
||||
if (trailColorCache && activeIcaos) {
|
||||
for (const icao of trailColorCache.keys()) {
|
||||
if (!activeIcaos.has(icao)) trailColorCache.delete(icao);
|
||||
}
|
||||
}
|
||||
|
||||
return new PathLayer<TrailEntry>({
|
||||
id: "flight-trails",
|
||||
pickable: false,
|
||||
visible,
|
||||
data: trailData,
|
||||
opacity: globeFade,
|
||||
updateTriggers: {
|
||||
getPath: [elapsed, trailDistance],
|
||||
getColor: [elapsed, altColors, trailDistance],
|
||||
getPath: [visualFrame, trailDistance, elevScale],
|
||||
getColor: [visualFrame, altColors, trailDistance],
|
||||
},
|
||||
getPath: (d) => {
|
||||
const animFlight = interpolatedMap.get(d.icao24);
|
||||
// Scale elevation exaggeration by zoom:
|
||||
// At globe zoom (<5) altitude spikes look absurd, so reduce.
|
||||
// At city zoom (>8) full exaggeration is needed for visual depth.
|
||||
const elevScale =
|
||||
currentZoom < 5
|
||||
? 0.15 + (currentZoom / 5) * 0.35
|
||||
: currentZoom < 8
|
||||
? 0.5 + ((currentZoom - 5) / 3) * 0.5
|
||||
: 1.0;
|
||||
|
||||
// Cache key: trail point count + rounded head position (~11m grid)
|
||||
// + elevScale. Gives ~6 frame cache hits between invalidations at
|
||||
// typical aircraft speed, reducing slope-limit computation from
|
||||
// 60fps to ~10fps per trail.
|
||||
const headLng = animFlight?.longitude?.toFixed(4) ?? "";
|
||||
const headLat = animFlight?.latitude?.toFixed(4) ?? "";
|
||||
const pathKey = `${d.path.length}_${headLng}_${headLat}_${elevScale.toFixed(3)}_${trailDistance}`;
|
||||
|
||||
if (trailPathCache) {
|
||||
const cached = trailPathCache.get(d.icao24);
|
||||
if (cached && cached.key === pathKey) return cached.result;
|
||||
}
|
||||
|
||||
const raw = getVisibleTrailPoints(d, animFlight).map(
|
||||
(p) =>
|
||||
[
|
||||
@ -184,15 +266,23 @@ export function buildTrailLayers(params: TrailLayerParams) {
|
||||
),
|
||||
] as [number, number, number],
|
||||
);
|
||||
return limitTrailSlope(raw);
|
||||
const result = limitTrailSlope(raw);
|
||||
trailPathCache?.set(d.icao24, { key: pathKey, result });
|
||||
return result;
|
||||
},
|
||||
getColor: (d) => {
|
||||
const animFlight = interpolatedMap.get(d.icao24);
|
||||
const visiblePoints = getVisibleTrailPoints(d, animFlight);
|
||||
const len = visiblePoints.length;
|
||||
const isFullHist = d.fullHistory === true;
|
||||
|
||||
return visiblePoints.map((point, i) => {
|
||||
const colorKey = `${len}_${altColors}_${d.fullHistory ?? false}_${d.baroAltitude != null ? Math.round(d.baroAltitude / 200) : "n"}`;
|
||||
if (trailColorCache) {
|
||||
const cached = trailColorCache.get(d.icao24);
|
||||
if (cached && cached.key === colorKey) return cached.result;
|
||||
}
|
||||
|
||||
const isFullHist = d.fullHistory === true;
|
||||
const result = visiblePoints.map((point, i) => {
|
||||
const tVal = len > 1 ? i / (len - 1) : 1;
|
||||
const fade = isFullHist
|
||||
? 0.35 + 0.65 * Math.pow(tVal, 1.1)
|
||||
@ -203,6 +293,8 @@ export function buildTrailLayers(params: TrailLayerParams) {
|
||||
: Math.round(60 + fade * 160);
|
||||
return [base[0], base[1], base[2], alpha];
|
||||
}) as [number, number, number, number][];
|
||||
trailColorCache?.set(d.icao24, { key: colorKey, result });
|
||||
return result;
|
||||
},
|
||||
getWidth: trailThickness,
|
||||
widthUnits: "pixels",
|
||||
@ -222,9 +314,12 @@ export interface SelectionPulseParams {
|
||||
selectedId: string | null;
|
||||
prevId: string | null;
|
||||
interpolated: FlightState[];
|
||||
interpolatedMap: Map<string, FlightState>;
|
||||
elapsed: number;
|
||||
globeFade: number;
|
||||
currentZoom: number;
|
||||
/** Pre-computed zoom-dependent elevation scale */
|
||||
elevScale: number;
|
||||
haloUrl: string;
|
||||
ringUrl: string;
|
||||
layersVisible?: boolean;
|
||||
@ -245,23 +340,15 @@ export function buildSelectionPulseLayers(
|
||||
selectionChangeTime,
|
||||
selectedId,
|
||||
prevId,
|
||||
interpolated,
|
||||
interpolatedMap,
|
||||
elapsed,
|
||||
globeFade,
|
||||
currentZoom,
|
||||
elevScale,
|
||||
haloUrl,
|
||||
ringUrl,
|
||||
layersVisible = true,
|
||||
} = params;
|
||||
|
||||
// Zoom-dependent elevation scale (matches trail/aircraft scaling)
|
||||
const elevScale =
|
||||
currentZoom < 5
|
||||
? 0.15 + (currentZoom / 5) * 0.35
|
||||
: currentZoom < 8
|
||||
? 0.5 + ((currentZoom - 5) / 3) * 0.5
|
||||
: 1.0;
|
||||
|
||||
const layers: IconLayer[] = [];
|
||||
const fadeElapsed = performance.now() - selectionChangeTime;
|
||||
const fadeT = Math.min(fadeElapsed / SELECTION_FADE_MS, 1);
|
||||
@ -281,32 +368,32 @@ export function buildSelectionPulseLayers(
|
||||
const targetId = isSelected ? selectedId : prevId;
|
||||
const op = isSelected ? fadeIn : fadeOut;
|
||||
|
||||
const flight = targetId
|
||||
? interpolated.find((f) => f.icao24 === targetId)
|
||||
: undefined;
|
||||
const flight = targetId ? interpolatedMap.get(targetId) : undefined;
|
||||
const hasPosition =
|
||||
flight && flight.longitude != null && flight.latitude != null;
|
||||
|
||||
const active = layersVisible && !!targetId && hasPosition && op > 0.01;
|
||||
const pos: [number, number, number] = hasPosition
|
||||
? [
|
||||
flight!.longitude!,
|
||||
flight!.latitude!,
|
||||
altitudeToElevation(flight!.baroAltitude) * elevScale,
|
||||
]
|
||||
: [0, 0, 0];
|
||||
const elevation =
|
||||
flight && flight.baroAltitude != null
|
||||
? altitudeToElevation(flight.baroAltitude) * elevScale
|
||||
: 0;
|
||||
const pos: [number, number, number] =
|
||||
flight && flight.longitude != null && flight.latitude != null
|
||||
? [flight.longitude, flight.latitude, elevation]
|
||||
: [0, 0, 0];
|
||||
const data = active ? [{ position: pos }] : EMPTY_PULSE_DATA;
|
||||
|
||||
const breathT = (elapsed % PULSE_PERIOD_MS) / PULSE_PERIOD_MS;
|
||||
const breath = Math.sin(breathT * Math.PI * 2);
|
||||
const softBreath = smoothStep(smoothStep((breath + 1) / 2)) * 2 - 1;
|
||||
|
||||
const haloSize = 75 + 8 * softBreath;
|
||||
const haloAlpha = Math.round((18 + 8 * softBreath) * op);
|
||||
const haloSize = 90 + 10 * softBreath;
|
||||
const haloAlpha = Math.round((22 + 10 * softBreath) * op);
|
||||
|
||||
layers.push(
|
||||
new IconLayer({
|
||||
id: `${prefix}-halo`,
|
||||
pickable: false,
|
||||
visible: active && haloAlpha > 0,
|
||||
data,
|
||||
opacity: globeFade,
|
||||
@ -326,13 +413,14 @@ export function buildSelectionPulseLayers(
|
||||
ringOffsets.forEach((offset, i) => {
|
||||
const t = ((elapsed + offset) % RING_PERIOD_MS) / RING_PERIOD_MS;
|
||||
const eased = 1 - (1 - t) ** 5;
|
||||
const ringSize = 30 + 60 * eased;
|
||||
const ringSize = 35 + 70 * eased;
|
||||
const fade = 1 - t;
|
||||
const ringAlpha = Math.round(70 * fade * fade * fade * fade * op);
|
||||
const ringAlpha = Math.round(80 * fade * fade * fade * fade * op);
|
||||
|
||||
layers.push(
|
||||
new IconLayer({
|
||||
id: `${prefix}-ring-${i}`,
|
||||
pickable: false,
|
||||
visible: active && ringAlpha >= 2,
|
||||
data,
|
||||
opacity: globeFade,
|
||||
|
||||
@ -24,10 +24,15 @@ export const TRAIL_BELOW_AIRCRAFT_METERS = 40;
|
||||
export const STARTUP_TRAIL_POLLS = 3;
|
||||
export const STARTUP_TRAIL_STEP_SEC = 12;
|
||||
export const TRACK_DAMPING = 0.18;
|
||||
/** EMA alpha for MLAT position smoothing. MLAT accuracy (~100m) is 10×
|
||||
* worse than ADS-B (~10m), so we blend toward the previous position to
|
||||
* suppress jitter. 0.65 retains responsiveness while cutting noise. */
|
||||
export const MLAT_POSITION_ALPHA = 0.65;
|
||||
export const TRAIL_SMOOTHING_ITERATIONS = 3;
|
||||
export const AIRCRAFT_SCENEGRAPH_URL = "/models/airplane.glb";
|
||||
export const AIRCRAFT_PX_PER_UNIT = 0.3;
|
||||
export const BASE_AIRCRAFT_SIZE = 25;
|
||||
export const BASE_AIRCRAFT_SIZE = 22;
|
||||
export const AIRCRAFT_MIN_PIXELS = 0.8;
|
||||
export const AIRCRAFT_MAX_PIXELS = 18;
|
||||
export const AIRCRAFT_PICK_RADIUS_PX = 14;
|
||||
export const SELECTION_FADE_MS = 600;
|
||||
|
||||
@ -37,6 +42,12 @@ export const GLOBE_FADE_ZOOM_FLOOR = GLOBE_SWITCH_ZOOM - 0.05;
|
||||
export const GLOBE_FADE_ZOOM_CEIL = GLOBE_SWITCH_ZOOM + 0.05;
|
||||
export const GLOBE_NATIVE_ZOOM_CEIL = GLOBE_SWITCH_ZOOM;
|
||||
|
||||
// LOD: switch between 3D ScenegraphLayers and 2D IconLayer.
|
||||
// Uses hysteresis to avoid flickering when hovering near the boundary.
|
||||
// Zoom in past LOD_3D_ZOOM_IN → 3D models; zoom out past LOD_3D_ZOOM_OUT → 2D icons.
|
||||
export const LOD_3D_ZOOM_IN = 6.0;
|
||||
export const LOD_3D_ZOOM_OUT = 5.0;
|
||||
|
||||
// GeoJSON globe dot layer timing
|
||||
export const GEOJSON_THROTTLE_MS = 1500;
|
||||
export const GEOJSON_DEBOUNCE_MS = 200;
|
||||
|
||||
@ -4,25 +4,28 @@ import { useEffect, useRef, useCallback } from "react";
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { MapboxOverlay } from "@deck.gl/mapbox";
|
||||
import { IconLayer } from "@deck.gl/layers";
|
||||
import { ScenegraphLayer } from "@deck.gl/mesh-layers";
|
||||
import { useMap } from "./map";
|
||||
import { altitudeToColor, altitudeToElevation } from "@/lib/flight-utils";
|
||||
import type { FlightState } from "@/lib/opensky";
|
||||
import type { TrailEntry } from "@/hooks/use-trail-history";
|
||||
import { type PickingInfo, MapView } from "@deck.gl/core";
|
||||
|
||||
import type { DeckGLOverlay, Snapshot } from "./flight-layer-constants";
|
||||
import type {
|
||||
DeckGLOverlay,
|
||||
ElevatedPoint,
|
||||
Snapshot,
|
||||
} from "./flight-layer-constants";
|
||||
import {
|
||||
DEFAULT_ANIM_DURATION_MS,
|
||||
MIN_ANIM_DURATION_MS,
|
||||
MAX_ANIM_DURATION_MS,
|
||||
TELEPORT_THRESHOLD,
|
||||
TRACK_DAMPING,
|
||||
AIRCRAFT_SCENEGRAPH_URL,
|
||||
AIRCRAFT_PX_PER_UNIT,
|
||||
BASE_AIRCRAFT_SIZE,
|
||||
MLAT_POSITION_ALPHA,
|
||||
AIRCRAFT_PICK_RADIUS_PX,
|
||||
GLOBE_FADE_ZOOM_FLOOR,
|
||||
GLOBE_FADE_ZOOM_CEIL,
|
||||
LOD_3D_ZOOM_IN,
|
||||
LOD_3D_ZOOM_OUT,
|
||||
type FlightLayerProps,
|
||||
} from "./flight-layer-constants";
|
||||
|
||||
@ -39,11 +42,16 @@ import {
|
||||
lerpAngle,
|
||||
smoothStep,
|
||||
computePitchByIcao,
|
||||
computeBankByIcao,
|
||||
computeInterpolatedFlights,
|
||||
updateInterpolatedInPlace,
|
||||
} from "./flight-animation-helpers";
|
||||
|
||||
import { buildTrailLayers } from "./flight-layer-builders";
|
||||
import { buildSelectionPulseLayers } from "./flight-layer-builders";
|
||||
import { buildAircraftModelLayers } from "./aircraft-model-layers";
|
||||
import { preloadAllModels } from "./aircraft-model-mapping";
|
||||
import { altitudeToColor, altitudeToElevation } from "@/lib/flight-utils";
|
||||
import { useGlobeDots } from "./use-globe-dots";
|
||||
|
||||
export function FlightLayers({
|
||||
@ -71,6 +79,48 @@ export function FlightLayers({
|
||||
const dataTimestampRef = useRef(0);
|
||||
const animDurationRef = useRef(DEFAULT_ANIM_DURATION_MS);
|
||||
const animFrameRef = useRef(0);
|
||||
// Recent poll intervals for median smoothing — prevents event loop
|
||||
// stalls from inflating animDuration beyond the true poll cadence.
|
||||
const recentIntervalsRef = useRef<number[]>([]);
|
||||
|
||||
// Persistent caches reused across animation frames to reduce GC pressure
|
||||
const trailBasePathCacheRef = useRef(
|
||||
new Map<string, { key: string; basePath: ElevatedPoint[] }>(),
|
||||
);
|
||||
const interpolatedMapRef = useRef(new Map<string, FlightState>());
|
||||
const pitchMapRef = useRef(new Map<string, number>());
|
||||
const bankMapRef = useRef(new Map<string, number>());
|
||||
// Reusable containers for buildTrailLayers — clear+reuse each frame
|
||||
const handledIdsRef = useRef(new Set<string>());
|
||||
const visibleTrailCacheRef = useRef(new Map<string, ElevatedPoint[]>());
|
||||
const activeIcaosRef = useRef(new Set<string>());
|
||||
// Persistent caches for slope-limited trail paths and colors across frames
|
||||
const trailPathCacheRef = useRef(
|
||||
new Map<string, { key: string; result: [number, number, number][] }>(),
|
||||
);
|
||||
const trailColorCacheRef = useRef(
|
||||
new Map<
|
||||
string,
|
||||
{ key: string; result: [number, number, number, number][] }
|
||||
>(),
|
||||
);
|
||||
// Cached trail-by-icao24 Map — rebuilt only when trailsRef changes, not every frame
|
||||
const trailMapRef = useRef(new Map<string, TrailEntry>());
|
||||
const lastTrailsForMapRef = useRef<TrailEntry[] | null>(null);
|
||||
|
||||
// Interpolation pool — reuse FlightState objects between animation frames
|
||||
// to avoid ~18K object allocations/sec from spread syntax
|
||||
const interpArrayRef = useRef<FlightState[]>([]);
|
||||
const lastFlightsForInterpRef = useRef<FlightState[] | null>(null);
|
||||
|
||||
// Set on tab resume, cleared when fresh flight data arrives.
|
||||
// While true, the RAF loop clamps rawT to 1 (no dead reckoning)
|
||||
// so aircraft freeze at last-known positions on stale data instead
|
||||
// of extrapolating forward on minutes-old headings.
|
||||
const resumeSnapRef = useRef(false);
|
||||
|
||||
// Data version increments when raw flight data changes — drives color/scale updateTriggers
|
||||
const dataVersionRef = useRef(0);
|
||||
|
||||
const flightsRef = useRef(flights);
|
||||
const trailsRef = useRef(trails);
|
||||
@ -139,7 +189,35 @@ export function FlightLayers({
|
||||
// ── Snapshot interpolation on new data ─────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
const elapsed = performance.now() - dataTimestampRef.current;
|
||||
const now = performance.now();
|
||||
const elapsed = now - dataTimestampRef.current;
|
||||
|
||||
// If data is stale (tab was hidden 15s+), snap directly to new
|
||||
// positions instead of slowly interpolating from outdated ones.
|
||||
const STALE_THRESHOLD_MS = 15_000;
|
||||
const isStale =
|
||||
dataTimestampRef.current > 0 && elapsed > STALE_THRESHOLD_MS;
|
||||
|
||||
if (isStale) {
|
||||
const snap = new Map<string, Snapshot>();
|
||||
for (const f of flights) {
|
||||
if (f.longitude != null && f.latitude != null) {
|
||||
snap.set(f.icao24, {
|
||||
lng: f.longitude,
|
||||
lat: f.latitude,
|
||||
alt: Number.isFinite(f.baroAltitude) ? f.baroAltitude! : 0,
|
||||
track: Number.isFinite(f.trueTrack) ? f.trueTrack! : 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
prevSnapshotsRef.current = snap;
|
||||
currSnapshotsRef.current = new Map(snap);
|
||||
animDurationRef.current = DEFAULT_ANIM_DURATION_MS;
|
||||
dataTimestampRef.current = now;
|
||||
lastFlightsForInterpRef.current = null;
|
||||
dataVersionRef.current++;
|
||||
return;
|
||||
}
|
||||
const oldLinearT = Math.min(elapsed / animDurationRef.current, 1);
|
||||
const oldAngleT = smoothStep(oldLinearT);
|
||||
|
||||
@ -175,9 +253,21 @@ export function FlightLayers({
|
||||
const prev = newPrev.get(f.icao24);
|
||||
const rawTrack = Number.isFinite(f.trueTrack) ? f.trueTrack! : 0;
|
||||
const rawAlt = Number.isFinite(f.baroAltitude) ? f.baroAltitude! : 0;
|
||||
|
||||
// MLAT positions (~100m accuracy) jitter visibly compared to
|
||||
// ADS-B (~10m). Apply EMA blending against the previous position
|
||||
// to suppress the noise while tracking real movement.
|
||||
const isMLAT = f.positionSource === 1;
|
||||
let lng = f.longitude;
|
||||
let lat = f.latitude;
|
||||
if (isMLAT && prev) {
|
||||
lng = prev.lng + (lng - prev.lng) * MLAT_POSITION_ALPHA;
|
||||
lat = prev.lat + (lat - prev.lat) * MLAT_POSITION_ALPHA;
|
||||
}
|
||||
|
||||
next.set(f.icao24, {
|
||||
lng: f.longitude,
|
||||
lat: f.latitude,
|
||||
lng,
|
||||
lat,
|
||||
alt: rawAlt,
|
||||
track:
|
||||
prev != null
|
||||
@ -187,15 +277,28 @@ export function FlightLayers({
|
||||
}
|
||||
}
|
||||
currSnapshotsRef.current = next;
|
||||
const now = performance.now();
|
||||
if (dataTimestampRef.current > 0) {
|
||||
const observedInterval = now - dataTimestampRef.current;
|
||||
// Use median of recent intervals to filter event-loop stalls.
|
||||
// A single blocked tick (e.g. heavy parse of 5K aircraft) would
|
||||
// inflate observedInterval → animDuration, making aircraft move
|
||||
// too slowly that cycle. Median is robust to such outliers.
|
||||
const intervals = recentIntervalsRef.current;
|
||||
intervals.push(observedInterval);
|
||||
if (intervals.length > 5) intervals.shift();
|
||||
const sorted = [...intervals].sort((a, b) => a - b);
|
||||
const medianInterval = sorted[Math.floor(sorted.length / 2)];
|
||||
animDurationRef.current = Math.max(
|
||||
MIN_ANIM_DURATION_MS,
|
||||
Math.min(MAX_ANIM_DURATION_MS, observedInterval * 0.94),
|
||||
Math.min(MAX_ANIM_DURATION_MS, medianInterval * 0.94),
|
||||
);
|
||||
}
|
||||
dataTimestampRef.current = now;
|
||||
// Fresh data arrived — allow dead reckoning again (was blocked during
|
||||
// the brief window after tab resume to prevent stale-heading extrapolation).
|
||||
resumeSnapRef.current = false;
|
||||
// Increment data version so model layers know color/scale need recomputation
|
||||
dataVersionRef.current++;
|
||||
}, [flights]);
|
||||
|
||||
// ── Cursor management ──────────────────────────────────────────────
|
||||
@ -222,6 +325,23 @@ export function FlightLayers({
|
||||
[onClick],
|
||||
);
|
||||
|
||||
// Stable refs for event handlers — prevents RAF loop restart when handlers change
|
||||
const handleHoverRef = useRef(handleHover);
|
||||
const handleClickRef = useRef(handleClick);
|
||||
useEffect(() => {
|
||||
handleHoverRef.current = handleHover;
|
||||
handleClickRef.current = handleClick;
|
||||
}, [handleHover, handleClick]);
|
||||
|
||||
const stableHover = useCallback(
|
||||
(info: PickingInfo<FlightState>) => handleHoverRef.current(info),
|
||||
[],
|
||||
);
|
||||
const stableClick = useCallback(
|
||||
(info: PickingInfo<FlightState>) => handleClickRef.current(info),
|
||||
[],
|
||||
);
|
||||
|
||||
// ── Map click pass-through ─────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
@ -254,23 +374,62 @@ export function FlightLayers({
|
||||
useEffect(() => {
|
||||
if (!map || !isLoaded) return;
|
||||
|
||||
if (!overlayRef.current) {
|
||||
function createOverlay() {
|
||||
overlayRef.current = new MapboxOverlay({
|
||||
interleaved: false,
|
||||
views: new MapView({ id: "mapbox" }) as never,
|
||||
pickingRadius: AIRCRAFT_PICK_RADIUS_PX,
|
||||
useDevicePixels: 1,
|
||||
_typedArrayManagerProps: { overAlloc: 1.5, poolSize: 0 },
|
||||
layers: [],
|
||||
});
|
||||
map.addControl(overlayRef.current as unknown as maplibregl.IControl);
|
||||
map!.addControl(overlayRef.current as unknown as maplibregl.IControl);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (!overlayRef.current) {
|
||||
createOverlay();
|
||||
preloadAllModels();
|
||||
}
|
||||
|
||||
// ── WebGL context loss recovery ──────────────────────────────
|
||||
// Mobile devices may reclaim GPU memory when the app is backgrounded.
|
||||
// Without explicit handling, the deck.gl overlay becomes permanently
|
||||
// blank. We listen for context events on MapLibre's canvas and
|
||||
// rebuild the overlay when the browser restores the context.
|
||||
const canvas = map.getCanvas();
|
||||
|
||||
function onContextLost(e: Event) {
|
||||
e.preventDefault(); // allow browser to attempt restoration
|
||||
}
|
||||
|
||||
function onContextRestored() {
|
||||
// Tear down the dead overlay and recreate with a fresh context.
|
||||
if (overlayRef.current) {
|
||||
try {
|
||||
map!.removeControl(
|
||||
overlayRef.current as unknown as maplibregl.IControl,
|
||||
);
|
||||
overlayRef.current.finalize();
|
||||
} catch {
|
||||
/* already dead */
|
||||
}
|
||||
overlayRef.current = null;
|
||||
}
|
||||
createOverlay();
|
||||
}
|
||||
|
||||
canvas.addEventListener("webglcontextlost", onContextLost);
|
||||
canvas.addEventListener("webglcontextrestored", onContextRestored);
|
||||
|
||||
return () => {
|
||||
canvas.removeEventListener("webglcontextlost", onContextLost);
|
||||
canvas.removeEventListener("webglcontextrestored", onContextRestored);
|
||||
if (overlayRef.current) {
|
||||
try {
|
||||
map.removeControl(
|
||||
overlayRef.current as unknown as maplibregl.IControl,
|
||||
);
|
||||
overlayRef.current.finalize();
|
||||
} catch {
|
||||
/* unmounted */
|
||||
}
|
||||
@ -279,22 +438,61 @@ export function FlightLayers({
|
||||
};
|
||||
}, [map, isLoaded]);
|
||||
|
||||
// Visual frame counter — increments once per rendered frame.
|
||||
// Used in updateTriggers so deck.gl recomputes attributes only when we push.
|
||||
const visualFrameRef = useRef(0);
|
||||
// LOD state: true = render 3D ScenegraphLayers, false = render 2D IconLayer.
|
||||
// Uses hysteresis to avoid flickering at the zoom boundary.
|
||||
const use3DRef = useRef(true);
|
||||
// Pitch/bank time-based throttle (~10fps regardless of animation frame rate)
|
||||
const lastPitchBankTimeRef = useRef(0);
|
||||
|
||||
// ── Main animation loop ────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (!atlasUrl) return;
|
||||
|
||||
// Hoisted constant — avoids allocating a new array every frame
|
||||
const DEFAULT_COLOR: [number, number, number, number] = [
|
||||
180, 220, 255, 200,
|
||||
];
|
||||
|
||||
// Snap aircraft to last-known positions on tab resume so they
|
||||
// don't slowly slide from stale locations. dataTimestampRef is
|
||||
// intentionally NOT reset — keeping it stale ensures the next
|
||||
// data arrival triggers the stale-data guard and snaps directly
|
||||
// to fresh positions instead of interpolating from minutes-old ones.
|
||||
// resumeSnapRef prevents dead reckoning on stale headings during
|
||||
// the brief window before fresh data arrives.
|
||||
function onVisibilityResume() {
|
||||
if (document.visibilityState === "visible") {
|
||||
const curr = currSnapshotsRef.current;
|
||||
if (curr.size > 0) {
|
||||
prevSnapshotsRef.current = new Map(curr);
|
||||
}
|
||||
animDurationRef.current = DEFAULT_ANIM_DURATION_MS;
|
||||
lastFlightsForInterpRef.current = null;
|
||||
resumeSnapRef.current = true;
|
||||
}
|
||||
}
|
||||
document.addEventListener("visibilitychange", onVisibilityResume);
|
||||
|
||||
function buildAndPushLayers() {
|
||||
animFrameRef.current = requestAnimationFrame(buildAndPushLayers);
|
||||
|
||||
// Skip all rendering work when tab is hidden — saves CPU/GPU.
|
||||
// RAF is already throttled to ~1fps in background but each tick
|
||||
// would still construct layers & run interpolation for nothing.
|
||||
if (document.hidden) return;
|
||||
|
||||
const overlay = overlayRef.current;
|
||||
if (!overlay) return;
|
||||
|
||||
const currentZoom = map?.getZoom() ?? 10;
|
||||
const now = performance.now();
|
||||
const isGlobe = globeModeRef.current;
|
||||
visualFrameRef.current++;
|
||||
|
||||
updateGlobeDotsRef.current(isGlobe, currentZoom, now);
|
||||
const currentZoom = map?.getZoom() ?? 10;
|
||||
const isGlobe = globeModeRef.current;
|
||||
|
||||
let globeFade = 1;
|
||||
let layersVisible = true;
|
||||
@ -312,39 +510,61 @@ export function FlightLayers({
|
||||
|
||||
try {
|
||||
const elapsed = performance.now() - dataTimestampRef.current;
|
||||
const rawT = elapsed / animDurationRef.current;
|
||||
// After tab resume, clamp rawT so aircraft freeze at last-known
|
||||
// positions instead of dead-reckoning forward on stale headings.
|
||||
// Cleared when fresh flight data arrives in the flights useEffect.
|
||||
const rawT = resumeSnapRef.current
|
||||
? Math.min(elapsed / animDurationRef.current, 1)
|
||||
: elapsed / animDurationRef.current;
|
||||
const tPos = Math.min(rawT, 1);
|
||||
const tAngle = smoothStep(smoothStep(smoothStep(tPos)));
|
||||
|
||||
const currentFlights = flightsRef.current;
|
||||
const currentTrails = trailsRef.current;
|
||||
const altColors = showAltColorsRef.current;
|
||||
const defaultColor: [number, number, number, number] = [
|
||||
180, 220, 255, 200,
|
||||
];
|
||||
|
||||
const interpolated = computeInterpolatedFlights(
|
||||
currentFlights,
|
||||
prevSnapshotsRef.current,
|
||||
currSnapshotsRef.current,
|
||||
tPos,
|
||||
tAngle,
|
||||
rawT,
|
||||
animDurationRef.current,
|
||||
);
|
||||
// On new poll data: full interpolation (creates new FlightState objects).
|
||||
// Between polls: mutate positions in-place (zero object allocations).
|
||||
let interpolated: FlightState[];
|
||||
if (currentFlights !== lastFlightsForInterpRef.current) {
|
||||
interpolated = computeInterpolatedFlights(
|
||||
currentFlights,
|
||||
prevSnapshotsRef.current,
|
||||
currSnapshotsRef.current,
|
||||
tPos,
|
||||
tAngle,
|
||||
rawT,
|
||||
animDurationRef.current,
|
||||
);
|
||||
interpArrayRef.current = interpolated;
|
||||
lastFlightsForInterpRef.current = currentFlights;
|
||||
|
||||
const interpolatedMap = new Map<string, FlightState>();
|
||||
for (const f of interpolated) {
|
||||
interpolatedMap.set(f.icao24, f);
|
||||
// Rebuild Map only on new poll — updateInterpolatedInPlace mutates
|
||||
// the same FlightState objects in-place, so existing Map entries
|
||||
// remain valid between polls.
|
||||
const interpolatedMap = interpolatedMapRef.current;
|
||||
interpolatedMap.clear();
|
||||
for (const f of interpolated) {
|
||||
interpolatedMap.set(f.icao24, f);
|
||||
}
|
||||
} else {
|
||||
interpolated = interpArrayRef.current;
|
||||
updateInterpolatedInPlace(
|
||||
interpolated,
|
||||
currentFlights,
|
||||
prevSnapshotsRef.current,
|
||||
currSnapshotsRef.current,
|
||||
tPos,
|
||||
tAngle,
|
||||
rawT,
|
||||
animDurationRef.current,
|
||||
);
|
||||
}
|
||||
|
||||
// FPV position output
|
||||
// FPV position output — O(1) Map lookup instead of O(n) find
|
||||
const fpvId = fpvIcao24Ref.current?.toLowerCase() ?? null;
|
||||
const visibleFlights = interpolated;
|
||||
const fpvPosOut = fpvPosRef.current;
|
||||
if (fpvPosOut && fpvId) {
|
||||
const fpvF =
|
||||
interpolated.find((f) => f.icao24.toLowerCase() === fpvId) ?? null;
|
||||
const fpvF = interpolatedMapRef.current.get(fpvId) ?? null;
|
||||
if (
|
||||
fpvF &&
|
||||
Number.isFinite(fpvF.longitude) &&
|
||||
@ -365,19 +585,63 @@ export function FlightLayers({
|
||||
fpvPosOut.current = null;
|
||||
}
|
||||
|
||||
const pitchByIcao = computePitchByIcao(
|
||||
interpolated,
|
||||
new Map(currentTrails.map((t) => [t.icao24, t])),
|
||||
currSnapshotsRef.current,
|
||||
prevSnapshotsRef.current,
|
||||
);
|
||||
// Rebuild trail-by-icao24 Map only when trails reference changes
|
||||
if (currentTrails !== lastTrailsForMapRef.current) {
|
||||
trailMapRef.current.clear();
|
||||
for (const t of currentTrails) {
|
||||
trailMapRef.current.set(t.icao24, t);
|
||||
}
|
||||
lastTrailsForMapRef.current = currentTrails;
|
||||
}
|
||||
|
||||
// ── Globe dots ────────────────────────────────────────────────
|
||||
updateGlobeDotsRef.current(isGlobe, currentZoom, now);
|
||||
|
||||
const altColors = showAltColorsRef.current;
|
||||
const visibleFlights = interpolated;
|
||||
|
||||
// Pitch/bank change slowly — recompute at ~10fps regardless of
|
||||
// animation frame rate. Values are retained in pitchMapRef/bankMapRef
|
||||
// between compute frames.
|
||||
const PITCH_BANK_INTERVAL_MS = 100;
|
||||
if (now - lastPitchBankTimeRef.current >= PITCH_BANK_INTERVAL_MS) {
|
||||
lastPitchBankTimeRef.current = now;
|
||||
computePitchByIcao(
|
||||
interpolated,
|
||||
trailMapRef.current,
|
||||
currSnapshotsRef.current,
|
||||
prevSnapshotsRef.current,
|
||||
pitchMapRef.current,
|
||||
);
|
||||
|
||||
computeBankByIcao(
|
||||
interpolated,
|
||||
prevSnapshotsRef.current,
|
||||
currSnapshotsRef.current,
|
||||
tAngle,
|
||||
bankMapRef.current,
|
||||
);
|
||||
}
|
||||
const pitchByIcao = pitchMapRef.current;
|
||||
const bankByIcao = bankMapRef.current;
|
||||
|
||||
const layers = [];
|
||||
|
||||
// Zoom-dependent elevation scale to prevent absurd altitude spikes
|
||||
// at globe zoom levels. Full exaggeration at city zoom (>8).
|
||||
// Computed once per frame and passed to all builders.
|
||||
const elevScale =
|
||||
currentZoom < 5
|
||||
? 0.15 + (currentZoom / 5) * 0.35
|
||||
: currentZoom < 8
|
||||
? 0.5 + ((currentZoom - 5) / 3) * 0.5
|
||||
: 1.0;
|
||||
|
||||
// Shadow layer — always included, toggled via `visible` to retain WebGL state
|
||||
layers.push(
|
||||
new IconLayer<FlightState>({
|
||||
id: "flight-shadows",
|
||||
pickable: false,
|
||||
visible: layersVisible && showShadowsRef.current,
|
||||
data: visibleFlights,
|
||||
opacity: globeFade,
|
||||
@ -392,6 +656,10 @@ export function FlightLayers({
|
||||
billboard: false,
|
||||
sizeUnits: "pixels",
|
||||
sizeScale: 1,
|
||||
updateTriggers: {
|
||||
getPosition: visualFrameRef.current,
|
||||
getAngle: visualFrameRef.current,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@ -399,86 +667,123 @@ export function FlightLayers({
|
||||
layers.push(
|
||||
buildTrailLayers({
|
||||
interpolated,
|
||||
interpolatedMap,
|
||||
interpolatedMap: interpolatedMapRef.current,
|
||||
currentTrails,
|
||||
trailMap: trailMapRef.current,
|
||||
trailDistance: trailDistanceRef.current,
|
||||
trailThickness: trailThicknessRef.current,
|
||||
altColors,
|
||||
defaultColor,
|
||||
defaultColor: DEFAULT_COLOR,
|
||||
elapsed,
|
||||
visualFrame: visualFrameRef.current,
|
||||
globeFade,
|
||||
currentZoom,
|
||||
elevScale,
|
||||
visible: layersVisible && showTrailsRef.current,
|
||||
trailBasePathCache: trailBasePathCacheRef.current,
|
||||
trailPathCache: trailPathCacheRef.current,
|
||||
trailColorCache: trailColorCacheRef.current,
|
||||
handledIdsSet: handledIdsRef.current,
|
||||
visibleTrailCacheMap: visibleTrailCacheRef.current,
|
||||
activeIcaosSet: activeIcaosRef.current,
|
||||
}),
|
||||
);
|
||||
|
||||
// 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.
|
||||
if (selectedIcao24Ref.current || prevSelectedRef.current) {
|
||||
const pulseResult = buildSelectionPulseLayers({
|
||||
selectionChangeTime: selectionChangeTimeRef.current,
|
||||
selectedId: selectedIcao24Ref.current,
|
||||
prevId: prevSelectedRef.current,
|
||||
interpolated,
|
||||
interpolatedMap: interpolatedMapRef.current,
|
||||
elapsed,
|
||||
globeFade,
|
||||
currentZoom,
|
||||
visible: layersVisible && showTrailsRef.current,
|
||||
}),
|
||||
);
|
||||
|
||||
// Selection pulse layers (halo + rings)
|
||||
const pulseResult = buildSelectionPulseLayers({
|
||||
selectionChangeTime: selectionChangeTimeRef.current,
|
||||
selectedId: selectedIcao24Ref.current,
|
||||
prevId: prevSelectedRef.current,
|
||||
interpolated,
|
||||
elapsed,
|
||||
globeFade,
|
||||
currentZoom,
|
||||
haloUrl,
|
||||
ringUrl,
|
||||
layersVisible,
|
||||
});
|
||||
layers.push(...pulseResult.layers);
|
||||
if (pulseResult.shouldClearPrev) {
|
||||
prevSelectedRef.current = null;
|
||||
elevScale,
|
||||
haloUrl,
|
||||
ringUrl,
|
||||
layersVisible,
|
||||
});
|
||||
layers.push(...pulseResult.layers);
|
||||
if (pulseResult.shouldClearPrev) {
|
||||
prevSelectedRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Zoom-dependent elevation scale to prevent absurd altitude spikes
|
||||
// at globe zoom levels. Full exaggeration at city zoom (>8).
|
||||
const elevScale =
|
||||
currentZoom < 5
|
||||
? 0.15 + (currentZoom / 5) * 0.35
|
||||
: currentZoom < 8
|
||||
? 0.5 + ((currentZoom - 5) / 3) * 0.5
|
||||
: 1.0;
|
||||
// ── LOD: 3D models vs 2D icons ────────────────────────────────
|
||||
// At low zoom, aircraft are too small to distinguish 3D silhouettes.
|
||||
// Switch to a single IconLayer (2D) below LOD_3D_ZOOM_OUT and back
|
||||
// to ScenegraphLayers (3D) above LOD_3D_ZOOM_IN. The hysteresis
|
||||
// band (6.5–7.5) prevents rapid flickering at the boundary.
|
||||
if (use3DRef.current && currentZoom < LOD_3D_ZOOM_OUT) {
|
||||
use3DRef.current = false;
|
||||
} else if (!use3DRef.current && currentZoom >= LOD_3D_ZOOM_IN) {
|
||||
use3DRef.current = true;
|
||||
}
|
||||
|
||||
// Aircraft 3D model layer — always included with `visible` to avoid
|
||||
// re-fetching the .glb model on every zoom in/out cycle
|
||||
layers.push(
|
||||
new ScenegraphLayer<FlightState>({
|
||||
id: "flight-aircraft",
|
||||
visible: layersVisible,
|
||||
data: visibleFlights,
|
||||
opacity: globeFade,
|
||||
getPosition: (d) => [
|
||||
d.longitude!,
|
||||
d.latitude!,
|
||||
altitudeToElevation(d.baroAltitude) * elevScale,
|
||||
],
|
||||
getOrientation: (d) => {
|
||||
const pitch = pitchByIcao.get(d.icao24) ?? 0;
|
||||
const yaw = -(Number.isFinite(d.trueTrack) ? d.trueTrack! : 0);
|
||||
return [pitch, yaw, 90];
|
||||
},
|
||||
getColor: (d) => {
|
||||
const base = altColors
|
||||
? altitudeToColor(d.baroAltitude)
|
||||
: defaultColor;
|
||||
return tintAircraftColor(base, d.category);
|
||||
},
|
||||
scenegraph: AIRCRAFT_SCENEGRAPH_URL,
|
||||
getScale: (d) => {
|
||||
const scale = categorySizeMultiplier(d.category);
|
||||
return [scale, scale, scale];
|
||||
},
|
||||
sizeScale: BASE_AIRCRAFT_SIZE,
|
||||
sizeMinPixels: AIRCRAFT_PX_PER_UNIT,
|
||||
sizeMaxPixels: AIRCRAFT_PX_PER_UNIT,
|
||||
_lighting: "pbr",
|
||||
pickable: true,
|
||||
onHover: handleHover,
|
||||
onClick: handleClick,
|
||||
autoHighlight: true,
|
||||
highlightColor: [255, 255, 255, 80],
|
||||
}),
|
||||
);
|
||||
if (use3DRef.current) {
|
||||
// 3D: one ScenegraphLayer per model type
|
||||
layers.push(
|
||||
...buildAircraftModelLayers({
|
||||
rawFlights: currentFlights,
|
||||
interpolatedMap: interpolatedMapRef.current,
|
||||
frameCounter: visualFrameRef.current,
|
||||
dataVersion: dataVersionRef.current,
|
||||
layersVisible,
|
||||
globeFade,
|
||||
elevScale,
|
||||
altColors,
|
||||
defaultColor: DEFAULT_COLOR,
|
||||
pitchByIcao,
|
||||
bankByIcao,
|
||||
handleHover: stableHover,
|
||||
handleClick: stableClick,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
// 2D: single IconLayer using the sprite atlas (much cheaper GPU-wise)
|
||||
layers.push(
|
||||
new IconLayer<FlightState>({
|
||||
id: "flight-aircraft-2d",
|
||||
pickable: true,
|
||||
visible: layersVisible,
|
||||
data: visibleFlights,
|
||||
opacity: globeFade,
|
||||
getPosition: (d) => [
|
||||
d.longitude!,
|
||||
d.latitude!,
|
||||
altitudeToElevation(d.baroAltitude) * elevScale,
|
||||
],
|
||||
getIcon: () => "aircraft",
|
||||
getSize: (d) => 20 * categorySizeMultiplier(d.category),
|
||||
getColor: (d) => {
|
||||
const base = altColors
|
||||
? altitudeToColor(d.baroAltitude)
|
||||
: DEFAULT_COLOR;
|
||||
return tintAircraftColor(base, d.category);
|
||||
},
|
||||
getAngle: (d) =>
|
||||
360 - (Number.isFinite(d.trueTrack) ? d.trueTrack! : 0),
|
||||
iconAtlas: atlasUrl,
|
||||
iconMapping: AIRCRAFT_ICON_MAPPING,
|
||||
billboard: false,
|
||||
sizeUnits: "pixels",
|
||||
sizeScale: 1,
|
||||
onHover: stableHover,
|
||||
onClick: stableClick,
|
||||
autoHighlight: true,
|
||||
highlightColor: [255, 255, 255, 80],
|
||||
updateTriggers: {
|
||||
getPosition: [visualFrameRef.current, elevScale],
|
||||
getAngle: visualFrameRef.current,
|
||||
getColor: [dataVersionRef.current, altColors],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
overlay.setProps({ layers });
|
||||
} catch (err) {
|
||||
@ -489,8 +794,11 @@ export function FlightLayers({
|
||||
}
|
||||
|
||||
buildAndPushLayers();
|
||||
return () => cancelAnimationFrame(animFrameRef.current);
|
||||
}, [atlasUrl, haloUrl, ringUrl, handleHover, handleClick, map]);
|
||||
return () => {
|
||||
cancelAnimationFrame(animFrameRef.current);
|
||||
document.removeEventListener("visibilitychange", onVisibilityResume);
|
||||
};
|
||||
}, [atlasUrl, haloUrl, ringUrl, stableHover, stableClick, map]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -90,9 +90,6 @@ export const Map = forwardRef<MapRef, MapProps>(function Map(
|
||||
const isDarkRef = useRef(isDark);
|
||||
isDarkRef.current = isDark;
|
||||
|
||||
const globeModeRef = useRef(globeMode);
|
||||
globeModeRef.current = globeMode;
|
||||
|
||||
// ── Map creation ──────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
@ -111,8 +108,10 @@ export const Map = forwardRef<MapRef, MapProps>(function Map(
|
||||
maxPitch: GLOBE_MAX_PITCH,
|
||||
attributionControl: false,
|
||||
cancelPendingTileRequestsWhileZooming: true,
|
||||
maxTileCacheZoomLevels: 3, // fewer cached zoom levels = less memory for DEM tiles
|
||||
maxTileCacheZoomLevels: 2, // fewer cached zoom levels = less GPU memory for tile textures
|
||||
renderWorldCopies: false,
|
||||
pixelRatio: 1, // render at 1x regardless of display DPI — significant GPU savings on HiDPI
|
||||
fadeDuration: 0, // disable tile/symbol fade animations — fewer intermediate render frames
|
||||
});
|
||||
|
||||
map.on("load", () => setIsLoaded(true));
|
||||
@ -123,7 +122,7 @@ export const Map = forwardRef<MapRef, MapProps>(function Map(
|
||||
setIsLoaded(false);
|
||||
setMapInstance(null);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Map initializes once; containerRef is stable, style/terrain/globe applied in separate effects
|
||||
}, []);
|
||||
|
||||
// Inject globe projection into every style change when globe mode is on.
|
||||
@ -165,13 +164,15 @@ export const Map = forwardRef<MapRef, MapProps>(function Map(
|
||||
);
|
||||
|
||||
// Set projection imperatively so it takes effect immediately.
|
||||
mapInstance.once("style.load", () => {
|
||||
const onStyleLoad = () => {
|
||||
mapInstance.setProjection({ type: globeMode ? "globe" : "mercator" });
|
||||
addAerowayLayers(mapInstance, isDarkRef.current);
|
||||
});
|
||||
};
|
||||
|
||||
mapInstance.once("style.load", onStyleLoad);
|
||||
|
||||
return () => {
|
||||
mapInstance.off("style.load", () => {});
|
||||
mapInstance.off("style.load", onStyleLoad);
|
||||
};
|
||||
}, [mapInstance, isLoaded, mapStyle, terrainProfile, globeMode]);
|
||||
|
||||
|
||||
@ -130,12 +130,36 @@ export function useFpvCamera(
|
||||
map.on(t, onMapInteraction);
|
||||
}
|
||||
|
||||
// Reset FPV tracking on tab resume to prevent camera jumps from
|
||||
// stale lerp values accumulated during the hidden period.
|
||||
let wasHidden = false;
|
||||
function onFpvVisibilityResume() {
|
||||
if (document.visibilityState === "visible" && wasHidden) {
|
||||
wasHidden = false;
|
||||
if (map) prevBearing = map.getBearing();
|
||||
fpvOffsetX = 0;
|
||||
fpvOffsetY = 0;
|
||||
lastInteractionTime = 0;
|
||||
recenterStartTime = 0;
|
||||
} else if (document.visibilityState === "hidden") {
|
||||
wasHidden = true;
|
||||
}
|
||||
}
|
||||
document.addEventListener("visibilitychange", onFpvVisibilityResume);
|
||||
|
||||
function keepInFrame() {
|
||||
if (!isFpvActiveRef.current || !map) {
|
||||
frameId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip camera updates when tab is hidden — saves CPU and
|
||||
// prevents jarring camera jumps from stale alpha lerps on resume.
|
||||
if (document.hidden) {
|
||||
frameId = requestAnimationFrame(keepInFrame);
|
||||
return;
|
||||
}
|
||||
|
||||
const interpPos = fpvPosRef.current?.current ?? null;
|
||||
const live = fpvFlightRef.current;
|
||||
|
||||
@ -263,6 +287,7 @@ export function useFpvCamera(
|
||||
return () => {
|
||||
if (startupTimer) clearTimeout(startupTimer);
|
||||
if (frameId != null) cancelAnimationFrame(frameId);
|
||||
document.removeEventListener("visibilitychange", onFpvVisibilityResume);
|
||||
for (const t of interactionEventTypes) {
|
||||
map.off(t, onMapInteraction);
|
||||
}
|
||||
|
||||
@ -42,6 +42,8 @@ export function useGlobeDots(
|
||||
const lastGeoJsonTimestampRef = useRef(0);
|
||||
const geoJsonClearedRef = useRef(false);
|
||||
const globeZoomEnteredAtRef = useRef(0);
|
||||
// Cache last visibility state to avoid calling setLayoutProperty every frame
|
||||
const lastDotsVisibleRef = useRef<boolean | null>(null);
|
||||
|
||||
// Set up MapLibre source, layer, and event handlers
|
||||
useEffect(() => {
|
||||
@ -197,23 +199,27 @@ export function useGlobeDots(
|
||||
|
||||
// Hide layers unless globe mode AND below switch zoom
|
||||
const dotsVisible = isGlobe && currentZoom < GLOBE_NATIVE_ZOOM_CEIL;
|
||||
try {
|
||||
if (map.getLayer(LAYER_ID)) {
|
||||
map.setLayoutProperty(
|
||||
LAYER_ID,
|
||||
"visibility",
|
||||
dotsVisible ? "visible" : "none",
|
||||
);
|
||||
// Only call setLayoutProperty when visibility actually changes
|
||||
if (dotsVisible !== lastDotsVisibleRef.current) {
|
||||
lastDotsVisibleRef.current = dotsVisible;
|
||||
try {
|
||||
if (map.getLayer(LAYER_ID)) {
|
||||
map.setLayoutProperty(
|
||||
LAYER_ID,
|
||||
"visibility",
|
||||
dotsVisible ? "visible" : "none",
|
||||
);
|
||||
}
|
||||
if (map.getLayer(TRAIL_LAYER_ID)) {
|
||||
map.setLayoutProperty(
|
||||
TRAIL_LAYER_ID,
|
||||
"visibility",
|
||||
dotsVisible ? "visible" : "none",
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* layer may not exist yet */
|
||||
}
|
||||
if (map.getLayer(TRAIL_LAYER_ID)) {
|
||||
map.setLayoutProperty(
|
||||
TRAIL_LAYER_ID,
|
||||
"visibility",
|
||||
dotsVisible ? "visible" : "none",
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* layer may not exist yet */
|
||||
}
|
||||
|
||||
if (isGlobe) {
|
||||
|
||||
@ -51,20 +51,25 @@ export function useKeyboardCamera(
|
||||
}
|
||||
|
||||
function applyDelta(type: CameraActionType, delta: number) {
|
||||
if (!map) return;
|
||||
if (type === "zoom") {
|
||||
const z = map!.getZoom() + delta;
|
||||
map!.setZoom(
|
||||
Math.min(Math.max(z, map!.getMinZoom()), map!.getMaxZoom()),
|
||||
);
|
||||
const z = map.getZoom() + delta;
|
||||
map.setZoom(Math.min(Math.max(z, map.getMinZoom()), map.getMaxZoom()));
|
||||
} else if (type === "pitch") {
|
||||
const p = map!.getPitch() + delta;
|
||||
map!.setPitch(Math.min(Math.max(p, 0), map!.getMaxPitch()));
|
||||
const p = map.getPitch() + delta;
|
||||
map.setPitch(Math.min(Math.max(p, 0), map.getMaxPitch()));
|
||||
} else {
|
||||
map!.setBearing(map!.getBearing() + delta);
|
||||
map.setBearing(map.getBearing() + delta);
|
||||
}
|
||||
}
|
||||
|
||||
function tick(now: number) {
|
||||
// Skip camera movement when tab is hidden
|
||||
if (document.hidden) {
|
||||
lastTime = 0; // reset so first visible frame uses default dt
|
||||
frameId = requestAnimationFrame(tick);
|
||||
return;
|
||||
}
|
||||
const dt = lastTime ? Math.min((now - lastTime) / 1000, 0.1) : 0.016;
|
||||
lastTime = now;
|
||||
|
||||
|
||||
@ -50,13 +50,25 @@ export function useOrbitCamera(
|
||||
if (!map || isInteractingRef.current) return;
|
||||
|
||||
const resumeStart = performance.now();
|
||||
let lastTime = 0;
|
||||
|
||||
function tick() {
|
||||
function tick(now: number) {
|
||||
if (!map || isInteractingRef.current) return;
|
||||
// Skip orbit rotation when tab is hidden — saves CPU and
|
||||
// prevents large bearing jumps on resume.
|
||||
if (document.hidden) {
|
||||
lastTime = 0;
|
||||
orbitFrameRef.current = requestAnimationFrame(tick);
|
||||
return;
|
||||
}
|
||||
const dt = lastTime ? Math.min((now - lastTime) / 1000, 0.1) : 1 / 60;
|
||||
lastTime = now;
|
||||
|
||||
const resumeElapsed = performance.now() - resumeStart;
|
||||
const t = Math.min(resumeElapsed / ORBIT_EASE_IN_MS, 1);
|
||||
const easeFactor = smoothstep(t);
|
||||
const bearing = map.getBearing() + speedRef.current * easeFactor;
|
||||
const bearing =
|
||||
map.getBearing() + speedRef.current * easeFactor * dt * 60;
|
||||
map.setBearing(bearing % 360);
|
||||
orbitFrameRef.current = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
X,
|
||||
Plane,
|
||||
ImageOff,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
NormalizedPhoto,
|
||||
@ -53,7 +54,7 @@ const Thumbnail = memo(function Thumbnail({
|
||||
ref={ref}
|
||||
type="button"
|
||||
onClick={() => onClick(index)}
|
||||
className="group relative h-16 w-24 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-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"
|
||||
aria-label={`View photo ${index + 1}${photo.photographer ? ` by ${photo.photographer}` : ""}`}
|
||||
>
|
||||
{!loaded && (
|
||||
@ -64,7 +65,7 @@ const Thumbnail = memo(function Thumbnail({
|
||||
)}
|
||||
{visible && (
|
||||
<img
|
||||
src={photo.thumbnail}
|
||||
src={photo.url}
|
||||
alt={`Aircraft photo ${index + 1}`}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@ -94,8 +95,12 @@ export function Lightbox({
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLoaded(false);
|
||||
setImgError(false);
|
||||
// Reset image state when navigating between photos
|
||||
const reset = () => {
|
||||
setLoaded(false);
|
||||
setImgError(false);
|
||||
};
|
||||
reset();
|
||||
}, [index]);
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
@ -201,7 +206,10 @@ export function Lightbox({
|
||||
</>
|
||||
)}
|
||||
|
||||
{(photo.photographer || photo.location || photo.dateTaken) && (
|
||||
{(photo.photographer ||
|
||||
photo.location ||
|
||||
photo.dateTaken ||
|
||||
photo.link) && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@ -226,6 +234,22 @@ export function Lightbox({
|
||||
{photo.dateTaken && (
|
||||
<span className="text-white/45">{photo.dateTaken}</span>
|
||||
)}
|
||||
{photo.link && (
|
||||
<>
|
||||
{(photo.photographer || photo.location || photo.dateTaken) && (
|
||||
<span className="text-white/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"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Source
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
@ -254,8 +278,18 @@ export function AircraftPhotos({
|
||||
}: AircraftPhotosProps) {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
const [showAllPhotos, setShowAllPhotos] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const PREVIEW_COUNT = 3;
|
||||
|
||||
// Reset "show all" when photos change (new aircraft selected)
|
||||
const photoKey = photos.map((p) => p.id).join(",");
|
||||
useEffect(() => {
|
||||
const reset = () => setShowAllPhotos(false);
|
||||
reset();
|
||||
}, [photoKey]);
|
||||
|
||||
const handlePhotoClick = useCallback(
|
||||
(index: number) => {
|
||||
if (onPhotoClick) {
|
||||
@ -277,6 +311,10 @@ export function AircraftPhotos({
|
||||
? loading || hasPhotos
|
||||
: loading || hasPhotos || hasAircraft;
|
||||
|
||||
const visiblePhotos = showAllPhotos ? photos : photos.slice(0, PREVIEW_COUNT);
|
||||
const hiddenCount = photos.length - PREVIEW_COUNT;
|
||||
const hasMore = hiddenCount > 0;
|
||||
|
||||
if (!showSection) return null;
|
||||
|
||||
const detailParts: string[] = [];
|
||||
@ -333,7 +371,7 @@ export function AircraftPhotos({
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-16 w-24 shrink-0 animate-pulse rounded-lg bg-white/5"
|
||||
className="h-20 w-32 shrink-0 animate-pulse rounded-lg bg-white/5"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -345,7 +383,7 @@ export function AircraftPhotos({
|
||||
className="mt-2 flex gap-2 overflow-x-auto pb-1 scrollbar-none"
|
||||
style={{ scrollbarWidth: "none" }}
|
||||
>
|
||||
{photos.map((photo, i) => (
|
||||
{visiblePhotos.map((photo, i) => (
|
||||
<Thumbnail
|
||||
key={photo.id}
|
||||
photo={photo}
|
||||
@ -353,6 +391,19 @@ export function AircraftPhotos({
|
||||
onClick={handlePhotoClick}
|
||||
/>
|
||||
))}
|
||||
{hasMore && !showAllPhotos && (
|
||||
<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"
|
||||
aria-label={`Show ${hiddenCount} more photo${hiddenCount === 1 ? "" : "s"}`}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
<span className="text-[9px] font-medium tabular-nums">
|
||||
{hiddenCount} more
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { useState, useEffect, useRef, useMemo, memo } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { Search, X, MapPin, ChevronRight } from "lucide-react";
|
||||
import { CITIES, type City } from "@/lib/cities";
|
||||
@ -226,7 +226,7 @@ export function AirportSearchInput({
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownRow({
|
||||
const DropdownRow = memo(function DropdownRow({
|
||||
name,
|
||||
detail,
|
||||
isActive,
|
||||
@ -254,4 +254,4 @@ function DropdownRow({
|
||||
<ChevronRight className="h-3 w-3 shrink-0 text-white/10 group-hover:text-white/20" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
408
src/components/ui/atc-panel.tsx
Normal file
408
src/components/ui/atc-panel.tsx
Normal file
@ -0,0 +1,408 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useCallback, useRef } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import {
|
||||
Radio,
|
||||
Play,
|
||||
Square,
|
||||
Loader2,
|
||||
X,
|
||||
AlertTriangle,
|
||||
Server,
|
||||
ChevronUp,
|
||||
} from "lucide-react";
|
||||
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";
|
||||
|
||||
// ── Feed helpers ───────────────────────────────────────────────────────
|
||||
|
||||
const TYPE_LABELS: Record<AtcFeedType, string> = {
|
||||
tower: "TWR",
|
||||
ground: "GND",
|
||||
approach: "APP",
|
||||
departure: "DEP",
|
||||
atis: "ATIS",
|
||||
center: "CTR",
|
||||
combined: "CMB",
|
||||
};
|
||||
|
||||
const TYPE_COLORS: Record<AtcFeedType, string> = {
|
||||
tower: "rgb(52, 211, 153)",
|
||||
ground: "rgb(251, 191, 36)",
|
||||
approach: "rgb(96, 165, 250)",
|
||||
departure: "rgb(167, 139, 250)",
|
||||
atis: "rgb(148, 163, 184)",
|
||||
center: "rgb(244, 114, 182)",
|
||||
combined: "rgb(156, 163, 175)",
|
||||
};
|
||||
|
||||
function sortFeeds(feeds: AtcFeed[]): AtcFeed[] {
|
||||
return [...feeds].sort(
|
||||
(a, b) => FEED_TYPE_PRIORITY[a.type] - FEED_TYPE_PRIORITY[b.type],
|
||||
);
|
||||
}
|
||||
|
||||
export function useAvailableFeeds(
|
||||
cityIata: string,
|
||||
cityCoordinates: [number, number],
|
||||
): AtcFeed[] {
|
||||
return useMemo(() => {
|
||||
const byCode = lookupAtcFeeds(cityIata);
|
||||
if (byCode.length > 0) return sortFeeds(byCode);
|
||||
const [lng, lat] = cityCoordinates;
|
||||
const nearby = findNearbyAtcFeeds(lat, lng, 30);
|
||||
return sortFeeds(nearby.flatMap((r) => r.feeds));
|
||||
}, [cityIata, cityCoordinates]);
|
||||
}
|
||||
|
||||
// Waveform is in atc-waveform.tsx
|
||||
|
||||
// ── Feed Dropdown (opens upward) ───────────────────────────────────────
|
||||
|
||||
export type AtcFeedDropdownProps = {
|
||||
feeds: AtcFeed[];
|
||||
atc: UseAtcStreamReturn;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function AtcFeedDropdown({
|
||||
feeds,
|
||||
atc,
|
||||
open,
|
||||
onClose,
|
||||
}: AtcFeedDropdownProps) {
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
useDropdownDismiss(dropdownRef, open, onClose);
|
||||
|
||||
const handleSelectFeed = useCallback(
|
||||
(feed: AtcFeed) => {
|
||||
if (atc.feed?.id === feed.id && atc.status === "playing") {
|
||||
atc.stop();
|
||||
} else {
|
||||
atc.play(feed);
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
[atc, onClose],
|
||||
);
|
||||
|
||||
// Group feeds by type for visual hierarchy
|
||||
const groupedFeeds = useMemo(() => {
|
||||
const groups: { type: AtcFeedType; label: string; feeds: AtcFeed[] }[] = [];
|
||||
const typeOrder: AtcFeedType[] = [
|
||||
"tower",
|
||||
"ground",
|
||||
"approach",
|
||||
"departure",
|
||||
"center",
|
||||
"atis",
|
||||
"combined",
|
||||
];
|
||||
for (const type of typeOrder) {
|
||||
const matching = feeds.filter((f) => f.type === type);
|
||||
if (matching.length > 0) {
|
||||
groups.push({ type, label: TYPE_LABELS[type], feeds: matching });
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}, [feeds]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
ref={dropdownRef}
|
||||
initial={{ opacity: 0, y: 8, scale: 0.97 }}
|
||||
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"
|
||||
style={{
|
||||
borderColor: "rgb(var(--ui-fg) / 0.08)",
|
||||
backgroundColor: "rgb(var(--ui-bg) / 0.75)",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-2.5"
|
||||
style={{ borderBottom: "1px solid rgb(var(--ui-fg) / 0.06)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Radio className="h-3 w-3 text-emerald-400/70" />
|
||||
<span
|
||||
className="text-[10px] font-semibold tracking-widest uppercase"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.35)" }}
|
||||
>
|
||||
Frequencies
|
||||
</span>
|
||||
</div>
|
||||
<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"
|
||||
aria-label="Close feed selector"
|
||||
>
|
||||
<X
|
||||
className="h-3 w-3"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.3)" }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Feed list */}
|
||||
{feeds.length === 0 ? (
|
||||
<div className="px-3.5 py-5 text-center">
|
||||
<span
|
||||
className="text-[10px]"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.3)" }}
|
||||
>
|
||||
No feeds for this area
|
||||
</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;
|
||||
|
||||
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>
|
||||
|
||||
{/* 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="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>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Bottom Player Bar (ElevenLabs-style) ───────────────────────────────
|
||||
|
||||
export type AtcPlayerBarProps = {
|
||||
atc: UseAtcStreamReturn;
|
||||
onOpenFeedSelector: () => void;
|
||||
};
|
||||
|
||||
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";
|
||||
|
||||
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 ? (
|
||||
<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"
|
||||
>
|
||||
<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" && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Status Bar ATC Trigger Button ──────────────────────────────────────
|
||||
|
||||
export type AtcTriggerProps = {
|
||||
hasFeeds: boolean;
|
||||
isPlaying: boolean;
|
||||
isError: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export function AtcTrigger({
|
||||
hasFeeds,
|
||||
isPlaying,
|
||||
isError,
|
||||
onClick,
|
||||
}: AtcTriggerProps) {
|
||||
if (!hasFeeds) return null;
|
||||
|
||||
return (
|
||||
<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"
|
||||
aria-label="Live ATC (A)"
|
||||
title="Live ATC (A)"
|
||||
>
|
||||
<ChevronUp
|
||||
className={`h-3 w-3 transition-colors ${isError ? "animate-pulse" : ""}`}
|
||||
style={{
|
||||
color: isPlaying
|
||||
? "rgb(52, 211, 153)"
|
||||
: isError
|
||||
? "rgb(251, 191, 36)"
|
||||
: "rgb(var(--ui-fg) / 0.35)",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
204
src/components/ui/atc-waveform.tsx
Normal file
204
src/components/ui/atc-waveform.tsx
Normal file
@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect } from "react";
|
||||
|
||||
const 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;
|
||||
|
||||
// ── Module-level Web Audio singleton ────────────────────────────────
|
||||
// A single AudioContext and WeakMap of captured elements survive across
|
||||
// component mounts/unmounts. This prevents:
|
||||
// 1. InvalidStateError from double-capturing the same <audio> element
|
||||
// 2. AudioContext leak (Chrome limits ~6 concurrent contexts)
|
||||
let sharedCtx: AudioContext | null = null;
|
||||
|
||||
const capturedElements = new WeakMap<
|
||||
HTMLAudioElement,
|
||||
{ source: MediaElementAudioSourceNode; analyser: AnalyserNode }
|
||||
>();
|
||||
|
||||
function getOrCreateConnection(
|
||||
audioElement: HTMLAudioElement,
|
||||
): AnalyserNode | null {
|
||||
if (!sharedCtx || sharedCtx.state === "closed") {
|
||||
sharedCtx = new AudioContext();
|
||||
}
|
||||
if (sharedCtx.state === "suspended") {
|
||||
sharedCtx.resume().catch(() => {});
|
||||
}
|
||||
|
||||
const existing = capturedElements.get(audioElement);
|
||||
if (existing) return existing.analyser;
|
||||
|
||||
try {
|
||||
const source = sharedCtx.createMediaElementSource(audioElement);
|
||||
const analyser = sharedCtx.createAnalyser();
|
||||
analyser.fftSize = 256;
|
||||
analyser.smoothingTimeConstant = 0.75;
|
||||
source.connect(analyser);
|
||||
analyser.connect(sharedCtx.destination);
|
||||
capturedElements.set(audioElement, { source, analyser });
|
||||
return analyser;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build bin ranges that spread bars across the voice-relevant spectrum.
|
||||
*
|
||||
* ATC audio is narrow-band voice (300–3 400 Hz). Icecast streams are
|
||||
* typically 8–16 kHz MP3 decoded to 44 100 Hz by the browser, so real
|
||||
* content lives in the lower ~20–25 % of FFT bins. We restrict mapping
|
||||
* to bins 1–maxBin (skip DC at bin 0) and distribute bars evenly so
|
||||
* every bar picks up voice energy.
|
||||
*/
|
||||
function buildBinRanges(
|
||||
binCount: number,
|
||||
barCount: number,
|
||||
): [number, number][] {
|
||||
// Only use the lower portion where voice/content actually lives
|
||||
// For 128 bins at 44100 Hz: bin 30 ≈ 5 160 Hz — covers voice + harmonics
|
||||
const maxBin = Math.min(Math.ceil(binCount * 0.25), binCount);
|
||||
const usable = maxBin - 1; // bins 1..maxBin
|
||||
const ranges: [number, number][] = [];
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const start = 1 + Math.floor((i / barCount) * usable);
|
||||
const end = 1 + Math.floor(((i + 1) / barCount) * usable);
|
||||
ranges.push([start, Math.max(end, start + 1)]);
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
/**
|
||||
* ElevenLabs-style audio-reactive waveform.
|
||||
*
|
||||
* Reads frequency data from a Web Audio AnalyserNode connected to
|
||||
* the given <audio> element, then draws smooth rounded bars on a
|
||||
* tiny canvas. When no signal is present the bars settle to their
|
||||
* minimum height with a dim tint.
|
||||
*/
|
||||
export function AtcWaveform({
|
||||
audioElement,
|
||||
active,
|
||||
}: {
|
||||
audioElement: HTMLAudioElement | null;
|
||||
active: boolean;
|
||||
}) {
|
||||
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));
|
||||
|
||||
// ── 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);
|
||||
|
||||
// Resume AudioContext when tab returns from background.
|
||||
function onVisibilityResume() {
|
||||
if (
|
||||
document.visibilityState === "visible" &&
|
||||
sharedCtx?.state === "suspended"
|
||||
) {
|
||||
sharedCtx.resume().catch(() => {});
|
||||
}
|
||||
}
|
||||
document.addEventListener("visibilitychange", onVisibilityResume);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", onVisibilityResume);
|
||||
};
|
||||
}, [active, audioElement]);
|
||||
|
||||
// ── Animation loop (always runs — idle or active) ────────────────
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
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);
|
||||
|
||||
// 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;
|
||||
|
||||
function draw() {
|
||||
rafRef.current = requestAnimationFrame(draw);
|
||||
|
||||
const now = performance.now();
|
||||
const analyser = analyserRef.current;
|
||||
const binCount = analyser?.frequencyBinCount ?? 128;
|
||||
|
||||
if (binCount !== lastBinCount) {
|
||||
dataArray = new Uint8Array(binCount) as Uint8Array<ArrayBuffer>;
|
||||
binRanges = buildBinRanges(binCount, BAR_COUNT);
|
||||
lastBinCount = binCount;
|
||||
}
|
||||
if (analyser && dataArray) analyser.getByteFrequencyData(dataArray);
|
||||
|
||||
draw2d!.clearRect(0, 0, CANVAS_W, CANVAS_H);
|
||||
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
// Average frequency bins in this bar's range
|
||||
const [startBin, endBin] = binRanges![i];
|
||||
let sum = 0;
|
||||
const count = endBin - startBin;
|
||||
for (let b = startBin; b < endBin; b++) {
|
||||
sum += dataArray![b];
|
||||
}
|
||||
const raw = analyser && count > 0 ? sum / count / 255 : 0;
|
||||
|
||||
// Idle breathing: gentle sine wave per bar when no signal
|
||||
const breathPhase = (now / 1200 + i * 0.35) % (Math.PI * 2);
|
||||
const breathVal = 0.08 + Math.sin(breathPhase) * 0.05;
|
||||
const target = raw > 0.02 ? raw : breathVal;
|
||||
|
||||
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 x = i * (BAR_WIDTH + BAR_GAP);
|
||||
const y = CANVAS_H - barH;
|
||||
|
||||
// Emerald when signal, dim white breathing 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!.beginPath();
|
||||
draw2d!.roundRect(x, y, BAR_WIDTH, barH, 1);
|
||||
draw2d!.fill();
|
||||
}
|
||||
}
|
||||
|
||||
draw();
|
||||
return () => cancelAnimationFrame(rafRef.current);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="h-7 shrink-0"
|
||||
style={{ width: `${CANVAS_W}px`, imageRendering: "auto" }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -56,6 +56,7 @@ function getRecents(): string[] {
|
||||
}
|
||||
return valid.map((e) => e.q);
|
||||
} catch {
|
||||
// localStorage unavailable or corrupted — return empty recent list
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@ -76,7 +77,7 @@ function addRecent(query: string) {
|
||||
const next = [{ q, ts: Date.now() }, ...filtered].slice(0, RECENT_MAX);
|
||||
localStorage.setItem(RECENT_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* quota exceeded — ignore */
|
||||
// localStorage unavailable or quota exceeded
|
||||
}
|
||||
}
|
||||
|
||||
@ -93,7 +94,7 @@ function removeRecent(query: string) {
|
||||
);
|
||||
localStorage.setItem(RECENT_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* ignore */
|
||||
// localStorage unavailable or corrupted
|
||||
}
|
||||
}
|
||||
|
||||
@ -101,7 +102,7 @@ function clearRecents() {
|
||||
try {
|
||||
localStorage.removeItem(RECENT_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
// localStorage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
@ -304,8 +305,6 @@ export function SearchContent({
|
||||
.slice(0, 15);
|
||||
}, [flights, compactQuery]);
|
||||
|
||||
const hasResults =
|
||||
featured.length > 0 || airports.length > 0 || flightMatches.length > 0;
|
||||
const showRecents = !query && recents.length > 0;
|
||||
|
||||
// Total result count for screen reader
|
||||
|
||||
@ -9,8 +9,16 @@ import {
|
||||
Palette,
|
||||
Globe,
|
||||
ArrowLeftRight,
|
||||
Shield,
|
||||
Flame,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import { useSettings, type OrbitDirection } from "@/hooks/use-settings";
|
||||
import {
|
||||
useSettings,
|
||||
AIRSPACE_OPACITY_MIN,
|
||||
AIRSPACE_OPACITY_MAX,
|
||||
type OrbitDirection,
|
||||
} from "@/hooks/use-settings";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { SHORTCUTS } from "@/components/ui/keyboard-shortcuts-help";
|
||||
@ -40,6 +48,9 @@ export function SettingsContent() {
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-0.5 p-3 pt-1">
|
||||
{/* ── Camera ── */}
|
||||
<SectionHeader title="Camera" />
|
||||
|
||||
<SettingRow
|
||||
icon={<RotateCw className="h-4 w-4" />}
|
||||
title="Auto-orbit"
|
||||
@ -64,7 +75,8 @@ export function SettingsContent() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mx-3 my-2 h-px bg-white/4" />
|
||||
{/* ── Visuals ── */}
|
||||
<SectionHeader title="Visuals" />
|
||||
|
||||
<SettingRow
|
||||
icon={<Route className="h-4 w-4" />}
|
||||
@ -100,7 +112,35 @@ export function SettingsContent() {
|
||||
onChange={(v) => update("showAltitudeColors", v)}
|
||||
/>
|
||||
|
||||
<div className="mx-3 my-2 h-px bg-white/4" />
|
||||
{/* ── Airspace ── */}
|
||||
<SectionHeader title="Airspace" />
|
||||
|
||||
<SettingRow
|
||||
icon={<Shield className="h-4 w-4" />}
|
||||
title="Airspace overlay"
|
||||
description="Show classified airspace boundaries (OpenAIP)"
|
||||
checked={settings.showAirspace}
|
||||
onChange={(v) => update("showAirspace", v)}
|
||||
/>
|
||||
|
||||
{settings.showAirspace && (
|
||||
<>
|
||||
<AirspaceOpacitySlider
|
||||
value={settings.airspaceOpacity}
|
||||
onChange={(v) => update("airspaceOpacity", v)}
|
||||
/>
|
||||
<SettingRow
|
||||
icon={<Flame className="h-4 w-4" />}
|
||||
title="Thermal hotspots"
|
||||
description="Glider & paraglider thermal activity areas"
|
||||
checked={settings.showAirspaceHotspots}
|
||||
onChange={(v) => update("showAirspaceHotspots", v)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Performance ── */}
|
||||
<SectionHeader title="Performance" />
|
||||
|
||||
<SettingRow
|
||||
icon={<Globe className="h-4 w-4" />}
|
||||
@ -290,6 +330,51 @@ function TrailDistanceSlider({
|
||||
);
|
||||
}
|
||||
|
||||
function AirspaceOpacitySlider({
|
||||
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-white/5 text-white/35 ring-1 ring-white/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">
|
||||
Airspace opacity
|
||||
</p>
|
||||
<span className="text-[11px] font-semibold text-white/40 tabular-nums">
|
||||
{Math.round(value * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={AIRSPACE_OPACITY_MIN}
|
||||
max={AIRSPACE_OPACITY_MAX}
|
||||
step={0.05}
|
||||
value={[value]}
|
||||
onValueChange={(vals) => onChange(vals[0])}
|
||||
aria-label="Airspace 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">
|
||||
{title}
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-white/4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingRow({
|
||||
icon,
|
||||
title,
|
||||
@ -410,6 +495,42 @@ function Toggle({ checked }: { checked: boolean }) {
|
||||
}
|
||||
|
||||
const CHANGELOG = [
|
||||
{
|
||||
date: "Mar 22",
|
||||
title: "3D aircraft models & smoother trails",
|
||||
description:
|
||||
"14 distinct 3D aircraft silhouettes assigned by ADS-B category and ICAO type code — from wide-bodies to helicopters. Models hosted on Cloudinary CDN with lazy loading and prefetch. Trail smoothing overhauled: 5-pass kernel filter, tighter corner rounding (15°), denser Catmull–Rom splines, and wider junction blending between historical and live data. Aircraft rendered 12% smaller for better proportions.",
|
||||
},
|
||||
{
|
||||
date: "Mar 22",
|
||||
title: "Multi-source flight data & circuit breaker",
|
||||
description:
|
||||
"Switched from OpenSky-only to a 2-tier fallback: adsb.lol → OpenSky (airplanes.live available via override). Each provider has its own parser normalising into a shared FlightState format. Circuit breaker tracks failures per provider and temporarily disables broken ones. Empty-response guard prevents data wipe-outs during transient failures, and an immediate re-fetch fires on network reconnect.",
|
||||
},
|
||||
{
|
||||
date: "Mar 22",
|
||||
title: "Code review fixes",
|
||||
description:
|
||||
"Fixed GPU memory monitor (duplicate WebGL enum cases, wrong byte sizes). Selection pulse halos now match aircraft height at all zoom levels. ATC stream properly cancels upstream on timeout. Airspace tile rate-limiter enforces spacing for queued requests. Photo fetch errors now surface to the UI. Spline cache clearing moved from useMemo to useEffect for React strict mode safety.",
|
||||
},
|
||||
{
|
||||
date: "Mar 21",
|
||||
title: "ATC feed lookup & GPU memory monitor",
|
||||
description:
|
||||
"New ATC lookup module — converts IATA to ICAO codes, finds nearby feeds by geographic proximity, and looks up feeds by airport or centre code. GPU memory monitor tracks WebGL resource allocations (textures, buffers, framebuffers) for debugging resource leaks.",
|
||||
},
|
||||
{
|
||||
date: "Mar 20",
|
||||
title: "Reliability & polish",
|
||||
description:
|
||||
"Serialised rate limiting in the flight API client. Logo cache with size limits and eviction. Registration country lookup via pre-built O(1) maps. Keyboard shortcuts focus trapping fix. SessionStorage guard for incognito mode. Airspace display toggle in map attribution. Utility functions extended with clamp().",
|
||||
},
|
||||
{
|
||||
date: "Mar 13",
|
||||
title: "Flight API client & rebase fixes",
|
||||
description:
|
||||
"New flight-api-client, flight-api-parsing, and flight-api-types modules. useFlights refactored to use the multi-source client — removed legacy credit management. useFlightMonitors switched to hex-based lookups. Fixed 6 files that diverged during rebase (IATA codes, globe mode ref, terrain attribution, cache eviction, OpenSky parsing).",
|
||||
},
|
||||
{
|
||||
date: "Mar 11",
|
||||
title: "Globe mode & aircraft photos",
|
||||
@ -459,8 +580,9 @@ export function AboutContent() {
|
||||
<div className="space-y-3 text-[13px] leading-relaxed text-white/40">
|
||||
<p>
|
||||
Live flight tracking in 3D. The planes you see are real — position
|
||||
data comes from the OpenSky Network, updated every few seconds via
|
||||
ADS-B receivers people run on their roofs worldwide.
|
||||
data comes from ADS-B Exchange, adsb.lol, and OpenSky Network,
|
||||
updated every few seconds via ADS-B receivers people run on their
|
||||
roofs worldwide.
|
||||
</p>
|
||||
<p>
|
||||
You can search through 9,000+ airports, jump into first-person view
|
||||
|
||||
@ -29,7 +29,7 @@ export function StyleContent({
|
||||
</div>
|
||||
<div className="border-t border-white/4 px-5 py-3">
|
||||
<p className="text-[11px] font-medium text-white/12">
|
||||
Satellite © Esri · Terrain © OpenTopoMap / Terrain Tiles · Base maps ©
|
||||
Satellite © Esri · Terrain © AWS/Mapzen Terrain Tiles · Base maps ©
|
||||
CARTO
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -195,7 +195,8 @@ function PanelDialog({
|
||||
|
||||
function trapFocus(e: KeyboardEvent) {
|
||||
if (e.key !== "Tab") return;
|
||||
const elements = dialog!.querySelectorAll<HTMLElement>(
|
||||
if (!dialog) return;
|
||||
const elements = dialog.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
const f = elements[0];
|
||||
@ -299,7 +300,7 @@ function PanelDialog({
|
||||
</a>
|
||||
<div className="border-t border-white/3 pt-2 px-2.5">
|
||||
<p className="text-[10px] font-medium text-white/10 tracking-wide">
|
||||
Powered by OpenSky Network
|
||||
Data from ADS-B Exchange, adsb.lol & OpenSky
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -30,6 +30,7 @@ import { aircraftTypeHint } from "@/lib/aircraft";
|
||||
import { airlineLogoCandidates } from "@/lib/airline-logos";
|
||||
import {
|
||||
loadedAirlineLogoUrls,
|
||||
trackAirlineLogoLoaded,
|
||||
markAirlineLogoFailed,
|
||||
wasAirlineLogoRecentlyFailed,
|
||||
} from "@/lib/logo-cache";
|
||||
@ -93,7 +94,7 @@ export function FlightCard({
|
||||
aircraft: photoAircraft,
|
||||
loading: photosLoading,
|
||||
error: photosError,
|
||||
} = useAircraftPhotos(flight?.icao24 ?? null);
|
||||
} = useAircraftPhotos(flight?.icao24 ?? null, flight?.registration);
|
||||
const heroPhoto = photos[0] ?? null;
|
||||
|
||||
return (
|
||||
@ -139,7 +140,7 @@ export function FlightCard({
|
||||
}`}
|
||||
unoptimized
|
||||
onLoad={() => {
|
||||
if (logoUrl) loadedAirlineLogoUrls.add(logoUrl);
|
||||
if (logoUrl) trackAirlineLogoLoaded(logoUrl);
|
||||
setLogoLoadedByKey((current) => ({
|
||||
...current,
|
||||
[logoLoadKey]: true,
|
||||
|
||||
@ -10,6 +10,7 @@ import { lookupAirline } from "@/lib/airlines";
|
||||
import { airlineLogoCandidates } from "@/lib/airline-logos";
|
||||
import {
|
||||
loadedAirlineLogoUrls,
|
||||
trackAirlineLogoLoaded,
|
||||
markAirlineLogoFailed,
|
||||
wasAirlineLogoRecentlyFailed,
|
||||
} from "@/lib/logo-cache";
|
||||
@ -232,7 +233,7 @@ export function FpvHud({ flight, onExit }: FpvHudProps) {
|
||||
className="relative object-contain p-1"
|
||||
unoptimized
|
||||
onLoad={() => {
|
||||
if (logoUrl) loadedAirlineLogoUrls.add(logoUrl);
|
||||
if (logoUrl) trackAirlineLogoLoaded(logoUrl);
|
||||
setLogoLoadedByKey((current) => ({
|
||||
...current,
|
||||
[logoLoadKey]: true,
|
||||
|
||||
@ -14,8 +14,12 @@ export function HeroBanner({ photo, loading }: HeroBannerProps) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLoaded(false);
|
||||
setFailed(false);
|
||||
// Reset load state when photo changes
|
||||
const reset = () => {
|
||||
setLoaded(false);
|
||||
setFailed(false);
|
||||
};
|
||||
reset();
|
||||
}, [photo?.id]);
|
||||
|
||||
const hasPhoto = photo != null && !failed;
|
||||
@ -48,7 +52,7 @@ export function HeroBanner({ photo, loading }: HeroBannerProps) {
|
||||
/>
|
||||
)}
|
||||
<img
|
||||
src={photo.thumbnail}
|
||||
src={photo.url}
|
||||
alt="Aircraft"
|
||||
onLoad={() => setLoaded(true)}
|
||||
onError={() => setFailed(true)}
|
||||
|
||||
@ -12,6 +12,7 @@ export const SHORTCUTS = [
|
||||
{ key: "⌘K", description: "Open search (anywhere)" },
|
||||
{ key: "F", description: "First person view" },
|
||||
{ key: "?", description: "Shortcuts help" },
|
||||
{ key: "A", description: "Toggle ATC panel" },
|
||||
{ key: "Esc", description: "Close / Deselect" },
|
||||
] as const;
|
||||
|
||||
@ -49,7 +50,8 @@ export function KeyboardShortcutsHelp({
|
||||
|
||||
function trapFocus(e: KeyboardEvent) {
|
||||
if (e.key !== "Tab") return;
|
||||
const elements = dialog!.querySelectorAll<HTMLElement>(
|
||||
if (!dialog) return;
|
||||
const elements = dialog.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
if (elements.length === 0) return;
|
||||
|
||||
@ -7,13 +7,14 @@ import { getAttributions, type AttributionEntry } from "@/lib/map-styles";
|
||||
|
||||
type MapAttributionProps = {
|
||||
styleId: string;
|
||||
showAirspace?: boolean;
|
||||
};
|
||||
|
||||
const SM_BREAKPOINT = 640;
|
||||
|
||||
export function MapAttribution({ styleId }: MapAttributionProps) {
|
||||
export function MapAttribution({ styleId, showAirspace }: MapAttributionProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const attributions = getAttributions(styleId);
|
||||
const attributions = getAttributions(styleId, { showAirspace });
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const toggle = useCallback(() => setExpanded((prev) => !prev), []);
|
||||
|
||||
547
src/components/ui/mobile-flight-toast.tsx
Normal file
547
src/components/ui/mobile-flight-toast.tsx
Normal file
@ -0,0 +1,547 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useEffect, useCallback, useRef } from "react";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Gauge,
|
||||
Compass,
|
||||
Eye,
|
||||
X,
|
||||
Building2,
|
||||
Globe,
|
||||
Navigation,
|
||||
Camera,
|
||||
ImageOff,
|
||||
Plane,
|
||||
} from "lucide-react";
|
||||
import { useAircraftPhotos } from "@/hooks/use-aircraft-photos";
|
||||
import type { FlightState } from "@/lib/opensky";
|
||||
import {
|
||||
metersToFeet,
|
||||
msToKnots,
|
||||
formatCallsign,
|
||||
headingToCardinal,
|
||||
} from "@/lib/flight-utils";
|
||||
import { lookupAirline, parseFlightNumber } from "@/lib/airlines";
|
||||
import { aircraftTypeHint } from "@/lib/aircraft";
|
||||
import { airlineLogoCandidates } from "@/lib/airline-logos";
|
||||
import {
|
||||
loadedAirlineLogoUrls,
|
||||
trackAirlineLogoLoaded,
|
||||
markAirlineLogoFailed,
|
||||
wasAirlineLogoRecentlyFailed,
|
||||
} from "@/lib/logo-cache";
|
||||
|
||||
type MobileFlightToastProps = {
|
||||
flight: FlightState;
|
||||
onClose: () => void;
|
||||
onToggleFpv?: (icao24: string) => void;
|
||||
isFpvActive?: boolean;
|
||||
};
|
||||
|
||||
const EMERGENCY_SQUAWKS = new Set(["7500", "7600", "7700"]);
|
||||
|
||||
function isEmergencySquawk(squawk: string | null): boolean {
|
||||
if (!squawk) return false;
|
||||
return EMERGENCY_SQUAWKS.has(squawk.trim());
|
||||
}
|
||||
|
||||
function squawkLabel(squawk: string): string {
|
||||
switch (squawk.trim()) {
|
||||
case "7500":
|
||||
return "Hijack";
|
||||
case "7600":
|
||||
return "Radio fail";
|
||||
case "7700":
|
||||
return "Emergency";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function MobileFlightToast({
|
||||
flight,
|
||||
onClose,
|
||||
onToggleFpv,
|
||||
isFpvActive = false,
|
||||
}: MobileFlightToastProps) {
|
||||
const airline = lookupAirline(flight.callsign);
|
||||
const flightNum = parseFlightNumber(flight.callsign);
|
||||
const company = airline ?? `${flight.originCountry} operator`;
|
||||
const model = aircraftTypeHint(flight.category);
|
||||
const heading = flight.trueTrack;
|
||||
const cardinal = heading !== null ? headingToCardinal(heading) : null;
|
||||
const canEnterFpv =
|
||||
flight.longitude != null && flight.latitude != null && !flight.onGround;
|
||||
|
||||
// ── Airline logo with fallback chain ──────────────────────────────
|
||||
const logoCandidates = airlineLogoCandidates(airline);
|
||||
const [logoIndexByAirline, setLogoIndexByAirline] = useState<
|
||||
Record<string, number>
|
||||
>({});
|
||||
const [logoLoadedByKey, setLogoLoadedByKey] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [genericLogoFailed, setGenericLogoFailed] = useState(false);
|
||||
|
||||
const airlineKey = airline ?? "__none__";
|
||||
const baseLogoIndex = logoIndexByAirline[airlineKey] ?? 0;
|
||||
const resolvedLogoIndex = useMemo(() => {
|
||||
let idx = baseLogoIndex;
|
||||
while (
|
||||
idx < logoCandidates.length &&
|
||||
wasAirlineLogoRecentlyFailed(logoCandidates[idx] ?? "")
|
||||
) {
|
||||
idx += 1;
|
||||
}
|
||||
return idx;
|
||||
}, [baseLogoIndex, logoCandidates]);
|
||||
|
||||
const logoLoadKey = `${airlineKey}:${resolvedLogoIndex}`;
|
||||
const logoUrl = logoCandidates[resolvedLogoIndex] ?? null;
|
||||
const logoLoaded =
|
||||
(logoUrl ? loadedAirlineLogoUrls.has(logoUrl) : false) ||
|
||||
(logoLoadedByKey[logoLoadKey] ?? false);
|
||||
const showLogo = Boolean(logoUrl);
|
||||
const genericLogoUrl = "/airline-logos/envoy-air.png";
|
||||
|
||||
// ── Aircraft photos & details ──────────────────────────────────────
|
||||
const {
|
||||
photos,
|
||||
aircraft: aircraftDetails,
|
||||
loading: photosLoading,
|
||||
} = useAircraftPhotos(flight.icao24, flight.registration);
|
||||
|
||||
// ── Photo carousel state ───────────────────────────────────────────
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [activeSlide, setActiveSlide] = useState(0);
|
||||
const [slideLoadState, setSlideLoadState] = useState<
|
||||
Record<number, "loaded" | "error">
|
||||
>({});
|
||||
// Progressive loading: only mount <img> for slides the user has reached
|
||||
const [mountedSlides, setMountedSlides] = useState<Set<number>>(
|
||||
() => new Set([0]),
|
||||
);
|
||||
|
||||
// Reset carousel when photos change (new aircraft)
|
||||
const photoKey = photos.map((p) => p.id).join(",");
|
||||
useEffect(() => {
|
||||
setActiveSlide(0);
|
||||
setSlideLoadState({});
|
||||
setMountedSlides(new Set([0]));
|
||||
if (scrollRef.current) scrollRef.current.scrollLeft = 0;
|
||||
}, [photoKey]);
|
||||
|
||||
// When the active slide changes, mount that slide's image
|
||||
useEffect(() => {
|
||||
setMountedSlides((prev) => {
|
||||
if (prev.has(activeSlide)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(activeSlide);
|
||||
return next;
|
||||
});
|
||||
}, [activeSlide]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el || el.clientWidth === 0) return;
|
||||
const idx = Math.round(el.scrollLeft / el.clientWidth);
|
||||
setActiveSlide(idx);
|
||||
}, []);
|
||||
|
||||
const handleSlideLoad = useCallback((index: number) => {
|
||||
setSlideLoadState((s) => ({ ...s, [index]: "loaded" }));
|
||||
}, []);
|
||||
|
||||
const handleSlideError = useCallback((index: number) => {
|
||||
setSlideLoadState((s) => ({ ...s, [index]: "error" }));
|
||||
}, []);
|
||||
|
||||
const hasPhotos = photos.length > 0;
|
||||
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">
|
||||
{/* Photo carousel / hero banner */}
|
||||
<div className="relative h-36 w-full overflow-hidden bg-white/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"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* No image placeholder */}
|
||||
{!photosLoading && !hasPhotos && (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-1 text-white/15">
|
||||
<ImageOff className="h-4 w-4" />
|
||||
<span className="text-[9px] font-medium">No photo</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Swipeable photo slider */}
|
||||
{showPhotos && (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex h-full snap-x snap-mandatory overflow-x-auto scrollbar-none"
|
||||
style={{ scrollSnapType: "x mandatory", scrollbarWidth: "none" }}
|
||||
>
|
||||
{photos.map((photo, i) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
className="relative h-full w-full shrink-0 snap-center"
|
||||
>
|
||||
{/* Show skeleton until this slide's image is loaded */}
|
||||
{slideLoadState[i] !== "loaded" &&
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
{slideLoadState[i] === "error" ? (
|
||||
<div className="flex h-full w-full items-center justify-center text-white/15">
|
||||
<ImageOff className="h-5 w-5" />
|
||||
</div>
|
||||
) : mountedSlides.has(i) ? (
|
||||
<img
|
||||
src={photo.url}
|
||||
alt={`Aircraft photo ${i + 1}`}
|
||||
decoding="async"
|
||||
onLoad={() => handleSlideLoad(i)}
|
||||
onError={() => handleSlideError(i)}
|
||||
className={`h-full w-full object-cover transition-opacity duration-300 ${
|
||||
slideLoadState[i] === "loaded"
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
}`}
|
||||
draggable={false}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gradient overlay */}
|
||||
{showPhotos && (
|
||||
<span className="pointer-events-none absolute inset-0 bg-linear-to-t from-black/40 via-black/5 to-transparent" />
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<Camera className="h-2 w-2" />
|
||||
{photos[activeSlide].photographer}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Dot indicators */}
|
||||
{showPhotos && photos.length > 1 && (
|
||||
<div className="absolute bottom-1.5 left-1/2 z-10 flex -translate-x-1/2 gap-1">
|
||||
{photos.slice(0, 10).map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`h-1 w-1 rounded-full transition-colors duration-200 ${
|
||||
i === activeSlide ? "bg-white/80" : "bg-white/30"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
{photos.length > 10 && (
|
||||
<span className="text-[7px] leading-none text-white/30">
|
||||
+{photos.length - 10}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
{activeSlide + 1}/{photos.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-3.5 pt-3">
|
||||
{/* 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">
|
||||
{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">
|
||||
{!logoLoaded && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 animate-pulse bg-linear-to-br from-white/85 via-neutral-200/65 to-white/80"
|
||||
/>
|
||||
)}
|
||||
<Image
|
||||
src={logoUrl ?? undefined}
|
||||
alt={company ? `${company} logo` : "Airline logo"}
|
||||
width={40}
|
||||
height={40}
|
||||
className={`relative h-8 w-8 object-contain transition-opacity duration-200 ${
|
||||
logoLoaded ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
unoptimized
|
||||
onLoad={() => {
|
||||
if (logoUrl) trackAirlineLogoLoaded(logoUrl);
|
||||
setLogoLoadedByKey((current) => ({
|
||||
...current,
|
||||
[logoLoadKey]: true,
|
||||
}));
|
||||
}}
|
||||
onError={() => {
|
||||
if (logoUrl) markAirlineLogoFailed(logoUrl);
|
||||
if (resolvedLogoIndex + 1 < logoCandidates.length) {
|
||||
setLogoIndexByAirline((current) => ({
|
||||
...current,
|
||||
[airlineKey]: resolvedLogoIndex + 1,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
setLogoIndexByAirline((current) => ({
|
||||
...current,
|
||||
[airlineKey]: logoCandidates.length,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</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">
|
||||
{genericLogoFailed ? (
|
||||
<span className="text-[16px] font-semibold text-black/25">
|
||||
—
|
||||
</span>
|
||||
) : (
|
||||
<Image
|
||||
src={genericLogoUrl}
|
||||
alt="Generic airline logo"
|
||||
width={40}
|
||||
height={40}
|
||||
className="h-8 w-8 object-contain grayscale opacity-80"
|
||||
unoptimized
|
||||
onError={() => setGenericLogoFailed(true)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Callsign + identifiers */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[15px] font-bold leading-tight text-white">
|
||||
{formatCallsign(flight.callsign)}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-[10px] font-medium tracking-widest text-white/30 uppercase">
|
||||
{flight.icao24}
|
||||
{flightNum ? ` · #${flightNum}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Close button */}
|
||||
<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"
|
||||
aria-label="Close flight details"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-white/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">
|
||||
{company}
|
||||
{model ? <span className="text-white/25"> · {model}</span> : null}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Aircraft details (registration, type, owner) */}
|
||||
{aircraftDetails &&
|
||||
(aircraftDetails.registration ||
|
||||
aircraftDetails.type ||
|
||||
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">
|
||||
{[
|
||||
aircraftDetails.registration,
|
||||
aircraftDetails.type ?? aircraftDetails.typeCode,
|
||||
aircraftDetails.owner,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metrics 4-column grid */}
|
||||
<div className="grid grid-cols-4 gap-px border-t border-white/5 bg-white/[0.02]">
|
||||
<MiniMetric
|
||||
icon={<ArrowUp className="h-2.5 w-2.5" />}
|
||||
label="ALT"
|
||||
value={metersToFeet(flight.baroAltitude)}
|
||||
/>
|
||||
<MiniMetric
|
||||
icon={<Gauge className="h-2.5 w-2.5" />}
|
||||
label="SPD"
|
||||
value={msToKnots(flight.velocity)}
|
||||
/>
|
||||
<MiniMetric
|
||||
icon={<Compass className="h-2.5 w-2.5" />}
|
||||
label="HDG"
|
||||
value={
|
||||
heading !== null && Number.isFinite(heading)
|
||||
? `${Math.round(heading)}° ${cardinal}`
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<MiniMetric
|
||||
icon={<ArrowDown className="h-2.5 w-2.5" />}
|
||||
label="V/S"
|
||||
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">
|
||||
{/* 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>
|
||||
</div>
|
||||
|
||||
{/* Heading direction + coordinates */}
|
||||
{cardinal && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Navigation
|
||||
className="h-3 w-3 text-white/25"
|
||||
style={{
|
||||
transform:
|
||||
heading !== null && Number.isFinite(heading)
|
||||
? `rotate(${heading}deg)`
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
<p className="text-[11px] text-white/40">
|
||||
Heading {cardinal}
|
||||
{flight.latitude !== null &&
|
||||
flight.longitude !== null &&
|
||||
Number.isFinite(flight.latitude) &&
|
||||
Number.isFinite(flight.longitude) && (
|
||||
<span className="text-white/20">
|
||||
{" "}
|
||||
· {Math.abs(flight.latitude).toFixed(2)}°
|
||||
{flight.latitude >= 0 ? "N" : "S"},{" "}
|
||||
{Math.abs(flight.longitude).toFixed(2)}°
|
||||
{flight.longitude >= 0 ? "E" : "W"}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Squawk code */}
|
||||
{flight.squawk && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`h-3 w-3 text-center text-[8px] font-bold leading-3 ${
|
||||
isEmergencySquawk(flight.squawk)
|
||||
? "text-red-400"
|
||||
: "text-white/25"
|
||||
}`}
|
||||
>
|
||||
SQ
|
||||
</span>
|
||||
<p
|
||||
className={`font-mono text-[11px] tabular-nums ${
|
||||
isEmergencySquawk(flight.squawk)
|
||||
? "text-red-400"
|
||||
: "text-white/40"
|
||||
}`}
|
||||
>
|
||||
{flight.squawk}
|
||||
{isEmergencySquawk(flight.squawk) && (
|
||||
<span className="ml-1.5 rounded bg-red-500/15 px-1.5 py-0.5 text-[9px] font-semibold tracking-wider text-red-400 uppercase">
|
||||
{squawkLabel(flight.squawk)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* FPV button */}
|
||||
{onToggleFpv && (
|
||||
<div className="border-t border-white/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 ${
|
||||
!isFpvActive && !canEnterFpv
|
||||
? "cursor-not-allowed opacity-30"
|
||||
: ""
|
||||
}`}
|
||||
aria-label={
|
||||
isFpvActive ? "Exit first person view" : "Enter first person view"
|
||||
}
|
||||
>
|
||||
<Eye
|
||||
className={`h-3 w-3 ${isFpvActive ? "text-emerald-400" : "text-white/30"}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-[10px] font-semibold tracking-wider uppercase ${
|
||||
isFpvActive ? "text-emerald-400/70" : "text-white/35"
|
||||
}`}
|
||||
>
|
||||
{isFpvActive ? "Exit FPV" : "First Person View"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniMetric({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-0.5 py-2.5">
|
||||
<div className="flex items-center gap-1 text-white/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">
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
340
src/components/ui/provider-panel.tsx
Normal file
340
src/components/ui/provider-panel.tsx
Normal file
@ -0,0 +1,340 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useCallback } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { Satellite, X, ChevronUp, Circle } from "lucide-react";
|
||||
import {
|
||||
getCircuitState,
|
||||
getProviderOverride,
|
||||
type CircuitState,
|
||||
} from "@/lib/flight-api-client";
|
||||
import type { ProviderName } from "@/lib/flight-api";
|
||||
import { useDropdownDismiss } from "@/hooks/use-dropdown-dismiss";
|
||||
|
||||
// ── Provider definitions ───────────────────────────────────────────────
|
||||
|
||||
interface ProviderInfo {
|
||||
id: ProviderName;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const PROVIDERS: ProviderInfo[] = [
|
||||
{ id: "adsb", label: "adsb.lol", description: "Primary — server proxy" },
|
||||
{
|
||||
id: "opensky",
|
||||
label: "OpenSky",
|
||||
description: "Fallback — limited credits",
|
||||
},
|
||||
{
|
||||
id: "airplanes",
|
||||
label: "Airplanes.live",
|
||||
description: "Direct — CORS restricted",
|
||||
},
|
||||
];
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
adsb: "adsb.lol",
|
||||
opensky: "OpenSky",
|
||||
airplanes: "Airplanes.live",
|
||||
none: "Unavailable",
|
||||
};
|
||||
|
||||
const SOURCE_COLORS: Record<string, string> = {
|
||||
adsb: "rgb(52, 211, 153)", // emerald
|
||||
opensky: "rgb(251, 191, 36)", // amber
|
||||
airplanes: "rgb(96, 165, 250)", // blue
|
||||
none: "rgb(248, 113, 113)", // red
|
||||
};
|
||||
|
||||
function circuitBadge(
|
||||
state: CircuitState,
|
||||
cooldownMs: number,
|
||||
): { label: string; color: string } {
|
||||
switch (state) {
|
||||
case "closed":
|
||||
return { label: "OK", color: "rgb(52, 211, 153)" };
|
||||
case "open":
|
||||
return {
|
||||
label: `DOWN ${Math.ceil(cooldownMs / 1000)}s`,
|
||||
color: "rgb(248, 113, 113)",
|
||||
};
|
||||
case "half-open":
|
||||
return { label: "PROBING", color: "rgb(251, 191, 36)" };
|
||||
}
|
||||
}
|
||||
|
||||
function setProviderOverride(provider: ProviderName | "auto"): void {
|
||||
const url = new URL(window.location.href);
|
||||
if (provider === "auto") {
|
||||
url.searchParams.delete("provider");
|
||||
} else {
|
||||
url.searchParams.set("provider", provider);
|
||||
}
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
}
|
||||
|
||||
// ── Provider Dropdown ──────────────────────────────────────────────────
|
||||
|
||||
export type ProviderDropdownProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
currentSource: string | null;
|
||||
};
|
||||
|
||||
export function ProviderDropdown({
|
||||
open,
|
||||
onClose,
|
||||
currentSource,
|
||||
}: ProviderDropdownProps) {
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
useDropdownDismiss(dropdownRef, open, onClose);
|
||||
|
||||
const [override, setOverride] = useState(() => getProviderOverride());
|
||||
const isAutoMode = override === "auto";
|
||||
const isDev =
|
||||
typeof window !== "undefined" &&
|
||||
(window.location.hostname === "localhost" ||
|
||||
window.location.hostname === "127.0.0.1");
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(provider: ProviderName | "auto") => {
|
||||
setProviderOverride(provider);
|
||||
setOverride(provider === "auto" ? "auto" : provider);
|
||||
onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
ref={dropdownRef}
|
||||
initial={{ opacity: 0, y: 8, scale: 0.97 }}
|
||||
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"
|
||||
style={{
|
||||
borderColor: "rgb(var(--ui-fg) / 0.08)",
|
||||
backgroundColor: "rgb(var(--ui-bg) / 0.75)",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-2.5"
|
||||
style={{ borderBottom: "1px solid rgb(var(--ui-fg) / 0.06)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Satellite className="h-3 w-3 text-emerald-400/70" />
|
||||
<span
|
||||
className="text-[10px] font-semibold tracking-widest uppercase"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.35)" }}
|
||||
>
|
||||
Providers
|
||||
</span>
|
||||
</div>
|
||||
<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"
|
||||
aria-label="Close provider selector"
|
||||
>
|
||||
<X
|
||||
className="h-3 w-3"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.3)" }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Provider list */}
|
||||
<div className="py-1">
|
||||
{/* Auto option */}
|
||||
<button
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
<Circle
|
||||
className="h-2.5 w-2.5"
|
||||
style={{
|
||||
color: isAutoMode
|
||||
? "rgb(52, 211, 153)"
|
||||
: "rgb(var(--ui-fg) / 0.2)",
|
||||
}}
|
||||
fill={isAutoMode ? "rgb(52, 211, 153)" : "transparent"}
|
||||
/>
|
||||
</div>
|
||||
<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: isAutoMode
|
||||
? "rgb(var(--ui-fg) / 0.85)"
|
||||
: "rgb(var(--ui-fg) / 0.55)",
|
||||
}}
|
||||
>
|
||||
Auto
|
||||
</span>
|
||||
<span
|
||||
className="text-[9px] leading-snug"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.25)" }}
|
||||
>
|
||||
Uses best available
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="shrink-0 rounded px-1.5 py-px text-[8px] font-bold tracking-wider"
|
||||
style={{
|
||||
backgroundColor: "rgb(52, 211, 153, 0.07)",
|
||||
color: "rgb(52, 211, 153)",
|
||||
}}
|
||||
>
|
||||
REC
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Individual providers */}
|
||||
{PROVIDERS.map((provider) => {
|
||||
const isSelected = override === provider.id;
|
||||
const isActive = currentSource === provider.id;
|
||||
const circuit = getCircuitState(provider.id);
|
||||
const badge = circuitBadge(
|
||||
circuit.state,
|
||||
circuit.cooldownRemaining,
|
||||
);
|
||||
const isAvailable = provider.id !== "airplanes" || isDev;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={provider.id}
|
||||
type="button"
|
||||
onClick={() => isAvailable && handleSelect(provider.id)}
|
||||
disabled={!isAvailable}
|
||||
className={`group flex w-full items-center gap-2.5 px-3.5 py-2 transition-colors ${
|
||||
isSelected
|
||||
? "bg-white/6"
|
||||
: isAvailable
|
||||
? "hover:bg-white/3 active:bg-white/6"
|
||||
: "cursor-not-allowed opacity-40"
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
<Circle
|
||||
className="h-2.5 w-2.5"
|
||||
style={{
|
||||
color: isActive
|
||||
? (SOURCE_COLORS[provider.id] ??
|
||||
"rgb(var(--ui-fg) / 0.2)")
|
||||
: isSelected
|
||||
? "rgb(var(--ui-fg) / 0.5)"
|
||||
: "rgb(var(--ui-fg) / 0.2)",
|
||||
}}
|
||||
fill={
|
||||
isActive
|
||||
? (SOURCE_COLORS[provider.id] ?? "transparent")
|
||||
: "transparent"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<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: isActive
|
||||
? "rgb(var(--ui-fg) / 0.85)"
|
||||
: "rgb(var(--ui-fg) / 0.55)",
|
||||
}}
|
||||
>
|
||||
{provider.label}
|
||||
</span>
|
||||
<span
|
||||
className="text-[9px] leading-snug"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.25)" }}
|
||||
>
|
||||
{!isAvailable
|
||||
? "CORS restricted — dev only"
|
||||
: provider.description}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="shrink-0 rounded px-1.5 py-px text-[8px] font-bold tracking-wider"
|
||||
style={{
|
||||
backgroundColor: `${badge.color}12`,
|
||||
color: isAvailable
|
||||
? badge.color
|
||||
: "rgb(var(--ui-fg) / 0.25)",
|
||||
}}
|
||||
>
|
||||
{isAvailable ? badge.label : "CORS"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Provider Trigger (for status bar) ──────────────────────────────────
|
||||
|
||||
export type ProviderTriggerProps = {
|
||||
source: string | null;
|
||||
loading: boolean;
|
||||
rateLimited: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export function ProviderTrigger({
|
||||
source,
|
||||
loading,
|
||||
rateLimited,
|
||||
onClick,
|
||||
}: ProviderTriggerProps) {
|
||||
const label = rateLimited
|
||||
? "Paused"
|
||||
: loading && !source
|
||||
? "Connecting…"
|
||||
: source
|
||||
? (SOURCE_LABELS[source] ?? source)
|
||||
: "Connecting…";
|
||||
|
||||
const dotColor = rateLimited
|
||||
? "text-amber-400/80"
|
||||
: source === "none"
|
||||
? "text-red-400/80"
|
||||
: source === "opensky"
|
||||
? "text-amber-400/80"
|
||||
: source === "airplanes"
|
||||
? "text-blue-400/80"
|
||||
: "text-emerald-400/80";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex items-center gap-2"
|
||||
aria-label="Select ADS-B provider"
|
||||
>
|
||||
<div className="relative">
|
||||
<Satellite className={`h-3 w-3 ${dotColor}`} />
|
||||
</div>
|
||||
<span
|
||||
className="text-[11px] font-medium tracking-wide"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.4)" }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<ChevronUp
|
||||
className="h-3 w-3 transition-colors"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.35)" }}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@ -55,6 +55,7 @@ export const ScrollArea = forwardRef<HTMLDivElement, ScrollAreaProps>(
|
||||
return () => {
|
||||
vp.removeEventListener("scroll", onScroll);
|
||||
observer.disconnect();
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
};
|
||||
}, [updateThumb]);
|
||||
|
||||
|
||||
@ -1,31 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { Dices, Plane, Radio, ShieldAlert } from "lucide-react";
|
||||
import { Dices, Plane, ShieldAlert } from "lucide-react";
|
||||
import {
|
||||
AtcTrigger,
|
||||
AtcFeedDropdown,
|
||||
useAvailableFeeds,
|
||||
} from "@/components/ui/atc-panel";
|
||||
import {
|
||||
ProviderTrigger,
|
||||
ProviderDropdown,
|
||||
} from "@/components/ui/provider-panel";
|
||||
import type { UseAtcStreamReturn } from "@/hooks/use-atc-stream";
|
||||
|
||||
type StatusBarProps = {
|
||||
flightCount: number;
|
||||
cityName: string;
|
||||
cityIata: string;
|
||||
cityCoordinates: [number, number];
|
||||
loading: boolean;
|
||||
rateLimited?: boolean;
|
||||
retryIn?: number;
|
||||
onNorthUp?: () => void;
|
||||
onResetView?: () => void;
|
||||
onRandomAirport?: () => void;
|
||||
atc: UseAtcStreamReturn;
|
||||
/** Incremented externally to toggle the feed dropdown (e.g. from keyboard shortcut) */
|
||||
atcToggle?: number;
|
||||
/** Current ADS-B data source (e.g. "adsb", "opensky", "none") */
|
||||
source?: string | null;
|
||||
};
|
||||
|
||||
export function StatusBar({
|
||||
flightCount,
|
||||
cityName,
|
||||
cityIata,
|
||||
cityCoordinates,
|
||||
loading,
|
||||
rateLimited = false,
|
||||
retryIn = 0,
|
||||
onNorthUp,
|
||||
onResetView,
|
||||
onRandomAirport,
|
||||
atc,
|
||||
atcToggle,
|
||||
source,
|
||||
}: StatusBarProps) {
|
||||
const [feedDropdownOpen, setFeedDropdownOpen] = useState(false);
|
||||
const [providerDropdownOpen, setProviderDropdownOpen] = useState(false);
|
||||
const availableFeeds = useAvailableFeeds(cityIata, cityCoordinates);
|
||||
const prevToggleRef = useRef(atcToggle);
|
||||
|
||||
// React to external toggle (keyboard shortcut)
|
||||
useEffect(() => {
|
||||
if (atcToggle !== undefined && atcToggle !== prevToggleRef.current) {
|
||||
prevToggleRef.current = atcToggle;
|
||||
setFeedDropdownOpen((p) => !p);
|
||||
}
|
||||
}, [atcToggle]);
|
||||
|
||||
const toggleFeedDropdown = useCallback(() => {
|
||||
setProviderDropdownOpen(false);
|
||||
setFeedDropdownOpen((p) => !p);
|
||||
}, []);
|
||||
|
||||
const closeFeedDropdown = useCallback(() => {
|
||||
setFeedDropdownOpen(false);
|
||||
}, []);
|
||||
|
||||
const toggleProviderDropdown = useCallback(() => {
|
||||
setFeedDropdownOpen(false);
|
||||
setProviderDropdownOpen((p) => !p);
|
||||
}, []);
|
||||
|
||||
const closeProviderDropdown = useCallback(() => {
|
||||
setProviderDropdownOpen(false);
|
||||
}, []);
|
||||
|
||||
const isAtcPlaying = atc.status === "playing";
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<div className="relative flex flex-col items-start gap-2">
|
||||
<AnimatePresence>
|
||||
{rateLimited && (
|
||||
<motion.div
|
||||
@ -70,19 +125,12 @@ export function StatusBar({
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Radio
|
||||
className={`h-3 w-3 ${rateLimited ? "text-amber-400/80" : "text-emerald-400/80"}`}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className="text-[11px] font-medium tracking-wide"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.4)" }}
|
||||
>
|
||||
{rateLimited ? "Paused" : loading ? "Scanning..." : "Live"}
|
||||
</span>
|
||||
</div>
|
||||
<ProviderTrigger
|
||||
source={source ?? null}
|
||||
loading={loading}
|
||||
rateLimited={rateLimited}
|
||||
onClick={toggleProviderDropdown}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="h-3 w-px"
|
||||
@ -113,6 +161,14 @@ export function StatusBar({
|
||||
>
|
||||
{cityName}
|
||||
</span>
|
||||
|
||||
{/* ATC trigger */}
|
||||
<AtcTrigger
|
||||
hasFeeds={availableFeeds.length > 0}
|
||||
isPlaying={isAtcPlaying}
|
||||
isError={atc.status === "error" || atc.status === "blocked"}
|
||||
onClick={toggleFeedDropdown}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
@ -176,6 +232,19 @@ export function StatusBar({
|
||||
</button>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Dropdowns — positioned above entire status bar */}
|
||||
<ProviderDropdown
|
||||
open={providerDropdownOpen}
|
||||
onClose={closeProviderDropdown}
|
||||
currentSource={source ?? null}
|
||||
/>
|
||||
<AtcFeedDropdown
|
||||
feeds={availableFeeds}
|
||||
atc={atc}
|
||||
open={feedDropdownOpen}
|
||||
onClose={closeFeedDropdown}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user