用css3实现饼图

1,531 阅读3分钟

开发中常常有做统计图的需求,主流的前端插件都是基于 canvas 来做的,那么使用 css3 能不能实现呢?今天我来尝试一下使用 css3 做一个饼图效果。

渐变背景

css3 在背景图片中增加了渐变,使用锥形渐变(conic-gradient)我们可以很方便的就实现一个饼图。

pie2.png

图中的效果就是渐变背景实现的,通过圆角将元素显示成圆形,再通过锥形渐变,围绕着中心点给划分区域填充颜色就可以了。这个方法实现起来非常的简单,适合要求不高的场景。如果想要有点动态效果,比如说鼠标悬停在扇区内,扇区突出显示或者显示扇区对应的数据,可能就无法满足了。

conic-gradient()

元素形状剪裁

既然要给不同的扇区添加动态效果,必须得让每个扇都是一个元素,然后给元素添加事件或添加伪类样式。那么怎么样才可以让元素显示成扇形的样子呢?这个我们可以通过 clip-path 来实现,clip-path 使用裁剪方式创建元素的可显示区域,区域内的部分显示,区域外的隐藏。clip-path 支持四种图形裁剪:inset,circle,polygon,path,这里我们使用 path 来直接裁剪出扇形。通过圆角将元素变成圆形,然后再使用多边形(polygon)裁剪也可以得到扇形,但是会复杂非常多。关于 clip-path 的详细信息,请参考下面的文档。

clip-path

准备数据

下图是 tiobe 2022 编程语言排行,太长我只截取了一部分,为了简单不在图中的部分归到 other 中。

编程语言排行.png

最后得到的统计数据是这样的:

const data = [
   {ratio: 0.1358, title: 'Python 13.58%'},
   {ratio: 0.1244, title: 'C 12.44%'},
   {ratio: 0.1066, title: 'Java 10.66%'},
   {ratio: 0.0829, title: 'C++ 8.29%'},
   {ratio: 0.0568, title: 'C# 5.68%'},
   {ratio: 0.0474, title: 'Visual Basic 4.74%'},
   {ratio: 0.0209, title: 'JavaScript 2.09%'},
   {ratio: 0.4252, title: 'Other 42.52%'},
]

计算路径

接下来做一个函数来计算出样式,clip-path 中的 path 模式和 svg 中的 path 是一样的,我们主要用到4个命令:M 移动,L 绘制直线,A 绘制弧形,Z 关闭路径。

pie9.png

上面是裁剪的路径流程示意图,中心点是固定的,需要计算出圆弧上的起止的两个坐标点。

pie7.png

按图上的方法来计算坐标点(超出 90 度也适用),编写计算一个函数来计算对应百分比在圆弧上的点坐标。

function execPoint(cx, cy, r, ratio) {
        // 计算弧度,一个整圆弧度是 2π
        const rad = ratio* 2 * Math.PI
        return {
            x: cx + Math.sin(rad) * r,
            y: cy - Math.cos(rad) * r
        }
    }

继续编写计算扇区路径的函数:

 function buildSectorPaths(data, width) {
        // 偏转量
        let offset = 0
        // 圆心坐标
        const cx = width / 2
        const cy = width / 2
        // 半径
        const r = width / 2
        const result = []
        for (const datum of data) {
            let path = `M ${cx},${cy}`
            // 圆弧起点
            const start = execPoint(cx, cy, r, offset)
            path += ` L ${start.x},${start.y}`
            // 圆弧终点
            offset += datum.ratio
            const end = execPoint(cx, cy, r, offset)
            // 圆弧大于 π 画大圆,否则画小圆
            const angle = datum.ratio * 2 * Math.PI
            path += ` A ${r},${r} 0,${angle > Math.PI ? 1 : 0},1 ${end.x},${end.y}`
            path += ' Z'
            result.push(path)
        }
        return result
    }

完成饼图布局

基础样式:

.pie {
    width: 300px;
    height: 300px;
    position: relative;
}

.pie .sector {
    position: absolute;
    width: 100%;
    height: 100%;
}

完成生成饼图函数,并执行:

function buildPie(containerEl, data) {
        containerEl.classList.add('pie')
        const paths = buildSectorPaths(data, 300)
        for (let path of paths) {
            const sector = document.createElement('div')
            sector.classList.add('sector')
            sector.style.clipPath = `path('${path}')`
            // 给个随机背景色
            const r = Math.floor(Math.random() * 255);
            const g = Math.floor(Math.random() * 255);
            const b = Math.floor(Math.random() * 255);
            sector.style.backgroundColor = `rgb(${r},${g},${b})`
            containerEl.appendChild(sector)
        }
    }

const pie = document.getElementById('pie')
buildPie(pie, data)

最后我们得到一个饼图:

pie8.png

添加加交互效果

到这里就和之前使用背景图渐变实现的效果一样了,但是每一个扇区都是一个 dom 元素,这意味着我们可以给它加样式或加事件来实现动态效果。那么继续改造一下,让鼠标悬停在扇区的时候突出扇区,并显示对应的标题。在每 .sector 的后面加一个兄弟元素 .title,当 .sector 有鼠标悬停的时候 title 才在右侧显示出来。此外,再给 .sector 增加一个鼠标悬停放大的效果。

.pie .sector {
    position: absolute;
    width: 100%;
    height: 100%;
    transition: all .3s ease-in;
}

.pie .sector:hover {
    transform: scale(1.1);
}

.pie .title {
    display: none;
}

.pie .sector:hover + .title {
    position: absolute;
    top: 50%;
    left: 110%;
    width: 120px;
    transform: translateY(-50%);
    display: block;
}

代码稍微改一下,在扇区后面加一个标题元素:

function buildPie(containerEl, data) {
        containerEl.classList.add('pie')
        const paths = buildSectorPaths(data, 300)
        for (let i = 0; i < paths.length; i++) {
            const path = paths[i]
            const sector = document.createElement('div')
            sector.classList.add('sector')
            sector.style.clipPath = `path('${path}')`
            // 给个随机背景色
            const r = Math.floor(Math.random() * 255);
            const g = Math.floor(Math.random() * 255);
            const b = Math.floor(Math.random() * 255);
            sector.style.backgroundColor = `rgb(${r},${g},${b})`
            containerEl.appendChild(sector)
            // 新增加的标题
            const title = document.createElement('div')
            title.classList.add('title')
            title.innerText = data[i].title
            containerEl.appendChild(title)
        }
    }

最后再来看一下效果:

5lztf-zfkvi.gif

总结

使用 css3 实现饼图整体上比基于 canvas 简单一些,毕竟基于 canvas 的话除了绘制状态外还需要实现元素边缘判定和事件绑定机制,而这些 dom 本来就有的,有些交互不需要写 js 直接 css 就可以实现,你只管裁剪出需要的形状就好。但是,path 裁剪的兼容性不是很好,ie 就别想了,移动端很多浏览器也不支持,使用需谨慎。

最后附上完整代码,仅供参考。

<html lang="zh">
<head>
    <meta charSet="utf-8"/>
    <title>饼图demo</title>
    <style>
        .pie {
            width: 300px;
            height: 300px;
            position: relative;
        }

        .pie .sector {
            position: absolute;
            width: 100%;
            height: 100%;
            transition: all .3s ease-in;
        }

        .pie .sector:hover {
            transform: scale(1.1);
        }

        .pie .title {
            display: none;
        }

        .pie .sector:hover + .title {
            position: absolute;
            top: 50%;
            left: 110%;
            width: 120px;
            transform: translateY(-50%);
            display: block;
        }


    </style>
</head>
<body>
<div id="pie">
</div>
<script>
    const data = [
        {ratio: 0.1358, title: 'Python 13.58%'},
        {ratio: 0.1244, title: 'C 12.44%'},
        {ratio: 0.1066, title: 'Java 10.66%'},
        {ratio: 0.0829, title: 'C++ 8.29%'},
        {ratio: 0.0568, title: 'C# 5.68%'},
        {ratio: 0.0474, title: 'Visual Basic 4.74%'},
        {ratio: 0.0209, title: 'JavaScript 2.09%'},
        {ratio: 0.4252, title: 'Other 42.52%'},
    ]


    function execPoint(cx, cy, r, ratio) {
        // 计算弧度,一个整圆弧度是 2π
        const rad = ratio * 2 * Math.PI
        return {
            x: cx + Math.sin(rad) * r,
            y: cy - Math.cos(rad) * r
        }
    }

    /**
     * 构建裁剪路径
     * @param data 数据
     * @param width 元素宽度,宽高相同
     */
    function buildSectorPaths(data, width) {
        // 偏转量
        let offset = 0
        // 圆心坐标
        const cx = width / 2
        const cy = width / 2
        // 半径
        const r = width / 2
        const result = []
        for (const datum of data) {
            let path = `M ${cx},${cy}`
            // 圆弧起点
            const start = execPoint(cx, cy, r, offset)
            path += ` L ${start.x},${start.y}`
            // 圆弧终点
            offset += datum.ratio
            const end = execPoint(cx, cy, r, offset)
            // 圆弧大关圆画大圆,否则画小圆
            const angle = datum.ratio * 2 * Math.PI
            path += ` A ${r},${r} 0,${angle > Math.PI ? 1 : 0},1 ${end.x},${end.y}`
            path += ' Z'
            result.push(path)
        }
        return result
    }

    function buildPie(containerEl, data) {
        containerEl.classList.add('pie')
        const paths = buildSectorPaths(data, 300)
        for (let i = 0; i < paths.length; i++) {
            const path = paths[i]
            const sector = document.createElement('div')
            sector.classList.add('sector')
            sector.style.clipPath = `path('${path}')`
            // 给个随机背景色
            const r = Math.floor(Math.random() * 255);
            const g = Math.floor(Math.random() * 255);
            const b = Math.floor(Math.random() * 255);
            sector.style.backgroundColor = `rgb(${r},${g},${b})`
            containerEl.appendChild(sector)
            // 新增加的标题
            const title = document.createElement('div')
            title.classList.add('title')
            title.innerText = data[i].title
            containerEl.appendChild(title)
        }
    }

    const pie = document.getElementById('pie')
    buildPie(pie, data)
</script>
</body>
</html>