介紹
大概說說這個事情的前因後果吧。
去年下半年,我有機會參加了一個大型項目,在裏面參與了前端可視化組建的開發,用的library是D3.js [2]。雖然3個月後我就結束了在這個項目裏的工作,但後面斷斷續續也用D3做了一些別的實現。
而從幾年前開始,我一直在做和healthcare相關的項目,主要就是做醫療數據的信息化,從而優化醫療領域的信息管理、決策支持。從項目的角度出發,我們的着眼點一般都是某個具體的問題,比如如何優化病人診療的流程。但從宏觀上出發,如果能瞭解某個地區乃至全國的醫療服務情況是怎麼樣的,也是 非常有意義的 。
結合這兩者,我就想做一件事情,將一些宏觀的醫療服務數據通過可視化技術,更直觀地表達出來。
原則
對於做這個事情,個人認爲也有一些原則要遵守。首先,最基本的,醫療數據的來源要公開、權威。其次,最重要的,這樣的可視化要體現醫療服務的特點,使得其在醫療領域能體現價值。當然,這不是一蹴而就的,而需要不斷地在探索和實踐中摸索。實踐本身也是一個學習的過程。除了D3.js官網的API文檔,主要的參考書就是Nick Qi Zhu的《D3.js數據可視化實戰手冊》[3]。由於本人較懶,後面很多實踐也是在Nick的源碼基礎上直接修改所得。
數據來源
本着公開、權威的原則,找到了國家衛生和計劃生育委員會統計信息中心[1]的統計數據。由於在這個階段我的目標是驗證醫療數據可視化的一個實現的流程,因此並沒有花太多時間在數據的抓取上。通過簡單的調研,發現其中全國醫療服務情況的數據還比較全面。而由於精力所限,最終選擇了以下幾類醫療衛生機構醫療服務量中的診療人次數,輸出的是JSON格式文檔。
- 機構合計
- 醫院
- 三級醫院
- 二級醫院
- 一級醫院
- 未定級醫院
- 基層醫療衛生機構
- 醫院
{
"醫療衛生機構合計": [
{
"date": "2013-05",
"value": 60005.2
},{
"date": "2013-06",
"value": 58861.9
},{
"date": "2013-07",
"value": 60264.2
},{
"date": "2013-08",
"value": 59519.3
},...
], ...
}
可視化選型
通過對數據的解讀並結合之前的原則,我一開始的想法是想用D3圖表表現全國不同醫療機構月均診療人次數隨着時間的變化。也就是說,圖表的橫軸是時間,縱軸是診療人次數,而不同的醫療機構的數據能夠出現在同一個圖表中以便做對比。因此,我自然而然將線圖(linechart)作爲了第一選擇。
D3實現
對於基本線圖的實現,《D3.js數據可視化實戰手冊》中有很好的例子,也提供了源碼,因此在整個coding過程中,比較多的時間花在了數據的處理上。本人使用的D3版本是3.5.5。源碼如下。
<!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: 30, bottom: 30},
_x, _y,
_data = [],
_colors = d3.scale.category10(),
_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)
.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();
}
function renderLines() {
_line = d3.svg.line()
.x(function (d) { return _x(d.date); })
.y(function (d) { return _y(d.value); });
_bodyG.selectAll("path.line")
.data(_data)
.enter()
.append("path")
.style("stroke", function (d, i) {
return _colors(i);
})
.attr("class", "line");
_bodyG.selectAll("path.line")
.data(_data)
.exit()
.remove();
_bodyG.selectAll("path.line")
.transition()
.duration(1000)
.attr("d", function (d) { return _line(d); });
}
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(i);
})
.transition()
.duration(1000)
.attr("cx", function (d) { return _x(d.date); })
.attr("cy", function (d) { return _y(d.value); })
.attr("r", 3.5);
});
}
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;
};
_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();
}
var names = [];
var data = [];
var minvalue = 0, maxvalue = 0;
var mindate = 0, maxdate = 0;
var timeformat = d3.time.format("%Y-%m");
var chart;
<pre name="code" class="javascript">//load data
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);
});
chart.render();
});
</script>
</body>
</html>
分析一下幾個技術點。
1. 從JSON文件加載數據
D3提供了d3.json,但很不幸地,此API只能加載list式的JSON(例如[1,2, 3])而不能加載key-value式的(例如{“id”:1})。而我之前構造的數據是以不同機構的名字作爲key,其下面的數據列表作爲value。因此我選擇了用d3.text加載JSON文件成String,再通過JSON.parse將String變換成JSON Object。
接下來,考慮到每條線對應的是list式的數據,因此還需要對原始JSONObject做一些變換,比如將每個value中的list放到list變量data中,並將原來以字符串形式存在的date通過d3.time.format變換成Date Object。
2. 得到數據分佈的最大最小值
要生成x軸和y軸,需要設置其合理的domain。這兒就要計算數據的最大最小值。對x軸而言,對應的是數據中的日期。而對不同的醫療機構而言,這兒我們假設其數據的日期範圍是一樣的,因此只要對一個機構的數據進行計算即可。用到的就是d3.min和d3.max。並且由於每條數據是一個JSON Object,設置d3.min和d3.max的accessor爲一個返回date的function。對於y軸而言,需要計算所有機構的數據的最大最小值,因此使用了嵌套的d3.min和d3.max。
可視化結果
討論
從呈現的結果來看,達到了初步的預期:每種機構的日均診療人次數各有大小;總體呈緩慢上升趨勢;過年期間會小降;等等。
但總體而言,這個可視化呈現還有很多待改進的地方。
1. 需要顯示圖例,說明每條曲線對應的機構
2. x軸最好能顯示每個月的中文名字,中間區域內的網格也是每個月一格。
3. 由於其中的機構種類有包含關係,是否對其改用堆積區圖 (stackedarea chart) 比較好?
4. 從美觀考慮,線的出現最好有動畫效果。
後面的系列會陸續探討這幾點。
參考文獻
1. 國家衛生和計劃生育委員會統計信息中心.http://www.moh.gov.cn/mohwsbwstjxxzx/index.shtml
2. D3.jsAPI Reference. https://github.com/mbostock/d3/wiki/API-Reference
3. Nick Qi Zhu. D3.js數據可視化實戰手冊. http://book.douban.com/subject/26256865/