So I’ve been investigating Javascript mapping libraries and I’ve had a chance to investigate three. I’ve been working with them and chatGPT to build basic applications using data from the Spatial Hub. I would not recommend them over something like ArcGIS Online (especially for display to the general public) for any planning authorities unless they want to be cutting edge as all three require development skills and servers to put into production which may not be available to all (Could be great for some non-critical internal tools though) – anyone thinking of an implemetation should know their way round web server administration and understand the structure of standard applications. For confident individuals with zero budget or for new startups with very limited budgets they can be used to produce most of what many providers can give you probably by using server provision you already have(depending on your skill levels). They represent probably the three leading open source mapping libraries available at November 2024 and all three are well supported. They also represent the only available option if you want no rate limiting and no per seat licencing costs.
Below the MapLibre Example
I’ve put together two example applications (one using Leaflet and one using MapLibre) each took a couple of hours. They are not direct copies of each other but the opening display layer displayed is the same – Housing / Employment Land and Vacant and Derelict land open sourced from Spatial Hub both are hosted on the same cpanel shared hosting and its quite clear that MapLibre has better performance especially on mobile. It should be noted all three demonstrate why WFS is so important. Long term provision of mapping is all about thinking who will be there in ten years and can we maintain and secure our information. I was able to get a similar open layers application up and running with WFS implemented but I don’t have that at the moment to hand to publish. With the help of QGIS2WEB the process of creating an application was quicker and simpler than others and importantly it automates much of good development procedures such as creating a proper web application directory structure and avoiding CDNs which makes it more resilient which is something you would want to do if you were pushing to put Openlayers or MapLibre into production.
Name and Link to Website | Some points | My Experience | Example Application Link |
OpenLayers | Solid well maintained library WFS compatible Not mobile friendly | Web Desktop Not Fully supported on Small Screens WFS compatible Requirements : Access to Server / Bespoke Software Development | n/a |
Leaflet | Solid well maintained WFS compatible Mobile Friendly Applications can be managed using QGIS2Web although using this I have had difficulty implementing WFS layers | Web Desktop and Web Mobile Most accessible because of QGIS2WEB Requirements : Access to Server / Can avoid pure development by using QGIS2WEB I’ve not been able to get QGIS2WEB to recognise WFS layers | link |
MapLibre | Solid well maintained library support from AWS and Meta I believe its WFS compatible but not tested Mobile Friendly No bespoke administration tool like QGIS2Web | Desktop and Mobile Smoothest and best support for Mobile I’ve come across Nice features – GPU support for graphics means its the smoothest of the three – which makes it easily the best mobile experience – downside is that it is less well known and no QGIS2WEB like tool. Requirements : Access to Server / Bespoke Software Development | link |
Compare this with the market leading ARCGIS Online which comes with paid support, paid hosting and full simplified Web Administration which is a massive thing, allowing for the democratisation of administration AND it also supports WFS. ARCGIS Online is designed specifically with simplified administration that separates users from ugly configuration or direct server access. Obviously it is seat and rate limited which is the deal you make.
I note that MapBender , Felt and Bing Maps are all built using MapLibre
MapLibre example application
The following zip file contains three files index.html which contains javascript application of the above example and two geojson related files stratland.geojson and stratland.qmd (Note the download is a slightly old version which does not contain the basemap switcher). To publish yourself you should have a cpanel account – go to your cpanel account, create a sub domain and within that subdomain directory create a folder called geojson, upload the index.html to the subdomain directory and the qmd and geojson suffixed files to the geojson subdomain directory. Navigate to the subdomain url and hey presto.
For those wanting to play with something even simpler here’s a MapLibre application which you can run locally on any windows machine by downloading and double clicking. It displays Open Streetmaps with no overlays but will display any geojson file if you have one providing that it must be in EPSG:27700 (see upload GeoJson button) – So UK Centric
And the code for the MapLibre Example v1 application (without basemap switcher)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>MapLibre Map with Polygon Highlight</title>
<!-- MapLibre GL CSS and JS -->
<link href="https://unpkg.com/maplibre-gl@2.4.0/dist/maplibre-gl.css" rel="stylesheet"/>
<script src="https://unpkg.com/maplibre-gl@2.4.0/dist/maplibre-gl.js"></script>
<!-- Mapbox GL Draw CSS and JS (compatible with MapLibre) -->
<link href="https://unpkg.com/@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css" rel="stylesheet"/>
<script src="https://unpkg.com/@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.js"></script>
<!-- Turf.js for geometry calculations -->
<script src="https://unpkg.com/@turf/turf@6.5.0/turf.min.js"></script>
<!-- Proj4js for coordinate conversion -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/proj4js/2.7.5/proj4.js"></script>
<style>
html, body {
height: 100%;
margin: 0;
padding: 0;
font-family: Arial, sans-serif;
}
#map {
height: 100%;
width: 100%;
position: relative;
}
/* Center cross */
#map-center-cross {
position: absolute;
top: 50%;
left: 50%;
width: 20px;
height: 20px;
transform: translate(-50%, -50%);
z-index: 1002;
pointer-events: none;
}
#map-center-cross:after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 20px;
height: 20px;
background: none;
border-left: 2px solid red;
border-top: 2px solid red;
transform: translate(-50%, -50%) rotate(45deg);
}
#feature-dialog {
position: fixed;
bottom: 20px;
right: 20px;
width: 300px;
max-height: 400px;
overflow-y: auto;
background-color: rgba(255, 255, 255, 0.95);
border: 1px solid #ccc;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
padding: 15px;
display: none;
z-index: 1100;
}
#feature-dialog h3 {
margin-top: 0;
font-size: 1.2em;
}
#feature-dialog .dialog-content {
margin-bottom: 10px;
}
#feature-dialog .dialog-buttons {
text-align: right;
}
#feature-dialog button {
margin-left: 5px;
padding: 8px 12px;
font-size: 1em;
cursor: pointer;
border: none;
border-radius: 4px;
background-color: #007BFF;
color: white;
transition: background-color 0.3s;
}
#feature-dialog button:hover {
background-color: #0056b3;
}
#coordinate-display {
position: fixed;
bottom: 20px;
left: 20px;
background-color: rgba(255, 255, 255, 0.95);
border: 1px solid #ccc;
border-radius: 4px;
padding: 8px 12px;
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
font-size: 14px;
z-index: 1100;
}
.loading-data-indicator {
font-size: 14px;
color: #333;
background-color: rgba(255, 255, 255, 0.8);
padding: 5px 10px;
border-radius: 4px;
box-shadow: 0 0 15px rgba(0,0,0,0.2);
position: absolute;
z-index: 1100;
top: 10px;
right: 10px;
}
/* Clear button positioned below the draw controls */
.clear-measurements-btn {
position: absolute;
top: 150px;
left: 10px;
background: white;
border: 1px solid #ccc;
border-radius: 4px;
padding: 5px 10px;
cursor: pointer;
z-index: 1100;
}
/* Layer toggle box in top right */
.layer-toggle-box {
position: absolute;
top: 10px;
right: 10px;
background: rgba(255,255,255,0.9);
border: 1px solid #ccc;
border-radius: 4px;
padding: 5px 10px;
font-size: 14px;
z-index: 1100;
display: flex;
align-items: center;
gap: 5px;
}
.layer-toggle-box input[type="checkbox"] {
cursor: pointer;
}
/* Legend box */
.legend-box {
position: absolute;
top: 60px;
right: 10px;
background: rgba(255,255,255,0.9);
border: 1px solid #ccc;
border-radius: 4px;
padding: 5px 10px;
font-size: 14px;
z-index: 1100;
}
.legend-item {
display: flex;
align-items: center;
margin-bottom: 5px;
}
.legend-color-box {
width: 20px;
height: 20px;
border: 1px solid #000;
margin-right: 5px;
}
</style>
<script>
const MAP_CRS = 'EPSG:4326';
class MapApp {
constructor() {
this.map = null;
this.draw = null;
// Polygon mode variables
this.polygonMode = false;
this.polygonSelectedFeatures = [];
this.polygonCurrentIndex = 0;
this.initMap();
this.setupCoordinateDisplay();
this.setupDialog();
this.createClearMeasurementsButton();
// Show loading data message until strategic land is displayed
this.showStratlandLoading(true);
}
initMap() {
this.map = new maplibregl.Map({
container: 'map',
style: {
"version": 8,
"sources": {
"osm": {
"type": "raster",
"tiles": [
"https://a.tile.openstreetmap.org/{z}/{x}/{y}.png",
"https://b.tile.openstreetmap.org/{z}/{x}/{y}.png",
"https://c.tile.openstreetmap.org/{z}/{x}/{y}.png"
],
"tileSize": 256
}
},
"layers": [
{
"id": "osm",
"type": "raster",
"source": "osm",
"minzoom": 0,
"maxzoom": 19
}
]
},
center: [-3.2, 55.95],
zoom: 10
});
// Add center cross
const cross = document.createElement('div');
cross.id = 'map-center-cross';
this.map.getContainer().appendChild(cross);
// Initialize Mapbox Draw with custom styling for measured areas
this.draw = new MapboxDraw({
displayControlsDefault: false,
controls: {
polygon: true,
line_string: true,
point: false,
trash: false,
combine_features: false,
uncombine_features: false
},
styles: [
// Active polygon fill
{
"id": "gl-draw-polygon-fill-active",
"type": "fill",
"filter": ["all", ["==", "$type", "Polygon"], ["==", "active", "true"]],
"paint": {
"fill-color": "rgba(255,0,0,0.5)"
}
},
// Active polygon outline
{
"id": "gl-draw-polygon-stroke-active",
"type": "line",
"filter": ["all", ["==", "$type", "Polygon"], ["==", "active", "true"]],
"paint": {
"line-color": "#FF0000",
"line-width": 2
}
},
// Inactive polygon fill
{
"id": "gl-draw-polygon-fill-inactive",
"type": "fill",
"filter": ["all", ["==", "$type", "Polygon"], ["==", "active", "false"]],
"paint": {
"fill-color": "rgba(255,0,0,0.5)"
}
},
// Inactive polygon outline
{
"id": "gl-draw-polygon-stroke-inactive",
"type": "line",
"filter": ["all", ["==", "$type", "Polygon"], ["==", "active", "false"]],
"paint": {
"line-color": "#FF0000",
"line-width": 2
}
},
// Active line
{
"id": "gl-draw-line-active",
"type": "line",
"filter": ["all", ["==", "$type", "LineString"], ["==", "active", "true"]],
"paint": {
"line-color": "#FF0000",
"line-width": 2
}
},
// Inactive line
{
"id": "gl-draw-line-inactive",
"type": "line",
"filter": ["all", ["==", "$type", "LineString"], ["==", "active", "false"]],
"paint": {
"line-color": "#FF0000",
"line-width": 2
}
}
]
});
this.map.addControl(this.draw, 'top-left');
this.map.on('draw.create', (e) => {
this.measureNewFeature(e.features[0]);
});
this.map.on('load', () => {
// Define projections
proj4.defs("EPSG:4326", "+proj=longlat +datum=WGS84 +no_defs");
proj4.defs("EPSG:27700", "+proj=tmerc +lat_0=49 +lon_0=-2 "
+ "+k=0.9996012717 +x_0=400000 +y_0=-100000 +ellps=airy "
+ "+towgs84=446.448,-125.157,542.060,0.1502,0.2470,0.8421,20.4894 +units=m +no_defs");
const projection27700 = proj4('EPSG:27700');
const projection4326 = proj4('EPSG:4326');
// Helper function to transform coordinates
function transformCoordinates(geometry, fromProj, toProj) {
if (geometry.type === 'Point') {
geometry.coordinates = proj4(fromProj, toProj, geometry.coordinates);
} else if (geometry.type === 'LineString' || geometry.type === 'MultiPoint') {
geometry.coordinates = geometry.coordinates.map(coord => proj4(fromProj, toProj, coord));
} else if (geometry.type === 'Polygon' || geometry.type === 'MultiLineString') {
geometry.coordinates = geometry.coordinates.map(ring =>
ring.map(coord => proj4(fromProj, toProj, coord))
);
} else if (geometry.type === 'MultiPolygon') {
geometry.coordinates = geometry.coordinates.map(polygon =>
polygon.map(ring =>
ring.map(coord => proj4(fromProj, toProj, coord))
)
);
}
}
// Load your GeoJSON polygon data
fetch('geojson/stratland.geojson')
.then(response => response.json())
.then(data => {
data.features.forEach(feature => {
transformCoordinates(feature.geometry, projection27700, projection4326);
});
this.map.addSource('stratland', {
type: 'geojson',
data: data
});
// Layer styling based on category attribute
this.map.addLayer({
id: 'stratland-layer',
type: 'fill',
source: 'stratland',
paint: {
'fill-color': [
'match',
['get', 'category'],
'housingland', 'rgba(139,69,19,0.5)', // Brown
'vacantandderelictland', 'rgba(128,128,128,0.5)', // Grey
'employmentland', 'rgba(128,0,128,0.5)', // Purple
'rgba(0,0,0,0.5)' // fallback if another category appears
],
'fill-outline-color': '#000000'
}
});
// Highlight layer for selected polygons
this.map.addLayer({
id: 'polygon-highlight-layer',
type: 'fill',
source: 'stratland',
paint: {
'fill-color': 'rgba(255, 255, 0, 0.5)',
'fill-outline-color': '#FF0000'
},
filter: ['==', 'pkid', '']
});
this.createLayerToggle();
this.createLegend();
this.showStratlandLoading(false);
});
// Polygon selection on click
this.map.on('click', 'stratland-layer', (e) => {
const polygonFeatures = this.map.queryRenderedFeatures(e.point, { layers: ['stratland-layer'] });
if (polygonFeatures.length > 0) {
this.polygonMode = true;
this.polygonSelectedFeatures = polygonFeatures;
this.polygonCurrentIndex = 0;
this.showDialog();
this.updateDialogContent();
this.updateButtons();
}
});
this.updateCenterCoordinates();
});
this.map.on('moveend', () => {
this.updateCenterCoordinates();
});
}
createLayerToggle() {
const container = document.createElement('div');
container.className = 'layer-toggle-box';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = true;
checkbox.id = 'stratland-toggle';
const label = document.createElement('label');
label.setAttribute('for', 'stratland-toggle');
label.textContent = 'Strategic Land';
checkbox.addEventListener('change', () => {
const visibility = checkbox.checked ? 'visible' : 'none';
this.map.setLayoutProperty('stratland-layer', 'visibility', visibility);
this.map.setLayoutProperty('polygon-highlight-layer', 'visibility', visibility);
});
container.appendChild(checkbox);
container.appendChild(label);
document.body.appendChild(container);
}
createLegend() {
const legend = document.createElement('div');
legend.className = 'legend-box';
legend.innerHTML = `
<div class="legend-item">
<span class="legend-color-box" style="background:rgba(139,69,19,0.5);"></span> Housing Land
</div>
<div class="legend-item">
<span class="legend-color-box" style="background:rgba(128,128,128,0.5);"></span> Vacant & Derelict Land
</div>
<div class="legend-item">
<span class="legend-color-box" style="background:rgba(128,0,128,0.5);"></span> Employment Land
</div>
`;
document.body.appendChild(legend);
}
measureNewFeature(feature) {
if (feature.geometry.type === 'LineString') {
const length = turf.length(feature, {units: 'meters'});
alert("Line length: " + length.toFixed(2) + " meters");
} else if (feature.geometry.type === 'Polygon') {
const area = turf.area(feature); // area in square meters
const hectares = area / 10000;
alert("Polygon area: " + hectares.toFixed(2) + " hectares");
}
}
createClearMeasurementsButton() {
const btn = document.createElement('div');
btn.className = 'clear-measurements-btn';
btn.innerHTML = 'Clear';
btn.onclick = () => this.clearAllDrawings();
document.body.appendChild(btn);
}
clearAllDrawings() {
const data = this.draw.getAll();
if (data.features.length > 0) {
this.draw.deleteAll();
}
}
setupDialog() {
// Create dialog elements
const dialog = document.createElement('div');
dialog.id = 'feature-dialog';
dialog.innerHTML = `
<h3>Features</h3>
<div class="dialog-content"></div>
<div class="dialog-buttons">
<button id="prev-button">Previous</button>
<button id="next-button">Next</button>
<button id="close-button">Close</button>
</div>
`;
document.body.appendChild(dialog);
this.dialog = dialog;
this.dialogContent = this.dialog.querySelector('.dialog-content');
this.prevButton = this.dialog.querySelector('#prev-button');
this.nextButton = this.dialog.querySelector('#next-button');
this.closeButton = this.dialog.querySelector('#close-button');
this.prevButton.addEventListener('click', () => this.showPreviousFeature());
this.nextButton.addEventListener('click', () => this.showNextFeature());
this.closeButton.addEventListener('click', () => this.hideDialog());
}
showDialog() {
if (this.polygonMode && this.polygonSelectedFeatures.length === 0) {
this.hideDialog();
return;
}
this.dialog.style.display = 'block';
}
hideDialog() {
this.dialog.style.display = 'none';
this.map.setFilter('polygon-highlight-layer', ['==', 'pkid', '']);
}
showPreviousFeature() {
if (this.polygonMode) {
if (this.polygonSelectedFeatures.length === 0) return;
this.polygonCurrentIndex = (this.polygonCurrentIndex - 1 + this.polygonSelectedFeatures.length) % this.polygonSelectedFeatures.length;
}
this.updateDialogContent();
this.updateButtons();
}
showNextFeature() {
if (this.polygonMode) {
if (this.polygonSelectedFeatures.length === 0) return;
this.polygonCurrentIndex = (this.polygonCurrentIndex + 1) % this.polygonSelectedFeatures.length;
}
this.updateDialogContent();
this.updateButtons();
}
updateDialogContent() {
let contentHTML = '';
const dialogTitle = this.dialog.querySelector('h3');
if (this.polygonMode) {
const total = this.polygonSelectedFeatures.length;
const feature = this.polygonSelectedFeatures[this.polygonCurrentIndex];
const properties = feature.properties;
dialogTitle.textContent = `Polygon ${this.polygonCurrentIndex + 1} of ${total}`;
contentHTML += '<div class="dialog-content">';
for (const key in properties) {
contentHTML += `<strong>${key}:</strong> ${properties[key]}<br/>`;
}
contentHTML += '</div>';
// Highlight the currently displayed polygon by pkid
const pkid = properties.pkid || '';
this.map.setFilter('polygon-highlight-layer', ['==', 'pkid', pkid]);
}
this.dialogContent.innerHTML = contentHTML;
}
updateButtons() {
if (this.polygonMode) {
const count = this.polygonSelectedFeatures.length;
if (count > 1) {
this.prevButton.style.display = 'inline-block';
this.nextButton.style.display = 'inline-block';
} else {
this.prevButton.style.display = 'none';
this.nextButton.style.display = 'none';
}
}
}
setupCoordinateDisplay() {
this.coordContainer = document.createElement('div');
this.coordContainer.id = 'coordinate-display';
this.coordContainer.innerHTML = 'E: 0, N: 0';
document.body.appendChild(this.coordContainer);
this.map.on('moveend', () => this.updateCenterCoordinates());
}
updateCenterCoordinates() {
const center = this.map.getCenter();
const lng = center.lng;
const lat = center.lat;
const [easting, northing] = proj4('EPSG:4326','EPSG:27700', [lng, lat]);
this.coordContainer.innerHTML = `E: ${Math.round(easting)}, N: ${Math.round(northing)}`;
}
showStratlandLoading(show) {
let indicator = document.querySelector('.loading-data-indicator');
if (show) {
if (!indicator) {
indicator = document.createElement('div');
indicator.className = 'loading-data-indicator';
indicator.innerHTML = 'Loading data...';
document.body.appendChild(indicator);
}
} else {
if (indicator) {
indicator.remove();
}
}
}
}
document.addEventListener("DOMContentLoaded", () => new MapApp());
</script>
</head>
<body>
<div id="map"></div>
</body>
</html>