/*
* 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.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.DefaultGeographicCRS;
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.
*
* @author Jody Garnett
* @author Michael Bedward
* @since 2.7
*
* @source $URL: http://svn.osgeo.org/geotools/trunk/modules/library/render/src/main/java/org/geotools/map/MapViewport.java $
*/
public class MapViewport {
/** The logger for the map module. */
static protected final Logger LOGGER = Logging.getLogger("org.geotools.map");
/*
* 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;
/**
* Creates a new view port. The viewport bounds, in both screen and world coordinates,
* will be empty rectangles, a default coordinate reference system (WGS84) will
* be set, and aspect ratio matching will not be enabled.
*/
public MapViewport(){
this(null);
}
/**
* Creates a new view port with aspect ratio matching enabled or disabled according
* to {@code matchAspectRatio}. The viewport bounds, in both screen and world coordinates,
* will be empty rectangles and a default coordinate reference system (WGS84) will
* be set.
*
* @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 not
* be enabled.
*
* @param bounds display area in world coordinates (may be {@code null})
*/
public MapViewport(ReferencedEnvelope bounds){
this(bounds, false);
}
/**
* 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 enabled
* or disabled according to {@code matchAspectRatio}.
*
* @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.screenArea = new Rectangle();
this.matchingAspectRatio = matchAspectRatio;
if (bounds == null || bounds.isEmpty()) {
setEmptyBounds();
} else {
// At this point we just store the bounds, copying them defensively.
this.bounds = new ReferencedEnvelope(bounds);
}
}
/**
* 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) {
if (enabled != matchingAspectRatio) {
matchingAspectRatio = enabled;
doSetBounds(bounds);
}
}
/**
* 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() {
return matchingAspectRatio;
}
/**
* Used by client application to track the bounds of this viewport.
*
* @param listener
*/
public void addMapBoundsListener(MapBoundsListener listener) {
if (boundsListeners == null) {
synchronized ( this ){
boundsListeners = new CopyOnWriteArrayList<MapBoundsListener>();
}
}
if (!boundsListeners.contains(listener)) {
boundsListeners.add(listener);
}
}
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() {
return screenArea.isEmpty() || bounds.isEmpty();
}
/**
* 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() {
return new ReferencedEnvelope(bounds);
}
/**
* Sets the display area in world coordinates.
* <p>
* If {@code bounds} is {@code null} or empty, default identity coordinate
* transforms will be set. The viewport's existing coordinate reference system
* will be preserved.
* <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.
*
* @param requestedBounds the requested bounds (may be {@code null})
*/
public void setBounds(ReferencedEnvelope requestedBounds) {
ReferencedEnvelope old = this.bounds;
if (requestedBounds == null || requestedBounds.isEmpty()) {
this.bounds = new ReferencedEnvelope(this.bounds.getCoordinateReferenceSystem());
setDefaultTransforms();
} else {
doSetBounds(requestedBounds);
}
// Note the bounds communicated by the event are the actual world bounds
// rather than the user-requested bounds (unless empty)
fireMapBoundsListenerMapBoundsChanged(Type.BOUNDS, old, this.bounds);
}
/**
* Screen area to render into when drawing.
* @return screen area to render into when drawing.
*/
public Rectangle getScreenArea() {
return screenArea;
}
/**
* 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) {
if (screenArea == null || screenArea.isEmpty()) {
this.screenArea = new Rectangle();
setDefaultTransforms();
} else {
boolean wasEmpty = this.screenArea.isEmpty();
// defensive copy
this.screenArea = new Rectangle(screenArea);
if (wasEmpty) {
doSetBounds(bounds);
} else if (!bounds.isEmpty()) {
bounds = calculateActualBounds();
}
}
}
/**
* The coordinate reference system used for rendering the map.
* <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.
*/
public CoordinateReferenceSystem getCoordianteReferenceSystem() {
return bounds == null ? null : bounds.getCoordinateReferenceSystem();
}
/**
* Set the <code>CoordinateReferenceSystem</code> for this map's internal viewport.
*
* @param crs
* @throws FactoryException
* @throws TransformException
*/
public void setCoordinateReferenceSystem(CoordinateReferenceSystem crs) {
if (bounds.getCoordinateReferenceSystem() != crs) {
if (bounds.isEmpty()) {
bounds = new ReferencedEnvelope(crs);
} else {
try {
ReferencedEnvelope old = bounds;
bounds = bounds.transform(crs, true);
fireMapBoundsListenerMapBoundsChanged(MapBoundsEvent.Type.CRS, old, bounds);
} catch (Exception e) {
LOGGER.log(Level.FINE, "Difficulty transforming to {0}", crs);
}
}
}
}
/**
* 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. If
* the display area is empty the identity transform is returned.
*
* @return a copy of the current screen to world transform
*/
public AffineTransform getScreenToWorld() {
return new AffineTransform(screenToWorld);
}
/**
* Gets the current world to screen coordinate transform. If
* the display area is empty the identity transform is returned.
*
* @return a copy of the current world to screen transform
*/
public AffineTransform getWorldToScreen() {
return new AffineTransform(worldToScreen);
}
/**
* Sets the screen and world bounds to empty rectangles and the
* coordinate reference system to WGS84.
*/
private void setEmptyBounds() {
bounds = new ReferencedEnvelope(DefaultGeographicCRS.WGS84);
screenArea = new Rectangle();
setDefaultTransforms();
}
/**
* Sets the transforms to the default identity transforms.
*/
private void setDefaultTransforms() {
screenToWorld = new AffineTransform();
worldToScreen = new AffineTransform();
}
/**
* Calculates the affine transforms used to convert between screen
* and world coordinates. If aspect ratio matching is enabled, the
* transforms will be calculated to centre the requested 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, no such centering and adjustment happen, and the resulting world
* bounds will be equal to the requested bounds.
*
* @param requestedBounds requested display area in world coordinates
*/
private void doSetBounds(ReferencedEnvelope requestedBounds) {
if (matchingAspectRatio && !screenArea.isEmpty()) {
calculateCenteringTransforms(requestedBounds);
bounds = calculateActualBounds();
} else {
calculateSimpleTransforms(requestedBounds);
bounds = new ReferencedEnvelope(requestedBounds);
}
}
/**
* Calculates transforms suitable for aspect ratio matching. The requested
* world bounds will be centred in the screen area.
*
* @param requestedBounds requested display area in world coordinates
*/
private void calculateCenteringTransforms(ReferencedEnvelope requestedBounds) {
if (!( requestedBounds.isEmpty() || screenArea.isEmpty() )) {
double xscale = screenArea.getWidth() / requestedBounds.getWidth();
double yscale = screenArea.getHeight() / requestedBounds.getHeight();
double scale = Math.min(xscale, yscale);
double xoff = requestedBounds.getMedian(0) * scale - screenArea.getCenterX();
double yoff = requestedBounds.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);
}
}
}
/**
* Calculates transforms suitable for no aspect ratio matching.
*
* @param requestedBounds requested display area in world coordinates
*/
private void calculateSimpleTransforms(ReferencedEnvelope requestedBounds) {
if (!( requestedBounds.isEmpty() || screenArea.isEmpty() )) {
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() {
if (screenArea.isEmpty()) {
throw new IllegalStateException("Screen area is empty");
} else {
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());
}
}
/**
* @todo MB: Not sure if this method should be used.
*
* @param transform
*/
public void transform(AffineTransform transform) {
ReferencedEnvelope old = this.bounds;
double[] coords = new double[4];
coords[0] = bounds.getMinX();
coords[1] = bounds.getMinY();
coords[2] = bounds.getMaxX();
coords[3] = bounds.getMaxY();
transform.transform(coords, 0, coords, 0, 2);
this.bounds = new ReferencedEnvelope(coords[0], coords[2], coords[1], coords[3], bounds
.getCoordinateReferenceSystem());
fireMapBoundsListenerMapBoundsChanged(MapBoundsEvent.Type.BOUNDS, old, bounds);
}
}