chore: initialize frontend miniapp repository
This commit is contained in:
831
src/components/map/ThreeMap.vue
Normal file
831
src/components/map/ThreeMap.vue
Normal file
@@ -0,0 +1,831 @@
|
||||
<template>
|
||||
<view class="three-map-container">
|
||||
<view ref="containerRef" class="three-canvas-wrapper"></view>
|
||||
|
||||
<view v-if="isLoading && !loadError" class="loading-overlay">
|
||||
<view class="loading-content">
|
||||
<view class="loading-spinner"></view>
|
||||
<text class="loading-text">{{ loadingMessage }}</text>
|
||||
<view class="loading-progress">
|
||||
<view class="loading-bar" :style="{ width: `${loadingProgress}%` }"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="loadError" class="loading-overlay error-overlay">
|
||||
<view class="loading-content error-content">
|
||||
<text class="error-title">3D 模型加载失败</text>
|
||||
<text class="error-text">{{ loadingMessage }}</text>
|
||||
<view class="retry-btn" @tap="retryLoad">
|
||||
<text class="retry-text">重新加载</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="showControls && floors.length" class="map-toolbar">
|
||||
<view class="overview-btn" :class="{ active: activeView === 'overview' }" @tap="showOverview">
|
||||
<text class="overview-text">全馆</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<FloorSwitcher
|
||||
v-if="showControls && floors.length"
|
||||
:floors="floors"
|
||||
:current-floor="currentFloor"
|
||||
@floor-change="handleFloorChange"
|
||||
/>
|
||||
|
||||
<view v-if="showControls && selectedPOI" class="poi-detail-popup">
|
||||
<view class="poi-content">
|
||||
<text class="poi-name">{{ selectedPOI.name }}</text>
|
||||
<text class="poi-floor">{{ formatFloorLabel(selectedPOI.floorId) }} · {{ selectedPOI.primaryCategoryZh }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import * as THREE from 'three'
|
||||
import { GLTFLoader, type GLTF } from 'three/addons/loaders/GLTFLoader.js'
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
|
||||
import FloorSwitcher from './FloorSwitcher.vue'
|
||||
import { NAV_ASSET_BASE_URL } from '@/services/navAssets'
|
||||
|
||||
type ViewMode = 'overview' | 'floor'
|
||||
|
||||
interface ManifestFloorModel {
|
||||
floorId: string
|
||||
order: number
|
||||
asset: string
|
||||
}
|
||||
|
||||
interface CleanNavManifest {
|
||||
status: string
|
||||
assets: {
|
||||
overviewModel: {
|
||||
asset: string
|
||||
}
|
||||
floorModels: ManifestFloorModel[]
|
||||
}
|
||||
data: {
|
||||
floorIndex: string
|
||||
}
|
||||
}
|
||||
|
||||
interface FloorIndexItem {
|
||||
floorId: string
|
||||
order: number
|
||||
modelAsset: string
|
||||
poiDataAsset: string
|
||||
poiCount: number
|
||||
}
|
||||
|
||||
interface FloorIndexData {
|
||||
status: string
|
||||
floors: FloorIndexItem[]
|
||||
}
|
||||
|
||||
interface FloorOption {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface CleanPoi {
|
||||
id: string
|
||||
name: string
|
||||
floorId: string
|
||||
primaryCategory: string
|
||||
primaryCategoryZh: string
|
||||
iconType: string
|
||||
positionGltf?: [number, number, number]
|
||||
}
|
||||
|
||||
interface FloorPoiData {
|
||||
status: string
|
||||
floorId: string
|
||||
pois: CleanPoi[]
|
||||
}
|
||||
|
||||
type ContainerRef = HTMLElement | { $el?: HTMLElement } | null
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
assetBaseUrl?: string
|
||||
initialFloorId?: string
|
||||
initialView?: ViewMode
|
||||
showControls?: boolean
|
||||
showPoi?: boolean
|
||||
}>(), {
|
||||
assetBaseUrl: NAV_ASSET_BASE_URL,
|
||||
initialFloorId: 'L1',
|
||||
initialView: 'overview',
|
||||
showControls: true,
|
||||
showPoi: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
floorChange: [floorId: string]
|
||||
poiClick: [poi: CleanPoi]
|
||||
}>()
|
||||
|
||||
const containerRef = ref<ContainerRef>(null)
|
||||
const isLoading = ref(true)
|
||||
const loadError = ref(false)
|
||||
const loadingProgress = ref(0)
|
||||
const loadingMessage = ref('正在读取室内导览资源...')
|
||||
const activeView = ref<ViewMode>(props.initialView)
|
||||
const currentFloor = ref(props.initialFloorId)
|
||||
const selectedPOI = ref<CleanPoi | null>(null)
|
||||
const floorIndex = ref<FloorIndexItem[]>([])
|
||||
|
||||
const floors = computed<FloorOption[]>(() => (
|
||||
[...floorIndex.value]
|
||||
.sort((a, b) => b.order - a.order)
|
||||
.map((floor) => ({
|
||||
id: floor.floorId,
|
||||
label: formatFloorLabel(floor.floorId)
|
||||
}))
|
||||
))
|
||||
|
||||
let manifest: CleanNavManifest | null = null
|
||||
let scene: THREE.Scene | null = null
|
||||
let camera: THREE.PerspectiveCamera | null = null
|
||||
let renderer: THREE.WebGLRenderer | null = null
|
||||
let controls: OrbitControls | null = null
|
||||
let loader: GLTFLoader | null = null
|
||||
let activeModel: THREE.Object3D | null = null
|
||||
let poiGroup: THREE.Group | null = null
|
||||
let animationId = 0
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let isDisposed = false
|
||||
|
||||
const normalizeBaseUrl = (baseUrl: string) => baseUrl.replace(/\/+$/, '')
|
||||
|
||||
const assetUrl = (relativePath: string) => `${normalizeBaseUrl(props.assetBaseUrl)}/${relativePath.replace(/^\/+/, '')}`
|
||||
|
||||
const getContainerElement = () => {
|
||||
const container = containerRef.value
|
||||
if (!container) return null
|
||||
|
||||
if (typeof HTMLElement === 'undefined') return null
|
||||
|
||||
if (container instanceof HTMLElement) {
|
||||
return container
|
||||
}
|
||||
|
||||
const element = '$el' in container ? container.$el : null
|
||||
return element instanceof HTMLElement ? element : null
|
||||
}
|
||||
|
||||
const formatFloorLabel = (floorId: string) => {
|
||||
if (floorId === 'L-2') return 'B2'
|
||||
if (floorId === 'L-1') return 'B1'
|
||||
if (floorId === 'L1.5') return '1.5F'
|
||||
if (floorId.startsWith('L')) return `${floorId.slice(1)}F`
|
||||
return floorId
|
||||
}
|
||||
|
||||
const setProgress = (progress: number, message: string) => {
|
||||
loadingProgress.value = Math.min(100, Math.max(0, progress))
|
||||
loadingMessage.value = message
|
||||
}
|
||||
|
||||
const fetchJson = async <T,>(url: string): Promise<T> => {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`资源读取失败: ${response.status} ${url}`)
|
||||
}
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
const waitForContainer = async () => {
|
||||
await nextTick()
|
||||
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
const container = getContainerElement()
|
||||
if (container && container.clientWidth > 0 && container.clientHeight > 0) {
|
||||
return container
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
}
|
||||
|
||||
return getContainerElement()
|
||||
}
|
||||
|
||||
const initThree = async () => {
|
||||
const container = await waitForContainer()
|
||||
if (!container) {
|
||||
throw new Error('3D 容器未就绪')
|
||||
}
|
||||
|
||||
scene = new THREE.Scene()
|
||||
scene.background = new THREE.Color(0xf1f3ee)
|
||||
|
||||
const width = Math.max(1, container.clientWidth)
|
||||
const height = Math.max(1, container.clientHeight)
|
||||
camera = new THREE.PerspectiveCamera(42, width / height, 0.1, 10000)
|
||||
camera.position.set(80, 80, 120)
|
||||
|
||||
renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
powerPreference: 'high-performance'
|
||||
})
|
||||
renderer.setSize(width, height)
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping
|
||||
renderer.toneMappingExposure = 1.05
|
||||
container.appendChild(renderer.domElement)
|
||||
|
||||
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
|
||||
|
||||
loader = new GLTFLoader()
|
||||
poiGroup = new THREE.Group()
|
||||
poiGroup.name = 'CleanPackagePOI'
|
||||
scene.add(poiGroup)
|
||||
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 1.6)
|
||||
scene.add(ambientLight)
|
||||
|
||||
const keyLight = new THREE.DirectionalLight(0xffffff, 2.2)
|
||||
keyLight.position.set(80, 120, 60)
|
||||
scene.add(keyLight)
|
||||
|
||||
const fillLight = new THREE.DirectionalLight(0xdfeaff, 0.9)
|
||||
fillLight.position.set(-80, 60, -80)
|
||||
scene.add(fillLight)
|
||||
|
||||
resizeObserver = new ResizeObserver(handleResize)
|
||||
resizeObserver.observe(container)
|
||||
container.addEventListener('pointerdown', handlePointerDown)
|
||||
|
||||
startRenderLoop()
|
||||
}
|
||||
|
||||
const startRenderLoop = () => {
|
||||
const render = () => {
|
||||
if (isDisposed || !renderer || !scene || !camera) return
|
||||
|
||||
controls?.update()
|
||||
renderer.render(scene, camera)
|
||||
animationId = window.requestAnimationFrame(render)
|
||||
}
|
||||
|
||||
render()
|
||||
}
|
||||
|
||||
const handleResize = () => {
|
||||
const container = getContainerElement()
|
||||
if (!container || !camera || !renderer) return
|
||||
|
||||
const width = Math.max(1, container.clientWidth)
|
||||
const height = Math.max(1, container.clientHeight)
|
||||
camera.aspect = width / height
|
||||
camera.updateProjectionMatrix()
|
||||
renderer.setSize(width, height)
|
||||
}
|
||||
|
||||
const disposeObject = (object: THREE.Object3D) => {
|
||||
object.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.geometry?.dispose()
|
||||
const materials = Array.isArray(child.material) ? child.material : [child.material]
|
||||
materials.forEach((material) => material?.dispose())
|
||||
}
|
||||
|
||||
if (child instanceof THREE.Sprite) {
|
||||
child.material.map?.dispose()
|
||||
child.material.dispose()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const clearSceneData = () => {
|
||||
if (activeModel && scene) {
|
||||
scene.remove(activeModel)
|
||||
disposeObject(activeModel)
|
||||
}
|
||||
|
||||
activeModel = null
|
||||
selectedPOI.value = null
|
||||
|
||||
if (poiGroup) {
|
||||
while (poiGroup.children.length) {
|
||||
const child = poiGroup.children[0]
|
||||
poiGroup.remove(child)
|
||||
disposeObject(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loadModel = (url: string, label: string) => new Promise<GLTF>((resolve, reject) => {
|
||||
if (!loader) {
|
||||
reject(new Error('GLTF 加载器未初始化'))
|
||||
return
|
||||
}
|
||||
|
||||
loader.load(
|
||||
url,
|
||||
resolve,
|
||||
(event) => {
|
||||
if (event.total > 0) {
|
||||
const modelProgress = Math.round((event.loaded / event.total) * 70)
|
||||
setProgress(20 + modelProgress, `${label}: ${Math.round((event.loaded / event.total) * 100)}%`)
|
||||
} else {
|
||||
setProgress(45, label)
|
||||
}
|
||||
},
|
||||
reject
|
||||
)
|
||||
})
|
||||
|
||||
const prepareModel = (model: THREE.Object3D) => {
|
||||
model.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.castShadow = false
|
||||
child.receiveShadow = true
|
||||
child.frustumCulled = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const fitCameraToObject = (object: THREE.Object3D) => {
|
||||
if (!camera || !controls) return
|
||||
|
||||
const box = new THREE.Box3().setFromObject(object)
|
||||
const center = box.getCenter(new THREE.Vector3())
|
||||
const size = box.getSize(new THREE.Vector3())
|
||||
const maxDim = Math.max(size.x, size.y, size.z, 1)
|
||||
const fov = camera.fov * (Math.PI / 180)
|
||||
const distance = Math.abs(maxDim / Math.sin(fov / 2)) * 0.58
|
||||
const direction = new THREE.Vector3(0.85, 0.62, 1).normalize()
|
||||
|
||||
camera.near = Math.max(0.1, maxDim / 1000)
|
||||
camera.far = Math.max(10000, maxDim * 20)
|
||||
camera.position.copy(center).add(direction.multiplyScalar(distance))
|
||||
camera.updateProjectionMatrix()
|
||||
|
||||
controls.target.copy(center)
|
||||
controls.minDistance = Math.max(2, maxDim * 0.08)
|
||||
controls.maxDistance = Math.max(20, maxDim * 8)
|
||||
controls.update()
|
||||
}
|
||||
|
||||
const loadOverview = async () => {
|
||||
if (!manifest || !scene) return
|
||||
|
||||
activeView.value = 'overview'
|
||||
clearSceneData()
|
||||
setProgress(18, '正在加载全馆三维模型...')
|
||||
|
||||
const gltf = await loadModel(assetUrl(manifest.assets.overviewModel.asset), '正在加载全馆三维模型')
|
||||
activeModel = gltf.scene
|
||||
activeModel.name = 'CleanOverviewModel'
|
||||
prepareModel(activeModel)
|
||||
scene.add(activeModel)
|
||||
fitCameraToObject(activeModel)
|
||||
}
|
||||
|
||||
const loadFloor = async (floorId: string) => {
|
||||
const floor = floorIndex.value.find((item) => item.floorId === floorId)
|
||||
if (!floor || !scene) return
|
||||
|
||||
activeView.value = 'floor'
|
||||
currentFloor.value = floor.floorId
|
||||
clearSceneData()
|
||||
setProgress(18, `正在加载 ${formatFloorLabel(floor.floorId)} 模型...`)
|
||||
|
||||
const gltf = await loadModel(assetUrl(floor.modelAsset), `正在加载 ${formatFloorLabel(floor.floorId)} 模型`)
|
||||
activeModel = gltf.scene
|
||||
activeModel.name = `CleanFloorModel_${floor.floorId}`
|
||||
prepareModel(activeModel)
|
||||
scene.add(activeModel)
|
||||
fitCameraToObject(activeModel)
|
||||
|
||||
if (props.showPoi || props.showControls) {
|
||||
await loadFloorPOIs(floor)
|
||||
}
|
||||
}
|
||||
|
||||
const createPoiMaterial = (poi: CleanPoi) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 96
|
||||
canvas.height = 96
|
||||
const context = canvas.getContext('2d')
|
||||
|
||||
if (context) {
|
||||
const color = getPoiColor(poi.primaryCategory)
|
||||
context.clearRect(0, 0, canvas.width, canvas.height)
|
||||
context.beginPath()
|
||||
context.arc(48, 42, 24, 0, Math.PI * 2)
|
||||
context.fillStyle = color
|
||||
context.fill()
|
||||
context.lineWidth = 6
|
||||
context.strokeStyle = '#ffffff'
|
||||
context.stroke()
|
||||
|
||||
context.beginPath()
|
||||
context.moveTo(48, 76)
|
||||
context.lineTo(36, 54)
|
||||
context.lineTo(60, 54)
|
||||
context.closePath()
|
||||
context.fillStyle = color
|
||||
context.fill()
|
||||
}
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas)
|
||||
texture.colorSpace = THREE.SRGBColorSpace
|
||||
return new THREE.SpriteMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
depthTest: false,
|
||||
depthWrite: false
|
||||
})
|
||||
}
|
||||
|
||||
const getPoiColor = (category: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
touring_poi: '#2f6fed',
|
||||
basic_service_facility: '#1f8f5f',
|
||||
transport_circulation: '#d77b20',
|
||||
accessibility_special_service: '#8a5cf6',
|
||||
operation_experience: '#cf3f59'
|
||||
}
|
||||
|
||||
return colorMap[category] || '#1f2329'
|
||||
}
|
||||
|
||||
const loadFloorPOIs = async (floor: FloorIndexItem) => {
|
||||
if (!poiGroup) return
|
||||
|
||||
const data = await fetchJson<FloorPoiData>(assetUrl(floor.poiDataAsset))
|
||||
const validPois = data.pois.filter((poi) => Array.isArray(poi.positionGltf) && poi.positionGltf.length === 3)
|
||||
const markerSize = activeModel ? Math.max(new THREE.Box3().setFromObject(activeModel).getSize(new THREE.Vector3()).length() * 0.012, 2.4) : 3
|
||||
|
||||
validPois.forEach((poi) => {
|
||||
const [x, y, z] = poi.positionGltf!
|
||||
const sprite = new THREE.Sprite(createPoiMaterial(poi))
|
||||
sprite.position.set(x, y + markerSize * 0.5, z)
|
||||
sprite.scale.set(markerSize, markerSize, markerSize)
|
||||
sprite.renderOrder = 10
|
||||
sprite.userData.poi = poi
|
||||
poiGroup!.add(sprite)
|
||||
})
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (!props.showControls || !camera || !renderer || !poiGroup || !getContainerElement()) return
|
||||
|
||||
const rect = renderer.domElement.getBoundingClientRect()
|
||||
const pointer = new THREE.Vector2(
|
||||
((event.clientX - rect.left) / rect.width) * 2 - 1,
|
||||
-((event.clientY - rect.top) / rect.height) * 2 + 1
|
||||
)
|
||||
const raycaster = new THREE.Raycaster()
|
||||
raycaster.setFromCamera(pointer, camera)
|
||||
const hits = raycaster.intersectObjects(poiGroup.children, false)
|
||||
|
||||
if (hits[0]?.object.userData.poi) {
|
||||
selectedPOI.value = hits[0].object.userData.poi as CleanPoi
|
||||
emit('poiClick', selectedPOI.value)
|
||||
}
|
||||
}
|
||||
|
||||
const loadCleanPackage = async () => {
|
||||
setProgress(8, '正在读取室内导览资源...')
|
||||
manifest = await fetchJson<CleanNavManifest>(assetUrl('app_nav_manifest.json'))
|
||||
|
||||
if (manifest.status !== 'pass') {
|
||||
throw new Error('室内导览资源包状态不是 pass')
|
||||
}
|
||||
|
||||
setProgress(14, '正在读取楼层索引...')
|
||||
const floorData = await fetchJson<FloorIndexData>(assetUrl(manifest.data.floorIndex))
|
||||
floorIndex.value = [...floorData.floors].sort((a, b) => a.order - b.order)
|
||||
|
||||
if (!floorIndex.value.some((floor) => floor.floorId === currentFloor.value)) {
|
||||
currentFloor.value = floorIndex.value.find((floor) => floor.floorId === 'L1')?.floorId || floorIndex.value[0]?.floorId || 'L1'
|
||||
}
|
||||
|
||||
if (props.initialView === 'floor') {
|
||||
await loadFloor(currentFloor.value)
|
||||
return
|
||||
}
|
||||
|
||||
await loadOverview()
|
||||
}
|
||||
|
||||
const init3DScene = async () => {
|
||||
try {
|
||||
disposeScene()
|
||||
isDisposed = false
|
||||
isLoading.value = true
|
||||
loadError.value = false
|
||||
selectedPOI.value = null
|
||||
setProgress(0, '正在初始化三维场景...')
|
||||
|
||||
await initThree()
|
||||
await loadCleanPackage()
|
||||
|
||||
setProgress(100, '室内三维模型加载完成')
|
||||
isLoading.value = false
|
||||
} catch (error) {
|
||||
console.error('室内 3D clean package 加载失败:', error)
|
||||
loadError.value = true
|
||||
isLoading.value = false
|
||||
setProgress(0, error instanceof Error ? error.message : '请检查模型资源或网络状态后重试')
|
||||
}
|
||||
}
|
||||
|
||||
const handleFloorChange = async (floorId: string) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
loadError.value = false
|
||||
await loadFloor(floorId)
|
||||
isLoading.value = false
|
||||
emit('floorChange', floorId)
|
||||
} catch (error) {
|
||||
console.error('楼层模型加载失败:', error)
|
||||
loadError.value = true
|
||||
isLoading.value = false
|
||||
setProgress(0, error instanceof Error ? error.message : '楼层模型加载失败')
|
||||
}
|
||||
}
|
||||
|
||||
const showOverview = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
loadError.value = false
|
||||
await loadOverview()
|
||||
isLoading.value = false
|
||||
} catch (error) {
|
||||
console.error('全馆模型加载失败:', error)
|
||||
loadError.value = true
|
||||
isLoading.value = false
|
||||
setProgress(0, error instanceof Error ? error.message : '全馆模型加载失败')
|
||||
}
|
||||
}
|
||||
|
||||
const retryLoad = () => {
|
||||
init3DScene()
|
||||
}
|
||||
|
||||
const disposeScene = () => {
|
||||
isDisposed = true
|
||||
|
||||
if (animationId) {
|
||||
window.cancelAnimationFrame(animationId)
|
||||
animationId = 0
|
||||
}
|
||||
|
||||
const container = getContainerElement()
|
||||
container?.removeEventListener('pointerdown', handlePointerDown)
|
||||
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
controls?.dispose()
|
||||
controls = null
|
||||
|
||||
clearSceneData()
|
||||
scene?.clear()
|
||||
scene = null
|
||||
camera = null
|
||||
loader = null
|
||||
poiGroup = null
|
||||
|
||||
if (renderer) {
|
||||
renderer.dispose()
|
||||
renderer.forceContextLoss()
|
||||
renderer.domElement.remove()
|
||||
}
|
||||
|
||||
renderer = null
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
switchFloor: handleFloorChange,
|
||||
showOverview,
|
||||
clearNavigation: () => {
|
||||
selectedPOI.value = null
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.assetBaseUrl, () => {
|
||||
init3DScene()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
init3DScene()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
disposeScene()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.three-map-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #f1f3ee;
|
||||
}
|
||||
|
||||
.three-canvas-wrapper {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
:deep(canvas) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
display: block;
|
||||
touch-action: none;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(241, 243, 238, 0.9);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.loading-content {
|
||||
width: min(260px, calc(100vw - 80px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 3px solid rgba(31, 35, 41, 0.16);
|
||||
border-top-color: #1f2329;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
color: #1f2329;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-progress {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
overflow: hidden;
|
||||
background: rgba(31, 35, 41, 0.12);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.loading-bar {
|
||||
height: 100%;
|
||||
background: #1f8f5f;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.error-overlay {
|
||||
background: rgba(31, 35, 41, 0.28);
|
||||
}
|
||||
|
||||
.error-content {
|
||||
padding: 18px;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid rgba(31, 35, 41, 0.1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 16px;
|
||||
line-height: 22px;
|
||||
font-weight: 700;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
color: #5f666d;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
min-width: 96px;
|
||||
height: 34px;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
background: #1f2329;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.retry-text {
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.map-toolbar {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 12;
|
||||
}
|
||||
|
||||
.overview-btn {
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
border: 1px solid rgba(31, 35, 41, 0.12);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 14px rgba(31, 35, 41, 0.12);
|
||||
}
|
||||
|
||||
.overview-btn.active {
|
||||
background: #1f2329;
|
||||
}
|
||||
|
||||
.overview-text {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
font-weight: 700;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.overview-btn.active .overview-text {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.poi-detail-popup {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 118px;
|
||||
width: min(280px, calc(100vw - 64px));
|
||||
padding: 12px 14px;
|
||||
transform: translateX(-50%);
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid rgba(31, 35, 41, 0.1);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 28px rgba(31, 35, 41, 0.16);
|
||||
z-index: 14;
|
||||
}
|
||||
|
||||
.poi-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.poi-name {
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
font-weight: 700;
|
||||
color: #1f2329;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.poi-floor {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: #5f666d;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user