|
|
@@ -0,0 +1,279 @@
|
|
|
+import { useEffect, useRef, useImperativeHandle, forwardRef, useMemo, useCallback, useState } from 'react';
|
|
|
+
|
|
|
+interface ProgressRingEndCapProps {
|
|
|
+ progress: number; // 0-100,只绘制末端 3%
|
|
|
+ width?: number;
|
|
|
+ height?: number;
|
|
|
+}
|
|
|
+
|
|
|
+export interface ProgressRingEndCapHandle {
|
|
|
+ redraw: () => void;
|
|
|
+}
|
|
|
+
|
|
|
+// 颜色节点配置数组(按照百分比位置)
|
|
|
+const colorStops = [
|
|
|
+ { percent: 0, color: '#FFC900' },
|
|
|
+ { percent: 11, color: '#FFC900' },
|
|
|
+ { percent: 32, color: '#13A6FF' },
|
|
|
+ { percent: 50, color: '#806EFF' },
|
|
|
+ { percent: 66, color: '#FF8EEC' },
|
|
|
+ { percent: 89, color: '#FE4F51' },
|
|
|
+ { percent: 100, color: '#FE4F51' },
|
|
|
+];
|
|
|
+
|
|
|
+// 根据百分比获取颜色
|
|
|
+const getColorForPercent = (percent: number): string | null => {
|
|
|
+ // 确保百分比在0-100范围内
|
|
|
+ percent = Math.max(0, Math.min(100, percent));
|
|
|
+
|
|
|
+ // 如果进度为0,返回null表示无颜色
|
|
|
+ if (percent === 0) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 找到百分比所在的两个颜色节点
|
|
|
+ for (let i = 0; i < colorStops.length - 1; i++) {
|
|
|
+ const startStop = colorStops[i];
|
|
|
+ const endStop = colorStops[i + 1];
|
|
|
+
|
|
|
+ if (percent >= startStop.percent && percent <= endStop.percent) {
|
|
|
+ // 计算在两个节点之间的比例
|
|
|
+ const t = (percent - startStop.percent) / (endStop.percent - startStop.percent);
|
|
|
+ return interpolateColor(startStop.color, endStop.color, t);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return colorStops[colorStops.length - 1].color;
|
|
|
+};
|
|
|
+
|
|
|
+// 颜色插值函数
|
|
|
+const interpolateColor = (color1: string, color2: string, t: number): string => {
|
|
|
+ // 将十六进制颜色转换为RGB
|
|
|
+ const r1 = parseInt(color1.slice(1, 3), 16);
|
|
|
+ const g1 = parseInt(color1.slice(3, 5), 16);
|
|
|
+ const b1 = parseInt(color1.slice(5, 7), 16);
|
|
|
+
|
|
|
+ const r2 = parseInt(color2.slice(1, 3), 16);
|
|
|
+ const g2 = parseInt(color2.slice(3, 5), 16);
|
|
|
+ const b2 = parseInt(color2.slice(5, 7), 16);
|
|
|
+
|
|
|
+ // 线性插值
|
|
|
+ const r = Math.round(r1 + (r2 - r1) * t);
|
|
|
+ const g = Math.round(g1 + (g2 - g1) * t);
|
|
|
+ const b = Math.round(b1 + (b2 - b1) * t);
|
|
|
+
|
|
|
+ // 转回十六进制
|
|
|
+ return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
|
|
+};
|
|
|
+
|
|
|
+export const ProgressRingEndCap = forwardRef<ProgressRingEndCapHandle, ProgressRingEndCapProps>(
|
|
|
+ ({ progress }, ref) => {
|
|
|
+ const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
|
+ const containerRef = useRef<HTMLDivElement>(null);
|
|
|
+ const [actualSize, setActualSize] = useState({ width: 114, height: 114 });
|
|
|
+
|
|
|
+ // 进度条相关参数(适配响应式容器)
|
|
|
+ const radius = useMemo(() => {
|
|
|
+ // 圆环中心半径,按实际容器尺寸比例缩放
|
|
|
+ // 设计稿 114px 对应 54px 半径
|
|
|
+ // 减小 0.5px 使 Canvas 圆环与 SVG 圆环重合
|
|
|
+ return (actualSize.width / 114) * 54 - 0.5;
|
|
|
+ }, [actualSize.width]);
|
|
|
+
|
|
|
+ const thickness = useMemo(() => {
|
|
|
+ // 圆环厚度,按实际容器尺寸比例缩放
|
|
|
+ // 设计稿 114px 对应 6.5px 厚度
|
|
|
+ return (actualSize.width / 114) * 6.5;
|
|
|
+ }, [actualSize.width]);
|
|
|
+
|
|
|
+ // 角度配置:Canvas 旋转 -180deg(逆时针 180 度)
|
|
|
+ // 底部圆环显示:215° 起始(7点钟方向),顺时针到 503°(215° + 288°)
|
|
|
+ // Canvas 内部角度:从 305° 开始(旋转后对应显示 215°),到 233° 结束(旋转后对应显示 503°)
|
|
|
+ // 每 1% 进度 = 2.88 度
|
|
|
+ const startAngleDeg = 305; // Canvas 内部起始角度(旋转后对应底部圆环的 215°)
|
|
|
+ const totalAngleDeg = 288; // 总角度:288度
|
|
|
+ const endCapPercent = 0.1; // 末端圆角部分占 0.1%
|
|
|
+
|
|
|
+ // 转换为弧度
|
|
|
+ const startAngleRad = useMemo(() => (startAngleDeg * Math.PI) / 180, []);
|
|
|
+ const totalAngleRad = useMemo(() => (totalAngleDeg * Math.PI) / 180, []);
|
|
|
+
|
|
|
+ // 计算画布中心点(使用实际尺寸)
|
|
|
+ const centerX = actualSize.width / 2;
|
|
|
+ const centerY = actualSize.height / 2;
|
|
|
+
|
|
|
+ const drawEndCap = useCallback(() => {
|
|
|
+ const canvas = canvasRef.current;
|
|
|
+ if (!canvas) return;
|
|
|
+
|
|
|
+ const ctx = canvas.getContext('2d');
|
|
|
+ if (!ctx) return;
|
|
|
+
|
|
|
+ // 设置高分辨率渲染(提高清晰度)
|
|
|
+ const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;
|
|
|
+ const displayWidth = actualSize.width;
|
|
|
+ const displayHeight = actualSize.height;
|
|
|
+
|
|
|
+ if (canvas.width !== displayWidth * dpr || canvas.height !== displayHeight * dpr) {
|
|
|
+ canvas.width = displayWidth * dpr;
|
|
|
+ canvas.height = displayHeight * dpr;
|
|
|
+ canvas.style.width = `${displayWidth}px`;
|
|
|
+ canvas.style.height = `${displayHeight}px`;
|
|
|
+ ctx.scale(dpr, dpr);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 优化渲染质量
|
|
|
+ ctx.imageSmoothingEnabled = true;
|
|
|
+ ctx.imageSmoothingQuality = 'high';
|
|
|
+
|
|
|
+ // 清空画布
|
|
|
+ ctx.clearRect(0, 0, displayWidth, displayHeight);
|
|
|
+
|
|
|
+ // 只有当进度大于 0 时才绘制末端圆角
|
|
|
+ if (progress > 0) {
|
|
|
+ // 计算末端圆角的起始和结束百分比
|
|
|
+ const endCapStartPercent = Math.max(0, progress - endCapPercent);
|
|
|
+ const endCapEndPercent = progress;
|
|
|
+
|
|
|
+ // 如果起始百分比为 0,说明进度小于 0.1%,直接绘制从 0 到 progress
|
|
|
+ if (endCapStartPercent === 0) {
|
|
|
+ // 只绘制末端部分,从 0 到 progress
|
|
|
+ const endAngle = startAngleRad + (totalAngleRad * progress / 100);
|
|
|
+
|
|
|
+ // 获取末端颜色(使用 progress 的颜色)
|
|
|
+ const endColor = getColorForPercent(progress);
|
|
|
+
|
|
|
+ if (endColor) {
|
|
|
+ ctx.beginPath();
|
|
|
+ ctx.arc(centerX, centerY, radius, startAngleRad, endAngle);
|
|
|
+ ctx.lineWidth = thickness;
|
|
|
+ ctx.lineCap = 'round'; // 圆角末端
|
|
|
+ ctx.strokeStyle = endColor;
|
|
|
+ ctx.globalAlpha = 1;
|
|
|
+ ctx.stroke();
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // 绘制末端 3% 部分
|
|
|
+ // 计算 Canvas 内部角度
|
|
|
+ const endCapStartAngle = startAngleRad + (totalAngleRad * endCapStartPercent / 100);
|
|
|
+ const endCapEndAngle = startAngleRad + (totalAngleRad * endCapEndPercent / 100);
|
|
|
+
|
|
|
+ // 处理跨 0° 的情况
|
|
|
+ let normalizedStartAngle = endCapStartAngle;
|
|
|
+ let normalizedEndAngle = endCapEndAngle;
|
|
|
+
|
|
|
+ // 如果超过 360°,需要归一化
|
|
|
+ if (normalizedStartAngle > Math.PI * 2) {
|
|
|
+ normalizedStartAngle = normalizedStartAngle - Math.PI * 2;
|
|
|
+ }
|
|
|
+ if (normalizedEndAngle > Math.PI * 2) {
|
|
|
+ normalizedEndAngle = normalizedEndAngle - Math.PI * 2;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 计算末端部分的颜色
|
|
|
+ // 使用 progress 的颜色,确保与 SVG 在相同进度位置的颜色一致
|
|
|
+ const endColor = getColorForPercent(progress);
|
|
|
+
|
|
|
+ if (endColor) {
|
|
|
+ ctx.beginPath();
|
|
|
+
|
|
|
+ // 处理跨 0° 的情况
|
|
|
+ if (normalizedStartAngle > normalizedEndAngle) {
|
|
|
+ // 第一段:从 normalizedStartAngle 到 2π
|
|
|
+ ctx.arc(centerX, centerY, radius, normalizedStartAngle, Math.PI * 2);
|
|
|
+ ctx.lineWidth = thickness;
|
|
|
+ ctx.lineCap = 'round';
|
|
|
+ ctx.strokeStyle = endColor;
|
|
|
+ ctx.globalAlpha = 1;
|
|
|
+ ctx.stroke();
|
|
|
+
|
|
|
+ // 第二段:从 0 到 normalizedEndAngle
|
|
|
+ ctx.beginPath();
|
|
|
+ ctx.arc(centerX, centerY, radius, 0, normalizedEndAngle);
|
|
|
+ ctx.lineWidth = thickness;
|
|
|
+ ctx.lineCap = 'round';
|
|
|
+ ctx.strokeStyle = endColor;
|
|
|
+ ctx.globalAlpha = 1;
|
|
|
+ ctx.stroke();
|
|
|
+ } else {
|
|
|
+ // 正常情况:从起始角度到结束角度
|
|
|
+ ctx.arc(centerX, centerY, radius, normalizedStartAngle, normalizedEndAngle);
|
|
|
+ ctx.lineWidth = thickness;
|
|
|
+ ctx.lineCap = 'round'; // 圆角末端
|
|
|
+ ctx.strokeStyle = endColor;
|
|
|
+ ctx.globalAlpha = 1;
|
|
|
+ ctx.stroke();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }, [progress, actualSize, radius, thickness, startAngleRad, totalAngleRad, centerX, centerY]);
|
|
|
+
|
|
|
+ useImperativeHandle(ref, () => ({
|
|
|
+ redraw: drawEndCap,
|
|
|
+ }));
|
|
|
+
|
|
|
+ // 监听容器尺寸变化,动态更新 Canvas 尺寸
|
|
|
+ useEffect(() => {
|
|
|
+ const updateSize = () => {
|
|
|
+ if (containerRef.current) {
|
|
|
+ const rect = containerRef.current.getBoundingClientRect();
|
|
|
+ setActualSize({
|
|
|
+ width: rect.width,
|
|
|
+ height: rect.height,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 初始设置
|
|
|
+ updateSize();
|
|
|
+
|
|
|
+ // 监听窗口大小变化
|
|
|
+ window.addEventListener('resize', updateSize);
|
|
|
+
|
|
|
+ // 使用 ResizeObserver 监听容器尺寸变化(更精确)
|
|
|
+ const resizeObserver = new ResizeObserver(updateSize);
|
|
|
+ if (containerRef.current) {
|
|
|
+ resizeObserver.observe(containerRef.current);
|
|
|
+ }
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ window.removeEventListener('resize', updateSize);
|
|
|
+ resizeObserver.disconnect();
|
|
|
+ };
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ drawEndCap();
|
|
|
+ }, [drawEndCap]);
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ ref={containerRef}
|
|
|
+ style={{
|
|
|
+ position: 'absolute',
|
|
|
+ // top: '0.5px',
|
|
|
+ // left: '-0.5PX', // 使用大写 PX 避免转换
|
|
|
+ width: '100%',
|
|
|
+ height: '100%',
|
|
|
+ pointerEvents: 'none',
|
|
|
+ zIndex: 1,
|
|
|
+ // Canvas 旋转 -180deg(逆时针 180 度),使内部角度 305° 对应显示 215°
|
|
|
+ transform: 'rotate(-180deg)',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <canvas
|
|
|
+ ref={canvasRef}
|
|
|
+ style={{
|
|
|
+ display: 'block',
|
|
|
+ width: '100%',
|
|
|
+ height: '100%',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ }
|
|
|
+);
|
|
|
+
|
|
|
+ProgressRingEndCap.displayName = 'ProgressRingEndCap';
|
|
|
+
|