Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 82 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@

*A local-first travel photo mapping app. Runs entirely in your browser, no account needed.*

![Globe rotation](assets/globe.gif)

<details>
<summary>Dark Map</summary>

![Dark Map](assets/dark.gif)
</details>

<details>
<summary>Pin a Location</summary>

![Pin a location](assets/pin.gif)
</details>

## Quick Start

1. **Download** the repository (git clone it).
Expand All @@ -21,18 +35,11 @@
or click it to browse your drive.

---
<img width="3438" height="1692" alt="image" src="https://github.com/user-attachments/assets/4d802eac-8328-4dcf-85ab-7dfa9999a584" />

---
<img width="810" height="686" alt="animated" src="https://github.com/user-attachments/assets/38eccc4b-95bb-4b9f-a668-b125b30e2d5e" />

---
<img width="988" height="604" alt="animated_panning" src="https://github.com/user-attachments/assets/a377f963-a288-4bce-bc78-f2f24ca52f20" />

---
[![Watch video](https://github.com/user-attachments/assets/d667bd48-5f01-463a-abe7-107561fb01b1)](https://www.youtube.com/watch?v=w9MhBRoiUho)

---

## Features

| Feature | Detail |
Expand All @@ -45,7 +52,7 @@
| 🗓 Timeline | Browse pinned photos chronologically |
| 🖼 Lightbox | Full-size photo viewer with smooth navigation animations |
| 📝 Notes | Add notes to any pin location |
| 🛰 Map styles | Light, Dark, Terrain (natural earth shading), and Satellite (Esri) |
| 🛰 Map styles | Light, Dark, Terrain, 3D Terrain, Satellite, and Globe |
| 🗺 Vector tiles | Smooth zoom with no tile flickering (OpenFreeMap Liberty) |
| 🔄 Clustering | Nearby pins cluster automatically, expand on zoom |
| 💾 Auto-save | Automatic backup to disk when running via `serve.py` |
Expand All @@ -61,21 +68,65 @@

## Tile Caching

Map tiles are cached in three layers for fast, offline-capable rendering:
Map tiles are cached in a multi-layer architecture designed for fast rendering and offline access:

### Storage Layers

| Layer | Storage | Speed | Scope | Persistence |
| Layer | What it is | Speed | Persistence | Browser support |
|---|---|---|---|---|
| **L1 — SW Cache API** | Browser (via service worker) | Instant | Per-browser | Cleared with browser data |
| **L2 — Disk cache** | `matrix-tiles/` on disk (via `serve.py`) | Fast local read | Shared across all browsers | Persists until manually deleted |
| **L3 — Origin fetch** | Remote tile server (OpenFreeMap / Esri) | Slowest | Requires internet | N/A |
| **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 |

### How it works

**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
```

### Data Storage (separate from tile caching)

On an L1 miss, the service worker checks L2 (disk proxy) first with a 50ms timeout. If the tile is on disk, it's served immediately with no origin request. If the disk check misses or times out, the service worker falls back to L3 (origin) with an 8-second timeout and one automatic retry on failure. Concurrency is limited by semaphores (4 concurrent disk, 6 concurrent origin) to prevent overwhelming either backend during rapid zoom transitions. After a successful origin fetch, the tile is saved to disk in the background for offline use.
| 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 |

**URL-based versioning:** Tile URLs include a version segment (e.g., `20260415_001001_pt`) that changes when OpenFreeMap rebuilds their tile set. This means cached tiles are never stale — when tiles are updated, the style JSON points to new URLs, the cache naturally misses, and fresh tiles are fetched and cached. Old versioned tiles are eventually removed by LRU eviction.
IndexedDB and photo storage are unaffected by the service worker — app data persists identically in all browsers.

The disk cache is capped at 500 MB with LRU eviction — when the limit is exceeded, the oldest tiles are removed down to 80% capacity. Eviction runs at startup and after each new tile is cached. Eviction events are logged to `matrix-requests.log`.
### Tile cache configuration

**Proactive caching:** After app load, tiles for the world overview (z0–3) and pinned photo locations (z4–14) are prefetched in small batches. Already-cached tiles are skipped to avoid redundant network requests.
- **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)

### URL-based versioning

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.

### Proactive caching

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.

## Video Export

Expand Down Expand Up @@ -128,13 +179,24 @@ The app uses three separate services that work together to render interactive ma
- **OpenFreeMap** — the tile server. Takes OSM's raw data, renders it into vector map tiles (`.pbf` files), 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 its `liberty` style (light) and `dark` style.
- **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 satellite view uses **ArcGIS World Imagery** (Esri) as a separate raster tile source, unrelated to the OSM ecosystem.
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.

## Privacy

Everything stays **100% local**. No data is sent anywhere except OpenStreetMap/Nominatim for place lookups. No login required.
Everything stays **100% local**. No data is sent anywhere except OpenStreetMap/Nominatim for place lookups. No login required.

---

Expand Down
Binary file added assets/dark.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/globe.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/pin.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions css/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ input,textarea,select{font-family:var(--font)}
/* MAP TOOLBAR */
#map-toolbar{position:absolute;top:12px;left:50%;transform:translateX(-50%);z-index:5;display:flex;gap:5px;align-items:center;background:var(--surface);border:1px solid var(--border);border-radius:28px;padding:5px 8px;box-shadow:var(--shadow)}
.tb-btn{background:none;border:none;color:var(--muted);font-size:.73rem;font-weight:500;padding:5px 10px;border-radius:20px;transition:all .15s;white-space:nowrap}
.tb-btn:disabled{opacity:0.35;cursor:not-allowed;pointer-events:none}
.tb-btn:hover{background:var(--surface2);color:var(--text)}
.tb-btn.active{background:var(--accent);color:#fff}
.tb-sep{width:1px;height:16px;background:var(--border);flex-shrink:0}
Expand Down
30 changes: 30 additions & 0 deletions dependencies.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"vendor": [
{
"name": "maplibre-gl",
"version": "5.24.0",
"files": {
"maplibre-gl.js": "https://unpkg.com/maplibre-gl@{{version}}/dist/maplibre-gl.js",
"maplibre-gl.js.map": "https://unpkg.com/maplibre-gl@{{version}}/dist/maplibre-gl.js.map",
"maplibre-gl.css": "https://unpkg.com/maplibre-gl@{{version}}/dist/maplibre-gl.css"
},
"registry": "npm"
},
{
"name": "supercluster",
"version": "8.0.1",
"files": {
"supercluster.min.js": "https://unpkg.com/supercluster@{{version}}/dist/supercluster.min.js"
},
"registry": "npm"
},
{
"name": "exif-js",
"version": "2.3.0",
"files": {
"exif.js": "https://cdnjs.cloudflare.com/ajax/libs/exif-js/{{version}}/exif.js"
},
"registry": "npm"
}
]
}
4 changes: 4 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,9 @@
<div class="style-menu-item" data-style="light" onclick="setMapStyle('light')">Light Map</div>
<div class="style-menu-item active" data-style="dark" onclick="setMapStyle('dark')">Dark Map</div>
<div class="style-menu-item" data-style="enriched" onclick="setMapStyle('enriched')">Terrain</div>
<div class="style-menu-item" data-style="terrain3d" onclick="setMapStyle('terrain3d')">3D Terrain</div>
<div class="style-menu-item" data-style="satellite" onclick="setMapStyle('satellite')">Satellite</div>
<div class="style-menu-item" data-style="globe" onclick="setMapStyle('globe')">Globe</div>
</div>
</div>
<div class="tb-sep"></div>
Expand Down Expand Up @@ -180,6 +182,7 @@
</div>
<img id="lb-img" src="" alt="" />
<div id="lb-caption"></div>
<div id="lb-camera"></div>
</div>
<div id="toast"></div>
<div id="offline-banner">Offline — browsing cached data</div>
Expand All @@ -194,5 +197,6 @@
<script src="js/search.js"></script>
<script src="js/media.js"></script>
<script src="js/data.js"></script>
<script src="js/demo.js"></script>
</body>
</html>
14 changes: 12 additions & 2 deletions js/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,11 @@ async function checkAutoRestore() {
// INIT
// ═══════════════════════════════════════
async function init() {
// Force stale service workers to update immediately
if (navigator.serviceWorker?.controller) {
const reg = await navigator.serviceWorker.getRegistration();
if (reg) { reg.update().catch(() => {}); }
}
await initMap();
await openDB();
const [savedPhotos, savedAlbums] = await Promise.all([dbGetAll('photos'), dbGetAll('albums')]);
Expand Down Expand Up @@ -593,12 +598,17 @@ function updateOfflineState(offline) {
window.addEventListener('online', () => updateOfflineState(false));
window.addEventListener('offline', () => updateOfflineState(true));

// Register service worker and send server port for tile proxy
if ('serviceWorker' in navigator) {
// Register service worker (skip Safari — its SW implementation causes persistent
// tile loading failures, "Context is stopped" errors, and stale cache issues)
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
if ('serviceWorker' in navigator && !isSafari) {
navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' }).catch(err => console.warn('SW registration failed:', err));
navigator.serviceWorker.ready.then(reg => {
if (reg.active) reg.active.postMessage({ type: 'set-port', port: location.port || '8765' });
});
} else if (isSafari && navigator.serviceWorker) {
// Unregister any existing SW in Safari to clean up stale state
navigator.serviceWorker.getRegistrations().then(regs => regs.forEach(r => r.unregister()));
}

// Show offline banner on load if needed
Expand Down
Loading
Loading