PageSourceSearch

https://www.vicbug.app/v2/io/route-publish.js

js vicbug.app collected 2026-09-24 13:55:57 UTC 42,631 bytes, 1,089 lines download raw bytes

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, '&amp;')
875    .replace(/</g, '&lt;')
876    .replace(/>/g, '&gt;')
877    .replace(/"/g, '&quot;')
878    .replace(/'/g, '&#39;');
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.