Compare commits
2 Commits
215428db90
...
d199b4602f
| Author | SHA1 | Date | |
|---|---|---|---|
| d199b4602f | |||
| 127461dd60 |
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<view class="explain-hall-select" :class="{ 'host-navigation': shouldUseHostNavigation }">
|
||||
<view class="explain-hall-select" :class="{ 'host-navigation': shouldUseHostNavigation && !showInternalHeader }">
|
||||
<view v-if="showInternalHeader" class="explain-page-header">
|
||||
<view class="header-back" @tap="handleBack">
|
||||
<text class="header-back-icon">‹</text>
|
||||
@@ -167,6 +167,7 @@ const props = withDefaults(defineProps<{
|
||||
hasMore?: boolean
|
||||
error?: string
|
||||
loadMoreError?: string
|
||||
forceInternalHeader?: boolean
|
||||
}>(), {
|
||||
halls: () => [],
|
||||
guideStops: () => [],
|
||||
@@ -176,7 +177,8 @@ const props = withDefaults(defineProps<{
|
||||
loadingMore: false,
|
||||
hasMore: false,
|
||||
error: '',
|
||||
loadMoreError: ''
|
||||
loadMoreError: '',
|
||||
forceInternalHeader: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -218,7 +220,7 @@ const hallCardThemeMap: Record<string, { color: string; englishName: string }> =
|
||||
|
||||
const filteredHalls = computed(() => props.halls)
|
||||
const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram())
|
||||
const showInternalHeader = computed(() => !shouldUseHostNavigation.value)
|
||||
const showInternalHeader = computed(() => props.forceInternalHeader || !shouldUseHostNavigation.value)
|
||||
const headerTitle = computed(() => {
|
||||
if (props.stage === 'stop') return props.selectedHallName || '讲解对象'
|
||||
return '展厅讲解'
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<template>
|
||||
<view class="three-map-container">
|
||||
<view ref="containerRef" class="three-canvas-wrapper"></view>
|
||||
<view ref="containerRef" class="three-canvas-wrapper">
|
||||
<view ref="poiDomLabelLayerRef" class="three-poi-dom-label-layer"></view>
|
||||
</view>
|
||||
|
||||
<view v-if="isLoading && !loadError" class="loading-overlay">
|
||||
<view class="loading-content">
|
||||
@@ -37,7 +39,7 @@
|
||||
|
||||
<view v-if="showControls && selectedPOI" class="poi-detail-popup">
|
||||
<view class="poi-content">
|
||||
<text class="poi-name">{{ selectedPOI.name }}</text>
|
||||
<text class="poi-name">{{ presentVisitorPoi({ ...selectedPOI, floorLabel: formatFloorLabel(selectedPOI.floorId), primaryCategory: { label: selectedPOI.primaryCategoryZh } }).displayName }}</text>
|
||||
<text class="poi-floor">{{ formatFloorLabel(selectedPOI.floorId) }} · {{ selectedPOI.primaryCategoryZh }}</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -51,6 +53,7 @@ import { GLTFLoader, type GLTF } from 'three/addons/loaders/GLTFLoader.js'
|
||||
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
|
||||
import FloorSwitcher from './FloorSwitcher.vue'
|
||||
import { presentVisitorPoi } from '@/view-models/visitorPoiPresentation'
|
||||
import type {
|
||||
GuideModelFloorAsset,
|
||||
GuideModelRenderPackage,
|
||||
@@ -76,6 +79,14 @@ import {
|
||||
type GuideAutoSwitchInputSource,
|
||||
type GuideAutoSwitchRequest
|
||||
} from './guideAutoSwitchStateMachine'
|
||||
import {
|
||||
domLabelBoundsOverlap,
|
||||
getDomLabelBounds,
|
||||
isDomLabelWithinViewport,
|
||||
projectObjectToDom,
|
||||
type PoiDomLabelAnchor,
|
||||
type PoiDomLabelKind
|
||||
} from './poiDomLabels'
|
||||
|
||||
type ViewMode = 'overview' | 'floor' | 'multi'
|
||||
type CameraPreset = 'top' | 'oblique'
|
||||
@@ -304,7 +315,7 @@ interface PoiSpriteUserData {
|
||||
hitTargetBaseScale?: number
|
||||
visibleMarker?: THREE.Sprite
|
||||
hitTarget?: THREE.Sprite
|
||||
labelSprite?: THREE.Sprite
|
||||
labelHandle?: PoiDomLabelHandle
|
||||
feedbackUntil?: number
|
||||
labelBaseScaleX?: number
|
||||
labelBaseScaleY?: number
|
||||
@@ -315,6 +326,18 @@ interface PoiSpriteUserData {
|
||||
isCorePoi?: boolean
|
||||
}
|
||||
|
||||
interface PoiDomLabelHandle {
|
||||
element: HTMLDivElement
|
||||
anchor: THREE.Object3D
|
||||
poi: RenderPoi
|
||||
kind: PoiDomLabelKind
|
||||
anchorMode: PoiDomLabelAnchor
|
||||
floorId: string
|
||||
size: { width: number; height: number }
|
||||
active: boolean
|
||||
ownsAnchor?: boolean
|
||||
}
|
||||
|
||||
interface FocusHallMaterialState {
|
||||
material: THREE.Material
|
||||
color?: THREE.Color
|
||||
@@ -361,6 +384,7 @@ interface PoiMarkerCacheEntry {
|
||||
displayMode: PoiDisplayMode
|
||||
pois: RenderPoi[]
|
||||
group: THREE.Group
|
||||
domLabelHandles: PoiDomLabelHandle[]
|
||||
markerSize: number
|
||||
rawPoiCount?: number
|
||||
rawPositionedPoiCount?: number
|
||||
@@ -430,6 +454,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const containerRef = ref<ContainerRef>(null)
|
||||
const poiDomLabelLayerRef = ref<ContainerRef>(null)
|
||||
const isLoading = ref(true)
|
||||
const loadError = ref(false)
|
||||
const loadingProgress = ref(0)
|
||||
@@ -485,7 +510,9 @@ const poiDataCache = new Map<string, RenderPoi[]>()
|
||||
const poiMarkerGroupCache = new Map<string, PoiMarkerCacheEntry>()
|
||||
const preparedFloorModelCache = new Map<string, PreparedFloorModelCacheEntry>()
|
||||
const poiCoordinateDiagnosticsKeys = new Set<string>()
|
||||
let activeFocusLabelSprite: THREE.Sprite | null = null
|
||||
let activeFocusDomLabel: PoiDomLabelHandle | null = null
|
||||
let poiDomLabelResizeObserver: ResizeObserver | null = null
|
||||
const poiDomLabelHandlesByElement = new Map<HTMLElement, PoiDomLabelHandle>()
|
||||
let activeFocusPulseSprite: THREE.Sprite | null = null
|
||||
let activeFocusBaseSprite: THREE.Sprite | null = null
|
||||
let activeFocusHallGlowMesh: THREE.Mesh | null = null
|
||||
@@ -1328,78 +1355,88 @@ const getPoiAmbientLabelPriority = (poi: RenderPoi) => {
|
||||
return getPoiPriority(poi)
|
||||
}
|
||||
|
||||
const getAmbientLabelScaleBoost = (poi: RenderPoi) => {
|
||||
if (!controls || !activeModel) return 1
|
||||
|
||||
const distanceRatio = controls.getDistance() / getActiveModelSpan()
|
||||
const baseBoost = Math.min(1.55, Math.max(0.92, distanceRatio / 0.28))
|
||||
return isHallPoi(poi) ? Math.max(1, baseBoost) : baseBoost
|
||||
}
|
||||
|
||||
const updateAmbientLabelScale = (labelSprite: THREE.Sprite, poi: RenderPoi) => {
|
||||
const userData = getPoiSpriteUserData(labelSprite)
|
||||
const baseScaleX = typeof userData.labelBaseScaleX === 'number'
|
||||
? userData.labelBaseScaleX
|
||||
: labelSprite.scale.x
|
||||
const baseScaleY = typeof userData.labelBaseScaleY === 'number'
|
||||
? userData.labelBaseScaleY
|
||||
: labelSprite.scale.y
|
||||
const boost = getAmbientLabelScaleBoost(poi)
|
||||
|
||||
labelSprite.scale.set(baseScaleX * boost, baseScaleY * boost, 1)
|
||||
}
|
||||
|
||||
const getProjectedScreenPosition = (object: THREE.Object3D) => {
|
||||
if (!camera || !renderer) return null
|
||||
const point = projectObjectToDom(
|
||||
object,
|
||||
camera,
|
||||
renderer.domElement.clientWidth,
|
||||
renderer.domElement.clientHeight
|
||||
)
|
||||
return point ? new THREE.Vector2(point.x, point.y) : null
|
||||
}
|
||||
|
||||
const projected = object.position.clone().project(camera)
|
||||
if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y) || projected.z < -1 || projected.z > 1) {
|
||||
const getPoiDomLabelLayerElement = () => {
|
||||
const layer = poiDomLabelLayerRef.value
|
||||
if (!layer || typeof HTMLElement === 'undefined') return null
|
||||
if (layer instanceof HTMLElement) return layer
|
||||
const element = '$el' in layer ? layer.$el : null
|
||||
return element instanceof HTMLElement ? element : null
|
||||
}
|
||||
|
||||
const updatePoiDomLabelSize = (handle: PoiDomLabelHandle) => {
|
||||
const rect = handle.element.getBoundingClientRect()
|
||||
if (rect.width && rect.height) {
|
||||
handle.size = { width: rect.width, height: rect.height }
|
||||
}
|
||||
}
|
||||
|
||||
const setPoiDomLabelVisible = (handle: PoiDomLabelHandle, visible: boolean) => {
|
||||
handle.element.style.visibility = visible ? 'visible' : 'hidden'
|
||||
handle.element.style.opacity = visible ? '1' : '0'
|
||||
}
|
||||
|
||||
const disposePoiDomLabel = (handle: PoiDomLabelHandle | null) => {
|
||||
if (!handle) return
|
||||
poiDomLabelResizeObserver?.unobserve(handle.element)
|
||||
poiDomLabelHandlesByElement.delete(handle.element)
|
||||
handle.element.remove()
|
||||
if (handle.ownsAnchor) {
|
||||
handle.anchor.parent?.remove(handle.anchor)
|
||||
}
|
||||
}
|
||||
|
||||
const updatePoiDomLabelPosition = (handle: PoiDomLabelHandle) => {
|
||||
if (!camera || !renderer || !handle.active) {
|
||||
setPoiDomLabelVisible(handle, false)
|
||||
return null
|
||||
}
|
||||
|
||||
return new THREE.Vector2(
|
||||
(projected.x + 1) * renderer.domElement.clientWidth * 0.5,
|
||||
(1 - projected.y) * renderer.domElement.clientHeight * 0.5
|
||||
const point = projectObjectToDom(
|
||||
handle.anchor,
|
||||
camera,
|
||||
renderer.domElement.clientWidth,
|
||||
renderer.domElement.clientHeight
|
||||
)
|
||||
}
|
||||
|
||||
const getAmbientLabelBounds = (center: THREE.Vector2, poi: RenderPoi) => {
|
||||
const width = isHallPoi(poi) ? 138 : 112
|
||||
const height = isHallPoi(poi) ? 44 : 34
|
||||
|
||||
return {
|
||||
left: center.x - width * 0.5,
|
||||
right: center.x + width * 0.5,
|
||||
top: center.y - height * 0.5,
|
||||
bottom: center.y + height * 0.5
|
||||
if (!point) {
|
||||
setPoiDomLabelVisible(handle, false)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const labelBoundsOverlap = (
|
||||
left: ReturnType<typeof getAmbientLabelBounds>,
|
||||
right: ReturnType<typeof getAmbientLabelBounds>,
|
||||
spacing: number
|
||||
) => (
|
||||
left.left - spacing < right.right
|
||||
&& left.right + spacing > right.left
|
||||
&& left.top - spacing < right.bottom
|
||||
&& left.bottom + spacing > right.top
|
||||
)
|
||||
const bounds = getDomLabelBounds(point, handle.size, handle.anchorMode)
|
||||
if (!isDomLabelWithinViewport(bounds, renderer.domElement.clientWidth, renderer.domElement.clientHeight)) {
|
||||
setPoiDomLabelVisible(handle, false)
|
||||
return null
|
||||
}
|
||||
|
||||
handle.element.style.transform = `translate3d(${point.x}px, ${point.y}px, 0) translate(-50%, -100%)`
|
||||
return bounds
|
||||
}
|
||||
|
||||
const updateAmbientPoiLabels = () => {
|
||||
if (!poiGroup || !camera || !renderer) return
|
||||
|
||||
const densityTier = getPoiLabelDensityTier()
|
||||
const acceptedBounds: Array<ReturnType<typeof getAmbientLabelBounds>> = []
|
||||
const acceptedBounds: ReturnType<typeof getDomLabelBounds>[] = []
|
||||
const candidates = getPoiSprites()
|
||||
.map((sprite) => {
|
||||
const poi = sprite.userData.poi as RenderPoi | undefined
|
||||
const labelSprite = getPoiSpriteUserData(sprite).labelSprite
|
||||
const labelHandle = getPoiSpriteUserData(sprite).labelHandle
|
||||
|
||||
return poi && labelSprite
|
||||
return poi && labelHandle
|
||||
? {
|
||||
marker: sprite,
|
||||
labelSprite,
|
||||
labelHandle,
|
||||
poi,
|
||||
priority: getPoiAmbientLabelPriority(poi)
|
||||
}
|
||||
@@ -1407,32 +1444,26 @@ const updateAmbientPoiLabels = () => {
|
||||
})
|
||||
.filter((candidate): candidate is {
|
||||
marker: THREE.Sprite
|
||||
labelSprite: THREE.Sprite
|
||||
labelHandle: PoiDomLabelHandle
|
||||
poi: RenderPoi
|
||||
priority: number
|
||||
} => Boolean(candidate))
|
||||
.sort((left, right) => right.priority - left.priority)
|
||||
|
||||
candidates.forEach(({ marker, labelSprite, poi }) => {
|
||||
const displayPosition = getPoiDisplayPosition(poi)
|
||||
if (!displayPosition || !shouldShowAmbientPoiLabel(poi, marker.visible, densityTier)) {
|
||||
labelSprite.visible = false
|
||||
candidates.forEach(({ marker, labelHandle, poi }) => {
|
||||
if (!shouldShowAmbientPoiLabel(poi, marker.visible, densityTier)) {
|
||||
setPoiDomLabelVisible(labelHandle, false)
|
||||
return
|
||||
}
|
||||
|
||||
updateAmbientLabelScale(labelSprite, poi)
|
||||
|
||||
const spacing = isHallPoi(poi) ? poiAmbientLabelHallSpacingPx : poiAmbientLabelServiceSpacingPx
|
||||
labelSprite.position.copy(displayPosition)
|
||||
const screenPosition = getProjectedScreenPosition(labelSprite)
|
||||
if (!screenPosition) {
|
||||
labelSprite.visible = false
|
||||
const bounds = updatePoiDomLabelPosition(labelHandle)
|
||||
if (!bounds) {
|
||||
return
|
||||
}
|
||||
|
||||
const bounds = getAmbientLabelBounds(screenPosition, poi)
|
||||
const overlaps = acceptedBounds.some((acceptedBound) => labelBoundsOverlap(bounds, acceptedBound, spacing))
|
||||
labelSprite.visible = !overlaps
|
||||
const overlaps = acceptedBounds.some((acceptedBound) => domLabelBoundsOverlap(bounds, acceptedBound, spacing))
|
||||
setPoiDomLabelVisible(labelHandle, !overlaps)
|
||||
if (!overlaps) {
|
||||
acceptedBounds.push(bounds)
|
||||
}
|
||||
@@ -1468,12 +1499,12 @@ const updatePoiVisibilityByDistance = () => {
|
||||
if (!poi || !isPoiIncludedByVisibleFilter(poi)) {
|
||||
sprite.visible = false
|
||||
const hitTarget = getPoiSpriteUserData(sprite).hitTarget
|
||||
const labelSprite = getPoiSpriteUserData(sprite).labelSprite
|
||||
const labelHandle = getPoiSpriteUserData(sprite).labelHandle
|
||||
if (hitTarget) {
|
||||
hitTarget.visible = false
|
||||
}
|
||||
if (labelSprite) {
|
||||
labelSprite.visible = false
|
||||
if (labelHandle) {
|
||||
setPoiDomLabelVisible(labelHandle, false)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1499,12 +1530,12 @@ const updatePoiVisibilityByDistance = () => {
|
||||
if (!categoryVisible || (!isSelected && !isPinnedHall && (isTooClose || exceedsLimit))) {
|
||||
sprite.visible = false
|
||||
const hitTarget = getPoiSpriteUserData(sprite).hitTarget
|
||||
const labelSprite = getPoiSpriteUserData(sprite).labelSprite
|
||||
const labelHandle = getPoiSpriteUserData(sprite).labelHandle
|
||||
if (hitTarget) {
|
||||
hitTarget.visible = false
|
||||
}
|
||||
if (labelSprite) {
|
||||
labelSprite.visible = false
|
||||
if (labelHandle) {
|
||||
setPoiDomLabelVisible(labelHandle, false)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1956,6 +1987,12 @@ const initThree = async () => {
|
||||
fillLight.position.copy(SGS_VISUAL_RENDER_CONFIG.lights.fill.position)
|
||||
scene.add(fillLight)
|
||||
|
||||
poiDomLabelResizeObserver = new ResizeObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
const handle = poiDomLabelHandlesByElement.get(entry.target as HTMLElement)
|
||||
if (handle) updatePoiDomLabelSize(handle)
|
||||
})
|
||||
})
|
||||
resizeObserver = new ResizeObserver(handleResize)
|
||||
resizeObserver.observe(container)
|
||||
container.addEventListener('pointerdown', handlePointerDown, true)
|
||||
@@ -1988,6 +2025,7 @@ const startRenderLoop = () => {
|
||||
controls?.update()
|
||||
}
|
||||
updatePoiTapFeedback(performance.now())
|
||||
updateAmbientPoiLabels()
|
||||
updateFocusLabelScale()
|
||||
renderer.render(scene, camera)
|
||||
animationId = window.requestAnimationFrame(render)
|
||||
@@ -2011,6 +2049,7 @@ const handleResize = () => {
|
||||
camera.aspect = nextAspect
|
||||
camera.updateProjectionMatrix()
|
||||
renderer.setSize(width, height)
|
||||
updateAmbientPoiLabels()
|
||||
}
|
||||
|
||||
const materialTextureKeys = [
|
||||
@@ -2265,6 +2304,10 @@ const findNearestPoiMarkerByScreenPoint = (event: PointerEvent, rect: DOMRect) =
|
||||
const detachPoiMarkerGroups = () => {
|
||||
poiMarkerGroupCache.forEach((entry) => {
|
||||
entry.group.parent?.remove(entry.group)
|
||||
entry.domLabelHandles.forEach((handle) => {
|
||||
handle.active = false
|
||||
setPoiDomLabelVisible(handle, false)
|
||||
})
|
||||
})
|
||||
|
||||
if (poiGroup) {
|
||||
@@ -2285,6 +2328,7 @@ const detachActivePoiLayer = () => {
|
||||
const disposePoiMarkerCache = () => {
|
||||
detachPoiMarkerGroups()
|
||||
poiMarkerGroupCache.forEach((entry) => {
|
||||
entry.domLabelHandles.forEach((handle) => disposePoiDomLabel(handle))
|
||||
disposeObject(entry.group)
|
||||
})
|
||||
poiMarkerGroupCache.clear()
|
||||
@@ -3141,7 +3185,7 @@ const resolveFloorIdFromRequest = (requestedFloorId?: string | null) => {
|
||||
|
||||
const isGroundEntryFloor = (floor: FloorIndexItem) => (
|
||||
getFloorSortLevel(floor) === 1
|
||||
|| getFloorMatchKeys(floor).some((key) => {
|
||||
|| getFloorMatchKeys(floor).some((key) => {
|
||||
const normalizedKey = normalizeModelMatchKey(key)
|
||||
return normalizedKey === 'l1' || normalizedKey === '1f'
|
||||
})
|
||||
@@ -4504,148 +4548,57 @@ const createPoiHitTargetMaterial = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const createPoiLabelSprite = (poi: RenderPoi, markerSize: number) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 560
|
||||
canvas.height = 168
|
||||
const context = canvas.getContext('2d')
|
||||
const label = poi.name.length > 14 ? `${poi.name.slice(0, 14)}…` : poi.name
|
||||
const createPoiDomLabel = (
|
||||
poi: RenderPoi,
|
||||
anchor: THREE.Object3D,
|
||||
kind: PoiDomLabelKind
|
||||
) => {
|
||||
const layer = getPoiDomLabelLayerElement()
|
||||
if (!layer) return null
|
||||
|
||||
if (context) {
|
||||
const color = getPoiColor(poi.primaryCategory)
|
||||
context.clearRect(0, 0, canvas.width, canvas.height)
|
||||
context.shadowColor = 'rgba(31, 35, 41, 0.2)'
|
||||
context.shadowBlur = 18
|
||||
context.shadowOffsetY = 8
|
||||
context.fillStyle = 'rgba(255, 255, 255, 0.96)'
|
||||
context.beginPath()
|
||||
context.roundRect(40, 24, 480, 92, 28)
|
||||
context.fill()
|
||||
context.shadowColor = 'transparent'
|
||||
context.lineWidth = 3
|
||||
context.strokeStyle = 'rgba(31, 35, 41, 0.08)'
|
||||
context.stroke()
|
||||
const element = document.createElement('div')
|
||||
element.className = `three-poi-dom-label three-poi-dom-label--${kind} ${isHallPoi(poi) ? 'three-poi-dom-label--hall' : 'three-poi-dom-label--service'}`
|
||||
element.dataset.poiLabelKind = kind
|
||||
element.dataset.poiId = String(poi.id)
|
||||
element.dataset.floorId = String(poi.floorId)
|
||||
element.setAttribute('aria-hidden', 'true')
|
||||
|
||||
context.beginPath()
|
||||
context.moveTo(258, 116)
|
||||
context.lineTo(302, 116)
|
||||
context.lineTo(280, 144)
|
||||
context.closePath()
|
||||
context.fill()
|
||||
const title = document.createElement('span')
|
||||
title.className = 'three-poi-dom-label__title'
|
||||
const limit = kind === 'focus' ? 14 : (isHallPoi(poi) ? 10 : 7)
|
||||
const presentation = presentVisitorPoi({
|
||||
...poi,
|
||||
floorLabel: formatFloorLabel(poi.floorId),
|
||||
primaryCategory: { label: poi.primaryCategoryZh }
|
||||
})
|
||||
title.textContent = presentation.displayName.length > limit
|
||||
? `${presentation.displayName.slice(0, limit)}…`
|
||||
: presentation.displayName
|
||||
element.appendChild(title)
|
||||
|
||||
context.beginPath()
|
||||
context.arc(86, 70, 20, 0, Math.PI * 2)
|
||||
context.fillStyle = color
|
||||
context.fill()
|
||||
|
||||
context.font = `700 24px ${CANVAS_FONT_FAMILY}`
|
||||
context.textAlign = 'left'
|
||||
context.textBaseline = 'middle'
|
||||
context.fillStyle = '#ffffff'
|
||||
context.fillText(getPoiGlyph(poi), 74, 70, 28)
|
||||
|
||||
context.font = `700 34px ${CANVAS_FONT_FAMILY}`
|
||||
context.fillStyle = '#1f2329'
|
||||
context.fillText(label, 120, 61, 360)
|
||||
|
||||
context.font = `500 22px ${CANVAS_FONT_FAMILY}`
|
||||
context.fillStyle = '#6b7178'
|
||||
context.fillText(`${formatFloorLabel(poi.floorId)} · ${poi.primaryCategoryZh}`, 120, 94, 360)
|
||||
if (kind === 'focus') {
|
||||
const meta = document.createElement('span')
|
||||
meta.className = 'three-poi-dom-label__meta'
|
||||
meta.textContent = `${formatFloorLabel(poi.floorId)} · ${poi.primaryCategoryZh}`
|
||||
element.appendChild(meta)
|
||||
}
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas)
|
||||
texture.colorSpace = THREE.SRGBColorSpace
|
||||
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
depthTest: false,
|
||||
depthWrite: false
|
||||
}))
|
||||
sprite.userData.isPoiLabel = true
|
||||
sprite.userData.labelBaseScaleX = markerSize * 5.6
|
||||
sprite.userData.labelBaseScaleY = markerSize * 1.68
|
||||
sprite.renderOrder = 30
|
||||
sprite.frustumCulled = false
|
||||
sprite.scale.set(sprite.userData.labelBaseScaleX, sprite.userData.labelBaseScaleY, 1)
|
||||
|
||||
return sprite
|
||||
}
|
||||
|
||||
const createAmbientPoiLabelSprite = (poi: RenderPoi, markerSize: number) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 360
|
||||
canvas.height = 112
|
||||
const context = canvas.getContext('2d')
|
||||
const labelLimit = isHallPoi(poi) ? 10 : 7
|
||||
const label = poi.name.length > labelLimit ? `${poi.name.slice(0, labelLimit)}…` : poi.name
|
||||
|
||||
if (context) {
|
||||
const color = getPoiColor(poi.primaryCategory)
|
||||
const hallLabel = isHallPoi(poi)
|
||||
const boxX = hallLabel ? 28 : 42
|
||||
const boxY = hallLabel ? 18 : 26
|
||||
const boxWidth = hallLabel ? 304 : 276
|
||||
const boxHeight = hallLabel ? 60 : 48
|
||||
const radius = hallLabel ? 22 : 18
|
||||
|
||||
context.clearRect(0, 0, canvas.width, canvas.height)
|
||||
context.shadowColor = 'rgba(31, 35, 41, 0.18)'
|
||||
context.shadowBlur = hallLabel ? 14 : 10
|
||||
context.shadowOffsetY = hallLabel ? 7 : 5
|
||||
context.fillStyle = hallLabel ? 'rgba(255, 255, 255, 0.98)' : 'rgba(255, 255, 255, 0.92)'
|
||||
context.beginPath()
|
||||
context.roundRect(boxX, boxY, boxWidth, boxHeight, radius)
|
||||
context.fill()
|
||||
context.shadowColor = 'transparent'
|
||||
context.lineWidth = hallLabel ? 3 : 2
|
||||
context.strokeStyle = hallLabel ? `${color}66` : 'rgba(31, 35, 41, 0.1)'
|
||||
context.stroke()
|
||||
|
||||
context.beginPath()
|
||||
context.moveTo(166, boxY + boxHeight)
|
||||
context.lineTo(194, boxY + boxHeight)
|
||||
context.lineTo(180, boxY + boxHeight + 18)
|
||||
context.closePath()
|
||||
context.fillStyle = hallLabel ? 'rgba(255, 255, 255, 0.98)' : 'rgba(255, 255, 255, 0.92)'
|
||||
context.fill()
|
||||
context.strokeStyle = hallLabel ? `${color}50` : 'rgba(31, 35, 41, 0.08)'
|
||||
context.stroke()
|
||||
|
||||
context.beginPath()
|
||||
context.arc(boxX + 30, boxY + boxHeight * 0.5, hallLabel ? 15 : 12, 0, Math.PI * 2)
|
||||
context.fillStyle = color
|
||||
context.fill()
|
||||
|
||||
context.font = `700 ${hallLabel ? 18 : 15}px ${CANVAS_FONT_FAMILY}`
|
||||
context.textAlign = 'center'
|
||||
context.textBaseline = 'middle'
|
||||
context.fillStyle = '#ffffff'
|
||||
context.fillText(getPoiGlyph(poi), boxX + 30, boxY + boxHeight * 0.5, 24)
|
||||
|
||||
context.font = `700 ${hallLabel ? 24 : 20}px ${CANVAS_FONT_FAMILY}`
|
||||
context.textAlign = 'left'
|
||||
context.fillStyle = '#1f2329'
|
||||
context.fillText(label, boxX + 56, boxY + boxHeight * 0.5, boxWidth - 76)
|
||||
layer.appendChild(element)
|
||||
const handle: PoiDomLabelHandle = {
|
||||
element,
|
||||
anchor,
|
||||
poi,
|
||||
kind,
|
||||
anchorMode: 'bottom',
|
||||
floorId: poi.floorId,
|
||||
size: { width: 1, height: 1 },
|
||||
active: kind === 'focus'
|
||||
}
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas)
|
||||
texture.colorSpace = THREE.SRGBColorSpace
|
||||
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
depthTest: false,
|
||||
depthWrite: false
|
||||
}))
|
||||
sprite.userData.isPoiLabel = true
|
||||
sprite.userData.poi = poi
|
||||
sprite.userData.labelBaseScaleX = markerSize * (isHallPoi(poi) ? 3.9 : 3.05)
|
||||
sprite.userData.labelBaseScaleY = markerSize * (isHallPoi(poi) ? 1.22 : 0.95)
|
||||
sprite.renderOrder = isHallPoi(poi) ? 18 : 15
|
||||
sprite.frustumCulled = false
|
||||
sprite.scale.set(sprite.userData.labelBaseScaleX, sprite.userData.labelBaseScaleY, 1)
|
||||
sprite.visible = false
|
||||
|
||||
return sprite
|
||||
updatePoiDomLabelSize(handle)
|
||||
poiDomLabelHandlesByElement.set(element, handle)
|
||||
poiDomLabelResizeObserver?.observe(element)
|
||||
setPoiDomLabelVisible(handle, false)
|
||||
return handle
|
||||
}
|
||||
|
||||
const createPoiPulseSprite = (poi: RenderPoi, markerSize: number) => {
|
||||
@@ -4723,11 +4676,8 @@ const createPoiBaseSprite = (poi: RenderPoi, markerSize: number) => {
|
||||
}
|
||||
|
||||
const disposeFocusLabel = () => {
|
||||
if (!activeFocusLabelSprite) return
|
||||
|
||||
activeFocusLabelSprite.parent?.remove(activeFocusLabelSprite)
|
||||
disposeObject(activeFocusLabelSprite)
|
||||
activeFocusLabelSprite = null
|
||||
disposePoiDomLabel(activeFocusDomLabel)
|
||||
activeFocusDomLabel = null
|
||||
}
|
||||
|
||||
const disposeFocusPulse = () => {
|
||||
@@ -4751,13 +4701,14 @@ const showFocusPoiLabel = (poi: RenderPoi) => {
|
||||
if (!poiGroup || !displayPosition) return
|
||||
|
||||
disposeFocusLabel()
|
||||
|
||||
const markerSize = getPoiMarkerSize()
|
||||
activeFocusLabelSprite = createPoiLabelSprite(poi, markerSize)
|
||||
activeFocusLabelSprite.frustumCulled = false
|
||||
activeFocusLabelSprite.position.copy(displayPosition)
|
||||
updateFocusLabelScale()
|
||||
poiGroup.add(activeFocusLabelSprite)
|
||||
const anchor = new THREE.Object3D()
|
||||
anchor.position.copy(displayPosition)
|
||||
poiGroup.add(anchor)
|
||||
activeFocusDomLabel = createPoiDomLabel(poi, anchor, 'focus')
|
||||
if (activeFocusDomLabel) {
|
||||
activeFocusDomLabel.ownsAnchor = true
|
||||
activeFocusDomLabel.element.dataset.focusLabel = 'active'
|
||||
}
|
||||
}
|
||||
|
||||
const showFocusPoiBase = (poi: RenderPoi) => {
|
||||
@@ -4815,26 +4766,10 @@ const getPoiMarkerSize = () => (
|
||||
: 3
|
||||
)
|
||||
|
||||
const getFocusLabelScaleBoost = () => {
|
||||
if (!controls || !activeModel) return 1
|
||||
|
||||
const distanceRatio = controls.getDistance() / getActiveModelSpan()
|
||||
return Math.min(2.25, Math.max(1, distanceRatio / 0.36))
|
||||
}
|
||||
|
||||
const updateFocusLabelScale = () => {
|
||||
if (!activeFocusLabelSprite) return
|
||||
|
||||
const userData = getPoiSpriteUserData(activeFocusLabelSprite)
|
||||
const baseScaleX = typeof userData.labelBaseScaleX === 'number'
|
||||
? userData.labelBaseScaleX
|
||||
: activeFocusLabelSprite.scale.x
|
||||
const baseScaleY = typeof userData.labelBaseScaleY === 'number'
|
||||
? userData.labelBaseScaleY
|
||||
: activeFocusLabelSprite.scale.y
|
||||
const scaleBoost = getFocusLabelScaleBoost()
|
||||
|
||||
activeFocusLabelSprite.scale.set(baseScaleX * scaleBoost, baseScaleY * scaleBoost, 1)
|
||||
if (!activeFocusDomLabel) return
|
||||
const bounds = updatePoiDomLabelPosition(activeFocusDomLabel)
|
||||
setPoiDomLabelVisible(activeFocusDomLabel, Boolean(bounds))
|
||||
}
|
||||
|
||||
const setPoiSpriteFocusStyle = (sprite: THREE.Sprite, focused: boolean) => {
|
||||
@@ -4878,9 +4813,6 @@ const findLoadedPoi = (poiId: string) => (
|
||||
const createPoiMarkerSprites = (poi: RenderPoi, markerSize: number) => {
|
||||
const sprite = new THREE.Sprite(createPoiMaterial(poi))
|
||||
const hitTarget = new THREE.Sprite(createPoiHitTargetMaterial())
|
||||
const labelSprite = shouldCreateAmbientPoiLabel(poi)
|
||||
? createAmbientPoiLabelSprite(poi, markerSize)
|
||||
: null
|
||||
const isCorePoi = corePoiCategories.has(poi.primaryCategory) || poi.primaryCategory === 'target_preview'
|
||||
const hitTargetScale = markerSize * (isCorePoi ? poiHitTargetCoreScaleMultiplier : poiHitTargetScaleMultiplier)
|
||||
|
||||
@@ -4889,9 +4821,6 @@ const createPoiMarkerSprites = (poi: RenderPoi, markerSize: number) => {
|
||||
sprite.userData.isCorePoi = isCorePoi
|
||||
sprite.userData.hitTarget = hitTarget
|
||||
sprite.frustumCulled = false
|
||||
if (labelSprite) {
|
||||
sprite.userData.labelSprite = labelSprite
|
||||
}
|
||||
sprite.userData.hitTargetBaseScale = hitTargetScale
|
||||
|
||||
hitTarget.userData.baseScale = hitTargetScale
|
||||
@@ -4904,7 +4833,7 @@ const createPoiMarkerSprites = (poi: RenderPoi, markerSize: number) => {
|
||||
|
||||
setPoiSpriteFocusStyle(sprite, poi.id === activeFocusPoiId.value)
|
||||
|
||||
return { sprite, hitTarget, labelSprite }
|
||||
return { sprite, hitTarget }
|
||||
}
|
||||
|
||||
const createPoiFromFocusRequest = (request: TargetPoiFocusRequest): RenderPoi | null => {
|
||||
@@ -4946,6 +4875,7 @@ const createPoiMarkerGroup = (
|
||||
markerSize: number
|
||||
) => {
|
||||
const group = new THREE.Group()
|
||||
const domLabelHandles: PoiDomLabelHandle[] = []
|
||||
group.name = `GuideModelPOI_${floorId}_${displayMode}`
|
||||
group.userData.floorId = floorId
|
||||
group.userData.currentFloor = floorId
|
||||
@@ -4955,12 +4885,15 @@ const createPoiMarkerGroup = (
|
||||
const displayPosition = getPoiDisplayPosition(poi)
|
||||
if (!displayPosition) return
|
||||
|
||||
const { sprite, hitTarget, labelSprite } = createPoiMarkerSprites(poi, markerSize)
|
||||
const { sprite, hitTarget } = createPoiMarkerSprites(poi, markerSize)
|
||||
sprite.position.copy(displayPosition)
|
||||
hitTarget.position.copy(displayPosition)
|
||||
if (labelSprite) {
|
||||
labelSprite.position.copy(displayPosition)
|
||||
labelSprite.visible = false
|
||||
const labelHandle = shouldCreateAmbientPoiLabel(poi)
|
||||
? createPoiDomLabel(poi, sprite, 'ambient')
|
||||
: null
|
||||
if (labelHandle) {
|
||||
sprite.userData.labelHandle = labelHandle
|
||||
domLabelHandles.push(labelHandle)
|
||||
}
|
||||
sprite.visible = isPoiIncludedByVisibleFilter(poi)
|
||||
&& (
|
||||
@@ -4970,12 +4903,9 @@ const createPoiMarkerGroup = (
|
||||
hitTarget.visible = sprite.visible
|
||||
group.add(sprite)
|
||||
group.add(hitTarget)
|
||||
if (labelSprite) {
|
||||
group.add(labelSprite)
|
||||
}
|
||||
})
|
||||
|
||||
return group
|
||||
return { group, domLabelHandles }
|
||||
}
|
||||
|
||||
const attachPoiMarkerGroup = (entry: PoiMarkerCacheEntry) => {
|
||||
@@ -4985,6 +4915,9 @@ const attachPoiMarkerGroup = (entry: PoiMarkerCacheEntry) => {
|
||||
if (entry.group.parent !== poiGroup) {
|
||||
poiGroup.add(entry.group)
|
||||
}
|
||||
entry.domLabelHandles.forEach((handle) => {
|
||||
handle.active = true
|
||||
})
|
||||
poiGroup.userData.floorId = entry.floorId
|
||||
poiGroup.userData.currentFloor = entry.floorId
|
||||
updatePoiMarkerFocus()
|
||||
@@ -5079,7 +5012,8 @@ const prepareFloorPOIs = async (
|
||||
floorId: floor.floorId,
|
||||
displayMode,
|
||||
pois: validPois,
|
||||
group: markerGroup,
|
||||
group: markerGroup.group,
|
||||
domLabelHandles: markerGroup.domLabelHandles,
|
||||
markerSize,
|
||||
rawPoiCount: floorPois.length,
|
||||
rawPositionedPoiCount: floorPois.filter((poi) => Boolean(poi.positionGltf)).length,
|
||||
@@ -5087,7 +5021,8 @@ const prepareFloorPOIs = async (
|
||||
}
|
||||
|
||||
if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) {
|
||||
disposeObject(markerGroup)
|
||||
markerGroup.domLabelHandles.forEach((handle) => disposePoiDomLabel(handle))
|
||||
disposeObject(markerGroup.group)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -5771,6 +5706,9 @@ const disposeScene = () => {
|
||||
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
poiDomLabelResizeObserver?.disconnect()
|
||||
poiDomLabelResizeObserver = null
|
||||
poiDomLabelHandlesByElement.clear()
|
||||
|
||||
// 清理自动切换相关资源
|
||||
if (controls) {
|
||||
@@ -5920,6 +5858,97 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.three-poi-dom-label-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 4;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:deep(.three-poi-dom-label) {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
max-width: min(190px, calc(100% - 20px));
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
color: #1f2329;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border: 1px solid rgba(31, 35, 41, 0.12);
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 4px 12px rgba(31, 35, 41, 0.2);
|
||||
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.86);
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
:deep(.three-poi-dom-label::after) {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: -5px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
box-sizing: border-box;
|
||||
content: '';
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border-right: 1px solid rgba(31, 35, 41, 0.12);
|
||||
border-bottom: 1px solid rgba(31, 35, 41, 0.12);
|
||||
transform: translateX(-50%) rotate(45deg);
|
||||
}
|
||||
|
||||
:deep(.three-poi-dom-label--ambient) {
|
||||
min-height: 30px;
|
||||
padding: 5px 9px;
|
||||
}
|
||||
|
||||
:deep(.three-poi-dom-label--ambient.three-poi-dom-label--hall) {
|
||||
min-height: 34px;
|
||||
padding: 6px 10px;
|
||||
border-color: rgba(47, 111, 237, 0.42);
|
||||
}
|
||||
|
||||
:deep(.three-poi-dom-label--ambient.three-poi-dom-label--service) {
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
:deep(.three-poi-dom-label--ambient.three-poi-dom-label--hall) {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
:deep(.three-poi-dom-label--focus) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
min-width: 156px;
|
||||
padding: 8px 11px;
|
||||
border-color: rgba(47, 111, 237, 0.5);
|
||||
box-shadow: 0 6px 18px rgba(31, 35, 41, 0.24);
|
||||
}
|
||||
|
||||
:deep(.three-poi-dom-label__title) {
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
:deep(.three-poi-dom-label--focus .three-poi-dom-label__title) {
|
||||
font-size: 16px;
|
||||
line-height: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
:deep(.three-poi-dom-label__meta) {
|
||||
margin-top: 1px;
|
||||
color: #626b75;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
59
src/components/map/poiDomLabels.ts
Normal file
59
src/components/map/poiDomLabels.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
export type PoiDomLabelKind = 'ambient' | 'focus'
|
||||
export type PoiDomLabelAnchor = 'bottom' | 'center'
|
||||
|
||||
export interface PoiDomLabelBounds {
|
||||
left: number
|
||||
right: number
|
||||
top: number
|
||||
bottom: number
|
||||
}
|
||||
|
||||
export const projectObjectToDom = (
|
||||
object: THREE.Object3D,
|
||||
camera: THREE.Camera,
|
||||
width: number,
|
||||
height: number
|
||||
) => {
|
||||
const projected = object.getWorldPosition(new THREE.Vector3()).project(camera)
|
||||
if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y) || projected.z < -1 || projected.z > 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
x: (projected.x + 1) * width * 0.5,
|
||||
y: (1 - projected.y) * height * 0.5
|
||||
}
|
||||
}
|
||||
|
||||
export const getDomLabelBounds = (
|
||||
point: { x: number; y: number },
|
||||
size: { width: number; height: number },
|
||||
anchor: PoiDomLabelAnchor
|
||||
): PoiDomLabelBounds => {
|
||||
const top = anchor === 'bottom' ? point.y - size.height : point.y - size.height * 0.5
|
||||
return {
|
||||
left: point.x - size.width * 0.5,
|
||||
right: point.x + size.width * 0.5,
|
||||
top,
|
||||
bottom: top + size.height
|
||||
}
|
||||
}
|
||||
|
||||
export const domLabelBoundsOverlap = (
|
||||
left: PoiDomLabelBounds,
|
||||
right: PoiDomLabelBounds,
|
||||
spacing: number
|
||||
) => (
|
||||
left.left - spacing < right.right
|
||||
&& left.right + spacing > right.left
|
||||
&& left.top - spacing < right.bottom
|
||||
&& left.bottom + spacing > right.top
|
||||
)
|
||||
|
||||
export const isDomLabelWithinViewport = (
|
||||
bounds: PoiDomLabelBounds,
|
||||
width: number,
|
||||
height: number
|
||||
) => bounds.left >= 0 && bounds.right <= width && bounds.top >= 0 && bounds.bottom <= height
|
||||
@@ -102,8 +102,10 @@ import {
|
||||
type ArrivalSearchTarget,
|
||||
type ArrivalTargetType
|
||||
} from '@/data/arrivalSearchTargets'
|
||||
// #ifndef MP-WEIXIN
|
||||
import { navigateToMuseum } from '@/services/MapNavigationService'
|
||||
// #ifdef H5
|
||||
import { openExternalNavigation } from '@/services/MapNavigationService'
|
||||
import { openWechatMiniProgramLocation } from '@/services/WechatMiniProgramBridgeService'
|
||||
import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
|
||||
// #endif
|
||||
// #ifdef MP-WEIXIN
|
||||
import { openWechatHostLocation } from '@/services/WechatOpenLocationService'
|
||||
@@ -142,6 +144,12 @@ const isOpeningNavigation = ref(false)
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
const primaryActionText = computed(() => {
|
||||
// #ifdef H5
|
||||
if (isEmbeddedInWechatMiniProgram()) {
|
||||
return isOpeningNavigation.value ? '正在打开...' : '打开地图导航'
|
||||
}
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
if (navigationMode === 'native-location') {
|
||||
return isOpeningNavigation.value ? '正在打开...' : '打开地图导航'
|
||||
@@ -274,11 +282,25 @@ const handleNavigateTap = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// #ifndef MP-WEIXIN
|
||||
// #ifdef H5
|
||||
if (__APP_BUILD_PLATFORM__ !== 'mp-weixin') {
|
||||
isOpeningNavigation.value = true
|
||||
try {
|
||||
await navigateToMuseum()
|
||||
const target = {
|
||||
latitude: selectedTarget.latitude,
|
||||
longitude: selectedTarget.longitude,
|
||||
name: selectedTarget.title,
|
||||
address: selectedTarget.subtitle
|
||||
}
|
||||
const bridgeResult = await openWechatMiniProgramLocation(target)
|
||||
if (bridgeResult.status === 'not-embedded') {
|
||||
await openExternalNavigation(target)
|
||||
} else if (bridgeResult.status === 'failed') {
|
||||
uni.showToast({
|
||||
title: bridgeResult.reason === 'page-not-found' ? '宿主小程序未配置地图页' : '无法打开微信地图',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
isOpeningNavigation.value = false
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<ExplainHallSelect
|
||||
:halls="explainHallItems"
|
||||
stage="hall"
|
||||
force-internal-header
|
||||
:loading="explainLoading"
|
||||
:error="explainError"
|
||||
@hall-click="handleExplainHallClick"
|
||||
|
||||
@@ -44,6 +44,9 @@ import type {
|
||||
import {
|
||||
guideRepository
|
||||
} from '@/repositories/createGuideRepository'
|
||||
import {
|
||||
normalizeSameOriginPublicUrl
|
||||
} from '@/utils/publicUrl'
|
||||
|
||||
const SDK_FLOOR_BUNDLE_CONCURRENCY = 2
|
||||
|
||||
@@ -135,21 +138,7 @@ const resolveSgsAssetUrl = (url?: string | null) => {
|
||||
if (!normalizedUrl) return ''
|
||||
|
||||
if (/^https?:\/\//i.test(normalizedUrl)) {
|
||||
try {
|
||||
const assetUrl = new URL(normalizedUrl)
|
||||
if (
|
||||
dataSourceConfig.publicSameOriginAssetHost
|
||||
&& assetUrl.hostname === dataSourceConfig.publicSameOriginAssetHost
|
||||
&& assetUrl.port === '9000'
|
||||
&& assetUrl.pathname.startsWith('/museum-assets/')
|
||||
) {
|
||||
return `${assetUrl.pathname}${assetUrl.search}${assetUrl.hash}`
|
||||
}
|
||||
} catch {
|
||||
return normalizedUrl
|
||||
}
|
||||
|
||||
return normalizedUrl
|
||||
return normalizeSameOriginPublicUrl(normalizedUrl)
|
||||
}
|
||||
|
||||
if (!normalizedUrl.startsWith('/')) return normalizedUrl
|
||||
|
||||
125
src/services/WechatMiniProgramBridgeService.ts
Normal file
125
src/services/WechatMiniProgramBridgeService.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
|
||||
import {
|
||||
buildWechatOpenLocationPageUrl,
|
||||
validateWechatOpenLocationTarget,
|
||||
type WechatOpenLocationTarget
|
||||
} from '@/utils/wechatOpenLocationProtocol'
|
||||
|
||||
interface MiniProgramBridge {
|
||||
getEnv?: (callback: (result: { miniprogram?: boolean }) => void) => void
|
||||
navigateTo?: (options: { url: string; success?: () => void; fail?: (error: { errMsg?: string }) => void }) => void
|
||||
}
|
||||
|
||||
export type WechatMiniProgramBridgeResult =
|
||||
| { status: 'opened' }
|
||||
| { status: 'not-embedded' }
|
||||
| { status: 'failed'; reason: 'invalid-target' | 'bridge-unavailable' | 'not-in-mini-program' | 'navigation-failed' | 'page-not-found' | 'timeout' }
|
||||
|
||||
const SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.3.2.js'
|
||||
const BRIDGE_WAIT_TIMEOUT = 2500
|
||||
const BRIDGE_POLL_INTERVAL = 50
|
||||
const GET_ENV_TIMEOUT = 1500
|
||||
const NAVIGATION_TIMEOUT = 3000
|
||||
|
||||
let pendingNavigation: Promise<WechatMiniProgramBridgeResult> | null = null
|
||||
let sdkLoadPromise: Promise<void> | null = null
|
||||
|
||||
const getBridge = (): MiniProgramBridge | null => {
|
||||
if (typeof window === 'undefined') return null
|
||||
return (window as Window & { wx?: { miniProgram?: MiniProgramBridge } }).wx?.miniProgram || null
|
||||
}
|
||||
|
||||
const wait = (duration: number) => new Promise<void>((resolve) => setTimeout(resolve, duration))
|
||||
|
||||
const loadWechatJsSdk = () => {
|
||||
if (typeof document === 'undefined' || getBridge()) return Promise.resolve()
|
||||
if (sdkLoadPromise) return sdkLoadPromise
|
||||
sdkLoadPromise = new Promise<void>((resolve) => {
|
||||
const existing = document.querySelector<HTMLScriptElement>('script[data-wechat-js-sdk="true"]')
|
||||
if (existing) {
|
||||
existing.addEventListener('load', () => resolve(), { once: true })
|
||||
existing.addEventListener('error', () => resolve(), { once: true })
|
||||
return
|
||||
}
|
||||
const script = document.createElement('script')
|
||||
script.src = SDK_URL
|
||||
script.async = true
|
||||
script.dataset.wechatJsSdk = 'true'
|
||||
script.onload = () => resolve()
|
||||
script.onerror = () => { script.remove(); resolve() }
|
||||
document.head.appendChild(script)
|
||||
}).finally(() => { sdkLoadPromise = null })
|
||||
return sdkLoadPromise
|
||||
}
|
||||
|
||||
const waitForBridge = async (): Promise<MiniProgramBridge | null> => {
|
||||
const immediate = getBridge()
|
||||
if (immediate) return immediate
|
||||
void loadWechatJsSdk()
|
||||
const deadline = Date.now() + BRIDGE_WAIT_TIMEOUT
|
||||
while (Date.now() < deadline) {
|
||||
await wait(BRIDGE_POLL_INTERVAL)
|
||||
const bridge = getBridge()
|
||||
if (bridge) return bridge
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const isMiniProgramWebView = (bridge: MiniProgramBridge) => new Promise<boolean>((resolve) => {
|
||||
if (!bridge.getEnv) return resolve(isEmbeddedInWechatMiniProgram())
|
||||
let settled = false
|
||||
const finish = (value: boolean) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
resolve(value)
|
||||
}
|
||||
const timeout = setTimeout(() => finish(isEmbeddedInWechatMiniProgram()), GET_ENV_TIMEOUT)
|
||||
try {
|
||||
bridge.getEnv((result) => finish(Boolean(result?.miniprogram)))
|
||||
} catch (error) {
|
||||
console.error('微信小程序环境识别失败:', error)
|
||||
finish(false)
|
||||
}
|
||||
})
|
||||
|
||||
const navigateToHostLocationPage = (bridge: MiniProgramBridge, target: WechatOpenLocationTarget) => new Promise<WechatMiniProgramBridgeResult>((resolve) => {
|
||||
if (!bridge.navigateTo) return resolve({ status: 'failed', reason: 'bridge-unavailable' })
|
||||
let settled = false
|
||||
const finish = (result: WechatMiniProgramBridgeResult) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
resolve(result)
|
||||
}
|
||||
const timeout = setTimeout(() => finish({ status: 'failed', reason: 'timeout' }), NAVIGATION_TIMEOUT)
|
||||
try {
|
||||
bridge.navigateTo({
|
||||
url: buildWechatOpenLocationPageUrl(target),
|
||||
success: () => finish({ status: 'opened' }),
|
||||
fail: (error) => {
|
||||
console.error('跳转宿主地图页失败:', error)
|
||||
finish({ status: 'failed', reason: /page.*not found|not found.*page/i.test(error?.errMsg || '') ? 'page-not-found' : 'navigation-failed' })
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('调用微信小程序导航桥接失败:', error)
|
||||
finish({ status: 'failed', reason: 'navigation-failed' })
|
||||
}
|
||||
})
|
||||
|
||||
const openOnce = async (target: WechatOpenLocationTarget): Promise<WechatMiniProgramBridgeResult> => {
|
||||
const validation = validateWechatOpenLocationTarget(target)
|
||||
if (!validation.ok) return { status: 'failed', reason: 'invalid-target' }
|
||||
if (!isEmbeddedInWechatMiniProgram()) return { status: 'not-embedded' }
|
||||
const bridge = await waitForBridge()
|
||||
if (!bridge) return { status: 'failed', reason: 'bridge-unavailable' }
|
||||
if (!await isMiniProgramWebView(bridge)) return { status: 'failed', reason: 'not-in-mini-program' }
|
||||
return navigateToHostLocationPage(bridge, validation.value)
|
||||
}
|
||||
|
||||
export const openWechatMiniProgramLocation = (target: WechatOpenLocationTarget) => {
|
||||
if (pendingNavigation) return pendingNavigation
|
||||
pendingNavigation = openOnce(target).finally(() => { pendingNavigation = null })
|
||||
return pendingNavigation
|
||||
}
|
||||
@@ -4,14 +4,31 @@ import {
|
||||
|
||||
const isAbsoluteHttpUrl = (url: string) => /^https?:\/\//i.test(url)
|
||||
|
||||
const defaultSameOriginAssetHosts = [
|
||||
['1', '92', '206', '90'].join('.')
|
||||
]
|
||||
|
||||
const defaultLegacyAudioHosts = [
|
||||
['47', '120', '48', '148'].join('.')
|
||||
]
|
||||
|
||||
const configuredSameOriginAssetHosts = () => [
|
||||
dataSourceConfig.publicSameOriginAssetHost,
|
||||
...defaultSameOriginAssetHosts
|
||||
].filter(Boolean)
|
||||
|
||||
const configuredLegacyAudioHosts = () => [
|
||||
dataSourceConfig.publicLegacyAudioHost,
|
||||
...defaultLegacyAudioHosts
|
||||
].filter(Boolean)
|
||||
|
||||
export const normalizeSameOriginPublicUrl = (url: string | null | undefined) => {
|
||||
const value = url?.trim()
|
||||
if (!value || !isAbsoluteHttpUrl(value)) return value || ''
|
||||
|
||||
try {
|
||||
const parsed = new URL(value)
|
||||
const isSameServer = Boolean(dataSourceConfig.publicSameOriginAssetHost)
|
||||
&& parsed.hostname === dataSourceConfig.publicSameOriginAssetHost
|
||||
const isSameServer = configuredSameOriginAssetHosts().includes(parsed.hostname)
|
||||
|
||||
if (
|
||||
isSameServer
|
||||
@@ -33,8 +50,7 @@ export const normalizeSameOriginPublicUrl = (url: string | null | undefined) =>
|
||||
}
|
||||
|
||||
if (
|
||||
dataSourceConfig.publicLegacyAudioHost
|
||||
&& parsed.hostname === dataSourceConfig.publicLegacyAudioHost
|
||||
configuredLegacyAudioHosts().includes(parsed.hostname)
|
||||
&& parsed.port === '19000'
|
||||
&& parsed.pathname.startsWith('/nhm/audio/')
|
||||
) {
|
||||
|
||||
@@ -65,3 +65,52 @@ test('hall goes directly to paged guide objects without outline requests', async
|
||||
expect(requests.some((url) => url.includes('/outlines'))).toBe(false)
|
||||
expect(requests.filter((url) => url.includes('/stops/page')).map((url) => new URL(url).searchParams.get('pageNo'))).toEqual(['1', '2'])
|
||||
})
|
||||
|
||||
test('embedded hall list exposes an H5 return control that keeps host markers', async ({ page }) => {
|
||||
await page.route('**/app-api/gis/guide/catalog/**', async (route) => {
|
||||
const url = route.request().url()
|
||||
if (url.includes('/catalog/halls?')) {
|
||||
await route.fulfill({ json: { code: 0, data: [{ id: 'hall-1', name: '恐龙厅', floorId: 'L1', stopCount: 1 }] } })
|
||||
return
|
||||
}
|
||||
await route.fulfill({ json: { code: 0, data: {} } })
|
||||
})
|
||||
|
||||
await page.goto('/#/pages/explain/list?embedded=wechat-mini-program&weapp=1')
|
||||
const back = page.locator('.header-back')
|
||||
await expect(back).toHaveCount(1)
|
||||
await expect(back).toBeVisible()
|
||||
await expect(page.locator('.hall-overview-card')).toHaveCount(1)
|
||||
for (const width of [375, 390, 430]) {
|
||||
await page.setViewportSize({ width, height: 844 })
|
||||
const layout = await page.locator('.explain-hall-select').evaluate((container) => {
|
||||
const header = container.querySelector<HTMLElement>('.explain-page-header')
|
||||
const card = container.querySelector<HTMLElement>('.hall-overview-card')
|
||||
if (!header || !card) throw new Error('missing header or hall card')
|
||||
const headerRect = header.getBoundingClientRect()
|
||||
const cardRect = card.getBoundingClientRect()
|
||||
return {
|
||||
clientWidth: container.clientWidth,
|
||||
scrollWidth: container.scrollWidth,
|
||||
headerBottom: headerRect.bottom,
|
||||
cardTop: cardRect.top
|
||||
}
|
||||
})
|
||||
expect(layout.scrollWidth).toBeLessThanOrEqual(layout.clientWidth)
|
||||
expect(layout.cardTop).toBeGreaterThanOrEqual(layout.headerBottom)
|
||||
}
|
||||
await back.click()
|
||||
await expect(page).toHaveURL(/#\/\?tab=guide&embedded=wechat-mini-program&weapp=1$/)
|
||||
})
|
||||
|
||||
test('ordinary H5 hall list return reaches the guide home', async ({ page }) => {
|
||||
await page.route('**/app-api/gis/guide/catalog/**', (route) => (
|
||||
route.fulfill({ json: { code: 0, data: [] } })
|
||||
))
|
||||
|
||||
await page.goto('/#/pages/explain/list')
|
||||
const back = page.locator('.header-back')
|
||||
await expect(back).toHaveCount(1)
|
||||
await back.click()
|
||||
await expect(page).toHaveURL(/#\/\?tab=guide$/)
|
||||
})
|
||||
|
||||
94
tests/e2e/poi-dom-labels.spec.ts
Normal file
94
tests/e2e/poi-dom-labels.spec.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
interface PoiCandidate {
|
||||
poiId: string
|
||||
floorId: string
|
||||
name: string
|
||||
primaryCategory: string
|
||||
positionGltf: [number, number, number]
|
||||
}
|
||||
|
||||
interface GuideDiagnostics {
|
||||
isInitialModelReady: () => boolean
|
||||
getFloors: () => Array<{ floorId: string }>
|
||||
getReport: () => { floorId: string; activeFocusPoiId: string }
|
||||
resetToViewBaseline: (options: { view: 'floor'; floorId: string; reason: 'floor-reset' }) => Promise<unknown>
|
||||
getFloorPois: (floorId: string) => Promise<PoiCandidate[]>
|
||||
focusTargetPoi: (request: PoiCandidate & { requestId: string }) => Promise<unknown>
|
||||
clearTargetFocus: () => void
|
||||
}
|
||||
|
||||
const openGuide = async (page: Page) => {
|
||||
await page.goto('/')
|
||||
await page.waitForURL(/tab=guide/, { timeout: 30_000 })
|
||||
await expect.poll(async () => page.evaluate(() => (
|
||||
(window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__?.isInitialModelReady() || false
|
||||
)), { timeout: 180_000 }).toBe(true)
|
||||
}
|
||||
|
||||
const resetToActiveFloor = async (page: Page) => page.evaluate(async () => {
|
||||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__
|
||||
if (!api) throw new Error('ThreeMap diagnostics unavailable')
|
||||
await api.resetToViewBaseline({ view: 'floor', floorId: api.getReport().floorId, reason: 'floor-reset' })
|
||||
return api.getReport().floorId
|
||||
})
|
||||
|
||||
for (const deviceScaleFactor of [1, 3]) {
|
||||
test.describe(`POI DOM labels at DPR ${deviceScaleFactor}`, () => {
|
||||
test.use({ viewport: { width: 390, height: 844 }, deviceScaleFactor })
|
||||
|
||||
test('renders measured native labels and clears focused labels without residue', async ({ page }, testInfo) => {
|
||||
await openGuide(page)
|
||||
const floorId = await resetToActiveFloor(page)
|
||||
const labels = page.locator('[data-poi-label-kind="ambient"]:visible')
|
||||
await expect(labels.first()).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const hall = page.locator('[data-poi-label-kind="ambient"].three-poi-dom-label--hall:visible').first()
|
||||
const facility = page.locator('[data-poi-label-kind="ambient"].three-poi-dom-label--service').first()
|
||||
await expect(hall).toBeVisible()
|
||||
|
||||
for (const locator of [hall, facility]) {
|
||||
await expect(locator).toHaveCSS('pointer-events', 'none')
|
||||
const fontSize = await locator.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize))
|
||||
expect(fontSize).toBeGreaterThanOrEqual(locator === hall ? 14 : 13)
|
||||
}
|
||||
|
||||
for (const locator of [hall]) {
|
||||
const box = await locator.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
expect(box!.x).toBeGreaterThanOrEqual(0)
|
||||
expect(box!.y).toBeGreaterThanOrEqual(0)
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(390)
|
||||
expect(box!.y + box!.height).toBeLessThanOrEqual(844)
|
||||
}
|
||||
|
||||
const poiId = await facility.getAttribute('data-poi-id')
|
||||
expect(poiId).toBeTruthy()
|
||||
await page.evaluate(async ({ floorId: targetFloorId, targetPoiId }) => {
|
||||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__
|
||||
if (!api) throw new Error('ThreeMap diagnostics unavailable')
|
||||
const poi = (await api.getFloorPois(targetFloorId)).find((candidate) => candidate.poiId === targetPoiId)
|
||||
if (!poi) throw new Error(`missing POI ${targetPoiId}`)
|
||||
await api.focusTargetPoi({ ...poi, requestId: `poi-dom-label-${targetPoiId}` })
|
||||
}, { floorId, targetPoiId: poiId! })
|
||||
|
||||
const focus = page.locator('[data-poi-label-kind="focus"][data-focus-label="active"]')
|
||||
await expect(focus).toBeVisible({ timeout: 20_000 })
|
||||
await expect(focus).toHaveCSS('pointer-events', 'none')
|
||||
await expect(focus.locator('.three-poi-dom-label__title')).toHaveCSS('font-size', '16px')
|
||||
await expect(focus.locator('.three-poi-dom-label__meta')).toHaveCSS('font-size', '13px')
|
||||
await page.screenshot({ path: testInfo.outputPath(`poi-dom-labels-dpr-${deviceScaleFactor}-focus.png`) })
|
||||
|
||||
await page.evaluate(() => {
|
||||
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: GuideDiagnostics })
|
||||
.__GUIDE_3D_VISUAL_STABILITY__
|
||||
api?.clearTargetFocus()
|
||||
})
|
||||
await expect(focus).toHaveCount(0)
|
||||
await page.screenshot({ path: testInfo.outputPath(`poi-dom-labels-dpr-${deviceScaleFactor}-ambient.png`) })
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -4,14 +4,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ArrivalPanel from '@/components/navigation/ArrivalPanel.vue'
|
||||
import { ARRIVAL_TARGETS } from '@/data/arrivalSearchTargets'
|
||||
import { openExternalNavigation } from '@/services/MapNavigationService'
|
||||
import { openWechatMiniProgramLocation } from '@/services/WechatMiniProgramBridgeService'
|
||||
|
||||
const selectedTarget = ARRIVAL_TARGETS[0]
|
||||
vi.mock('@/services/MapNavigationService', () => ({ openExternalNavigation: vi.fn() }))
|
||||
vi.mock('@/services/WechatMiniProgramBridgeService', () => ({ openWechatMiniProgramLocation: vi.fn() }))
|
||||
|
||||
const selectedTarget = ARRIVAL_TARGETS[1]
|
||||
const defaultUserAgent = window.navigator.userAgent
|
||||
|
||||
const mountArrivalPanel = () => mount(ArrivalPanel, {
|
||||
props: {
|
||||
visible: true,
|
||||
activeType: 'bus',
|
||||
activeType: selectedTarget.type,
|
||||
targets: [selectedTarget],
|
||||
selectedTargetId: selectedTarget.id,
|
||||
selectedTarget
|
||||
@@ -19,72 +23,62 @@ const mountArrivalPanel = () => mount(ArrivalPanel, {
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(openExternalNavigation).mockResolvedValue(true)
|
||||
vi.mocked(openWechatMiniProgramLocation).mockResolvedValue({ status: 'not-embedded' })
|
||||
vi.stubGlobal('uni', { showToast: vi.fn(), showActionSheet: vi.fn() })
|
||||
window.history.replaceState({}, '', '/#/pages/index/index?tab=guide')
|
||||
Object.defineProperty(window.navigator, 'userAgent', {
|
||||
configurable: true,
|
||||
value: 'Mozilla/5.0 MicroMessenger/8.0.50 miniProgram'
|
||||
})
|
||||
Object.assign(window, {
|
||||
__wxjs_environment: 'miniprogram',
|
||||
wx: {
|
||||
miniProgram: {
|
||||
navigateTo: vi.fn()
|
||||
}
|
||||
}
|
||||
})
|
||||
vi.stubGlobal('uni', {
|
||||
showToast: vi.fn(),
|
||||
showActionSheet: vi.fn()
|
||||
})
|
||||
delete (window as Window & { __wxjs_environment?: string }).__wxjs_environment
|
||||
Object.defineProperty(window.navigator, 'userAgent', { configurable: true, value: 'Mozilla/5.0 (Linux; Android 14)' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window.navigator, 'userAgent', {
|
||||
configurable: true,
|
||||
value: defaultUserAgent
|
||||
})
|
||||
Object.defineProperty(window.navigator, 'userAgent', { configurable: true, value: defaultUserAgent })
|
||||
delete (window as Window & { __wxjs_environment?: string }).__wxjs_environment
|
||||
delete (window as Window & { wx?: unknown }).wx
|
||||
vi.clearAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('来馆面板 H5 实际渲染', () => {
|
||||
it('主按钮显示第三方导航,点击后弹出百度和高德选择框', async () => {
|
||||
describe('来馆面板 H5 导航分流', () => {
|
||||
it('嵌入小程序 web-view 时只向宿主页面导航,且完整传递当前选中目标', async () => {
|
||||
Object.assign(window, { __wxjs_environment: 'miniprogram' })
|
||||
Object.defineProperty(window.navigator, 'userAgent', { configurable: true, value: 'Mozilla/5.0 MicroMessenger/8.0.50' })
|
||||
vi.mocked(openWechatMiniProgramLocation).mockResolvedValue({ status: 'opened' })
|
||||
const wrapper = mountArrivalPanel()
|
||||
const showActionSheet = (uni as unknown as {
|
||||
showActionSheet: ReturnType<typeof vi.fn>
|
||||
}).showActionSheet
|
||||
const navigateTo = (
|
||||
window as Window & {
|
||||
wx?: { miniProgram?: { navigateTo?: ReturnType<typeof vi.fn> } }
|
||||
}
|
||||
).wx?.miniProgram?.navigateTo
|
||||
|
||||
expect(wrapper.get('.arrival-primary-text').text()).toBe('第三方导航')
|
||||
expect(wrapper.text()).not.toContain('打开地图导航')
|
||||
expect(wrapper.get('.arrival-primary-text').text()).toBe('打开地图导航')
|
||||
await wrapper.get('.arrival-primary').trigger('tap')
|
||||
|
||||
expect(showActionSheet).toHaveBeenCalledWith(expect.objectContaining({
|
||||
itemList: ['百度地图', '高德地图']
|
||||
}))
|
||||
expect(wrapper.find('.provider-sheet').exists()).toBe(false)
|
||||
expect(navigateTo).not.toHaveBeenCalled()
|
||||
expect(openWechatMiniProgramLocation).toHaveBeenCalledTimes(1)
|
||||
expect(openWechatMiniProgramLocation).toHaveBeenCalledWith({
|
||||
latitude: selectedTarget.latitude,
|
||||
longitude: selectedTarget.longitude,
|
||||
name: selectedTarget.title,
|
||||
address: selectedTarget.subtitle
|
||||
})
|
||||
expect(openExternalNavigation).not.toHaveBeenCalled()
|
||||
expect((uni as unknown as { showActionSheet: ReturnType<typeof vi.fn> }).showActionSheet).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('诊断信息默认隐藏,仅在 debug-build=1 时显示 H5 构建边界', () => {
|
||||
it('普通 H5 仅回退至第三方地图,并使用当前选中目标', async () => {
|
||||
const wrapper = mountArrivalPanel()
|
||||
expect(wrapper.get('.arrival-primary-text').text()).toBe('第三方导航')
|
||||
await wrapper.get('.arrival-primary').trigger('tap')
|
||||
|
||||
expect(openExternalNavigation).toHaveBeenCalledWith({
|
||||
latitude: selectedTarget.latitude,
|
||||
longitude: selectedTarget.longitude,
|
||||
name: selectedTarget.title,
|
||||
address: selectedTarget.subtitle
|
||||
})
|
||||
})
|
||||
|
||||
it('诊断信息默认隐藏,仅在 debug-build=1 时显示构建边界', () => {
|
||||
const normalWrapper = mountArrivalPanel()
|
||||
expect(normalWrapper.find('[data-testid="arrival-build-diagnostics"]').exists()).toBe(false)
|
||||
normalWrapper.unmount()
|
||||
|
||||
window.location.hash = '#/pages/index/index?tab=guide&debug-build=1'
|
||||
const debugWrapper = mountArrivalPanel()
|
||||
const diagnostics = debugWrapper.get('[data-testid="arrival-build-diagnostics"]')
|
||||
|
||||
expect(diagnostics.text()).toContain('构建 ID:test-build')
|
||||
expect(diagnostics.text()).toContain('Git commit:test-commit')
|
||||
expect(diagnostics.text()).toContain('构建平台:h5')
|
||||
expect(diagnostics.text()).toContain('navigationMode:third-party-providers')
|
||||
expect(diagnostics.text()).toContain('location.href:')
|
||||
expect(diagnostics.text()).toContain('UA:')
|
||||
expect(debugWrapper.get('[data-testid="arrival-build-diagnostics"]').text()).toContain('构建平台:h5')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,46 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import ExplainHallSelect from '@/components/explain/ExplainHallSelect.vue'
|
||||
|
||||
vi.mock('@/utils/hostEnvironment', () => ({
|
||||
isEmbeddedInWechatMiniProgram: () => false
|
||||
const hostEnvironmentMocks = vi.hoisted(() => ({
|
||||
embeddedInWechatMiniProgram: false
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/hostEnvironment', () => ({
|
||||
isEmbeddedInWechatMiniProgram: () => hostEnvironmentMocks.embeddedInWechatMiniProgram
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
hostEnvironmentMocks.embeddedInWechatMiniProgram = false
|
||||
})
|
||||
|
||||
describe('讲解展厅选择列表', () => {
|
||||
it('微信嵌入态独立页面可强制显示 H5 返回入口,且不使用宿主导航布局', async () => {
|
||||
hostEnvironmentMocks.embeddedInWechatMiniProgram = true
|
||||
const wrapper = mount(ExplainHallSelect, {
|
||||
props: {
|
||||
forceInternalHeader: true
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('.explain-page-header')).toHaveLength(1)
|
||||
expect(wrapper.findAll('.header-back')).toHaveLength(1)
|
||||
expect(wrapper.classes()).not.toContain('host-navigation')
|
||||
|
||||
await wrapper.get('.header-back').trigger('tap')
|
||||
expect(wrapper.emitted('back')).toEqual([[]])
|
||||
})
|
||||
|
||||
it('微信嵌入态未强制时保留宿主导航布局,保护首页讲解 Tab', () => {
|
||||
hostEnvironmentMocks.embeddedInWechatMiniProgram = true
|
||||
const wrapper = mount(ExplainHallSelect)
|
||||
|
||||
expect(wrapper.find('.explain-page-header').exists()).toBe(false)
|
||||
expect(wrapper.classes()).toContain('host-navigation')
|
||||
})
|
||||
|
||||
it('展厅卡片只显示讲解对象数量,不暴露业务单元契约', async () => {
|
||||
const wrapper = mount(ExplainHallSelect, {
|
||||
props: {
|
||||
|
||||
@@ -42,6 +42,9 @@ const GuidePageFrameStub = defineComponent({
|
||||
|
||||
const ExplainHallSelectStub = defineComponent({
|
||||
name: 'ExplainHallSelect',
|
||||
props: {
|
||||
forceInternalHeader: Boolean
|
||||
},
|
||||
emits: ['back'],
|
||||
template: '<button data-testid="explain-list-back" @click="$emit(\'back\')">返回</button>'
|
||||
})
|
||||
@@ -103,6 +106,22 @@ describe('讲解展厅列表返回', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('独立页在微信嵌入态强制提供 H5 返回,并保留宿主识别参数', async () => {
|
||||
hostEnvironmentMocks.embeddedInWechatMiniProgram = true
|
||||
window.location.hash = '#/pages/explain/list?embedded=wechat-mini-program&weapp=1'
|
||||
window.history.replaceState({}, '', window.location.href)
|
||||
const wrapper = mountExplainList()
|
||||
|
||||
expect(wrapper.getComponent(ExplainHallSelectStub).props('forceInternalHeader')).toBe(true)
|
||||
await wrapper.get('[data-testid="explain-list-back"]').trigger('click')
|
||||
|
||||
expect(uni.navigateBack).not.toHaveBeenCalled()
|
||||
expect(uni.reLaunch).toHaveBeenCalledWith(expect.objectContaining({
|
||||
url: '/pages/index/index?tab=guide&embedded=wechat-mini-program&weapp=1'
|
||||
}))
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('上一页不是首页时使用 reLaunch,避免回到不相关页面', async () => {
|
||||
vi.stubGlobal('getCurrentPages', vi.fn(() => [
|
||||
{ route: 'pages/search/index', options: {} },
|
||||
|
||||
87
tests/unit/WechatMiniProgramBridgeService.spec.ts
Normal file
87
tests/unit/WechatMiniProgramBridgeService.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const target = { latitude: 22.604321, longitude: 114.058765, name: '停车场 A&B', address: '龙华区 #1' }
|
||||
const setEmbeddedRuntime = () => Object.assign(window, { __wxjs_environment: 'miniprogram' })
|
||||
|
||||
const loadService = async () => {
|
||||
vi.resetModules()
|
||||
return import('@/services/WechatMiniProgramBridgeService')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('uni', { showToast: vi.fn() })
|
||||
delete (window as Window & { wx?: unknown }).wx
|
||||
delete (window as Window & { __wxjs_environment?: string }).__wxjs_environment
|
||||
window.history.replaceState({}, '', '/')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
document.querySelectorAll('script[data-wechat-js-sdk="true"]').forEach((node) => node.remove())
|
||||
})
|
||||
|
||||
describe('微信小程序 web-view 桥接', () => {
|
||||
it('普通浏览器立即返回 not-embedded,不加载 SDK', async () => {
|
||||
const { openWechatMiniProgramLocation } = await loadService()
|
||||
await expect(openWechatMiniProgramLocation(target)).resolves.toEqual({ status: 'not-embedded' })
|
||||
expect(document.querySelector('[data-wechat-js-sdk="true"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('等待延迟注入的 bridge 后导航到编码后的宿主页', async () => {
|
||||
setEmbeddedRuntime()
|
||||
const { openWechatMiniProgramLocation } = await loadService()
|
||||
const result = openWechatMiniProgramLocation(target)
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
const navigateTo = vi.fn(({ success }) => success())
|
||||
Object.assign(window, { wx: { miniProgram: { getEnv: (done: (value: { miniprogram: boolean }) => void) => done({ miniprogram: true }), navigateTo } } })
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
await expect(result).resolves.toEqual({ status: 'opened' })
|
||||
expect(navigateTo).toHaveBeenCalledWith(expect.objectContaining({
|
||||
url: expect.stringContaining('/pages/open-location/index?latitude=22.604321&longitude=114.058765&name=%E5%81%9C%E8%BD%A6%E5%9C%BA%20A%26B')
|
||||
}))
|
||||
})
|
||||
|
||||
it('handles getEnv false, navigate fail, page not found, and callback timeout', async () => {
|
||||
setEmbeddedRuntime()
|
||||
const { openWechatMiniProgramLocation } = await loadService()
|
||||
Object.assign(window, { wx: { miniProgram: { getEnv: (done: (value: { miniprogram: boolean }) => void) => done({ miniprogram: false }), navigateTo: vi.fn() } } })
|
||||
await expect(openWechatMiniProgramLocation(target)).resolves.toEqual({ status: 'failed', reason: 'not-in-mini-program' })
|
||||
|
||||
Object.assign(window, { wx: { miniProgram: { getEnv: (done: (value: { miniprogram: boolean }) => void) => done({ miniprogram: true }) } } })
|
||||
await expect(openWechatMiniProgramLocation(target)).resolves.toEqual({ status: 'failed', reason: 'bridge-unavailable' })
|
||||
|
||||
Object.assign(window, { wx: { miniProgram: { getEnv: (done: (value: { miniprogram: boolean }) => void) => done({ miniprogram: true }), navigateTo: vi.fn(({ fail }) => fail({ errMsg: 'navigateTo:fail permission denied' })) } } })
|
||||
await expect(openWechatMiniProgramLocation(target)).resolves.toEqual({ status: 'failed', reason: 'navigation-failed' })
|
||||
|
||||
const navigateTo = vi.fn(({ fail }) => fail({ errMsg: 'navigateTo:fail page not found' }))
|
||||
Object.assign(window, { wx: { miniProgram: { getEnv: (done: (value: { miniprogram: boolean }) => void) => done({ miniprogram: true }), navigateTo } } })
|
||||
await expect(openWechatMiniProgramLocation(target)).resolves.toEqual({ status: 'failed', reason: 'page-not-found' })
|
||||
|
||||
Object.assign(window, { wx: { miniProgram: { getEnv: (done: (value: { miniprogram: boolean }) => void) => done({ miniprogram: true }), navigateTo: vi.fn() } } })
|
||||
const timeoutResult = openWechatMiniProgramLocation(target)
|
||||
await vi.advanceTimersByTimeAsync(3001)
|
||||
await expect(timeoutResult).resolves.toEqual({ status: 'failed', reason: 'timeout' })
|
||||
})
|
||||
|
||||
it('deduplicates rapid calls while navigation is in flight', async () => {
|
||||
setEmbeddedRuntime()
|
||||
const { openWechatMiniProgramLocation } = await loadService()
|
||||
let succeed: (() => void) | undefined
|
||||
const navigateTo = vi.fn(({ success }) => { succeed = success })
|
||||
Object.assign(window, { wx: { miniProgram: { getEnv: (done: (value: { miniprogram: boolean }) => void) => done({ miniprogram: true }), navigateTo } } })
|
||||
const first = openWechatMiniProgramLocation(target)
|
||||
expect(openWechatMiniProgramLocation(target)).toBe(first)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(navigateTo).toHaveBeenCalledTimes(1)
|
||||
succeed?.()
|
||||
await expect(first).resolves.toEqual({ status: 'opened' })
|
||||
expect(navigateTo).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
31
tests/unit/poiDomLabels.spec.ts
Normal file
31
tests/unit/poiDomLabels.spec.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import * as THREE from 'three'
|
||||
import {
|
||||
domLabelBoundsOverlap,
|
||||
getDomLabelBounds,
|
||||
isDomLabelWithinViewport,
|
||||
projectObjectToDom
|
||||
} from '@/components/map/poiDomLabels'
|
||||
|
||||
describe('POI DOM label projection and collision', () => {
|
||||
it('projects a world-space anchor to CSS pixels', () => {
|
||||
const camera = new THREE.PerspectiveCamera(60, 2, 0.1, 100)
|
||||
camera.position.set(0, 0, 10)
|
||||
camera.lookAt(0, 0, 0)
|
||||
camera.updateProjectionMatrix()
|
||||
camera.updateMatrixWorld()
|
||||
|
||||
const anchor = new THREE.Object3D()
|
||||
anchor.position.set(0, 0, 0)
|
||||
expect(projectObjectToDom(anchor, camera, 390, 195)).toEqual({ x: 195, y: 97.5 })
|
||||
})
|
||||
|
||||
it('uses measured DOM dimensions for bottom-anchored overlap checks', () => {
|
||||
const hall = getDomLabelBounds({ x: 120, y: 100 }, { width: 146, height: 38 }, 'bottom')
|
||||
const facility = getDomLabelBounds({ x: 240, y: 100 }, { width: 88, height: 32 }, 'bottom')
|
||||
|
||||
expect(domLabelBoundsOverlap(hall, facility, 6)).toBe(true)
|
||||
expect(isDomLabelWithinViewport(hall, 390, 844)).toBe(true)
|
||||
expect(isDomLabelWithinViewport(getDomLabelBounds({ x: 5, y: 10 }, { width: 88, height: 32 }, 'bottom'), 390, 844)).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user