Day29-D3 进阶图表:合并图表(长条+折线)

本篇大纲:合并图表绘制关键、图表画面与互动效果、本次使用资料、图表绘制

今天的一天一图表,我们要来看怎麽把两张图表合并啦!一般来说,我们最常看到的合并图表就是 长条图+折线图 这类图表,这种合并图表多了右边的Y轴,能表达的资讯稍微多了一些
https://ithelp.ithome.com.tw/upload/images/20211011/201349300tT7jLlaaa.jpg

合并图表绘制关键

虽然合并图表看似很简单,只是长条图跟折线图画在一起,但由於D3在绘制长条图跟折线图时使用的比例尺不同,因此也会稍微有点难度。我们有两种做法

  • 建立两个X轴比例尺,分别是用 scaleBand 跟 scaleLinear来绘制长条图跟折线图
  • 使用 scaleLinear 比例尺来建立长条图,但长条图的宽度就要自行设定,最左跟最右边的间隔也需另行设定

我自己是建议直接设定两种比例尺,这样直接用 scaleBand 旗下的 bandwidth 就能设定长条图的宽度,会方便很多

图表画面与互动效果

这次要做的图表包含以下功能

  • 切换年均、月均资料
  • 折线图缺少资料的部分,替换成虚线折线
  • hover 至折线图的圆点时,显示折线图资讯的 tooltip

https://i.imgur.com/iiQl9Ib.gif

本次使用资料

由於这次要把部分折线图设计成虚线线段代表缺少这部分的资料,比较难找到符合这种条件的开放资料,因此我们这次就先用自己定义的资料。资料结构长这样

{
    "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 Page 图表与 Github 程序码

最後附上本章的程序码:想看完整程序码的请上 Github,想直接操作图表的则去 Github Page 吧!请自行取用~


<<:  Day26 React-实作todoList(一)前置

>>:  开始建立专案

方丈的安全观 Day1

老衲是志在参加不在成铁人 肝的硬度决定能力的高度,油的厚度决定了嘴炮的层度! Day1来了!! 台湾...

IT 铁人赛 k8s 入门30天 -- day30 Share Process Namespace between Containers in a Pod

参考文件 https://kubernetes.io/docs/tasks/configure-po...

.NET Core第10天_搭配EF Core串联资料库Db First_使用EntityFramework执行检视的MVC控制器

首先新建好专案後 一样记得要先弄好MVC服务注入跟MVC预设路由形式设定 (可参考.NET Core...

Day10:时程安排

规划时间 开发时间 测试及验收 ...

Day11 永丰金API 回顾

透过前几天的内容,我们已经大概了解永丰金流api的流程, 以及对应的api所需参数和回传内容,并且提...