Skip to content
131 changes: 116 additions & 15 deletions .github/workflows/pr-summary.yml
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
name: PR Summary

on:
pull_request:
pull_request_target:
types: [opened, synchronize]

jobs:
summary:
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;
Expand All @@ -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,
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

---

Expand Down
11 changes: 6 additions & 5 deletions css/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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}
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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)}
Expand Down Expand Up @@ -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}
Expand Down
35 changes: 25 additions & 10 deletions js/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -502,21 +502,28 @@ 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) {
fitAll();
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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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); }
}

Expand Down
Loading
Loading