# HG changeset patch # User Markus Kottlaender # Date 1553545612 -3600 # Node ID eb91ad1d7a93aef09b3397858a6505125fed0775 # Parent e3c5efd21cb4aa5a14dcb87ff225294f6d4d6bd0 client: waterlevel diagram: optimized code for better readability diff -r e3c5efd21cb4 -r eb91ad1d7a93 client/src/components/gauge/Waterlevel.vue --- a/client/src/components/gauge/Waterlevel.vue Mon Mar 25 18:37:36 2019 +0100 +++ b/client/src/components/gauge/Waterlevel.vue Mon Mar 25 21:26:52 2019 +0100 @@ -22,6 +22,8 @@ circle stroke-width: 0 fill: steelblue + &.d3-line-chunked-chunk-predicted-point + fill-opacity: 0.6 .hdc-line, .ldc-line, @@ -123,522 +125,526 @@ 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", [ - "waterlevels", "dateFrom", "dateTo", + "waterlevels", "nashSutcliffe" ]), ...mapGetters("gauges", ["selectedGauge"]) }, - watch: { - waterlevels() { - this.drawDiagram(); - } - }, 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 + // remove old diagram and exit if necessary data is missing d3.select(".diagram-container svg").remove(); - if (!this.selectedGauge || !this.waterlevels.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); + + // scaling helpers + this.scale = this.getScale(); - let svgWidth = document.querySelector(".diagram-container").clientWidth; - let svgHeight = document.querySelector(".diagram-container").clientHeight; - let svg = d3 + // 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%"); - 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 - - // 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 (+ 1/8 HDC-LDC) and LDC (- 1/4 HDC-LDC) - let WaterlevelMinMax = 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 - ); - // setting the min and max values for the diagram axes - let dateTo = new Date(this.dateTo.getTime() + 86400); - x.domain(d3.extent([this.dateFrom, dateTo])); - 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); - let xAxis2 = d3.axisBottom(x2); - let yAxis = d3 - .axisRight(y) - .tickSizeInner(width) - .tickSizeOuter(0); - - // PREPARING CHART FUNCTIONS - - // points are "next to each other" when they are exactly 15 minutes apart - const isNext = (prev, current) => - current.date - prev.date === 15 * 60 * 1000; + // 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 + })` + ); - const preditionStyle = { - predicted: { - pointStyles: { - fill: "steelblue", - "fill-opacity": 0.6 - } - } - }; + // 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 + })` + ); - // waterlevel line in big chart - // d3-line-chunked plugin: https://github.com/pbeshai/d3-line-chunked - var mainLineChart = d3 - .lineChunked() - .x(d => x(d.date)) - .y(d => y(d.waterlevel)) - .curve(d3.curveLinear) - .isNext(isNext) - .pointAttrs({ r: 2.2 }) - .chunk(d => (d.predicted ? "predicted" : "line")) - .chunkDefinitions(preditionStyle); - // waterlevel line in small chart - let navLineChart = d3 - .lineChunked() - .x(d => x2(d.date)) - .y(d => y2(d.waterlevel)) - .curve(d3.curveMonotoneX) - .isNext(isNext) - .pointAttrs({ r: 1.7 }) - .chunk(d => (d.predicted ? "predicted" : "line")) - .chunkDefinitions(preditionStyle); - // hdc/ldc/mw - let refWaterlevelLine = d3 - .line() - .x(d => x(d.x)) - .y(d => y(d.y)); - // now - let nowLine = d3 - .line() - .x(d => x(d.x)) - .y(d => y(d.y)); - let nowLineNav = d3 - .line() - .x(d => x2(d.x)) - .y(d => y2(d.y)); - let nowLineLabel = selection => { - selection.attr( - "transform", - `translate(${x(new Date())}, ${y(WaterlevelMinMax[1] - 16)})` - ); - }; - // prediction area - let predictionArea = d3 - .area() - .defined(d => d.predicted && d.min && d.max) - .x(d => x(d.date)) - .y0(d => y(d.min)) - .y1(d => y(d.max)); - let predictionAreaNav = d3 - .area() - .defined(d => d.predicted && d.min && d.max) - .x(d => x2(d.date)) - .y0(d => y2(d.min)) - .y1(d => y2(d.max)); - - // 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.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 + })` + ); - let mainChart = svg - .append("g") - .attr("class", "main") - .attr("transform", `translate(${mainMargin.left}, ${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; - // axes - mainChart + 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, ${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); - // reference waterlevels - // filling area between HDC and LDC - mainChart - .append("rect") - .attr("class", "hdc-ldc-area") - .attr("x", 0) - .attr("y", y(refWaterLevels.HDC)) - .attr("width", width) - .attr("height", y(refWaterLevels.LDC) - y(refWaterLevels.HDC)); + 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: {} }); + }; - // HDC - mainChart - .append("path") - .datum([ - { x: 0, y: refWaterLevels.HDC }, - { x: dateTo, y: refWaterLevels.HDC } - ]) - .attr("class", "hdc-line") - .attr("d", refWaterlevelLine); - mainChart // label - .append("text") - .text("HDC") - .attr("class", "ref-waterlevel-label") - .attr("x", x(dateTo) - 20) - .attr("y", y(refWaterLevels.HDC) - 3); - // LDC - mainChart + 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: 0, y: refWaterLevels.LDC }, - { x: dateTo, y: refWaterLevels.LDC } - ]) - .attr("class", "ldc-line") - .attr("d", refWaterlevelLine); - mainChart // label - .append("text") - .text("LDC") - .attr("class", "ref-waterlevel-label") - .attr("x", x(dateTo) - 20) - .attr("y", y(refWaterLevels.LDC) - 3); - // MW - mainChart - .append("path") - .datum([ - { x: 0, y: refWaterLevels.MW }, - { x: dateTo, y: refWaterLevels.MW } - ]) - .attr("class", "mw-line") - .attr("d", refWaterlevelLine); - mainChart // label - .append("text") - .text("MW") - .attr("class", "ref-waterlevel-label") - .attr("x", x(dateTo) - 20) - .attr("y", y(refWaterLevels.MW) - 3); - - // now - mainChart - .append("path") - .datum([ - { x: new Date(), y: WaterlevelMinMax[0] }, - { x: new Date(), y: WaterlevelMinMax[1] - 20 } + { x: new Date(), y: this.extent.waterlevel[0] }, + { x: new Date(), y: this.extent.waterlevel[1] - 20 } ]) .attr("class", "now-line") .attr("d", nowLine); - mainChart // label + this.diagram // label .append("text") .text(this.$gettext("Now")) .attr("class", "now-line-label") .attr("text-anchor", "middle") - .call(nowLineLabel); + .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)) + ); - // prediction area - mainChart + 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); - - // waterlevel chart - mainChart - .append("g") - .attr("class", "line") - .datum(this.waterlevels) - .call(mainLineChart); - - // DRAWING NAVCHART + .attr("d", predictionArea(true)); - let navChart = svg - .append("g") - .attr("class", "nav") - .attr("transform", `translate(${navMargin.left}, ${navMargin.top})`); + 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) + ); - // axis (nav chart only has x-axis) - navChart - .append("g") - .attr("class", "axis axis--x") - .attr("transform", `translate(0, ${navHeight})`) - .call(xAxis2); + const refWaterlevelLine = d3 + .line() + .x(d => this.scale.x(d.x)) + .y(d => this.scale.y(d.y)); - // now - navChart + // HDC + this.diagram .append("path") .datum([ - { x: new Date(), y: WaterlevelMinMax[0] }, - { x: new Date(), y: WaterlevelMinMax[1] - 20 } + { x: 0, y: refWaterLevels.HDC }, + { x: this.extent.date[1], y: refWaterLevels.HDC } ]) - .attr("class", "now-line") - .attr("d", nowLineNav); - - // prediction area - navChart + .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(this.waterlevels) - .attr("class", "prediction-area") - .attr("d", predictionAreaNav); - - // waterlevel chart - navChart - .append("g") - .attr("class", "line") - .datum(this.waterlevels) - .call(navLineChart); - - // NASH SUTCLIFFE - - let nashSut24 = this.nashSutcliffe.coeffs.find(c => c.hours === 24); - let nashSut48 = this.nashSutcliffe.coeffs.find(c => c.hours === 48); - let nashSut72 = this.nashSutcliffe.coeffs.find(c => c.hours === 72); - - let nashSutDateNow = new Date(this.nashSutcliffe.when); - let nashSutDate24 = new Date(this.nashSutcliffe.when); - let nashSutDate48 = new Date(this.nashSutcliffe.when); - let nashSutDate72 = new Date(this.nashSutcliffe.when); - nashSutDate24.setDate(nashSutDate24.getDate() - 1); - nashSutDate48.setDate(nashSutDate48.getDate() - 2); - nashSutDate72.setDate(nashSutDate72.getDate() - 3); + .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 => x(d)) - .y0(() => mainHeight + 0.5) - .y1(() => mainHeight - 15 * (hours / 24)); + .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", x(date) + 3) - .attr("y", mainHeight - (15 * days + 0.5) + 12); + .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 (this.nashSutcliffe && x.domain()[1] - x.domain()[0] < 3024000000) { - if (nashSut24.samples) { - mainChart - .append("path") - .datum([nashSutDate24, nashSutDateNow]) - .attr("class", "nash-sutcliffe ns24") - .attr("d", nashSutcliffeBox(24)); - mainChart - .append("text") - .attr("class", "nash-sutcliffe ns24") - .call(nashSutcliffeLabel, nashSutDate24, 24) - .append("tspan") - .text(nashSut24.value.toFixed(2)) - .select(function() { - return this.parentNode; - }) - .append("tspan") - .text(` (${nashSut24.samples})`) - .attr("dy", -1); - } - if (nashSut48.samples) { - mainChart - .append("path") - .datum([nashSutDate48, nashSutDateNow]) - .attr("class", "nash-sutcliffe ns48") - .attr("d", nashSutcliffeBox(48)); - mainChart - .append("text") - .attr("class", "nash-sutcliffe ns48") - .call(nashSutcliffeLabel, nashSutDate48, 48) - .append("tspan") - .text(nashSut48.value.toFixed(2)) - .select(function() { - return this.parentNode; - }) - .append("tspan") - .text(` (${nashSut48.samples})`) - .attr("dy", -1); - } - if (nashSut72.samples) { - mainChart - .append("path") - .datum([nashSutDate72, nashSutDateNow]) - .attr("class", "nash-sutcliffe ns72") - .attr("d", nashSutcliffeBox(72)); - mainChart - .append("text") - .attr("class", "nash-sutcliffe ns72") - .call(nashSutcliffeLabel, nashSutDate72, 72) - .append("tspan") - .text(nashSut72.value.toFixed(2)) - .select(function() { - return this.parentNode; - }) - .append("tspan") - .text(` (${nashSut72.samples})`) - .attr("dy", -1); - } + 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); + }; } - - // INTERACTIVITY - - const updateChart = () => { - mainChart.select(".line").call(mainLineChart); - mainChart.select(".now-line").attr("d", nowLine); - mainChart.select(".now-line-label").call(nowLineLabel); - mainChart.select(".prediction-area").attr("d", predictionArea); - mainChart - .select("path.nash-sutcliffe.ns24") - .attr("d", nashSutcliffeBox(24)); - mainChart - .select("text.nash-sutcliffe.ns24") - .call(nashSutcliffeLabel, nashSutDate24, 24); - mainChart - .select("path.nash-sutcliffe.ns48") - .attr("d", nashSutcliffeBox(48)); - mainChart - .select("text.nash-sutcliffe.ns48") - .call(nashSutcliffeLabel, nashSutDate48, 48); - mainChart - .select("path.nash-sutcliffe.ns72") - .attr("d", nashSutcliffeBox(72)); - mainChart - .select("text.nash-sutcliffe.ns72") - .call(nashSutcliffeLabel, nashSutDate72, 72); - mainChart - .select(".axis--x") - .call(xAxis) - .selectAll(".tick text") - .attr("y", 15); - }; - - // selecting time period in nav chart - let brush = d3 + }, + 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)); - }) - .on("start", () => { - svg.select(".chart-dot").style("opacity", 0); - svg.select(".chart-tooltip").style("opacity", 0); - }); + .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()); - let zoomRect = svg - .append("rect") - .attr("class", "zoom") - .attr("width", width) - .attr("height", mainHeight) - .attr("transform", `translate(${mainMargin.left}, ${mainMargin.top})`) - .call(zoom); - - // TOOLTIPS - - let dots = mainChart.append("g").attr("class", "chart-dots"); + eventRect.call(zoom); + }, + createTooltips(eventRect) { + let dots = this.diagram.append("g").attr("class", "chart-dots"); dots .append("circle") .attr("class", "chart-dot") .attr("r", 4); - let tooltips = mainChart.append("g").attr("class", "chart-tooltip"); + let tooltips = this.diagram.append("g").attr("class", "chart-tooltip"); tooltips .append("rect") .attr("x", -25) @@ -657,35 +663,43 @@ .attr("x", 8) .attr("y", 8); - let bisectDate = d3.bisector(d => d.date).left; - zoomRect + eventRect .on("mouseover", () => { - svg.select(".chart-dot").style("opacity", 1); - svg.select(".chart-tooltip").style("opacity", 1); + this.svg.select(".chart-dot").style("opacity", 1); + this.svg.select(".chart-tooltip").style("opacity", 1); }) .on("mouseout", () => { - svg.select(".chart-dot").style("opacity", 0); - svg.select(".chart-tooltip").style("opacity", 0); + this.svg.select(".chart-dot").style("opacity", 0); + this.svg.select(".chart-tooltip").style("opacity", 0); }) .on("mousemove", () => { - let x0 = x.invert(d3.mouse(document.querySelector(".zoom"))[0]), - i = bisectDate(this.waterlevels, x0, 1), + let 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; - svg + this.svg .select(".chart-dot") .style("opacity", 1) - .attr("transform", `translate(${x(d.date)}, ${y(d.waterlevel)})`); - svg + .attr( + "transform", + `translate(${this.scale.x(d.date)}, ${this.scale.y( + d.waterlevel + )})` + ); + this.svg .select(".chart-tooltip") .style("opacity", 1) .attr( "transform", - `translate(${x(d.date) - 25}, ${y(d.waterlevel) - 25})` + `translate(${this.scale.x(d.date) - 25}, ${this.scale.y( + d.waterlevel + ) - 25})` ); - svg.select(".chart-tooltip text tspan:first-child").text( + this.svg.select(".chart-tooltip text tspan:first-child").text( d.date.toLocaleString([], { year: "2-digit", month: "2-digit", @@ -694,18 +708,20 @@ minute: "2-digit" }) ); - svg + this.svg .select(".chart-tooltip text tspan:last-child") .text(d.waterlevel + " cm"); }); + }, + 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(); }