1if ( typeof LmpMapRenderer !== 'function' ) { 2 3/** 4 * LmpMapRenderer 5 */ 6class LmpMapRenderer { 7 8 isInDiviEditor = false; 9 10 storageKey = 'lmp-map-data'; 11 12 restRoute = '/wp-json/lmp-map/v1/get-data'; 13 14 localStorageObject = { 15 geoJson: { 16 modified: 0, 17 shouldUpdate: false, 18 data: {}, 19 }, 20 statesData: { 21 modified: 0, 22 shouldUpdate: false, 23 data: {}, 24 }, 25 }; 26 27 mapsData = {}; // { [elementId]: { map, statesLayer, locationsLayer, pinCounts } } 28 29 infoWinPin = { 30 header: `<div class="info-win-location-header">Newport Academy Locations</div>` 31 } 32 33 constructor() { 34 if ( window !== window.parent && window.location.href.includes('et_pb_preview=true') ) { 35 this.isInDiviEditor = true; 36 } 37 38 document.addEventListener('DOMContentLoaded', () => this.init()); 39 } 40 41 async init() { 42 // preflight 43 if ( ! await this.preflight() ) { 44 return; 45 } 46 47 // setup local storage 48 this.setupLocalData(); 49 50 // check remote modifieds 51 if ( ! this.isInDiviEditor ) { 52 await this.checkRemoteModified(); 53 } 54 55 // get / update remote data 56 await this.checkData(); 57 58 // look for and loop maps 59 this.setupMaps(); 60 61 // add GB preview change events 62 this.setupGutenberg(); 63 } 64 65 async preflight() { 66 // check for remote geojson data 67 var check = await fetch(`${this.restRoute}`, { 68 method: 'POST', 69 headers: { 70 'Content-Type': 'application/json' 71 }, 72 body: JSON.stringify({ key: 'geoJson' }) 73 }) 74 .then(res => { 75 if ( ! res.ok ) { 76 console.error(`Remote call not ok: ${res.status}`); 77 console.log(res); 78 return false; 79 } 80 return res.json(); 81 }) 82 .then(response => { 83 var response = response, 84 check = response?.geoJson?.data; 85 86 if ( ! response || ( check && Array.isArray(check) && check.length < 2 ) ) { 87 console.error('No remote location data'); 88 return false; 89 } else { 90 return true; 91 } 92 }) 93 .catch(err => { 94 console.error('No remote location data', err); 95 return false; 96 }); 97 98 // render notice 99 100 if ( check === true ) { return true; } 101 102 this.maps = document.querySelectorAll('.lmp-map'); 103 104 for (const mapElement of this.maps) { 105 mapElement.innerHTML = '<div style="text-align: center; height: 100%; display: grid; justify-content: center; align-content: center; background-color: red;">No data yet, please add meta fields to Locations!</div>'; 106 } 107 } 108 109 setupMaps(reinit = false) { 110 this.maps = document.querySelectorAll('.lmp-map'); 111 112 for (const mapElement of this.maps) { 113 //if ( reinit === true ) { mapElement.innerHtml = ''; } 114 const id = mapElement.id || `map-${Math.random().toString(36).substr(2, 9)}`; 115 mapElement.id = id; 116 117 this.mapsData[id] = { map: null, pinCounts: {}, statesLayer: null, locationsLayer: null }; 118 119 this.updatePinCounts(id); 120 this.renderMap(mapElement); 121 } 122 } 123 124 setupGutenberg() { 125 const isInEditor = document.body.classList.contains('block-editor-page'); 126 127 if ( isInEditor ) { 128 const { subscribe, select } = wp.data; 129 130 let previous = select('core/editor').getDeviceType(); 131 132 subscribe(() => { 133 const current = select('core/editor').getDeviceType(); 134 135 if ( current !== previous ) { 136 previous = current; 137 138 setTimeout(() => { 139 this.setupMaps(true); 140 }, 2000); 141 } 142 }); 143 } 144 } 145 146 // Model / Data 147 148 setupLocalData() { 149 const cached = this.getLocalData(); 150 151 if ( ! cached ) { 152 localStorage.setItem(this.storageKey, JSON.stringify(this.localStorageObject)); 153 } 154 } 155 156 getLocalData() { 157 let cached = localStorage.getItem(this.storageKey) || false; 158 159 if ( cached && cached != '' ) { 160 cached = JSON.parse(cached); 161 } 162 163 return cached; 164 } 165 166 async checkRemoteModified() { 167 await this.getRemoteModified('geoJson'); 168 await this.getRemoteModified('statesData'); 169 } 170 171 async getRemoteModified(key = '') { 172 const cached = this.getLocalData(); 173 174 await fetch(`${this.restRoute}`, { 175 method: 'POST', 176 headers: { 177 'Content-Type': 'application/json' 178 }, 179 body: JSON.stringify({ key: `${key}Modified` }) 180 }) 181 .then(res => { 182 if ( ! res.ok ) { throw new Error(`HTTP error ${res.status}`); } 183 return res.json(); 184 }) 185 .then(response => { 186 var fileModifiedTime = response[`${key}Modified`]?.data; 187 188 if ( fileModifiedTime ) { 189 let newData = cached; 190 newData[key].modified = fileModifiedTime; 191 newData[key].shouldUpdate = true; 192 localStorage.setItem(this.storageKey, JSON.stringify(newData)); 193 Promise.resolve(true); 194 } else { 195 throw new Error('Invalid response structure'); 196 } 197 }) 198 .catch(err => { 199 console.error('Error fetching remote data:', err); 200 return false;
201 }); 202 } 203 204 async checkData() { 205 const cached = this.getLocalData(); 206 let keys = ['geoJson', 'statesData']; 207 208 for (const id in keys) { 209 const key = keys[id]; 210 211 if ( 212 cached 213 && this.isObject(cached?.[key]) 214 && Object.keys(cached?.[key]).length > 0 215 && ! this.shouldUpdateLocalData('key') 216 ) { 217 return Promise.resolve(cached); 218 } else { 219 await this.updateLocalData(key); 220 } 221 } 222 } 223 224 async updateLocalData(key = '') { 225 const cached = this.getLocalData(); 226 227 return fetch(`${this.restRoute}`, { 228 method: 'POST', 229 headers: { 230 'Content-Type': 'application/json' 231 }, 232 body: JSON.stringify({ key: `${key}` }) 233 }) 234 .then(res => { 235 if ( ! res.ok ) throw new Error(`HTTP error ${res.status}`); 236 return res.json(); 237 }) 238 .then(response => { 239 if ( response?.[key]?.data ) { 240 let newData = cached; 241 newData[key].data = response[key].data; 242 cached[key].shouldUpdate = false; 243 localStorage.setItem(this.storageKey, JSON.stringify(newData)); 244 245 return Promise.resolve(true); 246 } else { 247 throw new Error('Invalid response structure'); 248 } 249 }) 250 .catch(err => { 251 console.error('Error fetching data:', err); 252 return null; 253 }); 254 } 255 256 shouldUpdateLocalData(key = '') { 257 const cached = this.getLocalData(); 258 259 if ( 260 ! cached 261 || ! cached?.[key]?.modified 262 || ! cached[key].modified < 1 263 || !! cached[key].shouldUpdate 264 ) { 265 return true; 266 } else { 267 cached[key].shouldUpdate = false; 268 localStorage.setItem(this.storageKey, JSON.stringify(cached)); 269 } 270 } 271 272 updatePinCounts(mapId) { 273 let cached = this.getLocalData(); 274 275 const pinCounts = {}; 276 let max = 0; 277 278 cached.geoJson.data.features.forEach(feature => { 279 if ( ['Point', 'Polygon'].indexOf(feature.geometry.type) > -1 ) { 280 const key = feature.properties.state || feature.properties.region; 281 if ( ! key ) { return; } 282 283 pinCounts[key] = (pinCounts[key] || 0) + 1; 284 max = Math.max(max, pinCounts[key]); 285 } 286 }); 287 288 this.mapsData[mapId].pinCounts = pinCounts; 289 this.mapsData[mapId].maxPinCount = max; 290 } 291 292 // Views 293 294 renderMap(mapElement) { 295 const mapId = mapElement.id; 296 const map = L.map(mapElement, { 297 minZoom: 3, 298 maxZoom: 12, 299 maxBounds: [ 300 [26.396308, -124.000000], 301 [49.384358, -66.934570] 302 ], 303 maxBoundsViscosity: 1.0, 304 attributionControl: false, 305 // Sleep Controls 306 sleep: true, // false if you want an unruly map 307 sleepTime: 5000, // time(ms) until map sleeps on mouseout 308 wakeTime: 1000, // time(ms) until map wakes on mouseover 309 sleepNote: true, // should the user receive wake instructions? 310 hoverToWake: true, // should hovering wake the map? (non-touch devices only) 311 wakeMessage: 'Hover/Tap to Load', // a message to inform users about waking the map 312 sleepOpacity: .7, // opacity for the sleeping map 313 }); 314 315 // Define the bounds for the continental US 316 const bounds = [ 317 [23, -125], // Southwest corner 318 [49, -67] // Northeast corner 319 ]; 320 321 // Use fitBounds to automatically zoom and center the map 322 map.fitBounds(bounds); 323 324 // Leaflet Freezy 325 L.control.freezeMapControl({ 326 // Whether to immediately freeze the map when the control is added. 327 freezeOnAdd: true, 328 // Opacity of the map container when it's frozen. 329 frozenMapOpacity: 0.5, 330 // Thaw the map when hovering the cursor over it for a certain duration. 331 // Default: Browser-dependent (true for Chromium-based, false for others) 332 hoverToThaw: true, 333 // Amount of time after which hovering thaws the map. [ms] 334 hoverToThawDuration: 1000, 335 // Freeze the map again when leaving the map container with the cursor for a certain 336 // duration. 337 leaveToFreeze: true, 338 // Amount of time after which to freeze the map when the cursor has left the map container. 339 // [ms] 340 leaveToFreezeDuration: 2000,
341 // Whether to display the 'Freeze' button when the map is thawed. 342 freezeButtonWhenThawed: false, 343 // Inner HTML of the 'Freeze' button. 344 freezeButtonInnerHtml: 'ð', 345 // Title (hover text) of the 'Freeze' button. 346 freezeButtonTitle: 'Deactivate map', 347 // Overlay content to show while the map is frozen. This can be either of: 348 // undefined: Use the plugin's standard text overlay 349 // function: Function to evaluate in order to obtain a DOM element tree. 350 // null: Disable the overlay 351 frozenOverlay: undefined, 352 }).addTo(map); 353 354 this.mapsData[mapId].map = map; 355 356 L.tileLayer('https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png?key=cb1_2vtx_1_eaf1b1ea88f39991a92a992b', { 357 maxZoom: 12, 358 attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>, © <a href="https://carto.com/attributions">CARTO</a>', 359 }).addTo(map); 360 361 const infoControl = this.createInfoWinPins(mapId, map); 362 this.createStatesLayer(mapId, map); 363 this.createLocationsLayer(mapId, map, infoControl); 364 //this.createInfoWinLegend(mapId, map); 365 366 const { statesLayer, locationsLayer } = this.mapsData[mapId]; 367 const group = L.featureGroup([locationsLayer, statesLayer]); // ordered for stack 368 369 try { 370 const bounds = group.getBounds(); 371 372 if ( bounds.isValid() ) { 373 if ( window.innerWidth > 1200 ) { 374 map.setZoom(4.5); 375 //map.fitBounds(bounds); 376 //map.setZoom(3); 377 } 378 } else { 379 console.warn('Invalid bounds, using default view'); 380 } 381 } catch ( error ) { 382 console.error('Error setting map bounds:', error); 383 } 384 385 // Bind state click events after layers are added 386 statesLayer.eachLayer(layer => { 387 const stateCode = layer.feature.properties.code; 388 layer.setStyle(this.style(layer.feature, this.mapsData[mapId].pinCounts[stateCode] || 0, mapId)); 389 layer.off(); 390 this.onEachStateFeature(layer.feature, layer, map, mapId); 391 }); 392 393 // Add state legend content reset / close 394 this.setupStateLegendReset(mapId); 395 } 396 397 setupStateLegendReset(mapId = '') { 398 const map = document.getElementById(mapId); 399 400 if ( map ) { 401 map.addEventListener('click', (e) => { 402 if ( e.target && e.target.matches('.info-win-location-content__region-close') ) { 403 this.stateLegendReset(mapId); 404 } 405 }); 406 } 407 } 408 409 stateLegendReset(mapId = 0) { 410 const map = document.getElementById(mapId); 411 const infowin = map.querySelector('.info-win-location-content'); 412 413 if ( map && infowin ) { 414 // empty infowin 415 infowin.innerHTML = ''; 416 // reset map
417 this.mapsData[mapId].map.setView([49.384358, -95.7129], 4); 418 } 419 } 420 421 createStatesLayer(mapId, map) { 422 let cached = this.getLocalData(); 423 424 const statesLayer = L.geoJson(cached.statesData.data, { 425 style: feature => this.style(feature, this.mapsData[mapId].pinCounts[feature.properties.state] || 0, mapId) 426 }); 427 428 statesLayer.addTo(map); 429 this.mapsData[mapId].statesLayer = statesLayer; 430 } 431 432 createLocationsLayer(mapId, map, infoControl) { 433 let cached = this.getLocalData(); 434 435 const locationsLayer = L.geoJson(cached.geoJson.data, { 436 pointToLayer: (feature, latlng) => { 437 if ( latlng.lat && latlng.lng && latlng.lat !== 0 && latlng.lng !== 0 && ! isNaN(latlng.lat) && ! isNaN(latlng.lng) ) { 438 // client wants no markers 439 return L.circleMarker(latlng, { radius: 0, fill: false, stroke: false }); 440 /* 441 return feature.properties.use_pin === '1' 442 ? L.marker(latlng) 443 : L.circleMarker(latlng, { radius: 8, fillColor: '#FCC13B', fillOpacity: 1 }); 444 */ 445 } 446 console.warn('Invalid coordinates for feature:', feature); 447 return null; 448 }, 449 style: feature => 450 feature.geometry.type !== 'Point' 451 ? this.style(feature, this.mapsData[mapId].pinCounts[feature.properties.state] || 0, mapId) 452 : {}, 453 filter: (feature) => { 454 if ( feature.geometry.type === 'Point' ) { 455 const [lng, lat] = feature.geometry.coordinates; 456 return lng !== 0 && lat !== 0 && !isNaN(lng) && !isNaN(lat); 457 } 458 return true; 459 } 460 }); 461 462 locationsLayer.eachLayer(layer => { 463 this.onEachPinFeature(layer.feature, layer, map, infoControl); 464 }); 465 466 locationsLayer.addTo(map).bringToFront(); 467 this.mapsData[mapId].locationsLayer = locationsLayer; 468 } 469 470 // View - InfoWindows 471 472 createInfoWin(name, contentHTML) { 473 const div = L.DomUtil.create("div", name); 474 div.setAttribute('class', `info-win ${name}`); 475 div.innerHTML = contentHTML; 476 477 L.DomEvent.disableClickPropagation(div); 478 L.DomEvent.disableScrollPropagation(div); 479 480 return div; 481 } 482 483 createInfoWinLegend(mapId, map) { 484 const legend = L.control({ position: 'bottomleft' }); 485 486 legend.onAdd = () => { 487 const max = this.mapsData[mapId]?.maxPinCount || 5; 488 const grades = Array.from({ length: max + 1 }, (_, i) => i); 489 let content = [ 490 '<div class="info-win-legend__title">Locations in State</div>', 491 '<div class="info-win-legend__grid">', 492 ]; 493 494 grades.forEach(grade => { 495 const opacity = 0.8; 496 content.push(` 497 <div class="info-win-legend__item"> 498 <span>${grade}</span> 499 <span style="background:${this.getColor(grade, mapId)}; opacity:${opacity};"></span> 500 </div> 501 `); 502 }); 503 504 content.push('</div>'); 505 return this.createInfoWin('info-win-legend', content.join('')); 506 }; 507 508 legend.addTo(map); 509 } 510 511 createInfoWinPins(mapId, map) { 512 const info = L.control({ position: 'topright' }); 513 514 info.onAdd = () => { 515 const newBody = `<div class="info-win-location-content"> 516 <div class="info-win-location-content__region"> 517 <span class="info-win-location-content__region-text default"> 518 Click on the states in blue to explore our Newport Academy locations. 519 </span> 520 <span class="info-win-location-content__region-close"> 521 <span></span> 522 </span> 523 </div> 524 </div>`; 525 const content = `<div class="info-win-location__inner">${this.infoWinPin.header + newBody}</div>`; 526 return this.createInfoWin('info-win-location', content); 527 }; 528 529 info.update = (props) => { 530 if ( ! props ) { return; } 531 532 const { pinCounts, locationsLayer } = this.mapsData[mapId]; 533 this.updateInfo({ properties: props, geometry: { type: 'Point' } }, pinCounts, locationsLayer); 534 }; 535 536 info.addTo(map); 537 538 return info; 539 } 540 541 createPinInfo(title = '', photo = '', url = '', category = '') { 542 if ( photo == '' ) { 543 photo = `${window.location.origin}/apple-touch-icon-180x180.png`; 544 } 545 546 return ` 547 <div class="info-win-location-item"> 548 <div class="info-win-location-item__img"> 549 ${photo ? `<img src="${photo}" alt="Location photo of ${title}" />` : ''} 550 </div> 551 <div class="info-win-location-item__title">${title}</div> 552 ${category ? `<div class="info-win-location-item__category">${category}</div>` : ''} 553 <div class="info-win-location-item__link"> 554 ${url ? `<a class="et_pb_button" href="${url}" target="_blank">Visit Location</a>` : ''} 555 </div> 556 </div>`; 557 } 558 559 updateInfo(feature, pinCounts, locationsLayer) { 560 const info = document.querySelector('.info-win-location__inner'); 561 if ( ! info ) { return; } 562 563 let content = ''; 564 let region = ''; 565 let stateOrRegion = feature.properties.state || feature.properties.region || feature.properties.code || 'Unknown'; 566 let category = feature.properties.category || ''; 567 568 if ( feature.geometry.type === 'Point' ) { 569 content = this.createPinInfo(feature.properties.title, feature.properties.photo, feature.properties.url, category); 570 } else { 571 const pins = locationsLayer?.getLayers() 572 .filter(layer => { 573 const props = layer.feature.properties; 574 return props.state === stateOrRegion || props.region === stateOrRegion; 575 }) 576 .map(layer => ({ 577 title: layer.feature.properties.title || '', 578 photo: layer.feature.properties.photo || '', 579 url: layer.feature.properties.url || '', 580 category: layer.feature.properties.category || '' 581 })); 582 583 region = `<div class="info-win-location-content__region"><span class="info-win-location-content__region-text">${feature.properties.name}</span><span class="info-win-location-content__region-close"><span></div>`; 584 content = pins.map(pin => this.createPinInfo(pin.title, pin.photo, pin.url, pin.category)).join(''); 585 } 586 587 info.innerHTML = `${this.infoWinPin.header} 588 <div class="info-win-location-content"> 589 ${region} 590 <div class="info-win-location-grid">${content}</div> 591 </div>`; 592 } 593 594 // Event(ish) 595
596 onEachStateFeature(feature, layer, map, mapId) { 597 const { pinCounts, locationsLayer } = this.mapsData[mapId]; 598 const stateCode = layer.feature.properties.code; 599 const pinCount = this.mapsData[mapId].pinCounts[stateCode]; 600 601 layer.on({ 602 mouseover: (e) => { 603 this.onHoverFeature(e, pinCount); 604 }, 605 mouseout: (e) => { 606 this.resetHighlight(e, pinCount, mapId) 607 }, 608 click: (e) => { 609 if ( pinCount > 0 && typeof pinCount === 'number' ) { 610 this.updateInfo(feature, pinCounts, locationsLayer); 611 this.zoomToFeature(e, map); 612 } 613 } 614 }); 615 } 616 617 onEachPinFeature(feature, layer, map, infoControl) { 618 layer.on({ 619 click: () => { 620 infoControl.update(feature.properties); 621 } 622 }); 623 } 624 625 zoomToFeature(e, map) { 626 if ( e.target.getBounds ) { 627 map.fitBounds(e.target.getBounds()); 628 } 629 } 630 631 onHoverFeature(e, pinCount = 0) { 632 var layer = e.target; 633 634 layer.setStyle({ 635 weight: 2, 636 color: 'white', 637 dashArray: '', 638 fillColor: ( pinCount < 1 ) ? '#999999' : '#FCC13B', //#92A8D1 639 fillOpacity: 1 640 }); 641 642 //layer.bringToFront(); 643 } 644 645 // Utils 646 647 getColor(pinCount, mapId) { 648 const max = this.mapsData[mapId]?.maxPinCount || 5; 649 if ( max === 0 ) { return '#cccccc'; } 650 651 const t = Math.min(pinCount / max, 1); 652 const r = Math.round(204 + (39 - 204) * t); 653 const g = Math.round(204 + (41 - 204) * t); 654 const b = Math.round(204 + (82 - 204) * t); 655 656 return `rgb(${r},${g},${b})`; 657 } 658 659 style(feature, pinCount, mapId) { 660 return { 661 weight: 2, 662 opacity: 1, 663 color: 'white', 664 dashArray: '', 665 fillOpacity: 1, //(pinCount < 1) ? 0.95 : 0.8, 666 fillColor: (pinCount < 1) ? '#888888' : '#272952', // #4b70b3 667 //fillColor: this.getColor(pinCount, mapId) 668 }; 669 } 670 671 resetHighlight(e, pinCount = 0, mapId = 0) { 672 this.mapsData[mapId].statesLayer.eachLayer((layer) => { 673 if ( layer === e.target ) { 674 const stateCode = layer.feature.properties.code; 675 layer.setStyle(this.style(layer.feature, this.mapsData[mapId].pinCounts[stateCode] || 0, mapId)); 676 } 677 }); 678 } 679 680 isObject(obj) { 681 return (obj && typeof obj === 'object' && ! Array.isArray(obj) ); 682 } 683 684} 685 686new LmpMapRenderer(); 687}
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.