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
49 changes: 41 additions & 8 deletions packages/mapviewer/src/api/layers/WMSCapabilitiesParser.class.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,35 @@ import LayerTimeConfig from '@/api/layers/LayerTimeConfig.class'
import LayerTimeConfigEntry from '@/api/layers/LayerTimeConfigEntry.class'
import { WMS_SUPPORTED_VERSIONS } from '@/config/map.config'

/**
* Reproject a two-point extent, guarding against non-finite results.
*
* Proj4 silently returns NaN/Infinity when asked to reproject coordinates that are outside the
* valid domain of the target projection (e.g. latitude +/-90 into Web Mercator, which is undefined
* at the poles). Some WMS servers advertise EX_GeographicBoundingBox / BoundingBox values covering
* the whole globe (-180,-90,180,90), which triggers this. Without this guard, the resulting extent
* silently corrupts the map view (NaN center/zoom).
*
* @param {String} fromEpsg Source projection EPSG code
* @param {String} toEpsg Target projection EPSG code
* @param {[Number, Number]} lowerLeft Lower left corner of the extent, in the source projection
* @param {[Number, Number]} upperRight Upper right corner of the extent, in the source projection
* @returns {[[Number, Number], [Number, Number]] | null} The reprojected extent, or null if the
* reprojection produced a non-finite result
*/
function reprojectExtent(fromEpsg, toEpsg, lowerLeft, upperRight) {
const extent = [proj4(fromEpsg, toEpsg, lowerLeft), proj4(fromEpsg, toEpsg, upperRight)]
if (extent.flat().some((value) => !Number.isFinite(value))) {
log.error(
`Failed to reproject extent from ${fromEpsg} to ${toEpsg}, non-finite result`,
lowerLeft,
upperRight
)
return null
}
return extent
}

function findLayer(layerId, startFrom, parents) {
let found = {}
const layers = startFrom
Expand Down Expand Up @@ -376,20 +405,24 @@ export default class WMSCapabilitiesParser {
if (bbox.crs === WGS84.epsg && projection.epsg === WEBMERCATOR.epsg) {
extent = WGS84.getExtentInOrderXY(extent)
}
layerExtent = [
proj4(bbox.crs, projection.epsg, [extent[0], extent[1]]),
proj4(bbox.crs, projection.epsg, [extent[2], extent[3]]),
]
layerExtent = reprojectExtent(
bbox.crs,
projection.epsg,
[extent[0], extent[1]],
[extent[2], extent[3]]
)
}
}
// Fallback to the EX_GeographicBoundingBox
if (!layerExtent && layer.EX_GeographicBoundingBox) {
const bbox = layer.EX_GeographicBoundingBox
if (projection !== WGS84) {
layerExtent = [
proj4(WGS84.epsg, projection.epsg, [bbox[0], bbox[1]]),
proj4(WGS84.epsg, projection.epsg, [bbox[2], bbox[3]]),
]
layerExtent = reprojectExtent(
WGS84.epsg,
projection.epsg,
[bbox[0], bbox[1]],
[bbox[2], bbox[3]]
)
} else {
layerExtent = [
[bbox[0], bbox[1]],
Expand Down
39 changes: 35 additions & 4 deletions packages/mapviewer/src/api/layers/WMTSCapabilitiesParser.class.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,35 @@ import { CapabilitiesError } from '@/api/layers/layers-external.api'
import LayerTimeConfig from '@/api/layers/LayerTimeConfig.class'
import LayerTimeConfigEntry from '@/api/layers/LayerTimeConfigEntry.class'

/**
* Reproject a two-point extent, guarding against non-finite results.
*
* Proj4 silently returns NaN/Infinity when asked to reproject coordinates that are outside the
* valid domain of the target projection (e.g. latitude +/-90 into Web Mercator, which is undefined
* at the poles). Some WMTS servers advertise a WGS84BoundingBox / BoundingBox covering the whole
* globe (-180,-90,180,90), which triggers this. Without this guard, the resulting extent silently
* corrupts the map view (NaN center/zoom).
*
* @param {String} fromEpsg Source projection EPSG code
* @param {String} toEpsg Target projection EPSG code
* @param {[Number, Number]} lowerLeft Lower left corner of the extent, in the source projection
* @param {[Number, Number]} upperRight Upper right corner of the extent, in the source projection
* @returns {[[Number, Number], [Number, Number]] | null} The reprojected extent, or null if the
* reprojection produced a non-finite result
*/
function reprojectExtent(fromEpsg, toEpsg, lowerLeft, upperRight) {
const extent = [proj4(fromEpsg, toEpsg, lowerLeft), proj4(fromEpsg, toEpsg, upperRight)]
if (extent.flat().some((value) => !Number.isFinite(value))) {
log.error(
`Failed to reproject extent from ${fromEpsg} to ${toEpsg}, non-finite result`,
lowerLeft,
upperRight
)
return null
}
return extent
}

function parseCrs(crs) {
let epsgNumber = crs?.split(':').pop()
if (/84/.test(epsgNumber)) {
Expand Down Expand Up @@ -319,10 +348,12 @@ export default class WMTSCapabilitiesParser {
}
// Convert the extent if needed
if (layerExtent && extentEpsg && projection.epsg !== extentEpsg) {
layerExtent = [
proj4(extentEpsg, projection.epsg, layerExtent[0]),
proj4(extentEpsg, projection.epsg, layerExtent[1]),
]
layerExtent = reprojectExtent(
extentEpsg,
projection.epsg,
layerExtent[0],
layerExtent[1]
)
}
if (!layerExtent) {
const msg = `No layer extent found for ${layerId}`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,37 @@ describe('WMSCapabilitiesParser - layer extent', () => {
expect(layer.extent[1][0]).toBeCloseTo(expected[1][0], 1)
expect(layer.extent[1][1]).toBeCloseTo(expected[1][1], 1)
})
it('Returns a null extent instead of NaN when a global EX_GeographicBoundingBox is reprojected to Web Mercator', () => {
// A EX_GeographicBoundingBox of -180,-90,180,90 is a common, spec-legal way for WMS
// servers to advertise a layer covering the whole globe. Reprojecting latitude +/-90 to
// Web Mercator is mathematically undefined and must not silently yield NaN coordinates.
const content = `<?xml version='1.0' encoding="UTF-8" standalone="no"?>
<WMS_Capabilities version="1.3.0">
<Capability>
<Layer>
<Title>WMS BGDI</Title>
<Layer queryable="1" opaque="0" cascaded="1">
<Name>ch.swisstopo-vd.official-survey</Name>
<Title>OpenData-AV</Title>
<EX_GeographicBoundingBox>
<westBoundLongitude>-180</westBoundLongitude>
<eastBoundLongitude>180</eastBoundLongitude>
<southBoundLatitude>-90</southBoundLatitude>
<northBoundLatitude>90</northBoundLatitude>
</EX_GeographicBoundingBox>
</Layer>
</Layer>
</Capability>
</WMS_Capabilities>
`
const capabilities = new WMSCapabilitiesParser(content, 'https://wms.geo.admin.ch')
const layer = capabilities.getExternalLayerObject(
'ch.swisstopo-vd.official-survey',
WEBMERCATOR
)
expect(layer.id).toBe('ch.swisstopo-vd.official-survey')
expect(layer.extent).toBeNull()
})
})

describe('EX_GeographicBoundingBox - Group of layers', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -291,3 +291,57 @@ describe('WMTSCapabilitiesParser of wmts-ogc-sample.xml', () => {
expect(layer.timeConfig.currentTimestamp).toBe('Time A')
})
})

describe('WMTSCapabilitiesParser - layer extent edge cases', () => {
it('Returns a null extent instead of NaN when a global WGS84BoundingBox is reprojected to Web Mercator', () => {
// A WGS84BoundingBox of -180,-90,180,90 is a common, spec-legal way for WMTS servers to
// advertise a layer covering the whole globe. Reprojecting latitude +/-90 to Web Mercator
// is mathematically undefined and must not silently yield NaN coordinates.
const content = `<?xml version="1.0" encoding="UTF-8"?>
<Capabilities xmlns="http://www.opengis.net/wmts/1.0"
xmlns:ows="http://www.opengis.net/ows/1.1"
xmlns:xlink="http://www.w3.org/1999/xlink"
version="1.0.0">
<Contents>
<Layer>
<ows:Title>Global Layer</ows:Title>
<ows:Identifier>GlobalLayer</ows:Identifier>
<ows:WGS84BoundingBox>
<ows:LowerCorner>-180 -90</ows:LowerCorner>
<ows:UpperCorner>180 90</ows:UpperCorner>
</ows:WGS84BoundingBox>
<Style isDefault="true">
<ows:Identifier>default</ows:Identifier>
</Style>
<Format>image/png</Format>
<TileMatrixSetLink>
<TileMatrixSet>google3857</TileMatrixSet>
</TileMatrixSetLink>
<ResourceURL
format="image/png"
resourceType="tile"
template="http://www.example.com/wmts/global/{TileMatrix}/{TileRow}/{TileCol}.png"
/>
</Layer>
<TileMatrixSet>
<ows:Identifier>google3857</ows:Identifier>
<ows:SupportedCRS>urn:ogc:def:crs:EPSG::3857</ows:SupportedCRS>
<TileMatrix>
<ows:Identifier>0</ows:Identifier>
<ScaleDenominator>559082264.0287178</ScaleDenominator>
<TopLeftCorner>-20037508.3428 20037508.3428</TopLeftCorner>
<TileWidth>256</TileWidth>
<TileHeight>256</TileHeight>
<MatrixWidth>1</MatrixWidth>
<MatrixHeight>1</MatrixHeight>
</TileMatrix>
</TileMatrixSet>
</Contents>
</Capabilities>
`
const capabilities = new WMTSCapabilitiesParser(content, 'https://example.com')
const layer = capabilities.getExternalLayerObject('GlobalLayer', WEBMERCATOR)
expect(layer.id).toBe('GlobalLayer')
expect(layer.extent).toBeNull()
})
})
18 changes: 14 additions & 4 deletions packages/mapviewer/src/store/modules/position.store.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export function normalizeAngle(rotation) {
* @returns {Array} - The reprojected extent.
*/
function reprojectExtent(extent, sourceProjection, targetProjection) {
return extent.map((point) => proj4(sourceProjection, targetProjection, point));
return extent.map((point) => proj4(sourceProjection, targetProjection, point))
}

/**
Expand Down Expand Up @@ -269,11 +269,14 @@ const actions = {
}
}
},
zoomToExtent: ({ commit, state, rootState }, { extent, extentProjection, maxZoom, dispatcher }) => {
zoomToExtent: (
{ commit, state, rootState },
{ extent, extentProjection, maxZoom, dispatcher }
) => {
// If the extentProjection is not defined, we assume the extent is in the current projection
// and we don't need to reproject it.
if (extentProjection?.epsg && extentProjection.epsg !== state.projection.epsg) {
extent = reprojectExtent(extent, extentProjection.epsg, state.projection.epsg);
extent = reprojectExtent(extent, extentProjection.epsg, state.projection.epsg)
}
const normalizedExtent = extent ? normalizeExtent(extent) : null
if (normalizedExtent && Array.isArray(normalizedExtent) && normalizedExtent.length === 2) {
Expand All @@ -289,12 +292,19 @@ const actions = {
(points[0][1] + points[1][1]) / 2.0, // minY + maxY / 2
])

if (centerOfExtent && Array.isArray(centerOfExtent) && centerOfExtent.length === 2) {
if (
centerOfExtent &&
Array.isArray(centerOfExtent) &&
centerOfExtent.length === 2 &&
centerOfExtent.every(Number.isFinite)
) {
commit('setCenter', {
x: centerOfExtent[0],
y: centerOfExtent[1],
dispatcher: `${dispatcher}/zoomToExtent`,
})
} else {
log.error('zoomToExtent: computed center of extent is not finite, ignoring', extent)
}
const extentSize = {
width: normalizedExtent[1][0] - normalizedExtent[0][0],
Expand Down