Skip to content

Commit 503908b

Browse files
committed
fix: request snap WFS features in map projection to avoid datum shift error
When loading snap source features, the previous code made a WFS 1.0.0 request without SRSNAME. QGIS Server returned GeoJSON in EPSG:4326, and OL2 transformed coordinates to the map projection client-side. For CRS that require datum grid shifts (e.g. RD New / EPSG:28992, Swiss LV95, Belgian Lambert), OL2 uses a simplified Helmert approximation rather than the full NTv2 grid that QGIS Server uses internally. This caused a systematic ~cm offset between WMS-rendered feature positions and WFS snap source positions. Backport of fix from main branch, adapted for OL2 APIs used in 3.9.
1 parent d82cd03 commit 503908b

1 file changed

Lines changed: 53 additions & 26 deletions

File tree

assets/src/modules/Snapping.js

Lines changed: 53 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { mainEventDispatcher } from '../modules/Globals.js';
99
import Edition from './Edition.js';
1010
import { MapLayerLoadStatus, MapRootState } from './state/MapLayer.js';
1111
import { TreeRootState } from './state/LayerTree.js';
12+
import WFS from './WFS.js';
1213

1314
/**
1415
* @class
@@ -56,6 +57,7 @@ export default class Snapping {
5657
});
5758

5859
this._lizmap3.map.addLayer(snapLayer);
60+
this._snapLayer = snapLayer;
5961

6062
const snapControl = new OpenLayers.Control.Snapping({
6163
layer: this._edition.editLayer,
@@ -188,7 +190,7 @@ export default class Snapping {
188190
this._pendingMapReadyListener = null;
189191
}
190192
this.active = false;
191-
this._lizmap3.map.getLayersByName('snaplayer')[0].destroyFeatures();
193+
this._snapLayer.destroyFeatures();
192194
this.config = undefined;
193195

194196
// Remove listener to moveend event to layers visibility event
@@ -243,41 +245,66 @@ export default class Snapping {
243245

244246
getSnappingData () {
245247
// Empty snapping layer first
246-
this._lizmap3.map.getLayersByName('snaplayer')[0].destroyFeatures();
248+
this._snapLayer.destroyFeatures();
247249

248250
// filter only visible layers and toggled layers on the the snap list
249251
const currentSnapLayers = this._snapLayers.filter(
250252
(layerId) => this._snapEnabled[layerId] && this._snapToggled[layerId]
251253
);
252254

253-
// TODO : group aync calls with Promises
254-
for (const snapLayer of currentSnapLayers) {
255-
256-
lizMap.getFeatureData(this._lizmap3.getLayerConfigById(snapLayer)[0], null, null, 'geom', this._restrictToMapExtent, null, this._maxFeatures,
257-
(fName, fFilter, fFeatures) => {
255+
// Request the map projection so QGIS Server reprojects server-side with full
256+
// PROJ accuracy (including datum grid shifts). This prevents the ~cm coordinate
257+
// drift that occurs when OL2 performs a client-side EPSG:4326 → map-projection
258+
// transform using a simplified Helmert approximation instead of an NTv2 grid.
259+
const mapProjection = this._lizmap3.map.getProjection();
260+
const mapExtent = this._restrictToMapExtent ? this._lizmap3.map.getExtent() : null;
261+
const wfs = new WFS();
262+
const gFormat = new OpenLayers.Format.GeoJSON({ ignoreExtraDims: true });
258263

259-
// Transform features
260-
const snapLayerConfig = lizMap.config.layers[fName];
261-
let snapLayerCrs = snapLayerConfig['featureCrs'];
262-
if (!snapLayerCrs) {
263-
snapLayerCrs = snapLayerConfig['crs'];
264-
}
264+
for (const snapLayer of currentSnapLayers) {
265+
const layerConfigById = this._lizmap3.getLayerConfigById(snapLayer);
266+
if (!layerConfigById || !layerConfigById[0]) continue;
267+
268+
const layerName = layerConfigById[0];
269+
const layerConf = this._lizmap3.config.layers[layerName];
270+
if (!layerConf) continue;
271+
272+
// Resolve typename (same logic as getVectorLayerWfsUrl)
273+
let typeName = layerName.split(' ').join('_');
274+
if (layerConf.hasOwnProperty('shortname') && layerConf['shortname']) typeName = layerConf['shortname'];
275+
else if (layerConf.hasOwnProperty('typename') && layerConf['typename']) typeName = layerConf['typename'];
276+
277+
const wfsOptions = {
278+
VERSION: '1.1.0',
279+
TYPENAME: typeName,
280+
SRSNAME: mapProjection,
281+
MAXFEATURES: this._maxFeatures,
282+
};
265283

266-
// TODO : use OL 6 instead ?
267-
const gFormat = new OpenLayers.Format.GeoJSON({
268-
ignoreExtraDims: true,
269-
externalProjection: snapLayerCrs,
270-
internalProjection: this._lizmap3.map.getProjection()
271-
});
284+
// Apply existing layer filter if present (e.g. login-based filter)
285+
if (layerConf.hasOwnProperty('request_params') && layerConf['request_params'].hasOwnProperty('filter')) {
286+
const layerFilter = layerConf['request_params']['filter'];
287+
if (layerFilter) {
288+
wfsOptions['EXP_FILTER'] = layerFilter.replace(layerName + ':', '');
289+
}
290+
}
272291

273-
const tfeatures = gFormat.read({
274-
type: 'FeatureCollection',
275-
features: fFeatures
276-
});
292+
// Append CRS code so the server interprets the extent in the map projection
293+
if (mapExtent) {
294+
wfsOptions['BBOX'] = [mapExtent.left, mapExtent.bottom, mapExtent.right, mapExtent.top].join(',') + ',' + mapProjection;
295+
}
277296

278-
// Add features
279-
this._lizmap3.map.getLayersByName('snaplayer')[0].addFeatures(tfeatures);
297+
wfs.getFeature(wfsOptions).then(data => {
298+
if (!data || !Array.isArray(data.features)) return;
299+
// Features are already in map projection — no client-side reprojection needed.
300+
const tfeatures = gFormat.read({
301+
type: 'FeatureCollection',
302+
features: data.features
280303
});
304+
this._snapLayer.addFeatures(tfeatures);
305+
}).catch(err => {
306+
console.warn('Snapping: WFS request failed for layer', layerName, err);
307+
});
281308
}
282309

283310
this.snapLayersRefreshable = false;
@@ -358,7 +385,7 @@ export default class Snapping {
358385
}
359386

360387
// Set snap layer visibility
361-
this._lizmap3.map.getLayersByName('snaplayer')[0].setVisibility(this._active);
388+
this._snapLayer.setVisibility(this._active);
362389

363390
mainEventDispatcher.dispatch('snapping.active');
364391
}

0 commit comments

Comments
 (0)