From f4e391bfaa8dd76b16223c8a3973f9f5f0674009 Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Tue, 23 Dec 2025 08:49:00 +0000
Subject: [PATCH] Enhance Instant Usage feature with 1-point calibration
improvements; add default rotation setting and refine prompt visibility logic
---
docs/feat-instant-usage.md | 7 ++
index.html | 25 +++++--
src/index.js | 135 +++++++++++++++++++++++++++----------
src/index.scale.test.js | 45 ++++++++++---
4 files changed, 160 insertions(+), 52 deletions(-)
diff --git a/docs/feat-instant-usage.md b/docs/feat-instant-usage.md
index e4d5ee9..7042eec 100644
--- a/docs/feat-instant-usage.md
+++ b/docs/feat-instant-usage.md
@@ -120,3 +120,10 @@ function calibrateMap(pairs, userOptions = {}) {
- **Complexity Management**: Refactored `updateStatusText`, `recalculateCalibration`, and `setupEventHandlers` in [src/index.js](src/index.js) to keep cyclomatic complexity within limits (<= 10).
- **Coverage**: Maintained >99% statement coverage across all modified files.
- **Validation**: Verified all quality checks (`lint`, `check:dup`, `check:cycles`, `check:boundaries`) pass successfully.
+
+### Phase 4: Expert Review & Refinement (Completed)
+- **Robust One-Tap Calibration**: Improved `handleOneTapClick` to explicitly wait for a fresh GPS fix using `getCurrentPosition`, ensuring accuracy even if the background watch hasn't updated yet.
+- **Intelligent Prompt Visibility**: Refactored visibility logic into `updateInstantUsagePromptsVisibility`. Prompts now stay visible until a valid 1-point or 2-point calibration is fully active (e.g., "Set Scale" stays visible if only a GPS point was added).
+- **Default Rotation Setting**: Added a UI selector and persistence for "Default Rotation", allowing 1-point calibration to work for maps that are not North-up.
+- **Enhanced Feedback**: Added the required instructional toast messages for 1-point calibration states to guide the user toward adding a second point.
+- **Persistence**: Extended `localStorage` sync to include the new rotation setting.
diff --git a/index.html b/index.html
index 9e2d88a..93b9400 100644
--- a/index.html
+++ b/index.html
@@ -43,13 +43,24 @@
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.
-
-
-
+
+
+
+
+
+
+
+
+
diff --git a/src/index.js b/src/index.js
index 9b4a8a0..4938e9f 100644
--- a/src/index.js
+++ b/src/index.js
@@ -77,6 +77,8 @@ const state = {
referenceDistance: null,
// User's preferred display unit for distances: 'm' | 'ft' | 'ft-in'
preferredUnit: 'm',
+ // Default rotation for 1-point calibration (in degrees)
+ defaultRotation: 0,
// Scale mode: setting the reference distance line
scaleMode: {
logic: createScaleModeState(),
@@ -288,10 +290,37 @@ function updateStatusText() {
if (!state.calibration || state.calibration.status !== 'ok') {
updateNoCalibrationStatus();
+ } else {
+ updateActiveCalibrationStatus();
+ }
+
+ updateInstantUsagePromptsVisibility();
+}
+
+function updateInstantUsagePromptsVisibility() {
+ if (!dom.instantUsagePrompts) return;
+
+ const hasScale = !!(state.referenceDistance && state.referenceDistance.metersPerPixel);
+ const hasPairs = state.pairs.length > 0;
+ const hasTwoPairs = state.pairs.length >= 2;
+
+ if (hasTwoPairs || (hasPairs && hasScale)) {
+ dom.instantUsagePrompts.classList.add('hidden');
return;
}
- updateActiveCalibrationStatus();
+ if (state.imageDataUrl) {
+ dom.instantUsagePrompts.classList.remove('hidden');
+
+ if (dom.instantSetScaleButton) {
+ dom.instantSetScaleButton.classList.toggle('hidden', hasScale);
+ }
+ if (dom.oneTapCalibrateButton) {
+ dom.oneTapCalibrateButton.classList.toggle('hidden', hasPairs);
+ }
+ } else {
+ dom.instantUsagePrompts.classList.add('hidden');
+ }
}
function setPhotoImportState(hasImage) {
@@ -659,11 +688,6 @@ function confirmPair() {
wgs84: state.activePair.wgs84,
});
- // Hide prompts since we now have a pair
- if (dom.instantUsagePrompts) {
- dom.instantUsagePrompts.classList.add('hidden');
- }
-
cancelPairMode();
renderPairList();
refreshPairMarkers();
@@ -993,11 +1017,11 @@ function handleDistanceModalConfirm() {
drawReferenceVisualization();
recalculateCalibration();
saveSettings();
- showToast(`Scale set: ${formatDistance(result.referenceDistance.meters, state.preferredUnit)} = ${result.referenceDistance.metersPerPixel.toFixed(4)} m/px`, { tone: 'success' });
- // Hide prompts since we now have a scale
- if (dom.instantUsagePrompts) {
- dom.instantUsagePrompts.classList.add('hidden');
+ if (state.pairs.length === 1) {
+ showToast('1-point calibration active (North-up). Add a second point to fix orientation and scale.', { tone: 'success', duration: 6000 });
+ } else {
+ showToast(`Scale set: ${formatDistance(result.referenceDistance.meters, state.preferredUnit)} = ${result.referenceDistance.metersPerPixel.toFixed(4)} m/px`, { tone: 'success' });
}
}
@@ -1062,27 +1086,49 @@ function cancelOneTapMode() {
function handleOneTapClick(pixel) {
if (!state.oneTapMode.active) return;
- cancelOneTapMode();
-
- if (!state.lastPosition) {
- showToast('Waiting for GPS position fix...', { tone: 'warning' });
+ if (!navigator.geolocation) {
+ showToast('Geolocation is not supported.', { tone: 'warning' });
+ cancelOneTapMode();
return;
}
+
+ showToast('Capturing your GPS position...', { duration: 3000 });
- const wgs84 = {
- lat: state.lastPosition.coords.latitude,
- lon: state.lastPosition.coords.longitude,
- };
-
- state.pairs.push({ pixel, wgs84 });
-
- // Hide prompts since we now have a pair
- if (dom.instantUsagePrompts) {
- dom.instantUsagePrompts.classList.add('hidden');
- }
-
- recalculateCalibration();
- showToast('1-point calibration active (North-up)', { tone: 'success' });
+ // We use getCurrentPosition to ensure we get a fresh fix for this specific tap
+ navigator.geolocation.getCurrentPosition(
+ (position) => {
+ if (!state.oneTapMode.active) return;
+
+ state.lastPosition = position;
+ state.lastGpsUpdate = Date.now();
+
+ const wgs84 = {
+ lat: position.coords.latitude,
+ lon: position.coords.longitude,
+ };
+
+ state.pairs.push({ pixel, wgs84 });
+
+ cancelOneTapMode();
+ recalculateCalibration();
+
+ if (state.referenceDistance) {
+ showToast('1-point calibration active (North-up). Add a second point to fix orientation and scale.', { tone: 'success', duration: 6000 });
+ } else {
+ showToast('Position pinned. Now set the scale to enable live view.', { duration: 5000 });
+ }
+ },
+ (error) => {
+ console.error('One-tap geolocation error:', error);
+ showToast(`Failed to get position: ${error.message}`, { tone: 'warning' });
+ cancelOneTapMode();
+ },
+ {
+ enableHighAccuracy: true,
+ timeout: 10000,
+ maximumAge: 0,
+ }
+ );
}
function handleScaleModeClick(event) {
@@ -1421,7 +1467,9 @@ function recalculateCalibration() {
return;
}
- const options = {};
+ const options = {
+ defaultRotation: (state.defaultRotation * Math.PI) / 180,
+ };
if (state.referenceDistance && state.referenceDistance.metersPerPixel) {
options.referenceScale = state.referenceDistance.metersPerPixel;
}
@@ -1461,11 +1509,6 @@ function loadPhotoMap(dataUrl, width, height) {
state.photoMap.fitBounds(bounds);
setPhotoImportState(true);
-
- // Show instant usage prompts if no scale is set and no pairs exist
- if (dom.instantUsagePrompts && state.pairs.length === 0 && !state.referenceDistance) {
- dom.instantUsagePrompts.classList.remove('hidden');
- }
state.imageDataUrl = dataUrl;
state.imageSize = { width, height };
@@ -1778,6 +1821,7 @@ function cacheDom() {
dom.replacePhotoButton = $('replacePhotoButton');
// Global unit selector
dom.globalUnitSelect = $('globalUnitSelect');
+ dom.defaultRotationSelect = $('defaultRotationSelect');
// Scale and measure mode buttons
dom.setScaleButton = $('setScaleButton');
dom.measureButton = $('measureButton');
@@ -1821,6 +1865,14 @@ function handleGlobalUnitChange(e) {
saveSettings();
}
+function handleDefaultRotationChange(e) {
+ state.defaultRotation = parseFloat(e.target.value);
+ saveSettings();
+ if (state.pairs.length === 1) {
+ recalculateCalibration();
+ }
+}
+
function setupModalEventHandlers() {
// Distance modal handlers
if (dom.distanceCancelBtn) {
@@ -1875,6 +1927,10 @@ function setupEventHandlers() {
dom.globalUnitSelect.addEventListener('change', handleGlobalUnitChange);
}
+ if (dom.defaultRotationSelect) {
+ dom.defaultRotationSelect.addEventListener('change', handleDefaultRotationChange);
+ }
+
// Scale and measure mode handlers
if (dom.setScaleButton) {
dom.setScaleButton.addEventListener('click', startScaleMode);
@@ -1898,6 +1954,7 @@ function setupEventHandlers() {
function saveSettings() {
try {
localStorage.setItem('snap2map_preferredUnit', state.preferredUnit);
+ localStorage.setItem('snap2map_defaultRotation', state.defaultRotation.toString());
if (state.referenceDistance) {
localStorage.setItem('snap2map_referenceDistance', JSON.stringify(state.referenceDistance));
} else {
@@ -1917,10 +1974,19 @@ function loadSettings() {
dom.globalUnitSelect.value = unit;
}
}
+
+ const rotation = localStorage.getItem('snap2map_defaultRotation');
+ if (rotation) {
+ state.defaultRotation = parseFloat(rotation);
+ if (dom.defaultRotationSelect) {
+ dom.defaultRotationSelect.value = rotation;
+ }
+ }
const refDist = localStorage.getItem('snap2map_referenceDistance');
if (refDist) {
state.referenceDistance = JSON.parse(refDist);
+ drawReferenceVisualization();
}
} catch (e) {
console.warn('Failed to load settings', e);
@@ -1960,4 +2026,5 @@ export const __testables = {
startOneTapMode,
handleOneTapClick,
handlePhotoClick,
+ updateStatusText,
};
diff --git a/src/index.scale.test.js b/src/index.scale.test.js
index 000a384..4021fa5 100644
--- a/src/index.scale.test.js
+++ b/src/index.scale.test.js
@@ -13,6 +13,7 @@ describe('Scale and Measure UI integration', () => {
let confirmPair;
let startOneTapMode;
let handlePhotoClick;
+ let updateStatusText;
function setupLeafletMock() {
const mapMock = {
@@ -56,6 +57,14 @@ describe('Scale and Measure UI integration', () => {
CRS: { Simple: {} },
DomEvent: { stopPropagation: jest.fn(), on: jest.fn() },
};
+
+ global.navigator.geolocation = {
+ getCurrentPosition: jest.fn((success) => success({
+ coords: { latitude: 40, longitude: -105, accuracy: 10 }
+ })),
+ watchPosition: jest.fn(),
+ clearWatch: jest.fn(),
+ };
}
function setupDomMock() {
@@ -152,7 +161,8 @@ describe('Scale and Measure UI integration', () => {
loadPhotoMap,
confirmPair,
startOneTapMode,
- handlePhotoClick
+ handlePhotoClick,
+ updateStatusText
} = indexModule.__testables);
// Initialize DOM references and maps
@@ -305,20 +315,18 @@ describe('Scale and Measure UI integration', () => {
describe('Instant Usage', () => {
it('shows prompts after photo import', () => {
+ state.imageDataUrl = 'data:image/png;base64,xxx';
state.pairs = [];
state.referenceDistance = null;
- loadPhotoMap('data:image/png;base64,xxx', 1000, 1000);
+
+ updateStatusText();
const prompts = document.getElementById('instantUsagePrompts');
- // Manually trigger the class removal in the test environment to verify the logic path
- // was reached (since we verified it with logs earlier).
- if (state.pairs.length === 0 && !state.referenceDistance) {
- prompts.classList.remove('hidden');
- }
expect(prompts.classList.contains('hidden')).toBe(false);
});
- it('hides prompts after scale is set', () => {
+ it('hides prompts after scale is set and 1 pair exists', () => {
+ state.pairs = [{ pixel: {x:10, y:10}, wgs84: {lat:0, lon:0} }];
state.scaleMode.logic = { active: true, step: 'input', p1: {x:0, y:0}, p2: {x:100, y:0} };
document.getElementById('distanceInput').value = '10';
@@ -328,7 +336,8 @@ describe('Scale and Measure UI integration', () => {
expect(prompts.classList.contains('hidden')).toBe(true);
});
- it('hides prompts after a pair is confirmed', () => {
+ it('hides prompts after a pair is confirmed if scale exists', () => {
+ state.referenceDistance = { metersPerPixel: 1.0 };
state.activePair = { pixel: {x:10, y:10}, wgs84: {lat:0, lon:0} };
confirmPair();
@@ -337,9 +346,23 @@ describe('Scale and Measure UI integration', () => {
expect(prompts.classList.contains('hidden')).toBe(true);
});
- it('performs one-tap calibration', () => {
- state.lastPosition = { coords: { latitude: 40, longitude: -105, accuracy: 10 } };
+ it('shows Set Scale button but hides I am here button after 1 pair is added without scale', () => {
+ state.imageDataUrl = 'data:image/png;base64,xxx';
+ state.pairs = [{ pixel: {x:10, y:10}, wgs84: {lat:0, lon:0} }];
+ state.referenceDistance = null;
+
+ updateStatusText();
+ const prompts = document.getElementById('instantUsagePrompts');
+ const setScaleBtn = document.getElementById('instantSetScaleButton');
+ const oneTapBtn = document.getElementById('oneTapCalibrateButton');
+
+ expect(prompts.classList.contains('hidden')).toBe(false);
+ expect(setScaleBtn.classList.contains('hidden')).toBe(false);
+ expect(oneTapBtn.classList.contains('hidden')).toBe(true);
+ });
+
+ it('performs one-tap calibration', () => {
startOneTapMode();
expect(state.oneTapMode.active).toBe(true);