
Geohash Spatial Code Maps
- 69 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
geohash-spatial-code-maps is a Claude Code skill for ai & agent building.
About
geohash-spatial-code-maps is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- geohash-spatial-code-maps
- AI & Agent Building
- AI-coding skill
Geohash Spatial Code Maps by the numbers
- 69 all-time installs (skills.sh)
- +6 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #5,760 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill geohash-spatial-code-mapsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 69 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I helps with ai & agent building tasks during AI-assisted development.?
Helps with ai & agent building tasks during AI-assisted development.
Who is it for?
Best when you're working on ai & agent building and need structured help with geohash spatial code maps.
Skip if: Teams with no ai & agent building needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to helps with ai & agent building tasks during AI-assisted development., or when geohash-spatial-code-maps is a claude code skill for ai & agent building.
What you get
Structured output aligned to geohash-spatial-code-maps: geohash-spatial-code-maps, AI & Agent Building.
Files
Geohash & Spatial Code Maps Best Practices
How to implement geohashes correctly in TypeScript and Rust, how to query and index them at scale, and how to apply them to the "codebase as a navigable 2D map" pattern — projecting code into a plane so geohash prefixes become domain regions you can fly through like Google Maps. Contains 42 rules across 8 categories, prioritised by impact.
When to Apply
Reference these guidelines when:
- Implementing or reviewing a geohash encoder/decoder in TypeScript or Rust (bit interleaving, base32, precision, neighbours)
- Building proximity / radius / bounding-box search on lat/lon data, or storing geohashes as index keys (SQL B-tree, Redis sorted sets)
- Debugging the classic geohash bugs — swapped axes, wrong alphabet, border false negatives, off-by-one cells at high precision
- Projecting a codebase (or any abstract graph) into a 2D plane and geohashing it so prefixes name business domains or features
- Navigating a geohashed dataset like a slippy map: zoom-to-precision, viewport tile loading, level-of-detail aggregation, prefix clustering, deep links
A note on scope
Categories 1–4, 6, and 7 are textbook geohashing, drawn from authoritative sources (the geohash spec, the davetroy/geohash-js neighbour tables, Redis, Elasticsearch). Categories 5 (map-) and 8 (nav-) are a novel synthesis — there is no canonical "geohash your codebase" library, so those rules derive design principles from established techniques (deterministic graph layout, Morton/Z-order keys, slippy-map tiling, software cartography). They are honest about when the pattern is overkill.
Rule Categories by Priority
| Priority | Category | Impact | Prefix | Rules |
|---|---|---|---|---|
| 1 | Encoding & Bit Interleaving | CRITICAL | enc- | 6 |
| 2 | Precision & Cell Geometry | CRITICAL | prec- | 5 |
| 3 | Neighbours & Adjacency | HIGH | nbr- | 5 |
| 4 | Proximity & Range Queries | HIGH | qry- | 5 |
| 5 | Codebase-as-Map Spatial Layout | HIGH | map- | 7 |
| 6 | Decoding & Bounding Boxes | MEDIUM-HIGH | dec- | 4 |
| 7 | Spatial Indexing & Storage | MEDIUM-HIGH | idx- | 5 |
| 8 | Navigation & Rendering | MEDIUM | nav- | 5 |
Quick Reference
1. Encoding & Bit Interleaving (CRITICAL)
- `enc-interleave-longitude-first` — Interleave longitude on even bits, latitude on odd
- `enc-base32-alphabet` — Use the geohash base32 alphabet, not RFC 4648
- `enc-integer-morton-encode` — Encode to an interleaved 64-bit integer for speed and sortable keys
- `enc-binary-chop-no-float-drift` — Recompute interval midpoints; never accumulate a float step
- `enc-normalize-input-domain` — Clamp latitude, wrap longitude, reject non-finite input
- `enc-five-bit-char-boundary` — Accumulate exactly five bits per character
2. Precision & Cell Geometry (CRITICAL)
- `prec-choose-from-error-radius` — Choose geohash length from the required error radius
- `prec-cells-are-not-square` — Treat cells as rectangles whose aspect flips with length
- `prec-error-is-half-cell` — Report decoded accuracy as half the cell, not the full cell
- `prec-cells-shrink-toward-poles` — Scale longitude metres by cos(latitude)
- `prec-avoid-mixed-precision` — Normalise to one precision before comparing or storing
3. Neighbours & Adjacency (HIGH)
- `nbr-canonical-lookup-tables` — Compute neighbours with the canonical border/neighbour tables
- `nbr-antimeridian-wrap` — Wrap east/west neighbours across the antimeridian
- `nbr-pole-handling` — Return no neighbour past the poles
- `nbr-integer-level-neighbors` — Compute neighbours on the de-interleaved integer
- `nbr-eight-neighbor-set` — Build the full eight-neighbour set for proximity
4. Proximity & Range Queries (HIGH)
- `qry-search-cell-plus-neighbors` — Query the cell plus its eight neighbours, never the prefix alone
- `qry-precision-from-radius` — Match query precision to the search radius
- `qry-bbox-range-decomposition` — Decompose a bounding box into covering geohash ranges
- `qry-refine-with-haversine` — Refine geohash candidates with true distance
- `qry-expand-precision-when-sparse` — Widen the search by dropping a prefix character on sparse cells
5. Codebase-as-Map Spatial Layout (HIGH)
- `map-deterministic-projection` — Project code into 2D from a structural signal, not arbitrary layout
- `map-stable-coordinates` — Make coordinates reproducible and incremental-stable
- `map-normalize-to-geohash-domain` — Normalise the code plane into the geohash lat/lon domain
- `map-coupling-implies-proximity` — Validate that coupled code lands in the same region
- `map-prefix-as-domain-region` — Treat a geohash prefix as a named domain region
- `map-precision-as-architectural-level` — Map prefix length to architectural level
- `map-persist-coordinate-sidecar` — Persist the file-to-geohash assignment as a committed sidecar
6. Decoding & Bounding Boxes (MEDIUM-HIGH)
- `dec-decode-to-bbox` — Decode to a bounding box, then derive the centre
- `dec-symmetric-interval-reconstruction` — Decode by mirroring the encoder's interval halving
- `dec-avoid-roundtrip-reencode` — Keep the original hash; don't decode-then-re-encode
- `dec-precompute-reverse-alphabet` — Decode with a precomputed reverse-alphabet table
7. Spatial Indexing & Storage (MEDIUM-HIGH)
- `idx-sorted-string-range-scan` — Store geohashes as sorted strings for prefix range scans
- `idx-integer-sortable-key` — Use the interleaved integer as a compact sortable key
- `idx-db-prefix-index` — Make prefix queries sargable in Postgres and Redis
- `idx-range-query-from-covering-set` — Execute a box query as range scans over the covering set
- `idx-trie-hierarchical-bucketing` — Aggregate by region with a geohash trie
8. Navigation & Rendering (MEDIUM)
- `nav-precision-to-zoom-levels` — Map geohash precision to zoom levels
- `nav-level-of-detail-aggregation` — Render aggregated prefix buckets when zoomed out
- `nav-tile-lazy-loading` — Load only the geohash cells in the viewport
- `nav-cluster-by-prefix` — Cluster overlapping markers by shared prefix
- `nav-breadcrumb-prefix-path` — Use the geohash prefix as navigation state and deep link
How to Use
Read individual reference files for detailed explanations, code examples, and "when NOT to apply" guidance:
- Section definitions — Category structure and impact levels
- Rule template — Template for adding new rules
Rules cross-link via [[other-rule-slug]]; follow them when a related pattern is referenced. To build a code map end to end, the spine is: `map-deterministic-projection` → `map-normalize-to-geohash-domain` → encode (category 1) → `map-prefix-as-domain-region` → navigate (category 8).
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for new rules |
| metadata.json | Version and reference information |
Geohash (TypeScript & Rust)
Version 0.1.0 Geohash & Spatial Code Maps May 2026
Note:
This document is mainly for agents and LLMs to follow when maintaining,
generating, or refactoring codebases. Humans may also find it useful,
but guidance here is optimized for automation and consistency by AI-assisted workflows.
---
Abstract
Geohash implementation and applied spatial-indexing guide for TypeScript and Rust, plus a novel 'codebase as a navigable 2D map' pattern. Contains 42 rules across 8 categories, prioritised by impact from critical (encoding correctness and precision/cell geometry) through neighbours, proximity queries, and the codebase-map projection, down to decoding, storage indexing, and slippy-map navigation. Each rule explains why it matters and shows production-realistic incorrect vs. correct examples in TypeScript or Rust, with explicit when-NOT-to-apply guidance. The geohash fundamentals are drawn from authoritative sources (the geohash spec, the davetroy/geohash-js neighbour tables, Redis, Elasticsearch); the codebase-map categories synthesise established techniques (deterministic graph layout, Morton/Z-order keys, slippy-map tiling, software cartography) into an architectural pattern for navigating a codebase like Google Maps.
---
Table of Contents
1. Encoding & Bit Interleaving — CRITICAL
- 1.1 Accumulate Exactly Five Bits per Character — CRITICAL (prevents truncated or misaligned hashes)
- 1.2 Clamp and Validate Coordinates Before Encoding — CRITICAL (prevents silent garbage hashes from out-of-range input)
- 1.3 Encode to an Interleaved 64-bit Integer for Speed and Sortable Keys — CRITICAL (5-20x faster encode; yields a directly sortable key)
- 1.4 Interleave Longitude on Even Bits, Latitude on Odd — CRITICAL (prevents 100% of swapped-axis hashes)
- 1.5 Recompute Interval Midpoints; Never Accumulate a Float Step — CRITICAL (prevents off-by-one cell errors at precision >= 9)
- 1.6 Use the Geohash Base32 Alphabet, Not RFC 4648 — CRITICAL (prevents unshareable, non-interoperable hashes)
2. Precision & Cell Geometry — CRITICAL
- 2.1 Account for Longitude Metres Shrinking with Latitude — CRITICAL (prevents up to 2x metric error above 60° latitude)
- 2.2 Choose Geohash Length from the Required Error Radius — CRITICAL (prevents 10-100x oversized or undersized cells)
- 2.3 Normalise to One Precision Before Comparing or Storing — CRITICAL (prevents incorrect prefix-containment results)
- 2.4 Report Decoded Accuracy as Half the Cell, Not the Full Cell — CRITICAL (prevents 2x overstated accuracy)
- 2.5 Treat Cells as Rectangles Whose Aspect Flips with Length — CRITICAL (prevents up to 2x error on one axis)
3. Neighbors & Adjacency — HIGH
- 3.1 Build the Full Eight-Neighbour Set for Proximity — HIGH (prevents missing diagonal-cell matches)
- 3.2 Compute Neighbours on the De-interleaved Integer — HIGH (O(1) neighbour vs O(len) string recursion)
- 3.3 Compute Neighbours with the Canonical Border and Neighbour Tables — HIGH (prevents wrong-cell adjacency at all 4 edges)
- 3.4 Return No Neighbour Past the Poles — HIGH (prevents phantom cells at the top and bottom rows)
- 3.5 Wrap East/West Neighbours Across the Antimeridian — HIGH (prevents missing neighbours at ±180° longitude)
4. Proximity & Range Queries — HIGH
- 4.1 Decompose a Bounding Box into Covering Geohash Ranges — HIGH (prevents missing interior cells that corner queries drop)
- 4.2 Match Query Precision to the Search Radius — HIGH (prevents the 9-cell block being smaller than the radius)
- 4.3 Query the Cell Plus Its Eight Neighbours, Never the Prefix Alone — HIGH (eliminates border false negatives)
- 4.4 Refine Geohash Candidates with True Distance — HIGH (prevents square-corner false positives in results)
- 4.5 Widen the Search by Dropping a Prefix Character on Sparse Cells — HIGH (prevents empty results in sparse regions)
5. Codebase-as-Map Spatial Layout — HIGH
- 5.1 Make Coordinates Reproducible and Incremental-Stable — HIGH (prevents the whole map reshuffling on every commit)
- 5.2 Map Prefix Length to Architectural Level — HIGH (prevents inconsistent prefix semantics across call sites)
- 5.3 Normalise the Code Plane into the Geohash Lat/Lon Domain — HIGH (prevents out-of-range coordinates colliding at a corner)
- 5.4 Persist the File-to-Geohash Assignment as a Committed Sidecar — HIGH (prevents non-reproducible, unreviewable maps)
- 5.5 Project Code into 2D from a Structural Signal, Not Arbitrary Layout — HIGH (prevents random regions that group unrelated files)
- 5.6 Treat a Geohash Prefix as a Named Domain Region — HIGH (prevents brittle path-based domain heuristics)
- 5.7 Validate That Coupled Code Lands in the Same Region — HIGH (prevents incoherent regions that mix unrelated domains)
6. Decoding & Bounding Boxes — MEDIUM-HIGH
- 6.1 Decode by Mirroring the Encoder's Interval Halving — MEDIUM-HIGH (prevents decode drifting to a neighbour cell)
- 6.2 Decode to a Bounding Box, Then Derive the Centre — MEDIUM-HIGH (preserves the cell extent and its error margin)
- 6.3 Decode with a Precomputed Reverse-Alphabet Table — MEDIUM-HIGH (O(1) per character and rejects invalid input)
- 6.4 Keep the Original Hash; Don't Decode-then-Re-encode to "Normalise" — MEDIUM-HIGH (prevents cell drift from round-trip conversions)
7. Spatial Indexing & Storage — MEDIUM-HIGH
- 7.1 Aggregate by Region with a Geohash Trie — MEDIUM-HIGH (O(prefix-length) region rollups without rescanning)
- 7.2 Execute a Box Query as Range Scans over the Covering Set — MEDIUM-HIGH (prevents full scans; box query as N indexed range scans)
- 7.3 Make Prefix Queries Sargable in Postgres and Redis — MEDIUM-HIGH (prevents full-table scans on proximity queries)
- 7.4 Store Geohashes as Sorted Strings for Prefix Range Scans — MEDIUM-HIGH (prevents full scans; proximity becomes a range scan)
- 7.5 Use the Interleaved Integer as a Compact Sortable Key — MEDIUM-HIGH (prevents string-walk compares; 8-byte sortable key)
8. Navigation & Rendering — MEDIUM
- 8.1 Cluster Overlapping Markers by Shared Prefix — MEDIUM (prevents marker overdraw at low zoom)
- 8.2 Load Only the Geohash Cells in the Viewport — MEDIUM (loads the viewport, not the whole dataset)
- 8.3 Map Geohash Precision to Zoom Levels — MEDIUM (prevents drawing millions of off-scale cells per frame)
- 8.4 Render Aggregated Prefix Buckets When Zoomed Out — MEDIUM (prevents O(n) render cost; bounded by visible cells)
- 8.5 Use the Geohash Prefix as Navigation State and Deep Link — MEDIUM (prevents fragile coordinate links; enables region deep links)
---
References
1. https://en.wikipedia.org/wiki/Geohash 2. https://github.com/davetroy/geohash-js 3. https://github.com/georust/geohash 4. https://github.com/sunng87/node-geohash 5. https://redis.io/docs/latest/develop/data-types/geospatial/ 6. https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-geohashgrid-aggregation.html 7. https://en.wikipedia.org/wiki/Z-order_curve 8. https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames 9. https://wettel.github.io/codecity.html 10. https://use-the-index-luke.com/
---
Source Files
This document was compiled from individual reference files. For detailed editing or extension:
| File | Description |
|---|---|
| references/_sections.md | Category definitions and impact ordering |
| assets/templates/_template.md | Template for creating new rules |
| SKILL.md | Quick reference entry point |
| metadata.json | Version and reference URLs |
{Title}
{1-3 sentences explaining WHY this matters — what breaks without it, what cascade effect it has, and what the model should generalise from. Teach the reasoning, not just the rule. For the map-/nav- application rules, state plainly when the pattern is overkill.}
Incorrect ({problem label}):
// Production-realistic anti-pattern. Comment explains the cost.
// (Use ```rust for the encoding/neighbour/indexing rules where Rust idioms matter.)Correct ({solution label}):
// Minimal diff from the incorrect example. Comment explains the benefit.When NOT to apply:
- {Realistic exception 1}
- {Realistic exception 2}
Reference: {Title}
{
"version": "0.1.0",
"organization": "Geohash & Spatial Code Maps",
"technology": "Geohash (TypeScript & Rust)",
"discipline": "distillation",
"type": "library-reference",
"date": "May 2026",
"abstract": "Geohash implementation and applied spatial-indexing guide for TypeScript and Rust, plus a novel 'codebase as a navigable 2D map' pattern. Contains 42 rules across 8 categories, prioritised by impact from critical (encoding correctness and precision/cell geometry) through neighbours, proximity queries, and the codebase-map projection, down to decoding, storage indexing, and slippy-map navigation. Each rule explains why it matters and shows production-realistic incorrect vs. correct examples in TypeScript or Rust, with explicit when-NOT-to-apply guidance. The geohash fundamentals are drawn from authoritative sources (the geohash spec, the davetroy/geohash-js neighbour tables, Redis, Elasticsearch); the codebase-map categories synthesise established techniques (deterministic graph layout, Morton/Z-order keys, slippy-map tiling, software cartography) into an architectural pattern for navigating a codebase like Google Maps.",
"references": [
"https://en.wikipedia.org/wiki/Geohash",
"https://github.com/davetroy/geohash-js",
"https://github.com/georust/geohash",
"https://github.com/sunng87/node-geohash",
"https://redis.io/docs/latest/develop/data-types/geospatial/",
"https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-geohashgrid-aggregation.html",
"https://en.wikipedia.org/wiki/Z-order_curve",
"https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames",
"https://wettel.github.io/codecity.html",
"https://use-the-index-luke.com/"
]
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Encoding & Bit Interleaving (enc)
Impact: CRITICAL Description: The encoder is the foundation every other operation depends on — a wrong bit-interleaving order, a non-standard base32 alphabet, or float drift in the binary chop silently corrupts every hash, query, and neighbor downstream. Get this exactly right or nothing else works.
2. Precision & Cell Geometry (prec)
Impact: CRITICAL Description: Precision (geohash length) determines cell size, error bounds, and query correctness for the whole system. Treating cells as square, ignoring that they shrink toward the poles, or confusing cell size with error radius produces results that look plausible but are wrong by a factor of two or more everywhere.
3. Neighbors & Adjacency (nbr)
Impact: HIGH Description: Adjacency is the most bug-prone primitive in geohashing — the canonical lookup-table algorithm, antimeridian wraparound, and pole handling all have edge cases that naive bit-math gets wrong. Every proximity query is built on top of correct neighbors, so errors here cascade into silent missing results.
4. Proximity & Range Queries (qry)
Impact: HIGH Description: The defining geohash trap: two points metres apart can share zero common prefix when they straddle a cell border. A query that only prefix-matches the centre cell returns false negatives at every boundary. Correct queries cover the cell plus its eight neighbours and refine with true distance.
5. Codebase-as-Map Spatial Layout (map)
Impact: HIGH Description: The signature application — projecting a codebase into a 2D plane so geohash prefixes become navigable domain regions. Getting the projection deterministic, stable across runs, and monotonic with code coupling is what makes the map usable; getting it wrong produces a pretty picture that reshuffles every commit and clusters unrelated code together.
6. Decoding & Bounding Boxes (dec)
Impact: MEDIUM-HIGH Description: Decoding recovers geography from a hash. The common mistakes — returning a bare centre point instead of the cell it represents, dropping the error margin, or re-encoding and accumulating round-trip drift — corrupt rendering, hit-testing, and any logic that compares decoded coordinates.
7. Spatial Indexing & Storage (idx)
Impact: MEDIUM-HIGH Description: Geohashes earn their keep as sortable keys: stored as sorted strings or interleaved integers they turn proximity into range scans that any B-tree or sorted set serves efficiently. Choosing the wrong key representation or query decomposition forces full scans and erases the index's advantage at scale.
8. Navigation & Rendering (nav)
Impact: MEDIUM Description: The Google-Maps-style UX layer over a geohashed dataset: mapping precision to zoom levels, aggregating by prefix for level-of-detail, lazy-loading tiles, and clustering markers. Lower cascade impact than the data layer, but it is what turns a correct spatial index into something a human can actually fly through.
Keep the Original Hash; Don't Decode-then-Re-encode to "Normalise"
It is tempting to "normalise" a stored geohash by decoding it to a point and re-encoding. But the decoded centre sits on a cell's halfway line, and floating-point rounding in the re-encode can push it into a neighbouring cell — so the round-trip is not idempotent and your "normalised" hash names a different cell. Treat the hash string as the source of truth; truncate it for coarser precision instead of decoding and re-encoding.
Incorrect (decode then re-encode):
function coarsen(hash: string, len: number): string {
const { lat, lon } = decodeCenter(hash);
return encode(lat, lon, len); // may land one cell over — not the parent of `hash`
}Correct (truncate the string for coarser precision):
function coarsen(hash: string, len: number): string {
if (len > hash.length) throw new RangeError("cannot coarsen to a finer precision");
return hash.slice(0, len); // the parent cell, exactly and idempotently
}When NOT to apply:
- Re-encoding is correct when you genuinely have a new coordinate (an updated GPS fix). The anti-pattern is round-tripping an existing hash through coordinates with no new information.
Reference: Wikipedia — Geohash
Decode to a Bounding Box, Then Derive the Centre
A geohash represents a cell, not a point. Decoding straight to a single latitude/longitude throws away the only uncertainty information you have — the cell's extent — so downstream code treats an approximate region as an exact pin. Decode to the bounding box [latMin, lonMin, latMax, lonMax] and derive the centre and ±half-cell error from it ([[prec-error-is-half-cell]]); callers that want a point still get one, and callers that need the extent are not lied to.
Incorrect (decode to a bare point):
fn decode_point(hash: &str) -> (f64, f64) {
// returns the centre only; the cell size is lost
let (lat_iv, lon_iv) = decode_intervals(hash);
((lat_iv.0 + lat_iv.1) / 2.0, (lon_iv.0 + lon_iv.1) / 2.0)
}Correct (decode to a box; centre is derived):
struct BBox { lat_min: f64, lon_min: f64, lat_max: f64, lon_max: f64 }
fn decode_bbox(hash: &str) -> BBox {
let (lat, lon) = decode_intervals(hash); // ((lat_min,lat_max),(lon_min,lon_max))
BBox { lat_min: lat.0, lat_max: lat.1, lon_min: lon.0, lon_max: lon.1 }
}
impl BBox {
fn center(&self) -> (f64, f64) {
((self.lat_min + self.lat_max) / 2.0, (self.lon_min + self.lon_max) / 2.0)
}
fn error(&self) -> (f64, f64) { // ± half-cell
((self.lat_max - self.lat_min) / 2.0, (self.lon_max - self.lon_min) / 2.0)
}
}When NOT to apply:
- If a consumer only ever needs an approximate pin (dropping a marker), a thin
center()helper over the bbox is fine — just compute the box first so the extent is available.
Reference: Wikipedia — Geohash
Decode with a Precomputed Reverse-Alphabet Table
Decoding maps each character back to its 5-bit value. Calling GEOHASH_BASE32.indexOf(c) per character is O(32) each and silently returns -1 for an invalid character (a, i, l, o, or an uppercase typo), which then corrupts the bitstream without an error. Build a reverse table once: it is O(1) per character and lets you reject invalid input explicitly instead of decoding garbage.
Incorrect (indexOf per character, no validation):
function charValue(c: string): number {
return GEOHASH_BASE32.indexOf(c); // O(32); returns -1 for 'a','i','l','o' -> garbage bits
}Correct (precomputed table with explicit rejection):
const DECODE_MAP: Record<string, number> = {};
for (let i = 0; i < GEOHASH_BASE32.length; i++) DECODE_MAP[GEOHASH_BASE32[i]] = i;
function charValue(c: string): number {
const v = DECODE_MAP[c.toLowerCase()];
if (v === undefined) throw new RangeError(`invalid geohash character: '${c}'`);
return v; // O(1), and bad input fails loudly
}When NOT to apply:
- For a one-off decode of a known-valid hash the difference is immaterial — but the table is trivial to build and turns silent corruption into a clear error, so prefer it wherever untrusted input is decoded.
Reference: Wikipedia — Geohash; davetroy/geohash-js
Decode by Mirroring the Encoder's Interval Halving
Decoding must reverse encoding exactly: same bit order (longitude on even bits), same recomputed midpoints, same [-90,90] / [-180,180] start. If the decoder uses a different convention — adding a precomputed step, or assuming latitude-first — the reconstructed interval is offset and the centre lands in the wrong cell, so encode∘decode is no longer the identity on the cell. Walk the bits in the same order and narrow the same intervals.
Incorrect (decoder uses a different bit order than the encoder):
fn decode_intervals_bad(bits: &[u8]) -> ((f64, f64), (f64, f64)) {
let (mut lat, mut lon) = ((-90.0, 90.0), (-180.0, 180.0));
for (i, &b) in bits.iter().enumerate() {
if i % 2 == 0 { narrow(&mut lat, b); } // even -> latitude: WRONG; encoder used longitude
else { narrow(&mut lon, b); }
}
(lat, lon)
}Correct (mirror the encoder: even bit = longitude):
fn narrow(iv: &mut (f64, f64), bit: u8) {
let mid = (iv.0 + iv.1) / 2.0;
if bit == 1 { iv.0 = mid; } else { iv.1 = mid; }
}
fn decode_intervals(bits: &[u8]) -> ((f64, f64), (f64, f64)) {
let (mut lat, mut lon) = ((-90.0, 90.0), (-180.0, 180.0));
for (i, &b) in bits.iter().enumerate() {
if i % 2 == 0 { narrow(&mut lon, b); } // even -> longitude (matches encoder)
else { narrow(&mut lat, b); }
}
(lat, lon)
}When NOT to apply:
- Never — decode must be the exact inverse of encode ([[enc-interleave-longitude-first]], [[enc-binary-chop-no-float-drift]]). Sharing one
narrowhelper between encode and decode is the surest way to keep them symmetric.
Reference: Wikipedia — Geohash
Use the Geohash Base32 Alphabet, Not RFC 4648
Geohash uses its own base32 alphabet — 0123456789bcdefghjkmnpqrstuvwxyz — which deliberately omits a, i, l, and o to avoid visual ambiguity in printed and spoken hashes. It is not RFC 4648 base32 (A–Z2–7). Reach for a generic base32 encoder and every hash you emit is incompatible with maps, databases, and other libraries, and round-trips through your own decoder only by luck.
Incorrect (standard-library base32):
import { base32 } from "rfc4648"; // A-Z, 2-7 — the WRONG alphabet
function badEncode(bits: Uint8Array): string {
return base32.stringify(bits); // "MFRGG..." — not a geohash; no map will read it
}Correct (geohash alphabet, 5 bits per character):
const GEOHASH_BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz"; // no a, i, l, o
function toBase32(bits: number[]): string {
let hash = "";
for (let i = 0; i < bits.length; i += 5) {
let value = 0;
for (let j = 0; j < 5; j++) value = (value << 1) | (bits[i + j] ?? 0);
hash += GEOHASH_BASE32[value]; // index 0-31 into the geohash alphabet
}
return hash;
}Decoding must use the same alphabet — build a reverse lookup table once rather than calling indexOf per character in a hot loop (see [[idx-integer-sortable-key]] for why the table representation matters at scale).
When NOT to apply:
- Never for interoperable geohashes.
- A purely internal token system that never leaves your process could use any alphabet, but you lose the ability to paste a hash into geohash.org or a tile server to debug it.
Reference: Wikipedia — Geohash; davetroy/geohash-js
Recompute Interval Midpoints; Never Accumulate a Float Step
The encoder narrows [lo, hi] by repeated halving. If you precompute a fixed step (range / 2^bits) and add it cumulatively, floating-point rounding accumulates over 50+ iterations and the last bit flips for points near a cell edge — the hash jumps to the wrong neighbouring cell. Recomputing mid = (lo + hi) / 2 each step keeps the comparison exact relative to the current interval, which is what the standard requires.
Incorrect (cumulative step accumulates error):
fn chop_bad(value: f64, lo: f64, hi: f64, bits: u8) -> u64 {
let mut step = (hi - lo) / 2.0; // precomputed once
let mut acc = lo;
let mut out = 0u64;
for _ in 0..bits {
out <<= 1;
if value >= acc + step { out |= 1; acc += step; } // drift compounds each add
step /= 2.0;
}
out
}Correct (recompute the midpoint from the live bounds):
fn chop_good(value: f64, mut lo: f64, mut hi: f64, bits: u8) -> u64 {
let mut out = 0u64;
for _ in 0..bits {
out <<= 1;
let mid = (lo + hi) / 2.0; // exact w.r.t. the current interval
if value >= mid { out |= 1; lo = mid; } else { hi = mid; }
}
out
}When NOT to apply:
- At low precision (length <= 6) the drift rarely crosses a cell boundary — but there is no performance reason to prefer the buggy form, since the correct version costs the same.
Reference: Wikipedia — Geohash (binary subdivision)
Accumulate Exactly Five Bits per Character
Each base32 character carries exactly 5 bits, so a length-N geohash is N × 5 bits, with the longitude/latitude split alternating 3/2 within each character. If your bit loop and your character loop disagree — emitting a char every 4 bits, or stopping mid-character — you get a hash that is the wrong length, or whose final character encodes leftover bits as zeros and shifts the decoded cell. Drive the encoder by total bit count (N × 5) and flush a character only on a 5-bit boundary.
Incorrect (flushes on the wrong boundary):
fn pack_bad(bits: &[u8]) -> String {
let mut out = String::new();
let mut acc = 0u8;
for (i, &b) in bits.iter().enumerate() {
acc = (acc << 1) | b;
if (i + 1) % 4 == 0 { // 4-bit flush -> base16-ish, not geohash
out.push(BASE32[acc as usize] as char);
acc = 0;
}
}
out
}Correct (flush every 5 bits; require a multiple of 5):
fn pack_good(bits: &[u8]) -> String {
debug_assert!(bits.len() % 5 == 0, "geohash bit length must be a multiple of 5");
let mut out = String::with_capacity(bits.len() / 5);
for chunk in bits.chunks_exact(5) {
let v = chunk.iter().fold(0u8, |acc, &b| (acc << 1) | b);
out.push(BASE32[v as usize] as char);
}
out
}When NOT to apply:
- Never relax the 5-bit boundary for string geohashes.
- Internal integer geohashes ([[enc-integer-morton-encode]]) are not character-aligned and use a raw bit count, so the multiple-of-5 rule does not apply there.
Reference: Wikipedia — Geohash; davetroy/geohash-js
Encode to an Interleaved 64-bit Integer for Speed and Sortable Keys
Building a geohash by pushing bits into an array and indexing a string per 5 bits allocates and branches on every step. Interleaving the two coordinates directly into a single integer with bit-spreading ("Morton code" / Z-order) is branch-free, allocation-free, and — when longitude takes the high bit of each pair, matching the string convention — the integer sorts in the same order as the base32 string, so it doubles as a sortable index key. Convert to the base32 string only at the boundary where a human or another system needs it. (This is the same 52-bit budget Redis uses internally for its geospatial type.)
Incorrect (string built bit-by-bit in the hot path):
// Allocates a String and does per-bit work; ~5-20x slower in tight loops.
fn encode_slow(lat: f64, lon: f64, len: usize) -> String {
let mut bits = Vec::with_capacity(len * 5);
// ... binary chop pushing 0/1 into `bits` ...
let mut s = String::new();
for chunk in bits.chunks(5) {
let v = chunk.iter().fold(0u8, |acc, &b| (acc << 1) | b);
s.push(BASE32[v as usize] as char);
}
s
}Correct (spread coordinates into a Morton integer, stringify lazily):
/// Quantise a value in [min,max] to `bits`, then spread it across even positions.
fn spread(value: f64, min: f64, max: f64, bits: u32) -> u64 {
let norm = ((value - min) / (max - min)).clamp(0.0, 0.999_999_999);
let q = (norm * (1u64 << bits) as f64) as u64 & ((1 << bits) - 1);
// Classic bit-spreading: insert a zero between every bit (interleave64).
let mut x = q;
x = (x | (x << 16)) & 0x0000_FFFF_0000_FFFF;
x = (x | (x << 8)) & 0x00FF_00FF_00FF_00FF;
x = (x | (x << 4)) & 0x0F0F_0F0F_0F0F_0F0F;
x = (x | (x << 2)) & 0x3333_3333_3333_3333;
x = (x | (x << 1)) & 0x5555_5555_5555_5555;
x
}
/// 26 bits per axis = 52-bit geohash integer. Longitude on the high bit of each
/// pair so the integer's numeric order matches the base32 string order.
fn encode_u64(lat: f64, lon: f64) -> u64 {
(spread(lon, -180.0, 180.0, 26) << 1) | spread(lat, -90.0, 90.0, 26)
}The integer sorts identically to the base32 string, so one key serves comparisons, range scans, and storage — see [[idx-integer-sortable-key]].
When NOT to apply:
- If you only encode a handful of points (e.g. one per request) the string encoder's overhead is irrelevant.
- Reach for the integer path when encoding runs in a hot loop, or when you need the value as a database / sorted-set key.
Reference: Z-order curve (Morton code); Redis Geospatial
Interleave Longitude on Even Bits, Latitude on Odd
Geohash interleaves the binary subdivisions of longitude and latitude into one bitstream, with longitude taking the even bit positions (the very first bit) and latitude the odd positions. Swap the order and every hash you produce is internally consistent but incompatible with every other geohash system on earth — your "London" decodes to the Indian Ocean in Redis, PostGIS, or any tile server. The asymmetry is also why cells are wider than tall: longitude gets one extra bit at odd lengths.
Incorrect (latitude taken first):
// Bits alternate lat, lon, lat, lon... — the WRONG order.
function encodeBits(lat: number, lon: number, bits: number): number[] {
const latRange = [-90, 90], lonRange = [-180, 180];
const out: number[] = [];
for (let i = 0; i < bits; i++) {
if (i % 2 === 0) { // even bit -> latitude (WRONG)
const mid = (latRange[0] + latRange[1]) / 2;
if (lat >= mid) { out.push(1); latRange[0] = mid; } else { out.push(0); latRange[1] = mid; }
} else { // odd bit -> longitude (WRONG)
const mid = (lonRange[0] + lonRange[1]) / 2;
if (lon >= mid) { out.push(1); lonRange[0] = mid; } else { out.push(0); lonRange[1] = mid; }
}
}
return out; // hashes incompatible with every standard geohash library
}Correct (longitude on even bits, latitude on odd):
function encodeBits(lat: number, lon: number, bits: number): number[] {
const latRange = [-90, 90], lonRange = [-180, 180];
const out: number[] = [];
for (let i = 0; i < bits; i++) {
if (i % 2 === 0) { // even bit -> longitude (correct)
const mid = (lonRange[0] + lonRange[1]) / 2;
if (lon >= mid) { out.push(1); lonRange[0] = mid; } else { out.push(0); lonRange[1] = mid; }
} else { // odd bit -> latitude (correct)
const mid = (latRange[0] + latRange[1]) / 2;
if (lat >= mid) { out.push(1); latRange[0] = mid; } else { out.push(0); latRange[1] = mid; }
}
}
return out;
}The same invariant in Rust:
// even bit index -> longitude, odd -> latitude
fn encode_bits(lat: f64, lon: f64, bits: u8) -> u64 {
let (mut lat_lo, mut lat_hi) = (-90.0_f64, 90.0_f64);
let (mut lon_lo, mut lon_hi) = (-180.0_f64, 180.0_f64);
let mut hash = 0u64;
for i in 0..bits {
hash <<= 1;
if i % 2 == 0 {
let mid = (lon_lo + lon_hi) / 2.0; // longitude on even bits
if lon >= mid { hash |= 1; lon_lo = mid; } else { lon_hi = mid; }
} else {
let mid = (lat_lo + lat_hi) / 2.0; // latitude on odd bits
if lat >= mid { hash |= 1; lat_lo = mid; } else { lat_hi = mid; }
}
}
hash
}When NOT to apply:
- Never — the order is fixed by the geohash standard.
- The only reason to revisit is a deliberately private, non-interoperable variant; if you build one, document the bit order loudly because no standard tool will read it.
Reference: Wikipedia — Geohash
Clamp and Validate Coordinates Before Encoding
The binary chop assumes latitude in [-90, 90] and longitude in [-180, 180]. Feed it lon = 200 or a NaN and it produces a hash with no warning — every bit comparison falls one way, so you get a corner cell that looks like a real location. The two axes need different handling: longitude is cyclic (wrap at ±180) while latitude is clamped (you cannot go past a pole). Validate and normalise at the system boundary before encoding.
Incorrect (no validation, no wrap):
function encode(lat: number, lon: number, len: number): string {
// lat = 95 silently encodes to the north edge; lon = 200 to the east edge;
// NaN collapses to "s0000..." — all three look like valid locations.
return toBase32(encodeBits(lat, lon, len * 5));
}Correct (clamp latitude, wrap longitude, reject non-finite):
function normalize(lat: number, lon: number): [number, number] {
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
throw new RangeError(`non-finite coordinate: ${lat}, ${lon}`);
}
const clampedLat = Math.max(-90, Math.min(90, lat)); // poles are hard limits
const wrappedLon = ((((lon + 180) % 360) + 360) % 360) - 180; // 181 -> -179
return [clampedLat, wrappedLon];
}
function encode(lat: number, lon: number, len: number): string {
const [nlat, nlon] = normalize(lat, lon);
return toBase32(encodeBits(nlat, nlon, len * 5));
}Exactly ±180° longitude collapses to the west edge (-180) — the standard antimeridian aliasing, harmless for encoding but worth knowing if a caller passes a literal 180.
When NOT to apply:
- If inputs come from a validated source (a branded coordinate already constrained to range) you can skip the per-call check.
- Always keep it at the boundary where raw user or API input first enters the system.
Reference: Wikipedia — Geohash
Make Prefix Queries Sargable in Postgres and Redis
A geohash index only helps if your query can use it. LIKE 'gcp%' is sargable on a B-tree (Postgres uses the index for a left-anchored prefix with text_pattern_ops), but LIKE '%gcp%' or a function wrapped around the column is not and forces a scan. In Redis, store the integer geohash as a sorted-set score and use ZRANGEBYSCORE over each cell range. Match the query to what the index can serve.
Incorrect (non-sargable predicates defeat the index):
// substring match and a function on the column -> sequential scan
await db.query("SELECT * FROM places WHERE geohash LIKE $1", [`%${region}%`]);
await db.query("SELECT * FROM places WHERE substr(geohash,1,3) = $1", [region]);Correct (left-anchored range; Redis sorted set):
-- Let the B-tree serve LIKE 'region%' as well as range comparisons.
CREATE INDEX places_geohash_idx ON places USING btree (geohash text_pattern_ops);// Postgres: half-open range uses the B-tree directly.
await db.query(
"SELECT * FROM places WHERE geohash >= $1 AND geohash < $2",
[region, upperBound(region)],
);
// Redis: integer geohash as score; one ZRANGEBYSCORE per cell range.
for (const [lo, hi] of prefixRanges(cells)) {
await redis.zRangeByScore("places:geo", lo, hi);
}When NOT to apply:
- Tiny tables where a sequential scan is already fast do not need the index — but make the predicate sargable anyway so it keeps working as the table grows.
Reference: Use The Index, Luke; Redis Sorted Sets
Use the Interleaved Integer as a Compact Sortable Key
The interleaved 64-bit geohash ([[enc-integer-morton-encode]]) sorts in the same order as the base32 string but costs 8 bytes instead of a 10-to-12-character string (the 52-bit integer carries ~length-10 precision), and integer comparison is a single instruction. As a sorted-set score (Redis) or a bigint key, it gives the same prefix-range behaviour as the string with less storage and faster comparisons. Coarsening to a parent cell is a bit-shift, not a substring.
Incorrect (store the string when you only ever range-scan):
struct Record { id: u64, geohash: String } // 12+ bytes; every compare walks the stringCorrect (store the interleaved integer; coarsen by shifting):
struct Record { id: u64, geohash: u64 } // 8 bytes, single-instruction compares
/// Coarsen to `bits` of precision: zero the low (52 - bits) interleaved bits.
fn coarsen(hash: u64, bits: u32) -> u64 {
let shift = 52 - bits;
(hash >> shift) << shift // align to the cell boundary
}
/// Half-open range [lo, hi) covering all finer cells under this prefix.
fn prefix_range(hash: u64, bits: u32) -> (u64, u64) {
let lo = coarsen(hash, bits);
(lo, lo + (1u64 << (52 - bits)))
}When NOT to apply:
- When humans need to read or paste the key (debugging, URLs, logs), the base32 string is worth its size ([[idx-sorted-string-range-scan]]). Many systems store both: integer for the index, string for display.
Reference: Z-order curve; Redis Geospatial
Execute a Box Query as Range Scans over the Covering Set
The covering ranges from [[qry-bbox-range-decomposition]] are only useful if you execute them as index range scans and merge the results. Issuing one scan per [start, end] range against the sorted integer key touches only the rows inside the box; falling back to "scan everything and filter by lat/lon" discards the index you built. Merge the per-range results and refine with exact distance ([[qry-refine-with-haversine]]).
Incorrect (ignore the ranges, scan and filter):
fn box_query_bad(store: &Store, min: (f64, f64), max: (f64, f64)) -> Vec<Record> {
store.all().into_iter() // full scan
.filter(|r| in_box(r, min, max))
.collect()
}Correct (range-scan each covering range, then merge):
fn box_query(store: &Store, min: (f64, f64), max: (f64, f64), bits: u32) -> Vec<Record> {
let mut out = Vec::new();
for (lo, hi) in bbox_ranges(min, max, bits) { // covering set from the qry rule
out.extend(store.range_scan(lo, hi)); // index-served, touches only the box
}
out.sort_unstable_by_key(|r| r.id);
out.dedup_by_key(|r| r.id); // abutting ranges can overlap a row; drop duplicates
out
}When NOT to apply:
- When the box covers most of the dataset, a single full scan can beat many small range scans — estimate selectivity and fall back to a scan above a threshold.
Reference: Redis Geospatial; Use The Index, Luke
Store Geohashes as Sorted Strings for Prefix Range Scans
A geohash's great property as a key is that lexicographic string order follows spatial proximity within a region — all points in a cell share a prefix and sort contiguously. Stored in a B-tree-indexed string column, "everything in region R" is a single range scan (>= R AND < R⁺), not a full table scan. Storing the hash unindexed, or only as separate lat/lon columns, throws this away and forces scan-and-filter.
Incorrect (separate columns, full scan with bounds):
// No geohash key; every proximity query scans and filters on two columns.
await db.query(
"SELECT * FROM places WHERE lat BETWEEN $1 AND $2 AND lon BETWEEN $3 AND $4",
[latMin, latMax, lonMin, lonMax], // no single index serves both ranges well
);Correct (indexed geohash column, prefix range scan):
CREATE TABLE places (id bigint, geohash text);
CREATE INDEX places_geohash_idx ON places (geohash); -- B-tree// Region prefix -> contiguous half-open range. Increment the last char for the upper bound.
function upperBound(prefix: string): string {
const last = prefix.charCodeAt(prefix.length - 1);
return prefix.slice(0, -1) + String.fromCharCode(last + 1);
}
await db.query(
"SELECT * FROM places WHERE geohash >= $1 AND geohash < $2",
[prefix, upperBound(prefix)],
);For multi-cell proximity, run one range per cell in the 3×3 block ([[qry-search-cell-plus-neighbors]]) or per covering range ([[qry-bbox-range-decomposition]]).
When NOT to apply:
- If your store has native geospatial indexing (PostGIS
GEOGRAPHY+ GiST, MongoDB2dsphere), prefer it — it handles curvature and true distance directly. Geohash-as-string shines in plain key-value or relational stores without geo support.
Reference: Wikipedia — Geohash; Use The Index, Luke
Aggregate by Region with a Geohash Trie
When you need counts or summaries per region at varying zoom — "how many points in each length-3 cell, then drill into one" — repeatedly scanning and grouping is wasteful. A trie keyed by geohash characters stores aggregates at every prefix length at once: each node holds the rollup for its prefix, so a region total is a single node lookup and drilling down is following children. This is the structure behind level-of-detail rendering ([[nav-level-of-detail-aggregation]]).
Incorrect (re-scan and group per zoom level):
function countsAt(points: Point[], prefixLen: number): Map<string, number> {
const m = new Map<string, number>();
for (const p of points) { // a full pass for every zoom level
const k = p.geohash.slice(0, prefixLen);
m.set(k, (m.get(k) ?? 0) + 1);
}
return m;
}Correct (one trie holds every level's rollup):
interface Node { count: number; children: Map<string, Node>; }
const newNode = (): Node => ({ count: 0, children: new Map() });
class GeoTrie {
private root = newNode();
insert(geohash: string) {
let node = this.root;
node.count++;
for (const ch of geohash) {
let next = node.children.get(ch);
if (!next) { next = newNode(); node.children.set(ch, next); }
next.count++; // every prefix length is aggregated in one pass
node = next;
}
}
count(prefix: string): number {
let node: Node | undefined = this.root;
for (const ch of prefix) { node = node?.children.get(ch); if (!node) return 0; }
return node.count; // O(prefix length), no rescan
}
}When NOT to apply:
- For a single fixed zoom level a flat
Map<prefix, count>is simpler. Reach for the trie when you aggregate at multiple precisions or drill interactively.
Reference: Trie (prefix tree); Elasticsearch geohash_grid
Validate That Coupled Code Lands in the Same Region
The whole value of a code map is that a geohash prefix selects a cohesive set of files — a domain or feature. That holds only if the projection keeps coupled files spatially close. This is an invariant to measure, not a hope: after building the map, check that files sharing a prefix are more coupled to each other than to files outside it. If they are not, the projection (or its parameters) is wrong and the map will mislead more than it helps.
Incorrect (assume the projection worked):
const map = projectCodebase(graph);
shipMap(map); // no check that prefixes correspond to real domainsCorrect (measure intra- vs inter-region coupling):
function regionCohesion(map: CodeMap, prefixLen: number, graph: ImportGraph): number {
let intra = 0, inter = 0;
for (const [pair, weight] of graph.edges()) {
const sameRegion =
map.geohash(pair.a).slice(0, prefixLen) === map.geohash(pair.b).slice(0, prefixLen);
if (sameRegion) intra += weight; else inter += weight;
}
return intra / (intra + inter); // ~1.0 = cohesive regions; ~0.5 = projection failed
}
if (regionCohesion(map, 4, graph) < 0.7) {
throw new Error("projection does not preserve coupling — retune the layout before shipping");
}When NOT to apply:
- An intentionally non-semantic map (e.g. files laid out by directory for a file-tree view) does not need this invariant — but then a geohash prefix is just a directory and the spatial framing adds little.
Reference: CodeCity — Wettel & Lanza; Coupling and cohesion)
Project Code into 2D from a Structural Signal, Not Arbitrary Layout
Treating a codebase like a map only works if a file's (x, y) position means something — otherwise a geohash prefix groups unrelated files and the map is decoration. Derive coordinates from a structural signal (the import/dependency graph, co-change history, or a feature/token matrix) via a layout that places related code near related code. Then a geohash prefix becomes a genuine neighbourhood. Random or alphabetical placement defeats the entire premise.
Incorrect (arbitrary placement):
// Position by a hash of the path -> spatially adjacent files are unrelated.
function coordOf(path: string): [number, number] {
const h = fnv1a(path);
return [(h & 0xffff) / 0xffff, ((h >> 16) & 0xffff) / 0xffff];
}Correct (layout from the dependency graph):
// Edges = imports; a force-directed (or UMAP / t-SNE) layout pulls coupled files together.
function projectCodebase(graph: ImportGraph): Map<string, [number, number]> {
const layout = forceDirected(graph, {
seed: 42, // deterministic — see map-stable-coordinates
iterations: 500,
attraction: (a, b) => graph.edgeWeight(a, b), // imports pull nodes together
});
return layout.positions(); // coupled modules end up in the same region
}The invariant that makes this work — coupled code stays spatially near — must be measured, not assumed: see [[map-coupling-implies-proximity]]. (Note: UMAP and t-SNE are reproducible only with a pinned seed and single-threaded execution — see [[map-stable-coordinates]].)
When NOT to apply:
- For a small codebase (tens of files) a hand-placed or directory-tree layout is clearer than a graph projection.
- The structural-signal approach pays off once the file count exceeds what a person can hold in their head.
Reference: CodeCity — Wettel & Lanza; UMAP
Normalise the Code Plane into the Geohash Lat/Lon Domain
Your layout produces coordinates in some arbitrary range; geohash encoders expect latitude in [-90, 90] and longitude in [-180, 180]. Map your plane into that domain with a single fixed affine transform so you can reuse every standard geohash library, tile server, and visualiser without modification. Keep the transform square (equal scale on both axes) and skip the cos(latitude) metric correction — your code plane has no real geography, so a square grid is exactly what you want ([[prec-cells-shrink-toward-poles]]).
Incorrect (feed raw layout coordinates to the encoder):
const [x, y] = coordOf(file); // e.g. x in [-3.2, 4.8], y in [0, 1200]
const hash = encode(y, x, 9); // out of range -> clamped to a corner; all files collideCorrect (fixed affine map into the geohash domain):
function makeProjector(b: { minX: number; maxX: number; minY: number; maxY: number }) {
const span = Math.max(b.maxX - b.minX, b.maxY - b.minY); // one square scale for both axes
return ([x, y]: [number, number]): { lat: number; lon: number } => ({
lon: ((x - b.minX) / span) * 360 - 180, // -> [-180, 180)
lat: ((y - b.minY) / span) * 180 - 90, // -> [-90, 90), same scale
});
}
const toGeo = makeProjector(layoutBounds);
const { lat, lon } = toGeo(coordOf(file));
const hash = encode(lat, lon, 9); // standard tooling now worksCompute layoutBounds once from the full layout and persist it ([[map-persist-coordinate-sidecar]]); recomputing the bounds per run shifts every hash.
When NOT to apply:
- If you have written a geohash encoder that accepts an arbitrary square domain directly, you can skip the lat/lon remap — but you lose drop-in compatibility with off-the-shelf map tooling.
Reference: Wikipedia — Geohash; OSM Slippy Map
Persist the File-to-Geohash Assignment as a Committed Sidecar
The coordinate assignment is the map. If it lives only in memory, every run can produce a different map, links rot, and nobody can review how a refactor moved code across domain boundaries. Persist path → [x, y] → geohash (plus the layout seed and bounds) as a committed file. Then the map is reproducible, anchorable for incremental stability ([[map-stable-coordinates]]), and a code review shows a diff of which files crossed which region boundaries.
Incorrect (recompute in memory, nothing persisted):
function showMap(graph: ImportGraph) {
const map = projectCodebase(graph); // fresh, unanchored, unreviewable
render(map); // tomorrow's map differs; no record of how regions changed
}Correct (committed sidecar with seed and bounds):
interface CodeMapSidecar {
version: 1;
seed: number; // reproduces the layout
bounds: { minX: number; maxX: number; minY: number; maxY: number };
precision: number;
files: Record<string, { xy: [number, number]; geohash: string }>;
}
function writeSidecar(map: CodeMap, path = "codemap.json") {
const sidecar: CodeMapSidecar = {
version: 1, seed: map.seed, bounds: map.bounds, precision: map.precision,
files: Object.fromEntries(
[...map.files()].map((f) => [f, { xy: map.xy(f), geohash: map.geohash(f) }]),
),
};
fs.writeFileSync(path, JSON.stringify(sidecar, null, 2)); // commit this file
}When NOT to apply:
- A throwaway visualisation you never compare across time can stay in memory — but the moment two people need to see the same map, persist it.
Reference: CodeCity — Wettel & Lanza; Reproducible builds
Map Prefix Length to Architectural Level
A geohash gets more specific one character at a time, which fits architectural nesting naturally: a short prefix addresses a whole domain, a longer one a module, longer still a file, and the full hash a symbol. Fix this length-to-level mapping up front so every scale has a stable address — you can link to "the Billing domain" (length 3) or "this function" (length 12) and the link keeps meaning. Without a fixed mapping, prefixes have no consistent semantic and navigation is guesswork.
Incorrect (ad-hoc lengths per call site):
const domainView = group(map, 5); // 5 here...
const moduleView = group(map, 6); // ...6 there, with no shared meaning
const fileLink = map.geohash(file).slice(0, 7); // and 7 elsewhereCorrect (one declared level table):
const LEVELS = { domain: 3, module: 5, file: 8, symbol: 12 } as const;
type Level = keyof typeof LEVELS;
function addressAt(file: string, level: Level, map: CodeMap): string {
return map.geohash(file).slice(0, LEVELS[level]); // stable address per level
}
const billingDomain = addressAt(file, "domain", map); // length 3, alwaysThis semantic mapping (what a prefix length means) is distinct from the rendering zoom levels in [[nav-precision-to-zoom-levels]] (what you show at each zoom).
When NOT to apply:
- If your codebase has fewer architectural tiers than geohash characters allow, collapse unused levels rather than inventing tiers to fill them.
Reference: Wikipedia — Geohash; CodeCity — Wettel & Lanza
Treat a Geohash Prefix as a Named Domain Region
Once code is geohashed, a prefix is a rectangular region of the map, and "which domain owns this file?" becomes a prefix lookup. Maintain an explicit, ordered registry mapping prefixes to domain labels (longest-prefix-wins, like an IP routing table) so membership, ownership, and boundaries are data you can query and review — not tribal knowledge scattered across CODEOWNERS and folder names. New files get a domain automatically from where they land on the map.
Incorrect (re-derive domains from paths every time):
function domainOf(file: string): string {
if (file.includes("/checkout/")) return "Checkout"; // brittle path heuristics
if (file.includes("/billing/")) return "Billing";
return "Unknown"; // a moved or new file falls through
}Correct (longest-prefix match against a region registry):
// Ordered longest-first so the most specific region wins.
const REGIONS: { prefix: string; domain: string }[] = [
{ prefix: "gcpuv", domain: "Checkout / Payments" },
{ prefix: "gcpu", domain: "Checkout" },
{ prefix: "gcp", domain: "Commerce" },
].sort((a, b) => b.prefix.length - a.prefix.length);
function domainOf(file: string, map: CodeMap): string {
const hash = map.geohash(file);
return REGIONS.find((r) => hash.startsWith(r.prefix))?.domain ?? "Unassigned";
}A file's geohash is stable ([[map-stable-coordinates]]), so its domain is stable too; reviewers see domain changes as prefix changes in the sidecar diff ([[map-persist-coordinate-sidecar]]).
When NOT to apply:
- Tiny codebases with a handful of obvious domains do not need a region registry — a flat list works until prefix-based ownership earns its keep.
Reference: CodeCity — Wettel & Lanza; Longest prefix match
Make Coordinates Reproducible and Incremental-Stable
A code map is navigable only if a file stays roughly where it was last time you looked. Layout algorithms are usually randomised — a fresh run reshuffles everything, so a file's geohash region changes commit to commit and any saved "go to region X" breaks. Fix the random seed for reproducibility, and when the codebase changes, anchor existing nodes and solve only for new or moved ones so adding one file does not relocate the rest.
Incorrect (unseeded layout, full re-solve every run):
function rebuildMap(graph: ImportGraph) {
const layout = forceDirected(graph, { seed: Math.random() }); // different every run
return layout.positions(); // every file's geohash changes; the map is unrecognisable
}Correct (fixed seed; anchor existing nodes):
function rebuildMap(graph: ImportGraph, previous: Map<string, [number, number]>) {
const layout = forceDirected(graph, {
seed: 42, // reproducible across machines and runs
fixedPositions: previous, // existing files keep their coordinates
solveOnly: graph.nodesNotIn(previous), // only place new/changed files
});
return layout.positions();
}Persist the resulting coordinates so the next run can anchor against them — see [[map-persist-coordinate-sidecar]].
When NOT to apply:
- A one-off exploratory snapshot (rendered once, never compared) does not need incremental stability — but still seed the RNG so a teammate reproduces the same picture.
Reference: CodeCity — Wettel & Lanza; UMAP — random_state
Use the Geohash Prefix as Navigation State and Deep Link
In a map you want to share "look here" and show "where am I". A geohash prefix is that location state: each character is one step down a breadcrumb (g → gc → gcp → gcpu), and the current prefix encodes the viewport's region in a few characters. Put it in the URL and a link deep-links straight to a region; back/forward and breadcrumbs fall out of the prefix path. Tracking pan/zoom as opaque lat/lon/zoom triples instead loses the natural hierarchy and makes links fragile to rounding.
Incorrect (opaque coordinate triple in the URL):
history.pushState(null, "", `?lat=${lat}&lon=${lon}&z=${zoom}`);
// no hierarchy, no breadcrumb, brittle to roundingCorrect (prefix as the route; breadcrumb from the path):
function navigateTo(prefix: string) {
history.pushState({ prefix }, "", `#/g/${prefix}`); // deep-linkable region
}
function breadcrumb(prefix: string): { label: string; prefix: string }[] {
return [...prefix].map((_, i) => ({
label: prefix.slice(0, i + 1),
prefix: prefix.slice(0, i + 1), // click any crumb to zoom to that region
}));
}
// On load: read location.hash -> prefix -> centre/zoom via decodeBbox(prefix).For a code map this means a link like #/g/gcpuv jumps straight to a domain region ([[map-prefix-as-domain-region]]).
When NOT to apply:
- Views that must restore an exact centre and fractional zoom (not a whole cell) still need explicit coordinates — use the prefix for the region and a small offset for the precise framing.
Reference: OSM Slippy Map; Wikipedia — Geohash
Cluster Overlapping Markers by Shared Prefix
At a given zoom, many points fall within a few pixels of each other and draw as an illegible pile. Grouping markers that share a geohash prefix at the zoom's precision collapses each cluster into one marker sized or labelled by its member count, which both declutters the view and cuts draw calls. Because clustering reuses the same prefix the renderer already computed for the zoom, it costs almost nothing extra.
Incorrect (draw a marker per point):
function drawMarkers(points: Point[]) {
for (const p of points) drawMarker(p.lat, p.lon); // overlapping pile at low zoom
}Correct (one marker per prefix cluster):
function drawMarkers(points: Point[], zoom: number) {
const len = precisionForZoom(zoom);
const clusters = new Map<string, Point[]>();
for (const p of points) {
const key = p.geohash.slice(0, len);
let bucket = clusters.get(key);
if (!bucket) { bucket = []; clusters.set(key, bucket); }
bucket.push(p);
}
for (const [cell, members] of clusters) {
if (members.length === 1) drawMarker(members[0].lat, members[0].lon);
else drawCluster(centerOf(cell), members.length); // one sized marker
}
}When NOT to apply:
- When every individual marker must stay clickable (a small curated set), clustering hides targets — keep points distinct and rely on zoom instead.
Reference: OSM Slippy Map; Wikipedia — Geohash
Render Aggregated Prefix Buckets When Zoomed Out
Drawing every point at a low zoom both blows the frame budget and produces an unreadable smear. Level-of-detail rendering draws aggregates when zoomed out — one shape per geohash prefix bucket, labelled with its count — and switches to individual points only when a bucket is large on screen. With a geohash trie ([[idx-trie-hierarchical-bucketing]]) the bucket counts are already computed, so render cost depends on the number of visible cells, not the dataset size.
Incorrect (draw every point at every zoom):
function render(points: Point[], zoom: number) {
for (const p of points) drawDot(p); // 1e6 dots at zoom 3 -> dropped frames, smear
}Correct (aggregate by prefix; drill down on zoom):
function render(trie: GeoTrie, zoom: number, viewport: BBox) {
const len = precisionForZoom(zoom);
for (const cell of coverBbox(viewport, len)) {
const n = trie.count(cell);
if (n === 0) continue;
if (n <= POINT_THRESHOLD) drawPoints(trie.pointsUnder(cell)); // few enough -> real points
else drawBucket(cell, n); // many -> one labelled cell
}
}When NOT to apply:
- Small datasets that always fit the frame budget can draw raw points at every zoom — aggregation earns its keep once visible points exceed what a frame can paint.
Reference: OSM Slippy Map; Elasticsearch geohash_grid
Map Geohash Precision to Zoom Levels
A slippy map (Google/OSM style) renders different detail at each zoom level; a geohashed dataset should pick the precision whose cells are about the size of a screen tile at the current zoom. Render full-precision hashes when zoomed out and you draw millions of overlapping cells; render a short prefix when zoomed in and you show a few giant blocks. Define an explicit zoom→precision table — roughly every ~2.5 web-map zoom levels corresponds to one extra geohash character — so each zoom queries and draws the right granularity.
Incorrect (fixed precision at every zoom):
function cellsForViewport(zoom: number, bbox: BBox) {
return coverBbox(bbox, 9); // always length 9 — millions of cells when zoomed out
}Correct (precision chosen from zoom):
// Web-map zoom 0..21 -> geohash length. ~2-3 zoom levels per character.
function precisionForZoom(zoom: number): number {
return Math.max(1, Math.min(12, Math.round(zoom / 2.5) + 1));
}
function cellsForViewport(zoom: number, bbox: BBox) {
return coverBbox(bbox, precisionForZoom(zoom)); // detail matched to the view
}This rendering-zoom mapping is distinct from the semantic prefix-length mapping in [[map-precision-as-architectural-level]] — one decides what to draw, the other what a prefix means.
When NOT to apply:
- A non-zoomable, single-scale view (one fixed overview) needs only one precision — the table is for interactive pan/zoom.
Reference: OSM Slippy Map; Wikipedia — Geohash
Load Only the Geohash Cells in the Viewport
Loading the entire dataset to render one screen wastes bandwidth and memory and does not scale. Because the viewport is a bounding box, its covering geohash cells ([[qry-bbox-range-decomposition]]) are exactly the data you need — fetch those cells lazily as the user pans, cache what you have, and request only the newly revealed cells. This is the geohash equivalent of map tile loading.
Incorrect (load everything, filter client-side):
async function onPan(viewport: BBox) {
const all = await fetchAllPoints(); // downloads the world on every pan
return all.filter((p) => inBox(p, viewport));
}Correct (fetch only newly visible cells, with a cache):
const cache = new Map<string, Point[]>();
async function onPan(viewport: BBox, zoom: number) {
const cells = coverBbox(viewport, precisionForZoom(zoom));
const missing = cells.filter((c) => !cache.has(c));
for (const [cell, points] of await fetchCells(missing)) cache.set(cell, points);
return cells.flatMap((c) => cache.get(c) ?? []); // only the viewport's data
}When NOT to apply:
- If the whole dataset fits comfortably in memory, load it once and skip the per-pan fetch — lazy tiling is for data too large to ship whole.
Reference: OSM Slippy Map; Redis Geospatial
Wrap East/West Neighbours Across the Antimeridian
A cell touching +180° longitude has an east neighbour at -180° — they are physically adjacent across the dateline. The string lookup-table algorithm handles this through its carry recursion, but if you compute neighbours on the de-interleaved integer by adding one to the longitude column, the column overflows and either wraps incorrectly or names an out-of-range cell unless you mask to the valid bit width. Always wrap longitude modulo 2^bits; never wrap latitude (the poles are hard edges — see [[nbr-pole-handling]]).
Incorrect (overflow past the last column):
fn east_bad(lon_col: u32, lat_col: u32, _bits: u32) -> (u32, u32) {
(lon_col + 1, lat_col) // at the max column this exceeds 2^bits -> invalid cell
}Correct (wrap the longitude column modulo the grid width):
fn east(lon_col: u32, lat_col: u32, bits: u32) -> (u32, u32) {
let width = 1u32 << bits;
((lon_col + 1) & (width - 1), lat_col) // +180 wraps to -180
}
fn west(lon_col: u32, lat_col: u32, bits: u32) -> (u32, u32) {
let width = 1u32 << bits;
((lon_col + width - 1) & (width - 1), lat_col)
}When NOT to apply:
- For the synthetic codebase plane ([[map-normalize-to-geohash-domain]]) there is no dateline — leave the east/west edges unwrapped so regions do not wrap around the map, which is usually what you want for code.
Reference: davetroy/geohash-js; Wikipedia — Geohash
Compute Neighbours with the Canonical Border and Neighbour Tables
Adjacency cannot be done by incrementing the last base32 character — the Z-order curve means the cell to the east is not the next character in the alphabet. The de-facto algorithm (David Troy's geohash-js) uses two tables: NEIGHBORS maps each character to its neighbour in a direction, and BORDERS marks the characters on the edge of the parent cell, where you must recurse into the parent to carry. Hand-rolling adjacency almost always gets the carry wrong; use the proven tables. Note the parity flip: the table you index depends on whether the hash length is odd or even, because the lon/lat bit roles swap each character.
Incorrect (increment the last character):
// "u" + 1 is NOT the cell to the east — base32 order != spatial order.
function eastNeighbor(hash: string): string {
const last = hash[hash.length - 1];
const next = GEOHASH_BASE32[(GEOHASH_BASE32.indexOf(last) + 1) % 32];
return hash.slice(0, -1) + next; // wrong cell, often a different latitude band
}Correct (canonical tables with carry recursion):
const NEIGHBORS = {
north: { even: "p0r21436x8zb9dcf5h7kjnmqesgutwvy", odd: "bc01fg45238967deuvhjyznpkmstqrwx" },
south: { even: "14365h7k9dcfesgujnmqp0r2twvyx8zb", odd: "238967debc01fg45kmstqrwxuvhjyznp" },
east: { even: "bc01fg45238967deuvhjyznpkmstqrwx", odd: "p0r21436x8zb9dcf5h7kjnmqesgutwvy" },
west: { even: "238967debc01fg45kmstqrwxuvhjyznp", odd: "14365h7k9dcfesgujnmqp0r2twvyx8zb" },
};
const BORDERS = {
north: { even: "prxz", odd: "bcfguvyz" },
south: { even: "028b", odd: "0145hjnp" },
east: { even: "bcfguvyz", odd: "prxz" },
west: { even: "0145hjnp", odd: "028b" },
};
function adjacent(hash: string, dir: keyof typeof NEIGHBORS): string {
hash = hash.toLowerCase();
const last = hash[hash.length - 1];
const type = hash.length % 2 === 1 ? "odd" : "even"; // parity selects the table
let base = hash.slice(0, -1);
if (BORDERS[dir][type].includes(last)) base = adjacent(base, dir); // carry into parent
return base + GEOHASH_BASE32[NEIGHBORS[dir][type].indexOf(last)];
}When NOT to apply:
- If you store geohashes as integers, adjacency is cheaper at the bit level ([[nbr-integer-level-neighbors]]); the string tables are for string geohashes.
Reference: davetroy/geohash-js; Wikipedia — Geohash
Build the Full Eight-Neighbour Set for Proximity
A point near a cell corner has close neighbours in the diagonal cells, not just the four cardinal ones. Proximity that checks only N/S/E/W misses up to four of the eight surrounding cells, dropping matches that sit just across a corner. Compose the diagonals from the cardinal operations (north-then-east, north-then-west, etc.) and skip any that fall off a pole.
Incorrect (four cardinal cells only):
fn neighbors4(hash: u64, bits: u32) -> Vec<u64> {
[north(hash, bits), south(hash, bits)].into_iter().flatten()
.chain([east(hash, bits), west(hash, bits)])
.collect() // misses NE, NW, SE, SW — corner-adjacent points are lost
}Correct (all eight, pole-safe):
// north/south return None at the poles; east/west always wrap.
fn neighbors8(hash: u64, bits: u32) -> Vec<u64> {
let mut out = vec![east(hash, bits), west(hash, bits)];
for vertical in [north(hash, bits), south(hash, bits)] {
if let Some(v) = vertical {
out.push(v);
out.push(east(v, bits)); // NE / SE
out.push(west(v, bits)); // NW / SW
}
}
out // up to 8; fewer next to a pole
}When NOT to apply:
- A strictly axis-constrained search (e.g. "same row only") legitimately needs a subset of directions.
- For any radius/proximity search, always use all eight — see [[qry-search-cell-plus-neighbors]].
Reference: davetroy/geohash-js; Redis Geospatial
Compute Neighbours on the De-interleaved Integer
The string lookup-table algorithm is O(length) per neighbour and recurses on every border carry. If you already store geohashes as interleaved integers ([[enc-integer-morton-encode]]), neighbours are far cheaper: de-interleave into the longitude and latitude columns, add or subtract one on the relevant axis, and re-interleave. This is constant work with no table indirection or allocation, which matters when computing the eight-neighbour set for millions of points.
Incorrect (round-trip through strings to reuse the table algorithm):
fn east_int_bad(hash: u64, bits: u32) -> u64 {
let s = to_base32_string(hash, bits); // allocate
let n = adjacent_string(&s, Dir::East); // O(len) table recursion
from_base32_string(&n) // parse back
}Correct (de-interleave, step the axis, re-interleave):
fn deinterleave(hash: u64) -> (u32, u32) { /* inverse of `spread`: gather even/odd bits */ }
fn interleave(lon_col: u32, lat_col: u32) -> u64 { /* spread(lon) << 1 | spread(lat) */ }
fn east_int(hash: u64, bits: u32) -> u64 {
let (lon, lat) = deinterleave(hash);
let width = 1u32 << bits;
interleave((lon + 1) & (width - 1), lat) // O(1), no allocation
}When NOT to apply:
- If your storage is string geohashes and you need only the occasional neighbour, converting to integers just for adjacency is not worth it — use the table algorithm ([[nbr-canonical-lookup-tables]]) directly on the string.
Reference: Z-order curve; Redis Geospatial
Return No Neighbour Past the Poles
Longitude wraps, but latitude does not — there is no cell north of the top row (90°N) or south of the bottom row (90°S). If your neighbour function fabricates one anyway (by wrapping latitude, or by the string algorithm recursing off the top), you get a hash that decodes to the wrong hemisphere and silently pollutes proximity results. A north query at the top row must return "no neighbour".
Incorrect (latitude wraps like longitude):
function north(latCol: number, lonCol: number, bits: number) {
const height = 1 << bits;
return { latCol: (latCol + 1) % height, lonCol }; // wraps 90°N -> 90°S (wrong)
}Correct (clamp; signal "no neighbour"):
function north(latCol: number, lonCol: number, bits: number):
{ latCol: number; lonCol: number } | null {
const height = 1 << bits;
if (latCol + 1 >= height) return null; // already the top row — nothing beyond it
return { latCol: latCol + 1, lonCol };
}Callers treat null as "skip this direction", so the eight-neighbour set near a pole simply has fewer members — see [[nbr-eight-neighbor-set]].
When NOT to apply:
- Datasets that never approach the poles (most city-scale or codebase-map data) will not hit this, but the null-returning signature costs nothing and keeps the function correct everywhere.
Reference: Wikipedia — Geohash
Normalise to One Precision Before Comparing or Storing
Prefix containment ("is point X inside region R?") only works when hashes are compared consistently: a region prefix against point hashes that are at least as long. Store a mix of length-6 and length-9 hashes in one index and do naive equality or prefix checks and you get wrong answers — a length-6 hash is not "inside" a length-9 hash even when it geographically contains it. Pick a storage precision, normalise on the way in, and compare regions by prefix, never by equality across lengths.
Incorrect (equality across mixed lengths):
function inRegion(pointHash: string, regionHash: string): boolean {
return pointHash === regionHash; // fails whenever the two lengths differ
}Correct (region as a prefix of a normalised point hash):
const STORAGE_PRECISION = 9;
function store(lat: number, lon: number): string {
return encode(lat, lon, STORAGE_PRECISION); // every point at the same length
}
function inRegion(pointHash: string, regionPrefix: string): boolean {
return pointHash.startsWith(regionPrefix); // region may be any shorter length
}When NOT to apply:
- Multi-resolution indexes (deliberately storing several truncations of each hash) are a valid advanced pattern — but then each resolution lives in its own column/key and is never compared across resolutions by equality. See [[idx-sorted-string-range-scan]].
Reference: Wikipedia — Geohash; Elasticsearch geohash_grid
Treat Cells as Rectangles Whose Aspect Flips with Length
A geohash cell is almost never square. Each character adds 5 bits split 3/2 between longitude and latitude, so odd-length cells are wider than tall and even-length cells are nearer square — the width-to-height ratio alternates with every character added. Code that assumes a square cell (one "cell size" used for both axes) is off by up to 2x on one dimension, corrupting radius checks and rendering.
Incorrect (one size for both axes):
fn cell_size_m(len: u32) -> f64 {
// single number used for width AND height -> wrong on one axis
40_000_000.0 / 2f64.powi((len * 5 / 2) as i32)
}Correct (separate width/height from per-axis bit counts):
/// Longitude gets the extra bit at odd total bit counts.
fn axis_bits(len: u32) -> (u32, u32) {
let total = len * 5;
let lon = total / 2 + total % 2; // longitude: ceil(total/2)
let lat = total / 2; // latitude: floor(total/2)
(lon, lat)
}
fn cell_deg(len: u32) -> (f64, f64) {
let (lon_bits, lat_bits) = axis_bits(len);
let width_deg = 360.0 / 2f64.powi(lon_bits as i32);
let height_deg = 180.0 / 2f64.powi(lat_bits as i32);
(width_deg, height_deg) // len 5 -> wider than tall; len 6 -> nearer square
}When NOT to apply:
- Rough single-length visualisations can sometimes tolerate a square approximation.
- Never use a square approximation for distance thresholds or hit-testing.
Reference: Wikipedia — Geohash; Elasticsearch geohash_grid
Account for Longitude Metres Shrinking with Latitude
A geohash cell has constant width in degrees of longitude, but a degree of longitude is ~111 km at the equator and shrinks by cos(latitude) toward the poles — only ~55 km at 60°. Convert a cell's degree-width to metres with a fixed factor and your metric size is correct only at the equator and overstated everywhere else, which throws off radius queries and any "metres per cell" assumption at high latitude. Latitude degrees, by contrast, stay roughly constant.
Incorrect (fixed metres-per-degree on both axes):
const M_PER_DEG: f64 = 111_320.0; // only true near the equator
fn cell_width_m(width_deg: f64) -> f64 {
width_deg * M_PER_DEG // overstates east-west extent at high latitude
}Correct (scale longitude by cos(latitude)):
const M_PER_DEG_LAT: f64 = 111_320.0;
fn cell_width_m(width_deg: f64, centre_lat_deg: f64) -> f64 {
let scale = centre_lat_deg.to_radians().cos();
width_deg * M_PER_DEG_LAT * scale // ~half the equatorial width at 60°
}
fn cell_height_m(height_deg: f64) -> f64 {
height_deg * M_PER_DEG_LAT // latitude degrees are ~constant
}When NOT to apply:
- Near the equator (
|lat| < ~10°) the cosine term is within a few percent and can be dropped for rough work. - Never drop it for global datasets that include high latitudes, or for the synthetic coordinate plane in [[map-normalize-to-geohash-domain]] where you control the projection and can keep cells square instead.
Reference: Wikipedia — Geohash; Haversine / great-circle distance
Choose Geohash Length from the Required Error Radius
The most common geohash mistake is picking a length by feel ("8 looks precise enough"). Each character changes cell size by roughly 5-10x, so being one off means cells an order of magnitude too coarse (false matches) or too fine (your "nearby" query returns nothing because every point lands in its own cell). Derive the length from the coarsest cell whose error radius still satisfies the requirement.
Incorrect (hardcoded length):
const PRECISION = 8; // why 8? cell is ~38m x 19m — wrong for a 2 km "nearby" search
function nearbyHash(lat: number, lon: number) {
return encode(lat, lon, PRECISION);
}Correct (derive length from the required radius):
// Approximate cell half-diagonal (error radius) in metres, by geohash length 1..10.
const CELL_ERROR_METRES = [2_500_000, 630_000, 78_000, 20_000, 2_400, 610, 76, 19, 2.4, 0.6];
function lengthForRadius(metres: number): number {
for (let len = 1; len <= CELL_ERROR_METRES.length; len++) {
if (CELL_ERROR_METRES[len - 1] <= metres) return len;
}
return CELL_ERROR_METRES.length;
}
const len = lengthForRadius(2000); // 2 km search -> length 5 (~2.4 km cell), not 8When NOT to apply:
- When an external system fixes the length for you (Redis GEO uses an internal 52-bit precision; a tile scheme may mandate length 7). Match their precision rather than computing your own.
Reference: Wikipedia — Geohash precision table; Elasticsearch geohash_grid
Report Decoded Accuracy as Half the Cell, Not the Full Cell
A geohash names a cell; the only thing you know about the original point is that it lies somewhere inside. The decoded centre is therefore accurate to ±half the cell width/height, not the full cell. Reporting the full cell dimension as the error doubles your stated uncertainty and breaks any logic that decides "are these two hashes close enough" using the margin.
Incorrect (full-cell error):
function decodeWithError(hash: string) {
const { width, height } = cellDimensions(hash.length);
return { lat, lon, latErr: height, lonErr: width }; // 2x too large
}Correct (half-cell error from the bounding box):
function decodeWithError(hash: string) {
const [latMin, lonMin, latMax, lonMax] = decodeBbox(hash);
return {
lat: (latMin + latMax) / 2,
lon: (lonMin + lonMax) / 2,
latErr: (latMax - latMin) / 2, // ± half the cell height
lonErr: (lonMax - lonMin) / 2, // ± half the cell width
};
}The half-cell error falls out for free once you decode to a bounding box instead of a bare point — see [[dec-decode-to-bbox]].
When NOT to apply:
- Never overstate accuracy.
- If a consumer genuinely wants the full cell extent (e.g. to draw the cell rectangle), give them the bounding box explicitly rather than mislabelling it as "error".
Reference: Wikipedia — Geohash
Decompose a Bounding Box into Covering Geohash Ranges
To find everything inside a rectangle, a common mistake is to hash the four corners and query those four prefixes — but the interior and edges contain many cells the corners do not name. The correct approach enumerates the grid of cells covering the box at a chosen precision and collapses runs of consecutive integer geohashes into [start, end] ranges, which a B-tree or sorted set serves as range scans (see [[idx-integer-sortable-key]]).
Incorrect (four corner prefixes):
fn bbox_query_bad(min: (f64, f64), max: (f64, f64)) -> Vec<u64> {
vec![ // misses everything not under a corner cell
encode_u64(min.0, min.1), encode_u64(min.0, max.1),
encode_u64(max.0, min.1), encode_u64(max.0, max.1),
]
}Correct (enumerate covering cells, merge into ranges):
fn bbox_ranges(min: (f64, f64), max: (f64, f64), bits: u32) -> Vec<(u64, u64)> {
let mut cells: Vec<u64> = grid_cells_covering(min, max, bits).collect();
cells.sort_unstable();
// Collapse consecutive integers into [start, end] ranges for range scans.
let mut ranges = Vec::new();
let (mut start, mut prev) = (cells[0], cells[0]);
for &c in &cells[1..] {
if c == prev + 1 { prev = c; } else { ranges.push((start, prev)); start = c; prev = c; }
}
ranges.push((start, prev));
ranges
}When NOT to apply:
- For tiny boxes that fit within a 3×3 cell block, the cell-plus-neighbours approach ([[qry-search-cell-plus-neighbors]]) is simpler and just as correct.
Reference: Redis Geospatial; Z-order curve
Widen the Search by Dropping a Prefix Character on Sparse Cells
A fixed-precision proximity query returns nothing when the 3×3 block happens to be empty — common in sparse regions (rural areas, or thinly populated parts of a code map). Instead of returning "no results", drop the last character of the query geohash to zoom out one level (each step covers ~32x the area) and retry until you have enough candidates or hit a minimum precision. This yields graceful "nearest anything" behaviour rather than a hard empty.
Incorrect (single fixed precision, empty on miss):
fn nearby(hash: u64, bits: u32, store: &Store) -> Vec<Record> {
let block: Vec<u64> = std::iter::once(hash).chain(neighbors8(hash, bits)).collect();
store.fetch(&block) // returns [] in a sparse area, with no fallback
}Correct (expand precision until enough candidates):
fn nearby_adaptive(lat: f64, lon: f64, store: &Store, want: usize, min_bits: u32) -> Vec<Record> {
let mut bits = 30; // start fine
loop {
let hash = encode_u64_at(lat, lon, bits);
let block: Vec<u64> = std::iter::once(hash).chain(neighbors8(hash, bits)).collect();
let hits = store.fetch(&block);
if hits.len() >= want || bits <= min_bits {
return hits;
}
bits -= 5; // zoom out one base32 character (~32x larger area) and retry
}
}When NOT to apply:
- Strict-radius queries ("only within 500 m") must return empty when nothing qualifies — do not expand past the requested radius, or you return points that are genuinely too far.
Reference: Redis Geospatial; Wikipedia — Geohash
Match Query Precision to the Search Radius
The cell-plus-neighbours trick only works if a cell is about the size of your search radius. Pick a precision too fine and the 3×3 block is smaller than the radius, so points two cells away are missed; pick it too coarse and you scan a huge area and pull back thousands of candidates to filter. Choose the longest geohash whose cell is at least the radius, so the 3×3 block comfortably covers a circle of that radius.
Incorrect (fixed precision regardless of radius):
function candidatesFor(lat: number, lon: number, radiusM: number) {
const hash = encode(lat, lon, 7); // ~150 m cell — a 1 km search misses most matches
return [hash, ...eightNeighbors(hash)];
}Correct (precision derived from the radius):
function candidatesFor(lat: number, lon: number, radiusM: number) {
const len = lengthForRadius(radiusM); // coarsest cell >= radius (see prec rule)
const hash = encode(lat, lon, len);
return [hash, ...eightNeighbors(hash)]; // block spans ~3x the radius each way
}When NOT to apply:
- For variable-radius queries against a fixed-precision index, query at the index precision and expand the neighbour ring (more than one cell out) instead of re-encoding.
Reference: Wikipedia — Geohash; [[prec-choose-from-error-radius]]
Refine Geohash Candidates with True Distance
A geohash query returns a superset — the 3×3 cell block is square and larger than your circular radius, so its corners include points inside the cells but outside the radius. Treat the geohash result as a cheap coarse filter, then compute the exact great-circle (haversine) distance to drop the false positives and sort by real proximity. Skip this and you return points in the wrong corner as if they were nearest.
Incorrect (return raw cell candidates as results):
async function nearest(lat: number, lon: number, radiusM: number) {
const cells = candidatesFor(lat, lon, radiusM);
return fetchByCells(cells); // includes corner points beyond the radius, unsorted
}Correct (filter and sort by haversine distance):
function haversineM(a: LatLon, b: LatLon): number {
const R = 6_371_000, toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(b.lat - a.lat), dLon = toRad(b.lon - a.lon);
const h = Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(h));
}
async function nearest(lat: number, lon: number, radiusM: number) {
const candidates = await fetchByCells(candidatesFor(lat, lon, radiusM));
return candidates
.map((p) => ({ ...p, dist: haversineM({ lat, lon }, p) }))
.filter((p) => p.dist <= radiusM) // drop corner false positives
.sort((a, b) => a.dist - b.dist); // true nearest-first
}When NOT to apply:
- When approximate cell-level results are acceptable (heatmaps, bucketed aggregations) the exact-distance pass is wasted work — the geohash bucket is the answer.
- On the synthetic code plane, swap haversine for plain Euclidean distance on the layout coordinates — there is no real curvature ([[map-normalize-to-geohash-domain]]).
Reference: Haversine formula; Redis Geospatial
Query the Cell Plus Its Eight Neighbours, Never the Prefix Alone
This is the defining geohash pitfall. Two points a metre apart can land in different cells when they straddle a boundary, so they share no common prefix — a search that matches only the query point's prefix silently misses them. Build the candidate set from the query cell and its eight neighbours, so any point within roughly one cell of the query is captured regardless of which side of a border it falls on.
Incorrect (single prefix match):
async function nearby(lat: number, lon: number) {
const hash = encode(lat, lon, 6);
return db.query("SELECT * FROM places WHERE geohash LIKE $1", [`${hash}%`]);
// misses every point just across a cell border from (lat, lon)
}Correct (3×3 block of cells):
async function nearby(lat: number, lon: number) {
const hash = encode(lat, lon, 6);
const cells = [hash, ...eightNeighbors(hash)]; // centre + 8 surrounding cells
const clauses = cells.map((_, i) => `geohash LIKE $${i + 1}`).join(" OR ");
return db.query(`SELECT * FROM places WHERE ${clauses}`, cells.map((c) => `${c}%`));
}Choose the precision so one cell is about the size of the search radius ([[qry-precision-from-radius]]), then refine candidates by true distance ([[qry-refine-with-haversine]]).
When NOT to apply:
- Containment queries ("which region is this point in?") legitimately use a single prefix — the nine-cell expansion is specifically for proximity/radius search.
Reference: Wikipedia — Geohash; Redis Geospatial
Related skills
FAQ
What does geohash-spatial-code-maps do?
geohash-spatial-code-maps is a Claude Code skill for ai & agent building.
When should I use geohash-spatial-code-maps?
When you need to helps with ai & agent building tasks during AI-assisted development., or when geohash-spatial-code-maps is a claude code skill for ai & agent building.
What are the main capabilities?
geohash-spatial-code-maps; AI & Agent Building; AI-coding skill.