Mercurial > gemma
view client/src/components/gauge/Waterlevel.vue @ 3726:be939dcdfdfd
client: waterlevel diagrams: make now line label not update its position when zooming
Of course the line and label could both be updated and move with the time but since all other data is static after generating
the diagram once, a moving now line would be to fancy... but I was tempted.
author | Markus Kottlaender <markus@intevation.de> |
---|---|
date | Thu, 20 Jun 2019 19:16:26 +0200 |
parents | 05bbd1a97567 |
children | 2b6734a6730a |
line wrap: on
line source
<template> <div class="d-flex flex-column flex-fill"> <UIBoxHeader icon="ruler-vertical" :title="title" :closeCallback="close" class="rounded-0" /> <div class="d-flex flex-fill"> <DiagramLegend id="diagramlegendId"> <div class="legend"> <span style="background-color: steelblue; width: 20px; height: 20px;" ></span> Waterlevel </div> <div class="legend"> <span style="width: 8px; height: 8px; background-color: rgba(70, 130, 180, 0.6); border: solid 7px rgba(70, 130, 180, 0.2); background-clip: padding-box; box-sizing: content-box;" ></span> Prediction </div> <div class="legend"> <span style="background-color: rgba(0, 255, 0, 0.1); width: 20px; height: 20px;" ></span> Navigable Range </div> <div> <select @change="applyChange" v-model="form.template" class="form-control d-block custom-select-sm w-100" > <option v-for="template in templates" :value="template" :key="template.name" > {{ template.name }} </option> </select> <button @click="downloadPDF" type="button" class="btn btn-sm btn-info d-block w-100 mt-2" :disabled="!waterlevels.length" > <translate>Export to PDF</translate> </button> <a :class="[ 'btn btn-sm btn-info d-block w-100 mt-2', { disabled: !waterlevels.length } ]" :href="csvLink" :download="csvFileName" > <translate>Export as CSV</translate> </a> <!-- <button @click="downloadSVG" type="button" class="btn btn-sm btn-info d-block w-100 mt-2" :disabled="!waterlevels.length" > <translate>Export as SVG</translate> </button> --> </div> </DiagramLegend> <div class="d-flex flex-fill justify-content-center align-items-center" :id="containerId" > <div v-if="!waterlevels.length"> <translate>No data available.</translate> </div> </div> </div> <div id="pdfContainer" style="position: absolute; z-index: -1; top: -9999px;" ></div> </div> </template> <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) 2019 by via donau * – Österreichische Wasserstraßen-Gesellschaft mbH * Software engineering by Intevation GmbH * * Author(s): * * Bernhard Reiter <bernhard@intevation.de> * * Markus Kottländer <markus.kottlaender@intevation.de> * * Fadi Abbud <fadi.abbud@intevation.de> */ import { mapState, mapGetters } from "vuex"; import * as d3Base from "d3"; import { lineChunked } from "d3-line-chunked"; import { endOfDay } from "date-fns"; import debounce from "debounce"; import jsPDF from "jspdf"; import canvg from "canvg"; import { saveAs } from "file-saver"; import { pdfgen } from "@/lib/mixins"; import { HTTP } from "@/lib/http"; import { displayError } from "@/lib/errors"; // 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 { mixins: [pdfgen], components: { DiagramLegend: () => import("@/components/DiagramLegend") }, data() { return { containerId: "waterlevel-diagram-container", resizeListenerFunction: null, svg: null, diagram: null, navigation: null, dimensions: null, extent: null, scale: null, axes: null, form: { template: null }, templates: [], defaultTemplate: { name: "Default", properties: { paperSize: "a4" }, elements: [ { type: "diagram", position: "topleft", offset: { x: 15, y: 50 }, width: 290, height: 100 }, { type: "diagramlegend", position: "topleft", offset: { x: 30, y: 150 }, color: "black" }, { type: "diagramtitle", position: "topleft", offset: { x: 50, y: 26 }, fontsize: 22, color: "steelblue" }, { type: "text", position: "topleft", offset: { x: 3, y: 3 }, fontsize: 8, width: 90, color: "gray", text: this.$gettext("Generated by") + " " + "{user}, {date}" } ] }, pdf: { doc: null, width: 420, height: 297 }, templateData: null }; }, computed: { ...mapState("application", ["paneSetup"]), ...mapState("gauges", [ "dateFrom", "waterlevels", "waterlevelsCSV", "nashSutcliffe" ]), ...mapGetters("gauges", ["selectedGauge"]), title() { return `${this.selectedGauge.properties.objname}: ${this.$gettext( "Waterlevel" )} (${this.dateFrom.toLocaleDateString()} - ${this.dateTo.toLocaleDateString()})`; }, dateFrom: { get() { return this.$store.state.gauges.dateFrom; } }, dateTo: { get() { return this.$store.state.gauges.dateTo; } }, csvLink() { return ( "data:text/csv;charset=utf-8," + encodeURIComponent(this.waterlevelsCSV) ); }, csvFileName() { return `${this.$gettext("waterlevels")}-${ this.selectedGauge.properties.objname }-${this.dateFrom.toISOString().split("T")[0]}-${ this.dateTo.toISOString().split("T")[0] }.csv`; }, hasPredictions() { return this.waterlevels.find(d => d.predicted); } }, watch: { paneSetup() { this.$nextTick(() => this.drawDiagram()); }, waterlevels() { this.drawDiagram(); } }, methods: { close() { this.$store.commit( "application/paneSetup", this.paneSetup === "GAUGE_WATERLEVEL_HYDROLOGICALCONDITIONS" ? "GAUGE_HYDROLOGICALCONDITIONS" : "DEFAULT" ); }, downloadSVG() { let svg = document.getElementById(this.containerId).firstElementChild; let svgXML = new XMLSerializer().serializeToString(svg); let blog = new Blob([svgXML], { type: "image/svg+xml;charset=utf-8" }); let filename = this.selectedGauge.properties.objname + "-waterlevel-diagram.svg"; saveAs(blog, filename); }, downloadPDF() { this.pdf.doc = new jsPDF( "l", "mm", this.templateData.properties.paperSize ); // pdf width and height in millimeter (landscape) this.pdf.width = this.templateData.properties.paperSize === "a3" ? 420 : 297; this.pdf.height = this.templateData.properties.paperSize === "a3" ? 297 : 210; // check the template elements if (this.templateData) { let defaultFontSize = 11, defaultColor = "black", defaultWidth = 70, defaultTextColor = "black", defaultBorderColor = "white", defaultBgColor = "white", defaultRounding = 2, defaultPadding = 2, defaultOffset = { x: 0, y: 0 }; this.templateData.elements.forEach(e => { switch (e.type) { case "diagram": { this.addDiagram( e.position, e.offset || defaultOffset, e.width, e.height ); break; } case "diagramlegend": { this.addDiagramLegend( e.position, e.offset || defaultOffset, e.color || defaultColor ); break; } case "diagramtitle": { let gaugeInfo = this.selectedGauge.properties.objname + " (" + this.selectedGauge.id .split(".")[1] .replace(/[()]/g, "") .split(",")[3] + "):" + " Waterlevel " + this.dateFrom.toLocaleDateString() + " - " + this.dateTo.toLocaleDateString(); this.addDiagramTitle( e.position, e.offset || defaultOffset, e.fontsize || defaultFontSize, e.color || defaultColor, gaugeInfo ); break; } case "text": { this.addText( e.position, e.offset || defaultOffset, e.width || defaultWidth, e.fontsize || defaultFontSize, e.color || defaultTextColor, e.text || "" ); break; } case "image": { this.addImage( e.url, e.format || "", e.position, e.offset || defaultOffset, e.width || 90, e.height || 60 ); break; } case "box": { this.addBox( e.position, e.offset || defaultOffset, e.width || 90, e.height || 60, e.rounding === 0 || e.rounding ? e.rounding : defaultRounding, e.color || defaultBgColor, e.brcolor || defaultBorderColor ); break; } case "textbox": { this.addTextBox( e.position, e.offset || defaultOffset, e.width, e.height, e.rounding === 0 || e.rounding ? e.rounding : defaultRounding, e.padding || defaultPadding, e.fontsize || defaultFontSize, e.color || defaultTextColor, e.background || defaultBgColor, e.text || "", e.brcolor || defaultBorderColor ); break; } } }); } this.pdf.doc.save( this.selectedGauge.properties.objname + " Waterlevel-Diagram.pdf" ); }, applyChange() { if (this.form.template.hasOwnProperty("properties")) { this.templateData = this.defaultTemplate; return; } if (this.form.template) { HTTP.get("/templates/diagram/" + this.form.template.name, { headers: { "X-Gemma-Auth": localStorage.getItem("token"), "Content-type": "text/xml; charset=UTF-8" } }) .then(response => { this.templateData = response.data.template_data; }) .catch(e => { const { status, data } = e.response; displayError({ title: this.$gettext("Backend Error"), message: `${status}: ${data.message || data}` }); }); } }, addDiagram(position, offset, width, height) { let x = offset.x, y = offset.y; // check if there are tow diagrams on screen if ( ["GAUGE_WATERLEVEL_HYDROLOGICALCONDITIONS"].indexOf(this.paneSetup) !== -1 ) { this.containerId = "pdfContainer"; // set width and height document.querySelector("#pdfContainer").style.width = document.querySelector("#waterlevel-diagram-container").clientWidth * 2 + document.querySelector("#diagramlegendId").clientWidth + "px"; document.querySelector("#pdfContainer").style.height = document.querySelector("#waterlevel-diagram-container").clientHeight + "px"; this.drawDiagram(); } var svg = document.getElementById(this.containerId).innerHTML; if (svg) { svg = svg.replace(/\r?\n|\r/g, "").trim(); } this.containerId = "waterlevel-diagram-container"; 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"); // use default width,height if they are missing in the template definition if (!width) { width = this.templateData.properties.paperSize === "a3" ? 380 : 290; } if (!height) { height = this.templateData.properties.paperSize === "a3" ? 130 : 100; } if (["topright", "bottomright"].indexOf(position) !== -1) { x = this.pdf.width - offset.x - width; } if (["bottomright", "bottomleft"].indexOf(position) !== -1) { y = this.pdf.height - offset.y - height; } this.pdf.doc.addImage(imgData, "PNG", x, y, width, height); }, // Diagram legend addDiagramLegend(position, offset, color) { let x = offset.x; let y = offset.y; this.pdf.doc.setFontSize(10); let width = (this.pdf.doc.getStringUnitWidth("Navigable Range") * 10) / (72 / 25.6) + 5; if (["topright", "bottomright"].indexOf(position) !== -1) { x = this.pdf.width - offset.x - width; } if (["bottomright", "bottomleft"].indexOf(position) !== -1) { y = this.pdf.height - offset.y - this.getTextHeight(4); } this.pdf.doc.setTextColor(color); this.pdf.doc.setDrawColor("white"); this.pdf.doc.setFillColor("steelblue"); this.pdf.doc.circle(x, y, 2, "FD"); this.pdf.doc.text(x + 3, y + 1, "Waterlevel"); this.pdf.doc.setFillColor("#dae6f0"); this.pdf.doc.circle(x, y + 5, 2, "FD"); this.pdf.doc.setFillColor("#e5ffe5"); this.pdf.doc.circle(x, y + 10, 2, "FD"); this.pdf.doc.text(x + 3, y + 11, "Navigable Range"); this.pdf.doc.setDrawColor("#90b4d2"); this.pdf.doc.setFillColor("#90b4d2"); this.pdf.doc.circle(x, y + 5, 0.6, "FD"); this.pdf.doc.text(x + 3, y + 6, "Prediction"); }, drawDiagram() { // remove old diagram and exit if necessary data is missing d3.select("#" + this.containerId + " 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) .tickFormat(d => this.$options.filters.waterlevel(d)), x2: d3.axisBottom(this.scale.x2) }; // DRAW DIAGRAM/NAVIGATION AREAS // create svg this.svg = d3 .select("#" + this.containerId) .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", "waterlevel-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 (order matters) updaters.push(this.drawAxes()); updaters.push(this.drawWaterlevelChart()); if (this.hasPredictions) { updaters.push(this.drawPredictionAreas()); } updaters.push(this.drawNowLines()); // static, don't need updater this.drawNavigationChart(); if (refWaterLevels) { this.drawRefLines(refWaterLevels); } updaters.push(this.drawNashSutcliffe(72)); updaters.push(this.drawNashSutcliffe(48)); updaters.push(this.drawNashSutcliffe(24)); // INTERACTIONS // create rectanlge on the main chart area to capture mouse events const eventRect = this.svg .append("rect") .attr("id", "zoom-waterlevels") .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(); }, //set the styles of the diagrams to include them in the pdf setInlineStyles() { this.svg .selectAll(".line") .attr("clip-path", "url(#waterlevel-clip)") .selectAll("path") .attr("stroke", "steelblue") .attr("stroke-width", 2) .attr("fill", "none"); this.svg .selectAll(".line") .selectAll("path.d3-line-chunked-chunk-gap") .attr("stroke-opacity", 0); this.svg .selectAll(".line") .selectAll("circle") .attr("fill", "steelblue") .attr("stroke-width", 0); this.svg .selectAll(".line") .selectAll("circle.d3-line-chunked-chunk-predicted-point") .attr("fill-opacity", 0.6); this.svg .selectAll(".hdc-line, .mw-line, .ldc-line, .rn-line") .attr("stroke-width", 1) .attr("fill", "none") .attr("clip-path", "url(#waterlevel-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 .selectAll(".ref-waterlevel-label") .style("font-size", "10px") .attr("fill", "black"); this.svg .selectAll(".ref-waterlevel-label-background") .attr("fill", "rgb(255, 255, 255)") .attr("fill-opacity", 0.6); this.svg .selectAll(".hdc-ldc-area") .attr("fill", "rgb(0, 255, 0)") .attr("fill-opacity", 0.1); this.svg .selectAll(".now-line") .attr("stroke", "#999") .attr("stroke-width", 1) .attr("stroke-dasharray", "5, 5") .attr("clip-path", "url(#waterlevel-clip)"); this.svg .selectAll(".now-line-label") .attr("font-size", "11px") .attr("fill", "#999"); this.svg .selectAll(".prediction-area") .attr("fill", "steelblue") .attr("fill-opacity", 0.2) .attr("clip-path", "url(#waterlevel-clip)"); this.svg .selectAll("path.nash-sutcliffe") .attr("fill", "none") .attr("stroke", "darkgrey") .attr("stroke-width", 1) .attr("clip-path", "url(#waterlevel-clip)"); this.svg .selectAll("path.nash-sutcliffe.ns72") .attr("fill", "rgb(255, 255, 255)") .attr("fill-opacity", 0.5); this.svg .selectAll("text.nash-sutcliffe") .style("font-size", "10px") .attr("clip-path", "url(#waterlevel-clip)") .selectAll("tspan:last-child, tspan:first-child") .attr("fill", "#555"); 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 .selection") .attr("stroke", "none") .attr("fill-opacity", 0.2); this.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(#waterlevel-clip)"); this.svg .selectAll(".chart-dots .chart-dot") .attr("fill", "steelblue") .attr("stroke", "steelblue") .attr("stroke-opacity", 0) .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("#" + this.containerId) .clientWidth; const svgHeight = document.querySelector("#" + this.containerId) .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) { let waterlevelValues = [...this.waterlevels.map(wl => wl.waterlevel)]; if (refWaterLevels) { waterlevelValues.push( refWaterLevels.HDC + (refWaterLevels.HDC - refWaterLevels.LDC) / 8, Math.max( refWaterLevels.LDC - (refWaterLevels.HDC - refWaterLevels.LDC) / 4, 0 ) ); } else { let delta = d3.max(waterlevelValues) - d3.min(waterlevelValues); waterlevelValues.push( d3.max(waterlevelValues) + delta * 0.1, d3.min(waterlevelValues) - delta * 0.1 ); } return { // set min/max values for the date axis date: [ this.waterlevels[0].date, 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) // or, if no refWaterlevels exist, +-10% of delta between min and max wl waterlevel: d3.extent(waterlevelValues) }; }, 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 [m]")) .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); }; }, drawWaterlevelChart() { const waterlevelChartDrawer = () => { let domainLeft = new Date(this.scale.x.domain()[0].getTime()); let domainRight = new Date(this.scale.x.domain()[1].getTime()); domainLeft.setDate(domainLeft.getDate() - 1); domainRight.setDate(domainRight.getDate() + 1); return ( d3 .lineChunked() // render only data points that are visible in the current scale .defined(d => d.date > domainLeft && d.date < domainRight) .x(d => this.scale.x(d.date)) .y(d => this.scale.y(d.waterlevel)) .curve(d3.curveLinear) .isNext(this.isNext()) .pointAttrs({ r: 1.7 }) .chunk(d => (d.predicted ? "predicted" : "line")) .chunkDefinitions({ predicted: {} }) ); }; this.diagram .append("g") .attr("class", "line") .datum(this.waterlevels) .call(waterlevelChartDrawer()); return () => { this.diagram.select(".line").call(waterlevelChartDrawer()); }; }, drawNavigationChart() { this.navigation .append("g") .attr("class", "line") .datum(this.waterlevels) .call( d3 .lineChunked() .x(d => this.scale.x2(d.date)) .y(d => this.scale.y2(d.waterlevel)) .curve(d3.curveLinear) .isNext(this.isNext()) .pointAttrs({ r: 1.7 }) .chunk(d => (d.predicted ? "predicted" : "line")) .chunkDefinitions({ predicted: {} }) ); }, drawNowLines() { const now = new Date(); const nowCoords = [ { x: now, y: this.extent.waterlevel[0] }, { x: now, y: this.extent.waterlevel[1] } ]; 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(now)}, ${this.scale.y( this.extent.waterlevel[1] )})` ); }; // draw in main this.diagram .append("path") .datum(nowCoords) .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(nowCoords) .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)); for (let ref in refWaterLevels) { if (refWaterLevels[ref]) { this.diagram .append("path") .datum([ { x: this.extent.date[0], y: refWaterLevels[ref] }, { x: this.extent.date[1], y: refWaterLevels[ref] } ]) .attr("class", ref.toLowerCase() + "-line") .attr("d", refWaterlevelLine); this.diagram // label .append("rect") .attr("class", "ref-waterlevel-label-background") .attr("x", 1) .attr("y", this.scale.y(refWaterLevels[ref]) - 13) .attr("width", 55) .attr("height", 12); this.diagram .append("text") .text( `${ref} (${this.$options.filters.waterlevel( refWaterLevels[ref] )})` ) .attr("class", "ref-waterlevel-label") .attr("x", 5) .attr("y", this.scale.y(refWaterLevels[ref]) - 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 => { // show/hide boxes depending on scale of chart (hide if > 90 days) this.diagram .selectAll("path.nash-sutcliffe") .attr( "stroke-opacity", this.scale.x.domain()[1] - this.scale.x.domain()[0] > 90 * 86400000 ? 0 : 1 ); 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", Math.min(this.scale.x(date), this.dimensions.width) - 4) .attr("y", this.dimensions.mainHeight - (15 * days + 0.5) + 12); }; if (coeff.samples) { 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) .attr("text-anchor", "end") .call(nashSutcliffeLabel, dateNow, hours) .append("tspan") .text(hours + "h: ") .select(function() { return this.parentNode; }) .append("tspan") .text(coeff.value.toFixed(2)) .select(function() { return this.parentNode; }) .append("tspan") .text(` (${coeff.samples})`); } return () => { this.diagram .select("path.nash-sutcliffe.ns" + hours) .attr("d", nashSutcliffeBox(hours)); this.diagram .select("text.nash-sutcliffe.ns" + hours) .call(nashSutcliffeLabel, dateNow, 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.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.getElementById("zoom-waterlevels"))[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("dominant-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.4em") .attr("dominant-baseline", "hanging") .attr("text-anchor", "middle") .text(this.$options.filters.waterlevel(d.max) + " m"); tooltipText .append("tspan") .attr("x", 0) .attr("y", 0) .attr("dy", "2.6em") .attr("dominant-baseline", "hanging") .attr("text-anchor", "middle") .attr("class", "font-weight-bold") .text(this.$options.filters.waterlevel(d.waterlevel) + " m"); tooltipText .append("tspan") .attr("x", 0) .attr("y", 0) .attr("dy", "3.8em") .attr("dominant-baseline", "hanging") .attr("text-anchor", "middle") .text(this.$options.filters.waterlevel(d.min) + " m"); } else { tooltipText .append("tspan") .attr("x", 0) .attr("y", 0) .attr("dy", "1.4em") .attr("dominant-baseline", "hanging") .attr("text-anchor", "middle") .attr("class", "font-weight-bold") .text(this.$options.filters.waterlevel(d.waterlevel) + " m"); } // 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); }); }, isNext() { // Check whether points in the chart can be considered "next to each other". // For that they need to be exactly 15 minutes apart (for automatically // imported gauge measurements). If the chart shows more than 15 days then // 1 hour is also valid (for approved gauge measurements). return (prev, current) => { let difference = (current.date - prev.date) / 1000; if ( (this.scale.x.domain()[1] - this.scale.x.domain()[0]) / 86400000 > 15 ) return [900, 3600].includes(difference); return difference === 900; }; } }, created() { this.resizeListenerFunction = debounce(this.drawDiagram, 100); window.addEventListener("resize", this.resizeListenerFunction); }, mounted() { // Nasty but necessary if we don't want to use the updated hook to re-draw // the diagram because this would re-draw it also for irrelevant reasons. // In this case we need to wait for the child component (DiagramLegend) to // render. According to the docs (https://vuejs.org/v2/api/#mounted) this // should be possible with $nextTick() but it doesn't work because it does // not guarantee that the DOM is not only updated but also re-painted on the // screen. setTimeout(this.drawDiagram, 150); this.templates[0] = this.defaultTemplate; this.form.template = this.templates[0]; this.templateData = this.form.template; HTTP.get("/templates/diagram", { headers: { "X-Gemma-Auth": localStorage.getItem("token"), "Content-type": "text/xml; charset=UTF-8" } }) .then(response => { if (response.data.length) { this.templates = response.data; this.form.template = this.templates[0]; this.templates[this.templates.length] = this.defaultTemplate; this.applyChange(); } }) .catch(e => { const { status, data } = e.response; displayError({ title: this.$gettext("Backend Error"), message: `${status}: ${data.message || data}` }); }); }, destroyed() { window.removeEventListener("resize", this.resizeListenerFunction); } }; </script>