From eaa192ffcca14874f23c7a362e38ee83ce889132 Mon Sep 17 00:00:00 2001 From: hgosalia Date: Fri, 15 May 2026 23:34:35 -0400 Subject: [PATCH] Performance Updates --- css/styles.css | 2 + index.html | 7 +- js/data.js | 45 ++++++++---- js/map.js | 26 +++++-- js/media.js | 176 +-------------------------------------------- js/photo-worker.js | 174 ++++++++++++++++++++++++++++++++++++++++++++ js/photos.js | 14 ++-- js/pins.js | 25 ++++--- sw.js | 1 + 9 files changed, 265 insertions(+), 205 deletions(-) create mode 100644 js/photo-worker.js diff --git a/css/styles.css b/css/styles.css index 277626d..706cfe4 100644 --- a/css/styles.css +++ b/css/styles.css @@ -198,7 +198,9 @@ input,textarea,select{font-family:var(--font)} @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-inner{display:flex;flex-direction:column;align-items:center;gap:14px} .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-loading-status{font-size:.72rem;color:var(--muted);letter-spacing:.04em;font-family:var(--font)} /* 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} diff --git a/index.html b/index.html index dc56597..fcf0317 100644 --- a/index.html +++ b/index.html @@ -139,7 +139,12 @@
-
+
+
+
+
Starting…
+
+
© Esri
diff --git a/js/data.js b/js/data.js index fcd698e..757f9b8 100644 --- a/js/data.js +++ b/js/data.js @@ -156,7 +156,14 @@ function exportData() { setTimeout(() => _doExport(), 50); } async function _doExport() { - const payload = { version: 1, exportedAt: Date.now(), photos, albums, geoCodeCache: {..._geoCodeCache}, geoCountryCache: {..._geoCountryCache} }; + // Strip large binary fields — full-size images are stored on disk by serve.py, + // thumbnails are regenerated on import. Including them causes "Invalid string length" + // errors on large datasets (>512MB V8 string limit). + const exportPhotos = photos.map(p => { + const { dataUrl, ...rest } = p; + return rest; + }); + const payload = { version: 1, exportedAt: Date.now(), photos: exportPhotos, albums, geoCodeCache: {..._geoCodeCache}, geoCountryCache: {..._geoCountryCache} }; const json = JSON.stringify(payload); // Compress with gzip in chunks so we can report real progress const encoder = new TextEncoder(); @@ -494,15 +501,24 @@ async function checkAutoRestore() { // ═══════════════════════════════════════ // INIT // ═══════════════════════════════════════ +function setLoadingStatus(msg) { + const el = document.getElementById('map-loading-status'); + if (el) el.textContent = msg; +} + async function init() { - // Force stale service workers to update immediately + // Force stale service workers to update (non-blocking) if (navigator.serviceWorker?.controller) { - const reg = await navigator.serviceWorker.getRegistration(); - if (reg) { reg.update().catch(() => {}); } + navigator.serviceWorker.getRegistration().then(r => r?.update()).catch(() => {}); } - await initMap(); + setLoadingStatus('Loading map…'); + // Start map + DB load in parallel — they're independent + const mapReady = initMap(); + setLoadingStatus('Loading data…'); await openDB(); const [savedPhotos, savedAlbums] = await Promise.all([dbGetAll('photos'), dbGetAll('albums')]); + setLoadingStatus('Loading photos…'); + await mapReady; photos.push(...savedPhotos); albums.push(...savedAlbums); rebuildPhotoMap(); @@ -512,15 +528,20 @@ async function init() { 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(); + setLoadingStatus('Placing pins…'); + // Wait one animation frame so the map is truly ready to accept images + requestAnimationFrame(() => { + 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 + setLoadingStatus('Rendering map…'); const waitForMap = () => { if (map.loaded() && map.isStyleLoaded()) { ready(); } else { map.once('idle', ready); } diff --git a/js/map.js b/js/map.js index 3d2a3ac..2650986 100644 --- a/js/map.js +++ b/js/map.js @@ -463,13 +463,23 @@ async function initMap() { zoomEl.id = 'zoom-debug'; zoomEl.style.cssText = 'position:absolute;bottom:24px;left:8px;background:rgba(0,0,0,.6);color:#fff;font-size:11px;padding:2px 6px;border-radius:4px;z-index:10;pointer-events:none;font-family:monospace'; document.getElementById('map').appendChild(zoomEl); + const pitchWrap = document.createElement('div'); + pitchWrap.style.cssText = 'position:absolute;bottom:24px;left:70px;display:none;align-items:center;gap:4px;z-index:10'; const pitchEl = document.createElement('div'); pitchEl.id = 'pitch-debug'; - pitchEl.style.cssText = 'position:absolute;bottom:24px;left:70px;background:rgba(0,0,0,.6);color:#fff;font-size:11px;padding:2px 6px;border-radius:4px;z-index:10;pointer-events:none;font-family:monospace;display:none'; - document.getElementById('map').appendChild(pitchEl); + pitchEl.style.cssText = 'background:rgba(0,0,0,.6);color:#fff;font-size:11px;padding:2px 6px;border-radius:4px;pointer-events:none;font-family:monospace'; + const resetViewEl = document.createElement('button'); + resetViewEl.id = 'reset-view-btn'; + resetViewEl.title = 'Reset to top-down view'; + resetViewEl.textContent = '⊙'; + resetViewEl.style.cssText = 'background:rgba(0,0,0,.6);color:#fff;font-size:14px;border:none;border-radius:4px;cursor:pointer;padding:2px 8px;font-family:monospace;display:none;line-height:1'; + resetViewEl.addEventListener('click', () => { map.easeTo({ bearing: 0, duration: 500 }); }); + pitchWrap.appendChild(pitchEl); + pitchWrap.appendChild(resetViewEl); + document.getElementById('map').appendChild(pitchWrap); const exaggerationEl = document.createElement('div'); exaggerationEl.id = 'exaggeration-ctrl'; - exaggerationEl.style.cssText = 'position:absolute;bottom:20px;left:190px;background:rgba(0,0,0,.6);color:#fff;font-size:11px;padding:2px 8px;border-radius:4px;z-index:10;font-family:monospace;display:none;align-items:center;gap:6px'; + exaggerationEl.style.cssText = 'position:absolute;bottom:24px;left:310px;background:rgba(0,0,0,.6);color:#fff;font-size:11px;padding:2px 8px;border-radius:4px;z-index:10;font-family:monospace;display:none;align-items:center;gap:6px'; exaggerationEl.innerHTML = '⛰ 1.5×'; document.getElementById('map').appendChild(exaggerationEl); document.getElementById('exaggeration-slider').addEventListener('input', (e) => { @@ -481,10 +491,12 @@ async function initMap() { zoomEl.textContent = 'z' + map.getZoom().toFixed(2); const is3D = _mapStyle === 'terrain3d'; if (is3D) { - pitchEl.style.display = ''; + pitchWrap.style.display = 'flex'; pitchEl.textContent = `p${map.getPitch().toFixed(0)}° b${map.getBearing().toFixed(0)}°`; + const tilted = Math.abs(map.getBearing()) > 1; + resetViewEl.style.display = tilted ? '' : 'none'; } else { - pitchEl.style.display = 'none'; + pitchWrap.style.display = 'none'; } const exCtrl = document.getElementById('exaggeration-ctrl'); if (exCtrl) exCtrl.style.display = is3D ? 'flex' : 'none'; @@ -764,6 +776,8 @@ function _applyTerrainAndProjection() { const exaggeration = sliderEl ? parseFloat(sliderEl.value) : 1.5; map.setTerrain({ source: 'terrain-dem', exaggeration }); if (!map.getLayer('terrain-hillshade')) { + // Insert below the first road/label layer so hillshading shows through + const firstSymbol = map.getStyle().layers.find(l => l.type === 'line' || l.type === 'symbol'); map.addLayer({ id: 'terrain-hillshade', type: 'hillshade', @@ -776,7 +790,7 @@ function _applyTerrainAndProjection() { 'hillshade-highlight-color': '#f0f4f8', 'hillshade-accent-color': '#2d4a5a', } - }, 'waterway'); + }, firstSymbol?.id); } map.easeTo({ pitch: 50, bearing: 0, duration: 800 }); } else { diff --git a/js/media.js b/js/media.js index 77e0d39..f4eaa98 100644 --- a/js/media.js +++ b/js/media.js @@ -1,177 +1,8 @@ // ═══════════════════════════════════════ -// PHOTO PROCESSING WEB WORKER (inline) +// PHOTO PROCESSING WEB WORKER +// Worker code lives in js/photo-worker.js — served as a proper file. // ═══════════════════════════════════════ -const _photoWorkerCode = ` -// Lightweight EXIF parser — extracts GPS + DateTimeOriginal from JPEG ArrayBuffer -function parseExifFromBuffer(buf) { - const view = new DataView(buf); - let result = { lat: null, lng: null, date: null, time: null }; - if (view.getUint16(0) !== 0xFFD8) return result; // not JPEG - let offset = 2; - while (offset < view.byteLength - 1) { - const marker = view.getUint16(offset); - if (marker === 0xFFE1) { // APP1 (EXIF) - const length = view.getUint16(offset + 2); - const exifStart = offset + 4; - // Check "Exif\\0\\0" - if (view.getUint32(exifStart) === 0x45786966 && view.getUint16(exifStart + 4) === 0x0000) { - result = readExifData(view, exifStart + 6, exifStart + 6 + length - 6); - } - break; - } - if ((marker & 0xFF00) !== 0xFF00) break; - offset += 2 + view.getUint16(offset + 2); - } - return result; -} - -function readExifData(view, tiffStart, end) { - const le = view.getUint16(tiffStart) === 0x4949; // little-endian? - const g16 = (o) => view.getUint16(o, le); - const g32 = (o) => view.getUint32(o, le); - const result = { lat: null, lng: null, date: null, time: null, camera: null }; - - function readIFD(ifdOffset) { - if (ifdOffset + 2 > end) return {}; - const count = g16(ifdOffset); - const tags = {}; - for (let i = 0; i < count; i++) { - const entry = ifdOffset + 2 + i * 12; - if (entry + 12 > end) break; - const tag = g16(entry); - const type = g16(entry + 2); - const num = g32(entry + 4); - const valOff = entry + 8; - tags[tag] = { type, num, valOff }; - } - return tags; - } - - function getRational(off) { - return g32(tiffStart + off) / g32(tiffStart + off + 4); - } - - function getString(tag) { - if (!tag) return null; - if (tag.num <= 4) { - let s = ''; - for (let i = 0; i < tag.num; i++) { const c = view.getUint8(tag.valOff + i); if (c) s += String.fromCharCode(c); } - return s; - } - const off = tiffStart + g32(tag.valOff); - let s = ''; - for (let i = 0; i < Math.min(tag.num, 100); i++) { const c = view.getUint8(off + i); if (c) s += String.fromCharCode(c); } - return s; - } - - function getGpsCoord(tag) { - if (!tag || tag.num < 3) return null; - const off = g32(tag.valOff); - const d = getRational(off); - const m = getRational(off + 8); - const s = getRational(off + 16); - return d + m / 60 + s / 3600; - } - - function getGpsRef(tag) { - if (!tag) return ''; - return String.fromCharCode(view.getUint8(tag.valOff)); - } - - // Read IFD0 - const ifd0Off = g32(tiffStart + 4); - const ifd0 = readIFD(tiffStart + ifd0Off); - - // Camera make (0x010F) and model (0x0110) - const make = getString(ifd0[0x010F]); - const model = getString(ifd0[0x0110]); - if (model) { - const m2 = model.trim(); - const mk = make ? make.trim() : ''; - // If model already starts with make (e.g. "Apple iPhone 15 Pro"), use model as-is - result.camera = (mk && m2.toLowerCase().indexOf(mk.toLowerCase()) === 0) ? m2 : (mk ? mk + ' ' + m2 : m2); - } - - // DateTimeOriginal is in ExifIFD - if (ifd0[0x8769]) { // ExifIFD pointer - const exifOff = g32(ifd0[0x8769].valOff); - const exifIfd = readIFD(tiffStart + exifOff); - const dtTag = exifIfd[0x9003] || exifIfd[0x9004] || ifd0[0x0132]; // DateTimeOriginal, DateTimeDigitized, DateTime - if (dtTag) { - const dt = getString(dtTag); - if (dt) { - const m = dt.match(/^(\\d{4}):(\\d{2}):(\\d{2})\\s+(\\d{2}):(\\d{2})/); - if (m) { result.date = m[1]+'-'+m[2]+'-'+m[3]; result.time = m[4]+':'+m[5]; } - } - } - } - - // DateTime fallback from IFD0 - if (!result.date && ifd0[0x0132]) { - const dt = getString(ifd0[0x0132]); - if (dt) { - const m = dt.match(/^(\\d{4}):(\\d{2}):(\\d{2})\\s+(\\d{2}):(\\d{2})/); - if (m) { result.date = m[1]+'-'+m[2]+'-'+m[3]; result.time = m[4]+':'+m[5]; } - } - } - - // GPS IFD - if (ifd0[0x8825]) { - const gpsOff = g32(ifd0[0x8825].valOff); - const gps = readIFD(tiffStart + gpsOff); - const lat = getGpsCoord(gps[0x0002]); - const lng = getGpsCoord(gps[0x0004]); - const latRef = getGpsRef(gps[0x0001]); - const lngRef = getGpsRef(gps[0x0003]); - if (lat !== null && lng !== null) { - result.lat = (latRef === 'S') ? -lat : lat; - result.lng = (lngRef === 'W') ? -lng : lng; - } - } - - return result; -} - -// Thumbnail generation via OffscreenCanvas -async function makeThumbnail(blob, maxDim) { - const bmp = await createImageBitmap(blob); - const s = Math.min(maxDim / bmp.width, maxDim / bmp.height, 1); - const w = Math.round(bmp.width * s), h = Math.round(bmp.height * s); - const canvas = new OffscreenCanvas(w, h); - const ctx = canvas.getContext('2d'); - ctx.drawImage(bmp, 0, 0, w, h); - bmp.close(); - const outBlob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.7 }); - return outBlob; -} - -// Convert blob to data URL -function blobToDataURL(blob) { - return new Promise(r => { - const fr = new FileReader(); - fr.onload = () => r(fr.result); - fr.readAsDataURL(blob); - }); -} - -// Message handler -self.onmessage = async (e) => { - const { id, file, arrayBuffer } = e.data; - try { - const exif = parseExifFromBuffer(arrayBuffer); - const blob = new Blob([arrayBuffer], { type: file.type }); - const [dataUrl, thumbBlob] = await Promise.all([ - blobToDataURL(blob), - makeThumbnail(blob, 200) - ]); - const thumbUrl = await blobToDataURL(thumbBlob); - self.postMessage({ id, ok: true, exif, dataUrl, thumbUrl }); - } catch (err) { - self.postMessage({ id, ok: false, error: err.message }); - } -}; -`; let _photoWorker = null; let _photoWorkerCallbacks = {}; @@ -179,8 +10,7 @@ let _photoWorkerId = 0; function getPhotoWorker() { if (!_photoWorker) { - const blob = new Blob([_photoWorkerCode], { type: 'application/javascript' }); - _photoWorker = new Worker(URL.createObjectURL(blob)); + _photoWorker = new Worker('/js/photo-worker.js'); _photoWorker.onmessage = (e) => { const { id } = e.data; const cb = _photoWorkerCallbacks[id]; diff --git a/js/photo-worker.js b/js/photo-worker.js new file mode 100644 index 0000000..51ec0c5 --- /dev/null +++ b/js/photo-worker.js @@ -0,0 +1,174 @@ +// ═══════════════════════════════════════ +// PHOTO PROCESSING WEB WORKER +// Extracts EXIF data and generates thumbnails from JPEG files. +// Runs in an isolated worker context — no access to main page globals. +// ═══════════════════════════════════════ + +// Lightweight EXIF parser — extracts GPS, DateTimeOriginal, and camera info +function parseExifFromBuffer(buf) { + const view = new DataView(buf); + let result = { lat: null, lng: null, date: null, time: null }; + if (view.getUint16(0) !== 0xFFD8) return result; // not JPEG + let offset = 2; + while (offset < view.byteLength - 1) { + const marker = view.getUint16(offset); + if (marker === 0xFFE1) { // APP1 (EXIF) + const length = view.getUint16(offset + 2); + const exifStart = offset + 4; + // Check "Exif\0\0" + if (view.getUint32(exifStart) === 0x45786966 && view.getUint16(exifStart + 4) === 0x0000) { + result = readExifData(view, exifStart + 6, exifStart + 6 + length - 6); + } + break; + } + if ((marker & 0xFF00) !== 0xFF00) break; + offset += 2 + view.getUint16(offset + 2); + } + return result; +} + +function readExifData(view, tiffStart, end) { + const le = view.getUint16(tiffStart) === 0x4949; // little-endian? + const g16 = (o) => view.getUint16(o, le); + const g32 = (o) => view.getUint32(o, le); + const result = { lat: null, lng: null, date: null, time: null, camera: null }; + + function readIFD(ifdOffset) { + if (ifdOffset + 2 > end) return {}; + const count = g16(ifdOffset); + const tags = {}; + for (let i = 0; i < count; i++) { + const entry = ifdOffset + 2 + i * 12; + if (entry + 12 > end) break; + const tag = g16(entry); + const type = g16(entry + 2); + const num = g32(entry + 4); + const valOff = entry + 8; + tags[tag] = { type, num, valOff }; + } + return tags; + } + + function getRational(off) { + return g32(tiffStart + off) / g32(tiffStart + off + 4); + } + + function getString(tag) { + if (!tag) return null; + if (tag.num <= 4) { + let s = ''; + for (let i = 0; i < tag.num; i++) { const c = view.getUint8(tag.valOff + i); if (c) s += String.fromCharCode(c); } + return s; + } + const off = tiffStart + g32(tag.valOff); + let s = ''; + for (let i = 0; i < Math.min(tag.num, 100); i++) { const c = view.getUint8(off + i); if (c) s += String.fromCharCode(c); } + return s; + } + + function getGpsCoord(tag) { + if (!tag || tag.num < 3) return null; + const off = g32(tag.valOff); + const d = getRational(off); + const m = getRational(off + 8); + const s = getRational(off + 16); + return d + m / 60 + s / 3600; + } + + function getGpsRef(tag) { + if (!tag) return ''; + return String.fromCharCode(view.getUint8(tag.valOff)); + } + + // Read IFD0 + const ifd0Off = g32(tiffStart + 4); + const ifd0 = readIFD(tiffStart + ifd0Off); + + // Camera make (0x010F) and model (0x0110) + const make = getString(ifd0[0x010F]); + const model = getString(ifd0[0x0110]); + if (model) { + const m2 = model.trim(); + const mk = make ? make.trim() : ''; + // If model already starts with make (e.g. "Apple iPhone 15 Pro"), use model as-is + result.camera = (mk && m2.toLowerCase().indexOf(mk.toLowerCase()) === 0) ? m2 : (mk ? mk + ' ' + m2 : m2); + } + + // DateTimeOriginal is in ExifIFD + if (ifd0[0x8769]) { // ExifIFD pointer + const exifOff = g32(ifd0[0x8769].valOff); + const exifIfd = readIFD(tiffStart + exifOff); + const dtTag = exifIfd[0x9003] || exifIfd[0x9004] || ifd0[0x0132]; // DateTimeOriginal, DateTimeDigitized, DateTime + if (dtTag) { + const dt = getString(dtTag); + if (dt) { + const m = dt.match(/^(\d{4}):(\d{2}):(\d{2})\s+(\d{2}):(\d{2})/); + if (m) { result.date = m[1]+'-'+m[2]+'-'+m[3]; result.time = m[4]+':'+m[5]; } + } + } + } + + // DateTime fallback from IFD0 + if (!result.date && ifd0[0x0132]) { + const dt = getString(ifd0[0x0132]); + if (dt) { + const m = dt.match(/^(\d{4}):(\d{2}):(\d{2})\s+(\d{2}):(\d{2})/); + if (m) { result.date = m[1]+'-'+m[2]+'-'+m[3]; result.time = m[4]+':'+m[5]; } + } + } + + // GPS IFD + if (ifd0[0x8825]) { + const gpsOff = g32(ifd0[0x8825].valOff); + const gps = readIFD(tiffStart + gpsOff); + const lat = getGpsCoord(gps[0x0002]); + const lng = getGpsCoord(gps[0x0004]); + const latRef = getGpsRef(gps[0x0001]); + const lngRef = getGpsRef(gps[0x0003]); + if (lat !== null && lng !== null) { + result.lat = (latRef === 'S') ? -lat : lat; + result.lng = (lngRef === 'W') ? -lng : lng; + } + } + + return result; +} + +// Thumbnail generation via OffscreenCanvas +async function makeThumbnail(blob, maxDim) { + const bmp = await createImageBitmap(blob); + const s = Math.min(maxDim / bmp.width, maxDim / bmp.height, 1); + const w = Math.round(bmp.width * s), h = Math.round(bmp.height * s); + const canvas = new OffscreenCanvas(w, h); + const ctx = canvas.getContext('2d'); + ctx.drawImage(bmp, 0, 0, w, h); + bmp.close(); + const outBlob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.7 }); + return outBlob; +} + +// Convert blob to data URL +function blobToDataURL(blob) { + return new Promise(r => { + const fr = new FileReader(); + fr.onload = () => r(fr.result); + fr.readAsDataURL(blob); + }); +} + +// Message handler +self.onmessage = async (e) => { + const { id, file, arrayBuffer } = e.data; + try { + const exif = parseExifFromBuffer(arrayBuffer); + const blob = new Blob([arrayBuffer], { type: file.type }); + const [dataUrl, thumbBlob] = await Promise.all([ + blobToDataURL(blob), + makeThumbnail(blob, 200) + ]); + const thumbUrl = await blobToDataURL(thumbBlob); + self.postMessage({ id, ok: true, exif, dataUrl, thumbUrl }); + } catch (err) { + self.postMessage({ id, ok: false, error: err.message }); + } +}; diff --git a/js/photos.js b/js/photos.js index 2f13dcc..69c8af6 100644 --- a/js/photos.js +++ b/js/photos.js @@ -78,8 +78,8 @@ function rebuildPhotoList() { if (b === 'Undated') return -1; return a < b ? -1 : 1; }); - list.innerHTML = ''; _yearEntries = []; + const frag = document.createDocumentFragment(); years.forEach(yr => { const group = document.createElement('div'); group.className = 'year-group'; @@ -91,7 +91,7 @@ function rebuildPhotoList() { body.className = 'year-body'; byYear[yr].forEach(p => body.appendChild(_makeCard(p))); group.appendChild(body); - list.appendChild(group); + frag.appendChild(group); const entry = { yr, group }; hdr.addEventListener('click', () => { hdr.classList.toggle('collapsed'); @@ -101,6 +101,8 @@ function rebuildPhotoList() { }); _yearEntries.push(entry); }); + list.innerHTML = ''; + list.appendChild(frag); if (scrollParent) scrollParent.scrollTop = scrollTop; _syncCollapseBtn('photos'); } @@ -206,11 +208,11 @@ function buildTimeline() { (byYear[y][mk][p.date]=byYear[y][mk][p.date]||[]).push(p); yearCounts[y]=(yearCounts[y]||0)+1; }); - panel.innerHTML=''; + const frag = document.createDocumentFragment(); const tlHdr = document.createElement('div'); tlHdr.style.cssText = 'display:flex;align-items:center;justify-content:space-between'; tlHdr.innerHTML = `
Timeline
`; - panel.appendChild(tlHdr); + frag.appendChild(tlHdr); Object.keys(byYear).sort().forEach(yr=>{ const group = document.createElement('div'); group.className = 'year-group'; @@ -246,8 +248,10 @@ function buildTimeline() { _syncCollapseBtn('timeline'); }); - panel.appendChild(group); + frag.appendChild(group); }); + panel.innerHTML=''; + panel.appendChild(frag); _syncCollapseBtn('timeline'); } function focusTLPhoto(id) { diff --git a/js/pins.js b/js/pins.js index 24c5de6..372c8c6 100644 --- a/js/pins.js +++ b/js/pins.js @@ -173,14 +173,23 @@ function buildClusterIndex() { properties: { id: p.id, lat: p.lat, lng: p.lng, cc: p.countryCode || _geoCodeCache[locKey(p)] || null } }))); - // Ensure icons exist for all pinned photos - representatives.forEach(p => ensurePinIcon(p)); - - _animatingMap = false; - // Remove cluster DOM markers - Object.values(domMarkers).forEach(m => m.remove()); - domMarkers = {}; - _refreshClustersNow(); + // Generate pin icons in batches, yielding between batches to avoid + // blocking the main thread with GPU readbacks (getImageData) + const ICON_BATCH = 20; + let idx = 0; + const processIconBatch = () => { + const end = Math.min(idx + ICON_BATCH, representatives.length); + for (; idx < end; idx++) ensurePinIcon(representatives[idx]); + if (idx < representatives.length) { + requestAnimationFrame(processIconBatch); + } else { + _animatingMap = false; + Object.values(domMarkers).forEach(m => m.remove()); + domMarkers = {}; + _refreshClustersNow(); + } + }; + processIconBatch(); } function refreshClusters() { diff --git a/sw.js b/sw.js index 12daa68..0eead98 100644 --- a/sw.js +++ b/sw.js @@ -17,6 +17,7 @@ const APP_SHELL = [ '/js/modals.js', '/js/search.js', '/js/media.js', + '/js/photo-worker.js', '/js/data.js', '/js/demo.js', '/vendor/maplibre-gl.js',