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:
@ -1,16 +1,27 @@
|
|||||||
"use client";
|
"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 { ErrorBoundary } from "@/components/error-boundary";
|
||||||
import { Map } from "@/components/map/map";
|
import { Map } from "@/components/map/map";
|
||||||
import { CameraController } from "@/components/map/camera-controller";
|
import { CameraController } from "@/components/map/camera-controller";
|
||||||
import { AirportLayer } from "@/components/map/airport-layer";
|
import { AirportLayer } from "@/components/map/airport-layer";
|
||||||
import { FlightLayers } from "@/components/map/flight-layers";
|
import { FlightLayers } from "@/components/map/flight-layers";
|
||||||
import { FlightCard } from "@/components/ui/flight-card";
|
import { FlightCard } from "@/components/ui/flight-card";
|
||||||
|
import { KeyboardShortcutsHelp } from "@/components/ui/keyboard-shortcuts-help";
|
||||||
import { ControlPanel } from "@/components/ui/control-panel";
|
import { ControlPanel } from "@/components/ui/control-panel";
|
||||||
import { AltitudeLegend } from "@/components/ui/altitude-legend";
|
import { AltitudeLegend } from "@/components/ui/altitude-legend";
|
||||||
|
import { CameraControls } from "@/components/ui/camera-controls";
|
||||||
import { StatusBar } from "@/components/ui/status-bar";
|
import { StatusBar } from "@/components/ui/status-bar";
|
||||||
import { SettingsProvider, useSettings } from "@/hooks/use-settings";
|
import { SettingsProvider, useSettings } from "@/hooks/use-settings";
|
||||||
|
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||||
import { useFlights } from "@/hooks/use-flights";
|
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";
|
||||||
@ -18,7 +29,7 @@ import { CITIES, type City } from "@/lib/cities";
|
|||||||
import { AIRPORTS, 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, Keyboard } from "lucide-react";
|
||||||
|
|
||||||
const DEFAULT_CITY_ID = "sfo";
|
const DEFAULT_CITY_ID = "sfo";
|
||||||
const STYLE_STORAGE_KEY = "aeris:mapStyle";
|
const STYLE_STORAGE_KEY = "aeris:mapStyle";
|
||||||
@ -159,12 +170,17 @@ function FlightTrackerInner() {
|
|||||||
|
|
||||||
const [cityOverride, setCityOverride] = useState<City | undefined>();
|
const [cityOverride, setCityOverride] = useState<City | undefined>();
|
||||||
const [styleOverride, setStyleOverride] = useState<MapStyle | 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 activeCity = cityOverride ?? hydratedCity;
|
||||||
const mapStyle = styleOverride ?? hydratedStyle;
|
const mapStyle = styleOverride ?? hydratedStyle;
|
||||||
const { settings } = useSettings();
|
const { settings, update } = useSettings();
|
||||||
|
|
||||||
const setActiveCity = useCallback((city: City) => {
|
const setActiveCity = useCallback((city: City) => {
|
||||||
setCityOverride(city);
|
setCityOverride(city);
|
||||||
|
setSelectedIcao24(null);
|
||||||
syncCityToUrl(city);
|
syncCityToUrl(city);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -174,9 +190,44 @@ function FlightTrackerInner() {
|
|||||||
}, []);
|
}, []);
|
||||||
const { flights, loading, rateLimited, retryIn } = useFlights(activeCity);
|
const { flights, loading, rateLimited, retryIn } = useFlights(activeCity);
|
||||||
const trails = useTrailHistory(flights);
|
const trails = useTrailHistory(flights);
|
||||||
const [hoveredFlight, setHoveredFlight] = useState<FlightState | null>(null);
|
|
||||||
const [cursorPos, setCursorPos] = useState({ x: 0, y: 0 });
|
const selectedFlight = useMemo(() => {
|
||||||
const [repoStars, setRepoStars] = useState<number | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
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) {
|
if (info?.object) {
|
||||||
setHoveredFlight(info.object);
|
setSelectedIcao24((prev) =>
|
||||||
setCursorPos({ x: info.x ?? 0, y: info.y ?? 0 });
|
prev === info.object!.icao24 ? null : info.object!.icao24,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
setHoveredFlight(null);
|
setSelectedIcao24(null);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleClick = useCallback((info: PickingInfo<FlightState> | null) => {
|
const handleDeselectFlight = useCallback(() => {
|
||||||
if (info?.object) {
|
setSelectedIcao24(null);
|
||||||
setHoveredFlight(info.object);
|
|
||||||
setCursorPos({ x: info.x ?? 0, y: info.y ?? 0 });
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleNorthUp = useCallback(() => {
|
const handleNorthUp = useCallback(() => {
|
||||||
@ -233,6 +282,27 @@ function FlightTrackerInner() {
|
|||||||
setActiveCity(randomCity);
|
setActiveCity(randomCity);
|
||||||
}, [activeCity.iata, setActiveCity]);
|
}, [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 (
|
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}>
|
||||||
@ -245,8 +315,8 @@ function FlightTrackerInner() {
|
|||||||
<FlightLayers
|
<FlightLayers
|
||||||
flights={flights}
|
flights={flights}
|
||||||
trails={trails}
|
trails={trails}
|
||||||
onHover={handleHover}
|
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
selectedIcao24={selectedIcao24}
|
||||||
showTrails={settings.showTrails}
|
showTrails={settings.showTrails}
|
||||||
trailThickness={settings.trailThickness}
|
trailThickness={settings.trailThickness}
|
||||||
trailDistance={settings.trailDistance}
|
trailDistance={settings.trailDistance}
|
||||||
@ -263,7 +333,27 @@ function FlightTrackerInner() {
|
|||||||
<Brand isDark={mapStyle.dark} />
|
<Brand isDark={mapStyle.dark} />
|
||||||
</div>
|
</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">
|
<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
|
<a
|
||||||
href={GITHUB_REPO_URL}
|
href={GITHUB_REPO_URL}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@ -320,12 +410,16 @@ function FlightTrackerInner() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 />
|
<AltitudeLegend />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FlightCard flight={hoveredFlight} x={cursorPos.x} y={cursorPos.y} />
|
<KeyboardShortcutsHelp
|
||||||
|
open={showHelp}
|
||||||
|
onClose={() => setShowHelp(false)}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,10 +6,26 @@ import { useSettings } from "@/hooks/use-settings";
|
|||||||
import type { City } from "@/lib/cities";
|
import type { City } from "@/lib/cities";
|
||||||
|
|
||||||
const IDLE_TIMEOUT_MS = 5_000;
|
const IDLE_TIMEOUT_MS = 5_000;
|
||||||
|
const ORBIT_EASE_IN_MS = 2000;
|
||||||
const DEFAULT_ZOOM = 9.2;
|
const DEFAULT_ZOOM = 9.2;
|
||||||
const DEFAULT_PITCH = 49;
|
const DEFAULT_PITCH = 49;
|
||||||
const DEFAULT_BEARING = 27.4;
|
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 }) {
|
export function CameraController({ city }: { city: City }) {
|
||||||
const { map, isLoaded } = useMap();
|
const { map, isLoaded } = useMap();
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
@ -66,6 +82,124 @@ export function CameraController({ city }: { city: City }) {
|
|||||||
};
|
};
|
||||||
}, [map, isLoaded, 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(() => {
|
useEffect(() => {
|
||||||
if (!map || !isLoaded || !city || !settings.autoOrbit) {
|
if (!map || !isLoaded || !city || !settings.autoOrbit) {
|
||||||
if (orbitFrameRef.current) cancelAnimationFrame(orbitFrameRef.current);
|
if (orbitFrameRef.current) cancelAnimationFrame(orbitFrameRef.current);
|
||||||
@ -84,9 +218,14 @@ export function CameraController({ city }: { city: City }) {
|
|||||||
function startOrbit() {
|
function startOrbit() {
|
||||||
if (!map || isInteractingRef.current) return;
|
if (!map || isInteractingRef.current) return;
|
||||||
|
|
||||||
|
const resumeStart = performance.now();
|
||||||
|
|
||||||
function tick() {
|
function tick() {
|
||||||
if (!map || isInteractingRef.current) return;
|
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);
|
map.setBearing(bearing % 360);
|
||||||
orbitFrameRef.current = requestAnimationFrame(tick);
|
orbitFrameRef.current = requestAnimationFrame(tick);
|
||||||
}
|
}
|
||||||
@ -123,6 +262,18 @@ export function CameraController({ city }: { city: City }) {
|
|||||||
};
|
};
|
||||||
map.on("movestart", onMoveStart);
|
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(() => {
|
idleTimerRef.current = setTimeout(() => {
|
||||||
isInteractingRef.current = false;
|
isInteractingRef.current = false;
|
||||||
startOrbit();
|
startOrbit();
|
||||||
@ -133,6 +284,7 @@ export function CameraController({ city }: { city: City }) {
|
|||||||
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
|
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
|
||||||
events.forEach((e) => container.removeEventListener(e, resetIdleTimer));
|
events.forEach((e) => container.removeEventListener(e, resetIdleTimer));
|
||||||
map.off("movestart", onMoveStart);
|
map.off("movestart", onMoveStart);
|
||||||
|
window.removeEventListener("aeris:camera-stop", onCameraStop);
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
map,
|
map,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useCallback } from "react";
|
import { useEffect, useRef, useCallback } from "react";
|
||||||
|
import maplibregl from "maplibre-gl";
|
||||||
import { MapboxOverlay } from "@deck.gl/mapbox";
|
import { MapboxOverlay } from "@deck.gl/mapbox";
|
||||||
import { IconLayer, PathLayer } from "@deck.gl/layers";
|
import { IconLayer, PathLayer } from "@deck.gl/layers";
|
||||||
import { ScenegraphLayer } from "@deck.gl/mesh-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 TrailEntry } from "@/hooks/use-trail-history";
|
||||||
import type { PickingInfo } from "@deck.gl/core";
|
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 DEFAULT_ANIM_DURATION_MS = 30_000;
|
||||||
const MIN_ANIM_DURATION_MS = 8_000;
|
const MIN_ANIM_DURATION_MS = 8_000;
|
||||||
const MAX_ANIM_DURATION_MS = 45_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_SCENEGRAPH_URL = "/models/airplane.glb";
|
||||||
const AIRCRAFT_PX_PER_UNIT = 0.3;
|
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][] {
|
function buildStartupFallbackTrail(f: FlightState): [number, number][] {
|
||||||
if (f.longitude == null || f.latitude == null) return [];
|
if (f.longitude == null || f.latitude == null) return [];
|
||||||
|
|
||||||
@ -274,8 +381,8 @@ function getAircraftAtlasUrl(): string {
|
|||||||
type FlightLayerProps = {
|
type FlightLayerProps = {
|
||||||
flights: FlightState[];
|
flights: FlightState[];
|
||||||
trails: TrailEntry[];
|
trails: TrailEntry[];
|
||||||
onHover: (info: PickingInfo<FlightState> | null) => void;
|
|
||||||
onClick: (info: PickingInfo<FlightState> | null) => void;
|
onClick: (info: PickingInfo<FlightState> | null) => void;
|
||||||
|
selectedIcao24: string | null;
|
||||||
showTrails: boolean;
|
showTrails: boolean;
|
||||||
trailThickness: number;
|
trailThickness: number;
|
||||||
trailDistance: number;
|
trailDistance: number;
|
||||||
@ -286,8 +393,8 @@ type FlightLayerProps = {
|
|||||||
export function FlightLayers({
|
export function FlightLayers({
|
||||||
flights,
|
flights,
|
||||||
trails,
|
trails,
|
||||||
onHover,
|
|
||||||
onClick,
|
onClick,
|
||||||
|
selectedIcao24,
|
||||||
showTrails,
|
showTrails,
|
||||||
trailThickness,
|
trailThickness,
|
||||||
trailDistance,
|
trailDistance,
|
||||||
@ -297,6 +404,8 @@ export function FlightLayers({
|
|||||||
const { map, isLoaded } = useMap();
|
const { map, isLoaded } = useMap();
|
||||||
const overlayRef = useRef<MapboxOverlay | null>(null);
|
const overlayRef = useRef<MapboxOverlay | null>(null);
|
||||||
const atlasUrl = getAircraftAtlasUrl();
|
const atlasUrl = getAircraftAtlasUrl();
|
||||||
|
const haloUrl = getHaloUrl();
|
||||||
|
const ringUrl = getRingUrl();
|
||||||
|
|
||||||
const prevSnapshotsRef = useRef<Map<string, Snapshot>>(new Map());
|
const prevSnapshotsRef = useRef<Map<string, Snapshot>>(new Map());
|
||||||
const currSnapshotsRef = 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 trailDistanceRef = useRef(trailDistance);
|
||||||
const showShadowsRef = useRef(showShadows);
|
const showShadowsRef = useRef(showShadows);
|
||||||
const showAltColorsRef = useRef(showAltitudeColors);
|
const showAltColorsRef = useRef(showAltitudeColors);
|
||||||
|
const selectedIcao24Ref = useRef(selectedIcao24);
|
||||||
|
const prevSelectedRef = useRef<string | null>(null);
|
||||||
|
const selectionChangeTimeRef = useRef(0);
|
||||||
|
const SELECTION_FADE_MS = 600;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
flightsRef.current = flights;
|
flightsRef.current = flights;
|
||||||
@ -320,7 +433,21 @@ export function FlightLayers({
|
|||||||
trailDistanceRef.current = trailDistance;
|
trailDistanceRef.current = trailDistance;
|
||||||
showShadowsRef.current = showShadows;
|
showShadowsRef.current = showShadows;
|
||||||
showAltColorsRef.current = showAltitudeColors;
|
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(() => {
|
useEffect(() => {
|
||||||
const elapsed = performance.now() - dataTimestampRef.current;
|
const elapsed = performance.now() - dataTimestampRef.current;
|
||||||
@ -383,11 +510,20 @@ export function FlightLayers({
|
|||||||
|
|
||||||
const handleHover = useCallback(
|
const handleHover = useCallback(
|
||||||
(info: PickingInfo<FlightState>) => {
|
(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(
|
const handleClick = useCallback(
|
||||||
(info: PickingInfo<FlightState>) => {
|
(info: PickingInfo<FlightState>) => {
|
||||||
if (info.object) onClick(info);
|
if (info.object) onClick(info);
|
||||||
@ -395,6 +531,31 @@ export function FlightLayers({
|
|||||||
[onClick],
|
[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(() => {
|
useEffect(() => {
|
||||||
if (!map || !isLoaded) return;
|
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(
|
layers.push(
|
||||||
new ScenegraphLayer<FlightState>({
|
new ScenegraphLayer<FlightState>({
|
||||||
id: "flight-aircraft",
|
id: "flight-aircraft",
|
||||||
@ -764,7 +1013,7 @@ export function FlightLayers({
|
|||||||
|
|
||||||
buildAndPushLayers();
|
buildAndPushLayers();
|
||||||
return () => cancelAnimationFrame(animFrameRef.current);
|
return () => cancelAnimationFrame(animFrameRef.current);
|
||||||
}, [atlasUrl, handleHover, handleClick, map]);
|
}, [atlasUrl, haloUrl, ringUrl, handleHover, handleClick, map]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
175
src/components/ui/camera-controls.tsx
Normal file
175
src/components/ui/camera-controls.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -48,6 +48,15 @@ export function ControlPanel({
|
|||||||
}: ControlPanelProps) {
|
}: ControlPanelProps) {
|
||||||
const [openTab, setOpenTab] = useState<TabId | null>(null);
|
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 open = (tab: TabId) => setOpenTab(tab);
|
||||||
const close = () => setOpenTab(null);
|
const close = () => setOpenTab(null);
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion, AnimatePresence } from "motion/react";
|
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 type { FlightState } from "@/lib/opensky";
|
||||||
import {
|
import {
|
||||||
metersToFeet,
|
metersToFeet,
|
||||||
@ -9,40 +19,44 @@ import {
|
|||||||
formatCallsign,
|
formatCallsign,
|
||||||
headingToCardinal,
|
headingToCardinal,
|
||||||
} from "@/lib/flight-utils";
|
} from "@/lib/flight-utils";
|
||||||
|
import { lookupAirline, parseFlightNumber } from "@/lib/airlines";
|
||||||
|
|
||||||
type FlightCardProps = {
|
type FlightCardProps = {
|
||||||
flight: FlightState | null;
|
flight: FlightState | null;
|
||||||
x: number;
|
onClose: () => void;
|
||||||
y: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence mode="wait">
|
||||||
{flight && (
|
{flight && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, scale: 0.92, y: 8 }}
|
key={flight.icao24}
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
initial={{ opacity: 0, x: -16, scale: 0.96 }}
|
||||||
exit={{ opacity: 0, scale: 0.92, y: 8 }}
|
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, x: -16, scale: 0.96 }}
|
||||||
transition={{
|
transition={{
|
||||||
type: "spring",
|
type: "spring",
|
||||||
stiffness: 400,
|
stiffness: 400,
|
||||||
damping: 28,
|
damping: 28,
|
||||||
mass: 0.8,
|
mass: 0.8,
|
||||||
}}
|
}}
|
||||||
className="pointer-events-none fixed z-50 w-64 sm:w-72"
|
className="w-64 sm:w-72"
|
||||||
role="status"
|
role="complementary"
|
||||||
|
aria-label="Selected flight details"
|
||||||
aria-live="polite"
|
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="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 justify-between">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-white/6">
|
<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-white/80" />
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold tracking-wide text-white">
|
<p className="text-sm font-semibold tracking-wide text-white">
|
||||||
@ -50,17 +64,33 @@ export function FlightCard({ flight, x, y }: FlightCardProps) {
|
|||||||
</p>
|
</p>
|
||||||
<p className="text-[11px] font-medium tracking-wider text-white/40 uppercase">
|
<p className="text-[11px] font-medium tracking-wider text-white/40 uppercase">
|
||||||
{flight.icao24}
|
{flight.icao24}
|
||||||
|
{flightNum ? ` · #${flightNum}` : ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<motion.button
|
||||||
Live
|
onClick={onClose}
|
||||||
</span>
|
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>
|
||||||
|
|
||||||
<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
|
<Metric
|
||||||
icon={<ArrowUp className="h-3 w-3" />}
|
icon={<ArrowUp className="h-3 w-3" />}
|
||||||
label="Altitude"
|
label="Altitude"
|
||||||
@ -75,9 +105,7 @@ export function FlightCard({ flight, x, y }: FlightCardProps) {
|
|||||||
icon={<Compass className="h-3 w-3" />}
|
icon={<Compass className="h-3 w-3" />}
|
||||||
label="Heading"
|
label="Heading"
|
||||||
value={
|
value={
|
||||||
flight.trueTrack !== null
|
heading !== null ? `${Math.round(heading)}° ${cardinal}` : "—"
|
||||||
? `${Math.round(flight.trueTrack)}° ${headingToCardinal(flight.trueTrack)}`
|
|
||||||
: "—"
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Metric
|
<Metric
|
||||||
@ -91,14 +119,66 @@ export function FlightCard({ flight, x, y }: FlightCardProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="mt-2.5 flex flex-col gap-1.5">
|
||||||
<Globe className="h-3 w-3 text-white/30" />
|
<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">
|
<p className="text-[11px] font-medium tracking-wide text-white/40">
|
||||||
{flight.originCountry}
|
{flight.originCountry}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</motion.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({
|
function Metric({
|
||||||
icon,
|
icon,
|
||||||
label,
|
label,
|
||||||
|
|||||||
145
src/components/ui/keyboard-shortcuts-help.tsx
Normal file
145
src/components/ui/keyboard-shortcuts-help.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
src/hooks/use-keyboard-shortcuts.ts
Normal file
73
src/hooks/use-keyboard-shortcuts.ts
Normal 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
114
src/lib/airlines.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user