* Refactor UI components for improved theming and accessibility - Updated color schemes in `fpv-hud.tsx`, `hero-banner.tsx`, `keyboard-shortcuts-help.tsx`, `mobile-flight-toast.tsx`, `provider-panel.tsx`, `scroll-area.tsx`, and `slider.tsx` to utilize foreground and background variables for better dark mode support. - Enhanced visual consistency by replacing hardcoded colors with theme variables across various components. - Adjusted text and background colors for improved readability and accessibility. - Fixed minor issues with key bindings in `keyboard-shortcuts-help.tsx`. - Optimized flight data handling in `use-trail-history.ts` and `trail-cleanup.ts` for better performance and accuracy. - Implemented outlier filtering in trail history to reduce GPS glitches. * feat: enhance aircraft appearance and flight trail rendering with improved safety checks and visual effects * feat: implement last flight leg trimming and nearest airport search functionality * feat: Enhance flight data parsing and handling - Added optionalFinite helper function to ensure only finite numbers are processed in flight data. - Extended FlightState type to include avionics data (ias, tas, mach, roll, trackRate, magHeading) and navigation intent (navAltitudeMcp, navAltitudeFms, navHeading, navQnh, navModes). - Updated parseRawAircraft function to utilize optionalFinite for avionics and navigation data. - Adjusted removeSpikePoints function to increase cosThreshold from -0.17 to -0.05 for better spike removal. - Increased MAX_WINDOW in removePathLoops function from 120 to 300 to allow for larger path loops. - Integrated loop cleaning in stitchHistoricalTrail function to ensure cleaner paths and altitudes. * feat: add AtcSpectrum component for audio visualization and useAirportBoard hook for flight data management
729 lines
25 KiB
TypeScript
729 lines
25 KiB
TypeScript
"use client";
|
||
|
||
import type { ReactNode } from "react";
|
||
import { motion } from "motion/react";
|
||
import {
|
||
RotateCw,
|
||
Route,
|
||
Layers,
|
||
Palette,
|
||
Globe,
|
||
ArrowLeftRight,
|
||
Shield,
|
||
Flame,
|
||
Eye,
|
||
CloudRain,
|
||
} from "lucide-react";
|
||
import {
|
||
useSettings,
|
||
AIRSPACE_OPACITY_MIN,
|
||
AIRSPACE_OPACITY_MAX,
|
||
WEATHER_RADAR_OPACITY_MIN,
|
||
WEATHER_RADAR_OPACITY_MAX,
|
||
type OrbitDirection,
|
||
} from "@/hooks/use-settings";
|
||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||
import { Slider } from "@/components/ui/slider";
|
||
import { SHORTCUTS } from "@/components/ui/keyboard-shortcuts-help";
|
||
|
||
const ORBIT_SPEED_PRESETS = [
|
||
{ label: "Slow", value: 0.06 },
|
||
{ label: "Normal", value: 0.15 },
|
||
{ label: "Fast", value: 0.35 },
|
||
];
|
||
|
||
const ORBIT_SPEED_MIN = 0.02;
|
||
const ORBIT_SPEED_MAX = 0.5;
|
||
const ORBIT_SNAP_THRESHOLD = 0.025;
|
||
const TRAIL_THICKNESS_MIN = 0.5;
|
||
const TRAIL_THICKNESS_MAX = 8;
|
||
const TRAIL_DISTANCE_MIN = 12;
|
||
const TRAIL_DISTANCE_MAX = 100;
|
||
|
||
const ORBIT_DIRECTIONS: { label: string; value: OrbitDirection }[] = [
|
||
{ label: "Clockwise", value: "clockwise" },
|
||
{ label: "Counter", value: "counter-clockwise" },
|
||
];
|
||
|
||
export function SettingsContent() {
|
||
const { settings, update, reset } = useSettings();
|
||
|
||
return (
|
||
<ScrollArea className="h-full">
|
||
<div className="space-y-0.5 p-3 pt-1">
|
||
{/* ── Camera ── */}
|
||
<SectionHeader title="Camera" />
|
||
|
||
<SettingRow
|
||
icon={<RotateCw className="h-4 w-4" />}
|
||
title="Auto-orbit"
|
||
description="Camera slowly rotates around the airport"
|
||
checked={settings.autoOrbit}
|
||
onChange={(v) => update("autoOrbit", v)}
|
||
/>
|
||
|
||
{settings.autoOrbit && (
|
||
<>
|
||
<OrbitSpeedSlider
|
||
value={settings.orbitSpeed}
|
||
onChange={(v) => update("orbitSpeed", v)}
|
||
/>
|
||
<SegmentRow
|
||
icon={<ArrowLeftRight className="h-4 w-4" />}
|
||
title="Direction"
|
||
options={ORBIT_DIRECTIONS}
|
||
value={settings.orbitDirection}
|
||
onChange={(v) => update("orbitDirection", v)}
|
||
/>
|
||
</>
|
||
)}
|
||
|
||
{/* ── Visuals ── */}
|
||
<SectionHeader title="Visuals" />
|
||
|
||
<SettingRow
|
||
icon={<Route className="h-4 w-4" />}
|
||
title="Flight trails"
|
||
description="Altitude-colored trails behind aircraft"
|
||
checked={settings.showTrails}
|
||
onChange={(v) => update("showTrails", v)}
|
||
/>
|
||
{settings.showTrails && (
|
||
<>
|
||
<TrailThicknessSlider
|
||
value={settings.trailThickness}
|
||
onChange={(v) => update("trailThickness", v)}
|
||
/>
|
||
<TrailDistanceSlider
|
||
value={settings.trailDistance}
|
||
onChange={(v) => update("trailDistance", v)}
|
||
/>
|
||
</>
|
||
)}
|
||
<SettingRow
|
||
icon={<Layers className="h-4 w-4" />}
|
||
title="Ground shadows"
|
||
description="Shadow projections on the map surface"
|
||
checked={settings.showShadows}
|
||
onChange={(v) => update("showShadows", v)}
|
||
/>
|
||
<SettingRow
|
||
icon={<Palette className="h-4 w-4" />}
|
||
title="Altitude colors"
|
||
description="Color aircraft and trails by altitude"
|
||
checked={settings.showAltitudeColors}
|
||
onChange={(v) => update("showAltitudeColors", v)}
|
||
/>
|
||
|
||
{/* ── Airspace ── */}
|
||
<SectionHeader title="Airspace" />
|
||
|
||
<SettingRow
|
||
icon={<Shield className="h-4 w-4" />}
|
||
title="Airspace overlay"
|
||
description="Show classified airspace boundaries (OpenAIP)"
|
||
checked={settings.showAirspace}
|
||
onChange={(v) => update("showAirspace", v)}
|
||
/>
|
||
|
||
{settings.showAirspace && (
|
||
<>
|
||
<AirspaceOpacitySlider
|
||
value={settings.airspaceOpacity}
|
||
onChange={(v) => update("airspaceOpacity", v)}
|
||
/>
|
||
<SettingRow
|
||
icon={<Flame className="h-4 w-4" />}
|
||
title="Thermal hotspots"
|
||
description="Glider & paraglider thermal activity areas"
|
||
checked={settings.showAirspaceHotspots}
|
||
onChange={(v) => update("showAirspaceHotspots", v)}
|
||
/>
|
||
</>
|
||
)}
|
||
|
||
{/* ── Weather ── */}
|
||
<SectionHeader title="Weather" />
|
||
|
||
<SettingRow
|
||
icon={<CloudRain className="h-4 w-4" />}
|
||
title="Weather radar"
|
||
description="Live precipitation radar overlay (RainViewer)"
|
||
checked={settings.showWeatherRadar}
|
||
onChange={(v) => update("showWeatherRadar", v)}
|
||
/>
|
||
|
||
{settings.showWeatherRadar && (
|
||
<WeatherRadarOpacitySlider
|
||
value={settings.weatherRadarOpacity}
|
||
onChange={(v) => update("weatherRadarOpacity", v)}
|
||
/>
|
||
)}
|
||
|
||
{/* ── Performance ── */}
|
||
<SectionHeader title="Performance" />
|
||
|
||
<SettingRow
|
||
icon={<Globe className="h-4 w-4" />}
|
||
title="Globe mode"
|
||
description="Display earth as a 3D sphere when zoomed out"
|
||
checked={settings.globeMode}
|
||
onChange={(v) => update("globeMode", v)}
|
||
badge="BETA"
|
||
/>
|
||
|
||
<div className="mx-3 my-2 h-px bg-foreground/5" />
|
||
|
||
<div className="px-3 pt-2">
|
||
<button
|
||
type="button"
|
||
onClick={reset}
|
||
className="inline-flex h-8 items-center justify-center rounded-lg px-3 text-[12px] font-medium text-foreground/65 ring-1 ring-foreground/10 transition-colors hover:bg-foreground/5 hover:text-foreground/85"
|
||
>
|
||
Reset to defaults
|
||
</button>
|
||
</div>
|
||
|
||
<div className="mx-3 my-2 h-px bg-foreground/5" />
|
||
</div>
|
||
</ScrollArea>
|
||
);
|
||
}
|
||
|
||
export function ShortcutsContent() {
|
||
return (
|
||
<ScrollArea className="h-full">
|
||
<div className="p-3 pt-1">
|
||
<div className="space-y-1">
|
||
{SHORTCUTS.map(({ key, description }) => (
|
||
<div
|
||
key={key}
|
||
className="flex items-center justify-between gap-3 rounded-xl px-3 py-2.5 transition-colors hover:bg-foreground/4"
|
||
>
|
||
<span className="text-[13px] font-medium text-foreground/68">
|
||
{description}
|
||
</span>
|
||
<kbd className="flex h-7 min-w-7 items-center justify-center rounded-md bg-foreground/6 px-2 font-mono text-[11px] font-semibold text-foreground/74 ring-1 ring-foreground/8">
|
||
{key}
|
||
</kbd>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</ScrollArea>
|
||
);
|
||
}
|
||
|
||
function OrbitSpeedSlider({
|
||
value,
|
||
onChange,
|
||
}: {
|
||
value: number;
|
||
onChange: (v: number) => void;
|
||
}) {
|
||
const activeLabel =
|
||
ORBIT_SPEED_PRESETS.find(
|
||
(p) => Math.abs(p.value - value) < ORBIT_SNAP_THRESHOLD,
|
||
)?.label ?? `${value.toFixed(2)}×`;
|
||
|
||
function handleChange(vals: number[]) {
|
||
let raw = vals[0];
|
||
for (const preset of ORBIT_SPEED_PRESETS) {
|
||
if (Math.abs(raw - preset.value) < ORBIT_SNAP_THRESHOLD) {
|
||
raw = preset.value;
|
||
break;
|
||
}
|
||
}
|
||
onChange(raw);
|
||
}
|
||
|
||
return (
|
||
<div className="flex w-full items-center gap-3.5 rounded-xl px-3 py-2.5 text-left">
|
||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-foreground/5 text-foreground/35 ring-1 ring-foreground/6">
|
||
<RotateCw className="h-4 w-4" />
|
||
</div>
|
||
<div className="flex flex-1 min-w-0 flex-col gap-2">
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-[13px] font-medium text-foreground/80">
|
||
Orbit speed
|
||
</p>
|
||
<span className="text-[11px] font-semibold text-foreground/40 tabular-nums">
|
||
{activeLabel}
|
||
</span>
|
||
</div>
|
||
<div className="relative">
|
||
<Slider
|
||
min={ORBIT_SPEED_MIN}
|
||
max={ORBIT_SPEED_MAX}
|
||
step={0.01}
|
||
value={[value]}
|
||
onValueChange={handleChange}
|
||
aria-label="Orbit speed"
|
||
/>
|
||
<div className="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 flex justify-between px-0.5">
|
||
{ORBIT_SPEED_PRESETS.map((preset) => {
|
||
const pct =
|
||
((preset.value - ORBIT_SPEED_MIN) /
|
||
(ORBIT_SPEED_MAX - ORBIT_SPEED_MIN)) *
|
||
100;
|
||
const isActive =
|
||
Math.abs(preset.value - value) < ORBIT_SNAP_THRESHOLD;
|
||
return (
|
||
<span
|
||
key={preset.label}
|
||
className={`absolute h-1.5 w-1.5 rounded-full -translate-x-1/2 -translate-y-1/2 transition-colors ${
|
||
isActive ? "bg-foreground/50" : "bg-foreground/15"
|
||
}`}
|
||
style={{ left: `${pct}%` }}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TrailThicknessSlider({
|
||
value,
|
||
onChange,
|
||
}: {
|
||
value: number;
|
||
onChange: (v: number) => void;
|
||
}) {
|
||
return (
|
||
<div className="flex w-full items-center gap-3.5 rounded-xl px-3 py-2.5 text-left">
|
||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-foreground/5 text-foreground/35 ring-1 ring-foreground/6">
|
||
<Layers className="h-4 w-4" />
|
||
</div>
|
||
<div className="flex flex-1 min-w-0 flex-col gap-2">
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-[13px] font-medium text-foreground/80">
|
||
Trail thickness
|
||
</p>
|
||
<span className="text-[11px] font-semibold text-foreground/40 tabular-nums">
|
||
{value.toFixed(1)} px
|
||
</span>
|
||
</div>
|
||
<Slider
|
||
min={TRAIL_THICKNESS_MIN}
|
||
max={TRAIL_THICKNESS_MAX}
|
||
step={0.1}
|
||
value={[value]}
|
||
onValueChange={(vals) => onChange(vals[0])}
|
||
aria-label="Trail thickness"
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TrailDistanceSlider({
|
||
value,
|
||
onChange,
|
||
}: {
|
||
value: number;
|
||
onChange: (v: number) => void;
|
||
}) {
|
||
return (
|
||
<div className="flex w-full items-center gap-3.5 rounded-xl px-3 py-2.5 text-left">
|
||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-foreground/5 text-foreground/35 ring-1 ring-foreground/6">
|
||
<Route className="h-4 w-4" />
|
||
</div>
|
||
<div className="flex flex-1 min-w-0 flex-col gap-2">
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-[13px] font-medium text-foreground/80">
|
||
Trail distance
|
||
</p>
|
||
<span className="text-[11px] font-semibold text-foreground/40 tabular-nums">
|
||
{value} pts
|
||
</span>
|
||
</div>
|
||
<Slider
|
||
min={TRAIL_DISTANCE_MIN}
|
||
max={TRAIL_DISTANCE_MAX}
|
||
step={1}
|
||
value={[value]}
|
||
onValueChange={(vals) => onChange(vals[0])}
|
||
aria-label="Trail distance"
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AirspaceOpacitySlider({
|
||
value,
|
||
onChange,
|
||
}: {
|
||
value: number;
|
||
onChange: (v: number) => void;
|
||
}) {
|
||
return (
|
||
<div className="flex w-full items-center gap-3.5 rounded-xl px-3 py-2.5 text-left">
|
||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-foreground/5 text-foreground/35 ring-1 ring-foreground/6">
|
||
<Eye className="h-4 w-4" />
|
||
</div>
|
||
<div className="flex flex-1 min-w-0 flex-col gap-2">
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-[13px] font-medium text-foreground/80">
|
||
Airspace opacity
|
||
</p>
|
||
<span className="text-[11px] font-semibold text-foreground/40 tabular-nums">
|
||
{Math.round(value * 100)}%
|
||
</span>
|
||
</div>
|
||
<Slider
|
||
min={AIRSPACE_OPACITY_MIN}
|
||
max={AIRSPACE_OPACITY_MAX}
|
||
step={0.05}
|
||
value={[value]}
|
||
onValueChange={(vals) => onChange(vals[0])}
|
||
aria-label="Airspace opacity"
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function WeatherRadarOpacitySlider({
|
||
value,
|
||
onChange,
|
||
}: {
|
||
value: number;
|
||
onChange: (v: number) => void;
|
||
}) {
|
||
return (
|
||
<div className="flex w-full items-center gap-3.5 rounded-xl px-3 py-2.5 text-left">
|
||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-foreground/5 text-foreground/35 ring-1 ring-foreground/6">
|
||
<CloudRain className="h-4 w-4" />
|
||
</div>
|
||
<div className="flex flex-1 min-w-0 flex-col gap-2">
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-[13px] font-medium text-foreground/80">
|
||
Radar opacity
|
||
</p>
|
||
<span className="text-[11px] font-semibold text-foreground/40 tabular-nums">
|
||
{Math.round(value * 100)}%
|
||
</span>
|
||
</div>
|
||
<Slider
|
||
min={WEATHER_RADAR_OPACITY_MIN}
|
||
max={WEATHER_RADAR_OPACITY_MAX}
|
||
step={0.05}
|
||
value={[value]}
|
||
onValueChange={(vals) => onChange(vals[0])}
|
||
aria-label="Weather radar opacity"
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SectionHeader({ title }: { title: string }) {
|
||
return (
|
||
<div className="flex items-center gap-2 px-3 pt-3 pb-1">
|
||
<span className="text-[10px] font-bold tracking-widest text-foreground/25 uppercase">
|
||
{title}
|
||
</span>
|
||
<div className="h-px flex-1 bg-foreground/4" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SettingRow({
|
||
icon,
|
||
title,
|
||
description,
|
||
checked,
|
||
onChange,
|
||
badge,
|
||
}: {
|
||
icon: ReactNode;
|
||
title: string;
|
||
description: string;
|
||
checked: boolean;
|
||
onChange: (v: boolean) => void;
|
||
badge?: string;
|
||
}) {
|
||
return (
|
||
<button
|
||
role="switch"
|
||
aria-checked={checked}
|
||
onClick={() => onChange(!checked)}
|
||
className="flex w-full items-center gap-3.5 rounded-xl px-3 py-3 text-left transition-colors hover:bg-foreground/4 active:bg-foreground/6"
|
||
>
|
||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-foreground/5 text-foreground/35 ring-1 ring-foreground/6">
|
||
{icon}
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-1.5">
|
||
<p className="text-[13px] font-medium text-foreground/80">{title}</p>
|
||
{badge && (
|
||
<span className="inline-flex items-center rounded-md bg-indigo-500/15 px-1.5 py-0.5 text-[9px] font-bold tracking-wider text-indigo-300 ring-1 ring-indigo-400/20">
|
||
{badge}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<p className="mt-0.5 text-[11px] font-medium leading-relaxed text-foreground/22">
|
||
{description}
|
||
</p>
|
||
</div>
|
||
<Toggle checked={checked} />
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function SegmentRow<T extends string | number>({
|
||
icon,
|
||
title,
|
||
options,
|
||
value,
|
||
onChange,
|
||
}: {
|
||
icon: ReactNode;
|
||
title: string;
|
||
options: { label: string; value: T }[];
|
||
value: T;
|
||
onChange: (v: T) => void;
|
||
}) {
|
||
return (
|
||
<div className="flex w-full items-center gap-3.5 rounded-xl px-3 py-2.5 text-left">
|
||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-foreground/5 text-foreground/35 ring-1 ring-foreground/6">
|
||
{icon}
|
||
</div>
|
||
<p className="flex-1 min-w-0 text-[13px] font-medium text-foreground/80">
|
||
{title}
|
||
</p>
|
||
<div
|
||
role="radiogroup"
|
||
aria-label={title}
|
||
className="flex shrink-0 rounded-md bg-foreground/4 p-0.5 ring-1 ring-foreground/6"
|
||
>
|
||
{options.map((opt) => {
|
||
const isActive = opt.value === value;
|
||
return (
|
||
<button
|
||
key={String(opt.value)}
|
||
role="radio"
|
||
aria-checked={isActive}
|
||
onClick={() => onChange(opt.value)}
|
||
className={`relative rounded-md px-2 py-1 text-[11px] font-semibold transition-colors ${
|
||
isActive
|
||
? "text-foreground/90"
|
||
: "text-foreground/30 hover:text-foreground/50"
|
||
}`}
|
||
>
|
||
{isActive && (
|
||
<motion.div
|
||
layoutId={`seg-${title}`}
|
||
className="absolute inset-0 rounded-md bg-foreground/10"
|
||
transition={{
|
||
type: "spring",
|
||
stiffness: 500,
|
||
damping: 35,
|
||
}}
|
||
/>
|
||
)}
|
||
<span className="relative">{opt.label}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Toggle({ checked }: { checked: boolean }) {
|
||
return (
|
||
<div
|
||
className={`relative h-5 w-9 shrink-0 rounded-full transition-colors duration-200 ${
|
||
checked ? "bg-foreground/20" : "bg-foreground/6"
|
||
}`}
|
||
>
|
||
<motion.div
|
||
animate={{ x: checked ? 17 : 2 }}
|
||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||
className={`absolute top-0.75 h-3.5 w-3.5 rounded-full shadow-sm transition-colors duration-200 ${
|
||
checked ? "bg-foreground" : "bg-foreground/25"
|
||
}`}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const CHANGELOG = [
|
||
{
|
||
date: "Mar 22",
|
||
title: "3D aircraft models & smoother trails",
|
||
description:
|
||
"14 distinct 3D aircraft silhouettes assigned by ADS-B category and ICAO type code — from wide-bodies to helicopters. Models hosted on Cloudinary CDN with lazy loading and prefetch. Trail smoothing overhauled: 5-pass kernel filter, tighter corner rounding (15°), denser Catmull–Rom splines, and wider junction blending between historical and live data. Aircraft rendered 12% smaller for better proportions.",
|
||
},
|
||
{
|
||
date: "Mar 22",
|
||
title: "Multi-source flight data & circuit breaker",
|
||
description:
|
||
"Switched from OpenSky-only to a 2-tier fallback: adsb.lol → OpenSky (airplanes.live available via override). Each provider has its own parser normalising into a shared FlightState format. Circuit breaker tracks failures per provider and temporarily disables broken ones. Empty-response guard prevents data wipe-outs during transient failures, and an immediate re-fetch fires on network reconnect.",
|
||
},
|
||
{
|
||
date: "Mar 22",
|
||
title: "Code review fixes",
|
||
description:
|
||
"Fixed GPU memory monitor (duplicate WebGL enum cases, wrong byte sizes). Selection pulse halos now match aircraft height at all zoom levels. ATC stream properly cancels upstream on timeout. Airspace tile rate-limiter enforces spacing for queued requests. Photo fetch errors now surface to the UI. Spline cache clearing moved from useMemo to useEffect for React strict mode safety.",
|
||
},
|
||
{
|
||
date: "Mar 21",
|
||
title: "ATC feed lookup & GPU memory monitor",
|
||
description:
|
||
"New ATC lookup module — converts IATA to ICAO codes, finds nearby feeds by geographic proximity, and looks up feeds by airport or centre code. GPU memory monitor tracks WebGL resource allocations (textures, buffers, framebuffers) for debugging resource leaks.",
|
||
},
|
||
{
|
||
date: "Mar 20",
|
||
title: "Reliability & polish",
|
||
description:
|
||
"Serialised rate limiting in the flight API client. Logo cache with size limits and eviction. Registration country lookup via pre-built O(1) maps. Keyboard shortcuts focus trapping fix. SessionStorage guard for incognito mode. Airspace display toggle in map attribution. Utility functions extended with clamp().",
|
||
},
|
||
{
|
||
date: "Mar 13",
|
||
title: "Flight API client & rebase fixes",
|
||
description:
|
||
"New flight-api-client, flight-api-parsing, and flight-api-types modules. useFlights refactored to use the multi-source client — removed legacy credit management. useFlightMonitors switched to hex-based lookups. Fixed 6 files that diverged during rebase (IATA codes, globe mode ref, terrain attribution, cache eviction, OpenSky parsing).",
|
||
},
|
||
{
|
||
date: "Mar 11",
|
||
title: "Globe mode & aircraft photos",
|
||
description:
|
||
"Zoom out to see the entire earth as a 3D sphere with altitude-colored dots for every flight. Trails are now interpolated with centripetal Catmull\u2013Rom splines — a C\u00B9-continuous piecewise cubic that passes through every waypoint without overshooting, using \u03B1\u2009=\u20090.5 parameterization for natural curvature. Dark terrain, aircraft photo banners in flight cards, and a hard dot-to-flight cutover with zero overlap. Globe mode is in beta — find it in Settings.",
|
||
},
|
||
{
|
||
date: "Feb 22",
|
||
title: "Flight history tracking",
|
||
description:
|
||
"Full trail rendering for every tracked flight. Airline logo caching so they actually load.",
|
||
},
|
||
{
|
||
date: "Feb 21",
|
||
title: "First person view",
|
||
description:
|
||
"FPV mode — pick any plane and ride along with a HUD. Also added flight search by callsign.",
|
||
},
|
||
{
|
||
date: "Feb 17",
|
||
title: "Airline logos & attribution",
|
||
description:
|
||
"Proper logos for airlines, and attribution for OSM, OpenSky, CARTO, Esri, and everyone whose data makes this work.",
|
||
},
|
||
{
|
||
date: "Feb 15",
|
||
title: "9,000+ airports",
|
||
description:
|
||
"Went from a handful of cities to every airport we could find. Copilot helped build the dataset. Added keyboard shortcuts and click-to-select.",
|
||
},
|
||
{
|
||
date: "Feb 14",
|
||
title: "Day one",
|
||
description:
|
||
"Basic map, flight cards, trail rendering, orbit camera. Spent most of the day fighting Vercel timeouts and OpenSky IP blocks before realizing the API just supports CORS.",
|
||
},
|
||
];
|
||
|
||
export function AboutContent() {
|
||
return (
|
||
<ScrollArea className="h-full">
|
||
<div className="flex flex-col gap-5 p-5 pt-3">
|
||
<h3 className="text-[20px] font-bold tracking-tight text-foreground/90">
|
||
Aeris
|
||
</h3>
|
||
|
||
<div className="space-y-3 text-[13px] leading-relaxed text-foreground/40">
|
||
<p>
|
||
Live flight tracking in 3D. The planes you see are real — position
|
||
data comes from ADS-B Exchange, adsb.lol, and OpenSky Network,
|
||
updated every few seconds via ADS-B receivers people run on their
|
||
roofs worldwide.
|
||
</p>
|
||
<p>
|
||
You can search through 9,000+ airports, jump into first-person view
|
||
to ride along with any plane, or just leave it on a screen and watch
|
||
things move. Trails change color with altitude so you can tell
|
||
who's cruising at 35,000ft and who's on approach.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="h-px w-full bg-foreground/6" />
|
||
|
||
<p className="text-[12px] leading-relaxed text-foreground/30">
|
||
Built by a human, not just LLMs.{" "}
|
||
<a
|
||
href="https://github.com/kewonit"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="text-foreground/55 underline decoration-foreground/15 underline-offset-2 hover:text-foreground/70 transition-colors"
|
||
>
|
||
kewonit
|
||
</a>
|
||
{" · "}
|
||
<a
|
||
href="https://x.com/kewonit"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="text-foreground/55 underline decoration-foreground/15 underline-offset-2 hover:text-foreground/70 transition-colors"
|
||
>
|
||
@kewonit
|
||
</a>
|
||
. Open to internships —{" "}
|
||
<a
|
||
href="mailto:kew@edbn.me"
|
||
className="text-foreground/55 underline decoration-foreground/15 underline-offset-2 hover:text-foreground/70 transition-colors"
|
||
>
|
||
kew@edbn.me
|
||
</a>
|
||
</p>
|
||
<p className="text-[12px] leading-relaxed text-foreground/30">
|
||
Source is on{" "}
|
||
<a
|
||
href="https://github.com/kewonit/aeris"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="text-foreground/55 underline decoration-foreground/15 underline-offset-2 hover:text-foreground/70 transition-colors"
|
||
>
|
||
GitHub
|
||
</a>
|
||
. Got a question or just wanna say hi?{" "}
|
||
<a
|
||
href="mailto:aeris@edbn.me"
|
||
className="text-foreground/55 underline decoration-foreground/15 underline-offset-2 hover:text-foreground/70 transition-colors"
|
||
>
|
||
aeris@edbn.me
|
||
</a>
|
||
</p>
|
||
</div>
|
||
</ScrollArea>
|
||
);
|
||
}
|
||
|
||
export function ChangelogContent() {
|
||
return (
|
||
<ScrollArea className="h-full">
|
||
<div className="flex flex-col gap-4 p-5 pt-3">
|
||
{CHANGELOG.map((entry, i) => (
|
||
<div key={`${entry.date}-${i}`} className="flex gap-3">
|
||
<span className="shrink-0 pt-0.5 text-[11px] tabular-nums text-foreground/20 w-11">
|
||
{entry.date}
|
||
</span>
|
||
<div className="min-w-0">
|
||
<p className="text-[13px] font-medium text-foreground/55">
|
||
{entry.title}
|
||
</p>
|
||
<p className="mt-0.5 text-[12px] leading-relaxed text-foreground/30">
|
||
{entry.description}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</ScrollArea>
|
||
);
|
||
}
|