Alex Garbiak
← Notes

Building the "Where I've Been" map

astrosvgdata-viz

The first thing I built in the new /lab section is a small travel map: countries I’ve visited are shaded in, and specific cities have markers you can hover, focus, or tap for the date and a short note.

Why not Leaflet or Mapbox

This site is a fully static Astro build with no client-side framework and no external API calls. Pulling in Leaflet or Mapbox would mean a real map library, tile requests to an external server on every page view, and tiles that don’t restyle for dark mode. For a personal “here’s where I’ve been” page, none of that complexity earns its keep.

Instead, the whole map is generated once at build time and shipped as plain SVG.

Build-time geometry, not a runtime map

Two small libraries do the work in the component’s frontmatter, which runs in Node during astro build, not in the browser:

  • world-atlas ships simplified world country borders as TopoJSON.
  • topojson-client’s feature() converts that into GeoJSON.
  • d3-geo projects it: geoNaturalEarth1().fitSize([960, 500], countries) picks a projection that fits the whole world into a fixed viewBox, and geoPath turns each country’s geometry into an SVG d attribute.

The output is a <path> per country and a <circle> per visited place — no client-side map code at all, just static markup.

One dataset drives both layers

Every visited place — a city, with latitude, longitude, a date, and a one-line note — lives in a single typed array, src/data/visited-places.ts. The set of “visited” countries isn’t stored separately; it’s derived from that same list (new Set(places.map(p => p.countryName))), so there’s only one place to update when a new trip gets added.

Dates aren’t always precise — “home since 2020”, “grew up in the 1990s” — so each place also carries a plain sortKey ("YYYY-MM") used only to order the list, separate from the human-readable date string that’s actually displayed.

Each place’s [lng, lat] is run through the same projection used for the country borders, so its marker lands in exactly the right spot on the map.

Countries in that set get a visited class with a translucent accent fill (fill-opacity: 0.28) rather than a solid color — the goal was to still be able to see the base map underneath, not just block it out.

Hover, focus, and tap all show the same thing

Each marker is a focusable SVG element (tabindex="0", role="button") rather than relying on the browser’s native title tooltip, which is slow to appear and unstyleable. A small script listens for mouseenter/focus to show a single shared tooltip element, and mouseleave/blur/click-elsewhere/Escape to hide it.

Handling focus the same way as mouseenter means keyboard users get identical information to mouse users, not just a visual hover effect they can’t reach.

A fallback that isn’t an afterthought

The map is followed by a collapsed <details> list of the same places, sorted by date. It’s there for anyone who’d rather scan text than hover over dots, and it means the content is fully available with JavaScript off, to screen readers, and to search engines — none of which reliably read details out of an SVG.

A note on map resolution

world-atlas ships country borders at three resolutions. The map started out on countries-110m.json, the smallest, but that file drops tiny territories entirely — no Bermuda, no Hong Kong. Since none of this runs in the browser, there’s no client-side cost to using the more detailed countries-50m.json instead, so that’s what it uses now.

Categories, colored pins, and filtering without a re-render

Once there were 12 real places — a mix of home, work postings, and family holidays — a single color stopped being useful. Every place now has a category: 'personal' | 'work' | 'family', and pins are colored purely with CSS attribute selectors keyed off a data-category attribute (.marker[data-category="work"] circle { fill: var(--cat-work); }), never inline styles. Category is never color-only, either — the tooltip and the text fallback both spell it out, since color alone would exclude colorblind readers.

Filtering (three toggle pills, all on by default) is the one piece of this whole page that’s genuinely dynamic rather than build-time-computed: clicking a pill recomputes the active category set and toggles an is-filtered-out class on the matching markers and list rows. Countries get the same treatment, but need one extra piece of build-time data: each country <path> carries a data-categories="work personal" attribute (every category present in that country, derived from the same places array), so a country only un-shades once none of its categories are still active — the UK stays shaded if you turn off “Work”, because Guildford and Cornwall are still visible there.

Zooming in without a real zoom

Five of the twelve places (Guildford, Huddersfield, London, Birmingham, Cornwall) all sit within about 150 miles of each other — indistinguishable dots at whole-world scale. The obvious fix, cropping the existing SVG’s viewBox to magnify the UK, turns out to be a trap: markers and strokes sized for a whole-world view become oversized once you zoom a shared coordinate space into a small corner of it, and the Natural Earth projection isn’t built for a close-up view anyway.

Instead there are just two separately pre-built maps, both static SVG generated at build time the same way as the world view, switched by a plain “World / UK” toggle (CSS display: none, nothing removed from the DOM). The UK map uses its own projection (geoMercator().fitExtent(...) fitted to the UK’s own geometry) and its own correctly-sized markers — and since there’s finally room, it also draws a text label next to each pin, which the world view deliberately doesn’t do (12 labels at world scale would be clutter; 5 in a UK-sized frame reads fine). A marker counts as “in frame” by checking whether its projected point falls inside the view’s box, not by checking countryName === 'United Kingdom' — so a future place in, say, Dublin would show up here automatically without any extra logic.

Ideas for later: real photos per place, and linking places to their own trip write-ups.