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

总体思路

代码实现

  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存在兼容性问题
  • 目前折线已经实现了动画效果,但是拐点还未实现
  • 功能比较单一,主要是提供一种思路,思路有了,剩下的就简单很多
相关推荐
神之王楠10 分钟前
如何通过js加载css和html
javascript·css·html
余生H15 分钟前
前端的全栈混合之路Meteor篇:关于前后端分离及与各框架的对比
前端·javascript·node.js·全栈
程序员-珍18 分钟前
使用openapi生成前端请求文件报错 ‘Token “Integer“ does not exist.‘
java·前端·spring boot·后端·restful·个人开发
axihaihai22 分钟前
网站开发的发展(后端路由/前后端分离/前端路由)
前端
流烟默34 分钟前
Vue中watch监听属性的一些应用总结
前端·javascript·vue.js·watch
2401_8572979144 分钟前
招联金融2025校招内推
java·前端·算法·金融·求职招聘
茶卡盐佑星_1 小时前
meta标签作用/SEO优化
前端·javascript·html
与衫1 小时前
掌握嵌套子查询:复杂 SQL 中 * 列的准确表列关系
android·javascript·sql
Ink1 小时前
从底层看 path.resolve 实现
前端·node.js
金灰1 小时前
HTML5--裸体回顾
java·开发语言·前端·javascript·html·html5