feat: add random airport selection feature; update status bar with new button and styling
This commit is contained in:
@ -4,9 +4,16 @@ Real-time 3D flight tracking — altitude-aware, visually stunning.
|
|||||||
|
|
||||||
Aeris renders live air traffic over the world's busiest airspaces on a premium dark-mode map. Flights are separated by altitude in true 3D: low altitudes glow cyan, high altitudes shift to gold. Select a city, and the camera glides to that airspace with spring-eased animation.
|
Aeris renders live air traffic over the world's busiest airspaces on a premium dark-mode map. Flights are separated by altitude in true 3D: low altitudes glow cyan, high altitudes shift to gold. Select a city, and the camera glides to that airspace with spring-eased animation.
|
||||||
|
|
||||||
**[Live Demo](https://aeris.edbn.me)**
|
[Live Demo](https://aeris.edbn.me)
|
||||||
|
|
||||||
|
|
||||||
|
<img width="2559" height="1380" alt="Screenshot 2026-02-15 112222" src="https://github.com/user-attachments/assets/9d1f50ed-be4e-4ef5-95ac-257e9129f8c8" />
|
||||||
|
|
||||||
|
|
||||||
<img width="2555" height="1387" alt="image" src="https://github.com/user-attachments/assets/a1d2f673-dfdc-4c82-8ee2-7629d91ad94b" />
|
<img width="2555" height="1387" alt="image" src="https://github.com/user-attachments/assets/a1d2f673-dfdc-4c82-8ee2-7629d91ad94b" />
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
| Layer | Technology |
|
| Layer | Technology |
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import { useFlights } from "@/hooks/use-flights";
|
|||||||
import { useTrailHistory } from "@/hooks/use-trail-history";
|
import { useTrailHistory } from "@/hooks/use-trail-history";
|
||||||
import { MAP_STYLES, DEFAULT_STYLE, type MapStyle } from "@/lib/map-styles";
|
import { MAP_STYLES, DEFAULT_STYLE, type MapStyle } from "@/lib/map-styles";
|
||||||
import { CITIES, type City } from "@/lib/cities";
|
import { CITIES, type City } from "@/lib/cities";
|
||||||
import { findByIata, airportToCity } from "@/lib/airports";
|
import { AIRPORTS, findByIata, airportToCity } from "@/lib/airports";
|
||||||
import type { FlightState } from "@/lib/opensky";
|
import type { FlightState } from "@/lib/opensky";
|
||||||
import type { PickingInfo } from "@deck.gl/core";
|
import type { PickingInfo } from "@deck.gl/core";
|
||||||
import { Github, Star } from "lucide-react";
|
import { Github, Star } from "lucide-react";
|
||||||
@ -26,6 +26,33 @@ const STYLE_STORAGE_KEY = "aeris:mapStyle";
|
|||||||
const DEFAULT_CITY = CITIES.find((c) => c.id === DEFAULT_CITY_ID) ?? CITIES[0];
|
const DEFAULT_CITY = CITIES.find((c) => c.id === DEFAULT_CITY_ID) ?? CITIES[0];
|
||||||
const GITHUB_REPO_URL = "https://github.com/kewonit/aeris";
|
const GITHUB_REPO_URL = "https://github.com/kewonit/aeris";
|
||||||
const GITHUB_REPO_API = "https://api.github.com/repos/kewonit/aeris";
|
const GITHUB_REPO_API = "https://api.github.com/repos/kewonit/aeris";
|
||||||
|
const HIGH_TRAFFIC_IATA = [
|
||||||
|
"ATL",
|
||||||
|
"DXB",
|
||||||
|
"LHR",
|
||||||
|
"HND",
|
||||||
|
"DFW",
|
||||||
|
"DEN",
|
||||||
|
"IST",
|
||||||
|
"LAX",
|
||||||
|
"CDG",
|
||||||
|
"AMS",
|
||||||
|
"FRA",
|
||||||
|
"MAD",
|
||||||
|
"JFK",
|
||||||
|
"SIN",
|
||||||
|
"ORD",
|
||||||
|
"SFO",
|
||||||
|
"MIA",
|
||||||
|
"LAS",
|
||||||
|
"MUC",
|
||||||
|
"CLT",
|
||||||
|
] as const;
|
||||||
|
const HUB_PICK_PROBABILITY = 0.75;
|
||||||
|
const HIGH_TRAFFIC_IATA_SET = new Set<string>(HIGH_TRAFFIC_IATA);
|
||||||
|
const HIGH_TRAFFIC_AIRPORTS = AIRPORTS.filter((airport) =>
|
||||||
|
HIGH_TRAFFIC_IATA_SET.has(airport.iata.toUpperCase()),
|
||||||
|
);
|
||||||
|
|
||||||
const subscribeNoop = () => () => {};
|
const subscribeNoop = () => () => {};
|
||||||
|
|
||||||
@ -93,6 +120,31 @@ function saveMapStyle(style: MapStyle): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chooseRandom<T>(items: readonly T[]): T | null {
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
return items[Math.floor(Math.random() * items.length)] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickRandomAirportCity(excludeIata?: string): City {
|
||||||
|
const exclude = excludeIata?.toUpperCase();
|
||||||
|
const filteredHubs = exclude
|
||||||
|
? HIGH_TRAFFIC_AIRPORTS.filter(
|
||||||
|
(airport) => airport.iata.toUpperCase() !== exclude,
|
||||||
|
)
|
||||||
|
: HIGH_TRAFFIC_AIRPORTS;
|
||||||
|
|
||||||
|
const filteredAirports = exclude
|
||||||
|
? AIRPORTS.filter((airport) => airport.iata.toUpperCase() !== exclude)
|
||||||
|
: AIRPORTS;
|
||||||
|
|
||||||
|
const useHubs =
|
||||||
|
filteredHubs.length > 0 && Math.random() < HUB_PICK_PROBABILITY;
|
||||||
|
const source = useHubs ? filteredHubs : filteredAirports;
|
||||||
|
const randomAirport = chooseRandom(source);
|
||||||
|
if (!randomAirport) return DEFAULT_CITY;
|
||||||
|
return airportToCity(randomAirport);
|
||||||
|
}
|
||||||
|
|
||||||
function FlightTrackerInner() {
|
function FlightTrackerInner() {
|
||||||
const hydratedCity = useSyncExternalStore(
|
const hydratedCity = useSyncExternalStore(
|
||||||
subscribeNoop,
|
subscribeNoop,
|
||||||
@ -176,6 +228,11 @@ function FlightTrackerInner() {
|
|||||||
);
|
);
|
||||||
}, [activeCity.coordinates]);
|
}, [activeCity.coordinates]);
|
||||||
|
|
||||||
|
const handleRandomAirport = useCallback(() => {
|
||||||
|
const randomCity = pickRandomAirportCity(activeCity.iata);
|
||||||
|
setActiveCity(randomCity);
|
||||||
|
}, [activeCity.iata, setActiveCity]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="relative h-dvh w-screen overflow-hidden bg-black">
|
<main className="relative h-dvh w-screen overflow-hidden bg-black">
|
||||||
<Map mapStyle={mapStyle.style} isDark={mapStyle.dark}>
|
<Map mapStyle={mapStyle.style} isDark={mapStyle.dark}>
|
||||||
@ -259,6 +316,7 @@ function FlightTrackerInner() {
|
|||||||
retryIn={retryIn}
|
retryIn={retryIn}
|
||||||
onNorthUp={handleNorthUp}
|
onNorthUp={handleNorthUp}
|
||||||
onResetView={handleResetView}
|
onResetView={handleResetView}
|
||||||
|
onRandomAirport={handleRandomAirport}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion, AnimatePresence } from "motion/react";
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
import { Compass, Plane, Radio, ShieldAlert } from "lucide-react";
|
import { Compass, Dices, Plane, Radio, ShieldAlert } from "lucide-react";
|
||||||
|
|
||||||
type StatusBarProps = {
|
type StatusBarProps = {
|
||||||
flightCount: number;
|
flightCount: number;
|
||||||
@ -11,6 +11,7 @@ type StatusBarProps = {
|
|||||||
retryIn?: number;
|
retryIn?: number;
|
||||||
onNorthUp?: () => void;
|
onNorthUp?: () => void;
|
||||||
onResetView?: () => void;
|
onResetView?: () => void;
|
||||||
|
onRandomAirport?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function StatusBar({
|
export function StatusBar({
|
||||||
@ -21,6 +22,7 @@ export function StatusBar({
|
|||||||
retryIn = 0,
|
retryIn = 0,
|
||||||
onNorthUp,
|
onNorthUp,
|
||||||
onResetView,
|
onResetView,
|
||||||
|
onRandomAirport,
|
||||||
}: StatusBarProps) {
|
}: StatusBarProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-start gap-2">
|
<div className="flex flex-col items-start gap-2">
|
||||||
@ -122,7 +124,7 @@ export function StatusBar({
|
|||||||
damping: 24,
|
damping: 24,
|
||||||
delay: 0.48,
|
delay: 0.48,
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-1.5 rounded-xl border px-2.5 py-2 backdrop-blur-2xl"
|
className="flex items-center gap-3 rounded-xl border px-3.5 py-2 backdrop-blur-2xl"
|
||||||
style={{
|
style={{
|
||||||
borderColor: "rgb(var(--ui-fg) / 0.06)",
|
borderColor: "rgb(var(--ui-fg) / 0.06)",
|
||||||
backgroundColor: "rgb(var(--ui-bg) / 0.5)",
|
backgroundColor: "rgb(var(--ui-bg) / 0.5)",
|
||||||
@ -150,6 +152,21 @@ export function StatusBar({
|
|||||||
>
|
>
|
||||||
Reset
|
Reset
|
||||||
</button>
|
</button>
|
||||||
|
<div
|
||||||
|
className="h-3 w-px"
|
||||||
|
style={{ backgroundColor: "rgb(var(--ui-fg) / 0.08)" }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRandomAirport}
|
||||||
|
aria-label="Random airport"
|
||||||
|
title="Random airport"
|
||||||
|
className="inline-flex items-center gap-1 text-[11px] font-medium tracking-wide transition-colors"
|
||||||
|
style={{ color: "rgb(var(--ui-fg) / 0.55)" }}
|
||||||
|
>
|
||||||
|
<Dices className="h-3 w-3" />
|
||||||
|
Random
|
||||||
|
</button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user