view client/src/components/gauge/Waterlevel.vue @ 2805:eb91ad1d7a93

client: waterlevel diagram: optimized code for better readability
author Markus Kottlaender <markus@intevation.de>
date Mon, 25 Mar 2019 21:26:52 +0100
parents 8791becc40b1
children 97cf32cf2562
line wrap: on
line source

<template>
  <div
    class="d-flex flex-fill justify-content-center align-items-center diagram-container"
  >
    <div v-if="!waterlevels.length">
      <translate>No data available.</translate>
    </div>
  </div>
</template>

<style lang="sass" scoped>
.diagram-container
  /deep/
    .line
      clip-path: url(#clip)
      path
        stroke: steelblue
        stroke-width: 2
        fill: none
        &.d3-line-chunked-chunk-gap
          stroke-opacity: 0
      circle
        stroke-width: 0
        fill: steelblue
        &.d3-line-chunked-chunk-predicted-point
          fill-opacity: 0.6

    .hdc-line,
    .ldc-line,
    .mw-line
      stroke-width: 1
      fill: none
      clip-path: url(#clip)
    .hdc-line
      stroke: red
    .ldc-line
      stroke: green
    .mw-line
      stroke: grey
    .ref-waterlevel-label
      font-size: 11px
      fill: #999
    .hdc-ldc-area
      fill: rgba(0, 255, 0, 0.1)
    .now-line
      stroke: #999
      stroke-width: 1
      stroke-dasharray: 5, 5
      clip-path: url(#clip)
    .now-line-label
      font-size: 11px
      fill: #999
    .prediction-area
      fill: steelblue
      fill-opacity: 0.2
      clip-path: url(#clip)

    path.nash-sutcliffe
      fill: none
      stroke: black
      stroke-width: 1
      clip-path: url(#clip)
      &.ns72
        fill: rgba(0, 0, 0, 0.05)
    text.nash-sutcliffe
      font-size: 10px
      clip-path: url(#clip)
      tspan:last-child
        font-size: 9px
        fill: #777

    .tick
      line
        stroke-dasharray: 5
        stroke: #ccc

    .zoom
      cursor: move
      fill: none
      pointer-events: all
    .brush
      .selection
        stroke: none
        fill-opacity: 0.2
      .handle
        stroke: rgba($color-info, 0.5)
        fill: rgba($color-info, 0.5)

    .chart-dots
      clip-path: url(#clip)
      .chart-dot
        fill: steelblue
        stroke: steelblue
        pointer-events: none
        opacity: 0
        transition: opacity 0.1s
    .chart-tooltip
      opacity: 0
      transition: opacity 0.3s
      rect
        fill: #fff
        stroke: #ccc
      text
        fill: #666
        font-size: 12px
        tspan:last-child
          font-weight: bold
</style>

<script>
/* This is Free Software under GNU Affero General Public License v >= 3.0
 * without warranty, see README.md and license for details.
 *
 * SPDX-License-Identifier: AGPL-3.0-or-later
 * License-Filename: LICENSES/AGPL-3.0.txt
 *
 * Copyright (C) 2018 by via donau
 *   – Österreichische Wasserstraßen-Gesellschaft mbH
 * Software engineering by Intevation GmbH
 *
 * Author(s):
 * Markus Kottländer <markus.kottlaender@intevation.de>
 */

import { mapState, mapGetters } from "vuex";
import * as d3Base from "d3";
import { lineChunked } from "d3-line-chunked";
import { startOfDay, endOfDay } from "date-fns";
import debounce from "debounce";

// 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 {
  data() {
    return {
      svg: null,
      diagram: null,
      navigation: null,
      dimensions: null,
      extent: null,
      scale: null,
      axes: null
    };
  },
  computed: {
    ...mapState("gauges", [
      "dateFrom",
      "dateTo",
      "waterlevels",
      "nashSutcliffe"
    ]),
    ...mapGetters("gauges", ["selectedGauge"])
  },
  methods: {
    drawDiagram() {
      // remove old diagram and exit if necessary data is missing
      d3.select(".diagram-container 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(".diagram-container")
        .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", "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
      this.drawRefLines(refWaterLevels); // static, doesn't need an updater
      updaters.push(this.drawAxes());
      updaters.push(this.drawWaterlevelCharts());
      updaters.push(this.drawNowLines());
      updaters.push(this.drawPredictionAreas());
      updaters.push(this.drawNashSutcliffe(24));
      updaters.push(this.drawNashSutcliffe(48));
      updaters.push(this.drawNashSutcliffe(72));

      // INTERACTIONS

      // create rectanlge on the main chart area to capture mouse events
      const eventRect = this.svg
        .append("rect")
        .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);
    },
    getDimensions() {
      // dimensions and margins
      const svgWidth = document.querySelector(".diagram-container").clientWidth;
      const svgHeight = document.querySelector(".diagram-container")
        .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: [startOfDay(this.dateFrom), endOfDay(this.dateTo)],
        // 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()
          .x(d => this.scale[isNav ? "x2" : "x"](d.date))
          .y(d => this.scale[isNav ? "y2" : "y"](d.waterlevel))
          .curve(d3.curveLinear)
          .isNext(this.isNext(900))
          .pointAttrs({ r: isNav ? 1.7 : 2.2 })
          .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));

      // HDC
      this.diagram
        .append("path")
        .datum([
          { x: 0, y: refWaterLevels.HDC },
          { x: this.extent.date[1], y: refWaterLevels.HDC }
        ])
        .attr("class", "hdc-line")
        .attr("d", refWaterlevelLine);
      this.diagram // label
        .append("text")
        .text(`HDC (${refWaterLevels.HDC})`)
        .attr("class", "ref-waterlevel-label")
        .attr("x", 5)
        .attr("y", this.scale.y(refWaterLevels.HDC) - 3);
      // LDC
      this.diagram
        .append("path")
        .datum([
          { x: 0, y: refWaterLevels.LDC },
          { x: this.extent.date[1], y: refWaterLevels.LDC }
        ])
        .attr("class", "ldc-line")
        .attr("d", refWaterlevelLine);
      this.diagram // label
        .append("text")
        .text(`LDC (${refWaterLevels.LDC})`)
        .attr("class", "ref-waterlevel-label")
        .attr("x", 5)
        .attr("y", this.scale.y(refWaterLevels.LDC) - 3);
      // MW
      this.diagram
        .append("path")
        .datum([
          { x: 0, y: refWaterLevels.MW },
          { x: this.extent.date[1], y: refWaterLevels.MW }
        ])
        .attr("class", "mw-line")
        .attr("d", refWaterlevelLine);
      this.diagram // label
        .append("text")
        .text(`MW (${refWaterLevels.MW})`)
        .attr("class", "ref-waterlevel-label")
        .attr("x", 5)
        .attr("y", this.scale.y(refWaterLevels.MW) - 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.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.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) {
      let dots = this.diagram.append("g").attr("class", "chart-dots");
      dots
        .append("circle")
        .attr("class", "chart-dot")
        .attr("r", 4);
      let tooltips = this.diagram.append("g").attr("class", "chart-tooltip");
      tooltips
        .append("rect")
        .attr("x", -25)
        .attr("y", -25)
        .attr("rx", 4)
        .attr("ry", 4)
        .attr("width", 105)
        .attr("height", 40);
      let tooltipText = tooltips.append("text");
      tooltipText
        .append("tspan")
        .attr("x", -15)
        .attr("y", -8);
      tooltipText
        .append("tspan")
        .attr("x", 8)
        .attr("y", 8);

      eventRect
        .on("mouseover", () => {
          this.svg.select(".chart-dot").style("opacity", 1);
          this.svg.select(".chart-tooltip").style("opacity", 1);
        })
        .on("mouseout", () => {
          this.svg.select(".chart-dot").style("opacity", 0);
          this.svg.select(".chart-tooltip").style("opacity", 0);
        })
        .on("mousemove", () => {
          let x0 = this.scale.x.invert(
              d3.mouse(document.querySelector(".zoom"))[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;

          this.svg
            .select(".chart-dot")
            .style("opacity", 1)
            .attr(
              "transform",
              `translate(${this.scale.x(d.date)}, ${this.scale.y(
                d.waterlevel
              )})`
            );
          this.svg
            .select(".chart-tooltip")
            .style("opacity", 1)
            .attr(
              "transform",
              `translate(${this.scale.x(d.date) - 25}, ${this.scale.y(
                d.waterlevel
              ) - 25})`
            );
          this.svg.select(".chart-tooltip text tspan:first-child").text(
            d.date.toLocaleString([], {
              year: "2-digit",
              month: "2-digit",
              day: "2-digit",
              hour: "2-digit",
              minute: "2-digit"
            })
          );
          this.svg
            .select(".chart-tooltip text tspan:last-child")
            .text(d.waterlevel + " cm");
        });
    },
    isNext(seconds) {
      // helper to check whether points in the chart are "next to each other"
      // for that they need to be exactly the specified amount of seconds apart.
      return (prev, current) => current.date - prev.date === seconds * 1000;
    }
  },
  created() {
    window.addEventListener("resize", debounce(this.drawDiagram), 100);
  },
  updated() {
    this.drawDiagram();
  }
};
</script>