Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a761f96
Add feature specification for Instant Usage with 0-1 Point Calibration
cs-util Dec 23, 2025
41d5131
Update feature specification for Instant Usage: refine assumptions an…
cs-util Dec 23, 2025
40fd8d5
Enhance UI for Instant Usage: add prompts for scale setting and one-t…
cs-util Dec 23, 2025
3b85d69
Refine 1-Point Calibration Logic: clarify scale requirements for live…
cs-util Dec 23, 2025
fa18190
Implement 1-point calibration support and enhance UI prompts for inst…
cs-util Dec 23, 2025
aa97551
Enhance instant usage prompts: show/hide based on calibration state a…
cs-util Dec 23, 2025
4b00930
Refactor one-tap mode cancellation function and update related test c…
cs-util Dec 23, 2025
eb917f2
Add debug logs for instant usage prompts visibility check
cs-util Dec 23, 2025
7076c8d
Update instant usage prompts visibility logic to show only when no sc…
cs-util Dec 23, 2025
94011cc
Remove debug log for instant usage prompts visibility and add test lo…
cs-util Dec 23, 2025
c9f0d14
Show/hide instant usage prompts based on scale and pairs state in loa…
cs-util Dec 23, 2025
e6e636b
Refactor instant usage prompts visibility logic in loadPhotoMap funct…
cs-util Dec 23, 2025
625f13d
Update instant usage prompts visibility check in scale test; ensure t…
cs-util Dec 23, 2025
18b03d2
Refactor instant usage prompts visibility check in loadPhotoMap test;…
cs-util Dec 23, 2025
c6d2d14
Remove unused handleOneTapClick variable from Scale and Measure UI in…
cs-util Dec 23, 2025
0789cd9
Add calibrate1Point function for 1-point calibration logic; refactor …
cs-util Dec 23, 2025
0906847
Refactor calibration status updates; separate logic for no calibratio…
cs-util Dec 23, 2025
055fc1e
Refactor global unit change handling and modal event setup; improve c…
cs-util Dec 23, 2025
db25863
Add development progress notes for Instant Usage feature; document ph…
cs-util Dec 23, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions docs/feat-instant-usage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Feature Specification: Instant Usage (0-1 Point Calibration)

## 1. Overview
The "Instant Usage" feature aims to provide immediate value to the user after importing a map, even before they have provided the minimum required two GPS reference points. By making sensible assumptions (perfect top down view and North is up) and only asking the user to provide a reference scale, the app can enable the measurement tool and live position tracking earlier in the workflow.

## 2. Functional Requirements

### 2.1 Zero GPS Points
* **Measurement Tool**: Enabled if a manual `referenceDistance` (Set Scale) has been provided.
* **Live Position**: Disabled.

### 2.2 One GPS Point
* **Measurement Tool**: Enabled if a manual `referenceDistance` has been provided.
* **Live Position**: **Enabled** if a manual `referenceDistance` has been provided, using a fallback calibration.
* **Fallback Calibration (1-Point Similarity)**:
* **Translation**: Fixed by the single GPS reference point.
* **Rotation**: Assumed to be **0° (North is Up)**.
* **Scale**: Use `referenceDistance.metersPerPixel`.
* **User Feedback**: Display a banner/toast: *"Using 1-point calibration (North-up). Add a second point to fix orientation and scale."*

### 2.3 Two+ GPS Points
* **Standard Calibration**: Use the existing robust Similarity/Affine/Homography pipeline.
* **Scale Harmonization**: If a manual `referenceDistance` is provided, it can be used to constrain the Similarity fit (Phase 2).

### 2.4 Quick Start Flow
1. **Import Map**: User selects an image (of a map or floorplan).
2. Ask the user to provide a reference scale so that once he did provide that reference scale he can measure any distances on the image
3. **Initial Suggestion**: App asks: "Are you on currently on this map? If yes where".
1. **One-Tap Calibration**: If user says yes ask him to tap their current location on the photo.
Comment on lines +27 to +29

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This section of the 'Quick Start Flow' has a few areas that could be improved for clarity and professionalism:

  • Line 27: The sentence is a bit repetitive. It could be more concise.
  • Line 28: There's a typo in "Are you on currently on this map?".
  • Line 29: Using gender-neutral language (them instead of him) is a best practice.
Suggested change
2. Ask the user to provide a reference scale so that once he did provide that reference scale he can measure any distances on the image
3. **Initial Suggestion**: App asks: "Are you on currently on this map? If yes where".
1. **One-Tap Calibration**: If user says yes ask him to tap their current location on the photo.
2. Ask the user to provide a reference scale, which will enable distance measurement on the image.
3. **Initial Suggestion**: App asks: "Are you currently on this map? If so, where?".
1. **One-Tap Calibration**: If the user says yes, ask them to tap their current location on the photo.

2. **Immediate Live View**: The app immediately starts showing the live GPS position on the photo using the 1-point fallback.
3. **Refinement**: As the user moves, they can see if the dot follows the map. If not, they add a second point to "pin" the scale and rotation.


## 3. Technical Implementation

### 3.1 Math Layer (`src/geo/transformations.js`)
Implement `fitSimilarity1Point(pair, scale, rotation)` which returns a standard transform object.

```javascript
/**
* Creates a similarity transform from a single point, scale, and rotation.
* @param {Object} pair - { pixel: {x, y}, enu: {x, y} }
* @param {number} scale - Meters per pixel
* @param {number} [rotation=0] - Rotation in radians (0 = North is Up)
*/
export function fitSimilarity1Point(pair, scale, rotation = 0) {
const cos = Math.cos(rotation);
const sin = Math.sin(rotation);

// Matrix: [m00, m01, m10, m11, tx, ty]
// enu.x = m00 * px + m10 * py + tx
// enu.y = m01 * px + m11 * py + ty
const m00 = scale * cos;
const m01 = scale * sin;
const m10 = -scale * sin;
const m11 = scale * cos;

const tx = pair.enu.x - (m00 * pair.pixel.x + m10 * pair.pixel.y);
const ty = pair.enu.y - (m01 * pair.pixel.x + m11 * pair.pixel.y);

return {
matrix: [m00, m01, m10, m11, tx, ty],
kind: 'similarity',
rmse: 0,
maxResidual: 0,
inliers: [pair],
};
}
```

### 3.2 Calibration Layer (`src/calibration/calibrator.js`)
Update `calibrateMap` to handle the 1-point case.

```javascript
function calibrateMap(pairs, userOptions = {}) {
// ...
if (pairs.length === 1) {
const scale = userOptions.referenceScale;
if (!scale) return null; // Cannot calibrate 1-point without scale
const rotation = userOptions.defaultRotation || 0;
const model = fitSimilarity1Point(enrichedPairs[0], scale, rotation);
return { ...model, origin, pairs: enrichedPairs };
}
// ...
}
```
Comment on lines +39 to +86

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The code snippets for fitSimilarity1Point and calibrateMap in this specification document are inconsistent with the actual implementations in the source code (src/geo/transformations.js and src/calibration/calibrator.js).

  • The fitSimilarity1Point snippet returns a different object structure than the implementation.
  • The calibrateMap snippet is a simplified version and doesn't show the use of the calibrate1Point helper function.

To avoid confusion for future developers, it would be beneficial to update these snippets to accurately reflect the implemented code.


### 3.3 UI Layer (`src/index.js`)
* **Post-Import Prompt**: Immediately after map import (no scale is set yet), display a prominent "Set Scale" button.
* **Location Prompt**: In parallel show a button *"Are you currently on this map?"*
* If Clicked, enter **One-Tap Calibration** mode.
* **One-Tap Calibration UI**:
* When active, the next tap on the photo captures the current GPS position (waiting for accuracy if needed) and creates a single reference pair.
* Automatically trigger `calibrateMap()` and enable "Live" mode.
* **Fallback Indicator**:
* When `state.pairs.length === 1`, show a status badge: "1-Point Calibration (North-up)".
* **Live Button**: Enable when `state.pairs.length >= 1` AND a manual scale is set.
* **Settings**: Add a "Default Rotation" (default: 0°).

## 4. Edge Cases & Considerations
* **Unstable 2-Point Fit**: If two GPS points are extremely close together, the rotation becomes numerically unstable. In this case, the system should either warn the user or offer to stick to the "North-up" assumption.
* **Missing Scale**: If the user provides 1 GPS point but hasn't set a scale yet, the "Live" mode remains disabled until the scale is defined (either because the user later decided to set the reference scale or because he decided to set a second gps reference point).

## 5. Development Progress Notes

### Phase 1: Math & Logic (Completed)
- **1-Point Similarity**: Implemented `fitSimilarity1Point` in [src/geo/transformations.js](src/geo/transformations.js). It creates a transform from a single point, a fixed scale, and an assumed 0° rotation.
- **Fixed-Scale Similarity**: Implemented `fitSimilarityFixedScale` to allow 2+ point calibration while constraining the scale to a manual reference value.
- **Calibrator Update**: Updated `calibrateMap` in [src/calibration/calibrator.js](src/calibration/calibrator.js) to support the 1-point fallback when `referenceScale` is provided.

### Phase 2: UI Integration (Completed)
- **Quick Start Prompts**: Added a dedicated prompt area in [index.html](index.html) that appears immediately after map import.
- **One-Tap Calibration**: Implemented `startOneTapMode` and `handleOneTapClick` in [src/index.js](src/index.js). This allows users to "pin" their current GPS location to a spot on the photo with a single tap.
- **Scale & Measure Modes**: Integrated the state machine from [src/scale/scale-mode.js](src/scale/scale-mode.js) to handle "Set Scale" and "Measure" interactions.
- **Persistence**: Manual scale and preferred units are now persisted in `localStorage`.

### Phase 3: Quality & Validation (Completed)
- **Unit Tests**: Added comprehensive tests for 1-point and fixed-scale transformations in [src/geo/transformations.test.js](src/geo/transformations.test.js).
- **Integration Tests**: Created [src/index.scale.test.js](src/index.scale.test.js) to verify the end-to-end "Instant Usage" flow, including prompt visibility and one-tap calibration.
- **Complexity Management**: Refactored `updateStatusText`, `recalculateCalibration`, and `setupEventHandlers` in [src/index.js](src/index.js) to keep cyclomatic complexity within limits (<= 10).
- **Coverage**: Maintained >99% statement coverage across all modified files.
- **Validation**: Verified all quality checks (`lint`, `check:dup`, `check:cycles`, `check:boundaries`) pass successfully.
7 changes: 6 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,12 @@ <h1 class="text-4xl font-black tracking-tight">Snap2Map</h1>
Tap “Start pair” and select a pixel on the photo followed by its real-world location on the map.
</p>
</div>
<div class="flex flex-wrap gap-2">
<div id="instantUsagePrompts" class="hidden flex flex-wrap gap-3 p-3 bg-blue-600/10 border border-blue-500/30 rounded-xl">
<div class="w-full text-xs font-bold uppercase tracking-widest text-blue-400 mb-1">Quick Start</div>
<button id="instantSetScaleButton" class="flex-1 px-4 py-2 rounded-lg bg-teal-600 text-white text-sm font-bold hover:bg-teal-500 transition shadow-lg">📏 Set Scale</button>
<button id="oneTapCalibrateButton" class="flex-1 px-4 py-2 rounded-lg bg-emerald-600 text-white text-sm font-bold hover:bg-emerald-500 transition shadow-lg">📍 I am here</button>
</div>
<div class="flex flex-wrap gap-2">
<label id="replacePhotoButton" for="mapImageInput" class="hidden cursor-pointer px-4 py-2 rounded-lg bg-slate-700 text-white text-sm font-semibold hover:bg-slate-600 transition">Replace photo</label>
<button id="addPairButton" class="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-semibold hover:bg-blue-500 transition">Start pair</button>
<button id="usePositionButton" class="px-4 py-2 rounded-lg bg-emerald-600 text-white text-sm font-semibold hover:bg-emerald-500 transition">Use my position</button>
Expand Down
45 changes: 43 additions & 2 deletions src/calibration/calibrator.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { computeOrigin, wgs84ToEnu } from '../geo/coordinate.js';
import {
fitSimilarity,
fitSimilarityFixedScale,
fitSimilarity1Point,
fitAffine,
fitHomography,
applyTransform,
Expand Down Expand Up @@ -215,11 +216,47 @@ function pixelFromLocation(calibration, location) {
return pixel || null;
}

function calibrate1Point(enrichedPairs, origin, referenceScale, userOptions) {
if (!referenceScale) {
return {
status: 'insufficient-pairs',
message: 'A reference scale is required for 1-point calibration.',
};
}
const rotation = userOptions.defaultRotation || 0;
const model = fitSimilarity1Point(enrichedPairs[0], referenceScale, rotation);
if (!model) {
return {
status: 'fit-failed',
message: '1-point calibration failed.',
};
}
return {
status: 'ok',
origin,
kind: 'similarity',
model,
metrics: {
rmse: 0,
maxResidual: 0,
inliers: enrichedPairs,
residuals: [0],
},
quality: {
rmse: 0,
maxResidual: 0,
},
statusMessage: { level: 'low', message: '1-point calibration (North-up). Add a second point to fix orientation and scale.' },
residuals: [0],
inliers: enrichedPairs,
};
}

export function calibrateMap(pairs, userOptions = {}) {
if (!pairs || pairs.length < 2) {
if (!pairs || pairs.length < 1) {
return {
status: 'insufficient-pairs',
message: 'At least two reference pairs are required to calibrate the map.',
message: 'At least one reference pair is required to calibrate the map.',
};
}

Expand All @@ -228,6 +265,10 @@ export function calibrateMap(pairs, userOptions = {}) {
const enrichedPairs = createEnrichedPairs(pairs, origin);
const referenceScale = userOptions.referenceScale;

if (enrichedPairs.length === 1) {
return calibrate1Point(enrichedPairs, origin, referenceScale, userOptions);
}

const modelKinds = pickModelKinds(enrichedPairs.length);

for (let i = 0; i < modelKinds.length; i += 1) {
Expand Down
20 changes: 18 additions & 2 deletions src/calibration/calibrator.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,24 @@ describe('calibrator', () => {
expect(ring.pixelRadius).toBeLessThan(1000);
});

test('calibrateMap handles insufficient pairs', () => {
const result = calibrateMap([{ pixel: { x: 0, y: 0 }, wgs84: origin }]);
test('calibrateMap supports 1-point calibration with referenceScale', () => {
const pair = { pixel: { x: 100, y: 100 }, wgs84: { lat: 40, lon: -105 } };
const referenceScale = 0.5;
const result = calibrateMap([pair], { referenceScale });

expect(result.status).toBe('ok');
expect(result.kind).toBe('similarity');
expect(result.model.scale).toBe(referenceScale);
expect(result.statusMessage.message).toContain('1-point calibration');

const projected = projectLocationToPixel(result, pair.wgs84);
expect(projected.x).toBeCloseTo(pair.pixel.x);
expect(projected.y).toBeCloseTo(pair.pixel.y);
});

test('calibrateMap fails 1-point calibration without referenceScale', () => {
const pair = { pixel: { x: 100, y: 100 }, wgs84: { lat: 40, lon: -105 } };
const result = calibrateMap([pair]);
expect(result.status).toBe('insufficient-pairs');
});

Expand Down
22 changes: 22 additions & 0 deletions src/geo/transformations.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,27 @@ function computeWeightedCentroids(pairs, weights) {
};
}

export function fitSimilarity1Point(pair, scale, rotation = 0) {
if (!pair || !pair.pixel || !pair.enu || !Number.isFinite(scale) || scale <= 0) {
return null;
}

const cos = Math.cos(rotation);
const sin = Math.sin(rotation);

const tx = pair.enu.x - scale * (cos * pair.pixel.x - sin * pair.pixel.y);
const ty = pair.enu.y - scale * (sin * pair.pixel.x + cos * pair.pixel.y);

return {
type: 'similarity',
scale,
rotation,
cos,
sin,
translation: { x: tx, y: ty },
};
}

export function fitSimilarity(pairs, weights) {
if (pairs.length < 2) {
return null;
Expand Down Expand Up @@ -529,6 +550,7 @@ const api = {
TOLERANCE,
fitSimilarity,
fitSimilarityFixedScale,
fitSimilarity1Point,
fitAffine,
fitHomography,
applyTransform,
Expand Down
24 changes: 24 additions & 0 deletions src/geo/transformations.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
fitSimilarity,
fitSimilarityFixedScale,
fitSimilarity1Point,
fitAffine,
fitHomography,
applyTransform,
Expand Down Expand Up @@ -105,6 +106,29 @@ describe('transformations', () => {
expect(roundTrip.y).toBeCloseTo(point.y, 5);
});

test('fitSimilarity1Point creates valid transform from single point', () => {
const pair = { pixel: { x: 10, y: 20 }, enu: { x: 100, y: 200 } };
const scale = 2.5;
const rotation = Math.PI / 4; // 45 degrees
const transform = fitSimilarity1Point(pair, scale, rotation);

expect(transform.type).toBe('similarity');
expect(transform.scale).toBe(scale);
expect(transform.rotation).toBe(rotation);

const projected = applyTransform(transform, pair.pixel);
expect(projected.x).toBeCloseTo(pair.enu.x);
expect(projected.y).toBeCloseTo(pair.enu.y);
});

test('fitSimilarity1Point returns null for invalid inputs', () => {
expect(fitSimilarity1Point(null, 1, 0)).toBeNull();
expect(fitSimilarity1Point({ pixel: { x: 0, y: 0 } }, 1, 0)).toBeNull();
expect(fitSimilarity1Point({ pixel: { x: 0, y: 0 }, enu: { x: 0, y: 0 } }, -1, 0)).toBeNull();
expect(fitSimilarity1Point({ pixel: { x: 0, y: 0 }, enu: { x: 0, y: 0 } }, 0, 0)).toBeNull();
expect(fitSimilarity1Point({ pixel: { x: 0, y: 0 }, enu: { x: 0, y: 0 } }, NaN, 0)).toBeNull();
});

test('invertSimilarity and invertAffine expose consistent parameters', () => {
const similarity = {
type: 'similarity',
Expand Down
Loading