Last time we built a client-side Blazor site with an interactive map from the ground up. But for the website to help drivers, it needs more functionality. It should check where visitors are parking their cars and tell them how much and where they can pay. Otherwise, they would get a fine or their car may be towed. Can Blazor handle it?
In the previous article, I started building a website with an interactive map of Brno, my hometown. The site should automatically check a visitor's position and explain the rules for parking in the area. If applicable, it should show the nearest parking machines on the map.
I explained the data model and some of the key functionalities you need when working with Blazor like Blazor templates, JS Interop, Dependency Injection, and NuGet.
Today, I will explain more advanced features. The remaining tasks of the project are:
- Find the zone by the visitor's GPS coordinates
- Check applicable restrictions
- Display nearby parking machines
Matching the GPS coordinates with areas' polygons
We know where the visitor of the site is standing. The coordinates are either shared from a GPS module in his or her mobile device or provided by clicking on a spot in the interactive map. On the other side (i.e. in the CMS) there are all areas and coordinates of their borders.
To do the point inside polygon calculations, I created a simple helper class Helpers/ZoneHelper.cs.
public static class ZoneHelper {
public static bool IsPointInside(Location point, Zone zone)
{
// pre-check using a minimum bounding box
if (point.Latitude < zone.LatMin || point.Latitude > zone.LatMax || point.Longitude < zone.LonMin || point.Longitude > zone.LonMax)
{
return false;
}
var polygon = zone.Points;
int i, j;
bool c = false;
for (i = 0, j = polygon.Count - 1; i < polygon.Count; j = i++)
{
if ((((polygon[i].Latitude <= point.Latitude) && (point.Latitude < polygon[j].Latitude)) || ((polygon[j].Latitude <= point.Latitude) && (point.Latitude < polygon[i].Latitude))) && (point.Longitude < (polygon[j].Longitude - polygon[i].Longitude) * (point.Latitude - polygon[i].Latitude) / (polygon[j].Latitude - polygon[i].Latitude) + polygon[i].Longitude))
{
c = !c;
}
}
return c;
}
}
Note: The code for point in polygon inclusion was taken from here.
You see this code already uses a model for Zone. It's a POCO class (no functionality, just a placeholder for data) in Models/Zone.cs:
public class Zone {
public decimal LatMin { get; set; }
public decimal LatMax { get; set; }
public decimal LonMin { get; set; }
public decimal LonMax { get; set; }
public List<Location> Points { get; set; }
public string Name { get; set; }
}
If you're also using a headless CMS, you can generate the models from content type definitions stored in the CMS. However, I prefer to create my own models and map them in the implementation as they are more flexible.
To get the data of zones from a CMS, I created a new service Services/ICloudDeliveryService.cs. It's intentionally an interface to provide me with some abstraction around the headless system I want to use. That gives me the possibility of switching to a different provider in the future.
At this time I'm using Kontent to store all the data. If you're interested in the actual implementation of data gathering, take a look at the GitHub repository.
public interface ICloudDeliveryService
{
Task<IEnumerable<T>> GetAllItems<T>();
}
The cloud delivery service only gathers data via API. We still need to obtain the areas' data and provide a method to get a matching area by GPS coordinates. That will be called by the front-end when we know the GPS location or the visitor clicks on the map. For that, I created Services/ZoneService.cs.
public class ZoneService
{
private ICloudDeliveryService _cloudDeliveryService;
private static List<Zone> _zones = null;
public ZoneService(ICloudDeliveryService cloudDeliveryService)
{
_cloudDeliveryService = cloudDeliveryService;
}
public async Task<IReadOnlyList<Zone>> GetAllZonesAsync()
{
if (_zones == null)
{
_zones = await _cloudDeliveryService.GetAllItems<Zone>();
}
return _zones;
}
public async Task<Zone> GetZoneByPoint(Location point)
{
var zones = await GetAllZonesAsync();
return zones.FirstOrDefault(z => ZoneHelper.IsPointInside(point, z));
}
}
The front-end will use GetZoneByPoint
method that will find the respective zone the visitor is standing in. All services need to be registered in Startup.cs:
public void ConfigureServices(IServiceCollection services)
{
...
services.AddSingleton<ZoneService>();
services.AddSingleton<ICloudDeliveryService, KontentDeliveryService>();
}
And finally, we can update the front-end part of the website. The new ZoneService needs to be injected:
[Inject]
protected ZoneService ZoneService { get; set; }
And let's also add a property that will hold the current zone. That enables us to show details of the zone on the page:
public Zone CurrentZone { get; set; }
Next, find the SetLocation method. This method is invoked anytime the map is clicked, i.e. the GPS coordinates changed, or the visitor's device got a lock on the current position. At that time we want to check in which area the visitor stands.
protected async Task SetLocation(Location location)
{
Console.WriteLine($"Check this position: {location.Latitude} {location.Longitude}");
Latitude = location.Latitude;
Longitude = location.Longitude;
CurrentZone = await ZoneService.GetZoneByPoint(location);
StateHasChanged();
}
And display the zone details on the page:
@if (CurrentZone != null)
{
@:You are in the zone @CurrentZone.Name
}
else
{
@:You can park here freely.
}
Now we're ready to check how it all works on the site. Click anywhere around the city center on the map, you should see the zone name if there is one.
Checking Restrictions of a Zone
Even though Brno with its population around 370k is not that big and zones are applied only within the city center, there are currently many parking zones to ensure parking permits are issued only for the specific areas where residents live. This does not apply to visitors though. From their standpoint there are only 3 zones - A, B and C - and they only define how much they need to pay per hour of parking.
This is almost like a puzzle. Resident zones are the small pieces you need to put together and visitor zones (A, B, C) are the template. I added the visitor zones into the CMS and linked them to the respective resident zones (1-02 on the picture).
The restriction is, in fact, a string field with a description of the rules that we need to display to visitors. Because it's a new type of content it needs to have its own model in Models/VisitorRestriction.cs:
public class VisitorRestriction
{
public string Description { get; set; }
}
And as I linked it into the Zone, its model needs to be updated too:
public class Zone
{
...
public List<VisitorRestriction> VisitorRestrictions { get; set; }
}
The rest of the data gathering and transforming is handled by mapping configuration, for details check the GitHub repo.
As there may be multiple restrictions, the front-end needs to iterate over all restrictions and display them:
...
@if (CurrentZone != null)
{
@:You are in the zone @CurrentZone.Name
@if (CurrentZone.VisitorRestrictions.Any())
{
@string.Join("<br />", CurrentZone.VisitorRestrictions.Select(r => r.Description));
}
}
...
The website should now display information about the zone and the rules of parking there:
Displaying nearby parking machines
Now that visitors know what are the rules for the place where they are standing, it's good to guide them to the nearest parking machine where they need to register their car.
Parking machines and their GPS coordinates are already stored in the headless CMS as you see in the picture above. We will start with the same routine as before - create a model in Models/ParkingMachine.cs:
public class ParkingMachine
{
public string Name { get; set; }
public Location Location { get; set; }
}
And add a new service Services/ParkingMachineService.cs for getting the parking machines from the CMS:
public class ParkingMachineService
{
private ICloudDeliveryService _cloudDeliveryService;
private List<ParkingMachine> _parkingMachines = null;
public ParkingMachineService(ICloudDeliveryService cloudDeliveryService)
{
_cloudDeliveryService = cloudDeliveryService;
}
public async Task<List<ParkingMachine>> GetAllParkingMachinesAsync()
{
if (_parkingMachines == null)
{
_parkingMachines = await _cloudDeliveryService.GetAllItems<ParkingMachine>();
}
return _parkingMachines;
}
public async Task<List<ParkingMachine>> GetNearestParkingMachines()
{
...
}
}
You see we need to provide implementation limiting the whole set of parking machines to the two nearest ones. In order to do that, we need to be able to compare distances of two Location objects - GPS coordinates of each of the parking machines and visitor's location. That's a job for the new Helpers/GeoHelper.cs:
public static class GeoHelper
{
public static double GetDistanceTo(this Location source, Location target)
{
var sourceCoords = new GeoCoordinate(Convert.ToDouble(source.Latitude), Convert.ToDouble(source.Longitude));
var targetCoords = new GeoCoordinate(Convert.ToDouble(target.Latitude), Convert.ToDouble(target.Longitude));
return sourceCoords.GetDistanceTo(targetCoords);
}
}
The GeoCoordinate class is part of the .NET framework for portable devices and is not available in web development. To overcome this problem, I installed a NuGet package GeoCoordinate.NetCore
which is 1:1 API compliant implementation of the library.
Then we can filter the list based on the distance to the visitor:
public async Task<List<ParkingMachine>> GetNearestParkingMachines(Location point)
{
var parkingMachines = await GetAllParkingMachinesAsync();
return parkingMachines.OrderBy(p => p.Location.GetDistanceTo(point)).Take(2).ToList();
}
As always, we need to register the service in Startup.cs:
public void ConfigureServices(IServiceCollection services)
{
...
services.AddSingleton<ParkingMachineService>();
}
As we want to display the parking machines on the map and not just display their GPS coordinates in text, we will need a bit of JavaScript that handles that task within the map itself. The JS function expects two Location objects and you can explore its implementation here.
The important step of the implementation on Blazor's side is to obtain the nearest parking machines and invoke the JS function. We can do this in the code-behind of Map page within the SetLocation method that is invoked with every location change:
[Inject]
protected ParkingMachineService ParkingMachineService { get; set; }
...
protected async void SetLocation(Location location)
{
...
var parkingMachines = await ParkingMachineService.GetNearestParkingMachines(location);
if (parkingMachines.Count > 1)
{
await JSRuntime.InvokeAsync<object>("mapSetParkingMachines", parkingMachines[0].Location, parkingMachines[1].Location);
}
StateHasChanged();
}
When you run the site after this last step, anytime you click the map you should see the underlying visitor zone, details about the parking restrictions and see markers of two nearest parking machines where you need to register your car.
Blazor is Similar to WebForms
Although features, like matching GPS coordinates with areas' polygons and finding the nearest parking machine, sounded complicated, with Blazor it was not a hard task to accomplish. From a developer's point of view, Blazor resembles WebForms (which everybody frowns upon). I personally really appreciate the ability to use .NET code and practices that allow me to be productive and actually enjoy working on the project.
If you found the project interesting, I invite you to check out the whole GitHub repository and my articles where I describe other Blazor features.
Other articles in the series:
- #1 What is this Blazor everyone's talking about?
- #2 How to Build an Interactive SPA with Blazor
- #3 Avoiding Parking Fines with Blazor and Geolocation (this article)
- #4 Deploying Your Blazor Application to Azure
Top comments (0)