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);
          }
      }
  }