aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGuilhem Moulin <guilhem@fripost.org>2025-05-27 00:43:01 +0200
committerGuilhem Moulin <guilhem@fripost.org>2025-05-27 00:44:51 +0200
commit670f948f5786aa1fb460dd89e1f934d442a4a81f (patch)
tree7ff0b2e2980deee3fa525d665b3639d0fde42740
parente2eee4c224c6e5b09f4cf4ea8c1e9d6e4f8a7d7c (diff)
Overlay: Get an array of feature properties from the CGI.
Rather than an array GeoJSON objects. The Web Application doesn't need the original full/non-simplified geometry. After all, OpenLayers has fetched the tiles already and the (visible part of) the geometry is already cached in the target SRS with sensible simplification factors. So there is really no need to transfer megabytes of high-precison data to the client to highlight the feature. This changes means that CGI responses will remain small hence can be buffered.
-rw-r--r--main.js297
1 files changed, 168 insertions, 129 deletions
diff --git a/main.js b/main.js
index 250cfa6..3e6ff84 100644
--- a/main.js
+++ b/main.js
@@ -1,5 +1,5 @@
/***********************************************************************
- * Copyright © 2024 Guilhem Moulin <info@guilhem.se>
+ * Copyright © 2024-2025 Guilhem Moulin <info@guilhem.se>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
@@ -36,7 +36,6 @@ import { createXYZ } from 'ol/tilegrid.js';
import VectorLayer from 'ol/layer/Vector.js';
import VectorSource from 'ol/source/Vector.js';
-import GeoJSON from 'ol/format/GeoJSON.js';
import CircleStyle from 'ol/style/Circle.js';
import Fill from 'ol/style/Fill.js';
@@ -152,7 +151,6 @@ const map = new Map({
});
const popup = document.getElementById('popup');
-const featureOverlaySource = new VectorSource();
/* move the control container to the viewport */
const container = document.getElementById('map-control-container');
@@ -351,7 +349,7 @@ if (window.location === window.parent.location) {
map.addControl(control);
control.addEventListener('enterfullscreen', function() {
- featureOverlaySource.clear(true);
+ featureOverlayLayer.setVisible(false);
const popover = Popover.getInstance(popup);
if (popover !== null) {
/* dispose popover as entering fullscreen messes up its position */
@@ -370,7 +368,7 @@ if (window.location === window.parent.location) {
}
})
control.addEventListener('leavefullscreen', function() {
- featureOverlaySource.clear(true);
+ featureOverlayLayer.setVisible(false);
const popover = Popover.getInstance(popup);
if (popover !== null) {
/* dispose popover as is might overflow the viewport */
@@ -489,7 +487,7 @@ if (window.location === window.parent.location) {
container.setAttribute('aria-hidden', 'false');
view.on('change', function(event) {
- featureOverlaySource.clear(true);
+ featureOverlayLayer.setVisible(false);
const popover = Popover.getInstance(popup);
if (popover !== null) {
popover.dispose();
@@ -3195,7 +3193,7 @@ const styles = (function() {
}, {});
})();
-const vectorLayers = (function() {
+const [vectorLayers, featureOverlayLayer] = (function() {
const xyz = '/{z}/{x}/{y}.pbf';
const tileGrid = createXYZ({
extent: extent,
@@ -3204,46 +3202,70 @@ const vectorLayers = (function() {
minZoom: 0,
maxZoom: 7,
});
- return Object.fromEntries(
- ['mrr', 'nvr', 'ren', 'ri', 'sks', 'svk', 'vbk', 'misc']
- .map(function(k) {
- let visible = false;
- Object.keys(layers).forEach(function(lyr) {
- if (lyr.startsWith(k + '_')) {
- visible ||= styles[lyr] !== undefined;
- }
- });
- const vectorLayer = new VectorTileLayer({
- source: new VectorTile({
- url: '/tiles/' + k + xyz,
- format: new MVT(),
- projection: projection,
- wrapX: false,
- transition: 0,
- tileGrid: tileGrid,
- }),
- /* XXX switch to 'hybrid' if there are perf issues; but that seems to
- * put lines above points regardless of their respective z-index */
- renderMode: 'hybrid',
- declutter: false,
- visible: visible,
- style: function(feature, resolution) {
- const style = styles[k + '_' + feature.getProperties().layer];
- if (!Array.isArray(style)) {
- return style;
- } else {
- const maxi = style.length - 1;
- const z = 10 /* Math.log2(maxResolution) */ - Math.log2(resolution);
- /* use Math.floor() as VectorTile.js calls getZForResolution(resolution, 1) */
- const i = z <= 0 ? 0 : z >= maxi ? maxi : Math.floor(z);
- // console.log(`resolution=${resolution}, z=${z}, i=${i}`);
- return style[i];
+ return [
+ Object.fromEntries(
+ ['mrr', 'nvr', 'ren', 'ri', 'sks', 'svk', 'vbk', 'misc']
+ .map(function(k) {
+ let visible = false;
+ Object.keys(layers).forEach(function(lyr) {
+ if (lyr.startsWith(k + '_')) {
+ visible ||= styles[lyr] !== undefined;
}
- }});
- vectorLayer.set('layerGroup', k, true);
- map.addLayer(vectorLayer);
- return [k, vectorLayer];
- }));
+ });
+ const vectorLayer = new VectorTileLayer({
+ source: new VectorTile({
+ url: '/tiles/' + k + xyz,
+ format: new MVT(),
+ projection: projection,
+ wrapX: false,
+ transition: 0,
+ tileGrid: tileGrid,
+ }),
+ /* XXX switch to 'hybrid' if there are perf issues; but that seems to
+ * put lines above points regardless of their respective z-index */
+ renderMode: 'hybrid',
+ declutter: false,
+ visible: visible,
+ style: function(feature, resolution) {
+ const style = styles[k + '_' + feature.getProperties().layer];
+ if (!Array.isArray(style)) {
+ return style;
+ } else {
+ const maxi = style.length - 1;
+ const z = 10 /* Math.log2(maxResolution) */ - Math.log2(resolution);
+ /* use Math.floor() as VectorTile.js calls getZForResolution(resolution, 1) */
+ const i = z <= 0 ? 0 : z >= maxi ? maxi : Math.floor(z);
+ // console.log(`resolution=${resolution}, z=${z}, i=${i}`);
+ return style[i];
+ }
+ }});
+ vectorLayer.set('layerGroup', k, true);
+ map.addLayer(vectorLayer);
+ return [k, vectorLayer];
+ })),
+
+ /* We use a vector tile layer for featureOverlayLayer instead of a simple
+ * vector layer overlay, since we don't want to clip selected geometries at
+ * tile boundaries. It sounds overkill to load an entire tile layer to
+ * display a single feature, but this shouldn't cause much overhead in
+ * practice (the tiles are most likely cached already). */
+ new VectorTileLayer({
+ source: new VectorTile({
+ urls: [],
+ format: new MVT(),
+ projection: projection,
+ wrapX: false,
+ transition: 0,
+ tileGrid: tileGrid,
+ }),
+ zIndex: 65535,
+ declutter: false,
+ visible: false,
+ renderMode: 'vector',
+ map: map,
+ style: null,
+ }),
+ ];
})();
@@ -3501,19 +3523,7 @@ const vectorLayers = (function() {
});
map.addOverlay(popupOverlay);
- map.addLayer(new VectorLayer({
- source: featureOverlaySource,
- zIndex: 65535,
- style: new Style({
- stroke: new Stroke({
- color: 'rgba(0, 255, 255, .8)',
- width: 3,
- }),
- }),
- }));
-
- const features = [];
- let popover, featureNum = 0;
+ let popover, overlayAttributes = [], overlayAttrIdx = 0;
const header = document.createElement('div');
header.classList.add('d-flex');
@@ -3577,12 +3587,38 @@ const vectorLayers = (function() {
pageNode.appendChild(document.createTextNode(' av '));
pageNode.appendChild(pageCount);
+ const featureOverlayStyle = new Style({
+ stroke: new Stroke({
+ color: 'rgba(0, 255, 255, .8)',
+ width: 3,
+ }),
+ });
+ const updateFeatureOverlayLayer = function(layer_group, layer, id) {
+ const lyr = vectorLayers[layer_group];
+ if (lyr === undefined) {
+ return;
+ }
+ const urls = lyr.getSource().getUrls();
+ const source = featureOverlayLayer.getSource();
+ if (source.getUrls().length < 1 || source.getUrls()[0] !== urls[0]) {
+ featureOverlayLayer.setVisible(false);
+ source.setUrls(urls);
+ }
+ featureOverlayLayer.setStyle(function(feature, resolution) {
+ if (feature.getId() === id && feature.getProperties().layer === layer) {
+ return featureOverlayStyle;
+ } else {
+ return undefined;
+ }
+ });
+ featureOverlayLayer.setVisible(true);
+ featureOverlayLayer.changed();
+ };
const refreshPopover = function() {
- const x = features[featureNum];
- featureOverlaySource.clear(true);
- featureOverlaySource.addFeature(x.feature);
+ const x = overlayAttributes[overlayAttrIdx];
+ updateFeatureOverlayLayer(x.layer_group, x.layer, x.id);
- pageNum.innerHTML = (featureNum + 1).toString();
+ pageNum.innerHTML = (overlayAttrIdx + 1).toString();
popover.tip.getElementsByClassName('popover-body')[0].
replaceChildren(x.formattedContent);
};
@@ -3591,17 +3627,17 @@ const vectorLayers = (function() {
if (btn.classList.contains('disabled') || popover === null || popover.tip === null) {
return;
}
- if (featureNum + offset < 0 || featureNum + offset > features.length - 1) {
+ if (overlayAttrIdx + offset < 0 || overlayAttrIdx + offset > overlayAttributes.length - 1) {
return;
}
- featureNum += offset;
- if (featureNum < 1) {
+ overlayAttrIdx += offset;
+ if (overlayAttrIdx < 1) {
btnPrev.classList.add('disabled');
} else {
btnPrev.classList.remove('disabled');
}
- if (featureNum < features.length - 1) {
+ if (overlayAttrIdx < overlayAttributes.length - 1) {
btnNext.classList.remove('disabled');
} else {
btnNext.classList.add('disabled');
@@ -3659,7 +3695,8 @@ const vectorLayers = (function() {
btnClose.title = 'Stäng';
btnClose.setAttribute('aria-label', btnClose.title);
btnClose.onclick = function(event) {
- featureOverlaySource.clear(true);
+ featureOverlayLayer.setVisible(false);
+ featureOverlayLayer.changed();
if (popover !== null) {
popover.dispose();
}
@@ -3672,10 +3709,11 @@ const vectorLayers = (function() {
const container0 = map.getViewport().getElementsByClassName('ol-overlaycontainer-stopevent')[0];
map.on('singleclick', function(event) {
- /* clear the features list */
- featureOverlaySource.clear(true);
- features.length = 0;
- featureNum = 0;
+ /* clear the overlay list */
+ featureOverlayLayer.setVisible(false);
+ featureOverlayLayer.changed();
+ overlayAttributes = [];
+ overlayAttrIdx = 0;
/* dispose any pre-existing popover if not in detached mode */
popover = Popover.getInstance(popup);
@@ -3729,21 +3767,26 @@ const vectorLayers = (function() {
layerFilter: (l) => l !== null && l.get('layerGroup') !== undefined,
});
- if (fetch_body.length > 0) {
- // TODO use https://github.com/uhop/stream-json/wiki/StreamArray
- // to process each item immediately
- fetch('/q', {
- method: 'POST',
- body: JSON.stringify(fetch_body),
- headers: {
- 'Content-Type': 'application/json; charset=UTF-8'
- }
- }).then((resp) => resp.json())
- .then((data) => data.forEach(function(data) {
- document.body.classList.remove('progress');
- const properties = data.properties || {};
- const feature0 = new GeoJSON().readFeature(data);
+ if (fetch_body.length === 0) {
+ if (popover !== null) {
+ /* dispose pre-detached popover */
+ popover.dispose();
+ }
+ return;
+ }
+ fetch('/q', {
+ method: 'POST',
+ body: JSON.stringify(fetch_body),
+ headers: {
+ 'Content-Type': 'application/json; charset=UTF-8'
+ }
+ }).then((resp) => resp.json())
+ .then(function(data) {
+ /* the data is received from the CGI in the order it was sent */
+ /* TODO optimizations on the CGI would break the above assumption, so the
+ * decoded JSON response would need to be reordered to match fetch_body */
+ overlayAttributes = data.map(function(properties) {
/* turn the properties into a fine table */
const table = document.createElement('table');
table.classList.add('table', 'table-sm', 'table-borderless', 'table-hover');
@@ -3820,51 +3863,47 @@ const vectorLayers = (function() {
}
content.appendChild(table);
- features.push({feature: feature0, formattedContent: content});
-
- pageCount.innerHTML = features.length.toString();
- if (features.length == 2) {
- /* there are ≥2 features, render prev/pre buttons */
- btnNext.classList.remove('d-none', 'disabled');
- btnPrev.classList.remove('d-none');
- pageNode.classList.remove('d-none');
- }
-
- if (features.length > 1) {
- /* we're already showing the first feature */
- return;
- }
+ return {
+ layer_group: properties.layer_group,
+ layer: properties.layer,
+ id: properties.ogc_fid,
+ formattedContent: content
+ };
+ });
- if (popover === null || popover.tip === null) {
- /* create a new popover if we're not already showing one */
- pageNum.innerHTML = (featureNum + 1).toString();
- popupOverlay.setPosition(event.coordinate);
-
- popover = new Popover(popup, {
- template: '<div class="popover" role="tooltip"><div class="popover-arrow"></div>' +
- '<div class="popover-header"></div><div class="popover-body"></div></div>',
- title: header,
- content: content,
- html: true,
- placement: 'right',
- fallbackPlacements: ['right', 'left', 'bottom', 'top'],
- container: container0,
- });
- popover.show();
- featureOverlaySource.addFeature(feature0);
- }
- else if (popover.tip.classList.contains('popover-detached')) {
- refreshPopover();
- }
- }))
- .catch(function() {
+ pageCount.innerHTML = overlayAttributes.length.toString();
+ if (overlayAttributes.length >= 2) {
+ /* render prev/pre buttons */
+ btnNext.classList.remove('d-none', 'disabled');
+ btnPrev.classList.remove('d-none');
+ pageNode.classList.remove('d-none');
+ }
+ if (popover === null || popover.tip === null) {
+ /* create a new popover (we're not already showing one in detached mode) */
+ pageNum.innerHTML = (overlayAttrIdx + 1).toString();
+ popupOverlay.setPosition(event.coordinate);
+
+ const attr = overlayAttributes[0];
+ popover = new Popover(popup, {
+ template: '<div class="popover" role="tooltip"><div class="popover-arrow"></div>' +
+ '<div class="popover-header"></div><div class="popover-body"></div></div>',
+ title: header,
+ content: attr.formattedContent,
+ html: true,
+ placement: 'right',
+ fallbackPlacements: ['right', 'left', 'bottom', 'top'],
+ container: container0,
+ });
+ popover.show();
+ updateFeatureOverlayLayer(attr.layer_group, attr.layer, attr.id);
+ }
+ else if (popover.tip.classList.contains('popover-detached')) {
+ /* update existing detached mode popover */
+ refreshPopover();
+ }
+ })
+ .finally(function() {
document.body.classList.remove('progress');
});
- }
-
- if (features.length === 0 && popover !== null && popover.tip !== null) {
- /* dispose pre-detached popover */
- popover.dispose();
- }
});
}());