control.js (21544B)
1 import L from 'leaflet'; 2 import ko from 'knockout'; 3 import '~/lib/knockout.component.progress/progress'; 4 import '~/lib/controls-styles/controls-styles.css'; 5 import './control.css'; 6 import PageFeature from './pageFeature'; 7 import Contextmenu from '~/lib/contextmenu'; 8 import {renderPages} from './map-render'; 9 import formHtml from './form.html'; 10 import {notify} from '~/lib/notifications'; 11 import {makePdf} from './pdf'; 12 import {saveAs} from '~/vendored/github.com/eligrey/FileSaver'; 13 import {blobFromString} from '~/lib/binary-strings'; 14 import '~/lib/leaflet.hashState/leaflet.hashState'; 15 import '~/lib/leaflet.control.commons'; 16 import * as logging from '~/lib/logging'; 17 import {MagneticMeridians} from './decoration.magnetic-meridians'; 18 import {OverlayScale} from './decoration.scale'; 19 import {Grid} from './decoration.grid'; 20 21 ko.extenders.checkNumberRange = function(target, range) { 22 return ko.pureComputed({ 23 read: target, // always return the original observables value 24 write: function(newValue) { 25 newValue = parseFloat(newValue); 26 if (newValue >= range[0] && newValue <= range[1]) { 27 target(newValue); 28 } else { 29 target.notifySubscribers(target()); 30 } 31 } 32 } 33 ).extend({notify: 'always'}); 34 }; 35 36 function savePagesPdf(imagesInfo, resolution, fileName) { 37 let pdf = makePdf(imagesInfo, resolution); 38 pdf = blobFromString(pdf); 39 saveAs(pdf, fileName, true); 40 } 41 42 function savePageJpg(page, fileName) { 43 saveAs(blobFromString(page.data), fileName, true); 44 } 45 46 L.Control.PrintPages = L.Control.extend({ 47 options: { 48 position: 'bottomleft', 49 defaultMargin: 7, 50 }, 51 52 includes: [L.Mixin.Events, L.Mixin.HashState], 53 54 stateChangeEvents: ['change'], 55 56 pageSizes: [ 57 {name: 'A1', width: 594, height: 841}, 58 {name: 'A2', width: 420, height: 594}, 59 {name: 'A3', width: 297, height: 420}, 60 {name: 'A4', width: 210, height: 297}, 61 {name: 'A5', width: 148, height: 210} 62 ], 63 64 zoomLevels: ['auto', 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18], 65 66 initialize: function(options) { 67 L.Control.prototype.initialize.call(this, options); 68 this.pages = []; 69 this.scale = ko.observable(500).extend({checkNumberRange: [1, 1000000]}); 70 this.resolution = ko.observable(300).extend({checkNumberRange: [10, 9999]}); 71 this.zoomLevel = ko.observable('auto'); 72 this.pageWidth = ko.observable(210).extend({checkNumberRange: [10, 9999]}); 73 this.pageHeight = ko.observable(297).extend({checkNumberRange: [10, 9999]}); 74 this.settingsExpanded = ko.observable(false); 75 this.makingPdf = ko.observable(false); 76 this.downloadProgressRange = ko.observable(); 77 this.downloadProgressDone = ko.observable(); 78 this.marginLeft = ko.observable(this.options.defaultMargin).extend({checkNumberRange: [0, 99]}); 79 this.marginRight = ko.observable(this.options.defaultMargin).extend({checkNumberRange: [0, 99]}); 80 this.marginTop = ko.observable(this.options.defaultMargin).extend({checkNumberRange: [0, 99]}); 81 this.marginBottom = ko.observable(this.options.defaultMargin).extend({checkNumberRange: [0, 99]}); 82 this.autoZoomLevels = ko.observable({}); 83 this.printSize = ko.pureComputed(this._printSize, this); 84 this.printSize.subscribe(this.onPageSizeChanged, this); 85 this.scale.subscribe(this.onPageSizeChanged, this); 86 this.resolution.subscribe(this.onPageSizeChanged, this); 87 this.pageSizeDescription = ko.pureComputed(this._displayPageSize, this); 88 this.pagesNum = ko.observable(0); 89 this.pagesNumLabel = ko.pureComputed(this._pagesNumLabel, this); 90 this.gridOn = ko.observable(false); 91 this.magneticMeridiansOn = ko.observable(false); 92 93 // hash state notifications 94 this.scale.subscribe(this.notifyChange, this); 95 this.printSize.subscribe(this.notifyChange, this); 96 this.resolution.subscribe(this.notifyChange, this); 97 this.zoomLevel.subscribe(this.notifyChange, this); 98 this.gridOn.subscribe(this.notifyChange, this); 99 this.magneticMeridiansOn.subscribe(this.notifyChange, this); 100 }, 101 102 onAdd: function(map) { 103 this._map = map; 104 const container = this._container = 105 L.DomUtil.create('div', 'leaflet-control control-form control-print-pages'); 106 this._stopContainerEvents(); 107 108 map.on('move', this.updateFormZooms, this); 109 container.innerHTML = formHtml; 110 ko.applyBindings(this, container); 111 this.updateFormZooms(); 112 return container; 113 }, 114 115 setExpanded: function() { 116 L.DomUtil.removeClass(this._container, 'minimized'); 117 }, 118 119 setMinimized: function() { 120 L.DomUtil.addClass(this._container, 'minimized'); 121 }, 122 123 addPage: function(isLandscape, center) { 124 let [pageWidth, pageHeight] = this.printSize(); 125 if (isLandscape) { 126 [pageWidth, pageHeight] = [pageHeight, pageWidth]; 127 } 128 if (!center) { 129 center = this._map.getCenter(); 130 } 131 const page = new PageFeature(center, [pageWidth, pageHeight], 132 this.scale(), (this.pages.length + 1).toString() 133 ); 134 page._rotated = isLandscape; 135 page.addTo(this._map); 136 this.pages.push(page); 137 this.pagesNum(this.pages.length); 138 let cm = new Contextmenu(this.makePageContexmenuItems.bind(this, page)); 139 page.on('contextmenu', cm.show, cm); 140 page.on('click', this.rotatePage.bind(this, page)); 141 page.on('move', this.updateFormZooms, this); 142 page.on('moveend', this.notifyChange, this); 143 this.updateFormZooms(); 144 this.notifyChange(); 145 return page; 146 }, 147 148 addLandscapePage: function() { 149 this.addPage(true); 150 }, 151 152 addPortraitPage: function() { 153 this.addPage(false); 154 }, 155 156 removePage: function(page) { 157 let i = this.pages.indexOf(page); 158 this.pages.splice(i, 1); 159 this.pagesNum(this.pages.length); 160 this._map.removeLayer(page); 161 for (; i < this.pages.length; i++) { 162 this.pages[i].setLabel((i + 1).toString()); 163 } 164 this.notifyChange(); 165 this.updateFormZooms(); 166 }, 167 168 removePages: function() { 169 this.pages.forEach((page) => page.removeFrom(this._map)); 170 this.pages = []; 171 this.pagesNum(this.pages.length); 172 this.notifyChange(); 173 this.updateFormZooms(); 174 }, 175 176 onSavePdfClicked: function() { 177 if (!this.pages.length) { 178 notify('Add some pages to print'); 179 return; 180 } 181 this.savePdf(); 182 }, 183 184 zoomForPrint: function() { 185 let zoom = this.zoomLevel(); 186 if (zoom === 'auto') { 187 zoom = this.suggestZooms(); 188 } else { 189 zoom = {mapZoom: zoom, satZoom: zoom}; 190 } 191 return zoom; 192 }, 193 194 incrementProgress: function(inc, range) { 195 this.downloadProgressRange(range); 196 this.downloadProgressDone((this.downloadProgressDone() || 0) + inc); 197 }, 198 199 savePdf: function() { 200 logging.captureBreadcrumb('start save pdf'); 201 if (!this._map) { 202 return; 203 } 204 this.downloadProgressRange(1000); 205 this.downloadProgressDone(undefined); 206 this.makingPdf(true); 207 const pages = this.pages.map((page) => ({ 208 latLngBounds: page.getLatLngBounds(), 209 printSize: page.getPrintSize(), 210 label: page.getLabel() 211 })); 212 const resolution = this.resolution(); 213 const decorationLayers = []; 214 if (this.gridOn()) { 215 decorationLayers.push(new Grid()); 216 } 217 if (this.magneticMeridiansOn()) { 218 decorationLayers.push(new MagneticMeridians()); 219 } 220 decorationLayers.push(new OverlayScale()); 221 const scale = this.scale(); 222 const width = this.pageWidth(); 223 const height = this.pageHeight(); 224 const eventId = logging.randId(); 225 const zooms = this.zoomForPrint(); 226 this.fire('mapRenderStart', { 227 action: 'pdf', 228 eventId, 229 scale, 230 resolution, 231 pages, 232 zooms 233 }); 234 renderPages({ 235 map: this._map, 236 pages, 237 zooms, 238 resolution, 239 scale, 240 decorationLayers, 241 progressCallback: this.incrementProgress.bind(this) 242 } 243 ).then(({images, renderedLayers}) => { 244 if (images) { 245 const fileName = this.getFileName({ 246 renderedLayers, 247 scale, 248 width, 249 height, 250 extension: 'pdf' 251 }); 252 savePagesPdf(images, resolution, fileName); 253 this.fire('mapRenderEnd', {eventId, success: true}); 254 } 255 } 256 ).catch((e) => { 257 logging.captureException(e, 'raster creation failed'); 258 this.fire('mapRenderEnd', {eventId, success: false, error: e}); 259 notify(`Failed to create PDF: ${e.message}`); 260 } 261 ).then(() => this.makingPdf(false)); 262 }, 263 264 savePageJpg: function(page) { 265 logging.captureBreadcrumb('start save page jpg', {pageNumber: page.getLabel()}); 266 const pages = [{ 267 latLngBounds: page.getLatLngBounds(), 268 printSize: page.getPrintSize(), 269 label: page.getLabel() 270 }]; 271 const decorationLayers = []; 272 if (this.gridOn()) { 273 decorationLayers.push(new Grid()); 274 } 275 if (this.magneticMeridiansOn()) { 276 decorationLayers.push(new MagneticMeridians()); 277 } 278 decorationLayers.push(new OverlayScale()); 279 this.downloadProgressRange(1000); 280 this.downloadProgressDone(undefined); 281 this.makingPdf(true); 282 const resolution = this.resolution(); 283 const scale = this.scale(); 284 const width = this.pageWidth(); 285 const height = this.pageHeight(); 286 const eventId = logging.randId(); 287 const zooms = this.zoomForPrint(); 288 this.fire('mapRenderStart', { 289 action: 'jpg', 290 eventId, 291 scale, 292 resolution, 293 pages, 294 zooms 295 }); 296 renderPages({ 297 map: this._map, 298 pages, 299 zooms, 300 resolution, 301 scale, 302 decorationLayers, 303 progressCallback: this.incrementProgress.bind(this) 304 } 305 ) 306 .then(({images, renderedLayers}) => { 307 const fileName = this.getFileName({ 308 renderedLayers, 309 scale, 310 width, 311 height, 312 extension: 'jpg' 313 }); 314 savePageJpg(images[0], fileName); 315 this.fire('mapRenderEnd', {eventId, success: true}); 316 }) 317 .catch((e) => { 318 logging.captureException(e, 'raster creation failed'); 319 this.fire('mapRenderEnd', {eventId, success: false, error: e}); 320 notify(`Failed to create JPEG from page: ${e.message}`); 321 } 322 ).then(() => this.makingPdf(false)); 323 }, 324 325 onPageSizeChanged: function() { 326 let [pageWidth, pageHeight] = this.printSize(); 327 this.pages.forEach((page) => { 328 let [w, h] = [pageWidth, pageHeight]; 329 if (page._rotated) { 330 [w, h] = [h, w]; 331 } 332 page.setSize([w, h], this.scale()); 333 } 334 ); 335 this.updateFormZooms(); 336 }, 337 338 onPagesNumLabelClick: function() { 339 if (this.pages.length > 0) { 340 const bounds = L.latLngBounds([]); 341 for (let page of this.pages) { 342 bounds.extend(page.latLngBounds); 343 } 344 this._map.fitBounds(bounds.pad(0.2)); 345 } 346 }, 347 348 makePageContexmenuItems: function(page) { 349 const items = [ 350 {text: 'Rotate', callback: this.rotatePage.bind(this, page)}, 351 '-', 352 {text: 'Delete', callback: this.removePage.bind(this, page)}, 353 '-', 354 {text: 'Save image', callback: this.savePageJpg.bind(this, page), disabled: this.makingPdf()} 355 ]; 356 if (this.pages.length > 1) { 357 items.push({text: 'Change order', separator: true}); 358 this.pages.forEach((p, i) => { 359 if (p !== page) { 360 items.push({ 361 text: (i + 1).toString(), 362 callback: this.renumberPage.bind(this, page, i) 363 } 364 ); 365 } 366 } 367 ); 368 } 369 return items; 370 }, 371 372 rotatePage: function(page) { 373 page._rotated = !page._rotated; 374 page.rotate(); 375 this.notifyChange(); 376 }, 377 378 renumberPage: function(page, newIndex) { 379 const oldIndex = this.pages.indexOf(page); 380 this.pages.splice(oldIndex, 1); 381 this.pages.splice(newIndex, 0, page); 382 for (let i = Math.min(oldIndex, newIndex); i < this.pages.length; i++) { 383 this.pages[i].setLabel((i + 1).toString()); 384 } 385 this.notifyChange(); 386 }, 387 388 _printSize: function() { 389 return [this.pageWidth() - this.marginLeft() - this.marginRight(), 390 this.pageHeight() - this.marginTop() - this.marginBottom()]; 391 }, 392 393 suggestZooms: function() { 394 const scale = this.scale(), 395 resolution = this.resolution(); 396 let referenceLat; 397 if (this.pages.length > 0) { 398 let absLats = this.pages.map((page) => Math.abs(page.getLatLngBounds().getCenter().lat)); 399 referenceLat = Math.min(...absLats); 400 } else { 401 if (!this._map) { 402 return [null, null]; 403 } 404 referenceLat = this._map.getCenter().lat; 405 } 406 let targetMetersPerPixel = scale / (resolution / 2.54); 407 let mapUnitsPerPixel = targetMetersPerPixel / Math.cos(referenceLat * Math.PI / 180); 408 let satZoom = Math.ceil(Math.log(40075016.4 / 256 / mapUnitsPerPixel) / Math.LN2); 409 410 targetMetersPerPixel = scale / (90 / 2.54) / 1.5; 411 mapUnitsPerPixel = targetMetersPerPixel / Math.cos(referenceLat * Math.PI / 180); 412 let mapZoom = Math.round(Math.log(40075016.4 / 256 / mapUnitsPerPixel) / Math.LN2); 413 mapZoom = Math.min(mapZoom, 18); 414 satZoom = Math.min(satZoom, 18); 415 mapZoom = Math.max(mapZoom, 0); 416 satZoom = Math.max(satZoom, 0); 417 return {mapZoom, satZoom}; 418 }, 419 420 updateFormZooms: function() { 421 this.autoZoomLevels(this.suggestZooms()); 422 }, 423 424 _displayPageSize: function() { 425 const width = this.pageWidth(), 426 height = this.pageHeight(); 427 for (let size of this.pageSizes) { 428 if (size.width === width && size.height === height) { 429 return size.name; 430 } 431 } 432 return `${width} x ${height} mm`; 433 }, 434 435 notifyChange: function() { 436 this.fire('change'); 437 }, 438 439 hasPages: function() { 440 return this.pages.length > 0; 441 }, 442 443 _pagesNumLabel: function() { 444 const n = this.pagesNum(); 445 let label = ''; 446 if (n) { 447 label += n; 448 } else { 449 label = 'No'; 450 } 451 label += ' page'; 452 if (n === 0 || n > 1) { 453 label += 's'; 454 } 455 return label; 456 }, 457 458 serializeState: function() { 459 const pages = this.pages; 460 let state = null; 461 if (pages.length) { 462 state = []; 463 state.push(this.scale().toString()); 464 state.push(this.resolution().toString()); 465 state.push(this.zoomLevel().toString()); 466 state.push(this.pageWidth().toString()); 467 state.push(this.pageHeight().toString()); 468 state.push(this.marginLeft().toString()); 469 state.push(this.marginRight().toString()); 470 state.push(this.marginTop().toString()); 471 state.push(this.marginBottom().toString()); 472 for (let page of pages) { 473 let latLng = page.getLatLng().wrap(); 474 state.push(latLng.lat.toFixed(5)); 475 state.push(latLng.lng.toFixed(5)); 476 state.push(page._rotated ? '1' : '0'); 477 } 478 let flags = 479 (this.magneticMeridiansOn() ? 1 : 0) | 480 (this.gridOn() ? 2 : 0); 481 state.push(flags.toString()); 482 } 483 return state; 484 }, 485 486 unserializeState: function(state) { 487 if (!state || !state.length) { 488 return false; 489 } 490 this.removePages(); 491 state = [...state]; 492 this.scale(state.shift()); 493 this.resolution(state.shift()); 494 this.zoomLevel(state.shift()); 495 this.pageWidth(state.shift()); 496 this.pageHeight(state.shift()); 497 this.marginLeft(state.shift()); 498 this.marginRight(state.shift()); 499 this.marginTop(state.shift()); 500 this.marginBottom(state.shift()); 501 let lat, lng, rotated; 502 while (state.length >= 3) { 503 lat = parseFloat(state.shift()); 504 lng = parseFloat(state.shift()); 505 rotated = parseInt(state.shift(), 10); 506 if (isNaN(lat) || isNaN(lng) || lat < -85 || lat > 85 || lng < -180 || lng > 180) { 507 break; 508 } 509 this.addPage(Boolean(rotated), L.latLng(lat, lng)); 510 } 511 if (state.length) { 512 const flags = parseInt(state.shift(), 10); 513 if (flags >= 0 && flags <= 3) { 514 this.magneticMeridiansOn(Boolean(flags & 1)); 515 this.gridOn(Boolean(flags & 2)); 516 } 517 } 518 return true; 519 }, 520 521 getFileName: function({renderedLayers, scale, width, height, extension}) { 522 let fileName = ''; 523 524 let opaqueLayer; 525 const transparentOverlayLayers = []; 526 527 renderedLayers.forEach((layer) => { 528 const { 529 options: { 530 isOverlay, 531 isOverlayTransparent, 532 shortName 533 } 534 } = layer; 535 536 if (!shortName) { 537 return; 538 } 539 540 if (isOverlay) { 541 if (isOverlayTransparent) { 542 transparentOverlayLayers.push(layer); 543 } else { 544 opaqueLayer = layer; 545 } 546 } else if (!opaqueLayer) { 547 opaqueLayer = layer; 548 } 549 }); 550 551 function appendLayerShortName(layer) { 552 fileName += `${layer.options.shortName}_`; 553 } 554 if (opaqueLayer) { 555 appendLayerShortName(opaqueLayer); 556 } 557 transparentOverlayLayers.forEach(appendLayerShortName); 558 559 fileName += `${scale}m`; 560 561 const currentPageSize = this.pageSizes.find( 562 (pageSize) => width === pageSize.width && height === pageSize.height 563 ); 564 565 if (currentPageSize) { 566 fileName += `_${currentPageSize.name}`; 567 } 568 569 return `${fileName}.${extension}`; 570 } 571 } 572 );