Geo platform — self-hosted maps, geocoding & routing (zero Google keys)
Every map-touching feature on the platform — fleet-map pins, set-location by address, pin-move reverse geocoding, Location Scout's venue research, route drive times, and the map tiles themselves — runs on the platform's own OpenStreetMap-based services. There are no Google Maps, Places, or Geocoding API calls anywhere, and no keys to provision or leak.
Geocoding finds map features; it does not validate postal delivery or prove that a cabinet is installed there. The machine address workflow adds structured completeness checks, explicit candidate review and a separate operator installation attestation. Existing geocoded pins remain unverified.
What runs where
| Service | Software | Data | Endpoint |
|---|---|---|---|
| Tiles + style | TileServer-GL | Protomaps US basemap (daily planet build, ODbL) | https://tiles.kiosk-x.ai |
| Geocoder | Photon (komoot) | GraphHopper US index (weekly) | in-cluster, proxied by this API |
| Routing | OSRM (car profile) | Geofabrik Texas extract | in-cluster, surfaced in route plans |
Manifests, decision matrix, and the data-refresh runbook live in
intelli-verse-kube-infra/kiosk-x-geo/README.md. Attribution required on any
rendered map: © OpenStreetMap contributors © Protomaps.
Tiles (map rendering)
- Web (MapLibre GL JS) — point the map at the style URL, done:
new maplibregl.Map({container: 'map',
style: 'https://tiles.kiosk-x.ai/styles/kioskx-dark/style.json'})
- Flutter (flutter_map raster) — server-rendered tiles in the same style:
https://tiles.kiosk-x.ai/styles/kioskx-dark/256/{z}/{x}/{y}{r}.png
kioskx-dark is the Protomaps "black" flavor retinted to the Kiosk-X design
tokens, so both surfaces match the apps' deep-space palette out of the box.
Raw vector tiles (/data/us/{z}/{x}/{y}.pbf), glyphs, and sprites are served
from the same host. No token, no key.
Forward geocoding
GET /api/v1/geo/geocode?address=... — scope machines:read
{"lat": 30.245, "lng": -97.7508,
"formattedAddress": "1720 South Congress Avenue, Austin, Texas 78704",
"placeId": "osm:W158834536"}
The same resolver runs when PATCH /api/v1/machines/{no} receives an
address (see the Machines guide). placeId is an
OSM-derived identifier (osm:<type><id>).
formattedAddress leads with the venue name when the geocoder returns one,
because that is how an operator recognises their own placement in a fleet list,
and then orders the address components the way the country writes them —
Sydney Opera House, 2 Macquarie Street, Sydney New South Wales 2000,
Brandenburg Gate, Pariser Platz 1, 10117 Berlin,
Meguro Museum of Art, 4-36, Tokyo, 東京都 153-0063. The ordering table in
app/address_recipes.py is generated from OpenCage's address-formatting
templates by scripts/derive_address_templates.py; a country it does not cover
falls back to comma-separated components. US labels are unchanged. Nothing
relabels addresses already stored — a machine picks up the newer label the next
time its address is resolved or retried.
Reverse geocoding
GET /api/v1/geo/reverse?lat=..&lng=.. — scope machines:read
lat/lng → the nearest address, same shape as forward geocoding. Built for pin-move flows: drag a pin, label the machine with a human-readable address.
POI search
GET /api/v1/geo/search?type=school&near=32.7767,-96.797&radius=4.8 —
scope machines:read
Answers "how many schools within 3 miles of this corner?" from the
self-hosted OSM index. type accepts the Location Scout venue taxonomy
(school, gym, cafe, office, ... — the error message lists them all)
or a raw OSM tag (amenity:library). radius is km (max 80), limit ≤ 50.
{"type": "school", "center": {"lat": 32.7767, "lng": -96.797},
"radiusKm": 4.8, "count": 3, "dataSource": "osm-photon",
"venues": [{"name": "Travis Heights Elementary", "lat": 30.246,
"lng": -97.75, "distanceKm": 0.3,
"address": "2010 Alameda Drive, Austin, Texas",
"osmTag": "amenity=school"}]}
Caveat: Photon indexes named OSM features. That covers schools, gyms, cafes, and everything the venue taxonomy targets; anonymous street furniture (unnamed vending machines, benches) is thin — Location Scout double-checks empty answers against public Overpass mirrors for exactly this reason.
Routing (drive times)
The Operator OS route optimizer (route_runner, territory_balance, ...)
orders stops by real OSRM drive times when the deployment sets
KIOSKX_OSRM_URL — leg minutes/km in plan steps come from the road network,
not straight lines. Coverage today is the Texas extract (the operator demo
region); any pin outside it falls back to haversine estimates wholesale, so
plans never mix real and estimated legs. Turn-by-turn navigation stays on
the operator's phone via native deep links (Google/Apple Maps URLs — no keys
needed for those).
Error semantics
| Situation | Response |
|---|---|
Deployment has no KIOSKX_GEOCODER_URL |
503 with instructions |
| Geocoder configured but unreachable | 502 with the transport error |
| Address / area yields no match | 422 naming the input |
Bad near/radius/type input |
422 (or the app-wide 400 envelope for query-type violations) |
Configuration
| Env var | Production value | Empty means |
|---|---|---|
KIOSKX_GEOCODER_URL |
http://kiosk-x-photon.aicart.svc.cluster.local:2322 |
geocode paths return 503, scout falls back to Overpass/model |
KIOSKX_OSRM_URL |
http://kiosk-x-osrm.aicart.svc.cluster.local:5000 |
route plans use haversine estimates |
KIOSKX_GEOCODE_TIMEOUT |
8.0 s |
— |
KIOSKX_OSRM_TIMEOUT |
4.0 s |
— |
No secrets. That is the point.