MapView.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.javafx;

  27. import jakarta.annotation.Nonnull;
  28. import java.util.Collection;
  29. import java.util.function.Consumer;
  30. import java.nio.file.Path;
  31. import javafx.animation.Interpolatable;
  32. import javafx.animation.Interpolator;
  33. import javafx.animation.KeyFrame;
  34. import javafx.animation.KeyValue;
  35. import javafx.animation.Timeline;
  36. import javafx.beans.property.DoubleProperty;
  37. import javafx.beans.property.ObjectProperty;
  38. import javafx.beans.property.ReadOnlyDoubleProperty;
  39. import javafx.beans.property.SimpleDoubleProperty;
  40. import javafx.beans.property.SimpleObjectProperty;
  41. import javafx.collections.ObservableList;
  42. import javafx.scene.Node;
  43. import javafx.scene.input.MouseEvent;
  44. import javafx.scene.input.ScrollEvent;
  45. import javafx.scene.input.ZoomEvent;
  46. import javafx.scene.layout.AnchorPane;
  47. import javafx.scene.layout.Region;
  48. import javafx.util.Duration;
  49. import javafx.application.Platform;
  50. import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
  51. import it.tidalwave.mapviewer.MapArea;
  52. import it.tidalwave.mapviewer.MapCoordinates;
  53. import it.tidalwave.mapviewer.MapViewPoint;
  54. import it.tidalwave.mapviewer.OpenStreetMapTileSource;
  55. import it.tidalwave.mapviewer.TileSource;
  56. import it.tidalwave.mapviewer.impl.MapViewModel;
  57. import it.tidalwave.mapviewer.impl.RangeLimitedDoubleProperty;
  58. import it.tidalwave.mapviewer.javafx.impl.TileCache;
  59. import it.tidalwave.mapviewer.javafx.impl.TileGrid;
  60. import it.tidalwave.mapviewer.javafx.impl.Translation;
  61. import lombok.Getter;
  62. import lombok.RequiredArgsConstructor;
  63. import lombok.Setter;
  64. import lombok.With;
  65. import lombok.experimental.Accessors;
  66. import lombok.extern.slf4j.Slf4j;
  67. import static java.lang.Double.doubleToLongBits;
  68. import static javafx.util.Duration.ZERO;

  69. /***************************************************************************************************************************************************************
  70.  *
  71.  * A JavaFX control capable to render a map based on tiles. It must be associated to a {@link TileSource} that provides the tile bitmaps; two instances are
  72.  * provided, {@link OpenStreetMapTileSource} and {@link it.tidalwave.mapviewer.OpenTopoMapTileSource}. Further sources can be easily implemented by overriding
  73.  * {@link it.tidalwave.mapviewer.spi.TileSourceSupport}.
  74.  * The basic properties of a {@code MapView} are:
  75.  *
  76.  * <ul>
  77.  *   <li>{@link #tileSourceProperty()}: the tile source (that can be changed during the life of {@code MapView});</li>
  78.  *   <li>{@link #centerProperty()}: the coordinates that are rendered at the center of the screen;</li>
  79.  *   <li>{@link #zoomProperty()}: the detail level for the map, going from 1 (the lowest) to a value depending on the tile source.</li>
  80.  * </ul>
  81.  *
  82.  * Other properties are:
  83.  *
  84.  * <ul>
  85.  *   <li>{@link #minZoomProperty()} (read only): the minimum zoom level allowed;</li>
  86.  *   <li>{@link #maxZoomProperty()} (read only): the maximum zoom level allowed;</li>
  87.  *   <li>{@link #coordinatesUnderMouseProperty()} (read only): the coordinates corresponding to the point where the mouse is;</li>
  88.  *   <li>{@link #areaProperty()} (read only): the rectangular area delimited by north, east, south, west coordinates that is currently rendered.</li>
  89.  * </ul>
  90.  *
  91.  * The method {@link #fitArea(MapArea)} can be used to adapt rendering parameters so that the given area is rendered; this is useful e.g. when one wants to
  92.  * render a GPS track.
  93.  *
  94.  * Maps can be scrolled by dragging and re-centered by double-clicking on a point (use {@link #setRecenterOnDoubleClick(boolean)} to enable this behaviour).
  95.  *
  96.  * It is possible to add and remove overlays that move in solid with the map:
  97.  *
  98.  * <ul>
  99.  *   <li>{@link #addOverlay(String, Consumer)}</li>
  100.  *   <li>{@link #removeOverlay(String)}</li>
  101.  *   <li>{@link #removeAllOverlays()}</li>
  102.  * </ul>
  103.  *
  104.  * @see     OpenStreetMapTileSource
  105.  * @see     it.tidalwave.mapviewer.OpenTopoMapTileSource
  106.  *
  107.  * @author  Fabrizio Giudici
  108.  *
  109.  **************************************************************************************************************************************************************/
  110. @Slf4j
  111. public class MapView extends Region
  112.   {
  113.     private static final int DEFAULT_TILE_POOL_SIZE = 10;
  114.     private static final int DEFAULT_TILE_QUEUE_CAPACITY = 1000;
  115.     private static final OpenStreetMapTileSource DEFAULT_TILE_SOURCE = new OpenStreetMapTileSource();

  116.     /***********************************************************************************************************************************************************
  117.      * This helper class provides methods useful for creating map overlays.
  118.      **********************************************************************************************************************************************************/
  119.     @RequiredArgsConstructor(staticName = "of") @Accessors(fluent = true)
  120.     public static class OverlayHelper
  121.       {
  122.         @Nonnull
  123.         private final MapViewModel model;

  124.         @Nonnull
  125.         private final ObservableList<Node> children;

  126.         /*******************************************************************************************************************************************************
  127.          * Adds a {@link Node} to the overlay.
  128.          * @param   node          the {@code Node}
  129.          ******************************************************************************************************************************************************/
  130.         public void add (@Nonnull final Node node)
  131.           {
  132.             children.add(node);
  133.           }

  134.         /*******************************************************************************************************************************************************
  135.          * Adds multiple {@link Node}s to the overlay.
  136.          * @param   nodes         the {@code Node}s
  137.          ******************************************************************************************************************************************************/
  138.         public void addAll (@Nonnull final Collection<? extends Node> nodes)
  139.           {
  140.             children.addAll(nodes);
  141.           }

  142.         /*******************************************************************************************************************************************************
  143.          * {@return a map view point corresponding to the given coordinates}. This view point must be used to draw to the overlay.
  144.          * @param   coordinates   the coordinates
  145.          ******************************************************************************************************************************************************/
  146.         @Nonnull
  147.         public MapViewPoint toMapViewPoint (@Nonnull final MapCoordinates coordinates)
  148.           {
  149.             final var gridOffset = model.gridOffset();
  150.             final var point = model.coordinatesToMapViewPoint(coordinates);
  151.             return MapViewPoint.of(point.x() - gridOffset.x(), point.y() - gridOffset.y());
  152.           }

  153.         /*******************************************************************************************************************************************************
  154.          * {@return the area rendered in the map view}.
  155.          ******************************************************************************************************************************************************/
  156.         @Nonnull
  157.         public MapArea getArea()
  158.           {
  159.             return model.getArea();
  160.           }
  161.       }

  162.     /***********************************************************************************************************************************************************
  163.      * Options for creating a {@code MapView}.
  164.      * @param   cacheFolder         the {@link Path} of the folder where cached tiles are stored
  165.      * @param   downloadAllowed     whether downloading tiles is allowed
  166.      * @param   poolSize            the number of parallel thread of the tile downloader
  167.      * @param   tileQueueCapacity   the capacity of the tile queue
  168.      **********************************************************************************************************************************************************/
  169.     @With
  170.     public record Options(@Nonnull Path cacheFolder, boolean downloadAllowed, int poolSize, int tileQueueCapacity) {}

  171.     /** The tile source. */
  172.     @Nonnull
  173.     private final SimpleObjectProperty<TileSource> tileSource;

  174.     /** The coordinates at the center of the map view. */
  175.     @Nonnull
  176.     private final SimpleObjectProperty<MapCoordinates> center;

  177.     /** The zoom level. */
  178.     @Nonnull
  179.     private final RangeLimitedDoubleProperty zoom;

  180.     /** The minimum zoom level. */
  181.     @Nonnull
  182.     private final SimpleDoubleProperty minZoom;

  183.     /** The maximum zoom level. */
  184.     @Nonnull
  185.     private final SimpleDoubleProperty maxZoom;

  186.     /** The coordinates corresponding to the mouse position on the map. */
  187.     @Nonnull
  188.     private final SimpleObjectProperty<MapCoordinates> coordinatesUnderMouse;

  189.     /** The rectangular area in the view. */
  190.     @Nonnull
  191.     private final SimpleObjectProperty<MapArea> area;

  192.     /** The model for this control. */
  193.     @Nonnull
  194.     private final MapViewModel model;

  195.     /** The tile grid that the rendering relies upon. */
  196.     @Nonnull
  197.     private final TileGrid tileGrid;

  198.     /** A cache for tiles. */
  199.     @Nonnull
  200.     private final TileCache tileCache;

  201.     /** Whether double click re-centers the map to the clicked point. */
  202.     @Getter @Setter
  203.     private boolean recenterOnDoubleClick = true;

  204.     /** Whether the vertical scroll gesture should zoom. */
  205.     @Getter @Setter
  206.     private boolean scrollToZoom = false;

  207.     /** The duration of the re-centering animation. */
  208.     @Getter @Setter
  209.     private Duration recenterDuration = Duration.millis(200);

  210.     /** True if a zoom operation is in progress. */
  211.     private boolean zooming;

  212.     /** True if a drag operation is in progress. */
  213.     private boolean dragging;

  214.     /** The latest x coordinate in drag. */
  215.     private double dragX;

  216.     /** The latest y coordinate in drag. */
  217.     private double dragY;

  218.     /** The latest value in scroll. */
  219.     private double scroll;

  220.     /***********************************************************************************************************************************************************
  221.      * Creates a new instance.
  222.      * @param   options         options for the control
  223.      **********************************************************************************************************************************************************/
  224.     @SuppressWarnings("this-escape") @SuppressFBWarnings({"MALICIOUS_CODE", "CT_CONSTRUCTOR_THROW"})
  225.     public MapView (@Nonnull final Options options)
  226.       {
  227.         if (!Platform.isFxApplicationThread())
  228.           {
  229.             throw new IllegalStateException("Must be instantiated on JavaFX thread");
  230.           }

  231.         tileSource = new SimpleObjectProperty<>(this, "tileSource", DEFAULT_TILE_SOURCE);
  232.         model = new MapViewModel(tileSource.get());
  233.         tileCache = new TileCache(options);
  234.         tileGrid = new TileGrid(this, model, tileSource, tileCache);
  235.         center = new SimpleObjectProperty<>(this, "center", tileGrid.getCenter());
  236.         zoom = new RangeLimitedDoubleProperty(this, "zoom", model.zoom(), tileSource.get().getMinZoomLevel(), tileSource.get().getMaxZoomLevel());
  237.         minZoom = new SimpleDoubleProperty(this, "minZoom", tileSource.get().getMinZoomLevel());
  238.         maxZoom = new SimpleDoubleProperty(this, "maxZoom", tileSource.get().getMaxZoomLevel());
  239.         coordinatesUnderMouse = new SimpleObjectProperty<>(this, "coordinatesUnderMouse", MapCoordinates.of(0, 0));
  240.         area = new SimpleObjectProperty<>(this, "area", MapArea.of(0, 0, 0, 0));
  241.         tileSource.addListener((_1, _2, _3) -> onTileSourceChanged());
  242.         center.addListener((_1, _2, newValue) -> setCenterAndZoom(newValue, zoom.get()));
  243.         zoom.addListener((_1, _2, newValue) -> setCenterAndZoom(center.get(), newValue.intValue()));
  244.         getChildren().add(tileGrid);
  245.         AnchorPane.setLeftAnchor(tileGrid, 0d);
  246.         AnchorPane.setRightAnchor(tileGrid, 0d);
  247.         AnchorPane.setTopAnchor(tileGrid, 0d);
  248.         AnchorPane.setBottomAnchor(tileGrid, 0d);
  249.         // needRefresh();
  250.         setOnMouseClicked(this::onMouseClicked);
  251.         setOnZoom(this::onZoom);
  252.         setOnZoomStarted(this::onZoomStarted);
  253.         setOnZoomFinished(this::onZoomFinished);
  254.         setOnMouseMoved(this::onMouseMoved);
  255.         setOnScroll(this::onScroll);
  256.         tileGrid.setOnMousePressed(this::onMousePressed);
  257.         tileGrid.setOnMouseReleased(this::onMouseReleased);
  258.         tileGrid.setOnMouseDragged(this::onMouseDragged);
  259.       }

  260.     /***********************************************************************************************************************************************************
  261.      * {@return a new set of default options}.
  262.      **********************************************************************************************************************************************************/
  263.     @Nonnull
  264.     public static Options options()
  265.       {
  266.         return new Options(Path.of(System.getProperty("java.io.tmpdir")), true, DEFAULT_TILE_POOL_SIZE, DEFAULT_TILE_QUEUE_CAPACITY);
  267.       }

  268.     /***********************************************************************************************************************************************************
  269.      * {@return the tile source}.
  270.      * @see                   #setTileSource(TileSource)
  271.      * @see                   #tileSourceProperty()
  272.      **********************************************************************************************************************************************************/
  273.     @Nonnull
  274.     public final TileSource getTileSource()
  275.       {
  276.         return tileSource.get();
  277.       }

  278.     /***********************************************************************************************************************************************************
  279.      * Sets the tile source. Changing the tile source might change the zoom level to make sure it is within the limits of the new source.
  280.      * @param   tileSource    the tile source
  281.      * @see                   #getTileSource()
  282.      * @see                   #tileSourceProperty()
  283.      **********************************************************************************************************************************************************/
  284.     public final void setTileSource (@Nonnull final TileSource tileSource)
  285.       {
  286.         this.tileSource.set(tileSource);
  287.       }

  288.     /***********************************************************************************************************************************************************
  289.      * {@return the tile source property}.
  290.      * @see                   #setTileSource(TileSource)
  291.      * @see                   #getTileSource()
  292.      **********************************************************************************************************************************************************/
  293.     @Nonnull @SuppressFBWarnings("EI_EXPOSE_REP")
  294.     public final ObjectProperty<TileSource> tileSourceProperty()
  295.       {
  296.         return tileSource;
  297.       }

  298.     /***********************************************************************************************************************************************************
  299.      * {@return the center coordinates}.
  300.      * @see                   #setCenter(MapCoordinates)
  301.      * @see                   #centerProperty()
  302.      **********************************************************************************************************************************************************/
  303.     @Nonnull
  304.     public final MapCoordinates getCenter()
  305.       {
  306.         return center.get();
  307.       }

  308.     /***********************************************************************************************************************************************************
  309.      * Sets the coordinates to show at the center of the map.
  310.      * @param   center        the center coordinates
  311.      * @see                   #getCenter()
  312.      * @see                   #centerProperty()
  313.      **********************************************************************************************************************************************************/
  314.     public final void setCenter (@Nonnull final MapCoordinates center)
  315.       {
  316.         this.center.set(center);
  317.       }

  318.     /***********************************************************************************************************************************************************
  319.      * {@return the center property}.
  320.      * @see                   #getCenter()
  321.      * @see                   #setCenter(MapCoordinates)
  322.      **********************************************************************************************************************************************************/
  323.     @Nonnull @SuppressFBWarnings("EI_EXPOSE_REP")
  324.     public final ObjectProperty<MapCoordinates> centerProperty()
  325.       {
  326.         return center;
  327.       }

  328.     /***********************************************************************************************************************************************************
  329.      * {@return the zoom level}.
  330.      * @see                   #setZoom(double)
  331.      * @see                   #zoomProperty()
  332.      **********************************************************************************************************************************************************/
  333.     public final double getZoom()
  334.       {
  335.         return zoom.get();
  336.       }

  337.     /***********************************************************************************************************************************************************
  338.      * Sets the zoom level.
  339.      * @param   zoom    the zoom level
  340.      * @see                   #getZoom()
  341.      * @see                   #zoomProperty()
  342.      **********************************************************************************************************************************************************/
  343.     public final void setZoom (final double zoom)
  344.       {
  345.         this.zoom.set(zoom);
  346.       }

  347.     /***********************************************************************************************************************************************************
  348.      * {@return the zoom level property}.
  349.      * @see                   #getZoom()
  350.      * @see                   #setZoom(double)
  351.      **********************************************************************************************************************************************************/
  352.     @Nonnull @SuppressFBWarnings("EI_EXPOSE_REP")
  353.     public final DoubleProperty zoomProperty()
  354.       {
  355.         return zoom;
  356.       }

  357.     /***********************************************************************************************************************************************************
  358.      * {@return the min zoom level}.
  359.      * @see                   #minZoomProperty()
  360.      **********************************************************************************************************************************************************/
  361.     public final double getMinZoom()
  362.       {
  363.         return minZoom.get();
  364.       }

  365.     /***********************************************************************************************************************************************************
  366.      * {@return the min zoom level property}.
  367.      * @see                   #getMinZoom()
  368.      **********************************************************************************************************************************************************/
  369.     @Nonnull @SuppressFBWarnings("EI_EXPOSE_REP")
  370.     public final ReadOnlyDoubleProperty minZoomProperty()
  371.       {
  372.         return minZoom;
  373.       }

  374.     /***********************************************************************************************************************************************************
  375.      * {@return the max zoom level}.
  376.      * @see                   #maxZoomProperty()
  377.      **********************************************************************************************************************************************************/
  378.     public final double getMaxZoom()
  379.       {
  380.         return maxZoom.get();
  381.       }

  382.     /***********************************************************************************************************************************************************
  383.      * {@return the max zoom level property}.
  384.      * @see                   #getMaxZoom()
  385.      **********************************************************************************************************************************************************/
  386.     @Nonnull @SuppressFBWarnings("EI_EXPOSE_REP")
  387.     public final ReadOnlyDoubleProperty maxZoomProperty()
  388.       {
  389.         return maxZoom;
  390.       }

  391.     /***********************************************************************************************************************************************************
  392.      * {@return the coordinates corresponding to the point where the mouse is}.
  393.      **********************************************************************************************************************************************************/
  394.     @Nonnull @SuppressFBWarnings("EI_EXPOSE_REP")
  395.     public final ObjectProperty<MapCoordinates> coordinatesUnderMouseProperty()
  396.       {
  397.         return coordinatesUnderMouse;
  398.       }

  399.     /***********************************************************************************************************************************************************
  400.      * {@return the area rendered on the map}.
  401.      * @see                   #areaProperty()
  402.      * @see                   #fitArea(MapArea)
  403.      **********************************************************************************************************************************************************/
  404.     @Nonnull
  405.     public final MapArea getArea()
  406.       {
  407.         return area.get();
  408.       }

  409.     /***********************************************************************************************************************************************************
  410.      * {@return the area rendered on the map}.
  411.      * @see                   #getArea()
  412.      * @see                   #fitArea(MapArea)
  413.      **********************************************************************************************************************************************************/
  414.     @Nonnull @SuppressFBWarnings("EI_EXPOSE_REP")
  415.     public final ObjectProperty<MapArea> areaProperty()
  416.       {
  417.         return area;
  418.       }

  419.     /***********************************************************************************************************************************************************
  420.      * Fits the zoom level and centers the map so that the two corners are visible.
  421.      * @param   area          the area to fit
  422.      * @see                   #getArea()
  423.      * @see                   #areaProperty()
  424.      **********************************************************************************************************************************************************/
  425.     public void fitArea (@Nonnull final MapArea area)
  426.       {
  427.         log.debug("fitArea({})", area);
  428.         setCenterAndZoom(area.getCenter(), model.computeFittingZoom(area));
  429.       }

  430.     /***********************************************************************************************************************************************************
  431.      * {@return the scale of the map in meters per pixel}.
  432.      **********************************************************************************************************************************************************/
  433.     // @Nonnegative
  434.     public double getMetersPerPixel()
  435.       {
  436.         return tileSource.get().metersPerPixel(tileGrid.getCenter(), zoom.get());
  437.       }

  438.     /***********************************************************************************************************************************************************
  439.      * {@return a point on the map corresponding to the given coordinates}.
  440.      * @param  coordinates    the coordinates
  441.      **********************************************************************************************************************************************************/
  442.     @Nonnull
  443.     public MapViewPoint coordinatesToPoint (@Nonnull final MapCoordinates coordinates)
  444.       {
  445.         return model.coordinatesToMapViewPoint(coordinates);
  446.       }

  447.     /***********************************************************************************************************************************************************
  448.      * {@return the coordinates corresponding to a given point on the map}.
  449.      * @param   point         the point on the map
  450.      **********************************************************************************************************************************************************/
  451.     @Nonnull
  452.     public MapCoordinates pointToCoordinates (@Nonnull final MapViewPoint point)
  453.       {
  454.         return model.mapViewPointToCoordinates(point);
  455.       }

  456.     /***********************************************************************************************************************************************************
  457.      * Adds an overlay, passing a callback that will be responsible for rendering the overlay, when needed.
  458.      * @param   name          the name of the overlay
  459.      * @param   creator       the overlay creator
  460.      * @see                   OverlayHelper
  461.      **********************************************************************************************************************************************************/
  462.     public void addOverlay (@Nonnull final String name, @Nonnull final Consumer<OverlayHelper> creator)
  463.       {
  464.         tileGrid.addOverlay(name, creator);
  465.       }

  466.     /***********************************************************************************************************************************************************
  467.      * Removes an overlay.
  468.      * @param   name          the name of the overlay to remove
  469.      **********************************************************************************************************************************************************/
  470.     public void removeOverlay (@Nonnull final String name)
  471.       {
  472.         tileGrid.removeOverlay(name);
  473.       }

  474.     /***********************************************************************************************************************************************************
  475.      * Removes all overlays.
  476.      **********************************************************************************************************************************************************/
  477.     public void removeAllOverlays()
  478.       {
  479.         tileGrid.removeAllOverlays();
  480.       }

  481.     /***********************************************************************************************************************************************************
  482.      * Sets both the center and the zoom level.
  483.      * @param   center        the center
  484.      * @param   zoom          the zoom level
  485.      **********************************************************************************************************************************************************/
  486.     private void setCenterAndZoom (@Nonnull final MapCoordinates center, final double zoom)
  487.       {
  488.         log.trace("setCenterAndZoom({}, {})", center, zoom);

  489.         if (!center.equals(tileGrid.getCenter()) || doubleToLongBits(zoom) != doubleToLongBits(model.zoom()))
  490.           {
  491.             tileCache.retainPendingTiles((int)zoom);
  492.             tileGrid.setCenterAndZoom(center, zoom);
  493.             this.center.set(center);
  494.             this.zoom.set(zoom);
  495.             area.set(model.getArea());
  496.           }
  497.       }

  498.     /***********************************************************************************************************************************************************
  499.      * Translate the map center by the specified amount.
  500.      * @param   dx            the horizontal amount
  501.      * @param   dy            the vertical amount
  502.      **********************************************************************************************************************************************************/
  503.     private void translateCenter (final double dx, final double dy)
  504.       {
  505.         tileGrid.translate(dx, dy);
  506.         center.set(model.center());
  507.         area.set(model.getArea());
  508.       }

  509.     /***********************************************************************************************************************************************************
  510.      * This method is called when the tile source has been changed.
  511.      **********************************************************************************************************************************************************/
  512.     private void onTileSourceChanged()
  513.       {
  514.         final var minZoom = tileSourceProperty().get().getMinZoomLevel();
  515.         final var maxZoom = tileSourceProperty().get().getMaxZoomLevel();
  516.         zoom.setLimits(minZoom, maxZoom);
  517.         this.minZoom.set(minZoom);
  518.         this.maxZoom.set(maxZoom);
  519.         setNeedsLayout(true);
  520.       }

  521.     /***********************************************************************************************************************************************************
  522.      * Mouse callback.
  523.      **********************************************************************************************************************************************************/
  524.     private void onMousePressed (@Nonnull final MouseEvent event)
  525.       {
  526.         if (!zooming)
  527.           {
  528.             dragging = true;
  529.             dragX = event.getSceneX();
  530.             dragY = event.getSceneY();
  531.             log.trace("onMousePressed: {} {}", dragX, dragY);
  532.           }
  533.       }

  534.     /***********************************************************************************************************************************************************
  535.      * Mouse callback.
  536.      **********************************************************************************************************************************************************/
  537.     private void onMouseReleased (@Nonnull final MouseEvent ignored)
  538.       {
  539.         log.trace("onMouseReleased");
  540.         dragging = false;
  541.       }

  542.     /***********************************************************************************************************************************************************
  543.      * Mouse callback.
  544.      **********************************************************************************************************************************************************/
  545.     private void onMouseDragged (@Nonnull final MouseEvent event)
  546.       {
  547.         if (!zooming && dragging)
  548.           {
  549.             translateCenter(event.getSceneX() - dragX, event.getSceneY() - dragY);
  550.             dragX = event.getSceneX();
  551.             dragY = event.getSceneY();
  552.           }
  553.       }

  554.     /***********************************************************************************************************************************************************
  555.      * Mouse callback.
  556.      **********************************************************************************************************************************************************/
  557.     private void onMouseClicked (@Nonnull final MouseEvent event)
  558.       {
  559.         if (recenterOnDoubleClick && (event.getClickCount() == 2))
  560.           {
  561.             final var delta = Translation.of(getWidth() / 2 - event.getX(), getHeight() / 2 - event.getY());
  562.             final var target = new SimpleObjectProperty<>(Translation.of(0, 0));
  563.             target.addListener((__, oldValue, newValue) ->
  564.                                        translateCenter(newValue.x - oldValue.x, newValue.y - oldValue.y));
  565.             animate(target, Translation.of(0, 0), delta, recenterDuration);
  566.           }
  567.       }

  568.     /***********************************************************************************************************************************************************
  569.      * Mouse callback.
  570.      **********************************************************************************************************************************************************/
  571.     private void onMouseMoved (@Nonnull final MouseEvent event)
  572.       {
  573.         coordinatesUnderMouse.set(pointToCoordinates(MapViewPoint.of(event)));
  574.       }

  575.     /***********************************************************************************************************************************************************
  576.      * Gesture callback.
  577.      **********************************************************************************************************************************************************/
  578.     private void onZoomStarted (@Nonnull final ZoomEvent event)
  579.       {
  580.         log.trace("onZoomStarted({})", event);
  581.         zooming = true;
  582.         dragging = false;
  583.       }

  584.     /***********************************************************************************************************************************************************
  585.      * Gesture callback.
  586.      **********************************************************************************************************************************************************/
  587.     private void onZoomFinished (@Nonnull final ZoomEvent event)
  588.       {
  589.         log.trace("onZoomFinished({})", event);
  590.         zooming = false;
  591.       }

  592.     /***********************************************************************************************************************************************************
  593.      * Gesture callback.
  594.      **********************************************************************************************************************************************************/
  595.     private void onZoom (@Nonnull final ZoomEvent event)
  596.       {
  597.         log.trace("onZoom({})", event);
  598.       }

  599.     /***********************************************************************************************************************************************************
  600.      * Mouse callback.
  601.      **********************************************************************************************************************************************************/
  602.     private void onScroll (@Nonnull final ScrollEvent event)
  603.       {
  604.         if (scrollToZoom)
  605.           {
  606.             log.info("onScroll({})", event);
  607.             final var amount = -Math.signum(Math.floor(event.getDeltaY() - scroll));
  608.             scroll = event.getDeltaY();
  609.             log.debug("zoom change for scroll: {}", amount);
  610.             System.err.println("AMOUNT " + amount);
  611.             zoom.set(Math.round(zoom.get() + amount));
  612.           }
  613.       }

  614.     /***********************************************************************************************************************************************************
  615.      * Animates a property. If the duration is zero, the property is immediately set.
  616.      * @param   <T>           the static type of the property to animate
  617.      * @param   target        the property to animate
  618.      * @param   startValue    the start value of the property
  619.      * @param   endValue      the end value of the property
  620.      * @param   duration      the duration of the animation
  621.      **********************************************************************************************************************************************************/
  622.     private static <T extends Interpolatable<T>> void animate (@Nonnull final ObjectProperty<T> target,
  623.                                                                @Nonnull final T startValue,
  624.                                                                @Nonnull final T endValue,
  625.                                                                @Nonnull final Duration duration)
  626.       {
  627.         if (duration.equals(ZERO))
  628.           {
  629.             target.set(endValue);
  630.           }
  631.         else
  632.           {
  633.             final var start = new KeyFrame(ZERO, new KeyValue(target, startValue));
  634.             final var end = new KeyFrame(duration, new KeyValue(target, endValue, Interpolator.EASE_OUT));
  635.             new Timeline(start, end).play();
  636.           }
  637.       }

  638.     // FIXME: on close shut down the tile cache executor service.
  639.   }