* 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:
@ -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