Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9642113
feat: implement scale management utilities and extend application sta…
cs-util Dec 21, 2025
3c22454
feat: add scale and measure mode buttons with functionality for setti…
cs-util Dec 21, 2025
80d3fad
feat: extract scale and measure mode logic into dedicated state machi…
cs-util Dec 21, 2025
437aeac
feat: complete UI layer integration for reference distance feature wi…
cs-util Dec 21, 2025
047446c
feat: replaced the native prompt() with a custom modal dialog - add d…
cs-util Dec 21, 2025
50f005d
feat: refactoring by adding CSS classes to the HTML - add custom styl…
cs-util Dec 21, 2025
6e5315d
feat: refactor measure mode drag handling - extract drag event logic …
cs-util Dec 21, 2025
8c0efca
feat: remove unused API exports from scale and scale-mode modules for…
cs-util Dec 21, 2025
4e0f115
feat: remove 'Ft & In' option from distance selection for simplified …
cs-util Dec 21, 2025
7caf5fa
feat: streamline distance input handling - remove validation logic an…
cs-util Dec 21, 2025
bb758af
feat: implement convertToMeters function - add distance conversion ut…
cs-util Dec 22, 2025
8f84fdc
feat: remove Escape key handling from distance input modal for stream…
cs-util Dec 22, 2025
f402fbf
feat: refactor geolocation handling and improve user marker accuracy …
cs-util Dec 22, 2025
646e385
feat: enhance service worker fetch handling and add calibration readi…
cs-util Dec 22, 2025
0a1418b
feat: enhance distance input validation to support unit conversion
cs-util Dec 22, 2025
e649d37
feat: improve response handling in fetch navigation logic
cs-util Dec 22, 2025
63c4945
feat: adjust distance label positioning for improved visibility
cs-util Dec 22, 2025
f16b3ec
State Initialization: Updated the state object to use the new nested …
cs-util Dec 22, 2025
e225a41
feat: refactor color management by introducing COLORS object for cons…
cs-util Dec 22, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 103 additions & 13 deletions docs/feat-reference-distances.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,22 +289,112 @@ This property is now documented in the test suite to prevent future confusion.

---

### 🔲 Phase 2: UI Layer (Not Started)
### Phase 1.5: Scale Helpers & State Extension (Completed)

This phase implements the user-facing features: setting a reference distance and measuring arbitrary distances.
**Date:** 2025-12-21

This phase implements the pure utility functions for scale management and extends the application state.

#### `src/scale/scale.js` (New Module)
- [x] Created new `src/scale/` directory with `scale.js` module
- [x] Implemented `computeReferenceScale(p1, p2, meters)` - computes m/px from two points and known distance
- [x] Implemented `getMetersPerPixelFromCalibration(calibrationResult)` - extracts scale from calibration
- [x] Implemented `getActiveScale(state)` - determines active scale source (manual > GPS priority)
- [x] Implemented `measureDistance(p1, p2, metersPerPixel)` - calculates distance between pixel points
- [x] Implemented `formatDistance(meters, unit)` - formats for display (m, cm, mm, ft, ft-in)
- [x] Implemented `compareScales(scale1, scale2, threshold)` - detects scale disagreement
- [x] Exported constants: `METERS_PER_DEGREE_EQUATOR`, `METERS_TO_FEET`

#### `src/scale/scale.test.js`
- [x] 52 comprehensive unit tests covering all functions
- [x] 100% statement and branch coverage
- [x] Edge cases: invalid inputs, null/undefined, coincident points, boundary conditions

#### `src/index.js`
- [ ] Add `referenceDistance` to application state
- [ ] Implement "Set Scale" mode (`startReferenceMode()`)
- Two-tap workflow to define reference line
- Input dialog for distance in meters
- Visual feedback with dashed line and label
- [ ] Implement "Measure" mode (`startMeasureMode()`)
- Two-tap workflow to draw measurement line
- Real-time distance calculation using `metersPerPixel`
- Draggable endpoints for refinement
- [ ] Add UI buttons to toolbar
- [ ] Persistence of `referenceDistance` to IndexedDB
- [x] Added `referenceDistance: null` to state object (structure: `{ p1, p2, meters, metersPerPixel }`)
- [x] Added `preferredUnit: 'm'` to state object for user's display preference

**Test Results:** 99 tests pass, 98.6% overall coverage (scale module: 100%)

---

### ✅ Phase 1.6: State Machine Extraction (Completed)

**Date:** 2025-12-21

This phase extracts testable state machine logic from the UI layer into pure functions.

#### `src/scale/scale-mode.js` (New Module)
- [x] Created `scale-mode.js` with pure state machine functions
- [x] Extracted shared `handleTwoPointModeClick()` helper (eliminates duplication)

**Scale Mode Functions:**
- [x] `createScaleModeState()` - Creates initial state
- [x] `startScaleModeState(currentState)` - Activates scale mode
- [x] `handleScaleModePoint(currentState, point)` - Processes point clicks
- [x] `validateDistanceInput(input, unit)` - Validates user's distance input
- [x] `computeReferenceDistanceFromInput(scaleModeState, meters)` - Computes reference distance
- [x] `cancelScaleModeState()` - Resets state

**Measure Mode Functions:**
- [x] `createMeasureModeState()` - Creates initial state
- [x] `canStartMeasureMode(appState)` - Checks if scale is available
- [x] `startMeasureModeState(currentState)` - Activates measure mode
- [x] `handleMeasureModePoint(currentState, point)` - Processes point clicks
- [x] `updateMeasureModePoint(currentState, pointId, newPoint)` - Handles drag updates
- [x] `computeMeasurement(measureState, appState)` - Computes distance
- [x] `cancelMeasureModeState()` - Resets state

**UI State Derivation:**
- [x] `shouldEnableMeasureButton(appState, measureModeState)` - Button enablement logic
- [x] `shouldEnableSetScaleButton(scaleModeState)` - Button enablement logic

#### `src/scale/scale-mode.test.js`
- [x] 48 comprehensive unit tests
- [x] 100% statement and branch coverage
- [x] Tests state transitions, validation, error handling, UI derivation

**Test Results:** 147 tests pass, scale modules at 100% coverage, zero code duplication

---

### ✅ Phase 2: UI Layer (Complete)

**Date:** 2025-12-21

This phase implements the user-facing features and refactors `index.js` to use the testable state machine.

#### `src/index.js` (UI Integration)
- [x] Updated imports to use `scale-mode.js` functions
- [x] Refactored `startScaleMode()` to use `startScaleModeState()`
- [x] Refactored `cancelScaleMode()` to use `cancelScaleModeState()`
- [x] Refactored `handleScaleModeClick()` to use `handleScaleModePoint()` with action dispatch
- [x] Refactored `promptForReferenceDistance()` to use `validateDistanceInput()` and `computeReferenceDistanceFromInput()`
- [x] Refactored `startMeasureMode()` to use `canStartMeasureMode()` and `startMeasureModeState()`
- [x] Refactored `cancelMeasureMode()` to use `cancelMeasureModeState()`
- [x] Refactored `handleMeasureModeClick()` to use `handleMeasureModePoint()` with action dispatch
- [x] Refactored `updateMeasureLabel()` to use `computeMeasurement()`
- [x] Refactored `updateMeasureButtonState()` to use `shouldEnableMeasureButton()`
- [x] Drag handlers use `updateMeasureModePoint()` for state updates

#### Architecture Pattern
The refactored code follows an **action-based dispatch pattern**:
1. User interaction (click/drag) → extract pixel coordinates
2. Call pure state machine function → returns `{ state, action }`
3. Update logical state with `Object.assign(state.mode, newState)`
4. Dispatch UI side effects based on `action` string ('show-p2-toast', 'prompt-distance', 'measurement-complete')

This separates:
- **Testable logic** (in `scale-mode.js`) - state transitions, validation, computations
- **UI effects** (in `index.js`) - Leaflet markers, toasts, DOM updates

**Test Results:** 147 tests pass, 100% coverage on scale modules, zero code duplication, all quality checks pass

---

### 🔲 Phase 3: Remaining Work

#### Not Yet Implemented
- [ ] Persistence of `referenceDistance` to IndexedDB
- [ ] Scale disagreement warning (when GPS and manual scales differ by >10%)
- [ ] Unit selection UI (m/ft/ft-in preference)
70 changes: 70 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,31 @@
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha384-sHL9NAb7lN7rfvG5lfHpm643Xkcjzp4jFvuavGOndn6pjVqS6ny56CAt3nsEVT4H" crossorigin="anonymous">
<link rel="stylesheet" href="https://unpkg.com/leaflet.locatecontrol/dist/L.Control.Locate.min.css" crossorigin="anonymous">
<script src="https://cdn.tailwindcss.com"></script>
<style>
/* Custom marker and label styles for scale/measure features */
.scale-marker-dot {
width: 14px;
height: 14px;
border: 2px solid white;
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.distance-label {
color: white;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
transform: translate(-50%, -100%);
margin-top: -4px;
}
.distance-label--measure {
padding: 3px 10px;
font-size: 13px;
}
</style>
</head>
<body class="bg-slate-950 text-slate-100 min-h-screen">
<div class="min-h-screen flex flex-col lg:items-stretch">
Expand Down Expand Up @@ -42,6 +67,9 @@ <h1 class="text-4xl font-black tracking-tight">Snap2Map</h1>
<button id="usePositionButton" class="px-4 py-2 rounded-lg bg-emerald-600 text-white text-sm font-semibold hover:bg-emerald-500 transition">Use my position</button>
<button id="confirmPairButton" class="px-4 py-2 rounded-lg bg-violet-600 text-white text-sm font-semibold opacity-80 disabled:opacity-40 disabled:cursor-not-allowed hover:bg-violet-500 transition" disabled>Confirm pair</button>
<button id="cancelPairButton" class="px-4 py-2 rounded-lg bg-slate-700 text-white text-sm font-semibold opacity-80 disabled:opacity-40 disabled:cursor-not-allowed hover:bg-slate-600 transition" disabled>Cancel</button>
<span class="hidden sm:inline-block w-px h-6 bg-slate-600 self-center"></span>
<button id="setScaleButton" class="px-4 py-2 rounded-lg bg-teal-600 text-white text-sm font-semibold hover:bg-teal-500 transition" title="Set a known distance on the photo to define the scale">📏 Set Scale</button>
<button id="measureButton" class="px-4 py-2 rounded-lg bg-purple-600 text-white text-sm font-semibold hover:bg-purple-500 disabled:opacity-40 disabled:cursor-not-allowed transition" disabled title="Measure distances on the photo">📐 Measure</button>
</div>
</div>
</div>
Expand Down Expand Up @@ -113,6 +141,48 @@ <h2 class="text-lg font-semibold text-slate-100">Reference pairs</h2>

<div id="toastContainer" class="fixed bottom-6 left-1/2 -translate-x-1/2 md:left-auto md:right-8 md:translate-x-0 z-50 space-y-2 w-[calc(100%-2rem)] max-w-sm pointer-events-none"></div>

<!-- Distance Input Modal -->
<div id="distanceModal" class="fixed inset-0 z-50 hidden items-center justify-center bg-slate-950/80 backdrop-blur-sm" role="dialog" aria-modal="true" aria-labelledby="distanceModalTitle">
<div class="w-full max-w-sm mx-4 bg-slate-900 border border-slate-700 rounded-2xl shadow-2xl overflow-hidden">
<div class="px-6 py-5 border-b border-slate-800">
<h2 id="distanceModalTitle" class="text-lg font-semibold text-slate-100">Enter Reference Distance</h2>
<p class="text-sm text-slate-400 mt-1">Specify the real-world distance between the two points.</p>
</div>
<div class="px-6 py-5 space-y-4">
<div class="flex gap-3">
<div class="flex-1">
<label for="distanceInput" class="block text-sm font-medium text-slate-300 mb-2">Distance</label>
<input
id="distanceInput"
type="number"
step="any"
min="0"
inputmode="decimal"
placeholder="e.g. 10"
class="w-full px-4 py-2.5 rounded-lg bg-slate-800 border border-slate-600 text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
autocomplete="off"
>
</div>
<div class="w-28">
<label for="distanceUnit" class="block text-sm font-medium text-slate-300 mb-2">Unit</label>
<select
id="distanceUnit"
class="w-full px-3 py-2.5 rounded-lg bg-slate-800 border border-slate-600 text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="m">Meters</option>
<option value="ft">Feet</option>
</select>
</div>
</div>
<p id="distanceError" class="text-sm text-rose-400 hidden">Please enter a valid positive number.</p>
</div>
<div class="px-6 py-4 bg-slate-900/50 border-t border-slate-800 flex justify-end gap-3">
<button id="distanceCancelBtn" type="button" class="px-4 py-2 rounded-lg bg-slate-700 text-white text-sm font-semibold hover:bg-slate-600 transition">Cancel</button>
<button id="distanceConfirmBtn" type="button" class="px-4 py-2 rounded-lg bg-teal-600 text-white text-sm font-semibold hover:bg-teal-500 transition">Confirm</button>
</div>
</div>
</div>

<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha384-cxOPjt7s7Iz04uaHJceBmS+qpjv2JkIHNVcuOrM+YHwZOmJGBXI00mdUXEq65HTH" crossorigin="anonymous"></script>
<script src="https://unpkg.com/leaflet.locatecontrol/dist/L.Control.Locate.min.js" crossorigin="anonymous"></script>
<script type="importmap">
Expand Down
88 changes: 45 additions & 43 deletions service-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,73 +25,75 @@ self.addEventListener('activate', (event) => {
);
});

async function fetchAndUpdate(request, cache) {
const response = await fetch(request);
if (response && response.ok) {
cache.put(request, response.clone());
}
return response;
}

async function getNavigationFallback(cache) {
const fallback = (await cache.match('/index.html')) || (await cache.match('/'));
return fallback || null;
}

async function handleShellOrNavigation(request, cache, cached) {
const isNavigation = request.mode === 'navigate';
try {
const response = await fetchAndUpdate(request, cache);
if (response && response.ok) {
return response;
}
} catch {
// network request failed, fall back to cache if possible
}

if (cached) {
return cached;
}

if (isNavigation) {
const fallback = await getNavigationFallback(cache);
if (fallback) {
return fallback;
}
}

return Response.error();
}

self.addEventListener('fetch', (event) => {
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;
}

const isNavigation = event.request.mode === 'navigate';
const isShellResource = SHELL_ASSETS.includes(url.pathname);

event.respondWith(
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 {
// 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();
return handleShellOrNavigation(event.request, cache, cached);
}

if (cached) {
fetchAndUpdate().catch(() => null);
fetchAndUpdate(event.request, cache).catch(() => null);
return cached;
}

try {
return await fetchAndUpdate();
return await fetchAndUpdate(event.request, cache);
} catch {
if (cached) {
return cached;
}
return Response.error();
return cached || Response.error();
}
}),
);
Expand Down
Loading