diff --git a/README.md b/README.md index 7f39df7..ea7297f 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,20 @@ *A local-first travel photo mapping app. Runs entirely in your browser, no account needed.* +![Globe rotation](assets/globe.gif) + +
+Dark Map + +![Dark Map](assets/dark.gif) +
+ +
+Pin a Location + +![Pin a location](assets/pin.gif) +
+ ## Quick Start 1. **Download** the repository (git clone it). @@ -21,18 +35,11 @@ or click it to browse your drive. --- -image - ---- -animated ---- -animated_panning - ---- [![Watch video](https://github.com/user-attachments/assets/d667bd48-5f01-463a-abe7-107561fb01b1)](https://www.youtube.com/watch?v=w9MhBRoiUho) --- + ## Features | Feature | Detail | @@ -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` | @@ -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 @@ -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 `` 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. --- diff --git a/assets/dark.gif b/assets/dark.gif new file mode 100644 index 0000000..df38ebf Binary files /dev/null and b/assets/dark.gif differ diff --git a/assets/globe.gif b/assets/globe.gif new file mode 100644 index 0000000..f434a6d Binary files /dev/null and b/assets/globe.gif differ diff --git a/assets/pin.gif b/assets/pin.gif new file mode 100644 index 0000000..512c357 Binary files /dev/null and b/assets/pin.gif differ diff --git a/css/styles.css b/css/styles.css index b89fc22..277626d 100644 --- a/css/styles.css +++ b/css/styles.css @@ -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} diff --git a/dependencies.json b/dependencies.json new file mode 100644 index 0000000..c497704 --- /dev/null +++ b/dependencies.json @@ -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" + } + ] +} diff --git a/index.html b/index.html index a5d394b..dc56597 100644 --- a/index.html +++ b/index.html @@ -125,7 +125,9 @@
Light Map
Dark Map
Terrain
+
3D Terrain
Satellite
+
Globe
@@ -180,6 +182,7 @@
+
Offline — browsing cached data
@@ -194,5 +197,6 @@ + diff --git a/js/data.js b/js/data.js index db4caf6..fcd698e 100644 --- a/js/data.js +++ b/js/data.js @@ -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')]); @@ -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 diff --git a/js/demo.js b/js/demo.js new file mode 100644 index 0000000..12e0d87 --- /dev/null +++ b/js/demo.js @@ -0,0 +1,304 @@ + +// ═══════════════════════════════════════ +// DEMOS +// ═══════════════════════════════════════ + +// Ctrl+Shift+D — automated app walkthrough with fake cursor +// Ctrl+Shift+G — globe rotation demo +document.addEventListener('keydown', e => { + if (e.ctrlKey && e.shiftKey && e.key === 'D') { e.preventDefault(); runDemo(); } + if (e.ctrlKey && e.shiftKey && e.key === 'G') { e.preventDefault(); runGlobeDemo(); } +}); + +// ═══════════════════════════════════════ +// APP WALKTHROUGH DEMO (Ctrl+Shift+D) +// ═══════════════════════════════════════ +function runDemo() { + const step = (fn) => new Promise(res => fn(res)); + const fly = (center, zoom, duration) => step(res => { + map.flyTo({ center, zoom, duration }); + map.once('moveend', res); + }); + const wait = (ms) => new Promise(res => setTimeout(res, ms)); + const rightClick = (lat, lng) => step(res => { + const point = map.project([lng, lat]); + map.fire('contextmenu', { lngLat: { lng, lat }, point, preventDefault: () => {} }); + const poll = setInterval(() => { + const btn = document.querySelector('.dest-popup-btn[onclick*="pinEmptyLocation"]'); + if (btn) { clearInterval(poll); res(btn); } + }, 300); + }); + + const hover = (el) => { + el.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); + el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); + }; + const unhover = (el) => { + el.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true })); + el.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })); + }; + + // Fake cursor element + const cursor = document.createElement('div'); + cursor.style.cssText = 'position:fixed;z-index:100000;pointer-events:none;width:32px;height:32px;transition:left .5s ease,top .5s ease,opacity .3s;opacity:0;left:-50px;top:-50px'; + cursor.innerHTML = ` + + `; + document.body.appendChild(cursor); + + const moveTo = async (target, duration = 500) => { + let x, y; + if (target instanceof Element) { + const r = target.getBoundingClientRect(); + x = r.left + r.width / 2; + y = r.top + r.height / 2; + } else if (target.lat !== undefined) { + const pt = map.project([target.lng, target.lat]); + const mapRect = map.getContainer().getBoundingClientRect(); + x = mapRect.left + pt.x; + y = mapRect.top + pt.y; + } + cursor.style.transition = `left ${duration}ms ease, top ${duration}ms ease, opacity .3s`; + cursor.style.left = x + 'px'; + cursor.style.top = y + 'px'; + cursor.style.opacity = '1'; + await wait(duration + 50); + }; + const hideCursor = () => { cursor.style.opacity = '0'; }; + const clickPulse = async () => { + cursor.style.transition = 'transform .1s'; + cursor.style.transform = 'scale(0.8)'; + await wait(100); + cursor.style.transform = 'scale(1)'; + await wait(100); + }; + + (async () => { + const preExistingIds = new Set(photos.map(p => p.id)); + + if (activeAlbumId) closeAlbumDetail(); + switchSideTab('photos'); + await wait(300); + setMapStyle('dark'); + await wait(1500); + + map.jumpTo({ center: [2.3, 46.6], zoom: 4 }); + await wait(1000); + await fly([2.3522, 48.8566], 8, 3000); + await wait(500); + + await moveTo({ lat: 48.8566, lng: 2.3522 }); + await clickPulse(); + const pinBtn = await rightClick(48.8566, 2.3522); + await wait(800); + + await moveTo(pinBtn); + await clickPulse(); + pinBtn.click(); + await wait(1500); + hideCursor(); + + await fly([2.3522, 48.8566], 2, 2000); + await wait(1000); + + await fly([80.7718, 7.8731], 7, 4000); + await wait(1500); + + await fly([32.0, 39.9], 7, 4000); + await wait(1000); + + await fly([32.0, 39.9], 1, 2000); + setMapStyle('light'); + await wait(2000); + + await fly([-62.783, 17.357], 10, 4000); + await wait(1000); + await fly([-62.6884, 17.2829], 16, 3000); + await wait(2000); + + const flags = document.querySelectorAll('#countries-flags span'); + if (flags.length >= 2) { + await moveTo(flags[0]); + hover(flags[0]); + await wait(1000); + unhover(flags[0]); + await moveTo(flags[1]); + hover(flags[1]); + await wait(1000); + unhover(flags[1]); + } + await wait(500); + + const albumsTab = document.querySelector('.stab:nth-child(3)'); + if (albumsTab) { + await moveTo(albumsTab); + await clickPulse(); + } + switchSideTab('albums'); + await wait(800); + + const albumCard = document.querySelector('.album-card'); + if (albumCard) { + await moveTo(albumCard); + await clickPulse(); + albumCard.click(); + await wait(1000); + + const photoRow = document.querySelector('#alb-detail-body .alb-photo-row'); + if (photoRow) { + await moveTo(photoRow); + await clickPulse(); + photoRow.click(); + + await step(res => { + const poll = setInterval(() => { + const lb = document.getElementById('lightbox'); + const img = document.getElementById('lb-img'); + if (lb.classList.contains('open') && img && img.complete && img.naturalWidth) { + clearInterval(poll); + res(); + } + }, 200); + }); + hideCursor(); + await wait(2000); + + closeLightbox(); + } + } + + cursor.remove(); + + const demoPins = photos.filter(p => p.isEmptyPin && !preExistingIds.has(p.id)); + for (const p of demoPins) { + photos.splice(photos.indexOf(p), 1); + photoMap.delete(p.id); + dbDel('photos', p.id); + deletePhotoFiles(p.id); + } + if (demoPins.length) { + refreshAll(); + scheduleAutoSave(); + } + })(); +} + +// ═══════════════════════════════════════ +// GLOBE ROTATION DEMO (Ctrl+Shift+G) +// ═══════════════════════════════════════ +let _globeDemoActive = false; +async function runGlobeDemo() { + if (_globeDemoActive) { _globeDemoActive = false; return; } + _globeDemoActive = true; + + setMapStyle('globe'); + + await new Promise(r => setTimeout(r, 1200)); + if (!_globeDemoActive) return; + + map.flyTo({ center: [-100, 0], zoom: 2.35, bearing: 0, pitch: 0, duration: 800 }); + await new Promise(r => setTimeout(r, 1000)); + if (!_globeDemoActive) return; + + // Replace DOM cluster markers with GPU-rendered circle dots — no jitter during rotation + _animatingMap = true; + Object.values(domMarkers).forEach(m => m.remove()); + domMarkers = {}; + const pinSrc = map.getSource('photo-pins'); + if (pinSrc) pinSrc.setData({ type: 'FeatureCollection', features: [] }); + + // Query Supercluster at the globe zoom to get clusters with counts + const globeZoom = Math.floor(map.getZoom()); + const items = scIndex ? scIndex.getClusters([-180, -85, 180, 85], globeZoom) : []; + const dotFeatures = []; + for (const f of items) { + const [lng, lat] = f.geometry.coordinates; + let color, count; + if (f.properties.cluster) { + count = f.properties.point_count; + let topCC = null; + const ccCounts = f.properties.ccCounts; + if (ccCounts) { + let max = 0; + for (const cc in ccCounts) { if (ccCounts[cc] > max) { max = ccCounts[cc]; topCC = cc; } } + } + color = _continentColor(lat, lng, topCC); + } else { + count = 1; + const p = photoMap.get(f.properties.id); + const cc = p?.countryCode || _geoCodeCache[`${lat.toFixed(4)}_${lng.toFixed(4)}`] || null; + color = _continentColor(lat, lng, cc); + } + const size = count > 1 ? Math.min(10 + Math.sqrt(count) * 2.5, 22) : 6; + dotFeatures.push({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [lng, lat] }, + properties: { color, count, size, label: count > 1 ? String(count) : '' } + }); + } + const demoData = { type: 'FeatureCollection', features: dotFeatures }; + if (!map.getSource('globe-demo-dots')) { + map.addSource('globe-demo-dots', { type: 'geojson', data: demoData }); + } else { + map.getSource('globe-demo-dots').setData(demoData); + } + if (!map.getLayer('globe-demo-dots-layer')) { + map.addLayer({ + id: 'globe-demo-dots-layer', + type: 'circle', + source: 'globe-demo-dots', + paint: { + 'circle-radius': ['get', 'size'], + 'circle-color': ['get', 'color'], + 'circle-stroke-width': 1.5, + 'circle-stroke-color': '#ffffff' + } + }); + } + if (!map.getLayer('globe-demo-labels-layer')) { + map.addLayer({ + id: 'globe-demo-labels-layer', + type: 'symbol', + source: 'globe-demo-dots', + filter: ['>', ['get', 'count'], 1], + layout: { + 'text-field': ['get', 'label'], + 'text-size': 11, + 'text-font': ['Noto Sans Bold'], + 'text-allow-overlap': true + }, + paint: { + 'text-color': '#ffffff' + } + }); + } + + // Spin the globe along the equator — 3 rotations, 25s each + const ROTATIONS = 3; + const MS_PER_ROTATION = 25000; + const TOTAL_MS = ROTATIONS * MS_PER_ROTATION; + const start = performance.now(); + + await new Promise(resolve => { + function frame(now) { + if (!_globeDemoActive) { resolve(); return; } + const elapsed = now - start; + if (elapsed >= TOTAL_MS) { + resolve(); + return; + } + const lng = -100 + (elapsed / MS_PER_ROTATION) * 360; + map.setCenter([lng, 0]); + requestAnimationFrame(frame); + } + requestAnimationFrame(frame); + }); + + // Cleanup — remove demo layers, restore normal clusters + if (map.getLayer('globe-demo-labels-layer')) map.removeLayer('globe-demo-labels-layer'); + if (map.getLayer('globe-demo-dots-layer')) map.removeLayer('globe-demo-dots-layer'); + if (map.getSource('globe-demo-dots')) map.removeSource('globe-demo-dots'); + _animatingMap = false; + _refreshClustersNow(); + _globeDemoActive = false; +} diff --git a/js/map.js b/js/map.js index 7c37b63..3d2a3ac 100644 --- a/js/map.js +++ b/js/map.js @@ -1,7 +1,11 @@ // ═══════════════════════════════════════ // MAP // ═══════════════════════════════════════ -function _styleUrl() { return _mapStyle === 'satellite' ? STYLE_SAT : _mapStyle === 'dark' ? STYLE_DARK : STYLE_STREET; } +function _styleUrl() { + if (_mapStyle === 'satellite') return STYLE_SAT; + if (_mapStyle === 'dark') return STYLE_DARK; + return STYLE_STREET; // light, enriched, terrain3d, globe all use Liberty as base +} const STYLE_STREET = 'https://tiles.openfreemap.org/styles/liberty'; const STYLE_DARK = 'https://tiles.openfreemap.org/styles/dark'; const STYLE_SAT = { @@ -314,7 +318,7 @@ function addPinLayers() { // ═══════════════════════════════════════ function initTheme() { const stored = localStorage.getItem('matrix-theme'); - if (stored && ['dark', 'light', 'enriched'].includes(stored)) { + if (stored && ['dark', 'light', 'enriched', 'terrain3d', 'globe'].includes(stored)) { _mapStyle = stored; } else { _mapStyle = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; @@ -326,10 +330,16 @@ function applyTheme() { _tileTemplatesCache = null; // Set initial button label const btn = document.getElementById('tb-style-btn'); - const labels = { light: 'Light Map', enriched: 'Terrain', dark: 'Dark Map' }; + const labels = { light: 'Light Map', enriched: 'Terrain', dark: 'Dark Map', satellite: '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'; + } } // Cache fetched style JSONs so switching between styles is instant after the first load const _styleJsonCache = {}; @@ -372,6 +382,11 @@ async function _doStyleSwap(style) { applyLabelVisibility(); // Re-add pin icons from pixel cache (fast) and refresh clusters // without rebuilding the Supercluster index (unchanged) + // Update dark-map CSS class after pin icons are re-added with correct compensation + document.getElementById('map')?.classList.toggle('dark-map', _mapStyle === 'dark'); + // Apply terrain + projection BEFORE refreshing clusters so markers + // are positioned under the correct projection (globe vs mercator) + _applyTerrainAndProjection(); if (scIndex) { const pinned = photos.filter(p => p.lat !== null); const seen = new Set(); @@ -387,8 +402,6 @@ async function _doStyleSwap(style) { } else { buildClusterIndex(); } - // Update dark-map CSS class after pin icons are re-added with correct compensation - document.getElementById('map')?.classList.toggle('dark-map', _mapStyle === 'dark'); }; map.once('styledata', () => setTimeout(restore, 100)); setTimeout(restore, 600); @@ -415,7 +428,8 @@ async function initMap() { // Pre-warm the other style into cache so the first switch is instant const otherUrl = styleUrl === STYLE_DARK ? STYLE_STREET : STYLE_DARK; fetch(otherUrl).then(r => r.json()).then(j => { _styleJsonCache[otherUrl] = j; }).catch(() => {}); - map = new maplibregl.Map({ container:'map', style: initStyle, center:[0,20], zoom:1.8, attributionControl:false, preserveDrawingBuffer:true, maxTileCacheSize:200 }); + map = new maplibregl.Map({ container:'map', style: initStyle, center:[0,20], zoom:1.8, attributionControl:false, maxTileCacheSize:200, canvasContextAttributes:{ preserveDrawingBuffer:true } }); + map.on('error', (e) => console.error('MapLibre error:', e.error?.message || e.message || e)); map.addControl(new maplibregl.NavigationControl({showCompass:false}), 'bottom-right'); // Recover from WebGL context loss (Safari loses context after sleep or memory pressure) const canvas = map.getCanvas(); @@ -449,8 +463,35 @@ async function initMap() { 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 updateZoom = () => { zoomEl.textContent = 'z' + map.getZoom().toFixed(2); }; + const pitchEl = document.createElement('div'); + pitchEl.id = 'pitch-debug'; + pitchEl.style.cssText = 'position:absolute;bottom:24px;left:70px;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(pitchEl); + const exaggerationEl = document.createElement('div'); + exaggerationEl.id = 'exaggeration-ctrl'; + exaggerationEl.style.cssText = 'position:absolute;bottom:20px;left:190px;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.5Ɨ'; + 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 }); + }); + const updateZoom = () => { + zoomEl.textContent = 'z' + map.getZoom().toFixed(2); + const is3D = _mapStyle === 'terrain3d'; + if (is3D) { + pitchEl.style.display = ''; + pitchEl.textContent = `p${map.getPitch().toFixed(0)}° b${map.getBearing().toFixed(0)}°`; + } else { + pitchEl.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(); @@ -477,6 +518,7 @@ async function initMap() { } applyLabelScale(); applyLabelVisibility(); + _applyTerrainAndProjection(); // Tile loading spinner const tileSpinner = document.getElementById('tile-spinner'); map.on('dataloading', () => { tileSpinner?.classList.add('active'); }); @@ -492,11 +534,13 @@ async function initMap() { try { e.preventDefault(); // Detect water vs land early — water clicks are allowed at any zoom, - // land clicks require zoom >= 7 for meaningful Nominatim results. - // Satellite mode has no vector layers for water detection, so require zoom >= 7. + // Water clicks allowed at any zoom; land clicks require zoom >= 5. + // Satellite/terrain3d/globe have no vector layers for water detection. const allHits = map.queryRenderedFeatures(e.point); - const isWater = _mapStyle !== 'satellite' && allHits.some(f => f.layer.type === 'fill' && /^(water|ocean)/.test(f.layer.id)); - if (!isWater && map.getZoom() < 7) return; + const noVectorLayers = ['satellite', 'terrain3d', 'globe'].includes(_mapStyle); + const isWater = !noVectorLayers && allHits.some(f => f.layer.type === 'fill' && /^(water|ocean)/.test(f.layer.id)); + // If style is mid-transition (queryRenderedFeatures returns nothing), treat as land + if (!isWater && map.getZoom() < 5) return; const { lng, lat } = e.lngLat; // Close any existing popups if (activePopup) { activePopup.remove(); activePopup = null; } @@ -555,6 +599,15 @@ async function initMap() { loadingPopup.remove(); + // Elevation — only in 3D Terrain mode + let elevationStr = ''; + if (_mapStyle === 'terrain3d') { + const elev = map.queryTerrainElevation([lng, lat]); + if (elev !== null && elev !== undefined) { + elevationStr = `${Math.round(elev).toLocaleString()}m`; + } + } + // Create dest marker + confirmation popup const el = document.createElement('div'); el.className = 'dest-pin-el'; @@ -565,7 +618,7 @@ async function initMap() { const displayName = country && country !== placeName ? `${placeName}, ${country}` : placeName; const popup = new maplibregl.Popup({ maxWidth: '240px', closeButton: true, offset: 30 }) .setLngLat([lng, lat]) - .setHTML(`
${esc(displayName)}
`) + .setHTML(`
${esc(displayName)}
${elevationStr ? `
ā›° ${elevationStr} elevation
` : ''}
`) .addTo(map); popup.on('close', () => { if (destMarkerObj) { destMarkerObj.marker.remove(); destMarkerObj = null; } }); @@ -592,202 +645,9 @@ async function initMap() { }, true); // capture phase at window level — nothing can intercept before this } -// Demo: automated walkthrough with fake cursor -function runDemo() { - const step = (fn) => new Promise(res => fn(res)); - const fly = (center, zoom, duration) => step(res => { - map.flyTo({ center, zoom, duration }); - map.once('moveend', res); - }); - const wait = (ms) => new Promise(res => setTimeout(res, ms)); - const rightClick = (lat, lng) => step(res => { - const point = map.project([lng, lat]); - map.fire('contextmenu', { lngLat: { lng, lat }, point, preventDefault: () => {} }); - const poll = setInterval(() => { - const btn = document.querySelector('.dest-popup-btn[onclick*="pinEmptyLocation"]'); - if (btn) { clearInterval(poll); res(btn); } - }, 300); - }); - - const hover = (el) => { - el.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); - el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); - }; - const unhover = (el) => { - el.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true })); - el.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })); - }; - - // Fake cursor element - const cursor = document.createElement('div'); - cursor.style.cssText = 'position:fixed;z-index:100000;pointer-events:none;width:32px;height:32px;transition:left .5s ease,top .5s ease,opacity .3s;opacity:0;left:-50px;top:-50px'; - // SVG cursor arrow - cursor.innerHTML = ` - - `; - document.body.appendChild(cursor); - - // Move cursor to an element or map coordinates - const moveTo = async (target, duration = 500) => { - let x, y; - if (target instanceof Element) { - const r = target.getBoundingClientRect(); - x = r.left + r.width / 2; - y = r.top + r.height / 2; - } else if (target.lat !== undefined) { - const pt = map.project([target.lng, target.lat]); - const mapRect = map.getContainer().getBoundingClientRect(); - x = mapRect.left + pt.x; - y = mapRect.top + pt.y; - } - cursor.style.transition = `left ${duration}ms ease, top ${duration}ms ease, opacity .3s`; - cursor.style.left = x + 'px'; - cursor.style.top = y + 'px'; - cursor.style.opacity = '1'; - await wait(duration + 50); - }; - const hideCursor = () => { cursor.style.opacity = '0'; }; - // Brief scale pulse on click - const clickPulse = async () => { - cursor.style.transition = 'transform .1s'; - cursor.style.transform = 'scale(0.8)'; - await wait(100); - cursor.style.transform = 'scale(1)'; - await wait(100); - }; - - (async () => { - // Snapshot existing photo IDs so we can clean up demo pins at the end - const preExistingIds = new Set(photos.map(p => p.id)); - - // Start on Photos tab in Dark Map - if (activeAlbumId) closeAlbumDetail(); - switchSideTab('photos'); - await wait(300); - setMapStyle('dark'); - await wait(1500); - - // 1. Start at zoom 4 over France, fly to Paris at zoom 8 - map.jumpTo({ center: [2.3, 46.6], zoom: 4 }); - await wait(1000); - await fly([2.3522, 48.8566], 8, 3000); - await wait(500); - - // Move cursor to Paris, right-click - await moveTo({ lat: 48.8566, lng: 2.3522 }); - await clickPulse(); - const pinBtn = await rightClick(48.8566, 2.3522); - await wait(800); - - // Move cursor to "Pin this location" button and click - await moveTo(pinBtn); - await clickPulse(); - pinBtn.click(); - await wait(1500); - hideCursor(); - - // 2. Zoom back out to level 2 - await fly([2.3522, 48.8566], 2, 2000); - await wait(1000); - - // Fly to Sri Lanka at zoom 7 - await fly([80.7718, 7.8731], 7, 4000); - await wait(1500); - - // Pan to Turkey - await fly([32.0, 39.9], 7, 4000); - await wait(1000); - - // Zoom out to level 1, switch to Light Map - await fly([32.0, 39.9], 1, 2000); - setMapStyle('light'); - await wait(2000); - - // Zoom to Saint Kitts & Nevis at level 10, then into Frigate Bay - await fly([-62.783, 17.357], 10, 4000); - await wait(1000); - await fly([-62.6884, 17.2829], 16, 3000); - await wait(2000); - - // 3. Hover over country flags in Countries Visited - const flags = document.querySelectorAll('#countries-flags span'); - if (flags.length >= 2) { - await moveTo(flags[0]); - hover(flags[0]); - await wait(1000); - unhover(flags[0]); - await moveTo(flags[1]); - hover(flags[1]); - await wait(1000); - unhover(flags[1]); - } - await wait(500); - - // 4. Switch to Albums tab - const albumsTab = document.querySelector('.stab:nth-child(3)'); - if (albumsTab) { - await moveTo(albumsTab); - await clickPulse(); - } - switchSideTab('albums'); - await wait(800); - - // Open the first visible album card (sorted order matches what's on screen) - const albumCard = document.querySelector('.album-card'); - if (albumCard) { - await moveTo(albumCard); - await clickPulse(); - albumCard.click(); // triggers openAlbumDetail for the correct album - await wait(1000); - - // Click first photo in the album detail to open lightbox - const photoRow = document.querySelector('#alb-detail-body .alb-photo-row'); - if (photoRow) { - await moveTo(photoRow); - await clickPulse(); - photoRow.click(); - - // Wait for lightbox to open and image to load - await step(res => { - const poll = setInterval(() => { - const lb = document.getElementById('lightbox'); - const img = document.getElementById('lb-img'); - if (lb.classList.contains('open') && img && img.complete && img.naturalWidth) { - clearInterval(poll); - res(); - } - }, 200); - }); - hideCursor(); - await wait(2000); - - // Close lightbox - closeLightbox(); - } - } - - // Remove fake cursor - cursor.remove(); - - // Cleanup: remove empty pins created during demo - const demoPins = photos.filter(p => p.isEmptyPin && !preExistingIds.has(p.id)); - for (const p of demoPins) { - photos.splice(photos.indexOf(p), 1); - photoMap.delete(p.id); - dbDel('photos', p.id); - deletePhotoFiles(p.id); - } - if (demoPins.length) { - refreshAll(); - scheduleAutoSave(); - } - })(); -} - // Ctrl+Shift+D to trigger demo -document.addEventListener('keydown', e => { - if (e.ctrlKey && e.shiftKey && e.key === 'D') { e.preventDefault(); runDemo(); } -}); +// Ctrl+Shift+G to trigger globe rotation demo +// See js/demo.js for implementations // ═══════════════════════════════════════ // FIT MAP @@ -850,14 +710,21 @@ function setMapStyle(mode) { // compensation (otherwise pre-darkened images render without the CSS filter) const mapEl = document.getElementById('map'); if (_mapStyle === 'dark') mapEl.classList.add('dark-map'); - mapEl.classList.toggle('sat-mode', _mapStyle === 'satellite'); + mapEl.classList.toggle('sat-mode', ['satellite', 'terrain3d', 'globe'].includes(_mapStyle)); // Labels toggle visibility const labelsWrap = document.getElementById('labels-toggle-wrap'); if (labelsWrap) labelsWrap.style.visibility = _mapStyle === 'satellite' ? 'hidden' : 'visible'; + // 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'; + } + // Update button label - const labels = { light: 'Light Map', enriched: 'Terrain', dark: 'Dark Map', satellite: 'Satellite' }; + const labels = { light: 'Light Map', enriched: 'Terrain', dark: 'Dark Map', satellite: 'Satellite', terrain3d: '3D Terrain', globe: 'Globe' }; const btn = document.getElementById('tb-style-btn'); if (btn) btn.textContent = (labels[mode] || mode) + ' ā–¾'; @@ -867,6 +734,60 @@ function setMapStyle(mode) { // Close the dropdown document.getElementById('style-menu').classList.remove('open'); - // Swap the map style + // Clean up terrain + projection before swapping styles so the style diff doesn't fail + // (terrain-dem source added via map.addSource would cause a diff error if left attached) + if (map.getTerrain()) map.setTerrain(null); + if (map.setProjection) map.setProjection({ type: 'mercator' }); + _doStyleSwap(_styleUrl()); } + +function _applyTerrainAndProjection() { + const is3D = _mapStyle === 'terrain3d'; + const isGlobe = _mapStyle === 'globe'; + + // Set projection FIRST — before any camera moves — so easeTo never runs under the wrong projection + if (map.setProjection) { + map.setProjection({ type: isGlobe ? 'globe' : 'mercator' }); + } + + // Terrain — AWS Terrain Tiles (free, no API key, terrarium encoding) + if (is3D) { + if (!map.getSource('terrain-dem')) { + map.addSource('terrain-dem', { + type: 'raster-dem', + tiles: ['https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png'], + tileSize: 512, maxzoom: 15, encoding: 'terrarium' + }); + } + const sliderEl = document.getElementById('exaggeration-slider'); + const exaggeration = sliderEl ? parseFloat(sliderEl.value) : 1.5; + map.setTerrain({ source: 'terrain-dem', exaggeration }); + if (!map.getLayer('terrain-hillshade')) { + map.addLayer({ + id: 'terrain-hillshade', + type: 'hillshade', + source: 'terrain-dem', + paint: { + 'hillshade-exaggeration': 0.75, + 'hillshade-illumination-direction': 315, + 'hillshade-illumination-anchor': 'map', + 'hillshade-shadow-color': '#1a2a35', + 'hillshade-highlight-color': '#f0f4f8', + 'hillshade-accent-color': '#2d4a5a', + } + }, 'waterway'); + } + map.easeTo({ pitch: 50, bearing: 0, duration: 800 }); + } else { + if (map.getTerrain()) map.setTerrain(null); + if (map.getLayer('terrain-hillshade')) map.removeLayer('terrain-hillshade'); + if (isGlobe) { + map.setMinZoom(1); + map.flyTo({ center: [0, 0], zoom: 2, pitch: 0, bearing: 0, duration: 800 }); + } else { + map.setMinZoom(-2); + map.easeTo({ pitch: 0, bearing: 0, duration: 600 }); + } + } +} diff --git a/js/media.js b/js/media.js index 9ea9f8a..77e0d39 100644 --- a/js/media.js +++ b/js/media.js @@ -556,6 +556,7 @@ function exportPreloadPhoto(photoId) { 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) @@ -586,22 +587,25 @@ async function exportVideo() { // Close any open popups if (activePopup) { activePopup.remove(); activePopup = null; } + // Verify preserveDrawingBuffer is active — required to copy map canvas to export canvas + const gl = map.getCanvas().getContext('webgl2') || map.getCanvas().getContext('webgl'); + if (gl && !gl.getContextAttributes()?.preserveDrawingBuffer) { + showToast('Video export requires preserveDrawingBuffer — check map initialization', 'error'); + return; + } + // Helper: wait until map is fully rendered (tiles decoded + GPU flushed) - async function waitForMapReady(timeoutMs = 15000) { - if (!map.areTilesLoaded() || !map.isStyleLoaded()) { - await Promise.race([ - new Promise(resolve => { - const check = () => { - if (map.areTilesLoaded() && map.isStyleLoaded()) resolve(); - else map.once('idle', check); - }; - map.once('idle', check); - }), - new Promise(resolve => setTimeout(resolve, timeoutMs)) - ]); - } - // Force repaint and wait for GPU flush (2 frames) - map.triggerRepaint(); + async function waitForMapReady(timeoutMs = 8000) { + await new Promise(resolve => { + if (map.loaded() && map.areTilesLoaded() && map.isStyleLoaded()) { + resolve(); return; + } + let done = false; + const finish = () => { if (!done) { done = true; map.off('idle', finish); resolve(); } }; + map.on('idle', finish); + setTimeout(finish, timeoutMs); + }); + // Wait 2 animation frames for GPU to flush await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); } @@ -675,9 +679,38 @@ async function exportVideo() { const stream = _exportCanvas.captureStream(30); const mimeTypes = ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm']; let mime = mimeTypes.find(m => MediaRecorder.isTypeSupported(m)) || 'video/webm'; + // Start a disk-streaming session — chunks written directly to disk, not held in memory _exportChunks = []; + let _videoSessionId = null; + let _chunkQueue = Promise.resolve(); + const startResp = await fetch('/api/video/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mime }) + }); + if (startResp.ok) { + const startData = await startResp.json(); + _videoSessionId = startData.id; + } + _exportMediaRecorder = new MediaRecorder(stream, { mimeType: mime, videoBitsPerSecond: 40000000 }); - _exportMediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) _exportChunks.push(e.data); }; + _exportMediaRecorder.ondataavailable = (e) => { + if (!e.data.size) return; + if (_videoSessionId) { + // Stream chunk to disk (fire-and-forget, queued to maintain order) + const blob = e.data; + _chunkQueue = _chunkQueue.then(() => + fetch(`/api/video/chunk?id=${_videoSessionId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/octet-stream', 'Content-Length': blob.size }, + body: blob + }).catch(() => {}) + ); + } else { + // Fallback: buffer in memory if server unavailable + _exportChunks.push(e.data); + } + }; // Start recording + frame loop _exportFade = 0; @@ -699,24 +732,23 @@ async function exportVideo() { const pct = 30 + Math.round((i / stops.length) * 65); exportUpdateProgress(`Recording — destination ${i + 1} of ${stops.length}`, pct); - // ── Transition to destination ── - // Fade to black - await exportAnimateFade(0, 1, 600); + // ── Fly to destination (recorded) ── + await new Promise(resolve => { + map.once('moveend', resolve); + map.flyTo({ + center: [stop.lng, stop.lat], zoom: 14, + speed: 0.6, curve: 1.0, essential: true, + easing: t => t < 0.5 ? 4*t*t*t : 1 - Math.pow(-2*t+2, 3)/2 + }); + }); if (!_exporting) break; - // While screen is black: jump to destination and wait for tiles - map.jumpTo({ center: [stop.lng, stop.lat], zoom: 14 }); + // Wait for tiles to finish loading at destination await waitForMapReady(); - // Extra settle time for GPU - await new Promise(r => setTimeout(r, 200)); - if (!_exporting) break; - - // Fade in the map at the destination - await exportAnimateFade(1, 0, 600); if (!_exporting) break; // Hold on map view briefly - await new Promise(r => setTimeout(r, 800)); + await new Promise(r => setTimeout(r, 1000)); if (!_exporting) break; // ── Show photos at this destination ── @@ -745,14 +777,14 @@ async function exportVideo() { } if (!_exporting) break; - // Fade back to map + // Fade out photo, reveal map await exportAnimateFade(0, 1, 500); _exportShowPhoto = false; _exportPhotoImg = null; await exportAnimateFade(1, 0, 400); - // Brief hold on map before next destination - await new Promise(r => setTimeout(r, 400)); + // Hold on map briefly before flying to next stop + await new Promise(r => setTimeout(r, 600)); } // Stop recording @@ -765,16 +797,29 @@ async function exportVideo() { }); // Download - const blob = new Blob(_exportChunks, { type: mime }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); const dateStr = new Date().toISOString().slice(0, 10); - a.href = url; - a.download = `matrix-trip-${dateStr}.webm`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - setTimeout(() => URL.revokeObjectURL(url), 5000); + if (_videoSessionId) { + // Wait for all in-flight chunk uploads to complete, then finalize + await _chunkQueue; + await fetch(`/api/video/finalize?id=${_videoSessionId}`, { method: 'POST' }); + const a = document.createElement('a'); + a.href = `/api/video/download?id=${_videoSessionId}`; + a.download = `matrix-trip-${dateStr}.webm`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + } else { + // Fallback: in-memory blob + const blob = new Blob(_exportChunks, { type: mime }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `matrix-trip-${dateStr}.webm`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 5000); + } // Cleanup exportCleanup(); @@ -798,6 +843,10 @@ function cancelExport() { if (_exportMediaRecorder && _exportMediaRecorder.state !== 'inactive') { _exportMediaRecorder.stop(); } + // Abort any in-progress disk session + if (typeof _videoSessionId !== 'undefined' && _videoSessionId) { + fetch(`/api/video/abort?id=${_videoSessionId}`, { method: 'POST' }).catch(() => {}); + } exportCleanup(); showToast('Export cancelled', 'error'); } diff --git a/js/pins.js b/js/pins.js index e3bac56..24c5de6 100644 --- a/js/pins.js +++ b/js/pins.js @@ -197,6 +197,15 @@ function _refreshClustersNow() { const zoom = Math.floor(map.getZoom()); const bbox = [bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()]; + // In globe mode at very low zoom, Supercluster produces clusters with ocean centroids. + // Hide all markers below zoom 3 in globe mode — the clustering is too coarse. + if (_mapStyle === 'globe' && map.getZoom() < 3) { + Object.keys(domMarkers).forEach(key => { domMarkers[key].remove(); delete domMarkers[key]; }); + const pinSrc = map.getSource('photo-pins'); + if (pinSrc) pinSrc.setData({ type: 'FeatureCollection', features: [] }); + return; + } + let items; try { items = scIndex.getClusters(bbox, zoom); } catch { return; } diff --git a/serve.py b/serve.py index bc1afc8..73fa5c7 100644 --- a/serve.py +++ b/serve.py @@ -95,23 +95,38 @@ def _rotate_log(): except OSError: pass -# External dependencies to bundle locally -VENDOR_FILES = { - "maplibre-gl.js": "https://unpkg.com/maplibre-gl@4.5.0/dist/maplibre-gl.js", - "maplibre-gl.css": "https://unpkg.com/maplibre-gl@4.5.0/dist/maplibre-gl.css", - "supercluster.min.js": "https://unpkg.com/supercluster@8.0.1/dist/supercluster.min.js", - "exif.js": "https://cdnjs.cloudflare.com/ajax/libs/exif-js/2.3.0/exif.js", -} +DEPS_FILE = os.path.join(APP_DIR, "dependencies.json") # Google Fonts to download (woff2 for modern browsers) GOOGLE_FONTS_CSS_URL = "https://fonts.googleapis.com/css2?family=Josefin+Sans:wght@300;400;500;600;700&display=swap" +def _load_dependencies(): + """Load vendor dependencies from dependencies.json.""" + deps_file = os.path.join(APP_DIR, "dependencies.json") + with open(deps_file, "r") as f: + deps = json.load(f) + vendor_files = {} + for dep in deps["vendor"]: + version = dep["version"] + for filename, url_template in dep["files"].items(): + vendor_files[filename] = url_template.replace("{{version}}", version) + return deps["vendor"], vendor_files + def setup_vendor(): """Download external dependencies to vendor/ for offline use.""" os.makedirs(VENDOR_DIR, exist_ok=True) + + deps, vendor_files = _load_dependencies() + + # Print dependency versions + print(" Dependencies:") + for dep in deps: + print(f" {dep['name']} v{dep['version']}") + print() + needed = [] - for name, url in VENDOR_FILES.items(): + for name, url in vendor_files.items(): if not os.path.exists(os.path.join(VENDOR_DIR, name)): needed.append((name, url)) @@ -169,6 +184,10 @@ def setup_vendor(): # Allowed tile origins for the proxy TILE_ALLOWED_HOSTS = {'tiles.openfreemap.org', 'server.arcgisonline.com'} +# Video export streaming state: session_id -> {path, file, mime} +_video_sessions = {} +_video_lock = threading.Lock() + # Content-Type by extension TILE_CONTENT_TYPES = { @@ -252,6 +271,8 @@ def do_GET(self): self._serve_data() elif self.path.startswith("/api/tiles/proxy?"): self._proxy_tile() + elif self.path.startswith("/api/video/download?"): + self._download_video() elif self.path in self._SILENT_PATHS: self.send_response(204) self.end_headers() @@ -266,6 +287,14 @@ def do_POST(self): self._save_data() elif self.path.startswith("/api/tiles/cache?"): self._cache_tile() + elif self.path == "/api/video/start": + self._video_start() + elif self.path.startswith("/api/video/chunk?"): + self._video_chunk() + elif self.path.startswith("/api/video/finalize?"): + self._video_finalize() + elif self.path.startswith("/api/video/abort?"): + self._video_abort() else: m = PHOTO_RE.match(self.path) if m: @@ -458,6 +487,103 @@ def _cache_tile(self): self.end_headers() self.wfile.write(b'{"ok":true}') + def _video_start(self): + """Start a new video export session, return session ID.""" + from urllib.parse import parse_qs + length = int(self.headers.get('Content-Length', 0)) + body = json.loads(self.rfile.read(length)) if length else {} + mime = body.get('mime', 'video/webm') + ext = 'webm' if 'webm' in mime else 'mp4' + session_id = f"vid_{int(time.time() * 1000)}" + path = os.path.join(APP_DIR, f"matrix-video-{session_id}.{ext}") + with _video_lock: + _video_sessions[session_id] = {'path': path, 'file': open(path, 'wb'), 'mime': mime} + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(json.dumps({'ok': True, 'id': session_id}).encode()) + + def _video_chunk(self): + """Append a video chunk to the session file.""" + from urllib.parse import parse_qs + qs = parse_qs(self.path.split('?', 1)[1]) + session_id = qs.get('id', [None])[0] + length = int(self.headers.get('Content-Length', 0)) + data = self.rfile.read(length) + with _video_lock: + session = _video_sessions.get(session_id) + if session and session.get('file'): + session['file'].write(data) + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(b'{"ok":true}') + + def _video_finalize(self): + """Close the video file and prepare it for download.""" + from urllib.parse import parse_qs + qs = parse_qs(self.path.split('?', 1)[1]) + session_id = qs.get('id', [None])[0] + with _video_lock: + session = _video_sessions.get(session_id) + if not session: + self.send_error(404, 'Session not found') + return + if session.get('file'): + session['file'].close() + session['file'] = None + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(json.dumps({'ok': True, 'id': session_id}).encode()) + + def _download_video(self): + """Stream the completed video file to the browser for download.""" + from urllib.parse import parse_qs + qs = parse_qs(self.path.split('?', 1)[1]) + session_id = qs.get('id', [None])[0] + with _video_lock: + session = _video_sessions.get(session_id) + if not session or not os.path.isfile(session['path']): + self.send_error(404, 'Video not found') + return + path = session['path'] + size = os.path.getsize(path) + filename = os.path.basename(path).replace(f"-{session_id}", '') + self.send_response(200) + self.send_header('Content-Type', session['mime']) + self.send_header('Content-Length', str(size)) + self.send_header('Content-Disposition', f'attachment; filename="{filename}"') + self.end_headers() + with open(path, 'rb') as f: + while chunk := f.read(1024 * 1024): + self.wfile.write(chunk) + # Cleanup session and temp file + with _video_lock: + _video_sessions.pop(session_id, None) + try: + os.remove(path) + except OSError: + pass + + def _video_abort(self): + """Abort a video session and delete the temp file.""" + from urllib.parse import parse_qs + qs = parse_qs(self.path.split('?', 1)[1]) + session_id = qs.get('id', [None])[0] + with _video_lock: + session = _video_sessions.pop(session_id, None) + if session: + if session.get('file'): + try: session['file'].close() + except: pass + try: os.remove(session['path']) + except: pass + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(b'{"ok":true}') + def log_message(self, format, *args): _req_logger.info(format % args) diff --git a/sw.js b/sw.js index 8950dd0..12daa68 100644 --- a/sw.js +++ b/sw.js @@ -1,5 +1,5 @@ // Matrix — Service Worker for offline support -const CACHE_VERSION = 'matrix-v17'; +const CACHE_VERSION = 'matrix-v19'; const APP_CACHE = `${CACHE_VERSION}-app`; const TILE_CACHE = `${CACHE_VERSION}-tiles`; @@ -18,6 +18,7 @@ const APP_SHELL = [ '/js/search.js', '/js/media.js', '/js/data.js', + '/js/demo.js', '/vendor/maplibre-gl.js', '/vendor/maplibre-gl.css', '/vendor/supercluster.min.js', diff --git a/tests/playwright.config.js b/tests/playwright.config.js index acedd8f..cc6e209 100644 --- a/tests/playwright.config.js +++ b/tests/playwright.config.js @@ -16,5 +16,6 @@ module.exports = defineConfig({ }, projects: [ { name: 'chromium', use: { browserName: 'chromium' } }, + { name: 'webkit', use: { browserName: 'webkit' }, testMatch: /safari-.*\.spec\.js/ }, ], }); diff --git a/tests/specs/countries-bar.spec.js b/tests/specs/countries-bar.spec.js index ab6667b..1f3e0f3 100644 --- a/tests/specs/countries-bar.spec.js +++ b/tests/specs/countries-bar.spec.js @@ -73,18 +73,18 @@ test.describe('Countries Bar', () => { // Flags should start collapsed await expect(flags).toHaveClass(/collapsed/); - // Toggle text should show count - await expect(toggle).toHaveText(/Show all 50 countries/); + // Toggle shows chevron arrow + await expect(toggle).toHaveText('ā–¾'); - // Click to expand + // Click to expand — arrow rotates (has 'expanded' class) await toggle.click(); await expect(flags).not.toHaveClass(/collapsed/); - await expect(toggle).toHaveText('Show less'); + await expect(toggle).toHaveClass(/expanded/); // Click to collapse again await toggle.click(); await expect(flags).toHaveClass(/collapsed/); - await expect(toggle).toHaveText(/Show all 50 countries/); + await expect(toggle).not.toHaveClass(/expanded/); }); test('toggle text reflects correct count', async ({ page }) => { @@ -105,6 +105,6 @@ test.describe('Countries Bar', () => { const toggle = page.locator('#countries-toggle'); await expect(toggle).toBeVisible(); - await expect(toggle).toHaveText(/Show all 30 countries/); + await expect(toggle).toHaveText('ā–¾'); }); }); diff --git a/tests/specs/safari-reload.spec.js b/tests/specs/safari-reload.spec.js new file mode 100644 index 0000000..83c69fa --- /dev/null +++ b/tests/specs/safari-reload.spec.js @@ -0,0 +1,128 @@ +const { test, expect } = require('@playwright/test'); +const { setupApp, uploadTestPhotos } = require('../helpers/test-setup'); + +test.describe('Safari Reload Resilience', () => { + + test('map tiles load after initial page load', async ({ page }) => { + await setupApp(page); + // Wait for map to be idle (tiles rendered) + await page.waitForFunction(() => map && map.loaded() && map.isStyleLoaded(), { timeout: 15000 }); + // Verify canvas has rendered content (not blank) + const canvas = page.locator('#map canvas'); + await expect(canvas).toBeVisible(); + const box = await canvas.boundingBox(); + expect(box.width).toBeGreaterThan(100); + expect(box.height).toBeGreaterThan(100); + // Loading spinner should be gone + const spinner = page.locator('#map-loading'); + await expect(spinner).toHaveCount(0, { timeout: 12000 }); + }); + + test('map tiles survive page reload', async ({ page }) => { + await setupApp(page); + await page.waitForFunction(() => map && map.loaded(), { timeout: 15000 }); + + // Reload the page + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForFunction( + () => !!document.querySelector('#map canvas') && typeof db !== 'undefined' && db !== null, + { timeout: 20000 } + ); + + // Map should load again without getting stuck + await page.waitForFunction(() => map && map.loaded() && map.isStyleLoaded(), { timeout: 15000 }); + const canvas = page.locator('#map canvas'); + await expect(canvas).toBeVisible(); + const spinner = page.locator('#map-loading'); + await expect(spinner).toHaveCount(0, { timeout: 12000 }); + }); + + test('map tiles survive hard reload', async ({ page }) => { + await setupApp(page); + await page.waitForFunction(() => map && map.loaded(), { timeout: 15000 }); + + // Hard reload — clears cache + await page.evaluate(() => location.reload(true)); + await page.waitForFunction( + () => !!document.querySelector('#map canvas') && typeof db !== 'undefined' && db !== null, + { timeout: 20000 } + ); + + await page.waitForFunction(() => map && map.loaded() && map.isStyleLoaded(), { timeout: 15000 }); + const canvas = page.locator('#map canvas'); + await expect(canvas).toBeVisible(); + const spinner = page.locator('#map-loading'); + await expect(spinner).toHaveCount(0, { timeout: 12000 }); + }); + + test('pins render after reload with photos', async ({ page }) => { + await setupApp(page); + await uploadTestPhotos(page, ['paris.jpg']); + + // Wait for pin to appear on the map (cluster index builds async) + await page.waitForFunction(() => { + return typeof scIndex !== 'undefined' && scIndex !== null && + typeof photos !== 'undefined' && photos.some(p => p.lat !== null); + }, { timeout: 10000 }); + + // Save data to server so it persists across reload + await page.evaluate(async () => { if (typeof autoSave === 'function') await autoSave(); }); + await page.waitForTimeout(500); + + // Reload — accept auto-restore dialog + page.on('dialog', async (dialog) => { await dialog.accept(); }); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForFunction( + () => !!document.querySelector('#map canvas') && typeof db !== 'undefined' && db !== null, + { timeout: 20000 } + ); + await page.waitForFunction( + () => typeof map !== 'undefined' && map && map.loaded && map.loaded(), + { timeout: 20000 } + ); + + // Wait for pins to render after data restore + await page.waitForFunction(() => { + try { + return typeof scIndex !== 'undefined' && scIndex !== null && + typeof photos !== 'undefined' && photos.some(p => p.lat !== null); + } catch { return false; } + }, { timeout: 15000 }); + + const pinCountAfter = await page.evaluate(() => { + try { return photos.filter(p => p.lat !== null).length; } catch { return 0; } + }); + expect(pinCountAfter).toBeGreaterThan(0); + }); + + test('no service worker registered in Safari', async ({ page }) => { + await setupApp(page); + await page.waitForTimeout(2000); + + const swCount = await page.evaluate(async () => { + const regs = await navigator.serviceWorker?.getRegistrations(); + return regs?.length || 0; + }); + expect(swCount).toBe(0); + }); + + test('multiple rapid reloads do not break map', async ({ page }) => { + await setupApp(page); + await page.waitForFunction(() => map && map.loaded(), { timeout: 15000 }); + + // Rapid reload 3 times + for (let i = 0; i < 3; i++) { + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(500); + } + + // Final load should succeed + await page.waitForFunction( + () => !!document.querySelector('#map canvas') && typeof db !== 'undefined' && db !== null, + { timeout: 20000 } + ); + await page.waitForFunction(() => map && map.loaded() && map.isStyleLoaded(), { timeout: 15000 }); + const canvas = page.locator('#map canvas'); + await expect(canvas).toBeVisible(); + }); +});