没有 "flipping" 的最小画笔大小

Minimum brush size without "flipping"

我正在尝试设置最小画笔大小,但没有如下 gif 中所示的“翻转”。我发现数据“跳跃”导致翻转在视觉上令人困惑,并且希望如果用户继续拖动画笔手柄则什么也不会发生。

我看过一些设置最小画笔大小的示例(例如这个问题的答案:),但它们都允许这种“翻转”或“镜像”。

这是答案中引用的更新后的 "Brush and Zoom" 示例。我知道代码正在计算画笔选择相对于总宽度的百分比,但我没有看到在哪里对值进行标准化以启用镜像(如果这有意义的话)。

刷机时如何避免'flip'?

d3.event.selection 对于 brush 处理程序返回当前 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]; ...

因此,在您的动画中 - 当 'right' 手侧变为 'left' 时,为 brushX 选择返回的基础数组将进行调整,以便最小值始终为索引 0,最大值为始终索引 1.

您可以通过例如:

检测到这一点
const flipped = (s[1] === previousS0) || (s[0] === previousS1)

您可以在下面的代码片段中看到这一点 - 在第一个画笔中,在画笔的 right-hand 边缘越过左侧(然后成为左侧边缘)的点上缓慢移动 - flipped 发生这种情况时,输出将立即为真。

在第二个画笔中,您可以通过检查画笔宽度百分比小于阈值 flipped 变量 [=21] 来防止它发生=].

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>

您也可以在原始 bl.ocks 示例的 brushed 事件中添加此逻辑 - 见下文:

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));
}

工作示例(根据@Gerardo-Furtado ):

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>