nakarte

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

commit 49e54d397869d441fa628d99db906cea09862dc0
Author: Sergej Orlov <wladimirych@gmail.com>
Date:   Sat,  5 Nov 2016 16:50:17 +0300

initial commit

Diffstat:
Asrc/App.css | 4++++
Asrc/App.js | 44++++++++++++++++++++++++++++++++++++++++++++
Asrc/App.test.js | 8++++++++
Asrc/config.js | 3+++
Asrc/index.css | 9+++++++++
Asrc/index.js | 5+++++
Asrc/layers.js | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/clipboardCopy/clipboardCopy.js | 42++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/clipboardCopy/style.css | 10++++++++++
Asrc/lib/contextmenu/contextmenu.css | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/contextmenu/contextmenu.js | 132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/control.caption/caption.js | 25+++++++++++++++++++++++++
Asrc/lib/control.caption/style.css | 17+++++++++++++++++
Asrc/lib/control.coordinates/coordinates.css | 39+++++++++++++++++++++++++++++++++++++++
Asrc/lib/control.coordinates/coordinates.js | 171+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/control.coordinates/coords16.png | 0
Asrc/lib/control.layers.hotkeys/control.Layers-hotkeys.js | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/control.layers.hotkeys/style.css | 6++++++
Asrc/lib/control.printPages/control.js | 178+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/control.printPages/form.js | 197+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/control.printPages/images/add-page-horiz.png | 0
Asrc/lib/control.printPages/images/add-page-vert.png | 0
Asrc/lib/control.printPages/images/remove-pages.png | 0
Asrc/lib/control.printPages/images/settings.png | 0
Asrc/lib/control.printPages/map-render.js | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/control.printPages/pageFeature.js | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/control.printPages/style.css | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/controls-styles.css | 45+++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/layer.yandex/yandex.js | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/xhr-promise/xhr-promise.js | 19+++++++++++++++++++
30 files changed, 1469 insertions(+), 0 deletions(-)

diff --git a/src/App.css b/src/App.css @@ -0,0 +1,3 @@ +#map { + height: 100%; +} +\ No newline at end of file diff --git a/src/App.js b/src/App.js @@ -0,0 +1,44 @@ +import './App.css'; +import L from 'leaflet'; +import 'leaflet/dist/leaflet.css'; +import layers from './layers'; +import './lib/control.printPages/control' +import './lib/control.caption/caption' +import config from './config' +import './lib/control.coordinates/coordinates'; +import './lib/control.layers.hotkeys/control.Layers-hotkeys'; + +function setUp() { + const map = L.map('map', { + zoomControl: false, + fadeAnimation: false, + center:[55.75185, 37.61856], + zoom: 10 + }); + + new L.Control.Caption(`<a href=mailto:${config.email}">nakarte@nakarte.tk</a>`, { + position: 'topleft' + }).addTo(map); + L.control.zoom().addTo(map); + + { + let baseLayers = layers.getBaseMaps(); + const layersControl = L.control.layers(baseLayers, layers.getOverlays(), {collapsed: false}).addTo(map); + map.addLayer(baseLayers['OpenStreetMap']); + } + + new L.Control.PrintPages().addTo(map); + new L.Control.Coordinates().addTo(map); + + // const hashState = L.HashState(); + // hashState.bind(map); + // hashState.bind(layersControl); + + // let p = L.polyline([[0, 0], [20, 20]]); + // p.addTo(map); + // map.on('contextmenu', () => { + // map.flyTo([10, 20], 13, {duration: 1}); + // }); +} + +export default {setUp}; diff --git a/src/App.test.js b/src/App.test.js @@ -0,0 +1,8 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import App from './App'; + +it('renders without crashing', () => { + const div = document.createElement('div'); + ReactDOM.render(<App />, div); +}); diff --git a/src/config.js b/src/config.js @@ -0,0 +1,3 @@ +export default { + email: 'nakarte@nakarte.tk' +} diff --git a/src/index.css b/src/index.css @@ -0,0 +1,9 @@ +body { + margin: 0; + padding: 0; + font-family: sans-serif; +} + +body, html{ + height: 100%; +} diff --git a/src/index.js b/src/index.js @@ -0,0 +1,5 @@ +import './index.css'; +import App from './App' + +App.setUp(); + diff --git a/src/layers.js b/src/layers.js @@ -0,0 +1,61 @@ +import L from "leaflet"; +import './lib/layer.yandex/yandex'; + +function getBaseMaps() { + // var bingKey = 'AhZy06XFi8uAADPQvWNyVseFx4NHYAOH-7OTMKDPctGtYo86kMfx2T0zUrF5AAaM'; + return { + 'OpenStreetMap': L.tileLayer('http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + {code: 'O', scaleDependent: true, print: true, jnx: true} + ), + 'ESRI Sat': L.tileLayer( + 'http://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', + {code: 'E', maxNativeZoom: 17, print: true, jnx: true} + ), + 'Yandex': new L.Layer.Yandex('map', {scaleDependent: true, code: 'Y', print: true, jnx: true}), + 'Yandex Sat': new L.Layer.Yandex('sat', {scaleDependent: false, code: 'S', print: true, jnx: true}), + // 'Google': new L.Google('ROADMAP', {code: 'G', scaleDependent: true, print: true, jnx: true}), + // 'Google Sat': new L.Google('SATELLITE', {code: 'L', print: true, jnx: true}), + // 'Google Sat Hybrid': new L.Google('HYBRID', {code: 'H', scaleDependent: true, print: true, jnx: false}), + // 'Bing Sat': L.bingLayer(bingKey, {code: 'I', print: true, jnx: true}), + 'marshruty.ru': L.tileLayer('http://maps.marshruty.ru/ml.ashx?x={x}&y={y}&z={z}&i=1&al=1', + {code: 'M', maxNativeZoom: 18, noCors: true, scaleDependent: true, print: true, jnx: true} + ), + 'Topomapper 1km': L.tileLayer( + 'http://144.76.234.107//cgi-bin/ta/tilecache.py/1.0.0/topomapper_v2/{z}/{x}/{y}.jpg', + {code: 'T', maxNativeZoom: 13, noCors: true, print: true, jnx: true} + ) + }; +} + +function getOverlays() { + return { + "Topo 10km": new L.TileLayer("http://{s}.tiles.nakarte.tk/topo001m/{z}/{x}/{y}", + {code: 'D', tms: true, maxNativeZoom: 9, print: true, jnx: true}), + "GGC 2 km": new L.TileLayer("http://{s}.tiles.nakarte.tk/ggc2000/{z}/{x}/{y}", + {code: 'N', tms: true, maxNativeZoom: 15, print: true, jnx: true}), + "ArbaletMO": new L.TileLayer("http://{s}.tiles.nakarte.tk/ArbaletMO/{z}/{x}/{y}", + {code: 'A', tms: true, maxNativeZoom: 13, print: true, jnx: true}), + "Slazav mountains": new L.TileLayer("http://{s}.tiles.nakarte.tk/map_hr/{z}/{x}/{y}", + {code: 'Q', tms: true, maxNativeZoom: 13, print: true, jnx: true}), + "GGC 1km": new L.TileLayer("http://{s}.tiles.nakarte.tk/ggc1000/{z}/{x}/{y}", + {code: 'J', tms: true, maxNativeZoom: 13, print: true, jnx: true}), + "Topo 1km": new L.TileLayer("http://{s}.tiles.nakarte.tk/topo1000/{z}/{x}/{y}", + {code: 'C', tms: true, maxNativeZoom: 13, print: true, jnx: true}), + "GGC 500m": new L.TileLayer("http://{s}.tiles.nakarte.tk/ggc500/{z}/{x}/{y}", + {code: 'F', tms: true, maxNativeZoom: 14, print: true, jnx: true}), + "Topo 500m": new L.TileLayer("http://{s}.tiles.nakarte.tk/topo500/{z}/{x}/{y}", + {code: 'B', tms: true, maxNativeZoom: 14, print: true, jnx: true}), + "GGC 250m": new L.TileLayer("http://{s}.tiles.nakarte.tk/ggc250/{z}/{x}/{y}", + {code: 'K', tms: true, maxNativeZoom: 15, print: true, jnx: true}), + "Slazav map": new L.TileLayer("http://{s}.tiles.nakarte.tk/map_podm/{z}/{x}/{y}", + {code: 'Z', tms: true, maxNativeZoom: 14, print: true, jnx: true}), + "O-sport": new L.TileLayer("http://{s}.tiles.nakarte.tk/osport/{z}/{x}/{y}", + {code: 'R', tms: true, maxNativeZoom: 17, print: true, jnx: true}) + // "Soviet military grid": new L.SovietTopoGrid({code: 'Ng'}), + // "Wikimapia": new L.Wikimapia({code: 'W', zIndexOffset: 10000}), + // "Google Street View": new L.GoogleStreetView('street-view', {print: true, code: 'Gs', zIndexOffset: 10000}), + // "Mountain passes (Westra)": new L.WestraPasses('/westraPasses/', {code: 'Wp', print: true, zIndexOffset: 10000}) + }; +} + +export default {getBaseMaps, getOverlays}; +\ No newline at end of file diff --git a/src/lib/clipboardCopy/clipboardCopy.js b/src/lib/clipboardCopy/clipboardCopy.js @@ -0,0 +1,41 @@ +import './style.css'; + +function showNotification(message, mouseEvent) { + var el = document.createElement('div'); + el.innerHTML = message; + el.className = 'copy-clipboard-notification'; + document.body.appendChild(el); + var w = el.offsetWidth, + h = el.offsetHeight, + x = mouseEvent.clientX - w - 8, + y = mouseEvent.clientY - h / 2; + if (x < 0) { + x = 0 + } + if (y < 0) { + y = 0 + } + el.style.top = y + 'px'; + el.style.left = x + 'px'; + setTimeout(function() { + document.body.removeChild(el); + }, 1000); + +} + +function copyToClipboard(s, mouseEvent) { + try { + var ta = document.createElement('textarea'); + ta.value = s; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + showNotification('Copied', mouseEvent); + } catch (e) { + prompt("Copy to clipboard: Ctrl+C, Enter", s); + } finally { + document.body.removeChild(ta); + } +} + +export default copyToClipboard; +\ No newline at end of file diff --git a/src/lib/clipboardCopy/style.css b/src/lib/clipboardCopy/style.css @@ -0,0 +1,10 @@ +.copy-clipboard-notification { + color: white; + background-color: #333; + border-radius: 4px; + padding: 0.5em 0.7em; + position: absolute; + z-index: 100000; + font-family: Hevetica, arial; + font-size: 11px; +} diff --git a/src/lib/contextmenu/contextmenu.css b/src/lib/contextmenu/contextmenu.css @@ -0,0 +1,50 @@ +.contextmenu { + position: fixed; + background-color: #fff; + font: 12px/1.5 "Helvetica Neue",Arial,Helvetica,sans-serif; + box-shadow: 0 1px 7px rgba(0,0,0,0.4); + -webkit-border-radius: 4px; + border-radius: 4px; + padding: 4px 0; + cursor: default; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; +} + +.contextmenu .item { + display: block; + color: #222; + font-size: 12px; + line-height: 16px; + height: 16px; + text-decoration: none; + padding: 0 12px; + border-top: 1px solid transparent; + border-bottom: 1px solid transparent; + cursor: default; +} + +.contextmenu .item:hover { + background-color: #f4f4f4; + border-top: 1px solid #f0f0f0; + border-bottom: 1px solid #f0f0f0; +} + +.contextmenu .separator span { + background-color: white; + padding: 0 0.2em; + color: #888; + margin: 0 1em; +} +.contextmenu .separator { + border-bottom: 1px solid #ccc; + text-align: center; + width: 100%; + line-height: 1px; + margin: 8px 0; +} + +.contextmenu .disabled { + color: #aaa; +} +\ No newline at end of file diff --git a/src/lib/contextmenu/contextmenu.js b/src/lib/contextmenu/contextmenu.js @@ -0,0 +1,131 @@ +import './contextmenu.css'; + +/* + items = [ + {text: 'Hello', disabled: true}, + '-', + {text: 'World', callback: fn}, + {text: 'section', separator: true}, + ] + */ +class Contextmenu { + constructor(items) { + this.items = items; + } + + show(e) { + if (this._container) { + return; + } + if (e.originalEvent) { + e = e.originalEvent; + } + if (e.preventDefault) { + e.preventDefault(); + } else { + e.returnValue = false; + } + + const {clientX: x, clientY: y} = e; + + const container = this._container = document.createElement('div'); + document.body.appendChild(container); + container.className = 'contextmenu'; + container.style.zIndex = 10000; + + window.addEventListener('keydown', this.onKeyDown); + window.addEventListener('mousedown', this.onMouseDown, true); + + for (let item of this.createItems()) { + container.appendChild(item); + } + this.setPosition(x, y); + } + + hide() { + if (!this._container) { + return; + } + document.body.removeChild(this._container); + this._container = null; + + window.removeEventListener('keydown', this.onKeyDown); + window.removeEventListener('mousedown', this.onMouseDown, true); + } + + onKeyDown = (e) => { + if (e.keyCode === 27) { + this.hide(); + } + }; + + onMouseDown = () => { + this.hide(); + }; + + setPosition(x, y) { + const window_width = window.innerWidth, + window_height = window.innerHeight, + menu_width = this._container.offsetWidth, + menu_height = this._container.offsetHeight; + if (x + menu_width >= window_width) { + x -= menu_width; + } + if (y + menu_height >= window_height) { + y -= menu_height; + } + this._container.style.left = `${x}px`; + this._container.style.top = `${y}px`; + } + + *createItems() { + let items = this.items; + if (typeof items === 'function') { + items = items(); + } + for (let itemOptions of items) { + if (typeof itemOptions === 'function') { + itemOptions = itemOptions(); + } + if (itemOptions === '-' || itemOptions.separator) { + yield this.createSeparator(itemOptions); + } else { + yield this.createItem(itemOptions); + } + } + } + + createItem(itemOptions) { + const el = document.createElement('a'); + let className = 'item'; + if (itemOptions.disabled) { + className += ' disabled'; + } + el.className = className; + el.innerHTML = itemOptions.text; + + const callback = itemOptions.callback; + if (callback && !itemOptions.disabled) { + el.addEventListener('mousedown', this.onItemClick.bind(this, callback)); + } + return el; + } + + onItemClick(callback, e) { + callback(); + e.stopPropagation(); + e.preventDefault(); + } + + createSeparator(itemOptions) { + const el = document.createElement('div'); + el.className = 'separator'; + if (itemOptions.text) { + el.innerHTML = `<span>${itemOptions.text}</span>`; + } + return el; + } + +} + +export default Contextmenu; +\ No newline at end of file diff --git a/src/lib/control.caption/caption.js b/src/lib/control.caption/caption.js @@ -0,0 +1,25 @@ +import L from 'leaflet'; +import './style.css'; + +L.Control.Caption = L.Control.extend({ + options: { + position: 'bottomright', + className: 'leaflet-control-caption' + }, + + initialize: function (contents, options) { + L.setOptions(this, options); + this._contents = contents; + }, + + onAdd: function (map) { + this._container = L.DomUtil.create('div', this.options.className); + this._container.innerHTML = this._contents; + if (L.DomEvent) { + L.DomEvent.disableClickPropagation(this._container); + } + return this._container; + } + +}); + diff --git a/src/lib/control.caption/style.css b/src/lib/control.caption/style.css @@ -0,0 +1,17 @@ +.leaflet-control-container .leaflet-control-caption { + background: #fff; + background: rgba(255, 255, 255, 0.7); + margin: 0; + padding: 0 5px; + color: #333; + text-decoration: none; + font-size: 11px; +} + +.leaflet-control-container .leaflet-control-caption a { + text-decoration: none; +} + +.leaflet-control-container .leaflet-control-caption a:hover { + text-decoration: underline; +} diff --git a/src/lib/control.coordinates/coordinates.css b/src/lib/control.coordinates/coordinates.css @@ -0,0 +1,38 @@ +.leaflet-control-coordinates { + display: inline-block; + width: 26px; + height: 26px; + background-position: 50% 50%; + background-repeat: no-repeat; + background-image: url('coords16.png'); + border-radius: 4px; + background-color: white; + box-shadow: 0 1px 5px rgba(0, 0, 0, 0.4); + cursor: pointer; +} + +.leaflet-control-coordinates.expanded { + background-image: none; + width: auto; + padding: 0 0.7em; + overflow: hidden; +} + +.leaflet-control-coordinates-text { + display: none; + white-space: nowrap; + float: left; + line-height: 26px; +} + +.leaflet-control-coordinates-text:first-child { + margin-right: 1em; +} + +.leaflet-control-coordinates.expanded .leaflet-control-coordinates-text { + display: inline; +} + +.coordinates-control-active { + cursor: default; +} +\ No newline at end of file diff --git a/src/lib/control.coordinates/coordinates.js b/src/lib/control.coordinates/coordinates.js @@ -0,0 +1,171 @@ +import L from 'leaflet' +import './coordinates.css'; +import copyToClipboard from '../clipboardCopy/clipboardCopy'; +import Contextmenu from '../contextmenu/contextmenu'; + +function pad(s, n) { + var j = s.indexOf('.'); + if (j === -1) { + j = s.length; + } + var zeroes = (n - j); + if (zeroes > 0) { + s = Array(zeroes + 1).join('0') + s; + } + return s; +} + +L.Control.Coordinates = L.Control.extend({ + options: { + position: 'bottomleft' + }, + + onAdd: function(map) { + this._map = map; + var container = this._container = L.DomUtil.create('div', 'leaflet-control leaflet-control-coordinates'); + this._field_lat = L.DomUtil.create('div', 'leaflet-control-coordinates-text', container); + this._field_lon = L.DomUtil.create('div', 'leaflet-control-coordinates-text', container); + L.DomEvent + .on(container, { + 'dblclick': L.DomEvent.stop, + 'click': this.onClick + }, this); + map.on('mousemove', this.onMouseMove, this); + this.menu = new Contextmenu([ + {text: 'Click to copy to clipboard', callback: this.prepareForClickOnMap.bind(this)}, + '-', + {text: '&plusmn;ddd.ddddd', callback: this.onMenuSelect.bind(this, 'd')}, + {text: 'ddd.ddddd&deg;', callback: this.onMenuSelect.bind(this, 'D')}, + {text: 'ddd&deg;mm.mmm\'', callback: this.onMenuSelect.bind(this, 'DM')}, + {text: 'ddd&deg;mm\'ss.s"', callback: this.onMenuSelect.bind(this, 'DMS')} + ] + ); + this.loadStateFromStorage(); + this.onMouseMove(); + L.DomEvent.on(container, 'contextmenu', this.onRightClick, this); + return container; + }, + + loadStateFromStorage: function() { + var active = false, + fmt = 'D'; + if (window.Storage && window.localStorage) { + active = localStorage.leafletCoordinatesActive === '1'; + fmt = localStorage.leafletCoordinatesFmt || fmt; + } + this.setEnabled(active); + this.setFormat(fmt); + }, + + saveStateToStorage: function() { + if (!(window.Storage && window.localStorage)) { + return; + } + localStorage.leafletCoordinatesActive = this.isEnabled() ? '1' : '0'; + localStorage.leafletCoordinatesFmt = this.fmt; + }, + + formatCoodinate: function(value, isLat) { + if (value === undefined) { + return '-------'; + } + + var h, d, m, s; + if (isLat) { + h = (value < 0) ? 'S' : 'N'; + } else { + h = (value < 0) ? 'W' : 'E'; + } + if (this.fmt === 'd') { + d = value.toFixed(5); + d = pad(d, isLat ? 2 : 3); + return d; + } + + value = Math.abs(value); + if (this.fmt === 'D') { + d = value.toFixed(5); + d = pad(d, isLat ? 2 : 3); + return `${h} ${d}&deg;`; + } + if (this.fmt === 'DM') { + d = Math.floor(value).toString(); + d = pad(d, isLat ? 2 : 3); + m = ((value - d) * 60).toFixed(3); + m = pad(m, 2); + return `${h} ${d}&deg;${m}'` + } + if (this.fmt === 'DMS') { + d = Math.floor(value).toString(); + d = pad(d, isLat ? 2 : 3); + m = Math.floor((value - d) * 60).toString(); + m = pad(m, 2); + s = ((value - d - m / 60) * 3600).toFixed(2); + s = pad(s, 2); + return `${h} ${d}&deg;${m}'${s}"`; + } + }, + + onMenuSelect: function(fmt) { + this.setFormat(fmt); + this.saveStateToStorage(); + }, + + setFormat: function(fmt) { + this.fmt = fmt; + this.onMouseMove(); + }, + + onMouseMove: function(e) { + if (!this.isEnabled()) { + return; + } + var lat, lng; + if (e) { + ({lat, lng} = e.latlng); + } + this._field_lat.innerHTML = this.formatCoodinate(lat, true); + this._field_lon.innerHTML = this.formatCoodinate(lng, false); + }, + + setEnabled: function(enabled) { + if (enabled) { + L.DomUtil.addClass(this._container, 'expanded'); + L.DomUtil.addClass(this._map._container, 'coordinates-control-active'); + } else { + L.DomUtil.removeClass(this._container, 'expanded'); + L.DomUtil.removeClass(this._map._container, 'coordinates-control-active'); + } + }, + + isEnabled: function() { + return L.DomUtil.hasClass(this._container, 'expanded'); + }, + + onClick: function(e) { + this.setEnabled(!this.isEnabled()); + this.saveStateToStorage(); + this.onMouseMove(); + }, + + onRightClick: function(e) { + this.menu.show(e); + }, + + onMapClick: function(e) { + var s = this.formatCoodinate(e.latlng.lat, true) + ' ' + this.formatCoodinate(e.latlng.lng, false); + s = s.replace(/&deg;/g, '°'); + copyToClipboard(s, e.originalEvent); + }, + + prepareForClickOnMap: function() { + this._map.once('click', this.onMapClick, this); + } + + + + // TODO: onRemove + + } +); + diff --git a/src/lib/control.coordinates/coords16.png b/src/lib/control.coordinates/coords16.png Binary files differ. diff --git a/src/lib/control.layers.hotkeys/control.Layers-hotkeys.js b/src/lib/control.layers.hotkeys/control.Layers-hotkeys.js @@ -0,0 +1,66 @@ +import L from 'leaflet' +import './style.css'; + +const originalOnAdd = L.Control.Layers.prototype.onAdd; +const originalOnRemove = L.Control.Layers.prototype.onRemove; +const originalAddLayer = L.Control.Layers.prototype._addLayer; + +L.Control.Layers.include({ + _addLayer: function(layer, name, overlay) { + if (layer.options) { + const code = layer.options.code; + if (code.length === 1) { + name += `<span class="layers-control-hotkey">${code}</span>`; + } + } + return originalAddLayer.call(this, layer, name, overlay); + }, + + onAdd: function(map) { + var result = originalOnAdd.call(this, map); + L.DomEvent.on(document, 'keyup', this._onHotkeyUp, this); + L.DomEvent.on(document, 'keydown', this.onKeyDown, this); + return result; + }, + + onRemove: function(map) { + L.DomEvent.off(document, 'keyup', this._onHotkeyUp, this); + L.DomEvent.off(document, 'keydown', this.onKeyDown, this); + originalOnRemove.call(this, map); + + }, + + onKeyDown: function(e) { + if (e.altKey || e.ctrlKey || e.shiftKey) { + return; + } + this._keyDown = e.keyCode; + }, + + _onHotkeyUp: function(e) { + const pressedKey = this._keyDown; + this._keyDown = null; + const targetTag = e.target.tagName.toLowerCase(); + if (('input' === targetTag && e.target.type === 'text')|| 'textarea' === targetTag || pressedKey !== e.keyCode) { + return; + } + const key = String.fromCharCode(e.keyCode); + for (let layer of this._layers) { + let layerId = L.stamp(layer.layer); + if (layer.layer.options && layer.layer.options.code && layer.layer.options.code.toUpperCase() === key) { + const inputs = this._form.getElementsByTagName('input'); + for (let input of inputs) { + if (input.layerId === layerId) { + input.click(); + break; + } + } + break; + } + } + } + + } +); + + diff --git a/src/lib/control.layers.hotkeys/style.css b/src/lib/control.layers.hotkeys/style.css @@ -0,0 +1,5 @@ +.layers-control-hotkey { + font-size: 78%; + color: #aaa; + margin-left: 0.5em; +} +\ No newline at end of file diff --git a/src/lib/control.printPages/control.js b/src/lib/control.printPages/control.js @@ -0,0 +1,177 @@ +import L from 'leaflet' +import React from 'react'; +import ReactDOM from 'react-dom'; +import '../controls-styles.css'; +import './style.css'; +import PrintPagesForm from './form'; +import PageFeature from './pageFeature'; +import Contextmenu from '../contextmenu/contextmenu'; +import {renderMap} from './map-render' + +L.Control.PrintPages = L.Control.extend({ + options: {position: 'bottomleft'}, + initialize: function(options) { + L.Control.prototype.initialize.call(this, options); + this.pages = []; + }, + + onAdd: function(map) { + this._map = map; + const container = this._container = + L.DomUtil.create('div', 'leaflet-control control-form control-print-pages'); + L.DomEvent.disableClickPropagation(container); + if (!L.Browser.touch) { + L.DomEvent.disableScrollPropagation(container); + } + + map.on('move', this.updateFormZooms, this); + this.form = ReactDOM.render(<PrintPagesForm + onAddLandscapePage={this.addLandscapePage.bind(this)} + onAddPortraitPage={this.addPortraitPage.bind(this)} + onRemovePages={this.removePages.bind(this)} + onSavePdf={this.savePdf.bind(this)} + onFormDataChanged={this.onFormDataChanged.bind(this)} + />, container + ); + this.updateFormZooms(); + return container; + }, + + addPage: function(data, landsacape) { + let {pageWidth, pageHeight, marginLeft, marginTop, marginRight, marginBottom} = data; + if (landsacape) { + [pageWidth, pageHeight] = [pageHeight, pageWidth]; + } + const page = new PageFeature(this._map.getCenter(), + [pageWidth - marginLeft - marginRight, pageHeight - marginTop - marginBottom], + data.scale, (this.pages.length + 1).toString() + ); + page.addTo(this._map); + this.pages.push(page); + let cm = new Contextmenu(this.makePageContexmenuItems.bind(this, page)); + page.on('contextmenu', cm.show, cm); + page.on('click', this.rotatePage.bind(this, page)); + page.on('move', this.updateFormZooms, this); + this.updateFormZooms(); + return page + }, + + addLandscapePage: function(data) { + const page = this.addPage(data, true); + page._rotated = true; + }, + + addPortraitPage: function(data) { + this.addPage(data, false); + }, + + removePage: function(page) { + let i = this.pages.indexOf(page); + this.pages.splice(i, 1); + this._map.removeLayer(page); + for (; i < this.pages.length; i++) { + this.pages[i].setLabel((i + 1).toString()); + } + this.updateFormZooms() + }, + + removePages: function() { + this.pages.forEach((page) => page.removeFrom(this._map)); + this.pages = []; + this.updateFormZooms(); + }, + + savePdf: function(data) { + if (!this._map) { + return; + } + renderMap(this._map); + }, + + onFormDataChanged: function(data) { + let {pageWidth, pageHeight, marginLeft, marginTop, marginRight, marginBottom, scale} = data; + this.pages.forEach((page) => { + let w = pageWidth - marginLeft - marginRight, + h = pageHeight - marginTop - marginBottom; + if (page._rotated) { + [w, h] = [h, w]; + } + page.setSize([w, h], scale); + } + ); + this.updateFormZooms(); + }, + + makePageContexmenuItems: function(page) { + var items = [ + {text: 'Rotate', callback: this.rotatePage.bind(this, page)}, + '-', + {text: 'Delete', callback: this.removePage.bind(this, page)}, + '-', + {text: 'Save image', callback: this.savePageJpg.bind(this, page), disabled: true} + ]; + if (this.pages.length > 1) { + items.push({text: 'Change order', separator: true}); + this.pages.forEach((p, i) => { + if (p !== page) { + items.push({ + text: (i + 1).toString(), + callback: this.renumberPage.bind(this, page, i) + } + ); + } + } + ); + } + return items; + }, + + rotatePage: function(page) { + page._rotated = !page._rotated; + page.rotate(); + }, + + savePageJpg: function(page) { + + }, + + renumberPage: function(page, newIndex) { + const oldIndex = this.pages.indexOf(page); + this.pages.splice(oldIndex, 1); + this.pages.splice(newIndex, 0, page); + for (let i = Math.min(oldIndex, newIndex); i < this.pages.length; i++) { + this.pages[i].setLabel((i + 1).toString()); + } + }, + + suggestZooms: function() { + const scale = this.form.state.scale, + resolution = this.form.state.resolution; + let referenceLat; + if (this.pages.length > 0) { + let absLats = this.pages.map((page) => { + return Math.abs(page.getLatLngBounds().getSouth()) + } + ); + referenceLat = Math.min(...absLats); + } else { + if (!this._map) { + return [null, null]; + } + referenceLat = this._map.getCenter().lat; + } + var targetMetersPerPixel = scale / (resolution / 2.54); + var mapUnitsPerPixel = targetMetersPerPixel / Math.cos(referenceLat * Math.PI / 180); + var zoomSat = Math.ceil(Math.log(40075016.4 / 256 / mapUnitsPerPixel) / Math.LN2); + + targetMetersPerPixel = scale / (90 / 2.54) / 1.5; + mapUnitsPerPixel = targetMetersPerPixel / Math.cos(referenceLat * Math.PI / 180); + var zoomMap = Math.round(Math.log(40075016.4 / 256 / mapUnitsPerPixel) / Math.LN2); + return {zoomMap, zoomSat}; + }, + + updateFormZooms: function() { + this.form.setSuggestedZooms(this.suggestZooms()); + } + } +); +\ No newline at end of file diff --git a/src/lib/control.printPages/form.js b/src/lib/control.printPages/form.js @@ -0,0 +1,197 @@ +import React, {Component} from 'react'; + +export default class PrintPagesForm extends Component { + constructor() { + super(); + this.state = { + scale: 500, + pageWidth: 210, + pageHeight: 297, + marginLeft: 3, + marginRight: 3, + marginTop: 3, + marginBottom: 3, + resolution: 300, + zoomLevel: 'auto', + advancedSettingsExpanded: false + } + } + + render() { + return ( + <table className="layout"> + <tbody> + <tr> + <td colSpan="2"> + <a title="Add page in portrait orientation" className="button-add-page-vert image-button" + onClick={this.props.onAddPortraitPage.bind(null, this.state)}/> + <a title="Add page in landscape orientation" className="button-add-page-horiz image-button" + onClick={this.props.onAddLandscapePage.bind(null, this.state)}/> + <a title="Remove all pages" className="button-remove-pages image-button" + onClick={this.props.onRemovePages}/> + </td> + </tr> + <tr> + <td className="label">Print scale</td> + <td> + <div className="preset-values"> + <a onClick={this.handleScalePresetClicked.bind(null, 100)}>100 m</a> + <a onClick={this.handleScalePresetClicked.bind(null, 500)}>500 m</a> + <a onClick={this.handleScalePresetClicked.bind(null, 1000)}>1 km</a> + </div> + <input type="text" className="scale" maxLength="6" + value={this.state.scale.toString()} + onChange={this.handleNumericChange.bind(null, 'scale')} + />&nbsp;m in 1 cm + </td> + </tr> + {this.state.advancedSettingsExpanded && + <tr> + <td className="label">Page size</td> + <td> + <div className="preset-values"> + <a onClick={this.handleSizePresetClicked.bind(null, 594, 841)}>A1</a> + <a onClick={this.handleSizePresetClicked.bind(null, 420, 594)}>A2</a> + <a onClick={this.handleSizePresetClicked.bind(null, 297, 420)}>A3</a> + <a onClick={this.handleSizePresetClicked.bind(null, 210, 297)}>A4</a> + <a onClick={this.handleSizePresetClicked.bind(null, 148, 210)}>A5</a> + </div> + <input type="text" maxLength="4" title="width" placeholder="width" className="page-size" + value={this.state.pageWidth.toString()} + onChange={this.handleNumericChange.bind(null, 'pageWidth')}/> + &nbsp;x&nbsp; + <input type="text" maxLength="4" title="height" placeholder="height" className="page-size" + value={this.state.pageHeight.toString()} + onChange={this.handleNumericChange.bind(null, 'pageHeight')}/> + &nbsp;mm + </td> + </tr> + } + {this.state.advancedSettingsExpanded && + <tr> + <td className="label-high">Margins</td> + <td> + <table className="margins"> + <tbody> + <tr> + <td /> + <td> + <input type="text" maxLength="2" + value={this.state.marginTop.toString()} + onChange={this.handleNumericChange.bind(null, 'marginTop')}/> + </td> + <td /> + </tr> + <tr> + <td> + <input type="text" maxLength="2" + value={this.state.marginLeft.toString()} + onChange={this.handleNumericChange.bind(null, 'marginLeft')}/> + </td> + <td /> + <td> + <input type="text" maxLength="2" + value={this.state.marginRight.toString()} + onChange={this.handleNumericChange.bind(null, 'marginRight')}/>&nbsp;mm + </td> + </tr> + <tr> + <td /> + <td> + <input type="text" maxLength="2" + value={this.state.marginBottom.toString()} + onChange={this.handleNumericChange.bind(null, 'marginBottom')}/> + </td> + <td /> + </tr> + </tbody> + </table> + </td> + </tr> + } + {this.state.advancedSettingsExpanded && + <tr> + <td className="label">Resolution</td> + <td><input type="text" maxLength="4" className="resolution" + value={this.state.resolution.toString()} + onChange={this.handleNumericChange.bind(null, 'resolution')}/>&nbsp;dpi + </td> + </tr> + } + {this.state.advancedSettingsExpanded && + <tr> + <td className="label">Source zoom<br/>level</td> + <td> + <select value={this.state.zoomLevel} onChange={this.handleZoomChange}> + <option value="auto">auto</option> + {[7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18].map((i) => + <option value={i.toString()} key={i.toString()}>{i.toString()}</option> + )} + </select> + </td> + </tr> + } + <tr> + <td colSpan="2"> + <a className="button-settings image-button" + onClick={this.toggleSettingsExpanded}/> + <div className="settings-summary"> + {this.state.pageWidth} x {this.state.pageHeight} mm, <br/> + {this.state.resolution} dpi, zoom {this.state.zoomLevel} + {this.state.zoomLevel === 'auto' && + <span> ( + <span title="Zoom for satellite and scanned imagery" >{this.state.zoomSat}</span> + &nbsp;/&nbsp; + <span title="Zoom for maps like OSM and Google" >{this.state.zoomMap}</span> + )</span> + } + </div> + </td> + </tr> + <tr> + <td colSpan="2"> + <a className="text-button button-save" onClick={this.props.onSavePdf.bind(null, this.state)}> + Save PDF</a> + </td> + </tr> + + </tbody> + </table> + ) + } + + onDataChanged() { + setTimeout(() => {this.props.onFormDataChanged(this.state)}, 0); + } + + handleNumericChange = (stateField, e) => { + const value = parseInt(e.target.value, 10); + if (!isNaN(value)) { + this.setState({[stateField]: value}); + this.onDataChanged(); + } + }; + + handleZoomChange = (e) => { + this.setState({zoomLevel: e.target.value}); + this.onDataChanged(); + }; + + toggleSettingsExpanded = (e) => { + this.setState({advancedSettingsExpanded: !this.state.advancedSettingsExpanded}); + }; + + handleScalePresetClicked = (scale) => { + this.setState({scale}); + this.onDataChanged(); + }; + + handleSizePresetClicked = (pageWidth, pageHeight) => { + this.setState({pageWidth, pageHeight}); + this.onDataChanged(); + }; + + setSuggestedZooms(zooms) { + this.setState(zooms); + } +} diff --git a/src/lib/control.printPages/images/add-page-horiz.png b/src/lib/control.printPages/images/add-page-horiz.png Binary files differ. diff --git a/src/lib/control.printPages/images/add-page-vert.png b/src/lib/control.printPages/images/add-page-vert.png Binary files differ. diff --git a/src/lib/control.printPages/images/remove-pages.png b/src/lib/control.printPages/images/remove-pages.png Binary files differ. diff --git a/src/lib/control.printPages/images/settings.png b/src/lib/control.printPages/images/settings.png Binary files differ. diff --git a/src/lib/control.printPages/map-render.js b/src/lib/control.printPages/map-render.js @@ -0,0 +1,68 @@ + +function getZIndex(el) { + return parseInt(window.getComputedStyle(el).zIndex, 10) || 0; +} + +function compare(i1, i2) { + if (i1 < i2) { + return -1; + } else if (i1 > i2) { + return 1; + } + return 0; +} + +function compareArrays(ar1, ar2) { + const len = Math.min(ar1.length, ar2.length); + for (let i = 0; i < len; i++) { + let c = compare(ar1[i], ar2[i]); + if (c) { + return c; + } + } + return compare(ar1.length, ar2.length); +} + +function getLayerZOrder(layer) { + let el = layer._container || layer._path; + if (!el) { + throw TypeError('Unsupported layer type'); + } + const order = []; + while (el !== layer._map._container) { + order.push(getZIndex(el)); + el = el.parentElement; + } + return order.reverse() +} + +function compareLayersOrder(layer1, layer2) { + return compareArrays(getLayerZOrder(layer1), getLayerZOrder(layer2)); +} + +function getLayersForPrint(map) { + let layers = []; + map.eachLayer((layer) => { + if (layer.options.print) { + layers.push(layer); + } + } + ); + layers.sort(compareLayersOrder); + layers = layers.map((l) => {l.clone()}); + return layers; +} + +function renderPage(layers, bounds, zoom, resolution) { + // const canvas = +} + +function savePageJpg(map, bounds, zoom, resolution) { + const layers = getLayersForPrint(map); +} + +function savePagesPdf(map, boundsList, zoom, resolution) { + const layers = getLayersForPrint(map); +} + +export {savePageJpg, savePagesPdf}; +\ No newline at end of file diff --git a/src/lib/control.printPages/pageFeature.js b/src/lib/control.printPages/pageFeature.js @@ -0,0 +1,92 @@ +import L from 'leaflet' + + +const PageFeature = L.Marker.extend({ + initialize: function(centerLatLng, paperSize, scale, label) { + this.paperSize = paperSize; + this.scale = scale; + var icon = L.divIcon({className: "print-page-marker", html: label}); + L.Marker.prototype.initialize.call(this, centerLatLng, { + icon: icon, + draggable: true, + title: 'Left click to rotate, right click for menu' + } + ); + this.on('drag', this.updateView.bind(this, undefined)); + }, + + onAdd: function(map) { + L.Marker.prototype.onAdd.call(this, map); + map.on('viewreset', this.updateView, this); + this.rectangle = L.rectangle(this.getLatLngBounds(), + {color: '#ff7800', weight: 2, opacity: 0.7, fillOpacity: 0.2} + ).addTo(map); + this.updateView(); + + }, + + onRemove: function(map) { + map.off('viewreset', this.updateView, this); + L.Marker.prototype.onRemove.call(this, map); + this.rectangle.removeFrom(map); + }, + + getLatLngBounds: function() { + var latlng = this.getLatLng(), + lng = latlng.lng, + lat = latlng.lat; + var width = this.paperSize[0] * this.scale / 10 / 111319.49 / Math.cos(lat * Math.PI / 180); + var height = this.paperSize[1] * this.scale / 10 / 111319.49; + var latlng_sw = [lat - height / 2, lng - width / 2]; + var latlng_ne = [lat + height / 2, lng + width / 2]; + return L.latLngBounds([latlng_sw, latlng_ne]); + }, + + _animateZoom: function(e) { + L.Marker.prototype._animateZoom.call(this, e); + this.updateView(e.zoom); + }, + + updateView: function(newZoom) { + if (!this._map) { + return; + } + if (newZoom === undefined) { + newZoom = this._map.getZoom(); + } + var bounds = this.getLatLngBounds(); + var pixel_sw = this._map.project(bounds.getSouthWest(), newZoom); + var pixel_ne = this._map.project(bounds.getNorthEast(), newZoom); + var pixel_center = this._map.project(this.getLatLng(), newZoom); + var st = this._icon.style; + var pixel_width = pixel_ne.x - pixel_sw.x; + var pixel_height = pixel_sw.y - pixel_ne.y; + st.width = `${pixel_width}px`; + st.height = `${pixel_height}px`; + st.marginLeft = `${pixel_sw.x - pixel_center.x}px`; + st.marginTop = `${pixel_ne.y - pixel_center.y}px`; + st.fontSize = `${Math.min(pixel_width, pixel_height, 500) / 2}px`; + st.lineHeight = `${pixel_height}px`; + this.rectangle.setBounds(bounds); + }, + + setLabel: function(s) { + this._icon.innerHTML = s; + }, + + setSize: function(paperSize, scale) { + this.paperSize = paperSize; + this.scale = scale; + console.log(paperSize, scale); + this.updateView(); + }, + + rotate: function(e) { + this.paperSize = [this.paperSize[1], this.paperSize[0]]; + this.updateView(); + } + + } +); + +export default PageFeature; +\ No newline at end of file diff --git a/src/lib/control.printPages/style.css b/src/lib/control.printPages/style.css @@ -0,0 +1,120 @@ +.control-print-pages { + line-height: 1.4; +} + +.control-print-pages .scale { + width: 3.2em; +} + +.control-print-pages .page-size { + width: 2.1em; +} + +.control-print-pages .margins input { + width: 1.1em; +} + +.control-print-pages .resolution { + width: 2.1em; +} + +.button-add-page-horiz { + background-image: url("images/add-page-horiz.png"); + margin-left: 2mm; +} + +.button-add-page-vert { + background-image: url("images/add-page-vert.png"); +} + +.button-remove-pages { + background-image: url("images/remove-pages.png"); + float: right; +} + +.button-settings { + background-image: url("images/settings.png"); +} + +.control-print-pages table { + border-collapse: collapse; +} + +.control-print-pages tr, +.control-print-pages td, +.control-print-pages tbody { + margin: 0; + padding: 0; +} + +.control-print-pages td { + border-top: 1px solid #eee; + padding-top: 1.5mm; + padding-bottom: 1mm; +} + +.control-print-pages tr:first-child td { + border: 0; +} + +.control-print-pages td:nth-child(2) { + padding-left: 2mm; +} + +.control-print-pages .margins td { + padding: 0; + border: 0; +} + +.control-print-pages .settings-summary { + display: inline-block; + margin-left: 3mm; +} + +.control-print-pages tr:last-child td { + text-align: center; +} + +.preset-values { + margin-bottom: 1.5mm; +} + +.preset-values a { + color: #999; + border-bottom: 1px dotted #999; + cursor: pointer; + margin-right: 2.5mm; +} + +.preset-values a:last-child { + margin-right: 0; +} + +.control-print-pages .label { + vertical-align: bottom; +} + +.control-print-pages .label-high { + vertical-align: middle; +} + +.print-page-marker { + box-sizing: border-box; + color: rgba(255, 120, 0, 0.5); + text-align: center; + /*background-color: rgba(255, 120, 0, 0.2);*/ + /*border: solid 2px rgba(255, 120, 0, 0.7);*/ + margin: 0; + vertical-align: middle; + +} + +.leaflet-zoom-anim .leaflet-zoom-animated.print-page-marker { + transition: + width 0.25s cubic-bezier(0, 0, 0.25, 1), + height 0.25s cubic-bezier(0, 0, 0.25, 1), + margin 0.25s cubic-bezier(0, 0, 0.25, 1), + font-size 0.25s cubic-bezier(0, 0, 0.25, 1), + line-height 0.25s cubic-bezier(0, 0, 0.25, 1), + transform 0.25s cubic-bezier(0,0,0.25,1); +} +\ No newline at end of file diff --git a/src/lib/controls-styles.css b/src/lib/controls-styles.css @@ -0,0 +1,44 @@ +.leaflet-control.control-form { + background-color: white; + border-radius: 5px 5px 5px 5px; + box-shadow: 0 1px 5px rgba(0, 0, 0, 0.4); + padding: 5px 10px 5px 12px; + color: #333; +} + +.leaflet-control.control-form input[type="text"] { + padding: 0 5px; + margin: 0; + border-radius: 3px; + border: 1px solid #ddd; + text-align: right; +} + +.leaflet-control .image-button { + width: 26px; + height: 26px; + background-position: 50% 50%; + background-repeat: no-repeat; + display: inline-block; + height: 26px; + border-radius: 4px 4px 4px 4px; + border: 1px solid #ccc; + cursor: pointer; +} + +.leaflet-control .image-button:hover, +.leaflet-control .text-button:hover { + background-color: #f4f4f4; +} + +.leaflet-control .text-button { + display: inline-block; + height: 26px; + border-radius: 4px 4px 4px 4px; + border: 1px solid #ccc; + cursor: pointer; + padding: 0 1em; + line-height: 26px; + font-weight: bold; + color: #333; +} +\ No newline at end of file diff --git a/src/lib/layer.yandex/yandex.js b/src/lib/layer.yandex/yandex.js @@ -0,0 +1,52 @@ +import L from 'leaflet' + +const yandexCrs = L.CRS.EPSG3395; + +const origPxBoundsToTileRange = L.TileLayer.prototype._pxBoundsToTileRange; +const origInitTile = L.TileLayer.prototype._initTile; +const origCreateTile = L.TileLayer.prototype.createTile; + +L.Layer.Yandex = L.TileLayer.extend({ + options: { + subdomains: '1234' + }, + + initialize: function(mapType, options) { + var url; + this._mapType = mapType; + if (mapType === 'sat') { + url = 'https://sat0{s}.maps.yandex.net/tiles?l=sat&x={x}&y={y}&z={z}'; + } else { + url = 'https://vec0{s}.maps.yandex.net/tiles?l=map&x={x}&y={y}&z={z}'; + } + + L.TileLayer.prototype.initialize.call(this, url, options); + }, + + _getTilePos: function(coords) { + const tilePosLatLng = yandexCrs.pointToLatLng(coords.scaleBy(this.getTileSize()), coords.z); + return this._map.project(tilePosLatLng, coords.z).subtract(this._level.origin).round(); + }, + + _pxBoundsToTileRange: function(bounds) { + const zoom = this._tileZoom; + const bounds2 = new L.Bounds( + yandexCrs.latLngToPoint(this._map.unproject(bounds.min, zoom), zoom), + yandexCrs.latLngToPoint(this._map.unproject(bounds.max, zoom), zoom)); + return origPxBoundsToTileRange.call(this, bounds2); + }, + + createTile: function(coords, done) { + const tile = origCreateTile.call(this, coords, done); + const coordsBelow = L.point(coords).add([0, 1]); + coordsBelow.z = coords.z; + tile._adjustHeight = this._getTilePos(coordsBelow).y - this._getTilePos(coords).y; + return tile + }, + + _initTile: function(tile) { + origInitTile.call(this, tile); + tile.style.height = `${tile._adjustHeight}px`; + } + } +); +\ No newline at end of file diff --git a/src/lib/xhr-promise/xhr-promise.js b/src/lib/xhr-promise/xhr-promise.js @@ -0,0 +1,19 @@ +export default function XMLHttpRequestPromise(url, method='GET', data=null, responseType='', timeout=0) { + const xhr = new XMLHttpRequest(); + xhr.open(method, url); + xhr.responseType = responseType; + xhr.timeout = timeout; + const result = new Promise(function(resolve, reject) { + xhr.onreadystatechange = function() { + if (xhr.readyState === 4) { + if (xhr.status) { + resolve(xhr); + } else { + reject(xhr); + } + } + } + }); + xhr.send(); + return result; +}