Class GeoUtils
Geographic utilities for building map features: bearings, destinations, path math, bounds fitting and point-in-polygon.
public static class GeoUtils
- Inheritance
-
objectGeoUtils
Remarks
Pure and allocation-free, all doubles, with distances delegated to GeoMath and therefore on a spherical earth. Two different models appear here deliberately: bearings, destinations and midpoints are great-circle, while the fitting and interpolation helpers work in mercator so that motion and padding are uniform on screen rather than in degrees.
Fields
DefaultFitMaxZoom
Deepest zoom the fitting helpers return unless told otherwise, 22.
public const double DefaultFitMaxZoom = 22
Field Value
Remarks
Matches the default maxNativeZoom on TileSource, so a fit cannot ask for a tile deeper than the default provider serves. Clamping to Max instead is what put a single marker at zoom 28: the mercator span of one point falls to the 1e-12 floor below, the logarithm blows up, and the clamp catches it at the storage ceiling six levels past any served tile. Pass a larger value for a provider that serves deeper.
Methods
BearingDeg(LatLon, LatLon)
Initial great-circle bearing from one coordinate toward another.
public static double BearingDeg(LatLon a, LatLon b)
Parameters
Returns
- double
Bearing in degrees clockwise from true north, 0 inclusive to 360 exclusive. This is the initial bearing: along a great circle it changes continuously, so it does not equal the reverse of the bearing from b to a.
BoundsOf(IReadOnlyList<LatLon>)
Smallest axis-aligned latitude and longitude box containing every given point.
public static GeoBounds BoundsOf(IReadOnlyList<LatLon> points)
Parameters
pointsIReadOnlyList<LatLon>Coordinates in degrees WGS84.
Returns
- GeoBounds
Bounds in degrees WGS84.
Remarks
Longitudes are compared numerically, which is the smallest box only
while the set fits in a half-turn. A set straddling the antimeridian
gets the complement of the box it wanted, and the cost is not a
rounding: two points 0.2 degrees apart either side of 180 give a 359.8
degree box, 1799 times too wide, centered 20015 km away from them and
fitting 10.8 zoom levels further out than it should.
GeoBounds cannot hold the narrow arc, since a straddle
is exactly what its constructor rejects as min-exceeds-max, so this
still returns that box. Use CalculateZoomAndCenter(IReadOnlyList<LatLon>, ScreenSize, int, double, double),
which takes points and needs no box in between, or split at 180 and
fit two boxes. ScreenMapView.FitToMarkers takes the first
route, so active markers at the seam fit their short arc rather than
this invalid box.
Exceptions
- ArgumentException
pointsis null or empty.
CalculateCenter(IReadOnlyList<LatLon>)
Geographic centroid of a set of coordinates.
public static LatLon CalculateCenter(IReadOnlyList<LatLon> points)
Parameters
pointsIReadOnlyList<LatLon>Coordinates in degrees WGS84. Must contain at least one point.
Returns
- LatLon
Centroid in degrees WGS84.
Remarks
Averages 3D unit vectors rather than latitudes and longitudes, which is what makes it correct across the antimeridian; a naive longitude average puts the centroid of two points either side of it a hemisphere away. Input whose vectors cancel exactly, such as an antipodal pair, has no meaningful centroid and yields (0,0).
Exceptions
- ArgumentException
pointsis null or empty.
CalculateZoomAndCenter(IReadOnlyList<LatLon>, ScreenSize, int, double, double)
Center and zoom that fit a set of points on screen. The "zoom to fit markers" entry point.
public static (LatLon Center, double Zoom) CalculateZoomAndCenter(IReadOnlyList<LatLon> points, ScreenSize screen, int tileSizePx, double paddingPx = 0, double maxZoom = 22)
Parameters
pointsIReadOnlyList<LatLon>Coordinates to fit, degrees WGS84.
screenScreenSizeViewport size in screen px.
tileSizePxintTile edge length in px, 256 or 512.
paddingPxdoubleMargin to leave on every side, in screen px.
maxZoomdoubleDeepest zoom to return. Defaults to DefaultFitMaxZoom.
Returns
- (LatLon Center, double Zoom)
The center in degrees WGS84 and the fractional zoom that fits the points.
Remarks
Equivalent to BoundsOf(IReadOnlyList<LatLon>) followed by FitViewToBounds(GeoBounds, ScreenSize, int, double, double), except across the antimeridian, where no GeoBounds exists to pass. There it measures longitude as an offset from CalculateCenter(IReadOnlyList<LatLon>)'s unit-vector centroid, which is already correct at the seam, so this fits a set straddling 180 that BoundsOf(IReadOnlyList<LatLon>) refuses. Sets spread over more than half the world have no narrow arc to find and get the centroid's hemisphere, which is arbitrary but not wrong by 20015 km.
Exceptions
- ArgumentException
pointsis null or empty, the padding leaves no room inscreen, ormaxZoomis out of range.
DestinationAfter(LatLon, double, double, double)
Dead reckoning: where something travelling at a constant speed and heading will be after a given time.
public static LatLon DestinationAfter(LatLon start, double speedMetersPerSecond, double headingDeg, double seconds)
Parameters
startLatLonStart coordinate, degrees WGS84.
speedMetersPerSeconddoubleGround speed in meters per second.
headingDegdoubleHeading in degrees clockwise from true north, held constant as an initial great-circle bearing.
secondsdoubleElapsed time in seconds.
Returns
- LatLon
Predicted position in degrees WGS84.
DestinationPoint(LatLon, double, double)
The coordinate reached by travelling a distance along a bearing on a great circle.
public static LatLon DestinationPoint(LatLon start, double distanceMeters, double bearingDeg)
Parameters
startLatLonStart coordinate, degrees WGS84.
distanceMetersdoubleDistance to travel in meters.
bearingDegdoubleInitial bearing in degrees clockwise from true north.
Returns
- LatLon
Destination in degrees WGS84, longitude normalized to -180 to 180 so crossing the antimeridian returns a valid coordinate.
FitViewToBounds(GeoBounds, ScreenSize, int, double, double)
Center and zoom that fit a bounding box on screen with padding.
public static (LatLon Center, double Zoom) FitViewToBounds(GeoBounds bounds, ScreenSize screen, int tileSizePx, double paddingPx = 0, double maxZoom = 22)
Parameters
boundsGeoBoundsBox to fit, degrees WGS84.
screenScreenSizeViewport size in screen px.
tileSizePxintTile edge length in px, 256 or 512.
paddingPxdoubleMargin to leave on every side, in screen px. Must be zero or greater, and must leave a positive area inside the viewport.
maxZoomdoubleDeepest zoom to return. Defaults to DefaultFitMaxZoom.
Returns
- (LatLon Center, double Zoom)
The center in degrees WGS84 and the fractional zoom that fits the box. Zoom is clamped to 0 through
maxZoom, and is the tighter of the horizontal and vertical fits so nothing is cropped.
Remarks
The center is the mercator midpoint, not the midpoint in degrees, so
the fit is symmetric on screen.
The only screen this rejects is one the padding does not fit in, so at
the default padding of 0 no screen is ever rejected and a 1x1 viewport
returns a fit for a 1 px view. That is arithmetically what was asked
for, and this function cannot tell an uninitialized viewport from a
deliberate tiny one without a plausibility threshold nobody has
measured. Callers that can tell should check before calling;
ScreenMapView does, with its own padding default of 60.
A degenerate box is the other end of the same problem: one point, or
points closer than about 11 cm, drive the mercator span to the 1e-12
floor below and the fit to maxZoom.
Exceptions
- ArgumentException
paddingPxis negative, the padding leaves no room inscreen, ormaxZoomis outside 0 through Max.
IsInsidePolygon(LatLon, IReadOnlyList<LatLon>)
Whether a coordinate lies inside a polygon ring.
public static bool IsInsidePolygon(LatLon point, IReadOnlyList<LatLon> ring)
Parameters
pointLatLonCoordinate to test, degrees WGS84.
ringIReadOnlyList<LatLon>Ring vertices in degrees WGS84, in either winding order. The ring closes implicitly, so do not repeat the first vertex.
Returns
- bool
True when the point is inside. False when the ring is null or has fewer than three vertices. Points exactly on an edge are not guaranteed either way.
Remarks
Even-odd ray cast treating latitude and longitude as plane coordinates. Adequate for regions well away from the poles and the antimeridian, and wrong for rings that cross either.
Midpoint(LatLon, LatLon)
Great-circle midpoint of two coordinates, the halfway point along the shortest surface path.
public static LatLon Midpoint(LatLon a, LatLon b)
Parameters
Returns
- LatLon
Midpoint in degrees WGS84. Not the average of the two latitudes and longitudes, and correct across the antimeridian.
MoveTowards(LatLon, LatLon, double)
Interpolates between two coordinates in mercator space, the building block for fly-to and cluster glide animations.
public static LatLon MoveTowards(LatLon from, LatLon to, double t)
Parameters
fromLatLonStart coordinate, degrees WGS84.
toLatLonTarget coordinate, degrees WGS84.
tdoubleFraction of the way from start to target, clamped to 0 through 1.
Returns
- LatLon
Interpolated coordinate in degrees WGS84. Longitude takes the short way around the antimeridian and stays in range; latitude is clamped to the mercator limit.
Remarks
Interpolating in mercator rather than in degrees is what makes the motion uniform on screen, which is the point: a latitude lerp visibly accelerates as it approaches the poles.
PathLengthMeters(IReadOnlyList<LatLon>)
Total length of a path, summed over its segments.
public static double PathLengthMeters(IReadOnlyList<LatLon> path)
Parameters
pathIReadOnlyList<LatLon>Ordered coordinates in degrees WGS84.
Returns
- double
Length in meters, or 0 when the path is null or has fewer than two points.
PointAlongPath(IReadOnlyList<LatLon>, double)
The point a given fraction of the way along a path by distance, for animating something down a route.
public static LatLon PointAlongPath(IReadOnlyList<LatLon> path, double t)
Parameters
pathIReadOnlyList<LatLon>Ordered coordinates in degrees WGS84.
tdoubleFraction of total length, clamped to 0 through 1. Fraction of distance, not of segment count, so evenly spaced t gives even speed regardless of how the path is subdivided.
Returns
- LatLon
Position in degrees WGS84. A single-point path returns that point, and so does a path whose total length is zero.
Remarks
Interpolates latitude and longitude linearly inside a segment rather than along a great circle: accurate enough for route and track segments, and cheap enough to call every frame. Longitude takes the short way around the antimeridian, the same fold MoveTowards(LatLon, LatLon, double) applies in mercator, because the fraction comes from a haversine length that already took the short arc: a raw degree lerp against that fraction puts the midpoint of a 179 to -179 path on the antipode. The result is normalized through WrapUnit(double) so both helpers spell the seam identically, as -180.
Exceptions
- ArgumentException
pathis null or empty.