1/* 2########################### anthrazit ag ############################ 3### +-----------------------------------------------------------+ ### 4### | Project Name: moPage | ### 5### | swisstopo / OpenLayers map runtime | ### 6### | Replaces the Google Maps JS emitted by GoogleMap.php | ### 7### +-----------------------------------------------------------+ ### 8##################################################################### 9 10Consumes the config produced by SwissMapAPI::getMapJS() and builds an 11OpenLayers map on top of swisstopo WMTS base tiles. 12 13Public entry point: SwissMap.init(config) 14Live instances are exposed on window.swissMaps[mapId] so that per-page 15listener code and the geometry library can reach the ol/Map instance. 16*/ 17 18(function (global) { 19 'use strict'; 20 21 if (typeof ol === 'undefined') { 22 console.error('SwissMap: OpenLayers (ol) is not loaded'); 23 return; 24 } 25 26 global.swissMaps = global.swissMaps || {}; 27 28 // swisstopo WMTS layers (EPSG:3857 / web mercator) and their tile format 29 var WMTS_BASE = 'https://wmts.geo.admin.ch/1.0.0/'; 30 var LAYER_FORMAT = { 31 'ch.swisstopo.pixelkarte-farbe': 'jpeg', 32 'ch.swisstopo.pixelkarte-grau': 'jpeg', 33 'ch.swisstopo.swissimage': 'jpeg' 34 }; 35 36 function wmtsUrl(layer) { 37 var fmt = LAYER_FORMAT[layer] || 'png'; 38 return WMTS_BASE + layer + '/default/current/3857/{z}/{x}/{y}.' + fmt; 39 } 40 41 // swisstopo vector-tile styles (MapLibre spec) applied via ol-mapbox-style. 42 // maps the former raster layer ids onto the matching vector style. 43 var VECTOR_STYLE = { 44 'ch.swisstopo.pixelkarte-farbe': 'ch.swisstopo.lightbasemap.vt', 45 'ch.swisstopo.pixelkarte-grau': 'ch.swisstopo.lightbasemap.vt', 46 'ch.swisstopo.swissimage': 'ch.swisstopo.imagerybasemap.vt' 47 }; 48 49 function vectorStyleUrl(baseId) { 50 var style = VECTOR_STYLE[baseId] || 'ch.swisstopo.lightbasemap.vt'; 51 return 'https://vectortiles.geo.admin.ch/styles/' + style + '/style.json'; 52 } 53 54 // build the swisstopo base layer: vector tiles (ol-mapbox-style) when available, 55 // otherwise fall back to the raster WMTS tiles. The swisstopo styles contain 56 // several sources (relief + base), so apply() into a Group renders them all 57 // (applyStyle on a single layer would only show the first source -> relief only). 58 function makeBaseLayer(baseId) { 59 baseId = baseId || 'ch.swisstopo.pixelkarte-farbe'; 60 if (typeof olms !== 'undefined' && ol.layer.Group) { 61 var group = new ol.layer.Group(); 62 olms.apply(group, vectorStyleUrl(baseId)).catch(function (e) { 63 console.error('SwissMap: vector style failed, check ol-mapbox-style', e); 64 }); 65 return group; 66 } 67 return new ol.layer.Tile({ 68 source: new ol.source.XYZ({ 69 url: wmtsUrl(baseId), 70 attributions: '© <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>', 71 crossOrigin: 'anonymous', 72 maxZoom: 27 73 }) 74 }); 75 } 76 77 // #rrggbb -> rgba(...) helper (mirrors hexToRgbA used by the old marker JS) 78 function hexToRgbA(hex, alpha) { 79 if (!hex) return 'rgba(0,0,0,' + (alpha || 1) + ')'; 80 var c = hex.replace('#', ''); 81 if (c.length === 3) c = c[0] + c[0] + c[1] + c[1] + c[2] + c[2]; 82 var num = parseInt(c, 16); 83 return 'rgba(' + ((num >> 16) & 255) + ',' + ((num >> 8) & 255) + ',' + (num & 255) + ',' + (alpha == null ? 1 : alpha) + ')'; 84 } 85 86 function lonLat(coords) { 87 return ol.proj.fromLonLat(coords); // [lon, lat] -> 3857 88 } 89 90 // --- vector styles used by the clustered marker path --------------------- 91 function singleFeatureStyle(feature) { 92 var m = feature.get('data'); 93 if (m.icon && m.icon.url) { 94 var opt = { src: m.icon.url, anchor: [0.5, 1] }; 95 if (m.icon.w && m.icon.h) { opt.width = m.icon.w; opt.height = m.icon.h; opt.anchor = [0.5, 0.5]; } 96 return new ol.style.Style({ image: new ol.style.Icon(opt) }); 97 } 98 return new ol.style.Style({ 99 image: new ol.style.Circle({ 100 radius: 8, 101 fill: new ol.style.Fill({ color: m.color || '#EA4335' }), 102 stroke: new ol.style.Stroke({ color: '#fff', width: 2 }) 103 }) 104 }); 105 } 106 107 function clusterStyle(size) { 108 return new ol.style.Style({ 109 image: new ol.style.Circle({ 110 radius: 14, 111 fill: new ol.style.Fill({ color: '#3399CC' }), 112 stroke: new ol.style.Stroke({ color: '#fff', width: 2 }) 113 }), 114 text: new ol.style.Text({ 115 text: String(size), 116 font: 'bold 12px sans-serif', 117 fill: new ol.style.Fill({ color: '#fff' }) 118 }) 119 }); 120 } 121 122 /** 123 * Build the DOM element for a marker, reproducing getCreateMarkerJS(): 124 * optional glow wrapper > icon wrapper (img) + label, or a default color pin. 125 * Returns { el, positioning }. 126 */ 127 function buildMarkerElement(m) { 128 var label = null; 129 if (m.label) { 130 label = document.createElement('span'); 131 label.className = 'gm-pin-label'; 132 if (m.labelcolor) label.style.color = m.labelcolor; 133 label.textContent = m.label; 134 } 135 136 var pin; 137 138 if (m.icon && m.icon.url) { 139 var wrap = document.createElement('div'); 140 wrap.className = 'mapmarkerimg antmarker'; 141 var img = document.createElement('img'); 142 img.src = m.icon.url; 143 if (m.icon.w && m.icon.h) { 144 wrap.style.width = m.icon.w + 'px'; 145 wrap.style.height = m.icon.h + 'px'; 146 img.style.width = m.icon.w + 'px'; 147 img.style.height = m.icon.h + 'px'; 148 } 149 wrap.appendChild(img); 150 if (label) wrap.appendChild(label); 151 pin = wrap; 152 } else { 153 // default teardrop pin, colored via CSS 154 pin = document.createElement('div'); 155 pin.className = 'swissmap-pin'; 156 pin.style.backgroundColor = m.color || '#EA4335'; 157 pin.style.borderColor = m.color || '#EA4335'; 158 if (label) pin.appendChild(label); 159 } 160 161 if (m.glow) { 162 var glow = document.createElement('div'); 163 glow.className = 'mapmarkerglow antmarker'; 164 glow.style.backgroundColor = hexToRgbA(m.glowcolor, 0.4); 165 glow.style.width = m.glow + 'px'; 166 glow.style.height = m.glow + 'px'; 167 if (m.icon && m.icon.url && m.icon.w) { 168 var offset = (m.glow - m.icon.w) / 2; 169 pin.style.position = 'relative'; 170 pin.style.left = offset + 'px'; 171 pin.style.top = offset + 'px'; 172 } 173 glow.appendChild(pin); 174 pin = glow; 175 } 176 177 // glow halo is centred on the point; pins/icons anchor at their bottom tip 178 var positioning = m.glow ? 'center-center' : 'bottom-center'; 179 return { el: pin, positioning: positioning }; 180 } 181 182 // when the icon image has no known size yet, OL anchors it with height 0 and 183 // the image then loads downward -> re-apply the position once it has loaded 184 function repositionOnImgLoad(overlay, el) { 185 var img = el.querySelector ? el.querySelector('img') : null; 186 if (img && !img.complete) { 187 img.addEventListener('load', function () { overlay.setPosition(overlay.getPosition()); }); 188 } 189 } 190 191 // map / aerial (Luftbild) base-layer toggle control (top-left, below zoom) 192 function addBaseSwitcher(map, baseLayer, aerialLayer) { 193 var el = document.createElement('div'); 194 el.className = 'swissmap-baseswitcher ol-unselectable ol-control'; 195 var btn = document.createElement('button'); 196 btn.type = 'button'; 197 btn.title = 'Luftbild'; 198 btn.innerHTML = '<i class="fa fa-layer-group"></i>'; 199 el.appendChild(btn); 200 var aerialOn = false;
201 btn.addEventListener('click', function () { 202 aerialOn = !aerialOn; 203 baseLayer.setVisible(!aerialOn); 204 aerialLayer.setVisible(aerialOn); 205 el.classList.toggle('active', aerialOn); 206 btn.title = aerialOn ? 'Karte' : 'Luftbild'; 207 }); 208 map.addControl(new ol.control.Control({ element: el })); 209 } 210 211 // center-to-device (geolocation) control incl. accuracy + position marker 212 function addGeolocate(map) { 213 var el = document.createElement('div'); 214 el.className = 'jsMapCenterDeviceControlWrapper ol-unselectable ol-control'; 215 var btn = document.createElement('button'); 216 btn.type = 'button'; btn.title = 'Mein Standort'; 217 btn.className = 'jsMapCenterDeviceControl'; 218 el.appendChild(btn); 219 map.addControl(new ol.control.Control({ element: el })); 220 221 var geoloc = new ol.Geolocation({ 222 trackingOptions: { enableHighAccuracy: true }, 223 projection: map.getView().getProjection() 224 }); 225 var accFeature = new ol.Feature(); 226 var posFeature = new ol.Feature(); 227 posFeature.setStyle(new ol.style.Style({ 228 image: new ol.style.Circle({ 229 radius: 7, 230 fill: new ol.style.Fill({ color: '#3399CC' }), 231 stroke: new ol.style.Stroke({ color: '#fff', width: 2 }) 232 }) 233 })); 234 map.addLayer(new ol.layer.Vector({ 235 source: new ol.source.Vector({ features: [accFeature, posFeature] }), 236 zIndex: 999 237 })); 238 239 var doCenter = false; 240 geoloc.on('change:accuracyGeometry', function () { accFeature.setGeometry(geoloc.getAccuracyGeometry()); }); 241 geoloc.on('change:position', function () { 242 var c = geoloc.getPosition(); 243 if (!c) return; 244 posFeature.setGeometry(new ol.geom.Point(c)); 245 if (doCenter) { 246 doCenter = false; 247 map.getView().animate({ center: c, zoom: Math.max(map.getView().getZoom(), 16), duration: 300 }); 248 } 249 }); 250 geoloc.on('error', function () { btn.classList.remove('loading'); alert('Positionierung nicht möglich'); }); 251 btn.addEventListener('click', function () { 252 doCenter = true; 253 btn.classList.add('loading'); 254 geoloc.setTracking(true); 255 geoloc.once('change:position', function () { btn.classList.remove('loading'); }); 256 }); 257 } 258 259 function makePopup() { 260 // OL positions the overlay *element* via an inline transform, so we can't 261 // rely on CSS transform for centering on it. Use a zero-size anchor as the 262 // overlay element and center the actual (variable-width) popup on it via the 263 // popup's own transform (see .swissmap-popup in the stylesheet). 264 var anchor = document.createElement('div'); 265 anchor.className = 'swissmap-popup-anchor'; 266 var container = document.createElement('div'); 267 container.className = 'swissmap-popup'; 268 var closer = document.createElement('a'); 269 closer.className = 'swissmap-popup-closer'; 270 closer.href = '#'; 271 closer.innerHTML = '×'; 272 var content = document.createElement('div'); 273 content.className = 'swissmap-popup-content'; 274 container.appendChild(closer); 275 container.appendChild(content); 276 anchor.appendChild(container); 277 var overlay = new ol.Overlay({ 278 element: anchor, 279 autoPan: { animation: { duration: 200 } }, 280 stopEvent: true, 281 positioning: 'bottom-center', 282 offset: [0, -12] 283 }); 284 closer.addEventListener('click', function (e) { 285 e.preventDefault(); 286 overlay.setPosition(undefined); 287 }); 288 return { overlay: overlay, content: content }; 289 } 290 291 // event-name translation from the old google.maps events to ol/Map events 292 var EVENT_MAP = { 293 idle: 'moveend', 294 bounds_changed: 'moveend', 295 center_changed: 'moveend', 296 zoom_changed: 'moveend', 297 dragend: 'moveend', 298 tilesloaded: 'rendercomplete', 299 click: 'click' 300 }; 301 302 /** 303 * Thin wrapper handed to per-page listener callbacks so they don't need to 304 * know OL internals. Mirrors the handful of google.Map methods used. 305 */ 306 function makeWrapper(map, popup) { 307 return { 308 ol: map, 309 getZoom: function () { return Math.round(map.getView().getZoom()); }, 310 getCenter: function () { return ol.proj.toLonLat(map.getView().getCenter()); }, 311 // bounds as {sw:[lon,lat], ne:[lon,lat]} 312 getBounds: function () { 313 var e = map.getView().calculateExtent(map.getSize()); 314 return { sw: ol.proj.toLonLat([e[0], e[1]]), ne: ol.proj.toLonLat([e[2], e[3]]) }; 315 }, 316 setCenter: function (lon, lat) { map.getView().setCenter(lonLat([lon, lat])); }, 317 setZoom: function (z) { map.getView().setZoom(z); }, 318 319 // add a marker after init (used by dynamic POI loaders). 320 // opts may carry {label, labelcolor, color, glow, glowcolor} 321 addMarker: function (lon, lat, iconUrl, html, w, h, opts) { 322 opts = opts || {}; 323 var built = buildMarkerElement({ 324 icon: iconUrl ? { url: iconUrl, w: w, h: h } : null, 325 label: opts.label, labelcolor: opts.labelcolor,
326 color: opts.color, glow: opts.glow, glowcolor: opts.glowcolor 327 }); 328 built.el.style.cursor = 'pointer'; 329 var pos = lonLat([parseFloat(lon), parseFloat(lat)]); 330 var ov = new ol.Overlay({ element: built.el, position: pos, positioning: built.positioning, stopEvent: false }); 331 map.addOverlay(ov); 332 repositionOnImgLoad(ov, built.el); 333 if (html && popup) { 334 built.el.addEventListener('click', function (e) { 335 e.stopPropagation(); 336 popup.content.innerHTML = html; 337 popup.overlay.setPosition(pos); 338 }); 339 } 340 return ov; 341 }, 342 moveMarker: function (ov, lon, lat) { if (ov) ov.setPosition(lonLat([parseFloat(lon), parseFloat(lat)])); }, 343 removeMarker: function (ov) { if (ov) map.removeOverlay(ov); }, 344 showPopup: function (lon, lat, html) { 345 if (!popup) return; 346 popup.content.innerHTML = html; 347 popup.overlay.setPosition(lonLat([parseFloat(lon), parseFloat(lat)])); 348 }, 349 // swisstopo forward search -> recenter (replaces google Geocoder in the pickers) 350 search: function (query, zoom) { 351 if (!query) return; 352 var url = 'https://api3.geo.admin.ch/rest/services/api/SearchServer?type=locations&sr=4326&limit=1&searchText=' + encodeURIComponent(query); 353 fetch(url).then(function (r) { return r.json(); }).then(function (data) { 354 var res = data && data.results && data.results[0]; 355 if (res && res.attrs) { 356 map.getView().setCenter(lonLat([res.attrs.lon, res.attrs.lat])); 357 if (zoom) map.getView().setZoom(zoom); 358 } 359 }).catch(function () {}); 360 } 361 }; 362 } 363 364 var SwissMap = { 365 366 init: function (cfg) { 367 var el = document.getElementById(cfg.mapId); 368 if (!el) { 369 console.error('SwissMap: container #' + cfg.mapId + ' not found'); 370 return; 371 } 372 373 var base = makeBaseLayer(cfg.base); 374 var layers = [base]; 375 376 // optional aerial (Luftbild) layer for the base switcher 377 var aerial = null; 378 if (cfg.aerial) { 379 aerial = makeBaseLayer('ch.swisstopo.swissimage'); 380 aerial.setVisible(false); 381 layers.push(aerial); 382 } 383 384 var controls = ol.control.defaults.defaults({ 385 zoom: cfg.controls.zoom !== false, 386 attribution: true, 387 rotate: false 388 }); 389 if (cfg.controls.scale !== false) controls.push(new ol.control.ScaleLine()); 390 if (cfg.controls.fullscreen === true) controls.push(new ol.control.FullScreen()); 391 392 // static maps (thumbnails) have no interactions at all 393 var interactions = cfg.static 394 ? new ol.Collection() 395 : ol.interaction.defaults.defaults({ mouseWheelZoom: cfg.controls.mouseWheel !== false }); 396 397 var view = new ol.View({ 398 center: lonLat(cfg.center || [8.226692, 46.80121]), 399 zoom: cfg.zoom || 16, 400 maxZoom: 21 401 }); 402 403 var map = new ol.Map({ 404 target: el, 405 layers: layers, 406 controls: controls, 407 interactions: interactions, 408 view: view 409 }); 410 411 if (aerial) addBaseSwitcher(map, base, aerial); 412 if (cfg.geolocate) addGeolocate(map); 413 414 var popup = null; 415 if (cfg.infoWindow !== false) { 416 popup = makePopup(); 417 map.addOverlay(popup.overlay); 418 } 419 420 // --- ground overlay (georeferenced image) --- 421 if (cfg.overlay && cfg.overlay.img) { 422 var b = cfg.overlay.bounds; // [swLat, swLng, neLat, neLng] 423 var ext = ol.extent.boundingExtent([lonLat([b[1], b[0]]), lonLat([b[3], b[2]])]); 424 map.addLayer(new ol.layer.Image({ 425 opacity: cfg.overlay.opacity ? cfg.overlay.opacity / 10 : 1, 426 source: new ol.source.ImageStatic({ url: cfg.overlay.img, imageExtent: ext, crossOrigin: 'anonymous' }) 427 })); 428 } 429 430 // --- KML route overlays --- 431 (cfg.kml || []).forEach(function (url) { 432 map.addLayer(new ol.layer.Vector({ 433 source: new ol.source.Vector({ url: url, format: new ol.format.KML({ extractStyles: true }) }) 434 })); 435 }); 436 437 // --- markers --- 438 if (cfg.clustering && cfg.markers && cfg.markers.length) { 439 // clustered vector markers 440 var features = cfg.markers.map(function (m) { 441 var f = new ol.Feature({ geometry: new ol.geom.Point(lonLat([parseFloat(m.lon), parseFloat(m.lat)])) }); 442 f.set('data', m); 443 return f; 444 }); 445 var clusterSource = new ol.source.Cluster({ 446 distance: (cfg.clusterOptions && cfg.clusterOptions.distance) || 60, 447 source: new ol.source.Vector({ features: features }) 448 }); 449 var styleCache = {}; 450 map.addLayer(new ol.layer.Vector({ 451 source: clusterSource, 452 style: function (feature) { 453 var fs = feature.get('features'); 454 if (fs.length === 1) return singleFeatureStyle(fs[0]); 455 if (!styleCache[fs.length]) styleCache[fs.length] = clusterStyle(fs.length); 456 return styleCache[fs.length]; 457 } 458 })); 459 map.on('click', function (evt) {
460 map.forEachFeatureAtPixel(evt.pixel, function (feat) { 461 var fs = feat.get('features'); 462 if (!fs) return false; 463 if (fs.length === 1) { 464 var m = fs[0].get('data'); 465 if (m.html && popup) { 466 popup.content.innerHTML = m.html; 467 popup.overlay.setPosition(fs[0].getGeometry().getCoordinates()); 468 } 469 } else { 470 var cext = ol.extent.createEmpty(); 471 fs.forEach(function (ff) { ol.extent.extend(cext, ff.getGeometry().getExtent()); }); 472 view.fit(cext, { duration: 300, padding: [40, 40, 40, 40], maxZoom: 18 }); 473 } 474 return true; 475 }); 476 }); 477 } else { 478 // DOM overlay markers (faithful to the old renderer: icon/glow/label) 479 (cfg.markers || []).forEach(function (m) { 480 var built = buildMarkerElement(m); 481 var pos = lonLat([parseFloat(m.lon), parseFloat(m.lat)]); 482 var domOv = new ol.Overlay({ 483 element: built.el, 484 position: pos, 485 positioning: built.positioning, 486 stopEvent: false 487 }); 488 map.addOverlay(domOv); 489 repositionOnImgLoad(domOv, built.el); 490 491 if (m.html && popup) { 492 built.el.style.cursor = 'pointer'; 493 var open = function (e) { 494 if (e) e.stopPropagation(); 495 popup.content.innerHTML = m.html; 496 popup.overlay.setPosition(pos); 497 }; 498 built.el.addEventListener('click', open); 499 (m.openers || []).forEach(function (id) { 500 var o = document.getElementById(id); 501 if (o) o.addEventListener('click', function (ev) { ev.preventDefault(); open(); }); 502 }); 503 } 504 }); 505 } 506 507 // --- fit to marker bounds (replaces google fitBounds) --- 508 if (cfg.bounds && cfg.markers && cfg.markers.length > 1) { 509 var extent = ol.extent.boundingExtent([ 510 lonLat([cfg.bounds[0], cfg.bounds[1]]), 511 lonLat([cfg.bounds[2], cfg.bounds[3]]) 512 ]); 513 var pad = cfg.boundsPadding != null ? cfg.boundsPadding : 40; 514 view.fit(extent, { size: map.getSize(), padding: [pad, pad, pad, pad], maxZoom: cfg.zoom || 18 }); 515 } 516 517 // --- listeners bridge --- 518 var wrapper = makeWrapper(map, popup); 519 (cfg.listeners || []).forEach(function (l) { 520 var olEvent = EVENT_MAP[l.event] || l.event; 521 var fn = global[l.fn]; 522 if (typeof fn !== 'function') return; 523 map.on(olEvent, function () { 524 global.setTimeout(function () { fn(wrapper); }, 50); 525 }); 526 // google 'idle' also fired once after initial load -> replicate 527 if (l.event === 'idle') { 528 map.once('rendercomplete', function () { 529 global.setTimeout(function () { fn(wrapper); }, 50); 530 }); 531 } 532 }); 533 534 // optionally center on the device location (replaces google centerdevice) 535 if (cfg.centerdevice && global.navigator && navigator.geolocation) { 536 navigator.geolocation.getCurrentPosition(function (pos) { 537 view.animate({ center: lonLat([pos.coords.longitude, pos.coords.latitude]), duration: 300 }); 538 }); 539 } 540 541 // the container height is often set after init (addJSEof) -> keep size in sync 542 if (global.ResizeObserver) { 543 new global.ResizeObserver(function () { map.updateSize(); }).observe(el); 544 } 545 global.addEventListener('resize', function () { map.updateSize(); }); 546 global.setTimeout(function () { map.updateSize(); }, 200); 547 548 global.swissMaps[cfg.mapId] = map; 549 global.swissMapWrappers = global.swissMapWrappers || {}; 550 global.swissMapWrappers[cfg.mapId] = wrapper; 551 map.set('swissWrapper', wrapper); 552 553 // extension hook filled in by later feature modules (clustering, kml, overlay) 554 if (typeof SwissMap._afterInit === 'function') SwissMap._afterInit(map, cfg); 555 556 return map; 557 }, 558 559 // shared helpers reused by the geometry library (geometrien.lib.js) 560 baseTileLayer: function (layerId) { 561 return makeBaseLayer(layerId); 562 }, 563 baseLayerForStyle: function (styleName) { 564 var id = (styleName === 'gray' || styleName === 'dark' || styleName === 'uc') 565 ? 'ch.swisstopo.pixelkarte-grau' : 'ch.swisstopo.pixelkarte-farbe'; 566 return makeBaseLayer(id); 567 }, 568 // add a hidden aerial layer + the map/aerial toggle control to any map 569 addAerialToggle: function (map, baseLayer) { 570 var aerial = makeBaseLayer('ch.swisstopo.swissimage'); 571 aerial.setVisible(false); 572 map.addLayer(aerial); 573 addBaseSwitcher(map, baseLayer, aerial); 574 return aerial; 575 }, 576 hexToRgbA: hexToRgbA 577 }; 578 579 global.SwissMap = SwissMap; 580 581})(window);
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.