MapViewModel.java

/*
 * *************************************************************************************************************************************************************
 *
 * MapView: a JavaFX map renderer for tile-based servers
 * http://tidalwave.it/projects/mapview
 *
 * Copyright (C) 2024 - 2025 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.mapviewer.impl;

import jakarta.annotation.Nonnull;
import java.util.function.BiConsumer;
import java.net.URI;
import it.tidalwave.mapviewer.MapArea;
import it.tidalwave.mapviewer.MapCoordinates;
import it.tidalwave.mapviewer.MapPoint;
import it.tidalwave.mapviewer.MapViewPoint;
import it.tidalwave.mapviewer.TileSource;
import it.tidalwave.mapviewer.javafx.MapView;
import it.tidalwave.mapviewer.javafx.impl.TilePos;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.experimental.Accessors;
import lombok.experimental.Delegate;
import lombok.extern.slf4j.Slf4j;

/***************************************************************************************************************************************************************
 *
 * A model (independent of UI technology) that provides parameters for implementing a tile grid based map renderer.
 *
 * To understand how this class works, three coordinate systems are to be understood:
 *
 * <ul>
 *   <li>classic <b>coordinates</b> composed of latitude and longitude, modelled by the class {@link MapCoordinates}</li>;
 *   <li><b>map coordinates</b> that are pixel coordinates in the huge, untiled bitmap that represents a map at a given zoom level; they are
 *      modelled by the class {@link MapPoint}.
 *   <li><b>map view coordinates</b> that are pixel coordinates in the map view, modelled by the class {@link MapViewPoint}</li>;
 * </ul>
 *
 * The tile grid is always created large enough to cover the whole clip area, plus a "margin" frame of tiles of {@code MARGIN} tiles. This allows to drag
 * the map at least by the tile size amount before being forced to reload tiles.
 *
 * The rendering component must:
 *
 * <ul>
 * <li>call the {@link #updateGridSize(double, double)} method specifying the size of the rendering region; this method must be called again every time
 * the rendering region changes size. It returns {@code true} when the grid has been recomputed (because it has been moved so far that a new row or
 * column of tiles must be downloaded).</li>
 * <li>call the {@link #setCenterAndZoom(MapCoordinates, double)} or {@link #setCenterAndZoom(MapPoint, double)} methods to set point that the center of
 * the rendered area, using either coordinates or map points, and the zoom level.
 * </ul>
 *
 * After doing that, this class computes:
 *
 * <ul>
 *   <li>the center point in the coordinate system ({@link #center()};</li>
 *   <li>the center point in the map coordinate system ({@link #pointCenter()})</li>
 *   <li>the coordinates (colum and row) of the tile that is rendered at the center ({@link #tileCenter())</li>
 *   <li>the offset in pixels that the center tile must be applied to ({#{@link #tileOffset()}}</li>
 *   <li>the offset in pixels that the grid must be applied to ({#{@link #gridOffset()}}</li>
 *   <li>the number of columns in the grid ({@link #columns()})</li>
 *   <li>the number of rows in the grid ({@link #rows()} ()})</li>
 * </ul>
 *
 * At this point the implementor must invoke {@link #iterateOnGrid(BiConsumer)} that will call back passing the URL and the grid position for each tile.
 *
 * Two further methods are available:
 *
 * <ul>
 *   <li>{@link #getArea()} returns the coordinates of the rendered area;</li>
 *   <li>{@link #computeFittingZoom(MapArea)} returns the maximum zoom level that allows to fully render the given area.</li>
 * </ul>
 *
 * @author  Fabrizio Giudici
 *
 **************************************************************************************************************************************************************/
@Accessors(fluent = true) @Getter @Slf4j
public class MapViewModel
  {
    @RequiredArgsConstructor(staticName = "of")
    static class TileInfo
      {
        @Delegate @Nonnull
        private final TilePos pos;

        @Getter @Nonnull
        private final URI uri;

        @Override @Nonnull
        public String toString()
          {
            return String.format("(%d, %d) - %s", pos.column, pos.row, uri);
          }
      }

    private static final int MARGIN = 1;

    /** The source of tiles. */
    @Nonnull
    private TileSource tileSource;

    /** The zoom level.*/
    private double zoom = 1;

    /** The coordinates rendered at the center of the map — note: this is _not_ the center of the TileGrid, since there is an offset. */
    private MapCoordinates center = MapCoordinates.of(0, 0);

    /** The same of above, but expressed in terms of pixel coordinates relative to the map as a huge, untiled image. */
    private MapPoint pointCenter = MapPoint.of(0, 0);

    /** The position of the tile that corresponds to the coordinates. */
    private TilePos tileCenter;

    /** The offset inside the tile that corresponds to the coordinates. */
    private TileOffset tileOffset = TileOffset.of(0, 0);

    private TileOffset gridOffset = TileOffset.of(0, 0);

    /** How many columns in the TileGrid. */
    private int columns;

    /** How many rows in the TileGrid. */
    private int rows;

    /** The width of the MapView. */
    private double mapViewWidth;

    /** The height of the MapView. */
    private double mapViewHeight;

    /***********************************************************************************************************************************************************
     * @param   tileSource        the tile source
     **********************************************************************************************************************************************************/
    public MapViewModel (@Nonnull final TileSource tileSource)
      {
        this.tileSource = tileSource;
      }

    /***********************************************************************************************************************************************************
     * Changes the tile source.
     * @param   tileSource        the new tile source
     **********************************************************************************************************************************************************/
    public void setTileSource (@Nonnull final TileSource tileSource)
      {
        this.tileSource = tileSource;
        recompute();
      }

    /***********************************************************************************************************************************************************
     * Set the center coordinates and the zoom level.
     * @param   coordinates       the coordinates
     * @param   zoom              the zoom level
     **********************************************************************************************************************************************************/
    public void setCenterAndZoom (@Nonnull final MapCoordinates coordinates, final double zoom)
      {
        this.center = coordinates;
        this.zoom = Math.floor(zoom);
        pointCenter = tileSource.coordinatesToMapPoint(coordinates, zoom);
        recompute();
      }

    /***********************************************************************************************************************************************************
     * Set the center point and the zoom level.
     * @param   mapPoint          the mapPoint
     * @param   zoom              the zoom level
     **********************************************************************************************************************************************************/
    public void setCenterAndZoom (@Nonnull final MapPoint mapPoint, final double zoom)
      {
        this.pointCenter = mapPoint;
        this.zoom = Math.floor(zoom);
        center = tileSource.mapPointToCoordinates(pointCenter, zoom);
        recompute();
      }

    /***********************************************************************************************************************************************************
     * Updates the size of the grid given the size of the {@link MapView}.
     * @param   mapViewWidth      the mapViewWidth of the {@code MapView}
     * @param   mapViewHeight     the mapViewHeight of the {@code MapView}
     * @return                    {@code true} if the grid size has changed
     **********************************************************************************************************************************************************/
    public boolean updateGridSize (final double mapViewWidth, final double mapViewHeight)
      {
        this.mapViewWidth = mapViewWidth;
        this.mapViewHeight = mapViewHeight;
        final var prevColumns = columns;
        final var prevRows = rows;
        final var tileSize = tileSource.getTileSize();
        columns = greaterOdd((int)((mapViewWidth + tileSize - 1) / tileSize)) + MARGIN * 2;
        rows = greaterOdd((int)((mapViewHeight + tileSize - 1) / tileSize)) + MARGIN * 2;
        return (prevColumns != columns) || (prevRows != rows);
      }

    /***********************************************************************************************************************************************************
     * Iterates over all the tiles providing the URL of the image for each tile.
     * @param   consumer    the call back
     **********************************************************************************************************************************************************/
    public void iterateOnGrid (@Nonnull final BiConsumer<? super TilePos, ? super URI> consumer)
      {
        final var grid = getGrid();

        for (int r = 0; r < grid.length; r++)
          {
            for (int c = 0; c < grid[r].length; c++)
              {
                consumer.accept(TilePos.of(c, r), grid[r][c].uri);
              }
          }
      }

    /***********************************************************************************************************************************************************
     * {@return the zoom level to apply in order to accomodate the given area to the rendered region}.
     * @param   area      the area to fit
     **********************************************************************************************************************************************************/
    public int computeFittingZoom (@Nonnull final MapArea area)
      {
        log.info("computeFittingZoom({})", area);
        final var center = area.getCenter();
        final var otherModel = new MapViewModel(tileSource); // a temporary model to compute various attempts
        otherModel.updateGridSize(mapViewWidth, mapViewHeight);

        for (int zoomAttempt = tileSource.getMaxZoomLevel(); zoomAttempt >= tileSource.getMinZoomLevel(); zoomAttempt--)
          {
            otherModel.setCenterAndZoom(center, zoomAttempt);
            final var mapArea = otherModel.getArea();

            if (mapArea.contains(area))
              {
                return zoomAttempt;
              }
          }

        return 1;
      }

    /***********************************************************************************************************************************************************
     * {@return the smallest rectangular area which encloses the area rendered in the map view}.
     **********************************************************************************************************************************************************/
    @Nonnull
    public MapArea getArea()
      {
        final var nw = mapViewPointToCoordinates(MapViewPoint.of(0, 0));
        final var se = mapViewPointToCoordinates(MapViewPoint.of(mapViewWidth, mapViewHeight));
        return MapArea.of(nw.latitude(), se.longitude(), se.latitude(), nw.longitude());
      }

    /***********************************************************************************************************************************************************
     * {@return the current grid of tile info}.
     **********************************************************************************************************************************************************/
    @Nonnull
    private TileInfo[][] getGrid()
      {
        final int max = (int)Math.pow(2, zoom);
        // (left, top) tile must be adjusted for half the tile array size
        final int left = tileCenter.column() - columns / 2;
        final int top = tileCenter.row() - rows / 2;        // rows go top to bottom
        final var grid = new TileInfo[rows][columns];

        for (int r = 0; r < rows; r++)
          {
            for (int c = 0; c < columns; c++)
              {
                final var column = Math.floorMod(left + c, max);
                final var row = Math.floorMod(top + r, max);
                final var uri = tileSource.getTileUri(column, row, (int)zoom);
                grid[r][c] = TileInfo.of(TilePos.of(column, row), uri);
              }
          }

        return grid;
      }

    /***********************************************************************************************************************************************************
     * Recomputes the tile center, offset and the grid offset.
     **********************************************************************************************************************************************************/
    public void recompute()
      {
        // both pixel and tile h-axis goes left -> right, v-axis top -> bottom
        final var tileSize = tileSource.getTileSize();
        tileCenter = TilePos.of((int)(pointCenter.x() / tileSize), (int)(pointCenter.y() / tileSize));
        tileOffset = TileOffset.of(pointCenter.x() % tileSize, pointCenter.y() % tileSize);
        gridOffset = TileOffset.of(-tileOffset.x() - tileSize * columns / 2.0 + mapViewWidth / 2 + tileSize / 2.0,
                                   -tileOffset.y() - tileSize * rows / 2.0 + mapViewHeight / 2 + tileSize / 2.0);
        log.trace("center: {}, {} - tile center: {} - tile offset: {} - grid offset: {}", center, pointCenter, tileCenter, tileOffset, gridOffset);
      }

    /***********************************************************************************************************************************************************
     * {@return the point relative to the map view corresponding to the given coordinates}.
     * @param   coordinates         the coordinates
     **********************************************************************************************************************************************************/
    @Nonnull
    public MapViewPoint coordinatesToMapViewPoint (@Nonnull final MapCoordinates coordinates)
      {
        return toMapViewPoint(tileSource.coordinatesToMapPoint(coordinates, zoom));
      }

    /***********************************************************************************************************************************************************
     * {@return the coordinates corresponding to the given mapViewPoint on the map viewer}.
     * @param   mapViewPoint        the mapViewPoint relative to the map view: (0,0) is the top left and (w,h) is the bottom right
     **********************************************************************************************************************************************************/
    @Nonnull
    public MapCoordinates mapViewPointToCoordinates (@Nonnull final MapViewPoint mapViewPoint)
      {
        return tileSource.mapPointToCoordinates(toMapPoint(mapViewPoint), zoom);
      }

    /***********************************************************************************************************************************************************
     * {@return a point in map view coordinates corresponding to a point in map coordinates}.
     * @param   mapPoint            the point
     **********************************************************************************************************************************************************/
    @Nonnull
    private MapViewPoint toMapViewPoint (@Nonnull final MapPoint mapPoint)
      {
        return MapViewPoint.of(mapPoint.translated(mapViewWidth / 2 - pointCenter.x(), mapViewHeight / 2 - pointCenter().y()));
      }

    /***********************************************************************************************************************************************************
     * {@return a point in map coordinates corresponding to a point in map view coordinates}.
     * @param   mapViewPoint        the point
     **********************************************************************************************************************************************************/
    @Nonnull
    private MapPoint toMapPoint (@Nonnull final MapViewPoint mapViewPoint)
      {
        return mapViewPoint.translated(pointCenter.x() - mapViewWidth / 2, pointCenter.y() - mapViewHeight / 2);
      }

    /***********************************************************************************************************************************************************
     * {@return the first greater odd integer of the given number}.
     * @param   n   the number
     **********************************************************************************************************************************************************/
    /* visible for testing */ static int greaterOdd (final int n)
      {
        return n + ((n % 2 == 0) ? 1 : 0);
      }
  }