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 @@
+
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) {