Citylap™
Under the hood · offline-geocoder

Naming your city without telling anyone where you are

Citylap shows friends which city you're in. Turning coordinates into a city name happens entirely on your phone, using a 24.6 MB index of the world cut into geohash cells.

448,033 cells · 260 country datasets · zero network calls

↓ or Space to advance

Why offline

The server never sees your coordinates

Citylap is end-to-end encrypted, so there's no server that could look up the city for you. Your phone has to work it out before encrypting. Friends are grouped by comparing city keys exactly, so a wrong answer means two people in the same city don't show up together.

GPS fix
14.6007, −90.5133
Raw position, never sent anywhere
Geohash
9fxeh
Longest-prefix lookup in the bundled SQLite index
City key
wof:421169087
Guatemala City, the label your friends will see
Server
a3f9·c01e·77b2…
Only ciphertext, decrypted on your friends' phones
On your phone
The size problem

The world's borders don't fit in an app

The obvious approach is to ship every city boundary and test which one contains you. The Who's On First admin data behind it is 5.2 GB of GeoJSON even compressed: too big to bundle and too slow to scan on a phone.

Instead, every answer is computed in advance. The app ships only a lookup table from geohash → place, with no geometry at all.

~5.2 GB
Compressed Who's On First admin snapshot, from 260 per-country repositories
24.6 MB
Shipped SQLite index, compiled into the app
448,033
Geohash cells, each mapped to a single place
1
Indexed SQL query per lookup, with no geometry maths at runtime
Geohash in one slide

Each character zooms in 32×

A geohash cuts the world into 32 cells, then cuts each of those into 32 more, and so on. Each character picks one cell inside the previous one, so a longer hash is a smaller box nested inside the shorter one.

Each panel zooms into the highlighted cell of the one before it. Green is Guatemala City; lines are department and municipal boundaries.

Guatemala from the shipped index, with neighbouring countries faded. Each square is one stored cell.

Adaptive resolution

Fine where places are small, coarse where they're not

Smaller cells are more accurate but make the file larger. The index stores fine cells only where they change the answer, such as boundaries between dense municipalities. Elsewhere it keeps a single coarse cell.

Places whose bounding box covers no more than about 1,000 km² are allowed down to precision 5. Large, sparse ones such as the jungle of Petén stay at precision 4.

Click or tap anywhere to look up that point.

One owner per cell

A lookup is a longest-prefix match

Hash the point at the finest precision, then drop a character at a time until a stored cell matches. Whichever place owns that cell is the answer. In practice all the prefixes go into a single indexed query.

Hard case · borders

Cells ignore national borders

A cell that straddles a border can belong to only one country. Three build rules keep the damage small:

1

Home-cell ownership

A place owns the cell containing its own centre, even when a larger neighbour would outrank it.

2

Country-scoped rollups

A metro area can absorb its own suburbs, but never a town across the border.

3

Deterministic merit

Contested cells are decided by a fixed total order, so batch order can't change the result.

placetype→population→distance to centre→area→id

San Diego / Tijuana, with the real city and national boundaries behind the cells. A cell on the border can still belong to only one side, which is the error a 5 km grid can't avoid.

Product rule

A cabin in the Adirondacks should read “New York”, not “New York City”.

When a point can only be resolved coarsely, the honest answer is a region or country. A confident city name hundreds of kilometres away is wrong in a way users can't detect.

Moving out from Lake Placid, each label sits inside the one below it:

In townLake Placid
village
A farm outside townEssex County
county
Deep in the backcountryNew York
state
Nothing finer to sayUnited States
country
NeverNew York City
400 km away
How well it works

Tested against 40,523 towns worldwide

For every town in the index, does a point at its own centre come back as that town? All of them are checked, not a sample.

0.054%
Town centres that land in the wrong country, from cells straddling a border
5.1%
Resolve to a neighbour in the same country, often a suburb rolling up into its metro
24.6 MB
For the whole world, bundled inside the app
0
Cells that differ between two independent builds from the same pinned inputs

The known limits, such as twin towns facing each other across a border and places whose published centre lies outside their own boundary, are tracked as open issues.

Guatemala City. People along the road out to Fraijanes think of themselves as living in the city, so curation/gt.json relabels those four cells. Mixco and Villa Nueva keep their own names.

Regional adjustments

Where the rules stop, local knowledge takes over

No generic rule knows how residents name their own neighbourhood, or that the source data filed two Maltese towns under Mali. Those judgment calls live in small per-country overlay files, applied after the build.

1

Only fine cells

A relabel starts at a minimum precision, so coarse fallback cells keep their honest regional names.

2

Proven by probes

Each entry lists points that must get the new label and guard points that must not change. If any probe fails, the whole overlay is rolled back.

3

Fixes upstream data too

Records misfiled under the wrong country can be pulled back where they belong.

Community fixes

Know a place better than the data? Send a pull request

1

Edit one country's file

curation/<country>.json. Validation rejects any id from another country, so a change can only affect the country it's named for.

2

Explain the call

Write a rationale that a reviewer without local knowledge can evaluate.

3

Pin it with probes

Add at least one positive probe and one guard. Once merged, probes become permanent regression tests.

4

Dry-run and review

Conflicting or chained merges are rejected before anything is written.

{
  "country": "GT",
  "entries": [{
    "op": "merge",
    "into": 421169087,
    "absorb": [1108695621, 421185999],
    "minPrecision": 5,
    "rationale": "The corridor functions as part of the city…",
    "probes": [
      { "lat": 14.4801, "lon": -90.4175, "expect": "Guatemala City" },
      { "lat": 14.6339, "lon": -90.6064, "expect": "Mixco",
        "note": "guard: Mixco stays itself" },
      { "lat": 14.5567, "lon": -90.7337, "expect": "Antigua Guatemala",
        "note": "guard: must not leak beyond the metro" }
    ]
  }]
}

Simplified from curation/gt.json.

Open source

Use it in your own app

offline-geocoder runs in Node and in Expo / React Native via expo-sqlite. It ships the build scripts, a lockfile that pins each country's data snapshot, and curation files for the cases the rules can't handle.

MIT licensed, building on Luca Spiller's original offline-geocoder. Boundary data from Who's On First. The cells on every map are drawn directly from the index that ships in the Citylap app; the boundaries behind them are Who's On First polygons.

// Node (Expo: see the README)
const geocoder = require('offline-geocoder')({
  database: 'geocoder-boundary.db',
  reverseMode: 'boundary',
})

const place = await geocoder.reverse(14.6007, -90.5133)
// → { id: 421169087, name: 'Guatemala City', … }