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:
420
src/lib/trail-cleanup.ts
Normal file
420
src/lib/trail-cleanup.ts
Normal file
@ -0,0 +1,420 @@
|
||||
/**
|
||||
* Path cleanup algorithms for flight trails.
|
||||
*
|
||||
* Provides:
|
||||
* - Curvature-aware adaptive downsampling (Ramer-Douglas-Peucker)
|
||||
* - Spike / backtrack point removal
|
||||
* - Sharp-corner rounding (3D and 2D Bézier arcs)
|
||||
* - Post-spline self-intersection (loop) detection and removal
|
||||
*/
|
||||
|
||||
import type { ElevatedPoint } from "./trail-spline";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Curvature-aware adaptive downsampling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Downsample a dense path to at most `maxPoints` while preserving detail
|
||||
* at curves. Uses the Ramer-Douglas-Peucker algorithm adapted for 3D
|
||||
* elevated points.
|
||||
*/
|
||||
export function adaptiveDownsample(
|
||||
points: ElevatedPoint[],
|
||||
maxPoints: number,
|
||||
): ElevatedPoint[] {
|
||||
if (points.length <= maxPoints) return points;
|
||||
|
||||
let lo = 0;
|
||||
let hi = 5;
|
||||
let bestResult = points;
|
||||
|
||||
for (let iter = 0; iter < 20; iter++) {
|
||||
const mid = (lo + hi) / 2;
|
||||
const result = rdpSimplify(points, mid);
|
||||
if (result.length <= maxPoints) {
|
||||
bestResult = result;
|
||||
hi = mid;
|
||||
} else {
|
||||
lo = mid;
|
||||
}
|
||||
if (Math.abs(result.length - maxPoints) < maxPoints * 0.05) break;
|
||||
}
|
||||
|
||||
if (bestResult.length < maxPoints * 0.5 && points.length > maxPoints) {
|
||||
return uniformSample(points, maxPoints);
|
||||
}
|
||||
|
||||
return bestResult;
|
||||
}
|
||||
|
||||
/** Ramer-Douglas-Peucker simplification for 3D points. */
|
||||
function rdpSimplify(
|
||||
points: ElevatedPoint[],
|
||||
epsilon: number,
|
||||
): ElevatedPoint[] {
|
||||
if (points.length <= 2) return points.slice();
|
||||
|
||||
const first = points[0];
|
||||
const last = points[points.length - 1];
|
||||
let maxDist = 0;
|
||||
let maxIdx = 0;
|
||||
|
||||
for (let i = 1; i < points.length - 1; i++) {
|
||||
const d = perpendicularDistance(points[i], first, last);
|
||||
if (d > maxDist) {
|
||||
maxDist = d;
|
||||
maxIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (maxDist > epsilon) {
|
||||
const left = rdpSimplify(points.slice(0, maxIdx + 1), epsilon);
|
||||
const right = rdpSimplify(points.slice(maxIdx), epsilon);
|
||||
return [...left.slice(0, -1), ...right];
|
||||
}
|
||||
|
||||
return [first, last];
|
||||
}
|
||||
|
||||
/** Perpendicular distance from a point to a line segment (2D, using lng/lat). */
|
||||
function perpendicularDistance(
|
||||
point: ElevatedPoint,
|
||||
lineStart: ElevatedPoint,
|
||||
lineEnd: ElevatedPoint,
|
||||
): number {
|
||||
const dx = lineEnd[0] - lineStart[0];
|
||||
const dy = lineEnd[1] - lineStart[1];
|
||||
const denom = dx * dx + dy * dy;
|
||||
|
||||
if (denom < 1e-12) {
|
||||
const ex = point[0] - lineStart[0];
|
||||
const ey = point[1] - lineStart[1];
|
||||
return Math.sqrt(ex * ex + ey * ey);
|
||||
}
|
||||
|
||||
const t = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1,
|
||||
((point[0] - lineStart[0]) * dx + (point[1] - lineStart[1]) * dy) / denom,
|
||||
),
|
||||
);
|
||||
|
||||
const projX = lineStart[0] + t * dx;
|
||||
const projY = lineStart[1] + t * dy;
|
||||
const ex = point[0] - projX;
|
||||
const ey = point[1] - projY;
|
||||
return Math.sqrt(ex * ex + ey * ey);
|
||||
}
|
||||
|
||||
/** Uniform sampling — picks evenly-spaced points, always including first and last. */
|
||||
function uniformSample(
|
||||
points: ElevatedPoint[],
|
||||
count: number,
|
||||
): ElevatedPoint[] {
|
||||
if (points.length <= count) return points;
|
||||
const out: ElevatedPoint[] = [points[0]];
|
||||
const step = (points.length - 1) / (count - 1);
|
||||
for (let i = 1; i < count - 1; i++) {
|
||||
out.push(points[Math.round(i * step)]);
|
||||
}
|
||||
out.push(points[points.length - 1]);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spike / backtrack removal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Remove "spike" points where the path reverses direction sharply,
|
||||
* creating V-shaped artifacts.
|
||||
*/
|
||||
export function removeSpikePoints(
|
||||
path: [number, number][],
|
||||
altitudes: Array<number | null>,
|
||||
cosThreshold: number = -0.5,
|
||||
): { path: [number, number][]; altitudes: Array<number | null> } {
|
||||
if (path.length < 3) return { path, altitudes };
|
||||
|
||||
const keep: boolean[] = new Array(path.length).fill(true);
|
||||
let removed = 0;
|
||||
|
||||
for (let pass = 0; pass < 3; pass++) {
|
||||
let changed = false;
|
||||
for (let i = 1; i < path.length - 1; i++) {
|
||||
if (!keep[i]) continue;
|
||||
|
||||
let prevIdx = i - 1;
|
||||
while (prevIdx >= 0 && !keep[prevIdx]) prevIdx--;
|
||||
if (prevIdx < 0) continue;
|
||||
|
||||
let nextIdx = i + 1;
|
||||
while (nextIdx < path.length && !keep[nextIdx]) nextIdx++;
|
||||
if (nextIdx >= path.length) continue;
|
||||
|
||||
const prev = path[prevIdx];
|
||||
const curr = path[i];
|
||||
const next = path[nextIdx];
|
||||
|
||||
const dx1 = curr[0] - prev[0];
|
||||
const dy1 = curr[1] - prev[1];
|
||||
const dx2 = next[0] - curr[0];
|
||||
const dy2 = next[1] - curr[1];
|
||||
|
||||
const len1 = Math.sqrt(dx1 * dx1 + dy1 * dy1);
|
||||
const len2 = Math.sqrt(dx2 * dx2 + dy2 * dy2);
|
||||
|
||||
if (len1 < 1e-10 || len2 < 1e-10) continue;
|
||||
|
||||
const cos = (dx1 * dx2 + dy1 * dy2) / (len1 * len2);
|
||||
|
||||
if (cos < cosThreshold) {
|
||||
keep[i] = false;
|
||||
removed++;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!changed) break;
|
||||
}
|
||||
|
||||
if (removed === 0) return { path, altitudes };
|
||||
|
||||
const newPath: [number, number][] = [];
|
||||
const newAlt: Array<number | null> = [];
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
if (keep[i]) {
|
||||
newPath.push(path[i]);
|
||||
newAlt.push(altitudes[i] ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
return { path: newPath, altitudes: newAlt };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sharp-corner rounding (pre-spline loop prevention)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Round sharp corners in a 3D waypoint path by replacing each sharp turn
|
||||
* with a smooth quadratic Bézier arc.
|
||||
*/
|
||||
export function roundSharpCorners3D(
|
||||
points: ElevatedPoint[],
|
||||
thresholdDeg: number = 20,
|
||||
): ElevatedPoint[] {
|
||||
if (points.length < 3) return points;
|
||||
|
||||
const thresholdRad = (thresholdDeg * Math.PI) / 180;
|
||||
const result: ElevatedPoint[] = [points[0]];
|
||||
|
||||
for (let i = 1; i < points.length - 1; i++) {
|
||||
const prev = points[i - 1];
|
||||
const curr = points[i];
|
||||
const next = points[i + 1];
|
||||
|
||||
const distPrev = Math.sqrt(
|
||||
(curr[0] - prev[0]) ** 2 + (curr[1] - prev[1]) ** 2,
|
||||
);
|
||||
const distNext = Math.sqrt(
|
||||
(next[0] - curr[0]) ** 2 + (next[1] - curr[1]) ** 2,
|
||||
);
|
||||
|
||||
if (distPrev < 5e-4 || distNext < 5e-4) {
|
||||
result.push(curr);
|
||||
continue;
|
||||
}
|
||||
|
||||
const headingIn = Math.atan2(curr[0] - prev[0], curr[1] - prev[1]);
|
||||
const headingOut = Math.atan2(next[0] - curr[0], next[1] - curr[1]);
|
||||
let delta = headingOut - headingIn;
|
||||
if (delta > Math.PI) delta -= 2 * Math.PI;
|
||||
if (delta < -Math.PI) delta += 2 * Math.PI;
|
||||
const absDelta = Math.abs(delta);
|
||||
|
||||
if (absDelta > thresholdRad) {
|
||||
const setback = Math.min(distPrev, distNext) * 0.45;
|
||||
|
||||
const t1Factor = setback / distPrev;
|
||||
const T1: ElevatedPoint = [
|
||||
curr[0] + (prev[0] - curr[0]) * t1Factor,
|
||||
curr[1] + (prev[1] - curr[1]) * t1Factor,
|
||||
curr[2] + (prev[2] - curr[2]) * t1Factor,
|
||||
];
|
||||
|
||||
const t2Factor = setback / distNext;
|
||||
const T2: ElevatedPoint = [
|
||||
curr[0] + (next[0] - curr[0]) * t2Factor,
|
||||
curr[1] + (next[1] - curr[1]) * t2Factor,
|
||||
curr[2] + (next[2] - curr[2]) * t2Factor,
|
||||
];
|
||||
|
||||
const arcCount = Math.max(
|
||||
6,
|
||||
Math.min(14, Math.round((10 * absDelta) / Math.PI)),
|
||||
);
|
||||
|
||||
for (let j = 0; j <= arcCount; j++) {
|
||||
const t = j / arcCount;
|
||||
const u = 1 - t;
|
||||
result.push([
|
||||
u * u * T1[0] + 2 * u * t * curr[0] + t * t * T2[0],
|
||||
u * u * T1[1] + 2 * u * t * curr[1] + t * t * T2[1],
|
||||
u * u * T1[2] + 2 * u * t * curr[2] + t * t * T2[2],
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
result.push(curr);
|
||||
}
|
||||
}
|
||||
|
||||
result.push(points[points.length - 1]);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Round sharp corners in a 2D path (for active / live trails).
|
||||
* Same algorithm as roundSharpCorners3D but operates on [lng, lat] arrays.
|
||||
*/
|
||||
export function roundSharpCorners2D(
|
||||
points: [number, number][],
|
||||
thresholdDeg: number = 15,
|
||||
): [number, number][] {
|
||||
if (points.length < 3) return points;
|
||||
|
||||
const thresholdRad = (thresholdDeg * Math.PI) / 180;
|
||||
const result: [number, number][] = [points[0]];
|
||||
|
||||
for (let i = 1; i < points.length - 1; i++) {
|
||||
const prev = points[i - 1];
|
||||
const curr = points[i];
|
||||
const next = points[i + 1];
|
||||
|
||||
const distPrev = Math.sqrt(
|
||||
(curr[0] - prev[0]) ** 2 + (curr[1] - prev[1]) ** 2,
|
||||
);
|
||||
const distNext = Math.sqrt(
|
||||
(next[0] - curr[0]) ** 2 + (next[1] - curr[1]) ** 2,
|
||||
);
|
||||
|
||||
if (distPrev < 5e-4 || distNext < 5e-4) {
|
||||
result.push(curr);
|
||||
continue;
|
||||
}
|
||||
|
||||
const headingIn = Math.atan2(curr[0] - prev[0], curr[1] - prev[1]);
|
||||
const headingOut = Math.atan2(next[0] - curr[0], next[1] - curr[1]);
|
||||
let delta = headingOut - headingIn;
|
||||
if (delta > Math.PI) delta -= 2 * Math.PI;
|
||||
if (delta < -Math.PI) delta += 2 * Math.PI;
|
||||
const absDelta = Math.abs(delta);
|
||||
|
||||
if (absDelta > thresholdRad) {
|
||||
const setback = Math.min(distPrev, distNext) * 0.45;
|
||||
|
||||
const t1Factor = setback / distPrev;
|
||||
const T1: [number, number] = [
|
||||
curr[0] + (prev[0] - curr[0]) * t1Factor,
|
||||
curr[1] + (prev[1] - curr[1]) * t1Factor,
|
||||
];
|
||||
|
||||
const t2Factor = setback / distNext;
|
||||
const T2: [number, number] = [
|
||||
curr[0] + (next[0] - curr[0]) * t2Factor,
|
||||
curr[1] + (next[1] - curr[1]) * t2Factor,
|
||||
];
|
||||
|
||||
const arcCount = Math.max(
|
||||
6,
|
||||
Math.min(12, Math.round((8 * absDelta) / Math.PI)),
|
||||
);
|
||||
|
||||
for (let j = 0; j <= arcCount; j++) {
|
||||
const t = j / arcCount;
|
||||
const u = 1 - t;
|
||||
result.push([
|
||||
u * u * T1[0] + 2 * u * t * curr[0] + t * t * T2[0],
|
||||
u * u * T1[1] + 2 * u * t * curr[1] + t * t * T2[1],
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
result.push(curr);
|
||||
}
|
||||
}
|
||||
|
||||
result.push(points[points.length - 1]);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Post-spline self-intersection (loop) detection and removal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Check if two 2D line segments intersect (strict, not at endpoints). */
|
||||
function segmentsIntersect(
|
||||
a1: ElevatedPoint,
|
||||
a2: ElevatedPoint,
|
||||
b1: ElevatedPoint,
|
||||
b2: ElevatedPoint,
|
||||
): { hit: boolean; t: number } {
|
||||
const ax = a2[0] - a1[0],
|
||||
ay = a2[1] - a1[1];
|
||||
const bx = b2[0] - b1[0],
|
||||
by = b2[1] - b1[1];
|
||||
const denom = ax * by - ay * bx;
|
||||
if (Math.abs(denom) < 1e-15) return { hit: false, t: 0 };
|
||||
|
||||
const cx = b1[0] - a1[0],
|
||||
cy = b1[1] - a1[1];
|
||||
const t = (cx * by - cy * bx) / denom;
|
||||
const u = (cx * ay - cy * ax) / denom;
|
||||
|
||||
return { hit: t > 0.01 && t < 0.99 && u > 0.01 && u < 0.99, t };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect and remove self-intersecting loops in a splined path.
|
||||
*
|
||||
* Uses a local search window (up to 120 segments ahead) so the cost is
|
||||
* O(N × window) rather than O(N²).
|
||||
*/
|
||||
export function removePathLoops(path: ElevatedPoint[]): ElevatedPoint[] {
|
||||
if (path.length < 8) return path;
|
||||
|
||||
let result = path;
|
||||
const MAX_WINDOW = 120;
|
||||
|
||||
for (let pass = 0; pass < 5; pass++) {
|
||||
let found = false;
|
||||
|
||||
outer: for (let i = 0; i < result.length - 3; i++) {
|
||||
const maxJ = Math.min(i + MAX_WINDOW, result.length - 1);
|
||||
for (let j = i + 2; j < maxJ; j++) {
|
||||
const { hit, t } = segmentsIntersect(
|
||||
result[i],
|
||||
result[i + 1],
|
||||
result[j],
|
||||
result[j + 1],
|
||||
);
|
||||
if (hit) {
|
||||
const ix: ElevatedPoint = [
|
||||
result[i][0] + t * (result[i + 1][0] - result[i][0]),
|
||||
result[i][1] + t * (result[i + 1][1] - result[i][1]),
|
||||
result[i][2] + t * (result[i + 1][2] - result[i][2]),
|
||||
];
|
||||
|
||||
const next = [...result.slice(0, i + 1), ix, ...result.slice(j + 1)];
|
||||
result = next;
|
||||
found = true;
|
||||
break outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) break;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user