A while back someone on Mastodon asked if there was a way to find out which (UK) county they were in with a little bit of Javascript without "the cloud". I made a tool to do just that and this blog post goes into the details.
A cloud-free outlook
Obviously there are services that will tell you things about a set of coordinates (or for a postcode) but the person asking the question wanted a method that didn’t require "the cloud".
There are reasons you might want to avoid "the cloud". If you are privacy-conscious you might want to avoid sending your location across the network to some third-party service. You might also want to avo…
A while back someone on Mastodon asked if there was a way to find out which (UK) county they were in with a little bit of Javascript without "the cloud". I made a tool to do just that and this blog post goes into the details.
A cloud-free outlook
Obviously there are services that will tell you things about a set of coordinates (or for a postcode) but the person asking the question wanted a method that didn’t require "the cloud".
There are reasons you might want to avoid "the cloud". If you are privacy-conscious you might want to avoid sending your location across the network to some third-party service. You might also want to avoid making your webpage dependent on a service that might go down or disappear altogether. Or, you might just want something that works without needing code to run on a server.
Remote services will take your location and compare it to a big set of polygons for the areas to work out which one it is in. To achieve our cloud-free implementation we need to turn things around and send all the polygons to the browser so that we can work out the correct one in-situ. It protects privacy but we will need to use bandwidth to send all the polygons.
Optimising geography
I found the county borders from the Historic County Borders Project. I then used our tips for optimising GeoJSON files to reduce the resolution of the boundaries, removed unnecessary properties, and brought the file size down to 422kB. At this point, most of the file content is made up of coordinates. Could anything be done to reduce those?
A big chunk of coordinates in the file define the twiddly, fractal, coastline and islands in the north of Scotland. So my first attempt was to manually simplify the coastlines of Shetland, Orkney, Sutherland, Ross-shire, Inverness-shire, and Argyllshire by merging/simplifying some islands and removing a lot of vertices. I got the file down to 129kB. The downside is that it produces some funky looking maps.
Was there anything else I could do to reduce file size without making the map look odd? I realised that the numbers that make up the latitudes/longitudes don’t really vary much from vertex to vertex in each polygon. So I made a modified GeoJSON format where I replaced the coordinates with the change in latitude/longitude multiplied by 10,000. For instance, if a polygon was originally defined as...
[-2.2719,57.6821],[-2.2725,57.6625],[-2.2772,57.6589],[-2.2859,57.6391],[-2.2994,57.6349]...
this reduced to:
[-2.2719,57.6821],[-6,-196],[-47,-36],[-87,-198],[-135,-42]...
The first coordinate remains the same but all the others now take up less space. I created a simple page to create these modified files and wrote a little bit of code to convert them back into the standard GeoJSON format. By doing this I was able to reduce the 422kB file to 244kB without losing any detail.
Javascript function to convert to the modified GeoJSON
function compactGeoJSON(input,precision){ let geojson = JSON.stringify(input); let len = geojson.length; // Turn string into JSON geojson = JSON.parse(geojson); // Function to process an array of coordinates this.convertPolygon = function(coords,precision){ if(typeof precision!=="number"){ console.error('No precision given'); } // Check if the coordinates look like an object if(typeof coords==="object"){ // If the first element's first element is a number // we are at the correct level if(typeof coords[0][0]==="number"){ // Process this array of locations let newcoords = JSON.parse(JSON.stringify(coords)); let scale = Math.pow(10,precision); // Round the first pair of coordinates to the precision newcoords[0] = [Math.round(coords[0][0]*scale)/scale,Math.round(coords[0][1]*scale)/scale]; for(let c = 1; c < coords.length; c++) newcoords[c] = [Math.round((coords[c][0] - coords[c-1][0])*scale), Math.round((coords[c][1] - coords[c-1][1])*scale)]; return newcoords; }else if(typeof coords[0][0]==="object"){ // This looks like a higher-level array so process each part for(let c = 0; c < coords.length; c++) coords[c] = this.convertPolygon(coords[c],precision); } }else{ console.log('Not coords'); } return coords; }; if(!("_compact" in geojson)){ // Set some properties in the file to mark it as a "compact" version geojson._compact = { "version": "0.1", "precision": (typeof precision==="number" ? precision : 5) }; // Process each feature for(let i = 0; i < geojson.features.length; i++){ let g = geojson.features[i].geometry; if(g.type=="Polygon" || g.type=="MultiPolygon"){ g.coordinates = this.convertPolygon(g.coordinates,geojson._compact.precision); } } // Show how much it has compressed the file console.log("Reduced from " + len.toLocaleString() + " to " + JSON.stringify(geojson).length.toLocaleString()); } return geojson;}
Javascript function to convert back to standard GeoJSON
function expandGeoJSON(input){ let geojson = JSON.parse(JSON.stringify(input)); this.convertPolygon = function(coords,precision){ if(typeof precision!=="number"){ console.error('No precision given'); } if(typeof coords==="object"){ if(typeof coords[0][0]==="number"){ // Process this array of locations let newcoords = JSON.parse(JSON.stringify(coords)); let scale = Math.pow(10,precision); // Reconstruct each of the coordinate pairs for(let c = 1; c < coords.length; c++) newcoords[c] = [parseFloat(((newcoords[c][0]/scale) + newcoords[c-1][0]).toFixed(precision)), parseFloat(((newcoords[c][1]/scale) + newcoords[c-1][1]).toFixed(precision))]; return newcoords; }else if(typeof coords[0][0]==="object"){ for(let c = 0; c < coords.length; c++) coords[c] = this.convertPolygon(coords[c],precision); } }else{ console.log('Not coords'); } return coords; }; if("_compact" in geojson){ for(let i = 0; i < geojson.features.length; i++){ let g = geojson.features[i].geometry; if(g.type=="Polygon" || g.type=="MultiPolygon"){ g.coordinates = this.convertPolygon(g.coordinates,geojson._compact.precision); } } delete geojson._compact; } return geojson;}
Once I had things working it was time to add more types of area. I quickly added regions & nations, local authorities, parliamentary constituencies, and wards. There are a lot of wards so that requires a huge GeoJSON file. Even after reducing the resolution and optimising it was still 2.5MB! That feels far too big, for me, even if it is smaller than many webpages these days.
Bite-sized pieces
The only way to get much more improvement would be to not load every polygon.
I built a much smaller summary JSON file that contains the bounding box (the extent in latitude and longitude) for each feature along with the byte location of the feature in the main GeoJSON file. Using this summary we can do an initial pass of bounding boxes to see which features we should check in more detail. We can then use a partial fetch() to get the specific polygons and then see if our location is in those. Only getting a single feature (after the summary) massively reduces the bandwidth usage but at the expense of some location privacy as our server can know which feature you’ve loaded.
I ran into one problem whilst trying to implement a partial fetch() - if the server uses compression on the file, the fetch fails. I’ve had to make sure that compression is turned off on the files that will be accessed like this.
Conclusion
The find my area tool is now available on our website. If you have any ideas for other ways to make this sort of tool more efficient (whilst preserving privacy), let me know.