Skip to content

Commit 3d2677b

Browse files
authored
Merge pull request #19 from hothsys/features/nominatim-gps-search
Feature: Nominatim Search by latitude/longitude
2 parents 089a471 + 2c984f4 commit 3d2677b

4 files changed

Lines changed: 80 additions & 14 deletions

File tree

css/styles.css

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,10 @@ input,textarea,select{font-family:var(--font)}
5151
#countries-bar{display:none;margin:0 14px 7px;padding:6px 10px;border-radius:9px;background:var(--surface2);border:1px solid var(--border);flex-shrink:0;text-align:center}
5252
#countries-bar .cb-label{font-size:.58rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:8px}
5353
#countries-bar .cb-status{font-size:.68rem;color:var(--text);height:1.1em;margin-top:10px;transition:opacity .15s;opacity:.5}
54-
#countries-bar .cb-flags{font-size:1rem;letter-spacing:2px;line-height:1.4;overflow:hidden}
54+
#countries-bar .cb-flags{font-size:1rem;letter-spacing:2px;line-height:1.4;overflow:hidden;transition:max-height .3s ease}
5555
#countries-bar .cb-flags span{cursor:default;transition:transform .1s}
5656
#countries-bar .cb-flags span:hover{transform:scale(1.25)}
5757
#countries-bar .cb-flags.collapsed{max-height:2.8em}
58-
#countries-bar .cb-flags{transition:max-height .3s ease}
5958
#countries-bar .cb-toggle{background:none;border:none;color:var(--muted);font-size:1rem;cursor:pointer;padding:0;margin:6px 0 -4px;transition:color .15s, transform .3s ease;display:block;width:100%;text-align:center;line-height:1}
6059
#countries-bar .cb-toggle:hover{color:var(--text)}
6160
#countries-bar .cb-toggle.expanded{transform:rotate(180deg)}
@@ -178,7 +177,7 @@ input,textarea,select{font-family:var(--font)}
178177
.maplibregl-ctrl-labels{width:29px;height:29px;padding:0;cursor:pointer;border-bottom:none!important}
179178
.maplibregl-ctrl-logo{display:none!important}
180179
.maplibregl-popup{z-index:6!important}
181-
.maplibregl-popup-content{background:var(--surface)!important;border:1px solid var(--border)!important;border-radius:var(--radius)!important;box-shadow:var(--shadow)!important;padding:0!important;overflow:hidden}
180+
.maplibregl-popup-content{background:var(--surface)!important;border:1px solid var(--border)!important;border-radius:var(--radius)!important;box-shadow:var(--shadow)!important;padding:0!important;overflow:visible}
182181
.maplibregl-popup-tip{border-top-color:var(--surface)!important;border-bottom-color:var(--surface)!important}
183182
.maplibregl-popup-close-button{color:var(--muted)!important;font-size:1.3rem!important;padding:6px 10px!important;background:none!important;z-index:1}
184183
.maplibregl-popup-close-button:hover{color:var(--text)!important;background:none!important}
@@ -235,7 +234,7 @@ input,textarea,select{font-family:var(--font)}
235234
.dest-pin-el-inner{font-size:12px;line-height:1}
236235

237236
/* PIN POPUP */
238-
.popup-wrap{width:300px;display:flex;flex-direction:column;max-height:380px}
237+
.popup-wrap{width:300px;display:flex;flex-direction:column;max-height:380px;border-radius:var(--radius);overflow:hidden}
239238
.popup-hdr{padding:10px 12px 8px;border-bottom:1px solid var(--border);flex-shrink:0}
240239
.popup-hdr-title{font-size:.84rem;font-weight:600;word-wrap:break-word;overflow-wrap:break-word;padding-right:28px}
241240
.popup-hdr-count{font-size:.64rem;color:var(--muted);margin-top:2px}
@@ -272,11 +271,12 @@ input,textarea,select{font-family:var(--font)}
272271
.pn-save:hover{opacity:.85}
273272

274273
/* DEST POPUP */
275-
.dest-popup{width:220px;padding:12px 14px}
274+
.dest-popup{width:220px;padding:12px 14px;border-radius:var(--radius);overflow:hidden}
276275
.dest-popup-name{font-weight:600;font-size:.83rem;margin-bottom:3px;padding-right:20px}
277276
.dest-popup-detail{font-size:.7rem;color:var(--muted);margin-bottom:10px}
278277
.dest-popup-btn{display:flex;align-items:center;gap:6px;padding:7px 10px;border-radius:8px;font-size:.71rem;font-weight:500;border:1px solid var(--border);background:var(--surface2);color:var(--text);transition:all .15s;width:100%;cursor:pointer;margin-top:6px}
279278
.dest-popup-btn:hover{background:var(--surface3);border-color:var(--accent);color:var(--accent)}
279+
.dest-popup-coords{font-size:.58rem;color:var(--muted);margin-top:8px;text-align:center;opacity:.5;font-family:monospace}
280280

281281
/* META + ALBUM MODAL */
282282
#meta-backdrop{display:none;position:fixed;inset:0;z-index:500;background:rgba(0,0,0,.65);align-items:center;justify-content:center;backdrop-filter:blur(5px)}
@@ -370,9 +370,9 @@ textarea.fi{resize:vertical;min-height:60px;line-height:1.5}
370370
.settings-btn:hover{color:var(--text)}
371371
.settings-dropdown{display:none;position:absolute;top:calc(100% + 4px);right:0;background:var(--surface);border:1px solid var(--border);border-radius:10px;box-shadow:var(--shadow);z-index:50;overflow:hidden;min-width:200px}
372372
.settings-dropdown.open{display:block}
373-
.settings-item{display:flex;align-items:center;gap:8px;padding:9px 14px;font-size:.74rem;cursor:pointer;transition:background .12s;border-bottom:1px solid var(--border);color:var(--text)}
373+
.settings-item{display:flex;align-items:flex-start;gap:14px;padding:9px 14px;font-size:.74rem;cursor:pointer;transition:background .12s;border-bottom:1px solid var(--border);color:var(--text)}
374374
.settings-item:last-child{border-bottom:none}
375375
.settings-item:hover{background:var(--surface2)}
376-
.settings-item-icon{font-size:.9rem;flex-shrink:0;width:18px;text-align:center}
376+
.settings-item-icon{font-size:.9rem;flex-shrink:0;width:18px;text-align:center;padding-top:1px}
377377
.settings-item-label{flex:1}
378378
.settings-item-sub{font-size:.6rem;color:var(--muted);margin-top:1px}

js/lightbox.js

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ function showLbPhoto(animate=false){
3939
img.style.transform='scale(.97)';
4040
cap.style.opacity='0';
4141
setTimeout(()=>{
42+
const imgSrc = p.dataUrl && !p.dataUrl.startsWith('data:') ? `/${p.dataUrl}` : p.dataUrl;
4243
const next = new Image();
4344
next.onload = () => {
4445
img.src = next.src;
@@ -49,20 +50,31 @@ function showLbPhoto(animate=false){
4950
cap.style.opacity='1';
5051
});
5152
};
52-
next.src = p.dataUrl;
53+
next.src = imgSrc;
5354
},180);
5455
} else {
55-
// Clear stale image immediately to prevent a flash of the previous photo,
56-
// then preload the new image and restore once decoded.
56+
// Clear stale image and show spinner while the new photo loads
5757
img.style.opacity='0';
5858
img.removeAttribute('src');
5959
cap.textContent = caption;
60+
// Show loading spinner in lightbox while photo fetches from disk
61+
let lbSpinner = document.getElementById('lb-spinner');
62+
if (!lbSpinner) {
63+
lbSpinner = document.createElement('div');
64+
lbSpinner.id = 'lb-spinner';
65+
lbSpinner.style.cssText = 'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:36px;height:36px;border:3px solid rgba(255,255,255,.2);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite;z-index:1';
66+
document.getElementById('lightbox').appendChild(lbSpinner);
67+
}
68+
lbSpinner.style.display = 'block';
69+
const imgSrc = p.dataUrl && !p.dataUrl.startsWith('data:') ? `/${p.dataUrl}` : p.dataUrl;
6070
const next = new Image();
6171
next.onload = () => {
6272
img.src = next.src;
73+
if (lbSpinner) lbSpinner.style.display = 'none';
6374
requestAnimationFrame(() => { img.style.opacity='1'; });
6475
};
65-
next.src = p.dataUrl;
76+
next.onerror = () => { if (lbSpinner) lbSpinner.style.display = 'none'; };
77+
next.src = imgSrc;
6678
}
6779
highlightCard(p.id);
6880
}

js/map.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -654,7 +654,7 @@ async function initMap() {
654654
const displayName = country && country !== placeName ? `${placeName}, ${country}` : placeName;
655655
const popup = new maplibregl.Popup({ maxWidth: '240px', closeButton: true, offset: 30 })
656656
.setLngLat([lng, lat])
657-
.setHTML(`<div class="dest-popup"><div class="dest-popup-name">${esc(displayName)}</div>${elevationStr ? `<div class="dest-popup-detail">⛰ ${elevationStr} elevation</div>` : ''}<button class="dest-popup-btn" onclick="openPinPickerAt(${lat},${lng})">+ Add photos to this location</button><button class="dest-popup-btn" onclick="pinEmptyLocation(${lat},${lng})">📌 Pin this location</button></div>`)
657+
.setHTML(`<div class="dest-popup"><div class="dest-popup-name">${esc(displayName)}</div>${elevationStr ? `<div class="dest-popup-detail">⛰ ${elevationStr} elevation</div>` : ''}<button class="dest-popup-btn" onclick="openPinPickerAt(${lat},${lng})">+ Add photos to this location</button><button class="dest-popup-btn" onclick="pinEmptyLocation(${lat},${lng})">📌 Pin this location</button><div class="dest-popup-coords">${lat.toFixed(3)}, ${lng.toFixed(3)}</div></div>`)
658658
.addTo(map);
659659
popup.on('close', () => { if (destMarkerObj) { destMarkerObj.marker.remove(); destMarkerObj = null; } });
660660

js/search.js

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,46 @@ function renderSearchResults(data) {
9090
});
9191
}
9292

93+
// Reverse geocode GPS coordinates — returns a single result formatted
94+
// identically to forward search so it renders in the same dropdown
95+
async function runReverseGeoSearch(lat, lng) {
96+
try {
97+
const searchWait = Math.max(0, 1100 - (Date.now() - _lastNominatimCall));
98+
if (searchWait > 0) await new Promise(r => setTimeout(r, searchWait));
99+
_lastNominatimCall = Date.now();
100+
const r = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&addressdetails=1&accept-language=en`);
101+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
102+
const d = await r.json();
103+
if (d.error) {
104+
dLoading.style.display = 'none';
105+
dResults.innerHTML = '<div style="padding:9px 12px;font-size:.73rem;color:var(--muted)">No results for these coordinates</div>';
106+
return;
107+
}
108+
// Build a meaningful display_name from address fields — Nominatim reverse often
109+
// returns a street number as the first element which isn't useful for navigation
110+
const a = d.address || {};
111+
// Pick the most meaningful name — landmark/POI first, then road, then area
112+
const mainName = d.name || a.tourism || a.building || a.amenity || a.leisure || a.road || a.suburb || a.neighbourhood || a.city || a.town || a.village || a.state || d.display_name.split(',')[0];
113+
const city = a.city || a.town || a.village || '';
114+
const country = a.country || '';
115+
const displayParts = [mainName, city, country].filter((v, i, arr) => v && arr.indexOf(v) === i);
116+
const displayName = displayParts.join(', ');
117+
118+
const result = [{
119+
display_name: displayName,
120+
lat: String(lat),
121+
lon: String(lng),
122+
address: a
123+
}];
124+
_searchCache[`${lat},${lng}`] = result;
125+
renderSearchResults(result);
126+
} catch (err) {
127+
console.warn('Reverse geo search failed:', err);
128+
dLoading.style.display = 'none';
129+
dResults.innerHTML = '<div style="padding:9px 12px;font-size:.73rem;color:var(--accent2)">Coordinate lookup failed — try again</div>';
130+
}
131+
}
132+
93133
async function runDestSearch(q) {
94134
if (_isOffline) {
95135
dResults.style.display='block'; dLoading.style.display='none';
@@ -98,6 +138,20 @@ async function runDestSearch(q) {
98138
}
99139
dResults.style.display='block'; dLoading.style.display='block';
100140
dResults.querySelectorAll('.dest-item').forEach(el=>el.remove());
141+
142+
// Detect GPS coordinate input (e.g. "48.8566, 2.3522" or "48.8566° N, 2.3522° E")
143+
const coordMatch = q.trim().match(/^(-?\d+\.?\d*)[°\s]*[NSns]?\s*,\s*(-?\d+\.?\d*)[°\s]*[EWew]?$/);
144+
if (coordMatch) {
145+
let lat = parseFloat(coordMatch[1]), lng = parseFloat(coordMatch[2]);
146+
// Handle S/W suffixes making coords negative
147+
if (/[Ss]/.test(q)) lat = -Math.abs(lat);
148+
if (/[Ww]/.test(q)) lng = -Math.abs(lng);
149+
if (lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) {
150+
await runReverseGeoSearch(lat, lng);
151+
return;
152+
}
153+
}
154+
101155
const zoom = map ? map.getZoom() : 0;
102156
const cacheKey = q + (zoom >= 3 ? `@${map.getCenter().lng.toFixed(1)},${map.getCenter().lat.toFixed(1)}` : '');
103157
if (_searchCache[cacheKey]) { renderSearchResults(_searchCache[cacheKey]); return; }
@@ -166,7 +220,7 @@ function flyTo(item) {
166220
});
167221
const popup=new maplibregl.Popup({maxWidth:'240px',closeButton:true,offset:30})
168222
.setLngLat([lng,lat])
169-
.setHTML(`<div class="dest-popup"><div class="dest-popup-name">${esc(main)}</div><div class="dest-popup-detail">${esc(detail)}</div><button class="dest-popup-btn" onclick="openPinPickerAt(${lat},${lng})">+ Add photos to this location</button><button class="dest-popup-btn" onclick="pinEmptyLocation(${lat},${lng})">📌 Pin this location</button></div>`)
223+
.setHTML(`<div class="dest-popup"><div class="dest-popup-name">${esc(main)}</div><div class="dest-popup-detail">${esc(detail)}</div><button class="dest-popup-btn" onclick="openPinPickerAt(${lat},${lng})">+ Add photos to this location</button><button class="dest-popup-btn" onclick="pinEmptyLocation(${lat},${lng})">📌 Pin this location</button><div class="dest-popup-coords">${lat.toFixed(3)}, ${lng.toFixed(3)}</div></div>`)
170224
.addTo(map);
171225
popup.on('close', () => { if (destMarkerObj) { destMarkerObj.marker.remove(); destMarkerObj = null; } });
172226
destMarkerObj={marker,popup};
@@ -211,7 +265,7 @@ function reopenDestPopup(){
211265
const detail = country || '';
212266
const popup = new maplibregl.Popup({maxWidth:'240px',closeButton:true,offset:30})
213267
.setLngLat([lng,lat])
214-
.setHTML(`<div class="dest-popup"><div class="dest-popup-name">${esc(main)}</div><div class="dest-popup-detail">${esc(detail)}</div><button class="dest-popup-btn" onclick="openPinPickerAt(${lat},${lng})">+ Add photos to this location</button><button class="dest-popup-btn" onclick="pinEmptyLocation(${lat},${lng})">📌 Pin this location</button></div>`)
268+
.setHTML(`<div class="dest-popup"><div class="dest-popup-name">${esc(main)}</div><div class="dest-popup-detail">${esc(detail)}</div><button class="dest-popup-btn" onclick="openPinPickerAt(${lat},${lng})">+ Add photos to this location</button><button class="dest-popup-btn" onclick="pinEmptyLocation(${lat},${lng})">📌 Pin this location</button><div class="dest-popup-coords">${lat.toFixed(3)}, ${lng.toFixed(3)}</div></div>`)
215269
.addTo(map);
216270
popup.on('close', () => { if (destMarkerObj) { destMarkerObj.marker.remove(); destMarkerObj = null; } });
217271
destMarkerObj.popup = popup;

0 commit comments

Comments
 (0)