Box Plot
在画Box Plot之前,先来了解下Box Plot是什么?
箱线图(Box Plot)也称盒须图、盒式图或箱型图,是一种用于展示数据分布特征的统计图表。
它由以下几个部分组成:
- 箱子:表示数据的四分位数范围,箱子的上下边界分别为上四分位数(Q3)和下四分位数(Q1)。
- 中间的横线:表示中位数。
- 胡须:也称为触须,分别延伸到最小值和最大值。
箱线图的优点包括:
- 直观展示数据的分布情况,包括中心位置、离散程度等。
- 快速比较多个数据集的特征。
- 检测异常值。
它常用于:
- 展示一组数据的分布特征。
- 比较不同组数据的分布情况。
- 识别可能的异常值。
通过箱线图,可以快速了解数据的关键特征,帮助分析和解释数据。
效果图
代码实现
第一步,先来绘制画布。
const chart_id = '#box_plot'
const margin = ({top: 10, right: 10, bottom: 20, left: 30})
const height = 600
document.getElementById('box_plot').innerHTML = ''
const width = d3
.select(chart_id).node().getBoundingClientRect().width
const boxWidth = 50
const rd = dataset.slice().sort((a, b) => d3.ascending(a.name, b.name))
const chart = d3
.select(chart_id)
.attr('height', height)
第二步,生成 x 轴 和 y 轴比例尺
const yScale = d3.scaleLinear()
.domain(d3.extent(rd, d => d.value))
.range([height - margin.bottom, margin.top])
.nice()
const xScale = d3.scaleBand()
.domain(boxes().map(d => d.key))
.range([margin.left, width - margin.right])
.paddingInner(1)
.paddingOuter(.5)
第三步,生成 box
/* 生成 box 方法 */
const boxes = () => {
let arrMap = Array.from(d3.group(dataset, d => d.name), ([key, dat]) => ({key, dat}))
arrMap.map(o => {
const values = o.dat.map(d => d.value);
const min = d3.min(values);
const max = d3.max(values);
const q1 = d3.quantile(values, .25);
const q2 = d3.quantile(values, .5);
const q3 = d3.quantile(values, .75);
const iqr = q3 - q1;
const r0 = Math.max(min, q1 - iqr * 1.5);
const r1 = Math.min(max, q3 + iqr * 1.5);
o.quartiles = [q1, q2, q3];
o.range = [r0, r1];
o.outliers = values.filter(v => v < r0 || v > r1);
return o;
});
return (arrMap)
};
第四步,添加 box 组,设置其偏移量
const groups = chart.selectAll("g")
.data(boxes())
.join("g")
.attr("transform", d => `translate(${xScale(d.key)}, 0)`)
.attr('class', 'ind')
第五步,添加垂直方向上的线
groups
.selectAll("vertLine")
.data(d => [d.range])
.join("line")
.attr("class", "vertLine")
.attr("stroke", "#7e7e7e")
.attr('stroke-width', '1px')
.attr("x1", 0)
.attr("x2", 0)
.attr("y1", d => yScale(d[0]))
.attr("y2", d => yScale(d[1]))
第六步,添加水平方向上的线
水平方向上的三条线分别是 q1(第一四分位),median(中位数),q3(第三四分位),有的需求的第二条线不一定是中位数,也有可能是平均数(mean)。
groups
.selectAll('horizontalLine')
.data((d) => [d.range[0], d.quartiles[1], d.range[1]])
.join('line')
.attr('class', 'horizontalLine')
.attr('stroke', '#7e7e7e')
.attr('stroke-width', '1px')
.style('width', boxWidth)
.attr('x1', -boxWidth / 2)
.attr('x2', boxWidth / 2)
.attr('y1', (d) => yScale(d))
.attr('y2', (d) => yScale(d))
第七步,添加数据点
groups
.selectAll("points")
.data(d => d.dat)
.join("circle")
.attr("cx", () => 0 - 30 / 2 + Math.random() * 30)
.attr("cy", d => yScale(d.value))
.attr("r", 2)
.style("fill", "#1867c0")
.attr("fill-opacity", 1)
第八步,添加盒子
groups
.selectAll("box")
.data(d => [d])
.join("rect")
.attr("class", "box")
.attr("x", -boxWidth / 2)
.attr("y", d => yScale(d.quartiles[2]))
.attr("height", d => yScale(d.quartiles[0]) - yScale(d.quartiles[2]))
.attr("width", boxWidth)
.attr("stroke", "#545454")
.style("fill", "#1890ff")
.style("fill-opacity", 0.3)
第九步,添加 X 轴 和 Y 轴
/* Y 轴 */
chart.append("g")
.style("font", "12px")
.style('stroke-width', '1px')
.call(d3.axisLeft(yScale).tickSizeOuter(0))
.attr('transform', `translate(${margin.left},0)`)
.call(g => g.selectAll('.tick line').clone()
.attr('x2', width - margin.left - margin.right)
.attr('stroke-opacity', 0.2)
)
/* X 轴 */
chart.append('g')
.style('font', '12px')
.style('stroke-width', '1px')
.attr("transform", `translate(0,${height - margin.bottom})`)
.call(d3.axisBottom(xScale))
整体代码
<template>
<div style="width: 100%">
<svg id="box_plot" style="width: 100%"/>
</div>
</template>
<script setup>
import {onMounted} from "vue";
import * as d3 from "d3";
import dataset from "@/mock/dataset_boxplot"
onMounted(() => {
drawBoxPlot()
})
function drawBoxPlot() {
const chart_id = '#box_plot'
const margin = ({top: 10, right: 10, bottom: 20, left: 30})
const height = 600
document.getElementById('box_plot').innerHTML = ''
const width = d3
.select(chart_id).node().getBoundingClientRect().width
const boxWidth = 50
const rd = dataset.slice().sort((a, b) => d3.ascending(a.name, b.name))
const yScale = d3.scaleLinear()
.domain(d3.extent(rd, d => d.value))
.range([height - margin.bottom, margin.top])
.nice()
const xScale = d3.scaleBand()
.domain(boxes().map(d => d.key))
.range([margin.left, width - margin.right])
.paddingInner(1)
.paddingOuter(.5)
const chart = d3
.select(chart_id)
.attr('height', height)
const groups = chart.selectAll("g")
.data(boxes())
.join("g")
.attr("transform", d => `translate(${xScale(d.key)}, 0)`)
.attr('class', 'ind')
groups
.selectAll("vertLine")
.data(d => [d.range])
.join("line")
.attr("class", "vertLine")
.attr("stroke", "#7e7e7e")
.attr('stroke-width', '1px')
.attr("x1", 0)
.attr("x2", 0)
.attr("y1", d => yScale(d[0]))
.attr("y2", d => yScale(d[1]))
groups
.selectAll('horizontalLine')
.data((d) => [d.range[0], d.quartiles[1], d.range[1]])
.join('line')
.attr('class', 'horizontalLine')
.attr('stroke', '#7e7e7e')
.attr('stroke-width', '1px')
.style('width', boxWidth)
.attr('x1', -boxWidth / 2)
.attr('x2', boxWidth / 2)
.attr('y1', (d) => yScale(d))
.attr('y2', (d) => yScale(d))
groups
.selectAll("points")
.data(d => d.dat)
.join("circle")
.attr("cx", () => 0 - 30 / 2 + Math.random() * 30)
.attr("cy", d => yScale(d.value))
.attr("r", 2)
.style("fill", "#1867c0")
.attr("fill-opacity", 1)
// 添加盒子
groups
.selectAll("box")
.data(d => [d])
.join("rect")
.attr("class", "box")
.attr("x", -boxWidth / 2)
.attr("y", d => yScale(d.quartiles[2]))
.attr("height", d => yScale(d.quartiles[0]) - yScale(d.quartiles[2]))
.attr("width", boxWidth)
.attr("stroke", "#545454")
.style("fill", "#1890ff")
.style("fill-opacity", 0.3)
/* Y 轴 */
chart.append("g")
.style("font", "12px")
.style('stroke-width', '1px')
.call(d3.axisLeft(yScale).tickSizeOuter(0))
.attr('transform', `translate(${margin.left},0)`)
.call(g => g.selectAll('.tick line').clone()
.attr('x2', width - margin.left - margin.right)
.attr('stroke-opacity', 0.2)
)
/* X 轴 */
chart.append('g')
.style('font', '12px')
.style('stroke-width', '1px')
.attr("transform", `translate(0,${height - margin.bottom})`)
.call(d3.axisBottom(xScale))
const tooltip = d3.select(chart_id).append('div')
/* 设置鼠标进入显示提交框 */
chart.selectAll('.ind').on("mousemove", function (event) {
tooltip
.attr('class', 'tooltip')
.style('opacity', 1)
.style('transform', `translate(${event.clientX - 50}px,${event.clientY - 50}px)`)
.text('test: tooltip')
})
groups.on("mouseleave", function () {
tooltip
.style('opacity', 0)
})
return chart.node()
}
/* 生成 box 方法 */
const boxes = () => {
let arrMap = Array.from(d3.group(dataset, d => d.name), ([key, dat]) => ({key, dat}))
arrMap.map(o => {
const values = o.dat.map(d => d.value);
const min = d3.min(values);
const max = d3.max(values);
const q1 = d3.quantile(values, .25);
const q2 = d3.quantile(values, .5);
const q3 = d3.quantile(values, .75);
const iqr = q3 - q1;
const r0 = Math.max(min, q1 - iqr * 1.5);
const r1 = Math.min(max, q3 + iqr * 1.5);
o.quartiles = [q1, q2, q3];
o.range = [r0, r1];
o.outliers = values.filter(v => v < r0 || v > r1);
return o;
});
return (arrMap)
};
</script>
源码地址
源码和 mock 数据都在git仓库上,想要的小伙伴可以自己去git上拉一下。
gitee:https://gitee.com/li-jiayin167/data-visualization.git
github:https://github.com/Jane167/Data-visualization.git
如果觉得不错的话,点个 star