1/** 2 * Route Publish â publishes a saved route to the libraryRoutes collection. 3 * Transforms route builder data into the optimised schema with gzip-compressed geometry. 4 */ 5 6import { store } from '../core/state.js?v=284'; 7import { getWaypoints } from '../routing/waypoints.js?v=284'; 8import { eventBus } from '../core/event-bus.js?v=284'; 9import { haversine } from '../core/utils.js?v=284'; 10import { getCurrentSavedRouteId } from './route-save.js?v=284'; 11import { getPinnedPOIs } from '../poi/poi-manager.js?v=284'; 12import { getCustomPOIs, getCustomIconClass } from '../poi/custom-pois.js?v=284'; 13 14function getDb() { 15 try { return firebase.firestore(); } catch (e) { return null; } 16} 17 18function getStorage() { 19 try { return firebase.storage(); } catch (e) { return null; } 20} 21 22async function fetchOrgAvatarSnapshot(db, orgId) { 23 if (!orgId) return null; 24 try { 25 const orgDoc = await db.collection('organisations').doc(orgId).get(); 26 if (!orgDoc.exists) return null; 27 const od = orgDoc.data() || {}; 28 const av = od.avatar || null; 29 if (!av) return null; 30 const mode = av.mode || 'text'; 31 return { 32 mode, 33 color1: av.color1 || '#3b82f6', 34 color2: av.color2 || undefined, 35 stroke: av.stroke || undefined, 36 line1: av.line1 || '', 37 line2: av.line2 || '', 38 bigLine: av.bigLine || 1, 39 logoURL: mode === 'image' ? (od.logoURL || undefined) : undefined, 40 imageBg: av.imageBg || undefined, 41 imageScale: av.imageScale || undefined, 42 }; 43 } catch { 44 return null; 45 } 46} 47 48/** Gzip compress a string and return base64-encoded result */ 49async function gzipToBase64(str) { 50 const encoder = new TextEncoder(); 51 const data = encoder.encode(str); 52 const cs = new CompressionStream('gzip'); 53 const writer = cs.writable.getWriter(); 54 writer.write(data); 55 writer.close(); 56 const reader = cs.readable.getReader(); 57 const chunks = []; 58 while (true) { 59 const { done, value } = await reader.read(); 60 if (done) break; 61 chunks.push(value); 62 } 63 const totalLen = chunks.reduce((s, c) => s + c.length, 0); 64 const result = new Uint8Array(totalLen); 65 let offset = 0; 66 for (const chunk of chunks) { result.set(chunk, offset); offset += chunk.length; } 67 let binary = ''; 68 for (let i = 0; i < result.length; i++) binary += String.fromCharCode(result[i]); 69 return btoa(binary); 70} 71 72async function prewarmPublishedRoute(routeId) { 73 try { 74 const token = await firebase.auth().currentUser?.getIdToken(); 75 if (!token) { 76 console.warn('[Publish] Skipping route prewarm; no auth token available'); 77 return; 78 } 79 80 const response = await fetch('/api/prewarm-route', { 81 method: 'POST', 82 credentials: 'same-origin', 83 headers: { 84 'Content-Type': 'application/json', 85 'Authorization': `Bearer ${token}`, 86 }, 87 body: JSON.stringify({ routeId }), 88 }); 89 90 if (!response.ok) { 91 console.warn('[Publish] Route prewarm failed:', response.status); 92 } 93 } catch (error) { 94 console.warn('[Publish] Route prewarm request failed:', error); 95 } 96} 97 98async function uploadPublicRoutePayloads(routeId, detailDoc, mapDoc) { 99 const storage = getStorage(); 100 if (!storage) { 101 console.warn('[Publish] Firebase Storage not available; skipping public route payload upload'); 102 return; 103 } 104 105 const detailRef = storage.ref(`routes/${routeId}/segments/detail.json`); 106 const mapRef = storage.ref(`routes/${routeId}/segments/map.json`); 107 108 await Promise.all([ 109 detailRef.putString(JSON.stringify(detailDoc), 'raw', { contentType: 'application/json' }), 110 mapRef.putString(JSON.stringify(mapDoc), 'raw', { contentType: 'application/json' }), 111 ]); 112} 113 114async function uploadStaticRouteHtml(routeId, html) { 115 const storage = getStorage(); 116 if (!storage) { 117 console.warn('[Publish] Firebase Storage not available; skipping static route HTML upload'); 118 return; 119 } 120 121 const htmlRef = storage.ref(`routes/${routeId}/segments/index.html`); 122 await htmlRef.putString(html, 'raw', { 123 contentType: 'text/html; charset=utf-8', 124 cacheControl: 'public,max-age=900', 125 }); 126} 127 128function removeUndefinedDeep(value) { 129 if (Array.isArray(value)) { 130 return value.map(removeUndefinedDeep); 131 } 132 if (value && typeof value === 'object') { 133 // Preserve Firestore Timestamp/FieldValue, GeoPoint and other SDK values. 134 const constructorName = Object.getPrototypeOf(value)?.constructor?.name; 135 if (constructorName && constructorName !== 'Object') return value; 136 return Object.fromEntries( 137 Object.entries(value) 138 .filter(([, v]) => v !== undefined) 139 .map(([k, v]) => [k, removeUndefinedDeep(v)]) 140 ); 141 } 142 return value; 143} 144 145// âââ Publish Modal âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 146 147export function initRoutePublish() { 148 const publishBtn = document.getElementById('publish-btn'); 149 if (!publishBtn) return; 150 publishBtn.addEventListener('click', showPublishModal); 151 152 // Update button â direct update, no modal 153 const updateBtn = document.getElementById('update-published-btn'); 154 if (updateBtn) { 155 updateBtn.addEventListener('click', handleUpdatePublished); 156 } 157 158 // Show Update button when editing a library route
159 store.subscribe('editingLibraryRouteId', (id) => { 160 if (updateBtn) { 161 updateBtn.style.display = id ? '' : 'none'; 162 } 163 }); 164} 165 166async function handleUpdatePublished() { 167 const user = store.get('currentUser'); 168 if (!user) { alert('Please sign in.'); return; } 169 170 const routeData = store.get('_lastRouteData'); 171 if (!routeData) { alert('No route data. Build a route first.'); return; } 172 173 const libraryRouteId = store.get('editingLibraryRouteId'); 174 if (!libraryRouteId) { alert('No published route to update.'); return; } 175 176 const routeName = document.getElementById('route-title')?.textContent || 'Untitled Route'; 177 const savedRouteId = getCurrentSavedRouteId() || ''; 178 179 const btn = document.getElementById('update-published-btn'); 180 if (btn) { btn.disabled = true; btn.querySelector('span').textContent = 'Updating...'; } 181 182 try { 183 await updatePublishedRoute(user, routeData, savedRouteId, routeName, libraryRouteId); 184 alert('Published route updated!'); 185 } catch (err) { 186 console.error('[Publish] Update failed:', err); 187 alert('Failed to update: ' + err.message); 188 } finally { 189 if (btn) { btn.disabled = false; btn.querySelector('span').textContent = 'Update'; } 190 } 191} 192 193async function showPublishModal() { 194 const user = store.get('currentUser'); 195 if (!user) { 196 alert('Please sign in to publish routes.'); 197 return; 198 } 199 200 const routeData = store.get('_lastRouteData'); 201 if (!routeData) { 202 alert('No route to publish. Build and save a route first.'); 203 return; 204 } 205 206 const savedRouteId = getCurrentSavedRouteId(); 207 if (!savedRouteId) { 208 alert('Please save your route first before publishing.'); 209 return; 210 } 211 212 const editingLibraryRouteId = store.get('editingLibraryRouteId'); 213 const isUpdate = !!editingLibraryRouteId; 214 215 // Fetch groups the user can publish to (via org memberships) 216 const db = getDb(); 217 if (!db) return; 218 219 let groups = []; 220 let existingGroupSlug = ''; 221 const isSiteAdmin = store.get('isAdmin'); 222 const userOrgs = (store.get('userOrgs') || []).filter(o => o.role === 'owner' || o.role === 'admin' || o.role === 'publisher'); 223 224 try { 225 // If updating, fetch the existing library route to get its group 226 if (isUpdate) { 227 const existingDoc = await db.collection('libraryRoutes').doc(editingLibraryRouteId).get(); 228 if (existingDoc.exists) { 229 existingGroupSlug = existingDoc.data().groupSlug || ''; 230 } 231 } 232 233 // Fetch library groups 234 const groupsSnap = await db.collection('routeLibraryGroups').orderBy('order').get(); 235 const allGroups = [];
236 groupsSnap.forEach(doc => { 237 const d = doc.data(); 238 if (d.visible === false) return; 239 allGroups.push({ 240 id: doc.id, 241 name: d.name || '', 242 slug: d.slug || doc.id, 243 orgId: d.orgId || null, 244 }); 245 }); 246 247 if (isSiteAdmin) { 248 groups = allGroups; 249 } else { 250 const userOrgGroupSlugs = new Set(userOrgs.map(o => o.libraryGroupSlug).filter(Boolean)); 251 groups = allGroups.filter(g => userOrgGroupSlugs.has(g.slug)); 252 } 253 } catch (e) { 254 console.error('[Publish] Failed to load groups:', e); 255 } 256 257 // Build modal 258 let modal = document.getElementById('publish-modal'); 259 if (!modal) { 260 modal = document.createElement('div'); 261 modal.id = 'publish-modal'; 262 modal.className = 'modal-overlay hidden'; 263 document.body.appendChild(modal); 264 } 265 266 const routeName = document.getElementById('route-title')?.textContent || 'Untitled Route'; 267 268 modal.innerHTML = ` 269 <div class="modal-box" style="max-width:420px"> 270 <h3 style="margin:0 0 12px;font-size:16px;font-weight:600;color:#1e293b">${isUpdate ? 'Update Published Route' : 'Publish to Library'}</h3> 271 <p style="margin:0 0 16px;font-size:13px;color:#64748b"> 272 ${isUpdate 273 ? `This will update <strong>${routeName}</strong> in the route library.` 274 : `This will publish <strong>${routeName}</strong> to the route library for all users.` 275 } 276 </p> 277 ${isUpdate ? '' : ` 278 <div style="margin-bottom:12px"> 279 <label style="display:block;font-size:12px;font-weight:500;color:#475569;margin-bottom:4px">Group</label> 280 <select id="publish-group-select" style="width:100%;padding:8px 10px;border:1px solid #d1d5db;border-radius:6px;font-size:14px;color:#1e293b"> 281 <option value="">Select a group...</option> 282 ${groups.map(g => `<option value="${g.slug}" data-name="${g.name}">${g.name}</option>`).join('')} 283 </select> 284 </div> 285 `} 286 <div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px"> 287 <button id="publish-modal-cancel" style="padding:8px 16px;border:1px solid #d1d5db;border-radius:6px;background:#fff;color:#475569;font-size:13px;font-weight:500;cursor:pointer">Cancel</button> 288 <button id="publish-modal-confirm" style="padding:8px 16px;border:none;border-radius:6px;background:${isUpdate ? '#2563eb' : '#059669'};color:#fff;font-size:13px;font-weight:500;cursor:pointer">${isUpdate ? 'Update' : 'Publish'}</button> 289 </div> 290 </div> 291 `; 292 293 modal.classList.remove('hidden'); 294 295 modal.addEventListener('click', (e) => { if (e.target === modal) modal.classList.add('hidden'); }); 296 modal.querySelector('#publish-modal-cancel').addEventListener('click', () => modal.classList.add('hidden')); 297 modal.querySelector('#publish-modal-confirm').addEventListener('click', async () => { 298 let groupSlug, groupName; 299 300 if (isUpdate) { 301 groupSlug = existingGroupSlug; 302 groupName = ''; 303 } else { 304 const select = modal.querySelector('#publish-group-select'); 305 groupSlug = select.value; 306 groupName = select.selectedOptions[0]?.dataset.name || ''; 307 if (!groupSlug) { 308 alert('Please select a group.'); 309 return; 310 } 311 } 312 313 const confirmBtn = modal.querySelector('#publish-modal-confirm'); 314 confirmBtn.disabled = true; 315 confirmBtn.textContent = isUpdate ? 'Updating...' : 'Publishing...'; 316 317 try { 318 if (isUpdate) { 319 await updatePublishedRoute(user, routeData, savedRouteId, routeName, editingLibraryRouteId); 320 } else { 321 await publishRoute(user, routeData, savedRouteId, routeName, groupSlug, groupName); 322 } 323 modal.classList.add('hidden'); 324 alert(isUpdate ? 'Published route updated!' : 'Route published to library!'); 325 } catch (err) { 326 console.error('[Publish] Failed:', err); 327 alert('Failed: ' + err.message); 328 } finally { 329 confirmBtn.disabled = false; 330 confirmBtn.textContent = isUpdate ? 'Update' : 'Publish'; 331 } 332 }); 333} 334 335// âââ Publish Logic âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 336 337const UNPAVED_SURFACES = new Set([
338 'unpaved', 'gravel', 'dirt', 'fine_gravel', 'compacted', 339 'ground', 'earth', 'sand', 'mud', 'grass', 'wood', 340 'woodchips', 'pebblestone', 'rock', 341]); 342 343async function publishRoute(user, routeData, sourceRouteId, routeName, groupSlug, groupName) { 344 const db = getDb(); 345 if (!db) throw new Error('Firestore not available'); 346 347 const coords = routeData.coordinates || []; 348 if (coords.length < 2) throw new Error('Route has no coordinates'); 349 350 const routeId = `lib_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; 351 352 // Calculate bounds 353 let minLng = Infinity, maxLng = -Infinity, minLat = Infinity, maxLat = -Infinity; 354 for (const c of coords) { 355 const lng = c[0], lat = c[1]; 356 if (lng < minLng) minLng = lng; 357 if (lng > maxLng) maxLng = lng; 358 if (lat < minLat) minLat = lat; 359 if (lat > maxLat) maxLat = lat; 360 } 361 362 // Calculate unpaved percent from surface details 363 let unpavedPercent = 0; 364 const surfaceDetails = routeData.surfaceDetails || []; 365 if (surfaceDetails.length > 0 && coords.length > 1 && routeData.distance > 0) { 366 let unpavedDist = 0; 367 for (const [start, end, surface] of surfaceDetails) { 368 if (UNPAVED_SURFACES.has(surface)) { 369 for (let i = start; i < end && i < coords.length - 1; i++) { 370 unpavedDist += haversine(coords[i], coords[i + 1]); 371 } 372 } 373 } 374 unpavedPercent = Math.round((unpavedDist / routeData.distance) * 100); 375 } 376 377 // Build surface breakdown 378 const surfaceBreakdown = buildBreakdown(surfaceDetails, coords, routeData.distance); 379 380 // Build highway breakdown 381 const highwayBreakdown = buildBreakdown(routeData.roadClassDetails || [], coords, routeData.distance); 382 383 // âââ Main document (~1KB) âââ 384 const mainDoc = { 385 name: routeName, 386 distanceMeters: Math.round(routeData.distance || 0), 387 elevationGain: Math.round(routeData.ascend || 0), 388 elevationLoss: Math.round(routeData.descend || 0), 389 unpavedPercent, 390 routeType: 'route', 391 bounds: { 392 southwest: { lat: minLat, lng: minLng }, 393 northeast: { lat: maxLat, lng: maxLng }, 394 }, 395 groupSlug, 396 groupName, 397 orgId: (() => { 398 const userOrgs = (store.get('userOrgs') || []).filter(o => o.role === 'owner' || o.role === 'admin' || o.role === 'publisher'); 399 const org = userOrgs.find(o => o.libraryGroupSlug === groupSlug); 400 return org ? org.orgId : null; 401 })(), 402 order: 0, 403 featured: false, 404 descriptionHTML: '', 405 surfaceBreakdown, 406 highwayBreakdown, 407 usedBikePaths: routeData.usedBikePaths || [], 408 routeBreakdown: routeData.breakdown || [], 409 poiCount: 0, 410 photoCount: 0, 411 thumbnailURL: '', 412 sourceRouteId, 413 description: document.getElementById('route-description-text')?.value?.trim() || '', 414 ownerId: user.uid, 415 ownerDisplayName: user.displayName || '', 416 status: 'published', 417 publishedAt: firebase.firestore.FieldValue.serverTimestamp(), 418 updatedAt: firebase.firestore.FieldValue.serverTimestamp(), 419 }; 420 421 // âââ Geometry document (gzip-compressed) âââ 422 // Coordinates as [[lng, lat], ...] 423 const rawCoords = coords.map(c => c.slice()); 424 const compressedCoords = await gzipToBase64(JSON.stringify(rawCoords)); 425 426 // Highway segments: convert roadClassDetails ranges to index-based segments 427 const highwaySegments = (routeData.roadClassDetails || []).map(([start, end, type]) => ({ 428 type, 429 startIndex: start, 430 endIndex: end, 431 })); 432 433 // Unpaved segments: extract from surfaceDetails 434 const unpavedSegments = []; 435 for (const [start, end, surface] of surfaceDetails) { 436 if (UNPAVED_SURFACES.has(surface)) { 437 unpavedSegments.push({ startIndex: start, endIndex: end }); 438 } 439 } 440 441 // Compress per-coordinate arrays 442 const roadClassDetails = routeData.roadClassDetails || []; 443 const perCoordHighways = buildPerCoordArray(roadClassDetails, coords.length, 'cycleway'); 444 const perCoordSurfaces = buildPerCoordArray(surfaceDetails, coords.length, 'asphalt'); 445 446 // Elevation profile â build from routeData 447 const elevationProfile = buildElevationProfile(coords, routeData); 448 449 // Compress all fields in parallel 450 const [compressedHighways, compressedSurfaces, compressedElevation] = await Promise.all([ 451 gzipToBase64(JSON.stringify(perCoordHighways)), 452 gzipToBase64(JSON.stringify(perCoordSurfaces)), 453 gzipToBase64(JSON.stringify(elevationProfile)), 454 ]); 455
456 // Bike infrastructure segments 457 const bikeInfraSegments = (routeData.bikeInfraDetails || []).map(([start, end, type]) => ({ 458 type, startIndex: start, endIndex: end, 459 })); 460 const bikePathAccessSegments = (routeData.bikePathAccessDetails || []).map(([start, end, type]) => ({ 461 type, startIndex: start, endIndex: end, 462 })); 463 464 const geometryDoc = { 465 ...buildPublishedRouteMetadata(routeData), 466 coordinates: compressedCoords, 467 highwaySegments, 468 unpavedSegments, 469 bikeInfraSegments, 470 bikePathAccessSegments, 471 elevationProfile: compressedElevation, 472 highways: compressedHighways, 473 surfaces: compressedSurfaces, 474 compressionType: 'gzip', 475 }; 476 477 // âââ Collect POIs âââ 478 const pinnedPOIs = getPinnedPOIs() || []; 479 const customPOIs = getCustomPOIs() || []; 480 const allPOIs = []; 481 482 for (const p of pinnedPOIs) { 483 allPOIs.push({ 484 name: p.name || '', 485 type: p.categoryId || 'info', 486 categoryId: p.categoryId || null, 487 coordinates: { lng: p.lng, lat: p.lat }, 488 source: 'pinned', 489 color: p.color || null, 490 faIcon: p.faIcon || null, 491 }); 492 } 493 for (const p of customPOIs) { 494 allPOIs.push({ 495 name: p.name || '', 496 type: p.iconId || 'custom', 497 coordinates: { lng: p.lng, lat: p.lat }, 498 source: 'custom', 499 color: p.color || null, 500 faIcon: p.faIcon || getCustomIconClass(p.iconId), 501 description: p.description || '', 502 }); 503 } 504 505 mainDoc.poiCount = allPOIs.length; 506 const orgAvatar = await fetchOrgAvatarSnapshot(db, mainDoc.orgId); 507 const detailDoc = removeUndefinedDeep(buildRouteDetailDoc(routeId, mainDoc, allPOIs, coords, orgAvatar)); 508 const mapDoc = removeUndefinedDeep(buildRouteMapDoc(routeId, mainDoc, allPOIs, coords, elevationProfile, perCoordHighways, perCoordSurfaces, routeData)); 509 510 await commitLibraryRoute(db, routeId, mainDoc, geometryDoc, detailDoc, mapDoc, allPOIs); 511 await refreshLibraryRouteAssets(routeId, detailDoc, mapDoc); 512 513 console.log('[Publish] Route published:', routeId); 514 void prewarmPublishedRoute(routeId); 515 return routeId; 516} 517 518// âââ Update an existing published route âââ 519 520export async function updatePublishedRoute(user, routeData, sourceRouteId, routeName, libraryRouteId) { 521 const db = getDb(); 522 if (!db) throw new Error('Firestore not available'); 523 524 const coords = routeData.coordinates || []; 525 if (coords.length < 2) throw new Error('Route has no coordinates'); 526 527 // Calculate bounds 528 let minLng = Infinity, maxLng = -Infinity, minLat = Infinity, maxLat = -Infinity; 529 for (const c of coords) { 530 const lng = c[0], lat = c[1]; 531 if (lng < minLng) minLng = lng; 532 if (lng > maxLng) maxLng = lng; 533 if (lat < minLat) minLat = lat; 534 if (lat > maxLat) maxLat = lat; 535 } 536 537 // Calculate unpaved percent 538 let unpavedPercent = 0; 539 const surfaceDetails = routeData.surfaceDetails || []; 540 if (surfaceDetails.length > 0 && coords.length > 1 && routeData.distance > 0) { 541 let unpavedDist = 0; 542 for (const [start, end, surface] of surfaceDetails) { 543 if (UNPAVED_SURFACES.has(surface)) { 544 for (let i = start; i < end && i < coords.length - 1; i++) { 545 unpavedDist += haversine(coords[i], coords[i + 1]); 546 } 547 } 548 } 549 unpavedPercent = Math.round((unpavedDist / routeData.distance) * 100); 550 } 551 552 const surfaceBreakdown = buildBreakdown(surfaceDetails, coords, routeData.distance); 553 const highwayBreakdown = buildBreakdown(routeData.roadClassDetails || [], coords, routeData.distance); 554 555 // Update main document (merge to preserve fields like groupSlug, publishedAt) 556 const updateData = { 557 name: routeName, 558 distanceMeters: Math.round(routeData.distance || 0), 559 elevationGain: Math.round(routeData.ascend || 0), 560 elevationLoss: Math.round(routeData.descend || 0), 561 unpavedPercent, 562 bounds: { 563 southwest: { lat: minLat, lng: minLng }, 564 northeast: { lat: maxLat, lng: maxLng }, 565 }, 566 surfaceBreakdown, 567 highwayBreakdown, 568 usedBikePaths: routeData.usedBikePaths || [], 569 routeBreakdown: routeData.breakdown || [], 570 sourceRouteId, 571 description: document.getElementById('route-description-text')?.value?.trim() || '', 572 updatedAt: firebase.firestore.FieldValue.serverTimestamp(), 573 }; 574 575 // Rebuild geometry 576 const rawCoords = coords.map(c => c.slice()); 577 const compressedCoords = await gzipToBase64(JSON.stringify(rawCoords)); 578 579 const highwaySegments = (routeData.roadClassDetails || []).map(([start, end, type]) => ({ 580 type, startIndex: start, endIndex: end, 581 })); 582 583 const unpavedSegments = []; 584 for (const [start, end, surface] of surfaceDetails) { 585 if (UNPAVED_SURFACES.has(surface)) { 586 unpavedSegments.push({ startIndex: start, endIndex: end }); 587 } 588 } 589 590 const perCoordHighways = buildPerCoordArray(routeData.roadClassDetails || [], coords.length, 'cycleway'); 591 const perCoordSurfaces = buildPerCoordArray(surfaceDetails, coords.length, 'asphalt'); 592 const elevationProfile = buildElevationProfile(coords, routeData); 593 594 const [compressedHighways2, compressedSurfaces2, compressedElevation2] = await Promise.all([ 595 gzipToBase64(JSON.stringify(perCoordHighways)), 596 gzipToBase64(JSON.stringify(perCoordSurfaces)), 597 gzipToBase64(JSON.stringify(elevationProfile)), 598 ]); 599
600 // Bike infrastructure segments 601 const bikeInfraSegments = (routeData.bikeInfraDetails || []).map(([start, end, type]) => ({ 602 type, startIndex: start, endIndex: end, 603 })); 604 const bikePathAccessSegments = (routeData.bikePathAccessDetails || []).map(([start, end, type]) => ({ 605 type, startIndex: start, endIndex: end, 606 })); 607 608 const geometryDoc = { 609 ...buildPublishedRouteMetadata(routeData), 610 coordinates: compressedCoords, 611 highwaySegments, 612 unpavedSegments, 613 bikeInfraSegments, 614 bikePathAccessSegments, 615 elevationProfile: compressedElevation2, 616 highways: compressedHighways2, 617 surfaces: compressedSurfaces2, 618 compressionType: 'gzip', 619 }; 620 621 // Collect POIs 622 const pinnedPOIs = getPinnedPOIs() || []; 623 const customPOIs = getCustomPOIs() || []; 624 const allPOIs = []; 625 626 for (const p of pinnedPOIs) { 627 allPOIs.push({ 628 name: p.name || '', 629 type: p.categoryId || 'info', 630 categoryId: p.categoryId || null, 631 coordinates: { lng: p.lng, lat: p.lat }, 632 source: 'pinned', 633 color: p.color || null, 634 faIcon: p.faIcon || null, 635 }); 636 } 637 for (const p of customPOIs) { 638 allPOIs.push({ 639 name: p.name || '', 640 type: p.iconId || 'custom', 641 coordinates: { lng: p.lng, lat: p.lat }, 642 source: 'custom', 643 color: p.color || null, 644 faIcon: p.faIcon || `fa-solid fa-${p.iconId || 'map-pin'}`, 645 description: p.description || '', 646 }); 647 } 648 649 updateData.poiCount = allPOIs.length; 650 651 // Write updates 652 console.log('[Publish] Updating main doc:', libraryRouteId); 653 const existingDoc = await db.collection('libraryRoutes').doc(libraryRouteId).get(); 654 const existingData = existingDoc.exists ? existingDoc.data() : {}; 655 const existingGroupSlug = existingData?.groupSlug || ''; 656 const existingGroupName = existingData?.groupName || ''; 657 const existingOrgId = existingData?.orgId || null; 658 const existingPhotoCount = existingData?.photoCount || 0; 659 const existingThumbnailURL = existingData?.thumbnailURL || ''; 660 const existingPublishedAt = existingData?.publishedAt || null; 661 const existingDescriptionHTML = existingData?.descriptionHTML || ''; 662 const orgAvatar = await fetchOrgAvatarSnapshot(db, existingOrgId); 663 const detailDoc = removeUndefinedDeep(buildRouteDetailDoc(libraryRouteId, { 664 ...updateData, 665 groupSlug: existingGroupSlug, 666 groupName: existingGroupName, 667 orgId: existingOrgId, 668 photoCount: existingPhotoCount, 669 thumbnailURL: existingThumbnailURL, 670 publishedAt: existingPublishedAt, 671 descriptionHTML: existingDescriptionHTML, 672 routeType: 'route', 673 }, allPOIs, coords, orgAvatar)); 674 const mapDoc = removeUndefinedDeep(buildRouteMapDoc(libraryRouteId, { 675 ...updateData, 676 groupName: existingGroupName, 677 publishedAt: existingPublishedAt, 678 name: routeName, 679 }, allPOIs, coords, elevationProfile, perCoordHighways, perCoordSurfaces, routeData)); 680 await commitLibraryRoute(db, libraryRouteId, updateData, geometryDoc, detailDoc, mapDoc, allPOIs, true); 681 await refreshLibraryRouteAssets(libraryRouteId, detailDoc, mapDoc); 682 683 console.log('[Publish] Route updated:', libraryRouteId); 684 void prewarmPublishedRoute(libraryRouteId); 685} 686 687// Only Firestore needs this encoding. Storage JSON retains structured ranges. 688function libraryFirestoreDoc(doc) { 689 return removeUndefinedDeep({ ...doc, 690 ...(doc.details && typeof doc.details !== 'string' ? { details: JSON.stringify(doc.details) } : {}), 691 }); 692} 693 694async function commitLibraryRoute(db, routeId, mainDoc, geometryDoc, detailDoc, mapDoc, pois, updating = false) { 695 const parent = db.collection('libraryRoutes').doc(routeId); 696 const oldPois = updating ? await parent.collection('pois').get() : null; 697 const replacements = new Set(pois.map((_, i) => `poi_${i}`)); 698 const removed = []; 699 oldPois?.forEach(doc => { if (!replacements.has(doc.id)) removed.push(doc.ref); }); 700 if (4 + pois.length + removed.length > 500) throw new Error('Too many route points of interest to publish in one save. Reduce the number and try again.'); 701 // Construct the whole batch first: client-side validation must succeed before 702 // creating a parent. Existing rules require that parent for child writes. 703 const batch = db.batch(); 704 batch.set(parent.collection('geometry').doc('main'), libraryFirestoreDoc(geometryDoc)); 705 batch.set(parent.collection('public').doc('detail'), libraryFirestoreDoc(detailDoc)); 706 batch.set(parent.collection('public').doc('map'), libraryFirestoreDoc(mapDoc)); 707 pois.forEach((poi, i) => batch.set(parent.collection('pois').doc(`poi_${i}`), removeUndefinedDeep(poi))); 708 removed.forEach(ref => batch.delete(ref)); 709 if (updating) batch.update(parent, libraryFirestoreDoc(mainDoc)); 710 else batch.set(parent, libraryFirestoreDoc(mainDoc)); 711 if (!updating) await parent.set({ownerId:mainDoc.ownerId, orgId:mainDoc.orgId || null, status:'publishing'}); 712 try { 713 await batch.commit(); 714 } catch (error) { 715 if (!updating) { 716 // Never delete a successful commit after an ambiguous network response. 717 // The transaction also protects against the status changing concurrently.
718 try { 719 const saved = await db.runTransaction(async tx => { 720 const snapshot = await tx.get(parent); 721 if (snapshot.exists && snapshot.data().status === 'published') return true; 722 if (snapshot.exists && snapshot.data().status === 'publishing') tx.delete(parent); 723 return false; 724 }); 725 if (saved) return; 726 } catch (cleanupError) { console.warn('[Publish] Incomplete draft remains hidden; cleanup could not connect.', cleanupError); } 727 } 728 throw error; 729 } 730} 731 732async function refreshLibraryRouteAssets(routeId, detailDoc, mapDoc) { 733 try { 734 await uploadPublicRoutePayloads(routeId, detailDoc, mapDoc); 735 await uploadStaticRouteHtml(routeId, buildStaticRouteHtml(routeId, detailDoc)); 736 } catch (error) { 737 // The library save succeeded. Do not invite a duplicate publish because 738 // refreshing a secondary Storage copy failed. 739 console.warn('[Publish] Route saved; public page copies could not refresh.', error); 740 alert('Your route is saved in the library, but its public web page could not refresh. Open the saved route and update it to retry.'); 741 } 742} 743 744// Build per-coordinate array from [start, end, value] ranges 745function buildPerCoordArray(ranges, length, defaultValue) { 746 const arr = new Array(length).fill(defaultValue); 747 for (const [start, end, value] of ranges) { 748 for (let i = start; i <= end && i < length; i++) { 749 arr[i] = value; 750 } 751 } 752 return arr; 753} 754 755// Build breakdown summary from detail ranges 756function buildBreakdown(details, coords, totalDist) { 757 if (!details.length || !totalDist) return []; 758 const sums = {}; 759 for (const [start, end, type] of details) { 760 let dist = 0; 761 for (let i = start; i < end && i < coords.length - 1; i++) { 762 dist += haversine(coords[i], coords[i + 1]); 763 } 764 sums[type] = (sums[type] || 0) + dist; 765 } 766 return Object.entries(sums) 767 .map(([type, dist]) => ({ 768 type, 769 distance: Math.round(dist), 770 percentage: Math.round((dist / totalDist) * 100), 771 })) 772 .sort((a, b) => b.distance - a.distance); 773} 774 775// Build elevation profile array [{d, e}] from route data 776function buildElevationProfile(coords, routeData) { 777 // If we already have elevation samples from the route calculation, use those 778 if (routeData.elevationProfile && Array.isArray(routeData.elevationProfile)) { 779 return routeData.elevationProfile.map(s => ({ 780 d: s.distance ?? s.d ?? 0, 781 e: s.elevation ?? s.e ?? 0, 782 })); 783 } 784 785 // Build from coordinates with elevation (3rd element) 786 if (coords.length > 0 && coords[0].length >= 3) { 787 const profile = []; 788 let dist = 0; 789 for (let i = 0; i < coords.length; i++) { 790 if (i > 0) { 791 dist += haversine(coords[i - 1], coords[i]) / 1000; // km 792 } 793 // Sample every ~100m to keep profile manageable 794 if (i === 0 || i === coords.length - 1 || dist - (profile.length > 0 ? profile[profile.length - 1].d : 0) >= 0.1) { 795 profile.push({ d: Math.round(dist * 1000) / 1000, e: Math.round(coords[i][2] || 0) }); 796 } 797 } 798 return profile; 799 } 800 801 return []; 802} 803 804function buildRouteDetailDoc(routeId, routeDoc, pois, coords, orgAvatar) { 805 return { 806 id: routeId, 807 name: routeDoc.name || '', 808 description: routeDoc.description || '', 809 descriptionHTML: routeDoc.descriptionHTML || '', 810 distanceMeters: routeDoc.distanceMeters || 0, 811 elevationGain: routeDoc.elevationGain || 0, 812 elevationLoss: routeDoc.elevationLoss || 0, 813 unpavedPercent: routeDoc.unpavedPercent || 0, 814 thumbnailURL: routeDoc.thumbnailURL || '', 815 photoCount: routeDoc.photoCount || 0, 816 poiCount: routeDoc.poiCount || 0, 817 groupName: routeDoc.groupName || '', 818 groupSlug: routeDoc.groupSlug || '', 819 orgId: routeDoc.orgId || null, 820 orgAvatar: orgAvatar || undefined, 821 sourceRouteId: routeDoc.sourceRouteId || '', 822 publishedAt: routeDoc.publishedAt || firebase.firestore.FieldValue.serverTimestamp(), 823 updatedAt: firebase.firestore.FieldValue.serverTimestamp(), 824 surfaceBreakdown: routeDoc.surfaceBreakdown || [], 825 highwayBreakdown: routeDoc.highwayBreakdown || [], 826 bounds: routeDoc.bounds || null, 827 pois: buildDetailPOIs(pois, coords), 828 }; 829} 830 831function buildPublishedRouteMetadata(routeData) { 832 return { 833 details: { 834 lts: routeData.ltsDetails || [], 835 road_class: routeData.roadClassDetails || [], 836 surface: routeData.surfaceDetails || [], 837 bike_infra: routeData.bikeInfraDetails || [],
838 bike_path_access: routeData.bikePathAccessDetails || [], 839 road_name: routeData.roadNameDetails || [], 840 }, 841 usedBikePaths: routeData.usedBikePaths || [], 842 trackBreaks: routeData.trackBreaks || [], 843 }; 844} 845 846function buildRouteMapDoc(routeId, routeDoc, pois, coords, elevationProfile, highways, surfaces, routeData = {}) { 847 return { 848 ...buildPublishedRouteMetadata(routeData), 849 shareId: routeId, 850 name: routeDoc.name || 'Route', 851 ownerName: routeDoc.groupName || 'AusBUG Route Library', 852 coordinates: coords.map((c) => ({ 853 latitude: c[1], 854 longitude: c[0], 855 ...(Number.isFinite(c[2]) ? { elevation: c[2] } : {}), 856 })), 857 waypoints: [], 858 waypointTitles: [], 859 distanceMeters: routeDoc.distanceMeters || 0, 860 elevation: { 861 gain: routeDoc.elevationGain || 0, 862 loss: routeDoc.elevationLoss || 0, 863 }, 864 elevationProfile: Array.isArray(elevationProfile) ? elevationProfile : [], 865 highways: Array.isArray(highways) ? highways : [], 866 surfaces: Array.isArray(surfaces) ? surfaces : [], 867 pois: buildMapPOIs(pois), 868 sharedAt: toISOStringValue(routeDoc.publishedAt), 869 }; 870} 871 872function escapeHtml(value) { 873 return String(value ?? '') 874 .replace(/&/g, '&') 875 .replace(/</g, '<') 876 .replace(/>/g, '>') 877 .replace(/"/g, '"') 878 .replace(/'/g, '''); 879} 880 881function renderBreakdownList(title, entries, labels = {}) { 882 if (!Array.isArray(entries) || entries.length === 0) return ''; 883 const items = entries.map((entry) => ` 884 <div class="list-row"> 885 <span>${escapeHtml(labels[entry.type] || entry.type)}</span> 886 <strong>${entry.distance >= 1000 ? `${(entry.distance / 1000).toFixed(1)} km` : `${Math.round(entry.distance)} m`}</strong> 887 </div> 888 `).join(''); 889 890 return ` 891 <section class="card section"> 892 <h2>${escapeHtml(title)}</h2> 893 <div class="list">${items} 894 </div> 895 </section> 896 `; 897} 898 899function renderPOIList(pois) { 900 if (!Array.isArray(pois) || pois.length === 0) return ''; 901 const items = pois.slice(0, 12).map((poi) => ` 902 <div class="list-row"> 903 <span>${escapeHtml(poi.name || poi.type || 'POI')}</span> 904 <strong>${typeof poi.distanceKm === 'number' ? `${poi.distanceKm.toFixed(1)} km` : ''}</strong> 905 </div> 906 `).join(''); 907 908 return ` 909 <section class="card section"> 910 <h2>Points of Interest</h2> 911 <div class="list">${items} 912 </div> 913 </section> 914 `; 915} 916 917function buildStaticRouteHtml(routeId, detailDoc) { 918 const title = detailDoc.name || 'AusBUG Route'; 919 const description = detailDoc.description || ''; 920 const publishedLabel = toISOStringValue(detailDoc.publishedAt).slice(0, 10); 921 const json = JSON.stringify(detailDoc).replace(/</g, '\\u003c'); 922 const highwayLabels = { 923 cycleway: 'Bike Path', path: 'Bike Path', bridleway: 'Bike Path', 924 footway: 'Footpath', steps: 'Steps', pedestrian: 'Pedestrian', 925 residential: 'Residential', living_street: 'Living Street', service: 'Service Road', 926 track: 'Track', unclassified: 'Unclassified', tertiary: 'Tertiary', road: 'Road', 927 secondary: 'Secondary', primary: 'Primary', trunk: 'Trunk', motorway: 'Motorway', 928 }; 929 const surfaceLabels = { paved: 'Paved', unpaved: 'Unpaved' }; 930 931 return `<!doctype html> 932<html lang="en"> 933<head> 934 <meta charset="utf-8" /> 935 <meta name="viewport" content="width=device-width, initial-scale=1" /> 936 <title>${escapeHtml(title)} | AusBUG</title> 937 <meta name="description" content="${escapeHtml(description || `${title} route on AusBUG`)}" /> 938 <meta property="og:title" content="${escapeHtml(title)} | AusBUG" /> 939 <meta property="og:description" content="${escapeHtml(description || `${title} route on AusBUG`)}" /> 940 <meta property="og:type" content="website" /> 941 <meta property="og:url" content="https://www.vicbug.app/route/${routeId}" /> 942 ${detailDoc.thumbnailURL ? `<meta property="og:image" content="${escapeHtml(detailDoc.thumbnailURL)}" />` : ''} 943 <style> 944 :root { color-scheme: light; } 945 * { box-sizing: border-box; } 946 body { margin: 0; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f8fafc; color: #0f172a; } 947 .wrap { max-width: 960px; margin: 0 auto; padding: 32px 20px 80px; } 948 .eyebrow { font-size: 14px; color: #64748b; margin-bottom: 10px; } 949 h1 { margin: 0 0 16px; font-size: clamp(32px, 5vw, 56px); line-height: 1.04; letter-spacing: -0.03em; } 950 .stats { display: flex; flex-wrap: wrap; gap: 18px; margin: 0 0 28px; color: #334155; font-size: 16px; } 951 .stat strong { color: #0f172a; font-weight: 700; margin-right: 6px; } 952 .actions { display: flex; flex-wrap: wrap; gap: 12px; margin: 0 0 32px; } 953 .btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; padding: 0 16px; border-radius: 14px; border: 1px solid #dbe3ef; background: #fff; color: #334155; font-size: 15px; font-weight: 600; text-decoration: none; } 954 .btn-primary { background: #2563eb; color: #fff; border-color: #2563eb; } 955 .grid { display: grid; gap: 24px; } 956 .desc { max-width: 70ch; font-size: 18px; line-height: 1.6; color: #334155; margin: 0; white-space: pre-wrap; } 957 .panel { background: #fff; border: 1px solid #e2e8f0; border-radius: 24px; padding: 24px; box-shadow: 0 20px 50px rgba(15, 23, 42, 0.06); margin-bottom: 24px; } 958 .placeholder { display: grid; place-items: center; min-height: 360px; border-radius: 20px; background: linear-gradient(180deg, #eff6ff 0%, #f8fafc 100%); color: #64748b; font-size: 15px; } 959 .meta { margin-top: 20px; display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; color: #64748b; font-size: 14px; } 960 .card { background: #fff; border: 1px solid #e2e8f0; border-radius: 24px; padding: 24px; box-shadow: 0 20px 50px rgba(15, 23, 42, 0.06); } 961 .section h2 { margin: 0 0 16px; font-size: 22px; line-height: 1.2; } 962 .list { display: grid; gap: 12px; } 963 .list-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 0; border-top: 1px solid #eef2f7; color: #475569; } 964 .list-row:first-child { border-top: 0; padding-top: 0; } 965 .list-row strong { color: #0f172a; } 966 .badge-row { display: flex; align-items: center; justify-content: center; gap: 12px; flex-wrap: wrap; margin-top: 16px; } 967 .store-badge { height: 40px; } 968 .store-badge.play { height: 56px; margin: -8px 0; } 969 @media (min-width: 860px) { 970 .grid.two { grid-template-columns: 1.1fr 0.9fr; align-items: start; } 971 } 972 </style> 973</head> 974<body> 975 <main class="wrap"> 976 <div class="eyebrow">${escapeHtml(detailDoc.groupName || 'AusBUG')} ⢠Published ${escapeHtml(publishedLabel)}</div> 977 <h1>${escapeHtml(title)}</h1> 978 <div class="stats"> 979 <div class="stat"><strong>${Math.round((detailDoc.distanceMeters || 0) / 100) / 10}</strong>km</div> 980 <div class="stat"><strong>${detailDoc.elevationGain || 0}</strong>m up</div> 981 <div class="stat"><strong>${detailDoc.elevationLoss || 0}</strong>m down</div> 982 <div class="stat"><strong>${detailDoc.unpavedPercent || 0}%</strong>unpaved</div> 983 <div class="stat"><strong>${detailDoc.poiCount || 0}</strong>POIs</div> 984 </div> 985 <div class="actions"> 986 <a class="btn btn-primary" href="vicbug://route/${routeId}">Open in App</a> 987 <a class="btn" href="/api/shared-route/${routeId}">Load Map Payload</a> 988 <a class="btn" href="https://www.vicbug.app/route/${routeId}">Canonical URL</a> 989 </div> 990 <section class="panel"> 991 <div id="static-route-map" class="placeholder"><span data-map-status>Loading interactive map...</span></div> 992 <div class="meta">
993 <span>Route ID: ${escapeHtml(routeId)}</span> 994 <span>Payloads: detail.json + map.json</span> 995 </div> 996 </section> 997 <div class="grid two"> 998 ${description ? `<section class="card section"><h2>Route Description</h2><div class="desc">${escapeHtml(description)}</div></section>` : ''} 999 ${renderPOIList(detailDoc.pois)} 1000 </div> 1001 <div class="grid two" style="margin-top: 24px;"> 1002 ${renderBreakdownList('Way Types', detailDoc.highwayBreakdown, highwayLabels)} 1003 ${renderBreakdownList('Surfaces', detailDoc.surfaceBreakdown, surfaceLabels)} 1004 </div> 1005 <section class="card section" style="margin-top: 24px; text-align: center;"> 1006 <h2>Get the full experience in the app</h2> 1007 <div class="badge-row"> 1008 <a href="https://apps.apple.com/app/id6754266082" target="_blank" rel="noopener noreferrer"> 1009 <img class="store-badge" src="https://tools.applemediaservices.com/api/badges/download-on-the-app-store/black/en-us?size=250x83" alt="Download on the App Store" /> 1010 </a> 1011 <a href="https://play.google.com/store/apps/details?id=com.vicbug.app" target="_blank" rel="noopener noreferrer"> 1012 <img class="store-badge play" src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" /> 1013 </a> 1014 </div> 1015 </section> 1016 </main> 1017 <script> 1018 window.__VICBUG_ROUTE_DETAIL__ = ${json}; 1019 window.__VICBUG_ROUTE_ASSET_PATH__ = "/routes/${routeId}/";
1020 </script> 1021 <script src="https://www.vicbug.app/v2/io/static-route-page.js"></script> 1022</body> 1023</html>`; 1024} 1025 1026function buildDetailPOIs(pois, coords) { 1027 if (!Array.isArray(pois) || pois.length === 0) return []; 1028 1029 const routeCoords = coords.map(c => ({ lng: c[0], lat: c[1] })); 1030 const cumDist = [0]; 1031 for (let i = 1; i < routeCoords.length; i++) { 1032 cumDist.push(cumDist[i - 1] + haversine( 1033 [routeCoords[i - 1].lng, routeCoords[i - 1].lat], 1034 [routeCoords[i].lng, routeCoords[i].lat], 1035 )); 1036 } 1037 1038 return pois.map((poi, index) => { 1039 const poiLat = poi.coordinates?.lat || 0; 1040 const poiLng = poi.coordinates?.lng || 0; 1041 let minDist = Infinity; 1042 let distAlongRoute = 0; 1043 1044 for (let i = 0; i < routeCoords.length; i++) { 1045 const dx = (routeCoords[i].lng - poiLng) * Math.cos(poiLat * Math.PI / 180); 1046 const dy = routeCoords[i].lat - poiLat; 1047 const d = dx * dx + dy * dy; 1048 if (d < minDist) { 1049 minDist = d; 1050 distAlongRoute = cumDist[i] || 0; 1051 } 1052 } 1053 1054 return { 1055 id: poi.id || `poi_${index}`, 1056 name: poi.name || '', 1057 type: poi.type || poi.categoryId || '', 1058 color: poi.color || '#f43f5e', 1059 icon: poi.faIcon || 'fa-solid fa-location-dot', 1060 distanceKm: Math.round(distAlongRoute / 100) / 10, 1061 source: poi.source || '', 1062 lat: poiLat, 1063 lng: poiLng, 1064 categoryId: poi.categoryId || poi.type || '', 1065 }; 1066 }).sort((a, b) => a.distanceKm - b.distanceKm); 1067} 1068 1069function buildMapPOIs(pois) { 1070 if (!Array.isArray(pois) || pois.length === 0) return []; 1071 return pois.map((poi, index) => ({ 1072 id: poi.id || `poi_${index}`, 1073 name: poi.name || '', 1074 type: poi.type || poi.categoryId || '', 1075 lat: poi.coordinates?.lat || 0, 1076 lng: poi.coordinates?.lng || 0, 1077 color: poi.color || '#f43f5e', 1078 icon: poi.faIcon || '', 1079 })); 1080} 1081 1082function toISOStringValue(value) { 1083 if (!value) return new Date().toISOString(); 1084 if (typeof value?.toDate === 'function') { 1085 return value.toDate().toISOString(); 1086 } 1087 if (typeof value === 'string') return value; 1088 return new Date().toISOString(); 1089}
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.