Skip to content

Commit f75a5de

Browse files
committed
New feature: support application/json and
application/geo+json query responses Convert GeoJSON query responses to MapML using the built-in M.geojson2mapml() API. Adds a new content-type branch in QueryHandler.fetchFeatures() that handles application/json and application/geo+json responses, with automatic detection of projected vs geographic coordinates, since services may omit the "crs" member or return non-standard GeoJSON. Projected coordinate detection uses two signals: - Explicit "crs" member in the GeoJSON response (non-null) - Coordinate magnitude heuristic: values exceeding CRS:84 bounds (|lon| > 180 or |lat| > 90) indicate projected units When projected coordinates are detected, the cs meta is set to 'pcrs'; otherwise standard 'gcrs' (CRS:84) is used. Non-GeoJSON JSON responses fall through gracefully to HTML rendering via try/catch. Includes e2e tests covering three scenarios: standard CRS:84 GeoJSON, GeoJSON with explicit crs member, and projected coordinates without crs member (magnitude heuristic).
1 parent 18074ea commit f75a5de

File tree

6 files changed

+294
-1
lines changed

6 files changed

+294
-1
lines changed

src/mapml/handlers/QueryHandler.js

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,42 @@ import {
99
import { MapFeatureLayer } from '../layers/MapFeatureLayer.js';
1010
import { featureRenderer } from '../features/featureRenderer.js';
1111

12+
// Determine if a GeoJSON object has projected (non-CRS:84) coordinates.
13+
// Returns true if a "crs" member is present and non-null, or if coordinate
14+
// values exceed CRS:84 bounds (lon [-180,180], lat [-90,90]), indicating
15+
// meter-based projected units (e.g. from WMS GetFeatureInfo responses).
16+
function _hasProjectedCoordinates(json) {
17+
if (json.crs != null) return true;
18+
let c = _firstCoordinate(json);
19+
return c !== null && (Math.abs(c[0]) > 180 || Math.abs(c[1]) > 90);
20+
}
21+
22+
// Extract the first [x, y] coordinate pair from a GeoJSON object,
23+
// drilling into FeatureCollection → Feature → Geometry → coordinates.
24+
function _firstCoordinate(json) {
25+
if (!json) return null;
26+
let type = json.type && json.type.toUpperCase();
27+
if (type === 'FEATURECOLLECTION') {
28+
if (json.features && json.features.length > 0)
29+
return _firstCoordinate(json.features[0]);
30+
} else if (type === 'FEATURE') {
31+
return _firstCoordinate(json.geometry);
32+
} else if (json.coordinates) {
33+
// Unwrap nested arrays until we reach a [number, number] pair
34+
let coords = json.coordinates;
35+
while (Array.isArray(coords) && Array.isArray(coords[0])) {
36+
coords = coords[0];
37+
}
38+
if (coords.length >= 2 && typeof coords[0] === 'number') return coords;
39+
} else if (type === 'GEOMETRYCOLLECTION' && json.geometries) {
40+
if (json.geometries.length > 0) return _firstCoordinate(json.geometries[0]);
41+
}
42+
return null;
43+
}
44+
1245
export var QueryHandler = Handler.extend({
1346
addHooks: function () {
14-
// get a reference to the actual <map> element, so we can
47+
// get a reference to the actual <map>/<mapml-viewer> element, so we can
1548
// use its layers property to iterate the layers from top down
1649
// evaluating if they are 'on the map' (enabled)
1750
setOptions(this, { mapEl: this._map.options.mapEl });
@@ -149,6 +182,64 @@ export var QueryHandler = Handler.extend({
149182
);
150183
if (queryMetas.length)
151184
features.forEach((f) => (f.meta = queryMetas));
185+
} else if (
186+
response.contenttype.startsWith('application/json') ||
187+
response.contenttype.startsWith('application/geo+json')
188+
) {
189+
try {
190+
let json = JSON.parse(response.text);
191+
let mapmlLayer = M.geojson2mapml(json, {
192+
projection: layer.options.projection
193+
});
194+
// if crs member is present and non-null, or coordinate
195+
// values exceed CRS:84 range, the response coordinates
196+
// are in the layer's projected CRS, not CRS:84
197+
if (_hasProjectedCoordinates(json)) {
198+
let csMeta = mapmlLayer.querySelector('map-meta[name=cs]');
199+
if (csMeta) csMeta.setAttribute('content', 'pcrs');
200+
}
201+
features = Array.prototype.slice.call(
202+
mapmlLayer.querySelectorAll('map-feature')
203+
);
204+
queryMetas = Array.prototype.slice.call(
205+
mapmlLayer.querySelectorAll(
206+
'map-meta[name=cs], map-meta[name=zoom], map-meta[name=projection]'
207+
)
208+
);
209+
let geometrylessFeatures = features.filter(
210+
(f) => !f.querySelector('map-geometry')
211+
);
212+
if (geometrylessFeatures.length) {
213+
let g = parser.parseFromString(geom, 'text/html');
214+
for (let f of geometrylessFeatures) {
215+
f.appendChild(
216+
g.querySelector('map-geometry').cloneNode(true)
217+
);
218+
}
219+
}
220+
if (queryMetas.length)
221+
features.forEach((f) => (f.meta = queryMetas));
222+
} catch (err) {
223+
// not valid GeoJSON, fall through to HTML rendering
224+
let html = parser.parseFromString(response.text, 'text/html');
225+
let featureDoc = parser.parseFromString(
226+
'<map-feature><map-properties>' +
227+
'</map-properties>' +
228+
geom +
229+
'</map-feature>',
230+
'text/html'
231+
);
232+
if (html.body) {
233+
featureDoc
234+
.querySelector('map-properties')
235+
.appendChild(html.querySelector('html'));
236+
} else {
237+
featureDoc
238+
.querySelector('map-properties')
239+
.append(response.text);
240+
}
241+
features.push(featureDoc.querySelector('map-feature'));
242+
}
152243
} else {
153244
try {
154245
let featureDocument = parser.parseFromString(
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"type": "FeatureCollection",
3+
"features": [
4+
{
5+
"type": "Feature",
6+
"geometry": {
7+
"type": "Point",
8+
"coordinates": [1826324, -230839]
9+
},
10+
"properties": {
11+
"name": "Test Point projected no CRS",
12+
"value": 99
13+
}
14+
}
15+
]
16+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"type": "FeatureCollection",
3+
"crs": {
4+
"type": "name",
5+
"properties": {
6+
"name": "urn:ogc:def:crs:EPSG::3978"
7+
}
8+
},
9+
"features": [
10+
{
11+
"type": "Feature",
12+
"geometry": {
13+
"type": "Point",
14+
"coordinates": [1826324, -230839]
15+
},
16+
"properties": {
17+
"name": "Test Point with CRS",
18+
"value": 42
19+
}
20+
}
21+
]
22+
}

test/e2e/layers/queryGeoJSON.html

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
4+
<head>
5+
<title>GeoJSON Query Response Test</title>
6+
<meta charset="UTF-8">
7+
<script type="module" src="mapml.js"></script>
8+
</head>
9+
10+
<body>
11+
<mapml-viewer style="width: 500px;height: 500px;" projection="OSMTILE" zoom="10" lat="45.4" lon="-75.7" controls>
12+
<map-layer label="GeoJSON Query Layer" checked>
13+
<map-extent label="GeoJSON CRS:84 query" units="OSMTILE" checked>
14+
<map-input name="i" type="location" units="map" axis="i"></map-input>
15+
<map-input name="j" type="location" units="map" axis="j"></map-input>
16+
<map-input name="xmin" type="location" units="gcrs" axis="longitude" position="top-left" min="-180" max="180"></map-input>
17+
<map-input name="ymin" type="location" units="gcrs" axis="latitude" position="bottom-right" min="-90" max="90"></map-input>
18+
<map-input name="xmax" type="location" units="gcrs" axis="longitude" position="bottom-right" min="-180" max="180"></map-input>
19+
<map-input name="ymax" type="location" units="gcrs" axis="latitude" position="top-left" min="-90" max="90"></map-input>
20+
<map-input name="w" type="width"></map-input>
21+
<map-input name="h" type="height"></map-input>
22+
<map-link rel="query" tref="data/query/geojsonFeature?{i}{j}{xmin}{ymin}{xmax}{ymax}{w}{h}"></map-link>
23+
</map-extent>
24+
<map-extent label="GeoJSON with crs member" units="OSMTILE" checked>
25+
<map-input name="i" type="location" units="map" axis="i"></map-input>
26+
<map-input name="j" type="location" units="map" axis="j"></map-input>
27+
<map-input name="xmin" type="location" units="gcrs" axis="longitude" position="top-left" min="-180" max="180"></map-input>
28+
<map-input name="ymin" type="location" units="gcrs" axis="latitude" position="bottom-right" min="-90" max="90"></map-input>
29+
<map-input name="xmax" type="location" units="gcrs" axis="longitude" position="bottom-right" min="-180" max="180"></map-input>
30+
<map-input name="ymax" type="location" units="gcrs" axis="latitude" position="top-left" min="-90" max="90"></map-input>
31+
<map-input name="w" type="width"></map-input>
32+
<map-input name="h" type="height"></map-input>
33+
<map-link rel="query" tref="data/query/geojsonProjectedWithCrs?{i}{j}{xmin}{ymin}{xmax}{ymax}{w}{h}"></map-link>
34+
</map-extent>
35+
<map-extent label="GeoJSON projected no crs" units="OSMTILE" checked>
36+
<map-input name="i" type="location" units="map" axis="i"></map-input>
37+
<map-input name="j" type="location" units="map" axis="j"></map-input>
38+
<map-input name="xmin" type="location" units="gcrs" axis="longitude" position="top-left" min="-180" max="180"></map-input>
39+
<map-input name="ymin" type="location" units="gcrs" axis="latitude" position="bottom-right" min="-90" max="90"></map-input>
40+
<map-input name="xmax" type="location" units="gcrs" axis="longitude" position="bottom-right" min="-180" max="180"></map-input>
41+
<map-input name="ymax" type="location" units="gcrs" axis="latitude" position="top-left" min="-90" max="90"></map-input>
42+
<map-input name="w" type="width"></map-input>
43+
<map-input name="h" type="height"></map-input>
44+
<map-link rel="query" tref="data/query/geojsonProjectedNoCrs?{i}{j}{xmin}{ymin}{xmax}{ymax}{w}{h}"></map-link>
45+
</map-extent>
46+
</map-layer>
47+
</mapml-viewer>
48+
</body>
49+
50+
</html>
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { test, expect, chromium } from '@playwright/test';
2+
3+
test.describe('GeoJSON Query Response', () => {
4+
let page;
5+
let context;
6+
test.beforeAll(async function () {
7+
context = await chromium.launchPersistentContext('', {
8+
headless: true,
9+
slowMo: 250
10+
});
11+
page = await context.newPage();
12+
await page.goto('queryGeoJSON.html');
13+
await page.waitForTimeout(1000);
14+
});
15+
test.afterAll(async function () {
16+
await context.close();
17+
});
18+
test('Query returns features from all three GeoJSON extents', async () => {
19+
await page.click('mapml-viewer');
20+
const popupContainer = page.locator('.mapml-popup-content > iframe');
21+
await expect(popupContainer).toBeVisible();
22+
const popupFeatureCount = page.locator('.mapml-feature-count');
23+
await expect(popupFeatureCount).toHaveText('1/3', { useInnerText: true });
24+
});
25+
test('Standard CRS:84 GeoJSON feature has cs meta set to gcrs', async () => {
26+
// The first feature comes from the CRS:84 extent (geojsonFeature)
27+
// Its meta should have cs=gcrs since coordinates are standard lon/lat
28+
let csMeta = await page.evaluate(() => {
29+
let layer =
30+
document.querySelector('mapml-viewer').layers[0]._layer;
31+
let features = layer._mapmlFeatures;
32+
// find the feature from CRS:84 response (the polygon from geojsonFeature)
33+
let f = features.find(
34+
(feat) => feat.querySelector('map-polygon') !== null
35+
);
36+
if (f && f.meta) {
37+
let cs = f.meta.find(
38+
(m) => m.getAttribute('name') === 'cs'
39+
);
40+
return cs ? cs.getAttribute('content') : null;
41+
}
42+
return null;
43+
});
44+
expect(csMeta).toBe('gcrs');
45+
});
46+
test('GeoJSON with explicit crs member has cs meta set to pcrs', async () => {
47+
// The feature from geojsonProjectedWithCrs has a "crs" member
48+
// Its meta should have cs=pcrs
49+
let csMeta = await page.evaluate(() => {
50+
let layer =
51+
document.querySelector('mapml-viewer').layers[0]._layer;
52+
let features = layer._mapmlFeatures;
53+
// find the feature with properties containing "Test Point with CRS"
54+
let f = features.find((feat) => {
55+
let props = feat.querySelector('map-properties');
56+
return props && props.innerHTML.includes('Test Point with CRS');
57+
});
58+
if (f && f.meta) {
59+
let cs = f.meta.find(
60+
(m) => m.getAttribute('name') === 'cs'
61+
);
62+
return cs ? cs.getAttribute('content') : null;
63+
}
64+
return null;
65+
});
66+
expect(csMeta).toBe('pcrs');
67+
});
68+
test('GeoJSON with projected coordinates but no crs member has cs meta set to pcrs via magnitude heuristic', async () => {
69+
// The feature from geojsonProjectedNoCrs has large coordinate values
70+
// but no "crs" member — the magnitude heuristic should detect this
71+
let csMeta = await page.evaluate(() => {
72+
let layer =
73+
document.querySelector('mapml-viewer').layers[0]._layer;
74+
let features = layer._mapmlFeatures;
75+
// find the feature with properties containing "Test Point projected no CRS"
76+
let f = features.find((feat) => {
77+
let props = feat.querySelector('map-properties');
78+
return (
79+
props && props.innerHTML.includes('Test Point projected no CRS')
80+
);
81+
});
82+
if (f && f.meta) {
83+
let cs = f.meta.find(
84+
(m) => m.getAttribute('name') === 'cs'
85+
);
86+
return cs ? cs.getAttribute('content') : null;
87+
}
88+
return null;
89+
});
90+
expect(csMeta).toBe('pcrs');
91+
});
92+
});

test/server.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,28 @@ app.get('/data/query/geojsonPoint', (req, res, next) => {
137137
}
138138
);
139139
});
140+
app.get('/data/query/geojsonProjectedWithCrs', (req, res, next) => {
141+
res.sendFile(
142+
__dirname + '/e2e/data/geojson/geojsonProjectedWithCrs.json',
143+
{ headers: { 'Content-Type': 'application/json' } },
144+
(err) => {
145+
if (err) {
146+
res.status(403).send('Error.');
147+
}
148+
}
149+
);
150+
});
151+
app.get('/data/query/geojsonProjectedNoCrs', (req, res, next) => {
152+
res.sendFile(
153+
__dirname + '/e2e/data/geojson/geojsonProjectedNoCrs.json',
154+
{ headers: { 'Content-Type': 'application/json' } },
155+
(err) => {
156+
if (err) {
157+
res.status(403).send('Error.');
158+
}
159+
}
160+
);
161+
});
140162
app.get('/data/query/geojsonFeature.geojson', (req, res, next) => {
141163
res.sendFile(
142164
__dirname + '/e2e/data/geojson/geojsonFeature.geojson',

0 commit comments

Comments
 (0)