用D3.js進行醫療數據可視化 (二)圖例 (Legend)

介紹

在上一篇文章的結尾,對生成的可視化圖,我們提到了許多待改進的地方。這兒就先來討論下圖例的顯示。

圖例的主要目的是說明圖表中各種符號和顏色所代表的內容及指標的說明。對於我們之前生成的line chart,需要用圖例說明每條曲線所代表的具體含義是什麼。總結兩點基本原則如下:

1.     圖例中要對應曲線的顏色,並指出對應的醫療機構名稱;

2.     圖例要放在適當的位置,以便起到恰如其分的指示作用。

本文下面會實現兩種不同的實現方法,並討論其不同之處。

 

代碼示例

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>全國醫療衛生機構醫療服務量</title>
    <link rel="stylesheet" type="text/css" href="css/styles.css"/>
    <script type="text/javascript" src="js/d3.min.js"></script>
</head>

<body>

<script type="text/javascript">
function lineChart() {
    var _chart = {};

    var _width = screen.width * 0.9, _height = 350,
            _margins = {top: 30, left: 50, right: 130, bottom: 60},  //expand margin for legend rendor
            _legendbottom = 30,   //area for legend in Method 2
            _legendright = 100,  //area for legend in Method 1
            _x, _y,
            _names = [],    //keep name list for the chart
            _data = [],
            //_colors = d3.scale.category10(),
            _colors,    //define colors according to name list
            _svg,
            _bodyG,
            _line;

    _chart.render = function () {
        if (!_svg) {
            _svg = d3.select("body").append("div")
            		.style("text-align", "center")
            		.append("svg")
                    .attr("height", _height)
                    .attr("width", _width);

            renderAxes(_svg);

            defineBodyClip(_svg);
        }

        renderBody(_svg);
    };

    function renderAxes(svg) {
        var axesG = svg.append("g")
                .attr("class", "axes");

        renderXAxis(axesG);

        renderYAxis(axesG);
    }
    
    function renderXAxis(axesG){
        var xAxis = d3.svg.axis()
                .scale(_x.range([0, quadrantWidth()]))
                .orient("bottom");        

        axesG.append("g")
                .attr("class", "x axis")
                .attr("transform", function () {
                    return "translate(" + xStart() + "," + yStart() + ")";
                })
                .call(xAxis);
                
        d3.selectAll("g.x g.tick")
            .append("line")
                .classed("grid-line", true)
                .attr("x1", 0)
                .attr("y1", 0)
                .attr("x2", 0)
                .attr("y2", - quadrantHeight());
    }
    
    function renderYAxis(axesG){
        var yAxis = d3.svg.axis()
                .scale(_y.range([quadrantHeight(), 0]))
                .orient("left");
                
        axesG.append("g")
                .attr("class", "y axis")
                .attr("transform", function () {
                    return "translate(" + xStart() + "," + yEnd() + ")";
                })
                .call(yAxis);
                
         d3.selectAll("g.y g.tick")
            .append("line")
                .classed("grid-line", true)
                .attr("x1", 0)
                .attr("y1", 0)
                .attr("x2", quadrantWidth())
                .attr("y2", 0);
    }

    function defineBodyClip(svg) {
        var padding = 5;

        svg.append("defs")
                .append("clipPath")
                .attr("id", "body-clip")
                .append("rect")
                .attr("x", 0 - padding)
                .attr("y", 0)
                .attr("width", quadrantWidth() + 2 * padding + _legendright)  //expand clip path for legend
                .attr("height", quadrantHeight());
    }

    function renderBody(svg) {
        if (!_bodyG)
            _bodyG = svg.append("g")
                    .attr("class", "body")
                    .attr("transform", "translate(" 
                        + xStart() + "," 
                        + yEnd() + ")")
                    .attr("clip-path", "url(#body-clip)");        

        renderLines();

        renderDots();
        
        //Method 2: put legend at the bottom of the chart
        renderLegend();
    }

    function renderLines() {
        _line = d3.svg.line()
                        .x(function (d) { return _x(d.date); })
                        .y(function (d) { return _y(d.value); });
                        
        _bodyG.selectAll("g.linegroup path.line")
                    .data(_data)
                .enter()
                .append("g")
                .attr("class", "linegroup")
                .append("path")                
                .style("stroke", function (d, i) { 
                    return _colors(_names[i]);
                })
                .attr("class", "line");
        
        _bodyG.selectAll("g.linegroup path.line")
        			.data(_data)
        			.exit()
        			.remove();

        _bodyG.selectAll("g.linegroup path.line")
                .transition()
                .duration(1000)
                .attr("d", function (d) { return _line(d); });
        
        //Method 1: add legend along with the line
        _bodyG.selectAll("g.linegroup")
        	.append("text")
	        .datum(function(d, i) { 
	        	return {name: _names[i], date: d[d.length - 1].date, value: d[d.length - 1].value};   //get the last point of the line
	        })
	        .attr("transform", function(d) { 
	        	return "translate(" + (_x(d.date) + 5) + "," + _y(d.value) + ")";  //set the legend beside the last point
	        })
	        .attr("x", 3)
	        .attr("dy", ".35em")
	        .classed("legendtext", true)
	        .text(function(d) { 
	        	return d.name; 
	        });
    }

    function renderDots() {
        _data.forEach(function (list, i) {
            _bodyG.selectAll("circle._" + i)
                        .data(list)
                    .enter()
                    .append("circle")
                    .attr("class", "dot _" + i);
            
            _bodyG.selectAll("circle._" + i)
            		.data(list) 
            		.exit()
            		.remove();

            _bodyG.selectAll("circle._" + i)
                    .data(list)                    
                    .style("stroke", function (d) { 
                        return _colors(_names[i]);
                    })
                    .transition()
                    .duration(1000)
                    .attr("cx", function (d) { return _x(d.date); })
                    .attr("cy", function (d) { return _y(d.value); })
                    .attr("r", 3.5);
        });
    }
    
    //render legend at the bottom of the chart
    function renderLegend(){
    	var legend = _svg.selectAll(".legend")
			.data(_colors.domain())
			.enter()
			.append("g")
			.attr("class", "legend")
			.attr("transform", function(d, i) {
				var legendX = i * 120 + _margins.left;   //set position for each legend element
				var legendY = _height - _legendbottom;
				return "translate(" + legendX + ", " + legendY + ")";
			});
		
		legend.append("rect")
			.attr("x", 0)
			.attr("y", 1)
			.attr("width", 16)
			.attr("height", 8)
			.style("fill", _colors);
		
		legend.append("text")
			.attr("x", 20)
			.attr("y", 9)
			.classed("legendtext", true)
			.text(function(d) {
				return d;
			});
    }

    function xStart() {
        return _margins.left;
    }

    function yStart() {
        return _height - _margins.bottom;
    }

    function xEnd() {
        return _width - _margins.right;
    }

    function yEnd() {
        return _margins.top;
    }

    function quadrantWidth() {
        return _width - _margins.left - _margins.right;
    }

    function quadrantHeight() {
        return _height - _margins.top - _margins.bottom;
    }

    _chart.width = function (w) {
        if (!arguments.length) return _width;
        _width = w;
        return _chart;
    };

    _chart.height = function (h) {
        if (!arguments.length) return _height;
        _height = h;
        return _chart;
    };

    _chart.margins = function (m) {
        if (!arguments.length) return _margins;
        _margins = m;
        return _chart;
    };

    _chart.colors = function (c) {
        if (!arguments.length) return _colors;
        _colors = c;
        return _chart;
    };
    
    //set name list function
    _chart.names = function (n) {
        if (!arguments.length) return _names;
        _names = n;
        return _chart;
    };

    _chart.x = function (x) {
        if (!arguments.length) return _x;
        _x = x;
        return _chart;
    };

    _chart.y = function (y) {
        if (!arguments.length) return _y;
        _y = y;
        return _chart;
    };

    _chart.addSeries = function (series) {
        _data.push(series);
        return _chart;
    };

    return _chart;
}

function randomData() {
    return Math.random() * 9;
}

function update() {
    for (var i = 0; i < data.length; ++i) {
        var series = data[i];
        series.length = 0;
        for (var j = 0; j < numberOfDataPoint; ++j)
            series.push({x: j, y: randomData()});
    }

    chart.render();
}
        
//load data
var names = [];
var data = [];
var minvalue = 0, maxvalue = 0;
var mindate = 0, maxdate = 0;

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

var chart;

d3.text("data/health-service-quantity.json", function(rawdatastr){
	var rawdata = JSON.parse(rawdatastr);
	names = d3.keys(rawdata);
	names.forEach(function(name){
		var list = rawdata[name].map(function(item){
			return {
				"date": timeformat.parse(item.date), 
				"value": item.value
			}
		});
		
		//get min and max value
		if(minvalue == 0){
			minvalue = d3.min(list, function(d){return d.value;});			
		}else{
			minvalue = d3.min([minvalue, d3.min(list, function(d){return d.value;})]);
		}
		maxvalue = d3.max([maxvalue, d3.max(list, function(d){return d.value;})]);
		
		//get min and max date
		if(mindate == 0){
			mindate = d3.min(list, function(d){return d.date;});
		}
		if(maxdate == 0){
			maxdate = d3.max(list, function(d){return d.date;});
		}
		
		data.push(list);
	});
	
	chart = lineChart()
			.x(d3.time.scale().domain([mindate, maxdate]))
			.y(d3.scale.linear().domain([minvalue * 0.5, maxvalue * 1.02]));
	
	data.forEach(function (series) {
	    chart.addSeries(series);
	});
	
	//set name list to the chart
	chart.names(names);
	//define colors according to name list
	chart.colors(d3.scale.category10().domain(names));
	
	chart.render();
});

</script>

</body>

</html>

 

 

可視化結果

可以看到,其中一種實現方法(方法一)把圖例直接放到曲線的後面(原理可以參考[1]),而另一種方法(方法二)則把圖例集中放在了圖表的下方。

 


討論

先看看實現方法。在上面圖例的實現中,有幾個基本概念。

首先,無論哪種實現方法,都需要把names傳入chart,以在圖例中顯示醫療機構的名稱。其次,這個名稱列表和colors的對應關係需要明確地定義,以便在圖例中也能保持和曲線一致的關聯關係。

除了上面的一些通用的地方, 方法一關鍵的兩點,一是要將圖例的文字放到曲線最後一個點的位置旁邊,二是要爲圖例的文字留夠右邊的空白。本例中由於使用了clipPath,還需要讓clipPath的範圍也能覆蓋到圖例的範圍。

方法二也需要爲圖例留夠圖表下方的空白。另外還要設置恰當的圖例排版。現在的實現由於寬度足夠,因此是均勻排列成一行。也可以設置恰當的計算方式進行其他樣子的排版,比如排成多行或縱向排列等。

加入兩種方法產生的圖例後,上面整個chart的佈局如下圖所示。


從目前的實現效果來看, 第一種方法佔用的是橫向的空間,優點是圖例跟着曲線走,讓用戶對每根曲線代表的數據一目瞭然。但它的缺陷也是顯而易見的, 不同的圖例名字可能會重疊到一起而看不清楚。第二種方法是一種比較標準的圖例放置辦法,佔用的是縱向的空間,沒有特別明顯的缺陷。對於當前這個線圖,個人感覺第二種方法更合適一些。當然對於每個不同的圖表,需要根據其實際情況選擇其圖例呈現的方式。

 

參考文獻

1.     Multi-Series Line Chart. http://bl.ocks.org/mbostock/3884955

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章