确定实现的目标
- 主标题、副标题
- 分类色块
- x/y轴(轴、辅助线、分割点、分割点对应的值)
- 柱状(包含动画)
实现思路
- 通过
ref
获取盒子的宽高
、绘制对象ctx
- 标题、轴数据通过
props
获取 - 标题、副标题、分类色块根据数据直接绘制即可
轴分割点、轴辅助线、轴分割点对应的值、柱状需要依赖盒子的宽高
- 监听元素尺寸变化,变化则会重新绘制图形
具体实现(只写组件内部逻辑)
- 获取宽高、绘制对象
js
// 定义保存外层盒子的ref
const boxRef = React.useRef<HTMLDivElement>(null);
// 定义保存canvas元素的ref
const canvasRef = React.useRef<HTMLCanvasElement>(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>
)
- 绘制标题、副标题、色块
js
// 绘制标题
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);
}
};
// 绘制圆角矩形
const drawThumb = React.useCallback((c, d) => {
if (!c || !Array.isArray(d)) return;
d.forEach((it) => {
if (it.thumbnail) {
const { x, y, w, h, r } = it.thumbnail;
c.beginPath();
c.fillStyle = it.bg;
drawRoundedRect(c, x, y, w, h, r);
c.fill();
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);
}
}
});
}, []);
React.useEffect(() => {
if (ctx) {
// 绘制标题
drawText(ctx, title);
// 绘制副标题
drawText(ctx, subTitle);
// 绘制圆角矩形
drawThumb(ctx, data);
}
}, [ctx, w, h]);
- 绘制坐标轴以及辅助线和分割点
js
// 绘制坐标轴分割点
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);
}
drawLine(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 };
};
// 绘制坐标轴辅助线
const drawAssistLine = ({ c, d, btm, left, right, top, axs }) => {
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轴的兼容
}
drawLine(c, assist);
for (let i = 0; i <= len; i++) {
c.beginPath();
const text = `${i * space}`;
ctx.fillText(text, left - 10, btm - i * step - 5);
if (i === len) {
ctx.moveTo(left, endY);
ctx.lineTo(right, endY);
} else if (i !== 0) {
ctx.moveTo(left, btm - i * step);
ctx.lineTo(right, btm - i * step);
}
c.stroke();
c.closePath();
}
return { valRate };
};
React.useEffect(() => {
if (ctx) {
// 绘制标题
drawText(ctx, title);
// 绘制副标题
drawText(ctx, subTitle);
// 绘制圆角矩形
drawThumb(ctx, data);
// 绘制x轴
drawAxis(ctx, axis, w, h, 'x');
// 绘制y轴
const { btm, left, right, top } = drawAxis(ctx, axis, w, h, 'y');
// 绘制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,
});
}
}, [ctx, w, h]);
- 绘制柱状
js
// 绘制柱状
const drawBar = (step_x, left, btm, valRate) => {
const zw = (step_x - 6) / data.length;
let y_h = 0.1;
const drawZhu = (x, v, y_idx, idx) => {
const yh = y_h * v;
ctx.fillStyle = data[y_idx].bg;
ctx.beginPath();
drawRoundedRect(ctx, x, btm - yh, zw, yh, 4);
ctx.fill();
if (y_h < 1) {
if (y_idx === data.length - 1 && idx === data[y_idx].values.length - 1) {
// 刚好走完一轮
if (y_h > 0.99) {
y_h = 1;
} else {
y_h += 0.1 - y_h / 10;
}
}
requestAnimationFrame(() => drawZhu(x, v, y_idx, idx));
}
};
for (let i = 0; i < data.length; i++) {
const values = data[i].values;
for (let j = 0; j < values.length; j++) {
const value = values[j] * valRate;
const x = left + 2 + j * step_x + i * (zw + 2);
drawZhu(x, value, i, j);
}
}
};
React.useEffect(() => {
if (ctx) {
// 绘制标题
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,
});
// 绘制柱状
drawBar(step_x, left, btm, valRate);
}
}, [ctx, w, h]);
全部代码
types.ts
js
export interface ChartBarText {
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 ChartBarData {
name: string;
bg: string;
values: number[];
text?: ChartBarText;
thumbnail?: {
x?: number;
y?: number;
w?: number;
h?: number;
r?: number;
};
}
export interface ChartBarProps {
data: ChartBarData[];
xData: (string | number)[];
axis: Axis;
title?: ChartBarText;
subTitle?: ChartBarText;
}
utils.ts
js
/**
* @description debounce 防抖函数
* @param {func} func 需要防抖的函数
* @param {Number} delay 时间
*/
export const debounce = (func: Func, delay: number = 500) => {
let timer: any = null;
return () => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
if (typeof func === 'function') func();
clearTimeout(timer);
}, delay);
};
};
/**
* 绘制圆角
* @param ctx 绘制对象
* @param x
* @param y
* @param w
* @param h
* @param r
*/
export const drawRoundedRect = (ctx, x, y, w, h, r) => {
if (typeof ctx.roundRect === 'function') {
ctx.roundRect(x, y, w, h, r);
} else {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}
};
hooks.ts
js
import { type RefObject, useEffect } from 'react';
import { debounce } from '../utils';
/**
* useListenDomSize 监听节点尺寸变化
* @param dom 要监听尺寸变化的节点
* @param callback 尺寸变化时的回调
*/
export const useListenDomSize = (
elRef: RefObject<HTMLElement>,
callback?: (p?: any) => void,
time = 3000,
) => {
useEffect(() => {
if (elRef.current && typeof callback === 'function') {
if (window.ResizeObserver) {
const cb = debounce(callback, time);
const domObserver = new ResizeObserver(cb);
domObserver.observe(elRef.current);
return () => {
if (elRef.current) {
domObserver.unobserve(elRef.current);
domObserver.disconnect();
}
};
}
}
return () => null;
}, [elRef, callback]);
};
index.tsx
js
import React from 'react';
import type { ChartBarProps } from './types';
import { useListenDomSize } from './hooks';
import { drawRoundedRect } from './utils';
const ChartBar: React.FunctionComponent<ChartBarProps> = (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 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);
}
};
// 绘制坐标轴
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 {};
};
// 绘制圆角矩形
const drawThumb = React.useCallback((c, d) => {
if (!c || !Array.isArray(d))) return;
d.forEach((it) => {
if (it.thumbnail) {
const { x, y, w, h, r } = it.thumbnail;
c.beginPath();
c.fillStyle = it.bg;
drawRoundedRect(c, x, y, w, h, r);
c.fill();
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]);
// 绘制线条
const drawLine = (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) {
ctx.setLineDash(o.lineDash);
}
}
};
// 绘制坐标轴分割点
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);
}
drawLine(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 };
};
// 绘制坐标轴辅助线
const drawAssistLine = ({ c, d, btm, left, right, top, axs }) => {
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轴的兼容
}
drawLine(c, assist);
for (let i = 0; i <= len; i++) {
c.beginPath();
const text = `${i * space}`;
ctx.fillText(text, left - 10, btm - i * step - 5);
if (i === len) {
ctx.moveTo(left, endY);
ctx.lineTo(right, endY);
} else if (i !== 0) {
ctx.moveTo(left, btm - i * step);
ctx.lineTo(right, btm - i * step);
}
c.stroke();
c.closePath();
}
return { valRate };
};
// 绘制柱状
const drawBar = (step_x, left, btm, valRate) => {
const zw = (step_x - 6) / data.length;
let y_h = 0.1;
const drawZhu = (x, v, y_idx, idx) => {
const yh = y_h * v;
ctx.fillStyle = data[y_idx].bg;
ctx.beginPath();
drawRoundedRect(ctx, x, btm - yh, zw, yh, 4);
ctx.fill();
if (y_h < 1) {
if (y_idx === data.length - 1 && idx === data[y_idx].values.length - 1) {
// 刚好走完一轮
if (y_h > 0.99) {
y_h = 1;
} else {
y_h += 0.1 - y_h / 10;
}
}
requestAnimationFrame(() => drawZhu(x, v, y_idx, idx));
}
};
for (let i = 0; i < data.length; i++) {
const values = data[i].values;
for (let j = 0; j < values.length; j++) {
const value = values[j] * valRate;
const x = left + 2 + j * step_x + i * (zw + 2);
drawZhu(x, value, i, j);
}
}
};
React.useEffect(() => {
if (ctx) {
// 绘制标题
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,
});
// 绘制柱状
drawBar(step_x, left, btm, valRate);
}
}, [ctx, w, h]);
return (
<div ref={boxRef} style={{ width: '100%', height: '100%' }}>
<canvas ref={canvasRef} width={w} height={h} />
</div>
);
};
export default ChartBar;
demo
js
import { ChartBar } from '@esy-ui';
// x轴的值
const xData = [
'1月',
'2月',
'3月',
'4月',
'5月',
'6月',
'7月',
'8月',
'9月',
'10月',
'11月',
'12月',
];
// 每个分类柱状的高度值
const data = [
{
name: '蒸发量',
bg: 'skyblue',
thumbnail: {
x: 150,
y: 10,
w: 24,
h: 16,
r: 4,
},
text: {
text: '蒸发量',
fillStyle: 'skyblue',
font: '14px Arial',
x: 185,
y: 12,
},
values: [
2.0, 4.9, 7.0, 23.2, 25.6, 76.7, 135.6, 162.2, 32.6, 210, 6.4, 200,
],
},
{
name: '降水量',
bg: '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' }}>
<ChartBar
xData={xData}
data={data}
title={title}
subTitle={subTitle}
axis={axis}
/>
</div>
);
};
最终效果图(代码执行会有动画)
总结
- 目前功能单一,没有平均值、极值、鼠标以上等效果
- 主要是提供一种思路,思路确定后实现起来就会简单很多
- 尺寸发生变化时是通过
ResizeObserver
实现的,有兼容性问题