1/** Embeds a maplibregl map 2 * 3 * Options: 4 * 5 * width default: 100% 6 * height default: 400px 7 * stylesheet default: /orca/libs/maplibregl/base-style.json 8 * lng default: 9.99 9 * lat default: 53.55 10 * zoom default: 8 11 * pitch default: 0 12 * scroll-zoom default: false 13 * 14 * 15 */ 16 17class OrcaMap extends HTMLElement { 18 constructor() { 19 super(); 20 this.langCode = 'de'; 21 this.map = null; 22 this.bbox = []; 23 this.swLng = Infinity; 24 this.swLat = Infinity; 25 this.neLng = -Infinity; 26 this.neLat = -Infinity; 27 this.currentLayerGroup = undefined; 28 this.maplibregl_version = '5.14.0'; 29 30 this.t = { 31 weiter: 'Weiter', 32 }; 33 34 this.hideSearch = false; 35 36 this.sourceData = {}; 37 38 this.attachShadow({ mode: 'open' }); 39 this.shadowRoot.innerHTML = ` 40 <link rel="stylesheet" href="/orca/libs/maplibregl/${this.maplibregl_version}/maplibre-gl.css"> 41 <style> 42 fieldset { 43 border-color: #ddd; 44 border-width: 1px; 45 border-radius: 4px; 46 } 47 #orcamap-layout { 48 display: flex; 49 flex-direction: column; 50 height: 100%; 51 } 52 #orcamap-ui { 53 display: none; 54 flex: 0 0 auto; 55 padding: 16px; 56 } 57 #orcamap-ui-legend .orcamap-ui-legend-layergroup { 58 display: grid; 59 grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); 60 gap: 1rem; 61 } 62 #orcamap-ui-legend .orcamap-ui-legend-source { 63 display: grid; 64 grid-template-columns: 50px 1fr 30px 30px; 65 gap: 8px; 66 align-items: center; 67 box-shadow: 0 2px 5px 0 rgba(100,100,100,.3); 68 background-color: white; 69 transition: .3s ease; 70 position: relative; 71 top: 0; 72 user-select: none; 73 } 74 #orcamap-ui-legend .orcamap-ui-legend-source img { 75 max-width: 50px; 76 } 77 #orcamap-map { 78 flex: 1; 79 box-shadow: 0 1px 5px rgba(0,0,0,.4); 80 } 81 .hover-zoom { 82 transition: .1s ease; 83 transform: scale(1); 84 } 85 .hover-zoom:hover { 86 transform: scale(1.1); 87 } 88 .modal { 89 border:none; 90 box-shadow: 0 2px 8px rgba(0,0,0,0.6); 91 border-radius: 4px; 92 padding: 0; 93 min-width: 400px; 94 } 95 .modal::backdrop { 96 background-color: rgba(0,0,0,0.6); 97 backdrop-filter: blur(3px); 98 } 99 #orcamap-modal-close { 100 display:flex; 101 padding:8px 8px 0; 102 justify-content:end; 103 user-select: none; 104 cursor:pointer; 105 height: 20px; 106 margin: 1px; 107 background: white; 108 } 109 #orcamap-modal-body { 110 padding: 16px; 111 max-height: 90vh; 112 overflow: auto; 113 } 114 #orcamap-modal-body h3 { 115 margin: 0 0 8px; 116 } 117 #orcamap-modal-body a, 118 #orcamap-modal-body a:link, 119 #orcamap-modal-body a:visited { 120 color: #cc0502; 121 text-decoration: none; 122 padding-left: 25px; 123 background-image:url(/orca/img/chevron-right.svg); 124 background-size: contain; 125 background-position: left center; 126 background-repeat: no-repeat; 127 transition: .3s ease; 128 } 129 #orcamap-modal-body a:hover { 130 background-position: 3px center; 131 }
132 .modal-entry { 133 box-shadow: 0 2px 5px 0 rgba(100,100,100,.3); 134 background-color: white; 135 transition: .3s ease; 136 position: relative; 137 top: 0; 138 user-select: none; 139 padding: 8px; 140 margin-bottom: 8px; 141 cursor: pointer; 142 } 143 .modal-entry:hover { 144 background-color: #fefefe; 145 top: -3px; 146 box-shadow: 0 5px 5px 0 rgba(100,100,100,.3); 147 } 148 .modal-entry span { 149 font-size: 0.6em; 150 pointer-events:none; 151 } 152 </style> 153 <div id="orcamap-layout"> 154 <div id="orcamap-ui"> 155 <div id="orcamap-ui-legend"> 156 <div class="orcamap-ui-legend-layergroup"></div> 157 </div> 158 <div class="orcamap-ui-search"> 159 <fieldset> 160 <legend>Suchen</legend> 161 <img src="/orca/img/search.svg" style="width:20px"/> <input placeholder="Durchsuchen Sie den Hafenplan" id="orcamap-ui-search-field" style="width:300px;padding:4px;font-size:1em;"/> 162 </fieldset> 163 </div> 164 </div> 165 <div id="orcamap-map"></div> 166 </div> 167 <dialog id="orcamap-modal" class="modal"> 168 <div id="orcamap-modal-close"><img src="/orca/img/x.svg" style="width: 20px"/></div> 169 <div id="orcamap-modal-body"></div> 170 </dialog> 171 `; 172 173 this.modalElementSearch = document.createElement('div'); 174 this.modalElementSearch.innerHTML = ` 175 <div><input id="orca-modal-search-field" style="width:calc(100% - 16px);font-size:1em;padding:4px;" placeholder="Durchsuchen Sie den Hafenplan" focus/></div> 176 <div id="orca-modal-search-results"></div> 177 `; 178 this.modalElementSearch.addEventListener('keyup',(e)=>{ 179 this.handleModalSearch(e); 180 }); 181 this.modalElementSearch.addEventListener('click',(e)=>{ 182 this.handleModalClick(e); 183 }); 184 185 this.handleModalSearch = this.handleModalSearch.bind(this); 186 this.handleSourceVisibility = this.handleSourceVisibility.bind(this); 187 this.handleFeatureList = this.handleFeatureList.bind(this); 188 189 this.modalElement = this.shadowRoot.getElementById('orcamap-modal'); 190 this.modalElementBody = this.shadowRoot.getElementById('orcamap-modal-body'); 191 192 this.shadowRoot.getElementById('orcamap-modal-close').addEventListener('click', ()=>{ 193 this.modalElement.close(); 194 }); 195 this.shadowRoot.getElementById('orcamap-modal-body').addEventListener('click', (e)=>{ 196 this.handleModalClick(e); 197 }); 198 this.shadowRoot.getElementById('orcamap-ui-search-field').addEventListener('click', (e)=>{ 199 this.modalElementBody.innerHTML = ''; 200 this.modalElementBody.appendChild(this.modalElementSearch); 201 this.modalElement.showModal(); 202 }); 203 } 204 205 connectedCallback() { 206 this.style.width = this.getAttribute("width") || "100%"; 207 this.style.height = this.getAttribute("height") || "400px"; 208 this.style.display = "block"; 209 this.checkAndLoadDependencies(() => { 210 this.initializeMap(); 211 }); 212 } 213 214 checkAndLoadDependencies(callback) { 215 const loadCSS = () => { 216 if (!document.querySelector('link[href*="maplibre-gl.css"], link[href*="maplibre-gl.min.css"]')) { 217 const cssLink = document.createElement("link"); 218 cssLink.href = "/orca/libs/maplibregl/"+this.maplibregl_version+"/maplibre-gl.css"; 219 cssLink.rel = "stylesheet"; 220 document.head.appendChild(cssLink); 221 } 222 }; 223 224 if (typeof maplibregl === "undefined") { 225 const script = document.createElement("script"); 226 script.src = "/orca/libs/maplibregl/"+this.maplibregl_version+"/maplibre-gl.js"; 227 script.onload = () => { 228 loadCSS(); 229 callback(); 230 }; 231 script.onerror = function () { 232 console.error("The MapLibre script could not be loaded."); 233 }; 234 document.head.appendChild(script); 235 } else { 236 loadCSS(); 237 callback(); 238 } 239 } 240 241 initializeMap() { 242 if (!this.map) { 243 const mapOptions = { 244 container: this.shadowRoot.getElementById('orcamap-map'), 245 style: this.getAttribute("stylesheet") || "/orca/libs/maplibregl/base-style.json", 246 center: [parseFloat(this.getAttribute("lng") || 9.99), parseFloat(this.getAttribute("lat") || 53.55)], 247 zoom: parseFloat(this.getAttribute("zoom") || 8), 248 pitch: parseFloat(this.getAttribute("pitch") || 0), 249 cooperativeGestures: true, 250 };
251 this.map = new maplibregl.Map(mapOptions); 252 this.map.addControl(new maplibregl.NavigationControl()); 253 254 this.hideSearch = this.getAttribute("hide-search") || false; 255 256 this.langCode = this.getAttribute("lang-code") || 'de'; 257 258 if(this.langCode == 'en') { 259 this.t["weiter"] = 'Read More'; 260 } 261 262 this.map.on('load', async () => { 263 264 const layer_groups = this.querySelectorAll("orca-map-layer-group"); 265 await this.loadLayerGroups(layer_groups); 266 267 if (this.bbox.length > 1) { 268 this.map.fitBounds([[this.swLng, this.swLat], [this.neLng, this.neLat]], { padding: 200, maxZoom: 16 }); 269 } 270 }); 271 272 setTimeout(()=>{this.addMarkers();}, 200); 273 //if (this.getAttribute("scroll-zoom") != "true") this.map.scrollZoom.disable(); 274 if(this.hideSearch == 'true') { 275 this.shadowRoot.querySelector('.orcamap-ui-search').style.display = 'none'; 276 } 277 } 278 } 279 280 async loadLayerGroups(layer_groups) { 281 for (const layer_group of layer_groups) { 282 this.currentLayerGroup = document.createElement('fieldset'); 283 this.currentLayerGroup.innerHTML = '<legend>' + layer_group.getAttribute('name') + '</legend><div class="orcamap-ui-legend-layergroup"></div>'; 284 const sources = layer_group.querySelectorAll("orca-map-source"); 285 await this.loadSources(sources); 286 this.shadowRoot.querySelector('#orcamap-ui-legend').appendChild(this.currentLayerGroup); 287 this.currentLayerGroup = undefined; 288 }; 289 } 290 291 async loadSources(sources) { 292 for (let source of sources) { 293 const content = atob(source.textContent); 294 const source_name = source.getAttribute('name'); 295 let features; 296 try { 297 features = JSON.parse(content); 298 //console.log(features); 299 } catch (e) { 300 return console.error(e); 301 } 302 303 const image = await this.map.loadImage(features.icon); 304 this.map.addImage(source_name, image.data); 305 306 this.map.addSource(source_name, { 307 type: 'geojson', 308 data: features.geojson, 309 promoteId: 'id' 310 }); 311 312 this.sourceData[source_name] = {}; 313 this.sourceData[source_name].color = '#' + features.color; 314 this.sourceData[source_name].title = features.title; 315 316 this.map.addLayer({ 317 'id': source_name, 318 'type': features.type ?? 'symbol', 319 'source': source_name, 320 'maxzoom': features.maxZoom ?? 24, 321 'minzoom': features.minZoom ?? 1, 322 'layout': features.layout ?? {}, 323 'paint': features.paint ?? {}, 324 }); 325 this.map.setLayoutProperty(source_name, 'visibility', 'visible'); 326 327 const wrapper = document.createElement('div'); 328 wrapper.classList.add('orcamap-ui-legend-source'); 329 330 const imgDiv = document.createElement('div'); 331 imgDiv.innerHTML = `<img src="${features.icon}"/>`; 332 333 wrapper.appendChild(imgDiv); 334 335 const titleDiv = document.createElement('div'); 336 titleDiv.style.fontWeight = 'bold'; 337 titleDiv.style.color = '#' + features.color; 338 titleDiv.innerHTML = `${features.title}`; 339 340 wrapper.appendChild(titleDiv); 341 342 const featuresDiv = document.createElement('div'); 343 featuresDiv.style.cursor = 'pointer'; 344 featuresDiv.classList.add('hover-zoom'); 345 featuresDiv.innerHTML = `<img src="/orca/img/list.svg" style="pointer-events:none;width:20px;"/>`; 346 featuresDiv.setAttribute('data-source', source_name); 347 featuresDiv.addEventListener('click', this.handleFeatureList); 348 349 wrapper.appendChild(featuresDiv); 350 351 const visibilityDiv = document.createElement('div'); 352 visibilityDiv.style.cursor = 'pointer'; 353 visibilityDiv.classList.add('hover-zoom'); 354 visibilityDiv.innerHTML = `<img src="/orca/img/eye.svg" style="pointer-events:none;width:20px;"/>`; 355 visibilityDiv.setAttribute('data-source', source_name); 356 visibilityDiv.setAttribute('data-visible', 'true'); 357 visibilityDiv.addEventListener('click', this.handleSourceVisibility); 358 359 wrapper.appendChild(visibilityDiv); 360 361 if(!source_name.includes('-3d')) 362 this.currentLayerGroup.querySelector('.orcamap-ui-legend-layergroup').appendChild(wrapper); 363 364 const popup = new maplibregl.Popup({
365 closeButton: false, 366 closeOnClick: false 367 }); 368 369 let currentFeatureCoordinates = undefined; 370 this.map.on('mousemove', source_name, (e) => { 371 let featureCoordinates; 372 if(Array.isArray(e.features[0].geometry.coordinates)) { 373 featureCoordinates = e.features[0].geometry.coordinates[0].toString(); 374 } else { 375 featureCoordinates = e.features[0].geometry.coordinates.toString(); 376 } 377 if (currentFeatureCoordinates !== featureCoordinates) { 378 currentFeatureCoordinates = featureCoordinates; 379 this.map.getCanvas().style.cursor = 'pointer'; 380 const coordinates = this.getCentroid(e.features[0]); 381 const description = e.features[0].custom_popup_text ? e.features[0].custom_popup_text : e.features[0].properties.title; 382 while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) { 383 coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360; 384 } 385 popup.setLngLat(coordinates).setHTML(description).setOffset(JSON.parse(e.features[0].properties.popup_offset)).addTo(this.map); 386 } 387 }); 388 389 this.map.on('click', source_name, (e) => { 390 this.handleFeatureClick(e.features[0]); 391 }); 392 393 this.map.on('mouseleave', source_name, () => { 394 currentFeatureCoordinates = undefined; 395 this.map.getCanvas().style.cursor = ''; 396 popup.remove(); 397 }); 398 399 this.shadowRoot.getElementById('orcamap-ui').style.display = 'block'; 400 401 source = this.map.getSource(source_name); 402 features = source._data.features; 403 404 await this.updateBbox(features); 405 }; 406 } 407 408 async updateBbox(features) { 409 features.forEach(feature => { 410 const lng = feature.geometry.coordinates[0]; 411 const lat = feature.geometry.coordinates[1]; 412 this.bbox.push([lng, lat]); 413 if (lng < this.swLng) this.swLng = lng; 414 if (lat < this.swLat) this.swLat = lat; 415 if (lng > this.neLng) this.neLng = lng; 416 if (lat > this.neLat) this.neLat = lat; 417 }); 418 } 419 420 handleSourceVisibility(e) { 421 const source_name = e.target.getAttribute('data-source'); 422 if(source_name) { 423 const visible = e.target.getAttribute('data-visible'); 424 if(visible == 'true') { 425 e.target.setAttribute('data-visible', 'false'); 426 e.target.querySelector('img').setAttribute('src', '/orca/img/eye-off.svg'); 427 this.map.setLayoutProperty(source_name, 'visibility', 'none'); 428 if(this.map.getSource(source_name+'-3d')) 429 this.map.setLayoutProperty(source_name+'-3d', 'visibility', 'none'); 430 e.target.previousSibling.style.visibility = 'hidden'; 431 e.target.parentElement.style.backgroundColor = '#f2f2f2'; 432 e.target.parentElement.style.opacity = '0.6'; 433 } else { 434 e.target.setAttribute('data-visible', 'true'); 435 e.target.querySelector('img').setAttribute('src', '/orca/img/eye.svg'); 436 this.map.setLayoutProperty(source_name, 'visibility', 'visible'); 437 if(this.map.getSource(source_name+'-3d')) 438 this.map.setLayoutProperty(source_name+'-3d', 'visibility', 'visible'); 439 e.target.previousSibling.style.visibility = 'visible'; 440 e.target.parentElement.style.backgroundColor = '#fff'; 441 e.target.parentElement.style.opacity = '1.0'; 442 } 443 } 444 } 445 446 async handleFeatureList(e) { 447 const source_name = e.target.getAttribute('data-source'); 448 if(source_name) { 449 const data = await this.map.getSource(source_name).getData(); 450 const features = data.features; 451 const uniqueFeatures = this.getUniqueFeatures(features, 'id'); 452 uniqueFeatures.sort((a, b) => { 453 const titleA = a.properties.title?.toLowerCase() || ''; 454 const titleB = b.properties.title?.toLowerCase() || ''; 455 return titleA.localeCompare(titleB); 456 }); 457 this.modalElementBody.innerHTML = '';
458 uniqueFeatures.forEach((feature) => { 459 const coords = this.getCentroid(feature); 460 this.modalElementBody.innerHTML += `<div class="modal-entry" data-source="${source_name}" data-feature-id="${feature.properties.id}">${feature.properties.title}<br><span style="color:${this.sourceData[source_name].color}">${this.sourceData[source_name].title}</span><br><span>Lat: ${coords[0].toFixed(4)} Lng: ${coords[1].toFixed(4)}</span></div>`; 461 }); 462 this.modalElement.showModal(); 463 } 464 } 465 466 handleFeatureClick(feature) { 467 const callback = JSON.parse(feature.properties.callback); 468 if(callback) { 469 this.handleCallback(callback); 470 } 471 } 472 473 async handleModalClick(e) { 474 if(e.target.classList.contains('modal-entry')) { 475 const target = e.target; 476 const data = await this.map.getSource(target.getAttribute('data-source')).getData(); 477 const features = data.features; 478 const matching_feature = this.getUniqueFeatures(features, 'id').find(feature => parseInt(feature.properties.id) === parseInt(target.getAttribute('data-feature-id'))); 479 const callback = matching_feature.properties.callback; 480 if(callback) { 481 this.handleCallback(callback); 482 } 483 } 484 } 485 486 handleCallback(callback) { 487 switch(callback.function) { 488 case "pageRef": 489 if(!callback.clickable) return; 490 const url = "/"+this.langCode+"/ajax/portmap-func/get-pageref-data/" + callback.data.pageid + "/"; 491 fetch(url) 492 .then(response => response.json()) 493 .then(data => { 494 this.modalElement.close(); 495 if (data.error) return; 496 497 const img = data.img || undefined; 498 const title = data.title || undefined; 499 const body = data.body || undefined; 500 const url = data.url || undefined; 501 502 let out = ''; 503 if(title) out += '<div><h3>' + title + '</h3></div>'; 504 if(img) out += `<div><img src="${img}" alt="${data.title}" style="max-width:100%"/></div>`; 505 if(body) out += body; 506 if(url && !callback.nomodallink) out += ` 507 <div style="padding: 0 15px; text-align: right"> 508 <a href="${data.url}" title="${data.title}">${this.t["weiter"]}</a> 509 </div> 510 `; 511 512 this.modalElementBody.innerHTML = out; 513 this.modalElement.showModal(); 514 }) 515 .catch(error => console.error("Error fetching page data:", error)); 516 517 break; 518 519 default: 520 console.log(callback.function + ' to be implemented'); 521 } 522 } 523 524 handleModalSearch(e) { 525 const searchTerm = this.shadowRoot.getElementById('orca-modal-search-field').value; 526 if(searchTerm.length < 2) return; 527 const matches = []; 528 const lowerTerm = searchTerm.toLowerCase(); 529 const style = this.map.getStyle(); 530 Object.keys(style.sources).forEach(sourceId => { 531 if(sourceId.includes('-3d')) return; 532 const sourceData = style.sources[sourceId]; 533 if (sourceData.type !== 'geojson') return; 534 535 const source = this.map.getSource(sourceId); 536 if (!source || !source._data?.features) return; 537 538 source._data.features.forEach(feature => { 539 if (feature.properties?.title?.toLowerCase().includes(lowerTerm) && this.map.getLayoutProperty(sourceId, 'visibility') == 'visible') { 540 matches.push({ sourceId, feature }); 541 } 542 }); 543 }); 544 this.shadowRoot.getElementById('orca-modal-search-results').innerHTML = ''; 545 matches.forEach((match) => { 546 const feature = match.feature; 547 const coords = this.getCentroid(feature); 548 this.shadowRoot.getElementById('orca-modal-search-results').innerHTML += `<div class="modal-entry" data-source="${match.sourceId}" data-feature-id="${feature.properties.id}">${feature.properties.title}<br><span style="color:${this.sourceData[match.sourceId].color}">${this.sourceData[match.sourceId].title}</span><br><span>Lat: ${coords[0].toFixed(4)} Lng: ${coords[1].toFixed(4)}</span></div>`; 549 }); 550 } 551 552 getUniqueFeatures(features, keyProperty) { 553 const seen = new Set(); 554 return features.filter(feature => {
555 const key = feature.properties[keyProperty] || 556 JSON.stringify(feature.geometry.coordinates); 557 if (seen.has(key)) return false; 558 seen.add(key); 559 return true; 560 }); 561 } 562 563 getCentroid(feature) { 564 const type = feature.geometry.type; 565 const coords = feature.geometry.coordinates; 566 let sumLng = 0, sumLat = 0, count = 0; 567 568 if (type === 'Point') { 569 return coords; 570 } 571 572 function accumulate(coords) { 573 if (Array.isArray(coords[0])) { 574 coords.forEach(accumulate); 575 } else { 576 sumLng += coords[0]; 577 sumLat += coords[1]; 578 count++; 579 } 580 } 581 582 accumulate(coords); 583 return count ? [sumLng / count, sumLat / count] : [0, 0]; 584 } 585 586 addMarkers() { 587 const markers = this.querySelectorAll("orca-map-marker"); 588 const bbox = []; 589 let swLng = Infinity, swLat = Infinity; 590 let neLng = -Infinity, neLat = -Infinity; 591 592 markers.forEach((marker) => { 593 const lng = parseFloat(marker.getAttribute("lng")); 594 const lat = parseFloat(marker.getAttribute("lat")); 595 let popuptext = '<h2 style="font-size:16px;line-height:1.5em">' + marker.getAttribute("title") + "</h2>"; 596 if (marker.getAttribute("link")) 597 popuptext += '<div style="padding:8px 0;width:100%;text-align:right"><a href="' + marker.getAttribute("link") + '" title="">' + marker.getAttribute("linktext") + "</a></div>"; 598 const popup = new maplibregl.Popup({ offset: 45, closeOnClick: false, closeButton: false }).setHTML(popuptext); 599 bbox.push([lng, lat]); 600 if (lng < swLng) swLng = lng; 601 if (lat < swLat) swLat = lat; 602 if (lng > neLng) neLng = lng; 603 if (lat > neLat) neLat = lat; 604 605 new maplibregl.Marker({ element: this.createMarkerElement(marker), offset: [0, -25] }).setLngLat([lng, lat]).setPopup(popup).addTo(this.map); //.togglePopup(); 606 }); 607 if (bbox.length > 1) { 608 const padding = parseInt(this.offsetWidth / 10); 609 this.map.fitBounds([[swLng, swLat], [neLng, neLat]], { padding: padding }); 610 } 611 } 612 613 createMarkerElement(marker) { 614 const element = document.createElement("div"); 615 const img = document.createElement("img"); 616 img.src = marker.getAttribute("icon") || "/orca/libs/maplibregl/base-icon.png"; 617 img.title = marker.getAttribute("title"); 618 element.appendChild(img); 619 return element; 620 } 621} 622 623customElements.define("orca-map", OrcaMap);
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.