Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

d3 zoom with touch devices sometimes behaves weirdly (using IONIC)

I have a d3 & Ionic project that can be found on github here. I didn't create a Plunkr, because the bug must be reproduced via the android emulator or android device.

I have a graph that pans and zooms. I'm attempting to keep about 60 data points in the graph at a time. When the user reaches the "edge" meaning that the distance between the domain and the data points is about 2 then I refresh the data appending and removing data based upon the new domain. Before the async call it seems like zoom gets messed up and panning causes the zoom to act as if i'm pinching. This happens when the graph is being redrawn while panning. I can't figure out why.

I'm using Ionic and d3: This is how my zoom code looks

Graphing Code

import { Component, ElementRef, OnInit, ViewChild } from '@angular/core';
import { Content, LoadingController, NavController, PopoverController } from 'ionic-angular';

import { UsageLayer } from './usage-layer';
import { Observable } from 'rxjs/Rx';
import { UsageService } from './usage.service';

import * as Utils from './utils';
import * as d3 from 'd3';
import *  as moment from 'moment';

@Component({
  selector: 'page-home',
  templateUrl: 'home.html'
})
export class HomePage {

  constructor(public navCtrl: NavController,private el: ElementRef,private usageService: UsageService) {}
        //public properties
        @ViewChild('loader') loader: any;
        @ViewChild(Content) content: Content;
        billPeriod: any;
        costOverlay: boolean;
        graphType: string = 'usage';
        isWaiting: boolean = false;
        viewType: string;
        monthlyData: any;
        dailyData: any;
        minData: any;

        //private properties

        /**
         * Lets the Component know which whether or not to initialize the imported graphs
         */
        // graph properties
        private chart: any;
        private data: any;
        private numOfDaysInDomain: number;
        private graphCanvas: any;
        private isZooming: boolean  = false;
        private svg: any;
        private chartHeight: any;
        private height: number;
        private margin: any;
        private mode: string = 'daily';
        private selectedNode: any;
        private viewEl: any;
        private viewPortData: any;
        private xAxis: any;
        private yAxis: any;
        private xScale: any;
        private x2Scale: any;
        private usageLayer: UsageLayer;
        private width: number;
        private yScale: any;
        private zoom: any;
        private k: number;


        ngOnInit() {
            this.billPeriod = {start:'2016-10-07T22:17:48-05:00',end:'2016-11-07T22:17:48-05:00'};
            let buffer:any = 15;
            let bUnit:string = 'days';

            let queryDates = {
                start: moment(this.billPeriod.start).subtract(buffer,bUnit).format(),
                end: moment(this.billPeriod.end).add(buffer,bUnit).format()
            };

            let query = this.usageService.queryBuilder('daily',"SELECT * FROM ${{tablename}} where date(kDateTime) > date('" + queryDates.start + "') AND date(kDateTime) <= date('" + queryDates.end + "')");

            this.viewEl = d3.select(this.el.nativeElement);
            this.usageService.queryDaily(query).subscribe((x)=>{
                console.log('data returned',x);
                this.initializeGraph(x,this.billPeriod,this.viewEl,this.content);
            },(err)=>{
                console.log(err);
            })
        }

        /**
         * @name initializeGraph
         * @description Initialize the graph, main canvas (g element), and layers.
         * The canvas is referring to the main g element that holds all of the layers (usage,weather, cost bars).
         * The canvas is appended as a G Element to the SVG element.
         */
        initializeGraph(dailyData, billPeriod, viewElement,content:Content) {
            this.dailyData = dailyData;
            this.viewEl = viewElement;
            this.content = content;
            this.data = this.dailyData;
            this.mode = 'daily';
            this.billPeriod = billPeriod;
            this.costOverlay = true;
            this.calculateChartDimensions();

            this.initializeScales();

            this.numOfDaysInDomain = Utils.getNumberOfDaysInDomain(this.xScale.domain()[0], this.xScale.domain()[1]);

            this.initializeCanvasElement();

            this.initializeDefs();

            this.initializeCanvasLayers();

            this.initializeGraphAxis();

            this.initializeZoom();

            this.zoomAndPanTo('bill');

            setTimeout(()=>{
                this.triggerZoomLoader('hide');   
            }, 3000)
        }

        /**
         * @name initializeCanvasElement
         * @description The Canvas refers to the G Element that holds all of the 
         * graph layers. Including
         * -Usage Layer
         * -Weather Layer
         * -Cost Bars Layer
         */
        initializeCanvasElement() {
            this.graphCanvas = this.svg.append("g")
                .attr("class", "graphCanvas")
                .attr("transform", "translate(" + this.margin.left + "," + this.margin.top + ")");
        }
        /**
         * @name initializeDefs
         * @description Initializes several SVG defs.
         * Gradients for Usage Layers
         * Gradients for Cost Bars Layers
         * ClipPath for canvas
         */
        initializeDefs() {
            let defs = this.graphCanvas.append('defs');
            // append clipping path
            this.svg.append('defs').append("clipPath")
                .attr("id", "clip")
                .append("rect")
                .attr("width", this.width)
                .attr('transform', 'translate(0,-20)')
                .attr("height", this.height + 20);
            // append usage path gradient
            let gradient = defs
                .append('linearGradient')
                .attr('id', 'gradient')
                .attr('x1', '0%')
                .attr('y1', '0%')
                .attr('x2', '0%')
                .attr('y2', '100%');

            gradient.append("stop")
                .attr("offset", "0%")
                .attr("stop-color", "#51D0D7")
                .attr("stop-opacity", 1);

            gradient.append("stop")
                .attr("offset", "100%")
                .attr("stop-color", "#9FE25E")
                .attr("stop-opacity", 1);
            // append cost bar gradients
            let barGradient = defs
                .append('linearGradient')
                .attr('id', 'bar-gradient')
                .attr('x1', '0%')
                .attr('y1', '0%')
                .attr('x2', '0%')
                .attr('y2', '100%');

            barGradient.append("stop")
                .attr("offset", "0%")
                .attr("stop-color", "#AAE8EC")
                .attr("stop-opacity", 1);

            barGradient.append("stop")
                .attr("offset", "50%")
                .attr("stop-color", "#C2EEDF")
                .attr("stop-opacity", 1);

            barGradient.append("stop")
                .attr("offset", "100%")
                .attr("stop-color", "#E2F6D9")
                .attr("stop-opacity", 1);
            var weatherGradient = defs
                .append('linearGradient')
                .attr('id', 'weather-gradient')
                .attr('x1', '0%')
                .attr('y1', '0%')
                .attr('x2', '0%')
                .attr('y2', '100%');

            weatherGradient.append("stop")
                .attr("offset", "0%")
                .attr("stop-color", "#FFB4AA") //#FFAD27
                .attr("stop-opacity", 1);

            weatherGradient.append("stop")
                .attr("offset", "100%")
                .attr("stop-color", "#AADAFF") //#FFAD27
                .attr("stop-opacity", 1);
        }

        /**
         * @name initializeCanvasLayers
         * @description Initializes all of the graph layers.
         * UsageLayer - showing the kWh usage
         * WeatherLayer - showing the high and low temperature line and area
         * CostBarsLayer - showing the cost bar correlation to usage
         */
        initializeCanvasLayers() {
            this.usageLayer = new UsageLayer(this.data, this.graphCanvas, this.viewEl, this.height, { x: this.xScale, y: this.yScale }, this.mode);
        }

        /**
         * @name initializeGraphAxis
         * @descrpition initializes the canvas axis
         */
        initializeGraphAxis() {
            // setup axis
            this.xAxis = d3.axisBottom(this.xScale).tickSize(0).tickFormat(d3.timeFormat('%b %e')).ticks(5);
            this.yAxis = d3.axisLeft(this.yScale).tickValues(this.yScale.domain()).ticks(3).tickSize(0);
            this.graphCanvas.append("g")
                .attr("class", "axis axis--x")
                .attr("transform", "translate(0," + (this.height + 90) + ")")
                .call(this.xAxis);

            this.graphCanvas.append("g")
                .attr("class", "axis axis--y axis--kWh-y")
                .attr("transform", "translate(0," + (80) + ")")
                .call(this.yAxis);

            // change the axis label to show kWh
            setTimeout(() => {
                this.addkWhToAxis(this.graphCanvas.select('.axis--kWh-y'));
            }, 500);
        }

        /**
         * @name toggleCostOverlay
         * @descriptions Toggles the cost overlay bars for analysis
         */
        toggleCostOverlay() {
            this.costOverlay = !this.costOverlay;
            d3.select('.axis-cost').classed('on', this.costOverlay);
            d3.selectAll('.cost-bar').classed('on', this.costOverlay);
        }

        /**
         * @name zoomAndPanTo
         * @description Zooms to the identified levels
         * @param {string} level - The desired zoom level
         * Possible levels are - yearly, bill, weekly, daily
         */

        zoomAndPanTo = (level: string) => {
            let startDate,
                endDate,
                k,
                tx;

            if (level == 'yearly') {
                // Zoom all the way out
                this.svg.call(this.zoom.scaleBy, 0);
                return;
            } else {
                if (level == 'bill') {
                    // Get end date
                    endDate = moment(this.billPeriod.end);
                    // Get start date
                    startDate = moment(this.billPeriod.start);

                } else {
                    // if is weekly than add 7 days
                    // if is daily than add one day
                    let amountOfDaysToAdd = level === 'weekly' ? 7 : 1;
                    // Get start and end date
                    endDate = moment(this.xScale.domain()[1]);
                    startDate = moment(endDate).subtract(amountOfDaysToAdd, 'days');
                }
            }

            // Get scale k
            k = this.width / (this.xScale(endDate) - this.xScale(startDate));
            // Get transform value
            this.svg.call(this.zoom.scaleBy, k);
            tx = 0 - k * this.xScale(startDate);

            // if daily mode don't translate
            if (level == 'daily') return;

            this.svg.call(this.zoom.translateBy, tx, 0);
        }
        // private methods

        /**
         * @name changeDataSource
         * @description Changes the data source. There are three important data arrays: Minute, Daily, and Monthly data. 
         * As the user zooms in or out, the data source is changed. This methods accepts the new data and uses it to redraw the graphs
         */
        private changeDataSource(data) {
            var yAxisEl,
                yAxisWeather;

            this.drawXAxisTicks();
            this.yScale.domain([0, d3.max(data, (d: any) => { return d.kWh; })]);
            this.usageLayer.redraw(this.mode, data, { x: this.xScale, y: this.yScale });

            if (this.mode != 'minute') {
                this.costOverlay = true;
            } else {

                if (this.costOverlay === true) this.toggleCostOverlay();
            }

            this.yAxis = d3.axisLeft(this.yScale).tickValues(this.yScale.domain()).ticks(3).tickSize(0);

            yAxisEl = this.graphCanvas.select('.axis--kWh-y').call(this.yAxis);
            this.addkWhToAxis(yAxisEl);
            this.triggerZoomLoader('hide');
            this.isZooming = false;        
        }

        /**
         * @name addkWhToAxis
         * @description Manually adds the text kWh to the left axis of the graph for aesthetics.
         */
        private addkWhToAxis(yAxis) {
            var yAxisHeight = yAxis.node().getBBox().height;
            yAxis.append('g')
                .attr('class', 'tick')
                .attr('transform', 'translate(0,' + (yAxisHeight / 2) + ')')
                .append('text').attr('fill', '#000').html('kWh');
        }

        /**
         * @name initializeZoom
         * @description initializes the zoom generator
         */
        private initializeZoom = () => {
            this.zoom = d3.zoom()
                .scaleExtent([1, this.numOfDaysInDomain * 12])
                .translateExtent([[0, 0], [this.width, this.height]])
                .extent([[0, 0], [this.width, this.height]])
                .on("zoom", this.zoomed)
            // setup zoom on svg
            this.svg.call(this.zoom);
            this.svg.on("mousedown.zoom", null)
            this.svg.on("mousewheel.zoom", null)
            this.svg.on("mousemove.zoom", null)
            this.svg.on("DOMMouseScroll.zoom", null)
            this.svg.on("dblclick.zoom", null)
        }
            /**
         * @name initializeScales
         * @description initializes the zoom generator
         */
        private initializeScales() {
            // setup scales
            this.xScale = d3.scaleTime().range([0, this.width]);
            this.x2Scale = d3.scaleTime().range([0, this.width]);
            this.yScale = d3.scaleLinear().range([this.height, 0]);

            let xDomain = d3.extent(this.data, (d: any) => { return Utils.getDataPointDate(d); });
            let yDomain = [0, d3.max(this.data, (d: any) => { return d.kWh; })];

            this.xScale.domain(xDomain);
            this.yScale.domain(yDomain);
            this.x2Scale.domain(this.xScale.domain());
        }

        /**
         * @name calculateChartDimensions
         * @description calculate the height and width of the chart
         * @returns {Object}
         */
        private calculateChartDimensions() {
            let contentDimensions = this.content.getContentDimensions();
            let contentViewHeight = contentDimensions.contentHeight;
            this.svg = this.viewEl.select('svg#svgChart');
            this.chart = this.viewEl.select('div.chart');
            let chartHeight = this.chart.node().offsetHeight;
            this.svg.attr('height', contentViewHeight - 84 - 50);
            let chartWidth = contentDimensions.contentWidth;
            this.margin = { top: 20, right: 40, bottom: 30, left: 40 };
            // assign to global variables    
            this.width = chartWidth - this.margin.left - this.margin.right,
                this.height = +this.svg.attr("height") - this.margin.top - this.margin.bottom - 80;
            this.svg.attr('width', chartWidth);
        }

        /**
         * @name drawXAxisTicks
         * @description Determines how many ticks and what date format to show them in based upon the data granulatiry.
         * Calculates the amount of days between the start and end date to determine the format and number.
         */
        private drawXAxisTicks() {
            let diff = Utils.getNumberOfDaysInDomain(this.xScale.domain()[0], this.xScale.domain()[1]);
            let tickFormat: string;
            let tickNumber: number;

            if (this.mode === 'minute') {
                tickFormat = '%b %e %I:%M %p'; tickNumber = 2;
            }
            else if (diff >= 100) {
                tickFormat = '%b'; tickNumber = 7;
            } else if (diff < 100) {
                tickFormat = '%b %e'; tickNumber = 4;
            } else if (diff <= 7 && diff > 4) {
                tickFormat = '%b %e'; tickNumber = 6;
            } else if (diff == 4) {
                tickFormat = '%b %e'; tickNumber = 3;
            } else if (diff < 4) {
                tickFormat = '%b %e %I:%M %p'; tickNumber = 2;
            }

            // Assign Tick Format and Number.
            this.xAxis.tickFormat(d3.timeFormat(tickFormat)).ticks(tickNumber);
            // Apply new Format
            this.graphCanvas.select(".axis--x").call(this.xAxis);
        }

        /**
         * @name reDrawGraphElements
         * @description Draws or re-draws all graph elements, based on current xScales and generators.
         */
        private reDrawGraphElements(data?,scales?){
            this.redrawUsageGraphElements(data,scales);
        }

        private redrawUsageGraphElements(data?,scales?) {
            this.usageLayer.redraw(this.mode,data,scales);
        }

        /**
         * @name isChangeMode
         * @description Determines whether to change current mode
         */
        private isChangeMode():boolean {

            // // get distance between domains , x1 and x2
            let diff = Utils.getNumberOfDaysInDomain(this.xScale.domain()[0], this.xScale.domain()[1]);
            if (diff > 120 && this.mode !== 'monthly') {
                this.mode = 'monthly';
                return true;
            } else if ((diff <= 120 && diff > 2) && this.mode !== 'daily') {
                this.mode = 'daily';
                return true;
            } else if (diff <= 2 && this.mode !== 'minute') {
                this.mode = 'minute';
                return true;
            } else {
                return false;
            }
        }

        /**
         * @name isRefreshThreshold
         * @description determin whether not if data threshold should be refreshed based on the extreminities
         * of the domain and data. We will compare (data[0] and domain[0]) and (data[data.length - 1] and domain[1]) to find out if the threshold has been
         * trangressed
         */
        isRefreshThreshold():boolean{
            // TODO determine threshold for yearly mode
            if(this.mode === 'monthly') return false;

            let domain,
                threshold, 
                x1Diff, 
                x2Diff;

            if(this.mode === 'minute'){
                threshold = {
                    unit: 'seconds',
                    value: '86400'
                };
            } else if(this.mode === 'daily'){
                threshold = {
                    unit: 'days',
                    value: '2'
                };
            }
            domain = this.xScale.domain();
            x1Diff = moment(domain[0]).diff(this.data[0].kDateTime,threshold.unit);
            x2Diff = moment(this.data[this.data.length-1].kDateTime).diff(domain[1],threshold.unit);

            return (x1Diff <= threshold.value || x2Diff <= threshold.value);
        }




        /**
         * @name zoomed
         * @description Callback for zoom functionality.
         */
        private zoomed = () => {
            // if(this.isZooming){
            //     return;
            // }
            let t = d3.event.transform;
            console.log(t);
            if (isNaN(t.k)) return;
            this.xScale.domain(t.rescaleX(this.x2Scale).domain());
            this.drawXAxisTicks();

            // Do we change the mode
            if(this.isChangeMode()){
                console.log('changedMode');
                this.getData().subscribe((x:any)=>{
                    this.data = x;
                    this.changeDataSource(this.data);
                });
            } else if(this.isRefreshThreshold()) {
                console.log('refreshing threshold');
                this.isZooming = true;
                this.triggerZoomLoader('show');
                this.getData().subscribe((x:any)=>{
                    this.data = x;
                    this.changeDataSource(this.data);
                });
            } else {
                console.log('didn\'t do anything');
                // plainly render the graph updating it regularly
                this.reDrawGraphElements();
            }
        }

        private getData(){
            // if the mode is yearly then return the data immediately
            // return immediate data because we are not buffering data right now
            if(this.mode == 'monthly'){
                this.data = this.monthlyData;
                return Observable.of(this.data);
            }

            // if(this.mode == 'daily'){
            //     this.data = this.dailyData;
            //     return Observable.of(this.data);
            // }

            let xMin,
                xMax,
                buffer: number,
                bufferUnit: string = 'seconds',
                bufferXmin,
                bufferXmax,
                numberOfPoints,
                distanceBtwnXminXmax,
                dataLength,
                domain;

                domain = this.xScale.domain();
                xMin = moment(domain[0]);
                xMax = moment(domain[1]);

                // calculate buffer
                if(this.mode == 'daily'){
                    bufferUnit = 'days';
                    distanceBtwnXminXmax = xMax.diff(xMin,bufferUnit);
                    buffer = 100;
                } else if (this.mode == 'minute'){
                    bufferUnit = 'seconds';
                    distanceBtwnXminXmax = xMax.diff(xMin,bufferUnit);
                    buffer = 86400;
                }

                bufferXmin = xMin.subtract(buffer,bufferUnit);
                bufferXmax = xMax.add(buffer,bufferUnit);



                let query = this.usageService.queryBuilder(this.mode,"SELECT * FROM ${{tablename}} where date(kDateTime) > date('" + bufferXmin.format() + "') AND date(kDateTime) <= date('" + bufferXmax.format() + "')");

                return this.mode === 'minute' ? this.usageService.queryMin(query) : this.usageService.queryDaily(query);
        }


        /**
         * @name triggerZoomLoader
         * @description Hide/Show the zoom loader
         */
        triggerZoomLoader(action:string = 'show'){
            if(action == 'show'){
                this.loader.nativeElement.classList.remove('hidden');
            } else {
                this.loader.nativeElement.classList.add('hidden');            
            }
        }

}

export interface BillPeriod {
    start: string,
    end: string
}
like image 321
Abdullah Rasheed Avatar asked Oct 03 '17 21:10

Abdullah Rasheed


1 Answers

The problem occurs when redraw function is called in your code. Somehow D3 does not fire touchend or touchcancel events after the redraw. Even if one finger is removed, it seems like it is still in touch. So the next started touch makes it a multi-touch. This might be a bug on D3 side or in Webkit itself.

This change in touchStarted function in d3-zoom/src/zoom.js file fixes it for 2 touches. Could not test for more touches.

Change this line to:

  if (!g.touch0) g.touch0 = p, started = true;

This:

  if (!g.touch0 || (event.touches.length == 1 && touches.length == 1)) g.touch0 = p, started = true;

It would be nice if you could open an issue in d3 repository with reproducible steps and a clean code example.

Another solution

Instead of handling zoom on svg, have a layer above the svg to handle events. Because this layer will not disappear on redraw.

  <div class="chart">
    <div style="position: absolute; top: 0; bottom: 0; left: 0; right: 0;" class="zoomer"></div>
    <svg id="svgChart"></svg>
  </div>

And call zoom handlers on this zoomer element:

    private initializeZoom = () => {
        ....
        this.zoomer.call(this.zoom);
        ....
    }

But this layer also blocks click events so circles won't show. Read more d3 documentation to how to handle this case.

like image 140
Gokhan Kurt Avatar answered Sep 17 '22 23:09

Gokhan Kurt