Mercurial > gemma
view client/src/components/fairway/AvailableFairwayDepthLNWL.vue @ 5521:3cfbc5769e8b
Make invalid output of ISRSrange_area() less likely
Since ST_Transform() might produce an invalid polygon, add an extra
ST_MakeValid().
author | Tom Gottfried <tom@intevation.de> |
---|---|
date | Thu, 21 Oct 2021 20:20:30 +0200 |
parents | 89b6978afc58 |
children |
line wrap: on
line source
<template> <div class="d-flex flex-column flex-fill"> <UIBoxHeader icon="chart-area" :title="title" :closeCallback="close" /> <UISpinnerOverlay v-if="loading" /> <div class="d-flex flex-fill"> <DiagramLegend> <div v-for="(entry, index) in legendLNWL" :key="index" class="legend"> <span :style=" `${legendStyle( index )}; border-radius: 0.25rem; width: 40px; height: 20px;` " ></span> {{ entry }} </div> <div> <select @change="applyChange" v-model="form.template" class="form-control d-block custom-select-sm w-100 mt-2" > <option v-for="template in templates" :value="template" :key="template.name" > {{ template.name }} </option> </select> <button @click="downloadPDF" type="button" class="btn btn-sm btn-info d-block w-100 mt-2" > <translate>Export to PDF</translate> </button> <a :href="dataLink" :download="`${fileName}.csv`" class="mt-2 btn btn-sm btn-info w-100" ><translate>Download CSV</translate></a > <a @click="downloadImage('AFDvsLNWLpng', title)" id="AFDvsLNWLpng" class=" btn btn-sm btn-info text-white d-block w-100 mt-2" :download="`${fileName}.png`" > <translate>Download Image</translate> </a> </div> <div class="btn-group-toggle w-100 mt-2"> <label class="btn btn-outline-secondary btn-sm" :class="{ active: showNumbers }" ><input type="checkbox" v-model="showNumbers" autocomplete="off" /><translate>Numbers</translate> </label> </div> </DiagramLegend> <div ref="diagramContainer" :id="containerId" class="diagram-container flex-fill" ></div> </div> </div> </template> <style></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): * * Thomas Junk <thomas.junk@intevation.de> * * Markus Kottländer <markus.kottlaender@intevation.de> * * Fadi Abbud <fadi.abbud@intevation.de> * * Bernhard Reiter <bernhard.reiter@intevation.de> */ import * as d3 from "d3"; import app from "@/main"; import debounce from "debounce"; import { mapState } from "vuex"; import filters from "@/lib/filters.js"; import { diagram, pdfgen, templateLoader } from "@/lib/mixins"; import { HTTP } from "@/lib/http"; import { displayError } from "@/lib/errors"; import { defaultDiagramTemplate } from "@/lib/DefaultDiagramTemplate"; import { localeDateString } from "@/lib/datelocalization"; export default { mixins: [diagram, pdfgen, templateLoader], components: { DiagramLegend: () => import("@/components/DiagramLegend") }, data() { return { frequencyD: null, selectedFairwayAvailabilityFeatureD: null, fromDate: null, toDate: null, depthlimit1D: null, depthlimit2D: null, widthlimit1D: null, widthlimit2D: null, containerId: "availablefairwaydepthlnwl-diagram-container", resizeListenerFunction: null, loading: false, scalePaddingLeft: 60, scalePaddingRight: 0, paddingTop: 25, pdf: { doc: null, width: null, height: null }, form: { template: null }, templateData: null, templates: [], defaultTemplate: defaultDiagramTemplate, showNumbers: false }; }, created() { this.resizeListenerFunction = debounce(this.drawDiagram, 100); window.addEventListener("resize", this.resizeListenerFunction); }, destroyed() { window.removeEventListener("resize", this.resizeListenerFunction); }, mounted() { // Nasty but necessary if we don't want to use the updated hook to re-draw // the diagram because this would re-draw it also for irrelevant reasons. // In this case we need to wait for the child component (DiagramLegend) to // render. According to the docs (https://vuejs.org/v2/api/#mounted) this // should be possible with $nextTick() but it doesn't work because it does // not guarantee that the DOM is not only updated but also re-painted on the // screen. setTimeout(this.drawDiagram, 150); this.initDiagramValues(); this.templates[0] = this.defaultTemplate; this.form.template = this.templates[0]; this.templateData = this.form.template; HTTP.get("/templates/diagram", { headers: { "X-Gemma-Auth": localStorage.getItem("token"), "Content-type": "text/xml; charset=UTF-8" } }) .then(response => { if (response.data.length) { this.templates = response.data; this.form.template = this.templates[0]; this.templates[this.templates.length] = this.defaultTemplate; this.applyChange(); } }) .catch(e => { const { status, data } = e.response; displayError({ title: this.$gettext("Backend Error"), message: `${status}: ${data.message || data}` }); }); }, computed: { ...mapState("fairwayavailability", [ "selectedFairwayAvailabilityFeature", "fwLNWLData", "from", "to", "frequency", "csv", "depthlimit1", "depthlimit2", "widthlimit1", "widthlimit2" ]), legendLNWL() { const d = [this.depthlimit1D, this.depthlimit2D].sort((a, b) => a - b); const w = [this.widthlimit1D, this.widthlimit2D].sort((a, b) => a - b); const lowerBound = [d[0] / 100, w[0]].filter(x => x).join(", "); const upperBound = [d[1] / 100, w[1]].filter(x => x).join(", "); let result; if ( this.depthlimit1D !== this.depthlimit2D || this.widthlimit1 !== this.widthlimit2 ) { result = [ `> LDC`, `< ${lowerBound} [m]`, `< ${upperBound} [m]`, `>= ${upperBound} [m]` ]; } else { result = [`> LDC`, `< ${upperBound} [m]`, `>= ${upperBound} [m]`]; } return result; }, dataLink() { return `data:text/csv;charset=utf-8, ${encodeURIComponent(this.csv)}`; }, fileName() { if (!this.frequencyD) return; return this.downloadFilename( this.$gettext("FairwayAvailabilityVsLNWL"), this.featureName ); }, availability() { return this.plainAvailability; }, title() { if (!this.frequencyD) return; return `${this.$gettext("Available Fairway Depth vs LNWL:")} ${ this.featureName } (${filters.surveyDate(this.fromDate)} - ${filters.surveyDate( this.toDate )}) ${this.$gettext(this.frequencyD)}`; }, featureName() { if (this.selectedFairwayAvailabilityFeatureD == null) return ""; return this.selectedFairwayAvailabilityFeatureD.properties.name; } }, methods: { addLegendToCanvas(ctx, { width, height }) { let x = width / 20, y = height - 35; ctx.font = "14px sans-serif"; ctx.textAlign = "start"; if (this.legendLNWL[3]) { ctx.beginPath(); ctx.fillStyle = this.$options.LWNLCOLORS.LDC; ctx.strokeStyle = this.$options.LWNLCOLORS.LDC; ctx.rect(x, y, 35, 20); ctx.fill(); ctx.stroke(); ctx.fillStyle = "black"; ctx.fillText(this.legendLNWL[0], x + 40, y + 13); ctx.closePath(); ctx.beginPath(); ctx.fillStyle = this.$options.AFDCOLORS[2]; ctx.strokeStyle = this.$options.AFDCOLORS[2]; ctx.rect(x, (y += 25), 35, 20); ctx.fill(); ctx.stroke(); ctx.fillStyle = "black"; ctx.fillText(this.legendLNWL[1], x + 40, y + 13); ctx.closePath(); ctx.beginPath(); ctx.fillStyle = this.$options.AFDCOLORS[1]; ctx.strokeStyle = this.$options.AFDCOLORS[1]; ctx.rect(x, (y += 25), 35, 20); ctx.fill(); ctx.stroke(); ctx.fillStyle = "black"; ctx.fillText(this.legendLNWL[2], x + 40, y + 13); ctx.closePath(); ctx.beginPath(); ctx.fillStyle = this.$options.AFDCOLORS[0]; ctx.strokeStyle = this.$options.AFDCOLORS[0]; ctx.rect(x, (y += 25), 35, 20); ctx.fill(); ctx.stroke(); ctx.fillStyle = "black"; ctx.fillText(this.legendLNWL[2], x + 40, y + 13); ctx.closePath(); } else { ctx.beginPath(); ctx.fillStyle = this.$options.LWNLCOLORS.LDC; ctx.strokeStyle = this.$options.LWNLCOLORS.LDC; ctx.rect(x, y, 35, 20); ctx.fill(); ctx.stroke(); ctx.fillStyle = "black"; ctx.fillText(this.legendLNWL[0], x + 40, y + 13); ctx.closePath(); ctx.beginPath(); ctx.fillStyle = this.$options.AFDCOLORS[2]; ctx.strokeStyle = this.$options.AFDCOLORS[2]; ctx.rect(x, (y += 25), 35, 20); ctx.fill(); ctx.stroke(); ctx.fillStyle = "black"; ctx.fillText(this.legendLNWL[1], x + 40, y + 13); ctx.closePath(); ctx.beginPath(); ctx.fillStyle = this.$options.AFDCOLORS[0]; ctx.strokeStyle = this.$options.AFDCOLORS[0]; ctx.rect(x, (y += 25), 35, 20); ctx.fill(); ctx.stroke(); ctx.fillStyle = "black"; ctx.fillText(this.legendLNWL[2], x + 40, y + 13); ctx.closePath(); } }, initDiagramValues() { this.selectedFairwayAvailabilityFeatureD = this.selectedFairwayAvailabilityFeature; this.fromDate = this.from; this.toDate = this.to; this.depthlimit1D = this.depthlimit1; this.depthlimit2D = this.depthlimit2; this.widthlimit1D = this.widthlimit1; this.widthlimit2D = this.widthlimit2; this.frequencyD = this.frequency; }, legendStyle(index) { let style; if ( this.depthlimit1 !== this.depthlimit2 || this.widthlimit1 !== this.widthlimit2 ) { style = { 0: `background-color: ${this.$options.LWNLCOLORS.LDC};`, 1: `background-color: ${this.$options.AFDCOLORS[2]};`, 2: `background-color: ${this.$options.AFDCOLORS[1]};`, 3: `background-color: ${this.$options.AFDCOLORS[0]};` }; } else { style = { 0: `background-color: ${this.$options.LWNLCOLORS.LDC};`, 1: `background-color: ${this.$options.AFDCOLORS[2]};`, 2: `background-color: ${this.$options.AFDCOLORS[0]};` }; } return style[index]; }, applyChange() { if (this.form.template.hasOwnProperty("properties")) { this.templateData = this.defaultTemplate; return; } if (this.form.template) { this.loadTemplates("/templates/diagram/" + 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.templateData = response.data.template_data; } ); }) .catch(e => { const { status, data } = e.response; displayError({ title: this.$gettext("Backend Error"), message: `${status}: ${data.message || data}` }); }); } }, downloadPDF() { let title = `${this.$gettext("Available Fairway Depth vs LNWL:")} ${ this.featureName }`; this.generatePDF({ templateData: this.templateData, diagramTitle: title }); this.pdf.doc.save( this.downloadFilename( this.$gettext("FairwayAvailabilityVsLNWL"), this.featureName ) + ".pdf" ); }, addDiagramLegend(position, offset, color) { let x = offset.x, y = offset.y; this.pdf.doc.setFontSize(10); let width = (this.pdf.doc.getStringUnitWidth(">= LDC") * 10) / (72 / 25.6) + 15; // 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 - this.getTextHeight(6); } if (this.legendLNWL[3]) { this.pdf.doc.setTextColor(color); this.pdf.doc.setDrawColor(this.$options.LWNLCOLORS.LDC); this.pdf.doc.setFillColor(this.$options.LWNLCOLORS.LDC); this.pdf.doc.roundedRect(x, y, 10, 4, 1.5, 1.5, "FD"); this.pdf.doc.text(this.legendLNWL[0], x + 12, y + 3); this.pdf.doc.setDrawColor(this.$options.AFDCOLORS[2]); this.pdf.doc.setFillColor(this.$options.AFDCOLORS[2]); this.pdf.doc.roundedRect(x, y + 5, 10, 4, 1.5, 1.5, "FD"); this.pdf.doc.text(this.legendLNWL[1], x + 12, y + 8); this.pdf.doc.setDrawColor(this.$options.AFDCOLORS[1]); this.pdf.doc.setFillColor(this.$options.AFDCOLORS[1]); this.pdf.doc.roundedRect(x, y + 10, 10, 4, 1.5, 1.5, "FD"); this.pdf.doc.text(this.legendLNWL[2], x + 12, y + 13); this.pdf.doc.setDrawColor(this.$options.AFDCOLORS[0]); this.pdf.doc.setFillColor(this.$options.AFDCOLORS[0]); this.pdf.doc.roundedRect(x, y + 15, 10, 4, 1.5, 1.5, "FD"); this.pdf.doc.text(this.legendLNWL[3], x + 12, y + 18); } else { this.pdf.doc.setTextColor(color); this.pdf.doc.setDrawColor(this.$options.LWNLCOLORS.LDC); this.pdf.doc.setFillColor(this.$options.LWNLCOLORS.LDC); this.pdf.doc.roundedRect(x, y, 10, 4, 1.5, 1.5, "FD"); this.pdf.doc.text(this.legendLNWL[0], x + 12, y + 3); this.pdf.doc.setDrawColor(this.$options.AFDCOLORS[2]); this.pdf.doc.setFillColor(this.$options.AFDCOLORS[2]); this.pdf.doc.roundedRect(x, y + 5, 10, 4, 1.5, 1.5, "FD"); this.pdf.doc.text(this.legendLNWL[1], x + 12, y + 8); this.pdf.doc.setDrawColor(this.$options.AFDCOLORS[0]); this.pdf.doc.setFillColor(this.$options.AFDCOLORS[0]); this.pdf.doc.roundedRect(x, y + 10, 10, 4, 1.5, 1.5, "FD"); this.pdf.doc.text(this.legendLNWL[2], x + 12, y + 13); } }, close() { this.$store.commit("application/paneSetup", "DEFAULT"); }, getPrintLayout(svgHeight, svgWidth) { return { main: { top: 0, right: Math.floor(svgWidth * 0.025), bottom: Math.floor(svgHeight * 0.17), left: Math.floor(svgWidth * 0.025) } }; }, drawDiagram() { d3.timeFormatDefaultLocale(localeDateString); const elem = document.querySelector("#" + this.containerId); const svgWidth = elem != null ? elem.clientWidth : 0; const svgHeight = elem != null ? elem.clientHeight : 0; const layout = this.getPrintLayout(svgHeight, svgWidth); const dimensions = this.getDimensions({ svgHeight, svgWidth, ...layout }); d3.select(".diagram-container svg").remove(); this.renderTo({ element: ".diagram-container", dimensions }); }, drawTooltip(diagram) { diagram .append("text") .text("") .attr("font-size", "0.8em") .attr("opacity", 0) .attr("id", "tooltip"); }, renderTo({ element, dimensions }) { let diagram = d3 .select(element) .append("svg") .attr("width", "100%") .attr("height", "100%"); diagram = diagram.append("g"); diagram .append("g") .append("rect") .attr("width", "100%") .attr("height", "100%") .attr("fill", "#ffffff"); const yScale = d3 .scaleLinear() .domain([0, 100]) .range([dimensions.mainHeight - 30, 0]); this.drawScaleLabel({ diagram, dimensions }); this.drawScale({ diagram, dimensions, yScale }); this.drawBars({ diagram, yScale, dimensions }); this.drawTooltip(diagram); }, drawBars({ diagram, yScale, dimensions }) { if (this.fwLNWLData) { const widthPerItem = Math.min( (dimensions.width - this.scalePaddingLeft - this.scalePaddingRight) / this.fwLNWLData.length, 180 ); const spaceBetween = widthPerItem * 0.2; const afdWidth = widthPerItem * 0.5; const ldcWidth = widthPerItem * 0.3; this.fwLNWLData.forEach((data, i) => { this.drawLNWL( data, i, diagram, spaceBetween, widthPerItem, ldcWidth, yScale ); this.drawAFD( data, i, diagram, spaceBetween, widthPerItem, ldcWidth, yScale, afdWidth ); this.drawLabel(data.date, i, diagram, widthPerItem, dimensions); }); } }, drawLabel(date, i, diagram, widthPerItem, dimensions) { diagram .append("text") .text(date) .attr("text-anchor", "middle") .attr("font-size", 10) .attr( "transform", `translate(${this.scalePaddingLeft + widthPerItem * i + widthPerItem / 2} ${dimensions.mainHeight + this.paddingTop - 5})` ); }, drawAFD( data, i, diagram, spaceBetween, widthPerItem, ldcWidth, yScale, afdWidth ) { let afd = diagram .append("g") .attr( "transform", `translate(${this.scalePaddingLeft + spaceBetween / 2 + widthPerItem * i + ldcWidth})` ); afd .selectAll("rect") .data([data.above, data.between, data.below]) .enter() .append("rect") .on("mouseover", function() { d3.select(this).attr("opacity", "0.8"); d3.select("#tooltip").attr("opacity", 1); }) .on("mouseout", function() { d3.select(this).attr("opacity", 1); d3.select("#tooltip").attr("opacity", 0); }) .on("mousemove", function(d) { let y = d3.mouse(this)[1]; const dy = document .querySelector(".diagram-container") .getBoundingClientRect().left; d3.select("#tooltip") .text(Math.round(d)) .attr("y", y - 10) .attr("x", d3.event.pageX - dy); //d3.event.pageX gives coordinates relative to SVG //dy gives offset of svg on page }) .attr("height", d => { return yScale(0) - yScale(d); }) .attr("y", (d, i) => { if (i === 0) { return yScale(d); } if (i === 1) { return yScale(data.above + d); } if (i === 2) { return yScale(data.above + data.between + d); } }) .attr("transform", `translate(0 ${this.paddingTop})`) .attr("width", afdWidth) .attr("fill", (d, i) => { return this.$options.AFDCOLORS[i]; }); if (this.showNumbers) { afd .selectAll("text") .data([data.above, data.between, data.below]) .enter() .append("text") .attr("fill", "black") .attr("text-anchor", "middle") .attr("x", ldcWidth / 2) .attr("y", (d, i) => { let h = d; // i == 0 if (i > 0) { h += data.above; } if (i > 1) { h += data.between; } return yScale(h + 0.8) + this.paddingTop; }) .text(d => (d > 0 ? Math.round(d) : "")) .attr("font-size", "8"); } }, drawLNWL(data, i, diagram, spaceBetween, widthPerItem, ldcWidth, yScale) { let lnwl = diagram .append("g") .attr( "transform", `translate(${this.scalePaddingLeft + spaceBetween / 2 + widthPerItem * i})` ); lnwl .append("rect") .datum([data.ldc]) .on("mouseover", function() { d3.select(this).attr("opacity", "0.8"); d3.select("#tooltip").attr("opacity", 1); }) .on("mouseout", function() { d3.select(this).attr("opacity", 1); d3.select("#tooltip").attr("opacity", 0); }) .on("mousemove", function(d) { let y = d3.mouse(this)[1]; const dy = document .querySelector(".diagram-container") .getBoundingClientRect().left; d3.select("#tooltip") .text(Math.round(d[0])) .attr("y", y - 10) .attr("x", d3.event.pageX - dy); //d3.event.pageX gives coordinates relative to SVG //dy gives offset of svg on page }) .attr("height", d => yScale(0) - yScale(d)) .attr("y", d => yScale(d)) .attr("transform", `translate(0 ${this.paddingTop})`) .attr("width", ldcWidth) .attr("fill", () => { return this.$options.LWNLCOLORS.LDC; }); if (this.showNumbers) { // we do not need to bind data or a datum as the forEach in drawBars // already brings us only one datum in data lnwl .append("text") .attr("y", yScale(data.ldc + 0.8) + this.paddingTop) .text(data.ldc) .attr("text-anchor", "left") .attr("fill", "black") .attr("font-size", "8"); } }, drawScaleLabel({ diagram, dimensions }) { diagram .append("text") .text(this.$options.LEGEND) .attr("text-anchor", "middle") .attr("x", 0) .attr("y", 0) .attr("dy", "20") .attr("fill", "black") // translate a few mm to the right to allow for slightly higher letters .attr( "transform", `translate(2, ${(dimensions.mainHeight + this.paddingTop) / 2}), rotate(-90)` ); }, drawScale({ diagram, dimensions, yScale }) { const yAxisLeft = d3 .axisLeft() .tickSizeInner( dimensions.width - this.scalePaddingLeft - this.scalePaddingRight ) .tickSizeOuter(0) .scale(yScale); const yAxisRight = d3 .axisRight() .tickSizeInner( dimensions.width - this.scalePaddingLeft - this.scalePaddingRight ) .tickSizeOuter(0) .scale(yScale); diagram .append("g") .attr( "transform", `translate(${dimensions.width - this.scalePaddingRight} ${ this.paddingTop })` ) .call(yAxisLeft) .selectAll(".tick text") .attr("fill", "black") .attr("font-size", 10) .attr("dy", 3) .attr("dx", -3) .select(function() { return this.parentNode; }) .selectAll(".tick line") .attr("stroke-dasharray", 5) .attr("stroke", "#ccc") .select(function() { return this.parentNode; }) .filter(d => d === 0) .selectAll(".tick line") .attr("stroke-dasharray", "none") .attr("stroke", "#333"); diagram .append("g") .attr( "transform", `translate(${this.scalePaddingLeft} ${this.paddingTop})` ) .call(yAxisRight) .selectAll(".tick text") .attr("fill", "black") .attr("font-size", 10) .attr("dy", 3) .attr("dx", 3) .select(function() { return this.parentNode; }) .selectAll(".tick line") .attr("stroke", "transparent"); diagram.selectAll(".domain").attr("stroke", "black"); } }, watch: { fwLNWLData() { this.initDiagramValues(); this.drawDiagram(); }, showNumbers() { this.drawDiagram(); } }, LEGEND: app.$gettext("Percent"), AFDCOLORS: ["blue", "darksalmon", "hotpink"], LWNLCOLORS: { LDC: "aqua", HDC: "#43FFE1" } }; </script>