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";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
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) {
|
||||
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);
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
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