1/** 2 * js/map-view/details/services-controls.js 3 * 4 * "Route Services": find beds, water and bike shops along a line, via 5 * the Overpass API in corridor-sized chunks. 6 * 7 * Extracted from feature-render.js on 2026-08-24 for the same reason as 8 * atlas-controls.js: the Trip tab mounts it, and importing it back out 9 * of feature-render would have been circular. 10 * 11 * Behavior is unchanged. This is a move, not a rewrite. Note the gate 12 * has NOT changed with it: enable_osm_lookup is on for nine live 13 * projects, several of them client work, and they keep this panel in the 14 * Responses tab exactly where it has always been. 15 */ 16 17import { featureTitle } from '../feature-title.js'; 18 19export function buildServicesControls(feature) { 20 const div = document.createElement('div'); 21 div.className = 'mt-3 pt-2 border-top'; 22 23 // Import category definitions inline for the checkbox builder 24 const cats = { 25 water: { label: 'Drinking Water', icon: 'fa-faucet-drip', color: '#2196F3' }, 26 bike_shop: { label: 'Bike Shops', icon: 'fa-bicycle', color: '#F
26F5722' }, 27 campground: { label: 'Campgrounds', icon: 'fa-campground', color: '#4CAF50' }, 28 grocery: { label: 'Grocery / Food', icon: 'fa-cart-shopping', color: '#FF9800' }, 29 restaurant: { label: 'Restaurants', icon: 'fa-utensils', color: '#E91E63' }, 30 toilets: { label: 'Public Toilets', icon: 'fa-restroom', color: '#607D8B' }, 31 shelter: { label: 'Shelters', icon: 'fa-house-chimney', color: '#795548' }, 32 lodging: { label: 'Lodging', icon: 'fa-bed', color: '#9C27B0' }, 33 pharmacy: { label: 'Pharmacies', icon: 'fa-prescription-bottle-medical', color: '#F44336' }, 34 laundry: { label: 'Laundromats', icon: 'fa-shirt', color: '#00BCD4' }, 35 library: { label: 'Libraries', icon: 'fa-book', color: '#3F51B5' } 36 }; 37 38 const checkboxes = Object.entries(cats).map(([key, c]) => 39 `<label class="d-flex align-items-center gap-1" style="cursor:pointer;font-size:0.8rem;"> 40 <input type="checkbox" value="${key}" class="svc-cat-cb" ${['water','bike_shop','campground','grocery'].includes(key) ? 'checked' : ''}> 41 <i class="fas ${c.icon}" style="color:${c.color};width:16px;text-align:center;"></i> 42 <span>${c.label}</span> 43 </label>` 44 ).join(''); 45 46 div.innerHTML = ` 47 <details class="svc-discovery"> 48 <summary class="small fw-semibold" style="cursor:pointer;color:#4CAF50;"> 49 <i class="fas fa-map-signs me-1"></i>Route Services 50 </summary> 51 <div class="mt-2"> 52 <div class="d-flex flex-wrap gap-1 mb-2" style="max-height:140px;overflow-y:auto;"> 53 ${checkboxes} 54 </div> 55 <div class="d-flex align-items-center gap-2 mb-2"> 56 <label class="small text-muted mb-0" style="white-space:nowrap;">Buffer:</label> 57 <input type="range" class="form-range svc-buffer-range" min="0.5" max="5" step="0.5" value="1" style="flex:1;"> 58 <span class="small text-muted svc-buffer-label" style="min-width:3.5em;">1 mi</span> 59 </div> 60 <div class="d-flex gap-2"> 61 <button class="btn btn-sm btn-outline-success flex-fill svc-search-btn"> 62 <i class="fas fa-search me-1"></i>Find Services 63 </button> 64 <button class="btn btn-sm btn-outline-primary svc-save-btn d-none" title="Save to map"> 65 <i class="fas fa-save"></i> 66 </button> 67 <button class="btn btn-sm btn-outline-secondary svc-clear-btn d-none" title="Clear"> 68 <i class="fas fa-times"></i> 69 </button> 70 </div> 71 <!-- Every outcome in this panel went to console.log until 72 2026-08-24, when a failed save showed up as an unlabeled 73 warning triangle and Steve reasonably asked what it meant. 74 It is answerable here now. --> 75 <div class="svc-status small mt-2"></div> 76 </div> 77 </details> 78 `; 79 80 const searchBtn = div.querySelector('.svc-search-btn'); 81 const saveBtn = div.querySelector('.svc-save-btn'); 82 const clearBtn = div.querySelector('.svc-clear-btn'); 83 const statusEl = div.querySelector('.svc-status'); 84 85 /** Say what happened, in the panel, where the person is looking. */ 86 function say(text, tone = 'muted') { 87 if (!statusEl) return; 88 const cls = { muted: 'text-muted', ok: 'text-success', bad: 'text-danger' }[tone] || 'text-muted'; 89 statusEl.className = `svc-status small mt-2 ${cls}`; 90 statusEl.textContent = text; 91 } 92 93 /** 94 * Turn a failed save into something a person can act on. 95 * 96 * A 401 is the one worth naming: sessions are 90 days, so hitting it 97 * means signed out in another tab, and no amount of retrying the 98 * button will help. 99 */ 100 function saveFailureText(err) { 101 const msg = String(err?.message || err); 102 if (/\b401\b/.test(msg)) return 'Not signed in any more. Reload the page, sign in, then search and save again.'; 103 if (/\b403\b/.test(msg)) return 'You do not have permission to add features to this project.'; 104 if (/\b413\b/.test(msg)) return 'Too many results to save at once. Narrow the buffer or the categories and try again.'; 105 if (/Failed to fetch|NetworkError/i.test(msg)) return 'Could not reach the server. Check the connection and press save again.'; 106 return `${msg}. Press save again to retry.`; 107 } 108 const rangeInput = div.querySelector('.svc-buffer-range'); 109 const rangeLabel = div.querySelector('.svc-buffer-label'); 110 let lastResult = null; 111 112 /* Markers found earlier this session are still on the map even though 113 this panel was just rebuilt, so offer to save them straight away. 114 Otherwise a search, a closed popup and a reopened one leaves the 115 results visible on the map and unsaveable (Steve, 2026-08-24). */ 116 (async () => { 117 try { 118 const { getShownServices } = await import('../route-services.js'); 119 const already = getShownServices(); 120 if (already.features.length) { 121 lastResult = already; 122 saveBtn.classList.remove('d-none'); 123 clearBtn.classList.remove('d-none'); 124 saveBtn.title = `Save ${already.features.length}
124 services found this session`; 125 } 126 } catch { /* nothing found yet */ } 127 })(); 128 129 rangeInput.addEventListener('input', () => { 130 rangeLabel.textContent = `${rangeInput.value} mi`; 131 }); 132 133 searchBtn.addEventListener('click', async () => { 134 const selected = [...div.querySelectorAll('.svc-cat-cb:checked')].map(cb => cb.value); 135 // A blocking dialog on a map freezes every later map event, and 136 // this panel now has a place to put the sentence instead. 137 if (!selected.length) { say('Tick at least one kind of service first.', 'bad'); return; } 138 139 searchBtn.disabled = true; 140 searchBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Searching...'; 141 try { 142 const { searchServices } = await import('../route-services.js'); 143 const result = await searchServices(window._map, feature.geometry, selected, { 144 bufferMiles: parseFloat(rangeInput.value) 145 }); 146 lastResult = result; 147 const n = result?.features?.length || 0; 148 if (n) { 149 clearBtn.classList.remove('d-none'); 150 saveBtn.classList.remove('d-none'); 151 // "Found" is not "kept": these sit on the map until saved, and 152 // the save is a separate button people were missing. 153 say(`Found ${n} on the map. Press save to keep them.`, 'ok'); 154 } else { 155 say('Nothing found along this route with those categories. Try a wider buffer.', 'muted'); 156 } 157 } catch (err) { 158 console.error('[RouteServices] Search failed:', err); 159 say(`Search failed: ${err.message}`, 'bad'); 160 } finally { 161 searchBtn.disabled = false; 162 searchBtn.innerHTML = '<i class="fas fa-search me-1"></i>Find Services'; 163 } 164 }); 165 166 // Save as features 167 saveBtn.addEventListener('click', async () => { 168 // Re-read rather than trusting the snapshot: another search may 169 // have added to the map since this panel was built. 170 try { 171 const { getShownServices } = await import('../route-services.js'); 172 const current = getShownServices(); 173 if (current.features.length) lastResult = current; 174 } catch { /* fall back to what this panel captured */ } 175 176 if (!lastResult?.features?.length) return; 177 const projectId = window.wmState?.project?.id; 178 if (!projectId) return; 179 180 saveBtn.disabled = true; 181 saveBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>'; 182 try { 183 const sourceLabel = featureTitle(feature); 184 const res = await fetch('/api/route-services/save-features', { 185 method: 'POST', 186 headers: { 'Content-Type': 'application/json' }, 187 credentials: 'include', 188 body: JSON.stringify({ 189 projectId, 190 geojson: lastResult, 191 sourceFeatureId: feature.id, 192 sourceFeatureTitle: sourceLabel 193 }) 194 }); 195 if (!res.ok) throw new Error(`Save failed: ${res.status}`); 196 const data = await res.json(); 197 const msg = data.skipped 198 ? `${data.created} new, ${data.skipped} already existed` 199 : `${data.created} saved`; 200 saveBtn.innerHTML = '<i class="fas fa-check"></i>'; 201 saveBtn.classList.remove('btn-outline-danger'); 202 saveBtn.classList.add('btn-outline-primary'); 203 saveBtn.title = 'Save to map'; 204 console.log(`[RouteServices] ${msg}`); 205 say(data.created > 0 ? `${msg}. Reloading to show them.` : msg, 'ok'); 206 207 if (data.created > 0) { 208 setTimeout(() => { 209 saveBtn.classList.add('d-none'); 210 window.location.reload(); 211 }, 1500); 212 } else { 213 saveBtn.title = msg; 214 setTimeout(() => { saveBtn.classList.add('d-none'); }, 3000); 215 } 216 } catch (err) { 217 console.error('[RouteServices] Save failed:', err); 218 const why = saveFailureText(err); 219 // Red, not blue. It kept its primary styling through a failure, 220 // so the triangle was the only signal anything had gone wrong. 221 saveBtn.innerHTML = '<i class="fas fa-exclamation-triangle"></i>'; 222 saveBtn.classList.remove('btn-outline-primary'); 223 saveBtn.classList.add('btn-outline-danger'); 224 saveBtn.title = why; 225 saveBtn.disabled = false;
226 say(why, 'bad'); 227 } 228 }); 229 230 clearBtn.addEventListener('click', async () => { 231 const { clearServicesLayer } = await import('../route-services.js'); 232 clearServicesLayer(window._map); 233 clearBtn.classList.add('d-none'); 234 saveBtn.classList.add('d-none'); 235 lastResult = null; 236 say('Cleared. Nothing was saved.'); 237 }); 238 239 return div; 240}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.