Design techniques

A Power BI map without Azure Maps: a scatter chart over an image

Replace Azure Maps with a native scatter chart: longitude on X, latitude on Y, axes bounded to a map image set as the plot area background.

9 min read

A map of France drawn as a scatter chart: the plot area is the map image, bounded by longitude -8.2 to 11.9 and latitude 41.0 to 51.5, with city bubbles in gold

The Network page of Colis Pro, a free Power BI report built with an AI agent, started with an Azure Maps visual: one bubble per pickup point, dark map style. It gave us three problems. The map moved on its own. It asked for a Microsoft sign-in. And after a reload, a sign-in dialog blocked Power BI Desktop, which stopped the loop we use to render and capture every page.

So we replaced it with a native visual: a scatter chart drawn over an image of France. Longitude on X, latitude on Y, the axes hidden and bounded to the edges of the image. This article shows how it works, why the image has to be drawn a certain way, and how to build it in Power BI Desktop.

Before and after

On the left, the Azure Maps visual shows only a sign-in message. On the right, the same rectangle holds a scatter chart of 87 cities over a drawn map of France.
Left: Azure Maps, asking to sign in. Right: the same rectangle, a scatter chart over a drawn map. 87 cities, sized by pickups, red when saturated.

What the replacement gives you:

  • A native visual. No online map service and no sign-in prompt: the scatter chart and its image are saved inside the report.
  • A map that stays put. Same extent, same framing, every time the page opens.
  • A look you choose. Colours, borders, country names, the dotted sea: everything is drawn in the image.
  • The usual interactions. Tooltips still work, and a click on a bubble filters the pickup point sheet on the right of the page.

How it works

Two settings of the scatter chart do the job. Its plot area can hold a background image, and its axes accept a fixed start and end. Give the axes the geographic edges of the image, hide them, and every longitude and latitude pair lands on the right pixel of the drawing.

Y axis: start 41.0 (bottom edge), end 51.5 (top edge)-5°0°5°10°43°45°47°49°51.541.0-8.211.9022X axis: start -8.2 (left edge), end 11.9022 (right edge)Paris2.35 E, 48.86 Nlon 2.35lat 48.86plot area = map image (842 x 636)x = (2.35 - (-8.2)) / 20.1022 × 842 = 442 pxy = (51.5 - 48.86) / 10.5 × 636 = 160 pxThe axes and the drawing use the same fractions, so the bubble lands on the right pixel.

In Colis Pro, the visual is 842 x 636 px. The X axis runs from -8.2 to 11.9022 and the Y axis from 41 to 51.5. The image covers exactly that rectangle, so a bubble at a given longitude sits at the same fraction of the width as that longitude in the drawing. We measured the result on a Desktop capture: the bubbles land within one pixel of where the map puts their cities.

The map image alone: France in blue with its regions and departments, neighbouring countries and a dotted sea, country and sea names written in.
The plot area image on its own, a transparent PNG over the page background. Every bubble in the first image is the scatter chart.

The image must be drawn in the same projection

Scatter axes are linear: one degree is the same number of pixels anywhere on the axis. The image has to follow the same rule, in both directions. Colis Pro's map is generated by a Python script, tools/reseau.py, and this is its whole projection:

PYTHON
X0, Y0, W, H = 48, 262, 842, 636
LAT_MIN, LAT_MAX = 41.0, 51.5
PX_DEG = H / (LAT_MAX - LAT_MIN)
K_LON = math.cos(math.radians((LAT_MIN + LAT_MAX) / 2))
LON_MIN = -8.2
LON_MAX = LON_MIN + W / (PX_DEG * K_LON)

def proj(lon, lat):
    return ((lon - LON_MIN) / (LON_MAX - LON_MIN) * W, (LAT_MAX - lat) / (LAT_MAX - LAT_MIN) * H)

proj is linear in longitude and linear in latitude, the same as the axes. Every border, coastline and country name in the image goes through it.

K_LON only sets the shape. At the middle latitude of the map (46.25° N), a degree of longitude is about 0.69 of a degree of latitude on the ground, so the script gives it 0.69 of the pixels: 41.89 px per degree of longitude against 60.57 px per degree of latitude. Without it, France comes out stretched sideways. That is also why the right edge is 11.9022, a number nobody would pick by hand: the script computes it from the width of the visual.

The trap is the map you did not draw. Most web maps use Web Mercator, where the gap between two latitudes grows towards the north. Bound a Mercator image with linear axes and only the edges match.

Scatter Y axislinear in latitudeMap drawn by reseau.pylinear in latitudeWeb map (Mercator)wider towards the north41°41°41°43°43°43°45°45°45°47°47°47°49°49°49°51.5°51.5°51.5°up to 15 px offnear 46.5° N(to scale)same positiondriftsSame top and bottom in all three. Only a drawing that is linear in latitude keepsevery latitude in between where the axis puts it. Longitude is linear in all three.

Over France, on a 636 px map, the drift reaches about 15 px around 46.5° N: a quarter of a degree, some 28 km on the ground.

Step by step in Power BI Desktop

First, the coordinates. Colis Pro shows one bubble per city, at the average position of its pickup points, so the axes take two measures:

DAX
Carte longitude = AVERAGE ( DimPointDepot[Longitude] )

Carte latitude = AVERAGE ( DimPointDepot[Latitude] )

Then the visual:

  1. Add a scatter chart and fill its wells: Values DimPointDepot[Ville] (one point per city), X Axis [Carte longitude], Y Axis [Carte latitude], Size [Retraits] (pickups). Colis Pro adds occupancy, parcels waiting and saturated points as tooltips.
  2. Fix the axes. In X axis > Range, set Minimum -8.2 and Maximum 11.9022. In Y axis > Range, 41 and 51.5. Use the bounds of your own image.
  3. Hide everything around the plot. Turn off the X axis and Y axis, their titles, the gridlines and the legend. In General, turn off the title, background and border, and set the padding to 0.
  4. Set the image. In Plot area background, pick your PNG, set the image fit to Fill and the transparency to 0 %.
  5. Size the visual to the image's ratio. Colis Pro's visual is 842 x 636 and the image 2526 x 1908, three times larger, so Fill has nothing to crop or distort.
The Colis Pro scatter chart selected in Power BI Desktop, and its Plot area background settings: the image carte-france-reseau.png, image fit Fill, transparency 0 %
Step 4 in Desktop: the map is the plot area background of the scatter chart, set to Fill with no transparency.
The same scatter chart with no axis drawn, and its X-axis Range settings: minimum -8.2, maximum 11.9022, values off
Steps 2 and 3: the X axis fixed from -8.2 to 11.9022 with its values and title turned off. The Y axis gets 41 and 51.5 the same way.

In the report files (PBIR), the whole trick is a few properties of the visual:

JSON
"categoryAxis": [{ "properties": {
  "show":          { "expr": { "Literal": { "Value": "false" } } },
  "start":         { "expr": { "Literal": { "Value": "-8.2000D" } } },
  "end":           { "expr": { "Literal": { "Value": "11.9022D" } } },
  "showAxisTitle": { "expr": { "Literal": { "Value": "false" } } }
}}],
"plotArea": [{ "properties": {
  "transparency": { "expr": { "Literal": { "Value": "0D" } } },
  "image": { "image": {
    "name":    { "expr": { "Literal": { "Value": "'carte-france-reseau.png'" } } },
    "url":     { "expr": { "ResourcePackageItem": {
      "PackageName": "RegisteredResources", "PackageType": 1,
      "ItemName": "carte-france-reseau.png" } } },
    "scaling": { "expr": { "Literal": { "Value": "'Fill'" } } }
  }}
}}]

valueAxis is the same with 41.0000D and 51.5000D. The image itself sits in StaticResources/RegisteredResources, and Desktop registers it for you when you pick it in the Format pane.

Colour and size by measure

The bubble colour is a measure that returns a hex code:

DAX
Carte couleur ville =
IF ( DIVIDE ( [Points saturés], [Points actifs] ) >= 0.1, "#E5484D", "#FDCB12" )

Points saturés =
COUNTROWS ( FILTER ( VALUES ( DimPointDepot[PointID] ), [Occupation] >= [Seuil saturation] ) )

Apply it through the conditional formatting (fx) of the marker colour, format style Field value. In the file, it is a dataPoint.fill that points to the measure.

The 10 % rule came from counting. Red for any city with one saturated point turned 18 cities red; 20 % left a single one. At 10 %, 9 cities of 87 are red, Paris included (18 of its 130 points). And because [Points saturés] reads [Seuil saturation], the map follows the threshold set in the report's settings panel.

The bubble size setting (bubbleSize in the file) took three renders to settle:

Three crops of the north of France: at bubble size -10 Paris covers its whole region, at -30 the cities are pinheads, at -20 they read well.
At -10, Paris covers the Île-de-France. At -30, the cities shrink to pinheads. -20 stayed. The colour rule changed across these renders too: any saturated point, then 20 %, then 10 %.

City names are text boxes

The scatter chart's own category labels would name all 87 cities. Colis Pro names 11 large ones instead, with plain text boxes (Segoe UI Semibold, 9 pt, white) laid over the chart.

Each text box is placed with the same projection, plus the visual's position on the page and the bubble's radius:

PYTHON
x, y = proj(*centres[nom])
larg = 110
r = RAYONS.get(nom, DECALAGE_LIBELLE - 5) + 5
gx, gy = (X0 + x + r if cote == "d" else X0 + x - r - larg), Y0 + y - 12

cote is the side of the label ("d" right, "g" left), larg the width of the text box. The radii were measured on a capture at bubble size -20: Paris is 27.8 px. Its label lands at x = 48 + 442 + 33 = 523, which is where it sits in the report.

City names and legend11 text boxes and a legend card, on topScatter chart87 bubbles, axes and titles off, padding 0Plot area imagecarte-france-reseau.png, 2526 x 1908Page backgroundfond-04-reseau.pngOne extent-8.2 to 11.9022 E, 41 to 51.5 Nposition + radiusaxis start and enddraws the mapsits behind: nothing to alignEverything that must line up comes from the same numbers. Change the extent, change all three.

The legend followed the same path. It started as a text box in the Atlantic, the one empty area of the map, then became a card showing an SVG measure so it could switch to English with the rest of the report.

Restyling means redrawing one PNG

Because the scatter chart only knows the extent, the map's look can change without touching the visual. We compared six colourings as mockups, then applied the chosen one by regenerating the PNG.

The same scatter chart over two versions of the map image: a darker blue France on the left, the brighter blue with a dotted sea on the right.
Same bubbles, same labels, same visual. Only the image changed: France in the brand blue, the sea as a dot grid, neighbouring countries a little more present.

What you give up

Azure MapsScatter over an image
Zoom and panYesNo, one fixed extent
Base mapOnline map styles at any scaleYour drawing, at one scale
LocationsCoordinates or place namesNumeric longitude and latitude
LayersBubbles, heat map and moreBubbles
Online serviceRequired (it showed us a sign-in prompt)None
Text on the mapDrawn by the map serviceBaked into the image

That last row has a cost. When Colis Pro switches to English, the country and sea names stay in French, because they are pixels. Our bilingual report lists it as an accepted limit.

The traps

  • Hide the axis titles, not just the axes. In our first render, the axes were gone but "Latitude" and "Longitude" still showed along the edges.
  • Padding 0, no axis, no title, no legend. Labels are placed in page pixels, so they assume the plot area fills the whole visual. Anything that shrinks it moves the map and the bubbles away from the text boxes.
  • Keep the aspect ratio. Resize the visual proportionally, or redraw the image at the new ratio.
  • Leave the zoom slider off. Zooming changes the axis range, and the background image does not follow.
  • No web map screenshots over a large area. Their latitudes are not linear (see above).
  • Text boxes ignore filters. If a slicer hides Paris, the word Paris stays on the map.

Getting a map image without Python

Any tool that draws longitude and latitude linearly will do. In a GIS such as QGIS, display your layers in plain longitude and latitude (EPSG:4326, WGS 84) and export exactly the extent you will type into the axes. Restyle the export in your design tool if you like, under one rule: a resize, even a non-proportional one to match the visual's ratio, keeps both scales linear and is safe; a crop moves the edges, so the axis bounds have to move with it.

The same technique works for any flat coordinates, not only maps: a warehouse floor plan with X and Y in metres, for instance, under a scatter of its bins.

Try it

Download Colis Pro, free. The map is on the page Réseau (Network): the scatter chart sits over the panel, the image is carte-france-reseau.png in the report's registered resources, and the measures [Carte longitude], [Carte latitude] and [Carte couleur ville] are in the display folder 40 Réseau.

If you want this kind of finish on your own model without building it by hand, ChartCraft generates designed Power BI projects, and its MCP server lets your agent do it on your semantic model.

FAQ

Does it work once published to the Power BI Service?

Yes. The scatter chart is a core visual and the image is saved in the report, so nothing depends on an online map service.

Can users zoom into a region?

Not in the same visual: the extent is fixed. Build a second scatter chart with its own image and bounds, on another page.

Do I need to code to make the map image?

No. Any tool that draws longitude and latitude linearly, such as a GIS in EPSG:4326, can export the image; Desktop does the rest.

With your consent, we use Google Analytics cookies to measure audience. Cookies needed for sign-in and payment are always on. Learn more