index.js (7397B)
1 import L from 'leaflet'; 2 import ko from 'knockout'; 3 import './style.css'; 4 import '~/lib/leaflet.control.commons'; 5 import {RectangleSelect} from './selector'; 6 import Contextmenu from '~/lib/contextmenu'; 7 import {makeJnxFromLayer, minZoom} from './jnx-maker'; 8 import {saveAs} from '~/vendored/github.com/eligrey/FileSaver'; 9 import {notify} from '~/lib/notifications'; 10 import * as logging from '~/lib/logging'; 11 12 L.Control.JNX = L.Control.extend({ 13 includes: L.Mixin.Events, 14 15 initialize: function(layersControl, options) { 16 L.Control.prototype.initialize.call(this, options); 17 this._layersControl = layersControl; 18 this.makingJnx = ko.observable(false); 19 this.downloadProgressRange = ko.observable(1); 20 this.downloadProgressDone = ko.observable(0); 21 this.contextMenu = new Contextmenu(() => this.makeMenuItems()); 22 }, 23 24 getLayerForJnx: function() { 25 for (let layerRec of this._layersControl._layers.slice().reverse()) { 26 let layer = layerRec.layer; 27 if (!this._map.hasLayer(layer) || !layer.options) { 28 continue; 29 } 30 if (layer.options.isWrapper) { 31 const layerName = layerRec.name; 32 for (const subLayer of layer.getLayers().slice().reverse()) { 33 if (subLayer.options?.jnx) { 34 return {layer: subLayer, layerName}; 35 } 36 } 37 } else if (layer.options.jnx) { 38 return {layer, layerName: layerRec.name}; 39 } 40 } 41 return {}; 42 }, 43 44 estimateTilesCount: function(maxZoom) { 45 let tilesCount = 0; 46 const bounds = this._selector.getBounds(); 47 for (let zoom = minZoom(maxZoom); zoom <= maxZoom; zoom++) { 48 const topLeftTile = this._map.project(bounds.getNorthWest(), zoom).divideBy(256).floor(); 49 const bottomRightTile = this._map.project(bounds.getSouthEast(), zoom).divideBy(256).ceil(); 50 tilesCount += Math.ceil((bottomRightTile.x - topLeftTile.x) * (bottomRightTile.y - topLeftTile.y)); 51 } 52 return tilesCount; 53 }, 54 55 makeMenuItems: function() { 56 const {layer, layerName} = this.getLayerForJnx(); 57 if (!layer) { 58 return [{text: 'No supported layers'}]; 59 } 60 const maxLevel = layer.options.maxNativeZoom || layer.options.maxZoom || 18; 61 let minLevel = Math.max(0, maxLevel - 6); 62 if (layer.options.minZoom) { 63 minLevel = Math.max(minLevel, layer.options.minZoom); 64 } 65 66 const equatorLength = 40075016; 67 const lat = this._selector.getBounds().getCenter().lat; 68 let metersPerPixel = equatorLength / 2 ** maxLevel / 256 * Math.cos(lat / 180 * Math.PI); 69 70 const items = [{text: layerName, header: true}]; 71 for (let zoom = maxLevel; zoom >= minLevel; zoom -= 1) { 72 let tilesCount = this.estimateTilesCount(zoom); 73 let fileSizeMb = tilesCount * 0.02; 74 let itemClass = tilesCount > 50000 ? 'jnx-menu-warning' : ''; 75 let resolutionString = metersPerPixel.toFixed(2); 76 let sizeString = fileSizeMb.toFixed(fileSizeMb > 1 ? 0 : 1); 77 let item = { 78 text: `<span class="${itemClass}">Zoom ${zoom} (${resolutionString} m/pixel) — ${tilesCount} tiles (~${sizeString} Mb)</span>`, // eslint-disable-line max-len 79 callback: () => this.makeJnx(layer, layerName, zoom), 80 disabled: this.makingJnx() 81 }; 82 items.push(item); 83 metersPerPixel *= 2; 84 } 85 return items; 86 }, 87 88 notifyProgress: function(value, maxValue) { 89 this.downloadProgressDone(this.downloadProgressDone() + value); 90 this.downloadProgressRange(maxValue); 91 }, 92 93 makeJnx: function(layer, layerName, zoom) { 94 logging.captureBreadcrumb('start making jnx'); 95 this.makingJnx(true); 96 this.downloadProgressDone(0); 97 98 const bounds = this._selector.getBounds(); 99 const sanitizedLayerName = layerName.toLowerCase().replace(/[ ()]+/u, '_'); 100 const fileName = `nakarte.me_${sanitizedLayerName}_z${zoom}.jnx`; 101 const eventId = logging.randId(); 102 this.fire('tileExportStart', { 103 eventId, 104 layer, 105 zoom, 106 bounds, 107 }); 108 makeJnxFromLayer(layer, layerName, zoom, bounds, this.notifyProgress.bind(this)) 109 .then((fileData) => { 110 saveAs(fileData, fileName, true); 111 this.fire('tileExportEnd', {eventId, success: true}); 112 }) 113 .catch((e) => { 114 logging.captureException(e, 'Failed to create JNX'); 115 this.fire('tileExportEnd', { 116 eventId, 117 success: false, 118 error: e 119 }); 120 notify(`Failed to create JNX: ${e.message}`); 121 }) 122 .then(() => this.makingJnx(false)); 123 }, 124 125 onAdd: function(map) { 126 this._map = map; 127 const container = this._container = L.DomUtil.create('div', 'leaflet-control leaflet-control-jnx'); 128 container.innerHTML = ` 129 <a class="button" data-bind="visible: !makingJnx(), click: onButtonClicked" 130 title="Make JNX for Garmin receivers">JNX</a> 131 <div data-bind=" 132 component:{ 133 name: 'progress-indicator', 134 params: {progressRange: downloadProgressRange, progressDone: downloadProgressDone} 135 }, 136 visible: makingJnx()"></div>`; 137 ko.applyBindings(this, container); 138 this._stopContainerEvents(); 139 return container; 140 }, 141 142 removeSelector: function() { 143 if (this._selector) { 144 this._map.removeLayer(this._selector); 145 this._selector = null; 146 this.fire('selectionchange'); 147 } 148 }, 149 150 addSelector: function(bounds) { 151 if (!bounds) { 152 bounds = this._map.getBounds().pad(-0.25); 153 } 154 this._selector = new RectangleSelect(bounds) 155 .addTo(this._map) 156 .on('change', () => this.fire('selectionchange')) 157 .on('click contextmenu', (e) => { 158 L.DomEvent.stop(e); 159 this.contextMenu.show(e); 160 }); 161 this.fire('selectionchange'); 162 }, 163 164 onButtonClicked: function() { 165 if (this._selector) { 166 if (this._selector.getBounds().intersects(this._map.getBounds().pad(-0.05))) { 167 this.removeSelector(); 168 } else { 169 this.removeSelector(); 170 this.addSelector(); 171 } 172 } else { 173 this.addSelector(); 174 } 175 }, 176 } 177 ); 178