+
+ Replace photo
Start pair
Use my position
Confirm pair
@@ -60,11 +51,25 @@
1. Import map photo
OpenStreetMap
-
-
+
+
+
+
+ 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.
+
+ Upload map image
+
+
+
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.
Upload map image
-
+
-
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.