An interactive world map (zoom/pan) that plots a set of cities, each rendered as a star (green by default, or a per-city/per-country color set in the input) and clustered when zoomed out. Click a star for the city name; click a cluster to zoom in. It opens on the home city rather than the whole globe. When unlocked with a password (the top-right lock control), each star also shows that city's trip log — dates, activities, and comments — kept encrypted otherwise, and the map moves to the most recent visit (unless a popup is open, which it leaves alone).
The project is generic: it reads cities from an input file and displays them. The current use case is "places I've visited," but it works identically for any city list (e.g. every city with a Burger King).
Two parts:
- A small Python build step geocodes the input into a GeoJSON file.
- A static Mapbox GL JS web app renders that GeoJSON.
web/data/visited.json ──(scripts/build_geojson.py)──> web/data/places.geojson ──> map
web/data/visited.json — a list of countries, each with cities. A city may carry an
optional intermediate region level (state / province / etc.) used for display and to
disambiguate geocoding, and an optional color (also settable on the country, applying to
all its cities) overriding the default green star. Producing this file is out of scope here;
the committed visited.json holds the actual visited-places list.
[
{ "country": "Japan", "cities": [
{ "name": "Tokyo" },
{ "name": "Sapporo", "region": "Hokkaido" }
] }
]A parenthetical suffix is stripped before geocoding only ("Bruges (Brugge)" is
geocoded as "Bruges" but still displayed in full).
See docs/input-format.md for the full schema, field semantics, and geocode-cache details.
Per-city trip details (dates, activities, comments) are not part of visited.json — they
live in a separate, encrypted, password-gated source, shown on the map only after the owner
enters a password. See Enriched visit log.
python -m venv .venv
# Windows: .venv\Scripts\activate
# macOS / Linux: source .venv/bin/activate
pip install -r scripts/requirements.txt
python scripts/build_geojson.pyThis geocodes each city via Nominatim (free, no key, rate-limited to 1 request/sec) and writes:
web/data/coords_cache.json—{ "City, Region, Country": [lng, lat] }, committed and hand-editable: if Nominatim places a city wrong, fix its coordinate here and re-run. Cached queries are not re-fetched, so re-runs are instant.web/data/places.geojson— one Point feature per city, consumed by the web app.
./scripts/dev.shThen open http://localhost:8000/. The script regenerates the gitignored web/config.js
from Doppler (travel-map/dev) before serving, so a fresh clone never comes up with a
tokenless, blank map, and it refuses to start if something already holds the port.
Check changes here rather than by deploying — production is a direct upload with no staging environment, so every deploy is a live change to the public site.
The port is 8000 exactly, not a default: the dev Mapbox token is URL-restricted to
http://localhost:8000, and Mapbox restrictions take no wildcards or port ranges. On any
other port the map renders blank. By hand the equivalent is
python -m http.server 8000 --directory web, which skips the token refresh.
Fetching the GeoJSON needs
http://, notfile://, so openingindex.htmldirectly won't work.
The base map uses Mapbox, so it needs a free access token.
./scripts/dev.sh writes web/config.js from Doppler (travel-map/dev) on every run, so
normally there is nothing to do here. Without Doppler, get a token (Tokens) and paste it in
by hand:
cp web/config.example.js web/config.js # then paste your token into config.jsThe map renders a single, customized Mapbox Streets globe — roads hidden and country/region borders sharpened. Without a token the app shows a prompt instead of a map.
web/config.js is gitignored. You can override the style with any Mapbox style URL via
window.MAP_STYLE (which also skips the Streets-specific customizations).
.
├── scripts/
│ ├── requirements.txt
│ ├── build_geojson.py # writes into web/data/
│ ├── build_site.mjs # stages web/ → dist/travel/ for deploy
│ ├── dev.sh # local server on :8000 (refreshes web/config.js first)
│ ├── crypto/ # shared PBKDF2 + AES-GCM envelope (Node ESM)
│ ├── visits-encrypt.mjs # visits.source.json → visits.enc
│ └── visits-decrypt.mjs # visits.enc → visits.source.json (fresh clone)
└── web/ # the deployable bundle
├── index.html # loads Mapbox GL JS from CDN
├── app.js # star layer, star color, popups, lock control, Streets tweaks
├── crypto.js # in-browser decrypt of visits.enc (mirror of scripts/crypto)
├── style.css
├── config.example.js # template for the Mapbox token
└── data/
├── visited.json # INPUT: countries → cities (+ optional region, color)
├── coords_cache.json # geocode cache (committed, hand-editable)
├── ranks.json # label-prominence cache (committed, hand-editable)
├── places.geojson # generated; consumed by the web app
└── visits.enc # encrypted trip log (committed, shipped; in-browser unlock)
The site is published to Cloudflare Pages under a /travel subpath, by CI. Pushing to
main runs .github/workflows/publish.yml, which builds the
bundle, uploads it, and then verifies the live site is actually serving that build.
Publishing from a developer machine is deliberately not the path. A local upload ships the
working tree while Wrangler stamps the deployment with local HEAD, so uncommitted work can go
live under a commit hash that doesn't describe it — and picking the wrong secrets environment
bakes a non-production token into the bundle, which fails only in production. CI builds a clean
checkout of one commit with one pinned set of credentials, and neither is possible.
Secrets live in Doppler (project travel-map), injected via
doppler run so nothing sensitive is written to disk:
| Config | Holds | Used by |
|---|---|---|
dev |
MAPBOX_TOKEN (restricted to http://localhost:8000), TRAVEL_VISITS_PASSWORD |
local work |
prd |
MAPBOX_TOKEN (restricted to the live domain), Cloudflare credentials, TRAVEL_VISITS_PASSWORD |
source of truth |
ci |
references to prd's MAPBOX_TOKEN and Cloudflare credentials — and nothing else |
the publish workflow |
The two Mapbox tokens differ only in URL restriction. Building with the dev token ships a
localhost-only token, and the live map then renders as a blank white globe while the token
still validates — an easy failure to misdiagnose, and the reason publishing is automated.
The ci config exists so the workflow cannot read TRAVEL_VISITS_PASSWORD: the build never
needs it (visits.enc ships already encrypted), and the ciphertext is public in this repo, so
the password is the only thing protecting the trip log. GitHub Actions holds a Doppler service
token scoped to ci — read-only, one config, revocable — as the repo secret DOPPLER_TOKEN.
Because a service token resolves to exactly one config, doppler run needs no -p/-c flags,
so there is no argument to get wrong.
To stage a build locally (for inspection — not for publishing):
doppler run -p travel-map -c ci -- node scripts/build_site.mjsThis copies web/ → dist/travel/ (so the map serves under /travel/), generates
dist/travel/config.js from MAPBOX_TOKEN, content-hashes the local assets for cache-busting,
and writes dist/_redirects pointing the site root at /travel/. It refuses to build if
web/data/visits.enc is older than a locally-edited visits.source.json.
web/ stays a self-contained static bundle, so it can also be dropped onto any other static
host as-is. (.env.example lists the same variables for anyone preferring a local .env with
node --env-file=.env over Doppler.)