Scott Hanselman

ASP.NET MVC DisplayTemplate and EditorTemplates for Entity Framework DbGeography Spatial Types

September 04, 2012 Comment on this post [28] Posted in ASP.NET | ASP.NET MVC
Sponsored By

UPDATE: Be sure to read the whole post including the refactoring update at the end via a Pull Request from Dave Ward.

I was trying to write a blog post about something totally different and was using this small EntityFramework 5 Code First model:

public class TouristAttraction
{
public int TouristAttractionId { get; set; }
public string Name { get; set; }

public DbGeography Location { get; set; }
}

The little bit I added

You'll notice I'm using the DbGeography spatial type with Latitude and Longitude. This support is new in Entity Framework 5. For a more complex example I might want to make a custom type of my own or split things up into a double lat and double long, but for this little app I just wanted a few fields and a map.

However, when I was scaffolding things out, of course, DbGeography was left in the cold. It wasn't scaffolded or included. When I added a map, there was no model binder. When I started making more complex views, there was no EditorFor or DisplayFor template support. So, rather than finishing the thing I was trying to do (I'll finish that project later) I became slightly obsessed focused with getting some kind of basic system working for DbGeography.

First, I scaffolded out a standard Entity Framework controller for "TouristAttraction" and changed nothing. The goal was to change nothing because DbGeography should just be treated like any other complex type. My prototype should be easily changeable for any other database or map system. There shouldn't be any Controller hacks to get it to work.

There's basically a DisplayTemplate and an EditorTemplate to show and edit maps. There's a model binder to handle DbGeography, and a small helper extension to get the client id for something more easily.

There's also some supporting JavaScript that I'm sure Dave Ward will hate because I'm still learning how to write JavaScript the way the kids write it today. Refactoring is welcome.

Creating and Editing

When creating a TouristAttraction you type the name then click the map. Clicking the map puts the lat,long in a text box (that could be hidden, of course).

Creating a location with a clickable Google Map

I simply added a Google Map to the Layout:

<script type="text/javascript" src="http://maps.google.com/maps/api/js?sensor=false"></script> 

then on the Create.cshml used an EditorFor just like any other field:

<fieldset>
<legend>TouristAttraction</legend>

<div class="editor-label">
@Html.LabelFor(model => model.Name)
</div>
<div class="editor-field">
@Html.EditorFor(model => model.Name)
@Html.ValidationMessageFor(model => model.Name)
</div>
<div class="editor-label">
@Html.LabelFor(model => model.Location)
</div>
<div class="editor-field">
@Html.EditorFor(model => model.Location)
</div>
<p>
<input type="submit" value="Create" />
</p>
</fieldset>

There is an EditorTemplate called DbGeography, the name of the type by convention. If the type is "Foo" and the file is EditorTemplates/Foo.cshtml or DisplayTemplates/Foo.cshtml you can use EditorFor() and DisplayFor() and the whole app gets the benefits.

@model System.Data.Spatial.DbGeography
@Html.TextBox("")
@if (Model != null) {
<script>
$(function () {
maps.markerToSet = new google.maps.LatLng(@Model.Latitude, @Model.Longitude);
});
</script>
}
@{ string textbox = Html.ClientIdFor(model => model).ToString(); }
<div id="map_canvas" data-textboxid="@textbox" style="width:400px; height:400px"></div>

This EditorTemplate needs to support Create as well as Edit so if Model isn't null it will set a variable that will be used later when the map is initialized in an Edit scenario.

Next steps would be to make a generated map id for the div so that I could have multiple maps on one page. I'm close here as I'm sticking the "friend" textbox id in a data- attribute so I don't have to have any JavaScript on this page. All the JavaScript should figure out names unobtrusively. It's not there yet, but the base is there. I should also put the width and height elsewhere.

You'll notice the call to ClientIdFor. At this point I want the name of the textbox's client id but I don't know it. I don't want to make an EditorTemplate that only works hard-coded for one type so I need to get the generated value. I have an HtmlHelper extension method:

public static partial class HtmlExtensions
{
public static MvcHtmlString ClientIdFor<TModel, TProperty>(this HtmlHelper<TModel> htmlHelper, Expression<Func<TModel, TProperty>> expression)
{
return MvcHtmlString.Create(
htmlHelper.ViewContext.ViewData.TemplateInfo.GetFullHtmlFieldId(ExpressionHelper.GetExpressionText(expression)));
}
}

This won't work unless you are sure to add your HtmlExtensions full namespace to the web.config in the Views folder under namespaces. That will let Razor see your HtmlHelper Extension method.

NOTE: Make sure you add a reference to System.Data.Entity to your MVC View's web.config in order to have a @model like I have above.

<compilation ... >
<assemblies>
<add assembly="System.Data.Entity, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"/>
</assemblies>
</compilation>

When you click the map it fills out the text box. I've also got some initial values in here as well as the addListenerOnce which is used later in DisplayFor to load a read-only" map when viewing a record's details.

function maps() { }
maps.mapInstance = null;
maps.marker= null;
maps.mapInstanceId = "map_canvas";
maps.markerToSet = null;

function initialize() {
var latlng = new google.maps.LatLng(40.716948, -74.003563); //a nice default
var options = {
zoom: 14, center: latlng,
mapTypeId: google.maps.MapTypeId.ROADMAP,
maxZoom: 14 //so extents zoom doesn't go nuts
};
maps.mapInstance = new google.maps.Map(document.getElementById(maps.mapInstanceId), options);

google.maps.event.addListener(maps.mapInstance, 'click', function (event) {
placeMarker(event.latLng);
});

google.maps.event.addListenerOnce(maps.mapInstance, 'idle', function (event) {
if (maps.markerToSet) {
placeMarker(maps.markerToSet);
var bound = new google.maps.LatLngBounds();
bound.extend(maps.markerToSet);
maps.mapInstance.fitBounds(bound);
}
});
}

function placeMarker(location) {
if (maps.marker) {
maps.marker.setPosition(location);
} else {
maps.marker = new google.maps.Marker({
position: location,
map: maps.mapInstance
});
}

if (maps.marker) { //What's a better way than this dance?
var textboxid = $("#" + maps.mapInstanceId).data("textboxid");
$("#" + textboxid).val(maps.marker.getPosition().toUrlValue(13));
}
}

$(function () {
initialize();
});

When I've filled out a new location and hit SAVE the lat,long is POSTed to the controller which I want to "just work" so I made a Model Binder to handle the DbGeography type.

I add it (actually its provider in case I add to it with other Entity Framework types) to the ModelBinderProviders collection in Global.asax:

ModelBinderProviders.BinderProviders.Add(new EFModelBinderProvider());

The ModelBinder itself tries to be generic as well. I must say I see FAR too many CustomModelBinders out there with folks calling into Request.Form digging for custom strings. That's not reusable and it's just wrong.

public class DbGeographyModelBinder : DefaultModelBinder
{
public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
{
var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
string[] latLongStr = valueProviderResult.AttemptedValue.Split(',');
string point = string.Format("POINT ({0} {1})",latLongStr[1], latLongStr[0]);
//4326 format puts LONGITUDE first then LATITUDE
DbGeography result = valueProviderResult == null ? null :
DbGeography.FromText(point,4326);
return result;
}
}

public class EFModelBinderProvider : IModelBinderProvider
{
public IModelBinder GetBinder(Type modelType)
{
if (modelType == typeof(DbGeography))
{
return new DbGeographyModelBinder();
}
return null;
}
}

At this point, DbGeography is model bound and I don't have to change the controller. Now, again, I am NOT using a ViewModel here but I would on a larger application so be aware. I'd likely make a custom model binder for that hypothetical ViewModel and it would to be married to a database technology like this one is.

You can see in the database that I've got the points stored as "geography" types and they are round tripping just fine.

Entity Framework v5 Code First and VS2012 support for SQL Server's geography types

I threw this ASP.NET MVC 4 sample up on GitHub in https://github.com/shanselman/ASP.NET-MVC-and-DbGeography. You'll need VS2012 to play.

Again, please forgive my 3am hacking and poor JavaScript but I hope you get the idea and find it useful. I think this has the potential to be packaged up into a NuGet or perhaps useful as an EntityFramework and ASP.NET MVC sample.

IMPORTANT NOTE: In order to save lots of space space with samples I didn't check in binaries or include the packages folder with dependencies. Make sure that you have given NuGet permission to download missing packages during a build if you want to build this sample.

Be sure to click "Allow NuGet to download missing packages during build"

I've enabled NuGet Package Restore in the project by right clicking on the Solution node and clicking "Enable NuGet Package Restore." This will cause NuGet to download the missing packages on build, so be aware as there will be a pause on the first build when NuGet updates the package.

UPDATE #1

My buddy Dave Ward took a look at the code and submitted a pull request that improves some of the JavaScript and makes it more the way "the kids are writing JavaScript these days."

Dave has finished up a number of things for me. First, he's made things generic enough so one can have multiple maps on a page. He has also tidied up my JavaScript.

He starts by removing any inline script from the DisplayTemplats and EditorTemplates and instead uses the TextBox itself to hold the data. This, of course, is just one of several "duh" moments for me and a reminder that using HTML elements to hold data is kind of what they're for. It also brings up an interesting debate about views and models. I did think about using KnockoutJS for this but thought it overkill.

@model System.Data.Spatial.DbGeography
@if (Model != null) {
@Html.TextBox("", Model.Latitude + "," + Model.Longitude, new { @class = "editor-for-dbgeography" })
} else {
@Html.TextBox("", "", new { @class = "editor-for-dbgeography" })
}
Dave also introduces CSS classes as markers for the text boxes to identify them as editors for a DbGeography. That's editors, plural, since the primary limitation of my implementation was that there could be only one.

Dave now keys off the text boxes rather than a div. He initializes any of these text boxes (for display or editor) and dynamically adds the Google Map after each.

$('.editor-for-dbgeography, .display-for-dbgeography').each(initialize);

He pulls the lat,long out of the text box and creates the map. When the text box changes he set a marker and pans the map to the new location.

It's surprisingly readable.

(function() {
// Method signature matching $.fn.each()'s, for easy use in the .each loop later.
var initialize = function(i, el) {
// el is the input element that we need to initialize a map for, jQuery-ize it,
// and cache that since we'll be using it a few times.
var $input = $(el);

// Create the map div and insert it into the page.
var $map = $('<div>', {
css: {
width: '400px',
height: '400px'
}
}).insertAfter($input);

// Attempt to parse the lat/long coordinates out of this input element.
var latLong = parseLatLong(this.value);

// If there was a problem attaining a lat/long from the input element's value,
// set it to a sensible default that isn't in the middle of the ocean.
if (!latLong || !latLong.latitude || !latLong.longitude) {
latLong = {
latitude: 40.716948,
longitude: -74.003563
};
}

// Create a "Google(r)(tm)" LatLong object representing our DBGeometry's lat/long.
var position = new google.maps.LatLng(latLong.latitude, latLong.longitude);

// Initialize the map widget.
var map = new google.maps.Map($map[0], {
zoom: 14,
center: position,
mapTypeId: google.maps.MapTypeId.ROADMAP,
maxZoom: 14
});

// Place a marker on it, representing the DBGeometry object's position.
var marker = new google.maps.Marker({
position: position,
map: map
});

var updateMarker = function(updateEvent) {
marker.setPosition(updateEvent.latLng);

// This new location might be outside the current viewport, especially
// if it was manually entered. Pan to center on the new marker location.
map.panTo(updateEvent.latLng);

// Black magic, courtesy of Hanselman's original version.
$input.val(marker.getPosition().toUrlValue(13));
};

// If the input came from an EditorFor, initialize editing-related events.
if ($input.hasClass('editor-for-dbgeography')) {
google.maps.event.addListener(map, 'click', updateMarker);

// Attempt to react to user edits in the input field.
$input.on('change', function() {
var latLong = parseLatLong(this.value);

latLong = new google.maps.LatLng(latLong.latitude, latLong.longitude);

updateMarker({ latLng: latLong });
});
}
};

var parseLatLong = function(value) {
if (!value) { return undefined; }

var latLong = value.match(/-?\d+\.\d+/g);

return {
latitude: latLong[0],
longitude: latLong[1]
};
};

// Find all DBGeography inputs and initialize maps for them.
$('.editor-for-dbgeography, .display-for-dbgeography').each(initialize);
})();

The next step will be to bundle this all up in a NuGet. Any ideas on a clear name? AspNet.Net.Mvc.SpatialTemplates?

About Scott

Scott Hanselman is a former professor, former Chief Architect in finance, now speaker, consultant, father, diabetic, and Microsoft employee. He is a failed stand-up comic, a cornrower, and a book author.

facebook bluesky subscribe
About   Newsletter
Hosting By
Hosted on Linux using .NET in an Azure App Service
September 04, 2012 15:36
Thanks. I wish I could use EF 5.0 (with its Full features) with VS 2010 and .NET 4.
September 04, 2012 17:01
Do we have to use SQL server 2012 spatial feature OR
Entity framework 5 & SQL server 2012 spatial features are independant ?
September 04, 2012 18:10
I was surprised to see this support in EF 5.0, and quickly looked to see if they supported the hierarchy ID type, but alas, no.

Anyone have any idea if and when support for hierarchy type is coming?
September 05, 2012 11:02
Thanks for an interesting blog post. I downloaded the project as a zip-file and checked the "Allow NuGet to download missing packages during build" checkbox in VS2012 Preumium RTM before I unzipped and built the solution. However it doesn't build the solution due to missing assembly references.

Am I doing something wrong? Is it not enough to check the "Allow NuGet to download....".
September 05, 2012 18:32
Master (zip) download link not work!!
September 05, 2012 21:00
Neno and Martin - I did a new checkin with NuGet Package Restore enabled. Try now? Alternatively, right click on the Solution in Solution Explorer and click "Enable NuGet Package Restore" yourself.
September 05, 2012 23:29
Great post Scott! VS2012 complained it couldn't find the spatial type in my View Templates, until I found this post which points out that I need to add the assembly reference to the web.config. Builds fine after doing that.
September 06, 2012 0:34
Ah, thanks Eddie. I'll add that to the post.
September 06, 2012 6:07
Nice Post! Can I use EF 4.0 for spacial type?
September 06, 2012 12:29

Last week I wanted to do the same on azure
Unfortunately DbGeography is only available in 4.5 and 4.5 hasn't released to azure yet :(
September 06, 2012 17:53
Hi Scott, cheers for this, brilliant - was just the thing I needed, as was working on something to allow people to edit locations of items on a db.

I've done a very crude tweak some people may find useful (I'm sure there is a better way of doing it, but this was my first method, and it worked, so it'll do for the moment).

On my db, it calculates an approximate location from a postcode (UK equiv of zip) using Google geolocater, and that Lat/Lng is already in the db. As such, I wanted to offer your editor some lat/lng data as an OPTIONAL value to give the editor a location for people to improve.

so for the view

@if (Model.Location == null)
{
// no location specified, so provide the approxLatLng
@Html.EditorFor(model => model.Location, new { approxLatLng = Model.ApproxLatitude.ToString() + "," + Model.ApproxLongitude.ToString() })
}else
{
// location available, use standard editor using dbGeog
@Html.EditorFor(model => model.Location)
}


and for the DbGeography EditorFor View

@if (Model != null)
{
@Html.TextBox("", Model.Latitude + "," + Model.Longitude, new { @class = "editor-for-dbgeography" })
}
else if (ViewData["approxLatLng"] != null)
{
@Html.TextBox("", ViewData["approxLatLng"], new { @class = "editor-for-dbgeography" })
}
else
{
@Html.TextBox("", "", new { @class = "editor-for-dbgeography" })
}

September 06, 2012 18:04
You might want to put a disclaimer here about EULA's and TOS's for geospatial data. If I remember correctly, Google and Bing both specifically say you CANNOT store any of their map tiles or lat/long data in a database.
September 11, 2012 8:41
If you use conditionals in the TextBox call, all of a sudden the code turns into a two-line template: now that's what I call "beauty"... :)
@model System.Data.Spatial.DbGeography
@Html.TextBox(string.Empty, this.Model == null ? string.Empty : this.Model.Latitude + "," + this.Model.Longitude, new { @class = "editor-for-dbgeography" })
/*** JS goes here ***/

September 24, 2012 3:50
Hi all

I'm having trouble with the above. The templates are working fine but when I do a post to save, the model doesn't have a value for the Location. It's just null.

In the below piece of code valueProviderResult is always null...

public object BindModel(ControllerContext controllerContext, MvcModelBindingContext bindingContext)
{
var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
return BindModelImpl(valueProviderResult != null ? valueProviderResult.AttemptedValue : null);
}

I'm completely stumped as to why this isn't happening. Also, the DbGeographyModelBinder has stopped getting called completely now for some reason!?

Thanks in advance for any help you can give me.

Mark

October 03, 2012 7:56
My DbGeography property is alternately saving the correct value and then the next time it saves NULL. I can repeat this over and over. Other properties on the model object save successfully every time. A breakpoint on EFModelBinderProviderMvc.GetBinder() also alternately gets hit and then does NOT get hit. Any ideas?

Thanks for the great snippet, Scott!
October 04, 2012 1:38
Update to previous comment:
It turns out the MVC Git sample suffers from the same problem. Same test results as my prior comment states.
I'm stumped. FYI, the default MVC Route starts with "/MVC", it took me a few days to notice that. And the WebForms version works fine.
October 21, 2012 6:42
Cool post - EditorFor templates are so under used. I've been using them for over two years now to keep my views DRY. The template selection by type convention was a genius idea by the MVC team.

For people wanting to get to grips with DisplayFor and EditorFor templates check out my blog post:
http://dalsoft.co.uk/blog/index.php/2010/04/26/mvc-2-templates/
December 21, 2012 20:33
Great post, really fast tracked a project for me - thanks.

I'm unable to directly access the database, I can see it in VS2012 server explorer, but not expand it - permission denied. Forgive my ignorance, don't use local DB's much. Any insight? Thanks.
January 09, 2013 20:52
Hi,

very interesting stuff - I'm extending it to also provide GeoCoding from the Name textbox, so you can input an address fragment, get a set of markers, click one of them and have the full address in the Name box and the Lat,Lng in the location box.

All works fine in the Create view, but in the Edit view it does not. The location Lat,Lng is not saved in the DB, and the field becomes Null!

Digging a little deeper, DbGeographyModelBinder.BindModelImpl is not called when Save is clicked in the Edit view, while it is from the Create Save.

So it seems something is a miss with the binding, but I'm not sure what.

Organic, have you been able to solve the problem you reported? This seems related.

BTW, this behavior is manifest in the Git download, with non of my changes.

Guidance will be appreciated!
January 10, 2013 12:28
Solved, I think...

The problem was in DbGeography.cshtml

@Html.TextBox("", Model.Latitude.Value.ToString("G",CultureInfo.InvariantCulture) + "," + Model.Longitude.Value.ToString("G",CultureInfo.InvariantCulture), new { @class = "editor-for-dbgeography", disabled = true })}

What was the idea behind disabling the attribute? Or is it a bug.

Anyway, bot Create and Edit work fine now!
February 28, 2013 11:07
What library do you use for the maps?

Thank you
March 23, 2013 8:12
Perfect explanation! It's exactly what I was looking for!
Thanks!!
April 25, 2013 12:55
It just doesn't work out of box. Home/Idex = 404
June 29, 2013 18:20
Does anyone has a sample to dynamicData template to regular aspx

Thanks
August 28, 2013 21:13
I have downloaded the project and i have fix nuget than also i am getting error like this :



HTTP Error 403.14 - Forbidden

Why is this and other project are running correctly and i am using visual studio 2012
October 11, 2013 17:29
Hi Scott i am getting following error

The type 'System.Data.Objects.ObjectContext' is defined in an assembly that is not referenced. You must add a reference to assembly 'System.Data.Entity, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089'. K:\Projects\DemoProject\Demo.DbProvider\Data\DbResourceContext.cs 13 16 Demo.DbProvider

Even after adding

<compilation debug="true" targetFramework="4.0">
<assemblies>
<add assembly="System.Data.Entity, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"/>
</assemblies>
</compilation>

To my web.config file.

This is separate project just a class library

Any help will be highly appropriated

Thanks

October 11, 2013 17:35
Sorry to bother you Scott

As this was class library i have to add the reference of system.data.entity manually that resolved the issue
November 13, 2013 22:26
Trying to get this to work in VS 2013. Edited modelbinder to check for empty string:
if (string.IsNullOrEmpty(value))
and instead of registering the assembly in views/web.config, added
@using System.Data.Entity.Spatial
@model DbGeography (replaces @model System.Data.Spatial.DbGeography since in EF6 it moved)
to the top of the templates.
Looks like it's working, probably have to fix style class for Bootstrap 3.
Great work, Scott et al.
AK

Comments are closed.

Disclaimer: The opinions expressed herein are my own personal opinions and do not represent my employer's view in any way.