@@ -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<typeof setTimeout> | null = null
|
||||
let overviewEntryIntentStartedAt = 0
|
||||
let modelAdjustReportTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let hasPendingManualModelAdjustment = false
|
||||
let overviewEntryStartDistance = 0
|
||||
let overviewEntryZoomInRounds = 0
|
||||
let overviewWheelZoomInRounds = 0
|
||||
let overviewWheelZoomIntentUntil = 0
|
||||
let overviewWheelAutoSwitchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let activeAutoSwitchInputSource: GuideAutoSwitchInputSource = 'gesture'
|
||||
let isProgrammaticCameraChange = false
|
||||
let programmaticCameraTimer: ReturnType<typeof setTimeout> | 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<unknown>,
|
||||
@@ -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()
|
||||
|
||||
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"
|
||||
: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 = ''
|
||||
|
||||
@@ -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"
|
||||
|
||||
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