zjk
2026-06-03 d94c7599cdb112d3e12ecc50c6985c1ad4f6b05c
Merge branch 'feature_v1.0' of http://gitlab.xiaopeng.local:18080/robotaxi1/xp-robotaxi-miniprogram into feature_v1.0
1个文件已添加
15个文件已修改
233 ■■■■■ 已修改文件
src/pages/robotaxi/home/hooks/use-nearby-vehicles.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/robotaxi/home/hooks/use-region-change.ts 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/robotaxi/home/index.tsx 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/hooks/use-station-scale-focus.ts 80 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/calling-car-modules/bluetooth-operate-card.tsx 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/calling-car-modules/close-bluetooth.png 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/components/calling-car.tsx 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/trip-motion-utils.ts 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-active-trip-scale.ts 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-bluetooth-car-control.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-vehicle-tracking.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/hooks/use-view-delegates.tsx 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/begin-route/index.tsx 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/select-map-site/hooks/use-select-map-site.ts 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/select-map-site/index.tsx 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/robotaxi/pages/share-trip/hooks/use-vehicle-tracking.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/robotaxi/home/hooks/use-nearby-vehicles.ts
@@ -295,6 +295,7 @@
        },
        duration: ANIMATION_DURATION_MS,
        autoRotate: false,
        moveWithRotate: false,
        rotate: resolveMarkerRotate(
          interpolateDirection(track.fromDirection, track.toDirection, 1),
          isAndroidRef.current
src/pages/robotaxi/home/hooks/use-region-change.ts
@@ -12,12 +12,13 @@
  getMarkerCommitDelayMs?: () => number;
  onRegionBegin?: () => void;
  onRegionSettled?: (payload: { lat: number; lon: number; isInside: boolean }) => void;
  onScaleChange?: (scale: number) => void;
};
/**
 * 地图 region-change 编排:处理地图拖拽/缩放后的吸附、回填和自动归位。
 */
export default function useRegionChange({ mapState, siteState, toOrigin, getMarkerCommitDelayMs, onRegionBegin, onRegionSettled }: Deps) {
export default function useRegionChange({ mapState, siteState, toOrigin, getMarkerCommitDelayMs, onRegionBegin, onRegionSettled, onScaleChange }: Deps) {
  /**
   * 查询完成后提交站点 marker:命中吸附时立即提交,未吸附时避让车辆动画窗口再提交。
   */
@@ -94,8 +95,9 @@
              toOrigin();
            }, 6000);
          }
        } else if (causedBy === 'gesture' || causedBy === 'scale') {
          mapState.setMapScale(res.scale);
        }
        if (typeof onScaleChange === 'function') {
          onScaleChange(res.scale);
        }
      }
    });
@@ -112,8 +114,11 @@
        }
        
        if (causedBy === 'gesture' || causedBy === 'drag' || causedBy === 'scale') {
          mapState.setLatitude(lat);
          mapState.setLongitude(lng);
          // 只有发生显著位移时才同步经纬度,避免微小抖动引发的二次重绘
          if (Math.abs(mapState.latitude - lat) > 0.0001 || Math.abs(mapState.longitude - lng) > 0.0001) {
            mapState.setLatitude(lat);
            mapState.setLongitude(lng);
          }
        }
        try {
src/pages/robotaxi/home/index.tsx
@@ -176,9 +176,8 @@
    syncSelectedCityFromStorage,
    consumeCitySwitchFlag
  } = useCitySwitch();
  const { displayPolygons, displayFencePolylines } = useStationScaleFocus({
  const { displayPolygons, displayFencePolylines, handleScaleChange } = useStationScaleFocus({
    currentSite: siteState.currentSite,
    setMapScale: mapState.setMapScale,
    originalPolygons: mapState.polygons,
    originalFencePolylines: mapState.fencePolylines
  }); // 场站缩放聚焦与围栏展示控制
@@ -308,7 +307,8 @@
    onRegionSettled: (payload) => {
      handleVehicleRegionSettled(payload);
      syncStationFenceVisibility(payload.lat, payload.lon, payload.isInside);
    }
    },
    onScaleChange: handleScaleChange
  });
  const {
    toSelectSitePage,
@@ -452,6 +452,7 @@
      setBounce(false);
      setIsAdsorbed(true);
      setCurrentSite(normalizedTargetMarker);
      syncStationFenceVisibility(normalizedTargetMarker.latitude, normalizedTargetMarker.longitude, isOperatingArea);
      setPreferredStartSite(normalizedTargetMarker);
      Taro.setStorageSync('startSite', normalizedTargetMarker);
      writeConfirmRideSiteSourceType('start', RECOMMENDED_CONFIRM_RIDE_SOURCE_TYPE);
src/robotaxi/hooks/use-station-scale-focus.ts
@@ -1,11 +1,9 @@
import { useEffect, useRef, useMemo } from 'react';
import { useMemo, useState, useEffect, useRef, useCallback } from 'react';
import { GEOFENCE_STATION_FILL_COLOR, GEOFENCE_STATION_STROKE_COLOR } from '../constants/geofence';
type Deps = {
  /** 当前选中的站点对象 */
  currentSite: any;
  /** 设置地图缩放等级的方法 */
  setMapScale: (scale: number | ((prev: number) => number)) => void;
  /** 原始的多边形围栏列表 */
  originalPolygons: any[];
  /** 原始的虚线边框列表 */
@@ -13,62 +11,76 @@
};
/**
 * 场站站点地图缩放与围栏展示聚焦控制 Hook。
 * 场站站点地图围栏展示聚焦控制 Hook。
 * 
 * 职责:
 * 1. 当选中“场站内”的站点时,自动将地图缩放等级设置为 18,并仅展示场站围栏(隐藏蓝色运营区围栏)。
 * 2. 当选中非场站站点或取消选中时,恢复展示所有围栏。
 * 3. 使用 ref 记录最后一次处理的站点,避免用户手动缩放地图后被反复拉回。
 * 1. 当选中“场站内”的站点,并且发生地图放大操作使得缩放达到 17~18 级(即 >= 16.9)时,去掉运营区围栏蒙层,仅展示场站围栏。
 * 2. 移出场站时、选择其他站点时,或缩放比例小于 17 时,恢复展示所有围栏。
 */
export default function useStationScaleFocus({ currentSite, setMapScale, originalPolygons, originalFencePolylines }: Deps) {
  const lastHandledStationSiteIdRef = useRef<string>('');
export default function useStationScaleFocus({ currentSite, originalPolygons, originalFencePolylines }: Deps) {
  const isStationSite = useMemo(() => {
    if (!currentSite) return false;
    return !!currentSite.stationId;
  }, [currentSite]);
  const [shouldFocusFence, setShouldFocusFence] = useState(false);
  const lastScaleRef = useRef<number | undefined>(undefined);
  useEffect(() => {
    const stationId = currentSite?.stationId ? String(currentSite.stationId) : '';
    // 如果当前选中的是场站子站点,并且是一个新的场站(或者第一次进来),则触发缩放
    if (isStationSite && stationId) {
      if (lastHandledStationSiteIdRef.current !== stationId) {
        // 通过微调强制触发 React 状态与原生组件属性的同步
        // 避免因为用户手动缩放地图但未同步到 React 状态,导致 setMapScale(18) 被认为没改变而不生效
        setMapScale(prev => {
          if (prev === 18) return 17.99999;
          if (prev === 17.99999) return 18;
          return 18;
        });
        lastHandledStationSiteIdRef.current = stationId;
    if (!isStationSite) {
      setShouldFocusFence(false);
    } else {
      // 刚选中场站时,重置一次参照物,避免拿上一次旧的值去对比
      lastScaleRef.current = undefined;
    }
  }, [isStationSite]);
  const handleScaleChange = useCallback((scale: number) => {
    if (!isStationSite) {
      lastScaleRef.current = scale;
      return;
    }
    if (lastScaleRef.current !== undefined) {
      if (Math.abs(scale - lastScaleRef.current) > 0.001) {
        // 如果是有效放大操作(规避细微浮点误差),并且达到了 17 级及以上
        if (scale - lastScaleRef.current > 0.001 && scale >= 16.9) {
          setShouldFocusFence(true);
        }
        // 如果缩放比例掉到了 17 以下,恢复围栏
        if (scale < 16.9) {
          setShouldFocusFence(false);
        }
      }
    } else {
      // 离开场站站点,清除记录
      lastHandledStationSiteIdRef.current = '';
      // 初始化第一次赋予初始值
      lastScaleRef.current = scale;
      return;
    }
  }, [isStationSite, currentSite?.stationId, setMapScale]);
    lastScaleRef.current = scale;
  }, [isStationSite]);
  // 根据当前是否选中场站站点,过滤围栏数据
  // 根据当前是否选中场站站点且满足聚焦条件时,过滤围栏数据
  const displayPolygons = useMemo(() => {
    if (!isStationSite) {
    if (!shouldFocusFence) {
      return originalPolygons;
    }
    // 选中场站时,仅保留场站的围栏(通过颜色或透明度特征区分,因为 format 后的多边形已经丢失了 type 属性)
    // 选中场站且满足条件时,仅保留场站的围栏
    return originalPolygons.filter(polygon => polygon.fillColor === GEOFENCE_STATION_FILL_COLOR);
  }, [isStationSite, originalPolygons]);
  }, [shouldFocusFence, originalPolygons]);
  const displayFencePolylines = useMemo(() => {
    if (!isStationSite) {
    if (!shouldFocusFence) {
      return originalFencePolylines;
    }
    // 选中场站时,仅保留场站的虚线边框(通常全都是场站的,这里保险起见通过颜色过滤)
    // 选中场站且满足条件时,仅保留场站的虚线边框
    return originalFencePolylines.filter(polyline => polyline.color === GEOFENCE_STATION_STROKE_COLOR);
  }, [isStationSite, originalFencePolylines]);
  }, [shouldFocusFence, originalFencePolylines]);
  return {
    displayPolygons,
    displayFencePolylines,
    isStationSite
    isStationSite,
    handleScaleChange
  };
}
src/robotaxi/pages/begin-route/components/calling-car-modules/bluetooth-operate-card.tsx
@@ -1,14 +1,17 @@
import { View, Image } from "@tarojs/components";
import closeBluetoothIcon from './close-bluetooth.png';
type Props = {
    description: string;
    actionText?: string;
    onClick: () => void;
    authSuccess?: boolean;
};
export default function BluetoothOperateCard({ description, actionText = '', onClick }: Props) {
    const iconSrc = actionText === '去开启'
        ? "https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/close-bluetooth.png"
export default function BluetoothOperateCard({ description, actionText = '', onClick, authSuccess = false }: Props) {
    // 只要有动作需要执行,或者未达到成功授权的系统级要求,都展示关闭状态的图标
    const iconSrc = actionText === '去开启' || actionText === '去授权' || !authSuccess
        ? closeBluetoothIcon
        : "https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/bluetooth-icon.png";
    return (
src/robotaxi/pages/begin-route/components/calling-car-modules/close-bluetooth.png
src/robotaxi/pages/begin-route/components/calling-car.tsx
@@ -72,6 +72,7 @@
    handleCloseAuthBluetoothPopup?: () => void;
    handleCloseSuccessAuthPopup?: () => void;
    handleAuthorizeBluetooth?: () => void;
    authSuccess?: boolean;
    onDoorUnlockSuccess?: () => void;
    pageDidShowVersion?: number;
    onOpenCouponSelect?: () => void;
@@ -110,6 +111,7 @@
    handleCloseAuthBluetoothPopup,
    handleCloseSuccessAuthPopup,
    handleAuthorizeBluetooth,
    authSuccess = false,
    onDoorUnlockSuccess,
    pageDidShowVersion = 0,
    onOpenCouponSelect,
@@ -165,6 +167,7 @@
                    description={bluetoothCardDescription}
                    actionText={bluetoothCardActionText}
                    onClick={handleBluetoothCardClick || (() => { })}
                    authSuccess={authSuccess}
                />
            }
src/robotaxi/pages/begin-route/hooks/trip-motion-utils.ts
@@ -269,29 +269,3 @@
  }
  return Math.max(searchStartDistance, bestProjectedDistance);
}
/**
 * 根据最大距离截取路线片段(用于车速分级自适应缩放视口)。
 * 从起点开始,累加线段距离,直到超过 maxDistanceMeters,截取这部分点返回。
 */
export function sliceRoutePointsByDistance(points: RoutePoint[], maxDistanceMeters: number): RoutePoint[] {
  if (!Array.isArray(points) || points.length <= 1) {
    return points;
  }
  const result: RoutePoint[] = [points[0]];
  let accumulatedDistance = 0;
  for (let index = 1; index < points.length; index += 1) {
    const prev = points[index - 1];
    const curr = points[index];
    const segmentDistance = getDistanceMeters(prev, curr);
    if (accumulatedDistance + segmentDistance > maxDistanceMeters) {
      const ratio = (maxDistanceMeters - accumulatedDistance) / segmentDistance;
      result.push(interpolatePoint(prev, curr, ratio));
      break;
    } else {
      accumulatedDistance += segmentDistance;
      result.push(curr);
    }
  }
  return result;
}
src/robotaxi/pages/begin-route/hooks/use-active-trip-scale.ts
@@ -1,7 +1,6 @@
import Taro from '@tarojs/taro';
import { useCallback, useMemo, useEffect, useRef } from 'react';
import { TripStatus } from '../constants/trip-status';
import { sliceRoutePointsByDistance } from './trip-motion-utils';
type RoutePoint = {
  latitude: number;
@@ -15,6 +14,10 @@
  vehicleSpeed: number | null;
  /** 当前路线点集 */
  routePoints: RoutePoint[];
  /** 当前地图缩放比例 */
  mapScale: number;
  /** 设置地图缩放比例 */
  setMapScale: (scale: number) => void;
  /** 获取速度缩放暂停截止时间 */
  getDynamicScaleHoldUntil: () => number;
  /** 计算 includePoints 所需底部留白。 */
@@ -32,7 +35,7 @@
const ORDER_UPDATE_LINE_SIDE_PADDING = 100; // ORDER_UPDATE_LINE 路线入屏左右留白
const ORDER_UPDATE_LINE_TOP_PADDING = 24; // ORDER_UPDATE_LINE 路线入屏最小顶部留白
const ORDER_UPDATE_LINE_TOP_EXTRA_PADDING = 32; // ORDER_UPDATE_LINE 顶部安全区附加留白
const DYNAMIC_SCALE_INTERVAL_MS = 8000; // 动态缩放巡航周期
const DYNAMIC_SCALE_INTERVAL_MS = 3000; // 动态缩放巡航周期 (缩短到3秒,对车速变化更灵敏)
/**
 * 接驾中/行程中视口编排入口:仅保留视口编排职责。
@@ -48,7 +51,7 @@
  }, [deps]);
  /**
   * 基于车速区间进行路线截取,推导视口缩放。
   * 基于车速区间直接推导视口缩放比例 (直接驱动受控 mapScale,避免与原生 includePoints 冲突)。
   */
  const tryApplySpeedBasedScale = useCallback(() => {
    const currentDeps = depsRef.current;
@@ -60,30 +63,17 @@
      return; // 处于手势或强行全览冷却期
    }
    const speed = currentDeps.vehicleSpeed || 0;
    const points = currentDeps.routePoints;
    if (!Array.isArray(points) || points.length < 2) {
      return;
    }
    let targetDistance = 5000; // 高速段 5公里
    let targetScale = 14; // 高速段:视野更宏观
    if (speed < 8.3) { // 约 30km/h
      targetDistance = 300; // 低速段 300米
      targetScale = 18; // 低速段:细节更清晰
    } else if (speed < 16.6) { // 约 60km/h
      targetDistance = 1500; // 中速段 1.5公里
      targetScale = 16; // 中速段:均衡视野
    }
    const slicedPoints = sliceRoutePointsByDistance(points, targetDistance);
    if (slicedPoints.length < 2) {
      return;
    if (currentDeps.mapScale !== targetScale) {
      currentDeps.setMapScale(targetScale);
    }
    const mapCtx = Taro.createMapContext('map');
    const bottomPadding = currentDeps.computeBottomPadding(slicedPoints, 'route');
    const topPadding = Math.max(ORDER_UPDATE_LINE_TOP_PADDING, currentDeps.resolveSafeTopInset() + ORDER_UPDATE_LINE_TOP_EXTRA_PADDING);
    mapCtx.includePoints({
      points: slicedPoints,
      padding: [topPadding, ORDER_UPDATE_LINE_SIDE_PADDING, bottomPadding, ORDER_UPDATE_LINE_SIDE_PADDING]
    });
  }, []);
  useEffect(() => {
src/robotaxi/pages/begin-route/hooks/use-bluetooth-car-control.ts
@@ -347,10 +347,15 @@
      authSuccessRef.current = false;
      isAuthorizingRef.current = false;
      unlockFinishedRef.current = false;
      manualDoorUnlockRef.current = false;
      manualDoorUnlockRef.current = false; // 重置手动解锁状态,供下一次使用
      setAuthError('');
      // 行程进入 6(InService 行驶中)或以上,或者是回退/取消等情况时,清除该订单已弹出过成功解锁的标识缓存
      if (orderId) {
        Taro.removeStorageSync(`ROBOTAXI_UNLOCK_TOAST_SHOWN_${orderId}`);
      }
    }
  }, [tripStatus]);
  }, [tripStatus, orderId]);
  return {
    connectStatus,
src/robotaxi/pages/begin-route/hooks/use-vehicle-tracking.ts
@@ -58,6 +58,7 @@
          longitude: task.longitude,
        },
        autoRotate: false,
        moveWithRotate: false,
        rotate: task.rotate,
        duration: TRANSLATE_DURATION_MS,
        fail: () => {
src/robotaxi/pages/begin-route/hooks/use-view-delegates.tsx
@@ -146,13 +146,14 @@
  /** 蓝牙授权成功弹窗是否展示 */
  showSuccessAuthPopup: boolean;
  /** 关闭蓝牙授权弹窗 */
  handleCloseAuthBluetoothPopup: () => void;
  handleCloseAuthBluetoothPopup?: () => void;
  /** 关闭蓝牙授权成功弹窗 */
  handleCloseSuccessAuthPopup: () => void;
  handleCloseSuccessAuthPopup?: () => void;
  /** 去授权蓝牙 */
  handleAuthorizeBluetooth: () => void;
  handleAuthorizeBluetooth?: () => void;
  bluetoothAuthSuccess?: boolean;
  /** 手动解锁车门成功 */
  onDoorUnlockSuccess: () => void;
  onDoorUnlockSuccess?: () => void;
  /** 页面 didShow 版本号 */
  pageDidShowVersion: number;
  /** 排队位次轮询频率(秒) */
@@ -453,6 +454,7 @@
          handleCloseAuthBluetoothPopup={deps.handleCloseAuthBluetoothPopup}
          handleCloseSuccessAuthPopup={deps.handleCloseSuccessAuthPopup}
          handleAuthorizeBluetooth={deps.handleAuthorizeBluetooth}
          authSuccess={deps.bluetoothAuthSuccess}
          onDoorUnlockSuccess={deps.onDoorUnlockSuccess}
          pageDidShowVersion={deps.pageDidShowVersion}
          queuePollingTimeSeconds={deps.queuePollingTimeSeconds}
src/robotaxi/pages/begin-route/index.tsx
@@ -624,6 +624,8 @@
    tripStatus,
    vehicleSpeed,
    routePoints,
    mapScale,
    setMapScale: viewportState.setMapScale,
    getDynamicScaleHoldUntil,
    computeBottomPadding,
    resolveSafeTopInset,
@@ -1466,6 +1468,7 @@
    handleCloseAuthBluetoothPopup,
    handleCloseSuccessAuthPopup,
    handleAuthorizeBluetooth,
    bluetoothAuthSuccess,
    onDoorUnlockSuccess: markManualDoorUnlock,
    pageDidShowVersion,
    queuePollingTimeSeconds
src/robotaxi/pages/select-map-site/hooks/use-select-map-site.ts
@@ -49,7 +49,7 @@
/**
 * 选点页业务编排:聚合状态容器与副作用 hooks,并对外提供稳定页面接口。
 */
export default function useSelectMapSite() {
export default function useSelectMapSite(props?: { onScaleChange?: (scale: number) => void }) {
  const state = useSelectMapSiteState(); // 选点页状态容器
  /**
@@ -232,9 +232,19 @@
    }
    if (type !== 'end') return;
    state.setBounce(true);
    if (causedBy === 'gesture' || causedBy === 'drag') {
    const mapCtx = Taro.createMapContext('map');
    // 获取真实的缩放等级并通过回调传递出去,彻底阻断与 mapScale 状态的绑定,避免触发原生组件抖动
    mapCtx.getScale({
      success: (res) => {
        if (typeof props?.onScaleChange === 'function') {
          props.onScaleChange(res.scale);
        }
      }
    });
    if (causedBy === 'gesture' || causedBy === 'drag' || causedBy === 'scale') {
      state.pickupConfirmTypeRef.current = 'drag';
      const mapCtx = Taro.createMapContext('map');
      mapCtx.getCenterLocation({
        success: async (res) => {
          const { latitude: lat, longitude: lng } = res;
@@ -255,8 +265,11 @@
            resetStationSelection();
            state.setSelectedSite({});
            state.setOutOfServiceRange(getOutOfServiceRange(isInside, {}));
            state.setLatitude(lat);
            state.setLongitude(lng);
            // 只有当坐标发生实质性变化才去更新 React 状态中的经纬度,避免频繁反推给原生组件导致抖动
            if (Math.abs(state.latitude - lat) > 0.0001 || Math.abs(state.longitude - lng) > 0.0001) {
              state.setLatitude(lat);
              state.setLongitude(lng);
            }
            syncPinLocation(lat, lng);
            state.setShowToolTip(true);
          }
src/robotaxi/pages/select-map-site/index.tsx
@@ -14,15 +14,22 @@
 * 地图选点页入口:负责 hook 编排与视图组装。
 */
function SelectMapSite() {
  const selectMapSite = useSelectMapSite();
  const handleScaleChangeRef = useRef<(scale: number) => void>();
  const { displayPolygons, displayFencePolylines } = useStationScaleFocus({
  const selectMapSite = useSelectMapSite({
    onScaleChange: (scale) => handleScaleChangeRef.current?.(scale)
  });
  const { displayPolygons, displayFencePolylines, handleScaleChange } = useStationScaleFocus({
    currentSite: selectMapSite.selectedSite,
    setMapScale: selectMapSite.setMapScale,
    originalPolygons: selectMapSite.polygons,
    originalFencePolylines: selectMapSite.mapPolylines
  }); // 场站缩放聚焦与围栏展示控制
  useEffect(() => {
    handleScaleChangeRef.current = handleScaleChange;
  }, [handleScaleChange]);
  const hasReportedPickupPageViewRef = useRef(false);
  const hasReportedDropoffPageViewRef = useRef(false);
src/robotaxi/pages/share-trip/hooks/use-vehicle-tracking.ts
@@ -62,6 +62,7 @@
                longitude: vehicleLocation.longitude
            },
            autoRotate: false,
            moveWithRotate: false,
            rotate:
                typeof vehicleDirection === "number"
                    ? ((vehicleDirection % 360) + 360) % 360