Mercurial > gemma
view client/src/components/gauge/Waterlevel.vue @ 2792:cfd9ac0c92ee
client: waterlevel diagram: avoid code duplication
author | Markus Kottlaender <markus@intevation.de> |
---|---|
date | Mon, 25 Mar 2019 12:16:22 +0100 |
parents | 2feb9f8f6c66 |
children | 8791becc40b1 |
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-width: 1 stroke-dasharray: 4, 4 stroke-opacity: 1 circle stroke-width: 0 fill: steelblue .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 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/ const d3 = Object.assign(d3Base, { lineChunked }); export default { computed: { ...mapState("gauges", [ "waterlevels", "dateFrom", "dateTo", "nashSutcliffe" ]), ...mapGetters("gauges", ["selectedGauge"]) }, watch: { waterlevels() { this.drawDiagram(); } }, methods: { drawDiagram() { // TODO: Optimize code. I'm pretty sure all of this can be done in a much // more elegant way and with less lines of code. // remove old diagram d3.select(".diagram-container svg").remove(); if (!this.selectedGauge || !this.waterlevels.length) return; // get HDC/LDC/MW of the gauge let refWaterLevels = JSON.parse( this.selectedGauge.properties.reference_water_levels ); // CREATE SVG AND SET DIMENSIONS/MARGINS let svgWidth = document.querySelector(".diagram-container").clientWidth; let svgHeight = document.querySelector(".diagram-container").clientHeight; let svg = d3 .select(".diagram-container") .append("svg") .attr("width", "100%") .attr("height", "100%"); let mainMargin = { top: 20, right: 20, bottom: 110, left: 80 }, navMargin = { top: svgHeight - mainMargin.top - 65, right: 20, bottom: 30, left: 80 }, width = +svgWidth - mainMargin.left - mainMargin.right, mainHeight = +svgHeight - mainMargin.top - mainMargin.bottom, navHeight = +svgHeight - navMargin.top - navMargin.bottom; // PREPARING AXES/SCALING // scaling helpers to convert real values to pixels // based on the diagrams dimensions let x = d3.scaleTime().range([0, width]), x2 = d3.scaleTime().range([0, width]), y = d3.scaleLinear().range([mainHeight, 0]), y2 = d3.scaleLinear().range([navHeight, 0]); // find min/max values for the waterlevel axis // including HDC (+ 1/8 HDC-LDC) and LDC (- 1/4 HDC-LDC) let WaterlevelMinMax = 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 ); // setting the min and max values for the diagram axes let dateTo = new Date(this.dateTo.getTime() + 86400); x.domain(d3.extent([this.dateFrom, dateTo])); y.domain(WaterlevelMinMax); x2.domain(x.domain()); y2.domain(y.domain()); // creating the axes based on these scales let xAxis = d3 .axisTop(x) .tickSizeInner(mainHeight) .tickSizeOuter(0); let xAxis2 = d3.axisBottom(x2); let yAxis = d3 .axisRight(y) .tickSizeInner(width) .tickSizeOuter(0); // PREPARING CHART FUNCTIONS // points are "next to each other" when they are exactly 15 minutes apart const isNext = (prev, current) => current.date - prev.date === 15 * 60 * 1000; const preditionStyle = { predicted: { pointStyles: { fill: "steelblue", "fill-opacity": 0.6 } } }; // waterlevel line in big chart // d3-line-chunked plugin: https://github.com/pbeshai/d3-line-chunked var mainLineChart = d3 .lineChunked() .x(d => x(d.date)) .y(d => y(d.waterlevel)) .curve(d3.curveLinear) .isNext(isNext) .pointAttrs({ r: 2.2 }) .chunk(d => (d.predicted ? "predicted" : "line")) .chunkDefinitions(preditionStyle); // waterlevel line in small chart let navLineChart = d3 .lineChunked() .x(d => x2(d.date)) .y(d => y2(d.waterlevel)) .curve(d3.curveMonotoneX) .isNext(isNext) .pointAttrs({ r: 1.7 }) .chunk(d => (d.predicted ? "predicted" : "line")) .chunkDefinitions(preditionStyle); // hdc/ldc/mw let refWaterlevelLine = d3 .line() .x(d => x(d.x)) .y(d => y(d.y)); // now let nowLine = d3 .line() .x(d => x(d.x)) .y(d => y(d.y)); let nowLineNav = d3 .line() .x(d => x2(d.x)) .y(d => y2(d.y)); let nowLineLabel = selection => { selection.attr( "transform", `translate(${x(new Date())}, ${y(WaterlevelMinMax[1] - 16)})` ); }; // prediction area let predictionArea = d3 .area() .defined(d => d.predicted && d.min && d.max) .x(d => x(d.date)) .y0(d => y(d.min)) .y1(d => y(d.max)); let predictionAreaNav = d3 .area() .defined(d => d.predicted && d.min && d.max) .x(d => x2(d.date)) .y0(d => y2(d.min)) .y1(d => y2(d.max)); // DRAWING MAINCHART // define visible chart area // everything outside this area will be hidden (clipped) svg .append("defs") .append("clipPath") .attr("id", "clip") .append("rect") .attr("width", width) .attr("height", mainHeight); let mainChart = svg .append("g") .attr("class", "main") .attr("transform", `translate(${mainMargin.left}, ${mainMargin.top})`); // axes mainChart .append("g") .attr("class", "axis--x") .attr("transform", `translate(0, ${mainHeight})`) .call(xAxis) .selectAll(".tick text") .attr("y", 15); mainChart // label .append("text") .text(this.$gettext("Waterlevel [cm]")) .attr("text-anchor", "middle") .attr("transform", `translate(-45, ${mainHeight / 2}) rotate(-90)`); mainChart .append("g") .call(yAxis) .selectAll(".tick text") .attr("x", -25); // reference waterlevels // filling area between HDC and LDC mainChart .append("rect") .attr("class", "hdc-ldc-area") .attr("x", 0) .attr("y", y(refWaterLevels.HDC)) .attr("width", width) .attr("height", y(refWaterLevels.LDC) - y(refWaterLevels.HDC)); // HDC mainChart .append("path") .datum([ { x: 0, y: refWaterLevels.HDC }, { x: dateTo, y: refWaterLevels.HDC } ]) .attr("class", "hdc-line") .attr("d", refWaterlevelLine); mainChart // label .append("text") .text("HDC") .attr("class", "ref-waterlevel-label") .attr("x", x(dateTo) - 20) .attr("y", y(refWaterLevels.HDC) - 3); // LDC mainChart .append("path") .datum([ { x: 0, y: refWaterLevels.LDC }, { x: dateTo, y: refWaterLevels.LDC } ]) .attr("class", "ldc-line") .attr("d", refWaterlevelLine); mainChart // label .append("text") .text("LDC") .attr("class", "ref-waterlevel-label") .attr("x", x(dateTo) - 20) .attr("y", y(refWaterLevels.LDC) - 3); // MW mainChart .append("path") .datum([ { x: 0, y: refWaterLevels.MW }, { x: dateTo, y: refWaterLevels.MW } ]) .attr("class", "mw-line") .attr("d", refWaterlevelLine); mainChart // label .append("text") .text("MW") .attr("class", "ref-waterlevel-label") .attr("x", x(dateTo) - 20) .attr("y", y(refWaterLevels.MW) - 3); // now mainChart .append("path") .datum([ { x: new Date(), y: WaterlevelMinMax[0] }, { x: new Date(), y: WaterlevelMinMax[1] - 20 } ]) .attr("class", "now-line") .attr("d", nowLine); mainChart // label .append("text") .text(this.$gettext("Now")) .attr("class", "now-line-label") .attr("text-anchor", "middle") .call(nowLineLabel); // prediction area mainChart .append("path") .datum(this.waterlevels) .attr("class", "prediction-area") .attr("d", predictionArea); // waterlevel chart mainChart .append("g") .attr("class", "line") .datum(this.waterlevels) .call(mainLineChart); // DRAWING NAVCHART let navChart = svg .append("g") .attr("class", "nav") .attr("transform", `translate(${navMargin.left}, ${navMargin.top})`); // axis (nav chart only has x-axis) navChart .append("g") .attr("class", "axis axis--x") .attr("transform", `translate(0, ${navHeight})`) .call(xAxis2); // now navChart .append("path") .datum([ { x: new Date(), y: WaterlevelMinMax[0] }, { x: new Date(), y: WaterlevelMinMax[1] - 20 } ]) .attr("class", "now-line") .attr("d", nowLineNav); // prediction area navChart .append("path") .datum(this.waterlevels) .attr("class", "prediction-area") .attr("d", predictionAreaNav); // waterlevel chart navChart .append("g") .attr("class", "line") .datum(this.waterlevels) .call(navLineChart); // NASH SUTCLIFFE let nashSut24 = this.nashSutcliffe.coeffs.find(c => c.hours === 24); let nashSut48 = this.nashSutcliffe.coeffs.find(c => c.hours === 48); let nashSut72 = this.nashSutcliffe.coeffs.find(c => c.hours === 72); let nashSutDateNow = new Date(this.nashSutcliffe.when); let nashSutDate24 = new Date(this.nashSutcliffe.when); let nashSutDate48 = new Date(this.nashSutcliffe.when); let nashSutDate72 = new Date(this.nashSutcliffe.when); nashSutDate24.setDate(nashSutDate24.getDate() - 1); nashSutDate48.setDate(nashSutDate48.getDate() - 2); nashSutDate72.setDate(nashSutDate72.getDate() - 3); const nashSutcliffeBox = hours => { return d3 .area() .x(d => x(d)) .y0(() => mainHeight + 0.5) .y1(() => mainHeight - 15 * (hours / 24)); }; const nashSutcliffeLabel = (label, date, hours) => { let days = hours / 24; label .attr("x", x(date) + 3) .attr("y", 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 (this.nashSutcliffe && x.domain()[1] - x.domain()[0] < 3024000000) { if (nashSut24.samples) { mainChart .append("path") .datum([nashSutDate24, nashSutDateNow]) .attr("class", "nash-sutcliffe ns24") .attr("d", nashSutcliffeBox(24)); mainChart .append("text") .attr("class", "nash-sutcliffe ns24") .call(nashSutcliffeLabel, nashSutDate24, 24) .append("tspan") .text(nashSut24.value.toFixed(2)) .select(function() { return this.parentNode; }) .append("tspan") .text(` (${nashSut24.samples})`) .attr("dy", -1); } if (nashSut48.samples) { mainChart .append("path") .datum([nashSutDate48, nashSutDateNow]) .attr("class", "nash-sutcliffe ns48") .attr("d", nashSutcliffeBox(48)); mainChart .append("text") .attr("class", "nash-sutcliffe ns48") .call(nashSutcliffeLabel, nashSutDate48, 48) .append("tspan") .text(nashSut48.value.toFixed(2)) .select(function() { return this.parentNode; }) .append("tspan") .text(` (${nashSut48.samples})`) .attr("dy", -1); } if (nashSut72.samples) { mainChart .append("path") .datum([nashSutDate72, nashSutDateNow]) .attr("class", "nash-sutcliffe ns72") .attr("d", nashSutcliffeBox(72)); mainChart .append("text") .attr("class", "nash-sutcliffe ns72") .call(nashSutcliffeLabel, nashSutDate72, 72) .append("tspan") .text(nashSut72.value.toFixed(2)) .select(function() { return this.parentNode; }) .append("tspan") .text(` (${nashSut72.samples})`) .attr("dy", -1); } } // INTERACTIVITY const updateChart = () => { mainChart.select(".line").call(mainLineChart); mainChart.select(".now-line").attr("d", nowLine); mainChart.select(".now-line-label").call(nowLineLabel); mainChart.select(".prediction-area").attr("d", predictionArea); mainChart .select("path.nash-sutcliffe.ns24") .attr("d", nashSutcliffeBox(24)); mainChart .select("text.nash-sutcliffe.ns24") .call(nashSutcliffeLabel, nashSutDate24, 24); mainChart .select("path.nash-sutcliffe.ns48") .attr("d", nashSutcliffeBox(48)); mainChart .select("text.nash-sutcliffe.ns48") .call(nashSutcliffeLabel, nashSutDate48, 48); mainChart .select("path.nash-sutcliffe.ns72") .attr("d", nashSutcliffeBox(72)); mainChart .select("text.nash-sutcliffe.ns72") .call(nashSutcliffeLabel, nashSutDate72, 72); mainChart .select(".axis--x") .call(xAxis) .selectAll(".tick text") .attr("y", 15); }; // selecting time period in nav chart let brush = d3 .brushX() .handleSize(4) .extent([[0, 0], [width, navHeight]]) .on("brush end", () => { if (d3.event.sourceEvent && d3.event.sourceEvent.type === "zoom") return; // ignore brush-by-zoom let s = d3.event.selection || x2.range(); x.domain(s.map(x2.invert, x2)); updateChart(); svg .select(".zoom") .call( zoom.transform, d3.zoomIdentity.scale(width / (s[1] - s[0])).translate(-s[0], 0) ); }); // zooming with mousewheel in main chart let zoom = d3 .zoom() .scaleExtent([1, Infinity]) .translateExtent([[0, 0], [width, mainHeight]]) .extent([[0, 0], [width, mainHeight]]) .on("zoom", () => { if (d3.event.sourceEvent && d3.event.sourceEvent.type === "brush") return; // ignore zoom-by-brush let t = d3.event.transform; x.domain(t.rescaleX(x2).domain()); updateChart(); navChart .select(".brush") .call(brush.move, x.range().map(t.invertX, t)); }) .on("start", () => { svg.select(".chart-dot").style("opacity", 0); svg.select(".chart-tooltip").style("opacity", 0); }); navChart .append("g") .attr("class", "brush") .call(brush) .call(brush.move, x.range()); let zoomRect = svg .append("rect") .attr("class", "zoom") .attr("width", width) .attr("height", mainHeight) .attr("transform", `translate(${mainMargin.left}, ${mainMargin.top})`) .call(zoom); // TOOLTIPS let dots = mainChart.append("g").attr("class", "chart-dots"); dots .append("circle") .attr("class", "chart-dot") .attr("r", 4); let tooltips = mainChart.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); let bisectDate = d3.bisector(d => d.date).left; zoomRect .on("mouseover", () => { svg.select(".chart-dot").style("opacity", 1); svg.select(".chart-tooltip").style("opacity", 1); }) .on("mouseout", () => { svg.select(".chart-dot").style("opacity", 0); svg.select(".chart-tooltip").style("opacity", 0); }) .on("mousemove", () => { let x0 = x.invert(d3.mouse(document.querySelector(".zoom"))[0]), i = bisectDate(this.waterlevels, x0, 1), d0 = this.waterlevels[i - 1], d1 = this.waterlevels[i] || d0, d = x0 - d0.date > d1.date - x0 ? d1 : d0; svg .select(".chart-dot") .style("opacity", 1) .attr("transform", `translate(${x(d.date)}, ${y(d.waterlevel)})`); svg .select(".chart-tooltip") .style("opacity", 1) .attr( "transform", `translate(${x(d.date) - 25}, ${y(d.waterlevel) - 25})` ); 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" }) ); svg .select(".chart-tooltip text tspan:last-child") .text(d.waterlevel + " cm"); }); } }, created() { window.addEventListener("resize", debounce(this.drawDiagram), 100); }, mounted() { this.drawDiagram(); }, updated() { this.drawDiagram(); } }; </script>