PageSourceSearch

https://bikeatthebeach.com/js/map-view/details/services-controls.js

js bikeatthebeach.com collected 2026-10-02 00:31:22 UTC 10,632 bytes, 240 lines download raw bytes

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.