Mercurial > gemma
view client/src/components/Pdftool.vue @ 5175:6038f782e51d detectvisiblesoundings
client: Improve detecting of bottleneck for pdf-generation
* check the existence of bottleneck on the current viewport when it has no SR to include the its name in the exported pdf filename
author | Fadi Abbud <fadi.abbud@intevation.de> |
---|---|
date | Tue, 21 Apr 2020 16:30:28 +0200 |
parents | b73e8cc494f6 |
children | 528fa710650b |
line wrap: on
line source
<template> <div :class="[ 'box ui-element rounded bg-white text-nowrap', { expanded: showPdfTool } ]" > <div style="width: 17rem"> <UIBoxHeader icon="file-pdf" :title="generatePdfLable" :closeCallback="close" /> <div class="box-body"> <select @change="applyTemplateToForm" v-model="form.template" class="form-control d-block mb-2 w-100 font-weight-bold" > <option v-for="template in templates" :value="template" :key="template.name" > {{ template.name }} </option> </select> <select v-model="form.format" class="form-control form-control-sm d-block mb-2 w-100" > <option value="landscape"><translate>landscape</translate></option> <option value="portrait"><translate>portrait</translate></option> </select> <div class="d-flex"> <div class="flex-fill mr-2"> <select v-model="form.resolution" class="form-control form-control-sm mb-2 d-block w-100" > <option value="80"><translate>80 dpi</translate></option> <option value="120"><translate>120 dpi</translate></option> <option value="200"><translate>200 dpi</translate></option> </select> </div> <div class="flex-fill ml-2"> <select v-model="form.paperSize" class="form-control form-control-sm mb-2 d-block w-100" > <option value="a4"><translate>A4</translate></option> <option value="a3"><translate>A3</translate></option> </select> </div> </div> <div class="d-flex flex-fill-row"> <small class="my-auto text-muted"> <translate>Scale to 1:</translate> </small> <input class="form-control form-control-sm w-100 ml-2" placeholder="10000" v-model.number="form.scale" type="number" /> </div> <button @click="download" :key="'downloadBtn'" type="button" v-if="readyToGenerate" class="btn btn-sm btn-info d-block w-100 mt-2" > <translate>Generate PDF</translate> </button> <button @click="cancel" :key="'cancelBtn'" type="button" v-else class="btn btn-sm btn-danger d-block w-100 mt-2" > <font-awesome-icon class="mr-1" icon="spinner" spin /> <translate>Cancel</translate> </button> </div> </div> </div> </template> <style lang="scss" scoped> input, select { font-size: 0.8em; } </style> <script> /* This is Free Software under GNU Affero General Public License v >= 3.0 * without warranty, see README.md and license for details. * * SPDX-License-Identifier: AGPL-3.0-or-later * License-Filename: LICENSES/AGPL-3.0.txt * * Copyright (C) 2018, 2019 by via donau * – Österreichische Wasserstraßen-Gesellschaft mbH * Software engineering by Intevation GmbH * * Author(s): * * Markus Kottländer <markus.kottlaender@intevation.de> * * Bernhard E. Reiter <bernhard@intevation.de> * * Fadi Abbud <fadi.abbud@intevation.de> */ import { mapState, mapGetters } from "vuex"; import jsPDF from "jspdf-yworks"; import "@/lib/font-linbiolinum"; import { getPointResolution } from "ol/proj"; import { HTTP } from "@/lib/http"; import { displayError } from "@/lib/errors"; import { pdfgen, templateLoader } from "@/lib/mixins"; import sanitize from "sanitize-filename"; import { WFS } from "ol/format"; import { equalTo as equalToFilter } from "ol/format/filter"; import { intersects } from "ol/extent"; const paperSizes = { // in millimeter, landscape [width, height] a3: [420, 297], a4: [297, 210] }; const DEFAULT_TEMPLATE = "Default"; export default { mixins: [pdfgen, templateLoader], name: "pdftool", data() { return { form: { template: null, format: "landscape", paperSize: "a4", downloadType: "download", resolution: "80", scale: null }, templates: [ { name: DEFAULT_TEMPLATE, properties: { format: "landscape", paperSize: "a4", resolution: "80" }, elements: [ { type: "scalebar", position: "bottomright", offset: { x: 1, y: 1 } }, { type: "textbox", position: "bottomleft", offset: { x: 1, y: 1 }, fontSize: 8, text: this.$gettext("Generated by") + " " + "{user}, {date}" }, { type: "northarrow", position: "topleft", offset: { x: 6, y: 4 }, size: 2 }, { type: "bottleneck", position: "topright", offset: { x: 2, y: 2 } }, { type: "legend", position: "topright", offset: { x: 2, y: 25 } } ] } ], templateData: null, pdf: { doc: null, width: null, height: null }, logoImageForPDF: null, // a HTMLImageElement instance readyToGenerate: true, // if the user is allowed to press the button rendercompleteListener: null, mapSize: null, resolution: null }; }, computed: { ...mapState("application", ["showPdfTool", "logoForPDF"]), ...mapState("bottlenecks", [ "selectedBottleneck", "selectedSurvey", "bottleneckForPrint" ]), ...mapState("map", ["isolinesLegendImgDataURL"]), ...mapGetters("map", ["openLayersMap"]), generatePdfLable() { return this.$gettext("Generate PDF"); } }, methods: { close() { this.$store.commit("application/showPdfTool", false); }, // When a template is chosen from the dropdown, its propoerties are // applied to the rest of the form. applyTemplateToForm() { if (this.form.template && this.form.template.name !== DEFAULT_TEMPLATE) { this.loadTemplates( `/templates/${this.form.template.type}/${this.form.template.name}` ) .then(response => { this.prepareImages(response.data.template_data.elements).then( values => { values.forEach(v => { response.data.template_data.elements[v.index].url = v.url; }); this.setTemplate(response.data.template_data); } ); }) .catch(error => { let message = "Backend not reachable"; if (error.response) { const { status, data } = error.response; message = `${status}: ${data.message || data}`; } displayError({ title: this.$gettext("Backend Error"), message: message }); }); } else { this.setTemplate(this.templates[0]); } }, setTemplate(template) { this.templateData = template; this.form.format = this.templateData.properties.format; this.form.paperSize = this.templateData.properties.paperSize; this.form.resolution = this.templateData.properties.resolution; }, numberSoundingsVisible() { return new Promise((resolve, reject) => { const map = this.openLayersMap(); const currentExtent = map.getView().calculateExtent(map.getSize()); const params = { srsName: "EPSG:3857", featureNS: "gemma", featurePrefix: "gemma", featureTypes: ["sounding_results_areas_geoserver"], outputFormat: "application/json", resultType: "hits", bbox: currentExtent, geometryName: "areas" }; if (this.selectedSurvey) { params["filter"] = equalToFilter( "bottleneck_id", this.selectedSurvey.bottleneck_id ); } const getSoundingResultFeatures = new WFS().writeGetFeature(params); HTTP.post( "/internal/wfs", new XMLSerializer().serializeToString(getSoundingResultFeatures), { headers: { "X-Gemma-Auth": localStorage.getItem("token"), "Content-type": "text/xml; charset=UTF-8" } } ) .then(response => { resolve(response); }) .catch(error => { reject(error); }); }); }, download() { this.numberSoundingsVisible() .then(response => { const parser = new DOMParser(); const responseXML = parser.parseFromString(response.data, "text/xml"); const totalNumber = responseXML .getElementsByTagName("wfs:FeatureCollection")[0] .getAttribute("numberOfFeatures"); this.generatePDF(totalNumber > 0); }) .catch(error => { console.log(error); let message = "Backend not reachable"; if (error.response) { const { status, data } = error.response; message = `${status}: ${data.message || data}`; } displayError({ title: this.$gettext("Backend Error"), message: message }); }); }, generatePDF(soundingsVisible) { /** * In order to generate the image with the appropriate resolution * we have to temporaily scale the visible part of the map. * The newly rendered canvas is converted to Base64 DataURL. * After that is done, the resolution is resetted to its previous state. * * calculateExtent() and fit() do not give the desired result * when the view is rotated so we replace them completely by setting resolution * * Details: https://gis.stackexchange.com/questions/328933/openlayers-generating-clientside-pdfs * */ this.readyToGenerate = false; if (this.form.format !== "portrait") { this.pdf.width = paperSizes[this.form.paperSize][0]; this.pdf.height = paperSizes[this.form.paperSize][1]; } else { this.pdf.width = paperSizes[this.form.paperSize][1]; this.pdf.height = paperSizes[this.form.paperSize][0]; } // FUTURE: consider margins var pixelsPerMapMillimeter = this.form.resolution / 25.4; var mapSizeForPrint = [ Math.round(this.pdf.width * pixelsPerMapMillimeter), Math.round(this.pdf.height * pixelsPerMapMillimeter) ]; var map = this.openLayersMap(); this.mapSize = map.getSize(); this.resolution = map.getView().getResolution(); this.pdf.doc = new jsPDF(this.form.format, "mm", this.form.paperSize); this.rendercompleteListener = map.once("rendercomplete", event => { let canvas = event.context.canvas; let scaleDenominator = Math.round( 1000 * pixelsPerMapMillimeter * this.getMeterPerPixel( this.openLayersMap() .getView() .getResolution() ) ); var snapshot = canvas.toDataURL("image/jpeg"); this.pdf.doc.addImage( snapshot, "JPEG", 0, 0, this.pdf.width, this.pdf.height ); if (this.templateData) { this.pdf.doc.setFont("linbiolinum", "normal"); let defaultFontSize = 11, defaultRounding = 2, defaultTextColor = "black", defaultBgColor = "white", defaultPadding = 3, defaultOffset = { x: 0, y: 0 }, defaultBorderColor = "white"; this.templateData.elements.forEach(e => { switch (e.type) { case "text": { this.addText( e.position, e.offset || defaultOffset, e.width, e.fontSize || defaultFontSize, e.color || defaultTextColor, e.text ); break; } case "box": { this.addBox( e.position, e.offset || defaultOffset, e.width, e.height, e.rounding === 0 || e.rounding ? e.rounding : defaultRounding, e.color || defaultBgColor, e.brcolor || defaultBorderColor ); break; } case "textbox": { this.addTextBox( e.position, e.offset || defaultOffset, e.width, e.height, e.rounding === 0 || e.rounding ? e.rounding : defaultRounding, e.padding || defaultPadding, e.fontSize || defaultFontSize, e.color || defaultTextColor, e.background || defaultBgColor, e.text, e.brcolor || defaultBorderColor ); break; } case "image": { this.addImage( e.url, e.format, e.position, e.offset || defaultOffset, e.width, e.height ); break; } case "bottleneck": { this.addBottleneckInfo( e.position, e.offset || defaultOffset, e.rounding === 0 || e.rounding ? e.rounding : defaultRounding, e.color || defaultTextColor, e.brcolor || defaultBorderColor, soundingsVisible ); break; } case "legend": { this.addLegend( e.position, e.offset || defaultOffset, e.rounding === 0 || e.rounding ? e.rounding : defaultRounding, e.brcolor || defaultBorderColor, soundingsVisible ); break; } case "scalebar": { this.addScaleBar( scaleDenominator, e.position, e.offset || defaultOffset, e.rounding === 0 || e.rounding ? e.rounding : defaultRounding, e.brcolor || defaultBorderColor ); break; } case "scale": { this.addScale( scaleDenominator, e.position, e.width, e.offset || defaultOffset, e.fontSize || defaultFontSize, e.color || defaultTextColor ); break; } case "northarrow": { this.addNorthArrow( e.position, e.offset || defaultOffset, e.size ); break; } } }); // Check if the bottlenck in the current view Extent const isBottlenckVisible = () => { const currentExtent = map.getView().calculateExtent(map.getSize()); const btnExtent = map .getLayer("BOTTLENECKS") .getSource() .getFeatures() .find(f => f.get("objnam") === this.bottleneckForPrint) .getGeometry() .getExtent(); return intersects(currentExtent, btnExtent); }; let filename = "map"; if ( this.bottleneckForPrint && (soundingsVisible || isBottlenckVisible()) ) { filename = `BN-${sanitize(this.bottleneckForPrint).replace( / /g, "-" )}`; if (this.selectedSurvey) { filename += "-sr" + this.selectedSurvey.date_info.replace(/-/g, ""); } } this.pdf.doc.save(`${filename}-${this.dateForPDF()}.pdf`); } map.setSize(this.mapSize); map.getView().setResolution(this.resolution); this.readyToGenerate = true; }); const size = map.getSize(); const [width, height] = mapSizeForPrint; map.setSize(mapSizeForPrint); const scaling = Math.min(width / size[0], height / size[1]); map .getView() .setResolution( this.form.scale ? this.getResolutionFromScale() : this.resolution / scaling ); }, getResolutionFromScale() { const scaling = Math.round(this.form.scale / 1000); return scaling / this.getMeterPerPixel(this.form.resolution / 25.4); }, getMeterPerPixel(f) { var map = this.openLayersMap(); let view = map.getView(); let proj = view.getProjection(); return ( getPointResolution(proj, f, view.getCenter()) * proj.getMetersPerUnit() ); }, cancel() { try { this.openLayersMap().un( this.rendercompleteListener.type, this.rendercompleteListener.listener ); this.openLayersMap().setSize(this.mapSize); this.openLayersMap() .getView() .fit(this.resolution, { size: this.mapSize }); } finally { this.readyToGenerate = true; } }, // add the used map scale and papersize addScale(scaleDenominator, position, width, offset, fontSize, color) { //TODO: check the correctence of the scalnominator value here. let str = this.$gettext("Scale") + " 1 : " + scaleDenominator + " " + "(DIN" + " " + this.form.paperSize.toUpperCase() + ")"; this.addText(position, offset, width, fontSize, color, str); }, addScaleBar(scaleDenominator, position, offset, rounding, brcolor) { // scaleDenominator is the x in 1:x of the map scale // hardcode maximal width for now let maxWidth = 80; // in mm // reduce width until we'll find a nice number for printing // strategy: // 1. check which unit prefix we shall use to get [10:10000[ // 2. using a mapping for the leading digit to get [1:10[ // 3. select a smaller number which is nicely dividable // 4. scale up again to get length in paper mm and to be shown // from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/log10#Polyfill let log10 = Math.log10 || // more precise, but unsupported by IE function(x) { return Math.log(x) * Math.LOG10E; }; let maxLength = maxWidth * scaleDenominator; let unit = "mm"; let unitConversionFactor = 1; if (maxLength >= 1e7) { // >= 10 km unit = "km"; unitConversionFactor = 1e6; } else if (maxLength >= 1e4) { // >= 10 m unit = "m"; unitConversionFactor = 1e3; } maxLength /= unitConversionFactor; let unroundedLength = maxLength; let numberOfDigits = Math.floor(log10(unroundedLength)); let factor = Math.pow(10, numberOfDigits); let mapped = unroundedLength / factor; var length = Math.floor(maxLength); // just to have an upper limit // manually only use numbers that are very nice to devide by 4 // note that this is taken into account for rounding later if (mapped > 8) { length = 8 * factor; } else if (mapped > 4) { length = 4 * factor; } else if (mapped > 2) { length = 2 * factor; } else { length = factor; } let size = (length * unitConversionFactor) / scaleDenominator / 4; let fullSize = size * 4; // x/y defaults to offset for topleft corner (normal x/y coordinates) let x = offset.x; let y = offset.y; // if position is on the right, x needs to be calculate with pdf width and // the size of the element if (["topright", "bottomright"].indexOf(position) !== -1) { x = this.pdf.width - offset.x - fullSize - 8; } if (["bottomright", "bottomleft"].indexOf(position) !== -1) { y = this.pdf.height - offset.y - 10; } // to give the outer white box 4mm padding let scaleBarX = x + 4; let scaleBarY = y + 5; // 5 because above the scalebar will be the numbers // draw outer white box this.addRoundedBox(x, y, fullSize + 8, 10, "white", rounding, brcolor); // draw first part of scalebar this.pdf.doc.setDrawColor(0, 0, 0); this.pdf.doc.setFillColor(0, 0, 0); this.pdf.doc.rect(scaleBarX, scaleBarY, size, 1, "FD"); // draw second part of scalebar this.pdf.doc.setDrawColor(0, 0, 0); this.pdf.doc.setFillColor(255, 255, 255); this.pdf.doc.rect(scaleBarX + size, scaleBarY, size, 1, "FD"); // draw third part of scalebar this.pdf.doc.setDrawColor(0, 0, 0); this.pdf.doc.setFillColor(0, 0, 0); this.pdf.doc.rect(scaleBarX + size * 2, scaleBarY, size * 2, 1, "FD"); // draw numeric labels above scalebar this.pdf.doc.setTextColor("black"); this.pdf.doc.setFontSize(6); this.pdf.doc.text(scaleBarX, scaleBarY - 1, "0"); // /4 and could give 2.5. We still round, because of floating point arith this.pdf.doc.text( scaleBarX + size - 1, scaleBarY - 1, (Math.round((length * 10) / 4) / 10).toString() ); this.pdf.doc.text( scaleBarX + size * 2 - 2, scaleBarY - 1, Math.round(length / 2).toString() ); this.pdf.doc.text( scaleBarX + size * 4 - 4, scaleBarY - 1, Math.round(length).toString() + " " + unit ); }, addNorthArrow(position, offset, size) { // TODO: fix positioning // x/y defaults to offset for topleft corner (normal x/y coordinates) let x1 = offset.x; let y1 = offset.y; // if position is on the right, x needs to be calculate with pdf width and // the size of the element if (["topright", "bottomright"].indexOf(position) !== -1) { x1 = this.pdf.width - offset.x - size; } if (["bottomright", "bottomleft"].indexOf(position) !== -1) { y1 = this.pdf.height - offset.y - size; } var y2 = y1 + size * 3; var x3 = x1 - size * 2; var y3 = y1 + size * 5; var x4 = x1 + size * 2; // white triangle this.pdf.doc.setFillColor(255, 255, 255); this.pdf.doc.setDrawColor(255, 255, 255); this.pdf.doc.triangle( x3 - 0.8, y3 + 1.2, x1, y1 - 1.2, x1, y2 + 0.6, "F" ); this.pdf.doc.triangle( x1, y1 - 1.2, x1, y2 + 0.6, x4 + 0.8, y3 + 1.2, "F" ); // north arrow this.pdf.doc.setDrawColor(0, 0, 0); this.pdf.doc.setFillColor(255, 255, 255); this.pdf.doc.triangle(x3, y3, x1 - 0.1, y1 + 0.2, x1 - 0.1, y2, "FD"); this.pdf.doc.setFillColor(0, 0, 0); this.pdf.doc.triangle(x1 + 0.1, y1 + 0.2, x1 + 0.1, y2, x4, y3, "FD"); this.pdf.doc.setFontSize(size * 3.1); this.pdf.doc.setTextColor(255, 255, 255); this.pdf.doc.setFontStyle("bold"); this.pdf.doc.text(size < 3 ? x1 - 0.5 : x1 - 1.3, y3 + 1, "N"); this.pdf.doc.setFontSize(size * 3); this.pdf.doc.setTextColor(0, 0, 0); this.pdf.doc.setFontStyle("normal"); this.pdf.doc.text(size < 3 ? x1 - 0.5 : x1 - 1.3, y3 + 1, "N"); }, addLegend(position, offset, rounding, brcolor, soundingsVisible) { if ( soundingsVisible && this.bottleneckForPrint && this.selectedSurvey && this.openLayersMap() .getLayer("BOTTLENECKISOLINE") .getVisible() ) { // transforming into an HTMLImageElement only to find out // the width x height of the legend image // FUTURE: find a better way to get the width and height let legendImage = new Image(); legendImage.src = this.isolinesLegendImgDataURL; let aspectRatio = legendImage.width / legendImage.height; let width = 54; let height = width / aspectRatio; let padding = 2; // x/y defaults to offset for topleft corner (normal x/y coordinates) let x = offset.x; let y = offset.y; // if position is on the right, x needs to be calculate with pdf width and // the size of the element if (["topright", "bottomright"].indexOf(position) !== -1) { x = this.pdf.width - offset.x - width; } if (["bottomright", "bottomleft"].indexOf(position) !== -1) { y = this.pdf.height - offset.y - height; } this.addRoundedBox(x, y, width, height, "white", rounding, brcolor); this.pdf.doc.addImage( legendImage, x + padding, y + padding, width - 2 * padding, height - 2 * padding ); } }, addBottleneckInfo( position, offset, rounding, color, brcolor, soundingsVisible ) { if ( soundingsVisible && this.bottleneckForPrint && this.selectedSurvey && this.openLayersMap() .getLayer("BOTTLENECKISOLINE") .getVisible() ) { let survey = this.selectedSurvey; // determine text dimensions // this is a little bit cumbersome but we need to separate width // calculations and writing this.pdf.doc.setFontSize(10); this.pdf.doc.setTextColor(color); let textOptions = { baseline: "hanging" }; let str1_1 = this.$gettext("Bottleneck") + ": "; let str1_2 = this.selectedBottleneck; let str2_1 = this.$gettext("Survey date") + ": "; let str2_2 = survey.date_info; let str3_1 = this.$gettext("Ref gauge") + ": "; let str3_2 = survey.gauge_objname; let str4_1 = this.$gettext("Depth relativ to") + ": "; let str4_2 = survey.depth_reference + " = " + (survey.hasOwnProperty("waterlevel_value") ? survey.waterlevel_value + " cm" : "?"); this.pdf.doc.setFontStyle("italic"); let w1_1 = this.pdf.doc.getTextWidth(str1_1); this.pdf.doc.setFontStyle("bold"); let w1_2 = this.pdf.doc.getTextWidth(str1_2); this.pdf.doc.setFontStyle("italic"); let w2_1 = this.pdf.doc.getTextWidth(str2_1); this.pdf.doc.setFontStyle("normal"); let w2_2 = this.pdf.doc.getTextWidth(str2_2); this.pdf.doc.setFontStyle("italic"); let w3_1 = this.pdf.doc.getTextWidth(str3_1); this.pdf.doc.setFontStyle("normal"); let w3_2 = this.pdf.doc.getTextWidth(str3_2); this.pdf.doc.setFontStyle("italic"); let w4_1 = this.pdf.doc.getTextWidth(str4_1); this.pdf.doc.setFontStyle("normal"); let w4_2 = this.pdf.doc.getTextWidth(str4_2); let height = 21; let padding = 3; let width = Math.max(w1_1 + w1_2, w2_1 + w2_2, w3_1 + w3_2, w4_1 + w4_2) + 2 * padding; // x/y defaults to offset for topleft corner (normal x/y coordinates) let x = offset.x; let y = offset.y; // if position is on the right, x needs to be calculate with pdf width and // the size of the element if (["topright", "bottomright"].indexOf(position) !== -1) { x = this.pdf.width - offset.x - width; } if (["bottomright", "bottomleft"].indexOf(position) !== -1) { y = this.pdf.height - offset.y - height; } // white background box this.addRoundedBox(x, y, width, height, "white", rounding, brcolor); // bottleneck this.pdf.doc.setFontStyle("italic"); this.pdf.doc.text(x + padding, y + padding + 2, str1_1, textOptions); this.pdf.doc.setFontStyle("bold"); this.pdf.doc.text( x + padding + w1_1, y + padding + 2, str1_2, textOptions ); // survey date this.pdf.doc.setFontStyle("italic"); this.pdf.doc.text(x + padding, y + padding + 6, str2_1, textOptions); this.pdf.doc.setFontStyle("normal"); this.pdf.doc.text( x + padding + w2_1, y + padding + 6, str2_2, textOptions ); // ref gauge this.pdf.doc.setFontStyle("italic"); this.pdf.doc.text(x + padding, y + padding + 10, str3_1, textOptions); this.pdf.doc.setFontStyle("normal"); this.pdf.doc.text( x + padding + w3_1, y + padding + 10, str3_2, textOptions ); // depth relative to this.pdf.doc.setFontStyle("italic"); this.pdf.doc.text(x + padding, y + padding + 14, str4_1, textOptions); this.pdf.doc.setFontStyle("normal"); this.pdf.doc.text( x + padding + w4_1, y + padding + 14, str4_2, textOptions ); } } }, mounted() { HTTP.get("/templates/map", { headers: { "X-Gemma-Auth": localStorage.getItem("token"), "Content-type": "text/xml; charset=UTF-8" } }) .then(response => { if (response.data.length) { this.templates = [...this.templates, ...response.data]; this.form.template = this.templates[1]; this.applyTemplateToForm(); } else { this.form.template = this.templates[0]; this.templateData = this.form.template; } }) .catch(error => { let message = "Backend not reachable"; if (error.response) { const { status, data } = error.response; message = `${status}: ${data.message || data}`; } displayError({ title: this.$gettext("Backend Error"), message: message }); }); } }; </script>