总体思路
代码实现
- 保存画布宽高以及绘制对象
js
import React from 'react';
funtion ChartLine() {
const boxRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const [{ w, h, ctx }, setConfig] = React.useState({ w: null, h: null, ctx: null });
React.useEffect(() => {
if (canvasRef.current && boxRef.current) {
setConfig({
w: boxRef.current.offsetWidth,
h: boxRef.current.offsetHeight,
ctx: canvasRef.current.getContext('2d'),
});
}
}, []);
return (
<div ref={boxRef} style={{ width: '100%', height: '100%' }}>
<canvas ref={canvasRef} width={w} height={h} />
</div>
)
}
2.绘制(副)标题的方法
js
// ctx:绘制对象;t: 文字对应的信息
export const drawText = (ctx, t) => {
ctx.font = t.font;
ctx.fillStyle = t.fillStyle;
ctx.textBaseline = t.textBaseline;
ctx.fillText(`${t.text}`, t.x, t.y);
};
3.绘制坐标轴的方法
js
// c: 绘制对象; axs:传入的坐标轴数据;width:画布的宽度;height:画布的高度;t: x/y轴
const drawAxis = (c, axs, width, height, t) => {
c.beginPath();
const left = axs.margin.x || axs.margin.left;
const right = width - (axs.margin.x || axs.margin.right);
const btm = height - (axs.margin.y || axs.margin.bottom);
let top;
if (t === 'x') {
c.strokeStyle = axs.strokeStyle || axs.x.strokeStyle;
c.moveTo(left, btm);
c.lineTo(right, btm);
} else {
c.strokeStyle = axs.strokeStyle || axs.y.strokeStyle;
top = axs.margin.y || axs.margin.top;
c.moveTo(left, top);
c.lineTo(left, btm);
}
c.stroke();
c.closePath();
return {
btm,
left,
right,
top,
};
};
- 绘制分类样式
js
// 绘制折线上的小圆点
export const drawLinePoint = (c, x, y, lineStyle) => {
c.beginPath();
c.fillStyle = lineStyle;
c.arc(x, y, 3, 0, 2 * Math.PI);
c.closePath();
c.fill();
};
// c: 绘制对象;d:传入的data数据
const drawThumb = React.useCallback((c, d) => {
if (!c || !isArray(d) || !isNoEmpty(d)) return;
d.forEach((it) => {
if (it.thumbnail) {
const { x, y, w: wt, h: ht } = it.thumbnail;
c.beginPath();
c.strokeStyle = it.strokeStyle;
c.moveTo(x, y + ht / 2);
c.lineTo(x + wt, y + ht / 2);
c.stroke();
c.closePath();
drawLinePoint(c, x + wt / 2, y + ht / 4 + 4, it.strokeStyle);
if (it.text && it.text.text) {
c.font = it.text.font;
c.fillStyle = it.text.fillStyle;
c.fillText(`${it.text.text}`, it.text.x, it.text.y);
}
}
});
}, []);
5.绘制坐标轴分割点的方法
js
// c:绘制对象,d: y轴数据,btm,left,right:坐标轴距离画布边缘的边距,axs: x轴数据
const drawSplitPoint = ({ c, d, btm, left, right, axs }) => {
const xwt = right - left;
let len;
let step;
let splitPoint;
let endX;
let endY;
let endY2;
if (!d) {
// y 轴的兼容
} else {
len = d.length;
step = xwt / len;
endX = right - 2;
endY = btm;
endY2 = btm + 4;
splitPoint = axs.splitPoint || (axs.x && axs.x.splitPoint);
}
getLineConfig(c, splitPoint);
for (let i = 0; i <= len; i++) {
c.beginPath();
if (i === len) {
c.moveTo(endX, endY);
c.lineTo(endX, endY2);
} else {
c.fillText(`${d[i]}`, left + (i + 0.25) * step, endY + 10);
c.moveTo(left + 1 + i * step, endY);
c.lineTo(left + 1 + i * step, endY2);
}
c.stroke();
c.closePath();
}
return { step };
};
6.绘制坐标轴辅助线的方法
js
// maxVal:最大值,其他参数和上一个方法一样
const drawAssistLine = ({ c, d, btm, left, right, top, axs, maxVal }) => {
const yht = btm - top;
let len;
let step;
let assist;
let endY;
let space;
let valRate;
if (!d) {
assist = axs.assist || (axs.y && axs.y.assist);
space = axs.space || (axs.y && axs.y.space);
len = Math.ceil(maxVal / space);
step = yht / len;
endY = top + 1;
valRate = step / space;
} else {
// x轴的兼容
}
getLineConfig(c, assist);
for (let i = 0; i <= len; i++) {
c.beginPath();
const text = `${i * space}`;
c.fillText(text, left - 10, btm - i * step - 5);
if (i === len) {
c.moveTo(left, endY);
c.lineTo(right, endY);
} else if (i !== 0) {
c.moveTo(left, btm - i * step);
c.lineTo(right, btm - i * step);
}
c.stroke();
c.closePath();
}
return { valRate };
}
7.绘制折线的方法
js
// 绘制完整折线
const drawLinePaths = (idx, sx, l, data, rate, ctx, btm) => {
let yR = 0.1;
// 获取当前点x,y坐标
const curX = l + (idx + 0.5) * sx;
const curY = btm - data.values[idx] * rate;
// 获取上一个点x,y坐标
const preX = l + (idx - 0.5) * sx;
const preY = btm - data.values[idx - 1] * rate;
// 获取当前坐标点和上一个坐标点的差值
const difX = curX - preX;
const difY = curY - preY;
ctx.beginPath();
const drawLinePath = (px, py) => {
yR += 0.2;
// 绘制线条
ctx.beginPath();
ctx.strokeStyle = data.strokeStyle;
ctx.moveTo(px, py);
const drawX = preX + difX * (yR > 1 ? 1 : yR);
const drawY = preY + difY * (yR > 1 ? 1 : yR);
ctx.lineTo(drawX, drawY);
ctx.stroke();
if (yR <= 1) {
// 当前线段未绘制完成时继续执行绘制当前线段的函数
requestAnimationFrame(() => drawLinePath(drawX, drawY));
}
if (yR >= 1 && idx < data.values.length) {
// 绘制拐点
drawLinePoint(ctx, drawX, drawY, data.strokeStyle);
// 第一节的线条完成后,绘制第二节的线段
requestAnimationFrame(() => drawLinePaths(idx + 1, sx, l, data, rate, ctx, btm));
}
};
drawLinePath(preX, preY);
};
// step_x: x轴的分割距离;data:对应Y轴的value值的对象,valRate: data中的数据绘制到canvas坐标的转换比例
const drawLine = (ctx, data, step_x, left, btm, valRate) => {
// 设置线条为实线
ctx.setLineDash([]);
// 设置线条宽度为2
ctx.lineWidth = 2;
for (let i = 0, len = data.length; i < len; i++) {
// 绘制第一个线条
drawLinePaths(1, step_x, left, data[i], valRate, ctx, btm);
// 绘制第一个点
drawLinePoint(ctx, left + 0.5 * step_x, btm - data[i].values[0] * valRate, data[i].strokeStyle);
}
};
完整代码
js
// types.d.ts
export interface ChartText {
font?: string;
fillStyle?: string;
// eslint-disable-next-line no-undef
textBaseline?: CanvasTextBaseline;
text?: string;
x?: number;
y?: number;
}
export interface Line {
strokeStyle?: string;
fillStyle?: string;
lineWidth?: number;
textAlign?: string;
lineCap?: string;
lineDash?: number[];
}
export interface AxisBase {
strokeStyle?: string;
assist?: Line;
splitPoint?: Line;
space?: number;
}
export interface Axis extends AxisBase {
x?: AxisBase;
y?: AxisBase;
margin?: {
left?: number;
right?: number;
top?: number;
bottom?: number;
x?: number;
y?: number;
};
}
export interface ChartData {
name: string;
values: number[];
fillStyle?: string;
strokeStyle?: string;
text?: ChartText;
thumbnail?: {
x?: number;
y?: number;
w?: number;
h?: number;
r?: number;
};
}
export interface ChartProps {
data: ChartData[];
xData: (string | number)[];
axis: Axis;
title?: ChartText;
subTitle?: ChartText;
}
js
// hooks-useListenDomSize 监听节点尺寸变化
import { type RefObject, useEffect } from 'react';
// debounce 防抖函数,自己定义也可以,用loadsh里的也可以,这里不做展示
/**
* useListenDomSize 监听节点尺寸变化
* @param dom 要监听尺寸变化的节点
* @param callback 尺寸变化时的回调
*/
export const useListenDomSize = (
elRef: RefObject<HTMLElement>,
callback?: (_p?: any) => void,
time = 3000,
) => {
useEffect(() => {
if (elRef.current && typeof callback === 'function') {
const targetDom = elRef.current;
if (window.ResizeObserver) {
const cb = debounce(callback, time);
const domObserver = new ResizeObserver(cb);
domObserver.observe(targetDom);
return () => {
if (targetDom) {
domObserver.unobserve(targetDom);
domObserver.disconnect();
}
};
}
}
return () => null;
}, [elRef, callback, time]);
};
js
import React from 'react';
import type { ChartProps } from './types';
import { useListenDomSize } from './hooks';
// 绘制标题
export const drawText = (c, t) => {
if (t && t.text) {
c.font = t.font;
c.fillStyle = t.fillStyle;
c.textBaseline = t.textBaseline;
c.fillText(`${t.text}`, t.x, t.y);
}
};
// 绘制坐标轴
export const drawAxis = (c, axs, width, height, t) => {
if (axs && width && height) {
c.beginPath();
const left = axs.margin.x || axs.margin.left;
const right = width - (axs.margin.x || axs.margin.right);
const btm = height - (axs.margin.y || axs.margin.bottom);
let top;
if (t === 'x') {
c.strokeStyle = axs.strokeStyle || axs.x.strokeStyle;
c.moveTo(left, btm);
c.lineTo(right, btm);
} else {
c.strokeStyle = axs.strokeStyle || axs.y.strokeStyle;
top = axs.margin.y || axs.margin.top;
c.moveTo(left, top);
c.lineTo(left, btm);
}
c.stroke();
c.closePath();
return {
btm,
left,
right,
top,
};
}
return {};
};
// 配置线条的样式
export const getLineConfig = (c, o) => {
if (o) {
c.beginPath();
c.strokeStyle = o.strokeStyle;
c.fillStyle = o.fillStyle;
c.lineWidth = o.lineWidth;
c.textAlign = o.textAlign;
c.lineCap = o.lineCap;
if (o.lineDash) {
c.setLineDash(o.lineDash);
}
}
};
// 绘制坐标轴分割点
export const drawSplitPoint = ({ c, d, btm, left, right, axs }) => {
const xwt = right - left;
let len;
let step;
let splitPoint;
let endX;
let endY;
let endY2;
if (!d) {
// y 轴的兼容
} else {
len = d.length;
step = xwt / len;
endX = right - 2;
endY = btm;
endY2 = btm + 4;
splitPoint = axs.splitPoint || (axs.x && axs.x.splitPoint);
}
getLineConfig(c, splitPoint);
for (let i = 0; i <= len; i++) {
c.beginPath();
if (i === len) {
c.moveTo(endX, endY);
c.lineTo(endX, endY2);
} else {
c.fillText(`${d[i]}`, left + (i + 0.25) * step, endY + 10);
c.moveTo(left + 1 + i * step, endY);
c.lineTo(left + 1 + i * step, endY2);
}
c.stroke();
c.closePath();
}
return { step };
};
// 绘制坐标轴辅助线
export const drawAssistLine = ({ c, d, btm, left, right, top, axs, maxVal }) => {
const yht = btm - top;
let len;
let step;
let assist;
let endY;
let space;
let valRate;
if (!d) {
assist = axs.assist || (axs.y && axs.y.assist);
space = axs.space || (axs.y && axs.y.space);
len = Math.ceil(maxVal / space);
step = yht / len;
endY = top + 1;
valRate = step / space;
} else {
// x轴的兼容
}
getLineConfig(c, assist);
for (let i = 0; i <= len; i++) {
c.beginPath();
const text = `${i * space}`;
c.fillText(text, left - 10, btm - i * step - 5);
if (i === len) {
c.moveTo(left, endY);
c.lineTo(right, endY);
} else if (i !== 0) {
c.moveTo(left, btm - i * step);
c.lineTo(right, btm - i * step);
}
c.stroke();
c.closePath();
}
return { valRate };
};
// 绘制折线上的小圆点
export const drawLinePoint = (c, x, y, lineStyle) => {
c.beginPath();
c.fillStyle = lineStyle;
c.arc(x, y, 3, 0, 2 * Math.PI);
c.closePath();
c.fill();
};
// 绘制折线
const drawLinePaths = (idx, sx, l, data, rate, ctx, btm) => {
let yR = 0.1;
// 获取当前点x,y坐标
const curX = l + (idx + 0.5) * sx;
const curY = btm - data.values[idx] * rate;
// 获取上一个点x,y坐标
const preX = l + (idx - 0.5) * sx;
const preY = btm - data.values[idx - 1] * rate;
const difX = curX - preX;
const difY = curY - preY;
ctx.beginPath();
const drawLinePath = (px, py) => {
yR += 0.2;
// 绘制线条
ctx.beginPath();
ctx.strokeStyle = data.strokeStyle;
ctx.moveTo(px, py);
const drawX = preX + difX * (yR > 1 ? 1 : yR);
const drawY = preY + difY * (yR > 1 ? 1 : yR);
ctx.lineTo(drawX, drawY);
ctx.stroke();
if (yR <= 1) {
requestAnimationFrame(() => drawLinePath(drawX, drawY));
}
if (yR >= 1 && idx < data.values.length) {
drawLinePoint(ctx, drawX, drawY, data.strokeStyle);
// 第一节的线条完成后,绘制第二节的线段
requestAnimationFrame(() => drawLinePaths(idx + 1, sx, l, data, rate, ctx, btm));
}
};
drawLinePath(preX, preY);
};
// 绘制折线
export const drawLine = (ctx, data, step_x, left, btm, valRate) => {
ctx.setLineDash([]);
ctx.lineWidth = 2;
for (let i = 0, len = data.length; i < len; i++) {
// 绘制第一个线条
drawLinePaths(1, step_x, left, data[i], valRate, ctx, btm);
// 绘制第一个点
drawLinePoint(ctx, left + 0.5 * step_x, btm - data[i].values[0] * valRate, data[i].strokeStyle);
}
};
const ChartLine: React.FunctionComponent<ChartProps> = (props) => {
const { xData, data, title, subTitle, axis } = props;
const boxRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const [{ w, h, ctx }, setConfig] = React.useState({ w: null, h: null, ctx: null });
useListenDomSize(
boxRef,
React.useCallback(() => {
if (boxRef.current) {
setConfig((o) => ({
...o,
w: boxRef.current.offsetWidth,
h: boxRef.current.offsetHeight,
}));
}
}, []),
300,
);
React.useEffect(() => {
if (canvasRef.current) {
setConfig((o) => ({
...o,
ctx: canvasRef.current.getContext('2d'),
}));
}
}, []);
// 绘制分类
const drawThumb = React.useCallback((c, d) => {
d.forEach((it) => {
if (it.thumbnail) {
const { x, y, w: wt, h: ht } = it.thumbnail;
c.beginPath();
c.strokeStyle = it.strokeStyle;
c.moveTo(x, y + ht / 2);
c.lineTo(x + wt, y + ht / 2);
c.stroke();
c.closePath();
drawLinePoint(c, x + wt / 2, y + ht / 4 + 4, it.strokeStyle);
if (it.text && it.text.text) {
c.font = it.text.font;
c.fillStyle = it.text.fillStyle;
c.fillText(`${it.text.text}`, it.text.x, it.text.y);
}
}
});
}, []);
// 最大值
const maxVal = React.useMemo(() => {
let values = [];
for (let j = 0, len = data.length; j < len; j++) {
values = [...values, ...data[j].values];
}
const v = Math.max(...values);
return v;
}, [data]);
React.useEffect(() => {
if (ctx && w) {
// 绘制标题
drawText(ctx, title);
// 绘制副标题
drawText(ctx, subTitle);
// 绘制x轴
drawAxis(ctx, axis, w, h, 'x');
// 绘制y轴
const { btm, left, right, top } = drawAxis(ctx, axis, w, h, 'y');
// 绘制圆角矩形
drawThumb(ctx, data);
// 绘制x轴分割点
const { step: step_x } = drawSplitPoint({
c: ctx,
d: xData,
btm,
left,
axs: axis,
right,
});
// 绘制y轴辅助线和分界值
const { valRate } = drawAssistLine({
c: ctx,
d: null,
btm,
left,
axs: axis,
right,
top,
maxVal,
});
// 绘制折线
drawLine(ctx, data, step_x, left, btm, valRate);
}
}, [ctx, w, h, data, maxVal, title, subTitle, axis, drawThumb, xData]);
return (
<div ref={boxRef} style={{ width: '100%', height: '100%' }}>
<canvas ref={canvasRef} width={w} height={h} />
</div>
);
};
export default ChartLine;
demo
js
// x轴数据
const xData = [
'1月',
'2月',
'3月',
'4月',
'5月',
'6月',
'7月',
'8月',
'9月',
'10月',
'11月',
'12月',
];
// value对应的数据对象
const data = [
{
name: '蒸发量',
strokeStyle: 'skyblue',
thumbnail: {
x: 150,
y: 10,
w: 24,
h: 16,
r: 4,
},
text: {
text: '蒸发量',
fillStyle: 'skyblue',
font: '14px Arial',
x: 185,
y: 12,
},
values: [
4.0, 40.9, 7.0, 23.2, 25.6, 76.7, 135.6, 162.2, 32.6, 210, 6.4, 200,
],
},
{
name: '降水量',
strokeStyle: 'pink',
thumbnail: {
x: 250,
y: 10,
w: 24,
h: 16,
r: 4,
},
text: {
text: '降水量',
fillStyle: 'pink',
font: '14px Arial',
x: 285,
y: 12,
},
values: [
2.6, 5.9, 9.0, 26.4, 28.7, 70.7, 175.6, 182.2, 48.7, 18.8, 6.0, 250,
],
},
];
// 标题
const title = {
font: '24px bold Arial',
fillStyle: '#222',
textBaseline: 'top',
x: 10,
y: 10,
text: '这是标题',
};
// 副标题
const subTitle = {
font: '14px Arial',
fillStyle: '#ccc',
textBaseline: 'top',
x: 10,
y: 50,
text: '这是副标题',
};
// 坐标轴样式
const axis = {
x: {
splitPoint: {
strokeStyle: 'black',
fillStyle: 'black',
lineWidth: 1,
},
},
y: {
assist: {
strokeStyle: 'black',
fillStyle: 'black',
lineWidth: 0.2,
textAlign: 'right',
lineCap: 'round',
lineDash: [5, 5],
},
space: 50,
},
strokeStyle: 'blue',
margin: {
x: 50,
top: 120,
bottom: 50,
},
};
export default () => {
return (
<div style={{ width: '100%', height: '32rem' }}>
<ChartLine
xData={xData}
data={data}
title={title}
subTitle={subTitle}
axis={axis}
/>
</div>
);
};
展示效果(加载时会有动画效果)
总结
- 监听尺寸大小用到了
ResizeObserver
,该API存在兼容性问题 - 目前折线已经实现了动画效果,但是拐点还未实现
- 功能比较单一,主要是提供一种思路,思路有了,剩下的就简单很多