Wednesday, May 6, 2020

Searching Azure Cosmos DB with a polygon using ST_INTERSECTS or ST_WITHIN


     I want to continue to develop our new map application for Azure Cosmos DB. So far, we can run a custom spatial query in Cosmos DB and display the results on a map. I want my users to create a polygon on map and search for data under this polygon. If you are familiar with Zillow, that is how their application lets you look for houses to buy or rent. You select an area, and Zillow application displays all available houses or rental under the area you draw. It is extremely useful and user-friendly search.
   
     First, we need to be able to create custom polygons on map. We don't need to write any custom code for that because Leaflet has a plugin for this, all we need to do is, adding a reference and start the plugin. Add the following two links to your HTML's head part first.

<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css" />


     Also, we need to start this plugin when page is loaded, you need to add the following code which is in yellow color to your script too. We want to run all this code as soon as page is loaded.

document.addEventListener("DOMContentLoaded", function (event) {
        cosmosmap = L.map("CosmosMap", { drawControl:true }).setView([30.35, -90.08], 7);
        L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
            maxZoom: 10,
            attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
        }).addTo(cosmosmap);

        var drawnItems = new L.FeatureGroup();
        cosmosmap.addLayer(drawnItems);
});

     Run your application and you should see Draw options in the right side just like the following screenshot.


     We can create polygons on map, next step is we need to catch the event that creates objects on map and convert created objects into geoJson. To do that, we need to override CREATED event of LeafLet, Add the following code right under the earlier code.

cosmosmap.on(L.Draw.Event.CREATED, function (event) {
     var layer = event.layer;
     // Convert layer to GeoJSON and Call Cosmos DB
     drawnItems.addLayer(layer);
});

     LeafLet has a function which converts the created layer into GeoJSON so we don't need to create any new functions for that. When leaflet creates any shape on the map, it saves the points in clockwise. For example, if you create a rectangle like in the following screenshot, you will end up with 4 set of latitude, longitude and it will be in order of clockwise.


     The order of coordinatation is especially important in GeoJSON. By drawing this way, we are specifying a hole. If we send this to Cosmos DB in this way, Cosmos DB will return all results but this area! I did not know this, Thanks to Mark Brown and Tim Sander  from Cosmos DB team for their help on this. We need to create shapes in counterclockwise to define a bound box.

    There is no function exists I know to tell LeafLet that we want the coordination array to be in counterclockwise. So, I need to do this manually before converting shape into GeoJSON.
_latlngs property holds the coordination array, All I want  to do is to reverse the array by adding following code in yellow color into the CREATED event.

    Leaflet can generate any layer into a valid GeoJson object by calling toGeoJSON() function. GeoJson can have all kind of information about the shape rather than the coordinates and the type of shape. I want to pass only the shape's coordination's and its type to Cosmos DB. That is why I want to select only this information in the following code in blue.

cosmosmap.on(L.Draw.Event.CREATED, function (event) {
     var layer = event.layer;
     layer._latlngs[0] = layer._latlngs[0].reverse();
     alert(JSON.stringify(layer.toGeoJSON().geometry));
     // Convert layer to GeoJSON and Call Cosmos DB
     drawnItems.addLayer(layer);
});

     When you run the code again, and create a polygon, you should see an alert box which tells you the GeoJSON about the shape you just created. Thanks to the reverse() function, our coordinates are in counter-clockwise.


     We are ready to call Cosmos DB! To find data under the shape we create, we can run ST_WITHIN or ST_INTERSECTS. You can read about them in my earlier blog. Both functions return True or False and I want to use one of these functions in WHERE clause.
   
     Let's use ST_INTERSECTS, this function takes two spatial objects, If the first spatial object intersects with the second one, It returns True. Our first argument will be the data in Cosmos DB, second will be the custom polygon we create in this application. So, our query should look like this.

SELECT * FROM h WHERE ST_INTERSECTS(h.Location, geoJsonComingFromFrontEnd)

    I will use the Hurricanes container we created in my earlier post. Spatial Data of Hurricanes are in Location property. That is my first parameter, second one is the geoJSON front-end will send. I need a function which will run this query and return the data.

public async Task<JsonResult> SearchByGeoJson(string db, string cont, string geoJson)
{
    var cmd = "SELECT * FROM h WHERE ST_INTERSECTS(h.Location," + geoJson + ")";
    return await RunCosmosQuery(db, cont, cmd);
}

    I already have a function that can run custom queries in Cosmos DB named RunCosmosQuery, I want to use the same function. All I need to do in this function is creating a query and call the other function to run it.

    We have one more step left, we need a Jscript function which will make an AJAX call to this function and display the data in front-end. I want this function to run as soon as user creates a shape on map. Also, I have a new function named DisplayData which handles displaying data coming from Cosmos DB. This logic was in Ajax call in my earlier post, I moved it into a function so I can call it from any other function.

function SearchByLayer(gjson) {
        if (gjson) {
            $.ajax({
                url: 'SearchByGeoJson',
                data: { db: $('#databasename').val(), cont: $('#containername').val(), geoJson: gjson },
                cache: false,
                success: function (data) {
                    DisplayData(data);
                }
            });
        }
};


function DisplayData(cosmosdata) {
        var style = {
            "color": "#ff7800",
            "weight": 5
        }
        if (cosmosdata) {
            $('#QueryCost').text(cosmosdata.reqUnit);
            $('#ItemCount').text(cosmosdata.count);
            $.each(cosmosdata.data, function (key, val) {
                if (val) {                    
                    L.geoJson(val.geoJson, style).addTo(cosmosmap);                    
                }
            });
        }
    };



     Now, we need to call SearchByLayer when user creates a shape, to do that, we need to call it from CREATED event which we override in the beginning of this blog. To do that, just remove the Alert command and replace it with the following code in yellow color.

document.addEventListener("DOMContentLoaded", function (evt) {
   cosmosmap = L.map("CosmosMap", { drawControl:true }).setView([30.35, -90.08], 7);
   L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
       maxZoom: 10,
       attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
   }).addTo(cosmosmap);

   var drawnItems = new L.FeatureGroup();
   cosmosmap.addLayer(drawnItems);

   cosmosmap.on(L.Draw.Event.CREATED, function (event) {
       var layer = event.layer;
       layer._latlngs[0] = layer._latlngs[0].reverse();            
       SearchByLayer(JSON.stringify(layer.toGeoJSON().geometry));
       drawnItems.addLayer(layer);
   });
});

     Let's try this, I will draw a polygon on New Orleans area in the following screenshot. As soon as shape is generated, my function will reverse the array then it will generate a GeoJSON object and call my function to find if Cosmos DB has any data under the polygon.


     It returns 4 locations from that Hurricane Katrina's path. 3.05 Request Unit is not bad for this query!

No comments:

Post a Comment