nakarte

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

commit 2da03ca07813531dc7934627ff19da2f2b1fa4a2
parent fc93364713b6f100becd8cab3869dd3f3adb5e8f
Author: Sergej Orlov <wladimirych@gmail.com>
Date:   Sat, 29 Jul 2017 15:37:48 +0300

[panoramas] refactored; added wikimedia photos

Diffstat:
Msrc/config.js | 3++-
Asrc/lib/leaflet.control.panoramas/circle.svg | 8++++++++
Msrc/lib/leaflet.control.panoramas/index.js | 246+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Asrc/lib/leaflet.control.panoramas/lib/common/close.svg | 7+++++++
Asrc/lib/leaflet.control.panoramas/lib/common/style.css | 15+++++++++++++++
Msrc/lib/leaflet.control.panoramas/lib/mapillary/index.js | 5+++--
Msrc/lib/leaflet.control.panoramas/lib/mapillary/mapillary-coverage-layer.js | 1-
Asrc/lib/leaflet.control.panoramas/lib/mapillary/style.css | 11+++++++++++
Asrc/lib/leaflet.control.panoramas/lib/wikimedia/angle-left.svg | 5+++++
Asrc/lib/leaflet.control.panoramas/lib/wikimedia/angle-right.svg | 3+++
Asrc/lib/leaflet.control.panoramas/lib/wikimedia/coverage-layer.js | 182+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/leaflet.control.panoramas/lib/wikimedia/index.js | 340+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/leaflet.control.panoramas/lib/wikimedia/loading.svg | 2++
Asrc/lib/leaflet.control.panoramas/lib/wikimedia/style.css | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/leaflet.control.panoramas/point.svg | 6++++++
Msrc/lib/leaflet.control.panoramas/style.css | 52++++++++++++++++++++--------------------------------
16 files changed, 842 insertions(+), 149 deletions(-)

diff --git a/src/config.js b/src/config.js @@ -6,5 +6,6 @@ export default Object.assign({ westraDataBaseUrl: 'http://nakarte.tk/westraPasses/', CORSProxyUrl: 'http://proxy.nakarte.tk/', elevationsServer: 'http://elevation.nakarte.tk/', - newsUrl: 'http://about.nakarte.tk' + newsUrl: 'http://about.nakarte.tk', + wikimediaCommonsCoverageUrl: 'http://tiles.nakarte.tk/wikimedia_commons_images/{z}/{x}/{y}' }, secrets); diff --git a/src/lib/leaflet.control.panoramas/circle.svg b/src/lib/leaflet.control.panoramas/circle.svg @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="iso-8859-1"?> +<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" viewBox="-1 -1 31 31" + xml:space="preserve"> + <g> + <ellipse ry="15" rx="15" cy="15" cx="15" fill="none" stroke="#00d000" stroke-width="2"/> + <ellipse ry="5" rx="5" cy="15" cx="15" fill="none" stroke="#00d000" stroke-width="2"/> + </g> +</svg> diff --git a/src/lib/leaflet.control.panoramas/index.js b/src/lib/leaflet.control.panoramas/index.js @@ -4,6 +4,7 @@ import 'lib/controls-styles/controls-styles.css'; import ko from 'vendored/knockout'; import googleProvider from './lib/google'; import mapillaryProvider from './lib/mapillary'; +import wikimediaProvider from './lib/wikimedia'; function fireRefreshEventOnWindow() { const evt = document.createEvent("HTMLEvents"); @@ -13,6 +14,10 @@ function fireRefreshEventOnWindow() { const PanoMarker = L.Marker.extend({ + options: { + zIndexOffset: 10000 + }, + initialize: function() { const icon = L.divIcon({ className: 'leaflet-panorama-marker-wraper', @@ -22,10 +27,22 @@ const PanoMarker = L.Marker.extend({ L.Marker.prototype.initialize.call(this, [0, 0], {icon, interactive: false}); }, - setHeading: function(angle) { + getIcon: function() { let markerIcon = this.getElement(); markerIcon = markerIcon.children[0]; - markerIcon.style.transform = `rotate(${angle}deg)`; + return markerIcon; + }, + + setHeading: function(angle) { + const markerIcon = this.getIcon(); + markerIcon.style.transform = `rotate(${angle || 0}deg)`; + }, + + setType: function(markerType) { + const className = { + 'slim': 'leaflet-panorama-marker-circle', + 'normal': 'leaflet-panorama-marker-binocular'}[markerType] + this.getIcon().className = className; } }); @@ -36,15 +53,35 @@ L.Control.Panoramas = L.Control.extend({ position: 'topleft' }, + getProviders: function() { + return [ + {name: 'google', title: 'Google street view', provider: googleProvider, layerOptions: {zIndex:10}, + code: 'g', + selected: ko.observable(true), + mapMarkerType: 'normal'}, + {name: 'wikimedia', title: 'Wikimedia commons', provider: wikimediaProvider, + layerOptions: {opacity: 0.7, zIndex: 9}, + code: 'w', + selected: ko.observable(false), + mapMarkerType: 'slim' + }, + {name: 'mapillary', title: 'Mapillary', provider: mapillaryProvider, + layerOptions: {opacity: 0.7, zIndex: 8}, + code: 'm', + selected: ko.observable(false), + mapMarkerType: 'normal'}, + ] + }, + initialize: function(panoramaContainer, options) { L.Control.prototype.initialize.call(this, options); - this.googleCoverageSelected = ko.observable(true); - this.mapillaryCoverageSelected = ko.observable(false); - this.googleCoverageSelected.subscribe(this.updateCoverageVisibility, this); - this.mapillaryCoverageSelected.subscribe(this.updateCoverageVisibility, this); this._panoramaContainer = panoramaContainer; - this._googlePanoramaContainer = L.DomUtil.create('div', 'panorama-container', panoramaContainer); - this._mapillaryPanoramaContainer = L.DomUtil.create('div', 'panorama-container', panoramaContainer); + this.providers = this.getProviders(); + for (let provider of this.providers) { + provider.selected.subscribe(this.updateCoverageVisibility, this); + provider.container = L.DomUtil.create('div', 'panorama-container', panoramaContainer); + } + this.nearbyPoints = []; }, onAdd: function(map) { @@ -53,9 +90,8 @@ L.Control.Panoramas = L.Control.extend({ container.innerHTML = ` <a name="button" class="panoramas-button leaflet-control-button icon-panoramas" title="Show panoramas" data-bind="click: onButtonClick"></a> - <div class="panoramas-list control-form"> - <div><label><input type="checkbox" data-bind="checked: googleCoverageSelected">Google street view</label></div> - <div><label><input type="checkbox" data-bind="checked: mapillaryCoverageSelected">Mapillary</label></div> + <div class="panoramas-list control-form" data-bind="foreach: providers"> + <div><label><input type="checkbox" data-bind="checked: selected"><span data-bind="text: title"></span></label></div> </div> `; this._stopContainerEvents(); @@ -101,26 +137,17 @@ L.Control.Panoramas = L.Control.extend({ if (!this._map) { return; } - if (this.controlEnabled && this.googleCoverageSelected()) { - if (!this.googleCoverage) { - this.googleCoverage = googleProvider.getCoverageLayer({pane: 'rasterOverlay', zIndex: 2}); - } - this.googleCoverage.addTo(this._map); - } else { - if (this.googleCoverage) { - this.googleCoverage.removeFrom(this._map) - } - } - - if (this.controlEnabled && this.mapillaryCoverageSelected()) { - if (!this.mapillaryCoverage) { - this.mapillaryCoverage = mapillaryProvider.getCoverageLayer({pane: 'rasterOverlay', opacity: 0.7, - zIndex: 1}); - } - this.mapillaryCoverage.addTo(this._map); - } else { - if (this.mapillaryCoverage) { - this.mapillaryCoverage.removeFrom(this._map) + for (let provider of this.providers) { + if (this.controlEnabled && provider.selected()) { + if (!provider.coverageLayer) { + const options = L.extend({pane: 'rasterOverlay'}, provider.layerOptions); + provider.coverageLayer = provider.provider.getCoverageLayer(options); + } + provider.coverageLayer.addTo(this._map); + } else { + if (provider.coverageLayer) { + this._map.removeLayer(provider.coverageLayer); + } } } this.notifyChanged(); @@ -133,70 +160,67 @@ L.Control.Panoramas = L.Control.extend({ panoramaVisible: function() { if (L.DomUtil.hasClass(this._panoramaContainer, 'enabled')) { - if (L.DomUtil.hasClass(this._googlePanoramaContainer, 'enabled')) { - return 'google'; - } - if (L.DomUtil.hasClass(this._mapillaryPanoramaContainer, 'enabled')) { - return 'mapillary'; + for (let provider of this.providers) { + if (L.DomUtil.hasClass(provider.container, 'enabled')) { + return provider + } } } return false; }, - hidePanoGoogle: function() { - L.DomUtil.removeClass(this._googlePanoramaContainer, 'enabled'); - if (this.googleViewer) { - this.googleViewer.deactivate(); + setupNearbyPoints: function(points) { + for (let point of this.nearbyPoints) { + this._map.removeLayer(point) } - }, - - hidePanoMapillary: function() { - L.DomUtil.removeClass(this._mapillaryPanoramaContainer, 'enabled'); - if (this.mapillaryViewer) { - this.mapillaryViewer.deactivate(); + this.nearbyPoints = []; + if (points) { + const icon = L.divIcon({className: 'leaflet-panorama-marker-point'}); + for (let latlng of points) { + this.nearbyPoints.push(L.marker(latlng, {icon}).addTo(this._map)); + } } }, - showPanoGoogle: async function(data) { - this.hidePanoMapillary(); - this.showPanoramaContainer(); - L.DomUtil.addClass(this._googlePanoramaContainer, 'enabled'); - if (!this.googleViewer) { - this.googleViewer = await googleProvider.getViewer(this._googlePanoramaContainer); - this.setupViewerEvents(this.googleViewer); - } - this.googleViewer.activate(); - if (data) { - this.googleViewer.showPano(data); + hidePano: function(provider) { + L.DomUtil.removeClass(provider.container, 'enabled'); + if (provider.viewer) { + provider.viewer.deactivate(); } - this.notifyChanged(); + this.setupNearbyPoints(); }, - showPanoMapillary: async function(data) { + showPano: async function(provider, data) { this.showPanoramaContainer(); - this.hidePanoGoogle(); - L.DomUtil.addClass(this._mapillaryPanoramaContainer, 'enabled'); - if (!this.mapillaryViewer) { - this.mapillaryViewer = await mapillaryProvider.getViewer(this._mapillaryPanoramaContainer); - this.setupViewerEvents(this.mapillaryViewer); + for (let otherProvider of this.providers) { + if (otherProvider !== provider) { + this.hidePano(otherProvider); + } + } + L.DomUtil.addClass(provider.container, 'enabled'); + if (!provider.viewer) { + provider.viewer = await provider.provider.getViewer(provider.container); + this.setupViewerEvents(provider); } if (data) { - this.mapillaryViewer.showPano(data); + // wait for panorama container become of right size, needed for viewer setup + setTimeout(() => provider.viewer.showPano(data), 0); } - this.mapillaryViewer.activate(); + provider.viewer.activate(); this.notifyChanged(); }, - setupViewerEvents: function(viewer) { - viewer.on({ - 'change': this.onPanoramaChangeView, + setupViewerEvents: function(provider) { + provider.viewer.on({ + 'change': this.onPanoramaChangeView.bind(this, provider), 'closeclick': this.onPanoramaCloseClick }, this); }, hidePanoViewer: function() { - this.hidePanoGoogle(); - this.hidePanoMapillary(); + for (let provider of this.providers) { + this.hidePano(provider); + } L.DomUtil.removeClass(this._panoramaContainer, 'enabled'); this.hideMarker(); fireRefreshEventOnWindow(); @@ -225,11 +249,13 @@ L.Control.Panoramas = L.Control.extend({ this.fire('panoramachanged'); }, - onPanoramaChangeView: function(e) { + onPanoramaChangeView: function(provider, e) { if (!this._map.getBounds().pad(-0.05).contains(e.latlng)) { this._map.panTo(e.latlng); } this.placeMarker(e.latlng, e.heading); + this.marker.setType(provider.mapMarkerType); + this.setupNearbyPoints(e.latlngs); this.notifyChanged(); }, @@ -238,32 +264,26 @@ L.Control.Panoramas = L.Control.extend({ }, onMapClick: async function(e) { - let - googlePanoPromise, mapillaryPanoPromise; const searchRadiusPx = 24, p = this._map.project(e.latlng).add([searchRadiusPx, 0]), - searchRadiusMeters = e.latlng.distanceTo(this._map.unproject(p)); - if (this.googleCoverageSelected()) { - googlePanoPromise = googleProvider.getPanoramaAtPos(e.latlng, searchRadiusMeters); - } - if (this.mapillaryCoverageSelected()) { - mapillaryPanoPromise = mapillaryProvider.getPanoramaAtPos(e.latlng, searchRadiusMeters); - } - if (googlePanoPromise) { - let searchResult = await googlePanoPromise; - if (searchResult.found) { - this.showPanoGoogle(searchResult.data); - return; + searchRadiusMeters = e.latlng.distanceTo(this._map.unproject(p)), + promises = []; + for (let provider of this.providers) { + if (provider.selected()) { + promises.push({ + promise: provider.provider.getPanoramaAtPos(e.latlng, searchRadiusMeters), + provider: provider + }); } } - if (mapillaryPanoPromise) { - let searchResult = await mapillaryPanoPromise; + + for (let {promise, provider} of promises) { + let searchResult = await promise; if (searchResult.found) { - this.showPanoMapillary(searchResult.data); + this.showPano(provider, searchResult.data); return; } - } } }, @@ -278,23 +298,18 @@ L.Control.Panoramas.include({ if (this.controlEnabled) { state = []; let coverageCode='_'; - if (this.mapillaryCoverageSelected()) { - coverageCode += 'm'; - } - if (this.googleCoverageSelected()) { - coverageCode += 'g'; + for (let provider of this.providers) { + if (provider.selected()){ + coverageCode += provider.code; + } } state.push(coverageCode); - const panoramaVisible = this.panoramaVisible(); - if (panoramaVisible) { - let code = {'google': 'g', 'mapillary': 'm'}[panoramaVisible]; - let viewer = {'google': this.googleViewer, 'mapillary': this.mapillaryViewer}[panoramaVisible]; - if (viewer) { - let viewerState = viewer.getState(); - if (viewerState) { - state.push(code); - state.push(...viewerState); - } + const provider = this.panoramaVisible(); + if (provider && provider.viewer) { + let viewerState = provider.viewer.getState(); + if (viewerState) { + state.push(provider.code); + state.push(...viewerState); } } } @@ -302,7 +317,6 @@ L.Control.Panoramas.include({ }, unserializeState: function(state) { - if (!state) { this.disableControl(); return true; @@ -313,15 +327,21 @@ L.Control.Panoramas.include({ return false; } this.enableControl(); - this.googleCoverageSelected(coverageCode.includes('g')); - this.mapillaryCoverageSelected(coverageCode.includes('m')); + for (let provider of this.providers) { + provider.selected(coverageCode.includes(provider.code)) + } if (state.length > 2) { const panoramaVisible = state[1]; - if (panoramaVisible === 'g') { - this.showPanoGoogle().then(() => this.googleViewer.setState(state.slice(2))); - } - if (panoramaVisible === 'm') { - this.showPanoMapillary().then(() => this.mapillaryViewer.setState(state.slice(2))); + for (let provider of this.providers) { + if (panoramaVisible === provider.code) { + this.showPano(provider).then(() => { + const success = provider.viewer.setState(state.slice(2)); + if (!success) { + this.hidePanoViewer(); + } + }); + break; + } } } return true; diff --git a/src/lib/leaflet.control.panoramas/lib/common/close.svg b/src/lib/leaflet.control.panoramas/lib/common/close.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="iso-8859-1"?> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Capa_1" x="0px" y="0px" width="512px" height="512px" viewBox="0 0 357 357" style="enable-background:new 0 0 357 357;" xml:space="preserve"> + <g id="close"> + <polygon points="357,35.7 321.3,0 178.5,142.8 35.7,0 0,35.7 142.8,178.5 0,321.3 35.7,357 178.5,214.2 321.3,357 357,321.3 214.2,178.5 " fill="#FFFFFF"/> + </g> +</svg> diff --git a/src/lib/leaflet.control.panoramas/lib/common/style.css b/src/lib/leaflet.control.panoramas/lib/common/style.css @@ -0,0 +1,15 @@ +.photo-viewer-button-close { + position: absolute; + top: 0; + left: 0; + width: 40px; + height: 40px; + background-color: rgba(0,0,0,1); + z-index: 1000; + opacity: 0.5; + background-image: url("close.svg"); + background-repeat: no-repeat; + background-size: 20px; + background-position: 50% 50%; + cursor: pointer; +} diff --git a/src/lib/leaflet.control.panoramas/lib/mapillary/index.js b/src/lib/leaflet.control.panoramas/lib/mapillary/index.js @@ -2,7 +2,8 @@ import L from 'leaflet'; import {MapillaryCoverage} from './mapillary-coverage-layer' import {fetch} from 'lib/xhr-promise'; import config from 'config'; - +import './style.css'; +import '../common/style.css'; function getCoverageLayer(options) { return new MapillaryCoverage(options); @@ -47,7 +48,7 @@ const Viewer = L.Evented.extend({ viewer.on('nodechanged', this.onNodeChanged.bind(this)); viewer.on('bearingchanged', this.onBearingChanged.bind(this)); this.dateLabel = L.DomUtil.create('div', 'mapillary-viewer-date-overlay', container); - this.closeButton = L.DomUtil.create('div', 'mapillary-viewer-button-close', container); + this.closeButton = L.DomUtil.create('div', 'photo-viewer-button-close', container); L.DomEvent.on(this.closeButton, 'click', this.onCloseClick, this); this._bearing = 0; this._zoom = 0; diff --git a/src/lib/leaflet.control.panoramas/lib/mapillary/mapillary-coverage-layer.js b/src/lib/leaflet.control.panoramas/lib/mapillary/mapillary-coverage-layer.js @@ -153,7 +153,6 @@ const MapillaryCoverage = L.GridLayer.extend({ }, 1); } ); - canvas._abortLoading = abortLoading; return canvas; }, diff --git a/src/lib/leaflet.control.panoramas/lib/mapillary/style.css b/src/lib/leaflet.control.panoramas/lib/mapillary/style.css @@ -0,0 +1,11 @@ +.mapillary-viewer-date-overlay { + position: absolute; + top: 0; + right: 0; + background-color: rgba(0,0,0,.7); + font-family: Arial,Helvetica,sans-serif; + font-size: 11px; + padding: 4px 4px; + color: #fff; + z-index: 1000; +} diff --git a/src/lib/leaflet.control.panoramas/lib/wikimedia/angle-left.svg b/src/lib/leaflet.control.panoramas/lib/wikimedia/angle-left.svg @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"> + <path d="M1203 544q0 13-10 23l-393 393 393 393q10 10 10 23t-10 23l-50 50q-10 10-23 10t-23-10l-466-466q-10-10-10-23t10-23l466-466q10-10 23-10t23 10l50 50q10 10 10 23z" fill="#fff"/> +</svg> +\ No newline at end of file diff --git a/src/lib/leaflet.control.panoramas/lib/wikimedia/angle-right.svg b/src/lib/leaflet.control.panoramas/lib/wikimedia/angle-right.svg @@ -0,0 +1,2 @@ +<?xml version="1.0" encoding="utf-8"?> +<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M1171 960q0 13-10 23l-466 466q-10 10-23 10t-23-10l-50-50q-10-10-10-23t10-23l393-393-393-393q-10-10-10-23t10-23l50-50q10-10 23-10t23 10l466 466q10 10 10 23z" fill="#fff"/></svg> +\ No newline at end of file diff --git a/src/lib/leaflet.control.panoramas/lib/wikimedia/coverage-layer.js b/src/lib/leaflet.control.panoramas/lib/wikimedia/coverage-layer.js @@ -0,0 +1,181 @@ +import L from 'leaflet'; +import {TiledDataLoader} from 'lib/tiled-data-loader'; + +const MultiLayer = L.Layer.extend({ + initialize: function(layers) { + this.layers = layers; + }, + + setLayersVisibility: function(e) { + if (!this._map) { + return; + } + + let newZoom; + if (e && e.zoom !== undefined) { + newZoom = e.zoom; + } else { + newZoom = this._map.getZoom(); + } + + for (let layer of this.layers) { + if (layer.minZoom <= newZoom && newZoom <= layer.maxZoom) { + this._map.addLayer(layer.layer); + } else { + this._map.removeLayer(layer.layer); + } + } + }, + + onAdd: function(map) { + this._map = map; + this.setLayersVisibility(); + map.on('zoomend', this.setLayersVisibility, this); + }, + + onRemove: function() { + for (let layer of this.layers) { + this._map.removeLayer(layer.layer); + } + this._map.off('zoomend', this.setLayersVisibility, this); + this._map.off('zoomanim', this.setLayersVisibility, this); + this._map = null; + } + +}); + +class WikimediaLoader extends TiledDataLoader { + constructor(urlTemplate, zoom) { + super(); + this.url = urlTemplate; + this.maxZoom = zoom; + } + + getTileUrl(coords) { + const data = { + x: coords.x, + z: coords.z, + y: 2 ** coords.z - 1 - coords.y + }; + return L.Util.template(this.url, data); + } + + layerTileToDataTileCoords(layerTileCoords) { + let z = layerTileCoords.z; + let z2 = null; + if (z > this.maxZoom) { + z2 = this.maxZoom + } else { + return {z, x: layerTileCoords.x, y: layerTileCoords.y} + } + + let multiplier = 1 << (z - z2); + return { + x: Math.floor(layerTileCoords.x / multiplier), + y: Math.floor(layerTileCoords.y / multiplier), + z: z2 + } + } + + + makeRequestData(dataTileCoords) { + return { + url: this.getTileUrl(dataTileCoords), + options: { + responseType: 'arraybuffer', + timeout: 5000, + isResponseSuccess: (xhr) => xhr.status === 200 || xhr.status === 404 + } + } + } + + async processResponse(xhr, originalDataTileCoords) { + let tileData; + if (originalDataTileCoords.z >= this.maxZoom && xhr.status === 200 && xhr.response) { + tileData = new Uint16Array(xhr.response); + } else { + tileData = null; + } + + return { + tileData, + coords: originalDataTileCoords + } + } +} + +const WikimediaVectorCoverage = L.GridLayer.extend({ + options: { + tileSize: 256, + // updateWhenIdle: true, + color: '#ff00ff', + }, + + initialize: function(url, options) { + L.GridLayer.prototype.initialize.call(this, options); + this.loader = new WikimediaLoader(url, 11); + }, + + onAdd: function(map) { + L.GridLayer.prototype.onAdd.call(this, map); + this.on('tileunload', this.onTileUnload, this); + }, + + onRemove: function(map) { + L.GridLayer.prototype.onRemove.call(this, map); + this.off('tileunload', this.onTileUnload, this); + + }, + + onTileUnload: function(e) { + const tile = e.tile; + tile._abortLoading(); + delete tile._tileData; + delete tile._adjustment; + }, + + drawTile: function(canvas, coords) { + if (!this._map) { + return; + } + if (!canvas._tileData) { + return; + } + const dataOffset = 5000; + const dataExtent = 65535 - 2 * dataOffset; + const tileData = canvas._tileData; + let {multiplier, offsetX, offsetY} = canvas._adjustment; + const canvasCtx = canvas.getContext('2d'); + const pixelScale = multiplier * 256 / dataExtent; + canvasCtx.fillStyle = this.options.color; + for (let i = 0, l = tileData.length; i < l; i += 2) { + let x = (tileData[i] - dataOffset) * pixelScale - offsetX * 256; + let y = (tileData[i + 1] - dataOffset) * pixelScale - offsetY * 256; + canvasCtx.beginPath(); + canvasCtx.arc(x, y, 5, 0, 2 * Math.PI); + canvasCtx.fill(); + } + }, + + createTile: function(coords, done) { + const canvas = L.DomUtil.create('canvas', 'leaflet-tile'); + canvas.width = this.options.tileSize; + canvas.height = this.options.tileSize; + let {dataPromise, abortLoading} = this.loader.requestTileData(coords); + dataPromise.then((data) => { + canvas._tileData = data.tileData; + canvas._adjustment = data.adjustment || {multiplier: 1, offsetX: 0, offsetY: 0}; + setTimeout(() => { + this.drawTile(canvas, coords); + done(null, canvas); + }, 1); + } + ); + + canvas._abortLoading = abortLoading; + return canvas; + }, + } +); + +export {MultiLayer, WikimediaVectorCoverage}; +\ No newline at end of file diff --git a/src/lib/leaflet.control.panoramas/lib/wikimedia/index.js b/src/lib/leaflet.control.panoramas/lib/wikimedia/index.js @@ -0,0 +1,340 @@ +import L from 'leaflet'; +import {MultiLayer, WikimediaVectorCoverage} from './coverage-layer'; +import {fetch} from 'lib/xhr-promise'; +import './style.css'; +import '../common/style.css'; +import config from 'config'; + + +function getCoverageLayer(options) { + const url = config.wikimediaCommonsCoverageUrl; + return new MultiLayer([ + {layer: L.tileLayer(url, L.extend({}, options, {tms: true})), + minZoom: 0, maxZoom: 10}, + {layer: new WikimediaVectorCoverage(url, options), + minZoom: 11, maxZoom: 18} + ]); +} + +function parseSearchResponse(resp) { + const images = []; + if (resp && resp.query && resp.query.pages && resp.query.pages) { + for (let page of Object.values(resp.query.pages)) { + const iinfo = page.imageinfo[0]; + let imageDescription = iinfo.extmetadata.ImageDescription ? iinfo.extmetadata.ImageDescription.value : null; + let objectDescription = iinfo.extmetadata.ObjectName ? iinfo.extmetadata.ObjectName.value : null; + if (imageDescription && /^<table (.|\n)+<\/table>$/.test(imageDescription)) { + imageDescription = null; + } + if (imageDescription) { + imageDescription = imageDescription.replace(/<[^>]+>/g, ''); + imageDescription = imageDescription.replace(/[\n\r]/g, ''); + } + if (imageDescription && objectDescription && objectDescription.toLowerCase().includes(imageDescription.toLowerCase())) { + imageDescription = null; + } + if (objectDescription && imageDescription && imageDescription.toLowerCase().includes(objectDescription.toLowerCase())) { + objectDescription = null; + } + let description = 'Wikimedia commons'; + if (objectDescription || imageDescription) { + description = ''; + if (objectDescription) { + description = objectDescription; + } + if (imageDescription) { + if (objectDescription) { + description += '</br>'; + } + description += imageDescription; + } + } + + let author = iinfo.extmetadata.Artist ? iinfo.extmetadata.Artist.value : null; + if (author && /^<table (.|\n)+<\/table>$/.test(author)) { + author = `See author info at <a href="${iinfo.descriptionurl}">Wikimedia commons</a>`; + } + + // original images can be rotated, 90 degrees + // thumbnails are always oriented right + // so we request thumbnail of original image size + let url = iinfo.thumburl.replace('134px', `${iinfo.width}px`); + images.push({ + url, + width: iinfo.width, + height: iinfo.height, + lat: page.coordinates[0].lat, + lng: page.coordinates[0].lon, + author: author, + timeOriginal: iinfo.extmetadata.DateTimeOriginal ? iinfo.extmetadata.DateTimeOriginal.value : null, + time: iinfo.extmetadata.DateTime ? iinfo.extmetadata.DateTime.value : null, + description: description, + pageUrl: iinfo.descriptionurl, + pageId: page.pageid.toString() + }) + } + if (images.length) { + return images; + } + } + return null; +} + +function isCloser(target, a, b) { + const d1 = target.distanceTo(a); + const d2 = target.distanceTo(b); + if (d1 < d2) { + return -1; + } else if (d1 === d2) { + return 0; + } else { + return 1; + } +} + +async function getPanoramaAtPos(latlng, searchRadiusMeters) { + const clusterSize = 10; + const urlTemplate = 'https://commons.wikimedia.org/w/api.php?' + + 'origin=*&format=json&action=query&generator=geosearch&' + + 'ggsprimary=all&ggsnamespace=6&ggslimit=10&iilimit=1&' + + 'ggsradius={radius}&ggscoord={lat}|{lng}&' + + 'iiurlwidth=134&' + + 'prop=imageinfo|coordinates&' + + 'iiprop=url|mime|size|extmetadata|commonmetadata|metadata'; + searchRadiusMeters += clusterSize; + if (searchRadiusMeters < 10) { + searchRadiusMeters = 10; + } + if (searchRadiusMeters > 10000) { + searchRadiusMeters = 10000; + } + const url = L.Util.template(urlTemplate, {lat: latlng.lat, lng: latlng.lng, radius: searchRadiusMeters}); + const resp = await fetch(url, {responseType: 'json', timeout: 10000}); + if (resp.status === 200) { + let photos = parseSearchResponse(resp.responseJSON); + if (photos) { + latlng = L.latLng(latlng.lat, latlng.lng); + photos.sort(isCloser.bind(null, latlng)); + latlng = L.latLng(photos[0].lat, photos[0].lng); + photos = photos.filter((photo) => latlng.distanceTo(L.latLng(photo.lat, photo.lng)) <= clusterSize, latlng); + return { + found: true, + data: photos + }; + } else { + return {found: false}; + } + + } + return {found: false}; +} + +function formatDateTime(dateStr) { + const m = /^(\d+)-(\d+)-(\d+)/.exec(dateStr); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + if (m) { + let [year, month, day] = m.slice(1); + return `${day} ${months[month - 1]} ${year}`; + } else { + return dateStr; + } +} + +const Viewer = L.Evented.extend({ + initialize: function(container) { + container = L.DomUtil.create('div', 'wikimedia-viewer-container', container); + const mapContainer = this.mapContainer = L.DomUtil.create('div', 'wikimedia-viewer-map-container', container); + this.pageButtonContainer = L.DomUtil.create('div', 'wikimedia-viewer-page-buttons-container', container); + + this.map = L.map(mapContainer, { + maxBoundsViscosity: 1, + crs: L.CRS.Simple, + zoomControl: false, + attributionControl: false, + zoomSnap: 0, + }); + + this.map.on('zoomend', this.notifyChange, this); + this.map.on('moveend', this.notifyChange, this); + + this.infoLabel = L.DomUtil.create('div', 'wikimedia-viewer-info-overlay', mapContainer); + this.closeButton = L.DomUtil.create('div', 'photo-viewer-button-close', container); + this.prevPhotoButton = L.DomUtil.create('div', 'wikimedia-viewer-button-prev', mapContainer); + this.nextPhotoButton = L.DomUtil.create('div', 'wikimedia-viewer-button-next', mapContainer); + L.DomEvent.on(this.prevPhotoButton, 'click', () => { + this.switchPhoto(this._imageIdx - 1); + }); + L.DomEvent.on(this.nextPhotoButton, 'click', () => { + this.switchPhoto(this._imageIdx + 1); + }); + L.DomEvent.on(this.closeButton, 'click', this.onCloseClick, this); + }, + + setupPageButtons: function(count) { + if (this._buttons) { + for (let button of this._buttons) { + this.pageButtonContainer.removeChild(button); + } + } + this._buttons = []; + if (count > 1) { + L.DomUtil.addClass(this.pageButtonContainer, 'enabled'); + + for (let i = 0; i < count; i++) { + let button = L.DomUtil.create('div', 'wikimedia-viewer-page-button', this.pageButtonContainer); + button.innerHTML = '' + (i + 1); + this._buttons.push(button); + L.DomEvent.on(button, 'click', () => this.switchPhoto(i)); + } + } else { + L.DomUtil.removeClass(this.pageButtonContainer, 'enabled'); + } + }, + + switchPhoto: function(imageIdx, imagePos=null) { + this._imageIdx = imageIdx; + if (this.imageLayer) { + this.map.removeLayer(this.imageLayer); + } + let image = this.images[imageIdx]; + const mapSize = this.map.getSize(); + let maxZoom = Math.log2(Math.max(image.width / mapSize.x, image.height / mapSize.y)) + 2; + if (maxZoom < 1) { + maxZoom = 1; + } + let + southWest = this.map.unproject([0, image.height], maxZoom - 2), + northEast = this.map.unproject([image.width, 0], maxZoom - 2); + const bounds = new L.LatLngBounds(southWest, northEast); + this.map.setMaxZoom(maxZoom); + this.map.setMaxBounds(bounds); + if (imagePos) { + this.map.setView(imagePos.center, imagePos.zoom, {animate: false}); + } else { + this.map.fitBounds(bounds, {animate: false}); + } + + this.imageLayer = L.imageOverlay(null, bounds); + L.DomUtil.addClass(this.mapContainer, 'loading'); + this.imageLayer.on('load', () => { + L.DomUtil.removeClass(this.mapContainer, 'loading'); + }); + this.imageLayer.setUrl(image.url); + this.imageLayer.addTo(this.map); + let caption = []; + + if (image.timeOriginal) { + caption.push(formatDateTime(image.timeOriginal)); + } + if (image.author) { + caption.push(image.author); + } + caption.push(`<a href="${image.pageUrl}">${image.description}</a>`); + caption = caption.join('</br>'); + this.infoLabel.innerHTML = caption; + + if (imageIdx > 0) { + L.DomUtil.addClass(this.prevPhotoButton, 'enabled'); + } else { + L.DomUtil.removeClass(this.prevPhotoButton, 'enabled'); + } + if (imageIdx < this.images.length - 1) { + L.DomUtil.addClass(this.nextPhotoButton, 'enabled'); + } else { + L.DomUtil.removeClass(this.nextPhotoButton, 'enabled'); + } + for (let [i, button] of this._buttons.entries()) { + ((i === imageIdx) ? L.DomUtil.addClass : L.DomUtil.removeClass)(button, 'active'); + } + this.notifyChange(); + }, + + notifyChange: function() { + if (this.images && this._active) { + const image = this.images[this._imageIdx]; + this.fire('change', { + latlng: L.latLng(image.lat, image.lng), + latlngs: this.images.map((image) => { + return L.latLng(image.lat, image.lng); + }) + + } + ) + } + }, + + _showPano: function(images, imageIdx=0, imagePos=null) { + this.images = images; + this.setupPageButtons(images.length); + this.switchPhoto(imageIdx, imagePos); + }, + + showPano: function(images) { + this._showPano(images); + }, + + onCloseClick: function() { + this.fire('closeclick'); + }, + + activate: function() { + this._active = true; + }, + + deactivate: function() { + this._active = false; + }, + + setState: function(state) { + const lat = parseFloat(state[0]); + const lng = parseFloat(state[1]); + const pageId = state[2]; + const y = parseFloat(state[3]); + const x = parseFloat(state[4]); + const zoom = parseFloat(state[5]); + if (!isNaN(lat) && !isNaN(lng) && !isNaN(x) && !isNaN(y) && !isNaN(zoom)) { + let imageIdx = -1; + getPanoramaAtPos({lat, lng}, 0).then((resp) => { + if (!resp.found) { + return false; + } + for (let [i, image] of resp.data.entries()) { + if (image.pageId === pageId) { + imageIdx = i; + break; + } + } + if (imageIdx > -1) { + this._showPano(resp.data, imageIdx, {center: L.latLng(y, x), zoom}); + } + }); + return true; + } + return false; + }, + + getState: function() { + if (!this.images) { + return []; + } + const center = this.map.getCenter(); + return [ + this.images[0].lat.toFixed(6), + this.images[0].lng.toFixed(6), + this.images[this._imageIdx].pageId, + center.lat.toFixed(2), + center.lng.toFixed(2), + this.map.getZoom().toFixed(1) + ]; + } +}); + +function getViewer(container) { + return new Viewer(container); +} + +export default { + getCoverageLayer, + getPanoramaAtPos, + getViewer +}; diff --git a/src/lib/leaflet.control.panoramas/lib/wikimedia/loading.svg b/src/lib/leaflet.control.panoramas/lib/wikimedia/loading.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?><svg xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.0" width="64px" height="64px" viewBox="0 0 128 128" xml:space="preserve"><g><linearGradient id="linear-gradient"><stop offset="0%" stop-color="#ffffff" fill-opacity="0"/><stop offset="100%" stop-color="#000000" fill-opacity="1"/></linearGradient><path d="M63.85 0A63.85 63.85 0 1 1 0 63.85 63.85 63.85 0 0 1 63.85 0zm.65 19.5a44 44 0 1 1-44 44 44 44 0 0 1 44-44z" fill="url(#linear-gradient)" fill-rule="evenodd"/><animateTransform attributeName="transform" type="rotate" from="0 64 64" to="360 64 64" dur="1080ms" repeatCount="indefinite"></animateTransform></g></svg> +\ No newline at end of file diff --git a/src/lib/leaflet.control.panoramas/lib/wikimedia/style.css b/src/lib/leaflet.control.panoramas/lib/wikimedia/style.css @@ -0,0 +1,105 @@ +.wikimedia-viewer-container { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; +} + +.wikimedia-viewer-map-container { + width: 100%; + flex-grow: 1; +} + +.wikimedia-viewer-map-container.loading { + background-repeat: no-repeat; + background-position: 50% 50%; + background-image: url("loading.svg"); +} + +.wikimedia-viewer-page-buttons-container { + width: 100%; + background-color: #aaaaaa; + white-space: nowrap; + overflow-x: auto; + overflow-y: hidden; + display: none; +} + +.wikimedia-viewer-page-buttons-container.enabled { + display: block; +} + +.wikimedia-viewer-page-button { + margin-right: 4px; + background-color: #777777; + border: 3px solid #777777; + color: white; + height: 37px; + width: 37px; + line-height: 37px; + font-family: Arial,Helvetica,sans-serif; + font-size: 20px; + font-weight: bold; + text-align: center; + cursor: pointer; + user-select: none; + display: inline-block; +} + +.wikimedia-viewer-page-button.active { + border: 3px solid black; +} + +.wikimedia-viewer-info-overlay { + position: absolute; + bottom: 0; + right: 0; + background-color: rgba(0,0,0,.7); + color: white; + font-family: Arial,Helvetica,sans-serif; + font-size: 11px; + padding: 4px; + text-align: right; + z-index: 1000; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + max-width: 100%; + box-sizing: border-box; +} + +.wikimedia-viewer-info-overlay a { + color: white; +} + +.wikimedia-viewer-button-prev, .wikimedia-viewer-button-next { + display: none; + cursor: pointer; + position: absolute; + top: 50%; + width: 40px; + height: 80px; + margin-top: -40px; + background-color: rgba(0,0,0,1); + opacity: 0.5; + z-index: 1000; + background-repeat: no-repeat; + background-size: 60px; +} + +.wikimedia-viewer-button-prev.enabled, .wikimedia-viewer-button-next.enabled { + display: block; +} + +.wikimedia-viewer-button-prev { + background-image: url("angle-left.svg"); + left: 0; + background-position: 70% 50%; +} + +.wikimedia-viewer-button-next { + background-image: url("angle-right.svg"); + right: 0; + background-position: 30% 50%; +} + diff --git a/src/lib/leaflet.control.panoramas/point.svg b/src/lib/leaflet.control.panoramas/point.svg @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="iso-8859-1"?> +<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 5 5"> + <g> + <ellipse ry="2.5" rx="2.5" cy="2.5" cx="2.5" fill="#ffaa00" stroke="none"/> + </g> +</svg> diff --git a/src/lib/leaflet.control.panoramas/style.css b/src/lib/leaflet.control.panoramas/style.css @@ -41,47 +41,36 @@ margin: 0 !important; } -.leaflet-panorama-marker { +.leaflet-panorama-marker-binocular { background-image: url("binocualar.png"); width: 31px; height: 31px; margin-left: -17px; margin-top: -17px; - /*transform-origin: 17px 17px;*/ +} + +.leaflet-panorama-marker-circle { + background-image: url("circle.svg"); + background-size: 32px 32px; + width: 33px; + height: 33px; + margin-left: -17px; + margin-top: -17px; + -webkit-backface-visibility: hidden; } .panoramas-control-active { cursor: pointer; } -.mapillary-viewer-date-overlay { - position: absolute; - top: 0; - right: 0; - background-color: rgba(0,0,0,.7); - font-family: Arial,Helvetica,sans-serif; - font-size: 11px; - padding: 4px 4px; - color: #fff; - z-index: 1000; -} -.mapillary-viewer-button-close { - position: absolute; - top: 0; - left: 0; - width: 40px; - height: 40px; - background-color: rgba(0,0,0,.7); - color: #aaa; - z-index: 1000; - text-align: center; - vertical-align: middle; - line-height: 40px; - font-family: monospace; - font-size: 30px; +.leaflet-panorama-marker-point { + background-image: url("point.svg"); + background-repeat: no-repeat; + background-position: 50% 50%; + background-size: 12px 12px; + width: 12px !important; + height: 12px !important; + /*margin-left: -5px;*/ + /*margin-top: -5px;*/ } - -.mapillary-viewer-button-close:before { - content: "←"; -} -\ No newline at end of file