view client/src/components/gauge/Waterlevel.vue @ 3252:fccb28813159

client: wterlevel diagram: improved performance By not rendering points that are outside of the visible area of the chart, performance was significantly improved. But still the chart is not really very responsive and smooth when viewing large data sets.
author Markus Kottlaender <markus@intevation.de>
date Tue, 14 May 2019 12:24:14 +0200
parents d91c1200dc6b
children 3a7b6eb162db
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>
        <div class="legend">
          <span style="background-color: steelblue"></span> Waterlevel
        </div>
        <div class="legend">
          <span
            style="width: 4px; height: 4px; background-color: rgba(70, 130, 180, 0.6); border: solid 4px 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)"></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"
          >
            <translate>Export to PDF</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: 600px;"
    ></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):
 * 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 { 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",
      svg: null,
      diagram: null,
      navigation: null,
      dimensions: null,
      extent: null,
      scale: null,
      axes: null,
      form: {
        template: null
      },
      templates: [],
      defaultTemplate: {
        name: "Default",
        properties: {
          paperSize: "a4",
          resolution: "80"
        },
        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", "nashSutcliffe"]),
    ...mapGetters("gauges", ["selectedGauge"]),
    ...mapState("user", ["user"]),
    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;
      }
    }
  },
  watch: {
    paneSetup() {
      this.$nextTick(() => this.drawDiagram());
    },
    waterlevels() {
      this.drawDiagram();
    }
  },
  methods: {
    close() {
      this.$store.commit(
        "application/paneSetup",
        this.paneSetup === "GAUGE_WATERLEVEL_HYDROLOGICALCONDITIONS"
          ? "GAUGE_HYDROLOGICALCONDITIONS"
          : "DEFAULT"
      );
    },
    downloadPDF() {
      this.pdf.doc = new jsPDF(
        "l",
        "mm",
        this.templateData.properties.paperSize
      );
      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": {
              this.addDiagramTitle(e.position, e.offset, e.fontsize, e.color);
              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,
                e.height
              );
              break;
            }
            case "box": {
              this.addBox(
                e.position,
                e.offset,
                e.width,
                e.height,
                e.rounding === 0 || e.rounding ? e.rounding : defaultRounding,
                e.color || defaultBgColor,
                e.brcolor || defaultBorderColor
              );
              break;
            }
            case "textbox": {
              this.addTextBox(
                e.position,
                e.offset || defaultOffset,
                e.width,
                e.height,
                e.rounding === 0 || e.rounding ? e.rounding : defaultRounding,
                e.padding || defaultPadding,
                e.fontsize || defaultFontSize,
                e.color || defaultTextColor,
                e.background || defaultBgColor,
                e.text,
                e.brcolor || defaultBorderColor
              );
              break;
            }
          }
        });
      }
      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 +
          "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");
      // landscape format is used for both a3,a4 papersize
      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);
    },
    // Gauge info as a title for pdf
    addDiagramTitle(position, offset, size, color) {
      let gaugeInfo =
        this.selectedGauge.properties.objname +
        " (" +
        this.selectedGauge.id
          .split(".")[1]
          .replace(/[()]/g, "")
          .split(",")[3] +
        "):" +
        " Waterlevel " +
        this.dateFrom.toLocaleDateString() +
        " - " +
        this.dateTo.toLocaleDateString();
      let x = offset.x;
      let y = offset.y;
      this.pdf.doc.setTextColor(color);
      this.pdf.doc.setFontSize(size);
      this.pdf.doc.setFontStyle("bold");
      let width = this.pdf.doc.getTextWidth(gaugeInfo) + size / 2;

      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(1);
      }
      this.pdf.doc.text(gaugeInfo, x, y, {
        baseline: "hanging"
      });
    },
    getTextHeight(numberOfLines) {
      return (
        numberOfLines *
        ((this.pdf.doc.getFontSize() * 25.4) / 80) *
        this.pdf.doc.getLineHeightFactor()
      );
    },
    // Diagram legend
    addDiagramLegend(position, offset, color) {
      let x = offset.x;
      let y = offset.y;
      let width = this.pdf.doc.getTextWidth("Navigable Range") + 12;
      let height = 15;
      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.setFontSize(10);
      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),
        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
      updaters.push(this.drawAxes());
      updaters.push(this.drawWaterlevelCharts());
      updaters.push(this.drawPredictionAreas());
      updaters.push(this.drawNashSutcliffe(24));
      updaters.push(this.drawNashSutcliffe(48));
      updaters.push(this.drawNashSutcliffe(72));
      updaters.push(this.drawNowLines());
      this.drawRefLines(refWaterLevels); // static, doesn't need an updater

      // 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", "rgba(255, 255, 255, 0.6)");
      this.svg.selectAll(".hdc-ldc-area").attr("fill", "rgba(0, 255, 0, 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", "black")
        .attr("stroke-width", 1)
        .attr("clip-path", "url(#waterlevel-clip)");
      this.svg
        .selectAll("path.nash-sutcliffe.ns72")
        .attr("fill", "rgba(0, 0, 0, 0.05)");
      this.svg
        .selectAll("text.nash-sutcliffe")
        .style("font-size", "10px")
        .attr("clip-path", "url(#waterlevel-clip)")
        .selectAll("tspan:last-child")
        .style("font-size", "9px")
        .attr("fill", "#777");
      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) {
      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)
        waterlevel: d3.extent(
          [
            ...this.waterlevels,
            {
              waterlevel:
                refWaterLevels.HDC +
                (refWaterLevels.HDC - refWaterLevels.LDC) / 8
            },
            {
              waterlevel: Math.max(
                refWaterLevels.LDC -
                  (refWaterLevels.HDC - refWaterLevels.LDC) / 4,
                0
              )
            }
          ],
          d => d.waterlevel
        )
      };
    },
    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 [cm]"))
        .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);
      };
    },
    drawWaterlevelCharts() {
      const waterlevelChartDrawer = isNav => {
        return d3
          .lineChunked()
          .defined(
            // render only data points that are visible in the current scale
            d => {
              let domainLeft = new Date(this.scale.x.domain()[0].getTime());
              domainLeft.setDate(domainLeft.getDate() - 1);
              let domainRight = new Date(this.scale.x.domain()[1].getTime());
              domainRight.setDate(domainRight.getDate() + 1);
              return d.date > domainLeft && d.date < domainRight;
            }
          )
          .x(d => this.scale[isNav ? "x2" : "x"](d.date))
          .y(d => this.scale[isNav ? "y2" : "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());

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

      return () => {
        this.diagram.select(".line").call(waterlevelChartDrawer());
      };
    },
    drawNowLines() {
      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(new Date())}, ${this.scale.y(
            this.extent.waterlevel[1] - 16
          )})`
        );
      };

      // draw in main
      this.diagram
        .append("path")
        .datum([
          { x: new Date(), y: this.extent.waterlevel[0] },
          { x: new Date(), y: this.extent.waterlevel[1] - 20 }
        ])
        .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([
          { x: new Date(), y: this.extent.waterlevel[0] },
          { x: new Date(), y: this.extent.waterlevel[1] - 20 }
        ])
        .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: 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} (${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 => {
        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", this.scale.x(date) + 3)
          .attr("y", this.dimensions.mainHeight - (15 * days + 0.5) + 12);
      };

      // Show nash-sutcliffe only when x-axis extent is smaller than 35 days
      // (3024000000 ms). Since it shows squares representing 1, 2 and 3 days
      // it does not make sense to show them on a x-axis with hundres of days.
      if (
        coeff.samples &&
        this.scale.x.domain()[1] - this.scale.x.domain()[0] < 3024000000
      ) {
        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)
          .call(nashSutcliffeLabel, dateStart, hours)
          .append("tspan")
          .text(coeff.value.toFixed(2))
          .select(function() {
            return this.parentNode;
          })
          .append("tspan")
          .text(` (${coeff.samples})`)
          .attr("dy", -1);

        return () => {
          this.diagram
            .select("path.nash-sutcliffe.ns" + hours)
            .attr("d", nashSutcliffeBox(hours));
          this.diagram
            .select("text.nash-sutcliffe.ns" + hours)
            .call(nashSutcliffeLabel, dateStart, 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(d.max + " cm");
            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(d.waterlevel + " cm");
            tooltipText
              .append("tspan")
              .attr("x", 0)
              .attr("y", 0)
              .attr("dy", "3.8em")
              .attr("dominant-baseline", "hanging")
              .attr("text-anchor", "middle")
              .text(d.min + " cm");
          } 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(d.waterlevel + " cm");
          }

          // 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() {
    window.addEventListener("resize", debounce(this.drawDiagram), 100);
  },
  mounted() {
    this.drawDiagram();
    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}`
        });
      });
  },
  updated() {
    this.drawDiagram();
  },
  destroyed() {
    window.removeEventListener("resize", debounce(this.drawDiagram));
  }
};
</script>