view client/src/components/gauge/HydrologicalConditions.vue @ 3222:83e8e1ea0aff

print_templates: changed route /templates/print to /templates and /templates/print/... to /templates/
author Thomas Junk <thomas.junk@intevation.de>
date Thu, 09 May 2019 15:14:12 +0200
parents f87fd173f750
children 5e773e0c05be
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: red"></span>
          {{ yearCompare }}
        </div>
        <div class="legend">
          <span style="background-color: orange"></span> Q25%
        </div>
        <div class="legend">
          <span style="background-color: black"></span> Median
        </div>
        <div class="legend">
          <span style="background-color: purple"></span> Q75%
        </div>
        <div class="legend">
          <span style="background-color: lightsteelblue"></span>
          Long-term Amplitude
        </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>
        <div>
          <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="!longtermWaterlevels.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):
 * Markus Kottländer <markus.kottlaender@intevation.de>
 * Fadi Abbud <fadi.abbud@intevation.de>
 */

import { mapState, mapGetters } from "vuex";
import * as d3 from "d3";
import debounce from "debounce";
import { startOfYear, endOfYear } from "date-fns";
import jsPDF from "jspdf";
import canvg from "canvg";
import { pdfgen } from "@/lib/mixins";
import { HTTP } from "@/lib/http";
import { displayError } from "@/lib/errors";

export default {
  mixins: [pdfgen],
  components: {
    DiagramLegend: () => import("@/components/DiagramLegend")
  },
  data() {
    return {
      containerId: "hydrologicalconditions-diagram-container",
      svg: null,
      diagram: null,
      navigation: null,
      dimensions: null,
      extent: null,
      scale: null,
      axes: null,
      templateData: null,
      form: {
        template: null,
        form: 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 },
            colot: "black"
          },
          {
            type: "diagramtitle",
            position: "topleft",
            offset: { x: 50, y: 26 },
            fontsize: 22,
            color: "steelblue"
          }
        ]
      },
      pdf: {
        doc: null,
        width: 420,
        height: 297
      }
    };
  },
  computed: {
    ...mapState("application", ["paneSetup"]),
    ...mapState("gauges", [
      "longtermWaterlevels",
      "yearWaterlevels",
      "yearCompare",
      "longtermInterval"
    ]),
    ...mapGetters("gauges", ["selectedGauge"]),
    title() {
      return `${this.selectedGauge.properties.objname}: ${this.$gettext(
        "Hydrological Conditions"
      )} (${this.longtermInterval.join(" - ")})`;
    }
  },
  watch: {
    paneSetup() {
      this.$nextTick(() => this.drawDiagram());
    },
    longtermWaterlevels() {
      this.drawDiagram();
    },
    yearWaterlevels() {
      this.drawDiagram();
    }
  },
  methods: {
    close() {
      this.$store.commit(
        "application/paneSetup",
        this.paneSetup === "GAUGE_WATERLEVEL_HYDROLOGICALCONDITIONS"
          ? "GAUGE_WATERLEVEL"
          : "DEFAULT"
      );
    },
    downloadPDF() {
      if (this.templateData) {
        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;
        // default values if some are missing in template
        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 "diagramtitle": {
              this.addDiagramTitle(e.position, e.offset, e.fontsize, e.color);
              break;
            }
            case "diagramlegend": {
              this.addDiagramLegend(
                e.position,
                e.offset || defaultOffset,
                e.color || defaultColor
              );
              break;
            }
            case "image": {
              this.addImage(
                e.url,
                e.format,
                e.position,
                e.offset || defaultOffset,
                e.width,
                e.height
              );
              break;
            }
            case "text": {
              this.addText(
                e.position,
                e.offset || defaultOffset,
                e.width || defaultWidth,
                e.fontSize || defaultFontSize,
                e.color || defaultTextColor,
                e.text
              );
              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 +
          " Hydrological-condition Diagram.pdf"
      );
    },
    addDiagram(position, offset, width, height) {
      let x = offset.x,
        y = offset.y;
      var svg = document.getElementById(this.containerId).innerHTML;
      if (svg) {
        svg = svg.replace(/\r?\n|\r/g, "").trim();
      }
      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);
    },
    applyChange() {
      if (this.form.template.hasOwnProperty("properties")) {
        this.templateData = this.defaultTemplate;
        return;
      }
      if (this.form.template) {
        HTTP.get("/templates/" + 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;
            this.form.paperSize = this.templateData.properties.paperSize;
          })
          .catch(e => {
            const { status, data } = e.response;
            displayError({
              title: this.$gettext("Backend Error"),
              message: `${status}: ${data.message || data}`
            });
          });
      }
    },
    // Gauge info as title
    addDiagramTitle(position, offset, size, color) {
      let x = offset.x,
        y = offset.y;
      let gaugeInfo =
        this.selectedGauge.properties.objname +
        " (" +
        this.selectedGauge.id
          .split(".")[1]
          .replace(/[()]/g, "")
          .split(",")[3] +
        "): Hydrological Conditions " +
        this.longtermInterval.join(" - ");
      let width =
        (this.pdf.doc.getStringUnitWidth(gaugeInfo) * size) / (72 / 25.6) +
        size / 2;
      // if position is on the right, x needs to be calculate with pdf width and
      // the size of the element
      if (["topright", "bottomright"].indexOf(position) !== -1) {
        x = this.pdf.width - offset.x - width;
      }
      if (["bottomright", "bottomleft"].indexOf(position) !== -1) {
        y = this.pdf.height - offset.y - this.getTextHeight(1);
      }
      this.pdf.doc.setTextColor(color);
      this.pdf.doc.setFontSize(size);
      this.pdf.doc.setFontStyle("bold");
      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,
        y = offset.y;
      let width =
        (this.pdf.doc.getStringUnitWidth("Long-term Amplitude") * 10) /
          (72 / 25.6) +
        5;
      // if position is on the right, x needs to be calculate with pdf width and
      // the size of the element
      if (["topright", "bottomright"].indexOf(position) !== -1) {
        x = this.pdf.width - offset.x - width;
      }
      if (["bottomright", "bottomleft"].indexOf(position) !== -1) {
        y = this.pdf.height - offset.y - this.getTextHeight(4);
      }
      this.pdf.doc.setFontSize(10);
      this.pdf.doc.setTextColor(color);
      this.pdf.doc.setDrawColor("white");
      this.pdf.doc.setFillColor("red");
      this.pdf.doc.circle(x, y, 2, "FD");
      this.pdf.doc.text(x + 3, y + 1, "" + this.yearCompare);
      this.pdf.doc.setFillColor("orange");
      this.pdf.doc.circle(x, y + 5, 2, "FD");
      this.pdf.doc.text(x + 3, y + 6, "Q25%");
      this.pdf.doc.setFillColor("black");
      this.pdf.doc.circle(x, y + 10, 2, "FD");
      this.pdf.doc.text(x + 3, y + 11, "Median ");
      this.pdf.doc.setFillColor("purple");
      this.pdf.doc.circle(x, y + 15, 2, "FD");
      this.pdf.doc.text(x + 3, y + 16, "Q75%");
      this.pdf.doc.setFillColor("lightsteelblue");
      this.pdf.doc.circle(x, y + 20, 2, "FD");
      this.pdf.doc.text(x + 3, y + 21, "Long-term Amplitude");
    },
    drawDiagram() {
      // remove old diagram
      d3.select("#" + this.containerId + " svg").remove();
      if (!this.selectedGauge || !this.longtermWaterlevels.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 = this.getAxes();

      // 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", "hydrocond-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.drawWaterlevelMinMaxAreaChart());
      updaters.push(this.drawWaterlevelLineChart("median"));
      updaters.push(this.drawWaterlevelLineChart("q25"));
      updaters.push(this.drawWaterlevelLineChart("q75"));
      updaters.push(this.drawWaterlevelLineChart("mean", this.yearWaterlevels));
      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-hydrocond")
        .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();
    },
    setInlineStyles() {
      this.svg.selectAll(".hide").attr("fill-opacity", 0);
      this.svg
        .selectAll(".line")
        .attr("clip-path", "url(#hydrocond-clip)")
        .attr("stroke-width", 2)
        .attr("fill", "none");
      this.svg.selectAll(".line.mean").attr("stroke", "red");
      this.svg.selectAll(".line.median").attr("stroke", "black");
      this.svg.selectAll(".line.q25").attr("stroke", "orange");
      this.svg.selectAll(".line.q75").attr("stroke", "purple");
      this.svg
        .selectAll(".area")
        .attr("clip-path", "url(#hydrocond-clip)")
        .attr("stroke", "none")
        .attr("fill", "lightsteelblue");
      this.svg
        .selectAll(".hdc-line, .ldc-line, .mw-line, .rn-line")
        .attr("stroke-width", 1)
        .attr("fill", "none")
        .attr("clip-path", "url(#hydrocond-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(".now-line")
        .attr("stroke", "#999")
        .attr("stroke-width", 1)
        .attr("stroke-dasharray", "5, 5")
        .attr("clip-path", "url(#hydrocond-clip)");
      this.svg
        .selectAll(".now-line-label")
        .attr("fill", "#999")
        .style("font-size", "11px");
      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(#hydrocond-clip)");
      this.svg
        .selectAll(".chart-dots .chart-dot")
        .attr("fill", "black")
        .attr("stroke", "black")
        .attr("stroke-opacity", 0)
        .style("pointer-events", "none")
        .attr("fill-opacity", 0)
        .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) {
      const waterlevelsRelevantForExtent = [];
      this.longtermWaterlevels.forEach(wl => {
        waterlevelsRelevantForExtent.push(wl.min, wl.max);
      });
      waterlevelsRelevantForExtent.push(
        refWaterLevels.HDC + (refWaterLevels.HDC - refWaterLevels.LDC) / 8,
        Math.max(
          refWaterLevels.LDC - (refWaterLevels.HDC - refWaterLevels.LDC) / 4,
          0
        )
      );
      return {
        // set min/max values for the date axis
        date: [startOfYear(new Date()), endOfYear(new Date())],
        // set min/max values for the waterlevel axis
        // including HDC (+ 1/8 HDC-LDC) and LDC (- 1/4 HDC-LDC)
        waterlevel: d3.extent(waterlevelsRelevantForExtent)
      };
    },
    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 };
    },
    getAxes() {
      return {
        x: d3
          .axisTop(this.scale.x)
          .tickSizeInner(this.dimensions.mainHeight)
          .tickSizeOuter(0)
          .tickFormat(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("%I:%M")
              : d3.timeDay(date) < date
              ? d3.timeFormat("%I %p")
              : d3.timeMonth(date) < date
              ? d3.timeWeek(date) < date
                ? d3.timeFormat("%a %d")
                : d3.timeFormat("%b %d")
              : d3.timeFormat("%B"))(date);
          }),
        y: d3
          .axisRight(this.scale.y)
          .tickSizeInner(this.dimensions.width)
          .tickSizeOuter(0),
        x2: d3.axisBottom(this.scale.x2)
      };
    },
    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);
      };
    },
    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);
      };
    },
    drawWaterlevelMinMaxAreaChart() {
      const areaChart = isNav =>
        d3
          .area()
          .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.longtermWaterlevels)
        .attr("class", "area")
        .attr("d", areaChart());

      this.navigation
        .append("path")
        .datum(this.longtermWaterlevels)
        .attr("class", "area")
        .attr("d", areaChart(true));

      return () => {
        this.diagram.select(".area").attr("d", areaChart());
      };
    },
    drawWaterlevelLineChart(type, data) {
      const lineChart = type =>
        d3
          .line()
          .x(d => this.scale.x(d.date))
          .y(d => this.scale.y(d[type]))
          .curve(d3.curveLinear);
      this.diagram
        .append("path")
        .attr("class", "line " + type)
        .datum(data || this.longtermWaterlevels)
        .attr("d", lineChart(type));

      return () => {
        this.diagram.select(".line." + type).attr("d", lineChart(type));
      };
    },
    drawRefLines(refWaterLevels) {
      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);
        }
      }
    },
    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-hydrocond"))[0]
            ),
            i = d3.bisector(d => d.date).left(this.longtermWaterlevels, x0, 1),
            d0 = this.longtermWaterlevels[i - 1],
            d1 = this.longtermWaterlevels[i] || d0,
            d = x0 - d0.date > d1.date - x0 ? d1 : d0;

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

          // 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"
              })
            );

          tooltipText
            .append("tspan")
            .attr("x", 0)
            .attr("y", 0)
            .attr("dy", "1.4em")
            .attr("dominant-baseline", "hanging")
            .attr("text-anchor", "middle")
            .text(`Q75%: ${d.q75} cm`);
          tooltipText
            .append("tspan")
            .attr("x", 0)
            .attr("y", 0)
            .attr("dy", "2.6em")
            .attr("dominant-baseline", "hanging")
            .attr("text-anchor", "middle")
            .text(`Median: ${d.median} cm`);
          tooltipText
            .append("tspan")
            .attr("x", 0)
            .attr("y", 0)
            .attr("dy", "3.8em")
            .attr("dominant-baseline", "hanging")
            .attr("text-anchor", "middle")
            .text(`Q25%: ${d.q25} cm`);
          tooltipText
            .append("tspan")
            .attr("x", 0)
            .attr("y", 0)
            .attr("dy", "5em")
            .attr("dominant-baseline", "hanging")
            .attr("text-anchor", "middle")
            .text(`Max: ${d.max} cm`);
          tooltipText
            .append("tspan")
            .attr("x", 0)
            .attr("y", 0)
            .attr("dy", "6.2em")
            .attr("dominant-baseline", "hanging")
            .attr("text-anchor", "middle")
            .text(`Min: ${d.min} cm`);

          const dYear = this.yearWaterlevels.find(
            ywl => ywl.date.getTime() === d.date.getTime()
          );
          if (dYear) {
            tooltipText
              .append("tspan")
              .attr("x", 0)
              .attr("y", 0)
              .attr("dy", "7.4em")
              .attr("dominant-baseline", "hanging")
              .attr("text-anchor", "middle")
              .text(`${this.yearCompare}: ${dYear.mean.toFixed(1)} 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);
        });
    }
  },
  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", {
      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>