Home > Net >  Minimum brush size without "flipping"
Minimum brush size without "flipping"

Time:03-30

I am trying to set a minimum brush size but without the "flipping" as shown in the gif below. I find the data "jump" which results with the flipping visually confusing and would prefer that nothing happen if the user continues to drag a brush handle.

I've seen a few examples for setting minimum brush size (e.g. the answer to this question: enter image description here

CodePudding user response:

d3.event.selection for a brush handler is returning the current brush selection:

Returns the current brush selection for the specified node. ... For a two-dimensional brush, it is [[x0, y0], [x1, y1]], where x0 is the minimum x-value, y0 is the minimum y-value, x1 is the maximum x-value, and y1 is the maximum y-value. For an x-brush, it is [x0, x1]; ...

So, in your animation - when the 'right' hand side becomes the 'left' the underlying array returned for the brushX selection adjusts such that the minimum value is always index 0 and the maximum value is always index 1.

You can detect this with e.g.:

const flipped = (s[1] === previousS0) || (s[0] === previousS1)

You can see this in the snippet below - in the first brush go slowly at the point that the right-hand edge of the brush crosses over the left (and then becomes the left hand edge) - the flipped output will momentarily be true when this happens.

In the second brush you can prevent it happening by checking for both the brush percentage of width being less than a threshold or the flipped variable being true.

const width = 400;
const height = 32;
const margin = {top: 20, bottom: 40, left: 20, right: 20}
let previousS0_1;
let previousS1_1;
let previousS0_2;
let previousS1_2;

const data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];

const svg1 = d3.select("#svg1")
  .attr("width", width   margin.left   margin.right)
  .attr("height", height   margin.top   margin.bottom)
  .append("g")
  .attr("transform", `translate(${margin.left},${margin.top})`);

const svg2 = d3.select("#svg2")
  .attr("width", width   margin.left   margin.right)
  .attr("height", height   margin.top   margin.bottom)
  .append("g")
  .attr("transform", `translate(${margin.left},${margin.top})`);

const xScale = d3.scaleLinear()
  .range([0, width])
  .domain(d3.extent(data));

const xAxis1 = svg1.append("g")
  .attr("transform", `translate(0,${height})`)
  .call(d3.axisBottom(xScale));

const xAxis2 = svg2.append("g")
  .attr("transform", `translate(0,${height})`)
  .call(d3.axisBottom(xScale));

const brush1 = d3.brushX()
  .extent([[0, 0], [width, height]])
  .on("brush end", brushed1);

const brush2 = d3.brushX()
  .extent([[0, 0], [width, height]])
  .on("brush end", brushed2);

const context1 = svg1.append("g")

const context2 = svg2.append("g")

const brushGroup1 = context1.append("g")
  .call(brush1)
  .call(brush1.move, [xScale.range()[0]   80, xScale.range()[1] - 80]);

const brushGroup2 = context2.append("g")
  .call(brush2)
  .call(brush2.move, [xScale.range()[0]   80, xScale.range()[1] - 80]);

function brushed1() {
  const s = d3.event.selection || xScale.range();
  const brushPc = (((s[1] - s[0]) / width) * 100);
  const flipped = (s[1] === previousS0_1) || (s[0] === previousS1_1)
  let str = "";
  str  = `prevS: ${JSON.stringify([previousS0_1, previousS1_1])}`;
  str  = `s: ${JSON.stringify(s)}`;
  str  = ` flip: ${flipped}`;
  d3.select("#output1").html(`<pre>s: ${str}</pre>`);
  previousS0_1 = s[0];
  previousS1_1 = s[1];
}

function brushed2() {
  const s = d3.event.selection || xScale.range();
  const brushPc = (((s[1] - s[0]) / width) * 100);
  const flipped = (s[1] === previousS0_2) || (s[0] === previousS1_2)
  let str = "";
  str  = `prevS: ${JSON.stringify([previousS0_2, previousS1_2])}`;
  str  = ` s: ${JSON.stringify(s)}`;
  str  = ` pc: ${JSON.stringify(brushPc)}`;
  d3.select("#output2").html(`<pre>${str}</pre>`);

  if (brushPc < 10 || flipped) {
    brushGroup2.call(brush1.move, [previousS0_2, previousS1_2]);
    return;
  }
  previousS0_2 = s[0];
  previousS1_2 = s[1];
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.13.0/d3.min.js"></script>
<div>
  <div id="output1"></div>
  <svg id="svg1"></svg>
</div>
<div>
  <div id="output2"></div>
  <svg id="svg2"></svg>
</div>

You can add this logic in the brushed event of the original bl.ocks example too - see below:

function brushed() {
  if (d3.event.sourceEvent && d3.event.sourceEvent.type === "zoom") return; // ignore brush-by-zoom
  var s = d3.event.selection || x2.range();
  var brushPc = (((s[1] - s[0])/width)*100);
  var flipped = (s[1] === previousS0) || (s[0] === previousS1);     // <-- add 'flipped' check
  
  if(brushPc < 10 || flipped){                     // <-- consider along with brush percentage
    brushGroup.call(brush.move, [previousS0, previousS1]);
    return;
  };
  previousS0 = s[0];
  previousS1 = s[1];
  x.domain(s.map(x2.invert, x2));
  focus.select(".area").attr("d", area);
  focus.select(".axis--x").call(xAxis);
  svg.select(".zoom").call(zoom.transform, d3.zoomIdentity
      .scale(width / (s[1] - s[0]))
      .translate(-s[0], 0));
}

The working example (per @Gerardo-Furtado original answer):

let previousS0, previousS1, brushGroup;

var svg = d3.select("svg"),
    margin = {top: 20, right: 20, bottom: 100, left: 40},
    margin2 = {top: 120, right: 20, bottom: 30, left: 40},
    width =  svg.attr("width") - margin.left - margin.right,
    height =  svg.attr("height") - margin.top - margin.bottom,
    height2 =  svg.attr("height") - margin2.top - margin2.bottom;

var parseDate = d3.timeParse("%b %Y");

var x = d3.scaleTime().range([0, width]),
    x2 = d3.scaleTime().range([0, width]),
    y = d3.scaleLinear().range([height, 0]),
    y2 = d3.scaleLinear().range([height2, 0]);

var xAxis = d3.axisBottom(x),
    xAxis2 = d3.axisBottom(x2),
    yAxis = d3.axisLeft(y);

var brush = d3.brushX()
    .extent([[0, 0], [width, height2]])
    .on("brush end", brushed);

var zoom = d3.zoom()
    .scaleExtent([1, Infinity])
    .translateExtent([[0, 0], [width, height]])
    .extent([[0, 0], [width, height]])
    .on("zoom", zoomed);

var area = d3.area()
    .curve(d3.curveMonotoneX)
    .x(function(d) { return x(d.date); })
    .y0(height)
    .y1(function(d) { return y(d.price); });

var area2 = d3.area()
    .curve(d3.curveMonotoneX)
    .x(function(d) { return x2(d.date); })
    .y0(height2)
    .y1(function(d) { return y2(d.price); });

svg.append("defs").append("clipPath")
    .attr("id", "clip")
  .append("rect")
    .attr("width", width)
    .attr("height", height);

var focus = svg.append("g")
    .attr("class", "focus")
    .attr("transform", "translate("   margin.left   ","   margin.top   ")");

var context = svg.append("g")
    .attr("class", "context")
    .attr("transform", "translate("   margin2.left   ","   margin2.top   ")");

// fake data
var data = [];
for (var ix=0; ix<600; ix  ) {
  var yr = 2000   Math.floor(ix / 12)   "";
  var mth = ((ix % 12)   1);
  mth = (mth < 10 ? "0" : "")   mth;
  var dt = new Date(`${yr}-${mth}-01`);
  var price = Math.floor(Math.random() * 5)   1
  data.push({
    date: dt,
    price: price
  });
}

//d3.csv("sp500.csv", type, function(error, data) {
  //if (error) throw error;

  x.domain(d3.extent(data, function(d) { return d.date; }));
  y.domain([0, d3.max(data, function(d) { return d.price; })]);
  x2.domain(x.domain());
  y2.domain(y.domain());

  focus.append("path")
      .datum(data)
      .attr("class", "area")
      .attr("d", area);

  focus.append("g")
      .attr("class", "axis axis--x")
      .attr("transform", "translate(0,"   height   ")")
      .call(xAxis);

  focus.append("g")
      .attr("class", "axis axis--y")
      .call(yAxis);

  context.append("path")
      .datum(data)
      .attr("class", "area")
      .attr("d", area2);

  context.append("g")
      .attr("class", "axis axis--x")
      .attr("transform", "translate(0,"   height2   ")")
      .call(xAxis2);

 brushGroup = context.append("g")
      .attr("class", "brush")
      .call(brush)
      .call(brush.move, x.range());

  svg.append("rect")
      .attr("class", "zoom")
      .attr("width", width)
      .attr("height", height)
      .attr("transform", "translate("   margin.left   ","   margin.top   ")")
      .call(zoom);
//});

function brushed() {
  if (d3.event.sourceEvent && d3.event.sourceEvent.type === "zoom") return; // ignore brush-by-zoom
  var s = d3.event.selection || x2.range();
  var brushPc = (((s[1] - s[0])/width)*100);
  var flipped = (s[1] === previousS0) || (s[0] === previousS1);
  
  if(brushPc < 10 || flipped){
    brushGroup.call(brush.move, [previousS0, previousS1]);
    return;
  };
  previousS0 = s[0];
  previousS1 = s[1];
  x.domain(s.map(x2.invert, x2));
  focus.select(".area").attr("d", area);
  focus.select(".axis--x").call(xAxis);
  svg.select(".zoom").call(zoom.transform, d3.zoomIdentity
      .scale(width / (s[1] - s[0]))
      .translate(-s[0], 0));
}

function zoomed() {
  if (d3.event.sourceEvent && d3.event.sourceEvent.type === "brush") return; // ignore zoom-by-brush
  var t = d3.event.transform;
  x.domain(t.rescaleX(x2).domain());
  focus.select(".area").attr("d", area);
  focus.select(".axis--x").call(xAxis);
  context.select(".brush").call(brush.move, x.range().map(t.invertX, t));
}

function type(d) {
  d.date = parseDate(d.date);
  d.price =  d.price;
  return d;
};
.area {
  fill: steelblue;
  clip-path: url(#clip);
}

.zoom {
  cursor: move;
  fill: none;
  pointer-events: all;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.13.0/d3.min.js"></script>
<svg width="400" height="200"></svg>

  • Related