1
votes

I have a dc.js seriesChart using d3.scaleTime() on the X axis. The data changes dynamically, ranging from a single week to a few years, so I don't define a domain or a range for the scaleTime. Instead I'm using dc.js elasticX(true), which I guess calculates them as needed. I'm also using a fake group to remove the empty bins, in order for the X axis to refresh automatically as the user changes the data filters.

Everything works as expected, but if I filter only one week, I get more ticks than I'd like. Notice that I have only one data point, but up to 4 ticks, for each day:

Too many ticks

I can use axis.ticks to generate only one tick per day:

chart.xAxis()
     .tickFormat(d3.timeFormat('%Y-%m-%d'))
     .ticks(d3.timeDay);

But then it creates one tick per day. If I select a whole year, that's 365 of them overlapping.

I can't use .ticks(n) to always generate n ticks, because that would bring back the problem I had in the first place: duplicate ticks when the number I set is greater than the number of selected days. And I can't use .tickValues([ ]) either, because the values are dynamic.

Is there any way to tell d3 to generate ticks for a given interval, but limited to a max number of ticks? So in my case, it would actually be "daily if it fits, or every whatever days otherwise".

The closest thing I've found is this answer, where they change the interval dynamically depending on the range. However, since I'm letting dc.js and Crossfilter deal with the data and filtering, it wouldn't be easy to calculate that. I guess I would have to attach a renderlet event to the chart, and use xAxisMin() and xAxisMax()? I think it could work, but it wouldn't look too good. Is there an easier way to do it?

1

1 Answers

1
votes

I have a first "working" solution... and it's uglier than I tought it would be:

const chart = dc.seriesChart('#myChart')
    // ...
    .x(d3.scaleTime())
    .elasticY(true)
    .elasticX(true)
    .on('renderlet', updateTicks);
    .xAxis()
        .tickFormat(d3.timeFormat('%Y-%m-%d'))
        .ticks(10); // We set 10 ticks by default
chart.render();

// Needed to prevent infinite loops...
let redrawing = false;

function updateTicks(chart) {
    if (redrawing) {
        redrawing = false;
    } else {
        const days = (chart.xAxisMax() - chart.xAxisMin()) / 86400000;
        if (days < 10) {
            chart.xAxis().ticks(d3.timeDay);
        } else {
            chart.xAxis().ticks(10);
        }
        redrawing = true;
        chart.redraw();
    }
}

xAxisMin() and xAxisMax() values aren't calculated until the chart has already been rendered or redrawn, so I have to use the renderlet event instead of preRedraw. And since the chart is already drawn, any change I make to the axis won't be effective until the next redraw... so I have to force it myself (and prevent an infinite loop, because my redraw will trigger a new renderlet event).

It's not only ugly code, the axis transition is actually visible on the chart. I could avoid it by using the pretransition event instead, as pointed out by Gordon. Or by calculating the min and max values on the preRender/preRedraw event, directly from the Crossfilter group, but it's starting to feel like a huge overkill.


Here is a working-but-still-ugly solution, calculating the min and max myself:

const chart = dc.seriesChart('#myChart')
    // ...
    .x(d3.scaleTime())
    .elasticY(true)
    .elasticX(true)
    .on('preRender', updateTicks),
    .on('preRedraw', updateTicks);
    .xAxis().tickFormat(d3.timeFormat('%Y-%m-%d'));
chart.render();

function updateTicks(chart) {
    const range = chart.group().all().reduce((accum, d) => minMax(d.key.date, accum), {});
    const days = 1 + ((new Date(range.max) - new Date(range.min)) / 86400000);
    if (days <= 7) {
        chart.xAxis().ticks(d3.timeDay);
    } else if (days <= 30) {
        chart.xAxis().ticks(d3.timeMonday);
    } else {
        chart.xAxis().ticks(d3.timeMonth);
    }
}

function minMax(val, obj) {
    if ((obj.min === undefined) || (val < obj.min)) {
        obj.min = val;
    }
    if ((obj.max === undefined) || (val > obj.max)) {
        obj.max = val;
    }
    return obj;
}

Not ugly code per se, it's just annoying - traversing the whole data array each time I redraw the chart, only to find the min and max values, which will be calculated again by dc/crossfilter/d3 anyway.