Displaying a Configurable Interactive Map
In Displaying a Basic Interactive Map
we show how to get an interactive map by means of existing toolkits. It is important to understand
that the PTV xMap Server only provides tiles; any user interaction must be implemented client-side.
Though there are toolkits providing user interactivity (like Leaflet or
OpenLayers) which hide the complexity of composing a complete map image,
among other things.
Tiles are supplied to the client via requests to the tile providers. As a drawback, this procedure is handled internally and a
direct modification of the tile requests, as described in
Requesting a Single Map Image with Web Service API,
is not possible. For more flexibility some additional coding is necessary, which depends on the toolkit
and its version you use.
Benefits
Displaying an interactive map using the PTV xMap API has several advantages over the simple map using the API:
- You have better control about the map appearance and the objects rendered. For example, additional (textual) information
for Feature Layer attributes could be shown in tool tips.
- You are not restricted to integer-based zoom levels.
- For handling user interaction you can still rely on existing, free toolkits.
- Depending on your toolkit, there might be a ready-to-use sample below that integrates the web service API.
Prerequisites
Check if the following prerequisites are fulfilled before you start with the integration:
- Installed and licensed PTV xMap Server
- Installed PTV Map
Programming Guide
Commonly, the toolkits for map integration provide some native implementation for layers, rendering geographical content
by means of tile providers. Via URL templates the various tiles can be requested. What is missing here, is
an explicit configuration of the xMap requests. Especially the , which determines graphical options of the drawn
objects, is subject of interest. By the way: The following code snippets resemble the xServer 1 approaches.
Due to the fundamental character of the generic requests, the toolkit layers implementation
cannot be used. Below, some code samples are provided for a layer with a tile access and a map based access.
Because their approach to providing map content is totally different from the toolkit implementation, an own layer class
is provided, as shown in the following Leaflet samples.
Tile based layer extension
The following code provides a TiledPtvLayer
, which generates tiles similar to REST API.
Because of its approach, it extends the already existing L.TileLayer
class.
In function createTile
, the request is assembled and the response evaluated via anonymous functions.
This the place where these objects can be customized for additional elements:
// TiledPtvLayer is used to request tiled map imagery from PTV xServer with post requests.
TiledPtvLayer = L.TileLayer.extend({
options: {
profile: 'default',
beforeSend: null,
noWrap: true,
bounds: new L.LatLngBounds([[85.0, -178.965000], [-66.5, 178.965000]]),
minZoom: 0,
maxZoom: 19,
authHeader: ''
},
statics: {
url: xServerUrl + '/services/rs/XMap/renderMap',
maxConcurrentRequests: 6
},
initialize: function (options) {
L.Util.setOptions(this, options);
},
changeProfile: function(newProfile) {
this.options.profile = newProfile;
this.redraw();
},
activeRequests: [],
requestQueue: [],
onAdd: function (map) {
this.resetPendingRequests();
L.TileLayer.prototype.onAdd.call(this, map);
},
onRemove: function (map) {
this.resetPendingRequests();
L.TileLayer.prototype.onRemove.call(this, map);
},
resetPendingRequests: function () {
this.requestQueue = [];
for (var i = 0; i < this.activeRequests.length; i++)
this.activeRequests[i].abort();
this.activeRequests = [];
},
createTile: function (coords, done) {
var tile = document.createElement('img');
tile._layer = this;
tile.onload = L.bind(this._tileOnLoad, this, done, tile);
tile.onerror = L.bind(this._tileOnError, this, done, tile);
// Modify/extend this object for customization, for example the stored profile
var request = {
"storedProfile": this.options.profile,
"mapSection": { "$type": "MapSectionByTileKey", "zoomLevel": coords.z, "x": coords.x, "y": coords.y },
"imageOptions": { "width": 256, "height": 256 },
"resultFields": { "image": true }
};
this.runRequest(request,
function (response) {
// This function extracts the image from the response.
// Change it if you need to extract more information.
var rawImage = response.image;
switch (rawImage.substr(0, 5)) {
case "iVBOR": tile.src = "data:image/png"; break;
case "R0lGO": tile.src = "data:image/gif"; break;
case "/9j/4": tile.src = "data:image/jpeg"; break;
case "Qk02U": tile.src = "data:image/bmp"; break;
}
tile.src += ";base64," + rawImage;
});
return tile;
},
runRequest: function (request, handleSuccess) {
if (this.activeRequests.length >= TiledPtvLayer.maxConcurrentRequests) {
this.requestQueue.push({ request: request, handleSuccess: handleSuccess });
return;
}
var that = this;
var ajaxRequest = $.ajax({
url: TiledPtvLayer.url,
type: "POST",
data: JSON.stringify(request),
headers: {
"Authorization": (typeof this.options.authHeader === "string" && this.options.authHeader.length > 0) ? this.options.authHeader : undefined,
"Content-Type": "application/json"
},
success: handleSuccess,
error: function (xhr) { },
complete: function (xhr, status) {
that.activeRequests.splice(that.activeRequests.indexOf(request), 1);
if (that.requestQueue.length) {
var pendingRequest = that.requestQueue.shift();
that.runRequest(pendingRequest.request, pendingRequest.handleSuccess);
}
}
});
this.activeRequests.push(ajaxRequest);
}
});
var map = new L.Map('map', { center: [49.61, 6.125], zoom: 13 });
// Determine the copyright over a request to xRuntime.
function determineCopyright() {
var urlPath = xServerUrl + '/services/rest/XRuntime/dataInformation';
$.ajax(urlPath).always(function(response){
if (response) {
addPtvLayer(response.mapDescription.copyright);
}
});
};
determineCopyright();
// Create a TiledPtvLayer and add it to the map
function addPtvLayer(copyright) {
new TiledPtvLayer({
attribution: '© ' + new Date().getFullYear() + ' ' + copyright.basemap.join(", "),
// Insert 'silkysand', 'sandbox' or 'gravelpit' as possible start-up profile
profile: 'silkysand'
}).addTo(map);
}
Drawing individual layer content
The TiledPtvLayer
also provides the possibility to draw individual parts of map content. Available layers are background, transport, labels and any Feature Layer. It is possible to combine different layers with a single TiledPtvLayer
, but the drawing order is not considered or changed.
// TiledPtvLayer is used to request tiled map imagery from PTV xServer with post requests.
TiledPtvLayer = L.TileLayer.extend({
options: {
profile: 'default',
layers: [],
beforeSend: null,
noWrap: true,
bounds: new L.LatLngBounds([[85.0, -178.965000], [-66.5, 178.965000]]),
minZoom: 0,
maxZoom: 19,
authHeader: ''
},
statics: {
url: xServerUrl + '/services/rs/XMap/renderMap',
maxConcurrentRequests: 6
},
initialize: function (options) {
L.Util.setOptions(this, options);
},
changeProfile: function(newProfile) {
this.options.profile = newProfile;
this.redraw();
},
activeRequests: [],
requestQueue: [],
onAdd: function (map) {
this.resetPendingRequests();
L.TileLayer.prototype.onAdd.call(this, map);
},
onRemove: function (map) {
this.resetPendingRequests();
L.TileLayer.prototype.onRemove.call(this, map);
},
resetPendingRequests: function () {
this.requestQueue = [];
for (var i = 0; i < this.activeRequests.length; i++)
this.activeRequests[i].abort();
this.activeRequests = [];
},
createTile: function (coords, done) {
var tile = document.createElement('img');
tile._layer = this;
tile.onload = L.bind(this._tileOnLoad, this, done, tile);
tile.onerror = L.bind(this._tileOnError, this, done, tile);
// Modify/extend this object for customization, for example the stored profile
var request = {
"storedProfile": this.options.profile,
"mapSection": { "$type": "MapSectionByTileKey", "zoomLevel": coords.z, "x": coords.x, "y": coords.y },
"imageOptions": { "width": 256, "height": 256 },
"mapOptions" : { "layers" : this.options.layers },
"resultFields": { "image": true }
};
this.runRequest(request,
function (response) {
// This function extracts the image from the response.
// Change it if you need to extract more information.
var rawImage = response.image;
switch (rawImage.substr(0, 5)) {
case "iVBOR": tile.src = "data:image/png"; break;
case "R0lGO": tile.src = "data:image/gif"; break;
case "/9j/4": tile.src = "data:image/jpeg"; break;
case "Qk02U": tile.src = "data:image/bmp"; break;
}
tile.src += ";base64," + rawImage;
});
return tile;
},
runRequest: function (request, handleSuccess) {
if (this.activeRequests.length >= TiledPtvLayer.maxConcurrentRequests) {
this.requestQueue.push({ request: request, handleSuccess: handleSuccess });
return;
}
var that = this;
var ajaxRequest = $.ajax({
url: TiledPtvLayer.url,
type: "POST",
data: JSON.stringify(request),
headers: {
"Authorization": (typeof this.options.authHeader === "string" && this.options.authHeader.length > 0) ? this.options.authHeader : undefined,
"Content-Type": "application/json"
},
success: handleSuccess,
error: function (xhr) { },
complete: function (xhr, status) {
that.activeRequests.splice(that.activeRequests.indexOf(request), 1);
if (that.requestQueue.length) {
var pendingRequest = that.requestQueue.shift();
that.runRequest(pendingRequest.request, pendingRequest.handleSuccess);
}
}
});
this.activeRequests.push(ajaxRequest);
}
});
var map = new L.Map('map', { center: [49.61, 6.125], zoom: 13 });
// Determine the copyright over a request to xRuntime.
function determineCopyright() {
var urlPath = xServerUrl + '/services/rest/XRuntime/experimental/dataInformation';
$.ajax(urlPath).always(function(response){
if (response) {
addPtvLayer(response.mapDescription.copyright);
}
});
};
determineCopyright();
// Create a TiledPtvLayer and add it to the map
function addPtvLayer(copyright) {
var attributions = copyright.featureLayers.find(
function(el){return el.themeId === 'PTV_TruckAttributes'}
).copyright;
new TiledPtvLayer({
profile: 'silkysand',
//Insert the layers to draw
layers: [ 'PTV_TruckAttributes' ],
attribution: '© ' + new Date().getFullYear() + ' ' + attributions.join(", "),
}).addTo(map);
}
Window based layer extension
For some peculiar use cases it may be necessary to get the whole map image at once, avoiding the tile
partitioning. The following code provides a WindowBasedPtvLayer
, which generates a complete
image at all. Because of its more fundamental character, it extends the already existing L.Layer
class.
In function getImageUrlAsync
, the request is assembled and can be adapted to own requirements.
In function processResponse
the response object can be evaluated for additional returned values,
if necessary:
WindowBasedPtvLayer = L.Layer.extend({
options: {
profile: 'default',
attribution: '',
opacity: 1.0,
zIndex: undefined,
minZoom: -99,
pointerEvents: null,
authHeader: ''
},
statics: {
url: xServerUrl + '/services/rs/XMap/renderMap'
},
initialize: function (options) {
L.Util.setOptions(this, options);
},
/////////////////////////////////////////////
// xMap-relevant part of Layer implementation
/////////////////////////////////////////////
// unique key to identify the latest image
imageKey: 0,
changeProfile: function(newProfile) {
this.options.profile = newProfile;
this.redraw();
},
// This is called from our base class to get the image.
// When the image is available, we pass it to the callback given as the last parameter.
// Modify/extend the first runRequest parameter for customization, for example the stored profile
getImageUrlAsync: function (world1, world2, width, height, callback) {
var self = this;
this.runRequest({
"storedProfile": this.options.profile,
"mapSection": {
"$type": "MapSectionByBounds",
"bounds": { "minX": world1.lng, "minY": world1.lat, "maxX": world2.lng, "maxY": world2.lat }
},
"imageOptions": { "width": width, "height": height },
"resultFields": { "image": true }
},
function (response) { callback(self.processResponse(response), response); },
function (xhr) { callback(L.Util.emptyImageUrl); }
);
},
// This function extracts the image from the response.
// Change it if you need to extract more information or more image types.
processResponse: function (response) {
var result = "data:image/";
var rawImage = response.image;
switch (rawImage.substr(0, 5)) {
case "iVBOR": result += "png"; break;
case "R0lGO": result += "gif"; break;
case "/9j/4": result += "jpeg"; break;
case "Qk02U": result += "bmp"; break;
}
return result + ";base64," + rawImage;
},
// runRequest executes a json request on PTV xServer,
// given the endpoint and the callbacks to be called
// upon completion. The error callback is parameterless, the success
// callback is called with the object returned by the server.
runRequest: function (request, handleSuccess, handleError) {
$.ajax({
url: WindowBasedPtvLayer.url,
type: "POST",
data: JSON.stringify(request),
headers: {
"Authorization": (typeof this.options.authHeader === "string" && this.options.authHeader.length > 0) ? this.options.authHeader : undefined,
"Content-Type": "application/json"
},
success: handleSuccess,
error: handleError
});
},
/////////////////////////////////////////////
// Leaflet base implementation
/////////////////////////////////////////////
onAdd: function (map) {
this._map = map;
if (!this._div) {
this._div = L.DomUtil.create('div', 'leaflet-image-layer');
if (this.options.pointerEvents) {
this._div.style['pointer-events'] = this.options.pointerEvents;
}
}
this.getPane().appendChild(this._div);
this._bufferImage = this._initImage();
this._currentImage = this._initImage();
this._update();
},
onRemove: function (map) {
this.getPane().removeChild(this._div);
this._div.removeChild(this._bufferImage);
this._div.removeChild(this._currentImage);
},
addTo: function (map) {
map.addLayer(this);
return this;
},
getEvents: function () {
var events = {
moveend: this._update,
zoom: this._viewreset
};
if (this._zoomAnimated) {
events.zoomanim = this._animateZoom;
}
return events;
},
getElement: function () {
return this._div;
},
setOpacity: function (opacity) {
this.options.opacity = opacity;
if (this._currentImage)
this._updateOpacity(this._currentImage);
if (this._bufferImage)
this._updateOpacity(this._bufferImage);
return this;
},
bringToFront: function () {
if (this._div) {
this._pane.appendChild(this._div);
}
return this;
},
bringToBack: function () {
if (this._div) {
this._pane.insertBefore(this._div, this._pane.firstChild);
}
return this;
},
getAttribution: function () {
return this.options.attribution;
},
_initImage: function (_image) {
var _image = L.DomUtil.create('img', 'leaflet-image-layer');
if (this.options.zIndex !== undefined)
_image.style.zIndex = this.options.zIndex;
this._div.appendChild(_image);
if (this._map.options.zoomAnimation && L.Browser.any3d) {
L.DomUtil.addClass(_image, 'leaflet-zoom-animated');
} else {
L.DomUtil.addClass(_image, 'leaflet-zoom-hide');
}
this._updateOpacity(_image);
L.extend(_image, {
galleryimg: 'no',
onselectstart: L.Util.falseFn,
onmousemove: L.Util.falseFn,
onload: L.bind(this._onImageLoad, this)
});
return _image;
},
redraw: function () {
if (this._map) {
this._update();
}
return this;
},
_animateZoom: function (e) {
if (this._currentImage._bounds)
this._animateImage(this._currentImage, e);
if (this._bufferImage._bounds)
this._animateImage(this._bufferImage, e);
},
_animateImage: function (image, e) {
var map = this._map,
scale = map.getZoomScale(e.zoom),
nw = image._bounds.getNorthWest(),
offset = map._latLngToNewLayerPoint(nw, e.zoom, e.center);
L.DomUtil.setTransform(image, offset, scale);
},
_resetImage: function (image) {
var bounds = new L.Bounds(
this._map.latLngToLayerPoint(image._bounds.getNorthWest()),
this._map.latLngToLayerPoint(image._bounds.getSouthEast())),
size = bounds.getSize();
L.DomUtil.setPosition(image, bounds.min);
image.style.width = size.x + 'px';
image.style.height = size.y + 'px';
},
_getClippedBounds: function () {
var wgsBounds = this._map.getBounds();
// truncate bounds to valid wgs bounds
var lon1 = wgsBounds.getNorthWest().lng;
var lat1 = wgsBounds.getNorthWest().lat;
var lon2 = wgsBounds.getSouthEast().lng;
var lat2 = wgsBounds.getSouthEast().lat;
lon1 = (lon1 + 180) % 360 - 180;
if (lat1 > 85.05) lat1 = 85.05;
if (lat2 < -85.05) lat2 = -85.05;
if (lon1 < -180) lon1 = -180;
if (lon2 > 180) lon2 = 180;
var world1 = new L.LatLng(lat1, lon1);
var world2 = new L.LatLng(lat2, lon2);
return new L.LatLngBounds(world1, world2);
},
_viewreset: function () {
if (this._map.getZoom() < this.options.minZoom) {
this._div.style.visibility = 'hidden';
return;
}
this._div.style.visibility = 'visible';
if (this._bufferImage._bounds)
this._resetImage(this._bufferImage);
},
_update: function () {
this._viewreset();
var bounds = this._getClippedBounds();
// re-project to corresponding pixel bounds
var pix1 = this._map.latLngToContainerPoint(bounds.getNorthWest());
var pix2 = this._map.latLngToContainerPoint(bounds.getSouthEast());
// get pixel size
var width = pix2.x - pix1.x;
var height = pix2.y - pix1.y;
// resulting image is too small
if (width < 32 || height < 32)
return;
this._currentImage._bounds = bounds;
this._resetImage(this._currentImage);
this.imageKey++;
var i = this._currentImage;
i.key = this.imageKey;
if (this.getImageUrl) {
i.src = this.getImageUrl(bounds.getSouthWest(), bounds.getNorthEast(), width, height);
}
else {
var oiua = this._onImageUrlAsync;
var requestFunc = function (f, k) {
L.bind(f, this)(bounds.getSouthWest(), bounds.getNorthEast(), width, height, function (url, tag) {
oiua(i, k, url, tag);
})
};
L.bind(requestFunc, this)(this.getImageUrlAsync, this.imageKey);
}
},
_onImageUrlAsync: function (i, k, url, tag) {
if (i.key == k) {
i.src = url;
i.tag = tag;
i.key = k;
}
},
_onImageLoad: function (e) {
if (e.target.src == L.Util.emptyImageUrl)
return;
if (this.imageKey != e.target.key)
return;
if (this._addInteraction)
this._addInteraction(this._currentImage.tag)
L.DomUtil.setOpacity(this._currentImage, this.options.opacity);
L.DomUtil.setOpacity(this._bufferImage, 0);
var tmp = this._bufferImage;
this._bufferImage = this._currentImage;
this._currentImage = tmp;
this.fire('load');
},
_updateOpacity: function (image) {
L.DomUtil.setOpacity(image, this.options.opacity);
}
});
map = new L.Map('map').setView(new L.LatLng(49.61, 6.125), 13);
// Determine the copyright over a request to xRuntime.
function determineCopyright() {
var urlPath = xServerUrl + '/services/rest/XRuntime/experimental/dataInformation';
$.ajax(urlPath).always(function(response){
if (response) {
addPtvLayer(response.mapDescription.copyright);
}
});
};
determineCopyright();
// Create a WindowBasedPtvLayer and add it to the map
function addPtvLayer(copyright) {
new WindowBasedPtvLayer({
// Insert 'silkysand', 'sandbox' or 'gravelpit' as possible start-up profile
profile: 'silkysand',
attribution: '© ' + new Date().getFullYear() + ' ' + copyright.basemap.join(", "),
}).addTo(map);
}
Related Topics
The following topics might be relevant for this integration sample: