【前端功能点】柱状/条形图实现

确定实现的目标

  • 主标题、副标题
  • 分类色块
  • x/y轴(轴、辅助线、分割点、分割点对应的值)
  • 柱状(包含动画)

实现思路

  1. 通过ref获取盒子的宽高、绘制对象ctx
  2. 标题、轴数据通过props获取
  3. 标题、副标题、分类色块根据数据直接绘制即可
  4. 轴分割点、轴辅助线、轴分割点对应的值、柱状需要依赖盒子的宽高
  5. 监听元素尺寸变化,变化则会重新绘制图形

具体实现(只写组件内部逻辑)

  1. 获取宽高、绘制对象
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>
 )
  1. 绘制标题、副标题、色块
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]);
  1. 绘制坐标轴以及辅助线和分割点
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]);
  1. 绘制柱状
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实现的,有兼容性问题
相关推荐
多多米100535 分钟前
初学Vue(2)
前端·javascript·vue.js
柏箱1 小时前
PHP基本语法总结
开发语言·前端·html·php
新缸中之脑1 小时前
Llama 3.2 安卓手机安装教程
前端·人工智能·算法
hmz8561 小时前
最新网课搜题答案查询小程序源码/题库多接口微信小程序源码+自带流量主
前端·微信小程序·小程序
看到请催我学习1 小时前
内存缓存和硬盘缓存
开发语言·前端·javascript·vue.js·缓存·ecmascript
blaizeer2 小时前
深入理解 CSS 浮动(Float):详尽指南
前端·css
编程老船长2 小时前
网页设计基础 第一讲:软件分类介绍、工具选择与课程概览
前端
编程老船长2 小时前
网页设计基础 第二讲:安装与配置 VSCode 开发工具,创建第一个 HTML 页面
前端
速盾cdn2 小时前
速盾:网页游戏部署高防服务器有什么优势?
服务器·前端·web安全
小白求学12 小时前
CSS浮动
前端·css·css3