# HG changeset patch # User Thomas Junk # Date 1562683836 -7200 # Node ID 7d86beedfb00e9034257ea6f975d15a331783ec6 # Parent 91b4ca03174e5c5677627d1f2f86952b0cd8923b hydrological conditions: factor out side-effects from diagram rendering diff -r 91b4ca03174e -r 7d86beedfb00 client/src/components/gauge/HydrologicalConditions.vue --- 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})`)