The previous recipe showed us how a map can be used to display live information, updated at a relatively high frequency, in a scenario where the user is passive in practice and just observes what a server is broadcasting. In this recipe, we want to explore a much more interactive scenario, where the information displayed on the map is provided by the users.
This application can be used to place the name and a picture of a pet that has been lost on a map. These details will be added to other users' maps in real time. Anybody observing the map could drag-and-drop any marker on a different position to notify that the pet was there. Finally, the user who first raised an alarm about a specific lost pet can declare that he/she found it, and this action will correspond to the removal of the markers related to that specific animal from all the connected maps. This is illustrated in the following screenshot:
From a SignalR's perspective, we'll use the following features:
This example is quite interesting because it puts together a consistent amount of features that we've been analyzing throughout the book.
Our application consists of the following:
Hub
called PetsFinderHub
, which will be used to notify about lost pets' sightings in real time and to subscribe to specific pets in order to get notifications about their movements.IPetsFinder
interface, with a proper implementation and a couple of supporting types. This will be the core of the application, with the responsibility of storing information about the pets and supplying it back to PetsFinderHub
when required.AuthorizeLoggedAttribute
.IUserIdProvider
.index.html
, with a Google Map on it.pets.js
that contains the client-side logic around the interactions with SignalR and the Google Map, plus some extra bits to manipulate images.pets.css
.Startup
class to set up dependencies and start SignalR, proper server and client references, and so on).In order to proceed, you'll have to build a new empty web application and call it Recipe50
. Before proceeding, you should be aware of the fact that this sample uses the new HTML5 FileReader
API to read the content of an image file that is dropped onto a div
element. Only modern browsers support it, and therefore, this sample cannot work on older browsers. Please check http://caniuse.com/filereader to check whether your browser will support this sample.
First of all, we reference the Microsoft.AspNet.SignalR
NuGet package to have everything that we need to run SignalR in our application. We'll also use a Google Map widget that requires a personal API key; for more details about it, please refer to the previous recipe. Let's proceed with the following steps:
index.html
page to our project, with the following content:<!DOCTYPE html> <html> <head> <title></title> <link type="text/css" href="/Styles/pets.css"rel="stylesheet"/> <script src="Scripts/jquery-2.1.0.js"></script> <script src="Scripts/jquery.signalR-2.0.2.js"></script> <script src="https://maps.googleapis.com/maps/api/js? key=YOUR_API_KEY&sensor=false"></script> <script src="/signalr/hubs"></script> <script src="/Scripts/pets.js"></script> </head> <body> <div id="map"></div> <div id="panel"> <div id="login-panel"> <label>Nickname:</label> <input type="text" id="user"/> <button id="login">Log in</button> </div> <div id="logged-panel"> <label>Nickname:</label> <span id="nickname"></span> </div> <div id="lost-panel"> <div id="lost-panel-name"> <h5>Lost your pet?? What's its name?</h5> <label>Name:</label> <input type="text" id="name" /> <button id="proceed">Proceed</button> </div> <div id="lost-panel-photo"> <h5>Drop its photo here...</h5> <div id="photo"></div> </div> <div id="lost-panel-location"> <h5>...and locate it on the map</h5> <p> Position your map in the right area, when ready click on the 'Locate it!' button and then click on the right position on the map. </p> <button id="lost">Locate it!</button> </div> </div> <button id="found">Found!</button> <ul id="messages"></ul> </div> </body> </html>
The page starts with the usual references to JavaScript libraries, plus a reference to the Google Maps endpoint and to a file called pets.js
, which we'll add to the project shortly. The body
section of the page contains the div
element for the map and a series of panels that will be displayed or hidden according to the specific actions that the user will perform on the application. It's pretty long, but there is nothing worth any specific comment.
pets.css
, placed in the Styles
folder:html { height: 100%; } body { height: 100%; margin: 0; padding: 0; } #map { height: 100%; z-index: 1; } #panel { z-index: 10; position: absolute; top: 0; right: 0; width: 300px; margin: 30px; padding: 10px; font-family: "Helvetica"; background-color: lightgreen; opacity: 0.7; } #messages { list-style-type: none; } #messages li { background-color: yellow; width: 100%; margin: 3px; padding: 2px; } #photo { width: 240px; height: 240px; margin: 3px; background-color: red; border: 5px dotted lightgrey; z-index: 2; } #lost-panel, #logged-panel, #lost-panel-photo, #lost-panel-location, #found { display: none; }
Enough with markup and styling; let's move on to see how the server-side portion of the application is actually designed.
Pets.cs
to the project, where we'll add all the types involved in the implementation of the business rules of the application. Inside Pets.cs
, we first add the Location
and Pet
types, which are straightforward and do not need any particular explanation:public class Location { public Location(DateTime when, PointF where) { When = when; Where = where; } public PointF Where { get; private set; } public DateTime When { get; private set; } } public class Pet { private readonly ICollection<Location> _locations; public Pet( string user, string name, string photo, DateTime when) { User = user; Name = name; Photo = photo; When = when; _locations = new Collection<Location>(); } public string User { get; private set; } public string Name { get; private set; } public string Photo { get; private set; } public DateTime When { get; set; } public bool Found { get; set; } public string Id { get { return BuildId(User, Name, When); } } public IEnumerable<Location> GetLocations() { return _locations; } public Location AddLocation(Location location) { _locations.Add(location); return location; } public static string BuildId( string user, string name, DateTime now) { return string.Format( "{0}#{1}#{2}", user, name, now); } }
public interface IPetsFinder { Pet Lost( string user, string name, PointF location, string photo); Pet Seen(string id, PointF location); void Found(string id); IEnumerable<Pet> GetPets(); }
These methods are there to notify about a new lost pet, add a new sighting, record a finding, and enumerate all the pets recorded in the application.
public class PetsFinder : IPetsFinder { private readonly IDictionary<string, Pet> _pets = new Dictionary<string, Pet> (); public Pet Lost( string user, string name, PointF location, string photo) { var now = DateTime.UtcNow; var id = Pet.BuildId(user, name, now); if (_pets.ContainsKey(id)) return null; var pet = new Pet(user, name, photo, now); _pets.Add(id, pet); _pets[id].AddLocation(new Location(now, location)); return pet; } public Pet Seen(string id, PointF location) { var pet = _pets[id]; pet.AddLocation( new Location(DateTime.UtcNow, location)); return pet; } public void Found(string id) { _pets[id].Found = true; } public IEnumerable<Pet> GetPets() { return from id in _pets.Keys let pet = _pets[id] where !pet.Found select pet; } }
The service is simple, and it uses a dictionary to store information about the pets in the memory. A proper implementation would, of course, use a more resilient system. The rest of the code should be quite self explanatory.
Before getting to see the implementation of PetsFinderHub
, let's quickly implement a custom and the naïve authentication and authorization system; for this, we'll override the IUserIdProvider
service.
UserIdProvider
that contains the following code:public class UserIdProvider : IUserIdProvider { public string GetUserId(IRequest request) { return request.QueryString["user"]; } }
We already saw this approach earlier; we use a custom query string parameter placed on the SignalR endpoint by the client to transport that information about the current user at every request. It's a simplified approach, of course, but it's enough for us.
Now that the application logic is ready, let's use it from the application hub using the following steps:
PetsFinderHub
to the project:using System.Drawing; using System.Threading.Tasks; using Microsoft.AspNet.SignalR; using Microsoft.AspNet.SignalR.Hubs; namespace Recipe50 { [HubName("pets")] public class PetsFinderHub : Hub { private readonly IUserIdProvider _userIdProvider; private readonly IPetsFinder _petsFinder; public PetsFinderHub( IUserIdProvider userIdProvider, IPetsFinder petsFinder) { _userIdProvider = userIdProvider; _petsFinder = petsFinder; } ... } }
The constructor requires a reference of both IPetsFinder
and IUserIdProvider
contracts, which are then stored in the instance fields. We put some dots to indicate where the remaining methods will be added.
public override Task OnConnected() { foreach (var pet in _petsFinder.GetPets()) { Clients.All.pet(new { pet.Id, pet.User, pet.Name, pet.Photo, Locations = pet.GetLocations() }); } return base.OnConnected(); }
The OnConnected
override retrieves a list of the pets who the application has already been notified about, each one with its sightings, and sends it to the caller by triggering the client side pet
callback.
PetsFinderHub
about it:public void Lost( string name, float latitude, float longitude, string photo) { var user = _userIdProvider.GetUserId(Context.Request); var location = new PointF(latitude, longitude); var pet = _petsFinder.Lost( user, name, location, photo); if (pet == null) return; Clients.All.pet(new { pet.Id, pet.User, pet.Name, pet.Photo, Locations = pet.GetLocations() }); }
We first ask the _userIdProvider
service to resolve the name of the current user and then we supply the information about the pet who just got lost to the _petsFinder
service, which will properly store it. If everything goes fine, we notify all the connected clients about this new lost animal using the pet
callback.
public void Seen( string id, float latitude, float longitude) { var location = new PointF(latitude, longitude); var pet = _petsFinder.Seen(id, location); Clients.All.pet(new { pet.Id, pet.User, pet.Name, pet.Photo, Locations = pet.GetLocations() }); }
After having contacted the _petsService
service with the information related to the new position of a specific pet, we notify all the connected clients about it, triggering the client-side pet
callback.
Found()
method, shown in the following code snippet, will allow us to notify the application about this event:public void Found(string id) { _petsFinder.Found(id); Clients.All.found(id); }
Similar to the previous methods, the connected clients will be notified about the finding by the found
callback.
Startup
class. We use the dependency injection strategies that we learned earlier in Chapter 7, Analyzing Advanced Scenarios, to put all the components together, as shown in the following code snippet:public void Configuration(IAppBuilder app) { GlobalHost.DependencyResolver.Register( typeof(IUserIdProvider), () => new UserIdProvider()); var petsFinder = new PetsFinder(); GlobalHost.DependencyResolver.Register( typeof(IPetsFinder), () => petsFinder); GlobalHost.DependencyResolver.Register( typeof(PetsFinderHub), () => new PetsFinderHub( new UserIdProvider(), petsFinder)); app.MapSignalR(); }
Now that we are done with the server-side portion of the application, let's move back to the client side to add the pets.js
file to the Scripts
folder and fill its content. We'll use the following steps to do this:
$(function () { var hub = $.connection.hub, pets = $.connection.pets, map = new google.maps.Map($("#map")[0], { center: new google.maps.LatLng( 40.760976, -73.969041), zoom: 14 }), params = {}, locations = {}; ... })
Among other things, here, we center the map on a specific location and set its initial zoom level.
click
event handlers to the buttons that are displayed over the map: $('#login').click(function() {
params.user = $('#user').val();
hub.qs = { user: params.user };
hub.start()
.done(function () {
$('#nickname').text(params.user);
$('#login-panel').toggle();
$('#logged-panel').toggle();
$('#lost-panel').toggle();
});
});
$('#proceed').click(function () {
params.name = $('#name').val();
$('#lost-panel-photo').toggle();
});
$('#lost').click(function () {
params.adding = true;
});
$('#found').click(function () {
pets.server.found(params.id);
});
The only interesting line here is from the login
button, where we use the qs
member of the hub
variable to set the user
member just before we start the connection. This way, we guarantee that its value will be sent to the server at every request, allowing the UserIdProvider
method, which we defined earlier, to work as expected.
div
element called photo
. The following is how this can be set up:$('#photo') .on('dragover', function () { return false; }) .on('dragend', function () { return false; }) .on('drop', function (s) { var e = s.originalEvent; e.preventDefault(); drop(e.dataTransfer.files); $('#lost-panel-location').toggle(); });
Later, we'll see the drop()
function in detail; here, we just underline the fact that we can easily send images back and forth with SignalR using their inline Base64-encoded representation, which is what we do here.
At the moment of this writing, there are some limitations to this technique. In particular, strings passed to a hub cannot exceed a limit in size whose value might depend on the transport strategy. Inline images can easily exceed this limitation, and therefore, before taking architectural decisions on this feature, you should carefully verify its limitations.
click
events on the map, which are used to mark where a pet got lost:google.maps.event.addListener(map, 'click', function (me) { if (!params.adding) return; pets.server.lost(params.name, me.latLng.lat(),me.latLng.lng(), params.image); params.adding = false; $('#photo').empty(); $('#lost-panel-photo').toggle(); $('#lost-panel-location').toggle(); });
All the details that have been collected about the lost pet so far are sent to the PetsFinderHub
, including a string that contains the Base64-encoded image.
pets.client.pet = function (lost) { if (locations[lost.Id]) { $.each(locations[lost.Id].markers, function(mi, m) { m.setMap(null); }); } locations[lost.Id] = { id: lost.Id, markers: [] }; $.each(lost.Locations, function (li, location) { var image = new Image(); image.src = lost.Photo; var marker = new google.maps.Marker({ icon: framed(image, li ? 'green' : 'red'), map: map, title: lost.name, draggable: true, position: new google.maps.LatLng( location.Where.X, location.Where.Y) }); google.maps.event.addListener(marker, 'click', function () { var m = lost.Name + ' was here, time: ' + location.When; $('#messages') .append($('<li/>').text(m)); $('#found').toggle( lost.User == params.user); params.id = lost.Id; }); google.maps.event.addListener(marker, 'dragend', function(me) { pets.server.seen(lost.Id, me.latLng.lat(), me.latLng.lng()); }); locations[lost.Id].markers.push(marker); }); };
For each location provided along the detail of the pet, a new custom marker is created on the map. The custom marker will contain the picture of the pet, which was built using the framed()
function that we'll see later, and it will allow users to interact with it in a couple of ways:
Seen()
server-side method.found
callback will remove all the markers of the rescued pet from the map:pets.client.found = function (id) { $.each(locations[id].markers, function (mi, m) { m.setMap(null); }); locations[id] = {}; };
function drop(files) { var reader = new FileReader(); reader.onload = function (event) { var image = new Image(); image.src = event.target.result; params.image = resized(image); image.src = params.image; $('#photo').append(image); }; reader.readAsDataURL(files[0]); } function resized(img) { var canvas = document.createElement('canvas'), maxWidth = 180, maxHeight = 180, size = { width: img.width, height: img.height }; size = size.width > size.height ? size.width > maxWidth ? { height: Math.round( size.height * maxWidth / size.width), width: maxWidth } : size : size.height > maxHeight ? { width: Math.round( size.width * maxHeight / size.height), height: maxHeight } : size; canvas.width = size.width; canvas.height = size.height; var ctx = canvas.getContext("2d"); ctx.drawImage(img, 0, 0, size.width, size.height); return canvas.toDataURL("image/jpeg", 0.7); } function framed(image, color) { var canvas = document.createElement('canvas'), size = { width: image.width / 2 + 20, height: image.height / 2 + 20 }; canvas.width = size.width; canvas.height = size.height; var ctx = canvas.getContext("2d"); ctx.beginPath(); ctx.fillStyle = color; ctx.rect(0, 0, size.width, size.height); ctx.fill(); ctx.drawImage( image, 10, 10, size.width - 20, size.height - 20); ctx.fillStyle = "yellow"; ctx.beginPath(); ctx.moveTo(size.width / 2, size.height); ctx.lineTo(size.width / 2 + 7, size.height - 20); ctx.lineTo(size.width / 2 - 7, size.height - 20); ctx.closePath(); ctx.fill(); return canvas.toDataURL("image/jpeg", 0.7); }
The drop()
function is interesting because it uses the new HTML5 FileReader
API to read the content of the image file dropped onto the div
element named photo
. The resized()
and framed()
functions leverage the Base64-encoded image format and the canvas
capabilities to process the pictures in order to both send them to PetsFinderHub
on the server and use them to create custom map markers on the client.
We are done and are now ready to test the application by building the project and opening the index.html
page in multiple windows. From each of these windows, we can log in to the system and use the simple submission wizard to notify about a lost pet. We can drag-and-drop markers around to let the application know about the new positions, and the owner of the pet can eventually declare the pet as found. This way, its markers will be removed. All these actions are constantly propagated to every connected map in real time. Clicking on a marker will subscribe the user to text messages that alert about a new sighting related to the corresponding pet.