view client/src/components/gauge/Waterlevel.vue @ 2816:c02cebff3f16

client: SPUC7/8: fix tooltip size and positioning The size is now dynamically calculated based on the content and the tooltip is now guaranteed to be visible instead of reaching out of the viewport.
author Markus Kottlaender <markus@intevation.de>
date Tue, 26 Mar 2019 19:37:55 +0100
parents 97cf32cf2562
children 53c2bd009c68
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) {
      // 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");

      const padding = 5;

      // create container for multiple text rows
      const tooltipText = tooltip.append("text").attr("text-anchor", "middle");
      tooltipText.append("tspan").attr("alignment-baseline", "hanging");
      tooltipText
        .append("tspan")
        .attr("dy", 18)
        .attr("alignment-baseline", "hanging");

      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.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;

          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})`);

          // write date
          this.diagram.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"
            })
          );
          // write waterlevel
          this.diagram
            .select(".chart-tooltip text tspan:last-child")
            .text(d.waterlevel + " cm");

          // get text dimensions
          const textBBox = this.diagram
            .select(".chart-tooltip text")
            .node()
            .getBBox();

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

          // position and scale tooltip box

          let xMax = this.dimensions.width - textBBox.width;
          let tooltipX = Math.min(coords.x - textBBox.width / 2, xMax);
          let tooltipY = coords.y - textBBox.height * 2 + padding * 2;
          this.diagram
            .select(".chart-tooltip")
            .style("opacity", 1)
            .attr("transform", `translate(${tooltipX}, ${tooltipY})`)
            .select("rect")
            .attr("width", textBBox.width + padding * 2)
            .attr("height", textBBox.height + padding * 2);
        });
    },
    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);
  },
  mounted() {
    this.drawDiagram();
  },
  updated() {
    this.drawDiagram();
  }
};
</script>