Dev.to WebDev šŸ›  Dev šŸ‘ 0 šŸ“– 8 min read

Fanning out map markers in screen pixels, not map units

I make Last Thaw, a free browser strategy game. Up to a hundred players start with five settlers on a real map of Earth, cut into 408 regions, and have 30 real days to build an empire while an ice age advances from both

I make Last Thaw, a free browser strategy game. Up to a hundred players start with five settlers on a real map of Earth, cut into 408 regions, and have 30 real days to build an empire while an ice age advances from both poles. It's in early access and I'm looking for people to test it.

Everything that moves in the game is drawn on that map: settlers heading for a new region, caravans hauling goods, armies on the way to a raid, canoes and later ships on the sea lanes. Each one is a small round marker sitting on its route. This post is about one small bug in those markers that taught me something I should have known going in: some things on a zoomable map belong to the world, and some belong to the screen. Mixing them up gave me caravans swimming in the ocean.

The setup

The map is one SVG. The world lives in a fixed viewBox, and pan and zoom are a single transform on a group inside it:

/** The map's pan and zoom: the world is drawn translated by (x, y) and scaled by k, in viewBox units. */
export interface View {
  x: number;
  y: number;
  k: number;
}

Regions, coastlines, roads and routes are all in map units, so they scale with k like you'd expect. Markers are different. A marker should be the same size on screen whether you're looking at the whole planet or zoomed into the Mediterranean, so it gets the inverse scale. To do that the map needs to know how many map units one CSS pixel is worth, which it measures from the rendered SVG:

const [unitsPerPx, setUnitsPerPx] = useState(2.5);
useEffect(() => {
  const svg = svgRef.current;
  if (!svg) return;
  // How many viewBox units one CSS pixel covers at zoom 1. Re-measured whenever the map is resized.
  const measure = () => setUnitsPerPx(MAP_W / Math.max(1, svg.getBoundingClientRect().width));
  measure();
  const ro = new ResizeObserver(measure);
  ro.observe(svg);
  return () => ro.disconnect();
}, []);

Then each marker is drawn at its position on the route, scaled by unitsPerPx / view.k. At zoom 1 that's the plain conversion; at zoom 8 the marker is drawn an eighth the size in map units so it comes out the same size on screen. Fine so far.

The problem: everyone leaves from the capital

Early in a match nearly every convoy a player sends starts in the same place, their capital. Five settlers going out to claim neighbors, a caravan, a couple of scouts. At the moment they leave they all sit on exactly the same point, and for the first few minutes they're still close enough to cover each other. You can only hover the top one. The rest are invisible.

So I fanned them out. If a marker lands within a few units of one already placed, nudge it out onto a small ring around that spot, six to a ring, then a bigger ring for the next six. The first version did this where the markers were built, in the module that turns server data into marker positions:

// Fan out markers that sit on (almost) the same spot so each one can be hovered and clicked.
const placed: Array<{ x: number; y: number }> = [];
for (const mk of out) {
  let k = 0;
  // Spacing follows the marker size (the map draws them 1.75x): closer than 10 units overlaps, rings step 13 units out.
  while (placed.some((p) => Math.hypot(p.x - mk.x, p.y - mk.y) < 10) && k < 12) {
    const angle = (k * Math.PI) / 3;
    const r = 13 * (1 + Math.floor(k / 6));
    // ...move mk from ring position k-1 to ring position k...
    k += 1;
  }
  placed.push({ x: mk.x, y: mk.y });
}

It worked on my screen at the zoom I tested it at. It's even got a comment explaining that the numbers follow the marker size. That comment is the bug, I just didn't see it yet.

What went wrong

"10 units" and "13 units" are map units. The marker's size isn't. At the default zoom 13 map units is about the width of a marker, so the ring looks right. Zoom in 8x and those same 13 map units are now a long way on screen, while the markers stayed the same pixel size. The fan-out didn't shrink with the zoom, so it spread the markers further and further from where they actually were.

On land you just get a convoy floating oddly beside its road. On a coast it's worse. A settler leaving a coastal capital got pushed 13 units sideways, and at high zoom 13 units off a coastline is open water. Zoom in to watch your first caravan leave and it could be sitting in the sea, well off any route. It looked like a pathing bug. It was a drawing bug.

There was a second, quieter problem with the same root. The overlap test was also in map units. Zoomed out, two convoys a few regions apart are visually on top of each other but more than 10 units apart, so they weren't fanned at all. Zoomed in, convoys that were clearly separate on screen got nudged anyway. The test was answering the wrong question at every zoom level except one.

The fix: move it to where the pixels are known

The question "do these two markers overlap?" is about the screen. So is "how far should I push one so it's visible?" Neither can be answered by the code that builds markers, because that code doesn't know the zoom. The map does. So the fan-out moved out of the data module and into the render, and the data module went back to doing one thing:

// Every marker sits exactly on its route; the map nudges overlapping ones apart on screen, by a few pixels, not map units.
return out;

In the map, each marker works out how many earlier markers are within a marker's width of it, measured in screen pixels, and takes the next free spot on the ring:

{armies.map((a, i) => {
  const s = unitsPerPx / view.k;
  // Map units to screen pixels at the current zoom.
  const pxPer = (view.k / unitsPerPx) || 1;
  let k = 0;
  for (let j = 0; j < i; j++) {
    const b = armies[j]!;
    // Closer than 18px on screen counts as overlapping, whatever the zoom.
    if (Math.hypot(b.x - a.x, b.y - a.y) * pxPer < 18) k += 1;
  }
  // First marker stays put; the next six take a 16px ring, the six after that a 32px ring, and so on.
  const ring = k === 0 ? 0 : 16 * (1 + Math.floor((k - 1) / 6));
  const ang = ((k - 1) * Math.PI) / 3;
  const dx = k === 0 ? 0 : Math.cos(ang) * ring;
  const dy = k === 0 ? 0 : Math.sin(ang) * ring;
  return (
    <g
      key={a.id}
      // Position in map units, then scale to screen size, then the nudge, which is now in pixels.
      transform={`translate(${a.x} ${a.y}) scale(${s * MARKER_SCALE}) translate(${dx / MARKER_SCALE} ${dy / MARKER_SCALE})`}
    >
      {/* ...the marker itself... */}
    </g>
  );
})}

The trick is the order of the transforms. translate(a.x a.y) puts the marker on its route in map units. scale(...) switches everything after it into screen-sized units. The last translate happens after the scale, so dx and dy are effectively pixels (divided by MARKER_SCALE because the marker itself is drawn 1.75x). The offset is now a fixed 16 pixels at any zoom, so a fanned marker is always visually next to its true position and never wanders off the coast.

A few things I like about this version:

  • The data is honest. armyMarkers() returns where things really are. Anything else that reads it (the hover card, the route highlight, the arrow showing which way a convoy is heading) gets the true position, not a nudged one.
  • Zoom fixes overlap for free. Zoom in on a crowded capital and the markers separate on their own, because the 18px test stops matching. The ring only exists while you need it.
  • It's cheap. It's O(n²) over the markers on screen, which sounds bad until you remember a player sees their own convoys plus whatever is coming at them. That's tens, not thousands. I'd revisit it with a grid if it ever showed up in a profile. It hasn't.

The same rule for the badges

The same question comes up with the region badges (the little circles that mark a battle and other events on a region). Those sit on a region's center and need to fan out when there's more than one, so they use the same move: position in map units, scale, then offset in screen units.

// Fan several badges on one region around its center so each stays visible.
// A battle's crossed swords sit large on the region's center; the rest fan out beside it.
const k = badges.slice(0, i).filter((x) => x.regionId === b.regionId && !x.battleId).length;
const s = (unitsPerPx / view.k) * BADGE_SCALE * (battle ? BATTLE_BADGE_SCALE : 1);
const dx = battle ? 0 : 9 + k * 11; // in badge units, which are screen-sized after the scale

The rule I ended up writing down for this codebase is short: positions are world, sizes and gaps are screen. If a number describes where something is on Earth, it's in map units and it scales with zoom. If it describes how big something looks or how far apart two things should look, it's in pixels and it goes after the scale.

What I'd do differently

I'd have put the fan-out in the renderer from day one. The giveaway was that the first version needed a comment explaining that its numbers depended on how big the map draws markers. Any time a data function has to know about the renderer's scale to get its numbers right, it's probably doing the renderer's job.

I'd also have tested at more than one zoom level. The bug was invisible at the zoom I developed at and obvious at 8x, and players zoom in a lot more than I expected, especially in the first hour when they're watching their first settlers go out.

If you want to see it working, every convoy you send in a match gets drawn this way. The how to play page covers what each marker means. And if you've got a few minutes a day and want to help test a slow strategy game while it's in early access, a new match opens every week at lastthaw.com.

Feel free to borrow the transform-order trick for your own maps. It works the same in canvas if you swap the SVG transforms for ctx.translate and ctx.scale in the same order.

šŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.