@@ -69,6 +69,12 @@ import {
|
|||||||
guideModelLoadManager,
|
guideModelLoadManager,
|
||||||
type GuideModelResourceSet
|
type GuideModelResourceSet
|
||||||
} from '@/services/model/GuideModelLoadManager'
|
} from '@/services/model/GuideModelLoadManager'
|
||||||
|
import {
|
||||||
|
GuideAutoSwitchStateMachine,
|
||||||
|
type GuideAutoSwitchDirection,
|
||||||
|
type GuideAutoSwitchInputSource,
|
||||||
|
type GuideAutoSwitchRequest
|
||||||
|
} from './guideAutoSwitchStateMachine'
|
||||||
|
|
||||||
type ViewMode = 'overview' | 'floor' | 'multi'
|
type ViewMode = 'overview' | 'floor' | 'multi'
|
||||||
type CameraPreset = 'top' | 'oblique'
|
type CameraPreset = 'top' | 'oblique'
|
||||||
@@ -193,6 +199,7 @@ interface CameraFitOptions {
|
|||||||
targetOffsetRatio?: THREE.Vector3
|
targetOffsetRatio?: THREE.Vector3
|
||||||
screenOffsetRatio?: THREE.Vector2
|
screenOffsetRatio?: THREE.Vector2
|
||||||
up?: THREE.Vector3
|
up?: THREE.Vector3
|
||||||
|
onComplete?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LoadFloorOptions {
|
interface LoadFloorOptions {
|
||||||
@@ -337,8 +344,6 @@ const props = withDefaults(defineProps<{
|
|||||||
showRoute?: boolean
|
showRoute?: boolean
|
||||||
autoSwitch?: boolean
|
autoSwitch?: boolean
|
||||||
disableAutoExit?: boolean
|
disableAutoExit?: boolean
|
||||||
autoSwitchThresholdLow?: number
|
|
||||||
autoSwitchThresholdHigh?: number
|
|
||||||
autoSwitchCooldown?: number
|
autoSwitchCooldown?: number
|
||||||
}>(), {
|
}>(), {
|
||||||
assetBaseUrl: '',
|
assetBaseUrl: '',
|
||||||
@@ -354,9 +359,7 @@ const props = withDefaults(defineProps<{
|
|||||||
showRoute: false,
|
showRoute: false,
|
||||||
autoSwitch: true,
|
autoSwitch: true,
|
||||||
disableAutoExit: false,
|
disableAutoExit: false,
|
||||||
autoSwitchThresholdLow: 1.45,
|
autoSwitchCooldown: 1500
|
||||||
autoSwitchThresholdHigh: 3.8,
|
|
||||||
autoSwitchCooldown: 2000
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -456,28 +459,14 @@ let desktopRotateModifiers = {
|
|||||||
}
|
}
|
||||||
let cachedOverviewModel: THREE.Object3D | null = null
|
let cachedOverviewModel: THREE.Object3D | null = null
|
||||||
let cachedSharedModelUrl = ''
|
let cachedSharedModelUrl = ''
|
||||||
let autoSwitchOverviewMaxDim = 0
|
|
||||||
|
|
||||||
// 自动切换状态:建筑外观积极进入,馆内锁定后再二段退出。
|
// 自动切换状态:所有缩放输入统一使用相对距离状态机。
|
||||||
let isAutoSwitchLocked = false
|
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 autoSwitchTemporarilyDisabled = false
|
||||||
let autoSwitchDisableTimer: ReturnType<typeof setTimeout> | null = null
|
let autoSwitchDisableTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
let overviewEntryIntentStartedAt = 0
|
|
||||||
let modelAdjustReportTimer: ReturnType<typeof setTimeout> | null = null
|
let modelAdjustReportTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
let hasPendingManualModelAdjustment = false
|
let hasPendingManualModelAdjustment = false
|
||||||
let overviewEntryStartDistance = 0
|
let activeAutoSwitchInputSource: GuideAutoSwitchInputSource = 'gesture'
|
||||||
let overviewEntryZoomInRounds = 0
|
|
||||||
let overviewWheelZoomInRounds = 0
|
|
||||||
let overviewWheelZoomIntentUntil = 0
|
|
||||||
let overviewWheelAutoSwitchTimer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
let isProgrammaticCameraChange = false
|
let isProgrammaticCameraChange = false
|
||||||
let programmaticCameraTimer: ReturnType<typeof setTimeout> | null = null
|
let programmaticCameraTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
let cameraTween: CameraTweenState | null = null
|
let cameraTween: CameraTweenState | null = null
|
||||||
@@ -495,21 +484,9 @@ const poiScreenHitRadiusPx = 64
|
|||||||
const poiScreenHitRadiusCorePx = 76
|
const poiScreenHitRadiusCorePx = 76
|
||||||
const poiAmbientLabelHallSpacingPx = 92
|
const poiAmbientLabelHallSpacingPx = 92
|
||||||
const poiAmbientLabelServiceSpacingPx = 72
|
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 modelLoadRetryDelaysMs = [300, 900]
|
||||||
const adjacentPreloadDelayMs = 900
|
const adjacentPreloadDelayMs = 900
|
||||||
|
const manualAutoSwitchPauseMs = 1500
|
||||||
const poiMarkerVisualLiftFactor = 0.92
|
const poiMarkerVisualLiftFactor = 0.92
|
||||||
const poiAmbientLabelLiftFactor = 2.1
|
const poiAmbientLabelLiftFactor = 2.1
|
||||||
const poiFocusLabelLiftFactor = 2.72
|
const poiFocusLabelLiftFactor = 2.72
|
||||||
@@ -1593,7 +1570,10 @@ const cancelCameraTween = (options: { manual?: boolean } = {}) => {
|
|||||||
clearProgrammaticCameraTimer()
|
clearProgrammaticCameraTimer()
|
||||||
const distance = controls?.getDistance()
|
const distance = controls?.getDistance()
|
||||||
if (typeof distance === 'number' && Number.isFinite(distance)) {
|
if (typeof distance === 'number' && Number.isFinite(distance)) {
|
||||||
lastControlDistance = distance
|
if (activeView.value === 'floor') {
|
||||||
|
// 用户中断楼层初始拟合时,使用中断后的稳定距离重新建立退出基准。
|
||||||
|
autoSwitchStateMachine.setFloorInitialDistance(distance)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1610,11 +1590,6 @@ const moveCameraTo = (
|
|||||||
isProgrammaticCameraChange = true
|
isProgrammaticCameraChange = true
|
||||||
clearProgrammaticCameraTimer()
|
clearProgrammaticCameraTimer()
|
||||||
|
|
||||||
const nextDistance = toPosition.distanceTo(toTarget)
|
|
||||||
if (Number.isFinite(nextDistance)) {
|
|
||||||
lastControlDistance = nextDistance
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cameraTweenEnabled || options.immediate) {
|
if (!cameraTweenEnabled || options.immediate) {
|
||||||
cameraTween = null
|
cameraTween = null
|
||||||
camera.position.copy(toPosition)
|
camera.position.copy(toPosition)
|
||||||
@@ -1689,7 +1664,10 @@ const updatePoiTapFeedback = (now: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleControlStart = () => {
|
const handleControlStart = () => {
|
||||||
activeControlGestureStartedAt = Date.now()
|
const distance = controls?.getDistance()
|
||||||
|
if (typeof distance === 'number' && Number.isFinite(distance)) {
|
||||||
|
autoSwitchStateMachine.beginInput(distance, activeAutoSwitchInputSource)
|
||||||
|
}
|
||||||
if (!isProgrammaticCameraChange) {
|
if (!isProgrammaticCameraChange) {
|
||||||
hasPendingManualModelAdjustment = true
|
hasPendingManualModelAdjustment = true
|
||||||
clearModelAdjustReportTimer()
|
clearModelAdjustReportTimer()
|
||||||
@@ -1698,12 +1676,7 @@ const handleControlStart = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleControlEnd = () => {
|
const handleControlEnd = () => {
|
||||||
if (indoorEntryGestureLocked) {
|
activeAutoSwitchInputSource = 'gesture'
|
||||||
indoorContextLockedUntil = Math.max(indoorContextLockedUntil, Date.now() + indoorGestureExitGraceMs)
|
|
||||||
indoorEntryGestureLocked = false
|
|
||||||
}
|
|
||||||
activeControlGestureStartedAt = 0
|
|
||||||
updateLastControlDistance()
|
|
||||||
scheduleModelAdjustIdleReport()
|
scheduleModelAdjustIdleReport()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2491,7 +2464,6 @@ const showFocusHallHighlight = (poi: RenderPoi) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const disposeCachedOverviewModel = () => {
|
const disposeCachedOverviewModel = () => {
|
||||||
autoSwitchOverviewMaxDim = 0
|
|
||||||
if (!cachedOverviewModel) return
|
if (!cachedOverviewModel) return
|
||||||
|
|
||||||
disposeObject(cachedOverviewModel)
|
disposeObject(cachedOverviewModel)
|
||||||
@@ -2885,15 +2857,6 @@ const getObjectSize = (object: THREE.Object3D) => (
|
|||||||
getObjectBox(object).getSize(new THREE.Vector3())
|
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 getMultiFloorVerticalGap = (items: MultiFloorModelItem[]) => {
|
||||||
const maxFloorHeight = Math.max(...items.map((item) => item.size.y), 1)
|
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)
|
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))
|
.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 = () => {
|
const resetAutoSwitchDistanceTracking = () => {
|
||||||
activeControlGestureStartedAt = 0
|
activeAutoSwitchInputSource = 'gesture'
|
||||||
indoorEntryGestureLocked = false
|
autoSwitchStateMachine.reset(activeView.value)
|
||||||
indoorContextLockedUntil = 0
|
|
||||||
resetOverviewEntryIntent()
|
|
||||||
resetFloorExitCandidate()
|
|
||||||
updateLastControlDistance()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) => {
|
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()
|
const distance = controls.getDistance()
|
||||||
if (!Number.isFinite(distance)) return
|
if (!Number.isFinite(distance)) return
|
||||||
|
|
||||||
const now = Date.now()
|
activeAutoSwitchInputSource = 'wheel'
|
||||||
if (!isOverviewEntryIntentActive(now)) {
|
autoSwitchStateMachine.beginInput(distance, 'wheel')
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isOverviewEntryIntentActive = (now: number) => (
|
const canRunAutoSwitch = (direction: GuideAutoSwitchDirection) => {
|
||||||
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)
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!props.autoSwitch
|
!props.autoSwitch
|
||||||
|| autoSwitchTemporarilyDisabled
|
|| autoSwitchTemporarilyDisabled
|
||||||
|| isLoading.value
|
|| 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAutoSwitchLocked) {
|
isAutoSwitchLocked = true
|
||||||
emit('autoSwitchBlocked', { reason: 'loading-locked' })
|
if (request.direction === 'enter-floor') {
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetFloorId = getAutoEntryTargetFloorId()
|
const targetFloorId = getAutoEntryTargetFloorId()
|
||||||
const hasSceneToKeep = hasRenderableSceneForFloorTransition()
|
const hasSceneToKeep = hasRenderableSceneForFloorTransition()
|
||||||
isAutoSwitchLocked = true
|
|
||||||
lastAutoSwitchTime = now
|
|
||||||
resetFloorExitCandidate()
|
|
||||||
resetOverviewEntryIntent()
|
|
||||||
clearOverviewWheelAutoSwitchTimer()
|
|
||||||
|
|
||||||
void runAutoSwitchLoad(
|
void runAutoSwitchLoad(
|
||||||
{
|
{
|
||||||
from: 'overview',
|
from: 'overview',
|
||||||
to: 'floor',
|
to: 'floor',
|
||||||
trigger: 'zoom-in',
|
trigger: 'zoom-in',
|
||||||
distance
|
distance: request.distance
|
||||||
},
|
},
|
||||||
async () => {
|
async () => {
|
||||||
await loadFloor(targetFloorId, {
|
await loadFloor(targetFloorId, {
|
||||||
@@ -3332,65 +3124,33 @@ const checkAutoSwitch = (options: { forceOverviewEntry?: boolean } = {}) => {
|
|||||||
return
|
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(
|
void runAutoSwitchLoad(
|
||||||
{
|
{
|
||||||
from: 'floor',
|
from: 'floor',
|
||||||
to: 'overview',
|
to: 'overview',
|
||||||
trigger: 'zoom-out',
|
trigger: 'zoom-out',
|
||||||
distance
|
distance: request.distance
|
||||||
},
|
},
|
||||||
loadOverview
|
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 (
|
const runAutoSwitchLoad = async (
|
||||||
event: { from: 'overview' | 'floor'; to: 'overview' | 'floor'; trigger: 'zoom-in' | 'zoom-out'; distance: number },
|
event: { from: 'overview' | 'floor'; to: 'overview' | 'floor'; trigger: 'zoom-in' | 'zoom-out'; distance: number },
|
||||||
loadTask: () => Promise<unknown>,
|
loadTask: () => Promise<unknown>,
|
||||||
@@ -3407,8 +3167,10 @@ const runAutoSwitchLoad = async (
|
|||||||
if (showLoading) {
|
if (showLoading) {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
|
autoSwitchStateMachine.markTransitionSucceeded()
|
||||||
emit('autoSwitch', event)
|
emit('autoSwitch', event)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
autoSwitchStateMachine.markTransitionFailed()
|
||||||
if (isStaleModelLoadError(error)) return
|
if (isStaleModelLoadError(error)) return
|
||||||
|
|
||||||
console.error('馆内 3D 自动视角切换失败:', error)
|
console.error('馆内 3D 自动视角切换失败:', error)
|
||||||
@@ -3475,7 +3237,7 @@ const setCameraView = (
|
|||||||
}
|
}
|
||||||
controls.minDistance = Math.max(SGS_VISUAL_RENDER_CONFIG.controls.minDistance, maxDim * 0.08)
|
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)
|
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') => {
|
const fitCameraToObject = (object: THREE.Object3D, preset: CameraPreset = 'oblique') => {
|
||||||
@@ -3505,6 +3267,15 @@ const fitCameraToObject = (object: THREE.Object3D, preset: CameraPreset = 'obliq
|
|||||||
}
|
}
|
||||||
: {}
|
: {}
|
||||||
|
|
||||||
|
overviewFitOptions.onComplete = () => {
|
||||||
|
if (activeView.value !== 'floor' || !controls) return
|
||||||
|
|
||||||
|
const distance = controls.getDistance()
|
||||||
|
if (Number.isFinite(distance)) {
|
||||||
|
// 楼层切换或相机复位完成后,以最终拟合距离作为退出基准。
|
||||||
|
autoSwitchStateMachine.setFloorInitialDistance(distance)
|
||||||
|
}
|
||||||
|
}
|
||||||
setCameraView(center, maxDim, direction, overviewFitOptions)
|
setCameraView(center, maxDim, direction, overviewFitOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3523,11 +3294,6 @@ const applyOverviewInitialCamera = () => {
|
|||||||
camera.updateProjectionMatrix()
|
camera.updateProjectionMatrix()
|
||||||
controls.target.copy(OVERVIEW_INITIAL_CAMERA_PARAMS.target)
|
controls.target.copy(OVERVIEW_INITIAL_CAMERA_PARAMS.target)
|
||||||
controls.update()
|
controls.update()
|
||||||
|
|
||||||
const distance = camera.position.distanceTo(controls.target)
|
|
||||||
if (Number.isFinite(distance)) {
|
|
||||||
lastControlDistance = distance
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetCamera = () => {
|
const resetCamera = () => {
|
||||||
@@ -3555,33 +3321,16 @@ const zoomCamera = (direction: 'in' | 'out', options: { source?: ZoomCameraSourc
|
|||||||
if (!Number.isFinite(nextDistance) || nextDistance <= 0) return
|
if (!Number.isFinite(nextDistance) || nextDistance <= 0) return
|
||||||
|
|
||||||
const shouldTrackUserZoom = options.source === 'button'
|
const shouldTrackUserZoom = options.source === 'button'
|
||||||
|
if (shouldTrackUserZoom) {
|
||||||
if (shouldTrackUserZoom && direction === 'in' && activeView.value === 'overview') {
|
activeAutoSwitchInputSource = 'button'
|
||||||
const now = Date.now()
|
autoSwitchStateMachine.beginInput(currentDistance, 'button')
|
||||||
if (!isOverviewEntryIntentActive(now)) {
|
|
||||||
overviewEntryIntentStartedAt = now
|
|
||||||
overviewEntryStartDistance = currentDistance
|
|
||||||
overviewEntryZoomInRounds = 0
|
|
||||||
}
|
|
||||||
overviewEntryIntentStartedAt = now
|
|
||||||
overviewEntryZoomInRounds += 1
|
|
||||||
overviewWheelZoomInRounds += 1
|
|
||||||
overviewWheelZoomIntentUntil = now + 900
|
|
||||||
}
|
}
|
||||||
|
|
||||||
offset.setLength(nextDistance)
|
offset.setLength(nextDistance)
|
||||||
const nextPosition = controls.target.clone().add(offset)
|
const nextPosition = controls.target.clone().add(offset)
|
||||||
moveCameraTo(nextPosition, controls.target, {
|
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) => {
|
const loadCurrentFloorPoiMarkers = async (loadToken: number) => {
|
||||||
@@ -3635,6 +3384,7 @@ const loadOverview = async () => {
|
|||||||
|
|
||||||
const loadToken = startModelLoad()
|
const loadToken = startModelLoad()
|
||||||
activeView.value = 'overview'
|
activeView.value = 'overview'
|
||||||
|
autoSwitchStateMachine.setView('overview')
|
||||||
syncControlInteractionOptions()
|
syncControlInteractionOptions()
|
||||||
clearSceneData()
|
clearSceneData()
|
||||||
|
|
||||||
@@ -3647,7 +3397,6 @@ const loadOverview = async () => {
|
|||||||
activeModel.visible = true
|
activeModel.visible = true
|
||||||
applyIndoorInitialModelTransform(activeModel)
|
applyIndoorInitialModelTransform(activeModel)
|
||||||
applyModelVisibilityForView(activeModel, 'overview')
|
applyModelVisibilityForView(activeModel, 'overview')
|
||||||
cacheAutoSwitchOverviewMaxDim(activeModel)
|
|
||||||
targetScene.add(activeModel)
|
targetScene.add(activeModel)
|
||||||
applyOverviewInitialCamera()
|
applyOverviewInitialCamera()
|
||||||
loadCurrentFloorPoiMarkersInBackground(loadToken)
|
loadCurrentFloorPoiMarkersInBackground(loadToken)
|
||||||
@@ -3677,7 +3426,6 @@ const loadOverview = async () => {
|
|||||||
prepareModel(activeModel)
|
prepareModel(activeModel)
|
||||||
applyIndoorInitialModelTransform(activeModel)
|
applyIndoorInitialModelTransform(activeModel)
|
||||||
applyModelVisibilityForView(activeModel, 'overview')
|
applyModelVisibilityForView(activeModel, 'overview')
|
||||||
cacheAutoSwitchOverviewMaxDim(activeModel)
|
|
||||||
cachedOverviewModel = activeModel
|
cachedOverviewModel = activeModel
|
||||||
cachedSharedModelUrl = packageData.overviewModelUrl
|
cachedSharedModelUrl = packageData.overviewModelUrl
|
||||||
targetScene.add(activeModel)
|
targetScene.add(activeModel)
|
||||||
@@ -3742,10 +3490,6 @@ const prepareFloorScene = async (
|
|||||||
applyIndoorInitialModelTransform(model)
|
applyIndoorInitialModelTransform(model)
|
||||||
applyModelVisibilityForView(model, 'floor', requestedFloorId)
|
applyModelVisibilityForView(model, 'floor', requestedFloorId)
|
||||||
cacheAsSharedModel = Boolean(floor.sharedModelAsset)
|
cacheAsSharedModel = Boolean(floor.sharedModelAsset)
|
||||||
|
|
||||||
if (cacheAsSharedModel) {
|
|
||||||
cacheAutoSwitchOverviewMaxDim(model)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!model) {
|
if (!model) {
|
||||||
@@ -3786,6 +3530,8 @@ const commitPreparedFloorScene = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
activeView.value = 'floor'
|
activeView.value = 'floor'
|
||||||
|
autoSwitchStateMachine.setView('floor')
|
||||||
|
autoSwitchStateMachine.clearFloorInitialDistance()
|
||||||
syncControlInteractionOptions()
|
syncControlInteractionOptions()
|
||||||
currentFloor.value = expectedFloorId
|
currentFloor.value = expectedFloorId
|
||||||
clearSceneData()
|
clearSceneData()
|
||||||
@@ -3812,7 +3558,6 @@ const commitPreparedFloorScene = (
|
|||||||
updatePoiMarkerFocus()
|
updatePoiMarkerFocus()
|
||||||
hasLoadedFloorViewOnce = true
|
hasLoadedFloorViewOnce = true
|
||||||
fitCameraToObject(activeModel)
|
fitCameraToObject(activeModel)
|
||||||
markFloorAutoSwitchEntry()
|
|
||||||
refreshPoiVisibilityByDistance()
|
refreshPoiVisibilityByDistance()
|
||||||
renderRoutePreview()
|
renderRoutePreview()
|
||||||
markFirstModelVisible('floor', {
|
markFirstModelVisible('floor', {
|
||||||
@@ -3869,6 +3614,7 @@ const loadMultiFloor = async () => {
|
|||||||
|
|
||||||
const loadToken = startModelLoad()
|
const loadToken = startModelLoad()
|
||||||
activeView.value = 'multi'
|
activeView.value = 'multi'
|
||||||
|
autoSwitchStateMachine.setView('multi')
|
||||||
syncControlInteractionOptions()
|
syncControlInteractionOptions()
|
||||||
clearSceneData()
|
clearSceneData()
|
||||||
setProgress(18, '正在加载多层展示模型...')
|
setProgress(18, '正在加载多层展示模型...')
|
||||||
@@ -3897,7 +3643,6 @@ const loadMultiFloor = async () => {
|
|||||||
sourceModel.name = 'GuideOverviewModel'
|
sourceModel.name = 'GuideOverviewModel'
|
||||||
prepareModel(sourceModel)
|
prepareModel(sourceModel)
|
||||||
applyModelVisibilityForView(sourceModel, 'overview')
|
applyModelVisibilityForView(sourceModel, 'overview')
|
||||||
cacheAutoSwitchOverviewMaxDim(sourceModel)
|
|
||||||
cachedOverviewModel = sourceModel
|
cachedOverviewModel = sourceModel
|
||||||
cachedSharedModelUrl = sharedModelUrl
|
cachedSharedModelUrl = sharedModelUrl
|
||||||
}
|
}
|
||||||
@@ -4791,7 +4536,7 @@ const handleSceneTap = async (event: PointerEvent) => {
|
|||||||
if (hitMarker?.userData.poi) {
|
if (hitMarker?.userData.poi) {
|
||||||
const selectedPoi = hitMarker.userData.poi as RenderPoi
|
const selectedPoi = hitMarker.userData.poi as RenderPoi
|
||||||
if (activeView.value === 'overview') {
|
if (activeView.value === 'overview') {
|
||||||
disableAutoSwitchTemporarily(10000)
|
disableAutoSwitchTemporarily(manualAutoSwitchPauseMs)
|
||||||
try {
|
try {
|
||||||
await loadFloor(selectedPoi.floorId, {
|
await loadFloor(selectedPoi.floorId, {
|
||||||
preserveCurrentSceneUntilReady: hasRenderableSceneForFloorTransition(),
|
preserveCurrentSceneUntilReady: hasRenderableSceneForFloorTransition(),
|
||||||
@@ -4826,7 +4571,7 @@ const handleSceneTap = async (event: PointerEvent) => {
|
|||||||
.find((object): object is THREE.Object3D => Boolean(object))
|
.find((object): object is THREE.Object3D => Boolean(object))
|
||||||
|
|
||||||
if (floorObject?.userData.floorId) {
|
if (floorObject?.userData.floorId) {
|
||||||
disableAutoSwitchTemporarily(10000)
|
disableAutoSwitchTemporarily(manualAutoSwitchPauseMs)
|
||||||
void handleFloorChange(floorObject.userData.floorId as string)
|
void handleFloorChange(floorObject.userData.floorId as string)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -4841,6 +4586,12 @@ const handlePointerDown = (event: PointerEvent) => {
|
|||||||
if (event.pointerType === 'touch' && getActiveTouchPointerCount() >= 2) {
|
if (event.pointerType === 'touch' && getActiveTouchPointerCount() >= 2) {
|
||||||
hadMultiPointerGesture = true
|
hadMultiPointerGesture = true
|
||||||
pointerDownState = null
|
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') {
|
if (event.pointerType !== 'touch') {
|
||||||
updateDesktopRotateModifierState(event)
|
updateDesktopRotateModifierState(event)
|
||||||
@@ -5273,19 +5024,14 @@ const disposeScene = () => {
|
|||||||
autoSwitchDisableTimer = null
|
autoSwitchDisableTimer = null
|
||||||
}
|
}
|
||||||
|
|
||||||
clearOverviewWheelAutoSwitchTimer()
|
autoSwitchStateMachine.dispose()
|
||||||
clearProgrammaticCameraTimer()
|
clearProgrammaticCameraTimer()
|
||||||
clearModelAdjustReportTimer()
|
clearModelAdjustReportTimer()
|
||||||
hasPendingManualModelAdjustment = false
|
hasPendingManualModelAdjustment = false
|
||||||
cameraTween = null
|
cameraTween = null
|
||||||
isProgrammaticCameraChange = false
|
isProgrammaticCameraChange = false
|
||||||
lastControlDistance = 0
|
activeAutoSwitchInputSource = 'gesture'
|
||||||
activeControlGestureStartedAt = 0
|
|
||||||
resetInteractionGateState()
|
resetInteractionGateState()
|
||||||
indoorEntryGestureLocked = false
|
|
||||||
indoorContextLockedUntil = 0
|
|
||||||
resetOverviewEntryIntent()
|
|
||||||
resetFloorExitCandidate()
|
|
||||||
|
|
||||||
clearSceneData()
|
clearSceneData()
|
||||||
disposePoiMarkerCache()
|
disposePoiMarkerCache()
|
||||||
|
|||||||
251
src/components/map/guideAutoSwitchStateMachine.ts
Normal file
251
src/components/map/guideAutoSwitchStateMachine.ts
Normal file
@@ -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<typeof setTimeout>
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,8 +20,6 @@
|
|||||||
:route-preview="routePreview"
|
:route-preview="routePreview"
|
||||||
:show-route="showRoute"
|
:show-route="showRoute"
|
||||||
:disable-auto-exit="disableAutoExit"
|
:disable-auto-exit="disableAutoExit"
|
||||||
:auto-switch-threshold-low="autoSwitchThresholdLow"
|
|
||||||
:auto-switch-threshold-high="autoSwitchThresholdHigh"
|
|
||||||
@floor-change="handleThreeFloorChange"
|
@floor-change="handleThreeFloorChange"
|
||||||
@poi-click="handlePoiClick"
|
@poi-click="handlePoiClick"
|
||||||
@selection-clear="handleSelectionClear"
|
@selection-clear="handleSelectionClear"
|
||||||
@@ -358,8 +356,6 @@ const props = withDefaults(defineProps<{
|
|||||||
routePreview?: GuideRouteResult | null
|
routePreview?: GuideRouteResult | null
|
||||||
showRoute?: boolean
|
showRoute?: boolean
|
||||||
disableAutoExit?: boolean
|
disableAutoExit?: boolean
|
||||||
autoSwitchThresholdLow?: number
|
|
||||||
autoSwitchThresholdHigh?: number
|
|
||||||
outdoorNavPolylines?: OutdoorNavPolyline[]
|
outdoorNavPolylines?: OutdoorNavPolyline[]
|
||||||
outdoorMarkers?: OutdoorMapMarker[]
|
outdoorMarkers?: OutdoorMapMarker[]
|
||||||
activeOutdoorMarkerId?: string
|
activeOutdoorMarkerId?: string
|
||||||
@@ -411,8 +407,6 @@ const props = withDefaults(defineProps<{
|
|||||||
routePreview: null,
|
routePreview: null,
|
||||||
showRoute: false,
|
showRoute: false,
|
||||||
disableAutoExit: false,
|
disableAutoExit: false,
|
||||||
autoSwitchThresholdLow: 1.0,
|
|
||||||
autoSwitchThresholdHigh: 1.3,
|
|
||||||
outdoorNavPolylines: () => [] as OutdoorNavPolyline[],
|
outdoorNavPolylines: () => [] as OutdoorNavPolyline[],
|
||||||
outdoorMarkers: () => [] as OutdoorMapMarker[],
|
outdoorMarkers: () => [] as OutdoorMapMarker[],
|
||||||
activeOutdoorMarkerId: '',
|
activeOutdoorMarkerId: '',
|
||||||
@@ -440,10 +434,13 @@ const emit = defineEmits<{
|
|||||||
outdoorMarkerClick: [markerId: string]
|
outdoorMarkerClick: [markerId: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
// 手动切换与自动切换共用 1500ms 保护,避免操作完成后立即反向跳转。
|
||||||
|
const manualAutoSwitchPauseMs = 1500
|
||||||
|
|
||||||
// 监听视角切换,循环切换:reset -> top -> oblique -> reset
|
// 监听视角切换,循环切换:reset -> top -> oblique -> reset
|
||||||
watch(() => props.cameraView, (view) => {
|
watch(() => props.cameraView, (view) => {
|
||||||
if (props.mapType !== 'indoor') return
|
if (props.mapType !== 'indoor') return
|
||||||
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(10000)
|
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(manualAutoSwitchPauseMs)
|
||||||
|
|
||||||
if (view === 'reset') {
|
if (view === 'reset') {
|
||||||
indoorRendererRef.value?.resetCamera?.()
|
indoorRendererRef.value?.resetCamera?.()
|
||||||
@@ -595,7 +592,7 @@ const handleFloorChange = (floor: { id: string; label: string }) => {
|
|||||||
const requestSeq = ++floorSwitchRequestSeq
|
const requestSeq = ++floorSwitchRequestSeq
|
||||||
activeFloorSwitchRequestSeq.value = requestSeq
|
activeFloorSwitchRequestSeq.value = requestSeq
|
||||||
failedFloorId.value = ''
|
failedFloorId.value = ''
|
||||||
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(10000)
|
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(manualAutoSwitchPauseMs)
|
||||||
emit('floorRequest', {
|
emit('floorRequest', {
|
||||||
floorId,
|
floorId,
|
||||||
floorLabel: floor.label
|
floorLabel: floor.label
|
||||||
@@ -620,8 +617,8 @@ const handleFloorChange = (floor: { id: string; label: string }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleLayerModeChange = (mode: LayerDisplayMode) => {
|
const handleLayerModeChange = (mode: LayerDisplayMode) => {
|
||||||
// 手动切换展示层数时临时禁用缩放自动切换 10 秒
|
// 手动切换展示层数时使用统一的短保护期。
|
||||||
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(10000)
|
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(manualAutoSwitchPauseMs)
|
||||||
|
|
||||||
if (mode === 'multi') {
|
if (mode === 'multi') {
|
||||||
loadingFloorId.value = ''
|
loadingFloorId.value = ''
|
||||||
@@ -679,7 +676,7 @@ const handleFloorHeaderTap = () => {
|
|||||||
|
|
||||||
const handleToolClick = (tool: string) => {
|
const handleToolClick = (tool: string) => {
|
||||||
if (props.mapType === 'indoor') {
|
if (props.mapType === 'indoor') {
|
||||||
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(10000)
|
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(manualAutoSwitchPauseMs)
|
||||||
|
|
||||||
if (tool === '复位') {
|
if (tool === '复位') {
|
||||||
indoorRendererRef.value?.resetCamera?.()
|
indoorRendererRef.value?.resetCamera?.()
|
||||||
@@ -709,7 +706,7 @@ const handleZoomClick = (direction: 'in' | 'out') => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleShowOverview = async () => {
|
const handleShowOverview = async () => {
|
||||||
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(10000)
|
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(manualAutoSwitchPauseMs)
|
||||||
loadingFloorId.value = ''
|
loadingFloorId.value = ''
|
||||||
requestedFloorId.value = ''
|
requestedFloorId.value = ''
|
||||||
requestedFloorLabel.value = ''
|
requestedFloorLabel.value = ''
|
||||||
|
|||||||
@@ -45,8 +45,6 @@
|
|||||||
:active-outdoor-marker-id="activeArrivalMarkerId"
|
:active-outdoor-marker-id="activeArrivalMarkerId"
|
||||||
:outdoor-focus-offset-y="arrivalOutdoorFocusOffsetY"
|
:outdoor-focus-offset-y="arrivalOutdoorFocusOffsetY"
|
||||||
:disable-auto-exit="disableIndoorAutoExit"
|
:disable-auto-exit="disableIndoorAutoExit"
|
||||||
:auto-switch-threshold-low="0.58"
|
|
||||||
:auto-switch-threshold-high="1.18"
|
|
||||||
@mode-change="handleModeChange"
|
@mode-change="handleModeChange"
|
||||||
@floor-request="handleFloorRequest"
|
@floor-request="handleFloorRequest"
|
||||||
@floor-change="handleFloorChange"
|
@floor-change="handleFloorChange"
|
||||||
|
|||||||
147
tests/unit/GuideAutoSwitchStateMachine.spec.ts
Normal file
147
tests/unit/GuideAutoSwitchStateMachine.spec.ts
Normal file
@@ -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<GuideAutoSwitchInputSource>(['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'])
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user