diff --git a/src/components/map/ThreeMap.vue b/src/components/map/ThreeMap.vue index dd0b6f8..214318b 100644 --- a/src/components/map/ThreeMap.vue +++ b/src/components/map/ThreeMap.vue @@ -69,6 +69,12 @@ import { guideModelLoadManager, type GuideModelResourceSet } from '@/services/model/GuideModelLoadManager' +import { + GuideAutoSwitchStateMachine, + type GuideAutoSwitchDirection, + type GuideAutoSwitchInputSource, + type GuideAutoSwitchRequest +} from './guideAutoSwitchStateMachine' type ViewMode = 'overview' | 'floor' | 'multi' type CameraPreset = 'top' | 'oblique' @@ -193,6 +199,7 @@ interface CameraFitOptions { targetOffsetRatio?: THREE.Vector3 screenOffsetRatio?: THREE.Vector2 up?: THREE.Vector3 + onComplete?: () => void } interface LoadFloorOptions { @@ -337,8 +344,6 @@ const props = withDefaults(defineProps<{ showRoute?: boolean autoSwitch?: boolean disableAutoExit?: boolean - autoSwitchThresholdLow?: number - autoSwitchThresholdHigh?: number autoSwitchCooldown?: number }>(), { assetBaseUrl: '', @@ -354,9 +359,7 @@ const props = withDefaults(defineProps<{ showRoute: false, autoSwitch: true, disableAutoExit: false, - autoSwitchThresholdLow: 1.45, - autoSwitchThresholdHigh: 3.8, - autoSwitchCooldown: 2000 + autoSwitchCooldown: 1500 }) const emit = defineEmits<{ @@ -456,28 +459,14 @@ let desktopRotateModifiers = { } let cachedOverviewModel: THREE.Object3D | null = null let cachedSharedModelUrl = '' -let autoSwitchOverviewMaxDim = 0 -// 自动切换状态:建筑外观积极进入,馆内锁定后再二段退出。 +// 自动切换状态:所有缩放输入统一使用相对距离状态机。 let isAutoSwitchLocked = false -let lastAutoSwitchTime = 0 -let lastControlDistance = 0 -let activeControlGestureStartedAt = 0 -let indoorEntryGestureLocked = false -let indoorContextLockedUntil = 0 -let floorExitCandidateStartedAt = 0 -let floorExitCandidateBaseDistance = 0 -let floorExitZoomOutAccumulatedRatio = 0 let autoSwitchTemporarilyDisabled = false let autoSwitchDisableTimer: ReturnType | null = null -let overviewEntryIntentStartedAt = 0 let modelAdjustReportTimer: ReturnType | null = null let hasPendingManualModelAdjustment = false -let overviewEntryStartDistance = 0 -let overviewEntryZoomInRounds = 0 -let overviewWheelZoomInRounds = 0 -let overviewWheelZoomIntentUntil = 0 -let overviewWheelAutoSwitchTimer: ReturnType | null = null +let activeAutoSwitchInputSource: GuideAutoSwitchInputSource = 'gesture' let isProgrammaticCameraChange = false let programmaticCameraTimer: ReturnType | null = null let cameraTween: CameraTweenState | null = null @@ -495,21 +484,9 @@ const poiScreenHitRadiusPx = 64 const poiScreenHitRadiusCorePx = 76 const poiAmbientLabelHallSpacingPx = 92 const poiAmbientLabelServiceSpacingPx = 72 -const indoorContextLockMs = 5000 -const indoorGestureExitGraceMs = 900 -const overviewEntryIntentWindowMs = 1200 -const overviewEntryMinZoomInRatio = 0.04 -const overviewEntryAccumulatedRatio = 0.2 -const overviewEntryMinRounds = 2 -const overviewEntryMaxRounds = 4 -const overviewWheelEntryDelayMs = 180 -const floorExitCandidateThresholdMultiplier = 1.12 -const floorExitConfirmThresholdMultiplier = 1.34 -const floorExitZoomOutIntentMinDeltaRatio = 0.015 -const floorExitAccumulatedZoomOutRatio = 0.26 -const floorExitCandidateHoldMs = 900 const modelLoadRetryDelaysMs = [300, 900] const adjacentPreloadDelayMs = 900 +const manualAutoSwitchPauseMs = 1500 const poiMarkerVisualLiftFactor = 0.92 const poiAmbientLabelLiftFactor = 2.1 const poiFocusLabelLiftFactor = 2.72 @@ -1593,7 +1570,10 @@ const cancelCameraTween = (options: { manual?: boolean } = {}) => { clearProgrammaticCameraTimer() const distance = controls?.getDistance() if (typeof distance === 'number' && Number.isFinite(distance)) { - lastControlDistance = distance + if (activeView.value === 'floor') { + // 用户中断楼层初始拟合时,使用中断后的稳定距离重新建立退出基准。 + autoSwitchStateMachine.setFloorInitialDistance(distance) + } } } } @@ -1610,11 +1590,6 @@ const moveCameraTo = ( isProgrammaticCameraChange = true clearProgrammaticCameraTimer() - const nextDistance = toPosition.distanceTo(toTarget) - if (Number.isFinite(nextDistance)) { - lastControlDistance = nextDistance - } - if (!cameraTweenEnabled || options.immediate) { cameraTween = null camera.position.copy(toPosition) @@ -1689,7 +1664,10 @@ const updatePoiTapFeedback = (now: number) => { } const handleControlStart = () => { - activeControlGestureStartedAt = Date.now() + const distance = controls?.getDistance() + if (typeof distance === 'number' && Number.isFinite(distance)) { + autoSwitchStateMachine.beginInput(distance, activeAutoSwitchInputSource) + } if (!isProgrammaticCameraChange) { hasPendingManualModelAdjustment = true clearModelAdjustReportTimer() @@ -1698,12 +1676,7 @@ const handleControlStart = () => { } const handleControlEnd = () => { - if (indoorEntryGestureLocked) { - indoorContextLockedUntil = Math.max(indoorContextLockedUntil, Date.now() + indoorGestureExitGraceMs) - indoorEntryGestureLocked = false - } - activeControlGestureStartedAt = 0 - updateLastControlDistance() + activeAutoSwitchInputSource = 'gesture' scheduleModelAdjustIdleReport() } @@ -2491,7 +2464,6 @@ const showFocusHallHighlight = (poi: RenderPoi) => { } const disposeCachedOverviewModel = () => { - autoSwitchOverviewMaxDim = 0 if (!cachedOverviewModel) return disposeObject(cachedOverviewModel) @@ -2885,15 +2857,6 @@ const getObjectSize = (object: THREE.Object3D) => ( getObjectBox(object).getSize(new THREE.Vector3()) ) -const getObjectMaxDim = (object: THREE.Object3D) => { - const size = getObjectSize(object) - return Math.max(size.x, size.y, size.z, 1) -} - -const cacheAutoSwitchOverviewMaxDim = (model: THREE.Object3D) => { - autoSwitchOverviewMaxDim = getObjectMaxDim(model) -} - const getMultiFloorVerticalGap = (items: MultiFloorModelItem[]) => { const maxFloorHeight = Math.max(...items.map((item) => item.size.y), 1) const maxFootprint = Math.max(...items.map((item) => Math.max(item.size.x, item.size.z)), 1) @@ -3096,229 +3059,58 @@ const disposeDetachedMultiFloorModels = ( .forEach((item) => disposeObject(item.model, protectedResources)) } -const updateLastControlDistance = () => { - if (!controls) return - - const distance = controls.getDistance() - if (Number.isFinite(distance)) { - lastControlDistance = distance - } -} - -const resetOverviewEntryIntent = () => { - overviewEntryIntentStartedAt = 0 - overviewEntryStartDistance = 0 - overviewEntryZoomInRounds = 0 - overviewWheelZoomInRounds = 0 - overviewWheelZoomIntentUntil = 0 -} - -const resetFloorExitCandidate = () => { - floorExitCandidateStartedAt = 0 - floorExitCandidateBaseDistance = 0 - floorExitZoomOutAccumulatedRatio = 0 -} - -const markFloorAutoSwitchEntry = () => { - indoorContextLockedUntil = Date.now() + indoorContextLockMs - indoorEntryGestureLocked = activeControlGestureStartedAt > 0 - resetOverviewEntryIntent() - resetFloorExitCandidate() - updateLastControlDistance() -} - const resetAutoSwitchDistanceTracking = () => { - activeControlGestureStartedAt = 0 - indoorEntryGestureLocked = false - indoorContextLockedUntil = 0 - resetOverviewEntryIntent() - resetFloorExitCandidate() - updateLastControlDistance() + activeAutoSwitchInputSource = 'gesture' + autoSwitchStateMachine.reset(activeView.value) } -const clearOverviewWheelAutoSwitchTimer = () => { - if (overviewWheelAutoSwitchTimer) { - clearTimeout(overviewWheelAutoSwitchTimer) - overviewWheelAutoSwitchTimer = null - } -} - -const hasOverviewWheelEntryRounds = () => ( - activeView.value === 'overview' - && overviewWheelZoomInRounds >= overviewEntryMinRounds -) - -const hasPendingOverviewWheelEntryIntent = (now: number) => ( - hasOverviewWheelEntryRounds() - && isOverviewEntryIntentActive(now) -) - -const canRunPendingOverviewEntry = (now: number) => ( - props.autoSwitch - && !autoSwitchTemporarilyDisabled - && !isLoading.value - && !isAutoSwitchLocked - && now - lastAutoSwitchTime >= props.autoSwitchCooldown - && Boolean(controls && activeModel) -) - const handleWheelIntent = (event: WheelEvent) => { - if (!controls || activeView.value !== 'overview' || event.deltaY >= 0) return + if (!controls || activeView.value !== 'overview' || event.deltaY === 0) return const distance = controls.getDistance() if (!Number.isFinite(distance)) return - const now = Date.now() - if (!isOverviewEntryIntentActive(now)) { - overviewEntryIntentStartedAt = now - overviewEntryStartDistance = distance - overviewEntryZoomInRounds = 0 - overviewWheelZoomInRounds = 0 - } - overviewEntryIntentStartedAt = now - overviewEntryZoomInRounds += 1 - overviewWheelZoomInRounds += 1 - overviewWheelZoomIntentUntil = now + 900 - - clearOverviewWheelAutoSwitchTimer() - overviewWheelAutoSwitchTimer = setTimeout(() => { - const triggerNow = Date.now() - overviewWheelAutoSwitchTimer = null - if (hasPendingOverviewWheelEntryIntent(triggerNow) && canRunPendingOverviewEntry(triggerNow)) { - checkAutoSwitch({ forceOverviewEntry: true }) - } - }, overviewWheelEntryDelayMs) + activeAutoSwitchInputSource = 'wheel' + autoSwitchStateMachine.beginInput(distance, 'wheel') } -const isOverviewEntryIntentActive = (now: number) => ( - overviewEntryIntentStartedAt > 0 - && ( - now - overviewEntryIntentStartedAt <= overviewEntryIntentWindowMs - || now <= overviewWheelZoomIntentUntil - ) -) - -const getOverviewEntryAccumulatedRatio = (distance: number) => { - if (!overviewEntryStartDistance || !Number.isFinite(distance)) return 0 - - return Math.max(0, 1 - distance / overviewEntryStartDistance) -} - -const trackOverviewEntryIntent = (distance: number, previousDistance: number, now: number) => { - if (!Number.isFinite(distance) || !Number.isFinite(previousDistance) || previousDistance <= 0) return - - const zoomInRatio = (previousDistance - distance) / previousDistance - const hasClearZoomIn = zoomInRatio >= overviewEntryMinZoomInRatio - - if (!hasClearZoomIn) { - if (!isOverviewEntryIntentActive(now)) { - resetOverviewEntryIntent() - } - return - } - - if (!isOverviewEntryIntentActive(now) || !overviewEntryStartDistance || distance > overviewEntryStartDistance) { - overviewEntryIntentStartedAt = now - overviewEntryStartDistance = previousDistance - overviewEntryZoomInRounds = 0 - } - - overviewEntryIntentStartedAt = now - overviewEntryZoomInRounds += 1 - - const accumulatedRatio = getOverviewEntryAccumulatedRatio(distance) - overviewEntryZoomInRounds = Math.max( - overviewEntryZoomInRounds, - Math.floor(accumulatedRatio / overviewEntryMinZoomInRatio) - ) -} - -const shouldEnterFloorFromOverview = ( - distance: number, - maxDim: number, - now: number, - hasForcedOverviewEntryIntent = false -) => { - const enterFloorAt = maxDim * props.autoSwitchThresholdLow - if (distance <= enterFloorAt) return true - - if (hasForcedOverviewEntryIntent) return true - if (!isOverviewEntryIntentActive(now)) return false - - const accumulatedRatio = getOverviewEntryAccumulatedRatio(distance) - const hasRepeatedZoomIn = overviewEntryZoomInRounds >= overviewEntryMinRounds - && accumulatedRatio >= overviewEntryAccumulatedRatio - const hasSustainedZoomIn = overviewEntryZoomInRounds >= overviewEntryMaxRounds - && accumulatedRatio >= overviewEntryAccumulatedRatio * 0.68 - - return hasRepeatedZoomIn || hasSustainedZoomIn -} - -const isFloorAutoExitLocked = (now: number) => ( - props.disableAutoExit - || Boolean(props.targetFocus) - || indoorEntryGestureLocked - || now < indoorContextLockedUntil -) - -const checkAutoSwitch = (options: { forceOverviewEntry?: boolean } = {}) => { - const now = Date.now() - const hasForcedOverviewEntryIntent = options.forceOverviewEntry === true - && hasOverviewWheelEntryRounds() - && canRunPendingOverviewEntry(now) - +const canRunAutoSwitch = (direction: GuideAutoSwitchDirection) => { if ( !props.autoSwitch || autoSwitchTemporarilyDisabled || isLoading.value - || (!hasForcedOverviewEntryIntent && isProgrammaticCameraChange) + || isAutoSwitchLocked + || isProgrammaticCameraChange + || !controls + || !activeModel + || activeView.value === 'multi' ) { + return false + } + + if (direction === 'exit-overview') { + return !props.disableAutoExit && !props.targetFocus && activeView.value === 'floor' + } + + return activeView.value === 'overview' +} + +const requestAutoSwitch = (request: GuideAutoSwitchRequest) => { + if (!canRunAutoSwitch(request.direction)) { + autoSwitchStateMachine.markTransitionFailed() return } - if (isAutoSwitchLocked) { - emit('autoSwitchBlocked', { reason: 'loading-locked' }) - return - } - - if (now - lastAutoSwitchTime < props.autoSwitchCooldown) { - emit('autoSwitchBlocked', { reason: 'cooldown' }) - return - } - - if (!controls || !activeModel || activeView.value === 'multi') return - - const distance = controls.getDistance() - const previousDistance = lastControlDistance || distance - const distanceDelta = distance - previousDistance - lastControlDistance = distance - - // 自动切换使用全馆稳定尺寸,避免单楼层可见性过滤压低 floor -> overview 阈值。 - const maxDim = autoSwitchOverviewMaxDim || getObjectMaxDim(activeModel) - - if (activeView.value === 'overview') { - if (!hasForcedOverviewEntryIntent) { - trackOverviewEntryIntent(distance, previousDistance, now) - } - - if (!shouldEnterFloorFromOverview(distance, maxDim, now, hasForcedOverviewEntryIntent)) { - return - } - + isAutoSwitchLocked = true + if (request.direction === 'enter-floor') { const targetFloorId = getAutoEntryTargetFloorId() const hasSceneToKeep = hasRenderableSceneForFloorTransition() - isAutoSwitchLocked = true - lastAutoSwitchTime = now - resetFloorExitCandidate() - resetOverviewEntryIntent() - clearOverviewWheelAutoSwitchTimer() - void runAutoSwitchLoad( { from: 'overview', to: 'floor', trigger: 'zoom-in', - distance + distance: request.distance }, async () => { await loadFloor(targetFloorId, { @@ -3332,65 +3124,33 @@ const checkAutoSwitch = (options: { forceOverviewEntry?: boolean } = {}) => { return } - if (activeView.value !== 'floor') { - resetFloorExitCandidate() - return - } - - if (isFloorAutoExitLocked(now)) { - resetFloorExitCandidate() - return - } - - const baseExitThreshold = maxDim * props.autoSwitchThresholdHigh - const candidateExitAt = baseExitThreshold * floorExitCandidateThresholdMultiplier - const confirmExitAt = baseExitThreshold * floorExitConfirmThresholdMultiplier - const isBeyondCandidateThreshold = distance >= candidateExitAt - - if (!isBeyondCandidateThreshold) { - resetFloorExitCandidate() - return - } - - if (!floorExitCandidateStartedAt) { - floorExitCandidateStartedAt = now - floorExitCandidateBaseDistance = Math.max(previousDistance, distance, 1) - floorExitZoomOutAccumulatedRatio = 0 - return - } - - const zoomOutRatio = previousDistance > 0 ? distanceDelta / previousDistance : 0 - const isClearZoomOutIntent = zoomOutRatio >= floorExitZoomOutIntentMinDeltaRatio - if (isClearZoomOutIntent || distance > floorExitCandidateBaseDistance) { - floorExitZoomOutAccumulatedRatio = Math.max( - floorExitZoomOutAccumulatedRatio, - distance / Math.max(floorExitCandidateBaseDistance, 1) - 1 - ) - } - - const hasConfirmedScale = distance >= confirmExitAt - && floorExitZoomOutAccumulatedRatio >= floorExitAccumulatedZoomOutRatio - const hasHeldCandidate = now - floorExitCandidateStartedAt >= floorExitCandidateHoldMs - - if (!hasConfirmedScale || !hasHeldCandidate) { - return - } - - isAutoSwitchLocked = true - lastAutoSwitchTime = now - resetFloorExitCandidate() - void runAutoSwitchLoad( { from: 'floor', to: 'overview', trigger: 'zoom-out', - distance + distance: request.distance }, loadOverview ) } +const autoSwitchStateMachine = new GuideAutoSwitchStateMachine({ + cooldownMs: props.autoSwitchCooldown, + canSwitch: canRunAutoSwitch, + onSwitchRequested: requestAutoSwitch +}) + +const checkAutoSwitch = (source: GuideAutoSwitchInputSource = activeAutoSwitchInputSource) => { + if (!controls || isProgrammaticCameraChange) return + + const distance = controls.getDistance() + if (!Number.isFinite(distance)) return + + autoSwitchStateMachine.setView(activeView.value) + autoSwitchStateMachine.updateDistance(distance, { source }) +} + const runAutoSwitchLoad = async ( event: { from: 'overview' | 'floor'; to: 'overview' | 'floor'; trigger: 'zoom-in' | 'zoom-out'; distance: number }, loadTask: () => Promise, @@ -3407,8 +3167,10 @@ const runAutoSwitchLoad = async ( if (showLoading) { isLoading.value = false } + autoSwitchStateMachine.markTransitionSucceeded() emit('autoSwitch', event) } catch (error) { + autoSwitchStateMachine.markTransitionFailed() if (isStaleModelLoadError(error)) return console.error('馆内 3D 自动视角切换失败:', error) @@ -3475,7 +3237,7 @@ const setCameraView = ( } controls.minDistance = Math.max(SGS_VISUAL_RENDER_CONFIG.controls.minDistance, maxDim * 0.08) controls.maxDistance = Math.max(SGS_VISUAL_RENDER_CONFIG.controls.maxDistance, maxDim * 8) - moveCameraTo(nextPosition, nextTarget) + moveCameraTo(nextPosition, nextTarget, { onComplete: options.onComplete }) } const fitCameraToObject = (object: THREE.Object3D, preset: CameraPreset = 'oblique') => { @@ -3503,8 +3265,17 @@ const fitCameraToObject = (object: THREE.Object3D, preset: CameraPreset = 'obliq screenOffsetRatio: SGS_VISUAL_RENDER_CONFIG.framing.floorScreenOffsetRatio, up: new THREE.Vector3(0, 1, 0) } - : {} + : {} + overviewFitOptions.onComplete = () => { + if (activeView.value !== 'floor' || !controls) return + + const distance = controls.getDistance() + if (Number.isFinite(distance)) { + // 楼层切换或相机复位完成后,以最终拟合距离作为退出基准。 + autoSwitchStateMachine.setFloorInitialDistance(distance) + } + } setCameraView(center, maxDim, direction, overviewFitOptions) } @@ -3523,11 +3294,6 @@ const applyOverviewInitialCamera = () => { camera.updateProjectionMatrix() controls.target.copy(OVERVIEW_INITIAL_CAMERA_PARAMS.target) controls.update() - - const distance = camera.position.distanceTo(controls.target) - if (Number.isFinite(distance)) { - lastControlDistance = distance - } } const resetCamera = () => { @@ -3555,33 +3321,16 @@ const zoomCamera = (direction: 'in' | 'out', options: { source?: ZoomCameraSourc if (!Number.isFinite(nextDistance) || nextDistance <= 0) return const shouldTrackUserZoom = options.source === 'button' - - if (shouldTrackUserZoom && direction === 'in' && activeView.value === 'overview') { - const now = Date.now() - if (!isOverviewEntryIntentActive(now)) { - overviewEntryIntentStartedAt = now - overviewEntryStartDistance = currentDistance - overviewEntryZoomInRounds = 0 - } - overviewEntryIntentStartedAt = now - overviewEntryZoomInRounds += 1 - overviewWheelZoomInRounds += 1 - overviewWheelZoomIntentUntil = now + 900 + if (shouldTrackUserZoom) { + activeAutoSwitchInputSource = 'button' + autoSwitchStateMachine.beginInput(currentDistance, 'button') } offset.setLength(nextDistance) const nextPosition = controls.target.clone().add(offset) moveCameraTo(nextPosition, controls.target, { - onComplete: handleControlChange + onComplete: () => handleControlChange() }) - if (shouldTrackUserZoom) { - lastControlDistance = currentDistance - checkAutoSwitch({ - forceOverviewEntry: direction === 'in' && activeView.value === 'overview' - }) - } - - handleControlChange() } const loadCurrentFloorPoiMarkers = async (loadToken: number) => { @@ -3635,6 +3384,7 @@ const loadOverview = async () => { const loadToken = startModelLoad() activeView.value = 'overview' + autoSwitchStateMachine.setView('overview') syncControlInteractionOptions() clearSceneData() @@ -3647,7 +3397,6 @@ const loadOverview = async () => { activeModel.visible = true applyIndoorInitialModelTransform(activeModel) applyModelVisibilityForView(activeModel, 'overview') - cacheAutoSwitchOverviewMaxDim(activeModel) targetScene.add(activeModel) applyOverviewInitialCamera() loadCurrentFloorPoiMarkersInBackground(loadToken) @@ -3677,7 +3426,6 @@ const loadOverview = async () => { prepareModel(activeModel) applyIndoorInitialModelTransform(activeModel) applyModelVisibilityForView(activeModel, 'overview') - cacheAutoSwitchOverviewMaxDim(activeModel) cachedOverviewModel = activeModel cachedSharedModelUrl = packageData.overviewModelUrl targetScene.add(activeModel) @@ -3742,10 +3490,6 @@ const prepareFloorScene = async ( applyIndoorInitialModelTransform(model) applyModelVisibilityForView(model, 'floor', requestedFloorId) cacheAsSharedModel = Boolean(floor.sharedModelAsset) - - if (cacheAsSharedModel) { - cacheAutoSwitchOverviewMaxDim(model) - } } if (!model) { @@ -3786,6 +3530,8 @@ const commitPreparedFloorScene = ( } activeView.value = 'floor' + autoSwitchStateMachine.setView('floor') + autoSwitchStateMachine.clearFloorInitialDistance() syncControlInteractionOptions() currentFloor.value = expectedFloorId clearSceneData() @@ -3812,7 +3558,6 @@ const commitPreparedFloorScene = ( updatePoiMarkerFocus() hasLoadedFloorViewOnce = true fitCameraToObject(activeModel) - markFloorAutoSwitchEntry() refreshPoiVisibilityByDistance() renderRoutePreview() markFirstModelVisible('floor', { @@ -3869,6 +3614,7 @@ const loadMultiFloor = async () => { const loadToken = startModelLoad() activeView.value = 'multi' + autoSwitchStateMachine.setView('multi') syncControlInteractionOptions() clearSceneData() setProgress(18, '正在加载多层展示模型...') @@ -3897,7 +3643,6 @@ const loadMultiFloor = async () => { sourceModel.name = 'GuideOverviewModel' prepareModel(sourceModel) applyModelVisibilityForView(sourceModel, 'overview') - cacheAutoSwitchOverviewMaxDim(sourceModel) cachedOverviewModel = sourceModel cachedSharedModelUrl = sharedModelUrl } @@ -4791,7 +4536,7 @@ const handleSceneTap = async (event: PointerEvent) => { if (hitMarker?.userData.poi) { const selectedPoi = hitMarker.userData.poi as RenderPoi if (activeView.value === 'overview') { - disableAutoSwitchTemporarily(10000) + disableAutoSwitchTemporarily(manualAutoSwitchPauseMs) try { await loadFloor(selectedPoi.floorId, { preserveCurrentSceneUntilReady: hasRenderableSceneForFloorTransition(), @@ -4826,7 +4571,7 @@ const handleSceneTap = async (event: PointerEvent) => { .find((object): object is THREE.Object3D => Boolean(object)) if (floorObject?.userData.floorId) { - disableAutoSwitchTemporarily(10000) + disableAutoSwitchTemporarily(manualAutoSwitchPauseMs) void handleFloorChange(floorObject.userData.floorId as string) return } @@ -4841,6 +4586,12 @@ const handlePointerDown = (event: PointerEvent) => { if (event.pointerType === 'touch' && getActiveTouchPointerCount() >= 2) { hadMultiPointerGesture = true pointerDownState = null + activeAutoSwitchInputSource = 'touch' + const distance = controls?.getDistance() + if (typeof distance === 'number' && Number.isFinite(distance)) { + // 双指手势从第二个触点按下时重新记录连续缩放基准。 + autoSwitchStateMachine.beginInput(distance, 'touch', { resetBaseline: true }) + } } if (event.pointerType !== 'touch') { updateDesktopRotateModifierState(event) @@ -5273,19 +5024,14 @@ const disposeScene = () => { autoSwitchDisableTimer = null } - clearOverviewWheelAutoSwitchTimer() + autoSwitchStateMachine.dispose() clearProgrammaticCameraTimer() clearModelAdjustReportTimer() hasPendingManualModelAdjustment = false cameraTween = null isProgrammaticCameraChange = false - lastControlDistance = 0 - activeControlGestureStartedAt = 0 + activeAutoSwitchInputSource = 'gesture' resetInteractionGateState() - indoorEntryGestureLocked = false - indoorContextLockedUntil = 0 - resetOverviewEntryIntent() - resetFloorExitCandidate() clearSceneData() disposePoiMarkerCache() diff --git a/src/components/map/guideAutoSwitchStateMachine.ts b/src/components/map/guideAutoSwitchStateMachine.ts new file mode 100644 index 0000000..28217e2 --- /dev/null +++ b/src/components/map/guideAutoSwitchStateMachine.ts @@ -0,0 +1,251 @@ +export type GuideAutoSwitchView = 'overview' | 'floor' | 'multi' +export type GuideAutoSwitchDirection = 'enter-floor' | 'exit-overview' +export type GuideAutoSwitchInputSource = 'wheel' | 'button' | 'touch' | 'gesture' + +export interface GuideAutoSwitchRequest { + direction: GuideAutoSwitchDirection + distance: number + source: GuideAutoSwitchInputSource +} + +interface GuideAutoSwitchStateMachineOptions { + onSwitchRequested: (request: GuideAutoSwitchRequest) => void + canSwitch?: (direction: GuideAutoSwitchDirection) => boolean + now?: () => number + enterRatio?: number + exitRatio?: number + holdMs?: number + cooldownMs?: number + intentTimeoutMs?: number +} + +interface DistanceUpdateOptions { + previousDistance?: number + source?: GuideAutoSwitchInputSource +} + +interface BeginInputOptions { + resetBaseline?: boolean +} + +interface PendingCandidate { + direction: GuideAutoSwitchDirection + timer: ReturnType +} + +const isValidDistance = (distance: number) => Number.isFinite(distance) && distance > 0 + +export class GuideAutoSwitchStateMachine { + private readonly onSwitchRequested: (request: GuideAutoSwitchRequest) => void + private readonly canSwitch: (direction: GuideAutoSwitchDirection) => boolean + private readonly now: () => number + private readonly enterRatio: number + private readonly exitRatio: number + private readonly holdMs: number + private readonly cooldownMs: number + private readonly intentTimeoutMs: number + + // 外观以连续缩放起点为基准,楼层以相机拟合完成后的距离为基准。 + private view: GuideAutoSwitchView = 'overview' + private overviewStartDistance = 0 + private overviewLastDistance = 0 + private overviewLastInputAt: number | null = null + private floorInitialDistance = 0 + private currentDistance = 0 + private currentSource: GuideAutoSwitchInputSource = 'gesture' + private cooldownUntil = 0 + private transitionPending = false + private candidate: PendingCandidate | null = null + + constructor(options: GuideAutoSwitchStateMachineOptions) { + this.onSwitchRequested = options.onSwitchRequested + this.canSwitch = options.canSwitch || (() => true) + this.now = options.now || Date.now + this.enterRatio = options.enterRatio ?? 0.15 + this.exitRatio = options.exitRatio ?? 0.2 + this.holdMs = options.holdMs ?? 500 + this.cooldownMs = options.cooldownMs ?? 1500 + this.intentTimeoutMs = options.intentTimeoutMs ?? 1200 + } + + setView(view: GuideAutoSwitchView) { + if (this.view === view) return + + this.cancelCandidate() + this.view = view + this.resetOverviewTracking() + if (view !== 'floor') { + this.floorInitialDistance = 0 + } + } + + beginInput( + distance: number, + source: GuideAutoSwitchInputSource, + options: BeginInputOptions = {} + ) { + if (this.view !== 'overview' || !isValidDistance(distance)) return + + const now = this.now() + const intentExpired = this.hasOverviewIntentExpired(now) + const reversed = this.overviewLastDistance > 0 && distance > this.overviewLastDistance + if ( + options.resetBaseline + || !this.overviewStartDistance + || intentExpired + || reversed + ) { + this.cancelCandidate('enter-floor') + this.overviewStartDistance = distance + } + + this.overviewLastDistance = distance + this.overviewLastInputAt = now + this.currentDistance = distance + this.currentSource = source + } + + updateDistance(distance: number, options: DistanceUpdateOptions = {}) { + if (!isValidDistance(distance) || this.view === 'multi') { + this.cancelCandidate() + return + } + + const now = this.now() + const source = options.source || this.currentSource + this.currentDistance = distance + this.currentSource = source + + if (this.view === 'overview') { + this.updateOverviewDistance(distance, now, options.previousDistance) + return + } + + const shouldExit = this.floorInitialDistance > 0 + && distance >= this.floorInitialDistance * (1 + this.exitRatio) + this.updateCandidate('exit-overview', shouldExit) + } + + setFloorInitialDistance(distance: number) { + this.cancelCandidate('exit-overview') + this.floorInitialDistance = isValidDistance(distance) ? distance : 0 + this.currentDistance = this.floorInitialDistance + } + + clearFloorInitialDistance() { + this.cancelCandidate('exit-overview') + this.floorInitialDistance = 0 + } + + markTransitionSucceeded() { + this.transitionPending = false + this.cancelCandidate() + this.cooldownUntil = this.now() + this.cooldownMs + this.resetOverviewTracking() + } + + markTransitionFailed() { + this.transitionPending = false + this.cancelCandidate() + } + + reset(view: GuideAutoSwitchView = this.view) { + this.cancelCandidate() + this.view = view + this.transitionPending = false + this.cooldownUntil = 0 + this.floorInitialDistance = 0 + this.currentDistance = 0 + this.resetOverviewTracking() + } + + dispose() { + this.cancelCandidate() + } + + private updateOverviewDistance(distance: number, now: number, previousDistance?: number) { + const fallbackStartDistance = typeof previousDistance === 'number' && isValidDistance(previousDistance) + ? previousDistance + : distance + const intentExpired = this.hasOverviewIntentExpired(now) + const reversed = this.overviewLastDistance > 0 && distance > this.overviewLastDistance + + if (!this.overviewStartDistance || intentExpired || reversed) { + this.cancelCandidate('enter-floor') + this.overviewStartDistance = reversed ? distance : fallbackStartDistance + } + + this.overviewLastDistance = distance + this.overviewLastInputAt = now + + const shouldEnter = distance <= this.overviewStartDistance * (1 - this.enterRatio) + this.updateCandidate('enter-floor', shouldEnter) + } + + private hasOverviewIntentExpired(now: number) { + return this.overviewLastInputAt !== null + && now - this.overviewLastInputAt > this.intentTimeoutMs + } + + private updateCandidate(direction: GuideAutoSwitchDirection, conditionMet: boolean) { + if (!conditionMet) { + this.cancelCandidate(direction) + return + } + + if (this.transitionPending || this.candidate?.direction === direction) return + if (!this.canSwitch(direction)) return + + this.cancelCandidate() + // 条件必须保持 500ms;冷却尚未结束时主动等到更晚的确认时刻。 + const delay = Math.max(this.holdMs, this.cooldownUntil - this.now()) + const timer = setTimeout(() => { + this.confirmCandidate(direction) + }, Math.max(0, delay)) + this.candidate = { direction, timer } + } + + private confirmCandidate(direction: GuideAutoSwitchDirection) { + if (this.candidate?.direction !== direction) return + this.candidate = null + + if (this.transitionPending || !this.isConditionMet(direction)) return + if (this.now() < this.cooldownUntil) { + this.updateCandidate(direction, true) + return + } + if (!this.canSwitch(direction)) return + + this.transitionPending = true + this.onSwitchRequested({ + direction, + distance: this.currentDistance, + source: this.currentSource + }) + } + + private isConditionMet(direction: GuideAutoSwitchDirection) { + if (direction === 'enter-floor') { + return this.view === 'overview' + && this.overviewStartDistance > 0 + && this.currentDistance <= this.overviewStartDistance * (1 - this.enterRatio) + } + + return this.view === 'floor' + && this.floorInitialDistance > 0 + && this.currentDistance >= this.floorInitialDistance * (1 + this.exitRatio) + } + + private cancelCandidate(direction?: GuideAutoSwitchDirection) { + if (!this.candidate || (direction && this.candidate.direction !== direction)) return + + clearTimeout(this.candidate.timer) + this.candidate = null + } + + private resetOverviewTracking() { + this.overviewStartDistance = 0 + this.overviewLastDistance = 0 + this.overviewLastInputAt = null + } +} diff --git a/src/components/navigation/GuideMapShell.vue b/src/components/navigation/GuideMapShell.vue index 0ac1a3d..5b7e8fb 100644 --- a/src/components/navigation/GuideMapShell.vue +++ b/src/components/navigation/GuideMapShell.vue @@ -20,8 +20,6 @@ :route-preview="routePreview" :show-route="showRoute" :disable-auto-exit="disableAutoExit" - :auto-switch-threshold-low="autoSwitchThresholdLow" - :auto-switch-threshold-high="autoSwitchThresholdHigh" @floor-change="handleThreeFloorChange" @poi-click="handlePoiClick" @selection-clear="handleSelectionClear" @@ -358,8 +356,6 @@ const props = withDefaults(defineProps<{ routePreview?: GuideRouteResult | null showRoute?: boolean disableAutoExit?: boolean - autoSwitchThresholdLow?: number - autoSwitchThresholdHigh?: number outdoorNavPolylines?: OutdoorNavPolyline[] outdoorMarkers?: OutdoorMapMarker[] activeOutdoorMarkerId?: string @@ -411,8 +407,6 @@ const props = withDefaults(defineProps<{ routePreview: null, showRoute: false, disableAutoExit: false, - autoSwitchThresholdLow: 1.0, - autoSwitchThresholdHigh: 1.3, outdoorNavPolylines: () => [] as OutdoorNavPolyline[], outdoorMarkers: () => [] as OutdoorMapMarker[], activeOutdoorMarkerId: '', @@ -440,10 +434,13 @@ const emit = defineEmits<{ outdoorMarkerClick: [markerId: string] }>() +// 手动切换与自动切换共用 1500ms 保护,避免操作完成后立即反向跳转。 +const manualAutoSwitchPauseMs = 1500 + // 监听视角切换,循环切换:reset -> top -> oblique -> reset watch(() => props.cameraView, (view) => { if (props.mapType !== 'indoor') return - indoorRendererRef.value?.disableAutoSwitchTemporarily?.(10000) + indoorRendererRef.value?.disableAutoSwitchTemporarily?.(manualAutoSwitchPauseMs) if (view === 'reset') { indoorRendererRef.value?.resetCamera?.() @@ -595,7 +592,7 @@ const handleFloorChange = (floor: { id: string; label: string }) => { const requestSeq = ++floorSwitchRequestSeq activeFloorSwitchRequestSeq.value = requestSeq failedFloorId.value = '' - indoorRendererRef.value?.disableAutoSwitchTemporarily?.(10000) + indoorRendererRef.value?.disableAutoSwitchTemporarily?.(manualAutoSwitchPauseMs) emit('floorRequest', { floorId, floorLabel: floor.label @@ -620,8 +617,8 @@ const handleFloorChange = (floor: { id: string; label: string }) => { } const handleLayerModeChange = (mode: LayerDisplayMode) => { - // 手动切换展示层数时临时禁用缩放自动切换 10 秒 - indoorRendererRef.value?.disableAutoSwitchTemporarily?.(10000) + // 手动切换展示层数时使用统一的短保护期。 + indoorRendererRef.value?.disableAutoSwitchTemporarily?.(manualAutoSwitchPauseMs) if (mode === 'multi') { loadingFloorId.value = '' @@ -679,7 +676,7 @@ const handleFloorHeaderTap = () => { const handleToolClick = (tool: string) => { if (props.mapType === 'indoor') { - indoorRendererRef.value?.disableAutoSwitchTemporarily?.(10000) + indoorRendererRef.value?.disableAutoSwitchTemporarily?.(manualAutoSwitchPauseMs) if (tool === '复位') { indoorRendererRef.value?.resetCamera?.() @@ -709,7 +706,7 @@ const handleZoomClick = (direction: 'in' | 'out') => { } const handleShowOverview = async () => { - indoorRendererRef.value?.disableAutoSwitchTemporarily?.(10000) + indoorRendererRef.value?.disableAutoSwitchTemporarily?.(manualAutoSwitchPauseMs) loadingFloorId.value = '' requestedFloorId.value = '' requestedFloorLabel.value = '' diff --git a/src/pages/index/index.vue b/src/pages/index/index.vue index 50a639e..3e18a2e 100644 --- a/src/pages/index/index.vue +++ b/src/pages/index/index.vue @@ -45,8 +45,6 @@ :active-outdoor-marker-id="activeArrivalMarkerId" :outdoor-focus-offset-y="arrivalOutdoorFocusOffsetY" :disable-auto-exit="disableIndoorAutoExit" - :auto-switch-threshold-low="0.58" - :auto-switch-threshold-high="1.18" @mode-change="handleModeChange" @floor-request="handleFloorRequest" @floor-change="handleFloorChange" diff --git a/tests/unit/GuideAutoSwitchStateMachine.spec.ts b/tests/unit/GuideAutoSwitchStateMachine.spec.ts new file mode 100644 index 0000000..ef5b81b --- /dev/null +++ b/tests/unit/GuideAutoSwitchStateMachine.spec.ts @@ -0,0 +1,147 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + GuideAutoSwitchStateMachine, + type GuideAutoSwitchDirection, + type GuideAutoSwitchInputSource +} from '@/components/map/guideAutoSwitchStateMachine' + +const createMachine = () => { + const requests: GuideAutoSwitchDirection[] = [] + const machine = new GuideAutoSwitchStateMachine({ + onSwitchRequested: ({ direction }) => requests.push(direction) + }) + + return { machine, requests } +} + +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(0) +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('建筑外观与馆内楼层自动切换状态机', () => { + it('连续放大 14.9% 不进入,达到 15% 并保持 500ms 后进入', () => { + const { machine, requests } = createMachine() + + machine.beginInput(100, 'gesture') + machine.updateDistance(85.1, { source: 'gesture' }) + vi.advanceTimersByTime(500) + expect(requests).toEqual([]) + + machine.updateDistance(85, { source: 'gesture' }) + vi.advanceTimersByTime(499) + expect(requests).toEqual([]) + vi.advanceTimersByTime(1) + expect(requests).toEqual(['enter-floor']) + }) + + it('两次滚轮事件累计不足 15% 时不进入', () => { + const { machine, requests } = createMachine() + + machine.beginInput(100, 'wheel') + machine.updateDistance(95, { source: 'wheel' }) + machine.beginInput(95, 'wheel') + machine.updateDistance(90, { source: 'wheel' }) + vi.advanceTimersByTime(500) + + expect(requests).toEqual([]) + }) + + it('相对楼层初始距离拉远 19.9% 不退出,达到 20% 并保持 500ms 后退出', () => { + const { machine, requests } = createMachine() + + machine.setView('floor') + machine.setFloorInitialDistance(100) + machine.updateDistance(119.9) + vi.advanceTimersByTime(500) + expect(requests).toEqual([]) + + machine.updateDistance(120) + vi.advanceTimersByTime(499) + expect(requests).toEqual([]) + vi.advanceTimersByTime(1) + expect(requests).toEqual(['exit-overview']) + }) + + it('条件满足后反向缩放会取消防抖任务并重置进入基准', () => { + const { machine, requests } = createMachine() + + machine.beginInput(100, 'touch', { resetBaseline: true }) + machine.updateDistance(85, { source: 'touch' }) + vi.advanceTimersByTime(300) + machine.updateDistance(88, { source: 'touch' }) + vi.advanceTimersByTime(500) + + expect(requests).toEqual([]) + + machine.updateDistance(74.8, { source: 'touch' }) + vi.advanceTimersByTime(500) + expect(requests).toEqual(['enter-floor']) + }) + + it('退出条件满足后回到 20% 阈值内会取消防抖任务', () => { + const { machine, requests } = createMachine() + + machine.setView('floor') + machine.setFloorInitialDistance(100) + machine.updateDistance(120) + vi.advanceTimersByTime(300) + machine.updateDistance(119.9) + vi.advanceTimersByTime(500) + + expect(requests).toEqual([]) + }) + + it('进入意图超时后使用新的连续缩放距离重新计算基准', () => { + const { machine, requests } = createMachine() + + machine.beginInput(100, 'wheel') + machine.updateDistance(90, { source: 'wheel' }) + vi.advanceTimersByTime(1201) + machine.updateDistance(80, { previousDistance: 90, source: 'wheel' }) + vi.advanceTimersByTime(500) + expect(requests).toEqual([]) + + machine.updateDistance(76.5, { source: 'wheel' }) + vi.advanceTimersByTime(500) + expect(requests).toEqual(['enter-floor']) + }) + + it('切换成功后 1499ms 内不能反向切换,1500ms 后可以', () => { + const { machine, requests } = createMachine() + + machine.beginInput(100, 'wheel') + machine.updateDistance(85, { source: 'wheel' }) + vi.advanceTimersByTime(500) + expect(requests).toEqual(['enter-floor']) + + machine.markTransitionSucceeded() + machine.setView('floor') + machine.setFloorInitialDistance(100) + machine.updateDistance(120) + vi.advanceTimersByTime(1499) + expect(requests).toEqual(['enter-floor']) + vi.advanceTimersByTime(1) + expect(requests).toEqual(['enter-floor', 'exit-overview']) + }) + + it.each(['wheel', 'button', 'touch'])( + '%s 路径使用同一套 15% 进入判断', + (source) => { + const { machine, requests } = createMachine() + + machine.beginInput(100, source, { resetBaseline: source === 'touch' }) + machine.updateDistance(85.1, { source }) + vi.advanceTimersByTime(500) + expect(requests).toEqual([]) + + machine.updateDistance(85, { source }) + vi.advanceTimersByTime(500) + expect(requests).toEqual(['enter-floor']) + } + ) +})