Sizing Point Symbols Across Zoom Levels Deterministically
Scale symbol area rather than radius as zoom increases, and express the result as three to five clamped interpolation stops rather than a continuous exponential — because a curve fitted to look right at one zoom level produces sub-pixel markers at the bottom of the range and tile-filling ones at the top.
Core Algorithm and Workflow
Two independent decisions sit inside “how big should this symbol be”.
The first is perceptual. A filled circle drawn at twice the radius covers four times the page and reads as roughly four times the magnitude. So a scale that maps a value linearly onto radius overstates the large end badly, and the correction is to map the value onto area — which means taking a square root on the way to the radius. This is the same reasoning that governs hierarchy tiers in Visual Hierarchy in Code, and it is why Matplotlib’s scatter takes an area rather than a diameter.
The second is about zoom. A symbol that keeps a constant pixel size across zoom levels looks correct nowhere: at low zoom it crowds a continent, at high zoom it disappears against a street network. Some growth with zoom is required, and the useful question is how much and with what limits.
The answer is a small set of stops with hard clamps at both ends. Below roughly eight pixels an icon’s shape stops being identifiable and it reads as a dot; above roughly forty a single marker starts to dominate a tile. Those two numbers bound the curve, and everything between them is interpolation.
Production-Ready Python Implementation
import math
MIN_IDENTIFIABLE_PX = 8.0 # below this an icon reads as a dot
MAX_REASONABLE_PX = 40.0 # above this one marker dominates a tile
def value_to_radius(value: float, ref_value: float, ref_radius: float) -> float:
"""Radius such that area is proportional to value."""
if value < 0 or ref_value <= 0:
raise ValueError("values must be non-negative and the reference positive")
return ref_radius * math.sqrt(value / ref_value)
def zoom_size_stops(base_px: float, design_zoom: int,
min_zoom: int, max_zoom: int,
area_growth_per_zoom: float = 1.35) -> list:
"""Clamped [zoom, size] stops for a symbol across a zoom range.
Area grows by `area_growth_per_zoom` per level, so the linear dimension
grows by its square root. Both ends are clamped to usable sizes.
"""
if not min_zoom < design_zoom < max_zoom:
raise ValueError("design zoom must lie inside the declared range")
linear = math.sqrt(area_growth_per_zoom)
stops = []
for z in sorted({min_zoom, design_zoom, (design_zoom + max_zoom) // 2, max_zoom}):
size = base_px * linear ** (z - design_zoom)
stops.append([z, round(min(MAX_REASONABLE_PX,
max(MIN_IDENTIFIABLE_PX, size)), 2)])
return stops
The clamps are applied per stop rather than to the curve as a whole, which is what keeps the emitted stops honest: a renderer interpolating between two clamped values produces clamped output, whereas clamping only at evaluation time leaves the declared curve claiming sizes the style never intends to draw.
Requiring the design zoom to lie strictly inside the range catches the common configuration error of designing at the minimum zoom, which produces a curve that only ever grows and hits the upper clamp early.
Performance Tuning and Cartographic Best Practices
- Separate icon and text curves. Text has a legibility floor around six points that is unrelated to the icon beside it, and readers tolerate smaller type than they do smaller symbols. Sharing one curve produces either tiny text beside a large icon or the reverse.
- Declare stops at the real range ends. Renderers clamp beyond the outermost stop rather than extrapolating. Relying on that deliberately, with stops at the style’s actual minimum and maximum zoom, is what stops a marker reaching 200 pixels at a zoom level nobody tested.
- Round the emitted sizes. Two decimal places is beyond any display’s ability to distinguish, and unrounded values make style diffs unreadable.
- Check the collision consequences. Symbol size feeds the label placement pass, so growing symbols shrinks the space available for labels. A size change that looks harmless can suppress a tenth of the labels at high zoom — the interaction described in Label Collision Avoidance Algorithms.
- Keep the legend in step. A proportional symbol legend must show circles at the same area scale the map uses, at values the reader can interpolate between — typically the minimum, a round middle value and the maximum.
Integration and Next Steps
Size stops are style output, so they belong alongside the icon references validated in Symbol and Sprite Pipelines — and the two interact: an icon rendered at 40 pixels from a 24-unit sprite at 1x is being upscaled, so the ceiling should be checked against the sprite’s actual pixel dimensions rather than chosen abstractly. Feed the resulting sizes into the collision pass rather than treating them as a purely visual setting, and keep the proportional symbol legend on the same area scale so the key remains interpolable.
Frequently Asked Questions
Why does area matter more than radius?
Because readers judge a filled mark by how much ink it puts on the page. A circle at twice the radius covers four times the area and is read as roughly four times the magnitude, so mapping a value linearly onto radius systematically overstates the large end — a sixteen-fold range in the data becomes a 256-fold range in ink. Mapping the value onto area, which means taking a square root to get the radius, is what makes the picture and the numbers agree.
How many stops does a size curve need?
Three to five. Two give a straight line in whatever space the renderer interpolates in, which usually misses the middle of the range; beyond five you are fitting detail nobody can perceive, since adjacent stops two zoom levels apart differ by a pixel or two. Place stops at the minimum zoom, the zoom the map was designed around, and the maximum zoom, then add one or two in between only where the curve visibly needs bending.
Should icon size and text size use the same curve?
No. Text has a hard legibility floor around six points that has nothing to do with the icon beside it, and readers tolerate smaller type than they do smaller symbols, so text should grow more slowly. Give them separate stop sets and inspect the pair at both extremes of the range: a large icon with a tiny caption and a small icon with oversized type are both routine outcomes of sharing one curve, and both look like a design error rather than a configuration one.
What happens at zoom levels beyond the declared stops?
Most renderers clamp to the nearest stop rather than extrapolating, which is the behaviour you want — but it is worth relying on deliberately rather than accidentally. Declare stops at the style’s real minimum and maximum zoom so the clamping happens at values that were chosen. Leaving the range open and trusting the interpolation to stay sensible is how a marker ends up two hundred pixels across at a zoom level that was never previewed.
Related
- Building Mapbox Sprite Sheets from an SVG Icon Library — the sprite whose pixel dimensions bound the size ceiling.
- Visual Hierarchy in Code — the same area argument applied to priority tiers.
Back to Symbol and Sprite Pipelines