Offline, zero-dependency reverse geocoding and municipality search for Spain using exact municipality polygons and a quadtree spatial index.
Given coordinates β returns the municipality, province, autonomous community, and the municipality polygon (GeoJSON geometry).
| Feature | Description |
|---|---|
| Reverse Geocoding | Exact point-in-polygon lookup using a quadtree spatial index |
| Municipality Search | Find municipalities by name (exact or partial), accent & case insensitive |
| Streaming Parser | Iterative, non-recursive JSON parser (no StackOverflowError) |
| Virtual Threads | High-performance Demo Server scaling via Java 21 Virtual Threads |
| Batch Processing | Bulk reverse geocode coordinates from CSV/text files |
| Web Demo | Integrated interactive map for visual geocoding and search |
| Embedded Data | ~92 MB GeoJSON with all 8,131 Spanish municipalities bundled in the JAR |
| Library API | Clean builder-pattern API using Java 21 Records |
| CLI Tool | Advanced command-line interface for all operations |
| Geometry Output | Returns full municipality polygons as GeoJSON |
| Low Precision Mode | Option to use ~75MB GeoJSON to save space |
| Zero Dependencies | Pure Java 21 β no external libraries or build tools required |
import com.futesat.spaingeo.SpainGeo;
import com.futesat.spaingeo.model.ReverseGeocodeResult;
// 1. Build (loads embedded GeoJSON automatically)
SpainGeo geo = SpainGeo.builder().build();
// 2. Reverse geocode
ReverseGeocodeResult result = geo.reverse(40.4167, -3.70325);
System.out.println(result.municipality().name()); // β "Madrid"
// 3. Search by name
List<ReverseGeocodeResult> results = geo.searchByName("CΓ³rdoba");# Reverse geocode
java -jar spain-reverse-geocoder.jar lookup --lat 40.4167 --lon -3.70325
# Search by name
java -jar spain-reverse-geocoder.jar search --name "Madrid"
# Launch web demo (Scalable via Virtual Threads)
java -jar spain-reverse-geocoder.jar demoThe project includes a built-in, zero-dependency web server optimized for Java 21.
java -jar spain-reverse-geocoder.jar demo [--port 8080]Features:
- Click-to-Geocode: Click anywhere on the map to find the municipality.
- Search Bar: Search municipalities by name with real-time suggestions.
- Visual Highlight: Shows exact borders (polygon) of the selected municipality.
- Scalable: Uses Java 21 Virtual Threads to handle many concurrent users with minimal overhead.
- Offline: Port 8080 by default, no external API keys required.
Requires JDK 21+. No build tools needed.
./scripts/build.shOutput:
build/spain-reverse-geocoder.jarβ self-contained JAR with embedded GeoJSON data
// Default: loads all municipalities from embedded GeoJSON
SpainGeo geo = SpainGeo.builder().build();
// Load only specific provinces (saves memory)
SpainGeo geo = SpainGeo.builder()
.provinces("28", "08", "46") // Madrid, Barcelona, Valencia
.build();
// Use your own GeoJSON file
SpainGeo geo = SpainGeo.builder()
.geoJsonPath(Path.of("/path/to/municipalities.geojson"))
.build();
// Use low-precision mode (saves ~15MB JAR space and memory)
SpainGeo geo = SpainGeo.builder()
.lowPrecision(true)
.build();ReverseGeocodeResult result = geo.reverse(40.4167, -3.70325);
if (result != null) {
result.municipality().id(); // "28079"
result.municipality().name(); // "Madrid"
result.province().id(); // "28"
result.province().name(); // "Madrid"
result.autonomousCommunity().id(); // "13"
result.autonomousCommunity().name(); // "Madrid, Comunidad de"
result.geometry().toJson(); // GeoJSON Polygon/MultiPolygon
}All search methods are accent-insensitive and case-insensitive. Searching for "cordoba" will match "CΓ³rdoba", "CΓRDOBA", etc.
// Exact name match
List<ReverseGeocodeResult> results = geo.searchByName("CΓ³rdoba");
// Partial/substring match
List<ReverseGeocodeResult> results = geo.searchByNameContains("madri");
// Filter by province + municipality name
List<ReverseGeocodeResult> results = geo.search("Madrid", "Getafe");
List<ReverseGeocodeResult> results = geo.search("28", "Getafe"); // by province code
// Listing
List<AdminDivision> communities = geo.listCommunities();
List<AdminDivision> provinces = geo.listProvinces("13"); // Madrid
List<ReverseGeocodeResult> municipalities = geo.listMunicipalitiesByProvince("28");Province filtering uses 2-digit INE codes. Some common ones:
| Code | Province | Code | Province |
|---|---|---|---|
01 |
Γlava | 28 |
Madrid |
08 |
Barcelona | 29 |
MΓ‘laga |
11 |
CΓ‘diz | 33 |
Asturias |
15 |
A CoruΓ±a | 41 |
Sevilla |
20 |
Gipuzkoa | 46 |
Valencia |
48 |
Bizkaia | 50 |
Zaragoza |
51 |
Ceuta | 52 |
Melilla |
List autonomous communities, provinces, and municipalities.
# List all autonomous communities
java -jar spain-reverse-geocoder.jar list communities
# List provinces in a specific community (e.g. 13 - Madrid)
java -jar spain-reverse-geocoder.jar list provinces --community 13
# List all municipalities in a province (e.g. 28 - Madrid)
java -jar spain-reverse-geocoder.jar list municipalities --province 28
# Include full GeoJSON polygons in the listing
java -jar spain-reverse-geocoder.jar list municipalities --province 28 --geometryjava -jar spain-reverse-geocoder.jar lookup \
--lat 40.4167 \
--lon -3.70325Output:
{
"municipality": { "id": "28079", "name": "Madrid" },
"province": { "id": "28", "name": "Madrid" },
"autonomousCommunity": { "id": "13", "name": "Madrid, Comunidad de" },
"geometry": {"type":"Polygon","coordinates":[...]}
}# Exact match
java -jar spain-reverse-geocoder.jar search --name "Madrid"
# Partial match
java -jar spain-reverse-geocoder.jar search --name "madri" --partial
# Filter by province
java -jar spain-reverse-geocoder.jar search --province "Madrid" --name "Getafe"Efficiently process multiple coordinates from a file.
java -jar spain-reverse-geocoder.jar batch --in points.csvLaunch the interactive map.
java -jar spain-reverse-geocoder.jar demo [--port 8080]| Option | Description |
|---|---|
--geojson <path> |
Path to custom GeoJSON file (default: embedded) |
--provinces <codes> |
Comma-separated province codes to load (e.g. 28,08) |
--mapping <path> |
Path to property mapping JSON |
--catalog <path> |
Path to administrative catalog JSON |
--low-precision |
Use 4-decimal precision data (~75MB vs ~90MB) |
src/main/java/com/futesat/spaingeo/
βββ SpainGeo.java # Public library API (builder pattern)
βββ cli/
β βββ Main.java # CLI entry point (lookup + search + demo)
βββ demo/
β βββ DemoServer.java # Virtual thread-based HTTP server
βββ core/
β βββ SpainReverseGeocoder.java # Reverse geocoding engine
β βββ QuadtreeSpatialIndex.java # Quadtree spatial index
β βββ MunicipalityFeature.java # Feature record (result + geometry)
β βββ MunicipalityIndex.java # Name search index
β βββ TextNormalizer.java # Accent/case-insensitive normalization
βββ geo/
β βββ Geometry.java # Geometry interface
β βββ PolygonGeometry.java # Polygon implementation
β βββ MultiPolygonGeometry.java # MultiPolygon implementation
β βββ Ring.java # Ring (coordinate sequence)
β βββ Coordinate.java # (x, y) coordinate record
β βββ Envelope.java # Bounding box
βββ io/
β βββ GeoJsonLoader.java # GeoJSON parser with province filtering
β βββ MiniJsonParser.java # Zero-dependency JSON parser
β βββ PropertyMapping.java # GeoJSON property name mapping
β βββ PropertyMappingLoader.java # Mapping file loader
β βββ SpainCatalog.java # Province/community catalog
βββ model/
βββ ReverseGeocodeResult.java # Result record
βββ AdminDivision.java # (id, name) record
βββ JsonEscaper.java # JSON string escaper
- Loading: GeoJSON FeatureCollection is parsed, optionally filtered by province code
- Indexing: Municipality polygons are inserted into a quadtree spatial index; names are indexed for search
- Reverse Geocoding: The quadtree narrows candidates by bounding box, then exact point-in-polygon tests find the match
- Name Search: Text is normalized (NFD decomposition removes accents, lowercased) for accent/case-insensitive matching
The embedded GeoJSON contains official municipal boundaries from:
Centro de Descargas CNIG β LΓmites municipales, provinciales y autonΓ³micos (IGN/CNIG)
To use your own data, convert to GeoJSON with ogr2ogr:
ogr2ogr -f GeoJSON municipalities.geojson MUNICIPIOS.shpThe loader auto-detects common property names:
| Field | Detected Names |
|---|---|
| Municipality ID | municipalityId, municipality_id, id, CODIGO, CMUNI, CUMUN, INE_MUNI, CODMUN |
| Municipality Name | municipalityName, municipality_name, name, LITERAL, MUNICIPIO, NOMBRE, NMUNI |
| Province ID | provinceId, province_id, CPRO, CPROV, INE_PROV, CODPROV |
| Province Name | provinceName, province_name, PROVINCIA, NPRO, NPROV |
Override with a custom mapping JSON file via --mapping.
./scripts/test.shThe test suite covers:
- Reverse geocoding (positive + negative)
- Polygon with holes
- Province filtering
- Text normalization (accents, Γ±, ΓΌ, Γ§)
- Name search (exact, partial, case-insensitive, accent-insensitive)
- Province + name search
- Geometry JSON serialization
- Catalog lookups
- JSON escaping
- Builder API
GitHub Actions automatically builds and tests on every push to main and on pull requests. The JAR is uploaded as a build artifact.
- Coordinates use WGS84 (longitude/latitude in decimal degrees)
- Points on polygon borders are treated as inside
- The algorithm handles both
PolygonandMultiPolygongeometries - Province/community data is inferred from the municipality code (first 2 digits = province INE code) when not present in properties
This project is licensed under the MIT License - see the LICENSE.md file for details.