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.

Radius-linear scaling overstates the top of the range Two rows of four circles representing values of 10, 40, 90 and 160. In the radius-linear row each circle's radius is proportional to the value, so the largest circle covers 256 times the area of the smallest for a 16-fold difference in value. In the area-linear row the areas are proportional to the values and the largest circle covers 16 times the area of the smallest. radius ∝ value — overstates 10 40 90 160 16× the value, 256× the ink area ∝ value — correct 10 40 90 160 16× the value, 16× the ink Both rows encode the same four numbers. Only the right-hand row encodes them proportionally, and the left-hand row is what a naive size-equals-value mapping produces.
The overstatement compounds: a sixteen-fold range in the data becomes a 256-fold range in ink, which is why radius-linear proportional symbol maps read as dominated by two or three features.

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.
Where the clamps bind, and what the curve would do without them Symbol pixel size against zoom from 4 to 18. The unclamped curve falls to 2 pixels at zoom 4 and reaches 96 pixels at zoom 18. The clamped stop curve holds at the 8 pixel floor until zoom 7, follows the growth curve through the middle of the range, and holds at the 40 pixel ceiling from zoom 16. Four emitted stops are marked. 0 48 px 96 px zoom level 4 8 12 16 8 px floor 40 px ceiling unclamped emitted stops The two curves agree through the middle, where the growth rate was chosen, and diverge at both ends.
The clamps are not a safety net around a good curve — they are where the curve is defined at the zoom levels least likely to be reviewed.

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.

A legend that stays interpolable Two proportional symbol legends. The first shows three separate circles side by side at values 10, 60 and 200, which forces the reader to compare across a gap. The second nests the three circles sharing a baseline, so their relative areas are directly comparable and an intermediate value can be estimated by eye. side by side — hard to compare 10 60 200 areas compared across a gap nested on a shared baseline 200 60 10 intermediate values estimable by eye Both legends encode the same scale; only the nested one lets a reader place a fourth value on it.
A proportional symbol legend is a ruler, and a ruler with its marks separated is much harder to read between. Nesting costs nothing and restores interpolation.

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.


Back to Symbol and Sprite Pipelines