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);