-
-
-
No calibration
-
Add at least two reference pairs to calibrate the photo.
+
+
+
+
Reference pairs
+ Manage or remove points below
+
+
+
+
+
+ Pixel (x, y)
+ World (lat, lon)
+ Residual
+ Actions
+
+
+
+
+
-
-
-
-
Import a map photo to get started.
-
-
-
- Reference pairs
-
-
-
-
- Pixel (x, y)
- World (lat, lon)
- Residual
- Actions
-
-
-
-
-
-
+
+
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` : '—'}
-
- Remove
+ ${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` : '—'}
+
+ Remove
`;
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 });