48,544 阅读11分钟

# 起源

``````  visualMap: {
type: 'piecewise',
show: false,
dimension: 0,
seriesIndex: 0,
pieces: [
{
gt: 1,
lt: 3,
color: 'rgba(0, 0, 180, 0.4)'
},
{
gt: 5,
lt: 7,
color: 'rgba(0, 0, 180, 0.4)'
}
]
},

``````

``````  series: [
{
type: 'line',
smooth: 0.6,
symbol: 'none',
lineStyle: {
color: '#5470C6',
width: 5
},
markLine: {
symbol: ['none', 'none'],
label: { show: false },
data: [{ xAxis: 1 }, { xAxis: 3 }, { xAxis: 5 }, { xAxis: 7 }]
},
areaStyle: {},
data: [
['2019-10-10', 200],
['2019-10-11', 560],
['2019-10-12', 750],
['2019-10-13', 580],
['2019-10-14', 250],
['2019-10-15', 300],
['2019-10-16', 450],
['2019-10-17', 300],
['2019-10-18', 100]
]
}
]
``````

# 曲线图

## 考虑缩放

``````  // 计算 Y 轴坐标比例尺 ratioY
maxY = Math.max.apply(null, concatData);
minY = Math.min.apply(null, concatData);
rangeY = maxY - minY;
// 数据和坐标范围的比值
ratioY = (height - 2 * margin) / rangeY;
// 计算 X 轴坐标比例尺和步长
count = concatData.length;
rangeX = width - 2 * margin;
xk = 1, xkVal = xk * margin
dataLen = data.length
ratioX = rangeX / (count - dataLen);
stepX = ratioX;
``````

## 绘制坐标轴

``````/**
* 绘制坐标轴
*/
function drawAxis() {
ctx.beginPath();
ctx.moveTo(margin, margin);
ctx.lineTo(margin, height - margin);
ctx.lineTo(width - margin + 2, height - margin);
ctx.setLineDash([3, 3])
ctx.strokeStyle = '#aaa'
ctx.stroke();
ctx.setLineDash([1])
const yLen = newOpt.axisY.data.length
const xLen = newOpt.axisX.data.length

// 绘制 Y 轴坐标标记和标签
for (let i = 0; i < yLen; i++) {
let y = (rangeY * i) / (yLen - 1) + minY;
let yPos = height - margin - (y - minY) * ratioY;

if (i) {
ctx.beginPath();
ctx.moveTo(margin, yPos);
ctx.lineTo(width - margin, yPos);
ctx.strokeStyle = '#ddd'
ctx.stroke();
}

ctx.beginPath();
ctx.stroke();
newYs = []
for (const val of options.axisY.data) {
newYs.push(options.axisY.format(val))
}
ctx.fillText(newYs[i] + '', margin - 15 - options.axisY.right, yPos + 5);
firstEnding && axisYList.push(yPos + 5)
}

// 绘制 X 轴坐标标签
for (let i = 0; i < xLen; i++) {
let x = i * stepX;
let xPos = (margin + x);
if (i) {
ctx.beginPath();
ctx.moveTo(xPos, height - margin);
ctx.lineTo(xPos, margin);
ctx.strokeStyle = '#ddd'
ctx.stroke();
}
newXs = []
for (const val of options.axisX.data) {
newXs.push(options.axisX.format(val))
}
ctx.fillText(newXs[i], xPos - 1, height - margin + 10 + options.axisX.top);
firstEnding && axisXList.push(xPos - 1)
}
}
``````

## 绘制曲线入口

``````/**
* 绘制单组曲线
* @param data
*/
function drawLine(data: any) {
const { points, id, rgba, lineColor, hoverRgba } = data
startAreaX = endAreaX
startAreaY = endAreaY
// 分割区
if (firstEnding) {
areaList.push({ x: startAreaX, y: startAreaY })
}

function darwColorOrLine(lineMode: boolean) {
// 绘制折线
ctx.beginPath();
ctx.moveTo(id ? margin + endAreaX - xkVal : margin + endAreaX, height - margin - (points[0] - minY) * ratioY);
ctx.lineWidth = 2
ctx.setLineDash([0, 0])

let x = 0, y = 0, translateX = 0
if (id) {
translateX -= 20
}
for (let i = 0; i < points.length; i++) {
x = i * stepX + margin + endAreaX + translateX
y = height - margin - (points[i] - minY) * ratioY;

let x0 = (i - 1) * stepX + margin + endAreaX + translateX;
let y0 = height - margin - (points[i - 1] - minY) * ratioY;
let xc = x0 + stepX / 2;
let yc = (y0 + y) / 2;
if (i === 0) {
prePointPosX = x
prePointPosY = y
ctx.lineTo(x, y);
// 这里需要提前考虑是否是线、还是曲线
if (!(prePointPosX === x && prePointPosY === y)) {
pointList.push({ type: 'line', start: { x: prePointPosX, y: prePointPosY }, end: { x: x, y: y } })
}
} else {
ctx.bezierCurveTo(xc, y0, xc, y, x, y);
pointList.push({ type: 'curve', start: { x: prePointPosX, y: prePointPosY }, end: { x: x, y: y }, control1: { x: xc, y: y0 }, control2: { x: xc, y: y } })
}
prePointPosX = x
prePointPosY = y
if (i === points.length - 1) {
endAreaX = x
endAreaY = y

if (firstEnding && id === newOpt.data.length - 1) {
areaList.push({ x: x, y: y })
}
}
}
ctx.strokeStyle = lineColor
ctx.stroke()

lineMode && ctx.beginPath()

// 右侧闭合点
ctx.lineTo(endAreaX, height - margin)
// 左侧闭合点
ctx.lineTo(margin + startAreaX, height - margin)
let startClosePointX = id ? startAreaX : margin + startAreaX
// 交接闭合点
ctx.lineTo(startClosePointX, height - margin)
ctx.strokeStyle = 'transparent'
lineMode && ctx.stroke();
}
darwColorOrLine(false)
// 渐变

if (isHover && areaId === id) {
} else {
}

ctx.fill();
}
/**
* 绘制所有组的曲线
*/
function startDrawLines() {
const { data, series } = newOpt
for (let i = 0; i < data.length; i++) {
drawLine({ points: data[i], id: i, rgba: series[i].rgba, hoverRgba: series[i].hoverRgba, lineColor: series[i].lineColor })
}
firstEnding = false  //由于是不断绘制，我们需要得到第一次渲染完的我们想要的数组，防止数据被污染
}

``````

## 绘制贝塞尔曲线

``````x = i * stepX + margin + endAreaX + translateX
y = height - margin - (points[i] - minY) * ratioY;
let x0 = (i - 1) * stepX + margin + endAreaX + translateX;
let y0 = height - margin - (points[i - 1] - minY) * ratioY;
let xc = x0 + stepX / 2;
let yc = (y0 + y) / 2;
// ....
ctx.bezierCurveTo(xc, y0, xc, y, x, y);
``````

## bezierCurveTo原理

``````function getBezierCurvePoints(startX: number, startY: number, cp1X: number, cp1Y: number, cp2X: number, cp2Y: number, endX: number, endY: number, steps: number) {
let points = [];

// 使用二次贝塞尔曲线近似三次贝塞尔曲线
let q1x = startX + (cp1X - startX) * 2 / 3;
let q1y = startY + (cp1Y - startY) * 2 / 3;
let q2x = endX + (cp2X - endX) * 2 / 3;
let q2y = endY + (cp2Y - endY) * 2 / 3;

// 采样曲线上的所有点
for (let i = 0; i <= steps; i++) {
let t = i / steps;
let x = (1 - t) * (1 - t) * (1 - t) * startX +
3 * t * (1 - t) * (1 - t) * q1x +
3 * t * t * (1 - t) * q2x +
t * t * t * endX;
let y = (1 - t) * (1 - t) * (1 - t) * startY +
3 * t * (1 - t) * (1 - t) * q1y +
3 * t * t * (1 - t) * q2y +
t * t * t * endY;

points.push({ x: +x.toFixed(2), y: +y.toFixed(2) });
}

return points;
}

``````

## 如何实现点在路径上游走

``````function getAllPoints(segments: PointList) {
let points = [];
let lastPoint = null;

// 遍历所有线段的控制点和终点，将这些点的坐标存储到数组中
for (let i = 0; i < segments.length; i++) {
let segment = segments[i];
let pointsCount = 50; // 点的数量
// 如果是直线，则使用lineTo方法连接线段的终点
if (segment.type === "line") {
let x0 = segment.start.x;
let y0 = segment.start.y;
let x1 = segment.end.x;
let y1 = segment.end.y;
for (let j = 0; j <= pointsCount; j++) {
let t = j / pointsCount;
let x = x0 + (x1 - x0) * t;
let y = y0 + (y1 - y0) * t;
points.push({ x: +x.toFixed(2), y: +y.toFixed(2) });
}
// 如果是曲线，则使用贝塞尔曲线的方法绘制曲线，并将曲线上的所有点的坐标存储到数组中
} else if (segment.type === "curve") {
let x0 = segment.start.x;
let y0 = segment.start.y;
let x1 = segment.control1.x;
let y1 = segment.control1.y;
let x2 = segment.control2.x;
let y2 = segment.control2.y;
let x3 = segment.end.x;
let y3 = segment.end.y;
const point = getBezierCurvePoints(x0, y0, x1, y1, x2, y2, x3, y3, pointsCount)
points.push(...point);
}
// 更新线段的起点
lastPoint = segment.end;
}
return points
}

``````

## label的数据计算、区间的计算

``````/**
* label显示
* @param clientX
* @param clientY
*/
function drawTouchPoint(clientX: number, clientY: number) {
cx = clientX, cy = clientY

// 计算当前区间位置
for (let i = 0; i < areaList.length - 1; i++) {
const pre = areaList[i].x;
const after = areaList[i + 1].x;

if (cx > pre && cx < after) {
areaId = i
}
}
// 计算交叉位置，得到对应的x轴位置，从option的data中取对应的title
for (let i = 0; i < axisXList.length - 1; i++) {
const pre = axisXList[i];
const after = axisXList[i + 1];
if (cx > pre && cx < after) {
curInfo.x = i
}
}
for (let i = 0; i < axisYList.length - 1; i++) {
const max = axisYList[i];
const min = axisYList[i + 1];
if (cy < max && cy > min) {
curInfo.y = i + 1
}
}

let crossPoint = pathPoints.find((item: Pos) => {
const orderNum = .5
if (Math.abs(item.x - clientX) <= orderNum) {
return item
}
}) as Pos | undefined
if (crossPoint && canvas) {
dotCtx.clearRect(0, 0, canvas.width, canvas.height);

dotCtx.beginPath()
dotCtx.setLineDash([2, 4]);
dotCtx.moveTo(crossPoint.x, margin)
dotCtx.lineTo(crossPoint.x, height - margin)
dotCtx.strokeStyle = '#000'
dotCtx.stroke()

drawArc(dotCtx, crossPoint.x, crossPoint.y, 5)

//label
if (!isLabel) {
labelDOM = document.createElement("div");
labelDOM.id = 'canvasTopBox'
labelDOM.innerHTML = ""
container && container.appendChild(labelDOM)
isLabel = true
} else {
if (labelDOM) {
let t = crossPoint.y + labelDOM.offsetHeight > canvas.height - margin ? canvas.height - margin - labelDOM.offsetHeight : crossPoint.y - labelDOM.offsetHeight * .5
labelDOM.style.left = crossPoint.x + 20 + 'px'
labelDOM.style.top = t + 'px'
labelDOM.innerHTML = `
<div class='label'>
<div class='label-left' style='backGround: \${newOpt.series[areaId].lineColor}'>
</div>
<div class='label-right'>
<div class='label-text'>人数:\${newYs[curInfo.y]} </div>
<div class='label-text'>订单数:\${newXs[curInfo.x]} </div>
</div>
</div>
`
} else {
}
}
}
}
``````

## 遮罩动画

``````function drawAnimate() {
markCtx.clearRect(0, 0, width, height);

markCtx.fillStyle = "rgba(255, 255, 255, 1)"
markCtx.fillRect(0, 0, width, height);

markCtx.clearRect(
);

// 更新遮罩区域大小
animateId = requestAnimationFrame(drawAnimate);
} else {
cancelAnimationFrame(animateId)
watchEvent()
}
}

``````

## option配置入口

``````export const options = {
layout: {
w: 0,
h: 0,
root: '#container',
m: 30
},
data: [[40, 60, 40, 80, 10, 50, 80, 0, 50, 30, 20], [20, 30, 60, 40, 30, 10, 30, 20, 0, 30, 40, 20], [20, 30, 20, 40, 20, 10, 10, 30, 0, 30, 50, 20]],
axisX: {
data: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32],
format(param: string | number) {
return param + 'w'
},
top: 4,
},
axisY: {
data: [0, 20, 40, 60, 80],
format(param: string | number) {
return param + '人'
},
right: 10,
},
series: [
{
rgba: [[55, 162, 255], [116, 21, 219]],
hoverRgba: [[55, 162, 255], [116, 21, 219]],
lineColor: 'blue'
},
{
rgba: [[255, 0, 135], [135, 0, 157]],
hoverRgba: [[255, 0, 135], [135, 0, 157]],
lineColor: 'purple'
},
{
rgba: [[255, 190, 0], [224, 62, 76]],
hoverRgba: [[255, 190, 0], [224, 62, 76]],
lineColor: 'orange'
}
]
}

``````

# 总结

canvas的核心就是点的处理，在一些曲线衔接、路径的获取会比较复杂，同时如何管理好图层是很重要的，本曲线图底部是辅助图层不做变化，曲线是需要做动画的话，最好就单独做个图层，顶部在来个遮罩做标签等元素，为了更方便做自定义，我们也没必要用canvas绘制，直接dom或svg渲染就行。

# 补充说明

``````//...
series: [
{
name: 'xx1',
type: 'line',
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [{
offset: 0, color: 'blue'
}, {
offset: 1, color: 'transparent'
}],
global: false
}
},
lineStyle: {
color: '#0b8fb066'
},
data: data1,
smooth: true,
symbolSize: 0
},
{
name: 'xx2',
type: 'line',
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [{
offset: 0, color: 'yellow'
}, {
offset: 1, color: 'transparent'
}],
global: false
}
},
lineStyle: {
color: 'yellow'
},
data: data2,
smooth: true,
symbolSize: 0
},
{
name: 'xx3',
type: 'line',
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [{
offset: 0, color: 'red'
}, {
offset: 1, color: 'transparent'
}],
global: false
}
},
lineStyle: {
color: 'red'
},
data: data3,
smooth: true,
symbolSize: 0
}
]
``````