D3.js学习笔记:绘制日历

目录

背景

日历是按日期分布的数据最直观的可视化形式。

时间属性可以和人类日历对应,缤纷为年、月、周、日、小时等多个等级。因此,采用日历表达时间属性,和我们识别时间的习惯相符合。————《数据可视化》 陈为等

书中给出一个使用d3.js绘制的例子

2007年至2009年美国道琼斯股票指数,红色表示下跌,绿色表示上涨。图片来源://bl.ocks.org/mbostock/4063318

代码请参见d3.js作者的博客《Calendar View

GitHub也使用日历来显示用户的活跃度。下图是我2014年的活跃度。

2014年我在github的活动统计图。图片来源:https://github.com/perillaroc

参考上述两个示例,我使用d3.js绘制日历图。

实现

用一个例子来说明绘制日历的过程。

需求

与GitHub类似,展示从当前日期之前一年内每天的数据,用颜色表示数值的大小,颜色越浅,数目越小,颜色越深,数目越大。没有数据则用灰色背景表示。
纵轴表示星期,横轴表示周数。与GitHub不同,我设定起始日期始终是一年前的周日。

数据准备

输入数据

当前日期

var end_date = new Date();

测试

end_date
Mon Jan 05 2015 09:24:13 GMT+0800 (中国标准时间)

当前能获得的日期数据

var collect_date_value = [
    {date:'2014-10-01',value:0},
    {date:'2014-10-02',value:0},
    {date:'2014-10-03',value:0},
    {date:'2014-10-04',value:1},
    {date:'2014-10-05',value:0},
    {date:'2014-10-06',value:0},
    {date:'2014-10-07',value:5}
];

日期格式:
使用d3.time.format定义日期格式:

var date_format = d3.time.format("%Y-%m-%d");

测试

date_format(new Date())
"2015-01-05"

数据处理

一年前的周日

d3提供丰富的日期操作函数,使用d3.time.week返回之前最近的周日。

var start_date = new Date();
start_date.setFullYear(end_date.getFullYear()-1);
start_date = d3.time.week(start_date);

测试

start_date
Sun Jan 05 2014 00:00:00 GMT+0800 (中国标准时间)
日期序列

使用d3.time.days生成日期序列

var date_range = d3.time.days(start_date, end_date);

测试:

date_range
Array[366]
完整日期数据

将输入的不完整数据扩展到整个日期序列上,默认值为-1,表示没有对应数据。我想到使用map来更新特定索引的值。d3提供d3.map对象实现map。

设置初值

var date_value_map = d3.map();
date_range.forEach(function(element, index, array){
    date_value_map.set(date_format(element), -1);
});

更新数据

var date_value = [];
date_value_map.forEach(function(key,value){
    date_value.push({
        date:date_format.parse(key), value:value
    });
});

组装成最后的输入数据:

var calendarData= {
    date_value: date_value,
    start_date: start_date,
    end_date: end_date,
}

日期网格

横坐标

周数作为横坐标,先用d3.time.weeks得到周序列,用d3.scale.ordinal将周序列映射到横坐标长度范围中。

d3.scale.ordinal用于将离散数据映射到某个区间中。

var week_range = d3.time.weeks(d3.time.week(start_date), end_date);
var len = week_range.length;
var scale = d3.scale.ordinal().domain(week_range).rangeBands([0,len]);

纵坐标

纵坐标为星期序号。

var y_format = d3.time.format("%w");

颜色

使用colorbrewer提供的配色。利用d3.scale.quantile将整数数值范围映射到5个颜色中。

d3.scale.quantile用于将数据映射到离散区域中。

var calendar_style = {
    color_range: colorbrewer.YlOrRd[5]
};
var max = d3.max(data, function(d){
     return d.value;
});
var quantize = d3.scale.quantile().domain([0, max]).range(calendar_style.color_range);

绘制

为每个数据画矩形。

var rect_group=svg.append('g');
var r = rect_group.selectAll('rect').data(data).enter().append('rect')
    .attr('x', function(d,i){ return scale(d3.time.week(d.date))*cell_size;})
    .attr('y', function(d,i){ return y_format(d.date)*cell_size; })
    .attr('width', cell_size)
    .attr('height', cell_size)
    .style('stroke', '#ccc')
    .attr('fill', function(d) {
        if(d.value==-1)
            return "#eeeeee";
        else
            return quantize(d.value);
    });

交互

提示条

鼠标指向时显示的提示条。d3可以实现tooltip组件,但我为了省事,直接使用bootstrap的tooltip组件。不应该这么混用。

r.attr('data-toggle',"tooltip")
    .attr('data-placement','top')
    .attr('title',function(d) {
          if(d.value==-1)
               return date_format(d.date)+ ": No data.";
          else if(d.value==0)
              return date_format(d.date)+ ": There is no error.";
          else if(d.value==1)
              return date_format(d.date)+ ": "+ d.value + " error";
          else
              return date_format(d.date)+ ": "+ d.value + " errors";
    }
$('svg').tooltip({
    selector:'[data-toggle="tooltip"]',
    container:'body'
});

单击事件

单击事件需要与外部组件交互,所以添加到输入参数中。

/* 输入参数 */
calendarData.action.click=function(d){
    /* ... */
};

/* 绘图 */
var calendar_action = {};
if(calendarData.hasOwnProperty('action')) {
    var param_calendar_action = calendarData.action;
    for(var key in param_calendar_action){
        calendar_action[key] = param_calendar_action[key];
    }
}

/* ... */
r.on('click', function (d, i) {
    scope.$apply(function () {
        calendar_action.click(d);
    });
});

月份分割线

月份分割线的实现完全拷贝自前文calendar的例子。不过因为我的日期范围不是整年,所以无法画出所有月的分割线,最后一个月如果不是最后一天,则无法绘出完整的分割线。所以不画最后一个月(以后有待改进)。

var month_path_group = svg.append('g');
var month_start_day = new Date(start_date);
var month_end_day = new Date(end_date);
month_end_day.setMonth(end_date.getMonth()-1);
month_path_group.selectAll(".month")
    .data(function(d) { return d3.time.months(month_start_day, month_end_day); })
    .enter().append("path")
    .attr("class", "month")
    .attr("d", monthPath);

function monthPath(t0){
    var t1 = new Date(t0.getFullYear(), t0.getMonth()+1, 0);
    var d0 = +y_format(t0);
    var w0 = scale(d3.time.week(t0));
    var d1 = +y_format(t1);
    var w1 = scale(d3.time.week(t1));
    return "M" + (w0 + 1) * cell_size + "," + d0 * cell_size
        + "H" + w0 * cell_size + "V" + 7 * cell_size
        + "H" + w1 * cell_size + "V" + (d1 + 1) * cell_size
        + "H" + (w1 + 1) * cell_size + "V" + 0
        + "H" + (w0 + 1) * cell_size + "Z";
}

还需要设置样式表

/* calendar */
.month {
    fill: none;
    stroke:#000;
    stroke-width:2px;
}

月份标注

同样,不标注第一个月和最后一个月。

var month_text_format=d3.time.format('%Y-%m');
var month_text_group = svg.append('g');
month_text_group.selectAll(".month")
    .data(function(d) { return d3.time.months(month_start_day, month_end_day); })
    .enter().append("text")
    .attr("x", function(t0){
        var t1 = new Date(t0.getFullYear(), t0.getMonth()+1, 0);
        var w0 = scale(d3.time.week(t0));
        var w1 = scale(d3.time.week(t1));
        return (w0+w1)/2*cell_size;
    })
    .attr("y", 8*cell_size)
    .style("text-anchor", "middle")
    .style("font-size", 10)
    .text(function(d){
        return month_text_format(d);
    });

效果

上面例子的演示图片:

使用该组件的又一个示例:

资源

上述代码我已上传到github中,写本文时的文件快照如下:

使用示例

https://github.com/perillaroc/sms-log-reporter/blob/76fce93f3f76c1ecd487d4b6d7e5e9ac8874423f/sms_log_reporter/static/app/controllers.js#L16

日期控件代码

https://github.com/perillaroc/sms-log-reporter/blob/76fce93f3f76c1ecd487d4b6d7e5e9ac8874423f/sms_log_reporter/static/app/directive.js#L1