Refactoring the legend to allow the user to control layer opacity and implementing an info popup on the map

A free screencast (video) course is available for this post but you need to be signed in order to view it, you can sign in here if you already have an account or register here if you don't have one.

On the frontend side, we will refactor our legend to allow users to control layers' opacity instead of only on or off. We will then implement a basic popup that will show up on the map when the user clicks on a monument. It will show the name and a picture of the monument.

  • Make the following changes to the resources/views/components/map.blade.php file:
(...)
        <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">
            <div class="absolute inset-1 rounded-md bg-white bg-opacity-75 p-2">
                <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">
                    <template x-for="(layer, index) in map.getAllLayers().reverse()" :key="index">
                        <li class="flex items-center px-2 py-1">
                            <!-- Let's remove the layer's checkbox and list of features -->
                            <div x-id="['legend-checkbox']">
                                <label x-bind:for="$id('legend-checkbox')" class="flex items-center">
                                    <input type="checkbox" x-bind:checked="layer.getVisible()"
                                           x-bind:id="$id('legend-checkbox')"
                                           x-on:change="layer.setVisible(!layer.getVisible())"
                                           class="rounded border-slate-300 text-[#3369A1] shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50">
                                    <span class="ml-2 text-sm text-slate-600" x-text="layer.get('label')"></span>
                                </label>
                                <template x-if="layer.get('label') === 'Monuments' && layer.getVisible()">
                                    <div class="mt-2 ml-6 text-sm text-slate-600">
                                        <template x-for="(feature, index) in monumentsFeatures" :key="index">
                                            <a href="#" :title="'Go to ' +  feature.get('name')"
                                               x-text="feature.get('name')" x-on:click.prevent="gotoFeature(feature)"
                                               class="block transition hover:text-slate-800 hover:underline focus:text-slate-800 focus:underline focus:outline-none">
                                            </a>
                                        </template>
                                    </div>
                                </template>
                            </div>
                            <!-- Add a range input instead of the checkbox and bind it's value to the layer opacity -->
                            <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">
                                    <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>
(...)

Now in the resources/js/components/map.js, make the following changes:

import Map from "ol/Map.js";
import View from "ol/View.js";
import TileLayer from "ol/layer/Tile.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";

document.addEventListener("alpine:init", () => {
    Alpine.data("map", function () {
        return {
            legendOpened: false,
            map: {},
// Removes monumentsLayer as we don't need to keep it's state anymore
            monumentsLayer: [],
            initComponent() {
                const paramsObj = {
                    servive: 'WFS',
                    version: '2.0.0',
                    request: 'GetFeature',
                    typeName: 'laravelgis:monuments',
                    outputFormat: 'application/json',
                    crs: 'EPSG:4326',
                    srsName: 'EPSG:4326',
                }

                const urlParams = new URLSearchParams(paramsObj)
                const monumentsUrl = 'http://localhost:8080/geoserver/wfs?' +  urlParams.toString()

// We can inline the instanciation of the layer in the map instanciation
                this.monumentsLayer = new VectorLayer({
                    source: new VectorSource({
                        format: new GeoJSON(),
                        url: monumentsUrl,
                    }),
                    style: this.styleFunction,
                    label: "Monuments",
                })

                this.map = new Map({
                    target: this.$refs.map,
                    layers: [
                        new TileLayer({
                            source: new OSM(),
                            label: "OpenStreetMap",
                        }),
                        this.monumentsLayer,
                        new VectorLayer({
                            source: new VectorSource({
                                format: new GeoJSON(),
                                url: monumentsUrl,
                            }),
                            style: this.styleFunction,
                            label: "Monuments",
                        }),
                    ],
                    view: new View({
                        projection: "EPSG:4326",
                        center: [0, 0],
                        zoom: 2,
                    }),
                });
            },
// We don't need this getter anymore
            get monumentsFeatures() {
                return this.monumentsLayer.getSource().getFeatures()
            },
            styleFunction(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: 10,
                    duration: 2000,
                });
            },
        };
    });
});

Great, it looks that our refactor removed a few lines of code; it's always a good sign. Now, let's run our watcher again ("dr npm run dev") and go to http://localhost:8080/dashboard:

We will now create a popup that will show when the user clicks on a monument on the map; it will also automatically zoom to the monument. We will need to load the images with javascript dynamically to do so. At this moment, the image attribute of the features is a link to a Wikipedia image. Like with Geoserver, if we try to load resources with javascript from a different origin, we will run into CORS issues. The simplest way to do it, for now, is to download the images directly in the public folder, so they are available on the same origin. I already did it and committed the images in the repository:

Layer opacity and info popup in OpenLayers Now we need to apply these changes in our database; the most straightforward way, for now, is to edit the resources/geojson/monuments.geojson and rerun the command we prepared in Setting up PostGIS with Docker previous post. First, update the monuments.geojson file:

{
    "type": "FeatureCollection",
    "features": [
        {
            "type": "Feature",
            "properties": {
                "name": "Eiffel Tower",
                "image": "https://en.wikipedia.org/wiki/Eiffel_Tower#/media/File:Tour_Eiffel_Wikimedia_Commons.jpg"
                "image": "/img/eiffel-tower.jpg"
            },
            "geometry": {
                "type": "Point",
                "coordinates": [2.2944960089681175, 48.85824068679814]
            }
        },
        {
            "type": "Feature",
            "properties": {
                "name": "Statue of Liberty",
                "image": "https://en.wikipedia.org/wiki/Statue_of_Liberty#/media/File:Lady_Liberty_under_a_blue_sky_(cropped).jpg"
                "image": "/img/statue-of-liberty.jpg"
            },
            "geometry": {
                "type": "Point",
                "coordinates": [-74.04455265662958, 40.68928126997774]
            }
        },
        {
            "type": "Feature",
            "properties": {
                "name": "Rome Colosseum",
                "image": "https://en.wikipedia.org/wiki/Colosseum#/media/File:Colosseo_2020.jpg"
                "image": "/img/rome-colosseum.jpg"
            },
            "geometry": {
                "type": "Point",
                "coordinates": [12.492283213388305, 41.890266877448695]
            }
        },
        {
            "type": "Feature",
            "properties": {
                "name": "Door of No Return",
                "image": "https://en.wikipedia.org/wiki/Door_of_No_Return,_Ouidah#/media/File:The_Door_of_No_Return_in_Ouidah,_November_2007_(3).jpg"
                "image": "/img/door-of-no-return.jpg"
            },
            "geometry": {
                "type": "Point",
                "coordinates": [2.0895860296820206, 6.324244153348859]
            }
        }
        },
        {
            "type": "Feature",
            "properties": {
                "name": "Ciudad Mitad del Mundo",
                "image": "/img/ciudad-mitad-del-mundo.jpg"
             },
            "geometry": {
                "type": "Point",
                "coordinates": [-78.4558392183774, -0.002185609003195849]
             }
         }
    ]
}

Now let's run our reload-monuments command:

dr php artisan laravel-gis:reload-monuments

5 monuments loaded successfully.

As you probably noticed, I also added a new monument. Now we need to add the popup markup to the view (resources/views/components/map.blade.php):

<div x-data="map()" x-init="initComponent()">
    <div x-ref="map" class="relative h-[600px] overflow-clip rounded-md border border-slate-300 shadow-lg">
        <div class="absolute top-2 right-8 z-10 rounded-md bg-white bg-opacity-75">
            <div class="ol-unselectable ol-control">
                <button x-on:click.prevent="legendOpened = ! legendOpened" title="Open/Close legend"
                        class="absolute inset-0 flex items-center justify-center">
                    <!-- Heroicon name: outline/globe -->
                    <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 pl-0.5" fill="none" viewBox="0 0 24 24"
                         stroke="currentColor" stroke-width="1">
                        <path stroke-linecap="round" stroke-linejoin="round"
                              d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
                    </svg>
                </button>
            </div>
        </div>

        <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">
            <div class="absolute inset-1 rounded-md bg-white bg-opacity-75 p-2">
                <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">
                    <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">
                                    <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>

        <div x-cloak x-ref="popup" class="ol-popup ol-control transition">
            <div class="p-2 m-0.5 bg-white rounded-md">
                <div class="flex justify-between">
                    <h3 class="text-xs font-medium text-slate-400">Monument</h3>
                    <a href="#"
                        title="Close"
                        x-on:click.prevent="closePopup"
                        class="-mt-1 font-black text-slate-400 transition hover:text-slate-600 focus:text-slate-600 focus:outline-none">&times;</a>
                </div>
                <div x-ref="popupContent" class="mt-2 overflow-y-auto min-h-[200px]"></div>
            </div>
        </div>
    </div>
</div>

@once
    @push('styles')
        <link rel="stylesheet" href="{{ mix('css/map.css') }}">
    @endpush
    @push('scripts')
        <script src="{{ mix('js/map.js') }}"></script>
    @endpush
@endonce

We will also add a few css classes to style the popup; these styles are taken from this example. We will add it in the resources/css/components/map.css file:

@import 'ol/ol.css';

.ol-popup {
     position: absolute;
     background-color: white;
     box-shadow: 0 1px 4px rgba(0,0,0,0.2);
     padding: 15px;
     border-radius: 10px;
     border: 1px solid #cccccc;
     bottom: 12px;
     left: -50px;
     min-width: 280px;
}

.ol-popup:after, .ol-popup:before {
     top: 100%;
     border: solid transparent;
     content: " ";
     height: 0;
     width: 0;
     position: absolute;
     pointer-events: none;
}

.ol-popup:after {
     border-top-color: white;
     border-width: 10px;
     left: 48px;
     margin-left: -10px;
}

.ol-popup:before {
     border-top-color: #cccccc;
     border-width: 11px;
     left: 48px;
     margin-left: -11px;
}

We will have to add some Tailwind classes directly in our javascript to generate the popup content dynamically. We will tell tailwind about it so it can parse our javascript along with our blade templates. Add this line to the tailwind.config.js file:

const defaultTheme = require('tailwindcss/defaultTheme');

/** @type {import('tailwindcss').Config} */
module.exports = {
    content: [
        './vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php',
        './vendor/laravel/jetstream/**/*.blade.php',
        './storage/framework/views/*.php',
        './resources/views/**/*.blade.php',
        './resources/js/**/*.js',
    ],

    theme: {
        extend: {
            fontFamily: {
                sans: ['Nunito', ...defaultTheme.fontFamily.sans],
            },
        },
    },

    plugins: [require('@tailwindcss/forms'), require('@tailwindcss/typography')],
};

Please note that you will have to relaunch the watcher to apply this change. Finally, let's update the resources/js/components/map.js file like this:

import Map from "ol/Map.js";
import View from "ol/View.js";
import TileLayer from "ol/layer/Tile.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";

document.addEventListener("alpine:init", () => {
    Alpine.data("map", function () {
        return {
            legendOpened: false,
            map: {},
            initComponent() {
                const paramsObj = {
                    servive: "WFS",
                    version: "2.0.0",
                    request: "GetFeature",
                    typeName: "laravelgis:monuments",
                    outputFormat: "application/json",
                    crs: "EPSG:4326",
                    srsName: "EPSG:4326",
                };

                const urlParams = new URLSearchParams(paramsObj);
                const monumentsUrl =
                    "http://localhost:8080/geoserver/wfs?" +
                    urlParams.toString();

                this.map = new Map({
                    target: this.$refs.map,
                    layers: [
                        new TileLayer({
                            source: new OSM(),
                            label: 'OpenStreetMap',
                        }),
                        new VectorLayer({
                            source: new VectorSource({
                                format: new GeoJSON(),
                                url: monumentsUrl,
                            }),
                            style: this.styleFunction,
                            label: 'Monuments',
                        }),
                    ],
                    view: new View({
                        projection: "EPSG:4326",
                        center: [0, 0],
                        zoom: 2,
                    }),
// Create an overlay element from the x-ref popup dom element and add it to the map										
                    overlays: [
                        new Overlay({
                            id: 'info',
                            element: this.$refs.popup,
                            stopEvent: true,
                        }),
                    ],
                });

// Register a singleclick event on the map
                this.map.on("singleclick", (event) => {
                    if (event.dragging) {
                        return;
                    }
// Get the info overlay from the map and reset it to empty without position
// It will not be visible until we set a position on it. This will close any open
// popup if the user clicks on the map where no feature can be found.
                    let overlay = this.map.getOverlayById('info');
                    overlay.setPosition(undefined)
                    this.$refs.popupContent.innerHTML = ''
// This will loop through all features found on pixel clicked by the user
                    this.map.forEachFeatureAtPixel(
                        event.pixel,
                        (feature, layer) => {
                            if (layer.get('label') === 'Monuments' && feature) {
// We make sure the feature is from to monuments layer and then zoom to the feature
                                this.gotoFeature(feature)
// We have the feature so we generate the needed html and set the content of the popup with it
                                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
// We finally set the position of our overlay to the coordinates of the feature
// after a 500 miliseconds delays to let the gotoFeature animation finish before
// displaying the popup
                                setTimeout(() => {
                                    overlay.setPosition(
                                        feature.getGeometry().getCoordinates()
                                    );
                                }, 500)
// We return early to make sure we only take the first feature found into account
                                return
                            }
                        },
                        {
                            hitTolerance: 5,
                        }
                    );
                });
            },
            closePopup() {
                let overlay = this.map.getOverlayById('info')
                overlay.setPosition(undefined)
                this.$refs.popupContent.innerHTML = ''
            },
            styleFunction(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(),
// Increase the zoom and decrease the duration in the gotoFeature function 
                 zoom: 10
                 duration: 2000
                 zoom: 15,
                 duration: 500,
                });
            },
        };
    });
});

Let's make sure the watcher is running, and we should now be able to click on the monuments, be taken to them, and see their pictures in the popup:

In the next post, we will add more data with different geometry types (polygons and lines) to our application. We will also see an additional spatial vector data file other than geojson, the shapefile. Finally, we will learn about an open-source desktop GIS application: QGIS.

The commit for this post is available here: layer-opacity-and-info-popup-in-openlayers

First published 2 years 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.