Skip to content
Merged
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
93 changes: 92 additions & 1 deletion src/mapml/handlers/QueryHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <map> element, so we can
// get a reference to the actual <map>/<mapml-viewer> 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 });
Expand Down Expand Up @@ -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(
'<map-feature><map-properties>' +
'</map-properties>' +
geom +
'</map-feature>',
'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(
Expand Down
16 changes: 16 additions & 0 deletions test/e2e/data/geojson/geojsonProjectedNoCrs.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [1826324, -230839]
},
"properties": {
"name": "Test Point projected no CRS",
"value": 99
}
}
]
}
22 changes: 22 additions & 0 deletions test/e2e/data/geojson/geojsonProjectedWithCrs.json
Original file line number Diff line number Diff line change
@@ -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
}
}
]
}
50 changes: 50 additions & 0 deletions test/e2e/layers/queryGeoJSON.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="en">

<head>
<title>GeoJSON Query Response Test</title>
<meta charset="UTF-8">
<script type="module" src="mapml.js"></script>
</head>

<body>
<mapml-viewer style="width: 500px;height: 500px;" projection="OSMTILE" zoom="10" lat="45.4" lon="-75.7" controls>
<map-layer label="GeoJSON Query Layer" checked>
<map-extent label="GeoJSON CRS:84 query" units="OSMTILE" checked>
<map-input name="i" type="location" units="map" axis="i"></map-input>
<map-input name="j" type="location" units="map" axis="j"></map-input>
<map-input name="xmin" type="location" units="gcrs" axis="longitude" position="top-left" min="-180" max="180"></map-input>
<map-input name="ymin" type="location" units="gcrs" axis="latitude" position="bottom-right" min="-90" max="90"></map-input>
<map-input name="xmax" type="location" units="gcrs" axis="longitude" position="bottom-right" min="-180" max="180"></map-input>
<map-input name="ymax" type="location" units="gcrs" axis="latitude" position="top-left" min="-90" max="90"></map-input>
<map-input name="w" type="width"></map-input>
<map-input name="h" type="height"></map-input>
<map-link rel="query" tref="data/query/geojsonFeature?{i}{j}{xmin}{ymin}{xmax}{ymax}{w}{h}"></map-link>
</map-extent>
<map-extent label="GeoJSON with crs member" units="OSMTILE" checked>
<map-input name="i" type="location" units="map" axis="i"></map-input>
<map-input name="j" type="location" units="map" axis="j"></map-input>
<map-input name="xmin" type="location" units="gcrs" axis="longitude" position="top-left" min="-180" max="180"></map-input>
<map-input name="ymin" type="location" units="gcrs" axis="latitude" position="bottom-right" min="-90" max="90"></map-input>
<map-input name="xmax" type="location" units="gcrs" axis="longitude" position="bottom-right" min="-180" max="180"></map-input>
<map-input name="ymax" type="location" units="gcrs" axis="latitude" position="top-left" min="-90" max="90"></map-input>
<map-input name="w" type="width"></map-input>
<map-input name="h" type="height"></map-input>
<map-link rel="query" tref="data/query/geojsonProjectedWithCrs?{i}{j}{xmin}{ymin}{xmax}{ymax}{w}{h}"></map-link>
</map-extent>
<map-extent label="GeoJSON projected no crs" units="OSMTILE" checked>
<map-input name="i" type="location" units="map" axis="i"></map-input>
<map-input name="j" type="location" units="map" axis="j"></map-input>
<map-input name="xmin" type="location" units="gcrs" axis="longitude" position="top-left" min="-180" max="180"></map-input>
<map-input name="ymin" type="location" units="gcrs" axis="latitude" position="bottom-right" min="-90" max="90"></map-input>
<map-input name="xmax" type="location" units="gcrs" axis="longitude" position="bottom-right" min="-180" max="180"></map-input>
<map-input name="ymax" type="location" units="gcrs" axis="latitude" position="top-left" min="-90" max="90"></map-input>
<map-input name="w" type="width"></map-input>
<map-input name="h" type="height"></map-input>
<map-link rel="query" tref="data/query/geojsonProjectedNoCrs?{i}{j}{xmin}{ymin}{xmax}{ymax}{w}{h}"></map-link>
</map-extent>
</map-layer>
</mapml-viewer>
</body>

</html>
92 changes: 92 additions & 0 deletions test/e2e/layers/queryGeoJSON.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
22 changes: 22 additions & 0 deletions test/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading