Files
aeris/src/components/ui/control-panel.tsx
kew 147b69b944 feat(map): enhance globe projection handling and improve altitude color representation (#14)
* feat(map): enhance globe projection handling and improve altitude color representation

- Implemented elevation-aware pixel projection for globe mode in `projectLngLatElevationPixelDelta`.
- Refactored north-up animation in `CameraController` to use `setBearing` for smoother transitions.
- Added native GeoJSON support for globe zoom in `FlightLayers`, including dynamic opacity adjustments based on zoom levels.
- Introduced globe-specific pitch and projection settings in `Map` component, ensuring consistent rendering.
- Enhanced UI control panel with a visual separator for better organization.
- Minor formatting adjustments in `altitudeToColor` function for improved readability.

* feat(map): refactor elevation-aware projection handling for improved accuracy

* feat(map): add dark terrain profile support and enhance map styling

* feat: implement trail stitching for merging historical and live flight data

- Added a new module `trail-stitching.ts` to handle the merging of sparse historical track data with high-frequency live trail data.
- Introduced constants for thresholds and parameters to improve code readability and maintainability.
- Implemented a main function `stitchHistoricalTrail` that processes flight tracks, applies smoothing, and merges live tail data.
- Included utility functions for spherical interpolation and cubic easing for altitude transitions.
- Ensured the final path is cleaned of spikes and sharp corners for a smoother representation.

* feat: add centripetal Catmull-Rom spline interpolation for 3D flight trails

- Implemented `catmullRomSpline3D` function to interpolate waypoints into a smooth 3D path.
- Added helper functions for segment density calculation, safe linear interpolation, and endpoint reflection.
- Included support for variable tension based on heading changes to enhance smoothness.
- Introduced utility functions for linear interpolation between elevated points.

* feat(map): enhance layer visibility handling for flight and selection layers

* feat: enhance control panel with new tabs and settings

- Added "Changelog" and "About" tabs to the control panel.
- Introduced new icons for the added tabs using lucide-react.
- Updated the styling of the control panel buttons and dialog.
- Improved accessibility with aria-labels for buttons.

feat: integrate hero banner in flight card

- Added a HeroBanner component to display aircraft photos in the FlightCard.
- Implemented loading and error states for the photo display.
- Enhanced the layout and styling of the FlightCard for better user experience.

fix: update keyboard shortcuts for search functionality

- Added shortcut "⌘K" to open search from anywhere in the application.
- Adjusted keyboard shortcut handling to prevent conflicts with input fields.

fix: optimize flight tracking cache management

- Introduced a maximum cache size for flight tracking to prevent memory growth.
- Implemented a cache eviction strategy for stale entries.

feat: add great-circle utilities for geographical calculations

- Implemented functions for calculating haversine distance, great-circle interpolation, and densifying paths.
- Added functionality to handle antimeridian crossings in geographical paths.

refactor: streamline map styles and terrain handling

- Consolidated terrain DEM source for both terrain mesh and hillshade.
- Adjusted hillshade layer properties for better performance and visual fidelity.

fix: improve bounding box calculations for flight queries

- Enhanced longitude calculations to account for converging meridians at higher latitudes.
- Ensured bounding box calculations are accurate across different latitudes.

* feat(map): refine globe mode functionality and update trail settings
2026-03-11 00:54:51 +05:30

446 lines
15 KiB
TypeScript

"use client";
import { useState, useEffect, useRef, type ReactNode } from "react";
import { motion, AnimatePresence } from "motion/react";
import {
Search,
Map as MapIcon,
Settings,
Keyboard,
X,
Github,
Info,
Clock,
} from "lucide-react";
import type { City } from "@/lib/cities";
import type { MapStyle } from "@/lib/map-styles";
import type { FlightState } from "@/lib/opensky";
import { SearchContent } from "@/components/ui/control-panel-search";
import { StyleContent } from "@/components/ui/control-panel-styles";
import {
SettingsContent,
ShortcutsContent,
AboutContent,
ChangelogContent,
} from "@/components/ui/control-panel-settings";
type TabId =
| "search"
| "style"
| "settings"
| "shortcuts"
| "changelog"
| "about";
const MAIN_TABS: {
id: TabId;
icon: typeof Search;
label: string;
}[] = [
{ id: "search", icon: Search, label: "Search" },
{ id: "style", icon: MapIcon, label: "Map Style" },
];
const PANEL_TABS = [
...MAIN_TABS,
{ id: "settings" as TabId, icon: Settings, label: "Settings" },
{ id: "shortcuts" as TabId, icon: Keyboard, label: "Shortcuts" },
{ id: "changelog" as TabId, icon: Clock, label: "Changelog" },
{ id: "about" as TabId, icon: Info, label: "About" },
];
type ControlPanelProps = {
activeCity: City;
onSelectCity: (city: City) => void;
activeStyle: MapStyle;
onSelectStyle: (style: MapStyle) => void;
flights: FlightState[];
activeFlightIcao24: string | null;
onLookupFlight: (query: string, enterFpv?: boolean) => Promise<boolean>;
};
export function ControlPanel({
activeCity,
onSelectCity,
activeStyle,
onSelectStyle,
flights,
activeFlightIcao24,
onLookupFlight,
}: ControlPanelProps) {
const [openTab, setOpenTab] = useState<TabId | null>(null);
useEffect(() => {
function handleOpenSearch() {
setOpenTab("search");
}
function handleOpenShortcuts() {
setOpenTab("shortcuts");
}
window.addEventListener("aeris:open-search", handleOpenSearch);
window.addEventListener("aeris:open-shortcuts", handleOpenShortcuts);
return () => {
window.removeEventListener("aeris:open-search", handleOpenSearch);
window.removeEventListener("aeris:open-shortcuts", handleOpenShortcuts);
};
}, []);
const open = (tab: TabId) => setOpenTab(tab);
const close = () => setOpenTab(null);
return (
<>
{MAIN_TABS.map(({ id, icon: Icon, label }) => (
<motion.button
key={id}
onClick={() => open(id)}
className="flex h-9 w-9 items-center justify-center rounded-xl backdrop-blur-2xl transition-colors"
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={label}
>
<Icon className="h-4 w-4" />
</motion.button>
))}
<motion.button
onClick={() => open("settings")}
className="flex h-9 w-9 items-center justify-center rounded-xl backdrop-blur-2xl transition-colors"
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="Settings"
>
<Settings className="h-4 w-4" />
</motion.button>
<AnimatePresence>
{openTab && (
<PanelDialog
activeTab={openTab}
onTabChange={setOpenTab}
onClose={close}
activeCity={activeCity}
onSelectCity={(c) => {
onSelectCity(c);
close();
}}
activeStyle={activeStyle}
onSelectStyle={onSelectStyle}
flights={flights}
activeFlightIcao24={activeFlightIcao24}
onLookupFlight={onLookupFlight}
/>
)}
</AnimatePresence>
</>
);
}
function PanelDialog({
activeTab,
onTabChange,
onClose,
activeCity,
onSelectCity,
activeStyle,
onSelectStyle,
flights,
activeFlightIcao24,
onLookupFlight,
}: {
activeTab: TabId;
onTabChange: (tab: TabId) => void;
onClose: () => void;
activeCity: City;
onSelectCity: (city: City) => void;
activeStyle: MapStyle;
onSelectStyle: (style: MapStyle) => void;
flights: FlightState[];
activeFlightIcao24: string | null;
onLookupFlight: (query: string, enterFpv?: boolean) => Promise<boolean>;
}) {
const dialogRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [onClose]);
useEffect(() => {
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) return;
const first = focusable[0];
first.focus();
function trapFocus(e: KeyboardEvent) {
if (e.key !== "Tab") return;
const elements = dialog!.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
const f = elements[0];
const l = elements[elements.length - 1];
if (e.shiftKey) {
if (document.activeElement === f) {
e.preventDefault();
l.focus();
}
} else {
if (document.activeElement === l) {
e.preventDefault();
f.focus();
}
}
}
dialog.addEventListener("keydown", trapFocus);
return () => dialog.removeEventListener("keydown", trapFocus);
}, [activeTab]);
return (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-80 bg-black/70"
onClick={onClose}
/>
<motion.div
ref={dialogRef}
initial={{ opacity: 0, scale: 0.94, y: 16 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.94, y: 16 }}
transition={{
type: "spring",
stiffness: 400,
damping: 30,
mass: 0.8,
}}
className="fixed inset-x-3 bottom-3 top-auto z-90 sm:inset-auto sm:left-1/2 sm:top-1/2 sm:w-full sm:max-w-180 sm:-translate-x-1/2 sm:-translate-y-1/2 sm:px-4"
role="dialog"
aria-modal="true"
aria-labelledby="panel-dialog-title"
>
<div className="flex flex-col sm:flex-row overflow-hidden rounded-2xl sm:rounded-3xl border border-white/8 bg-[#0c0c0e] shadow-[0_40px_100px_rgba(0,0,0,0.8),0_0_0_1px_rgba(255,255,255,0.04)_inset] h-[75vh] sm:h-auto sm:max-h-[85vh]">
{/* Desktop sidebar (hidden on mobile) */}
<div className="hidden sm:flex w-52 shrink-0 flex-col border-r border-white/6 py-5 px-3">
<p className="mb-3 px-2 text-[11px] font-semibold uppercase tracking-widest text-white/20">
Controls
</p>
<nav className="flex flex-col gap-0.5">
{PANEL_TABS.map(({ id, icon: Icon, label }) => {
const active = id === activeTab;
return (
<button
key={id}
onClick={() => onTabChange(id)}
className={`group relative flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-left transition-colors ${
active
? "text-white/90"
: "text-white/35 hover:text-white/55 hover:bg-white/4"
}`}
>
{active && (
<motion.div
layoutId="panel-tab-bg"
className="absolute inset-0 rounded-xl bg-white/8"
transition={{
type: "spring",
stiffness: 400,
damping: 30,
}}
/>
)}
<Icon className="relative h-4 w-4 shrink-0" />
<span className="relative text-[14px] font-medium">
{label}
</span>
</button>
);
})}
</nav>
<div className="mt-auto pt-4 px-1 flex flex-col gap-3">
<a
href="https://github.com/kewonit/aeris"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub (opens in new tab)"
className="group relative flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-left transition-colors text-white/35 hover:text-white/55 hover:bg-white/4"
>
<Github
className="relative h-4 w-4 shrink-0"
aria-hidden="true"
/>
<span className="relative text-[14px] font-medium">GitHub</span>
</a>
<div className="border-t border-white/3 pt-2 px-2.5">
<p className="text-[10px] font-medium text-white/10 tracking-wide">
Powered by OpenSky Network
</p>
</div>
</div>
</div>
<div className="flex flex-1 flex-col min-h-0 sm:h-120">
{/* Mobile header */}
<div className="flex sm:hidden items-center justify-between px-4 pt-4 pb-2">
<h2
id="panel-dialog-title"
className="text-[14px] font-semibold tracking-tight text-white/90"
>
{PANEL_TABS.find((t) => t.id === activeTab)?.label}
</h2>
</div>
{/* Desktop header */}
<div className="hidden sm:flex items-center justify-between px-5 pt-5 pb-2">
<h2
id="panel-dialog-title"
className="text-[15px] font-semibold tracking-tight text-white/90"
>
{PANEL_TABS.find((t) => t.id === activeTab)?.label}
</h2>
<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="relative flex-1 overflow-hidden">
<AnimatePresence mode="wait" initial={false}>
{activeTab === "search" && (
<TabContent key="search">
<SearchContent
activeCity={activeCity}
onSelect={onSelectCity}
flights={flights}
activeFlightIcao24={activeFlightIcao24}
onLookupFlight={async (query, enterFpv = false) => {
const found = await onLookupFlight(query, enterFpv);
if (found) onClose();
return found;
}}
/>
</TabContent>
)}
{activeTab === "style" && (
<TabContent key="style">
<StyleContent
activeStyle={activeStyle}
onSelect={onSelectStyle}
/>
</TabContent>
)}
{activeTab === "settings" && (
<TabContent key="settings">
<SettingsContent />
</TabContent>
)}
{activeTab === "shortcuts" && (
<TabContent key="shortcuts">
<ShortcutsContent />
</TabContent>
)}
{activeTab === "changelog" && (
<TabContent key="changelog">
<ChangelogContent />
</TabContent>
)}
{activeTab === "about" && (
<TabContent key="about">
<AboutContent />
</TabContent>
)}
</AnimatePresence>
</div>
</div>
{/* Mobile tab bar */}
<div className="flex sm:hidden items-center gap-0.5 border-t border-white/6 px-2 pt-2 pb-3">
<nav className="flex flex-1 gap-0.5">
{PANEL_TABS.map(({ id, icon: Icon, label }) => {
const active = id === activeTab;
return (
<button
key={id}
onClick={() => onTabChange(id)}
className={`relative flex flex-1 items-center justify-center rounded-lg py-2.5 transition-colors ${
active
? "text-white/90"
: "text-white/35 active:bg-white/6"
}`}
aria-label={label}
>
{active && (
<motion.div
layoutId="panel-tab-bg-mobile"
className="absolute inset-0 rounded-lg bg-white/8"
transition={{
type: "spring",
stiffness: 400,
damping: 30,
}}
/>
)}
<Icon className="relative h-4 w-4 shrink-0" />
</button>
);
})}
</nav>
<motion.button
onClick={onClose}
className="ml-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white/6 transition-colors active:bg-white/12"
whileTap={{ scale: 0.9 }}
aria-label="Close"
>
<X className="h-3.5 w-3.5 text-white/40" />
</motion.button>
</div>
</div>
</motion.div>
</>
);
}
function TabContent({ children }: { children: ReactNode }) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="absolute inset-0"
>
{children}
</motion.div>
);
}