feat(robotaxi): 优化地图显示与动画逻辑,移除车速缩放功能
移除全部车速同步相关代码,包括websocket车速接收、车速状态管理及基于车速的动态地图缩放
新增车辆marker气泡跟随行驶方向动态调整锚点的功能,避免气泡遮挡前方路线
优化返程地图视口,将落客站点加入显示范围以完整展示行程
新增车辆位置动画兜底定时器,修复回调丢失导致的动画队列卡死问题
修复平滑路线移动时的路线擦除逻辑错误
优化区域地图变化触发条件及手动选址的显示判断逻辑
补充站点marker的显式name字段赋值
| | |
| | | import { |
| | | isHomeStartSiteDisplayStateUsable, |
| | | isManualSelectedHomeStartSiteDisplayState, |
| | | readHomeStartSiteDisplayState |
| | | readHomeStartSiteDisplayState, |
| | | shouldRetainManualSelectedHomeStartSiteOnNextHome |
| | | } from './use-home-start-site-display'; |
| | | |
| | | type Deps = { |
| | |
| | | */ |
| | | const resolveLockedManualStartSite = (cityCodeArg?: string) => { |
| | | const preferredState = readHomeStartSiteDisplayState(); |
| | | if (!isManualSelectedHomeStartSiteDisplayState(preferredState)) { |
| | | if (!shouldRetainManualSelectedHomeStartSiteOnNextHome(preferredState)) { |
| | | return null; |
| | | } |
| | | if (!isHomeStartSiteDisplayStateUsable(preferredState, cityCodeArg)) { |
| | |
| | | return; |
| | | } |
| | | |
| | | if (causedBy === 'gesture' || causedBy === 'drag' || causedBy === 'scale') { |
| | | if (causedBy === 'gesture' || causedBy === 'drag' || causedBy === 'scale' || wasReturningToOrigin) { |
| | | // 只有发生显著位移时才同步经纬度,避免微小抖动引发的二次重绘 |
| | | if (Math.abs(mapState.latitude - lat) > 0.0001 || Math.abs(mapState.longitude - lng) > 0.0001) { |
| | | mapState.setLatitude(lat); |
| | |
| | | eraseVehiclePointMode: VehiclePointMode; |
| | | /** 非擦线态车辆点策略 */ |
| | | normalVehiclePointMode: VehiclePointMode; |
| | | /** 当前车速 (m/s) */ |
| | | speed: number | null; |
| | | }; |
| | | |
| | | /** |
| | |
| | | forceFullRedraw: payload?.forceFullRedraw === true, |
| | | eraseVehiclePointMode: 'first_then_served_last', |
| | | normalVehiclePointMode: 'served_last_then_first', |
| | | speed: Number.isFinite(Number(route?.speed)) ? Number(route?.speed) : null, |
| | | }; |
| | | } |
| | | |
| | |
| | | import Taro from '@tarojs/taro'; |
| | | import { useCallback, useMemo, useEffect, useRef } from 'react'; |
| | | import { useCallback, useMemo } from 'react'; |
| | | import { TripStatus } from '../constants/trip-status'; |
| | | |
| | | type RoutePoint = { |
| | |
| | | type Deps = { |
| | | /** 当前行程状态。 */ |
| | | tripStatus: TripStatus; |
| | | /** 车辆速度 (m/s) */ |
| | | vehicleSpeed: number | null; |
| | | /** 当前路线点集 */ |
| | | routePoints: RoutePoint[]; |
| | | /** 当前地图缩放比例 */ |
| | | mapScale: number; |
| | | /** 设置地图缩放比例 */ |
| | | setMapScale: (scale: number) => void; |
| | | /** 获取速度缩放暂停截止时间 */ |
| | | getDynamicScaleHoldUntil: () => number; |
| | | /** 计算 includePoints 所需底部留白。 */ |
| | | computeBottomPadding: (points: any[], viewportType: string) => number; |
| | | /** 读取顶部安全区高度。 */ |
| | |
| | | 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 = 3000; // 动态缩放巡航周期 (缩短到3秒,对车速变化更灵敏) |
| | | |
| | | /** |
| | | * 接驾中/行程中视口编排入口:仅保留视口编排职责。 |
| | |
| | | const isActiveStatus = useMemo(() => { |
| | | return ACTIVE_TRIP_STATUSES.includes(deps.tripStatus); |
| | | }, [deps.tripStatus]); |
| | | |
| | | const depsRef = useRef(deps); |
| | | useEffect(() => { |
| | | depsRef.current = deps; |
| | | }, [deps]); |
| | | |
| | | /** |
| | | * 基于车速区间直接推导视口缩放比例 (直接驱动受控 mapScale,避免与原生 includePoints 冲突)。 |
| | | */ |
| | | const tryApplySpeedBasedScale = useCallback(() => { |
| | | const currentDeps = depsRef.current; |
| | | if (currentDeps.tripStatus !== TripStatus.InService) { |
| | | return; // 仅在行程中开启速度级缩放 |
| | | } |
| | | const now = Date.now(); |
| | | if (currentDeps.getDynamicScaleHoldUntil() > now) { |
| | | return; // 处于手势或强行全览冷却期 |
| | | } |
| | | const speed = currentDeps.vehicleSpeed || 0; |
| | | |
| | | let targetScale = 14; // 高速段:视野更宏观 |
| | | if (speed < 8.3) { // 约 30km/h |
| | | targetScale = 18; // 低速段:细节更清晰 |
| | | } else if (speed < 16.6) { // 约 60km/h |
| | | targetScale = 16; // 中速段:均衡视野 |
| | | } |
| | | |
| | | if (currentDeps.mapScale !== targetScale) { |
| | | currentDeps.setMapScale(targetScale); |
| | | } |
| | | }, []); |
| | | |
| | | useEffect(() => { |
| | | if (!isActiveStatus) return; |
| | | const timer = setInterval(() => { |
| | | tryApplySpeedBasedScale(); |
| | | }, DYNAMIC_SCALE_INTERVAL_MS); |
| | | return () => clearInterval(timer); |
| | | }, [isActiveStatus, tryApplySpeedBasedScale]); |
| | | |
| | | /** |
| | | * 处理地图手势,手动缩放/拖拽后短暂暂停视口同步。 |
| | |
| | | // 构建车辆 marker |
| | | const createCarMarker = () => { |
| | | if (!deps.vehicleLocation) return null; |
| | | |
| | | // 动态计算气泡位置,始终将气泡放在车辆行驶方向的后方,避免遮挡前方路线 |
| | | // 车辆图标高 40,宽 20。气泡宽约 140,高约 40。 |
| | | // anchorY/anchorX 的基准是基于 marker 自身的坐标点 (0, 0) |
| | | let anchorY = 60; // 默认向北行驶,气泡放在正下方,避开车身高度 (40) 和部分留白 |
| | | let anchorX = 0; |
| | | if (typeof deps.vehicleDirection === 'number') { |
| | | const normalizedDirection = ((deps.vehicleDirection % 360) + 360) % 360; |
| | | if (normalizedDirection >= 45 && normalizedDirection < 135) { |
| | | // 向东行驶,后方为西(左侧) |
| | | // 气泡向左偏移一半的气泡宽度 (约 70) 加上车身宽度一半 (10) 和留白 |
| | | anchorY = 20; // 垂直居中于车身 |
| | | anchorX = -85; |
| | | } else if (normalizedDirection >= 135 && normalizedDirection < 225) { |
| | | // 向南行驶,后方为北(上方) |
| | | // 气泡向上偏移气泡高度 (40) 加上车身高度的一半 (20) |
| | | anchorY = -40; |
| | | anchorX = 0; |
| | | } else if (normalizedDirection >= 225 && normalizedDirection < 315) { |
| | | // 向西行驶,后方为东(右侧) |
| | | // 气泡向右偏移一半的气泡宽度 (约 70) 加上车身宽度一半 (10) 和留白 |
| | | anchorY = 20; // 垂直居中于车身 |
| | | anchorX = 85; |
| | | } |
| | | } |
| | | |
| | | return { |
| | | id: deps.vehicleMarkerId, |
| | | latitude: deps.vehicleLocation.latitude, |
| | |
| | | zIndex: 2, |
| | | rotate: typeof deps.vehicleDirection === 'number' ? deps.vehicleDirection : 0, |
| | | name: '车辆位置', |
| | | customCallout: { anchorY: 70, anchorX: 0, display: 'ALWAYS' } |
| | | customCallout: { anchorY, anchorX, display: 'ALWAYS' } |
| | | }; |
| | | }; |
| | | |
| | |
| | | const toOrigin = () => { |
| | | const mapCtx = Taro.createMapContext('map'); |
| | | if (deps.tripStatus === TripStatus.InService && deps.routePoints.length > 0) { |
| | | const bottomPadding = computeBottomPadding(deps.routePoints, 'route'); |
| | | const { dropoffSite } = resolveViewportSites(); |
| | | const dropoffPoint = buildSiteViewportPoint(dropoffSite); |
| | | |
| | | const pointsToInclude = [...deps.routePoints]; |
| | | if (dropoffPoint) pointsToInclude.push(dropoffPoint); |
| | | |
| | | const bottomPadding = computeBottomPadding(pointsToInclude, 'route'); |
| | | const topPadding = Math.max(24, resolveSafeTopInset() + 32); |
| | | holdDynamicScale(); |
| | | mapCtx.includePoints({ |
| | | points: deps.routePoints, |
| | | points: pointsToInclude, |
| | | padding: [topPadding, 100, bottomPadding, 100] |
| | | }); |
| | | tripStatus6ViewportLockedRef.current = true; |
| | |
| | | const remainingGap = runtime.targetDistanceMeters - runtime.displayDistanceMeters; |
| | | lastRenderAtRef.current = now; |
| | | if (remainingGap <= 0.5) { |
| | | if (runtime.displayDistanceMeters - runtime.erasedDistanceMeters > 0) { |
| | | eraseRouteToDistance(routeIndex, runtime.displayDistanceMeters); |
| | | } |
| | | return; |
| | | } |
| | | const stepBySpeed = TripMotionConfig.FALLBACK_SPEED_MPS * (deltaMs / 1000); |
| | |
| | | Math.max(stepBySpeed, stepByGap) |
| | | ) |
| | | ); |
| | | |
| | | const prevDisplayDistance = runtime.displayDistanceMeters; |
| | | runtime.displayDistanceMeters = nextDisplayDistance; |
| | | const currentVehiclePoint = getPointByDistance(routeIndex, nextDisplayDistance); |
| | | deps.updateVehicleLocationThrottled(currentVehiclePoint.latitude, currentVehiclePoint.longitude, true); |
| | | |
| | | const shouldEraseRoute = ( |
| | | nextDisplayDistance - runtime.erasedDistanceMeters >= TripMotionConfig.ERASE_STEP_METERS |
| | | || nextDisplayDistance >= routeIndex.totalDistance |
| | | prevDisplayDistance - runtime.erasedDistanceMeters >= TripMotionConfig.ERASE_STEP_METERS |
| | | || prevDisplayDistance >= routeIndex.totalDistance |
| | | ); |
| | | if (shouldEraseRoute) { |
| | | eraseRouteToDistance(routeIndex, nextDisplayDistance); |
| | | eraseRouteToDistance(routeIndex, prevDisplayDistance); |
| | | } |
| | | }, TripMotionConfig.RENDER_INTERVAL_MS); |
| | | }; |
| | |
| | | pendingTranslateRef.current = null; |
| | | isTranslatingRef.current = true; |
| | | let finished = false; |
| | | let fallbackTimer: any = null; |
| | | const finishTranslate = () => { |
| | | if (finished) return; |
| | | finished = true; |
| | | if (fallbackTimer) clearTimeout(fallbackTimer); |
| | | isTranslatingRef.current = false; |
| | | if (pendingTranslateRef.current) { |
| | | flushTranslateTask(); |
| | | } |
| | | }; |
| | | |
| | | // 兜底定时器,防止回调丢失导致动画队列卡死 |
| | | fallbackTimer = setTimeout(() => { |
| | | finishTranslate(); |
| | | }, TRANSLATE_DURATION_MS + 50); |
| | | |
| | | Taro.nextTick(() => { |
| | | const mapCtx = Taro.createMapContext('map'); |
| | | mapCtx.translateMarker({ |
| | |
| | | moveWithRotate: false, |
| | | rotate: task.rotate, |
| | | duration: TRANSLATE_DURATION_MS, |
| | | fail: () => { |
| | | animationEnd: () => { |
| | | finishTranslate(); |
| | | }, |
| | | complete: () => { |
| | | fail: () => { |
| | | finishTranslate(); |
| | | } |
| | | }); |
| | |
| | | setVehicleMoveAlongPoints: (points: any[]) => void; |
| | | setRouteDistance: (value: any) => void; |
| | | setRouteDuration: (value: number) => void; |
| | | setVehicleSpeed?: (speed: number) => void; |
| | | setModifyDestinationSocketStatus: (value: number | null) => void; |
| | | setOrderRouteMetrics?: (metrics: { |
| | | distanceMeters: number | null; |
| | |
| | | return; |
| | | } |
| | | const routeSnapshot = normalizeSocketRouteSnapshot(payload); |
| | | if (typeof routeSnapshot.speed === 'number') { |
| | | currentDeps.setVehicleSpeed?.(routeSnapshot.speed); |
| | | } |
| | | const shouldOnlySyncModifyPreviewVehicle = isModifyDestinationPreviewActive && currentTripStatus === TripStatus.InService; |
| | | const shouldFreezeModifyPreviewRoute = isModifyDestinationPreviewActive && currentTripStatus !== TripStatus.InService; |
| | | if (isModifyDestinationPreviewActive) { |
| | |
| | | const [showCouponSelectPopup, setShowCouponSelectPopup] = useState(false); // 优惠券选择弹窗展示状态 |
| | | const [showModifyDestinationFailPopup, setShowModifyDestinationFailPopup] = useState(false); // 修改目的地失败弹窗 |
| | | const [modifyDestinationFailMessage, setModifyDestinationFailMessage] = useState(''); // 修改目的地失败文案(默认空,避免闪文案) |
| | | const [vehicleSpeed, setVehicleSpeed] = useState<number | null>(null); // 车辆当前速度 (m/s) |
| | | const [orderRouteMetrics, setOrderRouteMetrics] = useState<OrderRouteMetrics>({ |
| | | distanceMeters: null, |
| | | durationSeconds: null |
| | |
| | | } = viewportState; // 视口状态解构 |
| | | const activeTripScale = useActiveTripScale({ |
| | | tripStatus, |
| | | vehicleSpeed, |
| | | routePoints, |
| | | mapScale, |
| | | setMapScale: viewportState.setMapScale, |
| | | getDynamicScaleHoldUntil, |
| | | computeBottomPadding, |
| | | resolveSafeTopInset, |
| | | holdDynamicScale |
| | |
| | | setVehicleMoveAlongPoints, |
| | | setRouteDistance, |
| | | setRouteDuration, |
| | | setVehicleSpeed, |
| | | setModifyDestinationSocketStatus, |
| | | setOrderRouteMetrics, |
| | | setBeforeUserEndSite, |
| | |
| | | |
| | | const createCarMarker = () => { |
| | | if (!deps.vehicleLocation) return null; |
| | | |
| | | // 动态计算气泡位置,始终将气泡放在车辆行驶方向的后方,避免遮挡前方路线 |
| | | // 车辆图标高 40,宽 20。气泡宽约 140,高约 40。 |
| | | // anchorY/anchorX 的基准是基于 marker 自身的坐标点 (0, 0) |
| | | let anchorY = 60; // 默认向北行驶,气泡放在正下方,避开车身高度 (40) 和部分留白 |
| | | let anchorX = 0; |
| | | if (typeof deps.vehicleDirection === 'number') { |
| | | const normalizedDirection = ((deps.vehicleDirection % 360) + 360) % 360; |
| | | if (normalizedDirection >= 45 && normalizedDirection < 135) { |
| | | // 向东行驶,气泡放在左侧 |
| | | // 气泡向左偏移一半的气泡宽度 (约 70) 加上车身宽度一半 (10) 和留白 |
| | | anchorY = 20; // 垂直居中于车身 |
| | | anchorX = -85; |
| | | } else if (normalizedDirection >= 135 && normalizedDirection < 225) { |
| | | // 向南行驶,气泡放在上方 |
| | | // 气泡向上偏移气泡高度 (40) 加上车身高度的一半 (20) |
| | | anchorY = -40; |
| | | anchorX = 0; |
| | | } else if (normalizedDirection >= 225 && normalizedDirection < 315) { |
| | | // 向西行驶,气泡放在右侧 |
| | | // 气泡向右偏移一半的气泡宽度 (约 70) 加上车身宽度一半 (10) 和留白 |
| | | anchorY = 20; // 垂直居中于车身 |
| | | anchorX = 85; |
| | | } |
| | | } |
| | | |
| | | return { |
| | | id: deps.vehicleMarkerId, |
| | | latitude: deps.vehicleLocation.latitude, |
| | |
| | | ? deps.vehicleDirection |
| | | : 0, |
| | | name: "车辆位置", |
| | | customCallout: { anchorY: 70, anchorX: 0, display: "ALWAYS" } |
| | | customCallout: { anchorY, anchorX, display: "ALWAYS" } |
| | | }; |
| | | }; |
| | | |
| | |
| | | export const formatSiteToMarker = (sites: any[]) => { |
| | | return sites.map((site, index) => ({ |
| | | ...site, |
| | | name: site.name, |
| | | // id: index + 1, |
| | | iconPath: 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/site-poi.png', |
| | | width: 15, |