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:
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,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,
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user