From 5b10b5e833302743527049a9ab888cf0a55cbf7f Mon Sep 17 00:00:00 2001
From: Michael Zangl <michael.zangl@student.kit.edu>
Date: Wed, 1 Jul 2015 17:22:24 +0200
Subject: [PATCH 11/11] Moved zoom and center management and coordinate
 conversion of MapView to a new class.

---
 src/org/openstreetmap/josm/gui/MapMover.java       |  89 ++-
 src/org/openstreetmap/josm/gui/MapStatus.java      |   4 +-
 src/org/openstreetmap/josm/gui/MapView.java        |  27 +-
 .../josm/gui/NavigatableComponent.java             | 344 ++++------
 .../josm/gui/navigate/NavigationModel.java         | 730 +++++++++++++++++++++
 test/unit/org/openstreetmap/josm/TestUtils.java    |  36 +
 .../josm/gui/NavigatableComponentTest.java         | 187 ++++++
 7 files changed, 1158 insertions(+), 259 deletions(-)
 create mode 100644 src/org/openstreetmap/josm/gui/navigate/NavigationModel.java
 create mode 100644 test/unit/org/openstreetmap/josm/gui/NavigatableComponentTest.java

diff --git a/src/org/openstreetmap/josm/gui/MapMover.java b/src/org/openstreetmap/josm/gui/MapMover.java
index d8ea39a..943cd04 100644
--- a/src/org/openstreetmap/josm/gui/MapMover.java
+++ b/src/org/openstreetmap/josm/gui/MapMover.java
@@ -3,6 +3,7 @@ package org.openstreetmap.josm.gui;
 
 import static org.openstreetmap.josm.tools.I18n.tr;
 
+import java.awt.Component;
 import java.awt.Cursor;
 import java.awt.Point;
 import java.awt.event.ActionEvent;
@@ -12,6 +13,7 @@ import java.awt.event.MouseEvent;
 import java.awt.event.MouseMotionListener;
 import java.awt.event.MouseWheelEvent;
 import java.awt.event.MouseWheelListener;
+import java.awt.geom.Point2D;
 
 import javax.swing.AbstractAction;
 import javax.swing.ActionMap;
@@ -23,6 +25,9 @@ import javax.swing.KeyStroke;
 import org.openstreetmap.josm.Main;
 import org.openstreetmap.josm.actions.mapmode.SelectAction;
 import org.openstreetmap.josm.data.coor.EastNorth;
+import org.openstreetmap.josm.gui.navigate.NavigationCursorManager;
+import org.openstreetmap.josm.gui.navigate.NavigationModel;
+import org.openstreetmap.josm.gui.navigate.NavigationModel.ScrollMode;
 import org.openstreetmap.josm.tools.Destroyable;
 import org.openstreetmap.josm.tools.Shortcut;
 
@@ -44,29 +49,32 @@ public class MapMover extends MouseAdapter implements MouseMotionListener, Mouse
         @Override
         public void actionPerformed(ActionEvent e) {
             if (".".equals(action) || ",".equals(action)) {
-                Point mouse = nc.getMousePosition();
+                Point2D mouse = lastMousePosition;
                 if (mouse == null)
-                    mouse = new Point((int) nc.getBounds().getCenterX(), (int) nc.getBounds().getCenterY());
-                MouseWheelEvent we = new MouseWheelEvent(nc, e.getID(), e.getWhen(), e.getModifiers(), mouse.x, mouse.y, 0, false,
+                    mouse = nm.getScreenPosition(nm.getCenter());
+                MouseWheelEvent we = new MouseWheelEvent((Component) e.getSource(), e.getID(), e.getWhen(), e.getModifiers(), (int) mouse.getX(), (int) mouse.getY(), 0, false,
+
                         MouseWheelEvent.WHEEL_UNIT_SCROLL, 1, ",".equals(action) ? -1 : 1);
                 mouseWheelMoved(we);
             } else {
-                EastNorth center = nc.getCenter();
-                EastNorth newcenter = nc.getEastNorth(nc.getWidth()/2+nc.getWidth()/5, nc.getHeight()/2+nc.getHeight()/5);
+                double relativeX = .5;
+                double relativeY = .5;
                 switch(action) {
                 case "left":
-                    nc.zoomTo(new EastNorth(2*center.east()-newcenter.east(), center.north()));
+                    relativeX -= .2;
                     break;
                 case "right":
-                    nc.zoomTo(new EastNorth(newcenter.east(), center.north()));
+                    relativeX += .2;
                     break;
                 case "up":
-                    nc.zoomTo(new EastNorth(center.east(), 2*center.north()-newcenter.north()));
+                    relativeY -= .2;
                     break;
                 case "down":
-                    nc.zoomTo(new EastNorth(center.east(), newcenter.north()));
+                    relativeY += .2;
                     break;
                 }
+                EastNorth newcenter = nm.getEastNorthRelative(relativeX, relativeY);
+                nm.zoomTo(newcenter, ScrollMode.IMMEDIATE);
             }
         }
     }
@@ -79,22 +87,24 @@ public class MapMover extends MouseAdapter implements MouseMotionListener, Mouse
     /**
      * The map to move around.
      */
-    private final NavigatableComponent nc;
+    private final NavigationModel nm;
     private final JPanel contentPane;
 
     private boolean movementInPlace = false;
+    private final NavigationCursorManager cursorManager;
+
+    private Point lastMousePosition = null;
 
     /**
      * Constructs a new {@code MapMover}.
-     * @param navComp the navigatable component
+     * @param navigationModel the navigatable component
+     * @param cursorManager A cursor manager to which we should send cursor changes.
      * @param contentPane the content pane
      */
-    public MapMover(NavigatableComponent navComp, JPanel contentPane) {
-        this.nc = navComp;
+    public MapMover(NavigationModel navigationModel, NavigationCursorManager cursorManager, JPanel contentPane) {
+        this.nm = navigationModel;
+        this.cursorManager = cursorManager;
         this.contentPane = contentPane;
-        nc.addMouseListener(this);
-        nc.addMouseMotionListener(this);
-        nc.addMouseWheelListener(this);
 
         if (contentPane != null) {
             // CHECKSTYLE.OFF: LineLength
@@ -137,6 +147,16 @@ public class MapMover extends MouseAdapter implements MouseMotionListener, Mouse
     }
 
     /**
+     * Registers the mouse events of a component so that they move the map on the right actions.
+     * @param c The component to register the event on.
+     */
+    public void registerMouseEvents(Component c) {
+        c.addMouseListener(this);
+        c.addMouseMotionListener(this);
+        c.addMouseWheelListener(this);
+    }
+
+    /**
      * If the right (and only the right) mouse button is pressed, move the map.
      */
     @Override
@@ -150,14 +170,15 @@ public class MapMover extends MouseAdapter implements MouseMotionListener, Mouse
         if (stdMovement || (macMovement && allowedMode)) {
             if (mousePosMove == null)
                 startMovement(e);
-            EastNorth center = nc.getCenter();
-            EastNorth mouseCenter = nc.getEastNorth(e.getX(), e.getY());
-            nc.zoomTo(new EastNorth(
+            EastNorth center = nm.getCenter();
+            EastNorth mouseCenter = nm.getEastNorth(e.getPoint());
+            nm.zoomTo(new EastNorth(
                     mousePosMove.east() + center.east() - mouseCenter.east(),
-                    mousePosMove.north() + center.north() - mouseCenter.north()));
+                    mousePosMove.north() + center.north() - mouseCenter.north()), ScrollMode.IMMEDIATE);
         } else {
             endMovement();
         }
+        updateMousePosition(e);
     }
 
     /**
@@ -171,6 +192,7 @@ public class MapMover extends MouseAdapter implements MouseMotionListener, Mouse
                 Main.isPlatformOsx() && e.getModifiersEx() == macMouseMask) {
             startMovement(e);
         }
+        updateMousePosition(e);
     }
 
     /**
@@ -181,6 +203,12 @@ public class MapMover extends MouseAdapter implements MouseMotionListener, Mouse
         if (e.getButton() == MouseEvent.BUTTON3 || Main.isPlatformOsx() && e.getButton() == MouseEvent.BUTTON1) {
             endMovement();
         }
+        updateMousePosition(e);
+    }
+
+    @Override
+    public void mouseExited(MouseEvent e) {
+        lastMousePosition = null;
     }
 
     /**
@@ -192,8 +220,8 @@ public class MapMover extends MouseAdapter implements MouseMotionListener, Mouse
         if (movementInPlace)
             return;
         movementInPlace = true;
-        mousePosMove = nc.getEastNorth(e.getX(), e.getY());
-        nc.setNewCursor(Cursor.MOVE_CURSOR, this);
+        mousePosMove = nm.getEastNorth(e.getX(), e.getY());
+        cursorManager.setNewCursor(Cursor.getPredefinedCursor(Cursor.MOVE_CURSOR), this);
     }
 
     /**
@@ -203,7 +231,7 @@ public class MapMover extends MouseAdapter implements MouseMotionListener, Mouse
         if (!movementInPlace)
             return;
         movementInPlace = false;
-        nc.resetCursor(this);
+        cursorManager.resetCursor(this);
         mousePosMove = null;
     }
 
@@ -213,7 +241,7 @@ public class MapMover extends MouseAdapter implements MouseMotionListener, Mouse
      */
     @Override
     public void mouseWheelMoved(MouseWheelEvent e) {
-        nc.zoomToFactor(e.getX(), e.getY(), Math.pow(Math.sqrt(2), e.getWheelRotation()));
+        nm.zoomToFactorAround(e.getPoint(), Math.pow(Math.sqrt(2), e.getWheelRotation()));
     }
 
     /**
@@ -230,14 +258,15 @@ public class MapMover extends MouseAdapter implements MouseMotionListener, Mouse
                 if (mousePosMove == null) {
                     startMovement(e);
                 }
-                EastNorth center = nc.getCenter();
-                EastNorth mouseCenter = nc.getEastNorth(e.getX(), e.getY());
-                nc.zoomTo(new EastNorth(mousePosMove.east() + center.east() - mouseCenter.east(), mousePosMove.north()
-                        + center.north() - mouseCenter.north()));
+                EastNorth center = nm.getCenter();
+                EastNorth mouseCenter = nm.getEastNorth(e.getX(), e.getY());
+                nm.zoomTo(new EastNorth(mousePosMove.east() + center.east() - mouseCenter.east(), mousePosMove.north()
+                        + center.north() - mouseCenter.north()), ScrollMode.IMMEDIATE);
             } else {
                 endMovement();
             }
         }
+        updateMousePosition(e);
     }
 
     @Override
@@ -264,4 +293,8 @@ public class MapMover extends MouseAdapter implements MouseMotionListener, Mouse
             }
         }
     }
+
+    private void updateMousePosition(MouseEvent e) {
+        lastMousePosition = e.getPoint();
+    }
 }
diff --git a/src/org/openstreetmap/josm/gui/MapStatus.java b/src/org/openstreetmap/josm/gui/MapStatus.java
index 51e87e2..9cc71bb 100644
--- a/src/org/openstreetmap/josm/gui/MapStatus.java
+++ b/src/org/openstreetmap/josm/gui/MapStatus.java
@@ -376,7 +376,7 @@ public class MapStatus extends JPanel implements Helpful, Destroyable, Preferenc
                         return; // exit, if new parent.
 
                     // Do nothing, if required data is missing
-                    if (ms.mousePos == null || mv.center == null) {
+                    if (ms.mousePos == null || mv.getCenter() == null) {
                         continue;
                     }
 
@@ -822,7 +822,7 @@ public class MapStatus extends JPanel implements Helpful, Destroyable, Preferenc
 
             @Override
             public void mouseMoved(MouseEvent e) {
-                if (mv.center == null)
+                if (mv.getCenter() == null)
                     return;
                 // Do not update the view if ctrl is pressed.
                 if ((e.getModifiersEx() & MouseEvent.CTRL_DOWN_MASK) == 0) {
diff --git a/src/org/openstreetmap/josm/gui/MapView.java b/src/org/openstreetmap/josm/gui/MapView.java
index 77613b4..c9b80f4 100644
--- a/src/org/openstreetmap/josm/gui/MapView.java
+++ b/src/org/openstreetmap/josm/gui/MapView.java
@@ -26,6 +26,7 @@ import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.LinkedHashSet;
+import java.util.LinkedList;
 import java.util.List;
 import java.util.ListIterator;
 import java.util.Set;
@@ -61,6 +62,8 @@ import org.openstreetmap.josm.gui.layer.OsmDataLayer;
 import org.openstreetmap.josm.gui.layer.geoimage.GeoImageLayer;
 import org.openstreetmap.josm.gui.layer.markerlayer.MarkerLayer;
 import org.openstreetmap.josm.gui.layer.markerlayer.PlayHeadMarker;
+import org.openstreetmap.josm.gui.navigate.NavigationModel;
+import org.openstreetmap.josm.gui.navigate.NavigationModel.ZoomData;
 import org.openstreetmap.josm.gui.util.GuiHelper;
 import org.openstreetmap.josm.tools.AudioPlayer;
 import org.openstreetmap.josm.tools.BugReportExceptionHandler;
@@ -279,7 +282,6 @@ implements PropertyChangeListener, PreferenceChangedListener, OsmDataLayer.Layer
     // Layers that wasn't changed since last paint
     private final transient List<Layer> nonChangedLayers = new ArrayList<>();
     private transient Layer changedLayer;
-    private int lastViewID;
     private boolean paintPreferencesChanged = true;
     private Rectangle lastClipBounds = new Rectangle();
     private transient MapMover mapMover;
@@ -304,7 +306,10 @@ implements PropertyChangeListener, PreferenceChangedListener, OsmDataLayer.Layer
                     MapView.this.add(c);
                 }
 
-                mapMover = new MapMover(MapView.this, contentPane);
+                mapMover = new MapMover(getNavigationModel(), cursorManager, contentPane);
+                mapMover.registerMouseEvents(MapView.this);
+                // Notify the map view that it has changed.
+                repaint();
             }
         });
 
@@ -331,7 +336,7 @@ implements PropertyChangeListener, PreferenceChangedListener, OsmDataLayer.Layer
             }
         });
 
-        if (Shortcut.findShortcut(KeyEvent.VK_TAB, 0) != null) {
+        if (Shortcut.findShortcut(KeyEvent.VK_TAB, 0)!=null) {
             setFocusTraversalKeysEnabled(false);
         }
     }
@@ -727,8 +732,7 @@ implements PropertyChangeListener, PreferenceChangedListener, OsmDataLayer.Layer
             canUseBuffer = !paintPreferencesChanged;
             paintPreferencesChanged = false;
         }
-        canUseBuffer = canUseBuffer && nonChangedLayers.size() <= nonChangedLayersCount &&
-        lastViewID == getViewID() && lastClipBounds.contains(g.getClipBounds());
+        canUseBuffer = canUseBuffer && nonChangedLayers.size() <= nonChangedLayersCount && lastClipBounds.contains(g.getClipBounds());
         if (canUseBuffer) {
             for (int i = 0; i < nonChangedLayers.size(); i++) {
                 if (visibleLayers.get(i) != nonChangedLayers.get(i)) {
@@ -775,7 +779,6 @@ implements PropertyChangeListener, PreferenceChangedListener, OsmDataLayer.Layer
         for (int i = 0; i < nonChangedLayersCount; i++) {
             nonChangedLayers.add(visibleLayers.get(i));
         }
-        lastViewID = getViewID();
         lastClipBounds = g.getClipBounds();
 
         tempG.drawImage(nonChangedLayersBuffer, 0, 0, null);
@@ -1019,7 +1022,6 @@ implements PropertyChangeListener, PreferenceChangedListener, OsmDataLayer.Layer
         if (layer == activeLayer)
             return false;
 
-        Layer old = activeLayer;
         activeLayer = layer;
         if (setEditLayer) {
             setEditLayer(layers);
@@ -1175,6 +1177,17 @@ implements PropertyChangeListener, PreferenceChangedListener, OsmDataLayer.Layer
         }
     }
 
+    @Override
+    public void zoomChanged(NavigationModel navigationModel, ZoomData oldZoom, ZoomData newZoom) {
+        super.zoomChanged(navigationModel, oldZoom, newZoom);
+        synchronized (this) {
+            paintPreferencesChanged = true;
+        }
+    }
+
+    /**
+     * A selection listener that fires a repaint as soon as the selection changes.
+     */
     private transient SelectionChangedListener repaintSelectionChangedListener = new SelectionChangedListener() {
         @Override
         public void selectionChanged(Collection<? extends OsmPrimitive> newSelection) {
diff --git a/src/org/openstreetmap/josm/gui/NavigatableComponent.java b/src/org/openstreetmap/josm/gui/NavigatableComponent.java
index 6bd0808..a8e6eb2 100644
--- a/src/org/openstreetmap/josm/gui/NavigatableComponent.java
+++ b/src/org/openstreetmap/josm/gui/NavigatableComponent.java
@@ -52,6 +52,10 @@ import org.openstreetmap.josm.gui.help.Helpful;
 import org.openstreetmap.josm.gui.mappaint.MapPaintStyles;
 import org.openstreetmap.josm.gui.mappaint.mapcss.MapCSSStyleSource;
 import org.openstreetmap.josm.gui.navigate.NavigationCursorManager;
+import org.openstreetmap.josm.gui.navigate.NavigationModel;
+import org.openstreetmap.josm.gui.navigate.NavigationModel.ScrollMode;
+import org.openstreetmap.josm.gui.navigate.NavigationModel.WeakZoomChangeListener;
+import org.openstreetmap.josm.gui.navigate.NavigationModel.ZoomData;
 import org.openstreetmap.josm.tools.Predicate;
 import org.openstreetmap.josm.tools.Utils;
 
@@ -62,7 +66,7 @@ import org.openstreetmap.josm.tools.Utils;
  * @author imi
  * @since 41
  */
-public class NavigatableComponent extends JComponent implements Helpful {
+public class NavigatableComponent extends JComponent implements Helpful, NavigationModel.ZoomChangeListener {
 
     /**
      * Interface to notify listeners of the change of the zoom area.
@@ -74,6 +78,45 @@ public class NavigatableComponent extends JComponent implements Helpful {
         void zoomChanged();
     }
 
+    private static final class ZoomChangeAdapter implements NavigationModel.ZoomChangeListener {
+
+        private ZoomChangeListener listener;
+
+        public  ZoomChangeAdapter(ZoomChangeListener listener) {
+            this.listener = listener;
+        }
+
+        @Override
+        public void zoomChanged(NavigationModel navigationModel, ZoomData oldZoom, ZoomData newZoom) {
+            listener.zoomChanged();
+        }
+
+        @Override
+        public int hashCode() {
+            final int prime = 31;
+            int result = 1;
+            result = prime * result + ((listener == null) ? 0 : listener.hashCode());
+            return result;
+        }
+
+        @Override
+        public boolean equals(Object obj) {
+            if (this == obj)
+                return true;
+            if (obj == null)
+                return false;
+            if (getClass() != obj.getClass())
+                return false;
+            ZoomChangeAdapter other = (ZoomChangeAdapter) obj;
+            if (listener == null) {
+                if (other.listener != null)
+                    return false;
+            } else if (!listener.equals(other.listener))
+                return false;
+            return true;
+        }
+    }
+
     /**
      * Interface to notify listeners of the change of the system of measurement.
      * @since 6056
@@ -101,9 +144,10 @@ public class NavigatableComponent extends JComponent implements Helpful {
     public static final String PROPNAME_SCALE  = "scale";
 
     /**
-     * the zoom listeners
+     * This is the navigation model for the one single map view.
+     * Due to backwards compatibility (zoom change listeners, ...), we use a static field here.
      */
-    private static final CopyOnWriteArrayList<ZoomChangeListener> zoomChangeListeners = new CopyOnWriteArrayList<>();
+    private static final NavigationModel defaultNavigationModel = new NavigationModel();
 
     /**
      * Removes a zoom change listener
@@ -111,7 +155,7 @@ public class NavigatableComponent extends JComponent implements Helpful {
      * @param listener the listener. Ignored if null or already absent
      */
     public static void removeZoomChangeListener(NavigatableComponent.ZoomChangeListener listener) {
-        zoomChangeListeners.remove(listener);
+        defaultNavigationModel.removeZoomChangeListener(new ZoomChangeAdapter(listener));
     }
 
     /**
@@ -120,18 +164,9 @@ public class NavigatableComponent extends JComponent implements Helpful {
      * @param listener the listener. Ignored if null or already registered.
      */
     public static void addZoomChangeListener(NavigatableComponent.ZoomChangeListener listener) {
-        if (listener != null) {
-            zoomChangeListeners.addIfAbsent(listener);
-        }
-    }
-
-    protected static void fireZoomChanged() {
-        for (ZoomChangeListener l : zoomChangeListeners) {
-            l.zoomChanged();
-        }
+        defaultNavigationModel.addZoomChangeListener(new ZoomChangeAdapter(listener));
     }
 
-
     /**
      * Removes a SoM change listener
      *
@@ -175,11 +210,6 @@ public class NavigatableComponent extends JComponent implements Helpful {
         SystemOfMeasurement.setSystemOfMeasurement(somKey);
     }
 
-    private double scale = Main.getProjection().getDefaultZoomInPPD();
-    /**
-     * Center n/e coordinate of the desired screen center.
-     */
-    protected EastNorth center = calculateDefaultCenter();
 
     private final transient Object paintRequestLock = new Object();
     private Rectangle paintRect = null;
@@ -187,13 +217,40 @@ public class NavigatableComponent extends JComponent implements Helpful {
 
     protected transient ViewportData initialViewport;
 
+    private final NavigationModel navigationModel;
+
+    private transient final NavigationModel.ZoomChangeListener weakZoomListener = new WeakZoomChangeListener(this);
+
     protected transient final NavigationCursorManager cursorManager = new NavigationCursorManager(this);
 
     /**
-     * Constructs a new {@code NavigatableComponent}.
+     * Constructs a new {@code NavigatableComponent} using the static default {@link NavigationModel} and zooming to the current bounds,
      */
     public NavigatableComponent() {
+        this(defaultNavigationModel);
+        defaultNavigationModel.zoomTo(
+                calculateDefaultCenter(),
+                Main.getProjection().getDefaultZoomInPPD()
+                );
+    }
+
+    /**
+     * Constructs a new {@code NavigatableComponent}
+     * @param navigationModel The navigation model to use.
+     */
+    public NavigatableComponent(NavigationModel navigationModel) {
+        this.navigationModel = navigationModel;
         setLayout(null);
+        navigationModel.addZoomChangeListener(weakZoomListener);
+        navigationModel.trackComponentSize(this);
+    }
+
+    /**
+     * Gets the navigation model that is used to convert between screen and world coordinates and handles zooming.
+     * @return The navigation model this component was constructed with.
+     */
+    public NavigationModel getNavigationModel() {
+        return navigationModel;
     }
 
     protected DataSet getCurrentDataSet() {
@@ -257,11 +314,7 @@ public class NavigatableComponent extends JComponent implements Helpful {
     }
 
     public double getDist100Pixel() {
-        int w = getWidth()/2;
-        int h = getHeight()/2;
-        LatLon ll1 = getLatLon(w-50, h);
-        LatLon ll2 = getLatLon(w+50, h);
-        return ll1.greatCircleDistance(ll2);
+        return navigationModel.getPixelDistance(100);
     }
 
     /**
@@ -269,11 +322,11 @@ public class NavigatableComponent extends JComponent implements Helpful {
      *      change the center by accessing the return value. Use zoomTo instead.
      */
     public EastNorth getCenter() {
-        return center;
+        return navigationModel.getCenter();
     }
 
     public double getScale() {
-        return scale;
+        return navigationModel.getScale();
     }
 
     /**
@@ -283,19 +336,11 @@ public class NavigatableComponent extends JComponent implements Helpful {
      * @return Geographic coordinates from a specific pixel coordination on the screen.
      */
     public EastNorth getEastNorth(int x, int y) {
-        return new EastNorth(
-                center.east() + (x - getWidth()/2.0)*scale,
-                center.north() - (y - getHeight()/2.0)*scale);
+        return navigationModel.getEastNorth(x, y);
     }
 
     public ProjectionBounds getProjectionBounds() {
-        return new ProjectionBounds(
-                new EastNorth(
-                        center.east() - getWidth()/2.0*scale,
-                        center.north() - getHeight()/2.0*scale),
-                        new EastNorth(
-                                center.east() + getWidth()/2.0*scale,
-                                center.north() + getHeight()/2.0*scale));
+        return new ProjectionBounds(getEastNorth(0, getHeight()), getEastNorth(getWidth(), 0));
     }
 
     /* FIXME: replace with better method - used by MapSlider */
@@ -308,12 +353,8 @@ public class NavigatableComponent extends JComponent implements Helpful {
     /* FIXME: replace with better method - used by Main to reset Bounds when projection changes, don't use otherwise */
     public Bounds getRealBounds() {
         return new Bounds(
-                getProjection().eastNorth2latlon(new EastNorth(
-                        center.east() - getWidth()/2.0*scale,
-                        center.north() - getHeight()/2.0*scale)),
-                        getProjection().eastNorth2latlon(new EastNorth(
-                                center.east() + getWidth()/2.0*scale,
-                                center.north() + getHeight()/2.0*scale)));
+                getProjection().eastNorth2latlon(getEastNorth(0, getHeight())),
+                        getProjection().eastNorth2latlon(getEastNorth(getWidth(), 0)));
     }
 
     /**
@@ -324,11 +365,11 @@ public class NavigatableComponent extends JComponent implements Helpful {
      *      on the screen.
      */
     public LatLon getLatLon(int x, int y) {
-        return getProjection().eastNorth2latlon(getEastNorth(x, y));
+        return navigationModel.getLatLon(new Point2D.Double(x, y));
     }
 
     public LatLon getLatLon(double x, double y) {
-        return getLatLon((int) x, (int) y);
+        return navigationModel.getLatLon(new Point2D.Double(x, y));
     }
 
     /**
@@ -350,18 +391,17 @@ public class NavigatableComponent extends JComponent implements Helpful {
         double deltaNorth = (northMax - northMin) / 10;
 
         for (int i = 0; i < 10; i++) {
-            result.extend(Main.getProjection().eastNorth2latlon(new EastNorth(eastMin + i * deltaEast, northMin)));
-            result.extend(Main.getProjection().eastNorth2latlon(new EastNorth(eastMin + i * deltaEast, northMax)));
-            result.extend(Main.getProjection().eastNorth2latlon(new EastNorth(eastMin, northMin  + i * deltaNorth)));
-            result.extend(Main.getProjection().eastNorth2latlon(new EastNorth(eastMax, northMin  + i * deltaNorth)));
+            result.extend(getProjection().eastNorth2latlon(new EastNorth(eastMin + i * deltaEast, northMin)));
+            result.extend(getProjection().eastNorth2latlon(new EastNorth(eastMin + i * deltaEast, northMax)));
+            result.extend(getProjection().eastNorth2latlon(new EastNorth(eastMin, northMin  + i * deltaNorth)));
+            result.extend(getProjection().eastNorth2latlon(new EastNorth(eastMax, northMin  + i * deltaNorth)));
         }
 
         return result;
     }
 
     public AffineTransform getAffineTransform() {
-        return new AffineTransform(
-                1.0/scale, 0.0, 0.0, -1.0/scale, getWidth()/2.0 - center.east()/scale, getHeight()/2.0 + center.north()/scale);
+        return navigationModel.getAffineTransform();
     }
 
     /**
@@ -371,20 +411,11 @@ public class NavigatableComponent extends JComponent implements Helpful {
      *      to the own top/left.
      */
     public Point2D getPoint2D(EastNorth p) {
-        if (null == p)
-            return new Point();
-        double x = (p.east()-center.east())/scale + getWidth()/2d;
-        double y = (center.north()-p.north())/scale + getHeight()/2d;
-        return new Point2D.Double(x, y);
+        return navigationModel.getScreenPosition(p);
     }
 
     public Point2D getPoint2D(LatLon latlon) {
-        if (latlon == null)
-            return new Point();
-        else if (latlon instanceof CachedLatLon)
-            return getPoint2D(((CachedLatLon) latlon).getEastNorth());
-        else
-            return getPoint2D(getProjection().latlon2eastNorth(latlon));
+        return navigationModel.getScreenPosition(latlon);
     }
 
     public Point2D getPoint2D(Node n) {
@@ -430,89 +461,11 @@ public class NavigatableComponent extends JComponent implements Helpful {
      * @param initial true if this call initializes the viewport.
      */
     public void zoomTo(EastNorth newCenter, double newScale, boolean initial) {
-        Bounds b = getProjection().getWorldBoundsLatLon();
-        LatLon cl = Projections.inverseProject(newCenter);
-        boolean changed = false;
-        double lat = cl.lat();
-        double lon = cl.lon();
-        if (lat < b.getMinLat()) {
-            changed = true;
-            lat = b.getMinLat();
-        } else if (lat > b.getMaxLat()) {
-            changed = true;
-            lat = b.getMaxLat();
-        }
-        if (lon < b.getMinLon()) {
-            changed = true;
-            lon = b.getMinLon();
-        } else if (lon > b.getMaxLon()) {
-            changed = true;
-            lon = b.getMaxLon();
-        }
-        if (changed) {
-            newCenter = Projections.project(new LatLon(lat, lon));
-        }
-        int width = getWidth()/2;
-        int height = getHeight()/2;
-        LatLon l1 = new LatLon(b.getMinLat(), lon);
-        LatLon l2 = new LatLon(b.getMaxLat(), lon);
-        EastNorth e1 = getProjection().latlon2eastNorth(l1);
-        EastNorth e2 = getProjection().latlon2eastNorth(l2);
-        double d = e2.north() - e1.north();
-        if (height > 0 && d < height*newScale) {
-            double newScaleH = d/height;
-            e1 = getProjection().latlon2eastNorth(new LatLon(lat, b.getMinLon()));
-            e2 = getProjection().latlon2eastNorth(new LatLon(lat, b.getMaxLon()));
-            d = e2.east() - e1.east();
-            if (width > 0 && d < width*newScale) {
-                newScale = Math.max(newScaleH, d/width);
-            }
-        } else if (height > 0) {
-            d = d/(l1.greatCircleDistance(l2)*height*10);
-            if (newScale < d) {
-                newScale = d;
-            }
-        }
-
-        if (!newCenter.equals(center) || !Utils.equalsEpsilon(scale, newScale)) {
-            if (!initial) {
-                pushZoomUndo(center, scale);
-            }
-            zoomNoUndoTo(newCenter, newScale, initial);
-        }
-    }
-
-    /**
-     * Zoom to the given coordinate without adding to the zoom undo buffer.
-     *
-     * @param newCenter The center x-value (easting) to zoom to.
-     * @param newScale The scale to use.
-     * @param initial true if this call initializes the viewport.
-     */
-    private void zoomNoUndoTo(EastNorth newCenter, double newScale, boolean initial) {
-        if (!newCenter.equals(center)) {
-            EastNorth oldCenter = center;
-            center = newCenter;
-            if (!initial) {
-                firePropertyChange(PROPNAME_CENTER, oldCenter, newCenter);
-            }
-        }
-        if (!Utils.equalsEpsilon(scale, newScale)) {
-            double oldScale = scale;
-            scale = newScale;
-            if (!initial) {
-                firePropertyChange(PROPNAME_SCALE, oldScale, newScale);
-            }
-        }
-
-        if (!initial) {
-            repaint();
-            fireZoomChanged();
-        }
+        navigationModel.zoomTo(newCenter, newScale, initial ? ScrollMode.INITIAL : ScrollMode.DEFAULT);
     }
 
     public void zoomTo(EastNorth newCenter) {
-        zoomTo(newCenter, scale);
+        zoomTo(newCenter, navigationModel.getScale());
     }
 
     public void zoomTo(LatLon newCenter) {
@@ -529,48 +482,19 @@ public class NavigatableComponent extends JComponent implements Helpful {
      */
     public void smoothScrollTo(EastNorth newCenter) {
         // FIXME make these configurable.
-        final int fps = 20;     // animation frames per second
-        final int speed = 1500; // milliseconds for full-screen-width pan
-        if (!newCenter.equals(center)) {
-            final EastNorth oldCenter = center;
-            final double distance = newCenter.distance(oldCenter) / scale;
-            final double milliseconds = distance / getWidth() * speed;
-            final double frames = milliseconds * fps / 1000;
-            final EastNorth finalNewCenter = newCenter;
-
-            new Thread() {
-                @Override
-                public void run() {
-                    for (int i = 0; i < frames; i++) {
-                        // FIXME - not use zoom history here
-                        zoomTo(oldCenter.interpolate(finalNewCenter, (i+1) / frames));
-                        try {
-                            Thread.sleep(1000 / fps);
-                        } catch (InterruptedException ex) {
-                            Main.warn("InterruptedException in "+NavigatableComponent.class.getSimpleName()+" during smooth scrolling");
-                        }
-                    }
-                }
-            }.start();
-        }
+        navigationModel.zoomTo(newCenter, ScrollMode.ANIMATE);
     }
 
     public void zoomToFactor(double x, double y, double factor) {
-        double newScale = scale*factor;
-        // New center position so that point under the mouse pointer stays the same place as it was before zooming
-        // You will get the formula by simplifying this expression: newCenter = oldCenter + mouseCoordinatesInNewZoom - mouseCoordinatesInOldZoom
-        zoomTo(new EastNorth(
-                center.east() - (x - getWidth()/2.0) * (newScale - scale),
-                center.north() + (y - getHeight()/2.0) * (newScale - scale)),
-                newScale);
+        navigationModel.zoomToFactorAround(new Point2D.Double(x, y), factor);
     }
 
     public void zoomToFactor(EastNorth newCenter, double factor) {
-        zoomTo(newCenter, scale*factor);
+        zoomTo(newCenter, getScale()*factor);
     }
 
     public void zoomToFactor(double factor) {
-        zoomTo(center, scale*factor);
+        zoomTo(getCenter(), getScale()*factor);
     }
 
     public void zoomTo(ProjectionBounds box) {
@@ -624,62 +548,20 @@ public class NavigatableComponent extends JComponent implements Helpful {
         zoomTo(box.getBounds());
     }
 
-    private class ZoomData {
-        private final LatLon center;
-        private final double scale;
-
-        public ZoomData(EastNorth center, double scale) {
-            this.center = Projections.inverseProject(center);
-            this.scale = scale;
-        }
-
-        public EastNorth getCenterEastNorth() {
-            return getProjection().latlon2eastNorth(center);
-        }
-
-        public double getScale() {
-            return scale;
-        }
-    }
-
-    private Stack<ZoomData> zoomUndoBuffer = new Stack<>();
-    private Stack<ZoomData> zoomRedoBuffer = new Stack<>();
-    private Date zoomTimestamp = new Date();
-
-    private void pushZoomUndo(EastNorth center, double scale) {
-        Date now = new Date();
-        if ((now.getTime() - zoomTimestamp.getTime()) > (Main.pref.getDouble("zoom.undo.delay", 1.0) * 1000)) {
-            zoomUndoBuffer.push(new ZoomData(center, scale));
-            if (zoomUndoBuffer.size() > Main.pref.getInteger("zoom.undo.max", 50)) {
-                zoomUndoBuffer.remove(0);
-            }
-            zoomRedoBuffer.clear();
-        }
-        zoomTimestamp = now;
-    }
-
     public void zoomPrevious() {
-        if (!zoomUndoBuffer.isEmpty()) {
-            ZoomData zoom = zoomUndoBuffer.pop();
-            zoomRedoBuffer.push(new ZoomData(center, scale));
-            zoomNoUndoTo(zoom.getCenterEastNorth(), zoom.getScale(), false);
-        }
+        navigationModel.zoomPrevious();
     }
 
     public void zoomNext() {
-        if (!zoomRedoBuffer.isEmpty()) {
-            ZoomData zoom = zoomRedoBuffer.pop();
-            zoomUndoBuffer.push(new ZoomData(center, scale));
-            zoomNoUndoTo(zoom.getCenterEastNorth(), zoom.getScale(), false);
-        }
+        navigationModel.zoomNext();
     }
 
     public boolean hasZoomUndoEntries() {
-        return !zoomUndoBuffer.isEmpty();
+        return navigationModel.hasPreviousZoomEntries();
     }
 
     public boolean hasZoomRedoEntries() {
-        return !zoomRedoBuffer.isEmpty();
+        return navigationModel.hasNextZoomEntries();
     }
 
     private BBox getBBox(Point p, int snapDistance) {
@@ -1431,7 +1313,7 @@ public class NavigatableComponent extends JComponent implements Helpful {
      * @return A unique ID, as long as viewport dimensions are the same
      */
     public int getViewID() {
-        String x = center.east() + "_" + center.north() + "_" + scale + "_" +
+        String x = getCenter().east() + "_" + getCenter().north() + "_" + getScale() + "_" +
                 getWidth() + "_" + getHeight() + "_" + getProjection().toString();
         CRC32 id = new CRC32();
         id.update(x.getBytes(StandardCharsets.UTF_8));
@@ -1465,6 +1347,24 @@ public class NavigatableComponent extends JComponent implements Helpful {
     }
 
     @Override
+    public void zoomChanged(NavigationModel navigationModel, ZoomData oldZoom, ZoomData newZoom) {
+        if (oldZoom == null) {
+            // initial.
+            return;
+        }
+        EastNorth oldCenter = oldZoom.getCenterEastNorth(getProjection());
+        EastNorth newCenter = newZoom.getCenterEastNorth(getProjection());
+        if (!newCenter.equals(oldCenter)) {
+            firePropertyChange(PROPNAME_CENTER, oldCenter, newCenter);
+        }
+        double oldScale = oldZoom.getScale();
+        double newScale = newZoom.getScale();
+        if (!Utils.equalsEpsilon(oldScale, newScale)) {
+            firePropertyChange(PROPNAME_SCALE, oldScale, newScale);
+        }
+        repaint();
+    }
+    @Override
     public void paint(Graphics g) {
         synchronized (paintRequestLock) {
             if (paintRect != null) {
diff --git a/src/org/openstreetmap/josm/gui/navigate/NavigationModel.java b/src/org/openstreetmap/josm/gui/navigate/NavigationModel.java
new file mode 100644
index 0000000..4ec3828
--- /dev/null
+++ b/src/org/openstreetmap/josm/gui/navigate/NavigationModel.java
@@ -0,0 +1,730 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.navigate;
+
+import java.awt.Component;
+import java.awt.Dimension;
+import java.awt.Point;
+import java.awt.event.ComponentAdapter;
+import java.awt.event.ComponentEvent;
+import java.awt.geom.AffineTransform;
+import java.awt.geom.Point2D;
+import java.lang.ref.WeakReference;
+import java.util.Date;
+import java.util.Stack;
+import java.util.Timer;
+import java.util.TimerTask;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import org.openstreetmap.josm.Main;
+import org.openstreetmap.josm.data.Bounds;
+import org.openstreetmap.josm.data.coor.CachedLatLon;
+import org.openstreetmap.josm.data.coor.EastNorth;
+import org.openstreetmap.josm.data.coor.LatLon;
+import org.openstreetmap.josm.data.projection.Projection;
+import org.openstreetmap.josm.data.projection.Projections;
+import org.openstreetmap.josm.gui.util.GuiHelper;
+import org.openstreetmap.josm.tools.Utils;
+
+/**
+ * This class manages the current position on the map and provides utility methods to convert between view and NorthEast coordinates. There are convenience methods to directly convert to LatLon.
+ *
+ * @author Michael Zangl
+ *
+ */
+public class NavigationModel {
+    /**
+     * Interface to notify listeners of the change of the zoom area.
+     */
+    public interface ZoomChangeListener {
+        /**
+         * Method called when the zoom area has changed.
+         * @param navigationModel The model firing the change.
+         * @param oldZoom The old zoom. Might be null on initial zoom.
+         * @param newZoom The new zoom.
+         */
+        void zoomChanged(NavigationModel navigationModel, ZoomData oldZoom, ZoomData newZoom);
+    }
+
+    /**
+     * This is a weak reference to a zoom listener. The weak reference auto-removes itsef if the referenced zoom change listener is no longer used.
+     * @author michael
+     *
+     */
+    public static class WeakZoomChangeListener implements ZoomChangeListener {
+        private WeakReference<ZoomChangeListener> l;
+
+        /**
+         * Creates a new, weak zoom listener.
+         * @param l The listener.
+         */
+        public WeakZoomChangeListener(ZoomChangeListener l) {
+            // Note: We might use reference queues to clear the reference earlier.
+            this.l = new WeakReference<>(l);
+        }
+
+        @Override
+        public void zoomChanged(NavigationModel navigationModel, ZoomData oldZoom, ZoomData newZoom) {
+            ZoomChangeListener listener = l.get();
+            if (listener != null) {
+                listener.zoomChanged(navigationModel, oldZoom, newZoom);
+            } else {
+                navigationModel.removeZoomChangeListener(listener);
+            }
+        }
+    }
+
+    /**
+     * The mode that is used to zoom to a given position on the map. Modes influence how the zoom undo stack is handled and if smooth zooming is used.
+     * @author Michael Zangl
+     */
+    public enum ScrollMode {
+        /**
+         * An initial zoom. This resets the zoom history and zooms immediately.
+         */
+        INITIAL,
+        /**
+         * Use the default scroll mode.
+         */
+        DEFAULT,
+        /**
+         * Immeadiately zoom to the position.
+         */
+        IMMEDIATE,
+        /**
+         * Animate a smooth, slow move to the position.
+         */
+        ANIMATE,
+        /**
+         * Animate a relatively fast change (200ms).
+         */
+        ANIMATE_FAST;
+
+        // Replace this with better methods?
+        private boolean resetHistory() {
+            return this == INITIAL;
+        }
+
+        private int animationTime() {
+            if (this == ANIMATE) {
+                return 1500;
+            } else if (this == ANIMATE_FAST) {
+                return 200;
+            } else {
+                return 0;
+            }
+        }
+    }
+
+    /**
+     * This stores a position on the screen (relative to one projection).
+     * @author michael
+     *
+     */
+    public static class ZoomData {
+        /**
+         * Center n/e coordinate of the desired screen center using the projection when this object was created.
+         */
+        private final EastNorth center;
+
+        /**
+         * The scale factor in x or y-units per pixel. This means, if scale = 10,
+         * every physical pixel on screen are 10 x or 10 y units in the
+         * northing/easting space of the projection.
+         */
+        private final double scale;
+
+        /**
+         * The projection used to compute this center.
+         */
+        private final Projection usedProjection;
+
+        /**
+         * Create a new {@link ZoomData} with any content.
+         */
+        public ZoomData() {
+            this(new EastNorth(0, 0), 1);
+        }
+
+        /**
+         * Interpolates between two zoom data instances.
+         * @param otherZoom The other zoom
+         * @param proportion How much the other zoom object influences the result so that the values (0..1) form a straight line between the two centers.
+         * @param projection The projection to used. Currently, we interpolate in EastNorth coordinates, but this could change (180° problem, ...).
+         * @return A new, interpolated ZoomData.
+         */
+        public ZoomData interpolate(ZoomData otherZoom, double proportion, Projection projection) {
+            EastNorth from = getCenterEastNorth(projection);
+            EastNorth to = otherZoom.getCenterEastNorth(projection);
+            EastNorth currentCenter = from.interpolate(to, proportion);
+            double currentScale = (1 - proportion) * getScale() + proportion * otherZoom.getScale();
+            return new ZoomData(currentCenter, currentScale, projection);
+        }
+
+        /**
+         * Create a new {@link ZoomData} using no specified projection.
+         * @param center The center to store.
+         * @param scale The scale to store.
+         */
+        public ZoomData(EastNorth center, double scale) {
+            this(center, scale, null);
+        }
+
+        /**
+         * Create a new {@link ZoomData} specified using the given projection.
+         * @param center The center to store.
+         * @param scale The scale to store.
+         * @param usedProjection The projection in which the center is.
+         */
+        public ZoomData(EastNorth center, double scale, Projection usedProjection) {
+            this.center = center;
+            this.scale = scale;
+            this.usedProjection = usedProjection;
+        }
+
+        /**
+         * Gets the center position.
+         * @param projection The projection to use to get the center. If this is not the projection this object was constructed with, the EastNorth position of the center in the new projection is returned.
+         * @return The center.
+         */
+        public EastNorth getCenterEastNorth(Projection projection) {
+            if (usedProjection == null || projection == null || usedProjection == projection) {
+                return center;
+            } else {
+                // we need to project the coordinates using the new projection.
+                LatLon latlon = usedProjection.eastNorth2latlon(center);
+                return projection.latlon2eastNorth(latlon);
+            }
+        }
+
+        /**
+         * Gets the scale.
+         * @return The scale.
+         */
+        public double getScale() {
+            return scale;
+        }
+
+        /**
+         * Checks if this ZoomData instance is almost the same as an other instance.
+         * @param otherData THe other instance.
+         * @return <code>true</code> if the centers are the same and the scale only differers a small amount.
+         */
+        public boolean isWithinTolerance(ZoomData otherData) {
+            return otherData.center.equals(this.center) && Utils.equalsEpsilon(otherData.scale, scale)
+                    && otherData.usedProjection == usedProjection;
+        }
+
+        /**
+         * Creates a new {@link ZoomData} that uses the new projection as base. This improves performance but has no other impacts on the behavior of the object.
+         * @param projection The projection
+         * @return A new, optimized {@link ZoomData}
+         */
+        public ZoomData usingProjection(Projection projection) {
+            return new ZoomData(getCenterEastNorth(projection), getScale(), projection);
+        }
+
+        @Override
+        public int hashCode() {
+            final int prime = 31;
+            int result = 1;
+            result = prime * result + ((center == null) ? 0 : center.hashCode());
+            long temp;
+            temp = Double.doubleToLongBits(scale);
+            result = prime * result + (int) (temp ^ (temp >>> 32));
+            result = prime * result + ((usedProjection == null) ? 0 : usedProjection.hashCode());
+            return result;
+        }
+
+        @Override
+        public boolean equals(Object obj) {
+            if (this == obj)
+                return true;
+            if (obj == null)
+                return false;
+            if (getClass() != obj.getClass())
+                return false;
+            ZoomData other = (ZoomData) obj;
+            if (center == null) {
+                if (other.center != null)
+                    return false;
+            } else if (!center.equals(other.center))
+                return false;
+            if (Double.doubleToLongBits(scale) != Double.doubleToLongBits(other.scale))
+                return false;
+            if (usedProjection == null) {
+                if (other.usedProjection != null)
+                    return false;
+            } else if (!usedProjection.equals(other.usedProjection))
+                return false;
+            return true;
+        }
+
+        @Override
+        public String toString() {
+            return "ZoomData [center=" + center + ", scale=" + scale + ", usedProjection=" + usedProjection + "]";
+        }
+
+        /**
+         * Gets the affine transform that converts the east/north coordinates to pixel coordinates with no view offset. The center of the view would be at (0,0)
+         * @return The current affine transform.
+         */
+        public AffineTransform getAffineTransform() {
+            return new AffineTransform(1.0 / scale, 0.0, 0.0, -1.0 / scale, -center.east() / scale, center.north()
+                    / scale);
+        }
+
+    }
+
+    private static class ZoomHistoryStack extends Stack<ZoomData> {
+        @Override
+        public ZoomData push(ZoomData item) {
+            ZoomData pushResult = super.push(item);
+            if (size() > Main.pref.getInteger("zoom.undo.max", 50)) {
+                remove(0);
+            }
+            return pushResult;
+        }
+    }
+
+    /**
+     * A {@link TimerTask} that is used for zoom to animations.
+     * @author Michael Zangl
+     *
+     */
+    private final class AnimateZoomToTimerTask extends TimerTask {
+        private final int animationTime;
+        private int time = 0;
+        private final ZoomData currentZoom;
+        private final ZoomData newZoom;
+
+        private AnimateZoomToTimerTask(int animationTime, ZoomData currentZoom, ZoomData newZoom) {
+            this.animationTime = animationTime;
+            this.currentZoom = currentZoom;
+            this.newZoom = newZoom;
+        }
+
+        @Override
+        public void run() {
+            double progress = Math.min((double) time / animationTime, 1);
+
+            // Make animation smooth
+            progress = (1 - Math.cos(progress * Math.PI)) / 2;
+            final ZoomData position = currentZoom.interpolate(newZoom, progress, getProjection());
+
+            GuiHelper.runInEDT(new Runnable() {
+                @Override
+                public void run() {
+                    realZoomToNoUndo(position, true);
+                }
+            });
+
+            if (time >= animationTime) {
+                cancel();
+            } else {
+                time += TIMER_PERIOD;
+            }
+        }
+    }
+
+    // 20 FPS should be enough.
+    private static final long TIMER_PERIOD = 50;
+
+    /**
+     * The current center/scale that is used.
+     */
+    private ZoomData currentZoom = new ZoomData();
+
+    /**
+     * The size of the navigation view. It is used to translate pixel coordinates.
+     */
+    private Dimension viewDimension = new Dimension(0, 0);
+
+    private final ZoomHistoryStack zoomUndoBuffer = new ZoomHistoryStack();
+    private final ZoomHistoryStack zoomRedoBuffer = new ZoomHistoryStack();
+    private Date zoomTimestamp = new Date();
+
+    /**
+     * the zoom listeners
+     */
+    private final CopyOnWriteArrayList<ZoomChangeListener> zoomChangeListeners = new CopyOnWriteArrayList<>();
+
+    /**
+     * A weak reference to the component of which we are tracking the size. That way, that component can get garbage collected while this model is still active.
+     */
+    private WeakReference<Component> trackedComponent = new WeakReference<Component>(null);
+
+    private final ComponentAdapter resizeAdapter = new ComponentAdapter() {
+        @Override
+        public void componentResized(ComponentEvent e) {
+            setViewportSize(e.getComponent().getSize());
+        }
+        @Override
+        public void componentShown(ComponentEvent e) {
+            componentResized(e);
+        }
+    };
+
+    private Timer zoomToTimer;
+
+    /**
+     * The zoomTo animation that is currently running.
+     */
+    private TimerTask currentZoomToAnimation;
+
+    /**
+     * @return Returns the center point. A copy is returned, so users cannot
+     *      change the center by accessing the return value. Use zoomTo instead.
+     */
+    public EastNorth getCenter() {
+        return currentZoom.getCenterEastNorth(getProjection());
+    }
+
+    /**
+     * Get the current scale factor. This is [delta in eastnorth]/[pixels].
+     * @return The scale.
+     */
+    public double getScale() {
+        return currentZoom.getScale();
+    }
+
+    /**
+     * Starts to listen to size change events for that component and adjusts our reference size whenever that component size changed.
+     * @param component The component to track.
+     */
+    public void trackComponentSize(Component component) {
+        Component trackedComponent = this.trackedComponent.get();
+        if (trackedComponent != null) {
+            trackedComponent.removeComponentListener(resizeAdapter);
+        }
+        component.addComponentListener(resizeAdapter);
+        this.trackedComponent = new WeakReference<Component>(component);
+        setViewportSize(component.getSize());
+    }
+
+    protected void setViewportSize(Dimension size) {
+        if (!size.equals(viewDimension)) {
+            this.viewDimension = size;
+            fireZoomChanged(currentZoom, currentZoom);
+        }
+    }
+
+    /**
+     * Zoom to the given coordinate while preserving the current scale.
+     *
+     * @param newCenter The center to zoom to.
+     * @param mode The animation mode to use for zooming.
+     */
+    public void zoomTo(EastNorth newCenter, ScrollMode mode) {
+        zoomTo(newCenter, getScale(), mode);
+    }
+
+    /**
+     * Zoom to the given coordinate and scale.
+     *
+     * @param newCenter The center to zoom to.
+     * @param newScale The scale to use.
+     */
+    public void zoomTo(EastNorth newCenter, double newScale) {
+        zoomTo(newCenter, newScale, ScrollMode.DEFAULT);
+    }
+
+    /**
+     * Zoom to the given coordinate and scale.
+     *
+     * @param newCenter The center to zoom to.
+     * @param newScale The scale to use.
+     * @param mode The animation mode to use for zooming.
+     */
+    public void zoomTo(EastNorth newCenter, double newScale, ScrollMode mode) {
+        if (newScale <= 0) {
+            throw new IllegalArgumentException("Scale (" + newScale + ") may not be negative.");
+        }
+        Bounds b = getProjection().getWorldBoundsLatLon();
+        LatLon cl = Projections.inverseProject(newCenter);
+        boolean changed = false;
+        double lat = cl.lat();
+        double lon = cl.lon();
+        if (lat < b.getMinLat()) {
+            changed = true;
+            lat = b.getMinLat();
+        } else if (lat > b.getMaxLat()) {
+            changed = true;
+            lat = b.getMaxLat();
+        }
+        if (lon < b.getMinLon()) {
+            changed = true;
+            lon = b.getMinLon();
+        } else if (lon > b.getMaxLon()) {
+            changed = true;
+            lon = b.getMaxLon();
+        }
+        if (changed) {
+            newCenter = Projections.project(new LatLon(lat, lon));
+        }
+        int centerX = viewDimension.width / 2;
+        int centerY = viewDimension.height / 2;
+        LatLon l1 = new LatLon(b.getMinLat(), lon);
+        LatLon l2 = new LatLon(b.getMaxLat(), lon);
+        EastNorth e1 = getProjection().latlon2eastNorth(l1);
+        EastNorth e2 = getProjection().latlon2eastNorth(l2);
+        double d = e2.north() - e1.north();
+        if (centerY > 0 && d < centerY * newScale) {
+            double newScaleH = d / centerY;
+            e1 = getProjection().latlon2eastNorth(new LatLon(lat, b.getMinLon()));
+            e2 = getProjection().latlon2eastNorth(new LatLon(lat, b.getMaxLon()));
+            d = e2.east() - e1.east();
+            if (centerX > 0 && d < centerX * newScale) {
+                newScale = Math.max(newScaleH, d / centerX);
+            }
+        } else if (centerY > 0) {
+            d = d / (l1.greatCircleDistance(l2) * centerY * 10);
+            if (newScale < d) {
+                newScale = d;
+            }
+        }
+
+        ZoomData newZoom = new ZoomData(newCenter, newScale, getProjection());
+        if (!newZoom.isWithinTolerance(currentZoom)) {
+            realZoomTo(newZoom, mode);
+        }
+    }
+
+    /**
+     * Zooms around a given point on the screen by a given factor.
+     * <p>
+     * The EastNorth position below that point on the screen will stay the same.
+     * @param screenPosition The position on the screen to zoom around.
+     * @param factor The factor to zoom by.
+     */
+    public void zoomToFactorAround(Point2D screenPosition, double factor) {
+        double newScale = getScale() * factor;
+        // New center position so that point under the mouse pointer stays the same place as it was before zooming
+        // You will get the formula by simplifying this expression: newCenter = oldCenter + mouseCoordinatesInNewZoom - mouseCoordinatesInOldZoom
+        zoomTo(new EastNorth(getCenter().east() - (screenPosition.getX() - viewDimension.width / 2.0)
+                * (newScale - getScale()), getCenter().north() + (screenPosition.getY() - viewDimension.height / 2.0)
+                * (newScale - getScale())), newScale);
+    }
+
+    /**
+     * Zoom to a position without checking it.
+     * @param newZoom The new zoom.
+     * @param mode The zoom mode
+     */
+    private void realZoomTo(ZoomData newZoom, ScrollMode mode) {
+        if (mode.resetHistory()) {
+            zoomRedoBuffer.clear();
+            zoomUndoBuffer.clear();
+        } else {
+            pushZoomUndo(newZoom);
+        }
+        realZoomToNoUndo(newZoom, mode);
+    }
+
+    private void realZoomToNoUndo(ZoomData newZoom, ScrollMode mode) {
+        final int animationTime = mode.animationTime();
+        if (animationTime > 0) {
+            if (currentZoomToAnimation != null) {
+                currentZoomToAnimation.cancel();
+            }
+            currentZoomToAnimation = new AnimateZoomToTimerTask(animationTime, currentZoom, newZoom);
+            if (zoomToTimer == null) {
+                zoomToTimer = new Timer("Zoom animation.");
+            }
+            zoomToTimer.schedule(currentZoomToAnimation, 0, TIMER_PERIOD);
+        } else {
+            realZoomToNoUndo(newZoom, mode != ScrollMode.INITIAL);
+        }
+    }
+
+    private void realZoomToNoUndo(ZoomData newZoom, boolean passOldZoomToListeners) {
+        if (!newZoom.equals(currentZoom)) {
+            ZoomData oldZoom = currentZoom;
+            currentZoom = newZoom;
+            // XXX: Do not fire if mode is initial ?
+            fireZoomChanged(passOldZoomToListeners ? oldZoom : null, currentZoom);
+        }
+    }
+
+    // ================ Zoom undo and redo ================
+
+    private void pushZoomUndo(ZoomData zoomData) {
+        Date now = new Date();
+        if ((now.getTime() - zoomTimestamp.getTime()) > (Main.pref.getDouble("zoom.undo.delay", 1.0) * 1000)) {
+            zoomUndoBuffer.push(zoomData);
+            zoomRedoBuffer.clear();
+        }
+        zoomTimestamp = now;
+    }
+
+    /**
+     * Zoom to the previous zoom position. This call is ignored if there is no previous position.
+     */
+    public void zoomPrevious() {
+        zoomInBuffer(zoomUndoBuffer, zoomRedoBuffer);
+    }
+
+    /**
+     * Zoom to the next zoom position. This call is ignored if there is no next position.
+     */
+    public void zoomNext() {
+        zoomInBuffer(zoomRedoBuffer, zoomUndoBuffer);
+    }
+
+    private void zoomInBuffer(ZoomHistoryStack takeFrom, ZoomHistoryStack pushTo) {
+        if (!takeFrom.isEmpty()) {
+            ZoomData zoom = takeFrom.pop();
+            pushTo.push(currentZoom);
+            realZoomToNoUndo(zoom.usingProjection(getProjection()), ScrollMode.DEFAULT);
+        }
+    }
+
+    /**
+     * Check if there are previous zoom entries.
+     * @return <code>true</code> if there are previous zoom entries and {@link #zoomPrevious()} can be used to zoom to them.
+     */
+    public boolean hasPreviousZoomEntries() {
+        return !zoomUndoBuffer.isEmpty();
+    }
+
+    /**
+     * Check if there are next zoom entries.
+     * @return <code>true</code> if there are next zoom entries and {@link #zoomNext()} can be used to zoom to them.
+     */
+    public boolean hasNextZoomEntries() {
+        return !zoomRedoBuffer.isEmpty();
+    }
+
+
+
+    // ================ Screen/EastNorth/LatLon conversion ================
+
+    /**
+     * Get the NorthEast coordinate for a given screen position.
+     * @param x X-Pixelposition to get coordinate from
+     * @param y Y-Pixelposition to get coordinate from
+     *
+     * @return Geographic coordinates from a specific pixel coordination on the screen.
+     */
+    public EastNorth getEastNorth(double x, double y) {
+        return new EastNorth(getCenter().east() + (x - viewDimension.width / 2.0) * getScale(), getCenter().north()
+                - (y - viewDimension.height / 2.0) * getScale());
+    }
+
+    /**
+     * Get the NorthEast coordinate for a given screen position.
+     * @param point The screen position
+     *
+     * @return Geographic coordinates from a specific pixel coordination on the screen.
+     */
+    public EastNorth getEastNorth(Point2D point) {
+        return getEastNorth(point.getX(), point.getY());
+    }
+
+    /**
+     * Gets an EastNorth position using relative screen coordinates.
+     * @param relativeX The x-positon, where the interval [0,1] is the screen width
+     * @param relativeY The x-positon, where the interval [0,1] is the screen height
+     * @return The geographic coordinates for that pixel.
+     */
+    public EastNorth getEastNorthRelative(double relativeX, double relativeY) {
+        return getEastNorth(relativeX * viewDimension.width, relativeY * viewDimension.height);
+    }
+
+    /**
+     * Get the lat/lon coordinate for a given screen position.
+     * @param point The screen position
+     *
+     * @return Geographic coordinates from a specific pixel coordination on the screen.
+     */
+    public LatLon getLatLon(Point2D point) {
+        return getProjection().eastNorth2latlon(getEastNorth(point));
+    }
+
+    /**
+     * Converts an east/north coordinate to a screen position.
+     * @param eastNorth The point to convert.
+     * @return An arbitrary point if p is <code>null</code>, the screen position (may be outside the screen) otherwise.
+     */
+    public Point2D getScreenPosition(EastNorth eastNorth) {
+        if (null == eastNorth) {
+            return new Point();
+        } else {
+            Point2D p2d = new Point2D.Double(eastNorth.east(), eastNorth.north());
+            return getAffineTransform().transform(p2d, null);
+        }
+    }
+
+    /**
+     * Converts a latlon coordinate to a screen position.
+     * @param latlon The point to convert.
+     * @return An arbitrary point if p is <code>null</code>, the screen position (may be outside the screen) otherwise.
+     */
+    public Point2D getScreenPosition(LatLon latlon) {
+        if (latlon == null) {
+            return new Point();
+        } else if (latlon instanceof CachedLatLon) {
+            return getScreenPosition(((CachedLatLon)latlon).getEastNorth());
+        } else {
+            return getScreenPosition(getProjection().latlon2eastNorth(latlon));
+        }
+    }
+
+    /**
+     * Gets the affine transform that converts the east/north coordinates to pixel coordinates.
+     * @return The current affine transform. Do not modify it.
+     */
+    public AffineTransform getAffineTransform() {
+        AffineTransform transform = AffineTransform.getTranslateInstance(viewDimension.width / 2,
+                viewDimension.height / 2);
+        transform.concatenate(currentZoom.getAffineTransform());
+        return transform;
+    }
+
+    /**
+     * Gets the horizontal distance in meters that a line of the length of n pixels would cover in the center of our view.
+     * @param pixel The number of pixels the line should have.
+     * @return The length in meters.
+     */
+    public double getPixelDistance(int pixel) {
+        double centerX = viewDimension.getWidth() / 2;
+        double centerY = viewDimension.getHeight() / 2;
+        LatLon ll1 = getLatLon(new Point2D.Double(centerX - pixel / 2.0, centerY));
+        LatLon ll2 = getLatLon(new Point2D.Double(centerX + pixel / 2.0, centerY));
+        return ll1.greatCircleDistance(ll2);
+    }
+
+    // ================ Zoom change listeners ================
+
+    /**
+     * @return The projection to be used in calculating stuff.
+     */
+    private Projection getProjection() {
+        return Main.getProjection();
+    }
+
+    /**
+     * Adds a zoom change listener
+     *
+     * @param listener the listener. Ignored if null or already registered.
+     */
+    public void addZoomChangeListener(ZoomChangeListener listener) {
+        if (listener != null) {
+            zoomChangeListeners.addIfAbsent(listener);
+        }
+    }
+
+    /**
+     * Removes a zoom change listener
+     *
+     * @param listener the listener. Ignored if null or already absent
+     */
+    public void removeZoomChangeListener(ZoomChangeListener listener) {
+        zoomChangeListeners.remove(listener);
+    }
+
+    protected void fireZoomChanged(ZoomData oldZoom, ZoomData currentZoom) {
+        for (ZoomChangeListener l : zoomChangeListeners) {
+            l.zoomChanged(this, oldZoom, currentZoom);
+        }
+    }
+}
diff --git a/test/unit/org/openstreetmap/josm/TestUtils.java b/test/unit/org/openstreetmap/josm/TestUtils.java
index 729e511..678fce0 100644
--- a/test/unit/org/openstreetmap/josm/TestUtils.java
+++ b/test/unit/org/openstreetmap/josm/TestUtils.java
@@ -1,11 +1,16 @@
 // License: GPL. For details, see LICENSE file.
 package org.openstreetmap.josm;
 
+import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.fail;
 
+import java.awt.geom.Point2D;
 import java.util.Arrays;
 import java.util.Comparator;
 
+import org.openstreetmap.josm.data.coor.EastNorth;
+import org.openstreetmap.josm.data.coor.LatLon;
+
 /**
  * Various utils, useful for unit tests.
  */
@@ -106,4 +111,35 @@ public final class TestUtils {
         .append("\nCompared\no2: ").append(o2).append("\no3: ").append(o3).append("\ngave: ").append(d)
         .toString();
     }
+
+    /**
+     * An assertion that fails if the provided coordinates are not the same (within the default server precision).
+     * @param expected The expected EastNorth coordinate.
+     * @param actual The actual value.
+     */
+    public static void assertEastNorthEquals(EastNorth expected, EastNorth actual) {
+        assertEquals("Wrong x coordinate.", expected.getX(), actual.getX(), LatLon.MAX_SERVER_PRECISION);
+        assertEquals("Wrong y coordinate.", expected.getY(), actual.getY(), LatLon.MAX_SERVER_PRECISION);
+    }
+
+    /**
+     * An assertion that fails if the provided coordinates are not the same (within the default server precision).
+     * @param expected The expected LatLon coordinate.
+     * @param actual The actual value.
+     */
+    public static void assertLatLonEquals(LatLon expected, LatLon actual) {
+        assertEquals("Wrong lat coordinate.", expected.getX(), actual.getX(), LatLon.MAX_SERVER_PRECISION);
+        assertEquals("Wrong lon coordinate.", expected.getY(), actual.getY(), LatLon.MAX_SERVER_PRECISION);
+    }
+
+    /**
+     * An assertion that fails if the provided points are not the same.
+     * @param expected The expected Point2D
+     * @param actual The actual value.
+     */
+    public static void assertPointEquals(Point2D expected, Point2D actual) {
+        if (expected.distance(actual) > 0.0000001) {
+            throw new AssertionError("Expected " + expected + " but got " + actual);
+        }
+    }
 }
diff --git a/test/unit/org/openstreetmap/josm/gui/NavigatableComponentTest.java b/test/unit/org/openstreetmap/josm/gui/NavigatableComponentTest.java
new file mode 100644
index 0000000..2e12d0f
--- /dev/null
+++ b/test/unit/org/openstreetmap/josm/gui/NavigatableComponentTest.java
@@ -0,0 +1,187 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui;
+
+import static org.junit.Assert.assertEquals;
+import static org.openstreetmap.josm.TestUtils.assertEastNorthEquals;
+import static org.openstreetmap.josm.TestUtils.assertLatLonEquals;
+import static org.openstreetmap.josm.TestUtils.assertPointEquals;
+
+import java.awt.Rectangle;
+import java.awt.geom.Point2D;
+
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.openstreetmap.josm.JOSMFixture;
+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.gui.util.GuiHelper;
+
+/**
+ * Some tests for the {@link NavigatableComponent} class.
+ * @author Michael Zangl
+ *
+ */
+public class NavigatableComponentTest {
+
+    private static final int HEIGHT = 200;
+    private static final int WIDTH = 300;
+    private NavigatableComponent component;
+
+    /**
+     * Setup test.
+     */
+    @BeforeClass
+    public static void setUp() {
+        JOSMFixture.createUnitTestFixture().init();
+    }
+
+    /**
+     * Create a new, fresh {@link NavigatableComponent}
+     */
+    @Before
+    public void setup() {
+        component = new NavigatableComponent();
+        component.setBounds(new Rectangle(WIDTH, HEIGHT));
+        // wait for the event to be propagated.
+        GuiHelper.runInEDTAndWait(new Runnable() {
+            @Override
+            public void run() {
+            }
+        });
+    }
+
+    /**
+     * Test if the default scale was set correctly.
+     */
+    @Test
+    public void testDefaultScale() {
+        assertEquals(Main.getProjection().getDefaultZoomInPPD(), component.getScale(), 0.00001);
+    }
+
+    /**
+     * Tests {@link NavigatableComponent#getPoint2D(EastNorth)}
+     */
+    @Test
+    public void testPoint2DEastNorth() {
+        assertPointEquals(new Point2D.Double(), component.getPoint2D((EastNorth) null));
+        Point2D shouldBeCenter = component.getPoint2D(component.getCenter());
+        assertPointEquals(new Point2D.Double(WIDTH / 2, HEIGHT / 2), shouldBeCenter);
+
+        EastNorth testPoint = component.getCenter().add(300 * component.getScale(), 200 * component.getScale());
+        Point2D testPointConverted = component.getPoint2D(testPoint);
+        assertPointEquals(new Point2D.Double(WIDTH / 2 + 300, HEIGHT / 2 - 200), testPointConverted);
+    }
+
+    /**
+     * TODO: Implement this test.
+     */
+    @Test
+    public void testPoint2DLatLon() {
+        assertPointEquals(new Point2D.Double(), component.getPoint2D((LatLon) null));
+        // TODO: Really test this.
+    }
+
+    /**
+     * Tests {@link NavigatableComponent#zoomTo(LatLon)
+     */
+    @Test
+    public void testZoomToLatLon() {
+        component.zoomTo(new LatLon(10, 10));
+        Point2D shouldBeCenter = component.getPoint2D(new LatLon(10, 10));
+        assertPointEquals(new Point2D.Double(WIDTH / 2, HEIGHT / 2), shouldBeCenter);
+    }
+
+    /**
+     * Tests {@link NavigatableComponent#zoomToFactor(double)} and {@link NavigatableComponent#zoomToFactor(EastNorth, double)}
+     */
+    @Test
+    public void testZoomToFactor() {
+        EastNorth center = component.getCenter();
+        double initialScale = component.getScale();
+
+        // zoomToFactor(double)
+        component.zoomToFactor(0.5);
+        assertEquals(initialScale / 2, component.getScale(), 0.00000001);
+        assertEastNorthEquals(center, component.getCenter());
+        component.zoomToFactor(2);
+        assertEquals(initialScale, component.getScale(), 0.00000001);
+        assertEastNorthEquals(center, component.getCenter());
+
+        // zoomToFactor(EastNorth, double)
+        EastNorth newCenter = new EastNorth(10, 20);
+        component.zoomToFactor(newCenter, 0.5);
+        assertEquals(initialScale / 2, component.getScale(), 0.00000001);
+        assertEastNorthEquals(newCenter, component.getCenter());
+        component.zoomToFactor(newCenter, 2);
+        assertEquals(initialScale, component.getScale(), 0.00000001);
+        assertEastNorthEquals(newCenter, component.getCenter());
+    }
+
+    /**
+     * Tests {@link NavigatableComponent#getEastNorth(int, int)
+     */
+    @Test
+    public void testGetEastNorth() {
+        EastNorth center = component.getCenter();
+        assertEastNorthEquals(center, component.getEastNorth(WIDTH / 2, HEIGHT / 2));
+
+        EastNorth testPoint = component.getCenter().add(WIDTH * component.getScale(), HEIGHT * component.getScale());
+        assertEastNorthEquals(testPoint, component.getEastNorth(3 * WIDTH / 2, -HEIGHT / 2));
+    }
+
+    /**
+     * Tests {@link NavigatableComponent#zoomToFactor(double, double, double)
+     */
+    @Test
+    public void testZoomToFactorCenter() {
+        // zoomToFactor(double, double, double)
+        // assumes getEastNorth works as expected
+        EastNorth testPoint1 = component.getEastNorth(0, 0);
+        EastNorth testPoint2 = component.getEastNorth(200, 150);
+        double initialScale = component.getScale();
+
+        component.zoomToFactor(0, 0, 0.5);
+        assertEquals(initialScale / 2, component.getScale(), 0.00000001);
+        assertEastNorthEquals(testPoint1, component.getEastNorth(0, 0));
+        component.zoomToFactor(0, 0, 2);
+        assertEquals(initialScale, component.getScale(), 0.00000001);
+        assertEastNorthEquals(testPoint1, component.getEastNorth(0, 0));
+
+        component.zoomToFactor(200, 150, 0.5);
+        assertEquals(initialScale / 2, component.getScale(), 0.00000001);
+        assertEastNorthEquals(testPoint2, component.getEastNorth(200, 150));
+        component.zoomToFactor(200, 150, 2);
+        assertEquals(initialScale, component.getScale(), 0.00000001);
+        assertEastNorthEquals(testPoint2, component.getEastNorth(200, 150));
+
+    }
+
+    /**
+     * Tests {@link NavigatableComponent#getProjectionBounds()}
+     */
+    @Test
+    public void testGetProjectionBounds() {
+        ProjectionBounds bounds = component.getProjectionBounds();
+        assertEastNorthEquals(component.getCenter(), bounds.getCenter());
+
+        assertEastNorthEquals(component.getEastNorth(0, HEIGHT), bounds.getMin());
+        assertEastNorthEquals(component.getEastNorth(WIDTH, 0), bounds.getMax());
+    }
+
+    /**
+     * Tests {@link NavigatableComponent#getRealBounds()}
+     */
+    @Test
+    public void testGetRealBounds() {
+        Bounds bounds = component.getRealBounds();
+        assertLatLonEquals(component.getLatLon(WIDTH / 2, HEIGHT / 2), bounds.getCenter());
+
+        assertLatLonEquals(component.getLatLon(0, HEIGHT), bounds.getMin());
+        assertLatLonEquals(component.getLatLon(WIDTH, 0), bounds.getMax());
+    }
+
+}
-- 
1.9.1

