Skip to content
Shiny.NET

Maps & Directions

NuGet package Shiny.AppDeviceBridge.Maps NuGet package Shiny.AppDeviceBridge.Maps.Blazor NuGet package Shiny.AppDeviceBridge.Maps.Valhalla

The maps and directions bridges give the web app a vector map that works online by default and offline where the user downloaded a region, and directions the same way. The page asks for one set of tile URLs and one route endpoint; where the answer comes from is decided in the native app. Every online service is a provider you plug in, so the same page draws OpenStreetMap, Azure Maps or Google Maps without a change. See Providers.

Online Offline
Map OpenStreetMap from your PMTiles archive or any {z}/{x}/{y} tile service, Azure Maps, Google Maps, or a provider of your own from a region the user downloaded, or OpenStreetMap tiles already seen online
Labels and icons glyphs and sprites fetched once and kept the region catalog’s asset pack
Traffic flow from TomTom, HERE or Azure Maps, incidents from TomTom, or providers of your own empty
Directions Valhalla (your own server, or a hosted one such as Stadia Maps), Azure Maps, Google Maps, or a provider of your own Valhalla running on the phone over a region’s downloaded road network
Addresses Nominatim, Azure Maps, Google Maps, or a provider of your own —

Downloaded regions are OpenStreetMap in the Protomaps schema, whichever online provider you pick; the bridge reports the attribution each source requires.

Package What it is
Shiny.AppDeviceBridge.Maps the bridges: /_bridge/maps and /_bridge/directions, region downloads, and the built-in providers
Shiny.AppDeviceBridge.Maps.Valhalla on-device directions: valhalla-mobile’s engine for Android and iOS
Shiny.AppDeviceBridge.Maps.Client contracts and typed clients: IMapsBridge, IDirectionsBridge (MapsBridge, DirectionsBridge in TypeScript)
Shiny.AppDeviceBridge.Maps.Blazor <BridgeMap>: MapLibre GL JS, bundled so it draws offline, with pins, shapes, drawing and routes
Shiny.AppDeviceBridge.AspNetCore MapMapPacks(): the signed region catalog and its files, beside your web app releases
Shiny.AppDeviceBridge.MapPacks shiny-map-packs: builds regions — the map cut from a planet, the road network built with Valhalla
Android iOS Mac Catalyst macOS Windows Linux
Tiles, glyphs, sprites, regions ✅ ✅ ✅ ✅ ✅ ✅
Online directions ✅ ✅ ✅ ✅ ✅ ✅
On-device directions ✅ ✅ online only online only online only online only

Where the device can’t route, Auto requests go online and Device requests answer 503 offline_unavailable.

Terminal window
dotnet add package Shiny.AppDeviceBridge.Maps
dotnet add package Shiny.AppDeviceBridge.Maps.Valhalla # on-device directions, Android and iOS
builder.UseAppDeviceBridge(
bridge => bridge
.AddMapsBridge(o =>
{
// The online map: OpenStreetMap from a PMTiles archive on any server that answers Range requests (a CDN, S3,
// R2), or a template. Or AzureMapsBasemapProvider, GoogleMapsBasemapProvider — see Providers.
o.Basemap = new ProtomapsBasemapProvider("https://maps.example.com/planet.pmtiles") { MaxZoom = 15 };
// Downloadable regions, signed like your web app releases — usually with the same key.
o.Catalog = new Uri("https://releases.example.com/maps/catalog");
o.CatalogPublicKey = publicKeyPem;
// Online directions: a Valhalla route endpoint. Or AzureMapsRouteProvider, GoogleMapsRouteProvider.
o.Directions.Router = new ValhallaRouteProvider(new Uri("https://api.stadiamaps.com/route/v1")) { ApiKey = stadiaKey };
// Addresses into stops: OpenStreetMap's Nominatim, which asks every app to name itself.
o.Directions.Geocoder = new NominatimGeocoder("MyApp/1.0 (support@example.com)");
})
.AddOnDeviceDirections(),
webApp => { /* … */ }
);

AddMapsBridge can be called more than once; every call configures the same options, so a shared project can set the sources and one head add what only it needs.

MapsOptions Default
Basemap none the online map: ProtomapsBasemapProvider, AzureMapsBasemapProvider, GoogleMapsBasemapProvider, or an IBasemapProvider of your own. See Providers
Catalog, CatalogPublicKey none the region catalog, and the key it must be signed with; the key is required with a catalog
CatalogLifetime 6 hours how long a fetched catalog is used
OnlineAssets Protomaps’ basemaps-assets where glyphs and sprites come from until the asset pack is installed
TileCacheBytes 256 MB OpenStreetMap tiles seen online, kept for offline use, least recently used first to go. Azure Maps’ and Google’s are never kept
DownloadStallTimeout 30 s a download with no data for this long is dropped and resumed
DownloadAttempts 5 resumes in a row that make no progress before a download fails
ConfigureRequest none headers for catalog, region, glyph and sprite requests; each provider configures its own
HttpMessageHandlerFactory HttpClientHandler the handler for outgoing requests
Directions.Router none the online router: ValhallaRouteProvider, AzureMapsRouteProvider, GoogleMapsRouteProvider, or an IRouteProvider of your own
Directions.Timeout 30 s bounds every online route and geocoder search
Directions.Geocoder none addresses into stops: NominatimGeocoder, AzureMapsGeocoder, GoogleMapsGeocoder, or an IGeocoder of your own. See Addresses
Traffic none live traffic flow: TomTomTrafficProvider, HereTrafficProvider, AzureMapsTrafficProvider, or an ITrafficProvider of your own. See Traffic
TrafficIncidents none live incidents: TomTomIncidentProvider, or an ITrafficIncidentProvider of your own

The page never sees these. Every provider’s address and key stays in the native app; the page gets bridge URLs. The bridges are protected by the bridge policy like every other.

The map, the router and the geocoder each come from a provider set on MapsOptions — as traffic does — so changing service is a line of native code, and the page doesn’t change. Every built-in provider keeps its key in the app. Each property can be replaced while the app runs, say from a settings screen; the next request uses the new provider, and the page reads GET /_bridge/maps or GET /_bridge/directions again to see it.

OpenStreetMap Azure Maps Google Maps
Basemap ProtomapsBasemapProvider(url): vector, cached for offline AzureMapsBasemapProvider: Render Get Map Tile, Road, DarkGrey or Imagery GoogleMapsBasemapProvider(key): Map Tiles API 2D tiles, Roadmap, Satellite or Terrain
Directions.Router ValhallaRouteProvider(url): car, bicycle, walking, truck AzureMapsRouteProvider: Route Directions 2025-01-01, car, walking, truck, with live traffic GoogleMapsRouteProvider(key): Routes API, car, bicycle, walking
Directions.Geocoder NominatimGeocoder(userAgent) AzureMapsGeocoder: Get Geocoding 2025-01-01 GoogleMapsGeocoder(key): Geocoding API
Traffic — AzureMapsTrafficProvider —
// All Azure Maps: one credential — the account's shared key, or Microsoft Entra ID — for every Azure provider.
var azure = new AzureMapsCredential(azureMapsKey);
// var azure = new AzureMapsCredential(azureMapsClientId, ct => GetEntraTokenAsync(ct));
bridge.AddMapsBridge(o =>
{
o.Basemap = new AzureMapsBasemapProvider(azure) { Style = AzureMapsBasemapStyle.Road, Language = "en-US" };
o.Directions.Router = new AzureMapsRouteProvider(azure); // UseTraffic = true by default
o.Directions.Geocoder = new AzureMapsGeocoder(azure);
o.Traffic = new AzureMapsTrafficProvider(azure);
});
// All Google: one key with the Map Tiles, Routes and Geocoding APIs enabled.
bridge.AddMapsBridge(o =>
{
o.Basemap = new GoogleMapsBasemapProvider(googleKey) { Style = GoogleMapsBasemapStyle.Roadmap, Language = "en-US", Region = "US" };
o.Directions.Router = new GoogleMapsRouteProvider(googleKey); // UseTraffic = false by default: it costs more
o.Directions.Geocoder = new GoogleMapsGeocoder(googleKey) { Region = "us" };
});

Providers mix freely: Google’s map with Valhalla’s routes, OpenStreetMap’s map with Azure’s geocoder. Check each service’s terms, though. Google’s require its routes and places to be shown on a Google map.

OpenStreetMap comes as vector tiles in the Protomaps schema, the schema downloaded regions use, so the two draw as one map: a tile comes from a downloaded region, then tiles already seen online, then the provider. Azure Maps and Google come as raster images, which <BridgeMap> lays over the downloaded regions. Online the images cover the map. Offline the bridge answers 204 for them, so the regions underneath show through. Azure Maps’ and Google’s terms don’t allow their tiles to be stored, so they’re never kept in the tile cache. Offline, only downloaded regions draw.

GET /_bridge/maps describes the basemap under basemap: the provider’s name, Vector or Raster, its tile URL, zooms and tile size, and the attribution to show. Raster images are served at GET /_bridge/maps/basemap/{z}/{x}/{y}, which answers 501 when the basemap isn’t a raster one. <BridgeMap> handles all of this. BridgeMap.Basemap says what it drew, and Flavor still styles the vector regions under a raster map.

GoogleMapsBasemapProvider creates the Map Tiles API session itself, keeps it until shortly before it expires, and starts a new one if Google ends it early. HighDpi (on by default) asks for images at twice the pixels.

For another service, implement IBasemapProvider:

public sealed class MyBasemap(string apiKey) : IBasemapProvider
{
// Raster: images laid over the map. Vector providers must serve the Protomaps schema.
public BasemapLayer Layer => new("My Maps", TileFormat.Raster, MinZoom: 0, MaxZoom: 19, "© My Maps") { TileSize = 256 };
public Task<ProviderTile?> GetTileAsync(int z, int x, int y, HttpClient http, CancellationToken ct)
=> /* fetch the image; null where there's none */;
}

Set Cacheable = true on the layer only if the service’s terms allow tiles to be kept on the device.

Routes inside a downloaded road network are computed on the device whatever the router, and everything else goes to Directions.Router. A router declares the travel modes it offers: GET /_bridge/directions reports them as onlineModes, with the router’s name as router, and a request for another mode answers 400 mode_unsupported when the device can’t route it either. Every route carries the Attribution its source requires; show it with the route.

AzureMapsRouteProvider routes around live traffic for cars and trucks (UseTraffic, on by default). Azure has no bicycle routing. GoogleMapsRouteProvider has no truck routing, and asks for traffic-aware routes only with UseTraffic, which Google bills at a higher rate. Google gives no instruction for arriving, so each leg ends with an Arrive maneuver written by the bridge in English.

For another service, implement IRouteProvider:

public sealed class MyRouter(string apiKey) : IRouteProvider
{
public string Name => "My Routes";
public string Attribution => "© My Routes";
public IReadOnlyCollection<TravelMode> Modes => [TravelMode.Car, TravelMode.Walking];
public async Task<DirectionsRoute> RouteAsync(DirectionsRequest request, HttpClient http, CancellationToken ct)
{
// Return metres, seconds, the shape as [lon, lat] and maneuvers placed by index into it. The bridge sets the
// route's Source and Attribution, and applies Directions.Timeout.
// Throw DirectionsException(DirectionsError.NoRoute | InvalidRequest | RouterFailed, message) for a failure the
// service describes, HttpRequestException when it can't be reached.
}
}
Terminal window
dotnet add package Shiny.AppDeviceBridge.Maps.Blazor
builder.Services
.AddWebAppHostClient()
.AddBridgeMaps(); // the maps and directions clients
@inject IDirectionsBridge Directions
<BridgeMap @ref="map" Latitude="39.74" Longitude="-104.99" Zoom="11" Style="height: 60vh"
OnClick="Clicked" OnDrawn="Drawn" OnPinMoved="Moved" />
@code {
BridgeMap map = null!;
async Task Clicked(MapClick click)
{
if (click.IsPinMode)
await map.AddPinAsync(new MapPin($"pin-{Guid.NewGuid():n}", click.Position, "Dropped", Draggable: true));
}
Task Drawn(MapDrawn shape) => map.AddShapeAsync(new MapShape($"shape-{Guid.NewGuid():n}", shape.Kind, shape.Points)).AsTask();
async Task Route(GeoPoint from, GeoPoint to)
{
var route = await Directions.RouteAsync(new DirectionsRequest(
[new RouteStop(from.Latitude, from.Longitude), new RouteStop(to.Latitude, to.Longitude)]));
await map.ShowRouteAsync(route); // the line with a casing, start and end pins, fitted
}
}
BridgeMap
AddPinAsync(MapPin), RemovePinAsync markers, draggable, with a popup label
AddShapeAsync(MapShape), RemoveShapeAsync lines and areas, drawn above the roads and below the labels
AddCircleAsync(id, center, radiusMetres) a circle, as a 64-point area
ShowRouteAsync(DirectionsRoute) a route from the directions bridge
SetDrawModeAsync(MapDrawMode) Pin: clicks report a place for a pin. Line, Polygon: clicks add points and a double-click finishes, raising OnDrawn
FlyToAsync, FitBoundsAsync, FitAllAsync, ClearAsync the view and the overlays
SnapshotAsync() the map as drawn, pins included, as a PNG data: URL
ShowTraffic, SetTrafficAsync(bool), HasTraffic the live traffic flow layer, when the app configured a provider. See Traffic
ShowIncidents, SetIncidentsAsync(bool), HasIncidents live incidents: lines, and markers that show their details when tapped
OnReady, OnClick, OnDrawn, OnPinClicked, OnPinMoved, OnMoved events
Flavor, Language the Protomaps style (light, dark, white, grayscale, black) and label language

MapLibre GL JS 6 and the Protomaps style are in the package, not loaded from a CDN, so the map draws with no connection. Any other vector-tile renderer can use the bridge too: GET /_bridge/maps returns the tile, glyph and sprite URLs.

Two live layers draw over the roads. Flow colours each road by how fast traffic is moving: green, orange, then red compared to free flow, with closed roads in dark red, or the provider’s own colours for image tiles. Incidents mark accidents, jams, roadworks, closures and weather. Each shows as a dashed line along the affected road and a marker coloured by kind, and tapping a marker shows its description and delay. Each layer comes from a provider set in the native app, and the two are independent, so one provider’s flow works with another’s incidents. The page gets bridge URLs, never a provider’s address or key. Both layers are live, so offline they’re simply empty.

Provider Layer Tiles Key
TomTomTrafficProvider(key) flow vector, coloured by the map TomTom, with the Traffic API
HereTrafficProvider(key) flow 512 px images in HERE’s colours HERE, with traffic tiles
AzureMapsTrafficProvider(key) or (azureMapsCredential) flow 512 px images in Azure’s colours Azure Maps shared key, or Microsoft Entra ID
TomTomIncidentProvider(key) incidents vector: lines and markers TomTom, the same key as flow
bridge.AddMapsBridge(o =>
{
o.Traffic = new TomTomTrafficProvider(tomTomKey)
{
MinZoom = 6, // default: no tiles are fetched below this
Refresh = TimeSpan.FromMinutes(2) // default: how long a tile is kept, and how often the page refetches
};
o.TrafficIncidents = new TomTomIncidentProvider(tomTomKey) { Language = "en-GB" }; // MinZoom 8 by default
// Or HERE's flow, only drawing trouble: MinTrafficCongestion is free (default), heavy, queuing or blocked.
// o.Traffic = new HereTrafficProvider(hereKey) { MinTrafficCongestion = "heavy" };
// Or Azure Maps, with a shared key or Entra ID. Style picks the tileset: Relative (default), RelativeDark, Delay,
// ReducedSensitivity or Absolute.
// o.Traffic = new AzureMapsTrafficProvider(azureKey) { Style = AzureMapsTrafficStyle.RelativeDark };
// o.Traffic = new AzureMapsTrafficProvider(new AzureMapsCredential(azureMapsClientId, ct => GetEntraTokenAsync(ct)));
});
<BridgeMap ShowTraffic="traffic" ShowIncidents="incidents" … />
<button disabled="@(!map.HasTraffic)" @onclick="() => traffic = !traffic">Traffic</button>
<button disabled="@(!map.HasIncidents)" @onclick="() => incidents = !incidents">Incidents</button>

MapsOptions.Traffic and TrafficIncidents can also be changed while the app runs, for example to let the user pick a provider or enter their own key. A change takes effect from the next tile, and a map created after it (or GET /_bridge/maps read again) sees the new layers. The sample’s Map page does exactly this: a small bridge of its own keeps the keys in the device’s secure store and never sends them back to the page.

ShowTraffic and ShowIncidents can change at any time, and SetTrafficAsync(bool) and SetIncidentsAsync(bool) do the same from code. HasTraffic and HasIncidents say whether the app configured a provider for each. Every tile counts against the provider’s quota, and every tile on screen is fetched again after Refresh, so raise MinZoom or Refresh to spend less of it.

Any other source is two members. Flow:

public interface ITrafficProvider
{
// Vector: SourceLayer and SpeedRatioProperty (current / free-flow speed, 0–1), optionally ClosedProperty.
// Raster: TileSize; the images are drawn as they are.
TrafficLayer Layer { get; }
// Null where there's nothing. The HttpClient is the maps bridge's own, built from HttpMessageHandlerFactory.
Task<ProviderTile?> GetTileAsync(int z, int x, int y, HttpClient http, CancellationToken ct);
}

Incidents are vector tiles. The provider names the layers holding lines and points, and the property holding its own incident type. Kinds maps each value of that property to a TrafficIncidentKind (Accident, Congestion, RoadWorks, RoadClosed, LaneClosed, Weather, Hazard, BrokenDownVehicle), and anything not listed is Other:

public interface ITrafficIncidentProvider
{
// KindProperty + Kinds; optionally LineSourceLayer, PointSourceLayer, DescriptionProperty, DelayProperty (seconds),
// ClusterSizeProperty (a marker standing for several incidents shows the count).
TrafficIncidentLayer Layer { get; }
Task<ProviderTile?> GetTileAsync(int z, int x, int y, HttpClient http, CancellationToken ct);
}

The bridge serves GET /_bridge/maps/traffic/{z}/{x}/{y} and GET /_bridge/maps/incidents/{z}/{x}/{y}. It keeps each tile for its layer’s Refresh and answers 204 outside the layer’s zooms, where the provider has nothing, or when it can’t be reached. Failures are not kept. With no provider for a layer, its route answers 501 and GET /_bridge/maps reports traffic: null or incidents: null. Every platform. The traffic layer doesn’t change routes; a router that knows traffic, such as AzureMapsRouteProvider, uses its own.

A region is a province, a state or a city, and has up to two parts:

Request Details
Map the region’s vector tiles, a PMTiles archive. Colorado to zoom 14 is 247 MB.
Directions the region’s road network for on-device routing, a Valhalla tile extract. Colorado’s is about 480 MB.

The catalog also carries an asset pack — the label fonts and icons, about 6 MB — installed with the first region.

var catalog = await maps.GetRegionsAsync(); // what can be downloaded, and what is
await maps.InstallAsync("colorado", new MapPackInstallRequest(Directions: true));
await maps.OnDownloadAsync(d => { /* Queued, Downloading, Verifying, Installed, Failed, Cancelled */ return Task.CompletedTask; });
await maps.RemoveDirectionsAsync("colorado"); // keep the map, route online
await maps.RemoveAsync("colorado");

Every part is checked against the catalog’s signature before it’s used: the size and SHA-256 of the file, and the region’s id and bounds, signed with ECDSA P-256 like web app releases but under a scheme of its own, so neither signature can pass for the other. A download that doesn’t match is discarded. An interrupted download — the app killed, a connection that goes quiet — resumes from where it stopped. A newer build of an installed part shows as updateAvailable, and installing again downloads only what changed. Regions are kept out of iCloud backups.

InstallAsync returns at once: Installed when the device already has the catalog’s build, Queued otherwise. A download’s maps.download events can arrive before that reply does, so a page that shows progress keeps the newest event rather than overwriting it with the reply:

downloads.Remove(region.Id);
var reply = await maps.InstallAsync(region.Id, new MapPackInstallRequest(Directions: true));
downloads.TryAdd(region.Id, reply); // an event already recorded is newer

Offline, GET /_bridge/maps/regions answers from the last catalog and what’s installed, with catalogReachable: false.

The release server serves regions from a directory:

builder.Services.AddMapPacks(o =>
{
o.SigningKey = builder.Configuration["WebApps:SigningKey"];
o.PacksDirectory = "/srv/maps";
});
app.MapMapPacks("/maps"); // GET /maps/catalog, GET /maps/files/{name} with Range

shiny-map-packs builds that directory. It needs the pmtiles CLI, and for directions Valhalla’s tools or Docker:

Terminal window
dotnet tool install -g Shiny.AppDeviceBridge.MapPacks
shiny-map-packs region colorado --name Colorado --bbox=-109.06,36.99,-102.04,41.0 \
--planet https://build.protomaps.com/20260922.pmtiles \
--osm https://download.geofabrik.de/north-america/us/colorado-latest.osm.pbf \
--out /srv/maps
shiny-map-packs assets --out /srv/maps
shiny-map-packs list --out /srv/maps

--polygon file.geojson cuts along a border instead of a rectangle; --maxzoom sets the detail (14 is street level, and every zoom roughly doubles the size). Publishing is copying files: each part’s version is the start of its hash.

var route = await directions.RouteAsync(new DirectionsRequest(
[new RouteStop(39.7392, -104.9903), new RouteStop(40.0150, -105.2705)],
TravelMode.Car, // Bicycle, Walking, Truck
DistanceUnits.Kilometers, // for the instruction text; numbers are always metres and seconds
Language: "en-US",
Source: DirectionsSource.Auto,
Avoid: new RouteAvoid(Tolls: true)));
route.Source; // Device or Online
route.Distance; // metres
route.Duration; // seconds
route.Shape; // [[lon, lat], …], GeoJSON order
route.Legs[0].Maneuvers[0]; // Kind, Instruction, VerbalInstruction, Distance, Duration, StreetNames, ShapeIndex
Source
Auto on the device when a downloaded road network covers every stop, online otherwise — and online when the device finds no route near a region’s edge
Device on the device only
Online online only
Failure
400 bad_request fewer than 2 or more than 20 stops, a stop off the map, a bad language tag
400 mode_unsupported the device can’t route it and the online router has no such travel mode
404 no_route no road connects the stops
502 router_error the online router failed, refused the app’s key, or answered with something that isn’t a route
503 offline_unavailable the device can’t route it and the online router can’t be reached
501 not_supported no online router configured and no on-device engine on this platform

GeocodeAsync turns what the user typed into places, best first, through the app’s geocoder, so a page can route between addresses instead of pins:

var from = (await directions.GeocodeAsync("Union Station, Denver")).Places[0];
var to = (await directions.GeocodeAsync("Aspen, Colorado", limit: 5, language: "en-US")).Places[0];
var route = await directions.RouteAsync(new DirectionsRequest([
new RouteStop(from.Latitude, from.Longitude, from.Name),
new RouteStop(to.Latitude, to.Longitude, to.Name)
]));
import { DirectionsBridge } from "@shinyorg/appdevicebridge";
const directions = new DirectionsBridge();
const [from] = (await directions.geocode("Union Station, Denver")).places;
const [to] = (await directions.geocode("Aspen, Colorado", { limit: 5, language: "en-US" })).places;
const route = await directions.route({
stops: [
{ latitude: from.latitude, longitude: from.longitude, name: from.name },
{ latitude: to.latitude, longitude: to.longitude, name: to.name },
],
});

Each GeocodedPlace has a Name, the full Address, Latitude, Longitude and, for a place with an extent, Bounds as [west, south, east, north]. The result’s Attribution is the credit the service requires; show it with the results. GET /_bridge/directions says whether the app has a geocoder (geocoding). Geocoding is always online.

AzureMapsGeocoder and GoogleMapsGeocoder search Azure Maps and Google with the app’s key; see Providers. NominatimGeocoder uses Nominatim, OpenStreetMap’s geocoder. The public server is for light use: its policy asks for an application that identifies itself — the constructor’s User-Agent — at most one request a second, and no searching as the user types. The geocoder spaces its requests by MinimumInterval (one second), so search when the user asks. BaseAddress points it at your own Nominatim or a hosted one, and Countries limits results to a list of ISO country codes. For another service, implement IGeocoder; it gets the bridge’s HttpClient. Directions.Geocoder can be replaced while the app runs.

public sealed class MyGeocoder(string apiKey) : IGeocoder
{
public string Attribution => "Search by <a href=\"https://geo.example.com\">Example Geo</a>";
public async Task<IReadOnlyList<GeocodedPlace>> SearchAsync(GeocodeQuery query, HttpClient http, CancellationToken cancellationToken)
{
// query.Text is trimmed and at most 200 characters, query.Limit is 1–20, query.Language may be null.
var uri = $"https://geo.example.com/search?q={Uri.EscapeDataString(query.Text)}&limit={query.Limit}&key={apiKey}";
var hits = await http.GetFromJsonAsync(uri, MyGeoJsonContext.Default.MyHitArray, cancellationToken);
return hits!.Select(h => new GeocodedPlace(h.Name, h.Label, h.Lat, h.Lon)).ToList();
}
}

Throw HttpRequestException when the service can’t be reached (the page gets 503 geocoder_unavailable), and JsonException or FormatException for an answer that isn’t a list of places (502 geocoder_error). Directions.Timeout bounds every search.

Failure
400 bad_request an empty query or one over 200 characters, a limit outside 1–20, a bad language tag
502 geocoder_error the geocoder answered with something that isn’t a list of places
503 geocoder_unavailable the geocoder can’t be reached
501 not_supported no geocoder configured

To make a trip work offline, route it online, then download every region it passes through with its road network. The sample’s Map page does this with the region’s bounds:

var catalog = await maps.GetRegionsAsync();
var along = catalog.Regions.Where(r => route.Shape.Any(p =>
p[0] >= r.Bounds[0] && p[0] <= r.Bounds[2] && p[1] >= r.Bounds[1] && p[1] <= r.Bounds[3]));
foreach (var region in along)
await maps.InstallAsync(region.Id, new MapPackInstallRequest(Directions: true));

Bounds are rectangles, so a route near a border can also pull in a neighbour it only clips.

On the device a route takes tens of milliseconds; the first in a session also starts the engine. On iOS the package unpacks the time zone database Valhalla needs into Library/tzdata on first use.

Maps/_bridge/maps12 requests · 1 events

Vector maps the page draws with MapLibre or any other vector-tile renderer. Tiles come from a region the user downloaded when one covers them, and from the app's online basemap otherwise — OpenStreetMap through Protomaps, Azure Maps, Google Maps or one of the app's own — so the page asks for one set of URLs and never learns the provider's key. Regions are listed from the app's signed catalog and downloaded on request.

GET/_bridge/maps#

The URLs to hand the renderer, and what the app configured.

C#IMapsBridge.GetInfoAsyncTSMapsBridge.getInfo
Response
200 MapsInfo
MapsInfo · 9 fields
tilesUrlstringThe vector tiles, in the Protomaps basemap schema: …/maps/tiles/{z}/{x}/{y}. Served from a downloaded region when one covers the tile, from tiles already seen online, and from the online basemap when it is a vector one.
glyphsUrlstringThe label fonts: …/maps/glyphs/{fontstack}/{range}.pbf.
spritesUrlstringThe icon sheets. Append a flavor — light, dark, white, grayscale, black — for MapLibre's sprite.
maxZoomnumberThe highest zoom tilesUrl carries. The renderer draws closer zooms by scaling these, which vector tiles do cleanly.
catalogbooleanWhether the app configured a region catalog to download from.
attributionstringThe credit the vector map data's licence requires, as HTML.
basemapBasemapInfo | nullThe online basemap, when the app configured one. Null for downloaded regions only.
trafficTrafficInfo | nullThe live traffic layer, when the app configured a traffic provider. Null otherwise.
incidentsTrafficIncidentInfo | nullLive traffic incidents — accidents, roadworks, closures — when the app configured an incident provider. Null otherwise.
GET/_bridge/maps/regions#

The downloadable regions and what is installed. The catalog is fetched again when refresh is true or the last copy is old; offline, the last copy is used.

C#IMapsBridge.GetRegionsAsyncTSMapsBridge.getRegions
ParameterInType
refreshquerybooleanoptional
Response
200 MapCatalog
MapCatalog · 3 fields
regionsMapRegion[]The catalog's regions, and any installed region the catalog no longer lists.
catalogReachablebooleanFalse when the catalog could not be fetched — offline, say. The regions are then the ones last seen and the ones installed.
installedBytesnumberDisk space the installed regions take.
POST/_bridge/maps/regions/{id}#

Starts downloading a region — its map, and its road network when asked — or updates an installed one to the catalog's build. Returns at once: Installed when the device already has the catalog's build, Queued otherwise, with progress arriving as maps.download — possibly before this reply does, so a page that tracks progress keeps the newest event rather than this reply. Fails with 404 for a region the catalog does not list, 409 when one is already downloading, 501 when the app configured no catalog.

C#IMapsBridge.InstallAsyncTSMapsBridge.install
ParameterInType
idpathstringrequired
Body
application/json · MapPackInstallRequest
MapPackInstallRequest · 1 field
directionsbooleanAlso download the road network, so directions inside the region work offline. Ignored when the region does not offer it.
Response
200 MapPackDownload
MapPackDownload · 6 fields
regionIdstring
directionsbooleanWhether the download includes the road network.
stateMapPackDownloadState
bytesDownloadednumber
totalBytesnumber
errorstring | nullWhy it failed.
Errors
404Not found409Conflict501Not on this platform
DELETE/_bridge/maps/regions/{id}#

Removes a region — map and road network — from the device, cancelling its download if one is running.

C#IMapsBridge.RemoveAsyncTSMapsBridge.remove
ParameterInType
idpathstringrequired
Response
204 No content
DELETE/_bridge/maps/regions/{id}/directions#

Removes only a region's road network: the map still draws offline, directions go online.

C#IMapsBridge.RemoveDirectionsAsyncTSMapsBridge.removeDirections
ParameterInType
idpathstringrequired
Response
204 No content
DELETE/_bridge/maps/regions/{id}/download#

Cancels a region's download. What was already installed stays.

C#IMapsBridge.CancelDownloadAsyncTSMapsBridge.cancelDownload
ParameterInType
idpathstringrequired
Response
204 No content
GET/_bridge/maps/tiles/{z}/{x}/{y}#

One vector tile, as MapLibre asks for it through tilesUrl. 204 when no source has it — outside every downloaded region while offline.

C#IMapsBridge.GetTileAsyncTSMapsBridge.getTile
ParameterInType
zpathnumberrequired
xpathnumberrequired
ypathnumberrequired
Response
200 Binary content — Blob in TypeScript, Stream / byte[] in C#
GET/_bridge/maps/basemap/{z}/{x}/{y}#

One image from a raster online basemap, as the renderer asks for it through tilesUrl. 204 when the provider has nothing there or cannot be reached — offline, say, where downloaded regions show through instead; 501 when the app configured no raster basemap.

C#IMapsBridge.GetBasemapTileAsyncTSMapsBridge.getBasemapTile
ParameterInType
zpathnumberrequired
xpathnumberrequired
ypathnumberrequired
Response
200 Binary content — Blob in TypeScript, Stream / byte[] in C#
Errors
501Not on this platform
GET/_bridge/maps/traffic/{z}/{x}/{y}#

One live traffic tile, as the renderer asks for it through tilesUrl: a vector tile or an image, as format says. 204 when the provider has nothing there or cannot be reached; 501 when the app configured no traffic provider.

C#IMapsBridge.GetTrafficTileAsyncTSMapsBridge.getTrafficTile
ParameterInType
zpathnumberrequired
xpathnumberrequired
ypathnumberrequired
Response
200 Binary content — Blob in TypeScript, Stream / byte[] in C#
Errors
501Not on this platform
GET/_bridge/maps/incidents/{z}/{x}/{y}#

One live traffic incident tile, as the renderer asks for it through tilesUrl. 204 when the provider has nothing there or cannot be reached; 501 when the app configured no incident provider.

C#IMapsBridge.GetIncidentTileAsyncTSMapsBridge.getIncidentTile
ParameterInType
zpathnumberrequired
xpathnumberrequired
ypathnumberrequired
Response
200 Binary content — Blob in TypeScript, Stream / byte[] in C#
Errors
501Not on this platform
GET/_bridge/maps/glyphs/{fontstack}/{range}#

A range of label glyphs, as MapLibre asks for it through glyphsUrl: 0-255.pbf.

C#IMapsBridge.GetGlyphsAsyncTSMapsBridge.getGlyphs
ParameterInType
fontstackpathstringrequired
rangepathstringrequired
Response
200 Binary content — Blob in TypeScript, Stream / byte[] in C#
GET/_bridge/maps/sprites/{name}#

A sprite sheet or its index, as MapLibre asks for it: light.json, light@2x.png.

C#IMapsBridge.GetSpriteAsyncTSMapsBridge.getSprite
ParameterInType
namepathstringrequired
Response
200 Binary content — Blob in TypeScript, Stream / byte[] in C#
Directions/_bridge/directions3 requests · 0 events

Turn-by-turn directions. A route is computed on the device when the user downloaded the road network of a region covering every stop and the platform can run the router, and by the app's online router otherwise — Valhalla, Azure Maps, Google Maps or one of the app's own. Addresses become stops through the app's geocoder. The router's and the geocoder's addresses and keys stay in the native app; the page never sees them.

GET/_bridge/directions#

Which sources can answer.

C#IDirectionsBridge.GetInfoAsyncTSDirectionsBridge.getInfo
Response
200 DirectionsInfo
DirectionsInfo · 6 fields
onlinebooleanWhether the app configured an online router.
onlineModesTravelMode[]The travel modes the online router computes routes for. Empty without one.
onDevicebooleanWhether this platform can compute routes on the device. It still needs a downloaded road network to do so.
offlineRegionsstring[]The regions whose road network is on the device.
geocodingbooleanWhether the app configured a geocoder, so geocode can turn an address into a stop.
routerstring | nullWho computes online routes — Valhalla, Azure Maps, Google Maps — for showing the user. Null without an online router.
POST/_bridge/directions/route#

Computes a route through the stops. Fails with 400 for fewer than two stops or a stop off the map, or (mode_unsupported) for a travel mode the online router does not offer when the device cannot route; 404 when no route connects them, 503 (offline_unavailable) when the device cannot compute it and the online router cannot be reached, 501 when neither is available on this platform and app.

C#IDirectionsBridge.RouteAsyncTSDirectionsBridge.route
Body
application/json · DirectionsRequest
DirectionsRequest · 6 fields
stopsRouteStop[]Two or more, in order: where the route starts, any stops along the way, where it ends.
modeTravelMode
unitsDistanceUnits
languagestring | nullAn IETF language tag for the instructions, such as en-US or fr. English when null or unsupported.
sourceDirectionsSource
avoidRouteAvoid | null
Response
200 DirectionsRoute
DirectionsRoute · 7 fields
sourceDirectionsSourceWhere the route was computed: Device or Online.
distancenumberMetres.
durationnumberSeconds.
shapenumber[][]The line to draw, as [longitude, latitude] pairs — GeoJSON's order, so it drops into a LineString as it is.
boundsnumber[][west, south, east, north] around shape, for fitting the map to the route.
legsRouteLeg[]
attributionstringThe credit the router's terms require, as HTML.
Errors
400Bad request404Not found501Not on this platform503Unavailable
GET/_bridge/directions/geocode#

Finds places matching an address or a name — "1701 Wynkoop St, Denver", "Red Rocks Amphitheatre" — best first, for turning what the user typed into a RouteStop. Always online. Fails with 400 for an empty query or one over 200 characters, 503 (geocoder_unavailable) when the geocoder cannot be reached, 501 when the app configured no geocoder.

C#IDirectionsBridge.GeocodeAsyncTSDirectionsBridge.geocode
ParameterInType
queryquerystringrequired
limitquerynumberoptional
languagequerystring | nulloptional
Response
200 GeocodeResult
GeocodeResult · 2 fields
placesGeocodedPlace[]Best match first. Empty when nothing matched.
attributionstringThe credit the geocoder's terms require, as HTML.
Errors
400Bad request501Not on this platform503Unavailable
Maps1 event
EVENTmaps.download#

A region's download moved on, finished, failed or was cancelled.

C#IMapsBridge.OnDownloadAsyncTSMapsBridge.onDownload
Payload
MapPackDownload
regionIdstring
directionsbooleanWhether the download includes the road network.
stateMapPackDownloadState
bytesDownloadednumber
totalBytesnumber
errorstring | nullWhy it failed.

JSON property names are camelCase and enums travel as their names. A ? marks a property the payload may omit;| null one that may be null.

MapsInfoobject#

Everything a map renderer needs to draw from the bridge. The URLs are templates MapLibre (or any vector-tile renderer) fills in itself, relative to the page's origin and already under the bridge's prefix.

tilesUrlstringThe vector tiles, in the Protomaps basemap schema: …/maps/tiles/{z}/{x}/{y}. Served from a downloaded region when one covers the tile, from tiles already seen online, and from the online basemap when it is a vector one.
glyphsUrlstringThe label fonts: …/maps/glyphs/{fontstack}/{range}.pbf.
spritesUrlstringThe icon sheets. Append a flavor — light, dark, white, grayscale, black — for MapLibre's sprite.
maxZoomnumberThe highest zoom tilesUrl carries. The renderer draws closer zooms by scaling these, which vector tiles do cleanly.
catalogbooleanWhether the app configured a region catalog to download from.
attributionstringThe credit the vector map data's licence requires, as HTML.
basemapBasemapInfo | nullThe online basemap, when the app configured one. Null for downloaded regions only.
trafficTrafficInfo | nullThe live traffic layer, when the app configured a traffic provider. Null otherwise.
incidentsTrafficIncidentInfo | nullLive traffic incidents — accidents, roadworks, closures — when the app configured an incident provider. Null otherwise.
BasemapInfoobject#

The online basemap, whatever provider is behind it — OpenStreetMap through Protomaps, Azure Maps, Google Maps or the app's own. Tiles come through the bridge, which holds the provider's key.

Vector: tiles in the Protomaps schema, served through tilesUrl together with downloaded regions, so tilesUrl is that same URL and the page draws one source.

Raster: images served at tilesUrl, which the page lays over the vector tiles. Offline the bridge answers 204 for them, so downloaded regions still show through where the images are missing.

providerstringWho draws the map — Protomaps, Azure Maps, Google Maps — for showing the user.
formatTileFormatWhether the page styles the tiles itself or lays the provider's images over the map.
tilesUrlstringThe tiles: …/maps/tiles/{z}/{x}/{y} for vector, …/maps/basemap/{z}/{x}/{y} for raster.
minZoomnumberBelow this the provider has no tiles, and none are asked for.
maxZoomnumberThe highest zoom the provider has. The renderer scales past it.
tileSizenumberRaster: the size each tile is drawn at, in CSS pixels. Images may carry more pixels than this, for sharp drawing on high-density screens.
attributionstringThe credit the provider's terms require, as HTML.
TileFormatenum#

How a provider's tiles are drawn.

"Vector""Raster"
TrafficInfoobject#

A live traffic layer, whatever provider is behind it. Tiles come through the bridge, which holds the provider's key, and only while online: offline the layer is simply empty.

tilesUrlstringThe traffic tiles: …/maps/traffic/{z}/{x}/{y}.
formatTileFormat
minZoomnumberBelow this the layer draws nothing, and no tiles are asked for.
maxZoomnumberThe highest zoom the provider has. The renderer scales past it.
refreshSecondsnumberHow often the provider's data changes, so how often the page should fetch the tiles again.
sourceLayerstring | nullVector: the layer inside each tile holding the road segments.
speedRatioPropertystring | nullVector: the property holding current speed over free-flow speed, from 0 (stopped) to 1 (free flow).
closedPropertystring | nullVector: the property that is true on a closed road. Null when the provider does not mark closures.
tileSizenumberRaster: the images' size in pixels.
attributionstringThe credit the provider's terms require, as HTML.
TrafficIncidentInfoobject#

A live layer of traffic incidents, whatever provider is behind it: vector tiles holding the stretch of road an incident affects as lines, where it is as points, or both. Tiles come through the bridge, which holds the provider's key, and only while online.

tilesUrlstringThe incident tiles: …/maps/incidents/{z}/{x}/{y}.
minZoomnumberBelow this the layer draws nothing, and no tiles are asked for.
maxZoomnumberThe highest zoom the provider has. The renderer scales past it.
refreshSecondsnumberHow often the page should fetch the tiles again.
lineSourceLayerstring | nullThe layer inside each tile holding the affected stretches of road. Null when the provider has none.
pointSourceLayerstring | nullThe layer inside each tile holding incident locations. Null when the provider has none.
kindPropertystringThe property holding the provider's incident type.
kindsRecord&lt;string, TrafficIncidentKind&gt;The provider's values of kindProperty, as text, and what each is. Anything not listed is Other.
descriptionPropertystring | nullThe property holding a description to show for an incident. Null when there is none.
delayPropertystring | nullThe property holding the delay the incident causes, in seconds. Null when there is none.
clusterSizePropertystring | nullThe property holding how many incidents a point stands for, where the provider groups them. Null when it does not.
attributionstringThe credit the provider's terms require, as HTML.
TrafficIncidentKindenum#

What a traffic incident is, whatever each provider calls it. The map colours incidents by it.

"Other""Accident""Congestion""RoadWorks""RoadClosed""LaneClosed""Weather""Hazard""BrokenDownVehicle"
MapCatalogobject#
regionsMapRegion[]The catalog's regions, and any installed region the catalog no longer lists.
catalogReachablebooleanFalse when the catalog could not be fetched — offline, say. The regions are then the ones last seen and the ones installed.
installedBytesnumberDisk space the installed regions take.
MapRegionobject#

A region the page can download, merged with what is on the device.

idstring
namestring
boundsnumber[][west, south, east, north] in degrees.
mapSizenumberBytes to download for the map tiles.
directionsSizenumber | nullBytes to download for on-device directions, when the region offers them.
mapInstalledbooleanWhether the region's map is on the device, so it draws offline.
directionsInstalledbooleanWhether the region's road network is on the device, so directions inside it work offline.
updateAvailablebooleanWhether the catalog has a newer build of an installed part.
downloadMapPackDownload | nullThe download in progress for this region, if any.
MapPackDownloadobject#

A region's download. Raised as maps.download while it runs and once more when it ends.

regionIdstring
directionsbooleanWhether the download includes the road network.
stateMapPackDownloadState
bytesDownloadednumber
totalBytesnumber
errorstring | nullWhy it failed.
MapPackDownloadStateenum#
"Queued""Downloading""Verifying""Installed""Failed""Cancelled"
MapPackInstallRequestobject#
directions?booleanAlso download the road network, so directions inside the region work offline. Ignored when the region does not offer it.
DirectionsInfoobject#
onlinebooleanWhether the app configured an online router.
onlineModesTravelMode[]The travel modes the online router computes routes for. Empty without one.
onDevicebooleanWhether this platform can compute routes on the device. It still needs a downloaded road network to do so.
offlineRegionsstring[]The regions whose road network is on the device.
geocodingbooleanWhether the app configured a geocoder, so geocode can turn an address into a stop.
routerstring | nullWho computes online routes — Valhalla, Azure Maps, Google Maps — for showing the user. Null without an online router.
TravelModeenum#
"Car""Bicycle""Walking""Truck"
DirectionsRequestobject#
stopsRouteStop[]Two or more, in order: where the route starts, any stops along the way, where it ends.
mode?TravelMode
units?DistanceUnits
language?string | nullAn IETF language tag for the instructions, such as en-US or fr. English when null or unsupported.
source?DirectionsSource
avoid?RouteAvoid | null
RouteStopobject#
latitudenumber
longitudenumber
name?string | null
DistanceUnitsenum#

The units spoken and written instructions use. Distances in the response are always metres.

"Kilometers""Miles"
DirectionsSourceenum#

Where a route is computed.

"Auto""Device""Online"
RouteAvoidobject#
tolls?boolean
highways?boolean
ferries?boolean
DirectionsRouteobject#
sourceDirectionsSourceWhere the route was computed: Device or Online.
distancenumberMetres.
durationnumberSeconds.
shapenumber[][]The line to draw, as [longitude, latitude] pairs — GeoJSON's order, so it drops into a LineString as it is.
boundsnumber[][west, south, east, north] around shape, for fitting the map to the route.
legsRouteLeg[]
attributionstringThe credit the router's terms require, as HTML.
RouteLegobject#

The part of a route between two consecutive stops.

distancenumber
durationnumber
maneuversRouteManeuver[]
RouteManeuverobject#
kindManeuverKind
instructionstringWritten for reading: "Turn right onto North Broadway."
verbalInstructionstring | nullWritten for speaking before the maneuver, when the router provides it.
distancenumberMetres from this maneuver to the next.
durationnumberSeconds from this maneuver to the next.
streetNamesstring[]
shapeIndexnumberWhere the maneuver is: an index into shape.
roundaboutExitnumber | nullWhich exit to take, for EnterRoundabout.
ManeuverKindenum#

What a maneuver asks the traveller to do. Enough to pick an arrow icon.

"Depart""Arrive""Continue""SlightRight""Right""SharpRight""UTurn""SharpLeft""Left""SlightLeft""RampStraight""RampRight""RampLeft""ExitRight""ExitLeft""KeepStraight""KeepRight""KeepLeft""Merge""EnterRoundabout""ExitRoundabout""Ferry""Other"
GeocodeResultobject#
placesGeocodedPlace[]Best match first. Empty when nothing matched.
attributionstringThe credit the geocoder's terms require, as HTML.
GeocodedPlaceobject#

A place a geocoder found for an address or a name.

namestringWhat the place is called: "Union Station", "1701 Wynkoop Street".
addressstringThe full address or description, which tells apart places with the same name.
latitudenumber
longitudenumber
boundsnumber[] | null[west, south, east, north] around a place with an extent — a city, a park — for fitting the map to it. Null for a point.