diff --git a/src/main/java/dev/coph/flightscore/backend/coordinate/distance/DistanceCalculator.java b/src/main/java/dev/coph/flightscore/backend/coordinate/distance/DistanceCalculator.java index 6a27f2c..462b494 100644 --- a/src/main/java/dev/coph/flightscore/backend/coordinate/distance/DistanceCalculator.java +++ b/src/main/java/dev/coph/flightscore/backend/coordinate/distance/DistanceCalculator.java @@ -194,23 +194,39 @@ public final class DistanceCalculator { } private static double utmEuclidean(Coordinate from, Coordinate to, MapDatum datum) { - // Project the source naturally; this fixes the zone (and hemisphere) of - // the planar frame in which the distance is measured. - GridCoordinate fromUtm = CoordinateConverter.convert(from, datum, MapDatum.WGS84, MapGrid.UTM); - - // Project the target into the same zone / hemisphere as the source, so - // both points share one central meridian and the wedge-shaped overlap - // between zones causes no jump in the planar coordinates. + // Bring both points onto WGS84 first so they share one ellipsoid. + Coordinate fromWgs84 = CoordinateConverter.transformDatum(from, datum, MapDatum.WGS84); Coordinate toWgs84 = CoordinateConverter.transformDatum(to, datum, MapDatum.WGS84); - GridCoordinate toUtm = fromUtm.utmZone() != null - ? CoordinateConverter.toUtmGrid(toWgs84, MapDatum.WGS84, - fromUtm.utmZone(), fromUtm.southernHemisphere()) - : CoordinateConverter.toGrid(toWgs84, MapDatum.WGS84, MapGrid.UTM); + + // Pick the central meridian half-way between the two longitudes. This + // distributes the Transverse Mercator scale distortion symmetrically + // and roughly halves the maximum planar error compared to using either + // point's natural UTM zone — important when the two points are far + // apart in longitude or straddle a zone boundary ("Zwickel"). The + // shortest longitude arc is used so an antimeridian crossing is fine. + double dLon = normalizeLongitudeDelta(toWgs84.longitude() - fromWgs84.longitude()); + double midLon = normalizeLongitude(fromWgs84.longitude() + dLon / 2.0); + boolean southern = (fromWgs84.latitude() + toWgs84.latitude()) / 2.0 < 0.0; + + GridCoordinate fromUtm = CoordinateConverter.toUtmGridAtMeridian(fromWgs84, MapDatum.WGS84, midLon, southern); + GridCoordinate toUtm = CoordinateConverter.toUtmGridAtMeridian(toWgs84, MapDatum.WGS84, midLon, southern); return Math.hypot(toUtm.easting() - fromUtm.easting(), toUtm.northing() - fromUtm.northing()); } + private static double normalizeLongitudeDelta(double dLon) { + while (dLon > 180.0) dLon -= 360.0; + while (dLon < -180.0) dLon += 360.0; + return dLon; + } + + private static double normalizeLongitude(double lon) { + while (lon > 180.0) lon -= 360.0; + while (lon <= -180.0) lon += 360.0; + return lon; + } + // ------------------------------------------------------------------ // Result assembly // ------------------------------------------------------------------ diff --git a/src/main/java/dev/coph/flightscore/backend/map/CoordinateConverter.java b/src/main/java/dev/coph/flightscore/backend/map/CoordinateConverter.java index cc4e3fe..904b2ef 100644 --- a/src/main/java/dev/coph/flightscore/backend/map/CoordinateConverter.java +++ b/src/main/java/dev/coph/flightscore/backend/map/CoordinateConverter.java @@ -82,7 +82,7 @@ public final class CoordinateConverter { return new GridCoordinate(coordinate.longitude(), coordinate.latitude(), coordinate.barometricAltitude(), coordinate.gpsAltitude(), null, null, false); case TRANSVERSE_MERCATOR: - return transverseMercatorForward(coordinate, datum, grid, null, false); + return transverseMercatorForward(coordinate, datum, grid, null, null, false); case SWISS_OBLIQUE_MERCATOR: return swissObliqueMercatorForward(coordinate, datum, grid); default: @@ -106,7 +106,28 @@ public final class CoordinateConverter { if (zone < 1 || zone > 60) { throw new IllegalArgumentException("UTM zone must be in [1, 60], was " + zone); } - return transverseMercatorForward(coordinate, datum, MapGrid.UTM, zone, southern); + return transverseMercatorForward(coordinate, datum, MapGrid.UTM, zone, null, southern); + } + + /** + * Projects a {@link Coordinate} (referenced to {@code datum}) onto a + * UTM-style Transverse Mercator grid with an arbitrary central meridian + * (in decimal degrees). UTM's scale factor (0.9996), false easting + * (500 000 m) and hemisphere-dependent false northing are reused; + * only the central meridian is overridden. + *
+ * This is the primary tool for an accurate planar distance between two + * points that are far apart in longitude: picking the mid-longitude as the + * central meridian distributes the Transverse Mercator scale distortion + * symmetrically and roughly halves the maximum error. + *
+ * The returned {@link GridCoordinate} carries no UTM zone number (since + * the projection is not a standard UTM zone) but reports the hemisphere + * as requested. + */ + public static GridCoordinate toUtmGridAtMeridian(Coordinate coordinate, MapDatum datum, + double centralMeridianDeg, boolean southern) { + return transverseMercatorForward(coordinate, datum, MapGrid.UTM, null, centralMeridianDeg, southern); } /** @@ -288,7 +309,8 @@ public final class CoordinateConverter { // ------------------------------------------------------------------ private static GridCoordinate transverseMercatorForward(Coordinate coordinate, MapDatum datum, MapGrid grid, - Integer forcedZone, boolean forcedSouthern) { + Integer forcedZone, Double forcedCentralMeridianDeg, + boolean forcedSouthern) { double a = datum.semiMajorAxis(); double f = datum.flattening(); double e = datum.firstEccentricity(); @@ -300,7 +322,11 @@ public final class CoordinateConverter { boolean southern = false; double centralMeridian; double falseNorthing = grid.falseNorthing(); - if (grid.isZoneDependent()) { + if (forcedCentralMeridianDeg != null) { + southern = forcedSouthern; + centralMeridian = Math.toRadians(forcedCentralMeridianDeg); + falseNorthing = southern ? 10_000_000.0 : 0.0; + } else if (grid.isZoneDependent()) { if (forcedZone != null) { zone = forcedZone; southern = forcedSouthern;