view client/src/components/fairway/AvailableFairwayDepthLNWL.vue @ 4805:7de099c4824c

client: image-export: improve hyperlink ids for download * use own id-names for <a> element for each diagram to avoid having same id name in dom in case of two diagrams on screen
author Fadi Abbud <fadi.abbud@intevation.de>
date Mon, 28 Oct 2019 12:24:35 +0100
parents b3f65cff13e8
children db450fcc8ed7
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')"
            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();
      const w = [this.widthlimit1D, this.widthlimit2D].sort();
      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) {
        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: {
    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) {
        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>