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