优化导览预览与讲解展示层
This commit is contained in:
339
src/components/explain/ExplainHallSelect.vue
Normal file
339
src/components/explain/ExplainHallSelect.vue
Normal file
@@ -0,0 +1,339 @@
|
||||
<template>
|
||||
<view class="explain-hall-select">
|
||||
<view class="explain-page-header">
|
||||
<view class="header-back" @tap="handleBack">
|
||||
<text class="header-back-icon">‹</text>
|
||||
<text class="header-back-text">返回</text>
|
||||
</view>
|
||||
<text class="header-title">讲解</text>
|
||||
</view>
|
||||
|
||||
<scroll-view
|
||||
class="explain-scroll"
|
||||
scroll-y
|
||||
:show-scrollbar="false"
|
||||
>
|
||||
<view v-if="loading" class="state-block">
|
||||
<text class="state-title">正在加载展厅讲解</text>
|
||||
<text class="state-desc">稍后将展示可选择的展厅。</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="error" class="state-block error">
|
||||
<text class="state-title">讲解内容加载失败</text>
|
||||
<text class="state-desc">{{ error }}</text>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<view class="section-heading">
|
||||
<text class="section-title">请选择展厅</text>
|
||||
<text class="section-count">{{ filteredHalls.length }} 个展厅</text>
|
||||
</view>
|
||||
|
||||
<view v-if="filteredHalls.length" class="hall-list">
|
||||
<view
|
||||
v-for="hall in filteredHalls"
|
||||
:key="hall.id"
|
||||
class="hall-card"
|
||||
@tap="handleHallClick(hall.id)"
|
||||
>
|
||||
<image
|
||||
v-if="hall.image"
|
||||
class="hall-thumb"
|
||||
:src="hall.image"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view v-else class="hall-thumb placeholder">
|
||||
<text class="hall-thumb-text">{{ hallIconText(hall.name) }}</text>
|
||||
</view>
|
||||
|
||||
<view class="hall-main">
|
||||
<text class="hall-name">{{ hall.name }}</text>
|
||||
<view class="hall-meta-row">
|
||||
<view class="floor-badge">
|
||||
<text class="floor-badge-text">{{ hall.floorLabel || '楼层待补充' }}</text>
|
||||
</view>
|
||||
<text class="hall-meta-text">{{ hall.explainCount > 0 ? `${hall.explainCount} 条讲解` : '暂无讲解' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<text class="hall-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="state-block empty">
|
||||
<text class="state-title">未找到相关展厅</text>
|
||||
<text class="state-desc">暂无可选择的展厅讲解。</text>
|
||||
</view>
|
||||
</template>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
export interface ExplainHallSelectItem {
|
||||
id: string
|
||||
name: string
|
||||
floorLabel?: string
|
||||
image?: string
|
||||
explainCount: number
|
||||
searchText: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
halls?: ExplainHallSelectItem[]
|
||||
loading?: boolean
|
||||
error?: string
|
||||
}>(), {
|
||||
halls: () => [],
|
||||
loading: false,
|
||||
error: ''
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
hallClick: [hallId: string]
|
||||
back: []
|
||||
}>()
|
||||
|
||||
const filteredHalls = computed(() => props.halls)
|
||||
|
||||
const hallIconText = (name: string) => name.trim().slice(0, 1) || '讲'
|
||||
|
||||
const handleHallClick = (hallId: string) => {
|
||||
emit('hallClick', hallId)
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
emit('back')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.explain-hall-select {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.explain-scroll {
|
||||
height: 100%;
|
||||
padding: 76px 20px calc(28px + env(safe-area-inset-bottom));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.section-title,
|
||||
.section-count,
|
||||
.state-title,
|
||||
.state-desc,
|
||||
.hall-name,
|
||||
.hall-meta-text {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.explain-page-header {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 20px;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #eceee8;
|
||||
z-index: 2001;
|
||||
}
|
||||
|
||||
.header-back {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 0;
|
||||
height: 56px;
|
||||
min-width: 74px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.header-back-icon {
|
||||
font-size: 27px;
|
||||
line-height: 27px;
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
.header-back-text {
|
||||
font-size: 15px;
|
||||
line-height: 21px;
|
||||
font-weight: 500;
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 18px;
|
||||
line-height: 25px;
|
||||
font-weight: 800;
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 19px;
|
||||
line-height: 27px;
|
||||
font-weight: 800;
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
.section-count {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: #7b8275;
|
||||
}
|
||||
|
||||
.hall-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hall-card {
|
||||
min-height: 112px;
|
||||
padding: 14px 13px 14px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e4e6df;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 18px rgba(36, 49, 42, 0.05);
|
||||
}
|
||||
|
||||
.hall-card:active {
|
||||
background: #f7f8f3;
|
||||
}
|
||||
|
||||
.hall-thumb {
|
||||
flex-shrink: 0;
|
||||
width: 68px;
|
||||
height: 68px;
|
||||
border-radius: 8px;
|
||||
background: #f1f2ee;
|
||||
}
|
||||
|
||||
.hall-thumb.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hall-thumb-text {
|
||||
font-size: 24px;
|
||||
line-height: 32px;
|
||||
font-weight: 800;
|
||||
color: #83927a;
|
||||
}
|
||||
|
||||
.hall-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hall-name {
|
||||
font-size: 17px;
|
||||
line-height: 24px;
|
||||
font-weight: 800;
|
||||
color: #151713;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hall-meta-row {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.floor-badge {
|
||||
flex-shrink: 0;
|
||||
min-width: 28px;
|
||||
height: 24px;
|
||||
padding: 0 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e0df00;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.floor-badge-text {
|
||||
font-size: 13px;
|
||||
line-height: 17px;
|
||||
font-weight: 600;
|
||||
color: #aaa900;
|
||||
}
|
||||
|
||||
.hall-meta-text {
|
||||
min-width: 0;
|
||||
font-size: 15px;
|
||||
line-height: 21px;
|
||||
color: #555c51;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hall-arrow {
|
||||
flex-shrink: 0;
|
||||
font-size: 30px;
|
||||
line-height: 30px;
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
.state-block {
|
||||
margin: 34px 0;
|
||||
padding: 22px 16px;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e4e6df;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.state-title {
|
||||
font-size: 15px;
|
||||
line-height: 21px;
|
||||
font-weight: 700;
|
||||
color: #151713;
|
||||
}
|
||||
|
||||
.state-desc {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: #68725d;
|
||||
}
|
||||
|
||||
.state-block.error {
|
||||
border-color: #e5c2c2;
|
||||
background: #fff7f7;
|
||||
}
|
||||
</style>
|
||||
@@ -64,6 +64,7 @@ import type {
|
||||
|
||||
type ViewMode = 'overview' | 'floor' | 'multi'
|
||||
type CameraPreset = 'top' | 'oblique'
|
||||
type TouchGestureMode = 'orbit' | 'pan'
|
||||
|
||||
type FloorIndexItem = GuideModelFloorAsset
|
||||
|
||||
@@ -79,12 +80,23 @@ type PoiVisibilityTier = 'tight' | 'balanced' | 'full'
|
||||
interface PoiSpriteUserData {
|
||||
poi?: RenderPoi
|
||||
baseScale?: number
|
||||
labelBaseScaleX?: number
|
||||
labelBaseScaleY?: number
|
||||
isPoiLabel?: boolean
|
||||
isPoiPulse?: boolean
|
||||
isPoiBase?: boolean
|
||||
isCorePoi?: boolean
|
||||
}
|
||||
|
||||
interface FocusHallMaterialState {
|
||||
material: THREE.Material
|
||||
color?: THREE.Color
|
||||
emissive?: THREE.Color
|
||||
emissiveIntensity?: number
|
||||
opacity: number
|
||||
transparent: boolean
|
||||
}
|
||||
|
||||
interface TargetPoiFocusRequest {
|
||||
requestId: number | string
|
||||
poiId: string
|
||||
@@ -93,6 +105,7 @@ interface TargetPoiFocusRequest {
|
||||
floorLabel?: string
|
||||
primaryCategoryZh?: string
|
||||
positionGltf?: [number, number, number]
|
||||
sourceObjectName?: string
|
||||
}
|
||||
|
||||
interface TargetPoiFocusResult {
|
||||
@@ -120,6 +133,8 @@ const props = withDefaults(defineProps<{
|
||||
showControls?: boolean
|
||||
showPoi?: boolean
|
||||
targetFocus?: TargetPoiFocusRequest | null
|
||||
touchGestureMode?: TouchGestureMode
|
||||
targetFocusDistanceFactor?: number
|
||||
routePreview?: GuideRouteResult | null
|
||||
showRoute?: boolean
|
||||
autoSwitch?: boolean
|
||||
@@ -133,6 +148,8 @@ const props = withDefaults(defineProps<{
|
||||
showControls: true,
|
||||
showPoi: false,
|
||||
targetFocus: null,
|
||||
touchGestureMode: 'orbit',
|
||||
targetFocusDistanceFactor: 0.22,
|
||||
routePreview: null,
|
||||
showRoute: false,
|
||||
autoSwitch: true,
|
||||
@@ -183,6 +200,8 @@ let routeGroup: THREE.Group | null = null
|
||||
let activeFocusLabelSprite: THREE.Sprite | null = null
|
||||
let activeFocusPulseSprite: THREE.Sprite | null = null
|
||||
let activeFocusBaseSprite: THREE.Sprite | null = null
|
||||
let activeFocusHallGlowMesh: THREE.Mesh | null = null
|
||||
let activeFocusHallMaterialStates: FocusHallMaterialState[] = []
|
||||
let animationId = 0
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let isDisposed = false
|
||||
@@ -201,6 +220,15 @@ let autoSwitchDisableTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let isProgrammaticCameraChange = false
|
||||
let programmaticCameraTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const syncControlInteractionOptions = () => {
|
||||
if (!controls) return
|
||||
|
||||
const shouldEnablePan = props.showControls || props.touchGestureMode === 'pan'
|
||||
controls.enablePan = shouldEnablePan
|
||||
controls.touches.ONE = props.touchGestureMode === 'pan' ? THREE.TOUCH.PAN : THREE.TOUCH.ROTATE
|
||||
controls.touches.TWO = THREE.TOUCH.DOLLY_PAN
|
||||
}
|
||||
|
||||
const corePoiCategories = new Set([
|
||||
'basic_service_facility',
|
||||
'transport_circulation',
|
||||
@@ -276,6 +304,61 @@ const getModelNodeNames = (object: THREE.Object3D) => {
|
||||
return names
|
||||
}
|
||||
|
||||
const normalizeModelMatchKey = (value: string) => (
|
||||
value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_\-./\\]/g, '')
|
||||
)
|
||||
|
||||
const getPoiModelMatchKeys = (poi: RenderPoi) => (
|
||||
[
|
||||
poi.sourceObjectName,
|
||||
`${poi.floorId}_${poi.name}`,
|
||||
poi.name
|
||||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.map(normalizeModelMatchKey)
|
||||
.filter(Boolean)
|
||||
)
|
||||
|
||||
const isPoiModelNodeMatch = (object: THREE.Object3D, poi: RenderPoi) => {
|
||||
const keys = getPoiModelMatchKeys(poi)
|
||||
if (!keys.length) return false
|
||||
|
||||
return getModelNodeNames(object)
|
||||
.map(normalizeModelMatchKey)
|
||||
.some((name) => keys.some((key) => name === key || name.includes(key)))
|
||||
}
|
||||
|
||||
const findPoiModelRoot = (poi: RenderPoi): THREE.Object3D | null => {
|
||||
if (!activeModel) return null
|
||||
|
||||
let matchedRoot: THREE.Object3D | null = null
|
||||
|
||||
activeModel.traverse((child) => {
|
||||
if (matchedRoot || child === activeModel) return
|
||||
|
||||
const childName = child.name ? normalizeModelMatchKey(child.name) : ''
|
||||
const keys = getPoiModelMatchKeys(poi)
|
||||
if (childName && keys.some((key) => childName === key || childName.includes(key))) {
|
||||
matchedRoot = child
|
||||
}
|
||||
})
|
||||
|
||||
if (matchedRoot) return matchedRoot
|
||||
|
||||
activeModel.traverse((child) => {
|
||||
if (matchedRoot || !(child instanceof THREE.Mesh) || !child.visible) return
|
||||
|
||||
if (isPoiModelNodeMatch(child, poi)) {
|
||||
matchedRoot = child.parent && child.parent !== activeModel ? child.parent : child
|
||||
}
|
||||
})
|
||||
|
||||
return matchedRoot
|
||||
}
|
||||
|
||||
const isExteriorModelNode = (object: THREE.Object3D) => (
|
||||
getModelNodeNames(object).some((name) => (
|
||||
floorExteriorNameKeywords.some((keyword) => name.includes(keyword))
|
||||
@@ -591,12 +674,12 @@ const initThree = async () => {
|
||||
controls = new OrbitControls(camera, renderer.domElement)
|
||||
controls.enableDamping = true
|
||||
controls.dampingFactor = 0.08
|
||||
controls.enablePan = props.showControls
|
||||
controls.enableZoom = true
|
||||
controls.minDistance = 6
|
||||
controls.maxDistance = 1200
|
||||
controls.minPolarAngle = 0.08
|
||||
controls.maxPolarAngle = Math.PI * 0.48
|
||||
syncControlInteractionOptions()
|
||||
|
||||
loader = new GLTFLoader()
|
||||
poiGroup = new THREE.Group()
|
||||
@@ -636,6 +719,7 @@ const startRenderLoop = () => {
|
||||
if (isDisposed || !renderer || !scene || !camera) return
|
||||
|
||||
controls?.update()
|
||||
updateFocusLabelScale()
|
||||
renderer.render(scene, camera)
|
||||
animationId = window.requestAnimationFrame(render)
|
||||
}
|
||||
@@ -675,6 +759,174 @@ const disposeObject = (object: THREE.Object3D) => {
|
||||
})
|
||||
}
|
||||
|
||||
const getMaterialColor = (material: THREE.Material) => {
|
||||
const candidate = material as THREE.Material & { color?: unknown }
|
||||
return candidate.color instanceof THREE.Color ? candidate.color : undefined
|
||||
}
|
||||
|
||||
const getMaterialEmissive = (material: THREE.Material) => {
|
||||
const candidate = material as THREE.Material & { emissive?: unknown }
|
||||
return candidate.emissive instanceof THREE.Color ? candidate.emissive : undefined
|
||||
}
|
||||
|
||||
const getMaterialEmissiveIntensity = (material: THREE.Material) => {
|
||||
const candidate = material as THREE.Material & { emissiveIntensity?: unknown }
|
||||
return typeof candidate.emissiveIntensity === 'number'
|
||||
? candidate.emissiveIntensity
|
||||
: undefined
|
||||
}
|
||||
|
||||
const setMaterialEmissiveIntensity = (material: THREE.Material, value: number) => {
|
||||
const candidate = material as THREE.Material & { emissiveIntensity?: number }
|
||||
if (typeof candidate.emissiveIntensity === 'number') {
|
||||
candidate.emissiveIntensity = value
|
||||
}
|
||||
}
|
||||
|
||||
const clearFocusHallHighlight = () => {
|
||||
activeFocusHallMaterialStates.forEach((state) => {
|
||||
const color = getMaterialColor(state.material)
|
||||
const emissive = getMaterialEmissive(state.material)
|
||||
|
||||
if (color && state.color) {
|
||||
color.copy(state.color)
|
||||
}
|
||||
|
||||
if (emissive && state.emissive) {
|
||||
emissive.copy(state.emissive)
|
||||
}
|
||||
|
||||
if (typeof state.emissiveIntensity === 'number') {
|
||||
setMaterialEmissiveIntensity(state.material, state.emissiveIntensity)
|
||||
}
|
||||
|
||||
state.material.opacity = state.opacity
|
||||
state.material.transparent = state.transparent
|
||||
state.material.needsUpdate = true
|
||||
})
|
||||
|
||||
activeFocusHallMaterialStates = []
|
||||
|
||||
if (activeFocusHallGlowMesh) {
|
||||
activeFocusHallGlowMesh.parent?.remove(activeFocusHallGlowMesh)
|
||||
const material = activeFocusHallGlowMesh.material
|
||||
if (!Array.isArray(material)) {
|
||||
const glowMaterial = material as THREE.MeshBasicMaterial
|
||||
glowMaterial.map?.dispose()
|
||||
}
|
||||
disposeObject(activeFocusHallGlowMesh)
|
||||
activeFocusHallGlowMesh = null
|
||||
}
|
||||
}
|
||||
|
||||
const applyFocusHallMaterial = (material: THREE.Material, seenMaterials: Set<THREE.Material>) => {
|
||||
if (seenMaterials.has(material)) return
|
||||
seenMaterials.add(material)
|
||||
|
||||
const color = getMaterialColor(material)
|
||||
const emissive = getMaterialEmissive(material)
|
||||
const emissiveIntensity = getMaterialEmissiveIntensity(material)
|
||||
|
||||
activeFocusHallMaterialStates.push({
|
||||
material,
|
||||
color: color?.clone(),
|
||||
emissive: emissive?.clone(),
|
||||
emissiveIntensity,
|
||||
opacity: material.opacity,
|
||||
transparent: material.transparent
|
||||
})
|
||||
|
||||
const glowColor = new THREE.Color('#e0df00')
|
||||
|
||||
if (color) {
|
||||
color.lerp(glowColor, 0.34)
|
||||
}
|
||||
|
||||
if (emissive) {
|
||||
emissive.copy(glowColor)
|
||||
setMaterialEmissiveIntensity(material, Math.max(emissiveIntensity || 0, 0.76))
|
||||
}
|
||||
|
||||
material.opacity = Math.max(material.opacity, 0.96)
|
||||
material.transparent = true
|
||||
material.needsUpdate = true
|
||||
}
|
||||
|
||||
const createFocusHallGlowMesh = (poi: RenderPoi) => {
|
||||
if (!poi.positionGltf) return null
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 256
|
||||
canvas.height = 256
|
||||
const context = canvas.getContext('2d')
|
||||
|
||||
if (context) {
|
||||
const gradient = context.createRadialGradient(128, 128, 16, 128, 128, 118)
|
||||
gradient.addColorStop(0, 'rgba(224, 223, 0, 0.52)')
|
||||
gradient.addColorStop(0.45, 'rgba(224, 223, 0, 0.34)')
|
||||
gradient.addColorStop(0.78, 'rgba(224, 223, 0, 0.16)')
|
||||
gradient.addColorStop(1, 'rgba(224, 223, 0, 0)')
|
||||
|
||||
context.clearRect(0, 0, canvas.width, canvas.height)
|
||||
context.fillStyle = gradient
|
||||
context.fillRect(0, 0, canvas.width, canvas.height)
|
||||
}
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas)
|
||||
texture.colorSpace = THREE.SRGBColorSpace
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
opacity: 0.78,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
side: THREE.DoubleSide
|
||||
})
|
||||
const hallGlowSize = Math.max(getPoiMarkerSize() * 14, getActiveModelSpan() * 0.14)
|
||||
const geometry = new THREE.PlaneGeometry(hallGlowSize, hallGlowSize * 0.72)
|
||||
const mesh = new THREE.Mesh(geometry, material)
|
||||
const [x, y, z] = poi.positionGltf
|
||||
|
||||
mesh.name = 'GuideFocusHallGlow'
|
||||
mesh.position.set(x, y + 0.08, z)
|
||||
mesh.rotation.x = -Math.PI / 2
|
||||
mesh.renderOrder = 7
|
||||
|
||||
return mesh
|
||||
}
|
||||
|
||||
const showFocusHallHighlight = (poi: RenderPoi) => {
|
||||
clearFocusHallHighlight()
|
||||
if (!activeModel) return
|
||||
|
||||
activeFocusHallGlowMesh = createFocusHallGlowMesh(poi)
|
||||
if (activeFocusHallGlowMesh) {
|
||||
poiGroup?.add(activeFocusHallGlowMesh)
|
||||
}
|
||||
|
||||
const modelRoot = findPoiModelRoot(poi)
|
||||
if (!modelRoot) return
|
||||
|
||||
const seenMaterials = new Set<THREE.Material>()
|
||||
let matchedMeshCount = 0
|
||||
|
||||
modelRoot.traverse((child) => {
|
||||
if (!(child instanceof THREE.Mesh) || !child.visible) return
|
||||
|
||||
matchedMeshCount += 1
|
||||
const materials = Array.isArray(child.material) ? child.material : [child.material]
|
||||
materials.forEach((material) => {
|
||||
if (material) {
|
||||
applyFocusHallMaterial(material, seenMaterials)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (!matchedMeshCount) {
|
||||
clearFocusHallHighlight()
|
||||
}
|
||||
}
|
||||
|
||||
const disposeCachedOverviewModel = () => {
|
||||
if (!cachedOverviewModel) return
|
||||
|
||||
@@ -840,6 +1092,7 @@ const renderRoutePreview = () => {
|
||||
|
||||
const clearSceneData = () => {
|
||||
if (activeModel && scene) {
|
||||
clearFocusHallHighlight()
|
||||
scene.remove(activeModel)
|
||||
if (activeModel === cachedOverviewModel) {
|
||||
cachedOverviewModel.visible = false
|
||||
@@ -1390,8 +1643,10 @@ const createPoiLabelSprite = (poi: RenderPoi, markerSize: number) => {
|
||||
depthWrite: false
|
||||
}))
|
||||
sprite.userData.isPoiLabel = true
|
||||
sprite.userData.labelBaseScaleX = markerSize * 5.6
|
||||
sprite.userData.labelBaseScaleY = markerSize * 1.68
|
||||
sprite.renderOrder = 30
|
||||
sprite.scale.set(markerSize * 5.6, markerSize * 1.68, 1)
|
||||
sprite.scale.set(sprite.userData.labelBaseScaleX, sprite.userData.labelBaseScaleY, 1)
|
||||
|
||||
return sprite
|
||||
}
|
||||
@@ -1501,6 +1756,7 @@ const showFocusPoiLabel = (poi: RenderPoi) => {
|
||||
const [x, y, z] = poi.positionGltf
|
||||
activeFocusLabelSprite = createPoiLabelSprite(poi, markerSize)
|
||||
activeFocusLabelSprite.position.set(x, y + markerSize * 2.35, z)
|
||||
updateFocusLabelScale()
|
||||
poiGroup.add(activeFocusLabelSprite)
|
||||
}
|
||||
|
||||
@@ -1529,6 +1785,7 @@ const showFocusPoiPulse = (poi: RenderPoi) => {
|
||||
}
|
||||
|
||||
const showFocusPoiAffordances = (poi: RenderPoi) => {
|
||||
showFocusHallHighlight(poi)
|
||||
showFocusPoiBase(poi)
|
||||
showFocusPoiPulse(poi)
|
||||
showFocusPoiLabel(poi)
|
||||
@@ -1552,6 +1809,28 @@ 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)
|
||||
}
|
||||
|
||||
const setPoiSpriteFocusStyle = (sprite: THREE.Sprite, focused: boolean) => {
|
||||
const userData = getPoiSpriteUserData(sprite)
|
||||
const baseScale = typeof userData.baseScale === 'number'
|
||||
@@ -1603,7 +1882,8 @@ const createPoiFromFocusRequest = (request: TargetPoiFocusRequest): RenderPoi |
|
||||
primaryCategory: 'target_preview',
|
||||
primaryCategoryZh: request.primaryCategoryZh || '位置预览',
|
||||
iconType: 'target_preview',
|
||||
positionGltf: request.positionGltf
|
||||
positionGltf: request.positionGltf,
|
||||
sourceObjectName: request.sourceObjectName
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1654,6 +1934,7 @@ const loadFloorPOIs = async (floor: FloorIndexItem, loadToken?: number) => {
|
||||
const clearMapSelection = (shouldEmit = true) => {
|
||||
activeFocusPoiId.value = ''
|
||||
selectedPOI.value = null
|
||||
clearFocusHallHighlight()
|
||||
disposeFocusLabel()
|
||||
disposeFocusPulse()
|
||||
disposeFocusBase()
|
||||
@@ -1779,7 +2060,12 @@ const focusCameraOnPoi = (poi: RenderPoi) => {
|
||||
? new THREE.Box3().setFromObject(activeModel).getSize(new THREE.Vector3())
|
||||
: new THREE.Vector3(80, 80, 80)
|
||||
const maxDim = Math.max(modelSize.x, modelSize.y, modelSize.z, 1)
|
||||
const distance = Math.min(Math.max(maxDim * 0.22, 28), Math.max(maxDim * 0.55, 80))
|
||||
const focusDistanceFactor = Math.max(props.targetFocusDistanceFactor, 0.1)
|
||||
const focusMaxDistanceFactor = Math.max(0.55, focusDistanceFactor * 1.4)
|
||||
const distance = Math.min(
|
||||
Math.max(maxDim * focusDistanceFactor, 28),
|
||||
Math.max(maxDim * focusMaxDistanceFactor, 80)
|
||||
)
|
||||
const direction = new THREE.Vector3(0.72, 0.58, 1).normalize()
|
||||
|
||||
camera.position.copy(target).add(direction.multiplyScalar(distance))
|
||||
@@ -2036,6 +2322,10 @@ watch(() => props.modelSource, () => {
|
||||
init3DScene()
|
||||
})
|
||||
|
||||
watch(() => [props.showControls, props.touchGestureMode], () => {
|
||||
syncControlInteractionOptions()
|
||||
})
|
||||
|
||||
watch(() => props.targetFocus, (request) => {
|
||||
queueTargetFocus(request || null)
|
||||
}, {
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
:show-controls="false"
|
||||
:show-poi="shouldShowIndoorPois"
|
||||
:target-focus="targetFocusRequest"
|
||||
:touch-gesture-mode="touchGestureMode"
|
||||
:target-focus-distance-factor="targetFocusDistanceFactor"
|
||||
:route-preview="routePreview"
|
||||
:show-route="showRoute"
|
||||
@floor-change="handleThreeFloorChange"
|
||||
@@ -216,6 +218,7 @@ interface TargetPoiFocusRequest {
|
||||
floorLabel?: string
|
||||
primaryCategoryZh?: string
|
||||
positionGltf?: [number, number, number]
|
||||
sourceObjectName?: string
|
||||
}
|
||||
|
||||
interface TargetPoiFocusResult {
|
||||
@@ -228,6 +231,7 @@ interface TargetPoiFocusResult {
|
||||
|
||||
type IndoorViewMode = 'overview' | 'floor' | 'multi'
|
||||
type LayerDisplayMode = 'single' | 'multi'
|
||||
type TouchGestureMode = 'orbit' | 'pan'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
searchText?: string
|
||||
@@ -267,6 +271,8 @@ const props = withDefaults(defineProps<{
|
||||
normalizeFloorId?: (labelOrId: string) => string
|
||||
dimmed?: boolean
|
||||
targetFocusRequest?: TargetPoiFocusRequest | null
|
||||
touchGestureMode?: TouchGestureMode
|
||||
targetFocusDistanceFactor?: number
|
||||
routePreview?: GuideRouteResult | null
|
||||
showRoute?: boolean
|
||||
}>(), {
|
||||
@@ -307,6 +313,8 @@ const props = withDefaults(defineProps<{
|
||||
normalizeFloorId: (labelOrId: string) => labelOrId,
|
||||
dimmed: false,
|
||||
targetFocusRequest: null,
|
||||
touchGestureMode: 'orbit',
|
||||
targetFocusDistanceFactor: 0.22,
|
||||
routePreview: null,
|
||||
showRoute: false
|
||||
})
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<template>
|
||||
<view class="guide-page-frame" :class="`variant-${variant}`">
|
||||
<view
|
||||
class="guide-page-frame"
|
||||
:class="[`variant-${variant}`, { 'no-top-tabs': !showTopTabs }]"
|
||||
>
|
||||
<GuideTopTabs
|
||||
v-if="showTopTabs"
|
||||
:active-tab="activeTab"
|
||||
:variant="topTabsVariant"
|
||||
:top="topTabsTop"
|
||||
@@ -40,6 +44,7 @@ withDefaults(defineProps<{
|
||||
variant?: 'overlay' | 'static'
|
||||
topTabsVariant?: 'underline' | 'segmented'
|
||||
topTabsTop?: string
|
||||
showTopTabs?: boolean
|
||||
showBack?: boolean
|
||||
showCancel?: boolean
|
||||
backLabel?: string
|
||||
@@ -49,6 +54,7 @@ withDefaults(defineProps<{
|
||||
variant: 'overlay',
|
||||
topTabsVariant: 'underline',
|
||||
topTabsTop: '0',
|
||||
showTopTabs: true,
|
||||
showBack: false,
|
||||
showCancel: false,
|
||||
backLabel: '返回',
|
||||
@@ -102,6 +108,10 @@ const handleCancelTap = () => {
|
||||
background: var(--museum-bg-light);
|
||||
}
|
||||
|
||||
.variant-static.no-top-tabs .guide-page-frame-body {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.guide-page-frame-actions {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
Reference in New Issue
Block a user