diff --git a/.github/workflows/pr-summary.yml b/.github/workflows/pr-summary.yml index 58b9dab..ee7dd2a 100644 --- a/.github/workflows/pr-summary.yml +++ b/.github/workflows/pr-summary.yml @@ -1,7 +1,7 @@ name: PR Summary on: - pull_request: + pull_request_target: types: [opened, synchronize] jobs: @@ -9,9 +9,12 @@ jobs: runs-on: ubuntu-latest permissions: pull-requests: write + models: read steps: - name: Generate PR Summary uses: actions/github-script@v7 + env: + MODELS_TOKEN: ${{ secrets.MODELS_TOKEN }} with: script: | const prNumber = context.payload.pull_request.number; @@ -34,24 +37,122 @@ jobs: pull_number: prNumber, }); - const summary = `## πŸ“‹ PR Summary + // Build diff context for AI summary β€” skip CI/workflow files, focus on app code + const skipDirs = ['.github/', '.circleci/', '.gitlab/']; + const appFiles = files.filter(f => !skipDirs.some(d => f.filename.startsWith(d))); + const diffParts = (appFiles.length ? appFiles : files).slice(0, 12).map(f => { + const patch = (f.patch || '').slice(0, 1500); + return `--- ${f.filename} (${f.status}, +${f.additions}/-${f.deletions})\n${patch}`; + }); + const diffContext = diffParts.join('\n\n'); + + const commitList = commits + .map(c => c.commit.message.split('\n')[0].trim()) + .filter(m => m && !m.startsWith('Merge')) + .join('\n'); + + // Call GitHub Models API to generate a natural-language summary + let aiSummary = ''; + try { + const aiResp = await fetch('https://models.github.ai/inference/chat/completions', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${process.env.MODELS_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'openai/gpt-4o-mini', + messages: [ + { + role: 'system', + content: 'You are a senior software engineer reviewing a pull request. Write a concise summary (3-5 sentences) describing the functional changes in this PR. For each major change, explain WHAT was changed and WHY. Focus on application behavior and user-facing impact, not CI/workflow/tooling changes. Do not use markdown headers or bullet points. Write in third person (e.g., "This PR fixes..." not "I fixed..."). Be specific β€” name the features, functions, or components affected.' + }, + { + role: 'user', + content: `PR Title: ${pr.title}\n\nCommit messages:\n${commitList}\n\nDiff:\n${diffContext}` + } + ], + temperature: 0.3, + max_tokens: 300, + }) + }); + if (aiResp.ok) { + const aiData = await aiResp.json(); + aiSummary = aiData.choices?.[0]?.message?.content?.trim() || ''; + console.log('AI summary generated:', aiSummary.slice(0, 100)); + } else { + const errBody = await aiResp.text(); + console.log(`AI summary failed: HTTP ${aiResp.status} ${aiResp.statusText}`, errBody.slice(0, 500)); + } + } catch (e) { + console.log('AI summary error:', e.message); + } + + // Build per-file descriptions + function describeFile(file) { + const patch = file.patch || ''; + const ext = file.filename.split('.').pop(); + const lines = patch.split('\n'); + + if (file.status === 'added') return `New file (+${file.additions} lines)`; + if (file.status === 'removed') return `Deleted`; + if (file.status === 'renamed') return `Renamed from \`${file.previous_filename}\``; + + const touchedFns = []; + for (const line of lines) { + const m = line.match(/^@@.*@@\s*(?:(?:async\s+)?function\s+|(?:def|class)\s+)([a-zA-Z_$][\w$]*)/); + if (m && !touchedFns.includes(m[1])) touchedFns.push(m[1]); + } + + const newFns = []; + for (const line of lines) { + const m = line.match(/^\+\s*(?:(?:async\s+)?function\s+)([a-zA-Z_$][\w$]*)/); + if (m && !newFns.includes(m[1])) newFns.push(m[1]); + const py = line.match(/^\+\s*(?:def|class)\s+([a-zA-Z_][\w]*)/); + if (py && !newFns.includes(py[1])) newFns.push(py[1]); + } + + if (ext === 'css') { + const selectors = new Set(); + for (const line of lines) { + if (!line.startsWith('+')) continue; + const m = line.match(/^[+]\s*([.#][a-zA-Z][\w-]*|[a-z]+(?:\s*[>,+~]\s*[a-z]+)*)\s*\{/); + if (m) selectors.add(m[1]); + } + if (selectors.size) return `Updated ${[...selectors].slice(0, 4).join(', ')}${selectors.size > 4 ? ' +more' : ''}`; + return `Style updates (+${file.additions}/-${file.deletions})`; + } - **Title:** ${pr.title} - **Author:** @${pr.user.login} - **State:** ${pr.state.toUpperCase()} - **Draft:** ${pr.draft ? 'Yes' : 'No'} + if (['md', 'txt', 'json'].includes(ext)) return `Updated (+${file.additions}/-${file.deletions})`; - ### Changes - - **Commits:** ${commits.length} - - **Files Changed:** ${files.length} - - **Additions:** +${pr.additions} - - **Deletions:** -${pr.deletions} + const parts = []; + if (newFns.length) parts.push(`New: ${newFns.slice(0, 3).join(', ')}${newFns.length > 3 ? ' +' + (newFns.length - 3) + ' more' : ''}`); + const modOnly = touchedFns.filter(f => !newFns.includes(f)); + if (modOnly.length) parts.push(`Modified: ${modOnly.slice(0, 4).join(', ')}${modOnly.length > 4 ? ' +' + (modOnly.length - 4) + ' more' : ''}`); + if (parts.length) return parts.join('. '); + return `+${file.additions}/-${file.deletions}`; + } - ### Files Modified - ${files.slice(0, 10).map(f => `- \`${f.filename}\` (${f.changes} changes)`).join('\n')} - ${files.length > 10 ? `- ... and ${files.length - 10} more files` : ''} + const listedFiles = files.filter(f => !skipDirs.some(d => f.filename.startsWith(d))); + const fileLines = listedFiles.slice(0, 15).map(f => + `- \`${f.filename}\` β€” ${describeFile(f)}` + ); - ${pr.body ? `### Description\n${pr.body}` : ''}`; + const summary = [ + '## πŸ“‹ PR Summary', + '', + `**Title:** ${pr.title}`, + `**Author:** @${pr.user.login}`, + `**Draft:** ${pr.draft ? 'Yes' : 'No'}`, + '', + `**${files.length}** files changed: **+${pr.additions}** additions, **-${pr.deletions}** deletions`, + '', + aiSummary ? `### Description\n${aiSummary}` : null, + aiSummary ? '' : null, + '### Files', + ...fileLines, + files.length > 15 ? `- ... and ${files.length - 15} more files` : '', + ].filter(v => v !== null && v !== false && v !== undefined && v !== '').join('\n'); await github.rest.issues.createComment({ issue_number: prNumber, diff --git a/README.md b/README.md index a881095..d8dc996 100644 --- a/README.md +++ b/README.md @@ -69,11 +69,13 @@ Map tiles are cached in three layers for fast, offline-capable rendering: | **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 | -When MapLibre needs a tile, the style JSON provides the URL template (e.g., `.../planet/20260415_001001_pt/{z}/{x}/{y}.pbf`). The service worker intercepts the request and checks L1 (browser cache) first. On a miss, it asks `serve.py` if the tile exists on disk (L2). If the tile is on disk, it's served instantly. If not, the proxy returns 404 immediately and the service worker fetches directly from the origin (L3). After a successful origin fetch, the service worker sends the tile data back to `serve.py` in the background to be saved to disk for future use. +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. **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. -Tiles cached by one browser (e.g. Safari) are available to other browsers (e.g. Chrome) via the shared L2 disk cache. The disk cache is capped at 500 MB with LRU eviction β€” frequently accessed tiles have their timestamps updated on each read, so they stay in cache while rarely visited tiles are evicted first. +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`. + +**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. ## Video Export @@ -132,7 +134,7 @@ The satellite view uses **ArcGIS World Imagery** (Esri) as a separate raster til ## 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/css/styles.css b/css/styles.css index 4422c64..b89fc22 100644 --- a/css/styles.css +++ b/css/styles.css @@ -166,7 +166,7 @@ input,textarea,select{font-family:var(--font)} #map-wrap{flex:1;position:relative;overflow:hidden} #map{width:100%;height:100%} #map:not(.dark-map):not(.sat-mode) canvas{filter:brightness(0.85)} -#map.dark-map canvas{filter:brightness(1.8) contrast(0.9)} +#map.dark-map canvas{filter:brightness(1.8) contrast(0.9);will-change:filter} #esri-attribution{display:none;position:absolute;bottom:6px;left:6px;background:rgba(30,30,30,.6);color:#aaa;font-size:.55rem;padding:2px 6px;border-radius:4px;z-index:2;pointer-events:auto} #esri-attribution a{color:var(--accent);text-decoration:none} #map.sat-mode~#esri-attribution{display:block} @@ -179,7 +179,7 @@ input,textarea,select{font-family:var(--font)} .maplibregl-popup{z-index:6!important} .maplibregl-popup-content{background:var(--surface)!important;border:1px solid var(--border)!important;border-radius:var(--radius)!important;box-shadow:var(--shadow)!important;padding:0!important;overflow:hidden} .maplibregl-popup-tip{border-top-color:var(--surface)!important;border-bottom-color:var(--surface)!important} -.maplibregl-popup-close-button{color:var(--muted)!important;font-size:1rem!important;padding:6px 8px!important;background:none!important;z-index:1} +.maplibregl-popup-close-button{color:var(--muted)!important;font-size:1.3rem!important;padding:6px 10px!important;background:none!important;z-index:1} .maplibregl-popup-close-button:hover{color:var(--text)!important;background:none!important} #sidebar-toggle{position:absolute;top:50%;left:325px;transform:translateY(-50%);z-index:20;background:var(--surface);border:1px solid var(--border);width:18px;height:44px;border-radius:0 8px 8px 0;cursor:pointer;display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:.65rem;transition:left .3s,background .15s} @@ -233,7 +233,7 @@ input,textarea,select{font-family:var(--font)} /* PIN POPUP */ .popup-wrap{width:300px;display:flex;flex-direction:column;max-height:380px} .popup-hdr{padding:10px 12px 8px;border-bottom:1px solid var(--border);flex-shrink:0} -.popup-hdr-title{font-size:.84rem;font-weight:600;word-wrap:break-word;overflow-wrap:break-word;padding-right:20px} +.popup-hdr-title{font-size:.84rem;font-weight:600;word-wrap:break-word;overflow-wrap:break-word;padding-right:28px} .popup-hdr-count{font-size:.64rem;color:var(--muted);margin-top:2px} .popup-tab-bar{display:flex;border-bottom:1px solid var(--border);flex-shrink:0} .ptab-btn{flex:1;padding:7px 4px;font-size:.7rem;font-weight:500;background:none;border:none;color:var(--muted);border-bottom:2px solid transparent;transition:all .15s} @@ -269,7 +269,7 @@ input,textarea,select{font-family:var(--font)} /* DEST POPUP */ .dest-popup{width:220px;padding:12px 14px} -.dest-popup-name{font-weight:600;font-size:.83rem;margin-bottom:3px} +.dest-popup-name{font-weight:600;font-size:.83rem;margin-bottom:3px;padding-right:20px} .dest-popup-detail{font-size:.7rem;color:var(--muted);margin-bottom:10px} .dest-popup-btn{display:flex;align-items:center;gap:6px;padding:7px 10px;border-radius:8px;font-size:.71rem;font-weight:500;border:1px solid var(--border);background:var(--surface2);color:var(--text);transition:all .15s;width:100%;cursor:pointer;margin-top:6px} .dest-popup-btn:hover{background:var(--surface3);border-color:var(--accent);color:var(--accent)} @@ -336,7 +336,8 @@ textarea.fi{resize:vertical;min-height:60px;line-height:1.5} .lb-nav-btn{pointer-events:none;background:rgba(255,255,255,.1);border:none;color:#fff;font-size:1.2rem;width:40px;height:40px;border-radius:50%;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:background .15s} #lightbox.open .lb-nav-btn{pointer-events:all} .lb-nav-btn:hover{background:rgba(255,255,255,.2)} -#lb-caption{position:absolute;bottom:18px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,.65);color:#fff;padding:6px 16px;border-radius:20px;font-size:.74rem;backdrop-filter:blur(4px);max-width:80vw;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +#lb-caption{position:absolute;bottom:44px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,.65);color:#fff;padding:6px 16px;border-radius:20px;font-size:.74rem;backdrop-filter:blur(4px);max-width:80vw;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +#lb-camera{position:absolute;bottom:18px;left:50%;transform:translateX(-50%);color:rgba(255,255,255,.45);font-size:.62rem;text-align:center;white-space:nowrap;letter-spacing:.02em} /* OFFLINE BANNER */ #offline-banner{position:fixed;top:0;left:0;right:0;background:rgba(232,119,74,.92);color:#fff;text-align:center;padding:6px 12px;font-size:.75rem;font-weight:500;z-index:10000;transform:translateY(-100%);transition:transform .3s ease;pointer-events:none;font-family:var(--font);letter-spacing:.3px} diff --git a/js/data.js b/js/data.js index a373a58..db4caf6 100644 --- a/js/data.js +++ b/js/data.js @@ -502,6 +502,10 @@ async function init() { albums.push(...savedAlbums); rebuildPhotoMap(); rebuildPhotoList(); buildTimeline(); rebuildAlbumList(); updateStats(); + const dismissSpinner = () => { + const mapLoading = document.getElementById('map-loading'); + if (mapLoading) { mapLoading.classList.add('done'); setTimeout(() => mapLoading.remove(), 400); } + }; const ready = () => { buildClusterIndex(); if (savedPhotos.length) { @@ -509,14 +513,17 @@ async function init() { const realCount = savedPhotos.filter(p => !p.isEmptyPin).length; if (realCount) showToast(`Loaded ${realCount} photo${realCount!==1?'s':''}${savedAlbums.length?` and ${savedAlbums.length} album${savedAlbums.length!==1?'s':''}`:''}`,'success'); } + dismissSpinner(); }; // Ensure map is truly ready β€” use 'idle' which fires after tiles + style are fully rendered const waitForMap = () => { if (map.loaded() && map.isStyleLoaded()) { ready(); } else { map.once('idle', ready); } }; - if (map.isStyleLoaded()) waitForMap(); + if (map.loaded() || map.isStyleLoaded()) waitForMap(); else map.on('load', waitForMap); + // Safety net: force-remove spinner after 10s in case map never fires idle + setTimeout(dismissSpinner, 10000); // Check if serve.py is running and auto-restore if needed (non-blocking) checkAutoSaveServer().then(() => checkAutoRestore()); // Backfill country codes from restored cache (non-blocking, no API calls) @@ -628,7 +635,7 @@ async function getTileTemplates() { function fetchTile(url, timeoutMs = 8000) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); - return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(timer)); + return fetch(url, { mode: 'cors', signal: controller.signal }).finally(() => clearTimeout(timer)); } // Build tile URLs for a set of locations at given zoom levels @@ -686,22 +693,30 @@ async function cacheMapTiles() { const pinUrls = buildTileUrls(templates, locs, [4, 6, 8, 10, 12, 14], z => z >= 10 ? 2 : 1); tileUrls.push(...pinUrls); + // Skip tiles already in the SW cache + const cacheNames = await caches.keys(); + const tileCacheName = cacheNames.find(n => n.endsWith('-tiles')); + const swCache = tileCacheName ? await caches.open(tileCacheName) : null; + const uncached = []; + for (const url of tileUrls) { + if (!swCache || !(await swCache.match(url))) uncached.push(url); + } + if (!uncached.length) return; + // Fetch tiles β€” SW intercepts and caches via local server disk proxy - // Use small batches with delays to avoid starving interactive map rendering + // SW semaphores handle concurrency limiting let fetched = 0; - const BATCH = 2; - for (let i = 0; i < tileUrls.length; i += BATCH) { + const BATCH = 4; + for (let i = 0; i < uncached.length; i += BATCH) { if (_isOffline) break; - // Pause while map is busy or still loading tiles so interactive rendering gets priority while (_mapBusy || (map && !map.areTilesLoaded())) await new Promise(r => setTimeout(r, 500)); - const batch = tileUrls.slice(i, i + BATCH); + const batch = uncached.slice(i, i + BATCH); await Promise.all(batch.map(url => fetchTile(url).then(() => fetched++).catch(() => {}) )); - // Yield between batches to keep connections free for interactive requests - await new Promise(r => setTimeout(r, 200)); + await new Promise(r => setTimeout(r, 100)); } - if (fetched) console.log(`Tile cache: prefetched ${fetched} tiles (${tileUrls.length} total)`); + if (fetched) console.log(`Tile cache: prefetched ${fetched} tiles (${uncached.length} needed, ${tileUrls.length - uncached.length} already cached)`); } catch (e) { console.warn('Tile cache prefetch failed:', e); } } diff --git a/js/map.js b/js/map.js index 5fcae3a..7c37b63 100644 --- a/js/map.js +++ b/js/map.js @@ -415,7 +415,7 @@ 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 }); + map = new maplibregl.Map({ container:'map', style: initStyle, center:[0,20], zoom:1.8, attributionControl:false, preserveDrawingBuffer:true, maxTileCacheSize:200 }); 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(); @@ -482,8 +482,6 @@ async function initMap() { map.on('dataloading', () => { tileSpinner?.classList.add('active'); }); map.on('idle', () => { tileSpinner?.classList.remove('active'); - const mapLoading = document.getElementById('map-loading'); - if (mapLoading) { mapLoading.classList.add('done'); setTimeout(() => mapLoading.remove(), 400); } }); }); map.on('movestart', () => { _mapBusy = true; }); @@ -511,6 +509,7 @@ async function initMap() { .addTo(map); let clickedLabel = null; + let labelCoords = null; // use feature's own coordinates for geocoding when a label is clicked if (isWater) { // On water: search a wider area for water name labels (the label text // may not be exactly at the click point) @@ -532,13 +531,25 @@ async function initMap() { if (id === 'photo-pins-layer') continue; if (/^(road|highway|water|ferry|aeroway|boundary)/.test(id)) continue; const n = f.properties['name_en'] || f.properties['name:latin'] || f.properties['name']; - if (n) { clickedLabel = n; break; } + if (n) { + clickedLabel = n; + // Use the feature's actual coordinates for reverse geocode β€” the click + // point may be offset from the city center (e.g. Dubrovnik near Bosnia border) + const geom = f.geometry; + if (geom && geom.type === 'Point') { + labelCoords = { lng: geom.coordinates[0], lat: geom.coordinates[1] }; + } + break; + } } } - // Reverse geocode (still needed for country/countryCode even if label was clicked) - const geoName = await reverseGeocode(lat, lng); - const cacheKey = `${lat.toFixed(4)}_${lng.toFixed(4)}`; + // Reverse geocode using label's own coordinates when available (more accurate + // for border cities like Dubrovnik), otherwise fall back to click point + const geoLat = labelCoords ? labelCoords.lat : lat; + const geoLng = labelCoords ? labelCoords.lng : lng; + const geoName = await reverseGeocode(geoLat, geoLng); + const cacheKey = `${geoLat.toFixed(4)}_${geoLng.toFixed(4)}`; const placeName = clickedLabel || geoName || `${lat.toFixed(4)}Β°, ${lng.toFixed(4)}Β°`; const country = _geoCountryCache[cacheKey] || ''; diff --git a/js/media.js b/js/media.js index 276f8f9..9ea9f8a 100644 --- a/js/media.js +++ b/js/media.js @@ -30,7 +30,7 @@ function readExifData(view, tiffStart, end) { const le = view.getUint16(tiffStart) === 0x4949; // little-endian? const g16 = (o) => view.getUint16(o, le); const g32 = (o) => view.getUint32(o, le); - const result = { lat: null, lng: null, date: null, time: null }; + const result = { lat: null, lng: null, date: null, time: null, camera: null }; function readIFD(ifdOffset) { if (ifdOffset + 2 > end) return {}; @@ -83,6 +83,16 @@ function readExifData(view, tiffStart, end) { const ifd0Off = g32(tiffStart + 4); const ifd0 = readIFD(tiffStart + ifd0Off); + // Camera make (0x010F) and model (0x0110) + const make = getString(ifd0[0x010F]); + const model = getString(ifd0[0x0110]); + if (model) { + const m2 = model.trim(); + const mk = make ? make.trim() : ''; + // If model already starts with make (e.g. "Apple iPhone 15 Pro"), use model as-is + result.camera = (mk && m2.toLowerCase().indexOf(mk.toLowerCase()) === 0) ? m2 : (mk ? mk + ' ' + m2 : m2); + } + // DateTimeOriginal is in ExifIFD if (ifd0[0x8769]) { // ExifIFD pointer const exifOff = g32(ifd0[0x8769].valOff); @@ -223,6 +233,7 @@ async function processFiles(files) { name: r.name.replace(/\.[^.]+$/,''), date: r.exif.date, time: r.exif.time, lat: r.exif.lat, lng: r.exif.lng, + camera: r.exif.camera || null, placeName: null, countryCode: null, note: '', dataUrl: r.dataUrl, thumbUrl: r.thumbUrl, addedAt: Date.now(), _dk: r.dk @@ -280,8 +291,10 @@ function showLbPhoto(animate=false){ if(!p) return; const img=document.getElementById('lb-img'); const cap=document.getElementById('lb-caption'); + const camEl=document.getElementById('lb-camera'); const caption = (p.date ? fmtDate(p.date,p.time) : '') + (p.placeName ? (p.date?' Β· ':'') + p.placeName : ''); + if (camEl) camEl.textContent = p.camera ? `πŸ“· ${p.camera}` : ''; if (animate) { img.style.transition='opacity .18s ease, transform .18s ease'; cap.style.transition='opacity .18s ease'; diff --git a/js/pins.js b/js/pins.js index b8a788c..e3bac56 100644 --- a/js/pins.js +++ b/js/pins.js @@ -280,7 +280,22 @@ function _refreshClustersNow() { function countryFlag(code) { return String.fromCodePoint(...[...code].map(c => 0x1F1E6 + c.charCodeAt(0) - 65)); } async function reverseGeocode(lat, lng) { const key = `${lat.toFixed(4)}_${lng.toFixed(4)}`; - if (_geoCache[key]) return _geoCache[key]; + // Map visual zoom β†’ Nominatim zoom. Nominatim's zoomβ†’detail mapping varies by + // country (Japan needs β‰₯10 for cities, Paris returns city at 8). Fixed breakpoints + // ensure results match what the user sees at each zoom range. + // Each entry: [minMapZoom, nominatimZoom, addressFieldPriority] + const ZOOM_TIERS = [ + [14, 14, ['tourism','building','amenity','leisure','road','neighbourhood','suburb','village','town','city','county','state','province']], + [12, 12, ['suburb','neighbourhood','village','town','city','county','state','province']], + [ 9, 10, ['city','town','village','county','state','province']], + [ 0, 5, ['city','town','village','state','province','county']], + ]; + const z = map ? map.getZoom() : 5; + const tier = ZOOM_TIERS.find(t => z >= t[0]); + const nomZoom = tier[1] || Math.round(z); + // Cache by lat/lng + nominatim zoom so different zoom levels get fresh results + 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(); @@ -288,19 +303,6 @@ async function reverseGeocode(lat, lng) { if (wait > 0) await new Promise(r => setTimeout(r, wait)); _lastNominatimCall = Date.now(); try { - // Map visual zoom β†’ Nominatim zoom. Nominatim's zoomβ†’detail mapping varies by - // country (Japan needs β‰₯10 for cities, Paris returns city at 8). Fixed breakpoints - // ensure results match what the user sees at each zoom range. - // Each entry: [minMapZoom, nominatimZoom, addressFieldPriority] - const ZOOM_TIERS = [ - [14, 14, ['tourism','building','amenity','leisure','road','neighbourhood','suburb','village','town','city','county','state','province']], - [12, 12, ['suburb','neighbourhood','village','town','city','county','state','province']], - [ 9, 10, ['city','town','village','county','state','province']], - [ 0, 5, ['city','town','village','state','province','county']], - ]; - const z = map.getZoom(); - const tier = ZOOM_TIERS.find(t => z >= t[0]); - const nomZoom = tier[1] || Math.round(z); 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; } const d = await r.json(); @@ -310,9 +312,10 @@ async function reverseGeocode(lat, lng) { // Avoid using d.name as fallback β€” it often duplicates the country name at low zoom const name = rawName || (d.name && d.name !== country ? d.name : null); const countryCode = a.country_code || null; + // Always update country/code caches (higher zoom = more accurate for border areas) if (country) _geoCountryCache[key] = country; if (countryCode) _geoCodeCache[key] = countryCode.toUpperCase(); - if (name) { _geoCache[key] = name; return name; } + if (name) { _geoCache[cacheKey] = name; return name; } } catch(e) { console.warn('reverse geocode failed', e); } return null; } diff --git a/serve.py b/serve.py index 03c814d..bc1afc8 100644 --- a/serve.py +++ b/serve.py @@ -67,7 +67,7 @@ def _download(url, dest): PHOTOS_DIR = os.path.join(APP_DIR, "matrix-photos") TILES_DIR = os.path.join(APP_DIR, "matrix-tiles") VENDOR_DIR = os.path.join(APP_DIR, "vendor") -MAX_TILES_MB = 200 +MAX_TILES_MB = 500 LOG_FILE = os.path.join(APP_DIR, "matrix-requests.log") # Set up file logger for tile/GET requests @@ -209,16 +209,23 @@ def _evict_tiles_if_needed(): limit = MAX_TILES_MB * 1024 * 1024 if total <= limit: return + before_mb = total / (1024 * 1024) + _req_logger.info(f'TILE EVICTION: cache at {before_mb:.1f}MB exceeds {MAX_TILES_MB}MB limit, beginning eviction') # Sort by modification time (oldest first) and evict files.sort() + evicted = 0 for mtime, size, fp in files: if total <= limit * 0.8: # Evict down to 80% to avoid thrashing break try: os.remove(fp) total -= size + evicted += 1 except OSError: pass + after_mb = total / (1024 * 1024) + if evicted: + _req_logger.info(f'TILE EVICTION: removed {evicted} files, {before_mb:.1f}MB β†’ {after_mb:.1f}MB (limit {MAX_TILES_MB}MB)') finally: _evict_lock.release() diff --git a/sw.js b/sw.js index 916b114..8950dd0 100644 --- a/sw.js +++ b/sw.js @@ -1,5 +1,5 @@ // Matrix β€” Service Worker for offline support -const CACHE_VERSION = 'matrix-v15'; +const CACHE_VERSION = 'matrix-v17'; const APP_CACHE = `${CACHE_VERSION}-app`; const TILE_CACHE = `${CACHE_VERSION}-tiles`; @@ -154,56 +154,51 @@ async function cacheFirst(request, cacheName) { const TRANSPARENT_PNG = Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII='), c => c.charCodeAt(0)); async function tileStrategy(request) { - // L1: browser Cache API (instant) β€” ignoreVary prevents misses from header differences + // L1: browser Cache API (instant) const cached = await caches.match(request, { ignoreVary: true }); if (cached) return cached; - const cacheAndReturn = async (body, ct) => { - const cacheResp = new Response(body, { status: 200, headers: { 'Content-Type': ct } }); - const cache = await caches.open(TILE_CACHE); - cache.put(request, cacheResp.clone()); - evictOldTiles(cache); - return cacheResp; - }; + const proxyUrl = `http://localhost:${serverPort}/api/tiles/proxy?url=${encodeURIComponent(request.url)}`; - // Online: skip disk proxy, fetch directly from origin (fast, no Python overhead) - // Offline: try disk cache first, then fail gracefully - if (navigator.onLine) { + const cacheAndReturn = async (body, ct) => { try { - const oc = new AbortController(); - const ot = setTimeout(() => oc.abort(), 8000); - const resp = await fetch(request, { signal: oc.signal }); - clearTimeout(ot); - if (!resp.ok) throw new Error('origin error'); - const body = await resp.arrayBuffer(); - const ct = resp.headers.get('Content-Type') || 'application/octet-stream'; - // Save to disk cache in background (fire-and-forget) for offline use - const cacheUrl = `http://localhost:${serverPort}/api/tiles/cache?url=${encodeURIComponent(request.url)}`; - fetch(cacheUrl, { method: 'POST', body: body.slice(0) }).catch(() => {}); - return cacheAndReturn(body, ct); + const cacheResp = new Response(body, { status: 200, headers: { 'Content-Type': ct } }); + const cache = await caches.open(TILE_CACHE); + cache.put(request, cacheResp.clone()); + evictOldTiles(cache); + return cacheResp; } catch { - // Origin failed while online β€” try disk as fallback + return new Response(body, { status: 200, headers: { 'Content-Type': ct } }); } - } + }; - // Offline or origin failed: try disk cache (L2) - try { - const proxyUrl = `http://localhost:${serverPort}/api/tiles/proxy?url=${encodeURIComponent(request.url)}`; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 1000); - const resp = await fetch(proxyUrl, { signal: controller.signal }); - clearTimeout(timer); - if (resp.ok) { - const body = await resp.arrayBuffer(); - const ct = resp.headers.get('Content-Type') || 'application/octet-stream'; - return cacheAndReturn(body, ct); - } - } catch {} + // Race L2 (disk) and L3 (origin) β€” whichever responds first wins + const diskCheck = fetch(proxyUrl).then(r => { + if (!r.ok) throw new Error('miss'); + return r.arrayBuffer().then(body => ({ body, ct: r.headers.get('Content-Type') || 'application/octet-stream' })); + }); - return new Response(TRANSPARENT_PNG, { - status: 200, - headers: { 'Content-Type': 'image/png' } + const originFetch = fetch(request).then(r => { + if (!r.ok) throw new Error('origin error'); + return r.arrayBuffer().then(body => { + const ct = r.headers.get('Content-Type') || 'application/octet-stream'; + // Save to disk in background + fetch(`http://localhost:${serverPort}/api/tiles/cache?url=${encodeURIComponent(request.url)}`, { + method: 'POST', body: body.slice(0) + }).catch(() => {}); + return { body, ct }; + }); }); + + try { + const { body, ct } = await Promise.any([diskCheck, originFetch]); + return cacheAndReturn(body, ct); + } catch { + return new Response(TRANSPARENT_PNG, { + status: 200, + headers: { 'Content-Type': 'image/png', 'Cache-Control': 'no-store' } + }); + } } function zoomFromUrl(url) { diff --git a/tests/specs/pin-position.spec.js b/tests/specs/pin-position.spec.js new file mode 100644 index 0000000..e612dc8 --- /dev/null +++ b/tests/specs/pin-position.spec.js @@ -0,0 +1,82 @@ +const { test, expect } = require('@playwright/test'); +const { setupApp, uploadTestPhotos, clearState } = require('../helpers/test-setup'); + +test.describe('Pin Position Accuracy', () => { + test.beforeEach(async ({ page }) => { + await setupApp(page); + await clearState(page); + }); + + test('pin renders at correct geographic position', async ({ page }) => { + // Upload paris.jpg β€” GPS: 48.8566, 2.3522 + await uploadTestPhotos(page, ['paris.jpg']); + + // Fly the map to Paris at zoom 12 so the pin is an individual marker (not clustered) + await page.evaluate(() => { + map.flyTo({ center: [2.3522, 48.8566], zoom: 12, duration: 0 }); + }); + + // Wait for map to finish rendering + await page.evaluate(() => new Promise(r => { + if (map.loaded() && map.isStyleLoaded()) r(); + else map.once('idle', r); + })); + + // Wait for pin icon to load (async image load + canvas draw) then trigger a repaint + await page.waitForTimeout(1500); + await page.evaluate(() => { refreshClusters(); }); + await page.waitForTimeout(500); + + // Project the expected GPS coordinates to pixel position + const expected = await page.evaluate(() => { + const px = map.project([2.3522, 48.8566]); + return { x: px.x, y: px.y }; + }); + + // Query rendered features at the expected pixel position (Β±5px bbox for sub-pixel rounding) + const pinAtExpected = await page.evaluate(({ x, y }) => { + const bbox = [[x - 5, y - 5], [x + 5, y + 5]]; + const features = map.queryRenderedFeatures(bbox, { layers: ['photo-pins-layer'] }); + if (!features.length) return null; + return { lat: features[0].properties.lat, lng: features[0].properties.lng }; + }, expected); + + expect(pinAtExpected).not.toBeNull(); + expect(parseFloat(pinAtExpected.lat)).toBeCloseTo(48.8566, 3); + expect(parseFloat(pinAtExpected.lng)).toBeCloseTo(2.3522, 3); + }); + + test('pin is not shifted south (y-offset regression)', async ({ page }) => { + // Upload paris.jpg β€” GPS: 48.8566, 2.3522 + await uploadTestPhotos(page, ['paris.jpg']); + + await page.evaluate(() => { + map.flyTo({ center: [2.3522, 48.8566], zoom: 12, duration: 0 }); + }); + + await page.evaluate(() => new Promise(r => { + if (map.loaded() && map.isStyleLoaded()) r(); + else map.once('idle', r); + })); + + await page.waitForTimeout(1500); + await page.evaluate(() => { refreshClusters(); }); + await page.waitForTimeout(500); + + const expected = await page.evaluate(() => { + const px = map.project([2.3522, 48.8566]); + return { x: px.x, y: px.y }; + }); + + // Query 50px south of expected position β€” should NOT find the pin + // (if it does, the pin has the y-offset bug) + const pinBelow = await page.evaluate(({ x, y }) => { + const shiftedY = y + 50; + const bbox = [[x - 5, shiftedY - 5], [x + 5, shiftedY + 5]]; + const features = map.queryRenderedFeatures(bbox, { layers: ['photo-pins-layer'] }); + return features.length; + }, expected); + + expect(pinBelow).toBe(0); + }); +});