Skip to content

Commit 6b353c3

Browse files
authored
Merge pull request #13 from hothsys/batch_updates_20260511
Batch Updates
2 parents 8a45013 + a13c4dd commit 6b353c3

17 files changed

Lines changed: 933 additions & 287 deletions

README.md

Lines changed: 82 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,20 @@
22

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

5+
![Globe rotation](assets/globe.gif)
6+
7+
<details>
8+
<summary>Dark Map</summary>
9+
10+
![Dark Map](assets/dark.gif)
11+
</details>
12+
13+
<details>
14+
<summary>Pin a Location</summary>
15+
16+
![Pin a location](assets/pin.gif)
17+
</details>
18+
519
## Quick Start
620

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

2337
---
24-
<img width="3438" height="1692" alt="image" src="https://github.com/user-attachments/assets/4d802eac-8328-4dcf-85ab-7dfa9999a584" />
25-
26-
---
27-
<img width="810" height="686" alt="animated" src="https://github.com/user-attachments/assets/38eccc4b-95bb-4b9f-a668-b125b30e2d5e" />
2838

29-
---
30-
<img width="988" height="604" alt="animated_panning" src="https://github.com/user-attachments/assets/a377f963-a288-4bce-bc78-f2f24ca52f20" />
31-
32-
---
3339
[![Watch video](https://github.com/user-attachments/assets/d667bd48-5f01-463a-abe7-107561fb01b1)](https://www.youtube.com/watch?v=w9MhBRoiUho)
3440

3541
---
42+
3643
## Features
3744

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

6269
## Tile Caching
6370

64-
Map tiles are cached in three layers for fast, offline-capable rendering:
71+
Map tiles are cached in a multi-layer architecture designed for fast rendering and offline access:
72+
73+
### Storage Layers
6574

66-
| Layer | Storage | Speed | Scope | Persistence |
75+
| Layer | What it is | Speed | Persistence | Browser support |
6776
|---|---|---|---|---|
68-
| **L1 — SW Cache API** | Browser (via service worker) | Instant | Per-browser | Cleared with browser data |
69-
| **L2 — Disk cache** | `matrix-tiles/` on disk (via `serve.py`) | Fast local read | Shared across all browsers | Persists until manually deleted |
70-
| **L3 — Origin fetch** | Remote tile server (OpenFreeMap / Esri) | Slowest | Requires internet | N/A |
77+
| **L1 — Cache API** | Browser-side HTTP response cache, managed by the service worker | Instant (~0ms) | Cleared with browser data | Chrome, Firefox (not Safari) |
78+
| **L2 — Disk cache** | Local filesystem at `matrix-tiles/`, served by `serve.py` | Fast (~5-10ms) | Persists until manually deleted or evicted | All browsers |
79+
| **L3 — Origin** | Remote tile servers (OpenFreeMap, ArcGIS) | Network-dependent (~50-200ms) | N/A | All browsers |
80+
81+
### How it works
82+
83+
**Chrome / Firefox (with service worker):**
84+
85+
```
86+
MapLibre requests tile
87+
→ SW intercepts
88+
→ L1 check (Cache API) — instant hit if previously fetched
89+
→ L1 miss: race L2 (disk proxy) and L3 (origin) via Promise.any
90+
→ Whichever responds first wins
91+
→ Result stored in L1 for future requests
92+
→ Origin fetches saved to L2 in background
93+
```
94+
95+
**Safari (no service worker):**
96+
97+
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:
98+
99+
```
100+
MapLibre requests tile
101+
→ Browser fetches from origin (L3)
102+
→ Proactive caching (data.js) prefetches tiles via serve.py
103+
→ Disk cache (L2) available for offline use via serve.py proxy
104+
```
105+
106+
### Data Storage (separate from tile caching)
71107

72-
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.
108+
| Storage | What it stores | Used by |
109+
|---|---|---|
110+
| **IndexedDB** | Photos, albums, metadata, thumbnails, geo caches | App data layer (`dbPut()` / `dbGetAll()`) |
111+
| **Disk (matrix-data.json)** | Auto-save backup of all app data | `serve.py` auto-save endpoint |
112+
| **Disk (matrix-photos/)** | Full-size images and thumbnails | `serve.py` photo storage |
73113

74-
**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.
114+
IndexedDB and photo storage are unaffected by the service worker — app data persists identically in all browsers.
75115

76-
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`.
116+
### Tile cache configuration
77117

78-
**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.
118+
- **Disk cache limit:** 500 MB with LRU eviction (oldest tiles removed down to 80% when limit exceeded)
119+
- **Eviction runs:** at startup and after each new tile is cached
120+
- **Eviction logging:** written to `matrix-requests.log`
121+
- **SW Cache API limit:** 10,000 entries with zoom-aware LRU (low-zoom tiles z≤8 protected from eviction)
122+
123+
### URL-based versioning
124+
125+
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.
126+
127+
### Proactive caching
128+
129+
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.
79130

80131
## Video Export
81132

@@ -128,13 +179,24 @@ The app uses three separate services that work together to render interactive ma
128179
- **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.
129180
- **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.
130181

131-
The satellite view uses **ArcGIS World Imagery** (Esri) as a separate raster tile source, unrelated to the OSM ecosystem.
182+
The app offers six map styles:
183+
184+
| Style | Description |
185+
|---|---|
186+
| **Light Map** | Clean vector map with muted colors |
187+
| **Dark Map** | Dark-themed vector map with normalized labels |
188+
| **Terrain** | Light map with natural-earth shaded relief raster overlay |
189+
| **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. |
190+
| **Satellite** | ArcGIS World Imagery raster tiles (Esri) |
191+
| **Globe** | Spherical globe projection — pan to see the whole Earth |
192+
193+
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.
132194

133195
**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.
134196

135197
## Privacy
136198

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

139201
---
140202

assets/dark.gif

8.42 MB
Loading

assets/globe.gif

8.36 MB
Loading

assets/pin.gif

9.09 MB
Loading

css/styles.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ input,textarea,select{font-family:var(--font)}
189189
/* MAP TOOLBAR */
190190
#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)}
191191
.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}
192+
.tb-btn:disabled{opacity:0.35;cursor:not-allowed;pointer-events:none}
192193
.tb-btn:hover{background:var(--surface2);color:var(--text)}
193194
.tb-btn.active{background:var(--accent);color:#fff}
194195
.tb-sep{width:1px;height:16px;background:var(--border);flex-shrink:0}

dependencies.json

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"vendor": [
3+
{
4+
"name": "maplibre-gl",
5+
"version": "5.24.0",
6+
"files": {
7+
"maplibre-gl.js": "https://unpkg.com/maplibre-gl@{{version}}/dist/maplibre-gl.js",
8+
"maplibre-gl.js.map": "https://unpkg.com/maplibre-gl@{{version}}/dist/maplibre-gl.js.map",
9+
"maplibre-gl.css": "https://unpkg.com/maplibre-gl@{{version}}/dist/maplibre-gl.css"
10+
},
11+
"registry": "npm"
12+
},
13+
{
14+
"name": "supercluster",
15+
"version": "8.0.1",
16+
"files": {
17+
"supercluster.min.js": "https://unpkg.com/supercluster@{{version}}/dist/supercluster.min.js"
18+
},
19+
"registry": "npm"
20+
},
21+
{
22+
"name": "exif-js",
23+
"version": "2.3.0",
24+
"files": {
25+
"exif.js": "https://cdnjs.cloudflare.com/ajax/libs/exif-js/{{version}}/exif.js"
26+
},
27+
"registry": "npm"
28+
}
29+
]
30+
}

index.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,9 @@
125125
<div class="style-menu-item" data-style="light" onclick="setMapStyle('light')">Light Map</div>
126126
<div class="style-menu-item active" data-style="dark" onclick="setMapStyle('dark')">Dark Map</div>
127127
<div class="style-menu-item" data-style="enriched" onclick="setMapStyle('enriched')">Terrain</div>
128+
<div class="style-menu-item" data-style="terrain3d" onclick="setMapStyle('terrain3d')">3D Terrain</div>
128129
<div class="style-menu-item" data-style="satellite" onclick="setMapStyle('satellite')">Satellite</div>
130+
<div class="style-menu-item" data-style="globe" onclick="setMapStyle('globe')">Globe</div>
129131
</div>
130132
</div>
131133
<div class="tb-sep"></div>
@@ -180,6 +182,7 @@
180182
</div>
181183
<img id="lb-img" src="" alt="" />
182184
<div id="lb-caption"></div>
185+
<div id="lb-camera"></div>
183186
</div>
184187
<div id="toast"></div>
185188
<div id="offline-banner">Offline — browsing cached data</div>
@@ -194,5 +197,6 @@
194197
<script src="js/search.js"></script>
195198
<script src="js/media.js"></script>
196199
<script src="js/data.js"></script>
200+
<script src="js/demo.js"></script>
197201
</body>
198202
</html>

js/data.js

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,11 @@ async function checkAutoRestore() {
495495
// INIT
496496
// ═══════════════════════════════════════
497497
async function init() {
498+
// Force stale service workers to update immediately
499+
if (navigator.serviceWorker?.controller) {
500+
const reg = await navigator.serviceWorker.getRegistration();
501+
if (reg) { reg.update().catch(() => {}); }
502+
}
498503
await initMap();
499504
await openDB();
500505
const [savedPhotos, savedAlbums] = await Promise.all([dbGetAll('photos'), dbGetAll('albums')]);
@@ -593,12 +598,17 @@ function updateOfflineState(offline) {
593598
window.addEventListener('online', () => updateOfflineState(false));
594599
window.addEventListener('offline', () => updateOfflineState(true));
595600

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

604614
// Show offline banner on load if needed

0 commit comments

Comments
 (0)