PageSourceSearch

https://www.vicbug.app/v2/panels/trail-panel.js?v=284

js vicbug.app collected 2026-09-24 13:55:16 UTC 16,789 bytes, 453 lines download raw bytes

1/**
2 * Trail Panel — Click a cycling route to see context menu + trail details.
3 * Uses the existing #detail-panel slide-in panel and .poi-context-menu styling.
4 */
5
6import { eventBus } from '../core/event-bus.js?v=284';
7import { store } from '../core/state.js?v=284';
8import { haversine, escapeHtml } from '../core/utils.js?v=284';
9import { db } from '../app.js?v=284';
10import { getMap } from '../map/map-manager.js?v=284';
11import { getCachedCyclingFeatures } from '../map/map-layers.js?v=284';
12import { addWaypoint, reverseGeocode, waypointActionDescriptors, applyWaypointAction } from '../routing/waypoints.js?v=284';
13
14let trailCache = {};
15let selectedTrailId = null;
16let pendingTrailClick = null;
17let _trailPopup = null;
18
19export function initTrailPanel(map) {
20  const panel = document.getElementById('detail-panel');
21  const closeBtn = document.getElementById('detail-panel-close');
22  if (!panel || !closeBtn) return;
23
24  // Close panel handler
25  closeBtn.addEventListener('click', () => {
26    closeTrailPanel();
27  });
28
29  // Click on cycling route hit area → show context menu
30  map.on('click', 'routes-hitarea', (e) => {
31    if (!e.features || !e.features.length) return;
32
33    const feature = e.features[0];
34    const routeId = feature.properties.routeId;
35    const name = feature.properties.name || 'Cycling Route';
36
37    if (!routeId) return;
38
39    showTrailContextMenu(e, routeId, name);
40  });
41
42  // Hover cursor
43  map.on('mouseenter', 'routes-hitarea', () => {
44    map.getCanvas().style.cursor = 'pointer';
45  });
46  map.on('mouseleave', 'routes-hitarea', () => {
47    map.getCanvas().style.cursor = store.get('viewMode') ? '' : 'crosshair';
48  });
49
50  // Tooltip on hover
51  let tooltip = document.createElement('div');
52  tooltip.id = 'trail-tooltip';
53  tooltip.style.cssText = 'position:fixed;padding:6px 12px;border-radius:8px;font-size:12px;font-weight:600;color:white;background:#0097e6;pointer-events:none;z-index:1000;display:none;box-shadow:0 2px 8px rgba(0,0,0,0.2);white-space:nowrap;';
54  document.body.appendChild(tooltip);
55
56  map.on('mousemove', 'routes-hitarea', (e) => {
57    if (!e.features || !e.features.length) return;
58    const name = e.features[0].properties.name;
59    if (name) {
60      tooltip.textContent = name;
61      tooltip.style.display = 'block';
62      tooltip.style.left = (e.originalEvent.clientX + 12) + 'px';
63      tooltip.style.top = (e.originalEvent.clientY - 30) + 'px';
64    }
65  });
66  map.on('mouseleave', 'routes-hitarea', () => {
67    tooltip.style.display = 'none';
68  });
69
70  // Close panel when clicking elsewhere on map
71  map.on('click', (e) => {
72    const features = map.queryRenderedFeatures(e.point, {
73      layers: ['routes-hitarea'].filter(l => { try { return map.getLayer(l); } catch { return false; } })
74    });
75    if (features.length === 0 && selectedTrailId) {
76      closeTrailPanel();
77    }
78  });
79
80  // Add selected-trail source/layers
81  addHighlightLayers(map);
82
83  // Re-add highlight layers after style change
84  eventBus.on('map:styleChanged', () => {
85    const m = getMap();
86    if (m) {
87      addHighlightLayers(m);
88      if (selectedTrailId) highlightTrail(selectedTrailId);
89    }
90  });
91}
92
93// ─── Context Menu ───
94
95function showTrailContextMenu(e, routeId, name) {
96  const tooltip = document.getElementById('trail-tooltip');
97  if (tooltip) tooltip.style.display = 'none';
98  hideTrailContextMenu();
99  import('../map/user-hazards.js?v=284').then(m => m.showMapContextMenu(e.lngLat, e.point));
100}
101
102function hideTrailContextMenu() {
103  if (_trailPopup) { _trailPopup.remove(); _trailPopup = null; }
104}
105
106function dropPinAtTrail() {
107  if (!pendingTrailClick) return;
108  const lngLat = pendingTrailClick.lngLat;
109  reverseGeocode(lngLat).then(address => {
110    addWaypoint(lngLat, address);
111  });
112}
113
114function addControlPointAtTrail() {
115  if (!pendingTrailClick) return;
116  const [lng, lat] = pendingTrailClick.lngLat;
117  import('../map/user-hazards.js').then(m => {
118    m.insertWaypointOnRoute({ lng, lat });
119  });
120}
121
122function viewTrailDetails() {
123  if (!pendingTrailClick) return;
124  openTrailPanel(pendingTrailClick.routeId, pendingTrailClick.name);
125}
126
127// ─── Highlight Layers ───
128
129function addHighlightLayers(map) {
130  if (map.getSource('selected-trail')) return;
131
132  map.addSource('selected-trail', {
133    type: 'geojson',
134    data: { type: 'FeatureCollection', features: [] }
135  });
136
137  map.addLayer({
138    id: 'selected-trail-glow',
139    type: 'line',
140    source: 'selected-trail',
141    paint: {
142      'line-color': '#0097e6',
143      'line-width': 12,
144      'line-opacity': 0.25,
145      'line-blur': 4
146    }
147  });
148
149  map.addLayer({
150    id: 'selected-trail-line',
151    type: 'line',
152    source: 'selected-trail',
153    paint: {
154      'line-color': '#0097e6',
155      'line-width': 4,
156      'line-opacity': 0.9
157    },
158    layout: { 'line-cap': 'round', 'line-join': 'round' }
159  });
160}
161
162// ─── Trail Detail Panel ───
163
164export async function openTrailPanel(routeId, name) {
165  const panel = document.getElementById('detail-panel');
166  const title = document.getElementById('detail-panel-title');
167  const body = document.getElementById('detail-panel-body');
168  if (!panel || !title || !body) return;
169
170  // Highlight trail on map
171  highlightTrail(routeId);
172
173  // Show panel with loading
174  title.textContent = name;
175  body.innerHTML = `<div style="display:flex;align-items:center;gap:8px;padding:20px 0;color:#64748b;font-size:13px;">
176    <div class="loading-spinner"></div> Loading trail details...
177  </div>`;
178  panel.classList.add('visible');
179
180  // Check cache
181  if (trailCache[routeId]) {
182    populateTrailPanel(trailCache[routeId]);
183    return;
184  }
185
186  try {
187    const doc = await db.collection('osmRoutes').doc(routeId).get();
188    if (!doc.exists) {
189      body.innerHTML = '<div style="padding:20px 0;color:#ef4444;font-size:13px;">Route not found</div>';
190      return;
191    }
192
193    const data = doc.data();
194    // Compute unpaved percentage from segment polylines
195    let unpavedPct = 0;
196    const totalM = data.totalLengthM || 0;
197    if (totalM > 0 && Array.isArray(data.unpavedSegments) && data.unpavedSegments.length > 0) {
198      let unpavedM = 0;
199      for (const seg of data.unpavedSegments) {
200        if (!seg.p) continue;
201        const coords = decodePolyline(seg.p);
202        for (let i = 1; i < coords.length; i++) {
203          unpavedM += haversine(coords[i - 1], coords[i]);
204        }
205      }
206      unpavedPct = Math.round((unpavedM / totalM) * 100);
207    }
208
209    const trailData = {
210      name: data.name || name,
211      routeType: data.routeType || '',
212      network: data.network || '',
213      category: data.category || '',
214      totalLengthM: totalM,
215      description: data.masterRouteDescription || data.description || '',
216      shortCode: data.shieldText || data.ref || '',
217      unpavedPct,
218    };
219
220    trailCache[routeId] = trailData;
221    populateTrailPanel(trailData);
222  } catch (err) {
223    console.error('[v2] Error loading trail:', err);
224    body.innerHTML = '<div style="padding:20px 0;color:#ef4444;font-size:13px;">Error loading trail details</div>';
225  }
226}
227
228function populateTrailPanel(data) {
229  const title = document.getElementById('detail-panel-title');
230  const body = document.getElementById('detail-panel-body');
231  if (!title || !body) return;
232
233  title.textContent = data.name;
234
235  const distanceKm = data.totalLengthM ? (data.totalLengthM / 1000) : 0;
236  const distanceStr = distanceKm >= 10 ? Math.round(distanceKm) + ' km' : distanceKm.toFixed(1) + ' km';
237
238  let html = '';
239
240  // Stats badges
241  html += `<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px;">`;
242
243  if (distanceKm > 0) {
244    html += `<div style="display:inline-flex;align-items:center;gap:6px;padding:5px 12px;background:#f1f5f9;border-radius:8px;">
245      <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="#64748b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg>
246      <span style="font-size:13px;font-weight:600;color:#334155;">${distanceStr}</span>
247    </div>`;
248  }
249
250  if (data.shortCode) {
251    html += `<div style="display:inline-flex;align-items:center;gap:6px;padding:5px 12px;background:#dbeafe;border-radius:8px;">
252      <span style="font-size:13px;font-weight:700;color:#1d4ed8;">${data.shortCode}</span>
253    </div>`;
254  }
255
256  if (data.unpavedPct > 0) {
257    html += `<div style="display:inline-flex;align-items:center;gap:6px;padding:5px 12px;background:#fef3c7;border-radius:8px;">
258      <span style="font-size:13px;font-weight:500;color:#92400e;">${data.unpavedPct}% unpaved</span>
259    </div>`;
260  }
261
262  html += `</div>`;
263
264  // Description — split into accordion sections
265  if (data.description) {
266    console.log('[Trail Panel] Raw description:', data.description.substring(0, 200));
267    const sections = parseDescriptionSections(data.description);
268    console.log('[Trail Panel] Parsed sections:', sections.length, sections.map(s => s.title));
269    if (sections.length > 0) {
270      for (let i = 0; i < sections.length; i++) {
271        const s = sections[i];
272        const isOpen = i === 0 ? ' open' : '';
273        const icon = getSectionIcon(s.title);
274        html += `<div class="trail-accordion${isOpen}" data-trail-acc="${i}">
275          <button class="trail-accordion-toggle">
276            <span class="trail-accordion-label">
277              ${icon}
278              <span>${s.title}</span>
279            </span>
280            <svg class="trail-accordion-arrow" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
281          </button>
282          <div class="trail-accordion-content">
283            <div style="font-size:13px;color:#4b5563;line-height:1.7;padding:8px 16px 12px;">${formatDescription(s.body)}</div>
284          </div>
285        </div>`;
286      }
287    } else {
288      html += `<div style="margin-bottom:16px;">
289        <div style="font-size:13px;color:#4b5563;line-height:1.7;">${formatDescription(data.description)}</div>
290      </div>`;
291    }
292  }
293
294  body.innerHTML = html;
295
296  // Wire accordion toggles
297  body.querySelectorAll('.trail-accordion-toggle').forEach(btn => {
298    btn.addEventListener('click', () => {
299      const acc = btn.closest('.trail-accordion');
300      acc.classList.toggle('open');
301    });
302  });
303}
304
305function parseDescriptionSections(desc) {
306  if (!desc) return [];
307
308  // Normalize: convert <br> tags to newlines, then split
309  // Handle both <br> and <br/> and <br />
310  const normalized = desc.replace(/<br\s*\/?>/gi, '\n');
311  const lines = normalized.split('\n');
312  const sections = [];
313  let current = null;
314  let preamble = '';
315
316  console.log('[Trail Panel] Parsing description, total lines:', lines.length);
317  for (const line of lines) {
318    // Match headings in multiple formats:
319    // 1. <b>Heading</b> or <strong>Heading</strong> on its own line
320    // 2. ## Heading (markdown)
321    // 3. **Heading** (markdown)
322    const htmlMatch = line.match(/^<(b|strong)>([^<]+)<\/(b|strong)>\s*$/i);
323    const mdMatch = line.match(/^##\s+(.+)/) || line.match(/^\*\*([^*]+)\*\*\s*$/);
324    const m = htmlMatch || mdMatch;
325
326    if (m) {
327      let title = htmlMatch ? m[2] : m[1];
328      // Decode HTML entities if extracted from HTML tags
329      if (htmlMatch) {
330        title = decodeHtmlEntities(title);
331      }
332      console.log('[Trail Panel] Matched heading:', title);
333      if (current) sections.push(current);
334      current = { title: title.trim(), body: '' };
335    } else if (current) {
336      current.body += line + '\n';
337    } else {
338      preamble += line + '\n';
339    }
340  }
341  if (current) sections.push(current);
342
343  // If we found sections and there's a preamble, add it as "Overview"
344  if (sections.length > 0 && preamble.trim()) {
345    sections.unshift({ title: 'Overview', body: preamble.trim() });
346  }
347
348  // Strip trailing whitespace from bodies
349  for (const s of sections) s.body = s.body.trim();
350  // Filter out empty sections
351  return sections.filter(s => s.body.length > 0);
352}
353
354function getSectionIcon(title) {
355  const t = title.toLowerCase();
356  const s = (d) => `<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="#64748b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${d}</svg>`;
357  if (t.includes('overview'))    return s('<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>');
358  if (t.includes('location') || t.includes('geography'))  return s('<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/>');
359  if (t.includes('terrain') || t.includes('landscape'))   return s('<path d="M8 3l4 8 5-5 5 15H2L8 3z"/>');
360  if (t.includes('logistics') || t.includes('getting'))   return s('<rect x="1" y="3" width="15" height="13"/><polygon points="16 8 20 8 23 11 23 16 16 16 16 8"/><circle cx="5.5" cy="18.5" r="2.5"/><circle cx="18.5" cy="18.5" r="2.5"/>');
361  if (t.includes('safety') || t.includes('hazard'))        return s('<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>');
362  if (t.includes('history') || t.includes('cultural'))     return s('<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>');
363  if (t.includes('season') || t.includes('weather'))       return s('<path d="M17 18a5 5 0 0 0-10 0"/><line x1="12" y1="9" x2="12" y2="2"/><line x1="4.22" y1="10.22" x2="5.64" y2="11.64"/><line x1="1" y1="18" x2="3" y2="18"/><line x1="21" y1="18" x2="23" y2="18"/><line x1="18.36" y1="11.64" x2="19.78" y2="10.22"/><line x1="23" y1="22" x2="1" y2="22"/><polyline points="16 5 12 9 8 5"/>');
364  if (t.includes('wildlife') || t.includes('nature'))      return s('<path d="M18 8h1a4 4 0 0 1 0 8h-1"/><path d="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z"/><line x1="6" y1="1" x2="6" y2="4"/><line x1="10" y1="1" x2="10" y2="4"/><line x1="14" y1="1" x2="14" y2="4"/>');
365  // Default icon — document
366  return s('<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/>');
367}
368
369function decodeHtmlEntities(str) {
370  const textarea = document.createElement('textarea');
371  textarea.innerHTML = str;
372  return textarea.value;
373}
374
375
376function formatDescription(desc) {
377  if (!desc) return '';
378  // Convert markdown bold **text** to <strong>
379  desc = desc.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
380  // Convert markdown bullet lists
381  desc = desc.replace(/^- (.+)$/gm, '<li>$1</li>');
382  desc = desc.replace(/(<li>.*<\/li>\n?)+/gs, (match) => `<ul style="margin:6px 0;padding-left:20px;">${match}</ul>`);
383  if (desc.includes('<') && desc.includes('>') && !desc.includes('<li>')) {
384    if (typeof DOMPurify !== 'undefined') return DOMPurify.sanitize(desc);
385    return escapeHtml(desc);
386  }
387  const result = desc.split('\n\n')
388    .map(p => {
389      p = p.trim();
390      if (!p || p.startsWith('<ul')) return p;
391      return `<p style="margin:0 0 10px;">${p.replace(/\n/g, '<br>')}</p>`;
392    })
393    .join('');
394  if (typeof DOMPurify !== 'undefined') return DOMPurify.sanitize(result);
395  return escapeHtml(result);
396}
397
398// ─── Trail Highlight ───
399
400function highlightTrail(routeId) {
401  const map = getMap();
402  if (!map) return;
403
404  clearTrailHighlight();
405  selectedTrailId = routeId;
406
407  const allFeatures = getCachedCyclingFeatures();
408  const routeFeatures = allFeatures.filter(f => f.properties.routeId === routeId);
409
410  if (routeFeatures.length === 0) return;
411
412  if (map.getSource('selected-trail')) {
413    map.getSource('selected-trail').setData({
414      type: 'FeatureCollection',
415      features: routeFeatures
416    });
417  }
418}
419
420function clearTrailHighlight() {
421  const map = getMap();
422  if (!map) return;
423
424  selectedTrailId = null;
425
426  if (map.getSource('selected-trail')) {
427    map.getSource('selected-trail').setData({ type: 'FeatureCollection', features: [] });
428  }
429}
430
431function closeTrailPanel() {
432  const panel = document.getElementById('detail-panel');
433  if (panel) panel.classList.remove('visible');
434  clearTrailHighlight();
435}
436
437// ─── Helpers ───
438
439function decodePolyline(encoded) {
440  const coords = [];
441  let index = 0, lat = 0, lng = 0;
442  while (index < encoded.length) {
443    let b, shift = 0, result = 0;
444    do { b = encoded.charCodeAt(index++) - 63; result |= (b & 0x1f) << shift; shift += 5; } while (b >= 0x20);
445    lat += (result & 1) ? ~(result >> 1) : (result >> 1);
446    shift = 0; result = 0;
447    do { b = encoded.charCodeAt(index++) - 63; result |= (b & 0x1f) << shift; shift += 5; } while (b >= 0x20);
448    lng += (result & 1) ? ~(result >> 1) : (result >> 1);
449    coords.push([lng / 1e5, lat / 1e5]);
450  }
451  return coords;
452}
453

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.