From df93083dcd27155943b8d961ab0260c2ab2dc779 Mon Sep 17 00:00:00 2001 From: hgosalia Date: Sun, 28 Jun 2026 19:56:22 -0400 Subject: [PATCH] Feature: Destination prefetch and split search results --- css/styles.css | 1 + js/map.js | 3 +- js/search.js | 255 ++++++++++++++++++++++++++++++++++++------------- 3 files changed, 190 insertions(+), 69 deletions(-) diff --git a/css/styles.css b/css/styles.css index 01b9f6a..3b0aa3f 100644 --- a/css/styles.css +++ b/css/styles.css @@ -226,6 +226,7 @@ input,textarea,select{font-family:var(--font)} .dest-item-icon{flex-shrink:0;color:var(--accent);margin-top:1px} .dest-item-name{font-weight:500;color:var(--text)} .dest-item-detail{font-size:.66rem;color:var(--muted);margin-top:1px} +.dest-divider{padding:5px 12px;font-size:.66rem;color:var(--muted);border-top:1px solid var(--border);font-weight:500;letter-spacing:.02em} #dest-loading{padding:10px 12px;font-size:.73rem;color:var(--muted);display:none} #search-cat-wrap{position:relative} #search-cat-btn{background:var(--surface2);border:1px solid var(--border);color:var(--muted);font-size:.73rem;font-weight:500;padding:5px 10px;border-radius:20px;cursor:pointer;white-space:nowrap;transition:all .15s;line-height:1} diff --git a/js/map.js b/js/map.js index 4202983..a295a7b 100644 --- a/js/map.js +++ b/js/map.js @@ -86,7 +86,7 @@ function _patchStyleBoundaries(styleObj) { layer.filter = noMaritime; } if (!layer.paint) layer.paint = {}; - layer.paint['line-color'] = 'rgba(140,160,190,0.18)'; + layer.paint['line-color'] = 'rgba(140,160,190,0.28)'; } } } @@ -593,6 +593,7 @@ function _initMapOverlays() { map.on('rotate', updateZoom); map.on('moveend', updateZoom); map.on('moveend', _onMapMoveForSearch); + map.on('moveend', _onMapMoveForPrefetch); updateZoom(); } diff --git a/js/search.js b/js/search.js index 6b07f13..4725f93 100644 --- a/js/search.js +++ b/js/search.js @@ -11,6 +11,12 @@ const _searchCache = {}; let _searchMoveTimer = null; let _searchCategory = 'all'; +// POI prefetch cache — Overpass results for the current viewport at zoom 10+ +let _poiCache = []; +let _poiCacheBbox = null; +let _poiFetchInFlight = false; +let _poiPrefetchTimer = null; + const POI_CATEGORIES = { hotel: { label: '🏨 Hotel', tags: [['amenity','hotel'],['tourism','hotel']] }, restaurant: { label: '🍽 Restaurant', tags: [['amenity','restaurant'],['amenity','cafe'],['amenity','bar'],['amenity','pub'],['amenity','fast_food']] }, @@ -47,6 +53,71 @@ function _onMapMoveForSearch() { _searchMoveTimer = setTimeout(() => runDestSearch(q), 600); } +// POI prefetch — fetch named POIs in the viewport at zoom 10+ for instant local search +function _onMapMoveForPrefetch() { + clearTimeout(_poiPrefetchTimer); + if (!map || _isOffline) return; + if (map.getZoom() < 10) { _poiCache = []; _poiCacheBbox = null; return; } + _poiPrefetchTimer = setTimeout(_prefetchPOIs, 1500); +} + +function _bboxOverlap(a, b) { + if (!a || !b) return 0; + const intW = Math.max(a.west, b.west), intE = Math.min(a.east, b.east); + const intS = Math.max(a.south, b.south), intN = Math.min(a.north, b.north); + if (intW >= intE || intS >= intN) return 0; + const intArea = (intE - intW) * (intN - intS); + const aArea = (a.east - a.west) * (a.north - a.south); + return aArea > 0 ? intArea / aArea : 0; +} + +async function _prefetchPOIs() { + if (!map || _poiFetchInFlight || _isOffline || map.getZoom() < 10) return; + const b = map.getBounds(); + const bbox = { south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() }; + if (_bboxOverlap(bbox, _poiCacheBbox) > 0.5) return; + + _poiFetchInFlight = true; + const bboxStr = `(${bbox.south.toFixed(5)},${bbox.west.toFixed(5)},${bbox.north.toFixed(5)},${bbox.east.toFixed(5)})`; + const poiTags = ['tourism', 'amenity', 'leisure', 'historic']; + const placeTags = ['village', 'hamlet', 'town', 'isolated_dwelling', 'neighbourhood']; + const stmts = [ + ...poiTags.flatMap(tag => [ + `node["name"]["${tag}"]${bboxStr};`, + `way["name"]["${tag}"]${bboxStr};`, + ]), + ...placeTags.map(v => `node["place"="${v}"]["name"]${bboxStr};`), + ].join('\n '); + const query = `[out:json][timeout:12];\n(\n ${stmts}\n);\nout center 200;`; + try { + const r = await fetch('/api/overpass', { method: 'POST', body: new URLSearchParams({ data: query }) }); + if (!r.ok) { _poiFetchInFlight = false; return; } + const data = await r.json(); + _poiCache = (data.elements || []) + .filter(el => el.tags?.name) + .map(el => { + const t = el.tags, lat = el.lat ?? el.center?.lat, lon = el.lon ?? el.center?.lon; + if (lat == null || lon == null) return null; + const city = t['addr:city'] || t['addr:town'] || t['addr:village'] || ''; + const country = t['addr:country'] || ''; + return { + display_name: [t.name, city, country].filter(Boolean).join(', '), + lat: String(lat), lon: String(lon), + address: { city, country, country_code: (country || '').toLowerCase() } + }; + }) + .filter(Boolean); + _poiCacheBbox = bbox; + } catch(_) { /* proxy unreachable — leave cache as-is */ } + _poiFetchInFlight = false; +} + +function _filterPOICache(q) { + if (!_poiCache.length || !q) return []; + const lower = q.toLowerCase(); + return _poiCache.filter(item => item.display_name.toLowerCase().includes(lower)); +} + dInput.addEventListener('input', () => { const q = dInput.value.trim(); dClear.style.display = q ? 'block' : 'none'; @@ -61,65 +132,75 @@ document.addEventListener('click', e => { }); -function renderSearchResults(data) { - dLoading.style.display='none'; - // Dedup: same name within ~20km counts as duplicate +function _dedupResults(data) { const unique = []; for (const item of data) { const name = item.display_name.split(',')[0].trim(); const lat = parseFloat(item.lat), lon = parseFloat(item.lon); const isDup = unique.some(u => { if (u.display_name.split(',')[0].trim() !== name) return false; - const d = Math.hypot(parseFloat(u.lat) - lat, parseFloat(u.lon) - lon); - return d < 0.5; // ~50km + return Math.hypot(parseFloat(u.lat) - lat, parseFloat(u.lon) - lon) < 0.5; }); if (!isDup) unique.push(item); } - if (!unique.length) { dResults.innerHTML='
No results found
'; return; } - // Detect duplicate names so we can add disambiguating detail + return unique; +} + +function _renderResultItems(items, allItems) { const nameCounts = {}; - unique.forEach(item => { + allItems.forEach(item => { const name = item.display_name.split(',')[0]; nameCounts[name] = (nameCounts[name] || 0) + 1; }); - dResults.innerHTML=''; - unique.forEach(item=>{ + items.forEach(item => { const main = item.display_name.split(',')[0]; - // Build detail from address fields for cleaner results (e.g. "Slovenia" not "Upravna Enota Ljubljana") - let detail = ''; - if (item.address) { - const a = item.address; - const parts = [a.state || a.county || a.municipality || '', a.country || ''].filter(p => p && p !== main); - detail = parts.join(', '); - } - if (!detail) detail = item.display_name.split(',').slice(1,3).join(', ').trim(); - // For duplicate names, build a richer detail line with archipelago/region/country + // Build detail from display_name (most reliable — includes all address levels) + let detail = item.display_name.split(',').slice(1).map(s => s.trim()).filter(Boolean).join(', '); + // For duplicate names, enrich with archipelago/region if needed if (nameCounts[main] > 1 && item.address) { - const a = item.address; - const local = a.municipality || a.town || a.city || a.village || a.county || a.district || ''; - const parts = [local]; - if (item.address.archipelago) parts.push(item.address.archipelago); - if (item.address.state) parts.push(item.address.state); - if (item.address.country) parts.push(item.address.country); - // Append continent/region only when duplicates span different regions - const siblings = unique.filter(i => i.display_name.split(',')[0].trim() === main); + const parts = item.display_name.split(',').slice(1).map(s => s.trim()).filter(Boolean); + if (item.address.archipelago && !parts.includes(item.address.archipelago)) { + parts.splice(parts.length - 1, 0, item.address.archipelago); + } + const siblings = allItems.filter(i => i.display_name.split(',')[0].trim() === main); const regions = siblings.map(i => _regionFromCoords(i.lat, i.lon)); - const multiRegion = new Set(regions.filter(Boolean)).size > 1; - if (multiRegion) { + if (new Set(regions.filter(Boolean)).size > 1) { const region = _regionFromCoords(item.lat, item.lon); - if (region) parts.push(region); + if (region && !parts.includes(region)) parts.push(region); } - // Remove parts that repeat the main name detail = parts.filter(p => p && p !== main).join(', '); } - const el=document.createElement('div'); - el.className='dest-item'; - el.innerHTML=`📍
${esc(main)}
${esc(detail)}
`; - el.addEventListener('click', ()=>flyTo(item)); + const el = document.createElement('div'); + el.className = 'dest-item'; + el.innerHTML = `📍
${esc(main)}
${esc(detail)}
`; + el.addEventListener('click', () => flyTo(item)); dResults.appendChild(el); }); } +function renderSearchResults(data, globalData) { + dLoading.style.display = 'none'; + const nearby = globalData !== undefined ? _dedupResults(data) : []; + const global = _dedupResults(globalData !== undefined ? globalData : data); + const allItems = [...nearby, ...global]; + + if (!allItems.length) { + dResults.innerHTML = '
No results found
'; + return; + } + dResults.innerHTML = ''; + if (nearby.length) { + _renderResultItems(nearby, allItems); + if (global.length) { + const divider = document.createElement('div'); + divider.className = 'dest-divider'; + divider.textContent = 'Other results'; + dResults.appendChild(divider); + } + } + if (global.length) _renderResultItems(global, allItems); +} + // Reverse geocode GPS coordinates — returns a single result formatted // identically to forward search so it renders in the same dropdown async function runReverseGeoSearch(lat, lng) { @@ -226,7 +307,7 @@ async function runDestSearch(q) { return; } dResults.style.display='block'; dLoading.style.display='block'; - dResults.querySelectorAll('.dest-item').forEach(el=>el.remove()); + dResults.querySelectorAll('.dest-item,.dest-divider').forEach(el=>el.remove()); // Detect GPS coordinate input (e.g. "48.8566, 2.3522" or "48.8566° N, 2.3522° E") const coordMatch = q.trim().match(/^(-?\d+\.?\d*)[°\s]*[NSns]?\s*,\s*(-?\d+\.?\d*)[°\s]*[EWew]?$/); @@ -242,45 +323,83 @@ async function runDestSearch(q) { } const zoom = map ? map.getZoom() : 0; - const cacheKey = q + (zoom >= 3 ? `@${map.getCenter().lng.toFixed(1)},${map.getCenter().lat.toFixed(1)}` : '') + (_searchCategory !== 'all' ? `#${_searchCategory}` : ''); - if (_searchCache[cacheKey]) { renderSearchResults(_searchCache[cacheKey]); return; } + // Category searches are viewport-dependent (Overpass bbox); general searches are not + const cacheKey = _searchCategory !== 'all' + ? q + (zoom >= 3 ? `@${map.getCenter().lng.toFixed(1)},${map.getCenter().lat.toFixed(1)}` : '') + `#${_searchCategory}` + : q; if (_searchCategory !== 'all') { + if (_searchCache[cacheKey]) { renderSearchResults(_searchCache[cacheKey]); return; } await runCategorySearch(q, _searchCategory, cacheKey); return; } - try { - await nominatimThrottle(); - // Pass viewbox to bias results toward visible region - let viewbox = ''; - if (map && zoom >= 3) { - const b = map.getBounds(); - viewbox = `&viewbox=${b.getWest()},${b.getNorth()},${b.getEast()},${b.getSouth()}&bounded=0`; + + // Filter prefetched POI cache for instant nearby results + const nearby = _filterPOICache(q); + + // Check Nominatim cache + let nominatimData = _searchCache[cacheKey]; + if (!nominatimData) { + try { + await nominatimThrottle(); + const r = await fetch(`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&limit=10&addressdetails=1`,{headers:{'Accept-Language':'en'}}); + if (!r.ok) { throw new Error(`HTTP ${r.status}`); } + nominatimData = await r.json(); + _searchCache[cacheKey] = nominatimData; + } catch(err) { + console.warn('Search failed:', err); + if (!nearby.length) { + dLoading.style.display='none'; + dResults.innerHTML=`
Search failed — try again in a moment
`; + return; + } + nominatimData = []; } - const r = await fetch(`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&limit=10&addressdetails=1${viewbox}`,{headers:{'Accept-Language':'en'}}); - if (!r.ok) { throw new Error(`HTTP ${r.status}`); } - let data = await r.json(); - // Re-sort: results within the visible bounds come first, then by distance from center - if (map && zoom >= 3) { - const b = map.getBounds(); - const c = map.getCenter(); - const inBounds = (d) => { - const lat = parseFloat(d.lat), lon = parseFloat(d.lon); - return lat >= b.getSouth() && lat <= b.getNorth() && lon >= b.getWest() && lon <= b.getEast(); - }; - const dist = (d) => Math.hypot(parseFloat(d.lat) - c.lat, parseFloat(d.lon) - c.lng); - data.sort((a, b2) => { - const aIn = inBounds(a), bIn = inBounds(b2); - if (aIn !== bIn) return aIn ? -1 : 1; - return dist(a) - dist(b2); - }); + } + + // Split Nominatim results by viewport bounds — in-bounds results join + // the nearby group, out-of-bounds go below the "Other results" divider + let inBoundsNom = [], outBoundsNom = []; + if (map && zoom >= 3) { + const b = map.getBounds(); + for (const d of nominatimData) { + const lat = parseFloat(d.lat), lon = parseFloat(d.lon); + if (lat >= b.getSouth() && lat <= b.getNorth() && lon >= b.getWest() && lon <= b.getEast()) { + inBoundsNom.push(d); + } else { + outBoundsNom.push(d); + } } - _searchCache[cacheKey] = data; - renderSearchResults(data); - } catch(err) { - console.warn('Search failed:', err); - dLoading.style.display='none'; - dResults.innerHTML=`
Search failed — try again in a moment
`; + } else { + inBoundsNom = nominatimData; + } + + // Merge POI cache + in-bounds Nominatim, dedup by name + proximity + const allNearby = [...nearby]; + for (const nd of inBoundsNom) { + const nName = nd.display_name.split(',')[0].trim().toLowerCase(); + const isDup = allNearby.some(nb => { + const nbName = nb.display_name.split(',')[0].trim().toLowerCase(); + return nbName === nName && Math.hypot(parseFloat(nb.lat) - parseFloat(nd.lat), parseFloat(nb.lon) - parseFloat(nd.lon)) < 0.15; + }); + if (!isDup) allNearby.push(nd); + } + + // Dedup out-of-bounds against nearby + const globalData = outBoundsNom.filter(nd => { + const nName = nd.display_name.split(',')[0].trim().toLowerCase(); + return !allNearby.some(nb => { + const nbName = nb.display_name.split(',')[0].trim().toLowerCase(); + return nbName === nName && Math.hypot(parseFloat(nb.lat) - parseFloat(nd.lat), parseFloat(nb.lon) - parseFloat(nd.lon)) < 0.15; + }); + }); + + if (allNearby.length && globalData.length) { + renderSearchResults(allNearby, globalData); + } else if (allNearby.length) { + renderSearchResults(allNearby); + } else { + renderSearchResults(globalData); } }