Mercurial > gemma
view client/src/components/gauge/Waterlevel.vue @ 3150:38d9edce3331
client: fix width and height for pdf-template (waterlevel)
author | Fadi Abbud <fadi.abbud@intevation.de> |
---|---|
date | Fri, 03 May 2019 13:19:54 +0200 |
parents | cdfb0093b7b1 |
children | b6c10b30d6bd |
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> <div class="legend"> <span style="background-color: steelblue"></span> Waterlevel </div> <div class="legend"> <span style="width: 4px; height: 4px; background-color: rgba(70, 130, 180, 0.6); border: solid 4px 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)"></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" > <translate>Export to PDF</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> </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): * 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 { 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", svg: null, diagram: null, navigation: null, dimensions: null, extent: null, scale: null, axes: null, form: { template: null, form: null }, templates: [], defaultTemplate: { name: "Default", properties: { paperSize: "a4", resolution: "80" }, elements: [ { 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", "nashSutcliffe"]), ...mapGetters("gauges", ["selectedGauge"]), ...mapState("user", ["user"]), 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; } } }, watch: { paneSetup() { this.$nextTick(() => this.drawDiagram()); }, waterlevels() { this.drawDiagram(); } }, methods: { close() { this.$store.commit( "application/paneSetup", this.paneSetup === "GAUGE_WATERLEVEL_HYDROLOGICALCONDITIONS" ? "GAUGE_HYDROLOGICALCONDITIONS" : "DEFAULT" ); }, downloadPDF() { var svg = document.getElementById(this.containerId).innerHTML; if (svg) { svg = svg.replace(/\r?\n|\r/g, "").trim(); } this.pdf.doc = new jsPDF("l", "mm", this.form.paperSize); 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"); // landscape format is used for both a3,a4 papersize let xDiagram = this.form.paperSize === "a3" ? 40 : 15, yDiagram = this.form.paperSize === "a3" ? 60 : 50, widthDiagram = this.form.paperSize === "a3" ? 380 : 290, heightDiagram = this.form.paperSize === "a3" ? 130 : 100; this.pdf.width = this.form.paperSize === "a3" ? 420 : 297; this.pdf.height = this.form.paperSize === "a3" ? 297 : 210; this.pdf.doc.addImage( imgData, "PNG", xDiagram, yDiagram, widthDiagram, heightDiagram ); // check the template element if (this.templateData) { let defaultFontSize = 11, defaultColor = "black", defaultWidth = 70, defaultTextColor = "black", defaultBorderColor = "white", defaultBgColor = "white", defaultRounding = 2, defaultOffset = { x: 0, y: 0 }; this.templateData.elements.forEach(e => { switch (e.type) { case "diagramlegend": { this.addDiagramLegend( e.position, e.offset || defaultOffset, e.color || defaultColor ); break; } case "diagramtitle": { this.addDiagramTitle(e.position, e.offset, e.fontsize, e.color); 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, e.height ); break; } case "box": { this.addBox( e.position, e.offset, e.width, e.height, e.rounding === 0 || e.rounding ? e.rounding : defaultRounding, e.color || defaultBgColor, 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; this.form.paperSize = this.defaultTemplate.properties.paperSize; return; } if (this.form.template) { HTTP.get("/templates/print/" + 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; this.form.paperSize = this.templateData.properties.paperSize; }) .catch(e => { const { status, data } = e.response; displayError({ title: this.$gettext("Backend Error"), message: `${status}: ${data.message || data}` }); }); } }, // Gauge info as a title for pdf addDiagramTitle(position, offset, size, color) { let gaugeInfo = this.selectedGauge.properties.objname + " (" + this.selectedGauge.id .split(".")[1] .replace(/[()]/g, "") .split(",")[3] + "):" + " Waterlevel " + this.dateFrom.toLocaleDateString() + " - " + this.dateTo.toLocaleDateString(); let x = offset.x; let y = offset.y; this.pdf.doc.setTextColor(color); this.pdf.doc.setFontSize(size); this.pdf.doc.setFontStyle("bold"); let width = this.pdf.doc.getTextWidth(gaugeInfo) + size / 2; 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(1); } this.pdf.doc.text(gaugeInfo, x, y, { baseline: "hanging" }); }, getTextHeight(numberOfLines) { return ( numberOfLines * ((this.pdf.doc.getFontSize() * 25.4) / 80) * this.pdf.doc.getLineHeightFactor() ); }, // Diagram legend addDiagramLegend(position, offset, color) { let x = offset.x; let y = offset.y; let width = this.pdf.doc.getTextWidth("Navigable Range") + 12; let height = 15; 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.setFontSize(10); 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), 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 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); 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") .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(".ref-waterlevel-label") .style("font-size", "11px") .attr("fill", "#999"); this.svg.selectAll(".hdc-ldc-area").attr("fill", "rgba(0, 255, 0, 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", "black") .attr("stroke-width", 1) .attr("clip-path", "url(#waterlevel-clip)"); this.svg .selectAll("path.nash-sutcliffe.ns72") .attr("fill", "rgba(0, 0, 0, 0.05)"); this.svg .selectAll("text.nash-sutcliffe") .style("font-size", "10px") .attr("clip-path", "url(#waterlevel-clip)") .selectAll("tspan:last-child") .style("font-size", "9px") .attr("fill", "#777"); 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) { 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) 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.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.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(d.max + " cm"); 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(d.waterlevel + " cm"); tooltipText .append("tspan") .attr("x", 0) .attr("y", 0) .attr("dy", "3.8em") .attr("dominant-baseline", "hanging") .attr("text-anchor", "middle") .text(d.min + " cm"); } 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(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) ); 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(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(); this.templates[0] = this.defaultTemplate; this.form.template = this.templates[0]; this.templateData = this.form.template; HTTP.get("/templates/print", { 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}` }); }); }, updated() { this.drawDiagram(); }, destroyed() { window.removeEventListener("resize", debounce(this.drawDiagram)); } }; </script>