diff --git a/README.md b/README.md index a6286f8..40820dc 100644 --- a/README.md +++ b/README.md @@ -67,13 +67,14 @@ * On wake: **Quick refresh** → try `getCurrentPosition` (timeout **3 s**); if none, show **last known** (≤5 min, “stale” badge). Start `watchPosition` thereafter. * Update cadence follows device feed; UI throttles to \~**3–5 s**. +* First GPS fix recenters/zooms the photo and OSM views to the user; later updates keep the camera where the user last left it while markers/accuracy rings continue to update. * **Accuracy ring** around user dot (see §7). --- ## 4) Image handling -* **Import**: Camera (``) or gallery. +* **Import**: Native file picker (``) lets mobile users capture a new photo or pull one from their gallery without leaving the flow. * **Orientation**: Apply EXIF orientation, then **strip EXIF** (privacy). * **Storage**: **Only optimized display** version, long edge ≈ **4096 px**, **WebP/AVIF** (JPEG fallback), sRGB. * **Max per map**: 25–50 MB (config). Oversize → auto-downscale + toast. diff --git a/index.html b/index.html index 177e100..8636148 100644 --- a/index.html +++ b/index.html @@ -10,81 +10,105 @@ -
-
-

Snap2Map

-

- Photograph any trailboard or printed map, drop a few reference pairs, and watch your live GPS position glide across the photo. Works offline, with OpenStreetMap context when you are connected. -

-
- -
-

1. Import map photo

-

Use a sharp, well-lit image. Snap2Map keeps only an optimized copy on device.

- -
- -
-
-
-

2. Create reference pairs

-

Tap “Start pair” and select a pixel on the photo followed by its real-world location on the map.

-
-
- - - - -
+
+
+
+
+

Snap2Map

+

+ Photograph any trailboard or printed map, drop a few reference pairs, and watch your live GPS position glide across the photo. Works offline, with OpenStreetMap context when you are connected. +

+
+
-
-
- - -
-
-
-
+
+
+
+
+
+
+
+ Step 2 + Create reference pairs +
+

+ Tap “Start pair” and select a pixel on the photo followed by its real-world location on the map. +

+
+
+ + + + + +
+
- -
-
+
-
-
-
- No calibration - Add at least two reference pairs to calibrate the photo. +
+
+
+

Reference pairs

+ +
+
+ + + + + + + + + + +
Pixel (x, y)World (lat, lon)ResidualActions
+
-
-
-
-
Import a map photo to get started.
-
- -
-

Reference pairs

-
- - - - - - - - - - -
Pixel (x, y)World (lat, lon)ResidualActions
-
-
+ +
diff --git a/service-worker.js b/service-worker.js index c21d5d2..59282be 100644 --- a/service-worker.js +++ b/service-worker.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'snap2map-shell-v1'; +const CACHE_NAME = 'snap2map-shell-v2'; const SHELL_ASSETS = [ '/', '/index.html', @@ -8,31 +8,91 @@ const SHELL_ASSETS = [ self.addEventListener('install', (event) => { event.waitUntil( - caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_ASSETS)).then(() => self.skipWaiting()), + caches + .open(CACHE_NAME) + .then((cache) => cache.addAll(SHELL_ASSETS)) + .catch(() => null) + .finally(() => self.skipWaiting()), ); }); self.addEventListener('activate', (event) => { event.waitUntil( - caches.keys().then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))).then(() => self.clients.claim()), + caches + .keys() + .then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))) + .then(() => self.clients.claim()), ); }); self.addEventListener('fetch', (event) => { - const { request } = event; - if (request.method !== 'GET') { + if (event.request.method !== 'GET') { return; } + + const url = new URL(event.request.url); + const isSameOrigin = url.origin === self.location.origin; + const isNavigation = event.request.mode === 'navigate'; + const isShellResource = isSameOrigin && SHELL_ASSETS.includes(url.pathname); + + if (!isSameOrigin) { + return; + } + event.respondWith( - caches.match(request).then((cached) => { + caches.open(CACHE_NAME).then(async (cache) => { + const cached = await cache.match(event.request); + + const fetchAndUpdate = async () => { + const response = await fetch(event.request); + if (response && response.ok) { + cache.put(event.request, response.clone()); + } + return response; + }; + + const getNavigationFallback = async () => { + const fallback = (await cache.match('/index.html')) || (await cache.match('/')); + return fallback || null; + }; + + if (isNavigation || isShellResource) { + try { + const response = await fetchAndUpdate(); + if (response) { + return response; + } + } catch (error) { + // network request failed, fall back to cache if possible + } + + if (cached) { + return cached; + } + + if (isNavigation) { + const fallback = await getNavigationFallback(); + if (fallback) { + return fallback; + } + } + + return Response.error(); + } + if (cached) { + fetchAndUpdate().catch(() => null); return cached; } - return fetch(request).then((response) => { - const clone = response.clone(); - caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)); - return response; - }); - }).catch(() => caches.match('/index.html')), + + try { + return await fetchAndUpdate(); + } catch (error) { + if (cached) { + return cached; + } + return Response.error(); + } + }), ); }); diff --git a/src/index.js b/src/index.js index 6be2c58..47924ab 100644 --- a/src/index.js +++ b/src/index.js @@ -30,6 +30,8 @@ const state = { geoWatchId: null, lastPosition: null, lastGpsUpdate: null, + photoPendingCenter: false, + osmPendingCenter: false, // Prompt geolocation when OSM tab opened the first time osmGeoPrompted: false, guidedPairing: { @@ -187,6 +189,15 @@ function updateStatusText() { } } +function setPhotoImportState(hasImage) { + if (dom.photoPlaceholder) { + dom.photoPlaceholder.classList.toggle('hidden', hasImage); + } + if (dom.replacePhotoButton) { + dom.replacePhotoButton.classList.toggle('hidden', !hasImage); + } +} + function clearMarkers(markers) { markers.forEach((marker) => marker.remove()); return []; @@ -234,18 +245,18 @@ function renderPairList() { state.pairs.forEach((pair, index) => { const row = document.createElement('tr'); - row.className = index % 2 === 0 ? 'bg-white' : 'bg-gray-50'; + row.className = index % 2 === 0 ? 'bg-slate-900/40' : 'bg-slate-900/20'; const residual = state.calibration && state.calibration.residuals ? state.calibration.residuals[index] : null; const inlier = state.calibration && state.calibration.inliers ? state.calibration.inliers[index] : false; const indicatorClass = !state.calibration ? 'bg-blue-500' : inlier ? 'bg-green-500' : 'bg-red-500'; const indicator = ``; row.innerHTML = ` - ${indicator}${pair.pixel.x.toFixed(1)}, ${pair.pixel.y.toFixed(1)} - ${formatLatLon(pair.wgs84.lat, 'N', 'S')} · ${formatLatLon(pair.wgs84.lon, 'E', 'W')} - ${residual !== null && residual !== undefined ? `${residual.toFixed(1)} m` : '—'} - - + ${indicator}${pair.pixel.x.toFixed(1)}, ${pair.pixel.y.toFixed(1)} + ${formatLatLon(pair.wgs84.lat, 'N', 'S')} · ${formatLatLon(pair.wgs84.lon, 'E', 'W')} + ${residual !== null && residual !== undefined ? `${residual.toFixed(1)} m` : '—'} + + `; dom.pairTableBody.appendChild(row); @@ -264,7 +275,7 @@ function updateGpsStatus(message, isError) { return; } dom.gpsStatus.textContent = message; - dom.gpsStatus.className = isError ? 'text-sm text-red-600' : 'text-sm text-slate-600'; + dom.gpsStatus.className = isError ? 'text-sm text-rose-400' : 'text-sm text-slate-200'; } function updateLivePosition() { @@ -314,6 +325,11 @@ function updateLivePosition() { ensureUserMarker(latlng); + if (state.photoPendingCenter && state.photoMap) { + state.photoMap.panTo(latlng, { animate: true }); + state.photoPendingCenter = false; + } + const ring = accuracyRingRadiusPixels(state.calibration, location, coords.accuracy || 50); updateAccuracyCircle(latlng, ring); @@ -332,12 +348,15 @@ function startGeolocationWatch() { } updateGpsStatus('Waiting for location fix…', false); + state.photoPendingCenter = true; + state.osmPendingCenter = true; state.geoWatchId = navigator.geolocation.watchPosition( (position) => { state.lastPosition = position; state.lastGpsUpdate = Date.now(); updateGpsStatus(`Live position · accuracy ±${Math.round(position.coords.accuracy)} m`, false); + maybeCenterOsmOnFix(position.coords.latitude, position.coords.longitude); updateLivePosition(); updateStatusText(); }, @@ -652,6 +671,8 @@ function loadPhotoMap(dataUrl, width, height) { state.photoMap.setMaxBounds(bounds); state.photoMap.fitBounds(bounds); + setPhotoImportState(true); + state.imageDataUrl = dataUrl; state.imageSize = { width, height }; state.pairs = []; @@ -670,6 +691,16 @@ function loadPhotoMap(dataUrl, width, height) { updateStatusText(); updateGpsStatus('Photo loaded. Guided pairing active — follow the prompts.', false); startGuidedPairing(); + + if (dom.mapImageInput) { + dom.mapImageInput.value = ''; + } + + requestAnimationFrame(() => { + if (state.photoMap) { + state.photoMap.invalidateSize(); + } + }); } function handleImageImport(event) { @@ -764,6 +795,7 @@ function setupMaps() { state.lastGpsUpdate = now; updateGpsStatus(`Live position · accuracy ±${Math.round(event.accuracy)} m`, false); updateStatusText(); + maybeCenterOsmOnFix(event.latlng.lat, event.latlng.lng); updateLivePosition(); }; @@ -779,16 +811,18 @@ function setupMaps() { if (L.control && typeof L.control.locate === 'function') { const locateControl = L.control.locate({ position: 'topleft', - setView: 'always', + setView: false, flyTo: false, cacheLocation: true, showPopup: false, }); state.osmLocateControl = locateControl.addTo(state.osmMap); + state.osmPendingCenter = true; try { updateGpsStatus('Locating your position…', false); + state.osmPendingCenter = true; state.osmLocateControl.start(); } catch (error) { console.warn('Failed to start locate control', error); @@ -805,9 +839,18 @@ function centerOsmOnLatLon(lat, lon) { state.osmMap.setView(latlng, targetZoom); } +function maybeCenterOsmOnFix(lat, lon) { + if (!state.osmPendingCenter) { + return; + } + centerOsmOnLatLon(lat, lon); + state.osmPendingCenter = false; +} + function requestAndCenterOsmOnUser() { if (state.osmLocateControl) { try { + state.osmPendingCenter = true; state.osmLocateControl.start(); } catch (error) { console.warn('Failed to trigger locate control', error); @@ -821,6 +864,7 @@ function requestAndCenterOsmOnUser() { (pos) => { updateGpsStatus(`Centered on your location (±${Math.round(pos.coords.accuracy)} m)`, false); centerOsmOnLatLon(pos.coords.latitude, pos.coords.longitude); + state.osmPendingCenter = false; }, () => { // ignore errors – keep default view @@ -835,6 +879,7 @@ function maybePromptGeolocationForOsm() { if (state.lastPosition && Date.now() - (state.lastGpsUpdate || 0) <= 5_000) { const { latitude, longitude } = state.lastPosition.coords; centerOsmOnLatLon(latitude, longitude); + state.osmPendingCenter = false; // continue so we also keep the locate control active for future updates } @@ -917,6 +962,7 @@ function registerServiceWorker() { function cacheDom() { dom.mapImageInput = $('mapImageInput'); + dom.photoPlaceholder = $('photoPlaceholder'); dom.addPairButton = $('addPairButton'); dom.usePositionButton = $('usePositionButton'); dom.confirmPairButton = $('confirmPairButton'); @@ -934,6 +980,7 @@ function cacheDom() { dom.osmTabButton = $('osmTabButton'); dom.pairTable = $('pairTable'); dom.toastContainer = $('toastContainer'); + dom.replacePhotoButton = $('replacePhotoButton'); } function setupEventHandlers() { @@ -955,6 +1002,7 @@ function setupEventHandlers() { function init() { cacheDom(); + setPhotoImportState(false); setupEventHandlers(); setupMaps(); setActiveView('photo'); diff --git a/src/index.locate.test.js b/src/index.locate.test.js index bf569d8..b56d434 100644 --- a/src/index.locate.test.js +++ b/src/index.locate.test.js @@ -66,7 +66,7 @@ describe('OpenStreetMap locate control integration', () => { expect(global.L.control.locate).toHaveBeenCalledWith( expect.objectContaining({ - setView: 'always', + setView: false, flyTo: false, cacheLocation: true, showPopup: false, @@ -90,6 +90,7 @@ describe('OpenStreetMap locate control integration', () => { const fixedNow = 1700000000000; jest.spyOn(Date, 'now').mockReturnValue(fixedNow); + const preFixCalls = mapInstance.setView.mock.calls.length; const handler = mapHandlers.locationfound; expect(handler).toBeInstanceOf(Function); @@ -108,10 +109,37 @@ describe('OpenStreetMap locate control integration', () => { timestamp: fixedNow, }); expect(state.lastGpsUpdate).toBe(fixedNow); + expect(mapInstance.setView.mock.calls.length).toBe(preFixCalls + 1); Date.now.mockRestore(); }); + it('only recenters the OSM map on the first fix while locating', () => { + loadModule(); + + setupMaps(); + + const handler = mapHandlers.locationfound; + expect(handler).toBeInstanceOf(Function); + + const initialCalls = mapInstance.setView.mock.calls.length; + + handler({ + latlng: { lat: 34.05, lng: -118.25 }, + accuracy: 6.2, + }); + + const afterFirstFix = mapInstance.setView.mock.calls.length; + expect(afterFirstFix).toBe(initialCalls + 1); + + handler({ + latlng: { lat: 34.051, lng: -118.251 }, + accuracy: 6.0, + }); + + expect(mapInstance.setView.mock.calls.length).toBe(afterFirstFix); + }); + it('does not rely on the locate control exposing an event API', () => { loadModule({ includeLocateControlOn: false });