From 3000c395b692fbd5b9056ea06eaef08eb345941f Mon Sep 17 00:00:00 2001
From: Hallvard Traetteberg <hal@ntnu.no>
Date: Fri, 12 Jan 2018 00:17:52 +0100
Subject: [PATCH] Imports and uses FxMapControl. Issue #10.

---
 tdt4140-gr1800/FxMapControl/.classpath        |  26 +
 tdt4140-gr1800/FxMapControl/.project          |  23 +
 .../org.eclipse.core.resources.prefs          |   3 +
 .../.settings/org.eclipse.jdt.core.prefs      |   5 +
 .../.settings/org.eclipse.m2e.core.prefs      |   4 +
 tdt4140-gr1800/FxMapControl/pom.xml           |  15 +
 .../AzimuthalEquidistantProjection.java       |  50 ++
 .../fxmapcontrol/AzimuthalProjection.java     | 133 ++++
 .../java/fxmapcontrol/BingMapsTileLayer.java  |  83 +++
 .../java/fxmapcontrol/BingMapsTileSource.java |  33 +
 .../fxmapcontrol/CenteredBoundingBox.java     |  39 ++
 .../EquirectangularProjection.java            |  59 ++
 .../java/fxmapcontrol/GnomonicProjection.java |  54 ++
 .../src/main/java/fxmapcontrol/IMapNode.java  |  15 +
 .../main/java/fxmapcontrol/ITileCache.java    |  33 +
 .../java/fxmapcontrol/ITileImageLoader.java   |  15 +
 .../java/fxmapcontrol/ImageFileCache.java     | 177 +++++
 .../src/main/java/fxmapcontrol/Location.java  |  72 ++
 .../src/main/java/fxmapcontrol/Map.java       | 160 +++++
 .../src/main/java/fxmapcontrol/MapBase.java   | 626 ++++++++++++++++++
 .../java/fxmapcontrol/MapBoundingBox.java     |  74 +++
 .../main/java/fxmapcontrol/MapGraticule.java  | 287 ++++++++
 .../src/main/java/fxmapcontrol/MapImage.java  |  75 +++
 .../main/java/fxmapcontrol/MapImageLayer.java | 348 ++++++++++
 .../src/main/java/fxmapcontrol/MapItem.java   |  61 ++
 .../main/java/fxmapcontrol/MapItemBase.java   |  69 ++
 .../java/fxmapcontrol/MapItemsControl.java    | 238 +++++++
 .../src/main/java/fxmapcontrol/MapLayer.java  |  46 ++
 .../src/main/java/fxmapcontrol/MapLine.java   | 103 +++
 .../src/main/java/fxmapcontrol/MapNode.java   |  97 +++
 .../main/java/fxmapcontrol/MapNodeHelper.java |  66 ++
 .../main/java/fxmapcontrol/MapPolygon.java    |  86 +++
 .../main/java/fxmapcontrol/MapPolyline.java   | 126 ++++
 .../main/java/fxmapcontrol/MapProjection.java | 170 +++++
 .../main/java/fxmapcontrol/MapTileLayer.java  | 369 +++++++++++
 .../fxmapcontrol/StereographicProjection.java |  54 ++
 .../src/main/java/fxmapcontrol/Tile.java      |  84 +++
 .../src/main/java/fxmapcontrol/TileGrid.java  |  67 ++
 .../java/fxmapcontrol/TileImageLoader.java    | 200 ++++++
 .../main/java/fxmapcontrol/TileSource.java    | 148 +++++
 .../fxmapcontrol/ViewportChangedEvent.java    |  42 ++
 .../fxmapcontrol/WebMercatorProjection.java   |  82 +++
 .../main/java/fxmapcontrol/WmsImageLayer.java | 209 ++++++
 tdt4140-gr1800/app.ui/pom.xml                 |  10 +-
 .../java/tdt4140/gr1800/app/ui/MapMarker.java |  26 +
 .../tdt4140/gr1800/app/ui/MapPathLine.java    |  24 +
 .../tdt4140/gr1800/app/ui/FxApp.fxml          |  56 +-
 .../java/tdt4140/gr1800/app/ui/FxAppTest.java |   5 +-
 tdt4140-gr1800/pom.xml                        |   2 +-
 tdt4140-gr18nn/pom.xml                        |   1 -
 50 files changed, 4814 insertions(+), 36 deletions(-)
 create mode 100644 tdt4140-gr1800/FxMapControl/.classpath
 create mode 100644 tdt4140-gr1800/FxMapControl/.project
 create mode 100644 tdt4140-gr1800/FxMapControl/.settings/org.eclipse.core.resources.prefs
 create mode 100644 tdt4140-gr1800/FxMapControl/.settings/org.eclipse.jdt.core.prefs
 create mode 100644 tdt4140-gr1800/FxMapControl/.settings/org.eclipse.m2e.core.prefs
 create mode 100644 tdt4140-gr1800/FxMapControl/pom.xml
 create mode 100644 tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/AzimuthalEquidistantProjection.java
 create mode 100644 tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/AzimuthalProjection.java
 create mode 100644 tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/BingMapsTileLayer.java
 create mode 100644 tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/BingMapsTileSource.java
 create mode 100644 tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/CenteredBoundingBox.java
 create mode 100644 tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/EquirectangularProjection.java
 create mode 100644 tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/GnomonicProjection.java
 create mode 100644 tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/IMapNode.java
 create mode 100644 tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/ITileCache.java
 create mode 100644 tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/ITileImageLoader.java
 create mode 100644 tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/ImageFileCache.java
 create mode 100644 tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/Location.java
 create mode 100644 tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/Map.java
 create mode 100644 tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapBase.java
 create mode 100644 tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapBoundingBox.java
 create mode 100644 tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapGraticule.java
 create mode 100644 tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapImage.java
 create mode 100644 tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapImageLayer.java
 create mode 100644 tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapItem.java
 create mode 100644 tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapItemBase.java
 create mode 100644 tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapItemsControl.java
 create mode 100644 tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapLayer.java
 create mode 100644 tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapLine.java
 create mode 100644 tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapNode.java
 create mode 100644 tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapNodeHelper.java
 create mode 100644 tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapPolygon.java
 create mode 100644 tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapPolyline.java
 create mode 100644 tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapProjection.java
 create mode 100644 tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapTileLayer.java
 create mode 100644 tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/StereographicProjection.java
 create mode 100644 tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/Tile.java
 create mode 100644 tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/TileGrid.java
 create mode 100644 tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/TileImageLoader.java
 create mode 100644 tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/TileSource.java
 create mode 100644 tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/ViewportChangedEvent.java
 create mode 100644 tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/WebMercatorProjection.java
 create mode 100644 tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/WmsImageLayer.java
 create mode 100644 tdt4140-gr1800/app.ui/src/main/java/tdt4140/gr1800/app/ui/MapMarker.java
 create mode 100644 tdt4140-gr1800/app.ui/src/main/java/tdt4140/gr1800/app/ui/MapPathLine.java

diff --git a/tdt4140-gr1800/FxMapControl/.classpath b/tdt4140-gr1800/FxMapControl/.classpath
new file mode 100644
index 0000000..af1430b
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/.classpath
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<classpath>
+	<classpathentry kind="src" output="target/classes" path="src/main/java">
+		<attributes>
+			<attribute name="optional" value="true"/>
+			<attribute name="maven.pomderived" value="true"/>
+		</attributes>
+	</classpathentry>
+	<classpathentry kind="src" output="target/test-classes" path="src/test/java">
+		<attributes>
+			<attribute name="optional" value="true"/>
+			<attribute name="maven.pomderived" value="true"/>
+		</attributes>
+	</classpathentry>
+	<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-1.8">
+		<attributes>
+			<attribute name="maven.pomderived" value="true"/>
+		</attributes>
+	</classpathentry>
+	<classpathentry kind="con" path="org.eclipse.m2e.MAVEN2_CLASSPATH_CONTAINER">
+		<attributes>
+			<attribute name="maven.pomderived" value="true"/>
+		</attributes>
+	</classpathentry>
+	<classpathentry kind="output" path="target/classes"/>
+</classpath>
diff --git a/tdt4140-gr1800/FxMapControl/.project b/tdt4140-gr1800/FxMapControl/.project
new file mode 100644
index 0000000..3038f0e
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/.project
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<projectDescription>
+	<name>fx-map-control</name>
+	<comment></comment>
+	<projects>
+	</projects>
+	<buildSpec>
+		<buildCommand>
+			<name>org.eclipse.jdt.core.javabuilder</name>
+			<arguments>
+			</arguments>
+		</buildCommand>
+		<buildCommand>
+			<name>org.eclipse.m2e.core.maven2Builder</name>
+			<arguments>
+			</arguments>
+		</buildCommand>
+	</buildSpec>
+	<natures>
+		<nature>org.eclipse.jdt.core.javanature</nature>
+		<nature>org.eclipse.m2e.core.maven2Nature</nature>
+	</natures>
+</projectDescription>
diff --git a/tdt4140-gr1800/FxMapControl/.settings/org.eclipse.core.resources.prefs b/tdt4140-gr1800/FxMapControl/.settings/org.eclipse.core.resources.prefs
new file mode 100644
index 0000000..e9441bb
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/.settings/org.eclipse.core.resources.prefs
@@ -0,0 +1,3 @@
+eclipse.preferences.version=1
+encoding//src/main/java=UTF-8
+encoding/<project>=UTF-8
diff --git a/tdt4140-gr1800/FxMapControl/.settings/org.eclipse.jdt.core.prefs b/tdt4140-gr1800/FxMapControl/.settings/org.eclipse.jdt.core.prefs
new file mode 100644
index 0000000..714351a
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/.settings/org.eclipse.jdt.core.prefs
@@ -0,0 +1,5 @@
+eclipse.preferences.version=1
+org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.8
+org.eclipse.jdt.core.compiler.compliance=1.8
+org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning
+org.eclipse.jdt.core.compiler.source=1.8
diff --git a/tdt4140-gr1800/FxMapControl/.settings/org.eclipse.m2e.core.prefs b/tdt4140-gr1800/FxMapControl/.settings/org.eclipse.m2e.core.prefs
new file mode 100644
index 0000000..f897a7f
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/.settings/org.eclipse.m2e.core.prefs
@@ -0,0 +1,4 @@
+activeProfiles=
+eclipse.preferences.version=1
+resolveWorkspaceProjects=true
+version=1
diff --git a/tdt4140-gr1800/FxMapControl/pom.xml b/tdt4140-gr1800/FxMapControl/pom.xml
new file mode 100644
index 0000000..b8847d8
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/pom.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+	<modelVersion>4.0.0</modelVersion>
+	<name>FxMapControl</name>
+	<groupId>fischer.clemens</groupId>
+	<artifactId>fx-map-control</artifactId>
+	<version>1.0</version>
+	<packaging>jar</packaging>
+	<properties>
+		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+		<maven.compiler.source>1.8</maven.compiler.source>
+		<maven.compiler.target>1.8</maven.compiler.target>
+	</properties>
+</project>
diff --git a/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/AzimuthalEquidistantProjection.java b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/AzimuthalEquidistantProjection.java
new file mode 100644
index 0000000..9d5db19
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/AzimuthalEquidistantProjection.java
@@ -0,0 +1,50 @@
+/*
+ * FX Map Control - https://github.com/ClemensFischer/FX-Map-Control
+ * © 2017 Clemens Fischer
+ */
+package fxmapcontrol;
+
+import javafx.geometry.Point2D;
+
+/**
+ * Transforms geographic coordinates to cartesian coordinates according to the Azimuthal Equidistant
+ * Projection.
+ */
+public class AzimuthalEquidistantProjection extends AzimuthalProjection {
+
+    public AzimuthalEquidistantProjection() {
+        // No known standard or de-facto standard CRS ID
+    }
+
+    public AzimuthalEquidistantProjection(String crsId) {
+        this.crsId = crsId;
+    }
+
+    @Override
+    public Point2D locationToPoint(Location location) {
+        if (location.equals(projectionCenter)) {
+            return new Point2D(0d, 0d);
+        }
+
+        double[] azimuthDistance = getAzimuthDistance(projectionCenter, location);
+        double azimuth = azimuthDistance[0];
+        double distance = WGS84_EQUATORIAL_RADIUS * azimuthDistance[1];
+
+        return new Point2D(distance * Math.sin(azimuth), distance * Math.cos(azimuth));
+    }
+
+    @Override
+    public Location pointToLocation(Point2D point) {
+        double x = point.getX();
+        double y = point.getY();
+
+        if (x == 0d && y == 0d) {
+            return projectionCenter;
+        }
+
+        double azimuth = Math.atan2(x, y);
+        double distance = Math.sqrt(x * x + y * y) / WGS84_EQUATORIAL_RADIUS;
+
+        return getLocation(projectionCenter, azimuth, distance);
+    }
+}
diff --git a/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/AzimuthalProjection.java b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/AzimuthalProjection.java
new file mode 100644
index 0000000..2715171
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/AzimuthalProjection.java
@@ -0,0 +1,133 @@
+/*
+ * FX Map Control - https://github.com/ClemensFischer/FX-Map-Control
+ * © 2017 Clemens Fischer
+ */
+package fxmapcontrol;
+
+import java.util.Locale;
+import javafx.geometry.BoundingBox;
+import javafx.geometry.Bounds;
+import javafx.geometry.Point2D;
+
+/**
+ * Base class for azimuthal map projections.
+ */
+public abstract class AzimuthalProjection extends MapProjection {
+
+    protected Location projectionCenter = new Location(0d, 0d);
+
+    @Override
+    public boolean isWebMercator() {
+        return false;
+    }
+
+    @Override
+    public boolean isNormalCylindrical() {
+        return false;
+    }
+
+    @Override
+    public boolean isAzimuthal() {
+        return true;
+    }
+
+    @Override
+    public double maxLatitude() {
+        return 90d;
+    }
+
+    @Override
+    public Point2D getMapScale(Location location) {
+        return new Point2D(viewportScale, viewportScale);
+    }
+
+    @Override
+    public Bounds boundingBoxToBounds(MapBoundingBox boundingBox) {
+        if (boundingBox instanceof CenteredBoundingBox) {
+            CenteredBoundingBox cbbox = (CenteredBoundingBox) boundingBox;
+            Point2D center = locationToPoint(cbbox.getCenter());
+            double width = cbbox.getWidth();
+            double height = cbbox.getHeight();
+
+            return new BoundingBox(center.getX() - width / 2d, center.getY() - height / 2d, width, height);
+        }
+
+        return super.boundingBoxToBounds(boundingBox);
+    }
+
+    @Override
+    public MapBoundingBox boundsToBoundingBox(Bounds bounds) {
+        Location center = pointToLocation(new Point2D(
+                bounds.getMinX() + bounds.getWidth() / 2d,
+                bounds.getMinY() + bounds.getHeight() / 2d));
+
+        return new CenteredBoundingBox(center, bounds.getWidth(), bounds.getHeight()); // width and height in meters
+    }
+
+    @Override
+    public double getViewportScale(double zoomLevel) {
+        return super.getViewportScale(zoomLevel) / METERS_PER_DEGREE;
+    }
+
+    @Override
+    public void setViewportTransform(Location projectionCenter, Location mapCenter, Point2D viewportCenter, double zoomLevel, double heading) {
+        this.projectionCenter = projectionCenter;
+        super.setViewportTransform(projectionCenter, mapCenter, viewportCenter, zoomLevel, heading);
+    }
+
+    @Override
+    public String wmsQueryParameters(MapBoundingBox boundingBox, String version) {
+        if (crsId == null || crsId.isEmpty()) {
+            return null;
+        }
+
+        Bounds bounds = boundingBoxToBounds(boundingBox);
+        String crs = version.startsWith("1.1.") ? "SRS" : "CRS";
+
+        return String.format(Locale.ROOT,
+                "%s=%s,1,%f,%f&BBOX=%f,%f,%f,%f&WIDTH=%d&HEIGHT=%d",
+                crs, crsId, projectionCenter.getLongitude(), projectionCenter.getLatitude(),
+                bounds.getMinX(), bounds.getMinY(), bounds.getMaxX(), bounds.getMaxY(),
+                (int) Math.round(viewportScale * bounds.getWidth()),
+                (int) Math.round(viewportScale * bounds.getHeight()));
+    }
+
+    /**
+     * Calculates azimuth and distance in radians from location1 to location2.
+     */
+    public static double[] getAzimuthDistance(Location location1, Location location2) {
+        double lat1 = location1.getLatitude() * Math.PI / 180d;
+        double lon1 = location1.getLongitude() * Math.PI / 180d;
+        double lat2 = location2.getLatitude() * Math.PI / 180d;
+        double lon2 = location2.getLongitude() * Math.PI / 180d;
+        double cosLat1 = Math.cos(lat1);
+        double sinLat1 = Math.sin(lat1);
+        double cosLat2 = Math.cos(lat2);
+        double sinLat2 = Math.sin(lat2);
+        double cosLon12 = Math.cos(lon2 - lon1);
+        double sinLon12 = Math.sin(lon2 - lon1);
+        double cosDistance = sinLat1 * sinLat2 + cosLat1 * cosLat2 * cosLon12;
+        double azimuth = Math.atan2(sinLon12, cosLat1 * sinLat2 / cosLat2 - sinLat1 * cosLon12);
+        double distance = Math.acos(Math.max(Math.min(cosDistance, 1d), -1d));
+
+        return new double[]{azimuth, distance};
+    }
+
+    /**
+     * Calculates the Location of the point given by azimuth and distance in radians from location.
+     */
+    public static Location getLocation(Location location, double azimuth, double distance) {
+        double lat = location.getLatitude() * Math.PI / 180d;
+        double sinDistance = Math.sin(distance);
+        double cosDistance = Math.cos(distance);
+        double cosAzimuth = Math.cos(azimuth);
+        double sinAzimuth = Math.sin(azimuth);
+        double cosLat1 = Math.cos(lat);
+        double sinLat1 = Math.sin(lat);
+        double sinLat2 = sinLat1 * cosDistance + cosLat1 * sinDistance * cosAzimuth;
+        double lat2 = Math.asin(Math.max(Math.min(sinLat2, 1d), -1d));
+        double dLon = Math.atan2(sinDistance * sinAzimuth, cosLat1 * cosDistance - sinLat1 * sinDistance * cosAzimuth);
+
+        return new Location(180d / Math.PI * lat2, location.getLongitude() + 180d / Math.PI * dLon);
+    }
+}
diff --git a/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/BingMapsTileLayer.java b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/BingMapsTileLayer.java
new file mode 100644
index 0000000..ce57a7e
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/BingMapsTileLayer.java
@@ -0,0 +1,83 @@
+/*
+ * FX Map Control - https://github.com/ClemensFischer/FX-Map-Control
+ * © 2016 Clemens Fischer
+ */
+package fxmapcontrol;
+
+import java.io.IOException;
+import java.net.URL;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.parsers.ParserConfigurationException;
+import org.w3c.dom.DOMException;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.NodeList;
+import org.xml.sax.SAXException;
+
+/**
+ * Displays Bing Maps tiles. The static apiKey property must be set to a Bing Maps API Key.
+ */
+public class BingMapsTileLayer extends MapTileLayer {
+
+    public enum MapMode {
+        Road, Aerial, AerialWithLabels
+    }
+
+    private static String apiKey;
+    private final MapMode mapMode;
+
+    public BingMapsTileLayer(MapMode mode) {
+        this(new TileImageLoader(), mode);
+    }
+
+    public BingMapsTileLayer(ITileImageLoader tileImageLoader, MapMode mode) {
+        super(tileImageLoader);
+
+        mapMode = mode;
+
+        if (apiKey == null || apiKey.isEmpty()) {
+            Logger.getLogger(BingMapsTileLayer.class.getName()).log(Level.SEVERE, "BingMapsTileLayer requires a Bing Maps API Key.");
+
+        } else {
+            try {
+                String url = String.format("http://dev.virtualearth.net/REST/V1/Imagery/Metadata/%s?output=xml&key=%s", mapMode.toString(), apiKey);
+                Document document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(new URL(url).openStream());
+
+                Element metadataElement = (Element) document.getElementsByTagName("ImageryMetadata").item(0);
+                Element imageUrlElement = (Element) metadataElement.getElementsByTagName("ImageUrl").item(0);
+                Element subdomainsElement = (Element) metadataElement.getElementsByTagName("ImageUrlSubdomains").item(0);
+                Element zoomMinElement = (Element) metadataElement.getElementsByTagName("ZoomMin").item(0);
+                Element zoomMaxElement = (Element) metadataElement.getElementsByTagName("ZoomMax").item(0);
+
+                NodeList subdomainStrings = subdomainsElement.getElementsByTagName("string");
+                String[] subdomains = new String[subdomainStrings.getLength()];
+
+                for (int i = 0; i < subdomains.length; i++) {
+                    subdomains[i] = subdomainStrings.item(i).getTextContent();
+                }
+
+                setName("Bing Maps " + mapMode);
+                setTileSource(new BingMapsTileSource(imageUrlElement.getTextContent(), subdomains));
+                setMinZoomLevel(Integer.parseInt(zoomMinElement.getTextContent()));
+                setMaxZoomLevel(Integer.parseInt(zoomMaxElement.getTextContent()));
+
+            } catch (IOException | ParserConfigurationException | SAXException | DOMException ex) {
+                Logger.getLogger(BingMapsTileLayer.class.getName()).log(Level.SEVERE, null, ex);
+            }
+        }
+    }
+
+    public static String getApiKey() {
+        return apiKey;
+    }
+
+    public static void setApiKey(String key) {
+        apiKey = key;
+    }
+
+    public final MapMode getMapMode() {
+        return mapMode;
+    }
+}
diff --git a/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/BingMapsTileSource.java b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/BingMapsTileSource.java
new file mode 100644
index 0000000..e4001af
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/BingMapsTileSource.java
@@ -0,0 +1,33 @@
+/*
+ * FX Map Control - https://github.com/ClemensFischer/FX-Map-Control
+ * © 2016 Clemens Fischer
+ */
+package fxmapcontrol;
+
+class BingMapsTileSource extends TileSource {
+
+    private final String[] subdomains;
+
+    public BingMapsTileSource(String urlFormat, String[] subdomains) {
+        super(urlFormat);
+        this.subdomains = subdomains;
+    }
+
+    @Override
+    public String getUrl(int x, int y, int zoomLevel) {
+        if (zoomLevel < 1) {
+            return null;
+        }
+
+        String subdomain = subdomains[(x + y) % subdomains.length];
+        char[] quadkey = new char[zoomLevel];
+
+        for (int z = zoomLevel - 1; z >= 0; z--, x /= 2, y /= 2) {
+            quadkey[z] = (char) ('0' + 2 * (y % 2) + (x % 2));
+        }
+
+        return getUrlFormat()
+                .replace("{subdomain}", subdomain)
+                .replace("{quadkey}", new String(quadkey));
+    }
+}
diff --git a/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/CenteredBoundingBox.java b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/CenteredBoundingBox.java
new file mode 100644
index 0000000..acfd258
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/CenteredBoundingBox.java
@@ -0,0 +1,39 @@
+/*
+ * FX Map Control - https://github.com/ClemensFischer/FX-Map-Control
+ * © 2017 Clemens Fischer
+ */
+package fxmapcontrol;
+
+/**
+ */
+public class CenteredBoundingBox extends MapBoundingBox {
+
+    private final Location center;
+    private final double width;
+    private final double height;
+
+    public CenteredBoundingBox(Location center, double width, double height) {
+        this.center = center;
+        this.width = width;
+        this.height = height;
+    }
+
+    public Location getCenter() {
+        return center;
+    }
+
+    @Override
+    public double getWidth() {
+        return width;
+    }
+
+    @Override
+    public double getHeight() {
+        return height;
+    }
+
+    @Override
+    public MapBoundingBox clone() {
+        return new CenteredBoundingBox(center, width, height);
+    }
+}
diff --git a/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/EquirectangularProjection.java b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/EquirectangularProjection.java
new file mode 100644
index 0000000..eb69442
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/EquirectangularProjection.java
@@ -0,0 +1,59 @@
+/*
+ * FX Map Control - https://github.com/ClemensFischer/FX-Map-Control
+ * © 2017 Clemens Fischer
+ */
+package fxmapcontrol;
+
+import javafx.geometry.Point2D;
+
+/**
+ * Transforms geographic coordinates to cartesian coordinates according to the Equirectangular
+ * Projection. Longitude and Latitude values are transformed identically to X and Y.
+ */
+public class EquirectangularProjection extends MapProjection {
+
+    public EquirectangularProjection() {
+        this("EPSG:4326");
+    }
+
+    public EquirectangularProjection(String crsId) {
+        this.crsId = crsId;
+    }
+
+    @Override
+    public boolean isWebMercator() {
+        return false;
+    }
+
+    @Override
+    public boolean isNormalCylindrical() {
+        return true;
+    }
+
+    @Override
+    public boolean isAzimuthal() {
+        return false;
+    }
+
+    @Override
+    public double maxLatitude() {
+        return 90d;
+    }
+
+    @Override
+    public Point2D getMapScale(Location location) {
+        return new Point2D(
+            viewportScale / (METERS_PER_DEGREE * Math.cos(location.getLatitude() * Math.PI / 180d)),
+            viewportScale / METERS_PER_DEGREE);
+    }
+
+    @Override
+    public Point2D locationToPoint(Location location) {
+        return new Point2D(location.getLongitude(), location.getLatitude());
+    }
+
+    @Override
+    public Location pointToLocation(Point2D point) {
+        return new Location(point.getY(), point.getX());
+    }
+}
diff --git a/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/GnomonicProjection.java b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/GnomonicProjection.java
new file mode 100644
index 0000000..b79c9dc
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/GnomonicProjection.java
@@ -0,0 +1,54 @@
+/*
+ * FX Map Control - https://github.com/ClemensFischer/FX-Map-Control
+ * © 2017 Clemens Fischer
+ */
+package fxmapcontrol;
+
+import static fxmapcontrol.AzimuthalProjection.getAzimuthDistance;
+import javafx.geometry.Point2D;
+import static fxmapcontrol.AzimuthalProjection.getLocation;
+
+/**
+ * Transforms geographic coordinates to cartesian coordinates according to the Gnomonic Projection.
+ */
+public class GnomonicProjection extends AzimuthalProjection {
+
+    public GnomonicProjection() {
+        this("AUTO2:97001"); // GeoServer non-standard CRS ID
+    }
+
+    public GnomonicProjection(String crsId) {
+        this.crsId = crsId;
+    }
+
+    @Override
+    public Point2D locationToPoint(Location location) {
+        if (location.equals(projectionCenter)) {
+            return new Point2D(0d, 0d);
+        }
+
+        double[] azimuthDistance = getAzimuthDistance(projectionCenter, location);
+        double azimuth = azimuthDistance[0];
+        double distance = azimuthDistance[1];
+        double mapDistance = distance < Math.PI / 2d ? WGS84_EQUATORIAL_RADIUS * Math.tan(distance) : Double.POSITIVE_INFINITY;
+
+        return new Point2D(mapDistance * Math.sin(azimuth), mapDistance * Math.cos(azimuth));
+    }
+
+    @Override
+    public Location pointToLocation(Point2D point) {
+        double x = point.getX();
+        double y = point.getY();
+
+        if (x == 0d && y == 0d) {
+            return projectionCenter;
+        }
+
+        double azimuth = Math.atan2(x, y);
+        double mapDistance = Math.sqrt(x * x + y * y);
+        double distance = Math.atan(mapDistance / WGS84_EQUATORIAL_RADIUS);
+
+        return getLocation(projectionCenter, azimuth, distance);
+    }
+
+}
diff --git a/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/IMapNode.java b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/IMapNode.java
new file mode 100644
index 0000000..7c9f107
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/IMapNode.java
@@ -0,0 +1,15 @@
+/*
+ * FX Map Control - https://github.com/ClemensFischer/FX-Map-Control
+ * © 2016 Clemens Fischer
+ */
+package fxmapcontrol;
+
+/**
+ * Represents a Node in the visual tree of a MapBase instance.
+ */
+public interface IMapNode {
+
+    MapBase getMap();
+
+    void setMap(MapBase map);
+}
diff --git a/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/ITileCache.java b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/ITileCache.java
new file mode 100644
index 0000000..cfcb522
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/ITileCache.java
@@ -0,0 +1,33 @@
+/*
+ * FX Map Control - https://github.com/ClemensFischer/FX-Map-Control
+ * © 2016 Clemens Fischer
+ */
+package fxmapcontrol;
+
+/**
+ * Provides methods for caching tile image buffers.
+ */
+public interface ITileCache {
+
+    public static class CacheItem {
+        private final byte[] buffer;
+        private final long expiration; // milliseconds since 1970/01/01 00:00:00 UTC
+
+        public CacheItem(byte[] buffer, long expiration) {
+            this.buffer = buffer;
+            this.expiration = expiration;
+        }
+
+        public final byte[] getBuffer() {
+            return buffer;
+        }
+
+        public final long getExpiration() {
+            return expiration;
+        }
+    }
+
+    CacheItem get(String tileLayerName, int x, int y, int zoomLevel);
+
+    void set(String tileLayerName, int x, int y, int zoomLevel, byte[] buffer, long expiration);
+}
diff --git a/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/ITileImageLoader.java b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/ITileImageLoader.java
new file mode 100644
index 0000000..9cf04aa
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/ITileImageLoader.java
@@ -0,0 +1,15 @@
+/*
+ * FX Map Control - https://github.com/ClemensFischer/FX-Map-Control
+ * © 2016 Clemens Fischer
+ */
+package fxmapcontrol;
+
+/**
+ * Provides methods to begin and cancel loading of map tile images for a specific MapTileLayer.
+ */
+public interface ITileImageLoader {
+
+    void beginLoadTiles(MapTileLayer tileLayer, Iterable<Tile> tiles);
+
+    void cancelLoadTiles(MapTileLayer tileLayer);
+}
diff --git a/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/ImageFileCache.java b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/ImageFileCache.java
new file mode 100644
index 0000000..29c337c
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/ImageFileCache.java
@@ -0,0 +1,177 @@
+/*
+ * FX Map Control - https://github.com/ClemensFischer/FX-Map-Control
+ * © 2016 Clemens Fischer
+ */
+package fxmapcontrol;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Default ITileCache implementation. Caches tile image files in a directory given by the rootDirectory property.
+ */
+public class ImageFileCache implements ITileCache {
+
+    // For compatibility with XAML Map Control ImageFileCache, expiration dates are stored as .NET DateTime ticks,
+    // i.e. 100-nanosecond intervals since 0001/01/01 00:00:00 UTC. The DATETIME_OFFSET and DATETIME_FACTOR constants
+    // are used to convert to and from java.util.Date milliseconds since 1970/01/01 00:00:00 UTC.
+    private static final long DATETIME_OFFSET = 62135596800000L;
+    private static final long DATETIME_FACTOR = 10000L;
+    private static final ByteBuffer EXPIRATION_MARKER = ByteBuffer.wrap("EXPIRES:".getBytes(StandardCharsets.US_ASCII));
+
+    private final Path rootDirectory;
+
+    public ImageFileCache(Path rootDirectory) {
+        this.rootDirectory = rootDirectory;
+        //System.out.println(rootDirectory.toAbsolutePath());
+    }
+
+    public ImageFileCache() {
+        this(getDefaultRootDirectory());
+    }
+
+    public static final Path getDefaultRootDirectory() {
+        String osName = System.getProperty("os.name").toLowerCase();
+
+        if (osName.contains("windows")) {
+            String programData = System.getenv("ProgramData");
+
+            if (programData != null) {
+                // use default XAML Map Control cache directory
+                return Paths.get(programData, "MapControl", "TileCache");
+            }
+        } else {//if (osName.contains("linux")) {
+            return Paths.get("/var", "tmp", "FxMapControl-Cache");
+        }
+
+        return null;
+    }
+
+    public final Path getRootDirectory() {
+        return rootDirectory;
+    }
+
+    @Override
+    public CacheItem get(String tileLayerName, int x, int y, int zoomLevel) {
+        File cacheDir = rootDirectory
+                .resolve(tileLayerName)
+                .resolve(Integer.toString(zoomLevel))
+                .resolve(Integer.toString(x)).toFile();
+        String fileNameFilter = Integer.toString(y) + ".";
+
+        if (cacheDir.isDirectory()) {
+            File[] cacheFiles = cacheDir.listFiles((dir, name) -> name.startsWith(fileNameFilter));
+
+            if (cacheFiles.length > 0) {
+                //System.out.println("Reading " + cacheFiles[0].getPath());
+                try {
+                    byte[] buffer = new byte[(int) cacheFiles[0].length()];
+                    long expiration = 0;
+
+                    try (FileInputStream fileStream = new FileInputStream(cacheFiles[0])) {
+                        fileStream.read(buffer);
+                    }
+
+                    if (buffer.length >= 16 && ByteBuffer.wrap(buffer, buffer.length - 16, 8).equals(EXPIRATION_MARKER)) {
+                        expiration = ByteBuffer.wrap(buffer, buffer.length - 8, 8).order(ByteOrder.LITTLE_ENDIAN)
+                                .getLong() / DATETIME_FACTOR - DATETIME_OFFSET;
+                    }
+
+                    return new CacheItem(buffer, expiration);
+
+                } catch (IOException ex) {
+                    Logger.getLogger(ImageFileCache.class.getName()).log(Level.WARNING, ex.toString());
+                }
+            }
+        }
+
+        return null;
+    }
+
+    @Override
+    public void set(String tileLayerName, int x, int y, int zoomLevel, byte[] buffer, long expiration) {
+        File cacheFile = rootDirectory
+                .resolve(tileLayerName)
+                .resolve(Integer.toString(zoomLevel))
+                .resolve(Integer.toString(x))
+                .resolve(String.format("%d%s", y, getFileExtension(buffer))).toFile();
+
+        //System.out.println("Writing " + cacheFile.getPath() + ", Expires " + new Date(expiration));
+        try {
+            cacheFile.getParentFile().mkdirs();
+
+            try (FileOutputStream fileStream = new FileOutputStream(cacheFile)) {
+                fileStream.write(buffer, 0, buffer.length);
+                fileStream.write(EXPIRATION_MARKER.array());
+                fileStream.write(ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN)
+                        .putLong((expiration + DATETIME_OFFSET) * DATETIME_FACTOR).array());
+            }
+
+            cacheFile.setReadable(true, false);
+            cacheFile.setWritable(true, false);
+        } catch (IOException ex) {
+            Logger.getLogger(ImageFileCache.class.getName()).log(Level.WARNING, ex.toString());
+        }
+    }
+
+    private static String getFileExtension(byte[] buffer) {
+        if (buffer.length >= 8
+                && buffer[0] == (byte) 0x89
+                && buffer[1] == (byte) 0x50
+                && buffer[2] == (byte) 0x4E
+                && buffer[3] == (byte) 0x47
+                && buffer[4] == (byte) 0x0D
+                && buffer[5] == (byte) 0x0A
+                && buffer[6] == (byte) 0x1A
+                && buffer[7] == (byte) 0x0A) {
+            return ".png";
+        }
+
+        if (buffer.length >= 3
+                && buffer[0] == (byte) 0xFF
+                && buffer[1] == (byte) 0xD8
+                && buffer[2] == (byte) 0xFF) {
+            return ".jpg";
+        }
+
+        if (buffer.length >= 3
+                && buffer[0] == (byte) 0x47
+                && buffer[1] == (byte) 0x49
+                && buffer[2] == (byte) 0x46) {
+            return ".gif";
+        }
+
+        if (buffer.length >= 2
+                && buffer[0] == (byte) 0x42
+                && buffer[1] == (byte) 0x4D) {
+            return ".bmp";
+        }
+
+        if (buffer.length >= 4
+                && buffer[0] == (byte) 0x49
+                && buffer[1] == (byte) 0x49
+                && buffer[2] == (byte) 0x2A
+                && buffer[3] == (byte) 0x00) {
+            return ".tif";
+        }
+
+        if (buffer.length >= 4
+                && buffer[0] == (byte) 0x4D
+                && buffer[1] == (byte) 0x4D
+                && buffer[2] == (byte) 0x00
+                && buffer[3] == (byte) 0x2A) {
+            return ".tif";
+        }
+
+        return ".bin";
+    }
+}
diff --git a/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/Location.java b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/Location.java
new file mode 100644
index 0000000..8d656a6
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/Location.java
@@ -0,0 +1,72 @@
+/*
+ * FX Map Control - https://github.com/ClemensFischer/FX-Map-Control
+ * © 2016 Clemens Fischer
+ */
+package fxmapcontrol;
+
+/**
+ * A geographic location with latitude and longitude values in degrees.
+ */
+public class Location {
+
+    private final double latitude;
+    private final double longitude;
+
+    public Location(double latitude, double longitude) {
+        this.latitude = latitude;
+        this.longitude = longitude;
+    }
+
+    public final double getLatitude() {
+        return latitude;
+    }
+
+    public final double getLongitude() {
+        return longitude;
+    }
+
+    public final boolean equals(Location location) {
+        return this == location
+                || (latitude == location.latitude && longitude == location.longitude);
+    }
+
+    @Override
+    public final boolean equals(Object obj) {
+        return (obj instanceof Location) && equals((Location) obj);
+    }
+
+    @Override
+    public int hashCode() {
+        return Double.hashCode(latitude) ^ Double.hashCode(longitude);
+    }
+
+    public static Location valueOf(String locationString) {
+        String[] pair = locationString.split(",");
+        if (pair.length != 2) {
+            throw new IllegalArgumentException(
+                    "Location string must be a comma-separated pair of double values");
+        }
+        return new Location(
+                Double.parseDouble(pair[0]),
+                Double.parseDouble(pair[1]));
+    }
+
+    public static double normalizeLongitude(double longitude) {
+        if (longitude < -180.) {
+            longitude = ((longitude + 180.) % 360.) + 180.;
+        } else if (longitude > 180.) {
+            longitude = ((longitude - 180.) % 360.) - 180.;
+        }
+        return longitude;
+    }
+
+    static double nearestLongitude(double longitude, double referenceLongitude) {
+        longitude = normalizeLongitude(longitude);
+        if (longitude > referenceLongitude + 180d) {
+            longitude -= 360d;
+        } else if (longitude < referenceLongitude - 180d) {
+            longitude += 360d;
+        }
+        return longitude;
+    }
+}
diff --git a/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/Map.java b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/Map.java
new file mode 100644
index 0000000..cf650e5
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/Map.java
@@ -0,0 +1,160 @@
+/*
+ * FX Map Control - https://github.com/ClemensFischer/FX-Map-Control
+ * © 2016 Clemens Fischer
+ */
+package fxmapcontrol;
+
+import java.util.EnumSet;
+import java.util.List;
+import javafx.beans.property.DoubleProperty;
+import javafx.beans.property.ObjectProperty;
+import javafx.beans.property.ReadOnlyBooleanProperty;
+import javafx.beans.property.ReadOnlyBooleanWrapper;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.css.CssMetaData;
+import javafx.css.SimpleStyleableDoubleProperty;
+import javafx.css.Styleable;
+import javafx.css.StyleableDoubleProperty;
+import javafx.css.StyleablePropertyFactory;
+import javafx.geometry.Point2D;
+import javafx.scene.Cursor;
+import javafx.scene.input.MouseButton;
+import javafx.scene.input.MouseEvent;
+import javafx.scene.input.RotateEvent;
+import javafx.scene.input.ScrollEvent;
+import javafx.scene.input.ZoomEvent;
+
+/**
+ * MapBase with default input event handling.
+ */
+public class Map extends MapBase {
+
+    public enum ManipulationModes {
+        ROTATE, TRANSLATE, ZOOM;
+
+        public static final EnumSet<ManipulationModes> ALL = EnumSet.allOf(ManipulationModes.class);
+        public static final EnumSet<ManipulationModes> DEFAULT = EnumSet.of(ManipulationModes.TRANSLATE, ManipulationModes.ZOOM);
+    }
+
+    private static final StyleablePropertyFactory<Map> propertyFactory
+            = new StyleablePropertyFactory<>(MapBase.getClassCssMetaData());
+
+    private static final CssMetaData<Map, Number> mouseWheelZoomDeltaCssMetaData
+            = propertyFactory.createSizeCssMetaData("-fx-mouse-wheel-zoom-delta", s -> s.mouseWheelZoomDeltaProperty);
+
+    private final StyleableDoubleProperty mouseWheelZoomDeltaProperty
+            = new SimpleStyleableDoubleProperty(mouseWheelZoomDeltaCssMetaData, this, "mouseWheelZoomDelta", 1d);
+
+    private final ObjectProperty<EnumSet<ManipulationModes>> manipulationModesProperty
+            = new SimpleObjectProperty<>(this, "manipulationModes", ManipulationModes.DEFAULT);
+
+    private final ReadOnlyBooleanWrapper mouseDraggingProperty = new ReadOnlyBooleanWrapper(this, "mouseDragging");
+
+    private Point2D mousePosition;
+
+    public Map() {
+        getStyleClass().add("map");
+
+        addEventHandler(MouseEvent.MOUSE_PRESSED, e -> {
+            if (getManipulationModes().contains(ManipulationModes.TRANSLATE)
+                    && e.getTarget() == this
+                    && e.getButton() == MouseButton.PRIMARY
+                    && e.getClickCount() == 1) {
+
+                mousePosition = new Point2D(e.getX(), e.getY());
+                mouseDraggingProperty.set(true);
+                setCursor(Cursor.CLOSED_HAND);
+            }
+        });
+
+        addEventHandler(MouseEvent.MOUSE_RELEASED, e -> {
+            if (e.getTarget() == this && e.getButton() == MouseButton.PRIMARY) {
+                mousePosition = null;
+                mouseDraggingProperty.set(false);
+                setCursor(null);
+            }
+        });
+
+        addEventHandler(MouseEvent.MOUSE_DRAGGED, e -> {
+            if (mousePosition != null) {
+                Point2D position = new Point2D(e.getX(), e.getY());
+                translateMap(position.subtract(mousePosition));
+                mousePosition = position;
+            }
+        });
+
+        addEventHandler(ScrollEvent.SCROLL, e -> {
+            if (getManipulationModes().contains(ManipulationModes.ZOOM)
+                    && e.getTouchCount() == 0
+                    && !e.isInertia()) { // handle only mouse wheel events
+
+                Point2D center = getManipulationModes().contains(ManipulationModes.TRANSLATE)
+                        ? new Point2D(e.getX(), e.getY())
+                        : new Point2D(getWidth() / 2d, getHeight() / 2d);
+
+                zoomMap(center, getTargetZoomLevel() + Math.signum(e.getDeltaY()) * getMouseWheelZoomDelta(), true);
+            }
+        });
+
+        addEventHandler(ZoomEvent.ZOOM, e -> {
+            if (getManipulationModes().contains(ManipulationModes.ZOOM)) {
+                Point2D center = getManipulationModes().contains(ManipulationModes.TRANSLATE)
+                        ? new Point2D(e.getX(), e.getY())
+                        : new Point2D(getWidth() / 2d, getHeight() / 2d);
+
+                zoomMap(center, getZoomLevel() + Math.log(e.getZoomFactor()) / Math.log(2d), false);
+            }
+        });
+
+        addEventHandler(RotateEvent.ROTATE, e -> {
+            if (getManipulationModes().contains(ManipulationModes.ROTATE)) {
+                Point2D center = getManipulationModes().contains(ManipulationModes.TRANSLATE)
+                        ? new Point2D(e.getX(), e.getY())
+                        : new Point2D(getWidth() / 2d, getHeight() / 2d);
+
+                rotateMap(center, getHeading() + e.getAngle(), false);
+            }
+        });
+    }
+
+    public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
+        return propertyFactory.getCssMetaData();
+    }
+
+    @Override
+    public List<CssMetaData<? extends Styleable, ?>> getCssMetaData() {
+        return getClassCssMetaData();
+    }
+
+    public final DoubleProperty mouseWheelZoomDeltaProperty() {
+        return mouseWheelZoomDeltaProperty;
+    }
+
+    public final double getMouseWheelZoomDelta() {
+        return mouseWheelZoomDeltaProperty.get();
+    }
+
+    public final void setMouseWheelZoomDelta(double mouseWheelZoomDelta) {
+        mouseWheelZoomDeltaProperty.set(mouseWheelZoomDelta);
+    }
+
+    public final ObjectProperty<EnumSet<ManipulationModes>> manipulationModesProperty() {
+        return manipulationModesProperty;
+    }
+
+    public final EnumSet<ManipulationModes> getManipulationModes() {
+        return manipulationModesProperty.get();
+    }
+
+    public final void setManipulationModes(EnumSet<ManipulationModes> manipulationModes) {
+        manipulationModesProperty.set(manipulationModes);
+    }
+
+    public final ReadOnlyBooleanProperty mouseDraggingProperty() {
+        return mouseDraggingProperty.getReadOnlyProperty();
+    }
+
+    public final boolean isMouseDragging() {
+        return mouseDraggingProperty.get();
+    }
+}
diff --git a/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapBase.java b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapBase.java
new file mode 100644
index 0000000..000892e
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapBase.java
@@ -0,0 +1,626 @@
+/*
+ * FX Map Control - https://github.com/ClemensFischer/FX-Map-Control
+ * © 2017 Clemens Fischer
+ */
+package fxmapcontrol;
+
+import java.util.List;
+import javafx.animation.Animation;
+import javafx.animation.Interpolator;
+import javafx.animation.Transition;
+import javafx.beans.DefaultProperty;
+import javafx.beans.property.DoubleProperty;
+import javafx.beans.property.ObjectProperty;
+import javafx.beans.property.SimpleDoubleProperty;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.beans.value.ChangeListener;
+import javafx.beans.value.ObservableValue;
+import javafx.collections.ObservableList;
+import javafx.css.CssMetaData;
+import javafx.css.SimpleStyleableObjectProperty;
+import javafx.css.Styleable;
+import javafx.css.StyleableObjectProperty;
+import javafx.css.StyleablePropertyFactory;
+import javafx.geometry.Bounds;
+import javafx.geometry.Point2D;
+import javafx.scene.Node;
+import javafx.scene.layout.Region;
+import javafx.scene.shape.Rectangle;
+import javafx.scene.transform.Affine;
+import javafx.scene.transform.Rotate;
+import javafx.scene.transform.Scale;
+import javafx.util.Duration;
+
+/**
+ * The map control. Renders map content provided by one or more MapTileLayers. The visible map area
+ * is defined by the center and zoomLevel properties. The map can be rotated by an angle that is
+ * given by the heading property. MapBase can contain different child nodes, which typically
+ * implement the IMapNode interface.
+ */
+@DefaultProperty(value = "children")
+public class MapBase extends Region implements IMapNode {
+
+    private static final StyleablePropertyFactory<MapBase> propertyFactory
+            = new StyleablePropertyFactory<>(Region.getClassCssMetaData());
+
+    private static final CssMetaData<MapBase, Duration> tileFadeDurationCssMetaData
+            = propertyFactory.createDurationCssMetaData("-fx-tile-fade-duration", s -> s.tileFadeDurationProperty);
+
+    private static final CssMetaData<MapBase, Duration> transitionDurationCssMetaData
+            = propertyFactory.createDurationCssMetaData("-fx-transition-duration", s -> s.transitionDurationProperty);
+
+    private final StyleableObjectProperty<Duration> tileFadeDurationProperty
+            = new SimpleStyleableObjectProperty<>(tileFadeDurationCssMetaData, this, "tileFadeDuration", Duration.seconds(0.2));
+
+    private final StyleableObjectProperty<Duration> transitionDurationProperty
+            = new SimpleStyleableObjectProperty<>(transitionDurationCssMetaData, this, "transitionDuration", Duration.seconds(0.3));
+
+    private final ObjectProperty<MapProjection> projectionProperty = new SimpleObjectProperty<>(this, "projection", new WebMercatorProjection());
+    private final ObjectProperty<Location> projectionCenterProperty = new SimpleObjectProperty<>(this, "projectionCenter");
+    private final ObjectProperty<Location> centerProperty = new SimpleObjectProperty<>(this, "center", new Location(0d, 0d));
+    private final ObjectProperty<Location> targetCenterProperty = new SimpleObjectProperty<>(this, "targetCenter", new Location(0d, 0d));
+    private final DoubleProperty zoomLevelProperty = new SimpleDoubleProperty(this, "zoomLevel");
+    private final DoubleProperty targetZoomLevelProperty = new SimpleDoubleProperty(this, "targetZoomLevel");
+    private final DoubleProperty headingProperty = new SimpleDoubleProperty(this, "heading");
+    private final DoubleProperty targetHeadingProperty = new SimpleDoubleProperty(this, "targetHeading");
+    private final Scale scaleTransform = new Scale();
+    private final Rotate rotateTransform = new Rotate();
+    private final Affine scaleRotateTransform = new Affine();
+    private final CenterTransition centerTransition = new CenterTransition();
+    private final ZoomLevelTransition zoomLevelTransition = new ZoomLevelTransition();
+    private final HeadingTransition headingTransition = new HeadingTransition();
+
+    private Location transformCenter = new Location(0d, 0d);
+    private Point2D viewportCenter = new Point2D(0d, 0d);
+    private double centerLongitude;
+    private double minZoomLevel = 1d;
+    private double maxZoomLevel = 19d;
+    private boolean internalUpdate;
+
+    public MapBase() {
+        getStyleClass().add("map-base");
+        getChildren().addListener(new MapNodeHelper.ChildrenListener(this));
+
+        Rectangle clip = new Rectangle(getWidth(), getHeight());
+        setClip(clip);
+
+        layoutBoundsProperty().addListener((observable, oldValue, newValue) -> {
+            resetTransformCenter();
+            updateTransform(false, false);
+            clip.setWidth(newValue.getWidth());
+            clip.setHeight(newValue.getHeight());
+        });
+
+        Tile.setFadeDuration(getTileFadeDuration());
+
+        tileFadeDurationProperty.addListener((observable, oldValue, newValue) -> {
+            Tile.setFadeDuration(newValue);
+        });
+
+        transitionDurationProperty.addListener((observable, oldValue, newValue) -> {
+            centerTransition.setDuration(newValue);
+            zoomLevelTransition.setDuration(newValue);
+            headingTransition.setDuration(newValue);
+        });
+
+        projectionProperty.addListener(observable -> {
+            resetTransformCenter();
+            updateTransform(false, true);
+        });
+
+        projectionCenterProperty.addListener(observable -> {
+            if (getProjection().isAzimuthal()) {
+                resetTransformCenter();
+                updateTransform(false, false);
+            }
+        });
+
+        centerProperty.addListener(new CenterChangeListener());
+        targetCenterProperty.addListener(new TargetCenterChangeListener());
+        zoomLevelProperty.addListener(new ZoomLevelChangeListener());
+        targetZoomLevelProperty.addListener(new TargetZoomLevelChangeListener());
+        headingProperty.addListener(new HeadingChangeListener());
+        targetHeadingProperty.addListener(new TargetHeadingChangeListener());
+    }
+
+    public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
+        return propertyFactory.getCssMetaData();
+    }
+
+    @Override
+    public List<CssMetaData<? extends Styleable, ?>> getCssMetaData() {
+        return getClassCssMetaData();
+    }
+
+    @Override
+    public final ObservableList<Node> getChildren() {
+        return super.getChildren();
+    }
+
+    @Override
+    public final MapBase getMap() {
+        return this;
+    }
+
+    @Override
+    public final void setMap(MapBase map) {
+        throw new IllegalStateException();
+    }
+
+    public final ObjectProperty<Duration> tileFadeDurationProperty() {
+        return tileFadeDurationProperty;
+    }
+
+    public final Duration getTileFadeDuration() {
+        return tileFadeDurationProperty.get();
+    }
+
+    public final void setTileFadeDuration(Duration tileFadeDuration) {
+        tileFadeDurationProperty.set(tileFadeDuration);
+    }
+
+    public final ObjectProperty<Duration> transitionDurationProperty() {
+        return transitionDurationProperty;
+    }
+
+    public final Duration getTransitionDuration() {
+        return transitionDurationProperty.get();
+    }
+
+    public final void setTransitionDuration(Duration transitionDuration) {
+        transitionDurationProperty.set(transitionDuration);
+    }
+
+    public final ObjectProperty<MapProjection> projectionProperty() {
+        return projectionProperty;
+    }
+
+    public final MapProjection getProjection() {
+        return projectionProperty.get();
+    }
+
+    public final void setProjection(MapProjection projection) {
+        projectionProperty.set(projection);
+    }
+
+    public final ObjectProperty<Location> projectionCenterProperty() {
+        return projectionCenterProperty;
+    }
+
+    public final Location getProjectionCenter() {
+        return projectionCenterProperty.get();
+    }
+
+    public final void setProjectionCenter(Location projectionCenter) {
+        projectionCenterProperty.set(projectionCenter);
+    }
+
+    public final ObjectProperty<Location> centerProperty() {
+        return centerProperty;
+    }
+
+    public final Location getCenter() {
+        return centerProperty.get();
+    }
+
+    public final void setCenter(Location center) {
+        centerProperty.set(center);
+    }
+
+    public final ObjectProperty<Location> targetCenterProperty() {
+        return targetCenterProperty;
+    }
+
+    public final Location getTargetCenter() {
+        return targetCenterProperty.get();
+    }
+
+    public final void setTargetCenter(Location targetCenter) {
+        targetCenterProperty.set(targetCenter);
+    }
+
+    public final double getMinZoomLevel() {
+        return minZoomLevel;
+    }
+
+    public final void setMinZoomLevel(double minZoomLevel) {
+        this.minZoomLevel = minZoomLevel;
+    }
+
+    public final double getMaxZoomLevel() {
+        return maxZoomLevel;
+    }
+
+    public final void setMaxZoomLevel(double maxZoomLevel) {
+        this.maxZoomLevel = maxZoomLevel;
+    }
+
+    public final DoubleProperty zoomLevelProperty() {
+        return zoomLevelProperty;
+    }
+
+    public final double getZoomLevel() {
+        return zoomLevelProperty.get();
+    }
+
+    public final void setZoomLevel(double zoomLevel) {
+        zoomLevelProperty.set(zoomLevel);
+    }
+
+    public final DoubleProperty targetZoomLevelProperty() {
+        return targetZoomLevelProperty;
+    }
+
+    public final double getTargetZoomLevel() {
+        return targetZoomLevelProperty.get();
+    }
+
+    public final void setTargetZoomLevel(double targetZoomLevel) {
+        targetZoomLevelProperty.set(targetZoomLevel);
+    }
+
+    public final DoubleProperty headingProperty() {
+        return headingProperty;
+    }
+
+    public final double getHeading() {
+        return headingProperty.get();
+    }
+
+    public final void setHeading(double heading) {
+        headingProperty.set(heading);
+    }
+
+    public final DoubleProperty targetHeadingProperty() {
+        return targetHeadingProperty;
+    }
+
+    public final double getTargetHeading() {
+        return targetHeadingProperty.get();
+    }
+
+    public final void setTargetHeading(double targetHeading) {
+        targetHeadingProperty.set(targetHeading);
+    }
+
+    public final Scale getScaleTransform() {
+        return scaleTransform;
+    }
+
+    public final Rotate getRotateTransform() {
+        return rotateTransform;
+    }
+
+    public final Affine getScaleRotateTransform() {
+        return scaleRotateTransform;
+    }
+
+    public final void setTransformCenter(Point2D center) {
+        transformCenter = getProjection().viewportPointToLocation(center);
+        viewportCenter = center;
+    }
+
+    public final void resetTransformCenter() {
+        transformCenter = null;
+        viewportCenter = new Point2D(getWidth() / 2d, getHeight() / 2d);
+    }
+
+    public final void translateMap(Point2D translation) {
+        if (transformCenter != null) {
+            resetTransformCenter();
+        }
+
+        if (translation.getX() != 0d || translation.getY() != 0d) {
+            setCenter(getProjection().viewportPointToLocation(new Point2D(
+                    getWidth() / 2d - translation.getX(),
+                    getHeight() / 2d - translation.getY())));
+        }
+    }
+
+    public final void zoomMap(Point2D center, double zoomLevel, boolean animated) {
+        zoomLevel = Math.min(Math.max(zoomLevel, getMinZoomLevel()), getMaxZoomLevel());
+
+        if (animated && getProjection().isNormalCylindrical()) {
+            if (getTargetZoomLevel() != zoomLevel) {
+                setTransformCenter(center);
+                setTargetZoomLevel(zoomLevel);
+            }
+        } else if (getZoomLevel() != zoomLevel) {
+            setTransformCenter(center);
+            setZoomLevel(zoomLevel);
+        }
+    }
+
+    public final void rotateMap(Point2D center, double heading, boolean animated) {
+        if (animated) {
+            if (getTargetHeading() != heading) {
+                setTransformCenter(center);
+                setTargetHeading(heading);
+            }
+        } else if (getHeading() != heading) {
+            setTransformCenter(center);
+            setHeading(heading);
+        }
+    }
+
+    public final void zoomToBounds(MapBoundingBox boundingBox) {
+        if (boundingBox != null && boundingBox.hasValidBounds()) {
+            Bounds bounds = getProjection().boundingBoxToBounds(boundingBox);
+            double scale0 = 1d / getProjection().getViewportScale(0d);
+            double lonScale = scale0 * getWidth() / bounds.getWidth();
+            double latScale = scale0 * getHeight() / bounds.getHeight();
+            double lonZoom = Math.log(lonScale) / Math.log(2d);
+            double latZoom = Math.log(latScale) / Math.log(2d);
+
+            setTargetHeading(0d);
+            setTargetZoomLevel(Math.min(lonZoom, latZoom));
+            setTargetCenter(getProjection().pointToLocation(new Point2D(
+                    bounds.getMinX() + bounds.getWidth() / 2d,
+                    bounds.getMinY() + bounds.getHeight() / 2d)));
+        }
+    }
+
+    public final boolean isCenterAnimationRunning() {
+        return centerTransition.getStatus() == Animation.Status.RUNNING;
+    }
+
+    public final boolean isZoomLevelAnimationRunning() {
+        return zoomLevelTransition.getStatus() == Animation.Status.RUNNING;
+    }
+
+    public final boolean isHeadingAnimationRunning() {
+        return headingTransition.getStatus() == Animation.Status.RUNNING;
+    }
+
+    private void updateTransform(boolean resetTransformCenter, boolean projectionChanged) {
+        MapProjection projection = getProjection();
+        Location center = transformCenter != null ? transformCenter : getCenter();
+
+        projection.setViewportTransform(
+                getProjectionCenter() != null ? getProjectionCenter() : getCenter(),
+                center, viewportCenter, getZoomLevel(), getHeading());
+
+        if (transformCenter != null) {
+            center = projection.viewportPointToLocation(new Point2D(getWidth() / 2d, getHeight() / 2d));
+
+            double latitude = center.getLatitude();
+
+            if (latitude < -projection.maxLatitude() || latitude > projection.maxLatitude()) {
+                latitude = Math.min(Math.max(latitude, -projection.maxLatitude()), projection.maxLatitude());
+                resetTransformCenter = true;
+            }
+
+            center = new Location(latitude, Location.normalizeLongitude(center.getLongitude()));
+
+            internalUpdate = true;
+            setCenter(center);
+            internalUpdate = false;
+
+            if (resetTransformCenter) {
+                resetTransformCenter();
+                projection.setViewportTransform(
+                        getProjectionCenter() != null ? getProjectionCenter() : center,
+                        center, viewportCenter, getZoomLevel(), getHeading());
+            }
+        }
+
+        Point2D scale = projection.getMapScale(center);
+        scaleTransform.setX(scale.getX());
+        scaleTransform.setY(scale.getY());
+        rotateTransform.setAngle(getHeading());
+        scaleRotateTransform.setToTransform(scaleTransform.createConcatenation(rotateTransform));
+
+        fireEvent(new ViewportChangedEvent(projectionChanged, getCenter().getLongitude() - centerLongitude));
+
+        centerLongitude = getCenter().getLongitude();
+    }
+
+    private Location adjustCenterProperty(ObjectProperty<Location> property, Location value) {
+        double maxLatitude = getProjection().maxLatitude();
+        internalUpdate = true;
+        if (value == null) {
+            value = new Location(0d, 0d);
+            property.setValue(value);
+        } else if (value.getLongitude() < -180d || value.getLongitude() > 180d
+                || value.getLatitude() < -maxLatitude || value.getLatitude() > maxLatitude) {
+            value = new Location(
+                    Math.min(Math.max(value.getLatitude(), -maxLatitude), maxLatitude),
+                    Location.normalizeLongitude(value.getLongitude()));
+            property.setValue(value);
+        }
+        internalUpdate = false;
+        return value;
+    }
+
+    private double adjustZoomLevelProperty(DoubleProperty property, double value) {
+        if (value < minZoomLevel || value > maxZoomLevel) {
+            internalUpdate = true;
+            value = Math.min(Math.max(value, minZoomLevel), maxZoomLevel);
+            property.set(value);
+            internalUpdate = false;
+        }
+        return value;
+    }
+
+    private double adjustHeadingProperty(DoubleProperty property, double value) {
+        if (value < 0d || value > 360d) {
+            internalUpdate = true;
+            value = ((value % 360d) + 360d) % 360d;
+            property.set(value);
+            internalUpdate = false;
+        }
+        return value;
+    }
+
+    private class CenterChangeListener implements ChangeListener<Location> {
+
+        @Override
+        public void changed(ObservableValue<? extends Location> observable, Location oldValue, Location newValue) {
+            if (!internalUpdate) {
+                newValue = adjustCenterProperty(centerProperty, newValue);
+
+                if (!isCenterAnimationRunning()) {
+                    internalUpdate = true;
+                    setTargetCenter(newValue);
+                    internalUpdate = false;
+                }
+
+                resetTransformCenter();
+                updateTransform(false, false);
+            }
+        }
+    }
+
+    private class TargetCenterChangeListener implements ChangeListener<Location> {
+
+        @Override
+        public void changed(ObservableValue<? extends Location> observable, Location oldValue, Location newValue) {
+            if (!internalUpdate) {
+                centerTransition.start(getCenter(), adjustCenterProperty(targetCenterProperty, newValue));
+            }
+        }
+    }
+
+    private class ZoomLevelChangeListener implements ChangeListener<Number> {
+
+        @Override
+        public void changed(ObservableValue<? extends Number> observable, Number oldValue, Number newValue) {
+            if (!internalUpdate) {
+                double value = adjustZoomLevelProperty(zoomLevelProperty, newValue.doubleValue());
+
+                if (isZoomLevelAnimationRunning()) {
+                    updateTransform(false, false);
+                } else {
+                    internalUpdate = true;
+                    setTargetZoomLevel(value);
+                    internalUpdate = false;
+                    updateTransform(true, false);
+                }
+            }
+        }
+    }
+
+    private class TargetZoomLevelChangeListener implements ChangeListener<Number> {
+
+        @Override
+        public void changed(ObservableValue<? extends Number> observable, Number oldValue, Number newValue) {
+            if (!internalUpdate) {
+                zoomLevelTransition.start(getZoomLevel(), adjustZoomLevelProperty(targetZoomLevelProperty, newValue.doubleValue()));
+            }
+        }
+    }
+
+    private class HeadingChangeListener implements ChangeListener<Number> {
+
+        @Override
+        public void changed(ObservableValue<? extends Number> observable, Number oldValue, Number newValue) {
+            if (!internalUpdate) {
+                double value = adjustHeadingProperty(headingProperty, newValue.doubleValue());
+
+                if (isHeadingAnimationRunning()) {
+                    updateTransform(false, false);
+                } else {
+                    internalUpdate = true;
+                    setTargetHeading(value);
+                    internalUpdate = false;
+                    updateTransform(true, false);
+                }
+            }
+        }
+    }
+
+    private class TargetHeadingChangeListener implements ChangeListener<Number> {
+
+        @Override
+        public void changed(ObservableValue<? extends Number> observable, Number oldValue, Number newValue) {
+            if (!internalUpdate) {
+                headingTransition.start(getHeading(), adjustHeadingProperty(targetHeadingProperty, newValue.doubleValue()));
+            }
+        }
+    }
+
+    private abstract class TransitionBase<T> extends Transition {
+
+        protected T fromValue;
+        protected T toValue;
+
+        public TransitionBase() {
+            setDuration(getTransitionDuration());
+            setInterpolator(Interpolator.EASE_OUT);
+            setOnFinished(e -> updateTransform(true, false));
+        }
+
+        public final void setDuration(Duration duration) {
+            setCycleDuration(duration);
+        }
+
+        protected final void start() {
+            if (getCycleDuration().greaterThan(Duration.ZERO)) {
+                playFromStart();
+            } else {
+                interpolate(1d);
+            }
+        }
+    }
+
+    private class CenterTransition extends TransitionBase<Location> {
+
+        public void start(Location from, Location to) {
+            if (!from.equals(to)) {
+                double longitude = Location.normalizeLongitude(to.getLongitude());
+                if (longitude > from.getLongitude() + 180d) {
+                    longitude -= 360d;
+                } else if (longitude < from.getLongitude() - 180d) {
+                    longitude += 360d;
+                }
+                fromValue = from;
+                toValue = new Location(to.getLatitude(), longitude);
+                start();
+            }
+        }
+
+        @Override
+        protected void interpolate(double f) {
+            setCenter(new Location(
+                    (1d - f) * fromValue.getLatitude() + f * toValue.getLatitude(),
+                    (1d - f) * fromValue.getLongitude() + f * toValue.getLongitude()));
+        }
+    }
+
+    private class ZoomLevelTransition extends TransitionBase<Double> {
+
+        public void start(double from, double to) {
+            if (from != to) {
+                fromValue = from;
+                toValue = to;
+                start();
+            }
+        }
+
+        @Override
+        protected void interpolate(double f) {
+            setZoomLevel((1d - f) * fromValue + f * toValue);
+        }
+    };
+
+    private class HeadingTransition extends TransitionBase<Double> {
+
+        public void start(double from, double to) {
+            if (from != to) {
+                if (to - from > 180d) {
+                    to -= 360d;
+                } else if (to - from < -180d) {
+                    to += 360d;
+                }
+                fromValue = from;
+                toValue = to;
+                start();
+            }
+        }
+
+        @Override
+        protected void interpolate(double f) {
+            setHeading((1d - f) * fromValue + f * toValue);
+        }
+    };
+}
diff --git a/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapBoundingBox.java b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapBoundingBox.java
new file mode 100644
index 0000000..10a29ae
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapBoundingBox.java
@@ -0,0 +1,74 @@
+/*
+ * FX Map Control - https://github.com/ClemensFischer/FX-Map-Control
+ * © 2017 Clemens Fischer
+ */
+package fxmapcontrol;
+
+/**
+ * A bounding box with south and north latitude and west and east longitude values in degrees.
+ */
+public class MapBoundingBox {
+
+    private double south;
+    private double west;
+    private double north;
+    private double east;
+
+    public MapBoundingBox() {
+    }
+
+    public MapBoundingBox(double south, double west, double north, double east) {
+        this.south = Math.min(Math.max(south, -90d), 90d);
+        this.west = west;
+        this.north = Math.min(Math.max(north, -90d), 90d);
+        this.east = east;
+    }
+
+    public double getSouth() {
+        return south;
+    }
+
+    public void setSouth(double south) {
+        this.south = Math.min(Math.max(south, -90d), 90d);
+    }
+
+    public double getWest() {
+        return west;
+    }
+
+    public void setWest(double west) {
+        this.west = west;
+    }
+
+    public double getNorth() {
+        return north;
+    }
+
+    public void setNorth(double north) {
+        this.north = Math.min(Math.max(north, -90d), 90d);
+    }
+
+    public double getEast() {
+        return east;
+    }
+
+    public void setEast(double east) {
+        this.east = east;
+    }
+
+    public double getWidth() {
+        return east - west;
+    }
+
+    public double getHeight() {
+        return north - south;
+    }
+
+    public boolean hasValidBounds() {
+        return south < north && west < east;
+    }
+
+    public MapBoundingBox clone() {
+        return new MapBoundingBox(south, west, north, east);
+    }
+}
diff --git a/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapGraticule.java b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapGraticule.java
new file mode 100644
index 0000000..d164327
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapGraticule.java
@@ -0,0 +1,287 @@
+/*
+ * FX Map Control - https://github.com/ClemensFischer/FX-Map-Control
+ * © 2016 Clemens Fischer
+ */
+package fxmapcontrol;
+
+import java.util.ArrayList;
+import java.util.List;
+import javafx.beans.property.DoubleProperty;
+import javafx.beans.property.ObjectProperty;
+import javafx.collections.ObservableList;
+import javafx.css.CssMetaData;
+import javafx.css.SimpleStyleableDoubleProperty;
+import javafx.css.SimpleStyleableObjectProperty;
+import javafx.css.Styleable;
+import javafx.css.StyleableDoubleProperty;
+import javafx.css.StyleableObjectProperty;
+import javafx.css.StyleablePropertyFactory;
+import javafx.geometry.BoundingBox;
+import javafx.geometry.Point2D;
+import javafx.scene.Node;
+import javafx.scene.Parent;
+import javafx.scene.paint.Color;
+import javafx.scene.paint.Paint;
+import javafx.scene.shape.LineTo;
+import javafx.scene.shape.MoveTo;
+import javafx.scene.shape.Path;
+import javafx.scene.shape.PathElement;
+import javafx.scene.text.Font;
+import javafx.scene.text.Text;
+import javafx.scene.transform.Rotate;
+import javafx.scene.transform.Translate;
+
+/**
+ * Draws a graticule overlay.
+ */
+public class MapGraticule extends Parent implements IMapNode {
+
+    private static final StyleablePropertyFactory<MapGraticule> propertyFactory
+            = new StyleablePropertyFactory<>(Parent.getClassCssMetaData());
+
+    private static final CssMetaData<MapGraticule, Font> fontCssMetaData
+            = propertyFactory.createFontCssMetaData("-fx-font", s -> s.fontProperty);
+
+    private static final CssMetaData<MapGraticule, Paint> textFillCssMetaData
+            = propertyFactory.createPaintCssMetaData("-fx-text-fill", s -> s.textFillProperty);
+
+    private static final CssMetaData<MapGraticule, Paint> strokeCssMetaData
+            = propertyFactory.createPaintCssMetaData("-fx-stroke", s -> s.strokeProperty);
+
+    private static final CssMetaData<MapGraticule, Number> strokeWidthCssMetaData
+            = propertyFactory.createSizeCssMetaData("-fx-stroke-width", s -> s.strokeWidthProperty);
+
+    private static final CssMetaData<MapGraticule, Number> minLineDistanceCssMetaData
+            = propertyFactory.createSizeCssMetaData("-fx-min-line-distance", s -> s.minLineDistanceProperty);
+
+    private final StyleableObjectProperty<Font> fontProperty
+            = new SimpleStyleableObjectProperty<>(fontCssMetaData, this, "font", Font.getDefault());
+
+    private final StyleableObjectProperty<Paint> textFillProperty
+            = new SimpleStyleableObjectProperty<>(textFillCssMetaData, this, "textFill", Color.BLACK);
+
+    private final StyleableObjectProperty<Paint> strokeProperty
+            = new SimpleStyleableObjectProperty<>(strokeCssMetaData, this, "stroke", Color.BLACK);
+
+    private final StyleableDoubleProperty strokeWidthProperty
+            = new SimpleStyleableDoubleProperty(strokeWidthCssMetaData, this, "strokeWidth", 0.5);
+
+    private final StyleableDoubleProperty minLineDistanceProperty
+            = new SimpleStyleableDoubleProperty(minLineDistanceCssMetaData, this, "minLineDistance", 150d);
+
+    private final MapNodeHelper mapNode = new MapNodeHelper(e -> viewportChanged());
+
+    public MapGraticule() {
+        getStyleClass().add("map-graticule");
+        setMouseTransparent(true);
+    }
+
+    public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
+        return propertyFactory.getCssMetaData();
+    }
+
+    @Override
+    public List<CssMetaData<? extends Styleable, ?>> getCssMetaData() {
+        return getClassCssMetaData();
+    }
+
+    @Override
+    public final MapBase getMap() {
+        return mapNode.getMap();
+    }
+
+    @Override
+    public void setMap(MapBase map) {
+        mapNode.setMap(map);
+
+        if (map != null) {
+            viewportChanged();
+        }
+    }
+
+    public final ObjectProperty<Font> fontProperty() {
+        return fontProperty;
+    }
+
+    public final Font getFont() {
+        return fontProperty.get();
+    }
+
+    public final void setFont(Font font) {
+        fontProperty.set(font);
+    }
+
+    public final ObjectProperty<Paint> textFillProperty() {
+        return textFillProperty;
+    }
+
+    public final Paint getTextFill() {
+        return textFillProperty.get();
+    }
+
+    public final void setTextFill(Paint textFill) {
+        textFillProperty.set(textFill);
+    }
+
+    public final ObjectProperty<Paint> strokeProperty() {
+        return strokeProperty;
+    }
+
+    public final Paint getStroke() {
+        return strokeProperty.get();
+    }
+
+    public final void setStroke(Paint stroke) {
+        strokeProperty.set(stroke);
+    }
+
+    public final DoubleProperty strokeWidthProperty() {
+        return strokeWidthProperty;
+    }
+
+    public final double getStrokeWidth() {
+        return strokeWidthProperty.get();
+    }
+
+    public final void setStrokeWidth(double strokeWidth) {
+        strokeWidthProperty.set(strokeWidth);
+    }
+
+    public final DoubleProperty minLineDistanceProperty() {
+        return minLineDistanceProperty;
+    }
+
+    public final double getMinLineDistance() {
+        return minLineDistanceProperty.get();
+    }
+
+    public final void setMinLineDistance(double minLineDistance) {
+        minLineDistanceProperty.set(minLineDistance);
+    }
+
+    private void viewportChanged() {
+        MapBase map = getMap();
+        MapProjection projection = map.getProjection();
+        ObservableList<Node> children = getChildren();
+
+        if (projection.isNormalCylindrical()) {
+            MapBoundingBox mapBounds = projection.viewportBoundsToBoundingBox(
+                    new BoundingBox(0, 0, map.getWidth(), map.getHeight()));
+
+            double lineDistance = getLineDistance();
+            double latLabelStart = Math.ceil(mapBounds.getSouth() / lineDistance) * lineDistance;
+            double lonLabelStart = Math.ceil(mapBounds.getWest() / lineDistance) * lineDistance;
+            ArrayList<PathElement> pathElements = new ArrayList<>();
+
+            for (double lat = latLabelStart; lat <= mapBounds.getNorth(); lat += lineDistance) {
+                Point2D lineStart = projection.locationToViewportPoint(new Location(lat, mapBounds.getWest()));
+                Point2D lineEnd = projection.locationToViewportPoint(new Location(lat, mapBounds.getEast()));
+                pathElements.add(new MoveTo(lineStart.getX(), lineStart.getY()));
+                pathElements.add(new LineTo(lineEnd.getX(), lineEnd.getY()));
+            }
+
+            for (double lon = lonLabelStart; lon <= mapBounds.getEast(); lon += lineDistance) {
+                Point2D lineStart = projection.locationToViewportPoint(new Location(mapBounds.getSouth(), lon));
+                Point2D lineEnd = projection.locationToViewportPoint(new Location(mapBounds.getNorth(), lon));
+                pathElements.add(new MoveTo(lineStart.getX(), lineStart.getY()));
+                pathElements.add(new LineTo(lineEnd.getX(), lineEnd.getY()));
+            }
+
+            Path path;
+
+            if (children.isEmpty()) {
+                path = new Path();
+                path.strokeProperty().bind(strokeProperty);
+                path.strokeWidthProperty().bind(strokeWidthProperty);
+                children.add(path);
+            } else {
+                path = (Path) children.get(0);
+            }
+
+            path.getElements().setAll(pathElements);
+
+            Font font = getFont();
+            int childIndex = 1;
+
+            if (font != null) {
+                String format = getLabelFormat(lineDistance);
+                Rotate rotate = new Rotate(map.getHeading());
+
+                for (double lat = latLabelStart; lat <= mapBounds.getNorth(); lat += lineDistance) {
+                    for (double lon = lonLabelStart; lon <= mapBounds.getEast(); lon += lineDistance) {
+                        Point2D pos = projection.locationToViewportPoint(new Location(lat, lon));
+                        Translate translate = new Translate(pos.getX(), pos.getY());
+                        Text text;
+
+                        if (childIndex < children.size()) {
+                            text = (Text) children.get(childIndex);
+                            text.getTransforms().set(0, translate);
+                            text.getTransforms().set(1, rotate);
+                        } else {
+                            text = new Text();
+                            text.fillProperty().bind(textFillProperty);
+                            text.setX(3);
+                            text.setY(-font.getSize() / 4);
+                            text.getTransforms().add(translate);
+                            text.getTransforms().add(rotate);
+                            children.add(text);
+                        }
+
+                        text.setFont(getFont());
+                        text.setText(getLabelText(lat, format, "NS") + "\n"
+                                + getLabelText(Location.normalizeLongitude(lon), format, "EW"));
+                        childIndex++;
+                    }
+                }
+            }
+
+            children.remove(childIndex, children.size());
+
+        } else {
+            children.clear();
+        }
+    }
+
+    private double getLineDistance() {
+        double minDistance = getMinLineDistance() * 360d / (Math.pow(2d, getMap().getZoomLevel()) * TileSource.TILE_SIZE);
+        double scale = 1d;
+
+        if (minDistance < 1d) {
+            scale = minDistance < 1d / 60d ? 3600d : 60d;
+            minDistance *= scale;
+        }
+
+        double[] lineDistances = new double[]{1d, 2d, 5d, 10d, 15d, 30d, 60d};
+        int i = 0;
+
+        while (i < lineDistances.length - 1 && lineDistances[i] < minDistance) {
+            i++;
+        }
+
+        return lineDistances[i] / scale;
+    }
+
+    private static String getLabelFormat(double lineDistance) {
+        if (lineDistance < 1d / 60d) {
+            return "%c %d°%02d'%02d\"";
+        }
+        if (lineDistance < 1d) {
+            return "%c %d°%02d'";
+        }
+        return "%c %d°";
+    }
+
+    private static String getLabelText(double value, String format, String hemispheres) {
+        char hemisphere = hemispheres.charAt(0);
+
+        if (value < -1e-8) // ~1mm
+        {
+            value = -value;
+            hemisphere = hemispheres.charAt(1);
+        }
+
+        int seconds = (int) Math.round(value * 3600d);
+
+        return String.format(format, hemisphere, seconds / 3600, (seconds / 60) % 60, seconds % 60);
+    }
+}
diff --git a/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapImage.java b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapImage.java
new file mode 100644
index 0000000..6bb9d1d
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapImage.java
@@ -0,0 +1,75 @@
+/*
+ * FX Map Control - https://github.com/ClemensFischer/FX-Map-Control
+ * © 2017 Clemens Fischer
+ */
+package fxmapcontrol;
+
+import javafx.beans.property.ObjectProperty;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.geometry.Bounds;
+import javafx.scene.image.ImageView;
+import javafx.scene.transform.Affine;
+import javafx.scene.transform.Transform;
+
+/**
+ * Fills a rectangular map area defined by the boundingBox property with an Image.
+ */
+public class MapImage extends ImageView implements IMapNode {
+
+    private final ObjectProperty<MapBoundingBox> boundingBoxProperty = new SimpleObjectProperty<>(this, "boundingBox");
+    private final MapNodeHelper mapNode = new MapNodeHelper(e -> updateLayout());
+
+    public MapImage() {
+        setMouseTransparent(true);
+        boundingBoxProperty.addListener(observable -> updateLayout());
+    }
+
+    @Override
+    public final MapBase getMap() {
+        return mapNode.getMap();
+    }
+
+    @Override
+    public void setMap(MapBase map) {
+        mapNode.setMap(map);
+        updateLayout();
+    }
+
+    public final ObjectProperty<MapBoundingBox> boundingBoxProperty() {
+        return boundingBoxProperty;
+    }
+
+    public final MapBoundingBox getBoundingBox() {
+        return boundingBoxProperty.get();
+    }
+
+    public final void setBoundingBox(MapBoundingBox boundingBox) {
+        boundingBoxProperty.set(boundingBox);
+    }
+
+    private void updateLayout() {
+        Affine viewportTransform = null;
+        Bounds bounds = null;
+        MapBase map = getMap();
+        MapBoundingBox boundingBox = getBoundingBox();
+
+        if (map != null && boundingBox != null) {
+            bounds = map.getProjection().boundingBoxToBounds(boundingBox);
+            viewportTransform = map.getProjection().getViewportTransform();
+        }
+
+        if (bounds != null) {
+            getTransforms().setAll(viewportTransform, Transform.scale(1d, -1d, 0d, bounds.getMaxY()));
+            setX(bounds.getMinX());
+            setY(bounds.getMaxY());
+            setFitWidth(bounds.getWidth());
+            setFitHeight(bounds.getHeight());
+        } else {
+            getTransforms().clear();
+            setX(0);
+            setY(0);
+            setFitWidth(0);
+            setFitHeight(0);
+        }
+    }
+}
diff --git a/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapImageLayer.java b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapImageLayer.java
new file mode 100644
index 0000000..b9d2205
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapImageLayer.java
@@ -0,0 +1,348 @@
+/*
+ * FX Map Control - https://github.com/ClemensFischer/FX-Map-Control
+ * © 2016 Clemens Fischer
+ */
+package fxmapcontrol;
+
+import java.util.List;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import javafx.animation.FadeTransition;
+import javafx.animation.KeyFrame;
+import javafx.animation.Timeline;
+import javafx.beans.property.BooleanProperty;
+import javafx.beans.property.DoubleProperty;
+import javafx.beans.property.ObjectProperty;
+import javafx.beans.property.SimpleDoubleProperty;
+import javafx.collections.ObservableList;
+import javafx.css.CssMetaData;
+import javafx.css.SimpleStyleableBooleanProperty;
+import javafx.css.SimpleStyleableDoubleProperty;
+import javafx.css.SimpleStyleableObjectProperty;
+import javafx.css.Styleable;
+import javafx.css.StyleableBooleanProperty;
+import javafx.css.StyleableDoubleProperty;
+import javafx.css.StyleableObjectProperty;
+import javafx.css.StyleablePropertyFactory;
+import javafx.geometry.BoundingBox;
+import javafx.scene.Node;
+import javafx.scene.Parent;
+import javafx.scene.image.Image;
+import javafx.util.Duration;
+
+/**
+ * Map image overlay. Fills the entire viewport with map images provided by a web service, e.g. a
+ * Web Map Service (WMS). The image must be provided by the abstract updateImage(MapBoundingBox) method.
+ */
+public abstract class MapImageLayer extends Parent implements IMapNode {
+
+    private static final StyleablePropertyFactory<MapImageLayer> propertyFactory
+            = new StyleablePropertyFactory<>(Parent.getClassCssMetaData());
+
+    private static final CssMetaData<MapImageLayer, Duration> updateDelayCssMetaData
+            = propertyFactory.createDurationCssMetaData("-fx-update-delay", s -> s.updateDelayProperty);
+
+    private static final CssMetaData<MapImageLayer, Boolean> updateWhileViewportChangingCssMetaData
+            = propertyFactory.createBooleanCssMetaData("-fx-update-while-viewport-changing", s -> s.updateWhileViewportChangingProperty);
+
+    private static final CssMetaData<MapImageLayer, Number> relativeImageSizeCssMetaData
+            = propertyFactory.createSizeCssMetaData("-fx-relative-image-size", s -> s.relativeImageSizeProperty);
+
+    private final StyleableObjectProperty<Duration> updateDelayProperty
+            = new SimpleStyleableObjectProperty<>(updateDelayCssMetaData, this, "updateDelay", Duration.seconds(0.2));
+
+    private final StyleableBooleanProperty updateWhileViewportChangingProperty
+            = new SimpleStyleableBooleanProperty(updateWhileViewportChangingCssMetaData, this, "updateWhileViewportChanging");
+
+    private final StyleableDoubleProperty relativeImageSizeProperty
+            = new SimpleStyleableDoubleProperty(relativeImageSizeCssMetaData, this, "relativeImageSize", 1d);
+
+    private final DoubleProperty minLatitudeProperty = new SimpleDoubleProperty(this, "minLatitude", Double.NaN);
+    private final DoubleProperty maxLatitudeProperty = new SimpleDoubleProperty(this, "maxLatitude", Double.NaN);
+    private final DoubleProperty minLongitudeProperty = new SimpleDoubleProperty(this, "minLongitude", Double.NaN);
+    private final DoubleProperty maxLongitudeProperty = new SimpleDoubleProperty(this, "maxLongitude", Double.NaN);
+    private final DoubleProperty maxBoundingBoxWidthProperty = new SimpleDoubleProperty(this, "maxBoundingBoxWidth", Double.NaN);
+
+    private final Timeline updateTimeline = new Timeline();
+    private final MapNodeHelper mapNode = new MapNodeHelper(e -> viewportChanged(e.getProjectionChanged(), e.getLongitudeOffset()));
+    private MapBoundingBox boundingBox;
+    private boolean updateInProgress;
+
+    public MapImageLayer() {
+        getStyleClass().add("map-image-layer");
+        setMouseTransparent(true);
+
+        updateTimeline.getKeyFrames().add(new KeyFrame(getUpdateDelay(), e -> updateImage()));
+
+        updateDelayProperty.addListener(observable
+                -> updateTimeline.getKeyFrames().set(0, new KeyFrame(getUpdateDelay(), e -> updateImage())));
+    }
+
+    public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
+        return propertyFactory.getCssMetaData();
+    }
+
+    @Override
+    public List<CssMetaData<? extends Styleable, ?>> getCssMetaData() {
+        return getClassCssMetaData();
+    }
+
+    @Override
+    public final MapBase getMap() {
+        return mapNode.getMap();
+    }
+
+    @Override
+    public void setMap(MapBase map) {
+        mapNode.setMap(map);
+        getChildren().forEach(image -> ((MapImage) image).setMap(map));
+        updateImage();
+    }
+
+    public final ObjectProperty<Duration> updateDelayProperty() {
+        return updateDelayProperty;
+    }
+
+    public final Duration getUpdateDelay() {
+        return updateDelayProperty.get();
+    }
+
+    public final void setUpdateDelay(Duration updateDelay) {
+        updateDelayProperty.set(updateDelay);
+    }
+
+    public final BooleanProperty updateWhileViewportChangingProperty() {
+        return updateWhileViewportChangingProperty;
+    }
+
+    public final boolean getUpdateWhileViewportChanging() {
+        return updateWhileViewportChangingProperty.get();
+    }
+
+    public final void setUpdateWhileViewportChanging(boolean updateWhileViewportChanging) {
+        updateWhileViewportChangingProperty.set(updateWhileViewportChanging);
+    }
+
+    public final DoubleProperty relativeImageSizeProperty() {
+        return relativeImageSizeProperty;
+    }
+
+    public final double getRelativeImageSize() {
+        return relativeImageSizeProperty.get();
+    }
+
+    public final void setRelativeImageSize(double relativeImageSize) {
+        relativeImageSizeProperty.set(relativeImageSize);
+    }
+
+    public final DoubleProperty minLatitudeProperty() {
+        return minLatitudeProperty;
+    }
+
+    public final double getMinLatitude() {
+        return minLatitudeProperty.get();
+    }
+
+    public final void setMinLatitude(double minLatitude) {
+        minLatitudeProperty.set(minLatitude);
+    }
+
+    public final DoubleProperty maxLatitudeProperty() {
+        return maxLatitudeProperty;
+    }
+
+    public final double getMaxLatitude() {
+        return maxLatitudeProperty.get();
+    }
+
+    public final void setMaxLatitude(double maxLatitude) {
+        maxLatitudeProperty.set(maxLatitude);
+    }
+
+    public final DoubleProperty minLongitudeProperty() {
+        return minLongitudeProperty;
+    }
+
+    public final double getMinLongitude() {
+        return minLongitudeProperty.get();
+    }
+
+    public final void setMinLongitude(double minLongitude) {
+        minLongitudeProperty.set(minLongitude);
+    }
+
+    public final DoubleProperty maxLongitudeProperty() {
+        return maxLongitudeProperty;
+    }
+
+    public final double getMaxLongitude() {
+        return maxLongitudeProperty.get();
+    }
+
+    public final void setMaxLongitude(double maxLongitude) {
+        maxLongitudeProperty.set(maxLongitude);
+    }
+
+    public final DoubleProperty maxBoundingBoxWidthProperty() {
+        return maxBoundingBoxWidthProperty;
+    }
+
+    public final double getMaxBoundingBoxWidth() {
+        return maxBoundingBoxWidthProperty.get();
+    }
+
+    public final void setMaxBoundingBoxWidth(double maxBoundingBoxWidth) {
+        maxBoundingBoxWidthProperty.set(maxBoundingBoxWidth);
+    }
+
+    private void viewportChanged(boolean projectionChanged, double longitudeOffset) {
+        if (projectionChanged) {
+            updateImage((Image) null);
+            updateImage();
+
+        } else {
+            if (Math.abs(longitudeOffset) > 180d && boundingBox != null && boundingBox.hasValidBounds()) {
+                double offset = 360d * Math.signum(longitudeOffset);
+
+                boundingBox.setWest(boundingBox.getWest() + offset);
+                boundingBox.setEast(boundingBox.getEast() + offset);
+
+                getChildren().forEach(image -> {
+                    MapImage mapImage = (MapImage) image;
+                    MapBoundingBox bbox = mapImage.getBoundingBox();
+
+                    if (bbox != null && bbox.hasValidBounds()) {
+                        mapImage.setBoundingBox(new MapBoundingBox(
+                                bbox.getSouth(), bbox.getWest() + offset,
+                                bbox.getNorth(), bbox.getEast() + offset));
+                    }
+                });
+            }
+
+            if (getUpdateWhileViewportChanging()) {
+                updateTimeline.play();
+            } else {
+                updateTimeline.playFromStart();
+            }
+        }
+    }
+
+    protected void updateImage() {
+        MapBase map = getMap();
+
+        if (updateInProgress) {
+            updateTimeline.playFromStart(); // update image on next timer tick
+
+        } else if (map != null && map.getWidth() > 0 && map.getHeight() > 0) {
+            updateInProgress = true;
+
+            MapProjection projection = map.getProjection();
+            double width = map.getWidth() * getRelativeImageSize();
+            double height = map.getHeight() * getRelativeImageSize();
+            double x = (map.getWidth() - width) / 2d;
+            double y = (map.getHeight() - height) / 2d;
+
+            boundingBox = projection.viewportBoundsToBoundingBox(new BoundingBox(x, y, width, height));
+
+            if (boundingBox != null && boundingBox.hasValidBounds()) {
+                if (!Double.isNaN(getMinLatitude()) && boundingBox.getSouth() < getMinLatitude()) {
+                    boundingBox.setSouth(getMinLatitude());
+                }
+
+                if (!Double.isNaN(getMinLongitude()) && boundingBox.getWest() < getMinLongitude()) {
+                    boundingBox.setWest(getMinLongitude());
+                }
+
+                if (!Double.isNaN(getMaxLatitude()) && boundingBox.getNorth() > getMaxLatitude()) {
+                    boundingBox.setNorth(getMaxLatitude());
+                }
+
+                if (!Double.isNaN(getMaxLongitude()) && boundingBox.getEast() > getMaxLongitude()) {
+                    boundingBox.setEast(getMaxLongitude());
+                }
+
+                if (!Double.isNaN(getMaxBoundingBoxWidth()) && boundingBox.getWidth() > getMaxBoundingBoxWidth()) {
+                    double d = (boundingBox.getWidth() - getMaxBoundingBoxWidth()) / 2;
+                    boundingBox.setWest(boundingBox.getWest() + d);
+                    boundingBox.setEast(boundingBox.getEast() - d);
+                }
+            }
+
+            boolean imageUpdated = false;
+
+            try {
+                imageUpdated = updateImage(boundingBox);
+            } catch (Exception ex) {
+                Logger.getLogger(MapImageLayer.class.getName()).log(Level.SEVERE, null, ex);
+            }
+
+            if (!imageUpdated) {
+                updateImage((Image) null);
+            }
+        }
+    }
+
+    /**
+     * Creates an image request URL or a javafx.scene.image.Image for the specified bounding box.
+     * Must either call updateImage(String) or updateImage(Image) or return false on failure.
+     * 
+     * @param boundingBox the image's bounding box
+     * @return true on success, false on failure
+     */
+    protected abstract boolean updateImage(MapBoundingBox boundingBox);
+
+    protected void updateImage(String url) {
+        Image image = new Image(url, true);
+
+        image.progressProperty().addListener((observable, oldValue, newValue) -> {
+            if (newValue.doubleValue() >= 1d) {
+                updateImage(image);
+            }
+        });
+
+        image.errorProperty().addListener((observable, oldValue, newValue) -> {
+            if (newValue) {
+                updateImage((Image) null);
+            }
+        });
+    }
+
+    protected final void updateImage(Image image) {
+        MapBase map = getMap();
+
+        if (map != null) {
+            ObservableList<Node> children = getChildren();
+            MapImage mapImage;
+
+            if (children.isEmpty()) {
+                mapImage = new MapImage();
+                mapImage.setMap(map);
+                mapImage.setOpacity(0d);
+                children.add(mapImage);
+
+                mapImage = new MapImage();
+                mapImage.setMap(map);
+                mapImage.setOpacity(0d);
+            } else {
+                mapImage = (MapImage) children.remove(0);
+            }
+
+            children.add(mapImage);
+
+            mapImage.setImage(image);
+            mapImage.setBoundingBox(boundingBox != null ? boundingBox.clone() : null);
+
+            if (image != null) {
+                FadeTransition fadeTransition = new FadeTransition(map.getTileFadeDuration(), mapImage);
+                fadeTransition.setToValue(1d);
+                fadeTransition.setOnFinished(e -> children.get(0).setOpacity(0d));
+                fadeTransition.play();
+            } else {
+                children.get(0).setOpacity(0d);
+                children.get(1).setOpacity(0d);
+            }
+        }
+
+        updateInProgress = false;
+    }
+}
diff --git a/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapItem.java b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapItem.java
new file mode 100644
index 0000000..04d79c7
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapItem.java
@@ -0,0 +1,61 @@
+/*
+ * FX Map Control - https://github.com/ClemensFischer/FX-Map-Control
+ * © 2016 Clemens Fischer
+ */
+package fxmapcontrol;
+
+import javafx.beans.DefaultProperty;
+import javafx.beans.property.BooleanProperty;
+import javafx.beans.property.SimpleBooleanProperty;
+import javafx.scene.input.MouseButton;
+import javafx.scene.input.MouseEvent;
+
+/**
+ * Container node of an item in a MapItemsControl.
+ *
+ * @param <T> the item type of the parent MapItemsControl.
+ */
+public class MapItem<T> extends MapNode {
+
+    private final BooleanProperty selectedProperty = new SimpleBooleanProperty(this, "selected");
+    private final T item;
+
+    public MapItem() {
+        this(null, true);
+    }
+
+    public MapItem(T item) {
+        this(item, true);
+    }
+
+    public MapItem(T item, boolean defaultClickBehavior) {
+        this.item = item;
+
+        getStyleClass().add("map-item");
+
+        if (defaultClickBehavior) {
+            addEventHandler(MouseEvent.MOUSE_CLICKED, e -> {
+                if (e.getButton() == MouseButton.PRIMARY) {
+                    setSelected(!isSelected());
+                    e.consume();
+                }
+            });
+        }
+    }
+
+    public final T getItem() {
+        return item;
+    }
+
+    public final BooleanProperty selectedProperty() {
+        return selectedProperty;
+    }
+
+    public final boolean isSelected() {
+        return selectedProperty.get();
+    }
+
+    public final void setSelected(boolean selected) {
+        selectedProperty.set(selected);
+    }
+}
diff --git a/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapItemBase.java b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapItemBase.java
new file mode 100644
index 0000000..f3e3cd8
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapItemBase.java
@@ -0,0 +1,69 @@
+/*
+ * FX Map Control - https://github.com/ClemensFischer/FX-Map-Control
+ * © 2016 Clemens Fischer
+ */
+package fxmapcontrol;
+
+import javafx.beans.property.ObjectProperty;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.geometry.Point2D;
+
+/**
+ * Extendable base class for a map item with a location.
+ */
+public class MapItemBase extends MapLayer {
+    
+    private final ObjectProperty<Location> locationProperty = new SimpleObjectProperty<>(this, "location");
+
+    public MapItemBase() {
+        getStyleClass().add("map-item-base");
+        locationProperty.addListener(observable -> updateViewportPosition());
+    }
+
+    public final ObjectProperty<Location> locationProperty() {
+        return locationProperty;
+    }
+
+    public final Location getLocation() {
+        return locationProperty.get();
+    }
+
+    public final void setLocation(Location location) {
+        locationProperty.set(location);
+    }
+
+    @Override
+    public void setMap(MapBase map) {
+        super.setMap(map);
+        viewportChanged();
+    }
+
+    @Override
+    protected void viewportChanged() {
+        updateViewportPosition();
+    }
+
+    protected final void updateViewportPosition() {
+        Location location = getLocation();
+        Point2D viewportPosition = null;
+        MapBase map;
+
+        if (location != null && (map = getMap()) != null) {
+            viewportPosition = map.getProjection().locationToViewportPoint(new Location(
+                    location.getLatitude(),
+                    Location.nearestLongitude(location.getLongitude(), map.getCenter().getLongitude())));
+        }
+
+        viewportPositionChanged(viewportPosition);
+    }
+
+    protected void viewportPositionChanged(Point2D viewportPosition) {
+        if (viewportPosition != null) {
+            setTranslateX(viewportPosition.getX());
+            setTranslateY(viewportPosition.getY());
+        } else {
+            setTranslateX(0d);
+            setTranslateY(0d);
+        }
+    }
+}
diff --git a/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapItemsControl.java b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapItemsControl.java
new file mode 100644
index 0000000..744d1a7
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapItemsControl.java
@@ -0,0 +1,238 @@
+/*
+ * FX Map Control - https://github.com/ClemensFischer/FX-Map-Control
+ * © 2016 Clemens Fischer
+ */
+package fxmapcontrol;
+
+import java.util.Collection;
+import javafx.beans.property.ListProperty;
+import javafx.beans.property.ObjectProperty;
+import javafx.beans.property.ReadOnlyProperty;
+import javafx.beans.property.SimpleListProperty;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.beans.value.ChangeListener;
+import javafx.beans.value.ObservableValue;
+import javafx.collections.FXCollections;
+import javafx.collections.ListChangeListener;
+import javafx.collections.ObservableList;
+import javafx.scene.Parent;
+import javafx.scene.control.SelectionMode;
+import javafx.util.Callback;
+
+/**
+ * Manages a collection of selectable items on a map. Uses MapItem as item container node class.
+ *
+ * @param <T> the (element) type of the {@code items}, {@code selectedItem} and
+ * {@code selectedItems} properties. If T does not extend {@link MapItem}, an item generator
+ * callback must be assigned to the {@code itemGenerator} property.
+ */
+public class MapItemsControl<T> extends Parent implements IMapNode {
+
+    private final ListProperty<T> itemsProperty = new SimpleListProperty<>(this, "items", FXCollections.observableArrayList());
+    private final ObjectProperty<SelectionMode> selectionModeProperty = new SimpleObjectProperty<>(this, "selectionMode", SelectionMode.SINGLE);
+    private final ObjectProperty<T> selectedItemProperty = new SimpleObjectProperty<>(this, "selectedItem");
+    private final ObservableList<T> selectedItems = FXCollections.observableArrayList();
+    private final MapItemSelectedListener itemSelectedListener = new MapItemSelectedListener();
+    private Callback<T, MapItem<T>> itemGenerator;
+    private MapBase map;
+
+    public MapItemsControl() {
+        itemsProperty.addListener((ListChangeListener.Change<? extends T> change) -> {
+            while (change.next()) {
+                if (change.wasRemoved()) {
+                    removeChildren(change.getRemoved());
+                }
+                if (change.wasAdded()) {
+                    addChildren(change.getAddedSubList(), change.getFrom());
+                }
+            }
+        });
+
+        selectedItemProperty.addListener((observable, oldValue, newValue) -> {
+            if (oldValue != null && getSelectionMode() == SelectionMode.SINGLE) {
+                setItemSelected(oldValue, false);
+            }
+            if (newValue != null) {
+                setItemSelected(newValue, true);
+            }
+        });
+
+        selectedItems.addListener((ListChangeListener.Change<? extends T> change) -> {
+            while (change.next()) {
+                if (change.wasRemoved()) {
+                    change.getRemoved().forEach(item -> setItemSelected(item, false));
+                }
+                if (change.wasAdded()) {
+                    change.getAddedSubList().forEach(item -> setItemSelected(item, true));
+                }
+            }
+        });
+    }
+
+    @Override
+    public final MapBase getMap() {
+        return map;
+    }
+
+    @Override
+    public void setMap(MapBase map) {
+        this.map = map;
+
+        getChildren().forEach(item -> ((MapItem) item).setMap(map));
+    }
+
+    /**
+     * Gets the {@code Callback<T, MapItem>} that generates a MapItem if T does not extend MapItem.
+     *
+     * @return the {@code Callback<T, MapItem>}
+     */
+    public final Callback<T, MapItem<T>> getItemGenerator() {
+        return itemGenerator;
+    }
+
+    /**
+     * Sets the {@code Callback<T, MapItem>} that generates a MapItem if T does not extend MapItem.
+     *
+     * @param itemGenerator the {@code Callback<T, MapItem>}
+     */
+    public final void setItemGenerator(Callback<T, MapItem<T>> itemGenerator) {
+        this.itemGenerator = itemGenerator;
+    }
+
+    public final ListProperty<T> itemsProperty() {
+        return itemsProperty;
+    }
+
+    public final ObservableList<T> getItems() {
+        return itemsProperty.get();
+    }
+
+    public final void setItems(ObservableList<T> items) {
+        itemsProperty.set(items);
+    }
+
+    public final ObjectProperty<SelectionMode> selectionModeProperty() {
+        return selectionModeProperty;
+    }
+
+    public final SelectionMode getSelectionMode() {
+        return selectionModeProperty.get();
+    }
+
+    public final void setSelectionMode(SelectionMode selectionMode) {
+        selectionModeProperty.set(selectionMode);
+    }
+
+    public final ObjectProperty<T> selectedItemProperty() {
+        return selectedItemProperty;
+    }
+
+    public final T getSelectedItem() {
+        return selectedItemProperty.get();
+    }
+
+    public final void setSelectedItem(T selectedItem) {
+        selectedItemProperty.set(getItems().contains(selectedItem) ? selectedItem : null);
+    }
+
+    public final ObservableList<T> getSelectedItems() {
+        return selectedItems;
+    }
+
+    public final MapItem getMapItem(T item) {
+        return item instanceof MapItem
+                ? (MapItem) item
+                : (MapItem) getChildren().stream()
+                .filter(node -> ((MapItem) node).getItem() == item)
+                .findFirst().orElse(null);
+    }
+
+    private MapItem createMapItem(T item) {
+        return item instanceof MapItem
+                ? (MapItem) item
+                : (itemGenerator != null ? itemGenerator.call(item) : null);
+    }
+
+    private void setItemSelected(T item, boolean selected) {
+        if (getItems().contains(item)) {
+            MapItem mapItem = getMapItem(item);
+            if (mapItem != null) {
+                mapItem.setSelected(selected);
+            }
+        }
+    }
+
+    private void addChildren(Collection<? extends T> items, int position) {
+        for (T item : items) {
+            MapItem mapItem = createMapItem(item);
+            if (mapItem != null) {
+                mapItem.setMap(map);
+
+                if (mapItem.isSelected()) {
+                    addSelectedItem(item);
+                }
+
+                mapItem.selectedProperty().addListener(itemSelectedListener);
+                getChildren().add(position++, mapItem);
+            }
+        }
+    }
+
+    private void removeChildren(Collection<? extends T> items) {
+        for (T item : items) {
+            MapItem mapItem = getMapItem(item);
+            if (mapItem != null) {
+                mapItem.setMap(null);
+                mapItem.selectedProperty().removeListener(itemSelectedListener);
+                getChildren().remove(mapItem);
+            }
+            removeSelectedItem(item);
+        }
+    }
+
+    private void addSelectedItem(T item) {
+        if (getItems().contains(item) && !selectedItems.contains(item)) {
+            if (getSelectionMode() == SelectionMode.SINGLE) {
+                selectedItems.clear();
+            }
+            selectedItems.add(item);
+            if (getSelectedItem() == null || getSelectionMode() == SelectionMode.SINGLE) {
+                setSelectedItem(item);
+            }
+        }
+    }
+
+    private void removeSelectedItem(T item) {
+        if (selectedItems.remove(item) && getSelectedItem() == item) {
+            setSelectedItem(selectedItems.isEmpty() ? null : selectedItems.get(0));
+        }
+    }
+
+    private class MapItemSelectedListener implements ChangeListener<Boolean> {
+
+        private boolean selectionChanging;
+
+        @Override
+        public void changed(ObservableValue<? extends Boolean> observable, Boolean oldValue, Boolean newValue) {
+            if (!selectionChanging) {
+                selectionChanging = true;
+
+                ReadOnlyProperty<? extends Boolean> property = (ReadOnlyProperty<? extends Boolean>) observable;
+                MapItem mapItem = (MapItem) property.getBean();
+                T item = (T) mapItem.getItem();
+
+                if (item == null) {
+                    item = (T) mapItem;
+                }
+
+                if (newValue) {
+                    addSelectedItem(item);
+                } else {
+                    removeSelectedItem(item);
+                }
+
+                selectionChanging = false;
+            }
+        }
+    }
+}
diff --git a/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapLayer.java b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapLayer.java
new file mode 100644
index 0000000..e804cf5
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapLayer.java
@@ -0,0 +1,46 @@
+/*
+ * FX Map Control - https://github.com/ClemensFischer/FX-Map-Control
+ * © 2016 Clemens Fischer
+ */
+package fxmapcontrol;
+
+import javafx.beans.DefaultProperty;
+import javafx.collections.ObservableList;
+import javafx.scene.Node;
+import javafx.scene.Parent;
+
+/**
+ * Extendable base class for map overlays.
+ */
+@DefaultProperty(value="children")
+public class MapLayer extends Parent implements IMapNode {
+
+    private final MapNodeHelper mapNode = new MapNodeHelper(e -> viewportChanged());
+
+    public MapLayer() {
+        getStyleClass().add("map-layer");
+        getChildren().addListener(new MapNodeHelper.ChildrenListener(this));
+    }
+
+    @Override
+    public final ObservableList<Node> getChildren() {
+        return super.getChildren();
+    }
+
+    @Override
+    public final MapBase getMap() {
+        return mapNode.getMap();
+    }
+
+    @Override
+    public void setMap(MapBase map) {
+        mapNode.setMap(map);
+
+        getChildren().stream()
+                .filter(node -> node instanceof IMapNode)
+                .forEach(node -> ((IMapNode) node).setMap(map));
+    }
+
+    protected void viewportChanged() {
+    }
+}
diff --git a/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapLine.java b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapLine.java
new file mode 100644
index 0000000..99be3ff
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapLine.java
@@ -0,0 +1,103 @@
+/*
+ * FX Map Control - https://github.com/ClemensFischer/FX-Map-Control
+ * © 2016 Clemens Fischer
+ */
+package fxmapcontrol;
+
+import javafx.beans.property.ObjectProperty;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.geometry.Point2D;
+import javafx.scene.shape.Line;
+
+/**
+ * A Line with start and end points given as geographic positions.
+ */
+public class MapLine extends Line implements IMapNode {
+
+    private final ObjectProperty<Location> startLocationProperty = new SimpleObjectProperty<>(this, "startLocation");
+    private final ObjectProperty<Location> endLocationProperty = new SimpleObjectProperty<>(this, "endLocation");
+    private final MapNodeHelper mapNode = new MapNodeHelper(e -> updatePoints());
+    
+    public MapLine() {
+        getStyleClass().add("map-line");
+
+        startLocationProperty.addListener(observable -> updateStartPoint());
+        endLocationProperty.addListener(observable -> updateEndPoint());
+    }
+    
+    public MapLine(Location start, Location end) {
+        this();
+        setStartLocation(start);
+        setEndLocation(end);
+    }
+
+    @Override
+    public MapBase getMap() {
+        return mapNode.getMap();
+    }
+
+    @Override
+    public void setMap(MapBase map) {
+        mapNode.setMap(map);
+        updatePoints();
+    }
+
+    public final ObjectProperty<Location> startLocationProperty() {
+        return startLocationProperty;
+    }
+
+    public final Location getStartLocation() {
+        return startLocationProperty.get();
+    }
+
+    public final void setStartLocation(Location location) {
+        startLocationProperty.set(location);
+    }
+
+    public final ObjectProperty<Location> endLocationProperty() {
+        return endLocationProperty;
+    }
+
+    public final Location getEndLocation() {
+        return endLocationProperty.get();
+    }
+
+    public final void setEndLocation(Location location) {
+        endLocationProperty.set(location);
+    }
+
+    private void updatePoints() {
+        updateStartPoint();
+        updateEndPoint();
+    }
+
+    private void updateStartPoint() {
+        MapBase map = getMap();
+        Location start = getStartLocation();
+        if (map != null && start != null) {
+            Point2D p = map.getProjection().locationToViewportPoint(start);
+            setStartX(p.getX());
+            setStartY(p.getY());
+            setVisible(getEndLocation() != null);
+        } else {
+            setVisible(false);
+            setStartX(0);
+            setStartY(0);
+        }
+    }
+
+    private void updateEndPoint() {
+        MapBase map = getMap();
+        Location end = getEndLocation();
+        if (map != null && end != null) {
+            Point2D p = map.getProjection().locationToViewportPoint(end);
+            setEndX(p.getX());
+            setEndY(p.getY());
+            setVisible(getStartLocation() != null);
+        } else {
+            setVisible(false);
+            setEndX(0);
+            setEndY(0);
+        }
+    }
+}
diff --git a/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapNode.java b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapNode.java
new file mode 100644
index 0000000..0a8bdb3
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapNode.java
@@ -0,0 +1,97 @@
+/*
+ * FX Map Control - https://github.com/ClemensFischer/FX-Map-Control
+ * © 2016 Clemens Fischer
+ */
+package fxmapcontrol;
+
+import javafx.beans.DefaultProperty;
+import javafx.beans.property.ObjectProperty;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.collections.ObservableList;
+import javafx.geometry.Point2D;
+import javafx.scene.Node;
+import javafx.scene.Parent;
+
+/**
+ * Base class for map items and overlays. If the location property is set to a non-null value, the
+ * MapNode is placed at the appropriate map viewport position, otherwise at the top left corner.
+ */
+@DefaultProperty(value = "children")
+public class MapNode extends Parent implements IMapNode {
+
+    private final MapNodeHelper mapNode = new MapNodeHelper(e -> viewportChanged());
+    private final ObjectProperty<Location> locationProperty = new SimpleObjectProperty<>(this, "location");
+
+    public MapNode() {
+        getStyleClass().add("map-node");
+        getChildren().addListener(new MapNodeHelper.ChildrenListener(this));
+        locationProperty.addListener(observable -> updateViewportPosition());
+    }
+
+    public final ObjectProperty<Location> locationProperty() {
+        return locationProperty;
+    }
+
+    public final Location getLocation() {
+        return locationProperty.get();
+    }
+
+    public final void setLocation(Location location) {
+        locationProperty.set(location);
+    }
+
+    @Override
+    public final ObservableList<Node> getChildren() {
+        return super.getChildren();
+    }
+
+    @Override
+    public final MapBase getMap() {
+        return mapNode.getMap();
+    }
+
+    @Override
+    public void setMap(MapBase map) {
+        mapNode.setMap(map);
+
+        getChildren().stream()
+                .filter(node -> node instanceof IMapNode)
+                .forEach(node -> ((IMapNode) node).setMap(map));
+
+        viewportChanged();
+    }
+
+    protected void viewportChanged() {
+        updateViewportPosition();
+    }
+
+    protected void viewportPositionChanged(Point2D viewportPosition) {
+        if (viewportPosition != null) {
+            setTranslateX(viewportPosition.getX());
+            setTranslateY(viewportPosition.getY());
+        } else {
+            setTranslateX(0d);
+            setTranslateY(0d);
+        }
+    }
+
+    private void updateViewportPosition() {
+        Location location = getLocation();
+        Point2D viewportPosition = null;
+        MapBase map;
+
+        if (location != null && (map = getMap()) != null) {
+            viewportPosition = map.getProjection().locationToViewportPoint(location);
+
+            if (viewportPosition.getX() < 0d || viewportPosition.getX() > map.getWidth()
+                    || viewportPosition.getY() < 0d || viewportPosition.getY() > map.getHeight()) {
+
+                viewportPosition = map.getProjection().locationToViewportPoint(new Location(
+                        location.getLatitude(),
+                        Location.nearestLongitude(location.getLongitude(), map.getCenter().getLongitude())));
+            }
+        }
+
+        viewportPositionChanged(viewportPosition);
+    }
+}
diff --git a/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapNodeHelper.java b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapNodeHelper.java
new file mode 100644
index 0000000..74a1c7f
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapNodeHelper.java
@@ -0,0 +1,66 @@
+/*
+ * FX Map Control - https://github.com/ClemensFischer/FX-Map-Control
+ * © 2017 Clemens Fischer
+ */
+package fxmapcontrol;
+
+import javafx.collections.ListChangeListener;
+import javafx.event.EventHandler;
+import javafx.scene.Node;
+
+/**
+ * Helper class for implementing the IMapNode interface.
+ */
+public final class MapNodeHelper implements IMapNode {
+
+    private final EventHandler<ViewportChangedEvent> viewportChangedHandler;
+    private MapBase map;
+
+    public MapNodeHelper(EventHandler<ViewportChangedEvent> viewportChangedHandler) {
+        this.viewportChangedHandler = viewportChangedHandler;
+    }
+
+    @Override
+    public final MapBase getMap() {
+        return map;
+    }
+
+    @Override
+    public final void setMap(MapBase map) {
+        if (this.map != null) {
+            this.map.removeEventHandler(ViewportChangedEvent.VIEWPORT_CHANGED, viewportChangedHandler);
+        }
+
+        this.map = map;
+
+        if (this.map != null) {
+            this.map.addEventHandler(ViewportChangedEvent.VIEWPORT_CHANGED, viewportChangedHandler);
+        }
+    }
+
+    public static class ChildrenListener implements ListChangeListener<Node> {
+
+        private final IMapNode mapNode;
+
+        public ChildrenListener(IMapNode parentNode) {
+            mapNode = parentNode;
+        }
+
+        @Override
+        public void onChanged(ListChangeListener.Change<? extends Node> change) {
+            while (change.next()) {
+                if (change.wasRemoved()) {
+                    change.getRemoved().stream()
+                            .filter(node -> node instanceof IMapNode)
+                            .forEach(node -> ((IMapNode) node).setMap(null));
+                }
+                if (change.wasAdded()) {
+                    change.getAddedSubList().stream()
+                            .filter(node -> node instanceof IMapNode)
+                            .forEach(node -> ((IMapNode) node).setMap(mapNode.getMap()));
+
+                }
+            }
+        }
+    }
+}
diff --git a/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapPolygon.java b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapPolygon.java
new file mode 100644
index 0000000..e9b1230
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapPolygon.java
@@ -0,0 +1,86 @@
+/*
+ * FX Map Control - https://github.com/ClemensFischer/FX-Map-Control
+ * © 2016 Clemens Fischer
+ */
+package fxmapcontrol;
+
+import java.util.Collection;
+import java.util.List;
+import javafx.beans.property.ListProperty;
+import javafx.beans.property.ObjectProperty;
+import javafx.beans.property.SimpleListProperty;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.collections.FXCollections;
+import javafx.collections.ListChangeListener;
+import javafx.collections.ObservableList;
+import javafx.scene.shape.Polygon;
+import javafx.scene.shape.StrokeLineJoin;
+
+/**
+ * A Polygon with points given as geographic positions by the locations property. The optional location property helps
+ * to calculate a viewport position that is nearest to the map center, in the same way as it is done for MapItems.
+ */
+public class MapPolygon extends Polygon implements IMapNode {
+
+    private final ListProperty<Location> locationsProperty = new SimpleListProperty<>(this, "locations", FXCollections.observableArrayList());
+    private final ObjectProperty<Location> locationProperty = new SimpleObjectProperty<>(this, "location");
+    private final MapNodeHelper mapNode = new MapNodeHelper(e -> updatePoints());
+
+    public MapPolygon() {
+        getStyleClass().add("map-polygon");
+        setFill(null);
+        setStrokeLineJoin(StrokeLineJoin.ROUND);
+
+        locationsProperty.addListener((ListChangeListener.Change<? extends Location> c) -> updatePoints());
+    }
+
+    public MapPolygon(Collection<Location> locations) {
+        this();
+        getLocations().addAll(locations);
+    }
+
+    @Override
+    public final MapBase getMap() {
+        return mapNode.getMap();
+    }
+
+    @Override
+    public void setMap(MapBase map) {
+        mapNode.setMap(map);
+        updatePoints();
+    }
+
+    public final ListProperty<Location> locationsProperty() {
+        return locationsProperty;
+    }
+
+    public final ObservableList<Location> getLocations() {
+        return locationsProperty.get();
+    }
+
+    public final void setLocations(ObservableList<Location> locations) {
+        locationsProperty.set(locations);
+    }
+
+    public final ObjectProperty<Location> locationProperty() {
+        return locationProperty;
+    }
+
+    public final Location getLocation() {
+        return locationProperty.get();
+    }
+
+    public final void setLocation(Location location) {
+        locationProperty.set(location);
+    }
+
+    private void updatePoints() {
+        List<Double> points = MapPolyline.updatePoints(getMap(), getLocation(), getLocations());
+        
+        if (points != null) {
+            getPoints().setAll(points);
+        } else {
+            getPoints().setAll(new Double[] { -1000d, -1000d }); // clear() or empty collection is ignored
+        }
+    }
+}
diff --git a/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapPolyline.java b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapPolyline.java
new file mode 100644
index 0000000..1f03010
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapPolyline.java
@@ -0,0 +1,126 @@
+/*
+ * FX Map Control - https://github.com/ClemensFischer/FX-Map-Control
+ * © 2016 Clemens Fischer
+ */
+package fxmapcontrol;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import javafx.beans.property.ListProperty;
+import javafx.beans.property.ObjectProperty;
+import javafx.beans.property.SimpleListProperty;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.collections.FXCollections;
+import javafx.collections.ListChangeListener;
+import javafx.collections.ObservableList;
+import javafx.geometry.Point2D;
+import javafx.scene.shape.Polyline;
+import javafx.scene.shape.StrokeLineJoin;
+
+/**
+ * A Polyline with points given as geographic positions by the locations property. The optional
+ * location property helps to calculate a viewport position that is nearest to the map center, in
+ * the same way as it is done for MapItems.
+ */
+public class MapPolyline extends Polyline implements IMapNode {
+
+    private final ListProperty<Location> locationsProperty = new SimpleListProperty<>(this, "locations", FXCollections.observableArrayList());
+    private final ObjectProperty<Location> locationProperty = new SimpleObjectProperty<>(this, "location");
+    private final MapNodeHelper mapNode = new MapNodeHelper(e -> updatePoints());
+
+    public MapPolyline() {
+        getStyleClass().add("map-polyline");
+        setFill(null);
+        setStrokeLineJoin(StrokeLineJoin.ROUND);
+
+        locationsProperty.addListener((ListChangeListener.Change<? extends Location> c) -> updatePoints());
+    }
+
+    public MapPolyline(Collection<Location> locations) {
+        this();
+        getLocations().addAll(locations);
+    }
+
+    @Override
+    public final MapBase getMap() {
+        return mapNode.getMap();
+    }
+
+    @Override
+    public void setMap(MapBase map) {
+        mapNode.setMap(map);
+        updatePoints();
+    }
+
+    public final ListProperty<Location> locationsProperty() {
+        return locationsProperty;
+    }
+
+    public final ObservableList<Location> getLocations() {
+        return locationsProperty.get();
+    }
+
+    public final void setLocations(ObservableList<Location> locations) {
+        locationsProperty.set(locations);
+    }
+
+    public final ObjectProperty<Location> locationProperty() {
+        return locationProperty;
+    }
+
+    public final Location getLocation() {
+        return locationProperty.get();
+    }
+
+    public final void setLocation(Location location) {
+        locationProperty.set(location);
+    }
+
+    private void updatePoints() {
+        List<Double> points = updatePoints(getMap(), getLocation(), getLocations());
+        
+        if (points != null) {
+            getPoints().setAll(points);
+        } else {
+            getPoints().setAll(new Double[] { -1000d, -1000d }); // clear() or empty collection is ignored
+        }
+    }
+
+    static List<Double> updatePoints(MapBase map, Location location, ObservableList<Location> locations) {
+        ArrayList<Double> points = null;
+
+        if (map != null && locations != null && !locations.isEmpty()) {
+            MapProjection projection = map.getProjection();
+            double longitudeOffset = 0d;
+
+            if (location != null && projection.isNormalCylindrical()) {
+                Point2D viewportPosition = projection.locationToViewportPoint(location);
+
+                if (viewportPosition.getX() < 0d || viewportPosition.getX() > map.getWidth()
+                        || viewportPosition.getY() < 0d || viewportPosition.getY() > map.getHeight()) {
+
+                    double nearestLongitude = Location.nearestLongitude(location.getLongitude(), map.getCenter().getLongitude());
+                    longitudeOffset = nearestLongitude - location.getLongitude();
+                }
+            }
+
+            points = new ArrayList<>(locations.size() * 2);
+
+            for (Location loc : locations) {
+                Point2D p = projection.locationToViewportPoint(
+                        new Location(loc.getLatitude(), loc.getLongitude() + longitudeOffset));
+
+                if (Double.isInfinite(p.getX()) || Double.isInfinite(p.getY())) {
+                    points = null;
+                    break;
+                }
+
+                points.add(p.getX());
+                points.add(p.getY());
+            }
+        }
+        
+        return points;
+    }
+}
diff --git a/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapProjection.java b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapProjection.java
new file mode 100644
index 0000000..9f57f24
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapProjection.java
@@ -0,0 +1,170 @@
+/*
+ * FX Map Control - https://github.com/ClemensFischer/FX-Map-Control
+ * © 2017 Clemens Fischer
+ */
+package fxmapcontrol;
+
+import java.util.Locale;
+import javafx.geometry.BoundingBox;
+import javafx.geometry.Bounds;
+import javafx.geometry.Point2D;
+import javafx.scene.transform.Affine;
+import javafx.scene.transform.NonInvertibleTransformException;
+
+/**
+ * Defines a map projection between geographic coordinates and cartesian map coordinates and
+ * viewport coordinates, i.e. pixels.
+ */
+public abstract class MapProjection {
+
+    public static final double WGS84_EQUATORIAL_RADIUS = 6378137d;
+    public static final double WGS84_FLATTENING = 1d / 298.257223563;
+
+    public static final double METERS_PER_DEGREE = WGS84_EQUATORIAL_RADIUS * Math.PI / 180d;
+
+    protected final Affine viewportTransform = new Affine();
+    protected final Affine inverseViewportTransform = new Affine();
+    protected double viewportScale;
+    protected String crsId;
+
+    /**
+     * Gets the WMS 1.3.0 CRS Identifier.
+     */
+    public String getCrsId() {
+        return crsId;
+    }
+
+    /**
+     * Sets the WMS 1.3.0 CRS Identifier.
+     */
+    public void setCrsId(String crsId) {
+        this.crsId = crsId;
+    }
+
+    /**
+     * Gets the transformation from cartesian map coordinates to viewport coordinates.
+     */
+    public Affine getViewportTransform() {
+        return viewportTransform;
+    }
+
+    /**
+     * Gets the transformation from viewport coordinates to cartesian map coordinates.
+     */
+    public Affine getInverseViewportTransform() {
+        return inverseViewportTransform;
+    }
+    
+    /**
+     * Indicates if this is a web mercator projection, i.e. compatible with TileLayer.
+     */
+    public abstract boolean isWebMercator();
+    
+    /**
+     * Indicates if this is a normal cylindrical projection, i.e. compatible with MapGraticule.
+     */
+    public abstract boolean isNormalCylindrical();
+    
+    /**
+     * Indicates if this is an azimuthal projection.
+     */
+    public abstract boolean isAzimuthal();
+
+    /**
+     * Gets the absolute value of the minimum and maximum latitude that can be transformed.
+     */
+    public abstract double maxLatitude();
+
+    /**
+     * Gets the map scale at the specified Location as viewport coordinate units per meter (px/m).
+     */
+    public abstract Point2D getMapScale(Location location);
+
+    /**
+     * Transforms a Location in geographic coordinates to a Point2D in cartesian map coordinates.
+     */
+    public abstract Point2D locationToPoint(Location location);
+
+    /**
+     * Transforms a Point2D in cartesian map coordinates to a Location in geographic coordinates.
+     */
+    public abstract Location pointToLocation(Point2D point);
+
+    /**
+     * Transforms a MapBoundingBox in geographic coordinates to Bounds in cartesian map coordinates.
+     */
+    public Bounds boundingBoxToBounds(MapBoundingBox boundingBox) {
+        Point2D sw = locationToPoint(new Location(boundingBox.getSouth(), boundingBox.getWest()));
+        Point2D ne = locationToPoint(new Location(boundingBox.getNorth(), boundingBox.getEast()));
+
+        return new BoundingBox(sw.getX(), sw.getY(), ne.getX() - sw.getX(), ne.getY() - sw.getY());
+    }
+
+    /**
+     * Transforms Bounds in cartesian map coordinates to a BoundingBox in geographic coordinates.
+     */
+    public MapBoundingBox boundsToBoundingBox(Bounds bounds) {
+        Location sw = pointToLocation(new Point2D(bounds.getMinX(), bounds.getMinY()));
+        Location ne = pointToLocation(new Point2D(bounds.getMaxX(), bounds.getMaxY()));
+
+        return new MapBoundingBox(sw.getLatitude(), sw.getLongitude(), ne.getLatitude(), ne.getLongitude());
+    }
+
+    public Point2D locationToViewportPoint(Location location) {
+        return viewportTransform.transform(locationToPoint(location));
+    }
+
+    public Location viewportPointToLocation(Point2D point) {
+        return pointToLocation(inverseViewportTransform.transform(point));
+    }
+
+    public MapBoundingBox viewportBoundsToBoundingBox(Bounds bounds) {
+        return boundsToBoundingBox(inverseViewportTransform.transform(bounds));
+    }
+
+    public double getViewportScale(double zoomLevel) {
+        return Math.pow(2d, zoomLevel) * TileSource.TILE_SIZE / 360d;
+    }
+
+    public void setViewportTransform(Location projectionCenter, Location mapCenter, Point2D viewportCenter, double zoomLevel, double heading) {
+        viewportScale = getViewportScale(zoomLevel);
+
+        Point2D center = locationToPoint(mapCenter);
+
+        Affine transform = new Affine();
+        transform.prependTranslation(-center.getX(), -center.getY());
+        transform.prependScale(viewportScale, -viewportScale);
+        transform.prependRotation(heading);
+        transform.prependTranslation(viewportCenter.getX(), viewportCenter.getY());
+        viewportTransform.setToTransform(transform);
+
+        try {
+            transform.invert();
+        } catch (NonInvertibleTransformException ex) {
+            throw new RuntimeException(ex); // this will never happen
+        }
+
+        inverseViewportTransform.setToTransform(transform);
+    }
+
+    public String wmsQueryParameters(MapBoundingBox boundingBox, String version) {
+        if (crsId == null || crsId.isEmpty()) {
+            return null;
+        }
+
+        String format = "CRS=%s&BBOX=%f,%f,%f,%f&WIDTH=%d&HEIGHT=%d";
+
+        if (version.startsWith("1.1.")) {
+            format = "SRS=%s&BBOX=%f,%f,%f,%f&WIDTH=%d&HEIGHT=%d";
+        } else if (crsId.equals("EPSG:4326")) {
+            format = "CRS=%1$s&BBOX=%3$f,%2$f,%5$f,%4$f&WIDTH=%6$d&HEIGHT=%7$d";
+        }
+
+        Bounds bounds = boundingBoxToBounds(boundingBox);
+
+        return String.format(Locale.ROOT, format, crsId,
+                bounds.getMinX(), bounds.getMinY(), bounds.getMaxX(), bounds.getMaxY(),
+                (int) Math.round(viewportScale * bounds.getWidth()),
+                (int) Math.round(viewportScale * bounds.getHeight()));
+    }
+}
diff --git a/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapTileLayer.java b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapTileLayer.java
new file mode 100644
index 0000000..7c8534c
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/MapTileLayer.java
@@ -0,0 +1,369 @@
+/*
+ * FX Map Control - https://github.com/ClemensFischer/FX-Map-Control
+ * © 2016 Clemens Fischer
+ */
+package fxmapcontrol;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
+import javafx.animation.KeyFrame;
+import javafx.animation.Timeline;
+import javafx.beans.property.BooleanProperty;
+import javafx.beans.property.IntegerProperty;
+import javafx.beans.property.ObjectProperty;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.css.CssMetaData;
+import javafx.css.SimpleStyleableBooleanProperty;
+import javafx.css.SimpleStyleableIntegerProperty;
+import javafx.css.SimpleStyleableObjectProperty;
+import javafx.css.Styleable;
+import javafx.css.StyleableBooleanProperty;
+import javafx.css.StyleableIntegerProperty;
+import javafx.css.StyleableObjectProperty;
+import javafx.css.StyleablePropertyFactory;
+import javafx.geometry.Point2D;
+import javafx.scene.Parent;
+import javafx.scene.image.ImageView;
+import javafx.scene.transform.Affine;
+import javafx.util.Duration;
+
+/**
+ * Fills the map viewport with map tiles from a TileSource.
+ */
+public class MapTileLayer extends Parent implements IMapNode {
+
+    private static final StyleablePropertyFactory<MapTileLayer> propertyFactory
+            = new StyleablePropertyFactory<>(Parent.getClassCssMetaData());
+
+    private static final CssMetaData<MapTileLayer, Number> maxDownloadThreadsCssMetaData
+            = propertyFactory.createSizeCssMetaData("-fx-max-download-threads", s -> s.maxDownloadThreadsProperty);
+
+    private static final CssMetaData<MapTileLayer, Duration> updateDelayCssMetaData
+            = propertyFactory.createDurationCssMetaData("-fx-update-delay", s -> s.updateDelayProperty);
+
+    private static final CssMetaData<MapTileLayer, Boolean> updateWhileViewportChangingCssMetaData
+            = propertyFactory.createBooleanCssMetaData("-fx-update-while-viewport-changing", s -> s.updateWhileViewportChangingProperty);
+
+    private final StyleableIntegerProperty maxDownloadThreadsProperty
+            = new SimpleStyleableIntegerProperty(maxDownloadThreadsCssMetaData, this, "maxDownloadThreads", 4);
+
+    private final StyleableObjectProperty<Duration> updateDelayProperty
+            = new SimpleStyleableObjectProperty<>(updateDelayCssMetaData, this, "updateDelay", Duration.seconds(0.2));
+
+    private final StyleableBooleanProperty updateWhileViewportChangingProperty
+            = new SimpleStyleableBooleanProperty(updateWhileViewportChangingCssMetaData, this, "updateWhileViewportChanging", true);
+
+    private final ObjectProperty<TileSource> tileSourceProperty = new SimpleObjectProperty<>(this, "tileSource");
+
+    private final ITileImageLoader tileImageLoader;
+    private final Timeline updateTimeline = new Timeline();
+    private final MapNodeHelper mapNode = new MapNodeHelper(e -> viewportChanged(e.getProjectionChanged(), e.getLongitudeOffset()));
+    private ArrayList<Tile> tiles = new ArrayList<>();
+    private TileGrid tileGrid;
+    private double zoomLevelOffset;
+    private int minZoomLevel;
+    private int maxZoomLevel = 18;
+    private String name;
+    
+    public static MapTileLayer getOpenStreetMapLayer() {
+        return new MapTileLayer("OpenStreetMap", "http://{c}.tile.openstreetmap.org/{z}/{x}/{y}.png", 0, 19);
+    }
+
+    public MapTileLayer() {
+        this(new TileImageLoader());
+    }
+
+    public MapTileLayer(String name, String tileUrlFormat, int minZoomLevel, int maxZoomLevel) {
+        this(new TileImageLoader(), name, tileUrlFormat, minZoomLevel, maxZoomLevel);
+    }
+
+    public MapTileLayer(ITileImageLoader tileImageLoader, String name, String tileUrlFormat, int minZoomLevel, int maxZoomLevel) {
+        this(tileImageLoader);
+        this.name = name;
+        tileSourceProperty.set(new TileSource(tileUrlFormat));
+        this.minZoomLevel = minZoomLevel;
+        this.maxZoomLevel = maxZoomLevel;
+    }
+
+    public MapTileLayer(ITileImageLoader tileImageLoader) {
+        getStyleClass().add("map-tile-layer");
+        getTransforms().add(new Affine());
+        setMouseTransparent(true);
+
+        this.tileImageLoader = tileImageLoader;
+
+        tileSourceProperty.addListener(observable -> updateTiles(true));
+
+        updateTimeline.getKeyFrames().add(new KeyFrame(getUpdateDelay(), e -> updateTileGrid()));
+
+        updateDelayProperty.addListener(observable
+                -> updateTimeline.getKeyFrames().set(0, new KeyFrame(getUpdateDelay(), e -> updateTileGrid())));
+    }
+
+    public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
+        return propertyFactory.getCssMetaData();
+    }
+
+    @Override
+    public List<CssMetaData<? extends Styleable, ?>> getCssMetaData() {
+        return getClassCssMetaData();
+    }
+
+    @Override
+    public final MapBase getMap() {
+        return mapNode.getMap();
+    }
+
+    @Override
+    public void setMap(MapBase map) {
+        mapNode.setMap(map);
+        updateTileGrid();
+    }
+
+    public final IntegerProperty maxDownloadThreadsProperty() {
+        return maxDownloadThreadsProperty;
+    }
+
+    public final int getMaxDownloadThreads() {
+        return maxDownloadThreadsProperty.get();
+    }
+
+    public final void setMaxDownloadThreads(int maxDownloadThreads) {
+        maxDownloadThreadsProperty.set(maxDownloadThreads);
+    }
+
+    public final ObjectProperty<Duration> updateDelayProperty() {
+        return updateDelayProperty;
+    }
+
+    public final Duration getUpdateDelay() {
+        return updateDelayProperty.get();
+    }
+
+    public final void setUpdateDelay(Duration updateDelay) {
+        updateDelayProperty.set(updateDelay);
+    }
+
+    public final BooleanProperty updateWhileViewportChangingProperty() {
+        return updateWhileViewportChangingProperty;
+    }
+
+    public final boolean getUpdateWhileViewportChanging() {
+        return updateWhileViewportChangingProperty.get();
+    }
+
+    public final void setUpdateWhileViewportChanging(boolean updateWhileViewportChanging) {
+        updateWhileViewportChangingProperty.set(updateWhileViewportChanging);
+    }
+
+    public final ObjectProperty<TileSource> tileSourceProperty() {
+        return tileSourceProperty;
+    }
+
+    public final TileSource getTileSource() {
+        return tileSourceProperty.get();
+    }
+
+    public final void setTileSource(TileSource tileSource) {
+        tileSourceProperty.set(tileSource);
+    }
+
+    public final double getZoomLevelOffset() {
+        return zoomLevelOffset;
+    }
+
+    public final void setZoomLevelOffset(double zoomLevelOffset) {
+        this.zoomLevelOffset = zoomLevelOffset;
+    }
+
+    public final int getMinZoomLevel() {
+        return minZoomLevel;
+    }
+
+    public final void setMinZoomLevel(int minZoomLevel) {
+        this.minZoomLevel = minZoomLevel;
+    }
+
+    public final int getMaxZoomLevel() {
+        return maxZoomLevel;
+    }
+
+    public final void setMaxZoomLevel(int maxZoomLevel) {
+        this.maxZoomLevel = maxZoomLevel;
+    }
+
+    public final String getName() {
+        return name;
+    }
+
+    public final void setName(String name) {
+        this.name = name;
+    }
+
+    protected void updateTileGrid() {
+        updateTimeline.stop();
+
+        if (getMap() != null && getMap().getProjection().isWebMercator()) {
+            TileGrid grid = getTileGrid();
+
+            if (!grid.equals(tileGrid)) {
+                tileGrid = grid;
+                setTransform();
+                updateTiles(false);
+            }
+        } else {
+            tileGrid = null;
+            updateTiles(true);
+        }
+    }
+
+    private void viewportChanged(boolean projectionChanged, double longitudeOffset) {
+        if (tileGrid == null || projectionChanged || Math.abs(longitudeOffset) > 180d) {
+            // update immediately when map projection has changed or map center has moved across 180° longitude
+            updateTileGrid();
+
+        } else {
+            setTransform();
+
+            if (getUpdateWhileViewportChanging()) {
+                updateTimeline.play();
+            } else {
+                updateTimeline.playFromStart();
+            }
+        }
+    }
+
+    private Point2D getTileCenter(double tileScale) {
+        Location center = getMap().getCenter();
+
+        return new Point2D(
+                tileScale * (0.5 + center.getLongitude() / 360d),
+                tileScale * (0.5 - WebMercatorProjection.latitudeToY(center.getLatitude()) / 360d));
+    }
+
+    private TileGrid getTileGrid() {
+        MapBase map = getMap();
+
+        int tileZoomLevel = Math.max(0, (int) Math.floor(map.getZoomLevel() + zoomLevelOffset));
+        double tileScale = (1 << tileZoomLevel);
+        double scale = tileScale / (Math.pow(2d, map.getZoomLevel()) * TileSource.TILE_SIZE);
+        Point2D tileCenter = getTileCenter(tileScale);
+
+        Affine transform = new Affine();
+        transform.prependTranslation(-map.getWidth() / 2d, -map.getHeight() / 2d);
+        transform.prependScale(scale, scale);
+        transform.prependRotation(-map.getHeading());
+        transform.prependTranslation(tileCenter.getX(), tileCenter.getY());
+
+        // get tile index values of viewport rectangle
+        Point2D p1 = transform.transform(new Point2D(0d, 0d));
+        Point2D p2 = transform.transform(new Point2D(map.getWidth(), 0d));
+        Point2D p3 = transform.transform(new Point2D(0d, map.getHeight()));
+        Point2D p4 = transform.transform(new Point2D(map.getWidth(), map.getHeight()));
+
+        return new TileGrid(tileZoomLevel,
+                (int) Math.floor(Math.min(Math.min(p1.getX(), p2.getX()), Math.min(p3.getX(), p4.getX()))),
+                (int) Math.floor(Math.min(Math.min(p1.getY(), p2.getY()), Math.min(p3.getY(), p4.getY()))),
+                (int) Math.floor(Math.max(Math.max(p1.getX(), p2.getX()), Math.max(p3.getX(), p4.getX()))),
+                (int) Math.floor(Math.max(Math.max(p1.getY(), p2.getY()), Math.max(p3.getY(), p4.getY()))));
+    }
+
+    private void setTransform() {
+        MapBase map = getMap();
+
+        double tileScale = (1 << tileGrid.getZoomLevel());
+        double scale = Math.pow(2d, map.getZoomLevel()) / tileScale;
+        Point2D tileCenter = getTileCenter(tileScale);
+        double tileOriginX = TileSource.TILE_SIZE * (tileCenter.getX() - tileGrid.getXMin());
+        double tileOriginY = TileSource.TILE_SIZE * (tileCenter.getY() - tileGrid.getYMin());
+
+        Affine transform = new Affine();
+        transform.prependTranslation(-tileOriginX, -tileOriginY);
+        transform.prependScale(scale, scale);
+        transform.prependRotation(map.getHeading());
+        transform.prependTranslation(map.getWidth() / 2d, map.getHeight() / 2d);
+
+        getTransforms().set(0, transform);
+    }
+
+    private void updateTiles(boolean clearTiles) {
+        if (!tiles.isEmpty()) {
+            tileImageLoader.cancelLoadTiles(this);
+        }
+
+        if (clearTiles) {
+            tiles.clear();
+        }
+
+        MapBase map = getMap();
+        ArrayList<Tile> newTiles = new ArrayList<>();
+
+        if (map != null && tileGrid != null && getTileSource() != null) {
+            int maxZoom = Math.min(tileGrid.getZoomLevel(), maxZoomLevel);
+            int minZoom = minZoomLevel;
+
+            if (minZoom < maxZoom && this != map.getChildrenUnmodifiable().stream().findFirst().orElse(null)) {
+                // do not load background tiles if this is not the base layer
+                minZoom = maxZoom;
+            }
+
+            for (int tz = minZoom; tz <= maxZoom; tz++) {
+                int tileSize = 1 << (tileGrid.getZoomLevel() - tz);
+                int x1 = (int) Math.floor((double) tileGrid.getXMin() / tileSize); // may be negative
+                int x2 = tileGrid.getXMax() / tileSize;
+                int y1 = Math.max(tileGrid.getYMin() / tileSize, 0);
+                int y2 = Math.min(tileGrid.getYMax() / tileSize, (1 << tz) - 1);
+
+                for (int ty = y1; ty <= y2; ty++) {
+                    for (int tx = x1; tx <= x2; tx++) {
+                        int z = tz;
+                        int x = tx;
+                        int y = ty;
+                        Tile tile = tiles.stream()
+                                .filter(t -> t.getZoomLevel() == z && t.getX() == x && t.getY() == y)
+                                .findAny().orElse(null);
+
+                        if (tile == null) {
+                            tile = new Tile(z, x, y);
+                            int xIndex = tile.getXIndex();
+
+                            Tile equivalentTile = tiles.stream()
+                                    .filter(t -> t.getZoomLevel() == z && t.getXIndex() == xIndex && t.getY() == y && t.getImage() != null)
+                                    .findAny().orElse(null);
+
+                            if (equivalentTile != null) {
+                                tile.setImage(equivalentTile.getImage(), false);
+                            }
+                        }
+
+                        newTiles.add(tile);
+                    }
+                }
+            }
+        }
+
+        tiles = newTiles;
+
+        if (tiles.isEmpty()) {
+            getChildren().clear();
+
+        } else {
+            getChildren().setAll(tiles.stream()
+                    .map(tile -> {
+                        ImageView imageView = tile.getImageView();
+                        int size = TileSource.TILE_SIZE << (tileGrid.getZoomLevel() - tile.getZoomLevel());
+                        imageView.setX(size * tile.getX() - TileSource.TILE_SIZE * tileGrid.getXMin());
+                        imageView.setY(size * tile.getY() - TileSource.TILE_SIZE * tileGrid.getYMin());
+                        imageView.setFitWidth(size);
+                        imageView.setFitHeight(size);
+                        return imageView;
+                    })
+                    .collect(Collectors.toList()));
+
+            tileImageLoader.beginLoadTiles(this, tiles.stream()
+                    .filter(tile -> tile.isPending())
+                    .collect(Collectors.toList()));
+        }
+    }
+}
diff --git a/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/StereographicProjection.java b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/StereographicProjection.java
new file mode 100644
index 0000000..30f05fe
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/StereographicProjection.java
@@ -0,0 +1,54 @@
+/*
+ * FX Map Control - https://github.com/ClemensFischer/FX-Map-Control
+ * © 2017 Clemens Fischer
+ */
+package fxmapcontrol;
+
+import static fxmapcontrol.AzimuthalProjection.getAzimuthDistance;
+import javafx.geometry.Point2D;
+import static fxmapcontrol.AzimuthalProjection.getLocation;
+
+/**
+ * Transforms geographic coordinates to cartesian coordinates according to the Stereographic Projection.
+ */
+public class StereographicProjection extends AzimuthalProjection {
+
+    public StereographicProjection() {
+        this("AUTO2:97002"); // GeoServer non-standard CRS ID
+    }
+
+    public StereographicProjection(String crsId) {
+        this.crsId = crsId;
+    }
+
+    @Override
+    public Point2D locationToPoint(Location location) {
+        if (location.equals(projectionCenter)) {
+            return new Point2D(0d, 0d);
+        }
+
+        double[] azimuthDistance = getAzimuthDistance(projectionCenter, location);
+        double azimuth = azimuthDistance[0];
+        double distance = azimuthDistance[1];
+        double mapDistance = 2d * WGS84_EQUATORIAL_RADIUS * Math.tan(distance / 2d);
+
+        return new Point2D(mapDistance * Math.sin(azimuth), mapDistance * Math.cos(azimuth));
+    }
+
+    @Override
+    public Location pointToLocation(Point2D point) {
+        double x = point.getX();
+        double y = point.getY();
+
+        if (x == 0d && y == 0d) {
+            return projectionCenter;
+        }
+
+        double azimuth = Math.atan2(x, y);
+        double mapDistance = Math.sqrt(x * x + y * y);
+        double distance = 2d * Math.atan(mapDistance / (2d * WGS84_EQUATORIAL_RADIUS));
+
+        return getLocation(projectionCenter, azimuth, distance);
+    }
+
+}
diff --git a/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/Tile.java b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/Tile.java
new file mode 100644
index 0000000..0a0a193
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/Tile.java
@@ -0,0 +1,84 @@
+/*
+ * FX Map Control - https://github.com/ClemensFischer/FX-Map-Control
+ * © 2016 Clemens Fischer
+ */
+package fxmapcontrol;
+
+import javafx.animation.FadeTransition;
+import javafx.scene.image.Image;
+import javafx.scene.image.ImageView;
+import javafx.util.Duration;
+
+/**
+ * Provides the ImageView that display a map tile image.
+ */
+public class Tile {
+    private static Duration fadeDuration;
+
+    public static Duration getFadeDuration() {
+        return fadeDuration;
+    }
+
+    public static void setFadeDuration(Duration duration) {
+        fadeDuration = duration;
+    }
+
+    private final int zoomLevel;
+    private final int x;
+    private final int y;
+    private final ImageView imageView;
+    private boolean pending;
+
+    public Tile(int zoomLevel, int x, int y) {
+        this.zoomLevel = zoomLevel;
+        this.x = x;
+        this.y = y;
+        imageView = new ImageView();
+        imageView.setOpacity(0d);
+        pending = true;
+    }
+
+    public final int getZoomLevel() {
+        return zoomLevel;
+    }
+
+    public final int getX() {
+        return x;
+    }
+
+    public final int getY() {
+        return y;
+    }
+
+    public final int getXIndex() {
+        int numTiles = 1 << zoomLevel;
+        return ((x % numTiles) + numTiles) % numTiles;
+    }
+
+    public final boolean isPending() {
+        return pending;
+    }
+
+    public final ImageView getImageView() {
+        return imageView;
+    }
+
+    public final Image getImage() {
+        return imageView.getImage();
+    }
+
+    public final void setImage(Image image, boolean fade) {
+        pending = false;
+
+        if (image != null) {
+            imageView.setImage(image);
+            if (fade && fadeDuration != null && fadeDuration.greaterThan(Duration.ZERO)) {
+                FadeTransition fadeTransition = new FadeTransition(fadeDuration, imageView);
+                fadeTransition.setToValue(1d);
+                fadeTransition.play();
+            } else {
+                imageView.setOpacity(1d);
+            }
+        }
+    }
+}
diff --git a/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/TileGrid.java b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/TileGrid.java
new file mode 100644
index 0000000..73b17c0
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/TileGrid.java
@@ -0,0 +1,67 @@
+/*
+ * FX Map Control - https://github.com/ClemensFischer/FX-Map-Control
+ * © 2016 Clemens Fischer
+ */
+package fxmapcontrol;
+
+/**
+ * Defines zoom level and tile index ranges of a MapTileLayer.
+ */
+public class TileGrid {
+    private final int zoomLevel;
+    private final int xMin;
+    private final int yMin;
+    private final int xMax;
+    private final int yMax;
+
+    public TileGrid(int zoomLevel, int xMin, int yMin, int xMax, int yMax) {
+        this.zoomLevel = zoomLevel;
+        this.xMin = xMin;
+        this.yMin = yMin;
+        this.xMax = xMax;
+        this.yMax = yMax;
+    }
+
+    public final int getZoomLevel() {
+        return zoomLevel;
+    }
+
+    public final int getXMin() {
+        return xMin;
+    }
+
+    public final int getYMin() {
+        return yMin;
+    }
+
+    public final int getXMax() {
+        return xMax;
+    }
+
+    public final int getYMax() {
+        return yMax;
+    }
+
+    public final boolean equals(TileGrid tileGrid) {
+        return tileGrid != null
+                && zoomLevel == tileGrid.zoomLevel
+                && xMin == tileGrid.xMin
+                && yMin == tileGrid.yMin
+                && xMax == tileGrid.xMax
+                && yMax == tileGrid.yMax;
+    }
+
+    @Override
+    public final boolean equals(Object obj) {
+        return (obj instanceof TileGrid) && equals((TileGrid)obj);
+    }
+
+    @Override
+    public int hashCode() {
+        return Integer.hashCode(zoomLevel)
+                ^ Integer.hashCode(xMin)
+                ^ Integer.hashCode(yMin)
+                ^ Integer.hashCode(xMax)
+                ^ Integer.hashCode(yMax);
+    }
+}
diff --git a/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/TileImageLoader.java b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/TileImageLoader.java
new file mode 100644
index 0000000..7e2a19e
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/TileImageLoader.java
@@ -0,0 +1,200 @@
+/*
+ * FX Map Control - https://github.com/ClemensFischer/FX-Map-Control
+ * © 2016 Clemens Fischer
+ */
+package fxmapcontrol;
+
+import java.io.BufferedInputStream;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadFactory;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import javafx.concurrent.Service;
+import javafx.concurrent.Task;
+import javafx.scene.image.Image;
+
+/**
+ * Default ITileImageLoader implementation.
+ * Optionally caches tile images in a static ITileCache instance.
+ */
+public class TileImageLoader implements ITileImageLoader {
+
+    private static final int MINIMUM_EXPIRATION = 3600; // one hour
+    private static final int DEFAULT_EXPIRATION = 3600 * 24; // one day
+
+    private static final ThreadFactory threadFactory = runnable -> {
+        Thread thread = new Thread(runnable, TileImageLoader.class.getSimpleName() + " Thread");
+        thread.setDaemon(true);
+        return thread;
+    };
+
+    private static ITileCache cache;
+
+    private final Set<LoadImageService> pendingServices = Collections.synchronizedSet(new HashSet<LoadImageService>());
+    private int threadPoolSize;
+    private ExecutorService serviceExecutor;
+
+    public static void setCache(ITileCache cache) {
+        TileImageLoader.cache = cache;
+    }
+
+    @Override
+    public void beginLoadTiles(MapTileLayer tileLayer, Iterable<Tile> tiles) {
+        if (threadPoolSize != tileLayer.getMaxDownloadThreads()) {
+            threadPoolSize = tileLayer.getMaxDownloadThreads();
+            if (serviceExecutor != null) {
+                serviceExecutor.shutdown();
+            }
+            serviceExecutor = Executors.newFixedThreadPool(threadPoolSize, threadFactory);
+        }
+
+        for (Tile tile : tiles) {
+            LoadImageService service = new LoadImageService(tileLayer, tile);
+            pendingServices.add(service);
+            service.start();
+        }
+    }
+
+    @Override
+    public void cancelLoadTiles(MapTileLayer tileLayer) {
+        pendingServices.forEach(s -> ((LoadImageService) s).cancel());
+        pendingServices.clear();
+    }
+
+    private class LoadImageService extends Service<Image> {
+
+        private final MapTileLayer tileLayer;
+        private final Tile tile;
+
+        public LoadImageService(MapTileLayer tileLayer, Tile tile) {
+            this.tileLayer = tileLayer;
+            this.tile = tile;
+            setExecutor(serviceExecutor);
+        }
+
+        @Override
+        protected void running() {
+            super.running();
+            pendingServices.remove(this);
+        }
+
+        @Override
+        protected void succeeded() {
+            super.succeeded();
+            tile.setImage(getValue(), true);
+        }
+
+        @Override
+        protected Task<Image> createTask() {
+            return new Task<Image>() {
+                @Override
+                protected Image call() {
+                    String tileLayerName = tileLayer.getName();
+                    String tileUrl = tileLayer.getTileSource().getUrl(tile.getXIndex(), tile.getY(), tile.getZoomLevel());
+
+                    if (cache == null
+                            || tileLayerName == null
+                            || tileLayerName.isEmpty()
+                            || tileUrl.startsWith("file:")) { // no caching, create Image directly from URL
+                        return new Image(tileUrl);
+                    }
+
+                    long now = new Date().getTime();
+                    ITileCache.CacheItem cacheItem
+                            = cache.get(tileLayerName, tile.getXIndex(), tile.getY(), tile.getZoomLevel());
+                    Image image = null;
+
+                    if (cacheItem != null) {
+                        try {
+                            try (ByteArrayInputStream memoryStream = new ByteArrayInputStream(cacheItem.getBuffer())) {
+                                image = new Image(memoryStream);
+                            }
+
+                            if (cacheItem.getExpiration() > now) { // cached image not expired
+                                return image;
+                            }
+                        } catch (IOException ex) {
+                            Logger.getLogger(TileImageLoader.class.getName()).log(Level.WARNING, ex.toString());
+                        }
+                    }
+
+                    ImageStream imageStream = null;
+                    int expiration = 0;
+
+                    try {
+                        HttpURLConnection connection = (HttpURLConnection) new URL(tileUrl).openConnection();
+
+                        if (connection.getResponseCode() == HttpURLConnection.HTTP_OK) {
+                            try (InputStream inputStream = connection.getInputStream()) {
+                                imageStream = new ImageStream(inputStream);
+                                image = imageStream.getImage();
+                            }
+
+                            String cacheControl = connection.getHeaderField("cache-control");
+
+                            if (cacheControl != null) {
+                                String maxAge = Arrays.stream(cacheControl.split(","))
+                                        .filter(s -> s.contains("max-age="))
+                                        .findFirst().orElse(null);
+
+                                if (maxAge != null) {
+                                    expiration = Integer.parseInt(maxAge.trim().substring(8));
+                                }
+                            }
+                        } else {
+                            Logger.getLogger(TileImageLoader.class.getName()).log(Level.INFO,
+                                    String.format("%s: %d %s", tileUrl, connection.getResponseCode(), connection.getResponseMessage()));
+                        }
+                    } catch (IOException | NumberFormatException ex) {
+                        Logger.getLogger(TileImageLoader.class.getName()).log(Level.WARNING, ex.toString());
+                    }
+
+                    if (image != null && imageStream != null) { // download succeeded, write image to cache
+                        if (expiration <= 0) {
+                            expiration = DEFAULT_EXPIRATION;
+                        } else if (expiration < MINIMUM_EXPIRATION) {
+                            expiration = MINIMUM_EXPIRATION;
+                        }
+                        cache.set(tileLayerName, tile.getXIndex(), tile.getY(), tile.getZoomLevel(),
+                                imageStream.getBuffer(), now + 1000L * expiration);
+                    }
+
+                    return image;
+                }
+            };
+        }
+    }
+
+    private static class ImageStream extends BufferedInputStream {
+
+        public ImageStream(InputStream inputStream) {
+            super(inputStream);
+        }
+
+        public int getLength() {
+            return count;
+        }
+
+        public byte[] getBuffer() {
+            return buf;
+        }
+
+        public Image getImage() throws IOException {
+            mark(Integer.MAX_VALUE);
+            Image image = new Image(this);
+            reset();
+            return image;
+        }
+    }
+}
diff --git a/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/TileSource.java b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/TileSource.java
new file mode 100644
index 0000000..c7506bb
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/TileSource.java
@@ -0,0 +1,148 @@
+/*
+ * FX Map Control - https://github.com/ClemensFischer/FX-Map-Control
+ * © 2016 Clemens Fischer
+ */
+package fxmapcontrol;
+
+import java.util.Locale;
+
+/**
+ * Provides the URL of a map tile.
+ */
+public class TileSource {
+
+    private interface UrlFormatter {
+
+        String getUrl(int x, int y, int z);
+    }
+
+    public static final int TILE_SIZE = 256;
+
+    private UrlFormatter urlFormatter;
+    private String urlFormat;
+
+    public TileSource() {
+    }
+
+    public TileSource(String urlFormat) {
+        setUrlFormat(urlFormat);
+    }
+
+    public static TileSource valueOf(String urlFormat) {
+        return new TileSource(urlFormat);
+    }
+
+    public final String getUrlFormat() {
+        return urlFormat;
+    }
+
+    public final void setUrlFormat(String urlFormat) {
+        if (urlFormat == null || urlFormat.isEmpty()) {
+            throw new IllegalArgumentException("urlFormat must not be null or empty");
+        }
+
+        if (urlFormat.contains("{x}") && urlFormat.contains("{y}") && urlFormat.contains("{z}")) {
+            if (urlFormat.contains("{c}")) {
+                urlFormatter = (x, y, z) -> getOpenStreetMapUrl(x, y, z);
+            } else if (urlFormat.contains("{n}")) {
+                urlFormatter = (x, y, z) -> getMapQuestUrl(x, y, z);
+            } else {
+                urlFormatter = (x, y, z) -> getDefaultUrl(x, y, z);
+            }
+        } else if (urlFormat.contains("{q}")) {
+            urlFormatter = (x, y, z) -> getQuadKeyUrl(x, y, z);
+
+        } else if (urlFormat.contains("{W}") && urlFormat.contains("{S}")
+                && urlFormat.contains("{E}") && urlFormat.contains("{N}")) {
+            urlFormatter = (x, y, z) -> getBoundingBoxUrl(x, y, z);
+
+        } else if (urlFormat.contains("{w}") && urlFormat.contains("{s}")
+                && urlFormat.contains("{e}") && urlFormat.contains("{n}")) {
+            urlFormatter = (x, y, z) -> getLatLonBoundingBoxUrl(x, y, z);
+
+        } else {
+            throw new IllegalArgumentException("Invalid urlFormat: " + urlFormat);
+        }
+
+        this.urlFormat = urlFormat;
+    }
+
+    public String getUrl(int x, int y, int zoomLevel) {
+        return urlFormatter != null
+                ? urlFormatter.getUrl(x, y, zoomLevel)
+                : null;
+    }
+
+    private String getDefaultUrl(int x, int y, int zoomLevel) {
+        return urlFormat
+                .replace("{x}", Integer.toString(x))
+                .replace("{y}", Integer.toString(y))
+                .replace("{z}", Integer.toString(zoomLevel));
+    }
+
+    private String getOpenStreetMapUrl(int x, int y, int zoomLevel) {
+        int hostIndex = (x + y) % 3;
+        return urlFormat
+                .replace("{c}", "abc".substring(hostIndex, hostIndex + 1))
+                .replace("{x}", Integer.toString(x))
+                .replace("{y}", Integer.toString(y))
+                .replace("{z}", Integer.toString(zoomLevel));
+    }
+
+    private String getMapQuestUrl(int x, int y, int zoomLevel) {
+        int hostIndex = (x + y) % 4 + 1;
+        return urlFormat
+                .replace("{n}", Integer.toString(hostIndex))
+                .replace("{x}", Integer.toString(x))
+                .replace("{y}", Integer.toString(y))
+                .replace("{z}", Integer.toString(zoomLevel));
+    }
+
+    private String getQuadKeyUrl(int x, int y, int zoomLevel) {
+        if (zoomLevel < 1) {
+            return null;
+        }
+
+        char[] quadkey = new char[zoomLevel];
+
+        for (int z = zoomLevel - 1; z >= 0; z--, x /= 2, y /= 2) {
+            quadkey[z] = (char) ('0' + 2 * (y % 2) + (x % 2));
+        }
+
+        return urlFormat
+                .replace("{i}", new String(quadkey, zoomLevel - 1, 1))
+                .replace("{q}", new String(quadkey));
+    }
+
+    private String getBoundingBoxUrl(int x, int y, int zoomLevel) {
+        double tileSize = 360d / (1 << zoomLevel); // tile width in degrees
+        double west = MapProjection.METERS_PER_DEGREE * (x * tileSize - 180d);
+        double east = MapProjection.METERS_PER_DEGREE * ((x + 1) * tileSize - 180d);
+        double south = MapProjection.METERS_PER_DEGREE * (180d - (y + 1) * tileSize);
+        double north = MapProjection.METERS_PER_DEGREE * (180d - y * tileSize);
+
+        return urlFormat
+                .replace("{W}", String.format(Locale.ROOT, "%.1f", west))
+                .replace("{S}", String.format(Locale.ROOT, "%.1f", south))
+                .replace("{E}", String.format(Locale.ROOT, "%.1f", east))
+                .replace("{N}", String.format(Locale.ROOT, "%.1f", north))
+                .replace("{X}", Integer.toString(TILE_SIZE))
+                .replace("{Y}", Integer.toString(TILE_SIZE));
+    }
+
+    private String getLatLonBoundingBoxUrl(int x, int y, int zoomLevel) {
+        double tileSize = 360d / (1 << zoomLevel); // tile width in degrees
+        double west = x * tileSize - 180d;
+        double east = (x + 1) * tileSize - 180d;
+        double south = WebMercatorProjection.yToLatitude(180d - (y + 1) * tileSize);
+        double north = WebMercatorProjection.yToLatitude(180d - y * tileSize);
+
+        return urlFormat
+                .replace("{w}", String.format(Locale.ROOT, "%.6f", west))
+                .replace("{s}", String.format(Locale.ROOT, "%.6f", south))
+                .replace("{e}", String.format(Locale.ROOT, "%.6f", east))
+                .replace("{n}", String.format(Locale.ROOT, "%.6f", north))
+                .replace("{X}", Integer.toString(TILE_SIZE))
+                .replace("{Y}", Integer.toString(TILE_SIZE));
+    }
+}
diff --git a/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/ViewportChangedEvent.java b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/ViewportChangedEvent.java
new file mode 100644
index 0000000..1ffae6c
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/ViewportChangedEvent.java
@@ -0,0 +1,42 @@
+/*
+ * FX Map Control - https://github.com/ClemensFischer/FX-Map-Control
+ * © 2017 Clemens Fischer
+ */
+package fxmapcontrol;
+
+import javafx.event.Event;
+import javafx.event.EventType;
+
+/**
+ * Fired by MapBase when the viewport changes.
+ */
+public class ViewportChangedEvent extends Event {
+
+    public static final EventType<ViewportChangedEvent> VIEWPORT_CHANGED
+            = new EventType(Event.ANY, "VIEWPORT_CHANGED");
+
+    private final boolean projectionChanged;
+    private final double longitudeOffset;
+
+    public ViewportChangedEvent(boolean projectionChanged, double longitudeOffset) {
+        super(VIEWPORT_CHANGED);
+        this.projectionChanged = projectionChanged;
+        this.longitudeOffset = longitudeOffset;
+    }
+
+    /**
+     * Indicates if the map projection has changed, i.e. if a TileLayer or MapImageLayer should be
+     * immediately updated, or MapPath Data in cartesian map coordinates should be recalculated.
+     */
+    public final boolean getProjectionChanged() {
+        return projectionChanged;
+    }
+
+    /**
+     * Offset of the map center longitude value from the previous viewport.
+     * Used to detect if the map center has moved across 180° longitude.
+     */
+    public final double getLongitudeOffset() {
+        return longitudeOffset;
+    }
+}
diff --git a/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/WebMercatorProjection.java b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/WebMercatorProjection.java
new file mode 100644
index 0000000..44285b8
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/WebMercatorProjection.java
@@ -0,0 +1,82 @@
+/*
+ * FX Map Control - https://github.com/ClemensFischer/FX-Map-Control
+ * © 2017 Clemens Fischer
+ */
+package fxmapcontrol;
+
+import javafx.geometry.Point2D;
+
+/**
+ * Transforms geographic coordinates to cartesian coordinates according to the Web Mercator
+ * Projection. Longitude values are transformed linearly to X values in meters, by multiplying with
+ * METERS_PER_DEGREE. Latitude values in the interval [-maxLatitude .. maxLatitude] are transformed
+ * to Y values in meters in the interval [-R*pi .. R*pi], R=WGS84_EQUATORIAL_RADIUS.
+ */
+public class WebMercatorProjection extends MapProjection {
+
+    public static final double MAX_LATITUDE = yToLatitude(180.);
+
+    public WebMercatorProjection() {
+        this("EPSG:3857");
+    }
+
+    public WebMercatorProjection(String crsId) {
+        this.crsId = crsId;
+    }
+
+    @Override
+    public boolean isWebMercator() {
+        return true;
+    }
+
+    @Override
+    public boolean isNormalCylindrical() {
+        return true;
+    }
+
+    @Override
+    public boolean isAzimuthal() {
+        return false;
+    }
+
+    @Override
+    public double maxLatitude() {
+        return MAX_LATITUDE;
+    }
+
+    @Override
+    public Point2D getMapScale(Location location) {
+        double scale = viewportScale / Math.cos(location.getLatitude() * Math.PI / 180d);
+
+        return new Point2D(scale, scale);
+    }
+
+    @Override
+    public Point2D locationToPoint(Location location) {
+        return new Point2D(
+                METERS_PER_DEGREE * location.getLongitude(),
+                METERS_PER_DEGREE * latitudeToY(location.getLatitude()));
+    }
+
+    @Override
+    public Location pointToLocation(Point2D point) {
+        return new Location(
+                yToLatitude(point.getY() / METERS_PER_DEGREE),
+                point.getX() / METERS_PER_DEGREE);
+    }
+
+    @Override
+    public double getViewportScale(double zoomLevel) {
+        return super.getViewportScale(zoomLevel) / METERS_PER_DEGREE;
+    }
+
+    public static double latitudeToY(double latitude) {
+        return latitude <= -90 ? Double.NEGATIVE_INFINITY
+                : latitude >= 90 ? Double.POSITIVE_INFINITY
+                        : Math.log(Math.tan(latitude * Math.PI / 360 + Math.PI / 4)) / Math.PI * 180;
+    }
+
+    public static double yToLatitude(double y) {
+        return Math.atan(Math.sinh(y * Math.PI / 180)) / Math.PI * 180;
+    }
+}
diff --git a/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/WmsImageLayer.java b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/WmsImageLayer.java
new file mode 100644
index 0000000..035f735
--- /dev/null
+++ b/tdt4140-gr1800/FxMapControl/src/main/java/fxmapcontrol/WmsImageLayer.java
@@ -0,0 +1,209 @@
+/*
+ * FX Map Control - https://github.com/ClemensFischer/FX-Map-Control
+ * © 2016 Clemens Fischer
+ */
+package fxmapcontrol;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import javafx.beans.InvalidationListener;
+import javafx.beans.property.BooleanProperty;
+import javafx.beans.property.SimpleBooleanProperty;
+import javafx.beans.property.SimpleStringProperty;
+import javafx.beans.property.StringProperty;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.parsers.ParserConfigurationException;
+import org.w3c.dom.DOMException;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.Node;
+import org.w3c.dom.NodeList;
+import org.xml.sax.SAXException;
+
+/**
+ * Map image overlay. Fills the entire viewport with map images provided by a Web Map Service (WMS).
+ * The base request URL is specified by the serviceUrl property.
+ */
+public class WmsImageLayer extends MapImageLayer {
+
+    private final StringProperty serviceUrlProperty = new SimpleStringProperty(this, "serviceUrl");
+    private final StringProperty versionProperty = new SimpleStringProperty(this, "version", "1.3.0");
+    private final StringProperty layersProperty = new SimpleStringProperty(this, "layers");
+    private final StringProperty stylesProperty = new SimpleStringProperty(this, "styles");
+    private final StringProperty parametersProperty = new SimpleStringProperty(this, "parameters");
+    private final BooleanProperty transparentProperty = new SimpleBooleanProperty(this, "transparent");
+
+    public WmsImageLayer(String serviceUrl, String layers) {
+        this();
+        setServiceUrl(serviceUrl);
+        setLayers(layers);
+    }
+
+    public WmsImageLayer() {
+        InvalidationListener listener = observable -> updateImage();
+        serviceUrlProperty.addListener(listener);
+        versionProperty.addListener(listener);
+        layersProperty.addListener(listener);
+        stylesProperty.addListener(listener);
+        parametersProperty.addListener(listener);
+        transparentProperty.addListener(listener);
+    }
+
+    public final StringProperty serviceUrlProperty() {
+        return serviceUrlProperty;
+    }
+
+    public final String getServiceUrl() {
+        return serviceUrlProperty.get();
+    }
+
+    public final void setServiceUrl(String serviceUrl) {
+        serviceUrlProperty.set(serviceUrl);
+    }
+
+    public final StringProperty versionProperty() {
+        return versionProperty;
+    }
+
+    public final String getVersion() {
+        return versionProperty.get();
+    }
+
+    public final void setVersion(String version) {
+        versionProperty.set(version);
+    }
+
+    public final StringProperty layersProperty() {
+        return layersProperty;
+    }
+
+    public final String getLayers() {
+        return layersProperty.get();
+    }
+
+    public final void setLayers(String layers) {
+        layersProperty.set(layers);
+    }
+
+    public final StringProperty stylesProperty() {
+        return stylesProperty;
+    }
+
+    public final String getStyles() {
+        return stylesProperty.get();
+    }
+
+    public final void setStyles(String styles) {
+        stylesProperty.set(styles);
+    }
+
+    public final StringProperty parametersProperty() {
+        return parametersProperty;
+    }
+
+    public final String getParameters() {
+        return parametersProperty.get();
+    }
+
+    public final void setParameters(String parameters) {
+        parametersProperty.set(parameters);
+    }
+
+    public final BooleanProperty transparentProperty() {
+        return transparentProperty;
+    }
+
+    public final boolean isTransparent() {
+        return transparentProperty.get();
+    }
+
+    public final void setTransparent(boolean transparent) {
+        transparentProperty.set(transparent);
+    }
+
+    public ObservableList<String> getAllLayers() {
+        ObservableList<String> layers = FXCollections.observableArrayList();
+        String url = getServiceUrl();
+
+        if (url != null && !url.isEmpty()) {
+            try {
+                url += "?SERVICE=WMS&VERSION=1.3.0&REQUEST=GetCapabilities";
+                HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
+
+                if (connection.getResponseCode() == HttpURLConnection.HTTP_OK) {
+                    DocumentBuilder docBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder();
+                    Document document;
+
+                    try (InputStream inputStream = connection.getInputStream()) {
+                        document = docBuilder.parse(inputStream);
+                    }
+
+                    NodeList layerNodes = document.getDocumentElement().getElementsByTagName("Layer");
+
+                    if (layerNodes.getLength() > 0) {
+                        Element rootLayer = (Element) layerNodes.item(0);
+                        layerNodes = rootLayer.getElementsByTagName("Layer");
+
+                        for (int i = 0; i < layerNodes.getLength(); i++) {
+                            Node layerNode = layerNodes.item(i);
+
+                            if (layerNode.getNodeType() == Node.ELEMENT_NODE) {
+                                NodeList nameNodes = ((Element) layerNode).getElementsByTagName("Name");
+
+                                if (nameNodes.getLength() > 0) {
+                                    layers.add(((Element) nameNodes.item(0)).getTextContent());
+                                }
+                            }
+                        }
+                    }
+                }
+            } catch (IOException | ParserConfigurationException | SAXException | DOMException ex) {
+                Logger.getLogger(WmsImageLayer.class.getName()).log(Level.SEVERE, null, ex);
+            }
+        }
+
+        return layers;
+    }
+
+    @Override
+    protected boolean updateImage(MapBoundingBox boundingBox) {
+        String serviceUrl = getServiceUrl();
+
+        if (serviceUrl == null || serviceUrl.isEmpty()) {
+            return false;
+        }
+
+        String version = getVersion() != null ? getVersion() : "1.3.0";
+        String queryParameters = getMap().getProjection().wmsQueryParameters(boundingBox, version);
+        
+        if (queryParameters == null || queryParameters.isEmpty()) {
+            return false;
+        }
+
+        String imageUrl = serviceUrl
+                + "?SERVICE=WMS"
+                + "&VERSION=" + version
+                + "&REQUEST=GetMap"
+                + "&LAYERS=" + (getLayers() != null ? getLayers() : "")
+                + "&STYLES=" + (getStyles() != null ? getStyles() : "")
+                + "&" + queryParameters
+                + "&FORMAT=image/png"
+                + "&TRANSPARENT=" + (isTransparent() ? "TRUE" : "FALSE");
+
+        if (getParameters() != null) {
+            imageUrl += "&" + getParameters();
+        }
+        
+        imageUrl = imageUrl.replace(" ", "%20");
+
+        updateImage(imageUrl);
+        return true;
+    }
+}
diff --git a/tdt4140-gr1800/app.ui/pom.xml b/tdt4140-gr1800/app.ui/pom.xml
index bf8d4bf..0511740 100644
--- a/tdt4140-gr1800/app.ui/pom.xml
+++ b/tdt4140-gr1800/app.ui/pom.xml
@@ -16,14 +16,18 @@
 			<version>0.0.1-SNAPSHOT</version>
 		</dependency>
 		
-		<!-- https://mvnrepository.com/artifact/com.lynden/GMapsFX -->
+		<!-- https://mvnrepository.com/artifact/com.lynden/GMapsFX
 		<dependency>
 			<groupId>com.lynden</groupId>
 			<artifactId>GMapsFX</artifactId>
 			<version>2.12.0</version>
 		</dependency>
-		<!-- <dependency> <groupId>fischer.clemens</groupId> <artifactId>fx-map-control</artifactId> 
-			<version>1.0</version> </dependency> -->
+		 -->
+		<dependency>
+			<groupId>fischer.clemens</groupId>
+			<artifactId>fx-map-control</artifactId> 
+			<version>1.0</version>
+		</dependency>
 
 		<dependency>
 			<groupId>junit</groupId>
diff --git a/tdt4140-gr1800/app.ui/src/main/java/tdt4140/gr1800/app/ui/MapMarker.java b/tdt4140-gr1800/app.ui/src/main/java/tdt4140/gr1800/app/ui/MapMarker.java
new file mode 100644
index 0000000..d85568e
--- /dev/null
+++ b/tdt4140-gr1800/app.ui/src/main/java/tdt4140/gr1800/app/ui/MapMarker.java
@@ -0,0 +1,26 @@
+package tdt4140.gr1800.app.ui;
+
+import fxmapcontrol.Location;
+import fxmapcontrol.MapItem;
+import javafx.scene.paint.Color;
+import javafx.scene.paint.Paint;
+import javafx.scene.shape.Circle;
+import tdt4140.gr1800.app.core.LatLong;
+
+public class MapMarker extends MapItem<LatLong> {
+
+	public MapMarker(LatLong latLong) {
+		setLocation(new Location(latLong.latitude, latLong.longitude));
+		final Circle circle = new Circle();
+		circle.setRadius(5);
+		circle.setFill(getMarkerColor(false));
+		getChildren().add(circle);
+		selectedProperty().addListener((booleanProperty, oldValue, newValue) -> {
+			circle.setFill(getMarkerColor(newValue));
+		});
+	}
+
+	protected Paint getMarkerColor(boolean selected) {
+		return selected ? Color.BLUE : Color.GREEN;
+	}
+}
diff --git a/tdt4140-gr1800/app.ui/src/main/java/tdt4140/gr1800/app/ui/MapPathLine.java b/tdt4140-gr1800/app.ui/src/main/java/tdt4140/gr1800/app/ui/MapPathLine.java
new file mode 100644
index 0000000..005b068
--- /dev/null
+++ b/tdt4140-gr1800/app.ui/src/main/java/tdt4140/gr1800/app/ui/MapPathLine.java
@@ -0,0 +1,24 @@
+package tdt4140.gr1800.app.ui;
+
+import fxmapcontrol.MapItem;
+import fxmapcontrol.MapNode;
+import javafx.scene.paint.Color;
+import javafx.scene.shape.Line;
+import tdt4140.gr1800.app.core.LatLong;
+
+public class MapPathLine extends MapItem<LatLong> {
+
+	public MapPathLine(MapNode start, MapNode end) {
+		setLocation(start.getLocation());
+		final Line line = new Line();
+		line.setStrokeWidth(3);
+		line.setStroke(Color.GREEN);
+		end.translateXProperty().addListener((prop, oldValue, newValue) -> {
+			line.setEndX(end.getTranslateX() - getTranslateX());
+		});
+		end.translateYProperty().addListener((prop, oldValue, newValue) -> {
+			line.setEndY(end.getTranslateY() - getTranslateY());
+		});
+		getChildren().add(line);
+	}
+}
diff --git a/tdt4140-gr1800/app.ui/src/main/resources/tdt4140/gr1800/app/ui/FxApp.fxml b/tdt4140-gr1800/app.ui/src/main/resources/tdt4140/gr1800/app/ui/FxApp.fxml
index bbefa50..96ba6f7 100644
--- a/tdt4140-gr1800/app.ui/src/main/resources/tdt4140/gr1800/app/ui/FxApp.fxml
+++ b/tdt4140-gr1800/app.ui/src/main/resources/tdt4140/gr1800/app/ui/FxApp.fxml
@@ -7,38 +7,38 @@
 <?import javafx.scene.control.*?>
 <?import javafx.scene.layout.*?>
 
-<?import com.lynden.gmapsfx.GoogleMapView?>
+<?import fxmapcontrol.Map?>
 <?import javafx.scene.control.ComboBox?>
 <?import javafx.scene.layout.VBox?>
 <?import javafx.scene.layout.HBox?>
 <?import javafx.scene.control.TextField?>
 <?import javafx.scene.control.Button?>
 
-<BorderPane xmlns:fx="http://javafx.com/fxml"
-            fx:controller="tdt4140.gr1800.app.ui.FxAppController"
-            prefHeight="750" prefWidth="1000">
-    <top>
-    		<VBox>
-    			<HBox>
-    				<TextField fx:id="geolocationsFileText" promptText="Geo-locations file" onAction="#loadGeolocationsFile"/>
-    				<Button text="Browse..." onAction="#handleBrowseGeolocationsFile"/>
-    			</HBox>
-	        <ComboBox fx:id="geoLocationsSelector">
-	            <BorderPane.margin>
-	                <Insets left="5" right="5" top="5" bottom="5" />
-	            </BorderPane.margin>
-	        </ComboBox>
-    		</VBox>
-    </top>
-    <center>
-        <GoogleMapView fx:id="mapView">
-        </GoogleMapView>
-    </center>
-    <bottom>
-        <Slider fx:id="zoomSlider" min="1" max="20" value="9">
-            <BorderPane.margin>
-                <Insets left="5" right="5" top="5" bottom="5" />
-            </BorderPane.margin>
-        </Slider>
-    </bottom>
+<BorderPane xmlns:fx="http://javafx.com/fxml" fx:controller="tdt4140.gr1800.app.ui.FxAppController"
+	prefHeight="750" prefWidth="1000">
+	<top>
+		<VBox>
+			<MenuBar>
+				<menus>
+					<fx:include fx:id="fileMenu" source="FileMenu.fxml"/>
+				</menus>
+			</MenuBar>
+			<ComboBox fx:id="geoLocationsSelector">
+				<BorderPane.margin>
+					<Insets left="5" right="5" top="5" bottom="5" />
+				</BorderPane.margin>
+			</ComboBox>
+		</VBox>
+	</top>
+	<center>
+		<Map fx:id="mapView" center="63,10" maxZoomLevel="15">
+		</Map>
+	</center>
+	<bottom>
+		<Slider fx:id="zoomSlider" min="1" max="20" value="9">
+			<BorderPane.margin>
+				<Insets left="5" right="5" top="5" bottom="5" />
+			</BorderPane.margin>
+		</Slider>
+	</bottom>
 </BorderPane>
diff --git a/tdt4140-gr1800/app.ui/src/test/java/tdt4140/gr1800/app/ui/FxAppTest.java b/tdt4140-gr1800/app.ui/src/test/java/tdt4140/gr1800/app/ui/FxAppTest.java
index 18951d0..254f782 100644
--- a/tdt4140-gr1800/app.ui/src/test/java/tdt4140/gr1800/app/ui/FxAppTest.java
+++ b/tdt4140-gr1800/app.ui/src/test/java/tdt4140/gr1800/app/ui/FxAppTest.java
@@ -4,8 +4,7 @@ import org.junit.Assert;
 import org.junit.Test;
 import org.testfx.framework.junit.ApplicationTest;
 
-import com.lynden.gmapsfx.GoogleMapView;
-
+import fxmapcontrol.MapBase;
 import javafx.fxml.FXMLLoader;
 import javafx.scene.Node;
 import javafx.scene.Parent;
@@ -28,6 +27,6 @@ public class FxAppTest extends ApplicationTest {
     @Test
     public void testMapExists() {
     		Node map = lookup("#mapView").query();
-    		Assert.assertTrue(map instanceof GoogleMapView);
+    		Assert.assertTrue(map instanceof MapBase);
     }
 }
diff --git a/tdt4140-gr1800/pom.xml b/tdt4140-gr1800/pom.xml
index b619bf6..af5f743 100644
--- a/tdt4140-gr1800/pom.xml
+++ b/tdt4140-gr1800/pom.xml
@@ -13,7 +13,7 @@
 
 	<modules>
 		<module>app.core</module>
+		<module>FxMapControl</module>
 		<module>app.ui</module>
-		
 	</modules>
 </project>
\ No newline at end of file
diff --git a/tdt4140-gr18nn/pom.xml b/tdt4140-gr18nn/pom.xml
index ef59106..6163fc4 100644
--- a/tdt4140-gr18nn/pom.xml
+++ b/tdt4140-gr18nn/pom.xml
@@ -14,6 +14,5 @@
 	<modules>
 		<module>app.core</module>
 		<module>app.ui</module>
-		
 	</modules>
 </project>
\ No newline at end of file
-- 
GitLab