MapViewModel.java

  1. /*
  2.  * *************************************************************************************************************************************************************
  3.  *
  4.  * MapView: a JavaFX map renderer for tile-based servers
  5.  * http://tidalwave.it/projects/mapview
  6.  *
  7.  * Copyright (C) 2024 - 2025 by Tidalwave s.a.s. (http://tidalwave.it)
  8.  *
  9.  * *************************************************************************************************************************************************************
  10.  *
  11.  * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
  12.  * You may obtain a copy of the License at
  13.  *
  14.  *     http://www.apache.org/licenses/LICENSE-2.0
  15.  *
  16.  * 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
  17.  * CONDITIONS OF ANY KIND, either express or implied.  See the License for the specific language governing permissions and limitations under the License.
  18.  *
  19.  * *************************************************************************************************************************************************************
  20.  *
  21.  * git clone https://bitbucket.org/tidalwave/mapview-src
  22.  * git clone https://github.com/tidalwave-it/mapview-src
  23.  *
  24.  * *************************************************************************************************************************************************************
  25.  */
  26. package it.tidalwave.mapviewer.impl;

  27. import jakarta.annotation.Nonnull;
  28. import java.util.function.BiConsumer;
  29. import java.net.URI;
  30. import it.tidalwave.mapviewer.MapArea;
  31. import it.tidalwave.mapviewer.MapCoordinates;
  32. import it.tidalwave.mapviewer.MapPoint;
  33. import it.tidalwave.mapviewer.MapViewPoint;
  34. import it.tidalwave.mapviewer.TileSource;
  35. import it.tidalwave.mapviewer.javafx.MapView;
  36. import it.tidalwave.mapviewer.javafx.impl.TilePos;
  37. import lombok.Getter;
  38. import lombok.RequiredArgsConstructor;
  39. import lombok.experimental.Accessors;
  40. import lombok.experimental.Delegate;
  41. import lombok.extern.slf4j.Slf4j;

  42. /***************************************************************************************************************************************************************
  43.  *
  44.  * A model (independent of UI technology) that provides parameters for implementing a tile grid based map renderer.
  45.  *
  46.  * To understand how this class works, three coordinate systems are to be understood:
  47.  *
  48.  * <ul>
  49.  *   <li>classic <b>coordinates</b> composed of latitude and longitude, modelled by the class {@link MapCoordinates}</li>;
  50.  *   <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
  51.  *      modelled by the class {@link MapPoint}.
  52.  *   <li><b>map view coordinates</b> that are pixel coordinates in the map view, modelled by the class {@link MapViewPoint}</li>;
  53.  * </ul>
  54.  *
  55.  * 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
  56.  * the map at least by the tile size amount before being forced to reload tiles.
  57.  *
  58.  * The rendering component must:
  59.  *
  60.  * <ul>
  61.  * <li>call the {@link #updateGridSize(double, double)} method specifying the size of the rendering region; this method must be called again every time
  62.  * 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
  63.  * column of tiles must be downloaded).</li>
  64.  * <li>call the {@link #setCenterAndZoom(MapCoordinates, double)} or {@link #setCenterAndZoom(MapPoint, double)} methods to set point that the center of
  65.  * the rendered area, using either coordinates or map points, and the zoom level.
  66.  * </ul>
  67.  *
  68.  * After doing that, this class computes:
  69.  *
  70.  * <ul>
  71.  *   <li>the center point in the coordinate system ({@link #center()};</li>
  72.  *   <li>the center point in the map coordinate system ({@link #pointCenter()})</li>
  73.  *   <li>the coordinates (colum and row) of the tile that is rendered at the center ({@link #tileCenter())</li>
  74.  *   <li>the offset in pixels that the center tile must be applied to ({#{@link #tileOffset()}}</li>
  75.  *   <li>the offset in pixels that the grid must be applied to ({#{@link #gridOffset()}}</li>
  76.  *   <li>the number of columns in the grid ({@link #columns()})</li>
  77.  *   <li>the number of rows in the grid ({@link #rows()} ()})</li>
  78.  * </ul>
  79.  *
  80.  * At this point the implementor must invoke {@link #iterateOnGrid(BiConsumer)} that will call back passing the URL and the grid position for each tile.
  81.  *
  82.  * Two further methods are available:
  83.  *
  84.  * <ul>
  85.  *   <li>{@link #getArea()} returns the coordinates of the rendered area;</li>
  86.  *   <li>{@link #computeFittingZoom(MapArea)} returns the maximum zoom level that allows to fully render the given area.</li>
  87.  * </ul>
  88.  *
  89.  * @author  Fabrizio Giudici
  90.  *
  91.  **************************************************************************************************************************************************************/
  92. @Accessors(fluent = true) @Getter @Slf4j
  93. public class MapViewModel
  94.   {
  95.     @RequiredArgsConstructor(staticName = "of")
  96.     static class TileInfo
  97.       {
  98.         @Delegate @Nonnull
  99.         private final TilePos pos;

  100.         @Getter @Nonnull
  101.         private final URI uri;

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

  108.     private static final int MARGIN = 1;

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

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

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

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

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

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

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

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

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

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

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

  131.     /***********************************************************************************************************************************************************
  132.      * @param   tileSource        the tile source
  133.      **********************************************************************************************************************************************************/
  134.     public MapViewModel (@Nonnull final TileSource tileSource)
  135.       {
  136.         this.tileSource = tileSource;
  137.       }

  138.     /***********************************************************************************************************************************************************
  139.      * Changes the tile source.
  140.      * @param   tileSource        the new tile source
  141.      **********************************************************************************************************************************************************/
  142.     public void setTileSource (@Nonnull final TileSource tileSource)
  143.       {
  144.         this.tileSource = tileSource;
  145.         recompute();
  146.       }

  147.     /***********************************************************************************************************************************************************
  148.      * Set the center coordinates and the zoom level.
  149.      * @param   coordinates       the coordinates
  150.      * @param   zoom              the zoom level
  151.      **********************************************************************************************************************************************************/
  152.     public void setCenterAndZoom (@Nonnull final MapCoordinates coordinates, final double zoom)
  153.       {
  154.         this.center = coordinates;
  155.         this.zoom = Math.floor(zoom);
  156.         pointCenter = tileSource.coordinatesToMapPoint(coordinates, zoom);
  157.         recompute();
  158.       }

  159.     /***********************************************************************************************************************************************************
  160.      * Set the center point and the zoom level.
  161.      * @param   mapPoint          the mapPoint
  162.      * @param   zoom              the zoom level
  163.      **********************************************************************************************************************************************************/
  164.     public void setCenterAndZoom (@Nonnull final MapPoint mapPoint, final double zoom)
  165.       {
  166.         this.pointCenter = mapPoint;
  167.         this.zoom = Math.floor(zoom);
  168.         center = tileSource.mapPointToCoordinates(pointCenter, zoom);
  169.         recompute();
  170.       }

  171.     /***********************************************************************************************************************************************************
  172.      * Updates the size of the grid given the size of the {@link MapView}.
  173.      * @param   mapViewWidth      the mapViewWidth of the {@code MapView}
  174.      * @param   mapViewHeight     the mapViewHeight of the {@code MapView}
  175.      * @return                    {@code true} if the grid size has changed
  176.      **********************************************************************************************************************************************************/
  177.     public boolean updateGridSize (final double mapViewWidth, final double mapViewHeight)
  178.       {
  179.         this.mapViewWidth = mapViewWidth;
  180.         this.mapViewHeight = mapViewHeight;
  181.         final var prevColumns = columns;
  182.         final var prevRows = rows;
  183.         final var tileSize = tileSource.getTileSize();
  184.         columns = greaterOdd((int)((mapViewWidth + tileSize - 1) / tileSize)) + MARGIN * 2;
  185.         rows = greaterOdd((int)((mapViewHeight + tileSize - 1) / tileSize)) + MARGIN * 2;
  186.         return (prevColumns != columns) || (prevRows != rows);
  187.       }

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

  195.         for (int r = 0; r < grid.length; r++)
  196.           {
  197.             for (int c = 0; c < grid[r].length; c++)
  198.               {
  199.                 consumer.accept(TilePos.of(c, r), grid[r][c].uri);
  200.               }
  201.           }
  202.       }

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

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

  217.             if (mapArea.contains(area))
  218.               {
  219.                 return zoomAttempt;
  220.               }
  221.           }

  222.         return 1;
  223.       }

  224.     /***********************************************************************************************************************************************************
  225.      * {@return the smallest rectangular area which encloses the area rendered in the map view}.
  226.      **********************************************************************************************************************************************************/
  227.     @Nonnull
  228.     public MapArea getArea()
  229.       {
  230.         final var nw = mapViewPointToCoordinates(MapViewPoint.of(0, 0));
  231.         final var se = mapViewPointToCoordinates(MapViewPoint.of(mapViewWidth, mapViewHeight));
  232.         return MapArea.of(nw.latitude(), se.longitude(), se.latitude(), nw.longitude());
  233.       }

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

  245.         for (int r = 0; r < rows; r++)
  246.           {
  247.             for (int c = 0; c < columns; c++)
  248.               {
  249.                 final var column = Math.floorMod(left + c, max);
  250.                 final var row = Math.floorMod(top + r, max);
  251.                 final var uri = tileSource.getTileUri(column, row, (int)zoom);
  252.                 grid[r][c] = TileInfo.of(TilePos.of(column, row), uri);
  253.               }
  254.           }

  255.         return grid;
  256.       }

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

  270.     /***********************************************************************************************************************************************************
  271.      * {@return the point relative to the map view corresponding to the given coordinates}.
  272.      * @param   coordinates         the coordinates
  273.      **********************************************************************************************************************************************************/
  274.     @Nonnull
  275.     public MapViewPoint coordinatesToMapViewPoint (@Nonnull final MapCoordinates coordinates)
  276.       {
  277.         return toMapViewPoint(tileSource.coordinatesToMapPoint(coordinates, zoom));
  278.       }

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

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

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

  306.     /***********************************************************************************************************************************************************
  307.      * {@return the first greater odd integer of the given number}.
  308.      * @param   n   the number
  309.      **********************************************************************************************************************************************************/
  310.     /* visible for testing */ static int greaterOdd (final int n)
  311.       {
  312.         return n + ((n % 2 == 0) ? 1 : 0);
  313.       }
  314.   }