A local-first travel photo mapping app. Runs entirely in your browser, no account needed.
-
Download the repository (git clone it).
-
Launch the server (requires Python 3):
python3 serve.py
or
./start.command
On first run,
serve.pydownloads vendor dependencies (MapLibre, fonts, etc.) intovendor/. Your browser will open automatically at http://localhost:8765 -
Add photos β drag & drop JPEG/HEIC files (with GPS data) onto the upload zone, or click it to browse your drive.
| Feature | Detail |
|---|---|
| π Auto-pin | GPS EXIF read β no manual coordinates needed |
| π Destination search | Search any place and drop a pin |
| π± Right-click pin | Right-click the map to pin a location |
| π³ Countries visited | Flag emojis for every country you've visited |
| π Albums | Named albums with optional date ranges |
| π Timeline | Chronological photo browser |
| πΌ Lightbox | Full-size viewer with navigation and camera info |
| π Notes | Add notes to any pin |
| π° Map styles | Light, Dark, Terrain, 3D Terrain, Satellite, Globe |
| πΊ Vector tiles | Smooth zoom, no flickering (OpenFreeMap) |
| π Clustering | Pins cluster by zoom, expand on click |
| πΎ Auto-save | Background backup to disk via serve.py |
| π¦ Export / Import | Full dataset as compressed .json.gz |
| π¬ Video export | Trip animation as WebM video (VP9) |
| π‘ Offline mode | Browse photos and cached tiles without internet |
- IndexedDB β all photos, albums, and metadata persist in the browser across sessions.
- serve.py auto-save β when running with the local server, data is also saved to
matrix-data.jsonand photos tomatrix-photos/on disk. This provides a durable backup that survives browser data clearing. - Export/Import β use the settings menu to export your data as a gzip-compressed
.json.gzfile or import a backup. Importing supports both compressed (.json.gz) and plain (.json) files. Empty location pins are excluded from exports automatically. To migrate between machines, copy both the.json.gzexport and thematrix-photos/directory to the new machine, then import.
Map tiles are cached in a multi-layer architecture designed for fast rendering and offline access:
| Layer | What it is | Speed | Persistence | Browser support |
|---|---|---|---|---|
| L1 β Cache API | Browser-side HTTP response cache, managed by the service worker | Instant (~0ms) | Cleared with browser data | Chrome, Firefox (not Safari) |
| L2 β Disk cache | Local filesystem at matrix-tiles/, served by serve.py |
Fast (~5-10ms) | Persists until manually deleted or evicted | All browsers |
| L3 β Origin | Remote tile servers (OpenFreeMap, ArcGIS) | Network-dependent (~50-200ms) | N/A | All browsers |
Chrome / Firefox (with service worker):
MapLibre requests tile
β SW intercepts
β L1 check (Cache API) β instant hit if previously fetched
β L1 miss: race L2 (disk proxy) and L3 (origin) via Promise.any
β Whichever responds first wins
β Result stored in L1 for future requests
β Origin fetches saved to L2 in background
Safari (no service worker):
Safari's service worker implementation has persistent issues (premature context termination, stale caches, failed tile loads). The SW is intentionally disabled in Safari. Tiles flow directly:
MapLibre requests tile
β Browser fetches from origin (L3)
β Proactive caching (data.js) prefetches tiles via serve.py
β Disk cache (L2) available for offline use via serve.py proxy
| Storage | What it stores | Used by |
|---|---|---|
| IndexedDB | Photos, albums, metadata, thumbnails, geo caches | App data layer (dbPut() / dbGetAll()) |
| Disk (matrix-data.json) | Auto-save backup of all app data | serve.py auto-save endpoint |
| Disk (matrix-photos/) | Full-size images and thumbnails | serve.py photo storage |
IndexedDB and photo storage are unaffected by the service worker β app data persists identically in all browsers.
- Disk cache limit: 500 MB with LRU eviction (oldest tiles removed down to 80% when limit exceeded)
- Eviction runs: at startup and after each new tile is cached
- Eviction logging: written to
matrix-requests.log - SW Cache API limit: 10,000 entries with zoom-aware LRU (low-zoom tiles zβ€8 protected from eviction)
Tile URLs include a version segment (e.g., 20260415_001001_pt) that changes when OpenFreeMap rebuilds their tile set. Cached tiles are never stale β when tiles update, the style JSON points to new URLs, the cache naturally misses, and fresh tiles are fetched. Old versioned tiles are eventually evicted by LRU.
After app load (10s delay), tiles for the world overview (z0β3) and pinned photo locations (z4β14) are prefetched in small batches. Already-cached tiles are skipped. This runs in the background without blocking interactive map use.
The Play button animates the map between your pinned locations in chronological order. Export Video records this animation using the browser's MediaRecorder API and saves it as a .webm file (VP9 codec, 40 Mbps). WebM plays natively in Chrome, Firefox, and VLC. For Apple ecosystem apps (QuickTime, iMovie, Photos), convert with ffmpeg -i trip.webm trip.mp4.
The app works offline after your first visit:
- Vendor libraries are bundled locally in
vendor/(auto-downloaded on firstserve.pyrun) - Map tiles are served from the L1/L2 cache (see above) β previously viewed areas render without internet
- Photos, albums, and timeline work fully offline (stored in IndexedDB)
- Destination search and geocoding require internet β they show a friendly message when offline
- An orange banner appears at the top when you're offline
- When you reconnect, everything resumes automatically β no action needed
| Key | Action |
|---|---|
L |
Switch to Light Map |
B |
Switch to Bright Map |
D |
Switch to Dark Map |
T |
Switch to Terrain |
3 |
Switch to 3D Terrain |
S |
Switch to Satellite |
G |
Switch to Globe |
F |
Fit all pins into view |
Shortcuts are disabled when typing in any input field.
- JPEG photos from iPhones almost always have GPS data embedded β they'll auto-pin perfectly.
- Photos without GPS still appear in the sidebar list and can be manually pinned via the metadata editor or destination search.
- Right-click the map to pin any location and add photos to it.
- Countries visited flags appear automatically as you click through your pins. Country codes are persisted so they load instantly on refresh.
- The app works in both Chrome and Safari on macOS.
- Nominatim (OpenStreetMap) is used for geocoding. Requests are rate-limited to 1 per second to comply with their usage policy.
The app includes a Playwright integration test suite. Tests run against a temporary data directory so your real data is never touched.
Prerequisites: Node.js (for Playwright)
python3 serve.py --run-testsNote: You may need to run npx playwright install first before executing the test suite
This will:
- Create an isolated temp directory for test data
- Generate test fixture images (with EXIF GPS data)
- Install Playwright and Chromium (first run only)
- Start the server and run all tests
- Clean up and exit with the test result code
The app uses three separate services that work together to render interactive maps:
- OpenStreetMap (OSM) β the data source. A community-maintained database of geographic data (roads, buildings, boundaries, POIs). OSM provides the raw data but doesn't serve map tiles for app usage.
- OpenFreeMap β the tile server. Takes OSM's raw data, renders it into vector map tiles (
.pbffiles), and serves them alongside style definitions (JSON files that describe how to color roads, label cities, etc.). Free, no API key required. The app uses itslibertystyle (light) anddarkstyle. - MapLibre GL JS β the client-side rendering engine. A JavaScript library that takes tiles and style JSON from OpenFreeMap and renders an interactive, zoomable map on a
<canvas>element in the browser. Handles panning, zooming, markers, clusters, and all map interaction.
The app offers six map styles:
| Style | Description |
|---|---|
| Light Map | Clean vector map with muted colors |
| Dark Map | Dark-themed vector map with normalized labels |
| Terrain | Light map with natural-earth shaded relief raster overlay |
| 3D Terrain | True 3D elevation via AWS Terrain Tiles with hillshading. Pitch/bearing/exaggeration controls appear at bottom-left. Right-click shows elevation in meters. |
| Satellite | ArcGIS World Imagery raster tiles (Esri) |
| Globe | Spherical globe projection β pan to see the whole Earth |
The satellite and 3D Terrain views use separate raster tile sources unrelated to the OpenFreeMap/OSM ecosystem. 3D Terrain elevation data comes from AWS Terrain Tiles (free, no API key, terrarium encoding), capped at zoom 15 for maximum detail.
Nominatim (run by OpenStreetMap) is used for geocoding β converting place names to coordinates. Requests are rate-limited to 1 per second per their usage policy.
Everything stays 100% local. No data is sent anywhere except OpenStreetMap/Nominatim for place lookups. No login required.
The app includes two automated demos you can trigger with keyboard shortcuts:
| Shortcut | Description |
|---|---|
| Ctrl+Shift+D | App walkthrough β automated demo with fake cursor that flies around the map, pins locations, switches tabs, and opens photos in lightbox. Demo pins are automatically cleaned up when the demo ends. |
| Ctrl+Shift+G | Globe rotation β switches to globe view and spins it 3 times along the equator. Uses GPU-rendered dots instead of DOM markers for smooth animation without jitter. Press again to stop early. |
Built with Claude Code using Claude Opus 4.6




