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
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
// All Google: one key with the Map Tiles, Routes and Geocoding APIs enabled.
bridge.AddMapsBridge(o=>
{
o.Basemap =newGoogleMapsBasemapProvider(googleKey) { Style = GoogleMapsBasemapStyle.Roadmap, Language ="en-US", Region ="US" };
o.Directions.Router =newGoogleMapsRouteProvider(googleKey); // UseTraffic = false by default: it costs more
o.Directions.Geocoder =newGoogleMapsGeocoder(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.
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.
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 =newTomTomTrafficProvider(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 =newTomTomIncidentProvider(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)));
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.
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:
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.
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:
--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.
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.
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
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:
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.
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.
The 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.
glyphsUrl
string
The label fonts: …/maps/glyphs/{fontstack}/{range}.pbf.
spritesUrl
string
The icon sheets. Append a flavor — light, dark, white, grayscale, black — for MapLibre's sprite.
maxZoom
number
The highest zoom tilesUrl carries. The renderer draws closer zooms by scaling these, which vector tiles do cleanly.
catalog
boolean
Whether the app configured a region catalog to download from.
attribution
string
The credit the vector map data's licence requires, as HTML.
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.
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.
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.
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.
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.
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.
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.
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.
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.
tilesUrl
string
The 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.
glyphsUrl
string
The label fonts: …/maps/glyphs/{fontstack}/{range}.pbf.
spritesUrl
string
The icon sheets. Append a flavor — light, dark, white, grayscale, black — for MapLibre's sprite.
maxZoom
number
The highest zoom tilesUrl carries. The renderer draws closer zooms by scaling these, which vector tiles do cleanly.
catalog
boolean
Whether the app configured a region catalog to download from.
attribution
string
The credit the vector map data's licence requires, as HTML.
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.
provider
string
Who draws the map — Protomaps, Azure Maps, Google Maps — for showing the user.
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.
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.
tilesUrl
string
The incident tiles: …/maps/incidents/{z}/{x}/{y}.
minZoom
number
Below this the layer draws nothing, and no tiles are asked for.
maxZoom
number
The highest zoom the provider has. The renderer scales past it.
refreshSeconds
number
How often the page should fetch the tiles again.
lineSourceLayer
string | null
The layer inside each tile holding the affected stretches of road. Null when the provider has none.
pointSourceLayer
string | null
The layer inside each tile holding incident locations. Null when the provider has none.
kindProperty
string
The property holding the provider's incident type.