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.
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.
Why not <symbol>? It also defines reusable graphics that only render through <use> 7.
But a symbol carries its own viewBox, baked into the definition, and <use> can't override it per instance.
This recipe needs two different viewBoxes over the same geometry, one per window.
So the definition stays a viewport-less <g>, and each nested <svg> supplies its own window.
<symbol> shines when the framing is a property of the artwork, like an icon sprite, where every instance shows the whole drawing and the consumer never learns its coordinate space.
Here the framing is a property of each window. That's the whole recipe.
That baked-in framing has fun uses of its own.
I chained <symbol>s into self-similar patterns, each level <use>-ing the previous one into ever smaller boxes.
Maybe not very <use>-ful, but it is fun.
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.
One touch detail. On a touchscreen, dragging the rect would also scroll the page, because the browser keeps handling touch gestures unless told otherwise.
touch-action: none turns that off.
The rect would be the natural place for it, but browsers don't reliably honor touch-action on SVG child elements, so it goes on the demo's root <svg>.
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
The ‘symbol’ element is used to define graphical templates which can be instantiated by a ‘use’ element but which are not rendered directly. [...] A ‘symbol’ establishes a nested coordinate system for the graphics it contains.SVG2 Spec: 5.5 The ‘symbol’ element