Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/e2e_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ jobs:
env:
ACTIONS_STEP_DEBUG: true
with:
username: "3liz"
username: "meyerlor"
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Checkout
Expand Down
106 changes: 80 additions & 26 deletions assets/src/modules/Snapping.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { mainEventDispatcher } from '../modules/Globals.js';
import Edition from './Edition.js';
import { MapLayerLoadStatus, MapRootState } from './state/MapLayer.js';
import { TreeRootState } from './state/LayerTree.js';
import WFS from './WFS.js';

/**
* @class
Expand Down Expand Up @@ -41,6 +42,7 @@ export default class Snapping {
this._snapLayers = [];
this._snapOnStart = false;
this._pendingMapReadyListener = null;
this._wfsErrorNotified = false;

// Create layer to store snap features
const snapLayer = new OpenLayers.Layer.Vector('snaplayer', {
Expand All @@ -56,6 +58,7 @@ export default class Snapping {
});

this._lizmap3.map.addLayer(snapLayer);
this._snapLayer = snapLayer;

const snapControl = new OpenLayers.Control.Snapping({
layer: this._edition.editLayer,
Expand Down Expand Up @@ -188,7 +191,7 @@ export default class Snapping {
this._pendingMapReadyListener = null;
}
this.active = false;
this._lizmap3.map.getLayersByName('snaplayer')[0].destroyFeatures();
this._snapLayer.destroyFeatures();
this.config = undefined;

// Remove listener to moveend event to layers visibility event
Expand Down Expand Up @@ -241,43 +244,94 @@ export default class Snapping {
}
}

/**
* Log a WFS snap failure and surface a user-visible message, once per refresh batch.
* Called from both the rejection and the "no valid FeatureCollection" path.
* @param {string} layerName - the layer whose WFS request failed
* @param {Error|object|string} detail - the error or payload that triggered the notice
* @private
*/
_notifySnapWfsError(layerName, detail) {
console.warn('Snapping: WFS request failed for layer', layerName, detail);
if (this._wfsErrorNotified) return;
this._wfsErrorNotified = true;
const msg = lizDict['snapping.message.wfs_error']
|| 'Snapping: failed to load data for some layers — snap may be incomplete.';
this._lizmap3.addMessage(msg, 'error', true, 7000);
}

getSnappingData () {
// Empty snapping layer first
this._lizmap3.map.getLayersByName('snaplayer')[0].destroyFeatures();
this._snapLayer.destroyFeatures();

// Reset the once-per-refresh error notification flag so a new batch of
// requests can re-surface a user-visible message if WFS still fails.
this._wfsErrorNotified = false;

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

// TODO : group aync calls with Promises
for (const snapLayer of currentSnapLayers) {

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

// Transform features
const snapLayerConfig = lizMap.config.layers[fName];
let snapLayerCrs = snapLayerConfig['featureCrs'];
if (!snapLayerCrs) {
snapLayerCrs = snapLayerConfig['crs'];
}
for (const snapLayer of currentSnapLayers) {
const layerConfigById = this._lizmap3.getLayerConfigById(snapLayer);
if (!layerConfigById || !layerConfigById[0]) continue;

const layerName = layerConfigById[0];
const layerConf = this._lizmap3.config.layers[layerName];
if (!layerConf) continue;

// Resolve typename (same logic as getVectorLayerWfsUrl)
let typeName = layerName.split(' ').join('_');
if (layerConf.hasOwnProperty('shortname') && layerConf['shortname']) typeName = layerConf['shortname'];
else if (layerConf.hasOwnProperty('typename') && layerConf['typename']) typeName = layerConf['typename'];

const wfsOptions = {
VERSION: '1.1.0',
TYPENAME: typeName,
SRSNAME: mapProjection,
MAXFEATURES: this._maxFeatures,
};

// TODO : use OL 6 instead ?
const gFormat = new OpenLayers.Format.GeoJSON({
ignoreExtraDims: true,
externalProjection: snapLayerCrs,
internalProjection: this._lizmap3.map.getProjection()
});
// Apply existing layer filter if present (e.g. login-based filter)
if (layerConf.hasOwnProperty('request_params') && layerConf['request_params'].hasOwnProperty('filter')) {
const layerFilter = layerConf['request_params']['filter'];
if (layerFilter) {
wfsOptions['EXP_FILTER'] = layerFilter.replace(layerName + ':', '');
}
}

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

// Add features
this._lizmap3.map.getLayersByName('snaplayer')[0].addFeatures(tfeatures);
wfs.getFeature(wfsOptions).then(data => {
if (!data || !Array.isArray(data.features)) {
// The WFS endpoint returned something (no rejection) but not a
// FeatureCollection — most likely an OGC ExceptionReport wrapped as
// JSON. Treat as a failure so the user sees that snap may be incomplete.
this._notifySnapWfsError(layerName, data);
return;
}
// Features are already in map projection — no client-side reprojection needed.
const tfeatures = gFormat.read({
type: 'FeatureCollection',
features: data.features
});
this._snapLayer.addFeatures(tfeatures);
}).catch(err => {
this._notifySnapWfsError(layerName, err);
});
}

this.snapLayersRefreshable = false;
Expand Down Expand Up @@ -358,7 +412,7 @@ export default class Snapping {
}

// Set snap layer visibility
this._lizmap3.map.getLayersByName('snaplayer')[0].setVisibility(this._active);
this._snapLayer.setVisibility(this._active);

mainEventDispatcher.dispatch('snapping.active');
}
Expand Down
11 changes: 8 additions & 3 deletions lizmap/modules/lizmap/lib/Request/Proxy.php
Original file line number Diff line number Diff line change
Expand Up @@ -210,13 +210,18 @@ public static function normalizeParams($params)
ksort($data);

// Round bbox params to avoid rounding issues with cache
// Preserve non-numeric elements (e.g. WFS 1.1.0 5-element BBOX CRS suffix like "EPSG:3857")
if (array_key_exists('bbox', $data)) {
$bboxExp = explode(',', $data['bbox']);
$nBbox = array();
foreach ($bboxExp as $val) {
$val = (float) $val;
$val = round($val, 6);
$nBbox[] = str_replace(',', '.', (string) $val);
$val = trim($val);
if (is_numeric($val)) {
$val = round((float) $val, 6);
$nBbox[] = str_replace(',', '.', (string) $val);
} else {
$nBbox[] = $val;
}
}
$data['bbox'] = implode(',', $nBbox);
}
Expand Down
110 changes: 86 additions & 24 deletions lizmap/modules/lizmap/lib/Request/WFSRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@
*/
class WFSRequest extends OGCRequest
{
/**
* Default output SRID when no SRSNAME is provided on the WFS GetFeature request
* (EPSG:4326 — the historical WFS default).
*/
protected const DEFAULT_OUTPUT_SRID = 4326;

protected $tplExceptions = 'lizmap~wfs_exception';

/**
Expand Down Expand Up @@ -720,6 +726,31 @@ protected function buildQueryBase($cnx, $params, $wfsFields, $isHitsRequest = fa
return $sql;
}

/**
* Parse a WFS SRSNAME string and return its SRID.
*
* Accepts the colon-separated forms used by WFS clients:
* "EPSG:4326", "urn:ogc:def:crs:EPSG::4326"
* The last colon-separated segment must be a positive integer.
*
* @param string $srsname the raw SRSNAME value
*
* @return null|int the SRID on success, null if the input is empty or malformed
*/
protected function parseSrsnameSrid(string $srsname): ?int
{
if ($srsname === '') {
return null;
}
$parts = explode(':', $srsname);
$last = end($parts);
if ($last !== false && ctype_digit($last)) {
return intval($last);
}

return null;
}

/**
* Get the SQL clause to instersects bbox in the request parameters.
*
Expand All @@ -746,34 +777,42 @@ protected function getBboxSql(array $params): string
}

// Check the BBOX parameter
// It has to contain 4 numeric separated by comma
// WFS 1.0.0 sends 4 numeric values; WFS 1.1.0 appends the CRS as a 5th element
// e.g. "xmin,ymin,xmax,ymax,EPSG:3857"
$bboxitem = explode(',', $bbox);
if (count($bboxitem) !== 4) {
// BBOX parameter does not contain 4 elements
$bboxCount = count($bboxitem);
if ($bboxCount !== 4 && $bboxCount !== 5) {
// BBOX parameter does not contain 4 or 5 elements
return '';
}

// Check numeric elements
foreach ($bboxitem as $coord) {
if (!is_numeric(trim($coord))) {
// Check the 4 coordinate elements are numeric
for ($i = 0; $i < 4; ++$i) {
if (!is_numeric(trim($bboxitem[$i]))) {
return '';
}
}

$layerSrid = $this->qgisLayer->getSrid();
$srid = $this->qgisLayer->getSrid();
if (array_key_exists('srsname', $params)) {
$srid = $layerSrid;

// CRS priority: explicit SRSNAME param > 5th BBOX element
$srsname = null;
if (array_key_exists('srsname', $params) && !empty($params['srsname'])) {
$srsname = $params['srsname'];
if (!empty($srsname)) {
// SRSNAME parameter is not empty
// extracting srid
$exp_srsname = explode(':', $srsname);
$srsname_id = end($exp_srsname);
if (ctype_digit($srsname_id)) {
$srid = intval($srsname_id);
} else {
return '';
}
} elseif ($bboxCount === 5) {
$srsname = trim($bboxitem[4]);
}

if ($srsname !== null) {
$parsedSrid = $this->parseSrsnameSrid($srsname);
if ($parsedSrid !== null) {
$srid = $parsedSrid;
} else {
$this->appContext->logMessage(
'WFSRequest::getBboxSql: invalid SRSNAME "'.$srsname.'" — falling back to layer SRID',
'lizmapadmin'
);
}
}

Expand Down Expand Up @@ -1023,6 +1062,30 @@ protected function getfeaturePostgres(): OGCResponse
$geometryname = strtolower($params['geometryname']);
}

// Determine the output SRID: explicit SRSNAME param takes priority,
// then fall back to the CRS suffix of a 5-element WFS 1.1.0 BBOX.
$outputSrid = self::DEFAULT_OUTPUT_SRID;
$srsname = null;
if (array_key_exists('srsname', $params) && !empty($params['srsname'])) {
$srsname = $params['srsname'];
} elseif (array_key_exists('bbox', $params)) {
$bboxParts = explode(',', $params['bbox']);
if (count($bboxParts) === 5) {
$srsname = trim($bboxParts[4]);
}
}
if ($srsname !== null) {
$parsedSrid = $this->parseSrsnameSrid($srsname);
if ($parsedSrid !== null) {
$outputSrid = $parsedSrid;
} else {
$this->appContext->logMessage(
'WFSRequest::getfeaturePostgres: invalid SRSNAME "'.$srsname.'" — falling back to EPSG:'.self::DEFAULT_OUTPUT_SRID,
'lizmapadmin'
);
}
}

// For getFeature with parameter RESULTTYPE=hits
// We should just return the number of features
if ($isHitsRequest) {
Expand All @@ -1031,7 +1094,7 @@ protected function getfeaturePostgres(): OGCResponse
// Else we should return all corresponding features
// $this->appContext->logMessage($sql);
// Use PostgreSQL method to export geojson
$sql = $this->setGeojsonSql($sql, $cnx, $typename, $geometryname);
$sql = $this->setGeojsonSql($sql, $cnx, $typename, $geometryname, $outputSrid);
}

// $this->appContext->logMessage($sql);
Expand Down Expand Up @@ -1124,7 +1187,7 @@ protected function validateFilter(string $filter): false|string
* @param mixed $typename
* @param mixed $geometryname
*/
private function setGeojsonSql($sql, $cnx, $typename, $geometryname): string
protected function setGeojsonSql($sql, $cnx, $typename, $geometryname, int $outputSrid = 4326): string
{
$sql = '
WITH source AS (
Expand Down Expand Up @@ -1179,10 +1242,9 @@ private function setGeojsonSql($sql, $cnx, $typename, $geometryname): string
if (!empty($geosql)) {
// Define BBOX SQL
$bboxsql = 'ST_Envelope(lg.geosource::geometry)';
// For new QGIS versions, export into EPSG:4326
$lizservices = $this->services;
$geosql = 'ST_Transform('.$geosql.', 4326)';
$bboxsql = 'ST_Transform('.$bboxsql.', 4326)';
// Export geometry in the requested output CRS (defaults to EPSG:4326)
$geosql = 'ST_Transform('.$geosql.', '.$outputSrid.')';
$bboxsql = 'ST_Transform('.$bboxsql.', '.$outputSrid.')';

// Transform BBOX into JSON
$sql .= '
Expand Down
2 changes: 1 addition & 1 deletion tests/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ SHELL:=bash
.ONESHELL:
.PHONY: env

export LZMBRANCH ?= $(shell git rev-parse --abbrev-ref HEAD | tr '[:upper:]' '[:lower:]')
export LZMBRANCH ?= $(shell git rev-parse --abbrev-ref HEAD | tr '[:upper:]/' '[:lower:]-')

PREFIX:=lizmap-$(LZMBRANCH)-tests

Expand Down
6 changes: 4 additions & 2 deletions tests/end2end/playwright/pages/project.js
Original file line number Diff line number Diff line change
Expand Up @@ -344,13 +344,15 @@ export class ProjectPage extends BasePage {

/**
* Waits for a GetFeature request
* @param {undefined|string} featureType Optional TYPENAME to filter on
* @returns {Promise<Request>} The GetFeature request
*/
async waitForGetFeatureRequest() {
async waitForGetFeatureRequest(featureType = undefined) {
return this.page.waitForRequest(
request => request.method() === 'POST' &&
request.postData()?.includes('WFS') === true &&
request.postData()?.includes('GetFeature') === true
request.postData()?.includes('GetFeature') === true &&
(featureType === undefined || request.postData()?.includes(featureType) === true)
);
}

Expand Down
Loading
Loading