Files
web-Iot/src/components/devices/CarReverseGuideLine.tsx

519 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useRef } from 'react';
type GuideLineProps = {
direction: 'forward' | 'reverse';
steer: number;
/** 实时线速度(m/s):前进为正,后退为负。缺省时回退到 steer */
linearSpeed?: number;
/** 实时角速度(rad/s):左转为正,右转为负。缺省时回退到 steer */
angularSpeed?: number;
bottomGap?: number;
topGap?: number;
visible?: boolean;
/** 中间绿色人字线是否闪烁,默认开启 */
chevronFlash?: boolean;
};
type Point = {
x: number;
y: number;
};
const CarReverseGuideLine: React.FC<GuideLineProps> = ({
direction,
steer,
linearSpeed,
angularSpeed,
bottomGap,
topGap,
visible = true,
chevronFlash = true,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const steerRef = useRef(steer);
const linearSpeedRef = useRef(linearSpeed);
const angularSpeedRef = useRef(angularSpeed);
// 平滑后的转向量:> 0 右移,< 0 左移,0 为直行
const turnRef = useRef(0);
const directionRef = useRef(direction);
const flashRef = useRef(chevronFlash);
const gapRef = useRef({ bottomGap, topGap });
const visibleRef = useRef(visible);
// 更新参数,不触发 React 组件重新渲染
useEffect(() => {
steerRef.current = steer;
linearSpeedRef.current = linearSpeed;
angularSpeedRef.current = angularSpeed;
directionRef.current = direction;
flashRef.current = chevronFlash;
gapRef.current = { bottomGap, topGap };
visibleRef.current = visible;
}, [
steer,
linearSpeed,
angularSpeed,
direction,
chevronFlash,
bottomGap,
topGap,
visible,
]);
useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
let width = 0;
let height = 0;
let dpr = 1;
let rafId = 0;
let lastTime = 0;
let animationTime = 0;
const resize = () => {
const rect = container.getBoundingClientRect();
width = rect.width;
height = rect.height;
// 避免高分辨率车机屏幕上的辅助线模糊
dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(container);
resize();
/**
* 计算当前转向量(-1 左 … 0 直行 … +1 右)。
*
* 优先使用实时状态:曲率 κ = ω / v
* linear_speed : 前进为正,后退为负(只取大小参与曲率,方向由视角决定)
* angular_speed : 左转为正,右转为负
* 曲率 > 0(左转)对应画面左侧,因此转向量取负号;
* 静止 / 极低速时不产生行驶轨迹,按直行处理。
* 无实时数据时回退到方向盘转角 steer。
*/
const computeTurn = () => {
const v = linearSpeedRef.current;
const w = angularSpeedRef.current;
const hasRealtime =
typeof v === 'number' &&
Number.isFinite(v) &&
typeof w === 'number' &&
Number.isFinite(w);
if (!hasRealtime) {
const angle = Math.max(
-45,
Math.min(45, steerRef.current)
);
// 方向盘转角转换为曲率响应
return Math.tan((angle * Math.PI) / 180);
}
// 静止 / 极低速:没有行驶轨迹,保持直线
if (Math.abs(v) < 0.03) return 0;
// 低速时限制分母,避免曲率被放大到满量程
const speed = Math.max(Math.abs(v), 0.15);
// 曲率:ω / v,正值左转
const kappa = w / speed;
// 满量程曲率(约对应 1m 转弯半径)
const KAPPA_FULL_SCALE = 1.0;
const normalized = Math.max(
-1,
Math.min(1, kappa / KAPPA_FULL_SCALE)
);
// 左转为正 → 画面左侧为负偏移
return -normalized;
};
/**
* 计算转向后的车道中心。
*
* forward:
* 下方近端相对稳定,上方远端逐渐弯曲
*
* reverse:
* 上方远端相对稳定,下方近端逐渐弯曲
*/
const getCenterX = (progress: number) => {
// progress: 0 = 顶部远端,1 = 底部近端
let curveProgress: number;
if (directionRef.current === 'forward') {
// 前视:上方弯曲,下方稳定
curveProgress = 1 - progress;
} else {
// 后视:下方弯曲,上方稳定
curveProgress = progress;
}
// 平滑曲率,避免轨迹弯曲突兀
const curveFactor = curveProgress * curveProgress;
// 转向最大横向偏移
const maxOffset = width * 0.40;
const offset =
turnRef.current *
maxOffset *
curveFactor;
return width / 2 + offset;
};
/**
* 计算左右车道线。
*/
/**
* 计算左右车道线
*
* forward:
* 近端下方宽,远端上方窄
*
* reverse:
* 远端上方宽,近端下方窄
*/
const getLanePoint = (
progress: number,
side: 'left' | 'right'
): Point => {
const {
bottomGap: customBottom,
topGap: customTop,
} = gapRef.current;
const isReverse = directionRef.current === 'reverse';
// 前进:底部宽,顶部窄
// 倒车:底部窄,顶部宽
const defaultBottomGap = width * (
isReverse ? 0.25 : 0.45
);
const defaultTopGap = width * (
isReverse ? 0.45 : 0.25
);
const bGap = customBottom ?? defaultBottomGap;
const tGap = customTop ?? defaultTopGap;
// progress: 0 = 远端顶部,1 = 近端底部
const gap =
tGap * (1 - progress) +
bGap * progress;
// 左右车道对称分布
const sideOffset =
side === 'left'
? -gap / 2
: gap / 2;
const centerX = getCenterX(progress);
// 前进、倒车都保持近端在下方
const y =
height * 0.12 +
progress * height * 0.76;
return {
x: centerX + sideOffset,
y,
};
};
/**
* 绘制一条平滑车道线。
*/
const drawLane = (side: 'left' | 'right') => {
const points: Point[] = [];
const count = 100;
for (let i = 0; i <= count; i++) {
const progress = i / count;
points.push(getLanePoint(progress, side));
}
if (points.length < 2) return;
const gradient = ctx.createLinearGradient(
0,
height * 0.95,
0,
height * 0.12
);
gradient.addColorStop(0, '#18D99A');
gradient.addColorStop(0.45, '#16C99A');
gradient.addColorStop(0.75, '#E4B83B');
gradient.addColorStop(1, '#FF7043');
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
ctx.lineTo(points[i].x, points[i].y);
}
ctx.strokeStyle = gradient;
ctx.lineWidth = Math.max(3, width * 0.004);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.setLineDash([]);
ctx.stroke();
};
/**
* 绘制中间动态绿色人字线。
*
* phase 为动画相位:
* 1. 亮度呼吸
* 2. 线条流动
*/
const drawChevron = (
progress: number,
phase: number,
opacity: number
) => {
const centerX = getCenterX(progress);
const isReverse = directionRef.current === 'reverse';
const y =
height * 0.12 +
progress * height * 0.76;
const gap =
width *
(0.10 + progress * 0.06);
const size =
height *
(0.075 + progress * 0.035);
// 人字线整体跟随转向弯曲
const offset =
Math.tan(
(Math.max(-45, Math.min(45, steerRef.current)) *
Math.PI) /
180
) *
width *
0.04 *
progress;
const cx = getCenterX(progress);
// 绿色发光效果
ctx.save();
ctx.globalAlpha = opacity;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// 外层柔和光晕
ctx.shadowColor = '#00FFAA';
ctx.shadowBlur = width * 0.012;
ctx.strokeStyle = 'rgba(0, 255, 170, 0.22)';
ctx.lineWidth = width * 0.018;
// 前视:人字线尖端朝下 ∨
// 倒车:人字线尖端朝上 ∧
const isForward = directionRef.current === 'forward';
const leftX = cx - gap;
const rightX = cx + gap;
// 前视时尖端在上方,倒车时尖端在下方
const tipY = isForward ? y - size : y;
const outerY = isForward ? y : y - size;
// 外层光晕
ctx.beginPath();
ctx.moveTo(leftX, outerY);
ctx.lineTo(cx, tipY);
ctx.lineTo(rightX, outerY);
ctx.stroke();
// 内层清晰线
ctx.beginPath();
ctx.moveTo(leftX, outerY);
ctx.lineTo(cx, tipY);
ctx.lineTo(rightX, outerY);
ctx.stroke();
// 内层清晰绿色线
ctx.shadowBlur = width * 0.004;
ctx.strokeStyle = '#17E6A1';
ctx.lineWidth = width * 0.008;
// 外层光晕
ctx.beginPath();
ctx.moveTo(leftX, outerY);
ctx.lineTo(cx, tipY);
ctx.lineTo(rightX, outerY);
ctx.stroke();
// 内层清晰线
ctx.beginPath();
ctx.moveTo(leftX, outerY);
ctx.lineTo(cx, tipY);
ctx.lineTo(rightX, outerY);
ctx.stroke();
ctx.restore();
};
/**
* 绘制横向距离标记。
*/
const drawDistanceMarks = () => {
const marks = [0.25, 0.45, 0.65, 0.85];
ctx.save();
ctx.lineWidth = 1.5;
ctx.setLineDash([8, 6]);
marks.forEach((progress) => {
const left = getLanePoint(progress, 'left');
const right = getLanePoint(progress, 'right');
ctx.strokeStyle =
progress < 0.35
? 'rgba(0, 255, 170, 0.55)'
: progress < 0.7
? 'rgba(255, 210, 50, 0.50)'
: 'rgba(255, 90, 50, 0.45)';
ctx.beginPath();
ctx.moveTo(left.x, left.y);
ctx.lineTo(right.x, right.y);
ctx.stroke();
});
ctx.restore();
};
/**
* 动画主循环
*/
const render = (time: number) => {
rafId = requestAnimationFrame(render);
if (!width || !height || !visibleRef.current) {
ctx.clearRect(0, 0, width, height);
return;
}
// 限制动画时间增量,避免后台恢复后跳变
const delta = lastTime
? Math.min((time - lastTime) / 1000, 0.05)
: 0;
lastTime = time;
animationTime += delta;
// 实时数据抖动较大,按帧平滑转向量,避免辅助线抖动
const targetTurn = computeTurn();
turnRef.current +=
(targetTurn - turnRef.current) *
Math.min(1, delta * 8);
ctx.clearRect(0, 0, width, height);
// 底层车道线
drawLane('left');
drawLane('right');
drawDistanceMarks();
// 绿色人字线独立闪烁
const flash = flashRef.current
? 0.5 + 0.5 * Math.sin(animationTime * 2.8)
: 1;
// 三组人字线,形成向远处延伸的效果
const chevrons = [
{ progress: 0.25, opacity: 0.38 },
{ progress: 0.48, opacity: 0.65 },
{ progress: 0.70, opacity: 1.0 },
];
chevrons.forEach((item, index) => {
// 不同人字线有轻微相位差
const phase =
animationTime * 2.8 - index * 0.8;
const opacity = flashRef.current
? 0.25 + 0.75 * (0.5 + 0.5 * Math.sin(phase))
: 1;
drawChevron(
item.progress,
phase,
opacity * item.opacity
);
});
};
rafId = requestAnimationFrame(render);
return () => {
cancelAnimationFrame(rafId);
resizeObserver.disconnect();
};
}, []);
return (
<div
ref={containerRef}
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 5,
display: visible ? 'block' : 'none',
overflow: 'hidden',
}}
>
<canvas
ref={canvasRef}
style={{
width: '100%',
height: '100%',
display: 'block',
}}
/>
</div>
);
};
export default CarReverseGuideLine;