Added basic height filtering.

This commit is contained in:
2026-03-31 16:03:17 -05:00
parent 498504b73b
commit a246765884
6 changed files with 262 additions and 3 deletions

View File

@ -30,6 +30,7 @@ const ControlPanel = dynamic(() =>
import("@/components/ui/control-panel").then((mod) => mod.ControlPanel),
);
import { AltitudeLegend } from "@/components/ui/altitude-legend";
import { AltitudeFilter } from "@/components/ui/altitude-filter";
import { CameraControls } from "@/components/ui/camera-controls";
import { StatusBar } from "@/components/ui/status-bar";
import { MapAttribution } from "@/components/ui/map-attribution";
@ -149,8 +150,27 @@ function FlightTrackerInner() {
fpvSeedCenter,
);
const displayFlights = flights;
const displayTrails = useTrailHistory(displayFlights);
// Trail history is built from ALL flights so filtered-out planes keep their trails
const allTrails = useTrailHistory(flights);
const altMaxFt = settings.altitudeFilterMax;
const altMaxM = altMaxFt * 0.3048; // baroAltitude is stored in meters
const displayFlights = useMemo(
() =>
altMaxFt >= 50_000
? flights
: flights.filter(
(f) => f.baroAltitude == null || f.baroAltitude <= altMaxM,
),
[flights, altMaxFt, altMaxM],
);
// Filter trails by the flight's current altitude, not the trail points
const displayTrails = useMemo(() => {
if (altMaxFt >= 50_000) return allTrails;
const visible = new Set(displayFlights.map((f) => f.icao24));
return allTrails.filter((t) => visible.has(t.icao24));
}, [allTrails, displayFlights, altMaxFt]);
// Single Map for O(1) flight lookups — replaces 4× O(n) find() calls per poll
const displayFlightMap = useMemo(() => {
@ -550,6 +570,9 @@ function FlightTrackerInner() {
<div className="pointer-events-auto">
<CameraControls />
</div>
<div className="pointer-events-auto">
<AltitudeFilter />
</div>
<div className="pointer-events-auto">
<AltitudeLegend />
</div>

View File

@ -215,6 +215,61 @@ export function CameraController({
};
}, [map, isLoaded, city]);
// Lock-north-up: disable native dragRotate (which couples bearing + pitch)
// and replace with a custom right-click drag handler for pitch-only.
useEffect(() => {
if (!map || !isLoaded || !settings.lockNorthUp) return;
map.dragRotate.disable();
map.setBearing(0);
// Enforce bearing 0 for programmatic camera moves (flyTo, easeTo, orbit)
const onRotate = () => {
if (Math.abs(map.getBearing()) > 0.01) map.setBearing(0);
};
map.on("rotate", onRotate);
// Custom right-click drag → pitch only
const container = map.getContainer();
let dragging = false;
let lastY = 0;
const onContextMenu = (e: MouseEvent) => e.preventDefault();
const onMouseDown = (e: MouseEvent) => {
if (e.button !== 2) return; // right-click only
dragging = true;
lastY = e.clientY;
e.preventDefault();
};
const onMouseMove = (e: MouseEvent) => {
if (!dragging) return;
const dy = e.clientY - lastY;
lastY = e.clientY;
const pitch = map.getPitch() - dy * 0.3;
map.setPitch(Math.max(0, Math.min(pitch, 80)));
};
const onMouseUp = () => {
dragging = false;
};
container.addEventListener("contextmenu", onContextMenu);
container.addEventListener("mousedown", onMouseDown);
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
return () => {
map.off("rotate", onRotate);
container.removeEventListener("contextmenu", onContextMenu);
container.removeEventListener("mousedown", onMouseDown);
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
map.dragRotate.enable();
};
}, [map, isLoaded, settings.lockNorthUp]);
// Keyboard camera hook
useKeyboardCamera(
map,

View File

@ -0,0 +1,74 @@
"use client";
import { motion } from "motion/react";
import {
useSettings,
ALTITUDE_FILTER_LEVELS,
} from "@/hooks/use-settings";
function formatAlt(ft: number): string {
if (ft === 0) return "0";
if (ft >= 1_000) return `${ft / 1_000}k`;
return `${ft}`;
}
export function AltitudeFilter() {
const { settings, update } = useSettings();
const max = settings.altitudeFilterMax;
function handleTap(level: number) {
if (level === max) {
const idx = ALTITUDE_FILTER_LEVELS.indexOf(
level as (typeof ALTITUDE_FILTER_LEVELS)[number],
);
const next =
idx > 0 ? ALTITUDE_FILTER_LEVELS[idx - 1] : ALTITUDE_FILTER_LEVELS[0];
update("altitudeFilterMax", next);
} else {
update("altitudeFilterMax", level);
}
}
return (
<motion.div
initial={{ opacity: 0, x: 12 }}
animate={{ opacity: 1, x: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 24, delay: 0.5 }}
className="flex flex-col items-end gap-1 rounded-xl border p-1.5 backdrop-blur-2xl sm:p-2"
style={{
borderColor: "rgb(var(--ui-fg) / 0.06)",
backgroundColor: "rgb(var(--ui-bg) / 0.5)",
}}
>
<p
className="w-full text-center text-[8px] font-semibold tracking-widest uppercase sm:text-[10px]"
style={{ color: "rgb(var(--ui-fg) / 0.3)" }}
>
FL Filter
</p>
<div className="grid grid-cols-2 gap-0.5 sm:grid-cols-1 sm:gap-0.5">
{[...ALTITUDE_FILTER_LEVELS].reverse().map((level) => {
const active = level <= max;
return (
<button
key={level}
type="button"
onClick={() => handleTap(level)}
className="min-h-[28px] min-w-[36px] rounded-md px-1.5 py-0.5 text-[10px] font-medium transition-colors active:scale-95 sm:min-h-[22px] sm:min-w-[44px] sm:text-[10px]"
style={{
backgroundColor: active
? "rgb(var(--ui-fg) / 0.1)"
: "transparent",
color: active
? "rgb(var(--ui-fg) / 0.7)"
: "rgb(var(--ui-fg) / 0.2)",
}}
>
{formatAlt(level)}
</button>
);
})}
</div>
</motion.div>
);
}

View File

@ -9,7 +9,9 @@ import {
ChevronsDown,
RotateCw,
RotateCcw,
Navigation,
} from "lucide-react";
import { useSettings } from "@/hooks/use-settings";
type CameraActionType = "zoom" | "pitch" | "bearing";
@ -94,6 +96,9 @@ function Divider() {
}
export function CameraControls() {
const { settings, update } = useSettings();
const locked = settings.lockNorthUp;
return (
<motion.div
initial={{ opacity: 0, x: 12 }}
@ -170,6 +175,31 @@ export function CameraControls() {
>
<RotateCcw 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)" }}
/>
<motion.button
type="button"
className="flex h-8 w-8 items-center justify-center select-none"
style={{
color: locked
? "rgb(var(--ui-fg) / 0.85)"
: "rgb(var(--ui-fg) / 0.45)",
}}
whileHover={{ scale: 1.12 }}
whileTap={{ scale: 0.88 }}
aria-label="Lock north up"
title={locked ? "North locked — tap to unlock" : "Lock north up"}
onClick={() => update("lockNorthUp", !locked)}
>
<Navigation
className="h-3.5 w-3.5"
fill={locked ? "currentColor" : "none"}
/>
</motion.button>
</motion.div>
);
}