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.
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 shows the 'zoomed-out' full extent view on top of the zoomed-in view to give a sense of where we are looking.
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>`
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.
vector-effect="non-scaling-stroke" makes stroke-width mean screen pixels instead of map units 6.
Toggle the checkbox above. The strokes drop to half a pixel of screen space, so the coastlines and graticules render thin in both windows.
<path d="…" stroke-width="0.5" vector-effect="non-scaling-stroke" />
One limitation of <use>. The minimap renders the same full-resolution geometry as the detail view. You cannot give it simplified topology.
The locator inset has a counterpart, the detail inset, which magnifies a busy part of an overview map.
That's the same construction with the two viewBoxes swapped, so a detail inset is the zoom loupe again. A maximap.
The red rect is a plain SVG element, so it has no drag behavior of its own.
react-aria's useMove provides it.
The hook normalizes mouse, touch, and pointer events into one onMove callback that reports each movement as a deltaX and deltaY in screen pixels.
The zoom window doesn't live in screen pixels though. It lives in the map's 960×480 coordinate space.
getScreenCTM() on the minimap's nested <svg> returns the matrix that maps its map units to screen pixels.
The matrix's a and d entries are the horizontal and vertical scale factors, so dividing the pixel deltas by them converts them to map units.
const { moveProps } = useMove({ onMove(e) { const ctm = svgRef.current.getScreenCTM(); onPan(e.deltaX / ctm.a, e.deltaY / ctm.d); },});<rect x={win.x} y={win.y} width={240} height={120} {...moveProps} />;
The pan handler adds the deltas to the zoom window's x and y, clamped so the window can't leave the map.
function onPan(dx, dy) { setWin((w) => ({ x: Math.min(Math.max(w.x + dx, 0), 960 - 240), y: Math.min(Math.max(w.y + dy, 0), 480 - 120), }));}
Working in deltas keeps this small. We never ask where the pointer is inside the map, only how far it moved, so two scale factors are the entire coordinate conversion.
And we read the CTM from the minimap on purpose. Its viewBox never changes, so its scale factors are stable, while the main view's viewBox shifts on every drag.
The value of the ‘viewBox’ attribute is a list of four numbers min-x, min-y, width and height, separated by whitespace and/or a comma, that specify a rectangle in user space that should be mapped to the bounds of the SVG viewport established by the given element.SVG2 Spec: 8.6 The ‘viewBox’ attribute
The ‘defs’ element is a container element for referenced elements. [...] Elements that are descendants of a ‘defs’ are not rendered directly [...] Note, however, that the descendants of a ‘defs’ are always present in the source tree and thus can always be referenced by other elements.SVG2 Spec: 5.4.2 The ‘defs’ element
The ‘use’ element references another element, a copy of which is rendered in place of the ‘use’ in the document. The referenced element may be a container element, in which case a copy of the complete SVG document subtree rooted at that element is used.SVG2 Spec: 5.6 The ‘use’ element
Including an ‘svg’ element inside SVG content creates a new SVG viewport into which all contained graphics are drawn; this implicitly establishes both a new viewport coordinate system and a new user coordinate system.SVG2 Spec: 8.8 Establishing a new SVG viewport
Elements in an SVG document fragment have an implicit drawing order, with the first elements in the SVG document fragment getting "painted" first. Subsequent elements are painted on top of previously painted elements.SVG1.1 Spec: 3.3 Rendering Order
With the non-scaling-stroke vector effect, stroke outline shall be calculated in the "host" coordinate space instead of user coordinate system. [...] The resulting visual effect of this modification is that stroke width is not dependant on the transformations of the element (including non-uniform scaling and shear transformations) and zoom level.SVG2 Spec: 13.6 Vector effects