commit 49e54d397869d441fa628d99db906cea09862dc0
Author: Sergej Orlov <wladimirych@gmail.com>
Date:   Sat,  5 Nov 2016 16:50:17 +0300
initial commit
Diffstat:
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: '±ddd.ddddd', callback: this.onMenuSelect.bind(this, 'd')},
+                    {text: 'ddd.ddddd°', callback: this.onMenuSelect.bind(this, 'D')},
+                    {text: 'ddd°mm.mmm\'', callback: this.onMenuSelect.bind(this, 'DM')},
+                    {text: 'ddd°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}°`;
+            }
+            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}°${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}°${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(/°/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')}
+                        /> 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')}/>
+                         x 
+                        <input type="text" maxLength="4" title="height" placeholder="height" className="page-size"
+                               value={this.state.pageHeight.toString()}
+                               onChange={this.handleNumericChange.bind(null, 'pageHeight')}/>
+                         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')}/> 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')}/> 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>
+                                     / 
+                                    <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;
+}