PageSourceSearch

https://bikeatthebeach.com/js/map-view/map-core.js

js bikeatthebeach.com collected 2026-10-02 00:28:07 UTC 58,915 bytes, 1,546 lines download raw bytes

1/* =====================================================================
2   js/map-view/map-core.js
3   Mapbox GL JS Wrapper & Core Logic.
4   ===================================================================== */
5
6import state, { setMap } from './state.js';
7import { layerStartsHidden } from './overlay-start-visibility.js';
8import { resolveBelowAnchor } from './below-anchor.js';
9import { initBaseMap } from './map-basemap.js';
10
11import { fetchFeatures } from './api.js';
12import { refreshMapData, resetIcons } from './drawing/layers.js';
13
14import { fetchIcons } from './api.js';
15import { enrichFeatures } from '/js/presentation-viewer/utils.js'; // reuse logic
16
17
18// --- NEW DRAWING SYSTEM ---
19import { init as initDraw } from './drawing/index.js';
20import { init as initAnno } from '/js/annotation-manager.js';
21
22import { wireLegendFilters } from './map-legend-filters.js';
23import { ProjectionIndicator } from './projection-indicator.js';
24import { retranslateNow } from '/js/global-translate.js';
25
26/* temp */
27import { getInitialView } from './state.js';
28import { isFatalLoadError } from './boot-error.js';
29console.log('[Map Init] Initial View:', getInitialView());
30
31
32
33/* ---------------------------------------------------------------------
34   Mapbox GL JS — use global from <script> tag (v3.9.2)
35   Previously imported v3.3.0 via ESM which caused worker version conflicts.
36   --------------------------------------------------------------------- */
37const mapboxgl = window.mapboxgl;
38
39let mapInstance = null;
40
41
42async function restoreIconImages(map) {
43  const icons = window.wmState?.icons || [];
44
45  for (const icon of icons) {
46    const id = icon.path || icon.icon_path;
47    if (!id || map.hasImage(id)) continue;
48
49    try {
50      const image = await new Promise((resolve, reject) => {
51        map.loadImage(id, (err, img) => {
52          if (err || !img) reject(err);
53          else resolve(img);
54        });
55      });
56
57      map.addImage(id, image);
58    } catch (e) {
59      console.warn('[Map Core] Failed to restore icon:', id);
60    }
61  }
62
63  console.log('[Map Core] Restored icons:', icons.length);
64}
65
66/* --------------------------------------------------------------------
67   NOTE: I've commented out the original 'setupExtraLayers(map)' function 
68   definition here as it was being overwritten by the definition below
69   (the one without arguments). The logic has been correctly moved 
70   into the style.load event handler in init().
71   -------------------------------------------------------------------- */
72// async function setupExtraLayers(map) {
73//   // A simple check to prevent potential double-initialization issues
74//   if (map.getSource('icons')) {
75//     console.log('[map-core] Layers already initialized for this style. Skipping setup.');
76//     return;
77//   }
78// 
79//   // 1. Re-add custom images (icons) to the new style
80//   await restoreIconImages(map);
81// 
82//   // 2. Re-add sources and layers (GeoJSON geometries)
83//   refreshMapData();
84// 
85//   // 3. Re-add Draw/Annotation layers (if they are managed globally)
86//   if (window.wmDraw?.onStyleLoad) {
87//     window.wmDraw.onStyleLoad(map);
88//   }
89//   if (window.wmAnno?.onStyleLoad) {
90//     window.wmAnno.onStyleLoad(map);
91//   }
92// }
93
94/* =====================================================================
95   Map Core Initialization
96   ===================================================================== */
97
98export async function init() {
99    /* --------------------------------------------------------------
100       1. Resolve Mapbox Token
101       -------------------------------------------------------------- */
102    let token = state.settings.mapboxAccessToken || state.settings.mapbox_token;
103
104    if (!token) {
105        try {
106            const res = await fetch('/api/config/mapbox-token');
107            if (res.ok) {
108                const data = await res.json();
109                token = data.token;
110            }
111        } catch (err) {
112            console.warn("[Map Core] Failed to fetch global token:", err);
113        }
114    }
115
116    if (token) {
117        state.settings.mapboxAccessToken = token;
118    }
119
120   /* --------------------------------------------------------------
121   2. Create Map (AUTHORITATIVE)
122	-------------------------------------------------------------- */
123	console.log("[Map Core] Initializing basemap + map instance...");
124	
125	// ✅ DEFINE view BEFORE using it
126	const view = getInitialView();
127	// Include projection setting so initBaseMap can apply it
128	view.projection = (state.settings || {}).projection || 'mercator';
129	console.log('[Map Core] Using initial view:', view);
130
131	// ✅ PASS view explicitly
132	mapInstance = await initBaseMap('map', view);
133	window._map = mapInstance;
134
135    /* --------------------------------------------------------------
136       3. Sync State
137       -------------------------------------------------------------- */
138    setMap(mapInstance);
139    
140    /* --------------------------------------------------------------
141       3.1 Reload dynamic PNG icons after style changes
142       -------------------------------------------------------------- */
143    mapInstance.on('styleimagemissing', async (e) => {
144        const id = e.id;
145        if (!id || !id.startsWith('/uploads/icons/')) return;
146
147        try {
148            const image = await new Promise((resolve, reject) => {
149                mapInstance.loadImage(id, (err, img) => {
150                    if (err || !img) reject(err);
151                    else resolve(img);
152                });
153            });
154
155            if (!mapInstance.hasImage(id)) {
156                mapInstance.addImage(id, image);
157            }
158        } catch (err) {
159            console.warn('[Map Core] Failed to load icon:', id);
160        }
161    });
162    
163    /* --------------------------------------------------------------
164       3.5 Rebuild layers after basemap/style changes
165       -------------------------------------------------------------- */
166    mapInstance.on('style.load', async () => {
167        console.log('[Map Core] Style fully loaded — restoring icons and features');
168        await restoreIconImages(mapInstance);
169        refreshMapData();
170        
171        // This handles standard setting layers (mask, tiles, geojson) 
172        // every time the basemap changes.
173        await setupExtraLayers();
174        setupTerrainAndFog();
175    });
176
177    /* --------------------------------------------------------------
178       4. Wait for Initial Load
179       -------------------------------------------------------------- */
180    await new Promise((resolve, reject) => {
181        mapInstance.once('load', resolve);
182        // Mapbox GL error events are plain objects like {error: {message: "..."}},
183        // not Error instances. Wrap in a real Error so bootstrap's catch block
184        // gets a proper .message property.
185        // Only a style failure is fatal here. A tile or icon that fails before
186        // 'load' is recoverable, and rejecting on it aborted a map that would
187        // have finished loading. So listen with on(), not once(), and ignore
188        // source errors until 'load' removes the listener.
189        const onLoadError = (evt) => {
190            if (!isFatalLoadError(evt)) return;
191            mapInstance.off('error', onLoadError);
192            const msg = evt?.error?.message || evt?.message || 'Map error';
193            reject(new Error(msg));
194        };
195        mapInstance.on('error', onLoadError);
196        mapInstance.once('load', () => mapInstance.off('error', onLoadError));
197
198        // Diagnostic tracing while we wait. When the load event doesn't
199        // fire within 15s, the next block dumps the state so we can see
200        // which piece is stuck. Enable per-source logs with
201        // ?debugMapLoad=1 in the URL.
202        const debugLoad = new URLSearchParams(window.location.search)
203            .has('debugMapLoad');
204        const t0 = performance.now();
205        const logEvt = (label) => {
206            if (debugLoad) {
207                console.log(`[Map Load @${(performance.now() - t0).toFixed(0)}ms] ${label}`);
208            }
209        };
210        mapInstance.once('style.load', () => logEvt('style.load'));
211        mapInstance.once('idle', () => logEvt('idle'));
212        if (debugLoad) {
213            mapInstance.on('sourceloading', (e) => logEvt(`sourceloading ${e.sourceId}`));
214            mapInstance.on('sourcedata', (e) => {
215                if (e.isSourceLoaded) logEvt(`sourcedata loaded ${e.sourceId}`);
216            });
217            mapInstance.on('dataloading', (e) => logEvt(`dataloading ${e.dataType}`));
218        }
219
220        // Safety net: don't let bootstrap hang if load event never fires.
221        // On timeout, dump diagnostic state so we can identify what's
222        // still pending (unloaded style / tiles / a specific source).
223        setTimeout(() => {
224            try {
225                const style = mapInstance.getStyle?.();
226                const sources = style?.sources ? Object.keys(style.sources) : [];
227                const unloadedSources = sources.filter(id => {
228                    try { return !mapInstance.isSourceLoaded?.(id); } catch { return false; }
229                });
230                console.warn('[Map Core] Load timeout (15s) diagnostic:', {
231                    isStyleLoaded: mapInstance.isStyleLoaded?.(),
232                    loaded: mapInstance.loaded?.(),
233                    areTilesLoaded: mapInstance.areTilesLoaded?.(),
234                    sources_total: sources.length,
235                    sources_unloaded: unloadedSources,
236                    sprite: style?.sprite,
237                    glyphs: style?.glyphs,
238                });
239            } catch (e) {
240                console.warn('[Map Core] Load timeout diagnostic failed:', e);
241            }
242            console.warn('[Map Core] Load timeout (15s) — continuing bootstrap');
243            resolve();
244        }, 15000);
245    });
246
247    console.log("[Map Core] Map loaded.");
248
249    // Force a resize + repaint to work around a Mapbox GL v3 rendering bug
250    // where base tiles are fetched but not painted until user interaction.
251    // This ensures the internal viewport dimensions match the container.
252    mapInstance.resize();
253
254    // Keep the map in sync with any later container size changes. Late-
255    // loading banner content (populated by render.js) can reflow the
256    // layout AFTER this initial resize, leaving Mapbox stuck at pre-
257    // banner dimensions and causing base-layer polygons to render
258    // clipped or partial. ResizeObserver catches every subsequent size
259    // change; matches the pattern used in presentation-viewer/viewer-
260    // map.js and project-edit/map-view/map-utils.js.
261    const mapContainerEl = document.getElementById('map');
262    if (mapContainerEl && typeof ResizeObserver !== 'undefined') {
263        const containerResizeObserver = new ResizeObserver(() => {
264            if (mapInstance) mapInstance.resize();
265        });
266        containerResizeObserver.observe(mapContainerEl);
267    }
268
269    // 🔑 AUTHORITATIVE REHYDRATION HOOK
270	 window.addEventListener('wm:features-rehydrated', () => {
271  console.log('[Map Core] Rehydrating features → authoritative rebuild');
272	
273	  // 🔒 Hard-stop draw reconciliation for this cycle
274	  window.wmState._suspendDrawReconcile = true;
275	
276	  // 🧹 Single, atomic teardown + rebuild
277	  resetIcons();
278	  refreshMapData({ clearDraw: true, authoritative: true });
279	
280	  // ✅ Resume draw AFTER rebuild completes
281	  requestAnimationFrame(() => {
282	  requestAnimationFrame(() => {
283		window.wmState._suspendDrawReconcile = false;
284		console.log('[Map Core] Draw reconciliation resumed');
285	  });
286	});
287	
288	  // 🔔 Notify dependents exactly once
289	  window.dispatchEvent(new Event('wm:features-ready'));
290	});
291
292    /* --------------------------------------------------------------
293       5. Data Synchronization
294       -------------------------------------------------------------- */
295    try {
296        if (!window.wmState.project) {
297            console.error('[Map Core] No project data in wmState. Features cannot be fetched.');
298            return;
299        }
300
301        const projectId = state.project.id;
302
303        // 1️⃣ Features + icons. Normally already in flight — bootstrap
304        // (index.js) starts the download in parallel with map init and
305        // parks the promise on state.featuresPrefetch. Fall back to
306        // fetching here for entry paths that skip bootstrap's prefetch.
307        const [features, icons] = await (state.featuresPrefetch || Promise.all([
308            fetchFeatures({ project_id: projectId }),
309            fetchIcons(projectId)
310        ]));
311
312        console.log(`[Map Core] Loaded ${features.length} features, ${icons.length} icons`);
313		
314		// ⚠️ AUTHORITATIVE REHYDRATION
315		// This will drop local drafts by design
316        // 2️⃣ Enrich and store data
317        window.wmState.features = enrichFeatures(
318            features,
319            window.wmState.categories || [],
320            icons
321        );
322        window.wmState.icons = icons;
323
324        // 3️⃣ Render project features (authoritative — has latestAnswers)
325        if (window.wmState.categories?.length > 0) {
326            refreshMapData({ force: true });
327            // Force Mapbox to repaint with updated feature properties
328            if (mapInstance) {
329                mapInstance.triggerRepaint();
330            }
331        } else {
332            console.warn('[Map Core] Categories missing; skipping render.');
333        }
334
335        // Routes / Base view toggle. Mounts now (not in setupControls)
336        // because the gate reads wmState.categories which arrives with
337        // this fetch. No-ops if the project has no Route Builder
338        // category.
339        if (mapInstance && !mapInstance._routeBaseToggleMounted) {
340            mapInstance._routeBaseToggleMounted = true;
341            import('./route-base-toggle.js')
342              .then(({ mountRouteBaseToggle }) => mountRouteBaseToggle(mapInstance))
343              .catch(err => console.warn('[route-base-toggle] mount failed:', err));
344        }
345
346        /* --------------------------------------------------
347           🔑 LEGEND FILTERS (SCOPED + SAFE)
348        -------------------------------------------------- */
349        try {
350            if (!mapInstance._legendFiltersWired) {
351                console.log('[Legend] Wiring legend filters (project layers only)');
352                wireLegendFilters(mapInstance, state.categories, {
353                    layers: [
354                        // Points / icons
355                        'existing-icons',
356                        'existing-points-circles',
357                        'existing-points-reviewed',
358                        'existing-points-labels',
359                        // Base line stack
360                        'existing-lines',
361                        'existing-lines-dashed',
362                        'existing-lines-dotted',
363                        'existing-lines-highlight',
364                        'existing-lines-arrows',
365                        'existing-lines-reviewed',
366                        // Generic line decorations (halo / label / arrows
367                        // driven by annotation_style)
368                        'existing-lines-halo-generic',
369                        'existing-lines-labels-generic',
370                        'existing-lines-arrows-generic',
371                        // Route Builder stack — these have their own
372                        // __isRoute / __highlighted filters; the legend
373                        // module combines those with the category-id
374                        // expression using ['all', orig, expr].
375                        'existing-lines-routes-halo',
376                        'existing-lines-routes',
377                        'existing-lines-routes-labels',
378                        'existing-lines-routes-highlight',
379                        'existing-lines-bulk-selected',
380                        // Polygons + frames + buffers
381                        'existing-polygons',
382                        'existing-polygons-above',
383                        'existing-polygons-above-outline',
384                        'existing-polygons-outline',
385                        'existing-polygons-reviewed',
386                        'existing-polygons-labels',
387                        // Note: existing-polygons-highlight + existing-icons-highlight
388                        // are NOT included — their filters are managed by
389                        // hover handlers in layers.js, not the legend.
390                        'existing-frames',
391                        'existing-frames-outline',
392                        'buf-line-fill',
393                        'buf-line-outline'
394                    ]
395                });
396                mapInstance._legendFiltersWired = true;
397            }
398        } catch (err) {
399            console.warn('[Legend] Failed to wire legend filters:', err);
400        }
401
402    } catch (err) {
403        console.error('[Map Core] Critical error during data sync:', err);
404    }
405
406    /* --------------------------------------------------------------
407       6. Controls
408       -------------------------------------------------------------- */
409    setupControls();
410
411    /* --------------------------------------------------------------
412       7. Drawing & Annotations
413       -------------------------------------------------------------- */
414    await initDraw();
415    initAnnotations();
416
417    /* --------------------------------------------------------------
418       8. Resize Handler
419       -------------------------------------------------------------- */
420    window.addEventListener('resize', () => {
421        if (mapInstance) mapInstance.resize();
422    });
423}
424
425/* --- Helper Functions (Unchanged) --- */
426
427function setupControls() {
428    // Compass is on so visitors who land on a non-north-up project (admin
429    // set initial_bearing != 0) can click the compass to snap back to
430    // north. Clicking the compass also resets pitch when visualizePitch
431    // is on, giving visitors a single "back to plain orientation" button.
432    // The compass renders only as an arrow when bearing == 0, so it's
433    // not visually noisy on default north-up projects.
434    mapInstance.addControl(
435        new mapboxgl.NavigationControl({ showCompass: true, visualizePitch: true }),
436        'top-right'
437    );
438    mapInstance.addControl(new mapboxgl.ScaleControl(), 'bottom-right');
439
440    // Projection indicator — only visible for non-mercator projections
441    const projKey = (state.settings || {}).projection || 'mercator';
442    mapInstance.addControl(new ProjectionIndicator(projKey), 'bottom-right');
443    const geolocate = new mapboxgl.GeolocateControl({
444        positionOptions: { enableHighAccuracy: true },
445        trackUserLocation: true,
446        showAccuracyCircle: false
447    });
448    mapInstance.addControl(geolocate, 'top-right');
449
450    // Split-view (table) toggle is parked. Steve flagged the UX as 
450not
451    // helpful enough to justify the top-right toolbar slot; leaving the
452    // module in tree so it can come back when the table view is
453    // rethought. To restore: uncomment the dynamic import.
454    // import('./split-view/toolbar-button.js')
455    //   .then(({ mountSplitToolbarButton }) => mountSplitToolbarButton(mapInstance))
456    //   .catch(err => console.warn('[split-view] toolbar mount failed:', err));
457
458    // External views: small button group (Google Maps + bike layer,
459    // Street View, Strava heatmap) on the top-left. Per-feature versions
460    // live in details/external-links.js; this control is the map-wide
461    // jump-to-the-current-view-elsewhere pattern.
462    import('./external-views-control.js')
463      .then(({ mountExternalViewsControl }) => mountExternalViewsControl(mapInstance))
464      .catch(err => console.warn('[external-views] mount failed:', err));
465
466    // Search UI: either the address-answer top-centre search (school
467    // district map use case) or the standard collapsed geocoder in the
468    // bottom-left. The two are mutually exclusive — settings.enable_
469    // address_answer_mode switches modes.
470    if ((state.settings || {}).enable_address_answer_mode) {
471        import('./address-answer/index.js')
472          .then(({ mountAddressAnswer }) => mountAddressAnswer(mapInstance))
473          .catch(err => console.warn('[address-answer] mount failed:', err));
474    } else {
475        mountGeocoder();
476    }
477}
478
479/* ─────────────────────────────────────────────
480   Collapsed Mapbox Geocoder (bottom-left)
481   Mounted OUTSIDE #map to escape its z-index:1
482   stacking context. Buttons render above it.
483   ───────────────────────────────────────────── */
484async function mountGeocoder() {
485    const token = mapboxgl.accessToken || '';
486    if (!token) {
487        console.warn('[geocoder] no access token; skipping geocoder');
488        return;
489    }
490
491    // Ensure plugin is available (loaded via <script> tag in map-view.html)
492    let Geocoder = window.MapboxGeocoder;
493    if (typeof Geocoder !== 'function') {
494        try {
495            const mod = await import('https://cdn.jsdelivr.net/npm/@mapbox/[email protected]/dist/mapbox-gl-geocoder.min.js');
496            Geocoder = mod?.default || window.MapboxGeocoder;
497        } catch (e) {
498            console.warn('[geocoder] plugin not available:', e?.message || e);
499            return;
500        }
501    }
502
503    // Remove any existing geocoder UI
504    document.querySelectorAll('.mapboxgl-ctrl-geocoder').forEach(el => el.remove());
505    document.getElementById('wm-geocoder-host')?.remove();
506
507    try {
508        const geocoder = new Geocoder({
509            accessToken: token,
510            mapboxgl,
511            collapsed: true,
512            marker: false,
513            placeholder: 'Search',
514            // Soft bias toward the map center — ranks closer results higher
515            // but still finds matches anywhere in the world.
516            proximity: (() => {
517                const c = mapInstance.getCenter();
518                return { longitude: c.lng, latitude: c.lat };
519            })(),
520            // Include places (parks, schools, businesses) + addresses
521            types: 'poi,address,place,neighborhood,locality',
522
523            // Inject project features as search results.
524            // Matches feature.description (case-insensitive substring) so
525            // manually-placed pins (e.g. addresses on streets that don't exist
526            // in OSM yet) appear in the search dropdown alongside Mapbox hits.
527            localGeocoder: (query) => {
528                const q = (query || '').trim().toLowerCase();
529                if (q.length < 2) return [];
530                const feats = window.wmState?.features || [];
531                const out = [];
532                for (const f of feats) {
533                    const desc = f.description;
534                    if (!desc || typeof desc !== 'string') continue;
535                    if (!desc.toLowerCase().includes(q)) continue;
536                    const lng = Number(f.longitude);
537                    const lat = Number(f.latitude);
538                    if (!Number.isFinite(lng) || !Number.isFinite(lat)) continue;
539                    out.push({
540                        type: 'Feature',
541                        place_name: desc,
542                        place_type: ['address'],
543                        center: [lng, lat],
544                        geometry: { type: 'Point', coordinates: [lng, lat] },
545                        properties: {
546                            wm_feature_id: f.id,
547                            wm_category_id: f.category_id
548                        }
549                    });
550                    if (out.length >= 5) break;
551                }
552                return out;
553            }
554        });
555
556        // Update proximity as the map moves so the bias follows the user's focus
557        mapInstance.on('moveend', () => {
558            const c = mapInstance.getCenter();
559            geocoder.setProximity({ longitude: c.lng, latitude: c.lat });
560        });
561
562        // Mount outside #map so it isn't trapped by #map's z-index:1 stacking context.
563        // Use geocoder.onAdd(map) to get the DOM element, then place in our own container.
564        const el = geocoder.onAdd(mapInstance);
565        const host = document.createElement('div');
566        host.id = 'wm-geocoder-host';
567        // A search landmark with a name. The geocoder is mounted on
568        // <body>, outside every other landmark, so without this its
569        // input is content belonging to no region at all.
570        host.setAttribute('role', 'search');
571        host.setAttribute('aria-label', 'Search for a place on the map');
572        host.style.cssText = 'position:fixed;bottom:10px;left:10px;z-index:1400;';
573        host.appendChild(el);
574        document.body.appendChild(host);
575
576        console.debug('[map-core] Geocoder mounted (outside map container).');
577    } catch (e) {
578        console.warn('[geocoder] addControl failed:', e?.message || e);
579        return;
580    }
581
582    // i18n: mark attributes and translate
583    setTimeout(() => {
584        const gc = document.querySelector('.mapboxgl-ctrl-geocoder');
585        if (!gc) return;
586        const input = gc.querySelector('input[type="text"]');
587        if (input) {
588            input.dataset.i18nPlaceholder = 'geocoder.placeholder';
589            input.setAttribute('placeholder', 'Search');
590            input.setAttribute('aria-label', 'Search');
591        }
592        const clearBtn = gc.querySelector('.mapboxgl-ctrl-geocoder--button');
593        if (clearBtn) {
594            clearBtn.dataset.i18nTitle = 'geocoder.clear';
595            clearBtn.setAttribute('title', 'Clear search');
596        }
597        retranslateNow(gc);
598    }, 0);
599}
600
601function setupTerrainAndFog() {
602    if (!mapInstance) return;
603
604    if (state.settings.enable3dTerrain) {
605        if (!mapInstance.getSource('mapbox-dem')) {
606            mapInstance.addSource('mapbox-dem', {
607                type: 'raster-dem',
608                url: 'mapbox://mapbox.mapbox-terrain-dem-v1',
609                tileSize: 512,
610                maxzoom: 14
611            });
612        }
613
614        try {
615            mapInstance.setTerrain({
616                source: 'mapbox-dem',
617                exaggeration: 1.5
618            });
619        } catch (e) {
620            console.warn('[Terrain] Failed to set terrain', e);
621        }
622    }
623
624    // Only set fog when 3D terrain is active — calling setFog()
625    // unconditionally on Mapbox GL v3 can interfere with tile rendering
626    // for traditional 2D styles (streets-v12, satellite, etc.).
627    if (state.settings.enable3dTerrain) {
628        try {
629            mapInstance.setFog({
630                range: [0.5, 10],
631                color: 'white',
632                'horizon-blend': 0.1
633            });
634        } catch (e) {
635            console.warn('[Fog] Failed to set fog', e);
636        }
637    }
638}
639function initAnnotations() {
640    const minZoom = state.settings.min_annotation_zoom || 14;
641    const editable = (state.user.isOwner || state.user.isSuperAdmin);
642
643    const anno = initAnno(mapInstance, { minZoom, editable });
644
645    const annoFeatures = state.features.filter(
646        f => f.render_as === "annotation"
647    );
648
649    if (annoFeatures.length > 0) {
650        anno.render(annoFeatures);
651    }
652}
653
654/* --- Extra Layers (MODIFIED) --- */
655
656async function setupExtraLayers() {
657    console.group('[Map Core] setupExtraLayers');
658
659    // 1️⃣ Normalize layer source (critical)
660    const layers =
661        Array.isArray(state.layers) && state.layers.length
662            ? state.layers
663            : Array.isArray(state.settings?.extraLayers)
664                ? state.settings.extraLayers
665                : [];
666
667    console.log('[Map Core] resolved layers:', layers.map(l => ({
668        name: l.name,
669        type: l.type
670    })));
671
672    if (!layers.length) {
673        console.warn('[Map Core] No layers to render');
674        console.groupEnd();
675        return;
676    }
677
678    // 2️⃣ Render layers deterministically
679    // 💡 STEP 1: ADD LOG TO CONFIRM START OF LOOP
680    console.log('[Map Core] layer loop start:', layers.map(l => l.type));
681
682    for (const layer of layers) {
683        try {
684            // Skip layers marked as hidden in base-layers settings
685            if (layer.visible === false) {
686                console.log('[Map Core] skipping hidden layer:', layer.name);
687                continue;
688            }
689            // Track whether this layer should start hidden (togglable in legend)
690            const startHidden = layerStartsHidden(layer);
691            console.log('[Map Core] rendering layer:', layer.name, layer.type);
692
693            if (layer.type === 'mask') {
694                await addMaskLayer(layer);
695            } else if (layer.type === 'tile') {
696                await addTileLayer(layer);
697            } else if (layer.glLayers?.length) {
698                // Raw Mapbox GL layer definitions (from native class editor or Maputnik)
699                await addGlLayersCore(layer);
700            } else if (layer.type === 'geojson') {
701			  try {
702				// Overpass layers use a sentinel URL — fetch via server proxy first
703				if (layer.url === '(overpass)' && layer.overpassQuery) {
704				  const r = await fetch('/api/overpass', {
705					method: 'POST',
706					headers: { 'Content-Type': 'application/json' },
707					body: JSON.stringify({ query: layer.overpassQuery })
708				  });
709				  if (r.ok) {
710					const geojson = await r.json();
711					await addGeoJsonLayer({ ...layer, data: geojson, url: null });
712				  } else {
713					console.warn('[Map Core] Overpass HTTP', r.status, layer.name);
714				  }
715				} else {
716				  await addGeoJsonLayer(layer);
717				}
718			  } catch (err) {
719				console.warn('[Map Core] GeoJSON failed, continuing:', layer.name, err);
720			  }
721			} else if (layer.type === 'xyz-mvt' || layer.tiles) {
722                await addVectorTileLayer(layer);
723            } else if (layer.type === 'buildings') {
724                addBuildingsLayer(layer);
725            } else {
726                console.warn('[Map Core] Unknown layer type:', layer.type, layer);
727            }
728        } catch (err) {
729            // Empty catch block is intentional (silence individual layer failures)
730        }
731    }
732
733    console.groupEnd();
734}
735function addMaskLayer(layerObj) {
736    return new Promise((resolve) => {
737        const map = mapInstance;
738        const sourceId = `mask-${layerObj.name || Date.now()}-src`;
739        const layerId  = `mask-${layerObj.name || Date.now()}-fill`;
740        if (!map.getSource(sourceId)) {
741            map.addSource(sourceId, {
742                type: 'geojson',
743                data: layerObj.url || layerObj.data || { type: 'FeatureCollection', features: [] }
744            });
745        }
746        if (!map.getLayer(layerId)) {
747            map.addLayer({
748                id: layerId, type: 'fill', source: sourceId,
749                paint: {
750                    'fill-color': layerObj.maskPaint?.color || '#000000',
751                    'fill-opacity': layerObj.maskPaint?.opacity ?? 0.5,
752                    'fill-antialias': false
753                }
754            });
755        }
756        resolve();
757    });
758}
759
760function addTileLayer(layerObj) {
761    return new Promise((resolve) => {
762        const map = mapInstance;
763
764        // Stable, predictable IDs
765        const id = layerObj.name || layerObj.id || `tile-${Date.now()}`;
766
767        // Normalize tiles URL
768        const tiles =
769            Array.isArray(layerObj.tiles)
770                ? layerObj.tiles
771                : layerObj.tiles
772                    ? [layerObj.tiles]
773                    : layerObj.url
774                        ? [layerObj.url]
775                        : null;
776
777        if (!tiles) {
778            console.warn('[TileLayer] No tiles or URL provided:', layerObj);
779            resolve();
780            return;
781        }
782
783        // 1️⃣ Add raster source
784        if (!map.getSource(id)) {
785            map.addSource(id, {
786                type: 'raster',
787                tiles,
788                tileSize: layerObj.tileSize || 256,
789                minzoom: layerObj.minzoom ?? 0,
790                maxzoom: layerObj.maxzoom ?? 22
791            });
792        }
793
794        // 2️⃣ Add raster layer (ensure visibility above basemap)
795        if (!map.getLayer(id)) {
796            const before = resolveBelowAnchor(map, layerObj.below || 'road-label');
797
798            map.addLayer(
799                {
800                    id,
801                    type: 'raster',
802                    source: id
803                },
804                before
805            );
806        }
807
808        // Store render ID for legend toggles (same pattern as addVectorTileLayer)
809        layerObj._renderLayerId = id;
810        layerObj._mapboxIds = [id];
811
812        // If defaultOff, hide initially
813        if (layerStartsHidden(layerObj) && map.getLayer(id)) {
814            map.setLayoutProperty(id, 'visibility', 'none');
815        }
816
817        console.log('[TileLayer] Added raster layer:', {
818            id,
819            tiles,
820            minzoom: layerObj.minzoom,
821            maxzoom: layerObj.maxzoom
822        });
823
824        resolve();
825    });
826}
827
828
829/**
830 * Add Mapbox's built-in building footprints from the composite source.
831 * Supports 2D footprints and 3D extrusions.
832 * Layer config: { type: 'buildings', mode: '2d'|'3d', color, opacity, defaultOff }
833 */
834function addBuildingsLayer(layerObj) {
835    const map = mapInstance;
836    if (!map) return;
837
838    const color = layerObj.color || '#cc5522';
839    const opacity = layerObj.opacity ?? 0.6;
840    const mode = layerObj.mode || '2d'; // '2d' = footprints, '3d' = extrusions
841    const minzoom = layerObj.minzoom ?? 13;
842    const beforeId = map.getStyle().layers.find(l => l.type === 'symbol')?.id;
843
844    // Hide any native building layers from the base style so they don't overlap
845    for (const l of (map.getStyle().layers || [])) {
846        if (l['source-layer'] === 'building' && !l.id.startsWith('wm-bldg')) {
847            try { map.setLayoutProperty(l.id, 'visibility', 'none'); } catch {}
848        }
849    }
850
851    // Add a dedicated building source with overzooming so buildings
852    // appear at lower zoom levels (Mapbox tiles start at ~z13)
853    const buildingSrc = 'wm-buildings-src';
854    if (!map.getSource(buildingSrc)) {
855        const token = mapboxgl.accessToken;
856        map.addSource(buildingSrc, {
857            type: 'vector',
858            url: 'mapbox://mapbox.mapbox-streets-v8',
859            minzoom: minzoom,
860            maxzoom: 16
861        });
862    }
863
864    const ids = [];
865
866    // 3D extrusions
867    if (!map.getLayer('wm-bldg-3d')) {
868        map.addLayer({
869            id: 'wm-bldg-3d',
870            type: 'fill-extrusion',
871            source: buildingSrc,
872            'source-layer': 'building',
873            minzoom: minzoom,
874            filter: ['==', ['geometry-type'], 'Polygon'],
875            layout: { visibility: mode === '3d' ? 'visible' : 'none' },
876            paint: {
877                'fill-extrusion-color': color,
878                'fill-extrusion-opacity': opacity,
879                'fill-extrusion-height': [
880                    'case', ['has', 'height'],
881                    ['max', ['to-number', ['get', 'height']], ['to-number', ['get', 'min_height'], 0]],
882                    8
883                ],
884                'fill-extrusion-base': ['to-number', ['get', 'min_height'], 0]
885            }
886        }, beforeId);
887        ids.push('wm-bldg-3d');
888    }
889
890    // 2D footprints
891    if (!map.getLayer('wm-bldg-2d')) {
892        map.addLayer({
893            id: 'wm-bldg-2d',
894            type: 'fill',
895            source: buildingSrc,
896            'source-layer': 'building',
897            minzoom: minzoom,
898            layout: { visibility: mode === '2d' ? 'visible' : 'none' },
899            paint: { 'fill-color': color, 'fill-opacity': opacity }
900        }, beforeId);
901        ids.push('wm-bldg-2d');
902    }
903
904    // Outline
905    if (!map.getLayer('wm-bldg-outline')) {
906        map.addLayer({
907            id: 'wm-bldg-outline',
908            type: 'line',
909            source: buildingSrc,
910            'source-layer': 'building',
911            minzoom: minzoom,
912            layout: { visibility: mode === '2d' ? 'visible' : 'none' },
913            paint: { 'line-color': '#000', 'line-opacity': 0.15, 'line-width': 0.5 }
914        }, beforeId);
915        ids.push('wm-bldg-outline');
916    }
917
918    layerObj._mapboxIds = ids;
919
920    // defaultOff: hide all sub-layers initially
921    if (layerStartsHidden(layerObj)) {
922        for (const id of ids) {
923            if (map.getLayer(id)) map.setLayoutProperty(id, 'visibility', 'none');
924        }
925    }
926
927    console.log('[Map Core] Added buildings layer:', mode, color, opacity);
928}
929
930// Render a layer using raw Mapbox GL layer definitions (glLayers)
931// Produced by the native class color editor or Maputnik import.
932async function addGlLayersCore(layerObj) {
933    const map = mapInstance;
934    if (!map) return;
935
936    const srcId = layerObj._uid || layerObj.name || `gl-${Date.now()}`;
937    const before = resolveBelowAnchor(map, layerObj.below || 'road-label');
938
939    // Resolve GeoJSON data
940    let data = layerObj.data;
941    if (!data && layerObj.url && layerObj.url !== '(local-file)') {
942        try {
943            const res = await fetch(layerObj.url);
944            data = await res.json();
945        } catch { /* fall through */ }
946    }
947    if (!data) data = { type: 'FeatureCollection', features: [] };
948
949    // Remove previous layers/source
950    (layerObj.glLayers || []).forEach((_, i) => {
951        const id = `${srcId}-gl-${i}`;
952        if (map.getLayer(id)) map.removeLayer(id);
953    });
954    if (map.getSource(srcId)) map.removeSource(srcId);
955
956    // Add source
957    map.addSource(srcId, { type: 'geojson', data });
958
959    // Add each sublayer. Shapes go under the basemap's roads and place names,
960    // which is what `below` asks for, but text does not: Mapbox gives a symbol
961    // collision to whichever layer sits higher, so a labelled sublayer placed
962    // down there loses to the basemap and simply does not draw. Only 12 of the
963    // 25 Brownsville tract codes appeared until this. Symbols go on top.
964    layerObj.glLayers.forEach((gl, i) => {
965        const layerId = `${srcId}-gl-${i}`;
966        try {
967            const def = { ...gl, id: layerId, source: srcId };
968            const anchor = gl.type === 'symbol' ? undefined : before;
969            anchor ? map.addLayer(def, anchor) : map.addLayer(def);
970        } catch (err) {
971            console.warn(`[Map Core] glLayer ${layerId} failed:`, err.message);
972        }
973    });
974
975    // Store Mapbox layer IDs for legend toggles
976    layerObj._mapboxIds = layerObj.glLayers.map((_, i) => `${srcId}-gl-${i}`);
977
978    // If defaultOff, hide all sub-layers initially
979    if (layerObj.defaultOff) {
980        for (const id of layerObj._mapboxIds) {
981            if (map.getLayer(id)) map.setLayoutProperty(id, 'visibility', 'none');
982        }
983    }
984
985    console.log(`[Map Core] addGlLayersCore: ${srcId} (${layerObj.glLayers.length} sublayers)`);
986}
987
988function addGeoJsonLayer(layerObj) {
989    return new Promise(async (resolve) => {
990        const map = mapInstance;
991        if (!map || !map.addLayer) {
992            console.warn('[GeoJSON] Map not ready');
993            resolve();
994            return;
995        }
996
997        const srcId = layerObj._uid || layerObj.name || `src-${Date.now()}`;
998        // Validate that the "below" anchor layer exists in the current style
999        const below = resolveBelowAnchor(map, layerObj.below);
1000
1001        // When targeting a symbol layer (e.g. road-label), we need to
1002        // re-move all symbol layers at or above that position AFTER adding
1003        // the data layer. Mapbox GL JS v3 renders symbol text+halos in a
1004        // separate compositing pass — simply inserting before the symbol
1005        // layer doesn't always produce correct visual stacking.
1006        const symbolsToRaise = [];
1007        if (below) {
1008            const styleLayers = map.getStyle().layers;
1009            const belowIdx = styleLayers.findIndex(l => l.id === below);
1010            if (belowIdx >= 0) {
1011                for (let i = belowIdx; i < styleLayers.length; i++) {
1012                    if (styleLayers[i].type === 'symbol') {
1013                        symbolsToRaise.push(styleLayers[i].id);
1014                    }
1015                }
1016            }
1017        }
1018
1019        /* --------------------------------------------------
1020         * 1. Resolve GeoJSON data
1021         * -------------------------------------------------- */
1022        let data = layerObj.data;
1023        if (!data && layerObj.url) {
1024            try {
1025                const r = await fetch(layerObj.url);
1026                data = await r.json();
1027            } catch (e) {
1028                console.warn('[GeoJSON] Failed to fetch:', layerObj.url);
1029                resolve();
1030                return;
1031            }
1032        }
1033
1034        if (!data || !data.features) {
1035            console.warn('[GeoJSON] No valid data for layer:', layerObj.name);
1036            resolve();
1037            return;
1038        }
1039
1040        /* --------------------------------------------------
1041         * 2. Add source (once)
1042         * -------------------------------------------------- */
1043        if (!map.getSource(srcId)) {
1044            map.addSource(srcId, {
1045                type: 'geojson',
1046                data
1047            });
1048        }
1049
1050        // Normalize geometry type (blue server uses .toLowerCase())
1051        const type = (layerObj.geometry || 'fill').toLowerCase();
1052        const paint = layerObj.paintProps || {};
1053
1054        console.log('[GeoJSON] Adding layer:', srcId, 'type:', type,
1055            'strokeColor:', paint.strokeColor, 'fillColor:', paint.fillColor);
1056
1057        /* --------------------------------------------------
1058         * 3. Geometry layer
1059         * -------------------------------------------------- */
1060        if (type === 'fill') {
1061            // Categorical fill expression when classes + a style field are
1062            // present but no precomputed glLayers came in. Project-edit
1063            // normally renders glLayers for the categorical case, but if
1064            // a save dropped them (or the layer was imported before
1065            // glLayers existed), we still get the right colour by building
1066            // the match expression here.
1067            const catField = layerObj.styleField || layerObj.colorField || null;
1068            const hasCategorical = !!catField
1069                && layerObj.classes
1070                && typeof layerObj.classes === 'object'
1071                && Object.keys(layerObj.classes).length > 0;
1072            const fillColorExpr = hasCategorical
1073                ? buildCategoricalColorExpr(catField, layerObj.classes, paint.fillColor || '#3388ff')
1074                : (paint.fillColor || '#3388ff');
1075
1076            const fillId = `${srcId}-fill`;
1077            if (!map.getLayer(fillId)) {
1078                map.addLayer({
1079                    id: fillId,
1080                    type: 'fill',
1081                    source: srcId,
1082                    paint: {
1083                        'fill-color': fillColorExpr,
1084                        'fill-opacity': paint.fillAlpha ?? 0.4
1085                    }
1086                }, below);
1087            }
1088            // Outline stroke for fill polygons
1089            const outlineId = `${srcId}-outline`;
1090            if (!map.getLayer(outlineId) && (paint.strokeColor || paint.lineWidth)) {
1091                const outlinePaint = {
1092                    'line-color': paint.strokeColor || '#333',
1093                    'line-width': paint.lineWidth || 2,
1094                    'line-opacity': paint.strokeAlpha ?? 1
1095                };
1096                if (paint.lineDash && paint.lineDash !== 'solid') {
1097                    const dashes = { dashed: [4, 3], dotted: [1, 2], 'dash-dot': [4, 2, 1, 2] };
1098                    if (dashes[paint.lineDash]) outlinePaint['line-dasharray'] = dashes[paint.lineDash];
1099                }
1100                map.addLayer({
1101                    id: outlineId,
1102                    type: 'line',
1103                    source: srcId,
1104                    paint: outlinePaint
1105                }, below);
1106            }
1107        }
1108
1109        if (type === 'line') {
1110            const lineId = `${srcId}-line`;
1111            if (!map.getLayer(lineId)) {
1112                const linePaint = {
1113                    'line-color': paint.strokeColor || '#3388ff',
1114                    'line-width': paint.lineWidth || 2,
1115                    'line-opacity': paint.strokeAlpha ?? 1
1116                };
1117                // Apply dash pattern (dashed, dotted, dash-dot)
1118                if (paint.lineDash && paint.lineDash !== 'solid') {
1119                    const dashes = { dashed: [4, 3], dotted: [1, 2], 'dash-dot': [4, 2, 1, 2] };
1120                    if (dashes[paint.lineDash]) linePaint['line-dasharray'] = dashes[paint.lineDash];
1121                }
1122                map.addLayer({
1123                    id: lineId,
1124                    type: 'line',
1125                    source: srcId,
1126                    paint: linePaint
1127                }, below);
1128            }
1129        }
1130
1131        /* --------------------------------------------------
1132         * 3b. Force symbol layers above data layer
1133         * Mapbox GL v3 composites symbol halos in a separate pass
1134         * that can overlap data layers despite correct stack order.
1135         * Re-moving symbol layers forces a fresh composite.
1136         * -------------------------------------------------- */
1137        for (const symId of symbolsToRaise) {
1138            if (map.getLayer(symId)) {
1139                try { map.moveLayer(symId); } catch {}
1140            }
1141        }
1142
1143        /* --------------------------------------------------
1144         * 4. OPTIONAL LABEL LAYER (symbol)
1145         * -------------------------------------------------- */
1146        // Same contract as the vector path: labelField names an attribute so
1147        // each feature shows its own value; labelText is a literal string
1148        // (Mapbox resolves {FIELD} tokens inside it).
1149        const geoLabelExpr = layerObj.labelField
1150          ? ['get', layerObj.labelField]
1151          : (layerObj.labelText || null);
1152
1153        if (geoLabelExpr) {
1154	  const labelId = `${srcId}-label`;
1155
1156	  refreshLayer(map, labelId); // ✅ force refresh
1157
1158	  map.addLayer({
1159		id: labelId,
1160		type: 'symbol',
1161		source: srcId,
1162		layout: {
1163		  'text-field': geoLabelExpr,
1164		  'text-size': layerObj.labelSize || 12,
1165		  'text-anchor': 'center',
1166		  'text-allow-overlap': false
1167		},
1168		paint: {
1169		  'text-color': layerObj.labelColor || '#000000', // ✅ WORKS NOW
1170		  'text-halo-color': '#ffffff',
1171		  'text-halo-width': 1
1172		}
1173	  }, below);
1174	}
1175
1176        // Store Mapbox layer IDs for legend toggles
1177        const geoType = (layerObj.geometry || 'fill').toLowerCase();
1178        layerObj._mapboxIds = [];
1179        if (geoType === 'fill' && map.getLayer(`${srcId}-fill`)) layerObj._mapboxIds.push(`${srcId}-fill`);
1180        if (geoType === 'line' && map.getLayer(`${srcId}-line`)) layerObj._mapboxIds.push(`${srcId}-line`);
1181        if (map.getLayer(`${srcId}-label`)) layerObj._mapboxIds.push(`${srcId}-label`);
1182        // Outline layer for polygons
1183        if (map.getLayer(`${srcId}-outline`)) layerObj._mapboxIds.push(`${srcId}-outline`);
1184
1185        // If defaultOff, hide all sub-layers initially
1186        if (layerObj.defaultOff) {
1187            for (const id of layerObj._mapboxIds) {
1188                map.setLayoutProperty(id, 'visibility', 'none');
1189            }
1190        }
1191
1192        resolve();
1193    });
1194}
1195
1196function resolveVectorTextField(layer) {
1197    // Explicit labelText wins
1198    if (layer.labelText) return layer.labelText;
1199
1200    // Fallback: use styleField if present
1201    if (layer.styleField) {
1202        return ['get', layer.styleField];
1203    }
1204
1205    return null;
1206}
1207
1208function buildCategoricalColorExpr(styleField, classes, fallback = '#999999') {
1209  if (!styleField || !classes || typeof classes !== 'object') {
1210    return fallback;
1211  }
1212
1213  const entries = Object.entries(classes);
1214  if (!entries.length) return fallback;
1215
1216  // For numeric class keys, use 'step' expression (range-based) instead
1217  // of 'match' (exact). This handles both decimals (sw_ratio: 0, 0.25, 0.5)
1218  // and integer ranges (road width: 15, 22, 28, 36, 50) where values
1219  // between stops should get the lower stop's color.
1220  const allNumeric = entries.every(([v]) => !isNaN(v));
1221
1222  if (allNumeric && entries.length > 1) {
1223    // Sort by numeric value ascending for step expression
1224    const sorted = entries
1225      .map(([v, color]) => [Number(v), color])
1226      .sort((a, b) => a[0] - b[0]);
1227
1228    // 'step' assigns the color for values >= stop and < next stop
1229    // First color is the default (values below the first stop)
1230    return [
1231      'step',
1232      ['get', styleField],
1233      sorted[0][1],  // color for values below first threshold
1234      ...sorted.slice(1).flatMap(([stop, color]) => [stop, color])
1235    ];
1236  }
1237
1238  return [
1239    'match',
1240    ['get', styleField],
1241    ...entries.flatMap(([value, color]) => {
1242      const v = isNaN(value) ? value : Number(value);
1243      return [v, color];
1244    }),
1245    fallback
1246  ];
1247}
1248
1249async function addVectorTileLayer(layer) {
1250  const map = mapInstance;
1251  if (!map) return;
1252
1253  const sourceId = `src-${layer._uid || layer.name}`;
1254  const layerId  = `lyr-${layer._uid || layer.name}`;
1255  const labelId  = `${layerId}-label`;
1256
1257  const sourceLayer =
1258    layer.vectorLayer && layer.vectorLayer !== layer.styleField
1259      ? layer.vectorLayer
1260      : 'layer0';
1261
1262  console.log(
1263    '[Map Core] addVectorTileLayer',
1264    sourceId,
1265    'tiles:',
1266    layer.tiles,
1267    'sourceLayer:',
1268    sourceLayer
1269  );
1270
1271  /* --------------------------------------------------
1272   * 1️⃣ Ensure style is ready
1273   * -------------------------------------------------- */
1274  if (!map.isStyleLoaded()) {
1275    await new Promise(resolve => map.once('styledata', resolve));
1276  }
1277
1278  /* --------------------------------------------------
1279   * 2️⃣ Add source (once)
1280   * -------------------------------------------------- */
1281  if (!map.getSource(sourceId)) {
1282    // Ensure absolute URL — web workers can't resolve relative paths
1283    let tileUrl = layer.tiles;
1284    if (tileUrl && tileUrl.startsWith('/')) {
1285      tileUrl = `${location.origin}${tileUrl}`;
1286    }
1287    map.addSource(sourceId, {
1288      type: 'vector',
1289      tiles: [tileUrl],
1290      minzoom: layer.minzoom ?? 0,
1291      maxzoom: layer.maxzoom ?? 22
1292    });
1293  }
1294
1295  /* --------------------------------------------------
1296   * 3️⃣ Wait one frame so Mapbox registers the source
1297   * -------------------------------------------------- */
1298  await new Promise(requestAnimationFrame);
1299
1300  if (!map.getSource(sourceId)) {
1301    console.error('[Map Core] Source still missing after addSource:', sourceId);
1302    return;
1303  }
1304
1305  // Store sourceId on the layer so utils like detectGeometryType()
1306  // and autoDetectClasses() can query features from this source.
1307  layer._sourceId = sourceId;
1308
1309  // Insert below basemap labels/roads so tile layers don't obscure context
1310  const firstSymbol = map.getStyle().layers.find(
1311    l => l.type === 'symbol' && /label|place|poi/i.test(l.id)
1312  )?.id;
1313  const before = resolveBelowAnchor(map, layer.below) || firstSymbol;
1314
1315/* --------------------------------------------------
1316 * 4️⃣ LINE LAYER (field-aware)
1317 * -------------------------------------------------- */
1318
1319// Auto-assign default LTS color classes if styleField is LTS_MAX
1320// but no classes were saved (mirrors project-edit preview behavior)
1321if (
1322  layer.styleField === 'LTS_MAX' &&
1323  (!layer.classes || !Object.keys(layer.classes).length)
1324) {
1325  layer.classes = {
1326    '1': '#2ecc71',   // very low stress (green)
1327    '2': '#3498db',   // low stress (blue)
1328    '3': '#f1c40f',   // medium stress (yellow)
1329    '4': '#e74c3c'    // highest stress (red)
1330  };
1331}
1332
1333// 🔁 FORCE refresh so style changes apply
1334refreshLayer(map, layerId);
1335
1336// Determine render type from layer.geometry (fill/circle/line)
1337const renderType = layer.geometry === 'fill' ? 'fill'
1338  : layer.geometry === 'circle' ? 'circle' : 'line';
1339
1340const colorExpr = buildCategoricalColorExpr(
1341  layer.styleField, layer.classes, layer.lineColor || layer.baseColor || '#e20808'
1342);
1343
1344const renderPaint = renderType === 'fill'
1345  ? { 'fill-color': colorExpr, 'fill-opacity': layer.fillOpacity ?? 0.6 }
1346  : renderType === 'circle'
1347  ? { 'circle-color': colorExpr, 'circle-radius': layer.circleRadius ?? 5, 'circle-opacity': 0.8 }
1348  : {
1349      'line-color': colorExpr,
1350      'line-width': [
1351        'interpolate', ['linear'], ['zoom'],
1352        layer.minzoom ?? 6, layer.lineWidthStops?.[0]?.[1] ?? 0.6,
1353        layer.maxzoom ?? 14, layer.lineWidthStops?.[1]?.[1] ?? 2.2
1354      ]
1355    };
1356
1357map.addLayer(
1358  {
1359    id: layerId,
1360    type: renderType,
1361    source: sourceId,
1362    'source-layer': sourceLayer,
1363    paint: renderPaint
1364  },
1365  before
1366);
1367
1368  /* --------------------------------------------------
1369   * 5️⃣ OPTIONAL LABEL (SYMBOL) LAYER
1370   * -------------------------------------------------- */
1371  /* Two opt-in ways to ask for labels:
1372   *   labelField — an attribute name, so every feature shows its own value
1373   *                (this is what the "Label features by" picker writes)
1374   *   labelText  — a literal string, kept for layers styled before the
1375   *                picker existed. Mapbox resolves {FIELD} tokens in it.
1376   * Neither is set by default, so an untouched layer stays unlabeled. */
1377  const labelExpr = layer.labelField
1378    ? ['get', layer.labelField]
1379    : (layer.labelText || null);
1380
1381  // Preferred path: one anchor point per area, computed once when the author
1382  // picked the field (see services/mbtilesLabelPoints.js). Labeling the
1383  // polygons directly cannot do this, because the tiler cuts every polygon at
1384  // tile boundaries and each piece then asks for its own label.
1385  const labelPoints = layer.labelField && layer.labelPoints?.features?.length
1386    ? layer.labelPoints
1387    : null;
1388
1389  // The data layer belongs under the basemap's roads and place names, but its
1390  // labels do not. Symbol collisions are won by whichever layer sits higher, so
1391  // leaving these below basemap text let Mapbox drop "Gainesville Area" to keep
1392  // the city label "Gainesville". An area the author asked to name outranks
1393  // that. Legacy labelText layers keep their old position.
1394  const labelBefore = layer.labelField ? undefined : before;
1395
1396  // Ease the text down a few points at wide zooms. At county scale the areas
1397  // are only a couple of hundred pixels apart, and full-size text made the
1398  // longest name lose its collision and vanish. Three points smaller at z8 was
1399  // enough to fit all six Hall County names; by z12 the author's size is back.
1400  const labelSize = layer.labelSize || 12;
1401  const labelSizeExpr = [
1402    'interpolate', ['linear'], ['zoom'],
1403    8, Math.max(8, labelSize - 3),
1404    12, labelSize
1405  ];
1406
1407  if (labelPoints) {
1408  refreshLayer(map, labelId);
1409  const labelSrcId = `${sourceId}-label-pts`;
1410
1411  if (map.getSource(labelSrcId)) {
1412    map.getSource(labelSrcId).setData(labelPoints);
1413  } else {
1414    map.addSource(labelSrcId, { type: 'geojson', data: labelPoints });
1415  }
1416
1417  map.addLayer({
1418    id: labelId,
1419    type: 'symbol',
1420    source: labelSrcId,
1421    layout: {
1422      'text-field': ['get', 'label'],
1423      'text-size': labelSizeExpr,
1424      'text-max-width': 8,
1425      'text-allow-overlap': false
1426    },
1427    paint: {
1428      'text-color': layer.labelColor || '#000000',
1429      'text-halo-color': '#ffffff',
1430      'text-halo-width': 1.5
1431    }
1432  }, labelBefore);
1433
1434} else if (labelExpr) {
1435  refreshLayer(map, labelId); // ✅ force refresh
1436
1437  // Fallback: label the features themselves. Placement has to follow the
1438  // geometry. 'line' walks the label along the feature, which on a polygon
1439  // means repeating the name around its border instead of writing it once
1440  // inside. Trust the tile's own geometry type — layer.geometry is only set
1441  // when someone picked a render type by hand.
1442  const tileGeom = (layer.vectorLayers || [])
1443    .find(v => v.id === sourceLayer)?.geometry || '';
1444  const isArea = layer.geometry === 'fill' || /polygon/i.test(tileGeom);
1445
1446  map.addLayer({
1447    id: labelId,
1448    type: 'symbol',
1449    source: sourceId,
1450    'source-layer': sourceLayer,
1451    layout: {
1452      'text-field': labelExpr,
1453      'text-size': labelSizeExpr,
1454      'text-max-width': 8,
1455      ...(isArea ? {} : { 'symbol-placement': 'line' }),
1456      'text-allow-overlap': false
1457    },
1458    paint: {
1459      'text-color': layer.labelColor || '#000000',
1460      'text-halo-color': '#ffffff',
1461      'text-halo-width': 1.5
1462    }
1463  }, labelBefore);
1464}
1465
1466  // Store render IDs for legend toggles
1467  layer._renderLayerId = layerId;
1468  layer._mapboxIds = [layerId];
1469  if (map.getLayer(labelId)) layer._mapboxIds.push(labelId);
1470
1471  // If defaultOff, hide all sub-layers initially
1472  if (layer.defaultOff) {
1473    for (const id of layer._mapboxIds) {
1474      if (map.getLayer(id)) map.setLayoutProperty(id, 'visibility', 'none');
1475    }
1476  }
1477
1478  // Click handler — show feature attributes in a popup.
1479  // Modifier-key bypass: when the user holds Shift / Cmd / Ctrl,
1480  // stay silent so the click can land on a project feature beneath
1481  // this tile layer (multi-select on a base segment, tract polygon,
1482  // etc.).
1483  map.on('click', layerId, (e) => {
1484    if (!e.features?.length) return;
1485    const oe = e.originalEvent;
1486    if (oe?.shiftKey || oe?.metaKey || oe?.ctrlKey) return;
1487    const props = e.features[0].properties || {};
1488    const rows = Object.entries(props)
1489      .filter(([k]) => !k.startsWith('_'))
1490      .map(([k, v]) => `<tr><td style="padding:1px 6px 1px 0;font-weight:600;white-space:nowrap;">${k}</td><td style="padding:1px 0;">${v ?? ''}</td></tr>`)
1491      .join('');
1492    new mapboxgl.Popup({ maxWidth: '320px' })
1493      .setLngLat(e.lngLat)
1494      .setHTML(`<div style="max-height:280px;overflow-y:auto;font-size:0.8rem;">
1495        <div style="font-weight:700;margin-bottom:4px;">${layer.name || 'Tile Feature'}</div>
1496        <table style="border-collapse:collapse;">${rows}</table>
1497      </div>`)
1498      .addTo(map);
1499  });
1500  map.on('mouseenter', layerId, () => { map.getCanvas().style.cursor = 'pointer'; });
1501  map.on('mouseleave', layerId, () => { map.getCanvas().style.cursor = ''; });
1502}
1503
1504function refreshLayer(map, layerId) {
1505  if (map.getLayer(layerId)) {
1506    map.removeLayer(layerId);
1507  }
1508}
1509
1510export function zoomToLocation(loc) {
1511  if (!mapInstance || !loc) return;
1512
1513  const lng = Number(loc.lng ?? loc.lon);
1514  const lat = Number(loc.lat);
1515  const zoom = Number(loc.zoom) || 14;
1516
1517  if (!Number.isFinite(lng) || !Number.isFinite(lat)) {
1518    console.warn('[ZoomTo] Invalid coords', loc);
1519    return;
1520  }
1521
1522  mapInstance.flyTo({
1523    center: [lng, lat],
1524    zoom,
1525    essential: true,
1526    padding: { top: 60 }
1527  });
1528}
1529
1530let _currentBasemap = 'streets'; // or whatever your default is
1531
1532export function toggleBaseMap() {
1533  if (!mapInstance) return;
1534
1535  _currentBasemap =
1536    _currentBasemap === 'streets' ? 'satellite' : 'streets';
1537
1538  const style =
1539    _currentBasemap === 'satellite'
1540      ? 'mapbox://styles/mapbox/satellite-streets-v12'
1541      : 'mapbox://styles/mapbox/streets-v12';
1542
1543  console.log('[Map] Toggling basemap →', _currentBasemap);
1544
1545  mapInstance.setStyle(style);
1546}

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.