From database to dashboard

Spatial Data in .NET

SPATIAL DATA IN .NET

From database
to dashboard

Store the location, not just the coordinates.

Tim Purdum · dymaptic
TechBash 2026

41.3604° N75.6840° W
Cold open01

A freezer is failing. Who can we send?

A repair company near Scranton dispatches technicians to customer jobs.

URGENT

Summit Sports Catering

Walk-in freezer
temperature alarm

Covers this address.
Available now.
Closest to the job.
The job02

A point is a location. A polygon is an area.

SQL Server can find what is inside an area and measure how far away it is.

POINT · JOBS AND TECHNICIANS

How far from the job?

POLYGON · SERVICE TERRITORIES

Inside this territory?

Spatial data03

SQL Server has stored shapes since 2008

Mature, stable, and already in the database you use.

2008SQL SERVER 2008

geometry and geography column types

2012SQL SERVER 2012

Whole-globe shapes, AUTO_GRID spatial indexes, 48-bit precision

2012ENTITY FRAMEWORK 5

DbGeography, with SQL Server's types installed separately

2018EF CORE 2.2

NetTopologySuite Point and Polygon in your model

TODAYEF CORE 10 · SQL SERVER 2025

No spatial changes in the release notes since EF Core 7 in 2022

If you run SQL Server, you already have spatial types.

Spatial data03A

Build a dispatch dashboard

01SQL SERVER

Store locations

02EF CORE

Find a technician

03API

Send the result

04BLAZOR

Show it on a map

The app04

Use the geography Db type for latitude and longitude

THIS MAP

geography

Locations on Earth
Distance in meters with SRID 4326

FLAT COORDINATES

geometry

Floor plans and local grids
Distance in coordinate units

SRID 4326 identifies WGS 84, our longitude-and-latitude reference.

Store05

Map C# shapes to SQL columns

NetTopologySuite provides the C# spatial types.

public Point Location { get; set; }
public Polygon Boundary { get; set; }
NETTOPOLOGYSUITE

Define the shapes

options.UseSqlServer(
  connectionString,
  sql => sql.UseNetTopologySuite());
EF CORE

Enable spatial support

Location geography NOT NULL
Boundary geography NOT NULL
SQL SERVER

Store the values

Store06

How did this address become a point?

Geocode addresses as they are entered into your database.

ADDRESS

Summit Sports Catering

235 Montage Mountain Road
Moosic, Pennsylvania

GEOCODER

Geocoded coordinates

lon -75.683967
lat  41.360406
C# POINT

NetTopologySuite

new Point(-75.683967,
         41.360406)
{ SRID = 4326 }
SQL ROW

Jobs.Location

geography
POINT (-75.683967
  41.360406)

One customer record: an address becomes a queryable point.

Store07

Where does a service territory come from?

Draw your own areas, or import boundaries you already have.

DRAWN

North Valley

Four vertices on a map

STORED RING
POLYGON ((-75.830000 41.352000,
  -75.610000 41.352000,
  -75.585000 41.500000,
  -75.835000 41.500000,
  -75.830000 41.352000))
Boundary geography · SRID 4326
QUERIED LATER
t.Boundary
  .Contains(job.Location)

The same shape every containment query tests

Drawn or imported, it becomes one stored ring.

Store08

Point takes longitude first

NetTopologySuite: new Point(longitude, latitude)

World map with the swapped point in East Antarctica
SWAPPEDnew Point(41.4, -75.7)
75.7° S · 41.4° E · EAST ANTARCTICA
Map with the correct point in Pennsylvania
CORRECTnew Point(-75.7, 41.4)
41.4° N · 75.7° W · PENNSYLVANIA
Store09

Which territory contains the job?

EF Core translates Contains into a SQL spatial test.

LINQ QUERY
List<TerritoryEntity> containingTerritories =
    await db.Territories
        .Where(t => t.Boundary.Contains(job.Location))
        .ToListAsync(cancellationToken);
TerritoryEntity? territory =
    containingTerritories.SingleOrDefault();
GENERATED SQL
SELECT [t].[Id], [t].[Name], [t].[Boundary]
FROM [Territories] AS [t]
WHERE [t].[Boundary].STContains(
  @jobLocation) = CAST(1 AS bit);

The job is in North Valley.

Query10

Spatial query quirks

The seed data avoids all three situations.

Outside every territory

No match: the customer is outside coverage.

Inside two territories

Two matches: an explicit priority, or a review.

On a shared border

A deliberate boundary and ownership rule.

The query reports what the shapes are. Your application decides what that means.

Query11

Who is available and closest?

Filter by territory and availability. Then order by distance.

LINQ
db.Technicians
  .Where(t => t.TerritoryId == territory.Id
    && t.IsAvailable)
  .OrderBy(t => t.Location.Distance(job.Location))
  .FirstOrDefaultAsync();
  1. Nearest, unavailableMorgan Lee · 303 m
  2. Nearest, wrong territoryRiley Chen · 1,129 m
  3. Nearest matchJordan Alvarez · 5,688 m
GENERATED SQL
SELECT TOP(1) [t].[Id], [t].[Name], [t].[TerritoryId]
FROM [Technicians] AS [t]
WHERE [t].[TerritoryId] = @territory_Id
  AND [t].[IsAvailable] = CAST(1 AS bit)
ORDER BY [t].[Location].STDistance(@job_Location);
Query12

What will the database do with this?

Predict the work before the next slide reveals it.

db.Technicians
  .Where(t => t.TerritoryId == territory.Id
    && t.IsAvailable)
  .OrderBy(t => t.Location.Distance(job.Location))
  .FirstOrDefaultAsync();
  1. Rows readEvery row, or a shortcut?
  2. The sortWho sorts, and how many rows?
  3. The indexDoes one even get used?
Query13

Sorting by distance can still scan every row

Prepared plan: 200,000 technicians

SQL Server execution plan showing a clustered index scan
Query14

Use a spatial index to narrow the search

  1. Find candidate cellsSkip unrelated areas
  2. Check the shapesTest containment or distance
HAND-WRITTEN MIGRATION SQL
CREATE SPATIAL INDEX SIX_Territories_Boundary
  ON Territories(Boundary)
  USING GEOGRAPHY_AUTO_GRID;
Query15

Add a distance filter to use the spatial index

45.3 km covers this company's service area.

SQL Server execution plan showing a spatial index seek
Query16

Send shapes to the map and a result to the app

MAP

GeoJSON

Territory boundaries
Technician locations

DISPATCH RESULT

JSON

North Valley
Jordan Alvarez
5,688 meters

API17

GeoJSON pairs a shape with its properties

Geometry

Where to draw

Properties

What to label

{
  "type": "Feature",
  "geometry": {
    "type": "Point",
    "coordinates": [-75.683967, 41.360406]
  },
  "properties": { "name": "Summit Sports Catering" }
}

Coordinates use WGS 84, longitude first.

API18

Follow a territory from the database to the map

One Minimal API endpoint turns EF Core rows into GeoJSON features.

1ENDPOINTApi/MapLayerEndpoints.cs
layers.MapGet("/territories", async (DispatchDbContext db,
    CancellationToken cancellationToken) =>
{
    List<Territory> territories = await db.Territories
        .OrderBy(t => t.Id)
        .ToListAsync(cancellationToken);
    return MapLayerFeatures.ForTerritories(territories);
});
2FEATURESMapLayerFeatures.cs
AttributesTable attributes = new()
{
    { "TerritoryId", territory.Id },
    { "Name", territory.Name }
};
collection.Add(
    new Feature(territory.Boundary, attributes));
3SERIALIZERProgram.cs
builder.Services.ConfigureHttpJsonOptions(options =>
    options.SerializerOptions.Converters.Add(
        new GeoJsonConverterFactory()));
4ON THE WIREGET /api/layers/territories
{ "type": "FeatureCollection",
  "features": [{
    "type": "Feature",
    "geometry": { "type": "Polygon",
      "coordinates": [[[-75.83, 41.352], …]] },
    "properties": { "TerritoryId": 1,
                    "Name": "North Valley" } },
  … ] }

TerritoryId is a contract: the map's highlight filters on it.

API18A

Keep large maps quick to load

DATABASE

Keep full boundaries

REQUEST

Filter to the visible area

await db.Territories
  .Where(t => t.Boundary
    .Intersects(view))
  .ToListAsync();
DISPLAY

Simplify for this zoom

TopologyPreservingSimplifier
  .Simplify(boundary, meters);
API19

Show the dispatch result in Blazor

GeoBlazor adds maps and layers to Blazor.

LOAD

Territories and technicians

<GeoJSONLayer Url="@LayerSource.TerritoriesUrl" />
<GeoJSONLayer Url="@LayerSource.TechniciansUrl" />
SELECT

The customer's job

await graphicsLayer.Add(new Graphic(
    new Point(job.Longitude, job.Latitude),
    new SimpleMarkerSymbol(JobFill, 18)));
HIGHLIGHT

Territory and technician

<GeoJSONLayer Url="@LayerSource.TerritoriesUrl"
    DefinitionExpression="@($"TerritoryId = ${id}")"
    Opacity="0.55" />
DRAW

Route to the job

await graphicsLayer.Add(new Graphic(
    new Polyline([new MapPath(path)]),
    new SimpleLineSymbol(RouteColor, 4)));
await mapView.GoTo([routeGraphic]);
Map20

Jordan is the closest available match

Spatial Dispatch dashboard with North Valley selected and Jordan Alvarez recommended
Map21

Nearest by distance may take longer to drive

This demo selects a technician first, then requests a route.

SQL SERVER

Choose the technician

Geographic distance
Jordan · 5.7 km

ROUTING SERVICE

Draw the trip

Map22

Let a routing service rank by drive time

ArcGIS routing services answer the questions straight-line distance cannot.

CLOSEST FACILITY

Who gets to this job first?

Ranks up to 10 technicians per job by travel time, in one direct request.

SERVICE AREA

Where can a technician reach in 15 minutes?

Returns drive-time polygons, also called isochrones. GeoBlazor Pro wraps it as ServiceAreaService.

VEHICLE ROUTING PROBLEM

Who visits which jobs today, in what order?

Assigns a day's jobs to a fleet and orders each van's stops: the traveling salesman, solved as a service.

Let SQL Server shortlist the candidates. Pay for drive times on the shortlist.

Map22A

Send someone to fix the freezer

01SELECT

The urgent job

02FIND

North Valley

03RECOMMEND

Jordan · 5.7 km

04ASSIGN

Jordan to the job

Live demo23

What if we use PostgreSQL?

PostGIS answers the same territory and nearest-technician questions.

SQL SERVER
options.UseSqlServer(
  connectionString,
  sql => sql.UseNetTopologySuite());

Boundary.STContains(job)
meters via geography

POSTGIS · NPGSQL
options.UseNpgsql(
  connectionString,
  sql => sql.UseNetTopologySuite());

ST_Contains(Boundary, job)
same NetTopologySuite LINQ

Carries over: the C# model, the LINQ shape, the GeoJSON pipeline.

Check first: PostGIS defaults to geometry — opt into geography for meter distances. Index with GiST. Confirm translation coverage.

Other engines: Azure SQL Database runs the SQL Server engine. SQLite stores shapes but translates few spatial methods.

Next steps24

What if someone already publishes the spatial data?

The company keeps its boundaries in a GIS and serves them.

MAINTAIN

The GIS team draws territories

PUBLISH

A feature service on ArcGIS

CONSUME

Load it like any layer

MAP / DATA SERVICES

Boundaries and locations your app reads again and again. The dashboard's GeoJSON endpoint is one of these.

TRANSIENT SERVICES

Geocoding and routing, called per request — like this demo's single route call.

Next steps25

How much map can I build in Blazor?

The dashboard already uses layers, graphics, selection, and events.

<MapView Longitude="@ViewLongitude"
         Latitude="@ViewLatitude" Zoom="@ViewZoom">
    <Map>
        <Basemap>
            <OpenStreetMapLayer />
        </Basemap>
        <GeoJSONLayer Url="@LayerSource.TerritoriesUrl"
                      Title="Service territories" />
        <GeoJSONLayer Url="@LayerSource.TechniciansUrl"
                      Title="Technicians" />
        <GraphicsLayer @ref="_graphicsLayer" Title="Dispatch" />
    </Map>
</MapView>
DispatchMap.razor · trimmed
IN THIS DEMO
  • A MapView with a GeoJSONLayer per data set
  • A GraphicsLayer draws the job, technician, and route
  • The dispatch result's territory ID drives the highlight
GOING FURTHER · PRO
  • Sketch widget: draw and edit territory boundaries
  • Feature forms and tables for GIS-style workflows
  • This demo already requires a Pro license

A published ArcGIS feature service can feed the same layers.

Next steps26

Add spatial queries to your .NET app

Store

SQL geography

Model

C# Point / Polygon

Query

Contains / Distance

Send

GeoJSON + result

Display

Blazor map

Takeaways27

Check these in your own app

01

Coordinate order

Longitude first for our Point and GeoJSON.

02

Distance units

Meters for this geography data.

03

SQL translation

Check the generated query.

04

Index use

Inspect the execution plan.

Takeaways28

Spatial is just a data type.

Questions

Wrap29

Questions

QR code that opens the write-up for this session.
WRITE-UP

Write-up and links

timpurdum.dev/post/2026/10/15/techbash.html

Sample app: github.com/dymaptic/GeoBlazor-Samples · projects/SpatialDispatch

Wrap30

Running the sample

GeoBlazor-Samples · projects/SpatialDispatch

CONFIGURE
SpatialDispatch/
  appsettings.Development.json.template
SpatialDispatch.Client/wwwroot/
  appsettings.Development.json.template

Copy each template without .template. The host's copy holds the connection string; the client's holds GeoBlazor:LicenseKey. Both are gitignored.

RUN
cd projects/SpatialDispatch
docker compose up -d
dotnet run --project SpatialDispatch

Startup waits for SQL Server, applies the migration, and seeds the data.

Needs: the .NET 10 SDK, Docker, and a GeoBlazor Pro license key. A free Core key won't load Pro.

Without a key: the map asks for one, but the dispatch answer, the candidates, and assignment still work.

Optional: on Windows, set ConnectionStrings__DispatchDatabase to LocalDB instead of running the container. An ArcGISApiKey adds road routing; without it, a dashed connector.

AppendixA1

Reproducing the index investigation

Scratch data behind the plans on slides 14 and 16

RANKING
WHERE [t].[Location].STDistance(@jobLocation)
  <= 45269
ORDER BY [t].[Location].STDistance(@jobLocation)

STDistance in WHERE and the first ORDER BY
200,000 → 13,211 rows read

CONTAINMENT
List<TerritoryEntity> matches = await db.Territories
    .Where(t => t.Boundary.Contains(job.Location))
    .ToListAsync(cancellationToken);
TerritoryEntity? territory = matches.SingleOrDefault();

SingleOrDefaultAsync emits TOP(2), which chose a scan
15,918 → 24 rows read

Scratch data: 200,000 technician points and 40,000 territory polygons, with the real columns, clustered primary keys, and GEOGRAPHY_AUTO_GRID indexes.

Setup traps: no clustered primary key raises error 12008. Script with sqlcmd -I, or CREATE SPATIAL INDEX fails with error 1934.

Your data: 45 km is the diagonal of this company's territories, not a general rule. Measure at your own row counts.