TileGrid.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.impl;
import jakarta.annotation.Nonnull;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Consumer;
import java.net.URI;
import javafx.beans.property.ObjectProperty;
import javafx.scene.Node;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.StackPane;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import it.tidalwave.mapviewer.MapCoordinates;
import it.tidalwave.mapviewer.TileSource;
import it.tidalwave.mapviewer.impl.MapViewModel;
import it.tidalwave.mapviewer.javafx.MapView;
import lombok.experimental.Accessors;
import lombok.extern.slf4j.Slf4j;
/***************************************************************************************************************************************************************
*
* A grid of tiles, used to completely fill an arbitrary area of a graphic device.
*
* @author Fabrizio Giudici
*
**************************************************************************************************************************************************************/
@Slf4j @Accessors(fluent = true)
public class TileGrid extends StackPane
{
enum Dirty
{
/** Not dirty */ NONE,
/** Only grid needs to be rebuilt. */ GRID,
/** All need to be rebuilt, */ ALL
}
/** The owner component. */
@Nonnull @SuppressFBWarnings("EI_EXPOSE_REP2")
private final MapView parent;
/** The tile source. */
@Nonnull
private final ObjectProperty<TileSource> tileSource;
/** The model. */
@Nonnull
private final MapViewModel model;
/** The tile cache. */
@Nonnull
private final TileCache tileCache;
/** Whether this control needs to be redrawn. */
private Dirty dirty = Dirty.NONE;
/** The map of overlays indexed by name. */
private final Map<String, MapOverlay> overlayByName = new HashMap<>();
/** The container of tiles. */
private final GridPane tilePane = new GridPane();
/** The container of overlays. */
private final StackPane overlayPane = new StackPane();
/***********************************************************************************************************************************************************
* Creates a grid of tiles.
* @param parent the map view control
* @param model the map model
* @param tileSource the tile source
* @param tileCache the tile cache
**********************************************************************************************************************************************************/
@SuppressFBWarnings({"EI_EXPOSE_REP2", "MC_OVERRIDABLE_METHOD_CALL_IN_CONSTRUCTOR"})
public TileGrid (@Nonnull final MapView parent,
@Nonnull final MapViewModel model,
@Nonnull final ObjectProperty<TileSource> tileSource,
@Nonnull final TileCache tileCache)
{
this.parent = parent;
this.tileSource = tileSource;
this.model = model;
this.tileCache = tileCache;
getChildren().addAll(tilePane, overlayPane);
parent.layoutBoundsProperty().addListener((_1, _2, _3) -> setDirty(Dirty.GRID));
model.setCenterAndZoom(MapCoordinates.of(0, 0), 1);
tileSource.addListener((_1, _2, _3) -> onTileSourceChanged());
}
/***********************************************************************************************************************************************************
* Sets the coordinates at the center of the grid and the zoom level. This method will update the tiles in the grid with the proper URLs for the required
* setting. If the grid is already populated, existing tiles are recycled if possible (this is useful while moving the coordinates in order to avoid the
* number of tiles to download for the next position).
* @param coordinates the coordinates at the center of the tile
* @param zoomLevel the zoom level
**********************************************************************************************************************************************************/
public void setCenterAndZoom (@Nonnull final MapCoordinates coordinates, final double zoomLevel)
{
log.debug("setCenterAndZoom({}, {})", coordinates, zoomLevel);
model.setCenterAndZoom(coordinates, zoomLevel);
createTiles();
recreateOverlays();
setDirty(Dirty.ALL);
}
/***********************************************************************************************************************************************************
* {@return the coordinates of the point at the center of the map}.
**********************************************************************************************************************************************************/
@Nonnull
public MapCoordinates getCenter()
{
return model.center();
}
/***********************************************************************************************************************************************************
* Translates the tile grid. If the translation is so large that the tile at the center changes, the grid is recomputed and translated back.
* @param deltaX the drag in screen coordinates
* @param deltaY the drag in screen coordinates
**********************************************************************************************************************************************************/
public void translate (final double deltaX, final double deltaY)
{
log.trace("translate({}, {})", deltaX, deltaY);
final var prevTileCenter = model.tileCenter();
model.setCenterAndZoom(model.pointCenter().translated(-deltaX, -deltaY), model.zoom());
final var tileCenter = model.tileCenter();
if (!prevTileCenter.equals(tileCenter))
{
createTiles();
// no need to recreate overlays, just translate them
final var dX = overlayPane.getTranslateX() -(tileCenter.column - prevTileCenter.column) * tileSource.get().getTileSize();
final var dY = overlayPane.getTranslateY() -(tileCenter.row - prevTileCenter.row) * tileSource.get().getTileSize();
log.debug("translate overlays: {}, {}", dX, dY);
overlayPane.setTranslateX(dX);
overlayPane.setTranslateY(dY);
setDirty(Dirty.GRID);
}
else
{
applyTranslate();
}
}
/***********************************************************************************************************************************************************
* Adds an overlay.
* @param name the name of the overlay
* @param creator the overlay creator
**********************************************************************************************************************************************************/
public void addOverlay (@Nonnull final String name, @Nonnull final Consumer<MapView.OverlayHelper> creator)
{
final var overlay = new MapOverlay(model, creator);
overlayPane.getChildren().add(overlay);
overlayByName.put(name, overlay);
overlay.create();
}
/***********************************************************************************************************************************************************
* Removes an overlay.
* @param name the name of the overlay to remove
**********************************************************************************************************************************************************/
public void removeOverlay (@Nonnull final String name)
{
if (overlayByName.containsKey(name))
{
overlayPane.getChildren().remove(overlayByName.remove(name));
}
}
/***********************************************************************************************************************************************************
* Removes all overlays.
**********************************************************************************************************************************************************/
public void removeAllOverlays()
{
overlayByName.clear();
overlayPane.getChildren().clear();
}
/***********************************************************************************************************************************************************
* {@inheritDoc}
**********************************************************************************************************************************************************/
@Override
protected void layoutChildren()
{
log.trace("layoutChildren");
if (dirty != Dirty.NONE && isVisible())
{
final var parentWidth = parent.getWidth();
final var parentHeight = parent.getHeight();
final var centerTileChanged = model.updateGridSize(parentWidth, parentHeight);
model.recompute();
if (centerTileChanged)
{
log.debug("new view size: {} x {}, new grid size: {} x {}", parentWidth, parentHeight, model.columns(), model.rows());
createTiles();
if (dirty == Dirty.ALL)
{
recreateOverlays();
}
}
else
{
applyTranslate();
}
}
dirty = Dirty.NONE;
super.layoutChildren();
}
/***********************************************************************************************************************************************************
*
**********************************************************************************************************************************************************/
private void onTileSourceChanged()
{
log.debug("onTileSourceChanged()");
model.setTileSource(tileSource.get());
createTiles();
setDirty(Dirty.GRID);
}
/***********************************************************************************************************************************************************
*
**********************************************************************************************************************************************************/
private void createTiles()
{
log.debug("createTiles()");
tilePane.getChildren().clear();
model.iterateOnGrid((pos, url) -> tilePane.add(createTile(url), pos.column(), pos.row(), 1, 1));
applyTranslate();
}
/***********************************************************************************************************************************************************
*
**********************************************************************************************************************************************************/
private void recreateOverlays()
{
log.debug("recreateOverlays()");
overlayPane.setTranslateX(0);
overlayPane.setTranslateY(0);
overlayByName.values().forEach(MapOverlay::create);
}
/***********************************************************************************************************************************************************
*
**********************************************************************************************************************************************************/
@Nonnull
private Node createTile (@Nonnull final URI uri)
{
return new Tile(tileCache, tileSource.get(), uri, tileSource.get().getTileSize(), (int)model.zoom());
}
/***********************************************************************************************************************************************************
*
**********************************************************************************************************************************************************/
private void applyTranslate()
{
setTranslateX(model.gridOffset().x());
setTranslateY(model.gridOffset().y());
}
/***********************************************************************************************************************************************************
*
**********************************************************************************************************************************************************/
private void setDirty (@Nonnull final Dirty dirty)
{
this.dirty = dirty;
setNeedsLayout(true);
}
}