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
This commit is contained in:
377
src/components/map/use-globe-dots.ts
Normal file
377
src/components/map/use-globe-dots.ts
Normal file
@ -0,0 +1,377 @@
|
||||
"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 };
|
||||
}
|
||||
Reference in New Issue
Block a user