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

import jakarta.annotation.Nonnull;
import java.util.Collection;
import java.util.function.Consumer;
import java.nio.file.Path;
import javafx.animation.Interpolatable;
import javafx.animation.Interpolator;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.ReadOnlyDoubleProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.ObservableList;
import javafx.scene.Node;
import javafx.scene.input.MouseEvent;
import javafx.scene.input.ScrollEvent;
import javafx.scene.input.ZoomEvent;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.Region;
import javafx.util.Duration;
import javafx.application.Platform;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import it.tidalwave.mapviewer.MapArea;
import it.tidalwave.mapviewer.MapCoordinates;
import it.tidalwave.mapviewer.MapViewPoint;
import it.tidalwave.mapviewer.OpenStreetMapTileSource;
import it.tidalwave.mapviewer.TileSource;
import it.tidalwave.mapviewer.impl.MapViewModel;
import it.tidalwave.mapviewer.impl.RangeLimitedDoubleProperty;
import it.tidalwave.mapviewer.javafx.impl.TileCache;
import it.tidalwave.mapviewer.javafx.impl.TileGrid;
import it.tidalwave.mapviewer.javafx.impl.Translation;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.With;
import lombok.experimental.Accessors;
import lombok.extern.slf4j.Slf4j;
import static java.lang.Double.doubleToLongBits;
import static javafx.util.Duration.ZERO;

/***************************************************************************************************************************************************************
 *
 * 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
 * provided, {@link OpenStreetMapTileSource} and {@link it.tidalwave.mapviewer.OpenTopoMapTileSource}. Further sources can be easily implemented by overriding
 * {@link it.tidalwave.mapviewer.spi.TileSourceSupport}.
 * The basic properties of a {@code MapView} are:
 *
 * <ul>
 *   <li>{@link #tileSourceProperty()}: the tile source (that can be changed during the life of {@code MapView});</li>
 *   <li>{@link #centerProperty()}: the coordinates that are rendered at the center of the screen;</li>
 *   <li>{@link #zoomProperty()}: the detail level for the map, going from 1 (the lowest) to a value depending on the tile source.</li>
 * </ul>
 *
 * Other properties are:
 *
 * <ul>
 *   <li>{@link #minZoomProperty()} (read only): the minimum zoom level allowed;</li>
 *   <li>{@link #maxZoomProperty()} (read only): the maximum zoom level allowed;</li>
 *   <li>{@link #coordinatesUnderMouseProperty()} (read only): the coordinates corresponding to the point where the mouse is;</li>
 *   <li>{@link #areaProperty()} (read only): the rectangular area delimited by north, east, south, west coordinates that is currently rendered.</li>
 * </ul>
 *
 * 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
 * render a GPS track.
 *
 * Maps can be scrolled by dragging and re-centered by double-clicking on a point (use {@link #setRecenterOnDoubleClick(boolean)} to enable this behaviour).
 *
 * It is possible to add and remove overlays that move in solid with the map:
 *
 * <ul>
 *   <li>{@link #addOverlay(String, Consumer)}</li>
 *   <li>{@link #removeOverlay(String)}</li>
 *   <li>{@link #removeAllOverlays()}</li>
 * </ul>
 *
 * @see     OpenStreetMapTileSource
 * @see     it.tidalwave.mapviewer.OpenTopoMapTileSource
 *
 * @author  Fabrizio Giudici
 *
 **************************************************************************************************************************************************************/
@Slf4j
public class MapView extends Region
  {
    private static final int DEFAULT_TILE_POOL_SIZE = 10;
    private static final int DEFAULT_TILE_QUEUE_CAPACITY = 1000;
    private static final OpenStreetMapTileSource DEFAULT_TILE_SOURCE = new OpenStreetMapTileSource();

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

        @Nonnull
        private final ObservableList<Node> children;

        /*******************************************************************************************************************************************************
         * Adds a {@link Node} to the overlay.
         * @param   node          the {@code Node}
         ******************************************************************************************************************************************************/
        public void add (@Nonnull final Node node)
          {
            children.add(node);
          }

        /*******************************************************************************************************************************************************
         * Adds multiple {@link Node}s to the overlay.
         * @param   nodes         the {@code Node}s
         ******************************************************************************************************************************************************/
        public void addAll (@Nonnull final Collection<? extends Node> nodes)
          {
            children.addAll(nodes);
          }

        /*******************************************************************************************************************************************************
         * {@return a map view point corresponding to the given coordinates}. This view point must be used to draw to the overlay.
         * @param   coordinates   the coordinates
         ******************************************************************************************************************************************************/
        @Nonnull
        public MapViewPoint toMapViewPoint (@Nonnull final MapCoordinates coordinates)
          {
            final var gridOffset = model.gridOffset();
            final var point = model.coordinatesToMapViewPoint(coordinates);
            return MapViewPoint.of(point.x() - gridOffset.x(), point.y() - gridOffset.y());
          }

        /*******************************************************************************************************************************************************
         * {@return the area rendered in the map view}.
         ******************************************************************************************************************************************************/
        @Nonnull
        public MapArea getArea()
          {
            return model.getArea();
          }
      }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        tileSource = new SimpleObjectProperty<>(this, "tileSource", DEFAULT_TILE_SOURCE);
        model = new MapViewModel(tileSource.get());
        tileCache = new TileCache(options);
        tileGrid = new TileGrid(this, model, tileSource, tileCache);
        center = new SimpleObjectProperty<>(this, "center", tileGrid.getCenter());
        zoom = new RangeLimitedDoubleProperty(this, "zoom", model.zoom(), tileSource.get().getMinZoomLevel(), tileSource.get().getMaxZoomLevel());
        minZoom = new SimpleDoubleProperty(this, "minZoom", tileSource.get().getMinZoomLevel());
        maxZoom = new SimpleDoubleProperty(this, "maxZoom", tileSource.get().getMaxZoomLevel());
        coordinatesUnderMouse = new SimpleObjectProperty<>(this, "coordinatesUnderMouse", MapCoordinates.of(0, 0));
        area = new SimpleObjectProperty<>(this, "area", MapArea.of(0, 0, 0, 0));
        tileSource.addListener((_1, _2, _3) -> onTileSourceChanged());
        center.addListener((_1, _2, newValue) -> setCenterAndZoom(newValue, zoom.get()));
        zoom.addListener((_1, _2, newValue) -> setCenterAndZoom(center.get(), newValue.intValue()));
        getChildren().add(tileGrid);
        AnchorPane.setLeftAnchor(tileGrid, 0d);
        AnchorPane.setRightAnchor(tileGrid, 0d);
        AnchorPane.setTopAnchor(tileGrid, 0d);
        AnchorPane.setBottomAnchor(tileGrid, 0d);
        // needRefresh();
        setOnMouseClicked(this::onMouseClicked);
        setOnZoom(this::onZoom);
        setOnZoomStarted(this::onZoomStarted);
        setOnZoomFinished(this::onZoomFinished);
        setOnMouseMoved(this::onMouseMoved);
        setOnScroll(this::onScroll);
        tileGrid.setOnMousePressed(this::onMousePressed);
        tileGrid.setOnMouseReleased(this::onMouseReleased);
        tileGrid.setOnMouseDragged(this::onMouseDragged);
      }

    /***********************************************************************************************************************************************************
     * {@return a new set of default options}.
     **********************************************************************************************************************************************************/
    @Nonnull
    public static Options options()
      {
        return new Options(Path.of(System.getProperty("java.io.tmpdir")), true, DEFAULT_TILE_POOL_SIZE, DEFAULT_TILE_QUEUE_CAPACITY);
      }

    /***********************************************************************************************************************************************************
     * {@return the tile source}.
     * @see                   #setTileSource(TileSource)
     * @see                   #tileSourceProperty()
     **********************************************************************************************************************************************************/
    @Nonnull
    public final TileSource getTileSource()
      {
        return tileSource.get();
      }

    /***********************************************************************************************************************************************************
     * 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.
     * @param   tileSource    the tile source
     * @see                   #getTileSource()
     * @see                   #tileSourceProperty()
     **********************************************************************************************************************************************************/
    public final void setTileSource (@Nonnull final TileSource tileSource)
      {
        this.tileSource.set(tileSource);
      }

    /***********************************************************************************************************************************************************
     * {@return the tile source property}.
     * @see                   #setTileSource(TileSource)
     * @see                   #getTileSource()
     **********************************************************************************************************************************************************/
    @Nonnull @SuppressFBWarnings("EI_EXPOSE_REP")
    public final ObjectProperty<TileSource> tileSourceProperty()
      {
        return tileSource;
      }

    /***********************************************************************************************************************************************************
     * {@return the center coordinates}.
     * @see                   #setCenter(MapCoordinates)
     * @see                   #centerProperty()
     **********************************************************************************************************************************************************/
    @Nonnull
    public final MapCoordinates getCenter()
      {
        return center.get();
      }

    /***********************************************************************************************************************************************************
     * Sets the coordinates to show at the center of the map.
     * @param   center        the center coordinates
     * @see                   #getCenter()
     * @see                   #centerProperty()
     **********************************************************************************************************************************************************/
    public final void setCenter (@Nonnull final MapCoordinates center)
      {
        this.center.set(center);
      }

    /***********************************************************************************************************************************************************
     * {@return the center property}.
     * @see                   #getCenter()
     * @see                   #setCenter(MapCoordinates)
     **********************************************************************************************************************************************************/
    @Nonnull @SuppressFBWarnings("EI_EXPOSE_REP")
    public final ObjectProperty<MapCoordinates> centerProperty()
      {
        return center;
      }

    /***********************************************************************************************************************************************************
     * {@return the zoom level}.
     * @see                   #setZoom(double)
     * @see                   #zoomProperty()
     **********************************************************************************************************************************************************/
    public final double getZoom()
      {
        return zoom.get();
      }

    /***********************************************************************************************************************************************************
     * Sets the zoom level.
     * @param   zoom    the zoom level
     * @see                   #getZoom()
     * @see                   #zoomProperty()
     **********************************************************************************************************************************************************/
    public final void setZoom (final double zoom)
      {
        this.zoom.set(zoom);
      }

    /***********************************************************************************************************************************************************
     * {@return the zoom level property}.
     * @see                   #getZoom()
     * @see                   #setZoom(double)
     **********************************************************************************************************************************************************/
    @Nonnull @SuppressFBWarnings("EI_EXPOSE_REP")
    public final DoubleProperty zoomProperty()
      {
        return zoom;
      }

    /***********************************************************************************************************************************************************
     * {@return the min zoom level}.
     * @see                   #minZoomProperty()
     **********************************************************************************************************************************************************/
    public final double getMinZoom()
      {
        return minZoom.get();
      }

    /***********************************************************************************************************************************************************
     * {@return the min zoom level property}.
     * @see                   #getMinZoom()
     **********************************************************************************************************************************************************/
    @Nonnull @SuppressFBWarnings("EI_EXPOSE_REP")
    public final ReadOnlyDoubleProperty minZoomProperty()
      {
        return minZoom;
      }

    /***********************************************************************************************************************************************************
     * {@return the max zoom level}.
     * @see                   #maxZoomProperty()
     **********************************************************************************************************************************************************/
    public final double getMaxZoom()
      {
        return maxZoom.get();
      }

    /***********************************************************************************************************************************************************
     * {@return the max zoom level property}.
     * @see                   #getMaxZoom()
     **********************************************************************************************************************************************************/
    @Nonnull @SuppressFBWarnings("EI_EXPOSE_REP")
    public final ReadOnlyDoubleProperty maxZoomProperty()
      {
        return maxZoom;
      }

    /***********************************************************************************************************************************************************
     * {@return the coordinates corresponding to the point where the mouse is}.
     **********************************************************************************************************************************************************/
    @Nonnull @SuppressFBWarnings("EI_EXPOSE_REP")
    public final ObjectProperty<MapCoordinates> coordinatesUnderMouseProperty()
      {
        return coordinatesUnderMouse;
      }

    /***********************************************************************************************************************************************************
     * {@return the area rendered on the map}.
     * @see                   #areaProperty()
     * @see                   #fitArea(MapArea)
     **********************************************************************************************************************************************************/
    @Nonnull
    public final MapArea getArea()
      {
        return area.get();
      }

    /***********************************************************************************************************************************************************
     * {@return the area rendered on the map}.
     * @see                   #getArea()
     * @see                   #fitArea(MapArea)
     **********************************************************************************************************************************************************/
    @Nonnull @SuppressFBWarnings("EI_EXPOSE_REP")
    public final ObjectProperty<MapArea> areaProperty()
      {
        return area;
      }

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

    /***********************************************************************************************************************************************************
     * {@return the scale of the map in meters per pixel}.
     **********************************************************************************************************************************************************/
    // @Nonnegative
    public double getMetersPerPixel()
      {
        return tileSource.get().metersPerPixel(tileGrid.getCenter(), zoom.get());
      }

    /***********************************************************************************************************************************************************
     * {@return a point on the map corresponding to the given coordinates}.
     * @param  coordinates    the coordinates
     **********************************************************************************************************************************************************/
    @Nonnull
    public MapViewPoint coordinatesToPoint (@Nonnull final MapCoordinates coordinates)
      {
        return model.coordinatesToMapViewPoint(coordinates);
      }

    /***********************************************************************************************************************************************************
     * {@return the coordinates corresponding to a given point on the map}.
     * @param   point         the point on the map
     **********************************************************************************************************************************************************/
    @Nonnull
    public MapCoordinates pointToCoordinates (@Nonnull final MapViewPoint point)
      {
        return model.mapViewPointToCoordinates(point);
      }

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

    /***********************************************************************************************************************************************************
     * Removes an overlay.
     * @param   name          the name of the overlay to remove
     **********************************************************************************************************************************************************/
    public void removeOverlay (@Nonnull final String name)
      {
        tileGrid.removeOverlay(name);
      }

    /***********************************************************************************************************************************************************
     * Removes all overlays.
     **********************************************************************************************************************************************************/
    public void removeAllOverlays()
      {
        tileGrid.removeAllOverlays();
      }

    /***********************************************************************************************************************************************************
     * Sets both the center and the zoom level.
     * @param   center        the center
     * @param   zoom          the zoom level
     **********************************************************************************************************************************************************/
    private void setCenterAndZoom (@Nonnull final MapCoordinates center, final double zoom)
      {
        log.trace("setCenterAndZoom({}, {})", center, zoom);

        if (!center.equals(tileGrid.getCenter()) || doubleToLongBits(zoom) != doubleToLongBits(model.zoom()))
          {
            tileCache.retainPendingTiles((int)zoom);
            tileGrid.setCenterAndZoom(center, zoom);
            this.center.set(center);
            this.zoom.set(zoom);
            area.set(model.getArea());
          }
      }

    /***********************************************************************************************************************************************************
     * Translate the map center by the specified amount.
     * @param   dx            the horizontal amount
     * @param   dy            the vertical amount
     **********************************************************************************************************************************************************/
    private void translateCenter (final double dx, final double dy)
      {
        tileGrid.translate(dx, dy);
        center.set(model.center());
        area.set(model.getArea());
      }

    /***********************************************************************************************************************************************************
     * This method is called when the tile source has been changed.
     **********************************************************************************************************************************************************/
    private void onTileSourceChanged()
      {
        final var minZoom = tileSourceProperty().get().getMinZoomLevel();
        final var maxZoom = tileSourceProperty().get().getMaxZoomLevel();
        zoom.setLimits(minZoom, maxZoom);
        this.minZoom.set(minZoom);
        this.maxZoom.set(maxZoom);
        setNeedsLayout(true);
      }

    /***********************************************************************************************************************************************************
     * Mouse callback.
     **********************************************************************************************************************************************************/
    private void onMousePressed (@Nonnull final MouseEvent event)
      {
        if (!zooming)
          {
            dragging = true;
            dragX = event.getSceneX();
            dragY = event.getSceneY();
            log.trace("onMousePressed: {} {}", dragX, dragY);
          }
      }

    /***********************************************************************************************************************************************************
     * Mouse callback.
     **********************************************************************************************************************************************************/
    private void onMouseReleased (@Nonnull final MouseEvent ignored)
      {
        log.trace("onMouseReleased");
        dragging = false;
      }

    /***********************************************************************************************************************************************************
     * Mouse callback.
     **********************************************************************************************************************************************************/
    private void onMouseDragged (@Nonnull final MouseEvent event)
      {
        if (!zooming && dragging)
          {
            translateCenter(event.getSceneX() - dragX, event.getSceneY() - dragY);
            dragX = event.getSceneX();
            dragY = event.getSceneY();
          }
      }

    /***********************************************************************************************************************************************************
     * Mouse callback.
     **********************************************************************************************************************************************************/
    private void onMouseClicked (@Nonnull final MouseEvent event)
      {
        if (recenterOnDoubleClick && (event.getClickCount() == 2))
          {
            final var delta = Translation.of(getWidth() / 2 - event.getX(), getHeight() / 2 - event.getY());
            final var target = new SimpleObjectProperty<>(Translation.of(0, 0));
            target.addListener((__, oldValue, newValue) ->
                                       translateCenter(newValue.x - oldValue.x, newValue.y - oldValue.y));
            animate(target, Translation.of(0, 0), delta, recenterDuration);
          }
      }

    /***********************************************************************************************************************************************************
     * Mouse callback.
     **********************************************************************************************************************************************************/
    private void onMouseMoved (@Nonnull final MouseEvent event)
      {
        coordinatesUnderMouse.set(pointToCoordinates(MapViewPoint.of(event)));
      }

    /***********************************************************************************************************************************************************
     * Gesture callback.
     **********************************************************************************************************************************************************/
    private void onZoomStarted (@Nonnull final ZoomEvent event)
      {
        log.trace("onZoomStarted({})", event);
        zooming = true;
        dragging = false;
      }

    /***********************************************************************************************************************************************************
     * Gesture callback.
     **********************************************************************************************************************************************************/
    private void onZoomFinished (@Nonnull final ZoomEvent event)
      {
        log.trace("onZoomFinished({})", event);
        zooming = false;
      }

    /***********************************************************************************************************************************************************
     * Gesture callback.
     **********************************************************************************************************************************************************/
    private void onZoom (@Nonnull final ZoomEvent event)
      {
        log.trace("onZoom({})", event);
      }

    /***********************************************************************************************************************************************************
     * Mouse callback.
     **********************************************************************************************************************************************************/
    private void onScroll (@Nonnull final ScrollEvent event)
      {
        if (scrollToZoom)
          {
            log.info("onScroll({})", event);
            final var amount = -Math.signum(Math.floor(event.getDeltaY() - scroll));
            scroll = event.getDeltaY();
            log.debug("zoom change for scroll: {}", amount);
            System.err.println("AMOUNT " + amount);
            zoom.set(Math.round(zoom.get() + amount));
          }
      }

    /***********************************************************************************************************************************************************
     * Animates a property. If the duration is zero, the property is immediately set.
     * @param   <T>           the static type of the property to animate
     * @param   target        the property to animate
     * @param   startValue    the start value of the property
     * @param   endValue      the end value of the property
     * @param   duration      the duration of the animation
     **********************************************************************************************************************************************************/
    private static <T extends Interpolatable<T>> void animate (@Nonnull final ObjectProperty<T> target,
                                                               @Nonnull final T startValue,
                                                               @Nonnull final T endValue,
                                                               @Nonnull final Duration duration)
      {
        if (duration.equals(ZERO))
          {
            target.set(endValue);
          }
        else
          {
            final var start = new KeyFrame(ZERO, new KeyValue(target, startValue));
            final var end = new KeyFrame(duration, new KeyValue(target, endValue, Interpolator.EASE_OUT));
            new Timeline(start, end).play();
          }
      }

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