Merge branch 'feature_v1.0' of http://gitlab.xiaopeng.local:18080/robotaxi1/xp-robotaxi-miniprogram into feature_v1.0
| | |
| | | }, |
| | | duration: ANIMATION_DURATION_MS, |
| | | autoRotate: false, |
| | | moveWithRotate: false, |
| | | rotate: resolveMarkerRotate( |
| | | interpolateDirection(track.fromDirection, track.toDirection, 1), |
| | | isAndroidRef.current |
| | |
| | | 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:命中吸附时立即提交,未吸附时避让车辆动画窗口再提交。 |
| | | */ |
| | |
| | | toOrigin(); |
| | | }, 6000); |
| | | } |
| | | } else if (causedBy === 'gesture' || causedBy === 'scale') { |
| | | mapState.setMapScale(res.scale); |
| | | } |
| | | if (typeof onScaleChange === 'function') { |
| | | onScaleChange(res.scale); |
| | | } |
| | | } |
| | | }); |
| | |
| | | } |
| | | |
| | | 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 { |
| | |
| | | syncSelectedCityFromStorage, |
| | | consumeCitySwitchFlag |
| | | } = useCitySwitch(); |
| | | const { displayPolygons, displayFencePolylines } = useStationScaleFocus({ |
| | | const { displayPolygons, displayFencePolylines, handleScaleChange } = useStationScaleFocus({ |
| | | currentSite: siteState.currentSite, |
| | | setMapScale: mapState.setMapScale, |
| | | originalPolygons: mapState.polygons, |
| | | originalFencePolylines: mapState.fencePolylines |
| | | }); // 场站缩放聚焦与围栏展示控制 |
| | |
| | | onRegionSettled: (payload) => { |
| | | handleVehicleRegionSettled(payload); |
| | | syncStationFenceVisibility(payload.lat, payload.lon, payload.isInside); |
| | | } |
| | | }, |
| | | onScaleChange: handleScaleChange |
| | | }); |
| | | const { |
| | | toSelectSitePage, |
| | |
| | | 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); |
| | |
| | | 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[]; |
| | | /** 原始的虚线边框列表 */ |
| | |
| | | }; |
| | | |
| | | /** |
| | | * 场站站点地图缩放与围栏展示聚焦控制 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 |
| | | }; |
| | | } |
| | |
| | | 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 ( |
| | |
| | | handleCloseAuthBluetoothPopup?: () => void; |
| | | handleCloseSuccessAuthPopup?: () => void; |
| | | handleAuthorizeBluetooth?: () => void; |
| | | authSuccess?: boolean; |
| | | onDoorUnlockSuccess?: () => void; |
| | | pageDidShowVersion?: number; |
| | | onOpenCouponSelect?: () => void; |
| | |
| | | handleCloseAuthBluetoothPopup, |
| | | handleCloseSuccessAuthPopup, |
| | | handleAuthorizeBluetooth, |
| | | authSuccess = false, |
| | | onDoorUnlockSuccess, |
| | | pageDidShowVersion = 0, |
| | | onOpenCouponSelect, |
| | |
| | | description={bluetoothCardDescription} |
| | | actionText={bluetoothCardActionText} |
| | | onClick={handleBluetoothCardClick || (() => { })} |
| | | authSuccess={authSuccess} |
| | | /> |
| | | } |
| | | |
| | |
| | | } |
| | | 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; |
| | | } |
| | |
| | | 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; |
| | |
| | | vehicleSpeed: number | null; |
| | | /** 当前路线点集 */ |
| | | routePoints: RoutePoint[]; |
| | | /** 当前地图缩放比例 */ |
| | | mapScale: number; |
| | | /** 设置地图缩放比例 */ |
| | | setMapScale: (scale: number) => void; |
| | | /** 获取速度缩放暂停截止时间 */ |
| | | getDynamicScaleHoldUntil: () => number; |
| | | /** 计算 includePoints 所需底部留白。 */ |
| | |
| | | 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秒,对车速变化更灵敏) |
| | | |
| | | /** |
| | | * 接驾中/行程中视口编排入口:仅保留视口编排职责。 |
| | |
| | | }, [deps]); |
| | | |
| | | /** |
| | | * 基于车速区间进行路线截取,推导视口缩放。 |
| | | * 基于车速区间直接推导视口缩放比例 (直接驱动受控 mapScale,避免与原生 includePoints 冲突)。 |
| | | */ |
| | | const tryApplySpeedBasedScale = useCallback(() => { |
| | | const currentDeps = depsRef.current; |
| | |
| | | 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(() => { |
| | |
| | | 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, |
| | |
| | | longitude: task.longitude, |
| | | }, |
| | | autoRotate: false, |
| | | moveWithRotate: false, |
| | | rotate: task.rotate, |
| | | duration: TRANSLATE_DURATION_MS, |
| | | fail: () => { |
| | |
| | | /** 蓝牙授权成功弹窗是否展示 */ |
| | | showSuccessAuthPopup: boolean; |
| | | /** 关闭蓝牙授权弹窗 */ |
| | | handleCloseAuthBluetoothPopup: () => void; |
| | | handleCloseAuthBluetoothPopup?: () => void; |
| | | /** 关闭蓝牙授权成功弹窗 */ |
| | | handleCloseSuccessAuthPopup: () => void; |
| | | handleCloseSuccessAuthPopup?: () => void; |
| | | /** 去授权蓝牙 */ |
| | | handleAuthorizeBluetooth: () => void; |
| | | handleAuthorizeBluetooth?: () => void; |
| | | bluetoothAuthSuccess?: boolean; |
| | | /** 手动解锁车门成功 */ |
| | | onDoorUnlockSuccess: () => void; |
| | | onDoorUnlockSuccess?: () => void; |
| | | /** 页面 didShow 版本号 */ |
| | | pageDidShowVersion: number; |
| | | /** 排队位次轮询频率(秒) */ |
| | |
| | | handleCloseAuthBluetoothPopup={deps.handleCloseAuthBluetoothPopup} |
| | | handleCloseSuccessAuthPopup={deps.handleCloseSuccessAuthPopup} |
| | | handleAuthorizeBluetooth={deps.handleAuthorizeBluetooth} |
| | | authSuccess={deps.bluetoothAuthSuccess} |
| | | onDoorUnlockSuccess={deps.onDoorUnlockSuccess} |
| | | pageDidShowVersion={deps.pageDidShowVersion} |
| | | queuePollingTimeSeconds={deps.queuePollingTimeSeconds} |
| | |
| | | tripStatus, |
| | | vehicleSpeed, |
| | | routePoints, |
| | | mapScale, |
| | | setMapScale: viewportState.setMapScale, |
| | | getDynamicScaleHoldUntil, |
| | | computeBottomPadding, |
| | | resolveSafeTopInset, |
| | |
| | | handleCloseAuthBluetoothPopup, |
| | | handleCloseSuccessAuthPopup, |
| | | handleAuthorizeBluetooth, |
| | | bluetoothAuthSuccess, |
| | | onDoorUnlockSuccess: markManualDoorUnlock, |
| | | pageDidShowVersion, |
| | | queuePollingTimeSeconds |
| | |
| | | /** |
| | | * 选点页业务编排:聚合状态容器与副作用 hooks,并对外提供稳定页面接口。 |
| | | */ |
| | | export default function useSelectMapSite() { |
| | | export default function useSelectMapSite(props?: { onScaleChange?: (scale: number) => void }) { |
| | | const state = useSelectMapSiteState(); // 选点页状态容器 |
| | | |
| | | /** |
| | |
| | | } |
| | | 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; |
| | |
| | | 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); |
| | | } |
| | |
| | | * 地图选点页入口:负责 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); |
| | | |
| | |
| | | longitude: vehicleLocation.longitude |
| | | }, |
| | | autoRotate: false, |
| | | moveWithRotate: false, |
| | | rotate: |
| | | typeof vehicleDirection === "number" |
| | | ? ((vehicleDirection % 360) + 360) % 360 |