Mastering OpenLayers 3
上QQ阅读APP看书,第一时间看更新

Adding layers dynamically

Now that we have a basic layer tree, it's time to add some logic when adding layers. In this example, called ch03_addlayer, we will extend our code so far with some layer requesting and WMS metadata fetching capabilities. We will add every additional method to the object's prototype for the sake of clarity and performance.

Creating the interface

To keep things simple, we first hard code some forms in the HTML part. We will be able to add layers by filling out and submitting these forms. This will only need some event listeners in the JavaScript code. As these forms are quite verbose, we will only discuss the most important parts here. You can see the full code in the example's HTML file:

<div id="addwms" class="toggleable" style="display: none;">
    <form id="addwms_form" class="addlayer">
        [...]
                <td><input id="wmsurl" name="server" type="text"
                    required="required" value="http://demo.opengeo.org/geoserver/wms"></td>
                <td><input id="checkwmslayer" name="check" type="button"
                    value="Check for layers"></td>
            [...]
                <td><select name="layer" required="required"></select></td>
            [...]
                <td><input name="displayname" type="text"></td>
            [...]
                <td><select name="format" required="required"></select></td>
            [...]
                <td><input type="checkbox" name="tiled"></td>
            [...]
                <td><input type="submit" value="Add layer"></td>
                <td><input type="button" value="Cancel"
                    onclick="this.form.parentNode.style.display = 'none'"></td>
            [...]

The forms basically consist of a div and form element with ids, some required fields, optional fields, a submit button, and a cancel button. The cancel button hides the form with a simple inline expression. We default the URL to the demo GeoServer instance, which is provided by Boundless.

Tip

Giving form members a name makes them accessible from the form's DOM object as properties. Just make sure that the names are unique in a single form.

Showing the appropriate form will be handled by our object, but the actions that are triggered by clicking on a form button, other than cancel, won't be. We assign events to these buttons manually using the init function:

document.getElementById('checkwmslayer').addEventListener('click', function () {
    tree.checkWmsLayer(this.form);
});
document.getElementById('addwms_form').addEventListener('submit', function (evt) {
    evt.preventDefault();
    tree.addWmsLayer(this);
    this.parentNode.style.display = 'none';
});
document.getElementById('wmsurl').addEventListener('change', function () {
    tree.removeContent(this.form.layer)
        .removeContent(this.form.format);
});
document.getElementById('addwfs_form').addEventListener('submit', function (evt) {
    evt.preventDefault();
    tree.addWfsLayer(this);
    this.parentNode.style.display = 'none';
});

The first event triggers our object's WMS metadata fetching capability. The second event disables the browser's default behavior of redirecting on form submission, and then it adds a WMS layer to the map with our object. The third event is a security consideration, which includes clearing out the layer and format options when we change the URL. Don't worry about these unknown methods as yet; we will cover them later on in this chapter.

Tip

Note that forms and form-related events should be generated dynamically by the object in the production code.

Extending the constructor

As we would like to add two buttons to the button container automatically, we first add a helper method to our object's prototype:

layerTree.prototype.createButton = function (elemName, elemTitle, elemType) {
    var buttonElem = document.createElement('button');
    buttonElem.className = elemName;
    buttonElem.title = elemTitle;
    switch (elemType) {
        case 'addlayer':
            buttonElem.addEventListener('click', function () {
                document.getElementById(elemName).style.display = 'block';
            });
            return buttonElem;
        default:
            return false;
    }
};

This method creates a button and adds properties provided as arguments. It uses a switch statement as we will need to extend it later when we add a delete button to the layer switcher. For now, it registers an event to the button, which makes the appropriate form visible when it's clicked. Next, we extend the constructor function:

[…]
controlDiv.appendChild(this.createButton('addwms', 'Add WMS Layer', 'addlayer'));
controlDiv.appendChild(this.createButton('addwfs', 'Add WFS Layer', 'addlayer'));
[…]
this.map.getLayers().on('add', function (evt) {
    if (evt.element instanceof ol.layer.Vector) {
        this.createRegistry(evt.element, true);
    } else {
        this.createRegistry(evt.element);
    }
}, this);
[…]

Tip

You can call any method that is added to an object's prototype directly in its constructor function. The naked object gets all of its prototype methods on instantiation before it gets shaped by the constructor function. Adding methods to an object's prototype also speeds up instantiation; therefore, the whole code.

As the createButton method returns the button element that it creates, we can directly append it to its container in a single call. Next, we add an event listener to the layers' collection object.

Note

In OpenLayers 3, every change event that is triggered is handled by the nearest ol.Observable child to the source of the event. For example, layer events are triggered by the layers collection object, feature change events are triggered by their source object, while rotation change events are triggered by the view object.

As you have probably noticed, we use two versions of the createRegistry method. If the layer is a vector, we add an additional true to the arguments. Let's see the modified method to clear things up:

this.createRegistry = function (layer, buffer) {
    [...]
    layerDiv.className = buffer ? 'layer ol-unselectable buffering' :
        'layer ol-unselectable';
    [...]
};

We use a ternary operator to decide whether we have to add an additional buffering class to the layer element's class list. But why? To put the last part of the puzzle in place, let's take a look at our addBufferIcon method:

layerTree.prototype.addBufferIcon = function (layer) {
    layer.getSource().on('change', function (evt) {
        var layerElem = document.getElementById(layer.get('id'));
        switch (evt.target.getState()) {
            case 'ready':
                layerElem.className = layerElem.className.replace(/(?:^|\s)(error|buffering)(?!\S)/g, '');
                break;
            case 'error':
                layerElem.classList.add('error');
                break;
            default:
                layerElem.classList.add('buffering');
                break;
        }
    });
};

Tip

You can use JavaScript's ternary (conditional) operator to substitute a simple if-else statement with a single operator. The syntax is condition ? expression, if condition is true : expression, if condition is false.

With this method, we register an event listener to a layer's source object. The listener listens to every change event, which only occurs if the source's state changes. However, the event won't carry the state of the source (which can be classified as undefined, error, ready, or loading); we have to ask the source for it using the source's getState method.

Tip

You can listen to a single image source's ready status by registering a listener on its imageloadend event. However, tiled sources only have a tileloadend event, which fires every time a single tile has been loaded.

If the source's status is ready, then we remove the error or buffering classes from the element with a regular expression, while in other cases, we add them to it accordingly. The reasons behind this are that we use this check only for vector layers; they need some time to get processed, and they fire their change event consistently. For raster layers, defining their state can be cumbersome (especially for tile sources).

Note

Knowing how to use regular expressions can be a powerful tool for string manipulation. The preceding expression consists of a noncapturing group ((?:^|\s)), capturing group ((error|buffering)), negative look ahead ((?!\S)), and a global flag (g). It says this: look for the start of the line or a whitespace. Found one? Don't capture it, just stay sharp! Look for the error, or bufferingstrings. Got one? Capture it! Now, look at the character after the captured string. Is it a whitespace? No? Great, then we have a match. Don't stop, we have a global flag, so search the entire input string for possible matches!

Before going further, let's refer to the CSS file of the example. There are some rules for the forms, which we won't discuss here, but there are also some more important declarations that determine the look of our buttons and vector layer states:

.layertree .layertree-buttons button {
    height: 2em;
    width: 2em;
    [...]
    position: relative;
    top: 50%;
    transform: translateY(-50%);
    vertical-align: middle;
}
.layertree-buttons .addwms {
    background-image: url(../../res/button_wms.png);
}
[…]
.buffering span::after {
  content: '*';
}
.error {
  border-color: red;
}

The buttons are positioned with both of the methods that are mentioned in the previous chapter. This ensures compatibility with Firefox, which uses the vertical-align rule. The other browsers use the vertical transformation. We provide visual identifiers to the buttons via background images. Finally, the loading sign will be a simple star that's appended to the layer's name. An erroneous layer will have a red border, but this is a very rare phenomenon in OpenLayers 3. If you load the example, you will see the two buttons in their place:

Fetching the WMS metadata

OpenLayers 3, like its predecessor, provides a great perk such as knowing how to serialize the GetCapabilities response from a map server. Up until now, it only allowed us to use it for WMS and WMTS. The lack of a WFS capabilities object is not necessarily a problem, and we will see why later on in the chapter. For now, let's create a method that helps us create options for the available layers and formats on a WMS server from its capabilities response. First, let's create some utility methods:

layerTree.prototype.removeContent = function (element) {
    while (element.firstChild) {
        element.removeChild(element.firstChild);
    }
    return this;
};

layerTree.prototype.createOption = function (optionValue) {
    var option = document.createElement('option');
    option.value = optionValue;
    option.textContent = optionValue;
    return option;
};

The first method clears out the entire element that's been given as an argument, while the second one creates an option element, gives it properties based on our input value, and returns it. As we will use both of them multiple times, it is a good practice to export them as individual methods. Next, let's create the metadata processing method:

layerTree.prototype.checkWmsLayer = function (form) {
    form.check.disabled = true;
    var _this = this;
    this.removeContent(form.layer).removeContent(form.format);
    var url = form.server.value;
    url = /^((http)|(https))(:\/\/)/.test(url) ? url : 'http://' + url;
    form.server.value = url;

The method expects a form DOM object and requests capabilities based on the form's input values. The first part disables the check button from the time of processing. It goes on and prepends http:// to the URL if the protocol is missing. It also updates the form's URL field as the addWmsLayer method will use the same form object. Now, we can go on and create the AJAX request:

    var request = new XMLHttpRequest();
    request.onreadystatechange = function () {
        if (request.readyState === 4 && request.status === 200) {
            var parser = new ol.format.WMSCapabilities();
            try {
                var capabilities = parser.read(request.responseText);
                var currentProj = _this.map.getView().getProjection().getCode();
                var crs;
                var messageText = 'Layers read successfully.';
                if (capabilities.version === '1.3.0') {
                    crs = capabilities.Capability.Layer.CRS;
                } else {
                    crs = [currentProj];
                    messageText += ' Warning! Projection compatibility could not be checked due to version mismatch (' + capabilities.version + ').';
                }

Tip

The parser can only read the list of projections from the WMS 1.3.0 responses. If you have to deal with a previous version, you can default the projection to the currently used one. Eventually, if the WMS does not support the default projection, the layer simply won't load.

As the request can fail due to a mistype of the URL and other bad URLs, we will wrap the whole process in a try-catch-finally clause. AJAX requests (as the acronym suggests) are asynchronous; therefore, we have to assign a listener to its readystatechange event and check if everything went well. If this is the case, we can go on and process the serialized results. We check the projections the WMS supports, and grab a reference to our current projection.

Note

By default, the this keyword refers to the context it has been called from. In our methods, the context is our object. However, as we define an event listener, we scope our object and scope into the one we assigned the listener to. Because we need our object's methods inside the listener function, we have to solve this issue. One way to do it is by assigning this to a variable while it still refers to our object.

Next, we check conditions against the layers from the response. If our current map projection is supported and there is at least one layer served, the function creates options based on the layer names and the propagated format values:

                 var layers = capabilities.Capability.Layer.Layer;
                if (layers.length > 0 && crs.indexOf(currentProj) > -1) {
                    for (var i = 0; i < layers.length; i += 1) {
                        form.layer.appendChild(_this.createOption(layers[i].Name));
                    }
                    var formats = capabilities.Capability.Request.GetMap.Format;
                    for (i = 0; i < formats.length; i += 1) {
                        form.format.appendChild(_this.createOption(formats[i]));
                    }
                    _this.messages.textContent = messageText;
                }

Next, we display the error message in the notification bar if something bad has happened during the parsing process. We also enable the check button, regardless of the error type:

            } catch (error) {
                _this.messages.textContent = 'Some unexpected error occurred: (' + error.message + ').';
            } finally {
                form.check.disabled = false;
            }
        } else if (request.status > 200) {
            form.check.disabled = false;
        }
    };

Finally, we send the request to the destination server after doing a final check on the URL:

    url = /\?/.test(url) ? url + '&' : url + '?';
    url = url + 'REQUEST=GetCapabilities&SERVICE=WMS';
    request.open('GET', '../../../cgi-bin/proxy.py?' + encodeURIComponent(url), true);
    //request.open('GET', url, true);
    request.send();
};

As a final check, we decide how to parameterize the URL. If we use GeoServer, it probably does not have any parameters in the URL so far; therefore, we need to start them with a ? token. In the case of using MapServer, the URL is possibly pointing to a mapfile as a parameter; therefore, we need to continue and add some more parameters with the help of an & token. Next, if we use the proxy file, we encode the final URL because sending special characters to the server (especially with Python 2's SimpleHttpCGIServer) can cause an error. Finally, we can send the request through our proxy script.

Tip

This is the part where you have to use the second open method if you can't use the proxy file. If you can use it but the relative path does not match, just modify it to the correct path.

If you save and load up the example so far, you can see the layers and formats popping up on a valid URL input:

Adding WMS layers

Now that we have a working metadata parser, we create a method to add WMS layers from the list as shown in preceding screenshot. This method requires the same form, creates a layer object based on the parameters, and adds it to the map:

layerTree.prototype.addWmsLayer = function (form) {
    var params = {
        url: form.server.value,
        params: {
            layers: form.layer.value,
            format: form.format.value
        }
    };
    var layer;
    if (form.tiled.checked) {
        layer = new ol.layer.Tile({
            source: new ol.source.TileWMS(params),
            name: form.displayname.value
        });
    } else {
        layer = new ol.layer.Image({
            source: new ol.source.ImageWMS(params),
            name: form.displayname.value
        });
    }
    this.map.addLayer(layer);
    this.messages.textContent = 'WMS layer added successfully.';
    return this;
};

First, we create an object based on the general WMS parameters in a structure; the library accepts the URL, layer name, and the format. Next, as the user can choose between a single image layer and a tiled layer, we create the layer and the source object accordingly. Finally, we add the layer to the map.

Note

Both tiled and single-image WMS layers have their ups and downs. Tiled layers significantly outperform single image layers. On the other hand, if the map server renders some content on top of the layer dynamically (such as labels or charts), tiled layers can have duplicated content or artifacts near tile edges.

Adding WFS layers

As the last step of this example, we create a simple method to add WFS layers to the map. WFS capabilities in OpenLayers 3 are strongly limited. The library can't read capabilities or build queries; it just requests features based on a URL and some parameters. Furthermore, it only supports WFS 1.0.0 and 1.1.0. In this simple method, we request vector layers based on basic parameters. First, we process the input form and create some related objects:

layerTree.prototype.addWfsLayer = function (form) {
    var url = form.server.value;
    url = /^((http)|(https))(:\/\/)/.test(url) ? url : 'http://' + url;
    url = /\?/.test(url) ? url + '&' : url + '?';
    var typeName = form.layer.value;
    var mapProj = this.map.getView().getProjection().getCode();
    var proj = form.projection.value || mapProj;
    var parser = new ol.format.WFS();
    var source = new ol.source.Vector({
        strategy: ol.loadingstrategy.bbox
    });

Tip

Strategies in vector sources affect the rendering process, not the feature requests. As OpenLayers 3 uses an internal R-tree to index spatial data, it can easily select features based on an extent. With bbox, or tile strategy, you can speed up your application, as features with a minimum bounding rectangle out of the viewport are not even considered for rendering.

Next, we construct an AJAX request, default the version to 1.1.0, register an event listener to it, and send the request to the destination server:

    var request = new XMLHttpRequest();
    request.onreadystatechange = function () {
        if (request.readyState === 4 && request.status === 200) {
            source.addFeatures(parser.readFeatures(request.responseText, {
                dataProjection: proj,
                featureProjection: mapProj
            }));
        }
    };
    url = url + 'SERVICE=WFS&REQUEST=GetFeature&TYPENAME=' + typeName + '&VERSION=1.1.0&SRSNAME=' + proj;
    request.open('GET', '../../../cgi-bin/proxy.py?' + encodeURIComponent(url));
    //request.open('GET', url);
    request.send();

The same rule applies here when you use the proxy script. If you can't use it, just use the second open method, which is commented out. If we get a response from the server, we process it with our WFS parser and add the features to the source object.

Tip

Note that in OpenLayers 3, every feature needs to be present in the map's projection. You can ask the library to perform this transformation automatically by setting the appropriate projection parameters in the readFeatures method or in the format object itself. However, only readFeatures is consistent among the different formats.

Meanwhile, when the features are being downloaded, we construct the layer object, call the addBufferIcon method on it (which registers listeners related to the layer's status), and add it to the map:

    var layer = new ol.layer.Vector({
        source: source,
        name: form.displayname.value
    });
    this.addBufferIcon(layer);
    this.map.addLayer(layer);
    this.messages.textContent = 'WFS layer added successfully.';
    return this;
};

Now, it's time to save and test our code. Take your time to do this and check some of the resources that we mentioned at the beginning of this chapter. Check the water areas layer last. While it loads, you can go and grab a coffee. What do you experience when you play with the layer?

WFS considerations

Web Feature Service is designed to be able to support datasets in variable sizes. It has a logical server side and spatial filtering capabilities, which depend on the implementation of WFS and can be accessed via a GetCapabilities request. Sadly, only the GML support is mandatory in the service specifications, which is very verbose and produces large responses when bigger datasets are requested.

It is also a common practice to store large datasets in a single WFS layer and leave the filtering to the user. This was the reason behind the long loading time in our case, too. To effectively use a WFS server, we would need native support for the parsing of capabilities and query building from OpenLayers 3.

On the other hand, requesting already filtered data is quite unusual in the GIS field. Users are used to seeing the layer as a whole and apply their own filtering logic in it. Using canvas-based rendering, OpenLayers 3 has achieved an immense rendering performance. However, it still relies on the CPU, and rendering 28,539 polygons is just too much for it. Without proper hardware acceleration (WebGL), rendering large datasets should be avoided.

OpenLayers 3 can request WFS features based on a loading function. With this approach, we can request features in the current spatial extent. This can speed up our larger-scale applications. Dynamic loading is good enough for simple web mapping; however, in WebGIS, based on the preceding considerations, it should be avoided. You can see an example of dynamic WFS in the ch03_dynamicwfs.js file between lines 177 and 196.