GisMap.java

package nl.tudelft.simulation.dsol.animation.gis.map;

import java.awt.BasicStroke;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.geom.AffineTransform;
import java.awt.geom.NoninvertibleTransformException;
import java.awt.geom.Path2D;
import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.SortedMap;
import java.util.TreeMap;

import org.djutils.draw.bounds.Bounds2d;
import org.djutils.immutablecollections.ImmutableArrayList;
import org.djutils.immutablecollections.ImmutableHashMap;
import org.djutils.immutablecollections.ImmutableList;
import org.djutils.immutablecollections.ImmutableMap;
import org.djutils.logger.CategoryLogger;

import nl.tudelft.simulation.dsol.animation.gis.DsolGisException;
import nl.tudelft.simulation.dsol.animation.gis.FeatureInterface;
import nl.tudelft.simulation.dsol.animation.gis.GisMapInterface;
import nl.tudelft.simulation.dsol.animation.gis.LayerInterface;
import nl.tudelft.simulation.dsol.animation.gis.MapImageInterface;
import nl.tudelft.simulation.dsol.animation.gis.MapUnits;

/**
 * Provides the implementation of a Map.
 * <p>
 * Copyright (c) 2020-2025 Delft University of Technology, Jaffalaan 5, 2628 BX Delft, the Netherlands. All rights reserved. See
 * for project information <a href="https://simulation.tudelft.nl/dsol/manual/" target="_blank">DSOL Manual</a>. The DSOL
 * project is distributed under a three-clause BSD-style license, which can be found at
 * <a href="https://simulation.tudelft.nl/dsol/docs/latest/license.html" target="_blank">DSOL License</a>.
 * </p>
 * <p>
 * The dsol-animation-gis project is based on the gisbeans project that has been part of DSOL since 2002, originally by Peter
 * Jacobs and Paul Jacobs.
 * </p>
 * @author <a href="https://github.com/averbraeck">Alexander Verbraeck</a>
 */
public class GisMap implements GisMapInterface
{
    /** the extent of the map. */
    private Bounds2d extent;

    /** the complete map of layer names to layers. */
    private Map<String, LayerInterface> layerMap = new LinkedHashMap<>();

    /** the complete list of layer names of the map in the order they are displayed. */
    private List<String> layerNames = new ArrayList<>();

    /** the set of visible layers of the map. */
    private Set<LayerInterface> visibleLayers = new LinkedHashSet<>();

    /** The z-sorted map of all features. */
    private SortedMap<Double, List<FeatureInterface>> sortedFeatureMap = new TreeMap<>();

    /** the set of visible features to draw. */
    private Set<FeatureInterface> visibleFeatures = new LinkedHashSet<>();

    /** same set to false after layer change. */
    private boolean same = false;

    /** the mapfileImage. */
    private MapImageInterface image = new MapImage();

    /** the name of the mapFile. */
    private String name;

    /** the map units. */
    private MapUnits mapUnits = MapUnits.METERS;

    /** draw the background? */
    private boolean drawBackground = true;

    /** the mapping of one map unit to the equivalent number of meters in the x-direction. */
    private final double metersPerUnitX;

    /** the mapping of one map unit to the equivalent number of meters in the y-direction. */
    private final double metersPerUnitY;

    /**
     * Construct a new Map, defining the size of a map unit in meters. The size of a map unit can then be used to draw scalable
     * lines with a certain width, and to make certain features disappear if the meter-to-pixel ratio is passing a certain
     * threshold. In WGS84 maps, typically the latitude is used, since it does not vary dependent on the location.
     * @param metersPerUnitX the mapping of one map unit to the equivalent number of meters in the x-direction
     * @param metersPerUnitY the mapping of one map unit to the equivalent number of meters in the y-direction
     */
    public GisMap(final double metersPerUnitX, final double metersPerUnitY)
    {
        this.metersPerUnitX = metersPerUnitX;
        this.metersPerUnitY = metersPerUnitY;
    }

    /**
     * Construct a new Map, with WGS84 as the scale definition, with values for the equator. One degree of latitude is
     * approximately 111,120 meters, while one degree of longitude varies from approximately 111,120 meters at the equator to 0
     * meters at the poles.
     */
    public GisMap()
    {
        this(111120.0, 111120.0);
    }

    @Override
    public void addLayer(final LayerInterface layer)
    {
        this.layerMap.put(layer.getName(), layer);
        this.layerNames.add(layer.getName());
        this.visibleLayers.add(layer);
        for (var feature : layer.getFeatures())
        {
            var featureList = this.sortedFeatureMap.get(feature.getZIndex());
            if (featureList == null)
            {
                featureList = new ArrayList<>();
                this.sortedFeatureMap.put(feature.getZIndex(), featureList);
            }
            featureList.add(feature);
            this.visibleFeatures.add(feature);
        }
        this.same = false;
    }

    @Override
    public void setLayers(final List<LayerInterface> layers)
    {
        this.layerMap.clear();
        this.layerNames.clear();
        this.visibleLayers.clear();
        this.sortedFeatureMap.clear();
        this.visibleFeatures.clear();
        for (var layer : layers)
            addLayer(layer);
    }

    @Override
    public void setLayer(final int index, final LayerInterface layer)
    {
        this.layerMap.put(layer.getName(), layer);
        this.layerNames.set(index, layer.getName());
        this.visibleLayers.add(layer);
        for (var feature : layer.getFeatures())
        {
            var featureList = this.sortedFeatureMap.get(feature.getZIndex());
            if (featureList == null)
            {
                featureList = new ArrayList<>();
                this.sortedFeatureMap.put(feature.getZIndex(), featureList);
            }
            featureList.add(feature);
            this.visibleFeatures.add(feature);
        }
        this.same = false;
    }

    @Override
    public void hideLayer(final LayerInterface layer)
    {
        this.visibleLayers.remove(layer);
        for (var feature : layer.getFeatures())
        {
            this.visibleFeatures.remove(feature);
        }
        this.same = false;
    }

    @Override
    public void hideLayer(final String layerName)
    {
        if (this.layerMap.keySet().contains(layerName))
        {
            hideLayer(this.layerMap.get(layerName));
        }
        this.same = false;
    }

    @Override
    public void showLayer(final LayerInterface layer)
    {
        this.visibleLayers.add(layer);
        for (var feature : layer.getFeatures())
        {
            this.visibleFeatures.add(feature);
        }
        this.same = false;
    }

    @Override
    public void showLayer(final String layerName)
    {
        if (this.layerMap.keySet().contains(layerName))
        {
            var layer = this.layerMap.get(layerName);
            showLayer(layer);
        }
        this.same = false;
    }

    @Override
    public boolean isSame()
    {
        boolean ret = this.same;
        this.same = true;
        return ret;
    }

    @Override
    @SuppressWarnings("checkstyle:methodlength")
    public Graphics2D drawMap(final Graphics2D graphics) throws DsolGisException
    {
        if (this.drawBackground)
        {
            // fill the background.
            graphics.setColor(getImage().getBackgroundColor());
            graphics.fillRect(0, 0, (int) getImage().getSize().getWidth(), (int) getImage().getSize().getHeight());
        }

        // compute the transform of the map
        AffineTransform transform = new AffineTransform();
        transform.scale(getImage().getSize().getWidth() / this.extent.getDeltaX(),
                -getImage().getSize().getHeight() / this.extent.getDeltaY());
        transform.translate(-this.extent.getMinX(), -this.extent.getMinY() - this.extent.getDeltaY());
        AffineTransform antiTransform = null;
        try
        {
            antiTransform = transform.createInverse();
        }
        catch (NoninvertibleTransformException e)
        {
            CategoryLogger.always().error(e);
        }

        // set the rendering hints
        graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);

        // loop over the features and see if they have to be drawn
        for (var featureList : this.sortedFeatureMap.values())
        {
            for (var feature : featureList)
            {
                if (this.visibleFeatures.contains(feature) && feature.isDisplay()
                        && getMetersPerPixelY() < feature.getShapeStyle().getScaleThresholdMetersPerPx())
                {
                    try
                    {
                        var shapeStyle = feature.getShapeStyle();
                        if (shapeStyle.getOutlineColor() != null)
                        {
                            if (!Double.isNaN(shapeStyle.getLineWidthM()))
                            {
                                graphics.setStroke(
                                        new BasicStroke(Math.max(1, (int) (shapeStyle.getLineWidthM() / getMetersPerPixelY())),
                                                BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
                            }
                            else if (shapeStyle.getLineWidthPx() > 1)
                                graphics.setStroke(new BasicStroke(shapeStyle.getLineWidthPx(), BasicStroke.CAP_ROUND,
                                        BasicStroke.JOIN_ROUND));
                            else
                                graphics.setStroke(new BasicStroke(1));
                        }
                        var shapeIterator = feature.shapeIterator(this.extent);
                        while (shapeIterator.hasNext())
                        {
                            Path2D shape = shapeIterator.next();
                            if (feature.isTransform())
                            {
                                shape.transform(transform);
                            }
                            if (shapeStyle.getFillColor() != null)
                            {
                                graphics.setColor(shapeStyle.getFillColor());
                                graphics.fill(shape);
                            }
                            if (shapeStyle.getOutlineColor() != null)
                            {
                                graphics.setColor(shapeStyle.getOutlineColor());
                                graphics.draw(shape);
                            }
                            if (feature.isTransform())
                            {
                                shape.transform(antiTransform);
                            }
                        }
                        graphics.setStroke(new BasicStroke(1));
                    }
                    catch (Exception exception)
                    {
                        CategoryLogger.always().error(exception);
                        throw new DsolGisException(exception);
                    }
                }
            }
        }
        return graphics;
    }

    @Override
    public Bounds2d getExtent()
    {
        return this.extent;
    }

    @Override
    public MapImageInterface getImage()
    {
        return this.image;
    }

    @Override
    public ImmutableList<LayerInterface> getAllLayers()
    {
        return new ImmutableArrayList<>(this.layerMap.values());
    }

    @Override
    public ImmutableList<LayerInterface> getVisibleLayers()
    {
        return new ImmutableArrayList<>(this.visibleLayers);
    }

    @Override
    public ImmutableMap<String, LayerInterface> getLayerMap()
    {
        return new ImmutableHashMap<>(this.layerMap);
    }

    @Override
    public String getName()
    {
        return this.name;
    }

    @Override
    public double getScaleX()
    {
        return this.extent.getDeltaX() / getImage().getSize().getWidth();
    }

    @Override
    public double getScaleY()
    {
        return this.extent.getDeltaY() / getImage().getSize().getHeight();
    }

    @Override
    public double getMetersPerUnitX()
    {
        return this.metersPerUnitX;
    }

    @Override
    public double getMetersPerUnitY()
    {
        return this.metersPerUnitY;
    }

    @Override
    public MapUnits getMapUnits()
    {
        return this.mapUnits;
    }

    @Override
    public void setExtent(final Bounds2d extent)
    {
        this.extent = extent;
    }

    @Override
    public void setImage(final MapImageInterface image)
    {
        this.image = image;
    }

    @Override
    public void setName(final String name)
    {
        this.name = name;
    }

    @Override
    public void setUnits(final MapUnits units)
    {
        this.mapUnits = units;
    }

    @Override
    public void zoom(final double zoomFactor)
    {
        double correcteddZoomFactor = (zoomFactor == 0) ? 1 : zoomFactor;

        double maxX = getImage().getSize().getWidth() / getScaleX() + this.extent.getMinX();
        double maxY = getImage().getSize().getHeight() / getScaleY() + this.extent.getMinY();

        double centerX = (maxX - this.extent.getMinX()) / 2 + this.extent.getMinX();
        double centerY = (maxY - this.extent.getMinY()) / 2 + this.extent.getMinY();

        double width = (1.0 / correcteddZoomFactor) * (maxX - this.extent.getMinX());
        double height = (1.0 / correcteddZoomFactor) * (maxY - this.extent.getMinY());

        this.extent =
                new Bounds2d(centerX - 0.5 * width, centerX + 0.5 * width, centerY - 0.5 * height, centerY + 0.5 * height);
    }

    @Override
    public void zoomPoint(final Point2D pixelPosition, final double zoomFactor)
    {
        double correcteddZoomFactor = (zoomFactor == 0) ? 1 : zoomFactor;

        double maxX = getImage().getSize().getWidth() / getScaleX() + this.extent.getMinX();
        double maxY = getImage().getSize().getHeight() / getScaleY() + this.extent.getMinY();

        double centerX = (pixelPosition.getX() / getImage().getSize().getWidth()) * (maxX - this.extent.getMinX())
                + this.extent.getMinX();
        double centerY = maxY - (pixelPosition.getY() / getImage().getSize().getHeight()) * (maxY - this.extent.getMinY());

        double width = (1.0 / correcteddZoomFactor) * (maxX - this.extent.getMinX());
        double height = (1.0 / correcteddZoomFactor) * (maxY - getExtent().getMinY());

        this.extent =
                new Bounds2d(centerX - 0.5 * width, centerX + 0.5 * width, centerY - 0.5 * height, centerY + 0.5 * height);
    }

    @Override
    public void zoomRectangle(final Rectangle2D rectangle)
    {

        double maxX = getImage().getSize().getWidth() / getScaleX() + this.extent.getMinX();
        double maxY = getImage().getSize().getHeight() / getScaleY() + this.extent.getMinY();

        double width = maxX - this.extent.getMinX();
        double height = maxY - this.extent.getMinY();

        double minX = this.extent.getMinX() + (rectangle.getMinX() / getImage().getSize().getWidth()) * width;
        double minY = this.extent.getMinY()
                + ((getImage().getSize().getHeight() - rectangle.getMaxY()) / getImage().getSize().getHeight()) * height;

        maxX = minX + (rectangle.getWidth() / getImage().getSize().getWidth()) * width;
        maxY = minY + ((getImage().getSize().getHeight() - rectangle.getHeight()) / getImage().getSize().getHeight()) * height;
        this.extent = new Bounds2d(minX, maxX, minY, maxY);
    }

    @Override
    public boolean isDrawBackground()
    {
        return this.drawBackground;
    }

    @Override
    public void setDrawBackground(final boolean drawBackground)
    {
        this.drawBackground = drawBackground;
    }

}