Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

D3 axis labels become too fine grained when zoomed in

I'm trying to create an axis function that the ticks/labels are dynamic meaning they hide/show automatically. BUT on top of that, I want at some zoom level to stop rendering more ticks/labels. Here's an example: At first the axis shows the years, then when you zoom in the ticks become Months, and when you zoom further in it shows the days (I.E., Dec 28). Except I want to restrict d3 such that when zooming further than the months, it doesn't render any more ticks because months are the smallest unit I want.

I have a couple examples that if combined would be exactly what I want, but I can't figure out how to do that.

Also: I added the .tickFormat because I want to display every tick to have an abbreviated month format.

Example 1: http://jsfiddle.net/GGYKL/

var xAxis = d3.svg.axis().scale(x)
    .tickFormat(d3.time.format('%b'))
    .orient("bottom");

This example shows how the ticks/labels appear and disappear correctly when zooming in, BUT when you continue zooming in, it splits the months and starts repeating the month ticks/labels, and I do not want to restrict the user from zooming in.

Example 2: http://jsfiddle.net/4kz7t/

var xAxis = d3.svg.axis().scale(x)
    .ticks(d3.time.months)
    .tickFormat(d3.time.format('%b'))
    .orient("bottom"),

This example fixes the problem when you continue zooming in like what we saw with Example 1, BUT it doesn't dynamically hide/show the ticks/labels when zooming.

like image 874
d_coder Avatar asked Nov 15 '13 21:11

d_coder


2 Answers

The closest answer I've come across was the multi-scale time Format that @Lars Kotthoff suggested. I edited the custom time formatter to be:

http://jsfiddle.net/BdGv5/1/

var customTimeFormat = timeFormat([
    [d3.time.format("%Y"), function() { return true; }],
    [d3.time.format("%b"), function(d) { return d.getMonth(); }],
    [function(){return "";}, function(d) { return d.getDate() != 1; }]
]);

So the ticks themselves would still be generated when zooming in, but the labels would be empty string.

UPDATE:

I ended up creating my own function that I used in .ticks(). Ticks passes to your function the scale extent as t0 and t1. I divided the chart width by the label size (and padding) to find the maximum amount of labels without overlapping, and used underscore to remove every other tick if overlapping will occur.

http://jsfiddle.net/BdGv5/3/

function customTickFunction(t0, t1, dt)
    {

        var labelSize = 30; // largest label is 23 pixels ("May")
        var maxTotalLabels = Math.floor(width / labelSize);

        function step(date, offset)
        {
            date.setMonth(date.getMonth() + offset);
        }

        var time = d3.time.month.ceil(t0), times = [];

        while (time < t1) times.push(new Date(+time)), step(time, 1);

        if(times.length > maxTotalLabels)
            times = _.filter(times, function(d){
                return (d.getMonth() + 1) % 2;
            });

        return times;
    }

    var domain = xScale.domain();
    var xAxisMonthsFunction = d3.svg.axis().scale(xScale)
        .ticks(customTickFunction)
        .tickFormat(d3.time.format("%b"))
        .orient("bottom");

Update 2:

http://jsfiddle.net/BdGv5/5/

Made the ticks adjust further than just 1 level, now it has 4 levels (2,3,4,6).

function customTickFunction(t0, t1, dt)
{

    var labelSize = 30; // largest label is 23 pixels ("May")
    var maxTotalLabels = Math.floor(width / labelSize);

    function step(date, offset)
    {
        date.setMonth(date.getMonth() + offset);
    }

    var time = d3.time.month.ceil(t0), times = [], monthFactors = [2,3,4,6];

    while (time < t1) times.push(new Date(+time)), step(time, 1);

    var i;
    for(i=1 ; times.length > maxTotalLabels ; i++)
        times = _.filter(times, function(d){
            return (d.getMonth()) % monthFactors[i] == 0;
        });

    return times;
}
like image 54
d_coder Avatar answered Nov 11 '22 07:11

d_coder


I had a similar issue while working on a d3 charting components layer and solved it with this method I call before axis rendering:

function preventTooSmallTimeIntervals(period) {
        var tickSeconds = (xScale.ticks()[1] - xScale.ticks()[0]) / 1000;
        var minSecondsPerInterval = period.seconds;
        if (tickSeconds < minSecondsPerInterval) {
            xAxis.ticks(period.d3TimeInterval.unit, period.d3TimeInterval.value);
        } else {
            xAxis.ticks(6);
        }
    }

In that example, period is an object, but you can provide hard-coded values for your situation instead of period.seconds (which is the duration of one interval) and period.d3TimeInterval fields.

    sc.model.period.day1 = periodFactory({
        display: '1 Day',
        seconds: 86400,
        d3TimeInterval: {unit: d3.time.day, value: 1},
        timeFormat: '%b-%d'});
    sc.model.period.hour1 = periodFactory({
        display: '1 Hour',
        seconds: 3600,
        d3TimeInterval: {unit: d3.time.hour, value: 1},
        timeFormat: '%b-%d %Hh'});
    sc.model.period.minute5 = periodFactory({
        display: '5 Minutes',
        seconds: 300,
        d3TimeInterval: {unit: d3.time.minute, value: 5},
        timeFormat: '%H:%M'});
    sc.model.period.minute1 = periodFactory({
        display: '1 Minute',
        seconds: 60,
        d3TimeInterval: {unit: d3.time.minute, value: 1},
        timeFormat: '%H:%M'});

The reason I am not using seconds every time in xAxis.ticks is that I had a huge performance drop when expressing a day minimum as xAxis.ticks(d3.time.seconds, 86400), so I stored the elements needed for d3 in a field.

The idea is to play with the 2 behaviours of axis.ticks, either an interval or a count. Below the threshold, we use a fixed interval, above we revert to d3 automated behaviour. Which solves the problem of zooming out after having zoomed in. Without the else part, you end up with a lot of ticks that stay at the interval you specified.

And in case you just want to copy/paste a simple example, here is an example for a minimum interval of 1 day:

    function preventTimeIntervalsSmallerThanADay() {
        var tickSeconds = (xScale.ticks()[1] - xScale.ticks()[0]) / 1000;
        var secondsPerDay = 86400;
        if (tickSeconds < secondsPerDay) {
            xAxis.ticks(d3.time.day, 1);
        } else {
            xAxis.ticks(6);
        }
    }
like image 39
Shaiar Avatar answered Nov 11 '22 05:11

Shaiar