This article has participated in the “Digitalstar Project” and won a creative gift package to challenge the creative incentive money
preface
Recently, the company’s projects tend to display 🥱 on large digital screens, but there is a pyramid diagram 🤔️ in the project prototype sent to me this time. Unfortunately, as we use Echarts for our charts, and Echarts does not support pyramid diagram, as a front-end developer who likes making wheels, although his own technology is not good, However, the spirit of conquering difficult problems is still there 😁, and continuous roll-in is the core competitiveness of our common front-end development 😂, so we have the idea of imitating Echarts to achieve the pyramid.
No more talking about the effect first
Project Address:(Github.com/SHDjason/Py…)
The body of the
Demo is currently based on the Vue2. X framework
The project implementation can be passed in configurations as follows: Body graph position (distance), body graph offset (offset), data sort (sort), graph color (color), data text callback (fontFormatter), ToolTip configuration (ToolTip), Data presentation Style (infoStyle), etc
Initialize canvas basic information and implement resizing
<template>
<div id="canvas-warpper">
<div id="canvas-tooltip"></div>
</div>
</template>
Copy the code
Let’s create the Canvas canvas
This.canvas = document.createElement('canvas') // Add canvas node to el el.appendChild(this.canvas) This. canvasWidth = el.offsetwidth this.canvasHeight = el.offsetheight // Set the canvas element to be the same width as the parent element This.canvas. SetAttribute ('width', this.canvasWidth) // Sets the Canvas element to the same height as the parent element this.canvas. SetAttribute ('height', this.canvasHeight)Copy the code
Get the center point of the canvas for later adaptation and fixing
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]
]
Copy the code
Monitor incoming data and calculate the ratio
This is where you write the incoming configuration of data 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 }) } } } },Copy the code
Now we can locate the four basic points of the pyramid
The position of these basic points determines the shape of the pyramid displayed at the back which can be fine-tuned to suit its own aesthetic
If (this.canvas. GetContext) {this.ctx = this.canvas. GetContext ('2d'); - this. CanvasWidth / 13, enclosing integration. The short [1]] this. Point. Left = [this. Integration. Short [0] * 1.5, this.canvasHeight - this.integration.distance[1] - this.canvasHeight / 5 ] this.point.right = [ this.canvasWidth - This. Integration. Short [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. 1.2 this canvasHeight /. Integration. Short [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 'no getContext method found under Canvas'}Copy the code
- The complete code
Let el = document.getelementById ('canvas-warpper') // Create canvas element this.canvas = document.createElement('canvas') // AppendChild (this.canvas) this.canvasWidth = el.offsetwidth this.canvasheight = el.offsetheight // SetAttribute ('width', this.canvas. SetAttribute ('width', This.canvaswidth) // Set the canvas element to the same height as the parent element 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') // Pyramid base position this.point. Top = [this.canvasCenter[0] - Enclosing canvasWidth / 13, this. Integration. Short. [1]] this point. Left = [this. Integration. Short [0] * 1.5, this.canvasHeight - this.integration.distance[1] - this.canvasHeight / 5 ] this.point.right = [ this.canvasWidth - This. Integration. Short [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. 1.2 this canvasHeight /. Integration. Short [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] } } TopAngle.LTB = this.angle(this.point.top, this.point.left, this.point.left, this.point.left); this.point.bottom) this.topAngle.RTB = this.angle(this.point.top, this.point.right, This. The point. The bottom) / / calculation of each data point position this. CalculationPointPosition (enclosing dataInfo)},Copy the code
Calculate the Angle of each side of the pyramid
In order to fix each data point later, but alas, the math was so bad that I came up with an idea:
The fixed point range of each piece of data must be on the line of four basic points. Then I will figure out the Angle of the line of each basic point, and then I will figure out the ratio of each piece of data to the line of the current basic point after I flip the Angle to vertical.
Const a = {X: a[0], Y: const a = {X: a[0], Y: const a = {X: a[0], Y: const a = {X: a[0], Y: const a = {X: a[0], Y: const a = {X: a[0], Y: const a = {X: a[0], Y: const 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 }Copy the code
Calculate the position of each data point
- The next step is to determine the drawing scope of each piece of data
Let’s first position the left side of the pyramid and the points rotated perpendicular to the right
/** * @description: ptSrc * @param {*} ptSrc; * @param {*} ptRotationCenter * @param {*} ptRotationCenter * @param {*} ptRotationCenter Clockwise negative * @return {*} * @author: 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 },Copy the code
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)
Copy the code
LP is the position of the point after the LTB Angle is rotated counterclockwise by the edge of TL
RP is the position of the point at which the edge of TR is rotated clockwise by Angle RTB
- This can determine the length of each data point on the three edges of the complete code
To calculate the length of each point, take the point on the edge of TL as an example: get the length of LP (position after counterclockwise rotation of LTB Angle), calculate the length of the data according to the proportion of total data occupied by the data, and then turn the Angle back to restore the edge to get the position information of the data on the TL edge. const vertical = [ this.point.top[0], (LP[1] – this.point.top[1]) * (item.accounted / 100) + this.point.top[1] ]
/** * @description: calculate the data point position * @param {*} val point ratio * @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') {// Index) => {index) => {if (index === 0) {for (const key in temporary) {if (key === 'left') = [ this.point.top[0], (LP[1] - this.point.top[1]) * (item.point.top [1]) + this.point.top[1]] // Temporary. 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.point.top [1]) + this.point.top[1]] 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], (this.point.bottom[1] - this.point.top[1]) * (item.accounted / 100) + this.point.top[1] ] ] } } } else { for (const key Parse (json.stringify (temporary[key][2])) if (key === 'left') {// In temporary) {const vertical = json.parse (json.stringify (temporary[key][2])) vertical1 = [this.point.top[0], Vertical [1] + (LP[1] - this.point.top[1]) * (item. left = [this.point.top, three times)] this.rotatePoint(vertical1, this.point.top, this.topAngle.LTB), } else if (key === 'right') {const vertical1 = [this.point.top[0], Vertical [1] + (RP[1] - this.point.top[1]) * (item. right = [this.point.top, three times)] this.rotatePoint(vertical1, this.point.top, this.topAngle.RTB * -1), vertical1 ] } else if (key === 'middle') { temporary.middle = [ this.point.top, [this.point.top[0], (this.point.bottom[1] - this.point.top[1]) * (item.accounted / 100) + vertical[1]], [this.point.top[0], (this.point.bottom[1] - this.point.top[1]) * (item.accounted / 100) + vertical[1]] ] } } } return { ... item, temporary: JSON.parse(JSON.stringify(temporary)) } }) this.dataInfo = dataInfo },Copy the code
This gives you the point position of the length of each piece of data on each edge.
painting
Data layer drawing
We’ve got the points of the length of each piece of data on each edge. So how do you get the length of this line segment on that edge? That’s easy because the position of the first piece of data at the second point of this length is the position of the first point of the second piece of data and now we can go to the next step. Data layer drawing up
/** * @description: Data layer painting * @param {*} * @return {*} * @author: Parse (json.stringify (this.datainfo)) // data.reverse() var index = -1 this.dataInfo = this.dataInfo.map(item => { index++ if (this.integration.color.length === index) { index = 0 } return { . item, color: this.integration.color[index] } }) this.dataInfo = this.dataInfo.map((item, index) => { let drawingPoint = [] this.ctx.fillStyle = item.color this.ctx.beginPath() let point1, point2, point3, point4, point5, point6 if (index === 0) { [point1, point2, point3, point4, point5, point6] = [ item.temporary.left[0], item.temporary.left[1], item.temporary.middle[1], item.temporary.right[1], item.temporary.right[0], item.temporary.middle[0] ] } else { [point1, point2, point3, point4, point5, point6] = [ this.dataInfo[index - 1].temporary.left[1], item.temporary.left[1], item.temporary.middle[1], item.temporary.right[1], this.dataInfo[index - 1].temporary.right[1], this.dataInfo[index - 1].temporary.middle[1] ] } this.ctx.moveTo(... point1) this.ctx.lineTo(... point2) this.ctx.lineTo(... point3) this.ctx.lineTo(... point4) this.ctx.lineTo(... point5) this.ctx.lineTo(... point6) drawingPoint = [point1, point2, point3, point4, point5, point6] if (this.integration.infoStyle.stroke) { this.ctx.shadowOffsetX = 0 this.ctx.shadowOffsetY = 0 this.ctx.shadowBlur = 2 this.ctx.shadowColor = this.integration.infoStyle.strokeColor } this.ctx.fill() return { ... item, drawingPoint } }) }Copy the code
That basically completes the core of the pyramid.
But that’s not enough. To achieve Echarts’ simple functionality, you need more than just diagrams
Painting of words
Font painting is relatively simple, we have the position of each data point, the length of each data point F C points divided by 2 points of the position set as the starting point
* @param {*} * @return {*} * @author: ShuDong winter * / paintingText (lData) {this. CTX. ShadowColor = 'rgba (90,90,90,0)' const color = this. Integration. InfoStyle. Color? this.integration.infoStyle.color : '#fff' const width = this.integration.infoStyle.width ? this.integration.infoStyle.width : 0 const dotSize = this.integration.infoStyle.dotSize ? this.integration.infoStyle.dotSize : 4 const offset = this.integration.infoStyle.offset ? this.integration.infoStyle.offset : [0, 0] let text = '' this.ctx.strokeStyle = color this.ctx.fillStyle = color this.dataInfo.forEach((item, index) => { if (item.drawingPoint) { let line = [ [0, 0], [0, 0] ] this.ctx.font = `normal lighter ${ this.integration.infoStyle.size ? this.integration.infoStyle.size : 14 }px sans-serif ` this.ctx.beginPath() if (lData && index + 1 === lData.l) { line = [ [ lData.obj.drawingPoint[2][0], (lData.obj.drawingPoint[2][1] - lData.obj.drawingPoint[5][1]) / 2 + lData.obj.drawingPoint[5][1] ], [ lData.obj.drawingPoint[2][0] + lData.obj.drawingPoint[2][0] / 2 + width, (lData.obj.drawingPoint[2][1] - lData.obj.drawingPoint[5][1]) / 2 + lData.obj.drawingPoint[5][1] ] ] this.ctx.font = `normal lighter ${ this.integration.infoStyle.size ? this.integration.infoStyle.size + 2 : 16 }px sans-serif ` text = this.integration.fontFormatter(item) ! == 'default' ? this.integration.fontFormatter(item) : lData.obj.value + ' ---- ' + lData.obj.name this.ctx.setLineDash([0, 0]) this.ctx.strokeText( text, line[1][0] + offset[0], line[1][1] + (this.integration.infoStyle.size ? this.integration.infoStyle.size + 2 : 14) / 3 + offset[1] ) } else { line = [ [ item.drawingPoint[2][0], (item.drawingPoint[2][1] - item.drawingPoint[5][1]) / 2 + item.drawingPoint[5][1] ], [ item.drawingPoint[2][0] + item.drawingPoint[2][0] / 2 + width, (item.drawingPoint[2][1] - item.drawingPoint[5][1]) / 2 + item.drawingPoint[5][1] ] ] text = this.integration.fontFormatter(item) ! == 'default' ? this.integration.fontFormatter(item) : item.value + ' ----- ' + item.name this.ctx.setLineDash([0, 0]) this.ctx.strokeText( text, line[1][0] + offset[0], line[1][1] + (this.integration.infoStyle.size ? this.integration.infoStyle.size + 2 : 16) / 3 + offset[1] ) } this.ctx.setLineDash(this.integration.infoStyle.setLineDash) this.ctx.moveTo(... line[0]) this.ctx.lineTo(... line[1]) this.ctx.stroke() this.ctx.arc(... Line [0], dotSize, 0, 360, false) this.ctx.fill() else {throw 'drawingPoint attribute not found'}})},Copy the code
Highlight layer
The highlight layer is nothing more than to monitor the mouse position, determine whether the mouse position exists in the layer, which layer, and then redraw the current layer
/** * @description: mouse event registration * @param {*} * @return {*} * @author: */ eventRegistered() {const canvasWarpper = document.getelementById (' Canvas-warpper ') // Register event canvasWarpper.addEventListener('mousedown', this.doMouseDown, false) canvasWarpper.addEventListener('mouseup', this.doMouseUp, false) canvasWarpper.addEventListener('mousemove', this.doMouseMove, False) / / / / / / registered events this. Canvas. AddEventListener (mousedown, enclosing doMouseDown, false) // this.canvas.addEventListener('mouseup', this.doMouseUp, false) // this.canvas.addEventListener('mousemove', This. DoMouseMove, false)}, /** * @description: mouse move * @param {*} e * @return {*} * @author: */ / eslint-disable-next-line no-unused-vars doMouseMove(e) {const x = e.pagex const y = e.pagey this.highlightCurrentRegion(this.determineDataMouse(this.getLocation(x, y))) if (this.integration.tooltip.show) { this.showTooltip(this.determineDataMouse(this.getLocation(x, y)), This.getlocation (x, y))}}, /** * @description: {@param {*} * @return {*} * @author: DetermineDataMouse (mouseLocation) {let req = false for (let index = 0; index < this.dataInfo.length; index++) { if (this.insidePolygon(this.dataInfo[index].drawingPoint, mouseLocation)) { return (req = { l: index + 1, obj: this.dataInfo[index] }) } } return req }, /** * @description: * @param {*} lData * @return {*} * @author: */ highlightCurrentRegion(lData) {// const width = this.canvas.width; // this.canvas.width = width; this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height) if (! LData) {this.paintdatainfo () this.ctx.shadowcolor = 'rgba(90,90,90,0)' this.paintingbody () this.paintingtext () return} This.ctx. ShadowColor = 'rgba(gba(90,90,90,0)' this.ctx.fillstyle = ldata.obj.color // this.ctx.scale(1.05, 1.05) this.ctx.beginPath() this.ctx.moveto (ldata.obj. drawingPoint[0][0], this.ctx.scale(1.05, 1.05) this.ctx.beginPath() this.ctx.moveto (ldata.obj. DrawingPoint [0], lData.obj.drawingPoint[0][1]) this.ctx.lineTo(lData.obj.drawingPoint[1][0], lData.obj.drawingPoint[1][1]) this.ctx.lineTo(lData.obj.drawingPoint[2][0], lData.obj.drawingPoint[2][1]) this.ctx.lineTo(lData.obj.drawingPoint[3][0], lData.obj.drawingPoint[3][1]) this.ctx.lineTo(lData.obj.drawingPoint[4][0], lData.obj.drawingPoint[4][1]) this.ctx.lineTo(lData.obj.drawingPoint[5][0], lData.obj.drawingPoint[5][1]) this.ctx.shadowOffsetX = 0 this.ctx.shadowOffsetY = 0 this.ctx.shadowBlur = 10 This. CTX. ShadowColor = this. Integration. InfoStyle. HighlightedColor enclosing CTX. The fill () / / shadow map this. CTX. BeginPath () this.ctx.moveTo(lData.obj.drawingPoint[0][0], lData.obj.drawingPoint[0][1]) this.ctx.lineTo(lData.obj.drawingPoint[1][0], lData.obj.drawingPoint[1][1]) this.ctx.lineTo(lData.obj.drawingPoint[2][0], lData.obj.drawingPoint[2][1]) this.ctx.lineTo(lData.obj.drawingPoint[5][0], DrawingPoint [5][1]) this.ctx.fillstyle = 'rgba(120,120,120,.15)' this.ctx.fill() this.paintingtext (lData)}Copy the code
Displays the tooltip location
You can start by defining the render template for tooltip
Then render it on the code
showTooltip(lData, coordinates) { let canvasWarpper = document.getElementById('canvas-warpper') let canvasTooltip = document.getElementById('canvas-tooltip') if (lData) { canvasTooltip.style.zIndex = this.integration.tooltip.z CanvasTooltip. Style. The transition = 'opacity s cubic bezier - 0.2 (0.23, 1, 0.32, 1) 0 s, 0.2 s cubic bezier - visibility (0.23, 1, 0.32, 1) 0 s, 0.15 s' transform. Let HTML = JSON parse (JSON. Stringify (enclosing tooltipDiv)) if (this. Integration. The tooltip. The formatter) { html = this.integration.tooltip.formatter(lData) } else { const searchVal = [ ['$[title]$', lData.obj.title], ['$[name]$', lData.obj.name], ['$[val]$', lData.obj.value], ['$[color]$', lData.obj.color], ['$[fontSize]$', this.integration.tooltip.fontSize], ['$[backgroundColor]$', this.integration.tooltip.backgroundColor], ['$[fontColor]$', this.integration.tooltip.fontColor] ] searchVal.forEach(el => { html = html.replaceAll(... el) }) } canvasTooltip.innerHTML = html canvasWarpper.style.cursor = 'pointer' canvasTooltip.style.visibility = 'visible' canvasTooltip. Style. Opacity = 1 let (x, y) = coordinates x = x + y = y + 20 height / / / / canvas canvasHeight: 0, // canvasWidth // canvasWidth: 0, / / determine whether beyond framework content if (x + canvasTooltip. ClientWidth > enclosing canvasWidth) {x = x - canvasTooltip. ClientWidth - 40} the if (y + canvasTooltip.clientHeight > this.canvasHeight) { y = y - canvasTooltip.clientHeight - 40 } canvasTooltip.style.transform = `translate3d(${x}px, ${y}px, 0px)` } else { canvasWarpper.style.cursor = 'default' canvasTooltip.style.visibility = 'hidden' canvasTooltip.style.opacity = 0 } },Copy the code
And some other configuration function is also relatively simple operation, mainly is too lazy 😂, directly on the complete source code! Source notes are more complete, not very clear can comment, I see will reply!
Complete source code
<template>
<div id="canvas-warpper">
<div id="canvas-tooltip"></div>
</div>
</template>
<script>
export default {
name: 'Pyramid',
props: {
options: {
type: Object,
default: () => {
return {
title: '',
// 主体离边框距离
distance: [0, 0],
// 主体偏移值 (x,y)
offset: [0, 0],
// 排序(max , min)优先
sort: '',
// 颜色
color: ['#80FFA5', '#00DDFF', '#37A2FF', '#FF0087', '#FFBF00'],
// 格式化字体输出
fontFormatter: () => {
return 'default'
},
// tooltip信息配置
tooltip: {
show: true, // 是否显示
fontColor: '#000', // 字体内部颜色
fontSize: 14, // 字体大小
backgroundColor: '#fff', // tooltip背景
formatter: null, // 回调方法
z: 999999 // tooltip z-index层级
},
// 样式
infoStyle: {
stroke: false, // 是否描边
strokeColor: '#fff', //描边颜色
size: null, // 字体大小
color: null, //颜色
highlightedColor: '#fff', // 高亮颜色
setLineDash: [0, 0], // 虚线值
width: -10, // 设置多少 就会在基础上加上设置的值
offset: [0, 0], // 字体x,y的偏移度
dotSize: 4 //点大小
}
}
}
},
// 渲染数据
data: {
type: Array,
default: () => {
return [
{ name: 'name1', value: 11 },
{ name: 'name2', value: 11 },
{ name: 'name3', value: 11 },
{ name: 'name4', value: 77 },
{ name: 'name5', value: 55 },
{ name: 'name6', value: 66 }
]
}
}
},
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
})
}
}
}
},
computed: {
integration() {
return {
title: this.options.title ? this.options.title : '',
// 主体离边框距离
distance: this.options.distance ? this.options.distance : [0, 0],
// 主体偏移值 (x,y)
offset: this.options.offset ? this.options.offset : [0, 0],
// 排序(max , min)优先
sort: this.options.sort ? this.options.sort : '',
// 颜色
color: this.options.color ? this.options.color : ['#80FFA5', '#00DDFF', '#37A2FF', '#FF0087', '#FFBF00'],
// 格式化字体输出
fontFormatter: this.options.fontFormatter
? this.options.fontFormatter
: () => {
return 'default'
},
// tooltip显示
tooltip: {
show: this.options.tooltip ? (this.options.tooltip.show ? this.options.tooltip.show : true) : true, // 是否显示
fontColor: this.options.tooltip
? this.options.tooltip.fontColor
? this.options.tooltip.fontColor
: '#000'
: '#000', // 字体内部颜色
fontSize: this.options.tooltip ? (this.options.tooltip.fontSize ? this.options.tooltip.fontSize : 14) : 14, // 字体大小
backgroundColor: this.options.tooltip
? this.options.tooltip.backgroundColor
? this.options.tooltip.backgroundColor
: '#fff'
: '#fff', // tooltip背景
formatter: this.options.tooltip
? this.options.tooltip.formatter
? this.options.tooltip.formatter
: null
: null, // 返回方法
z: this.options.tooltip ? (this.options.tooltip.z ? this.options.tooltip.z : 999999) : 999999 // tooltip z-index层级
},
// 样式
infoStyle: {
stroke: this.options.infoStyle
? this.options.infoStyle.stroke
? this.options.infoStyle.stroke
: false
: false, //是否描边
strokeColor: this.options.infoStyle
? this.options.infoStyle.strokeColor
? this.options.infoStyle.strokeColor
: '#fff'
: '#fff', // 描边颜色
size: this.options.infoStyle ? (this.options.infoStyle.size ? this.options.infoStyle.size : null) : null, // 字体大小
color: this.options.infoStyle ? (this.options.infoStyle.color ? this.options.infoStyle.color : null) : null, //颜色
width: this.options.infoStyle
? this.options.infoStyle.width || this.options.infoStyle.width !== 0
? this.options.infoStyle.width
: -10
: -10, // 设置多少 就会在基础上加上设置的值
offset: this.options.infoStyle
? this.options.infoStyle.offset
? this.options.infoStyle.offset
: [0, 0]
: [0, 0], // 字体x,y的偏移度
setLineDash: this.options.infoStyle
? this.options.infoStyle.setLineDash
? this.options.infoStyle.setLineDash
: [0, 0]
: [0, 0], //虚线值
highlightedColor: this.options.infoStyle
? this.options.infoStyle.highlightedColor
? this.options.infoStyle.highlightedColor
: '#fff'
: '#fff', //高亮颜色
dotSize: this.options.infoStyle
? this.options.infoStyle.dotSize || this.options.infoStyle.dotSize !== 0
? this.options.infoStyle.dotSize
: 4
: 4 //点大小
}
}
}
},
data() {
return {
// canvas 主体
canvas: null,
// 图像渲染内容
ctx: null,
// 画布高度
canvasHeight: 0,
// 画布宽度
canvasWidth: 0,
// 画布中心点 [x,y]
canvasCenter: [0, 0],
// 金字塔四个点位置
point: {
top: [0, 0],
left: [0, 0],
right: [0, 0],
bottom: [0, 0],
shadow: [0, 0]
},
// 数据信息
dataInfo: [],
// 金字塔顶端角度信息
topAngle: {
LTB: 0,
RTB: 0
},
// tooltip 模板
tooltipDiv: `<div style="margin: 0px 0 0; line-height: 1;border-color: $[backgroundColor]$ ;background-color: $[backgroundColor]$;color: $[fontColor]$;
border-width: 1px;border-radius: 4px;padding: 10px;pointer-events: none;box-shadow: rgb(0 0 0 / 20%) 1px 2px 10px;border-style: solid;white-space: nowrap;">
<div style="margin: 0px 0 0; line-height: 1">
<div style="font-size: $[fontSize]$px; color: $[fontColor]$; font-weight: 400; line-height: 1"> $[title]$ </div>
<div style="margin: 10px 0 0; line-height: 1">
<div style="margin: 0px 0 0; line-height: 1">
<div style="margin: 0px 0 0; line-height: 1">
<span
style="
display: inline-block;
margin-right: 4px;
border-radius: 10px;
width: 10px;
height: 10px;
background-color: $[color]$;
"
></span>
<span style="font-size: $[fontSize]$px; color: $[fontColor]$; font-weight: 400; margin-left: 2px">$[name]$</span>
<span style="float: right; margin-left: 20px; font-size: $[fontSize]$px; color: $[fontColor]$; font-weight: 900">$[val]$</span>
<div style="clear: both"></div>
</div>
<div style="clear: both"></div>
</div>
<div style="clear: both"></div>
</div>
<div style="clear: both"></div>
</div>
<div style="clear: both"></div>
</div>`
}
},
mounted() {
this.init()
},
methods: {
init() {
this.initCanvasBaseInfo()
this.paintDataInfo()
this.paintingText()
this.paintingBody()
this.eventRegistered()
},
/**
* @description: 初始化canvas基本信息
* @param {*}
* @return {*}
* @author: 舒冬冬
*/
initCanvasBaseInfo() {
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: 鼠标事件注册
* @param {*}
* @return {*}
* @author: 舒冬冬
*/
eventRegistered() {
const canvasWarpper = document.getElementById('canvas-warpper')
//注册事件
canvasWarpper.addEventListener('mousedown', this.doMouseDown, false)
canvasWarpper.addEventListener('mouseup', this.doMouseUp, false)
canvasWarpper.addEventListener('mousemove', this.doMouseMove, false)
// //注册事件
// this.canvas.addEventListener('mousedown', this.doMouseDown, false)
// this.canvas.addEventListener('mouseup', this.doMouseUp, false)
// this.canvas.addEventListener('mousemove', this.doMouseMove, false)
},
/**
* @description: 鼠标按下
* @param {*} e
* @return {*}
* @author: 舒冬冬
*/
// eslint-disable-next-line no-unused-vars
doMouseDown(e) {},
/**
* @description: 鼠标弹起
* @param {*} e
* @return {*}
* @author: 舒冬冬
*/
// eslint-disable-next-line no-unused-vars
doMouseUp(e) {},
/**
* @description: 鼠标移动
* @param {*} e
* @return {*}
* @author: 舒冬冬
*/
// eslint-disable-next-line no-unused-vars
doMouseMove(e) {
const x = e.pageX
const y = e.pageY
this.highlightCurrentRegion(this.determineDataMouse(this.getLocation(x, y)))
if (this.integration.tooltip.show) {
this.showTooltip(this.determineDataMouse(this.getLocation(x, y)), this.getLocation(x, y))
}
},
/**
* @description 判断一个点是否在多边形内部
* @param points 多边形坐标集合
* @param testPoint 测试点坐标
* @author: 舒冬冬
* 返回true为真,false为假
*/
insidePolygon(points, testPoint) {
const x = testPoint[0],
y = testPoint[1]
let inside = false
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
const xi = points[i][0],
yi = points[i][1]
const xj = points[j][0],
yj = points[j][1]
const intersect = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi
if (intersect) inside = !inside
}
return inside
},
/**
* @description: 获取当前鼠标坐标
* @param {*}
* @return {*}
* @author: 舒冬冬
*/
getLocation(x, y) {
const bbox = this.canvas.getBoundingClientRect()
return [(x - bbox.left) * (this.canvas.width / bbox.width), (y - bbox.top) * (this.canvas.height / bbox.height)]
},
// ======================================算法==========================================
/**
* @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
},
/**
* @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: 计算两点之间距离
* @return {*}
* @author: 舒冬冬
*/
getDistanceBetweenTwoPoints(a, b) {
const A = a[0] - b[0]
const B = a[1] - b[1]
const result = Math.sqrt(Math.pow(A, 2) + Math.pow(B, 2))
return result
},
/**
* @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],
(this.point.bottom[1] - this.point.top[1]) * (item.accounted / 100) + this.point.top[1]
]
]
}
}
} else {
for (const key in temporary) {
const vertical = JSON.parse(JSON.stringify(temporary[key][2]))
if (key === 'left') {
// 垂直后点点距离
const vertical1 = [this.point.top[0], vertical[1] + (LP[1] - this.point.top[1]) * (item.accounted / 100)]
// 还原后点的位置
temporary.left = [
this.point.top,
this.rotatePoint(vertical1, this.point.top, this.topAngle.LTB),
vertical1
]
} else if (key === 'right') {
// 垂直后点点距离
const vertical1 = [this.point.top[0], vertical[1] + (RP[1] - this.point.top[1]) * (item.accounted / 100)]
// 还原后点的位置
temporary.right = [
this.point.top,
this.rotatePoint(vertical1, this.point.top, this.topAngle.RTB * -1),
vertical1
]
} else if (key === 'middle') {
temporary.middle = [
this.point.top,
[this.point.top[0], (this.point.bottom[1] - this.point.top[1]) * (item.accounted / 100) + vertical[1]],
[this.point.top[0], (this.point.bottom[1] - this.point.top[1]) * (item.accounted / 100) + vertical[1]]
]
}
}
}
return { ...item, temporary: JSON.parse(JSON.stringify(temporary)) }
})
this.dataInfo = dataInfo
},
/**
* @description: 判断鼠标在哪层位置上
* @param {*}
* @return {*}
* @author: 舒冬冬
*/
determineDataMouse(mouseLocation) {
let req = false
for (let index = 0; index < this.dataInfo.length; index++) {
if (this.insidePolygon(this.dataInfo[index].drawingPoint, mouseLocation)) {
return (req = { l: index + 1, obj: this.dataInfo[index] })
}
}
return req
},
// ======================================绘图==========================================
/**
* @description: 绘画主体
* @param {*}
* @return {*}
* @author: 舒冬冬
*/
paintingBody() {
// 左半边金字塔阴影
this.ctx.fillStyle = 'rgba(120,120,120,.15)'
this.ctx.beginPath()
this.ctx.moveTo(...this.point.top)
this.ctx.lineTo(...this.point.bottom)
this.ctx.lineTo(...this.point.left)
this.ctx.fill()
this.ctx.fill()
},
/**
* @description: 数据图层绘画
* @param {*}
* @return {*}
* @author: 舒冬冬
*/
paintDataInfo() {
var index = -1
this.dataInfo = this.dataInfo.map(item => {
index++
if (this.integration.color.length === index) {
index = 0
}
return { ...item, color: this.integration.color[index] }
})
this.dataInfo = this.dataInfo.map((item, index) => {
let drawingPoint = []
this.ctx.fillStyle = item.color
this.ctx.beginPath()
let point1, point2, point3, point4, point5, point6
if (index === 0) {
[point1, point2, point3, point4, point5, point6] = [
item.temporary.left[0],
item.temporary.left[1],
item.temporary.middle[1],
item.temporary.right[1],
item.temporary.right[0],
item.temporary.middle[0]
]
} else {
[point1, point2, point3, point4, point5, point6] = [
this.dataInfo[index - 1].temporary.left[1],
item.temporary.left[1],
item.temporary.middle[1],
item.temporary.right[1],
this.dataInfo[index - 1].temporary.right[1],
this.dataInfo[index - 1].temporary.middle[1]
]
}
this.ctx.moveTo(...point1)
this.ctx.lineTo(...point2)
this.ctx.lineTo(...point3)
this.ctx.lineTo(...point4)
this.ctx.lineTo(...point5)
this.ctx.lineTo(...point6)
drawingPoint = [point1, point2, point3, point4, point5, point6]
if (this.integration.infoStyle.stroke) {
this.ctx.shadowOffsetX = 0
this.ctx.shadowOffsetY = 0
this.ctx.shadowBlur = 2
this.ctx.shadowColor = this.integration.infoStyle.strokeColor
}
this.ctx.fill()
return { ...item, drawingPoint }
})
},
/**
* @description: 绘画字体
* 此方法请在 paintDataInfo() 执行后使用
* @param {*}
* @return {*}
* @author: 舒冬冬
*/
paintingText(lData) {
this.ctx.shadowColor = 'rgba(90,90,90,0)'
const color = this.integration.infoStyle.color ? this.integration.infoStyle.color : '#fff'
const width = this.integration.infoStyle.width ? this.integration.infoStyle.width : 0
const dotSize = this.integration.infoStyle.dotSize ? this.integration.infoStyle.dotSize : 4
const offset = this.integration.infoStyle.offset ? this.integration.infoStyle.offset : [0, 0]
let text = ''
this.ctx.strokeStyle = color
this.ctx.fillStyle = color
this.dataInfo.forEach((item, index) => {
if (item.drawingPoint) {
let line = [
[0, 0],
[0, 0]
]
this.ctx.font = `normal lighter ${
this.integration.infoStyle.size ? this.integration.infoStyle.size : 14
}px sans-serif `
this.ctx.beginPath()
if (lData && index + 1 === lData.l) {
line = [
[
lData.obj.drawingPoint[2][0],
(lData.obj.drawingPoint[2][1] - lData.obj.drawingPoint[5][1]) / 2 + lData.obj.drawingPoint[5][1]
],
[
lData.obj.drawingPoint[2][0] + lData.obj.drawingPoint[2][0] / 2 + width,
(lData.obj.drawingPoint[2][1] - lData.obj.drawingPoint[5][1]) / 2 + lData.obj.drawingPoint[5][1]
]
]
this.ctx.font = `normal lighter ${
this.integration.infoStyle.size ? this.integration.infoStyle.size + 2 : 16
}px sans-serif `
text =
this.integration.fontFormatter(item) !== 'default'
? this.integration.fontFormatter(item)
: lData.obj.value + ' ---- ' + lData.obj.name
this.ctx.setLineDash([0, 0])
this.ctx.strokeText(
text,
line[1][0] + offset[0],
line[1][1] + (this.integration.infoStyle.size ? this.integration.infoStyle.size + 2 : 14) / 3 + offset[1]
)
} else {
line = [
[
item.drawingPoint[2][0],
(item.drawingPoint[2][1] - item.drawingPoint[5][1]) / 2 + item.drawingPoint[5][1]
],
[
item.drawingPoint[2][0] + item.drawingPoint[2][0] / 2 + width,
(item.drawingPoint[2][1] - item.drawingPoint[5][1]) / 2 + item.drawingPoint[5][1]
]
]
text =
this.integration.fontFormatter(item) !== 'default'
? this.integration.fontFormatter(item)
: item.value + ' ----- ' + item.name
this.ctx.setLineDash([0, 0])
this.ctx.strokeText(
text,
line[1][0] + offset[0],
line[1][1] + (this.integration.infoStyle.size ? this.integration.infoStyle.size + 2 : 16) / 3 + offset[1]
)
}
this.ctx.setLineDash(this.integration.infoStyle.setLineDash)
this.ctx.moveTo(...line[0])
this.ctx.lineTo(...line[1])
this.ctx.stroke()
this.ctx.arc(...line[0], dotSize, 0, 360, false)
this.ctx.fill() //画实心圆
} else {
throw '未找到 drawingPoint 属性'
}
})
},
/**
* @description: 显示tooltip位置
* @param {*} lData 当前层级
* @param {*} coordinates 鼠标位置
* @return {*}
* @author: 舒冬冬
*/
showTooltip(lData, coordinates) {
let canvasWarpper = document.getElementById('canvas-warpper')
let canvasTooltip = document.getElementById('canvas-tooltip')
if (lData) {
canvasTooltip.style.zIndex = this.integration.tooltip.z
canvasTooltip.style.transition =
' opacity 0.2s cubic-bezier(0.23, 1, 0.32, 1) 0s, visibility 0.2s cubic-bezier(0.23, 1, 0.32, 1) 0s,transform 0.15s'
let html = JSON.parse(JSON.stringify(this.tooltipDiv))
if (this.integration.tooltip.formatter) {
html = this.integration.tooltip.formatter(lData)
} else {
const searchVal = [
['$[title]$', lData.obj.title],
['$[name]$', lData.obj.name],
['$[val]$', lData.obj.value],
['$[color]$', lData.obj.color],
['$[fontSize]$', this.integration.tooltip.fontSize],
['$[backgroundColor]$', this.integration.tooltip.backgroundColor],
['$[fontColor]$', this.integration.tooltip.fontColor]
]
searchVal.forEach(el => {
html = html.replaceAll(...el)
})
}
canvasTooltip.innerHTML = html
canvasWarpper.style.cursor = 'pointer'
canvasTooltip.style.visibility = 'visible'
canvasTooltip.style.opacity = 1
let [x, y] = coordinates
x = x + 20
y = y + 20
// 画布高度
// canvasHeight: 0,
// 画布宽度
// canvasWidth: 0,
// 判断是否超出框架内容
if (x + canvasTooltip.clientWidth > this.canvasWidth) {
x = x - canvasTooltip.clientWidth - 40
}
if (y + canvasTooltip.clientHeight > this.canvasHeight) {
y = y - canvasTooltip.clientHeight - 40
}
canvasTooltip.style.transform = `translate3d(${x}px, ${y}px, 0px)`
} else {
canvasWarpper.style.cursor = 'default'
canvasTooltip.style.visibility = 'hidden'
canvasTooltip.style.opacity = 0
}
},
/**
* @description: 高亮某一层级
* @param {*} lData 层级数据
* @return {*}
* @author: 舒冬冬
*/
highlightCurrentRegion(lData) {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
if (!lData) {
this.paintDataInfo()
this.ctx.shadowColor = 'rgba(90,90,90,0)'
this.paintingBody()
this.paintingText()
return
}
this.paintDataInfo()
this.ctx.shadowColor = 'rgba(90,90,90,0)'
this.paintingBody()
this.ctx.fillStyle = lData.obj.color
// this.ctx.scale(1.05, 1.05)
this.ctx.beginPath()
this.ctx.moveTo(lData.obj.drawingPoint[0][0], lData.obj.drawingPoint[0][1])
this.ctx.lineTo(lData.obj.drawingPoint[1][0], lData.obj.drawingPoint[1][1])
this.ctx.lineTo(lData.obj.drawingPoint[2][0], lData.obj.drawingPoint[2][1])
this.ctx.lineTo(lData.obj.drawingPoint[3][0], lData.obj.drawingPoint[3][1])
this.ctx.lineTo(lData.obj.drawingPoint[4][0], lData.obj.drawingPoint[4][1])
this.ctx.lineTo(lData.obj.drawingPoint[5][0], lData.obj.drawingPoint[5][1])
this.ctx.shadowOffsetX = 0
this.ctx.shadowOffsetY = 0
this.ctx.shadowBlur = 10
this.ctx.shadowColor = this.integration.infoStyle.highlightedColor
this.ctx.fill()
// 阴影绘制
this.ctx.beginPath()
this.ctx.moveTo(lData.obj.drawingPoint[0][0], lData.obj.drawingPoint[0][1])
this.ctx.lineTo(lData.obj.drawingPoint[1][0], lData.obj.drawingPoint[1][1])
this.ctx.lineTo(lData.obj.drawingPoint[2][0], lData.obj.drawingPoint[2][1])
this.ctx.lineTo(lData.obj.drawingPoint[5][0], lData.obj.drawingPoint[5][1])
this.ctx.fillStyle = 'rgba(120,120,120,.15)'
this.ctx.fill()
this.paintingText(lData)
}
}
}
</script>
Copy the code
At the end
😂 Chinese is not good, the expression ability is poor, this is my first time to send technology blog what do not understand welcome comment discussion, also welcome the big guy’s correction.
Mutual encouragement!!
Project address :(github.com/SHDjason/Py…)