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 coordinates corresponding at the center of the map view}.
  155.          ******************************************************************************************************************************************************/
  156.         @Nonnull
  157.         public MapCoordinates getCenter()
  158.           {
  159.             return model.center();
  160.           }

  161.         /*******************************************************************************************************************************************************
  162.          * {@return the zoom level}.
  163.          ******************************************************************************************************************************************************/
  164.         public double getZoom()
  165.           {
  166.             return model.zoom();
  167.           }

  168.         /*******************************************************************************************************************************************************
  169.          * {@return the area rendered in the map view}.
  170.          ******************************************************************************************************************************************************/
  171.         @Nonnull
  172.         public MapArea getArea()
  173.           {
  174.             return model.getArea();
  175.           }
  176.       }

  177.     /***********************************************************************************************************************************************************
  178.      * Options for creating a {@code MapView}.
  179.      * @param   cacheFolder         the {@link Path} of the folder where cached tiles are stored
  180.      * @param   downloadAllowed     whether downloading tiles is allowed
  181.      * @param   poolSize            the number of parallel thread of the tile downloader
  182.      * @param   tileQueueCapacity   the capacity of the tile queue
  183.      **********************************************************************************************************************************************************/
  184.     @With
  185.     public record Options(@Nonnull Path cacheFolder, boolean downloadAllowed, int poolSize, int tileQueueCapacity) {}

  186.     /** The tile source. */
  187.     @Nonnull
  188.     private final SimpleObjectProperty<TileSource> tileSource;

  189.     /** The coordinates at the center of the map view. */
  190.     @Nonnull
  191.     private final SimpleObjectProperty<MapCoordinates> center;

  192.     /** The zoom level. */
  193.     @Nonnull
  194.     private final RangeLimitedDoubleProperty zoom;

  195.     /** The minimum zoom level. */
  196.     @Nonnull
  197.     private final SimpleDoubleProperty minZoom;

  198.     /** The maximum zoom level. */
  199.     @Nonnull
  200.     private final SimpleDoubleProperty maxZoom;

  201.     /** The coordinates corresponding to the mouse position on the map. */
  202.     @Nonnull
  203.     private final SimpleObjectProperty<MapCoordinates> coordinatesUnderMouse;

  204.     /** The rectangular area in the view. */
  205.     @Nonnull
  206.     private final SimpleObjectProperty<MapArea> area;

  207.     /** The model for this control. */
  208.     @Nonnull
  209.     private final MapViewModel model;

  210.     /** The tile grid that the rendering relies upon. */
  211.     @Nonnull
  212.     private final TileGrid tileGrid;

  213.     /** A cache for tiles. */
  214.     @Nonnull
  215.     private final TileCache tileCache;

  216.     /** Whether double click re-centers the map to the clicked point. */
  217.     @Getter @Setter
  218.     private boolean recenterOnDoubleClick = true;

  219.     /** Whether the vertical scroll gesture should zoom. */
  220.     @Getter @Setter
  221.     private boolean scrollToZoom = false;

  222.     /** The duration of the re-centering animation. */
  223.     @Getter @Setter
  224.     private Duration recenterDuration = Duration.millis(200);

  225.     /** True if a zoom operation is in progress. */
  226.     private boolean zooming;

  227.     /** True if a drag operation is in progress. */
  228.     private boolean dragging;

  229.     /** The latest x coordinate in drag. */
  230.     private double dragX;

  231.     /** The latest y coordinate in drag. */
  232.     private double dragY;

  233.     /** The latest value in scroll. */
  234.     private double scroll;

  235.     /** A guard to manage reentrant calls to {@link #setCenterAndZoom(MapCoordinates, double)}. */
  236.     private boolean reentrantGuard;

  237.     /***********************************************************************************************************************************************************
  238.      * Creates a new instance.
  239.      * @param   options         options for the control
  240.      **********************************************************************************************************************************************************/
  241.     @SuppressWarnings("this-escape") @SuppressFBWarnings({"MALICIOUS_CODE", "CT_CONSTRUCTOR_THROW"})
  242.     public MapView (@Nonnull final Options options)
  243.       {
  244.         if (!Platform.isFxApplicationThread())
  245.           {
  246.             throw new IllegalStateException("Must be instantiated on JavaFX thread");
  247.           }

  248.         tileSource = new SimpleObjectProperty<>(this, "tileSource", DEFAULT_TILE_SOURCE);
  249.         model = new MapViewModel(tileSource.get());
  250.         tileCache = new TileCache(options);
  251.         tileGrid = new TileGrid(this, model, tileSource, tileCache);
  252.         center = new SimpleObjectProperty<>(this, "center", tileGrid.getCenter());
  253.         zoom = new RangeLimitedDoubleProperty(this, "zoom", model.zoom(), tileSource.get().getMinZoomLevel(), tileSource.get().getMaxZoomLevel());
  254.         minZoom = new SimpleDoubleProperty(this, "minZoom", tileSource.get().getMinZoomLevel());
  255.         maxZoom = new SimpleDoubleProperty(this, "maxZoom", tileSource.get().getMaxZoomLevel());
  256.         coordinatesUnderMouse = new SimpleObjectProperty<>(this, "coordinatesUnderMouse", MapCoordinates.of(0, 0));
  257.         area = new SimpleObjectProperty<>(this, "area", MapArea.of(0, 0, 0, 0));
  258.         tileSource.addListener((_1, _2, _3) -> onTileSourceChanged());
  259.         center.addListener((_1, _2, newValue) -> setCenterAndZoom(newValue, zoom.get()));
  260.         zoom.addListener((_1, _2, newValue) -> setCenterAndZoom(center.get(), newValue.intValue()));
  261.         getChildren().add(tileGrid);
  262.         AnchorPane.setLeftAnchor(tileGrid, 0d);
  263.         AnchorPane.setRightAnchor(tileGrid, 0d);
  264.         AnchorPane.setTopAnchor(tileGrid, 0d);
  265.         AnchorPane.setBottomAnchor(tileGrid, 0d);
  266.         // needRefresh();
  267.         setOnMouseClicked(this::onMouseClicked);
  268.         setOnZoom(this::onZoom);
  269.         setOnZoomStarted(this::onZoomStarted);
  270.         setOnZoomFinished(this::onZoomFinished);
  271.         setOnMouseMoved(this::onMouseMoved);
  272.         setOnScroll(this::onScroll);
  273.         tileGrid.setOnMousePressed(this::onMousePressed);
  274.         tileGrid.setOnMouseReleased(this::onMouseReleased);
  275.         tileGrid.setOnMouseDragged(this::onMouseDragged);
  276.       }

  277.     /***********************************************************************************************************************************************************
  278.      * {@return a new set of default options}.
  279.      **********************************************************************************************************************************************************/
  280.     @Nonnull
  281.     public static Options options()
  282.       {
  283.         return new Options(Path.of(System.getProperty("java.io.tmpdir")), true, DEFAULT_TILE_POOL_SIZE, DEFAULT_TILE_QUEUE_CAPACITY);
  284.       }

  285.     /***********************************************************************************************************************************************************
  286.      * {@return the tile source}.
  287.      * @see                   #setTileSource(TileSource)
  288.      * @see                   #tileSourceProperty()
  289.      **********************************************************************************************************************************************************/
  290.     @Nonnull
  291.     public final TileSource getTileSource()
  292.       {
  293.         return tileSource.get();
  294.       }

  295.     /***********************************************************************************************************************************************************
  296.      * 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.
  297.      * @param   tileSource    the tile source
  298.      * @see                   #getTileSource()
  299.      * @see                   #tileSourceProperty()
  300.      **********************************************************************************************************************************************************/
  301.     public final void setTileSource (@Nonnull final TileSource tileSource)
  302.       {
  303.         this.tileSource.set(tileSource);
  304.       }

  305.     /***********************************************************************************************************************************************************
  306.      * {@return the tile source property}.
  307.      * @see                   #setTileSource(TileSource)
  308.      * @see                   #getTileSource()
  309.      **********************************************************************************************************************************************************/
  310.     @Nonnull @SuppressFBWarnings("EI_EXPOSE_REP")
  311.     public final ObjectProperty<TileSource> tileSourceProperty()
  312.       {
  313.         return tileSource;
  314.       }

  315.     /***********************************************************************************************************************************************************
  316.      * {@return the center coordinates}.
  317.      * @see                   #setCenter(MapCoordinates)
  318.      * @see                   #centerProperty()
  319.      **********************************************************************************************************************************************************/
  320.     @Nonnull
  321.     public final MapCoordinates getCenter()
  322.       {
  323.         return center.get();
  324.       }

  325.     /***********************************************************************************************************************************************************
  326.      * Sets the coordinates to show at the center of the map.
  327.      * @param   center        the center coordinates
  328.      * @see                   #getCenter()
  329.      * @see                   #centerProperty()
  330.      **********************************************************************************************************************************************************/
  331.     public final void setCenter (@Nonnull final MapCoordinates center)
  332.       {
  333.         this.center.set(center);
  334.       }

  335.     /***********************************************************************************************************************************************************
  336.      * {@return the center property}.
  337.      * @see                   #getCenter()
  338.      * @see                   #setCenter(MapCoordinates)
  339.      **********************************************************************************************************************************************************/
  340.     @Nonnull @SuppressFBWarnings("EI_EXPOSE_REP")
  341.     public final ObjectProperty<MapCoordinates> centerProperty()
  342.       {
  343.         return center;
  344.       }

  345.     /***********************************************************************************************************************************************************
  346.      * {@return the zoom level}.
  347.      * @see                   #setZoom(double)
  348.      * @see                   #zoomProperty()
  349.      **********************************************************************************************************************************************************/
  350.     public final double getZoom()
  351.       {
  352.         return zoom.get();
  353.       }

  354.     /***********************************************************************************************************************************************************
  355.      * Sets the zoom level.
  356.      * @param   zoom    the zoom level
  357.      * @see                   #getZoom()
  358.      * @see                   #zoomProperty()
  359.      **********************************************************************************************************************************************************/
  360.     public final void setZoom (final double zoom)
  361.       {
  362.         this.zoom.set(zoom);
  363.       }

  364.     /***********************************************************************************************************************************************************
  365.      * {@return the zoom level property}.
  366.      * @see                   #getZoom()
  367.      * @see                   #setZoom(double)
  368.      **********************************************************************************************************************************************************/
  369.     @Nonnull @SuppressFBWarnings("EI_EXPOSE_REP")
  370.     public final DoubleProperty zoomProperty()
  371.       {
  372.         return zoom;
  373.       }

  374.     /***********************************************************************************************************************************************************
  375.      * {@return the min zoom level}.
  376.      * @see                   #minZoomProperty()
  377.      **********************************************************************************************************************************************************/
  378.     public final double getMinZoom()
  379.       {
  380.         return minZoom.get();
  381.       }

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

  391.     /***********************************************************************************************************************************************************
  392.      * {@return the max zoom level}.
  393.      * @see                   #maxZoomProperty()
  394.      **********************************************************************************************************************************************************/
  395.     public final double getMaxZoom()
  396.       {
  397.         return maxZoom.get();
  398.       }

  399.     /***********************************************************************************************************************************************************
  400.      * {@return the max zoom level property}.
  401.      * @see                   #getMaxZoom()
  402.      **********************************************************************************************************************************************************/
  403.     @Nonnull @SuppressFBWarnings("EI_EXPOSE_REP")
  404.     public final ReadOnlyDoubleProperty maxZoomProperty()
  405.       {
  406.         return maxZoom;
  407.       }

  408.     /***********************************************************************************************************************************************************
  409.      * {@return the coordinates corresponding to the point where the mouse is}.
  410.      **********************************************************************************************************************************************************/
  411.     @Nonnull @SuppressFBWarnings("EI_EXPOSE_REP")
  412.     public final ObjectProperty<MapCoordinates> coordinatesUnderMouseProperty()
  413.       {
  414.         return coordinatesUnderMouse;
  415.       }

  416.     /***********************************************************************************************************************************************************
  417.      * {@return the area rendered on the map}.
  418.      * @see                   #areaProperty()
  419.      * @see                   #fitArea(MapArea)
  420.      **********************************************************************************************************************************************************/
  421.     @Nonnull
  422.     public final MapArea getArea()
  423.       {
  424.         return area.get();
  425.       }

  426.     /***********************************************************************************************************************************************************
  427.      * {@return the area rendered on the map}.
  428.      * @see                   #getArea()
  429.      * @see                   #fitArea(MapArea)
  430.      **********************************************************************************************************************************************************/
  431.     @Nonnull @SuppressFBWarnings("EI_EXPOSE_REP")
  432.     public final ObjectProperty<MapArea> areaProperty()
  433.       {
  434.         return area;
  435.       }

  436.     /***********************************************************************************************************************************************************
  437.      * Fits the zoom level and centers the map so that the two corners are visible.
  438.      * @param   area          the area to fit
  439.      * @see                   #getArea()
  440.      * @see                   #areaProperty()
  441.      **********************************************************************************************************************************************************/
  442.     public void fitArea (@Nonnull final MapArea area)
  443.       {
  444.         log.debug("fitArea({})", area);
  445.         setCenterAndZoom(area.getCenter(), model.computeFittingZoom(area));
  446.       }

  447.     /***********************************************************************************************************************************************************
  448.      * {@return the scale of the map in meters per pixel}.
  449.      **********************************************************************************************************************************************************/
  450.     // @Nonnegative
  451.     public double getMetersPerPixel()
  452.       {
  453.         return tileSource.get().metersPerPixel(tileGrid.getCenter(), zoom.get());
  454.       }

  455.     /***********************************************************************************************************************************************************
  456.      * {@return a point on the map corresponding to the given coordinates}.
  457.      * @param  coordinates    the coordinates
  458.      **********************************************************************************************************************************************************/
  459.     @Nonnull
  460.     public MapViewPoint coordinatesToPoint (@Nonnull final MapCoordinates coordinates)
  461.       {
  462.         return model.coordinatesToMapViewPoint(coordinates);
  463.       }

  464.     /***********************************************************************************************************************************************************
  465.      * {@return the coordinates corresponding to a given point on the map}.
  466.      * @param   point         the point on the map
  467.      **********************************************************************************************************************************************************/
  468.     @Nonnull
  469.     public MapCoordinates pointToCoordinates (@Nonnull final MapViewPoint point)
  470.       {
  471.         return model.mapViewPointToCoordinates(point);
  472.       }

  473.     /***********************************************************************************************************************************************************
  474.      * Adds an overlay, passing a callback that will be responsible for rendering the overlay, when needed.
  475.      * @param   name          the name of the overlay
  476.      * @param   creator       the overlay creator
  477.      * @see                   OverlayHelper
  478.      **********************************************************************************************************************************************************/
  479.     public void addOverlay (@Nonnull final String name, @Nonnull final Consumer<OverlayHelper> creator)
  480.       {
  481.         tileGrid.addOverlay(name, creator);
  482.       }

  483.     /***********************************************************************************************************************************************************
  484.      * Removes an overlay.
  485.      * @param   name          the name of the overlay to remove
  486.      **********************************************************************************************************************************************************/
  487.     public void removeOverlay (@Nonnull final String name)
  488.       {
  489.         tileGrid.removeOverlay(name);
  490.       }

  491.     /***********************************************************************************************************************************************************
  492.      * Removes all overlays.
  493.      **********************************************************************************************************************************************************/
  494.     public void removeAllOverlays()
  495.       {
  496.         tileGrid.removeAllOverlays();
  497.       }

  498.     /***********************************************************************************************************************************************************
  499.      * Sets both the center and the zoom level. This method has got a reentrant protection since it touches the {@link #center} and {@link #zoom} properties,
  500.      * that in turn will fire events that call back this method, the first time with the previous zoom level. There's no way to change them atomically.
  501.      * @param   center        the center
  502.      * @param   zoom          the zoom level
  503.      **********************************************************************************************************************************************************/
  504.     private void setCenterAndZoom (@Nonnull final MapCoordinates center, final double zoom)
  505.       {
  506.         if (!reentrantGuard)
  507.           {
  508.             try
  509.               {
  510.                 reentrantGuard = true;
  511.                 log.trace("setCenterAndZoom({}, {})", center, zoom);

  512.                 if (!center.equals(tileGrid.getCenter()) || doubleToLongBits(zoom) != doubleToLongBits(model.zoom()))
  513.                   {
  514.                     tileCache.retainPendingTiles((int)zoom);
  515.                     tileGrid.setCenterAndZoom(center, zoom);
  516.                     this.center.set(center);
  517.                     this.zoom.set(zoom);
  518.                     area.set(model.getArea());
  519.                   }
  520.               }
  521.             finally // defensive
  522.               {
  523.                 reentrantGuard = false;
  524.               }
  525.           }
  526.       }

  527.     /***********************************************************************************************************************************************************
  528.      * Translate the map center by the specified amount.
  529.      * @param   dx            the horizontal amount
  530.      * @param   dy            the vertical amount
  531.      **********************************************************************************************************************************************************/
  532.     private void translateCenter (final double dx, final double dy)
  533.       {
  534.         tileGrid.translate(dx, dy);
  535.         center.set(model.center());
  536.         area.set(model.getArea());
  537.       }

  538.     /***********************************************************************************************************************************************************
  539.      * This method is called when the tile source has been changed.
  540.      **********************************************************************************************************************************************************/
  541.     private void onTileSourceChanged()
  542.       {
  543.         final var minZoom = tileSourceProperty().get().getMinZoomLevel();
  544.         final var maxZoom = tileSourceProperty().get().getMaxZoomLevel();
  545.         zoom.setLimits(minZoom, maxZoom);
  546.         this.minZoom.set(minZoom);
  547.         this.maxZoom.set(maxZoom);
  548.         setNeedsLayout(true);
  549.       }

  550.     /***********************************************************************************************************************************************************
  551.      * Mouse callback.
  552.      **********************************************************************************************************************************************************/
  553.     private void onMousePressed (@Nonnull final MouseEvent event)
  554.       {
  555.         if (!zooming)
  556.           {
  557.             dragging = true;
  558.             dragX = event.getSceneX();
  559.             dragY = event.getSceneY();
  560.             log.trace("onMousePressed: {} {}", dragX, dragY);
  561.           }
  562.       }

  563.     /***********************************************************************************************************************************************************
  564.      * Mouse callback.
  565.      **********************************************************************************************************************************************************/
  566.     private void onMouseReleased (@Nonnull final MouseEvent ignored)
  567.       {
  568.         log.trace("onMouseReleased");
  569.         dragging = false;
  570.       }

  571.     /***********************************************************************************************************************************************************
  572.      * Mouse callback.
  573.      **********************************************************************************************************************************************************/
  574.     private void onMouseDragged (@Nonnull final MouseEvent event)
  575.       {
  576.         if (!zooming && dragging)
  577.           {
  578.             translateCenter(event.getSceneX() - dragX, event.getSceneY() - dragY);
  579.             dragX = event.getSceneX();
  580.             dragY = event.getSceneY();
  581.           }
  582.       }

  583.     /***********************************************************************************************************************************************************
  584.      * Mouse callback.
  585.      **********************************************************************************************************************************************************/
  586.     private void onMouseClicked (@Nonnull final MouseEvent event)
  587.       {
  588.         if (recenterOnDoubleClick && (event.getClickCount() == 2))
  589.           {
  590.             final var delta = Translation.of(getWidth() / 2 - event.getX(), getHeight() / 2 - event.getY());
  591.             final var target = new SimpleObjectProperty<>(Translation.of(0, 0));
  592.             target.addListener((__, oldValue, newValue) ->
  593.                                        translateCenter(newValue.x - oldValue.x, newValue.y - oldValue.y));
  594.             animate(target, Translation.of(0, 0), delta, recenterDuration);
  595.           }
  596.       }

  597.     /***********************************************************************************************************************************************************
  598.      * Mouse callback.
  599.      **********************************************************************************************************************************************************/
  600.     private void onMouseMoved (@Nonnull final MouseEvent event)
  601.       {
  602.         coordinatesUnderMouse.set(pointToCoordinates(MapViewPoint.of(event)));
  603.       }

  604.     /***********************************************************************************************************************************************************
  605.      * Gesture callback.
  606.      **********************************************************************************************************************************************************/
  607.     private void onZoomStarted (@Nonnull final ZoomEvent event)
  608.       {
  609.         log.trace("onZoomStarted({})", event);
  610.         zooming = true;
  611.         dragging = false;
  612.       }

  613.     /***********************************************************************************************************************************************************
  614.      * Gesture callback.
  615.      **********************************************************************************************************************************************************/
  616.     private void onZoomFinished (@Nonnull final ZoomEvent event)
  617.       {
  618.         log.trace("onZoomFinished({})", event);
  619.         zooming = false;
  620.       }

  621.     /***********************************************************************************************************************************************************
  622.      * Gesture callback.
  623.      **********************************************************************************************************************************************************/
  624.     private void onZoom (@Nonnull final ZoomEvent event)
  625.       {
  626.         log.trace("onZoom({})", event);
  627.       }

  628.     /***********************************************************************************************************************************************************
  629.      * Mouse callback.
  630.      **********************************************************************************************************************************************************/
  631.     private void onScroll (@Nonnull final ScrollEvent event)
  632.       {
  633.         if (scrollToZoom)
  634.           {
  635.             log.info("onScroll({})", event);
  636.             final var amount = -Math.signum(Math.floor(event.getDeltaY() - scroll));
  637.             scroll = event.getDeltaY();
  638.             log.debug("zoom change for scroll: {}", amount);
  639.             System.err.println("AMOUNT " + amount);
  640.             zoom.set(Math.round(zoom.get() + amount));
  641.           }
  642.       }

  643.     /***********************************************************************************************************************************************************
  644.      * Animates a property. If the duration is zero, the property is immediately set.
  645.      * @param   <T>           the static type of the property to animate
  646.      * @param   target        the property to animate
  647.      * @param   startValue    the start value of the property
  648.      * @param   endValue      the end value of the property
  649.      * @param   duration      the duration of the animation
  650.      **********************************************************************************************************************************************************/
  651.     private static <T extends Interpolatable<T>> void animate (@Nonnull final ObjectProperty<T> target,
  652.                                                                @Nonnull final T startValue,
  653.                                                                @Nonnull final T endValue,
  654.                                                                @Nonnull final Duration duration)
  655.       {
  656.         if (duration.equals(ZERO))
  657.           {
  658.             target.set(endValue);
  659.           }
  660.         else
  661.           {
  662.             final var start = new KeyFrame(ZERO, new KeyValue(target, startValue));
  663.             final var end = new KeyFrame(duration, new KeyValue(target, endValue, Interpolator.EASE_OUT));
  664.             new Timeline(start, end).play();
  665.           }
  666.       }

  667.     // FIXME: on close shut down the tile cache executor service.
  668.   }