OrdnanceSurvey / os-data-hub-tutorials

Step-by-step guides to get your project up and running. Here you'll find out how we develop solutions with our APIs. You can follow our step-by-step guides to start building your own innovative projects. These tutorials are related to the Mapping and Data APIs available from our Data Hub (https://osdatahub.os.uk/).
Other
23 stars 7 forks source link

Problem with rendering OS Maps Leisure Layers #28

Closed nbarrett closed 7 months ago

nbarrett commented 7 months ago

Background to Problem I'm developing a typescript react web application using the react-leaflet library. A more detailed tooling list is documented on the readme of my github project. I've already got a deployed version up and running that you can take a look at.

I initially built the app to use the OpenStreetMaps mapping provider as this was easier to get the auth setup, but the target application really needs to use the Leisure_27700 Layer of OS Maps API. So for diagnostics, comparison and testing, I can now switch between different mapping providers in the UI.

image

Everything's working quite well until I switch to the OS Maps Leisure_27700 Layer, at which point the maps don't render. Under the covers, react-leaflet is issuing a series of get requests to the API e.g. https://api.os.uk/maps/raster/v1/wmts?key=mykeyremoved&service=WMTS&request=GetTile&version=1.0.0&style=default&layer=Leisure_027700&tileMatrixSet=EPSG:27700&tileMatrix=18&tileRow=87172&tileCol=131005.

However on the specified 27700 Layer selections, I get HTTP 400 response from the OS Maps API with payload responses in the browser:

<ExceptionReport xmlns="http://www.opengis.net/ows/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="1.1.0" xsi:schemaLocation="http://www.opengis.net/ows/1.1 http://schemas.opengis.net/ows/1.1.0/owsExceptionReport.xsd">
<Exception exceptionCode="RaiseFault">
<ExceptionText>Raising fault. Fault name : Bad-Request-Error</ExceptionText>
</Exception>
</ExceptionReport>

When I choose other Layers e.g. Light_3857 the application works okay. I've got an OSMaps developer premium account and the api key works in other OS-Data-Hub demo applications for instance: https://github.com/OrdnanceSurvey/OS-Data-Hub-API-Demos/tree/master/OSMapsAPI/Leaflet/Proj4Leaflet, so it does not seem to be a problem with me trying to access data I'm not entitled to.

The OS-Data-Hub-API-Demos demos do not use react-leaflet but use lower-level leaflet javascript for example here, so it's hard to see why these apps work but mine doesn't. Note that all parameters are passed by react-leaflet to the API using query string parameters and there is no http header setting. Could you let me know what I might be doing wrong please?

Steps to reproduce Please see my production deployment where you can choose between the different layers and see the behaviour outlined in this issue.

image

You can go into developer tools in Chrome and see the urls being attempted.

Thanks in advance for your help!

tmnnrs commented 7 months ago

Hi @nbarrett. I haven't done much in the the way of development with react-leaflet... however (looking at the problematic tile request) it would appear that the issue relates to the following:

  1. The layer name should be Leisure_27700 not Leisure_027700.
  2. The tileMatrix + tileRow + tileColumn values aren't valid. If you take a look at the EPSG:27700 Tile Matrix Set section in the OS Maps API: Technical specification you will see that zoom levels go from 0-13. Unlike the EPSG:3857 Tile Matrix Set (which is a global projection) the EPSG:27700 version is defined by a false origin + resolutions to cover the only the extent of onshore GB.

As you have suggested there shouldn't be any reason why a react-leaflet app cannot utilise the Proj4JS and proj4Leaflet libraries. I therefore think the problem might be coming down to the map not being destroyed; and reinitiated with the appropriate options (i.e. a custom CRS) when you switch to the EPSG:27700 base mapping.

nbarrett commented 7 months ago

Thanks @tmnnrs for your response. There were a few typos in my Layer data and I was getting the layer name slightly wrong as in the OS Maps API Demo, it's encoded into Leisure%2027700 which does work. I do have some zoom constraints in my app that ensure that the UI zoom levels are kept within limits e.g.

        tileMatrixSet: "EPSG:27700",
        minZoom: 0,
        maxZoom: 13,

        tileMatrixSet: "EPSG:3857",
        minZoom: 7,
        maxZoom: 20,

In order to better understand the behaviour differences, I've done a like-for-like comparison of querystring parameters between the working OS Maps API Demo and my from My App via react-leaflet and it seems that the thing that's wrong is the setting of the tileCol, tileMatrix and tileRow which are all wildly out.

From OS Maps API Demo

https://api.os.uk/maps/raster/v1/wmts? key=(mykey) layer=Leisure%2027700 request=GetTile service=WMTS style=default tileCol=213 tileMatrix=6 tileMatrixSet=EPSG:27700 tileRow=333 version=1.0.0

From My App via react-leaflet

https://api.os.uk/maps/raster/v1/wmts?key= key=(mykey) layer=Leisure%2027700& request=GetTile service=WMTS style=default tileCol=32749 tileMatrix=16 tileMatrixSet=EPSG:27700& tileRow=21792 version=1.0.0

The thing that confuses me is that with react-leaflet you supply a url with placeholders on the end e.g. &tileMatrix={z}&tileRow={y}&tileCol={x} and based on the zoom you set in the UI, it's (presumably) supposed to calculate the parameters for you. But I don't know how I control this. Do you have any idea how the calculations should be done if I was to manually set those parameters? For instance when I manually hard-code the end of the url to &tileMatrix=6&tileRow=333&tileCol=213 it works and I get a map of Westminster, but I don't know how those values should be calculated automatically by react-leaflet.

tmnnrs commented 7 months ago

Provided the CRS has been set within the map options - all the parameter calculations should be calculated automatically.

It feels like the CRS isn't getting set... meaning the map is defaulting to Web Mercator (EPSG:3857) hence the incorrect tileMatrix + tileRow + tileColumn values.

There is https://github.com/OrdnanceSurvey/tile-name-derivation to help with the mathematical relationships which can be used to derive tile names (ZXY and WMTS) from coordinates at a particular zoom level and vice-versa, however the Proj4JS and proj4Leaflet libraries should be taking away this headache.

Have you seen the OS Data Hub Examples? These are vanilla JS + Leaflet code examples, but they might offer some further insight into where the react-leaflet setup might be going wrong.

nbarrett commented 7 months ago

Thanks @tmnnrs - you got me on the right track!!

For anyone else looking for a solution to this (I've seen a few people asking about this on stack overflow and no responses exist on there), the answer was that I needed to add a custom CRS - I did this in the form of a custom hook useCustomCRSFor27700Projection() which returns a crs value which can be passed into the crs prop of the MapContainer. I've enclosed example code for this hook below which was inspired by the leaflet js + plain html example on the os-maps-api website:

import * as L from "leaflet";
import proj4 from "proj4";

export function useCustomCRSFor27700Projection() {

    const zoom = 7 // could be some changeable app state
    const center = [51.505, -0.09] // could be some changeable app state

    const crs = new L.Proj.CRS("EPSG:27700", "+proj=tmerc +lat_0=49 +lon_0=-2 +k=0.9996012717 +x_0=400000 +y_0=-100000 +ellps=airy +towgs84=446.448,-125.157,542.06,0.15,0.247,0.842,-20.489 +units=m +no_defs", {
        resolutions: [896.0, 448.0, 224.0, 112.0, 56.0, 28.0, 14.0, 7.0, 3.5, 1.75],
        origin: [-238375.0, 1376256.0]
    });

    function transformCoords(input) {
        return proj4("EPSG:27700", "EPSG:4326", input).reverse();
    }

    const options = {
        crs,
        minZoom: 0,
        maxZoom: 8,
        center,
        zoom: zoom,
        maxBounds: [
            transformCoords([-238375.0, 0.0]),
            transformCoords([900000.0, 1376256.0])
        ],
        attributionControl: false
    };

    return {crs, options};
}

With that hook defined, you can then use it the vicinity of your MapContainer usage:

    const useCustomCRS = true // some boolean value that determines whether or not you need the custom crs (might change if you want to choose different projections)

    const customCRS = useCustomCRSFor27700Projection();
    const crs = useCustomCRS ? customCRS.crs : L.CRS.EPSG3857;
    const layer = "Leisure_27700";
    const key = "my-api-key";

    return <div style={{height: '80vh', width: '100%'}}>
            <MapContainer crs={crs}
                          minZoom={customCRS.options.minZoom}
                          maxZoom={customCRS.options.maxZoom}
                          zoom={customCRS.options.zoom} center={customCRS.options.center} scrollWheelZoom={true}
                          style={{height: '100%'}}>
                <TileLayer url={`https://api.os.uk/maps/raster/v1/zxy/${layer}/{z}/{x}/{y}.png?key=${key}`}/>
                </MapContainer>
        </div>;

Example of app now working at: https://oscillation-production.up.railway.app/ - and commit that delivered the above

Footnote: Might be a good idea to provide some other examples in this repo (especially react-leaflet) or example projects built with more "current" tooling as I suspect the majority of developers are not building pure html solutions nowadays?