MapArea.java
/*
* *************************************************************************************************************************************************************
*
* MapView: a JavaFX map renderer for tile-based servers
* http://tidalwave.it/projects/mapview
*
* Copyright (C) 2024 - 2026 by Tidalwave s.a.s. (http://tidalwave.it)
*
* *************************************************************************************************************************************************************
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
* CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*
* *************************************************************************************************************************************************************
*
* git clone https://bitbucket.org/tidalwave/mapview-src
* git clone https://github.com/tidalwave-it/mapview-src
*
* *************************************************************************************************************************************************************
*/
package it.tidalwave.mapview;
import jakarta.annotation.Nonnull;
import org.apiguardian.api.API;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.Arrays;
import java.util.Collection;
import java.util.Optional;
import static org.apiguardian.api.API.Status.STABLE;
import static lombok.AccessLevel.PRIVATE;
/***************************************************************************************************************************************************************
* A rectangular area on the map.
* @author Fabrizio Giudici
**************************************************************************************************************************************************************/
@API(status = STABLE)
@RequiredArgsConstructor(access = PRIVATE) @Getter @EqualsAndHashCode
public class MapArea
{
/**
* The north limit.
* -- GETTER --
* {@return the north limit}.
*/
private final double north;
/**
* The east limit.
* -- GETTER --
* {@return the east limit}.
*/
private final double east;
/**
* The south limit.
* -- GETTER --
* {@return the south limit}.
*/
private final double south;
/**
* The west limit.
* -- GETTER --
* {@return the west limit}.
*/
private final double west;
/***********************************************************************************************************************************************************
* {@return a new area}.
* @param north the north limit
* @param east the east limit
* @param south the south limit
* @param west the west limit
* @throws IllegalArgumentException when the values are unfeasible
**********************************************************************************************************************************************************/
@Nonnull
public static MapArea of (final double north, final double east, final double south, final double west)
{
checkLatitude(north, "north");
checkLatitude(south, "south");
checkLongitude(east, "east");
checkLongitude(west, "west");
if (north < south)
{
throw new IllegalArgumentException(String.format("north (%f) must be greater on equal than south (%f)", north, south));
}
return new MapArea(north, east, south, west);
}
/***********************************************************************************************************************************************************
* {@return the smallest area that encloses all the given coordinates, or an empty value if there are no coordinates}.
*
* Longitudes are treated as points on a circle, so the returned area spans across the Greenwich antimeridian when this makes it smaller: for instance,
* coordinates with longitudes 179° and -179° are enclosed by an area 2° wide, with {@code west} = 179 and {@code east} = -179. The order of coordinates
* doesn't matter; if more than one area of the same minimal size exists (which may happen only when the area spans at least 180° of longitude), the
* result is deterministic and the area that doesn't span across the antimeridian is preferred, if any.
*
* A longitude of -180° is accepted, as the GPX format does, and is treated as 180°, as it is the same meridian. A single pair of coordinates (or many
* equal ones) results in an area of zero size.
*
* @param coordinates the coordinates
* @throws IllegalArgumentException when a latitude is not in the range [-90, 90] or a longitude is not in the range [-180, 180]
* @since 1.0-ALPHA-5
**********************************************************************************************************************************************************/
@Nonnull
public static Optional<MapArea> enclosing (@Nonnull final Collection<? extends MapCoordinates> coordinates)
{
if (coordinates.isEmpty())
{
return Optional.empty();
}
var north = -90d;
var south = 90d;
final var longitudes = new double[coordinates.size()];
var n = 0;
for (final var c : coordinates)
{
final var latitude = c.latitude();
final var longitude = (c.longitude() == -180) ? 180 : c.longitude();
checkLatitude(latitude, "latitude");
checkLongitude(longitude, "longitude");
north = Math.max(north, latitude);
south = Math.min(south, latitude);
longitudes[n++] = longitude;
}
// Each gap between two consecutive longitudes on the circle can be left out of the area: the smallest area leaves out the largest gap. The gap
// across the antimeridian, between the greatest and the smallest longitude, is examined first, so it wins ties; if it's the largest one, the area
// doesn't span across the antimeridian. Otherwise, the area spans from the longitude after the largest gap to the one before it, so east < west.
Arrays.sort(longitudes);
var west = longitudes[0];
var east = longitudes[longitudes.length - 1];
var largestGap = 360 - (east - west);
for (int i = 1; i < longitudes.length; i++)
{
final var gap = longitudes[i] - longitudes[i - 1];
if (gap > largestGap)
{
largestGap = gap;
west = longitudes[i];
east = longitudes[i - 1];
}
}
return Optional.of(of(north, east, south, west));
}
/***********************************************************************************************************************************************************
* {@return the center of this area}.
**********************************************************************************************************************************************************/
@Nonnull
public MapCoordinates getCenter()
{
var longitudeCenter = (west + east) / 2;
if (isAcrossGreenwichAntimeridian())
{
longitudeCenter = longitudeCenter - 180;
if (longitudeCenter <= -180)
{
longitudeCenter = 360 + longitudeCenter;
}
}
return MapCoordinates.of((north + south) / 2, longitudeCenter);
}
/***********************************************************************************************************************************************************
* {@return {@code true} if this area spans across the Greenwich antimeridian (180° W)}.
**********************************************************************************************************************************************************/
public boolean isAcrossGreenwichAntimeridian()
{
return east < west;
}
/***********************************************************************************************************************************************************
* {@return {@code true} if this area contains the given coordinates}.
* @param coordinates the coordinates
**********************************************************************************************************************************************************/
public boolean contains (@Nonnull final MapCoordinates coordinates)
{
final var latitude = coordinates.latitude();
final var longitude = coordinates.longitude();
final var latitudeInRange = latitude <= north && latitude >= south;
final var longitudeInRange = isAcrossGreenwichAntimeridian() ? longitude >= west || longitude <= east
: longitude >= west && longitude <= east;
return latitudeInRange && longitudeInRange;
}
/***********************************************************************************************************************************************************
* {@return {@code true} if this area contains the given area}.
* @param that the area to compare
**********************************************************************************************************************************************************/
public boolean contains (@Nonnull final MapArea that)
{
final var latitudeInRange = north >= that.north && south <= that.south;
final boolean longitudeInRange;
if (!isAcrossGreenwichAntimeridian())
{
// an area not across the antimeridian can't contain one that is across it
longitudeInRange = !that.isAcrossGreenwichAntimeridian() && that.west >= west && that.east <= east;
}
else if (that.isAcrossGreenwichAntimeridian())
{
longitudeInRange = that.west >= west && that.east <= east;
}
else
{
// this area is made of two segments, [west, 180] and (-180, east]: that area must fit entirely in one of them
longitudeInRange = that.west >= west || that.east <= east;
}
return latitudeInRange && longitudeInRange;
}
/***********************************************************************************************************************************************************
* {@inheritDoc}
**********************************************************************************************************************************************************/
@Override @Nonnull
public String toString()
{
return String.format("(n=%s, e=%s, s=%s, w=%s)", north, east, south, west);
}
/***********************************************************************************************************************************************************
* {@return a formatted string with 6 decimals}.
* @since 1.0-ALPHA-4
**********************************************************************************************************************************************************/
@Nonnull
public String toFormattedString()
{
return String.format("(n=%.6f, e=%.6f, s=%.6f, w=%.6f)", north, east, south, west);
}
/***********************************************************************************************************************************************************
*
**********************************************************************************************************************************************************/
private static void checkLatitude (final double latitude, @Nonnull final String name)
{
if (latitude < -90 || latitude > 90)
{
throw new IllegalArgumentException("Latitude must be in range [-90, 90]: " + name + "=" + latitude);
}
}
/***********************************************************************************************************************************************************
*
**********************************************************************************************************************************************************/
private static void checkLongitude (final double longitude, @Nonnull final String name)
{
if (longitude <= -180 || longitude > 180)
{
throw new IllegalArgumentException("Longitude must be in range (-180, 180]: " + name + "=" + longitude);
}
}
}