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.