Skip to content
Closed

V4 #21

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
f4d9dd6
feat: add initial documentation for manual reference distance feature…
cs-util Dec 21, 2025
986951f
refined the spec further: enhance user experience by adding zoom func…
cs-util Dec 21, 2025
3dc224d
feat: enhance manual reference distance feature with scale stabilizat…
cs-util Dec 21, 2025
34be6b6
feat: add fitSimilarityFixedScale function for fixed scale similarity…
cs-util Dec 21, 2025
2ac9f13
feat: implement fixed scale similarity transformation and add corresp…
cs-util Dec 21, 2025
99a2823
feat: document implementation progress for manual reference distance …
cs-util Dec 21, 2025
922e1f4
feat: simplify error handling in fetch event and geolocation prompt
cs-util Dec 21, 2025
9272d65
refactor to extract the shared centroid computation into a helper fun…
cs-util Dec 21, 2025
134e175
feat: refactor fitSimilarityFixedScale to use computeWeightedCentroid…
cs-util Dec 21, 2025
b248c12
feat: improve user instructions for setting reference distances and m…
cs-util Dec 21, 2025
d325a8a
feat: enhance rotation computation test for fitSimilarityFixedScale w…
cs-util Dec 21, 2025
b979daa
feat: expand unit tests for fitSimilarityFixedScale to document geome…
cs-util Dec 21, 2025
f62b458
feat: update reference distance calculations to use metersPerPixel fo…
cs-util Dec 21, 2025
a0fc40e
feat: update fitSimilarityFixedScale to handle negative fixedScale va…
cs-util Dec 21, 2025
808b136
feat: handle degenerate cases in fitSimilarityFixedScale by returning…
cs-util Dec 21, 2025
46497dc
feat: update documentation to clarify handling of degenerate coincide…
cs-util Dec 21, 2025
8cc2bce
feat: enhance measurement mode to prioritize manual reference distanc…
cs-util Dec 21, 2025
2c6c538
feat: enhance reference distance feature with scale source indicators…
cs-util Dec 21, 2025
9642113
feat: implement scale management utilities and extend application sta…
cs-util Dec 21, 2025
3c22454
feat: add scale and measure mode buttons with functionality for setti…
cs-util Dec 21, 2025
80d3fad
feat: extract scale and measure mode logic into dedicated state machi…
cs-util Dec 21, 2025
437aeac
feat: complete UI layer integration for reference distance feature wi…
cs-util Dec 21, 2025
047446c
feat: replaced the native prompt() with a custom modal dialog - add d…
cs-util Dec 21, 2025
50f005d
feat: refactoring by adding CSS classes to the HTML - add custom styl…
cs-util Dec 21, 2025
6e5315d
feat: refactor measure mode drag handling - extract drag event logic …
cs-util Dec 21, 2025
8c0efca
feat: remove unused API exports from scale and scale-mode modules for…
cs-util Dec 21, 2025
4e0f115
feat: remove 'Ft & In' option from distance selection for simplified …
cs-util Dec 21, 2025
7caf5fa
feat: streamline distance input handling - remove validation logic an…
cs-util Dec 21, 2025
bb758af
feat: implement convertToMeters function - add distance conversion ut…
cs-util Dec 22, 2025
8f84fdc
feat: remove Escape key handling from distance input modal for stream…
cs-util Dec 22, 2025
f402fbf
feat: refactor geolocation handling and improve user marker accuracy …
cs-util Dec 22, 2025
646e385
feat: enhance service worker fetch handling and add calibration readi…
cs-util Dec 22, 2025
0a1418b
feat: enhance distance input validation to support unit conversion
cs-util Dec 22, 2025
e649d37
feat: improve response handling in fetch navigation logic
cs-util Dec 22, 2025
63c4945
feat: adjust distance label positioning for improved visibility
cs-util Dec 22, 2025
f16b3ec
State Initialization: Updated the state object to use the new nested …
cs-util Dec 22, 2025
e225a41
feat: refactor color management by introducing COLORS object for cons…
cs-util Dec 22, 2025
7024bf0
feat: complete Phase 3 with persistence, scale validation, and measur…
cs-util Dec 22, 2025
81cdbba
feat: enhance computeReferenceScale validation and add property-based…
cs-util Dec 22, 2025
41465b7
feat: add scale and measure UI integration tests with persistence and…
cs-util Dec 22, 2025
b4d3da2
feat: add property-based tests for scale module and update existing t…
cs-util Dec 22, 2025
b6a8554
refactor: streamline module loading by consolidating setup functions
cs-util Dec 22, 2025
62f8572
updated the test case in index.scale.test.js:243-252 to set an initia…
cs-util Dec 22, 2025
55dfaeb
Updated Event Listener: Modified the change event handler for globalU…
cs-util Dec 22, 2025
3f3de48
refactored the code to address these issues by:
cs-util Dec 22, 2025
a00221c
Replaced the manual template string for newHtml with a call to create…
cs-util Dec 23, 2025
ce0cf83
Improved Code Coverage:
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
427 changes: 427 additions & 0 deletions docs/feat-reference-distances.md

Large diffs are not rendered by default.

81 changes: 81 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,31 @@
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha384-sHL9NAb7lN7rfvG5lfHpm643Xkcjzp4jFvuavGOndn6pjVqS6ny56CAt3nsEVT4H" crossorigin="anonymous">
<link rel="stylesheet" href="https://unpkg.com/leaflet.locatecontrol/dist/L.Control.Locate.min.css" crossorigin="anonymous">
<script src="https://cdn.tailwindcss.com"></script>
<style>
/* Custom marker and label styles for scale/measure features */
.scale-marker-dot {
width: 14px;
height: 14px;
border: 2px solid white;
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.distance-label {
color: white;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
transform: translate(-50%, -100%);
margin-top: -4px;
}
.distance-label--measure {
padding: 3px 10px;
font-size: 13px;
}
</style>
</head>
<body class="bg-slate-950 text-slate-100 min-h-screen">
<div class="min-h-screen flex flex-col lg:items-stretch">
Expand All @@ -18,6 +43,14 @@ <h1 class="text-4xl font-black tracking-tight">Snap2Map</h1>
<p class="text-slate-300 text-sm leading-relaxed lg:text-base">
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.
</p>
<div class="flex items-center gap-3 pt-2">
<label for="globalUnitSelect" class="text-xs font-semibold uppercase tracking-wider text-slate-500">Display Unit</label>
<select id="globalUnitSelect" class="bg-slate-900 border border-slate-700 rounded-lg px-3 py-1.5 text-xs font-semibold text-slate-300 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all">
<option value="m">Meters (m)</option>
<option value="ft">Feet (ft)</option>
<option value="ft-in">Feet & Inches (ft-in)</option>
</select>
</div>
</header>
</div>
</section>
Expand All @@ -42,6 +75,10 @@ <h1 class="text-4xl font-black tracking-tight">Snap2Map</h1>
<button id="usePositionButton" class="px-4 py-2 rounded-lg bg-emerald-600 text-white text-sm font-semibold hover:bg-emerald-500 transition">Use my position</button>
<button id="confirmPairButton" class="px-4 py-2 rounded-lg bg-violet-600 text-white text-sm font-semibold opacity-80 disabled:opacity-40 disabled:cursor-not-allowed hover:bg-violet-500 transition" disabled>Confirm pair</button>
<button id="cancelPairButton" class="px-4 py-2 rounded-lg bg-slate-700 text-white text-sm font-semibold opacity-80 disabled:opacity-40 disabled:cursor-not-allowed hover:bg-slate-600 transition" disabled>Cancel</button>
<span class="hidden sm:inline-block w-px h-6 bg-slate-600 self-center"></span>
<button id="setScaleButton" class="px-4 py-2 rounded-lg bg-teal-600 text-white text-sm font-semibold hover:bg-teal-500 transition" title="Set a known distance on the photo to define the scale">📏 Set Scale</button>
<button id="measureButton" class="px-4 py-2 rounded-lg bg-purple-600 text-white text-sm font-semibold hover:bg-purple-500 disabled:opacity-40 disabled:cursor-not-allowed transition" disabled title="Measure distances on the photo">📐 Measure</button>
<button id="clearMeasurementsButton" class="hidden px-4 py-2 rounded-lg bg-slate-700 text-white text-sm font-semibold hover:bg-slate-600 transition" title="Clear all measurements">🗑️ Clear All</button>
</div>
</div>
</div>
Expand Down Expand Up @@ -83,6 +120,7 @@ <h2 class="text-2xl font-semibold text-slate-100">Import map photo</h2>
</div>
<div class="text-sm text-blue-200" id="accuracyDetails"></div>
<div class="text-sm text-slate-200" id="gpsStatus">Import a map photo to get started.</div>
<div id="scaleWarning" class="text-sm text-amber-400 hidden"></div>
</div>
</div>
</section>
Expand Down Expand Up @@ -113,6 +151,49 @@ <h2 class="text-lg font-semibold text-slate-100">Reference pairs</h2>

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

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

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

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

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

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

if (cached) {
return cached;
}

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

return Response.error();
}

self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') {
return;
}

const url = new URL(event.request.url);
const isSameOrigin = url.origin === self.location.origin;
const isNavigation = event.request.mode === 'navigate';
const isShellResource = isSameOrigin && SHELL_ASSETS.includes(url.pathname);

if (!isSameOrigin) {
return;
}

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

event.respondWith(
caches.open(CACHE_NAME).then(async (cache) => {
const cached = await cache.match(event.request);

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

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

if (isNavigation || isShellResource) {
try {
const response = await fetchAndUpdate();
if (response) {
return response;
}
} catch (error) {
// 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();
} catch (error) {
if (cached) {
return cached;
}
return Response.error();
return await fetchAndUpdate(event.request, cache);
} catch {
return cached || Response.error();
}
}),
);
Expand Down
21 changes: 13 additions & 8 deletions src/calibration/calibrator.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { computeOrigin, wgs84ToEnu } from '../geo/coordinate.js';
import {
fitSimilarity,
fitSimilarityFixedScale,
fitAffine,
fitHomography,
applyTransform,
Expand Down Expand Up @@ -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);
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -274,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,
};
}

Expand Down
Loading