canvas 手撸U型时刻图

0 阅读9分钟

前言

ui评审会上,ui小姐姐问我这个图表能做嘛

我一看,天塌啦!

做这么麻烦的图不得手撸canvans,还怎么愉快的摸鱼,但是👨又不能说不行,只能开干了

需求分析

我需要实现的功能:

  1. 图例显示 :
    • 没有车辆巡查
    • 有车辆巡查
    • 当前选中
  2. U型时间轴 :
    • 固定时间从7:30到22:30均匀分布
    • 扩展时间是不固定的,根据数据动态显示
    • 节点圆点使用了指定的渐变背景色(白色到蓝色)
    • 根据数据动态显示有无巡查的状态
  3. 交互功能 :
    • 时间段可点击,点击后变为选中状态(绿色)
    • 选中时在图中心显示该时段的车辆巡查数量
    • 未选中时显示总巡查数量
    • 无数据的时段不能点击
    • 鼠标指向时间轴时出现pointer指针
  4. 响应式设计 :
    • 组件会根据容器大小自适应调整
    • 监听窗口大小变化,自动重绘Canvas

需求是相对明确的,于是我拿起了我半吊子的canvas功底花了1天多的时间干完了

具体实现

1. 组件结构与状态管理

组件接受的输入值为开始点,结束点和数量,考虑到方便计算,选中时间的点我选择使用了开始点位,即startTime

interface PatrolTimeDistributionProps {
  data?: {
    startTime: string; // 时间段开始时间
    endTime: string;   // 时间段结束时间
    count: number;     // 巡查数量
  }[];
}

// 状态管理
const [selectedTime, setSelectedTime] = useState<string>();

2. 核心功能实现

  1. Canvas 尺寸与像素比优化

在初版绘制完成后,我发现组件显示时非常糊,不清晰,查询了下主要原因是Canvas的尺寸设置可能不匹配设备像素比(DPR),导致在高分辨率屏幕上模糊,所以需要专门优化下

// 固定设计尺寸
const designWidth = 572;
const designHeight = 300;

// DPR优化
const dpr = window.devicePixelRatio || 1;
canvas.width = designWidth * dpr;
canvas.height = designHeight * dpr;
ctx.scale(dpr, dpr);
  1. 时间段数据处理

扩展时间段如果输入的数据存在的话就需要构建新的坐标轴数据

const timeSlots = useMemo(() => {
  const startTime = data.find((item) => item.endTime === "07:30")?.startTime;
  const endTime = data.find((item) => item.startTime === "22:30")?.endTime;
  return [
    startTime ? startTime : "before",
    "07:30", "08:30", /* ... */ "22:30",
    endTime ? endTime : "after"
  ];
}, [data]);
  1. U型轴绘制逻辑
  • 上部分:直线段(6个时间点)
  • 右侧:曲线段(5个时间点)
  • 底部:直线段(剩余时间点)
// 计算关键尺寸
const padding = 50;
const topY = padding;
const bottomY = designHeight - padding;
const leftX = padding;
const rightX = designWidth - padding;
const curveRadius = (bottomY - topY) / 2;

// 分段处理时间点
const topTimeSlots = timeSlots.slice(0, 6);
const rightTimeSlots = timeSlots.slice(6, 11);
const bottomTimeSlots = timeSlots.slice(11);
  1. 交互处理

在用户hover或者click时,根据用户鼠标位置去计算用户这时候处于什么时间段

const handleMouseEvent = (e: React.MouseEvent<HTMLCanvasElement>, type?: "click" | "move") => {
  // 计算点击位置
  const x = e.clientX - rect.left;
  const y = e.clientY - rect.top;

  // 检测点击区域(直线段和曲线段)
  const clickRadius = 20;
  
  // 根据区域计算距离并处理点击事件
  if (distanceToSegment(y, segmentY) <= clickRadius) {
    if (type === "click") {
      setSelectedTime(timeSlot);
      drawCanvas();
    }
    canvas.style.cursor = "pointer";
  }
};
  1. 视觉效果处理

组件大量使用渐变色

const drawTimeNode = (ctx: CanvasRenderingContext2D, x: number, y: number, time: string) => {
  // 节点渐变效果
  const gradient = ctx.createLinearGradient(x - 7, y - 7, x + 7, y + 7);
  gradient.addColorStop(0.14286, "rgb(255, 255, 255)");
  gradient.addColorStop(0.85714, "rgb(46, 161, 255)");
  
  // 绘制节点和文本
  ctx.arc(x, y, 7, 0, Math.PI * 2);
  ctx.fillStyle = gradient;
  ctx.fill();
};

3. 特色功能

  1. 规定时段与扩展时段
  • 通过 drawSelected 函数绘制不同区域背景
  • 添加说明文字标识不同区域
  1. 智能时间点显示
  • 动态计算开始和结束时间
  • 使用 "before" 和 "after" 标记边界时段
  1. 高亮状态管理
const highLightTimeSlot = (time: string) => {
  return data.some(item => 
    (item.endTime === time || item.startTime === time) && item.count > 0
  );
};
  1. 点击弹窗交互
  • 选中时段后显示详细信息
  • 支持点击查看更多巡查详情

4. 性能优化

  1. 状态更新优化
useEffect(() => {
  setSelectedTime(undefined);
}, [data]);
  1. 重绘控制
useEffect(() => {
  drawCanvas();
  window.addEventListener("resize", drawCanvas);
  return () => {
    window.removeEventListener("resize", drawCanvas);
  };
}, [data, selectedTime, timeSlots]);

最终实现效果

输入的数据

   <PatrolTimeDistribution
                  data={[
                    {
                      startTime: "02:00",
                      endTime: "07:30",
                      count: 100,
                    },
                    {
                      startTime: "07:30",
                      endTime: "08:30",
                      count: 30,
                    },
                    {
                      startTime: "08:30",
                      endTime: "09:30",
                      count: 20,
                    },
                    {
                      startTime: "10:30",
                      endTime: "11:30",
                      count: 10,
                    },
                    {
                      startTime: "11:30",
                      endTime: "12:30",
                      count: 10,
                    },
                    {
                      startTime: "13:30",
                      endTime: "14:30",
                      count: 10,
                    },
                    {
                      startTime: "14:30",
                      endTime: "15:30",
                      count: 10,
                    },
                    {
                      startTime: "15:30",
                      endTime: "16:30",
                      count: 10,
                    },
                  ]}
                />

最终效果

录屏2025-05-30 16.39.21.gif

非常的还原!!

完整代码

import { useGlobalModalServices } from "@/pages/GlobalModalServices/provider";
import { message } from "antd";
import React, { useRef, useEffect, useState, useMemo } from "react";

interface PatrolTimeDistributionProps {
  data?: {
    startTime: string; // 时间段,如 "06:00"
    endTime: string; // 时间段,如 "06:00"
    count: number; // 该时段的巡查数量
  }[];
}

const designWidth = 572;
const designHeight = 300;

const PatrolTimeDistribution: React.FC<PatrolTimeDistributionProps> = ({
  data = [],
}) => {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [selectedTime, setSelectedTime] = useState<string>();
  const { dispatch } = useGlobalModalServices();
  // 定义颜色常量
  const colors = {
    noPatrol: "rgb(0, 21, 43)", // 没有车辆巡查
    hasPatrol: "rgb(39, 91, 161)", // 有车辆巡查
    selected: "rgb(85, 255, 156)", // 当前选中
  };

  /** data变化时重置选中 */
  useEffect(() => {
    setSelectedTime(undefined);
  }, [data]);

  // 定义时间段
  const timeSlots = useMemo(() => {
    const startTime = data.find((item) => item.endTime === "07:30")?.startTime;
    const endTime = data.find((item) => item.startTime === "22:30")?.endTime;
    return [
      startTime ? startTime : "before",
      "07:30",
      "08:30",
      "09:30",
      "10:30",
      "11:30",
      "12:30",
      "13:30",
      "14:30",
      "15:30",
      "16:30",
      "17:30",
      "18:30",
      "19:30",
      "20:30",
      "21:30",
      "22:30",
      endTime ? endTime : "after",
    ];
  }, [data]);

  // 检查时间段是否有巡查数据
  const hasPatrolData = (time: string) => {
    return data.some((item) => item.startTime === time && item.count > 0);
  };
  /** 检查是否高亮时间节点 */
  const highLightTimeSlot = (time: string) => {
    return data.some(
      (item) =>
        (item.endTime === time || item.startTime === time) && item.count > 0
    );
  };

  // 获取时间段的巡查数量
  const getPatrolCount = (time: string) => {
    const item = data.find((item) => item.startTime === time);
    return item ? Number(item.count) : 0;
  };

  const drawSelected = (
    ctx: CanvasRenderingContext2D,
    leftX: number,
    rightX: number,
    topY: number,
    bottomY: number,
    curveRadius: number,
    topSegmentLength: number
  ) => {
    ctx.beginPath();
    ctx.moveTo(leftX + topSegmentLength, topY);
    ctx.lineTo(rightX - curveRadius, topY);
    ctx.arc(
      rightX - curveRadius,
      topY + curveRadius,
      curveRadius,
      -Math.PI / 2,
      Math.PI / 2
    );
    ctx.lineTo(leftX + topSegmentLength, bottomY);

    ctx.lineTo(leftX + topSegmentLength, topY);
    ctx.fillStyle = "rgba(37, 77, 123, 0.2)";
    ctx.fill();

    ctx.moveTo(leftX, topY);
    ctx.lineTo(leftX + topSegmentLength, topY);
    ctx.lineTo(leftX + topSegmentLength, bottomY);
    ctx.lineTo(leftX, bottomY);
    ctx.lineTo(leftX, topY);
    ctx.fillStyle = "rgba(6, 29, 55, 0.5)";
    ctx.fill();

    if (!selectedTime) {
      /** 设置文字 */
      ctx.fillStyle = "rgba(154, 194, 255,28%)";
      ctx.textAlign = "center";
      ctx.font = "20px DD";
      ctx.fillText("规定", leftX + topSegmentLength * 4, topY + curveRadius);
      ctx.fillText(
        "时段",
        leftX + topSegmentLength * 4,
        topY + curveRadius + 20
      );
      ctx.fillText("扩展", leftX + topSegmentLength / 2, topY + curveRadius);
      ctx.fillText(
        "时段",
        leftX + topSegmentLength / 2,
        topY + curveRadius + 20
      );
      ctx.closePath();
    }
  };

  // 绘制Canvas
  const drawCanvas = () => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    // 获取设备像素比
    const dpr = window.devicePixelRatio || 1;
    // 设置Canvas的实际尺寸,考虑设备像素比
    canvas.width = designWidth * dpr;
    canvas.height = designHeight * dpr;

    // 确保Canvas的CSS尺寸保持不变
    canvas.style.width = `${designWidth}px`;
    canvas.style.height = `${designHeight}px`;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;
    // 缩放绘图上下文以匹配设备像素比
    ctx.scale(dpr, dpr);
    // 清空画布
    ctx.clearRect(0, 0, canvas.width, canvas.height);

    // 计算U型时间轴的尺寸和位置
    const padding = 50;
    const lineWidth = 10; // 线宽
    const topY = padding;
    const bottomY = designHeight - padding;
    const leftX = padding;
    const rightX = designWidth - padding;
    const curveRadius = (bottomY - topY) / 2;

    // 绘制U型轨道背景
    ctx.beginPath();
    ctx.lineWidth = lineWidth;
    // 计算时间点位置
    const topTimeSlots = timeSlots.slice(0, 6); // 上半部分时间点 (06:00 - 12:30)
    const rightTimeSlots = timeSlots.slice(6, 11); // 右侧时间点 (13:30 - 18:30)
    const bottomTimeSlots = timeSlots.slice(11); // 下半部分时间点 (19:30 - 23:00)

    // // 绘制上半部分时间点和线段
    const topSegmentLength =
      (rightX - curveRadius - leftX) / topTimeSlots.length;

    const centerPoint = {
      x: rightX - curveRadius,
      y: topY + curveRadius,
    };

    drawSelected(
      ctx,
      leftX,
      rightX,
      topY,
      bottomY,
      curveRadius,
      topSegmentLength
    );

    topTimeSlots.forEach((time, index) => {
      const x = leftX + index * topSegmentLength;
      const hasData = hasPatrolData(time);
      const isSelected = selectedTime === time;

      ctx.beginPath();
      ctx.moveTo(x, topY);
      ctx.lineTo(x + topSegmentLength, topY);
      ctx.strokeStyle = hasData ? colors.hasPatrol : colors.noPatrol;
      if (isSelected) {
        ctx.strokeStyle = colors.selected;
      }
      ctx.stroke();
    });

    // 绘制右侧曲线部分时间点和线段
    const angleStep = Math.PI / rightTimeSlots.length;

    rightTimeSlots.forEach((time, index) => {
      const angle = index * angleStep;
      const hasData = hasPatrolData(time);
      const isSelected = selectedTime === time;
      ctx.beginPath();
      ctx.arc(
        centerPoint.x,
        centerPoint.y,
        curveRadius,
        angle - Math.PI / 2,
        angle + angleStep - Math.PI / 2
      );
      ctx.strokeStyle = hasData ? colors.hasPatrol : colors.noPatrol;
      if (isSelected) {
        ctx.strokeStyle = colors.selected;
      }
      ctx.stroke();
    });

    bottomTimeSlots.forEach((time, index) => {
      const x = rightX - curveRadius - index * topSegmentLength;
      const hasData = hasPatrolData(time);
      const isSelected = selectedTime === time;
      if (index != bottomTimeSlots.length - 1) {
        ctx.beginPath();
        ctx.moveTo(x, bottomY);
        ctx.lineTo(x - topSegmentLength, bottomY);
        ctx.strokeStyle = hasData ? colors.hasPatrol : colors.noPatrol;
        if (isSelected) {
          ctx.strokeStyle = colors.selected;
        }
        ctx.stroke();
      }
    });

    // 绘制时间节点

    topTimeSlots.forEach((time, index) => {
      const x = leftX + index * topSegmentLength;
      drawTimeNode(ctx, x, topY, time, designHeight);
    });

    rightTimeSlots.forEach((time, index) => {
      const angle = index * angleStep;
      const x = centerPoint.x + Math.sin(angle) * curveRadius;
      const y = centerPoint.y - Math.cos(angle) * curveRadius;
      drawTimeNode(ctx, x, y, time, designHeight);
    });

    bottomTimeSlots.forEach((time, index) => {
      const x = rightX - curveRadius - index * topSegmentLength;
      drawTimeNode(ctx, x, bottomY, time, designHeight);
    });
  };

  // 绘制时间节点
  const drawTimeNode = (
    ctx: CanvasRenderingContext2D,
    x: number,
    y: number,
    time: string,
    canvasHeight: number
  ) => {
    const hightLight = highLightTimeSlot(time);

    // 绘制节点圆点
    ctx.beginPath();
    ctx.arc(x, y, 7, 0, Math.PI * 2);
    // 创建球体渐变填充
    const hasDataGradient = ctx.createLinearGradient(
      x - 7,
      y - 7,
      x + 7,
      y + 7
    );
    hasDataGradient.addColorStop(0.14286, "rgb(255, 255, 255)");
    hasDataGradient.addColorStop(0.85714, "rgb(46, 161, 255)");
    const noDataGradient = ctx.createLinearGradient(x - 7, y - 7, x + 7, y + 7);
    noDataGradient.addColorStop(0.14286, "rgb(107, 126, 178)");
    noDataGradient.addColorStop(0.85714, "rgb(40, 66, 87)");

    ctx.fillStyle = hightLight ? hasDataGradient : noDataGradient;
    ctx.fill();
    if (time == "before" || time == "after") {
      return;
    }
    // 绘制时间文本
    ctx.font = "20px DD";
    // 根据节点位置调整文本位置
    const textY = y > canvasHeight / 2 ? y + 38 : y - 28;
    let textX = x;
    if (y < canvasHeight - 40 && 40 < y) {
      textX = textX + 20;
    }

    const hasDateTextGradient = ctx.createLinearGradient(
      textX,
      textY - 50,
      textX + 50,
      textY + 50
    );
    hasDateTextGradient.addColorStop(0.14286, "rgb(255, 255, 255)");
    hasDateTextGradient.addColorStop(0.85714, "rgb(46, 161, 255)");
    const noDataTextGradient = ctx.createLinearGradient(
      textX,
      textY - 50,
      textX + 50,
      textY + 50
    );
    noDataTextGradient.addColorStop(0.14286, "rgb(107, 126, 178)");
    noDataTextGradient.addColorStop(0.85714, "rgb(40, 66, 87)");

    ctx.fillStyle = hightLight ? hasDateTextGradient : noDataTextGradient;
    ctx.textAlign = "center";
    ctx.fillText(time, textX, textY);
  };

  // 计算点到线段的距离
  const distanceToSegment = (
    py: number,
    y: number // 点坐标
  ) => {
    return Math.abs(y - py);
  };

  // 计算点到圆弧的距离
  const distanceToArc = (
    px: number,
    py: number, // 点坐标
    cx: number,
    cy: number, // 圆心坐标
    radius: number // 圆弧半径
  ) => {
    // 计算点到圆心的距离
    const dx = px - cx;
    const dy = py - cy;
    const distanceToCenter = Math.sqrt(dx * dx + dy * dy);
    // 计算点到圆的最近距离
    const distanceToCircle = Math.abs(distanceToCenter - radius);
    return distanceToCircle;
  };

  // 处理鼠标移动事件,设置鼠标样式
  const handleMouseEvent = (
    e: React.MouseEvent<HTMLCanvasElement>,
    type?: "click" | "move"
  ) => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    const rect = canvas.getBoundingClientRect();
    const x = e.clientX - rect.left;
    const y = e.clientY - rect.top;

    // 计算U型时间轴的尺寸和位置
    const padding = 40;
    const clickRadius = 20; // 点击判定半径

    const topY = padding;
    const bottomY = designHeight - padding;
    const leftX = padding;
    const rightX = designWidth - padding;
    const curveRadius = (bottomY - topY) / 2;

    // 检查是否在可点击区域内
    let isOverClickableArea = false;

    // 上半部分时间段
    const topTimeSlots = timeSlots.slice(0, 6);
    const topSegmentLength =
      (rightX - curveRadius - leftX) / topTimeSlots.length;

    // 检查上半部分时间段
    for (let i = 0; i < topTimeSlots.length; i++) {
      const startX = leftX + i * topSegmentLength;
      const endX = leftX + (i + 1) * topSegmentLength;
      const segmentY = topY;

      if (
        Math.max(startX, endX) > x &&
        x > Math.min(startX, endX) &&
        clickRadius > distanceToSegment(y, segmentY) &&
        y > segmentY
      ) {
        isOverClickableArea = true;

        if (type === "click") {
          if (getPatrolCount(topTimeSlots[i]) === 0) {
            message.info("该时段无巡查数据");
            return;
          }
          setSelectedTime((state) =>
            state === topTimeSlots[i] ? undefined : topTimeSlots[i]
          );
          drawCanvas();
        }
        break;
      }
    }

    //右侧曲线部分时间段
    if (!isOverClickableArea) {
      const rightTimeSlots = timeSlots.slice(6, 11);
      const angleStep = Math.PI / rightTimeSlots.length;

      for (let i = 0; i < rightTimeSlots.length; i++) {
        const startAngle = Math.PI / 2 - i * angleStep;
        const endAngle = Math.PI / 2 - (i + 1) * angleStep;
        const startY = topY + curveRadius - Math.sin(startAngle) * curveRadius;
        const endY = topY + curveRadius - Math.sin(endAngle) * curveRadius;

        if (
          Math.min(startY, endY) < y &&
          y < Math.max(startY, endY) &&
          x > rightX - curveRadius
        ) {
          const distanceToLine = distanceToArc(
            x,
            y,
            rightX - curveRadius,
            topY + curveRadius,
            curveRadius
          );

          if (distanceToLine < clickRadius) {
            isOverClickableArea = true;
            if (type === "click") {
              if (getPatrolCount(rightTimeSlots[i]) === 0) {
                message.info("该时段无巡查数据");
                return;
              }

              setSelectedTime((state) =>
                state === rightTimeSlots[i] ? undefined : rightTimeSlots[i]
              );
              drawCanvas();
            }
            break;
          }
        }
      }
    }

    //底部时间段;
    if (!isOverClickableArea) {
      const bottomTimeSlots = timeSlots.slice(11);
      for (let i = 0; i < bottomTimeSlots.length - 1; i++) {
        const startX = rightX - curveRadius - i * topSegmentLength;
        const endX = rightX - curveRadius - (i + 1) * topSegmentLength;
        const segmentY = bottomY;

        if (
          startX > x &&
          x > endX &&
          clickRadius > distanceToSegment(y, segmentY) &&
          y < segmentY
        ) {
          isOverClickableArea = true;
          if (type === "click") {
            if (getPatrolCount(bottomTimeSlots[i]) === 0) {
              message.info("该时段无巡查数据");
              return;
            }
            setSelectedTime((state) =>
              state === bottomTimeSlots[i] ? undefined : bottomTimeSlots[i]
            );
            drawCanvas();
          }
          break;
        }
      }
    }
    // 设置鼠标样式
    canvas.style.cursor = isOverClickableArea ? "pointer" : "default";
  };

  // 初始化和窗口大小变化时重绘Canvas
  useEffect(() => {
    drawCanvas();
    window.addEventListener("resize", drawCanvas);
    return () => {
      window.removeEventListener("resize", drawCanvas);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [data, selectedTime, timeSlots]);

  const handleTimeSlotClick = () => {
    const timeSlot = data.find((item) => item.startTime === selectedTime);

    dispatch.push("vehicleDistributionOfPeriod", {
      props: {
        timeSlot: timeSlot,
      },
    });
  };

  return (
    <div className="w-full h-full rounded-lg p-4 flex flex-col">
      {/* Canvas容器 */}
      <div className="flex-1 relative" style={{ minHeight: "280px" }}>
        <canvas
          ref={canvasRef}
          onClick={(e) => handleMouseEvent(e, "click")}
          onMouseMove={(e) => handleMouseEvent(e, "move")}
        />
        {selectedTime && (
          <div
            className="absolute cursor-pointer active:opacity-80 flex items-center justify-center top-[48%] left-1/2 -translate-1/2 w-[367px] h-[169px]  bg-[url(@/assets/bg/bg14.svg)] bg-size-[100%_100%]"
            onClick={handleTimeSlotClick}
          >
            <div className=" base_number font-[DD]! to-[rgb(190,255,199)] text-[20px]!">
              共有
            </div>
            <div className="base_number to-[rgb(154,255,161)] w-[80px] text-center">
              {getPatrolCount(selectedTime)}
            </div>
            <div className=" base_number font-[DD]! to-[rgb(190,255,199)] text-[20px]!">
              辆巡查
            </div>
          </div>
        )}
      </div>
      {/* 图例 */}
      <div className="flex items-center justify-center mb-4 space-x-4">
        <div className="flex items-center">
          <div className="w-8 h-3 bg-[rgb(0,21,43)] mr-2"></div>
          <span className="text-[rgb(172,191,219)] text-sm">没有车辆巡查</span>
        </div>
        <div className="flex items-center">
          <div className="w-8 h-3 bg-[rgb(39,91,161)] mr-2"></div>
          <span className="text-[rgb(172,191,219)] text-sm">有车辆巡查</span>
        </div>
        <div className="flex items-center">
          <div className="w-8 h-3 bg-[rgb(85,255,156)] mr-2"></div>
          <span className="text-[rgb(172,191,219)] text-sm">当前选中</span>
        </div>
      </div>
    </div>
  );
};

export default PatrolTimeDistribution;



感谢阅读!!