feat: keyboard shortcuts, click-to-select, pulse/glow, smooth orbit resume (#4)

* feat: keyboard shortcuts, click-to-select, pulse/glow, smooth orbit resume

* feat: add camera controls and enhance keyboard shortcuts help; improve flight card accessibility

* feat: enhance flight layers and keyboard shortcuts; improve airline data structure
This commit is contained in:
kew
2026-02-15 21:50:48 +05:30
committed by GitHub
parent 709b73cbbb
commit 06956f8b59
9 changed files with 1166 additions and 55 deletions

View File

@ -1,16 +1,27 @@
"use client";
import { useState, useCallback, useEffect, useSyncExternalStore } from "react";
import {
useState,
useCallback,
useEffect,
useMemo,
useRef,
useSyncExternalStore,
} from "react";
import { motion } from "motion/react";
import { ErrorBoundary } from "@/components/error-boundary";
import { Map } from "@/components/map/map";
import { CameraController } from "@/components/map/camera-controller";
import { AirportLayer } from "@/components/map/airport-layer";
import { FlightLayers } from "@/components/map/flight-layers";
import { FlightCard } from "@/components/ui/flight-card";
import { KeyboardShortcutsHelp } from "@/components/ui/keyboard-shortcuts-help";
import { ControlPanel } from "@/components/ui/control-panel";
import { AltitudeLegend } from "@/components/ui/altitude-legend";
import { CameraControls } from "@/components/ui/camera-controls";
import { StatusBar } from "@/components/ui/status-bar";
import { SettingsProvider, useSettings } from "@/hooks/use-settings";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { useFlights } from "@/hooks/use-flights";
import { useTrailHistory } from "@/hooks/use-trail-history";
import { MAP_STYLES, DEFAULT_STYLE, type MapStyle } from "@/lib/map-styles";
@ -18,7 +29,7 @@ import { CITIES, type City } from "@/lib/cities";
import { AIRPORTS, findByIata, airportToCity } from "@/lib/airports";
import type { FlightState } from "@/lib/opensky";
import type { PickingInfo } from "@deck.gl/core";
import { Github, Star } from "lucide-react";
import { Github, Star, Keyboard } from "lucide-react";
const DEFAULT_CITY_ID = "sfo";
const STYLE_STORAGE_KEY = "aeris:mapStyle";
@ -159,12 +170,17 @@ function FlightTrackerInner() {
const [cityOverride, setCityOverride] = useState<City | undefined>();
const [styleOverride, setStyleOverride] = useState<MapStyle | undefined>();
const [selectedIcao24, setSelectedIcao24] = useState<string | null>(null);
const [showHelp, setShowHelp] = useState(false);
const [repoStars, setRepoStars] = useState<number | null>(null);
const activeCity = cityOverride ?? hydratedCity;
const mapStyle = styleOverride ?? hydratedStyle;
const { settings } = useSettings();
const { settings, update } = useSettings();
const setActiveCity = useCallback((city: City) => {
setCityOverride(city);
setSelectedIcao24(null);
syncCityToUrl(city);
}, []);
@ -174,9 +190,44 @@ function FlightTrackerInner() {
}, []);
const { flights, loading, rateLimited, retryIn } = useFlights(activeCity);
const trails = useTrailHistory(flights);
const [hoveredFlight, setHoveredFlight] = useState<FlightState | null>(null);
const [cursorPos, setCursorPos] = useState({ x: 0, y: 0 });
const [repoStars, setRepoStars] = useState<number | null>(null);
const selectedFlight = useMemo(() => {
if (!selectedIcao24) return null;
return flights.find((f) => f.icao24 === selectedIcao24) ?? null;
}, [selectedIcao24, flights]);
const lastKnownFlightRef = useRef<FlightState | null>(null);
useEffect(() => {
if (selectedFlight) lastKnownFlightRef.current = selectedFlight;
if (!selectedIcao24) lastKnownFlightRef.current = null;
}, [selectedFlight, selectedIcao24]);
// Safe: ref only changes in the effect above, which runs after state-driven re-renders.
const displayFlight =
// eslint-disable-next-line react-hooks/refs
selectedFlight ?? (selectedIcao24 ? lastKnownFlightRef.current : null);
const missingSinceRef = useRef<number | null>(null);
useEffect(() => {
if (!selectedIcao24) {
missingSinceRef.current = null;
return;
}
if (selectedFlight) {
missingSinceRef.current = null;
return;
}
// Flight is selected but not in the current flights list.
const now = Date.now();
if (missingSinceRef.current == null) {
missingSinceRef.current = now;
return;
}
if (now - missingSinceRef.current >= 30_000) {
setSelectedIcao24(null);
missingSinceRef.current = null;
}
}, [selectedIcao24, selectedFlight, flights]);
useEffect(() => {
let mounted = true;
@ -200,20 +251,18 @@ function FlightTrackerInner() {
};
}, []);
const handleHover = useCallback((info: PickingInfo<FlightState> | null) => {
const handleClick = useCallback((info: PickingInfo<FlightState> | null) => {
if (info?.object) {
setHoveredFlight(info.object);
setCursorPos({ x: info.x ?? 0, y: info.y ?? 0 });
setSelectedIcao24((prev) =>
prev === info.object!.icao24 ? null : info.object!.icao24,
);
} else {
setHoveredFlight(null);
setSelectedIcao24(null);
}
}, []);
const handleClick = useCallback((info: PickingInfo<FlightState> | null) => {
if (info?.object) {
setHoveredFlight(info.object);
setCursorPos({ x: info.x ?? 0, y: info.y ?? 0 });
}
const handleDeselectFlight = useCallback(() => {
setSelectedIcao24(null);
}, []);
const handleNorthUp = useCallback(() => {
@ -233,6 +282,27 @@ function FlightTrackerInner() {
setActiveCity(randomCity);
}, [activeCity.iata, setActiveCity]);
const handleToggleOrbit = useCallback(() => {
update("autoOrbit", !settings.autoOrbit);
}, [settings.autoOrbit, update]);
const handleOpenSearch = useCallback(() => {
window.dispatchEvent(new CustomEvent("aeris:open-search"));
}, []);
const handleToggleHelp = useCallback(() => {
setShowHelp((prev) => !prev);
}, []);
useKeyboardShortcuts({
onNorthUp: handleNorthUp,
onResetView: handleResetView,
onToggleOrbit: handleToggleOrbit,
onOpenSearch: handleOpenSearch,
onToggleHelp: handleToggleHelp,
onDeselect: handleDeselectFlight,
});
return (
<main className="relative h-dvh w-screen overflow-hidden bg-black">
<Map mapStyle={mapStyle.style} isDark={mapStyle.dark}>
@ -245,8 +315,8 @@ function FlightTrackerInner() {
<FlightLayers
flights={flights}
trails={trails}
onHover={handleHover}
onClick={handleClick}
selectedIcao24={selectedIcao24}
showTrails={settings.showTrails}
trailThickness={settings.trailThickness}
trailDistance={settings.trailDistance}
@ -263,7 +333,27 @@ function FlightTrackerInner() {
<Brand isDark={mapStyle.dark} />
</div>
<div className="pointer-events-auto absolute left-3 top-14 sm:left-4 sm:top-16">
<FlightCard flight={displayFlight} onClose={handleDeselectFlight} />
</div>
<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">
<motion.button
onClick={handleToggleHelp}
className="hidden h-9 w-9 items-center justify-center rounded-xl backdrop-blur-2xl transition-colors sm:flex"
style={{
borderWidth: 1,
borderColor: "rgb(var(--ui-fg) / 0.06)",
backgroundColor: "rgb(var(--ui-fg) / 0.03)",
color: "rgb(var(--ui-fg) / 0.5)",
}}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
aria-label="Keyboard shortcuts"
title="Keyboard shortcuts (?)"
>
<Keyboard className="h-4 w-4" />
</motion.button>
<a
href={GITHUB_REPO_URL}
target="_blank"
@ -320,12 +410,16 @@ function FlightTrackerInner() {
/>
</div>
<div className="pointer-events-auto absolute bottom-[env(safe-area-inset-bottom,0px)] right-3 mb-3 sm:bottom-4 sm:right-4 sm:mb-0">
<div className="pointer-events-auto 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">
<CameraControls />
<AltitudeLegend />
</div>
</div>
<FlightCard flight={hoveredFlight} x={cursorPos.x} y={cursorPos.y} />
<KeyboardShortcutsHelp
open={showHelp}
onClose={() => setShowHelp(false)}
/>
</main>
);
}

View File

@ -6,10 +6,26 @@ import { useSettings } from "@/hooks/use-settings";
import type { City } from "@/lib/cities";
const IDLE_TIMEOUT_MS = 5_000;
const ORBIT_EASE_IN_MS = 2000;
const DEFAULT_ZOOM = 9.2;
const DEFAULT_PITCH = 49;
const DEFAULT_BEARING = 27.4;
const CAMERA_ACCEL = 2.5;
const CAMERA_DECEL = 4.0;
const ZOOM_SPEED = 1.2;
const PITCH_SPEED = 28;
const BEARING_SPEED = 55;
const MINIMUM_IMPULSE_DURATION_MS = 180;
type CameraActionType = "zoom" | "pitch" | "bearing";
type ActionState = {
direction: number;
velocity: number;
held: boolean;
impulseEnd: number;
};
export function CameraController({ city }: { city: City }) {
const { map, isLoaded } = useMap();
const { settings } = useSettings();
@ -66,6 +82,124 @@ export function CameraController({ city }: { city: City }) {
};
}, [map, isLoaded, city]);
useEffect(() => {
if (!map || !isLoaded) return;
const actions = new Map<CameraActionType, ActionState>();
let frameId: number | null = null;
let lastTime = 0;
function getOrCreate(
type: CameraActionType,
direction: number,
): ActionState {
let s = actions.get(type);
if (!s) {
s = { direction, velocity: 0, held: false, impulseEnd: 0 };
actions.set(type, s);
}
return s;
}
function maxSpeed(type: CameraActionType): number {
if (type === "zoom") return ZOOM_SPEED;
if (type === "pitch") return PITCH_SPEED;
return BEARING_SPEED;
}
function applyDelta(type: CameraActionType, delta: number) {
if (type === "zoom") {
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()));
} else {
map!.setBearing(map!.getBearing() + delta);
}
}
function tick(now: number) {
const dt = lastTime ? Math.min((now - lastTime) / 1000, 0.1) : 0.016;
lastTime = now;
let anyActive = false;
for (const [type, state] of actions) {
const wantSpeed = state.held || now < state.impulseEnd;
if (wantSpeed) {
state.velocity = Math.min(
state.velocity + CAMERA_ACCEL * dt * maxSpeed(type),
maxSpeed(type),
);
} else {
state.velocity = Math.max(
state.velocity - CAMERA_DECEL * dt * maxSpeed(type),
0,
);
}
if (state.velocity > 0.001) {
applyDelta(type, state.direction * state.velocity * dt);
anyActive = true;
} else {
state.velocity = 0;
if (!state.held) {
actions.delete(type);
if (type === "bearing") {
isInteractingRef.current = false;
}
}
}
}
frameId = anyActive ? requestAnimationFrame(tick) : null;
}
function ensureLoop() {
if (frameId == null) {
lastTime = 0;
frameId = requestAnimationFrame(tick);
}
}
const onStart = (e: Event) => {
const { type, direction } = (e as CustomEvent).detail as {
type: CameraActionType;
direction: number;
};
const state = getOrCreate(type, direction);
state.direction = direction;
state.held = true;
state.impulseEnd = performance.now() + MINIMUM_IMPULSE_DURATION_MS;
if (type === "bearing") {
isInteractingRef.current = true;
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
}
ensureLoop();
};
const onStop = (e: Event) => {
const { type } = (e as CustomEvent).detail as { type: CameraActionType };
const state = actions.get(type);
if (state) state.held = false;
};
window.addEventListener("aeris:camera-start", onStart);
window.addEventListener("aeris:camera-stop", onStop);
return () => {
window.removeEventListener("aeris:camera-start", onStart);
window.removeEventListener("aeris:camera-stop", onStop);
if (frameId != null) cancelAnimationFrame(frameId);
};
}, [map, isLoaded]);
useEffect(() => {
if (!map || !isLoaded || !city || !settings.autoOrbit) {
if (orbitFrameRef.current) cancelAnimationFrame(orbitFrameRef.current);
@ -84,9 +218,14 @@ export function CameraController({ city }: { city: City }) {
function startOrbit() {
if (!map || isInteractingRef.current) return;
const resumeStart = performance.now();
function tick() {
if (!map || isInteractingRef.current) return;
const bearing = map.getBearing() + speed;
const resumeElapsed = performance.now() - resumeStart;
const t = Math.min(resumeElapsed / ORBIT_EASE_IN_MS, 1);
const easeFactor = t * t * (3 - 2 * t);
const bearing = map.getBearing() + speed * easeFactor;
map.setBearing(bearing % 360);
orbitFrameRef.current = requestAnimationFrame(tick);
}
@ -123,6 +262,18 @@ export function CameraController({ city }: { city: City }) {
};
map.on("movestart", onMoveStart);
const onCameraStop = (e: Event) => {
const { type } = (e as CustomEvent).detail ?? {};
if (type === "bearing") {
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
idleTimerRef.current = setTimeout(() => {
isInteractingRef.current = false;
startOrbit();
}, IDLE_TIMEOUT_MS);
}
};
window.addEventListener("aeris:camera-stop", onCameraStop);
idleTimerRef.current = setTimeout(() => {
isInteractingRef.current = false;
startOrbit();
@ -133,6 +284,7 @@ export function CameraController({ city }: { city: City }) {
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
events.forEach((e) => container.removeEventListener(e, resetIdleTimer));
map.off("movestart", onMoveStart);
window.removeEventListener("aeris:camera-stop", onCameraStop);
};
}, [
map,

View File

@ -1,6 +1,7 @@
"use client";
import { useEffect, useRef, useCallback } from "react";
import maplibregl from "maplibre-gl";
import { MapboxOverlay } from "@deck.gl/mapbox";
import { IconLayer, PathLayer } from "@deck.gl/layers";
import { ScenegraphLayer } from "@deck.gl/mesh-layers";
@ -10,6 +11,15 @@ import type { FlightState } from "@/lib/opensky";
import { type TrailEntry } from "@/hooks/use-trail-history";
import type { PickingInfo } from "@deck.gl/core";
/** Typed overlay with deck.gl's pickObject capability */
type DeckGLOverlay = MapboxOverlay & {
pickObject?(opts: {
x: number;
y: number;
radius: number;
}): PickingInfo | null;
};
const DEFAULT_ANIM_DURATION_MS = 30_000;
const MIN_ANIM_DURATION_MS = 8_000;
const MAX_ANIM_DURATION_MS = 45_000;
@ -22,6 +32,103 @@ const TRAIL_SMOOTHING_ITERATIONS = 3;
const AIRCRAFT_SCENEGRAPH_URL = "/models/airplane.glb";
const AIRCRAFT_PX_PER_UNIT = 0.3;
const PULSE_PERIOD_MS = 7000;
const RING_PERIOD_MS = 5500;
function createHaloAtlas(): HTMLCanvasElement {
const size = 256;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d")!;
ctx.clearRect(0, 0, size, size);
const c = size / 2;
for (let r = 0; r < c; r++) {
const norm = r / c;
let alpha = 0;
if (norm < 0.18) {
alpha = 0;
} else if (norm < 0.35) {
const t = (norm - 0.18) / 0.17;
alpha = t * t * 0.7;
} else if (norm < 0.55) {
alpha = 0.7 - ((norm - 0.35) / 0.2) * 0.3;
} else {
const t = (norm - 0.55) / 0.45;
alpha = 0.4 * (1 - t) * (1 - t);
}
if (alpha < 0.003) continue;
ctx.strokeStyle = `rgba(255,255,255,${alpha})`;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(c, c, r, 0, Math.PI * 2);
ctx.stroke();
}
return canvas;
}
function createSoftRingAtlas(): HTMLCanvasElement {
const size = 256;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d")!;
ctx.clearRect(0, 0, size, size);
const c = size / 2;
const ringCenter = c * 0.75;
const ringWidth = c * 0.18;
for (let r = 0; r < c; r++) {
const dist = Math.abs(r - ringCenter);
const falloff = Math.max(0, 1 - (dist / ringWidth) ** 2);
const alpha = falloff * 0.85;
if (alpha < 0.005) continue;
ctx.strokeStyle = `rgba(255,255,255,${alpha})`;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(c, c, r, 0, Math.PI * 2);
ctx.stroke();
}
return canvas;
}
const HALO_MAPPING = {
halo: {
x: 0,
y: 0,
width: 256,
height: 256,
anchorX: 128,
anchorY: 128,
mask: true,
},
};
const RING_MAPPING = {
ring: {
x: 0,
y: 0,
width: 256,
height: 256,
anchorX: 128,
anchorY: 128,
mask: true,
},
};
let _haloCache: string | undefined;
function getHaloUrl(): string {
if (typeof document === "undefined") return "";
if (!_haloCache) _haloCache = createHaloAtlas().toDataURL();
return _haloCache;
}
let _ringCache: string | undefined;
function getRingUrl(): string {
if (typeof document === "undefined") return "";
if (!_ringCache) _ringCache = createSoftRingAtlas().toDataURL();
return _ringCache;
}
function buildStartupFallbackTrail(f: FlightState): [number, number][] {
if (f.longitude == null || f.latitude == null) return [];
@ -274,8 +381,8 @@ function getAircraftAtlasUrl(): string {
type FlightLayerProps = {
flights: FlightState[];
trails: TrailEntry[];
onHover: (info: PickingInfo<FlightState> | null) => void;
onClick: (info: PickingInfo<FlightState> | null) => void;
selectedIcao24: string | null;
showTrails: boolean;
trailThickness: number;
trailDistance: number;
@ -286,8 +393,8 @@ type FlightLayerProps = {
export function FlightLayers({
flights,
trails,
onHover,
onClick,
selectedIcao24,
showTrails,
trailThickness,
trailDistance,
@ -297,6 +404,8 @@ export function FlightLayers({
const { map, isLoaded } = useMap();
const overlayRef = useRef<MapboxOverlay | null>(null);
const atlasUrl = getAircraftAtlasUrl();
const haloUrl = getHaloUrl();
const ringUrl = getRingUrl();
const prevSnapshotsRef = useRef<Map<string, Snapshot>>(new Map());
const currSnapshotsRef = useRef<Map<string, Snapshot>>(new Map());
@ -311,6 +420,10 @@ export function FlightLayers({
const trailDistanceRef = useRef(trailDistance);
const showShadowsRef = useRef(showShadows);
const showAltColorsRef = useRef(showAltitudeColors);
const selectedIcao24Ref = useRef(selectedIcao24);
const prevSelectedRef = useRef<string | null>(null);
const selectionChangeTimeRef = useRef(0);
const SELECTION_FADE_MS = 600;
useEffect(() => {
flightsRef.current = flights;
@ -320,7 +433,21 @@ export function FlightLayers({
trailDistanceRef.current = trailDistance;
showShadowsRef.current = showShadows;
showAltColorsRef.current = showAltitudeColors;
});
if (selectedIcao24 !== selectedIcao24Ref.current) {
prevSelectedRef.current = selectedIcao24Ref.current;
selectionChangeTimeRef.current = performance.now();
}
selectedIcao24Ref.current = selectedIcao24;
}, [
flights,
trails,
showTrails,
trailThickness,
trailDistance,
showShadows,
showAltitudeColors,
selectedIcao24,
]);
useEffect(() => {
const elapsed = performance.now() - dataTimestampRef.current;
@ -383,11 +510,20 @@ export function FlightLayers({
const handleHover = useCallback(
(info: PickingInfo<FlightState>) => {
onHover(info.object ? info : null);
const canvas = map?.getCanvas();
if (canvas) canvas.style.cursor = info.object ? "pointer" : "";
},
[onHover],
[map],
);
// Reset cursor if component unmounts while hovering.
useEffect(() => {
return () => {
const canvas = map?.getCanvas();
if (canvas) canvas.style.cursor = "";
};
}, [map]);
const handleClick = useCallback(
(info: PickingInfo<FlightState>) => {
if (info.object) onClick(info);
@ -395,6 +531,31 @@ export function FlightLayers({
[onClick],
);
useEffect(() => {
if (!map || !isLoaded) return;
function onMapClick(e: maplibregl.MapMouseEvent) {
const overlay = overlayRef.current;
if (!overlay) {
onClick(null);
return;
}
const picked = (overlay as unknown as DeckGLOverlay).pickObject?.({
x: e.point.x,
y: e.point.y,
radius: 10,
});
if (!picked?.object) {
onClick(null);
}
}
map.on("click", onMapClick);
return () => {
map.off("click", onMapClick);
};
}, [map, isLoaded, onClick]);
useEffect(() => {
if (!map || !isLoaded) return;
@ -725,6 +886,94 @@ export function FlightLayers({
);
}
const smoothstep = (t: number) => t * t * (3 - 2 * t);
const easeOutQuint = (t: number) => 1 - (1 - t) ** 5;
const fadeElapsed = performance.now() - selectionChangeTimeRef.current;
const fadeT = Math.min(fadeElapsed / SELECTION_FADE_MS, 1);
const fadeIn = smoothstep(fadeT);
const fadeOut = 1 - fadeIn;
const selectedId = selectedIcao24Ref.current;
const prevId = prevSelectedRef.current;
const pulseTargets: { id: string; opacity: number; prefix: string }[] =
[];
if (selectedId)
pulseTargets.push({ id: selectedId, opacity: fadeIn, prefix: "sel" });
if (prevId && prevId !== selectedId && fadeOut > 0.01) {
pulseTargets.push({ id: prevId, opacity: fadeOut, prefix: "prev" });
} else if (fadeT >= 1) {
prevSelectedRef.current = null;
}
for (const target of pulseTargets) {
const flight = interpolated.find((f) => f.icao24 === target.id);
if (!flight || flight.longitude == null || flight.latitude == null)
continue;
const pos: [number, number, number] = [
flight.longitude,
flight.latitude,
altitudeToElevation(flight.baroAltitude),
];
const op = target.opacity;
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);
if (haloAlpha > 0) {
layers.push(
new IconLayer({
id: `${target.prefix}-halo`,
data: [{ position: pos }],
getPosition: (d: { position: [number, number, number] }) =>
d.position,
getIcon: () => "halo",
getSize: haloSize,
getColor: [70, 160, 240, haloAlpha],
iconAtlas: haloUrl,
iconMapping: HALO_MAPPING,
billboard: true,
sizeUnits: "pixels",
sizeScale: 1,
}),
);
}
const ringOffsets = [0, RING_PERIOD_MS / 3, (RING_PERIOD_MS * 2) / 3];
ringOffsets.forEach((offset, i) => {
const t = ((elapsed + offset) % RING_PERIOD_MS) / RING_PERIOD_MS;
const eased = easeOutQuint(t);
const ringSize = 30 + 60 * eased;
const fade = 1 - t;
const ringAlpha = Math.round(70 * fade * fade * fade * fade * op);
if (ringAlpha < 2) return;
layers.push(
new IconLayer({
id: `${target.prefix}-ring-${i}`,
data: [{ position: pos }],
getPosition: (d: { position: [number, number, number] }) =>
d.position,
getIcon: () => "ring",
getSize: ringSize,
getColor: [70, 165, 235, ringAlpha],
iconAtlas: ringUrl,
iconMapping: RING_MAPPING,
billboard: true,
sizeUnits: "pixels",
sizeScale: 1,
}),
);
});
}
layers.push(
new ScenegraphLayer<FlightState>({
id: "flight-aircraft",
@ -764,7 +1013,7 @@ export function FlightLayers({
buildAndPushLayers();
return () => cancelAnimationFrame(animFrameRef.current);
}, [atlasUrl, handleHover, handleClick, map]);
}, [atlasUrl, haloUrl, ringUrl, handleHover, handleClick, map]);
return null;
}

View File

@ -0,0 +1,175 @@
"use client";
import { useCallback, useRef, useEffect } from "react";
import { motion } from "motion/react";
import {
Plus,
Minus,
ChevronsUp,
ChevronsDown,
RotateCw,
RotateCcw,
} from "lucide-react";
type CameraActionType = "zoom" | "pitch" | "bearing";
function dispatchCameraStart(type: CameraActionType, direction: number) {
window.dispatchEvent(
new CustomEvent("aeris:camera-start", { detail: { type, direction } }),
);
}
function dispatchCameraStop(type: CameraActionType) {
window.dispatchEvent(
new CustomEvent("aeris:camera-stop", { detail: { type } }),
);
}
function useCameraAction(type: CameraActionType, direction: number) {
const activeRef = useRef(false);
const start = useCallback(() => {
if (activeRef.current) return;
activeRef.current = true;
dispatchCameraStart(type, direction);
}, [type, direction]);
const stop = useCallback(() => {
if (!activeRef.current) return;
activeRef.current = false;
dispatchCameraStop(type);
}, [type]);
useEffect(
() => () => {
if (activeRef.current) dispatchCameraStop(type);
},
[type],
);
return { onPointerDown: start, onPointerUp: stop, onPointerLeave: stop };
}
function ControlButton({
type,
direction,
label,
title,
children,
}: {
type: CameraActionType;
direction: number;
label: string;
title: string;
children: React.ReactNode;
}) {
const handlers = useCameraAction(type, direction);
return (
<motion.button
type="button"
className="flex h-8 w-8 items-center justify-center select-none"
style={{ color: "rgb(var(--ui-fg) / 0.45)" }}
whileHover={{ scale: 1.12 }}
whileTap={{ scale: 0.88 }}
aria-label={label}
title={title}
onPointerDown={handlers.onPointerDown}
onPointerUp={handlers.onPointerUp}
onPointerLeave={handlers.onPointerLeave}
onContextMenu={(e) => e.preventDefault()}
>
{children}
</motion.button>
);
}
function Divider() {
return (
<div
className="mx-auto h-px w-4"
style={{ backgroundColor: "rgb(var(--ui-fg) / 0.06)" }}
/>
);
}
export function CameraControls() {
return (
<motion.div
initial={{ opacity: 0, x: 12 }}
animate={{ opacity: 1, x: 0 }}
transition={{
type: "spring",
stiffness: 300,
damping: 24,
delay: 0.55,
}}
className="flex flex-col items-center rounded-xl border backdrop-blur-2xl"
style={{
borderColor: "rgb(var(--ui-fg) / 0.06)",
backgroundColor: "rgb(var(--ui-bg) / 0.5)",
}}
role="toolbar"
aria-label="Camera controls"
>
<ControlButton type="zoom" direction={1} label="Zoom in" title="Zoom in">
<Plus className="h-3.5 w-3.5" />
</ControlButton>
<Divider />
<ControlButton
type="zoom"
direction={-1}
label="Zoom out"
title="Zoom out"
>
<Minus className="h-3.5 w-3.5" />
</ControlButton>
<div
className="mx-auto my-0.5 h-px w-6"
style={{ backgroundColor: "rgb(var(--ui-fg) / 0.10)" }}
/>
<ControlButton
type="pitch"
direction={-1}
label="Tilt up"
title="Tilt up (flatter view)"
>
<ChevronsUp className="h-3.5 w-3.5" />
</ControlButton>
<Divider />
<ControlButton
type="pitch"
direction={1}
label="Tilt down"
title="Tilt down (more 3D)"
>
<ChevronsDown className="h-3.5 w-3.5" />
</ControlButton>
<div
className="mx-auto my-0.5 h-px w-6"
style={{ backgroundColor: "rgb(var(--ui-fg) / 0.10)" }}
/>
<ControlButton
type="bearing"
direction={1}
label="Rotate clockwise"
title="Rotate clockwise"
>
<RotateCw className="h-3.5 w-3.5" />
</ControlButton>
<Divider />
<ControlButton
type="bearing"
direction={-1}
label="Rotate counter-clockwise"
title="Rotate counter-clockwise"
>
<RotateCcw className="h-3.5 w-3.5" />
</ControlButton>
</motion.div>
);
}

View File

@ -48,6 +48,15 @@ export function ControlPanel({
}: ControlPanelProps) {
const [openTab, setOpenTab] = useState<TabId | null>(null);
useEffect(() => {
function handleOpenSearch() {
setOpenTab("search");
}
window.addEventListener("aeris:open-search", handleOpenSearch);
return () =>
window.removeEventListener("aeris:open-search", handleOpenSearch);
}, []);
const open = (tab: TabId) => setOpenTab(tab);
const close = () => setOpenTab(null);

View File

@ -1,7 +1,17 @@
"use client";
import { motion, AnimatePresence } from "motion/react";
import { Plane, ArrowUp, ArrowDown, Gauge, Compass, Globe } from "lucide-react";
import {
Plane,
ArrowUp,
ArrowDown,
Gauge,
Compass,
Globe,
X,
Navigation,
Building2,
} from "lucide-react";
import type { FlightState } from "@/lib/opensky";
import {
metersToFeet,
@ -9,40 +19,44 @@ import {
formatCallsign,
headingToCardinal,
} from "@/lib/flight-utils";
import { lookupAirline, parseFlightNumber } from "@/lib/airlines";
type FlightCardProps = {
flight: FlightState | null;
x: number;
y: number;
onClose: () => void;
};
export function FlightCard({ flight, x, y }: FlightCardProps) {
export function FlightCard({ flight, onClose }: FlightCardProps) {
const airline = flight ? lookupAirline(flight.callsign) : null;
const flightNum = flight ? parseFlightNumber(flight.callsign) : null;
const heading = flight?.trueTrack ?? null;
const cardinal = heading !== null ? headingToCardinal(heading) : null;
return (
<AnimatePresence>
<AnimatePresence mode="wait">
{flight && (
<motion.div
initial={{ opacity: 0, scale: 0.92, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.92, y: 8 }}
key={flight.icao24}
initial={{ opacity: 0, x: -16, scale: 0.96 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: -16, scale: 0.96 }}
transition={{
type: "spring",
stiffness: 400,
damping: 28,
mass: 0.8,
}}
className="pointer-events-none fixed z-50 w-64 sm:w-72"
role="status"
className="w-64 sm:w-72"
role="complementary"
aria-label="Selected flight details"
aria-live="polite"
style={{
left: `clamp(8px, ${x + 16}px, calc(100vw - 272px))`,
top: `clamp(8px, ${y - 8}px, calc(100vh - 280px))`,
}}
>
<div className="rounded-2xl border border-white/8 bg-black/60 p-4 shadow-2xl shadow-black/40 backdrop-blur-2xl">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-white/6">
<Plane className="h-4 w-4 text-white/80" />
<div className="relative flex h-8 w-8 items-center justify-center rounded-lg bg-sky-500/10">
<Plane className="h-4 w-4 text-sky-400/80" />
<span className="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-sky-400 shadow-[0_0_6px_rgba(56,189,248,0.6)]" />
</div>
<div>
<p className="text-sm font-semibold tracking-wide text-white">
@ -50,17 +64,33 @@ export function FlightCard({ flight, x, y }: FlightCardProps) {
</p>
<p className="text-[11px] font-medium tracking-wider text-white/40 uppercase">
{flight.icao24}
{flightNum ? ` · #${flightNum}` : ""}
</p>
</div>
</div>
<span className="rounded-full bg-emerald-500/10 px-2.5 py-0.5 text-[10px] font-semibold tracking-wider text-emerald-400 uppercase">
Live
</span>
<motion.button
onClick={onClose}
className="flex h-6 w-6 items-center justify-center rounded-full bg-white/6 transition-colors hover:bg-white/12"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
aria-label="Deselect flight"
>
<X className="h-3 w-3 text-white/40" />
</motion.button>
</div>
<div className="mt-4 h-px bg-linear-to-r from-transparent via-white/6 to-transparent" />
{airline && (
<div className="mt-2.5 flex items-center gap-1.5">
<Building2 className="h-3 w-3 text-white/25" />
<p className="text-[11px] font-semibold tracking-wide text-white/55">
{airline}
</p>
</div>
)}
<div className="mt-3.5 grid grid-cols-2 gap-3">
<div className="mt-3 h-px bg-linear-to-r from-transparent via-white/6 to-transparent" />
<div className="mt-3 grid grid-cols-2 gap-3">
<Metric
icon={<ArrowUp className="h-3 w-3" />}
label="Altitude"
@ -75,9 +105,7 @@ export function FlightCard({ flight, x, y }: FlightCardProps) {
icon={<Compass className="h-3 w-3" />}
label="Heading"
value={
flight.trueTrack !== null
? `${Math.round(flight.trueTrack)}° ${headingToCardinal(flight.trueTrack)}`
: "—"
heading !== null ? `${Math.round(heading)}° ${cardinal}` : "—"
}
/>
<Metric
@ -91,14 +119,66 @@ export function FlightCard({ flight, x, y }: FlightCardProps) {
/>
</div>
<div className="mt-3.5 h-px bg-linear-to-r from-transparent via-white/6 to-transparent" />
<div className="mt-3 h-px bg-linear-to-r from-transparent via-white/6 to-transparent" />
<div className="mt-3 flex items-center gap-1.5">
<Globe className="h-3 w-3 text-white/30" />
<div className="mt-2.5 flex flex-col gap-1.5">
<div className="flex items-center gap-1.5">
<Globe className="h-3 w-3 text-white/25" />
<p className="text-[11px] font-medium tracking-wide text-white/40">
{flight.originCountry}
</p>
</div>
{cardinal && (
<div className="flex items-center gap-1.5">
<Navigation
className="h-3 w-3 text-white/25"
style={{
transform:
heading !== null ? `rotate(${heading}deg)` : undefined,
}}
/>
<p className="text-[11px] font-medium tracking-wide text-white/40">
Heading {cardinal}
{flight.latitude !== null && flight.longitude !== null && (
<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>
)}
{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] font-medium tracking-wide ${
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>
</div>
</motion.div>
)}
@ -106,6 +186,26 @@ export function FlightCard({ flight, x, y }: FlightCardProps) {
);
}
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 "";
}
}
function Metric({
icon,
label,

View File

@ -0,0 +1,145 @@
"use client";
import { useEffect, useRef } from "react";
import { motion, AnimatePresence } from "motion/react";
import { X, Keyboard } from "lucide-react";
const SHORTCUTS = [
{ key: "N", description: "North up" },
{ key: "R", description: "Reset view" },
{ key: "O", description: "Toggle orbit" },
{ key: "/", description: "Open search" },
{ key: "?", description: "Shortcuts help" },
{ key: "Esc", description: "Close / Deselect" },
] as const;
type KeyboardShortcutsHelpProps = {
open: boolean;
onClose: () => void;
};
export function KeyboardShortcutsHelp({
open,
onClose,
}: KeyboardShortcutsHelpProps) {
const dialogRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handleKey(e: KeyboardEvent) {
if (e.key === "Escape") {
onClose();
}
}
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [open, onClose]);
useEffect(() => {
if (!open) return;
const dialog = dialogRef.current;
if (!dialog) return;
const focusable = dialog.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
if (focusable.length > 0) focusable[0].focus();
function trapFocus(e: KeyboardEvent) {
if (e.key !== "Tab") return;
const elements = dialog!.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
if (elements.length === 0) return;
const first = elements[0];
const last = elements[elements.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
dialog.addEventListener("keydown", trapFocus);
return () => dialog.removeEventListener("keydown", trapFocus);
}, [open]);
return (
<AnimatePresence>
{open && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-80 bg-black/60 backdrop-blur-md"
onClick={onClose}
/>
<motion.div
initial={{ opacity: 0, scale: 0.92, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.92, y: 20 }}
transition={{
type: "spring",
stiffness: 400,
damping: 30,
mass: 0.8,
}}
className="fixed left-1/2 top-1/2 z-90 w-72 -translate-x-1/2 -translate-y-1/2"
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label="Keyboard shortcuts"
>
<div className="overflow-hidden rounded-2xl border border-white/8 bg-[#0c0c0e]/95 shadow-[0_40px_100px_rgba(0,0,0,0.8)] backdrop-blur-3xl">
<div className="flex items-center justify-between px-5 pt-5 pb-3">
<div className="flex items-center gap-2.5">
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-white/6">
<Keyboard className="h-3.5 w-3.5 text-white/50" />
</div>
<h2 className="text-[14px] font-semibold tracking-tight text-white/90">
Keyboard Shortcuts
</h2>
</div>
<motion.button
onClick={onClose}
className="flex h-7 w-7 items-center justify-center rounded-full bg-white/6 transition-colors hover:bg-white/12"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
aria-label="Close"
>
<X className="h-3.5 w-3.5 text-white/40" />
</motion.button>
</div>
<div className="px-5 pb-5">
<div className="space-y-1">
{SHORTCUTS.map(({ key, description }) => (
<div
key={key}
className="flex items-center justify-between py-1.5"
>
<span className="text-[13px] font-medium text-white/50">
{description}
</span>
<kbd className="flex h-6 min-w-6 items-center justify-center rounded-md bg-white/6 px-2 font-mono text-[11px] font-semibold text-white/70 ring-1 ring-white/8">
{key}
</kbd>
</div>
))}
</div>
</div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
}

View File

@ -0,0 +1,73 @@
"use client";
import { useEffect, useRef } from "react";
type ShortcutActions = {
onNorthUp: () => void;
onResetView: () => void;
onToggleOrbit: () => void;
onOpenSearch: () => void;
onToggleHelp: () => void;
onDeselect: () => void;
};
const INPUT_TAGS = new Set(["INPUT", "TEXTAREA", "SELECT"]);
export function useKeyboardShortcuts(actions: ShortcutActions) {
const ref = useRef(actions);
useEffect(() => {
ref.current = actions;
}, [actions]);
useEffect(() => {
function handler(e: KeyboardEvent) {
const target = e.target as HTMLElement;
if (INPUT_TAGS.has(target.tagName) || target.isContentEditable) return;
const dialogOpen = !!document.querySelector(
'[role="dialog"][aria-modal="true"]',
);
if (e.ctrlKey || e.metaKey || e.altKey) return;
const a = ref.current;
if (e.key === "Escape") {
if (!dialogOpen) a.onDeselect();
return;
}
if (dialogOpen) return;
switch (e.key) {
case "n":
case "N":
e.preventDefault();
a.onNorthUp();
break;
case "r":
case "R":
e.preventDefault();
a.onResetView();
break;
case "o":
case "O":
e.preventDefault();
a.onToggleOrbit();
break;
case "/":
e.preventDefault();
a.onOpenSearch();
break;
case "?":
e.preventDefault();
a.onToggleHelp();
break;
}
}
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
}

114
src/lib/airlines.ts Normal file
View File

@ -0,0 +1,114 @@
type AirlineInfo = {
name: string;
};
const ICAO_AIRLINES: Record<string, AirlineInfo> = {
AAL: { name: "American Airlines" },
AAR: { name: "Asiana Airlines" },
ACA: { name: "Air Canada" },
AEE: { name: "Aegean Airlines" },
AFR: { name: "Air France" },
AIC: { name: "Air India" },
AIJ: { name: "Interjet" },
AJT: { name: "Amerijet" },
ALK: { name: "SriLankan Airlines" },
AMX: { name: "Aeroméxico" },
ANA: { name: "All Nippon Airways" },
ANZ: { name: "Air New Zealand" },
ASA: { name: "Alaska Airlines" },
AUA: { name: "Austrian Airlines" },
AVA: { name: "Avianca" },
AWE: { name: "US Airways" },
AZA: { name: "Alitalia / ITA Airways" },
BAW: { name: "British Airways" },
BEL: { name: "Brussels Airlines" },
BER: { name: "Air Berlin" },
CAL: { name: "China Airlines" },
CCA: { name: "Air China" },
CES: { name: "China Eastern" },
CLH: { name: "Lufthansa CityLine" },
CMP: { name: "Copa Airlines" },
CPA: { name: "Cathay Pacific" },
CSN: { name: "China Southern" },
CTN: { name: "Croatia Airlines" },
CXA: { name: "Xiamen Airlines" },
DAL: { name: "Delta Air Lines" },
DLH: { name: "Lufthansa" },
EIN: { name: "Aer Lingus" },
EJU: { name: "easyJet Europe" },
ELY: { name: "El Al" },
ETD: { name: "Etihad Airways" },
ETH: { name: "Ethiopian Airlines" },
EVA: { name: "EVA Air" },
EWG: { name: "Eurowings" },
EZY: { name: "easyJet" },
FDX: { name: "FedEx Express" },
FIN: { name: "Finnair" },
FJI: { name: "Fiji Airways" },
GAF: { name: "German Air Force" },
GIA: { name: "Garuda Indonesia" },
GTI: { name: "Atlas Air" },
HAL: { name: "Hawaiian Airlines" },
HVN: { name: "Vietnam Airlines" },
IBE: { name: "Iberia" },
IBK: { name: "Norwegian Air Int'l" },
ICE: { name: "Icelandair" },
JAL: { name: "Japan Airlines" },
JBU: { name: "JetBlue" },
JST: { name: "Jetstar" },
KAL: { name: "Korean Air" },
KLM: { name: "KLM" },
LAN: { name: "LATAM Airlines" },
LOT: { name: "LOT Polish Airlines" },
MAU: { name: "Air Mauritius" },
MAS: { name: "Malaysia Airlines" },
MSR: { name: "EgyptAir" },
NAX: { name: "Norwegian Air Shuttle" },
NKS: { name: "Spirit Airlines" },
PAL: { name: "Philippine Airlines" },
PIA: { name: "Pakistan Int'l Airlines" },
QFA: { name: "Qantas" },
QTR: { name: "Qatar Airways" },
RAM: { name: "Royal Air Maroc" },
RJA: { name: "Royal Jordanian" },
ROT: { name: "TAROM" },
RYR: { name: "Ryanair" },
SAS: { name: "Scandinavian Airlines" },
SAA: { name: "South African Airways" },
SIA: { name: "Singapore Airlines" },
SKW: { name: "SkyWest Airlines" },
SVA: { name: "Saudia" },
SWA: { name: "Southwest Airlines" },
SWR: { name: "Swiss Int'l Air Lines" },
TAM: { name: "LATAM Brasil" },
TAP: { name: "TAP Air Portugal" },
THA: { name: "Thai Airways" },
THY: { name: "Turkish Airlines" },
TUI: { name: "TUI Airways" },
TVF: { name: "Transavia France" },
UAE: { name: "Emirates" },
UAL: { name: "United Airlines" },
UPS: { name: "UPS Airlines" },
VIR: { name: "Virgin Atlantic" },
VOZ: { name: "Virgin Australia" },
VLG: { name: "Vueling" },
WJA: { name: "WestJet" },
WZZ: { name: "Wizz Air" },
};
export function lookupAirline(callsign: string | null): string | null {
if (!callsign) return null;
const trimmed = callsign.trim().toUpperCase();
if (trimmed.length < 3) return null;
const prefix = trimmed.slice(0, 3);
return ICAO_AIRLINES[prefix]?.name ?? null;
}
export function parseFlightNumber(callsign: string | null): string | null {
if (!callsign) return null;
const trimmed = callsign.trim().toUpperCase();
if (trimmed.length <= 3) return null;
const digits = trimmed.slice(3).replace(/^0+/, "");
if (!digits || !/^\d+[A-Z]?$/.test(digits)) return null;
return digits;
}