Mercurial > gemma
view client/src/components/gauge/HydrologicalConditions.vue @ 4545:e7970d84cb4f iso-areas
Stitch together arcs on cuts.
author | Sascha L. Teichmann <sascha.teichmann@intevation.de> |
---|---|
date | Fri, 27 Sep 2019 17:13:29 +0200 |
parents | 3543af71d04c |
children | 3632cfc44b69 |
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: red; width: 20px; height: 20px;" ></span> {{ yearCompareD }} </div> <div class="legend"> <span style="background-color: orange; width: 20px; height: 20px;" ></span> Q25% </div> <div class="legend"> <span style="background-color: black; width: 20px; height: 20px;" ></span> Median </div> <div class="legend"> <span style="background-color: purple; width: 20px; height: 20px;" ></span> Q75% </div> <div class="legend"> <span style="background-color: lightsteelblue; width: 20px; height: 20px;" ></span> Long-term Amplitude </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> <div> <button @click="downloadPDF" type="button" class="btn btn-sm btn-info d-block w-100 mt-2" :disabled="!longtermWaterlevels.length" > <translate>Export to PDF</translate> </button> </div> <a :class="[ 'btn btn-sm btn-info d-block w-100 mt-2', { disabled: !longtermWaterlevels.length } ]" :href="csvLink" :download="csvFileName" > <translate>Export as CSV</translate> </a> </DiagramLegend> <div class="d-flex flex-fill justify-content-center align-items-center" :id="containerId" > <div v-if="!longtermWaterlevels.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 d3 from "d3"; import debounce from "debounce"; import { startOfYear, endOfYear } from "date-fns"; import { diagram, pdfgen, templateLoader } from "@/lib/mixins"; import { HTTP } from "@/lib/http"; import { displayError } from "@/lib/errors"; import { defaultDiagramTemplate } from "@/lib/DefaultDiagramTemplate"; import sanitize from "sanitize-filename"; export default { mixins: [diagram, pdfgen, templateLoader], components: { DiagramLegend: () => import("@/components/DiagramLegend") }, data() { return { selectedGaugeD: null, longtermIntervalD: null, yearCompareD: null, zoomStore: null, containerId: "hydrologicalconditions-diagram-container", resizeListenerFunction: null, templateData: null, form: { template: null, form: null }, templates: [], defaultTemplate: defaultDiagramTemplate, pdf: { doc: null, width: 420, height: 297 } }; }, computed: { ...mapState("application", ["paneSetup"]), ...mapState("gauges", [ "longtermWaterlevels", "yearWaterlevels", "yearCompare", "longtermInterval" ]), ...mapGetters("gauges", ["selectedGauge"]), title() { if (!this.selectedGaugeD || !this.longtermIntervalD) return; return `${this.selectedGaugeD.properties.objname}: ${this.$gettext( "Hydrological Conditions" )} (${this.longtermIntervalD.join(" - ")})`; }, csvLink() { return "data:text/csv;charset=utf-8," + encodeURIComponent(this.csvData); }, csvFileName() { if (!this.selectedGaugeD || !this.longtermIntervalD) return; return `${this.$gettext("hydrological-conditions")}-${ this.selectedGaugeD.properties.objname }-${this.longtermIntervalD.join(" - ")}.csv`; }, csvData() { if (!this.longtermIntervalD || !this.yearCompareD) return; // We cannot directly use the csv data provided by the backend because the // diagram uses data from two endpoints, longterm- and yearWaterlevels. // So we need to merge them here to have them in one csv export. let merged = this.longtermWaterlevels .filter(d => d) // copy array, don't mutate original .map(d => { let yearData = this.yearWaterlevels.find(y => { return d.date.getTime() === y.date.getTime(); }); d[this.yearCompareD] = yearData ? yearData.mean : ""; return `${d.date.getMonth() + 1}-${d.date.getDate()};${d.min};${ d.max };${d.mean};${d.median};${d.q25};${d.q75};${d[this.yearCompareD]}`; }) .join("\n"); return `#Interval: ${this.longtermIntervalD.join( " - " )}\n#date;#min;#max;#mean;#median;#q25;#q75;#${ this.yearCompareD }\n${merged}`; } }, watch: { paneSetup() { this.$nextTick(() => { this.initialDiagramValues(); this.drawDiagram(); }); }, longtermWaterlevels() { this.initialDiagramValues(); this.drawDiagram(); }, yearWaterlevels() { this.initialDiagramValues(); this.drawDiagram(); } }, methods: { initialDiagramValues() { this.selectedGaugeD = this.selectedGauge; this.longtermIntervalD = this.longtermInterval; this.yearCompareD = this.yearCompare; }, close() { this.$store.commit( "application/paneSetup", this.paneSetup === "GAUGE_WATERLEVEL_HYDROLOGICALCONDITIONS" ? "GAUGE_WATERLEVEL" : "DEFAULT" ); }, downloadPDF() { let diagramTitle = `${this.selectedGaugeD.properties.objname} (${ this.isrsInfo(this.selectedGaugeD).orc }): Hydrological Conditions ${this.longtermIntervalD.join(" - ")}`; this.generatePDF({ templateData: this.templateData, diagramTitle: diagramTitle }); const filename = `HydrologicalCondition-${sanitize( this.selectedGaugeD.properties.objname )}-${this.dateForPDF()}.pdf`; this.pdf.doc.save(filename); }, applyChange() { if (this.form.template.hasOwnProperty("properties")) { this.templateData = this.defaultTemplate; return; } if (this.form.template) { this.loadTemplates("/templates/diagram/" + this.form.template.name) .then(response => { this.prepareImages(response.data.template_data.elements).then( values => { values.forEach(v => { response.data.template_data.elements[v.index].url = v.url; }); this.templateData = response.data.template_data; } ); }) .catch(e => { const { status, data } = e.response; displayError({ title: this.$gettext("Backend Error"), message: `${status}: ${data.message || data}` }); }); } }, // Diagram legend addDiagramLegend(position, offset, color) { if (!this.yearCompareD) return; let x = offset.x + 2, // 2 is the radius of the circle y = offset.y, padding = 3; this.pdf.doc.setFontStyle("normal"); this.pdf.doc.setFontSize(10); let width = this.pdf.doc.getTextWidth("Long-term Amplitude") + padding; // if position is on the right, x needs to be calculate with pdf width and // the size of the element 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(5) - 2; } if (y < this.getTextHeight(1)) { y = y + this.getTextHeight(1) / 2; } this.pdf.doc.setTextColor(color); this.pdf.doc.setDrawColor("white"); this.pdf.doc.setFillColor("red"); this.pdf.doc.circle(x, y, 2, "FD"); this.pdf.doc.text(x + padding, y + 1, "" + this.yearCompareD); this.pdf.doc.setFillColor("orange"); this.pdf.doc.circle(x, y + 5, 2, "FD"); this.pdf.doc.text(x + padding, y + 6, "Q25%"); this.pdf.doc.setFillColor("black"); this.pdf.doc.circle(x, y + 10, 2, "FD"); this.pdf.doc.text(x + 3, y + 11, "Median "); this.pdf.doc.setFillColor("purple"); this.pdf.doc.circle(x, y + 15, 2, "FD"); this.pdf.doc.text(x + padding, y + 16, "Q75%"); this.pdf.doc.setFillColor("lightsteelblue"); this.pdf.doc.circle(x, y + 20, 2, "FD"); this.pdf.doc.text(x + padding, y + 21, "Long-term Amplitude"); }, getPrintLayout(svgHeight, svgWidth) { return { main: { top: Math.floor(0.05 * svgHeight), right: Math.floor(0.05 * svgWidth), bottom: Math.floor(0.32 * svgHeight), left: Math.floor(0.07 * svgWidth) }, nav: { top: Math.floor(0.78 * svgHeight), right: Math.floor(0.013 * svgWidth), bottom: Math.floor(0.095 * svgHeight), left: Math.floor(0.07 * svgWidth) } }; }, drawDiagram() { // remove old diagram d3.select("#" + this.containerId + " svg").remove(); const el = document.querySelector("#" + this.containerId); if (!this.selectedGaugeD || !this.longtermWaterlevels.length || !el) return; const svgWidth = el.clientWidth; const svgHeight = el.clientHeight; const layout = this.getPrintLayout(svgHeight, svgWidth); this.renderTo({ element: `#${this.containerId}`, dimensions: this.getDimensions({ svgWidth: svgWidth, svgHeight: svgHeight, ...layout }) }); }, renderTo({ element, dimensions, zoomLevel }) { // PREPARE HELPERS // HDC/LDC/MW for the selected gauge const refWaterLevels = JSON.parse( this.selectedGaugeD.properties.reference_water_levels ); // dimensions (widths, heights, margins) // get min/max values for date and waterlevel axis const extent = this.getExtent(refWaterLevels); // scaling helpers const scale = this.getScale({ dimensions, extent }); // creating the axes based on the scales const axes = this.getAxes({ scale, dimensions }); // DRAW DIAGRAM/NAVIGATION AREAS // create svg const svg = d3 .select(element) .append("svg") .attr("width", "100%") .attr("height", "100%"); // create container for main diagram const diagram = svg .append("g") .attr("class", "main") .attr( "transform", `translate(${dimensions.mainMargin.left}, ${ dimensions.mainMargin.top })` ); // create container for navigation diagram const navigation = svg .append("g") .attr("class", "nav") .attr( "transform", `translate(${dimensions.navMargin.left}, ${dimensions.navMargin.top})` ); // define visible area, everything outside this area will be hidden svg .append("defs") .append("clipPath") .attr("id", "hydrocond-clip") .append("rect") .attr("width", dimensions.width) .attr("height", 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 updaters.push(this.drawAxes({ diagram, dimensions, axes, navigation })); updaters.push( this.drawWaterlevelMinMaxAreaChart({ scale, diagram, navigation }) ); updaters.push( this.drawWaterlevelLineChart({ type: "median", scale, diagram }) ); updaters.push( this.drawWaterlevelLineChart({ type: "q25", scale, diagram }) ); updaters.push( this.drawWaterlevelLineChart({ type: "q75", scale, diagram }) ); updaters.push( this.drawWaterlevelLineChart({ type: "mean", data: this.yearWaterlevels, scale, diagram }) ); updaters.push(this.drawNowLines({ extent, diagram, scale, navigation })); if (refWaterLevels) { this.drawRefLines({ refWaterLevels, scale, diagram, extent }); // static, doesn't need an updater } // INTERACTIONS // create rectanlge on the main chart area to capture mouse events const eventRect = svg .append("rect") .attr("id", "zoom-hydrocond") .attr("class", "zoom") .attr("width", dimensions.width) .attr("height", dimensions.mainHeight) .attr( "transform", `translate(${dimensions.mainMargin.left}, ${ dimensions.mainMargin.top })` ); this.createZoom({ updaters, eventRect, dimensions, scale, svg, navigation, zoomLevel }); this.createTooltips({ eventRect, diagram, scale, dimensions }); this.setInlineStyles(svg); }, setInlineStyles(svg) { svg.selectAll(".hide").attr("fill-opacity", 0); svg .selectAll(".line") .attr("clip-path", "url(#hydrocond-clip)") .attr("stroke-width", 2) .attr("fill", "none"); svg.selectAll(".line.mean").attr("stroke", "red"); svg.selectAll(".line.median").attr("stroke", "black"); svg.selectAll(".line.q25").attr("stroke", "orange"); svg.selectAll(".line.q75").attr("stroke", "purple"); svg .selectAll(".area") .attr("clip-path", "url(#hydrocond-clip)") .attr("stroke", "none") .attr("fill", "lightsteelblue"); svg .selectAll(".hdc-line, .ldc-line, .mw-line, .rn-line") .attr("stroke-width", 1) .attr("fill", "none") .attr("clip-path", "url(#hydrocond-clip)"); svg.selectAll(".hdc-line").attr("stroke", "red"); svg.selectAll(".ldc-line").attr("stroke", "green"); svg.selectAll(".mw-line").attr("stroke", "gray"); svg.selectAll(".rn-line").attr("stroke", "gray"); svg .selectAll(".ref-waterlevel-label") .style("font-size", "10px") .attr("fill", "black"); svg .selectAll(".ref-waterlevel-label-background") .attr("fill", "rgba(255, 255, 255, 0.6)"); svg .selectAll(".now-line") .attr("stroke", "#999") .attr("stroke-width", 1) .attr("stroke-dasharray", "5, 5") .attr("clip-path", "url(#hydrocond-clip)"); svg .selectAll(".now-line-label") .attr("fill", "#999") .style("font-size", "11px"); svg .selectAll(".axis--x .tick line, .axis--y .tick line") .attr("stroke-dasharray", 5) .attr("stroke", "#ccc"); svg.selectAll(".axis--y-right .tick line").attr("stroke", "transparent"); svg.selectAll(".tick text").attr("fill", "black"); svg.selectAll(".domain").attr("stroke", "black"); svg .selectAll(".zoom") .attr("cursor", "move") .attr("fill", "none") .attr("pointer-events", "all"); svg .selectAll(".brush .selection") .attr("stroke", "none") .attr("fill-opacity", 0.2); svg .selectAll(".brush .handle") .attr("stroke", "rgba(23, 162, 184, 0.5)") .attr("fill", "rgba(23, 162, 184, 0.5)"); svg.selectAll(".chart-dots").attr("clip-path", "url(#hydrocond-clip)"); svg .selectAll(".chart-dots .chart-dot") .attr("fill", "black") .attr("stroke", "black") .attr("stroke-opacity", 0) .style("pointer-events", "none") .attr("fill-opacity", 0) .transition() .attr("fill-opacity", "0.1s"); svg .selectAll(".chart-tooltip") .attr("fill-opacity", 0) .transition() .attr("fill-opacity", "0.3s"); svg .selectAll(".chart-tooltip rect") .attr("fill", "#fff") .attr("stroke", "#ccc") // set width,height attributes for rect element in case d3 does not // generate them to let svg2pdf continue rendernig of the following elements .attr("width", "0px") .attr("height", "0px"); svg .selectAll(".chart-tooltip text") .attr("fill", "666") .style("font-size", "0.8em"); }, getExtent(refWaterLevels) { const waterlevelValues = []; this.longtermWaterlevels.forEach(wl => { waterlevelValues.push(wl.min, wl.max); }); 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: [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(waterlevelValues) }; }, getScale({ dimensions, extent }) { // scaling helpers to convert real world values into pixels const x = d3.scaleTime().range([0, dimensions.width]); const y = d3.scaleLinear().range([dimensions.mainHeight, 0]); const x2 = d3.scaleTime().range([0, dimensions.width]); const y2 = d3.scaleLinear().range([dimensions.navHeight, 0]); // setting the min and max values for the diagram axes x.domain(d3.extent(extent.date)); y.domain(extent.waterlevel); x2.domain(x.domain()); y2.domain(y.domain()); return { x, y, x2, y2 }; }, getAxes({ scale, dimensions }) { return { x: d3 .axisTop(scale.x) .tickSizeInner(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(scale.y) .tickSizeInner(dimensions.width) .tickSizeOuter(0) .tickFormat(d => this.$options.filters.waterlevel(d)), yRight: d3 .axisRight(scale.y) .tickSizeInner(3) .tickSizeOuter(0) .tickFormat(d => this.$options.filters.waterlevel(d)), x2: d3.axisBottom(scale.x2) }; }, drawNowLines({ extent, diagram, scale, navigation }) { const now = new Date(); const nowCoords = [ { x: now, y: extent.waterlevel[0] }, { x: now, y: extent.waterlevel[1] } ]; const nowLine = d3 .line() .x(d => scale.x(d.x)) .y(d => scale.y(d.y)); const nowLabel = selection => { selection.attr( "transform", `translate(${scale.x(now)}, ${scale.y(extent.waterlevel[1])})` ); }; // draw in main diagram .append("path") .datum(nowCoords) .attr("class", "now-line") .attr("d", nowLine); diagram // label .append("text") .text(this.$gettext("Now")) .attr("class", "now-line-label") .attr("text-anchor", "middle") .call(nowLabel); // draw in nav navigation .append("path") .datum(nowCoords) .attr("class", "now-line") .attr( "d", d3 .line() .x(d => scale.x2(d.x)) .y(d => scale.y2(d.y)) ); return () => { diagram.select(".now-line").attr("d", nowLine); diagram.select(".now-line-label").call(nowLabel); }; }, drawAxes({ diagram, dimensions, axes, navigation }) { diagram .append("g") .attr("class", "axis--x") .attr("transform", `translate(0, ${dimensions.mainHeight})`) .call(axes.x) .selectAll(".tick text") .attr("y", Math.floor(0.015 * dimensions.mainHeight)); diagram // label .append("text") .text(this.$gettext("Waterlevel [m]")) .attr("text-anchor", "middle") .attr( "transform", `translate(${-0.05 * Math.floor(dimensions.width)}, ${dimensions.mainHeight / 2}) rotate(-90)` ); diagram .append("g") .attr("class", "axis--y") .call(axes.y) .selectAll(".tick text") .attr("x", -25); diagram .append("g") .attr("class", "axis--y-right") .attr("transform", `translate(${dimensions.width})`) .call(axes.yRight) .selectAll(".tick text"); navigation .append("g") .attr("class", "axis axis--x") .attr("transform", `translate(0, ${dimensions.navHeight})`) .call(axes.x2); return () => { diagram .select(".axis--x") .call(axes.x) .selectAll(".tick text") .attr("y", 15); }; }, drawWaterlevelMinMaxAreaChart({ scale, diagram, navigation }) { const areaChart = isNav => d3 .area() .x(d => scale[isNav ? "x2" : "x"](d.date)) .y0(d => scale[isNav ? "y2" : "y"](d.min)) .y1(d => scale[isNav ? "y2" : "y"](d.max)); diagram .append("path") .datum(this.longtermWaterlevels) .attr("class", "area") .attr("d", areaChart()); navigation .append("path") .datum(this.longtermWaterlevels) .attr("class", "area") .attr("d", areaChart(true)); return () => { diagram.select(".area").attr("d", areaChart()); }; }, drawWaterlevelLineChart({ type, data, scale, diagram }) { const lineChart = type => d3 .line() .x(d => scale.x(d.date)) .y(d => scale.y(d[type])) .curve(d3.curveLinear); diagram .append("path") .attr("class", "line " + type) .datum(data || this.longtermWaterlevels) .attr("d", lineChart(type)); return () => { diagram.select(".line." + type).attr("d", lineChart(type)); }; }, drawRefLines({ refWaterLevels, scale, diagram, extent }) { const refWaterlevelLine = d3 .line() .x(d => scale.x(d.x)) .y(d => scale.y(d.y)); for (let ref in refWaterLevels) { if (refWaterLevels[ref]) { diagram .append("path") .datum([ { x: extent.date[0], y: refWaterLevels[ref] }, { x: extent.date[1], y: refWaterLevels[ref] } ]) .attr("class", ref.toLowerCase() + "-line") .attr("d", refWaterlevelLine); diagram // label .append("rect") .attr("class", "ref-waterlevel-label-background") .attr("x", 1) .attr("y", scale.y(refWaterLevels[ref]) - 13) .attr("width", 55) .attr("height", 12); diagram .append("text") .text( `${ref} (${this.$options.filters.waterlevel( refWaterLevels[ref] )})` ) .attr("class", "ref-waterlevel-label") .attr("x", 5) .attr("y", scale.y(refWaterLevels[ref]) - 3); } } }, createZoom({ updaters, eventRect, dimensions, scale, svg, navigation, zoomLevel }) { const brush = d3 .brushX() .handleSize(4) .extent([[0, 0], [dimensions.width, dimensions.navHeight]]); const zoom = d3 .zoom() .scaleExtent([1, Infinity]) .translateExtent([[0, 0], [dimensions.width, dimensions.mainHeight]]) .extent([[0, 0], [dimensions.width, 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 || scale.x2.range(); scale.x.domain(s.map(scale.x2.invert, scale.x2)); updaters.forEach(u => u && u()); this.setInlineStyles(svg); svg .select(".zoom") .call( zoom.transform, d3.zoomIdentity .scale(dimensions.width / (s[1] - s[0])) .translate(-s[0], 0) ); }); let scaleForZoom = t => { scale.x.domain(t.rescaleX(scale.x2).domain()); updaters.forEach(u => u && u()); this.setInlineStyles(svg); navigation .select(".brush") .call(brush.move, scale.x.range().map(t.invertX, t)); }; zoom.on("zoom", () => { if (d3.event.sourceEvent && d3.event.sourceEvent.type === "brush") return; // ignore zoom-by-brush let t = d3.event.transform; // Set the last zoom level and recalculate x,y for pdf-export if (zoomLevel) { const tx = (zoomLevel.x * dimensions.width) / zoomLevel.width; const k = zoomLevel.k; const ty = zoomLevel.y; t = d3.zoomIdentity.translate(tx, ty).scale(k); } scaleForZoom(t); }); zoom.on("start", () => { svg.select(".chart-dot").style("opacity", 0); svg.select(".chart-tooltip").style("opacity", 0); }); // store the zoom level after zomming is ended if (!zoomLevel) { zoom.on("end", () => { this.zoomStore = { ...d3.event.transform, width: dimensions.width }; }); } navigation .append("g") .attr("class", "brush") .call(brush) .call(brush.move, scale.x.range()); eventRect.call(zoom); }, createTooltips({ eventRect, diagram, scale, dimensions }) { // create clippable container for the dot diagram .append("g") .attr("class", "chart-dots") .append("circle") .attr("class", "chart-dot") .attr("r", 4); // create container for the tooltip const tooltip = 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", () => { diagram.select(".chart-dot").style("opacity", 1); diagram.select(".chart-tooltip").style("opacity", 1); }) .on("mouseout", () => { diagram.select(".chart-dot").style("opacity", 0); diagram.select(".chart-tooltip").style("opacity", 0); }) .on("mousemove", () => { // find data point closest to mouse const x0 = scale.x.invert( d3.mouse(document.getElementById("zoom-hydrocond"))[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: scale.x(d.date), y: scale.y(d.median) }; // position the dot 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%: ${this.$options.filters.waterlevel(d.q75)} m`); tooltipText .append("tspan") .attr("x", 0) .attr("y", 0) .attr("dy", "2.6em") .attr("dominant-baseline", "hanging") .attr("text-anchor", "middle") .text(`Median: ${this.$options.filters.waterlevel(d.median)} m`); tooltipText .append("tspan") .attr("x", 0) .attr("y", 0) .attr("dy", "3.8em") .attr("dominant-baseline", "hanging") .attr("text-anchor", "middle") .text(`Q25%: ${this.$options.filters.waterlevel(d.q25)} m`); tooltipText .append("tspan") .attr("x", 0) .attr("y", 0) .attr("dy", "5em") .attr("dominant-baseline", "hanging") .attr("text-anchor", "middle") .text(`Max: ${this.$options.filters.waterlevel(d.max)} m`); tooltipText .append("tspan") .attr("x", 0) .attr("y", 0) .attr("dy", "6.2em") .attr("dominant-baseline", "hanging") .attr("text-anchor", "middle") .text(`Min: ${this.$options.filters.waterlevel(d.min)} m`); const dYear = this.yearWaterlevels.find( ywl => ywl.date.getTime() === d.date.getTime() ); if (dYear) { if (!this.yearCompareD) return; tooltipText .append("tspan") .attr("x", 0) .attr("y", 0) .attr("dy", "7.4em") .attr("dominant-baseline", "hanging") .attr("text-anchor", "middle") .text(`${this.yearCompareD}: ${dYear.mean.toFixed(1)} cm`); } // get text dimensions const textBBox = tooltipText.node().getBBox(); diagram .selectAll(".chart-tooltip text tspan") .attr("x", textBBox.width / 2 + tooltipPadding) .attr("y", tooltipPadding); // position and scale tooltip box const xMax = 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; } 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() { 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.initialDiagramValues(); 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>