From 06956f8b59964678faf8e50decb64df81be16b6a Mon Sep 17 00:00:00 2001 From: kew <108450560+kewonit@users.noreply.github.com> Date: Sun, 15 Feb 2026 21:50:48 +0530 Subject: [PATCH] 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 --- src/components/flight-tracker.tsx | 130 +++++++-- src/components/map/camera-controller.tsx | 154 ++++++++++- src/components/map/flight-layers.tsx | 261 +++++++++++++++++- src/components/ui/camera-controls.tsx | 175 ++++++++++++ src/components/ui/control-panel.tsx | 9 + src/components/ui/flight-card.tsx | 160 +++++++++-- src/components/ui/keyboard-shortcuts-help.tsx | 145 ++++++++++ src/hooks/use-keyboard-shortcuts.ts | 73 +++++ src/lib/airlines.ts | 114 ++++++++ 9 files changed, 1166 insertions(+), 55 deletions(-) create mode 100644 src/components/ui/camera-controls.tsx create mode 100644 src/components/ui/keyboard-shortcuts-help.tsx create mode 100644 src/hooks/use-keyboard-shortcuts.ts create mode 100644 src/lib/airlines.ts diff --git a/src/components/flight-tracker.tsx b/src/components/flight-tracker.tsx index 5eb9bd5..774f4d7 100644 --- a/src/components/flight-tracker.tsx +++ b/src/components/flight-tracker.tsx @@ -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(); const [styleOverride, setStyleOverride] = useState(); + const [selectedIcao24, setSelectedIcao24] = useState(null); + const [showHelp, setShowHelp] = useState(false); + const [repoStars, setRepoStars] = useState(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(null); - const [cursorPos, setCursorPos] = useState({ x: 0, y: 0 }); - const [repoStars, setRepoStars] = useState(null); + + const selectedFlight = useMemo(() => { + if (!selectedIcao24) return null; + return flights.find((f) => f.icao24 === selectedIcao24) ?? null; + }, [selectedIcao24, flights]); + + const lastKnownFlightRef = useRef(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(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 | null) => { + const handleClick = useCallback((info: PickingInfo | 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 | 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 (
@@ -245,8 +315,8 @@ function FlightTrackerInner() { +
+ +
+
+ + +
-
+
+
- + setShowHelp(false)} + />
); } diff --git a/src/components/map/camera-controller.tsx b/src/components/map/camera-controller.tsx index eec0619..7687a16 100644 --- a/src/components/map/camera-controller.tsx +++ b/src/components/map/camera-controller.tsx @@ -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(); + 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, diff --git a/src/components/map/flight-layers.tsx b/src/components/map/flight-layers.tsx index 379cc7e..52bd55b 100644 --- a/src/components/map/flight-layers.tsx +++ b/src/components/map/flight-layers.tsx @@ -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 | null) => void; onClick: (info: PickingInfo | 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(null); const atlasUrl = getAircraftAtlasUrl(); + const haloUrl = getHaloUrl(); + const ringUrl = getRingUrl(); const prevSnapshotsRef = useRef>(new Map()); const currSnapshotsRef = useRef>(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(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) => { - 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) => { 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({ 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; } diff --git a/src/components/ui/camera-controls.tsx b/src/components/ui/camera-controls.tsx new file mode 100644 index 0000000..95d4870 --- /dev/null +++ b/src/components/ui/camera-controls.tsx @@ -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 ( + e.preventDefault()} + > + {children} + + ); +} + +function Divider() { + return ( +
+ ); +} + +export function CameraControls() { + return ( + + + + + + + + + +
+ + + + + + + + + +
+ + + + + + + + + + ); +} diff --git a/src/components/ui/control-panel.tsx b/src/components/ui/control-panel.tsx index 50805d1..815aad7 100644 --- a/src/components/ui/control-panel.tsx +++ b/src/components/ui/control-panel.tsx @@ -48,6 +48,15 @@ export function ControlPanel({ }: ControlPanelProps) { const [openTab, setOpenTab] = useState(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); diff --git a/src/components/ui/flight-card.tsx b/src/components/ui/flight-card.tsx index 66cc5f1..5a0d7c1 100644 --- a/src/components/ui/flight-card.tsx +++ b/src/components/ui/flight-card.tsx @@ -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 ( - + {flight && (
-
- +
+ +

@@ -50,17 +64,33 @@ export function FlightCard({ flight, x, y }: FlightCardProps) {

{flight.icao24} + {flightNum ? ` · #${flightNum}` : ""}

- - Live - + + +
-
+ {airline && ( +
+ +

+ {airline} +

+
+ )} -
+
+ +
} label="Altitude" @@ -75,9 +105,7 @@ export function FlightCard({ flight, x, y }: FlightCardProps) { icon={} label="Heading" value={ - flight.trueTrack !== null - ? `${Math.round(flight.trueTrack)}° ${headingToCardinal(flight.trueTrack)}` - : "—" + heading !== null ? `${Math.round(heading)}° ${cardinal}` : "—" } />
-
+
-
- -

- {flight.originCountry} -

+
+
+ +

+ {flight.originCountry} +

+
+ {cardinal && ( +
+ +

+ Heading {cardinal} + {flight.latitude !== null && flight.longitude !== null && ( + + {" "} + · {Math.abs(flight.latitude).toFixed(2)}° + {flight.latitude >= 0 ? "N" : "S"},{" "} + {Math.abs(flight.longitude).toFixed(2)}° + {flight.longitude >= 0 ? "E" : "W"} + + )} +

+
+ )} + {flight.squawk && ( +
+ + SQ + +

+ {flight.squawk} + {isEmergencySquawk(flight.squawk) && ( + + {squawkLabel(flight.squawk)} + + )} +

+
+ )}
@@ -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, diff --git a/src/components/ui/keyboard-shortcuts-help.tsx b/src/components/ui/keyboard-shortcuts-help.tsx new file mode 100644 index 0000000..2482c61 --- /dev/null +++ b/src/components/ui/keyboard-shortcuts-help.tsx @@ -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(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( + '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( + '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 ( + + {open && ( + <> + + +
+
+
+
+ +
+

+ Keyboard Shortcuts +

+
+ + + +
+ +
+
+ {SHORTCUTS.map(({ key, description }) => ( +
+ + {description} + + + {key} + +
+ ))} +
+
+
+
+ + )} +
+ ); +} diff --git a/src/hooks/use-keyboard-shortcuts.ts b/src/hooks/use-keyboard-shortcuts.ts new file mode 100644 index 0000000..c6750d3 --- /dev/null +++ b/src/hooks/use-keyboard-shortcuts.ts @@ -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); + }, []); +} diff --git a/src/lib/airlines.ts b/src/lib/airlines.ts new file mode 100644 index 0000000..eb70723 --- /dev/null +++ b/src/lib/airlines.ts @@ -0,0 +1,114 @@ +type AirlineInfo = { + name: string; +}; + +const ICAO_AIRLINES: Record = { + 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; +}