Notes

Singapore does not exist at 1:110m, and other notes from counting countries in the browser

Written 23 September 2026  ·  by Aki Takashima, Shiori Studio

I made a page that answers “how many countries have you been to?” from files people already have: photos, a Google Takeout folder, a Google Maps Timeline export. It is one HTML file with no dependencies, it runs entirely in the browser, and it uploads nothing. Try it here; the code is on GitHub under MIT.

Almost none of the work was where I expected. These are the parts that surprised me.

The default world map is missing the countries people photograph most

Most web-map tutorials start from world-atlas's countries-110m.json, Natural Earth at 1:110m. It has 177 shapes. It does not have Singapore, Hong Kong, Macao, Malta, Monaco, Vatican City, Andorra, Liechtenstein, San Marino, Bahrain, the Maldives, Mauritius, Barbados, Tonga or Samoa. They are too small to draw at that scale, so they are not in the file at all.

For a map that is fine. For a lookup it is a silent bug. At 1:110m Malaysia's outline covers the island of Singapore, so a photo taken in Singapore lands inside Malaysia and is counted as Malaysia, with no error anywhere. Most of Hong Kong lands in China, Monaco in France, Vatican City in Italy. Malta and Bahrain land in no polygon at all and are dropped. Transit hubs and city-states are exactly where travellers take photos.

The fix is 1:50m for everything and 1:10m for about thirty small places, which takes the border file to 890 KB after simplification and delta encoding. Natural Earth draws Vatican City roughly a kilometre west of where it is, so that one is traced by hand.

Smallest polygon wins

Vatican City is inside Italy, San Marino is inside Italy, Lesotho is inside South Africa. Natural Earth cuts holes in Italy and South Africa for San Marino and Lesotho, but the hand-traced Vatican sits inside Italy's solid outline, so with a plain “first polygon that contains the point” loop, whether an enclave wins depends on file order. Sorting features by bounding-box area, smallest first, makes the smallest containing polygon win every time, and it is also faster: the tiny features are rejected by their bounding box in a comparison or two.

The rest of the lookup is ordinary: an even–odd ray cast per ring, a bounding-box test per feature and per polygon, and a cache keyed on a ~500 m grid cell, because a photo library is mostly thousands of points in the same few places.

Reading HEIC without a library

iPhone photos are HEIC, and most browsers will not decode HEIC for you (Safari 17 and later can; Chrome and Firefox cannot). You do not need to decode it: the location is in an ordinary EXIF block stored as an item inside the ISO-BMFF container. Getting at it is about fifty lines:

  1. Walk the top-level boxes to meta.
  2. In iinf, find the infe entry whose item type is Exif and note its item ID.
  3. In iloc, find that item ID's extent. The offset and length field widths are packed into nibbles, and versions 1 and 2 add an index size and a construction method, which is where most of the fiddliness is.
  4. Read just those bytes. The payload starts with a four-byte offset to the TIFF header; from there it is the same IFD walk as a JPEG.

The page reads the first 512 KB of a HEIC to find the boxes and then only the EXIF extent. JPEGs get their first 128 KB, videos their first and last megabyte (the ISO 6709 location string, +35.6812+139.7671/, lives in the moov box, which can be at either end).

The iPhone photo picker hands the location to web pages

I assumed a phone browser would strip location from photos picked for a web page. On the iPhone it does not, by default. In Safari on iOS 26.5, the photo picker shows “Location Is Included” in its toolbar and passes the original metadata through. So the page works on an iPhone with no app.

Android is the opposite: the browser gets a copy of the photo with the location removed. On Android the page asks for a Google Maps Timeline export instead.

Google has spelled a coordinate three ways

Timeline no longer exists on the web — Google's help page says “Timeline is not available for Maps on your computer” — but the phone will export it, and old Takeout archives are still around. Depending on vintage the file writes a coordinate as:

"latitudeE7": 488584000, "longitudeE7": 22945000    // Records.json
"latLng": "48.8584°, 2.2945°"                       // Timeline.json (Android)
"placeLocation": "geo:48.858400,2.294500"           // location-history.json (iPhone)

Records.json can be hundreds of megabytes, and the structure around the coordinates has changed several times. So the page does not JSON.parse it. It reads the file in 8 MB slices and runs one regular expression that matches all three spellings plus the nearby timestamp fields. Memory stays flat, the progress bar is honest, and a format change inside the JSON does not break it.

The slice boundary is where I got it wrong first. I carried the last 512 characters of each slice into the next one, so a coordinate that started just before the cut was read in full and then its tail was read again: 40.7128°, -74.0060° came back a second time as 0.7128°, -74.0060°, a point in Colombia. The fix is to carry over from the end of the last match instead of from a fixed offset, and the test suite now generates files that put a coordinate across the boundary at every possible offset.

One trap: in newer Records.json files the timestamp comes after the coordinates in each object, while in the other formats it comes before. Taking “the most recent timestamp seen” gives every point the previous point's year, which is wrong at every year boundary, so the page looks ahead a few hundred characters for Records-style points. Older Records.json files write timestampMs before the coordinates, and the look-ahead has to skip it. My first version did not: its pattern took the first four digits of the next record's timestampMs and passed them to new Date() as milliseconds, which is January 1970, so every country except the one in the last record came out as 1970.

And a small one: a few old exports stored negative E7 values as unsigned 32-bit integers, so a longitude of −0.78° arrives as 4287155296.

Coasts, the date line, and France

Making “nothing is uploaded” checkable

Every page like this says nothing is uploaded. The page makes no third-party requests at all — fonts are self-hosted, there are no analytics and no cookies — and it fetches its border file as soon as the browser is idle, so the stronger test works: load the page, switch off Wi-Fi, then drop your files in. It keeps working.

What it still gets wrong

Disclosure. I make Driftlog, an iPhone app that keeps this map up to date on its own: it records the countries and cities you pass through in the background and reads the location in your existing photos to rebuild earlier trips. The page links to it. The page itself is free, MIT-licensed, and does not need the app.

Try the counter