本篇大纲:合并图表绘制关键、图表画面与互动效果、本次使用资料、图表绘制
今天的一天一图表,我们要来看怎麽把两张图表合并
啦!一般来说,我们最常看到的合并图表就是 长条图+折线图
这类图表,这种合并图表多了右边的Y轴,能表达的资讯稍微多了一些
虽然合并图表看似很简单,只是长条图跟折线图画在一起,但由於D3在绘制长条图跟折线图时使用的比例尺不同
,因此也会稍微有点难度。我们有两种做法
我自己是建议直接设定两种比例尺,这样直接用 scaleBand 旗下的 bandwidth 就能设定长条图的宽度,会方便很多
这次要做的图表包含以下功能
由於这次要把部分折线图设计成虚线线段代表缺少这部分的资料,比较难找到符合这种条件的开放资料,因此我们这次就先用自己定义的资料。资料结构长这样
{
"code": 200,
"msg": "success",
"list": [{
"date": "110-5",
"domesticBagPrice": 0.0000,
"dealCount": 0,
"foreignBagPrice": 70.4700
},
{
"date": "110-4",
"domesticBagPrice": 0.0000,
"dealCount": 0,
"foreignBagPrice": 87.0078
},
{
"date": "110-3",
"domesticBagPrice": 0.0000,
"dealCount": 0,
"foreignBagPrice": 0
},
{
"date": "110-2",
"domesticBagPrice": 60.3050,
"dealCount": 2,
"foreignBagPrice": 105.2008
},
{
"date": "110-1",
"domesticBagPrice": 0,
"dealCount": 2,
"foreignBagPrice": 103.5544
},
{
"date": "109-12",
"domesticBagPrice": 58.7133,
"dealCount": 3,
"foreignBagPrice": 100.3663
},
{
"date": "109-11",
"domesticBagPrice": 60.6288,
"dealCount": 8,
"foreignBagPrice": 85.5546
},
{
"date": "109-10",
"domesticBagPrice": 56.8400,
"dealCount": 4,
"foreignBagPrice": 94.7130
},
{
"date": "109-9",
"domesticBagPrice": 54.7029,
"dealCount": 7,
"foreignBagPrice": 0
},
{
"date": "109-8",
"domesticBagPrice": 65.8500,
"dealCount": 2,
"foreignBagPrice": 92.0291
},
{
"date": "109-7",
"domesticBagPrice": 54.4367,
"dealCount": 3,
"foreignBagPrice": 80.3800
},
{
"date": "109-6",
"domesticBagPrice": 54.1520,
"dealCount": 5,
"foreignBagPrice": 81.9309
}
]
}
那我们就直接来绘制图表啦!这次的图表算是这一整篇D3铁人赛系列的集大成图表,如果再拆成片段的讲解反而较难看懂,所以就直接上完整的程序码吧~重要的细节我会写在注解内
// css
html {
font-size: 13px;
}
#wrapper {
position: relative;
width: 100%;
height: 100%;
min-width: 300px;
max-width: 980px;
min-height: 300px;
}
.chartContainer {
margin: auto;
width: 80%;
min-width: 300px;
height: 500px;
}
.chartTitle {
font-size: 1.5rem;
font-weight: bold;
color: #333333;
}
.infoWrap {
display: flex;
justify-content: space-between;
}
.bagsInfo {
align-self: center;
font-size: 13px;
line-height: 1.6;
color: #333333;
}
.switchBtnWrap {
display: flex;
margin-bottom: 10px;
align-items: center;
}
.switchBtn {
box-sizing: border-box;
font-size: 1.2rem;
color: #666666;
cursor: pointer;
padding-top: 4px;
padding-bottom: 4px;
}
.switchBtn:hover,
.active {
color: #d02b41;
font-weight: bold;
border-bottom: 4px solid #ffcc33;
}
.greyLine {
margin-right: 10px;
margin-left: 10px;
color: #cccccc;
}
svg {
width: 100%;
}
svg text {
font-size: 1rem;
color: #777777;
}
.graph-info text {
font-size: 0.8rem;
}
.y1Axis .tick line {
stroke: #e8e8e8;
}
/* 监听方块透明 */
.listening-rect {
fill: transparent;
}
// html
<div id="wrapper">
<div class="chartContainer"></div>
</div>
// js
// 加图表标题资讯
const chartInfo = `<div class="chartTitle"包包购买趋势</div>
<div class="infoWrap">
<div class="switchBtnWrap">
<div class="switchBtn yearEverage active">看年均</div>
<div class="greyLine">|</div>
<div class="switchBtn monthEverage ">看月均</div>
</div>
<div class="bagsInfo">精品包价格与购买趋势</div>
</div>`;
const chartContainer = document.querySelector(".chartContainer");
chartContainer.innerHTML = chartInfo;
// 绘制D3图表,Select 选定元素
const container = d3.select(".chartContainer");
// 定义资料
let data = [],
data2 = [],
dataURL = "./data/data.json";
// 切换年均月均Data
const yearEverage = document.querySelector(".yearEverage");
const monthEverage = document.querySelector(".monthEverage");
yearEverage.addEventListener("click", function (e) {
monthEverage.classList.remove("active");
this.classList.add("active");
dataURL = "./data/data.json";
getData();
});
monthEverage.addEventListener("click", function (e) {
yearEverage.classList.remove("active");
this.classList.add("active");
dataURL = "./data/data2.json";
getData();
});
// 非同步取资料
async function getData() {
dataGet = await d3.json(dataURL);
data = dataGet.list.reverse();
renderChart();
}
// 画图表
function renderChart() {
// 调整画面大小
let svgwidth = parseInt(d3.select(".chartContainer").style("width")),
svgheight = svgwidth * 0.6,
marginLeft = (svgwidth / 100) * 6, //25
marginBottom = (svgwidth / 100) * 6, //30
chartWidth = svgwidth - marginLeft * 2,
chartHeight = svgheight - marginBottom;
// 限定margin
marginBottom = marginBottom < 28 ? 28 : marginBottom;
marginLeft = marginLeft < 28 ? 28 : marginLeft;
// 限定宽高不可以小於200
chartWidth = svgwidth < 200 ? 200 : chartWidth;
chartHeight = svgheight < 300 ? 300 : chartHeight;
// 先删除原本的svg.charts,再重新渲染计算过宽度的svg
d3.select(".charts").remove();
// 绘制画布
const svg = container
.append("svg")
.attr("width", chartWidth)
.attr("height", chartHeight)
.attr("class", "charts");
// X轴是年份(Date)、Y1是单价(domesticBagPrice)
//Y2是卖出数量(dealCount),Y3是国外包包价格(foreignBagPrice)
const xData = data.map((i) => parseInt(i.date.slice(4))),
y1Data = data.map((i) => i.domesticBagPrice),
y2Data = data.map((i) => i.dealCount),
y3Data = data.map((i) => i.foreignBagPrice);
//数值取出後,转换成X轴的scale
let xScale = d3
.scaleBand()
.domain(xData)
.range([marginLeft, chartWidth + marginLeft])
.padding(0.6);
// 设定X轴线与tick
let xAxis = d3
.axisBottom(xScale)
.tickSizeOuter(0)
.tickSizeInner(0)
.tickPadding(10)
.tickFormat(function (d) {
return d;
});
// 手机板时减少X轴的tick
let xMobileTicks = [];
xData.forEach((item, index) => {
if (index % 2 == 0) {
xMobileTicks.push(item);
}
});
let xAxisWeb;
// 手机板跟电脑版的tick数量改动
if (svgwidth < 400) {
xAxisWeb = xAxis
.tickFormat(function (d) {
return `${d}月`;
})
.tickValues(xMobileTicks);
} else {
xAxisWeb = xAxis.tickFormat(function (d) {
return `${d}月`;
});
}
// 呼叫绘制X轴线
const xAxisLine = svg
.append("g")
.attr("class", "xAxis")
//让X轴到下方
.style("transform", `translate(0px,${chartHeight - marginBottom}px)`)
.call(xAxis);
// 改变X轴颜色与粗细
xAxisLine
.select(".domain")
.attr("stroke", "#e8e8e8")
.attr("stroke-width", "1")
.attr("opacity", "1");
// 调整X轴标签位置
xAxisLine
.selectAll("text")
.attr("y", 15)
.attr("x", 0)
.style("color", "#777777");
// Y1轴(单价)
// 平均分配Y1的轴数 (六个轴)
// 抓Y1,Y3的最大值
let maxYData = y1Data.concat(y3Data);
let maxY1Data = parseInt(Math.max.apply(null, maxYData)) + 10;
let minY1Data = Math.min.apply(null, maxYData);
let y1Summary = maxY1Data + minY1Data,
y1EventNumber = y1Summary / 5,
Y1EventArray = [];
let arrayBase = 0;
for (let i = 1; i < 6; i++) {
arrayBase = arrayBase + y1EventNumber;
Y1EventArray.push(minY1Data);
Y1EventArray.push(parseInt(arrayBase));
}
// 把平均後的数字吐回给Y domain
let y1Scale = d3
.scalePoint()
.domain(Y1EventArray)
.range([chartHeight, marginBottom * 2]);
// Y1轴线刻度
let y1Axis = d3
.axisLeft(y1Scale)
.tickSizeOuter(0)
.tickSizeInner(-chartWidth) //轴线向内(右)延伸,比照绘图区高度
.tickPadding(8)
.ticks(6);
const y1AxisLine = svg
.append("g")
.attr("class", "y1Axis")
.style("transform", `translate(${marginLeft}px, -${marginBottom}px)`)
.call(y1Axis);
// 加上Y1标签
const y1AxisLabel = y1AxisLine
.append("text")
.attr("class", "y1axis-label")
.text("单价/万元")
.attr("x", marginLeft)
.attr("y", marginBottom * 1.5)
.style("fill", "#333333")
.style("font-size", "1rem");
// 改变Y1轴成透明
y1AxisLine.select(".domain").attr("opacity", "0");
// Y2轴
let y2Scale = d3
.scaleLinear()
.domain([0, 10])
.range([chartHeight, marginBottom * 2]);
let y2Axis = d3
.axisRight(y2Scale)
.tickSizeInner(0)
.tickSizeOuter(0)
.ticks(5)
.tickPadding(8);
const y2AxisLine = svg
.append("g")
.attr("class", "y2Axis")
.style(
"transform",
`translate(${svgwidth - marginLeft}px, -${marginBottom}px)`
)
.call(y2Axis);
// 加上Y2标签
const y2AxisLabel = y2AxisLine
.append("text")
.attr("class", "y2axis-label")
.text("购买数量")
.attr("x", -(marginLeft * 0.7))
.attr("y", marginBottom * 1.5)
.style("fill", "#333333")
.style("font-size", "1rem");
// 改变Y2轴成透明
y2AxisLine.select(".domain").attr("opacity", "0");
// 画折线图跟长条图
// 这边先设定方法,让折线图跟长条图的XY轴能产出正确的座标
const xAccessor = (d) => parseInt(d.date.slice(4));
const y1Accessor = (d) => d.domesticBagPrice;
const y2Accessor = (d) => d.dealCount;
const y3Accessor = (d) => d.foreignBagPrice;
// 先画长条图,折线图才不会被盖住
const barChart = svg
.append("g")
.selectAll("rect")
.data(data)
.join("rect")
.attr("x", (d, i) => {
return xScale(xAccessor(d));
})
.attr("y", (d, i) => {
return y2Scale(y2Accessor(d)) - marginBottom;
})
.attr("width", xScale.bandwidth())
.attr("height", (d) => y2Scale(0) - y2Scale(y2Accessor(d)))
.attr("fill", "#e0e0e0");
// 画折线图
// 处理Y1,Y3折线图ScaleLinear
let y1LineScale = d3
.scaleLinear()
.domain([d3.min(Y1EventArray), d3.max(Y1EventArray)])
.range([chartHeight, marginBottom * 2]);
// 绘制Y轴第一条折线图
const lineChart = d3
.line()
.x((d) => xScale(xAccessor(d)) + marginLeft / 4)
.y((d) => {
return y1LineScale(y1Accessor(d)) - marginBottom;
})
.defined((d) => d.domesticBagPrice > 0)
.curve(d3.curveLinear); //决定曲线线条
// 有资料的线
const line = svg
.append("g")
.append("path")
.attr("d", lineChart(data))
.attr("fill", "none")
.attr("stroke", "#FFCC33")
.attr("stroke-width", 2);
let filteredData = data.filter(lineChart.defined());
// 覆盖的dashed
svg
.append("g")
.append("path")
.attr("d", lineChart(filteredData))
.attr("fill", "none")
.attr("stroke", "#FFCC33")
.attr("stroke-width", 2)
.style("stroke-dasharray", "4,4");
// 折线图圆点点
svg
.selectAll(".dot")
.data(filteredData)
.enter()
.append("g")
.classed("dot1", true)
.append("circle")
.attr("cx", (d) => xScale(xAccessor(d)) + marginLeft / 4)
.attr("cy", (d) => y1LineScale(y1Accessor(d)) - marginBottom)
.attr("r", 5)
.attr("fill", "#FFCC33")
.attr("stroke", "#FFCC33");
// 绘制Y轴第二条折线图 foreignBagPrice
const lineChart2 = d3
.line()
.x((d) => xScale(xAccessor(d)) + marginLeft / 4)
.y((d) => {
// console.log(y1LineScale(y1Accessor(d)))
return y1LineScale(y3Accessor(d)) - marginBottom;
})
.defined((d) => d.foreignBagPrice > 0)
.curve(d3.curveLinear); //决定曲线线条
// 第二条折线图
const line2 = svg
.append("g")
.append("path")
.attr("d", lineChart2(data))
.attr("fill", "none")
.attr("stroke", "#78c9b7")
.attr("stroke-width", 2);
let filteredData2 = data.filter(lineChart2.defined());
// 第二条覆盖的dashed
svg
.append("g")
.append("path")
.attr("d", lineChart2(filteredData2))
.attr("fill", "none")
.attr("stroke", "#78c9b7")
.attr("stroke-width", 2)
.style("stroke-dasharray", "4,4");
// 第二条折线图圆点
const dots2 = svg
.selectAll(".dot2")
.data(filteredData2)
.enter()
.append("g")
.classed("dot2", true)
.append("circle")
.attr("cx", (d) => xScale(xAccessor(d)) + marginLeft / 4)
.attr("cy", (d) => y1LineScale(y3Accessor(d)) - marginBottom)
.attr("r", 5)
.attr("fill", "#78c9b7")
.attr("stroke", "#78c9b7");
// 滑鼠事件
let tooltip = d3
.select(".chartContainer")
.append("div")
.text("text!!")
.style("position", "absolute")
.style("opacity", 0)
.attr("class", "tooltip")
.style("background-color", "white")
.style("border", "solid")
.style("border-width", "1px")
.style("border-color", "grey")
.style("padding", "5px");
dots2
.on("mouseover", function () {
const mousePosition = d3.pointer(event, this);
const hoveredDate = y1LineScale.invert(mousePosition[1]);
// 圆点放大变色
d3.select(this)
.attr("cursor", "pointer")
.attr("r", 7)
.style("fill", "white")
.style("stroke-width", 5);
// tooltip
tooltip
.html(
`单价<br><span style="color:#d02b41">${parseInt(
hoveredDate
)}</span> 万元`
)
.style("opacity", 1)
.style('position', 'absolute')
.style("left", `${mousePosition[0]}px`)
.style("top", `${mousePosition[1]}px`);
})
.on("mouseleave", function () {
d3.select(this)
.attr("r", 5)
.style("fill", "#78c9b7")
.style("stroke-width", 1);
tooltip.style("opacity", 0);
});
// 最後再渲染图表标签
renderChartLabel();
}
// 画资料标签
function renderChartLabel() {
// 删掉原本元素,切换时需重新渲染
d3.select(".graph-info").remove();
// 定义 margin
let svgwidth = parseInt(d3.select(".chartContainer").style("width")),
marginX = svgwidth / 10 + 20,
marginY = 15;
// 资料标签
const graphInfo = d3
.select(".chartContainer")
.append("svg")
.attr("class", "graph-info")
.attr("height", "60px")
.style("padding-top", "10px");
// 黄色折线-国内包包
graphInfo
.append("line")
.style("stroke", "#FFCC33")
.style("stroke-width", 3)
.attr("x1", 70)
.attr("y1", marginY)
.attr("x2", 100)
.attr("y2", marginY);
graphInfo
.append("circle")
.attr("cx", 85)
.attr("cy", marginY)
.attr("r", 7)
.style("stroke", "#FFCC33")
.style("fill", "#FFCC33");
graphInfo
.append("text")
.attr("x", 105)
.attr("y", 20)
.text("国产包")
.style("font-size", "1rem")
.style("fill", "#777777");
// 蓝色折线-国外包包
graphInfo
.append("line")
.style("stroke", "#78c9b7")
.style("stroke-width", 3)
.attr("x1", 170)
.attr("y1", marginY)
.attr("x2", 200)
.attr("y2", 15);
graphInfo
.append("circle")
.attr("cx", 185)
.attr("cy", marginY)
.attr("r", 7)
.style("stroke", "#78c9b7")
.style("fill", "#78c9b7");
graphInfo
.append("text")
.attr("x", 205)
.attr("y", 20)
.text("进口包")
.style("font-size", "1rem")
.style("fill", "#777777");
// 直条图
graphInfo
.append("rect")
.attr("x", 300)
.attr("y", 5)
.attr("width", 30)
.attr("height", 20)
.attr("fill", "#e0e0e0");
graphInfo
.append("text")
.attr("x", 335)
.attr("y", 20)
.text("购买数量")
.style("font-size", "1rem")
.style("fill", "#777777");
}
getData();
// RWD
d3.select(window).on("resize", renderChart);
这样就完成啦!想不到写着写着也到第29天了,明天就是最後一天要完赛了,感谢老天爷!
最後附上本章的程序码:想看完整程序码的请上 Github,想直接操作图表的则去 Github Page 吧!请自行取用~
<<: Day26 React-实作todoList(一)前置
老衲是志在参加不在成铁人 肝的硬度决定能力的高度,油的厚度决定了嘴炮的层度! Day1来了!! 台湾...
参考文件 https://kubernetes.io/docs/tasks/configure-po...
首先新建好专案後 一样记得要先弄好MVC服务注入跟MVC预设路由形式设定 (可参考.NET Core...
规划时间 开发时间 测试及验收 ...
透过前几天的内容,我们已经大概了解永丰金流api的流程, 以及对应的api所需参数和回传内容,并且提...