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 Web serviceClosed A web service represents a communication technology applied in computer networks. It provides an interface described in a machine-processable format, for example WSDL. API has several advantages over the simple map using the RESTClosed REST (Representational State Transfer) represents a World Wide Web paradigm, consisting of constraints to the design of components which results in a better performance and maintainability. 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 request profileClosed The request profile is a partial profile provided in object form for a specific request. Any parameter which is not set in the request profile will be taken from the stored profile., 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 window basedClosed Window based is a way of dealing with refreshing map windows. Each time the map needs to be refreshed, the JavaScript framework requests one large image, which has the same size as your map window. This way, only one request is necessary, but images cannot be reused for the next refresh. 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 tile basedClosed Tile based is a special way of drawing maps. To draw the map window, the framework requests several small images (tiles) and paints them next to each other. This way, several requests are necessary for one refresh, but tiles can be reused. Only the tiles that have not been visible so far need to be fetched from the server. This approach gives a better user experience, especially if the bandwidth is low. 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: