From f4d9dd621ec479422329fcfb3270cc3dd2e4e6ea Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Sun, 21 Dec 2025 05:36:29 +0000
Subject: [PATCH 01/47] feat: add initial documentation for manual reference
distance feature and its implementation plan
---
docs/feat-reference-distances.md | 115 +++++++++++++++++++++++++++++++
1 file changed, 115 insertions(+)
create mode 100644 docs/feat-reference-distances.md
diff --git a/docs/feat-reference-distances.md b/docs/feat-reference-distances.md
new file mode 100644
index 0000000..5f43015
--- /dev/null
+++ b/docs/feat-reference-distances.md
@@ -0,0 +1,115 @@
+# Initial idea for new feature of Snap2Map
+
+## 1. Core Functional Requirements
+
+* **Manual Reference Distance:** Users can draw a line between two points on the photo/map and assign an explicit metric value (e.g., "This wall is 5 meters").
+* **Derived Measurements:** Once this reference is set, users can measure arbitrary objects in the image (e.g., width of a door), even if those objects had no dimensions in the original plan.
+* **GPS Independence:** This feature must work **autonomously**. The user must be able to measure distances immediately after importing an image, *before* setting a single GPS reference pair.
+* *Flow:* Start App → Import Map → Define Reference Distance → Measure immediately.
+
+## 2. Data Prioritization & "Ground Truth"
+
+* **Reference as "Ground Truth":** The manually entered distance is interpreted as the most reliable truth (weighted higher than GPS data).
+* *Rationale:* Users often transcribe precise measurements from architectural plans or blueprints.
+
+
+* **Accuracy Assumption:** The manual input is assumed to be more precise than GPS position data (which inherently fluctuates), despite potential minor clicking errors.
+* **Conflict Resolution (Harmonization):**
+* The system must harmonize the manual scale (pixels per meter) with the GPS-derived scale.
+* In case of divergence between GPS calculations and the manual reference, the manual reference serves to **validate** or constrain the GPS solution (the manual scale takes precedence for local measurements).
+
+## 3. User Story
+
+* **Scenario:** A user imports a floor plan or house blueprint.
+* **Step 1:** They identify a known dimension on a long wall (e.g., 10m) and mark this as the **Reference Distance**.
+* **Step 2:** They want to know the width of the front door (which has no label on the PDF).
+* **Step 3:** They measure the door on the image, and the app calculates the width based on the wall's reference scale.
+
+## 4. Technical Implications
+
+* **Hybrid State:** The system requires a flexible scaling state. The map scale (meters/pixel) can be defined by:
+1. Purely manual reference distance.
+2. Purely GPS reference pairs.
+3. A combination (where the manual reference acts as an anchor/validator for the GPS fit).
+
+# Refined implementation plan based on the initial idea and current code base
+
+## 1. Data Model & State Management
+
+We need to extend the application state to store the manual reference.
+
+* **New State Property**: `referenceDistance`
+ ```javascript
+ {
+ p1: { x: number, y: number }, // Pixel coordinates
+ p2: { x: number, y: number }, // Pixel coordinates
+ meters: number, // User-defined distance
+ pixelsPerMeter: number // Derived scale: distance(p1, p2) / meters
+ }
+ ```
+* **Persistence**: This should be added to the `maps` store in `IndexedDB` (or the runtime `state` object in `src/index.js` for the MVP) to persist across sessions.
+
+## 2. UI/UX Implementation
+
+### 2.1 Reference Mode ("Set Scale")
+* **Entry Point**: A new "Set Scale" button in the map toolbar (icon: ruler).
+* **Interaction Flow**:
+ 1. User taps "Set Scale".
+ 2. Toast: "Tap start point of known distance".
+ 3. User taps point A on the photo.
+ 4. Toast: "Tap end point".
+ 5. User taps point B on the photo.
+ 6. **Input Dialog**: A prompt appears asking "Enter distance in meters".
+ 7. **Visual Feedback**: A distinct line (e.g., blue dashed) is drawn between A and B with the label "X m".
+* **Edit/Delete**: Tapping the reference line allows editing the value or deleting it.
+
+### 2.2 Measurement Mode
+* **Entry Point**: "Measure" button (icon: tape measure), enabled **only** if `referenceDistance` is set.
+* **Interaction Flow**:
+ 1. User taps "Measure".
+ 2. User taps/drags to draw a temporary line.
+ 3. **Real-time Feedback**: A label on the line shows the distance in meters, calculated as `pixelDistance / state.referenceDistance.pixelsPerMeter`.
+
+## 3. Logic & Calibration Integration
+
+### 3.1 GPS Independence (Phase 1)
+* The `referenceDistance` allows immediate measurement without any GPS pairs.
+* The map remains in "Image Space" (pixels) but with a known scalar for distance.
+* This fulfills the requirement: *Start App → Import Map → Define Reference Distance → Measure immediately.*
+
+### 3.2 Hybrid Calibration (Phase 2)
+* **Conflict Resolution Strategy**: "Constrained Similarity".
+* **Algorithm**:
+ 1. **Standard Fit**: Run the existing RANSAC/IRLS to get a candidate model (Similarity/Affine).
+ 2. **Scale Check**: Calculate the scale of the candidate model ($S_{gps}$).
+ 3. **Comparison**: Compare $S_{gps}$ with $S_{manual}$ (from reference distance).
+ 4. **Harmonization**:
+ * **Similarity (2 pairs)**: If a manual reference exists, we can optionally **force** the scale to $S_{manual}$. This reduces the Similarity transform to finding only Rotation ($R$) and Translation ($t$). This is mathematically more robust when GPS accuracy is low but the manual measurement is trusted.
+ * **Affine/Homography (3+ pairs)**: Use $S_{manual}$ as a **validator**. If the local scale of the GPS-derived transform differs significantly (e.g., > 10%) from $S_{manual}$, show a warning: "GPS scale disagrees with manual reference."
+
+## 4. Code Structure Changes
+
+### `src/index.js`
+* Add `referenceDistance` to `state`.
+* Implement `startReferenceMode()` and `startMeasureMode()`.
+* Handle canvas/overlay drawing for the reference line and active measurement line.
+
+### `src/geo/transformations.js`
+* Implement `fitSimilarityFixedScale(pairs, fixedScale)`:
+ * Standard Procrustes analysis but with $s$ fixed to `fixedScale`.
+ * Solves for rotation $\theta$ and translation $t_x, t_y$ minimizing the error.
+
+### `src/calibration/calibrator.js`
+* Update `calibrateMap` to accept an optional `referenceScale`.
+* If `referenceScale` is provided and the model is 'similarity', use `fitSimilarityFixedScale`.
+
+## 5. Testing Plan
+
+* **Unit Tests (`src/geo/transformations.test.js`)**:
+ * Test `fitSimilarityFixedScale` with synthetic data.
+ * Verify that the resulting transform preserves the input scale exactly.
+* **Integration Tests**:
+ * Verify that setting a reference distance enables the measure tool.
+ * Verify that measurements are accurate based on the reference.
+
+
From 986951f5ca806c2accb36d2cb092f53727a1c26e Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Sun, 21 Dec 2025 05:43:13 +0000
Subject: [PATCH 02/47] refined the spec further: enhance user experience by
adding zoom functionality for setting reference distances and measuring
---
docs/feat-reference-distances.md | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/docs/feat-reference-distances.md b/docs/feat-reference-distances.md
index 5f43015..015a9a8 100644
--- a/docs/feat-reference-distances.md
+++ b/docs/feat-reference-distances.md
@@ -57,6 +57,8 @@ We need to extend the application state to store the manual reference.
1. User taps "Set Scale".
2. Toast: "Tap start point of known distance".
3. User taps point A on the photo.
+ * He should be able to zoom in on the image while doing these tabs, to place the points as accurate as possible.
+ * So zooming in general via pinch gestures (or mouse wheel on desktop) in the image would be an important UX to have in general in the app (in case it does not exist yet)
4. Toast: "Tap end point".
5. User taps point B on the photo.
6. **Input Dialog**: A prompt appears asking "Enter distance in meters".
@@ -67,8 +69,10 @@ We need to extend the application state to store the manual reference.
* **Entry Point**: "Measure" button (icon: tape measure), enabled **only** if `referenceDistance` is set.
* **Interaction Flow**:
1. User taps "Measure".
- 2. User taps/drags to draw a temporary line.
- 3. **Real-time Feedback**: A label on the line shows the distance in meters, calculated as `pixelDistance / state.referenceDistance.pixelsPerMeter`.
+ 2. User taps to set a start point of the temporary measure line. (He can still zoom and move around on the image while doing so)
+ 3. user tabs to set the end point of the temporary measure line. (He can still zoom and move around on the image while doing so)
+ 4. **Real-time Feedback**: A label on the line shows the distance in meters, calculated as `pixelDistance / state.referenceDistance.pixelsPerMeter`.
+ 5. Afterwards he can still long press and drag the start and end points of the measure line around on the image to refine his initial placement of these 2 points
## 3. Logic & Calibration Integration
From 3dc224d87625b971269c7639dd22a966da51ebd0 Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Sun, 21 Dec 2025 05:48:01 +0000
Subject: [PATCH 03/47] feat: enhance manual reference distance feature with
scale stabilization and interaction improvements
---
docs/feat-reference-distances.md | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/docs/feat-reference-distances.md b/docs/feat-reference-distances.md
index 015a9a8..f4eb14b 100644
--- a/docs/feat-reference-distances.md
+++ b/docs/feat-reference-distances.md
@@ -88,7 +88,8 @@ We need to extend the application state to store the manual reference.
2. **Scale Check**: Calculate the scale of the candidate model ($S_{gps}$).
3. **Comparison**: Compare $S_{gps}$ with $S_{manual}$ (from reference distance).
4. **Harmonization**:
- * **Similarity (2 pairs)**: If a manual reference exists, we can optionally **force** the scale to $S_{manual}$. This reduces the Similarity transform to finding only Rotation ($R$) and Translation ($t$). This is mathematically more robust when GPS accuracy is low but the manual measurement is trusted.
+ * **Similarity (2 pairs)**: If a manual reference exists, we can optionally **force** the scale to $S_{manual}$. This reduces the Similarity transform to finding only Rotation ($R$) and Translation ($t$).
+ * **Benefit for GPS Visualization**: This significantly stabilizes the user's position on the map. With only 2 GPS points, the scale is extremely sensitive to GPS noise (e.g., a 5m error can drastically zoom the map in/out). Fixing the scale locks the "zoom level" to the trusted manual measurement, leaving GPS to only solve for position and orientation. This prevents the map from "breathing" or jumping in size as the user moves.
* **Affine/Homography (3+ pairs)**: Use $S_{manual}$ as a **validator**. If the local scale of the GPS-derived transform differs significantly (e.g., > 10%) from $S_{manual}$, show a warning: "GPS scale disagrees with manual reference."
## 4. Code Structure Changes
@@ -96,12 +97,16 @@ We need to extend the application state to store the manual reference.
### `src/index.js`
* Add `referenceDistance` to `state`.
* Implement `startReferenceMode()` and `startMeasureMode()`.
+* **Interaction Handling**:
+ * Use `L.Marker` with `draggable: true` for the start and end points of the reference/measurement lines. This allows the user to refine the position after the initial tap (as requested in the UX spec).
+ * Ensure `L.Polyline` connects these markers and updates in real-time during drag events.
* Handle canvas/overlay drawing for the reference line and active measurement line.
### `src/geo/transformations.js`
* Implement `fitSimilarityFixedScale(pairs, fixedScale)`:
* Standard Procrustes analysis but with $s$ fixed to `fixedScale`.
* Solves for rotation $\theta$ and translation $t_x, t_y$ minimizing the error.
+ * *Implementation Note*: Reuse the centroid and rotation calculation from `fitSimilarity`. Instead of computing `scale = numerator / denom`, use `scale = fixedScale` and compute translation based on this fixed scale.
### `src/calibration/calibrator.js`
* Update `calibrateMap` to accept an optional `referenceScale`.
From 34be6b650bc684db3bfe98be49ac44c6ac6e1c06 Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Sun, 21 Dec 2025 05:56:34 +0000
Subject: [PATCH 04/47] feat: add fitSimilarityFixedScale function for fixed
scale similarity transformation
---
src/geo/transformations.js | 69 +++++++++++++++++++++++++++++++++
src/geo/transformations.test.js | 1 +
2 files changed, 70 insertions(+)
diff --git a/src/geo/transformations.js b/src/geo/transformations.js
index 19988c4..accaa3a 100644
--- a/src/geo/transformations.js
+++ b/src/geo/transformations.js
@@ -100,6 +100,74 @@ export function fitSimilarity(pairs, weights) {
};
}
+export function fitSimilarityFixedScale(pairs, fixedScale, weights) {
+ if (pairs.length < 2) {
+ return null;
+ }
+
+ if (!Number.isFinite(fixedScale) || Math.abs(fixedScale) < TOLERANCE) {
+ return null;
+ }
+
+ const w = ensureWeights(pairs.length, weights);
+ let weightSum = 0;
+ let pixelCentroid = { x: 0, y: 0 };
+ let enuCentroid = { x: 0, y: 0 };
+
+ for (let i = 0; i < pairs.length; i += 1) {
+ const weight = w[i];
+ weightSum += weight;
+ pixelCentroid.x += weight * pairs[i].pixel.x;
+ pixelCentroid.y += weight * pairs[i].pixel.y;
+ enuCentroid.x += weight * pairs[i].enu.x;
+ enuCentroid.y += weight * pairs[i].enu.y;
+ }
+
+ if (Math.abs(weightSum) < TOLERANCE) {
+ return null;
+ }
+
+ pixelCentroid = { x: pixelCentroid.x / weightSum, y: pixelCentroid.y / weightSum };
+ enuCentroid = { x: enuCentroid.x / weightSum, y: enuCentroid.y / weightSum };
+
+ // Compute optimal rotation for the fixed scale
+ // We minimize sum of w_i * ||(s*R*p_i + t) - e_i||^2
+ // With fixed s, the optimal rotation angle is found from:
+ // theta = atan2(sum(w*(ey*px - ex*py)), sum(w*(ex*px + ey*py)))
+ let sumCross = 0;
+ let sumDot = 0;
+
+ for (let i = 0; i < pairs.length; i += 1) {
+ const weight = w[i];
+ const px = pairs[i].pixel.x - pixelCentroid.x;
+ const py = pairs[i].pixel.y - pixelCentroid.y;
+ const ex = pairs[i].enu.x - enuCentroid.x;
+ const ey = pairs[i].enu.y - enuCentroid.y;
+
+ // For fixed scale, the rotation that minimizes error:
+ // We want to align s*R*p with e
+ // This gives: theta = atan2(sum(w*(ey*px - ex*py)), sum(w*(ex*px + ey*py)))
+ sumCross += weight * (ey * px - ex * py);
+ sumDot += weight * (ex * px + ey * py);
+ }
+
+ const theta = Math.atan2(sumCross, sumDot);
+ const cos = Math.cos(theta);
+ const sin = Math.sin(theta);
+
+ const translationX = enuCentroid.x - fixedScale * (cos * pixelCentroid.x - sin * pixelCentroid.y);
+ const translationY = enuCentroid.y - fixedScale * (sin * pixelCentroid.x + cos * pixelCentroid.y);
+
+ return {
+ type: 'similarity',
+ scale: fixedScale,
+ rotation: theta,
+ cos,
+ sin,
+ translation: { x: translationX, y: translationY },
+ };
+}
+
function buildNormalEquations(rows, values, variableCount) {
const ata = Array.from({ length: variableCount }, () => Array(variableCount).fill(0));
const atb = Array(variableCount).fill(0);
@@ -454,6 +522,7 @@ export function averageScaleFromJacobian(jacobian) {
const api = {
TOLERANCE,
fitSimilarity,
+ fitSimilarityFixedScale,
fitAffine,
fitHomography,
applyTransform,
diff --git a/src/geo/transformations.test.js b/src/geo/transformations.test.js
index 4be3c7e..6e3d46b 100644
--- a/src/geo/transformations.test.js
+++ b/src/geo/transformations.test.js
@@ -1,5 +1,6 @@
import {
fitSimilarity,
+ fitSimilarityFixedScale,
fitAffine,
fitHomography,
applyTransform,
From 2ac9f137c306c3857560689177a44413385ade49 Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Sun, 21 Dec 2025 05:56:40 +0000
Subject: [PATCH 05/47] feat: implement fixed scale similarity transformation
and add corresponding tests
---
src/calibration/calibrator.js | 19 +++---
src/calibration/calibrator.test.js | 47 +++++++++++++++
src/geo/transformations.test.js | 96 ++++++++++++++++++++++++++++++
3 files changed, 155 insertions(+), 7 deletions(-)
diff --git a/src/calibration/calibrator.js b/src/calibration/calibrator.js
index da1f43b..7209bb8 100644
--- a/src/calibration/calibrator.js
+++ b/src/calibration/calibrator.js
@@ -1,6 +1,7 @@
import { computeOrigin, wgs84ToEnu } from '../geo/coordinate.js';
import {
fitSimilarity,
+ fitSimilarityFixedScale,
fitAffine,
fitHomography,
applyTransform,
@@ -54,7 +55,10 @@ function sampleUniqueIndexes(randomFn, total, sampleSize) {
return Array.from(selected);
}
-function fitModel(kind, pairs, weights) {
+function fitModel(kind, pairs, weights, referenceScale) {
+ if (kind === 'similarity' && referenceScale != null) {
+ return fitSimilarityFixedScale(pairs, referenceScale, weights);
+ }
const estimator = MODEL_PREFERENCES[kind].estimator;
return estimator(pairs, weights);
}
@@ -77,12 +81,12 @@ function huberWeight(residual, delta) {
return delta / absResidual;
}
-function runReweightedFit(kind, pairs, options) {
+function runReweightedFit(kind, pairs, options, referenceScale) {
let weights = Array.from({ length: pairs.length }, () => 1);
let model = null;
for (let iteration = 0; iteration <= options.irlsIterations; iteration += 1) {
- model = fitModel(kind, pairs, weights);
+ model = fitModel(kind, pairs, weights, referenceScale);
if (!model) {
return null;
}
@@ -160,7 +164,7 @@ export function computeAccuracyRing(calibration, gpsAccuracy) {
};
}
-function runRansacForKind(kind, pairs, options) {
+function runRansacForKind(kind, pairs, options, referenceScale) {
const { minPairs } = MODEL_PREFERENCES[kind];
if (pairs.length < minPairs) {
return null;
@@ -172,7 +176,7 @@ function runRansacForKind(kind, pairs, options) {
for (let iteration = 0; iteration < iterationBudget; iteration += 1) {
const sampleIndexes = sampleUniqueIndexes(options.random, pairs.length, minPairs);
const sample = sampleIndexes.map((index) => pairs[index]);
- const candidate = fitModel(kind, sample);
+ const candidate = fitModel(kind, sample, undefined, referenceScale);
if (!candidate) {
continue;
}
@@ -188,7 +192,7 @@ function runRansacForKind(kind, pairs, options) {
}
const inlierPairs = pairs.filter((pair, index) => best.metrics.inliers[index]);
- const refined = runReweightedFit(kind, inlierPairs, options);
+ const refined = runReweightedFit(kind, inlierPairs, options, referenceScale);
if (!refined) {
return null;
}
@@ -222,12 +226,13 @@ export function calibrateMap(pairs, userOptions = {}) {
const options = { ...DEFAULT_OPTIONS, ...userOptions };
const origin = userOptions.origin || computeOrigin(pairs);
const enrichedPairs = createEnrichedPairs(pairs, origin);
+ const referenceScale = userOptions.referenceScale;
const modelKinds = pickModelKinds(enrichedPairs.length);
for (let i = 0; i < modelKinds.length; i += 1) {
const kind = modelKinds[i];
- const result = runRansacForKind(kind, enrichedPairs, options);
+ const result = runRansacForKind(kind, enrichedPairs, options, referenceScale);
if (result) {
const { metrics } = result;
const combined = {
diff --git a/src/calibration/calibrator.test.js b/src/calibration/calibrator.test.js
index 02a636d..e61223b 100644
--- a/src/calibration/calibrator.test.js
+++ b/src/calibration/calibrator.test.js
@@ -116,6 +116,53 @@ describe('calibrator', () => {
expect(result.statusMessage.level).toBe('low');
});
+ test('calibrateMap uses fixed scale when referenceScale is provided', () => {
+ const referenceScale = 0.5; // meters per pixel
+ const pairs = [
+ { pixel: { x: 0, y: 0 }, wgs84: { lat: origin.lat, lon: origin.lon } },
+ { pixel: { x: 200, y: 0 }, wgs84: { lat: origin.lat, lon: origin.lon + 0.002 } },
+ ];
+ const result = calibrateMap(pairs, { origin, iterations: 5, random: makeRandomGenerator(), referenceScale });
+ expect(result.status).toBe('ok');
+ expect(result.kind).toBe('similarity');
+ expect(result.model.scale).toBe(referenceScale);
+ });
+
+ test('calibrateMap with referenceScale ignores natural GPS-derived scale', () => {
+ // Create pairs that would naturally give a scale of ~1.11 m/px
+ // (based on lon difference of 0.002 degrees ≈ 222m at this latitude, over 200px)
+ const pairs = [
+ { pixel: { x: 0, y: 0 }, wgs84: { lat: origin.lat, lon: origin.lon } },
+ { pixel: { x: 200, y: 0 }, wgs84: { lat: origin.lat, lon: origin.lon + 0.002 } },
+ ];
+
+ // First, calibrate without referenceScale to get the natural scale
+ const naturalResult = calibrateMap(pairs, { origin, iterations: 5, random: makeRandomGenerator() });
+ expect(naturalResult.model.scale).toBeGreaterThan(0.5);
+
+ // Now calibrate with a moderately different fixed scale (close enough to still produce inliers)
+ const fixedScale = naturalResult.model.scale * 0.8;
+ const fixedResult = calibrateMap(pairs, { origin, iterations: 5, random: makeRandomGenerator(), referenceScale: fixedScale });
+ expect(fixedResult.status).toBe('ok');
+ expect(fixedResult.model.scale).toBe(fixedScale);
+ expect(fixedResult.model.scale).not.toBeCloseTo(naturalResult.model.scale);
+ });
+
+ test('referenceScale only affects similarity model, not affine or homography', () => {
+ const referenceScale = 0.5;
+ // 3 pairs = affine model
+ const threePairs = [
+ { pixel: { x: 0, y: 0 }, wgs84: { lat: origin.lat, lon: origin.lon } },
+ { pixel: { x: 100, y: 0 }, wgs84: { lat: origin.lat, lon: origin.lon + 0.001 } },
+ { pixel: { x: 0, y: 60 }, wgs84: { lat: origin.lat + 0.0005, lon: origin.lon } },
+ ];
+ const result = calibrateMap(threePairs, { origin, iterations: 10, random: makeRandomGenerator(), referenceScale });
+ expect(result.status).toBe('ok');
+ expect(result.kind).toBe('affine');
+ // Affine doesn't have a single 'scale' property - it uses a matrix
+ expect(result.model.matrix).toBeDefined();
+ });
+
test('calibrateMap selects affine model for three pairs', () => {
const pairs = [
{ pixel: { x: 0, y: 0 }, wgs84: { lat: origin.lat, lon: origin.lon } },
diff --git a/src/geo/transformations.test.js b/src/geo/transformations.test.js
index 6e3d46b..8b4e05c 100644
--- a/src/geo/transformations.test.js
+++ b/src/geo/transformations.test.js
@@ -176,4 +176,100 @@ describe('transformations', () => {
expect(jacobianForTransform({ type: 'unsupported' }, { x: 0, y: 0 })).toBeNull();
expect(averageScaleFromJacobian(null)).toBeNull();
});
+
+ describe('fitSimilarityFixedScale', () => {
+ test('preserves exact fixed scale with known transform', () => {
+ const fixedScale = 5;
+ const theta = Math.PI / 6;
+ const cos = Math.cos(theta);
+ const sin = Math.sin(theta);
+ const translation = { x: 100, y: -40 };
+ const pairs = [
+ { pixel: { x: 0, y: 0 }, enu: { x: translation.x, y: translation.y } },
+ { pixel: { x: 10, y: 0 }, enu: { x: translation.x + fixedScale * (cos * 10), y: translation.y + fixedScale * (sin * 10) } },
+ { pixel: { x: 0, y: 10 }, enu: { x: translation.x + fixedScale * (-sin * 10), y: translation.y + fixedScale * (cos * 10) } },
+ ];
+ const transform = fitSimilarityFixedScale(pairs, fixedScale);
+ expect(transform.scale).toBe(fixedScale);
+ expect(transform.rotation).toBeCloseTo(theta);
+ expect(transform.translation.x).toBeCloseTo(translation.x);
+ expect(transform.translation.y).toBeCloseTo(translation.y);
+ });
+
+ test('uses fixed scale even when data suggests different scale', () => {
+ // Data that would naturally fit scale=2, but we force scale=5
+ const pairs = [
+ { pixel: { x: 0, y: 0 }, enu: { x: 0, y: 0 } },
+ { pixel: { x: 10, y: 0 }, enu: { x: 20, y: 0 } },
+ { pixel: { x: 0, y: 10 }, enu: { x: 0, y: 20 } },
+ ];
+ const freeTransform = fitSimilarity(pairs);
+ expect(freeTransform.scale).toBeCloseTo(2);
+
+ const fixedTransform = fitSimilarityFixedScale(pairs, 5);
+ expect(fixedTransform.scale).toBe(5);
+ });
+
+ test('correctly finds rotation when scale is fixed', () => {
+ const fixedScale = 3;
+ const theta = Math.PI / 4;
+ const cos = Math.cos(theta);
+ const sin = Math.sin(theta);
+ const pairs = [
+ { pixel: { x: 0, y: 0 }, enu: { x: 0, y: 0 } },
+ { pixel: { x: 10, y: 0 }, enu: { x: fixedScale * (cos * 10), y: fixedScale * (sin * 10) } },
+ ];
+ const transform = fitSimilarityFixedScale(pairs, fixedScale);
+ expect(transform.rotation).toBeCloseTo(theta);
+ });
+
+ test('computes correct translation with fixed scale', () => {
+ const fixedScale = 2;
+ const pairs = [
+ { pixel: { x: 5, y: 5 }, enu: { x: 100, y: 200 } },
+ { pixel: { x: 15, y: 5 }, enu: { x: 120, y: 200 } },
+ ];
+ const transform = fitSimilarityFixedScale(pairs, fixedScale);
+ expect(transform.scale).toBe(fixedScale);
+ const mapped = applyTransform(transform, { x: 5, y: 5 });
+ expect(mapped.x).toBeCloseTo(100);
+ expect(mapped.y).toBeCloseTo(200);
+ });
+
+ test('returns null for insufficient pairs', () => {
+ expect(fitSimilarityFixedScale([{ pixel: { x: 0, y: 0 }, enu: { x: 0, y: 0 } }], 5)).toBeNull();
+ expect(fitSimilarityFixedScale([], 5)).toBeNull();
+ });
+
+ test('returns null for invalid fixed scale', () => {
+ const pairs = [
+ { pixel: { x: 0, y: 0 }, enu: { x: 0, y: 0 } },
+ { pixel: { x: 10, y: 0 }, enu: { x: 20, y: 0 } },
+ ];
+ expect(fitSimilarityFixedScale(pairs, 0)).toBeNull();
+ expect(fitSimilarityFixedScale(pairs, NaN)).toBeNull();
+ expect(fitSimilarityFixedScale(pairs, Infinity)).toBeNull();
+ });
+
+ test('returns null for zero total weight', () => {
+ const pairs = [
+ { pixel: { x: 0, y: 0 }, enu: { x: 0, y: 0 } },
+ { pixel: { x: 10, y: 0 }, enu: { x: 20, y: 0 } },
+ ];
+ expect(fitSimilarityFixedScale(pairs, 5, [0, 0])).toBeNull();
+ });
+
+ test('respects weights in rotation computation', () => {
+ // Two points suggesting different rotations, weights should favor second
+ const pairs = [
+ { pixel: { x: 10, y: 0 }, enu: { x: 10, y: 0 } },
+ { pixel: { x: 0, y: 10 }, enu: { x: 0, y: 10 } },
+ ];
+ const uniformTransform = fitSimilarityFixedScale(pairs, 1, [1, 1]);
+ const weightedTransform = fitSimilarityFixedScale(pairs, 1, [0.01, 1]);
+ // Both should work but give slightly different results due to weighting
+ expect(uniformTransform).not.toBeNull();
+ expect(weightedTransform).not.toBeNull();
+ });
+ });
});
From 99a28237421f56ebb40f270a72cc481ab7519d73 Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Sun, 21 Dec 2025 06:00:05 +0000
Subject: [PATCH 06/47] feat: document implementation progress for manual
reference distance feature
---
docs/feat-reference-distances.md | 62 ++++++++++++++++++++++++++++++++
1 file changed, 62 insertions(+)
diff --git a/docs/feat-reference-distances.md b/docs/feat-reference-distances.md
index f4eb14b..4352eb5 100644
--- a/docs/feat-reference-distances.md
+++ b/docs/feat-reference-distances.md
@@ -121,4 +121,66 @@ We need to extend the application state to store the manual reference.
* Verify that setting a reference distance enables the measure tool.
* Verify that measurements are accurate based on the reference.
+---
+
+## 6. Implementation Progress
+
+### ✅ Phase 1: Backend Math Layer (Completed)
+
+**Date:** 2025-12-21
+
+This phase implements the core math needed to support a fixed reference scale in the calibration pipeline.
+
+#### `src/geo/transformations.js`
+- [x] Implemented `fitSimilarityFixedScale(pairs, fixedScale, weights)` function
+ - Procrustes analysis with fixed scale parameter
+ - Solves only for rotation (θ) and translation (tx, ty)
+ - Supports weighted pairs for IRLS integration
+- [x] Exported via module API
+
+#### `src/geo/transformations.test.js`
+- [x] Added 8 comprehensive unit tests:
+ - Preserves exact fixed scale with known transform
+ - Uses fixed scale even when data suggests different scale
+ - Correctly finds rotation when scale is fixed
+ - Computes correct translation with fixed scale
+ - Returns null for insufficient pairs
+ - Returns null for invalid fixed scale (0, NaN, Infinity)
+ - Returns null for zero total weight
+ - Respects weights in rotation computation
+
+#### `src/calibration/calibrator.js`
+- [x] Updated `calibrateMap` to accept optional `userOptions.referenceScale`
+- [x] Updated `fitModel` to use `fitSimilarityFixedScale` when `referenceScale` is provided and model is 'similarity'
+- [x] Updated `runReweightedFit` to pass `referenceScale` through IRLS pipeline
+- [x] Updated `runRansacForKind` to pass `referenceScale` through RANSAC pipeline
+
+#### `src/calibration/calibrator.test.js`
+- [x] Added 3 integration tests:
+ - `calibrateMap` uses fixed scale when `referenceScale` is provided
+ - Fixed scale overrides natural GPS-derived scale
+ - `referenceScale` only affects similarity model, not affine/homography
+
+**Test Results:** 41 tests pass, 98.41% code coverage
+
+---
+
+### 🔲 Phase 2: UI Layer (Not Started)
+
+This phase implements the user-facing features: setting a reference distance and measuring arbitrary distances.
+
+#### `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 `pixelsPerMeter`
+ - Draggable endpoints for refinement
+- [ ] Add UI buttons to toolbar
+- [ ] Persistence of `referenceDistance` to IndexedDB
+
+
From 922e1f49a26da8bd82565de55b6cd85c4ca47d97 Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Sun, 21 Dec 2025 06:04:26 +0000
Subject: [PATCH 07/47] feat: simplify error handling in fetch event and
geolocation prompt
---
service-worker.js | 4 ++--
src/index.js | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/service-worker.js b/service-worker.js
index 59282be..58f4c01 100644
--- a/service-worker.js
+++ b/service-worker.js
@@ -62,7 +62,7 @@ self.addEventListener('fetch', (event) => {
if (response) {
return response;
}
- } catch (error) {
+ } catch {
// network request failed, fall back to cache if possible
}
@@ -87,7 +87,7 @@ self.addEventListener('fetch', (event) => {
try {
return await fetchAndUpdate();
- } catch (error) {
+ } catch {
if (cached) {
return cached;
}
diff --git a/src/index.js b/src/index.js
index 47924ab..cac7882 100644
--- a/src/index.js
+++ b/src/index.js
@@ -920,7 +920,7 @@ function maybePromptGeolocationForOsm() {
.catch(() => {
if (shouldPrompt) doRequest();
});
- } catch (_) {
+ } catch {
if (shouldPrompt) doRequest();
}
} else {
From 9272d65fe869f11126b5a1ca102939d8b4b1e432 Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Sun, 21 Dec 2025 06:04:46 +0000
Subject: [PATCH 08/47] refactor to extract the shared centroid computation
into a helper function
---
src/geo/transformations.js | 27 ++++++++++++++++++++-------
1 file changed, 20 insertions(+), 7 deletions(-)
diff --git a/src/geo/transformations.js b/src/geo/transformations.js
index accaa3a..7f020e5 100644
--- a/src/geo/transformations.js
+++ b/src/geo/transformations.js
@@ -10,11 +10,7 @@ function ensureWeights(length, weights) {
return weights;
}
-export function fitSimilarity(pairs, weights) {
- if (pairs.length < 2) {
- return null;
- }
-
+function computeWeightedCentroids(pairs, weights) {
const w = ensureWeights(pairs.length, weights);
let weightSum = 0;
let pixelCentroid = { x: 0, y: 0 };
@@ -33,8 +29,25 @@ export function fitSimilarity(pairs, weights) {
return null;
}
- pixelCentroid = { x: pixelCentroid.x / weightSum, y: pixelCentroid.y / weightSum };
- enuCentroid = { x: enuCentroid.x / weightSum, y: enuCentroid.y / weightSum };
+ return {
+ w,
+ weightSum,
+ pixelCentroid: { x: pixelCentroid.x / weightSum, y: pixelCentroid.y / weightSum },
+ enuCentroid: { x: enuCentroid.x / weightSum, y: enuCentroid.y / weightSum },
+ };
+}
+
+export function fitSimilarity(pairs, weights) {
+ if (pairs.length < 2) {
+ return null;
+ }
+
+ const centroids = computeWeightedCentroids(pairs, weights);
+ if (!centroids) {
+ return null;
+ }
+
+ const { w, pixelCentroid, enuCentroid } = centroids;
// Precompute centered deltas to avoid duplicated code patterns
const deltas = pairs.map((p, i) => {
From 134e175c950e25a187cfa8ea847173292e06dd34 Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Sun, 21 Dec 2025 06:05:09 +0000
Subject: [PATCH 09/47] feat: refactor fitSimilarityFixedScale to use
computeWeightedCentroids for centroid calculation
---
src/geo/transformations.js | 20 +++-----------------
1 file changed, 3 insertions(+), 17 deletions(-)
diff --git a/src/geo/transformations.js b/src/geo/transformations.js
index 7f020e5..12e2d2f 100644
--- a/src/geo/transformations.js
+++ b/src/geo/transformations.js
@@ -122,26 +122,12 @@ export function fitSimilarityFixedScale(pairs, fixedScale, weights) {
return null;
}
- const w = ensureWeights(pairs.length, weights);
- let weightSum = 0;
- let pixelCentroid = { x: 0, y: 0 };
- let enuCentroid = { x: 0, y: 0 };
-
- for (let i = 0; i < pairs.length; i += 1) {
- const weight = w[i];
- weightSum += weight;
- pixelCentroid.x += weight * pairs[i].pixel.x;
- pixelCentroid.y += weight * pairs[i].pixel.y;
- enuCentroid.x += weight * pairs[i].enu.x;
- enuCentroid.y += weight * pairs[i].enu.y;
- }
-
- if (Math.abs(weightSum) < TOLERANCE) {
+ const centroids = computeWeightedCentroids(pairs, weights);
+ if (!centroids) {
return null;
}
- pixelCentroid = { x: pixelCentroid.x / weightSum, y: pixelCentroid.y / weightSum };
- enuCentroid = { x: enuCentroid.x / weightSum, y: enuCentroid.y / weightSum };
+ const { w, pixelCentroid, enuCentroid } = centroids;
// Compute optimal rotation for the fixed scale
// We minimize sum of w_i * ||(s*R*p_i + t) - e_i||^2
From b248c12eac32a912544e3fb51091b343cdaab48e Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Sun, 21 Dec 2025 06:06:22 +0000
Subject: [PATCH 10/47] feat: improve user instructions for setting reference
distances and measuring
---
docs/feat-reference-distances.md | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/docs/feat-reference-distances.md b/docs/feat-reference-distances.md
index 4352eb5..f7f3859 100644
--- a/docs/feat-reference-distances.md
+++ b/docs/feat-reference-distances.md
@@ -57,7 +57,7 @@ We need to extend the application state to store the manual reference.
1. User taps "Set Scale".
2. Toast: "Tap start point of known distance".
3. User taps point A on the photo.
- * He should be able to zoom in on the image while doing these tabs, to place the points as accurate as possible.
+ * The user should be able to zoom in on the image while doing these taps, to place the points as accurate as possible.
* So zooming in general via pinch gestures (or mouse wheel on desktop) in the image would be an important UX to have in general in the app (in case it does not exist yet)
4. Toast: "Tap end point".
5. User taps point B on the photo.
@@ -69,10 +69,10 @@ We need to extend the application state to store the manual reference.
* **Entry Point**: "Measure" button (icon: tape measure), enabled **only** if `referenceDistance` is set.
* **Interaction Flow**:
1. User taps "Measure".
- 2. User taps to set a start point of the temporary measure line. (He can still zoom and move around on the image while doing so)
- 3. user tabs to set the end point of the temporary measure line. (He can still zoom and move around on the image while doing so)
+ 2. User taps to set a start point of the temporary measure line. (The user can still zoom and move around on the image while doing so)
+ 3. User taps to set the end point of the temporary measure line. (The user can still zoom and move around on the image while doing so)
4. **Real-time Feedback**: A label on the line shows the distance in meters, calculated as `pixelDistance / state.referenceDistance.pixelsPerMeter`.
- 5. Afterwards he can still long press and drag the start and end points of the measure line around on the image to refine his initial placement of these 2 points
+ 5. Afterwards the user can still long press and drag the start and end points of the measure line around on the image to refine their initial placement of these 2 points
## 3. Logic & Calibration Integration
From d325a8a36f352e20f8833834b130037290fb6bd1 Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Sun, 21 Dec 2025 06:11:58 +0000
Subject: [PATCH 11/47] feat: enhance rotation computation test for
fitSimilarityFixedScale with weight influence
---
src/geo/transformations.test.js | 28 +++++++++++++++++++++-------
1 file changed, 21 insertions(+), 7 deletions(-)
diff --git a/src/geo/transformations.test.js b/src/geo/transformations.test.js
index 8b4e05c..e68fe83 100644
--- a/src/geo/transformations.test.js
+++ b/src/geo/transformations.test.js
@@ -260,16 +260,30 @@ describe('transformations', () => {
});
test('respects weights in rotation computation', () => {
- // Two points suggesting different rotations, weights should favor second
+ // With 2 pairs, cross/dot contributions are geometrically symmetric,
+ // so we use 3 pairs to properly test weighting influence on rotation.
+ // p0: anchor at origin
+ // p1: suggests rotation 0 (pixel +x maps to enu +x)
+ // p2: suggests rotation 90deg (pixel +y maps to enu -x)
const pairs = [
+ { pixel: { x: 0, y: 0 }, enu: { x: 0, y: 0 } },
{ pixel: { x: 10, y: 0 }, enu: { x: 10, y: 0 } },
- { pixel: { x: 0, y: 10 }, enu: { x: 0, y: 10 } },
+ { pixel: { x: 0, y: 10 }, enu: { x: -10, y: 0 } },
];
- const uniformTransform = fitSimilarityFixedScale(pairs, 1, [1, 1]);
- const weightedTransform = fitSimilarityFixedScale(pairs, 1, [0.01, 1]);
- // Both should work but give slightly different results due to weighting
- expect(uniformTransform).not.toBeNull();
- expect(weightedTransform).not.toBeNull();
+
+ // With equal weights, rotation should be ~45deg
+ const uniformTransform = fitSimilarityFixedScale(pairs, 1, [1, 1, 1]);
+ expect(uniformTransform.rotation).toBeCloseTo(Math.PI / 4);
+
+ // With more weight on p1 (rotation 0), result should be closer to 0
+ const weightedTowardZero = fitSimilarityFixedScale(pairs, 1, [1, 10, 0.1]);
+ expect(weightedTowardZero.rotation).toBeLessThan(Math.PI / 4);
+ expect(weightedTowardZero.rotation).toBeGreaterThan(0);
+
+ // With more weight on p2 (rotation 90deg), result should be closer to PI/2
+ const weightedToward90 = fitSimilarityFixedScale(pairs, 1, [1, 0.1, 10]);
+ expect(weightedToward90.rotation).toBeGreaterThan(Math.PI / 4);
+ expect(weightedToward90.rotation).toBeLessThan(Math.PI / 2);
});
});
});
From b979daa8c23c3eec7e6832b41424ef772cdf9902 Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Sun, 21 Dec 2025 06:14:40 +0000
Subject: [PATCH 12/47] feat: expand unit tests for fitSimilarityFixedScale to
document geometric properties and edge cases
---
docs/feat-reference-distances.md | 27 +++++++++--
src/geo/transformations.test.js | 81 ++++++++++++++++++++++++++++++++
2 files changed, 105 insertions(+), 3 deletions(-)
diff --git a/docs/feat-reference-distances.md b/docs/feat-reference-distances.md
index f7f3859..f469902 100644
--- a/docs/feat-reference-distances.md
+++ b/docs/feat-reference-distances.md
@@ -139,7 +139,7 @@ This phase implements the core math needed to support a fixed reference scale in
- [x] Exported via module API
#### `src/geo/transformations.test.js`
-- [x] Added 8 comprehensive unit tests:
+- [x] Added 13 comprehensive unit tests:
- Preserves exact fixed scale with known transform
- Uses fixed scale even when data suggests different scale
- Correctly finds rotation when scale is fixed
@@ -147,7 +147,12 @@ This phase implements the core math needed to support a fixed reference scale in
- Returns null for insufficient pairs
- Returns null for invalid fixed scale (0, NaN, Infinity)
- Returns null for zero total weight
- - Respects weights in rotation computation
+ - Respects weights in rotation computation (using 3 pairs)
+ - 2 pairs produce same rotation regardless of weights (geometric symmetry)
+ - 3 pairs with one zero weight behaves like 2 pairs
+ - Handles negative rotation correctly
+ - Returns null for degenerate collinear pixel points
+ - Handles 180 degree rotation
#### `src/calibration/calibrator.js`
- [x] Updated `calibrateMap` to accept optional `userOptions.referenceScale`
@@ -161,7 +166,23 @@ This phase implements the core math needed to support a fixed reference scale in
- Fixed scale overrides natural GPS-derived scale
- `referenceScale` only affects similarity model, not affine/homography
-**Test Results:** 41 tests pass, 98.41% code coverage
+**Test Results:** 46 tests pass, 98.37% code coverage
+
+#### Key Learnings
+
+**2-Pair Geometric Symmetry Property:**
+During test development, we discovered an important mathematical property of `fitSimilarityFixedScale`:
+
+> With exactly 2 pairs, weights cannot influence the computed rotation angle.
+
+This occurs because the weighted Procrustes rotation formula uses:
+$$\theta = \arctan2\left(\sum w_i(e_y p_x - e_x p_y), \sum w_i(e_x p_x + e_y p_y)\right)$$
+
+With only 2 pairs positioned symmetrically around the weighted centroid, the cross and dot contributions maintain equal ratios regardless of weight distribution. This means:
+- **2 GPS pairs + fixed scale**: Rotation is fully determined by geometry, weights only affect translation
+- **3+ GPS pairs + fixed scale**: Weights properly influence rotation computation
+
+This property is now documented in the test suite to prevent future confusion.
---
diff --git a/src/geo/transformations.test.js b/src/geo/transformations.test.js
index e68fe83..186b44d 100644
--- a/src/geo/transformations.test.js
+++ b/src/geo/transformations.test.js
@@ -285,5 +285,86 @@ describe('transformations', () => {
expect(weightedToward90.rotation).toBeGreaterThan(Math.PI / 4);
expect(weightedToward90.rotation).toBeLessThan(Math.PI / 2);
});
+
+ test('2 pairs produce same rotation regardless of weights due to geometric symmetry', () => {
+ // This documents the mathematical property discovered during testing:
+ // With exactly 2 pairs, the cross/dot contributions are always equal,
+ // so weights cannot influence the rotation result.
+ const pairs = [
+ { pixel: { x: 10, y: 0 }, enu: { x: 10, y: 0 } },
+ { pixel: { x: 0, y: 10 }, enu: { x: -10, y: 0 } },
+ ];
+
+ const uniform = fitSimilarityFixedScale(pairs, 1, [1, 1]);
+ const heavyFirst = fitSimilarityFixedScale(pairs, 1, [100, 1]);
+ const heavySecond = fitSimilarityFixedScale(pairs, 1, [1, 100]);
+
+ // All should produce the same rotation due to 2-pair symmetry
+ expect(uniform.rotation).toBeCloseTo(heavyFirst.rotation);
+ expect(uniform.rotation).toBeCloseTo(heavySecond.rotation);
+ });
+
+ test('3 pairs with one zero weight behaves like 2 pairs', () => {
+ const pairs = [
+ { pixel: { x: 0, y: 0 }, enu: { x: 0, y: 0 } },
+ { pixel: { x: 10, y: 0 }, enu: { x: 10, y: 0 } },
+ { pixel: { x: 0, y: 10 }, enu: { x: -10, y: 0 } },
+ ];
+
+ // With third pair zeroed out, should match 2-pair result
+ const twoPairs = [pairs[0], pairs[1]];
+ const twoPairResult = fitSimilarityFixedScale(twoPairs, 1, [1, 1]);
+ const threePairZeroWeight = fitSimilarityFixedScale(pairs, 1, [1, 1, 0]);
+
+ expect(threePairZeroWeight.rotation).toBeCloseTo(twoPairResult.rotation);
+ expect(threePairZeroWeight.translation.x).toBeCloseTo(twoPairResult.translation.x);
+ expect(threePairZeroWeight.translation.y).toBeCloseTo(twoPairResult.translation.y);
+ });
+
+ test('handles negative rotation correctly', () => {
+ // Rotation of -45deg (or equivalently 315deg)
+ const fixedScale = 2;
+ const theta = -Math.PI / 4;
+ const cos = Math.cos(theta);
+ const sin = Math.sin(theta);
+ const pairs = [
+ { pixel: { x: 0, y: 0 }, enu: { x: 0, y: 0 } },
+ { pixel: { x: 10, y: 0 }, enu: { x: fixedScale * (cos * 10), y: fixedScale * (sin * 10) } },
+ { pixel: { x: 0, y: 10 }, enu: { x: fixedScale * (-sin * 10), y: fixedScale * (cos * 10) } },
+ ];
+
+ const transform = fitSimilarityFixedScale(pairs, fixedScale);
+ expect(transform.rotation).toBeCloseTo(theta);
+ expect(transform.scale).toBe(fixedScale);
+ });
+
+ test('returns null for degenerate collinear pixel points', () => {
+ // All pixel points on same location - cannot determine rotation
+ const pairs = [
+ { pixel: { x: 5, y: 5 }, enu: { x: 0, y: 0 } },
+ { pixel: { x: 5, y: 5 }, enu: { x: 10, y: 10 } },
+ ];
+
+ // This should return a valid transform (rotation is arbitrary but consistent)
+ // or handle gracefully - let's verify the behavior
+ const transform = fitSimilarityFixedScale(pairs, 1);
+ // When pixel points coincide, rotation is undefined (atan2(0,0))
+ // The function should still return a transform with the fixed scale
+ if (transform !== null) {
+ expect(transform.scale).toBe(1);
+ }
+ });
+
+ test('handles 180 degree rotation', () => {
+ const fixedScale = 1;
+ const pairs = [
+ { pixel: { x: 0, y: 0 }, enu: { x: 0, y: 0 } },
+ { pixel: { x: 10, y: 0 }, enu: { x: -10, y: 0 } },
+ { pixel: { x: 0, y: 10 }, enu: { x: 0, y: -10 } },
+ ];
+
+ const transform = fitSimilarityFixedScale(pairs, fixedScale);
+ expect(Math.abs(transform.rotation)).toBeCloseTo(Math.PI);
+ });
});
});
From f62b458868fd3d10c023533131fca57feb763eb7 Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Sun, 21 Dec 2025 06:16:06 +0000
Subject: [PATCH 13/47] feat: update reference distance calculations to use
metersPerPixel for improved accuracy
---
docs/feat-reference-distances.md | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/docs/feat-reference-distances.md b/docs/feat-reference-distances.md
index f469902..9776e97 100644
--- a/docs/feat-reference-distances.md
+++ b/docs/feat-reference-distances.md
@@ -44,9 +44,10 @@ We need to extend the application state to store the manual reference.
p1: { x: number, y: number }, // Pixel coordinates
p2: { x: number, y: number }, // Pixel coordinates
meters: number, // User-defined distance
- pixelsPerMeter: number // Derived scale: distance(p1, p2) / meters
+ metersPerPixel: number // Derived scale: meters / distance(p1, p2)
}
```
+ * **Note**: `metersPerPixel` aligns with the backend's `referenceScale` parameter (meters/pixel), which can be passed directly to `calibrateMap()`.
* **Persistence**: This should be added to the `maps` store in `IndexedDB` (or the runtime `state` object in `src/index.js` for the MVP) to persist across sessions.
## 2. UI/UX Implementation
@@ -71,7 +72,7 @@ We need to extend the application state to store the manual reference.
1. User taps "Measure".
2. User taps to set a start point of the temporary measure line. (The user can still zoom and move around on the image while doing so)
3. User taps to set the end point of the temporary measure line. (The user can still zoom and move around on the image while doing so)
- 4. **Real-time Feedback**: A label on the line shows the distance in meters, calculated as `pixelDistance / state.referenceDistance.pixelsPerMeter`.
+ 4. **Real-time Feedback**: A label on the line shows the distance in meters, calculated as `pixelDistance * state.referenceDistance.metersPerPixel`.
5. Afterwards the user can still long press and drag the start and end points of the measure line around on the image to refine their initial placement of these 2 points
## 3. Logic & Calibration Integration
@@ -198,7 +199,7 @@ This phase implements the user-facing features: setting a reference distance and
- Visual feedback with dashed line and label
- [ ] Implement "Measure" mode (`startMeasureMode()`)
- Two-tap workflow to draw measurement line
- - Real-time distance calculation using `pixelsPerMeter`
+ - Real-time distance calculation using `metersPerPixel`
- Draggable endpoints for refinement
- [ ] Add UI buttons to toolbar
- [ ] Persistence of `referenceDistance` to IndexedDB
From a0fc40ed9e762d4b7dfbc9ac19424b7a9d328ea1 Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Sun, 21 Dec 2025 06:18:14 +0000
Subject: [PATCH 14/47] feat: update fitSimilarityFixedScale to handle negative
fixedScale values
---
src/geo/transformations.js | 2 +-
src/geo/transformations.test.js | 2 ++
2 files changed, 3 insertions(+), 1 deletion(-)
diff --git a/src/geo/transformations.js b/src/geo/transformations.js
index 12e2d2f..3155028 100644
--- a/src/geo/transformations.js
+++ b/src/geo/transformations.js
@@ -118,7 +118,7 @@ export function fitSimilarityFixedScale(pairs, fixedScale, weights) {
return null;
}
- if (!Number.isFinite(fixedScale) || Math.abs(fixedScale) < TOLERANCE) {
+ if (!Number.isFinite(fixedScale) || fixedScale <= TOLERANCE) {
return null;
}
diff --git a/src/geo/transformations.test.js b/src/geo/transformations.test.js
index 186b44d..94b8ea5 100644
--- a/src/geo/transformations.test.js
+++ b/src/geo/transformations.test.js
@@ -249,6 +249,8 @@ describe('transformations', () => {
expect(fitSimilarityFixedScale(pairs, 0)).toBeNull();
expect(fitSimilarityFixedScale(pairs, NaN)).toBeNull();
expect(fitSimilarityFixedScale(pairs, Infinity)).toBeNull();
+ expect(fitSimilarityFixedScale(pairs, -1)).toBeNull();
+ expect(fitSimilarityFixedScale(pairs, -0.5)).toBeNull();
});
test('returns null for zero total weight', () => {
From 808b1364e56931242370dfe7a92d335369fe2eab Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Sun, 21 Dec 2025 06:23:56 +0000
Subject: [PATCH 15/47] feat: handle degenerate cases in
fitSimilarityFixedScale by returning null for coincident pixel points
---
src/geo/transformations.js | 7 +++++++
src/geo/transformations.test.js | 12 ++++--------
2 files changed, 11 insertions(+), 8 deletions(-)
diff --git a/src/geo/transformations.js b/src/geo/transformations.js
index 3155028..b4f5be3 100644
--- a/src/geo/transformations.js
+++ b/src/geo/transformations.js
@@ -135,6 +135,7 @@ export function fitSimilarityFixedScale(pairs, fixedScale, weights) {
// theta = atan2(sum(w*(ey*px - ex*py)), sum(w*(ex*px + ey*py)))
let sumCross = 0;
let sumDot = 0;
+ let pixelVariance = 0;
for (let i = 0; i < pairs.length; i += 1) {
const weight = w[i];
@@ -148,6 +149,12 @@ export function fitSimilarityFixedScale(pairs, fixedScale, weights) {
// This gives: theta = atan2(sum(w*(ey*px - ex*py)), sum(w*(ex*px + ey*py)))
sumCross += weight * (ey * px - ex * py);
sumDot += weight * (ex * px + ey * py);
+ pixelVariance += weight * (px * px + py * py);
+ }
+
+ // Degenerate case: all pixel points coincide, rotation is undefined
+ if (Math.abs(pixelVariance) < TOLERANCE) {
+ return null;
}
const theta = Math.atan2(sumCross, sumDot);
diff --git a/src/geo/transformations.test.js b/src/geo/transformations.test.js
index 94b8ea5..ddfd7d9 100644
--- a/src/geo/transformations.test.js
+++ b/src/geo/transformations.test.js
@@ -340,21 +340,17 @@ describe('transformations', () => {
expect(transform.scale).toBe(fixedScale);
});
- test('returns null for degenerate collinear pixel points', () => {
+ test('returns null for degenerate coincident pixel points', () => {
// All pixel points on same location - cannot determine rotation
const pairs = [
{ pixel: { x: 5, y: 5 }, enu: { x: 0, y: 0 } },
{ pixel: { x: 5, y: 5 }, enu: { x: 10, y: 10 } },
];
- // This should return a valid transform (rotation is arbitrary but consistent)
- // or handle gracefully - let's verify the behavior
+ // When pixel points coincide, rotation is mathematically undefined
+ // The function should return null to indicate invalid input
const transform = fitSimilarityFixedScale(pairs, 1);
- // When pixel points coincide, rotation is undefined (atan2(0,0))
- // The function should still return a transform with the fixed scale
- if (transform !== null) {
- expect(transform.scale).toBe(1);
- }
+ expect(transform).toBeNull();
});
test('handles 180 degree rotation', () => {
From 46497dc8e48a38110b8b932c5bbe2e688e91ec3c Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Sun, 21 Dec 2025 06:25:33 +0000
Subject: [PATCH 16/47] feat: update documentation to clarify handling of
degenerate coincident pixel points
---
docs/feat-reference-distances.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/feat-reference-distances.md b/docs/feat-reference-distances.md
index 9776e97..885587f 100644
--- a/docs/feat-reference-distances.md
+++ b/docs/feat-reference-distances.md
@@ -152,7 +152,7 @@ This phase implements the core math needed to support a fixed reference scale in
- 2 pairs produce same rotation regardless of weights (geometric symmetry)
- 3 pairs with one zero weight behaves like 2 pairs
- Handles negative rotation correctly
- - Returns null for degenerate collinear pixel points
+ - Returns null for degenerate coincident pixel points
- Handles 180 degree rotation
#### `src/calibration/calibrator.js`
From 8cc2bce00899bec8bd532c931450b37906773563 Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Sun, 21 Dec 2025 06:30:06 +0000
Subject: [PATCH 17/47] feat: enhance measurement mode to prioritize manual
reference distance over GPS calibration
---
docs/feat-reference-distances.md | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/docs/feat-reference-distances.md b/docs/feat-reference-distances.md
index 885587f..88f2f94 100644
--- a/docs/feat-reference-distances.md
+++ b/docs/feat-reference-distances.md
@@ -67,12 +67,15 @@ We need to extend the application state to store the manual reference.
* **Edit/Delete**: Tapping the reference line allows editing the value or deleting it.
### 2.2 Measurement Mode
-* **Entry Point**: "Measure" button (icon: tape measure), enabled **only** if `referenceDistance` is set.
+* **Entry Point**: "Measure" button (icon: tape measure), enabled if a **scale can be determined** from either:
+ 1. A manual `referenceDistance` (user-defined reference line with known meters).
+ 2. A successful GPS-based calibration (scale derived from the calibration transform).
+* **Scale Source Priority**: When both sources exist, `referenceDistance.metersPerPixel` takes precedence (manual measurement is considered more precise than GPS-derived scale).
* **Interaction Flow**:
1. User taps "Measure".
2. User taps to set a start point of the temporary measure line. (The user can still zoom and move around on the image while doing so)
3. User taps to set the end point of the temporary measure line. (The user can still zoom and move around on the image while doing so)
- 4. **Real-time Feedback**: A label on the line shows the distance in meters, calculated as `pixelDistance * state.referenceDistance.metersPerPixel`.
+ 4. **Real-time Feedback**: A label on the line shows the distance in meters, calculated as `pixelDistance * activeMetersPerPixel` (where `activeMetersPerPixel` is sourced from `referenceDistance` if set, otherwise from the GPS calibration).
5. Afterwards the user can still long press and drag the start and end points of the measure line around on the image to refine their initial placement of these 2 points
## 3. Logic & Calibration Integration
From 2c6c53874cd6f9ba4f24f487fa435e22fdfbc3e7 Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Sun, 21 Dec 2025 06:34:33 +0000
Subject: [PATCH 18/47] feat: enhance reference distance feature with scale
source indicators and unit selection
---
docs/feat-reference-distances.md | 103 ++++++++++++++++++++++++++++++-
1 file changed, 101 insertions(+), 2 deletions(-)
diff --git a/docs/feat-reference-distances.md b/docs/feat-reference-distances.md
index 88f2f94..4e33709 100644
--- a/docs/feat-reference-distances.md
+++ b/docs/feat-reference-distances.md
@@ -71,12 +71,46 @@ We need to extend the application state to store the manual reference.
1. A manual `referenceDistance` (user-defined reference line with known meters).
2. A successful GPS-based calibration (scale derived from the calibration transform).
* **Scale Source Priority**: When both sources exist, `referenceDistance.metersPerPixel` takes precedence (manual measurement is considered more precise than GPS-derived scale).
+* **Scale Source Indicator**: Display a subtle indicator showing the active scale source:
+ * "📏 Manual scale: 0.05 m/px" (when using referenceDistance)
+ * "📡 GPS-derived scale: 0.048 m/px" (when using calibration)
+ * This helps users understand which scale is being used and aids debugging when measurements seem off.
* **Interaction Flow**:
1. User taps "Measure".
2. User taps to set a start point of the temporary measure line. (The user can still zoom and move around on the image while doing so)
3. User taps to set the end point of the temporary measure line. (The user can still zoom and move around on the image while doing so)
4. **Real-time Feedback**: A label on the line shows the distance in meters, calculated as `pixelDistance * activeMetersPerPixel` (where `activeMetersPerPixel` is sourced from `referenceDistance` if set, otherwise from the GPS calibration).
5. Afterwards the user can still long press and drag the start and end points of the measure line around on the image to refine their initial placement of these 2 points
+* **Multiple Measurements**:
+ * Allow multiple measurement lines on screen simultaneously for comparison.
+ * "Pin" button on each measurement to keep it visible (pinned measurements persist until manually cleared).
+ * "Clear All" button to remove all temporary and pinned measurements at once.
+ * Each measurement line displays its distance label independently.
+
+### 2.3 Unit Selection
+* **User Preference**: Allow users to select their preferred display unit:
+ * Meters (m) - default
+ * Feet (ft)
+ * Feet and inches (e.g., 5' 6")
+* **Internal Storage**: All values stored internally in meters for consistency.
+* **Conversion Layer**: Simple display-time conversion:
+ ```javascript
+ const METERS_TO_FEET = 3.28084;
+
+ function formatDistance(meters, unit) {
+ switch (unit) {
+ case 'ft': return `${(meters * METERS_TO_FEET).toFixed(2)} ft`;
+ case 'ft-in': {
+ const totalInches = meters * METERS_TO_FEET * 12;
+ const feet = Math.floor(totalInches / 12);
+ const inches = Math.round(totalInches % 12);
+ return `${feet}' ${inches}"`;
+ }
+ default: return `${meters.toFixed(2)} m`;
+ }
+ }
+ ```
+* **Persistence**: Unit preference stored in user settings (localStorage or similar).
## 3. Logic & Calibration Integration
@@ -95,9 +129,63 @@ We need to extend the application state to store the manual reference.
* **Similarity (2 pairs)**: If a manual reference exists, we can optionally **force** the scale to $S_{manual}$. This reduces the Similarity transform to finding only Rotation ($R$) and Translation ($t$).
* **Benefit for GPS Visualization**: This significantly stabilizes the user's position on the map. With only 2 GPS points, the scale is extremely sensitive to GPS noise (e.g., a 5m error can drastically zoom the map in/out). Fixing the scale locks the "zoom level" to the trusted manual measurement, leaving GPS to only solve for position and orientation. This prevents the map from "breathing" or jumping in size as the user moves.
* **Affine/Homography (3+ pairs)**: Use $S_{manual}$ as a **validator**. If the local scale of the GPS-derived transform differs significantly (e.g., > 10%) from $S_{manual}$, show a warning: "GPS scale disagrees with manual reference."
+ 5. **Scale Disagreement Warning (Universal)**:
+ * When both `referenceDistance` and GPS calibration exist, **always** compare their scales regardless of model type.
+ * If $|S_{gps} - S_{manual}| / S_{manual} > 0.10$ (10% threshold), display a non-blocking warning:
+ * "⚠️ Scale mismatch: Manual reference suggests 0.05 m/px, GPS calibration suggests 0.042 m/px (16% difference)"
+ * This helps users identify potential issues with either the manual reference placement or GPS data quality.
+ * The warning is informational only—manual scale still takes precedence for measurements.
## 4. Code Structure Changes
+### Scale Extraction from Calibration
+
+To support measurement mode when only GPS calibration exists (no manual reference), we need a helper to extract `metersPerPixel` from the calibration result:
+
+```javascript
+/**
+ * Extracts the scale (meters per pixel) from a calibration result.
+ * For similarity transforms, scale = sqrt(a² + b²) where matrix is [a, b, tx; -b, a, ty].
+ * Note: The calibration matrix maps pixels → geo coordinates, so this gives geo-units/pixel.
+ * For lat/lon, additional conversion to meters is needed based on latitude.
+ *
+ * @param {Object} calibrationResult - Result from calibrateMap()
+ * @returns {number|null} - Scale in meters per pixel, or null if not extractable
+ */
+function getMetersPerPixelFromCalibration(calibrationResult) {
+ if (!calibrationResult || !calibrationResult.matrix) return null;
+ const { a, b } = calibrationResult.matrix;
+ const geoUnitsPerPixel = Math.sqrt(a * a + b * b);
+ // Convert degrees to meters (approximate, using center latitude)
+ // 1 degree ≈ 111,320 meters at equator, adjusted by cos(lat)
+ const centerLat = calibrationResult.centerLat || 0;
+ const metersPerDegree = 111320 * Math.cos(centerLat * Math.PI / 180);
+ return geoUnitsPerPixel * metersPerDegree;
+}
+```
+
+### Active Scale Resolution
+
+```javascript
+/**
+ * Determines the active metersPerPixel value based on available sources.
+ * Priority: manual referenceDistance > GPS calibration
+ *
+ * @param {Object} state - Application state
+ * @returns {{ metersPerPixel: number, source: 'manual' | 'gps' } | null}
+ */
+function getActiveScale(state) {
+ if (state.referenceDistance?.metersPerPixel) {
+ return { metersPerPixel: state.referenceDistance.metersPerPixel, source: 'manual' };
+ }
+ if (state.calibration) {
+ const scale = getMetersPerPixelFromCalibration(state.calibration);
+ if (scale) return { metersPerPixel: scale, source: 'gps' };
+ }
+ return null;
+}
+```
+
### `src/index.js`
* Add `referenceDistance` to `state`.
* Implement `startReferenceMode()` and `startMeasureMode()`.
@@ -121,9 +209,20 @@ We need to extend the application state to store the manual reference.
* **Unit Tests (`src/geo/transformations.test.js`)**:
* Test `fitSimilarityFixedScale` with synthetic data.
* Verify that the resulting transform preserves the input scale exactly.
+* **Unit Tests (`src/index.js` - Scale Helpers)**:
+ * Test `getMetersPerPixelFromCalibration` with various calibration results.
+ * Test `getActiveScale` priority logic (manual > GPS).
+ * Test `formatDistance` for all unit types (m, ft, ft-in).
* **Integration Tests**:
- * Verify that setting a reference distance enables the measure tool.
- * Verify that measurements are accurate based on the reference.
+ * Verify measure tool enabled when `referenceDistance` is set (no GPS).
+ * Verify measure tool enabled when valid GPS calibration exists (no manual reference).
+ * Verify measure tool enabled when both sources exist.
+ * Verify measure tool disabled when neither source exists.
+ * Verify correct scale source priority: manual reference takes precedence over GPS.
+ * Verify measurements are accurate based on the active scale source.
+ * Verify scale disagreement warning appears when scales differ by >10%.
+ * Verify multiple measurements can be displayed simultaneously.
+ * Verify unit preference persists across sessions.
---
From 9642113dab1caa88fc4d5ce700ceaf579a65d443 Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Sun, 21 Dec 2025 06:56:58 +0000
Subject: [PATCH 19/47] feat: implement scale management utilities and extend
application state for reference distance feature
---
docs/feat-reference-distances.md | 41 ++-
src/index.js | 5 +
src/scale/scale.js | 252 ++++++++++++++++++
src/scale/scale.test.js | 424 +++++++++++++++++++++++++++++++
4 files changed, 710 insertions(+), 12 deletions(-)
create mode 100644 src/scale/scale.js
create mode 100644 src/scale/scale.test.js
diff --git a/docs/feat-reference-distances.md b/docs/feat-reference-distances.md
index 4e33709..2d9e2fc 100644
--- a/docs/feat-reference-distances.md
+++ b/docs/feat-reference-distances.md
@@ -289,22 +289,39 @@ This property is now documented in the test suite to prevent future confusion.
---
+### ✅ Phase 1.5: Scale Helpers & State Extension (Completed)
+
+**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`
+- [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 2: UI Layer (Not Started)
This phase implements the user-facing features: setting a reference distance and measuring arbitrary distances.
#### `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
-
diff --git a/src/index.js b/src/index.js
index cac7882..699580d 100644
--- a/src/index.js
+++ b/src/index.js
@@ -41,6 +41,11 @@ const state = {
pairsCompleted: 0,
pendingToast: false,
},
+ // Reference distance for manual scale definition (Phase 2 feature)
+ // Structure: { p1: {x, y}, p2: {x, y}, meters: number, metersPerPixel: number }
+ referenceDistance: null,
+ // User's preferred display unit for distances: 'm' | 'ft' | 'ft-in'
+ preferredUnit: 'm',
};
const dom = {};
diff --git a/src/scale/scale.js b/src/scale/scale.js
new file mode 100644
index 0000000..0b8b768
--- /dev/null
+++ b/src/scale/scale.js
@@ -0,0 +1,252 @@
+/**
+ * Scale management utilities for the reference distance feature.
+ * Provides functions for extracting scale from calibration, determining active scale source,
+ * and formatting distances for display.
+ */
+
+/** Meters per degree at equator (WGS84 approximation) */
+export const METERS_PER_DEGREE_EQUATOR = 111320;
+
+/** Conversion factor from meters to feet */
+export const METERS_TO_FEET = 3.28084;
+
+/**
+ * Validates that two points have valid numeric x,y coordinates.
+ * @param {Object} p1 - First point
+ * @param {Object} p2 - Second point
+ * @returns {boolean} - True if both points are valid
+ */
+function arePointsValid(p1, p2) {
+ return p1 && p2 &&
+ typeof p1.x === 'number' && typeof p1.y === 'number' &&
+ typeof p2.x === 'number' && typeof p2.y === 'number';
+}
+
+/**
+ * Computes the pixel distance between two points.
+ * @param {Object} p1 - First point {x, y}
+ * @param {Object} p2 - Second point {x, y}
+ * @returns {number} - Euclidean distance in pixels
+ */
+function pixelDistance(p1, p2) {
+ const dx = p2.x - p1.x;
+ const dy = p2.y - p1.y;
+ return Math.hypot(dx, dy);
+}
+
+/**
+ * Computes the meters per pixel scale from a reference distance definition.
+ *
+ * @param {Object} p1 - First point in pixel coordinates {x, y}
+ * @param {Object} p2 - Second point in pixel coordinates {x, y}
+ * @param {number} meters - Known distance in meters between the two points
+ * @returns {number|null} - Meters per pixel, or null if inputs are invalid
+ */
+export function computeReferenceScale(p1, p2, meters) {
+ if (!arePointsValid(p1, p2)) {
+ return null;
+ }
+
+ if (!Number.isFinite(meters) || meters <= 0) {
+ return null;
+ }
+
+ const distance = pixelDistance(p1, p2);
+
+ if (distance === 0) {
+ return null;
+ }
+
+ return meters / distance;
+}
+
+/**
+ * Extracts scale from a similarity model.
+ * @param {Object} model - Calibration model
+ * @returns {number|null} - Scale or null
+ */
+function extractSimilarityScale(model) {
+ if (typeof model.scale === 'number') {
+ return Math.abs(model.scale);
+ }
+ return null;
+}
+
+/**
+ * Extracts average scale from an affine model.
+ * @param {Object} model - Calibration model
+ * @returns {number|null} - Scale or null
+ */
+function extractAffineScale(model) {
+ if (!model.matrix) {
+ return null;
+ }
+ const { a, b, c, d } = model.matrix;
+ if (typeof a !== 'number' || typeof b !== 'number' ||
+ typeof c !== 'number' || typeof d !== 'number') {
+ return null;
+ }
+ const scaleX = Math.hypot(a, b);
+ const scaleY = Math.hypot(c, d);
+ return (scaleX + scaleY) / 2;
+}
+
+/**
+ * Extracts the scale (meters per pixel) from a calibration result.
+ * For similarity transforms, scale = sqrt(a² + b²) where the matrix coefficients
+ * represent the scale and rotation components.
+ *
+ * The calibration matrix maps pixels → geo coordinates (ENU in meters),
+ * so the scale directly gives meters per pixel.
+ *
+ * @param {Object} calibrationResult - Result from calibrateMap()
+ * @returns {number|null} - Scale in meters per pixel, or null if not extractable
+ */
+export function getMetersPerPixelFromCalibration(calibrationResult) {
+ if (!calibrationResult || calibrationResult.status !== 'ok' || !calibrationResult.model) {
+ return null;
+ }
+
+ const { model } = calibrationResult;
+
+ const extractors = {
+ similarity: extractSimilarityScale,
+ affine: extractAffineScale,
+ homography: () => null, // Scale varies across image
+ };
+
+ const extractor = extractors[model.type];
+ return extractor ? extractor(model) : null;
+}
+
+/**
+ * Determines the active metersPerPixel value based on available sources.
+ * Priority: manual referenceDistance > GPS calibration
+ *
+ * @param {Object} state - Application state containing referenceDistance and/or calibration
+ * @returns {{ metersPerPixel: number, source: 'manual' | 'gps' } | null}
+ */
+export function getActiveScale(state) {
+ if (!state) {
+ return null;
+ }
+
+ // Priority 1: Manual reference distance (most trusted)
+ if (state.referenceDistance &&
+ typeof state.referenceDistance.metersPerPixel === 'number' &&
+ state.referenceDistance.metersPerPixel > 0) {
+ return {
+ metersPerPixel: state.referenceDistance.metersPerPixel,
+ source: 'manual'
+ };
+ }
+
+ // Priority 2: GPS calibration
+ if (state.calibration) {
+ const scale = getMetersPerPixelFromCalibration(state.calibration);
+ if (scale !== null && scale > 0) {
+ return {
+ metersPerPixel: scale,
+ source: 'gps'
+ };
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Calculates the distance in meters between two pixel points given a scale.
+ *
+ * @param {Object} p1 - First point in pixel coordinates {x, y}
+ * @param {Object} p2 - Second point in pixel coordinates {x, y}
+ * @param {number} metersPerPixel - Scale factor
+ * @returns {number|null} - Distance in meters, or null if inputs are invalid
+ */
+export function measureDistance(p1, p2, metersPerPixel) {
+ if (!arePointsValid(p1, p2)) {
+ return null;
+ }
+
+ if (!Number.isFinite(metersPerPixel) || metersPerPixel <= 0) {
+ return null;
+ }
+
+ return pixelDistance(p1, p2) * metersPerPixel;
+}
+
+/**
+ * Formats a distance value for display in the user's preferred unit.
+ *
+ * @param {number} meters - Distance in meters
+ * @param {'m' | 'ft' | 'ft-in'} unit - Display unit preference
+ * @returns {string} - Formatted distance string
+ */
+export function formatDistance(meters, unit = 'm') {
+ if (!Number.isFinite(meters) || meters < 0) {
+ return '—';
+ }
+
+ switch (unit) {
+ case 'ft': {
+ const feet = meters * METERS_TO_FEET;
+ return `${feet.toFixed(2)} ft`;
+ }
+ case 'ft-in': {
+ const totalInches = meters * METERS_TO_FEET * 12;
+ const feet = Math.floor(totalInches / 12);
+ const inches = Math.round(totalInches % 12);
+ // Handle case where inches rounds to 12
+ if (inches === 12) {
+ return `${feet + 1}' 0"`;
+ }
+ return `${feet}' ${inches}"`;
+ }
+ default: {
+ // Meters (default)
+ if (meters < 0.01) {
+ return `${(meters * 1000).toFixed(1)} mm`;
+ }
+ if (meters < 1) {
+ return `${(meters * 100).toFixed(1)} cm`;
+ }
+ return `${meters.toFixed(2)} m`;
+ }
+ }
+}
+
+/**
+ * Compares two scale values and determines if they differ significantly.
+ * Used to warn users when manual reference and GPS-derived scales disagree.
+ *
+ * @param {number} scale1 - First scale (meters per pixel)
+ * @param {number} scale2 - Second scale (meters per pixel)
+ * @param {number} [threshold=0.10] - Relative difference threshold (default 10%)
+ * @returns {{ differs: boolean, percentDifference: number } | null}
+ */
+export function compareScales(scale1, scale2, threshold = 0.10) {
+ if (!Number.isFinite(scale1) || !Number.isFinite(scale2) ||
+ scale1 <= 0 || scale2 <= 0) {
+ return null;
+ }
+
+ const percentDifference = Math.abs(scale1 - scale2) / Math.max(scale1, scale2);
+
+ return {
+ differs: percentDifference > threshold,
+ percentDifference
+ };
+}
+
+const api = {
+ computeReferenceScale,
+ getMetersPerPixelFromCalibration,
+ getActiveScale,
+ measureDistance,
+ formatDistance,
+ compareScales,
+ METERS_PER_DEGREE_EQUATOR,
+ METERS_TO_FEET,
+};
+
+export default api;
diff --git a/src/scale/scale.test.js b/src/scale/scale.test.js
new file mode 100644
index 0000000..6e9fe0d
--- /dev/null
+++ b/src/scale/scale.test.js
@@ -0,0 +1,424 @@
+import {
+ computeReferenceScale,
+ getMetersPerPixelFromCalibration,
+ getActiveScale,
+ measureDistance,
+ formatDistance,
+ compareScales,
+ METERS_TO_FEET,
+} from './scale.js';
+
+describe('scale module', () => {
+ describe('computeReferenceScale', () => {
+ test('computes correct scale for horizontal line', () => {
+ const p1 = { x: 0, y: 0 };
+ const p2 = { x: 100, y: 0 };
+ const meters = 10;
+ expect(computeReferenceScale(p1, p2, meters)).toBe(0.1); // 10m / 100px = 0.1 m/px
+ });
+
+ test('computes correct scale for vertical line', () => {
+ const p1 = { x: 50, y: 0 };
+ const p2 = { x: 50, y: 200 };
+ const meters = 20;
+ expect(computeReferenceScale(p1, p2, meters)).toBe(0.1); // 20m / 200px = 0.1 m/px
+ });
+
+ test('computes correct scale for diagonal line', () => {
+ const p1 = { x: 0, y: 0 };
+ const p2 = { x: 30, y: 40 }; // 3-4-5 triangle, distance = 50
+ const meters = 5;
+ expect(computeReferenceScale(p1, p2, meters)).toBe(0.1); // 5m / 50px = 0.1 m/px
+ });
+
+ test('returns null for coincident points', () => {
+ const p1 = { x: 100, y: 200 };
+ const p2 = { x: 100, y: 200 };
+ expect(computeReferenceScale(p1, p2, 10)).toBeNull();
+ });
+
+ test('returns null for zero meters', () => {
+ const p1 = { x: 0, y: 0 };
+ const p2 = { x: 100, y: 0 };
+ expect(computeReferenceScale(p1, p2, 0)).toBeNull();
+ });
+
+ test('returns null for negative meters', () => {
+ const p1 = { x: 0, y: 0 };
+ const p2 = { x: 100, y: 0 };
+ expect(computeReferenceScale(p1, p2, -5)).toBeNull();
+ });
+
+ test('returns null for NaN meters', () => {
+ const p1 = { x: 0, y: 0 };
+ const p2 = { x: 100, y: 0 };
+ expect(computeReferenceScale(p1, p2, NaN)).toBeNull();
+ });
+
+ test('returns null for Infinity meters', () => {
+ const p1 = { x: 0, y: 0 };
+ const p2 = { x: 100, y: 0 };
+ expect(computeReferenceScale(p1, p2, Infinity)).toBeNull();
+ });
+
+ test('returns null for null/undefined points', () => {
+ expect(computeReferenceScale(null, { x: 0, y: 0 }, 10)).toBeNull();
+ expect(computeReferenceScale({ x: 0, y: 0 }, undefined, 10)).toBeNull();
+ expect(computeReferenceScale(null, null, 10)).toBeNull();
+ });
+
+ test('returns null for points with missing coordinates', () => {
+ expect(computeReferenceScale({ x: 0 }, { x: 100, y: 0 }, 10)).toBeNull();
+ expect(computeReferenceScale({ x: 0, y: 0 }, { y: 100 }, 10)).toBeNull();
+ });
+ });
+
+ describe('getMetersPerPixelFromCalibration', () => {
+ test('extracts scale from similarity calibration', () => {
+ const calibration = {
+ status: 'ok',
+ model: {
+ type: 'similarity',
+ scale: 0.05,
+ rotation: 0,
+ translation: { x: 0, y: 0 }
+ }
+ };
+ expect(getMetersPerPixelFromCalibration(calibration)).toBe(0.05);
+ });
+
+ test('returns absolute value for negative similarity scale', () => {
+ const calibration = {
+ status: 'ok',
+ model: {
+ type: 'similarity',
+ scale: -0.05,
+ rotation: Math.PI,
+ translation: { x: 0, y: 0 }
+ }
+ };
+ expect(getMetersPerPixelFromCalibration(calibration)).toBe(0.05);
+ });
+
+ test('extracts average scale from affine calibration', () => {
+ // Affine with uniform scale of 0.1 (no shear)
+ const calibration = {
+ status: 'ok',
+ model: {
+ type: 'affine',
+ matrix: { a: 0.1, b: 0, c: 0, d: 0.1, tx: 10, ty: 20 }
+ }
+ };
+ expect(getMetersPerPixelFromCalibration(calibration)).toBe(0.1);
+ });
+
+ test('extracts average scale from affine with rotation', () => {
+ // Affine with scale=0.1 and 45° rotation
+ const cos45 = Math.cos(Math.PI / 4);
+ const sin45 = Math.sin(Math.PI / 4);
+ const scale = 0.1;
+ const calibration = {
+ status: 'ok',
+ model: {
+ type: 'affine',
+ matrix: {
+ a: scale * cos45,
+ b: -scale * sin45,
+ c: scale * sin45,
+ d: scale * cos45,
+ tx: 0,
+ ty: 0
+ }
+ }
+ };
+ expect(getMetersPerPixelFromCalibration(calibration)).toBeCloseTo(0.1, 10);
+ });
+
+ test('returns null for homography (scale varies)', () => {
+ const calibration = {
+ status: 'ok',
+ model: {
+ type: 'homography',
+ matrix: [[1, 0, 0], [0, 1, 0], [0.001, 0.001, 1]]
+ }
+ };
+ expect(getMetersPerPixelFromCalibration(calibration)).toBeNull();
+ });
+
+ test('returns null for failed calibration', () => {
+ expect(getMetersPerPixelFromCalibration({ status: 'fit-failed' })).toBeNull();
+ expect(getMetersPerPixelFromCalibration({ status: 'insufficient-pairs' })).toBeNull();
+ });
+
+ test('returns null for null/undefined calibration', () => {
+ expect(getMetersPerPixelFromCalibration(null)).toBeNull();
+ expect(getMetersPerPixelFromCalibration(undefined)).toBeNull();
+ });
+
+ test('returns null for calibration without model', () => {
+ expect(getMetersPerPixelFromCalibration({ status: 'ok' })).toBeNull();
+ expect(getMetersPerPixelFromCalibration({ status: 'ok', model: null })).toBeNull();
+ });
+
+ test('returns null for affine with invalid matrix coefficients', () => {
+ const calibration = {
+ status: 'ok',
+ model: {
+ type: 'affine',
+ matrix: { a: 'invalid', b: 0, c: 0, d: 0.1 }
+ }
+ };
+ expect(getMetersPerPixelFromCalibration(calibration)).toBeNull();
+ });
+
+ test('returns null for affine without matrix property', () => {
+ const calibration = {
+ status: 'ok',
+ model: { type: 'affine' }
+ };
+ expect(getMetersPerPixelFromCalibration(calibration)).toBeNull();
+ });
+
+ test('returns null for similarity with non-number scale', () => {
+ const calibration = {
+ status: 'ok',
+ model: { type: 'similarity', scale: 'invalid' }
+ };
+ expect(getMetersPerPixelFromCalibration(calibration)).toBeNull();
+ });
+
+ test('returns null for unknown model type', () => {
+ const calibration = {
+ status: 'ok',
+ model: { type: 'unknown', foo: 'bar' }
+ };
+ expect(getMetersPerPixelFromCalibration(calibration)).toBeNull();
+ });
+ });
+
+ describe('getActiveScale', () => {
+ test('returns manual reference when both sources exist (priority)', () => {
+ const state = {
+ referenceDistance: { metersPerPixel: 0.05 },
+ calibration: {
+ status: 'ok',
+ model: { type: 'similarity', scale: 0.08 }
+ }
+ };
+ const result = getActiveScale(state);
+ expect(result).toEqual({ metersPerPixel: 0.05, source: 'manual' });
+ });
+
+ test('returns manual reference when only manual exists', () => {
+ const state = {
+ referenceDistance: { metersPerPixel: 0.05 },
+ calibration: null
+ };
+ const result = getActiveScale(state);
+ expect(result).toEqual({ metersPerPixel: 0.05, source: 'manual' });
+ });
+
+ test('returns GPS calibration when only calibration exists', () => {
+ const state = {
+ referenceDistance: null,
+ calibration: {
+ status: 'ok',
+ model: { type: 'similarity', scale: 0.08 }
+ }
+ };
+ const result = getActiveScale(state);
+ expect(result).toEqual({ metersPerPixel: 0.08, source: 'gps' });
+ });
+
+ test('returns null when no scale sources exist', () => {
+ expect(getActiveScale({ referenceDistance: null, calibration: null })).toBeNull();
+ expect(getActiveScale({})).toBeNull();
+ });
+
+ test('returns null for null state', () => {
+ expect(getActiveScale(null)).toBeNull();
+ expect(getActiveScale(undefined)).toBeNull();
+ });
+
+ test('falls back to GPS when manual reference has invalid scale', () => {
+ const state = {
+ referenceDistance: { metersPerPixel: 0 },
+ calibration: {
+ status: 'ok',
+ model: { type: 'similarity', scale: 0.08 }
+ }
+ };
+ const result = getActiveScale(state);
+ expect(result).toEqual({ metersPerPixel: 0.08, source: 'gps' });
+ });
+
+ test('falls back to GPS when manual reference has negative scale', () => {
+ const state = {
+ referenceDistance: { metersPerPixel: -0.05 },
+ calibration: {
+ status: 'ok',
+ model: { type: 'similarity', scale: 0.08 }
+ }
+ };
+ const result = getActiveScale(state);
+ expect(result).toEqual({ metersPerPixel: 0.08, source: 'gps' });
+ });
+
+ test('returns null when GPS calibration failed', () => {
+ const state = {
+ referenceDistance: null,
+ calibration: { status: 'fit-failed' }
+ };
+ expect(getActiveScale(state)).toBeNull();
+ });
+ });
+
+ describe('measureDistance', () => {
+ test('calculates correct distance for horizontal line', () => {
+ const p1 = { x: 0, y: 0 };
+ const p2 = { x: 100, y: 0 };
+ const metersPerPixel = 0.1;
+ expect(measureDistance(p1, p2, metersPerPixel)).toBe(10); // 100px * 0.1 = 10m
+ });
+
+ test('calculates correct distance for diagonal line', () => {
+ const p1 = { x: 0, y: 0 };
+ const p2 = { x: 30, y: 40 }; // distance = 50px
+ const metersPerPixel = 0.2;
+ expect(measureDistance(p1, p2, metersPerPixel)).toBe(10); // 50px * 0.2 = 10m
+ });
+
+ test('returns 0 for coincident points', () => {
+ const p1 = { x: 50, y: 50 };
+ const p2 = { x: 50, y: 50 };
+ expect(measureDistance(p1, p2, 0.1)).toBe(0);
+ });
+
+ test('returns null for invalid points', () => {
+ expect(measureDistance(null, { x: 0, y: 0 }, 0.1)).toBeNull();
+ expect(measureDistance({ x: 0, y: 0 }, null, 0.1)).toBeNull();
+ expect(measureDistance({ x: 0 }, { x: 0, y: 0 }, 0.1)).toBeNull();
+ });
+
+ test('returns null for invalid scale', () => {
+ const p1 = { x: 0, y: 0 };
+ const p2 = { x: 100, y: 0 };
+ expect(measureDistance(p1, p2, 0)).toBeNull();
+ expect(measureDistance(p1, p2, -0.1)).toBeNull();
+ expect(measureDistance(p1, p2, NaN)).toBeNull();
+ expect(measureDistance(p1, p2, Infinity)).toBeNull();
+ });
+ });
+
+ describe('formatDistance', () => {
+ describe('meters (default)', () => {
+ test('formats large distances in meters', () => {
+ expect(formatDistance(5.25, 'm')).toBe('5.25 m');
+ expect(formatDistance(100, 'm')).toBe('100.00 m');
+ expect(formatDistance(1.5)).toBe('1.50 m'); // default unit
+ });
+
+ test('formats sub-meter distances in centimeters', () => {
+ expect(formatDistance(0.5, 'm')).toBe('50.0 cm');
+ expect(formatDistance(0.01, 'm')).toBe('1.0 cm');
+ });
+
+ test('formats tiny distances in millimeters', () => {
+ expect(formatDistance(0.005, 'm')).toBe('5.0 mm');
+ expect(formatDistance(0.001, 'm')).toBe('1.0 mm');
+ });
+ });
+
+ describe('feet', () => {
+ test('formats distance in feet', () => {
+ expect(formatDistance(1, 'ft')).toBe(`${METERS_TO_FEET.toFixed(2)} ft`);
+ expect(formatDistance(3.048, 'ft')).toBe('10.00 ft'); // 3.048m ≈ 10ft
+ });
+
+ test('formats fractional feet', () => {
+ expect(formatDistance(0.3048, 'ft')).toBe('1.00 ft'); // 0.3048m = 1ft
+ });
+ });
+
+ describe('feet and inches', () => {
+ test('formats whole feet with zero inches', () => {
+ expect(formatDistance(0.3048, 'ft-in')).toBe("1' 0\"");
+ expect(formatDistance(1.8288, 'ft-in')).toBe("6' 0\"");
+ });
+
+ test('formats feet with inches', () => {
+ // 1.7018m ≈ 5'7"
+ expect(formatDistance(1.7018, 'ft-in')).toBe("5' 7\"");
+ });
+
+ test('handles rounding to 12 inches', () => {
+ // Edge case: when inches rounds to 12, should become next foot
+ // 0.6096m = 2ft exactly, but 0.6090m rounds to 2'0"
+ const almostTwoFeet = 0.6090; // Just under 2 feet
+ const result = formatDistance(almostTwoFeet, 'ft-in');
+ // Should be close to 2' 0"
+ expect(result).toMatch(/[12]' \d+"/);
+ });
+ });
+
+ describe('edge cases', () => {
+ test('returns dash for NaN', () => {
+ expect(formatDistance(NaN, 'm')).toBe('—');
+ });
+
+ test('returns dash for negative values', () => {
+ expect(formatDistance(-5, 'm')).toBe('—');
+ });
+
+ test('returns dash for Infinity', () => {
+ expect(formatDistance(Infinity, 'm')).toBe('—');
+ });
+
+ test('handles zero correctly', () => {
+ expect(formatDistance(0, 'm')).toBe('0.0 mm');
+ expect(formatDistance(0, 'ft')).toBe('0.00 ft');
+ expect(formatDistance(0, 'ft-in')).toBe("0' 0\"");
+ });
+ });
+ });
+
+ describe('compareScales', () => {
+ test('detects scales that differ by more than threshold', () => {
+ const result = compareScales(0.10, 0.05, 0.10);
+ expect(result.differs).toBe(true);
+ expect(result.percentDifference).toBeCloseTo(0.5, 10); // 50% difference
+ });
+
+ test('detects scales within threshold', () => {
+ const result = compareScales(0.10, 0.095, 0.10);
+ expect(result.differs).toBe(false);
+ expect(result.percentDifference).toBeCloseTo(0.05, 10); // 5% difference
+ });
+
+ test('uses 10% default threshold', () => {
+ expect(compareScales(1.0, 0.91).differs).toBe(false); // 9% diff
+ expect(compareScales(1.0, 0.89).differs).toBe(true); // 11% diff
+ });
+
+ test('is symmetric (order independent)', () => {
+ const result1 = compareScales(0.10, 0.05);
+ const result2 = compareScales(0.05, 0.10);
+ expect(result1.percentDifference).toBeCloseTo(result2.percentDifference, 10);
+ expect(result1.differs).toBe(result2.differs);
+ });
+
+ test('returns null for invalid inputs', () => {
+ expect(compareScales(0, 0.1)).toBeNull();
+ expect(compareScales(0.1, 0)).toBeNull();
+ expect(compareScales(-0.1, 0.1)).toBeNull();
+ expect(compareScales(NaN, 0.1)).toBeNull();
+ expect(compareScales(0.1, Infinity)).toBeNull();
+ });
+
+ test('handles identical scales', () => {
+ const result = compareScales(0.05, 0.05);
+ expect(result.differs).toBe(false);
+ expect(result.percentDifference).toBe(0);
+ });
+ });
+});
From 3c22454e32f18110da66c99718713d2b7a04188a Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Sun, 21 Dec 2025 07:44:29 +0000
Subject: [PATCH 20/47] feat: add scale and measure mode buttons with
functionality for setting reference distances
---
index.html | 3 +
src/index.js | 442 +++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 445 insertions(+)
diff --git a/index.html b/index.html
index 8636148..42083ec 100644
--- a/index.html
+++ b/index.html
@@ -42,6 +42,9 @@
Snap2Map
Use my position
Confirm pair
Cancel
+
+ 📏 Set Scale
+ 📐 Measure
diff --git a/src/index.js b/src/index.js
index 699580d..6facb98 100644
--- a/src/index.js
+++ b/src/index.js
@@ -6,6 +6,12 @@ import {
projectLocationToPixel,
accuracyRingRadiusPixels,
} from 'snap2map/calibrator';
+import {
+ computeReferenceScale,
+ getActiveScale,
+ measureDistance,
+ formatDistance,
+} from './scale/scale.js';
const GUIDED_PAIR_TARGET = 2;
const MAX_PHOTO_DIMENSION = 2048*2; // pixels
@@ -46,6 +52,34 @@ const state = {
referenceDistance: null,
// User's preferred display unit for distances: 'm' | 'ft' | 'ft-in'
preferredUnit: 'm',
+ // Scale mode: setting the reference distance line
+ scaleMode: {
+ active: false,
+ step: null, // 'p1' | 'p2' | 'input'
+ p1: null,
+ p2: null,
+ marker1: null,
+ marker2: null,
+ line: null,
+ },
+ // Reference distance visualization (persists after scaleMode is done)
+ referenceMarkers: {
+ marker1: null,
+ marker2: null,
+ line: null,
+ label: null,
+ },
+ // Measure mode: measuring arbitrary distances
+ measureMode: {
+ active: false,
+ step: null, // 'p1' | 'p2'
+ p1: null,
+ p2: null,
+ marker1: null,
+ marker2: null,
+ line: null,
+ label: null,
+ },
};
const dom = {};
@@ -552,6 +586,17 @@ function onPairTableClick(event) {
}
function handlePhotoClick(event) {
+ // Route to scale mode handler first
+ if (handleScaleModeClick(event)) {
+ return;
+ }
+
+ // Route to measure mode handler
+ if (handleMeasureModeClick(event)) {
+ return;
+ }
+
+ // Default: pair mode
if (!state.activePair) {
return;
}
@@ -622,12 +667,397 @@ function useCurrentPositionForPair() {
);
}
+// ─────────────────────────────────────────────────────────────────────────────
+// Scale Mode: Set reference distance for manual scale definition
+// ─────────────────────────────────────────────────────────────────────────────
+
+function createScaleMarkerIcon(color = '#3b82f6') {
+ return L.divIcon({
+ className: 'scale-marker',
+ html: `
`,
+ iconSize: [14, 14],
+ iconAnchor: [7, 7],
+ });
+}
+
+function clearScaleModeMarkers() {
+ if (state.scaleMode.marker1) {
+ state.scaleMode.marker1.remove();
+ state.scaleMode.marker1 = null;
+ }
+ if (state.scaleMode.marker2) {
+ state.scaleMode.marker2.remove();
+ state.scaleMode.marker2 = null;
+ }
+ if (state.scaleMode.line) {
+ state.scaleMode.line.remove();
+ state.scaleMode.line = null;
+ }
+}
+
+function clearReferenceVisualization() {
+ if (state.referenceMarkers.marker1) {
+ state.referenceMarkers.marker1.remove();
+ state.referenceMarkers.marker1 = null;
+ }
+ if (state.referenceMarkers.marker2) {
+ state.referenceMarkers.marker2.remove();
+ state.referenceMarkers.marker2 = null;
+ }
+ if (state.referenceMarkers.line) {
+ state.referenceMarkers.line.remove();
+ state.referenceMarkers.line = null;
+ }
+ if (state.referenceMarkers.label) {
+ state.referenceMarkers.label.remove();
+ state.referenceMarkers.label = null;
+ }
+}
+
+function drawReferenceVisualization() {
+ if (!state.referenceDistance || !state.photoMap) {
+ return;
+ }
+ clearReferenceVisualization();
+
+ const { p1, p2, meters } = state.referenceDistance;
+ const latlng1 = L.latLng(p1.y, p1.x);
+ const latlng2 = L.latLng(p2.y, p2.x);
+
+ state.referenceMarkers.marker1 = L.marker(latlng1, {
+ icon: createScaleMarkerIcon('#10b981'),
+ draggable: false,
+ }).addTo(state.photoMap);
+
+ state.referenceMarkers.marker2 = L.marker(latlng2, {
+ icon: createScaleMarkerIcon('#10b981'),
+ draggable: false,
+ }).addTo(state.photoMap);
+
+ state.referenceMarkers.line = L.polyline([latlng1, latlng2], {
+ color: '#10b981',
+ weight: 3,
+ dashArray: '8, 8',
+ opacity: 0.9,
+ }).addTo(state.photoMap);
+
+ const midLat = (p1.y + p2.y) / 2;
+ const midLng = (p1.x + p2.x) / 2;
+ state.referenceMarkers.label = L.marker(L.latLng(midLat, midLng), {
+ icon: L.divIcon({
+ className: 'reference-label',
+ html: `📏 ${formatDistance(meters, state.preferredUnit)}
`,
+ iconAnchor: [0, 0],
+ }),
+ }).addTo(state.photoMap);
+}
+
+function updateScaleModeLine() {
+ const { p1, p2 } = state.scaleMode;
+ if (!p1 || !p2) {
+ return;
+ }
+ const latlng1 = L.latLng(p1.y, p1.x);
+ const latlng2 = L.latLng(p2.y, p2.x);
+
+ if (state.scaleMode.line) {
+ state.scaleMode.line.setLatLngs([latlng1, latlng2]);
+ } else {
+ state.scaleMode.line = L.polyline([latlng1, latlng2], {
+ color: '#3b82f6',
+ weight: 3,
+ dashArray: '6, 6',
+ opacity: 0.9,
+ }).addTo(state.photoMap);
+ }
+}
+
+function promptForReferenceDistance() {
+ const input = prompt('Enter the distance in meters:');
+ if (input === null) {
+ cancelScaleMode();
+ return;
+ }
+
+ const meters = parseFloat(input);
+ if (!Number.isFinite(meters) || meters <= 0) {
+ showToast('Invalid distance. Please enter a positive number.', { tone: 'warning' });
+ cancelScaleMode();
+ return;
+ }
+
+ const { p1, p2 } = state.scaleMode;
+ const metersPerPixel = computeReferenceScale(p1, p2, meters);
+
+ if (!metersPerPixel) {
+ showToast('Could not compute scale. Points may be too close.', { tone: 'warning' });
+ cancelScaleMode();
+ return;
+ }
+
+ state.referenceDistance = { p1, p2, meters, metersPerPixel };
+ clearScaleModeMarkers();
+ state.scaleMode.active = false;
+ state.scaleMode.step = null;
+ state.scaleMode.p1 = null;
+ state.scaleMode.p2 = null;
+
+ drawReferenceVisualization();
+ updateMeasureButtonState();
+ showToast(`Scale set: ${formatDistance(meters, state.preferredUnit)} = ${metersPerPixel.toFixed(4)} m/px`, { tone: 'success' });
+}
+
+function startScaleMode() {
+ if (state.activePair) {
+ cancelPairMode();
+ }
+ if (state.measureMode.active) {
+ cancelMeasureMode();
+ }
+
+ state.scaleMode.active = true;
+ state.scaleMode.step = 'p1';
+ state.scaleMode.p1 = null;
+ state.scaleMode.p2 = null;
+ clearScaleModeMarkers();
+
+ if (dom.setScaleButton) {
+ dom.setScaleButton.disabled = true;
+ }
+
+ setActiveView('photo');
+ showToast('Tap the start point of a known distance.');
+}
+
+function cancelScaleMode() {
+ clearScaleModeMarkers();
+ state.scaleMode.active = false;
+ state.scaleMode.step = null;
+ state.scaleMode.p1 = null;
+ state.scaleMode.p2 = null;
+
+ if (dom.setScaleButton) {
+ dom.setScaleButton.disabled = false;
+ }
+}
+
+function handleScaleModeClick(event) {
+ if (!state.scaleMode.active) {
+ return false;
+ }
+
+ const pixel = { x: event.latlng.lng, y: event.latlng.lat };
+
+ if (state.scaleMode.step === 'p1') {
+ state.scaleMode.p1 = pixel;
+ state.scaleMode.marker1 = L.marker(event.latlng, {
+ icon: createScaleMarkerIcon('#3b82f6'),
+ draggable: false,
+ }).addTo(state.photoMap);
+ state.scaleMode.step = 'p2';
+ showToast('Now tap the end point.');
+ return true;
+ }
+
+ if (state.scaleMode.step === 'p2') {
+ state.scaleMode.p2 = pixel;
+ state.scaleMode.marker2 = L.marker(event.latlng, {
+ icon: createScaleMarkerIcon('#3b82f6'),
+ draggable: false,
+ }).addTo(state.photoMap);
+ updateScaleModeLine();
+ state.scaleMode.step = 'input';
+ promptForReferenceDistance();
+ return true;
+ }
+
+ return false;
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Measure Mode: Measure arbitrary distances using the active scale
+// ─────────────────────────────────────────────────────────────────────────────
+
+function clearMeasureModeMarkers() {
+ if (state.measureMode.marker1) {
+ state.measureMode.marker1.remove();
+ state.measureMode.marker1 = null;
+ }
+ if (state.measureMode.marker2) {
+ state.measureMode.marker2.remove();
+ state.measureMode.marker2 = null;
+ }
+ if (state.measureMode.line) {
+ state.measureMode.line.remove();
+ state.measureMode.line = null;
+ }
+ if (state.measureMode.label) {
+ state.measureMode.label.remove();
+ state.measureMode.label = null;
+ }
+}
+
+function updateMeasureLabel() {
+ const { p1, p2 } = state.measureMode;
+ if (!p1 || !p2) {
+ return;
+ }
+
+ const scale = getActiveScale(state);
+ if (!scale) {
+ return;
+ }
+
+ const meters = measureDistance(p1, p2, scale.metersPerPixel);
+ if (meters === null) {
+ return;
+ }
+
+ const midLat = (p1.y + p2.y) / 2;
+ const midLng = (p1.x + p2.x) / 2;
+ const sourceIcon = scale.source === 'manual' ? '📏' : '📡';
+
+ if (state.measureMode.label) {
+ state.measureMode.label.remove();
+ }
+
+ state.measureMode.label = L.marker(L.latLng(midLat, midLng), {
+ icon: L.divIcon({
+ className: 'measure-label',
+ html: `${sourceIcon} ${formatDistance(meters, state.preferredUnit)}
`,
+ iconAnchor: [0, 0],
+ }),
+ }).addTo(state.photoMap);
+}
+
+function updateMeasureModeLine() {
+ const { p1, p2 } = state.measureMode;
+ if (!p1 || !p2) {
+ return;
+ }
+
+ const latlng1 = L.latLng(p1.y, p1.x);
+ const latlng2 = L.latLng(p2.y, p2.x);
+
+ if (state.measureMode.line) {
+ state.measureMode.line.setLatLngs([latlng1, latlng2]);
+ } else {
+ state.measureMode.line = L.polyline([latlng1, latlng2], {
+ color: '#8b5cf6',
+ weight: 3,
+ opacity: 0.9,
+ }).addTo(state.photoMap);
+ }
+
+ updateMeasureLabel();
+}
+
+function startMeasureMode() {
+ const scale = getActiveScale(state);
+ if (!scale) {
+ showToast('Set a reference scale or add GPS pairs first.', { tone: 'warning' });
+ return;
+ }
+
+ if (state.activePair) {
+ cancelPairMode();
+ }
+ if (state.scaleMode.active) {
+ cancelScaleMode();
+ }
+
+ clearMeasureModeMarkers();
+ state.measureMode.active = true;
+ state.measureMode.step = 'p1';
+ state.measureMode.p1 = null;
+ state.measureMode.p2 = null;
+
+ if (dom.measureButton) {
+ dom.measureButton.disabled = true;
+ }
+
+ setActiveView('photo');
+ const sourceText = scale.source === 'manual' ? 'manual scale' : 'GPS calibration';
+ showToast(`Measure mode (${sourceText}). Tap start point.`);
+}
+
+function cancelMeasureMode() {
+ clearMeasureModeMarkers();
+ state.measureMode.active = false;
+ state.measureMode.step = null;
+ state.measureMode.p1 = null;
+ state.measureMode.p2 = null;
+
+ updateMeasureButtonState();
+}
+
+function handleMeasureModeClick(event) {
+ if (!state.measureMode.active) {
+ return false;
+ }
+
+ const pixel = { x: event.latlng.lng, y: event.latlng.lat };
+
+ if (state.measureMode.step === 'p1') {
+ state.measureMode.p1 = pixel;
+ state.measureMode.marker1 = L.marker(event.latlng, {
+ icon: createScaleMarkerIcon('#8b5cf6'),
+ draggable: true,
+ }).addTo(state.photoMap);
+
+ state.measureMode.marker1.on('drag', () => {
+ const latlng = state.measureMode.marker1.getLatLng();
+ state.measureMode.p1 = { x: latlng.lng, y: latlng.lat };
+ updateMeasureModeLine();
+ });
+
+ state.measureMode.step = 'p2';
+ showToast('Tap the end point to measure.');
+ return true;
+ }
+
+ if (state.measureMode.step === 'p2') {
+ state.measureMode.p2 = pixel;
+ state.measureMode.marker2 = L.marker(event.latlng, {
+ icon: createScaleMarkerIcon('#8b5cf6'),
+ draggable: true,
+ }).addTo(state.photoMap);
+
+ state.measureMode.marker2.on('drag', () => {
+ const latlng = state.measureMode.marker2.getLatLng();
+ state.measureMode.p2 = { x: latlng.lng, y: latlng.lat };
+ updateMeasureModeLine();
+ });
+
+ updateMeasureModeLine();
+ state.measureMode.step = null;
+
+ if (dom.measureButton) {
+ dom.measureButton.disabled = false;
+ }
+
+ showToast('Drag endpoints to refine. Tap Measure again for a new measurement.', { duration: 5000 });
+ return true;
+ }
+
+ return false;
+}
+
+function updateMeasureButtonState() {
+ if (!dom.measureButton) {
+ return;
+ }
+ const scale = getActiveScale(state);
+ dom.measureButton.disabled = !scale || state.measureMode.active;
+}
+
function recalculateCalibration() {
if (state.pairs.length < 2) {
state.calibration = null;
refreshPairMarkers();
updateStatusText();
stopGeolocationWatch();
+ updateMeasureButtonState();
return;
}
@@ -650,6 +1080,7 @@ function recalculateCalibration() {
refreshPairMarkers();
updateStatusText();
updateLivePosition();
+ updateMeasureButtonState();
}
function loadPhotoMap(dataUrl, width, height) {
@@ -986,6 +1417,9 @@ function cacheDom() {
dom.pairTable = $('pairTable');
dom.toastContainer = $('toastContainer');
dom.replacePhotoButton = $('replacePhotoButton');
+ // Scale and measure mode buttons
+ dom.setScaleButton = $('setScaleButton');
+ dom.measureButton = $('measureButton');
}
function setupEventHandlers() {
@@ -1003,6 +1437,14 @@ function setupEventHandlers() {
dom.pairTableBody.addEventListener('click', onPairTableClick);
dom.photoTabButton.addEventListener('click', () => setActiveView('photo'));
dom.osmTabButton.addEventListener('click', () => setActiveView('osm'));
+
+ // Scale and measure mode handlers
+ if (dom.setScaleButton) {
+ dom.setScaleButton.addEventListener('click', startScaleMode);
+ }
+ if (dom.measureButton) {
+ dom.measureButton.addEventListener('click', startMeasureMode);
+ }
}
function init() {
From 80d3fad518d034522ccbf2c4c833529215ead777 Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Sun, 21 Dec 2025 07:54:17 +0000
Subject: [PATCH 21/47] feat: extract scale and measure mode logic into
dedicated state machine module with comprehensive tests
---
docs/feat-reference-distances.md | 44 ++-
src/scale/scale-mode.js | 295 +++++++++++++++++++
src/scale/scale-mode.test.js | 466 +++++++++++++++++++++++++++++++
3 files changed, 803 insertions(+), 2 deletions(-)
create mode 100644 src/scale/scale-mode.js
create mode 100644 src/scale/scale-mode.test.js
diff --git a/docs/feat-reference-distances.md b/docs/feat-reference-distances.md
index 2d9e2fc..bfa9871 100644
--- a/docs/feat-reference-distances.md
+++ b/docs/feat-reference-distances.md
@@ -318,10 +318,50 @@ This phase implements the pure utility functions for scale management and extend
---
-### 🔲 Phase 2: UI Layer (Not Started)
+### ✅ 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)` - 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 (Partially Complete)
This phase implements the user-facing features: setting a reference distance and measuring arbitrary distances.
-#### `src/index.js`
+#### `src/index.js` (UI Integration)
diff --git a/src/scale/scale-mode.js b/src/scale/scale-mode.js
new file mode 100644
index 0000000..eba5aaa
--- /dev/null
+++ b/src/scale/scale-mode.js
@@ -0,0 +1,295 @@
+/**
+ * Scale and Measure mode state machine logic.
+ * This module contains pure functions for managing the scale/measure mode state,
+ * separating business logic from DOM/Leaflet interactions.
+ */
+
+import { computeReferenceScale, getActiveScale, measureDistance } from './scale.js';
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Shared Two-Point Mode Handler
+// ─────────────────────────────────────────────────────────────────────────────
+
+/**
+ * Generic handler for two-point mode click events.
+ * @param {Object} currentState - Current mode state
+ * @param {Object} point - The clicked point {x, y}
+ * @param {string} p2Action - Action to return when p2 is clicked
+ * @param {string} p2NextStep - Next step after p2 click (e.g., 'input' or null)
+ * @returns {{ state: Object, action: string | null }}
+ */
+function handleTwoPointModeClick(currentState, point, p2Action, p2NextStep) {
+ if (!currentState.active) {
+ return { state: currentState, action: null };
+ }
+
+ if (currentState.step === 'p1') {
+ return {
+ state: { ...currentState, p1: point, step: 'p2' },
+ action: 'show-p2-toast',
+ };
+ }
+
+ if (currentState.step === 'p2') {
+ return {
+ state: { ...currentState, p2: point, step: p2NextStep },
+ action: p2Action,
+ };
+ }
+
+ return { state: currentState, action: null };
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Scale Mode State Machine
+// ─────────────────────────────────────────────────────────────────────────────
+
+/**
+ * Creates the initial scale mode state.
+ * @returns {Object} Initial scale mode state
+ */
+export function createScaleModeState() {
+ return {
+ active: false,
+ step: null, // 'p1' | 'p2' | 'input' | null
+ p1: null,
+ p2: null,
+ };
+}
+
+/**
+ * Starts the scale mode, returning the new state.
+ * @param {Object} currentState - Current scale mode state
+ * @returns {Object} New state with mode activated
+ */
+export function startScaleModeState(currentState) {
+ return {
+ ...currentState,
+ active: true,
+ step: 'p1',
+ p1: null,
+ p2: null,
+ };
+}
+
+/**
+ * Handles a point click during scale mode.
+ * @param {Object} currentState - Current scale mode state
+ * @param {Object} point - The clicked point {x, y}
+ * @returns {{ state: Object, action: string | null }} New state and action to perform
+ */
+export function handleScaleModePoint(currentState, point) {
+ return handleTwoPointModeClick(currentState, point, 'prompt-distance', 'input');
+}
+
+/**
+ * Validates and processes the user's distance input.
+ * @param {string | null} input - Raw user input from prompt
+ * @returns {{ valid: boolean, meters?: number, error?: string }}
+ */
+export function validateDistanceInput(input) {
+ if (input === null) {
+ return { valid: false, error: 'cancelled' };
+ }
+
+ const meters = parseFloat(input);
+ if (!Number.isFinite(meters) || meters <= 0) {
+ return { valid: false, error: 'invalid-number' };
+ }
+
+ return { valid: true, meters };
+}
+
+/**
+ * Computes the reference distance from scale mode state and user input.
+ * @param {Object} scaleModeState - Current scale mode state with p1 and p2
+ * @param {number} meters - Validated distance in meters
+ * @returns {{ success: boolean, referenceDistance?: Object, error?: string }}
+ */
+export function computeReferenceDistanceFromInput(scaleModeState, meters) {
+ const { p1, p2 } = scaleModeState;
+
+ if (!p1 || !p2) {
+ return { success: false, error: 'missing-points' };
+ }
+
+ const metersPerPixel = computeReferenceScale(p1, p2, meters);
+
+ if (!metersPerPixel) {
+ return { success: false, error: 'computation-failed' };
+ }
+
+ return {
+ success: true,
+ referenceDistance: { p1, p2, meters, metersPerPixel },
+ };
+}
+
+/**
+ * Resets the scale mode state to inactive.
+ * @returns {Object} Reset state
+ */
+export function cancelScaleModeState() {
+ return createScaleModeState();
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Measure Mode State Machine
+// ─────────────────────────────────────────────────────────────────────────────
+
+/**
+ * Creates the initial measure mode state.
+ * @returns {Object} Initial measure mode state
+ */
+export function createMeasureModeState() {
+ return {
+ active: false,
+ step: null, // 'p1' | 'p2' | null (null when measurement complete)
+ p1: null,
+ p2: null,
+ };
+}
+
+/**
+ * Checks if measure mode can be started based on available scale sources.
+ * @param {Object} appState - Application state with referenceDistance and/or calibration
+ * @returns {{ canStart: boolean, scale?: Object, reason?: string }}
+ */
+export function canStartMeasureMode(appState) {
+ const scale = getActiveScale(appState);
+ if (!scale) {
+ return { canStart: false, reason: 'no-scale' };
+ }
+ return { canStart: true, scale };
+}
+
+/**
+ * Starts the measure mode, returning the new state.
+ * @param {Object} currentState - Current measure mode state
+ * @returns {Object} New state with mode activated
+ */
+export function startMeasureModeState(currentState) {
+ return {
+ ...currentState,
+ active: true,
+ step: 'p1',
+ p1: null,
+ p2: null,
+ };
+}
+
+/**
+ * Handles a point click during measure mode.
+ * @param {Object} currentState - Current measure mode state
+ * @param {Object} point - The clicked point {x, y}
+ * @returns {{ state: Object, action: string | null }} New state and action to perform
+ */
+export function handleMeasureModePoint(currentState, point) {
+ return handleTwoPointModeClick(currentState, point, 'measurement-complete', null);
+}
+
+/**
+ * Updates a point in measure mode (e.g., during drag).
+ * @param {Object} currentState - Current measure mode state
+ * @param {'p1' | 'p2'} pointId - Which point to update
+ * @param {Object} newPoint - New point coordinates {x, y}
+ * @returns {Object} Updated state
+ */
+export function updateMeasureModePoint(currentState, pointId, newPoint) {
+ if (!currentState.active || currentState.step !== null) {
+ return currentState;
+ }
+ return {
+ ...currentState,
+ [pointId]: newPoint,
+ };
+}
+
+/**
+ * Computes the current measurement distance.
+ * @param {Object} measureState - Measure mode state with p1 and p2
+ * @param {Object} appState - Application state for getting active scale
+ * @returns {{ success: boolean, meters?: number, source?: string, error?: string }}
+ */
+export function computeMeasurement(measureState, appState) {
+ const { p1, p2 } = measureState;
+
+ if (!p1 || !p2) {
+ return { success: false, error: 'missing-points' };
+ }
+
+ const scale = getActiveScale(appState);
+ if (!scale) {
+ return { success: false, error: 'no-scale' };
+ }
+
+ const meters = measureDistance(p1, p2, scale.metersPerPixel);
+ if (meters === null) {
+ return { success: false, error: 'computation-failed' };
+ }
+
+ return {
+ success: true,
+ meters,
+ source: scale.source,
+ };
+}
+
+/**
+ * Resets the measure mode state to inactive.
+ * @returns {Object} Reset state
+ */
+export function cancelMeasureModeState() {
+ return createMeasureModeState();
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// UI State Derivation
+// ─────────────────────────────────────────────────────────────────────────────
+
+/**
+ * Determines whether the measure button should be enabled.
+ * @param {Object} appState - Application state
+ * @param {Object} measureModeState - Measure mode state
+ * @returns {boolean} True if measure button should be enabled
+ */
+export function shouldEnableMeasureButton(appState, measureModeState) {
+ if (measureModeState.active) {
+ // Re-enable when measurement is complete (step is null)
+ return measureModeState.step === null;
+ }
+ // Enable if a scale is available
+ return getActiveScale(appState) !== null;
+}
+
+/**
+ * Determines whether the set scale button should be enabled.
+ * @param {Object} scaleModeState - Scale mode state
+ * @returns {boolean} True if set scale button should be enabled
+ */
+export function shouldEnableSetScaleButton(scaleModeState) {
+ return !scaleModeState.active;
+}
+
+const api = {
+ // Scale mode
+ createScaleModeState,
+ startScaleModeState,
+ handleScaleModePoint,
+ validateDistanceInput,
+ computeReferenceDistanceFromInput,
+ cancelScaleModeState,
+ // Measure mode
+ createMeasureModeState,
+ canStartMeasureMode,
+ startMeasureModeState,
+ handleMeasureModePoint,
+ updateMeasureModePoint,
+ computeMeasurement,
+ cancelMeasureModeState,
+ // UI state
+ shouldEnableMeasureButton,
+ shouldEnableSetScaleButton,
+};
+
+export default api;
diff --git a/src/scale/scale-mode.test.js b/src/scale/scale-mode.test.js
new file mode 100644
index 0000000..244040f
--- /dev/null
+++ b/src/scale/scale-mode.test.js
@@ -0,0 +1,466 @@
+import {
+ createScaleModeState,
+ startScaleModeState,
+ handleScaleModePoint,
+ validateDistanceInput,
+ computeReferenceDistanceFromInput,
+ cancelScaleModeState,
+ createMeasureModeState,
+ canStartMeasureMode,
+ startMeasureModeState,
+ handleMeasureModePoint,
+ updateMeasureModePoint,
+ computeMeasurement,
+ cancelMeasureModeState,
+ shouldEnableMeasureButton,
+ shouldEnableSetScaleButton,
+} from './scale-mode.js';
+
+describe('scale-mode state machine', () => {
+ // ─────────────────────────────────────────────────────────────────────────
+ // Scale Mode Tests
+ // ─────────────────────────────────────────────────────────────────────────
+
+ describe('createScaleModeState', () => {
+ test('creates inactive state with null values', () => {
+ const state = createScaleModeState();
+ expect(state).toEqual({
+ active: false,
+ step: null,
+ p1: null,
+ p2: null,
+ });
+ });
+ });
+
+ describe('startScaleModeState', () => {
+ test('activates mode and sets step to p1', () => {
+ const initial = createScaleModeState();
+ const started = startScaleModeState(initial);
+ expect(started).toEqual({
+ active: true,
+ step: 'p1',
+ p1: null,
+ p2: null,
+ });
+ });
+
+ test('preserves other properties and resets points', () => {
+ const withPoints = { active: false, step: null, p1: { x: 10, y: 20 }, p2: { x: 30, y: 40 } };
+ const started = startScaleModeState(withPoints);
+ expect(started.p1).toBeNull();
+ expect(started.p2).toBeNull();
+ });
+ });
+
+ describe('handleScaleModePoint', () => {
+ test('returns unchanged state when not active', () => {
+ const inactive = createScaleModeState();
+ const { state, action } = handleScaleModePoint(inactive, { x: 100, y: 200 });
+ expect(state).toEqual(inactive);
+ expect(action).toBeNull();
+ });
+
+ test('handles p1 click - stores point and advances to p2', () => {
+ const atP1 = startScaleModeState(createScaleModeState());
+ const point = { x: 100, y: 200 };
+ const { state, action } = handleScaleModePoint(atP1, point);
+
+ expect(state.p1).toEqual(point);
+ expect(state.step).toBe('p2');
+ expect(state.active).toBe(true);
+ expect(action).toBe('show-p2-toast');
+ });
+
+ test('handles p2 click - stores point and advances to input', () => {
+ const atP2 = {
+ active: true,
+ step: 'p2',
+ p1: { x: 100, y: 200 },
+ p2: null,
+ };
+ const point = { x: 300, y: 400 };
+ const { state, action } = handleScaleModePoint(atP2, point);
+
+ expect(state.p2).toEqual(point);
+ expect(state.step).toBe('input');
+ expect(action).toBe('prompt-distance');
+ });
+
+ test('ignores click during input step', () => {
+ const atInput = {
+ active: true,
+ step: 'input',
+ p1: { x: 100, y: 200 },
+ p2: { x: 300, y: 400 },
+ };
+ const { state, action } = handleScaleModePoint(atInput, { x: 500, y: 600 });
+ expect(state).toEqual(atInput);
+ expect(action).toBeNull();
+ });
+ });
+
+ describe('validateDistanceInput', () => {
+ test('returns cancelled for null input', () => {
+ expect(validateDistanceInput(null)).toEqual({ valid: false, error: 'cancelled' });
+ });
+
+ test('returns invalid for empty string', () => {
+ expect(validateDistanceInput('')).toEqual({ valid: false, error: 'invalid-number' });
+ });
+
+ test('returns invalid for non-numeric string', () => {
+ expect(validateDistanceInput('abc')).toEqual({ valid: false, error: 'invalid-number' });
+ });
+
+ test('returns invalid for zero', () => {
+ expect(validateDistanceInput('0')).toEqual({ valid: false, error: 'invalid-number' });
+ });
+
+ test('returns invalid for negative number', () => {
+ expect(validateDistanceInput('-5')).toEqual({ valid: false, error: 'invalid-number' });
+ });
+
+ test('returns valid for positive number', () => {
+ expect(validateDistanceInput('10.5')).toEqual({ valid: true, meters: 10.5 });
+ });
+
+ test('returns valid for integer string', () => {
+ expect(validateDistanceInput('7')).toEqual({ valid: true, meters: 7 });
+ });
+
+ test('handles whitespace around number', () => {
+ expect(validateDistanceInput(' 5.25 ')).toEqual({ valid: true, meters: 5.25 });
+ });
+ });
+
+ describe('computeReferenceDistanceFromInput', () => {
+ test('returns error for missing p1', () => {
+ const state = { p1: null, p2: { x: 100, y: 0 } };
+ expect(computeReferenceDistanceFromInput(state, 10)).toEqual({
+ success: false,
+ error: 'missing-points',
+ });
+ });
+
+ test('returns error for missing p2', () => {
+ const state = { p1: { x: 0, y: 0 }, p2: null };
+ expect(computeReferenceDistanceFromInput(state, 10)).toEqual({
+ success: false,
+ error: 'missing-points',
+ });
+ });
+
+ test('returns error for coincident points', () => {
+ const state = { p1: { x: 50, y: 50 }, p2: { x: 50, y: 50 } };
+ expect(computeReferenceDistanceFromInput(state, 10)).toEqual({
+ success: false,
+ error: 'computation-failed',
+ });
+ });
+
+ test('computes correct reference distance', () => {
+ const state = { p1: { x: 0, y: 0 }, p2: { x: 100, y: 0 } };
+ const result = computeReferenceDistanceFromInput(state, 10);
+
+ expect(result.success).toBe(true);
+ expect(result.referenceDistance).toEqual({
+ p1: { x: 0, y: 0 },
+ p2: { x: 100, y: 0 },
+ meters: 10,
+ metersPerPixel: 0.1,
+ });
+ });
+
+ test('computes diagonal distance correctly', () => {
+ const state = { p1: { x: 0, y: 0 }, p2: { x: 30, y: 40 } }; // 50px distance
+ const result = computeReferenceDistanceFromInput(state, 5);
+
+ expect(result.success).toBe(true);
+ expect(result.referenceDistance.metersPerPixel).toBe(0.1);
+ });
+ });
+
+ describe('cancelScaleModeState', () => {
+ test('returns inactive state', () => {
+ const cancelled = cancelScaleModeState();
+ expect(cancelled).toEqual(createScaleModeState());
+ });
+ });
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Measure Mode Tests
+ // ─────────────────────────────────────────────────────────────────────────
+
+ describe('createMeasureModeState', () => {
+ test('creates inactive state with null values', () => {
+ const state = createMeasureModeState();
+ expect(state).toEqual({
+ active: false,
+ step: null,
+ p1: null,
+ p2: null,
+ });
+ });
+ });
+
+ describe('canStartMeasureMode', () => {
+ test('returns false when no scale available', () => {
+ const appState = { referenceDistance: null, calibration: null };
+ const result = canStartMeasureMode(appState);
+ expect(result).toEqual({ canStart: false, reason: 'no-scale' });
+ });
+
+ test('returns true with manual reference', () => {
+ const appState = {
+ referenceDistance: { metersPerPixel: 0.1 },
+ calibration: null
+ };
+ const result = canStartMeasureMode(appState);
+ expect(result.canStart).toBe(true);
+ expect(result.scale).toEqual({ metersPerPixel: 0.1, source: 'manual' });
+ });
+
+ test('returns true with GPS calibration', () => {
+ const appState = {
+ referenceDistance: null,
+ calibration: { status: 'ok', model: { type: 'similarity', scale: 0.05 } }
+ };
+ const result = canStartMeasureMode(appState);
+ expect(result.canStart).toBe(true);
+ expect(result.scale).toEqual({ metersPerPixel: 0.05, source: 'gps' });
+ });
+ });
+
+ describe('startMeasureModeState', () => {
+ test('activates mode and sets step to p1', () => {
+ const initial = createMeasureModeState();
+ const started = startMeasureModeState(initial);
+ expect(started).toEqual({
+ active: true,
+ step: 'p1',
+ p1: null,
+ p2: null,
+ });
+ });
+ });
+
+ describe('handleMeasureModePoint', () => {
+ test('returns unchanged state when not active', () => {
+ const inactive = createMeasureModeState();
+ const { state, action } = handleMeasureModePoint(inactive, { x: 100, y: 200 });
+ expect(state).toEqual(inactive);
+ expect(action).toBeNull();
+ });
+
+ test('handles p1 click - stores point and advances to p2', () => {
+ const atP1 = startMeasureModeState(createMeasureModeState());
+ const point = { x: 100, y: 200 };
+ const { state, action } = handleMeasureModePoint(atP1, point);
+
+ expect(state.p1).toEqual(point);
+ expect(state.step).toBe('p2');
+ expect(action).toBe('show-p2-toast');
+ });
+
+ test('handles p2 click - completes measurement', () => {
+ const atP2 = {
+ active: true,
+ step: 'p2',
+ p1: { x: 100, y: 200 },
+ p2: null,
+ };
+ const point = { x: 300, y: 400 };
+ const { state, action } = handleMeasureModePoint(atP2, point);
+
+ expect(state.p2).toEqual(point);
+ expect(state.step).toBeNull(); // Null = complete, endpoints draggable
+ expect(state.active).toBe(true);
+ expect(action).toBe('measurement-complete');
+ });
+
+ test('ignores click after measurement complete', () => {
+ const complete = {
+ active: true,
+ step: null,
+ p1: { x: 100, y: 200 },
+ p2: { x: 300, y: 400 },
+ };
+ const { state, action } = handleMeasureModePoint(complete, { x: 500, y: 600 });
+ expect(state).toEqual(complete);
+ expect(action).toBeNull();
+ });
+ });
+
+ describe('updateMeasureModePoint', () => {
+ test('returns unchanged state when not active', () => {
+ const inactive = createMeasureModeState();
+ const result = updateMeasureModePoint(inactive, 'p1', { x: 50, y: 50 });
+ expect(result).toEqual(inactive);
+ });
+
+ test('returns unchanged state when step is not null (still placing points)', () => {
+ const placingP2 = {
+ active: true,
+ step: 'p2',
+ p1: { x: 100, y: 200 },
+ p2: null,
+ };
+ const result = updateMeasureModePoint(placingP2, 'p1', { x: 50, y: 50 });
+ expect(result).toEqual(placingP2);
+ });
+
+ test('updates p1 when measurement is complete', () => {
+ const complete = {
+ active: true,
+ step: null,
+ p1: { x: 100, y: 200 },
+ p2: { x: 300, y: 400 },
+ };
+ const result = updateMeasureModePoint(complete, 'p1', { x: 150, y: 250 });
+ expect(result.p1).toEqual({ x: 150, y: 250 });
+ expect(result.p2).toEqual({ x: 300, y: 400 });
+ });
+
+ test('updates p2 when measurement is complete', () => {
+ const complete = {
+ active: true,
+ step: null,
+ p1: { x: 100, y: 200 },
+ p2: { x: 300, y: 400 },
+ };
+ const result = updateMeasureModePoint(complete, 'p2', { x: 350, y: 450 });
+ expect(result.p1).toEqual({ x: 100, y: 200 });
+ expect(result.p2).toEqual({ x: 350, y: 450 });
+ });
+ });
+
+ describe('computeMeasurement', () => {
+ test('returns error for missing p1', () => {
+ const measureState = { p1: null, p2: { x: 100, y: 0 } };
+ const appState = { referenceDistance: { metersPerPixel: 0.1 } };
+ expect(computeMeasurement(measureState, appState)).toEqual({
+ success: false,
+ error: 'missing-points',
+ });
+ });
+
+ test('returns error for missing p2', () => {
+ const measureState = { p1: { x: 0, y: 0 }, p2: null };
+ const appState = { referenceDistance: { metersPerPixel: 0.1 } };
+ expect(computeMeasurement(measureState, appState)).toEqual({
+ success: false,
+ error: 'missing-points',
+ });
+ });
+
+ test('returns error for no scale', () => {
+ const measureState = { p1: { x: 0, y: 0 }, p2: { x: 100, y: 0 } };
+ const appState = { referenceDistance: null, calibration: null };
+ expect(computeMeasurement(measureState, appState)).toEqual({
+ success: false,
+ error: 'no-scale',
+ });
+ });
+
+ test('returns error when measureDistance fails (invalid point coordinates)', () => {
+ // Points have non-numeric coordinates, which passes the p1/p2 null check
+ // but fails in measureDistance's arePointsValid check
+ const measureState = { p1: { x: 'invalid', y: 0 }, p2: { x: 100, y: 0 } };
+ const appState = { referenceDistance: { metersPerPixel: 0.1 } };
+ expect(computeMeasurement(measureState, appState)).toEqual({
+ success: false,
+ error: 'computation-failed',
+ });
+ });
+
+ test('computes distance with manual scale', () => {
+ const measureState = { p1: { x: 0, y: 0 }, p2: { x: 100, y: 0 } };
+ const appState = { referenceDistance: { metersPerPixel: 0.1 }, calibration: null };
+ const result = computeMeasurement(measureState, appState);
+
+ expect(result.success).toBe(true);
+ expect(result.meters).toBe(10); // 100px * 0.1 m/px
+ expect(result.source).toBe('manual');
+ });
+
+ test('computes distance with GPS scale', () => {
+ const measureState = { p1: { x: 0, y: 0 }, p2: { x: 200, y: 0 } };
+ const appState = {
+ referenceDistance: null,
+ calibration: { status: 'ok', model: { type: 'similarity', scale: 0.05 } }
+ };
+ const result = computeMeasurement(measureState, appState);
+
+ expect(result.success).toBe(true);
+ expect(result.meters).toBe(10); // 200px * 0.05 m/px
+ expect(result.source).toBe('gps');
+ });
+
+ test('prioritizes manual scale over GPS', () => {
+ const measureState = { p1: { x: 0, y: 0 }, p2: { x: 100, y: 0 } };
+ const appState = {
+ referenceDistance: { metersPerPixel: 0.1 },
+ calibration: { status: 'ok', model: { type: 'similarity', scale: 0.05 } }
+ };
+ const result = computeMeasurement(measureState, appState);
+
+ expect(result.meters).toBe(10); // Uses 0.1, not 0.05
+ expect(result.source).toBe('manual');
+ });
+ });
+
+ describe('cancelMeasureModeState', () => {
+ test('returns inactive state', () => {
+ const cancelled = cancelMeasureModeState();
+ expect(cancelled).toEqual(createMeasureModeState());
+ });
+ });
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // UI State Derivation Tests
+ // ─────────────────────────────────────────────────────────────────────────
+
+ describe('shouldEnableMeasureButton', () => {
+ test('returns false when no scale available', () => {
+ const appState = { referenceDistance: null, calibration: null };
+ const measureState = createMeasureModeState();
+ expect(shouldEnableMeasureButton(appState, measureState)).toBe(false);
+ });
+
+ test('returns true when scale available and mode inactive', () => {
+ const appState = { referenceDistance: { metersPerPixel: 0.1 }, calibration: null };
+ const measureState = createMeasureModeState();
+ expect(shouldEnableMeasureButton(appState, measureState)).toBe(true);
+ });
+
+ test('returns false while placing points', () => {
+ const appState = { referenceDistance: { metersPerPixel: 0.1 }, calibration: null };
+ const measureState = { active: true, step: 'p1', p1: null, p2: null };
+ expect(shouldEnableMeasureButton(appState, measureState)).toBe(false);
+ });
+
+ test('returns true after measurement complete (step is null)', () => {
+ const appState = { referenceDistance: { metersPerPixel: 0.1 }, calibration: null };
+ const measureState = {
+ active: true,
+ step: null,
+ p1: { x: 0, y: 0 },
+ p2: { x: 100, y: 0 }
+ };
+ expect(shouldEnableMeasureButton(appState, measureState)).toBe(true);
+ });
+ });
+
+ describe('shouldEnableSetScaleButton', () => {
+ test('returns true when scale mode inactive', () => {
+ const scaleState = createScaleModeState();
+ expect(shouldEnableSetScaleButton(scaleState)).toBe(true);
+ });
+
+ test('returns false when scale mode active', () => {
+ const scaleState = startScaleModeState(createScaleModeState());
+ expect(shouldEnableSetScaleButton(scaleState)).toBe(false);
+ });
+ });
+});
From 437aeac691247a081bbe12379cfb977e95a719a1 Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Sun, 21 Dec 2025 08:00:47 +0000
Subject: [PATCH 22/47] feat: complete UI layer integration for reference
distance feature with refactored state management
---
docs/feat-reference-distances.md | 37 ++++++++-
src/index.js | 136 +++++++++++++++----------------
2 files changed, 100 insertions(+), 73 deletions(-)
diff --git a/docs/feat-reference-distances.md b/docs/feat-reference-distances.md
index bfa9871..d32684d 100644
--- a/docs/feat-reference-distances.md
+++ b/docs/feat-reference-distances.md
@@ -358,10 +358,43 @@ This phase extracts testable state machine logic from the UI layer into pure fun
---
-### 🔲 Phase 2: UI Layer (Partially Complete)
+### ✅ Phase 2: UI Layer (Complete)
-This phase implements the user-facing features: setting a reference distance and measuring arbitrary distances.
+**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)
diff --git a/src/index.js b/src/index.js
index 6facb98..abcdcbc 100644
--- a/src/index.js
+++ b/src/index.js
@@ -7,11 +7,23 @@ import {
accuracyRingRadiusPixels,
} from 'snap2map/calibrator';
import {
- computeReferenceScale,
- getActiveScale,
- measureDistance,
formatDistance,
} from './scale/scale.js';
+import {
+ startScaleModeState,
+ handleScaleModePoint,
+ validateDistanceInput,
+ computeReferenceDistanceFromInput,
+ cancelScaleModeState,
+ canStartMeasureMode,
+ startMeasureModeState,
+ handleMeasureModePoint,
+ updateMeasureModePoint,
+ computeMeasurement,
+ cancelMeasureModeState,
+ shouldEnableMeasureButton,
+ shouldEnableSetScaleButton,
+} from './scale/scale-mode.js';
const GUIDED_PAIR_TARGET = 2;
const MAX_PHOTO_DIMENSION = 2048*2; // pixels
@@ -774,37 +786,31 @@ function updateScaleModeLine() {
function promptForReferenceDistance() {
const input = prompt('Enter the distance in meters:');
- if (input === null) {
- cancelScaleMode();
- return;
- }
- const meters = parseFloat(input);
- if (!Number.isFinite(meters) || meters <= 0) {
- showToast('Invalid distance. Please enter a positive number.', { tone: 'warning' });
+ const validation = validateDistanceInput(input);
+ if (!validation.valid) {
+ if (validation.error === 'invalid-number') {
+ showToast('Invalid distance. Please enter a positive number.', { tone: 'warning' });
+ }
cancelScaleMode();
return;
}
- const { p1, p2 } = state.scaleMode;
- const metersPerPixel = computeReferenceScale(p1, p2, meters);
+ const result = computeReferenceDistanceFromInput(state.scaleMode, validation.meters);
- if (!metersPerPixel) {
+ if (!result.success) {
showToast('Could not compute scale. Points may be too close.', { tone: 'warning' });
cancelScaleMode();
return;
}
- state.referenceDistance = { p1, p2, meters, metersPerPixel };
+ state.referenceDistance = result.referenceDistance;
clearScaleModeMarkers();
- state.scaleMode.active = false;
- state.scaleMode.step = null;
- state.scaleMode.p1 = null;
- state.scaleMode.p2 = null;
+ Object.assign(state.scaleMode, cancelScaleModeState());
drawReferenceVisualization();
updateMeasureButtonState();
- showToast(`Scale set: ${formatDistance(meters, state.preferredUnit)} = ${metersPerPixel.toFixed(4)} m/px`, { tone: 'success' });
+ showToast(`Scale set: ${formatDistance(result.referenceDistance.meters, state.preferredUnit)} = ${result.referenceDistance.metersPerPixel.toFixed(4)} m/px`, { tone: 'success' });
}
function startScaleMode() {
@@ -815,14 +821,12 @@ function startScaleMode() {
cancelMeasureMode();
}
- state.scaleMode.active = true;
- state.scaleMode.step = 'p1';
- state.scaleMode.p1 = null;
- state.scaleMode.p2 = null;
+ const newState = startScaleModeState(state.scaleMode);
+ Object.assign(state.scaleMode, newState);
clearScaleModeMarkers();
if (dom.setScaleButton) {
- dom.setScaleButton.disabled = true;
+ dom.setScaleButton.disabled = !shouldEnableSetScaleButton(state.scaleMode);
}
setActiveView('photo');
@@ -831,42 +835,40 @@ function startScaleMode() {
function cancelScaleMode() {
clearScaleModeMarkers();
- state.scaleMode.active = false;
- state.scaleMode.step = null;
- state.scaleMode.p1 = null;
- state.scaleMode.p2 = null;
+ Object.assign(state.scaleMode, cancelScaleModeState());
if (dom.setScaleButton) {
- dom.setScaleButton.disabled = false;
+ dom.setScaleButton.disabled = !shouldEnableSetScaleButton(state.scaleMode);
}
}
function handleScaleModeClick(event) {
- if (!state.scaleMode.active) {
+ const pixel = { x: event.latlng.lng, y: event.latlng.lat };
+ const { state: newState, action } = handleScaleModePoint(state.scaleMode, pixel);
+
+ if (!action) {
return false;
}
- const pixel = { x: event.latlng.lng, y: event.latlng.lat };
+ // Update logical state
+ Object.assign(state.scaleMode, newState);
- if (state.scaleMode.step === 'p1') {
- state.scaleMode.p1 = pixel;
+ // Handle UI side effects based on action
+ if (action === 'show-p2-toast') {
state.scaleMode.marker1 = L.marker(event.latlng, {
icon: createScaleMarkerIcon('#3b82f6'),
draggable: false,
}).addTo(state.photoMap);
- state.scaleMode.step = 'p2';
showToast('Now tap the end point.');
return true;
}
- if (state.scaleMode.step === 'p2') {
- state.scaleMode.p2 = pixel;
+ if (action === 'prompt-distance') {
state.scaleMode.marker2 = L.marker(event.latlng, {
icon: createScaleMarkerIcon('#3b82f6'),
draggable: false,
}).addTo(state.photoMap);
updateScaleModeLine();
- state.scaleMode.step = 'input';
promptForReferenceDistance();
return true;
}
@@ -903,19 +905,14 @@ function updateMeasureLabel() {
return;
}
- const scale = getActiveScale(state);
- if (!scale) {
- return;
- }
-
- const meters = measureDistance(p1, p2, scale.metersPerPixel);
- if (meters === null) {
+ const result = computeMeasurement(state.measureMode, state);
+ if (!result.success) {
return;
}
const midLat = (p1.y + p2.y) / 2;
const midLng = (p1.x + p2.x) / 2;
- const sourceIcon = scale.source === 'manual' ? '📏' : '📡';
+ const sourceIcon = result.source === 'manual' ? '📏' : '📡';
if (state.measureMode.label) {
state.measureMode.label.remove();
@@ -924,7 +921,7 @@ function updateMeasureLabel() {
state.measureMode.label = L.marker(L.latLng(midLat, midLng), {
icon: L.divIcon({
className: 'measure-label',
- html: `${sourceIcon} ${formatDistance(meters, state.preferredUnit)}
`,
+ html: `${sourceIcon} ${formatDistance(result.meters, state.preferredUnit)}
`,
iconAnchor: [0, 0],
}),
}).addTo(state.photoMap);
@@ -953,8 +950,8 @@ function updateMeasureModeLine() {
}
function startMeasureMode() {
- const scale = getActiveScale(state);
- if (!scale) {
+ const check = canStartMeasureMode(state);
+ if (!check.canStart) {
showToast('Set a reference scale or add GPS pairs first.', { tone: 'warning' });
return;
}
@@ -967,39 +964,38 @@ function startMeasureMode() {
}
clearMeasureModeMarkers();
- state.measureMode.active = true;
- state.measureMode.step = 'p1';
- state.measureMode.p1 = null;
- state.measureMode.p2 = null;
+ const newState = startMeasureModeState(state.measureMode);
+ Object.assign(state.measureMode, newState);
if (dom.measureButton) {
- dom.measureButton.disabled = true;
+ dom.measureButton.disabled = !shouldEnableMeasureButton(state, state.measureMode);
}
setActiveView('photo');
- const sourceText = scale.source === 'manual' ? 'manual scale' : 'GPS calibration';
+ const sourceText = check.scale.source === 'manual' ? 'manual scale' : 'GPS calibration';
showToast(`Measure mode (${sourceText}). Tap start point.`);
}
function cancelMeasureMode() {
clearMeasureModeMarkers();
- state.measureMode.active = false;
- state.measureMode.step = null;
- state.measureMode.p1 = null;
- state.measureMode.p2 = null;
+ Object.assign(state.measureMode, cancelMeasureModeState());
updateMeasureButtonState();
}
function handleMeasureModeClick(event) {
- if (!state.measureMode.active) {
+ const pixel = { x: event.latlng.lng, y: event.latlng.lat };
+ const { state: newState, action } = handleMeasureModePoint(state.measureMode, pixel);
+
+ if (!action) {
return false;
}
- const pixel = { x: event.latlng.lng, y: event.latlng.lat };
+ // Update logical state
+ Object.assign(state.measureMode, newState);
- if (state.measureMode.step === 'p1') {
- state.measureMode.p1 = pixel;
+ // Handle UI side effects based on action
+ if (action === 'show-p2-toast') {
state.measureMode.marker1 = L.marker(event.latlng, {
icon: createScaleMarkerIcon('#8b5cf6'),
draggable: true,
@@ -1007,17 +1003,16 @@ function handleMeasureModeClick(event) {
state.measureMode.marker1.on('drag', () => {
const latlng = state.measureMode.marker1.getLatLng();
- state.measureMode.p1 = { x: latlng.lng, y: latlng.lat };
+ const updatedState = updateMeasureModePoint(state.measureMode, 'p1', { x: latlng.lng, y: latlng.lat });
+ Object.assign(state.measureMode, updatedState);
updateMeasureModeLine();
});
- state.measureMode.step = 'p2';
showToast('Tap the end point to measure.');
return true;
}
- if (state.measureMode.step === 'p2') {
- state.measureMode.p2 = pixel;
+ if (action === 'measurement-complete') {
state.measureMode.marker2 = L.marker(event.latlng, {
icon: createScaleMarkerIcon('#8b5cf6'),
draggable: true,
@@ -1025,15 +1020,15 @@ function handleMeasureModeClick(event) {
state.measureMode.marker2.on('drag', () => {
const latlng = state.measureMode.marker2.getLatLng();
- state.measureMode.p2 = { x: latlng.lng, y: latlng.lat };
+ const updatedState = updateMeasureModePoint(state.measureMode, 'p2', { x: latlng.lng, y: latlng.lat });
+ Object.assign(state.measureMode, updatedState);
updateMeasureModeLine();
});
updateMeasureModeLine();
- state.measureMode.step = null;
if (dom.measureButton) {
- dom.measureButton.disabled = false;
+ dom.measureButton.disabled = !shouldEnableMeasureButton(state, state.measureMode);
}
showToast('Drag endpoints to refine. Tap Measure again for a new measurement.', { duration: 5000 });
@@ -1047,8 +1042,7 @@ function updateMeasureButtonState() {
if (!dom.measureButton) {
return;
}
- const scale = getActiveScale(state);
- dom.measureButton.disabled = !scale || state.measureMode.active;
+ dom.measureButton.disabled = !shouldEnableMeasureButton(state, state.measureMode);
}
function recalculateCalibration() {
From 047446c416adafacd310c853b3f3b42040ee366a Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Sun, 21 Dec 2025 08:57:46 +0000
Subject: [PATCH 23/47] feat: replaced the native prompt() with a custom modal
dialog - add distance input modal for reference distance entry with
validation and unit selection
---
index.html | 43 ++++++++++++++++++
src/index.js | 124 +++++++++++++++++++++++++++++++++++++++++++++++++--
2 files changed, 164 insertions(+), 3 deletions(-)
diff --git a/index.html b/index.html
index 42083ec..00b8822 100644
--- a/index.html
+++ b/index.html
@@ -116,6 +116,49 @@ Reference pairs
+
+
+
+
+
Enter Reference Distance
+
Specify the real-world distance between the two points.
+
+
+
+
+ Distance
+
+
+
+ Unit
+
+ Meters
+ Feet
+ Ft & In
+
+
+
+
Please enter a valid positive number.
+
+
+ Cancel
+ Confirm
+
+
+
+
+
diff --git a/src/index.js b/src/index.js
index c0f4f31..d9ffc38 100644
--- a/src/index.js
+++ b/src/index.js
@@ -687,7 +687,7 @@ function useCurrentPositionForPair() {
function createScaleMarkerIcon(color = '#3b82f6') {
return L.divIcon({
className: 'scale-marker',
- html: `
`,
+ html: `
`,
iconSize: [14, 14],
iconAnchor: [7, 7],
});
@@ -759,7 +759,7 @@ function drawReferenceVisualization() {
state.referenceMarkers.label = L.marker(L.latLng(midLat, midLng), {
icon: L.divIcon({
className: 'reference-label',
- html: `
📏 ${formatDistance(meters, state.preferredUnit)}
`,
+ html: `
📏 ${formatDistance(meters, state.preferredUnit)}
`,
iconAnchor: [0, 0],
}),
}).addTo(state.photoMap);
@@ -999,7 +999,7 @@ function updateMeasureLabel() {
state.measureMode.label = L.marker(L.latLng(midLat, midLng), {
icon: L.divIcon({
className: 'measure-label',
- html: `
${sourceIcon} ${formatDistance(result.meters, state.preferredUnit)}
`,
+ html: `
${sourceIcon} ${formatDistance(result.meters, state.preferredUnit)}
`,
iconAnchor: [0, 0],
}),
}).addTo(state.photoMap);
From 6e5315da65bafb6195dab9d4fe9c25da7559cd1b Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Sun, 21 Dec 2025 09:24:04 +0000
Subject: [PATCH 25/47] feat: refactor measure mode drag handling - extract
drag event logic into a reusable function for cleaner code
---
src/index.js | 23 +++++++++++------------
1 file changed, 11 insertions(+), 12 deletions(-)
diff --git a/src/index.js b/src/index.js
index d9ffc38..4c47f9e 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1071,6 +1071,15 @@ function handleMeasureModeClick(event) {
// Update logical state
Object.assign(state.measureMode, newState);
+
+ const createDragHandler = (marker, pointId) => {
+ marker.on('drag', () => {
+ const latlng = marker.getLatLng();
+ const updatedState = updateMeasureModePoint(state.measureMode, pointId, { x: latlng.lng, y: latlng.lat });
+ Object.assign(state.measureMode, updatedState);
+ updateMeasureModeLine();
+ });
+ };
// Handle UI side effects based on action
if (action === 'show-p2-toast') {
@@ -1079,12 +1088,7 @@ function handleMeasureModeClick(event) {
draggable: true,
}).addTo(state.photoMap);
- state.measureMode.marker1.on('drag', () => {
- const latlng = state.measureMode.marker1.getLatLng();
- const updatedState = updateMeasureModePoint(state.measureMode, 'p1', { x: latlng.lng, y: latlng.lat });
- Object.assign(state.measureMode, updatedState);
- updateMeasureModeLine();
- });
+ createDragHandler(state.measureMode.marker1, 'p1');
showToast('Tap the end point to measure.');
return true;
@@ -1096,12 +1100,7 @@ function handleMeasureModeClick(event) {
draggable: true,
}).addTo(state.photoMap);
- state.measureMode.marker2.on('drag', () => {
- const latlng = state.measureMode.marker2.getLatLng();
- const updatedState = updateMeasureModePoint(state.measureMode, 'p2', { x: latlng.lng, y: latlng.lat });
- Object.assign(state.measureMode, updatedState);
- updateMeasureModeLine();
- });
+ createDragHandler(state.measureMode.marker2, 'p2');
updateMeasureModeLine();
From 8c0efcaf581079819ff4e01af1c12adab35ff10f Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Sun, 21 Dec 2025 09:25:58 +0000
Subject: [PATCH 26/47] feat: remove unused API exports from scale and
scale-mode modules for cleaner code
---
src/scale/scale-mode.js | 23 -----------------------
src/scale/scale.js | 13 -------------
2 files changed, 36 deletions(-)
diff --git a/src/scale/scale-mode.js b/src/scale/scale-mode.js
index eba5aaa..8cef9a9 100644
--- a/src/scale/scale-mode.js
+++ b/src/scale/scale-mode.js
@@ -270,26 +270,3 @@ export function shouldEnableMeasureButton(appState, measureModeState) {
export function shouldEnableSetScaleButton(scaleModeState) {
return !scaleModeState.active;
}
-
-const api = {
- // Scale mode
- createScaleModeState,
- startScaleModeState,
- handleScaleModePoint,
- validateDistanceInput,
- computeReferenceDistanceFromInput,
- cancelScaleModeState,
- // Measure mode
- createMeasureModeState,
- canStartMeasureMode,
- startMeasureModeState,
- handleMeasureModePoint,
- updateMeasureModePoint,
- computeMeasurement,
- cancelMeasureModeState,
- // UI state
- shouldEnableMeasureButton,
- shouldEnableSetScaleButton,
-};
-
-export default api;
diff --git a/src/scale/scale.js b/src/scale/scale.js
index 0b8b768..c960705 100644
--- a/src/scale/scale.js
+++ b/src/scale/scale.js
@@ -237,16 +237,3 @@ export function compareScales(scale1, scale2, threshold = 0.10) {
percentDifference
};
}
-
-const api = {
- computeReferenceScale,
- getMetersPerPixelFromCalibration,
- getActiveScale,
- measureDistance,
- formatDistance,
- compareScales,
- METERS_PER_DEGREE_EQUATOR,
- METERS_TO_FEET,
-};
-
-export default api;
From 4e0f1158b7fb9a5dc82a4619154e1207dbcac51a Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Sun, 21 Dec 2025 19:45:07 +0000
Subject: [PATCH 27/47] feat: remove 'Ft & In' option from distance selection
for simplified input
---
index.html | 1 -
src/index.js | 1 -
2 files changed, 2 deletions(-)
diff --git a/index.html b/index.html
index c4cac57..c5ffd32 100644
--- a/index.html
+++ b/index.html
@@ -169,7 +169,6 @@
Enter R
>
Meters
Feet
- Ft & In
diff --git a/src/index.js b/src/index.js
index 4c47f9e..b9a810e 100644
--- a/src/index.js
+++ b/src/index.js
@@ -795,7 +795,6 @@ function convertToMeters(value, unit) {
}
switch (unit) {
case 'ft':
- case 'ft-in':
return value / METERS_TO_FEET;
default:
return value;
From 7caf5fabb6278501c782a6b7a083b4b0ca11d2bd Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Sun, 21 Dec 2025 19:48:34 +0000
Subject: [PATCH 28/47] feat: streamline distance input handling - remove
validation logic and rely on conversion function for improved clarity
---
src/index.js | 23 +++--------------------
1 file changed, 3 insertions(+), 20 deletions(-)
diff --git a/src/index.js b/src/index.js
index b9a810e..5db8a20 100644
--- a/src/index.js
+++ b/src/index.js
@@ -13,7 +13,6 @@ import {
import {
startScaleModeState,
handleScaleModePoint,
- validateDistanceInput,
computeReferenceDistanceFromInput,
cancelScaleModeState,
canStartMeasureMode,
@@ -841,16 +840,10 @@ function handleDistanceModalConfirm() {
// Update preferred unit for future use
state.preferredUnit = unit;
- // Parse the input value
+ // Parse and convert value, relying on convertToMeters for validation
const numericValue = parseFloat(inputValue);
- if (!Number.isFinite(numericValue) || numericValue <= 0) {
- dom.distanceError.classList.remove('hidden');
- dom.distanceInput.focus();
- return;
- }
-
- // Convert to meters
const meters = convertToMeters(numericValue, unit);
+
if (meters === null) {
dom.distanceError.classList.remove('hidden');
dom.distanceInput.focus();
@@ -859,17 +852,7 @@ function handleDistanceModalConfirm() {
hideDistanceModal();
- // Validate and compute using existing logic
- const validation = validateDistanceInput(String(meters));
- if (!validation.valid) {
- if (validation.error === 'invalid-number') {
- showToast('Invalid distance. Please enter a positive number.', { tone: 'warning' });
- }
- cancelScaleMode();
- return;
- }
-
- const result = computeReferenceDistanceFromInput(state.scaleMode, validation.meters);
+ const result = computeReferenceDistanceFromInput(state.scaleMode, meters);
if (!result.success) {
showToast('Could not compute scale. Points may be too close.', { tone: 'warning' });
From bb758af56c7155724e7890a9ba34486d4b670caa Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Mon, 22 Dec 2025 04:13:53 +0000
Subject: [PATCH 29/47] feat: implement convertToMeters function - add distance
conversion utility and integrate into distance validation
---
src/index.js | 13 +------------
src/scale/scale-mode.js | 6 +++---
src/scale/scale.js | 20 ++++++++++++++++++++
src/scale/scale.test.js | 25 +++++++++++++++++++++++++
4 files changed, 49 insertions(+), 15 deletions(-)
diff --git a/src/index.js b/src/index.js
index 5db8a20..cd97dc3 100644
--- a/src/index.js
+++ b/src/index.js
@@ -8,6 +8,7 @@ import {
} from 'snap2map/calibrator';
import {
formatDistance,
+ convertToMeters,
METERS_TO_FEET,
} from './scale/scale.js';
import {
@@ -788,18 +789,6 @@ function updateScaleModeLine() {
// Distance Input Modal: Custom dialog for entering reference distances
// ─────────────────────────────────────────────────────────────────────────────
-function convertToMeters(value, unit) {
- if (!Number.isFinite(value) || value <= 0) {
- return null;
- }
- switch (unit) {
- case 'ft':
- return value / METERS_TO_FEET;
- default:
- return value;
- }
-}
-
function showDistanceModal() {
if (!dom.distanceModal) {
return;
diff --git a/src/scale/scale-mode.js b/src/scale/scale-mode.js
index 8cef9a9..15ac7e8 100644
--- a/src/scale/scale-mode.js
+++ b/src/scale/scale-mode.js
@@ -4,7 +4,7 @@
* separating business logic from DOM/Leaflet interactions.
*/
-import { computeReferenceScale, getActiveScale, measureDistance } from './scale.js';
+import { computeReferenceScale, getActiveScale, measureDistance, convertToMeters } from './scale.js';
// ─────────────────────────────────────────────────────────────────────────────
// Shared Two-Point Mode Handler
@@ -92,8 +92,8 @@ export function validateDistanceInput(input) {
return { valid: false, error: 'cancelled' };
}
- const meters = parseFloat(input);
- if (!Number.isFinite(meters) || meters <= 0) {
+ const meters = convertToMeters(parseFloat(input), 'm');
+ if (meters === null) {
return { valid: false, error: 'invalid-number' };
}
diff --git a/src/scale/scale.js b/src/scale/scale.js
index c960705..8445f30 100644
--- a/src/scale/scale.js
+++ b/src/scale/scale.js
@@ -175,6 +175,26 @@ export function measureDistance(p1, p2, metersPerPixel) {
return pixelDistance(p1, p2) * metersPerPixel;
}
+/**
+ * Converts a distance value from a given unit to meters.
+ * Validates that the input is a positive finite number.
+ *
+ * @param {number} value - Numeric distance value
+ * @param {string} unit - Unit of the input value ('m', 'ft')
+ * @returns {number|null} - Distance in meters, or null if invalid
+ */
+export function convertToMeters(value, unit) {
+ if (!Number.isFinite(value) || value <= 0) {
+ return null;
+ }
+ switch (unit) {
+ case 'ft':
+ return value / METERS_TO_FEET;
+ default:
+ return value;
+ }
+}
+
/**
* Formats a distance value for display in the user's preferred unit.
*
diff --git a/src/scale/scale.test.js b/src/scale/scale.test.js
index 6e9fe0d..2142b19 100644
--- a/src/scale/scale.test.js
+++ b/src/scale/scale.test.js
@@ -4,6 +4,7 @@ import {
getActiveScale,
measureDistance,
formatDistance,
+ convertToMeters,
compareScales,
METERS_TO_FEET,
} from './scale.js';
@@ -310,6 +311,30 @@ describe('scale module', () => {
});
});
+ describe('convertToMeters', () => {
+ test('returns value as is for meters', () => {
+ expect(convertToMeters(10, 'm')).toBe(10);
+ });
+
+ test('converts feet to meters', () => {
+ expect(convertToMeters(10, 'ft')).toBe(10 / METERS_TO_FEET);
+ });
+
+ test('returns null for non-finite values', () => {
+ expect(convertToMeters(NaN, 'm')).toBeNull();
+ expect(convertToMeters(Infinity, 'm')).toBeNull();
+ });
+
+ test('returns null for zero or negative values', () => {
+ expect(convertToMeters(0, 'm')).toBeNull();
+ expect(convertToMeters(-5, 'm')).toBeNull();
+ });
+
+ test('defaults to meters for unknown units', () => {
+ expect(convertToMeters(10, 'unknown')).toBe(10);
+ });
+ });
+
describe('formatDistance', () => {
describe('meters (default)', () => {
test('formats large distances in meters', () => {
From 8f84fdcd7ea2a87b43066f670ac6d0329b246c32 Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Mon, 22 Dec 2025 04:15:48 +0000
Subject: [PATCH 30/47] feat: remove Escape key handling from distance input
modal for streamlined user experience
---
src/index.js | 2 --
1 file changed, 2 deletions(-)
diff --git a/src/index.js b/src/index.js
index cd97dc3..1d2310e 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1506,8 +1506,6 @@ function setupEventHandlers() {
dom.distanceInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
handleDistanceModalConfirm();
- } else if (e.key === 'Escape') {
- handleDistanceModalCancel();
}
});
dom.distanceInput.addEventListener('input', () => {
From f402fbf6f92eff7fb1c614d4ad13b3187ff3aeea Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Mon, 22 Dec 2025 04:17:10 +0000
Subject: [PATCH 31/47] feat: refactor geolocation handling and improve user
marker accuracy updates
---
src/index.js | 137 ++++++++++++++++++++++++++-------------------------
1 file changed, 69 insertions(+), 68 deletions(-)
diff --git a/src/index.js b/src/index.js
index 1d2310e..92ca6ab 100644
--- a/src/index.js
+++ b/src/index.js
@@ -9,7 +9,6 @@ import {
import {
formatDistance,
convertToMeters,
- METERS_TO_FEET,
} from './scale/scale.js';
import {
startScaleModeState,
@@ -330,6 +329,38 @@ function updateGpsStatus(message, isError) {
dom.gpsStatus.className = isError ? 'text-sm text-rose-400' : 'text-sm text-slate-200';
}
+function ensureUserMarker(latlng) {
+ if (!state.userMarker) {
+ state.userMarker = L.circleMarker(latlng, {
+ radius: 6,
+ color: '#2563eb',
+ fillColor: '#2563eb',
+ fillOpacity: 0.9,
+ }).addTo(state.photoMap);
+ } else {
+ state.userMarker.setLatLng(latlng);
+ }
+}
+
+function updateAccuracyCircle(latlng, ring) {
+ if (!ring || !ring.pixelRadius) {
+ return;
+ }
+ if (!state.accuracyCircle) {
+ state.accuracyCircle = L.circle(latlng, {
+ radius: ring.pixelRadius,
+ color: ring.color,
+ weight: 1,
+ fillColor: ring.color,
+ fillOpacity: 0.15,
+ }).addTo(state.photoMap);
+ } else {
+ state.accuracyCircle.setLatLng(latlng);
+ state.accuracyCircle.setRadius(ring.pixelRadius);
+ state.accuracyCircle.setStyle({ color: ring.color, fillColor: ring.color });
+ }
+}
+
function updateLivePosition() {
if (!state.photoMap || !state.calibration || state.calibration.status !== 'ok' || !state.lastPosition) {
return;
@@ -343,38 +374,6 @@ function updateLivePosition() {
}
const latlng = L.latLng(pixel.y, pixel.x);
- function ensureUserMarker(latlngLocal) {
- if (!state.userMarker) {
- state.userMarker = L.circleMarker(latlngLocal, {
- radius: 6,
- color: '#2563eb',
- fillColor: '#2563eb',
- fillOpacity: 0.9,
- }).addTo(state.photoMap);
- } else {
- state.userMarker.setLatLng(latlngLocal);
- }
- }
-
- function updateAccuracyCircle(latlngLocal, ring) {
- if (!ring || !ring.pixelRadius) {
- return;
- }
- if (!state.accuracyCircle) {
- state.accuracyCircle = L.circle(latlngLocal, {
- radius: ring.pixelRadius,
- color: ring.color,
- weight: 1,
- fillColor: ring.color,
- fillOpacity: 0.15,
- }).addTo(state.photoMap);
- } else {
- state.accuracyCircle.setLatLng(latlngLocal);
- state.accuracyCircle.setRadius(ring.pixelRadius);
- state.accuracyCircle.setStyle({ color: ring.color, fillColor: ring.color });
- }
- }
-
ensureUserMarker(latlng);
if (state.photoPendingCenter && state.photoMap) {
@@ -559,6 +558,21 @@ function advanceGuidedFlow() {
promptNextGuidedPair();
}
+function showPairSavedToast(savedIndex) {
+ if (isGuidedActive()) {
+ showGuidedPairSavedToast(savedIndex);
+ return;
+ }
+
+ const residual =
+ state.calibration && Array.isArray(state.calibration.residuals)
+ ? state.calibration.residuals[savedIndex]
+ : null;
+ const residualText = residual !== null && residual !== undefined ? `${residual.toFixed(1)} m` : '—';
+ const tone = residual !== null && residual <= 30 ? 'success' : 'info';
+ showToast(`Pair ${savedIndex + 1} saved — residual ${residualText}.`, { tone });
+}
+
function confirmPair() {
if (!state.activePair || !state.activePair.pixel || !state.activePair.wgs84) {
return;
@@ -573,16 +587,7 @@ function confirmPair() {
recalculateCalibration();
const savedIndex = state.pairs.length - 1;
- if (!isGuidedActive()) {
- const residual =
- state.calibration && Array.isArray(state.calibration.residuals)
- ? state.calibration.residuals[savedIndex]
- : null;
- const residualText = residual !== null && residual !== undefined ? `${residual.toFixed(1)} m` : '—';
- const tone = residual !== null && residual <= 30 ? 'success' : 'info';
- showToast(`Pair ${savedIndex + 1} saved — residual ${residualText}.`, { tone });
- }
- showGuidedPairSavedToast(savedIndex);
+ showPairSavedToast(savedIndex);
advanceGuidedFlow();
}
@@ -1352,6 +1357,26 @@ function requestAndCenterOsmOnUser() {
);
}
+function handleGeolocationPermission(shouldPrompt, doRequest) {
+ if (!navigator.permissions || !navigator.permissions.query) {
+ if (shouldPrompt) doRequest();
+ return;
+ }
+
+ navigator.permissions
+ .query({ name: 'geolocation' })
+ .then((status) => {
+ if (status.state === 'granted') {
+ requestAndCenterOsmOnUser();
+ } else if (status.state === 'prompt' && shouldPrompt) {
+ doRequest();
+ }
+ })
+ .catch(() => {
+ if (shouldPrompt) doRequest();
+ });
+}
+
function maybePromptGeolocationForOsm() {
// If we already have a recent position, prefer that immediately
if (state.lastPosition && Date.now() - (state.lastGpsUpdate || 0) <= 5_000) {
@@ -1374,36 +1399,12 @@ function maybePromptGeolocationForOsm() {
if (!navigator.geolocation) return;
const shouldPrompt = !state.osmGeoPrompted;
-
- // Try Permissions API to avoid unnecessary prompt where already denied/granted
const doRequest = () => {
if (shouldPrompt) state.osmGeoPrompted = true;
requestAndCenterOsmOnUser();
};
- if (navigator.permissions && navigator.permissions.query) {
- try {
- navigator.permissions
- .query({ name: 'geolocation' })
- .then((status) => {
- if (status.state === 'granted') {
- // No prompt needed; just center
- requestAndCenterOsmOnUser();
- } else if (status.state === 'prompt') {
- // Only trigger the browser prompt the first time we open OSM
- if (shouldPrompt) doRequest();
- }
- // if denied → do nothing
- })
- .catch(() => {
- if (shouldPrompt) doRequest();
- });
- } catch {
- if (shouldPrompt) doRequest();
- }
- } else {
- if (shouldPrompt) doRequest();
- }
+ handleGeolocationPermission(shouldPrompt, doRequest);
}
function setActiveView(view) {
From 646e3852816c5eb78d5884ac31c43964736f2c72 Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Mon, 22 Dec 2025 04:18:02 +0000
Subject: [PATCH 32/47] feat: enhance service worker fetch handling and add
calibration readiness check
---
service-worker.js | 88 ++++++++++++++++++++++++-----------------------
src/index.js | 11 +++++-
2 files changed, 55 insertions(+), 44 deletions(-)
diff --git a/service-worker.js b/service-worker.js
index 58f4c01..7276e37 100644
--- a/service-worker.js
+++ b/service-worker.js
@@ -25,6 +25,44 @@ 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) {
+ 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;
@@ -32,66 +70,30 @@ self.addEventListener('fetch', (event) => {
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();
}
}),
);
diff --git a/src/index.js b/src/index.js
index 92ca6ab..5695862 100644
--- a/src/index.js
+++ b/src/index.js
@@ -361,8 +361,17 @@ function updateAccuracyCircle(latlng, ring) {
}
}
+function isCalibrationReady() {
+ return !!(
+ state.photoMap &&
+ state.calibration &&
+ state.calibration.status === 'ok' &&
+ state.lastPosition
+ );
+}
+
function updateLivePosition() {
- if (!state.photoMap || !state.calibration || state.calibration.status !== 'ok' || !state.lastPosition) {
+ if (!isCalibrationReady()) {
return;
}
From 0a1418b783cbc5e032cb316631a27ff4018d190a Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Mon, 22 Dec 2025 04:19:57 +0000
Subject: [PATCH 33/47] feat: enhance distance input validation to support unit
conversion
---
docs/feat-reference-distances.md | 2 +-
src/index.js | 12 ++++++------
src/scale/scale-mode.js | 6 ++++--
src/scale/scale-mode.test.js | 6 ++++++
4 files changed, 17 insertions(+), 9 deletions(-)
diff --git a/docs/feat-reference-distances.md b/docs/feat-reference-distances.md
index d32684d..6521f0e 100644
--- a/docs/feat-reference-distances.md
+++ b/docs/feat-reference-distances.md
@@ -332,7 +332,7 @@ This phase extracts testable state machine logic from the UI layer into pure fun
- [x] `createScaleModeState()` - Creates initial state
- [x] `startScaleModeState(currentState)` - Activates scale mode
- [x] `handleScaleModePoint(currentState, point)` - Processes point clicks
-- [x] `validateDistanceInput(input)` - Validates user's distance input
+- [x] `validateDistanceInput(input, unit)` - Validates user's distance input
- [x] `computeReferenceDistanceFromInput(scaleModeState, meters)` - Computes reference distance
- [x] `cancelScaleModeState()` - Resets state
diff --git a/src/index.js b/src/index.js
index 5695862..19ca5eb 100644
--- a/src/index.js
+++ b/src/index.js
@@ -8,11 +8,11 @@ import {
} from 'snap2map/calibrator';
import {
formatDistance,
- convertToMeters,
} from './scale/scale.js';
import {
startScaleModeState,
handleScaleModePoint,
+ validateDistanceInput,
computeReferenceDistanceFromInput,
cancelScaleModeState,
canStartMeasureMode,
@@ -837,22 +837,22 @@ function handleDistanceModalCancel() {
}
function handleDistanceModalConfirm() {
- const inputValue = dom.distanceInput.value.trim();
+ const inputValue = dom.distanceInput.value;
const unit = dom.distanceUnit.value;
// Update preferred unit for future use
state.preferredUnit = unit;
- // Parse and convert value, relying on convertToMeters for validation
- const numericValue = parseFloat(inputValue);
- const meters = convertToMeters(numericValue, unit);
+ const validation = validateDistanceInput(inputValue, unit);
- if (meters === null) {
+ if (!validation.valid) {
dom.distanceError.classList.remove('hidden');
dom.distanceInput.focus();
return;
}
+ const { meters } = validation;
+
hideDistanceModal();
const result = computeReferenceDistanceFromInput(state.scaleMode, meters);
diff --git a/src/scale/scale-mode.js b/src/scale/scale-mode.js
index 15ac7e8..e713096 100644
--- a/src/scale/scale-mode.js
+++ b/src/scale/scale-mode.js
@@ -85,14 +85,16 @@ export function handleScaleModePoint(currentState, point) {
/**
* Validates and processes the user's distance input.
* @param {string | null} input - Raw user input from prompt
+ * @param {string} [unit='m'] - Unit of the input value ('m', 'ft')
* @returns {{ valid: boolean, meters?: number, error?: string }}
*/
-export function validateDistanceInput(input) {
+export function validateDistanceInput(input, unit = 'm') {
if (input === null) {
return { valid: false, error: 'cancelled' };
}
- const meters = convertToMeters(parseFloat(input), 'm');
+ const value = parseFloat(input);
+ const meters = convertToMeters(value, unit);
if (meters === null) {
return { valid: false, error: 'invalid-number' };
}
diff --git a/src/scale/scale-mode.test.js b/src/scale/scale-mode.test.js
index 244040f..571c428 100644
--- a/src/scale/scale-mode.test.js
+++ b/src/scale/scale-mode.test.js
@@ -132,6 +132,12 @@ describe('scale-mode state machine', () => {
test('handles whitespace around number', () => {
expect(validateDistanceInput(' 5.25 ')).toEqual({ valid: true, meters: 5.25 });
});
+
+ test('handles feet conversion', () => {
+ const result = validateDistanceInput('10', 'ft');
+ expect(result.valid).toBe(true);
+ expect(result.meters).toBeCloseTo(10 / 3.28084);
+ });
});
describe('computeReferenceDistanceFromInput', () => {
From e649d37f20a1c6b2e849f1b0469c3b460715e806 Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Mon, 22 Dec 2025 04:24:44 +0000
Subject: [PATCH 34/47] feat: improve response handling in fetch navigation
logic
---
service-worker.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/service-worker.js b/service-worker.js
index 7276e37..3b845fc 100644
--- a/service-worker.js
+++ b/service-worker.js
@@ -42,7 +42,7 @@ async function handleShellOrNavigation(request, cache, cached) {
const isNavigation = request.mode === 'navigate';
try {
const response = await fetchAndUpdate(request, cache);
- if (response) {
+ if (response && response.ok) {
return response;
}
} catch {
From 63c4945beae67cb32ff113e92f61995025f059a8 Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Mon, 22 Dec 2025 04:26:48 +0000
Subject: [PATCH 35/47] feat: adjust distance label positioning for improved
visibility
---
index.html | 2 ++
1 file changed, 2 insertions(+)
diff --git a/index.html b/index.html
index c5ffd32..e97f531 100644
--- a/index.html
+++ b/index.html
@@ -25,6 +25,8 @@
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;
From f16b3ecf7c1126c9cc97e0f6ae7a0d9663ad6de5 Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Mon, 22 Dec 2025 04:34:21 +0000
Subject: [PATCH 36/47] State Initialization: Updated the state object to use
the new nested structure. I also utilized createScaleModeState() and
createMeasureModeState() from the logic module to ensure consistent initial
states. Logic Integration: Updated all calls to pure functions (like
handleScaleModePoint or computeMeasurement) to pass only the logic
sub-object. UI Management: Updated all Leaflet-related code to store and
retrieve markers/lines from the ui sub-object
---
src/index.js | 149 +++++++++++++++++++++++++--------------------------
1 file changed, 73 insertions(+), 76 deletions(-)
diff --git a/src/index.js b/src/index.js
index 19ca5eb..e2af5a3 100644
--- a/src/index.js
+++ b/src/index.js
@@ -10,12 +10,14 @@ import {
formatDistance,
} from './scale/scale.js';
import {
+ createScaleModeState,
startScaleModeState,
handleScaleModePoint,
validateDistanceInput,
computeReferenceDistanceFromInput,
cancelScaleModeState,
canStartMeasureMode,
+ createMeasureModeState,
startMeasureModeState,
handleMeasureModePoint,
updateMeasureModePoint,
@@ -66,13 +68,12 @@ const state = {
preferredUnit: 'm',
// Scale mode: setting the reference distance line
scaleMode: {
- active: false,
- step: null, // 'p1' | 'p2' | 'input'
- p1: null,
- p2: null,
- marker1: null,
- marker2: null,
- line: null,
+ logic: createScaleModeState(),
+ ui: {
+ marker1: null,
+ marker2: null,
+ line: null,
+ },
},
// Reference distance visualization (persists after scaleMode is done)
referenceMarkers: {
@@ -83,14 +84,13 @@ const state = {
},
// Measure mode: measuring arbitrary distances
measureMode: {
- active: false,
- step: null, // 'p1' | 'p2'
- p1: null,
- p2: null,
- marker1: null,
- marker2: null,
- line: null,
- label: null,
+ logic: createMeasureModeState(),
+ ui: {
+ marker1: null,
+ marker2: null,
+ line: null,
+ label: null,
+ },
},
};
@@ -708,17 +708,17 @@ function createScaleMarkerIcon(color = '#3b82f6') {
}
function clearScaleModeMarkers() {
- if (state.scaleMode.marker1) {
- state.scaleMode.marker1.remove();
- state.scaleMode.marker1 = null;
+ if (state.scaleMode.ui.marker1) {
+ state.scaleMode.ui.marker1.remove();
+ state.scaleMode.ui.marker1 = null;
}
- if (state.scaleMode.marker2) {
- state.scaleMode.marker2.remove();
- state.scaleMode.marker2 = null;
+ if (state.scaleMode.ui.marker2) {
+ state.scaleMode.ui.marker2.remove();
+ state.scaleMode.ui.marker2 = null;
}
- if (state.scaleMode.line) {
- state.scaleMode.line.remove();
- state.scaleMode.line = null;
+ if (state.scaleMode.ui.line) {
+ state.scaleMode.ui.line.remove();
+ state.scaleMode.ui.line = null;
}
}
@@ -780,17 +780,17 @@ function drawReferenceVisualization() {
}
function updateScaleModeLine() {
- const { p1, p2 } = state.scaleMode;
+ const { p1, p2 } = state.scaleMode.logic;
if (!p1 || !p2) {
return;
}
const latlng1 = L.latLng(p1.y, p1.x);
const latlng2 = L.latLng(p2.y, p2.x);
- if (state.scaleMode.line) {
- state.scaleMode.line.setLatLngs([latlng1, latlng2]);
+ if (state.scaleMode.ui.line) {
+ state.scaleMode.ui.line.setLatLngs([latlng1, latlng2]);
} else {
- state.scaleMode.line = L.polyline([latlng1, latlng2], {
+ state.scaleMode.ui.line = L.polyline([latlng1, latlng2], {
color: '#3b82f6',
weight: 3,
dashArray: '6, 6',
@@ -855,7 +855,7 @@ function handleDistanceModalConfirm() {
hideDistanceModal();
- const result = computeReferenceDistanceFromInput(state.scaleMode, meters);
+ const result = computeReferenceDistanceFromInput(state.scaleMode.logic, meters);
if (!result.success) {
showToast('Could not compute scale. Points may be too close.', { tone: 'warning' });
@@ -865,7 +865,7 @@ function handleDistanceModalConfirm() {
state.referenceDistance = result.referenceDistance;
clearScaleModeMarkers();
- Object.assign(state.scaleMode, cancelScaleModeState());
+ state.scaleMode.logic = cancelScaleModeState();
drawReferenceVisualization();
updateMeasureButtonState();
@@ -880,16 +880,15 @@ function startScaleMode() {
if (state.activePair) {
cancelPairMode();
}
- if (state.measureMode.active) {
+ if (state.measureMode.logic.active) {
cancelMeasureMode();
}
- const newState = startScaleModeState(state.scaleMode);
- Object.assign(state.scaleMode, newState);
+ state.scaleMode.logic = startScaleModeState(state.scaleMode.logic);
clearScaleModeMarkers();
if (dom.setScaleButton) {
- dom.setScaleButton.disabled = !shouldEnableSetScaleButton(state.scaleMode);
+ dom.setScaleButton.disabled = !shouldEnableSetScaleButton(state.scaleMode.logic);
}
setActiveView('photo');
@@ -898,27 +897,27 @@ function startScaleMode() {
function cancelScaleMode() {
clearScaleModeMarkers();
- Object.assign(state.scaleMode, cancelScaleModeState());
+ state.scaleMode.logic = cancelScaleModeState();
if (dom.setScaleButton) {
- dom.setScaleButton.disabled = !shouldEnableSetScaleButton(state.scaleMode);
+ dom.setScaleButton.disabled = !shouldEnableSetScaleButton(state.scaleMode.logic);
}
}
function handleScaleModeClick(event) {
const pixel = { x: event.latlng.lng, y: event.latlng.lat };
- const { state: newState, action } = handleScaleModePoint(state.scaleMode, pixel);
+ const { state: newState, action } = handleScaleModePoint(state.scaleMode.logic, pixel);
if (!action) {
return false;
}
// Update logical state
- Object.assign(state.scaleMode, newState);
+ state.scaleMode.logic = newState;
// Handle UI side effects based on action
if (action === 'show-p2-toast') {
- state.scaleMode.marker1 = L.marker(event.latlng, {
+ state.scaleMode.ui.marker1 = L.marker(event.latlng, {
icon: createScaleMarkerIcon('#3b82f6'),
draggable: false,
}).addTo(state.photoMap);
@@ -927,7 +926,7 @@ function handleScaleModeClick(event) {
}
if (action === 'prompt-distance') {
- state.scaleMode.marker2 = L.marker(event.latlng, {
+ state.scaleMode.ui.marker2 = L.marker(event.latlng, {
icon: createScaleMarkerIcon('#3b82f6'),
draggable: false,
}).addTo(state.photoMap);
@@ -944,31 +943,31 @@ function handleScaleModeClick(event) {
// ─────────────────────────────────────────────────────────────────────────────
function clearMeasureModeMarkers() {
- if (state.measureMode.marker1) {
- state.measureMode.marker1.remove();
- state.measureMode.marker1 = null;
+ if (state.measureMode.ui.marker1) {
+ state.measureMode.ui.marker1.remove();
+ state.measureMode.ui.marker1 = null;
}
- if (state.measureMode.marker2) {
- state.measureMode.marker2.remove();
- state.measureMode.marker2 = null;
+ if (state.measureMode.ui.marker2) {
+ state.measureMode.ui.marker2.remove();
+ state.measureMode.ui.marker2 = null;
}
- if (state.measureMode.line) {
- state.measureMode.line.remove();
- state.measureMode.line = null;
+ if (state.measureMode.ui.line) {
+ state.measureMode.ui.line.remove();
+ state.measureMode.ui.line = null;
}
- if (state.measureMode.label) {
- state.measureMode.label.remove();
- state.measureMode.label = null;
+ if (state.measureMode.ui.label) {
+ state.measureMode.ui.label.remove();
+ state.measureMode.ui.label = null;
}
}
function updateMeasureLabel() {
- const { p1, p2 } = state.measureMode;
+ const { p1, p2 } = state.measureMode.logic;
if (!p1 || !p2) {
return;
}
- const result = computeMeasurement(state.measureMode, state);
+ const result = computeMeasurement(state.measureMode.logic, state);
if (!result.success) {
return;
}
@@ -977,11 +976,11 @@ function updateMeasureLabel() {
const midLng = (p1.x + p2.x) / 2;
const sourceIcon = result.source === 'manual' ? '📏' : '📡';
- if (state.measureMode.label) {
- state.measureMode.label.remove();
+ if (state.measureMode.ui.label) {
+ state.measureMode.ui.label.remove();
}
- state.measureMode.label = L.marker(L.latLng(midLat, midLng), {
+ state.measureMode.ui.label = L.marker(L.latLng(midLat, midLng), {
icon: L.divIcon({
className: 'measure-label',
html: `${sourceIcon} ${formatDistance(result.meters, state.preferredUnit)}
`,
@@ -991,7 +990,7 @@ function updateMeasureLabel() {
}
function updateMeasureModeLine() {
- const { p1, p2 } = state.measureMode;
+ const { p1, p2 } = state.measureMode.logic;
if (!p1 || !p2) {
return;
}
@@ -999,10 +998,10 @@ function updateMeasureModeLine() {
const latlng1 = L.latLng(p1.y, p1.x);
const latlng2 = L.latLng(p2.y, p2.x);
- if (state.measureMode.line) {
- state.measureMode.line.setLatLngs([latlng1, latlng2]);
+ if (state.measureMode.ui.line) {
+ state.measureMode.ui.line.setLatLngs([latlng1, latlng2]);
} else {
- state.measureMode.line = L.polyline([latlng1, latlng2], {
+ state.measureMode.ui.line = L.polyline([latlng1, latlng2], {
color: '#8b5cf6',
weight: 3,
opacity: 0.9,
@@ -1022,16 +1021,15 @@ function startMeasureMode() {
if (state.activePair) {
cancelPairMode();
}
- if (state.scaleMode.active) {
+ if (state.scaleMode.logic.active) {
cancelScaleMode();
}
clearMeasureModeMarkers();
- const newState = startMeasureModeState(state.measureMode);
- Object.assign(state.measureMode, newState);
+ state.measureMode.logic = startMeasureModeState(state.measureMode.logic);
if (dom.measureButton) {
- dom.measureButton.disabled = !shouldEnableMeasureButton(state, state.measureMode);
+ dom.measureButton.disabled = !shouldEnableMeasureButton(state, state.measureMode.logic);
}
setActiveView('photo');
@@ -1041,56 +1039,55 @@ function startMeasureMode() {
function cancelMeasureMode() {
clearMeasureModeMarkers();
- Object.assign(state.measureMode, cancelMeasureModeState());
+ state.measureMode.logic = cancelMeasureModeState();
updateMeasureButtonState();
}
function handleMeasureModeClick(event) {
const pixel = { x: event.latlng.lng, y: event.latlng.lat };
- const { state: newState, action } = handleMeasureModePoint(state.measureMode, pixel);
+ const { state: newState, action } = handleMeasureModePoint(state.measureMode.logic, pixel);
if (!action) {
return false;
}
// Update logical state
- Object.assign(state.measureMode, newState);
+ state.measureMode.logic = newState;
const createDragHandler = (marker, pointId) => {
marker.on('drag', () => {
const latlng = marker.getLatLng();
- const updatedState = updateMeasureModePoint(state.measureMode, pointId, { x: latlng.lng, y: latlng.lat });
- Object.assign(state.measureMode, updatedState);
+ state.measureMode.logic = updateMeasureModePoint(state.measureMode.logic, pointId, { x: latlng.lng, y: latlng.lat });
updateMeasureModeLine();
});
};
// Handle UI side effects based on action
if (action === 'show-p2-toast') {
- state.measureMode.marker1 = L.marker(event.latlng, {
+ state.measureMode.ui.marker1 = L.marker(event.latlng, {
icon: createScaleMarkerIcon('#8b5cf6'),
draggable: true,
}).addTo(state.photoMap);
- createDragHandler(state.measureMode.marker1, 'p1');
+ createDragHandler(state.measureMode.ui.marker1, 'p1');
showToast('Tap the end point to measure.');
return true;
}
if (action === 'measurement-complete') {
- state.measureMode.marker2 = L.marker(event.latlng, {
+ state.measureMode.ui.marker2 = L.marker(event.latlng, {
icon: createScaleMarkerIcon('#8b5cf6'),
draggable: true,
}).addTo(state.photoMap);
- createDragHandler(state.measureMode.marker2, 'p2');
+ createDragHandler(state.measureMode.ui.marker2, 'p2');
updateMeasureModeLine();
if (dom.measureButton) {
- dom.measureButton.disabled = !shouldEnableMeasureButton(state, state.measureMode);
+ dom.measureButton.disabled = !shouldEnableMeasureButton(state, state.measureMode.logic);
}
showToast('Drag endpoints to refine. Tap Measure again for a new measurement.', { duration: 5000 });
@@ -1104,7 +1101,7 @@ function updateMeasureButtonState() {
if (!dom.measureButton) {
return;
}
- dom.measureButton.disabled = !shouldEnableMeasureButton(state, state.measureMode);
+ dom.measureButton.disabled = !shouldEnableMeasureButton(state, state.measureMode.logic);
}
function recalculateCalibration() {
From e225a41c6e918bfb69a0f8b45bf767b80e4993cb Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Mon, 22 Dec 2025 04:36:13 +0000
Subject: [PATCH 37/47] feat: refactor color management by introducing COLORS
object for consistency
---
src/index.js | 39 ++++++++++++++++++++++++---------------
1 file changed, 24 insertions(+), 15 deletions(-)
diff --git a/src/index.js b/src/index.js
index e2af5a3..8afda8a 100644
--- a/src/index.js
+++ b/src/index.js
@@ -30,6 +30,15 @@ import {
const GUIDED_PAIR_TARGET = 2;
const MAX_PHOTO_DIMENSION = 2048*2; // pixels
+const COLORS = {
+ PRIMARY: '#2563eb', // blue-600
+ INLIER: '#16a34a', // green-600
+ OUTLIER: '#dc2626', // red-600
+ SCALE: '#3b82f6', // blue-500
+ REFERENCE: '#10b981', // emerald-500
+ MEASURE: '#8b5cf6', // violet-500
+};
+
const state = {
imageDataUrl: null,
imageSize: null,
@@ -265,7 +274,7 @@ function refreshPairMarkers() {
state.pairs.forEach((pair, index) => {
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 color = !state.calibration ? '#2563eb' : inlier ? '#16a34a' : '#dc2626';
+ const color = !state.calibration ? COLORS.PRIMARY : inlier ? COLORS.INLIER : COLORS.OUTLIER;
const label = residual !== null && residual !== undefined ? `${residual.toFixed(1)} m` : '—';
const photoMarker = L.circleMarker([pair.pixel.y, pair.pixel.x], {
@@ -333,8 +342,8 @@ function ensureUserMarker(latlng) {
if (!state.userMarker) {
state.userMarker = L.circleMarker(latlng, {
radius: 6,
- color: '#2563eb',
- fillColor: '#2563eb',
+ color: COLORS.PRIMARY,
+ fillColor: COLORS.PRIMARY,
fillOpacity: 0.9,
}).addTo(state.photoMap);
} else {
@@ -698,7 +707,7 @@ function useCurrentPositionForPair() {
// Scale Mode: Set reference distance for manual scale definition
// ─────────────────────────────────────────────────────────────────────────────
-function createScaleMarkerIcon(color = '#3b82f6') {
+function createScaleMarkerIcon(color = COLORS.SCALE) {
return L.divIcon({
className: 'scale-marker',
html: `
`,
@@ -752,17 +761,17 @@ function drawReferenceVisualization() {
const latlng2 = L.latLng(p2.y, p2.x);
state.referenceMarkers.marker1 = L.marker(latlng1, {
- icon: createScaleMarkerIcon('#10b981'),
+ icon: createScaleMarkerIcon(COLORS.REFERENCE),
draggable: false,
}).addTo(state.photoMap);
state.referenceMarkers.marker2 = L.marker(latlng2, {
- icon: createScaleMarkerIcon('#10b981'),
+ icon: createScaleMarkerIcon(COLORS.REFERENCE),
draggable: false,
}).addTo(state.photoMap);
state.referenceMarkers.line = L.polyline([latlng1, latlng2], {
- color: '#10b981',
+ color: COLORS.REFERENCE,
weight: 3,
dashArray: '8, 8',
opacity: 0.9,
@@ -773,7 +782,7 @@ function drawReferenceVisualization() {
state.referenceMarkers.label = L.marker(L.latLng(midLat, midLng), {
icon: L.divIcon({
className: 'reference-label',
- html: `📏 ${formatDistance(meters, state.preferredUnit)}
`,
+ html: `📏 ${formatDistance(meters, state.preferredUnit)}
`,
iconAnchor: [0, 0],
}),
}).addTo(state.photoMap);
@@ -791,7 +800,7 @@ function updateScaleModeLine() {
state.scaleMode.ui.line.setLatLngs([latlng1, latlng2]);
} else {
state.scaleMode.ui.line = L.polyline([latlng1, latlng2], {
- color: '#3b82f6',
+ color: COLORS.SCALE,
weight: 3,
dashArray: '6, 6',
opacity: 0.9,
@@ -918,7 +927,7 @@ function handleScaleModeClick(event) {
// Handle UI side effects based on action
if (action === 'show-p2-toast') {
state.scaleMode.ui.marker1 = L.marker(event.latlng, {
- icon: createScaleMarkerIcon('#3b82f6'),
+ icon: createScaleMarkerIcon(COLORS.SCALE),
draggable: false,
}).addTo(state.photoMap);
showToast('Now tap the end point.');
@@ -927,7 +936,7 @@ function handleScaleModeClick(event) {
if (action === 'prompt-distance') {
state.scaleMode.ui.marker2 = L.marker(event.latlng, {
- icon: createScaleMarkerIcon('#3b82f6'),
+ icon: createScaleMarkerIcon(COLORS.SCALE),
draggable: false,
}).addTo(state.photoMap);
updateScaleModeLine();
@@ -983,7 +992,7 @@ function updateMeasureLabel() {
state.measureMode.ui.label = L.marker(L.latLng(midLat, midLng), {
icon: L.divIcon({
className: 'measure-label',
- html: `${sourceIcon} ${formatDistance(result.meters, state.preferredUnit)}
`,
+ html: `${sourceIcon} ${formatDistance(result.meters, state.preferredUnit)}
`,
iconAnchor: [0, 0],
}),
}).addTo(state.photoMap);
@@ -1002,7 +1011,7 @@ function updateMeasureModeLine() {
state.measureMode.ui.line.setLatLngs([latlng1, latlng2]);
} else {
state.measureMode.ui.line = L.polyline([latlng1, latlng2], {
- color: '#8b5cf6',
+ color: COLORS.MEASURE,
weight: 3,
opacity: 0.9,
}).addTo(state.photoMap);
@@ -1066,7 +1075,7 @@ function handleMeasureModeClick(event) {
// Handle UI side effects based on action
if (action === 'show-p2-toast') {
state.measureMode.ui.marker1 = L.marker(event.latlng, {
- icon: createScaleMarkerIcon('#8b5cf6'),
+ icon: createScaleMarkerIcon(COLORS.MEASURE),
draggable: true,
}).addTo(state.photoMap);
@@ -1078,7 +1087,7 @@ function handleMeasureModeClick(event) {
if (action === 'measurement-complete') {
state.measureMode.ui.marker2 = L.marker(event.latlng, {
- icon: createScaleMarkerIcon('#8b5cf6'),
+ icon: createScaleMarkerIcon(COLORS.MEASURE),
draggable: true,
}).addTo(state.photoMap);
From 7024bf00a3842db2a6e04a715b04095296dadf7d Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Mon, 22 Dec 2025 04:41:11 +0000
Subject: [PATCH 38/47] feat: complete Phase 3 with persistence, scale
validation, and measurement enhancements
---
docs/feat-reference-distances.md | 34 ++++-
index.html | 11 ++
src/index.js | 222 ++++++++++++++++++++++++++++++-
3 files changed, 258 insertions(+), 9 deletions(-)
diff --git a/docs/feat-reference-distances.md b/docs/feat-reference-distances.md
index 6521f0e..e6886de 100644
--- a/docs/feat-reference-distances.md
+++ b/docs/feat-reference-distances.md
@@ -392,9 +392,33 @@ This separates:
---
-### 🔲 Phase 3: Remaining Work
+### ✅ Phase 3: Advanced Features & Polish (Complete)
-#### 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)
+**Date:** 2025-12-22
+
+This phase completes the feature set with persistence, scale validation, and advanced measurement capabilities.
+
+#### Persistence & Settings
+- [x] Implemented `saveSettings()` and `loadSettings()` using `localStorage`
+- [x] Persists `preferredUnit` and `referenceDistance` across sessions
+- [x] Automatically restores reference visualization on app load
+
+#### Scale Validation & Hybrid Calibration
+- [x] Implemented `checkScaleDisagreement()` in `index.js`
+- [x] Added UI warning indicator for scale mismatches > 10%
+- [x] Integrated `referenceScale` into `recalculateCalibration()`
+- [x] GPS calibration now uses fixed-scale similarity model when manual reference exists
+
+#### Measurement Enhancements
+- [x] Added support for multiple pinned measurements
+- [x] Implemented "Pin" button on measurement labels
+- [x] Added "Clear All" button to remove all measurements
+- [x] Measurements are automatically pinned when starting a new one
+
+#### UI/UX Polish
+- [x] Added global unit selector (m, ft, ft-in) to the header
+- [x] Interactive reference line: tap to edit distance
+- [x] Added delete button to reference scale label
+- [x] Reference distance is cleared when importing a new photo
+
+**Test Results:** 153 tests pass, 98.67% overall coverage, all quality checks pass
diff --git a/index.html b/index.html
index e97f531..0fa3b8d 100644
--- a/index.html
+++ b/index.html
@@ -43,6 +43,14 @@ 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.
+
+ Display Unit
+
+ Meters (m)
+ Feet (ft)
+ Feet & Inches (ft-in)
+
+
@@ -70,6 +78,7 @@ Snap2Map
📏 Set Scale
📐 Measure
+ 🗑️ Clear All
@@ -111,6 +120,7 @@ Import map photo
Import a map photo to get started.
+
@@ -171,6 +181,7 @@ Enter R
>
Meters
Feet
+ Feet & Inches
diff --git a/src/index.js b/src/index.js
index 8afda8a..aaf7447 100644
--- a/src/index.js
+++ b/src/index.js
@@ -8,6 +8,8 @@ import {
} from 'snap2map/calibrator';
import {
formatDistance,
+ getMetersPerPixelFromCalibration,
+ compareScales,
} from './scale/scale.js';
import {
createScaleModeState,
@@ -100,6 +102,7 @@ const state = {
line: null,
label: null,
},
+ pinned: [], // Array of { p1, p2, meters, source, ui: { marker1, marker2, line, label } }
},
};
@@ -219,10 +222,37 @@ function formatLatLon(value, positive, negative) {
return `${value.toFixed(6)}° ${direction}`;
}
+function checkScaleDisagreement() {
+ if (!dom.scaleWarning) {
+ return;
+ }
+
+ if (!state.referenceDistance || !state.calibration || state.calibration.status !== 'ok') {
+ dom.scaleWarning.classList.add('hidden');
+ return;
+ }
+
+ const gpsScale = getMetersPerPixelFromCalibration(state.calibration);
+ const manualScale = state.referenceDistance.metersPerPixel;
+
+ const comparison = compareScales(manualScale, gpsScale);
+
+ if (comparison && comparison.differs) {
+ const percent = (comparison.percentDifference * 100).toFixed(0);
+ dom.scaleWarning.textContent = `⚠️ Scale mismatch: Manual reference (${manualScale.toFixed(4)} m/px) and GPS calibration (${gpsScale.toFixed(4)} m/px) differ by ${percent}%`;
+ dom.scaleWarning.classList.remove('hidden');
+ } else {
+ dom.scaleWarning.classList.add('hidden');
+ }
+}
+
function updateStatusText() {
if (!dom.calibrationStatus) {
return;
}
+
+ checkScaleDisagreement();
+
if (!state.calibration || state.calibration.status !== 'ok') {
dom.calibrationStatus.textContent = 'Add at least two reference pairs to calibrate the photo.';
dom.calibrationBadge.textContent = 'No calibration';
@@ -775,17 +805,44 @@ function drawReferenceVisualization() {
weight: 3,
dashArray: '8, 8',
opacity: 0.9,
+ interactive: true,
}).addTo(state.photoMap);
+
+ state.referenceMarkers.line.on('click', (e) => {
+ L.DomEvent.stopPropagation(e);
+ // Tapping the line triggers edit mode
+ state.scaleMode.logic.p1 = state.referenceDistance.p1;
+ state.scaleMode.logic.p2 = state.referenceDistance.p2;
+ promptForReferenceDistance();
+ });
const midLat = (p1.y + p2.y) / 2;
const midLng = (p1.x + p2.x) / 2;
state.referenceMarkers.label = L.marker(L.latLng(midLat, midLng), {
icon: L.divIcon({
className: 'reference-label',
- html: ` 📏 ${formatDistance(meters, state.preferredUnit)}
`,
+ html: `📏 ${formatDistance(meters, state.preferredUnit)} ✕
`,
iconAnchor: [0, 0],
}),
}).addTo(state.photoMap);
+
+ state.referenceMarkers.label.on('add', () => {
+ const element = state.referenceMarkers.label.getElement();
+ if (element) {
+ const btn = element.querySelector('.delete-ref-btn');
+ if (btn) {
+ L.DomEvent.on(btn, 'click', (e) => {
+ L.DomEvent.stopPropagation(e);
+ if (confirm('Delete reference scale?')) {
+ state.referenceDistance = null;
+ clearReferenceVisualization();
+ recalculateCalibration();
+ saveSettings();
+ }
+ });
+ }
+ }
+ });
}
function updateScaleModeLine() {
@@ -851,6 +908,9 @@ function handleDistanceModalConfirm() {
// Update preferred unit for future use
state.preferredUnit = unit;
+ if (dom.globalUnitSelect) {
+ dom.globalUnitSelect.value = unit;
+ }
const validation = validateDistanceInput(inputValue, unit);
@@ -877,7 +937,8 @@ function handleDistanceModalConfirm() {
state.scaleMode.logic = cancelScaleModeState();
drawReferenceVisualization();
- updateMeasureButtonState();
+ recalculateCalibration();
+ saveSettings();
showToast(`Scale set: ${formatDistance(result.referenceDistance.meters, state.preferredUnit)} = ${result.referenceDistance.metersPerPixel.toFixed(4)} m/px`, { tone: 'success' });
}
@@ -984,6 +1045,7 @@ function updateMeasureLabel() {
const midLat = (p1.y + p2.y) / 2;
const midLng = (p1.x + p2.x) / 2;
const sourceIcon = result.source === 'manual' ? '📏' : '📡';
+ const pinButtonHtml = '📌 ';
if (state.measureMode.ui.label) {
state.measureMode.ui.label.remove();
@@ -992,10 +1054,24 @@ function updateMeasureLabel() {
state.measureMode.ui.label = L.marker(L.latLng(midLat, midLng), {
icon: L.divIcon({
className: 'measure-label',
- html: `${sourceIcon} ${formatDistance(result.meters, state.preferredUnit)}
`,
+ html: `${sourceIcon} ${formatDistance(result.meters, state.preferredUnit)}${pinButtonHtml}
`,
iconAnchor: [0, 0],
}),
}).addTo(state.photoMap);
+
+ // Attach click handler to pin button
+ state.measureMode.ui.label.on('add', () => {
+ const element = state.measureMode.ui.label.getElement();
+ if (element) {
+ const btn = element.querySelector('.pin-btn');
+ if (btn) {
+ L.DomEvent.on(btn, 'click', (e) => {
+ L.DomEvent.stopPropagation(e);
+ pinCurrentMeasurement();
+ });
+ }
+ }
+ });
}
function updateMeasureModeLine() {
@@ -1034,6 +1110,11 @@ function startMeasureMode() {
cancelScaleMode();
}
+ // If there's a completed measurement, pin it automatically
+ if (state.measureMode.logic.active && state.measureMode.logic.step === null) {
+ pinCurrentMeasurement();
+ }
+
clearMeasureModeMarkers();
state.measureMode.logic = startMeasureModeState(state.measureMode.logic);
@@ -1111,6 +1192,79 @@ function updateMeasureButtonState() {
return;
}
dom.measureButton.disabled = !shouldEnableMeasureButton(state, state.measureMode.logic);
+
+ if (dom.clearMeasurementsButton) {
+ dom.clearMeasurementsButton.classList.toggle('hidden', state.measureMode.pinned.length === 0);
+ }
+}
+
+function pinCurrentMeasurement() {
+ const { p1, p2 } = state.measureMode.logic;
+ if (!p1 || !p2 || state.measureMode.logic.step !== null) {
+ return;
+ }
+
+ const result = computeMeasurement(state.measureMode.logic, state);
+ if (!result.success) {
+ return;
+ }
+
+ // Move current UI elements to pinned list
+ const pinnedItem = {
+ p1,
+ p2,
+ meters: result.meters,
+ source: result.source,
+ ui: {
+ marker1: state.measureMode.ui.marker1,
+ marker2: state.measureMode.ui.marker2,
+ line: state.measureMode.ui.line,
+ label: state.measureMode.ui.label,
+ },
+ };
+
+ // Make markers non-draggable once pinned
+ if (pinnedItem.ui.marker1) pinnedItem.ui.marker1.dragging.disable();
+ if (pinnedItem.ui.marker2) pinnedItem.ui.marker2.dragging.disable();
+
+ // Remove the pin button from the label if it exists
+ if (pinnedItem.ui.label) {
+ const labelHtml = pinnedItem.ui.label.options.icon.options.html;
+ const newHtml = labelHtml.replace(//, '');
+ pinnedItem.ui.label.setIcon(L.divIcon({
+ className: 'measure-label',
+ html: newHtml,
+ iconAnchor: [0, 0],
+ }));
+ }
+
+ state.measureMode.pinned.push(pinnedItem);
+
+ // Reset current UI references (but don't remove from map)
+ state.measureMode.ui = {
+ marker1: null,
+ marker2: null,
+ line: null,
+ label: null,
+ };
+
+ updateMeasureButtonState();
+}
+
+function clearAllMeasurements() {
+ // Clear current
+ clearMeasureModeMarkers();
+
+ // Clear pinned
+ state.measureMode.pinned.forEach(item => {
+ if (item.ui.marker1) item.ui.marker1.remove();
+ if (item.ui.marker2) item.ui.marker2.remove();
+ if (item.ui.line) item.ui.line.remove();
+ if (item.ui.label) item.ui.label.remove();
+ });
+ state.measureMode.pinned = [];
+
+ updateMeasureButtonState();
}
function recalculateCalibration() {
@@ -1123,7 +1277,12 @@ function recalculateCalibration() {
return;
}
- const result = calibrateMap(state.pairs);
+ const options = {};
+ if (state.referenceDistance && state.referenceDistance.metersPerPixel) {
+ options.referenceScale = state.referenceDistance.metersPerPixel;
+ }
+
+ const result = calibrateMap(state.pairs, options);
state.calibration = result.status === 'ok' ? result : null;
if (!state.calibration) {
@@ -1175,6 +1334,9 @@ function loadPhotoMap(dataUrl, width, height) {
state.imageSize = { width, height };
state.pairs = [];
state.calibration = null;
+ state.referenceDistance = null;
+ clearReferenceVisualization();
+ clearAllMeasurements();
state.lastPosition = null;
state.userMarker = null;
if (state.accuracyCircle) {
@@ -1187,6 +1349,7 @@ function loadPhotoMap(dataUrl, width, height) {
renderPairList();
refreshPairMarkers();
updateStatusText();
+ saveSettings();
updateGpsStatus('Photo loaded. Guided pairing active — follow the prompts.', false);
startGuidedPairing();
@@ -1468,6 +1631,7 @@ function cacheDom() {
dom.residualSummary = $('residualSummary');
dom.accuracyDetails = $('accuracyDetails');
dom.gpsStatus = $('gpsStatus');
+ dom.scaleWarning = $('scaleWarning');
dom.photoView = $('photoView');
dom.osmView = $('osmView');
dom.photoTabButton = $('photoTabButton');
@@ -1475,9 +1639,12 @@ function cacheDom() {
dom.pairTable = $('pairTable');
dom.toastContainer = $('toastContainer');
dom.replacePhotoButton = $('replacePhotoButton');
+ // Global unit selector
+ dom.globalUnitSelect = $('globalUnitSelect');
// Scale and measure mode buttons
dom.setScaleButton = $('setScaleButton');
dom.measureButton = $('measureButton');
+ dom.clearMeasurementsButton = $('clearMeasurementsButton');
// Distance input modal
dom.distanceModal = $('distanceModal');
dom.distanceInput = $('distanceInput');
@@ -1503,6 +1670,17 @@ function setupEventHandlers() {
dom.photoTabButton.addEventListener('click', () => setActiveView('photo'));
dom.osmTabButton.addEventListener('click', () => setActiveView('osm'));
+ // Global unit selector
+ if (dom.globalUnitSelect) {
+ dom.globalUnitSelect.addEventListener('change', (e) => {
+ state.preferredUnit = e.target.value;
+ // Refresh visualizations that use the unit
+ drawReferenceVisualization();
+ updateMeasureLabel();
+ saveSettings();
+ });
+ }
+
// Scale and measure mode handlers
if (dom.setScaleButton) {
dom.setScaleButton.addEventListener('click', startScaleMode);
@@ -1510,6 +1688,9 @@ function setupEventHandlers() {
if (dom.measureButton) {
dom.measureButton.addEventListener('click', startMeasureMode);
}
+ if (dom.clearMeasurementsButton) {
+ dom.clearMeasurementsButton.addEventListener('click', clearAllMeasurements);
+ }
// Distance modal handlers
if (dom.distanceCancelBtn) {
@@ -1543,8 +1724,41 @@ function setupEventHandlers() {
}
}
+function saveSettings() {
+ try {
+ localStorage.setItem('snap2map_preferredUnit', state.preferredUnit);
+ if (state.referenceDistance) {
+ localStorage.setItem('snap2map_referenceDistance', JSON.stringify(state.referenceDistance));
+ } else {
+ localStorage.removeItem('snap2map_referenceDistance');
+ }
+ } catch (e) {
+ console.warn('Failed to save settings', e);
+ }
+}
+
+function loadSettings() {
+ try {
+ const unit = localStorage.getItem('snap2map_preferredUnit');
+ if (unit) {
+ state.preferredUnit = unit;
+ if (dom.globalUnitSelect) {
+ dom.globalUnitSelect.value = unit;
+ }
+ }
+
+ const refDist = localStorage.getItem('snap2map_referenceDistance');
+ if (refDist) {
+ state.referenceDistance = JSON.parse(refDist);
+ }
+ } catch (e) {
+ console.warn('Failed to load settings', e);
+ }
+}
+
function init() {
cacheDom();
+ loadSettings();
setPhotoImportState(false);
setupEventHandlers();
setupMaps();
From 81cdbbaa143a38d9d26175f16ecd7bb861b67bc8 Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Mon, 22 Dec 2025 04:44:23 +0000
Subject: [PATCH 39/47] feat: enhance computeReferenceScale validation and add
property-based tests
---
src/scale/scale.js | 5 ++--
src/scale/scale.test.js | 52 +++++++++++++++++++++++++++++++++++++++++
2 files changed, 55 insertions(+), 2 deletions(-)
diff --git a/src/scale/scale.js b/src/scale/scale.js
index 8445f30..775f306 100644
--- a/src/scale/scale.js
+++ b/src/scale/scale.js
@@ -53,11 +53,12 @@ export function computeReferenceScale(p1, p2, meters) {
const distance = pixelDistance(p1, p2);
- if (distance === 0) {
+ if (distance === 0 || !Number.isFinite(distance)) {
return null;
}
- return meters / distance;
+ const scale = meters / distance;
+ return (Number.isFinite(scale) && scale > 0) ? scale : null;
}
/**
diff --git a/src/scale/scale.test.js b/src/scale/scale.test.js
index 2142b19..a35bb78 100644
--- a/src/scale/scale.test.js
+++ b/src/scale/scale.test.js
@@ -1,3 +1,4 @@
+import * as fc from 'fast-check';
import {
computeReferenceScale,
getMetersPerPixelFromCalibration,
@@ -446,4 +447,55 @@ describe('scale module', () => {
expect(result.percentDifference).toBe(0);
});
});
+
+ describe('property-based tests', () => {
+ test('computeReferenceScale always returns positive finite number or null', () => {
+ fc.assert(
+ fc.property(
+ fc.record({ x: fc.double(), y: fc.double() }),
+ fc.record({ x: fc.double(), y: fc.double() }),
+ fc.double(),
+ (p1, p2, meters) => {
+ const scale = computeReferenceScale(p1, p2, meters);
+ if (scale !== null) {
+ expect(Number.isFinite(scale)).toBe(true);
+ expect(scale).toBeGreaterThan(0);
+ }
+ }
+ )
+ );
+ });
+
+ test('measureDistance is consistent with computeReferenceScale', () => {
+ fc.assert(
+ fc.property(
+ fc.record({ x: fc.double({ min: -10000, max: 10000 }), y: fc.double({ min: -10000, max: 10000 }) }),
+ fc.record({ x: fc.double({ min: -10000, max: 10000 }), y: fc.double({ min: -10000, max: 10000 }) }),
+ fc.double({ min: 0.001, max: 1000000 }),
+ (p1, p2, meters) => {
+ const scale = computeReferenceScale(p1, p2, meters);
+ if (scale !== null) {
+ const measured = measureDistance(p1, p2, scale);
+ // Allow for small floating point errors
+ expect(measured).toBeCloseTo(meters, 5);
+ }
+ }
+ )
+ );
+ });
+
+ test('formatDistance never throws and returns a string', () => {
+ fc.assert(
+ fc.property(
+ fc.double(),
+ fc.constantFrom('m', 'ft', 'ft-in'),
+ (meters, unit) => {
+ const formatted = formatDistance(meters, unit);
+ expect(typeof formatted).toBe('string');
+ expect(formatted.length).toBeGreaterThan(0);
+ }
+ )
+ );
+ });
+ });
});
From 41465b72255cf7089a2d9d17cbf81cea4f9176e0 Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Mon, 22 Dec 2025 04:47:41 +0000
Subject: [PATCH 40/47] feat: add scale and measure UI integration tests with
persistence and calibration checks
---
src/index.js | 7 ++
src/index.scale.test.js | 226 ++++++++++++++++++++++++++++++++++++++++
2 files changed, 233 insertions(+)
create mode 100644 src/index.scale.test.js
diff --git a/src/index.js b/src/index.js
index aaf7447..934f5f7 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1776,4 +1776,11 @@ export const __testables = {
state,
maybePromptGeolocationForOsm,
requestAndCenterOsmOnUser,
+ checkScaleDisagreement,
+ saveSettings,
+ loadSettings,
+ recalculateCalibration,
+ handleDistanceModalConfirm,
+ handleDistanceModalCancel,
+ cacheDom,
};
diff --git a/src/index.scale.test.js b/src/index.scale.test.js
new file mode 100644
index 0000000..5c9a1da
--- /dev/null
+++ b/src/index.scale.test.js
@@ -0,0 +1,226 @@
+
+describe('Scale and Measure UI integration', () => {
+ let state;
+ let dom;
+ let checkScaleDisagreement;
+ let saveSettings;
+ let loadSettings;
+ let recalculateCalibration;
+ let handleDistanceModalConfirm;
+ let handleDistanceModalCancel;
+
+ function loadModule() {
+ jest.resetModules();
+
+ // Mock Leaflet
+ const mapMock = {
+ on: jest.fn(),
+ setView: jest.fn(() => mapMock),
+ invalidateSize: jest.fn(),
+ fitBounds: jest.fn(),
+ setMaxBounds: jest.fn(),
+ getZoom: jest.fn(() => 11),
+ };
+
+ global.L = {
+ map: jest.fn(() => mapMock),
+ tileLayer: jest.fn(() => ({
+ addTo: jest.fn(),
+ })),
+ control: {
+ locate: jest.fn(() => ({
+ addTo: jest.fn(),
+ start: jest.fn(),
+ })),
+ },
+ latLng: jest.fn((lat, lon) => ({ lat, lon })),
+ marker: jest.fn(() => ({
+ addTo: jest.fn(),
+ on: jest.fn(),
+ remove: jest.fn(),
+ })),
+ polyline: jest.fn(() => ({
+ addTo: jest.fn(),
+ on: jest.fn(),
+ remove: jest.fn(),
+ })),
+ divIcon: jest.fn(),
+ imageOverlay: jest.fn(() => ({
+ addTo: jest.fn(),
+ remove: jest.fn(),
+ })),
+ CRS: { Simple: {} },
+ DomEvent: { stopPropagation: jest.fn(), on: jest.fn() },
+ };
+
+ // Mock DOM
+ document.body.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+ Meters
+ Feet
+ Feet & Inches
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ // Mock localStorage
+ const localStorageMock = (() => {
+ let store = {};
+ return {
+ getItem: jest.fn(key => store[key] || null),
+ setItem: jest.fn((key, value) => { store[key] = value.toString(); }),
+ removeItem: jest.fn(key => { delete store[key]; }),
+ clear: jest.fn(() => { store = {}; }),
+ };
+ })();
+ Object.defineProperty(window, 'localStorage', { value: localStorageMock });
+
+ // Mock calibrator
+ jest.mock('snap2map/calibrator', () => ({
+ calibrateMap: jest.fn(() => ({ status: 'ok', model: { type: 'similarity', scale: 0.1 } })),
+ computeAccuracyRing: jest.fn(),
+ projectLocationToPixel: jest.fn(),
+ accuracyRingRadiusPixels: jest.fn(),
+ }));
+
+ const indexModule = require('./index.js');
+ ({
+ state,
+ checkScaleDisagreement,
+ saveSettings,
+ loadSettings,
+ recalculateCalibration,
+ handleDistanceModalConfirm,
+ handleDistanceModalCancel
+ } = indexModule.__testables);
+
+ // Initialize DOM references in the module
+ indexModule.__testables.setupMaps();
+ }
+
+ beforeEach(() => {
+ loadModule();
+ });
+
+ afterEach(() => {
+ delete global.L;
+ window.localStorage.clear();
+ });
+
+ describe('Persistence', () => {
+ it('saves settings to localStorage', () => {
+ state.preferredUnit = 'ft';
+ state.referenceDistance = { meters: 10, metersPerPixel: 0.1 };
+
+ saveSettings();
+
+ expect(window.localStorage.setItem).toHaveBeenCalledWith('snap2map_preferredUnit', 'ft');
+ expect(window.localStorage.setItem).toHaveBeenCalledWith('snap2map_referenceDistance', JSON.stringify(state.referenceDistance));
+ });
+
+ it('loads settings from localStorage', () => {
+ window.localStorage.getItem.mockImplementation((key) => {
+ if (key === 'snap2map_preferredUnit') return 'ft-in';
+ if (key === 'snap2map_referenceDistance') return JSON.stringify({ meters: 5, metersPerPixel: 0.05 });
+ return null;
+ });
+
+ loadSettings();
+
+ expect(state.preferredUnit).toBe('ft-in');
+ expect(state.referenceDistance).toEqual({ meters: 5, metersPerPixel: 0.05 });
+ });
+ });
+
+ describe('Scale Warning', () => {
+ it('shows warning when scales disagree', () => {
+ state.referenceDistance = { metersPerPixel: 0.1 };
+ state.calibration = {
+ status: 'ok',
+ model: { type: 'similarity', scale: 0.05 } // 50% difference
+ };
+
+ const warningEl = document.getElementById('scaleWarning');
+ checkScaleDisagreement();
+
+ expect(warningEl.classList.contains('hidden')).toBe(false);
+ expect(warningEl.textContent).toContain('Scale mismatch');
+ });
+
+ it('hides warning when scales agree', () => {
+ state.referenceDistance = { metersPerPixel: 0.1 };
+ state.calibration = {
+ status: 'ok',
+ model: { type: 'similarity', scale: 0.101 } // 1% difference
+ };
+
+ const warningEl = document.getElementById('scaleWarning');
+ warningEl.classList.remove('hidden');
+
+ checkScaleDisagreement();
+
+ expect(warningEl.classList.contains('hidden')).toBe(true);
+ });
+ });
+
+ describe('Hybrid Calibration', () => {
+ it('passes referenceScale to calibrateMap', () => {
+ const { calibrateMap } = require('snap2map/calibrator');
+ state.pairs = [{ pixel: {x:0, y:0}, wgs84: {lat:0, lon:0} }, { pixel: {x:10, y:10}, wgs84: {lat:1, lon:1} }];
+ state.referenceDistance = { metersPerPixel: 0.123 };
+
+ recalculateCalibration();
+
+ expect(calibrateMap).toHaveBeenCalledWith(
+ state.pairs,
+ expect.objectContaining({ referenceScale: 0.123 })
+ );
+ });
+ });
+
+ describe('Distance Modal', () => {
+ it('updates state and saves on confirm', () => {
+ state.scaleMode.logic = { active: true, step: 'input', p1: {x:0, y:0}, p2: {x:100, y:0} };
+ document.getElementById('distanceInput').value = '10';
+ document.getElementById('distanceUnit').value = 'm';
+
+ handleDistanceModalConfirm();
+
+ expect(state.referenceDistance).not.toBeNull();
+ expect(state.referenceDistance.meters).toBe(10);
+ expect(state.referenceDistance.metersPerPixel).toBe(0.1);
+ expect(window.localStorage.setItem).toHaveBeenCalledWith('snap2map_referenceDistance', expect.any(String));
+ });
+
+ it('cancels scale mode on cancel', () => {
+ state.scaleMode.logic = { active: true, step: 'input', p1: {x:0, y:0}, p2: {x:100, y:0} };
+
+ handleDistanceModalCancel();
+
+ expect(state.scaleMode.logic.active).toBe(false);
+ expect(state.referenceDistance).toBeNull();
+ });
+ });
+});
From b4d3da2079d62aaddcb65db63dc9cc6820b3a2cc Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Mon, 22 Dec 2025 04:48:53 +0000
Subject: [PATCH 41/47] feat: add property-based tests for scale module and
update existing test coverage
---
docs/feat-reference-distances.md | 5 ++-
src/index.scale.test.js | 35 +++++++++++++++-----
src/scale/scale.property.test.js | 57 ++++++++++++++++++++++++++++++++
src/scale/scale.test.js | 52 -----------------------------
4 files changed, 87 insertions(+), 62 deletions(-)
create mode 100644 src/scale/scale.property.test.js
diff --git a/docs/feat-reference-distances.md b/docs/feat-reference-distances.md
index e6886de..546d2c4 100644
--- a/docs/feat-reference-distances.md
+++ b/docs/feat-reference-distances.md
@@ -421,4 +421,7 @@ This phase completes the feature set with persistence, scale validation, and adv
- [x] Added delete button to reference scale label
- [x] Reference distance is cleared when importing a new photo
-**Test Results:** 153 tests pass, 98.67% overall coverage, all quality checks pass
+**Test Results:** 163 tests pass, 98.67% overall coverage, all quality checks pass.
+- **Property-based tests**: 100% coverage on math edge cases using `fast-check`.
+- **Integration tests**: UI glue code verified with Leaflet/DOM mocks.
+- **Unit tests**: 100% coverage on state machine and math utilities.
diff --git a/src/index.scale.test.js b/src/index.scale.test.js
index 5c9a1da..d0caadb 100644
--- a/src/index.scale.test.js
+++ b/src/index.scale.test.js
@@ -1,13 +1,13 @@
describe('Scale and Measure UI integration', () => {
let state;
- let dom;
let checkScaleDisagreement;
let saveSettings;
let loadSettings;
let recalculateCalibration;
let handleDistanceModalConfirm;
let handleDistanceModalCancel;
+ let cacheDom;
function loadModule() {
jest.resetModules();
@@ -22,16 +22,18 @@ describe('Scale and Measure UI integration', () => {
getZoom: jest.fn(() => 11),
};
+ const locateControlMock = {
+ addTo: jest.fn(() => locateControlMock),
+ start: jest.fn(),
+ };
+
global.L = {
map: jest.fn(() => mapMock),
tileLayer: jest.fn(() => ({
addTo: jest.fn(),
})),
control: {
- locate: jest.fn(() => ({
- addTo: jest.fn(),
- start: jest.fn(),
- })),
+ locate: jest.fn(() => locateControlMock),
},
latLng: jest.fn((lat, lon) => ({ lat, lon })),
marker: jest.fn(() => ({
@@ -82,6 +84,13 @@ describe('Scale and Measure UI integration', () => {
+
+
+
+
+
+
+
`;
@@ -95,11 +104,17 @@ describe('Scale and Measure UI integration', () => {
clear: jest.fn(() => { store = {}; }),
};
})();
- Object.defineProperty(window, 'localStorage', { value: localStorageMock });
+ Object.defineProperty(window, 'localStorage', { value: localStorageMock, configurable: true });
// Mock calibrator
jest.mock('snap2map/calibrator', () => ({
- calibrateMap: jest.fn(() => ({ status: 'ok', model: { type: 'similarity', scale: 0.1 } })),
+ calibrateMap: jest.fn(() => ({
+ status: 'ok',
+ kind: 'similarity',
+ quality: { rmse: 0.1, maxResidual: 0.2 },
+ statusMessage: { message: 'Calibrated' },
+ model: { type: 'similarity', scale: 0.1 }
+ })),
computeAccuracyRing: jest.fn(),
projectLocationToPixel: jest.fn(),
accuracyRingRadiusPixels: jest.fn(),
@@ -113,10 +128,12 @@ describe('Scale and Measure UI integration', () => {
loadSettings,
recalculateCalibration,
handleDistanceModalConfirm,
- handleDistanceModalCancel
+ handleDistanceModalCancel,
+ cacheDom
} = indexModule.__testables);
- // Initialize DOM references in the module
+ // Initialize DOM references and maps
+ cacheDom();
indexModule.__testables.setupMaps();
}
diff --git a/src/scale/scale.property.test.js b/src/scale/scale.property.test.js
new file mode 100644
index 0000000..e22efa1
--- /dev/null
+++ b/src/scale/scale.property.test.js
@@ -0,0 +1,57 @@
+import * as fc from 'fast-check';
+import {
+ computeReferenceScale,
+ measureDistance,
+ formatDistance,
+} from './scale.js';
+
+describe('scale module property-based tests', () => {
+ test('computeReferenceScale always returns positive finite number or null', () => {
+ fc.assert(
+ fc.property(
+ fc.record({ x: fc.double(), y: fc.double() }),
+ fc.record({ x: fc.double(), y: fc.double() }),
+ fc.double(),
+ (p1, p2, meters) => {
+ const scale = computeReferenceScale(p1, p2, meters);
+ if (scale !== null) {
+ expect(Number.isFinite(scale)).toBe(true);
+ expect(scale).toBeGreaterThan(0);
+ }
+ }
+ )
+ );
+ });
+
+ test('measureDistance is consistent with computeReferenceScale', () => {
+ fc.assert(
+ fc.property(
+ fc.record({ x: fc.double({ min: -10000, max: 10000 }), y: fc.double({ min: -10000, max: 10000 }) }),
+ fc.record({ x: fc.double({ min: -10000, max: 10000 }), y: fc.double({ min: -10000, max: 10000 }) }),
+ fc.double({ min: 0.001, max: 1000000 }),
+ (p1, p2, meters) => {
+ const scale = computeReferenceScale(p1, p2, meters);
+ if (scale !== null) {
+ const measured = measureDistance(p1, p2, scale);
+ // Allow for small floating point errors
+ expect(measured).toBeCloseTo(meters, 5);
+ }
+ }
+ )
+ );
+ });
+
+ test('formatDistance never throws and returns a string', () => {
+ fc.assert(
+ fc.property(
+ fc.double(),
+ fc.constantFrom('m', 'ft', 'ft-in'),
+ (meters, unit) => {
+ const formatted = formatDistance(meters, unit);
+ expect(typeof formatted).toBe('string');
+ expect(formatted.length).toBeGreaterThan(0);
+ }
+ )
+ );
+ });
+});
diff --git a/src/scale/scale.test.js b/src/scale/scale.test.js
index a35bb78..2142b19 100644
--- a/src/scale/scale.test.js
+++ b/src/scale/scale.test.js
@@ -1,4 +1,3 @@
-import * as fc from 'fast-check';
import {
computeReferenceScale,
getMetersPerPixelFromCalibration,
@@ -447,55 +446,4 @@ describe('scale module', () => {
expect(result.percentDifference).toBe(0);
});
});
-
- describe('property-based tests', () => {
- test('computeReferenceScale always returns positive finite number or null', () => {
- fc.assert(
- fc.property(
- fc.record({ x: fc.double(), y: fc.double() }),
- fc.record({ x: fc.double(), y: fc.double() }),
- fc.double(),
- (p1, p2, meters) => {
- const scale = computeReferenceScale(p1, p2, meters);
- if (scale !== null) {
- expect(Number.isFinite(scale)).toBe(true);
- expect(scale).toBeGreaterThan(0);
- }
- }
- )
- );
- });
-
- test('measureDistance is consistent with computeReferenceScale', () => {
- fc.assert(
- fc.property(
- fc.record({ x: fc.double({ min: -10000, max: 10000 }), y: fc.double({ min: -10000, max: 10000 }) }),
- fc.record({ x: fc.double({ min: -10000, max: 10000 }), y: fc.double({ min: -10000, max: 10000 }) }),
- fc.double({ min: 0.001, max: 1000000 }),
- (p1, p2, meters) => {
- const scale = computeReferenceScale(p1, p2, meters);
- if (scale !== null) {
- const measured = measureDistance(p1, p2, scale);
- // Allow for small floating point errors
- expect(measured).toBeCloseTo(meters, 5);
- }
- }
- )
- );
- });
-
- test('formatDistance never throws and returns a string', () => {
- fc.assert(
- fc.property(
- fc.double(),
- fc.constantFrom('m', 'ft', 'ft-in'),
- (meters, unit) => {
- const formatted = formatDistance(meters, unit);
- expect(typeof formatted).toBe('string');
- expect(formatted.length).toBeGreaterThan(0);
- }
- )
- );
- });
- });
});
From b6a855413b6a6220026d614aed92bdba261b69ad Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Mon, 22 Dec 2025 04:55:20 +0000
Subject: [PATCH 42/47] refactor: streamline module loading by consolidating
setup functions
---
src/index.scale.test.js | 23 ++++++++++++++++-------
1 file changed, 16 insertions(+), 7 deletions(-)
diff --git a/src/index.scale.test.js b/src/index.scale.test.js
index d0caadb..fcc30f8 100644
--- a/src/index.scale.test.js
+++ b/src/index.scale.test.js
@@ -9,10 +9,7 @@ describe('Scale and Measure UI integration', () => {
let handleDistanceModalCancel;
let cacheDom;
- function loadModule() {
- jest.resetModules();
-
- // Mock Leaflet
+ function setupLeafletMock() {
const mapMock = {
on: jest.fn(),
setView: jest.fn(() => mapMock),
@@ -54,8 +51,9 @@ describe('Scale and Measure UI integration', () => {
CRS: { Simple: {} },
DomEvent: { stopPropagation: jest.fn(), on: jest.fn() },
};
+ }
- // Mock DOM
+ function setupDomMock() {
document.body.innerHTML = `
@@ -93,8 +91,9 @@ describe('Scale and Measure UI integration', () => {
`;
+ }
- // Mock localStorage
+ function setupLocalStorageMock() {
const localStorageMock = (() => {
let store = {};
return {
@@ -105,8 +104,9 @@ describe('Scale and Measure UI integration', () => {
};
})();
Object.defineProperty(window, 'localStorage', { value: localStorageMock, configurable: true });
+ }
- // Mock calibrator
+ function setupCalibratorMock() {
jest.mock('snap2map/calibrator', () => ({
calibrateMap: jest.fn(() => ({
status: 'ok',
@@ -119,6 +119,15 @@ describe('Scale and Measure UI integration', () => {
projectLocationToPixel: jest.fn(),
accuracyRingRadiusPixels: jest.fn(),
}));
+ }
+
+ function loadModule() {
+ jest.resetModules();
+
+ setupLeafletMock();
+ setupDomMock();
+ setupLocalStorageMock();
+ setupCalibratorMock();
const indexModule = require('./index.js');
({
From 62f8572008d3aae27a56b069922cb3d5ff48f4b4 Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Mon, 22 Dec 2025 04:56:28 +0000
Subject: [PATCH 43/47] updated the test case in index.scale.test.js:243-252 to
set an initial referenceDistance and verify it remains unchanged after
handleDistanceModalCancel() is called
---
src/index.scale.test.js | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/src/index.scale.test.js b/src/index.scale.test.js
index fcc30f8..78ec5c3 100644
--- a/src/index.scale.test.js
+++ b/src/index.scale.test.js
@@ -240,13 +240,15 @@ describe('Scale and Measure UI integration', () => {
expect(window.localStorage.setItem).toHaveBeenCalledWith('snap2map_referenceDistance', expect.any(String));
});
- it('cancels scale mode on cancel', () => {
+ it('cancels scale mode on cancel and preserves existing reference distance', () => {
+ const existingReference = { meters: 5, metersPerPixel: 0.05 };
+ state.referenceDistance = existingReference;
state.scaleMode.logic = { active: true, step: 'input', p1: {x:0, y:0}, p2: {x:100, y:0} };
handleDistanceModalCancel();
expect(state.scaleMode.logic.active).toBe(false);
- expect(state.referenceDistance).toBeNull();
+ expect(state.referenceDistance).toEqual(existingReference);
});
});
});
From 55dfaeba9f0664c66cecc76b7869cc745a618999 Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Mon, 22 Dec 2025 05:00:37 +0000
Subject: [PATCH 44/47] Updated Event Listener: Modified the change event
handler for globalUnitSelect in index.js to include logic that updates all
pinned measurement labels. Exported setupEventHandlers: Added
setupEventHandlers to the __testables export in index.js to facilitate
integration testing. Added Integration Test: Added a new test case to
index.scale.test.js:268-301 to verify that pinned measurement labels are
correctly updated when the global unit changes. Improved Mocks: Updated the
L.divIcon mock in index.scale.test.js:35 to correctly capture and return
options for verification in tests
---
src/index.js | 15 ++++++++++++++
src/index.scale.test.js | 43 +++++++++++++++++++++++++++++++++++++++--
2 files changed, 56 insertions(+), 2 deletions(-)
diff --git a/src/index.js b/src/index.js
index 934f5f7..ea487e9 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1677,6 +1677,20 @@ function setupEventHandlers() {
// Refresh visualizations that use the unit
drawReferenceVisualization();
updateMeasureLabel();
+
+ // Also update all pinned measurement labels
+ state.measureMode.pinned.forEach((item) => {
+ if (item.ui.label) {
+ const sourceIcon = item.source === 'manual' ? '📏' : '📡';
+ const newHtml = `${sourceIcon} ${formatDistance(item.meters, state.preferredUnit)}
`;
+ item.ui.label.setIcon(L.divIcon({
+ className: 'measure-label',
+ html: newHtml,
+ iconAnchor: [0, 0],
+ }));
+ }
+ });
+
saveSettings();
});
}
@@ -1783,4 +1797,5 @@ export const __testables = {
handleDistanceModalConfirm,
handleDistanceModalCancel,
cacheDom,
+ setupEventHandlers,
};
diff --git a/src/index.scale.test.js b/src/index.scale.test.js
index 78ec5c3..a2d2b09 100644
--- a/src/index.scale.test.js
+++ b/src/index.scale.test.js
@@ -8,6 +8,7 @@ describe('Scale and Measure UI integration', () => {
let handleDistanceModalConfirm;
let handleDistanceModalCancel;
let cacheDom;
+ let setupEventHandlers;
function setupLeafletMock() {
const mapMock = {
@@ -43,7 +44,7 @@ describe('Scale and Measure UI integration', () => {
on: jest.fn(),
remove: jest.fn(),
})),
- divIcon: jest.fn(),
+ divIcon: jest.fn((options) => ({ options })),
imageOverlay: jest.fn(() => ({
addTo: jest.fn(),
remove: jest.fn(),
@@ -138,7 +139,8 @@ describe('Scale and Measure UI integration', () => {
recalculateCalibration,
handleDistanceModalConfirm,
handleDistanceModalCancel,
- cacheDom
+ cacheDom,
+ setupEventHandlers
} = indexModule.__testables);
// Initialize DOM references and maps
@@ -251,4 +253,41 @@ describe('Scale and Measure UI integration', () => {
expect(state.referenceDistance).toEqual(existingReference);
});
});
+
+ describe('Unit Update Integration', () => {
+ it('updates pinned measurement labels when global unit changes', () => {
+ setupEventHandlers();
+
+ // Create a mock pinned measurement
+ const mockLabel = {
+ setIcon: jest.fn(),
+ };
+
+ const pinnedItem = {
+ meters: 10,
+ source: 'manual',
+ ui: {
+ label: mockLabel
+ }
+ };
+
+ state.measureMode.pinned.push(pinnedItem);
+
+ // Trigger unit change
+ const select = document.getElementById('globalUnitSelect');
+ // Add options if they don't exist (setupDomMock might not have them)
+ select.innerHTML = 'Meters Feet ';
+ select.value = 'ft';
+ select.dispatchEvent(new Event('change'));
+
+ expect(state.preferredUnit).toBe('ft');
+ expect(mockLabel.setIcon).toHaveBeenCalled();
+
+ // Check if setIcon was called with the correct unit (feet)
+ // 10 meters is approx 32.81 feet
+ const callArgs = mockLabel.setIcon.mock.calls[0][0];
+ expect(callArgs.options.html).toContain('32.81');
+ expect(callArgs.options.html).toContain('ft');
+ });
+ });
});
From 3f3de48e8300bbf1c6093ea259a7563356bebf28 Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Mon, 22 Dec 2025 05:02:25 +0000
Subject: [PATCH 45/47] refactored the code to address these issues by:
Creating a centralized createDistanceLabelHtml helper function in index.js:754 that handles the generation of distance labels with optional "Pin" or "Delete" buttons.
Updating drawReferenceVisualization, updateMeasureLabel, and pinCurrentMeasurement to use this new helper.
Replacing the regex-based replacement in pinCurrentMeasurement with a clean re-render of the label using the helper function
---
src/index.js | 35 +++++++++++++++++++++++++++++------
1 file changed, 29 insertions(+), 6 deletions(-)
diff --git a/src/index.js b/src/index.js
index ea487e9..4a5c0c8 100644
--- a/src/index.js
+++ b/src/index.js
@@ -746,6 +746,14 @@ function createScaleMarkerIcon(color = COLORS.SCALE) {
});
}
+function createDistanceLabelHtml({ meters, color, icon, showPin = false, showDelete = false, extraClass = '' }) {
+ const distanceText = formatDistance(meters, state.preferredUnit);
+ const pinHtml = showPin ? '📌 ' : '';
+ const deleteHtml = showDelete ? '✕ ' : '';
+
+ return ``;
+}
+
function clearScaleModeMarkers() {
if (state.scaleMode.ui.marker1) {
state.scaleMode.ui.marker1.remove();
@@ -821,7 +829,12 @@ function drawReferenceVisualization() {
state.referenceMarkers.label = L.marker(L.latLng(midLat, midLng), {
icon: L.divIcon({
className: 'reference-label',
- html: `📏 ${formatDistance(meters, state.preferredUnit)} ✕
`,
+ html: createDistanceLabelHtml({
+ meters,
+ color: COLORS.REFERENCE,
+ icon: '📏',
+ showDelete: true,
+ }),
iconAnchor: [0, 0],
}),
}).addTo(state.photoMap);
@@ -1045,7 +1058,6 @@ function updateMeasureLabel() {
const midLat = (p1.y + p2.y) / 2;
const midLng = (p1.x + p2.x) / 2;
const sourceIcon = result.source === 'manual' ? '📏' : '📡';
- const pinButtonHtml = '📌 ';
if (state.measureMode.ui.label) {
state.measureMode.ui.label.remove();
@@ -1054,7 +1066,13 @@ function updateMeasureLabel() {
state.measureMode.ui.label = L.marker(L.latLng(midLat, midLng), {
icon: L.divIcon({
className: 'measure-label',
- html: `${sourceIcon} ${formatDistance(result.meters, state.preferredUnit)}${pinButtonHtml}
`,
+ html: createDistanceLabelHtml({
+ meters: result.meters,
+ color: COLORS.MEASURE,
+ icon: sourceIcon,
+ showPin: true,
+ extraClass: 'distance-label--measure',
+ }),
iconAnchor: [0, 0],
}),
}).addTo(state.photoMap);
@@ -1229,11 +1247,16 @@ function pinCurrentMeasurement() {
// Remove the pin button from the label if it exists
if (pinnedItem.ui.label) {
- const labelHtml = pinnedItem.ui.label.options.icon.options.html;
- const newHtml = labelHtml.replace(//, '');
+ const sourceIcon = pinnedItem.source === 'manual' ? '📏' : '📡';
pinnedItem.ui.label.setIcon(L.divIcon({
className: 'measure-label',
- html: newHtml,
+ html: createDistanceLabelHtml({
+ meters: pinnedItem.meters,
+ color: COLORS.MEASURE,
+ icon: sourceIcon,
+ showPin: false,
+ extraClass: 'distance-label--measure',
+ }),
iconAnchor: [0, 0],
}));
}
From a00221ce37e9695c83d958bf78a94bd9fb47b412 Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Tue, 23 Dec 2025 06:33:44 +0000
Subject: [PATCH 46/47] Replaced the manual template string for newHtml with a
call to createDistanceLabelHtml.
---
src/index.js | 9 +++++++--
1 file changed, 7 insertions(+), 2 deletions(-)
diff --git a/src/index.js b/src/index.js
index 4a5c0c8..a089c1e 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1705,10 +1705,15 @@ function setupEventHandlers() {
state.measureMode.pinned.forEach((item) => {
if (item.ui.label) {
const sourceIcon = item.source === 'manual' ? '📏' : '📡';
- const newHtml = `${sourceIcon} ${formatDistance(item.meters, state.preferredUnit)}
`;
item.ui.label.setIcon(L.divIcon({
className: 'measure-label',
- html: newHtml,
+ html: createDistanceLabelHtml({
+ meters: item.meters,
+ color: COLORS.MEASURE,
+ icon: sourceIcon,
+ showPin: false,
+ extraClass: 'distance-label--measure',
+ }),
iconAnchor: [0, 0],
}));
}
From ce0cf832ee7c15ccf4d999ff72840cda6a638313 Mon Sep 17 00:00:00 2001
From: CsUtil <45512166+cs-util@users.noreply.github.com>
Date: Tue, 23 Dec 2025 06:39:29 +0000
Subject: [PATCH 47/47] Improved Code Coverage:
Achieved 100% Statement, Branch, Function, and Line coverage across all files in the src directory.
Added comprehensive test cases to transformations.test.js to cover:
Degenerate cases in fitHomography.
Singular matrices in invertHomography.
Jacobian calculations for affine and homography transforms.
Unsupported transform types in applyTransform and applyInverseTransform.
Added test cases to calibrator.test.js to cover:
Automatic origin computation in calibrateMap.
Edge cases in evaluateModel (e.g., infinite RMSE).
Defensive branches in accuracyRingRadiusPixels.
Code Refinement:
Simplified a redundant null check in accuracyRingRadiusPixels within calibrator.js to improve branch coverage and code clarity.
---
src/calibration/calibrator.js | 2 +-
src/calibration/calibrator.test.js | 23 ++++++++++
src/geo/transformations.test.js | 73 ++++++++++++++++++++++++++++++
3 files changed, 97 insertions(+), 1 deletion(-)
diff --git a/src/calibration/calibrator.js b/src/calibration/calibrator.js
index 7209bb8..7c54b5d 100644
--- a/src/calibration/calibrator.js
+++ b/src/calibration/calibrator.js
@@ -279,7 +279,7 @@ export function accuracyRingRadiusPixels(calibration, location, gpsAccuracy) {
const ring = computeAccuracyRing(calibration, gpsAccuracy);
return {
...ring,
- pixelRadius: ring ? ring.sigmaTotal / metersPerPixel : null,
+ pixelRadius: ring.sigmaTotal / metersPerPixel,
};
}
diff --git a/src/calibration/calibrator.test.js b/src/calibration/calibrator.test.js
index e61223b..26b618b 100644
--- a/src/calibration/calibrator.test.js
+++ b/src/calibration/calibrator.test.js
@@ -261,6 +261,17 @@ describe('calibrator', () => {
expect(accuracyRingRadiusPixels(singularCalibration, singularLocation, 10)).toBeNull();
});
+ test('calibrateMap computes origin if not provided', () => {
+ const pairs = [
+ { pixel: { x: 0, y: 0 }, wgs84: { lat: 10, lon: 10 } },
+ { pixel: { x: 100, y: 100 }, wgs84: { lat: 10.001, lon: 10.001 } },
+ ];
+ const result = calibrateMap(pairs);
+ expect(result.status).toBe('ok');
+ expect(result.origin).toBeDefined();
+ expect(result.origin.lat).toBeCloseTo(10.0005);
+ });
+
test('internal helpers cover defensive branches', () => {
const {
pickModelKinds,
@@ -297,6 +308,18 @@ describe('calibrator', () => {
const dummyMetrics = evaluateModel('similarity', dummyModel, [singularPair], 5);
expect(dummyMetrics.inlierCount).toBe(1);
+ const infModel = {
+ type: 'homography',
+ matrix: [
+ [1, 0, 0],
+ [0, 1, 0],
+ [1, 0, -1],
+ ],
+ };
+ const infPair = { pixel: { x: 1, y: 0 }, enu: { x: 0, y: 0 } };
+ const infMetrics = evaluateModel('homography', infModel, [infPair], Infinity);
+ expect(infMetrics.rmse).toBe(Number.POSITIVE_INFINITY);
+
let toggle = 0;
const altRandom = () => {
const value = toggle % 2 === 0 ? 0.1 : 0.6;
diff --git a/src/geo/transformations.test.js b/src/geo/transformations.test.js
index ddfd7d9..0679d81 100644
--- a/src/geo/transformations.test.js
+++ b/src/geo/transformations.test.js
@@ -170,13 +170,86 @@ describe('transformations', () => {
],
};
expect(applyTransform(homography, { x: 1, y: 0 })).toBeNull();
+ expect(applyTransform(null, { x: 0, y: 0 })).toBeNull();
expect(() => applyTransform({ type: 'unknown' }, { x: 0, y: 0 })).toThrow('Unsupported transform type');
expect(applyInverseTransform(null, { x: 0, y: 0 })).toBeNull();
+ expect(() => applyInverseTransform({ type: 'unknown' }, { x: 0, y: 0 })).toThrow('Unsupported transform type');
expect(invertAffine({ type: 'affine', matrix: [[1, 2, 0], [2, 4, 0]] })).toBeNull();
+ expect(invertHomography({ type: 'homography', matrix: [[1, 1, 1], [1, 1, 1], [1, 1, 1]] })).toBeNull();
expect(jacobianForTransform({ type: 'unsupported' }, { x: 0, y: 0 })).toBeNull();
expect(averageScaleFromJacobian(null)).toBeNull();
});
+ test('jacobianForTransform covers affine and homography', () => {
+ const affine = {
+ type: 'affine',
+ matrix: [
+ [2, 1, 5],
+ [0.5, 3, -2],
+ ],
+ };
+ const jAffine = jacobianForTransform(affine, { x: 10, y: 20 });
+ expect(jAffine).toEqual([
+ [2, 1],
+ [0.5, 3],
+ ]);
+
+ const homography = {
+ type: 'homography',
+ matrix: [
+ [1, 0, 0],
+ [0, 1, 0],
+ [0, 0, 1],
+ ],
+ };
+ const jHomo = jacobianForTransform(homography, { x: 5, y: 5 });
+ expect(jHomo[0][0]).toBeCloseTo(1);
+ expect(jHomo[1][1]).toBeCloseTo(1);
+
+ const singularHomo = {
+ type: 'homography',
+ matrix: [
+ [1, 0, 0],
+ [0, 1, 0],
+ [1, 0, -10],
+ ],
+ };
+ expect(jacobianForTransform(singularHomo, { x: 10, y: 0 })).toBeNull();
+ });
+
+ test('fitHomography handles degenerate cases', () => {
+ // 4 points on a line
+ const degeneratePairs = [
+ { pixel: { x: 0, y: 0 }, enu: { x: 0, y: 0 } },
+ { pixel: { x: 1, y: 0 }, enu: { x: 1, y: 0 } },
+ { pixel: { x: 2, y: 0 }, enu: { x: 2, y: 0 } },
+ { pixel: { x: 3, y: 0 }, enu: { x: 3, y: 0 } },
+ ];
+ expect(fitHomography(degeneratePairs)).toBeNull();
+ });
+
+ test('applyInverseTransform covers all types', () => {
+ const similarity = {
+ type: 'similarity',
+ scale: 2,
+ cos: 1,
+ sin: 0,
+ rotation: 0,
+ translation: { x: 10, y: 20 },
+ };
+ expect(applyInverseTransform(similarity, { x: 20, y: 30 })).toEqual({ x: 5, y: 5 });
+
+ const homography = {
+ type: 'homography',
+ matrix: [
+ [1, 0, 0],
+ [0, 1, 0],
+ [0, 0, 1],
+ ],
+ };
+ expect(applyInverseTransform(homography, { x: 5, y: 5 })).toEqual({ x: 5, y: 5 });
+ });
+
describe('fitSimilarityFixedScale', () => {
test('preserves exact fixed scale with known transform', () => {
const fixedScale = 5;