From 746393367066f81b6d3060e9734059845565acd6 Mon Sep 17 00:00:00 2001 From: CsUtil <45512166+cs-util@users.noreply.github.com> Date: Sun, 28 Sep 2025 07:56:27 +0000 Subject: [PATCH 1/6] feat: update UI styles for pair list and GPS status display --- index.html | 153 +++++++++++++++++++++++++++++---------------------- src/index.js | 14 ++--- 2 files changed, 93 insertions(+), 74 deletions(-) diff --git a/index.html b/index.html index 177e100..e8c91cc 100644 --- a/index.html +++ b/index.html @@ -10,81 +10,100 @@ -
-
-

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/src/index.js b/src/index.js index 6be2c58..951be6d 100644 --- a/src/index.js +++ b/src/index.js @@ -234,18 +234,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 +264,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() { From 571acc68953368a0ad469871c2d4fe85fbf9dcd3 Mon Sep 17 00:00:00 2001 From: CsUtil <45512166+cs-util@users.noreply.github.com> Date: Mon, 29 Sep 2025 07:21:00 +0000 Subject: [PATCH 2/6] Moved step 1 back to top --- index.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/index.html b/index.html index e8c91cc..e925ad5 100644 --- a/index.html +++ b/index.html @@ -10,8 +10,8 @@ -
- +
From b889e103c7db1cac30eaa78e97ccfaec98cddb40 Mon Sep 17 00:00:00 2001 From: CsUtil <45512166+cs-util@users.noreply.github.com> Date: Mon, 29 Sep 2025 07:45:38 +0000 Subject: [PATCH 3/6] further ux improvements --- index.html | 35 ++++++++++++++++++++--------------- src/index.js | 24 ++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 15 deletions(-) diff --git a/index.html b/index.html index e925ad5..66fb2fd 100644 --- a/index.html +++ b/index.html @@ -12,23 +12,13 @@
-
+

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.

- -

Tip: On mobile you can snap a picture directly using your camera.

-
@@ -46,7 +36,8 @@

1. Import map photo

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

-
+
+ @@ -60,11 +51,25 @@

1. Import map photo

-
-
+
+
+
+
+ Step 1 +

Import map photo

+
+

Choose a sharp, well-lit image of your trailboard or printed map. Snap2Map keeps only an optimized copy on your device.

+ +

Tip: On mobile you can snap a picture directly using your camera.

+
+
+
diff --git a/src/index.js b/src/index.js index 951be6d..8eae23e 100644 --- a/src/index.js +++ b/src/index.js @@ -187,6 +187,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 []; @@ -652,6 +661,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 +681,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) { @@ -917,6 +938,7 @@ function registerServiceWorker() { function cacheDom() { dom.mapImageInput = $('mapImageInput'); + dom.photoPlaceholder = $('photoPlaceholder'); dom.addPairButton = $('addPairButton'); dom.usePositionButton = $('usePositionButton'); dom.confirmPairButton = $('confirmPairButton'); @@ -934,6 +956,7 @@ function cacheDom() { dom.osmTabButton = $('osmTabButton'); dom.pairTable = $('pairTable'); dom.toastContainer = $('toastContainer'); + dom.replacePhotoButton = $('replacePhotoButton'); } function setupEventHandlers() { @@ -955,6 +978,7 @@ function setupEventHandlers() { function init() { cacheDom(); + setPhotoImportState(false); setupEventHandlers(); setupMaps(); setActiveView('photo'); From ae27a9899347065bc008efa6d56538618ea3afea Mon Sep 17 00:00:00 2001 From: CsUtil <45512166+cs-util@users.noreply.github.com> Date: Mon, 29 Sep 2025 18:41:24 +0000 Subject: [PATCH 4/6] feat: enhance GPS functionality to center maps on first fix and improve state management --- README.md | 1 + src/index.js | 26 +++++++++++++++++++++++++- src/index.locate.test.js | 30 +++++++++++++++++++++++++++++- 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a6286f8..d75b8f4 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ * 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). --- diff --git a/src/index.js b/src/index.js index 8eae23e..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: { @@ -323,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); @@ -341,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(); }, @@ -785,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(); }; @@ -800,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); @@ -826,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); @@ -842,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 @@ -856,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 } 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 }); From 8fd42aa312ef147c9880474ad11ec3d32be0543c Mon Sep 17 00:00:00 2001 From: CsUtil <45512166+cs-util@users.noreply.github.com> Date: Mon, 29 Sep 2025 18:45:30 +0000 Subject: [PATCH 5/6] feat: update image import instructions for mobile users to enhance usability --- README.md | 2 +- index.html | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d75b8f4..40820dc 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,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 66fb2fd..8636148 100644 --- a/index.html +++ b/index.html @@ -61,9 +61,9 @@

Import map photo

Choose a sharp, well-lit image of your trailboard or printed map. Snap2Map keeps only an optimized copy on your device.

-

Tip: On mobile you can snap a picture directly using your camera.

+

Tip: Mobile browsers let you take a new photo or pick one from your gallery when you tap the button above.

From 656f9c8d77824e11ab13fe0f5ad80ad2ad43e504 Mon Sep 17 00:00:00 2001 From: CsUtil <45512166+cs-util@users.noreply.github.com> Date: Mon, 29 Sep 2025 18:53:23 +0000 Subject: [PATCH 6/6] feat: update service worker cache version and improve fetch handling logic --- service-worker.js | 84 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 72 insertions(+), 12 deletions(-) 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(); + } + }), ); });