前言
ui评审会上,ui小姐姐问我这个图表能做嘛
我一看,天塌啦!
做这么麻烦的图不得手撸canvans,还怎么愉快的摸鱼,但是👨又不能说不行,只能开干了
需求分析
我需要实现的功能:
- 图例显示 :
- 没有车辆巡查
- 有车辆巡查
- 当前选中
- U型时间轴 :
- 固定时间从7:30到22:30均匀分布
- 扩展时间是不固定的,根据数据动态显示
- 节点圆点使用了指定的渐变背景色(白色到蓝色)
- 根据数据动态显示有无巡查的状态
- 交互功能 :
- 时间段可点击,点击后变为选中状态(绿色)
- 选中时在图中心显示该时段的车辆巡查数量
- 未选中时显示总巡查数量
- 无数据的时段不能点击
- 鼠标指向时间轴时出现pointer指针
- 响应式设计 :
- 组件会根据容器大小自适应调整
- 监听窗口大小变化,自动重绘Canvas
需求是相对明确的,于是我拿起了我半吊子的canvas功底花了1天多的时间干完了
具体实现
1. 组件结构与状态管理
组件接受的输入值为开始点,结束点和数量,考虑到方便计算,选中时间的点我选择使用了开始点位,即startTime
interface PatrolTimeDistributionProps {
data?: {
startTime: string; // 时间段开始时间
endTime: string; // 时间段结束时间
count: number; // 巡查数量
}[];
}
// 状态管理
const [selectedTime, setSelectedTime] = useState<string>();
2. 核心功能实现
- 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);
- 时间段数据处理
扩展时间段如果输入的数据存在的话就需要构建新的坐标轴数据
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]);
- 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);
- 交互处理
在用户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";
}
};
- 视觉效果处理
组件大量使用渐变色
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. 特色功能
- 规定时段与扩展时段
- 通过
drawSelected
函数绘制不同区域背景 - 添加说明文字标识不同区域
- 智能时间点显示
- 动态计算开始和结束时间
- 使用 "before" 和 "after" 标记边界时段
- 高亮状态管理
const highLightTimeSlot = (time: string) => {
return data.some(item =>
(item.endTime === time || item.startTime === time) && item.count > 0
);
};
- 点击弹窗交互
- 选中时段后显示详细信息
- 支持点击查看更多巡查详情
4. 性能优化
- 状态更新优化
useEffect(() => {
setSelectedTime(undefined);
}, [data]);
- 重绘控制
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,
},
]}
/>
最终效果
非常的还原!!
完整代码
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;
感谢阅读!!