nakarte

Source code of https://map.sikmir.ru (fork)
git clone git://git.sikmir.ru/nakarte
Log | Files | Refs | LICENSE

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;