Last clicked: Nothing yet
Map plots Dataverse rows from a view or query at their latitude and longitude, beside markers, lines, areas and circles of your own. One component, the same on Blazor and React, drawn by the provider you choose: Azure Maps, Esri, Google, OpenStreetMap or any MapLibre style.
Twelve offices as markers, a route, a delivery zone and a sales region. Markers close together gather into clusters, which zoom in when clicked. Click an office for its popup, here the page's own content; the route and the areas open none, as the page answers their clicks itself, and the line under the map shows what it heard.
Last clicked: Nothing yet
The active accounts, colored by their relationship type, or with the switch the contacts, read by a FetchXML query with the visitor's permissions and placed at their latitude and longitude. Click one for its popup: the query's other columns under the row's name, which opens its page. The box over the map finds an address with the portal's provider and pins it.
Last clicked: Nothing yet
Address found: Search for one in the box over the map
The portal's configuration names its provider and holds each provider's keys, in the PowerPortalsPro:Maps section, so a map needs to name none and no page holds a key. Markers, clusters, popups and clicks work the same on every provider:
Esri:ApiKey, and any ArcGIS basemap style in place of streets.Google:ApiKey, and a map id for a map styled in Google Cloud.OpenStreetMap:TileUrl for production.MapLibre:StyleUrl or MapLibre:TileUrl, such as MapTiler, OpenFreeMap, Stadia or your own server.// appsettings.json; keep the keys with the portal's other secrets
{
"PowerPortalsPro": {
"Maps": {
"Provider": "AzureMaps",
"AzureMaps": { "ClientId": "the Azure Maps account's client id" },
"Esri": { "ApiKey": "an ArcGIS API key" },
"Google": { "ApiKey": "a Maps JavaScript API key", "MapId": "a map id" }
}
}
}
Esri's and Google's keys reach the viewer's browser, which sends them to the provider, as every web map's do. Restrict each to your portal's addresses in the provider's console.
A page names a provider only to use another than the portal's, and says only how it looks: a style, a map id, a view of disputed borders. What it leaves unset comes from the configuration. In a dark theme the map switches to the provider's dark style; UsePortalTheme turns that off.
<Map items={items} />
<Map provider={{ name: 'google', mapId: 'a map id' }} items={items} />
<Map provider={{ name: 'esri', styleName: 'arcgis/navigation' }} items={items} />
<Map provider={{ name: 'mapLibre', styleUrl: 'https://tiles.openfreemap.org/styles/liberty' }} items={items} /><Map Items="items" />
<Map Provider="@(new GoogleMapProvider { MapId = "a map id" })" Items="items" />
<Map Provider="@(new EsriMapProvider { StyleName = "arcgis/navigation" })" Items="items" />
<Map Provider="@(new MapLibreMapProvider { StyleUrl = "https://tiles.openfreemap.org/styles/liberty" })" Items="items" />An Azure Maps map signs in with Entra tokens the portal issues to its own identity, the one it connects to Dataverse as, so no Azure Maps key is needed or reaches the browser. Give that identity the Azure Maps Data Reader role on the Azure Maps account and nothing more, and set AzureMaps:ClientId to the client id on the account's Authentication page.
Every viewer holds the token while it runs, so it can do only what that role allows; limit the account's allowed origins to your portal's addresses too. The map asks for a new token before it expires. A page asks once, and one viewer may be handed 120 tokens a minute unless PowerPortalsPro:Maps:RateLimit says otherwise; visitors who aren't signed in are counted by their address, which everyone behind one office's connection shares.
Give a view or a FetchXML query, and each row with a latitude and longitude becomes a marker named by the table's primary name. Rows are read with the user's permissions, as a grid's are, and clicking one shows its view's columns.
<Map
viewId={activeAccountsViewId}
latitudeColumn="address1_latitude"
longitudeColumn="address1_longitude"
recordColor="var(--colorPaletteRedBackground3)"
/><Map ViewId="activeAccountsViewId"
LatitudeColumn="address1_latitude"
LongitudeColumn="address1_longitude"
RecordColor="var(--colorPaletteRedBackground3)" />Rows without both coordinates are left out of the query. The map reads 1,000 rows unless MaxRows says otherwise (at most 5,000) and notes on the map when there are more.
Rows without coordinates can have them filled in from their addresses as they are saved: see Geocoding.
With MultiTable (React: multiTable), the map shows the rows of every table the server registers for maps with ForMaps, each table in its own color with a legend. The registration names the columns holding a row's latitude and longitude, which are always read. DefaultTables chooses the tables shown at first and Tables the ones it may show at all; Regarding narrows it to the rows about a record.
// Program.cs
builder.Services.AddMultiTableSource("account", t => t
.Columns("address1_city")
.ForMaps(latitude: "address1_latitude", longitude: "address1_longitude"));
builder.Services.AddMultiTableSource("contact", t => t
.ForMaps(latitude: "address1_latitude", longitude: "address1_longitude"));
A table whose coordinates are registered with AddMapCoordinates, or filled in by AddGeocoding, is registered for maps with ForMaps() alone: see Geocoding.
<Map multiTable defaultTables={['account']} allowSavedViews /><Map MultiTable="true" DefaultTables="@(["account"])" AllowSavedViews="true" />A toolbar over the map chooses the view: every table, or a view of several tables registered with AddMultiTableView and ForMaps(). ViewIds (React: viewIds) offers only the registered views it names, in its order, and DefaultViewId (defaultViewId) the one it opens on. Its Filter menu switches tables on and off and edits each table's filters. With AllowSavedViews (React: allowSavedViews), a signed-in user can save what the map shows as a view of their own: see Saved Views.
With AllowNearSearch (React: allowNearSearch), a map of several tables has a Near button on its toolbar. It finds a place — an address, with the map provider's own search, or the visitor's location, which the browser asks them to share — and the map then shows only the rows within the distance chosen, nearest first over every table: the circle is drawn, the map fitted to it, and each row's popup says how far it is. NearDistances (nearDistances) sets the distances on offer, 1, 5, 10, 25, 50 and 100 unless set, NearDistance (nearDistance) the one it starts at, 25 unless set, and NearDistanceUnit (nearDistanceUnit) whether it starts in miles, the default, or kilometers; the visitor can switch. See it on the Nearby demo.
<Map
multiTable
allowNearSearch
nearDistances={[10, 25, 50, 100]}
nearDistance={25}
nearDistanceUnit={DistanceUnits.Kilometers}
/><Map MultiTable="true"
AllowNearSearch="true"
NearDistances="@([10, 25, 50, 100])"
NearDistance="25"
NearDistanceUnit="DistanceUnit.Kilometers" />Each table is read by its registered coordinates, with the visitor's permissions: the server reads the square around the circle, keeps the rows within the distance by their distance over the ground, and returns the nearest. A search near a place keeps 500 rows at most, the nearest, and the map's legend says when there were more; NearSearchOptions.MaximumMatches raises or lowers that, up to 5,000. A visitor who may not read a table's coordinates finds none of its rows.
// Program.cs
builder.Services.Configure<NearSearchOptions>(options => options.MaximumMatches = 1_000);
Clicking an item, a marker or a line, area or circle alike, raises OnItemClick with the item, its data and where it was clicked, and then opens a popup over it. The popup is a Fluent card with your own content: a template on Blazor, a render function on React. A click on the map itself raises OnMapClick with its position.
<Map
items={items}
onItemClick={({ item, position }) => select(item)}
onMapClick={({ position }) => addStop(position)}
renderPopup={(item) => (
<>
<strong>{item.title}</strong>
<Button onClick={() => open(item)}>Open</Button>
</>
)}
/><Map Items="items" OnItemClick="OnItemClicked" OnMapClick="OnMapClicked">
<PopupTemplate>
<strong>@context.Title</strong>
<FluentButton OnClick="@(() => Open(context))">Open</FluentButton>
</PopupTemplate>
</Map>To answer a click your own way, open a record's page, say, or select a region, call PreventDefault() on the click (preventDefault() on React) and the map opens no popup for it. ShowPopups set to false turns popups off for every item.
<Map
viewId={accountsViewId}
onItemClick={(event) => {
// A row's marker opens its record's page; anything else its popup.
const row = event.item.data as MapRecord | undefined;
if (row?.record) {
event.preventDefault();
navigate(`/accounts/${row.record.id}`);
}
}}
/><Map ViewId="accountsViewId" OnItemClick="OnItemClicked" />
@code {
// A row's marker opens its record's page; anything else its popup.
private void OnItemClicked(MapItemClickEventArgs args)
{
if (args.Item.Record is { } record)
{
args.PreventDefault();
Navigation.NavigateTo($"accounts/{record.Id}");
}
}
}A row's popup heading links to its record's page, the one registered for its table with AddRecordPage (see Record Pages), on a map of one table or several. RecordUrl links it elsewhere: a URL with {0} in place of the row's id and {1} its table's. ShowRecordLinks (React: showRecordLinks) set to false turns the links off. An item of your own links to its Url. On React, onNavigate follows the link with your router.
Markers at one place, such as the contacts at one address, never come apart by zooming, so their cluster lists them instead.
ColorColumn colors each row's marker by its option in a choice, status, status reason or yes/no column: the color the option has in Dataverse, or one of Fluent's palette when it has none, with a legend of the options on the map. Rows with no value are gray. For any other rule, RecordColorSelector on Blazor and a function for recordColor on React give each row its own color.
<Map viewId={accountsViewId} colorColumn="customertypecode" recordUrl="/accounts/{0}" />
<Map viewId={accountsViewId} recordColor={(record) => (isKeyAccount(record) ? 'var(--colorPaletteRedBackground3)' : undefined)} /><Map ViewId="accountsViewId" ColorColumn="customertypecode" RecordUrl="accounts/{0}" />
<Map ViewId="accountsViewId" RecordColorSelector="@(record => IsKeyAccount(record) ? "var(--colorPaletteRedBackground3)" : null)" />ShowAddressSearch puts a search box over the map's corner. It finds addresses and places with the portal's own provider, places in view first, and pins the one chosen; OnAddressSelected hands the page the place, and null once the search is cleared. AddressSearchCountries keeps it to some countries, and GoToAddressAsync (goToAddress on React's ref) searches from code.
<Map
viewId={accountsViewId}
showAddressSearch
addressSearchCountries={['US', 'CA']}
onAddressSelected={(place) => setNear(place?.position ?? null)}
/><Map ViewId="accountsViewId"
ShowAddressSearch="true"
AddressSearchCountries="@(["US", "CA"])"
OnAddressSelected="@(place => _near = place?.Position)" />Azure Maps, Esri and Google suggest places as the visitor types. Google's key needs the Places API (New) enabled, and Esri's key its geocoding privilege. OpenStreetMap and MapLibre maps search OpenStreetMap's Nominatim, which its usage policy allows only for light use and never as someone types, so they search when Enter is pressed; name your own Nominatim server in OpenStreetMap:GeocoderUrl or MapLibre:GeocoderUrl for more.
Address (address in React) shows one address without a search box: the map finds it with the same search once it is drawn, and again whenever it changes, pins it and zooms to it. A spinner shows while it looks, and a message when it isn't found. It is what AddressDisplayValue's map pin opens for an address with no coordinates.
<Map address="1 Microsoft Way, Redmond, WA 98052" height="300px" /><Map Address="1 Microsoft Way, Redmond, WA 98052" Height="300px" />ShowPrintButton (showPrintButton in React) adds a Print button — on the toolbar of a MultiTable map, else over the map's top right corner — that prints the map as it shows, with its pins, its legend, the popup open on it and the provider's attribution. PrintTitle is the heading above it, and PrintEntries lists records under it, each beside its pin's label. A marker's Label is drawn on its pin in place of the dot: give stops a number and the printout reads as a route sheet. PrintAsync() (print() on the React ref) prints from a button of the page's own, and OnPrintChanged (onPrintChange) says when the map can be printed and whether it can be saved as an image, for that button to follow.
const stops: MapMarker[] = [{ kind: 'marker', id: '1', label: '1', title: 'Contoso', position: { latitude: 47.61, longitude: -122.33 } }];
<Map
ref={mapRef}
items={stops}
showPrintButton
printTitle="Tuesday's visits"
printEntries={stops.map((stop) => ({ label: stop.label, title: stop.title ?? '' }))}
/>
// The same from a button of the page's own.
mapRef.current?.print();<Map @ref="_map"
Items="stops"
ShowPrintButton="true"
PrintTitle="Tuesday's visits"
PrintEntries="@stops.Select(stop => new MapPrintEntry(stop.Title!, stop.Label)).ToList()" />
@code {
// A marker's Label is drawn on its pin, and beside it in the printed list.
private List<MapMarker> stops = [new() { Id = "1", Label = "1", Title = "Contoso", Position = new(47.61, -122.33) }];
private Map? _map;
// The same from a button of the page's own.
private Task PrintAsync() => _map!.PrintAsync();
}Azure Maps, Esri, OpenStreetMap and MapLibre maps are printed as an image of the map, so the page around the map is left out and the map doesn't move; the same button also saves that image as a PNG file (SaveImageAsync, or saveImage()). An open popup is drawn on the image with its title and fields, or the markers it lists; one showing the page's own content (PopupTemplate, or renderPopup) is drawn with its item's title. A Google map can't be copied into an image, so it is printed as it is on the page, popup and all, the rest of the page left out of the printout, and offers no image to save. A provider of your own is printed as an image when its surface implements capture.
NativeOptions are passed to the provider's own map as it is made, over the map's own. That turns on Street View for Google, for example, or lets a MapLibre map zoom with the scroll wheel alone (cooperativeGestures: false); by default it needs Ctrl, as Google's does on a page that scrolls, so the page scrolls past it. The provider's map object is also available to your own JavaScript: GetNativeMapAsync on Blazor, native on the React ref.
<Map provider={{ name: 'google' }} nativeOptions={{ streetViewControl: true }} /><Map Provider="@(new GoogleMapProvider())"
NativeOptions="@(new Dictionary<string, object?> { ["streetViewControl"] = true })" />A provider draws the base map and shapes, places elements and reports clicks and moves. Everything else is the map's. Register one with its name, then name it in the map's provider settings.
import { registerMapProvider, type MapSurface } from '@powerportalspro/react-maps';
registerMapProvider({
name: 'arcgis-sdk',
async create(context): Promise<MapSurface> {
// Draw into context.container with your library; report clicks and moves through
// context.events; return the surface: camera, projection, overlays and shapes.
},
});
On Blazor, use CustomMapProvider with the name you registered in your page's JavaScript.
MapLibre, which draws every provider but Google, loads a worker script from a file of its own. Install maplibre-gl beside @powerportalspro/react-maps, and tell it where your bundler put the worker once, before a map is drawn. Blazor needs no setup.
// main.tsx, with Vite
import { setMapLibreWorkerUrl } from '@powerportalspro/react-maps';
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url';
setMapLibreWorkerUrl(workerUrl);
Name | Type | Default | Description |
|---|---|---|---|
Address | string? | ||
AddressSearchCountries | IReadOnlyList<string>? | ||
AllowFiltering | bool | False | |
AllowNearSearch | bool | False | |
AllowSavedViews | bool | False | |
Center | MapPosition? | ||
Cluster | bool | True | |
ColorColumn | string? | ||
DefaultTables | IEnumerable<string>? | ||
DefaultViewId | Guid? | ||
FetchXml | string? | ||
Filters | IReadOnlyList<QueryFilter>? | ||
FitItemIds | IReadOnlyList<string>? | ||
Height | string | 400px | |
Items | IReadOnlyList<MapItem>? | ||
LatitudeColumn | string | address1_latitude | |
LongitudeColumn | string | address1_longitude | |
MapStyle | MapStyle | Road | |
MaxRows | int | 1000 | |
MultiTable | bool | False | |
NativeOptions | IReadOnlyDictionary<string, Object>? | ||
NearDistance | double? | ||
NearDistances | IReadOnlyList<double>? | ||
NearDistanceUnit | DistanceUnit | Miles | |
PopupTemplate | RenderFragment<MapItem>? | ||
PrintEntries | IReadOnlyList<MapPrintEntry>? | ||
PrintEntriesProvider | Func<IReadOnlyList<MapPrintEntry>>? | ||
PrintTitle | string? | ||
Provider | MapProvider? | ||
RecordColor | string? | ||
RecordColorSelector | Func<TableRecord, string>? | ||
RecordUrl | string? | ||
RecordUrlSelector | Func<TableRecord, string>? | ||
Regarding | TableRecordReference? | ||
SavedViewsKey | string? | ||
ShowAddressSearch | bool | False | |
ShowLegend | bool | True | |
ShowPopups | bool | True | |
ShowPrintButton | bool | False | |
ShowRecordLinks | bool | True | |
SignInUrl | string | Account/Login | |
Tables | IEnumerable<string>? | ||
Title | string? | ||
TitleColumn | string? | ||
UsePortalTheme | bool | True | |
ViewId | Guid? | ||
ViewIds | IEnumerable<Guid>? | ||
Width | string | 100% | |
Zoom | double? |
AddressAddressSearchCountriesAllowFilteringAllowNearSearchAllowSavedViewsCenterClusterColorColumnDefaultTablesDefaultViewIdFetchXmlFiltersFitItemIdsHeightItemsLatitudeColumnLongitudeColumnMapStyleMaxRowsMultiTableNativeOptionsNearDistanceNearDistancesNearDistanceUnitPopupTemplatePrintEntriesPrintEntriesProviderPrintTitleProviderRecordColorRecordColorSelectorRecordUrlRecordUrlSelectorRegardingSavedViewsKeyShowAddressSearchShowLegendShowPopupsShowPrintButtonShowRecordLinksSignInUrlTablesTitleTitleColumnUsePortalThemeViewIdViewIdsWidthZoomName | Type | Description |
|---|---|---|
OnAddressSelected | EventCallback<MapPlace> | |
OnError | EventCallback<string> | |
OnItemClick | EventCallback<MapItemClickEventArgs> | |
OnMapClick | EventCallback<MapClickEventArgs> | |
OnPrintChanged | EventCallback<MapPrintState> | |
OnViewChanged | EventCallback<MapView> |
OnAddressSelectedOnErrorOnItemClickOnMapClickOnPrintChangedOnViewChangedName | Parameters | Type | Description |
|---|---|---|---|
ClearAddressAsync | Task | ||
ClosePopupAsync | Task | ||
FitToItemsAsync | IEnumerable<string> ids | Task | |
GetMapSettingsAsync | string provider | Task<MapSettingsResponse> | |
GetNativeMapAsync | Task<IJSObjectReference> | ||
GetViewAsync | Task<MapView> | ||
GoToAddressAsync | string text | Task<MapPlace> | |
NotifyAddressSelectedAsync | MapPlace place | Task | |
NotifyAddressStatusAsync | string status | Task | |
NotifyClusterClickAsync | string[] ids MapPosition position | Task | |
NotifyErrorAsync | string kind string message | Task | |
NotifyItemClickAsync | string id MapPosition position | Task | |
NotifyMapClickAsync | MapPosition position | Task | |
NotifyViewChangedAsync | MapView view | Task | |
OpenPopupAsync | string id | Task | |
PrintAsync | Task | ||
SaveImageAsync | string fileName | Task<bool> | |
SetCameraAsync | MapCamera camera bool animate | Task |
ClearAddressAsyncClosePopupAsyncFitToItemsAsyncGetMapSettingsAsyncGetNativeMapAsyncGetViewAsyncGoToAddressAsyncNotifyAddressSelectedAsyncNotifyAddressStatusAsyncNotifyClusterClickAsyncNotifyErrorAsyncNotifyItemClickAsyncNotifyMapClickAsyncNotifyViewChangedAsyncOpenPopupAsyncPrintAsyncSaveImageAsyncSetCameraAsync