Skip to content

Instantly share code, notes, and snippets.

@yuki-matsushita
Last active March 16, 2023 12:18
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save yuki-matsushita/dcace11b8db72a64f56314922e58e4f9 to your computer and use it in GitHub Desktop.
Save yuki-matsushita/dcace11b8db72a64f56314922e58e4f9 to your computer and use it in GitHub Desktop.
D3.js Stacked Line Chart with Tooltip & Switching Ratio
[
{"day" : "2018/5/08", "value1": 47190, "value2": 57190, "value3": 78230},
{"day" : "2018/5/09", "value1": 77150, "value2": 77190, "value3": 48230},
{"day" : "2018/5/10", "value1": 47190, "value2": 57190, "value3": 78230},
{"day" : "2018/5/11", "value1": 77150, "value2": 77190, "value3": 48230},
{"day" : "2018/5/12", "value1": 76650, "value2": 77190, "value3": 78230},
{"day" : "2018/5/13", "value1": 57040, "value2": 77190, "value3": 74230},
{"day" : "2018/5/14", "value1": 77100, "value2": 47190, "value3": 78230},
{"day" : "2018/5/15", "value1": 76940, "value2": 27190, "value3": 48230},
{"day" : "2018/5/16", "value1": 77410, "value2": 77190, "value3": 78230},
{"day" : "2018/5/17", "value1": 67670, "value2": 77190, "value3": 78230},
{"day" : "2018/5/18", "value1": 77710, "value2": 87190, "value3": 48230},
{"day" : "2018/5/19", "value1": 77920, "value2": 77190, "value3": 78230},
{"day" : "2018/5/20", "value1": 78230, "value2": 37190, "value3": 78230},
{"day" : "2018/5/21", "value1": 77410, "value2": 77190, "value3": 68230},
{"day" : "2018/5/22", "value1": 77670, "value2": 57190, "value3": 78230},
{"day" : "2018/5/23", "value1": 37710, "value2": 77190, "value3": 78230},
{"day" : "2018/5/24", "value1": 97920, "value2": 57190, "value3": 38230}
]
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" type="text/css" href="style.css" media="all" />
</head>
<body>
<div id="contents">
<div id="chartArea">
<svg width="960" height="500" id="chart"></svg>
<div id="changeBtn">合計 | 比率 表示変更</div>
</div>
</div>
<script src="https://d3js.org/d3.v5.min.js"></script>
<script src="stacked_line_chart.js"></script>
</body>
</html>
// SVG要素の選択とサイズの取得
var svg = d3.select("svg"),
margin = {top: 60, right: 30, bottom: 40, left: 40},
width = svg.attr("width"),
height = svg.attr("height"),
cWidth = width - margin.left - margin.right,
cHeight = height - margin.top - margin.bottom;
var formatTime = d3.timeFormat("%m/%d"),
formatClass = d3.timeFormat("date-%m%d");
var g = svg.append("g").attr("transform", "translate(" + margin.left + "," + margin.top + ")");
// マウスカーソルからのフォーカスの位置推定
var parseTime = d3.timeParse("%Y/%m/%e");
var bisectDate = d3.bisector(function(d) { return d.day; }).left;
var parseDateTime = d3.timeParse("%Y-%m-%e %H:%M:%S");
var x = d3.scaleTime().range([0, cWidth]),
y = d3.scaleLinear().range([cHeight, 0]),
z = d3.scaleOrdinal().range(["#98abc5", "#8a89a6", "#7b6888"]);
d3.json("data.json").then(
function(data) {
// 比率表示用にデータを複製
var showData = data;
var ratioData = JSON.parse(JSON.stringify(data));
// データを日付形式に変換
data.forEach(function(d) {
d.day = parseTime(d.day);
});
ratioData.forEach(function(d) {
d.day = parseTime(d.day);
});
var keys = [];
for (key in data[0]){
if (key != "day")
keys.push(key);
}
// データ表示を比率に変換
ratioData.forEach(function(d, i) {
// 階層全ての合計値を求める
for (j = 0, t = 0; j < keys.length; ++j){
t += parseInt(d[keys[j]]);
}
// 合計値における比率を求める
for (j = 0; j < keys.length; ++j){
ratioData[i][keys[j]] = d[keys[j]] / t;
}
});
x.domain(d3.extent(data, function(d) { return d.day; }));
y.domain([0, d3.max(data, function(d) {
for (i = 0, t = 0; i < keys.length; ++i){
t += parseInt(d[keys[i]]);
}
return t;
})]).nice();
z.domain(keys);
var area = d3.area()
.x(function(d, i) {
return x(d.data.day); })
.y0(function(d) { return y(d[0]); })
.y1(function(d) { return y(d[1]); });
var layer = g.selectAll(".layer")
.data(d3.stack().keys(keys)(data))
.enter().append("g")
.attr("class", "layer")
.append("path")
.attr("class", "area")
.style("fill", function(d) { return z(d.key); })
.attr("d", area);
// 横の目盛り
g.append("g")
.attr("class", "axis axis--x")
.attr("transform", "translate(0," + cHeight + ")")
.call(
d3.axisBottom(x)
.ticks(12)
.tickFormat(d3.timeFormat("%Y/%m/%d"))
);
// 横の目盛りを日付のみにして土日にクラスを付与する
var ticks = d3.selectAll(".axis--x text");
ticks.attr("class", function(d){
if(d3.timeFormat("%a")(d) == "Sat") return "sat";
if(d3.timeFormat("%a")(d) == "Sun") return "sun";
return "weekday";
}).html(function(d) {return formatTime(d);});
// 縦の目盛り
g.append("g")
.attr("class", "axis axis--y")
.call(
d3.axisLeft(y)
.ticks(6)
.tickSizeInner(-cWidth)
.tickFormat(function(d) { return d/1000 + "k"; }))
.append("text")
.attr("class", "axis-title")
.attr("y", -20)
.attr("dy", ".71em")
.style("text-anchor", "end")
.attr("fill", "#5D6971")
.text("合計");
var focus = g.append("g")
.attr("class", "focus")
.style("display", "none");
focus.append("line")
.attr("class", "x-hover-line hover-line")
.attr("y1", 0)
.attr("y2", cHeight);
var circle1 = focus.append("circle").attr("r", 7.5),
circle2 = focus.append("circle").attr("r", 7.5),
circle3 = focus.append("circle").attr("r", 7.5);
/* 中央上の表記 */
var legend = g.append("g")
.attr("font-family", "sans-serif")
.attr("font-size", 10)
.attr("text-anchor", "end")
.selectAll("g")
.data(keys.slice().reverse())
.enter().append("g")
.attr("transform", function(d, i) { return "translate("+ i * 60 +"," + -25 + ")"; });
legend.append("circle")
.attr("cx", cWidth/2 - 70)
.attr("cy", 9)
.attr("r", 6)
.attr("fill", z);
legend.append("text")
.attr("x", cWidth /2 - 39)
.attr("y", 9.5)
.attr("dy", "0.32em")
.text(function(d, i) { return "FQ" + parseInt(i+1); });
/* 中央上の表記 */
svg.append("rect")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")")
.attr("class", "overlay")
.attr("width", cWidth)
.attr("height", cHeight)
.on("mouseover", function() {
tooltip.style("display", "block");
focus.style("display", null); })
.on("mouseout", function() {
tooltip.style("display", "none");
focus.style("display", "none");
})
.on("mousemove", mousemove);
// tooltip と focusの設定
var tooltip = d3.select("#contents").append("div").attr("class", "tooltip"),
tt_date = tooltip.append("time").attr("class", "tt_date"),
tt_value = tooltip.append("div").attr("class", "tt_value");
function mousemove() {
var x0 = x.invert(d3.mouse(this)[0]),
i = bisectDate(showData, x0, 1),
d0 = data[i - 1],
d1 = data[i],
rd0 = ratioData[i - 1],
rd1 = ratioData[i],
d = x0 - d0.day > d1.day - x0 ? d1 : d0;
rd = x0 - rd0.day > rd1.day - x0 ? rd1 : rd0;
tt_date.html(function() {return formatTime(d.day);});
tt_value.html(function() {
var fq_values = "FQ1:" + d.value3 +" (" + (rd.value3*100).toFixed(1) + "%)<br/>" +
"FQ2:" + d.value2 +" (" + (rd.value2*100).toFixed(1) + "%)<br/>" +
"FQ3:" + d.value1 +" (" + (rd.value1*100).toFixed(1) + "%)<br/>";
return fq_values});
// マウスの位置によりtooltipを表示位置を変更(右側 or 左側)
var centerX = cWidth / 2;
var tooltipPosX = 5,
tooltipPosY = -15;
if(d3.mouse(this)[0] > centerX) {
// tooltipの大きさ分、左側にx座標をずらす
tooltipPosX = -tooltip.node().getBoundingClientRect().width;
}
tooltip.transition()
.duration(200)
.ease(d3.easeLinear)
.style("left", (d3.event.pageX + tooltipPosX) + "px")
.style("top", (d3.event.pageY - tooltipPosY) + "px");
focus.attr("transform", "translate(" + x(d.day) + "," + 0 + ")");
focus.select(".x-hover-line").attr("y2", cHeight);
if(isRatio){
circle1.attr("transform", "translate(" + 0 + "," + y(rd.value1) + ")");
circle2.attr("transform", "translate(" + 0 + "," + parseInt(y(rd.value1) - (cHeight - y(rd.value2))) + ")");
circle3.attr("transform", "translate(" + 0 + "," + parseInt(y(rd.value1) - (cHeight - y(rd.value2) + cHeight - y(rd.value3))) + ")");
}else{
circle1.attr("transform", "translate(" + 0 + "," + y(d.value1) + ")");
circle2.attr("transform", "translate(" + 0 + "," + parseInt(y(d.value1) - (cHeight - y(d.value2))) + ")");
circle3.attr("transform", "translate(" + 0 + "," + parseInt(y(d.value1) - (cHeight - y(d.value2) + cHeight - y(d.value3))) + ")");
}
}
// 合計⇄比率変換用ボタン
var changeBtn = d3.select("#changeBtn");
// 比率表示状態かどうか
var isRatio = false;
changeBtn.on("click", function() {
isRatio = !isRatio;
if(isRatio){
// 比率表示
y.domain([0, 1]);
showData = ratioData;
d3.selectAll(".axis--y")
.call(
d3.axisLeft(y)
.ticks(6)
.tickSizeInner(-cWidth)
.tickFormat(function(d) { return d*100 + "%"; })
);
d3.select(".axis-title")
.text("比率");
}else{
// 合計表示
y.domain([0, d3.max(data, function(d) {
for (i = 0, t = 0; i < keys.length; ++i){
t += parseInt(d[keys[i]]);
}
return t;
}
)]).nice();
showData = data;
d3.selectAll(".axis--y")
.call(
d3.axisLeft(y)
.ticks(6)
.tickSizeInner(-cWidth)
.tickFormat(function(d) { return d/1000 + "k"; })
);
d3.select(".axis-title")
.text("合計");
}
d3.selectAll(".layer")
.data(d3.stack().keys(keys)(showData))
.select("path")
.attr("class", "area")
.transition()
.duration(500)
.ease(d3.easeLinear)
.attr("d", area);
});
}
);
@charset "UTF-8";
/* CSS Document */
.axis {
font: 10px sans-serif;
}
.axis path,
.axis line {
fill: none;
stroke: #D4D8DA;
stroke-width: 2px;
shape-rendering: crispEdges;
}
.line {
fill: none;
stroke: #1490d8;
stroke-width: 5px;
}
.overlay {
fill: none;
pointer-events: all;
}
.hover-line {
stroke: #ccc;
stroke-width: 1px;
stroke-dasharray: 2;
}
#chartArea {
width:980px;
position:relative;
background-color: #fff;
}
.tick line{
opacity: 0.4;
}
.tooltip {
position: absolute;
padding: 5px 12px 10px;
background: #fff;
-webkit-box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.4);
-moz-box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.4);
box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.4);
border-radius: 5px;
color : #666;
display: none;
pointer-events: none;
opacity: 0.9;
}
.tt_date{
font-weight: bold;
font-size: 8px;
}
.tt_value{
font-size: 12px;
}
.sat{
fill:#1874CD;
}
.sun{
fill:#f2594b;
}
.bar.focus{
fill:#c1d5e6;
}
.focus circle {
fill: #fff;
stroke: #aaa;
stroke-width: 3px;
}
#changeBtn{
position:absolute;
font-size: 10px;
top:0;
right:10px;
margin:10px 40px;
padding:6px 8px;
background-color: #d0e0f7;
color:#1f1f1f;
border-radius: 5px;
display: inline-block;
cursor : pointer;
}
#changeBtn:hover{
opacity: 0.6;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment