Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions css/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
7 changes: 6 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,12 @@

</div>
<div id="map"></div>
<div id="map-loading"><div class="map-loading-spinner"></div></div>
<div id="map-loading">
<div class="map-loading-inner">
<div class="map-loading-spinner"></div>
<div id="map-loading-status">Starting…</div>
</div>
</div>
<div id="esri-attribution">&copy; <a href="https://www.esri.com/" target="_blank">Esri</a></div>
</div>
</div>
Expand Down
45 changes: 33 additions & 12 deletions js/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand All @@ -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); }
Expand Down
26 changes: 20 additions & 6 deletions js/map.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '⛰ <input type="range" id="exaggeration-slider" min="1" max="3" step="0.1" value="1.5" style="width:70px;accent-color:#fff;vertical-align:middle"> <span id="exaggeration-val">1.5×</span>';
document.getElementById('map').appendChild(exaggerationEl);
document.getElementById('exaggeration-slider').addEventListener('input', (e) => {
Expand All @@ -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';
Expand Down Expand Up @@ -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',
Expand All @@ -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 {
Expand Down
176 changes: 3 additions & 173 deletions js/media.js
Original file line number Diff line number Diff line change
@@ -1,186 +1,16 @@

// ═══════════════════════════════════════
// 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 = {};
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];
Expand Down
Loading
Loading