view client/src/components/gauge/Waterlevel.vue @ 5736:55892008ec96 default tip

Fixed a bunch of corner cases in WG import.
author Sascha Wilde <wilde@sha-bang.de>
date Wed, 29 May 2024 19:02:42 +0200
parents de86a96d55c3
children
line wrap: on
line source

<template>
  <div class="d-flex flex-column flex-fill">
    <UIBoxHeader
      icon="ruler-vertical"
      :title="title"
      :closeCallback="close"
      class="rounded-0"
    />
    <div class="d-flex flex-fill">
      <DiagramLegend id="diagramlegendId">
        <div class="legend">
          <span
            style="background-color: steelblue; width: 20px; height: 20px;"
          ></span>
          <span class="fix-trans-space" style="display:inline;" v-translate
            >Waterlevel</span
          >
        </div>
        <div class="legend">
          <span
            style="width: 8px; height: 8px; background-color: rgba(70, 130, 180, 0.6); border: solid 7px rgba(70, 130, 180, 0.2); background-clip: padding-box; box-sizing: content-box;"
          ></span>
          <span class="fix-trans-space" style="display:inline;" v-translate
            >Prediction</span
          >
        </div>
        <div class="legend">
          <span
            style="background-color: rgba(0, 255, 0, 0.1); width: 20px; height: 20px;"
          ></span>
          <span class="fix-trans-space" style="display:inline;" v-translate
            >Navigable Range</span
          >
        </div>
        <div>
          <select
            @change="applyChange"
            v-model="form.template"
            class="form-control d-block custom-select-sm w-100"
          >
            <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"
            :disabled="!waterlevels.length"
          >
            <translate>Export to PDF</translate>
          </button>
          <a
            :class="[
              'btn btn-sm btn-info d-block w-100 mt-2',
              { disabled: !waterlevels.length }
            ]"
            :href="csvLink"
            :download="`${fileName}.csv`"
          >
            <translate>Export as CSV</translate>
          </a>
          <a
            @click="downloadImage('waterlevelpng', title)"
            id="waterlevelpng"
            :class="[
              'btn btn-sm btn-info text-white d-block w-100 mt-2',
              { disabled: !waterlevels.length }
            ]"
            :download="`${fileName}.png`"
          >
            <translate>Export as Image</translate>
          </a>
          <!--
          <button
            @click="downloadSVG"
            type="button"
            class="btn btn-sm btn-info d-block w-100 mt-2"
            :disabled="!waterlevels.length"
          >
            <translate>Export as SVG</translate>
          </button>
          -->
        </div>
        <div class="btn-group-toggle w-100 mt-2">
          <label
            class="btn btn-outline-secondary btn-sm"
            :class="{ active: showNSC }"
            ><input
              type="checkbox"
              v-model="showNSC"
              autocomplete="off"
            />Nash-Sutcliffe
          </label>
        </div>
      </DiagramLegend>
      <div
        class="d-flex flex-fill justify-content-center align-items-center"
        :id="containerId"
      >
        <div v-if="!waterlevels.length">
          <translate>No data available.</translate>
        </div>
      </div>
    </div>
  </div>
</template>

<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) 2019 by via donau
 *   – Österreichische Wasserstraßen-Gesellschaft mbH
 * Software engineering by Intevation GmbH
 *
 * Author(s):
 * * Bernhard Reiter <bernhard@intevation.de>
 * * Markus Kottländer <markus.kottlaender@intevation.de>
 * * Fadi Abbud <fadi.abbud@intevation.de>
 */

import app from "@/main";
import { mapState, mapGetters } from "vuex";
import * as d3Base from "d3";
import { lineChunked } from "d3-line-chunked";
import debounce from "debounce";
import { saveAs } from "file-saver";
import { diagram, pdfgen, templateLoader, refwaterlevels } from "@/lib/mixins";
import { HTTP } from "@/lib/http";
import { displayError } from "@/lib/errors";
import { defaultDiagramTemplate } from "@/lib/DefaultDiagramTemplate";
import { localeDateString } from "@/lib/datelocalization";

// we should load only d3 modules we need but for now we'll go with the lazy way
// https://www.giacomodebidda.com/how-to-import-d3-plugins-with-webpack/
// d3-line-chunked plugin: https://github.com/pbeshai/d3-line-chunked
const d3 = Object.assign(d3Base, { lineChunked });

let temp = null;

export default {
  mixins: [diagram, pdfgen, templateLoader, refwaterlevels],
  components: {
    DiagramLegend: () => import("@/components/DiagramLegend")
  },
  data() {
    return {
      dateFromD: null,
      dateToD: null,
      selectedGaugeD: null,
      containerId: "waterlevel-diagram-container",
      resizeListenerFunction: null,
      form: {
        template: null
      },
      templates: [],
      defaultTemplate: defaultDiagramTemplate,
      pdf: {
        doc: null,
        width: 420,
        height: 297
      },
      templateData: null,
      zoomStore: null,
      showNSC: true
    };
  },
  computed: {
    ...mapState("application", ["paneSetup"]),
    ...mapState("gauges", [
      "dateFrom",
      "dateTo",
      "waterlevels",
      "waterlevelsCSV",
      "nashSutcliffe"
    ]),
    ...mapGetters("gauges", ["selectedGauge"]),
    title() {
      if (!this.selectedGaugeD) return;
      return `${this.selectedGaugeD.properties.objname}: ${this.$gettext(
        "Waterlevel"
      )} (${this.dateFromD.toLocaleDateString()} - ${this.dateToD.toLocaleDateString()})`;
    },
    csvLink() {
      return (
        "data:text/csv;charset=utf-8," + encodeURIComponent(this.waterlevelsCSV)
      );
    },
    fileName() {
      return this.downloadFilename(
        this.$gettext("Waterlevel"),
        this.selectedGauge.properties.objname
      );
    },
    hasPredictions() {
      return this.waterlevels.find(d => d.predicted);
    }
  },
  watch: {
    showNSC() {
      this.drawDiagram({ ...this.zoomStore });
    },
    paneSetup() {
      this.$nextTick(() => this.drawDiagram());
    },
    waterlevels() {
      this.initialDiagramValues();
      this.drawDiagram();
    }
  },
  methods: {
    addLegendToCanvas(ctx, { width, height }) {
      let x = width / 10,
        y = height - 25;
      ctx.font = "12px sans-serif";
      ctx.textAlign = "start";

      ctx.beginPath();
      ctx.fillStyle = "steelblue";
      ctx.strokeStyle = "white";
      ctx.arc(x, y, 8, 0, 2 * Math.PI);
      ctx.fill();
      ctx.stroke();
      ctx.fillStyle = "black";
      ctx.fillText(this.$gettext("Waterlevel"), x + 14, y + 5);
      ctx.closePath();

      ctx.beginPath();
      ctx.fillStyle = "#90b4d2";
      ctx.strokeStyle = "#90b4d2";
      ctx.arc(x, (y += 20), 8, 0, 2 * Math.PI);
      ctx.fill();
      ctx.stroke();
      ctx.closePath();
      ctx.beginPath();
      ctx.fillStyle = "#4682B4";
      ctx.strokeStyle = "#4682B4";
      ctx.arc(x, y, 2, 0, 2 * Math.PI);
      ctx.fill();
      ctx.stroke();
      ctx.fillStyle = "black";
      ctx.fillText(this.$gettext("prediction"), x + 14, y + 5);
      ctx.closePath();

      ctx.beginPath();
      ctx.fillStyle = "rgba(0, 255, 0, 0.1)";
      ctx.strokeStyle = "rgba(0, 255, 0, 0.1)";
      ctx.arc(x, (y += 20), 8, 0, 2 * Math.PI);
      ctx.fill();
      ctx.stroke();
      ctx.fillStyle = "black";
      ctx.fillText(this.$gettext("Navigable Range"), x + 14, y + 5);
      ctx.closePath();
    },
    initialDiagramValues() {
      this.dateFromD = this.dateFrom;
      this.dateToD = this.dateTo;
      this.selectedGaugeD = this.selectedGauge;
    },
    close() {
      this.$store.commit(
        "application/paneSetup",
        this.paneSetup === "GAUGE_WATERLEVEL_HYDROLOGICALCONDITIONS"
          ? "GAUGE_HYDROLOGICALCONDITIONS"
          : "DEFAULT"
      );
    },
    downloadSVG() {
      let svg = document.getElementById(this.containerId).firstElementChild;
      let svgXML = new XMLSerializer().serializeToString(svg);
      let blog = new Blob([svgXML], { type: "image/svg+xml;charset=utf-8" });
      let filename =
        this.selectedGauge.properties.objname + "-waterlevel-diagram.svg";
      saveAs(blog, filename);
    },
    downloadPDF() {
      let diagramTitle = `${this.selectedGaugeD.properties.objname} (${
        this.isrsInfo(this.selectedGaugeD).orc
      }): ${this.$gettext(
        "Waterlevel"
      )} ${this.dateFromD.toLocaleDateString()} - ${this.dateToD.toLocaleDateString()}`;
      this.generatePDF({
        templateData: this.templateData,
        diagramTitle: diagramTitle
      });
      this.pdf.doc.save(this.fileName + ".pdf");
    },
    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(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
            });
          });
      }
    },
    // Diagram legend
    addDiagramLegend(position, offset, color) {
      let x = offset.x + 2, // 2 is the radius of the circle
        y = offset.y,
        padding = 3;
      this.pdf.doc.setFontStyle("normal");
      this.pdf.doc.setFontSize(10);
      let width = this.pdf.doc.getTextWidth("Navigable Range") + padding;
      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(3);
      }
      if (y < this.getTextHeight(1)) {
        y = y + this.getTextHeight(1) / 2;
      }
      this.pdf.doc.setTextColor(color);
      this.pdf.doc.setDrawColor("white");
      this.pdf.doc.setFillColor("steelblue");
      this.pdf.doc.circle(x, y, 2, "FD");
      this.pdf.doc.text(x + padding, y + 1, this.$gettext("Waterlevel"));
      this.pdf.doc.setFillColor("#dae6f0");
      this.pdf.doc.circle(x, y + 5, 2, "FD");
      this.pdf.doc.setFillColor("#e5ffe5");
      this.pdf.doc.circle(x, y + 10, 2, "FD");
      this.pdf.doc.text(x + padding, y + 11, this.$gettext("Navigable Range"));
      this.pdf.doc.setDrawColor("#90b4d2");
      this.pdf.doc.setFillColor("#90b4d2");
      this.pdf.doc.circle(x, y + 5, 0.6, "FD");
      this.pdf.doc.text(x + padding, y + 6, this.$gettext("Prediction"));
    },
    getPrintLayout(svgHeight, svgWidth) {
      return {
        main: {
          top: Math.floor(0.05 * svgHeight),
          right: Math.floor(0.05 * svgWidth),
          bottom: Math.floor(0.32 * svgHeight),
          left: Math.floor(0.09 * svgWidth)
        },
        nav: {
          top: Math.floor(0.78 * svgHeight),
          right: Math.floor(0.013 * svgWidth),
          bottom: Math.floor(0.095 * svgHeight),
          left: Math.floor(0.09 * svgWidth)
        }
      };
    },
    drawDiagram(zoom) {
      // remove old diagram and exit if necessary data is missing
      d3.select("#" + this.containerId + " svg").remove();
      d3.timeFormatDefaultLocale(localeDateString);
      const elem = document.querySelector("#" + this.containerId);
      const svgWidth = elem.clientWidth;
      const svgHeight = elem.clientHeight;
      const layout = this.getPrintLayout(svgHeight, svgWidth);
      if (!this.selectedGauge || !this.waterlevels.length || !elem) return;
      this.renderTo({
        element: `#${this.containerId}`,
        dimensions: this.getDimensions({
          svgWidth: svgWidth,
          svgHeight: svgHeight,
          ...layout
        }),
        zoomLevel: zoom ? zoom : null
      });
    },
    renderTo({ element, dimensions, zoomLevel }) {
      // PREPARE HELPERS
      // HDC/LDC/MW for the selected gauge
      const refWaterLevels = JSON.parse(
        this.selectedGauge.properties.reference_water_levels
      );

      // get min/max values for date and waterlevel axis
      const extent = this.getExtent(refWaterLevels);

      // scaling helpers
      const scale = this.getScale({ dimensions, extent });
      const dFormat = date => {
        // make the x-axis label formats dynamic, based on zoom
        // but never display year numbers since they don't make any sense in
        // this diagram
        return (d3.timeSecond(date) < date
          ? d3.timeFormat(".%L")
          : d3.timeMinute(date) < date
          ? d3.timeFormat(":%S")
          : d3.timeHour(date) < date
          ? d3.timeFormat("%H:%M")
          : d3.timeDay(date) < date
          ? d3.timeFormat("%H:%M")
          : d3.timeMonth(date) < date
          ? d3.timeWeek(date) < date
            ? d3.timeFormat(app.$gettext("%a %d"))
            : d3.timeFormat(app.$gettext("%b %d"))
          : d3.timeYear(date) < date
          ? d3.timeFormat("%B")
          : d3.timeFormat("%Y"))(date);
      };
      // creating the axes based on the scales
      const axes = {
        x: d3
          .axisTop(scale.x)
          .tickSizeInner(dimensions.mainHeight)
          .tickSizeOuter(0)
          .tickFormat(dFormat),
        y: d3
          .axisRight(scale.y)
          .tickSizeInner(dimensions.width)
          .tickSizeOuter(0)
          .tickFormat(d => this.$options.filters.waterlevel(d)),
        yRight: d3
          .axisRight(scale.y)
          .tickSizeInner(3)
          .tickSizeOuter(0)
          .tickFormat(d => this.$options.filters.waterlevel(d)),
        x2: d3.axisBottom(scale.x2).tickFormat(dFormat)
      };

      // DRAW DIAGRAM/NAVIGATION AREAS

      // create svg
      const svg = d3
        .select(element)
        .append("svg")
        .attr("width", "100%")
        .attr("height", "100%");
      // add white background in the size of the svg
      // to solve alpha-channel problem when using canvg to export image
      svg
        .append("g")
        .append("rect")
        .attr("width", "100%")
        .attr("height", "100%")
        .attr("fill", "#ffffff");

      // create container for main diagram
      const diagram = svg
        .append("g")
        .attr("class", "main")
        .attr(
          "transform",
          `translate(${dimensions.mainMargin.left}, ${dimensions.mainMargin.top})`
        );

      // create container for navigation diagram
      const navigation = svg
        .append("g")
        .attr("class", "nav")
        .attr(
          "transform",
          `translate(${dimensions.navMargin.left}, ${dimensions.navMargin.top})`
        );

      // define visible area, everything outside this area will be hidden
      svg
        .append("defs")
        .append("clipPath")
        .attr("id", "waterlevel-clip")
        .append("rect")
        .attr("width", dimensions.width)
        .attr("height", dimensions.mainHeight);

      // DRAW DIAGRAM PARTS

      // Each drawSomething function (with the exception of drawRefLines)
      // returns a fuction to update the respective chart/area/etc. These
      // updater functions are used by the zoom feature to rescale all elements.
      const updaters = [];

      // draw (order matters)
      updaters.push(this.drawAxes({ diagram, dimensions, axes, navigation }));
      updaters.push(this.drawWaterlevelChart({ scale, diagram }));
      if (this.hasPredictions) {
        updaters.push(this.drawPredictionAreas({ scale, diagram, navigation }));
      }
      updaters.push(
        this.drawNowLines({ scale, extent, diagram, navigation, dimensions })
      );

      // static, don't need updater
      this.drawNavigationChart({ scale, navigation });
      this.drawRefLines({ refWaterLevels, diagram, scale, dimensions, extent });
      if (this.showNSC) {
        updaters.push(
          this.drawNashSutcliffe({ hours: 72, diagram, scale, dimensions })
        );
        updaters.push(
          this.drawNashSutcliffe({ hours: 48, diagram, scale, dimensions })
        );
        updaters.push(
          this.drawNashSutcliffe({ hours: 24, diagram, scale, dimensions })
        );
      }

      // INTERACTIONS

      // create rectanlge on the main chart area to capture mouse events
      const eventRect = svg
        .append("rect")
        .attr("id", "zoom-waterlevels")
        .attr("class", "zoom")
        .attr("width", dimensions.width)
        .attr("height", dimensions.mainHeight)
        .attr(
          "transform",
          `translate(${dimensions.mainMargin.left}, ${dimensions.mainMargin.top})`
        );

      this.createZoom({
        updaters,
        eventRect,
        dimensions,
        scale,
        navigation,
        svg,
        zoomLevel
      });
      this.createTooltips({ eventRect, diagram, scale, dimensions });
      this.setInlineStyles(svg);
    },
    //set the styles of the diagrams to include them in the pdf
    setInlineStyles(svg) {
      svg
        .selectAll(".line")
        .attr("clip-path", "url(#waterlevel-clip)")
        .selectAll("path")
        .attr("stroke", "steelblue")
        .attr("stroke-width", 2)
        .attr("fill", "none");
      svg
        .selectAll(".line")
        .selectAll("path.d3-line-chunked-chunk-gap")
        .attr("stroke-opacity", 0);
      svg
        .selectAll(".line")
        .selectAll("circle")
        .attr("fill", "steelblue")
        .attr("stroke-width", 0);
      svg
        .selectAll(".line")
        .selectAll("circle.d3-line-chunked-chunk-predicted-point")
        .attr("fill-opacity", 0.6);

      svg
        .selectAll(".hdc-line, .mw-line, .ldc-line, .rn-line")
        .attr("stroke-width", 1)
        .attr("fill", "none")
        .attr("clip-path", "url(#waterlevel-clip)");
      svg.selectAll(".hdc-line").attr("stroke", "red");
      svg.selectAll(".ldc-line").attr("stroke", "green");

      svg.selectAll(".mw-line").attr("stroke", "rgb(128,128,128)");
      svg.selectAll(".rn-line").attr("stroke", "rgb(128,128,128)");
      svg
        .selectAll(".ref-waterlevel-label")
        .style("font-size", "10px")
        .attr("fill", "black");
      svg
        .selectAll(".ref-waterlevel-label-background")
        .attr("fill", "rgb(255, 255, 255)")
        .attr("fill-opacity", 0.6);
      svg
        .selectAll(".hdc-ldc-area")
        .attr("fill", "rgb(0, 255, 0)")
        .attr("fill-opacity", 0.1);
      svg
        .selectAll(".now-line")
        .attr("stroke", "#999")
        .attr("stroke-width", 1)
        .attr("stroke-dasharray", "5, 5")
        .attr("clip-path", "url(#waterlevel-clip)");
      svg
        .selectAll(".now-line-label")
        .attr("font-size", "11px")
        .attr("fill", "#999");
      svg
        .selectAll(".prediction-area")
        .attr("fill", "steelblue")
        .attr("fill-opacity", 0.2)
        .attr("clip-path", "url(#waterlevel-clip)");
      svg
        .selectAll("path.nash-sutcliffe")
        .attr("fill", "none")
        .attr("stroke", "gray")
        .attr("stroke-width", 1)
        .attr("clip-path", "url(#waterlevel-clip)");
      svg
        .selectAll("path.nash-sutcliffe.ns72")
        .attr("fill", "rgb(255, 255, 255)")
        .attr("fill-opacity", 0.5);
      svg
        .selectAll("text.nash-sutcliffe")
        .style("font-size", "10px")
        .attr("clip-path", "url(#waterlevel-clip)")
        .selectAll("tspan:last-child, tspan:first-child")
        .attr("fill", "#555");
      svg
        .selectAll(".axis--x .tick line, .axis--y .tick line")
        .attr("stroke-dasharray", 5)
        .attr("stroke", "#ccc");
      svg.selectAll(".axis--y-right .tick line").attr("stroke", "transparent");
      svg.selectAll(".tick text").attr("fill", "black");
      svg.selectAll(".domain").attr("stroke", "black");
      svg
        .selectAll(".domain")
        .attr("stroke", "black")
        .attr("fill", "none");
      svg
        .selectAll(".zoom")
        .attr("cursor", "move")
        .attr("fill", "none")
        .attr("pointer-events", "all");
      svg
        .selectAll(".brush .selection")
        .attr("stroke", "none")
        .attr("fill-opacity", 0.2);
      svg
        .selectAll(".brush .handle")
        .attr("stroke", "rgba(23, 162, 184, 0.5)")
        .attr("fill", "rgba(23, 162, 184, 0.5)");
      svg.selectAll(".brush .overlay").attr("fill", "none");
      svg.selectAll(".chart-dots").attr("clip-path", "url(#waterlevel-clip)");
      svg
        .selectAll(".chart-dots .chart-dot")
        .attr("fill", "steelblue")
        .attr("stroke", "steelblue")
        .attr("stroke-opacity", 0)
        .style("pointer-events", "none")
        .transition()
        .attr("fill-opacity", "0.1s");
      svg
        .selectAll(".chart-tooltip")
        .attr("fill-opacity", 0)
        .transition()
        .attr("fill-opacity", "0.3s");
      svg
        .selectAll(".chart-tooltip rect")
        .attr("fill", "#fff")
        .attr("stroke", "#ccc");
      svg
        .selectAll(".chart-tooltip text")
        .attr("fill", "666")
        .style("font-size", "0.8em");
    },
    getExtent(refWaterLevels) {
      let rest;
      if (refWaterLevels) {
        // set min/max values for the waterlevel axis
        // including HDC (+ 1/8 HDC-LDC) and LDC (- 1/4 HDC-LDC)
        const { LDC, HDC } = this.determineLDCHDC(refWaterLevels);
        rest = [
          {
            waterlevel: HDC + (HDC - LDC) / 8
          },
          {
            waterlevel: Math.max(LDC - (HDC - LDC) / 4, 0)
          }
        ];
      } else {
        rest = [];
      }
      return {
        // set min/max values for the date axis
        date: [this.dateFrom, this.dateTo],
        waterlevel: d3.extent([...this.waterlevels, ...rest], d => d.waterlevel)
      };
    },
    getScale({ dimensions, extent }) {
      // scaling helpers to convert real world values into pixels
      const x = d3.scaleTime().range([0, dimensions.width]);
      const y = d3.scaleLinear().range([dimensions.mainHeight, 0]);
      const x2 = d3.scaleTime().range([0, dimensions.width]);
      const y2 = d3.scaleLinear().range([dimensions.navHeight, 0]);
      const [lo, hi] = extent.waterlevel;
      // setting the min and max values for the diagram axes
      const dy = Math.ceil(0.15 * (hi - lo));
      x.domain(d3.extent(extent.date));
      y.domain([lo - dy, hi + dy]);
      x2.domain(x.domain());
      y2.domain(y.domain());

      return { x, y, x2, y2 };
    },
    drawAxes({ diagram, dimensions, axes, navigation }) {
      diagram
        .append("g")
        .attr("class", "axis--x")
        .attr("transform", `translate(0, ${dimensions.mainHeight})`)
        .call(axes.x)
        .selectAll(".tick text")
        .attr("y", 15);
      diagram // label
        .append("text")
        .text(this.$gettext("Waterlevel [m]"))
        .attr("text-anchor", "middle")
        .attr(
          "transform",
          `translate(-45, ${dimensions.mainHeight / 2}) rotate(-90)`
        );
      diagram
        .append("g")
        .attr("class", "axis--y")
        .call(axes.y)
        .selectAll(".tick text")
        .attr("x", -25);
      diagram
        .append("g")
        .attr("class", "axis--y-right")
        .attr("transform", `translate(${dimensions.width})`)
        .call(axes.yRight)
        .selectAll(".tick text");

      navigation
        .append("g")
        .attr("class", "axis axis--x")
        .attr("transform", `translate(0, ${dimensions.navHeight})`)
        .call(axes.x2);

      return () => {
        diagram
          .select(".axis--x")
          .call(axes.x)
          .selectAll(".tick text")
          .attr("y", 15);
      };
    },
    drawWaterlevelChart({ scale, diagram }) {
      const waterlevelChartDrawer = () => {
        let domainLeft = new Date(scale.x.domain()[0].getTime());
        let domainRight = new Date(scale.x.domain()[1].getTime());
        domainLeft.setDate(domainLeft.getDate() - 1);
        domainRight.setDate(domainRight.getDate() + 1);

        let lineChunked = d3
          .lineChunked()
          // render only data points that are visible in the current scale
          .defined(d => d.date > domainLeft && d.date < domainRight)
          .x(d => scale.x(d.date))
          .y(d => scale.y(d.waterlevel))
          .curve(d3.curveLinear)
          .isNext(this.isNext(scale))
          .pointAttrs({ r: 1.7 });
        // to avoid creating empty clip-path element
        if (this.hasPredictions) {
          lineChunked
            .chunk(d => (d.predicted ? "predicted" : "line"))
            .chunkDefinitions({ predicted: {} });
        }
        return lineChunked;
      };

      diagram
        .append("g")
        .attr("class", "line")
        .datum(this.waterlevels)
        .call(waterlevelChartDrawer());

      return () => {
        diagram.select(".line").call(waterlevelChartDrawer());
      };
    },
    drawNavigationChart({ scale, navigation }) {
      let lineChunked = d3
        .lineChunked()
        .x(d => scale.x2(d.date))
        .y(d => scale.y2(d.waterlevel))
        .curve(d3.curveLinear)
        .isNext(this.isNext(scale))
        .pointAttrs({ r: 1.7 });
      // to avoid creating empty clip-path element
      if (this.hasPredictions) {
        lineChunked
          .chunk(d => (d.predicted ? "predicted" : "line"))
          .chunkDefinitions({ predicted: {} });
      }
      navigation
        .append("g")
        .attr("class", "line")
        .datum(this.waterlevels)
        .call(lineChunked);
    },
    drawNowLines({ scale, extent, diagram, navigation, dimensions }) {
      const [lo, hi] = extent.waterlevel;
      const dy = Math.ceil(0.15 * (hi - lo));
      const nowLine = d3
        .line()
        .x(d => scale.x(d.x))
        .y(d => scale.y(d.y));

      const nowLabel = selection => {
        selection
          .attr(
            "transform",
            `translate(${scale.x(new Date())}, ${scale.y(hi + dy * 0.4)})`
          )
          // hide Now label outside the diagram y-axises
          .attr(
            "opacity",
            scale.x(new Date()) >= dimensions.width || scale.x(new Date()) <= 0
              ? 0
              : 1
          );
      };

      // draw in main
      diagram
        .append("path")
        .datum([
          { x: new Date(), y: lo - dy },
          { x: new Date(), y: hi + dy * 0.4 }
        ])
        .attr("class", "now-line")
        .attr("d", nowLine);
      diagram // label
        .append("text")
        .text(this.$gettext("Now"))
        .attr("class", "now-line-label")
        .attr("text-anchor", "middle")
        .call(nowLabel);

      // draw in nav
      navigation
        .append("path")
        .datum([
          { x: new Date(), y: hi + dy },
          { x: new Date(), y: lo - dy }
        ])
        .attr("class", "now-line")
        .attr(
          "d",
          d3
            .line()
            .x(d => scale.x2(d.x))
            .y(d => scale.y2(d.y))
        );

      return () => {
        diagram.select(".now-line").attr("d", nowLine);
        diagram.select(".now-line-label").call(nowLabel);
      };
    },
    drawPredictionAreas({ scale, diagram, navigation }) {
      const predictionArea = isNav =>
        d3
          .area()
          .defined(d => d.predicted && d.min && d.max)
          .x(d => scale[isNav ? "x2" : "x"](d.date))
          .y0(d => scale[isNav ? "y2" : "y"](d.min))
          .y1(d => scale[isNav ? "y2" : "y"](d.max));

      diagram
        .append("path")
        .datum(this.waterlevels)
        .attr("class", "prediction-area")
        .attr("d", predictionArea());

      navigation
        .append("path")
        .datum(this.waterlevels)
        .attr("class", "prediction-area")
        .attr("d", predictionArea(true));

      return () => {
        diagram.select(".prediction-area").attr("d", predictionArea());
      };
    },
    drawRefLines({ refWaterLevels, diagram, scale, dimensions, extent }) {
      if (refWaterLevels) {
        const { LDC, HDC } = this.determineLDCHDC(refWaterLevels);
        // filling area between HDC and LDC if both of them are available
        if (LDC && HDC) {
          diagram
            .append("rect")
            .attr("class", "hdc-ldc-area")
            .attr("x", 0)
            .attr("y", scale.y(HDC))
            .attr("width", dimensions.width)
            .attr("height", refWaterLevels ? scale.y(LDC) - scale.y(HDC) : 0);
        }
      }

      const refWaterlevelLine = d3
        .line()
        .x(d => scale.x(d.x))
        .y(d => scale.y(d.y));

      const levelStyle = name => {
        if (/HDC/.test(name)) return "hdc-line";
        if (/LDC/.test(name)) return "ldc-line";
        return `${name.toLowerCase()}-line`;
      };

      for (let ref in refWaterLevels) {
        if (refWaterLevels[ref]) {
          diagram
            .append("path")
            .datum([
              { x: 0, y: refWaterLevels[ref] },
              { x: extent.date[1], y: refWaterLevels[ref] }
            ])
            .attr("class", levelStyle(ref))
            .attr("d", refWaterlevelLine);
          diagram // label
            .append("rect")
            .attr("class", "ref-waterlevel-label-background")
            .attr("x", 1)
            .attr("y", scale.y(refWaterLevels[ref]) - 13)
            .attr("width", 55)
            .attr("height", 12);
          diagram
            .append("text")
            .text(
              `${ref} (${this.$options.filters.waterlevel(
                refWaterLevels[ref]
              )})`
            )
            .attr("class", "ref-waterlevel-label")
            .attr("x", 5)
            .attr("y", scale.y(refWaterLevels[ref]) - 3);
        }
      }
    },
    drawNashSutcliffe({ hours, diagram, scale, dimensions }) {
      const coeff = this.nashSutcliffe.coeffs.find(c => c.hours === hours);
      const dateNow = new Date(this.nashSutcliffe.when);
      const dateStart = new Date(dateNow.getTime() - hours * 60 * 60 * 1000);

      const nashSutcliffeBox = (hours, date) => {
        // show/hide boxes depending on scale of chart (hide if > 90 days)
        diagram
          .selectAll("path.nash-sutcliffe")
          .attr(
            "stroke-opacity",
            scale.x.domain()[1] - scale.x.domain()[0] > 90 * 86400000 ? 0 : 1
          )
          // show boxes only if now line in the selected time range
          .attr("opacity", dimensions.width >= scale.x(date) ? 1 : 0);
        return d3
          .area()
          .x(d => scale.x(d))
          .y0(() => dimensions.mainHeight + 0.5)
          .y1(
            () =>
              dimensions.mainHeight -
              Math.floor(0.06 * dimensions.mainHeight) * (hours / 24)
          );
      };

      const nashSutcliffeLabel = (label, date, hours) => {
        let days = hours / 24;
        label
          .attr("x", Math.min(scale.x(date), dimensions.width) - 4)
          .attr(
            "y",
            dimensions.mainHeight -
              (Math.floor(0.06 * dimensions.mainHeight) * days + 0.5) +
              12
          )
          // show label only if now line in the selected time range
          .attr("opacity", dimensions.width >= scale.x(date) ? 1 : 0);
      };

      if (coeff.samples) {
        diagram
          .append("path")
          .datum([dateStart, dateNow])
          .attr("class", "nash-sutcliffe ns" + hours)
          .attr("d", nashSutcliffeBox(hours, dateNow));
        diagram
          .append("text")
          .attr("class", "nash-sutcliffe ns" + hours)
          .attr("text-anchor", "end")
          .call(nashSutcliffeLabel, dateNow, hours)
          .append("tspan")
          .text(hours + "h: ")
          .select(function() {
            return this.parentNode;
          })
          .append("tspan")
          .text(coeff.value.toFixed(2))
          .select(function() {
            return this.parentNode;
          })
          .append("tspan")
          .text(` (${coeff.samples})`);
      }

      return () => {
        diagram
          .select("path.nash-sutcliffe.ns" + hours)
          .attr("d", nashSutcliffeBox(hours, dateNow));
        diagram
          .select("text.nash-sutcliffe.ns" + hours)
          .call(nashSutcliffeLabel, dateNow, hours);
      };
    },
    createZoom({
      updaters,
      eventRect,
      dimensions,
      scale,
      navigation,
      svg,
      zoomLevel
    }) {
      const brush = d3
        .brushX()
        .handleSize(4)
        .extent([
          [0, 0],
          [dimensions.width, dimensions.navHeight]
        ]);

      const zoom = d3
        .zoom()
        .scaleExtent([1, Infinity])
        .translateExtent([
          [0, 0],
          [dimensions.width, dimensions.mainHeight]
        ])
        .extent([
          [0, 0],
          [dimensions.width, dimensions.mainHeight]
        ]);

      brush.on("brush end", () => {
        if (d3.event.sourceEvent && d3.event.sourceEvent.type === "zoom")
          return; // ignore brush-by-zoom
        let s = d3.event.selection || scale.x2.range();
        scale.x.domain(s.map(scale.x2.invert, scale.x2));
        updaters.forEach(u => u && u());
        this.setInlineStyles(svg);
        svg
          .select(".zoom")
          .call(
            zoom.transform,
            d3.zoomIdentity
              .scale(dimensions.width / (s[1] - s[0]))
              .translate(-s[0], 0)
          );
      });
      let scaleForZoom = t => {
        scale.x.domain(t.rescaleX(scale.x2).domain());
        updaters.forEach(u => u && u());
        this.setInlineStyles(svg);
        navigation
          .select(".brush")
          .call(brush.move, scale.x.range().map(t.invertX, t));
      };
      zoom.on("zoom", () => {
        if (d3.event.sourceEvent && d3.event.sourceEvent.type === "brush") {
          return; // ignore zoom-by-brush
        }
        let t = d3.event.transform;
        // set the zoom to the passed zoom level.
        if (zoomLevel) {
          let tx = (zoomLevel.x * dimensions.width) / zoomLevel.width;
          let k = zoomLevel.k;
          let ty = zoomLevel.y;
          t = d3.zoomIdentity.translate(tx, ty).scale(k);
          zoomLevel = null; // avoid to stuck at same zoom level after setting the zoom by subsequent zooming.
        } else {
          temp = { ...d3.event.transform, width: dimensions.width };
        }
        scaleForZoom(t);
      });
      zoom.on("start", () => {
        svg.select(".chart-dot").style("opacity", 0);
        svg.select(".chart-tooltip").style("opacity", 0);
      });
      // store the zoom level after zomming is ended
      zoom.on("end", () => {
        if (!zoomLevel) {
          this.zoomStore = temp
            ? temp
            : { ...d3.event.transform, width: dimensions.width };
        }
      });

      navigation
        .append("g")
        .attr("class", "brush")
        .call(brush)
        .call(brush.move, scale.x.range());

      eventRect.call(zoom);
    },
    createTooltips({ eventRect, diagram, scale, dimensions }) {
      // create clippable container for the dot
      diagram
        .append("g")
        .attr("class", "chart-dots")
        .append("circle")
        .attr("class", "chart-dot")
        .attr("opacity", 0)
        .attr("r", 4);

      // create container for the tooltip
      const tooltip = diagram.append("g").attr("class", "chart-tooltip");
      tooltip
        .append("rect")
        .attr("rx", "0.25em")
        .attr("ry", "0.25em")
        .attr("width", "0px")
        .attr("height", "0px");

      // create container for multiple text rows
      const tooltipText = tooltip.append("text").attr("text-anchor", "middle");

      // padding inside the tooltip box and diagram padding to determine left
      // and right offset from the diagram boundaries for the tooltip position.
      const tooltipPadding = 10;
      const diagramPadding = 5;

      eventRect
        .on("mouseover", () => {
          diagram.select(".chart-dot").style("opacity", 1);
          diagram.select(".chart-tooltip").style("opacity", 1);
        })
        .on("mouseout", () => {
          diagram.select(".chart-dot").style("opacity", 0);
          diagram.select(".chart-tooltip").style("opacity", 0);
        })
        .on("mousemove", () => {
          // find data point closest to mouse
          const x0 = scale.x.invert(
              d3.mouse(document.getElementById("zoom-waterlevels"))[0]
            ),
            i = d3.bisector(d => d.date).left(this.waterlevels, x0, 1),
            d0 = this.waterlevels[i - 1],
            d1 = this.waterlevels[i] || d0,
            d = x0 - d0.date > d1.date - x0 ? d1 : d0;

          const coords = {
            x: scale.x(d.date),
            y: scale.y(d.waterlevel)
          };

          // position the dot
          diagram
            .select(".chart-dot")
            .style("opacity", 1)
            .attr("transform", `translate(${coords.x}, ${coords.y})`);

          // remove current texts
          tooltipText.selectAll("tspan").remove();

          // write date
          tooltipText
            .append("tspan")
            .attr("dominant-baseline", "hanging")
            .attr("text-anchor", "middle")
            .text(
              d.date.toLocaleString([], {
                year: "2-digit",
                month: "2-digit",
                day: "2-digit",
                hour: "2-digit",
                minute: "2-digit"
              })
            );

          if (d.predicted) {
            tooltipText
              .append("tspan")
              .attr("x", 0)
              .attr("y", 0)
              .attr("dy", "1.4em")
              .attr("dominant-baseline", "hanging")
              .attr("text-anchor", "middle")
              .text(this.$options.filters.waterlevel(d.max) + " m");
            tooltipText
              .append("tspan")
              .attr("x", 0)
              .attr("y", 0)
              .attr("dy", "2.6em")
              .attr("dominant-baseline", "hanging")
              .attr("text-anchor", "middle")
              .attr("class", "font-weight-bold")
              .text(this.$options.filters.waterlevel(d.waterlevel) + " m");
            tooltipText
              .append("tspan")
              .attr("x", 0)
              .attr("y", 0)
              .attr("dy", "3.8em")
              .attr("dominant-baseline", "hanging")
              .attr("text-anchor", "middle")
              .text(this.$options.filters.waterlevel(d.min) + " m");
          } else {
            tooltipText
              .append("tspan")
              .attr("x", 0)
              .attr("y", 0)
              .attr("dy", "1.4em")
              .attr("dominant-baseline", "hanging")
              .attr("text-anchor", "middle")
              .attr("class", "font-weight-bold")
              .text(this.$options.filters.waterlevel(d.waterlevel) + " m");
          }

          // get text dimensions
          const textBBox = tooltipText.node().getBBox();

          diagram
            .selectAll(".chart-tooltip text tspan")
            .attr("x", textBBox.width / 2 + tooltipPadding)
            .attr("y", tooltipPadding);

          // position and scale tooltip box
          const xMax =
            dimensions.width -
            (textBBox.width + diagramPadding + tooltipPadding * 2);
          const tooltipX = Math.max(
            diagramPadding,
            Math.min(coords.x - (textBBox.width + tooltipPadding * 2) / 2, xMax)
          );
          let tooltipY = coords.y - (textBBox.height + tooltipPadding * 2) - 10;
          if (coords.y < textBBox.height + tooltipPadding * 2) {
            tooltipY = coords.y + 10;
          }

          diagram
            .select(".chart-tooltip")
            .style("opacity", 1)
            .attr("transform", `translate(${tooltipX}, ${tooltipY})`)
            .select("rect")
            .attr("width", textBBox.width + tooltipPadding * 2)
            .attr("height", textBBox.height + tooltipPadding * 2);
        });
    },
    isNext(scale) {
      // Check whether points in the chart can be considered "next to each other".
      // For that they need to be exactly 15 minutes apart (for automatically
      // imported gauge measurements). If the chart shows more than 15 days then
      // 1 hour is also valid (for approved gauge measurements).
      return (prev, current) => {
        let difference = (current.date - prev.date) / 1000;
        if ((scale.x.domain()[1] - scale.x.domain()[0]) / 86400000 > 15)
          return [900, 3600].includes(difference);
        return difference === 900;
      };
    }
  },
  created() {
    this.resizeListenerFunction = debounce(() => {
      this.zoomStore // restore last zoom level
        ? this.drawDiagram({ ...this.zoomStore })
        : this.drawDiagram();
    }, 100);
    window.addEventListener("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.initialDiagramValues();
    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(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
        });
      });
  },
  destroyed() {
    window.removeEventListener("resize", this.resizeListenerFunction);
  }
};
</script>