commit 3dd0d32f9515846b1f34f3bb798e5454baf344e2 parent 32b1b4c900fd9a5d94c5b5ff88843bbe919619e0 Author: Sergej Orlov <wladimirych@gmail.com> Date: Wed, 17 Dec 2025 16:41:41 +0100 JNX: Create a control for creating JNX files Replace current button + context menu with a form-like control Fixes #77 Diffstat:
16 files changed, 343 insertions(+), 141 deletions(-)
diff --git a/src/App.js b/src/App.js @@ -75,6 +75,8 @@ function setUp() { // eslint-disable-line complexity layers: validateMinimizeState(minimizeState[1]), print: validateMinimizeState(minimizeState[2]), search: validateMinimizeState(minimizeState[3]), + jnx: validateMinimizeState(minimizeState[4]), + }; const map = new MapWithSidebars('map', { @@ -227,6 +229,12 @@ function setUp() { // eslint-disable-line complexity const jnxControl = new L.Control.JNX(layersControl, {position: 'bottomleft'}) .addTo(map) .enableHashState('j'); + if ( + minimizeControls.jnx === minimizeStateMinimized || + (minimizeControls.jnx === minimizeStateAuto && !jnxControl.areaSelectorVisible()) + ) { + jnxControl.setMinimized(); + } /* controls bottom-right corner */ diff --git a/src/lib/controls-styles/controls-styles.css b/src/lib/controls-styles/controls-styles.css @@ -37,11 +37,24 @@ color: #333; } +.leaflet-control .text-button.disabled, +.leaflet-control .image-button.disabled +{ + border-color: #eee; + color: #aaa; +} + .leaflet-control .image-button:hover, .leaflet-control .text-button:hover { background-color: #f4f4f4; } +.leaflet-control .image-button.disabled:hover, +.leaflet-control .text-button.disabled:hover { + background-color: unset; + cursor: unset; +} + .leaflet-control-button { display: inline-block; width: 26px; @@ -113,4 +126,14 @@ .icon-spinner-nuclear { background-image: url('images/spinner.gif'); -} -\ No newline at end of file +} + +.leaflet-control .icon-info { + display: inline-block; + vertical-align: bottom; + background-image: url("./images/info.svg"); + width: 16px; + height: 16px; + padding: 0; + margin: 0 0 0 2px; +} diff --git a/src/lib/leaflet.control.azimuth/info.svg b/src/lib/controls-styles/images/info.svg diff --git a/src/lib/leaflet.control.azimuth/control.html b/src/lib/leaflet.control.azimuth/control.html @@ -7,7 +7,6 @@ <tr> <td> Magnetic azimuth -<!-- <div class="icon-info" data-bind="attr: {title: magneticModelInfoText}"></div>--> <div class="icon-info" data-bind="attr: {title: `Magnetic model: ${magneticModelInfo.modelName}\n` + `Date: ${magneticModelInfo.dateYMD[2]}.${magneticModelInfo.dateYMD[1]}.${magneticModelInfo.dateYMD[0]}\n` diff --git a/src/lib/leaflet.control.azimuth/style.css b/src/lib/leaflet.control.azimuth/style.css @@ -44,14 +44,3 @@ .azimuth-control-active { cursor: crosshair; } - -.leaflet-control-azimuth .icon-info { - display: inline-block; - vertical-align: bottom; - background-image: url("./info.svg"); - width: 16px; - height: 16px; - margin: 0; - padding: 0; - margin-left: 2px; -} diff --git a/src/lib/leaflet.control.jnx/control.css b/src/lib/leaflet.control.jnx/control.css @@ -0,0 +1,81 @@ +.leaflet-control-jnx .leaflet-control-content { + margin-bottom: 4px; + width: 250px; +} + +.leaflet-control-jnx .icon-navigator { + background-image: url("images/navigator.svg"); +} + +.leaflet-control-jnx .row { + margin-top: 4px; +} + +.leaflet-control-jnx .title { + text-align: center; + font-weight: bold; +} + +.leaflet-control-jnx .area-selection-row { + text-align: center; +} + +.leaflet-control-jnx .zoom-choices { + text-align: right; + margin-top: 8px; +} + +.leaflet-control-jnx .zoom-choices th, +.leaflet-control-jnx .zoom-choices td { + padding: 0 2px 0 2px; +} +.leaflet-control-jnx .zoom-choices, +.leaflet-control-jnx .zoom-choices th, +.leaflet-control-jnx .zoom-choices td { + border-collapse: collapse; + border: 1px dotted hsl(0, 0%, 80%); +} + +.leaflet-control-jnx .zoom-choice.selected { + background-color: hsl(200, 70%, 80%) !important; +} +.leaflet-control-jnx .zoom-choice:hover { + background-color: hsl(0, 0%, 95%); +} + +.leaflet-control-jnx .zoom-choices-header { + vertical-align: top; + font-size: 11px; +} + +.leaflet-control-jnx .warning { + color: hsl(0, 60%, 50%); +} + +.leaflet-control-jnx .error { + color: hsl(0, 60%, 50%); + font-weight: bold; +} + +.leaflet-control-jnx .value { + font-weight: bold; +} + +.leaflet-control-jnx .row.warning { + opacity: 0; +} + +.leaflet-control-jnx .row.warning.visible { + opacity: unset; +} + +.leaflet-control-jnx .bottom-row { + position: relative; + text-align: center; +} + +.leaflet-control-jnx .button-minimize { + position: absolute; + left: 0; + bottom: 0; +} diff --git a/src/lib/leaflet.control.jnx/control.html b/src/lib/leaflet.control.jnx/control.html @@ -0,0 +1,60 @@ +<div class="leaflet-control-button-toggle" title="Export JNX file for Garmin (Birdseye, Outdoor Maps+)" + data-bind="click: onMinimizedDialogButtonClick, css: {'icon-spinner-nuclear': makingJnx, 'icon-navigator': !makingJnx()}"> +</div> +<div class="leaflet-control-content"> + <div class="title">Export Garmin JNX</div> + <div class="row"> + <div data-bind="if: !layerForExport()" class="error">No supported layer for export</div> + <div data-bind="if: layerForExport()">Layer to export: <span class="value" data-bind="text: layerForExport().name"></span></div> + </div> + + <div class="row area-selection-row"> + Area selection<br> + <a class="text-button" data-bind="click: setAreaSelector()">Set here</a> + <a class="text-button" data-bind="css: {disabled: !areaSelectorVisible()}, click: removeAreaSelector()">Remove</a> + <a class="text-button" data-bind="css: {disabled: !areaSelectorVisible()}, click: moveMapToAreaSelector()">Go to</a> + </div> + + + <table class="zoom-choices" data-bind="visible: zoomChoices"> + <tbody> + <tr class="zoom-choices-header"> + <th>Zoom</th><th>Resolution,<br>m/pixel</th><th>Est. file size,<br>Mb</th><th>Max tiles<br>count</th> + </tr> + <!-- ko foreach: zoomChoices() ? Object.values(zoomChoices()).reverse() : null --> + <tr class="zoom-choice" data-bind=" + css: {selected: $root.zoomLevel() === zoom, warning: warning}, + click: function() {$root.zoomLevel(zoom)}"> + <td data-bind="text: zoom"></td> + <td data-bind="text: metersPerPixel.toFixed(2)"></td> + <td data-bind="text: fileSizeMb.toFixed(fileSizeMb > 1 ? 0 : 1)"></td> + <td data-bind="text: maxLevelTiles"></td> + </tr> + <!-- /ko --> + </tbody> + </table> + + <div class="row warning" data-bind="css: {visible: zoomChoices() && zoomChoices()[zoomLevel()] && zoomChoices()[zoomLevel()].warning}"> + Warning: Too many tiles + <div class="icon-info" title="Garmin devices do not support JNX files with more than 50 000 tiles on a single zoom level"> + </div> + </div> + + <div class="bottom-row row"> + <a class="text-button" data-bind=" + click: makeJnx, + visible: !makingJnx(), + attr: {title: errorMessage()}, + css: {disabled: errorMessage()}">Save JNX + </a> + <div class="button-minimize" data-bind="click: setMinimized"></div> + <div data-bind=" + component: { + name: 'progress-indicator', + params: {progressRange: downloadProgressRange, progressDone: downloadProgressDone} + }, + visible: makingJnx()"> + </div> + </div> +</div> + diff --git a/src/lib/leaflet.control.jnx/hash-state.js b/src/lib/leaflet.control.jnx/hash-state.js @@ -7,8 +7,8 @@ L.Control.JNX.include({ serializeState: function() { let state; - if (this._selector) { - const bounds = this._selector.getBounds(); + if (this._areaSelector) { + const bounds = this._areaSelector.getBounds(); state = [ bounds.getSouth().toFixed(5), bounds.getWest().toFixed(5), @@ -49,8 +49,7 @@ L.Control.JNX.include({ } throw e; } - this.removeSelector(); - this.addSelector([[south, west], [north, east]]); + this.setAreaSelector([[south, west], [north, east]]); return true; } return false; diff --git a/src/lib/leaflet.control.jnx/images/navigator.png.url b/src/lib/leaflet.control.jnx/images/navigator.png.url @@ -0,0 +1 @@ +https://www.freepik.com/icon/map_15733109#fromView=search&page=1&position=19&uuid=d2d34bcf-ded4-4330-875c-2c9d7667fea0 diff --git a/src/lib/leaflet.control.jnx/images/navigator.svg b/src/lib/leaflet.control.jnx/images/navigator.svg @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> + +<svg + version="1.1" + viewBox="0 0 11 16" + xmlns="http://www.w3.org/2000/svg" + xmlns:svg="http://www.w3.org/2000/svg"> + <g style="fill:none;stroke:#707070;stroke-width:1"> + <rect + width="10" + height="15" + x="0.5" + y="0.5" + ry="1.5" /> + <path d="m 0,12.5 h 11" /> + <path style="stroke-linecap:round" d="m 5,14 h 1.5" /> + <path + style="stroke-linejoin:round" + d="m 2.958,9.392 2.52966,-5.46878 2.57218,5.49286 -2.55689,-1.54607 z" /> + </g> +</svg> diff --git a/src/lib/leaflet.control.jnx/index.js b/src/lib/leaflet.control.jnx/index.js @@ -1,14 +1,17 @@ import L from 'leaflet'; import ko from 'knockout'; -import './style.css'; + import '~/lib/leaflet.control.commons'; -import {RectangleSelect} from './selector'; -import Contextmenu from '~/lib/contextmenu'; -import {makeJnxFromLayer, minZoom} from './jnx-maker'; +import '~/lib/controls-styles/controls-styles.css'; import {saveAs} from '~/vendored/github.com/eligrey/FileSaver'; import {notify} from '~/lib/notifications'; import * as logging from '~/lib/logging'; +import {RectangleSelect} from './selector'; +import controlHtml from "./control.html"; +import './control.css'; +import {makeJnxFromLayer, minZoom} from './jnx-maker'; + L.Control.JNX = L.Control.extend({ includes: L.Mixin.Events, @@ -18,7 +21,10 @@ L.Control.JNX = L.Control.extend({ this.makingJnx = ko.observable(false); this.downloadProgressRange = ko.observable(1); this.downloadProgressDone = ko.observable(0); - this.contextMenu = new Contextmenu(() => this.makeMenuItems()); + this.layerForExport = ko.observable(null); + this.areaSelectorVisible = ko.observable(false); + this.zoomLevel = ko.observable(null); + this.zoomChoices = ko.observable(null); }, getLayerForJnx: function() { @@ -31,32 +37,38 @@ L.Control.JNX = L.Control.extend({ const layerName = layerRec.name; for (const subLayer of layer.getLayers().slice().reverse()) { if (subLayer.options?.jnx) { - return {layer: subLayer, layerName}; + return {layer: subLayer, name: layerName}; } } } else if (layer.options.jnx) { - return {layer, layerName: layerRec.name}; + return {layer, name: layerRec.name}; } } - return {}; + return null; }, estimateTilesCount: function(maxZoom) { - let tilesCount = 0; - const bounds = this._selector.getBounds(); + const bounds = this._areaSelector.getBounds(); + let maxTilesPerLevel = 0; + let totalTiles = 0; for (let zoom = minZoom(maxZoom); zoom <= maxZoom; zoom++) { const topLeftTile = this._map.project(bounds.getNorthWest(), zoom).divideBy(256).floor(); const bottomRightTile = this._map.project(bounds.getSouthEast(), zoom).divideBy(256).ceil(); - tilesCount += Math.ceil((bottomRightTile.x - topLeftTile.x) * (bottomRightTile.y - topLeftTile.y)); + const tilesCount = Math.ceil((bottomRightTile.x - topLeftTile.x) * (bottomRightTile.y - topLeftTile.y)); + totalTiles += tilesCount; + maxTilesPerLevel = Math.max(maxTilesPerLevel, tilesCount); } - return tilesCount; + return {total: totalTiles, maxPerLevel: maxTilesPerLevel}; }, - makeMenuItems: function() { - const {layer, layerName} = this.getLayerForJnx(); - if (!layer) { - return [{text: 'No supported layers'}]; + updateZoomChoices: function() { + if (!this.layerForExport() || !this._areaSelector) { + this.zoomChoices(null); + this.zoomLevel(null); + return; } + const layer = this.layerForExport().layer; + const choices = {}; const maxLevel = layer.options.maxNativeZoom || layer.options.maxZoom || 18; let minLevel = Math.max(0, maxLevel - 6); if (layer.options.minZoom) { @@ -64,25 +76,25 @@ L.Control.JNX = L.Control.extend({ } const equatorLength = 40075016; - const lat = this._selector.getBounds().getCenter().lat; + const lat = this._areaSelector.getBounds().getCenter().lat; let metersPerPixel = equatorLength / 2 ** maxLevel / 256 * Math.cos(lat / 180 * Math.PI); - const items = [{text: layerName, header: true}]; for (let zoom = maxLevel; zoom >= minLevel; zoom -= 1) { - let tilesCount = this.estimateTilesCount(zoom); - let fileSizeMb = tilesCount * 0.02; - let itemClass = tilesCount > 50000 ? 'jnx-menu-warning' : ''; - let resolutionString = metersPerPixel.toFixed(2); - let sizeString = fileSizeMb.toFixed(fileSizeMb > 1 ? 0 : 1); - let item = { - text: `<span class="${itemClass}">Zoom ${zoom} (${resolutionString} m/pixel) — ${tilesCount} tiles (~${sizeString} Mb)</span>`, // eslint-disable-line max-len - callback: () => this.makeJnx(layer, layerName, zoom), - disabled: this.makingJnx() + const tilesCount = this.estimateTilesCount(zoom); + const fileSizeMb = tilesCount.total * 0.02; + choices[zoom] = { + zoom, + metersPerPixel, + maxLevelTiles: tilesCount.maxPerLevel, + fileSizeMb, + warning: tilesCount.maxPerLevel > 50000, }; - items.push(item); metersPerPixel *= 2; } - return items; + this.zoomChoices(choices); + if (!this.zoomChoices()[this.zoomLevel()]) { + this.zoomLevel(null); + } }, notifyProgress: function(value, maxValue) { @@ -90,12 +102,31 @@ L.Control.JNX = L.Control.extend({ this.downloadProgressRange(maxValue); }, - makeJnx: function(layer, layerName, zoom) { + errorMessage: function() { + if (!this.layerForExport()) { + return 'Select layer for export'; + } + if (!this.areaSelectorVisible()) { + return 'Set area for export'; + } + if (this.zoomLevel() === null) { + return 'Select zoom level'; + } + return null; + }, + + makeJnx: function() { + if (!this.zoomLevel() || !this._areaSelector || !this.layerForExport()) { + return; + } logging.captureBreadcrumb('start making jnx'); this.makingJnx(true); this.downloadProgressDone(0); - const bounds = this._selector.getBounds(); + const bounds = this._areaSelector.getBounds(); + const layer = this.layerForExport().layer; + const layerName = this.layerForExport().name; + const zoom = this.zoomLevel(); const sanitizedLayerName = layerName.toLowerCase().replace(/[ ()]+/u, '_'); const fileName = `nakarte.me_${sanitizedLayerName}_z${zoom}.jnx`; const eventId = logging.randId(); @@ -124,54 +155,71 @@ L.Control.JNX = L.Control.extend({ onAdd: function(map) { this._map = map; - const container = this._container = L.DomUtil.create('div', 'leaflet-control leaflet-control-jnx'); - container.innerHTML = ` - <a class="button" data-bind="visible: !makingJnx(), click: onButtonClicked" - title="Make JNX for Garmin receivers">JNX</a> - <div data-bind=" - component:{ - name: 'progress-indicator', - params: {progressRange: downloadProgressRange, progressDone: downloadProgressDone} - }, - visible: makingJnx()"></div>`; + const container = this._container = L.DomUtil.create( + 'div', 'leaflet-control control-form leaflet-control-jnx' + ); + container.innerHTML = controlHtml; + map.on('baselayerchange overlayadd overlayremove', this.updateLayer, this); + this.updateLayer(); ko.applyBindings(this, container); this._stopContainerEvents(); return container; }, - removeSelector: function() { - if (this._selector) { - this._map.removeLayer(this._selector); - this._selector = null; - this.fire('selectionchange'); + removeAreaSelector: function() { + if (this._areaSelector) { + this._map.removeLayer(this._areaSelector); + this._areaSelector = null; + this.areaSelectorVisible(false); + this.onSelectorChange(); } }, - addSelector: function(bounds) { + setAreaSelector: function(bounds) { + if (this._areaSelector) { + this._map.removeLayer(this._areaSelector); + } + if (!bounds) { bounds = this._map.getBounds().pad(-0.25); } - this._selector = new RectangleSelect(bounds) + this._areaSelector = new RectangleSelect(bounds) .addTo(this._map) - .on('change', () => this.fire('selectionchange')) - .on('click contextmenu', (e) => { - L.DomEvent.stop(e); - this.contextMenu.show(e); - }); + .on('change', this.onSelectorChange, this) + .on('click', this.setExpanded, this); + this.areaSelectorVisible(true); + this.onSelectorChange(); + }, + + onSelectorChange: function() { + this.updateZoomChoices(); this.fire('selectionchange'); }, - onButtonClicked: function() { - if (this._selector) { - if (this._selector.getBounds().intersects(this._map.getBounds().pad(-0.05))) { - this.removeSelector(); - } else { - this.removeSelector(); - this.addSelector(); - } - } else { - this.addSelector(); + moveMapToAreaSelector: function() { + if (this._areaSelector) { + this._map.fitBounds(this._areaSelector.getBounds().pad(0.25)); + } + }, + + onMinimizedDialogButtonClick: function() { + if (!this._areaSelector) { + this.setAreaSelector(); } + this.setExpanded(); + }, + + setExpanded: function() { + L.DomUtil.removeClass(this._container, 'minimized'); + }, + + setMinimized: function() { + L.DomUtil.addClass(this._container, 'minimized'); + }, + + updateLayer: function() { + this.layerForExport(this.getLayerForJnx()); + this.updateZoomChoices(); }, } ); diff --git a/src/lib/leaflet.control.jnx/jnx-encoder.js b/src/lib/leaflet.control.jnx/jnx-encoder.js @@ -101,7 +101,6 @@ const JnxWriter = L.Class.extend({ level = parseInt(level, 10); stream.writeInt32(this.tiles[level].length); stream.writeUint32(tileDescriptorOffset); - // jnxScale = JnxScales[level + 3]; jnxScale = 34115555 / (2 ** level) * Math.cos((north + south) / 2 / 180 * Math.PI) / 1.1; stream.writeInt32(jnxScale); stream.writeInt32(2); diff --git a/src/lib/leaflet.control.jnx/selector.css b/src/lib/leaflet.control.jnx/selector.css @@ -0,0 +1,25 @@ +.leaflet-rectangle-select-edge.edge-top, .leaflet-rectangle-select-edge.edge-bottom { + height: 30px !important; + margin-top: -15px !important; +} + +.leaflet-rectangle-select-edge.edge-left, .leaflet-rectangle-select-edge.edge-right { + width: 30px !important; + margin-left: -15px !important; +} + +.leaflet-rectangle-select-edge.edge-top { + cursor: n-resize !important; +} + +.leaflet-rectangle-select-edge.edge-bottom { + cursor: s-resize !important; +} + +.leaflet-rectangle-select-edge.edge-left { + cursor: w-resize !important; +} + +.leaflet-rectangle-select-edge.edge-right { + cursor: e-resize !important; +} diff --git a/src/lib/leaflet.control.jnx/selector.js b/src/lib/leaflet.control.jnx/selector.js @@ -1,5 +1,7 @@ import L from 'leaflet'; +import './selector.css'; + const RectangleSelect = L.Rectangle.extend({ includes: L.Mixin.Events, diff --git a/src/lib/leaflet.control.jnx/style.css b/src/lib/leaflet.control.jnx/style.css @@ -1,58 +0,0 @@ -.leaflet-control-jnx .ko-progress{ - width: 200px; - background-color: white; -} - -.leaflet-control-jnx .button { - display: inline-block; - background-color: white; - height: 26px; - border-radius: 4px 4px 4px 4px; - border: 1px solid #ccc; - cursor: pointer; - margin-right: 6px; - padding: 0 1em; - line-height: 26px; - font-weight: bold; - color: #333; -} - -.leaflet-control-jnx .button:hover { - background-color: #f4f4f4; -} - -/*.leaflet-rectangle-select-edge {*/ - /*border: 1px solid black;*/ - /*background-color: red;*/ - /*opacity: 0.5;*/ -/*}*/ - -.leaflet-rectangle-select-edge.edge-top, .leaflet-rectangle-select-edge.edge-bottom { - height: 30px !important; - margin-top: -15px !important; -} - -.leaflet-rectangle-select-edge.edge-left, .leaflet-rectangle-select-edge.edge-right { - width: 30px !important; - margin-left: -15px !important; -} - -.leaflet-rectangle-select-edge.edge-top { - cursor: n-resize !important; -} - -.leaflet-rectangle-select-edge.edge-bottom { - cursor: s-resize !important; -} - -.leaflet-rectangle-select-edge.edge-left { - cursor: w-resize !important; -} - -.leaflet-rectangle-select-edge.edge-right { - cursor: e-resize !important; -} - -.jnx-menu-warning { - color: red; -} -\ No newline at end of file diff --git a/src/lib/leaflet.control.layers.configure/index.js b/src/lib/leaflet.control.layers.configure/index.js @@ -401,10 +401,16 @@ function enableConfig(control, {layers, customLayersOrder}, options = {withHotke for (let layer of enabledLayers) { if (!layer.layer.options.isOverlay) { this._map.addLayer(layer.layer); + hasBaselayerOnMap = true; break; } } } + // Not quite correct - the event will be fired even if there was no base layer before the update. + // Still it is better than not firing event at all when there is no base layer after update. + if (!hasBaselayerOnMap) { + this._map.fire('baselayerchange'); + } }, updateLayers: function(addedLayers) {
