Mercurial > gemma
view client/src/components/gauge/Waterlevel.vue @ 2816:c02cebff3f16
client: SPUC7/8: fix tooltip size and positioning
The size is now dynamically calculated based on the content and the tooltip is now
guaranteed to be visible instead of reaching out of the viewport.
author | Markus Kottlaender <markus@intevation.de> |
---|---|
date | Tue, 26 Mar 2019 19:37:55 +0100 |
parents | 97cf32cf2562 |
children | 53c2bd009c68 |
line wrap: on
line source
<template> <div class="d-flex flex-fill justify-content-center align-items-center diagram-container" > <div v-if="!waterlevels.length"> <translate>No data available.</translate> </div> </div> </template> <style lang="sass" scoped> .diagram-container /deep/ .line clip-path: url(#clip) path stroke: steelblue stroke-width: 2 fill: none &.d3-line-chunked-chunk-gap stroke-opacity: 0 circle stroke-width: 0 fill: steelblue &.d3-line-chunked-chunk-predicted-point fill-opacity: 0.6 .hdc-line, .ldc-line, .mw-line stroke-width: 1 fill: none clip-path: url(#clip) .hdc-line stroke: red .ldc-line stroke: green .mw-line stroke: grey .ref-waterlevel-label font-size: 11px fill: #999 .hdc-ldc-area fill: rgba(0, 255, 0, 0.1) .now-line stroke: #999 stroke-width: 1 stroke-dasharray: 5, 5 clip-path: url(#clip) .now-line-label font-size: 11px fill: #999 .prediction-area fill: steelblue fill-opacity: 0.2 clip-path: url(#clip) path.nash-sutcliffe fill: none stroke: black stroke-width: 1 clip-path: url(#clip) &.ns72 fill: rgba(0, 0, 0, 0.05) text.nash-sutcliffe font-size: 10px clip-path: url(#clip) tspan:last-child font-size: 9px fill: #777 .tick line stroke-dasharray: 5 stroke: #ccc .zoom cursor: move fill: none pointer-events: all .brush .selection stroke: none fill-opacity: 0.2 .handle stroke: rgba($color-info, 0.5) fill: rgba($color-info, 0.5) .chart-dots clip-path: url(#clip) .chart-dot fill: steelblue stroke: steelblue pointer-events: none opacity: 0 transition: opacity 0.1s .chart-tooltip opacity: 0 transition: opacity 0.3s rect fill: #fff stroke: #ccc text fill: #666 font-size: 12px tspan:last-child font-weight: bold </style> <script> /* This is Free Software under GNU Affero General Public License v >= 3.0 * without warranty, see README.md and license for details. * * SPDX-License-Identifier: AGPL-3.0-or-later * License-Filename: LICENSES/AGPL-3.0.txt * * Copyright (C) 2018 by via donau * – Österreichische Wasserstraßen-Gesellschaft mbH * Software engineering by Intevation GmbH * * Author(s): * Markus Kottländer <markus.kottlaender@intevation.de> */ import { mapState, mapGetters } from "vuex"; import * as d3Base from "d3"; import { lineChunked } from "d3-line-chunked"; import { startOfDay, endOfDay } from "date-fns"; import debounce from "debounce"; // we should load only d3 modules we need but for now we'll go with the lazy way // https://www.giacomodebidda.com/how-to-import-d3-plugins-with-webpack/ // d3-line-chunked plugin: https://github.com/pbeshai/d3-line-chunked const d3 = Object.assign(d3Base, { lineChunked }); export default { data() { return { svg: null, diagram: null, navigation: null, dimensions: null, extent: null, scale: null, axes: null }; }, computed: { ...mapState("gauges", [ "dateFrom", "dateTo", "waterlevels", "nashSutcliffe" ]), ...mapGetters("gauges", ["selectedGauge"]) }, methods: { drawDiagram() { // remove old diagram and exit if necessary data is missing d3.select(".diagram-container svg").remove(); if (!this.selectedGauge || !this.waterlevels.length) return; // PREPARE HELPERS // HDC/LDC/MW for the selected gauge const refWaterLevels = JSON.parse( this.selectedGauge.properties.reference_water_levels ); // dimensions (widths, heights, margins) this.dimensions = this.getDimensions(); // get min/max values for date and waterlevel axis this.extent = this.getExtent(refWaterLevels); // scaling helpers this.scale = this.getScale(); // creating the axes based on the scales this.axes = { x: d3 .axisTop(this.scale.x) .tickSizeInner(this.dimensions.mainHeight) .tickSizeOuter(0), y: d3 .axisRight(this.scale.y) .tickSizeInner(this.dimensions.width) .tickSizeOuter(0), x2: d3.axisBottom(this.scale.x2) }; // DRAW DIAGRAM/NAVIGATION AREAS // create svg this.svg = d3 .select(".diagram-container") .append("svg") .attr("width", "100%") .attr("height", "100%"); // create container for main diagram this.diagram = this.svg .append("g") .attr("class", "main") .attr( "transform", `translate(${this.dimensions.mainMargin.left}, ${ this.dimensions.mainMargin.top })` ); // create container for navigation diagram this.navigation = this.svg .append("g") .attr("class", "nav") .attr( "transform", `translate(${this.dimensions.navMargin.left}, ${ this.dimensions.navMargin.top })` ); // define visible area, everything outside this area will be hidden this.svg .append("defs") .append("clipPath") .attr("id", "clip") .append("rect") .attr("width", this.dimensions.width) .attr("height", this.dimensions.mainHeight); // DRAW DIAGRAM PARTS // Each drawSomething function (with the exception of drawRefLines) // returns a fuction to update the respective chart/area/etc. These // updater functions are used by the zoom feature to rescale all elements. const updaters = []; // draw this.drawRefLines(refWaterLevels); // static, doesn't need an updater updaters.push(this.drawAxes()); updaters.push(this.drawWaterlevelCharts()); updaters.push(this.drawNowLines()); updaters.push(this.drawPredictionAreas()); updaters.push(this.drawNashSutcliffe(24)); updaters.push(this.drawNashSutcliffe(48)); updaters.push(this.drawNashSutcliffe(72)); // INTERACTIONS // create rectanlge on the main chart area to capture mouse events const eventRect = this.svg .append("rect") .attr("class", "zoom") .attr("width", this.dimensions.width) .attr("height", this.dimensions.mainHeight) .attr( "transform", `translate(${this.dimensions.mainMargin.left}, ${ this.dimensions.mainMargin.top })` ); this.createZoom(updaters, eventRect); this.createTooltips(eventRect); }, getDimensions() { // dimensions and margins const svgWidth = document.querySelector(".diagram-container").clientWidth; const svgHeight = document.querySelector(".diagram-container") .clientHeight; const mainMargin = { top: 20, right: 20, bottom: 110, left: 80 }; const navMargin = { top: svgHeight - mainMargin.top - 65, right: 20, bottom: 30, left: 80 }; const width = +svgWidth - mainMargin.left - mainMargin.right; const mainHeight = +svgHeight - mainMargin.top - mainMargin.bottom; const navHeight = +svgHeight - navMargin.top - navMargin.bottom; return { width, mainHeight, navHeight, mainMargin, navMargin }; }, getExtent(refWaterLevels) { return { // set min/max values for the date axis date: [startOfDay(this.dateFrom), endOfDay(this.dateTo)], // set min/max values for the waterlevel axis // including HDC (+ 1/8 HDC-LDC) and LDC (- 1/4 HDC-LDC) waterlevel: d3.extent( [ ...this.waterlevels, { waterlevel: refWaterLevels.HDC + (refWaterLevels.HDC - refWaterLevels.LDC) / 8 }, { waterlevel: Math.max( refWaterLevels.LDC - (refWaterLevels.HDC - refWaterLevels.LDC) / 4, 0 ) } ], d => d.waterlevel ) }; }, getScale() { // scaling helpers to convert real world values into pixels const x = d3.scaleTime().range([0, this.dimensions.width]); const y = d3.scaleLinear().range([this.dimensions.mainHeight, 0]); const x2 = d3.scaleTime().range([0, this.dimensions.width]); const y2 = d3.scaleLinear().range([this.dimensions.navHeight, 0]); // setting the min and max values for the diagram axes x.domain(d3.extent(this.extent.date)); y.domain(this.extent.waterlevel); x2.domain(x.domain()); y2.domain(y.domain()); return { x, y, x2, y2 }; }, drawAxes() { this.diagram .append("g") .attr("class", "axis--x") .attr("transform", `translate(0, ${this.dimensions.mainHeight})`) .call(this.axes.x) .selectAll(".tick text") .attr("y", 15); this.diagram // label .append("text") .text(this.$gettext("Waterlevel [cm]")) .attr("text-anchor", "middle") .attr( "transform", `translate(-45, ${this.dimensions.mainHeight / 2}) rotate(-90)` ); this.diagram .append("g") .call(this.axes.y) .selectAll(".tick text") .attr("x", -25); this.navigation .append("g") .attr("class", "axis axis--x") .attr("transform", `translate(0, ${this.dimensions.navHeight})`) .call(this.axes.x2); return () => { this.diagram .select(".axis--x") .call(this.axes.x) .selectAll(".tick text") .attr("y", 15); }; }, drawWaterlevelCharts() { const waterlevelChartDrawer = isNav => { return d3 .lineChunked() .x(d => this.scale[isNav ? "x2" : "x"](d.date)) .y(d => this.scale[isNav ? "y2" : "y"](d.waterlevel)) .curve(d3.curveLinear) .isNext(this.isNext(900)) .pointAttrs({ r: isNav ? 1.7 : 2.2 }) .chunk(d => (d.predicted ? "predicted" : "line")) .chunkDefinitions({ predicted: {} }); }; this.diagram .append("g") .attr("class", "line") .datum(this.waterlevels) .call(waterlevelChartDrawer()); this.navigation .append("g") .attr("class", "line") .datum(this.waterlevels) .call(waterlevelChartDrawer(true)); return () => { this.diagram.select(".line").call(waterlevelChartDrawer()); }; }, drawNowLines() { const nowLine = d3 .line() .x(d => this.scale.x(d.x)) .y(d => this.scale.y(d.y)); const nowLabel = selection => { selection.attr( "transform", `translate(${this.scale.x(new Date())}, ${this.scale.y( this.extent.waterlevel[1] - 16 )})` ); }; // draw in main this.diagram .append("path") .datum([ { x: new Date(), y: this.extent.waterlevel[0] }, { x: new Date(), y: this.extent.waterlevel[1] - 20 } ]) .attr("class", "now-line") .attr("d", nowLine); this.diagram // label .append("text") .text(this.$gettext("Now")) .attr("class", "now-line-label") .attr("text-anchor", "middle") .call(nowLabel); // draw in nav this.navigation .append("path") .datum([ { x: new Date(), y: this.extent.waterlevel[0] }, { x: new Date(), y: this.extent.waterlevel[1] - 20 } ]) .attr("class", "now-line") .attr( "d", d3 .line() .x(d => this.scale.x2(d.x)) .y(d => this.scale.y2(d.y)) ); return () => { this.diagram.select(".now-line").attr("d", nowLine); this.diagram.select(".now-line-label").call(nowLabel); }; }, drawPredictionAreas() { const predictionArea = isNav => d3 .area() .defined(d => d.predicted && d.min && d.max) .x(d => this.scale[isNav ? "x2" : "x"](d.date)) .y0(d => this.scale[isNav ? "y2" : "y"](d.min)) .y1(d => this.scale[isNav ? "y2" : "y"](d.max)); this.diagram .append("path") .datum(this.waterlevels) .attr("class", "prediction-area") .attr("d", predictionArea()); this.navigation .append("path") .datum(this.waterlevels) .attr("class", "prediction-area") .attr("d", predictionArea(true)); return () => { this.diagram.select(".prediction-area").attr("d", predictionArea()); }; }, drawRefLines(refWaterLevels) { // filling area between HDC and LDC this.diagram .append("rect") .attr("class", "hdc-ldc-area") .attr("x", 0) .attr("y", this.scale.y(refWaterLevels.HDC)) .attr("width", this.dimensions.width) .attr( "height", this.scale.y(refWaterLevels.LDC) - this.scale.y(refWaterLevels.HDC) ); const refWaterlevelLine = d3 .line() .x(d => this.scale.x(d.x)) .y(d => this.scale.y(d.y)); // HDC this.diagram .append("path") .datum([ { x: 0, y: refWaterLevels.HDC }, { x: this.extent.date[1], y: refWaterLevels.HDC } ]) .attr("class", "hdc-line") .attr("d", refWaterlevelLine); this.diagram // label .append("text") .text(`HDC (${refWaterLevels.HDC})`) .attr("class", "ref-waterlevel-label") .attr("x", 5) .attr("y", this.scale.y(refWaterLevels.HDC) - 3); // LDC this.diagram .append("path") .datum([ { x: 0, y: refWaterLevels.LDC }, { x: this.extent.date[1], y: refWaterLevels.LDC } ]) .attr("class", "ldc-line") .attr("d", refWaterlevelLine); this.diagram // label .append("text") .text(`LDC (${refWaterLevels.LDC})`) .attr("class", "ref-waterlevel-label") .attr("x", 5) .attr("y", this.scale.y(refWaterLevels.LDC) - 3); // MW this.diagram .append("path") .datum([ { x: 0, y: refWaterLevels.MW }, { x: this.extent.date[1], y: refWaterLevels.MW } ]) .attr("class", "mw-line") .attr("d", refWaterlevelLine); this.diagram // label .append("text") .text(`MW (${refWaterLevels.MW})`) .attr("class", "ref-waterlevel-label") .attr("x", 5) .attr("y", this.scale.y(refWaterLevels.MW) - 3); }, drawNashSutcliffe(hours) { const coeff = this.nashSutcliffe.coeffs.find(c => c.hours === hours); const dateNow = new Date(this.nashSutcliffe.when); const dateStart = new Date(dateNow.getTime() - hours * 60 * 60 * 1000); const nashSutcliffeBox = hours => { return d3 .area() .x(d => this.scale.x(d)) .y0(() => this.dimensions.mainHeight + 0.5) .y1(() => this.dimensions.mainHeight - 15 * (hours / 24)); }; const nashSutcliffeLabel = (label, date, hours) => { let days = hours / 24; label .attr("x", this.scale.x(date) + 3) .attr("y", this.dimensions.mainHeight - (15 * days + 0.5) + 12); }; // Show nash-sutcliffe only when x-axis extent is smaller than 35 days // (3024000000 ms). Since it shows squares representing 1, 2 and 3 days // it does not make sense to show them on a x-axis with hundres of days. if ( coeff.samples && this.scale.x.domain()[1] - this.scale.x.domain()[0] < 3024000000 ) { this.diagram .append("path") .datum([dateStart, dateNow]) .attr("class", "nash-sutcliffe ns" + hours) .attr("d", nashSutcliffeBox(hours)); this.diagram .append("text") .attr("class", "nash-sutcliffe ns" + hours) .call(nashSutcliffeLabel, dateStart, hours) .append("tspan") .text(coeff.value.toFixed(2)) .select(function() { return this.parentNode; }) .append("tspan") .text(` (${coeff.samples})`) .attr("dy", -1); return () => { this.diagram .select("path.nash-sutcliffe.ns" + hours) .attr("d", nashSutcliffeBox(hours)); this.diagram .select("text.nash-sutcliffe.ns" + hours) .call(nashSutcliffeLabel, dateStart, hours); }; } }, createZoom(updaters, eventRect) { const brush = d3 .brushX() .handleSize(4) .extent([[0, 0], [this.dimensions.width, this.dimensions.navHeight]]); const zoom = d3 .zoom() .scaleExtent([1, Infinity]) .translateExtent([ [0, 0], [this.dimensions.width, this.dimensions.mainHeight] ]) .extent([[0, 0], [this.dimensions.width, this.dimensions.mainHeight]]); brush.on("brush end", () => { if (d3.event.sourceEvent && d3.event.sourceEvent.type === "zoom") return; // ignore brush-by-zoom let s = d3.event.selection || this.scale.x2.range(); this.scale.x.domain(s.map(this.scale.x2.invert, this.scale.x2)); updaters.forEach(u => u && u()); this.svg .select(".zoom") .call( zoom.transform, d3.zoomIdentity .scale(this.dimensions.width / (s[1] - s[0])) .translate(-s[0], 0) ); }); zoom.on("zoom", () => { if (d3.event.sourceEvent && d3.event.sourceEvent.type === "brush") return; // ignore zoom-by-brush let t = d3.event.transform; this.scale.x.domain(t.rescaleX(this.scale.x2).domain()); updaters.forEach(u => u && u()); this.navigation .select(".brush") .call(brush.move, this.scale.x.range().map(t.invertX, t)); }); zoom.on("start", () => { this.svg.select(".chart-dot").style("opacity", 0); this.svg.select(".chart-tooltip").style("opacity", 0); }); this.navigation .append("g") .attr("class", "brush") .call(brush) .call(brush.move, this.scale.x.range()); eventRect.call(zoom); }, createTooltips(eventRect) { // create clippable container for the dot this.diagram .append("g") .attr("class", "chart-dots") .append("circle") .attr("class", "chart-dot") .attr("r", 4); // create container for the tooltip const tooltip = this.diagram.append("g").attr("class", "chart-tooltip"); tooltip.append("rect"); const padding = 5; // create container for multiple text rows const tooltipText = tooltip.append("text").attr("text-anchor", "middle"); tooltipText.append("tspan").attr("alignment-baseline", "hanging"); tooltipText .append("tspan") .attr("dy", 18) .attr("alignment-baseline", "hanging"); 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})`); // write date this.diagram.select(".chart-tooltip text tspan:first-child").text( d.date.toLocaleString([], { year: "2-digit", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }) ); // write waterlevel this.diagram .select(".chart-tooltip text tspan:last-child") .text(d.waterlevel + " cm"); // get text dimensions const textBBox = this.diagram .select(".chart-tooltip text") .node() .getBBox(); this.diagram .selectAll(".chart-tooltip text tspan") .attr("x", textBBox.width / 2 + padding) .attr("y", padding); // position and scale tooltip box let xMax = this.dimensions.width - textBBox.width; let tooltipX = Math.min(coords.x - textBBox.width / 2, xMax); let tooltipY = coords.y - textBBox.height * 2 + padding * 2; this.diagram .select(".chart-tooltip") .style("opacity", 1) .attr("transform", `translate(${tooltipX}, ${tooltipY})`) .select("rect") .attr("width", textBBox.width + padding * 2) .attr("height", textBBox.height + padding * 2); }); }, isNext(seconds) { // helper to check whether points in the chart are "next to each other" // for that they need to be exactly the specified amount of seconds apart. return (prev, current) => current.date - prev.date === seconds * 1000; } }, created() { window.addEventListener("resize", debounce(this.drawDiagram), 100); }, mounted() { this.drawDiagram(); }, updated() { this.drawDiagram(); } }; </script>