In this tip, you will learn how to perform a freehand drawing using Google maps and apply this drawing to a SQL search.
Using the Code
We will use JavaScript to perform a freehand drawing on Google maps. Then, we will pass the drawing information to a controller and perform Spatial search using Linq to SQL.
First, we will add this HTML on the page:
<div>
<input id="DrawOnMap" type="checkbox">
<label for="DrawOnMap"><span>Draw on Map</span></label>
</div>
<div id="DrawOnMapSearch">
<p>Draw around the area you would like to search</p>
<input id="DrawOnMapApply" value="Apply" type="button" disabled="disabled">
<input id="DrawOnMapReDraw" value="Redraw" type="button" disabled="disabled">
</div>
<div id="map_canvas"></div>
The map will be created and displayed on:
<div id="map_canvas"></div>
Let's talk about the JavaScript.
For this code, we will use the geometry library of Google maps, we will use this page like a base example:
But unfortunately, Google map only allows these options to draw:
{
CIRCLE: "circle",
MARKER: "marker",
POLYGON: "polygon",
POLYLINE: "polyline",
RECTANGLE: "rectangle"
}
For the FreeHand drawing, we will use a combination of POLYGON and POLYLINE.
This will be the JS that we are going to use:
var drawOnMap = document.getElementById("DrawOnMap");
var drawOnMapApply = document.getElementById("DrawOnMapApply");
var drawOnMapReDraw = document.getElementById("DrawOnMapReDraw");
MapDrawing = function () {
}
MapDrawing.prototype = {
ActiveDrawing: function (isMobile) {
var self = this;
var deviceEvents = self.getDeviceEvent(isMobile);
google.maps.event.addDomListener(self.map.getDiv(),
deviceEvents.mousedown,
function (e) {
self.drawFreeHand(deviceEvents.mousedown,
deviceEvents.mousemove, deviceEvents.mouseup, isMobile);
self.cancelEvtNStopProp(e);
});
},
getDeviceEvent: function (isMobileDevice) {
var deviceEvents;
if (isMobileDevice) {
deviceEvents = {
mousedown: 'touchstart',
mousemove: 'mousemove',
mouseup: 'mouseup'
};
} else {
deviceEvents = {
mousedown: 'mousedown',
mousemove: 'mousemove',
mouseup: 'mouseup'
};
}
return deviceEvents;
},
enableDrawing: function () {
var self = this;
if (self.map !== null) {
self.map.setOptions({
draggable: false,
zoomControl: false,
scrollwheel: false,
disableDoubleClickZoom: false,
draggableCursor: 'crosshair',
streetViewControl: false,
overviewMapControl: false,
mapTypeControl: false,
scaleControl: false,
rotateControl: false,
fullscreenControl: false
});
}
},
disableDrawing: function () {
var self = this;
if (self.map !== null) {
self.map.setOptions({
draggable: true,
zoomControl: true,
scrollwheel: true,
disableDoubleClickZoom: true,
draggableCursor: null,
streetViewControl: true,
overviewMapControl: true,
mapTypeControl: true,
scaleControl: true,
rotateControl: true,
fullscreenControl: true
});
}
},
drawFreeHand: function (mousedown, mousemove, mouseup, isMobile) {
var self = this;
var dOptions = {
clickable: false,
fillColor: '#BCDCF9',
fillOpacity: 0.5,
strokeWeight: 2,
strokeColor: '#57ACF9',
zIndex: 1
};
self.poly = new google.maps.Polyline(dOptions);
self.poly.setMap(self.map);
var events = google.maps.event;
var move = events.addListener(self.map, mousemove, function (e)
{ self.poly.getPath().push(e.latLng); });
events.addListenerOnce(self.map, mouseup, function (e) {
events.removeListener(move);
var path = self.poly.getPath();
self.poly.setMap(null);
self.poly = new google.maps.Polygon(dOptions);
self.poly.setMap(self.map);
self.poly.setPaths(path);
events.clearListeners(self.map.getDiv(), mousedown);
var bounds = new google.maps.LatLngBounds();
var myCoordinates = [];
for (var i = 0; i < path.getLength(); i++) {
var xy = path.getAt(i);
var lat = xy.lat();
var lng = xy.lng();
bounds.extend(xy);
myCoordinates.push({ Longitude: lng, Latitude: lat });
}
var ne = bounds.getNorthEast();
var sw = bounds.getSouthWest();
var data = {
minLat: sw.lat(),
minLng: sw.lng(),
maxLat: ne.lat(),
maxLng: ne.lng(),
geographies: myCoordinates
};
if (data.geographies.length === 0) {
self.disableDrawing();
self.ActiveDrawing(isMobile);
} else {
self.DrawOnMapCallBack(data);
}
});
},
DrawOnMapCallBack: function (data) {
this.DrawData = data;
drawOnMapApply.disabled = false;
},
remFreeHand: function (isMobile) {
var self = this;
var deviceEvents = self.getDeviceEvent(isMobile);
if (typeof (google) !== "undefined") {
var clearListeners = google.maps.event.clearListeners;
clearListeners(self.map, deviceEvents.mousemove);
clearListeners(self.map, deviceEvents.mouseup);
clearListeners(self.map.getDiv(), deviceEvents.mousedown);
}
},
cancelEvtNStopProp: function (event) {
var ev = event ? event : window.event;
ev.cancelBubble = true;
ev.returnValue = false;
if (ev.stopPropagation) ev.stopPropagation();
if (ev.preventDefault) ev.preventDefault();
},
init: function () {
var self = this;
var mapOptions = {
zoom: 14,
center: new google.maps.LatLng(30.3079827, -97.8934853),
mapTypeId: google.maps.MapTypeId.ROADMAP,
zoomControl: true,
zoomControlOptions: {
style: google.maps.ZoomControlStyle[true],
},
disableDefaultUI: false,
draggable: true,
scrollwheel: true,
disableDoubleClickZoom: false,
panControl: true,
streetViewControl: true,
overviewMapControl: true,
mapTypeControl: true,
scaleControl: true,
rotateControl: true,
fullscreenControl: true
};
self.map = new google.maps.Map
(document.getElementById('map_canvas'), mapOptions);
}
};
function initialize() {
var mapDrawing = new MapDrawing();
mapDrawing.init();
drawOnMap.addEventListener("change", function () {
if (drawOnMap.checked) {
drawOnMapReDraw.disabled = false;
mapDrawing.enableDrawing();
mapDrawing.ActiveDrawing(false);
} else {
drawOnMapApply.disabled = true;
drawOnMapReDraw.disabled = true;
mapDrawing.disableDrawing();
}
});
drawOnMapReDraw.addEventListener("click", function () {
drawOnMapApply.disabled = true;
if (typeof (mapDrawing.poly) !== "undefined") {
mapDrawing.poly.setMap(null);
}
drawOnMapApply.disabled = true;
mapDrawing.enableDrawing();
mapDrawing.ActiveDrawing(false);
});
drawOnMapApply.addEventListener("click", function () {
var data = mapDrawing.DrawData;
});
}
google.maps.event.addDomListener(window, 'load', initialize);
Let's break it in small parts:
The first thing that we need to know will be the events that we are going to use, because on mobile, there is a small difference, on mobile, the mousedown
event is call touchstart
.
getDeviceEvent: function (isMobileDevice) {
var deviceEvents;
if (isMobileDevice) {
deviceEvents = {
mousedown: 'touchstart',
mousemove: 'mousemove',
mouseup: 'mouseup'
};
} else {
deviceEvents = {
mousedown: 'mousedown',
mousemove: 'mousemove',
mouseup: 'mouseup'
};
}
return deviceEvents;
}
The drawing will occur when the user clicks and holds the house on the map, is necessary to remove the necessary items and control the maps, we can enable them again if we don't want to draw any more on the map. Also, we will change the cursor for cross during the drawing to make it easy to see and understand.
enableDrawing: function () {
var self = this;
if (self.map !== null) {
self.map.setOptions({
draggable: false,
zoomControl: false,
scrollwheel: false,
disableDoubleClickZoom: false,
draggableCursor: 'crosshair',
streetViewControl: false,
overviewMapControl: false,
mapTypeControl: false,
scaleControl: false,
rotateControl: false,
fullscreenControl: false
});
}
},
disableDrawing: function () {
var self = this;
if (self.map !== null) {
self.map.setOptions({
draggable: true,
zoomControl: true,
scrollwheel: true,
disableDoubleClickZoom: true,
draggableCursor: null,
streetViewControl: true,
overviewMapControl: true,
mapTypeControl: true,
scaleControl: true,
rotateControl: true,
fullscreenControl: true
});
}
}
Here comes the trick, when the user clicks and holds the mouse, the mousedown
event will be fired, inside of this event, we will have a listener for mousemove
and using the POLYLINE, the code will draw lines following the mouse cursor. When the user releases the mouse, the event mouseup
will be triggered and will close the draw using the POLYGON.
Know we can gather the coordinates of the drawing:
var bounds = new google.maps.LatLngBounds();
var myCoordinates = [];
for (var i = 0; i < path.getLength(); i++) {
var xy = path.getAt(i);
var lat = xy.lat();
var lng = xy.lng();
bounds.extend(xy);
myCoordinates.push({ Longitude: lng, Latitude: lat });
}
var ne = bounds.getNorthEast();
var sw = bounds.getSouthWest();
var data = {
minLat: sw.lat(),
minLng: sw.lng(),
maxLat: ne.lat(),
maxLng: ne.lng(),
geographies: myCoordinates
};
We are also going to use a google.maps.LatLngBounds
, to create an imaginary rectangle around the drawing, this will help us to speed up the search on the Linq to SQL.
Here is a live example of the JavaScript and HTML:
Controller to Perform the Search
This is the class that I am going use to pass the information to the server.
[JsonObject]
[Serializable]
public class ApiGeographyInformation
{
public decimal MinLat { get; set; }
public decimal MinLng { get; set; }
public decimal MaxLat { get; set; }
public decimal MaxLng { get; set; }
public List<ApiGeography> Geographies { get; set; }
}
[JsonObject]
[Serializable]
public class ApiGeography
{
public decimal Latitude { get; set; }
public decimal Longitude { get; set; }
public override string ToString()
{
return Longitude + " " + Latitude;
}
}
To make sure that drawing is a valid one, we are going to use this validation to check the first and last point.
private string ValidatePolygonIntersects(ApiGeographyInformation searchParams)
{
if (searchParams.Geographies.Any())
{
var first = searchParams.Geographies.First();
var last = searchParams.Geographies.Last();
if (first.ToString() != last.ToString())
searchParams.Geographies.Add(first);
}
var bboxWkt = $"POLYGON(({string.Join(" , ", searchParams.Geographies)}))";
var result = GetValidGeography(bboxWkt);
return result;
}
For the method, GetValidGeography
, we have two options to validate the drawing, because there is a big difference on the results if the draw is performed starting from the left with one that is starting from the right. This is a small hack when the user start drawing to the right.
First Option
We can use the SQL server to perform this validation, but some of the SQL methods are only available starting from SQL 2014.
USE [master]
GO
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
Create PROCEDURE [dbo].[pr_GetValidGeography]
(
@bboxWkt varchar(max)
)
AS
Set Transaction Isolation Level Read Uncommitted
BEGIN
DECLARE @g geography = geography::STGeomFromText(@bboxWkt, 4326).MakeValid();
DECLARE @g2 geography = @g.ReorientObject().MakeValid();
DECLARE @geographyReturn geography;
IF(@g.STArea()> @g2.STArea())
BEGIN
SET @geographyReturn = @g2;
END
ELSE
BEGIN
SET @geographyReturn = @g;
END
SELECT @geographyReturn.STAsText() AS 'geography'
END
public string GetValidGeography(string area)
{
var dataTable = new DataTable();
using (var connection = new SqlConnection(""))
{
var command = new SqlCommand("pr_GetValidGeography", connection)
{ CommandType = CommandType.StoredProcedure };
command.Parameters.Add(new SqlParameter
{ParameterName = "@bboxWkt", DbType = DbType.String, Value = area});
var dataAdapter = new SqlDataAdapter(command);
connection.Open();
dataAdapter.Fill(dataTable);
connection.Close();
var data = dataTable.Rows[0]["geography"].ToString();
return data;
}
}
Using the library of Microsoft.SqlServer.DACFx, personally, I recommend this option, because is faster than the SQL one.
Install-Package Microsoft.SqlServer.DACFx
private string GetValidGeography(string area)
{
var bboxWkt = SqlGeography.STGeomFromText
(new System.Data.SqlTypes.SqlChars(area), 4326);
var g = bboxWkt.MakeValid();
var g2 = g.ReorientObject();
var geographyReturn = g.STArea() > g2.STArea() ? g2 : g;
return geographyReturn.ToString();
}
And finally, we just need to perform the search using Ling to SQL:
public void ApplyMapPolygon(ApiGeographyInformation @params)
{
var area = ValidatePolygonIntersects(@params);
var minLat = @params.MinLat;
var minLng = @params.MinLng;
var maxLat = @params.MaxLat;
var maxLng = @params.MaxLng;
var polygon = DbGeography.FromText(area, 4326);
var filterL = new List<Locations>();
var results = from c in filterL
where c.Latitude > minLat &&
c.Latitude < maxLat &&
c.Longitude > minLng &&
c.Longitude < maxLng
select c;
results = from c in filterL
let lng = c.Longitude
let lat = c.Latitude
let point = DbGeography.FromText("POINT(" + lng + " " + lat + ")", 4326)
where polygon.Intersects(point)
select c;
}
Extra Documentation
History
- 15th February, 2021: Initial submission