/*
* GeoTools - The Open Source Java GIS Toolkit
* http://geotools.org
*
* (C) 2010-2011, Open Source Geospatial Foundation (OSGeo)
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation;
* version 2.1 of the License.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*/
package org.geotools.map;
import java.awt.Rectangle;
import java.awt.geom.AffineTransform;
import java.awt.geom.NoninvertibleTransformException;
import java.awt.geom.Point2D;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.geotools.geometry.jts.ReferencedEnvelope;
import org.geotools.map.event.MapBoundsEvent;
import org.geotools.map.event.MapBoundsListener;
import org.geotools.map.event.MapBoundsEvent.Type;
import org.geotools.referencing.CRS;
import org.geotools.util.logging.Logging;
import org.opengis.referencing.crs.CoordinateReferenceSystem;
/**
* Represents the area of a map to be displayed, expressed in world coordinates and (optionally)
* screen (window, image) coordinates. A viewport is used to stage information for map rendering.
* While the viewport provides support for bounds and coordinate reference system out of the box
* it is expected that the user data support in {@code MapContent} will be used to record
* additional information such as elevation and time as required for rendering.
* <p>
* When both world and screen bounds are defined, the viewport calculates {@code AffineTransforms}
* to convert the coordinates of one bounds to those of the other. It can also optionally adjust
* the world bounds to maintain an identical aspect ratio with the screen bounds. Note however
* that aspect ratio adjustment should not be enabled when the viewport is used with a service
* such as WMS which mandates that specified screen and world bounds must be honoured exactly,
* regardless of the resulting aspect ratio differences.
* <p>
* The {@code AffineTransforms} can be retrieved with the methods
* {@linkplain #getScreenToWorld()} and {@linkplain #getWorldToScreen()}.
* The following rules apply to the return values of these methods:
* <ul>
* <li>
* If screen area is not defined, {@code null} is returned.
* </li>
* <li>
* If screen area only is defined, the identity transform is returned.
* </li>
* <li>
* If both screen area and world extent are defined, calculated transforms are returned.
* </li>
* </ul>
*
* @author Jody Garnett
* @author Michael Bedward
* @since 2.7
* @source $URL$
*/
public class MapViewport {
/** The logger for the map module. */
static protected final Logger LOGGER = Logging.getLogger("org.geotools.map");
/*
* Flags whether this viewport can be changed
*/
private final AtomicBoolean editable;
/*
* The current display area expressed in window coordinates
* (e.g. the visible rectangle of a JMapPane). The area can
* include slack space beyond the edges of the map layers.
*/
private Rectangle screenArea;
/*
* The current dispay area in world coordinates. The area can
* include slack space beyond the edges of the map layers.
*/
private ReferencedEnvelope bounds;
/*
* Transform to convert screen (window, image) coordinates to corresponding
* world coordinates.
*/
private AffineTransform screenToWorld;
/*
* Transform to convert world coordinates to corresponding screen (window,
* image) coordinates.
*/
private AffineTransform worldToScreen;
private CopyOnWriteArrayList<MapBoundsListener> boundsListeners;
private boolean matchingAspectRatio;
private boolean hasCenteringTransforms;
private final ReadWriteLock lock = new ReentrantReadWriteLock();
/**
* Creates a new view port. Screen area and world bounds will be empty,
* and aspect ratio matching will be disabled.
*/
public MapViewport() {
this(false);
}
/**
* Creates a new view port. Screen area and world bounds will be empty.
*
* @param matchAspectRatio whether to enable aspect ratio matching
*/
public MapViewport(boolean matchAspectRatio) {
this(null, matchAspectRatio);
}
/**
* Creates a new view port with the specified display area in world coordinates.
* The input envelope is copied so subsequent changes to it will not affect the
* viewport.
* <p>
* The initial screen area will be empty and aspect ratio matching will be
* disabled.
*
* @param bounds display area in world coordinates (may be {@code null})
*/
public MapViewport(ReferencedEnvelope bounds) {
this(bounds, false);
}
/**
* Creates a new viewport with the specified world bounds.
* The input envelope is copied so subsequent changes to it will not affect the
* viewport.
* <p>
* The initial screen area will be empty.
*
* @param bounds display area in world coordinates (may be {@code null})
* @param matchAspectRatio whether to enable aspect ratio matching
*/
public MapViewport(ReferencedEnvelope bounds, boolean matchAspectRatio) {
this.editable = new AtomicBoolean(true);
this.screenArea = new Rectangle();
this.hasCenteringTransforms = false;
this.matchingAspectRatio = matchAspectRatio;
copyBounds(bounds);
setTransforms(true);
}
/**
* Creates a new viewport based on an existing instance. The world bounds,
* screen area and aspect ratio matching setting of {@code sourceViewport} are
* copied.
* <p>
* <strong>Note:</strong> The new viewport will be editable even if
* {@code sourceViewport} is not editable.
*
* @param sourceViewport the viewport to copy
*
* @throws IllegalArgumentException if {@code viewport} is {@code null}
*/
public MapViewport(MapViewport sourceViewport) {
this.editable = new AtomicBoolean(true);
this.matchingAspectRatio = sourceViewport.matchingAspectRatio;
copyBounds(sourceViewport.bounds);
doSetScreenArea(sourceViewport.screenArea);
setTransforms(true);
}
/**
* Tests whether this viewport's attributes can be changed. Viewports are
* editable by default. A non-editable viewport will not allow the value
* of any of its attributes to be changed and will issue a log message
* (fine level) on any attempt to do so.
*
* @return {@code true} if this viewport is editable
*/
public boolean isEditable() {
return editable.get();
}
/**
* Sets whether the value of this viewport's attributes can be changed.
* Viewports are editable by default.
*
* @param editable {@code true} to allow changes
*/
public void setEditable(boolean editable) {
this.editable.set(editable);
}
/**
* Sets whether to adjust input world bounds to match the aspect
* ratio of the screen area.
*
* @param enabled whether to enable aspect ratio adjustment
*/
public void setMatchingAspectRatio(boolean enabled) {
lock.writeLock().lock();
try {
if (checkEditable("setMatchingAspectRatio")) {
if (enabled != matchingAspectRatio) {
matchingAspectRatio = enabled;
setTransforms(true);
}
}
} finally {
lock.writeLock().unlock();
}
}
/**
* Queries whether input worlds bounds will be adjusted to match the
* aspect ratio of the screen area.
*
* @return {@code true} if enabled
*/
public boolean isMatchingAspectRatio() {
lock.readLock().lock();
try {
return matchingAspectRatio;
} finally {
lock.readLock().unlock();
}
}
/**
* Used by client application to track the bounds of this viewport.
*
* @param listener
*/
public void addMapBoundsListener(MapBoundsListener listener) {
lock.writeLock().lock();
try {
if (boundsListeners == null) {
boundsListeners = new CopyOnWriteArrayList<MapBoundsListener>();
}
if (!boundsListeners.contains(listener)) {
boundsListeners.add(listener);
}
} finally {
lock.writeLock().unlock();
}
}
public void removeMapBoundsListener(MapBoundsListener listener) {
if (boundsListeners != null) {
boundsListeners.remove(listener);
}
}
/**
* Checks if the view port bounds are empty (undefined). This will be
* {@code true} if either or both of the world bounds and screen bounds
* are empty.
*
* @return {@code true} if empty
*/
public boolean isEmpty() {
lock.readLock().lock();
try {
return screenArea.isEmpty() || bounds.isEmpty();
} finally {
lock.readLock().unlock();
}
}
/**
* Gets the display area in world coordinates.
* <p>
* Note Well: this only covers spatial extent; you may wish to use the user data map
* to record the current viewport time or elevation.
*
* @return a copy of the current bounds
*/
public ReferencedEnvelope getBounds() {
lock.readLock().lock();
try {
return new ReferencedEnvelope(bounds);
} finally {
lock.readLock().unlock();
}
}
/**
* Sets the display area in world coordinates.
* <p>
* If {@code bounds} is {@code null} or empty, default identity coordinate
* transforms will be set.
* <p>
* If {@code bounds} is not empty, and aspect ratio matching is enabled,
* the coordinate transforms will be calculated to centre the requested bounds
* in the current screen area (if defined), after which the world bounds will
* be adjusted (enlarged) as required to match the screen area's aspect ratio.
* <p>
* A {@code MapBoundsEvent} will be fired to inform listeners of the change from
* old to new bounds. Note that when aspect ratio matching is enabled, the new
* bounds carried by the event will be the viewport's adjusted bounds, not the
* originally requested bounds.
*
* @param requestedBounds the requested bounds (may be {@code null})
*/
public void setBounds(ReferencedEnvelope requestedBounds) {
lock.writeLock().lock();
try {
if (checkEditable("setBounds")) {
ReferencedEnvelope old = bounds;
copyBounds(requestedBounds);
setTransforms(true);
fireMapBoundsListenerMapBoundsChanged(Type.BOUNDS, old, bounds);
}
} finally {
lock.writeLock().unlock();
}
}
private void copyBounds(ReferencedEnvelope newBounds) {
if (newBounds == null || newBounds.isEmpty()) {
this.bounds = new ReferencedEnvelope();
} else {
this.bounds = new ReferencedEnvelope(newBounds);
}
}
/**
* Gets a copy of the current screen area.
*
* @return screen area to render into when drawing.
*/
public Rectangle getScreenArea() {
lock.readLock().lock();
try {
return new Rectangle(screenArea);
} finally {
lock.readLock().unlock();
}
}
/**
* Sets the display area in screen (window, image) coordinates.
*
* @param screenArea display area in screen coordinates (may be {@code null})
*/
public void setScreenArea(Rectangle screenArea) {
lock.writeLock().lock();
try {
if (checkEditable("setScreenArea")) {
doSetScreenArea(screenArea);
}
} finally {
lock.writeLock().unlock();
}
}
private void doSetScreenArea(Rectangle screenArea) {
if (screenArea == null || screenArea.isEmpty()) {
this.screenArea = new Rectangle();
} else {
this.screenArea = new Rectangle(screenArea);
}
setTransforms(false);
}
/**
* The coordinate reference system used for rendering the map. If not yet
* set, {@code null} is returned.
* <p>
* The coordinate reference system used for rendering is often considered to be the "world"
* coordinate reference system; this is distinct from the coordinate reference system used for
* each layer (which is often data dependent).
* </p>
*
* @return coordinate reference system used for rendering the map (may be {@code null}).
*/
public CoordinateReferenceSystem getCoordinateReferenceSystem() {
lock.readLock().lock();
try {
return bounds.getCoordinateReferenceSystem();
} finally {
lock.readLock().unlock();
}
}
/**
* Set the {@code CoordinateReferenceSystem} for the viewport. If {@code crs}
* is null, the existing reference system will be discarded.
*
* @param crs the new coordinate reference system, or {@code null} for no reference system
*/
public void setCoordinateReferenceSystem(CoordinateReferenceSystem crs) {
lock.writeLock().lock();
try {
if (checkEditable("setCoordinateReferenceSystem")) {
if (crs == null) {
bounds = new ReferencedEnvelope(bounds, null);
} else if (!CRS.equalsIgnoreMetadata(crs, bounds.getCoordinateReferenceSystem())) {
if (bounds.isEmpty()) {
bounds = new ReferencedEnvelope(crs);
} else {
try {
ReferencedEnvelope old = bounds;
bounds = bounds.transform(crs, true);
setTransforms(true);
fireMapBoundsListenerMapBoundsChanged(MapBoundsEvent.Type.CRS, old, bounds);
} catch (Exception e) {
LOGGER.log(Level.FINE, "Difficulty transforming to {0}", crs);
}
}
}
}
} finally {
lock.writeLock().unlock();
}
}
/**
* Notifies MapBoundsListeners about a change to the bounds or crs.
*
* @param event
* The event to be fired
*/
protected void fireMapBoundsListenerMapBoundsChanged(Type type, ReferencedEnvelope oldBounds,
ReferencedEnvelope newBounds) {
if (boundsListeners == null) {
return;
}
if (newBounds == bounds) {
// issue a copy to the boundsListeners for safety
newBounds = new ReferencedEnvelope(bounds);
}
MapBoundsEvent event = new MapBoundsEvent(this, type, oldBounds, newBounds);
for (MapBoundsListener boundsListener : boundsListeners) {
try {
boundsListener.mapBoundsChanged(event);
} catch (Throwable t) {
if (LOGGER.isLoggable(Level.FINER)) {
LOGGER.logp(Level.FINE, boundsListener.getClass().getName(),
"mapBoundsChanged", t.getLocalizedMessage(), t);
}
}
}
}
/**
* Gets the current screen to world coordinate transform.
*
* @return a copy of the current screen to world transform or
* {@code null} if the transform is not set
*/
public AffineTransform getScreenToWorld() {
lock.readLock().lock();
try {
return screenToWorld == null ? null : new AffineTransform(screenToWorld);
} finally {
lock.readLock().unlock();
}
}
/**
* Gets the current world to screen coordinate transform.
*
* @return a copy of the current world to screen transform or
* {@code null} if the transform is not set
*/
public AffineTransform getWorldToScreen() {
lock.readLock().lock();
try {
return worldToScreen == null ? null : new AffineTransform(worldToScreen);
} finally {
lock.readLock().unlock();
}
}
/**
* Sets the affine transforms used to convert between screen
* and world coordinates.
* <p>
* If screen area is undefined, the transforms are set to {@code null}.
* <p>
* If screen area is defined but not world bounds, the transforms are set
* to identity.
* <p>
* When both screen area and world bounds are defined, the transforms are
* set as follows. If aspect ratio matching is enabled, the transforms
* transforms are calculated to centre the world bounds in the
* screen area, after which the bounds will be adjusted if necessary to have
* the same aspect ratio as the screen area. If aspect ratio matching is not
* enabled, basic transforms are calculated without centering or bounds
* adjustment.
*
* @param newBounds indicates whether world bounds have just been changed
*/
private void setTransforms(boolean newBounds) {
if (screenArea.isEmpty()) {
screenToWorld = worldToScreen = null;
hasCenteringTransforms = false;
} else if (bounds.isEmpty()) {
screenToWorld = new AffineTransform();
worldToScreen = new AffineTransform();
hasCenteringTransforms = false;
} else if (matchingAspectRatio) {
if (newBounds || !hasCenteringTransforms) {
calculateCenteringTransforms();
}
bounds = calculateActualBounds();
} else {
calculateSimpleTransforms(bounds);
hasCenteringTransforms = false;
}
}
/**
* Calculates transforms suitable for aspect ratio matching. The
* world bounds will be centred in the screen area.
*/
private void calculateCenteringTransforms() {
double xscale = screenArea.getWidth() / bounds.getWidth();
double yscale = screenArea.getHeight() / bounds.getHeight();
double scale = Math.min(xscale, yscale);
double xoff = bounds.getMedian(0) * scale - screenArea.getCenterX();
double yoff = bounds.getMedian(1) * scale + screenArea.getCenterY();
worldToScreen = new AffineTransform(scale, 0, 0, -scale, -xoff, yoff);
try {
screenToWorld = worldToScreen.createInverse();
} catch (NoninvertibleTransformException ex) {
throw new RuntimeException("Unable to create coordinate transforms.", ex);
}
hasCenteringTransforms = true;
}
/**
* Calculates transforms suitable for no aspect ratio matching.
*
* @param requestedBounds requested display area in world coordinates
*/
private void calculateSimpleTransforms(ReferencedEnvelope requestedBounds) {
double xscale = screenArea.getWidth() / requestedBounds.getWidth();
double yscale = screenArea.getHeight() / requestedBounds.getHeight();
double scale = Math.min(xscale, yscale);
worldToScreen = new AffineTransform(scale, 0, 0, -scale,
-requestedBounds.getMinX(), requestedBounds.getMaxY());
try {
screenToWorld = worldToScreen.createInverse();
} catch (NoninvertibleTransformException ex) {
throw new RuntimeException("Unable to create coordinate transforms.", ex);
}
}
/**
* Calculates the world bounds of the current screen area.
*/
private ReferencedEnvelope calculateActualBounds() {
Point2D p0 = new Point2D.Double(screenArea.getMinX(), screenArea.getMinY());
Point2D p1 = new Point2D.Double(screenArea.getMaxX(), screenArea.getMaxY());
screenToWorld.transform(p0, p0);
screenToWorld.transform(p1, p1);
return new ReferencedEnvelope(
Math.min(p0.getX(), p1.getX()),
Math.max(p0.getX(), p1.getX()),
Math.min(p0.getY(), p1.getY()),
Math.max(p0.getY(), p1.getY()),
bounds.getCoordinateReferenceSystem());
}
/**
* Helper for setter methods which checkst that this viewport
* is editable and issues a log message if not.
*/
private boolean checkEditable(String methodName) {
final boolean state = editable.get();
if (!state) {
if (LOGGER.isLoggable(Level.FINE)) {
LOGGER.log(Level.FINE, "Ignored call to {0} because viewport is not editable",
methodName);
}
}
return state;
}
}