Mercurial > gemma
view client/src/components/gauge/Waterlevel.vue @ 2821:f87783a28c34
client: spuc7: removed console log
author | Markus Kottlaender <markus@intevation.de> |
---|---|
date | Wed, 27 Mar 2019 11:36:15 +0100 |
parents | d973d6f04eb6 |
children | d7c4169516fa |
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 </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", "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.waterlevels[this.waterlevels.length - 1].date) ], // 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") .attr("rx", "0.25rem") .attr("ry", "0.25rem"); // create container for multiple text rows const tooltipText = tooltip.append("text").attr("text-anchor", "middle"); // padding inside the tooltip box and diagram padding to determine left // and right offset from the diagram boundaries for the tooltip position. const tooltipPadding = 10; const diagramPadding = 5; 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})`); // remove current texts tooltipText.selectAll("tspan").remove(); // write date tooltipText .append("tspan") .attr("alignment-baseline", "hanging") .attr("text-anchor", "middle") .text( d.date.toLocaleString([], { year: "2-digit", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }) ); if (d.predicted) { tooltipText .append("tspan") .attr("x", 0) .attr("y", 0) .attr("dy", "1.3rem") .attr("alignment-baseline", "hanging") .attr("text-anchor", "middle") .text(d.max + " cm"); tooltipText .append("tspan") .attr("x", 0) .attr("y", 0) .attr("dy", "2.3rem") .attr("alignment-baseline", "hanging") .attr("text-anchor", "middle") .attr("class", "font-weight-bold") .text(d.waterlevel + " cm"); tooltipText .append("tspan") .attr("x", 0) .attr("y", 0) .attr("dy", "3.3rem") .attr("alignment-baseline", "hanging") .attr("text-anchor", "middle") .text(d.min + " cm"); } else { tooltipText .append("tspan") .attr("x", 0) .attr("y", 0) .attr("dy", "1.3rem") .attr("alignment-baseline", "hanging") .attr("text-anchor", "middle") .attr("class", "font-weight-bold") .text(d.waterlevel + " cm"); } // get text dimensions const textBBox = tooltipText.node().getBBox(); this.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 - (textBBox.width + diagramPadding + tooltipPadding * 2); const tooltipX = Math.max( diagramPadding, Math.min(coords.x - (textBBox.width + tooltipPadding * 2) / 2, xMax) ); const tooltipY = coords.y - (textBBox.height + tooltipPadding * 2) - 10; this.diagram .select(".chart-tooltip") .style("opacity", 1) .attr("transform", `translate(${tooltipX}, ${tooltipY})`) .select("rect") .attr("width", textBBox.width + tooltipPadding * 2) .attr("height", textBBox.height + tooltipPadding * 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>