diff --git a/README.md b/README.md
index 2a02a24..d93a3a9 100644
--- a/README.md
+++ b/README.md
@@ -66,15 +66,15 @@
## Documentation
+- [Architecture](docs/architecture.md)
- [Data persistence & migration](docs/persistence.md)
- [Data storage internals](docs/data-storage.md)
- [Tile caching architecture](docs/tile-caching.md)
-- [Synology NAS + Tailscale setup](docs/synology-setup.md)
-- [Keyboard shortcuts](docs/keyboard-shortcuts.md)
- [Tips & features](docs/tips.md)
-- [Architecture](docs/architecture.md)
-- [Testing](docs/testing.md)
+- [Keyboard shortcuts](docs/keyboard-shortcuts.md)
+- [Synology NAS + Tailscale setup](docs/synology-setup.md)
- [Demo modes](docs/demo-mode.md)
+- [Testing](docs/testing.md)
- [GitHub workflows](docs/github-workflows.md)
---
diff --git a/css/styles.css b/css/styles.css
index 95c7c36..00cf2c8 100644
--- a/css/styles.css
+++ b/css/styles.css
@@ -7,7 +7,7 @@
--accent2:#e8774a;--green:#4ec98a;
--text:#e4e7f2;--muted:#7a829c;--muted2:#555d78;
--radius:12px;--shadow:0 8px 40px rgba(0,0,0,.55);
- --font:'Josefin Sans',sans-serif;--font-serif:'Josefin Sans',sans-serif;
+ --font:'Josefin Sans',sans-serif;
}
html{font-size:19px}
html,body{height:100%;overflow:hidden;font-family:var(--font);background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased}
@@ -26,7 +26,7 @@ input,textarea,select{font-family:var(--font)}
.sidebar-header{padding:14px 18px 12px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;flex-shrink:0}
.logo-mark{width:56px;height:56px;flex-shrink:0;display:flex;align-items:center;justify-content:center}
.logo-mark svg{width:100%;height:100%;fill:var(--accent)}
-.logo-text{font-family:var(--font-serif);font-size:1.15rem;font-weight:600;letter-spacing:-.02em}
+.logo-text{font-family:var(--font);font-size:1.15rem;font-weight:600;letter-spacing:-.02em}
.logo-text span{color:var(--accent)}
/* 3-tab sidebar */
@@ -360,13 +360,12 @@ textarea.fi{resize:vertical;min-height:60px;line-height:1.5}
#export-overlay{display:none;position:fixed;inset:0;z-index:9000;background:rgba(0,0,0,.75);backdrop-filter:blur(6px);align-items:center;justify-content:center}
#export-overlay.open{display:flex}
.export-overlay-box{background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:32px 40px;text-align:center;box-shadow:0 12px 60px rgba(0,0,0,.6);min-width:320px}
-.export-overlay-title{font-family:var(--font-serif);font-size:1.2rem;font-weight:600;color:var(--text);margin-bottom:8px}
+.export-overlay-title{font-family:var(--font);font-size:1.2rem;font-weight:600;color:var(--text);margin-bottom:8px}
.export-overlay-progress{font-size:.78rem;color:var(--muted);margin-bottom:16px}
.export-overlay-bar-wrap{height:6px;background:var(--surface3);border-radius:3px;overflow:hidden}
.export-overlay-bar{height:100%;width:0%;background:var(--accent);border-radius:3px;transition:width .3s ease}
.export-cancel-btn{margin-top:16px;background:none;border:1px solid var(--border2);color:var(--muted);padding:6px 20px;border-radius:8px;font-size:.75rem;cursor:pointer;transition:color .15s,border-color .15s}
.export-cancel-btn:hover{color:var(--text);border-color:var(--text)}
-#tb-export-video.recording{color:var(--accent2)}
/* TOAST */
#toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%) translateY(60px);background:var(--surface);border:1px solid var(--border);padding:8px 16px;border-radius:28px;font-size:.74rem;box-shadow:var(--shadow);z-index:999;transition:transform .28s ease;pointer-events:none}
diff --git a/js/data.js b/js/data.js
index d96f35c..7c0930c 100644
--- a/js/data.js
+++ b/js/data.js
@@ -598,93 +598,9 @@ async function init() {
}, 500);
// Proactive tile caching — start after a short delay so it doesn't compete with initial load
setTimeout(() => cacheMapTiles(), 10000);
- // One-time migration: convert PNG thumbnails to JPEG (remove once both machines have run this)
- setTimeout(() => _migrateThumbsToWebP(), 2000);
- // One-time migration: move full-size base64 images from IndexedDB to disk
- // After this, IndexedDB holds only metadata + thumbnails (~50KB per photo vs ~4MB)
- setTimeout(() => _migrateImagesToDisk(), 3000);
}
-// ═══════════════════════════════════════
-// ONE-TIME MIGRATION: PNG → JPEG thumbnails
-// Remove this function and its setTimeout call in init() once both machines have run it.
-// ═══════════════════════════════════════
-async function _migrateThumbsToWebP() {
- let migrated = 0;
- for (const p of photos) {
- if (!p.thumbUrl || p.thumbUrl.startsWith('data:image/jpeg')) continue;
- try {
- const img = new Image();
- await new Promise((res, rej) => { img.onload = res; img.onerror = rej; img.src = p.thumbUrl; });
- const canvas = document.createElement('canvas');
- canvas.width = img.naturalWidth;
- canvas.height = img.naturalHeight;
- canvas.getContext('2d').drawImage(img, 0, 0);
- p.thumbUrl = canvas.toDataURL('image/jpeg', 0.7);
- await dbPut('photos', p);
- // Upload the new WebP thumbnail to disk (bypasses _savedPhotoDisk guard)
- if (_autoSaveAvailable) {
- await fetch(`/api/photos/${p.id}/thumb`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ dataUrl: p.thumbUrl })
- });
- }
- migrated++;
- } catch (_) { /* skip failures */ }
- }
- if (migrated) {
- rebuildPhotoList();
- buildTimeline();
- scheduleAutoSave();
- }
-}
-
-// ═══════════════════════════════════════
-// ONE-TIME MIGRATION: Move base64 images from IndexedDB to disk
-// After this migration, IndexedDB holds metadata + thumbnails only (~50KB/photo vs ~4MB).
-// Full-size images live in matrix-photos/ and are loaded on-demand by the lightbox.
-// ═══════════════════════════════════════
-async function _migrateImagesToDisk() {
- if (!_autoSaveAvailable) return; // serve.py must be running to save files
- // Find photos still storing base64 full-size images in IndexedDB
- const needMigration = photos.filter(p => p.dataUrl && p.dataUrl.startsWith('data:'));
- if (!needMigration.length) return;
-
- showToast(`Migrating ${needMigration.length} photo${needMigration.length !== 1 ? 's' : ''} to disk…`, 'info');
- let migrated = 0;
-
- for (const p of needMigration) {
- try {
- const ext = (p.dataUrl.match(/data:image\/(\w+)/) || [])[1] === 'png' ? 'png' : 'jpg';
- const filePath = `matrix-photos/${p.id}.${ext}`;
- // Check if the image already exists on disk (from prior auto-save)
- const exists = await fetch(`/${filePath}`, { method: 'HEAD' }).then(r => r.ok).catch(() => false);
- if (!exists) {
- // Image not on disk yet — save it now from the base64 in IndexedDB
- await fetch(`/api/photos/${p.id}`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ dataUrl: p.dataUrl })
- });
- }
- // Replace base64 with the file path reference in memory + IndexedDB
- p.dataUrl = filePath;
- await dbPut('photos', p);
- migrated++;
- } catch (err) {
- // Skip failures — photo retains its base64 and can retry next load
- console.warn(`Migration failed for ${p.id}:`, err.message);
- }
- }
-
- if (migrated) {
- scheduleAutoSave();
- showToast(`Migrated ${migrated} photo${migrated !== 1 ? 's' : ''} to disk ✓`, 'success');
- }
-}
-
// ═══════════════════════════════════════
// OFFLINE SUPPORT
// ═══════════════════════════════════════
diff --git a/js/export.js b/js/export.js
index fdcb848..1a11956 100644
--- a/js/export.js
+++ b/js/export.js
@@ -101,27 +101,13 @@ async function exportVideo() {
if (_exporting || _playbackActive) return;
if (_mapStyle === 'globe') { showToast('Export Video is not available in Globe mode', 'error'); return; }
- // Build stops (same logic as startPlayback)
- const dated = photos.filter(p => p.lat !== null && p.date)
- .sort((a, b) => photoSortKey(a) < photoSortKey(b) ? -1 : 1);
+ const stops = buildPlaybackStops();
- if (dated.length < 2) {
+ if (stops.length < 2) {
showToast('Need at least 2 dated pinned photos to export', 'error');
return;
}
- const stops = [];
- const seen = new Set();
- for (const p of dated) {
- const k = locKey(p);
- if (seen.has(k)) {
- stops.find(s => s.key === k).photoIds.push(p.id);
- } else {
- seen.add(k);
- stops.push({ key: k, lat: p.lat, lng: p.lng, photoIds: [p.id] });
- }
- }
-
// Show overlay
_exporting = true;
document.getElementById('export-overlay').classList.add('open');
@@ -366,7 +352,7 @@ async function exportVideo() {
// Cleanup
exportCleanup();
- showToast(`Video exported (${(blob.size / 1024 / 1024).toFixed(1)} MB)`, 'success');
+ showToast('Video exported', 'success');
}
function exportCleanup() {
diff --git a/js/map.js b/js/map.js
index 7c1a9ab..4202983 100644
--- a/js/map.js
+++ b/js/map.js
@@ -448,22 +448,22 @@ function initTheme() {
}
applyTheme();
}
+function _syncExportBtnState() {
+ const exportBtn = document.getElementById('tb-export-video');
+ if (exportBtn) {
+ exportBtn.disabled = _mapStyle === 'globe';
+ exportBtn.title = _mapStyle === 'globe' ? 'Export Video is not available in Globe mode' : 'Export trip animation as video';
+ }
+}
+
function applyTheme() {
document.getElementById('map')?.classList.toggle('dark-map', _mapStyle === 'dark');
_tileTemplatesCache = null;
- // Set initial button label
const btn = document.getElementById('tb-style-btn');
const labels = { light: 'Light Map', bright: 'Bright Map', enriched: 'Terrain', dark: 'Dark Map', satellite: 'Satellite', satellite3d: '3D Satellite', terrain3d: '3D Terrain', globe: 'Globe' };
if (btn) btn.textContent = (labels[_mapStyle] || _mapStyle) + ' ▾';
-
- // Set initial active state in menu
document.querySelectorAll('.style-menu-item').forEach(el => el.classList.toggle('active', el.dataset.style === _mapStyle));
- // Disable Export Video in Globe mode
- const exportBtn = document.getElementById('tb-export-video');
- if (exportBtn) {
- exportBtn.disabled = _mapStyle === 'globe';
- exportBtn.title = _mapStyle === 'globe' ? 'Export Video is not available in Globe mode' : 'Export trip animation as video';
- }
+ _syncExportBtnState();
}
// Cache fetched style JSONs so switching between styles is instant after the first load
const _styleJsonCache = {};
@@ -534,6 +534,113 @@ async function _doStyleSwap(style) {
go();
}
+function _initMapOverlays() {
+ const mapEl = document.getElementById('map');
+ const zoomEl = document.createElement('div');
+ zoomEl.id = 'zoom-debug';
+ zoomEl.style.cssText = 'position:absolute;bottom:24px;left:8px;background:rgba(0,0,0,.6);color:#fff;font-size:11px;padding:2px 6px;border-radius:4px;z-index:10;pointer-events:none;font-family:monospace';
+ mapEl.appendChild(zoomEl);
+ const pitchWrap = document.createElement('div');
+ pitchWrap.id = 'pitch-wrap';
+ pitchWrap.style.cssText = 'position:absolute;bottom:24px;left:70px;display:none;align-items:center;z-index:10;background:rgba(0,0,0,.6);border-radius:4px;font-family:monospace;font-size:11px;color:#fff';
+ const pitchEl = document.createElement('span');
+ pitchEl.id = 'pitch-debug';
+ pitchEl.style.cssText = 'padding:2px 6px;pointer-events:none';
+ const resetViewEl = document.createElement('button');
+ resetViewEl.id = 'reset-view-btn';
+ resetViewEl.title = 'Reset north';
+ resetViewEl.textContent = '⊙';
+ resetViewEl.style.cssText = 'background:none;color:#fff;font-size:15px;border:none;border-left:1px solid rgba(255,255,255,.2);cursor:pointer;padding:0 6px;font-family:monospace;display:none;line-height:1';
+ resetViewEl.addEventListener('click', () => { map.easeTo({ bearing: 0, duration: 500 }); });
+ pitchWrap.appendChild(pitchEl);
+ pitchWrap.appendChild(resetViewEl);
+ mapEl.appendChild(pitchWrap);
+ const exaggerationEl = document.createElement('div');
+ exaggerationEl.id = 'exaggeration-ctrl';
+ exaggerationEl.style.cssText = 'position:absolute;bottom:24px;left:310px;background:rgba(0,0,0,.6);color:#fff;font-size:11px;padding:2px 8px;border-radius:4px;z-index:10;font-family:monospace;display:none;align-items:center;gap:6px';
+ exaggerationEl.innerHTML = '⛰ 1.2×';
+ mapEl.appendChild(exaggerationEl);
+ document.getElementById('exaggeration-slider').addEventListener('input', (e) => {
+ const val = parseFloat(e.target.value);
+ document.getElementById('exaggeration-val').textContent = val.toFixed(1) + '×';
+ if (map.getTerrain()) map.setTerrain({ source: 'terrain-dem', exaggeration: val });
+ });
+ const coordsEl = document.createElement('div');
+ coordsEl.id = 'coords-debug';
+ coordsEl.style.cssText = 'position:absolute;bottom:24px;right:50px;background:rgba(0,0,0,.6);color:#fff;font-size:11px;padding:2px 6px;border-radius:4px;z-index:10;pointer-events:none;font-family:monospace;display:none';
+ mapEl.appendChild(coordsEl);
+ map.on('mousemove', (e) => {
+ coordsEl.textContent = `${e.lngLat.lat.toFixed(4)}°, ${e.lngLat.lng.toFixed(4)}°`;
+ coordsEl.style.display = '';
+ });
+ map.getCanvas().addEventListener('mouseout', () => { coordsEl.style.display = 'none'; });
+ const updateZoom = () => {
+ zoomEl.textContent = 'z' + map.getZoom().toFixed(2);
+ const is3D = _mapStyle === 'terrain3d' || _mapStyle === 'satellite3d';
+ if (is3D) {
+ pitchWrap.style.display = 'flex';
+ pitchEl.textContent = `p${map.getPitch().toFixed(0)}° b${map.getBearing().toFixed(0)}°`;
+ const tilted = Math.abs(map.getBearing()) > 1;
+ resetViewEl.style.display = tilted ? '' : 'none';
+ } else {
+ pitchWrap.style.display = 'none';
+ }
+ const exCtrl = document.getElementById('exaggeration-ctrl');
+ if (exCtrl) exCtrl.style.display = is3D ? 'flex' : 'none';
+ };
+ map.on('zoom', updateZoom);
+ map.on('pitch', updateZoom);
+ map.on('rotate', updateZoom);
+ map.on('moveend', updateZoom);
+ map.on('moveend', _onMapMoveForSearch);
+ updateZoom();
+}
+
+function _initMapControls() {
+ normalizeDarkLabels();
+ raiseLabelsAboveRoads();
+ const ctrlContainer = document.querySelector('.maplibregl-ctrl-bottom-right');
+ const navGroup = ctrlContainer?.querySelector('.maplibregl-ctrl-group');
+ if (ctrlContainer && navGroup) {
+ const wrap = document.createElement('div');
+ wrap.className = 'maplibregl-ctrl maplibregl-ctrl-group';
+ wrap.id = 'labels-toggle-wrap';
+ const btn = document.createElement('button');
+ btn.id = 'labels-toggle-btn';
+ btn.type = 'button';
+ btn.className = 'maplibregl-ctrl-labels';
+ btn.title = labelsVisible ? 'Hide labels' : 'Show labels';
+ btn.setAttribute('aria-label', 'Toggle labels');
+ btn.style.opacity = labelsVisible ? '1' : '.4';
+ btn.innerHTML = 'Aa';
+ btn.addEventListener('click', toggleLabels);
+ wrap.appendChild(btn);
+ navGroup.after(wrap);
+
+ const bldgWrap = document.createElement('div');
+ bldgWrap.className = 'maplibregl-ctrl maplibregl-ctrl-group';
+ bldgWrap.id = 'buildings-toggle-wrap';
+ const bldgBtn = document.createElement('button');
+ bldgBtn.id = 'tb-3d-buildings';
+ bldgBtn.type = 'button';
+ bldgBtn.title = '3D Buildings (zoom 15+)';
+ bldgBtn.setAttribute('aria-label', 'Toggle 3D buildings');
+ bldgBtn.style.opacity = buildings3DVisible ? '1' : '.4';
+ bldgBtn.innerHTML = '3D';
+ bldgBtn.addEventListener('click', toggle3DBuildings);
+ bldgWrap.appendChild(bldgBtn);
+ if (['satellite', 'satellite3d', 'terrain3d', 'globe'].includes(_mapStyle)) bldgWrap.style.display = 'none';
+ navGroup.before(bldgWrap);
+ }
+ applyLabelScale();
+ applyLabelVisibility();
+ applyExtraLayers();
+ _applyTerrainAndProjection();
+ const tileSpinner = document.getElementById('tile-spinner');
+ map.on('dataloading', () => { tileSpinner?.classList.add('active'); });
+ map.on('idle', () => { tileSpinner?.classList.remove('active'); });
+}
+
async function initMap() {
initTheme();
// Fetch and patch style JSON before creating the map to prevent halo flash
@@ -589,113 +696,8 @@ async function initMap() {
map.on('load', () => {
addPinLayers();
map.on('moveend', refreshClusters);
- // Debug: zoom level indicator
- const zoomEl = document.createElement('div');
- zoomEl.id = 'zoom-debug';
- zoomEl.style.cssText = 'position:absolute;bottom:24px;left:8px;background:rgba(0,0,0,.6);color:#fff;font-size:11px;padding:2px 6px;border-radius:4px;z-index:10;pointer-events:none;font-family:monospace';
- document.getElementById('map').appendChild(zoomEl);
- const pitchWrap = document.createElement('div');
- pitchWrap.id = 'pitch-wrap';
- pitchWrap.style.cssText = 'position:absolute;bottom:24px;left:70px;display:none;align-items:center;z-index:10;background:rgba(0,0,0,.6);border-radius:4px;font-family:monospace;font-size:11px;color:#fff';
- const pitchEl = document.createElement('span');
- pitchEl.id = 'pitch-debug';
- pitchEl.style.cssText = 'padding:2px 6px;pointer-events:none';
- const resetViewEl = document.createElement('button');
- resetViewEl.id = 'reset-view-btn';
- resetViewEl.title = 'Reset north';
- resetViewEl.textContent = '⊙';
- resetViewEl.style.cssText = 'background:none;color:#fff;font-size:15px;border:none;border-left:1px solid rgba(255,255,255,.2);cursor:pointer;padding:0 6px;font-family:monospace;display:none;line-height:1';
- resetViewEl.addEventListener('click', () => { map.easeTo({ bearing: 0, duration: 500 }); });
- pitchWrap.appendChild(pitchEl);
- pitchWrap.appendChild(resetViewEl);
- document.getElementById('map').appendChild(pitchWrap);
- const exaggerationEl = document.createElement('div');
- exaggerationEl.id = 'exaggeration-ctrl';
- exaggerationEl.style.cssText = 'position:absolute;bottom:24px;left:310px;background:rgba(0,0,0,.6);color:#fff;font-size:11px;padding:2px 8px;border-radius:4px;z-index:10;font-family:monospace;display:none;align-items:center;gap:6px';
- exaggerationEl.innerHTML = '⛰ 1.2×';
- document.getElementById('map').appendChild(exaggerationEl);
- document.getElementById('exaggeration-slider').addEventListener('input', (e) => {
- const val = parseFloat(e.target.value);
- document.getElementById('exaggeration-val').textContent = val.toFixed(1) + '×';
- if (map.getTerrain()) map.setTerrain({ source: 'terrain-dem', exaggeration: val });
- });
- // Live GPS coordinates on mouse move
- const coordsEl = document.createElement('div');
- coordsEl.id = 'coords-debug';
- coordsEl.style.cssText = 'position:absolute;bottom:24px;right:50px;background:rgba(0,0,0,.6);color:#fff;font-size:11px;padding:2px 6px;border-radius:4px;z-index:10;pointer-events:none;font-family:monospace;display:none';
- document.getElementById('map').appendChild(coordsEl);
- map.on('mousemove', (e) => {
- coordsEl.textContent = `${e.lngLat.lat.toFixed(4)}°, ${e.lngLat.lng.toFixed(4)}°`;
- coordsEl.style.display = '';
- });
- map.getCanvas().addEventListener('mouseout', () => { coordsEl.style.display = 'none'; });
- const updateZoom = () => {
- zoomEl.textContent = 'z' + map.getZoom().toFixed(2);
- const is3D = _mapStyle === 'terrain3d' || _mapStyle === 'satellite3d';
- if (is3D) {
- pitchWrap.style.display = 'flex';
- pitchEl.textContent = `p${map.getPitch().toFixed(0)}° b${map.getBearing().toFixed(0)}°`;
- const tilted = Math.abs(map.getBearing()) > 1;
- resetViewEl.style.display = tilted ? '' : 'none';
- } else {
- pitchWrap.style.display = 'none';
- }
- const exCtrl = document.getElementById('exaggeration-ctrl');
- if (exCtrl) exCtrl.style.display = is3D ? 'flex' : 'none';
- };
- map.on('zoom', updateZoom);
- map.on('pitch', updateZoom);
- map.on('rotate', updateZoom);
- map.on('moveend', updateZoom);
- map.on('moveend', _onMapMoveForSearch);
- updateZoom();
- normalizeDarkLabels();
- raiseLabelsAboveRoads();
- // Inject labels toggle as its own control group above the zoom controls
- const ctrlContainer = document.querySelector('.maplibregl-ctrl-bottom-right');
- const navGroup = ctrlContainer?.querySelector('.maplibregl-ctrl-group');
- if (ctrlContainer && navGroup) {
- const wrap = document.createElement('div');
- wrap.className = 'maplibregl-ctrl maplibregl-ctrl-group';
- wrap.id = 'labels-toggle-wrap';
- const btn = document.createElement('button');
- btn.id = 'labels-toggle-btn';
- btn.type = 'button';
- btn.className = 'maplibregl-ctrl-labels';
- btn.title = labelsVisible ? 'Hide labels' : 'Show labels';
- btn.setAttribute('aria-label', 'Toggle labels');
- btn.style.opacity = labelsVisible ? '1' : '.4';
- btn.innerHTML = 'Aa';
- btn.addEventListener('click', toggleLabels);
- wrap.appendChild(btn);
- navGroup.after(wrap);
-
- // 3D Buildings toggle — inserted before navGroup so it appears above zoom controls
- const bldgWrap = document.createElement('div');
- bldgWrap.className = 'maplibregl-ctrl maplibregl-ctrl-group';
- bldgWrap.id = 'buildings-toggle-wrap';
- const bldgBtn = document.createElement('button');
- bldgBtn.id = 'tb-3d-buildings';
- bldgBtn.type = 'button';
- bldgBtn.title = '3D Buildings (zoom 15+)';
- bldgBtn.setAttribute('aria-label', 'Toggle 3D buildings');
- bldgBtn.style.opacity = buildings3DVisible ? '1' : '.4';
- bldgBtn.innerHTML = '3D';
- bldgBtn.addEventListener('click', toggle3DBuildings);
- bldgWrap.appendChild(bldgBtn);
- if (['satellite', 'satellite3d', 'terrain3d', 'globe'].includes(_mapStyle)) bldgWrap.style.display = 'none';
- navGroup.before(bldgWrap);
- }
- applyLabelScale();
- applyLabelVisibility();
- applyExtraLayers();
- _applyTerrainAndProjection();
- // Tile loading spinner
- const tileSpinner = document.getElementById('tile-spinner');
- map.on('dataloading', () => { tileSpinner?.classList.add('active'); });
- map.on('idle', () => {
- tileSpinner?.classList.remove('active');
- });
+ _initMapOverlays();
+ _initMapControls();
});
map.on('movestart', () => { _mapBusy = true; });
map.on('moveend', () => { _mapBusy = false; });
@@ -894,12 +896,7 @@ function setMapStyle(mode) {
const bldgWrap = document.getElementById('buildings-toggle-wrap');
if (bldgWrap) bldgWrap.style.display = ['satellite', 'satellite3d', 'terrain3d', 'globe'].includes(_mapStyle) ? 'none' : '';
- // Disable Export Video in Globe mode (flyTo animation doesn't translate to globe projection)
- const exportBtn = document.getElementById('tb-export-video');
- if (exportBtn) {
- exportBtn.disabled = _mapStyle === 'globe';
- exportBtn.title = _mapStyle === 'globe' ? 'Export Video is not available in Globe mode' : 'Export trip animation as video';
- }
+ _syncExportBtnState();
// Update button label
const labels = { light: 'Light Map', bright: 'Bright Map', enriched: 'Terrain', dark: 'Dark Map', satellite: 'Satellite', satellite3d: '3D Satellite', terrain3d: '3D Terrain', globe: 'Globe' };
diff --git a/js/media.js b/js/media.js
index 81a8101..5a16e4f 100644
--- a/js/media.js
+++ b/js/media.js
@@ -39,6 +39,7 @@ async function processFiles(files) {
showProg(true);
let ok=0, pinned=0, dupes=0;
const BATCH = 4;
+ const existingDks = new Set(photos.map(p => p._dk));
for (let i=0; i {
const dk = `${f.name}_${f.size}`;
- if (photos.find(p => p._dk===dk)) return { dup: true };
+ if (existingDks.has(dk)) return { dup: true };
try {
const result = await processFileInWorker(f);
if (!result.ok) return { err: true };
@@ -87,6 +88,7 @@ async function processFiles(files) {
addedAt: Date.now(), _dk: r.dk
};
photos.push(photo);
+ existingDks.add(r.dk);
await dbPut('photos', photo);
if (photo.lat !== null) pinned++;
ok++;
diff --git a/js/modals.js b/js/modals.js
index def2356..9516b88 100644
--- a/js/modals.js
+++ b/js/modals.js
@@ -239,9 +239,7 @@ async function geocodePlace() {
if (_isOffline) { showToast('Place lookup requires internet','error'); return; }
showToast('Looking up…');
try {
- const geoWait = Math.max(0, 1100 - (Date.now() - _lastNominatimCall));
- if (geoWait > 0) await new Promise(r => setTimeout(r, geoWait));
- _lastNominatimCall = Date.now();
+ await nominatimThrottle();
const r = await fetch(`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&limit=1`,{headers:{'Accept-Language':'en'}});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data = await r.json();
diff --git a/js/pins.js b/js/pins.js
index 372c8c6..2d783ab 100644
--- a/js/pins.js
+++ b/js/pins.js
@@ -315,11 +315,7 @@ async function reverseGeocode(lat, lng) {
const cacheKey = `${key}_z${nomZoom}`;
if (_geoCache[cacheKey]) return _geoCache[cacheKey];
if (_isOffline) return null;
- // Rate-limit: ensure at least 1.1s between Nominatim calls
- const now = Date.now();
- const wait = Math.max(0, 1100 - (now - _lastNominatimCall));
- if (wait > 0) await new Promise(r => setTimeout(r, wait));
- _lastNominatimCall = Date.now();
+ await nominatimThrottle();
try {
const r = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&zoom=${nomZoom}&accept-language=en`);
if (!r.ok) { console.warn('reverse geocode HTTP', r.status); return null; }
diff --git a/js/playback.js b/js/playback.js
index a236add..54950fc 100644
--- a/js/playback.js
+++ b/js/playback.js
@@ -10,29 +10,13 @@ function togglePlayback() {
}
function startPlayback() {
- // Build chronological sequence of pinned, dated photos grouped by location
- const dated = photos.filter(p => p.lat !== null && p.date)
- .sort((a, b) => photoSortKey(a) < photoSortKey(b) ? -1 : 1);
+ const stops = buildPlaybackStops();
- if (dated.length < 2) {
+ if (stops.length < 2) {
showToast('Need at least 2 dated pinned photos to play', 'warn');
return;
}
- // Group into stops by location, preserving chronological order
- const stops = [];
- const seen = new Set();
- for (const p of dated) {
- const k = locKey(p);
- if (seen.has(k)) {
- // Add to existing stop
- stops.find(s => s.key === k).photoIds.push(p.id);
- } else {
- seen.add(k);
- stops.push({ key: k, lat: p.lat, lng: p.lng, photoIds: [p.id] });
- }
- }
-
_playbackStops = stops;
_playbackIdx = 0;
_playbackActive = true;
diff --git a/js/search.js b/js/search.js
index 8796602..6b07f13 100644
--- a/js/search.js
+++ b/js/search.js
@@ -124,9 +124,7 @@ function renderSearchResults(data) {
// identically to forward search so it renders in the same dropdown
async function runReverseGeoSearch(lat, lng) {
try {
- const searchWait = Math.max(0, 1100 - (Date.now() - _lastNominatimCall));
- if (searchWait > 0) await new Promise(r => setTimeout(r, searchWait));
- _lastNominatimCall = Date.now();
+ await nominatimThrottle();
const r = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&addressdetails=1&accept-language=en`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const d = await r.json();
@@ -204,9 +202,7 @@ async function runCategorySearch(q, cat, cacheKey) {
// --- Fallback: Nominatim bounded=1 + layer=poi (works everywhere, less precise) ---
const viewbox = `&viewbox=${b.getWest()},${b.getNorth()},${b.getEast()},${b.getSouth()}&bounded=1&layer=poi`;
- const searchWait = Math.max(0, 1100 - (Date.now() - _lastNominatimCall));
- if (searchWait > 0) await new Promise(r => setTimeout(r, searchWait));
- _lastNominatimCall = Date.now();
+ await nominatimThrottle();
try {
const r = await fetch(
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&limit=20&addressdetails=1${viewbox}`,
@@ -254,9 +250,7 @@ async function runDestSearch(q) {
return;
}
try {
- const searchWait = Math.max(0, 1100 - (Date.now() - _lastNominatimCall));
- if (searchWait > 0) await new Promise(r => setTimeout(r, searchWait));
- _lastNominatimCall = Date.now();
+ await nominatimThrottle();
// Pass viewbox to bias results toward visible region
let viewbox = '';
if (map && zoom >= 3) {
diff --git a/js/state.js b/js/state.js
index 1e197b7..7baf1cc 100644
--- a/js/state.js
+++ b/js/state.js
@@ -29,6 +29,11 @@ const _geoCache = {};
const _geoCountryCache = {};
const _geoCodeCache = {}; // country codes (e.g. 'QA', 'US')
let _lastNominatimCall = 0;
+async function nominatimThrottle() {
+ const wait = Math.max(0, 1100 - (Date.now() - _lastNominatimCall));
+ if (wait > 0) await new Promise(r => setTimeout(r, wait));
+ _lastNominatimCall = Date.now();
+}
// pin picker state
let pinPickerSel = new Set();
let pinPickerCoords = null;
@@ -48,6 +53,23 @@ let _playbackTimer = null;
function rebuildPhotoMap() { photoMap = new Map(photos.map(p => [p.id, p])); }
+function buildPlaybackStops() {
+ const dated = photos.filter(p => p.lat !== null && p.date)
+ .sort((a, b) => photoSortKey(a) < photoSortKey(b) ? -1 : 1);
+ const stops = [];
+ const seen = new Set();
+ for (const p of dated) {
+ const k = locKey(p);
+ if (seen.has(k)) {
+ stops.find(s => s.key === k).photoIds.push(p.id);
+ } else {
+ seen.add(k);
+ stops.push({ key: k, lat: p.lat, lng: p.lng, photoIds: [p.id] });
+ }
+ }
+ return stops;
+}
+
function refreshAll(opts = {}) {
rebuildPhotoMap();
rebuildPhotoList();