We are now serving PostGIS stored data through OpenLayers as vector features. Still, we have a few problems in our design, one of them being that we are loading the features synchronously, passing them to the OpenLayers component as an argument in the Html itself. If we scale to hundreds, potentially thousands of features, the page size will overgrow and not be responsive until all data is finished loading in the browser. Using WFS (Web Feature Service) services in Geoserver will fix this problem as we will load our features asynchronously with OpenLayers.
- Let's add a Geoserver service to our docker-compose.yml file and a volume, so we persist the data directory (make sure your services are all down first by running "docker-compose down" before saving the changes):
version: "3.7"
volumes:
postgres-data:
redis-data:
# The volume for persisting geoserver data directory
geoserver-data:
networks:
frontend:
backend:
services:
proxy:
image: nginx:latest
ports:
- "8080:80"
volumes:
- ./:/var/www/app
- ./docker/nginx/nginx-site.conf:/etc/nginx/conf.d/default.conf
networks:
- frontend
- backend
php:
build:
context: ./docker/php
dockerfile: Dockerfile
image: laravelgis-php:latest
ports:
- "5173:5173"
volumes:
- ./:/var/www/app
networks:
- backend
postgres:
image: postgis/postgis:15-3.3
volumes:
- postgres-data:/var/lib/postgresql/data
ports:
- "5432:5432"
environment:
POSTGRES_PASSWORD: 12345
POSTGRES_USER: laravelgis
POSTGRES_DB: laravelgis
PGDATA: /var/lib/postgresql/data
networks:
- backend
redis:
image: redis:latest
sysctls:
- net.core.somaxconn=511
ports:
- "6379:6379"
volumes:
- redis-data:/data
networks:
- backend
# We use a well maintained and widely used docker image for geoserver: kartoza/geoserver.
# Geoserver uses port 8080 by default so we map our host port 8081 to
# the container's 8080 port, map the volume and set up a few environment variables
geoserver:
image: kartoza/geoserver:latest
ports:
- "8081:8080"
volumes:
- geoserver-data:/opt/geoserver/data_dir
environment:
GEOSERVER_DATA_DIR: /opt/geoserver/data_dir
GEOSERVER_ADMIN_USER: admin
GEOSERVER_ADMIN_PASSWORD: geoserver
networks:
- backend
After running "docker-compose up" again, the latest kartoza/geoserver docker image should be automatically downloaded, and you should be able to point your browser to http://localhost:8081/geoserver and see a page like this:
We can log in using the credentials specified in the docker-compose.yml file (admin/geoserver in our case). At this point, we have a plain empty Geoserver; we will first need to create a workspace for our project. Once logged in, click "Workspaces" in the menu on the left and the "Add new Workspace" link at the top of the page. Create a new workspace named laravelgis and set it as the default workspace like this:
Once we have a workspace, we will be able to create a PostGIS Store; this will be the direct connection between Geoserver and PostGIS. Click "Stores" in the menu on the left and the "Add new Store" link at the top. In the next screen, choose "PostGIS - PostGIS Database" from the "Vector Data Sources" section. Then, fill the database connection information exactly as it is set in the .env file:
You will be presented with the option to add a new layer from the new PostGIS Store; click publish on the monuments table:
In the next page, leave everything as it is with the default values but click on "Compute from SRS Bounds" in the "Native Bounding Box" section and on "Compute from Native Bounds" in the "Lat/Lon Bounding Box" section before saving.
Great, that's all we need to do in terms of Geoserver setup for now; the layer is now shared by Geoserver:
Let's see how we can asynchronously load it directly from OpenLayers. First, remove the ugly Laravel code in the routes/web.php file:
<?php
use App\Models\Monument;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
});
Route::middleware([
'auth:sanctum',
config('jetstream.auth_session'),
'verified'
$geojson = [
'type' => 'FeatureCollection',
'features' => [],
];
Monument::selectRaw('id, name, image, ST_AsGeoJSON(geom) as geom')
->get()
->each(function ($monument) use (&$geojson) {
$geojson['features'][] = [
'type' => 'Feature',
'properties' => [
'name' => $monument->name,
'image' => $monument->image,
],
'geometry' => json_decode($monument->geom, true),
];
});
return view('dashboard', ['geojson' => json_encode($geojson)]);
return view('dashboard');
})->name('dashboard');
In the resources/views/components/map.blade.php file, let's make the following changes:
@props(['monuments'])
<div x-data="map()" x-init="initComponent({{ json_encode($monuments) }})">
<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">
(...)
<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-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 layer.getSource().getFeatures()" :key="index">
<a href="#"
:title="'Go to ' + feature.get('name')"
x-text="feature.get('name')"
x-on:click.prevent="gotoFeature(feature)"
class="block hover:underline hover:text-slate-800 focus:outline-none focus:underline focus:text-slate-800 transition">
</a>
</template>
<!-- As the monuments are loading asynchronously, we will setup a getter on the
component so Alpine will pick up on changes and trigger the legend refresh -->
<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>
</li>
</template>
</ul>
(...)
In the resources/views/dashboard.blade.php, remove the monuments parameter passed to the map component as we don't need it anymore:
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('Dashboard') }}
</h2>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg">
<x-map :monuments="$geojson"/>
<x-map />
</div>
</div>
</div>
</x-app-layout>
Finally, make the following changes to the resources/js/components/map.js:
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: {},
features: [],
monumentsLayer: [],
// No need to receive the monuments anymore
initComponent(monuments) {
initComponent() {
this.features = new GeoJSON().readFeatures(monuments);
// WFS is a REST service so we configure the query string parameters first
// We use the GetFeature request type on the laravelgis:monuments layer
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)
// Adds the query string to the base geoserver WFS url
const monumentsUrl = 'http://localhost:8081/geoserver/wfs?' + urlParams.toString()
// Creates the layer with a vector source of format get, with the url we just generated and with
// the same styleFunction we've been using.
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",
}),
new VectorLayer({
source: new VectorSource({
features: this.features,
}),
style: this.styleFunction,
label: "Monuments",
}),
// Use the new monumentsLayer
this.monumentsLayer,
],
view: new View({
projection: "EPSG:4326",
center: [0, 0],
zoom: 2,
}),
});
},
// Getter on the monuments features, used in the legend
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,
});
},
};
});
});
If we did everything correctly, run "dr npm run dev" and go to http://localhost:8080/dashboard, we should still see our features on the map, but we are now using the Geoserver WFS as a data source for the monuments layers. In the image below, you can see the response from the WFS service in json:
In the next post, we will secure our Geoserver a little bit and prevent future complicated debugging CORS problems by taking advantage of our Nginx proxy service and set up a reverse proxy for Geoserver.
The commit for this post is available here: setting-up-geoserver-with-docker