# HG changeset patch # User Markus Kottlaender # Date 1553614608 -3600 # Node ID 12f053763be2740919c6b976b0dd19b405c846bf # Parent 6f435a9558f25b5b6f657459b161b46b0146ff21 client: spuc8: finished drawing charts, optimized code diff -r 6f435a9558f2 -r 12f053763be2 client/src/components/gauge/HydrologicalConditions.vue --- a/client/src/components/gauge/HydrologicalConditions.vue Tue Mar 26 14:56:38 2019 +0100 +++ b/client/src/components/gauge/HydrologicalConditions.vue Tue Mar 26 16:36:48 2019 +0100 @@ -45,6 +45,14 @@ .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 @@ -85,338 +93,443 @@ 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", "minMaxWaterlevelForDay"]) + ...mapGetters("gauges", ["selectedGauge"]) }, methods: { drawDiagram() { - // TODO: Optimize code. I'm pretty sure all of this can be done in a much - // more elegant way and with less lines of code. - // remove old diagram d3.select(".diagram-container svg").remove(); - if (!this.selectedGauge || !this.longtermWaterlevels.length) return; - // get HDC/LDC/MW of the gauge - let refWaterLevels = JSON.parse( + // PREPARE HELPERS + + // HDC/LDC/MW for the selected gauge + const refWaterLevels = JSON.parse( this.selectedGauge.properties.reference_water_levels ); - // CREATE SVG AND SET DIMENSIONS/MARGINS + // dimensions (widths, heights, margins) + this.dimensions = this.getDimensions(); + + // get min/max values for date and waterlevel axis + this.extent = this.getExtent(refWaterLevels); - let svgWidth = document.querySelector(".diagram-container").clientWidth; - let svgHeight = document.querySelector(".diagram-container").clientHeight; - let svg = d3 + // 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%"); - let mainMargin = { top: 20, right: 20, bottom: 110, left: 80 }, - navMargin = { - top: svgHeight - mainMargin.top - 65, - right: 20, - bottom: 30, - left: 80 - }, - width = +svgWidth - mainMargin.left - mainMargin.right, - mainHeight = +svgHeight - mainMargin.top - mainMargin.bottom, - navHeight = +svgHeight - navMargin.top - navMargin.bottom; - // PREPARING AXES/SCALING + // 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 + })` + ); - // scaling helpers to convert real values to pixels - // based on the diagrams dimensions - let x = d3.scaleTime().range([0, width]), - x2 = d3.scaleTime().range([0, width]), - y = d3.scaleLinear().range([mainHeight, 0]), - y2 = d3.scaleLinear().range([navHeight, 0]); - // find min/max values for the waterlevel axis - // including HDC/LDC (+/- 1/8 HDC-LDC) - let WaterlevelMinMax = d3.extent( - [ - ...this.longtermWaterlevels, - { - waterlevel: - refWaterLevels.HDC + (refWaterLevels.HDC - refWaterLevels.LDC) / 8 - }, - { - waterlevel: Math.max( - refWaterLevels.LDC - - (refWaterLevels.HDC - refWaterLevels.LDC) / 8, - 0 - ) - } - ], - d => d.waterlevel - ); - // setting the min and max values for the diagram axes - let yearStart = startOfYear(new Date()); - let yearEnd = endOfYear(new Date()); - x.domain(d3.extent([yearStart, yearEnd])); - y.domain(WaterlevelMinMax); - x2.domain(x.domain()); - y2.domain(y.domain()); - // creating the axes based on these scales - let xAxis = d3 - .axisTop(x) - .tickSizeInner(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); - }); - let xAxis2 = d3.axisBottom(x2).tickFormat(d3.timeFormat("%B")); - let yAxis = d3 - .axisRight(y) - .tickSizeInner(width) - .tickSizeOuter(0); + // 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 + })` + ); - // PREPARING CHART FUNCTIONS - - // waterlevel line charts in big chart - const lineChart = type => - d3 - .line() - .x(d => x(d.date)) - .y(d => y(d[type])) - .curve(d3.curveLinear); - - // overall min/max area chart - const areaChart = d3 - .area() - .x(d => x(d.date)) - .y0(d => y(d.min)) - .y1(d => y(d.max)); - - // overall min/max area chart in nav - const areaChartNav = d3 - .area() - .x(d => x2(d.date)) - .y0(d => y2(d.min)) - .y1(d => y2(d.max)); - - // hdc/ldc/mw - let refWaterlevelLine = d3 - .line() - .x(d => x(d.x)) - .y(d => y(d.y)); - - // DRAWING MAINCHART - - // define visible chart area - // everything outside this area will be hidden (clipped) - svg + // define visible area, everything outside this area will be hidden + this.svg .append("defs") .append("clipPath") .attr("id", "clip") .append("rect") - .attr("width", width) - .attr("height", mainHeight); + .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()); - let mainChart = svg - .append("g") - .attr("class", "main") - .attr("transform", `translate(${mainMargin.left}, ${mainMargin.top})`); + 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)); - // axes - mainChart + 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, ${mainHeight})`) - .call(xAxis) + .attr("transform", `translate(0, ${this.dimensions.mainHeight})`) + .call(this.axes.x) .selectAll(".tick text") .attr("y", 15); - mainChart // label + this.diagram // label .append("text") .text(this.$gettext("Waterlevel [cm]")) .attr("text-anchor", "middle") - .attr("transform", `translate(-45, ${mainHeight / 2}) rotate(-90)`); - mainChart + .attr( + "transform", + `translate(-45, ${this.dimensions.mainHeight / 2}) rotate(-90)` + ); + this.diagram .append("g") - .call(yAxis) + .call(this.axes.y) .selectAll(".tick text") .attr("x", -25); - // overall min/max area chart - mainChart + 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); + .attr("d", areaChart()); + + this.navigation + .append("path") + .datum(this.longtermWaterlevels) + .attr("class", "area") + .attr("d", areaChart(true)); - // reference waterlevels + 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 - mainChart + this.diagram .append("path") .datum([ { x: 0, y: refWaterLevels.HDC }, - { x: yearEnd, y: refWaterLevels.HDC } + { x: this.extent.date[1], y: refWaterLevels.HDC } ]) .attr("class", "hdc-line") .attr("d", refWaterlevelLine); - mainChart // label + this.diagram // label .append("text") - .text("HDC") + .text(`HDC (${refWaterLevels.HDC})`) .attr("class", "ref-waterlevel-label") - .attr("x", x(yearEnd) - 20) - .attr("y", y(refWaterLevels.HDC) - 3); + .attr("x", 5) + .attr("y", this.scale.y(refWaterLevels.HDC) - 3); // LDC - mainChart + this.diagram .append("path") .datum([ { x: 0, y: refWaterLevels.LDC }, - { x: yearEnd, y: refWaterLevels.LDC } + { x: this.extent.date[1], y: refWaterLevels.LDC } ]) .attr("class", "ldc-line") .attr("d", refWaterlevelLine); - mainChart // label + this.diagram // label .append("text") - .text("LDC") + .text(`LDC (${refWaterLevels.LDC})`) .attr("class", "ref-waterlevel-label") - .attr("x", x(yearEnd) - 20) - .attr("y", y(refWaterLevels.LDC) - 3); + .attr("x", 5) + .attr("y", this.scale.y(refWaterLevels.LDC) - 3); // MW - mainChart + this.diagram .append("path") .datum([ { x: 0, y: refWaterLevels.MW }, - { x: yearEnd, y: refWaterLevels.MW } + { x: this.extent.date[1], y: refWaterLevels.MW } ]) .attr("class", "mw-line") .attr("d", refWaterlevelLine); - mainChart // label + this.diagram // label .append("text") - .text("MW") + .text(`MW (${refWaterLevels.MW})`) .attr("class", "ref-waterlevel-label") - .attr("x", x(yearEnd) - 20) - .attr("y", y(refWaterLevels.MW) - 3); - - // mean waterlevel chart - mainChart - .append("path") - .attr("class", "line mean") - .datum(this.longtermWaterlevels) - .attr("d", lineChart("mean")); - // median waterlevel chart - mainChart - .append("path") - .attr("class", "line median") - .datum(this.longtermWaterlevels) - .attr("d", lineChart("median")); - // q25 waterlevel chart - mainChart - .append("path") - .attr("class", "line q25") - .datum(this.longtermWaterlevels) - .attr("d", lineChart("q25")); - // q75 waterlevel chart - mainChart - .append("path") - .attr("class", "line q75") - .datum(this.longtermWaterlevels) - .attr("d", lineChart("q75")); - - // DRAWING NAVCHART - - let navChart = svg - .append("g") - .attr("class", "nav") - .attr("transform", `translate(${navMargin.left}, ${navMargin.top})`); - - // axis (nav chart only has x-axis) - navChart - .append("g") - .attr("class", "axis axis--x") - .attr("transform", `translate(0, ${navHeight})`) - .call(xAxis2); - - // overall min/max area chart - navChart - .append("path") - .datum(this.longtermWaterlevels) - .attr("class", "area") - .attr("d", areaChartNav); - - // INTERACTIVITY - - const updateChart = () => { - mainChart.select(".line.mean").attr("d", lineChart("mean")); - mainChart.select(".line.median").attr("d", lineChart("median")); - mainChart.select(".line.q25").attr("d", lineChart("q25")); - mainChart.select(".line.q75").attr("d", lineChart("q75")); - mainChart.select(".area").attr("d", areaChart); - mainChart - .select(".axis--x") - .call(xAxis) - .selectAll(".tick text") - .attr("y", 15); - }; - - // selecting time period in nav chart - let brush = d3 + .attr("x", 5) + .attr("y", this.scale.y(refWaterLevels.MW) - 3); + }, + createZoom(updaters, eventRect) { + const brush = d3 .brushX() .handleSize(4) - .extent([[0, 0], [width, navHeight]]) - .on("brush end", () => { - if (d3.event.sourceEvent && d3.event.sourceEvent.type === "zoom") - return; // ignore brush-by-zoom - let s = d3.event.selection || x2.range(); - x.domain(s.map(x2.invert, x2)); - updateChart(); - svg - .select(".zoom") - .call( - zoom.transform, - d3.zoomIdentity.scale(width / (s[1] - s[0])).translate(-s[0], 0) - ); - }); + .extent([[0, 0], [this.dimensions.width, this.dimensions.navHeight]]); - // zooming with mousewheel in main chart - let zoom = d3 + const zoom = d3 .zoom() .scaleExtent([1, Infinity]) - .translateExtent([[0, 0], [width, mainHeight]]) - .extent([[0, 0], [width, mainHeight]]) - .on("zoom", () => { - if (d3.event.sourceEvent && d3.event.sourceEvent.type === "brush") - return; // ignore zoom-by-brush - let t = d3.event.transform; - x.domain(t.rescaleX(x2).domain()); - updateChart(); - navChart - .select(".brush") - .call(brush.move, x.range().map(t.invertX, t)); - }); + .translateExtent([ + [0, 0], + [this.dimensions.width, this.dimensions.mainHeight] + ]) + .extent([[0, 0], [this.dimensions.width, this.dimensions.mainHeight]]); - navChart + 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, x.range()); + .call(brush.move, this.scale.x.range()); - svg - .append("rect") - .attr("class", "zoom") - .attr("width", width) - .attr("height", mainHeight) - .attr("transform", `translate(${mainMargin.left}, ${mainMargin.top})`) - .call(zoom); + 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() { diff -r 6f435a9558f2 -r 12f053763be2 client/src/store/gauges.js --- a/client/src/store/gauges.js Tue Mar 26 14:56:38 2019 +0100 +++ b/client/src/store/gauges.js Tue Mar 26 16:36:48 2019 +0100 @@ -201,12 +201,7 @@ ); return { date: date, - min: Number(wl[1]), - max: Number(wl[2]), - mean: Number(wl[3]), - median: Number(wl[4]), - q25: Number(wl[5]), - q75: Number(wl[6]) + mean: Number(wl[1]) }; }); data = data.sort((a, b) => a.date - b.date);