* 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
378 lines
12 KiB
TypeScript
378 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, type MutableRefObject } from "react";
|
|
import maplibregl from "maplibre-gl";
|
|
import type { FlightState } from "@/lib/opensky";
|
|
import { altitudeToColor } from "@/lib/flight-utils";
|
|
import { type PickingInfo } from "@deck.gl/core";
|
|
import type { TrailEntry } from "@/hooks/use-trail-history";
|
|
import {
|
|
densifyGreatCircle2D,
|
|
splitAtAntimeridian,
|
|
unwrapLngPath,
|
|
} from "@/lib/geo";
|
|
import {
|
|
GLOBE_NATIVE_ZOOM_CEIL,
|
|
GLOBE_SWITCH_ZOOM,
|
|
GEOJSON_THROTTLE_MS,
|
|
GEOJSON_DEBOUNCE_MS,
|
|
} from "./flight-layer-constants";
|
|
|
|
const SOURCE_ID = "globe-aircraft-source";
|
|
const LAYER_ID = "globe-aircraft-dots";
|
|
const TRAIL_SOURCE_ID = "globe-trail-source";
|
|
const TRAIL_LAYER_ID = "globe-trail-lines";
|
|
|
|
/**
|
|
* Custom hook that manages native MapLibre GeoJSON circle + line layers for
|
|
* rendering aircraft dots AND trail lines at low globe zoom levels where
|
|
* deck.gl accuracy degrades. Native MapLibre layers follow the globe
|
|
* curvature perfectly and handle antimeridian crossings automatically.
|
|
*/
|
|
export function useGlobeDots(
|
|
map: maplibregl.Map | null,
|
|
isLoaded: boolean,
|
|
flightsRef: MutableRefObject<FlightState[]>,
|
|
trailsRef: MutableRefObject<TrailEntry[]>,
|
|
dataTimestampRef: MutableRefObject<number>,
|
|
onClickRef: MutableRefObject<(info: PickingInfo<FlightState> | null) => void>,
|
|
showTrailsRef: MutableRefObject<boolean>,
|
|
) {
|
|
const lastGeoJsonUpdateRef = useRef(0);
|
|
const lastGeoJsonTimestampRef = useRef(0);
|
|
const geoJsonClearedRef = useRef(false);
|
|
const globeZoomEnteredAtRef = useRef(0);
|
|
|
|
// Set up MapLibre source, layer, and event handlers
|
|
useEffect(() => {
|
|
if (!map || !isLoaded) return;
|
|
|
|
const ensureGlobeLayers = () => {
|
|
// ── Aircraft dots ──
|
|
if (!map.getSource(SOURCE_ID)) {
|
|
map.addSource(SOURCE_ID, {
|
|
type: "geojson",
|
|
data: { type: "FeatureCollection", features: [] },
|
|
});
|
|
}
|
|
|
|
if (!map.getLayer(LAYER_ID)) {
|
|
map.addLayer({
|
|
id: LAYER_ID,
|
|
type: "circle",
|
|
source: SOURCE_ID,
|
|
paint: {
|
|
"circle-radius": [
|
|
"interpolate",
|
|
["exponential", 1.5],
|
|
["zoom"],
|
|
0,
|
|
["interpolate", ["linear"], ["get", "alt_norm"], 0, 1.2, 1, 2.0],
|
|
2,
|
|
["interpolate", ["linear"], ["get", "alt_norm"], 0, 1.8, 1, 2.8],
|
|
GLOBE_NATIVE_ZOOM_CEIL,
|
|
["interpolate", ["linear"], ["get", "alt_norm"], 0, 3.0, 1, 5.0],
|
|
],
|
|
"circle-color": ["get", "color"],
|
|
"circle-opacity": [
|
|
"interpolate",
|
|
["linear"],
|
|
["zoom"],
|
|
GLOBE_SWITCH_ZOOM - 0.05,
|
|
0.9,
|
|
GLOBE_SWITCH_ZOOM,
|
|
0,
|
|
],
|
|
"circle-stroke-color": "rgba(255, 255, 255, 0.5)",
|
|
"circle-stroke-width": [
|
|
"interpolate",
|
|
["linear"],
|
|
["zoom"],
|
|
0,
|
|
0.3,
|
|
GLOBE_SWITCH_ZOOM,
|
|
0.8,
|
|
],
|
|
"circle-blur": 0.1,
|
|
},
|
|
});
|
|
}
|
|
|
|
// ── Trail lines ──
|
|
if (!map.getSource(TRAIL_SOURCE_ID)) {
|
|
map.addSource(TRAIL_SOURCE_ID, {
|
|
type: "geojson",
|
|
data: { type: "FeatureCollection", features: [] },
|
|
});
|
|
}
|
|
|
|
if (!map.getLayer(TRAIL_LAYER_ID)) {
|
|
map.addLayer(
|
|
{
|
|
id: TRAIL_LAYER_ID,
|
|
type: "line",
|
|
source: TRAIL_SOURCE_ID,
|
|
paint: {
|
|
"line-color": ["get", "color"],
|
|
"line-width": [
|
|
"interpolate",
|
|
["linear"],
|
|
["zoom"],
|
|
0,
|
|
0.8,
|
|
2,
|
|
1.2,
|
|
GLOBE_NATIVE_ZOOM_CEIL,
|
|
1.8,
|
|
],
|
|
"line-opacity": [
|
|
"interpolate",
|
|
["linear"],
|
|
["zoom"],
|
|
GLOBE_SWITCH_ZOOM - 0.05,
|
|
0.65,
|
|
GLOBE_SWITCH_ZOOM,
|
|
0,
|
|
],
|
|
},
|
|
layout: {
|
|
"line-cap": "round",
|
|
"line-join": "round",
|
|
},
|
|
},
|
|
LAYER_ID, // render trails below dots
|
|
);
|
|
}
|
|
};
|
|
|
|
ensureGlobeLayers();
|
|
map.on("style.load", ensureGlobeLayers);
|
|
|
|
const onDotClick = (
|
|
e: maplibregl.MapMouseEvent & { features?: maplibregl.GeoJSONFeature[] },
|
|
) => {
|
|
const icao24 = e.features?.[0]?.properties?.icao24;
|
|
if (!icao24) return;
|
|
const flight = flightsRef.current.find((f) => f.icao24 === icao24);
|
|
if (flight) {
|
|
onClickRef.current({ object: flight } as PickingInfo<FlightState>);
|
|
}
|
|
};
|
|
map.on("click", LAYER_ID, onDotClick);
|
|
|
|
const onDotEnter = () => {
|
|
map.getCanvas().style.cursor = "pointer";
|
|
};
|
|
const onDotLeave = () => {
|
|
map.getCanvas().style.cursor = "";
|
|
};
|
|
map.on("mouseenter", LAYER_ID, onDotEnter);
|
|
map.on("mouseleave", LAYER_ID, onDotLeave);
|
|
|
|
return () => {
|
|
map.off("style.load", ensureGlobeLayers);
|
|
map.off("click", LAYER_ID, onDotClick);
|
|
map.off("mouseenter", LAYER_ID, onDotEnter);
|
|
map.off("mouseleave", LAYER_ID, onDotLeave);
|
|
try {
|
|
if (map.getLayer(TRAIL_LAYER_ID)) map.removeLayer(TRAIL_LAYER_ID);
|
|
if (map.getSource(TRAIL_SOURCE_ID)) map.removeSource(TRAIL_SOURCE_ID);
|
|
if (map.getLayer(LAYER_ID)) map.removeLayer(LAYER_ID);
|
|
if (map.getSource(SOURCE_ID)) map.removeSource(SOURCE_ID);
|
|
} catch {
|
|
/* map already removed */
|
|
}
|
|
};
|
|
}, [map, isLoaded, flightsRef, onClickRef]);
|
|
|
|
/**
|
|
* Called from the RAF animation loop. Updates (or clears) both the dot
|
|
* GeoJSON source and the trail line GeoJSON source based on current
|
|
* zoom level and globe mode.
|
|
*/
|
|
function updateGlobeDots(isGlobe: boolean, currentZoom: number, now: number) {
|
|
if (!map) return;
|
|
|
|
const MAX_ALTITUDE_METERS = 13000;
|
|
|
|
// Hide layers unless globe mode AND below switch zoom
|
|
const dotsVisible = isGlobe && currentZoom < GLOBE_NATIVE_ZOOM_CEIL;
|
|
try {
|
|
if (map.getLayer(LAYER_ID)) {
|
|
map.setLayoutProperty(
|
|
LAYER_ID,
|
|
"visibility",
|
|
dotsVisible ? "visible" : "none",
|
|
);
|
|
}
|
|
if (map.getLayer(TRAIL_LAYER_ID)) {
|
|
map.setLayoutProperty(
|
|
TRAIL_LAYER_ID,
|
|
"visibility",
|
|
dotsVisible ? "visible" : "none",
|
|
);
|
|
}
|
|
} catch {
|
|
/* layer may not exist yet */
|
|
}
|
|
|
|
if (isGlobe) {
|
|
if (currentZoom < GLOBE_NATIVE_ZOOM_CEIL) {
|
|
if (globeZoomEnteredAtRef.current === 0) {
|
|
globeZoomEnteredAtRef.current = now;
|
|
}
|
|
const stableMs = now - globeZoomEnteredAtRef.current;
|
|
|
|
if (stableMs >= GEOJSON_DEBOUNCE_MS) {
|
|
const dataChanged =
|
|
dataTimestampRef.current !== lastGeoJsonTimestampRef.current;
|
|
const throttleExpired =
|
|
now - lastGeoJsonUpdateRef.current > GEOJSON_THROTTLE_MS;
|
|
|
|
if (dataChanged || throttleExpired) {
|
|
// ── Update aircraft dots ──
|
|
const dotSrc = map.getSource(SOURCE_ID) as
|
|
| maplibregl.GeoJSONSource
|
|
| undefined;
|
|
if (dotSrc) {
|
|
const flights = flightsRef.current;
|
|
const features = [];
|
|
for (const f of flights) {
|
|
if (
|
|
f.longitude == null ||
|
|
f.latitude == null ||
|
|
!Number.isFinite(f.longitude) ||
|
|
!Number.isFinite(f.latitude)
|
|
)
|
|
continue;
|
|
const c = altitudeToColor(f.baroAltitude);
|
|
const altNorm = Math.min(
|
|
1,
|
|
Math.max(0, (f.baroAltitude ?? 0) / MAX_ALTITUDE_METERS),
|
|
);
|
|
features.push({
|
|
type: "Feature" as const,
|
|
geometry: {
|
|
type: "Point" as const,
|
|
coordinates: [f.longitude, f.latitude],
|
|
},
|
|
properties: {
|
|
icao24: f.icao24,
|
|
color: `rgb(${c[0]},${c[1]},${c[2]})`,
|
|
alt_norm: altNorm,
|
|
},
|
|
});
|
|
}
|
|
dotSrc.setData({ type: "FeatureCollection", features });
|
|
}
|
|
|
|
// ── Update trail lines ──
|
|
const trailSrc = map.getSource(TRAIL_SOURCE_ID) as
|
|
| maplibregl.GeoJSONSource
|
|
| undefined;
|
|
if (trailSrc) {
|
|
// Respect the showTrails user setting
|
|
if (!showTrailsRef.current) {
|
|
trailSrc.setData({ type: "FeatureCollection", features: [] });
|
|
} else {
|
|
const trails = trailsRef.current;
|
|
const trailFeatures: GeoJSON.Feature[] = [];
|
|
|
|
for (const trail of trails) {
|
|
if (trail.path.length < 2) continue;
|
|
|
|
// Get the trail color from the most recent altitude
|
|
const lastAlt =
|
|
trail.baroAltitude ??
|
|
trail.altitudes[trail.altitudes.length - 1] ??
|
|
0;
|
|
const c = altitudeToColor(lastAlt);
|
|
const color = `rgba(${c[0]},${c[1]},${c[2]},0.7)`;
|
|
|
|
// Limit to last N points for performance at globe zoom
|
|
const maxPts = 60;
|
|
const rawPath =
|
|
trail.path.length > maxPts
|
|
? trail.path.slice(trail.path.length - maxPts)
|
|
: trail.path;
|
|
|
|
// Unwrap longitudes for continuity
|
|
const unwrapped = unwrapLngPath(rawPath);
|
|
|
|
// Densify along great-circle arcs so trails curve
|
|
// properly on the globe (segments > 0.3° get subdivided)
|
|
const densified = densifyGreatCircle2D(unwrapped, 0.3, 16);
|
|
|
|
// Normalize longitudes back to [-180, 180] range
|
|
const normalized: [number, number][] = densified.map(
|
|
([lng, lat]) => {
|
|
let normLng = lng;
|
|
while (normLng > 180) normLng -= 360;
|
|
while (normLng < -180) normLng += 360;
|
|
return [normLng, lat];
|
|
},
|
|
);
|
|
|
|
// Split at antimeridian crossings for MapLibre
|
|
const segments = splitAtAntimeridian(normalized);
|
|
|
|
for (const seg of segments) {
|
|
if (seg.length < 2) continue;
|
|
trailFeatures.push({
|
|
type: "Feature",
|
|
geometry: {
|
|
type: "LineString",
|
|
coordinates: seg,
|
|
},
|
|
properties: { color, icao24: trail.icao24 },
|
|
});
|
|
}
|
|
}
|
|
|
|
trailSrc.setData({
|
|
type: "FeatureCollection",
|
|
features: trailFeatures,
|
|
});
|
|
} // end showTrails check
|
|
}
|
|
|
|
lastGeoJsonUpdateRef.current = now;
|
|
lastGeoJsonTimestampRef.current = dataTimestampRef.current;
|
|
geoJsonClearedRef.current = false;
|
|
}
|
|
}
|
|
} else {
|
|
globeZoomEnteredAtRef.current = 0;
|
|
if (!geoJsonClearedRef.current) {
|
|
clearNativeSources();
|
|
}
|
|
}
|
|
} else if (!geoJsonClearedRef.current) {
|
|
clearNativeSources();
|
|
}
|
|
}
|
|
|
|
function clearNativeSources() {
|
|
if (!map) return;
|
|
try {
|
|
const dotSrc = map.getSource(SOURCE_ID) as
|
|
| maplibregl.GeoJSONSource
|
|
| undefined;
|
|
if (dotSrc) {
|
|
dotSrc.setData({ type: "FeatureCollection", features: [] });
|
|
}
|
|
const trailSrc = map.getSource(TRAIL_SOURCE_ID) as
|
|
| maplibregl.GeoJSONSource
|
|
| undefined;
|
|
if (trailSrc) {
|
|
trailSrc.setData({ type: "FeatureCollection", features: [] });
|
|
}
|
|
geoJsonClearedRef.current = true;
|
|
} catch {
|
|
/* source may be removed */
|
|
}
|
|
}
|
|
|
|
return { updateGlobeDots };
|
|
}
|