view client/src/components/gauge/Waterlevel.vue @ 3726:be939dcdfdfd

client: waterlevel diagrams: make now line label not update its position when zooming Of course the line and label could both be updated and move with the time but since all other data is static after generating the diagram once, a moving now line would be to fancy... but I was tempted.
author Markus Kottlaender <markus@intevation.de>
date Thu, 20 Jun 2019 19:16:26 +0200
parents 05bbd1a97567
children 2b6734a6730a
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>
          Waterlevel
        </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>
          Prediction
        </div>
        <div class="legend">
          <span
            style="background-color: rgba(0, 255, 0, 0.1); width: 20px; height: 20px;"
          ></span>
          Navigable Range
        </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="csvFileName"
          >
            <translate>Export as CSV</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>
      </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
      id="pdfContainer"
      style="position: absolute; z-index: -1; top: -9999px;"
    ></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 { mapState, mapGetters } from "vuex";
import * as d3Base from "d3";
import { lineChunked } from "d3-line-chunked";
import { endOfDay } from "date-fns";
import debounce from "debounce";
import jsPDF from "jspdf";
import canvg from "canvg";
import { saveAs } from "file-saver";
import { pdfgen } from "@/lib/mixins";
import { HTTP } from "@/lib/http";
import { displayError } from "@/lib/errors";
// 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 });

export default {
  mixins: [pdfgen],
  components: {
    DiagramLegend: () => import("@/components/DiagramLegend")
  },
  data() {
    return {
      containerId: "waterlevel-diagram-container",
      resizeListenerFunction: null,
      svg: null,
      diagram: null,
      navigation: null,
      dimensions: null,
      extent: null,
      scale: null,
      axes: null,
      form: {
        template: null
      },
      templates: [],
      defaultTemplate: {
        name: "Default",
        properties: {
          paperSize: "a4"
        },
        elements: [
          {
            type: "diagram",
            position: "topleft",
            offset: { x: 15, y: 50 },
            width: 290,
            height: 100
          },
          {
            type: "diagramlegend",
            position: "topleft",
            offset: { x: 30, y: 150 },
            color: "black"
          },
          {
            type: "diagramtitle",
            position: "topleft",
            offset: { x: 50, y: 26 },
            fontsize: 22,
            color: "steelblue"
          },
          {
            type: "text",
            position: "topleft",
            offset: { x: 3, y: 3 },
            fontsize: 8,
            width: 90,
            color: "gray",
            text: this.$gettext("Generated by") + " " + "{user}, {date}"
          }
        ]
      },
      pdf: {
        doc: null,
        width: 420,
        height: 297
      },
      templateData: null
    };
  },
  computed: {
    ...mapState("application", ["paneSetup"]),
    ...mapState("gauges", [
      "dateFrom",
      "waterlevels",
      "waterlevelsCSV",
      "nashSutcliffe"
    ]),
    ...mapGetters("gauges", ["selectedGauge"]),
    title() {
      return `${this.selectedGauge.properties.objname}: ${this.$gettext(
        "Waterlevel"
      )} (${this.dateFrom.toLocaleDateString()} - ${this.dateTo.toLocaleDateString()})`;
    },
    dateFrom: {
      get() {
        return this.$store.state.gauges.dateFrom;
      }
    },
    dateTo: {
      get() {
        return this.$store.state.gauges.dateTo;
      }
    },
    csvLink() {
      return (
        "data:text/csv;charset=utf-8," + encodeURIComponent(this.waterlevelsCSV)
      );
    },
    csvFileName() {
      return `${this.$gettext("waterlevels")}-${
        this.selectedGauge.properties.objname
      }-${this.dateFrom.toISOString().split("T")[0]}-${
        this.dateTo.toISOString().split("T")[0]
      }.csv`;
    },
    hasPredictions() {
      return this.waterlevels.find(d => d.predicted);
    }
  },
  watch: {
    paneSetup() {
      this.$nextTick(() => this.drawDiagram());
    },
    waterlevels() {
      this.drawDiagram();
    }
  },
  methods: {
    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() {
      this.pdf.doc = new jsPDF(
        "l",
        "mm",
        this.templateData.properties.paperSize
      );
      // pdf width and height in millimeter (landscape)
      this.pdf.width =
        this.templateData.properties.paperSize === "a3" ? 420 : 297;
      this.pdf.height =
        this.templateData.properties.paperSize === "a3" ? 297 : 210;
      // check the template elements
      if (this.templateData) {
        let defaultFontSize = 11,
          defaultColor = "black",
          defaultWidth = 70,
          defaultTextColor = "black",
          defaultBorderColor = "white",
          defaultBgColor = "white",
          defaultRounding = 2,
          defaultPadding = 2,
          defaultOffset = { x: 0, y: 0 };
        this.templateData.elements.forEach(e => {
          switch (e.type) {
            case "diagram": {
              this.addDiagram(
                e.position,
                e.offset || defaultOffset,
                e.width,
                e.height
              );
              break;
            }
            case "diagramlegend": {
              this.addDiagramLegend(
                e.position,
                e.offset || defaultOffset,
                e.color || defaultColor
              );
              break;
            }
            case "diagramtitle": {
              let gaugeInfo =
                this.selectedGauge.properties.objname +
                " (" +
                this.selectedGauge.id
                  .split(".")[1]
                  .replace(/[()]/g, "")
                  .split(",")[3] +
                "):" +
                " Waterlevel " +
                this.dateFrom.toLocaleDateString() +
                " - " +
                this.dateTo.toLocaleDateString();
              this.addDiagramTitle(
                e.position,
                e.offset || defaultOffset,
                e.fontsize || defaultFontSize,
                e.color || defaultColor,
                gaugeInfo
              );
              break;
            }
            case "text": {
              this.addText(
                e.position,
                e.offset || defaultOffset,
                e.width || defaultWidth,
                e.fontsize || defaultFontSize,
                e.color || defaultTextColor,
                e.text || ""
              );
              break;
            }
            case "image": {
              this.addImage(
                e.url,
                e.format || "",
                e.position,
                e.offset || defaultOffset,
                e.width || 90,
                e.height || 60
              );
              break;
            }
            case "box": {
              this.addBox(
                e.position,
                e.offset || defaultOffset,
                e.width || 90,
                e.height || 60,
                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;
            }
          }
        });
      }
      this.pdf.doc.save(
        this.selectedGauge.properties.objname + " Waterlevel-Diagram.pdf"
      );
    },
    applyChange() {
      if (this.form.template.hasOwnProperty("properties")) {
        this.templateData = this.defaultTemplate;
        return;
      }
      if (this.form.template) {
        HTTP.get("/templates/diagram/" + this.form.template.name, {
          headers: {
            "X-Gemma-Auth": localStorage.getItem("token"),
            "Content-type": "text/xml; charset=UTF-8"
          }
        })
          .then(response => {
            this.templateData = response.data.template_data;
          })
          .catch(e => {
            const { status, data } = e.response;
            displayError({
              title: this.$gettext("Backend Error"),
              message: `${status}: ${data.message || data}`
            });
          });
      }
    },
    addDiagram(position, offset, width, height) {
      let x = offset.x,
        y = offset.y;
      // check if there are tow diagrams on screen
      if (
        ["GAUGE_WATERLEVEL_HYDROLOGICALCONDITIONS"].indexOf(this.paneSetup) !==
        -1
      ) {
        this.containerId = "pdfContainer";
        // set width and height
        document.querySelector("#pdfContainer").style.width =
          document.querySelector("#waterlevel-diagram-container").clientWidth *
            2 +
          document.querySelector("#diagramlegendId").clientWidth +
          "px";
        document.querySelector("#pdfContainer").style.height =
          document.querySelector("#waterlevel-diagram-container").clientHeight +
          "px";
        this.drawDiagram();
      }
      var svg = document.getElementById(this.containerId).innerHTML;
      if (svg) {
        svg = svg.replace(/\r?\n|\r/g, "").trim();
      }
      this.containerId = "waterlevel-diagram-container";
      var canvas = document.createElement("canvas");
      canvas.width = window.innerWidth;
      canvas.height = window.innerHeight / 2;
      canvg(canvas, svg, {
        ignoreMouse: true,
        ignoreAnimation: true,
        ignoreDimensions: true
      });
      var imgData = canvas.toDataURL("image/png");
      // use default width,height if they are missing in the template definition
      if (!width) {
        width = this.templateData.properties.paperSize === "a3" ? 380 : 290;
      }
      if (!height) {
        height = this.templateData.properties.paperSize === "a3" ? 130 : 100;
      }
      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.pdf.doc.addImage(imgData, "PNG", x, y, width, height);
    },
    // Diagram legend
    addDiagramLegend(position, offset, color) {
      let x = offset.x;
      let y = offset.y;
      this.pdf.doc.setFontSize(10);
      let width =
        (this.pdf.doc.getStringUnitWidth("Navigable Range") * 10) /
          (72 / 25.6) +
        5;
      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(4);
      }
      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 + 3, y + 1, "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 + 3, y + 11, "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 + 3, y + 6, "Prediction");
    },
    drawDiagram() {
      // remove old diagram and exit if necessary data is missing
      d3.select("#" + this.containerId + " svg").remove();
      if (!this.selectedGauge || !this.waterlevels.length) return;

      // PREPARE HELPERS

      // HDC/LDC/MW for the selected gauge
      const refWaterLevels = JSON.parse(
        this.selectedGauge.properties.reference_water_levels
      );

      // dimensions (widths, heights, margins)
      this.dimensions = this.getDimensions();

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

      // scaling helpers
      this.scale = this.getScale();

      // creating the axes based on the scales
      this.axes = {
        x: d3
          .axisTop(this.scale.x)
          .tickSizeInner(this.dimensions.mainHeight)
          .tickSizeOuter(0),
        y: d3
          .axisRight(this.scale.y)
          .tickSizeInner(this.dimensions.width)
          .tickSizeOuter(0)
          .tickFormat(d => this.$options.filters.waterlevel(d)),
        x2: d3.axisBottom(this.scale.x2)
      };

      // DRAW DIAGRAM/NAVIGATION AREAS

      // create svg
      this.svg = d3
        .select("#" + this.containerId)
        .append("svg")
        .attr("width", "100%")
        .attr("height", "100%");

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

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

      // define visible area, everything outside this area will be hidden
      this.svg
        .append("defs")
        .append("clipPath")
        .attr("id", "waterlevel-clip")
        .append("rect")
        .attr("width", this.dimensions.width)
        .attr("height", this.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());
      updaters.push(this.drawWaterlevelChart());
      if (this.hasPredictions) {
        updaters.push(this.drawPredictionAreas());
      }
      updaters.push(this.drawNowLines());

      // static, don't need updater
      this.drawNavigationChart();
      if (refWaterLevels) {
        this.drawRefLines(refWaterLevels);
      }

      updaters.push(this.drawNashSutcliffe(72));
      updaters.push(this.drawNashSutcliffe(48));
      updaters.push(this.drawNashSutcliffe(24));

      // INTERACTIONS

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

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

      this.svg
        .selectAll(".hdc-line, .mw-line, .ldc-line, .rn-line")
        .attr("stroke-width", 1)
        .attr("fill", "none")
        .attr("clip-path", "url(#waterlevel-clip)");
      this.svg.selectAll(".hdc-line").attr("stroke", "red");
      this.svg.selectAll(".ldc-line").attr("stroke", "green");
      this.svg.selectAll(".mw-line").attr("stroke", "grey");
      this.svg.selectAll(".rn-line").attr("stroke", "grey");
      this.svg
        .selectAll(".ref-waterlevel-label")
        .style("font-size", "10px")
        .attr("fill", "black");
      this.svg
        .selectAll(".ref-waterlevel-label-background")
        .attr("fill", "rgb(255, 255, 255)")
        .attr("fill-opacity", 0.6);
      this.svg
        .selectAll(".hdc-ldc-area")
        .attr("fill", "rgb(0, 255, 0)")
        .attr("fill-opacity", 0.1);
      this.svg
        .selectAll(".now-line")
        .attr("stroke", "#999")
        .attr("stroke-width", 1)
        .attr("stroke-dasharray", "5, 5")
        .attr("clip-path", "url(#waterlevel-clip)");
      this.svg
        .selectAll(".now-line-label")
        .attr("font-size", "11px")
        .attr("fill", "#999");
      this.svg
        .selectAll(".prediction-area")
        .attr("fill", "steelblue")
        .attr("fill-opacity", 0.2)
        .attr("clip-path", "url(#waterlevel-clip)");
      this.svg
        .selectAll("path.nash-sutcliffe")
        .attr("fill", "none")
        .attr("stroke", "darkgrey")
        .attr("stroke-width", 1)
        .attr("clip-path", "url(#waterlevel-clip)");
      this.svg
        .selectAll("path.nash-sutcliffe.ns72")
        .attr("fill", "rgb(255, 255, 255)")
        .attr("fill-opacity", 0.5);
      this.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");
      this.svg
        .selectAll(".tick line")
        .attr("stroke-dasharray", 5)
        .attr("stroke", "#ccc");
      this.svg.selectAll(".tick text").attr("fill", "black");
      this.svg.selectAll(".domain").attr("stroke", "black");
      this.svg
        .selectAll(".zoom")
        .attr("cursor", "move")
        .attr("fill", "none")
        .attr("pointer-events", "all");
      this.svg
        .selectAll(".brush .selection")
        .attr("stroke", "none")
        .attr("fill-opacity", 0.2);
      this.svg
        .selectAll(".brush .handle")
        .attr("stroke", "rgba(23, 162, 184, 0.5)")
        .attr("fill", "rgba(23, 162, 184, 0.5)");
      this.svg
        .selectAll(".chart-dots")
        .attr("clip-path", "url(#waterlevel-clip)");
      this.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");
      this.svg
        .selectAll(".chart-tooltip")
        .attr("fill-opacity", 0)
        .transition()
        .attr("fill-opacity", "0.3s");
      this.svg
        .selectAll(".chart-tooltip rect")
        .attr("fill", "#fff")
        .attr("stroke", "#ccc");
      this.svg
        .selectAll(".chart-tooltip text")
        .attr("fill", "666")
        .style("font-size", "0.8em");
    },
    getDimensions() {
      // dimensions and margins
      const svgWidth = document.querySelector("#" + this.containerId)
        .clientWidth;
      const svgHeight = document.querySelector("#" + this.containerId)
        .clientHeight;
      const mainMargin = { top: 20, right: 20, bottom: 110, left: 80 };
      const navMargin = {
        top: svgHeight - mainMargin.top - 65,
        right: 20,
        bottom: 30,
        left: 80
      };
      const width = +svgWidth - mainMargin.left - mainMargin.right;
      const mainHeight = +svgHeight - mainMargin.top - mainMargin.bottom;
      const navHeight = +svgHeight - navMargin.top - navMargin.bottom;

      return { width, mainHeight, navHeight, mainMargin, navMargin };
    },
    getExtent(refWaterLevels) {
      let waterlevelValues = [...this.waterlevels.map(wl => wl.waterlevel)];
      if (refWaterLevels) {
        waterlevelValues.push(
          refWaterLevels.HDC + (refWaterLevels.HDC - refWaterLevels.LDC) / 8,
          Math.max(
            refWaterLevels.LDC - (refWaterLevels.HDC - refWaterLevels.LDC) / 4,
            0
          )
        );
      } else {
        let delta = d3.max(waterlevelValues) - d3.min(waterlevelValues);
        waterlevelValues.push(
          d3.max(waterlevelValues) + delta * 0.1,
          d3.min(waterlevelValues) - delta * 0.1
        );
      }

      return {
        // set min/max values for the date axis
        date: [
          this.waterlevels[0].date,
          endOfDay(this.waterlevels[this.waterlevels.length - 1].date)
        ],
        // set min/max values for the waterlevel axis
        // including HDC (+ 1/8 HDC-LDC) and LDC (- 1/4 HDC-LDC)
        // or, if no refWaterlevels exist, +-10% of delta between min and max wl
        waterlevel: d3.extent(waterlevelValues)
      };
    },
    getScale() {
      // scaling helpers to convert real world values into pixels
      const x = d3.scaleTime().range([0, this.dimensions.width]);
      const y = d3.scaleLinear().range([this.dimensions.mainHeight, 0]);
      const x2 = d3.scaleTime().range([0, this.dimensions.width]);
      const y2 = d3.scaleLinear().range([this.dimensions.navHeight, 0]);

      // setting the min and max values for the diagram axes
      x.domain(d3.extent(this.extent.date));
      y.domain(this.extent.waterlevel);
      x2.domain(x.domain());
      y2.domain(y.domain());

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

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

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

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

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

      return () => {
        this.diagram.select(".line").call(waterlevelChartDrawer());
      };
    },
    drawNavigationChart() {
      this.navigation
        .append("g")
        .attr("class", "line")
        .datum(this.waterlevels)
        .call(
          d3
            .lineChunked()
            .x(d => this.scale.x2(d.date))
            .y(d => this.scale.y2(d.waterlevel))
            .curve(d3.curveLinear)
            .isNext(this.isNext())
            .pointAttrs({ r: 1.7 })
            .chunk(d => (d.predicted ? "predicted" : "line"))
            .chunkDefinitions({ predicted: {} })
        );
    },
    drawNowLines() {
      const now = new Date();
      const nowCoords = [
        { x: now, y: this.extent.waterlevel[0] },
        { x: now, y: this.extent.waterlevel[1] }
      ];
      const nowLine = d3
        .line()
        .x(d => this.scale.x(d.x))
        .y(d => this.scale.y(d.y));
      const nowLabel = selection => {
        selection.attr(
          "transform",
          `translate(${this.scale.x(now)}, ${this.scale.y(
            this.extent.waterlevel[1]
          )})`
        );
      };

      // draw in main
      this.diagram
        .append("path")
        .datum(nowCoords)
        .attr("class", "now-line")
        .attr("d", nowLine);
      this.diagram // label
        .append("text")
        .text(this.$gettext("Now"))
        .attr("class", "now-line-label")
        .attr("text-anchor", "middle")
        .call(nowLabel);

      // draw in nav
      this.navigation
        .append("path")
        .datum(nowCoords)
        .attr("class", "now-line")
        .attr(
          "d",
          d3
            .line()
            .x(d => this.scale.x2(d.x))
            .y(d => this.scale.y2(d.y))
        );

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

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

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

      return () => {
        this.diagram.select(".prediction-area").attr("d", predictionArea());
      };
    },
    drawRefLines(refWaterLevels) {
      // filling area between HDC and LDC
      this.diagram
        .append("rect")
        .attr("class", "hdc-ldc-area")
        .attr("x", 0)
        .attr("y", this.scale.y(refWaterLevels.HDC))
        .attr("width", this.dimensions.width)
        .attr(
          "height",
          this.scale.y(refWaterLevels.LDC) - this.scale.y(refWaterLevels.HDC)
        );

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

      for (let ref in refWaterLevels) {
        if (refWaterLevels[ref]) {
          this.diagram
            .append("path")
            .datum([
              { x: this.extent.date[0], y: refWaterLevels[ref] },
              { x: this.extent.date[1], y: refWaterLevels[ref] }
            ])
            .attr("class", ref.toLowerCase() + "-line")
            .attr("d", refWaterlevelLine);
          this.diagram // label
            .append("rect")
            .attr("class", "ref-waterlevel-label-background")
            .attr("x", 1)
            .attr("y", this.scale.y(refWaterLevels[ref]) - 13)
            .attr("width", 55)
            .attr("height", 12);
          this.diagram
            .append("text")
            .text(
              `${ref} (${this.$options.filters.waterlevel(
                refWaterLevels[ref]
              )})`
            )
            .attr("class", "ref-waterlevel-label")
            .attr("x", 5)
            .attr("y", this.scale.y(refWaterLevels[ref]) - 3);
        }
      }
    },
    drawNashSutcliffe(hours) {
      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 => {
        // show/hide boxes depending on scale of chart (hide if > 90 days)
        this.diagram
          .selectAll("path.nash-sutcliffe")
          .attr(
            "stroke-opacity",
            this.scale.x.domain()[1] - this.scale.x.domain()[0] > 90 * 86400000
              ? 0
              : 1
          );

        return d3
          .area()
          .x(d => this.scale.x(d))
          .y0(() => this.dimensions.mainHeight + 0.5)
          .y1(() => this.dimensions.mainHeight - 15 * (hours / 24));
      };

      const nashSutcliffeLabel = (label, date, hours) => {
        let days = hours / 24;
        label
          .attr("x", Math.min(this.scale.x(date), this.dimensions.width) - 4)
          .attr("y", this.dimensions.mainHeight - (15 * days + 0.5) + 12);
      };

      if (coeff.samples) {
        this.diagram
          .append("path")
          .datum([dateStart, dateNow])
          .attr("class", "nash-sutcliffe ns" + hours)
          .attr("d", nashSutcliffeBox(hours));
        this.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 () => {
        this.diagram
          .select("path.nash-sutcliffe.ns" + hours)
          .attr("d", nashSutcliffeBox(hours));
        this.diagram
          .select("text.nash-sutcliffe.ns" + hours)
          .call(nashSutcliffeLabel, dateNow, hours);
      };
    },
    createZoom(updaters, eventRect) {
      const brush = d3
        .brushX()
        .handleSize(4)
        .extent([[0, 0], [this.dimensions.width, this.dimensions.navHeight]]);

      const zoom = d3
        .zoom()
        .scaleExtent([1, Infinity])
        .translateExtent([
          [0, 0],
          [this.dimensions.width, this.dimensions.mainHeight]
        ])
        .extent([[0, 0], [this.dimensions.width, this.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 || this.scale.x2.range();
        this.scale.x.domain(s.map(this.scale.x2.invert, this.scale.x2));
        updaters.forEach(u => u && u());
        this.setInlineStyles();
        this.svg
          .select(".zoom")
          .call(
            zoom.transform,
            d3.zoomIdentity
              .scale(this.dimensions.width / (s[1] - s[0]))
              .translate(-s[0], 0)
          );
      });

      zoom.on("zoom", () => {
        if (d3.event.sourceEvent && d3.event.sourceEvent.type === "brush")
          return; // ignore zoom-by-brush
        let t = d3.event.transform;
        this.scale.x.domain(t.rescaleX(this.scale.x2).domain());
        updaters.forEach(u => u && u());
        this.setInlineStyles();
        this.navigation
          .select(".brush")
          .call(brush.move, this.scale.x.range().map(t.invertX, t));
      });
      zoom.on("start", () => {
        this.svg.select(".chart-dot").style("opacity", 0);
        this.svg.select(".chart-tooltip").style("opacity", 0);
      });

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

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

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

      // 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", () => {
          this.diagram.select(".chart-dot").style("opacity", 1);
          this.diagram.select(".chart-tooltip").style("opacity", 1);
        })
        .on("mouseout", () => {
          this.diagram.select(".chart-dot").style("opacity", 0);
          this.diagram.select(".chart-tooltip").style("opacity", 0);
        })
        .on("mousemove", () => {
          // find data point closest to mouse
          const x0 = this.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: this.scale.x(d.date),
            y: this.scale.y(d.waterlevel)
          };

          // position the dot
          this.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();

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

          // position and scale tooltip box
          const xMax =
            this.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;
          }

          this.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() {
      // 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 (
          (this.scale.x.domain()[1] - this.scale.x.domain()[0]) / 86400000 >
          15
        )
          return [900, 3600].includes(difference);
        return difference === 900;
      };
    }
  },
  created() {
    this.resizeListenerFunction = debounce(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.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}`
        });
      });
  },
  destroyed() {
    window.removeEventListener("resize", this.resizeListenerFunction);
  }
};
</script>