完善馆内点位展示与微信地图桥接
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
lyf
2026-07-19 16:01:32 +08:00
parent 127461dd60
commit d199b4602f
10 changed files with 780 additions and 334 deletions

View File

@@ -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;

View 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

View File

@@ -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
}

View File

@@ -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

View 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
}

View File

@@ -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/')
) {