Skip to content

Commit 20ba37e

Browse files
committed
feat: Add geodetic calculation option for Pseudo-Mercator
This commit introduces the useGeodeticCalculations parameter for maps. When set to true for Pseudo-Mercator projections, it ensures more accurate scale and bounding box computations by accounting for Mercator projection distortions, especially away from the equator. This change affects map bounds, scale calculation, and WMTS layer scaling. It also includes new unit tests and an example to demonstrate the feature.
1 parent 20af492 commit 20ba37e

21 files changed

+940
-43
lines changed
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package org.mapfish.print;
2+
3+
import org.geotools.api.referencing.crs.CoordinateReferenceSystem;
4+
5+
/**
6+
* Utility class for handling WGS 84 with Pseudo-Mercator projection specific calculations.
7+
*
8+
* @author fdiaz
9+
*/
10+
public final class PseudoMercatorUtils {
11+
12+
private PseudoMercatorUtils() {
13+
14+
}
15+
16+
/**
17+
* Checks if a given CoordinateReferenceSystem is WGS 84 with Pseudo-Mercator projection.
18+
*
19+
* @param crs The CoordinateReferenceSystem to check.
20+
* @return {@code true} if the CRS is Pseudo-Mercator, {@code false} otherwise.
21+
*/
22+
public static boolean isPseudoMercator(final CoordinateReferenceSystem crs) {
23+
String crsNameCode = crs.getName().getCode();
24+
String crsId = crs.getIdentifiers().iterator().next().toString().toLowerCase();
25+
return (crsNameCode.contains("wgs 84") && (
26+
crsNameCode.contains("pseudo-mercator") ||
27+
crsNameCode.contains("pseudo mercator") ||
28+
crsNameCode.contains("web-mercator") ||
29+
crsNameCode.contains("web mercator")
30+
)) || "EPSG:3857".equalsIgnoreCase(crsId);
31+
}
32+
33+
}

core/src/main/java/org/mapfish/print/attribute/map/BBoxMapBounds.java

Lines changed: 101 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,21 @@
22

33
import java.awt.Rectangle;
44
import org.geotools.api.referencing.crs.CoordinateReferenceSystem;
5+
import org.geotools.api.referencing.operation.MathTransform;
6+
import org.geotools.geometry.jts.JTS;
57
import org.geotools.geometry.jts.ReferencedEnvelope;
8+
import org.geotools.referencing.CRS;
69
import org.geotools.referencing.GeodeticCalculator;
710
import org.locationtech.jts.geom.Coordinate;
811
import org.locationtech.jts.geom.Envelope;
912
import org.mapfish.print.FloatingPointUtil;
13+
import org.mapfish.print.PrintException;
1014
import org.mapfish.print.map.DistanceUnit;
1115
import org.mapfish.print.map.Scale;
16+
import org.mapfish.print.PseudoMercatorUtils;
17+
18+
19+
1220

1321
/**
1422
* Represent the map bounds with a bounding box.
@@ -23,12 +31,43 @@ public final class BBoxMapBounds extends MapBounds {
2331
*
2432
* @param projection the projection these bounds are defined in.
2533
* @param envelope the bounds
34+
* @param useGeodeticCalculations force to use geodetic calculations in PseudoMercator projection
2635
*/
27-
public BBoxMapBounds(final CoordinateReferenceSystem projection, final Envelope envelope) {
28-
super(projection);
36+
public BBoxMapBounds(final CoordinateReferenceSystem projection, final Envelope envelope, final boolean useGeodeticCalculations) {
37+
super(projection, useGeodeticCalculations);
2938
this.bbox = envelope;
3039
}
3140

41+
/**
42+
* Constructor.
43+
*
44+
* @param projection the projection these bounds are defined in.
45+
* @param envelope the bounds
46+
*/
47+
public BBoxMapBounds(final CoordinateReferenceSystem projection, final Envelope envelope) {
48+
this(projection, envelope, false);
49+
}
50+
51+
/**
52+
* Constructor.
53+
*
54+
* @param projection the projection these bounds are defined in.
55+
* @param minX min X coordinate for the MapBounds
56+
* @param minY min Y coordinate for the MapBounds
57+
* @param maxX max X coordinate for the MapBounds
58+
* @param maxY max Y coordinate for the MapBounds
59+
* @param useGeodeticCalculations force to use geodetic calculations in PseudoMercator projection
60+
*/
61+
public BBoxMapBounds(
62+
final CoordinateReferenceSystem projection,
63+
final double minX,
64+
final double minY,
65+
final double maxX,
66+
final double maxY,
67+
final boolean useGeodeticCalculations) {
68+
this(projection, new Envelope(minX, maxX, minY, maxY), useGeodeticCalculations);
69+
}
70+
3271
/**
3372
* Constructor.
3473
*
@@ -47,6 +86,22 @@ public BBoxMapBounds(
4786
this(projection, new Envelope(minX, maxX, minY, maxY));
4887
}
4988

89+
/**
90+
* Create from a bbox.
91+
*
92+
* @param bbox the bounds.
93+
* @param useGeodeticCalculations force to use geodetic calculations in PseudoMercator projection
94+
*/
95+
public BBoxMapBounds(final ReferencedEnvelope bbox, final boolean useGeodeticCalculations) {
96+
this(
97+
bbox.getCoordinateReferenceSystem(),
98+
bbox.getMinX(),
99+
bbox.getMinY(),
100+
bbox.getMaxX(),
101+
bbox.getMaxY(),
102+
useGeodeticCalculations);
103+
}
104+
50105
/**
51106
* Create from a bbox.
52107
*
@@ -80,7 +135,8 @@ public MapBounds adjustedEnvelope(final Rectangle paintArea) {
80135
centerX - finalDiff,
81136
this.bbox.getMinY(),
82137
centerX + finalDiff,
83-
this.bbox.getMaxY());
138+
this.bbox.getMaxY(),
139+
this.useGeodeticCalculations());
84140
} else {
85141
double centerY = (this.bbox.getMinY() + this.bbox.getMaxY()) / 2;
86142
double factor = bboxAspectRatio / paintAreaAspectRatio;
@@ -90,7 +146,8 @@ public MapBounds adjustedEnvelope(final Rectangle paintArea) {
90146
this.bbox.getMinX(),
91147
centerY - finalDiff,
92148
this.bbox.getMaxX(),
93-
centerY + finalDiff);
149+
centerY + finalDiff,
150+
this.useGeodeticCalculations());
94151
}
95152
}
96153

@@ -107,26 +164,21 @@ public MapBounds adjustBoundsToNearestScale(
107164
getNearestScale(zoomLevels, tolerance, zoomLevelSnapStrategy, geodetic, paintArea, dpi);
108165

109166
Coordinate center = this.bbox.centre();
110-
return new CenterScaleMapBounds(getProjection(), center.x, center.y, newScale);
167+
return new CenterScaleMapBounds(getProjection(), center.x, center.y, newScale, this.useGeodeticCalculations());
111168
}
112169

113170
@Override
114171
public Scale getScale(final Rectangle paintArea, final double dpi) {
115172
final ReferencedEnvelope bboxAdjustedToScreen = toReferencedEnvelope(paintArea);
116173

117-
DistanceUnit projUnit = DistanceUnit.fromProjection(getProjection());
174+
CoordinateReferenceSystem crs = getProjection();
175+
DistanceUnit projUnit = DistanceUnit.fromProjection(crs);
118176

119177
double geoWidthInInches;
120-
if (projUnit == DistanceUnit.DEGREES) {
121-
GeodeticCalculator calculator = new GeodeticCalculator(getProjection());
122-
final double centerY = bboxAdjustedToScreen.centre().y;
123-
calculator.setStartingGeographicPoint(bboxAdjustedToScreen.getMinX(), centerY);
124-
calculator.setDestinationGeographicPoint(bboxAdjustedToScreen.getMaxX(), centerY);
125-
double geoWidthInEllipsoidUnits = calculator.getOrthodromicDistance();
126-
DistanceUnit ellipsoidUnit =
127-
DistanceUnit.fromString(calculator.getEllipsoid().getAxisUnit().toString());
128-
129-
geoWidthInInches = ellipsoidUnit.convertTo(geoWidthInEllipsoidUnits, DistanceUnit.IN);
178+
179+
// If it is geodetic/degress OR it is a special case requiring geodetic calculation (PseudoMercator)
180+
if (projUnit == DistanceUnit.DEGREES || (this.useGeodeticCalculations() && PseudoMercatorUtils.isPseudoMercator(crs))) {
181+
geoWidthInInches = this.computeGeodeticWidthInInches(bboxAdjustedToScreen);
130182
} else {
131183
// (scale * width ) / dpi = geowidith
132184
geoWidthInInches = projUnit.convertTo(bboxAdjustedToScreen.getWidth(), DistanceUnit.IN);
@@ -135,6 +187,36 @@ public Scale getScale(final Rectangle paintArea, final double dpi) {
135187
return new Scale(geoWidthInInches * (dpi / paintArea.getWidth()), projUnit, dpi);
136188
}
137189

190+
@SuppressWarnings("UseSpecificCatch")
191+
private double computeGeodeticWidthInInches(final ReferencedEnvelope bbox) {
192+
try {
193+
CoordinateReferenceSystem crs = bbox.getCoordinateReferenceSystem();
194+
GeodeticCalculator calculator = new GeodeticCalculator(crs); // Use the original CRS for the ellipsoid
195+
196+
double centerY = bbox.centre().y;
197+
Coordinate start = new Coordinate(bbox.getMinX(), centerY);
198+
Coordinate end = new Coordinate(bbox.getMaxX(), centerY);
199+
200+
if (this.useGeodeticCalculations() && PseudoMercatorUtils.isPseudoMercator(crs)) {
201+
// Needs reprojection
202+
final MathTransform transform = CRS.findMathTransform(crs, GenericMapAttribute.parseProjection("EPSG:4326", true));
203+
start = JTS.transform(start, null, transform);
204+
end = JTS.transform(end, null, transform);
205+
}
206+
207+
// --- Common Logic ---
208+
calculator.setStartingGeographicPoint(start.x, start.y);
209+
calculator.setDestinationGeographicPoint(end.x, end.y);
210+
final double orthodromicWidth = calculator.getOrthodromicDistance();
211+
final DistanceUnit ellipsoidUnit = DistanceUnit.fromString(calculator.getEllipsoid().getAxisUnit().toString());
212+
213+
return ellipsoidUnit.convertTo(orthodromicWidth, DistanceUnit.IN);
214+
215+
} catch (Exception e) {
216+
throw new PrintException("Failed to compute geodetic width", e);
217+
}
218+
}
219+
138220
@Override
139221
public MapBounds adjustBoundsToRotation(final double rotation) {
140222
if (FloatingPointUtil.equals(rotation, 0.0)) {
@@ -157,7 +239,7 @@ public MapBounds adjustBoundsToRotation(final double rotation) {
157239
final double rotatedMinY = this.bbox.getMinY() - heightDifference;
158240
final double rotatedMaxY = this.bbox.getMaxY() + heightDifference;
159241

160-
return new BBoxMapBounds(getProjection(), rotatedMinX, rotatedMinY, rotatedMaxX, rotatedMaxY);
242+
return new BBoxMapBounds(getProjection(), rotatedMinX, rotatedMinY, rotatedMaxX, rotatedMaxY, this.useGeodeticCalculations());
161243
}
162244

163245
private double getRotatedWidth(final double rotation) {
@@ -197,7 +279,7 @@ public MapBounds zoomOut(final double factor) {
197279
double minGeoY = centerY - destHeight / 2.0f;
198280
double maxGeoY = centerY + destHeight / 2.0f;
199281

200-
return new BBoxMapBounds(getProjection(), minGeoX, minGeoY, maxGeoX, maxGeoY);
282+
return new BBoxMapBounds(getProjection(), minGeoX, minGeoY, maxGeoX, maxGeoY, this.useGeodeticCalculations());
201283
}
202284

203285
@Override
@@ -241,7 +323,7 @@ public MapBounds expand(final int margin, final Rectangle paintArea) {
241323
final double minGeoY = centerY - destHeight / 2.0;
242324
final double maxGeoY = centerY + destHeight / 2.0;
243325

244-
return new BBoxMapBounds(getProjection(), minGeoX, minGeoY, maxGeoX, maxGeoY);
326+
return new BBoxMapBounds(getProjection(), minGeoX, minGeoY, maxGeoX, maxGeoY, this.useGeodeticCalculations());
245327
}
246328

247329
@Override

0 commit comments

Comments
 (0)