This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user