Files
aeris/src/components/map/use-globe-dots.ts
kew 147b69b944 feat(map): enhance globe projection handling and improve altitude color representation (#14)
* 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
2026-03-11 00:54:51 +05:30

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 };
}