/*
* This is part of Geomajas, a GIS framework, http://www.geomajas.org/.
*
* Copyright 2008-2011 Geosparc nv, http://www.geosparc.com/, Belgium.
*
* The program is available in open source according to the GNU Affero
* General Public License. All contributions in this program are covered
* by the Geomajas Contributors License Agreement. For full licensing
* details, see LICENSE.txt in the project root.
*/
package org.geomajas.gwt.client.map;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.geomajas.geometry.Coordinate;
import org.geomajas.annotation.Api;
import org.geomajas.gwt.client.map.event.MapViewChangedEvent;
import org.geomajas.gwt.client.map.event.MapViewChangedHandler;
import org.geomajas.gwt.client.spatial.Bbox;
import org.geomajas.gwt.client.spatial.Matrix;
import org.geomajas.gwt.client.spatial.WorldViewTransformer;
import com.google.gwt.event.shared.HandlerManager;
import com.google.gwt.event.shared.HandlerRegistration;
/**
* <p>
* This class represents the viewing controller behind a <code>MapWidget</code>. It knows the map's width, height, but
* it also controls what is visible on the map through a <code>Camera</code> object. This camera hangs over the map at a
* certain height (represented by the scale), and together with the width and height, this MapView can determine the
* boundaries of the visible area on the map.
* </p>
* <p>
* But it's more then that. This MapView can also calculate necessary transformation matrices to go from world to view
* space an back. It can also snap the scale-levels to fixed resolutions (in case these are actually defined).
* </p>
*
* @author Pieter De Graef
* @author Oliver May
* @since 1.6.0
*/
@Api
public class MapView {
private static final double MAX_RESOLUTION = Float.MAX_VALUE;
/** Zoom options. */
public enum ZoomOption {
/** Zoom exactly to the new scale. */
EXACT,
/**
* Zoom to a scale level that is different from the current (lower or higher according to the new scale, only if
* allowed of course).
*/
LEVEL_CHANGE,
/** Zoom to a scale level that is as close as possible to the new scale. */
LEVEL_CLOSEST,
/** Zoom to a scale level that makes the bounds fit inside our view. */
LEVEL_FIT
}
/** The map's width in pixels. */
private int width;
/** The map's height in pixels. */
private int height;
/** A maximum scale level, that this MapView is not allowed to cross. */
private double maximumScale = 10;
/** The maximum bounding box available to this MapView. Never go outside it! */
private Bbox maxBounds;
/**
* A series of scale levels to which zooming in and out should snap. This is optional! If you which to use these
* fixed zooming steps, all you have to do, is define them.
*/
private List<Double> resolutions = new ArrayList<Double>();
/**
* The current index in the resolutions array. That is, if the resolutions are actually used.
*/
private int resolutionIndex = -1;
/**
* The current view state.
*/
private MapViewState viewState = new MapViewState();
/**
* The previous view state.
*/
private MapViewState lastViewState;
private HandlerManager handlerManager;
private WorldViewTransformer worldViewTransformer;
// -------------------------------------------------------------------------
// Constructors:
// -------------------------------------------------------------------------
/** Default constructor that initializes all it's fields. */
public MapView() {
handlerManager = new HandlerManager(this);
}
/**
* Adds this handler to the view.
*
* @param handler
* the handler
* @return {@link com.google.gwt.event.shared.HandlerRegistration} used to remove the handler
*/
public final HandlerRegistration addMapViewChangedHandler(final MapViewChangedHandler handler) {
return handlerManager.addHandler(MapViewChangedEvent.getType(), handler);
}
// -------------------------------------------------------------------------
// Retrieval of transformation matrices:
// -------------------------------------------------------------------------
/** Return the world-to-view space transformation matrix. */
public Matrix getWorldToViewTransformation() {
if (viewState.getScale() > 0) {
double dX = -(viewState.getX() * viewState.getScale()) + width / 2;
double dY = viewState.getY() * viewState.getScale() + height / 2;
return new Matrix(viewState.getScale(), 0, 0, -viewState.getScale(), dX, dY);
}
return new Matrix(1, 0, 0, 1, 0, 0);
}
/** Return the world-to-view space translation matrix. */
public Matrix getWorldToViewTranslation() {
if (viewState.getScale() > 0) {
double dX = -(viewState.getX() * viewState.getScale()) + width / 2;
double dY = viewState.getY() * viewState.getScale() + height / 2;
return new Matrix(1, 0, 0, 1, dX, dY);
}
return new Matrix(1, 0, 0, 1, 0, 0);
}
/** Return the world-to-pan space translation matrix. */
public Matrix getWorldToPanTransformation() {
if (viewState.getScale() > 0) {
double dX = -(viewState.getPanX() * viewState.getScale());
double dY = viewState.getPanY() * viewState.getScale();
return new Matrix(viewState.getScale(), 0, 0, -viewState.getScale(), dX, dY);
}
return new Matrix(1, 0, 0, 1, 0, 0);
}
/**
* Return the translation of coordinates relative to the pan origin to view coordinates.
*/
public Matrix getPanToViewTranslation() {
if (viewState.getScale() > 0) {
double dX = -((viewState.getX() - viewState.getPanX()) * viewState.getScale()) + width / 2;
double dY = (viewState.getY() - viewState.getPanY()) * viewState.getScale() + height / 2;
return new Matrix(1, 0, 0, 1, dX, dY);
}
return new Matrix(1, 0, 0, 1, 0, 0);
}
/**
* Return the translation of scaled world coordinates to coordinates relative to the pan origin.
*/
public Matrix getWorldToPanTranslation() {
if (viewState.getScale() > 0) {
double dX = -(viewState.getPanX() * viewState.getScale());
double dY = viewState.getPanY() * viewState.getScale();
return new Matrix(1, 0, 0, 1, dX, dY);
}
return new Matrix(1, 0, 0, 1, 0, 0);
}
/** Return the world-to-view space translation matrix. */
public Matrix getWorldToViewScaling() {
if (viewState.getScale() > 0) {
return new Matrix(viewState.getScale(), 0, 0, -viewState.getScale(), 0, 0);
}
return new Matrix(1, 0, 0, 1, 0, 0);
}
// -------------------------------------------------------------------------
// Functions that manipulate or retrieve what is visible on the map:
// -------------------------------------------------------------------------
/**
* Re-centers the map to a new position.
*
* @param coordinate
* the new center position
*/
public void setCenterPosition(Coordinate coordinate) {
saveState();
doSetOrigin(coordinate);
fireEvent(false, null);
}
/**
* Apply a new scale level on the map. In case the are fixed resolutions defined on this MapView, it will
* automatically snap to the nearest resolution. In case the maximum extents are exceeded, it will pan to avoid
* this.
*
* @param newScale
* The preferred new scale.
* @param option
* zoom option, {@link org.geomajas.gwt.client.map.MapView.ZoomOption}
*/
public void setCurrentScale(final double newScale, final ZoomOption option) {
setCurrentScale(newScale, option, new Coordinate(viewState.getX(), viewState.getY()));
}
/**
* Apply a new scale level on the map. In case the are fixed resolutions defined on this MapView, it will
* automatically snap to the nearest resolution. In case the maximum extents are exceeded, it will pan to avoid
* this.
*
* @param newScale
* The preferred new scale.
* @param option
* zoom option, {@link org.geomajas.gwt.client.map.MapView.ZoomOption}
* @param rescalePoint
* After zooming, this point will still be on the same position in the view as before.
*/
public void setCurrentScale(final double newScale, final ZoomOption option, final Coordinate rescalePoint) {
saveState();
// calculate theoretical new bounds
Bbox newBbox = new Bbox(0, 0, getWidth() / newScale, getHeight() / newScale);
double factor = newScale / getCurrentScale();
// Calculate translate vector to assure rescalePoint is on the same
// position as before.
double dX = (rescalePoint.getX() - viewState.getX()) * (1 - 1 / factor);
double dY = (rescalePoint.getY() - viewState.getY()) * (1 - 1 / factor);
newBbox.setCenterPoint(new Coordinate(viewState.getX(), viewState.getY()));
newBbox.translate(dX, dY);
// and apply...
doApplyBounds(newBbox, option);
}
/**
* <p>
* Change the view on the map by applying a bounding box (world coordinates!). Since the width/height ratio of the
* bounding box may differ from that of the map, the fit is "as good as possible".
* </p>
* <p>
* Also this function will almost certainly change the scale on the map, so if there have been resolutions defined,
* it will snap to them.
* </p>
*
* @param bounds
* A bounding box in world coordinates that determines the view from now on.
* @param option
* zoom option, {@link org.geomajas.gwt.client.map.MapView.ZoomOption}
*/
public void applyBounds(final Bbox bounds, final ZoomOption option) {
saveState();
doApplyBounds(bounds, option);
}
/**
* Set the size of the map in pixels.
*
* @param newWidth
* The map's width.
* @param newHeight
* The map's height.
*/
public void setSize(int newWidth, int newHeight) {
saveState();
Bbox oldbbox = getBounds();
this.width = newWidth;
this.height = newHeight;
if (viewState.getScale() < getMinimumScale()) {
// The new scale is too low, re-apply old values:
double scale = getBestScale(oldbbox);
doSetScale(snapToResolution(scale, ZoomOption.LEVEL_FIT), ZoomOption.LEVEL_FIT);
doSetOrigin(oldbbox.getCenterPoint());
fireEvent(true, null);
} else {
// Use the same center point for the new bounds, but don't zoom in or out.
doSetOrigin(oldbbox.getCenterPoint());
fireEvent(true, null);
}
}
/**
* Move the view on the map. This happens by translating the camera in turn.
*
* @param x
* Translation factor along the X-axis in world space.
* @param y
* Translation factor along the Y-axis in world space.
*/
public void translate(double x, double y) {
saveState();
doSetOrigin(new Coordinate(viewState.getX() + x, viewState.getY() + y));
fireEvent(false, null);
}
/**
* Adjust the current scale on the map by a new factor.
*
* @param delta
* Adjust the scale by factor "delta".
*/
public void scale(double delta, ZoomOption option) {
setCurrentScale(viewState.getScale() * delta, option);
}
/**
* Adjust the current scale on the map by a new factor, keeping a coordinate in place.
*
* @param delta
* Adjust the scale by factor "delta".
* @param center
* Keep this coordinate on the same position as before.
*
*/
public void scale(double delta, ZoomOption option, Coordinate center) {
setCurrentScale(viewState.getScale() * delta, option, center);
}
// -------------------------------------------------------------------------
// Getters:
// -------------------------------------------------------------------------
/** Return the current scale. */
public double getCurrentScale() {
return viewState.getScale();
}
/**
* Given the information in this MapView object, what is the currently visible area?
*
* @return Notice that at this moment an Axis Aligned Bounding Box is returned! This means that rotating is not yet
* possible.
*/
public Bbox getBounds() {
double w = getViewSpaceWidth();
double h = getViewSpaceHeight();
double x = viewState.getX() - w / 2;
double y = viewState.getY() - h / 2;
return new Bbox(x, y, w, h);
}
/**
* Set the list of predefined map resolutions (resolution = inverse of scale).
*
* @param resolutions
* the list of predefined resolutions (expressed in map unit/pixel)
*/
public void setResolutions(List<Double> resolutions) {
this.resolutions.clear();
this.resolutions.addAll(resolutions);
Collections.sort(this.resolutions, Collections.reverseOrder());
}
/**
* Get the list of predefined map resolutions (resolution = inverse of scale).
*/
public List<Double> getResolutions() {
return resolutions;
}
/**
* Is the given resolution available (given the maximum bounds, and current size of the map) or not?
*
* @param resolution
* The resolution to calculate availability for.
* @return Returns true or false. If false, the given resolution cannot be reached.
* @since 1.8.0
*/
public boolean isResolutionAvailable(double resolution) {
double max = MAX_RESOLUTION;
double minimumScale = getMinimumScale();
if (minimumScale > 0) {
max = 1.0 / getMinimumScale();
}
double min = 1.0 / maximumScale;
if (resolution >= min && resolution <= max) {
return true;
}
return false;
}
/**
* Return the transformer that is used to transform coordinate and geometries between world and screen space.
*/
public WorldViewTransformer getWorldViewTransformer() {
if (null == worldViewTransformer) {
worldViewTransformer = new WorldViewTransformer(this);
}
return worldViewTransformer;
}
public int getWidth() {
return width;
}
public int getHeight() {
return height;
}
public void setMaximumScale(double maximumScale) {
if (maximumScale > 0) {
this.maximumScale = maximumScale;
}
}
public Bbox getMaxBounds() {
return maxBounds;
}
public void setMaxBounds(Bbox maxBounds) {
this.maxBounds = maxBounds;
}
public void setPanDragging(boolean panDragging) {
saveState();
viewState = viewState.copyAndSetPanDragging(panDragging);
}
public MapViewState getViewState() {
return viewState;
}
public Coordinate getPanOrigin() {
return new Coordinate(viewState.getPanX(), viewState.getPanY());
}
public String toString() {
return "VIEW: " + viewState.toString();
}
// -------------------------------------------------------------------------
// Private functions:
// -------------------------------------------------------------------------
private boolean doSetScale(double scale, ZoomOption option) {
boolean res = Math.abs(viewState.getScale() - scale) > .0000001;
viewState = viewState.copyAndSetScale(scale);
return res;
}
private void doSetOrigin(Coordinate coordinate) {
Coordinate center = calcCenterFromPoint(coordinate);
viewState = viewState.copyAndSetOrigin(center.getX(), center.getY());
}
private void doApplyBounds(Bbox bounds, ZoomOption option) {
if (bounds != null) {
// first set the scale, taking minimum and maximum scale into
// account
// boolean scaleChanged = false;
if (!bounds.isEmpty()) {
// find best scale
double scale = getBestScale(bounds);
// snap and limit
scale = snapToResolution(scale, option);
// set scale
/* scaleChanged = */doSetScale(scale, option);
}
// now translate, taking maximum bounds into account
doSetOrigin(bounds.getCenterPoint());
if (bounds.isEmpty()) {
fireEvent(false, null);
} else {
// find pan origin by rounding origin to 10000 x 10000 grid (to enable caching)
// pan origin is given in world space but the rounding is done in pixels,
// so convert to pixel, round, convert back to world
double x = Math.round(viewState.getX() * viewState.getScale() / 10000) * 10000 / viewState.getScale();
double y = Math.round(viewState.getY() * viewState.getScale() / 10000) * 10000 / viewState.getScale();
// set pan origin
viewState = viewState.copyAndSetPanOrigin(x, y);
fireEvent(false, option);
}
}
}
private double getMinimumScale() {
// the minimum scale is determined by the maximum bounds and the pixel
// size of the map
if (maxBounds != null) {
double wRatio = width / maxBounds.getWidth();
double hRatio = height / maxBounds.getHeight();
// return the maximum to fit outside
return wRatio > hRatio ? wRatio : hRatio;
} else {
return Double.MIN_VALUE;
}
}
private double getBestScale(Bbox bounds) {
double wRatio;
double boundsWidth = bounds.getWidth();
if (boundsWidth <= 0) {
wRatio = getMinimumScale();
} else {
wRatio = width / boundsWidth;
}
double hRatio;
double boundsHeight = bounds.getHeight();
if (boundsHeight <= 0) {
hRatio = getMinimumScale();
} else {
hRatio = height / boundsHeight;
}
// return the minimum to fit inside
return wRatio < hRatio ? wRatio : hRatio;
}
private double limitScale(double scale) {
double minimumScale = getMinimumScale();
if (scale < minimumScale) {
return minimumScale;
} else if (scale > maximumScale) {
return maximumScale;
} else {
return scale;
}
}
private IndexRange getResolutionRange() {
IndexRange range = new IndexRange();
double max = MAX_RESOLUTION;
double minimumScale = getMinimumScale();
if (minimumScale > 0) {
max = 1.0 / getMinimumScale();
}
double min = 1.0 / maximumScale;
for (int i = 0; i < resolutions.size(); i++) {
Double resolution = resolutions.get(i);
if (resolution >= min && resolution <= max) {
range.setMin(i);
range.setMax(i);
}
}
return range;
}
private double getViewSpaceWidth() {
return width / viewState.getScale();
}
private double getViewSpaceHeight() {
return height / viewState.getScale();
}
/**
* keeps a copy of the previous pan data so we can detect if we are panning.
*
* @see #isPanning()
*/
private void saveState() {
this.lastViewState = viewState;
}
/** Fire an event. */
private void fireEvent(boolean resized, ZoomOption option) {
// keep old semantics of sameScaleLevel for api compatibility !
boolean sameScale = lastViewState != null && viewState.isPannableFrom(lastViewState);
handlerManager.fireEvent(new MapViewChangedEvent(getBounds(), getCurrentScale(), sameScale, viewState
.isPanDragging(), resized, option));
}
/**
* Finds an optimal scale by snapping to resolutions.
*
* @param scale
* scale which needs to be snapped
* @param option
* snapping option
* @return snapped scale
*/
private double snapToResolution(double scale, ZoomOption option) {
// clip upper bounds
double allowedScale = limitScale(scale);
if (resolutions != null) {
IndexRange indexes = getResolutionRange();
if (option == ZoomOption.EXACT || !indexes.isValid()) {
// should not or cannot snap to resolutions
return allowedScale;
} else {
// find the new index
int newResolutionIndex = 0;
double screenResolution = 1.0 / allowedScale;
if (screenResolution >= resolutions.get(indexes.getMin())) {
newResolutionIndex = indexes.getMin();
} else if (screenResolution <= resolutions.get(indexes.getMax())) {
newResolutionIndex = indexes.getMax();
} else {
for (int i = indexes.getMin(); i < indexes.getMax(); i++) {
double upper = resolutions.get(i);
double lower = resolutions.get(i + 1);
if (screenResolution <= upper && screenResolution > lower) {
if (option == ZoomOption.LEVEL_FIT) {
newResolutionIndex = i;
break;
} else {
if ((upper / screenResolution) > (screenResolution / lower)) {
newResolutionIndex = i + 1;
break;
} else {
newResolutionIndex = i;
break;
}
}
}
}
}
// check if we need to change level
if (newResolutionIndex == resolutionIndex && option == ZoomOption.LEVEL_CHANGE) {
if (scale > viewState.getScale() && newResolutionIndex < indexes.getMax()) {
newResolutionIndex++;
} else if (scale < viewState.getScale() && newResolutionIndex > indexes.getMin()) {
newResolutionIndex--;
}
}
resolutionIndex = newResolutionIndex;
return 1.0 / resolutions.get(resolutionIndex);
}
} else {
return scale;
}
}
/**
* Adjusts the center point of the map, to an allowed center point. This method tries to make sure the whole map
* extent is inside the maximum allowed bounds.
*
* @param worldCenter
* @return
*/
private Coordinate calcCenterFromPoint(final Coordinate worldCenter) {
double xCenter = worldCenter.getX();
double yCenter = worldCenter.getY();
if (maxBounds != null) {
double w = getViewSpaceWidth() / 2;
double h = getViewSpaceHeight() / 2;
Coordinate minCoordinate = maxBounds.getOrigin();
Coordinate maxCoordinate = maxBounds.getEndPoint();
if ((w * 2) > maxBounds.getWidth()) {
xCenter = maxBounds.getCenterPoint().getX();
} else {
if ((xCenter - w) < minCoordinate.getX()) {
xCenter = minCoordinate.getX() + w;
}
if ((xCenter + w) > maxCoordinate.getX()) {
xCenter = maxCoordinate.getX() - w;
}
}
if ((h * 2) > maxBounds.getHeight()) {
yCenter = maxBounds.getCenterPoint().getY();
} else {
if ((yCenter - h) < minCoordinate.getY()) {
yCenter = minCoordinate.getY() + h;
}
if ((yCenter + h) > maxCoordinate.getY()) {
yCenter = maxCoordinate.getY() - h;
}
}
}
return new Coordinate(xCenter, yCenter);
}
/**
* A range of indexes.
*/
private class IndexRange {
private Integer min;
private Integer max;
public int getMax() {
return max;
}
public void setMax(int max) {
if (this.max == null || max > this.max) {
this.max = max;
}
}
public int getMin() {
return min;
}
public void setMin(int min) {
if (this.min == null || min < this.min) {
this.min = min;
}
}
public boolean isValid() {
return min != null && max != null && min <= max;
}
}
}