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.