A map minimap in SVG
A recipe for a map with a minimap, using d3-geo, nested SVGs, viewBox, and <use>
A recipe for a map with a minimap, using d3-geo, nested SVGs, viewBox, and <use>
At the end of the zoom loupe recipe, I said the technique could work for minimaps and map insets. Let's apply it to a real map and build what's called a locator inset in cartography. A minimap that shows where the zoomed view sits in the full map.
Here is what we're building. Drag the red rectangle to move around the map.
We'll use d3-geo to project geographic coordinates to SVG. Nothing in the recipe depends on it though. It works with any map that ends up as SVG paths, however you got them. I just happen to like d3-geo.
import { geoEqualEarth, geoGraticule10, geoPath } from "d3-geo";import { feature } from "topojson-client";import world from "./land-50m.json";const projection = geoEqualEarth().fitSize([960, 480], { type: "Sphere" });const path = geoPath(projection);const land = feature(world, world.objects.land);const spherePath = path({ type: "Sphere" }); // the globe's outlineconst graticulePath = path(geoGraticule10()); // the grid linesconst landPath = path(land); // the coastlines
Each path is a d string in one 960ร480 coordinate space.
Render them in an <svg> whose viewBox matches that space, and you get the whole world.
svg`<svg viewBox="0 0 960 480"> <path d="${spherePath}" /> <path d="${graticulePath}" /> <path d="${landPath}" /></svg>`
If we give the viewBox a smaller width and height, a smaller region of the map fills the same element.
Everything in that region renders bigger. Hence, we zoom in.
svg`<svg viewBox="371 7 240 120"> <path d="${spherePath}" /> <path d="${graticulePath}" /> <path d="${landPath}" /></svg>`
Now we are looking at Europe.
The viewBox picks which rectangle of the 960ร480 space fills the element.
Its four numbers are x, y, width, and height 1.
That rectangle is our window into the map. Let's call it the zoom window.
Yes, I am aware of map projections. It's a deep subject that I can't really get into here, so here is the two-sentence version. A projection flattens the globe onto a plane, and no flattening preserves area, shape, distance, and direction all at once. Every projection gives up some of these to keep others, which is why there are so many of them.
Equal Earth keeps areas 'honest' across the whole world, at the cost of some shape. A dedicated map of one region would pick a projection fitted to that region instead, like Lambert Conformal Conic for Europe or Albers for the USA. That can't be done here. The whole trick is that both views share one geometry, and one geometry means one projection. Web maps like Google Maps make the same trade.
A minimap is the whole map, drawn small, on top of the zoomed view. That means showing the same geometry twice, at two different scales. We don't want to draw it twice.
So the map is put in a <g id> inside <defs>, where it is not rendered by itself 2.
The <use> element renders a copy of whatever its href points to, without duplicating it in the document 3.
Two <use> windows look at the map using different viewBoxes.
A nested <svg> gets its own viewBox, so each window decides for itself what it looks at 4.
svg`<svg viewBox="0 0 500 250"> <!-- 1. The map's geometry. Rendered once. --> <defs> <g id="map"> <path d="${spherePath}" /> <path d="${graticulePath}" /> <path d="${landPath}" /> </g> </defs> <!-- 2. The main view: a window into the map --> <svg width="500" height="250" viewBox="371 7 240 120"> <use href="#map" /> </svg> <!-- 3. The minimap: the full extent --> <svg x="340" y="165" width="150" height="75" viewBox="0 0 960 480"> <use href="#map" /> </svg></svg>`
Here is that markup as a tree. Both <use> elements reference the single <g id="map"> in <defs>.
The only difference between the two windows is their viewBox.
The main view crops to the region you are looking at. The minimap shows the whole 960ร480 map.
The minimap comes after the main view in the document, so it paints on top 5.
Let's add a <rect> inside the minimap to show where the main view is looking.
So the rect that outlines the zoom window is the main view's viewBox. The same four numbers.
<svg x="340" y="165" width="150" height="75" viewBox="0 0 960 480"> <use href="#map" /> <rect x="371" y="7" width="240" height="120" fill="transparent" stroke="tomato" /></svg>
What I like about this approach is that the two maps never coordinate, as in, we don't have to draw two maps and keep them in sync.
Just four numbers for the zoom window viewBox, written in one place and read by both views.
const zoomWindow = { x: 371, y: 7, w: 240, h: 120 };// the main view's viewBox<svg width="500" height="250" viewBox={`${zoomWindow.x} ${zoomWindow.y} ${zoomWindow.w} ${zoomWindow.h}`}> <use href="#map" /></svg>// the minimap's indicator<rect x={zoomWindow.x} y={zoomWindow.y} width={zoomWindow.w} height={zoomWindow.h}/>
To move the zoom window, change the numbers. Nothing needs to stay in sync.
Both windows draw the same paths that we defined in <g id="map"> in the <defs>.
But the main view draws them at 13ร the minimap's scale.
The stroke-width is defined on that shared geometry but viewed at very different scales.
A width that suits the minimap draws far too thick in the main view, and there is no per-window place to override it.