Interacting with WMS layers with OpenLayers: getFeatureInfoUrl and getLegendUrl functions

No screencast available yet for this post.

All of our base layers are now converted from WFS to WMS. We didn't convert the monuments layer because we knew it would break the zoom-to feature and information popup functionalities. With WFS, we had access to the json representation of each monument in Javascript (including geometry and attributes). Now with WMS, the monuments would be served as tile images without any json; this is very good for performance but not so good for interactive functionality. In this lesson, we will see how we can interface from OpenLayers to the WMS service to keep the same user experience (even better) and get the best of both worlds.

  • First, exactly as we've done for the world-rivers and world-administrative boundaries, let's use WMS instead of WFS for the monuments in the resources/js/components/map.js file:
// First, we remove 5 imports no longer needed
import Map from "ol/Map.js";
import View from "ol/View.js";
import {
    Tile as TileLayer
} from 'ol/layer.js';
import VectorSource from "ol/source/Vector";
import VectorLayer from "ol/layer/Vector";
import OSM from "ol/source/OSM.js";
import {
    Style,
    Fill,
    Stroke,
    Circle,
    Text
} from "ol/style.js";
import GeoJSON from "ol/format/GeoJSON";
import Overlay from "ol/Overlay.js";
import {
    bbox as bboxStrategy
} from "ol/loadingstrategy.js";
import TileWMS from 'ol/source/TileWMS.js';

document.addEventListener("alpine:init", () => {
    Alpine.data("map", function () {
        return {
            legendOpened: false,
            map: {},
            initComponent() {
// We don't need the paramsObj anymore
                let paramsObj = {
                    servive: "WFS",
                    version: "2.0.0",
                    request: "GetFeature",
                    outputFormat: "application/json",
                    crs: "EPSG:4326",
                    srsName: "EPSG:4326",
                };

// The WFS base Url is no longer needed
                const baseUrl = "http://localhost:8080/geoserver/wfs?";

// We switch the monumentsLayer from a VectorLayer to a TileLayer
                let monumentsLayer = new VectorLayer({
                    source: new VectorSource({
                        format: new GeoJSON(),
                        url: (extent) => {
                            paramsObj.typeName = "laravelgis:monuments";
                            paramsObj.bbox = extent.join(",") + ",EPSG:4326";
                            let urlParams = new URLSearchParams(paramsObj);
                            return baseUrl + urlParams.toString();
                        },
                        strategy: bboxStrategy,
                    }),
                    style: this.monumentsStyleFunction,
                    label: 'Monuments',
                });

                let monumentsLayer = new TileLayer({
                    source: new TileWMS({
                        url: 'http://localhost:8080/geoserver/wms',
                        params: {
                            'LAYERS': 'laravelgis:monuments',
                            'TILED': true
                        },
                        serverType: 'geoserver',
                    }),
                    label: 'Monuments',
                });
								
                let worldAdministrativeBoundariesLayer = new TileLayer({
                    source: new TileWMS({
                        url: 'http://localhost:8080/geoserver/wms',
                        params: {
                            'LAYERS': 'laravelgis:world-administrative-boundaries',
                            'TILED': true
                        },
                        serverType: 'geoserver',
                    }),
                    label: 'World Administrative Boundaries',
                });

                let worldRiversLayer = new TileLayer({
                    source: new TileWMS({
                        url: 'http://localhost:8080/geoserver/wms',
                        params: {
                            'LAYERS': 'laravelgis:world-rivers',
                            'TILED': true
                        },
                        serverType: 'geoserver',
                    }),
                    label: 'World Rivers',
                });

                this.map = new Map({
                    target: this.$refs.map,
                    layers: [
                        new TileLayer({
                            source: new OSM(),
                            label: 'OpenStreetMap',
                        }),
                        worldAdministrativeBoundariesLayer,
                        worldRiversLayer,
                        monumentsLayer
                    ],
                    view: new View({
                        projection: "EPSG:4326",
                        center: [-78.2161, -0.7022],
                        zoom: 8,
                    }),
                    overlays: [
                        new Overlay({
                            id: 'info',
                            element: this.$refs.popup,
                            stopEvent: true,
                        }),
                    ],
                });
// Let's leave the code related to the zoom to feature and info popup, we will adjust it later
                this.map.on("singleclick", (event) => {
                    if (event.dragging) {
                        return;
                    }

                    let overlay = this.map.getOverlayById('info')
                    overlay.setPosition(undefined)
                    this.$refs.popupContent.innerHTML = ''

                    this.map.forEachFeatureAtPixel(
                        event.pixel,
                        (feature, layer) => {
                            if (layer.get('label') === 'Monuments' && feature) {
                                this.gotoFeature(feature)

                                let content =
                                    '<h4 class="text-gray-500 font-bold">' +
                                    feature.get('name') +
                                    '</h4>'

                                content +=
                                    '<img src="' +
                                    feature.get('image') +
                                    '" class="mt-2 w-full max-h-[200px] rounded-md shadow-md object-contain overflow-clip">'

                                this.$refs.popupContent.innerHTML = content

                                setTimeout(() => {
                                    overlay.setPosition(
                                        feature.getGeometry().getCoordinates()
                                    );
                                }, 500)

                                return
                            }
                        }, {
                            hitTolerance: 5,
                        }
                    );
                });
            },
            closePopup() {
                let overlay = this.map.getOverlayById('info')
                overlay.setPosition(undefined)
                this.$refs.popupContent.innerHTML = ''
            },
// We don't need the monumentsStyleFunction anymore
            monumentsStyleFunction(feature, resolution) {
                return new Style({
                    image: new Circle({
                        radius: 4,
                        fill: new Fill({
                            color: "rgba(0, 255, 255, 1)",
                        }),
                        stroke: new Stroke({
                            color: "rgba(192, 192, 192, 1)",
                            width: 2,
                        }),
                    }),
                    text: new Text({
                      font: "12px sans-serif",
                      textAlign: "left",
                      text: feature.get("name"),
                      offsetY: -15,
                      offsetX: 5,
                      backgroundFill: new Fill({
                          color: "rgba(255, 255, 255, 0.5)",
                      }),
                      backgroundStroke: new Stroke({
                          color: "rgba(227, 227, 227, 1)",
                      }),
                      padding: [5, 2, 2, 5],
                  }),
               });
            },
            gotoFeature(feature) {
                this.map.getView().animate({
                    center: feature.getGeometry().getCoordinates(),
                    zoom: 15,
                    duration: 500,
                });
            },
        };
    });
});
  • As you can see, we reduced our Javascript by more than 50 lines and removed five imports that are no longer needed (this will significantly reduce the final Javascript bundle size). All good! But now, as we thought, we've broken the zoom to feature and the information popup functionalities, and we are left with geoserver default style for the monuments (but we see the layer on the map):

Interactions with WMS in OpenLayers

  • Let's first fix the monuments style problem by creating a new style in geoserver with the code below and assigning it to the monuments layer:
<?xml version="1.0" encoding="UTF-8"?>
<StyledLayerDescriptor version="1.0.0"
    xsi:schemaLocation="http://www.opengis.net/sld http://schemas.opengis.net/sld/1.0.0/StyledLayerDescriptor.xsd"
    xmlns="http://www.opengis.net/sld" xmlns:ogc="http://www.opengis.net/ogc"
    xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">

    <NamedLayer>
        <Name>Monuments</Name>
        <UserStyle>
            <Title>Monuments</Title>
            <FeatureTypeStyle>
                <Rule>
                    <Title>Monuments</Title>
                    <PointSymbolizer>
<!--The PointSymbolizer is represented by a circle graphic mark-->
                        <Graphic>
                            <Mark>
                                <WellKnownName>Circle</WellKnownName>
                                <Fill>
                                    <CssParameter name="fill">#00FFFF</CssParameter>
                                </Fill>
                                <Stroke>
                                    <CssParameter name="stroke">#C0C0C0</CssParameter>
                                    <CssParameter name="stroke-width">2</CssParameter>
                                </Stroke>
                            </Mark>
                            <Size>8</Size>
                        </Graphic>
                    </PointSymbolizer>
                    <TextSymbolizer>
<!--The TextSymbolizer using the name attribute and a LabelPlacement-->											
                        <Label>
                            <ogc:PropertyName>name</ogc:PropertyName>
                        </Label>
                        <Font>
                            <CssParameter name="font-family">SansSerif.plain</CssParameter>
                            <CssParameter name="font-size">10</CssParameter>
                        </Font>
                        <Fill>
                            <CssParameter name="fill">#000000</CssParameter>
                        </Fill>
                        <LabelPlacement>
                            <PointPlacement>
                                <Displacement>
                                    <DisplacementX> 8 </DisplacementX>
                                    <DisplacementY> 18 </DisplacementY>
                                </Displacement>
                            </PointPlacement>
                        </LabelPlacement>
<!--We use another Graphic to create a background fill around the label-->											
                        <Graphic>
                            <Mark>
                                <WellKnownName>Square</WellKnownName>
                                <Fill>
                                    <CssParameter name="fill">#FFFFFF</CssParameter>
                                    <CssParameter name="fill-opacity">0.5</CssParameter>
                                </Fill>
                                <Stroke>
                                    <CssParameter name="stroke">#E3E3E3</CssParameter>
                                    <CssParameter name="stroke-width">2</CssParameter>
                                </Stroke>
                                <Size>10</Size>
                            </Mark>
                        </Graphic>
                        <VendorOption name="graphic-resize">stretch</VendorOption>
                        <VendorOption name="graphic-margin">4</VendorOption>
                    </TextSymbolizer>
                </Rule>
            </FeatureTypeStyle>
        </UserStyle>
    </NamedLayer>
</StyledLayerDescriptor>
  • SLD is a mix of science and art, especially for label placement; again, I highly recommend you look at very friendly and detailed documentation, including a cookbook and samples available on the Geoserver documentation website.
  • Let's save the style and assign it as the default style for the monuments layer in geoserver:

Interactions with WMS in OpenLayers

Interactions with WMS in OpenLayers

  • We should now be able to navigate to a monument and see the new server-side style applied (very similar to the OpenLayers style we had before):

Interactions with WMS in OpenLayers

  • One problem fixed, one more to go; how can we get our information popup back now that we are using WMS? Well, the solution is the OpenLayers getFeatureInfoUrl. Let's see how we can use it in the resources/js/components/map.js file:
import Map from "ol/Map.js";
import View from "ol/View.js";
import {
    Tile as TileLayer
} from 'ol/layer.js';
import OSM from "ol/source/OSM.js";
import Overlay from "ol/Overlay.js";
import TileWMS from 'ol/source/TileWMS.js';
// We need to import Feature and Point so we can recreate the feature object from json
import Feature from "ol/Feature";
import Point from 'ol/geom/Point.js';

document.addEventListener("alpine:init", () => {
    Alpine.data("map", function () {
        return {
            legendOpened: false,
            map: {},
            initComponent() {
                let monumentsLayer = new TileLayer({
                    source: new TileWMS({
                        url: 'http://localhost:8080/geoserver/wms',
                        params: {
                            'LAYERS': 'laravelgis:monuments',
                            'TILED': true
                        },
                        serverType: 'geoserver',
                    }),
                    label: 'Monuments',
                });

                let worldAdministrativeBoundariesLayer = new TileLayer({
                    source: new TileWMS({
                        url: 'http://localhost:8080/geoserver/wms',
                        params: {
                            'LAYERS': 'laravelgis:world-administrative-boundaries',
                            'TILED': true
                        },
                        serverType: 'geoserver',
                    }),
                    label: 'World Administrative Boundaries',
                });

                let worldRiversLayer = new TileLayer({
                    source: new TileWMS({
                        url: 'http://localhost:8080/geoserver/wms',
                        params: {
                            'LAYERS': 'laravelgis:world-rivers',
                            'TILED': true
                        },
                        serverType: 'geoserver',
                    }),
                    label: 'World Rivers',
                });

                this.map = new Map({
                    target: this.$refs.map,
                    layers: [
                        new TileLayer({
                            source: new OSM(),
                            label: 'OpenStreetMap',
                        }),
                        worldAdministrativeBoundariesLayer,
                        worldRiversLayer,
                        monumentsLayer
                    ],
                    view: new View({
                        projection: "EPSG:4326",
                        center: [-78.2161, -0.7022],
                        zoom: 8,
                    }),
                    overlays: [
                        new Overlay({
                            id: 'info',
                            element: this.$refs.popup,
                            stopEvent: true,
                        }),
                    ],
                });

                this.map.on("singleclick", (event) => {
                    if (event.dragging) {
                        return;
                    }

                    let overlay = this.map.getOverlayById('info')
                    overlay.setPosition(undefined)
                    this.$refs.popupContent.innerHTML = ''
// We don't have features loaded in the map anymore, so we will need to get it from the server
                    this.map.forEachFeatureAtPixel(
                        event.pixel,
                        (feature, layer) => {
                            if (layer.get('label') === 'Monuments' && feature) {
                                this.gotoFeature(feature)

                                let content =
                                    '<h4 class="text-gray-500 font-bold">' +
                                    feature.get('name') +
                                    '</h4>'

                                content +=
                                    '<img src="' +
                                    feature.get('image') +
                                    '" class="mt-2 w-full max-h-[200px] rounded-md shadow-md object-contain overflow-clip">'

                                this.$refs.popupContent.innerHTML = content

                                setTimeout(() => {
                                    overlay.setPosition(
                                        feature.getGeometry().getCoordinates()
                                    );
                                }, 500)

                                return
                            }
                        }, {
                            hitTolerance: 5,
                        }
                    );

// We will prepare a url to get the features from the monument layer source at the 
// click coordinates from the server
// The server will need the viewResolution, the coordinates, the projection and the format
                    const viewResolution = /** @type {number} */ (event.map.getView().getResolution())

                    const url = monumentsLayer.getSource().getFeatureInfoUrl(
                        event.coordinate,
                        viewResolution,
                        'EPSG:4326', {
// We ask for the json format but could also ask for xml or html for instance
                            'INFO_FORMAT': 'application/json'
                        })
// The url will be ready to query the WMS service and look something like this:
// http://localhost:8080/geoserver/wms?SERVICE=WMS&VERSION=1.3.0&REQUEST=GetFeatureInfo&FORMAT=image%2Fpng&TRANSPARENT=true&QUERY_LAYERS=laravelgis%3Amonuments&LAYERS=laravelgis%3Amonuments&TILED=true&INFO_FORMAT=application%2Fjson&I=53&J=1&WIDTH=256&HEIGHT=256&CRS=EPSG%3A4326&STYLES=&BBOX=-1.40625%2C-78.75%2C0%2C-77.34375
                    if (url) {
// We use the Javascript built-in fetch API to get the json result from the server
                        fetch(url)
                            .then((response) => response.json())
                            .then((json) => {
// We receive a collection of json represented features from the server so we check if we have at least one
                                if (json.features.length > 0) {
// We take the first on and recreate a feature object from the json attributes
                                    let jsonFeature = json.features[0]

                                    let feature = new Feature({
                                        geometry: new Point(jsonFeature.geometry.coordinates),
                                        name: jsonFeature.properties.name,
                                        image: jsonFeature.properties.image
                                    })
// Then we can go on and use the exact same code as we used before to zoom to the feature,
// generate the information popup and show it to the user
                                    this.gotoFeature(feature)

                                    let content =
                                        '<h4 class="text-gray-500 font-bold">' +
                                        feature.get('name') +
                                        '</h4>'

                                    content +=
                                        '<img src="' +
                                        feature.get('image') +
                                        '" class="mt-2 w-full max-h-[200px] rounded-md shadow-md object-contain overflow-clip">'

                                    this.$refs.popupContent.innerHTML = content

                                    setTimeout(() => {
                                        overlay.setPosition(
                                            feature.getGeometry().getCoordinates()
                                        );
                                    }, 500)

                                    return
                                }
                            });
                    }
                });
            },
            closePopup() {
                let overlay = this.map.getOverlayById('info')
                overlay.setPosition(undefined)
                this.$refs.popupContent.innerHTML = ''
            },
            gotoFeature(feature) {
                this.map.getView().animate({
                    center: feature.getGeometry().getCoordinates(),
                    zoom: 15,
                    duration: 500,
                });
            },
        };
    });
});
  • As you can see, with just a few tweaks to our code, we have our excellent end-user functionalities back. Moreover, now, if we have thousands of monuments in the database, we don't need to load them all in json; we only load the json on demand for only the features clicked by the user.
  • The getFeatureInfoUrl function is available on TileWMS and ImageWMS sources in OpenLayers; it will generate the url needed to query the service behind the source. The url will look like this: http://localhost:8080/geoserver/wms?SERVICE=WMS&VERSION=1.3.0&REQUEST=GetFeatureInfo&FORMAT=image%2Fpng&TRANSPARENT=true&QUERY_LAYERS=laravelgis%3Amonuments&LAYERS=laravelgis%3Amonuments&TILED=true&INFO_FORMAT=application%2Fjson&I=53&J=1&WIDTH=256&HEIGHT=256&CRS=EPSG%3A4326&STYLES=&BBOX=-1.40625%2C-78.75%2C0%2C-77.34375 and return a collection of feature like this (in json in our case):

Interactions with WMS in OpenLayers

  • All we are left to do is to parse the json result (recreate the feature object in our case) and display it to the user.
  • Now you might have noticed that if you are at a very small scale (zoomed out) when you click on a monument, the information popup will not appear exactly on the point, like in the example below:

Interactions with WMS in OpenLayers

  • This problem comes from the fact that geoserver will automatically "simplify" geometries for optimized viewing at the view resolution we gave it (as an argument to the getFeatureInfoUrl function). We then zoom in quite a lot, so the coordinates are not as precise as we would need at the new view resolution. We can quickly fix this problem by telling geoserver not to simplify by default. This can be done per data store, so, in geoserver, go to "Stores" in the "Data" menu on the left-hand side of the page, click on the "postgis" store name, scroll down the page, uncheck "Support on the fly geometry simplification" and hit save.

Interactions with WMS in OpenLayers

  • Now the information popup will be adequately located no matter at what scale we click on the monument:

Interactions with WMS in OpenLayers

  • Finally, one last perk of using WMS instead of WFS is that we can have a true legend; you probably noticed in the geoserver style editor that there is a "Preview legend" link on the page. Wouldn't it be nice to display it dynamically in our legend? Well, we can, and with very little code!

Interactions with WMS in OpenLayers

  • To do it, let's add two helpers functions in the resources/js/components/map.js file:
(...)
            closePopup() {
                let overlay = this.map.getOverlayById('info')
                overlay.setPosition(undefined)
                this.$refs.popupContent.innerHTML = ''
            },
            gotoFeature(feature) {
                this.map.getView().animate({
                    center: feature.getGeometry().getCoordinates(),
                    zoom: 15,
                    duration: 500,
                });
            },
// We need a function to check if the layer has a legend available, we just check that it's source is a TileWMS
            hasLegend(layer) {
                return layer.getSource() instanceof TileWMS
            },
// Then a function to return the url of the legend image using the getLegendUrl function on the source
            legendUrl(layer) {
                if (this.hasLegend(layer)) {
                    return layer
                        .getSource()
                        .getLegendUrl(this.map.getView().getResolution(), {
                            LEGEND_OPTIONS: 'forceLabels:on'
                        })
                }
            }
        };
    });
});
  • Now in the map's blade view (resources/views/components/map.blade.php):
(...)
        <div x-cloak x-show="legendOpened" x-transition:enter="transition-opacity duration-300"
            x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
            x-transition:leave="transition-opacity duration-300" x-transition:leave-start="opacity-100"
            x-transition:leave-end="opacity-0"
            class="absolute right-0 top-16 left-2 bottom-2 z-10 max-w-sm rounded-md border border-slate-300 bg-white bg-opacity-50 shadow-sm">
<!-- Put a vertical scroll bar in the legend in case of overflow -->
            <div class="absolute inset-1 rounded-md bg-white bg-opacity-75 p-2">
            <div class="absolute inset-1 rounded-md bg-white bg-opacity-75 p-2 overflow-y-auto">
                <div class="flex items-start justify-between">
                    <h3 class="text-lg font-medium text-slate-700">Legend</h3>
                    <button x-on:click.prevent="legendOpened = false"
                        class="text-2xl font-black text-slate-400 transition hover:text-[#3369A1] focus:text-[#3369A1] focus:outline-none">&times;</button>
                </div>
                <ul class="mt-2 space-y-1 rounded-md border border-slate-300 bg-white p-2">
                <ul class="mt-2 space-y-1 rounded-md border border-slate-300 bg-white p-1">
                    <template x-for="(layer, index) in map.getAllLayers().reverse()" :key="index">
                        <li class="flex items-center px-2 py-1">
                            <div x-id="['legend-range']" class="w-full">
                                <label x-bind:for="$id('legend-range')" class="flex items-center">
                                    <span class="text-sm text-slate-600" x-text="layer.get('label')"></span>
                                </label>
                                <div class="mt-1 text-sm text-slate-600">
                        <li class="flex items-center p-0.5">
                            <div x-id="['legend-range']" class="px-2 py-1 w-full border border-gray-300 rounded-md">
                                <div class="space-y-1">
                                    <label x-bind:for="$id('legend-range')" class="flex items-center">
                                        <span class="text-sm text-slate-600" x-text="layer.get('label')"></span>
                                    </label>
<!-- Show this div only in case the layer has a legend -->
                                    <div x-show="hasLegend(layer)">
<!-- Bind the src attribute of an image to the legendUrl function declared in the Javascript file -->
                                        <img x-bind:src="legendUrl(layer)" alt="Legend">
                                    </div>
                                </div>
                                <div class="mt-2 text-sm text-slate-600">
                                    <input class="w-full accent-[#3369A1]" type="range" min="0" max="1" step="0.01"
                                        x-bind:id="$id('legend-range')" x-bind:value="layer.getOpacity()"
                                        x-on:change="layer.setOpacity(Number($event.target.value))">
                                </div>
                            </div>
                        </li>
                    </template>
                </ul>
            </div>
        </div>
(...)
  • The getLegendUrl will return an url like this: http://localhost:8080/geoserver/wms?SERVICE=WMS&VERSION=1.3.0&REQUEST=GetLegendGraphic&FORMAT=image%2Fpng&LAYER=laravelgis%3Aworld-administrative-boundaries&SCALE=19.618443080357146&LEGEND_OPTIONS=forceLabels%3Aon which is directly returning an image of the legend. The good thing about it is that it's 100% dynamic, so if we ever change the SLD for the style, the image will automatically be adjusted. Our legend now looks like this:

Interactions with WMS in OpenLayers

We've made significant progress with OpenLayers, geoserver, and WMS; we now have a very performant, dynamic, and user-friendly map and legend. In the next post, we will go back to Laraval and Livewire a little bit, and we will implement a component to list the monuments along with their respective countries (using a spatial query in PostGIS), we will also implements results pagination and instant search.

The commit for this post is available here: interactions-with-wms-in-openlayers

First published 1 year ago
Latest update 1 year ago
No comment has been posted yet, start the conversation by posting a comment below.
You need to be signed in to post comments, you can sign in here if you already have an account or register here if you don't.