Welcome to OGeek Q&A Community for programmer and developer-Open, Learning and Share
Welcome To Ask or Share your Answers For Others

Categories

0 votes
110 views
in Technique[技术] by (71.8m points)

javascript - Setting ticks on a time scale during zoom

The snippet below creates a single x axis with starting ticks of 10. During zoom I'm updating ticks on the rescaled axis with:

.ticks(startTicks * Math.floor(event.transform.k))

With .scaleExtent([1, 50]) I can get down from years to 3-hourly blocks fairly smoothly (besides a little label overlap here and there).

But, when I request the number of ticks applied on the scale (xScale.ticks().length) I get a different number to the one I just assigned.

Also, when I get the labels (xScale.ticks().map(xScale.tickFormat())) they differ from the ones rendered as I get deeper into the zoom.

Reading here:

An optional count argument requests more or fewer ticks. The number of ticks returned, however, is not necessarily equal to the requested count. Ticks are restricted to nicely-rounded values (multiples of 1, 2, 5 and powers of 10), and the scale’s domain can not always be subdivided in exactly count such intervals. See d3.ticks for more details.

I understand I might not get the number of ticks I request, but it's counter-intuitive that:

  • I request more and more ticks (per k) - between 10 and 500
  • Then the returned ticks fluctuates between 5 and 19.

Why is this ? Is there a better or 'standard' way to update ticks whilst zooming for scaleTime or scaleUtc ?

var margin = {top: 0, right: 25, bottom: 20, left: 25}
var width = 600 - margin.left - margin.right;
var height = 40 - margin.top - margin.bottom;

// x domain
var x = d3.timeDays(new Date(2020, 00, 01), new Date(2025, 00, 01));

// start with 10 ticks
var startTicks = 10;

// zoom function 
var zoom = d3.zoom()
  .on("zoom", (event) => {
  
    var t = event.transform;

    xScale
      .domain(t.rescaleX(xScale2).domain())
      .range([0, width].map(d => t.applyX(d))); 
      
    var zoomedRangeWidth = xScale.range()[1] - xScale.range()[0];
    var zrw = zoomedRangeWidth.toFixed(4);
    var kAppliedToWidth = kw = t.k * width;
    var kw = kAppliedToWidth.toFixed(4);
    var zoomTicks = zt = startTicks * Math.floor(t.k);
      
    svg.select(".x-axis")
      .call(d3.axisBottom(xScale)
        .ticks(zt) 
    );

    var realTicks = rt = xScale.ticks().length;
    console.log(`zrw: ${zrw}, kw: ${kw}, zt: ${zt}, rt: ${rt}`);
    console.log(`labels: ${xScale.ticks().map(xScale.tickFormat())}`);
    
  })
  .scaleExtent([1, 50]); 


// x scale
var xScale = d3.scaleTime()
  .domain(d3.extent(x))
  .range([0, width]); 

// x scale copy
var xScale2 = xScale.copy();

// svg
var svg = d3.select("#scale")
  .append("svg")
  .attr("width", width + margin.left + margin.right)
  .attr("height", height + margin.top + margin.bottom)
  .call(zoom) 
  .append("g")
  .attr("transform", `translate(${margin.left},${margin.top})`);

// clippath 
svg.append("defs").append("clipPath")
  .attr("id", "clip")
  .append("rect")
  .attr("x", 0)
  .attr("width", width)
  .attr("height", height);
    
// x-axis
svg.append("g")
  .attr("class", "x-axis")
  .attr("clip-path", "url(#clip)") 
  .attr("transform", "translate(0," + height + ")")
  .call(d3.axisBottom(xScale)
    .ticks(startTicks));
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.3.1/d3.min.js"></script>
<div id="scale"></div>
question from:https://stackoverflow.com/questions/65916866/setting-ticks-on-a-time-scale-during-zoom

与恶龙缠斗过久,自身亦成为恶龙;凝视深渊过久,深渊将回以凝视…
Welcome To Ask or Share your Answers For Others

1 Reply

0 votes
by (71.8m points)

The issue is in how the xScale is being updated on zoom.

The current approach in the example is:

    xScale
      .domain(t.rescaleX(xScale2).domain())
      .range([0, width].map(d => t.applyX(d))); 

This is doing two things:

  1. Creating a rescaled copy of xScale2, but only to get its domain.
  2. Extending the range of the xScale depending on the transform.

Because of step 2, the scale range is growing outside of the screen. When you request 500 ticks but only see 10, it is because there are 490 out of the viewport.

The solution is that continuous scales don't need to have the range updated on zoom, because the rescaleX method is enough for the transformation process.

The appropriate way to rescale a continuous scale on zoom is:

   xScale = t.rescaleX(xScale2)

Which changes only the domain and keeps the range intact.

Consider this example to illustrate why only changing the domain is enough: If a scale maps from a domain [0,1] to a range [0, 100], and it is transformed with rescaleX, the new scale will now map from another domain (say, [0.4, 0.6]) to the same range [0, 100]. This is the zoom concept: it was showing data from 0 to 1 in a 100 width viewport, but now it is showing data from 0.4 to 0.6 in the same viewport; it "zoomed in" to 0.4 and 0.6.

The incorrect format returned from xScale.tickFormat() was a consequence of the range extension, but also of a mismatch between the displayed ticks and the computed ticks. The method only return the same ticks that are displayed if it also consideres the same amount of ticks, which is informed in the first parameter (in your example, it would be xScale.tickFormat(zt)). Since it had no arguments, it defaults to 10, and the 10 ticks computed in the time scale could be different or be in a different time granularity than the zt ticks that are displayed.

In summary, the snippet needs three changes:

  • Change 1: Update only the domain directly with rescaleX.
  • Change 2: Fix zoom ticks to a number, such as 10.
  • Change 3: Consider the number of ticks when using the tickFormat method.

The snippet below is updated with those changes:

var margin = {top: 0, right: 25, bottom: 20, left: 25}
var width = 600 - margin.left - margin.right;
var height = 40 - margin.top - margin.bottom;

// x domain
var x = d3.timeDays(new Date(2020, 00, 01), new Date(2025, 00, 01));

// start with 10 ticks
var startTicks = 10;

// zoom function 
var zoom = d3.zoom()
  .on("zoom", (event) => {
  
    var t = event.transform;

    // Change 1: Update only the domain directly with rescaleX
    xScale = t.rescaleX(xScale2); 
      
    var zoomedRangeWidth = xScale.range()[1] - xScale.range()[0];
    var zrw = zoomedRangeWidth.toFixed(4);
    var kAppliedToWidth = kw = t.k * width;
    var kw = kAppliedToWidth.toFixed(4);
    
    // Change 2: Fix zoom ticks to a number, such as 10
    var zoomTicks = zt = 10
      
    svg.select(".x-axis")
      .call(d3.axisBottom(xScale)
        .ticks(zt) 
    );

    var realTicks = rt = xScale.ticks().length;
    console.log(`zrw: ${zrw}, kw: ${kw}, zt: ${zt}, rt: ${rt}`);
    
    // Change 3: Consider zt when using the tickFormat method
    console.log(`labels: ${xScale.ticks().map(xScale.tickFormat(zt))}`);
    
  })
  .scaleExtent([1, 50]); 


// x scale
var xScale = d3.scaleTime()
  .domain(d3.extent(x))
  .range([0, width]); 

// x scale copy
var xScale2 = xScale.copy();

// svg
var svg = d3.select("#scale")
  .append("svg")
  .attr("width", width + margin.left + margin.right)
  .attr("height", height + margin.top + margin.bottom)
  .call(zoom) 
  .append("g")
  .attr("transform", `translate(${margin.left},${margin.top})`);

// clippath 
svg.append("defs").append("clipPath")
  .attr("id", "clip")
  .append("rect")
  .attr("x", 0)
  .attr("width", width)
  .attr("height", height);
    
// x-axis
svg.append("g")
  .attr("class", "x-axis")
  .attr("clip-path", "url(#clip)") 
  .attr("transform", "translate(0," + height + ")")
  .call(d3.axisBottom(xScale)
    .ticks(startTicks));
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.3.1/d3.min.js"></script>
<div id="scale"></div>

与恶龙缠斗过久,自身亦成为恶龙;凝视深渊过久,深渊将回以凝视…
OGeek|极客中国-欢迎来到极客的世界,一个免费开放的程序员编程交流平台!开放,进步,分享!让技术改变生活,让极客改变未来! Welcome to OGeek Q&A Community for programmer and developer-Open, Learning and Share
Click Here to Ask a Question

...