* 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
446 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|