简体   繁体   中英

D3 - Reset Zoom when Chart is Brushed?

I'm working on a D3 timescale/pricescale financial chart. The chart SVG itself uses zoom() to pan and scale the data geometrically and re-draw the axes. Beneath the chart is an SVG brush pane which shows the entire data set at a high level and allows panning itself. The issue I'm facing is the same behavior as shown in this fiddle (not my code): http://jsfiddle.net/p29qC/8/ . Zooming after brushing results in jumpy behavior because zoom() never picked up the changes from brush().

Zoom and brush work well independently, but I'm having trouble making them work together. When the chart is brushed, I'd expect zoom to detect that so the next time the chart is zoomed, it picks up where brush left off. And visa versa.

I've managed to set up a synchronization function to get the brush to update properly when zoom is initiated, but I can't get the reverse to work - update the chart zoom when brush occurs in the navigator. I've searched for hours to no avail. Are there any patches out there to fix this? My apologies for the long code blocks but I hope that it's helpful to set the context!

Setup code (some basic variables omitted for brevity):

// Create svg
var svg = d3.select('#chart')
  .append('svg')
  .attr({
    class: 'fcChartArea',
    width: width+margin.left+margin.right,
    height: height+margin.bottom,
  })
  .style({'margin-top': margin.top});

// Create group for the chart
var chart = svg.append('g');

// Clipping path
chart.append('defs').append('clipPath')
  .attr('id', 'plotAreaClip')
  .append('rect')
  .attr({
    width: width,
    height: height
  });

// Create plot area, using the clipping path
var plotArea = chart.append('g')
  .attr({
    class: 'plotArea',
    'clip-path': 'url(#plotAreaClip)'
  });

// Compute mins and maxes
var minX = d3.min(data, function (d) {
  return new Date(d.startTime*1000);
});
var maxX = d3.max(data, function (d) {
  return new Date(d.startTime*1000);
});
var minY = d3.min(data, function (d) {
  return d.low;
});
var maxY = d3.max(data, function (d) {
  return d.high;
});

// Compute scales & axes
var dateScale = d3.time.scale()
  .domain([minX, maxX])
  .range([0, width]);
var dateAxis = d3.svg.axis()
  .scale(dateScale)
  .orient('bottom');
var priceScale = d3.scale.linear()
  .domain([minY, maxY])
  .nice()
  .range([height, 0]);
var priceAxis = d3.svg.axis()
  .scale(priceScale)
  .orient('right');

// Store initial scales
var initialXScale = dateScale.copy();
var initialYScale = priceScale.copy();

// Add axes to the chart
chart.append('g')
  .attr('class', 'axis date')
  .attr('transform', 'translate(0,' + height + ')')
  .call(dateAxis);
chart.append('g')
  .attr('class', 'axis price')
  .attr('transform', 'translate(' + width + ',0)')
  .call(priceAxis);

// Compute and append the OHLC series
var series = fc.series.ohlc('path')
  .xScale(dateScale)
  .yScale(priceScale);
var dataSeries = plotArea.append('g')
  .attr('class', 'series')
  .datum(data)
  .call(series);

// Create the SVG navigator
var navChart = d3.select('#chart')
  .classed('chart', true)
  .append('svg')
    .classed('navigator', true)
    .attr('width', navWidth + margin.left + margin.right)
    .attr('height', navHeight+margin.top+margin.bottom)
    .style({'margin-bottom': margin.bottom})
    .append('g');
// Compute scales & axes
var navXScale = d3.time.scale()
  .domain([minX, maxX])
  .range([0, navWidth]);
var navXAxis = d3.svg.axis()
  .scale(navXScale)
  .orient('bottom');
var navYScale = d3.scale.linear()
  .domain([minY, maxY])
  .range([navHeight, 0]);
// Add x-axis to the chart
navChart.append('g')
  .attr('class', 'axis date')
  .attr('transform', 'translate(0,' + navHeight + ')')
  .call(navXAxis);
// Add data to the navigator
var navData = d3.svg.area()
  .x(function (d) {
    return navXScale(new Date(d.startTime*1000));
  })
  .y0(navHeight)
  .y1(function (d) {
    return navYScale(d.close);
  });
var navLine = d3.svg.line()
  .x(function (d) {
    return navXScale(new Date(d.startTime*1000));
  })
  .y(function (d) {
    return navYScale(d.close);
  });
navChart.append('path')
  .attr('class', 'data')
  .attr('d', navData(data));
navChart.append('path')
  .attr('class', 'line')
  .attr('d', navLine(data));

// create brush viewport
var viewport = d3.svg.brush()
  .x(navXScale)
  .on("brush", brush);

// add brush viewport to the SVG navigator
navChart.append("g")
  .attr("class", "viewport")
  .call(viewport)
  .selectAll("rect")
  .attr("height", navHeight);

// set zoom behavior
var zoom = d3.behavior.zoom()
  .x(dateScale)
  .scaleExtent([1, 12.99])
  .on('zoom', zoom);

// Create zoom pane
plotArea.append('rect')
  .attr('class', 'zoom-overlay')
  .attr('width', width)
  .attr('height', height)
  .call(zoom);

Brush and zoom functions:

// zoom - brush synchronizations
function updateBrushFromZoom() {
  if ((dateScale.domain()[0] <= minX) && (dateScale.domain()[1] >= maxX)) {
    viewport.clear();
  } else {
    viewport.extent(dateScale.domain());
  }
  navChart.select('.viewport').call(viewport);
}

function updateZoomFromBrush() {
  // help!!
}

function brush() {
  var g = d3.selectAll('svg').select('g');
  var newDomain = viewport.extent();
  if (newDomain[0].getTime() !== newDomain[1].getTime()) {
    dateScale.domain([newDomain[0], newDomain[1]]);
    var xTransform = fc.utilities.xScaleTransform(initialXScale, dateScale);

    // define new data set
    var range = moment().range(newDomain[0], newDomain[1]);
    var rangeData = [];
    for (var i = 0; i < data.length; i += 1) {
      if (range.contains(new Date(data[i].startTime*1000))) {
        rangeData.push(data[i]);
      }
    }
    // define new mins and maxes
    var newMinY = d3.min(rangeData, function (d) {
      return d.low;
    });
    var newMaxY = d3.max(rangeData, function (d) {
      return d.high;
    });

    // set new yScale
    priceScale.domain([newMinY, newMaxY]);
    var yTransform = fc.utilities.yScaleTransform(initialYScale, priceScale);

    // draw new axes on main chart
    g.select('.fcChartArea .date.axis')
      .call(dateAxis);
    g.select('.fcChartArea .price.axis')
      .call(priceAxis);

    // transform the data to fit new chart viewport
    g.select('.series')
      .attr('transform', 'translate(' + xTransform.translate + ',' + yTransform.translate+ ')' + ' scale(' + xTransform.scale + ',' + yTransform.scale + ')');
  }
  else {
    // remove transformation
    g.select('.series')
      .attr('transform', null);
  }
  updateZoomFromBrush();
}

// Zoom functions
function zoom() {
  var g = d3.selectAll('svg').select('g');
  // set new xScale
  var newDomain = dateScale.domain();
  var xTransformTranslate = d3.event.translate[0];
  var xTransformScale = d3.event.scale;

  // define new data set
  var range = moment().range(newDomain[0], newDomain[1]);
  var rangeData = [];
  for (var i = 0; i < data.length; i += 1) {
    if (range.contains(new Date(data[i].startTime*1000))) {
      rangeData.push(data[i]);
    }
  }

  // define new max and min
  var newMinY = d3.min(rangeData, function (d) {
    return d.low;
  });
  var newMaxY = d3.max(rangeData, function (d) {
    return d.high;
  });

  // set new yScale
  priceScale.domain([newMinY, newMaxY]);
  var yTransform = fc.utilities.yScaleTransform(initialYScale, priceScale);

  // draw new axes on main chart
  g.select('.fcChartArea .date.axis')
    .call(dateAxis);
  g.select('.fcChartArea .price.axis')
    .call(priceAxis);

  // transform the data to fit new chart viewport
  g.select('.series')
    .attr('transform', 'translate(' + xTransformTranslate + ',' + yTransform.translate+ ')' + ' scale(' + xTransformScale + ',' + yTransform.scale + ')');

  // update SVG navigator
  updateBrushFromZoom();
}

Helper functions:

fc.utilities.yScaleTransform = function(oldScale, newScale) {
  var oldDomain = oldScale.domain();
  var newDomain = newScale.domain();
  var scale = (oldDomain[1] - oldDomain[0]) / (newDomain[1] - newDomain[0]);
  var translate = scale * (oldScale.range()[1] - oldScale(newDomain[1]));
  return {
    translate: translate,
    scale: scale
  };
};

fc.utilities.xScaleTransform = function(oldScale, newScale) {
  var oldDomain = oldScale.domain();
  var newDomain = newScale.domain();
  var scale = (oldDomain[1] - oldDomain[0]) / (newDomain[1] - newDomain[0]);
  var translate = scale * (oldScale.range()[0] - oldScale(newDomain[0]));
  return {
    translate: translate,
    scale: scale
  };
};

In updateZoomFromBrush() , rebind the scale to the zoom behavior with zoom.x(dateScale) .

This is needed because d3.behavior.zoom() operates on a copy of the scale you pass in, so without rebinding the scale, the behavior won't have any of the changes made to the scale's domain in brush() .

See this example http://bl.ocks.org/mbostock/3892928

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM