index.js (13405B)
1 import L from 'leaflet'; 2 import {MultiLayer, WikimediaVectorCoverage} from './coverage-layer'; 3 import {fetch} from '~/lib/xhr-promise'; 4 import './style.css'; 5 import '../common/style.css'; 6 import config from '~/config'; 7 import {CloseButtonMixin, Events} from "../common"; 8 9 function getCoverageLayer(options) { 10 const url = config.wikimediaCommonsCoverageUrl; 11 return new MultiLayer([ 12 {layer: L.tileLayer(url, L.extend({}, options, {tms: true})), minZoom: 0, maxZoom: 10}, 13 {layer: new WikimediaVectorCoverage(url, options), minZoom: 11, maxZoom: 18} 14 ]); 15 } 16 17 function isCoordLikeArtificial(x) { 18 // check if number has less then 2 non-zero digits after decimal point 19 return Number.isInteger(x * 10); 20 } 21 22 function parseSearchResponse(resp) { // eslint-disable-line complexity 23 const images = []; 24 if (resp && resp.query && resp.query.pages && resp.query.pages) { 25 for (let page of Object.values(resp.query.pages)) { 26 const coordinates = page.coordinates?.[0]; 27 const pageTitle = page.title ?? ''; 28 const extension = pageTitle.split('.').pop(); 29 if ( 30 !coordinates || 31 coordinates.globe !== 'earth' || 32 (isCoordLikeArtificial(coordinates.lat) && isCoordLikeArtificial(coordinates.lon)) || 33 coordinates.lat === 0 || 34 coordinates.lon === 0 || 35 coordinates.lat === coordinates.lon || 36 pageTitle.includes('View of Earth') || 37 extension.toLowerCase() !== 'jpg' 38 ) { 39 continue; 40 } 41 42 const iinfo = page.imageinfo[0]; 43 let imageDescription = iinfo.extmetadata.ImageDescription ? iinfo.extmetadata.ImageDescription.value : null; 44 let objectDescription = iinfo.extmetadata.ObjectName ? iinfo.extmetadata.ObjectName.value : null; 45 if (imageDescription && /^<table (.|\n)+<\/table>$/u.test(imageDescription)) { 46 imageDescription = null; 47 } 48 if (imageDescription) { 49 imageDescription = imageDescription.replace(/<[^>]+>/ug, ''); 50 imageDescription = imageDescription.replace(/[\n\r]/ug, ''); 51 } 52 if ( 53 imageDescription && 54 objectDescription && 55 objectDescription.toLowerCase().includes(imageDescription.toLowerCase()) 56 ) { 57 imageDescription = null; 58 } 59 if ( 60 objectDescription && 61 imageDescription && 62 imageDescription.toLowerCase().includes(objectDescription.toLowerCase()) 63 ) { 64 objectDescription = null; 65 } 66 let description = 'Wikimedia commons'; 67 if (objectDescription || imageDescription) { 68 description = ''; 69 if (objectDescription) { 70 description = objectDescription; 71 } 72 if (imageDescription) { 73 // eslint-disable-next-line max-depth 74 if (objectDescription) { 75 description += '</br>'; 76 } 77 description += imageDescription; 78 } 79 } 80 81 let author = iinfo.extmetadata.Artist ? iinfo.extmetadata.Artist.value : null; 82 if (author && /^<table (.|\n)+<\/table>$/u.test(author)) { 83 author = `See author info at <a href="${iinfo.descriptionurl}">Wikimedia commons</a>`; 84 } 85 86 // original images can be rotated, 90 degrees 87 // thumbnails are always oriented right 88 // so we request thumbnail of original image size 89 let url = iinfo.thumburl.replace('134px', `${iinfo.width}px`); 90 images.push({ 91 url, 92 width: iinfo.width, 93 height: iinfo.height, 94 lat: coordinates.lat, 95 lng: coordinates.lon, 96 author: author, 97 timeOriginal: iinfo.extmetadata.DateTimeOriginal ? iinfo.extmetadata.DateTimeOriginal.value : null, 98 time: iinfo.extmetadata.DateTime ? iinfo.extmetadata.DateTime.value : null, 99 description: description, 100 pageUrl: iinfo.descriptionurl, 101 pageId: page.pageid.toString() 102 }); 103 } 104 if (images.length) { 105 return images; 106 } 107 } 108 return null; 109 } 110 111 function isCloser(target, a, b) { 112 const d1 = target.distanceTo(a); 113 const d2 = target.distanceTo(b); 114 if (d1 < d2) { 115 return -1; 116 } else if (d1 === d2) { 117 return 0; 118 } 119 return 1; 120 } 121 122 async function getPanoramaAtPos(latlng, searchRadiusMeters) { 123 latlng = L.latLng(latlng.lat, latlng.lng); // make independent copy 124 125 const clusterSize = 10; 126 const urlTemplate = 'https://commons.wikimedia.org/w/api.php?' + 127 'origin=*&format=json&action=query&generator=geosearch&' + 128 'ggsprimary=all&ggsnamespace=6&ggslimit=10&iilimit=1&coprimary=all&' + 129 'ggsradius={radius}&ggscoord={lat}|{lng}&' + 130 'iiurlwidth=134&' + 131 'prop=imageinfo|coordinates&' + 132 'iiprop=url|mime|size|extmetadata|commonmetadata|metadata'; 133 searchRadiusMeters += clusterSize; 134 if (searchRadiusMeters < 10) { 135 searchRadiusMeters = 10; 136 } 137 if (searchRadiusMeters > 10000) { 138 searchRadiusMeters = 10000; 139 } 140 const url = L.Util.template(urlTemplate, {lat: latlng.lat, lng: latlng.lng, radius: Math.ceil(searchRadiusMeters)}); 141 const resp = await fetch(url, {responseType: 'json', timeout: 10000}); 142 if (resp.status === 200) { 143 let photos = parseSearchResponse(resp.responseJSON); 144 if (photos) { 145 photos.sort(isCloser.bind(null, latlng)); 146 const nearestLatlng = L.latLng(photos[0].lat, photos[0].lng); 147 photos = photos.filter((photo) => nearestLatlng.distanceTo(L.latLng(photo.lat, photo.lng)) <= clusterSize); 148 return { 149 found: true, 150 data: photos 151 }; 152 } 153 return {found: false}; 154 } 155 return {found: false}; 156 } 157 158 function formatDateTime(dateStr) { 159 const m = /^(\d+)-(\d+)-(\d+)/u.exec(dateStr); 160 const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; 161 if (m) { 162 let [year, month, day] = m.slice(1); 163 return `${day} ${months[month - 1]} ${year}`; 164 } 165 return dateStr; 166 } 167 168 const Viewer = L.Evented.extend({ 169 includes: [CloseButtonMixin], 170 initialize: function(container) { 171 container = L.DomUtil.create('div', 'wikimedia-viewer-container', container); 172 this.createCloseButton(container); 173 const mapContainer = this.mapContainer = L.DomUtil.create('div', 'wikimedia-viewer-map-container', container); 174 this.pageButtonContainer = L.DomUtil.create('div', 'wikimedia-viewer-page-buttons-container', container); 175 176 this.map = L.map(mapContainer, { 177 maxBoundsViscosity: 1, 178 crs: L.CRS.Simple, 179 zoomControl: false, 180 attributionControl: false, 181 zoomSnap: 0, 182 }); 183 184 this.map.on('zoomend moovend', () => this.fire(Events.YawPitchZoomChangeEnd)); 185 186 this.infoLabel = L.DomUtil.create('div', 'wikimedia-viewer-info-overlay', mapContainer); 187 this.prevPhotoButton = L.DomUtil.create('div', 'wikimedia-viewer-button-prev', mapContainer); 188 this.nextPhotoButton = L.DomUtil.create('div', 'wikimedia-viewer-button-next', mapContainer); 189 L.DomEvent.on(this.prevPhotoButton, 'click', () => { 190 this.switchPhoto(this._imageIdx - 1); 191 }); 192 L.DomEvent.on(this.nextPhotoButton, 'click', () => { 193 this.switchPhoto(this._imageIdx + 1); 194 }); 195 }, 196 197 setupPageButtons: function(count) { 198 if (this._buttons) { 199 for (let button of this._buttons) { 200 this.pageButtonContainer.removeChild(button); 201 } 202 } 203 this._buttons = []; 204 if (count > 1) { 205 L.DomUtil.addClass(this.pageButtonContainer, 'enabled'); 206 207 for (let i = 0; i < count; i++) { 208 let button = L.DomUtil.create('div', 'wikimedia-viewer-page-button', this.pageButtonContainer); 209 button.innerHTML = String(i + 1); 210 this._buttons.push(button); 211 L.DomEvent.on(button, 'click', () => this.switchPhoto(i)); 212 } 213 } else { 214 L.DomUtil.removeClass(this.pageButtonContainer, 'enabled'); 215 } 216 }, 217 218 switchPhoto: function(imageIdx, imagePos = null) { 219 this._imageIdx = imageIdx; 220 if (this.imageLayer) { 221 this.map.removeLayer(this.imageLayer); 222 } 223 let image = this.images[imageIdx]; 224 let mapSize = this.map.getSize(); 225 if (!mapSize.x || !mapSize.y) { 226 mapSize = {x: 500, y: 500}; 227 } 228 let maxZoom = Math.log2(Math.max(image.width / mapSize.x, image.height / mapSize.y)) + 2; 229 if (maxZoom < 1) { 230 maxZoom = 1; 231 } 232 let 233 southWest = this.map.unproject([0, image.height], maxZoom - 2), 234 northEast = this.map.unproject([image.width, 0], maxZoom - 2); 235 const bounds = new L.LatLngBounds(southWest, northEast); 236 this.map.setMaxZoom(maxZoom); 237 this.map.setMaxBounds(bounds); 238 if (imagePos) { 239 this.map.setView(imagePos.center, imagePos.zoom, {animate: false}); 240 } else { 241 this.map.fitBounds(bounds, {animate: false}); 242 } 243 244 this.imageLayer = L.imageOverlay(null, bounds); 245 L.DomUtil.addClass(this.mapContainer, 'loading'); 246 this.imageLayer.on('load', () => { 247 L.DomUtil.removeClass(this.mapContainer, 'loading'); 248 }); 249 this.imageLayer.setUrl(image.url); 250 this.imageLayer.addTo(this.map); 251 let caption = []; 252 253 if (image.timeOriginal) { 254 caption.push(formatDateTime(image.timeOriginal)); 255 } 256 if (image.author) { 257 caption.push(image.author); 258 } 259 caption.push(`<a href="${image.pageUrl}">${image.description}</a>`); 260 caption = caption.join('</br>'); 261 this.infoLabel.innerHTML = caption; 262 263 if (imageIdx > 0) { 264 L.DomUtil.addClass(this.prevPhotoButton, 'enabled'); 265 } else { 266 L.DomUtil.removeClass(this.prevPhotoButton, 'enabled'); 267 } 268 if (imageIdx < this.images.length - 1) { 269 L.DomUtil.addClass(this.nextPhotoButton, 'enabled'); 270 } else { 271 L.DomUtil.removeClass(this.nextPhotoButton, 'enabled'); 272 } 273 for (let [i, button] of this._buttons.entries()) { 274 ((i === imageIdx) ? L.DomUtil.addClass : L.DomUtil.removeClass)(button, 'active'); 275 } 276 this.notifyImageChange(); 277 }, 278 279 notifyImageChange: function() { 280 if (this.images && this._active) { 281 const image = this.images[this._imageIdx]; 282 this.fire(Events.ImageChange, { 283 latlng: L.latLng(image.lat, image.lng), 284 latlngs: this.images.map((image) => L.latLng(image.lat, image.lng)) 285 } 286 ); 287 } 288 }, 289 290 showPano: function(images, imageIdx = 0, imagePos = null) { 291 this.images = images; 292 this.setupPageButtons(images.length); 293 this.switchPhoto(imageIdx, imagePos); 294 }, 295 296 activate: function() { 297 this._active = true; 298 }, 299 300 deactivate: function() { 301 this._active = false; 302 }, 303 304 setState: function(state) { 305 const lat = parseFloat(state[0]); 306 const lng = parseFloat(state[1]); 307 const pageId = state[2]; 308 const y = parseFloat(state[3]); 309 const x = parseFloat(state[4]); 310 const zoom = parseFloat(state[5]); 311 if (!isNaN(lat) && !isNaN(lng) && !isNaN(x) && !isNaN(y) && !isNaN(zoom)) { 312 let imageIdx = -1; 313 getPanoramaAtPos({lat, lng}, 0).then((resp) => { 314 if (!resp.found) { 315 return; 316 } 317 for (let [i, image] of resp.data.entries()) { 318 if (image.pageId === pageId) { 319 imageIdx = i; 320 break; 321 } 322 } 323 if (imageIdx > -1) { 324 this.showPano(resp.data, imageIdx, {center: L.latLng(y, x), zoom}); 325 } 326 }); 327 return true; 328 } 329 return false; 330 }, 331 332 getState: function() { 333 if (!this.images) { 334 return []; 335 } 336 const center = this.map.getCenter(); 337 return [ 338 this.images[0].lat.toFixed(6), 339 this.images[0].lng.toFixed(6), 340 this.images[this._imageIdx].pageId, 341 center.lat.toFixed(2), 342 center.lng.toFixed(2), 343 this.map.getZoom().toFixed(1) 344 ]; 345 }, 346 347 invalidateSize: function() { 348 this.map.invalidateSize(); 349 } 350 }); 351 352 function getViewer(container) { 353 return new Viewer(container); 354 } 355 356 const wikimediaProvider = {getCoverageLayer, getPanoramaAtPos, getViewer}; 357 export default wikimediaProvider;