Skip to content

Commit c0b784c

Browse files
authored
Merge pull request #6 from cs-util-com/feat/mobileUx
Feat/mobile ux
2 parents 33325a3 + 656f9c8 commit c0b784c

File tree

5 files changed

+251
-90
lines changed

5 files changed

+251
-90
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,13 +67,14 @@
6767

6868
* On wake: **Quick refresh** → try `getCurrentPosition` (timeout **3 s**); if none, show **last known** (≤5 min, “stale” badge). Start `watchPosition` thereafter.
6969
* Update cadence follows device feed; UI throttles to \~**3–5 s**.
70+
* First GPS fix recenters/zooms the photo and OSM views to the user; later updates keep the camera where the user last left it while markers/accuracy rings continue to update.
7071
* **Accuracy ring** around user dot (see §7).
7172

7273
---
7374

7475
## 4) Image handling
7576

76-
* **Import**: Camera (`<input type="file" accept="image/*" capture="environment">`) or gallery.
77+
* **Import**: Native file picker (`<input type="file" accept="image/*">`) lets mobile users capture a new photo or pull one from their gallery without leaving the flow.
7778
* **Orientation**: Apply EXIF orientation, then **strip EXIF** (privacy).
7879
* **Storage**: **Only optimized display** version, long edge ≈ **4096 px**, **WebP/AVIF** (JPEG fallback), sRGB.
7980
* **Max per map**: 25–50 MB (config). Oversize → auto-downscale + toast.

index.html

Lines changed: 92 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -10,81 +10,105 @@
1010
<script src="https://cdn.tailwindcss.com"></script>
1111
</head>
1212
<body class="bg-slate-950 text-slate-100 min-h-screen">
13-
<div class="max-w-6xl mx-auto px-4 py-10 space-y-8">
14-
<header class="space-y-4">
15-
<h1 class="text-4xl font-black tracking-tight">Snap2Map</h1>
16-
<p class="text-slate-300 max-w-3xl">
17-
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.
18-
</p>
19-
</header>
20-
21-
<section class="bg-slate-900/70 border border-slate-700 rounded-xl p-6 space-y-4">
22-
<h2 class="text-xl font-semibold text-slate-100">1. Import map photo</h2>
23-
<p class="text-sm text-slate-300">Use a sharp, well-lit image. Snap2Map keeps only an optimized copy on device.</p>
24-
<label class="block w-full">
25-
<span class="sr-only">Upload map image</span>
26-
<input id="mapImageInput" type="file" accept="image/*" capture="environment" class="block w-full text-sm text-slate-200 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-600 file:text-white hover:file:bg-blue-500">
27-
</label>
28-
</section>
29-
30-
<section class="bg-slate-900/70 border border-slate-700 rounded-xl p-6 space-y-4">
31-
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
32-
<div>
33-
<h2 class="text-xl font-semibold text-slate-100">2. Create reference pairs</h2>
34-
<p id="pairStatus" class="text-sm text-slate-300">Tap “Start pair” and select a pixel on the photo followed by its real-world location on the map.</p>
35-
</div>
36-
<div class="flex flex-wrap gap-2">
37-
<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>
38-
<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>
39-
<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>
40-
<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>
41-
</div>
13+
<div class="min-h-screen flex flex-col lg:items-stretch">
14+
<section class="w-full border-b border-slate-800 bg-slate-950/70 backdrop-blur-sm">
15+
<div class="px-4 py-6 sm:px-6 lg:px-8">
16+
<header class="space-y-3">
17+
<h1 class="text-4xl font-black tracking-tight">Snap2Map</h1>
18+
<p class="text-slate-300 text-sm leading-relaxed lg:text-base">
19+
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.
20+
</p>
21+
</header>
4222
</div>
23+
</section>
4324

44-
<div class="bg-slate-950/60 border border-slate-700 rounded-lg">
45-
<div class="flex">
46-
<button id="photoTabButton" class="flex-1 px-4 py-2 text-sm font-semibold bg-blue-600 text-white rounded-tl-lg">Photo</button>
47-
<button id="osmTabButton" class="flex-1 px-4 py-2 text-sm font-semibold bg-white/10 text-blue-300 rounded-tr-lg">OpenStreetMap</button>
48-
</div>
49-
<div class="p-3">
50-
<div id="photoView" class="rounded-lg overflow-hidden border border-slate-800">
51-
<div id="photoMap" class="h-96"></div>
25+
<main class="flex-1 flex flex-col bg-slate-950 min-h-0">
26+
<section class="flex-1 flex flex-col px-4 py-6 sm:px-6 lg:px-10">
27+
<div class="flex-1 flex flex-col bg-slate-900/70 border border-slate-700 rounded-2xl shadow-xl overflow-hidden">
28+
<div class="border-b border-slate-800 p-4 sm:p-6 space-y-4">
29+
<div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
30+
<div class="space-y-2">
31+
<div class="flex items-center gap-2 text-xs uppercase tracking-wide text-slate-400">
32+
<span class="inline-flex items-center gap-1 rounded-full bg-blue-600/20 px-3 py-1 font-semibold text-blue-200">Step 2</span>
33+
<span class="font-semibold text-slate-200">Create reference pairs</span>
34+
</div>
35+
<p id="pairStatus" class="text-sm text-slate-300 max-w-2xl">
36+
Tap “Start pair” and select a pixel on the photo followed by its real-world location on the map.
37+
</p>
38+
</div>
39+
<div class="flex flex-wrap gap-2">
40+
<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>
41+
<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>
42+
<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>
43+
<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>
44+
<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>
45+
</div>
46+
</div>
5247
</div>
53-
<div id="osmView" class="hidden rounded-lg overflow-hidden border border-slate-800">
54-
<div id="osmMap" class="h-96"></div>
48+
<div class="flex-1 flex flex-col">
49+
<div class="flex border-b border-slate-800">
50+
<button id="photoTabButton" class="flex-1 px-4 py-2 text-sm font-semibold bg-blue-600 text-white">Photo</button>
51+
<button id="osmTabButton" class="flex-1 px-4 py-2 text-sm font-semibold bg-white/10 text-blue-300">OpenStreetMap</button>
52+
</div>
53+
<div class="flex-1 p-3 sm:p-4 lg:p-6 flex flex-col">
54+
<div id="photoView" class="relative flex-1 rounded-xl overflow-hidden border border-slate-800 bg-slate-950/60">
55+
<div id="photoPlaceholder" class="absolute inset-0 flex items-center justify-center p-6 sm:p-10">
56+
<div class="w-full max-w-xl space-y-5 rounded-2xl border border-slate-800 bg-slate-900/80 p-6 shadow-lg backdrop-blur">
57+
<div class="space-y-2">
58+
<span class="inline-flex items-center gap-2 rounded-full bg-blue-600/10 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-blue-200">Step 1</span>
59+
<h2 class="text-2xl font-semibold text-slate-100">Import map photo</h2>
60+
</div>
61+
<p class="text-sm text-slate-300">Choose a sharp, well-lit image of your trailboard or printed map. Snap2Map keeps only an optimized copy on your device.</p>
62+
<label class="block w-full">
63+
<span class="sr-only">Upload map image</span>
64+
<input id="mapImageInput" type="file" accept="image/*" class="block w-full text-sm text-slate-200 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-600 file:text-white hover:file:bg-blue-500">
65+
</label>
66+
<p class="text-xs text-slate-400">Tip: Mobile browsers let you take a new photo or pick one from your gallery when you tap the button above.</p>
67+
</div>
68+
</div>
69+
<div id="photoMap" class="h-full w-full min-h-[55vh] sm:min-h-[60vh] lg:min-h-[65vh]"></div>
70+
</div>
71+
<div id="osmView" class="hidden flex-1 rounded-xl overflow-hidden border border-slate-800 min-h-[55vh] sm:min-h-[60vh] lg:min-h-[65vh]">
72+
<div id="osmMap" class="h-full w-full min-h-[55vh] sm:min-h-[60vh] lg:min-h-[65vh]"></div>
73+
</div>
74+
</div>
75+
</div>
76+
<div class="border-t border-slate-800 bg-slate-900/80 p-4 sm:p-5 space-y-3">
77+
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
78+
<div class="flex items-center gap-2 text-sm">
79+
<span id="calibrationBadge" class="px-2 py-1 rounded text-xs font-semibold bg-gray-200 text-gray-700">No calibration</span>
80+
<span id="calibrationStatus" class="text-slate-200">Add at least two reference pairs to calibrate the photo.</span>
81+
</div>
82+
<div class="text-sm text-slate-300" id="residualSummary"></div>
83+
</div>
84+
<div class="text-sm text-blue-200" id="accuracyDetails"></div>
85+
<div class="text-sm text-slate-200" id="gpsStatus">Import a map photo to get started.</div>
5586
</div>
5687
</div>
57-
</div>
58-
</section>
88+
</section>
5989

60-
<section class="bg-slate-900/70 border border-slate-700 rounded-xl p-6 space-y-4">
61-
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
62-
<div class="flex items-center gap-2 text-sm">
63-
<span id="calibrationBadge" class="px-2 py-1 rounded text-xs font-semibold bg-gray-200 text-gray-700">No calibration</span>
64-
<span id="calibrationStatus" class="text-slate-200">Add at least two reference pairs to calibrate the photo.</span>
90+
<section class="px-4 pb-10 sm:px-6 lg:px-10">
91+
<div class="bg-slate-900/60 border border-slate-700 rounded-2xl shadow-lg overflow-hidden">
92+
<div class="flex items-center justify-between px-4 py-4 sm:px-6 sm:py-5">
93+
<h2 class="text-lg font-semibold text-slate-100">Reference pairs</h2>
94+
<span class="text-xs uppercase tracking-wide text-slate-500 hidden sm:block">Manage or remove points below</span>
95+
</div>
96+
<div class="border-t border-slate-800 max-h-80 overflow-y-auto">
97+
<table class="min-w-full text-left" id="pairTable">
98+
<thead class="text-xs uppercase tracking-wider text-slate-400 border-b border-slate-800 bg-slate-900/70">
99+
<tr>
100+
<th class="px-4 py-3">Pixel (x, y)</th>
101+
<th class="px-4 py-3">World (lat, lon)</th>
102+
<th class="px-4 py-3">Residual</th>
103+
<th class="px-4 py-3 text-right">Actions</th>
104+
</tr>
105+
</thead>
106+
<tbody id="pairTableBody" class="divide-y divide-slate-800/70"></tbody>
107+
</table>
108+
</div>
65109
</div>
66-
<div class="text-sm text-slate-300" id="residualSummary"></div>
67-
</div>
68-
<div id="accuracyDetails" class="text-sm text-blue-200"></div>
69-
<div class="text-sm" id="gpsStatus">Import a map photo to get started.</div>
70-
</section>
71-
72-
<section class="bg-slate-900/70 border border-slate-700 rounded-xl p-6">
73-
<h2 class="text-xl font-semibold text-slate-100 mb-4">Reference pairs</h2>
74-
<div class="overflow-x-auto">
75-
<table class="min-w-full text-left" id="pairTable">
76-
<thead class="text-xs uppercase tracking-wider text-slate-400 border-b border-slate-700">
77-
<tr>
78-
<th class="px-3 py-2">Pixel (x, y)</th>
79-
<th class="px-3 py-2">World (lat, lon)</th>
80-
<th class="px-3 py-2">Residual</th>
81-
<th class="px-3 py-2 text-right">Actions</th>
82-
</tr>
83-
</thead>
84-
<tbody id="pairTableBody" class="divide-y divide-slate-800"></tbody>
85-
</table>
86-
</div>
87-
</section>
110+
</section>
111+
</main>
88112
</div>
89113

90114
<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>

service-worker.js

Lines changed: 72 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const CACHE_NAME = 'snap2map-shell-v1';
1+
const CACHE_NAME = 'snap2map-shell-v2';
22
const SHELL_ASSETS = [
33
'/',
44
'/index.html',
@@ -8,31 +8,91 @@ const SHELL_ASSETS = [
88

99
self.addEventListener('install', (event) => {
1010
event.waitUntil(
11-
caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_ASSETS)).then(() => self.skipWaiting()),
11+
caches
12+
.open(CACHE_NAME)
13+
.then((cache) => cache.addAll(SHELL_ASSETS))
14+
.catch(() => null)
15+
.finally(() => self.skipWaiting()),
1216
);
1317
});
1418

1519
self.addEventListener('activate', (event) => {
1620
event.waitUntil(
17-
caches.keys().then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))).then(() => self.clients.claim()),
21+
caches
22+
.keys()
23+
.then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))))
24+
.then(() => self.clients.claim()),
1825
);
1926
});
2027

2128
self.addEventListener('fetch', (event) => {
22-
const { request } = event;
23-
if (request.method !== 'GET') {
29+
if (event.request.method !== 'GET') {
2430
return;
2531
}
32+
33+
const url = new URL(event.request.url);
34+
const isSameOrigin = url.origin === self.location.origin;
35+
const isNavigation = event.request.mode === 'navigate';
36+
const isShellResource = isSameOrigin && SHELL_ASSETS.includes(url.pathname);
37+
38+
if (!isSameOrigin) {
39+
return;
40+
}
41+
2642
event.respondWith(
27-
caches.match(request).then((cached) => {
43+
caches.open(CACHE_NAME).then(async (cache) => {
44+
const cached = await cache.match(event.request);
45+
46+
const fetchAndUpdate = async () => {
47+
const response = await fetch(event.request);
48+
if (response && response.ok) {
49+
cache.put(event.request, response.clone());
50+
}
51+
return response;
52+
};
53+
54+
const getNavigationFallback = async () => {
55+
const fallback = (await cache.match('/index.html')) || (await cache.match('/'));
56+
return fallback || null;
57+
};
58+
59+
if (isNavigation || isShellResource) {
60+
try {
61+
const response = await fetchAndUpdate();
62+
if (response) {
63+
return response;
64+
}
65+
} catch (error) {
66+
// network request failed, fall back to cache if possible
67+
}
68+
69+
if (cached) {
70+
return cached;
71+
}
72+
73+
if (isNavigation) {
74+
const fallback = await getNavigationFallback();
75+
if (fallback) {
76+
return fallback;
77+
}
78+
}
79+
80+
return Response.error();
81+
}
82+
2883
if (cached) {
84+
fetchAndUpdate().catch(() => null);
2985
return cached;
3086
}
31-
return fetch(request).then((response) => {
32-
const clone = response.clone();
33-
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
34-
return response;
35-
});
36-
}).catch(() => caches.match('/index.html')),
87+
88+
try {
89+
return await fetchAndUpdate();
90+
} catch (error) {
91+
if (cached) {
92+
return cached;
93+
}
94+
return Response.error();
95+
}
96+
}),
3797
);
3898
});

0 commit comments

Comments
 (0)