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 = 600document.getElementById('box_plot').innerHTML = ''const width = d3.select(chart_id).node().getBoundingClientRect().widthconst boxWidth = 50const 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 = 600document.getElementById('box_plot').innerHTML = ''const width = d3.select(chart_id).node().getBoundingClientRect().widthconst boxWidth = 50const 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