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

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

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

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

View File

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

View File

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

View File

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

View File

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