// License: GPL. For details, see LICENSE file. package org.openstreetmap.josm.gui; import java.awt.Container; import java.awt.Point; import java.awt.geom.AffineTransform; import java.awt.geom.Area; import java.awt.geom.Path2D; import java.awt.geom.Point2D; import java.awt.geom.Point2D.Double; import java.awt.geom.Rectangle2D; import java.io.Serializable; import java.util.Objects; import java.util.Optional; import javax.swing.JComponent; import org.openstreetmap.josm.Main; import org.openstreetmap.josm.data.Bounds; import org.openstreetmap.josm.data.ProjectionBounds; import org.openstreetmap.josm.data.coor.EastNorth; import org.openstreetmap.josm.data.coor.LatLon; import org.openstreetmap.josm.data.osm.Node; import org.openstreetmap.josm.data.projection.Projecting; import org.openstreetmap.josm.data.projection.Projection; import org.openstreetmap.josm.gui.download.DownloadDialog; import org.openstreetmap.josm.tools.CheckParameterUtil; import org.openstreetmap.josm.tools.Geometry; import org.openstreetmap.josm.tools.JosmRuntimeException; import org.openstreetmap.josm.tools.bugreport.BugReport; /** * This class represents a state of the {@link MapView}. * @author Michael Zangl * @since 10343 */ public final class MapViewState implements Serializable { private static final long serialVersionUID = 1L; /** * A flag indicating that the point is outside to the top of the map view. * @since 10827 */ public static final int OUTSIDE_TOP = 1; /** * A flag indicating that the point is outside to the bottom of the map view. * @since 10827 */ public static final int OUTSIDE_BOTTOM = 2; /** * A flag indicating that the point is outside to the left of the map view. * @since 10827 */ public static final int OUTSIDE_LEFT = 4; /** * A flag indicating that the point is outside to the right of the map view. * @since 10827 */ public static final int OUTSIDE_RIGHT = 8; /** * Additional pixels outside the view for where to start clipping. */ private static final int CLIP_BOUNDS = 50; private final transient Projecting projecting; private final int viewWidth; private final int viewHeight; private final double scale; /** * Top left {@link EastNorth} coordinate of the view. */ private final EastNorth topLeft; private final Point topLeftOnScreen; private final Point topLeftInWindow; /** * Create a new {@link MapViewState} * @param projection The projection to use. * @param viewWidth The view width * @param viewHeight The view height * @param scale The scale to use * @param topLeft The top left corner in east/north space. * @param topLeftInWindow The top left point in window * @param topLeftOnScreen The top left point on screen */ private MapViewState(Projecting projection, int viewWidth, int viewHeight, double scale, EastNorth topLeft, Point topLeftInWindow, Point topLeftOnScreen) { CheckParameterUtil.ensureParameterNotNull(projection, "projection"); CheckParameterUtil.ensureParameterNotNull(topLeft, "topLeft"); CheckParameterUtil.ensureParameterNotNull(topLeftInWindow, "topLeftInWindow"); CheckParameterUtil.ensureParameterNotNull(topLeftOnScreen, "topLeftOnScreen"); this.projecting = projection; this.scale = scale; this.topLeft = topLeft; this.viewWidth = viewWidth; this.viewHeight = viewHeight; this.topLeftInWindow = topLeftInWindow; this.topLeftOnScreen = topLeftOnScreen; } private MapViewState(Projecting projection, int viewWidth, int viewHeight, double scale, EastNorth topLeft) { this(projection, viewWidth, viewHeight, scale, topLeft, new Point(0, 0), new Point(0, 0)); } private MapViewState(EastNorth topLeft, MapViewState mvs) { this(mvs.projecting, mvs.viewWidth, mvs.viewHeight, mvs.scale, topLeft, mvs.topLeftInWindow, mvs.topLeftOnScreen); } private MapViewState(double scale, MapViewState mvs) { this(mvs.projecting, mvs.viewWidth, mvs.viewHeight, scale, mvs.topLeft, mvs.topLeftInWindow, mvs.topLeftOnScreen); } private MapViewState(JComponent position, MapViewState mvs) { this(mvs.projecting, position.getWidth(), position.getHeight(), mvs.scale, mvs.topLeft, findTopLeftInWindow(position), findTopLeftOnScreen(position)); } private MapViewState(Projecting projecting, MapViewState mvs) { this(projecting, mvs.viewWidth, mvs.viewHeight, mvs.scale, mvs.topLeft, mvs.topLeftInWindow, mvs.topLeftOnScreen); } private static Point findTopLeftInWindow(JComponent position) { Point result = new Point(); // better than using swing utils, since this allows us to use the method if no screen is present. Container component = position; while (component != null) { result.x += component.getX(); result.y += component.getY(); component = component.getParent(); } return result; } private static Point findTopLeftOnScreen(JComponent position) { try { return position.getLocationOnScreen(); } catch (JosmRuntimeException | IllegalArgumentException | IllegalStateException e) { throw BugReport.intercept(e).put("position", position).put("parent", position::getParent); } } /** * The scale in east/north units per pixel. * @return The scale. */ public double getScale() { return scale; } /** * Gets the MapViewPoint representation for a position in view coordinates. * @param x The x coordinate inside the view. * @param y The y coordinate inside the view. * @return The MapViewPoint. */ public MapViewPoint getForView(double x, double y) { return new MapViewViewPoint(x, y); } /** * Gets the {@link MapViewPoint} for the given {@link EastNorth} coordinate. * @param eastNorth the position. * @return The point for that position. */ public MapViewPoint getPointFor(EastNorth eastNorth) { return new MapViewEastNorthPoint(eastNorth); } /** * Gets the {@link MapViewPoint} for the given {@link LatLon} coordinate. * @param latlon the position * @return The point for that position. * @since 10651 */ public MapViewPoint getPointFor(LatLon latlon) { return getPointFor(getProjection().latlon2eastNorth(latlon)); } /** * Gets the {@link MapViewPoint} for the given node. This is faster than {@link #getPointFor(LatLon)} because it uses the node east/north * cache. * @param node The node * @return The position of that node. * @since 10827 */ public MapViewPoint getPointFor(Node node) { try { return getPointFor(node.getEastNorth(getProjection())); } catch (JosmRuntimeException | IllegalArgumentException | IllegalStateException e) { throw BugReport.intercept(e).put("node", node); } } /** * Gets a rectangle representing the whole view area. * @return The rectangle. */ public MapViewRectangle getViewArea() { return getForView(0, 0).rectTo(getForView(viewWidth, viewHeight)); } /** * Gets a rectangle of the view as map view area. * @param rectangle The rectangle to get. * @return The view area. * @since 10827 */ public MapViewRectangle getViewArea(Rectangle2D rectangle) { return getForView(rectangle.getMinX(), rectangle.getMinY()).rectTo(getForView(rectangle.getMaxX(), rectangle.getMaxY())); } /** * Gets the center of the view. * @return The center position. */ public MapViewPoint getCenter() { return getForView(viewWidth / 2.0, viewHeight / 2.0); } /** * Gets the center of the view, rounded to a pixel coordinate * @return The center position. * @since 10856 */ public MapViewPoint getCenterAtPixel() { return getForView(viewWidth / 2, viewHeight / 2); } /** * Gets the width of the view on the Screen; * @return The width of the view component in screen pixel. */ public double getViewWidth() { return viewWidth; } /** * Gets the height of the view on the Screen; * @return The height of the view component in screen pixel. */ public double getViewHeight() { return viewHeight; } /** * Gets the current projection used for the MapView. * @return The projection. */ public Projection getProjection() { return projecting.getBaseProjection(); } /** * Creates an affine transform that is used to convert the east/north coordinates to view coordinates. * @return The affine transform. It should not be changed. * @since 10375 */ public AffineTransform getAffineTransform() { return new AffineTransform(1.0 / scale, 0.0, 0.0, -1.0 / scale, -topLeft.east() / scale, topLeft.north() / scale); } /** * Gets a rectangle that is several pixel bigger than the view. It is used to define the view clipping. * @return The rectangle. */ public MapViewRectangle getViewClipRectangle() { return getForView(-CLIP_BOUNDS, -CLIP_BOUNDS).rectTo(getForView(getViewWidth() + CLIP_BOUNDS, getViewHeight() + CLIP_BOUNDS)); } /** * Returns the area for the given bounds. * @param bounds bounds * @return the area for the given bounds */ public Area getArea(Bounds bounds) { Path2D area = new Path2D.Double(); bounds.visitEdge(getProjection(), latlon -> { MapViewPoint point = getPointFor(latlon); if (area.getCurrentPoint() == null) { area.moveTo(point.getInViewX(), point.getInViewY()); } else { area.lineTo(point.getInViewX(), point.getInViewY()); } }); area.closePath(); return new Area(area); } /** * Creates a new state that is the same as the current state except for that it is using a new center. * @param newCenter The new center coordinate. * @return The new state. * @since 10375 */ public MapViewState usingCenter(EastNorth newCenter) { return movedTo(getCenter(), newCenter); } /** * @param mapViewPoint The reference point. * @param newEastNorthThere The east/north coordinate that should be there. * @return The new state. * @since 10375 */ public MapViewState movedTo(MapViewPoint mapViewPoint, EastNorth newEastNorthThere) { EastNorth delta = newEastNorthThere.subtract(mapViewPoint.getEastNorth()); if (delta.distanceSq(0, 0) < .1e-20) { return this; } else { return new MapViewState(topLeft.add(delta), this); } } /** * Creates a new state that is the same as the current state except for that it is using a new scale. * @param newScale The new scale to use. * @return The new state. * @since 10375 */ public MapViewState usingScale(double newScale) { return new MapViewState(newScale, this); } /** * Creates a new state that is the same as the current state except for that it is using the location of the given component. * <p> * The view is moved so that the center is the same as the old center. * @param positon The new location to use. * @return The new state. * @since 10375 */ public MapViewState usingLocation(JComponent positon) { EastNorth center = this.getCenter().getEastNorth(); return new MapViewState(positon, this).usingCenter(center); } /** * Creates a state that uses the projection. * @param projection The projection to use. * @return The new state. * @since 10486 */ public MapViewState usingProjection(Projection projection) { if (projection.equals(this.projecting)) { return this; } else { return new MapViewState(projection, this); } } /** * Create the default {@link MapViewState} object for the given map view. The screen position won't be set so that this method can be used * before the view was added to the hirarchy. * @param width The view width * @param height The view height * @return The state * @since 10375 */ public static MapViewState createDefaultState(int width, int height) { Projection projection = Main.getProjection(); double scale = projection.getDefaultZoomInPPD(); MapViewState state = new MapViewState(projection, width, height, scale, new EastNorth(0, 0)); EastNorth center = calculateDefaultCenter(); return state.movedTo(state.getCenter(), center); } private static EastNorth calculateDefaultCenter() { Bounds b = Optional.ofNullable(DownloadDialog.getSavedDownloadBounds()).orElseGet( () -> Main.getProjection().getWorldBoundsLatLon()); return Main.getProjection().latlon2eastNorth(b.getCenter()); } /** * Check if this MapViewState equals another one, disregarding the position * of the JOSM window on screen. * @param other the other MapViewState * @return true if the other MapViewState has the same size, scale, position and projection, * false otherwise */ public boolean equalsInWindow(MapViewState other) { return other != null && this.viewWidth == other.viewWidth && this.viewHeight == other.viewHeight && this.scale == other.scale && Objects.equals(this.topLeft, other.topLeft) && Objects.equals(this.projecting, other.projecting); } /** * A class representing a point in the map view. It allows to convert between the different coordinate systems. * @author Michael Zangl */ public abstract class MapViewPoint { /** * Get this point in view coordinates. * @return The point in view coordinates. */ public Point2D getInView() { return new Point2D.Double(getInViewX(), getInViewY()); } /** * Get the x coordinate in view space without creating an intermediate object. * @return The x coordinate * @since 10827 */ public abstract double getInViewX(); /** * Get the y coordinate in view space without creating an intermediate object. * @return The y coordinate * @since 10827 */ public abstract double getInViewY(); /** * Convert this point to window coordinates. * @return The point in window coordinates. */ public Point2D getInWindow() { return getUsingCorner(topLeftInWindow); } /** * Convert this point to screen coordinates. * @return The point in screen coordinates. */ public Point2D getOnScreen() { return getUsingCorner(topLeftOnScreen); } private Double getUsingCorner(Point corner) { return new Point2D.Double(corner.getX() + getInViewX(), corner.getY() + getInViewY()); } /** * Gets the {@link EastNorth} coordinate of this point. * @return The east/north coordinate. */ public EastNorth getEastNorth() { return new EastNorth(topLeft.east() + getInViewX() * scale, topLeft.north() - getInViewY() * scale); } /** * Create a rectangle from this to the other point. * @param other The other point. Needs to be of the same {@link MapViewState} * @return A rectangle. */ public MapViewRectangle rectTo(MapViewPoint other) { return new MapViewRectangle(this, other); } /** * Gets the current position in LatLon coordinates according to the current projection. * @return The positon as LatLon. * @see #getLatLonClamped() */ public LatLon getLatLon() { return projecting.getBaseProjection().eastNorth2latlon(getEastNorth()); } /** * Gets the latlon coordinate clamped to the current world area. * @return The lat/lon coordinate * @since 10805 */ public LatLon getLatLonClamped() { return projecting.eastNorth2latlonClamped(getEastNorth()); } /** * Add the given offset to this point * @param en The offset in east/north space. * @return The new point * @since 10651 */ public MapViewPoint add(EastNorth en) { return new MapViewEastNorthPoint(getEastNorth().add(en)); } /** * Check if this point is inside the view bounds. * * This is the case iff <code>getOutsideRectangleFlags(getViewArea())</code> returns no flags * @return true if it is. * @since 10827 */ public boolean isInView() { return inRange(getInViewX(), 0, getViewWidth()) && inRange(getInViewY(), 0, getViewHeight()); } private boolean inRange(double val, int min, double max) { return val >= min && val < max; } /** * Gets the direction in which this point is outside of the given view rectangle. * @param rect The rectangle to check agains. * @return The direction in which it is outside of the view, as OUTSIDE_... flags. * @since 10827 */ public int getOutsideRectangleFlags(MapViewRectangle rect) { Rectangle2D bounds = rect.getInView(); int flags = 0; if (getInViewX() < bounds.getMinX()) { flags |= OUTSIDE_LEFT; } else if (getInViewX() > bounds.getMaxX()) { flags |= OUTSIDE_RIGHT; } if (getInViewY() < bounds.getMinY()) { flags |= OUTSIDE_TOP; } else if (getInViewY() > bounds.getMaxY()) { flags |= OUTSIDE_BOTTOM; } return flags; } /** * Gets the sum of the x/y view distances between the points. |x1 - x2| + |y1 - y2| * @param p2 The other point * @return The norm * @since 10827 */ public double oneNormInView(MapViewPoint p2) { return Math.abs(getInViewX() - p2.getInViewX()) + Math.abs(getInViewY() - p2.getInViewY()); } /** * Gets the squared distance between this point and an other point. * @param p2 The other point * @return The squared distance. * @since 10827 */ public double distanceToInViewSq(MapViewPoint p2) { double dx = getInViewX() - p2.getInViewX(); double dy = getInViewY() - p2.getInViewY(); return dx * dx + dy * dy; } /** * Gets the distance between this point and an other point. * @param p2 The other point * @return The distance. * @since 10827 */ public double distanceToInView(MapViewPoint p2) { return Math.sqrt(distanceToInViewSq(p2)); } /** * Do a linear interpolation to the other point * @param p1 The other point * @param i The interpolation factor. 0 is at the current point, 1 at the other point. * @return The new point * @since 10874 */ public MapViewPoint interpolate(MapViewPoint p1, double i) { return new MapViewViewPoint((1 - i) * getInViewX() + i * p1.getInViewX(), (1 - i) * getInViewY() + i * p1.getInViewY()); } } private class MapViewViewPoint extends MapViewPoint { private final double x; private final double y; MapViewViewPoint(double x, double y) { this.x = x; this.y = y; } @Override public double getInViewX() { return x; } @Override public double getInViewY() { return y; } @Override public String toString() { return "MapViewViewPoint [x=" + x + ", y=" + y + ']'; } } private class MapViewEastNorthPoint extends MapViewPoint { private final EastNorth eastNorth; MapViewEastNorthPoint(EastNorth eastNorth) { this.eastNorth = Objects.requireNonNull(eastNorth, "eastNorth"); } @Override public double getInViewX() { return (eastNorth.east() - topLeft.east()) / scale; } @Override public double getInViewY() { return (topLeft.north() - eastNorth.north()) / scale; } @Override public EastNorth getEastNorth() { return eastNorth; } @Override public String toString() { return "MapViewEastNorthPoint [eastNorth=" + eastNorth + ']'; } } /** * A rectangle on the MapView. It is rectangular in screen / EastNorth space. * @author Michael Zangl */ public class MapViewRectangle { private final MapViewPoint p1; private final MapViewPoint p2; /** * Create a new MapViewRectangle * @param p1 The first point to use * @param p2 The second point to use. */ MapViewRectangle(MapViewPoint p1, MapViewPoint p2) { this.p1 = p1; this.p2 = p2; } /** * Gets the projection bounds for this rectangle. * @return The projection bounds. */ public ProjectionBounds getProjectionBounds() { ProjectionBounds b = new ProjectionBounds(p1.getEastNorth()); b.extend(p2.getEastNorth()); return b; } /** * Gets a rough estimate of the bounds by assuming lat/lon are parallel to x/y. * @return The bounds computed by converting the corners of this rectangle. * @see #getLatLonBoundsBox() */ public Bounds getCornerBounds() { Bounds b = new Bounds(p1.getLatLon()); b.extend(p2.getLatLon()); return b; } /** * Gets the real bounds that enclose this rectangle. * This is computed respecting that the borders of this rectangle may not be a straignt line in latlon coordinates. * @return The bounds. * @since 10458 */ public Bounds getLatLonBoundsBox() { // TODO @michael2402: Use hillclimb. return projecting.getBaseProjection().getLatLonBoundsBox(getProjectionBounds()); } /** * Gets this rectangle on the screen. * @return The rectangle. * @since 10651 */ public Rectangle2D getInView() { double x1 = p1.getInViewX(); double y1 = p1.getInViewY(); double x2 = p2.getInViewX(); double y2 = p2.getInViewY(); return new Rectangle2D.Double(Math.min(x1, x2), Math.min(y1, y2), Math.abs(x1 - x2), Math.abs(y1 - y2)); } /** * Check if the rectangle intersects the map view area. * @return <code>true</code> if it intersects. * @since 10827 */ public boolean isInView() { return getInView().intersects(getViewArea().getInView()); } /** * Gets the entry point at which a line between start and end enters the current view. * @param start The start * @param end The end * @return The entry point or <code>null</code> if the line does not intersect this view. */ public MapViewPoint getLineEntry(MapViewPoint start, MapViewPoint end) { ProjectionBounds bounds = getProjectionBounds(); if (bounds.contains(start.getEastNorth())) { return start; } double dx = end.getEastNorth().east() - start.getEastNorth().east(); double boundX = dx > 0 ? bounds.minEast : bounds.maxEast; EastNorth borderIntersection = Geometry.getSegmentSegmentIntersection(start.getEastNorth(), end.getEastNorth(), new EastNorth(boundX, bounds.minNorth), new EastNorth(boundX, bounds.maxNorth)); if (borderIntersection != null) { return getPointFor(borderIntersection); } double dy = end.getEastNorth().north() - start.getEastNorth().north(); double boundY = dy > 0 ? bounds.minNorth : bounds.maxNorth; borderIntersection = Geometry.getSegmentSegmentIntersection(start.getEastNorth(), end.getEastNorth(), new EastNorth(bounds.minEast, boundY), new EastNorth(bounds.maxEast, boundY)); if (borderIntersection != null) { return getPointFor(borderIntersection); } return null; } } }