index.js (19869B)
1 import L from 'leaflet'; 2 import ko from 'knockout'; 3 import googleProvider from './lib/google'; 4 import '~/lib/leaflet.hashState/leaflet.hashState'; 5 6 import './style.css'; 7 import {Events} from './lib/common'; 8 import '~/lib/controls-styles/controls-styles.css'; 9 import {makeButtonWithBar} from '~/lib/leaflet.control.commons'; 10 import mapillaryProvider from './lib/mapillary'; 11 import wikimediaProvider from './lib/wikimedia'; 12 import {DragEvents} from '~/lib/leaflet.events.drag'; 13 import {onElementResize} from '~/lib/anyElementResizeEvent'; 14 import safeLocalStorage from '~/lib/safe-localstorage'; 15 import mapyczProvider from './lib/mapycz'; 16 17 const PanoMarker = L.Marker.extend({ 18 options: { 19 zIndexOffset: 10000 20 }, 21 22 initialize: function() { 23 const icon = L.divIcon({ 24 className: 'leaflet-panorama-marker-wraper', 25 html: '<div class="leaflet-panorama-marker"></div>' 26 } 27 ); 28 L.Marker.prototype.initialize.call(this, [0, 0], {icon, interactive: false}); 29 this._postponeType = null; 30 this._postponeHeading = null; 31 }, 32 33 onAdd: function(map) { 34 L.Marker.prototype.onAdd.call(this, map); 35 if (this._postponeType !== null) { 36 this.setType(this._postponeType); 37 } 38 if (this._postponeHeading !== null) { 39 this.setHeading(this._postponeHeading); 40 } 41 }, 42 43 onRemove: function(map) { 44 L.Marker.prototype.onRemove.call(this, map); 45 }, 46 47 getIcon: function() { 48 let markerIcon = this.getElement(); 49 markerIcon = markerIcon.children[0]; 50 return markerIcon; 51 }, 52 53 setHeading: function(angle) { 54 this._postponeHeading = angle; 55 if (!this._map) { 56 return; 57 } 58 const markerIcon = this.getIcon(); 59 markerIcon.style.transform = `rotate(${angle || 0}deg)`; 60 }, 61 62 setType: function(markerType) { 63 this._postponeType = markerType; 64 if (!this._map) { 65 return; 66 } 67 const className = { 68 slim: 'leaflet-panorama-marker-circle', 69 normal: 'leaflet-panorama-marker-binocular' 70 }[markerType]; 71 this.getIcon().className = className; 72 } 73 }); 74 75 L.Control.Panoramas = L.Control.extend({ 76 includes: L.Mixin.Events, 77 78 options: { 79 position: 'topleft', 80 splitVerically: true, 81 splitSizeFraction: 0.5, 82 minViewerSize: 30, 83 }, 84 85 getProviders: function() { 86 return [ 87 { 88 name: 'google', 89 title: 'Google street view', 90 provider: googleProvider, 91 layerOptions: {zIndex: 10}, 92 code: 'g', 93 selected: ko.observable(true), 94 mapMarkerType: 'normal' 95 }, 96 { 97 name: 'wikimedia', 98 title: 'Wikimedia commons', 99 provider: wikimediaProvider, 100 layerOptions: {opacity: 0.7, zIndex: 9}, 101 code: 'w', 102 selected: ko.observable(false), 103 mapMarkerType: 'slim' 104 }, 105 { 106 name: 'mapillary', 107 title: 'Mapillary', 108 provider: mapillaryProvider, 109 layerOptions: {opacity: 0.7, zIndex: 8}, 110 code: 'm', 111 selected: ko.observable(false), 112 mapMarkerType: 'normal' 113 }, 114 { 115 name: 'mapycz', 116 title: 'mapy.cz', 117 provider: mapyczProvider, 118 layerOptions: {opacity: 0.7, zIndex: 8}, 119 code: 'c', 120 selected: ko.observable(false), 121 mapMarkerType: 'normal' 122 } 123 ]; 124 }, 125 126 initialize: function(options) { 127 L.Control.prototype.initialize.call(this, options); 128 this.loadSettings(); 129 this._panoramasContainer = L.DomUtil.create('div', 'panoramas-container'); 130 onElementResize( 131 this._panoramasContainer, 132 L.Util.requestAnimFrame.bind(null, this.onContainerResize.bind(this)) 133 ); 134 this.providers = this.getProviders(); 135 for (let provider of this.providers) { 136 provider.selected.subscribe(this.updateCoverageVisibility, this); 137 provider.container = L.DomUtil.create('div', 'panorama-container', this._panoramasContainer); 138 } 139 this.nearbyPoints = []; 140 this.marker = new PanoMarker(); 141 }, 142 143 loadSettings: function() { 144 let storedSettings; 145 try { 146 storedSettings = JSON.parse(safeLocalStorage.panoramaSettings); 147 } catch { 148 // ignore 149 } 150 this._splitVerically = storedSettings?.spitVertically ?? this.options.splitVerically; 151 const fraction = storedSettings?.splitSizeFraction; 152 this._splitSizeFraction = isNaN(fraction) ? this.options.splitSizeFraction : fraction; 153 }, 154 155 saveSetting: function() { 156 safeLocalStorage.panoramaSettings = JSON.stringify({ 157 spitVertically: this._splitVerically, 158 splitSizeFraction: this._splitSizeFraction 159 }); 160 }, 161 162 onAdd: function(map) { 163 this._map = map; 164 165 this._splitterDragging = false; 166 const splitter = L.DomUtil.create('div', 'panorama-splitter', this._panoramasContainer); 167 L.DomUtil.create('div', 'splitter-border', splitter); 168 const splitterButton = L.DomUtil.create('div', 'button', splitter); 169 new DragEvents(splitter, null, {trackOutsideElement: true}).on({ 170 dragstart: this.onSplitterDragStart, 171 dragend: this.onSplitterDragEnd, 172 drag: this.onSplitterDrag, 173 }, this); 174 new DragEvents(splitterButton, null, {trackOutsideElement: true}).on({ 175 click: this.onSplitterClick 176 }, this); 177 this.setupViewerLayout(); 178 const {container, link, barContainer} = makeButtonWithBar( 179 'leaflet-contol-panoramas', 'Show panoramas (Alt-P)', 'icon-panoramas'); 180 map.on('resize', this.onMapResize, this); 181 this._container = container; 182 L.DomEvent.on(link, 'click', this.onButtonClick, this); 183 L.DomEvent.on(document, 'keyup', this.onKeyUp, this); 184 barContainer.innerHTML = ` 185 <div class="panoramas-list" data-bind="foreach: providers"> 186 <div> 187 <label> 188 <input type="checkbox" data-bind="checked: selected"><span data-bind="text: title"></span> 189 </label> 190 </div> 191 </div> 192 `; 193 194 ko.applyBindings(this, container); 195 map.createPane('rasterOverlay').style.zIndex = 300; 196 return container; 197 }, 198 199 onSplitterDrag: function(e) { 200 const minSize = this.options.minViewerSize; 201 const container = this._panoramasContainer; 202 const oldSize = container[this._splitVerically ? 'offsetWidth' : 'offsetHeight']; 203 let newSize = oldSize + e.dragMovement[this._splitVerically ? 'x' : 'y']; 204 const mapSize = this._map._container[this._splitVerically ? 'offsetWidth' : 'offsetHeight']; 205 if (newSize < minSize) { 206 newSize = this.options.minViewerSize; 207 } 208 const maxSize = oldSize + mapSize - minSize; 209 if (newSize > maxSize) { 210 newSize = maxSize; 211 } 212 this.setContainerSizePixels(newSize); 213 }, 214 215 onSplitterClick: function() { 216 this._splitVerically = !this._splitVerically; 217 this.saveSetting(); 218 this.setupViewerLayout(); 219 }, 220 221 onButtonClick: function() { 222 this.switchControl(); 223 }, 224 225 switchControl: function() { 226 if (this.controlEnabled) { 227 this.disableControl(); 228 } else { 229 this.enableControl(); 230 } 231 }, 232 233 onKeyUp: function(e) { 234 if (e.keyCode === 'P'.codePointAt(0) && e.altKey) { 235 this.switchControl(); 236 } 237 }, 238 239 enableControl: function() { 240 if (this.controlEnabled) { 241 return; 242 } 243 this.controlEnabled = true; 244 L.DomUtil.addClass(this._container, 'active'); 245 this.updateCoverageVisibility(); 246 this._map.on('click', this.onMapClick, this); 247 L.DomUtil.addClass(this._map._container, 'panoramas-control-active'); 248 this.notifyChange(); 249 }, 250 251 disableControl: function() { 252 if (!this.controlEnabled) { 253 return; 254 } 255 this.controlEnabled = false; 256 L.DomUtil.removeClass(this._container, 'active'); 257 this.updateCoverageVisibility(); 258 this._map.off('click', this.onMapClick, this); 259 this.hidePanoViewer(); 260 L.DomUtil.removeClass(this._map._container, 'panoramas-control-active'); 261 this.notifyChange(); 262 }, 263 264 updateCoverageVisibility: function() { 265 if (!this._map) { 266 return; 267 } 268 for (let provider of this.providers) { 269 if (this.controlEnabled && provider.selected()) { 270 if (!provider.coverageLayer) { 271 const options = L.extend({pane: 'rasterOverlay'}, provider.layerOptions); 272 provider.coverageLayer = provider.provider.getCoverageLayer(options); 273 } 274 provider.coverageLayer.addTo(this._map); 275 } else { 276 if (provider.coverageLayer) { 277 this._map.removeLayer(provider.coverageLayer); 278 } 279 } 280 } 281 this.notifyChange(); 282 }, 283 284 showPanoramaContainer: function() { 285 L.DomUtil.addClass(this._panoramasContainer, 'enabled'); 286 }, 287 288 panoramaVisible: function() { 289 if (L.DomUtil.hasClass(this._panoramasContainer, 'enabled')) { 290 for (let provider of this.providers) { 291 if (L.DomUtil.hasClass(provider.container, 'enabled')) { 292 return provider; 293 } 294 } 295 } 296 return false; 297 }, 298 299 setupNearbyPoints: function(points) { 300 for (let point of this.nearbyPoints) { 301 this._map.removeLayer(point); 302 } 303 this.nearbyPoints = []; 304 if (points) { 305 const icon = L.divIcon({className: 'leaflet-panorama-marker-point'}); 306 for (let latlng of points) { 307 this.nearbyPoints.push(L.marker(latlng, {icon}).addTo(this._map)); 308 } 309 } 310 }, 311 312 hidePano: function(provider) { 313 L.DomUtil.removeClass(provider.container, 'enabled'); 314 if (provider.viewer) { 315 provider.viewer.deactivate(); 316 } 317 this.setupNearbyPoints(); 318 }, 319 320 showPano: async function(provider, data) { 321 this.showPanoramaContainer(); 322 for (let otherProvider of this.providers) { 323 if (otherProvider !== provider) { 324 this.hidePano(otherProvider); 325 } 326 } 327 L.DomUtil.addClass(provider.container, 'enabled'); 328 if (!provider.viewer) { 329 // eslint-disable-next-line require-atomic-updates 330 provider.viewer = await provider.provider.getViewer(provider.container); 331 this.setupViewerEvents(provider); 332 } 333 if (data) { 334 // wait for panorama container become of right size, needed for viewer setup 335 setTimeout(() => provider.viewer.showPano(data), 0); 336 } 337 provider.viewer.activate(); 338 this.marker.setType(provider.mapMarkerType); 339 this.notifyChange(); 340 }, 341 342 setupViewerEvents: function(provider) { 343 provider.viewer.on({ 344 [Events.ImageChange]: this.onViewerImageChange, 345 [Events.BearingChange]: this.onViewerBearingChange, 346 [Events.YawPitchZoomChangeEnd]: this.onViewerZoomYawPitchChangeEnd, 347 closeclick: this.onPanoramaCloseClick 348 }, this); 349 }, 350 351 hidePanoViewer: function() { 352 for (let provider of this.providers) { 353 this.hidePano(provider); 354 } 355 L.DomUtil.removeClass(this._panoramasContainer, 'enabled'); 356 this._map.removeLayer(this.marker); 357 this.notifyChange(); 358 }, 359 360 notifyChange: function() { 361 this.fire('change'); 362 }, 363 364 onViewerImageChange: function(e) { 365 if (!this._map.getBounds().pad(-0.05).contains(e.latlng)) { 366 this._map.panTo(e.latlng); 367 } 368 this._map.addLayer(this.marker); 369 this.marker.setLatLng(e.latlng); 370 this.notifyChange(); 371 }, 372 373 onViewerBearingChange: function(e) { 374 this.marker.setHeading(e.bearing); 375 }, 376 377 onViewerZoomYawPitchChangeEnd: function() { 378 this.notifyChange(); 379 }, 380 381 onPanoramaCloseClick: function() { 382 this.hidePanoViewer(); 383 }, 384 385 onMapClick: async function(e) { 386 const 387 searchRadiusPx = 24, 388 p = this._map.project(e.latlng).add([searchRadiusPx, 0]), 389 searchRadiusMeters = e.latlng.distanceTo(this._map.unproject(p)), 390 promises = []; 391 for (let provider of this.providers) { 392 if (provider.selected()) { 393 promises.push({ 394 promise: provider.provider.getPanoramaAtPos(e.latlng, searchRadiusMeters), 395 provider: provider 396 }); 397 } 398 } 399 400 for (let {promise, provider} of promises) { 401 let searchResult = await promise; 402 if (searchResult.found) { 403 this._map.removeLayer(this.marker); 404 this.provider = provider; 405 this.showPano(provider, searchResult.data); 406 return; 407 } 408 } 409 }, 410 411 setupViewerLayout: function() { 412 let sidebar; 413 if (this._splitVerically) { 414 L.DomUtil.addClass(this._panoramasContainer, 'split-vertical'); 415 L.DomUtil.removeClass(this._panoramasContainer, 'split-horizontal'); 416 sidebar = 'left'; 417 this._panoramasContainer.style.height = '100%'; 418 } else { 419 L.DomUtil.addClass(this._panoramasContainer, 'split-horizontal'); 420 L.DomUtil.removeClass(this._panoramasContainer, 'split-vertical'); 421 sidebar = 'top'; 422 this._panoramasContainer.style.width = '100%'; 423 } 424 this._map.addElementToSidebar(sidebar, this._panoramasContainer); 425 this.updateContainerSize(); 426 }, 427 428 setContainerSizePixels: function(size) { 429 size = Math.round(size); 430 this._panoramasContainer.style[this._splitVerically ? 'width' : 'height'] = `${size}px`; 431 setTimeout(() => { // map size has not updated yet 432 const mapSize = this._map._container[this._splitVerically ? 'offsetWidth' : 'offsetHeight']; 433 this._splitSizeFraction = size / (mapSize + size); 434 this.saveSetting(); 435 }, 0); 436 }, 437 438 updateContainerSize: function() { 439 const fraction = this._splitSizeFraction; 440 const container = this._panoramasContainer; 441 const containerSize = container[this._splitVerically ? 'offsetWidth' : 'offsetHeight']; 442 const mapSize = this._map._container[this._splitVerically ? 'offsetWidth' : 'offsetHeight']; 443 const newSize = Math.round(fraction * (mapSize + containerSize)); 444 container.style[this._splitVerically ? 'width' : 'height'] = `${newSize}px`; 445 }, 446 447 onContainerResize: function() { 448 const provider = this.panoramaVisible(); 449 if (provider && provider.viewer) { 450 provider.viewer.invalidateSize(); 451 } 452 }, 453 454 onMapResize: function() { 455 if (!this._splitterDragging) { 456 this.updateContainerSize(); 457 } 458 }, 459 460 onSplitterDragStart: function() { 461 this._splitterDragging = true; 462 }, 463 464 onSplitterDragEnd: function() { 465 this._splitterDragging = false; 466 }, 467 }, 468 ); 469 470 L.Control.Panoramas.include(L.Mixin.HashState); 471 L.Control.Panoramas.include({ 472 stateChangeEvents: ['change'], 473 474 serializeState: function() { 475 let state = null; 476 if (this.controlEnabled) { 477 state = []; 478 let coverageCode = '_'; 479 for (let provider of this.providers) { 480 if (provider.selected()) { 481 coverageCode += provider.code; 482 } 483 } 484 state.push(coverageCode); 485 const provider = this.panoramaVisible(); 486 if (provider && provider.viewer) { 487 let viewerState = provider.viewer.getState(); 488 if (viewerState) { 489 state.push(provider.code); 490 state.push(...viewerState); 491 } 492 } 493 } 494 return state; 495 }, 496 497 unserializeState: function(state) { 498 if (!state) { 499 this.disableControl(); 500 return true; 501 } 502 503 const coverageCode = state[0]; 504 if (!coverageCode || coverageCode[0] !== '_') { 505 return false; 506 } 507 this.enableControl(); 508 for (let provider of this.providers) { 509 provider.selected(coverageCode.includes(provider.code)); 510 } 511 if (state.length > 2) { 512 const panoramaVisible = state[1]; 513 for (let provider of this.providers) { 514 if (panoramaVisible === provider.code) { 515 this.showPano(provider).then(() => { 516 const success = provider.viewer.setState(state.slice(2)); 517 if (!success) { 518 this.hidePanoViewer(); 519 } 520 }); 521 break; 522 } 523 } 524 } 525 return true; 526 } 527 } 528 ); 529 530 L.Control.Panoramas.hashStateUpgrader = function(panoramasControl) { 531 return L.Util.extend({}, L.Mixin.HashState, { 532 unserializeState: function(oldState) { 533 if (oldState) { 534 const upgradedState = ['_g']; 535 if (oldState.length) { 536 upgradedState.push('g', ...oldState); 537 } 538 setTimeout(() => panoramasControl.unserializeState(upgradedState), 0); 539 } 540 return false; 541 }, 542 543 serializeState: function() { 544 return null; 545 }, 546 }); 547 };