Added basic height filtering.
This commit is contained in:
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
74
src/components/ui/altitude-filter.tsx
Normal file
74
src/components/ui/altitude-filter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user