519 lines
11 KiB
TypeScript
519 lines
11 KiB
TypeScript
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;
|