diff --git a/.github/workflows/pr-summary.yml b/.github/workflows/pr-summary.yml index 259a35a..58b9dab 100644 --- a/.github/workflows/pr-summary.yml +++ b/.github/workflows/pr-summary.yml @@ -7,7 +7,55 @@ on: jobs: summary: runs-on: ubuntu-latest + permissions: + pull-requests: write steps: - - uses: Codium-ai/pr-agent@main + - name: Generate PR Summary + uses: actions/github-script@v7 with: - github_token: ${{ secrets.GITHUB_TOKEN }} + script: | + const prNumber = context.payload.pull_request.number; + + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + + const { data: commits } = await github.rest.pulls.listCommits({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + + const { data: files } = await github.rest.pulls.listFiles({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + + const summary = `## šŸ“‹ PR Summary + + **Title:** ${pr.title} + **Author:** @${pr.user.login} + **State:** ${pr.state.toUpperCase()} + **Draft:** ${pr.draft ? 'Yes' : 'No'} + + ### Changes + - **Commits:** ${commits.length} + - **Files Changed:** ${files.length} + - **Additions:** +${pr.additions} + - **Deletions:** -${pr.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` : ''} + + ${pr.body ? `### Description\n${pr.body}` : ''}`; + + await github.rest.issues.createComment({ + issue_number: prNumber, + owner: context.repo.owner, + repo: context.repo.repo, + body: summary, + }); diff --git a/README.md b/README.md index 0701430..a881095 100644 --- a/README.md +++ b/README.md @@ -136,4 +136,4 @@ Everything stays **100% local**. No data is sent anywhere except OpenStreetMap/N --- -Built with [Claude Code](https://claude.ai/claude-code) using Claude Opus 4.6 (Anthropic). +Built with [Claude Code](https://claude.ai/claude-code) using Claude Opus 4.6 diff --git a/css/styles.css b/css/styles.css index 0c6888c..4422c64 100644 --- a/css/styles.css +++ b/css/styles.css @@ -55,8 +55,10 @@ input,textarea,select{font-family:var(--font)} #countries-bar .cb-flags span{cursor:default;transition:transform .1s} #countries-bar .cb-flags span:hover{transform:scale(1.25)} #countries-bar .cb-flags.collapsed{max-height:2.8em} -#countries-bar .cb-toggle{background:none;border:none;color:var(--muted);font-size:.58rem;cursor:pointer;padding:2px 0 0;transition:color .15s} +#countries-bar .cb-flags{transition:max-height .3s ease} +#countries-bar .cb-toggle{background:none;border:none;color:var(--muted);font-size:1rem;cursor:pointer;padding:0;margin:6px 0 -4px;transition:color .15s, transform .3s ease;display:block;width:100%;text-align:center;line-height:1} #countries-bar .cb-toggle:hover{color:var(--text)} +#countries-bar .cb-toggle.expanded{transform:rotate(180deg)} .stat{flex:1;padding:7px 4px;text-align:center} .stat+.stat{border-left:1px solid var(--border)} .stat .val{font-size:.9rem;font-weight:600;color:var(--text)} @@ -91,7 +93,7 @@ input,textarea,select{font-family:var(--font)} .badge-nogps{background:rgba(122,130,156,.1);color:var(--muted)} .badge-date{background:rgba(78,201,138,.12);color:var(--green)} .no-gps-row .badge-date{background:rgba(122,130,156,.08);color:var(--muted2)} -.place-label{font-size:.62rem;color:var(--muted);margin-top:6px;padding-left:5px} +.place-label{font-size:.62rem;color:var(--muted);margin-top:6px} .card-actions{display:flex;gap:1px;opacity:0;transition:opacity .15s;flex-shrink:0} .photo-card:hover .card-actions{opacity:1} .card-btn{background:none;border:none;color:var(--muted);font-size:.72rem;padding:4px 5px;border-radius:5px;transition:color .15s} @@ -145,7 +147,7 @@ input,textarea,select{font-family:var(--font)} .alb-detail-edit:hover{background:var(--surface2);color:var(--text)} .alb-detail-body{overflow-y:auto;flex:1;padding:8px 14px 12px} .alb-detail-desc{font-size:.76rem;color:var(--muted);margin-bottom:10px;line-height:1.5} -.alb-detail-meta{font-size:.66rem;color:var(--muted2);margin-bottom:10px;display:flex;gap:10px;flex-wrap:wrap} +.alb-detail-meta{font-size:.66rem;color:var(--muted2);margin-bottom:10px;display:flex;gap:20px;flex-wrap:wrap} .alb-detail-meta span{display:flex;align-items:center;gap:3px} .alb-add-photos-btn{display:flex;align-items:center;justify-content:center;gap:5px;padding:8px;background:var(--surface2);border:1px dashed var(--border2);border-radius:8px;font-size:.72rem;color:var(--accent);margin-bottom:10px;transition:all .15s} .alb-add-photos-btn:hover{background:var(--accent-dim);border-color:var(--accent)} @@ -193,6 +195,9 @@ input,textarea,select{font-family:var(--font)} .tile-spinner{width:14px;height:14px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .6s linear infinite;opacity:0;transition:opacity .2s;flex-shrink:0} .tile-spinner.active{opacity:1} @keyframes spin{to{transform:rotate(360deg)}} +#map-loading{position:absolute;inset:0;z-index:5;display:flex;align-items:center;justify-content:center;background:var(--bg);opacity:1;transition:opacity .4s ease} +#map-loading.done{opacity:0;pointer-events:none} +.map-loading-spinner{width:64px;height:64px;border:4px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .6s linear infinite} /* MAP STYLE DROPDOWN */ .style-menu{display:none;position:absolute;top:calc(100% + 8px);left:50%;transform:translateX(-50%);background:var(--surface);border:1px solid var(--border);border-radius:8px;box-shadow:var(--shadow);z-index:50;overflow:hidden;min-width:120px} @@ -240,7 +245,7 @@ input,textarea,select{font-family:var(--font)} .gallery-cell{position:relative;aspect-ratio:1;overflow:hidden;background:var(--surface2);cursor:pointer;border-radius:8px;border:2px solid transparent;transition:border-color .15s} .gallery-cell img{width:100%;height:100%;object-fit:cover;display:block;border-radius:6px} .gallery-cell:hover{border-color:var(--accent)} -.gallery-rm{position:absolute;top:4px;right:4px;background:rgba(232,119,74,.88);border:none;color:#fff;border-radius:50%;width:17px;height:17px;font-size:.55rem;display:flex;align-items:center;justify-content:center;opacity:0;transition:opacity .15s;line-height:1;cursor:pointer} +.gallery-rm{position:absolute;top:4px;right:4px;background:var(--accent2);border:none;color:#fff;border-radius:50%;width:17px;height:17px;font-size:.55rem;display:flex;align-items:center;justify-content:center;opacity:0;transition:opacity .15s;line-height:1;cursor:pointer} .gallery-cell:hover .gallery-rm{opacity:1} .gallery-empty-msg{padding:20px 12px;text-align:center;font-size:.74rem;color:var(--muted);line-height:1.6} .gallery-add-row{display:flex;align-items:center;justify-content:center;gap:6px;margin:7px 10px;padding:7px;background:var(--surface2);border:1px dashed var(--border2);border-radius:8px;font-size:.71rem;color:var(--accent);cursor:pointer;transition:all .15s;flex-shrink:0} diff --git a/index.html b/index.html index cc5f104..a5d394b 100644 --- a/index.html +++ b/index.html @@ -59,7 +59,7 @@
Countries Visited
- +
 
@@ -137,6 +137,7 @@
+
© Esri
diff --git a/js/data.js b/js/data.js index 918098d..a373a58 100644 --- a/js/data.js +++ b/js/data.js @@ -56,8 +56,7 @@ function updateCountriesBar() { const isOverflowing = flagsEl.scrollHeight > flagsEl.clientHeight + 2; toggle.style.display = isOverflowing || !flagsEl.classList.contains('collapsed') ? 'block' : 'none'; if (toggle.style.display === 'block') { - const collapsed = flagsEl.classList.contains('collapsed'); - toggle.textContent = collapsed ? `Show all ${sorted.length} countries` : 'Show less'; + toggle.classList.toggle('expanded', !flagsEl.classList.contains('collapsed')); } }); } @@ -65,10 +64,23 @@ function updateCountriesBar() { function toggleCountriesBar() { const flagsEl = document.getElementById('countries-flags'); const toggle = document.getElementById('countries-toggle'); - flagsEl.classList.toggle('collapsed'); const collapsed = flagsEl.classList.contains('collapsed'); - const count = flagsEl.querySelectorAll('span[data-name]').length; - toggle.textContent = collapsed ? `Show all ${count} countries` : 'Show less'; + if (collapsed) { + // Expand: set max-height to actual content height for smooth animation + flagsEl.style.maxHeight = flagsEl.scrollHeight + 'px'; + flagsEl.classList.remove('collapsed'); + toggle.classList.add('expanded'); + // After transition, remove inline max-height so it can grow if flags change + setTimeout(() => { flagsEl.style.maxHeight = ''; }, 300); + } else { + // Collapse: set current height first, then animate to collapsed height + flagsEl.style.maxHeight = flagsEl.scrollHeight + 'px'; + requestAnimationFrame(() => { + flagsEl.classList.add('collapsed'); + flagsEl.style.maxHeight = ''; + toggle.classList.remove('expanded'); + }); + } } // Country flag hover → show name in status bar (function() { @@ -135,13 +147,44 @@ async function _decompressGzip(blob) { // ═══════════════════════════════════════ // EXPORT DATA // ═══════════════════════════════════════ -async function exportData() { +function exportData() { document.getElementById('settings-dropdown').classList.remove('open'); if (!photos.length && !albums.length) { showToast('No data to export','error'); return; } + showProg(true); + updProg(5, 'Preparing data...'); + // Defer the heavy work so the progress bar renders immediately + setTimeout(() => _doExport(), 50); +} +async function _doExport() { const payload = { version: 1, exportedAt: Date.now(), photos, albums, geoCodeCache: {..._geoCodeCache}, geoCountryCache: {..._geoCountryCache} }; const json = JSON.stringify(payload); - // Compress with gzip to save disk space - const blob = await _compressGzip(json); + // Compress with gzip in chunks so we can report real progress + const encoder = new TextEncoder(); + const totalBytes = json.length; + const chunkSize = 256 * 1024; + const cs = new CompressionStream('gzip'); + const writer = cs.writable.getWriter(); + const reader = cs.readable.getReader(); + const compressed = []; + const readAll = (async () => { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + compressed.push(value); + } + })(); + let written = 0; + for (let i = 0; i < totalBytes; i += chunkSize) { + await writer.write(encoder.encode(json.slice(i, i + chunkSize))); + written = Math.min(i + chunkSize, totalBytes); + updProg(10 + Math.round(written / totalBytes * 85), `Compressing... ${Math.round(written / totalBytes * 100)}%`); + } + await writer.close(); + await readAll; + const blob = new Blob(compressed); + updProg(100, 'Done'); + await new Promise(r => setTimeout(r, 2000)); + showProg(false); const url = URL.createObjectURL(blob); const a = document.createElement('a'); const d = new Date(); @@ -184,6 +227,11 @@ _importInput.addEventListener('change', async e => { showToast('Invalid backup file — missing photos or albums','error'); return; } + const totalPhotos = data.photos.length; + const pinnedPhotos = data.photos.filter(p => p.lat !== null).length; + const totalAlbums = data.albums.length; + const summary = `Import summary:\n• ${totalPhotos} photo${totalPhotos !== 1 ? 's' : ''} (${pinnedPhotos} pinned)\n• ${totalAlbums} album${totalAlbums !== 1 ? 's' : ''}\n\nProceed with import?`; + if (!confirm(summary)) return; await doImport(data); } catch(err) { showToast('Failed to read backup file','error'); @@ -375,7 +423,7 @@ async function checkAutoRestore() { if (_geoCodeCache[key]) { p.countryCode = _geoCodeCache[key]; dbPut('photos', p); ccFilled++; } } } - if (ccFilled) updateCountriesBar(); + if (ccFilled) { updateCountriesBar(); buildClusterIndex(); } // Mark photos as already on disk so auto-save doesn't re-upload them photos.forEach(p => _savedPhotoDisk.add(p.id)); @@ -480,7 +528,7 @@ async function init() { const key = `${p.lat.toFixed(4)}_${p.lng.toFixed(4)}`; if (_geoCodeCache[key]) { p.countryCode = _geoCodeCache[key]; dbPut('photos', p); filled++; } } - if (filled) { updateCountriesBar(); scheduleAutoSave(); } + if (filled) { updateCountriesBar(); scheduleAutoSave(); buildClusterIndex(); } }, 500); // Proactive tile caching — start after a short delay so it doesn't compete with initial load setTimeout(() => cacheMapTiles(), 10000); diff --git a/js/map.js b/js/map.js index 27b9654..5fcae3a 100644 --- a/js/map.js +++ b/js/map.js @@ -95,6 +95,16 @@ function _patchStyleWater(styleObj) { styleObj.layers = styleObj.layers.filter(l => l.id !== 'natural_earth'); if (styleObj.sources) delete styleObj.sources['ne2_shaded']; } + // Apple Maps Dark palette — neutral dark land, distinctly blue water. + // Colors pre-compensated for canvas filter brightness(1.8) contrast(0.9). + if (_mapStyle === 'dark') { + for (const layer of styleObj.layers) { + if (!layer.paint) layer.paint = {}; + if (layer.type === 'fill' && /^water/.test(layer.id)) { + layer.paint['fill-color'] = '#131619'; + } + } + } const color = _mapStyle === 'dark' ? '#6a9fd8' : '#2c5f8a'; let hasPointLabel = false; for (const layer of styleObj.layers) { @@ -108,10 +118,15 @@ function _patchStyleWater(styleObj) { } // Hide major city labels at zoom 10+ so they don't mislead when right-clicking returns a sub-area const cityLayers = ['place_city', 'place_city_large', 'label_city', 'label_city_capital']; + // Show state/province labels only at zoom 2.5+ + const stateLayers = ['place_state', 'label_state']; for (const layer of styleObj.layers) { if (cityLayers.includes(layer.id)) { layer.maxzoom = 10; } + if (stateLayers.includes(layer.id)) { + layer.minzoom = 2.5; + } } // Dark style lacks a point-based water label layer — add one for ocean names @@ -331,7 +346,10 @@ async function _doStyleSwap(style) { styleObj = JSON.parse(JSON.stringify(_styleJsonCache[style])); } else { try { - const r = await fetch(style); + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), 5000); + const r = await fetch(style, { signal: ac.signal }); + clearTimeout(timer); const json = await r.json(); _styleJsonCache[style] = json; styleObj = JSON.parse(JSON.stringify(json)); @@ -369,6 +387,8 @@ 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); @@ -382,7 +402,10 @@ async function initMap() { const styleUrl = _styleUrl(); let initStyle; try { - const r = await fetch(styleUrl); + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), 5000); + const r = await fetch(styleUrl, { signal: ac.signal }); + clearTimeout(timer); const json = await r.json(); _styleJsonCache[styleUrl] = json; // seed cache so first style switch is instant initStyle = JSON.parse(JSON.stringify(json)); @@ -394,6 +417,24 @@ async function initMap() { 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.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(); + canvas.addEventListener('webglcontextlost', (e) => { + e.preventDefault(); // allow context to be restored + console.warn('WebGL context lost — waiting for restore'); + }); + canvas.addEventListener('webglcontextrestored', () => { + console.log('WebGL context restored — reinitializing map'); + map.triggerRepaint(); + }); + // Safety net: if map is still blank after 8 seconds, show a reload prompt + setTimeout(() => { + const gl = canvas.getContext('webgl2') || canvas.getContext('webgl'); + if (!gl || gl.isContextLost()) { + console.warn('Map canvas has no WebGL context — prompting reload'); + showToast('Map failed to load — please refresh the page', 'error'); + } + }, 8000); // Provide a transparent 1x1 placeholder for any missing sprite images (e.g. POI icons) map.on('styleimagemissing', (e) => { if (!map.hasImage(e.id)) { @@ -439,13 +480,18 @@ async function initMap() { // Tile loading spinner const tileSpinner = document.getElementById('tile-spinner'); map.on('dataloading', () => { tileSpinner?.classList.add('active'); }); - map.on('idle', () => { tileSpinner?.classList.remove('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; }); map.on('moveend', () => { _mapBusy = false; }); // Right-click on map to pin a location map.on('contextmenu', async (e) => { + try { e.preventDefault(); // Detect water vs land early — water clicks are allowed at any zoom, // land clicks require zoom >= 7 for meaningful Nominatim results. @@ -513,6 +559,7 @@ async function initMap() { popup.on('close', () => { if (destMarkerObj) { destMarkerObj.marker.remove(); destMarkerObj = null; } }); destMarkerObj = { marker, popup }; + } catch(err) { console.error('[right-click] ERROR in handler:', err); } }); // Window-level capture handler — fires before anything else can intercept. @@ -783,13 +830,15 @@ function toggleStyleMenu(e) { } function setMapStyle(mode) { + const wasDark = _mapStyle === 'dark'; _mapStyle = mode; // Persist style preference (satellite resets to previous on reload) if (mode !== 'satellite') localStorage.setItem('matrix-theme', mode); - // Update CSS classes + // Defer dark-map CSS class removal until pin icons are re-added with correct + // compensation (otherwise pre-darkened images render without the CSS filter) const mapEl = document.getElementById('map'); - mapEl.classList.toggle('dark-map', _mapStyle === 'dark'); + if (_mapStyle === 'dark') mapEl.classList.add('dark-map'); mapEl.classList.toggle('sat-mode', _mapStyle === 'satellite'); // Labels toggle visibility diff --git a/js/modals.js b/js/modals.js index e2322ef..def2356 100644 --- a/js/modals.js +++ b/js/modals.js @@ -185,11 +185,13 @@ async function savePhotoMeta() { async function saveNewAlbum() { const name = v('alb-name').trim(); if (!name) { showToast('Please enter an album name','error'); return; } + const startDate = getDatePickerValue('alb-start-date'); + const endDate = getDatePickerValue('alb-end-date'); + if (startDate && endDate && startDate > endDate) { showToast('Start date cannot be after end date','error'); return; } const album = { id: `a_${Date.now()}_${Math.random().toString(36).slice(2)}`, name, description: v('alb-desc').trim(), - startDate: getDatePickerValue('alb-start-date'), - endDate: getDatePickerValue('alb-end-date'), + startDate, endDate, coverPhotoId: null, photoIds: [], createdAt: Date.now() }; albums.push(album); @@ -207,10 +209,13 @@ async function saveEditAlbum() { if (!album) return; const name = v('alb-name').trim(); if (!name) { showToast('Album name is required','error'); return; } + const startDate = getDatePickerValue('alb-start-date'); + const endDate = getDatePickerValue('alb-end-date'); + if (startDate && endDate && startDate > endDate) { showToast('Start date cannot be after end date','error'); return; } album.name = name; album.description = v('alb-desc').trim(); - album.startDate = getDatePickerValue('alb-start-date'); - album.endDate = getDatePickerValue('alb-end-date'); + album.startDate = startDate; + album.endDate = endDate; await dbPut('albums', album); closeMetaModal(); renderAlbumDetail(album.id); diff --git a/js/pins.js b/js/pins.js index 40856e6..b8a788c 100644 --- a/js/pins.js +++ b/js/pins.js @@ -12,13 +12,16 @@ const _pinPixelCache = {}; // iconId → { width, height, data: Uint8ClampedArra // Pre-compensate pin icon pixels for the dark-map CSS brightness/contrast filter // so photo thumbnails look natural despite canvas-wide filter: brightness(1.8) contrast(0.9). // Inverse: undo contrast first → (v - 12.8) / 0.9, then undo brightness → v / 1.8. -function _compensateDarkFilter(imageData) { +function _compensateDarkFilter(imageData, strength) { if (_mapStyle !== 'dark') return; + const s = strength !== undefined ? strength : 1; const d = imageData.data; for (let i = 0; i < d.length; i += 4) { if (d[i + 3] === 0) continue; // skip transparent pixels for (let c = 0; c < 3; c++) { - d[i + c] = Math.max(0, Math.min(255, ((d[i + c] - 12.8) / 0.9) / 1.8)); + const orig = d[i + c]; + const comp = ((orig - 12.8) / 0.9) / 1.8; + d[i + c] = Math.max(0, Math.min(255, Math.round(orig + (comp - orig) * s))); } } } @@ -32,7 +35,8 @@ function ensureEmptyPinIcon() { const cached = _pinPixelCache[iconId]; if (cached) { const copy = new ImageData(new Uint8ClampedArray(cached.data), cached.width, cached.height); - _compensateDarkFilter(copy); + // Empty pins are UI elements — use lighter compensation so they stay visible on dark map + _compensateDarkFilter(copy, 0.55); if (map.hasImage(iconId)) map.removeImage(iconId); map.addImage(iconId, { width: cached.width, height: cached.height, data: new Uint8Array(copy.data.buffer) }, { pixelRatio: dpr }); return iconId; @@ -48,7 +52,7 @@ function ensureEmptyPinIcon() { ctx.shadowColor = 'rgba(0,0,0,0.45)'; ctx.shadowBlur = 6 * dpr; ctx.shadowOffsetY = 2 * dpr; - ctx.fillStyle = 'rgba(255,255,255,0.95)'; + ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(cx, cy, size/2, 0, Math.PI*2); ctx.fill(); ctx.shadowColor = 'transparent'; ctx.fillStyle = EMPTY_PIN_COLOR; @@ -60,8 +64,8 @@ function ensureEmptyPinIcon() { ctx.fillText('?', cx, cy + 1); try { const imageData = ctx.getImageData(0, 0, total, total); - _pinPixelCache[iconId] = { width: total, height: total, data: new Uint8ClampedArray(imageData.data.data) }; - _compensateDarkFilter(imageData); + _pinPixelCache[iconId] = { width: total, height: total, data: new Uint8ClampedArray(imageData.data) }; + _compensateDarkFilter(imageData, 0.55); if (map.hasImage(iconId)) map.removeImage(iconId); map.addImage(iconId, { width: total, height: total, data: new Uint8Array(imageData.data.buffer) }, { pixelRatio: dpr }); } catch(e) { console.warn('addImage error', iconId, e); } @@ -118,7 +122,7 @@ function ensurePinIcon(photo) { try { const imageData = ctx.getImageData(0, 0, total, total); - _pinPixelCache[iconId] = { width: total, height: total, data: new Uint8ClampedArray(imageData.data.data) }; + _pinPixelCache[iconId] = { width: total, height: total, data: new Uint8ClampedArray(imageData.data) }; _compensateDarkFilter(imageData); if (map.hasImage(iconId)) map.removeImage(iconId); map.addImage(iconId, { width: total, height: total, data: new Uint8Array(imageData.data.buffer) }, { pixelRatio: dpr }); @@ -148,22 +152,25 @@ function buildClusterIndex() { }); scIndex = new Supercluster({ - radius: 60, maxZoom: 22, + radius: 45, maxZoom: 22, map: (props) => { const ccCounts = {}; if (props.cc) ccCounts[props.cc] = 1; return { ccCounts }; }, reduce: (acc, props) => { - for (const cc in props.ccCounts) { - acc.ccCounts[cc] = (acc.ccCounts[cc] || 0) + props.ccCounts[cc]; - } + // Clone before merging — Supercluster reuses property objects across zoom + // levels, so mutating in place leaks counts between unrelated clusters + const merged = {}; + for (const cc in acc.ccCounts) merged[cc] = acc.ccCounts[cc]; + for (const cc in props.ccCounts) merged[cc] = (merged[cc] || 0) + props.ccCounts[cc]; + acc.ccCounts = merged; } }); scIndex.load(representatives.map(p => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [p.lng, p.lat] }, - properties: { id: p.id, lat: p.lat, lng: p.lng, cc: p.countryCode || null } + properties: { id: p.id, lat: p.lat, lng: p.lng, cc: p.countryCode || _geoCodeCache[locKey(p)] || null } }))); // Ensure icons exist for all pinned photos @@ -208,23 +215,23 @@ function _refreshClustersNow() { if (domMarkers[key]) return; const size = Math.min(16 + Math.sqrt(count) * 4, 40); - // Pick continent color: use majority country code when all pins share one - // continent; fall back to cluster center coordinates for mixed-continent clusters + // Color by country code when available, fall back to geographic position let topCC = null; const ccCounts = feature.properties.ccCounts; if (ccCounts) { - const continents = new Set(); let max = 0; - for (const cc in ccCounts) { - if (_countryContinent[cc]) continents.add(_countryContinent[cc]); - if (ccCounts[cc] > max) { max = ccCounts[cc]; topCC = cc; } - } - if (continents.size > 1) topCC = null; // mixed → use center coords + for (const cc in ccCounts) { if (ccCounts[cc] > max) { max = ccCounts[cc]; topCC = cc; } } + } + // Smooth rainbow gradient for clusters with 10+ photos + let bg; + if (count >= 10) { + bg = 'conic-gradient(#DA1212, #b88e26, #3c8a3f, #1abfad, #1e56c1, #482ae0, #b826b3, #612D53, #DA1212)'; + } else { + bg = _continentColor(lat, lng, topCC); } - const color = _continentColor(lat, lng, topCC); const el = document.createElement('div'); el.className = 'cluster-el'; - el.style.cssText = `width:${size}px;height:${size}px;background:${color};`; + el.style.cssText = `width:${size}px;height:${size}px;background:${bg};`; el.textContent = count; el.addEventListener('click', () => { const nextZoom = scIndex.getClusterExpansionZoom(clusterId); @@ -289,7 +296,7 @@ async function reverseGeocode(lat, lng) { [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, 0, ['city','town','village','state','province','county']], + [ 0, 5, ['city','town','village','state','province','county']], ]; const z = map.getZoom(); const tier = ZOOM_TIERS.find(t => z >= t[0]); diff --git a/js/search.js b/js/search.js index 023a056..600fd52 100644 --- a/js/search.js +++ b/js/search.js @@ -55,7 +55,14 @@ function renderSearchResults(data) { dResults.innerHTML=''; unique.forEach(item=>{ const main = item.display_name.split(',')[0]; - let detail = item.display_name.split(',').slice(1,3).join(', ').trim(); + // Build detail from address fields for cleaner results (e.g. "Slovenia" not "Upravna Enota Ljubljana") + let detail = ''; + if (item.address) { + const a = item.address; + const parts = [a.state || a.county || a.municipality || '', a.country || ''].filter(p => p && p !== main); + detail = parts.join(', '); + } + if (!detail) detail = item.display_name.split(',').slice(1,3).join(', ').trim(); // For duplicate names, build a richer detail line with archipelago/region/country if (nameCounts[main] > 1 && item.address) { const a = item.address; @@ -104,7 +111,7 @@ async function runDestSearch(q) { const b = map.getBounds(); viewbox = `&viewbox=${b.getWest()},${b.getNorth()},${b.getEast()},${b.getSouth()}&bounded=0`; } - const r = await fetch(`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&limit=8&addressdetails=1${viewbox}`,{headers:{'Accept-Language':'en'}}); + const r = await fetch(`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&limit=10&addressdetails=1${viewbox}`,{headers:{'Accept-Language':'en'}}); if (!r.ok) { throw new Error(`HTTP ${r.status}`); } let data = await r.json(); // Re-sort: results within the visible bounds come first, then by distance from center @@ -134,7 +141,10 @@ async function runDestSearch(q) { function flyTo(item) { const lat=parseFloat(item.lat), lng=parseFloat(item.lon); dResults.style.display='none'; - dInput.value=item.display_name.split(',').slice(0,2).join(', '); + const a = item.address || {}; + const main = item.display_name.split(',')[0]; + const country = a.country || item.display_name.split(',').pop().trim(); + dInput.value = country && country !== main ? `${main}, ${country}` : main; dClear.style.display='block'; map.flyTo({center:[lng,lat],zoom:12,duration:1200}); if (destMarkerObj) { destMarkerObj.marker.remove(); if(destMarkerObj.popup) destMarkerObj.popup.remove(); destMarkerObj=null; } @@ -142,8 +152,7 @@ function flyTo(item) { const el=document.createElement('div'); el.className='dest-pin-el'; el.innerHTML='
šŸ“
'; - const main=item.display_name.split(',')[0]; - const detail=item.display_name.split(',').slice(1,3).join(', '); + const detail = a.country && a.country !== main ? a.country : item.display_name.split(',').pop().trim(); // Cache the search name, country, and country code so pin popups and countries bar use it const cacheKey = `${lat.toFixed(4)}_${lng.toFixed(4)}`; _geoCache[cacheKey] = main; diff --git a/js/utils.js b/js/utils.js index 5c10594..c570199 100644 --- a/js/utils.js +++ b/js/utils.js @@ -13,6 +13,8 @@ function showToast(msg,type='info'){const t=document.getElementById('toast');t.t function esc(s){return String(s||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');} function fmtDate(date,time){ if(!date)return''; + if(date.length===4)return date; + if(date.length===7){const d=new Date(date+'-01T12:00:00');return d.toLocaleDateString('en-US',{year:'numeric',month:'short'});} const d=new Date(date+'T12:00:00'); const ds=d.toLocaleDateString('en-US',{year:'numeric',month:'short',day:'numeric'}); return time?`${ds} ${fmtTime12(time)}`:ds; @@ -26,11 +28,15 @@ function fmtTime12(time){ } function fmtDateShort(date){ if(!date)return''; + if(date.length===4)return date; + if(date.length===7){const d=new Date(date+'-01T12:00:00');return d.toLocaleDateString('en-US',{month:'short'});} const d=new Date(date+'T12:00:00'); return d.toLocaleDateString('en-US',{month:'short',day:'numeric'}); } function fmtDateLong(date){ if(!date)return'No date'; + if(date.length===4)return date; + if(date.length===7){const d=new Date(date+'-01T12:00:00');return d.toLocaleDateString('en-US',{year:'numeric',month:'long'});} const d=new Date(date+'T12:00:00'); return d.toLocaleDateString('en-US',{weekday:'short',year:'numeric',month:'long',day:'numeric'}); } @@ -44,7 +50,15 @@ function _regionFromCoords(lat, lon) { if (lat >= 5 && lat <= 84 && lon >= -170 && lon <= -30) return 'North America'; // Middle East checked before Europe — covers Arabian Peninsula, Levant, Iran, Iraq if (lat >= 12 && lat <= 42 && lon >= 34 && lon <= 63) return 'Middle East'; - if (lat >= 35 && lat <= 75 && lon >= -25 && lon <= 65) return 'Europe'; + // Atlantic European islands: Madeira (~33°N, -17°W), Canaries (~28°N, -16°W) + if (lat >= 27 && lat <= 34 && lon >= -19 && lon < -13.5) return 'Europe'; + // North African coast west of Mediterranean (Morocco, western Algeria) + // lon >= -14 excludes Madeira (-16.9) which is Portuguese (Europe) + if (lat >= 27 && lat < 36 && lon >= -14 && lon < -1) return 'Africa'; + // Mediterranean islands below lat 36 (Malta, Crete, etc.) are Europe + if (lat >= 34 && lat < 36 && lon >= 10 && lon <= 36) return 'Europe'; + // lon >= -32 includes Azores (~-25 to -31) as Europe + if (lat >= 36 && lat <= 75 && lon >= -32 && lon <= 65) return 'Europe'; if (lat >= -40 && lat <= 38 && lon >= -25 && lon <= 55) return 'Africa'; if (lat >= -10 && lat <= 80 && lon >= 25 && lon <= 180) return 'Asia'; if (lat >= -50 && lat <= 0 && lon >= 100 && lon <= 180) return 'Oceania'; @@ -107,9 +121,9 @@ const _countryContinent = { const EMPTY_PIN_COLOR = '#E8706F'; const _continentColors = { - 'North America':'#E04545', 'Caribbean':'#E0822A', 'South America':'#4AAF4E', - 'Europe':'#4A7BD9', 'Middle East':'#22D4C8', 'Africa':'#B8A225', - 'Asia':'#9B6FD9', 'Oceania':'#D94A8A' + 'North America':'#DA1212', 'Caribbean':'#612D53', 'South America':'#3c8a3f', + 'Europe':'#1e56c1', 'Middle East':'#1abfad', 'Africa':'#b88e26', + 'Asia':'#b826b3', 'Oceania':'#482ae0' }; function _continentColor(lat, lng, countryCode) { if (countryCode && _countryContinent[countryCode]) { @@ -139,7 +153,9 @@ function datePickerHTML(id, value, opts={}) { } function getDatePickerValue(id) { const y=v(id+'_y'), m=v(id+'_m'), d=v(id+'_d'); - if (!y||!m||!d) return ''; + if (!y) return ''; + if (!m) return y; + if (!d) return `${y}-${m}`; return `${y}-${m}-${d}`; } diff --git a/serve.py b/serve.py index d6d27ac..03c814d 100644 --- a/serve.py +++ b/serve.py @@ -67,10 +67,11 @@ 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 = 500 +MAX_TILES_MB = 200 LOG_FILE = os.path.join(APP_DIR, "matrix-requests.log") # Set up file logger for tile/GET requests +MAX_LOG_LINES = 10000 _req_logger = logging.getLogger('requests') _req_logger.setLevel(logging.INFO) _req_handler = logging.FileHandler(LOG_FILE) @@ -78,6 +79,22 @@ def _download(url, dest): _req_logger.addHandler(_req_handler) _req_logger.propagate = False +def _rotate_log(): + """Keep only the last MAX_LOG_LINES lines in the request log.""" + try: + if not os.path.exists(LOG_FILE): + return + size = os.path.getsize(LOG_FILE) + if size < 1_000_000: # Only rotate above 1MB + return + with open(LOG_FILE, 'r') as f: + lines = f.readlines() + if len(lines) > MAX_LOG_LINES: + with open(LOG_FILE, 'w') as f: + f.writelines(lines[-MAX_LOG_LINES:]) + 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", @@ -223,37 +240,43 @@ def end_headers(self): super().end_headers() def do_GET(self): - if self.path == "/api/data": - self._serve_data() - elif self.path.startswith("/api/tiles/proxy?"): - try: + try: + if self.path == "/api/data": + self._serve_data() + elif self.path.startswith("/api/tiles/proxy?"): self._proxy_tile() - except (ConnectionResetError, BrokenPipeError): - pass # Client (SW) timed out and disconnected - elif self.path in self._SILENT_PATHS: - self.send_response(204) - self.end_headers() - else: - super().do_GET() + elif self.path in self._SILENT_PATHS: + self.send_response(204) + self.end_headers() + else: + super().do_GET() + except (ConnectionResetError, BrokenPipeError): + pass # Client disconnected mid-response (refresh, SW abort, etc.) def do_POST(self): - if self.path == "/api/data": - self._save_data() - elif self.path.startswith("/api/tiles/cache?"): - self._cache_tile() - else: - m = PHOTO_RE.match(self.path) - if m: - self._save_photo(m.group(1), is_thumb=bool(m.group(2))) + try: + if self.path == "/api/data": + self._save_data() + elif self.path.startswith("/api/tiles/cache?"): + self._cache_tile() else: - self.send_error(404) + m = PHOTO_RE.match(self.path) + if m: + self._save_photo(m.group(1), is_thumb=bool(m.group(2))) + else: + self.send_error(404) + except (ConnectionResetError, BrokenPipeError): + pass def do_DELETE(self): - m = PHOTO_RE.match(self.path) - if m and not m.group(2): - self._delete_photo(m.group(1)) - else: - self.send_error(404) + try: + m = PHOTO_RE.match(self.path) + if m and not m.group(2): + self._delete_photo(m.group(1)) + else: + self.send_error(404) + except (ConnectionResetError, BrokenPipeError): + pass def _serve_data(self): if not os.path.exists(DATA_FILE): @@ -390,11 +413,6 @@ def _proxy_tile(self): if os.path.isfile(tile_path): with open(tile_path, 'rb') as f: data = f.read() - # Touch mtime so LRU eviction keeps frequently accessed tiles - try: - os.utime(tile_path) - except OSError: - pass self._send_tile(data, content_type) return # Not on disk — return 404 so SW fetches directly from origin @@ -521,8 +539,11 @@ def _run_tests(port): pass print(f" Data file: {DATA_FILE}") print(f" Photos dir: {PHOTOS_DIR}") - print(f" Tiles cache: {TILES_DIR}") + print(f" Tiles cache: {TILES_DIR} (max {MAX_TILES_MB}MB)") print(f" Request log: {LOG_FILE}") + # Trim tile cache and rotate log on startup + _rotate_log() + threading.Thread(target=_evict_tiles_if_needed, daemon=True).start() print(f" Press Ctrl+C to stop\n") threading.Thread(target=open_browser, daemon=True).start() try: diff --git a/sw.js b/sw.js index 503a11b..916b114 100644 --- a/sw.js +++ b/sw.js @@ -1,5 +1,5 @@ // Matrix — Service Worker for offline support -const CACHE_VERSION = 'matrix-v11'; +const CACHE_VERSION = 'matrix-v15'; const APP_CACHE = `${CACHE_VERSION}-app`; const TILE_CACHE = `${CACHE_VERSION}-tiles`; @@ -62,7 +62,9 @@ self.addEventListener('activate', (event) => { event.waitUntil( caches.keys().then((keys) => { return Promise.all( - keys.filter((k) => k !== APP_CACHE && k !== TILE_CACHE).map((k) => caches.delete(k)) + // Keep current app + tile caches, and preserve tile caches from older + // versions (tiles are map data — still valid across app updates) + keys.filter((k) => k !== APP_CACHE && k !== TILE_CACHE && !k.endsWith('-tiles')).map((k) => caches.delete(k)) ); }) ); @@ -156,8 +158,6 @@ async function tileStrategy(request) { const cached = await caches.match(request, { ignoreVary: true }); if (cached) return cached; - const proxyUrl = `http://localhost:${serverPort}/api/tiles/proxy?url=${encodeURIComponent(request.url)}`; - const cacheAndReturn = async (body, ct) => { const cacheResp = new Response(body, { status: 200, headers: { 'Content-Type': ct } }); const cache = await caches.open(TILE_CACHE); @@ -166,39 +166,44 @@ async function tileStrategy(request) { return cacheResp; }; - // Race L2 (disk cache) and L3 (origin) in parallel. - // The proxy only checks disk (no origin fetch), so this doesn't double origin requests. - // Disk hits win instantly (<10ms); cache misses lose to the direct fetch. - const diskCheck = (async () => { + // Online: skip disk proxy, fetch directly from origin (fast, no Python overhead) + // Offline: try disk cache first, then fail gracefully + if (navigator.onLine) { + 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); + } catch { + // Origin failed while online — try disk as fallback + } + } + + // 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(), 500); + const timer = setTimeout(() => controller.abort(), 1000); const resp = await fetch(proxyUrl, { signal: controller.signal }); clearTimeout(timer); - if (!resp.ok) throw new Error('not on disk'); - const body = await resp.arrayBuffer(); - const ct = resp.headers.get('Content-Type') || 'application/octet-stream'; - return cacheAndReturn(body, ct); - })(); - - const originFetch = (async () => { - const resp = await fetch(request); - 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) - const cacheUrl = `http://localhost:${serverPort}/api/tiles/cache?url=${encodeURIComponent(request.url)}`; - fetch(cacheUrl, { method: 'POST', body: body }).catch(() => {}); - return cacheAndReturn(body, ct); - })(); + if (resp.ok) { + const body = await resp.arrayBuffer(); + const ct = resp.headers.get('Content-Type') || 'application/octet-stream'; + return cacheAndReturn(body, ct); + } + } catch {} - try { - return await Promise.any([diskCheck, originFetch]); - } catch { - return new Response(TRANSPARENT_PNG, { - status: 200, - headers: { 'Content-Type': 'image/png' } - }); - } + return new Response(TRANSPARENT_PNG, { + status: 200, + headers: { 'Content-Type': 'image/png' } + }); } function zoomFromUrl(url) {