From cf37e7a40e4a8ef4a760394e10d386a2ec7cf90f Mon Sep 17 00:00:00 2001 From: lyf <2514544224@qq.com> Date: Wed, 1 Jul 2026 00:07:25 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=A6=86=E5=86=85=E5=AF=BC?= =?UTF-8?q?=E8=A7=88=E6=A5=BC=E5=B1=82=E7=82=B9=E4=BD=8D=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/map/ThreeMap.vue | 387 ++++- src/components/navigation/GuideMapShell.vue | 171 ++- src/data/adapters/sgsSdkGuideAdapter.ts | 1475 ++++++++++--------- src/pages/index/index.vue | 72 +- src/repositories/GuideModelRepository.ts | 55 +- 5 files changed, 1379 insertions(+), 781 deletions(-) diff --git a/src/components/map/ThreeMap.vue b/src/components/map/ThreeMap.vue index 44eb380..828d74e 100644 --- a/src/components/map/ThreeMap.vue +++ b/src/components/map/ThreeMap.vue @@ -85,6 +85,7 @@ interface LoadFloorOptions { preserveCurrentSceneUntilReady?: boolean suppressProgress?: boolean detachPoiBeforeLoad?: boolean + allowSameFloorReload?: boolean } interface LoadModelOptions { @@ -176,6 +177,21 @@ interface PoiMarkerCacheEntry { pois: RenderPoi[] group: THREE.Group markerSize: number + displayOffsetY: number +} + +interface PoiYDistribution { + count: number + min: number + max: number + median: number +} + +interface ModelYBounds { + min: number + max: number + center: number + height: number } interface PreparedFloorScene { @@ -267,6 +283,8 @@ let routeGroup: THREE.Group | null = null let activeRoutePreviewSignature = '' const poiDataCache = new Map() const poiMarkerGroupCache = new Map() +const poiDisplayOffsetYCache = new Map() +const poiDisplayDiagnosticsKeys = new Set() let activeFocusLabelSprite: THREE.Sprite | null = null let activeFocusPulseSprite: THREE.Sprite | null = null let activeFocusBaseSprite: THREE.Sprite | null = null @@ -279,6 +297,9 @@ let pendingTargetFocus: TargetPoiFocusRequest | null = null let targetFocusQueue: Promise = Promise.resolve() let modelLoadVersion = 0 let pendingRequestedFloorId = '' +let floorSwitchLoadToken = 0 +let floorSwitchRequestedFloorId = '' +let isFloorSwitching = false let hasLoadedFloorViewOnce = false let pointerDownState: { x: number; y: number } | null = null let cachedOverviewModel: THREE.Object3D | null = null @@ -631,6 +652,115 @@ const getPoiDisplayMode = (): PoiDisplayMode => { const getPoiMarkerCacheKey = (floorId: string, displayMode = getPoiDisplayMode()) => `${floorId}:${displayMode}` +const summarizePoiYDistribution = (pois: RenderPoi[]): PoiYDistribution | null => { + const values = pois + .map((poi) => poi.positionGltf?.[1]) + .filter((value): value is number => Number.isFinite(value)) + .sort((left, right) => left - right) + + if (!values.length) return null + + const middle = Math.floor(values.length / 2) + const median = values.length % 2 + ? values[middle] + : (values[middle - 1] + values[middle]) / 2 + + return { + count: values.length, + min: values[0], + max: values[values.length - 1], + median + } +} + +const getModelYBounds = (model: THREE.Object3D): ModelYBounds => { + const box = getObjectBox(model) + const height = Math.max(box.max.y - box.min.y, 0) + + return { + min: box.min.y, + max: box.max.y, + center: (box.min.y + box.max.y) / 2, + height + } +} + +const roundDiagnosticNumber = (value: number) => Math.round(value * 1000) / 1000 + +const getPoiDisplayOffsetCacheKey = (floorId: string) => floorId + +const getCachedPoiDisplayOffsetY = (floorId: string) => ( + poiDisplayOffsetYCache.get(getPoiDisplayOffsetCacheKey(floorId)) || 0 +) + +const getPoiDisplayPosition = (poi: RenderPoi, floorId = poi.floorId) => { + if (!poi.positionGltf) return null + + const [x, y, z] = poi.positionGltf + return new THREE.Vector3(x, y + getCachedPoiDisplayOffsetY(floorId), z) +} + +const resolvePoiDisplayOffsetY = ( + floorId: string, + model: THREE.Object3D, + pois: RenderPoi[] +) => { + const modelY = getModelYBounds(model) + const poiY = summarizePoiYDistribution(pois) + let offsetY = 0 + let reason = 'no-positioned-poi' + + if (poiY) { + const modelTolerance = Math.max(2, modelY.height * 0.35) + const overlapsModelRange = poiY.max >= modelY.min - modelTolerance && poiY.min <= modelY.max + modelTolerance + + if (overlapsModelRange) { + reason = 'poi-y-overlaps-model-y' + } else { + offsetY = modelY.center - poiY.median + reason = 'align-poi-median-to-model-center' + } + } + + poiDisplayOffsetYCache.set(getPoiDisplayOffsetCacheKey(floorId), offsetY) + + if (import.meta.env.DEV) { + const diagnosticsKey = [ + floorId, + roundDiagnosticNumber(modelY.min), + roundDiagnosticNumber(modelY.max), + poiY ? roundDiagnosticNumber(poiY.min) : 'none', + poiY ? roundDiagnosticNumber(poiY.max) : 'none', + roundDiagnosticNumber(offsetY) + ].join(':') + + if (!poiDisplayDiagnosticsKeys.has(diagnosticsKey)) { + poiDisplayDiagnosticsKeys.add(diagnosticsKey) + console.info('[ThreeMap] floor POI display coordinate diagnostics', { + floorId, + modelY: { + min: roundDiagnosticNumber(modelY.min), + max: roundDiagnosticNumber(modelY.max), + center: roundDiagnosticNumber(modelY.center), + height: roundDiagnosticNumber(modelY.height) + }, + poiY: poiY + ? { + count: poiY.count, + min: roundDiagnosticNumber(poiY.min), + max: roundDiagnosticNumber(poiY.max), + median: roundDiagnosticNumber(poiY.median) + } + : null, + displayOffsetY: roundDiagnosticNumber(offsetY), + reason + }) + } + } + + return offsetY +} + const getPoiGlyph = (poi: RenderPoi) => ( poiGlyphMap[poi.iconType] || poi.name.charAt(0) || '点' ) @@ -850,14 +980,14 @@ const updateAmbientPoiLabels = () => { .sort((left, right) => right.priority - left.priority) candidates.forEach(({ marker, labelSprite, poi }) => { - if (!poi.positionGltf || !shouldShowAmbientPoiLabel(poi, marker.visible, densityTier)) { + const displayPosition = getPoiDisplayPosition(poi) + if (!displayPosition || !shouldShowAmbientPoiLabel(poi, marker.visible, densityTier)) { labelSprite.visible = false return } updateAmbientLabelScale(labelSprite, poi) - const [x, y, z] = poi.positionGltf const baseOffset = markerSize * (isHallPoi(poi) ? 1.82 : 1.5) const offsetAttempts = [ 0, @@ -870,7 +1000,7 @@ const updateAmbientPoiLabels = () => { let accepted = false for (const offset of offsetAttempts) { - labelSprite.position.set(x, y + baseOffset + offset, z) + labelSprite.position.set(displayPosition.x, displayPosition.y + baseOffset + offset, displayPosition.z) const screenPosition = getProjectedScreenPosition(labelSprite) if (!screenPosition) continue @@ -885,7 +1015,17 @@ const updateAmbientPoiLabels = () => { } if (!accepted) { - labelSprite.visible = false + if (isHallPoi(poi)) { + const fallbackOffset = offsetAttempts[offsetAttempts.length - 1] + labelSprite.position.set( + displayPosition.x, + displayPosition.y + baseOffset + fallbackOffset, + displayPosition.z + ) + labelSprite.visible = true + } else { + labelSprite.visible = false + } } }) } @@ -1116,6 +1256,9 @@ const startModelLoad = () => { const invalidateModelLoads = () => { modelLoadVersion += 1 pendingRequestedFloorId = '' + floorSwitchLoadToken = 0 + floorSwitchRequestedFloorId = '' + isFloorSwitching = false } const isCurrentModelLoad = (loadToken: number) => ( @@ -1128,6 +1271,46 @@ const isStaleModelLoadError = (error: unknown) => ( error instanceof Error && error.message === staleModelLoadMessage ) +const startFloorContextTransaction = (requestedFloorId: string) => { + const loadToken = startModelLoad() + pendingRequestedFloorId = requestedFloorId + floorSwitchLoadToken = loadToken + floorSwitchRequestedFloorId = requestedFloorId + isFloorSwitching = true + return loadToken +} + +const isCurrentFloorContextTransaction = (loadToken: number, requestedFloorId: string) => ( + isFloorSwitching + && floorSwitchLoadToken === loadToken + && floorSwitchRequestedFloorId === requestedFloorId + && pendingRequestedFloorId === requestedFloorId + && isCurrentModelLoad(loadToken) +) + +const assertCurrentFloorContextTransaction = ( + loadToken: number, + requestedFloorId: string, + staleObject?: THREE.Object3D +) => { + if (isCurrentFloorContextTransaction(loadToken, requestedFloorId)) return + + if (staleObject) { + disposeObject(staleObject) + } + + throw createStaleModelLoadError() +} + +const completeFloorContextTransaction = (loadToken: number, requestedFloorId: string) => { + if (!isCurrentFloorContextTransaction(loadToken, requestedFloorId)) return + + isFloorSwitching = false + floorSwitchLoadToken = 0 + floorSwitchRequestedFloorId = '' + pendingRequestedFloorId = '' +} + const assertCurrentModelLoad = (loadToken: number, staleObject?: THREE.Object3D) => { if (isCurrentModelLoad(loadToken)) return @@ -1556,8 +1739,9 @@ const detachActivePoiLayer = () => { disposeFocusLabel() disposeFocusPulse() disposeFocusBase() + clearFocusHallHighlight() clearRoutePreview() - clearPoiGroupChildren() + detachPoiMarkerGroups() } const disposePoiMarkerCache = () => { @@ -1567,6 +1751,8 @@ const disposePoiMarkerCache = () => { }) poiMarkerGroupCache.clear() poiDataCache.clear() + poiDisplayOffsetYCache.clear() + poiDisplayDiagnosticsKeys.clear() } const clearPoiGroupChildren = () => { @@ -1679,7 +1865,8 @@ const applyFocusHallMaterial = (material: THREE.Material, seenMaterials: Set { - if (!poi.positionGltf) return null + const displayPosition = getPoiDisplayPosition(poi) + if (!displayPosition) return null const canvas = document.createElement('canvas') canvas.width = 256 @@ -1711,10 +1898,9 @@ const createFocusHallGlowMesh = (poi: RenderPoi) => { const hallGlowSize = Math.max(getPoiMarkerSize() * 14, getActiveModelSpan() * 0.14) const geometry = new THREE.PlaneGeometry(hallGlowSize, hallGlowSize * 0.72) const mesh = new THREE.Mesh(geometry, material) - const [x, y, z] = poi.positionGltf mesh.name = 'GuideFocusHallGlow' - mesh.position.set(x, y + 0.08, z) + mesh.position.set(displayPosition.x, displayPosition.y + 0.08, displayPosition.z) mesh.rotation.x = -Math.PI / 2 mesh.renderOrder = 7 @@ -2363,11 +2549,13 @@ const checkAutoSwitch = (options: { forceOverviewEntry?: boolean } = {}) => { trigger: 'zoom-in', distance }, - () => loadFloor(targetFloorId, { - preserveCurrentSceneUntilReady: hasSceneToKeep, - suppressProgress: hasSceneToKeep, - detachPoiBeforeLoad: true - }), + async () => { + await loadFloor(targetFloorId, { + preserveCurrentSceneUntilReady: hasSceneToKeep, + suppressProgress: hasSceneToKeep, + detachPoiBeforeLoad: true + }) + }, '单楼层模型加载失败', { showLoading: !hasSceneToKeep && !canLoadFloorSilently(targetFloorId) } ) @@ -2436,7 +2624,7 @@ const checkAutoSwitch = (options: { forceOverviewEntry?: boolean } = {}) => { const runAutoSwitchLoad = async ( event: { from: 'overview' | 'floor'; to: 'overview' | 'floor'; trigger: 'zoom-in' | 'zoom-out'; distance: number }, - loadTask: () => Promise, + loadTask: () => Promise, fallbackMessage: string, options: { showLoading?: boolean } = {} ) => { @@ -2611,7 +2799,16 @@ const loadCurrentFloorPoiMarkers = async (loadToken: number) => { const floor = floorIndex.value.find((item) => item.floorId === currentFloor.value) || floorIndex.value[0] if (!floor) return - await loadFloorPOIs(floor, loadToken) + if (activeView.value === 'floor') { + await loadFloorPOIs(floor, loadToken) + assertCurrentModelLoad(loadToken) + return + } + + const entry = await prepareFloorPOIs(floor, loadToken) + if (entry) { + attachPoiMarkerGroup(entry) + } assertCurrentModelLoad(loadToken) } @@ -2742,7 +2939,7 @@ const prepareFloorScene = async ( let poiEntry: PoiMarkerCacheEntry | undefined | null = null try { poiEntry = shouldRenderPoiMarkers.value - ? await prepareFloorPOIs(floor, loadToken) + ? await prepareFloorPOIs(floor, loadToken, model) : null } catch (error) { if (ownsModel) { @@ -2769,6 +2966,7 @@ const commitPreparedFloorScene = ( loadToken: number, expectedFloorId: string ) => { + assertCurrentFloorContextTransaction(loadToken, expectedFloorId, prepared.ownsModel ? prepared.model : undefined) assertPreparedFloorScene(prepared, loadToken, expectedFloorId) const targetScene = scene @@ -2783,6 +2981,7 @@ const commitPreparedFloorScene = ( currentFloor.value = expectedFloorId clearSceneData() activeModel = prepared.model + activeModel.userData.floorId = expectedFloorId if (prepared.cacheAsSharedModel) { cachedOverviewModel = activeModel @@ -2798,6 +2997,15 @@ const commitPreparedFloorScene = ( } assertCommittedFloorScene(loadToken, expectedFloorId) + + activeFocusPoiId.value = '' + selectedPOI.value = null + updatePoiMarkerFocus() + hasLoadedFloorViewOnce = true + fitCameraToObject(activeModel) + markFloorAutoSwitchEntry() + refreshPoiVisibilityByDistance() + renderRoutePreview() } const loadFloor = async (floorId: string, options: LoadFloorOptions = {}) => { @@ -2809,24 +3017,35 @@ const loadFloor = async (floorId: string, options: LoadFloorOptions = {}) => { throw createStaleModelLoadError() } - const loadToken = startModelLoad() const requestedFloorId = floor.floorId - pendingRequestedFloorId = requestedFloorId - if (options.detachPoiBeforeLoad || activeView.value === 'floor' || currentFloor.value !== requestedFloorId) { + if ( + !options.allowSameFloorReload + && activeView.value === 'floor' + && currentFloor.value === requestedFloorId + && activeModel?.userData.floorId === requestedFloorId + && !isFloorSwitching + ) { + return false + } + + const loadToken = startFloorContextTransaction(requestedFloorId) + if (options.detachPoiBeforeLoad) { detachActivePoiLayer() } - const prepared = await prepareFloorScene(floor, loadToken, options) - commitPreparedFloorScene(prepared, loadToken, requestedFloorId) - - if (!activeModel) throw createStaleModelLoadError() - assertCommittedFloorScene(loadToken, requestedFloorId) - hasLoadedFloorViewOnce = true - fitCameraToObject(activeModel) - markFloorAutoSwitchEntry() - - refreshPoiVisibilityByDistance() - renderRoutePreview() + try { + const prepared = await prepareFloorScene(floor, loadToken, options) + assertCurrentFloorContextTransaction(loadToken, requestedFloorId, prepared.ownsModel ? prepared.model : undefined) + commitPreparedFloorScene(prepared, loadToken, requestedFloorId) + return true + } catch (error) { + if (!isStaleModelLoadError(error)) { + restoreCommittedFloorPoiLayer() + } + throw error + } finally { + completeFloorContextTransaction(loadToken, requestedFloorId) + } } const loadMultiFloor = async () => { @@ -3290,39 +3509,39 @@ const disposeFocusBase = () => { } const showFocusPoiLabel = (poi: RenderPoi) => { - if (!poiGroup || !poi.positionGltf) return + const displayPosition = getPoiDisplayPosition(poi) + if (!poiGroup || !displayPosition) return disposeFocusLabel() const markerSize = getPoiMarkerSize() - const [x, y, z] = poi.positionGltf activeFocusLabelSprite = createPoiLabelSprite(poi, markerSize) - activeFocusLabelSprite.position.set(x, y + markerSize * 2.35, z) + activeFocusLabelSprite.position.set(displayPosition.x, displayPosition.y + markerSize * 2.35, displayPosition.z) updateFocusLabelScale() poiGroup.add(activeFocusLabelSprite) } const showFocusPoiBase = (poi: RenderPoi) => { - if (!poiGroup || !poi.positionGltf) return + const displayPosition = getPoiDisplayPosition(poi) + if (!poiGroup || !displayPosition) return disposeFocusBase() const markerSize = getPoiMarkerSize() - const [x, y, z] = poi.positionGltf activeFocusBaseSprite = createPoiBaseSprite(poi, markerSize) - activeFocusBaseSprite.position.set(x, y + markerSize * 0.04, z) + activeFocusBaseSprite.position.set(displayPosition.x, displayPosition.y + markerSize * 0.04, displayPosition.z) poiGroup.add(activeFocusBaseSprite) } const showFocusPoiPulse = (poi: RenderPoi) => { - if (!poiGroup || !poi.positionGltf) return + const displayPosition = getPoiDisplayPosition(poi) + if (!poiGroup || !displayPosition) return disposeFocusPulse() const markerSize = getPoiMarkerSize() - const [x, y, z] = poi.positionGltf activeFocusPulseSprite = createPoiPulseSprite(poi, markerSize) - activeFocusPulseSprite.position.set(x, y + markerSize * 0.15, z) + activeFocusPulseSprite.position.set(displayPosition.x, displayPosition.y + markerSize * 0.15, displayPosition.z) poiGroup.add(activeFocusPulseSprite) } @@ -3462,12 +3681,12 @@ const addFocusMarkerFromRequest = (request: TargetPoiFocusRequest) => { if (!poiGroup || findPoiSprite(request.poiId)) return findLoadedPoi(request.poiId) || null const poi = createPoiFromFocusRequest(request) - if (!poi?.positionGltf) return null + const displayPosition = poi ? getPoiDisplayPosition(poi, request.floorId) : null + if (!poi || !displayPosition) return null - const [x, y, z] = poi.positionGltf const markerSize = getPoiMarkerSize() const { sprite, hitTarget } = createPoiMarkerSprites(poi, markerSize) - sprite.position.set(x, y + markerSize * 0.5, z) + sprite.position.set(displayPosition.x, displayPosition.y + markerSize * 0.5, displayPosition.z) hitTarget.position.copy(sprite.position) poiGroup.add(sprite) poiGroup.add(hitTarget) @@ -3488,12 +3707,14 @@ const createPoiMarkerGroup = ( group.userData.displayMode = displayMode pois.forEach((poi) => { - const [x, y, z] = poi.positionGltf! + const displayPosition = getPoiDisplayPosition(poi, floorId) + if (!displayPosition) return + const { sprite, hitTarget, labelSprite } = createPoiMarkerSprites(poi, markerSize) - sprite.position.set(x, y + markerSize * 0.5, z) + sprite.position.set(displayPosition.x, displayPosition.y + markerSize * 0.5, displayPosition.z) hitTarget.position.copy(sprite.position) if (labelSprite) { - labelSprite.position.set(x, y + markerSize * 1.82, z) + labelSprite.position.set(displayPosition.x, displayPosition.y + markerSize * 1.82, displayPosition.z) labelSprite.visible = false } sprite.visible = shouldShowPoiAtDistance(poi, getPoiVisibilityTier()) @@ -3520,7 +3741,39 @@ const attachPoiMarkerGroup = (entry: PoiMarkerCacheEntry) => { updatePoiMarkerFocus() } -const prepareFloorPOIs = async (floor: FloorIndexItem, loadToken?: number) => { +const restoreCommittedFloorPoiLayer = () => { + if (!poiGroup || activeView.value !== 'floor' || !activeModel) return + + const floorId = currentFloor.value + const modelFloorId = typeof activeModel.userData.floorId === 'string' + ? activeModel.userData.floorId + : '' + if (!floorId || modelFloorId !== floorId) return + + const entry = shouldRenderPoiMarkers.value + ? poiMarkerGroupCache.get(getPoiMarkerCacheKey(floorId)) + : null + if (entry?.floorId === floorId) { + attachPoiMarkerGroup(entry) + refreshPoiVisibilityByDistance() + return + } + + poiGroup.userData.floorId = floorId + poiGroup.userData.currentFloor = floorId +} + +const getPoiMarkerSizeForModel = (model: THREE.Object3D | null) => ( + model + ? Math.max(new THREE.Box3().setFromObject(model).getSize(new THREE.Vector3()).length() * 0.012, 2.4) + : getPoiMarkerSize() +) + +const prepareFloorPOIs = async ( + floor: FloorIndexItem, + loadToken?: number, + markerModel: THREE.Object3D | null = activeModel +) => { if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) return const displayMode = getPoiDisplayMode() @@ -3536,14 +3789,18 @@ const prepareFloorPOIs = async (floor: FloorIndexItem, loadToken?: number) => { poiDataCache.set(floor.floorId, floorPois) const validPois = floorPois.filter((poi) => shouldShowPoiInCurrentMode(poi)) - const markerSize = getPoiMarkerSize() + const displayOffsetY = markerModel + ? resolvePoiDisplayOffsetY(floor.floorId, markerModel, validPois) + : getCachedPoiDisplayOffsetY(floor.floorId) + const markerSize = getPoiMarkerSizeForModel(markerModel) const markerGroup = createPoiMarkerGroup(floor.floorId, displayMode, validPois, markerSize) const entry: PoiMarkerCacheEntry = { floorId: floor.floorId, displayMode, pois: validPois, group: markerGroup, - markerSize + markerSize, + displayOffsetY } if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) { @@ -3727,10 +3984,10 @@ const isSceneReadyForTargetFocus = () => ( ) const focusCameraOnPoi = (poi: RenderPoi) => { - if (!camera || !controls || !poi.positionGltf) return + const displayPosition = getPoiDisplayPosition(poi) + if (!camera || !controls || !displayPosition) return - const [x, y, z] = poi.positionGltf - const target = new THREE.Vector3(x, y, z) + const target = displayPosition.clone() const modelSize = activeModel ? new THREE.Box3().setFromObject(activeModel).getSize(new THREE.Vector3()) : new THREE.Vector3(80, 80, 80) @@ -3889,26 +4146,46 @@ const init3DScene = async () => { const handleFloorChange = async (floorId: string) => { const requestedFloorId = resolveFloorIdFromRequest(floorId) || floorId + const isCommittedSameFloor = ( + activeView.value === 'floor' + && currentFloor.value === requestedFloorId + && activeModel?.userData.floorId === requestedFloorId + && !isFloorSwitching + ) + if (isCommittedSameFloor) { + return + } + if (isFloorSwitching && floorSwitchRequestedFloorId === requestedFloorId) { + return + } + + const previousLoadToken = modelLoadVersion try { isLoading.value = true loadError.value = false setProgress(12, `正在切换到 ${formatFloorLabel(requestedFloorId)}...`) - await loadFloor(requestedFloorId, { + const didCommit = await loadFloor(requestedFloorId, { preserveCurrentSceneUntilReady: true, suppressProgress: false, detachPoiBeforeLoad: true }) updatePoiVisibilityByDistance() - isLoading.value = false - emit('floorChange', requestedFloorId) + if (didCommit) { + emit('floorChange', requestedFloorId) + } } catch (error) { if (isStaleModelLoadError(error)) return console.error('楼层模型加载失败:', error) loadError.value = true - isLoading.value = false + restoreCommittedFloorPoiLayer() setProgress(0, error instanceof Error ? error.message : '楼层模型加载失败') + } finally { + const requestWasSuperseded = modelLoadVersion > previousLoadToken + 1 + if (!requestWasSuperseded || floorSwitchRequestedFloorId === requestedFloorId) { + isLoading.value = false + } } } diff --git a/src/components/navigation/GuideMapShell.vue b/src/components/navigation/GuideMapShell.vue index 8d88019..6485df4 100644 --- a/src/components/navigation/GuideMapShell.vue +++ b/src/components/navigation/GuideMapShell.vue @@ -150,7 +150,12 @@ :id="floor.scrollId" :key="floor.id" class="floor-item" - :class="{ active: activeFloorId === floor.id && layerMode !== 'multi' }" + :class="{ + active: activeFloorId === floor.id && layerMode !== 'multi', + pending: loadingFloorId === floor.id, + failed: failedFloorId === floor.id, + disabled: isFloorSwitchDisabled(floor.id) + }" @tap.stop="handleFloorChange(floor)" > {{ floor.label }} @@ -247,6 +252,11 @@ interface GuideFloorOption { label: string } +interface FloorSwitchEvent { + floorId: string + floorLabel: string +} + interface OutdoorNavPolyline { points: Array<{ latitude: number; longitude: number }> color: string @@ -381,7 +391,9 @@ const props = withDefaults(defineProps<{ const emit = defineEmits<{ searchTap: [] modeChange: [mode: '2d' | '3d'] + floorRequest: [event: FloorSwitchEvent] floorChange: [floor: string] + floorSwitchFailed: [event: FloorSwitchEvent] toolClick: [tool: string] moreClick: [] indoorViewChange: [view: IndoorViewMode] @@ -445,8 +457,15 @@ const activeFloorId = computed(() => { return normalizedFloor?.id || normalizedFloorId }) const activeFloorScrollIntoView = ref('') -const pendingFloorId = ref('') -let suppressFloorAutoScrollUntil = 0 +const requestedFloorId = ref('') +const requestedFloorLabel = ref('') +const loadingFloorId = ref('') +const renderedFloorId = ref(activeFloorId.value) +const activeFloorSwitchRequestSeq = ref(0) +const renderedFloorSwitchRequestSeq = ref(0) +const failedFloorId = ref('') +let suppressFloorAutoScrollForFloorId = '' +let floorSwitchRequestSeq = 0 const syncActiveFloorScroll = () => { if (props.mapType !== 'indoor' || props.indoorView === 'overview' || props.layerMode === 'multi') { @@ -454,10 +473,12 @@ const syncActiveFloorScroll = () => { return } - if (Date.now() < suppressFloorAutoScrollUntil) { + if (activeFloorId.value === suppressFloorAutoScrollForFloorId) { activeFloorScrollIntoView.value = '' + suppressFloorAutoScrollForFloorId = '' return } + suppressFloorAutoScrollForFloorId = '' const matchedFloor = floorItems.value.find((floor) => floor.id === activeFloorId.value) activeFloorScrollIntoView.value = matchedFloor?.scrollId || '' @@ -475,6 +496,11 @@ watch( { immediate: true } ) +watch(activeFloorId, (floorId) => { + if (!floorId || loadingFloorId.value === floorId) return + renderedFloorId.value = floorId +}) + const searchFieldStyle = computed(() => ({ top: props.searchTop })) @@ -528,27 +554,77 @@ const isStaleFloorSwitchError = (error: unknown) => ( error instanceof Error && error.message === 'STALE_MODEL_LOAD' ) -const handleFloorChange = (floor: { id: string; label: string }) => { - const floorId = floor.id || props.normalizeFloorId(floor.label) - if (!floorId || pendingFloorId.value === floorId) return +const findFloorItemById = (floorId: string) => ( + floorItems.value.find((floor) => floor.id === floorId) +) - pendingFloorId.value = floorId - suppressFloorAutoScrollUntil = Date.now() + 650 +const isFloorSwitchDisabled = (floorId: string) => ( + Boolean(loadingFloorId.value) && loadingFloorId.value !== floorId +) + +const clearFloorLoadingIfCurrent = (floorId: string) => { + if (loadingFloorId.value === floorId) { + loadingFloorId.value = '' + } +} + +const markFloorSwitchFailedIfUnrendered = (floorId: string, requestSeq: number) => { + if ( + loadingFloorId.value !== floorId + || activeFloorSwitchRequestSeq.value !== requestSeq + || renderedFloorSwitchRequestSeq.value === requestSeq + ) return + + failedFloorId.value = floorId + clearFloorLoadingIfCurrent(floorId) + emit('floorSwitchFailed', { + floorId, + floorLabel: requestedFloorId.value === floorId + ? requestedFloorLabel.value || findFloorItemById(floorId)?.label || floorId + : findFloorItemById(floorId)?.label || floorId + }) +} + +const handleFloorChange = (floor: { id: string; label: string }) => { + const floorId = floor.id + if (!floorId || loadingFloorId.value) return + if (floorId === renderedFloorId.value && activeFloorId.value === floorId && props.layerMode !== 'multi') { + emit('floorChange', floorId) + emit('indoorViewChange', 'floor') + emit('layerModeChange', 'single') + return + } + + requestedFloorId.value = floorId + requestedFloorLabel.value = floor.label + loadingFloorId.value = floorId + const requestSeq = ++floorSwitchRequestSeq + activeFloorSwitchRequestSeq.value = requestSeq + failedFloorId.value = '' + suppressFloorAutoScrollForFloorId = floorId activeFloorScrollIntoView.value = '' indoorRendererRef.value?.disableAutoSwitchTemporarily?.(10000) + emit('floorRequest', { + floorId, + floorLabel: floor.label + }) emit('indoorViewChange', 'floor') emit('layerModeChange', 'single') Promise.resolve(indoorRendererRef.value?.switchFloor?.(floorId)) + .then(() => { + markFloorSwitchFailedIfUnrendered(floorId, requestSeq) + }) .catch((error) => { if (isStaleFloorSwitchError(error)) return + failedFloorId.value = floorId + clearFloorLoadingIfCurrent(floorId) + emit('floorSwitchFailed', { + floorId, + floorLabel: floor.label + }) console.error('楼层切换失败:', error) }) - .finally(() => { - if (pendingFloorId.value === floorId) { - pendingFloorId.value = '' - } - }) } const handleLayerModeChange = (mode: LayerDisplayMode) => { @@ -556,22 +632,45 @@ const handleLayerModeChange = (mode: LayerDisplayMode) => { indoorRendererRef.value?.disableAutoSwitchTemporarily?.(10000) if (mode === 'multi') { - pendingFloorId.value = '' + loadingFloorId.value = '' + requestedFloorId.value = '' + requestedFloorLabel.value = '' void indoorRendererRef.value?.showMultiFloor?.() emit('indoorViewChange', 'multi') } else { const floorId = activeFloorId.value - pendingFloorId.value = floorId + const floorLabel = findFloorItemById(floorId)?.label || floorId + if (!floorId) return + if (floorId === renderedFloorId.value && props.layerMode !== 'multi') { + emit('indoorViewChange', 'floor') + emit('layerModeChange', mode) + return + } + + requestedFloorId.value = floorId + requestedFloorLabel.value = floorLabel + loadingFloorId.value = floorId + const requestSeq = ++floorSwitchRequestSeq + activeFloorSwitchRequestSeq.value = requestSeq + failedFloorId.value = '' + emit('floorRequest', { + floorId, + floorLabel + }) Promise.resolve(indoorRendererRef.value?.switchFloor?.(floorId)) + .then(() => { + markFloorSwitchFailedIfUnrendered(floorId, requestSeq) + }) .catch((error) => { if (isStaleFloorSwitchError(error)) return + failedFloorId.value = floorId + clearFloorLoadingIfCurrent(floorId) + emit('floorSwitchFailed', { + floorId, + floorLabel + }) console.error('楼层切换失败:', error) }) - .finally(() => { - if (pendingFloorId.value === floorId) { - pendingFloorId.value = '' - } - }) emit('indoorViewChange', 'floor') } @@ -622,6 +721,14 @@ const handleMoreTap = () => { } const handleThreeFloorChange = (floorId: string) => { + renderedFloorId.value = floorId + if (loadingFloorId.value === floorId) { + renderedFloorSwitchRequestSeq.value = activeFloorSwitchRequestSeq.value + } + if (failedFloorId.value === floorId) { + failedFloorId.value = '' + } + clearFloorLoadingIfCurrent(floorId) emit('floorChange', floorId) emit('indoorViewChange', 'floor') emit('layerModeChange', 'single') @@ -1120,6 +1227,28 @@ defineExpose({ background: #000000; } +.floor-item.pending { + pointer-events: none; + opacity: 0.72; +} + +.floor-item.pending::before { + content: ''; + position: absolute; + inset: 7px; + border: 1px solid rgba(224, 225, 0, 0.9); + border-radius: 6px; +} + +.floor-item.disabled { + pointer-events: none; + opacity: 0.46; +} + +.floor-item.failed:not(.pending) .floor-label { + color: #b84a4a; +} + .floor-label { font-size: 13px; line-height: 18px; diff --git a/src/data/adapters/sgsSdkGuideAdapter.ts b/src/data/adapters/sgsSdkGuideAdapter.ts index 5db6350..aa8cf10 100644 --- a/src/data/adapters/sgsSdkGuideAdapter.ts +++ b/src/data/adapters/sgsSdkGuideAdapter.ts @@ -1,167 +1,167 @@ -import type { - GuideDataIntegrityIssue, - GuideDataIntegrityReport, - GuideDiagnosticsStatus, - GuideFloorDetail, - GuideLocationPreview, - GuideMapDiagnostics, - GuideRouteReadiness, - MuseumCategory, - MuseumFloor, - MuseumPoi -} from '@/domain/museum' -import { - NAV_ROUTE_UNAVAILABLE_MESSAGE -} from '@/domain/guideReadiness' -import { - isIndoorNavigableFloor -} from '@/domain/guideFloor' -import type { - SgsFloorDiagnosticsPayload, - SgsMapDiagnosticsPayload, - SgsNavigablePlacePayload, - SgsPoiPayload, - SgsPositionPayload, - SgsSdkFloorSummaryPayload, - SgsSdkManifestPayload, - SgsSpacePayload -} from '@/data/providers/sgsSdkApiProvider' - -const defaultCategory: MuseumCategory = { - id: 'operation_experience', - label: '导览点位', - iconType: 'poi' -} - -const hallCategory: MuseumCategory = { - id: 'exhibition_hall', - label: '展厅', - iconType: 'exhibition_hall' -} - -const hallEntranceCategory: MuseumCategory = { - id: 'exhibition_hall_entrance', - label: '展厅出入口', - iconType: 'hall_entrance' -} - -const categoryBySgsType: Record = { - toilet: { - id: 'basic_service_facility', - label: '卫生间', - iconType: 'toilet' - }, - accessible_toilet: { - id: 'accessibility_special_service', - label: '无障碍卫生间', - iconType: 'accessible_toilet', - accessible: true - }, - elevator: { - id: 'transport_circulation', - label: '电梯', - iconType: 'elevator', - accessible: true - }, - stairs: { - id: 'transport_circulation', - label: '楼梯', - iconType: 'stairs' - }, - escalator: { - id: 'transport_circulation', - label: '扶梯', - iconType: 'escalator' - }, - entrance_exit: { - id: 'transport_circulation', - label: '出入口', - iconType: 'entrance_exit' - }, - service_desk: { - id: 'basic_service_facility', - label: '服务台', - iconType: 'service_desk' - }, - mother_baby_room: { - id: 'basic_service_facility', - label: '母婴室', - iconType: 'mother_baby_room', - accessible: true - }, - locker: { - id: 'basic_service_facility', - label: '存包处', - iconType: 'locker' - }, - rental_service: { - id: 'basic_service_facility', - label: '租赁服务', - iconType: 'rental_service', - accessible: true - }, - ticket_office: { - id: 'basic_service_facility', - label: '售票处', - iconType: 'ticket_office' - } -} - -export const stringifyId = (value: string | number | null | undefined) => ( - value === null || typeof value === 'undefined' ? '' : String(value) -) - -const toNumber = (value: number | null | undefined, fallback = 0) => ( - Number.isFinite(Number(value)) ? Number(value) : fallback -) - -const optionalNumber = (value: number | null | undefined) => ( - Number.isFinite(Number(value)) ? Number(value) : undefined -) - -const normalizedText = (value?: string | null) => (value || '').trim() - -const normalizePositionSource = (source: SgsPositionPayload): [number, number, number] | undefined => { - const x = Number(source.x) - const y = Number(source.y) - const z = Number(source.z) - - if (![x, y, z].every(Number.isFinite)) return undefined - return [x, y, z] -} - -const normalizeStatus = (status?: string): GuideDiagnosticsStatus => { - if (status === 'OK' || status === 'WARN' || status === 'ERROR') return status - return 'WARN' -} - -export const formatSgsFloorLabel = (floorCode?: string | null, floorName?: string | null) => { - if (floorCode === 'EXTERIOR') return '馆外' - - const basementMatch = floorCode?.match(/^L-(\d+(?:\.\d+)?)$/) - if (basementMatch) return `B${basementMatch[1]}` - - const floorMatch = floorCode?.match(/^L(\d+(?:\.\d+)?)$/) - if (floorMatch) return `${floorMatch[1]}F` - - return floorName || floorCode || '未知楼层' -} - -export const toMuseumFloorFromSgs = (floor: SgsSdkFloorSummaryPayload): MuseumFloor => ({ - id: stringifyId(floor.floorId), - label: formatSgsFloorLabel(floor.floorCode, floor.floorName), - order: toNumber(floor.sortOrder) -}) - -export const buildSgsFloorAliases = (floors: SgsSdkFloorSummaryPayload[]) => { - const aliases = new Map() - - floors.forEach((floor) => { - if (!isIndoorNavigableFloor(floor)) return - - const floorId = stringifyId(floor.floorId) - if (!floorId) return - +import type { + GuideDataIntegrityIssue, + GuideDataIntegrityReport, + GuideDiagnosticsStatus, + GuideFloorDetail, + GuideLocationPreview, + GuideMapDiagnostics, + GuideRouteReadiness, + MuseumCategory, + MuseumFloor, + MuseumPoi +} from '@/domain/museum' +import { + NAV_ROUTE_UNAVAILABLE_MESSAGE +} from '@/domain/guideReadiness' +import { + isIndoorNavigableFloor +} from '@/domain/guideFloor' +import type { + SgsFloorDiagnosticsPayload, + SgsMapDiagnosticsPayload, + SgsNavigablePlacePayload, + SgsPoiPayload, + SgsPositionPayload, + SgsSdkFloorSummaryPayload, + SgsSdkManifestPayload, + SgsSpacePayload +} from '@/data/providers/sgsSdkApiProvider' + +const defaultCategory: MuseumCategory = { + id: 'operation_experience', + label: '导览点位', + iconType: 'poi' +} + +const hallCategory: MuseumCategory = { + id: 'exhibition_hall', + label: '展厅', + iconType: 'exhibition_hall' +} + +const hallEntranceCategory: MuseumCategory = { + id: 'exhibition_hall_entrance', + label: '展厅出入口', + iconType: 'hall_entrance' +} + +const categoryBySgsType: Record = { + toilet: { + id: 'basic_service_facility', + label: '卫生间', + iconType: 'toilet' + }, + accessible_toilet: { + id: 'accessibility_special_service', + label: '无障碍卫生间', + iconType: 'accessible_toilet', + accessible: true + }, + elevator: { + id: 'transport_circulation', + label: '电梯', + iconType: 'elevator', + accessible: true + }, + stairs: { + id: 'transport_circulation', + label: '楼梯', + iconType: 'stairs' + }, + escalator: { + id: 'transport_circulation', + label: '扶梯', + iconType: 'escalator' + }, + entrance_exit: { + id: 'transport_circulation', + label: '出入口', + iconType: 'entrance_exit' + }, + service_desk: { + id: 'basic_service_facility', + label: '服务台', + iconType: 'service_desk' + }, + mother_baby_room: { + id: 'basic_service_facility', + label: '母婴室', + iconType: 'mother_baby_room', + accessible: true + }, + locker: { + id: 'basic_service_facility', + label: '存包处', + iconType: 'locker' + }, + rental_service: { + id: 'basic_service_facility', + label: '租赁服务', + iconType: 'rental_service', + accessible: true + }, + ticket_office: { + id: 'basic_service_facility', + label: '售票处', + iconType: 'ticket_office' + } +} + +export const stringifyId = (value: string | number | null | undefined) => ( + value === null || typeof value === 'undefined' ? '' : String(value) +) + +const toNumber = (value: number | null | undefined, fallback = 0) => ( + Number.isFinite(Number(value)) ? Number(value) : fallback +) + +const optionalNumber = (value: number | null | undefined) => ( + Number.isFinite(Number(value)) ? Number(value) : undefined +) + +const normalizedText = (value?: string | null) => (value || '').trim() + +const normalizePositionSource = (source: SgsPositionPayload): [number, number, number] | undefined => { + const x = Number(source.x) + const y = Number(source.y) + const z = Number(source.z) + + if (![x, y, z].every(Number.isFinite)) return undefined + return [x, y, z] +} + +const normalizeStatus = (status?: string): GuideDiagnosticsStatus => { + if (status === 'OK' || status === 'WARN' || status === 'ERROR') return status + return 'WARN' +} + +export const formatSgsFloorLabel = (floorCode?: string | null, floorName?: string | null) => { + if (floorCode === 'EXTERIOR') return '馆外' + + const basementMatch = floorCode?.match(/^L-(\d+(?:\.\d+)?)$/) + if (basementMatch) return `B${basementMatch[1]}` + + const floorMatch = floorCode?.match(/^L(\d+(?:\.\d+)?)$/) + if (floorMatch) return `${floorMatch[1]}F` + + return floorName || floorCode || '未知楼层' +} + +export const toMuseumFloorFromSgs = (floor: SgsSdkFloorSummaryPayload): MuseumFloor => ({ + id: stringifyId(floor.floorId), + label: formatSgsFloorLabel(floor.floorCode, floor.floorName), + order: toNumber(floor.sortOrder) +}) + +export const buildSgsFloorAliases = (floors: SgsSdkFloorSummaryPayload[]) => { + const aliases = new Map() + + floors.forEach((floor) => { + if (!isIndoorNavigableFloor(floor)) return + + const floorId = stringifyId(floor.floorId) + if (!floorId) return + const label = formatSgsFloorLabel(floor.floorCode, floor.floorName) aliases.set(floorId, floorId) aliases.set(floorId.toLowerCase(), floorId) @@ -172,632 +172,725 @@ export const buildSgsFloorAliases = (floors: SgsSdkFloorSummaryPayload[]) => { if (floor.floorName) { aliases.set(String(floor.floorName), floorId) aliases.set(String(floor.floorName).toLowerCase(), floorId) - } + } aliases.set(label, floorId) aliases.set(label.toLowerCase(), floorId) }) - - return aliases -} - -const exhibitionSpaceTypeWhitelist = new Set([ - 'exhibition_hall', - 'theater', - 'education_activity', - 'ramp' -]) - + + return aliases +} + +const exhibitionSpaceTypeWhitelist = new Set([ + 'exhibition_hall', + 'theater', + 'education_activity', + 'ramp' +]) + const exhibitionHallKeywords = [ - '展厅', - '临展', - '展览', - '影院', - '球幕', - '巨幕', - '报告厅', - '活动室', - '科普', - '实验室', - '展览坡道', - '宇宙', - '地球', - '演化', - '恐龙', - '人类', - '生物', - '生态', + '展厅', + '临展', + '展览', + '影院', + '球幕', + '巨幕', + '报告厅', + '活动室', + '科普', + '实验室', + '展览坡道', + '宇宙', + '地球', + '演化', + '恐龙', + '人类', + '生物', + '生态', '家园' ] +const nonHallNavigablePlaceTypes = new Set([ + 'elevator', + 'stairs', + 'stair', + 'escalator', + 'lift', + 'toilet', + 'accessible_toilet', + 'restroom', + '卫生间', + '洗手间', + '无障碍卫生间', + '电梯', + '楼梯', + '扶梯' +]) + export interface SgsHallPoiDiagnostics { spaceCount: number eligibleSpaceCount: number navigablePlaceCount: number hallPlaceCount: number - hallPoiCount: number - hallPoiWithPositionCount: number - skippedSpaceCount: number + hallPoiCount: number + hallPoiWithPositionCount: number + skippedSpaceCount: number skippedPlaceCount: number } -const hasExhibitionKeyword = (value?: string | null) => ( - exhibitionHallKeywords.some((keyword) => normalizedText(value).includes(keyword)) -) - -const searchableSpaceText = (space: SgsSpacePayload) => [ - space.name, - space.type, - space.sourceNodeName -] - .map((value) => normalizedText(value)) - .filter(Boolean) - .join(' ') - -const normalizeSpaceCenter = (space: SgsSpacePayload): [number, number, number] | undefined => normalizePositionSource( - space.center || { - x: undefined, - y: undefined, - z: undefined - } -) - -export const isExhibitionHallSpace = (space: SgsSpacePayload) => { - const type = normalizedText(space.type).toLowerCase() - const name = normalizedText(space.name) - const searchableText = searchableSpaceText(space) - - if (!exhibitionSpaceTypeWhitelist.has(type)) return false - if (type === 'exhibition_hall') return true - if (type === 'ramp') return name.includes('展览坡道') - - return hasExhibitionKeyword(searchableText) +interface SgsHallPoiBuildOptions { + fallbackY?: number } +const sgsSpaceCenterConfidence = 'backend-sgs-sdk-space-center' +const sgsSpaceBoundaryCenterConfidence = 'backend-sgs-sdk-space-boundary-center' +const sgsHallEntranceConfidence = 'backend-sgs-sdk-hall-entrance' + +const hasExhibitionKeyword = (value?: string | null) => ( + exhibitionHallKeywords.some((keyword) => normalizedText(value).includes(keyword)) +) + +const searchableSpaceText = (space: SgsSpacePayload) => [ + space.name, + space.type, + space.sourceNodeName +] + .map((value) => normalizedText(value)) + .filter(Boolean) + .join(' ') + +const parseBoundaryWktCenter = ( + boundaryWkt?: string | null, + fallbackY?: number +): [number, number, number] | undefined => { + const matches = [...(boundaryWkt || '').matchAll(/(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)/g)] + const points = matches + .map((match) => [Number(match[1]), Number(match[2])] as const) + .filter(([x, z]) => Number.isFinite(x) && Number.isFinite(z)) + + if (!points.length || !Number.isFinite(fallbackY)) return undefined + + const xs = points.map(([x]) => x) + const zs = points.map(([, z]) => z) + const x = (Math.min(...xs) + Math.max(...xs)) / 2 + const z = (Math.min(...zs) + Math.max(...zs)) / 2 + + return [x, fallbackY as number, z] +} + +const normalizePositionSourceWithFallbackY = ( + source: SgsPositionPayload | null | undefined, + fallbackY?: number +): [number, number, number] | undefined => { + if (!source) return undefined + + const x = Number(source.x) + const rawY = Number(source.y) + const y = Number.isFinite(rawY) ? rawY : Number(fallbackY) + const z = Number(source.z) + + if (![x, y, z].every(Number.isFinite)) return undefined + return [x, y, z] +} + +const normalizeSpacePosition = ( + space: SgsSpacePayload, + fallbackY?: number +) => { + const centerPosition = normalizePositionSourceWithFallbackY(space.center, fallbackY) + if (centerPosition) { + return { + position: centerPosition, + sourceConfidence: sgsSpaceCenterConfidence + } + } + + const boundaryCenter = parseBoundaryWktCenter(space.boundaryWkt, fallbackY) + return boundaryCenter + ? { + position: boundaryCenter, + sourceConfidence: sgsSpaceBoundaryCenterConfidence + } + : undefined +} + +export const isExhibitionHallSpace = (space: SgsSpacePayload) => { + const type = normalizedText(space.type).toLowerCase() + const name = normalizedText(space.name) + const searchableText = searchableSpaceText(space) + + if (!exhibitionSpaceTypeWhitelist.has(type)) return false + if (type === 'exhibition_hall') return true + if (type === 'ramp') return name.includes('展览坡道') + + return hasExhibitionKeyword(searchableText) +} + export const isExhibitionHallNavigablePlace = (place: SgsNavigablePlacePayload) => { - const searchableText = [ - place.name, - place.ownerName, + const placeTypes = [ place.category, place.type, place.typeCode, place.typeName ] - .map((value) => normalizedText(value)) + .map((value) => normalizedText(value).toLowerCase()) .filter(Boolean) - .join(' ') - return hasExhibitionKeyword(searchableText) -} + if (placeTypes.some((type) => nonHallNavigablePlaceTypes.has(type))) return false -const normalizedHallName = (value?: string | null) => normalizedText(value) - .replace(/[\s()()【】[\]_-]/g, '') - -const normalizePosition = (poi: SgsPoiPayload): [number, number, number] | undefined => normalizePositionSource( - poi.position || { - x: poi.x, - y: poi.y, - z: poi.z - } -) - -const normalizePlacePosition = (place: SgsNavigablePlacePayload): [number, number, number] | undefined => normalizePositionSource( + const searchableText = [ + place.name, + place.ownerName, + place.category, + place.type, + place.typeCode, + place.typeName + ] + .map((value) => normalizedText(value)) + .filter(Boolean) + .join(' ') + + return hasExhibitionKeyword(searchableText) +} + +const normalizedHallName = (value?: string | null) => normalizedText(value) + .replace(/[\s()()【】[\]_-]/g, '') + +const normalizePosition = (poi: SgsPoiPayload): [number, number, number] | undefined => normalizePositionSource( + poi.position || { + x: poi.x, + y: poi.y, + z: poi.z + } +) + +const normalizePlacePosition = ( + place: SgsNavigablePlacePayload, + fallbackY?: number +): [number, number, number] | undefined => normalizePositionSourceWithFallbackY( place.position ? { x: place.position.x, - y: place.position.y ?? 0, + y: place.position.y, z: place.position.z } : { x: place.x, - y: place.y ?? 0, + y: place.y, z: place.z - } -) - -const categoryFor = (poi: SgsPoiPayload): MuseumCategory & { accessible?: boolean } => { - const normalizedType = poi.type?.trim() - if (normalizedType && categoryBySgsType[normalizedType]) { - return categoryBySgsType[normalizedType] - } - - if (poi.typeName) { - return { - ...defaultCategory, - label: poi.typeName, - iconType: normalizedType || defaultCategory.iconType - } - } - - return defaultCategory -} - -export const toMuseumPoiFromSgs = ( - poi: SgsPoiPayload, - floors: SgsSdkFloorSummaryPayload[] -): MuseumPoi => { - const floorId = stringifyId(poi.floorId) - const matchedFloor = floors.find((floor) => stringifyId(floor.floorId) === floorId || floor.floorCode === poi.floorCode) - const category = categoryFor(poi) - - return { - id: stringifyId(poi.id), - name: poi.name?.trim() || `未命名点位 ${stringifyId(poi.id)}`, - floorId, - floorLabel: formatSgsFloorLabel(matchedFloor?.floorCode || poi.floorCode, matchedFloor?.floorName), - primaryCategory: { - id: category.id, - label: category.label, - iconType: category.iconType }, - categories: [ - { - id: category.id, - label: category.label, - iconType: category.iconType - } - ], - positionGltf: normalizePosition(poi), - sourceObjectName: poi.anchorNodeName || undefined, - sourceConfidence: 'backend-sgs-sdk', - navigationReadiness: '位置预览', - accessible: category.accessible === true, - kind: category.id === 'operation_experience' ? 'guide' : 'facility' - } -} - -export const toLocationPreviewFromPoi = (poi: MuseumPoi): GuideLocationPreview => ({ - poiId: poi.id, - name: poi.name, - floorId: poi.floorId, - floorLabel: poi.floorLabel, - primaryCategoryZh: poi.primaryCategory.label, - positionGltf: poi.positionGltf, - sourceObjectName: poi.sourceObjectName, - kind: poi.kind, - hallId: poi.hallId, - hallName: poi.hallName, - entrances: poi.entrances -}) - -const placeHallName = (place: SgsNavigablePlacePayload) => ( - normalizedText(place.ownerName) - || normalizedText(place.name) - .replace(/(?:主)?(?:出入口|入口|出口|门)\s*[A-Za-z0-9一二三四五六七八九十号-]*$/u, '') - .trim() - || normalizedText(place.name) + fallbackY ) - -const findMatchedHallSpace = ( - place: SgsNavigablePlacePayload, - spaces: SgsSpacePayload[] -) => { - const placeName = normalizedHallName(placeHallName(place)) - if (!placeName) return undefined - - const rankedMatches = spaces - .map((space) => { - const spaceName = normalizedHallName(space.name) - if (!spaceName) return null - - const exact = placeName === spaceName - const overlaps = exact || placeName.includes(spaceName) || spaceName.includes(placeName) - if (!overlaps) return null - - return { - space, - exact, - specificity: spaceName.length - } - }) - .filter((match): match is { - space: SgsSpacePayload - exact: boolean - specificity: number - } => Boolean(match)) - .sort((left, right) => { - if (left.exact !== right.exact) return left.exact ? -1 : 1 - return right.specificity - left.specificity - }) - - return rankedMatches[0]?.space -} - -const floorLabelForSgs = ( - floorId: string, - floors: SgsSdkFloorSummaryPayload[], - floorCode?: string | null, - floorName?: string | null -) => { - const matchedFloor = floors.find((floor) => stringifyId(floor.floorId) === floorId || floor.floorCode === floorCode) - return formatSgsFloorLabel( - matchedFloor?.floorCode || floorCode, - matchedFloor?.floorName || floorName - ) -} - + +const categoryFor = (poi: SgsPoiPayload): MuseumCategory & { accessible?: boolean } => { + const normalizedType = poi.type?.trim() + if (normalizedType && categoryBySgsType[normalizedType]) { + return categoryBySgsType[normalizedType] + } + + if (poi.typeName) { + return { + ...defaultCategory, + label: poi.typeName, + iconType: normalizedType || defaultCategory.iconType + } + } + + return defaultCategory +} + +export const toMuseumPoiFromSgs = ( + poi: SgsPoiPayload, + floors: SgsSdkFloorSummaryPayload[] +): MuseumPoi => { + const floorId = stringifyId(poi.floorId) + const matchedFloor = floors.find((floor) => stringifyId(floor.floorId) === floorId || floor.floorCode === poi.floorCode) + const category = categoryFor(poi) + + return { + id: stringifyId(poi.id), + name: poi.name?.trim() || `未命名点位 ${stringifyId(poi.id)}`, + floorId, + floorLabel: formatSgsFloorLabel(matchedFloor?.floorCode || poi.floorCode, matchedFloor?.floorName), + primaryCategory: { + id: category.id, + label: category.label, + iconType: category.iconType + }, + categories: [ + { + id: category.id, + label: category.label, + iconType: category.iconType + } + ], + positionGltf: normalizePosition(poi), + sourceObjectName: poi.anchorNodeName || undefined, + sourceConfidence: 'backend-sgs-sdk', + navigationReadiness: '位置预览', + accessible: category.accessible === true, + kind: category.id === 'operation_experience' ? 'guide' : 'facility' + } +} + +export const toLocationPreviewFromPoi = (poi: MuseumPoi): GuideLocationPreview => ({ + poiId: poi.id, + name: poi.name, + floorId: poi.floorId, + floorLabel: poi.floorLabel, + primaryCategoryZh: poi.primaryCategory.label, + positionGltf: poi.positionGltf, + sourceObjectName: poi.sourceObjectName, + kind: poi.kind, + hallId: poi.hallId, + hallName: poi.hallName, + entrances: poi.entrances +}) + +const placeHallName = (place: SgsNavigablePlacePayload) => ( + normalizedText(place.ownerName) + || normalizedText(place.name) + .replace(/(?:主)?(?:出入口|入口|出口|门)\s*[A-Za-z0-9一二三四五六七八九十号-]*$/u, '') + .trim() + || normalizedText(place.name) +) + +const findMatchedHallSpace = ( + place: SgsNavigablePlacePayload, + spaces: SgsSpacePayload[] +) => { + const placeName = normalizedHallName(placeHallName(place)) + if (!placeName) return undefined + + const rankedMatches = spaces + .map((space) => { + const spaceName = normalizedHallName(space.name) + if (!spaceName) return null + + const exact = placeName === spaceName + const overlaps = exact || placeName.includes(spaceName) || spaceName.includes(placeName) + if (!overlaps) return null + + return { + space, + exact, + specificity: spaceName.length + } + }) + .filter((match): match is { + space: SgsSpacePayload + exact: boolean + specificity: number + } => Boolean(match)) + .sort((left, right) => { + if (left.exact !== right.exact) return left.exact ? -1 : 1 + return right.specificity - left.specificity + }) + + return rankedMatches[0]?.space +} + +const floorLabelForSgs = ( + floorId: string, + floors: SgsSdkFloorSummaryPayload[], + floorCode?: string | null, + floorName?: string | null +) => { + const matchedFloor = floors.find((floor) => stringifyId(floor.floorId) === floorId || floor.floorCode === floorCode) + return formatSgsFloorLabel( + matchedFloor?.floorCode || floorCode, + matchedFloor?.floorName || floorName + ) +} + const createHallEntrance = ( place: SgsNavigablePlacePayload, floors: SgsSdkFloorSummaryPayload[], - fallbackFloorId: string + fallbackFloorId: string, + fallbackY?: number ) => { - const floorId = stringifyId(place.floorId) || fallbackFloorId - const placeId = stringifyId(place.id || place.nodeId) - const routeNodeId = stringifyId(place.nodeId) || undefined - - return { + const floorId = stringifyId(place.floorId) || fallbackFloorId + const placeId = stringifyId(place.id || place.nodeId) + const routeNodeId = stringifyId(place.nodeId) || undefined + + return { id: placeId ? `hall-entrance-${placeId}` : `hall-entrance-${floorId}-${normalizedHallName(place.name)}`, name: normalizedText(place.name) || normalizedText(place.ownerName) || '展厅出入口', floorId, floorLabel: floorLabelForSgs(floorId, floors, place.floorCode, place.floorName), - positionGltf: normalizePlacePosition(place), - sourceObjectName: routeNodeId || undefined, - sourcePlaceId: routeNodeId ? placeId || undefined : undefined, - routeNodeId - } -} - + positionGltf: normalizePlacePosition(place, fallbackY), + sourceObjectName: routeNodeId || undefined, + sourcePlaceId: routeNodeId ? placeId || undefined : undefined, + routeNodeId + } +} + const createHallPoiId = ( - space: SgsSpacePayload | undefined, - place: SgsNavigablePlacePayload, - fallbackIndex: number + space: SgsSpacePayload ) => { - const spaceId = stringifyId(space?.id) + const spaceId = stringifyId(space.id) if (spaceId) return `hall-${spaceId}` - const ownerName = normalizedHallName(placeHallName(place)) - const floorIdentity = normalizedHallName(stringifyId(place.floorId) || place.floorCode || 'unknown-floor') - if (ownerName) return `hall-place-${floorIdentity}-${ownerName}` + const ownerName = normalizedHallName(space.name) + const floorIdentity = normalizedHallName(stringifyId(space.floorId) || 'unknown-floor') + if (ownerName) return `hall-space-${floorIdentity}-${ownerName}` - return `hall-place-${floorIdentity}-${stringifyId(place.id) || fallbackIndex + 1}` + return `hall-space-${floorIdentity}-unknown` } - + const createSpaceFallbackHallPoi = ( space: SgsSpacePayload, floors: SgsSdkFloorSummaryPayload[], - fallbackFloorId: string + fallbackFloorId: string, + fallbackY?: number ): MuseumPoi | null => { const floorId = stringifyId(space.floorId) || fallbackFloorId - const position = normalizeSpaceCenter(space) + const spacePosition = normalizeSpacePosition(space, fallbackY) + const position = spacePosition?.position const hallId = stringifyId(space.id) - - if (!hallId || !normalizedText(space.name) || !position) return null - - return { - id: `hall-${hallId}`, - name: normalizedText(space.name), - floorId, - floorLabel: floorLabelForSgs(floorId, floors), - primaryCategory: hallCategory, - categories: [ - hallCategory + + if (!hallId || !normalizedText(space.name) || !position) return null + + return { + id: `hall-${hallId}`, + name: normalizedText(space.name), + floorId, + floorLabel: floorLabelForSgs(floorId, floors), + primaryCategory: hallCategory, + categories: [ + hallCategory ], positionGltf: position, sourceObjectName: space.sourceNodeName || undefined, - sourceConfidence: 'backend-sgs-sdk-space-center', - navigationReadiness: '位置预览', - accessible: false, - kind: 'hall', - hallId, - hallName: normalizedText(space.name), - spaceId: hallId, - sourceSpaceId: hallId, - entrances: [] - } -} - + sourceConfidence: spacePosition.sourceConfidence, + navigationReadiness: '位置预览', + accessible: false, + kind: 'hall', + hallId, + hallName: normalizedText(space.name), + spaceId: hallId, + sourceSpaceId: hallId, + entrances: [] + } +} + export const toMuseumHallPoisFromSgs = ( spaces: SgsSpacePayload[], navigablePlaces: SgsNavigablePlacePayload[], floors: SgsSdkFloorSummaryPayload[], - fallbackFloorId: string + fallbackFloorId: string, + options: SgsHallPoiBuildOptions = {} ): MuseumPoi[] => { - const hallSpaces = spaces.filter(isExhibitionHallSpace) - const hallPlaces = navigablePlaces.filter(isExhibitionHallNavigablePlace) - const halls = new Map() - - hallPlaces.forEach((place, index) => { + const hallSpaces = spaces.filter(isExhibitionHallSpace) + const hallPlaces = navigablePlaces.filter(isExhibitionHallNavigablePlace) + const halls = new Map() + + hallPlaces.forEach((place) => { const matchedSpace = findMatchedHallSpace(place, hallSpaces) - const entrance = createHallEntrance(place, floors, fallbackFloorId) - const entranceHasRouteAnchor = Boolean(entrance.positionGltf && entrance.routeNodeId) - const fallbackPosition = matchedSpace ? normalizeSpaceCenter(matchedSpace) : undefined + if (!matchedSpace) return + + const entrance = createHallEntrance(place, floors, fallbackFloorId, options.fallbackY) + const spacePosition = matchedSpace ? normalizeSpacePosition(matchedSpace, options.fallbackY) : undefined + const spaceCenter = spacePosition?.position + const entrancePosition = entrance.positionGltf const fallbackFloorIdForPoi = stringifyId(matchedSpace?.floorId) || entrance.floorId || fallbackFloorId const fallbackFloorLabel = floorLabelForSgs( fallbackFloorIdForPoi, - floors + floors ) const hallId = stringifyId(matchedSpace?.id) || undefined - const poiId = createHallPoiId(matchedSpace, place, index) + const poiId = createHallPoiId(matchedSpace) const hallName = normalizedText(matchedSpace?.name) || placeHallName(place) || entrance.name - const existing = halls.get(poiId) - - if (existing) { + const existing = halls.get(poiId) + + if (existing) { existing.entrances = [ ...(existing.entrances || []), entrance ] - if (entranceHasRouteAnchor) { - existing.positionGltf = entrance.positionGltf + if (spaceCenter) { + existing.positionGltf = spaceCenter + existing.floorId = fallbackFloorIdForPoi + existing.floorLabel = fallbackFloorLabel + existing.sourceObjectName = matchedSpace?.sourceNodeName || undefined + existing.sourceConfidence = spacePosition.sourceConfidence + return + } + + if (!existing.positionGltf && entrancePosition) { + existing.positionGltf = entrancePosition existing.floorId = entrance.floorId existing.floorLabel = entrance.floorLabel existing.sourceObjectName = entrance.sourceObjectName existing.sourcePlaceId = entrance.sourcePlaceId - existing.sourceConfidence = 'backend-sgs-sdk-hall-entrance' - return - } - - if (!existing.positionGltf && fallbackPosition) { - existing.positionGltf = fallbackPosition - existing.floorId = fallbackFloorIdForPoi - existing.floorLabel = fallbackFloorLabel - existing.sourceObjectName = matchedSpace?.sourceNodeName || undefined - existing.sourceConfidence = 'backend-sgs-sdk-space-center' + existing.sourceConfidence = sgsHallEntranceConfidence } return } - const positionGltf = entranceHasRouteAnchor ? entrance.positionGltf : fallbackPosition + const usesSpaceCenter = Boolean(spaceCenter) + const positionGltf = spaceCenter || entrancePosition if (!positionGltf) return halls.set(poiId, { id: poiId, name: hallName, - floorId: entranceHasRouteAnchor ? entrance.floorId : fallbackFloorIdForPoi, - floorLabel: entranceHasRouteAnchor ? entrance.floorLabel : fallbackFloorLabel, + floorId: usesSpaceCenter ? fallbackFloorIdForPoi : entrance.floorId, + floorLabel: usesSpaceCenter ? fallbackFloorLabel : entrance.floorLabel, primaryCategory: hallCategory, categories: [ hallCategory, - hallEntranceCategory + hallEntranceCategory ], positionGltf, - sourceObjectName: entranceHasRouteAnchor - ? entrance.sourceObjectName - : matchedSpace?.sourceNodeName || undefined, - sourceConfidence: entranceHasRouteAnchor - ? 'backend-sgs-sdk-hall-entrance' - : 'backend-sgs-sdk-space-center', + sourceObjectName: usesSpaceCenter + ? matchedSpace?.sourceNodeName || undefined + : entrance.sourceObjectName, + sourceConfidence: usesSpaceCenter + ? spacePosition?.sourceConfidence || sgsSpaceCenterConfidence + : sgsHallEntranceConfidence, navigationReadiness: '位置预览', accessible: false, kind: 'hall', hallId, hallName, spaceId: hallId, - sourcePlaceId: entranceHasRouteAnchor ? entrance.sourcePlaceId : undefined, + sourcePlaceId: usesSpaceCenter ? undefined : entrance.sourcePlaceId, sourceSpaceId: hallId, entrances: [entrance] }) - }) - - hallSpaces.forEach((space) => { - const hallId = stringifyId(space.id) - if (!hallId || halls.has(`hall-${hallId}`)) return - - const fallbackPoi = createSpaceFallbackHallPoi(space, floors, fallbackFloorId) - if (fallbackPoi) { - halls.set(fallbackPoi.id, fallbackPoi) - } - }) - - return Array.from(halls.values()) - .filter((poi) => poi.id && poi.name && poi.positionGltf) -} - -export const createSgsHallPoiDiagnostics = ( - spaces: SgsSpacePayload[], - navigablePlaces: SgsNavigablePlacePayload[], - hallPois: MuseumPoi[] -): SgsHallPoiDiagnostics => { - const eligibleHallSpaces = spaces.filter(isExhibitionHallSpace) - const eligibleSpaceCount = eligibleHallSpaces.length - const hallPlaceCount = navigablePlaces.filter(isExhibitionHallNavigablePlace).length - const hallPoiIds = new Set(hallPois.map((poi) => poi.id)) - const hallPoiWithPositionCount = hallPois.filter((poi) => Boolean(poi.positionGltf)).length - - return { - spaceCount: spaces.length, - eligibleSpaceCount, - navigablePlaceCount: navigablePlaces.length, - hallPlaceCount, - hallPoiCount: hallPois.length, - hallPoiWithPositionCount, - skippedSpaceCount: eligibleHallSpaces.filter((space) => !hallPoiIds.has(`hall-${stringifyId(space.id)}`)).length, - skippedPlaceCount: Math.max(hallPlaceCount - hallPois.reduce((count, poi) => count + (poi.entrances?.length || 0), 0), 0) - } -} - -export const toGuideFloorDetailFromSgs = ( - floor: SgsSdkFloorSummaryPayload | SgsFloorDiagnosticsPayload -): GuideFloorDetail => ({ - ...toMuseumFloorFromSgs(floor), - sourceCode: floor.floorCode || undefined, - sourceName: floor.floorName || undefined, - modelReady: 'modelReady' in floor ? floor.modelReady === true : undefined, - poiCount: optionalNumber(floor.poiCount), - spaceCount: optionalNumber(floor.spaceCount), - guideStopCount: 'guideStopCount' in floor ? toNumber(floor.guideStopCount, 0) : undefined, - navigablePlaceCount: 'navigablePlaceCount' in floor ? toNumber(floor.navigablePlaceCount, 0) : undefined, - routeNodeCount: 'routeNodeCount' in floor ? toNumber(floor.routeNodeCount, 0) : undefined, - routeEdgeCount: 'routeEdgeCount' in floor ? toNumber(floor.routeEdgeCount, 0) : undefined, - routePlanningReady: 'routePlanningReady' in floor ? floor.routePlanningReady === true : undefined, - warnings: 'warnings' in floor ? floor.warnings || [] : [] -}) - -export const toGuideMapDiagnostics = ( - diagnostics: SgsMapDiagnosticsPayload, - manifest?: SgsSdkManifestPayload -): GuideMapDiagnostics => { - const summary = diagnostics.summary || {} - const floors = diagnostics.floors?.length - ? diagnostics.floors.map(toGuideFloorDetailFromSgs) - : (manifest?.floors || []).map(toGuideFloorDetailFromSgs) - const indoorFloors = floors.filter(isIndoorNavigableFloor) - - return { - mapId: stringifyId(diagnostics.mapId || manifest?.mapId), - mapName: diagnostics.mapName || manifest?.mapName || 'SGS 地图', - sdkVersion: diagnostics.sdkVersion || manifest?.sdkVersion || undefined, - status: normalizeStatus(diagnostics.status), - floors: indoorFloors, - summary: { - floorCount: indoorFloors.length, - modelReadyFloorCount: indoorFloors.filter((floor) => floor.modelReady === true).length || toNumber(summary.modelReadyFloorCount), - poiCount: toNumber(summary.poiCount), - spaceCount: toNumber(summary.spaceCount), - guideStopCount: toNumber(summary.guideStopCount), - navigablePlaceCount: toNumber(summary.navigablePlaceCount), - routeNodeCount: toNumber(summary.routeNodeCount), - routeEdgeCount: toNumber(summary.routeEdgeCount) - }, - warnings: diagnostics.warnings || [] - } -} - -export const toRouteReadinessFromSgsDiagnostics = ( - diagnostics: SgsMapDiagnosticsPayload -): GuideRouteReadiness => { - const warnings = diagnostics.warnings || [] - const floors = (diagnostics.floors || []).filter(isIndoorNavigableFloor) - const notReadyFloors = floors.filter((floor) => ( - floor.modelReady !== true - || floor.routePlanningReady !== true - || toNumber(floor.routeNodeCount) <= 0 - || toNumber(floor.routeEdgeCount) <= 0 - || toNumber(floor.navigablePlaceCount) <= 0 - )) - - const ready = diagnostics.status === 'OK' && notReadyFloors.length === 0 && warnings.length === 0 - - return { - ready, - message: ready - ? 'SGS 后端路线数据已就绪' - : NAV_ROUTE_UNAVAILABLE_MESSAGE, - requiredData: ready - ? [] - : [ - ...warnings, - ...notReadyFloors.map((floor) => `${formatSgsFloorLabel(floor.floorCode, floor.floorName)} 数据未完全就绪`) - ] - } -} - -const addIssue = ( - issues: GuideDataIntegrityIssue[], - issue: Omit -) => { - issues.push({ - id: `issue-${issues.length + 1}`, - ...issue - }) -} - -const missingFieldsForPoi = (poi: MuseumPoi) => { - const fields: string[] = [] - if (!poi.id) fields.push('id') - if (!poi.name) fields.push('name') - if (!poi.floorId) fields.push('floorId') - if (!poi.positionGltf) fields.push('position') - return fields -} - -export const createGuideDataIntegrityReport = ( - diagnostics: GuideMapDiagnostics, - floors: MuseumFloor[], - pois: MuseumPoi[] -): GuideDataIntegrityReport => { - const issues: GuideDataIntegrityIssue[] = [] - const floorIds = new Set(floors.map((floor) => floor.id)) - const poiIds = new Set() - - floors.forEach((floor) => { - if (!floor.id || !floor.label) { - addIssue(issues, { - scope: 'floor', - severity: 'error', - message: '楼层缺少稳定 ID 或显示名称', - floorId: floor.id, - floorLabel: floor.label, - fields: ['id', 'label'].filter((field) => !floor[field as keyof MuseumFloor]) - }) - } - }) - - diagnostics.floors.forEach((floor) => { - if (floor.modelReady === false) { - addIssue(issues, { - scope: 'floor', - severity: 'warn', - message: `${floor.label} 模型未就绪`, - floorId: floor.id, - floorLabel: floor.label, - fields: ['modelReady'] - }) - } - - if (floor.navigablePlaceCount === 0) { - addIssue(issues, { - scope: 'floor', - severity: 'warn', - message: `${floor.label} 缺少可导航目的地`, - floorId: floor.id, - floorLabel: floor.label, - fields: ['navigablePlaceCount'] - }) - } - }) - - pois.forEach((poi) => { - const missingFields = missingFieldsForPoi(poi) - if (missingFields.length) { - addIssue(issues, { - scope: 'poi', - severity: 'error', - message: `${poi.name || poi.id || '未知点位'} 缺少必要字段`, - floorId: poi.floorId, - floorLabel: poi.floorLabel, - poiId: poi.id, - fields: missingFields - }) - } - - if (poi.id && poiIds.has(poi.id)) { - addIssue(issues, { - scope: 'poi', - severity: 'error', - message: `${poi.name || poi.id} 点位 ID 重复`, - floorId: poi.floorId, - floorLabel: poi.floorLabel, - poiId: poi.id, - fields: ['id'] - }) - } - if (poi.id) poiIds.add(poi.id) - - if (poi.floorId && !floorIds.has(poi.floorId)) { - addIssue(issues, { - scope: 'poi', - severity: 'error', - message: `${poi.name || poi.id} 关联的楼层不存在`, - floorId: poi.floorId, - floorLabel: poi.floorLabel, - poiId: poi.id, - fields: ['floorId'] - }) - } - }) - - const errorCount = issues.filter((issue) => issue.severity === 'error').length - const warningCount = issues.filter((issue) => issue.severity === 'warn').length - - return { - status: errorCount > 0 ? 'ERROR' : warningCount > 0 || diagnostics.status === 'WARN' ? 'WARN' : 'OK', - summary: { - floorCount: floors.length, - poiCount: pois.length, - issueCount: issues.length, - errorCount, - warningCount - }, - issues, - warnings: diagnostics.warnings - } -} + }) + + hallSpaces.forEach((space) => { + const hallId = stringifyId(space.id) + if (!hallId || halls.has(`hall-${hallId}`)) return + + const fallbackPoi = createSpaceFallbackHallPoi(space, floors, fallbackFloorId, options.fallbackY) + if (fallbackPoi) { + halls.set(fallbackPoi.id, fallbackPoi) + } + }) + + return Array.from(halls.values()) + .filter((poi) => poi.id && poi.name && poi.positionGltf) +} + +export const createSgsHallPoiDiagnostics = ( + spaces: SgsSpacePayload[], + navigablePlaces: SgsNavigablePlacePayload[], + hallPois: MuseumPoi[] +): SgsHallPoiDiagnostics => { + const eligibleHallSpaces = spaces.filter(isExhibitionHallSpace) + const eligibleSpaceCount = eligibleHallSpaces.length + const hallPlaceCount = navigablePlaces.filter(isExhibitionHallNavigablePlace).length + const hallPoiIds = new Set(hallPois.map((poi) => poi.id)) + const hallPoiWithPositionCount = hallPois.filter((poi) => Boolean(poi.positionGltf)).length + + return { + spaceCount: spaces.length, + eligibleSpaceCount, + navigablePlaceCount: navigablePlaces.length, + hallPlaceCount, + hallPoiCount: hallPois.length, + hallPoiWithPositionCount, + skippedSpaceCount: eligibleHallSpaces.filter((space) => !hallPoiIds.has(`hall-${stringifyId(space.id)}`)).length, + skippedPlaceCount: Math.max(hallPlaceCount - hallPois.reduce((count, poi) => count + (poi.entrances?.length || 0), 0), 0) + } +} + +export const toGuideFloorDetailFromSgs = ( + floor: SgsSdkFloorSummaryPayload | SgsFloorDiagnosticsPayload +): GuideFloorDetail => ({ + ...toMuseumFloorFromSgs(floor), + sourceCode: floor.floorCode || undefined, + sourceName: floor.floorName || undefined, + modelReady: 'modelReady' in floor ? floor.modelReady === true : undefined, + poiCount: optionalNumber(floor.poiCount), + spaceCount: optionalNumber(floor.spaceCount), + guideStopCount: 'guideStopCount' in floor ? toNumber(floor.guideStopCount, 0) : undefined, + navigablePlaceCount: 'navigablePlaceCount' in floor ? toNumber(floor.navigablePlaceCount, 0) : undefined, + routeNodeCount: 'routeNodeCount' in floor ? toNumber(floor.routeNodeCount, 0) : undefined, + routeEdgeCount: 'routeEdgeCount' in floor ? toNumber(floor.routeEdgeCount, 0) : undefined, + routePlanningReady: 'routePlanningReady' in floor ? floor.routePlanningReady === true : undefined, + warnings: 'warnings' in floor ? floor.warnings || [] : [] +}) + +export const toGuideMapDiagnostics = ( + diagnostics: SgsMapDiagnosticsPayload, + manifest?: SgsSdkManifestPayload +): GuideMapDiagnostics => { + const summary = diagnostics.summary || {} + const floors = diagnostics.floors?.length + ? diagnostics.floors.map(toGuideFloorDetailFromSgs) + : (manifest?.floors || []).map(toGuideFloorDetailFromSgs) + const indoorFloors = floors.filter(isIndoorNavigableFloor) + + return { + mapId: stringifyId(diagnostics.mapId || manifest?.mapId), + mapName: diagnostics.mapName || manifest?.mapName || 'SGS 地图', + sdkVersion: diagnostics.sdkVersion || manifest?.sdkVersion || undefined, + status: normalizeStatus(diagnostics.status), + floors: indoorFloors, + summary: { + floorCount: indoorFloors.length, + modelReadyFloorCount: indoorFloors.filter((floor) => floor.modelReady === true).length || toNumber(summary.modelReadyFloorCount), + poiCount: toNumber(summary.poiCount), + spaceCount: toNumber(summary.spaceCount), + guideStopCount: toNumber(summary.guideStopCount), + navigablePlaceCount: toNumber(summary.navigablePlaceCount), + routeNodeCount: toNumber(summary.routeNodeCount), + routeEdgeCount: toNumber(summary.routeEdgeCount) + }, + warnings: diagnostics.warnings || [] + } +} + +export const toRouteReadinessFromSgsDiagnostics = ( + diagnostics: SgsMapDiagnosticsPayload +): GuideRouteReadiness => { + const warnings = diagnostics.warnings || [] + const floors = (diagnostics.floors || []).filter(isIndoorNavigableFloor) + const notReadyFloors = floors.filter((floor) => ( + floor.modelReady !== true + || floor.routePlanningReady !== true + || toNumber(floor.routeNodeCount) <= 0 + || toNumber(floor.routeEdgeCount) <= 0 + || toNumber(floor.navigablePlaceCount) <= 0 + )) + + const ready = diagnostics.status === 'OK' && notReadyFloors.length === 0 && warnings.length === 0 + + return { + ready, + message: ready + ? 'SGS 后端路线数据已就绪' + : NAV_ROUTE_UNAVAILABLE_MESSAGE, + requiredData: ready + ? [] + : [ + ...warnings, + ...notReadyFloors.map((floor) => `${formatSgsFloorLabel(floor.floorCode, floor.floorName)} 数据未完全就绪`) + ] + } +} + +const addIssue = ( + issues: GuideDataIntegrityIssue[], + issue: Omit +) => { + issues.push({ + id: `issue-${issues.length + 1}`, + ...issue + }) +} + +const missingFieldsForPoi = (poi: MuseumPoi) => { + const fields: string[] = [] + if (!poi.id) fields.push('id') + if (!poi.name) fields.push('name') + if (!poi.floorId) fields.push('floorId') + if (!poi.positionGltf) fields.push('position') + return fields +} + +export const createGuideDataIntegrityReport = ( + diagnostics: GuideMapDiagnostics, + floors: MuseumFloor[], + pois: MuseumPoi[] +): GuideDataIntegrityReport => { + const issues: GuideDataIntegrityIssue[] = [] + const floorIds = new Set(floors.map((floor) => floor.id)) + const poiIds = new Set() + + floors.forEach((floor) => { + if (!floor.id || !floor.label) { + addIssue(issues, { + scope: 'floor', + severity: 'error', + message: '楼层缺少稳定 ID 或显示名称', + floorId: floor.id, + floorLabel: floor.label, + fields: ['id', 'label'].filter((field) => !floor[field as keyof MuseumFloor]) + }) + } + }) + + diagnostics.floors.forEach((floor) => { + if (floor.modelReady === false) { + addIssue(issues, { + scope: 'floor', + severity: 'warn', + message: `${floor.label} 模型未就绪`, + floorId: floor.id, + floorLabel: floor.label, + fields: ['modelReady'] + }) + } + + if (floor.navigablePlaceCount === 0) { + addIssue(issues, { + scope: 'floor', + severity: 'warn', + message: `${floor.label} 缺少可导航目的地`, + floorId: floor.id, + floorLabel: floor.label, + fields: ['navigablePlaceCount'] + }) + } + }) + + pois.forEach((poi) => { + const missingFields = missingFieldsForPoi(poi) + if (missingFields.length) { + addIssue(issues, { + scope: 'poi', + severity: 'error', + message: `${poi.name || poi.id || '未知点位'} 缺少必要字段`, + floorId: poi.floorId, + floorLabel: poi.floorLabel, + poiId: poi.id, + fields: missingFields + }) + } + + if (poi.id && poiIds.has(poi.id)) { + addIssue(issues, { + scope: 'poi', + severity: 'error', + message: `${poi.name || poi.id} 点位 ID 重复`, + floorId: poi.floorId, + floorLabel: poi.floorLabel, + poiId: poi.id, + fields: ['id'] + }) + } + if (poi.id) poiIds.add(poi.id) + + if (poi.floorId && !floorIds.has(poi.floorId)) { + addIssue(issues, { + scope: 'poi', + severity: 'error', + message: `${poi.name || poi.id} 关联的楼层不存在`, + floorId: poi.floorId, + floorLabel: poi.floorLabel, + poiId: poi.id, + fields: ['floorId'] + }) + } + }) + + const errorCount = issues.filter((issue) => issue.severity === 'error').length + const warningCount = issues.filter((issue) => issue.severity === 'warn').length + + return { + status: errorCount > 0 ? 'ERROR' : warningCount > 0 || diagnostics.status === 'WARN' ? 'WARN' : 'OK', + summary: { + floorCount: floors.length, + poiCount: pois.length, + issueCount: issues.length, + errorCount, + warningCount + }, + issues, + warnings: diagnostics.warnings + } +} diff --git a/src/pages/index/index.vue b/src/pages/index/index.vue index 0a72875..c06bdae 100644 --- a/src/pages/index/index.vue +++ b/src/pages/index/index.vue @@ -45,7 +45,9 @@ :outdoor-nav-polylines="outdoorNavPolylines" @search-tap="handleGuideSearchTap" @mode-change="handleModeChange" + @floor-request="handleFloorRequest" @floor-change="handleFloorChange" + @floor-switch-failed="handleFloorSwitchFailed" @indoor-view-change="handleIndoorViewChange" @poi-click="handleGuidePoiClick" @selection-clear="handleGuideSelectionClear" @@ -291,7 +293,12 @@ const initialTopTab = (): GuideTopTab => topTabFromHash() || 'guide' const is3DMode = ref(true) const guideOutdoorState = ref<'home' | 'entrance'>('home') const indoorView = ref('overview') -const activeGuideFloor = ref('1F') +const activeGuideFloor = ref('') +const requestedFloorId = ref('') +const requestedFloorLabel = ref('') +const loadingFloorId = ref('') +const renderedFloorId = ref('') +const failedFloorId = ref('') const selectedGuidePoi = ref(null) // 状态 @@ -330,6 +337,12 @@ const indoorNavAssetBaseUrl = guideUseCase.getAssetBaseUrl() const indoorModelSource = guideUseCase.getModelSource() const guideFloors = ref([]) const normalizeGuideFloorId = (labelOrId: string) => guideUseCase.normalizeFloorId(labelOrId) +const getGuideFloorById = (floorId: string) => ( + guideFloors.value.find((floor) => floor.id === floorId) +) +const getGuideFloorLabel = (floorId: string) => ( + getGuideFloorById(floorId)?.label || floorId +) const indoorLayerMode = computed(() => ( indoorView.value === 'multi' ? 'multi' : 'single' )) @@ -527,10 +540,14 @@ const loadGuideFloors = async () => { try { const floors = await guideUseCase.getFloors() guideFloors.value = floors - activeGuideFloor.value = floors.find((floor) => floor.label === activeGuideFloor.value)?.label - || floors.find((floor) => floor.label === '1F')?.label - || floors[0]?.label + const normalizedActiveFloorId = activeGuideFloor.value + ? normalizeGuideFloorId(activeGuideFloor.value) + : '' + activeGuideFloor.value = floors.find((floor) => floor.id === normalizedActiveFloorId)?.id + || floors.find((floor) => floor.label === '1F')?.id + || floors[0]?.id || activeGuideFloor.value + renderedFloorId.value = activeGuideFloor.value } catch (error) { console.error('加载导览楼层失败:', error) guideFloors.value = [] @@ -632,13 +649,36 @@ const handleGuideSearchTap = () => { }) } -const handleFloorChange = (floor: string) => { - activeGuideFloor.value = floor +const handleFloorRequest = ({ floorId, floorLabel }: { floorId: string; floorLabel: string }) => { + requestedFloorId.value = floorId + requestedFloorLabel.value = floorLabel + loadingFloorId.value = floorId + failedFloorId.value = '' +} + +const handleFloorChange = (floorId: string) => { + activeGuideFloor.value = floorId + renderedFloorId.value = floorId + if (loadingFloorId.value === floorId) { + loadingFloorId.value = '' + } + if (failedFloorId.value === floorId) { + failedFloorId.value = '' + } indoorView.value = 'floor' selectedGuidePoi.value = null isPoiCardCollapsed.value = false - showIndoorHint(`已切换到 ${floor},单指旋转、双指可平移`, 3200) - console.log('切换楼层:', floor) + showIndoorHint(`已切换到 ${getGuideFloorLabel(floorId)},单指旋转、双指可平移`, 3200) + console.log('楼层渲染完成:', floorId) +} + +const handleFloorSwitchFailed = ({ floorId, floorLabel }: { floorId: string; floorLabel: string }) => { + if (loadingFloorId.value === floorId) { + loadingFloorId.value = '' + } + failedFloorId.value = floorId + showIndoorHint(`${floorLabel || getGuideFloorLabel(floorId)} 加载失败,请稍后重试`, 3600) + console.warn('楼层渲染失败:', floorId) } const handleIndoorViewChange = (view: GuideIndoorView) => { @@ -682,8 +722,8 @@ const handleGuidePoiClick = (poi: GuideRenderPoi) => { isPoiCardCollapsed.value = !selectedGuidePoiIsHall.value indoorView.value = 'floor' isSimulatingRoute.value = false - const matchedFloor = guideFloors.value.find((floor) => floor.id === poi.floorId) - activeGuideFloor.value = matchedFloor?.label || poi.floorId + activeGuideFloor.value = poi.floorId + renderedFloorId.value = poi.floorId showIndoorHint('已在当前楼层标出点位', 3000) } @@ -887,9 +927,9 @@ const handleRoutePickerSearch = ({ keyword }: { mode: 'start' | 'end'; keyword: } const focusRouteFloor = (route: GuideRouteResult) => { - const floor = guideFloors.value.find((item) => item.id === route.start.floorId) const routeFloorCount = new Set(route.floorSegments.map((segment) => segment.floorId)).size - activeGuideFloor.value = floor?.label || route.start.floorLabel + activeGuideFloor.value = route.start.floorId + renderedFloorId.value = route.start.floorId indoorView.value = routeFloorCount > 1 ? 'multi' : 'floor' } @@ -975,7 +1015,13 @@ const guideStatusLabel = computed(() => { if (is3DMode.value) { if (indoorView.value === 'overview') return '建筑外观' if (indoorView.value === 'multi') return '馆内多层' - return '馆内单层' + if (loadingFloorId.value) { + return `切换${requestedFloorLabel.value || getGuideFloorLabel(requestedFloorId.value || loadingFloorId.value)}` + } + if (failedFloorId.value) { + return `${getGuideFloorLabel(failedFloorId.value)}失败` + } + return getGuideFloorLabel(renderedFloorId.value || activeGuideFloor.value) || '馆内单层' } if (guideOutdoorState.value === 'entrance') { diff --git a/src/repositories/GuideModelRepository.ts b/src/repositories/GuideModelRepository.ts index 5d39cb3..a224b98 100644 --- a/src/repositories/GuideModelRepository.ts +++ b/src/repositories/GuideModelRepository.ts @@ -126,6 +126,43 @@ const warnSgsGuideModelDiagnostics = ( console.warn(`[SGS guide model] ${message}`, payload) } +const summarizeYValues = (pois: GuideRenderPoi[]) => { + const values = pois + .map((poi) => poi.positionGltf?.[1]) + .filter((value): value is number => Number.isFinite(value)) + .sort((left, right) => left - right) + + if (!values.length) { + return { + count: 0, + min: null, + median: null, + max: null + } + } + + return { + count: values.length, + min: values[0], + median: values[Math.floor(values.length / 2)], + max: values[values.length - 1] + } +} + +const getMedianPoiY = (pois: GuideRenderPoi[]) => { + const values = pois + .map((poi) => poi.positionGltf?.[1]) + .filter((value): value is number => Number.isFinite(value)) + .sort((left, right) => left - right) + + if (!values.length) return undefined + + const middle = Math.floor(values.length / 2) + return values.length % 2 + ? values[middle] + : (values[middle - 1] + values[middle]) / 2 +} + const getSgsFloorModelMatchKeys = (floor: SgsSdkFloorSummaryPayload, label: string) => ( [ String(floor.floorId), @@ -285,7 +322,10 @@ export class SgsSdkGuideModelRepository implements GuideModelRepository { loadFloorData('navigablePlaces', () => this.provider.getNavigablePlaces(resolvedFloorId)) ]) const ordinaryPois = pois.map((poi) => toSgsRenderPoi(toMuseumPoiFromSgs(poi, manifest.floors))) - const museumHallPois = toMuseumHallPoisFromSgs(spaces, navigablePlaces, manifest.floors, resolvedFloorId) + const floorPoiMedianY = getMedianPoiY(ordinaryPois) + const museumHallPois = toMuseumHallPoisFromSgs(spaces, navigablePlaces, manifest.floors, resolvedFloorId, { + fallbackY: floorPoiMedianY + }) const hallDiagnostics = createSgsHallPoiDiagnostics(spaces, navigablePlaces, museumHallPois) if (hallDiagnostics.eligibleSpaceCount > 0 && hallDiagnostics.hallPoiWithPositionCount === 0) { @@ -300,8 +340,21 @@ export class SgsSdkGuideModelRepository implements GuideModelRepository { ...hallPois, ...ordinaryPois ]) + .filter((poi) => poi.floorId === resolvedFloorId) .filter((poi) => Array.isArray(poi.positionGltf) && poi.positionGltf.length === 3) + const renderHallPois = renderPois.filter((poi) => poi.kind === 'hall' || poi.primaryCategory === 'exhibition_hall') + warnSgsGuideModelDiagnostics('floor render POI diagnostics', { + floorId: resolvedFloorId, + floorCode: matchedFloor.floorCode, + poiCount: renderPois.length, + hallPoiCount: renderHallPois.length, + hallPoiWithPositionCount: renderHallPois.filter((poi) => Boolean(poi.positionGltf)).length, + poiY: summarizeYValues(renderPois), + hallPoiY: summarizeYValues(renderHallPois), + fallbackHallY: floorPoiMedianY ?? null + }) + if (hallDiagnostics.hallPoiWithPositionCount > 0 && !renderPois.some((poi) => poi.primaryCategory === 'exhibition_hall')) { warnSgsGuideModelDiagnostics('render POI filter removed all hall POIs', { floorId: resolvedFloorId,