New: Photo Explorer · Find any mapped travel photo

Engineering journal

How Mapsake Built Its Offline Travel Gazetteer

Turning a photo coordinate into a country, region, city, and airport requires more than a nearest-place query. Here is how Mapsake combines open place data, boundary geometry, deterministic rules, and local indexing.

By 12 minute read
Mapsake Atlas showing a detailed world map built from bundled geographic boundaries

A coordinate is not yet a place

A travel photo can contain a latitude and longitude with impressive precision. Those two numbers still do not say whether the camera was in Japan, Kyoto Prefecture, Kyoto, or Kansai International Airport on the way home. Mapsake needs that hierarchy before a point can become a useful travel record.

The component that answers those questions is a gazetteer: a structured directory of named geographic places. Mapsake bundles its gazetteer inside the app, alongside the boundary lines used to draw and test countries and first-level regions. Search, manual marking, photo import, place stories, Passport statistics, achievements, and even a few friend views all depend on it.

It would have been simpler to send every coordinate to a web geocoder. It also would have made a large photo import slower, network-dependent, harder to reproduce, and less private. The offline route required more engineering up front, but it gave Mapsake a stable geographic vocabulary that works the same way on an airplane, at home, and years after a source dataset changes.

This is the story of how that layer was built, where points and lines disagree, and why “nearest city” is only the beginning of a correct answer.

Four open datasets, four different jobs

No single source contains everything Mapsake needs, so the build pipeline combines four kinds of open data:

  • GeoNames provides countries, first-level administrative regions, populated places, stable identifiers, alternate names, coordinates, and population.
  • OurAirports provides airports, IATA and ICAO codes, names, municipalities, and coordinates.
  • Natural Earth provides country and admin-1 boundary geometry plus useful map label points.
  • A small Mapsake-owned layer records product decisions such as supported country-count modes, aliases, and corrections that cannot be inferred safely from a generic source.

Each source is good at a different thing. GeoNames knows that a city belongs to a region and country, but a city coordinate is a point, not a border. Natural Earth knows where a polygon is drawn, but its feature identifiers do not always line up cleanly with GeoNames. OurAirports knows KLAX and LAX refer to the same airport, but it is not a hierarchy of every place around the airport.

The build script downloads and caches the upstream files, normalizes them, validates relationships, and emits two committed artifacts: a read-only SQLite database and simplified GeoJSON geometry. The app ships those results. It does not download a world database on first launch or depend on the source sites being online during a trip.

Committing the generated artifacts also makes a release reproducible. A shipped build has a known geographic world. Updating the sources is an intentional code change that can be tested and reviewed, not an invisible server-side change that alters someone's map overnight.

A deliberately boring SQLite database

The first gazetteer contained 252 countries, 3,861 regions, 33,744 cities, and 4,564 airports in about 14 MB. It used the SQLite library already provided by the operating system and a small local wrapper rather than adding a larger database framework.

The schema is intentionally direct. Continents contain countries. Countries contain regions. Regions contain cities. Airports carry country codes and coordinates. Stable source identifiers become the identifiers stored with a Mapsake mark: ISO alpha-2 for a country, a GeoNames admin code for a region, a GeoNames ID for a city, and an IATA code for an airport.

Mapsake also denormalizes the human-readable name and ancestry into each saved mark. That duplication is useful. A personal travel record should remain readable if a later gazetteer renames a place, removes a record, or is not present during an export. The identifier supports matching; the snapshot keeps the user's data self-contained.

SQLite fits this workload well because the database is generated once and queried many times. It supports indexes, transactions during the build, and full-text search without a service process. The app opens the bundled file read-only, so there is no migration risk for the reference data and no chance that an interrupted write corrupts it.

Search is more than contains(text)

Manual marking starts with a single search field that spans continents, countries, regions, cities, and airports. A search for “san” should find meaningful cities before obscure records; “LAX” should find the airport; and a name typed without its diacritics should still work.

The build creates an FTS5 table with display names, selected alternate names, codes, ancestry, coordinates, kind, and an importance value. The unicode tokenizer removes diacritics for matching. At query time, Mapsake folds case and diacritics, strips characters that could become full-text syntax, adds prefix matching to each token, and ranks exact names ahead of prefixes and general matches.

Importance breaks the remaining ties. Continents and countries should not disappear below similarly named villages. City population gives major places sensible weight. Large airports rank above small ones when the text match is otherwise comparable.

Alternate names are intentionally bounded. GeoNames can provide a very long multilingual list for a popular place. Copying every spelling into the device index would add noise and size. The builder keeps a limited, deduplicated set of useful variants and preserves the original display name seperately from its folded search form.

Search results return the same GazetteerPlace model used by hierarchical browsing. A user can search directly or browse continent, country, region, and city without creating two geographic systems that might disagree.

Lines answer a different question from points

The earliest photo resolver chose the nearest city and inherited that city's country and region. In dense areas this often looks perfect. Near a border it can be wrong in a way that is difficult to notice.

Imagine a photo taken just inside Montana when the closest populated place in the database is across the line in North Dakota. The nearest-city calculation is behaving correctly, but the result is not the administrative place where the photo was taken. The same problem appears at international borders, near enclaves, and across water where a sparse coastline has no nearby populated point.

Boundary geometry answers containment instead of proximity. Mapsake decodes the Natural Earth country and admin-1 polygons, checks which rings contain the coordinate, and uses that result to protect the country and region assignment. It can then search for the nearest city constrained to the polygon's country or region.

This creates a useful division of labor:

  1. Polygon containment establishes the administrative area.
  2. The gazetteer's hierarchy supplies stable IDs and names.
  3. A constrained nearest-city search provides a useful locality without crossing the line that was just established.
  4. A nearby-airport check adds an airport only within a deliberately tight distance.

Neither the line data nor the place directory is sufficient by itself. Together they turn a coordinate into a defensible chain.

The admin-1 audit exposed systemic mismatches

Country polygons and region polygons came from different Natural Earth layers, and the region layer's identifiers did not always match GeoNames. Some failures were obvious, while others produced plausible but incorrect fills.

One early conversion table assigned Quebec the geometry for New Brunswick. Other features were missing a code, carried a code from a neighboring system, or represented an administrative unit differently from the gazetteer. A visual glance at the world map could not reliably find all of those errors.

The replacement states build treats cities as an oracle. For each candidate polygon, it asks which gazetteer region's own cities actually fall inside it. Existing Natural Earth codes are validated rather than trusted blindly. Features that fail validation can be reassigned spatially, split, or excluded. The generated output then uses the exact region IDs already present in SQLite.

This is a practical form of cross-dataset testing. A polygon that claims to be a region should contain a convincing sample of cities that claim the same region. When the two sources disagree, the build produces evidence instead of quietly choosing whichever value was loaded first.

The same audit found special cases such as inhabited dependencies folded into a parent country's geometry. Mapsake patches a small number of those cases so a photo can resolve to the same country the gazetteer and country-count settings understand.

Sharper lines without shipping the source-sized world

The first Atlas used Natural Earth's 1:110 million country layer. It was compact and fast, but coastlines became visibly coarse when Mapsake added closer maps, place stories, and regional inspection.

The map later moved to the 1:10 million layer. That source is much more detailed, so bundling and rendering it unchanged would have increased storage, decode time, overlay construction, and recolor work. The build pipeline simplifies each ring with Douglas-Peucker at roughly a 0.004-degree tolerance, then rounds coordinates to stable precision.

The resulting country file is about 6.5 MB. It retains useful coastline detail at the zoom levels Mapsake presents while removing vertices that would land on effectively the same pixels. Admin-1 geometry goes through a similar validation and simplification path.

Simplification has a correctness constraint: a smaller polygon must still make the same decisions for real photo coordinates. Later benchmark fixtures deliberately place points around borders and compare optimized hit testing with a frozen reference. Faster lines are only useful when they still answer the same containment question.

Expanding from 34,000 to 234,000 cities

The first database used GeoNames cities with populations above 15,000. That kept the bundle compact, but it left rural trips, small islands, trail towns, and many home locations with an unnecessarily distant label.

Mapsake later adopted GeoNames cities500, covering populated places above roughly 500 people plus administrative seats. The city table grew about sevenfold to approximately 234,000 records, and the bundled database grew from roughly 14 MB to roughly 69 MB.

That trade was made after the App Clip was removed. The Clip's download ceiling had been the strongest reason to constrain the database. Once the main app was the only consumer, better coverage was more valuable than preserving an artificial small-city cutoff.

The full-text index remains selective about alternate names for minor places, and region browsing still caps what it renders at once. The data can be broad without requiring every screen to materialize the whole table.

Coverage matters most where a network geocoder would be least dependable. A small town on a remote trip should not be labeled as a city hours away merely because the compact dataset omitted it.

Nearest city became a shared performance problem

The original nearest-city query widened a latitude and longitude box, asked SQLite to calculate a weighted distance for every candidate, built a temporary sort, and returned the closest row. It was easy to understand and accurate enough, but an 82,000-photo library turned a small per-query cost into seconds of repeated work across imports, memories, map derivation, and Constellations.

The first improvement added a narrow 0.4-degree probe ahead of wider fallbacks. Dense locations usually found a city from a much smaller candidate set. Memoization and a persisted geographic-cell cache also stopped nearby photos from repeating identical work.

The larger improvement loads the numeric coordinates of the 234,000 cities into a latitude-sorted in-memory index. A binary search finds the slice inside the current latitude window, a bounded loop checks longitude and weighted distance, and only the winning row is fetched from SQLite. A small record cache keeps repeat winners cheap.

The widening behavior did not change. The optimized function still chooses the closest city in the first nonempty search window, including deterministic tie breaking. A benchmark-only copy of the old SQL path checks hundreds of border-heavy coordinates for exact ID equivalence.

On the simulator, a medium nearest-city batch fell from 2.11 seconds to 5.2 milliseconds. A physical iPhone run fell from 3.06 seconds to 6.35 milliseconds. Those gains reached several features because the gazetteer is shared infrastructure rather than a private implementation detail of the import screen.

Overlapping polygons found a correctness bug

The performance equivalence guard uncovered a bug that existed before the optimization. Some admin-1 polygons overlap by design, especially capital-city regions inside a surrounding region. Berlin and Brandenburg, Seoul and Gyeonggi, and Kyiv City and its oblast are examples.

The old hit test accepted whichever matching polygon appeared first in a Swift dictionary. Dictionary iteration order changes between processes, so the same coordinate could acquire a different region after relaunching the app.

Mapsake now sorts overlapping candidates by polygon area and lets the smallest, most specific feature win. The reference implementation follows the same rule. An 82,000-photo frozen fixture must produce zero differences between cold and warm derivation paths before a geometry optimization is accepted.

This bug is a good reminder that “inside a polygon” is not always a yes-or-no question. Geographic data contains enclaves, nested capitals, antimeridian crossings, multipolygons, holes, disputed boundaries, and source conventions. Deterministic policy is as important as the point-in-polygon algorithm.

Offline is a privacy feature and a product feature

Mapsake's photo import can resolve a large library without sending its coordinates to a third-party geocoding service. That protects sensitive travel and home locations, removes per-request pricing, avoids throttling, and makes progress predictable.

It also makes editing coherent. Manual search, photo import, Passport cards, achievements, Friends snapshots, stamp matching, and place stories speak the same language because they use the same stable place IDs. An airport imported from a flight log can deduplicate with the same airport detected near a photo. A city found through search can match the city used by Then & Now.

The bundle is not treated as perfect or permanent. Source attributions are visible in the app. Build scripts are kept beside the code. Known corrections are explicit. Benchmark fixtures protect behavior at difficult coordinates. Updating the geographic world is a release event with reviewable consequences.

What I would keep if rebuilding it

The most durable choices were not individual algorithms. They were boundaries between responsibilities:

  • Use points for names, stable identities, search, and hierarchy.
  • Use lines and polygons for containment.
  • Generate a read-only product artifact instead of parsing four upstream formats on a phone.
  • Store a readable snapshot with user data while retaining the source ID for matching.
  • Make the common offline path deterministic before making it fast.
  • Keep a slow reference implementation long enough to prove optimized output is equivalent.

The gazetteer began as an early search feature. It became one of Mapsake's load-bearing systems because almost every richer feature eventually needs to answer the same humble question: what place is this?

Getting that answer right means accepting that geography is not one database or one clever query. It is a careful agreement between names, points, lines, product rules, and the travel record a person expects to recognize.

Mapsake app icon

Independent developer of Mapsake, writing from the product, mapping, privacy, and Apple-platform work behind the app.

Build your own atlas

Start with the travel history you already have.

Mapsake is free, requires no Mapsake account for core use, and keeps supported photo matching on your device.

Get Mapsake free