Mercurial > gemma
view client/src/components/gauge/HydrologicalConditions.vue @ 2815:12f053763be2
client: spuc8: finished drawing charts, optimized code
author | Markus Kottlaender <markus@intevation.de> |
---|---|
date | Tue, 26 Mar 2019 16:36:48 +0100 |
parents | 49c1570919ae |
children | f2e4c39cdcfa |
line wrap: on
line source
<template> <div class="d-flex flex-fill justify-content-center align-items-center diagram-container" > <div v-if="!longtermWaterlevels.length"> <translate>No data available.</translate> </div> </div> </template> <style lang="sass" scoped> .diagram-container /deep/ .hide opacity: 0 .line clip-path: url(#clip) stroke-width: 2 fill: none &.mean stroke: steelblue &.median stroke: black &.q25 stroke: orange &.q75 stroke: purple .area clip-path: url(#clip) stroke: none fill: lightsteelblue .hdc-line, .ldc-line, .mw-line stroke-width: 1 fill: none clip-path: url(#clip) .hdc-line stroke: red .ldc-line stroke: green .mw-line stroke: grey .ref-waterlevel-label font-size: 11px fill: #999 .now-line stroke: #999 stroke-width: 1 stroke-dasharray: 5, 5 clip-path: url(#clip) .now-line-label font-size: 11px fill: #999 .tick line stroke-dasharray: 5 stroke: #ccc .zoom cursor: move fill: none pointer-events: all .brush .selection stroke: none fill-opacity: 0.2 .handle stroke: rgba($color-info, 0.5) fill: rgba($color-info, 0.5) </style> <script> /* This is Free Software under GNU Affero General Public License v >= 3.0 * without warranty, see README.md and license for details. * * SPDX-License-Identifier: AGPL-3.0-or-later * License-Filename: LICENSES/AGPL-3.0.txt * * Copyright (C) 2018 by via donau * – Österreichische Wasserstraßen-Gesellschaft mbH * Software engineering by Intevation GmbH * * Author(s): * Markus Kottländer <markus.kottlaender@intevation.de> */ import { mapState, mapGetters } from "vuex"; import * as d3 from "d3"; import debounce from "debounce"; import { startOfYear, endOfYear } from "date-fns"; export default { data() { return { svg: null, diagram: null, navigation: null, dimensions: null, extent: null, scale: null, axes: null }; }, computed: { ...mapState("gauges", ["longtermWaterlevels", "yearWaterlevels"]), ...mapGetters("gauges", ["selectedGauge"]) }, methods: { drawDiagram() { // remove old diagram d3.select(".diagram-container svg").remove(); if (!this.selectedGauge || !this.longtermWaterlevels.length) return; // PREPARE HELPERS // HDC/LDC/MW for the selected gauge const refWaterLevels = JSON.parse( this.selectedGauge.properties.reference_water_levels ); // dimensions (widths, heights, margins) this.dimensions = this.getDimensions(); // get min/max values for date and waterlevel axis this.extent = this.getExtent(refWaterLevels); // scaling helpers this.scale = this.getScale(); // creating the axes based on the scales this.axes = this.getAxes(); // DRAW DIAGRAM/NAVIGATION AREAS // create svg this.svg = d3 .select(".diagram-container") .append("svg") .attr("width", "100%") .attr("height", "100%"); // create container for main diagram this.diagram = this.svg .append("g") .attr("class", "main") .attr( "transform", `translate(${this.dimensions.mainMargin.left}, ${ this.dimensions.mainMargin.top })` ); // create container for navigation diagram this.navigation = this.svg .append("g") .attr("class", "nav") .attr( "transform", `translate(${this.dimensions.navMargin.left}, ${ this.dimensions.navMargin.top })` ); // define visible area, everything outside this area will be hidden this.svg .append("defs") .append("clipPath") .attr("id", "clip") .append("rect") .attr("width", this.dimensions.width) .attr("height", this.dimensions.mainHeight); // DRAW DIAGRAM PARTS // Each drawSomething function (with the exception of drawRefLines) // returns a fuction to update the respective chart/area/etc. These // updater functions are used by the zoom feature to rescale all elements. const updaters = []; // draw this.drawRefLines(refWaterLevels); // static, doesn't need an updater updaters.push(this.drawAxes()); updaters.push(this.drawNowLines()); updaters.push(this.drawWaterlevelMinMaxAreaChart()); updaters.push(this.drawWaterlevelLineChart("median")); updaters.push(this.drawWaterlevelLineChart("q25")); updaters.push(this.drawWaterlevelLineChart("q75")); updaters.push(this.drawWaterlevelLineChart("mean", this.yearWaterlevels)); // INTERACTIONS // create rectanlge on the main chart area to capture mouse events const eventRect = this.svg .append("rect") .attr("class", "zoom") .attr("width", this.dimensions.width) .attr("height", this.dimensions.mainHeight) .attr( "transform", `translate(${this.dimensions.mainMargin.left}, ${ this.dimensions.mainMargin.top })` ); this.createZoom(updaters, eventRect); }, getDimensions() { // dimensions and margins const svgWidth = document.querySelector(".diagram-container").clientWidth; const svgHeight = document.querySelector(".diagram-container") .clientHeight; const mainMargin = { top: 20, right: 20, bottom: 110, left: 80 }; const navMargin = { top: svgHeight - mainMargin.top - 65, right: 20, bottom: 30, left: 80 }; const width = +svgWidth - mainMargin.left - mainMargin.right; const mainHeight = +svgHeight - mainMargin.top - mainMargin.bottom; const navHeight = +svgHeight - navMargin.top - navMargin.bottom; return { width, mainHeight, navHeight, mainMargin, navMargin }; }, getExtent(refWaterLevels) { const waterlevelsRelevantForExtent = []; this.longtermWaterlevels.forEach(wl => { waterlevelsRelevantForExtent.push(wl.min, wl.max); }); waterlevelsRelevantForExtent.push( refWaterLevels.HDC + (refWaterLevels.HDC - refWaterLevels.LDC) / 8, Math.max( refWaterLevels.LDC - (refWaterLevels.HDC - refWaterLevels.LDC) / 4, 0 ) ); return { // set min/max values for the date axis date: [startOfYear(new Date()), endOfYear(new Date())], // set min/max values for the waterlevel axis // including HDC (+ 1/8 HDC-LDC) and LDC (- 1/4 HDC-LDC) waterlevel: d3.extent(waterlevelsRelevantForExtent) }; }, getScale() { // scaling helpers to convert real world values into pixels const x = d3.scaleTime().range([0, this.dimensions.width]); const y = d3.scaleLinear().range([this.dimensions.mainHeight, 0]); const x2 = d3.scaleTime().range([0, this.dimensions.width]); const y2 = d3.scaleLinear().range([this.dimensions.navHeight, 0]); // setting the min and max values for the diagram axes x.domain(d3.extent(this.extent.date)); y.domain(this.extent.waterlevel); x2.domain(x.domain()); y2.domain(y.domain()); return { x, y, x2, y2 }; }, getAxes() { return { x: d3 .axisTop(this.scale.x) .tickSizeInner(this.dimensions.mainHeight) .tickSizeOuter(0) .tickFormat(date => { // make the x-axis label formats dynamic, based on zoom // but never display year numbers since they don't make any sense in // this diagram return (d3.timeSecond(date) < date ? d3.timeFormat(".%L") : d3.timeMinute(date) < date ? d3.timeFormat(":%S") : d3.timeHour(date) < date ? d3.timeFormat("%I:%M") : d3.timeDay(date) < date ? d3.timeFormat("%I %p") : d3.timeMonth(date) < date ? d3.timeWeek(date) < date ? d3.timeFormat("%a %d") : d3.timeFormat("%b %d") : d3.timeFormat("%B"))(date); }), y: d3 .axisRight(this.scale.y) .tickSizeInner(this.dimensions.width) .tickSizeOuter(0), x2: d3.axisBottom(this.scale.x2) }; }, drawNowLines() { const nowLine = d3 .line() .x(d => this.scale.x(d.x)) .y(d => this.scale.y(d.y)); const nowLabel = selection => { selection.attr( "transform", `translate(${this.scale.x(new Date())}, ${this.scale.y( this.extent.waterlevel[1] - 16 )})` ); }; // draw in main this.diagram .append("path") .datum([ { x: new Date(), y: this.extent.waterlevel[0] }, { x: new Date(), y: this.extent.waterlevel[1] - 20 } ]) .attr("class", "now-line") .attr("d", nowLine); this.diagram // label .append("text") .text(this.$gettext("Now")) .attr("class", "now-line-label") .attr("text-anchor", "middle") .call(nowLabel); // draw in nav this.navigation .append("path") .datum([ { x: new Date(), y: this.extent.waterlevel[0] }, { x: new Date(), y: this.extent.waterlevel[1] - 20 } ]) .attr("class", "now-line") .attr( "d", d3 .line() .x(d => this.scale.x2(d.x)) .y(d => this.scale.y2(d.y)) ); return () => { this.diagram.select(".now-line").attr("d", nowLine); this.diagram.select(".now-line-label").call(nowLabel); }; }, drawAxes() { this.diagram .append("g") .attr("class", "axis--x") .attr("transform", `translate(0, ${this.dimensions.mainHeight})`) .call(this.axes.x) .selectAll(".tick text") .attr("y", 15); this.diagram // label .append("text") .text(this.$gettext("Waterlevel [cm]")) .attr("text-anchor", "middle") .attr( "transform", `translate(-45, ${this.dimensions.mainHeight / 2}) rotate(-90)` ); this.diagram .append("g") .call(this.axes.y) .selectAll(".tick text") .attr("x", -25); this.navigation .append("g") .attr("class", "axis axis--x") .attr("transform", `translate(0, ${this.dimensions.navHeight})`) .call(this.axes.x2); return () => { this.diagram .select(".axis--x") .call(this.axes.x) .selectAll(".tick text") .attr("y", 15); }; }, drawWaterlevelMinMaxAreaChart() { const areaChart = isNav => d3 .area() .x(d => this.scale[isNav ? "x2" : "x"](d.date)) .y0(d => this.scale[isNav ? "y2" : "y"](d.min)) .y1(d => this.scale[isNav ? "y2" : "y"](d.max)); this.diagram .append("path") .datum(this.longtermWaterlevels) .attr("class", "area") .attr("d", areaChart()); this.navigation .append("path") .datum(this.longtermWaterlevels) .attr("class", "area") .attr("d", areaChart(true)); return () => { this.diagram.select(".area").attr("d", areaChart()); }; }, drawWaterlevelLineChart(type, data) { const lineChart = type => d3 .line() .x(d => this.scale.x(d.date)) .y(d => this.scale.y(d[type])) .curve(d3.curveLinear); this.diagram .append("path") .attr("class", "line " + type) .datum(data || this.longtermWaterlevels) .attr("d", lineChart(type)); return () => { this.diagram.select(".line." + type).attr("d", lineChart(type)); }; }, drawRefLines(refWaterLevels) { const refWaterlevelLine = d3 .line() .x(d => this.scale.x(d.x)) .y(d => this.scale.y(d.y)); // HDC this.diagram .append("path") .datum([ { x: 0, y: refWaterLevels.HDC }, { x: this.extent.date[1], y: refWaterLevels.HDC } ]) .attr("class", "hdc-line") .attr("d", refWaterlevelLine); this.diagram // label .append("text") .text(`HDC (${refWaterLevels.HDC})`) .attr("class", "ref-waterlevel-label") .attr("x", 5) .attr("y", this.scale.y(refWaterLevels.HDC) - 3); // LDC this.diagram .append("path") .datum([ { x: 0, y: refWaterLevels.LDC }, { x: this.extent.date[1], y: refWaterLevels.LDC } ]) .attr("class", "ldc-line") .attr("d", refWaterlevelLine); this.diagram // label .append("text") .text(`LDC (${refWaterLevels.LDC})`) .attr("class", "ref-waterlevel-label") .attr("x", 5) .attr("y", this.scale.y(refWaterLevels.LDC) - 3); // MW this.diagram .append("path") .datum([ { x: 0, y: refWaterLevels.MW }, { x: this.extent.date[1], y: refWaterLevels.MW } ]) .attr("class", "mw-line") .attr("d", refWaterlevelLine); this.diagram // label .append("text") .text(`MW (${refWaterLevels.MW})`) .attr("class", "ref-waterlevel-label") .attr("x", 5) .attr("y", this.scale.y(refWaterLevels.MW) - 3); }, createZoom(updaters, eventRect) { const brush = d3 .brushX() .handleSize(4) .extent([[0, 0], [this.dimensions.width, this.dimensions.navHeight]]); const zoom = d3 .zoom() .scaleExtent([1, Infinity]) .translateExtent([ [0, 0], [this.dimensions.width, this.dimensions.mainHeight] ]) .extent([[0, 0], [this.dimensions.width, this.dimensions.mainHeight]]); brush.on("brush end", () => { if (d3.event.sourceEvent && d3.event.sourceEvent.type === "zoom") return; // ignore brush-by-zoom let s = d3.event.selection || this.scale.x2.range(); this.scale.x.domain(s.map(this.scale.x2.invert, this.scale.x2)); updaters.forEach(u => u && u()); this.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); }, 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>