diff --git a/src/mapml/handlers/QueryHandler.js b/src/mapml/handlers/QueryHandler.js index c5d77a782..bac48c89b 100644 --- a/src/mapml/handlers/QueryHandler.js +++ b/src/mapml/handlers/QueryHandler.js @@ -9,9 +9,42 @@ import { import { MapFeatureLayer } from '../layers/MapFeatureLayer.js'; import { featureRenderer } from '../features/featureRenderer.js'; +// Determine if a GeoJSON object has projected (non-CRS:84) coordinates. +// Returns true if a "crs" member is present and non-null, or if coordinate +// values exceed CRS:84 bounds (lon [-180,180], lat [-90,90]), indicating +// meter-based projected units (e.g. from WMS GetFeatureInfo responses). +function _hasProjectedCoordinates(json) { + if (json.crs != null) return true; + let c = _firstCoordinate(json); + return c !== null && (Math.abs(c[0]) > 180 || Math.abs(c[1]) > 90); +} + +// Extract the first [x, y] coordinate pair from a GeoJSON object, +// drilling into FeatureCollection → Feature → Geometry → coordinates. +function _firstCoordinate(json) { + if (!json) return null; + let type = json.type && json.type.toUpperCase(); + if (type === 'FEATURECOLLECTION') { + if (json.features && json.features.length > 0) + return _firstCoordinate(json.features[0]); + } else if (type === 'FEATURE') { + return _firstCoordinate(json.geometry); + } else if (json.coordinates) { + // Unwrap nested arrays until we reach a [number, number] pair + let coords = json.coordinates; + while (Array.isArray(coords) && Array.isArray(coords[0])) { + coords = coords[0]; + } + if (coords.length >= 2 && typeof coords[0] === 'number') return coords; + } else if (type === 'GEOMETRYCOLLECTION' && json.geometries) { + if (json.geometries.length > 0) return _firstCoordinate(json.geometries[0]); + } + return null; +} + export var QueryHandler = Handler.extend({ addHooks: function () { - // get a reference to the actual element, so we can + // get a reference to the actual / element, so we can // use its layers property to iterate the layers from top down // evaluating if they are 'on the map' (enabled) setOptions(this, { mapEl: this._map.options.mapEl }); @@ -149,6 +182,64 @@ export var QueryHandler = Handler.extend({ ); if (queryMetas.length) features.forEach((f) => (f.meta = queryMetas)); + } else if ( + response.contenttype.startsWith('application/json') || + response.contenttype.startsWith('application/geo+json') + ) { + try { + let json = JSON.parse(response.text); + let mapmlLayer = M.geojson2mapml(json, { + projection: layer.options.projection + }); + // if crs member is present and non-null, or coordinate + // values exceed CRS:84 range, the response coordinates + // are in the layer's projected CRS, not CRS:84 + if (_hasProjectedCoordinates(json)) { + let csMeta = mapmlLayer.querySelector('map-meta[name=cs]'); + if (csMeta) csMeta.setAttribute('content', 'pcrs'); + } + features = Array.prototype.slice.call( + mapmlLayer.querySelectorAll('map-feature') + ); + queryMetas = Array.prototype.slice.call( + mapmlLayer.querySelectorAll( + 'map-meta[name=cs], map-meta[name=zoom], map-meta[name=projection]' + ) + ); + let geometrylessFeatures = features.filter( + (f) => !f.querySelector('map-geometry') + ); + if (geometrylessFeatures.length) { + let g = parser.parseFromString(geom, 'text/html'); + for (let f of geometrylessFeatures) { + f.appendChild( + g.querySelector('map-geometry').cloneNode(true) + ); + } + } + if (queryMetas.length) + features.forEach((f) => (f.meta = queryMetas)); + } catch (err) { + // not valid GeoJSON, fall through to HTML rendering + let html = parser.parseFromString(response.text, 'text/html'); + let featureDoc = parser.parseFromString( + '' + + '' + + geom + + '', + 'text/html' + ); + if (html.body) { + featureDoc + .querySelector('map-properties') + .appendChild(html.querySelector('html')); + } else { + featureDoc + .querySelector('map-properties') + .append(response.text); + } + features.push(featureDoc.querySelector('map-feature')); + } } else { try { let featureDocument = parser.parseFromString( diff --git a/test/e2e/data/geojson/geojsonProjectedNoCrs.json b/test/e2e/data/geojson/geojsonProjectedNoCrs.json new file mode 100644 index 000000000..81cea9614 --- /dev/null +++ b/test/e2e/data/geojson/geojsonProjectedNoCrs.json @@ -0,0 +1,16 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [1826324, -230839] + }, + "properties": { + "name": "Test Point projected no CRS", + "value": 99 + } + } + ] +} diff --git a/test/e2e/data/geojson/geojsonProjectedWithCrs.json b/test/e2e/data/geojson/geojsonProjectedWithCrs.json new file mode 100644 index 000000000..0c6861763 --- /dev/null +++ b/test/e2e/data/geojson/geojsonProjectedWithCrs.json @@ -0,0 +1,22 @@ +{ + "type": "FeatureCollection", + "crs": { + "type": "name", + "properties": { + "name": "urn:ogc:def:crs:EPSG::3978" + } + }, + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [1826324, -230839] + }, + "properties": { + "name": "Test Point with CRS", + "value": 42 + } + } + ] +} diff --git a/test/e2e/layers/queryGeoJSON.html b/test/e2e/layers/queryGeoJSON.html new file mode 100644 index 000000000..54c45b034 --- /dev/null +++ b/test/e2e/layers/queryGeoJSON.html @@ -0,0 +1,50 @@ + + + + + GeoJSON Query Response Test + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/e2e/layers/queryGeoJSON.test.js b/test/e2e/layers/queryGeoJSON.test.js new file mode 100644 index 000000000..83c92e64b --- /dev/null +++ b/test/e2e/layers/queryGeoJSON.test.js @@ -0,0 +1,92 @@ +import { test, expect, chromium } from '@playwright/test'; + +test.describe('GeoJSON Query Response', () => { + let page; + let context; + test.beforeAll(async function () { + context = await chromium.launchPersistentContext('', { + headless: true, + slowMo: 250 + }); + page = await context.newPage(); + await page.goto('queryGeoJSON.html'); + await page.waitForTimeout(1000); + }); + test.afterAll(async function () { + await context.close(); + }); + test('Query returns features from all three GeoJSON extents', async () => { + await page.click('mapml-viewer'); + const popupContainer = page.locator('.mapml-popup-content > iframe'); + await expect(popupContainer).toBeVisible(); + const popupFeatureCount = page.locator('.mapml-feature-count'); + await expect(popupFeatureCount).toHaveText('1/3', { useInnerText: true }); + }); + test('Standard CRS:84 GeoJSON feature has cs meta set to gcrs', async () => { + // The first feature comes from the CRS:84 extent (geojsonFeature) + // Its meta should have cs=gcrs since coordinates are standard lon/lat + let csMeta = await page.evaluate(() => { + let layer = + document.querySelector('mapml-viewer').layers[0]._layer; + let features = layer._mapmlFeatures; + // find the feature from CRS:84 response (the polygon from geojsonFeature) + let f = features.find( + (feat) => feat.querySelector('map-polygon') !== null + ); + if (f && f.meta) { + let cs = f.meta.find( + (m) => m.getAttribute('name') === 'cs' + ); + return cs ? cs.getAttribute('content') : null; + } + return null; + }); + expect(csMeta).toBe('gcrs'); + }); + test('GeoJSON with explicit crs member has cs meta set to pcrs', async () => { + // The feature from geojsonProjectedWithCrs has a "crs" member + // Its meta should have cs=pcrs + let csMeta = await page.evaluate(() => { + let layer = + document.querySelector('mapml-viewer').layers[0]._layer; + let features = layer._mapmlFeatures; + // find the feature with properties containing "Test Point with CRS" + let f = features.find((feat) => { + let props = feat.querySelector('map-properties'); + return props && props.innerHTML.includes('Test Point with CRS'); + }); + if (f && f.meta) { + let cs = f.meta.find( + (m) => m.getAttribute('name') === 'cs' + ); + return cs ? cs.getAttribute('content') : null; + } + return null; + }); + expect(csMeta).toBe('pcrs'); + }); + test('GeoJSON with projected coordinates but no crs member has cs meta set to pcrs via magnitude heuristic', async () => { + // The feature from geojsonProjectedNoCrs has large coordinate values + // but no "crs" member — the magnitude heuristic should detect this + let csMeta = await page.evaluate(() => { + let layer = + document.querySelector('mapml-viewer').layers[0]._layer; + let features = layer._mapmlFeatures; + // find the feature with properties containing "Test Point projected no CRS" + let f = features.find((feat) => { + let props = feat.querySelector('map-properties'); + return ( + props && props.innerHTML.includes('Test Point projected no CRS') + ); + }); + if (f && f.meta) { + let cs = f.meta.find( + (m) => m.getAttribute('name') === 'cs' + ); + return cs ? cs.getAttribute('content') : null; + } + return null; + }); + expect(csMeta).toBe('pcrs'); + }); +}); diff --git a/test/server.js b/test/server.js index ac88c9478..972b4ead6 100644 --- a/test/server.js +++ b/test/server.js @@ -137,6 +137,28 @@ app.get('/data/query/geojsonPoint', (req, res, next) => { } ); }); +app.get('/data/query/geojsonProjectedWithCrs', (req, res, next) => { + res.sendFile( + __dirname + '/e2e/data/geojson/geojsonProjectedWithCrs.json', + { headers: { 'Content-Type': 'application/json' } }, + (err) => { + if (err) { + res.status(403).send('Error.'); + } + } + ); +}); +app.get('/data/query/geojsonProjectedNoCrs', (req, res, next) => { + res.sendFile( + __dirname + '/e2e/data/geojson/geojsonProjectedNoCrs.json', + { headers: { 'Content-Type': 'application/json' } }, + (err) => { + if (err) { + res.status(403).send('Error.'); + } + } + ); +}); app.get('/data/query/geojsonFeature.geojson', (req, res, next) => { res.sendFile( __dirname + '/e2e/data/geojson/geojsonFeature.geojson',