Implementing a Common-Profile Map Renderer for JavaFX (Part 1: the Model)
Join the DZone community and get the full member experience.
Join For FreeAs part of the blueBill Mobile project, I've developed a clean implementation of a map renderer, based on the common profile, thus able to run everywhere, including on a mobile phone. This library is flexible and customizable, and of course includes the cability of overlaying layers with various kinds of information.
The work is basically a port of a similar API for JME which is part of windRose, with some specific refactoring for JavaFX (and also some generic refactoring that the JME library has to catch up).
Because of this bug, I haven't been able yet to spin a subproject off blueBill Mobile. You can check out the sources I'm describing from:
svn co -r 167 https://kenai.com/svn/bluebill-mobile~svn/trunk/src/FX/blueBill-mobileFX/src/it/tidalwave/geo/mapfx
The JME counterpart can be checked out from:
svn co -r 490 https://windrose.dev.java.net/svn/windrose/trunk/src/MobileMaps
In this first article I'm going to describe the easier part of the library, that is its model.
Point, Coordinates, Sector
The first model classes are quite obvious:
- Point: represents a point in a cartesian system with integer coordinates. It can both represent a point of the rendered map and as point in the bitmap of the map.
- Coordinates: represents a point in geographic coordinates (latitude, longitude, altitude).
- Sector: represents a geographic area delimited by min/max latitude and min/max longitude.
There's not much to say, but a short note for Coordinates. JSR-179 (Location API) provides a valid implementation of this concept, that I initially used for my JavaFX code. The reason for which I preferred a fresh implementation is because in this way I can fully enjoy JavaFX binding.
package it.tidalwave.geo.mapfx.model;
public class Point
{
public var x: Integer;
public var y: Integer;
public function withTranslation (deltaX : Integer, deltaY : Integer)
{
return Point
{
x: x + deltaX
y: y + deltaY
};
}
public function withAntiTranslation (deltaX : Integer, deltaY : Integer)
{
return Point
{
x: x - deltaX
y: y - deltaY
};
}
public function withTranslation (point : Point)
{
return withTranslation(point.x, point.y);
}
public function withAntiTranslation (point : Point)
{
return withAntiTranslation(point.x, point.y);
}
override function toString()
{
return "Point[{x};{y}]";
}
}
package it.tidalwave.geo.mapfx.model;
public class Coordinates
{
public var latitude : Number = 0;
public var longitude : Number = 0;
public var altitude : Number = 0;
override function toString()
{
return "Coordinates[{latitude},{longitude},{altitude}]";
}
}
package it.tidalwave.geo.mapfx.model;
public class Sector
{
public var minLatitude : Number = 0;
public var maxLatitude : Number = 0;
public var minLongitude : Number = 0;
public var maxLongitude : Number = 0;
override function toString()
{
return "Sector[{minLatitude},{minLongitude} -> {maxLatitude},{maxLongitude}]";
}
}
Projection, MercatorProjection
A (map) projection is a method of representing the surface of a sphere or other shape on a plane. From the software point of view, it is a class that must provide methods for converting Coordinates into a Point and vice-versa. Projection is an abstract class that can be implemented in different ways, according to the map projection system we need. MercatorProjection is a common method, used both by OpenStreetMap and Microsoft Virtual Earth.
There are only a few things to say about MercatorProjection:
- JavaFXScript doesn't provide bitwise operators (neither shift or bitwise operations). In their place, there's a javafx.util.Bits class with some static methods.
- While you can use java.lang.Math for accessing some mathematic functions, with the mobile profile this class is missing a lot of stuff (because it's the JME implementation), including exponential and trigonometric functions that are needed by a map projection system. Starting from JavaFX 1.2 you have a javafx.util.Math class with all the things you need, and this is a small but important improvement.
package it.tidalwave.geo.mapfx.model;
public abstract class Projection
{
public abstract function longitudeToX (longitude : Number, zoomLevel : Integer) : Integer;
public abstract function latitudeToY (latitude : Number, zoomLevel : Integer) : Integer;
public abstract function xToLongitude (x : Integer, zoomLevel : Integer) : Number;
public abstract function yToLatitude (y : Integer, zoomLevel : Integer) : Number;
public function coordinatesToPoint (coordinates : Coordinates, zoomLevel : Integer) : Point
{
return Point
{
y: latitudeToY(coordinates.latitude, zoomLevel)
x: longitudeToX(coordinates.longitude, zoomLevel)
};
}
public function pointToCoordinates (point : Point, zoomLevel : Integer) : Coordinates
{
return Coordinates
{
latitude: yToLatitude(point.y, zoomLevel)
longitude: xToLongitude(point.x, zoomLevel)
}
}
public abstract function metersPerPixel (coordinates : Coordinates, zoomLevel : Integer) : Number;
}
package it.tidalwave.geo.mapfx.model;
import javafx.util.Bits;
import javafx.util.Math;
public class MercatorProjection extends Projection
{
public-init var maxZoomLevel : Integer;
public-init var tileSize : Integer;
def EARTH_RADIUS = 6378137;
def EARTH_CIRCUMFERENCE = EARTH_RADIUS * 2.0 * Math.PI;
def EARTH_HALF_CIRCUMFERENCE = EARTH_CIRCUMFERENCE / 2;
override function metersPerPixel (coordinates : Coordinates, zoomLevel : Integer) : Number
{
return earthArc(zoomLevel) * Math.cos(Math.toRadians(coordinates.latitude));
}
override function longitudeToX (longitude : Number, zoomLevel : Integer) : Integer
{
def metersX = EARTH_RADIUS * Math.toRadians(longitude);
return Math.round((EARTH_HALF_CIRCUMFERENCE + metersX) / earthArc(zoomLevel));
}
override function latitudeToY (latitude : Number, zoomLevel : Integer) : Integer
{
def sinLat = Math.sin(Math.toRadians(latitude));
def metersY = EARTH_RADIUS / 2 * Math.log((1 + sinLat) / (1 - sinLat));
return Math.round((EARTH_HALF_CIRCUMFERENCE - metersY) / earthArc(zoomLevel));
}
override function xToLongitude (x : Integer, zoomLevel : Integer) : Number
{
def metersX = x * earthArc(zoomLevel) - EARTH_HALF_CIRCUMFERENCE;
return Math.toDegrees(metersX / EARTH_RADIUS);
}
override function yToLatitude (y : Integer, zoomLevel : Integer) : Number
{
def metersY = EARTH_HALF_CIRCUMFERENCE - y * earthArc(zoomLevel);
def exp = Math.exp(metersY / (EARTH_RADIUS / 2));
return Math.toDegrees(Math.asin((exp - 1) / (exp + 1)));
}
function earthArc (zoomLevel : Integer): Number
{
return EARTH_CIRCUMFERENCE / ((Bits.shiftLeft(1, maxZoomLevel - zoomLevel)) * tileSize);
}
}
TileSource, TileSourceSupport
TileSource is a class that provides the map tiles for the given coordinates; more specifically, it provides the same conversion capabilities of Projection, with an additional function, findTileURL(), that returns the URL of the map tile that should be downloaded from a remote map providing service such as OpenStreetMap or Microsoft Visual Earth.
TileSourceSupport is a partial implementation that delegates the coordinate conversion functions to an instance of Projection; concrete implementations should inherit from this class, providing an implementation of findTileURL().
package it.tidalwave.geo.mapfx.model;
public abstract class TileSource
{
public-read protected var displayName : String;
public-read protected var maxZoomLevel : Integer;
public-read protected var minZoomLevel : Integer;
public-read protected var defaultZoomLevel : Integer;
public-read protected var cachePrefix : String;
public-read protected var tileSize : Integer;
public abstract function findTileURL (x : Integer, y : Integer, zoomLevel : Integer) : String;
public abstract function coordinatesToPoint (coordinates : Coordinates, zoomLevel : Integer) : Point;
public abstract function pointToCoordinates (point : Point, zoomLevel : Integer): Coordinates;
public abstract function longitudeToX (longitude : Number, zoomLevel : Integer) : Integer;
public abstract function latitudeToY (latitude : Number, zoomLevel : Integer) : Integer;
public abstract function metersPerPixel (coordinates : Coordinates, zoomLevel : Integer) : Number;
override function toString()
{
return "TileSource[{displayName}, zoom: {minZoomLevel}-{maxZoomLevel};{defaultZoomLevel} "
"cachePrefix: {cachePrefix} tileSize:{tileSize}";
}
}
package it.tidalwave.geo.mapfx.model;
public abstract class TileSourceSupport extends TileSource
{
public-read protected var projection : Projection;
override function coordinatesToPoint (coordinates : Coordinates, zoomLevel : Integer) : Point
{
return projection.coordinatesToPoint(coordinates, zoomLevel);
}
override function pointToCoordinates (point : Point, zoomLevel : Integer) : Coordinates
{
return projection.pointToCoordinates(point, zoomLevel);
}
override function latitudeToY (latitude : Number, zoomLevel : Integer) : Integer
{
return projection.latitudeToY(latitude, zoomLevel);
}
override function longitudeToX (longitude : Number, zoomLevel : Integer) : Integer
{
return projection.longitudeToX(longitude, zoomLevel);
}
override function metersPerPixel (coordinates : Coordinates, zoomLevel : Integer) : Number
{
return projection.metersPerPixel(coordinates, zoomLevel);
}
}
TileSource implementations for OpenStreetMaps and Microsoft Visual Earth
Below you can find the sources of concrete implementations of TileSource for two of the most common map providing web services, OpenStreetMap (OSM) and Microsoft Virtual Earth (MVE). The MVE implementation accepts a parameter that allows to choose the pure map service, the satellite service and the map + satellite service.
I'm not going into details of the algorithm used for computing the URLs, since you can find them in the documentations of the two web services.
The only point worth noting is that the MVE implementation demonstrates again the use of javafx.lang.Bits for both bit shifts and bitwise and.
As a note, while OSM is fully open sourced and can be freely used, recall that MVE isn't open and you should get in touch with a Microsoft representative for getting the authorisation to connect to it.
The code could easily work with Google Maps, but the use of their maps outside of a web browser component, directly using the Google Maps APIs in JavaScript, is strictly forbidden.
package it.tidalwave.geo.mapfx.model.osm;
import it.tidalwave.geo.mapfx.model.TileSourceSupport;
import it.tidalwave.geo.mapfx.model.MercatorProjection;
public class OSMTileSource extends TileSourceSupport
{
init
{
displayName = "OpenStreetMap";
cachePrefix = "OSM/";
minZoomLevel = 1;
maxZoomLevel = 17;
defaultZoomLevel = 9;
tileSize = 256;
projection = MercatorProjection
{
maxZoomLevel: maxZoomLevel
tileSize: tileSize
};
}
override function findTileURL (x : Integer, y : Integer, zoom : Integer) : String
{
return "http://tile.openstreetmap.org/{(maxZoomLevel - zoom)}/{x}/{y}.png";
}
}
package it.tidalwave.geo.mapfx.model.mve;
import javafx.util.Bits;
import it.tidalwave.geo.mapfx.model.TileSourceSupport;
import it.tidalwave.geo.mapfx.model.MercatorProjection;
public class Mode
{
var type : String;
var ext : String;
var displayName : String;
}
public def MAP = Mode
{
displayName : "map"
type : "r"
ext : ".png"
};
public def SATELLITE = Mode
{
displayName : "satellite"
type : "a"
ext : ".jpeg"
};
public def MAP_AND_SATELLITE = Mode
{
displayName : "map + satellite"
type : "h"
ext : ".jpeg"
};
public class MVETileSource extends TileSourceSupport
{
public-init var mode = MAP on replace
{
displayName = "Microsoft Virtual Earth ({mode.displayName})";
};
init
{
displayName = "Microsoft Virtual Earth";
cachePrefix = "MVE/";
minZoomLevel = 1;
maxZoomLevel = 19;
defaultZoomLevel = 7;
tileSize = 256;
projection = MercatorProjection
{
maxZoomLevel: maxZoomLevel
tileSize: tileSize
};
}
override function findTileURL (x : Integer, y : Integer, zoomLevel : Integer) : String
{
def quad = tileToQuadKey(x, y, maxZoomLevel - zoomLevel);
return "http://{mode.type}{quad.charAt(quad.length() - 1)}.ortho.tiles.virtualearth.net/"
"tiles/{mode.type}{quad}{mode.ext}?g=1";
}
function tileToQuadKey (x : Integer, y : Integer, zoomLevel : Integer): String
{
var quad = "";
var i = zoomLevel;
while (i > 0)
{
def mask = Bits.shiftLeft(1, (i - 1));
var cell = 0;
if (Bits.bitAnd(x, mask) != 0)
{
cell++;
}
if (Bits.bitAnd(y, mask) != 0)
{
cell += 2;
}
quad += "{cell}";
i--;
}
return quad;
}
}
MapModel (and MapView)
The last class we're going to see is MapModel. It's basically a façade for the whole system, as it's the only class of the model that you have to instantiate in order to render a map. It can be configured with a TileSource and basically delegates its functions to it.
The current implementation is not complete. In windRose, the equivalent class (still named TileProvider) also includes support for downloading tiles from the internet with background threads (something that we don't need in JavaFX since background downloading is performed by the runtime. But it also provides the capability of caching map tiles on the local storage, a thing that I've not implemented yet since JavaFX 1.2 has introduced a portable way to access the local storage (being a filesystem or anything else) that I've to try yet.
package it.tidalwave.geo.mapfx.model;
public class MapModel
{
public var tileSource : TileSource;
public-read var maxZoomLevel = bind tileSource.maxZoomLevel;
public-read var minZoomLevel = bind tileSource.minZoomLevel;
public-read var defaultZoomLevel = bind tileSource.defaultZoomLevel;
public-read var cachePrefix = bind tileSource.cachePrefix;
public var internetDownloadAllowed = false;
public function metersPerPixel (coordinates : Coordinates, zoomLevel : Integer) : Number
{
return tileSource.metersPerPixel(coordinates, zoomLevel);
}
public function coordinatesToPoint (coordinates : Coordinates, zoomLevel : Integer) : Point
{
return tileSource.coordinatesToPoint(coordinates, zoomLevel);
}
public function pointToCoordinates (point : Point, zoomLevel : Integer) : Coordinates
{
return tileSource.pointToCoordinates(point, zoomLevel);
}
public function findTileURL (x : Integer, y : Integer, zoomLevel : Integer) : String
{
return tileSource.findTileURL(x, y, zoomLevel);
}
public function latitudeToY (latitude : Number, zoomLevel : Integer) : Integer
{
return tileSource.latitudeToY(latitude, zoomLevel);
}
public function longitudeToX (longitude : Number, zoomLevel : Integer) : Integer
{
return tileSource.longitudeToX(longitude, zoomLevel);
}
}
While the full description of the rendering component (MapView and companions) will be published in a future article, I'm just giving you an example on how the MapModel can be used together MapView in an application:
import it.tidalwave.geo.mapfx.model.Coordinates;
import it.tidalwave.geo.mapfx.model.MapModel;
import it.tidalwave.geo.mapfx.model.mve.MVETileSource;
def mapModel = MapModel
{
tileSource: MVETileSource{};
};
var centerCoordinates = Coordinates {latitude: 44.410208; longitude: 8.926315 };
def mapView = MapView
{
mapModel: bind mapModel
width: 480
height: 640
centerCoordinates: bind centerCoordinates
enabled: true
};
Opinions expressed by DZone contributors are their own.
Comments