changeset 3869:7d86beedfb00

hydrological conditions: factor out side-effects from diagram rendering
author Thomas Junk <thomas.junk@intevation.de>
date Tue, 09 Jul 2019 16:50:36 +0200
parents 91b4ca03174e
children f52f9d2dc8bd
files client/src/components/gauge/HydrologicalConditions.vue
diffstat 1 files changed, 171 insertions(+), 165 deletions(-) [+]
line wrap: on
line diff
--- a/client/src/components/gauge/HydrologicalConditions.vue	Tue Jul 09 16:26:54 2019 +0200
+++ b/client/src/components/gauge/HydrologicalConditions.vue	Tue Jul 09 16:50:36 2019 +0200
@@ -118,13 +118,6 @@
     return {
       containerId: "hydrologicalconditions-diagram-container",
       resizeListenerFunction: null,
-      svg: null,
-      diagram: null,
-      navigation: null,
-      dimensions: null,
-      extent: null,
-      scale: null,
-      axes: null,
       templateData: null,
       form: {
         template: null,
@@ -354,56 +347,54 @@
       );
 
       // dimensions (widths, heights, margins)
-      this.dimensions = this.getDimensions();
+      const dimensions = this.getDimensions();
 
       // get min/max values for date and waterlevel axis
-      this.extent = this.getExtent(refWaterLevels);
+      const extent = this.getExtent(refWaterLevels);
 
       // scaling helpers
-      this.scale = this.getScale();
+      const scale = this.getScale({ dimensions, extent });
 
       // creating the axes based on the scales
-      this.axes = this.getAxes();
+      const axes = this.getAxes({ scale, dimensions });
 
       // DRAW DIAGRAM/NAVIGATION AREAS
 
       // create svg
-      this.svg = d3
+      const svg = d3
         .select(element)
         .append("svg")
         .attr("width", "100%")
         .attr("height", "100%");
 
       // create container for main diagram
-      this.diagram = this.svg
+      const diagram = svg
         .append("g")
         .attr("class", "main")
         .attr(
           "transform",
-          `translate(${this.dimensions.mainMargin.left}, ${
-            this.dimensions.mainMargin.top
+          `translate(${dimensions.mainMargin.left}, ${
+            dimensions.mainMargin.top
           })`
         );
 
       // create container for navigation diagram
-      this.navigation = this.svg
+      const navigation = svg
         .append("g")
         .attr("class", "nav")
         .attr(
           "transform",
-          `translate(${this.dimensions.navMargin.left}, ${
-            this.dimensions.navMargin.top
-          })`
+          `translate(${dimensions.navMargin.left}, ${dimensions.navMargin.top})`
         );
 
       // define visible area, everything outside this area will be hidden
-      this.svg
+      svg
         .append("defs")
         .append("clipPath")
         .attr("id", "hydrocond-clip")
         .append("rect")
-        .attr("width", this.dimensions.width)
-        .attr("height", this.dimensions.mainHeight);
+        .attr("width", dimensions.width)
+        .attr("height", dimensions.mainHeight);
 
       // DRAW DIAGRAM PARTS
 
@@ -413,104 +404,124 @@
       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());
+      updaters.push(this.drawAxes({ diagram, dimensions, axes, navigation }));
+      updaters.push(
+        this.drawWaterlevelMinMaxAreaChart({ scale, diagram, navigation })
+      );
+      updaters.push(
+        this.drawWaterlevelLineChart({ type: "median", scale, diagram })
+      );
+      updaters.push(
+        this.drawWaterlevelLineChart({ type: "q25", scale, diagram })
+      );
+      updaters.push(
+        this.drawWaterlevelLineChart({ type: "q75", scale, diagram })
+      );
+      updaters.push(
+        this.drawWaterlevelLineChart({
+          type: "mean",
+          data: this.yearWaterlevels,
+          scale,
+          diagram
+        })
+      );
+      updaters.push(this.drawNowLines({ extent, diagram, scale, navigation }));
 
       if (refWaterLevels) {
-        this.drawRefLines(refWaterLevels); // static, doesn't need an updater
+        this.drawRefLines({ refWaterLevels, scale, diagram, extent }); // static, doesn't need an updater
       }
 
       // INTERACTIONS
 
       // create rectanlge on the main chart area to capture mouse events
-      const eventRect = this.svg
+      const eventRect = svg
         .append("rect")
         .attr("id", "zoom-hydrocond")
         .attr("class", "zoom")
-        .attr("width", this.dimensions.width)
-        .attr("height", this.dimensions.mainHeight)
+        .attr("width", dimensions.width)
+        .attr("height", dimensions.mainHeight)
         .attr(
           "transform",
-          `translate(${this.dimensions.mainMargin.left}, ${
-            this.dimensions.mainMargin.top
+          `translate(${dimensions.mainMargin.left}, ${
+            dimensions.mainMargin.top
           })`
         );
 
-      this.createZoom(updaters, eventRect);
-      this.createTooltips(eventRect);
-      this.setInlineStyles();
+      this.createZoom({
+        updaters,
+        eventRect,
+        dimensions,
+        scale,
+        svg,
+        navigation
+      });
+      this.createTooltips({ eventRect, diagram, scale, dimensions });
+      this.setInlineStyles(svg);
     },
-    setInlineStyles() {
-      this.svg.selectAll(".hide").attr("fill-opacity", 0);
-      this.svg
+    setInlineStyles(svg) {
+      svg.selectAll(".hide").attr("fill-opacity", 0);
+      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
+      svg.selectAll(".line.mean").attr("stroke", "red");
+      svg.selectAll(".line.median").attr("stroke", "black");
+      svg.selectAll(".line.q25").attr("stroke", "orange");
+      svg.selectAll(".line.q75").attr("stroke", "purple");
+      svg
         .selectAll(".area")
         .attr("clip-path", "url(#hydrocond-clip)")
         .attr("stroke", "none")
         .attr("fill", "lightsteelblue");
-      this.svg
+      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
+      svg.selectAll(".hdc-line").attr("stroke", "red");
+      svg.selectAll(".ldc-line").attr("stroke", "green");
+      svg.selectAll(".mw-line").attr("stroke", "grey");
+      svg.selectAll(".rn-line").attr("stroke", "grey");
+      svg
         .selectAll(".ref-waterlevel-label")
         .style("font-size", "10px")
         .attr("fill", "black");
-      this.svg
+      svg
         .selectAll(".ref-waterlevel-label-background")
         .attr("fill", "rgba(255, 255, 255, 0.6)");
-      this.svg
+      svg
         .selectAll(".now-line")
         .attr("stroke", "#999")
         .attr("stroke-width", 1)
         .attr("stroke-dasharray", "5, 5")
         .attr("clip-path", "url(#hydrocond-clip)");
-      this.svg
+      svg
         .selectAll(".now-line-label")
         .attr("fill", "#999")
         .style("font-size", "11px");
-      this.svg
+      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");
+      svg.selectAll(".tick text").attr("fill", "black");
+      svg.selectAll(".domain").attr("stroke", "black");
 
-      this.svg
+      svg
         .selectAll(".zoom")
         .attr("cursor", "move")
         .attr("fill", "none")
         .attr("pointer-events", "all");
-      this.svg
+      svg
         .selectAll(".brush .selection")
         .attr("stroke", "none")
         .attr("fill-opacity", 0.2);
-      this.svg
+      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
+      svg.selectAll(".chart-dots").attr("clip-path", "url(#hydrocond-clip)");
+      svg
         .selectAll(".chart-dots .chart-dot")
         .attr("fill", "black")
         .attr("stroke", "black")
@@ -519,16 +530,16 @@
         .attr("fill-opacity", 0)
         .transition()
         .attr("fill-opacity", "0.1s");
-      this.svg
+      svg
         .selectAll(".chart-tooltip")
         .attr("fill-opacity", 0)
         .transition()
         .attr("fill-opacity", "0.3s");
-      this.svg
+      svg
         .selectAll(".chart-tooltip rect")
         .attr("fill", "#fff")
         .attr("stroke", "#ccc");
-      this.svg
+      svg
         .selectAll(".chart-tooltip text")
         .attr("fill", "666")
         .style("font-size", "0.8em");
@@ -580,26 +591,26 @@
         waterlevel: d3.extent(waterlevelValues)
       };
     },
-    getScale() {
+    getScale({ dimensions, extent }) {
       // 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]);
+      const x = d3.scaleTime().range([0, dimensions.width]);
+      const y = d3.scaleLinear().range([dimensions.mainHeight, 0]);
+      const x2 = d3.scaleTime().range([0, dimensions.width]);
+      const y2 = d3.scaleLinear().range([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);
+      x.domain(d3.extent(extent.date));
+      y.domain(extent.waterlevel);
       x2.domain(x.domain());
       y2.domain(y.domain());
 
       return { x, y, x2, y2 };
     },
-    getAxes() {
+    getAxes({ scale, dimensions }) {
       return {
         x: d3
-          .axisTop(this.scale.x)
-          .tickSizeInner(this.dimensions.mainHeight)
+          .axisTop(scale.x)
+          .tickSizeInner(dimensions.mainHeight)
           .tickSizeOuter(0)
           .tickFormat(date => {
             // make the x-axis label formats dynamic, based on zoom
@@ -620,39 +631,37 @@
               : d3.timeFormat("%B"))(date);
           }),
         y: d3
-          .axisRight(this.scale.y)
-          .tickSizeInner(this.dimensions.width)
+          .axisRight(scale.y)
+          .tickSizeInner(dimensions.width)
           .tickSizeOuter(0)
           .tickFormat(d => this.$options.filters.waterlevel(d)),
-        x2: d3.axisBottom(this.scale.x2)
+        x2: d3.axisBottom(scale.x2)
       };
     },
-    drawNowLines() {
+    drawNowLines({ extent, diagram, scale, navigation }) {
       const now = new Date();
       const nowCoords = [
-        { x: now, y: this.extent.waterlevel[0] },
-        { x: now, y: this.extent.waterlevel[1] }
+        { x: now, y: extent.waterlevel[0] },
+        { x: now, y: extent.waterlevel[1] }
       ];
       const nowLine = d3
         .line()
-        .x(d => this.scale.x(d.x))
-        .y(d => this.scale.y(d.y));
+        .x(d => scale.x(d.x))
+        .y(d => scale.y(d.y));
       const nowLabel = selection => {
         selection.attr(
           "transform",
-          `translate(${this.scale.x(now)}, ${this.scale.y(
-            this.extent.waterlevel[1]
-          )})`
+          `translate(${scale.x(now)}, ${scale.y(extent.waterlevel[1])})`
         );
       };
 
       // draw in main
-      this.diagram
+      diagram
         .append("path")
         .datum(nowCoords)
         .attr("class", "now-line")
         .attr("d", nowLine);
-      this.diagram // label
+      diagram // label
         .append("text")
         .text(this.$gettext("Now"))
         .attr("class", "now-line-label")
@@ -660,7 +669,7 @@
         .call(nowLabel);
 
       // draw in nav
-      this.navigation
+      navigation
         .append("path")
         .datum(nowCoords)
         .attr("class", "now-line")
@@ -668,116 +677,116 @@
           "d",
           d3
             .line()
-            .x(d => this.scale.x2(d.x))
-            .y(d => this.scale.y2(d.y))
+            .x(d => scale.x2(d.x))
+            .y(d => scale.y2(d.y))
         );
 
       return () => {
-        this.diagram.select(".now-line").attr("d", nowLine);
-        this.diagram.select(".now-line-label").call(nowLabel);
+        diagram.select(".now-line").attr("d", nowLine);
+        diagram.select(".now-line-label").call(nowLabel);
       };
     },
-    drawAxes() {
-      this.diagram
+    drawAxes({ diagram, dimensions, axes, navigation }) {
+      diagram
         .append("g")
         .attr("class", "axis--x")
-        .attr("transform", `translate(0, ${this.dimensions.mainHeight})`)
-        .call(this.axes.x)
+        .attr("transform", `translate(0, ${dimensions.mainHeight})`)
+        .call(axes.x)
         .selectAll(".tick text")
         .attr("y", 15);
-      this.diagram // label
+      diagram // label
         .append("text")
         .text(this.$gettext("Waterlevel [m]"))
         .attr("text-anchor", "middle")
         .attr(
           "transform",
-          `translate(-45, ${this.dimensions.mainHeight / 2}) rotate(-90)`
+          `translate(-45, ${dimensions.mainHeight / 2}) rotate(-90)`
         );
-      this.diagram
+      diagram
         .append("g")
-        .call(this.axes.y)
+        .call(axes.y)
         .selectAll(".tick text")
         .attr("x", -25);
 
-      this.navigation
+      navigation
         .append("g")
         .attr("class", "axis axis--x")
-        .attr("transform", `translate(0, ${this.dimensions.navHeight})`)
-        .call(this.axes.x2);
+        .attr("transform", `translate(0, ${dimensions.navHeight})`)
+        .call(axes.x2);
 
       return () => {
-        this.diagram
+        diagram
           .select(".axis--x")
-          .call(this.axes.x)
+          .call(axes.x)
           .selectAll(".tick text")
           .attr("y", 15);
       };
     },
-    drawWaterlevelMinMaxAreaChart() {
+    drawWaterlevelMinMaxAreaChart({ scale, diagram, navigation }) {
       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));
+          .x(d => scale[isNav ? "x2" : "x"](d.date))
+          .y0(d => scale[isNav ? "y2" : "y"](d.min))
+          .y1(d => scale[isNav ? "y2" : "y"](d.max));
 
-      this.diagram
+      diagram
         .append("path")
         .datum(this.longtermWaterlevels)
         .attr("class", "area")
         .attr("d", areaChart());
 
-      this.navigation
+      navigation
         .append("path")
         .datum(this.longtermWaterlevels)
         .attr("class", "area")
         .attr("d", areaChart(true));
 
       return () => {
-        this.diagram.select(".area").attr("d", areaChart());
+        diagram.select(".area").attr("d", areaChart());
       };
     },
-    drawWaterlevelLineChart(type, data) {
+    drawWaterlevelLineChart({ type, data, scale, diagram }) {
       const lineChart = type =>
         d3
           .line()
-          .x(d => this.scale.x(d.date))
-          .y(d => this.scale.y(d[type]))
+          .x(d => scale.x(d.date))
+          .y(d => scale.y(d[type]))
           .curve(d3.curveLinear);
-      this.diagram
+      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));
+        diagram.select(".line." + type).attr("d", lineChart(type));
       };
     },
-    drawRefLines(refWaterLevels) {
+    drawRefLines({ refWaterLevels, scale, diagram, extent }) {
       const refWaterlevelLine = d3
         .line()
-        .x(d => this.scale.x(d.x))
-        .y(d => this.scale.y(d.y));
+        .x(d => scale.x(d.x))
+        .y(d => scale.y(d.y));
 
       for (let ref in refWaterLevels) {
         if (refWaterLevels[ref]) {
-          this.diagram
+          diagram
             .append("path")
             .datum([
-              { x: this.extent.date[0], y: refWaterLevels[ref] },
-              { x: this.extent.date[1], y: refWaterLevels[ref] }
+              { x: extent.date[0], y: refWaterLevels[ref] },
+              { x: extent.date[1], y: refWaterLevels[ref] }
             ])
             .attr("class", ref.toLowerCase() + "-line")
             .attr("d", refWaterlevelLine);
-          this.diagram // label
+          diagram // label
             .append("rect")
             .attr("class", "ref-waterlevel-label-background")
             .attr("x", 1)
-            .attr("y", this.scale.y(refWaterLevels[ref]) - 13)
+            .attr("y", scale.y(refWaterLevels[ref]) - 13)
             .attr("width", 55)
             .attr("height", 12);
-          this.diagram
+          diagram
             .append("text")
             .text(
               `${ref} (${this.$options.filters.waterlevel(
@@ -786,38 +795,35 @@
             )
             .attr("class", "ref-waterlevel-label")
             .attr("x", 5)
-            .attr("y", this.scale.y(refWaterLevels[ref]) - 3);
+            .attr("y", scale.y(refWaterLevels[ref]) - 3);
         }
       }
     },
-    createZoom(updaters, eventRect) {
+    createZoom({ updaters, eventRect, dimensions, scale, svg, navigation }) {
       const brush = d3
         .brushX()
         .handleSize(4)
-        .extent([[0, 0], [this.dimensions.width, this.dimensions.navHeight]]);
+        .extent([[0, 0], [dimensions.width, 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]]);
+        .translateExtent([[0, 0], [dimensions.width, dimensions.mainHeight]])
+        .extent([[0, 0], [dimensions.width, 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));
+        let s = d3.event.selection || scale.x2.range();
+        scale.x.domain(s.map(scale.x2.invert, scale.x2));
         updaters.forEach(u => u && u());
-        this.setInlineStyles();
-        this.svg
+        this.setInlineStyles(svg);
+        svg
           .select(".zoom")
           .call(
             zoom.transform,
             d3.zoomIdentity
-              .scale(this.dimensions.width / (s[1] - s[0]))
+              .scale(dimensions.width / (s[1] - s[0]))
               .translate(-s[0], 0)
           );
       });
@@ -826,29 +832,29 @@
         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());
+        scale.x.domain(t.rescaleX(scale.x2).domain());
         updaters.forEach(u => u && u());
-        this.setInlineStyles();
-        this.navigation
+        this.setInlineStyles(svg);
+        navigation
           .select(".brush")
-          .call(brush.move, this.scale.x.range().map(t.invertX, t));
+          .call(brush.move, 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);
+        svg.select(".chart-dot").style("opacity", 0);
+        svg.select(".chart-tooltip").style("opacity", 0);
       });
 
-      this.navigation
+      navigation
         .append("g")
         .attr("class", "brush")
         .call(brush)
-        .call(brush.move, this.scale.x.range());
+        .call(brush.move, scale.x.range());
 
       eventRect.call(zoom);
     },
-    createTooltips(eventRect) {
+    createTooltips({ eventRect, diagram, scale, dimensions }) {
       // create clippable container for the dot
-      this.diagram
+      diagram
         .append("g")
         .attr("class", "chart-dots")
         .append("circle")
@@ -856,7 +862,7 @@
         .attr("r", 4);
 
       // create container for the tooltip
-      const tooltip = this.diagram.append("g").attr("class", "chart-tooltip");
+      const tooltip = diagram.append("g").attr("class", "chart-tooltip");
       tooltip
         .append("rect")
         .attr("rx", "0.25em")
@@ -872,16 +878,16 @@
 
       eventRect
         .on("mouseover", () => {
-          this.diagram.select(".chart-dot").style("opacity", 1);
-          this.diagram.select(".chart-tooltip").style("opacity", 1);
+          diagram.select(".chart-dot").style("opacity", 1);
+          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);
+          diagram.select(".chart-dot").style("opacity", 0);
+          diagram.select(".chart-tooltip").style("opacity", 0);
         })
         .on("mousemove", () => {
           // find data point closest to mouse
-          const x0 = this.scale.x.invert(
+          const x0 = scale.x.invert(
               d3.mouse(document.getElementById("zoom-hydrocond"))[0]
             ),
             i = d3.bisector(d => d.date).left(this.longtermWaterlevels, x0, 1),
@@ -890,12 +896,12 @@
             d = x0 - d0.date > d1.date - x0 ? d1 : d0;
 
           const coords = {
-            x: this.scale.x(d.date),
-            y: this.scale.y(d.median)
+            x: scale.x(d.date),
+            y: scale.y(d.median)
           };
 
           // position the dot
-          this.diagram
+          diagram
             .select(".chart-dot")
             .style("opacity", 1)
             .attr("transform", `translate(${coords.x}, ${coords.y})`);
@@ -974,14 +980,14 @@
           // get text dimensions
           const textBBox = tooltipText.node().getBBox();
 
-          this.diagram
+          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 -
+            dimensions.width -
             (textBBox.width + diagramPadding + tooltipPadding * 2);
           const tooltipX = Math.max(
             diagramPadding,
@@ -992,7 +998,7 @@
             tooltipY = coords.y + 10;
           }
 
-          this.diagram
+          diagram
             .select(".chart-tooltip")
             .style("opacity", 1)
             .attr("transform", `translate(${tooltipX}, ${tooltipY})`)