【前端功能点】折线图实现

总体思路

代码实现

  1. 保存画布宽高以及绘制对象
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,
    };
};
  1. 绘制分类样式
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存在兼容性问题
  • 目前折线已经实现了动画效果,但是拐点还未实现
  • 功能比较单一,主要是提供一种思路,思路有了,剩下的就简单很多
相关推荐
前端大卫7 分钟前
Vue3 + Element-Plus 自定义虚拟表格滚动实现方案【附源码】
前端
却尘23 分钟前
Next.js 请求最佳实践 - vercel 2026一月发布指南
前端·react.js·next.js
ccnocare24 分钟前
浅浅看一下设计模式
前端
Lee川27 分钟前
🎬 从标签到屏幕:揭秘现代网页构建与适配之道
前端·面试
Ticnix1 小时前
ECharts初始化、销毁、resize 适配组件封装(含完整封装代码)
前端·echarts
纯爱掌门人1 小时前
终焉轮回里,藏着 AI 与人类的答案
前端·人工智能·aigc
twl1 小时前
OpenClaw 深度技术解析
前端
崔庆才丨静觅1 小时前
比官方便宜一半以上!Grok API 申请及使用
前端
星光不问赶路人1 小时前
vue3使用jsx语法详解
前端·vue.js
天蓝色的鱼鱼1 小时前
shadcn/ui,给你一个真正可控的UI组件库
前端