Mercurial > gemma
view client/src/components/gauge/HydrologicalConditions.vue @ 3068:492c30ca3142
client: diagrams: added watchers to ensure diagram update when data changes
author | Markus Kottlaender <markus@intevation.de> |
---|---|
date | Wed, 17 Apr 2019 09:46:03 +0200 |
parents | 0ffa9d418959 |
children | f269bd001e78 |
line wrap: on
line source
<template> <div class="d-flex flex-fill"> <DiagramLegend> <div class="legend"> <span style="background-color: red"></span> {{ yearCompare }} </div> <div class="legend"> <span style="background-color: orange"></span> Q25% </div> <div class="legend"> <span style="background-color: black"></span> Median </div> <div class="legend"> <span style="background-color: purple"></span> Q75% </div> <div class="legend"> <span style="background-color: lightsteelblue"></span> Long-term Amplitude </div> <div> <button @click="downloadPDF" type="button" class="btn btn-sm btn-info d-block w-100 mt-2" > <translate>Export to PDF</translate> </button> </div> </DiagramLegend> <div class="d-flex flex-fill justify-content-center align-items-center diagram-container" > <div v-if="!longtermWaterlevels.length"> <translate>No data available.</translate> </div> </div> </div> </template> <style lang="sass" scoped> .diagram-container /deep/ .hide opacity: 0 .line clip-path: url(#clip) stroke-width: 2 fill: none &.mean stroke: red &.median stroke: black &.q25 stroke: orange &.q75 stroke: purple .area clip-path: url(#clip) stroke: none fill: lightsteelblue .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 .now-line stroke: #999 stroke-width: 1 stroke-dasharray: 5, 5 clip-path: url(#clip) .now-line-label font-size: 11px fill: #999 .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: black stroke: black 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 d3 from "d3"; import debounce from "debounce"; import { startOfYear, endOfYear } from "date-fns"; import jsPDF from "jspdf"; import canvg from "canvg"; export default { components: { DiagramLegend: () => import("@/components/DiagramLegend") }, data() { return { svg: null, diagram: null, navigation: null, dimensions: null, extent: null, scale: null, axes: null }; }, computed: { ...mapState("gauges", [ "longtermWaterlevels", "yearWaterlevels", "yearCompare", "longtermInterval" ]), ...mapGetters("gauges", ["selectedGauge"]) }, watch: { longtermWaterlevels() { this.drawDiagram(); }, yearWaterlevels() { this.drawDiagram(); } }, methods: { downloadPDF() { var svg = this.svg._groups[0][0].outerHTML; var gaugeInfo = this.selectedGauge.properties.objname + " (" + this.selectedGauge.id .split(".")[1] .replace(/[()]/g, "") .split(",")[3] + "): Hydrological Conditions " + this.longtermInterval.join(" - "); if (svg) { svg = svg.replace(/\r?\n|\r/g, "").trim(); } var pdf = new jsPDF("l", "mm", "a3"); var canvas = document.createElement("canvas"); canvas.width = window.innerWidth; canvas.height = window.innerHeight / 2; canvg(canvas, svg, { ignoreMouse: true, ignoreAnimation: true, ignoreDimensions: true }); var imgData = canvas.toDataURL("image/png"); pdf.addImage(imgData, "PNG", 40, 60, 380, 130); // Gauge info as as title pdf.setTextColor("steelblue"); pdf.setFontSize(22); pdf.setFontStyle("bold"); pdf.text(gaugeInfo, 108, 30); // Diagram legend pdf.setFontSize(10); pdf.setTextColor("black"); pdf.setDrawColor("white"); pdf.setFillColor("red"); pdf.circle(60, 190, 2, "FD"); pdf.text(63, 191, "" + this.yearCompare); pdf.setFillColor("orange"); pdf.circle(60, 195, 2, "FD"); pdf.text(63, 196, "Q25%"); pdf.setFillColor("black"); pdf.circle(60, 200, 2, "FD"); pdf.text(63, 201, "Median "); pdf.setFillColor("purple"); pdf.circle(60, 205, 2, "FD"); pdf.text(63, 206, "Q75%"); pdf.setFillColor("lightsteelblue"); pdf.circle(60, 210, 2, "FD"); pdf.text(63, 211, "Long-term Amplitude"); pdf.save( this.selectedGauge.properties.objname + " Hydrological-condition Diagram.pdf" ); }, drawDiagram() { // remove old diagram d3.select(".diagram-container svg").remove(); if (!this.selectedGauge || !this.longtermWaterlevels.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 = this.getAxes(); // 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.drawNowLines()); 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)); // 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); this.setInlineStyles(); }, setInlineStyles() { this.svg.selectAll(".hide").attr("fill-opacity", 0); this.svg .selectAll(".line") .attr("clip-path", "url(#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 .selectAll(".area") .attr("clip-path", "url(#clip)") .attr("stroke", "none") .attr("fill", "lightsteelblue"); this.svg .selectAll(".hdc-line, .ldc-line, .mw-line") .attr("stroke-width", 1) .attr("fill", "none") .attr("clip-path", "url(#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(".ref-waterlevel-label") .style("font-size", "11px") .attr("fill", "#999"); this.svg .selectAll(".now-line") .attr("stroke", "#999") .attr("stroke-width", 1) .attr("stroke-dasharray", "5, 5") .attr("clip-path", "url(#clip)"); this.svg .selectAll(".now-line-label") .attr("fill", "#999") .style("font-size", "11px"); this.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"); this.svg .selectAll(".zoom") .attr("cursor", "move") .attr("fill", "none") .attr("pointer-events", "all"); this.svg .selectAll(".brush") .selectAll(".selection") .attr("stroke", "none") .attr("fill-opacity", 0.2); this.svg .selectAll(".brush") .selectAll(".handle") .attr("stroke", "rgba(23, 162, 184, 0.5)") .attr("fill", "rgba(23, 162, 184, 0.5)"); this.svg .selectAll(".chart-dot") .attr("stroke-opacity", 0) .attr("fill-opacity", 0) .attr("stroke", "steelblue") .style("pointer-events", "none") .transition() .attr("fill-opacity", "0.1s"); this.svg .selectAll(".chart-tooltip") .attr("fill-opacity", 0) .transition() .attr("fill-opacity", "0.3s"); this.svg .selectAll(".chart-tooltip rect") .attr("fill", "#fff") .attr("stroke", "#ccc"); this.svg .selectAll(".chart-tooltip text") .attr("fill", "666") .style("font-size", "0.8em"); }, 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) { const waterlevelsRelevantForExtent = []; this.longtermWaterlevels.forEach(wl => { waterlevelsRelevantForExtent.push(wl.min, wl.max); }); waterlevelsRelevantForExtent.push( refWaterLevels.HDC + (refWaterLevels.HDC - refWaterLevels.LDC) / 8, Math.max( refWaterLevels.LDC - (refWaterLevels.HDC - refWaterLevels.LDC) / 4, 0 ) ); return { // set min/max values for the date axis date: [startOfYear(new Date()), endOfYear(new Date())], // set min/max values for the waterlevel axis // including HDC (+ 1/8 HDC-LDC) and LDC (- 1/4 HDC-LDC) waterlevel: d3.extent(waterlevelsRelevantForExtent) }; }, 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 }; }, getAxes() { return { x: d3 .axisTop(this.scale.x) .tickSizeInner(this.dimensions.mainHeight) .tickSizeOuter(0) .tickFormat(date => { // make the x-axis label formats dynamic, based on zoom // but never display year numbers since they don't make any sense in // this diagram return (d3.timeSecond(date) < date ? d3.timeFormat(".%L") : d3.timeMinute(date) < date ? d3.timeFormat(":%S") : d3.timeHour(date) < date ? d3.timeFormat("%I:%M") : d3.timeDay(date) < date ? d3.timeFormat("%I %p") : d3.timeMonth(date) < date ? d3.timeWeek(date) < date ? d3.timeFormat("%a %d") : d3.timeFormat("%b %d") : d3.timeFormat("%B"))(date); }), y: d3 .axisRight(this.scale.y) .tickSizeInner(this.dimensions.width) .tickSizeOuter(0), x2: d3.axisBottom(this.scale.x2) }; }, 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); }; }, 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); }; }, drawWaterlevelMinMaxAreaChart() { 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)); this.diagram .append("path") .datum(this.longtermWaterlevels) .attr("class", "area") .attr("d", areaChart()); this.navigation .append("path") .datum(this.longtermWaterlevels) .attr("class", "area") .attr("d", areaChart(true)); return () => { this.diagram.select(".area").attr("d", areaChart()); }; }, drawWaterlevelLineChart(type, data) { const lineChart = type => d3 .line() .x(d => this.scale.x(d.date)) .y(d => this.scale.y(d[type])) .curve(d3.curveLinear); this.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)); }; }, drawRefLines(refWaterLevels) { 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); }, 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.setInlineStyles(); 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.setInlineStyles(); 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.25em") .attr("ry", "0.25em"); // 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.longtermWaterlevels, x0, 1), d0 = this.longtermWaterlevels[i - 1], d1 = this.longtermWaterlevels[i] || d0, d = x0 - d0.date > d1.date - x0 ? d1 : d0; const coords = { x: this.scale.x(d.date), y: this.scale.y(d.median) }; // 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("dominant-baseline", "hanging") .attr("text-anchor", "middle") .text( d.date.toLocaleString([], { year: "2-digit", month: "2-digit", day: "2-digit" }) ); tooltipText .append("tspan") .attr("x", 0) .attr("y", 0) .attr("dy", "1.4em") .attr("dominant-baseline", "hanging") .attr("text-anchor", "middle") .text(`Q75%: ${d.q75} cm`); tooltipText .append("tspan") .attr("x", 0) .attr("y", 0) .attr("dy", "2.6em") .attr("dominant-baseline", "hanging") .attr("text-anchor", "middle") .text(`Median: ${d.median} cm`); tooltipText .append("tspan") .attr("x", 0) .attr("y", 0) .attr("dy", "3.8em") .attr("dominant-baseline", "hanging") .attr("text-anchor", "middle") .text(`Q25%: ${d.q25} cm`); tooltipText .append("tspan") .attr("x", 0) .attr("y", 0) .attr("dy", "5em") .attr("dominant-baseline", "hanging") .attr("text-anchor", "middle") .text(`Max: ${d.max} cm`); tooltipText .append("tspan") .attr("x", 0) .attr("y", 0) .attr("dy", "6.2em") .attr("dominant-baseline", "hanging") .attr("text-anchor", "middle") .text(`Min: ${d.min} cm`); const dYear = this.yearWaterlevels.find( ywl => ywl.date.getTime() === d.date.getTime() ); if (dYear) { tooltipText .append("tspan") .attr("x", 0) .attr("y", 0) .attr("dy", "7.4em") .attr("dominant-baseline", "hanging") .attr("text-anchor", "middle") .text(`${this.yearCompare}: ${dYear.mean.toFixed(1)} 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) ); let tooltipY = coords.y - (textBBox.height + tooltipPadding * 2) - 10; if (coords.y < textBBox.height + tooltipPadding * 2) { tooltipY = coords.y + 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); }); } }, created() { window.addEventListener("resize", debounce(this.drawDiagram), 100); }, mounted() { this.drawDiagram(); }, updated() { this.drawDiagram(); }, destroyed() { window.removeEventListener("resize", debounce(this.drawDiagram)); } }; </script>