前言
最近公司项目都偏向于数字化大屏展示🥱,而这次发给我的项目原型中出现了一个金字塔图🤔️, 好巧不巧,由于我们的图表都是使用
Echarts
,而
Echarts
中又不支持金字塔图,作为一个喜欢造轮子的前端开发,虽然自身技术不咋滴,但喜欢攻克难题的精神还是有的😁, 不断地内卷,才是我们这些普通前端开发的核心竞争力😂,所以就有了仿Echarts实现金字塔图的想法。
不多说先上效果
项目地址:
(https://github.com/SHDjason/Pyramid.git)
正文
项目实现可传入配置有:主体图位置(distance)、主体图偏移度(offset)、数据排序(sort)、图颜色(color)、数据文本回调(fontFormatter)、tooltip配置(tooltip)、数据展示样式配置(infoStyle)等
初始化canvas基本信息 并实现大小自适应
<template>
<div id="canvas-warpper">
<div id="canvas-tooltip"></div>
</div>
</template>
先创建 canvas画布
// 创建canvas元素
this.canvas = document.createElement('canvas')
// 把canvas元素节点添加在el元素下
el.appendChild(this.canvas)
this.canvasWidth = el.offsetWidth
this.canvasHeight = el.offsetHeight
// 将canvas元素设置与父元素同宽
this.canvas.setAttribute('width', this.canvasWidth)
// 将canvas元素设置与父元素同高
this.canvas.setAttribute('height', this.canvasHeight)
获取画布中心点 方便后面做自适应和定点
this.canvasCenter = [
Math.round((this.canvasWidth - this.integration.distance[0] * 2) / 2) + this.integration.distance[0],
Math.round((this.canvasHeight - this.integration.distance[1] * 2) / 2) + this.integration.distance[1]
]
监听传来的数据 并计算数据占比
刚好在这编写 数据排序(sort)的传入配置
watch: {
data: {
immediate: true,
deep: true,
handler(newValue) {
// 数据总量
let totalData = 0
newValue.forEach(element => {
totalData = totalData + Number(element.value)
})
this.dataInfo = newValue.map(item => {
const accounted = (item.value / totalData) * 100
return { ...item, accounted, title: this.integration.title }
})
if (this.integration.sort === 'max') {
this.dataInfo.sort((a, b) => {
return a.value - b.value
})
} else if (this.integration.sort === 'min') {
this.dataInfo.sort((a, b) => {
return b.value - a.value
})
}
}
}
},
下面可以确定金字塔4个基本点的位置了
这几个基本点的位置决定在后面金字塔展示的形状 可以根据自己的审美进行微调
if (this.canvas.getContext) {
this.ctx = this.canvas.getContext('2d')
// 金字塔基本点位置
this.point.top = [this.canvasCenter[0] - this.canvasWidth / 13, this.integration.distance[1]]
this.point.left = [
this.integration.distance[0] * 1.5,
this.canvasHeight - this.integration.distance[1] - this.canvasHeight / 5
]
this.point.right = [
this.canvasWidth - this.integration.distance[0] * 1.9,
this.canvasHeight - this.integration.distance[1] - this.canvasHeight / 5
]
this.point.bottom = [
this.canvasCenter[0] - this.canvasWidth / 13,
this.canvasHeight - this.integration.distance[1]
]
this.point.shadow = [
this.integration.distance[0] - this.canvasCenter[0] / 5,
this.canvasHeight / 1.2 - this.integration.distance[1]
]
for (const key in this.point) {
this.point[key][0] = this.point[key][0] + this.integration.offset[0]
this.point[key][1] = this.point[key][1] + this.integration.offset[1]
}
} else {
throw 'canvas下未找到 getContext方法'
}
- 完整代码
let el = document.getElementById('canvas-warpper')
// 创建canvas元素
this.canvas = document.createElement('canvas')
// 把canvas元素节点添加在el元素下
el.appendChild(this.canvas)
this.canvasWidth = el.offsetWidth
this.canvasHeight = el.offsetHeight
// 将canvas元素设置与父元素同宽
this.canvas.setAttribute('width', this.canvasWidth)
// 将canvas元素设置与父元素同高
this.canvas.setAttribute('height', this.canvasHeight)
this.canvasCenter = [
Math.round((this.canvasWidth - this.integration.distance[0] * 2) / 2) + this.integration.distance[0],
Math.round((this.canvasHeight - this.integration.distance[1] * 2) / 2) + this.integration.distance[1]
]
if (this.canvas.getContext) {
this.ctx = this.canvas.getContext('2d')
// 金字塔基本点位置
this.point.top = [this.canvasCenter[0] - this.canvasWidth / 13, this.integration.distance[1]]
this.point.left = [
this.integration.distance[0] * 1.5,
this.canvasHeight - this.integration.distance[1] - this.canvasHeight / 5
]
this.point.right = [
this.canvasWidth - this.integration.distance[0] * 1.9,
this.canvasHeight - this.integration.distance[1] - this.canvasHeight / 5
]
this.point.bottom = [
this.canvasCenter[0] - this.canvasWidth / 13,
this.canvasHeight - this.integration.distance[1]
]
this.point.shadow = [
this.integration.distance[0] - this.canvasCenter[0] / 5,
this.canvasHeight / 1.2 - this.integration.distance[1]
]
for (const key in this.point) {
this.point[key][0] = this.point[key][0] + this.integration.offset[0]
this.point[key][1] = this.point[key][1] + this.integration.offset[1]
}
} else {
throw 'canvas下未找到 getContext方法'
}
this.topAngle.LTB = this.angle(this.point.top, this.point.left, this.point.bottom)
this.topAngle.RTB = this.angle(this.point.top, this.point.right, this.point.bottom)
// 计算各数据点位置
this.calculationPointPosition(this.dataInfo)
},
计算金字塔每条边的角度
为了后面给每个数据定点 但是 唉~ 奈何数学太差 所以我就想到了一个方法 :
每条数据的定点范围肯定都是在 四个基本点的连线上。那我把每个基本点连线的角度求出来 ,到时候 在进行角度翻转到垂直后 再求每个条数据所占当前基本点连线的占比不就行了?
/**
* @description: 求3点之间角度
* @return {*} 点 a 的角度
* @author: 舒冬冬
*/
angle(a, b, c) {
const A = { X: a[0], Y: a[1] }
const B = { X: b[0], Y: b[1] }
const C = { X: c[0], Y: c[1] }
const AB = Math.sqrt(Math.pow(A.X - B.X, 2) + Math.pow(A.Y - B.Y, 2))
const AC = Math.sqrt(Math.pow(A.X - C.X, 2) + Math.pow(A.Y - C.Y, 2))
const BC = Math.sqrt(Math.pow(B.X - C.X, 2) + Math.pow(B.Y - C.Y, 2))
const cosA = (Math.pow(AB, 2) + Math.pow(AC, 2) - Math.pow(BC, 2)) / (2 * AB * AC)
const angleA = Math.round((Math.acos(cosA) * 180) / Math.PI)
return angleA
}
计算各个数据点的位置
- 接下来就是确定每条数据的 绘画范围了
我们先把金字塔左边和有右边旋转垂直后的点的位置确定下来
/**
* @description: 根据A点旋转指定角度后B点的坐标位置
* @param {*} ptSrc 圆上某点(初始点);
* @param {*} ptRotationCenter 圆心点
* @param {*} angle 旋转角度° -- [angle * M_PI / 180]:将角度换算为弧度
* 【注意】angle 逆时针为正,顺时针为负
* @return {*}
* @author: 舒冬冬
*/
rotatePoint(ptSrc, ptRotationCenter, angle) {
const a = ptRotationCenter[0]
const b = ptRotationCenter[1]
const x0 = ptSrc[0]
const y0 = ptSrc[1]
const rx = a + (x0 - a) * Math.cos((angle * Math.PI) / 180) - (y0 - b) * Math.sin((angle * Math.PI) / 180)
const ry = b + (x0 - a) * Math.sin((angle * Math.PI) / 180) + (y0 - b) * Math.cos((angle * Math.PI) / 180)
const point = [rx, ry]
return point
},
const LP = this.rotatePoint(this.point.left, this.point.top, this.topAngle.LTB * -1)
const RP = this.rotatePoint(this.point.right, this.point.top, this.topAngle.RTB)
LP 为 TL 的边 逆时针旋转 LTB 角度后的 点的位置
RP 为 TR 的边 顺时针旋转 RTB 角度后的 点的位置
-
这样就可以确定 每个数据点在 三条边上的各自所占长度了 完整代码
每个点的长度计算思路, 以在TL边上点为例:
拿到 LP (逆时针旋转 LTB角度后的位置)长度,根据数据所占总数据占比 求出该条数据的长度 再把角度转回去还原该边 就能拿到该条数据再 TL 边的上的位置信息。
const vertical = [ this.point.top[0], (LP[1] - this.point.top[1]) * (item.accounted / 100) + this.point.top[1] ]
/**
* @description: 计算数据的点位置
* @param {*} val 点占比
* @return {*}
* @author: 舒冬冬
*/
calculationPointPosition(val) {
const LP = this.rotatePoint(this.point.left, this.point.top, this.topAngle.LTB * -1)
const RP = this.rotatePoint(this.point.right, this.point.top, this.topAngle.RTB)
let temporary = {
left: [
[0, 0],
[0, 0],
[0, 0]
],
right: [
[0, 0],
[0, 0],
[0, 0]
],
middle: [
[0, 0],
[0, 0],
[0, 0]
]
}
const dataInfo = val.map((item, index) => {
if (index === 0) {
for (const key in temporary) {
if (key === 'left') {
// 垂直后点的位置
// 垂直后点点距离
const vertical = [
this.point.top[0],
(LP[1] - this.point.top[1]) * (item.accounted / 100) + this.point.top[1]
]
// 还原后点的位置
temporary.left = [this.point.top, this.rotatePoint(vertical, this.point.top, this.topAngle.LTB), vertical]
} else if (key === 'right') {
// 垂直后点点距离
const vertical = [
this.point.top[0],
(RP[1] - this.point.top[1]) * (item.accounted / 100) + this.point.top[1]
]
// 还原后点的位置
temporary.right = [
this.point.top,
this.rotatePoint(vertical, this.point.top, this.topAngle.RTB * -1),
vertical
]
} else if (key === 'middle') {
// 垂直后点点距离
temporary.middle = [
this.point.top,
[
this.point.top[0],
(this.point.bottom[1] - this.point.top[1]) * (item.accounted / 100) + this.point.top[1]
],
[
this.point.top[0],