I have a d3 and Ionic project which can be found on github here . I did not create Plunkr because the error must be reproduced using an android emulator or an Android device.
I have a graph that shows panning and zooming. I am trying to store about 60 data points on a chart at a time. When the user reaches the “edge”, which means that the distance between the domain and the data points is about 2, I update the data, adding and removing data based on the new domain. Before an asynchronous call, it seems that the zoom is messed up, and panning makes the scale act as if I were pinching. This happens when the chart is redrawn during panning. I can’t understand why.
I use Ionic and d3: this is how the scaling code looks
Graphic 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) {}
@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 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);
})
}
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)
}
initializeCanvasElement() {
this.graphCanvas = this.svg.append("g")
.attr("class", "graphCanvas")
.attr("transform", "translate(" + this.margin.left + "," + this.margin.top + ")");
}
initializeDefs() {
let defs = this.graphCanvas.append('defs');
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);
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);
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")
.attr("stop-opacity", 1);
weatherGradient.append("stop")
.attr("offset", "100%")
.attr("stop-color", "#AADAFF")
.attr("stop-opacity", 1);
}
initializeCanvasLayers() {
this.usageLayer = new UsageLayer(this.data, this.graphCanvas, this.viewEl, this.height, { x: this.xScale, y: this.yScale }, this.mode);
}
initializeGraphAxis() {
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);
setTimeout(() => {
this.addkWhToAxis(this.graphCanvas.select('.axis--kWh-y'));
}, 500);
}
toggleCostOverlay() {
this.costOverlay = !this.costOverlay;
d3.select('.axis-cost').classed('on', this.costOverlay);
d3.selectAll('.cost-bar').classed('on', this.costOverlay);
}
zoomAndPanTo = (level: string) => {
let startDate,
endDate,
k,
tx;
if (level == 'yearly') {
this.svg.call(this.zoom.scaleBy, 0);
return;
} else {
if (level == 'bill') {
endDate = moment(this.billPeriod.end);
startDate = moment(this.billPeriod.start);
} else {
let amountOfDaysToAdd = level === 'weekly' ? 7 : 1;
endDate = moment(this.xScale.domain()[1]);
startDate = moment(endDate).subtract(amountOfDaysToAdd, 'days');
}
}
k = this.width / (this.xScale(endDate) - this.xScale(startDate));
this.svg.call(this.zoom.scaleBy, k);
tx = 0 - k * this.xScale(startDate);
if (level == 'daily') return;
this.svg.call(this.zoom.translateBy, tx, 0);
}
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;
}
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');
}
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)
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)
}
private initializeScales() {
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());
}
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 };
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);
}
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;
}
this.xAxis.tickFormat(d3.timeFormat(tickFormat)).ticks(tickNumber);
this.graphCanvas.select(".axis--x").call(this.xAxis);
}
private reDrawGraphElements(data?,scales?){
this.redrawUsageGraphElements(data,scales);
}
private redrawUsageGraphElements(data?,scales?) {
this.usageLayer.redraw(this.mode,data,scales);
}
private isChangeMode():boolean {
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;
}
}
isRefreshThreshold():boolean{
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);
}
private zoomed = () => {
let t = d3.event.transform;
console.log(t);
if (isNaN(t.k)) return;
this.xScale.domain(t.rescaleX(this.x2Scale).domain());
this.drawXAxisTicks();
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');
this.reDrawGraphElements();
}
}
private getData(){
if(this.mode == 'monthly'){
this.data = this.monthlyData;
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]);
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);
}
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
}