From 685d88c728560d295e9862f11a0e8d65d8cfaba1 Mon Sep 17 00:00:00 2001 From: hgosalia Date: Thu, 4 Jun 2026 20:12:25 -0400 Subject: [PATCH 1/2] Performance: Cleanup/Refactor --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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) --- From 21f1090f05e38d29cb6259624c8724417b99f194 Mon Sep 17 00:00:00 2001 From: hgosalia Date: Thu, 4 Jun 2026 20:13:26 -0400 Subject: [PATCH 2/2] Performance: Cleanup/Refactor --- css/styles.css | 7 +- js/data.js | 84 ----------------- js/export.js | 20 +--- js/map.js | 241 ++++++++++++++++++++++++------------------------- js/media.js | 4 +- js/modals.js | 4 +- js/pins.js | 6 +- js/playback.js | 20 +--- js/search.js | 12 +-- js/state.js | 22 +++++ 10 files changed, 157 insertions(+), 263 deletions(-) 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();