PageSourceSearch

https://www.hafen-hamburg.de/orca/custom-elements/orcamap.js?589699061

js hafen-hamburg.de collected 2026-09-24 08:59:33 UTC 26,184 bytes, 623 lines download raw bytes

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"/>&nbsp;&nbsp;<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.