diff --git a/.env.development b/.env.development index 0b243f1..31263c9 100644 --- a/.env.development +++ b/.env.development @@ -24,9 +24,10 @@ VITE_PUBLIC_SAME_ORIGIN_ASSET_HOST= VITE_PUBLIC_LEGACY_AUDIO_HOST= # Vite 开发服务器代理目标,只由 vite.config.ts 读取。 -DEV_PROXY_APP_API_TARGET=http://1.92.206.90:3001 -DEV_PROXY_ENGINE_TARGET=http://1.92.206.90:3001 -DEV_PROXY_SDK_TARGET=http://1.92.206.90:3001 +# /engine 由 public/engine 本地静态资源提供,保持和大屏端一致,不再代理到地图管理端。 +DEV_PROXY_APP_API_TARGET=http://localhost:48080 +DEV_PROXY_ENGINE_TARGET= +DEV_PROXY_SDK_TARGET= DEV_PROXY_MUSEUM_ASSETS_TARGET=http://1.92.206.90:9000 -DEV_PROXY_MINIO_TARGET=http://1.92.206.90:3001 -DEV_PROXY_AUDIO_TARGET=http://1.92.206.90:19000 +DEV_PROXY_MINIO_TARGET=http://localhost:48080 +DEV_PROXY_AUDIO_TARGET=http://localhost:19000 diff --git a/.env.test b/.env.test index 6629b93..a8cedd3 100644 --- a/.env.test +++ b/.env.test @@ -16,7 +16,8 @@ VITE_SGS_API_BASE_URL=/app-api VITE_SGS_MAP_ID=1 VITE_SGS_SDK_SCRIPT_URL=/static/sgs-map-sdk/index.global.js?v=2.5.0 VITE_SGS_H5_ENGINE_URL=/engine/index.html -VITE_SGS_SDK_ORIGIN=https://guide.whaoyue.com +# 测试构建使用同源 Engine;避免把历史测试域名写入产物。 +VITE_SGS_SDK_ORIGIN= VITE_SGS_SDK_TIMEOUT_MS=30000 VITE_TENCENT_MAP_KEY=__REPLACE_WITH_TENCENT_MAP_WEB_KEY__ VITE_PUBLIC_SAME_ORIGIN_ASSET_HOST= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3aaf752..7e9ae80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: run: pnpm lint - name: Build H5 - run: pnpm build:test:h5 + run: pnpm build:h5 - name: Build WeChat Mini Program run: pnpm build:mp-weixin diff --git a/README.md b/README.md index ce845e8..0d7293f 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ VITE_SGS_API_BASE_URL=/app-api VITE_AUDIO_API_BASE_URL=/yudao-server VITE_AUDIO_LANGUAGE=zh-CN -# SGS SDK/H5 地图基座配置;当前代码尚未把 SDK renderer 接入页面渲染 +# SGS SDK/H5 地图基座配置;/engine 由本项目 public/engine 静态资源提供 VITE_SGS_MAP_ID=1 VITE_SGS_SDK_SCRIPT_URL=/static/sgs-map-sdk/index.global.js?v=2.5.0 VITE_SGS_H5_ENGINE_URL=/engine/index.html @@ -142,6 +142,9 @@ VITE_SGS_SDK_TIMEOUT_MS=5000 ```bash pnpm install +# 同步本地 SDK Engine 静态底座 +pnpm sync:sdk-engine + # H5 开发 pnpm dev:h5 @@ -177,7 +180,7 @@ uni build -p h5 && node scripts/copy-h5-nav-assets.cjs - `dist/build/h5` 存在应用产物。 - H5 可访问 `static/nav-assets/...` 下的 GLB/GLTF/bin/texture/manifest 文件。 - H5 可访问 `static/guide-data` 下的讲解和内容数据。 -- 如启用 `api` 或 `sdk` 模式,Nginx/网关需代理 `/app-api`、`/yudao-server`、`/engine` 等路径。`/engine/index.html` 必须是 SGS Map SDK Engine 2.5.x 的独立发布页面,且其资源路径可加载并能完成 `HELLO` -> `ENGINE_READY`;业务 SPA fallback 返回的 200 不是 Engine 健康。 +- 如启用 `api` 或 `sdk` 模式,Nginx/网关需代理 `/app-api`、`/yudao-server` 等后端路径。`/engine/index.html` 由本项目静态资源独立提供,必须是 SGS Map SDK Engine 2.5.x 的发布页面,且其资源路径可加载并能完成 `HELLO` -> `ENGINE_READY`;业务 SPA fallback 返回的 200 不是 Engine 健康。 更完整的部署说明见 `docs/H5_DEPLOYMENT_GUIDE.md`。 diff --git a/docs/H5_DEPLOYMENT_GUIDE.md b/docs/H5_DEPLOYMENT_GUIDE.md index 1590576..e5369cf 100644 --- a/docs/H5_DEPLOYMENT_GUIDE.md +++ b/docs/H5_DEPLOYMENT_GUIDE.md @@ -24,6 +24,17 @@ pnpm type-check pnpm build:h5 ``` +生产构建必须通过部署环境变量注入真实腾讯地图 Web Key,Key 不提交到仓库: + +```powershell +$env:VITE_TENCENT_MAP_KEY = '<真实腾讯地图 Web Key>' +pnpm build:h5 +Remove-Item Env:VITE_TENCENT_MAP_KEY +``` + +构建脚本会拒绝 Key 占位符、历史测试域名和旧 MinIO 地址;测试构建可使用 +`pnpm build:test:h5`,该命令只允许保留地图 Key 占位符,不允许旧域名进入产物。 + `pnpm build:h5` 会先执行 `uni build -p h5`,再执行 `scripts/copy-h5-nav-assets.cjs`,把 `static/nav-assets` 复制到 `dist/build/h5/static/nav-assets`。 构建后重点检查: @@ -95,6 +106,39 @@ location ^~ /static/nav-assets/ { try_files $uri =404; } +# Vite 产物文件名带内容 hash,可以长时间缓存且不会污染新版本。 +location ^~ /assets/ { + expires 365d; + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; +} + +# SDK、Engine、讲解图片和音频使用带版本/日期的路径,缓存 30 天。 +location = /engine/index.html { + expires -1; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + try_files $uri =404; +} + +location ^~ /engine/ { + expires 30d; + add_header Cache-Control "public, max-age=2592000"; + try_files $uri =404; +} + +location ^~ /static/ { + expires 30d; + add_header Cache-Control "public, max-age=2592000"; + try_files $uri =404; +} + +# HTML 负责指向最新 hash 资源,必须及时重新验证。 +location = /index.html { + expires -1; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + try_files $uri =404; +} + location / { try_files $uri $uri/ /index.html; } diff --git a/index.html b/index.html index 547fb07..41ea3f9 100644 --- a/index.html +++ b/index.html @@ -5,7 +5,7 @@ -
Render only floor code(s), comma separated or repeated
+ --width Output width (default: ${DEFAULT_WIDTH})
+ --height Output height (default: ${DEFAULT_HEIGHT})
+ --padding Reserved framing padding metadata (default: ${DEFAULT_PADDING})
+ --help Show this help
+
+Environment overrides: GUIDE_BASEMAP_API_URL, GUIDE_BASEMAP_MAP_ID,
+GUIDE_BASEMAP_OUTPUT_DIR, GUIDE_BASEMAP_VITE_URL, GUIDE_BASEMAP_FLOOR,
+GUIDE_BASEMAP_WIDTH, GUIDE_BASEMAP_HEIGHT, GUIDE_BASEMAP_PADDING.`
+
+const parseArgs = (argv) => {
+ const values = new Map()
+ const floorCodes = []
+
+ for (let index = 0; index < argv.length; index += 1) {
+ const argument = argv[index]
+ if (argument === '--help' || argument === '-h') return { help: true, values, floorCodes }
+ if (!argument.startsWith('--')) throw new Error(`Unknown argument: ${argument}`)
+
+ const [key, inlineValue] = argument.slice(2).split('=', 2)
+ const value = inlineValue ?? argv[index + 1]
+ if (!value || value.startsWith('--')) throw new Error(`Missing value for --${key}`)
+ if (inlineValue === undefined) index += 1
+
+ if (key === 'floor') {
+ floorCodes.push(...value.split(',').map((item) => item.trim()).filter(Boolean))
+ } else if (['api', 'map-id', 'out-dir', 'vite-url', 'width', 'height', 'padding'].includes(key)) {
+ values.set(key, value)
+ } else {
+ throw new Error(`Unknown argument: --${key}`)
+ }
+ }
+
+ return { help: false, values, floorCodes }
+}
+
+const normalizeOrigin = (value) => value.replace(/\/+$/, '')
+
+const resolveApiRoot = (value) => {
+ const normalized = normalizeOrigin(value)
+ return normalized.endsWith('/app-api') ? normalized : `${normalized}/app-api`
+}
+
+const asPositiveInteger = (value, option) => {
+ const number = Number(value)
+ if (!Number.isInteger(number) || number <= 0) throw new Error(`${option} must be a positive integer`)
+ return number
+}
+
+const asPadding = (value) => {
+ const number = Number(value)
+ if (!Number.isFinite(number) || number < 0 || number > 0.5) {
+ throw new Error('--padding must be between 0 and 0.5')
+ }
+ return number
+}
+
+const safeFilePart = (value) => String(value || 'unknown')
+ .replace(/[^A-Za-z0-9._-]+/g, '_')
+ .replace(/^_+|_+$/g, '') || 'unknown'
+
+const toPublicPath = (absoluteOutputDir, fileName) => {
+ const staticRoot = path.resolve(process.cwd(), 'static')
+ const relative = path.relative(staticRoot, path.join(absoluteOutputDir, fileName))
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
+ throw new Error(`Output directory must be inside ${staticRoot}`)
+ }
+ return `/static/${relative.split(path.sep).join('/')}`
+}
+
+const getJson = async (url) => {
+ const response = await fetch(url)
+ if (!response.ok) throw new Error(`Request failed (${response.status}): ${url}`)
+ const payload = await response.json()
+ if (!payload || payload.code !== 0) {
+ throw new Error(`SDK request failed: ${url} code=${payload?.code ?? 'unknown'} msg=${payload?.msg || ''}`)
+ }
+ return payload.data
+}
+
+const getBinary = async (url) => {
+ const response = await fetch(url)
+ if (!response.ok) throw new Error(`GLB download failed (${response.status}): ${url}`)
+ const contentType = response.headers.get('content-type') || ''
+ const buffer = Buffer.from(await response.arrayBuffer())
+ if (buffer.length < 20) throw new Error(`GLB download is unexpectedly small: ${url}`)
+ return { buffer, contentType }
+}
+
+const resolveModelUrl = (apiOrigin, modelUrl) => {
+ if (/^https?:\/\//i.test(modelUrl)) return modelUrl
+ return new URL(modelUrl, `${apiOrigin}/`).toString()
+}
+
+const createRenderer = async (page, options) => page.evaluate(async ({
+ assetUrl,
+ width,
+ height,
+ padding,
+ floorCode,
+ resourceKind,
+ decoderPath,
+ cameraContract
+}) => {
+ const THREE = await import('/node_modules/three/build/three.module.js')
+ const { GLTFLoader } = await import('/node_modules/three/examples/jsm/loaders/GLTFLoader.js')
+ const { DRACOLoader } = await import('/node_modules/three/examples/jsm/loaders/DRACOLoader.js')
+
+ const decodeGlb = async () => {
+ const response = await fetch(assetUrl)
+ if (!response.ok) throw new Error(`Virtual GLB request failed: ${response.status}`)
+ return response.arrayBuffer()
+ }
+
+ const source = await decodeGlb()
+ const dracoLoader = new DRACOLoader()
+ dracoLoader.setDecoderPath(decoderPath)
+ dracoLoader.setDecoderConfig({ type: 'wasm' })
+ const loader = new GLTFLoader()
+ loader.setDRACOLoader(dracoLoader)
+ const gltf = await new Promise((resolve, reject) => {
+ loader.parse(source, '', resolve, reject)
+ })
+
+ const scene = new THREE.Scene()
+ scene.background = new THREE.Color('#eceff1')
+ const model = gltf.scene
+ model.updateMatrixWorld(true)
+ const bounds = new THREE.Box3().setFromObject(model)
+ if (bounds.isEmpty()) throw new Error('GLB contains no renderable bounds')
+
+ model.traverse((object) => {
+ if (!object.isMesh) return
+ const sourceMaterial = Array.isArray(object.material) ? object.material[0] : object.material
+ object.material = sourceMaterial?.clone?.() || new THREE.MeshStandardMaterial({
+ color: new THREE.Color('#bfd0c6'),
+ side: THREE.DoubleSide
+ })
+ object.castShadow = false
+ object.receiveShadow = false
+ })
+ scene.add(model)
+
+ const modelCenter = bounds.getCenter(new THREE.Vector3())
+ const size = bounds.getSize(new THREE.Vector3())
+ const span = Math.max(size.x, size.y, size.z, 1)
+ const aspect = width / height
+ const yaw = THREE.MathUtils.degToRad(cameraContract.yawDegrees)
+ const screenRight = { x: Math.cos(yaw), z: -Math.sin(yaw) }
+ const screenDown = { x: Math.sin(yaw), z: Math.cos(yaw) }
+ const normalizedFloorCode = String(floorCode).trim().toUpperCase()
+ const isExterior = normalizedFloorCode === 'EXTERIOR'
+ const isComposite = resourceKind === 'route-asset'
+ const isOverviewLike = isExterior || isComposite
+ const floorLevelMatch = /^L(-?\d+(?:\.\d+)?)$/.exec(normalizedFloorCode)
+ const floorLevel = floorLevelMatch ? Number(floorLevelMatch[1]) : Number.NaN
+ const useCompactFloorVisualCenter = isComposite || floorLevel === 1.5 || floorLevel >= 3
+ const defaultDistance = isOverviewLike
+ ? cameraContract.overviewDistance
+ : Math.abs(
+ cameraContract.indoorReferenceFitDimension
+ / Math.sin(THREE.MathUtils.degToRad(cameraContract.fovDegrees) / 2)
+ ) * cameraContract.indoorDistanceFactor
+ const defaultVisibleWorldSpan = 2
+ * defaultDistance
+ * Math.tan(THREE.MathUtils.degToRad(cameraContract.fovDegrees) / 2)
+ * aspect
+ const maxVisibleWorldSpan = defaultVisibleWorldSpan * (
+ isOverviewLike ? cameraContract.overviewMaxDistanceFactor : cameraContract.floorMaxDistanceFactor
+ )
+ const overviewVisibleWorldSpan = 2
+ * cameraContract.overviewDistance
+ * Math.tan(THREE.MathUtils.degToRad(cameraContract.fovDegrees) / 2)
+ * aspect
+ const baseVisualCenter = useCompactFloorVisualCenter
+ ? { x: modelCenter.x, z: modelCenter.z }
+ : cameraContract.target
+ const horizontalScreenOffset = useCompactFloorVisualCenter
+ ? cameraContract.floorScreenOffsetRatioX * defaultVisibleWorldSpan
+ : cameraContract.overviewScreenOffsetRatioX * overviewVisibleWorldSpan
+ const projectionCenter = {
+ x: baseVisualCenter.x + screenRight.x * horizontalScreenOffset,
+ z: baseVisualCenter.z + screenRight.z * horizontalScreenOffset
+ }
+
+ const modelCorners = [
+ { x: bounds.min.x, z: bounds.min.z },
+ { x: bounds.min.x, z: bounds.max.z },
+ { x: bounds.max.x, z: bounds.min.z },
+ { x: bounds.max.x, z: bounds.max.z }
+ ]
+ const modelBasisExtents = modelCorners.reduce((extents, point) => {
+ const deltaX = point.x - projectionCenter.x
+ const deltaZ = point.z - projectionCenter.z
+ return {
+ right: Math.max(extents.right, Math.abs(deltaX * screenRight.x + deltaZ * screenRight.z)),
+ down: Math.max(extents.down, Math.abs(deltaX * screenDown.x + deltaZ * screenDown.z))
+ }
+ }, { right: 0, down: 0 })
+ const requiredWidth = Math.max(2 * modelBasisExtents.right * (1 + padding), 1)
+ const requiredHeight = Math.max(2 * modelBasisExtents.down * (1 + padding), 1)
+ const projectionWidth = Math.max(
+ maxVisibleWorldSpan,
+ requiredWidth,
+ requiredHeight * aspect
+ )
+ const projectionHeight = projectionWidth / aspect
+ const coverageCorners = [
+ [-projectionWidth / 2, -projectionHeight / 2],
+ [-projectionWidth / 2, projectionHeight / 2],
+ [projectionWidth / 2, -projectionHeight / 2],
+ [projectionWidth / 2, projectionHeight / 2]
+ ].map(([right, down]) => ({
+ x: projectionCenter.x + right * screenRight.x + down * screenDown.x,
+ z: projectionCenter.z + right * screenRight.z + down * screenDown.z
+ }))
+ const minX = Math.min(...coverageCorners.map((point) => point.x))
+ const maxX = Math.max(...coverageCorners.map((point) => point.x))
+ const minZ = Math.min(...coverageCorners.map((point) => point.z))
+ const maxZ = Math.max(...coverageCorners.map((point) => point.z))
+ const planeY = modelCenter.y
+ const requiredDensityWidth = cameraContract.minimumInitialViewportCssWidth
+ * cameraContract.minimumInitialViewportPixelDensity
+ * projectionWidth / defaultVisibleWorldSpan
+ const resolutionScale = Math.max(1, Math.ceil(requiredDensityWidth / width))
+ const outputWidth = width * resolutionScale
+ const outputHeight = height * resolutionScale
+ const initialViewportSourcePixels = outputWidth * defaultVisibleWorldSpan / projectionWidth
+ const initialViewportPixelDensity = initialViewportSourcePixels
+ / cameraContract.minimumInitialViewportCssWidth
+ const cameraHeight = bounds.max.y + Math.max(projectionWidth, projectionHeight, 100)
+ const camera = new THREE.OrthographicCamera(
+ -projectionWidth / 2,
+ projectionWidth / 2,
+ projectionHeight / 2,
+ -projectionHeight / 2,
+ Math.max(0.1, span / 1000),
+ Math.max(20000, span * 20)
+ )
+ camera.position.set(projectionCenter.x, cameraHeight, projectionCenter.z)
+ camera.up.set(-screenDown.x, 0, -screenDown.z)
+ camera.lookAt(projectionCenter.x, planeY, projectionCenter.z)
+ camera.updateProjectionMatrix()
+ camera.updateMatrixWorld()
+
+ scene.add(new THREE.HemisphereLight('#ffffff', '#b0bec5', 1.7))
+ const keyLight = new THREE.DirectionalLight('#ffffff', 2.1)
+ keyLight.position.set(80, 120, 80)
+ scene.add(keyLight)
+ const fillLight = new THREE.DirectionalLight('#ffffff', 0.55)
+ fillLight.position.set(-60, 70, -50)
+ scene.add(fillLight)
+
+ const canvas = document.createElement('canvas')
+ canvas.width = outputWidth
+ canvas.height = outputHeight
+ const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false, preserveDrawingBuffer: true })
+ renderer.setPixelRatio(1)
+ renderer.setSize(outputWidth, outputHeight, false)
+ renderer.outputColorSpace = THREE.SRGBColorSpace
+ renderer.toneMapping = THREE.NoToneMapping
+ renderer.toneMappingExposure = 1
+ renderer.render(scene, camera)
+
+ const dataUrl = canvas.toDataURL('image/webp', 0.9)
+ const image = new Image()
+ image.src = dataUrl
+ await image.decode()
+ const inspectionCanvas = document.createElement('canvas')
+ inspectionCanvas.width = outputWidth
+ inspectionCanvas.height = outputHeight
+ const inspectionContext = inspectionCanvas.getContext('2d', { willReadFrequently: true })
+ if (!inspectionContext) throw new Error('Unable to inspect rendered image pixels')
+ inspectionContext.drawImage(image, 0, 0)
+ const pixels = inspectionContext.getImageData(0, 0, outputWidth, outputHeight).data
+ const background = [236, 239, 241]
+ let nonBackgroundPixels = 0
+ for (let index = 0; index < pixels.length; index += 4) {
+ const difference = Math.abs(pixels[index] - background[0])
+ + Math.abs(pixels[index + 1] - background[1])
+ + Math.abs(pixels[index + 2] - background[2])
+ if (difference > 24 && pixels[index + 3] > 0) nonBackgroundPixels += 1
+ }
+ renderer.dispose()
+ dracoLoader.dispose()
+ scene.clear()
+
+ return {
+ dataUrl,
+ pixelStats: {
+ nonBackgroundPixels,
+ nonBackgroundRatio: nonBackgroundPixels / (outputWidth * outputHeight)
+ },
+ bounds: {
+ min: { x: bounds.min.x, y: bounds.min.y, z: bounds.min.z },
+ max: { x: bounds.max.x, y: bounds.max.y, z: bounds.max.z },
+ size: { x: size.x, y: size.y, z: size.z }
+ },
+ projection: {
+ type: 'orthographic-xz',
+ minX,
+ maxX,
+ minZ,
+ maxZ,
+ planeY,
+ center: projectionCenter,
+ width: projectionWidth,
+ height: projectionHeight,
+ screenRight,
+ screenDown
+ },
+ initialViewport: {
+ centerX: projectionCenter.x,
+ centerZ: projectionCenter.z,
+ visibleWorldSpan: defaultVisibleWorldSpan,
+ maxVisibleWorldSpan
+ },
+ visualCenter: {
+ strategy: useCompactFloorVisualCenter
+ ? 'model-box-center-with-screen-offset'
+ : 'shared-overview-target-with-screen-offset',
+ base: baseVisualCenter,
+ horizontalScreenOffset,
+ floorLevel: Number.isFinite(floorLevel) ? floorLevel : null
+ },
+ output: {
+ width: outputWidth,
+ height: outputHeight,
+ resolutionScale,
+ initialViewportSourcePixels,
+ initialViewportPixelDensity
+ }
+ }
+}, options)
+
+const decodeDataUrl = (dataUrl) => {
+ const match = /^data:image\/webp;base64,(.+)$/i.exec(dataUrl)
+ if (!match) throw new Error('Renderer did not produce a WebP data URL')
+ return Buffer.from(match[1], 'base64')
+}
+
+const main = async () => {
+ const parsed = parseArgs(process.argv.slice(2))
+ if (parsed.help) {
+ console.log(usage)
+ return
+ }
+
+ const apiOrigin = normalizeOrigin(parsed.values.get('api') || process.env.GUIDE_BASEMAP_API_URL || DEFAULT_API_URL)
+ const apiRoot = resolveApiRoot(apiOrigin)
+ const mapId = parsed.values.get('map-id') || process.env.GUIDE_BASEMAP_MAP_ID || DEFAULT_MAP_ID
+ const outputDir = path.resolve(parsed.values.get('out-dir') || process.env.GUIDE_BASEMAP_OUTPUT_DIR || DEFAULT_OUTPUT_DIR)
+ const viteUrl = normalizeOrigin(parsed.values.get('vite-url') || process.env.GUIDE_BASEMAP_VITE_URL || DEFAULT_VITE_URL)
+ const width = asPositiveInteger(parsed.values.get('width') || process.env.GUIDE_BASEMAP_WIDTH || DEFAULT_WIDTH, '--width')
+ const height = asPositiveInteger(parsed.values.get('height') || process.env.GUIDE_BASEMAP_HEIGHT || DEFAULT_HEIGHT, '--height')
+ const padding = asPadding(parsed.values.get('padding') || process.env.GUIDE_BASEMAP_PADDING || DEFAULT_PADDING)
+ const requestedFloors = new Set([
+ ...parsed.floorCodes,
+ ...(process.env.GUIDE_BASEMAP_FLOOR || '').split(',').map((item) => item.trim()).filter(Boolean)
+ ].map((item) => item.toUpperCase()))
+
+ const staticRoot = path.resolve(process.cwd(), 'static')
+ const outputRelative = path.relative(staticRoot, outputDir)
+ if (outputRelative.startsWith('..') || path.isAbsolute(outputRelative)) {
+ throw new Error(`--out-dir must be inside ${staticRoot}`)
+ }
+
+ await fetch(`${viteUrl}/node_modules/three/build/three.module.js`).then((response) => {
+ if (!response.ok) throw new Error(`Vite Three.js module is unavailable: ${viteUrl}`)
+ })
+ await mkdir(outputDir, { recursive: true })
+
+ const sdkManifest = await getJson(`${apiRoot}/gis/sdk/maps/${encodeURIComponent(mapId)}/manifest`)
+ const accessibleFloors = (sdkManifest.floors || []).filter((floor) => {
+ const code = String(floor.floorCode || '').trim().toUpperCase()
+ return Boolean(code && floor.modelUrl && (!requestedFloors.size || requestedFloors.has(code)))
+ })
+ const sdkFloorsByCode = new Map((sdkManifest.floors || []).map((floor) => (
+ [String(floor.floorCode || '').trim().toUpperCase(), floor]
+ )))
+ const compositeRouteAssets = (sdkManifest.routeAssets || [])
+ .filter((asset) => String(asset.assetRole || '').trim().toUpperCase() === 'SAME_GROUND_COMPOSITE')
+ .filter((asset) => {
+ const code = String(asset.floorCode || '').trim().toUpperCase()
+ return Boolean(code && asset.modelUrl && (!requestedFloors.size || requestedFloors.has(code)))
+ })
+ .map((asset) => {
+ const coverageFloorCodes = Array.from(new Set((asset.coverageFloorCodes || [])
+ .map((code) => String(code || '').trim().toUpperCase())
+ .filter(Boolean)))
+ const coverageFloors = coverageFloorCodes.map((code) => sdkFloorsByCode.get(code))
+ if (coverageFloorCodes.length <= 1 || coverageFloors.some((floor) => !floor)) {
+ throw new Error(`${asset.floorCode}: composite coverage cannot be resolved to SDK floors`)
+ }
+ return {
+ ...asset,
+ floorCode: String(asset.floorCode).trim().toUpperCase(),
+ coverageFloorCodes,
+ coverageFloorIds: coverageFloors.map((floor) => String(floor.floorId))
+ }
+ })
+ const resources = [
+ ...accessibleFloors.map((floor) => ({
+ kind: 'floor',
+ source: floor,
+ floorId: String(floor.floorId),
+ floorCode: String(floor.floorCode).trim().toUpperCase(),
+ floorName: floor.floorName || null,
+ sortOrder: floor.sortOrder ?? null,
+ modelUrl: floor.modelUrl,
+ modelVersion: String(floor.modelVersion || sdkManifest.dataVersion || 'unversioned')
+ })),
+ ...compositeRouteAssets.map((asset) => ({
+ kind: 'route-asset',
+ source: asset,
+ assetId: String(asset.id || asset.floorCode),
+ role: 'SAME_GROUND_COMPOSITE',
+ floorId: String(asset.floorCode),
+ floorCode: String(asset.floorCode),
+ floorName: null,
+ sortOrder: asset.sortOrder ?? null,
+ modelUrl: asset.modelUrl,
+ modelVersion: String(asset.modelVersion || sdkManifest.dataVersion || 'unversioned'),
+ coverageFloorCodes: asset.coverageFloorCodes,
+ coverageFloorIds: asset.coverageFloorIds
+ }))
+ ]
+ if (!resources.length) throw new Error('No accessible SDK floor or composite route assets matched the requested filters')
+
+ const browser = await chromium.launch({ headless: true })
+ const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 1 })
+ const virtualAssets = new Map()
+ await page.route('**/__guide-floor-basemap-assets__/*.glb', async (route) => {
+ const asset = virtualAssets.get(new URL(route.request().url()).pathname)
+ if (!asset) return route.abort()
+ await route.fulfill({
+ status: 200,
+ contentType: asset.contentType || 'model/gltf-binary',
+ body: asset.buffer
+ })
+ })
+ await page.goto(`${viteUrl}/`, { waitUntil: 'domcontentloaded' })
+
+ const floors = []
+ const routeAssets = []
+ try {
+ for (const resource of resources) {
+ const { floorCode, modelVersion } = resource
+ const modelUrl = resolveModelUrl(apiOrigin, resource.modelUrl)
+ const downloaded = await getBinary(modelUrl)
+ const assetPath = `/__guide-floor-basemap-assets__/${safeFilePart(floorCode)}.glb`
+ virtualAssets.set(assetPath, downloaded)
+
+ const rendered = await createRenderer(page, {
+ assetUrl: assetPath,
+ width,
+ height,
+ padding,
+ floorCode,
+ resourceKind: resource.kind,
+ decoderPath: `${viteUrl}/node_modules/three/examples/jsm/libs/draco/gltf/`,
+ cameraContract: {
+ yawDegrees: GUIDE_CAMERA_YAW_DEGREES,
+ fovDegrees: GUIDE_CAMERA_FOV_DEGREES,
+ overviewDistance: OVERVIEW_CAMERA_DISTANCE,
+ indoorReferenceFitDimension: INDOOR_REFERENCE_FIT_DIMENSION,
+ indoorDistanceFactor: INDOOR_ENTRY_CAMERA_DISTANCE_FACTOR,
+ overviewMaxDistanceFactor: OVERVIEW_MAX_DISTANCE_FACTOR,
+ floorMaxDistanceFactor: FLOOR_MAX_DISTANCE_FACTOR,
+ overviewScreenOffsetRatioX: OVERVIEW_SCREEN_OFFSET_RATIO_X,
+ floorScreenOffsetRatioX: FLOOR_SCREEN_OFFSET_RATIO_X,
+ target: GUIDE_CAMERA_TARGET,
+ minimumInitialViewportCssWidth: MIN_INITIAL_VIEWPORT_CSS_WIDTH,
+ minimumInitialViewportPixelDensity: MIN_INITIAL_VIEWPORT_PIXEL_DENSITY
+ }
+ })
+ if (rendered.pixelStats.nonBackgroundRatio < 0.003) {
+ throw new Error(`${floorCode} rendered as an almost blank image (${rendered.pixelStats.nonBackgroundRatio})`)
+ }
+ if (rendered.output.initialViewportPixelDensity + 1e-6 < MIN_INITIAL_VIEWPORT_PIXEL_DENSITY) {
+ throw new Error(`${floorCode} initial viewport pixel density is too low (${rendered.output.initialViewportPixelDensity})`)
+ }
+
+ const fileName = `${safeFilePart(floorCode)}.${safeFilePart(modelVersion)}.${PROJECTION_VERSION}.${COMPOSITION_VERSION}.webp`
+ const outputFile = path.join(outputDir, fileName)
+ const image = decodeDataUrl(rendered.dataUrl)
+ if (image.length < 1024) throw new Error(`${floorCode} WebP output is unexpectedly small`)
+ if (image.length > MAX_WEBP_BYTES) {
+ throw new Error(`${floorCode} WebP exceeds the weak-network size budget (${image.length} bytes)`)
+ }
+ await writeFile(outputFile, image)
+ const entry = {
+ floorId: resource.floorId,
+ floorCode,
+ floorName: resource.floorName,
+ sortOrder: resource.sortOrder,
+ modelVersion,
+ modelUrl: resource.modelUrl,
+ glbBytes: downloaded.buffer.length,
+ bounds: rendered.bounds,
+ projection: rendered.projection,
+ initialViewport: rendered.initialViewport,
+ visualCenter: rendered.visualCenter,
+ image: {
+ path: toPublicPath(outputDir, fileName),
+ width: rendered.output.width,
+ height: rendered.output.height,
+ format: 'webp',
+ bytes: image.length,
+ nonBackgroundPixels: rendered.pixelStats.nonBackgroundPixels,
+ nonBackgroundRatio: rendered.pixelStats.nonBackgroundRatio,
+ resolutionScale: rendered.output.resolutionScale,
+ initialViewportSourcePixels: rendered.output.initialViewportSourcePixels,
+ initialViewportPixelDensityAt390CssPx: rendered.output.initialViewportPixelDensity
+ }
+ }
+ if (resource.kind === 'route-asset') {
+ routeAssets.push({
+ ...entry,
+ assetId: resource.assetId,
+ role: resource.role,
+ coverageFloorCodes: resource.coverageFloorCodes,
+ coverageFloorIds: resource.coverageFloorIds
+ })
+ } else {
+ floors.push(entry)
+ }
+ virtualAssets.delete(assetPath)
+ console.log(`Rendered ${floorCode}: ${fileName} (${image.length} bytes)`)
+ }
+ } finally {
+ await browser.close()
+ }
+
+ const outputManifest = {
+ schemaVersion: 4,
+ generatedAt: new Date().toISOString(),
+ generator: 'scripts/generate-guide-floor-basemaps.mjs',
+ source: {
+ apiUrl: apiRoot,
+ mapId: String(sdkManifest.mapId || mapId),
+ mapName: sdkManifest.mapName || null,
+ dataVersion: sdkManifest.dataVersion || null,
+ coordinateSystem: sdkManifest.coordinateSystem || 'GLB_METER'
+ },
+ render: {
+ renderer: 'three.js GLTFLoader + DRACOLoader via Playwright',
+ projection: 'orthographic-xz',
+ projectionVersion: PROJECTION_VERSION,
+ width,
+ height,
+ padding,
+ cameraContract: {
+ yawDegrees: GUIDE_CAMERA_YAW_DEGREES,
+ fovDegrees: GUIDE_CAMERA_FOV_DEGREES,
+ overviewDistance: OVERVIEW_CAMERA_DISTANCE,
+ indoorReferenceFitDimension: INDOOR_REFERENCE_FIT_DIMENSION,
+ indoorDistanceFactor: INDOOR_ENTRY_CAMERA_DISTANCE_FACTOR,
+ overviewMaxDistanceFactor: OVERVIEW_MAX_DISTANCE_FACTOR,
+ floorMaxDistanceFactor: FLOOR_MAX_DISTANCE_FACTOR,
+ overviewScreenOffsetRatioX: OVERVIEW_SCREEN_OFFSET_RATIO_X,
+ floorScreenOffsetRatioX: FLOOR_SCREEN_OFFSET_RATIO_X,
+ target: GUIDE_CAMERA_TARGET
+ },
+ compositionVersion: COMPOSITION_VERSION,
+ minimumInitialViewportCssWidth: MIN_INITIAL_VIEWPORT_CSS_WIDTH,
+ minimumInitialViewportPixelDensity: MIN_INITIAL_VIEWPORT_PIXEL_DENSITY
+ },
+ floors,
+ routeAssets
+ }
+ await writeFile(path.join(outputDir, 'manifest.json'), `${JSON.stringify(outputManifest, null, 2)}\n`, 'utf8')
+ console.log(`Wrote ${floors.length} floor and ${routeAssets.length} composite basemap entries to ${path.join(outputDir, 'manifest.json')}`)
+}
+
+main().catch((error) => {
+ console.error(error instanceof Error ? error.stack || error.message : error)
+ process.exitCode = 1
+})
diff --git a/scripts/sync-sdk-engine.cjs b/scripts/sync-sdk-engine.cjs
new file mode 100644
index 0000000..117b5f8
--- /dev/null
+++ b/scripts/sync-sdk-engine.cjs
@@ -0,0 +1,52 @@
+const fs = require('fs')
+const path = require('path')
+
+const projectRoot = path.resolve(__dirname, '..')
+const repoRoot = path.resolve(projectRoot, '..')
+const sourceDir = path.join(repoRoot, 'sgs-map-sdk-release', 'engine')
+const targetDir = path.join(projectRoot, 'public', 'engine')
+
+if (!fs.existsSync(sourceDir)) {
+ throw new Error(`SDK Engine source not found: ${sourceDir}`)
+}
+
+const publicDir = path.join(projectRoot, 'public')
+const resolvedTarget = path.resolve(targetDir)
+if (!resolvedTarget.startsWith(path.resolve(publicDir) + path.sep)) {
+ throw new Error(`Refusing to write outside public directory: ${resolvedTarget}`)
+}
+
+const rewriteTextFiles = (dir) => {
+ const replacements = [
+ ['http://1.92.206.90:9000/museum-assets', '/museum-assets'],
+ ['http://1.92.206.90:9000/tts-audio', '/tts-audio'],
+ ['http://1.92.206.90:9000', 'http://legacy-minio.local:9000'],
+ ['https://1.92.206.90:9000', 'https://legacy-minio.local:9000'],
+ ['https://guide.whaoyue.com/museum-assets', '/museum-assets'],
+ ['https://guide.whaoyue.com/tts-audio', '/tts-audio'],
+ ['https://guide.whaoyue.com/app-api', '/app-api']
+ ]
+
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const filePath = path.join(dir, entry.name)
+ if (entry.isDirectory()) {
+ rewriteTextFiles(filePath)
+ continue
+ }
+ if (!entry.isFile() || !/\.(?:html|js|mjs|json|css|map)$/i.test(entry.name)) continue
+
+ let text = fs.readFileSync(filePath, 'utf8')
+ const original = text
+ for (const [from, to] of replacements) {
+ text = text.split(from).join(to)
+ }
+ if (text !== original) fs.writeFileSync(filePath, text, 'utf8')
+ }
+}
+
+fs.rmSync(targetDir, { recursive: true, force: true })
+fs.mkdirSync(path.dirname(targetDir), { recursive: true })
+fs.cpSync(sourceDir, targetDir, { recursive: true })
+rewriteTextFiles(targetDir)
+
+console.log(`Synced SDK Engine: ${sourceDir} -> ${targetDir}`)
diff --git a/scripts/verify-guide-floor-basemaps.mjs b/scripts/verify-guide-floor-basemaps.mjs
new file mode 100644
index 0000000..4071a54
--- /dev/null
+++ b/scripts/verify-guide-floor-basemaps.mjs
@@ -0,0 +1,307 @@
+import { readFile } from 'node:fs/promises'
+import path from 'node:path'
+import process from 'node:process'
+import { chromium } from '@playwright/test'
+
+const DEFAULT_MANIFEST = 'static/guide-floor-basemaps/manifest.json'
+const DEFAULT_VITE_URL = 'http://127.0.0.1:5173'
+const PROJECTION_VERSION = 'ortho-yaw54-v2'
+const COMPOSITION_VERSION = 'compact-floor-center-v1'
+const EXPECTED_FLOOR_COUNT = 9
+const EXPECTED_COMPOSITE_COUNT = 1
+const GUIDE_CAMERA_YAW_DEGREES = 54
+const GUIDE_CAMERA_FOV_DEGREES = 42
+const OVERVIEW_CAMERA_DISTANCE = 720
+const INDOOR_REFERENCE_FIT_DIMENSION = 270.39
+const INDOOR_ENTRY_CAMERA_DISTANCE_FACTOR = 0.56
+const OVERVIEW_MAX_DISTANCE_FACTOR = 1.35
+const FLOOR_MAX_DISTANCE_FACTOR = 1.18
+const OVERVIEW_SCREEN_OFFSET_RATIO_X = -0.045
+const FLOOR_SCREEN_OFFSET_RATIO_X = -0.05
+const GUIDE_CAMERA_TARGET = { x: 29.0598, z: 23.9424 }
+const MIN_INITIAL_VIEWPORT_CSS_WIDTH = 390
+const MIN_INITIAL_VIEWPORT_PIXEL_DENSITY = 1
+const MAX_WEBP_BYTES = 1_500_000
+const MAX_TOTAL_WEBP_BYTES = 3_000_000
+
+const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value)
+
+const verifyOrthographicProjection = (floor) => {
+ const projection = floor.projection
+ if (projection?.type !== 'orthographic-xz') {
+ throw new Error(`${floor.floorCode}: projection must be orthographic-xz`)
+ }
+ if (
+ !isFiniteNumber(projection.minX)
+ || !isFiniteNumber(projection.maxX)
+ || !isFiniteNumber(projection.minZ)
+ || !isFiniteNumber(projection.maxZ)
+ || !isFiniteNumber(projection.planeY)
+ || !isFiniteNumber(projection.center?.x)
+ || !isFiniteNumber(projection.center?.z)
+ || !isFiniteNumber(projection.width)
+ || !isFiniteNumber(projection.height)
+ || !isFiniteNumber(projection.screenRight?.x)
+ || !isFiniteNumber(projection.screenRight?.z)
+ || !isFiniteNumber(projection.screenDown?.x)
+ || !isFiniteNumber(projection.screenDown?.z)
+ || projection.maxX <= projection.minX
+ || projection.maxZ <= projection.minZ
+ || projection.width <= 0
+ || projection.height <= 0
+ ) {
+ throw new Error(`${floor.floorCode}: projection contract is invalid`)
+ }
+ const rightLength = Math.hypot(projection.screenRight.x, projection.screenRight.z)
+ const downLength = Math.hypot(projection.screenDown.x, projection.screenDown.z)
+ const basisDot = projection.screenRight.x * projection.screenDown.x
+ + projection.screenRight.z * projection.screenDown.z
+ if (
+ Math.abs(rightLength - 1) > 1e-6
+ || Math.abs(downLength - 1) > 1e-6
+ || Math.abs(basisDot) > 1e-6
+ ) {
+ throw new Error(`${floor.floorCode}: projection basis must be normalized and orthogonal`)
+ }
+ const yaw = GUIDE_CAMERA_YAW_DEGREES * Math.PI / 180
+ if (
+ Math.abs(projection.screenRight.x - Math.cos(yaw)) > 1e-6
+ || Math.abs(projection.screenRight.z + Math.sin(yaw)) > 1e-6
+ || Math.abs(projection.screenDown.x - Math.sin(yaw)) > 1e-6
+ || Math.abs(projection.screenDown.z - Math.cos(yaw)) > 1e-6
+ ) {
+ throw new Error(`${floor.floorCode}: projection basis does not match yaw ${GUIDE_CAMERA_YAW_DEGREES}`)
+ }
+ const projectionAspect = projection.width / projection.height
+ const imageAspect = floor.image.width / floor.image.height
+ if (Math.abs(projectionAspect - imageAspect) > 1e-6) {
+ throw new Error(`${floor.floorCode}: projection/image aspect mismatch`)
+ }
+
+ const initial = floor.initialViewport
+ if (
+ !isFiniteNumber(initial?.centerX)
+ || !isFiniteNumber(initial?.centerZ)
+ || !isFiniteNumber(initial?.visibleWorldSpan)
+ || !isFiniteNumber(initial?.maxVisibleWorldSpan)
+ || initial.visibleWorldSpan <= 0
+ || initial.maxVisibleWorldSpan < initial.visibleWorldSpan
+ ) {
+ throw new Error(`${floor.floorCode}: initialViewport is invalid`)
+ }
+ const isComposite = floor.role === 'SAME_GROUND_COMPOSITE'
+ const isExterior = String(floor.floorCode).toUpperCase() === 'EXTERIOR'
+ const isOverviewLike = isExterior || isComposite
+ const distance = isOverviewLike
+ ? OVERVIEW_CAMERA_DISTANCE
+ : Math.abs(
+ INDOOR_REFERENCE_FIT_DIMENSION
+ / Math.sin((GUIDE_CAMERA_FOV_DEGREES * Math.PI / 180) / 2)
+ ) * INDOOR_ENTRY_CAMERA_DISTANCE_FACTOR
+ const expectedInitialSpan = 2
+ * distance
+ * Math.tan((GUIDE_CAMERA_FOV_DEGREES * Math.PI / 180) / 2)
+ * imageAspect
+ const expectedMaxSpan = expectedInitialSpan * (
+ isOverviewLike ? OVERVIEW_MAX_DISTANCE_FACTOR : FLOOR_MAX_DISTANCE_FACTOR
+ )
+ if (
+ Math.abs(initial.visibleWorldSpan - expectedInitialSpan) > 1e-6
+ || Math.abs(initial.maxVisibleWorldSpan - expectedMaxSpan) > 1e-6
+ ) {
+ throw new Error(`${floor.floorCode}: initial viewport does not match the live GLB camera contract`)
+ }
+
+ const floorLevelMatch = /^L(-?\d+(?:\.\d+)?)$/.exec(String(floor.floorCode).toUpperCase())
+ const floorLevel = floorLevelMatch ? Number(floorLevelMatch[1]) : Number.NaN
+ const useCompactFloorVisualCenter = isComposite || floorLevel === 1.5 || floorLevel >= 3
+ const modelCenter = {
+ x: (floor.bounds.min.x + floor.bounds.max.x) / 2,
+ z: (floor.bounds.min.z + floor.bounds.max.z) / 2
+ }
+ const baseCenter = useCompactFloorVisualCenter ? modelCenter : GUIDE_CAMERA_TARGET
+ const overviewVisibleWorldSpan = 2
+ * OVERVIEW_CAMERA_DISTANCE
+ * Math.tan((GUIDE_CAMERA_FOV_DEGREES * Math.PI / 180) / 2)
+ * imageAspect
+ const horizontalScreenOffset = useCompactFloorVisualCenter
+ ? FLOOR_SCREEN_OFFSET_RATIO_X * expectedInitialSpan
+ : OVERVIEW_SCREEN_OFFSET_RATIO_X * overviewVisibleWorldSpan
+ const expectedCenter = {
+ x: baseCenter.x + projection.screenRight.x * horizontalScreenOffset,
+ z: baseCenter.z + projection.screenRight.z * horizontalScreenOffset
+ }
+ if (
+ Math.abs(initial.centerX - expectedCenter.x) > 1e-6
+ || Math.abs(initial.centerZ - expectedCenter.z) > 1e-6
+ || Math.abs(projection.center.x - expectedCenter.x) > 1e-6
+ || Math.abs(projection.center.z - expectedCenter.z) > 1e-6
+ ) {
+ throw new Error(`${floor.floorCode}: projection center does not match the live GLB floor visual-center contract`)
+ }
+ const expectedStrategy = useCompactFloorVisualCenter
+ ? 'model-box-center-with-screen-offset'
+ : 'shared-overview-target-with-screen-offset'
+ if (
+ floor.visualCenter?.strategy !== expectedStrategy
+ || Math.abs(floor.visualCenter?.base?.x - baseCenter.x) > 1e-6
+ || Math.abs(floor.visualCenter?.base?.z - baseCenter.z) > 1e-6
+ || Math.abs(floor.visualCenter?.horizontalScreenOffset - horizontalScreenOffset) > 1e-6
+ ) {
+ throw new Error(`${floor.floorCode}: visual-center metadata is invalid`)
+ }
+
+ const corners = [
+ { x: floor.bounds.min.x, z: floor.bounds.min.z },
+ { x: floor.bounds.min.x, z: floor.bounds.max.z },
+ { x: floor.bounds.max.x, z: floor.bounds.min.z },
+ { x: floor.bounds.max.x, z: floor.bounds.max.z }
+ ]
+ for (const point of corners) {
+ const deltaX = point.x - projection.center.x
+ const deltaZ = point.z - projection.center.z
+ const right = deltaX * projection.screenRight.x + deltaZ * projection.screenRight.z
+ const down = deltaX * projection.screenDown.x + deltaZ * projection.screenDown.z
+ if (Math.abs(right) > projection.width / 2 + 1e-6 || Math.abs(down) > projection.height / 2 + 1e-6) {
+ throw new Error(`${floor.floorCode}: projection coverage does not contain the model bounds`)
+ }
+ }
+
+ const initialSourcePixels = floor.image.width * initial.visibleWorldSpan / projection.width
+ const density = initialSourcePixels / MIN_INITIAL_VIEWPORT_CSS_WIDTH
+ if (density + 1e-6 < MIN_INITIAL_VIEWPORT_PIXEL_DENSITY) {
+ throw new Error(`${floor.floorCode}: initial viewport pixel density is too low (${density})`)
+ }
+}
+
+const parseArgs = (argv) => {
+ const values = new Map()
+ for (let index = 0; index < argv.length; index += 1) {
+ const argument = argv[index]
+ if (argument === '--help' || argument === '-h') return { help: true, values }
+ if (!argument.startsWith('--')) throw new Error(`Unknown argument: ${argument}`)
+ const [key, inlineValue] = argument.slice(2).split('=', 2)
+ const value = inlineValue ?? argv[index + 1]
+ if (!value || value.startsWith('--')) throw new Error(`Missing value for --${key}`)
+ if (inlineValue === undefined) index += 1
+ if (!['manifest', 'vite-url'].includes(key)) throw new Error(`Unknown argument: --${key}`)
+ values.set(key, value)
+ }
+ return { help: false, values }
+}
+
+const main = async () => {
+ const parsed = parseArgs(process.argv.slice(2))
+ if (parsed.help) {
+ console.log('Usage: npm run verify:guide-floor-basemaps -- [--manifest ] [--vite-url ]')
+ return
+ }
+
+ const manifestPath = path.resolve(parsed.values.get('manifest') || process.env.GUIDE_BASEMAP_MANIFEST || DEFAULT_MANIFEST)
+ const viteUrl = (parsed.values.get('vite-url') || process.env.GUIDE_BASEMAP_VITE_URL || DEFAULT_VITE_URL).replace(/\/+$/, '')
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
+ if (!Array.isArray(manifest.floors) || manifest.floors.length !== EXPECTED_FLOOR_COUNT) {
+ throw new Error(`Basemap manifest must contain ${EXPECTED_FLOOR_COUNT} floor entries`)
+ }
+ if (!Array.isArray(manifest.routeAssets) || manifest.routeAssets.length !== EXPECTED_COMPOSITE_COUNT) {
+ throw new Error(`Basemap manifest must contain ${EXPECTED_COMPOSITE_COUNT} composite route entry`)
+ }
+ if (
+ manifest.schemaVersion !== 4
+ || manifest.render?.projection !== 'orthographic-xz'
+ || manifest.render?.projectionVersion !== PROJECTION_VERSION
+ || manifest.render?.compositionVersion !== COMPOSITION_VERSION
+ ) {
+ throw new Error('Basemap manifest is not an orthographic schema v4 manifest')
+ }
+ const composite = manifest.routeAssets[0]
+ const expectedCoverageCodes = ['EXTERIOR', 'L1']
+ const expectedCoverageIds = expectedCoverageCodes.map((floorCode) => {
+ const floor = manifest.floors.find((entry) => entry.floorCode === floorCode)
+ if (!floor) throw new Error(`Composite coverage floor ${floorCode} is missing from ordinary floor entries`)
+ return String(floor.floorId)
+ })
+ if (
+ composite.role !== 'SAME_GROUND_COMPOSITE'
+ || composite.floorCode !== 'EXTERIOR_L1'
+ || !composite.assetId
+ || !composite.modelUrl
+ || !composite.modelVersion
+ || JSON.stringify(composite.coverageFloorCodes) !== JSON.stringify(expectedCoverageCodes)
+ || JSON.stringify(composite.coverageFloorIds) !== JSON.stringify(expectedCoverageIds)
+ ) {
+ throw new Error('EXTERIOR_L1 composite metadata or coverage does not match the SDK floor identities')
+ }
+
+ const entries = [...manifest.floors, ...manifest.routeAssets]
+ for (const floor of entries) {
+ verifyOrthographicProjection(floor)
+ if (!/\.ortho-yaw54-v2\.compact-floor-center-v1\.webp$/i.test(String(floor.image?.path || ''))) {
+ throw new Error(`${floor.floorCode}: image URL is missing the ${PROJECTION_VERSION}/${COMPOSITION_VERSION} cache identity`)
+ }
+ }
+
+ const browser = await chromium.launch({ headless: true })
+ const page = await browser.newPage()
+ await page.goto(`${viteUrl}/`, { waitUntil: 'domcontentloaded' })
+ let totalWebpBytes = 0
+ try {
+ for (const floor of entries) {
+ const imagePath = String(floor.image?.path || '')
+ const response = await page.request.get(`${viteUrl}${imagePath}`)
+ if (!response.ok()) throw new Error(`${floor.floorCode}: image is unavailable at ${imagePath} (${response.status()})`)
+ const bytes = await response.body()
+ if (bytes.length > MAX_WEBP_BYTES) {
+ throw new Error(`${floor.floorCode}: WebP exceeds the weak-network size budget (${bytes.length} bytes)`)
+ }
+ if (Number(floor.image?.bytes) !== bytes.length) {
+ throw new Error(`${floor.floorCode}: manifest byte count does not match the WebP file`)
+ }
+ totalWebpBytes += bytes.length
+ const inspection = await page.evaluate(async ({ base64, expectedWidth, expectedHeight }) => {
+ const image = new Image()
+ image.src = `data:image/webp;base64,${base64}`
+ await image.decode()
+ const canvas = document.createElement('canvas')
+ canvas.width = image.naturalWidth
+ canvas.height = image.naturalHeight
+ const context = canvas.getContext('2d', { willReadFrequently: true })
+ context.drawImage(image, 0, 0)
+ const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data
+ const first = [pixels[0], pixels[1], pixels[2]]
+ let differingPixels = 0
+ for (let index = 0; index < pixels.length; index += 4) {
+ const difference = Math.abs(pixels[index] - first[0])
+ + Math.abs(pixels[index + 1] - first[1])
+ + Math.abs(pixels[index + 2] - first[2])
+ if (difference > 24 && pixels[index + 3] > 0) differingPixels += 1
+ }
+ return {
+ width: image.naturalWidth,
+ height: image.naturalHeight,
+ differingRatio: differingPixels / (canvas.width * canvas.height),
+ dimensionsMatch: image.naturalWidth === expectedWidth && image.naturalHeight === expectedHeight
+ }
+ }, {
+ base64: bytes.toString('base64'),
+ expectedWidth: floor.image.width,
+ expectedHeight: floor.image.height
+ })
+ if (!inspection.dimensionsMatch || inspection.differingRatio < 0.003) {
+ throw new Error(`${floor.floorCode}: invalid WebP (dimensionsMatch=${inspection.dimensionsMatch}, differingRatio=${inspection.differingRatio})`)
+ }
+ console.log(`Verified ${floor.floorCode}: ${inspection.width}x${inspection.height}, differing ratio ${inspection.differingRatio.toFixed(4)}`)
+ }
+ if (totalWebpBytes > MAX_TOTAL_WEBP_BYTES) {
+ throw new Error(`WebP set exceeds the total weak-network budget (${totalWebpBytes} bytes)`)
+ }
+ console.log(`Verified ${entries.length} WebP files, total ${totalWebpBytes} bytes`)
+ } finally {
+ await browser.close()
+ }
+}
+
+main().catch((error) => {
+ console.error(error instanceof Error ? error.stack || error.message : error)
+ process.exitCode = 1
+})
diff --git a/src/components/explain/ExplainGuideStopCatalog.vue b/src/components/explain/ExplainGuideStopCatalog.vue
index 32c618d..639259f 100644
--- a/src/components/explain/ExplainGuideStopCatalog.vue
+++ b/src/components/explain/ExplainGuideStopCatalog.vue
@@ -9,13 +9,26 @@
- 正在加载讲解对象 稍后将展示该展厅的讲解对象。
+
讲解对象加载失败 {{ error }}
-
-
+
+
+
+ {{ stop.name.slice(0, 1) }}
+
{{ stop.name }}
@@ -34,8 +47,9 @@
diff --git a/src/components/map/ThreeMap.vue b/src/components/map/ThreeMap.vue
index 7f502d3..894a532 100644
--- a/src/components/map/ThreeMap.vue
+++ b/src/components/map/ThreeMap.vue
@@ -1,9 +1,44 @@
-
+
+
+
@@ -14,7 +49,7 @@
-
+
{{ modelLoadErrorTitle }}
{{ modelLoadErrorMessage }}
@@ -31,13 +66,63 @@
-
+
+ 相机
+
+
+
+
+
+ 相机校准
+ {{ activeView === 'overview' ? '外观模型' : formatFloorLabel(currentFloor) }}
+
+ ×
+
+
+
+ 水平朝向 {{ formatCalibrationValue(cameraCalibration.yaw) }}°
+
+
+
+ 三维俯视 {{ formatCalibrationValue(cameraCalibration.elevation) }}°
+
+
+
+ FOV {{ formatCalibrationValue(cameraCalibration.fov) }}°
+
+
+
+ 距离 {{ formatCalibrationValue(cameraCalibration.distance) }}
+
+
+
+
+ 视觉中心
+
+ {{ key.toUpperCase() }}
+
+
+
+
+ {{ cameraCalibrationSummary }}
+
+ 复位
+ 复制参数
+
+
+
+
{{ presentVisitorPoi({ ...selectedPOI, floorLabel: formatFloorLabel(selectedPOI.floorId), primaryCategory: { label: selectedPOI.primaryCategoryZh } }).displayName }}
{{ formatFloorLabel(selectedPOI.floorId) }} · {{ selectedPOI.primaryCategoryZh }}
@@ -53,23 +138,65 @@ 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 WeakNetworkGuideFallback from './WeakNetworkGuideFallback.vue'
+import {
+ isolateMeshMaterialsForFocus,
+ restoreIsolatedFocusMaterials,
+ type IsolatedFocusMaterialState
+} from './focusMaterialIsolation'
import { presentVisitorPoi } from '@/view-models/visitorPoiPresentation'
import type {
GuideModelFloorAsset,
GuideModelRenderPackage,
+ GuideModelRouteAsset,
GuideModelSource,
GuideRenderPoi
} from '@/domain/guideModel'
+import type { GuideViewportState } from '@/composables/useGuideSceneState'
+import {
+ createGuideBoundaryIntentState,
+ GUIDE_AUTO_SWITCH_ENTER_RATIO,
+ GUIDE_AUTO_SWITCH_EXIT_RATIO,
+ isGuideFloorAutoExitBlocked,
+ reduceGuideBoundaryIntent
+} from '@/domain/guideViewport'
+import {
+ createGuideObliqueReturnPose,
+ createGuideTopViewPose
+} from '@/domain/guideTopView'
+import {
+ getOverviewMapLabelDefinition,
+ isOverviewMapLabelMatch,
+ OVERVIEW_MAP_LABEL_DEFINITIONS,
+ type OverviewMapLabelDefinition
+} from '@/domain/overviewMapLabels'
import type {
GuideRouteEndpoint,
GuideRouteFloorSegment,
GuideRouteResult
} from '@/domain/museum'
import {
+ compileNavigationScene,
+ type NavigationScenePlan
+} from '@/domain/navigationScene'
+import {
+ getAmbientFacilityKind,
+ getAmbientFacilityLimit,
+ getAmbientFacilityTier,
getPoiDisplayPolicy,
+ getPoiMapLabelText,
+ getPoiMarkerLimit,
+ getPoiScreenSpacing,
+ getPoiVisibilityTier as getPoiVisibilityTierForDistanceRatio,
isPoiVisibilityTierAtLeast,
+ type AmbientFacilityKind,
type PoiVisibilityTier
} from '@/domain/poiDisplay'
+import {
+ getPoiIconHref,
+ resolvePoiIconKey,
+ type PoiIconKey
+} from '@/domain/poiCategories'
import {
compareFloorsTopToBottom,
getFloorSortLevel
@@ -78,6 +205,14 @@ import {
guideModelLoadManager,
type GuideModelResourceSet
} from '@/services/model/GuideModelLoadManager'
+import { guideModelPersistentCache } from '@/services/model/GuideModelPersistentCache'
+import {
+ createBackgroundPreloadScheduler,
+ getBackgroundPreloadPolicy,
+ getBrowserNetworkInfo
+} from '@/services/performance/backgroundPreloadScheduler'
+import { getGuideRendererPixelRatio } from '@/services/performance/rendererPerformance'
+import { startGuidePerformance } from '@/services/performance/guidePerformance'
import {
GuideAutoSwitchStateMachine,
type GuideAutoSwitchDirection,
@@ -93,12 +228,26 @@ import {
type PoiDomLabelKind
} from './poiDomLabels'
import { applyGuideModelMaterialPolicy } from './modelMaterialPolicy'
+import { loadFloorPoisForInitialRender } from './floorPoiLoader'
+import {
+ projectRoutePositionToSurface,
+ resolveDominantRouteSurfaceY
+} from './routeSurfaceProjection'
+import {
+ resolveRouteStartCandidate,
+ toRouteStartCandidatePayload,
+ type RouteStartCandidatePayload,
+ type RouteStartSelectablePoint
+} from './routeStartCandidateResolver'
type ViewMode = 'overview' | 'floor' | 'multi'
type CameraPreset = 'top' | 'oblique'
type TouchGestureMode = 'orbit' | 'pan'
type ZoomCameraSource = 'button' | 'gesture'
type InteractionPolicyMode = 'display' | 'explore'
+type RenderMode = 'three-d' | 'two-d'
+type FallbackPresentation = 'weak-network' | 'two-dimensional'
+type ActualRendererPath = 'three-d' | 'live-glb-top' | 'webp-fallback'
interface InitialModelProgressEvent {
progress: number
@@ -110,6 +259,9 @@ interface InitialModelProgressEvent {
const CANVAS_FONT_FAMILY = '"鸿蒙黑体", "HarmonyOS Sans SC", "HarmonyOS Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'
const FLOOR_CAMERA_DISTANCE_FACTOR = 0.78
+// The approved indoor entry composition is one zoom step closer than the
+// former floor baseline. It is also the outward-zoom boundary for every floor.
+const INDOOR_ENTRY_CAMERA_DISTANCE_FACTOR = 0.56
const SGS_VISUAL_RENDER_CONFIG = {
sceneBackground: 0xeceff1,
camera: {
@@ -153,32 +305,71 @@ const SGS_VISUAL_RENDER_CONFIG = {
},
framing: {
overviewScreenOffsetRatio: new THREE.Vector2(-0.045, -0.095),
- floorScreenOffsetRatio: new THREE.Vector2(0, -0.055)
+ overviewReferenceVerticalOffset: 44,
+ overviewMaxDistanceFactor: 1.35,
+ // The left floor rail and right zoom tools make the geometric center look
+ // left-biased. Shift the floor framing right into the visible map area.
+ floorScreenOffsetRatio: new THREE.Vector2(-0.05, -0.055)
}
} as const
-// 使用调试面板确认的建筑外观固定相机参数。
-const OVERVIEW_INITIAL_CAMERA_PARAMS = {
- position: new THREE.Vector3(338.6842, 410.8487, 225.5939),
- target: new THREE.Vector3(29.0598, -61.8768, 23.9424),
- zoom: 1
-} as const
+// 外观模型采用建筑 54 度方向观察,保留 42 度俯视的空间层次。
+const GUIDE_CAMERA_YAW_DEGREES = 54
+const GUIDE_CAMERA_ELEVATION_DEGREES = 42
+// Route cameras use one GLB-space contract and never derive distance from a
+// floor or composite-model bounding box.
+const ROUTE_PREVIEW_CAMERA_DISTANCE = 310
+const ROUTE_PREVIEW_LOOK_AHEAD = 14
+const ROUTE_NAVIGATION_CAMERA_DISTANCE = 160
+const ROUTE_NAVIGATION_LOOK_AHEAD = 16
+const ROUTE_NAVIGATION_CAMERA_ENTRY_MS = 460
+const ROUTE_NAVIGATION_RECENTER_MS = 620
+const ROUTE_NAVIGATION_RECENTER_MIN_INTERVAL_MS = 2200
+// L1 is the complete indoor footprint. Keep this reference dimension for every
+// floor so partial upper-floor assets do not receive an artificial zoom-in.
+const INDOOR_REFERENCE_FIT_DIMENSION = 270.39
-// The source floor ID is only the calibration origin. This is the shared
-// visitor-facing baseline for every ordinary floor, never a per-floor override.
-const FLOOR_INITIAL_CAMERA_PARAMS = {
- position: new THREE.Vector3(315.2911, 385.8819, 234.6114),
- target: new THREE.Vector3(19.7236, -65.382, 42.1148),
- zoom: 1
-} as const
+const getGuideCameraDirection = () => {
+ const yaw = THREE.MathUtils.degToRad(GUIDE_CAMERA_YAW_DEGREES)
+ const elevation = THREE.MathUtils.degToRad(GUIDE_CAMERA_ELEVATION_DEGREES)
+ const horizontal = Math.cos(elevation)
-const getIndoorInitialCameraDirection = () => (
- FLOOR_INITIAL_CAMERA_PARAMS.position
- .clone()
- .sub(FLOOR_INITIAL_CAMERA_PARAMS.target)
- .normalize()
+ return new THREE.Vector3(
+ Math.sin(yaw) * horizontal,
+ Math.sin(elevation),
+ Math.cos(yaw) * horizontal
+ ).normalize()
+}
+
+const getIndoorReferenceCameraDistance = (
+ fov: number = SGS_VISUAL_RENDER_CONFIG.camera.fov
+) => (
+ Math.abs(INDOOR_REFERENCE_FIT_DIMENSION / Math.sin(THREE.MathUtils.degToRad(fov) / 2))
+ * INDOOR_ENTRY_CAMERA_DISTANCE_FACTOR
)
+// 使用调试面板确认的建筑外观中心和基准距离。
+const OVERVIEW_INITIAL_CAMERA_TARGET = new THREE.Vector3(29.0598, -61.8768, 23.9424)
+const OVERVIEW_INITIAL_CAMERA_PARAMS = {
+ position: OVERVIEW_INITIAL_CAMERA_TARGET.clone().add(getGuideCameraDirection().multiplyScalar(720)),
+ target: OVERVIEW_INITIAL_CAMERA_TARGET,
+ zoom: 1
+} as const
+
+// Indoor views inherit the exterior's approved visual center. Individual floor
+// bounding-box centers are not visitor-facing camera targets.
+const FLOOR_INITIAL_CAMERA_TARGET = OVERVIEW_INITIAL_CAMERA_TARGET.clone()
+const FLOOR_INITIAL_CAMERA_PARAMS = {
+ position: FLOOR_INITIAL_CAMERA_TARGET.clone()
+ .add(getGuideCameraDirection().multiplyScalar(getIndoorReferenceCameraDistance())),
+ target: FLOOR_INITIAL_CAMERA_TARGET,
+ zoom: 1
+} as const
+
+// Indoor and exterior share the approved yaw/elevation/FOV contract. Floors
+// vary only by their precomputed target and fitted distance.
+const getIndoorInitialCameraDirection = () => getGuideCameraDirection()
+
const INDOOR_INITIAL_MODEL_PARAMS = {
position: new THREE.Vector3(0, 0, 0),
rotation: new THREE.Euler(
@@ -226,6 +417,7 @@ interface CameraFitOptions {
screenOffsetRatio?: THREE.Vector2
up?: THREE.Vector3
durationMs?: number
+ immediate?: boolean
onComplete?: () => void
}
@@ -233,6 +425,7 @@ interface LoadFloorOptions {
preserveCurrentSceneUntilReady?: boolean
suppressProgress?: boolean
detachPoiBeforeLoad?: boolean
+ preserveRouteRoaming?: boolean
allowSameFloorReload?: boolean
cameraSnapshot?: CameraSnapshot
applyFloorBaseline?: boolean
@@ -241,10 +434,12 @@ interface LoadFloorOptions {
interface LoadOverviewOptions {
cameraSnapshot?: CameraSnapshot
+ onCameraStable?: () => void
}
interface LoadModelOptions {
suppressProgress?: boolean
+ modelVersion?: string
}
interface CameraTweenState {
@@ -283,6 +478,54 @@ interface FloorViewBaseline {
aspectRatio: number
boundsSignature: string
camera: CameraSnapshot
+ minDistance: number
+ maxDistance: number
+}
+
+interface OrbitControlsSnapshot {
+ enabled: boolean
+ cursor: THREE.Vector3
+ enableDamping: boolean
+ dampingFactor: number
+ enableZoom: boolean
+ zoomSpeed: number
+ zoomToCursor: boolean
+ minDistance: number
+ maxDistance: number
+ minZoom: number
+ maxZoom: number
+ minTargetRadius: number
+ maxTargetRadius: number
+ enableRotate: boolean
+ rotateSpeed: number
+ minPolarAngle: number
+ maxPolarAngle: number
+ minAzimuthAngle: number
+ maxAzimuthAngle: number
+ enablePan: boolean
+ panSpeed: number
+ screenSpacePanning: boolean
+ keyPanSpeed: number
+ autoRotate: boolean
+ autoRotateSpeed: number
+ keys: OrbitControls['keys']
+ mouseButtons: OrbitControls['mouseButtons']
+ touches: OrbitControls['touches']
+ target0: THREE.Vector3
+ position0: THREE.Vector3
+ zoom0: number
+}
+
+type CameraCalibrationKey = 'yaw' | 'elevation' | 'fov' | 'distance' | 'x' | 'y' | 'z'
+
+interface CameraCalibrationState {
+ yaw: number
+ elevation: number
+ fov: number
+ distance: number
+ x: number
+ y: number
+ z: number
}
// 所有普通楼层/外观切换共享该外观基准;切换时使用完整快照恢复而非包围盒拟合。
@@ -363,6 +606,7 @@ interface PoiSpriteUserData {
isPoiBase?: boolean
isPoiHitTarget?: boolean
isCorePoi?: boolean
+ usesDomLabelIcon?: boolean
}
interface PoiDomLabelHandle {
@@ -374,18 +618,10 @@ interface PoiDomLabelHandle {
floorId: string
size: { width: number; height: number }
active: boolean
+ layoutOffset: { x: number; y: number }
ownsAnchor?: 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
@@ -421,7 +657,9 @@ interface ReusableModelResources {
interface PoiMarkerCacheEntry {
floorId: string
displayMode: PoiDisplayMode
+ dataTier: 'fast' | 'full'
pois: RenderPoi[]
+ rawPois?: RenderPoi[]
group: THREE.Group
domLabelHandles: PoiDomLabelHandle[]
markerSize: number
@@ -447,11 +685,15 @@ interface PreparedFloorModelCacheEntry {
type ContainerRef = HTMLElement | { $el?: HTMLElement } | null
+type RouteSelectablePoint = RouteStartSelectablePoint
+
const props = withDefaults(defineProps<{
assetBaseUrl?: string
modelSource: GuideModelSource
+ activeFloor?: string
initialFloorId?: string
initialView?: ViewMode
+ sceneView?: ViewMode
showControls?: boolean
showPoi?: boolean
visiblePoiIds?: string[] | null
@@ -460,13 +702,22 @@ const props = withDefaults(defineProps<{
targetFocusDistanceFactor?: number
routePreview?: GuideRouteResult | null
showRoute?: boolean
+ routeNavigationActive?: boolean
+ routeStartSelectionActive?: boolean
+ routeSelectablePoiIds?: string[]
+ routeSelectablePoints?: RouteSelectablePoint[]
+ renderMode?: RenderMode
+ sceneRevision?: number
+ sceneViewport?: GuideViewportState | null
autoSwitch?: boolean
disableAutoExit?: boolean
autoSwitchCooldown?: number
}>(), {
assetBaseUrl: '',
+ activeFloor: '',
initialFloorId: 'L1',
initialView: 'overview',
+ sceneView: undefined,
showControls: true,
showPoi: false,
visiblePoiIds: null,
@@ -475,27 +726,84 @@ const props = withDefaults(defineProps<{
targetFocusDistanceFactor: 0.36,
routePreview: null,
showRoute: false,
+ routeNavigationActive: false,
+ routeStartSelectionActive: false,
+ routeSelectablePoiIds: () => [],
+ routeSelectablePoints: () => [],
+ renderMode: 'three-d',
+ sceneRevision: 0,
+ sceneViewport: null,
autoSwitch: true,
disableAutoExit: false,
autoSwitchCooldown: 1500
})
const emit = defineEmits<{
- floorChange: [floorId: string]
+ floorChange: [floorId: string, sceneRevision?: number]
poiClick: [poi: RenderPoi]
+ routeStartCandidate: [candidate: RouteStartCandidatePayload]
+ routeStartCandidateRejected: []
selectionClear: []
targetFocus: [result: TargetPoiFocusResult]
- autoSwitch: [event: { from: 'overview' | 'floor'; to: 'overview' | 'floor'; trigger: 'zoom-in' | 'zoom-out'; distance: number }]
+ routeRoamingProgress: [event: { remainingMeters: number; progress: number }]
+ routeRoamingTransfer: [event: {
+ fromFloorId: string
+ toFloorId: string
+ transferType: string
+ connectorName?: string
+ }]
+ autoSwitch: [event: {
+ from: 'overview' | 'floor'
+ to: 'overview' | 'floor'
+ trigger: 'zoom-in' | 'zoom-out'
+ distance: number
+ sceneRevision: number
+ }]
autoSwitchBlocked: [reason: { reason: 'loading-locked' | 'cooldown' | 'disabled' }]
initialModelProgress: [event: InitialModelProgressEvent]
initialModelReady: [event: { view: ViewMode; floorId?: string; elapsedMs?: number }]
- initialModelFailed: [event: { view: ViewMode; floorId?: string; message: string; elapsedMs?: number }]
+ initialModelFailed: [event: {
+ view: ViewMode
+ floorId?: string
+ message: string
+ elapsedMs?: number
+ fallbackAvailable?: boolean
+ actualRenderMode?: RenderMode
+ }]
+ renderModeFallback: [event: {
+ mode: 'two-d'
+ view: 'overview' | 'floor'
+ floorId?: string
+ reason: string
+ sceneRevision: number
+ }]
+ sceneViewportChange: [viewport: GuideViewportState]
}>()
const containerRef = ref(null)
const poiDomLabelLayerRef = ref(null)
const isLoading = ref(true)
const loadError = ref(false)
+const weakNetworkFallbackActive = ref(false)
+const threeRendererRetainedForTwoD = ref(false)
+const liveGlbTopActive = ref(false)
+const actualRendererPath = computed(() => (
+ weakNetworkFallbackActive.value
+ ? 'webp-fallback'
+ : liveGlbTopActive.value
+ ? 'live-glb-top'
+ : 'three-d'
+))
+const weakNetworkFallbackPois = ref([])
+const weakNetworkFallbackPoiCache = new Map()
+let weakNetworkFallbackPoiRequestRevision = 0
+const fallbackPresentation = ref(null)
+const weakNetworkFallbackRef = ref<{
+ resetCamera?: () => void
+ zoomCamera?: (direction: 'in' | 'out', source?: ZoomCameraSource) => GuideViewportState | void
+ getGuideViewportState?: () => GuideViewportState | null
+} | null>(null)
+let pendingWeakNetworkZoom: { direction: 'in' | 'out'; source: ZoomCameraSource } | null = null
const loadingProgress = ref(0)
const loadingMessage = ref('正在读取馆内导览资源...')
const loadingDisplayMessage = '正在加载馆内三维场景'
@@ -505,11 +813,51 @@ const setFriendlyModelLoadError = () => {
setProgress(0, modelLoadErrorMessage)
}
const activeView = ref(props.initialView)
+
+// `sceneView` is the page-owned business scene. `initialView` remains only as
+// a compatibility default for existing embedding points.
+const requestedSceneView = computed(() => props.sceneView || props.initialView)
+
+const emitFloorChange = (floorId: string, sceneRevision = props.sceneRevision) => {
+ emit('floorChange', floorId, sceneRevision)
+}
const currentFloor = ref(props.initialFloorId)
const selectedPOI = ref(null)
const activeFocusPoiId = ref('')
const floorIndex = ref([])
const renderPackage = ref(null)
+const isCameraCalibrationMode = ref(
+ import.meta.env.DEV
+ && typeof window !== 'undefined'
+ && new URLSearchParams(window.location.search).get('camera-calibration') === '1'
+)
+const isWeakNetworkPreviewMode = () => (
+ import.meta.env.DEV
+ && typeof window !== 'undefined'
+ && new URLSearchParams(window.location.search).get('weak-map-preview') === '1'
+)
+const isTwoDimensionalMode = computed(() => (
+ props.renderMode === 'two-d' || isWeakNetworkPreviewMode()
+))
+const weakNetworkFallbackFloor = computed(() => {
+ if (activeView.value === 'overview') {
+ return getWeakNetworkOverviewFloor()
+ }
+
+ return floorIndex.value.find((floor) => floor.floorId === currentFloor.value) || null
+})
+const isCameraCalibrationPanelExpanded = ref(false)
+const cameraCalibration = ref({
+ yaw: GUIDE_CAMERA_YAW_DEGREES,
+ elevation: GUIDE_CAMERA_ELEVATION_DEGREES,
+ fov: SGS_VISUAL_RENDER_CONFIG.camera.fov,
+ distance: OVERVIEW_INITIAL_CAMERA_PARAMS.position.distanceTo(OVERVIEW_INITIAL_CAMERA_PARAMS.target),
+ x: OVERVIEW_INITIAL_CAMERA_PARAMS.target.x,
+ y: OVERVIEW_INITIAL_CAMERA_PARAMS.target.y,
+ z: OVERVIEW_INITIAL_CAMERA_PARAMS.target.z
+})
+const cameraCalibrationCenterKeys = ['x', 'y', 'z'] as const
+const cameraCalibrationCopyStatus = ref('')
const floors = computed(() => (
[...floorIndex.value]
@@ -542,27 +890,71 @@ let controls: OrbitControls | null = null
let loader: GLTFLoader | null = null
let dracoLoader: DRACOLoader | null = null
let activeModel: THREE.Object3D | null = null
+let activeRouteCompositeModel: THREE.Object3D | null = null
+let activeRouteCompositeUrl = ''
+let routeCompositeLoadSignature = ''
+let routeCompositeExitSyncSeq = 0
+const routeFloorSurfaceYCache = new WeakMap>()
+const resolvedRouteFloorSurfaceCache = new WeakMap>()
+const routePointSurfaceYCache = new WeakMap>()
let poiGroup: THREE.Group | null = null
let routeGroup: THREE.Group | null = null
let activeRoutePreviewSignature = ''
+let routeRoamingStartedAt = 0
+let routeRoamingDurationMs = 0
+let routeRoamingPoints: THREE.Vector3[] = []
+let routeRoamingMarker: THREE.Sprite | null = null
+let routeRoamingSessionActive = false
+let routeRoamingTransitioning = false
+let routeRoamingSegmentIndex = 0
+let routeRoamingCompletedDistance = 0
+let routeRoamingTotalDistance = 0
+let lastRouteRoamingRemainingMeters: number | null = null
+let routeRoamingCameraOffset: THREE.Vector3 | null = null
+let routeRoamingLookAhead = 0
+let routeRoamingLastCameraMoveAt = 0
+
+const ensureModelLoader = () => {
+ if (!loader) {
+ loader = new GLTFLoader()
+ }
+ if (!dracoLoader) {
+ dracoLoader = new DRACOLoader()
+ dracoLoader.setDecoderPath('/static/three/draco/')
+ }
+ loader.setDRACOLoader(dracoLoader)
+ return loader
+}
+
const poiDataCache = new Map()
+const poiDataTierCache = new Map()
+const poiDataLoadInFlight = new Map>()
const poiMarkerGroupCache = new Map()
+const poiEnrichmentInFlight = new Map>()
const preparedFloorModelCache = new Map()
const poiCoordinateDiagnosticsKeys = new Set()
let activeFocusDomLabel: PoiDomLabelHandle | null = null
let poiDomLabelResizeObserver: ResizeObserver | null = null
const poiDomLabelHandlesByElement = new Map()
-let activeFocusPulseSprite: THREE.Sprite | null = null
-let activeFocusBaseSprite: THREE.Sprite | null = null
+const overviewMapLabelHandles: PoiDomLabelHandle[] = []
+let overviewMapLabelOwner: THREE.Object3D | null = null
+let activeFocusPulseSprites: THREE.Sprite[] = []
+let activeFocusBaseSprites: THREE.Sprite[] = []
+let activeFocusStartedDataTier: 'fast' | 'full' | null = null
let activeFocusHallGlowMesh: THREE.Mesh | null = null
-let activeFocusHallMaterialStates: FocusHallMaterialState[] = []
+let activeFocusHallMaterialStates: IsolatedFocusMaterialState[] = []
+let activeFocusModelRootCount = 0
+let activeFocusModelRootNames: string[] = []
let animationId = 0
+let renderLoopRunning = false
let resizeObserver: ResizeObserver | null = null
let isDisposed = false
let pendingTargetFocus: TargetPoiFocusRequest | null = null
let targetFocusQueue: Promise = Promise.resolve()
let targetFocusGeneration = 0
let modelLoadVersion = 0
+let sceneInitializationVersion = 0
+let renderModeTransitionRevision = 0
let pendingRequestedFloorId = ''
let floorSwitchLoadToken = 0
let floorSwitchRequestedFloorId = ''
@@ -590,18 +982,29 @@ let modelAdjustReportTimer: ReturnType | null = null
let hasPendingManualModelAdjustment = false
let activeAutoSwitchInputSource: GuideAutoSwitchInputSource = 'gesture'
let isProgrammaticCameraChange = false
+let hasActiveUserCameraGesture = false
let programmaticCameraTimer: ReturnType | null = null
let cameraTween: CameraTweenState | null = null
+let buttonAutoSwitchTimer: ReturnType | null = null
+let floorNavigationDistance = 0
let poiFocusCameraAnimationCount = 0
let cameraSnapshotRestoreCount = 0
let adjacentPreloadSeq = 0
let defaultFloorPreloadSeq = 0
+let weakNetworkFallbackPreloadSeq = 0
+const adjacentPreloadScheduler = createBackgroundPreloadScheduler()
+const defaultFloorPreloadScheduler = createBackgroundPreloadScheduler()
+const poiEnrichmentScheduler = createBackgroundPreloadScheduler()
let firstModelLoadStartedAt = 0
let firstModelVisibleReported = false
let initialModelSettled = false
let initialGuideState: { floorId: string; camera: CameraSnapshot } | null = null
const floorViewBaselines = new Map()
let modelPackageEpoch = 0
+let twoDViewportChangedSinceModeEntry = false
+let weakNetworkBoundaryIntentState = createGuideBoundaryIntentState()
+let liveGlbThreeDCameraSnapshot: CameraSnapshot | null = null
+let liveGlbThreeDControlsSnapshot: OrbitControlsSnapshot | null = null
const cameraTweenEnabled = true
const cameraTweenDurationMs = 480
@@ -610,13 +1013,24 @@ const programmaticCameraTailMs = 40
const poiTapFeedbackDurationMs = 180
const poiHitTargetScaleMultiplier = 4.8
const poiHitTargetCoreScaleMultiplier = 5.4
-const poiScreenHitRadiusPx = 64
-const poiScreenHitRadiusCorePx = 76
+// Keep blank-map taps from being absorbed by a nearby POI. Labels have their
+// full DOM rectangle as the hit area; these radii only cover the small icon.
+const poiScreenHitRadiusPx = 32
+const poiScreenHitRadiusCorePx = 42
const modelLoadRetryDelaysMs = [300, 900]
-const adjacentPreloadDelayMs = 900
+const foregroundModelLoadStallTimeoutMs = 2500
+const foregroundInitialInteractiveBudgetMs = 5000
+const backgroundModelLoadStallTimeoutMs = 6500
+const backgroundModelLoadTotalTimeoutMs = 18000
const manualAutoSwitchPauseMs = 1500
-const autoSwitchExitDistanceRatio = 0.2
-const autoSwitchExitDistanceMarginRatio = 0.08
+// The shared exterior composition remains the indoor return reference, but users
+// need a short indoor inspection band before a deliberate zoom-out exits it.
+// 达到室内外退出阈值后在按钮动画内接续模型切换,避免停留在无效的中间状态。
+const autoSwitchEnterHoldMs = 120
+// Indoor entry framing is the visual baseline. The extra outward range leaves
+// room for ordinary inspection gestures; only the final 3% is the exit band.
+const floorZoomInLimitRatio = 0.2
+const floorZoomOutLimitRatio = 1.18
type ThreeMapDiagnosticEvent =
| 'init-start'
@@ -639,10 +1053,19 @@ type ThreeMapDiagnosticEvent =
| 'poi-filter-diagnostics'
| 'poi-coordinate-diagnostics'
| 'poi-marker-render-diagnostics'
+ | 'poi-marker-attach-skipped'
+ | 'overview-label-anchor'
| 'poi-hit-diagnostics'
+ | 'route-surface-resolved'
| 'adjacent-preload-skip'
+ | 'default-floor-preload-schedule'
| 'default-floor-preload-start'
+ | 'default-floor-preload-skip'
| 'default-floor-preload-complete'
+ | 'weak-network-model-preload-start'
+ | 'weak-network-model-preload-skip'
+ | 'weak-network-model-preload-complete'
+ | 'weak-network-model-preload-failed'
interface BuildingAnchor {
id: string
@@ -809,6 +1232,7 @@ const resetInteractionGateState = () => {
alt: false
}
isDesktopRotateModifierActive = false
+ hasActiveUserCameraGesture = false
syncControlInteractionOptions()
}
@@ -816,6 +1240,24 @@ const syncControlInteractionOptions = (event?: PointerEvent) => {
if (!controls) return
const policy = INTERACTION_POLICY[getInteractionPolicyMode()]
+ if (liveGlbTopActive.value) {
+ controls.enableRotate = false
+ controls.enablePan = policy.allowPan
+ controls.enableZoom = policy.allowZoom
+ controls.screenSpacePanning = true
+ controls.panSpeed = 0.85
+ controls.minPolarAngle = 0
+ controls.maxPolarAngle = Math.PI
+ controls.minAzimuthAngle = Number.NEGATIVE_INFINITY
+ controls.maxAzimuthAngle = Number.POSITIVE_INFINITY
+ controls.mouseButtons.LEFT = policy.allowPan ? THREE.MOUSE.PAN : null
+ controls.mouseButtons.MIDDLE = THREE.MOUSE.DOLLY
+ controls.mouseButtons.RIGHT = policy.allowPan ? THREE.MOUSE.PAN : null
+ controls.touches.ONE = policy.allowPan ? THREE.TOUCH.PAN : null
+ controls.touches.TWO = policy.allowZoom ? THREE.TOUCH.DOLLY_PAN : null
+ return
+ }
+
const enableRotate = shouldEnableRotation()
const enablePan = policy.allowPan && !enableRotate
const directMouseRotate = enableRotate && shouldUseDirectMouseRotate(event)
@@ -847,12 +1289,6 @@ const syncControlInteractionOptions = (event?: PointerEvent) => {
controls.touches.TWO = THREE.TOUCH.DOLLY_ROTATE
}
-const isHallPoi = (poi: RenderPoi) => (
- poi.kind === 'hall'
- || poi.primaryCategory === 'exhibition_hall'
- || poi.primaryCategory === 'touring_poi'
-)
-
const isServiceFacilityPoi = (poi: RenderPoi) => (
poi.primaryCategory === 'basic_service_facility'
|| poi.primaryCategory === 'accessibility_special_service'
@@ -864,32 +1300,18 @@ const isTransportPoi = (poi: RenderPoi) => poi.primaryCategory === 'transport_ci
const getPoiPolicy = (poi: RenderPoi) => (
poi.displayPolicy || getPoiDisplayPolicy({
primaryCategory: poi.primaryCategory,
+ iconType: poi.iconType,
kind: poi.kind
})
)
-const poiGlyphMap: Record = {
- poi: '点',
- accessible_restroom: '无',
- accessibility: '无',
- restroom: '卫',
- water: '水',
- rest_area: '休',
- elevator: '梯',
- stair: '梯',
- experience: '展',
- theater: '演',
- restaurant: '餐',
- cafe: '咖',
- shop: '购',
- bookstore: '书',
- cultural_shop: '文',
- business: '商',
- exhibition_hall: '展',
- hall_entrance: '门',
- entrance_exit: '门',
- target_preview: '位'
-}
+const waitForNextVisualFrame = () => new Promise((resolve) => {
+ if (typeof requestAnimationFrame === 'function') {
+ requestAnimationFrame(() => resolve())
+ return
+ }
+ setTimeout(resolve, 0)
+})
const getContainerElement = () => {
const container = containerRef.value
@@ -909,6 +1331,35 @@ const formatFloorLabel = (floorId: string) => (
floorIndex.value.find((floor) => floor.floorId === floorId)?.label || floorId
)
+const getWeakNetworkOverviewFloor = (): FloorIndexItem | null => {
+ const overviewFloorId = renderPackage.value?.overviewFloorId
+ if (!overviewFloorId || !renderPackage.value?.overviewModelUrl) return null
+
+ // The exterior WebP must never inherit L1 simply because that floor happens
+ // to be first in a package. Only an explicitly exterior floor is allowed to
+ // supply overview labels.
+ const indexedExteriorFloor = floorIndex.value.find((floor) => (
+ [floor.floorId, floor.label, ...(floor.modelMatchKeys || [])]
+ .some((value) => /^(EXTERIOR|OUTDOOR|OUT|室外)$/i.test(String(value || '').trim()))
+ ))
+ if (indexedExteriorFloor) return indexedExteriorFloor
+
+ // The SDK can omit EXTERIOR from the indoor floor list. Keep this virtual
+ // source deliberately separate from the current indoor floor, but retain
+ // the real overview floor id so the repository can load the exterior bundle
+ // and its space labels instead of querying an unknown alias.
+ return {
+ floorId: overviewFloorId,
+ label: '室外导览',
+ order: Number.NEGATIVE_INFINITY,
+ modelUrl: renderPackage.value.overviewModelUrl,
+ modelUrls: renderPackage.value.overviewModelUrls,
+ modelVersion: renderPackage.value.overviewModelVersion,
+ sharedModelAsset: true,
+ modelMatchKeys: ['EXTERIOR', 'OUTDOOR', '室外']
+ }
+}
+
const floorExteriorNameKeywords = [
'外墙',
'玻璃幕墙',
@@ -954,27 +1405,48 @@ const normalizeModelMatchKey = (value: string) => (
.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 getUniqueModelMatchKeys = (values: Array) => values
+ .map((value) => value?.trim() || '')
+ .filter(Boolean)
+ .filter((value, index, keys) => keys.indexOf(value) === index)
+
+const getPoiSourceModelMatchKeys = (poi: RenderPoi) => getUniqueModelMatchKeys([
+ poi.sourceObjectName,
+ ...(poi.mergedSourceObjectNames || [])
+])
+
+const getPoiFallbackModelMatchKeys = (poi: RenderPoi) => getUniqueModelMatchKeys([
+ `${poi.floorId}_${poi.name}`,
+ poi.name
+])
+
+const isExactModelNodeNameMatch = (nodeName: string, matchKey: string) => {
+ const normalizedName = normalizeModelMatchKey(nodeName)
+ const normalizedKey = normalizeModelMatchKey(matchKey)
+ if (!normalizedName || !normalizedKey) return false
+ if (normalizedName === normalizedKey) return true
+
+ // Blender may append a .001-style duplicate suffix. Keep this compatibility
+ // narrow so numbered devices such as 导览屏1 never match 导览屏10.
+ const trimmedName = nodeName.trim().toLowerCase()
+ if (!/\.\d{3}$/.test(trimmedName)) return false
+ return normalizeModelMatchKey(trimmedName.replace(/\.\d{3}$/, '')) === normalizedKey
+}
+
+const doesObjectHierarchyMatchKeys = (object: THREE.Object3D, keys: string[]) => (
+ getModelNodeNames(object).some((name) => (
+ keys.some((key) => isExactModelNodeNameMatch(name, key))
+ ))
)
const isPoiModelNodeMatch = (object: THREE.Object3D, poi: RenderPoi) => {
- const keys = getPoiModelMatchKeys(poi)
- if (!keys.length) return false
+ const sourceKeys = getPoiSourceModelMatchKeys(poi)
+ if (sourceKeys.length && doesObjectHierarchyMatchKeys(object, sourceKeys)) return true
- return getModelNodeNames(object)
- .map(normalizeModelMatchKey)
- .some((name) => keys.some((key) => name === key || name.includes(key)))
+ return doesObjectHierarchyMatchKeys(object, getPoiFallbackModelMatchKeys(poi))
}
-const findPoiModelRoot = (poi: RenderPoi): THREE.Object3D | null => {
+const findModelRootByKeys = (keys: string[]): THREE.Object3D | null => {
if (!activeModel) return null
let matchedRoot: THREE.Object3D | null = null
@@ -982,9 +1454,7 @@ const findPoiModelRoot = (poi: RenderPoi): THREE.Object3D | 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))) {
+ if (child.name && keys.some((key) => isExactModelNodeNameMatch(child.name, key))) {
matchedRoot = child
}
})
@@ -994,7 +1464,7 @@ const findPoiModelRoot = (poi: RenderPoi): THREE.Object3D | null => {
activeModel.traverse((child) => {
if (matchedRoot || !(child instanceof THREE.Mesh) || !child.visible) return
- if (isPoiModelNodeMatch(child, poi)) {
+ if (doesObjectHierarchyMatchKeys(child, keys)) {
matchedRoot = child.parent && child.parent !== activeModel ? child.parent : child
}
})
@@ -1002,6 +1472,19 @@ const findPoiModelRoot = (poi: RenderPoi): THREE.Object3D | null => {
return matchedRoot
}
+const findPoiModelRoots = (poi: RenderPoi) => {
+ const sourceObjectNames = getPoiSourceModelMatchKeys(poi)
+
+ const roots = sourceObjectNames
+ .map((sourceObjectName) => findModelRootByKeys([sourceObjectName]))
+ .filter((root): root is THREE.Object3D => Boolean(root))
+ .filter((root, index, candidates) => candidates.indexOf(root) === index)
+
+ if (roots.length) return roots
+ const fallbackRoot = findModelRootByKeys(getPoiFallbackModelMatchKeys(poi))
+ return fallbackRoot ? [fallbackRoot] : []
+}
+
const isExteriorModelNode = (object: THREE.Object3D) => (
getModelNodeNames(object).some((name) => (
floorExteriorNameKeywords.some((keyword) => name.includes(keyword))
@@ -1188,6 +1671,34 @@ const getPoiDisplayPosition = (poi: RenderPoi) => {
return new THREE.Vector3(x, y, z)
}
+const getPoiDisplayPositions = (poi: RenderPoi) => {
+ const positions = [poi.positionGltf, ...(poi.mergedDisplayPositions || [])]
+ .filter((position): position is [number, number, number] => Boolean(position))
+
+ return positions
+ .filter((position, index) => (
+ positions.findIndex((candidate) => (
+ candidate.every((value, coordinateIndex) => (
+ Math.abs(value - position[coordinateIndex]) < 1e-6
+ ))
+ )) === index
+ ))
+ .map(([x, y, z]) => new THREE.Vector3(x, y, z))
+}
+
+const getPoiMarkerPositions = (poi: RenderPoi) => {
+ const positions = [poi.positionGltf, ...(poi.mergedMarkerPositions || [])]
+ .filter((position): position is [number, number, number] => Boolean(position))
+
+ return positions
+ .filter((position, index) => (
+ positions.findIndex((candidate) => candidate.every((value, coordinateIndex) => (
+ Math.abs(value - position[coordinateIndex]) < 1e-6
+ ))) === index
+ ))
+ .map(([x, y, z]) => new THREE.Vector3(x, y, z))
+}
+
const recordPoiCoordinateDiagnostics = (
floorId: string,
model: THREE.Object3D,
@@ -1244,10 +1755,6 @@ const recordPoiCoordinateDiagnostics = (
})
}
-const getPoiGlyph = (poi: RenderPoi) => (
- poiGlyphMap[poi.iconType] || poi.name.charAt(0) || '点'
-)
-
const shouldShowPoiInCurrentMode = (poi: RenderPoi) => {
const mode = getPoiDisplayMode()
const policy = getPoiPolicy(poi)
@@ -1285,11 +1792,16 @@ const getActiveModelSpan = () => {
const getPoiVisibilityTier = (): PoiVisibilityTier => {
if (!controls || !activeModel) return 'full'
- const ratio = controls.getDistance() / getActiveModelSpan()
+ // Distance tiers must be reachable within the floor navigation range. The
+ // model span is useful for fitting a camera, but it is not the user's zoom
+ // baseline and varies between floor assets. Use the committed floor entry
+ // distance when available so every floor can reach balanced/full labels.
+ const referenceDistance = activeView.value === 'floor' && floorNavigationDistance > 0
+ ? floorNavigationDistance
+ : getActiveModelSpan()
+ const ratio = controls.getDistance() / referenceDistance
- if (ratio >= 0.32) return 'tight'
- if (ratio >= 0.2) return 'balanced'
- return 'full'
+ return getPoiVisibilityTierForDistanceRatio(ratio)
}
const shouldShowPoiAtDistance = (poi: RenderPoi, tier: PoiVisibilityTier) => {
@@ -1302,36 +1814,74 @@ const shouldShowPoiAtDistance = (poi: RenderPoi, tier: PoiVisibilityTier) => {
&& isPoiVisibilityTierAtLeast(tier, policy.minMarkerTier)
}
-const getPoiVisibilityLimit = (tier: PoiVisibilityTier) => {
- if (tier === 'tight') return 5
- if (tier === 'balanced') return 9
- return Number.POSITIVE_INFINITY
+const isIndoorLandmarkPoi = (poi: RenderPoi) => (
+ activeView.value === 'floor'
+ && getPoiPolicy(poi).pinInOverview
+)
+
+const isAmbientFacilityPoi = (poi: RenderPoi) => {
+ const policy = getPoiPolicy(poi)
+ return Boolean(getAmbientFacilityTier(poi.iconType))
+ && policy.labelVisible
+ && !policy.pinInOverview
}
-const getPoiScreenSpacing = (tier: PoiVisibilityTier) => {
- if (tier === 'tight') return 116
- if (tier === 'balanced') return 78
- return 0
+const isRouteEndpointPoi = (poi: RenderPoi) => Boolean(
+ props.showRoute
+ && props.routePreview
+ && (poi.id === props.routePreview.start.poiId || poi.id === props.routePreview.end.poiId)
+)
+
+interface AmbientFacilityCounts {
+ total: number
+ balanced: number
+ nearOnly: number
+ byKind: Map
+}
+
+const createAmbientFacilityCounts = (): AmbientFacilityCounts => ({
+ total: 0,
+ balanced: 0,
+ nearOnly: 0,
+ byKind: new Map()
+})
+
+const exceedsAmbientFacilityQuota = (
+ poi: RenderPoi,
+ tier: PoiVisibilityTier,
+ counts: AmbientFacilityCounts
+) => {
+ const facilityTier = getAmbientFacilityTier(poi.iconType)
+ const facilityKind = getAmbientFacilityKind(poi.iconType)
+ if (!facilityTier || !facilityKind) return false
+
+ if (counts.total >= getAmbientFacilityLimit(tier)) return true
+ if ((counts.byKind.get(facilityKind) || 0) >= 2) return true
+ if (facilityTier === 'near-only') return tier !== 'full' || counts.nearOnly >= 4
+ return counts.balanced >= 4
+}
+
+const recordAmbientFacility = (poi: RenderPoi, counts: AmbientFacilityCounts) => {
+ const facilityTier = getAmbientFacilityTier(poi.iconType)
+ const facilityKind = getAmbientFacilityKind(poi.iconType)
+ if (!facilityTier || !facilityKind) return
+
+ counts.total += 1
+ if (facilityTier === 'near-only') counts.nearOnly += 1
+ else counts.balanced += 1
+ counts.byKind.set(facilityKind, (counts.byKind.get(facilityKind) || 0) + 1)
}
const getPoiPriority = (poi: RenderPoi) => {
const categoryPriority = getPoiPolicy(poi).priority
const selectedBoost = poi.id === activeFocusPoiId.value ? 1000 : 0
- const hallBoost = activeView.value === 'floor' && isHallPoi(poi) ? 40 : 0
- const transportPenalty = isTransportPoi(poi) ? 18 : 0
+ const hallBoost = activeView.value === 'floor' && isIndoorLandmarkPoi(poi) ? 40 : 0
+ const transportPenalty = isTransportPoi(poi) && !isAmbientFacilityPoi(poi) ? 18 : 0
return selectedBoost + hallBoost + categoryPriority - transportPenalty
}
-const getPoiLabelDensityTier = (): PoiVisibilityTier => {
- if (!controls || !activeModel) return 'full'
-
- const ratio = controls.getDistance() / getActiveModelSpan()
-
- if (ratio >= 0.42) return 'tight'
- if (ratio >= 0.26) return 'balanced'
- return 'full'
-}
+const getPoiLabelDensityTier = getPoiVisibilityTier
const shouldCreateAmbientPoiLabel = (poi: RenderPoi) => (
getPoiPolicy(poi).labelVisible
@@ -1342,15 +1892,20 @@ const shouldShowAmbientPoiLabel = (
markerVisible: boolean,
densityTier: PoiVisibilityTier
) => {
+ if (props.showRoute && activeView.value === 'multi') return false
if (!markerVisible || activeView.value !== 'floor') return false
- if (poi.id === activeFocusPoiId.value || poi.primaryCategory === 'target_preview') return false
+ if (isRouteEndpointPoi(poi)) return true
+ if (visiblePoiIdFilter.value !== null && isPoiIncludedByVisibleFilter(poi)) return true
+ // 选中对象仍沿用同一枚轻量标签;详情仅由底部信息卡承载,避免出现重复的大浮层。
+ if (poi.id === activeFocusPoiId.value || poi.primaryCategory === 'target_preview') return true
const policy = getPoiPolicy(poi)
return policy.labelVisible
&& isPoiVisibilityTierAtLeast(densityTier, policy.minLabelTier)
}
const getPoiAmbientLabelPriority = (poi: RenderPoi) => {
- if (isHallPoi(poi)) return getPoiPriority(poi) + 120
+ if (isIndoorLandmarkPoi(poi)) return getPoiPriority(poi) + 120
+ if (isAmbientFacilityPoi(poi)) return getPoiPriority(poi)
if (isServiceFacilityPoi(poi)) return getPoiPriority(poi) + 20
return getPoiPriority(poi)
@@ -1387,6 +1942,20 @@ const setPoiDomLabelVisible = (handle: PoiDomLabelHandle, visible: boolean) => {
handle.element.style.opacity = visible ? '1' : '0'
}
+const isPoiScreenPositionInViewport = (
+ position: THREE.Vector2,
+ width: number,
+ height: number,
+ padding = 32
+) => position.x >= -padding
+ && position.x <= width + padding
+ && position.y >= -padding
+ && position.y <= height + padding
+
+const getScreenCenterDistance = (position: THREE.Vector2, width: number, height: number) => (
+ position.distanceTo(new THREE.Vector2(width * 0.5, height * 0.5))
+)
+
const disposePoiDomLabel = (handle: PoiDomLabelHandle | null) => {
if (!handle) return
poiDomLabelResizeObserver?.unobserve(handle.element)
@@ -1414,21 +1983,45 @@ const updatePoiDomLabelPosition = (handle: PoiDomLabelHandle) => {
return null
}
- const bounds = getDomLabelBounds(point, handle.size, handle.anchorMode)
+ const positionedPoint = {
+ x: point.x + handle.layoutOffset.x,
+ y: point.y + handle.layoutOffset.y
+ }
+ const bounds = getDomLabelBounds(positionedPoint, 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%)`
+ handle.element.style.transform = `translate3d(${positionedPoint.x}px, ${positionedPoint.y}px, 0) translate(-50%, -100%)`
return bounds
}
+const B2_MOBILE_LABEL_OFFSETS: Record = {
+ // These two authoritative anchors are intentionally close in the B2 model.
+ // Their measured mobile positions are moved inward and apart to keep both names legible
+ // without changing their GLB coordinates or drawing a leader line.
+ 'space-354500707765842380': { x: 50, y: -17 },
+ 'space-354500708231410188': { x: 35, y: 25 }
+}
+
+const getPoiLabelPresentationOffset = (poi: RenderPoi) => (
+ B2_MOBILE_LABEL_OFFSETS[poi.id] || { x: 0, y: 0 }
+)
+
const updateAmbientPoiLabels = () => {
+ updateOverviewMapLabels()
+
if (!poiGroup || !camera || !renderer) return
const densityTier = getPoiLabelDensityTier()
- const acceptedBounds: ReturnType[] = []
+ const viewportWidth = renderer.domElement.clientWidth
+ const viewportHeight = renderer.domElement.clientHeight
+ const acceptedBounds: Array<{
+ bounds: ReturnType
+ spacing: number
+ }> = []
+ const facilityCounts = createAmbientFacilityCounts()
const candidates = getPoiSprites()
.map((sprite) => {
const poi = sprite.userData.poi as RenderPoi | undefined
@@ -1439,7 +2032,8 @@ const updateAmbientPoiLabels = () => {
marker: sprite,
labelHandle,
poi,
- priority: getPoiAmbientLabelPriority(poi)
+ priority: getPoiAmbientLabelPriority(poi),
+ screenPosition: getProjectedScreenPosition(sprite)
}
: null
})
@@ -1448,24 +2042,62 @@ const updateAmbientPoiLabels = () => {
labelHandle: PoiDomLabelHandle
poi: RenderPoi
priority: number
+ screenPosition: THREE.Vector2 | null
} => Boolean(candidate))
- .sort((left, right) => right.priority - left.priority)
+ .sort((left, right) => {
+ const pinDifference = Number(getPoiPolicy(right.poi).pinInOverview)
+ - Number(getPoiPolicy(left.poi).pinInOverview)
+ const priorityDifference = right.priority - left.priority
+ if (pinDifference || priorityDifference) return pinDifference || priorityDifference
+
+ const leftDistance = left.screenPosition
+ ? getScreenCenterDistance(left.screenPosition, viewportWidth, viewportHeight)
+ : Number.POSITIVE_INFINITY
+ const rightDistance = right.screenPosition
+ ? getScreenCenterDistance(right.screenPosition, viewportWidth, viewportHeight)
+ : Number.POSITIVE_INFINITY
+ return leftDistance - rightDistance
+ })
candidates.forEach(({ marker, labelHandle, poi }) => {
- if (!shouldShowAmbientPoiLabel(poi, marker.visible, densityTier)) {
+ labelHandle.element.classList.toggle(
+ 'three-poi-dom-label--selected',
+ poi.id === activeFocusPoiId.value
+ )
+ const policy = getPoiPolicy(poi)
+ const isFacility = isAmbientFacilityPoi(poi)
+ const isForced = poi.id === activeFocusPoiId.value
+ || poi.primaryCategory === 'target_preview'
+ || isRouteEndpointPoi(poi)
+ || (visiblePoiIdFilter.value !== null && isPoiIncludedByVisibleFilter(poi))
+ labelHandle.element.style.zIndex = policy.pinInOverview ? '3' : isForced ? '2' : '1'
+
+ if (
+ !shouldShowAmbientPoiLabel(poi, marker.visible, densityTier)
+ || (isFacility && !isForced && exceedsAmbientFacilityQuota(poi, densityTier, facilityCounts))
+ ) {
setPoiDomLabelVisible(labelHandle, false)
return
}
- const spacing = getPoiPolicy(poi).labelCollisionSpacing
+ labelHandle.layoutOffset = getPoiLabelPresentationOffset(poi)
const bounds = updatePoiDomLabelPosition(labelHandle)
if (!bounds) return
-
- const overlaps = acceptedBounds.some((acceptedBound) => (
- domLabelBoundsOverlap(bounds, acceptedBound, spacing)
+ const overlaps = isFacility && !isForced && acceptedBounds.some((accepted) => (
+ domLabelBoundsOverlap(
+ bounds,
+ accepted.bounds,
+ Math.max(policy.labelCollisionSpacing, accepted.spacing)
+ )
))
- setPoiDomLabelVisible(labelHandle, !overlaps)
- if (!overlaps) acceptedBounds.push(bounds)
+ if (overlaps) {
+ setPoiDomLabelVisible(labelHandle, false)
+ return
+ }
+
+ setPoiDomLabelVisible(labelHandle, true)
+ acceptedBounds.push({ bounds, spacing: policy.labelCollisionSpacing })
+ if (isFacility && !isForced) recordAmbientFacility(poi, facilityCounts)
})
}
@@ -1473,29 +2105,41 @@ const updatePoiVisibilityByDistance = () => {
if (!poiGroup || !controls || !activeModel || !camera || !renderer) return
const tier = getPoiVisibilityTier()
- const limit = getPoiVisibilityLimit(tier)
+ const limit = getPoiMarkerLimit(tier)
const spacing = getPoiScreenSpacing(tier)
+ const viewportWidth = renderer.domElement.clientWidth
+ const viewportHeight = renderer.domElement.clientHeight
const acceptedPositions: THREE.Vector2[] = []
let visibleCount = 0
+ const facilityCounts = createAmbientFacilityCounts()
const candidates = getPoiSprites()
.map((sprite) => {
const poi = sprite.userData.poi as RenderPoi | undefined
- const screenPosition = sprite.position.clone().project(camera!)
+ const projectedPosition = sprite.getWorldPosition(new THREE.Vector3()).project(camera!)
+ const screenPosition = new THREE.Vector2(
+ (projectedPosition.x + 1) * viewportWidth * 0.5,
+ (1 - projectedPosition.y) * viewportHeight * 0.5
+ )
return {
sprite,
poi,
- screenPosition: new THREE.Vector2(
- (screenPosition.x + 1) * renderer!.domElement.clientWidth * 0.5,
- (1 - screenPosition.y) * renderer!.domElement.clientHeight * 0.5
- )
+ screenPosition,
+ inViewport: projectedPosition.z >= -1
+ && projectedPosition.z <= 1
+ && isPoiScreenPositionInViewport(screenPosition, viewportWidth, viewportHeight),
+ centerDistance: getScreenCenterDistance(screenPosition, viewportWidth, viewportHeight)
}
})
- .sort((a, b) => (b.poi ? getPoiPriority(b.poi) : 0) - (a.poi ? getPoiPriority(a.poi) : 0))
+ .sort((a, b) => {
+ const viewportDifference = Number(b.inViewport) - Number(a.inViewport)
+ const priorityDifference = (b.poi ? getPoiPriority(b.poi) : 0) - (a.poi ? getPoiPriority(a.poi) : 0)
+ return viewportDifference || priorityDifference || a.centerDistance - b.centerDistance
+ })
- candidates.forEach(({ sprite, poi, screenPosition }) => {
- if (!poi || !isPoiIncludedByVisibleFilter(poi)) {
+ candidates.forEach(({ sprite, poi, screenPosition, inViewport }) => {
+ if (!poi || !isPoiIncludedByVisibleFilter(poi) || !inViewport) {
sprite.visible = false
const hitTarget = getPoiSpriteUserData(sprite).hitTarget
const labelHandle = getPoiSpriteUserData(sprite).labelHandle
@@ -1521,12 +2165,19 @@ const updatePoiVisibilityByDistance = () => {
}
const isSelected = poi.id === activeFocusPoiId.value || poi.primaryCategory === 'target_preview'
- const isPinnedHall = activeView.value === 'floor' && isHallPoi(poi)
- const categoryVisible = shouldShowPoiAtDistance(poi, tier)
+ const isPinnedHall = activeView.value === 'floor' && isIndoorLandmarkPoi(poi)
+ const isRouteEndpoint = isRouteEndpointPoi(poi)
+ const isFacility = isAmbientFacilityPoi(poi)
+ // Indoor landmarks are the stable orientation layer of a floor map.
+ // Keep their neutral position marker and name visible at the entry view;
+ // density filtering only applies to the remaining service POIs.
+ const categoryVisible = isPinnedHall || isRouteEndpoint || shouldShowPoiAtDistance(poi, tier)
const isTooClose = spacing > 0 && acceptedPositions.some((position) => position.distanceTo(screenPosition) < spacing)
- const exceedsLimit = Number.isFinite(limit) && visibleCount >= limit
+ const exceedsLimit = isFacility
+ ? exceedsAmbientFacilityQuota(poi, tier, facilityCounts)
+ : Number.isFinite(limit) && visibleCount >= limit
- if (!categoryVisible || (!isSelected && !isPinnedHall && (isTooClose || exceedsLimit))) {
+ if (!categoryVisible || (!isSelected && !isPinnedHall && !isRouteEndpoint && (isTooClose || exceedsLimit))) {
sprite.visible = false
const hitTarget = getPoiSpriteUserData(sprite).hitTarget
const labelHandle = getPoiSpriteUserData(sprite).labelHandle
@@ -1545,7 +2196,11 @@ const updatePoiVisibilityByDistance = () => {
hitTarget.visible = true
}
acceptedPositions.push(screenPosition)
- visibleCount += 1
+ if (isFacility && !isSelected && !isRouteEndpoint) {
+ recordAmbientFacility(poi, facilityCounts)
+ } else {
+ visibleCount += 1
+ }
})
updateAmbientPoiLabels()
@@ -1555,10 +2210,222 @@ const refreshPoiVisibilityByDistance = () => {
updatePoiVisibilityByDistance()
}
+const getCameraCalibrationDirection = (yawDegrees: number, elevationDegrees: number) => {
+ const yaw = THREE.MathUtils.degToRad(yawDegrees)
+ const elevation = THREE.MathUtils.degToRad(elevationDegrees)
+ const horizontal = Math.cos(elevation)
+
+ return new THREE.Vector3(
+ Math.sin(yaw) * horizontal,
+ Math.sin(elevation),
+ Math.cos(yaw) * horizontal
+ ).normalize()
+}
+
+const formatCalibrationValue = (value: number) => (
+ Number.isFinite(value) ? Number(value.toFixed(3)).toString() : '-'
+)
+
+const getCameraCalibrationInputValue = (event: Event) => {
+ const nativeValue = (event.target as HTMLInputElement | null)?.value
+ return Number(nativeValue)
+}
+
+const syncCameraCalibrationFromCamera = () => {
+ if (!camera || !controls) return
+
+ const offset = camera.position.clone().sub(controls.target)
+ const distance = offset.length()
+ if (!Number.isFinite(distance) || distance <= 0) return
+
+ cameraCalibration.value = {
+ yaw: THREE.MathUtils.radToDeg(Math.atan2(offset.x, offset.z)),
+ elevation: THREE.MathUtils.radToDeg(Math.asin(THREE.MathUtils.clamp(offset.y / distance, -1, 1))),
+ fov: camera.fov,
+ distance,
+ x: controls.target.x,
+ y: controls.target.y,
+ z: controls.target.z
+ }
+}
+
+const cameraCalibrationSummary = computed(() => {
+ if (cameraCalibrationCopyStatus.value) return cameraCalibrationCopyStatus.value
+
+ const { yaw, elevation, fov, distance, x, y, z } = cameraCalibration.value
+ return `目标 (${formatCalibrationValue(x)}, ${formatCalibrationValue(y)}, ${formatCalibrationValue(z)}) · 距离 ${formatCalibrationValue(distance)} · FOV ${formatCalibrationValue(fov)}° · 朝向 ${formatCalibrationValue(yaw)}°/${formatCalibrationValue(elevation)}°`
+})
+
+const applyCameraCalibration = () => {
+ if (!camera || !controls) return
+
+ const calibration = cameraCalibration.value
+ const direction = getCameraCalibrationDirection(calibration.yaw, calibration.elevation)
+ const target = new THREE.Vector3(calibration.x, calibration.y, calibration.z)
+ const distance = Math.max(1, calibration.distance)
+
+ cancelCameraTween()
+ clearProgrammaticCameraTimer()
+ isProgrammaticCameraChange = true
+ autoSwitchStateMachine.reset(activeView.value)
+
+ // The calibration tool changes only camera state. Model, POI, route and label
+ // coordinates stay in the shared GLB coordinate system.
+ const dampingEnabled = controls.enableDamping
+ controls.enableDamping = false
+ try {
+ controls.minDistance = Math.min(controls.minDistance, distance)
+ controls.maxDistance = Math.max(controls.maxDistance, distance)
+ controls.target.copy(target)
+ camera.position.copy(target).add(direction.multiplyScalar(distance))
+ camera.up.set(0, 1, 0)
+ camera.fov = THREE.MathUtils.clamp(calibration.fov, 30, 60)
+ camera.updateProjectionMatrix()
+ controls.update()
+ camera.updateMatrixWorld()
+ } finally {
+ controls.enableDamping = dampingEnabled
+ isProgrammaticCameraChange = false
+ }
+
+ refreshPoiVisibilityByDistance()
+ syncCameraCalibrationFromCamera()
+}
+
+const updateCameraCalibration = (key: CameraCalibrationKey, value: number) => {
+ if (!Number.isFinite(value)) return
+
+ const next = { ...cameraCalibration.value }
+ switch (key) {
+ case 'yaw':
+ next.yaw = THREE.MathUtils.clamp(value, -75, 75)
+ break
+ case 'elevation':
+ next.elevation = THREE.MathUtils.clamp(value, 35, 75)
+ break
+ case 'fov':
+ next.fov = THREE.MathUtils.clamp(value, 30, 60)
+ break
+ case 'distance':
+ next.distance = THREE.MathUtils.clamp(value, 20, 1500)
+ break
+ case 'x':
+ case 'y':
+ case 'z':
+ next[key] = value
+ break
+ }
+
+ cameraCalibration.value = next
+ cameraCalibrationCopyStatus.value = ''
+ applyCameraCalibration()
+}
+
+const resetCameraCalibration = () => {
+ cameraCalibrationCopyStatus.value = ''
+ resetCamera()
+ syncCameraCalibrationFromCamera()
+}
+
+const copyCameraCalibration = async () => {
+ const { yaw, elevation, fov, distance, x, y, z } = cameraCalibration.value
+ const parameters = JSON.stringify({
+ view: activeView.value,
+ floorId: activeView.value === 'floor' ? currentFloor.value : undefined,
+ yaw,
+ elevation,
+ fov,
+ distance,
+ target: { x, y, z }
+ }, null, 2)
+
+ try {
+ if (!navigator.clipboard?.writeText) throw new Error('Clipboard API unavailable')
+ await navigator.clipboard.writeText(parameters)
+ cameraCalibrationCopyStatus.value = '相机参数已复制,可作为镜头契约记录。'
+ } catch {
+ console.info('[camera-calibration]', parameters)
+ cameraCalibrationCopyStatus.value = '浏览器未授权复制,参数已输出到控制台。'
+ }
+}
+
+const getGuideViewportState = (): GuideViewportState | null => {
+ if (weakNetworkFallbackActive.value) {
+ return weakNetworkFallbackRef.value?.getGuideViewportState?.() || props.sceneViewport || null
+ }
+ if (!controls || !camera) return props.sceneViewport || null
+
+ const distance = controls.getDistance()
+ if (!Number.isFinite(distance) || distance <= 0) return props.sceneViewport || null
+ const scene = activeView.value === 'overview' ? 'overview' : 'floor'
+ const floorId = scene === 'floor' ? currentFloor.value : ''
+ const previousRevision = props.sceneViewport
+ && props.sceneViewport.scene === scene
+ && (scene === 'overview' || props.sceneViewport.floorId === floorId)
+ ? props.sceneViewport.revision
+ : -1
+ return {
+ scene,
+ floorId,
+ centerX: controls.target.x,
+ centerZ: controls.target.z,
+ visibleWorldSpan: 2 * distance * Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2) * camera.aspect,
+ revision: previousRevision + 1
+ }
+}
+
+const emitSceneViewportChange = () => {
+ const viewport = getGuideViewportState()
+ if (viewport) emit('sceneViewportChange', viewport)
+}
+
+const applySharedGuideViewport = (viewport: GuideViewportState | null | undefined) => {
+ if (!viewport || !camera || !controls) return
+ if (
+ !Number.isFinite(viewport.centerX)
+ || !Number.isFinite(viewport.centerZ)
+ || !Number.isFinite(viewport.visibleWorldSpan)
+ || viewport.visibleWorldSpan <= 0
+ ) return
+
+ const offset = camera.position.clone().sub(controls.target)
+ const nextDistance = THREE.MathUtils.clamp(
+ viewport.visibleWorldSpan
+ / Math.max(2 * Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2) * camera.aspect, 1e-6),
+ controls.minDistance || 2,
+ controls.maxDistance || SGS_VISUAL_RENDER_CONFIG.controls.maxDistance
+ )
+ if (!Number.isFinite(nextDistance) || nextDistance <= 0 || offset.lengthSq() === 0) return
+
+ const nextTarget = controls.target.clone()
+ nextTarget.x = viewport.centerX
+ nextTarget.z = viewport.centerZ
+ offset.setLength(nextDistance)
+ controls.target.copy(nextTarget)
+ camera.position.copy(nextTarget).add(offset)
+ controls.update()
+ refreshPoiVisibilityByDistance()
+}
+
+const handleWeakNetworkViewportChange = (event: {
+ viewport: GuideViewportState
+ source: 'initialize' | 'interaction'
+}) => {
+ if (event.source === 'interaction') twoDViewportChangedSinceModeEntry = true
+ emit('sceneViewportChange', event.viewport)
+}
+
const handleControlChange = () => {
updatePoiVisibilityByDistance()
- checkAutoSwitch()
+ if (hasActiveUserCameraGesture) {
+ checkAutoSwitch()
+ }
scheduleModelAdjustIdleReport()
+ if (isCameraCalibrationMode.value) {
+ cameraCalibrationCopyStatus.value = ''
+ syncCameraCalibrationFromCamera()
+ }
+ if (hasActiveUserCameraGesture) emitSceneViewportChange()
}
const clearProgrammaticCameraTimer = () => {
@@ -1695,13 +2562,16 @@ const updatePoiTapFeedback = (now: number) => {
const scale = baseScale * scaleBoost * selectedBoost * tapBoost
sprite.scale.set(scale, scale, scale)
- sprite.material.opacity = Math.min(1, focused ? 1 : 0.92 + progress * 0.08)
+ sprite.material.opacity = userData.usesDomLabelIcon
+ ? 0
+ : Math.min(1, focused ? 1 : 0.92 + progress * 0.08)
})
}
const handleControlStart = () => {
const distance = controls?.getDistance()
- if (typeof distance === 'number' && Number.isFinite(distance)) {
+ if (!isProgrammaticCameraChange && typeof distance === 'number' && Number.isFinite(distance)) {
+ hasActiveUserCameraGesture = true
autoSwitchStateMachine.beginInput(distance, activeAutoSwitchInputSource)
}
if (!isProgrammaticCameraChange) {
@@ -1712,6 +2582,7 @@ const handleControlStart = () => {
}
const handleControlEnd = () => {
+ hasActiveUserCameraGesture = false
activeAutoSwitchInputSource = 'gesture'
scheduleModelAdjustIdleReport()
}
@@ -1758,6 +2629,10 @@ const startModelLoad = () => {
const invalidateModelLoads = () => {
modelLoadVersion += 1
+ weakNetworkFallbackPreloadSeq += 1
+ adjacentPreloadScheduler.cancel()
+ defaultFloorPreloadScheduler.cancel()
+ poiEnrichmentScheduler.cancel()
pendingRequestedFloorId = ''
floorSwitchLoadToken = 0
floorSwitchRequestedFloorId = ''
@@ -1768,6 +2643,10 @@ const isCurrentModelLoad = (loadToken: number) => (
loadToken === modelLoadVersion && !isDisposed && Boolean(scene)
)
+const isCurrentSceneInitialization = (initializationVersion: number) => (
+ initializationVersion === sceneInitializationVersion && !isDisposed
+)
+
const createStaleModelLoadError = () => new Error(staleModelLoadMessage)
const isStaleModelLoadError = (error: unknown) => (
@@ -1775,6 +2654,14 @@ const isStaleModelLoadError = (error: unknown) => (
)
const startFloorContextTransaction = (requestedFloorId: string) => {
+ // A foreground floor request owns model parsing and label preparation. Stop
+ // queued speculative work before it can compete for the main thread.
+ adjacentPreloadSeq += 1
+ defaultFloorPreloadSeq += 1
+ weakNetworkFallbackPreloadSeq += 1
+ adjacentPreloadScheduler.cancel()
+ defaultFloorPreloadScheduler.cancel()
+ poiEnrichmentScheduler.cancel()
const loadToken = startModelLoad()
pendingRequestedFloorId = requestedFloorId
floorSwitchLoadToken = loadToken
@@ -1934,7 +2821,10 @@ const initThree = async () => {
powerPreference: SGS_VISUAL_RENDER_CONFIG.renderer.powerPreference
})
renderer.setSize(width, height)
- renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, SGS_VISUAL_RENDER_CONFIG.renderer.dprCap))
+ renderer.setPixelRatio(getGuideRendererPixelRatio(
+ undefined,
+ SGS_VISUAL_RENDER_CONFIG.renderer.dprCap
+ ))
renderer.outputColorSpace = THREE.SRGBColorSpace
renderer.toneMapping = SGS_VISUAL_RENDER_CONFIG.renderer.toneMapping
renderer.toneMappingExposure = SGS_VISUAL_RENDER_CONFIG.renderer.toneMappingExposure
@@ -1953,10 +2843,7 @@ const initThree = async () => {
// 首次进入外观或任意单层前,先建立唯一的外观参考投影。
applyReferenceOverviewCameraState()
- loader = new GLTFLoader()
- dracoLoader = new DRACOLoader()
- dracoLoader.setDecoderPath('/static/three/draco/')
- loader.setDRACOLoader(dracoLoader)
+ ensureModelLoader()
poiGroup = new THREE.Group()
poiGroup.name = 'GuideModelPOI'
scene.add(poiGroup)
@@ -2015,15 +2902,30 @@ const initThree = async () => {
}
const startRenderLoop = () => {
+ if (renderLoopRunning || document.visibilityState !== 'visible') return
+ renderLoopRunning = true
+
const render = () => {
- if (isDisposed || !renderer || !scene || !camera) return
+ if (
+ isDisposed
+ || document.visibilityState !== 'visible'
+ || !renderer
+ || !scene
+ || !camera
+ ) {
+ renderLoopRunning = false
+ animationId = 0
+ return
+ }
if (cameraTween) {
updateCameraTween(performance.now())
} else {
controls?.update()
}
- updatePoiTapFeedback(performance.now())
+ const now = performance.now()
+ updatePoiTapFeedback(now)
+ updateRouteRoaming(now)
updateAmbientPoiLabels()
updateFocusLabelScale()
renderer.render(scene, camera)
@@ -2033,6 +2935,14 @@ const startRenderLoop = () => {
render()
}
+const stopRenderLoop = () => {
+ renderLoopRunning = false
+ if (!animationId) return
+
+ window.cancelAnimationFrame(animationId)
+ animationId = 0
+}
+
const handleResize = () => {
const container = getContainerElement()
if (!container || !camera || !renderer) return
@@ -2270,7 +3180,11 @@ const getPoiScreenPoint = (sprite: THREE.Sprite, rect: DOMRect) => {
)
}
-const findNearestPoiMarkerByScreenPoint = (event: PointerEvent, rect: DOMRect) => {
+const findNearestPoiMarkerByScreenPoint = (
+ event: PointerEvent,
+ rect: DOMRect,
+ minimumHitRadius = 0
+) => {
const pointer = new THREE.Vector2(event.clientX, event.clientY)
return getPoiSprites()
@@ -2281,7 +3195,10 @@ const findNearestPoiMarkerByScreenPoint = (event: PointerEvent, rect: DOMRect) =
if (!screenPoint || !poi) return null
const isCorePoi = Boolean(getPoiSpriteUserData(sprite).isCorePoi)
- const hitRadius = isCorePoi ? poiScreenHitRadiusCorePx : poiScreenHitRadiusPx
+ const hitRadius = Math.max(
+ isCorePoi ? poiScreenHitRadiusCorePx : poiScreenHitRadiusPx,
+ minimumHitRadius
+ )
const distance = screenPoint.distanceTo(pointer)
return {
@@ -2300,6 +3217,51 @@ const findNearestPoiMarkerByScreenPoint = (event: PointerEvent, rect: DOMRect) =
.sort((a, b) => (a.distance - b.distance) || (b.priority - a.priority))[0]?.sprite || null
}
+const distanceToRect = (x: number, y: number, rect: DOMRect) => {
+ const dx = Math.max(rect.left - x, 0, x - rect.right)
+ const dy = Math.max(rect.top - y, 0, y - rect.bottom)
+ return Math.hypot(dx, dy)
+}
+
+// POI 名称使用 DOM 标签绘制,标签层为了不抢占地图拖拽事件而设置了
+// pointer-events:none。因此点击文字时事件仍会落到 canvas,所有选择模式都
+// 必须先用标签实际矩形反查对应 marker,不能只依赖 3D 精灵的深度命中。
+const findPoiMarkerByDomLabel = (event: PointerEvent) => {
+ const pointer = { x: event.clientX, y: event.clientY }
+ return Array.from(poiDomLabelHandlesByElement.values())
+ .filter((handle) => (
+ handle.active
+ && handle.kind === 'ambient'
+ && handle.floorId === currentFloor.value
+ && handle.element.style.visibility !== 'hidden'
+ && handle.element.style.opacity !== '0'
+ ))
+ .map((handle) => {
+ const labelRect = handle.element.getBoundingClientRect()
+ if (!labelRect.width || !labelRect.height) return null
+
+ const distance = distanceToRect(pointer.x, pointer.y, labelRect)
+ if (distance > 10) return null
+
+ const marker = handle.anchor instanceof THREE.Sprite
+ ? handle.anchor
+ : findPoiSprite(handle.poi.id)
+ if (!marker || !marker.visible) return null
+
+ return {
+ marker,
+ distance,
+ priority: getPoiPriority(handle.poi)
+ }
+ })
+ .filter((candidate): candidate is {
+ marker: THREE.Sprite
+ distance: number
+ priority: number
+ } => Boolean(candidate))
+ .sort((left, right) => (left.distance - right.distance) || (right.priority - left.priority))[0]?.marker || null
+}
+
const detachPoiMarkerGroups = () => {
poiMarkerGroupCache.forEach((entry) => {
entry.group.parent?.remove(entry.group)
@@ -2315,12 +3277,12 @@ const detachPoiMarkerGroups = () => {
}
}
-const detachActivePoiLayer = () => {
+const detachActivePoiLayer = (options: { preserveRouteRoaming?: boolean } = {}) => {
disposeFocusLabel()
disposeFocusPulse()
disposeFocusBase()
clearFocusHallHighlight()
- clearRoutePreview()
+ clearRoutePreview({ preserveRoaming: options.preserveRouteRoaming })
detachPoiMarkerGroups()
}
@@ -2332,6 +3294,9 @@ const disposePoiMarkerCache = () => {
})
poiMarkerGroupCache.clear()
poiDataCache.clear()
+ poiDataTierCache.clear()
+ poiDataLoadInFlight.clear()
+ poiEnrichmentInFlight.clear()
poiCoordinateDiagnosticsKeys.clear()
}
@@ -2376,28 +3341,11 @@ const setMaterialEmissiveIntensity = (material: THREE.Material, value: number) =
}
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.forEach(restoreIsolatedFocusMaterials)
activeFocusHallMaterialStates = []
+ activeFocusModelRootCount = 0
+ activeFocusModelRootNames = []
if (activeFocusHallGlowMesh) {
activeFocusHallGlowMesh.parent?.remove(activeFocusHallGlowMesh)
@@ -2411,107 +3359,63 @@ const clearFocusHallHighlight = () => {
}
}
-const applyFocusHallMaterial = (material: THREE.Material, seenMaterials: Set) => {
- if (seenMaterials.has(material)) return
- seenMaterials.add(material)
-
+const applyFocusHallMaterial = (material: THREE.Material) => {
const color = getMaterialColor(material)
const emissive = getMaterialEmissive(material)
const emissiveIntensity = getMaterialEmissiveIntensity(material)
+ const mappedMaterial = material as THREE.Material & {
+ map?: THREE.Texture | null
+ vertexColors?: boolean
+ }
- activeFocusHallMaterialStates.push({
- material,
- color: color?.clone(),
- emissive: emissive?.clone(),
- emissiveIntensity,
- opacity: material.opacity,
- transparent: material.transparent
- })
+ const glowColor = new THREE.Color('#f2e600')
- const glowColor = new THREE.Color('#e0df00')
+ // Selection uses cloned materials. Removing their color map makes the
+ // direct Mesh highlight readable even when the source texture is blue/green;
+ // the original texture remains untouched and is restored on deselection.
+ if ('map' in mappedMaterial) mappedMaterial.map = null
+ if ('vertexColors' in mappedMaterial) mappedMaterial.vertexColors = false
if (color) {
- color.lerp(glowColor, 0.34)
+ color.copy(glowColor)
}
if (emissive) {
emissive.copy(glowColor)
- setMaterialEmissiveIntensity(material, Math.max(emissiveIntensity || 0, 0.76))
+ setMaterialEmissiveIntensity(material, Math.max(emissiveIntensity || 0, 0.92))
}
- material.opacity = Math.max(material.opacity, 0.96)
+ material.opacity = 1
material.transparent = true
+ material.depthTest = false
+ material.depthWrite = false
material.needsUpdate = true
}
-const createFocusHallGlowMesh = (poi: RenderPoi) => {
- const displayPosition = getPoiDisplayPosition(poi)
- if (!displayPosition) 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)
-
- mesh.name = 'GuideFocusHallGlow'
- mesh.position.set(displayPosition.x, displayPosition.y + 0.08, displayPosition.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 modelRoots = findPoiModelRoots(poi)
+ if (!modelRoots.length) return
+ activeFocusModelRootNames = modelRoots.map((root) => root.name).filter(Boolean)
- const modelRoot = findPoiModelRoot(poi)
- if (!modelRoot) return
-
- const seenMaterials = new Set()
+ const isolatedMeshes = new Set()
let matchedMeshCount = 0
- modelRoot.traverse((child) => {
- if (!(child instanceof THREE.Mesh) || !child.visible) return
+ modelRoots.forEach((modelRoot) => {
+ let rootMeshCount = 0
+ modelRoot.traverse((child) => {
+ if (!(child instanceof THREE.Mesh) || !child.visible || isolatedMeshes.has(child)) return
- matchedMeshCount += 1
- const materials = Array.isArray(child.material) ? child.material : [child.material]
- materials.forEach((material) => {
- if (material) {
- applyFocusHallMaterial(material, seenMaterials)
- }
+ isolatedMeshes.add(child)
+ rootMeshCount += 1
+ matchedMeshCount += 1
+ const materialState = isolateMeshMaterialsForFocus(child)
+ activeFocusHallMaterialStates.push(materialState)
+ materialState.clonedMaterials.forEach(applyFocusHallMaterial)
})
+ if (rootMeshCount > 0) activeFocusModelRootCount += 1
})
if (!matchedMeshCount) {
@@ -2534,8 +3438,23 @@ const disposePreparedFloorModelCache = () => {
preparedFloorModelCache.clear()
}
-const clearRoutePreview = () => {
+const clearRoutePreview = (options: { preserveRoaming?: boolean } = {}) => {
activeRoutePreviewSignature = ''
+ routeRoamingMarker = null
+ if (!options.preserveRoaming) {
+ routeRoamingStartedAt = 0
+ routeRoamingDurationMs = 0
+ routeRoamingPoints = []
+ routeRoamingSessionActive = false
+ routeRoamingTransitioning = false
+ routeRoamingSegmentIndex = 0
+ routeRoamingCompletedDistance = 0
+ routeRoamingTotalDistance = 0
+ lastRouteRoamingRemainingMeters = null
+ routeRoamingCameraOffset = null
+ routeRoamingLookAhead = 0
+ routeRoamingLastCameraMoveAt = 0
+ }
if (!routeGroup) return
while (routeGroup.children.length) {
@@ -2545,23 +3464,163 @@ const clearRoutePreview = () => {
}
}
-const routePointToVector = (position: [number, number, number]) => (
- new THREE.Vector3(position[0], position[1], position[2])
-)
+const getRouteFloorModel = (floorId?: string) => {
+ if (!floorId || !activeModel) return null
+ const resolvedFloorId = resolveFloorIdFromRequest(floorId) || floorId
+ if (activeRouteCompositeModel && activeModel === activeRouteCompositeModel) {
+ return activeModel
+ }
+ if (activeView.value === 'multi') {
+ return activeModel.children.find((child) => child.userData.floorId === resolvedFloorId) || null
+ }
+ return currentFloor.value === resolvedFloorId ? activeModel : null
+}
+
+const getCachedRouteFloorSurfaceY = (
+ floorModel: THREE.Object3D,
+ floorId?: string
+) => routeFloorSurfaceYCache.get(floorModel)?.get(floorId || '')
+
+const cacheRouteFloorSurfaceY = (
+ floorModel: THREE.Object3D,
+ floorId: string | undefined,
+ surfaceY: number,
+ resolvedFromRoute = false
+) => {
+ const floorKey = floorId || ''
+ let surfaceByFloor = routeFloorSurfaceYCache.get(floorModel)
+ if (!surfaceByFloor) {
+ surfaceByFloor = new Map()
+ routeFloorSurfaceYCache.set(floorModel, surfaceByFloor)
+ }
+ surfaceByFloor.set(floorKey, surfaceY)
+ if (resolvedFromRoute) {
+ let resolvedFloors = resolvedRouteFloorSurfaceCache.get(floorModel)
+ if (!resolvedFloors) {
+ resolvedFloors = new Set()
+ resolvedRouteFloorSurfaceCache.set(floorModel, resolvedFloors)
+ }
+ resolvedFloors.add(floorKey)
+ }
+}
+
+const getRouteFloorSurfaceY = (floorId?: string) => {
+ const floorModel = getRouteFloorModel(floorId)
+ if (!floorModel) return 0
+
+ const cachedSurfaceY = getCachedRouteFloorSurfaceY(floorModel, floorId)
+ if (typeof cachedSurfaceY === 'number') return cachedSurfaceY
+
+ // This is only an emergency fallback. Route segments normally prime the
+ // exact walkable plane before any line, marker or roaming point is rendered.
+ const surfaceY = getObjectBox(floorModel).getCenter(new THREE.Vector3()).y
+ const resolvedSurfaceY = Number.isFinite(surfaceY) ? surfaceY : 0
+ cacheRouteFloorSurfaceY(floorModel, floorId, resolvedSurfaceY)
+ return resolvedSurfaceY
+}
+
+const primeRouteFloorSurfaces = (segments: GuideRouteFloorSegment[]) => {
+ const positionsByFloor = new Map>()
+ segments.forEach((segment) => {
+ const positions = positionsByFloor.get(segment.floorId) || []
+ positions.push(...segment.points.map((point) => point.position))
+ positionsByFloor.set(segment.floorId, positions)
+ })
+
+ positionsByFloor.forEach((positions, floorId) => {
+ const floorModel = getRouteFloorModel(floorId)
+ if (!floorModel || !positions.length) return
+ if (resolvedRouteFloorSurfaceCache.get(floorModel)?.has(floorId)) return
+ const modelBounds = getObjectBox(floorModel)
+ const surfaceY = resolveDominantRouteSurfaceY(
+ floorModel,
+ modelBounds,
+ positions
+ )
+ if (surfaceY === null) return
+ cacheRouteFloorSurfaceY(floorModel, floorId, surfaceY, true)
+ const sourceYs = positions.map((position) => Number(position[1]) || 0)
+ logThreeMapDiagnostic('route-surface-resolved', {
+ floorId,
+ pointCount: positions.length,
+ surfaceY: Number(surfaceY.toFixed(4)),
+ sourceYMin: Number(Math.min(...sourceYs).toFixed(4)),
+ sourceYMax: Number(Math.max(...sourceYs).toFixed(4)),
+ modelYMin: Number(modelBounds.min.y.toFixed(4)),
+ modelYMax: Number(modelBounds.max.y.toFixed(4))
+ })
+ })
+}
+
+const getRoutePointSurfaceY = (
+ position: [number, number, number],
+ floorId?: string
+) => {
+ const floorModel = getRouteFloorModel(floorId)
+ if (!floorModel) return getRouteFloorSurfaceY(floorId)
+
+ const cacheKey = `${floorId || ''}:${position[0].toFixed(3)},${position[2].toFixed(3)}`
+ let surfaceByPoint = routePointSurfaceYCache.get(floorModel)
+ if (!surfaceByPoint) {
+ surfaceByPoint = new Map()
+ routePointSurfaceYCache.set(floorModel, surfaceByPoint)
+ }
+ const cachedSurfaceY = surfaceByPoint.get(cacheKey)
+ if (typeof cachedSurfaceY === 'number') return cachedSurfaceY
+
+ const preferredSurfaceY = getCachedRouteFloorSurfaceY(floorModel, floorId)
+ const projected = projectRoutePositionToSurface(
+ floorModel,
+ getObjectBox(floorModel),
+ position,
+ preferredSurfaceY,
+ 0
+ )
+ const surfaceY = projected?.y ?? getRouteFloorSurfaceY(floorId)
+ surfaceByPoint.set(cacheKey, surfaceY)
+ return surfaceY
+}
+
+const routePointToVector = (
+ position: [number, number, number],
+ floorId?: string
+) => {
+ // WALK routes are a 2D X/Z network. Their legacy Y field is not a GLB world
+ // elevation, so every route visual must resolve height from the floor model.
+ const floorY = getRoutePointSurfaceY(position, floorId)
+ return new THREE.Vector3(
+ position[0],
+ floorY + 0.24,
+ position[2]
+ )
+}
const getVisibleRouteSegments = (route: GuideRouteResult): GuideRouteFloorSegment[] => {
if (activeView.value === 'floor') {
+ const visibleFloorId = resolveFloorIdFromRequest(currentFloor.value) || currentFloor.value
return route.floorSegments
- .filter((segment) => segment.floorId === currentFloor.value)
+ .filter((segment) => (
+ (resolveFloorIdFromRequest(segment.floorId) || segment.floorId) === visibleFloorId
+ ))
}
return route.floorSegments
}
-const createRouteLine = (points: THREE.Vector3[], opacity: number) => {
+const getNavigationScenePlan = (route = props.routePreview): NavigationScenePlan | null => (
+ route
+ ? compileNavigationScene(route, renderPackage.value?.routeAssets || [])
+ : null
+)
+
+const getSameGroundCompositeRouteAsset = (route = props.routePreview): GuideModelRouteAsset | null => {
+ return getNavigationScenePlan(route)?.compositeAsset || null
+}
+
+const createRouteLine = (points: THREE.Vector3[], opacity: number, navigationActive = false) => {
const geometry = new THREE.BufferGeometry().setFromPoints(points)
const material = new THREE.LineBasicMaterial({
- color: 0x1f8f5f,
+ color: 0x356ae6,
transparent: true,
opacity,
depthTest: false,
@@ -2569,6 +3628,24 @@ const createRouteLine = (points: THREE.Vector3[], opacity: number) => {
})
const line = new THREE.Line(geometry, material)
line.renderOrder = 18
+ if (navigationActive && points.length > 1) {
+ const routeCurve = new THREE.CurvePath()
+ for (let index = 1; index < points.length; index += 1) {
+ routeCurve.add(new THREE.LineCurve3(points[index - 1], points[index]))
+ }
+ const tube = new THREE.Mesh(
+ new THREE.TubeGeometry(routeCurve, Math.max((points.length - 1) * 10, 20), 0.34, 10, false),
+ new THREE.MeshBasicMaterial({
+ color: 0x3f6fe6,
+ transparent: true,
+ opacity: 0.94,
+ depthTest: false,
+ depthWrite: false
+ })
+ )
+ tube.renderOrder = 17
+ routeGroup?.add(tube)
+ }
return line
}
@@ -2584,7 +3661,7 @@ const createRouteMarkerSprite = (label: string, color: string, markerSize: numbe
context.shadowBlur = 16
context.shadowOffsetY = 8
context.beginPath()
- context.arc(64, 56, 32, 0, Math.PI * 2)
+ context.arc(64, 54, 36, 0, Math.PI * 2)
context.fillStyle = color
context.fill()
context.shadowColor = 'transparent'
@@ -2593,9 +3670,9 @@ const createRouteMarkerSprite = (label: string, color: string, markerSize: numbe
context.stroke()
context.beginPath()
- context.moveTo(64, 116)
- context.lineTo(43, 78)
- context.lineTo(85, 78)
+ context.moveTo(64, 118)
+ context.lineTo(39, 78)
+ context.lineTo(89, 78)
context.closePath()
context.fillStyle = color
context.fill()
@@ -2603,11 +3680,11 @@ const createRouteMarkerSprite = (label: string, color: string, markerSize: numbe
context.strokeStyle = '#ffffff'
context.stroke()
- context.font = `700 30px ${CANVAS_FONT_FAMILY}`
+ context.font = `700 40px ${CANVAS_FONT_FAMILY}`
context.textAlign = 'center'
context.textBaseline = 'middle'
context.fillStyle = '#ffffff'
- context.fillText(label, 64, 56, 52)
+ context.fillText(label, 64, 54, 56)
}
const texture = new THREE.CanvasTexture(canvas)
@@ -2623,12 +3700,415 @@ const createRouteMarkerSprite = (label: string, color: string, markerSize: numbe
return sprite
}
+const createRouteDirectionSprite = (
+ position: THREE.Vector3,
+ direction: THREE.Vector3,
+ markerSize: number
+) => {
+ if (!routeGroup) return
+ const canvas = document.createElement('canvas')
+ canvas.width = 96
+ canvas.height = 96
+ const context = canvas.getContext('2d')
+ if (!context) return
+
+ context.clearRect(0, 0, 96, 96)
+ context.beginPath()
+ context.moveTo(48, 16)
+ context.lineTo(74, 58)
+ context.lineTo(48, 47)
+ context.lineTo(22, 58)
+ context.closePath()
+ context.fillStyle = '#ffffff'
+ context.shadowColor = 'rgba(21, 50, 135, 0.45)'
+ context.shadowBlur = 6
+ context.fill()
+
+ 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.position.copy(position)
+ sprite.scale.setScalar(markerSize * 0.55)
+ sprite.renderOrder = 30
+ orientRouteSpriteToDirection(sprite, position, direction)
+ routeGroup.add(sprite)
+}
+
+const orientRouteSpriteToDirection = (
+ sprite: THREE.Sprite,
+ position: THREE.Vector3,
+ direction: THREE.Vector3
+) => {
+ if (!camera || !sprite.material || direction.lengthSq() < 0.0001) return
+
+ const screenPosition = position.clone().project(camera)
+ const screenAhead = position.clone().add(direction).project(camera)
+ const deltaX = screenAhead.x - screenPosition.x
+ const deltaY = screenAhead.y - screenPosition.y
+ if (Math.abs(deltaX) + Math.abs(deltaY) < 0.00001) return
+
+ // The arrow artwork points upward by default. Rotate it in the camera plane,
+ // rather than by world axes, so it continues to align with the visible route.
+ sprite.material.rotation = -Math.atan2(deltaX, deltaY)
+}
+
+const createRouteRoamingMarker = (markerSize: number) => {
+ const canvas = document.createElement('canvas')
+ canvas.width = 128
+ canvas.height = 128
+ const context = canvas.getContext('2d')
+ if (context) {
+ context.clearRect(0, 0, 128, 128)
+ context.beginPath()
+ context.arc(64, 64, 36, 0, Math.PI * 2)
+ context.fillStyle = '#1565c0'
+ context.shadowColor = 'rgba(21, 101, 192, 0.36)'
+ context.shadowBlur = 14
+ context.fill()
+ context.shadowColor = 'transparent'
+ context.lineWidth = 6
+ context.strokeStyle = '#ffffff'
+ context.stroke()
+ context.beginPath()
+ context.moveTo(64, 35)
+ context.lineTo(86, 78)
+ context.lineTo(64, 67)
+ context.lineTo(42, 78)
+ context.closePath()
+ context.fillStyle = '#ffffff'
+ context.fill()
+ }
+
+ const texture = new THREE.CanvasTexture(canvas)
+ texture.colorSpace = THREE.SRGBColorSpace
+ const marker = new THREE.Sprite(new THREE.SpriteMaterial({
+ map: texture,
+ transparent: true,
+ depthTest: false,
+ depthWrite: false
+ }))
+ marker.scale.setScalar(markerSize * 1.15)
+ marker.renderOrder = 34
+ marker.frustumCulled = false
+ return marker
+}
+
+const createRouteEndpointLabelSprite = (name: string, markerSize: number) => {
+ const text = name
+ const canvas = document.createElement('canvas')
+ canvas.width = Math.min(560, Math.max(220, text.length * 38 + 44))
+ canvas.height = 76
+ const context = canvas.getContext('2d')
+ if (context) {
+ context.clearRect(0, 0, canvas.width, canvas.height)
+ const left = 8
+ const top = 6
+ const width = canvas.width - 16
+ const height = canvas.height - 12
+ const radius = 12
+ context.beginPath()
+ context.moveTo(left + radius, top)
+ context.arcTo(left + width, top, left + width, top + height, radius)
+ context.arcTo(left + width, top + height, left, top + height, radius)
+ context.arcTo(left, top + height, left, top, radius)
+ context.arcTo(left, top, left + width, top, radius)
+ context.closePath()
+ context.fillStyle = 'rgba(255, 255, 255, 0.68)'
+ context.shadowColor = 'rgba(26, 35, 126, 0.08)'
+ context.shadowBlur = 6
+ context.shadowOffsetY = 2
+ context.fill()
+ context.shadowColor = 'transparent'
+ context.lineWidth = 2
+ context.strokeStyle = 'rgba(26, 35, 126, 0.16)'
+ context.stroke()
+ context.fillStyle = '#14205f'
+ context.font = `400 32px ${CANVAS_FONT_FAMILY}`
+ context.textAlign = 'center'
+ context.textBaseline = 'middle'
+ context.fillText(text, canvas.width / 2, canvas.height / 2, canvas.width - 24)
+ }
+ 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
+ }))
+ const width = markerSize * 2.9
+ sprite.scale.set(width, width / (canvas.width / canvas.height), 1)
+ sprite.renderOrder = 35
+ sprite.frustumCulled = false
+ return sprite
+}
+
+const buildRouteRoamingPoints = (route: GuideRouteResult) => route.floorSegments
+ .flatMap((segment) => segment.points.map((point) => routePointToVector(point.position, point.floorId)))
+ .filter((point, index, points) => index === 0 || point.distanceToSquared(points[index - 1]) > 0.01)
+
+const getRouteDistance = (points: THREE.Vector3[]) => points.reduce((distance, point, index) => (
+ index === 0 ? 0 : distance + point.distanceTo(points[index - 1])
+), 0)
+
+const getRouteRoamingPosition = (points: THREE.Vector3[], progress: number) => {
+ if (points.length < 2) return points[0]?.clone() || new THREE.Vector3()
+ const totalDistance = getRouteDistance(points)
+ const targetDistance = totalDistance * THREE.MathUtils.clamp(progress, 0, 1)
+ let walked = 0
+ for (let index = 1; index < points.length; index += 1) {
+ const start = points[index - 1]
+ const end = points[index]
+ const segmentDistance = start.distanceTo(end)
+ if (walked + segmentDistance >= targetDistance) {
+ const segmentProgress = segmentDistance > 0
+ ? (targetDistance - walked) / segmentDistance
+ : 1
+ return start.clone().lerp(end, segmentProgress)
+ }
+ walked += segmentDistance
+ }
+ return points[points.length - 1].clone()
+}
+
+const getRouteRoamingDirection = (points: THREE.Vector3[], progress: number) => {
+ if (points.length < 2) return new THREE.Vector3(0, 0, -1)
+ const totalDistance = getRouteDistance(points)
+ const targetDistance = totalDistance * THREE.MathUtils.clamp(progress, 0, 1)
+ let walked = 0
+ for (let index = 1; index < points.length; index += 1) {
+ const start = points[index - 1]
+ const end = points[index]
+ const segmentDistance = start.distanceTo(end)
+ if (walked + segmentDistance >= targetDistance || index === points.length - 1) {
+ return end.clone().sub(start).normalize()
+ }
+ walked += segmentDistance
+ }
+ return points[points.length - 1].clone().sub(points[points.length - 2]).normalize()
+}
+
+const isRoutePositionOutsideCameraSafeArea = (position: THREE.Vector3) => {
+ if (!camera || !renderer) return false
+ const projected = position.clone().project(camera)
+ const x = (projected.x + 1) / 2
+ const y = (1 - projected.y) / 2
+ return projected.z < -1 || projected.z > 1 || x < 0.16 || x > 0.84 || y < 0.18 || y > 0.76
+}
+
+const getRouteArrowPlacements = (points: THREE.Vector3[], spacing: number) => {
+ const placements: Array<{ position: THREE.Vector3; direction: THREE.Vector3 }> = []
+ if (points.length < 2) return placements
+
+ let distanceToNext = spacing * 0.65
+ for (let index = 1; index < points.length; index += 1) {
+ const start = points[index - 1]
+ const end = points[index]
+ const vector = end.clone().sub(start)
+ const segmentLength = vector.length()
+ if (segmentLength < 0.01) continue
+
+ const direction = vector.clone().multiplyScalar(1 / segmentLength)
+ let walkedOnSegment = 0
+ while (walkedOnSegment + distanceToNext < segmentLength) {
+ walkedOnSegment += distanceToNext
+ placements.push({
+ position: start.clone().addScaledVector(direction, walkedOnSegment),
+ direction: direction.clone()
+ })
+ distanceToNext = spacing
+ }
+ distanceToNext -= segmentLength - walkedOnSegment
+ }
+ return placements
+}
+
+const getRoamingSegments = (route: GuideRouteResult) => {
+ // L1 and EXTERIOR share one dedicated ground-composite model. They are
+ // spatially continuous, so navigation must not perform a fake floor switch.
+ if (getNavigationScenePlan(route)?.kind === 'same-ground-composite') {
+ const points = route.floorSegments.flatMap((segment) => segment.points)
+ return [{
+ floorId: 'EXTERIOR_L1',
+ floorLabel: '室外连廊',
+ points: points.length === 1 ? [points[0], points[0]] : points
+ }]
+ }
+
+ // A zero-length WALK segment is valid at a transfer or a point that is
+ // already on the connector. Keep it as a two-point hold so the roaming
+ // state advances to the next physical floor instead of losing its index.
+ return route.floorSegments
+ .filter((segment) => segment.points.length > 0)
+ .map((segment) => segment.points.length === 1
+ ? { ...segment, points: [segment.points[0], segment.points[0]] }
+ : segment)
+}
+
+const startRouteRoaming = (route: GuideRouteResult, markerSize: number) => {
+ if (!routeGroup || routeRoamingTransitioning) return
+
+ const segments = getRoamingSegments(route)
+ const segment = segments[routeRoamingSegmentIndex]
+ if (!segment) return
+
+ const isResumingCurrentSegment = Boolean(
+ routeRoamingSessionActive
+ && routeRoamingStartedAt > 0
+ && routeRoamingPoints.length > 0
+ )
+
+ if (!routeRoamingSessionActive) {
+ routeRoamingSessionActive = true
+ routeRoamingSegmentIndex = 0
+ routeRoamingCompletedDistance = 0
+ routeRoamingTotalDistance = Math.max(segments.reduce((total, item) => (
+ total + getRouteDistance(item.points.map((point) => routePointToVector(point.position, point.floorId)))
+ ), 0), 0.001)
+ lastRouteRoamingRemainingMeters = null
+ }
+
+ const points = isResumingCurrentSegment
+ ? routeRoamingPoints
+ : segment.points.map((point) => routePointToVector(point.position, point.floorId))
+ if (!isResumingCurrentSegment) {
+ routeRoamingPoints = points
+ routeRoamingDurationMs = THREE.MathUtils.clamp(getRouteDistance(points) * 115, 6000, 18000)
+ routeRoamingStartedAt = performance.now() + ROUTE_NAVIGATION_CAMERA_ENTRY_MS
+ }
+ if (routeRoamingMarker) return
+
+ routeRoamingMarker = createRouteRoamingMarker(markerSize)
+ routeRoamingMarker.position.copy(points[0])
+ routeGroup.add(routeRoamingMarker)
+
+ if (camera && controls) {
+ const routeDirection = getRouteRoamingDirection(points, 0)
+ const cameraDirection = getCameraCalibrationDirection(
+ GUIDE_CAMERA_YAW_DEGREES,
+ GUIDE_CAMERA_ELEVATION_DEGREES
+ )
+ controls.minDistance = Math.min(controls.minDistance, ROUTE_NAVIGATION_CAMERA_DISTANCE * 0.6)
+ controls.maxDistance = Math.max(controls.maxDistance, ROUTE_PREVIEW_CAMERA_DISTANCE)
+ routeRoamingLookAhead = ROUTE_NAVIGATION_LOOK_AHEAD
+ const target = points[0].clone().addScaledVector(routeDirection, routeRoamingLookAhead)
+ routeRoamingCameraOffset = cameraDirection.multiplyScalar(ROUTE_NAVIGATION_CAMERA_DISTANCE)
+ routeRoamingLastCameraMoveAt = performance.now()
+ moveCameraTo(target.clone().add(routeRoamingCameraOffset), target, {
+ durationMs: ROUTE_NAVIGATION_CAMERA_ENTRY_MS
+ })
+ }
+}
+
+const transferRouteRoamingToNextFloor = async (route: GuideRouteResult) => {
+ const segments = getRoamingSegments(route)
+ const nextSegment = segments[routeRoamingSegmentIndex + 1]
+ if (!nextSegment) return
+
+ routeRoamingTransitioning = true
+ routeRoamingSegmentIndex += 1
+ routeRoamingStartedAt = 0
+ let shouldResume = false
+ const previousSegment = segments[routeRoamingSegmentIndex - 1]
+ const transition = route.transitions?.find((item) => (
+ item.fromFloorId === previousSegment?.floorId
+ && item.toFloorId === nextSegment.floorId
+ ))
+ emit('routeRoamingTransfer', {
+ fromFloorId: previousSegment?.floorId || '',
+ toFloorId: nextSegment.floorId,
+ transferType: transition?.transferType || 'TRANSFER',
+ connectorName: transition?.connectorName
+ })
+
+ try {
+ await loadFloor(nextSegment.floorId, {
+ preserveCurrentSceneUntilReady: true,
+ detachPoiBeforeLoad: true,
+ applyFloorBaseline: true,
+ preserveRouteRoaming: true
+ })
+ shouldResume = props.routeNavigationActive && props.routePreview?.id === route.id
+ if (!shouldResume) return
+ emitFloorChange(nextSegment.floorId)
+ } catch (error) {
+ if (!props.routeNavigationActive || props.routePreview?.id !== route.id) return
+ console.error('[ThreeMap] 导览换层失败:', error)
+ clearRoutePreview()
+ emit('routeRoamingProgress', { remainingMeters: 0, progress: 1 })
+ return
+ } finally {
+ routeRoamingTransitioning = false
+ }
+
+ if (!shouldResume) return
+ clearRoutePreview({ preserveRoaming: true })
+ renderRoutePreview()
+}
+
+const updateRouteRoaming = (now: number) => {
+ if (
+ !props.routeNavigationActive
+ || routeRoamingTransitioning
+ || !routeRoamingMarker
+ || !routeRoamingStartedAt
+ || !routeRoamingPoints.length
+ ) return
+
+ const progress = THREE.MathUtils.clamp((now - routeRoamingStartedAt) / routeRoamingDurationMs, 0, 1)
+ const position = getRouteRoamingPosition(routeRoamingPoints, progress)
+ routeRoamingMarker.position.copy(position)
+ const direction = getRouteRoamingDirection(routeRoamingPoints, progress)
+ const travelledDistance = routeRoamingCompletedDistance + getRouteDistance(routeRoamingPoints) * progress
+ const totalProgress = THREE.MathUtils.clamp(travelledDistance / routeRoamingTotalDistance, 0, 1)
+ const routeDistance = Number(props.routePreview?.distanceMeters)
+ const remainingMeters = Math.max(0, Math.round(
+ (Number.isFinite(routeDistance) && routeDistance > 0 ? routeDistance : routeRoamingTotalDistance)
+ * (1 - totalProgress)
+ ))
+ if (remainingMeters !== lastRouteRoamingRemainingMeters) {
+ lastRouteRoamingRemainingMeters = remainingMeters
+ emit('routeRoamingProgress', { remainingMeters, progress: totalProgress })
+ }
+
+ if (
+ camera
+ && controls
+ && routeRoamingCameraOffset
+ && now - routeRoamingLastCameraMoveAt >= ROUTE_NAVIGATION_RECENTER_MIN_INTERVAL_MS
+ && isRoutePositionOutsideCameraSafeArea(position)
+ ) {
+ const target = position.clone().addScaledVector(direction, routeRoamingLookAhead)
+ routeRoamingLastCameraMoveAt = now
+ moveCameraTo(target.clone().add(routeRoamingCameraOffset), target, {
+ durationMs: ROUTE_NAVIGATION_RECENTER_MS
+ })
+ }
+ orientRouteSpriteToDirection(routeRoamingMarker, position, direction)
+
+ if (progress < 1 || !props.routePreview) return
+
+ routeRoamingCompletedDistance += getRouteDistance(routeRoamingPoints)
+ if (routeRoamingSegmentIndex < getRoamingSegments(props.routePreview).length - 1) {
+ void transferRouteRoamingToNextFloor(props.routePreview)
+ return
+ }
+
+ emit('routeRoamingProgress', { remainingMeters: 0, progress: 1 })
+}
+
const shouldShowRouteEndpoint = (endpoint: GuideRouteEndpoint) => (
activeView.value !== 'floor' || endpoint.floorId === currentFloor.value
)
const renderRouteEndpoint = (
endpoint: GuideRouteEndpoint,
+ routePosition: [number, number, number],
label: string,
color: string,
markerSize: number
@@ -2636,18 +4116,37 @@ const renderRouteEndpoint = (
if (!routeGroup || !shouldShowRouteEndpoint(endpoint)) return
const marker = createRouteMarkerSprite(label, color, markerSize)
- marker.position.copy(routePointToVector(endpoint.position))
+ marker.position.copy(routePointToVector(routePosition, endpoint.floorId))
routeGroup.add(marker)
}
+const renderRouteEndpointLabel = (
+ endpoint: GuideRouteEndpoint,
+ routePosition: [number, number, number],
+ markerSize: number
+) => {
+ if (!routeGroup || !shouldShowRouteEndpoint(endpoint)) return
+ const endpointPoiSprite = findPoiSprite(endpoint.poiId)
+ if (
+ activeView.value === 'floor'
+ && endpointPoiSprite
+ && getPoiSpriteUserData(endpointPoiSprite).labelHandle
+ ) return
+ const label = createRouteEndpointLabelSprite(endpoint.name, markerSize)
+ label.position.copy(routePointToVector(routePosition, endpoint.floorId))
+ label.position.y += markerSize * 0.8
+ routeGroup.add(label)
+}
+
const renderRouteConnectorMarkers = (route: GuideRouteResult, markerSize: number) => {
if (!routeGroup) return
+ if (getSameGroundCompositeRouteAsset(route)) return
route.connectorPoints
.filter((point) => activeView.value !== 'floor' || point.floorId === currentFloor.value)
.forEach((point) => {
const marker = createRouteMarkerSprite('换', '#8a5cf6', markerSize * 0.72)
- marker.position.copy(routePointToVector(point.position))
+ marker.position.copy(routePointToVector(point.position, point.floorId))
routeGroup!.add(marker)
})
}
@@ -2659,19 +4158,24 @@ const routePositionSignature = (position: [number, number, number]) => (
const getRoutePreviewSignature = (
route: GuideRouteResult,
visibleSegments: GuideRouteFloorSegment[],
- markerSize: number
+ markerSize: number,
+ endpointMarkerSize: number
) => JSON.stringify({
routeId: route.id,
view: activeView.value,
+ navigationActive: props.routeNavigationActive,
floor: activeView.value === 'floor' ? currentFloor.value : '',
markerSize: Number(markerSize.toFixed(3)),
+ endpointMarkerSize: Number(endpointMarkerSize.toFixed(3)),
start: {
poiId: route.start.poiId,
+ name: route.start.name,
floorId: route.start.floorId,
position: routePositionSignature(route.start.position)
},
end: {
poiId: route.end.poiId,
+ name: route.end.name,
floorId: route.end.floorId,
position: routePositionSignature(route.end.position)
},
@@ -2684,6 +4188,39 @@ const getRoutePreviewSignature = (
.map((point) => `${point.nodeId}:${point.floorId}:${routePositionSignature(point.position)}`)
})
+const getRouteTransitions = (route: GuideRouteResult) => {
+ if (route.transitions?.length) return route.transitions
+
+ return route.floorSegments.flatMap((segment, index) => {
+ if (index === 0) return []
+ const previous = route.floorSegments[index - 1]
+ const fromPoint = previous.points[previous.points.length - 1]
+ const toPoint = segment.points[0]
+ if (!fromPoint || !toPoint || previous.floorId === segment.floorId) return []
+ return [{
+ id: `transition-${fromPoint.nodeId}-${toPoint.nodeId}`,
+ fromFloorId: previous.floorId,
+ toFloorId: segment.floorId,
+ fromPosition: fromPoint.position,
+ toPosition: toPoint.position
+ }]
+ })
+}
+
+const renderMultiFloorRouteConnectors = (
+ route: GuideRouteResult,
+ guideStyle: boolean
+) => {
+ if (!routeGroup || activeView.value !== 'multi') return
+
+ getRouteTransitions(route).forEach((transition) => {
+ routeGroup?.add(createRouteLine([
+ routePointToVector(transition.fromPosition, transition.fromFloorId),
+ routePointToVector(transition.toPosition, transition.toFloorId)
+ ], 0.88, guideStyle))
+ })
+}
+
const renderRoutePreview = () => {
if (!props.showRoute || !props.routePreview || !routeGroup || !scene) {
clearRoutePreview()
@@ -2695,27 +4232,54 @@ const renderRoutePreview = () => {
const targetRouteGroup = routeGroup
const visibleSegments = getVisibleRouteSegments(route)
const markerSize = Math.max(getPoiMarkerSize() * 0.82, 2.8)
- const signature = getRoutePreviewSignature(route, visibleSegments, markerSize)
+ const endpointMarkerSize = Math.max(getPoiMarkerSize() * 1.12, 3.8)
+ const useRouteGuideStyle = true
+ primeRouteFloorSurfaces(visibleSegments)
+ const signature = getRoutePreviewSignature(route, visibleSegments, markerSize, endpointMarkerSize)
if (signature === activeRoutePreviewSignature) return
- clearRoutePreview()
+ // A floor commit rebuilds the route visuals. During roaming that rebuild
+ // must keep the compiled segment index, otherwise every transfer restarts
+ // at the first floor and appears as an endless loop.
+ clearRoutePreview({
+ preserveRoaming: props.routeNavigationActive && routeRoamingSessionActive
+ })
activeRoutePreviewSignature = signature
visibleSegments.forEach((segment) => {
if (segment.points.length < 2) return
+ const segmentVectors = segment.points.map((point) => routePointToVector(point.position, point.floorId))
targetRouteGroup.add(createRouteLine(
- segment.points.map((point) => routePointToVector(point.position)),
- activeView.value === 'floor' ? 1 : 0.88
+ segmentVectors,
+ activeView.value === 'floor' ? 1 : 0.88,
+ useRouteGuideStyle
))
+
+ if (useRouteGuideStyle) {
+ const arrowSpacing = activeView.value === 'floor' ? 7.5 : 9
+ getRouteArrowPlacements(segmentVectors, arrowSpacing).forEach(({ position, direction }) => {
+ createRouteDirectionSprite(position, direction, markerSize)
+ })
+ }
})
- renderRouteEndpoint(route.start, '起', '#1f8f5f', markerSize)
- renderRouteEndpoint(route.end, '终', '#cf3f59', markerSize)
+ renderMultiFloorRouteConnectors(route, useRouteGuideStyle)
+
+ const routeStartPosition = route.floorSegments[0]?.points[0]?.position || route.start.position
+ const lastRouteSegment = route.floorSegments[route.floorSegments.length - 1]
+ const routeEndPosition = lastRouteSegment?.points[lastRouteSegment.points.length - 1]?.position || route.end.position
+ renderRouteEndpoint(route.start, routeStartPosition, '起', '#54b86c', endpointMarkerSize)
+ renderRouteEndpointLabel(route.start, routeStartPosition, endpointMarkerSize)
+ renderRouteEndpoint(route.end, routeEndPosition, '终', '#df6459', endpointMarkerSize)
+ renderRouteEndpointLabel(route.end, routeEndPosition, endpointMarkerSize)
renderRouteConnectorMarkers(route, markerSize)
+ if (props.routeNavigationActive) {
+ startRouteRoaming(route, markerSize)
+ }
}
-const clearSceneData = () => {
+const clearSceneData = (options: { preserveRouteRoaming?: boolean } = {}) => {
cancelCameraTween()
if (activeModel && scene) {
@@ -2729,11 +4293,15 @@ const clearSceneData = () => {
}
activeModel = null
+ activeRouteCompositeModel = null
+ activeRouteCompositeUrl = ''
disposeFocusLabel()
disposeFocusPulse()
disposeFocusBase()
+ disposeOverviewMapLabels()
selectedPOI.value = null
- clearRoutePreview()
+ clearRoutePreview({ preserveRoaming: options.preserveRouteRoaming })
+ detachPoiMarkerGroups()
clearPoiGroupChildren()
}
@@ -2769,31 +4337,113 @@ const formatModelLoadError = (error: unknown, url: string) => {
return rawMessage || '模型资源加载失败'
}
+const modelLoadTimeoutCode = 'GUIDE_MODEL_LOAD_TIMEOUT'
+
+const createModelLoadTimeoutError = (reason: 'stalled' | 'total') => {
+ const error = new Error(reason === 'stalled' ? '模型资源下载长时间无进度' : '模型资源请求超时')
+ error.name = modelLoadTimeoutCode
+ return error
+}
+
+const isModelLoadTimeoutError = (error: unknown) => (
+ error instanceof Error && error.name === modelLoadTimeoutCode
+)
+
+const getInitialForegroundBudgetRemainingMs = () => (
+ foregroundInitialInteractiveBudgetMs
+ - Math.max(0, getNow() - (firstModelLoadStartedAt || getNow()))
+)
+
+const hasExceededInitialForegroundBudget = () => (
+ !initialModelSettled
+ && firstModelLoadStartedAt > 0
+ && getInitialForegroundBudgetRemainingMs() <= 0
+)
+
+const isPersistentCachedModelUrl = (url: string) => (
+ String(url || '').trim().toLowerCase().startsWith('blob:')
+)
+
+const getModelLoadWatchdogTimeouts = (
+ url: string,
+ options: LoadModelOptions = {}
+) => {
+ if (options.suppressProgress || isPersistentCachedModelUrl(url)) {
+ return {
+ stallMs: backgroundModelLoadStallTimeoutMs,
+ totalMs: backgroundModelLoadTotalTimeoutMs
+ }
+ }
+
+ const initialBudgetRemainingMs = !initialModelSettled
+ ? getInitialForegroundBudgetRemainingMs()
+ : backgroundModelLoadTotalTimeoutMs
+
+ return {
+ stallMs: foregroundModelLoadStallTimeoutMs,
+ totalMs: Math.max(500, initialBudgetRemainingMs)
+ }
+}
+
const loadModelOnce = (
url: string,
label: string,
loadToken?: number,
options: LoadModelOptions = {}
) => new Promise((resolve, reject) => {
- if (!loader) {
- reject(new Error('GLTF 加载器未初始化'))
- return
- }
+ const activeLoader = ensureModelLoader()
const startedAt = getNow()
+ const watchdogTimeouts = getModelLoadWatchdogTimeouts(url, options)
let requestCompletedAt: number | null = null
+ let settled = false
+ let stallTimer: ReturnType | null = null
+ let totalTimer: ReturnType | null = null
+ const clearWatchdogs = () => {
+ if (stallTimer) clearTimeout(stallTimer)
+ if (totalTimer) clearTimeout(totalTimer)
+ stallTimer = null
+ totalTimer = null
+ }
+ const rejectOnce = (error: unknown) => {
+ if (settled) return
+ settled = true
+ clearWatchdogs()
+ reject(error)
+ }
+ const armStallWatchdog = () => {
+ if (stallTimer) clearTimeout(stallTimer)
+ stallTimer = setTimeout(() => {
+ rejectOnce(createModelLoadTimeoutError('stalled'))
+ }, watchdogTimeouts.stallMs)
+ }
+ armStallWatchdog()
+ totalTimer = setTimeout(() => {
+ rejectOnce(createModelLoadTimeoutError('total'))
+ }, watchdogTimeouts.totalMs)
logThreeMapDiagnostic('model-load-start', {
label,
- url
+ url,
+ foreground: !options.suppressProgress,
+ stallTimeoutMs: watchdogTimeouts.stallMs,
+ totalTimeoutMs: watchdogTimeouts.totalMs
})
logThreeMapDiagnostic('model-request-start', {
label,
url
})
- loader.load(
+ activeLoader.load(
url,
(gltf) => {
+ if (settled || (loadToken !== undefined && !isCurrentModelLoad(loadToken))) {
+ clearWatchdogs()
+ disposeObject(gltf.scene)
+ if (!settled) rejectOnce(createStaleModelLoadError())
+ return
+ }
+ settled = true
+ clearWatchdogs()
const completedAt = getNow()
if (requestCompletedAt === null) {
requestCompletedAt = completedAt
@@ -2819,6 +4469,8 @@ const loadModelOnce = (
resolve(gltf)
},
(event) => {
+ if (settled) return
+ armStallWatchdog()
if (event.total > 0 && event.loaded >= event.total && requestCompletedAt === null) {
requestCompletedAt = getNow()
logThreeMapDiagnostic('model-request-complete', {
@@ -2843,13 +4495,14 @@ const loadModelOnce = (
}
},
(error) => {
+ if (settled) return
logThreeMapDiagnostic('model-load-failed', {
label,
url,
elapsedMs: Math.round(getNow() - startedAt),
error: error instanceof Error ? error.message : String(error)
})
- reject(error)
+ rejectOnce(error)
}
)
})
@@ -2867,19 +4520,36 @@ const loadModelFromNetwork = async (
throw new Error('模型 URL 缺失')
}
+ // Persistent Cache Storage is used before the network. A corrupt local blob is discarded and
+ // the same request continues through the normal network retry path.
+ const cachedAsset = await guideModelPersistentCache.get(normalizedUrl, options.modelVersion)
+ if (cachedAsset) {
+ try {
+ return await loadModelOnce(cachedAsset.url, `${label}(本地缓存)`, loadToken, options)
+ } catch (error) {
+ lastError = error
+ await guideModelPersistentCache.remove(normalizedUrl, options.modelVersion)
+ } finally {
+ cachedAsset.release()
+ }
+ }
+
for (let attempt = 0; attempt <= modelLoadRetryDelaysMs.length; attempt += 1) {
if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) {
throw createStaleModelLoadError()
}
try {
- return await loadModelOnce(normalizedUrl, label, loadToken, options)
+ const gltf = await loadModelOnce(normalizedUrl, label, loadToken, options)
+ void guideModelPersistentCache.store(normalizedUrl, options.modelVersion)
+ return gltf
} catch (error) {
if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) {
throw createStaleModelLoadError()
}
lastError = error
+ if (isModelLoadTimeoutError(error) || !options.suppressProgress) break
const retryDelay = modelLoadRetryDelaysMs[attempt]
if (retryDelay === undefined) break
@@ -2891,6 +4561,7 @@ const loadModelFromNetwork = async (
}
}
+ if (isModelLoadTimeoutError(lastError)) throw lastError
throw new Error(formatModelLoadError(lastError, normalizedUrl))
}
@@ -2909,7 +4580,11 @@ const loadModelWithFallback = async (
label,
semanticKey,
loadSource: (url) => loadModelFromNetwork(url, label, loadToken, options),
- shouldStopOnError: isStaleModelLoadError
+ shouldStopOnError: (error) => (
+ isStaleModelLoadError(error)
+ || isModelLoadTimeoutError(error)
+ || !options.suppressProgress
+ )
})
if (!options.suppressProgress) {
setProgress(92, `${label}: 正在准备三维场景...`)
@@ -2953,7 +4628,13 @@ const applyMultiFloorLayout = (items: MultiFloorModelItem[]) => {
const centerIndex = (items.length - 1) / 2
items.forEach((item, index) => {
- const offsetY = (centerIndex - index) * verticalGap
+ // Each GLB floor has its own source elevation. Align its visual centre to
+ // an evenly spaced route deck instead of applying one identical delta;
+ // otherwise the rendered floor height and the route's vertical connector
+ // disagree on MF/3F/4F/5F.
+ const sourceCenterY = getObjectBox(item.model).getCenter(new THREE.Vector3()).y
+ const targetCenterY = (centerIndex - index) * verticalGap
+ const offsetY = targetCenterY - sourceCenterY
item.model.position.y += offsetY
item.model.userData.multiFloorOffsetY = offsetY
item.model.userData.multiFloorVerticalGap = verticalGap
@@ -3034,18 +4715,115 @@ const preloadModelUrls = async (
}
}
+const getWeakNetworkModelPreloadTarget = (
+ view: 'overview' | 'floor',
+ floorId: string
+) => {
+ const packageData = renderPackage.value
+ if (!packageData) return null
+
+ if (view === 'overview') {
+ return {
+ urls: getOverviewModelUrls(packageData),
+ label: '弱网后台预热建筑外观模型',
+ modelVersion: packageData.overviewModelVersion,
+ semanticKey: packageData.overviewModelUrl
+ }
+ }
+
+ const floor = floorIndex.value.find((item) => item.floorId === floorId)
+ if (!floor) return null
+
+ return {
+ urls: getFloorModelUrls(floor),
+ label: `弱网后台预热 ${formatFloorLabel(floor.floorId)} 模型`,
+ modelVersion: floor.modelVersion,
+ semanticKey: floor.modelUrl
+ }
+}
+
+const preloadWeakNetworkFallbackModelInBackground = (
+ view: 'overview' | 'floor',
+ floorId: string,
+ reason: string
+) => {
+ const target = getWeakNetworkModelPreloadTarget(view, floorId)
+ const candidates = target?.urls.map((url) => url.trim()).filter(Boolean) || []
+ if (!target || !candidates.length || isDisposed) {
+ logThreeMapDiagnostic('weak-network-model-preload-skip', {
+ view,
+ floorId,
+ reason: !target ? 'target-missing' : !candidates.length ? 'url-missing' : 'disposed'
+ })
+ return
+ }
+
+ const preloadSeq = ++weakNetworkFallbackPreloadSeq
+ void (async () => {
+ try {
+ ensureModelLoader()
+ if (preloadSeq !== weakNetworkFallbackPreloadSeq || isDisposed) return
+
+ const startedAt = getNow()
+ logThreeMapDiagnostic('weak-network-model-preload-start', {
+ view,
+ floorId,
+ reason,
+ urls: candidates
+ })
+ await guideModelLoadManager.preload({
+ urls: candidates,
+ label: target.label,
+ semanticKey: target.semanticKey,
+ loadSource: (url) => loadModelFromNetwork(url, target.label, undefined, {
+ suppressProgress: true,
+ modelVersion: target.modelVersion
+ })
+ })
+ if (preloadSeq !== weakNetworkFallbackPreloadSeq || isDisposed) return
+ logThreeMapDiagnostic('weak-network-model-preload-complete', {
+ view,
+ floorId,
+ reason,
+ elapsedMs: Math.round(getNow() - startedAt),
+ diagnostics: guideModelLoadManager.getDiagnostics()
+ })
+ } catch (error) {
+ if (preloadSeq !== weakNetworkFallbackPreloadSeq || isDisposed) return
+ logThreeMapDiagnostic('weak-network-model-preload-failed', {
+ view,
+ floorId,
+ reason,
+ error: error instanceof Error ? error.message : String(error)
+ })
+ console.warn('[ThreeMap] 弱网 WebP 后台模型预热失败:', {
+ view,
+ floorId,
+ error: error instanceof Error ? error.message : String(error)
+ })
+ }
+ })()
+}
+
const prepareDefaultFloorAssetsInBackground = async (
floor: FloorIndexItem,
preloadSeq: number
) => {
const existing = preparedFloorModelCache.get(floor.floorId)
- if (existing?.modelUrl === floor.modelUrl) return
+ if (existing?.modelUrl === floor.modelUrl) {
+ preparedFloorModelCache.forEach((entry, cachedFloorId) => {
+ if (cachedFloorId === floor.floorId) return
+ disposeObject(entry.model)
+ preparedFloorModelCache.delete(cachedFloorId)
+ })
+ return
+ }
const gltf = await loadModelWithFallback(
getFloorModelUrls(floor),
`准备默认进入楼层 ${formatFloorLabel(floor.floorId)} 场景`,
undefined,
- { suppressProgress: true }
+ { suppressProgress: true, modelVersion: floor.modelVersion }
)
if (!isCurrentPreload('default-entry', preloadSeq) || isDisposed) {
disposeObject(gltf.scene)
@@ -3058,10 +4836,13 @@ const prepareDefaultFloorAssetsInBackground = async (
prepareModel(model)
applyIndoorInitialModelTransform(model)
applyModelVisibilityForView(model, 'floor', floor.floorId)
- const previous = preparedFloorModelCache.get(floor.floorId)
- if (previous) {
- disposeObject(previous.model)
- }
+
+ // 只保留一个待进入楼层的完整场景。源 GLTF 缓存负责复用解析结果,
+ // 这里的 clone 只服务于下一次室内进入,不能随楼层切换无限累积。
+ preparedFloorModelCache.forEach((entry, cachedFloorId) => {
+ disposeObject(entry.model)
+ preparedFloorModelCache.delete(cachedFloorId)
+ })
preparedFloorModelCache.set(floor.floorId, {
floorId: floor.floorId,
modelUrl: floor.modelUrl,
@@ -3070,34 +4851,52 @@ const prepareDefaultFloorAssetsInBackground = async (
if (shouldRenderPoiMarkers.value) {
await prepareFloorPOIs(floor)
+ if (!isCurrentPreload('default-entry', preloadSeq) || isDisposed) {
+ const cached = preparedFloorModelCache.get(floor.floorId)
+ if (cached?.model === model) {
+ preparedFloorModelCache.delete(floor.floorId)
+ disposeObject(cached.model)
+ }
+ return
+ }
}
}
const scheduleDefaultFloorPreload = () => {
const floorId = getAutoEntryTargetFloorId()
const floor = floorIndex.value.find((item) => item.floorId === floorId)
- if (!floor) return
+ if (!floor) {
+ logThreeMapDiagnostic('default-floor-preload-skip', {
+ floorId,
+ reason: 'floor-metadata-missing'
+ })
+ return
+ }
const preloadSeq = defaultFloorPreloadSeq + 1
defaultFloorPreloadSeq = preloadSeq
- // 外观首帧已经提交后再启动默认楼层预加载,不占用首屏模型的关键路径。
- window.setTimeout(() => {
- if (!isCurrentPreload('default-entry', preloadSeq) || isDisposed || activeView.value !== 'overview') return
-
- const startedAt = getNow()
- logThreeMapDiagnostic('default-floor-preload-start', {
- floorId,
- modelUrl: floor.modelUrl
- })
- void (async () => {
- await preloadModelUrls(
- getFloorModelUrls(floor),
- `预加载默认进入楼层 ${formatFloorLabel(floorId)} 模型`,
- preloadSeq,
- 'default-entry'
- )
- if (!isCurrentPreload('default-entry', preloadSeq) || isDisposed) return
+ // 外观首帧提交后只在浏览器空闲时准备默认楼层,避免下载和 GLTF 解析抢占首屏。
+ const networkInfo = getBrowserNetworkInfo()
+ const policy = getBackgroundPreloadPolicy(networkInfo)
+ logThreeMapDiagnostic('default-floor-preload-schedule', {
+ floorId,
+ delayMs: policy.delayMs,
+ reason: policy.reason,
+ allowed: policy.allowed,
+ effectiveType: networkInfo?.effectiveType || 'unknown'
+ })
+ const scheduled = defaultFloorPreloadScheduler.schedule(async () => {
+ try {
+ const startedAt = getNow()
+ logThreeMapDiagnostic('default-floor-preload-start', {
+ floorId,
+ modelUrl: floor.modelUrl,
+ network: networkInfo?.effectiveType || 'unknown'
+ })
+ if (!isCurrentPreload('default-entry', preloadSeq) || isDisposed || activeView.value !== 'overview') return
+ // loadModelWithFallback 已经通过 GuideModelLoadManager 复用源 GLTF
+ // 缓存;无需先做一次只为预热的 preload,再重复命中缓存并 clone。
await prepareDefaultFloorAssetsInBackground(floor, preloadSeq)
if (!isCurrentPreload('default-entry', preloadSeq) || isDisposed) return
logThreeMapDiagnostic('default-floor-preload-complete', {
@@ -3108,14 +4907,22 @@ const scheduleDefaultFloorPreload = () => {
elapsedMs: Math.round(getNow() - startedAt),
diagnostics: guideModelLoadManager.getDiagnostics()
})
- })().catch((error) => {
+ } catch (error) {
console.warn('[ThreeMap] 默认进入楼层后台准备失败:', {
floorId,
modelUrl: floor.modelUrl,
error: error instanceof Error ? error.message : String(error)
})
+ }
+ })
+
+ if (!scheduled) {
+ logThreeMapDiagnostic('default-floor-preload-skip', {
+ floorId,
+ reason: policy.reason,
+ effectiveType: networkInfo?.effectiveType || 'unknown'
})
- }, 0)
+ }
}
const getAdjacentFloors = (floorId: string) => {
@@ -3136,10 +4943,12 @@ const scheduleAdjacentFloorPreload = (floorId: string) => {
const preloadSeq = adjacentPreloadSeq + 1
adjacentPreloadSeq = preloadSeq
- window.setTimeout(() => {
- if (preloadSeq !== adjacentPreloadSeq || isDisposed) return
+ const networkInfo = getBrowserNetworkInfo()
+ const policy = getBackgroundPreloadPolicy(networkInfo)
+ const scheduled = adjacentPreloadScheduler.schedule(async () => {
+ try {
+ if (preloadSeq !== adjacentPreloadSeq || isDisposed) return
- void (async () => {
const seenModelKeys = new Set()
for (const floor of adjacentFloors) {
if (preloadSeq !== adjacentPreloadSeq || isDisposed) return
@@ -3163,8 +4972,21 @@ const scheduleAdjacentFloorPreload = (floorId: string) => {
preloadSeq
)
}
- })()
- }, adjacentPreloadDelayMs)
+ } catch (error) {
+ console.warn('[ThreeMap] 相邻楼层模型预加载失败:', {
+ floorIds: adjacentFloors.map((floor) => floor.floorId),
+ error: error instanceof Error ? error.message : String(error)
+ })
+ }
+ }, { delayMs: 1800 })
+
+ if (!scheduled) {
+ logThreeMapDiagnostic('adjacent-preload-skip', {
+ floorId,
+ reason: policy.reason,
+ effectiveType: networkInfo?.effectiveType || 'unknown'
+ })
+ }
}
const getFloorMatchKeys = (floor: FloorIndexItem) => (
@@ -3240,6 +5062,10 @@ const disposeDetachedMultiFloorModels = (
const resetAutoSwitchDistanceTracking = () => {
activeAutoSwitchInputSource = 'gesture'
+ if (buttonAutoSwitchTimer) {
+ clearTimeout(buttonAutoSwitchTimer)
+ buttonAutoSwitchTimer = null
+ }
autoSwitchStateMachine.reset(activeView.value)
}
@@ -3249,10 +5075,66 @@ const ensureFloorAutoExitZoomRange = () => {
const distance = controls.getDistance()
if (!Number.isFinite(distance) || distance <= 0) return
- // 楼层退出以当前距离的 120% 为阈值;参考外观相机正好处于原 600 上限时,
- // 必须预留额外缩小空间,否则切层后无法满足自动返回外观的条件。
- const requiredMaxDistance = distance * (1 + autoSwitchExitDistanceRatio + autoSwitchExitDistanceMarginRatio)
- controls.maxDistance = Math.max(controls.maxDistance, requiredMaxDistance)
+ // Indoor navigation keeps a stable baseline and a small zoom-out inspection
+ // band. Only sustained travel beyond the exit threshold returns to exterior.
+ controls.minDistance = Math.max(SGS_VISUAL_RENDER_CONFIG.controls.minDistance, distance * floorZoomInLimitRatio)
+ controls.maxDistance = distance * floorZoomOutLimitRatio
+ floorNavigationDistance = distance
+}
+
+const getOverviewAutoEntryDistance = () => (
+ getReferenceOverviewCameraState().distance * (1 - GUIDE_AUTO_SWITCH_ENTER_RATIO)
+)
+
+const scheduleButtonAutoSwitch = (
+ direction: GuideAutoSwitchDirection,
+ candidateDistance: number
+) => {
+ if (buttonAutoSwitchTimer) clearTimeout(buttonAutoSwitchTimer)
+
+ const candidateStartedAt = getNow()
+ buttonAutoSwitchTimer = window.setTimeout(() => {
+ buttonAutoSwitchTimer = null
+ if (!controls || !canRunAutoSwitch(direction)) return
+
+ const currentDistance = controls.getDistance()
+ const conditionStillMet = direction === 'enter-floor'
+ ? currentDistance <= getOverviewAutoEntryDistance()
+ : floorNavigationDistance > 0
+ && currentDistance >= floorNavigationDistance * (1 + GUIDE_AUTO_SWITCH_EXIT_RATIO)
+ if (!conditionStillMet) return
+
+ autoSwitchStateMachine.reset(activeView.value)
+ requestAutoSwitch({
+ direction,
+ distance: candidateDistance,
+ source: 'button',
+ candidateStartedAt,
+ confirmedAt: getNow()
+ })
+ // Wait for the button tween to reach the requested distance before confirming.
+ // Checking halfway through the animation previously left the user in an
+ // over-zoomed exterior/floor state without triggering the transition.
+ }, Math.max(autoSwitchEnterHoldMs, buttonZoomTweenDurationMs + programmaticCameraTailMs + 40))
+}
+
+const trackButtonZoomForAutoSwitch = (currentDistance: number, nextDistance: number) => {
+ activeAutoSwitchInputSource = 'button'
+
+ if (activeView.value === 'overview') {
+ if (nextDistance <= getOverviewAutoEntryDistance()) {
+ scheduleButtonAutoSwitch('enter-floor', nextDistance)
+ }
+ return
+ }
+
+ if (
+ activeView.value === 'floor'
+ && floorNavigationDistance > 0
+ && nextDistance >= floorNavigationDistance * (1 + GUIDE_AUTO_SWITCH_EXIT_RATIO)
+ ) {
+ scheduleButtonAutoSwitch('exit-overview', nextDistance)
+ }
}
const handleWheelIntent = (event: WheelEvent) => {
@@ -3274,7 +5156,9 @@ const canRunAutoSwitch = (direction: GuideAutoSwitchDirection) => {
|| (isProgrammaticCameraChange && activeAutoSwitchInputSource !== 'button')
|| !controls
|| !activeModel
+ || props.showRoute
|| activeView.value === 'multi'
+ || activeRouteCompositeModel === activeModel
) {
return false
}
@@ -3309,13 +5193,15 @@ const requestAutoSwitch = (request: GuideAutoSwitchRequest) => {
from: 'overview',
to: 'floor',
trigger: 'zoom-in',
- distance: request.distance
+ distance: request.distance,
+ sceneRevision: props.sceneRevision
},
async () => {
await loadFloor(targetFloorId, {
preserveCurrentSceneUntilReady: hasSceneToKeep,
suppressProgress: hasSceneToKeep,
detachPoiBeforeLoad: true,
+ applyFloorBaseline: true,
onCameraStable: () => {
activeAutoSwitchInputSource = 'gesture'
logThreeMapDiagnostic('auto-switch-stable', {
@@ -3332,23 +5218,41 @@ const requestAutoSwitch = (request: GuideAutoSwitchRequest) => {
return
}
+ // Do not first restore the floor-local camera onto the exterior model and
+ // then run a second zoom tween. That transient state was the main source of
+ // the perceived exit hitch. Commit the cached exterior directly at its
+ // shared reference composition.
+ const overviewCamera = getAutoSwitchExitCamera()
void runAutoSwitchLoad(
{
from: 'floor',
to: 'overview',
trigger: 'zoom-out',
- distance: request.distance
+ distance: request.distance,
+ sceneRevision: props.sceneRevision
},
- loadOverview
+ () => loadOverview({
+ cameraSnapshot: overviewCamera,
+ onCameraStable: () => {
+ activeAutoSwitchInputSource = 'gesture'
+ logThreeMapDiagnostic('auto-switch-stable', {
+ direction: request.direction,
+ source: request.source,
+ elapsedSinceThresholdMs: Math.round(getNow() - thresholdReachedAt)
+ })
+ }
+ })
)
}
const autoSwitchStateMachine = new GuideAutoSwitchStateMachine({
- enterHoldMs: 120,
+ enterRatio: GUIDE_AUTO_SWITCH_ENTER_RATIO,
+ enterHoldMs: autoSwitchEnterHoldMs,
exitHoldMs: 400,
intentTimeoutMs: 2000,
reverseToleranceRatio: 0.008,
- exitRatio: autoSwitchExitDistanceRatio,
+ overviewEntryDistance: getOverviewAutoEntryDistance,
+ exitRatio: GUIDE_AUTO_SWITCH_EXIT_RATIO,
cooldownMs: props.autoSwitchCooldown,
canSwitch: canRunAutoSwitch,
onSwitchRequested: requestAutoSwitch
@@ -3365,7 +5269,13 @@ const checkAutoSwitch = (source: GuideAutoSwitchInputSource = activeAutoSwitchIn
}
const runAutoSwitchLoad = async (
- event: { from: 'overview' | 'floor'; to: 'overview' | 'floor'; trigger: 'zoom-in' | 'zoom-out'; distance: number },
+ event: {
+ from: 'overview' | 'floor'
+ to: 'overview' | 'floor'
+ trigger: 'zoom-in' | 'zoom-out'
+ distance: number
+ sceneRevision: number
+ },
loadTask: () => Promise,
options: { showLoading?: boolean } = {}
) => {
@@ -3420,6 +5330,8 @@ const cloneCameraSnapshot = (snapshot: CameraSnapshot): CameraSnapshot => ({
distance: snapshot.distance
})
+const getAutoSwitchExitCamera = () => getReferenceOverviewCameraState()
+
const captureCameraSnapshot = (): CameraSnapshot => {
if (!camera || !controls) return cloneCameraSnapshot(referenceOverviewCameraState)
@@ -3477,10 +5389,231 @@ const restoreCameraSnapshot = (snapshot: CameraSnapshot) => {
}
}
+const captureOrbitControlsSnapshot = (): OrbitControlsSnapshot | null => {
+ if (!controls) return null
+ return {
+ enabled: controls.enabled,
+ cursor: controls.cursor.clone(),
+ enableDamping: controls.enableDamping,
+ dampingFactor: controls.dampingFactor,
+ enableZoom: controls.enableZoom,
+ zoomSpeed: controls.zoomSpeed,
+ zoomToCursor: controls.zoomToCursor,
+ minDistance: controls.minDistance,
+ maxDistance: controls.maxDistance,
+ minZoom: controls.minZoom,
+ maxZoom: controls.maxZoom,
+ minTargetRadius: controls.minTargetRadius,
+ maxTargetRadius: controls.maxTargetRadius,
+ enableRotate: controls.enableRotate,
+ rotateSpeed: controls.rotateSpeed,
+ minPolarAngle: controls.minPolarAngle,
+ maxPolarAngle: controls.maxPolarAngle,
+ minAzimuthAngle: controls.minAzimuthAngle,
+ maxAzimuthAngle: controls.maxAzimuthAngle,
+ enablePan: controls.enablePan,
+ panSpeed: controls.panSpeed,
+ screenSpacePanning: controls.screenSpacePanning,
+ keyPanSpeed: controls.keyPanSpeed,
+ autoRotate: controls.autoRotate,
+ autoRotateSpeed: controls.autoRotateSpeed,
+ keys: { ...controls.keys },
+ mouseButtons: { ...controls.mouseButtons },
+ touches: { ...controls.touches },
+ target0: controls.target0.clone(),
+ position0: controls.position0.clone(),
+ zoom0: controls.zoom0
+ }
+}
+
+const restoreOrbitControlsSnapshot = (snapshot: OrbitControlsSnapshot) => {
+ if (!controls) return
+ controls.enabled = snapshot.enabled
+ controls.cursor.copy(snapshot.cursor)
+ controls.enableDamping = snapshot.enableDamping
+ controls.dampingFactor = snapshot.dampingFactor
+ controls.enableZoom = snapshot.enableZoom
+ controls.zoomSpeed = snapshot.zoomSpeed
+ controls.zoomToCursor = snapshot.zoomToCursor
+ controls.minDistance = snapshot.minDistance
+ controls.maxDistance = snapshot.maxDistance
+ controls.minZoom = snapshot.minZoom
+ controls.maxZoom = snapshot.maxZoom
+ controls.minTargetRadius = snapshot.minTargetRadius
+ controls.maxTargetRadius = snapshot.maxTargetRadius
+ controls.enableRotate = snapshot.enableRotate
+ controls.rotateSpeed = snapshot.rotateSpeed
+ controls.minPolarAngle = snapshot.minPolarAngle
+ controls.maxPolarAngle = snapshot.maxPolarAngle
+ controls.minAzimuthAngle = snapshot.minAzimuthAngle
+ controls.maxAzimuthAngle = snapshot.maxAzimuthAngle
+ controls.enablePan = snapshot.enablePan
+ controls.panSpeed = snapshot.panSpeed
+ controls.screenSpacePanning = snapshot.screenSpacePanning
+ controls.keyPanSpeed = snapshot.keyPanSpeed
+ controls.autoRotate = snapshot.autoRotate
+ controls.autoRotateSpeed = snapshot.autoRotateSpeed
+ Object.assign(controls.keys, snapshot.keys)
+ Object.assign(controls.mouseButtons, snapshot.mouseButtons)
+ Object.assign(controls.touches, snapshot.touches)
+ controls.target0.copy(snapshot.target0)
+ controls.position0.copy(snapshot.position0)
+ controls.zoom0 = snapshot.zoom0
+}
+
+const applyLiveGlbTopView = (options: {
+ captureRestoreState?: boolean
+ transitionRevision?: number
+} = {}) => {
+ if (
+ !scene
+ || !renderer
+ || !camera
+ || !controls
+ || !activeModel
+ || weakNetworkFallbackActive.value
+ || !isTwoDimensionalMode.value
+ || (
+ options.transitionRevision !== undefined
+ && options.transitionRevision !== renderModeTransitionRevision
+ )
+ ) return false
+
+ cancelCameraTween()
+ clearProgrammaticCameraTimer()
+ const cameraSnapshot = captureCameraSnapshot()
+ const controlsSnapshot = captureOrbitControlsSnapshot()
+ if (!controlsSnapshot) return false
+
+ if (options.captureRestoreState !== false) {
+ liveGlbThreeDCameraSnapshot = cloneCameraSnapshot(cameraSnapshot)
+ liveGlbThreeDControlsSnapshot = controlsSnapshot
+ }
+
+ const pose = createGuideTopViewPose({
+ position: cameraSnapshot.position,
+ target: cameraSnapshot.target,
+ quaternion: cameraSnapshot.quaternion,
+ fov: cameraSnapshot.fov,
+ aspect: camera.aspect
+ })
+ const dampingEnabled = controls.enableDamping
+ controls.enableDamping = false
+ try {
+ controls.update()
+ controls.target.copy(pose.target)
+ camera.position.copy(pose.position)
+ camera.up.copy(pose.up)
+ camera.lookAt(pose.target)
+ camera.updateProjectionMatrix()
+ camera.updateMatrixWorld()
+ liveGlbTopActive.value = true
+ threeRendererRetainedForTwoD.value = false
+ syncControlInteractionOptions()
+ controls.update()
+ } finally {
+ controls.enableDamping = dampingEnabled
+ }
+
+ startRenderLoop()
+ refreshPoiVisibilityByDistance()
+ return true
+}
+
+const reapplyLiveGlbTopAfterSceneCommit = () => {
+ if (
+ !liveGlbTopActive.value
+ || weakNetworkFallbackActive.value
+ || !isTwoDimensionalMode.value
+ ) return false
+
+ // The committed camera is the new scene's 3D baseline. Capture it before
+ // applying top view so returning to 3D never restores the previous floor.
+ liveGlbTopActive.value = false
+ syncControlInteractionOptions()
+ return applyLiveGlbTopView({ captureRestoreState: true })
+}
+
+const restoreLiveGlbThreeDimensionalView = () => {
+ if (
+ !liveGlbTopActive.value
+ || !camera
+ || !controls
+ || !liveGlbThreeDCameraSnapshot
+ || !liveGlbThreeDControlsSnapshot
+ ) return false
+
+ const currentTarget = controls.target.clone()
+ const currentDistance = controls.getDistance()
+ const snapshot = cloneCameraSnapshot(liveGlbThreeDCameraSnapshot)
+ const returnPose = createGuideObliqueReturnPose({
+ originalPosition: snapshot.position,
+ originalTarget: snapshot.target,
+ currentTarget,
+ currentDistance
+ })
+ snapshot.target.copy(returnPose.target)
+ snapshot.position.copy(returnPose.position)
+ snapshot.distance = returnPose.distance
+
+ liveGlbTopActive.value = false
+ restoreOrbitControlsSnapshot(liveGlbThreeDControlsSnapshot)
+ restoreCameraSnapshot(snapshot)
+ restoreOrbitControlsSnapshot(liveGlbThreeDControlsSnapshot)
+ liveGlbThreeDCameraSnapshot = null
+ liveGlbThreeDControlsSnapshot = null
+ startRenderLoop()
+ refreshPoiVisibilityByDistance()
+ return true
+}
+
export type ResetViewBaselineResult = 'applied' | 'not-ready' | 'invalid-target' | 'failed' | 'stale'
+const getReferenceOverviewCameraState = () => {
+ const snapshot = cloneCameraSnapshot(referenceOverviewCameraState)
+ if (!renderer) return snapshot
+
+ const viewportWidth = renderer.domElement.clientWidth
+ const viewportHeight = renderer.domElement.clientHeight
+ if (!viewportWidth || !viewportHeight) return snapshot
+
+ const viewportAspect = viewportWidth / viewportHeight
+ const referenceAspect = 430 / 720
+ const narrowViewportScale = Math.min(1.35, Math.max(1, referenceAspect / viewportAspect))
+ if (narrowViewportScale > 1) {
+ const direction = snapshot.position.clone().sub(snapshot.target)
+ snapshot.position.copy(snapshot.target).add(direction.multiplyScalar(narrowViewportScale))
+ }
+
+ // Use the same horizontal safe-area composition when returning from an
+ // indoor floor. Without this offset the exterior model appears left-biased
+ // beside the right-side action controls.
+ const viewDirection = snapshot.target.clone().sub(snapshot.position).normalize()
+ const cameraRight = new THREE.Vector3().crossVectors(viewDirection, snapshot.up).normalize()
+ const fov = snapshot.fov * (Math.PI / 180)
+ const viewHeight = 2 * snapshot.distance * Math.tan(fov / 2)
+ const viewWidth = viewHeight * viewportAspect
+ const horizontalOffset = cameraRight.multiplyScalar(
+ SGS_VISUAL_RENDER_CONFIG.framing.overviewScreenOffsetRatio.x * viewWidth
+ )
+ snapshot.position.add(horizontalOffset)
+ snapshot.target.add(horizontalOffset)
+
+ snapshot.position.y += SGS_VISUAL_RENDER_CONFIG.framing.overviewReferenceVerticalOffset
+ snapshot.target.y += SGS_VISUAL_RENDER_CONFIG.framing.overviewReferenceVerticalOffset
+ snapshot.distance = snapshot.position.distanceTo(snapshot.target)
+ return snapshot
+}
+
const applyReferenceOverviewCameraState = () => {
- restoreCameraSnapshot(referenceOverviewCameraState)
+ const snapshot = getReferenceOverviewCameraState()
+ restoreCameraSnapshot(snapshot)
+ if (controls) {
+ controls.maxDistance = Math.max(
+ controls.maxDistance,
+ snapshot.distance * SGS_VISUAL_RENDER_CONFIG.framing.overviewMaxDistanceFactor
+ )
+ }
}
const clearFloorViewBaselines = () => {
@@ -3496,10 +5629,89 @@ const getBoxSignature = (box: THREE.Box3) => [
box.max.z
].map((value) => value.toFixed(6)).join(':')
+const createFloorNavigationCamera = (model: THREE.Object3D) => {
+ const fallback = cloneCameraSnapshot(referenceFloorCameraState)
+ if (!camera || !controls) {
+ return {
+ camera: fallback,
+ minDistance: fallback.distance * floorZoomInLimitRatio,
+ maxDistance: fallback.distance * floorZoomOutLimitRatio
+ }
+ }
+
+ const box = getObjectBox(model)
+ const size = box.getSize(new THREE.Vector3())
+ const maxDim = Math.max(size.x, size.y, size.z, 1)
+ const distance = getIndoorReferenceCameraDistance(camera.fov)
+ const exteriorCamera = getReferenceOverviewCameraState()
+ const direction = exteriorCamera.position.clone().sub(exteriorCamera.target).normalize()
+ const up = exteriorCamera.up.clone()
+ const floorId = typeof model.userData.floorId === 'string'
+ ? model.userData.floorId
+ : currentFloor.value
+ const floor = floorIndex.value.find((item) => item.floorId === floorId)
+ const level = getFloorSortLevel(floor)
+ // B2 to 2F share the exterior's world target. MF and the compact upper
+ // floors occupy only a corner of that footprint, so they use their own
+ // geometric center while preserving the same camera direction/distance and
+ // the same screen-safe visual center.
+ const useCompactFloorVisualCenter = level === 1.5 || level >= 3
+ const target = useCompactFloorVisualCenter
+ ? box.getCenter(new THREE.Vector3())
+ : exteriorCamera.target.clone()
+ const position = target.clone().add(direction.multiplyScalar(distance))
+
+ if (useCompactFloorVisualCenter) {
+ const viewDirection = target.clone().sub(position).normalize()
+ const cameraRight = new THREE.Vector3().crossVectors(viewDirection, up).normalize()
+ const fov = camera.fov * (Math.PI / 180)
+ const viewHeight = 2 * distance * Math.tan(fov / 2)
+ const viewWidth = viewHeight * camera.aspect
+ const screenOffset = cameraRight
+ .multiplyScalar(SGS_VISUAL_RENDER_CONFIG.framing.floorScreenOffsetRatio.x * viewWidth)
+ .add(up.clone().multiplyScalar(SGS_VISUAL_RENDER_CONFIG.framing.floorScreenOffsetRatio.y * viewHeight))
+ position.add(screenOffset)
+ target.add(screenOffset)
+ }
+
+ const framingCamera = camera.clone()
+ framingCamera.position.copy(position)
+ framingCamera.up.copy(up)
+ framingCamera.lookAt(target)
+ framingCamera.updateProjectionMatrix()
+ framingCamera.updateMatrixWorld()
+
+ return {
+ camera: {
+ position: framingCamera.position.clone(),
+ target,
+ up: framingCamera.up.clone(),
+ quaternion: framingCamera.quaternion.clone(),
+ fov: framingCamera.fov,
+ zoom: framingCamera.zoom,
+ near: Math.max(SGS_VISUAL_RENDER_CONFIG.camera.near, maxDim / 1000),
+ far: Math.max(SGS_VISUAL_RENDER_CONFIG.camera.far, maxDim * 20),
+ distance
+ },
+ minDistance: Math.max(SGS_VISUAL_RENDER_CONFIG.controls.minDistance, distance * floorZoomInLimitRatio),
+ maxDistance: Math.max(distance, distance * floorZoomOutLimitRatio)
+ }
+}
+
+const applyFloorNavigationRange = (baseline: Pick) => {
+ if (!controls) return
+
+ controls.minDistance = baseline.minDistance
+ controls.maxDistance = baseline.maxDistance
+ floorNavigationDistance = baseline.camera.distance
+ autoSwitchStateMachine.setFloorInitialDistance(floorNavigationDistance)
+}
+
const createFloorViewBaseline = (floorId: string, model: THREE.Object3D, modelUrl: string): FloorViewBaseline | null => {
if (!camera || !controls) return null
const box = getObjectBox(model)
+ const framing = createFloorNavigationCamera(model)
return {
floorId,
@@ -3507,7 +5719,9 @@ const createFloorViewBaseline = (floorId: string, model: THREE.Object3D, modelUr
packageEpoch: modelPackageEpoch,
aspectRatio: camera.aspect,
boundsSignature: getBoxSignature(box),
- camera: cloneCameraSnapshot(referenceFloorCameraState)
+ camera: framing.camera,
+ minDistance: framing.minDistance,
+ maxDistance: framing.maxDistance
}
}
@@ -3697,8 +5911,37 @@ const getVisiblePoiScreenPositions = () => {
})
}
+const getVisibleVisualMarkerWithoutTextCount = () => getPoiSprites().filter((sprite) => {
+ const userData = getPoiSpriteUserData(sprite)
+ if (!userData.usesDomLabelIcon || sprite.material.opacity <= 0) return false
+
+ let ancestor: THREE.Object3D | null = sprite
+ while (ancestor) {
+ if (!ancestor.visible) return false
+ ancestor = ancestor.parent
+ }
+ return true
+}).length
+
const getVisualStabilityReport = () => ({
camera: serializeCameraSnapshot(captureCameraSnapshot()),
+ actualRendererPath: actualRendererPath.value,
+ renderLoopRunning,
+ sceneUuid: scene?.uuid || null,
+ activeModelUuid: activeModel?.uuid || null,
+ controls: controls
+ ? {
+ enableRotate: controls.enableRotate,
+ enablePan: controls.enablePan,
+ enableZoom: controls.enableZoom,
+ minDistance: controls.minDistance,
+ maxDistance: controls.maxDistance,
+ minPolarAngle: controls.minPolarAngle,
+ maxPolarAngle: controls.maxPolarAngle,
+ minAzimuthAngle: controls.minAzimuthAngle,
+ maxAzimuthAngle: controls.maxAzimuthAngle
+ }
+ : null,
modelRoot: getModelRootTransformAudit(activeModel),
anchors: getProjectedBuildingAnchors(),
activeView: activeView.value,
@@ -3708,9 +5951,85 @@ const getVisualStabilityReport = () => ({
isCameraTweening: Boolean(cameraTween),
poiFocusCameraAnimationCount,
cameraSnapshotRestoreCount,
+ visibleVisualMarkerWithoutTextCount: getVisibleVisualMarkerWithoutTextCount(),
focus: getVisualFocusReport()
})
+const getPoiFocusState = (requestedPoiId = activeFocusPoiId.value) => {
+ const markerCacheKey = getPoiMarkerCacheKey(currentFloor.value)
+ const entry = poiMarkerGroupCache.get(markerCacheKey)
+ const poi = entry?.pois.find((candidate) => candidate.id === requestedPoiId)
+ || (selectedPOI.value?.id === requestedPoiId ? selectedPOI.value : null)
+ const isActiveFocus = Boolean(requestedPoiId && requestedPoiId === activeFocusPoiId.value)
+
+ return {
+ poiId: requestedPoiId,
+ selectedPoiId: selectedPOI.value?.id || '',
+ dataTier: entry?.dataTier || poiDataTierCache.get(currentFloor.value) || null,
+ focusStartedDataTier: isActiveFocus ? activeFocusStartedDataTier : null,
+ markerCount: getPoiSprites().filter((sprite) => (
+ (sprite.userData.poi as RenderPoi | undefined)?.id === requestedPoiId
+ )).length,
+ displayPositionCount: poi ? getPoiDisplayPositions(poi).length : 0,
+ baseAffordanceCount: isActiveFocus ? activeFocusBaseSprites.length : 0,
+ pulseAffordanceCount: isActiveFocus ? activeFocusPulseSprites.length : 0,
+ glowAffordanceCount: isActiveFocus && activeFocusHallGlowMesh ? 1 : 0,
+ modelHighlightRootCount: isActiveFocus ? activeFocusModelRootCount : 0,
+ modelHighlightRootNames: isActiveFocus ? [...activeFocusModelRootNames] : [],
+ modelHighlightMaterials: isActiveFocus
+ ? activeFocusHallMaterialStates.flatMap((state) => state.clonedMaterials.map((material) => ({
+ meshName: state.mesh.name,
+ attached: Array.isArray(state.mesh.material)
+ ? state.mesh.material.includes(material)
+ : state.mesh.material === material,
+ color: getMaterialColor(material)?.getHexString() || null,
+ emissive: getMaterialEmissive(material)?.getHexString() || null,
+ hasMap: Boolean((material as THREE.Material & { map?: THREE.Texture | null }).map)
+ })))
+ : []
+ }
+}
+
+const getAmbientPoiLabelStates = () => (
+ Array.from(poiDomLabelHandlesByElement.values())
+ .filter((handle) => handle.kind === 'ambient' && handle.floorId === currentFloor.value)
+ .map((handle) => {
+ const worldPosition = handle.anchor.getWorldPosition(new THREE.Vector3())
+ const screenPosition = camera && renderer
+ ? getProjectedScreenPosition(handle.anchor)
+ : null
+ const positionedPoint = screenPosition
+ ? {
+ x: screenPosition.x + handle.layoutOffset.x,
+ y: screenPosition.y + handle.layoutOffset.y
+ }
+ : null
+ const inViewport = Boolean(
+ positionedPoint
+ && renderer
+ && isDomLabelWithinViewport(
+ getDomLabelBounds(positionedPoint, handle.size, handle.anchorMode),
+ renderer.domElement.clientWidth,
+ renderer.domElement.clientHeight
+ )
+ )
+ return {
+ poiId: handle.poi.id,
+ floorId: handle.poi.floorId,
+ iconType: handle.poi.iconType,
+ text: handle.element.textContent || '',
+ visible: handle.element.style.visibility === 'visible'
+ && handle.element.style.opacity !== '0',
+ inViewport,
+ anchor: {
+ x: worldPosition.x,
+ y: worldPosition.y,
+ z: worldPosition.z
+ }
+ }
+ })
+)
+
const installVisualStabilityDiagnostics = () => {
if (!import.meta.env.DEV || typeof window === 'undefined') return
@@ -3719,8 +6038,10 @@ const installVisualStabilityDiagnostics = () => {
}
diagnosticsWindow.__GUIDE_3D_VISUAL_STABILITY__ = {
getReport: getVisualStabilityReport,
+ getPoiFocusState,
isInitialModelReady: () => initialModelSettled && Boolean(initialGuideState && activeModel),
getVisiblePoiScreenPositions,
+ getAmbientPoiLabelStates,
getFloors: () => floorIndex.value.map((floor) => ({
floorId: floor.floorId,
label: floor.label,
@@ -3730,9 +6051,20 @@ const installVisualStabilityDiagnostics = () => {
const baseline = floorViewBaselines.get(floorId)
return baseline ? serializeCameraSnapshot(baseline.camera) : null
},
+ getCameraCalibration: () => ({ ...cameraCalibration.value }),
+ setCameraCalibration: (values: Partial) => {
+ for (const [key, value] of Object.entries(values) as Array<[CameraCalibrationKey, number | undefined]>) {
+ if (typeof value === 'number') {
+ updateCameraCalibration(key, value)
+ }
+ }
+ },
switchFloor: handleFloorChange,
showOverview,
+ showMultiFloor,
resetCamera,
+ zoomCamera,
+ getPoiVisibilityTier,
getFloorPois: async (floorId: string) => {
const floor = floorIndex.value.find((item) => item.floorId === floorId)
if (!floor) return []
@@ -3745,6 +6077,9 @@ const installVisualStabilityDiagnostics = () => {
name: poi.name,
kind: poi.kind,
primaryCategory: poi.primaryCategory,
+ iconType: poi.iconType,
+ sourceObjectName: poi.sourceObjectName,
+ mergedSourceObjectNames: poi.mergedSourceObjectNames || [],
positionGltf: poi.positionGltf!
}))
},
@@ -3829,6 +6164,7 @@ const setCameraView = (
controls.maxDistance = Math.max(SGS_VISUAL_RENDER_CONFIG.controls.maxDistance, maxDim * 8)
moveCameraTo(nextPosition, nextTarget, {
durationMs: options.durationMs,
+ immediate: options.immediate,
onComplete: options.onComplete
})
}
@@ -3838,6 +6174,7 @@ const fitCameraToObject = (
preset: CameraPreset = 'oblique',
transitionOptions: {
durationMs?: number
+ immediate?: boolean
onComplete?: () => void
} = {}
) => {
@@ -3868,6 +6205,7 @@ const fitCameraToObject = (
: {}
overviewFitOptions.durationMs = transitionOptions.durationMs
+ overviewFitOptions.immediate = transitionOptions.immediate
overviewFitOptions.onComplete = () => {
if (activeView.value === 'floor' && controls) {
const distance = controls.getDistance()
@@ -3890,11 +6228,22 @@ const applyIndoorInitialModelTransform = (model: THREE.Object3D) => {
}
const resetCamera = () => {
- applyReferenceOverviewCameraState()
- if (activeView.value === 'floor' && controls) {
- ensureFloorAutoExitZoomRange()
- autoSwitchStateMachine.setFloorInitialDistance(controls.getDistance())
+ if (weakNetworkFallbackActive.value) {
+ weakNetworkFallbackRef.value?.resetCamera?.()
+ return
}
+
+ if (activeView.value === 'floor' && activeModel) {
+ const floor = floorIndex.value.find((item) => item.floorId === currentFloor.value)
+ const baseline = floor && getFloorViewBaseline(currentFloor.value, activeModel, floor.modelUrl)
+ if (baseline) {
+ restoreCameraSnapshot(baseline.camera)
+ applyFloorNavigationRange(baseline)
+ }
+ } else {
+ applyReferenceOverviewCameraState()
+ }
+ reapplyLiveGlbTopAfterSceneCommit()
refreshPoiVisibilityByDistance()
}
@@ -3904,7 +6253,111 @@ const setCameraPreset = (preset: CameraPreset) => {
}
}
+let weakNetworkSceneTransitionRevision = 0
+let weakNetworkSceneTransitionInFlight = false
+
+const handleWeakNetworkZoomIntent = async (event: {
+ direction: 'in' | 'out'
+ source: 'button' | 'gesture'
+ boundaryAttempt: boolean
+ referenceVisibleWorldSpan: number | null
+ thresholdReached: boolean
+ viewport: GuideViewportState
+}) => {
+ if (
+ !weakNetworkFallbackActive.value
+ || isLoading.value
+ || !props.autoSwitch
+ || weakNetworkSceneTransitionInFlight
+ ) return
+ const from = activeView.value
+ const shouldEnterFloor = from === 'overview' && event.direction === 'in'
+ const shouldExitOverview = from === 'floor' && event.direction === 'out'
+ if (!shouldEnterFloor && !shouldExitOverview) return
+
+ const exitBlocked = shouldExitOverview && isGuideFloorAutoExitBlocked({
+ disableAutoExit: props.disableAutoExit,
+ hasQuery: props.visiblePoiIds !== null || Boolean(props.targetFocus),
+ hasSelection: Boolean(selectedPOI.value),
+ hasRoute: props.showRoute || Boolean(props.routePreview) || props.routeStartSelectionActive,
+ isNavigating: props.routeNavigationActive
+ })
+ const now = Date.now()
+ const hasReferenceSpan = event.referenceVisibleWorldSpan !== null
+ && Number.isFinite(event.referenceVisibleWorldSpan)
+ && event.referenceVisibleWorldSpan > 0
+ if (hasReferenceSpan) {
+ weakNetworkBoundaryIntentState = {
+ ...weakNetworkBoundaryIntentState,
+ pendingKey: '',
+ pendingAt: 0
+ }
+ if (
+ !event.thresholdReached
+ || exitBlocked
+ || now - weakNetworkBoundaryIntentState.lastTransitionAt < props.autoSwitchCooldown
+ ) return
+ } else {
+ const boundaryDecision = reduceGuideBoundaryIntent(weakNetworkBoundaryIntentState, {
+ scene: from === 'overview' ? 'overview' : 'floor',
+ direction: event.direction,
+ boundaryAttempt: event.boundaryAttempt,
+ blocked: exitBlocked,
+ now,
+ cooldownMs: props.autoSwitchCooldown
+ })
+ weakNetworkBoundaryIntentState = boundaryDecision.state
+ if (!boundaryDecision.allowed) return
+ }
+
+ weakNetworkSceneTransitionInFlight = true
+ const transitionRevision = ++weakNetworkSceneTransitionRevision
+ try {
+ const didPrepare = shouldEnterFloor
+ ? await prepareWeakNetworkFallbackFloor(currentFloor.value)
+ : await prepareWeakNetworkFallbackOverview()
+ if (!didPrepare || transitionRevision !== weakNetworkSceneTransitionRevision) return
+
+ if (hasReferenceSpan) {
+ weakNetworkBoundaryIntentState = {
+ pendingKey: '',
+ pendingAt: 0,
+ lastTransitionAt: now
+ }
+ }
+ const to = shouldEnterFloor ? 'floor' : 'overview'
+ emit('autoSwitch', {
+ from: from === 'overview' ? 'overview' : 'floor',
+ to,
+ trigger: shouldEnterFloor ? 'zoom-in' : 'zoom-out',
+ distance: event.viewport.visibleWorldSpan,
+ sceneRevision: props.sceneRevision
+ })
+ } finally {
+ if (transitionRevision === weakNetworkSceneTransitionRevision) {
+ weakNetworkSceneTransitionInFlight = false
+ }
+ }
+}
+
const zoomCamera = (direction: 'in' | 'out', options: { source?: ZoomCameraSource } = {}) => {
+ if (weakNetworkFallbackActive.value) {
+ const source = options.source || 'button'
+ const renderer = weakNetworkFallbackRef.value
+ if (renderer?.zoomCamera) {
+ pendingWeakNetworkZoom = null
+ return renderer.zoomCamera(direction, source)
+ }
+ pendingWeakNetworkZoom = { direction, source }
+ void nextTick(() => {
+ if (!weakNetworkFallbackActive.value || !pendingWeakNetworkZoom) return
+ const nextZoom = pendingWeakNetworkZoom
+ pendingWeakNetworkZoom = null
+ weakNetworkFallbackRef.value?.zoomCamera?.(nextZoom.direction, nextZoom.source)
+ })
+ return
+ }
+
if (!camera || !controls) return
const offset = camera.position.clone().sub(controls.target)
@@ -3918,13 +6371,7 @@ const zoomCamera = (direction: 'in' | 'out', options: { source?: ZoomCameraSourc
const shouldTrackUserZoom = options.source === 'button'
if (shouldTrackUserZoom) {
- activeAutoSwitchInputSource = 'button'
- autoSwitchStateMachine.beginInput(currentDistance, 'button')
- // 防抖与按钮动画同时开始,达到阈值时直接衔接楼层过渡,避免连续播放两段完整变焦。
- autoSwitchStateMachine.updateDistance(nextDistance, {
- previousDistance: currentDistance,
- source: 'button'
- })
+ trackButtonZoomForAutoSwitch(currentDistance, nextDistance)
}
offset.setLength(nextDistance)
@@ -3938,7 +6385,17 @@ const zoomCamera = (direction: 'in' | 'out', options: { source?: ZoomCameraSourc
const loadCurrentFloorPoiMarkers = async (loadToken: number) => {
if (!shouldRenderPoiMarkers.value) return
- const floor = floorIndex.value.find((item) => item.floorId === currentFloor.value) || floorIndex.value[0]
+ const expectedView = activeView.value
+ const expectedModel = activeModel
+ const overviewFloorId = renderPackage.value?.overviewFloorId
+ const floor = expectedView === 'overview' && overviewFloorId
+ ? {
+ floorId: overviewFloorId,
+ label: '室外',
+ order: 0,
+ modelUrl: ''
+ }
+ : floorIndex.value.find((item) => item.floorId === currentFloor.value) || floorIndex.value[0]
if (!floor) return
if (activeView.value === 'floor') {
@@ -3949,6 +6406,20 @@ const loadCurrentFloorPoiMarkers = async (loadToken: number) => {
const entry = await prepareFloorPOIs(floor, loadToken)
if (entry) {
+ if (
+ !isCurrentModelLoad(loadToken)
+ || activeView.value !== expectedView
+ || activeModel !== expectedModel
+ ) return
+
+ if (
+ expectedView === 'overview'
+ && expectedModel
+ && activeRouteCompositeModel !== expectedModel
+ && overviewFloorId === floor.floorId
+ ) {
+ rebuildOverviewMapLabels(expectedModel, entry.rawPois || entry.pois)
+ }
attachPoiMarkerGroup(entry)
}
assertCurrentModelLoad(loadToken)
@@ -3958,14 +6429,22 @@ const loadCurrentFloorPoiMarkersInBackground = (loadToken: number) => {
if (!shouldRenderPoiMarkers.value) return
const startedAt = getNow()
+ const initialFloorId = currentFloor.value
+ const finishPerformance = startGuidePerformance('interaction', 'poi-label-first-render', {
+ floorId: initialFloorId,
+ view: activeView.value
+ })
logThreeMapDiagnostic('poi-background-start', {
floorId: currentFloor.value,
view: activeView.value
})
void loadCurrentFloorPoiMarkers(loadToken)
- .then(() => {
- if (!isCurrentModelLoad(loadToken)) return
+ .then(async () => {
+ if (!isCurrentModelLoad(loadToken)) {
+ finishPerformance('cancelled', { reason: 'stale-model-load' })
+ return
+ }
logThreeMapDiagnostic('poi-background-ready', {
floorId: currentFloor.value,
view: activeView.value,
@@ -3973,30 +6452,74 @@ const loadCurrentFloorPoiMarkersInBackground = (loadToken: number) => {
})
refreshPoiVisibilityByDistance()
renderRoutePreview()
+ await waitForNextVisualFrame()
+ if (!isCurrentModelLoad(loadToken)) {
+ finishPerformance('cancelled', { reason: 'stale-after-layout' })
+ return
+ }
+ const visibleMarkers = getPoiSprites().filter((sprite) => sprite.visible).length
+ const visibleDomLabels = getPoiSprites().filter((sprite) => {
+ const labelHandle = getPoiSpriteUserData(sprite).labelHandle
+ return sprite.visible && labelHandle?.element.style.visibility === 'visible'
+ }).length
+ finishPerformance('success', {
+ floorId: currentFloor.value,
+ visibleMarkers,
+ visibleDomLabels
+ })
})
.catch((error) => {
- if (isStaleModelLoadError(error)) return
+ if (isStaleModelLoadError(error)) {
+ finishPerformance('cancelled', { reason: 'stale-model-load' })
+ return
+ }
+ finishPerformance('failure', {
+ error: error instanceof Error ? error.name : String(error)
+ })
console.warn('馆内外观点位标记后台加载失败:', error)
})
}
+const loadSameGroundRoutePoiMarkers = (loadToken: number) => {
+ if (!shouldRenderPoiMarkers.value || !activeRouteCompositeModel) return
+ const l1Floor = floorIndex.value.find((floor) => (
+ floor.modelMatchKeys?.some((key) => String(key).trim().toUpperCase() === 'L1')
+ ))
+ if (!l1Floor) return
+
+ void prepareFloorPOIs(l1Floor, loadToken, activeRouteCompositeModel)
+ .then((entry) => {
+ if (!entry || !isCurrentModelLoad(loadToken) || activeModel !== activeRouteCompositeModel) return
+ attachPoiMarkerGroup(entry)
+ refreshPoiVisibilityByDistance()
+ })
+ .catch((error) => {
+ if (!isStaleModelLoadError(error)) {
+ console.warn('[ThreeMap] 同地面路线标签加载失败:', error)
+ }
+ })
+}
+
const loadOverview = async (options: LoadOverviewOptions = {}) => {
const packageData = renderPackage.value
if (!packageData || !scene) return
- const cameraSnapshot = cloneCameraSnapshot(options.cameraSnapshot || captureCameraSnapshot())
+ const cameraSnapshot = cloneCameraSnapshot(
+ options.cameraSnapshot
+ || (liveGlbTopActive.value ? getReferenceOverviewCameraState() : captureCameraSnapshot())
+ )
const previousView = activeView.value
const previousFloorId = currentFloor.value
const loadToken = startModelLoad()
- activeView.value = 'overview'
- autoSwitchStateMachine.setView('overview')
- syncControlInteractionOptions()
- clearSceneData()
if (cachedOverviewModel) {
const targetScene = scene
if (!targetScene) throw createStaleModelLoadError()
+ clearSceneData()
+ activeView.value = 'overview'
+ autoSwitchStateMachine.setView('overview')
+ syncControlInteractionOptions()
activeModel = cachedOverviewModel
activeModel.name = 'GuideOverviewModel'
activeModel.visible = true
@@ -4004,12 +6527,15 @@ const loadOverview = async (options: LoadOverviewOptions = {}) => {
applyModelVisibilityForView(activeModel, 'overview')
targetScene.add(activeModel)
restoreCameraSnapshot(cameraSnapshot)
+ reapplyLiveGlbTopAfterSceneCommit()
updateReferenceBuildingAnchors(activeModel)
+ rebuildOverviewMapLabels(activeModel)
recordCameraSnapshotRestoration(cameraSnapshot, `${previousView}:${previousFloorId}->overview`)
loadCurrentFloorPoiMarkersInBackground(loadToken)
resetAutoSwitchDistanceTracking()
refreshPoiVisibilityByDistance()
renderRoutePreview()
+ options.onCameraStable?.()
markFirstModelVisible('overview', {
source: 'cached-overview',
modelUrl: cachedSharedModelUrl
@@ -4020,7 +6546,12 @@ const loadOverview = async (options: LoadOverviewOptions = {}) => {
setProgress(18, '正在加载建筑外观模型...')
- const gltf = await loadModelWithFallback(getOverviewModelUrls(packageData), '正在加载建筑外观模型', loadToken)
+ const gltf = await loadModelWithFallback(
+ getOverviewModelUrls(packageData),
+ '正在加载建筑外观模型',
+ loadToken,
+ { modelVersion: packageData.overviewModelVersion }
+ )
assertCurrentModelLoad(loadToken, gltf.scene)
const targetScene = scene
@@ -4029,6 +6560,10 @@ const loadOverview = async (options: LoadOverviewOptions = {}) => {
throw createStaleModelLoadError()
}
+ clearSceneData()
+ activeView.value = 'overview'
+ autoSwitchStateMachine.setView('overview')
+ syncControlInteractionOptions()
activeModel = gltf.scene
activeModel.name = 'GuideOverviewModel'
prepareModel(activeModel)
@@ -4038,12 +6573,15 @@ const loadOverview = async (options: LoadOverviewOptions = {}) => {
cachedSharedModelUrl = packageData.overviewModelUrl
targetScene.add(activeModel)
restoreCameraSnapshot(cameraSnapshot)
+ reapplyLiveGlbTopAfterSceneCommit()
updateReferenceBuildingAnchors(activeModel)
+ rebuildOverviewMapLabels(activeModel)
recordCameraSnapshotRestoration(cameraSnapshot, `${previousView}:${previousFloorId}->overview`)
loadCurrentFloorPoiMarkersInBackground(loadToken)
resetAutoSwitchDistanceTracking()
refreshPoiVisibilityByDistance()
renderRoutePreview()
+ options.onCameraStable?.()
markFirstModelVisible('overview', {
source: 'network',
modelUrl: packageData.overviewModelUrl
@@ -4057,6 +6595,16 @@ const prepareFloorScene = async (
options: LoadFloorOptions = {}
): Promise => {
const requestedFloorId = floor.floorId
+ if (shouldRenderPoiMarkers.value && !poiDataCache.has(requestedFloorId)) {
+ void ensureFloorPoiData(floor).catch((error) => {
+ if (!isStaleModelLoadError(error)) {
+ console.warn('[ThreeMap] 楼层标签预取失败:', {
+ floorId: requestedFloorId,
+ error: error instanceof Error ? error.message : String(error)
+ })
+ }
+ })
+ }
let model: THREE.Object3D | null = null
let ownsModel = true
let cacheAsSharedModel = false
@@ -4097,7 +6645,7 @@ const prepareFloorScene = async (
getFloorModelUrls(floor),
`正在加载 ${formatFloorLabel(requestedFloorId)} 模型`,
loadToken,
- { suppressProgress: options.suppressProgress }
+ { suppressProgress: options.suppressProgress, modelVersion: floor.modelVersion }
)
try {
@@ -4121,9 +6669,12 @@ const prepareFloorScene = async (
throw new Error(`楼层模型加载失败:${requestedFloorId}`)
}
- const poiEntry = shouldRenderPoiMarkers.value
+ let poiEntry = shouldRenderPoiMarkers.value
? poiMarkerGroupCache.get(getPoiMarkerCacheKey(floor.floorId)) || null
: null
+ if (!poiEntry && shouldRenderPoiMarkers.value && poiDataCache.has(floor.floorId)) {
+ poiEntry = await prepareFloorPOIs(floor, loadToken, model) || null
+ }
const prepared: PreparedFloorScene = {
floor,
@@ -4169,9 +6720,13 @@ const commitPreparedFloorScene = (
activeView.value = 'floor'
autoSwitchStateMachine.setView('floor')
autoSwitchStateMachine.clearFloorInitialDistance()
+ // Overview labels belong to the exterior scene only. Clear them at the
+ // floor commit boundary so an async route-scene update cannot leave them
+ // attached while the indoor model is already visible.
+ disposeOverviewMapLabels()
syncControlInteractionOptions()
currentFloor.value = expectedFloorId
- clearSceneData()
+ clearSceneData({ preserveRouteRoaming: options.preserveRouteRoaming })
activeModel = prepared.model
activeModel.userData.floorId = expectedFloorId
@@ -4203,10 +6758,14 @@ const commitPreparedFloorScene = (
updateReferenceBuildingAnchors(activeModel)
}
recordCameraSnapshotRestoration(cameraSnapshot, `floor:${expectedFloorId}`)
- if (controls) {
+ if (floorBaseline) {
+ applyFloorNavigationRange(floorBaseline)
+ } else if (controls) {
ensureFloorAutoExitZoomRange()
- autoSwitchStateMachine.setFloorInitialDistance(controls.getDistance())
+ floorNavigationDistance = controls.getDistance()
+ autoSwitchStateMachine.setFloorInitialDistance(floorNavigationDistance)
}
+ reapplyLiveGlbTopAfterSceneCommit()
options.onCameraStable?.()
refreshPoiVisibilityByDistance()
renderRoutePreview()
@@ -4251,7 +6810,7 @@ const loadFloor = async (floorId: string, options: LoadFloorOptions = {}) => {
: undefined
const loadToken = startFloorContextTransaction(requestedFloorId)
if (options.detachPoiBeforeLoad) {
- detachActivePoiLayer()
+ detachActivePoiLayer({ preserveRouteRoaming: options.preserveRouteRoaming })
}
try {
@@ -4276,15 +6835,19 @@ const loadMultiFloor = async () => {
if (!scene || !floorIndex.value.length) return
const loadToken = startModelLoad()
- activeView.value = 'multi'
- autoSwitchStateMachine.setView('multi')
- syncControlInteractionOptions()
- clearSceneData()
setProgress(18, '正在加载多层展示模型...')
const group = new THREE.Group()
- group.name = 'GuideMultiFloorModel'
- const orderedFloors = [...floorIndex.value].sort(compareFloorsTopToBottom)
+ const routeScene = props.showRoute && props.routePreview
+ ? getNavigationScenePlan(props.routePreview)
+ : null
+ const routeFloorIds = new Set(routeScene?.floorIds || [])
+ const routeFloors = floorIndex.value.filter((floor) => routeFloorIds.has(floor.floorId))
+ const useRouteFloorSet = routeScene?.kind === 'multi-floor' && routeFloors.length > 1
+ group.name = useRouteFloorSet ? 'GuideRouteMultiFloorModel' : 'GuideMultiFloorModel'
+ const orderedFloors = (useRouteFloorSet ? routeFloors : floorIndex.value)
+ .slice()
+ .sort(compareFloorsTopToBottom)
const loadedFloors: MultiFloorModelItem[] = []
const sharedModelUrl = getSharedMultiFloorModelUrl(orderedFloors)
@@ -4292,7 +6855,13 @@ const loadMultiFloor = async () => {
let sourceModel = cachedSharedModelUrl === sharedModelUrl ? cachedOverviewModel : null
if (!sourceModel) {
- const gltf = await loadModelWithFallback([sharedModelUrl], '正在加载多层共享模型', loadToken)
+ const sharedModelVersion = orderedFloors.find((floor) => getFloorModelUrls(floor).includes(sharedModelUrl))?.modelVersion
+ const gltf = await loadModelWithFallback(
+ [sharedModelUrl],
+ '正在加载多层共享模型',
+ loadToken,
+ { modelVersion: sharedModelVersion }
+ )
assertCurrentModelLoad(loadToken, gltf.scene)
const targetScene = scene
@@ -4334,12 +6903,20 @@ const loadMultiFloor = async () => {
throw createStaleModelLoadError()
}
+ clearSceneData()
+ activeView.value = 'multi'
+ autoSwitchStateMachine.setView('multi')
+ syncControlInteractionOptions()
activeModel = group
targetScene.add(activeModel)
- fitCameraToObject(activeModel)
+ fitCameraToObject(activeModel, 'oblique', { immediate: liveGlbTopActive.value })
+ reapplyLiveGlbTopAfterSceneCommit()
resetAutoSwitchDistanceTracking()
refreshPoiVisibilityByDistance()
renderRoutePreview()
+ if (props.showRoute && props.routePreview) {
+ focusRouteStartOnScreen()
+ }
markFirstModelVisible('multi', {
source: 'shared-model',
modelUrl: sharedModelUrl,
@@ -4368,7 +6945,12 @@ const loadMultiFloor = async () => {
const floor = orderedFloors[index]
const label = formatFloorLabel(floor.floorId)
- const gltf = await loadModelWithFallback(getFloorModelUrls(floor), `正在加载 ${label} 多层模型`, loadToken)
+ const gltf = await loadModelWithFallback(
+ getFloorModelUrls(floor),
+ `正在加载 ${label} 多层模型`,
+ loadToken,
+ { modelVersion: floor.modelVersion }
+ )
if (!isCurrentModelLoad(loadToken)) {
disposeObject(gltf.scene)
disposeObject(group)
@@ -4410,12 +6992,20 @@ const loadMultiFloor = async () => {
throw createStaleModelLoadError()
}
+ clearSceneData()
+ activeView.value = 'multi'
+ autoSwitchStateMachine.setView('multi')
+ syncControlInteractionOptions()
activeModel = group
targetScene.add(activeModel)
- fitCameraToObject(activeModel)
+ fitCameraToObject(activeModel, 'oblique', { immediate: liveGlbTopActive.value })
+ reapplyLiveGlbTopAfterSceneCommit()
resetAutoSwitchDistanceTracking()
refreshPoiVisibilityByDistance()
renderRoutePreview()
+ if (props.showRoute && props.routePreview) {
+ focusRouteStartOnScreen()
+ }
markFirstModelVisible('multi', {
source: 'per-floor-models',
floorCount: orderedFloors.length
@@ -4425,47 +7015,60 @@ const loadMultiFloor = async () => {
const createPoiMaterial = (poi: RenderPoi) => {
const canvas = document.createElement('canvas')
canvas.width = 128
- canvas.height = 156
+ canvas.height = 128
const context = canvas.getContext('2d')
if (context) {
const color = getPoiColor(poi.primaryCategory)
- const glyph = getPoiGlyph(poi)
context.clearRect(0, 0, canvas.width, canvas.height)
- context.shadowColor = 'rgba(31, 35, 41, 0.24)'
- context.shadowBlur = 14
- context.shadowOffsetY = 8
+ // POI 图标保持轻量:白色底盘、分类色描边和短锚点,不再使用大面积实心图钉。
+ context.shadowColor = 'rgba(26, 35, 126, 0.16)'
+ context.shadowBlur = 8
+ context.shadowOffsetY = 3
context.beginPath()
- context.arc(64, 56, 31, 0, Math.PI * 2)
- context.fillStyle = color
+ context.arc(64, 51, 26, 0, Math.PI * 2)
+ context.fillStyle = 'rgba(255, 255, 255, 0.96)'
context.fill()
context.shadowColor = 'transparent'
- context.lineWidth = 6
- context.strokeStyle = '#ffffff'
+ context.lineWidth = 4
+ context.strokeStyle = color
context.stroke()
context.beginPath()
- context.moveTo(64, 116)
- context.lineTo(42, 78)
- context.lineTo(86, 78)
- context.closePath()
- context.fillStyle = color
- context.fill()
- context.lineWidth = 6
- context.strokeStyle = '#ffffff'
+ context.moveTo(64, 77)
+ context.lineTo(64, 96)
+ context.lineCap = 'round'
+ context.lineWidth = 7
+ context.strokeStyle = 'rgba(255, 255, 255, 0.96)'
+ context.stroke()
+ context.beginPath()
+ context.moveTo(64, 77)
+ context.lineTo(64, 96)
+ context.lineWidth = 2.5
+ context.strokeStyle = color
context.stroke()
context.beginPath()
- context.arc(64, 56, 21, 0, Math.PI * 2)
- context.fillStyle = 'rgba(255, 255, 255, 0.94)'
+ context.arc(64, 99, 4, 0, Math.PI * 2)
+ context.fillStyle = 'rgba(255, 255, 255, 0.98)'
context.fill()
+ context.lineWidth = 2
+ context.strokeStyle = color
+ context.stroke()
- context.font = `700 28px ${CANVAS_FONT_FAMILY}`
- context.textAlign = 'center'
- context.textBaseline = 'middle'
+ // 所有普通点位使用同一位置符号;类别由文字标签与详情卡表达,避免“购、餐”等字样堆在模型上。
+ context.beginPath()
+ context.arc(64, 51, 10, 0, Math.PI * 2)
+ context.fillStyle = `${color}1f`
+ context.fill()
+ context.lineWidth = 2
+ context.strokeStyle = color
+ context.stroke()
+ context.beginPath()
+ context.arc(64, 51, 4, 0, Math.PI * 2)
context.fillStyle = color
- context.fillText(glyph, 64, 57, 46)
+ context.fill()
}
const texture = new THREE.CanvasTexture(canvas)
@@ -4504,6 +7107,19 @@ const createPoiHitTargetMaterial = () => {
})
}
+const createPoiDomLabelIcon = (iconKey: PoiIconKey) => {
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
+ svg.setAttribute('class', 'three-poi-dom-label__icon')
+ svg.setAttribute('viewBox', '0 0 32 32')
+ svg.setAttribute('aria-hidden', 'true')
+ svg.setAttribute('data-poi-icon', iconKey)
+
+ const use = document.createElementNS('http://www.w3.org/2000/svg', 'use')
+ use.setAttribute('href', getPoiIconHref(iconKey))
+ svg.appendChild(use)
+ return svg
+}
+
const createPoiDomLabel = (
poi: RenderPoi,
anchor: THREE.Object3D,
@@ -4513,32 +7129,41 @@ const createPoiDomLabel = (
if (!layer) return null
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'}`
+ const isLandmark = isIndoorLandmarkPoi(poi)
+ element.className = `three-poi-dom-label three-poi-dom-label--${kind} ${isLandmark ? 'three-poi-dom-label--landmark' : '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')
+ const iconKey = resolvePoiIconKey({
+ primaryCategory: poi.primaryCategory,
+ iconType: poi.iconType,
+ name: poi.name
+ })
+ element.dataset.poiIcon = iconKey
+ element.appendChild(createPoiDomLabelIcon(iconKey))
+
const title = document.createElement('span')
title.className = 'three-poi-dom-label__title'
- const limit = kind === 'focus' ? 14 : (isHallPoi(poi) ? 10 : 7)
+ const isDeviceTerminal = poi.iconType === 'device_terminal'
+ const isVisitorService = isServiceFacilityPoi(poi)
+ const limit = kind === 'focus'
+ ? 14
+ : (isLandmark || isDeviceTerminal ? 24 : isVisitorService ? 12 : 7)
const presentation = presentVisitorPoi({
...poi,
floorLabel: formatFloorLabel(poi.floorId),
primaryCategory: { label: poi.primaryCategoryZh }
})
- title.textContent = presentation.displayName.length > limit
- ? `${presentation.displayName.slice(0, limit)}…`
+ const displayName = kind === 'ambient' && (isAmbientFacilityPoi(poi) || isDeviceTerminal)
+ ? getPoiMapLabelText({ name: poi.name, iconType: poi.iconType })
: presentation.displayName
+ title.textContent = displayName.length > limit
+ ? `${displayName.slice(0, limit)}…`
+ : displayName
element.appendChild(title)
- 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)
- }
-
layer.appendChild(element)
const handle: PoiDomLabelHandle = {
element,
@@ -4548,7 +7173,8 @@ const createPoiDomLabel = (
anchorMode: 'bottom',
floorId: poi.floorId,
size: { width: 1, height: 1 },
- active: kind === 'focus'
+ active: kind === 'focus',
+ layoutOffset: { x: 0, y: 0 }
}
updatePoiDomLabelSize(handle)
poiDomLabelHandlesByElement.set(element, handle)
@@ -4557,78 +7183,287 @@ const createPoiDomLabel = (
return handle
}
-const createPoiPulseSprite = (poi: RenderPoi, markerSize: number) => {
- const canvas = document.createElement('canvas')
- canvas.width = 160
- canvas.height = 160
- const context = canvas.getContext('2d')
-
- if (context) {
- const color = getPoiColor(poi.primaryCategory)
- context.clearRect(0, 0, canvas.width, canvas.height)
- context.beginPath()
- context.arc(80, 80, 56, 0, Math.PI * 2)
- context.fillStyle = `${color}30`
- context.fill()
- context.lineWidth = 6
- context.strokeStyle = `${color}66`
- context.stroke()
+const showSameGroundRouteOverview = async (asset: GuideModelRouteAsset) => {
+ if (!scene || !props.routePreview || !props.showRoute) return
+ const signature = `${props.routePreview.id}:${asset.modelUrl}`
+ if (activeRouteCompositeModel && activeRouteCompositeUrl === asset.modelUrl && activeModel === activeRouteCompositeModel) {
+ activeView.value = 'overview'
+ renderRoutePreview()
+ return
}
+ if (routeCompositeLoadSignature === signature) return
- 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,
- opacity: 0.8
- }))
- sprite.userData.isPoiPulse = true
- sprite.userData.baseScale = markerSize * 1.9
- sprite.renderOrder = 9
- sprite.frustumCulled = false
- sprite.scale.set(markerSize * 1.9, markerSize * 1.9, markerSize * 1.9)
+ routeCompositeLoadSignature = signature
+ const loadToken = startModelLoad()
+ try {
+ activeView.value = 'overview'
+ autoSwitchStateMachine.setView('overview')
+ syncControlInteractionOptions()
+ clearSceneData()
+ setProgress(18, '正在加载路线场景...')
- return sprite
+ const gltf = await loadModelWithFallback(
+ [asset.modelUrl],
+ '正在加载室外连廊路线场景',
+ loadToken,
+ { modelVersion: asset.modelVersion }
+ )
+ assertCurrentModelLoad(loadToken, gltf.scene)
+ if (!scene) {
+ disposeObject(gltf.scene)
+ throw createStaleModelLoadError()
+ }
+
+ const composite = gltf.scene
+ composite.name = 'GuideSameGroundRouteComposite'
+ composite.userData.floorId = 'EXTERIOR_L1'
+ prepareModel(composite)
+ applyIndoorInitialModelTransform(composite)
+ applyModelVisibilityForView(composite, 'overview')
+ activeModel = composite
+ activeRouteCompositeModel = composite
+ activeRouteCompositeUrl = asset.modelUrl
+ scene.add(composite)
+ applyReferenceOverviewCameraState()
+ if (controls) {
+ const overviewDistance = controls.getDistance()
+ controls.minDistance = Math.max(
+ SGS_VISUAL_RENDER_CONFIG.controls.minDistance,
+ overviewDistance * 0.45
+ )
+ controls.maxDistance = Math.max(
+ overviewDistance * SGS_VISUAL_RENDER_CONFIG.framing.overviewMaxDistanceFactor,
+ overviewDistance
+ )
+ }
+ reapplyLiveGlbTopAfterSceneCommit()
+ loadSameGroundRoutePoiMarkers(loadToken)
+ resetAutoSwitchDistanceTracking()
+ renderRoutePreview()
+ focusRouteStartOnScreen()
+ markFirstModelVisible('overview', {
+ source: 'same-ground-route-composite',
+ modelUrl: asset.modelUrl
+ })
+ } finally {
+ if (routeCompositeLoadSignature === signature) routeCompositeLoadSignature = ''
+ }
}
-const createPoiBaseSprite = (poi: RenderPoi, markerSize: number) => {
- const canvas = document.createElement('canvas')
- canvas.width = 180
- canvas.height = 180
- const context = canvas.getContext('2d')
-
- if (context) {
- const color = getPoiColor(poi.primaryCategory)
- const gradient = context.createRadialGradient(90, 90, 8, 90, 90, 76)
- gradient.addColorStop(0, `${color}50`)
- gradient.addColorStop(0.45, `${color}28`)
- gradient.addColorStop(1, `${color}00`)
-
- context.clearRect(0, 0, canvas.width, canvas.height)
- context.beginPath()
- context.arc(90, 90, 76, 0, Math.PI * 2)
- context.fillStyle = gradient
- context.fill()
+const syncRouteCompositeView = () => {
+ const routeScene = props.showRoute ? getNavigationScenePlan() : null
+ const asset = routeScene?.compositeAsset || null
+ if (asset) {
+ void showSameGroundRouteOverview(asset).catch((error) => {
+ if (!isStaleModelLoadError(error)) {
+ console.warn('[ThreeMap] 同地面路线场景加载失败,回退为普通路线展示:', error)
+ renderRoutePreview()
+ }
+ })
+ return
}
- 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,
- opacity: 0.7
- }))
- sprite.userData.isPoiBase = true
- sprite.userData.baseScale = markerSize * 2.15
- sprite.renderOrder = 8
- sprite.frustumCulled = false
- sprite.scale.set(markerSize * 2.15, markerSize * 2.15, markerSize * 2.15)
+ if (routeScene?.kind === 'multi-floor') {
+ const requestedRouteFloorId = props.routeNavigationActive
+ ? resolveFloorIdFromRequest(props.initialFloorId) || currentFloor.value
+ : routeScene.floorIds[0]
+ const routeFloorId = resolveFloorIdFromRequest(requestedRouteFloorId) || requestedRouteFloorId
+ const isRouteFloorReady = Boolean(
+ routeFloorId
+ && activeView.value === 'floor'
+ && currentFloor.value === routeFloorId
+ && activeModel?.userData.floorId === routeFloorId
+ )
+ if (!isRouteFloorReady && routeFloorId) {
+ void handleFloorChange(routeFloorId).catch((error) => {
+ if (!isStaleModelLoadError(error)) {
+ console.warn('[ThreeMap] 跨楼层路线起始楼层加载失败:', error)
+ }
+ })
+ return
+ }
+ renderRoutePreview()
+ return
+ }
- return sprite
+ if (activeRouteCompositeModel && activeModel === activeRouteCompositeModel) {
+ // Route props and the page's indoorView are committed in separate Vue
+ // updates when roaming finishes. Defer one microtask and honor the new
+ // floor view if it has arrived; otherwise a stale composite watcher can
+ // restore the exterior model over the requested indoor floor.
+ const syncSeq = ++routeCompositeExitSyncSeq
+ void Promise.resolve().then(() => {
+ if (
+ syncSeq !== routeCompositeExitSyncSeq
+ || !activeRouteCompositeModel
+ || activeModel !== activeRouteCompositeModel
+ ) return
+
+ // The page intentionally keeps initialView='overview' for the home
+ // screen. activeFloor is the authoritative post-route target and the
+ // page's baseline reset will perform the actual floor commit.
+ if (resolveFloorIdFromRequest(props.activeFloor)) {
+ return
+ }
+
+ void loadOverview().catch((error) => {
+ if (!isStaleModelLoadError(error)) {
+ console.warn('[ThreeMap] 路线场景恢复外观模型失败:', error)
+ }
+ })
+ })
+ return
+ }
+
+ renderRoutePreview()
+ if (!props.routeNavigationActive && props.showRoute && props.routePreview) {
+ focusRouteStartOnScreen()
+ }
+}
+
+const findOverviewLabelPoi = (
+ pois: RenderPoi[] | undefined,
+ definition: OverviewMapLabelDefinition
+) => {
+ if (!pois?.length) return null
+
+ return pois.find((poi) => (
+ poi.positionGltf && getOverviewMapLabelDefinition(poi)?.id === definition.id
+ )) || null
+}
+
+const findOverviewLabelAnchor = (model: THREE.Object3D, definition: OverviewMapLabelDefinition) => {
+ let matched: THREE.Object3D | null = null
+
+ model.traverse((child) => {
+ if (matched || !child.visible) return
+
+ const names = getModelNodeNames(child)
+ if (names.some((name) => isOverviewMapLabelMatch(name, definition))) {
+ matched = child
+ }
+ })
+
+ if (!matched) return null
+
+ const box = new THREE.Box3().setFromObject(matched)
+ if (box.isEmpty()) return null
+
+ const center = box.getCenter(new THREE.Vector3())
+ center.y = box.max.y + Math.max(box.getSize(new THREE.Vector3()).y * 0.08, 0.8)
+ return center
+}
+
+const createOverviewLabelPosition = (
+ model: THREE.Object3D,
+ definition: OverviewMapLabelDefinition,
+ pois?: RenderPoi[]
+) => {
+ const matchedPoi = findOverviewLabelPoi(pois, definition)
+ if (matchedPoi?.positionGltf) {
+ const position = new THREE.Vector3(...matchedPoi.positionGltf)
+ model.localToWorld(position)
+ return {
+ position,
+ usesPrecomputedPosition: true,
+ matchedPoi
+ }
+ }
+
+ const position = findOverviewLabelAnchor(model, definition)
+ return position
+ ? {
+ position,
+ usesPrecomputedPosition: false,
+ matchedPoi: null
+ }
+ : null
+}
+
+const disposeOverviewMapLabels = () => {
+ while (overviewMapLabelHandles.length) {
+ disposePoiDomLabel(overviewMapLabelHandles.pop() || null)
+ }
+ overviewMapLabelOwner = null
+}
+
+const updateOverviewMapLabels = () => {
+ if (!overviewMapLabelHandles.length) return
+
+ if (
+ activeView.value !== 'overview'
+ || !activeModel
+ || activeModel !== overviewMapLabelOwner
+ || activeRouteCompositeModel === activeModel
+ ) {
+ disposeOverviewMapLabels()
+ return
+ }
+
+ const acceptedBounds: ReturnType[] = []
+ overviewMapLabelHandles.forEach((handle) => {
+ handle.active = true
+ const bounds = updatePoiDomLabelPosition(handle)
+ if (!bounds) return
+
+ const overlaps = acceptedBounds.some((acceptedBound) => (
+ domLabelBoundsOverlap(bounds, acceptedBound, 8)
+ ))
+ setPoiDomLabelVisible(handle, !overlaps)
+ if (!overlaps) acceptedBounds.push(bounds)
+ })
+}
+
+const rebuildOverviewMapLabels = (model: THREE.Object3D, pois?: RenderPoi[]) => {
+ disposeOverviewMapLabels()
+ if (
+ !scene
+ || activeView.value !== 'overview'
+ || activeModel !== model
+ || activeRouteCompositeModel === model
+ ) return
+
+ overviewMapLabelOwner = model
+
+ OVERVIEW_MAP_LABEL_DEFINITIONS.forEach((definition) => {
+ const anchorInfo = createOverviewLabelPosition(model, definition, pois)
+ if (!anchorInfo) return
+
+ const { position, usesPrecomputedPosition } = anchorInfo
+
+ const anchor = new THREE.Object3D()
+ anchor.position.copy(position)
+ scene?.add(anchor)
+
+ const labelPoi: RenderPoi = {
+ id: definition.id,
+ name: definition.label,
+ floorId: 'EXTERIOR',
+ primaryCategory: 'overview_label',
+ primaryCategoryZh: '外观标签',
+ iconType: definition.category,
+ kind: 'space',
+ positionGltf: [position.x, position.y, position.z]
+ }
+
+ const handle = createPoiDomLabel(labelPoi, anchor, 'ambient')
+ if (!handle) return
+
+ handle.ownsAnchor = true
+ handle.element.classList.add(
+ 'three-poi-dom-label--overview',
+ `three-poi-dom-label--overview-${definition.category}`
+ )
+ handle.active = true
+ overviewMapLabelHandles.push(handle)
+
+ logThreeMapDiagnostic('overview-label-anchor', {
+ id: definition.id,
+ label: definition.label,
+ source: usesPrecomputedPosition ? 'space-label-position' : 'model-node-fallback',
+ position: [position.x, position.y, position.z]
+ })
+ })
}
const disposeFocusLabel = () => {
@@ -4637,72 +7472,37 @@ const disposeFocusLabel = () => {
}
const disposeFocusPulse = () => {
- if (!activeFocusPulseSprite) return
-
- activeFocusPulseSprite.parent?.remove(activeFocusPulseSprite)
- disposeObject(activeFocusPulseSprite)
- activeFocusPulseSprite = null
+ activeFocusPulseSprites.forEach((sprite) => {
+ sprite.parent?.remove(sprite)
+ disposeObject(sprite)
+ })
+ activeFocusPulseSprites = []
}
const disposeFocusBase = () => {
- if (!activeFocusBaseSprite) return
-
- activeFocusBaseSprite.parent?.remove(activeFocusBaseSprite)
- disposeObject(activeFocusBaseSprite)
- activeFocusBaseSprite = null
+ activeFocusBaseSprites.forEach((sprite) => {
+ sprite.parent?.remove(sprite)
+ disposeObject(sprite)
+ })
+ activeFocusBaseSprites = []
}
const showFocusPoiLabel = (poi: RenderPoi) => {
- const displayPosition = getPoiDisplayPosition(poi)
- if (!poiGroup || !displayPosition) return
-
+ // 选中态沿用环境标签,名称、楼层和操作只在底部详情卡展示。
+ void poi
disposeFocusLabel()
- 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) => {
- const displayPosition = getPoiDisplayPosition(poi)
- if (!poiGroup || !displayPosition) return
-
- disposeFocusBase()
-
- const markerSize = getPoiMarkerSize()
- activeFocusBaseSprite = createPoiBaseSprite(poi, markerSize)
- activeFocusBaseSprite.frustumCulled = false
- activeFocusBaseSprite.position.copy(displayPosition)
- poiGroup.add(activeFocusBaseSprite)
-}
-
-const showFocusPoiPulse = (poi: RenderPoi) => {
- const displayPosition = getPoiDisplayPosition(poi)
- if (!poiGroup || !displayPosition) return
-
- disposeFocusPulse()
-
- const markerSize = getPoiMarkerSize()
- activeFocusPulseSprite = createPoiPulseSprite(poi, markerSize)
- activeFocusPulseSprite.frustumCulled = false
- activeFocusPulseSprite.position.copy(displayPosition)
- poiGroup.add(activeFocusPulseSprite)
}
const showFocusPoiAffordances = (poi: RenderPoi) => {
+ disposeFocusBase()
+ disposeFocusPulse()
showFocusHallHighlight(poi)
- showFocusPoiBase(poi)
- showFocusPoiPulse(poi)
showFocusPoiLabel(poi)
}
const getPoiColor = (category: string) => {
const colorMap: Record = {
- poi: '#56606b',
+ poi: '#1565c0',
touring_poi: '#2f6fed',
exhibition_hall: '#2f6fed',
exhibition_hall_entrance: '#4659d8',
@@ -4713,7 +7513,7 @@ const getPoiColor = (category: string) => {
operation_experience: '#cf3f59'
}
- return colorMap[category] || '#1f2329'
+ return colorMap[category] || '#1565c0'
}
const getPoiMarkerSize = () => (
@@ -4738,7 +7538,9 @@ const setPoiSpriteFocusStyle = (sprite: THREE.Sprite, focused: boolean) => {
sprite.scale.set(scale, scale, scale)
sprite.renderOrder = focused ? 22 : (userData.isCorePoi ? 12 : 10)
- sprite.material.opacity = focused ? 1 : 0.86
+ sprite.material.opacity = userData.usesDomLabelIcon
+ ? 0
+ : focused ? 1 : 0.86
if (userData.hitTarget) {
const hitBaseScale = userData.hitTargetBaseScale || baseScale * poiHitTargetScaleMultiplier
@@ -4838,41 +7640,75 @@ const createPoiMarkerGroup = (
group.userData.displayMode = displayMode
pois.forEach((poi) => {
- const displayPosition = getPoiDisplayPosition(poi)
- if (!displayPosition) return
-
- const { sprite, hitTarget } = createPoiMarkerSprites(poi, markerSize)
- sprite.position.copy(displayPosition)
- hitTarget.position.copy(displayPosition)
- const labelHandle = shouldCreateAmbientPoiLabel(poi)
- ? createPoiDomLabel(poi, sprite, 'ambient')
- : null
- if (labelHandle) {
- sprite.userData.labelHandle = labelHandle
- domLabelHandles.push(labelHandle)
- }
- sprite.visible = isPoiIncludedByVisibleFilter(poi)
- && (
- visiblePoiIdFilter.value !== null
- || shouldShowPoiAtDistance(poi, getPoiVisibilityTier())
- )
- hitTarget.visible = sprite.visible
- group.add(sprite)
- group.add(hitTarget)
+ const usesDomLabelIcon = shouldCreateAmbientPoiLabel(poi)
+ getPoiMarkerPositions(poi).forEach((displayPosition, index) => {
+ const { sprite, hitTarget } = createPoiMarkerSprites(poi, markerSize)
+ sprite.position.copy(displayPosition)
+ hitTarget.position.copy(displayPosition)
+ const labelHandle = index === 0 && usesDomLabelIcon
+ ? createPoiDomLabel(poi, sprite, 'ambient')
+ : null
+ sprite.userData.usesDomLabelIcon = usesDomLabelIcon
+ setPoiSpriteFocusStyle(sprite, poi.id === activeFocusPoiId.value)
+ if (labelHandle) {
+ sprite.userData.labelHandle = labelHandle
+ domLabelHandles.push(labelHandle)
+ }
+ sprite.visible = isPoiIncludedByVisibleFilter(poi)
+ && (
+ visiblePoiIdFilter.value !== null
+ || shouldShowPoiAtDistance(poi, getPoiVisibilityTier())
+ )
+ hitTarget.visible = sprite.visible
+ group.add(sprite)
+ group.add(hitTarget)
+ })
})
return { group, domLabelHandles }
}
+const canAttachPoiMarkerGroup = (entry: PoiMarkerCacheEntry) => {
+ if (!activeModel) return false
+
+ if (activeView.value === 'floor') {
+ return (
+ entry.floorId === currentFloor.value
+ && activeModel.userData.floorId === currentFloor.value
+ )
+ }
+
+ if (activeView.value === 'overview') {
+ if (activeRouteCompositeModel === activeModel) {
+ return entry.floorId === currentFloor.value
+ }
+ return entry.floorId === renderPackage.value?.overviewFloorId
+ }
+
+ return false
+}
+
const attachPoiMarkerGroup = (entry: PoiMarkerCacheEntry) => {
- if (!poiGroup) return
+ if (!poiGroup || !canAttachPoiMarkerGroup(entry)) {
+ logThreeMapDiagnostic('poi-marker-attach-skipped', {
+ entryFloorId: entry.floorId,
+ currentFloor: currentFloor.value,
+ activeView: activeView.value,
+ activeModelFloorId: activeModel?.userData.floorId || '',
+ isRouteComposite: activeRouteCompositeModel === activeModel
+ })
+ return false
+ }
detachPoiMarkerGroups()
if (entry.group.parent !== poiGroup) {
poiGroup.add(entry.group)
}
entry.domLabelHandles.forEach((handle) => {
- handle.active = true
+ handle.active = activeView.value === 'floor'
+ && entry.floorId === currentFloor.value
+ && activeModel?.userData.floorId === currentFloor.value
+ if (!handle.active) setPoiDomLabelVisible(handle, false)
})
poiGroup.userData.floorId = entry.floorId
poiGroup.userData.currentFloor = entry.floorId
@@ -4894,6 +7730,7 @@ const attachPoiMarkerGroup = (entry: PoiMarkerCacheEntry) => {
markerSize: roundDiagnosticNumber(entry.markerSize),
coordinateSpace: 'GLB_METER'
})
+ return true
}
const restoreCommittedFloorPoiLayer = () => {
@@ -4924,25 +7761,13 @@ const getPoiMarkerSizeForModel = (model: THREE.Object3D | null) => (
: getPoiMarkerSize()
)
-const prepareFloorPOIs = async (
+const createFloorPoiEntry = (
floor: FloorIndexItem,
- loadToken?: number,
- markerModel: THREE.Object3D | null = activeModel
+ floorPois: RenderPoi[],
+ markerModel: THREE.Object3D | null,
+ dataTier: 'fast' | 'full'
) => {
- if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) return
-
const displayMode = getPoiDisplayMode()
- const markerCacheKey = getPoiMarkerCacheKey(floor.floorId, displayMode)
- const cachedEntry = poiMarkerGroupCache.get(markerCacheKey)
- if (cachedEntry) {
- return cachedEntry
- }
-
- const cachedPois = poiDataCache.get(floor.floorId)
- const floorPois = cachedPois || await props.modelSource.loadFloorPois(floor.floorId)
- if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) return
-
- poiDataCache.set(floor.floorId, floorPois)
const validPois = floorPois.filter((poi) => shouldShowPoiInCurrentMode(poi))
const filteredPois = floorPois.filter((poi) => !validPois.some((validPoi) => validPoi.id === poi.id))
logThreeMapDiagnostic('poi-filter-diagnostics', {
@@ -4964,10 +7789,12 @@ const prepareFloorPOIs = async (
}
const markerSize = getPoiMarkerSizeForModel(markerModel)
const markerGroup = createPoiMarkerGroup(floor.floorId, displayMode, validPois, markerSize)
- const entry: PoiMarkerCacheEntry = {
+ return {
floorId: floor.floorId,
displayMode,
+ dataTier,
pois: validPois,
+ rawPois: floorPois,
group: markerGroup.group,
domLabelHandles: markerGroup.domLabelHandles,
markerSize,
@@ -4975,14 +7802,156 @@ const prepareFloorPOIs = async (
rawPositionedPoiCount: floorPois.filter((poi) => Boolean(poi.positionGltf)).length,
filteredCategoryCounts: countPoisByCategory(filteredPois)
}
+}
+
+const disposePoiMarkerEntry = (entry: PoiMarkerCacheEntry) => {
+ entry.group.parent?.remove(entry.group)
+ entry.domLabelHandles.forEach((handle) => disposePoiDomLabel(handle))
+ disposeObject(entry.group)
+}
+
+const runFloorPoiEnrichment = (
+ floor: FloorIndexItem
+) => {
+ if (poiEnrichmentInFlight.has(floor.floorId)) return
+
+ const enrichment = props.modelSource.loadFloorPois(floor.floorId)
+ .then((fullPois) => {
+ if (isDisposed) return
+
+ poiDataCache.set(floor.floorId, fullPois)
+ poiDataTierCache.set(floor.floorId, 'full')
+
+ const overviewFloorId = renderPackage.value?.overviewFloorId
+ const isPlainOverviewScene = (
+ activeView.value === 'overview'
+ && activeModel
+ && activeRouteCompositeModel !== activeModel
+ && overviewFloorId === floor.floorId
+ )
+ const isCurrentFloorScene = (
+ activeView.value === 'floor'
+ && currentFloor.value === floor.floorId
+ && activeModel?.userData.floorId === floor.floorId
+ )
+ if (!isPlainOverviewScene && !isCurrentFloorScene) return
+
+ const markerCacheKey = getPoiMarkerCacheKey(floor.floorId)
+ const previousEntry = poiMarkerGroupCache.get(markerCacheKey)
+ const fullEntry = createFloorPoiEntry(floor, fullPois, activeModel, 'full')
+ poiMarkerGroupCache.set(markerCacheKey, fullEntry)
+ if (previousEntry && previousEntry !== fullEntry) {
+ disposePoiMarkerEntry(previousEntry)
+ }
+
+ if (isPlainOverviewScene && activeModel) {
+ rebuildOverviewMapLabels(activeModel, fullPois)
+ }
+ if (attachPoiMarkerGroup(fullEntry)) {
+ const enrichedFocusPoi = activeFocusPoiId.value
+ ? fullEntry.pois.find((poi) => poi.id === activeFocusPoiId.value)
+ : undefined
+ if (enrichedFocusPoi) {
+ selectedPOI.value = enrichedFocusPoi
+ showFocusPoiAffordances(enrichedFocusPoi)
+ }
+ refreshPoiVisibilityByDistance()
+ }
+ })
+ .catch((error) => {
+ if (!isStaleModelLoadError(error)) {
+ console.warn('[ThreeMap] 楼层标签补充数据加载失败:', {
+ floorId: floor.floorId,
+ error: error instanceof Error ? error.message : String(error)
+ })
+ }
+ })
+ .finally(() => {
+ poiEnrichmentInFlight.delete(floor.floorId)
+ })
+
+ poiEnrichmentInFlight.set(floor.floorId, enrichment)
+}
+
+const enrichFloorPoiMarkersInBackground = (floor: FloorIndexItem) => {
+ if (poiEnrichmentInFlight.has(floor.floorId)) return
+
+ poiEnrichmentScheduler.schedule(() => {
+ if (isDisposed) return
+ runFloorPoiEnrichment(floor)
+ }, { delayMs: 1200 })
+}
+
+const ensureFloorPoiData = async (floor: FloorIndexItem) => {
+ const cachedPois = poiDataCache.get(floor.floorId)
+ const cachedDataTier = poiDataTierCache.get(floor.floorId)
+ if (cachedPois && cachedDataTier) {
+ return { floorPois: cachedPois, dataTier: cachedDataTier }
+ }
+
+ const pending = poiDataLoadInFlight.get(floor.floorId)
+ if (pending) return pending
+
+ const request = loadFloorPoisForInitialRender(props.modelSource, floor.floorId)
+ .then((loadResult) => {
+ if (loadResult.fastLoadError) {
+ console.warn('[ThreeMap] 楼层标签快速数据加载失败,已回退完整数据:', {
+ floorId: floor.floorId,
+ error: loadResult.fastLoadError instanceof Error
+ ? loadResult.fastLoadError.message
+ : String(loadResult.fastLoadError)
+ })
+ }
+ poiDataCache.set(floor.floorId, loadResult.floorPois)
+ poiDataTierCache.set(floor.floorId, loadResult.dataTier)
+ return {
+ floorPois: loadResult.floorPois,
+ dataTier: loadResult.dataTier
+ }
+ })
+ .finally(() => {
+ poiDataLoadInFlight.delete(floor.floorId)
+ })
+
+ poiDataLoadInFlight.set(floor.floorId, request)
+ return request
+}
+
+const prepareFloorPOIs = async (
+ floor: FloorIndexItem,
+ loadToken?: number,
+ markerModel: THREE.Object3D | null = activeModel
+) => {
+ if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) return
+
+ const displayMode = getPoiDisplayMode()
+ const markerCacheKey = getPoiMarkerCacheKey(floor.floorId, displayMode)
+ const cachedEntry = poiMarkerGroupCache.get(markerCacheKey)
+ const cachedPois = poiDataCache.get(floor.floorId)
+ const cachedDataTier = poiDataTierCache.get(floor.floorId)
+ if (cachedEntry && cachedPois && cachedEntry.dataTier === cachedDataTier) return cachedEntry
+
+ const loadedData = cachedPois && cachedDataTier
+ ? { floorPois: cachedPois, dataTier: cachedDataTier }
+ : await ensureFloorPoiData(floor)
+ const { floorPois, dataTier } = loadedData
+ if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) return
+
+ const entry = createFloorPoiEntry(floor, floorPois, markerModel, dataTier)
if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) {
- markerGroup.domLabelHandles.forEach((handle) => disposePoiDomLabel(handle))
- disposeObject(markerGroup.group)
+ entry.domLabelHandles.forEach((handle) => disposePoiDomLabel(handle))
+ disposeObject(entry.group)
return
}
poiMarkerGroupCache.set(markerCacheKey, entry)
+ if (cachedEntry && cachedEntry !== entry) {
+ disposePoiMarkerEntry(cachedEntry)
+ }
+ if (entry.dataTier === 'fast') {
+ enrichFloorPoiMarkersInBackground(floor)
+ }
return entry
}
@@ -5031,20 +8000,66 @@ const findFloorObject = (object: THREE.Object3D) => {
return null
}
-const getPoiMarkerFromHit = (object: THREE.Object3D) => {
- if (object instanceof THREE.Sprite && isPoiHitTargetSprite(object)) {
- return getPoiSpriteUserData(object).visibleMarker || null
- }
+const triggerPoiTapFeedback = (sprite: THREE.Sprite) => {
+ getPoiSpriteUserData(sprite).feedbackUntil = performance.now() + poiTapFeedbackDurationMs
+}
- if (object instanceof THREE.Sprite && isPoiMarkerSprite(object)) {
- return object
+// 空白区域只做近距离容错吸附;业务对象优先命中自身接入点,缺少显式
+// 接入点时保留对象坐标,由路线服务完成正式路网吸附。
+const routeStartSnapMaxDistanceMeters = 8
+
+const getRouteSourcePoiIds = (poi: RenderPoi) => [
+ poi.id,
+ poi.spaceId,
+ poi.sourceSpaceId,
+ poi.sourcePlaceId
+].filter((value): value is string => Boolean(value))
+
+const findPoiForModelHit = (object: THREE.Object3D) => {
+ const seen = new Set()
+ return getPoiSprites()
+ .map((sprite) => sprite.userData.poi as RenderPoi | undefined)
+ .filter((poi): poi is RenderPoi => {
+ if (!poi || seen.has(poi.id)) return false
+ seen.add(poi.id)
+ return true
+ })
+ .find((poi) => isPoiModelNodeMatch(object, poi)) || null
+}
+
+const findPoiForModelHits = (hits: THREE.Intersection[]) => {
+ for (const hit of hits) {
+ const poi = findPoiForModelHit(hit.object)
+ if (poi) return { hit, poi }
}
return null
}
-const triggerPoiTapFeedback = (sprite: THREE.Sprite) => {
- getPoiSpriteUserData(sprite).feedbackUntil = performance.now() + poiTapFeedbackDurationMs
+const selectRouteStartCandidateNear = (
+ floorId: string,
+ position: [number, number, number],
+ sourceName?: string,
+ sourcePoiIds: Array = [],
+ requireObjectMatch = false
+) => {
+ const resolved = resolveRouteStartCandidate({
+ floorId,
+ position,
+ sourceName,
+ sourcePoiIds,
+ points: props.routeSelectablePoints,
+ maxDistanceMeters: routeStartSnapMaxDistanceMeters,
+ requireObjectMatch
+ })
+
+ if (!resolved) {
+ emit('routeStartCandidateRejected')
+ return false
+ }
+
+ emit('routeStartCandidate', toRouteStartCandidatePayload(resolved, sourceName))
+ return true
}
const handleSceneTap = async (event: PointerEvent) => {
@@ -5059,16 +8074,20 @@ const handleSceneTap = async (event: PointerEvent) => {
raycaster.setFromCamera(pointer, camera)
if ((activeView.value === 'floor' || activeView.value === 'overview') && poiGroup) {
- const hitTargets = getPoiHitTargets()
const poiSprites = getPoiSprites()
- const poiHits = raycaster.intersectObjects([...hitTargets, ...poiSprites], false)
- const hit = poiHits.find((item) => (
- item.object.visible
- && item.object instanceof THREE.Sprite
- && getPoiMarkerFromHit(item.object)
- ))
- const fallbackMarker = hit ? null : findNearestPoiMarkerByScreenPoint(event, rect)
- const hitMarker = hit ? getPoiMarkerFromHit(hit.object) : fallbackMarker
+ const labelMarker = findPoiMarkerByDomLabel(event)
+ // The old transparent hit-target sprites are deliberately excluded from
+ // authoritative selection. Their 3D depth is unrelated to the label the
+ // user touched and can select a POI behind an adjacent label. Use the
+ // projected screen position instead, which is deterministic for touch and
+ // mouse input alike.
+ const fallbackMarker = labelMarker ? null : findNearestPoiMarkerByScreenPoint(
+ event,
+ rect,
+ props.routeStartSelectionActive ? 42 : 0
+ )
+ const hitMarker = labelMarker
+ || fallbackMarker
logThreeMapDiagnostic('poi-hit-diagnostics', {
view: activeView.value,
@@ -5079,15 +8098,16 @@ const handleSceneTap = async (event: PointerEvent) => {
y: Math.round(event.clientY - rect.top)
},
visibleMarkerCount: poiSprites.filter((sprite) => sprite.visible).length,
- hitTargetCount: hitTargets.length,
- rayHitCount: poiHits.length,
- raySelectedPoiId: hit ? (getPoiMarkerFromHit(hit.object)?.userData.poi as RenderPoi | undefined)?.id : null,
+ hitTargetCount: getPoiHitTargets().length,
+ rayHitCount: 0,
+ labelSelectedPoiId: (labelMarker?.userData.poi as RenderPoi | undefined)?.id || null,
+ raySelectedPoiId: null,
fallbackSelectedPoiId: (fallbackMarker?.userData.poi as RenderPoi | undefined)?.id || null
})
if (hitMarker?.userData.poi) {
const selectedPoi = hitMarker.userData.poi as RenderPoi
- if (activeView.value === 'overview') {
+ if (activeView.value === 'overview' && activeRouteCompositeModel !== activeModel) {
disableAutoSwitchTemporarily(manualAutoSwitchPauseMs)
try {
await loadFloor(selectedPoi.floorId, {
@@ -5095,7 +8115,7 @@ const handleSceneTap = async (event: PointerEvent) => {
suppressProgress: true,
detachPoiBeforeLoad: true
})
- emit('floorChange', selectedPoi.floorId)
+ emitFloorChange(selectedPoi.floorId)
} catch (error) {
if (isStaleModelLoadError(error)) return
console.error('展厅点位楼层切换失败:', error)
@@ -5103,9 +8123,24 @@ const handleSceneTap = async (event: PointerEvent) => {
}
const poi = findLoadedPoi(selectedPoi.id) || selectedPoi
+ if (props.routeStartSelectionActive) {
+ if (poi.positionGltf) {
+ selectRouteStartCandidateNear(
+ poi.floorId,
+ poi.positionGltf,
+ poi.name,
+ getRouteSourcePoiIds(poi),
+ true
+ )
+ } else {
+ emit('routeStartCandidateRejected')
+ }
+ return
+ }
const focusMarker = findPoiSprite(poi.id) || hitMarker
selectedPOI.value = poi
activeFocusPoiId.value = poi.id
+ activeFocusStartedDataTier = poiDataTierCache.get(poi.floorId) || null
triggerPoiTapFeedback(focusMarker)
updatePoiMarkerFocus()
showFocusPoiAffordances(poi)
@@ -5116,6 +8151,50 @@ const handleSceneTap = async (event: PointerEvent) => {
}
}
+ const modelHits = activeModel
+ ? raycaster.intersectObject(activeModel, true)
+ : []
+ const matchedModelPoi = findPoiForModelHits(modelHits)
+
+ if (props.routeStartSelectionActive) {
+ if (matchedModelPoi?.poi.positionGltf) {
+ selectRouteStartCandidateNear(
+ matchedModelPoi.poi.floorId,
+ matchedModelPoi.poi.positionGltf,
+ matchedModelPoi.poi.name,
+ getRouteSourcePoiIds(matchedModelPoi.poi),
+ true
+ )
+ } else if (modelHits[0]) {
+ selectRouteStartCandidateNear(currentFloor.value, [
+ modelHits[0].point.x,
+ modelHits[0].point.y,
+ modelHits[0].point.z
+ ])
+ } else {
+ emit('routeStartCandidateRejected')
+ }
+ return
+ }
+
+ // A direct tap on a POI mesh is a second, exact selection path. It is
+ // intentionally evaluated only after marker/label hit testing so a model
+ // surface cannot override an explicitly touched label.
+ if (matchedModelPoi?.poi) {
+ const poi = matchedModelPoi.poi
+ const focusMarker = findPoiSprite(poi.id)
+ selectedPOI.value = poi
+ activeFocusPoiId.value = poi.id
+ activeFocusStartedDataTier = poiDataTierCache.get(poi.floorId) || null
+ if (focusMarker) triggerPoiTapFeedback(focusMarker)
+ updatePoiMarkerFocus()
+ showFocusPoiAffordances(poi)
+ focusCameraOnPoi(poi)
+ refreshPoiVisibilityByDistance()
+ emit('poiClick', poi)
+ return
+ }
+
if (activeView.value === 'multi' && activeModel) {
const floorHits = raycaster.intersectObjects(activeModel.children, true)
const floorObject = floorHits
@@ -5218,7 +8297,11 @@ const handleWindowBlur = () => {
const handleVisibilityChange = () => {
if (document.visibilityState !== 'visible') {
resetInteractionGateState()
+ stopRenderLoop()
+ return
}
+
+ startRenderLoop()
}
const emitTargetFocus = (
@@ -5242,9 +8325,12 @@ const clearTargetFocus = () => {
}
const isSceneReadyForTargetFocus = () => (
- Boolean(scene && camera && controls && loader && floorIndex.value.length)
- && !isLoading.value
- && !loadError.value
+ weakNetworkFallbackActive.value
+ || (
+ Boolean(scene && camera && controls && loader && floorIndex.value.length)
+ && !isLoading.value
+ && !loadError.value
+ )
)
const focusCameraOnPoi = (poi: RenderPoi) => {
@@ -5269,6 +8355,27 @@ const focusCameraOnPoi = (poi: RenderPoi) => {
moveCameraTo(nextPosition, target, { reason: 'poi-focus' })
}
+const focusRouteStartOnScreen = () => {
+ const start = props.routePreview?.start.position
+ if (!start || !camera || !controls) return
+
+ const routePoints = props.routePreview ? buildRouteRoamingPoints(props.routePreview) : []
+ const routeDirection = getRouteRoamingDirection(routePoints, 0)
+ const referenceDirection = liveGlbTopActive.value
+ ? camera.position.clone().sub(controls.target).normalize()
+ : getCameraCalibrationDirection(
+ GUIDE_CAMERA_YAW_DEGREES,
+ GUIDE_CAMERA_ELEVATION_DEGREES
+ )
+ controls.minDistance = Math.min(controls.minDistance, ROUTE_NAVIGATION_CAMERA_DISTANCE * 0.6)
+ controls.maxDistance = Math.max(controls.maxDistance, ROUTE_PREVIEW_CAMERA_DISTANCE)
+ const target = routePointToVector(start, props.routePreview?.start.floorId)
+ .addScaledVector(routeDirection, ROUTE_PREVIEW_LOOK_AHEAD)
+ const position = target.clone().addScaledVector(referenceDirection, ROUTE_PREVIEW_CAMERA_DISTANCE)
+
+ moveCameraTo(position, target, { durationMs: 460 })
+}
+
const isCurrentTargetFocus = (generation: number) => generation === targetFocusGeneration
const focusTargetPoi = async (request: TargetPoiFocusRequest, generation: number) => {
@@ -5296,7 +8403,7 @@ const focusTargetPoi = async (request: TargetPoiFocusRequest, generation: number
})
if (!isCurrentTargetFocus(generation)) return false
isLoading.value = false
- emit('floorChange', request.floorId)
+ emitFloorChange(request.floorId)
} else if (shouldRenderPoiMarkers.value && !getPoiSprites().length) {
await loadFloorPOIs(floor, modelLoadVersion)
if (!isCurrentTargetFocus(generation)) return false
@@ -5314,6 +8421,7 @@ const focusTargetPoi = async (request: TargetPoiFocusRequest, generation: number
}
selectedPOI.value = poi
+ activeFocusStartedDataTier = poiDataTierCache.get(request.floorId) || null
updatePoiMarkerFocus()
showFocusPoiAffordances(poi)
focusCameraOnPoi(poi)
@@ -5324,6 +8432,15 @@ const focusTargetPoi = async (request: TargetPoiFocusRequest, generation: number
if (isStaleModelLoadError(error)) return false
console.error('目标 POI 聚焦失败:', error)
+ invalidateModelLoads()
+ if (await activateWeakNetworkFallback(
+ request.floorId,
+ 'weak-network',
+ 'floor',
+ isModelLoadTimeoutError(error) ? 'model-load-timeout' : 'model-load-failed'
+ )) {
+ return focusWeakNetworkTarget(request, generation)
+ }
loadError.value = true
isLoading.value = false
setFriendlyModelLoadError()
@@ -5348,7 +8465,11 @@ const queueTargetFocus = (request: TargetPoiFocusRequest | null) => {
pendingTargetFocus = null
targetFocusQueue = targetFocusQueue
- .then(() => focusTargetPoi(nextRequest, requestGeneration))
+ .then(() => (
+ weakNetworkFallbackActive.value
+ ? focusWeakNetworkTarget(nextRequest, requestGeneration)
+ : focusTargetPoi(nextRequest, requestGeneration)
+ ))
.finally(() => {
if (pendingTargetFocus) {
queueTargetFocus(pendingTargetFocus)
@@ -5356,9 +8477,12 @@ const queueTargetFocus = (request: TargetPoiFocusRequest | null) => {
})
}
-const loadModelPackage = async () => {
+const loadModelPackage = async (initializationVersion = sceneInitializationVersion) => {
setProgress(8, '正在读取馆内导览资源...')
const packageData = await props.modelSource.loadPackage()
+ if (!isCurrentSceneInitialization(initializationVersion)) {
+ throw createStaleModelLoadError()
+ }
modelPackageEpoch += 1
clearFloorViewBaselines()
renderPackage.value = packageData
@@ -5366,9 +8490,10 @@ const loadModelPackage = async () => {
setProgress(14, '正在读取楼层索引...')
floorIndex.value = [...packageData.floors].sort(compareFloorsTopToBottom)
logThreeMapDiagnostic('package-ready', {
- initialView: props.initialView,
+ initialView: requestedSceneView.value,
requestedInitialFloorId: props.initialFloorId,
overviewModelUrl: packageData.overviewModelUrl,
+ overviewFloorId: packageData.overviewFloorId,
floorCount: floorIndex.value.length,
floorModels: floorIndex.value.map((floor) => ({
floorId: floor.floorId,
@@ -5384,23 +8509,265 @@ const loadModelPackage = async () => {
currentFloor.value = getDefaultFloorId()
}
- if (props.initialView === 'floor') {
+ if (!isTwoDimensionalMode.value && hasExceededInitialForegroundBudget()) {
+ const didActivate = await activateWeakNetworkFallback(
+ currentFloor.value,
+ 'weak-network',
+ requestedSceneView.value === 'overview' ? 'overview' : 'floor',
+ 'initial-interactive-budget',
+ initializationVersion
+ )
+ if (didActivate) return
+ }
+
+ if (isTwoDimensionalMode.value && !scene) {
+ const didActivate = await activateWeakNetworkFallback(
+ currentFloor.value,
+ 'two-dimensional',
+ requestedSceneView.value === 'overview' ? 'overview' : 'floor',
+ 'model-load-failed',
+ initializationVersion
+ )
+ if (!didActivate) throw new Error('二维底图预览初始化失败')
+ return
+ }
+
+ if (requestedSceneView.value === 'floor') {
const didCommit = await loadFloor(currentFloor.value)
+ if (!isCurrentSceneInitialization(initializationVersion)) {
+ throw createStaleModelLoadError()
+ }
if (didCommit) {
- emit('floorChange', currentFloor.value)
+ emitFloorChange(currentFloor.value)
}
return
}
- if (props.initialView === 'multi') {
+ if (requestedSceneView.value === 'multi') {
await loadMultiFloor()
+ if (!isCurrentSceneInitialization(initializationVersion)) {
+ throw createStaleModelLoadError()
+ }
return
}
await loadOverview()
+ if (!isCurrentSceneInitialization(initializationVersion)) {
+ throw createStaleModelLoadError()
+ }
}
-const init3DScene = async () => {
+const prepareWeakNetworkFallbackFloor = async (
+ requestedFloorId?: string,
+ initializationVersion = sceneInitializationVersion,
+ transitionRevision?: number
+) => {
+ const isRequestCurrent = () => (
+ isCurrentSceneInitialization(initializationVersion)
+ && (
+ transitionRevision === undefined
+ || transitionRevision === renderModeTransitionRevision
+ )
+ )
+ if (!isRequestCurrent()) return false
+ const floorId = resolveFloorIdFromRequest(requestedFloorId)
+ || floorIndex.value.find((item) => item.floorId === requestedFloorId)?.floorId
+ || currentFloor.value
+ || getDefaultFloorId()
+ const floor = floorIndex.value.find((item) => item.floorId === floorId)
+ if (!floor) return false
+
+ const requestRevision = ++weakNetworkFallbackPoiRequestRevision
+ const cacheKey = `floor:${floor.floorId}`
+ currentFloor.value = floor.floorId
+ activeView.value = 'floor'
+ autoSwitchStateMachine.setView('floor')
+ weakNetworkFallbackPois.value = []
+ const cached = weakNetworkFallbackPoiCache.get(cacheKey)
+ if (cached) {
+ weakNetworkFallbackPois.value = cached
+ return true
+ }
+ try {
+ const result = await ensureFloorPoiData(floor)
+ if (
+ requestRevision !== weakNetworkFallbackPoiRequestRevision
+ || !isRequestCurrent()
+ ) return false
+ const floorKeys = new Set(getFloorMatchKeys(floor).map(normalizeModelMatchKey))
+ const floorPois = result.floorPois.filter((poi) => (
+ floorKeys.has(normalizeModelMatchKey(String(poi.floorId || '')))
+ ))
+ weakNetworkFallbackPoiCache.set(cacheKey, floorPois)
+ weakNetworkFallbackPois.value = floorPois
+ } catch (error) {
+ if (
+ requestRevision !== weakNetworkFallbackPoiRequestRevision
+ || !isRequestCurrent()
+ ) return false
+ const floorKeys = new Set(getFloorMatchKeys(floor).map(normalizeModelMatchKey))
+ const floorPois = (poiDataCache.get(floor.floorId) || []).filter((poi) => (
+ floorKeys.has(normalizeModelMatchKey(String(poi.floorId || '')))
+ ))
+ weakNetworkFallbackPoiCache.set(cacheKey, floorPois)
+ weakNetworkFallbackPois.value = floorPois
+ console.warn('[ThreeMap] 弱网简图 POI 数据加载失败:', {
+ floorId: floor.floorId,
+ error: error instanceof Error ? error.message : String(error)
+ })
+ }
+ return true
+}
+
+const prepareWeakNetworkFallbackOverview = async (
+ initializationVersion = sceneInitializationVersion,
+ transitionRevision?: number
+) => {
+ const isRequestCurrent = () => (
+ isCurrentSceneInitialization(initializationVersion)
+ && (
+ transitionRevision === undefined
+ || transitionRevision === renderModeTransitionRevision
+ )
+ )
+ if (!isRequestCurrent()) return false
+ const floor = getWeakNetworkOverviewFloor()
+ if (!floor) return false
+
+ const requestRevision = ++weakNetworkFallbackPoiRequestRevision
+ const cacheKey = `overview:${floor.floorId}`
+ activeView.value = 'overview'
+ autoSwitchStateMachine.setView('overview')
+ weakNetworkFallbackPois.value = []
+ const cached = weakNetworkFallbackPoiCache.get(cacheKey)
+ if (cached) {
+ weakNetworkFallbackPois.value = cached
+ return true
+ }
+ try {
+ const result = await ensureFloorPoiData(floor)
+ if (
+ requestRevision !== weakNetworkFallbackPoiRequestRevision
+ || !isRequestCurrent()
+ ) return false
+ const overviewKeys = new Set(getFloorMatchKeys(floor).map(normalizeModelMatchKey))
+ const overviewPois = result.floorPois.filter((poi) => (
+ overviewKeys.has(normalizeModelMatchKey(String(poi.floorId || '')))
+ ))
+ weakNetworkFallbackPoiCache.set(cacheKey, overviewPois)
+ weakNetworkFallbackPois.value = overviewPois
+ } catch (error) {
+ if (
+ requestRevision !== weakNetworkFallbackPoiRequestRevision
+ || !isRequestCurrent()
+ ) return false
+ const overviewKeys = new Set(getFloorMatchKeys(floor).map(normalizeModelMatchKey))
+ const overviewPois = (poiDataCache.get(floor.floorId) || []).filter((poi) => (
+ overviewKeys.has(normalizeModelMatchKey(String(poi.floorId || '')))
+ ))
+ weakNetworkFallbackPoiCache.set(cacheKey, overviewPois)
+ weakNetworkFallbackPois.value = overviewPois
+ console.warn('[ThreeMap] 弱网室外底图 POI 数据加载失败:', {
+ floorId: floor.floorId,
+ error: error instanceof Error ? error.message : String(error)
+ })
+ }
+ return true
+}
+
+const activateWeakNetworkFallback = async (
+ requestedFloorId?: string,
+ presentation: FallbackPresentation = 'weak-network',
+ view: 'overview' | 'floor' = 'floor',
+ reason = 'model-load-failed',
+ initializationVersion = sceneInitializationVersion,
+ transitionRevision?: number
+) => {
+ if (!floorIndex.value.length) return false
+ const isRequestCurrent = () => (
+ isCurrentSceneInitialization(initializationVersion)
+ && (
+ transitionRevision === undefined
+ || transitionRevision === renderModeTransitionRevision
+ )
+ )
+ if (!isRequestCurrent()) return false
+ const requestSceneRevision = props.sceneRevision
+
+ const isReady = view === 'overview'
+ ? await prepareWeakNetworkFallbackOverview(initializationVersion, transitionRevision)
+ : await prepareWeakNetworkFallbackFloor(requestedFloorId, initializationVersion, transitionRevision)
+ if (!isReady || !isRequestCurrent()) return false
+
+ liveGlbTopActive.value = false
+ liveGlbThreeDCameraSnapshot = null
+ liveGlbThreeDControlsSnapshot = null
+ threeRendererRetainedForTwoD.value = Boolean(scene && renderer && activeModel)
+ if (threeRendererRetainedForTwoD.value) stopRenderLoop()
+ weakNetworkFallbackActive.value = true
+ fallbackPresentation.value = presentation
+ loadError.value = false
+ isLoading.value = false
+ setProgress(100, presentation === 'two-dimensional' ? '二维导览已就绪' : '网络较弱,已切换为简图导览')
+ if (presentation === 'weak-network') {
+ emit('renderModeFallback', {
+ mode: 'two-d',
+ view: activeView.value === 'overview' ? 'overview' : 'floor',
+ floorId: currentFloor.value,
+ reason,
+ sceneRevision: requestSceneRevision
+ })
+ preloadWeakNetworkFallbackModelInBackground(
+ activeView.value === 'overview' ? 'overview' : 'floor',
+ currentFloor.value,
+ reason
+ )
+ }
+ return true
+}
+
+const handleWeakNetworkPoiClick = (poi: GuideRenderPoi) => {
+ selectedPOI.value = poi
+ activeFocusPoiId.value = poi.id
+ emit('poiClick', poi)
+}
+
+const focusWeakNetworkTarget = async (request: TargetPoiFocusRequest, generation: number) => {
+ if (!isCurrentTargetFocus(generation)) return false
+ if (!request.poiId || !request.floorId) {
+ emitTargetFocus(request, 'missing', '目标位置数据不完整')
+ return false
+ }
+
+ const didPrepare = await prepareWeakNetworkFallbackFloor(request.floorId)
+ if (!didPrepare || !isCurrentTargetFocus(generation)) {
+ emitTargetFocus(request, 'missing', '目标楼层暂无简图数据')
+ return false
+ }
+
+ const poi = weakNetworkFallbackPois.value.find((item) => item.id === request.poiId)
+ || poiDataCache.get(request.floorId)?.find((item) => item.id === request.poiId)
+ || createPoiFromFocusRequest(request)
+ if (!poi?.positionGltf) {
+ emitTargetFocus(request, 'missing', '目标暂无可用坐标')
+ return false
+ }
+
+ selectedPOI.value = poi
+ activeFocusPoiId.value = poi.id
+ emitFloorChange(request.floorId)
+ emitTargetFocus(request, 'focused')
+ return true
+}
+
+const init3DScene = async (options: {
+ preserveSelectedPoi?: RenderPoi | null
+ restoreSceneViewport?: boolean
+ forceThreeRenderer?: boolean
+} = {}) => {
+ const initializationVersion = ++sceneInitializationVersion
+ const preservedSelectedPoi = options.preserveSelectedPoi || null
+ pendingWeakNetworkZoom = null
try {
disposeScene()
clearFloorViewBaselines()
@@ -5408,6 +8775,9 @@ const init3DScene = async () => {
firstModelLoadStartedAt = getNow()
firstModelVisibleReported = false
initialModelSettled = false
+ weakNetworkFallbackActive.value = false
+ weakNetworkFallbackPois.value = []
+ fallbackPresentation.value = null
poiFocusCameraAnimationCount = 0
cameraSnapshotRestoreCount = 0
isLoading.value = true
@@ -5416,19 +8786,44 @@ const init3DScene = async () => {
hasLoadedFloorViewOnce = false
setProgress(0, '正在初始化三维场景...')
logThreeMapDiagnostic('init-start', {
- initialView: props.initialView,
+ initialView: requestedSceneView.value,
initialFloorId: props.initialFloorId
})
- await initThree()
- installVisualStabilityDiagnostics()
- await loadModelPackage()
+ if (!isTwoDimensionalMode.value || options.forceThreeRenderer) {
+ await initThree()
+ installVisualStabilityDiagnostics()
+ }
+ await loadModelPackage(initializationVersion)
+ if (!isCurrentSceneInitialization(initializationVersion)) {
+ throw createStaleModelLoadError()
+ }
+
+ if (options.restoreSceneViewport && !weakNetworkFallbackActive.value) {
+ applySharedGuideViewport(props.sceneViewport)
+ }
+
+ if (preservedSelectedPoi) {
+ const restoredPoi = findLoadedPoi(preservedSelectedPoi.id) || preservedSelectedPoi
+ selectedPOI.value = restoredPoi
+ activeFocusPoiId.value = restoredPoi.id
+ if (!weakNetworkFallbackActive.value) {
+ updatePoiMarkerFocus()
+ showFocusPoiAffordances(restoredPoi)
+ }
+ }
// The package, not a hard-coded floor, determines the stable first-ready baseline.
initialGuideState = {
floorId: currentFloor.value,
camera: captureCameraSnapshot()
}
+ if (isTwoDimensionalMode.value && !weakNetworkFallbackActive.value) {
+ applyLiveGlbTopView({ captureRestoreState: true })
+ }
+ if (isCameraCalibrationMode.value) {
+ syncCameraCalibrationFromCamera()
+ }
setProgress(100, '馆内三维模型加载完成')
initialModelSettled = true
@@ -5444,22 +8839,46 @@ const init3DScene = async () => {
if (isStaleModelLoadError(error)) return
console.error('馆内 3D 模型资源加载失败:', error)
- loadError.value = true
- isLoading.value = false
const message = error instanceof Error ? error.message : '请检查模型资源或网络状态后重试'
- setFriendlyModelLoadError()
+ invalidateModelLoads()
+ const didFallback = await activateWeakNetworkFallback(
+ currentFloor.value,
+ 'weak-network',
+ activeView.value === 'overview' ? 'overview' : 'floor',
+ isModelLoadTimeoutError(error) ? 'model-load-timeout' : 'model-load-failed',
+ initializationVersion
+ )
+ if (!didFallback) {
+ loadError.value = true
+ isLoading.value = false
+ setFriendlyModelLoadError()
+ } else {
+ queueTargetFocus(pendingTargetFocus || props.targetFocus || null)
+ }
initialModelSettled = true
emit('initialModelFailed', {
view: activeView.value,
floorId: currentFloor.value,
message,
- elapsedMs: firstModelLoadStartedAt ? Math.round(getNow() - firstModelLoadStartedAt) : undefined
+ elapsedMs: firstModelLoadStartedAt ? Math.round(getNow() - firstModelLoadStartedAt) : undefined,
+ fallbackAvailable: didFallback,
+ actualRenderMode: didFallback ? 'two-d' : undefined
})
}
}
const handleFloorChange = async (floorId: string) => {
const requestedFloorId = resolveFloorIdFromRequest(floorId) || floorId
+ const requestSceneRevision = props.sceneRevision
+ if (weakNetworkFallbackActive.value) {
+ const didPrepare = await prepareWeakNetworkFallbackFloor(requestedFloorId)
+ if (didPrepare) {
+ selectedPOI.value = null
+ activeFocusPoiId.value = ''
+ emitFloorChange(requestedFloorId, requestSceneRevision)
+ }
+ return
+ }
const isCommittedSameFloor = (
activeView.value === 'floor'
&& currentFloor.value === requestedFloorId
@@ -5467,7 +8886,7 @@ const handleFloorChange = async (floorId: string) => {
&& !isFloorSwitching
)
if (isCommittedSameFloor) {
- emit('floorChange', requestedFloorId)
+ emitFloorChange(requestedFloorId, requestSceneRevision)
return
}
@@ -5488,15 +8907,25 @@ const handleFloorChange = async (floorId: string) => {
})
updatePoiVisibilityByDistance()
if (didCommit) {
- emit('floorChange', requestedFloorId)
+ emitFloorChange(requestedFloorId, requestSceneRevision)
}
} catch (error) {
if (isStaleModelLoadError(error)) return
console.error('楼层模型加载失败:', error)
- loadError.value = true
- restoreCommittedFloorPoiLayer()
- setFriendlyModelLoadError()
+ invalidateModelLoads()
+ if (await activateWeakNetworkFallback(
+ requestedFloorId,
+ 'weak-network',
+ 'floor',
+ isModelLoadTimeoutError(error) ? 'model-load-timeout' : 'model-load-failed'
+ )) {
+ emitFloorChange(requestedFloorId, requestSceneRevision)
+ } else {
+ loadError.value = true
+ restoreCommittedFloorPoiLayer()
+ setFriendlyModelLoadError()
+ }
} finally {
const requestWasSuperseded = modelLoadVersion > previousLoadToken + 1
if (!requestWasSuperseded || floorSwitchRequestedFloorId === requestedFloorId) {
@@ -5506,9 +8935,20 @@ const handleFloorChange = async (floorId: string) => {
}
const showOverview = async () => {
+ if (weakNetworkFallbackActive.value) {
+ selectedPOI.value = null
+ activeFocusPoiId.value = ''
+ return prepareWeakNetworkFallbackOverview()
+ }
+ const routeCompositeAsset = props.showRoute ? getSameGroundCompositeRouteAsset() : null
+ if (routeCompositeAsset) {
+ await showSameGroundRouteOverview(routeCompositeAsset)
+ return true
+ }
+
if (activeView.value === 'overview') {
resetCamera()
- return
+ return true
}
const previousLoadToken = modelLoadVersion
@@ -5517,7 +8957,13 @@ const showOverview = async () => {
loadError.value = false
clearMapSelection(false)
clearRoutePreview()
- await loadOverview()
+ // Manual return from an indoor floor must use the same exterior handoff
+ // snapshot as automatic zoom-out. Reusing the current indoor camera makes
+ // the exterior model appear several times larger than the entry view.
+ await loadOverview({
+ cameraSnapshot: getAutoSwitchExitCamera()
+ })
+ return true
} catch (error) {
if (!isStaleModelLoadError(error)) {
console.error('建筑外观模型加载失败:', error)
@@ -5534,25 +8980,60 @@ const showOverview = async () => {
}
const showMultiFloor = async () => {
+ if (weakNetworkFallbackActive.value) return false
+ if (props.showRoute) {
+ // During an active simulation, the route session owns the current floor.
+ // Never send it back to the route start merely because a multi-floor
+ // command was received from the ordinary browse UI.
+ if (props.routeNavigationActive) return false
+
+ const initialRouteFloorId = getNavigationScenePlan()?.floorIds[0]
+ if (initialRouteFloorId && (activeView.value !== 'floor' || currentFloor.value !== initialRouteFloorId)) {
+ await handleFloorChange(initialRouteFloorId)
+ }
+ return false
+ }
+
try {
isLoading.value = true
loadError.value = false
activeFocusPoiId.value = ''
await loadMultiFloor()
isLoading.value = false
+ return activeView.value === 'multi'
} catch (error) {
- if (isStaleModelLoadError(error)) return
+ if (isStaleModelLoadError(error)) return false
console.error('多层展示模型加载失败:', error)
loadError.value = true
isLoading.value = false
setFriendlyModelLoadError()
+ return false
}
}
// Restores business and camera state on the existing scene. It never initializes WebGL or
// reloads the model package, so closing a detail page cannot remount ThreeMap.
const resetToViewBaseline = async (options: ResetViewBaselineOptions) => {
+ if (fallbackPresentation.value === 'two-dimensional') {
+ const didPrepare = options.view === 'overview'
+ ? await prepareWeakNetworkFallbackOverview()
+ : options.floorId
+ ? await prepareWeakNetworkFallbackFloor(options.floorId)
+ : false
+ if (!didPrepare) return 'invalid-target' as const
+
+ clearTargetFocus()
+ clearRoutePreview()
+ selectedPOI.value = null
+ activeFocusPoiId.value = ''
+ weakNetworkFallbackRef.value?.resetCamera?.()
+ if (options.view === 'floor' && options.floorId) {
+ emitFloorChange(options.floorId)
+ }
+ return 'applied' as const
+ }
+
// Do not invalidate the first model load while the renderer is still being initialized.
// The page-level guide model state will replay only its latest not-ready request.
const baseline = initialGuideState
@@ -5584,6 +9065,7 @@ const resetToViewBaseline = async (options: ResetViewBaselineOptions) => {
isLoading.value = true
try {
+ let cameraRequiresTopReapply = false
if (options.view === 'floor') {
const floorId = options.floorId
if (!floorId) return 'invalid-target' as const
@@ -5596,6 +9078,8 @@ const resetToViewBaseline = async (options: ResetViewBaselineOptions) => {
const floorBaseline = getFloorViewBaseline(floorId, activeModel, floor.modelUrl)
if (!floorBaseline) return 'failed' as const
restoreCameraSnapshot(floorBaseline.camera)
+ applyFloorNavigationRange(floorBaseline)
+ cameraRequiresTopReapply = true
}
if (!scene || !isCurrentModelLoad(modelLoadVersion)) return 'stale' as const
activeView.value = 'floor'
@@ -5608,14 +9092,16 @@ const resetToViewBaseline = async (options: ResetViewBaselineOptions) => {
}
if (!scene || !isCurrentModelLoad(modelLoadVersion)) return 'stale' as const
restoreCameraSnapshot(baseline.camera)
+ cameraRequiresTopReapply = true
currentFloor.value = baseline.floorId
activeView.value = 'overview'
autoSwitchStateMachine.reset('overview')
}
- if (controls && activeView.value === 'floor') {
+ if (controls && activeView.value === 'floor' && !floorNavigationDistance) {
ensureFloorAutoExitZoomRange()
autoSwitchStateMachine.setFloorInitialDistance(controls.getDistance())
}
+ if (cameraRequiresTopReapply) reapplyLiveGlbTopAfterSceneCommit()
refreshPoiVisibilityByDistance()
return 'applied' as const
} catch (error) {
@@ -5635,20 +9121,25 @@ const resetToInitialState = () => resetToViewBaseline({
})
const retryLoad = () => {
- init3DScene()
+ weakNetworkFallbackActive.value = false
+ threeRendererRetainedForTwoD.value = false
+ weakNetworkFallbackPois.value = []
+ init3DScene({ forceThreeRenderer: isTwoDimensionalMode.value })
}
const disposeScene = () => {
isDisposed = true
+ threeRendererRetainedForTwoD.value = false
+ liveGlbTopActive.value = false
+ liveGlbThreeDCameraSnapshot = null
+ liveGlbThreeDControlsSnapshot = null
+ pendingWeakNetworkZoom = null
adjacentPreloadSeq += 1
defaultFloorPreloadSeq += 1
referenceBuildingAnchors = []
invalidateModelLoads()
- if (animationId) {
- window.cancelAnimationFrame(animationId)
- animationId = 0
- }
+ stopRenderLoop()
const container = getContainerElement()
container?.removeEventListener('pointerdown', handlePointerDown, true)
@@ -5689,7 +9180,9 @@ const disposeScene = () => {
cameraTween = null
poiFocusCameraAnimationCount = 0
cameraSnapshotRestoreCount = 0
+ floorNavigationDistance = 0
isProgrammaticCameraChange = false
+ hasActiveUserCameraGesture = false
activeAutoSwitchInputSource = 'gesture'
resetInteractionGateState()
@@ -5730,11 +9223,97 @@ defineExpose({
resetToViewBaseline,
resetToInitialState,
clearNavigation: () => clearMapSelection(false),
- disableAutoSwitchTemporarily
+ disableAutoSwitchTemporarily,
+ getGuideViewportState
})
watch(() => props.modelSource, () => {
- init3DScene()
+ init3DScene({ forceThreeRenderer: liveGlbTopActive.value })
+})
+
+const enterTwoDimensionalMode = async (transitionRevision: number) => {
+ const hasRetainableThreeDScene = Boolean(scene && renderer && camera && controls && activeModel)
+ twoDViewportChangedSinceModeEntry = false
+ weakNetworkBoundaryIntentState = createGuideBoundaryIntentState()
+ weakNetworkSceneTransitionRevision += 1
+ weakNetworkSceneTransitionInFlight = false
+
+ if (hasRetainableThreeDScene) {
+ if (transitionRevision !== renderModeTransitionRevision) return
+ const viewport = getGuideViewportState()
+ if (viewport) emit('sceneViewportChange', viewport)
+ weakNetworkFallbackActive.value = false
+ weakNetworkFallbackPois.value = []
+ fallbackPresentation.value = null
+ applyLiveGlbTopView({
+ captureRestoreState: true,
+ transitionRevision
+ })
+ return
+ }
+
+ if (transitionRevision !== renderModeTransitionRevision) return
+ const targetView = requestedSceneView.value === 'overview' ? 'overview' : 'floor'
+ const didActivate = floorIndex.value.length
+ ? await activateWeakNetworkFallback(
+ currentFloor.value,
+ 'two-dimensional',
+ targetView,
+ 'explicit-two-dimensional',
+ sceneInitializationVersion,
+ transitionRevision
+ )
+ : false
+ if (transitionRevision !== renderModeTransitionRevision) return
+ if (didActivate) return
+
+ await init3DScene()
+}
+
+const restoreThreeDimensionalMode = async (transitionRevision: number) => {
+ if (transitionRevision !== renderModeTransitionRevision) return
+ if (restoreLiveGlbThreeDimensionalView()) {
+ weakNetworkFallbackActive.value = false
+ threeRendererRetainedForTwoD.value = false
+ weakNetworkFallbackPois.value = []
+ fallbackPresentation.value = null
+ loadError.value = false
+ isLoading.value = false
+ twoDViewportChangedSinceModeEntry = false
+ renderRoutePreview()
+ return
+ }
+
+ const preservedSelectedPoi = selectedPOI.value
+ if (weakNetworkFallbackActive.value || !scene || !renderer || !camera || !controls || !activeModel) {
+ await init3DScene({
+ preserveSelectedPoi: preservedSelectedPoi,
+ restoreSceneViewport: true
+ })
+ return
+ }
+ if (transitionRevision !== renderModeTransitionRevision) return
+
+ weakNetworkFallbackActive.value = false
+ threeRendererRetainedForTwoD.value = false
+ weakNetworkFallbackPois.value = []
+ fallbackPresentation.value = null
+ loadError.value = false
+ isLoading.value = false
+ twoDViewportChangedSinceModeEntry = false
+ startRenderLoop()
+ refreshPoiVisibilityByDistance()
+ renderRoutePreview()
+}
+
+watch(() => props.renderMode, (renderMode, previousRenderMode) => {
+ if (renderMode === previousRenderMode) return
+ const transitionRevision = ++renderModeTransitionRevision
+ if (renderMode === 'two-d') {
+ void enterTwoDimensionalMode(transitionRevision)
+ return
+ }
+ void restoreThreeDimensionalMode(transitionRevision)
})
watch(() => [props.showControls, props.touchGestureMode], () => {
@@ -5754,23 +9333,56 @@ watch(() => props.visiblePoiIds, () => {
deep: true
})
-watch(() => [props.routePreview, props.showRoute], () => {
- renderRoutePreview()
+watch(() => props.routeStartSelectionActive, (isSelectingStart) => {
+ if (isSelectingStart) {
+ clearMapSelection(false)
+ }
+}, {
+ immediate: true
+})
+
+watch(() => [props.routePreview, props.showRoute, props.routeNavigationActive], () => {
+ syncRouteCompositeView()
}, {
deep: true
})
const syncRequestedIndoorView = async () => {
+ if (weakNetworkFallbackActive.value) {
+ if (floorIndex.value.length) {
+ const requestedFloorId = resolveFloorIdFromRequest(props.initialFloorId) || currentFloor.value
+ await activateWeakNetworkFallback(
+ requestedFloorId,
+ 'two-dimensional',
+ requestedSceneView.value === 'overview' ? 'overview' : 'floor'
+ )
+ }
+ return
+ }
+
if (!scene || !floorIndex.value.length || isLoading.value) return
- if (props.initialView === 'multi') {
+ // Route guidance never inherits a stale ordinary-browse multi-floor view.
+ // It presents only the active WALK segment on its physical floor.
+ const routeScene = props.showRoute ? getNavigationScenePlan() : null
+ if (routeScene?.kind === 'multi-floor') {
+ if (props.routeNavigationActive) return
+
+ const initialRouteFloorId = routeScene.floorIds[0]
+ if (initialRouteFloorId && (activeView.value !== 'floor' || currentFloor.value !== initialRouteFloorId)) {
+ await handleFloorChange(initialRouteFloorId)
+ }
+ return
+ }
+
+ if (requestedSceneView.value === 'multi') {
if (activeView.value !== 'multi') {
await showMultiFloor()
}
return
}
- if (props.initialView === 'floor') {
+ if (requestedSceneView.value === 'floor') {
const requestedFloorId = resolveFloorIdFromRequest(props.initialFloorId)
|| currentFloor.value
if (requestedFloorId && (activeView.value !== 'floor' || currentFloor.value !== requestedFloorId)) {
@@ -5779,7 +9391,7 @@ const syncRequestedIndoorView = async () => {
}
}
-watch(() => [props.initialView, props.initialFloorId], () => {
+watch(() => [props.sceneView, props.initialView, props.initialFloorId], () => {
void syncRequestedIndoorView()
})
@@ -5827,16 +9439,22 @@ onUnmounted(() => {
position: absolute;
display: flex;
align-items: center;
+ min-height: 22px;
+ gap: 3px;
+ padding: 2px 5px;
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);
+ color: #14205f;
+ background: rgba(255, 255, 255, 0.56);
+ border: 1px solid rgba(26, 35, 126, 0.12);
+ border-radius: 4px;
+ box-shadow: 0 2px 7px rgba(26, 35, 126, 0.1);
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.86);
+ font-size: 12px;
+ line-height: 15px;
+ font-weight: 400;
transition: opacity 120ms ease;
}
@@ -5848,54 +9466,120 @@ onUnmounted(() => {
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);
+ background: rgba(255, 255, 255, 0.56);
+ border-right: 1px solid rgba(26, 35, 126, 0.12);
+ border-bottom: 1px solid rgba(26, 35, 126, 0.12);
transform: translateX(-50%) rotate(45deg);
}
:deep(.three-poi-dom-label--ambient) {
- min-height: 30px;
- padding: 5px 9px;
+ color: #14205f;
}
-: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--selected) {
+ color: #0f4f9d;
+ background: rgba(238, 246, 255, 0.72);
+ border-color: rgba(21, 101, 192, 0.48);
+ box-shadow: 0 2px 8px rgba(21, 101, 192, 0.16);
}
-: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--selected::after) {
+ background: rgba(238, 246, 255, 0.72);
+ border-color: rgba(21, 101, 192, 0.48);
}
-: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--overview) {
+ color: #14205f;
+}
+
+:deep(.three-poi-dom-label--overview::after) {
+ background: rgba(255, 255, 255, 0.56);
}
: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);
+ color: #0f4f9d;
+ background: rgba(238, 246, 255, 0.82);
+ border-color: rgba(21, 101, 192, 0.48);
+ box-shadow: 0 2px 8px rgba(21, 101, 192, 0.16);
}
:deep(.three-poi-dom-label__title) {
+ min-width: 0;
overflow: hidden;
max-width: 100%;
text-overflow: ellipsis;
}
+:deep(.three-poi-dom-label__icon) {
+ display: block;
+ flex: 0 0 16px;
+ width: 16px;
+ height: 16px;
+ box-sizing: border-box;
+ padding: 2px;
+ color: var(--poi-icon-color, #1565c0);
+ background: rgba(255, 255, 255, 0.74);
+ border: 1px solid currentColor;
+ border-radius: 50%;
+ pointer-events: none;
+}
+
+:deep(.three-poi-dom-label[data-poi-icon='exhibition-hall']) {
+ --poi-icon-color: #356f9c;
+}
+
+:deep(.three-poi-dom-label[data-poi-icon='cinema']) {
+ --poi-icon-color: #78649f;
+}
+
+:deep(.three-poi-dom-label[data-poi-icon='ticket-office']) {
+ --poi-icon-color: #a7752f;
+}
+
+:deep(.three-poi-dom-label[data-poi-icon='dining']) {
+ --poi-icon-color: #b76545;
+}
+
+:deep(.three-poi-dom-label[data-poi-icon='shopping']) {
+ --poi-icon-color: #4c8c7d;
+}
+
+:deep(.three-poi-dom-label[data-poi-icon='service-center']),
+:deep(.three-poi-dom-label[data-poi-icon='elevator']),
+:deep(.three-poi-dom-label[data-poi-icon='escalator']),
+:deep(.three-poi-dom-label[data-poi-icon='stairs']) {
+ --poi-icon-color: #39739b;
+}
+
+:deep(.three-poi-dom-label[data-poi-icon='restroom']),
+:deep(.three-poi-dom-label[data-poi-icon='nursing-room']) {
+ --poi-icon-color: #6b77a8;
+}
+
+:deep(.three-poi-dom-label[data-poi-icon='entrance']) {
+ --poi-icon-color: #356f9c;
+}
+
+:deep(.three-poi-dom-label[data-poi-icon='parking']) {
+ --poi-icon-color: #7b6a9d;
+}
+
+:deep(.three-poi-dom-label[data-poi-icon='place']) {
+ --poi-icon-color: #4d8a66;
+}
+
+:deep(.three-poi-dom-label[data-poi-icon='road']) {
+ --poi-icon-color: #71808b;
+}
+
+:deep(.three-poi-dom-label[data-poi-icon='transport']) {
+ --poi-icon-color: #b16c32;
+}
+
:deep(.three-poi-dom-label--focus .three-poi-dom-label__title) {
- font-size: 16px;
- line-height: 22px;
- font-weight: 700;
+ font-size: 12px;
+ line-height: 17px;
+ font-weight: 400;
}
:deep(.three-poi-dom-label__meta) {
@@ -6013,6 +9697,196 @@ onUnmounted(() => {
z-index: 12;
}
+.camera-calibration-panel {
+ position: absolute;
+ right: 10px;
+ top: 10px;
+ width: min(282px, calc(100% - 20px));
+ padding: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 9px;
+ box-sizing: border-box;
+ background: rgba(255, 255, 255, 0.96);
+ border: 1px solid rgba(26, 35, 126, 0.18);
+ border-radius: 6px;
+ box-shadow: 0 6px 18px rgba(31, 35, 41, 0.16);
+ z-index: 90;
+ pointer-events: auto;
+ transform: translateX(calc(100% + 12px));
+ transition: transform 180ms ease;
+}
+
+.camera-calibration-panel.expanded {
+ transform: translateX(0);
+}
+
+.camera-calibration-toggle {
+ position: absolute;
+ right: 10px;
+ top: 14px;
+ width: 42px;
+ height: 58px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
+ background: rgba(255, 255, 255, 0.94);
+ border: 1px solid rgba(26, 35, 126, 0.2);
+ border-radius: 6px;
+ box-shadow: 0 4px 12px rgba(31, 35, 41, 0.12);
+ z-index: 90;
+ pointer-events: auto;
+}
+
+.camera-calibration-toggle text {
+ color: #1a237e;
+ font-size: 12px;
+ line-height: 17px;
+ font-weight: 700;
+ writing-mode: vertical-rl;
+}
+
+.camera-calibration-header,
+.camera-calibration-actions,
+.camera-calibration-input-row {
+ display: flex;
+ align-items: center;
+}
+
+.camera-calibration-header {
+ justify-content: space-between;
+}
+
+.camera-calibration-title,
+.camera-calibration-subtitle,
+.camera-calibration-row text,
+.camera-calibration-center-title,
+.camera-calibration-input-row text,
+.camera-calibration-output,
+.camera-calibration-actions text {
+ display: block;
+}
+
+.camera-calibration-title {
+ color: #1a237e;
+ font-size: 14px;
+ line-height: 20px;
+ font-weight: 700;
+}
+
+.camera-calibration-subtitle {
+ color: #5f666d;
+ font-size: 11px;
+ line-height: 15px;
+}
+
+.camera-calibration-close {
+ width: 24px;
+ height: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #4d5560;
+ font-size: 20px;
+ line-height: 24px;
+}
+
+.camera-calibration-row {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.camera-calibration-row text,
+.camera-calibration-center-title,
+.camera-calibration-input-row text {
+ color: #2f3741;
+ font-size: 12px;
+ line-height: 17px;
+}
+
+.camera-calibration-row slider {
+ width: 100%;
+ margin: 0;
+}
+
+.camera-calibration-center {
+ padding-top: 7px;
+ border-top: 1px solid rgba(31, 35, 41, 0.1);
+}
+
+.camera-calibration-center-title {
+ margin-bottom: 4px;
+ font-weight: 700;
+}
+
+.camera-calibration-input-row {
+ gap: 8px;
+ margin-top: 4px;
+}
+
+.camera-calibration-input-row text {
+ width: 12px;
+ color: #5f666d;
+ text-align: center;
+}
+
+.camera-calibration-input-row input {
+ height: 28px;
+ flex: 1;
+ padding: 0 8px;
+ box-sizing: border-box;
+ color: #1f2329;
+ background: #f7f8f4;
+ border: 1px solid rgba(31, 35, 41, 0.16);
+ border-radius: 4px;
+ font-size: 12px;
+ line-height: 28px;
+}
+
+.camera-calibration-output {
+ min-height: 32px;
+ padding: 6px 8px;
+ box-sizing: border-box;
+ color: #4d5560;
+ background: #f4f7fb;
+ border-radius: 4px;
+ font-size: 11px;
+ line-height: 16px;
+}
+
+.camera-calibration-actions {
+ gap: 8px;
+}
+
+.camera-calibration-actions view {
+ height: 30px;
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
+ background: #1565c0;
+ border-radius: 4px;
+}
+
+.camera-calibration-actions view:first-child {
+ background: #eef2f6;
+ border: 1px solid rgba(31, 35, 41, 0.14);
+}
+
+.camera-calibration-actions text {
+ color: #ffffff;
+ font-size: 12px;
+ line-height: 16px;
+ font-weight: 700;
+}
+
+.camera-calibration-actions view:first-child text {
+ color: #2f3741;
+}
+
.overview-btn {
height: 34px;
padding: 0 12px;
diff --git a/src/components/map/WeakNetworkGuideFallback.vue b/src/components/map/WeakNetworkGuideFallback.vue
new file mode 100644
index 0000000..1d78976
--- /dev/null
+++ b/src/components/map/WeakNetworkGuideFallback.vue
@@ -0,0 +1,1513 @@
+
+
+
+ {{ presentation === 'two-dimensional' ? '已切换为二维导览' : '网络较弱,已切换为简图导览' }}
+
+ {{ presentation === 'two-dimensional' ? '切换三维' : '重试三维' }}
+
+
+
+
+ {{ floorLabel || '馆内简图' }}
+
+
+
+
+
+ {{ floor.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ marker.label }}
+
+
+
+ 该楼层暂无可显示地点
+
+
+
+
+
+
+
+
+
diff --git a/src/components/map/floorPoiLoader.ts b/src/components/map/floorPoiLoader.ts
new file mode 100644
index 0000000..7259320
--- /dev/null
+++ b/src/components/map/floorPoiLoader.ts
@@ -0,0 +1,32 @@
+import type { GuideModelSource, GuideRenderPoi } from '@/domain/guideModel'
+
+export interface InitialFloorPoiLoadResult {
+ floorPois: GuideRenderPoi[]
+ dataTier: 'fast' | 'full'
+ fastLoadError?: unknown
+}
+
+export const loadFloorPoisForInitialRender = async (
+ modelSource: GuideModelSource,
+ floorId: string
+): Promise => {
+ if (!modelSource.loadFloorPoisFast) {
+ return {
+ floorPois: await modelSource.loadFloorPois(floorId),
+ dataTier: 'full'
+ }
+ }
+
+ try {
+ return {
+ floorPois: await modelSource.loadFloorPoisFast(floorId),
+ dataTier: 'fast'
+ }
+ } catch (fastLoadError) {
+ return {
+ floorPois: await modelSource.loadFloorPois(floorId),
+ dataTier: 'full',
+ fastLoadError
+ }
+ }
+}
diff --git a/src/components/map/focusMaterialIsolation.ts b/src/components/map/focusMaterialIsolation.ts
new file mode 100644
index 0000000..0d76d74
--- /dev/null
+++ b/src/components/map/focusMaterialIsolation.ts
@@ -0,0 +1,25 @@
+import * as THREE from 'three'
+
+export interface IsolatedFocusMaterialState {
+ mesh: THREE.Mesh
+ originalMaterial: THREE.Material | THREE.Material[]
+ clonedMaterials: THREE.Material[]
+}
+
+export const isolateMeshMaterialsForFocus = (
+ mesh: THREE.Mesh
+): IsolatedFocusMaterialState => {
+ const originalMaterial = mesh.material
+ const clonedMaterial = Array.isArray(originalMaterial)
+ ? originalMaterial.map((material) => material.clone())
+ : originalMaterial.clone()
+ const clonedMaterials = Array.isArray(clonedMaterial) ? clonedMaterial : [clonedMaterial]
+
+ mesh.material = clonedMaterial
+ return { mesh, originalMaterial, clonedMaterials }
+}
+
+export const restoreIsolatedFocusMaterials = (state: IsolatedFocusMaterialState) => {
+ state.mesh.material = state.originalMaterial
+ state.clonedMaterials.forEach((material) => material.dispose())
+}
diff --git a/src/components/map/guideAutoSwitchStateMachine.ts b/src/components/map/guideAutoSwitchStateMachine.ts
index 1f7e4ab..298333c 100644
--- a/src/components/map/guideAutoSwitchStateMachine.ts
+++ b/src/components/map/guideAutoSwitchStateMachine.ts
@@ -21,6 +21,7 @@ interface GuideAutoSwitchStateMachineOptions {
cooldownMs?: number
intentTimeoutMs?: number
reverseToleranceRatio?: number
+ overviewEntryDistance?: number | (() => number)
}
interface DistanceUpdateOptions {
@@ -52,6 +53,7 @@ export class GuideAutoSwitchStateMachine {
private readonly cooldownMs: number
private readonly intentTimeoutMs: number
private readonly reverseToleranceRatio: number
+ private readonly configuredOverviewEntryDistance: () => number
// 外观以连续缩放起点为基准,楼层以相机拟合完成后的距离为基准。
private view: GuideAutoSwitchView = 'overview'
@@ -77,6 +79,13 @@ export class GuideAutoSwitchStateMachine {
this.cooldownMs = options.cooldownMs ?? 1500
this.intentTimeoutMs = options.intentTimeoutMs ?? 2000
this.reverseToleranceRatio = options.reverseToleranceRatio ?? 0.008
+ const overviewEntryDistance = options.overviewEntryDistance
+ const staticOverviewEntryDistance = typeof overviewEntryDistance === 'number'
+ ? overviewEntryDistance
+ : 0
+ this.configuredOverviewEntryDistance = typeof overviewEntryDistance === 'function'
+ ? overviewEntryDistance
+ : () => isValidDistance(staticOverviewEntryDistance) ? staticOverviewEntryDistance : 0
}
setView(view: GuideAutoSwitchView) {
@@ -213,7 +222,7 @@ export class GuideAutoSwitchStateMachine {
}
this.overviewLastInputAt = now
- const shouldEnter = distance <= this.overviewStartDistance * (1 - this.enterRatio)
+ const shouldEnter = distance <= this.getOverviewEntryThreshold()
this.updateCandidate('enter-floor', shouldEnter, isReverseJitter)
}
@@ -287,9 +296,9 @@ export class GuideAutoSwitchStateMachine {
private isConditionMet(direction: GuideAutoSwitchDirection, candidateDistance: number) {
if (direction === 'enter-floor') {
- const thresholdDistance = this.overviewStartDistance * (1 - this.enterRatio)
+ const thresholdDistance = this.getOverviewEntryThreshold()
return this.view === 'overview'
- && this.overviewStartDistance > 0
+ && thresholdDistance > 0
&& (
this.currentDistance <= thresholdDistance
|| this.currentDistance <= candidateDistance * (1 + this.reverseToleranceRatio)
@@ -305,6 +314,12 @@ export class GuideAutoSwitchStateMachine {
)
}
+ private getOverviewEntryThreshold() {
+ const configuredDistance = this.configuredOverviewEntryDistance()
+ if (isValidDistance(configuredDistance)) return configuredDistance
+ return this.overviewStartDistance * (1 - this.enterRatio)
+ }
+
private cancelCandidate(direction?: GuideAutoSwitchDirection) {
if (!this.candidate || (direction && this.candidate.direction !== direction)) return
diff --git a/src/components/map/routeStartCandidateResolver.ts b/src/components/map/routeStartCandidateResolver.ts
new file mode 100644
index 0000000..20246da
--- /dev/null
+++ b/src/components/map/routeStartCandidateResolver.ts
@@ -0,0 +1,192 @@
+export interface RouteStartSelectablePoint {
+ routeTargetId?: string
+ poiId: string
+ sourceId?: string
+ name?: string
+ floorId: string
+ positionGltf?: [number, number, number]
+ routeNodeId?: string
+}
+
+export interface RouteStartCandidatePayload {
+ routeTargetId?: string
+ poiId: string
+ sourceId?: string
+ sourceName?: string
+ floorId?: string
+ positionGltf?: [number, number, number]
+ routeNodeId?: string
+ coordinateFallback?: boolean
+}
+
+export interface ResolveRouteStartCandidateInput {
+ floorId: string
+ position: [number, number, number]
+ sourceName?: string
+ sourcePoiIds?: Array
+ points: RouteStartSelectablePoint[]
+ maxDistanceMeters: number
+ requireObjectMatch?: boolean
+}
+
+export interface ResolvedRouteStartCandidate {
+ mode: 'linked' | 'coordinate'
+ point: RouteStartSelectablePoint
+ distance?: number
+}
+
+const addIdentity = (identities: Set, value: unknown) => {
+ if (value === null || typeof value === 'undefined') return
+
+ const text = String(value).trim()
+ if (!text) return
+
+ identities.add(text)
+
+ // Map POI ids intentionally carry a presentation prefix (hall-123 / space-123),
+ // while SDK navigable places return the numeric source id. Treat both as one
+ // business identity without weakening the match to arbitrary nearby objects.
+ const prefixedId = text.match(/^(?:hall|space|poi|place)-(.+)$/i)
+ if (prefixedId?.[1]) identities.add(prefixedId[1])
+
+ const routeTargetParts = text.split(':')
+ if (routeTargetParts.length > 1 && routeTargetParts[0]) {
+ identities.add(routeTargetParts[0])
+ }
+}
+
+const identitiesFor = (values: Array) => {
+ const identities = new Set()
+ values.forEach((value) => addIdentity(identities, value))
+ return identities
+}
+
+const normalizedRouteName = (value?: string) => {
+ if (!value) return ''
+
+ const normalized = value
+ .trim()
+ .toLocaleLowerCase()
+ .replace(/[\s()()【】{}._-]/g, '')
+ .replace(/\[/g, '')
+ .replace(/\]/g, '')
+ .replace(/(?:主)?(?:出入口|入口|出口|门点?)\d*$/u, '')
+
+ return normalized
+}
+
+const hasNameMatch = (sourceName: string | undefined, targetName: string | undefined) => {
+ const source = normalizedRouteName(sourceName)
+ const target = normalizedRouteName(targetName)
+ if (!source || !target || source.length < 2 || target.length < 2) return false
+ return source === target || source.includes(target) || target.includes(source)
+}
+
+const distanceBetween = (
+ point: RouteStartSelectablePoint,
+ position: [number, number, number]
+) => {
+ if (!point.positionGltf) return Number.POSITIVE_INFINITY
+
+ return Math.hypot(
+ point.positionGltf[0] - position[0],
+ point.positionGltf[2] - position[2]
+ )
+}
+
+const pickNearest = (
+ points: RouteStartSelectablePoint[],
+ position: [number, number, number]
+) => points
+ .map((point) => ({
+ point,
+ distance: distanceBetween(point, position)
+ }))
+ .sort((left, right) => left.distance - right.distance)[0]
+
+export const resolveRouteStartCandidate = ({
+ floorId,
+ position,
+ sourceName,
+ sourcePoiIds = [],
+ points,
+ maxDistanceMeters,
+ requireObjectMatch = false
+}: ResolveRouteStartCandidateInput): ResolvedRouteStartCandidate | null => {
+ const floorPoints = points.filter((point) => (
+ point.poiId
+ && String(point.floorId) === String(floorId)
+ ))
+
+ const sourceIdentities = identitiesFor(sourcePoiIds)
+ const linkedPoints = floorPoints.filter((point) => {
+ const pointIdentities = identitiesFor([
+ point.routeTargetId,
+ point.poiId,
+ point.sourceId
+ ])
+
+ return Array.from(sourceIdentities).some((identity) => pointIdentities.has(identity))
+ })
+
+ const namedPoints = sourceName
+ ? floorPoints.filter((point) => hasNameMatch(sourceName, point.name))
+ : []
+ const matchedPoints = linkedPoints.length ? linkedPoints : namedPoints
+
+ if (matchedPoints.length) {
+ const nearest = pickNearest(matchedPoints, position)
+ if (nearest && (requireObjectMatch || nearest.distance <= maxDistanceMeters)) {
+ return {
+ mode: 'linked',
+ point: nearest.point,
+ distance: nearest.distance
+ }
+ }
+ }
+
+ // A clicked business object can be a valid route endpoint even when the
+ // published navigable-place list has no explicit anchor for it. The route
+ // API accepts its GLB coordinate and performs the authoritative graph snap.
+ // Keep this fallback object-scoped; blank map taps still require a nearby
+ // published route target and never synthesize an arbitrary one.
+ if (requireObjectMatch && sourceName && position.every(Number.isFinite)) {
+ const objectId = sourcePoiIds.find((value) => value !== null && value !== undefined && String(value).trim())
+ const poiId = objectId ? String(objectId) : `map-coordinate:${floorId}:${position[0]}:${position[2]}`
+
+ return {
+ mode: 'coordinate',
+ point: {
+ routeTargetId: `coordinate:${poiId}`,
+ poiId,
+ sourceId: objectId ? String(objectId) : undefined,
+ name: sourceName,
+ floorId,
+ positionGltf: position
+ }
+ }
+ }
+
+ const nearest = pickNearest(floorPoints, position)
+ if (!nearest || nearest.distance > maxDistanceMeters) return null
+
+ return {
+ mode: 'linked',
+ point: nearest.point,
+ distance: nearest.distance
+ }
+}
+
+export const toRouteStartCandidatePayload = (
+ resolved: ResolvedRouteStartCandidate,
+ sourceName?: string
+): RouteStartCandidatePayload => ({
+ routeTargetId: resolved.point.routeTargetId,
+ poiId: resolved.point.poiId,
+ sourceId: resolved.point.sourceId,
+ sourceName: sourceName || resolved.point.name,
+ floorId: resolved.point.floorId,
+ positionGltf: resolved.point.positionGltf,
+ routeNodeId: resolved.point.routeNodeId,
+ coordinateFallback: resolved.mode === 'coordinate'
+})
diff --git a/src/components/map/routeSurfaceProjection.ts b/src/components/map/routeSurfaceProjection.ts
new file mode 100644
index 0000000..2c199c0
--- /dev/null
+++ b/src/components/map/routeSurfaceProjection.ts
@@ -0,0 +1,124 @@
+import * as THREE from 'three'
+
+export type RoutePosition = readonly [number, number, number]
+
+const WALKABLE_NORMAL_Y_MIN = 0.5
+const SURFACE_CLUSTER_TOLERANCE = 0.35
+const HEIGHT_EPSILON = 0.001
+
+const isVisibleWithin = (object: THREE.Object3D, root: THREE.Object3D) => {
+ let current: THREE.Object3D | null = object
+ while (current) {
+ if (!current.visible) return false
+ if (current === root) return true
+ current = current.parent
+ }
+ return false
+}
+
+const uniqueSortedHeights = (values: number[]) => values
+ .filter(Number.isFinite)
+ .sort((a, b) => a - b)
+ .filter((value, index, sorted) => index === 0 || Math.abs(value - sorted[index - 1]) > HEIGHT_EPSILON)
+
+export const getWalkableSurfaceHits = (
+ floorModel: THREE.Object3D,
+ bounds: THREE.Box3,
+ position: RoutePosition
+) => {
+ if (bounds.isEmpty()) return []
+
+ floorModel.updateWorldMatrix(true, true)
+ const rayStart = new THREE.Vector3(position[0], bounds.max.y + 2, position[2])
+ const rayLength = Math.max(bounds.max.y - bounds.min.y + 4, 8)
+ const intersections = new THREE.Raycaster(
+ rayStart,
+ new THREE.Vector3(0, -1, 0),
+ 0,
+ rayLength
+ ).intersectObject(floorModel, true)
+
+ return uniqueSortedHeights(intersections.flatMap((hit) => {
+ if (!hit.face || !isVisibleWithin(hit.object, floorModel)) return []
+ const normal = hit.face.normal.clone()
+ .applyMatrix3(new THREE.Matrix3().getNormalMatrix(hit.object.matrixWorld))
+ .normalize()
+ return normal.y > WALKABLE_NORMAL_Y_MIN ? [hit.point.y] : []
+ }))
+}
+
+const median = (values: number[]) => {
+ const sorted = [...values].sort((a, b) => a - b)
+ const middle = Math.floor(sorted.length / 2)
+ return sorted.length % 2 === 0
+ ? (sorted[middle - 1] + sorted[middle]) / 2
+ : sorted[middle]
+}
+
+export const resolveDominantRouteSurfaceY = (
+ floorModel: THREE.Object3D,
+ bounds: THREE.Box3,
+ positions: RoutePosition[]
+): number | null => {
+ const samples = positions.flatMap((position) => {
+ const hits = getWalkableSurfaceHits(floorModel, bounds, position)
+ // The lowest upward-facing hit avoids furniture and equipment above the deck.
+ return hits.length ? [hits[0]] : []
+ }).sort((a, b) => a - b)
+
+ if (!samples.length) return null
+
+ const clusters: number[][] = []
+ samples.forEach((sample) => {
+ const cluster = clusters.find((candidate) => (
+ Math.abs(sample - median(candidate)) <= SURFACE_CLUSTER_TOLERANCE
+ ))
+ if (cluster) {
+ cluster.push(sample)
+ } else {
+ clusters.push([sample])
+ }
+ })
+
+ const dominant = clusters.sort((a, b) => (
+ b.length - a.length || median(a) - median(b)
+ ))[0]
+ return dominant?.length ? median(dominant) : null
+}
+
+export const resolveRoutePointSurfaceY = (
+ floorModel: THREE.Object3D,
+ bounds: THREE.Box3,
+ position: RoutePosition,
+ preferredSurfaceY?: number
+): number | null => {
+ const hits = getWalkableSurfaceHits(floorModel, bounds, position)
+ if (!hits.length) {
+ return Number.isFinite(preferredSurfaceY) ? preferredSurfaceY! : null
+ }
+ if (!Number.isFinite(preferredSurfaceY)) return hits[0]
+
+ return hits.reduce((closest, height) => (
+ Math.abs(height - preferredSurfaceY!) < Math.abs(closest - preferredSurfaceY!)
+ ? height
+ : closest
+ ), hits[0])
+}
+
+export const projectRoutePositionToSurface = (
+ floorModel: THREE.Object3D,
+ bounds: THREE.Box3,
+ position: RoutePosition,
+ preferredSurfaceY?: number,
+ lift = 0.24
+) => {
+ const surfaceY = resolveRoutePointSurfaceY(
+ floorModel,
+ bounds,
+ position,
+ preferredSurfaceY
+ )
+ return surfaceY === null
+ ? null
+ : new THREE.Vector3(position[0], surfaceY + lift, position[2])
+}
diff --git a/src/components/navigation/GuideFeedbackState.vue b/src/components/navigation/GuideFeedbackState.vue
new file mode 100644
index 0000000..1c6d948
--- /dev/null
+++ b/src/components/navigation/GuideFeedbackState.vue
@@ -0,0 +1,143 @@
+
+
+
+ {{ title }}
+ {{ description }}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/navigation/GuideLoadingState.vue b/src/components/navigation/GuideLoadingState.vue
new file mode 100644
index 0000000..d49beb3
--- /dev/null
+++ b/src/components/navigation/GuideLoadingState.vue
@@ -0,0 +1,83 @@
+
+
+
+ {{ title }}
+ {{ description }}
+
+
+
+
+
+
diff --git a/src/components/navigation/GuideMapShell.vue b/src/components/navigation/GuideMapShell.vue
index 9e2b599..00ba475 100644
--- a/src/components/navigation/GuideMapShell.vue
+++ b/src/components/navigation/GuideMapShell.vue
@@ -9,8 +9,10 @@
class="indoor-three-map"
:asset-base-url="indoorAssetBaseUrl"
:model-source="effectiveIndoorModelSource"
+ :active-floor="activeFloorId"
:initial-floor-id="activeFloorId"
:initial-view="indoorInitialView"
+ :scene-view="indoorView"
:show-controls="false"
:show-poi="shouldShowIndoorPois"
:visible-poi-ids="visiblePoiIds"
@@ -19,15 +21,28 @@
:target-focus-distance-factor="targetFocusDistanceFactor"
:route-preview="routePreview"
:show-route="showRoute"
+ :route-navigation-active="routeNavigationActive"
+ :route-start-selection-active="routeStartSelectionActive"
+ :route-selectable-poi-ids="routeSelectablePoiIds"
+ :route-selectable-points="routeSelectablePoints"
+ :render-mode="indoorRenderMode"
+ :scene-revision="sceneRevision"
+ :scene-viewport="sceneViewport"
:disable-auto-exit="disableAutoExit"
@floor-change="handleThreeFloorChange"
@poi-click="handlePoiClick"
+ @route-start-candidate="handleRouteStartCandidate"
+ @route-start-candidate-rejected="handleRouteStartCandidateRejected"
+ @route-roaming-progress="handleRouteRoamingProgress"
+ @route-roaming-transfer="handleRouteRoamingTransfer"
@selection-clear="handleSelectionClear"
@target-focus="handleTargetFocus"
@auto-switch="handleAutoSwitch"
@initial-model-progress="handleInitialModelProgress"
@initial-model-ready="handleInitialModelReady"
@initial-model-failed="handleInitialModelFailed"
+ @render-mode-fallback="handleRenderModeFallback"
+ @scene-viewport-change="emit('sceneViewportChange', $event)"
/>
@@ -124,7 +139,7 @@
-
- ▱
- {{ layerModeActionLabel }}
-
+
+ ▱
+ {{ layerModeActionLabel }}
+
-
-
- +
+
+
+ {{ indoorRenderMode === 'three-d' ? '3D' : '2D' }}
-
-
- −
+
+
+ +
+
+
+
+ −
+
@@ -252,6 +288,12 @@ import type {
import {
isIndoorNavigableFloor
} from '@/domain/guideFloor'
+import { startGuidePerformance } from '@/services/performance/guidePerformance'
+import type {
+ RouteStartCandidatePayload,
+ RouteStartSelectablePoint
+} from '@/components/map/routeStartCandidateResolver'
+import type { GuideViewportState } from '@/composables/useGuideSceneState'
interface GuideFloorOption {
id: string
@@ -301,6 +343,7 @@ interface TargetPoiFocusResult {
type IndoorViewMode = 'overview' | 'floor' | 'multi'
type LayerDisplayMode = 'single' | 'multi'
type TouchGestureMode = 'orbit' | 'pan'
+type IndoorRenderMode = 'three-d' | 'two-d'
interface InitialModelProgressEvent {
progress: number
@@ -343,6 +386,10 @@ const props = withDefaults(defineProps<{
modeLayout?: 'full' | 'status'
modeStatus?: string
modeStatusTone?: 'solid' | 'glass'
+ indoorRenderMode?: IndoorRenderMode
+ sceneRevision?: number
+ sceneViewport?: GuideViewportState | null
+ showIndoorRenderModeToggle?: boolean
mapType?: 'indoor' | 'outdoor'
outdoorVariant?: 'home' | 'entrance'
indoorAssetBaseUrl?: string
@@ -356,6 +403,10 @@ const props = withDefaults(defineProps<{
targetFocusDistanceFactor?: number
routePreview?: GuideRouteResult | null
showRoute?: boolean
+ routeNavigationActive?: boolean
+ routeStartSelectionActive?: boolean
+ routeSelectablePoiIds?: string[]
+ routeSelectablePoints?: RouteStartSelectablePoint[]
disableAutoExit?: boolean
outdoorNavPolylines?: OutdoorNavPolyline[]
outdoorMarkers?: OutdoorMapMarker[]
@@ -394,6 +445,10 @@ const props = withDefaults(defineProps<{
modeLayout: 'full',
modeStatus: '',
modeStatusTone: 'solid',
+ indoorRenderMode: 'three-d',
+ sceneRevision: 0,
+ sceneViewport: null,
+ showIndoorRenderModeToggle: false,
mapType: 'indoor',
outdoorVariant: 'home',
indoorAssetBaseUrl: '',
@@ -407,6 +462,10 @@ const props = withDefaults(defineProps<{
targetFocusDistanceFactor: 0.36,
routePreview: null,
showRoute: false,
+ routeNavigationActive: false,
+ routeStartSelectionActive: false,
+ routeSelectablePoiIds: () => [] as string[],
+ routeSelectablePoints: () => [],
disableAutoExit: false,
outdoorNavPolylines: () => [] as OutdoorNavPolyline[],
outdoorMarkers: () => [] as OutdoorMapMarker[],
@@ -417,20 +476,51 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{
searchTap: []
modeChange: [mode: '2d' | '3d']
+ indoorRenderModeChange: [mode: IndoorRenderMode]
+ sceneViewportChange: [viewport: GuideViewportState]
floorRequest: [event: FloorSwitchEvent]
- floorChange: [floor: string]
+ floorChange: [floor: string, sceneRevision?: number]
floorSwitchFailed: [event: FloorSwitchEvent]
toolClick: [tool: string]
moreClick: []
- indoorViewChange: [view: IndoorViewMode]
+ indoorViewChange: [view: IndoorViewMode, sceneRevision?: number]
layerModeChange: [mode: LayerDisplayMode]
poiClick: [poi: GuideRenderPoi]
+ routeStartCandidate: [candidate: RouteStartCandidatePayload]
+ routeStartCandidateRejected: []
+ routeRoamingProgress: [event: { remainingMeters: number; progress: number }]
+ routeRoamingTransfer: [event: {
+ fromFloorId: string
+ toFloorId: string
+ transferType: string
+ connectorName?: string
+ }]
selectionClear: []
targetFocus: [result: TargetPoiFocusResult]
- autoSwitch: [event: { from: 'overview' | 'floor'; to: 'overview' | 'floor'; trigger: string; distance: number }]
+ autoSwitch: [event: {
+ from: 'overview' | 'floor'
+ to: 'overview' | 'floor'
+ trigger: string
+ distance: number
+ sceneRevision?: number
+ }]
initialModelProgress: [event: InitialModelProgressEvent]
initialModelReady: [event: { view: IndoorViewMode; floorId?: string; elapsedMs?: number }]
- initialModelFailed: [event: { view: IndoorViewMode; floorId?: string; message: string; elapsedMs?: number }]
+ initialModelFailed: [event: {
+ view: IndoorViewMode
+ floorId?: string
+ message: string
+ elapsedMs?: number
+ fallbackAvailable?: boolean
+ actualRenderMode?: IndoorRenderMode
+ }]
+ indoorRenderModeFallback: [event: {
+ mode: 'two-d'
+ view: 'overview' | 'floor'
+ floorId?: string
+ reason: string
+ sceneRevision: number
+ }]
mapTap: [location: { latitude: number; longitude: number }]
outdoorMarkerClick: [markerId: string]
}>()
@@ -454,8 +544,8 @@ watch(() => props.cameraView, (view) => {
const indoorRendererRef = ref<{
switchFloor?: (floorId: string) => Promise | void
- showOverview?: () => Promise | void
- showMultiFloor?: () => Promise | void
+ showOverview?: () => Promise | boolean | void
+ showMultiFloor?: () => Promise | boolean | void
resetCamera?: () => void
setCameraPreset?: (preset: 'top' | 'oblique') => void
zoomCamera?: (direction: 'in' | 'out', options?: { source?: 'button' | 'gesture' }) => void
@@ -468,6 +558,7 @@ const indoorRendererRef = ref<{
}) => Promise | ResetViewBaselineResult
resetToInitialState?: () => Promise | ResetViewBaselineResult
disableAutoSwitchTemporarily?: (durationMs: number) => void
+ getGuideViewportState?: () => GuideViewportState | null
} | null>(null)
const indoorFloors = computed(() => props.floors.filter((floor) => isIndoorNavigableFloor(floor)))
@@ -476,9 +567,46 @@ const floorItems = computed(() => indoorFloors.value)
const effectiveIndoorModelSource = computed(() => props.indoorModelSource)
// #endif
const showIndoorRightControls = computed(() => (
- props.mapType === 'indoor' && props.indoorView !== 'overview'
+ props.mapType === 'indoor'
+ && props.indoorView !== 'overview'
))
+const indoorRenderModeToggleLabel = computed(() => (
+ props.indoorRenderMode === 'three-d'
+ ? '当前三维地图,切换到二维地图'
+ : '当前二维地图,切换到三维地图'
+))
+
+const isCurrentRendererRevision = (sceneRevision: number | undefined) => (
+ sceneRevision === undefined || sceneRevision === props.sceneRevision
+)
+
+const emitFloorSceneCommit = (floorId: string, sceneRevision?: number) => {
+ if (sceneRevision === undefined) {
+ emit('floorChange', floorId)
+ } else {
+ emit('floorChange', floorId, sceneRevision)
+ }
+}
+
+const emitViewSceneCommit = (view: IndoorViewMode, sceneRevision?: number) => {
+ if (sceneRevision === undefined) {
+ emit('indoorViewChange', view)
+ } else {
+ emit('indoorViewChange', view, sceneRevision)
+ }
+}
+
+const toggleIndoorRenderMode = () => {
+ const now = Date.now()
+ if (now - lastRenderModeToggleAt < 100) return
+ lastRenderModeToggleAt = now
+ emit('indoorRenderModeChange', props.indoorRenderMode === 'three-d' ? 'two-d' : 'three-d')
+}
+
+let lastRenderModeToggleAt = 0
+let lastZoomActionAt = 0
+
const activeFloorId = computed(() => {
const matchedFloor = indoorFloors.value.find((floor) => (
floor.id === props.activeFloor || floor.label === props.activeFloor
@@ -499,6 +627,33 @@ const activeFloorSwitchRequestSeq = ref(0)
const renderedFloorSwitchRequestSeq = ref(0)
const failedFloorId = ref('')
let floorSwitchRequestSeq = 0
+let activeFloorSwitchPerformance: {
+ requestSeq: number
+ floorId: string
+ finish: ReturnType
+} | null = null
+
+const startFloorSwitchPerformance = (requestSeq: number, floorId: string, source: string) => {
+ activeFloorSwitchPerformance?.finish('cancelled', { reason: 'superseded' })
+ activeFloorSwitchPerformance = {
+ requestSeq,
+ floorId,
+ finish: startGuidePerformance('interaction', 'floor-switch', { floorId, source })
+ }
+}
+
+const finishFloorSwitchPerformance = (
+ requestSeq: number,
+ floorId: string,
+ outcome: 'success' | 'failure' | 'cancelled',
+ detail?: Record
+) => {
+ const measurement = activeFloorSwitchPerformance
+ if (!measurement || measurement.requestSeq !== requestSeq || measurement.floorId !== floorId) return
+
+ activeFloorSwitchPerformance = null
+ measurement.finish(outcome, detail)
+}
const searchFieldStyle = computed(() => ({
top: props.searchTop
@@ -533,8 +688,11 @@ const moreControlStyle = computed(() => ({
const shouldShowIndoorPois = computed(() => (
props.mapType === 'indoor'
) || Boolean(props.targetFocusRequest))
+const effectiveLayerMode = computed(() => (
+ props.indoorView === 'multi' ? 'multi' : 'single'
+))
const nextLayerMode = computed(() => (
- props.layerMode === 'multi' ? 'single' : 'multi'
+ effectiveLayerMode.value === 'multi' ? 'single' : 'multi'
))
const layerModeActionLabel = computed(() => (
nextLayerMode.value === 'multi' ? '多层' : '单层'
@@ -575,6 +733,7 @@ const markFloorSwitchFailedIfUnrendered = (floorId: string, requestSeq: number)
failedFloorId.value = floorId
clearFloorLoadingIfCurrent(floorId)
+ finishFloorSwitchPerformance(requestSeq, floorId, 'failure', { reason: 'renderer-not-committed' })
emit('floorSwitchFailed', {
floorId,
floorLabel: requestedFloorId.value === floorId
@@ -583,21 +742,17 @@ const markFloorSwitchFailedIfUnrendered = (floorId: string, requestSeq: number)
})
}
-const requestFloorSwitch = (
- floor: { id: string; label: string },
- options: { force?: boolean } = {}
-) => {
+const handleFloorChange = (floor: { id: string; label: string }) => {
const floorId = floor.id
- if (!floorId) return Promise.resolve()
- if (loadingFloorId.value === floorId) return Promise.resolve()
+ if (!floorId || loadingFloorId.value) return
if (
- !options.force && floorId === renderedFloorId.value
+ floorId === renderedFloorId.value
&& activeFloorId.value === floorId
&& props.indoorView === 'floor'
&& props.layerMode !== 'multi'
) {
- emit('floorChange', floorId)
- return Promise.resolve()
+ emit('floorChange', floorId, props.sceneRevision)
+ return
}
requestedFloorId.value = floorId
@@ -605,13 +760,14 @@ const requestFloorSwitch = (
loadingFloorId.value = floorId
const requestSeq = ++floorSwitchRequestSeq
activeFloorSwitchRequestSeq.value = requestSeq
+ startFloorSwitchPerformance(requestSeq, floorId, 'manual')
failedFloorId.value = ''
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(manualAutoSwitchPauseMs)
emit('floorRequest', {
floorId,
floorLabel: floor.label
})
- return Promise.resolve(indoorRendererRef.value?.switchFloor?.(floorId))
+ Promise.resolve(indoorRendererRef.value?.switchFloor?.(floorId))
.then(() => {
markFloorSwitchFailedIfUnrendered(floorId, requestSeq)
})
@@ -619,6 +775,9 @@ const requestFloorSwitch = (
if (isStaleFloorSwitchError(error)) return
failedFloorId.value = floorId
clearFloorLoadingIfCurrent(floorId)
+ finishFloorSwitchPerformance(requestSeq, floorId, 'failure', {
+ error: error instanceof Error ? error.name : String(error)
+ })
emit('floorSwitchFailed', {
floorId,
floorLabel: floor.label
@@ -627,11 +786,13 @@ const requestFloorSwitch = (
})
}
-const handleFloorChange = (floor: { id: string; label: string }) => {
- void requestFloorSwitch(floor)
-}
+const handleLayerModeChange = async (mode: LayerDisplayMode) => {
+ if (props.indoorRenderMode === 'two-d' && mode === 'multi') return
+ // Cross-floor navigation owns its presentation. A vertically exploded
+ // multi-floor model cannot keep route, marker and DOM-label coordinates in
+ // one system, so visitors cannot enter that mode during route guidance.
+ if (props.showRoute) return
-const handleLayerModeChange = (mode: LayerDisplayMode) => {
// 手动切换展示层数时使用统一的短保护期。
indoorRendererRef.value?.disableAutoSwitchTemporarily?.(manualAutoSwitchPauseMs)
@@ -639,14 +800,18 @@ const handleLayerModeChange = (mode: LayerDisplayMode) => {
loadingFloorId.value = ''
requestedFloorId.value = ''
requestedFloorLabel.value = ''
- void indoorRendererRef.value?.showMultiFloor?.()
- emit('indoorViewChange', 'multi')
+ const committed = await indoorRendererRef.value?.showMultiFloor?.()
+ if (committed === false) return
+ emit('indoorViewChange', 'multi', props.sceneRevision)
} else {
- const floorId = activeFloorId.value
+ // The parent floor can lag while a search/detail transaction is closing or
+ // while multi-floor is committing. The last renderer-confirmed floor is the
+ // authoritative return target in that window.
+ const floorId = activeFloorId.value || renderedFloorId.value
const floorLabel = findFloorItemById(floorId)?.label || floorId
if (!floorId) return
- if (floorId === renderedFloorId.value && props.layerMode !== 'multi') {
- emit('indoorViewChange', 'floor')
+ if (floorId === renderedFloorId.value && props.indoorView === 'floor') {
+ emit('indoorViewChange', 'floor', props.sceneRevision)
emit('layerModeChange', mode)
return
}
@@ -656,20 +821,62 @@ const handleLayerModeChange = (mode: LayerDisplayMode) => {
loadingFloorId.value = floorId
const requestSeq = ++floorSwitchRequestSeq
activeFloorSwitchRequestSeq.value = requestSeq
+ startFloorSwitchPerformance(requestSeq, floorId, 'multi-to-single')
failedFloorId.value = ''
emit('floorRequest', {
floorId,
floorLabel
})
+
+ // A multi-floor view owns a temporary exploded model group. Returning to
+ // one floor must restore the renderer's floor baseline transaction so the
+ // model, POI layer, camera, and floor state commit together.
+ if (indoorRendererRef.value?.resetToViewBaseline) {
+ try {
+ const resetResult = await indoorRendererRef.value.resetToViewBaseline({
+ view: 'floor',
+ floorId,
+ reason: 'floor-reset'
+ })
+ if (resetResult !== 'applied') {
+ finishFloorSwitchPerformance(requestSeq, floorId, 'cancelled', { reason: resetResult })
+ return
+ }
+
+ renderedFloorId.value = floorId
+ clearFloorLoadingIfCurrent(floorId)
+ finishFloorSwitchPerformance(requestSeq, floorId, 'success', { source: 'view-baseline' })
+ emit('floorChange', floorId, props.sceneRevision)
+ emit('indoorViewChange', 'floor', props.sceneRevision)
+ emit('layerModeChange', mode)
+ } catch (error) {
+ if (isStaleFloorSwitchError(error)) return
+ failedFloorId.value = floorId
+ clearFloorLoadingIfCurrent(floorId)
+ finishFloorSwitchPerformance(requestSeq, floorId, 'failure', {
+ error: error instanceof Error ? error.name : String(error)
+ })
+ emit('floorSwitchFailed', {
+ floorId,
+ floorLabel
+ })
+ console.error('恢复单层楼层失败:', error)
+ }
+ return
+ }
+
Promise.resolve(indoorRendererRef.value?.switchFloor?.(floorId))
.then(() => {
markFloorSwitchFailedIfUnrendered(floorId, requestSeq)
})
.catch((error) => {
- if (isStaleFloorSwitchError(error)) return
- failedFloorId.value = floorId
- clearFloorLoadingIfCurrent(floorId)
- emit('floorSwitchFailed', {
+ if (isStaleFloorSwitchError(error)) return
+ failedFloorId.value = floorId
+ clearFloorLoadingIfCurrent(floorId)
+ finishFloorSwitchPerformance(requestSeq, floorId, 'failure', {
+ error: error instanceof Error ? error.name : String(error)
+ })
+ emit('floorSwitchFailed', {
floorId,
floorLabel
})
@@ -684,11 +891,17 @@ const handleLayerModeChange = (mode: LayerDisplayMode) => {
}
const handleLayerModeToggle = () => {
- handleLayerModeChange(nextLayerMode.value)
+ void handleLayerModeChange(nextLayerMode.value)
}
+let lastFloorHeaderInteractionAt = 0
const handleFloorHeaderTap = () => {
- handleLayerModeChange(props.layerMode === 'multi' ? 'single' : 'multi')
+ const now = Date.now()
+ if (now - lastFloorHeaderInteractionAt < 250) return
+ lastFloorHeaderInteractionAt = now
+ const currentLayerMode = effectiveLayerMode.value
+ const nextMode = currentLayerMode === 'multi' ? 'single' : 'multi'
+ void handleLayerModeChange(nextMode)
}
const handleToolClick = (tool: string) => {
@@ -718,6 +931,9 @@ const toolIconType = (tool: string) => {
}
const handleZoomClick = (direction: 'in' | 'out') => {
+ const now = Date.now()
+ if (now - lastZoomActionAt < 100) return
+ lastZoomActionAt = now
indoorRendererRef.value?.zoomCamera?.(direction, { source: 'button' })
emit('toolClick', direction === 'in' ? '放大' : '缩小')
}
@@ -728,9 +944,10 @@ const handleShowOverview = async () => {
requestedFloorId.value = ''
requestedFloorLabel.value = ''
failedFloorId.value = ''
- await indoorRendererRef.value?.showOverview?.()
+ const committed = await indoorRendererRef.value?.showOverview?.()
+ if (committed === false) return
emit('selectionClear')
- emit('indoorViewChange', 'overview')
+ emit('indoorViewChange', 'overview', props.sceneRevision)
emit('layerModeChange', 'single')
}
@@ -738,17 +955,22 @@ const handleMoreTap = () => {
emit('moreClick')
}
-const handleThreeFloorChange = (floorId: string) => {
+const handleThreeFloorChange = (floorId: string, sceneRevision?: number) => {
+ if (!isCurrentRendererRevision(sceneRevision)) return
+ // A foreground multi-floor commit invalidates any earlier floor request.
+ // Do not let its late event roll the Shell back to single-floor controls.
+ if (effectiveLayerMode.value === 'multi') return
renderedFloorId.value = floorId
if (loadingFloorId.value === floorId) {
renderedFloorSwitchRequestSeq.value = activeFloorSwitchRequestSeq.value
+ finishFloorSwitchPerformance(activeFloorSwitchRequestSeq.value, floorId, 'success')
}
if (failedFloorId.value === floorId) {
failedFloorId.value = ''
}
clearFloorLoadingIfCurrent(floorId)
- emit('floorChange', floorId)
- emit('indoorViewChange', 'floor')
+ emitFloorSceneCommit(floorId, sceneRevision)
+ emitViewSceneCommit('floor', sceneRevision)
emit('layerModeChange', 'single')
}
@@ -756,6 +978,27 @@ const handlePoiClick = (poi: GuideRenderPoi) => {
emit('poiClick', poi)
}
+const handleRouteRoamingProgress = (event: { remainingMeters: number; progress: number }) => {
+ emit('routeRoamingProgress', event)
+}
+
+const handleRouteRoamingTransfer = (event: {
+ fromFloorId: string
+ toFloorId: string
+ transferType: string
+ connectorName?: string
+}) => {
+ emit('routeRoamingTransfer', event)
+}
+
+const handleRouteStartCandidate = (candidate: RouteStartCandidatePayload) => {
+ emit('routeStartCandidate', candidate)
+}
+
+const handleRouteStartCandidateRejected = () => {
+ emit('routeStartCandidateRejected')
+}
+
const handleSelectionClear = () => {
emit('selectionClear')
}
@@ -764,7 +1007,14 @@ const handleTargetFocus = (result: TargetPoiFocusResult) => {
emit('targetFocus', result)
}
-const handleAutoSwitch = (event: { from: 'overview' | 'floor'; to: 'overview' | 'floor'; trigger: string; distance: number }) => {
+const handleAutoSwitch = (event: {
+ from: 'overview' | 'floor'
+ to: 'overview' | 'floor'
+ trigger: string
+ distance: number
+ sceneRevision?: number
+}) => {
+ if (!isCurrentRendererRevision(event.sceneRevision)) return
emit('autoSwitch', event)
}
@@ -773,13 +1023,33 @@ const handleInitialModelProgress = (event: InitialModelProgressEvent) => {
}
const handleInitialModelReady = (event: { view: IndoorViewMode; floorId?: string; elapsedMs?: number }) => {
+ if (event.view === 'floor' && event.floorId) renderedFloorId.value = event.floorId
emit('initialModelReady', event)
}
-const handleInitialModelFailed = (event: { view: IndoorViewMode; floorId?: string; message: string; elapsedMs?: number }) => {
+const handleInitialModelFailed = (event: {
+ view: IndoorViewMode
+ floorId?: string
+ message: string
+ elapsedMs?: number
+ fallbackAvailable?: boolean
+ actualRenderMode?: IndoorRenderMode
+}) => {
emit('initialModelFailed', event)
}
+const handleRenderModeFallback = (event: {
+ mode: 'two-d'
+ view: 'overview' | 'floor'
+ floorId?: string
+ reason: string
+ sceneRevision: number
+}) => {
+ if (!isCurrentRendererRevision(event.sceneRevision)) return
+ if (event.view === 'floor' && event.floorId) renderedFloorId.value = event.floorId
+ emit('indoorRenderModeFallback', event)
+}
+
const handleMapTap = (location: { latitude?: number; longitude?: number }) => {
const { latitude, longitude } = location
if (latitude !== undefined && longitude !== undefined) {
@@ -796,12 +1066,16 @@ defineExpose({
clearRoute: () => {
indoorRendererRef.value?.clearRoute?.()
},
- // 仅发起切换;父级必须以 floor-change 作为已提交的唯一依据。
+ clearSelection: () => {
+ indoorRendererRef.value?.clearSelection?.(false)
+ },
+ // 通过 Shell 的事务入口发起切换;父级仍以 floor-change 为唯一提交依据。
switchFloor: (floorId: string) => {
const floor = findFloorItemById(floorId)
- if (!floor) return Promise.resolve()
- return requestFloorSwitch(floor, { force: true })
+ if (!floor) return
+ return handleFloorChange(floor)
},
+ setLayerMode: handleLayerModeChange,
showOverview: handleShowOverview,
resetToViewBaseline: (options: {
view: 'overview' | 'floor'
@@ -1026,6 +1300,37 @@ defineExpose({
z-index: 40;
}
+.map-zoom-stack {
+ position: absolute;
+ right: 18px;
+ z-index: 35;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 6px;
+ transform: translateY(-40px);
+}
+
+.indoor-render-mode-toggle {
+ width: 44px;
+ height: 36px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
+ color: #ffffff;
+ background: #3f6fc4;
+ border: 1px solid rgba(21, 101, 192, 0.4);
+ border-radius: 4px;
+ box-shadow: 0 3px 10px rgba(26, 35, 126, 0.14);
+}
+
+.indoor-render-mode-toggle text {
+ font-size: 12px;
+ line-height: 16px;
+ font-weight: 700;
+}
+
.guide-mode-row.layout-full {
display: flex;
align-items: center;
@@ -1109,7 +1414,8 @@ defineExpose({
.floor-switcher {
position: absolute;
- width: 44px;
+ /* Keep both side rails on the same outer width, including their borders. */
+ width: 46px;
height: auto;
padding: 1px;
box-sizing: border-box;
@@ -1137,8 +1443,8 @@ defineExpose({
.floor-header {
position: relative;
- min-height: 38px;
- padding: 5px 4px 4px;
+ min-height: 50px;
+ padding: 6px 0 5px;
display: flex;
flex-direction: column;
align-items: center;
@@ -1150,7 +1456,8 @@ defineExpose({
}
.floor-header.active {
- background: #000000;
+ background: #edf1ff;
+ box-shadow: inset 0 0 0 1px #c7d2f4;
}
.floor-header::after {
@@ -1164,13 +1471,15 @@ defineExpose({
}
.floor-header-icon {
- width: 16px;
- height: 16px;
position: relative;
+ flex: 0 0 18px;
+ width: 19px;
+ height: 18px;
display: block;
color: #151713;
font-size: 0;
line-height: 0;
+ z-index: 2;
}
.floor-header-icon::before,
@@ -1183,32 +1492,48 @@ defineExpose({
}
.floor-header-icon::before {
- left: 2px;
- top: 4px;
- width: 11px;
- height: 9px;
+ left: 1px;
+ top: 5px;
+ width: 13px;
+ height: 10px;
}
.floor-header-icon::after {
left: 5px;
top: 1px;
- width: 11px;
- height: 9px;
+ width: 13px;
+ height: 10px;
background: #ffffff;
}
.floor-header.active .floor-header-icon {
- color: var(--museum-accent);
+ color: #1a237e;
+}
+
+.floor-header.active .floor-header-icon::after {
+ background: #edf1ff;
}
.floor-header-label {
- font-size: 10px;
- line-height: 12px;
+ position: static;
+ flex: 0 0 13px;
+ width: 100%;
+ height: 13px;
+ padding: 0;
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ white-space: nowrap;
+ font-size: 11px;
+ line-height: 13px;
color: #545861;
+ z-index: 1;
}
.floor-header.active .floor-header-label {
- color: var(--museum-accent);
+ color: #1a237e;
+ font-weight: 700;
}
.layer-mode-toggle {
@@ -1266,7 +1591,7 @@ defineExpose({
}
.floor-item.active {
- background: #000000;
+ background: var(--museum-accent);
}
.floor-item.pending {
@@ -1299,7 +1624,8 @@ defineExpose({
}
.floor-item.active .floor-label {
- color: var(--museum-accent);
+ color: #151713;
+ font-weight: 700;
}
.tool-stack {
@@ -1345,15 +1671,13 @@ defineExpose({
}
.zoom-controls {
- position: absolute;
- right: 18px;
- width: 48px;
+ width: 46px;
+ box-sizing: border-box;
overflow: hidden;
background: rgba(255, 255, 255, 0.94);
border: 1px solid #dde5df;
border-radius: 8px;
box-shadow: 0 8px 18px rgba(110, 127, 115, 0.12);
- z-index: 35;
}
.zoom-btn {
diff --git a/src/components/navigation/RoutePlannerPanel.vue b/src/components/navigation/RoutePlannerPanel.vue
index be56f67..6efddb2 100644
--- a/src/components/navigation/RoutePlannerPanel.vue
+++ b/src/components/navigation/RoutePlannerPanel.vue
@@ -1,726 +1,115 @@
-
-
-
-
-
- 馆内导览
- {{ collapsedSummary }}
+
+
+
+ 馆内导览
+ {{ panelSubtitle }}
-
- 展开
+
+ ×
-
-
-
- 馆内导览
- {{ summary }}
-
-
-
- 返回
+
+
+
+ {{ normalizeVisitorPoiDisplayName(startPoint.name) }}
+ 起点 · {{ pointMeta(startPoint) }}
+
+
+
+
+ 正在生成路线...
+
+
+ {{ error }}
+
+
+ {{ summary }}
+
+ 模拟导览
+
+
+
+
+
+ 请点击地图选择起点
+ 仅可选择已接入馆内路网的地点
+
+
+
+
+
+ 确认选择
+
+
+
+ 取消
-
- 收起
-
-
- 清除
+
+ 确定
-
-
-
-
- 起点
-
- {{ startPoint?.name || '选择起点' }}
-
- {{ pointMeta(startPoint) }}
-
-
-
- ⇅
-
-
-
-
- 终点
-
- {{ endPoint?.name || '选择终点' }}
-
- {{ pointMeta(endPoint) }}
-
-
-
-
-
- {{ routeOptionsTitle }}
- 偏好选择
-
-
-
- {{ option.title }}
- {{ option.meta }}
-
-
-
-
-
- {{ loadingText }}
- {{ error }}
- {{ summary }}
-
-
-
- {{ primaryActionText }}
-
-
-
-
+
diff --git a/src/components/navigation/RoutePointPicker.vue b/src/components/navigation/RoutePointPicker.vue
index 003f74e..fdcb3be 100644
--- a/src/components/navigation/RoutePointPicker.vue
+++ b/src/components/navigation/RoutePointPicker.vue
@@ -68,13 +68,13 @@
- {{ option.name }}
+ {{ normalizeVisitorPoiDisplayName(option.name) }}
{{ formatMeta(option) }}
@@ -94,13 +94,20 @@ import {
compareFloorsTopToBottom,
isIndoorNavigableFloor
} from '@/domain/guideFloor'
+import { normalizeVisitorPoiDisplayName } from '@/view-models/visitorPoiPresentation'
export interface RoutePointOption {
+ routeTargetId?: string
poiId: string
+ sourceId?: string
name: string
floorId: string
floorLabel: string
categoryLabel?: string
+ positionGltf?: [number, number, number]
+ routeNodeId?: string
+ /** 点击地图对象后由路线服务按坐标吸附到正式路网节点。 */
+ coordinateFallback?: boolean
}
const props = withDefaults(defineProps<{
diff --git a/src/components/search/PoiSearchPanel.vue b/src/components/search/PoiSearchPanel.vue
index 410fde9..a8accdf 100644
--- a/src/components/search/PoiSearchPanel.vue
+++ b/src/components/search/PoiSearchPanel.vue
@@ -18,7 +18,7 @@
‹
- 点位搜索
+ 地图导览
@@ -39,7 +39,7 @@
@input="handleSearchInput"
@confirm="handleSearchConfirm"
/>
-
+
×
@@ -55,6 +55,7 @@
class="home-category-chip"
:class="{ active: activeCategoryId === item.id, disabled: isCategoryDisabled(item) }"
:data-testid="`poi-category-${item.id}`"
+ :data-category="item.id"
:aria-disabled="isCategoryDisabled(item)"
@tap="handleFacilityShortcut(item)"
>
@@ -76,7 +77,7 @@
{{ activeCategory?.label || searchKeyword }}
- 当前楼层 {{ activeFloor || '待确认' }} · {{ floorResults.length }} 个点位
+ 当前楼层 {{ activeFloor || '待确认' }} · {{ floorResults.length }} 处地点
@@ -158,7 +160,7 @@
深圳自然博物馆
- 当前楼层 {{ floorResults.length }} 个点位
+ 当前楼层 {{ floorResults.length }} 处地点
{{ dataWarning }}
@@ -231,9 +233,6 @@ import {
import {
guideUseCase
} from '@/usecases/guideUseCase'
-import {
- createVisitorPoiPresentations
-} from '@/view-models/visitorPoiPresentation'
import {
HOME_POI_CATEGORIES,
POI_CATEGORIES,
@@ -248,8 +247,8 @@ import type {
PoiCategoryResultState,
PoiSearchContext
} from '@/domain/poiSearch'
-import { nextHomeSearchResultVersion } from './homeSearchResultVersion'
import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
+import { normalizeVisitorPoiDisplayName } from '@/view-models/visitorPoiPresentation'
const props = withDefaults(defineProps<{
initialKeyword?: string
@@ -304,6 +303,7 @@ const duplicatePoiCount = ref(0)
const resultListScrollTop = ref(0)
const resultListRef = ref(null)
let searchRequestSeq = 0
+let pendingSearchFloorId = ''
const collapseDragThreshold = 48
const homeExpandTapGuardMs = 360
@@ -328,16 +328,20 @@ const displayFloors = computed(() => [...floors.value]
.filter(isIndoorNavigableFloor)
.sort(compareFloorsTopToBottom))
-const floorResults = computed(() => (
- activeFloor.value
- ? pois.value.filter((poi) => poi.floorLabel === activeFloor.value)
- : []
+const activeFloorOption = computed(() => (
+ displayFloors.value.find((floor) => floor.label === activeFloor.value) || null
))
+// GuidePoiSearchViewState.results is already the canonical, current-floor
+// render list. Result POIs may carry a backend floor alias, so filtering them
+// again through the temporarily selected UI option can incorrectly erase a
+// valid floor response during floor synchronization.
+const floorResults = computed(() => pois.value)
+
const emptyTitle = computed(() => {
- if (isLoading.value) return '正在读取点位'
+ if (isLoading.value) return '正在读取地点'
if (loadError.value) return loadError.value
- return '当前楼层暂无匹配点位'
+ return '当前楼层暂无匹配地点'
})
const emptyDesc = computed(() => {
@@ -366,14 +370,9 @@ const dataWarning = computed(() => {
const isCategoryDisabled = (category: PoiCategoryDefinition) => {
const state = categoryStatesById.value.get(category.id)
return isLoading.value
- || !state
- || state.disabled
+ || Boolean(state?.disabled)
}
-const activeFloorOption = computed(() => (
- displayFloors.value.find((floor) => floor.label === activeFloor.value) || null
-))
-
const createSearchContext = (): PoiSearchContext => {
const visiblePoiIds = searchViewState.value?.visiblePoiIds || []
const resultCount = pois.value.length
@@ -398,8 +397,7 @@ const emitResultsState = () => {
emit('results-change', {
...context,
visiblePoiIds: active ? context.visiblePoiIds : [],
- active,
- requestId: props.variant === 'home' ? nextHomeSearchResultVersion() : undefined
+ active
})
}
@@ -413,8 +411,7 @@ const emitPendingHomeSearchResults = (floor?: Pick)
floorLabel: floor?.label || context.floorLabel,
visiblePoiIds: [],
active: true,
- pending: true,
- requestId: nextHomeSearchResultVersion()
+ pending: true
})
}
@@ -466,36 +463,48 @@ const activeFloorId = () => (
const commitPoiSearchViewState = async (
state: GuidePoiSearchViewState,
- requestSeq: number
+ requestSeq: number,
+ requestedFloorId: string
) => {
- if (requestSeq !== searchRequestSeq) return
+ if (
+ requestSeq !== searchRequestSeq
+ || requestedFloorId !== pendingSearchFloorId
+ || state.floorId !== requestedFloorId
+ ) return false
searchViewState.value = state
pois.value = state.results
activeFloor.value = state.floorLabel
+ pendingSearchFloorId = ''
duplicatePoiCount.value = 0
excludedPoiCount.value = 0
await nextTick()
- if (requestSeq !== searchRequestSeq) return
+ if (requestSeq !== searchRequestSeq) return false
emitResultsState()
+ return true
}
const runPoiSearchRequest = async (
- loader: () => Promise
+ loader: () => Promise,
+ requestedFloorId = activeFloorId()
) => {
const requestSeq = ++searchRequestSeq
+ pendingSearchFloorId = requestedFloorId
isLoading.value = true
searchError.value = ''
try {
const state = await loader()
- await commitPoiSearchViewState(state, requestSeq)
+ await commitPoiSearchViewState(state, requestSeq, requestedFloorId)
return state
} catch (error) {
await handleLoadFailure(requestSeq, error, '加载点位搜索结果失败:')
return null
} finally {
- if (requestSeq === searchRequestSeq) isLoading.value = false
+ if (requestSeq === searchRequestSeq) {
+ pendingSearchFloorId = ''
+ isLoading.value = false
+ }
}
}
@@ -521,18 +530,23 @@ const handleLoadFailure = async (requestSeq: number, error: unknown, message: st
}
const loadInitialSpacePoints = async () => {
+ const requestedFloorId = activeFloorId()
const requestSeq = ++searchRequestSeq
+ pendingSearchFloorId = requestedFloorId
isLoading.value = true
searchError.value = ''
try {
- const state = await guideUseCase.createInitialPoiSearchState(activeFloorId())
+ const state = await guideUseCase.createInitialPoiSearchState(requestedFloorId)
if (requestSeq !== searchRequestSeq) return
- await commitPoiSearchViewState(state, requestSeq)
+ await commitPoiSearchViewState(state, requestSeq, requestedFloorId)
} catch (error) {
await handleLoadFailure(requestSeq, error, '加载点位基础数据失败:')
} finally {
- if (requestSeq === searchRequestSeq) isLoading.value = false
+ if (requestSeq === searchRequestSeq) {
+ pendingSearchFloorId = ''
+ isLoading.value = false
+ }
}
}
@@ -542,7 +556,9 @@ const loadPois = async (keyword = '') => {
return
}
+ const requestedFloorId = activeFloorId()
const requestSeq = ++searchRequestSeq
+ pendingSearchFloorId = requestedFloorId
isLoading.value = true
searchError.value = ''
if (props.variant === 'home') {
@@ -554,14 +570,17 @@ const loadPois = async (keyword = '') => {
const state = await guideUseCase.searchPoiKeyword(
searchViewState.value,
keyword,
- activeFloorId()
+ requestedFloorId
)
if (requestSeq !== searchRequestSeq) return
- await commitPoiSearchViewState(state, requestSeq)
+ await commitPoiSearchViewState(state, requestSeq, requestedFloorId)
} catch (error) {
await handleLoadFailure(requestSeq, error, '加载点位搜索结果失败:')
} finally {
- if (requestSeq === searchRequestSeq) isLoading.value = false
+ if (requestSeq === searchRequestSeq) {
+ pendingSearchFloorId = ''
+ isLoading.value = false
+ }
}
}
@@ -571,6 +590,7 @@ const refreshCurrentPoiSearchFloor = async (floorLabel: string) => {
activeFloor.value = floor.label
resultListScrollTop.value = 0
+ pois.value = []
const hasExplicitQuery = searchViewState.value?.mode === 'category'
|| searchViewState.value?.mode === 'keyword'
if (props.variant === 'home' && hasExplicitQuery) {
@@ -580,7 +600,7 @@ const refreshCurrentPoiSearchFloor = async (floorLabel: string) => {
await runPoiSearchRequest(() => guideUseCase.changePoiSearchFloor(
searchViewState.value,
floor.id
- ))
+ ), floor.id)
}
const focusSearchInput = async () => {
@@ -754,7 +774,7 @@ const enterFullSearch = () => {
if (props.variant !== 'home' || homeExpanded.value) return
const cameFromCategory = homeCategoryMode.value
expandHomePanel()
- if (cameFromCategory) {
+ if (cameFromCategory || !searchViewState.value) {
activeCategoryId.value = ''
searchKeyword.value = ''
searchDraftKeyword.value = ''
@@ -806,7 +826,9 @@ const searchShortcut = async (
: categoryInput
if (!category || isCategoryDisabled(category)) return
+ const requestedFloorId = activeFloorId()
const requestSeq = ++searchRequestSeq
+ pendingSearchFloorId = requestedFloorId
const useHomeResultList = props.variant === 'home'
&& (options.homeCategoryResults ?? !homeExpanded.value)
if (useHomeResultList) {
@@ -832,14 +854,17 @@ const searchShortcut = async (
const state = await guideUseCase.selectPoiSearchCategory(
searchViewState.value,
category.id,
- activeFloorId()
+ requestedFloorId
)
if (requestSeq !== searchRequestSeq) return
- await commitPoiSearchViewState(state, requestSeq)
+ await commitPoiSearchViewState(state, requestSeq, requestedFloorId)
} catch (error) {
await handleLoadFailure(requestSeq, error, '加载点位分类搜索结果失败:')
} finally {
- if (requestSeq === searchRequestSeq) isLoading.value = false
+ if (requestSeq === searchRequestSeq) {
+ pendingSearchFloorId = ''
+ isLoading.value = false
+ }
}
}
@@ -895,14 +920,15 @@ const restoreResultListScroll = async () => {
if (resultListElement) resultListElement.scrollTop = scrollTop
}
-const poiPresentations = computed(() => createVisitorPoiPresentations(pois.value))
const poiDisplayName = (poi: MuseumPoi) => (
- poiPresentations.value.get(poi.id)?.displayName || '未命名点位'
+ normalizeVisitorPoiDisplayName(poi.name) || '未命名点位'
)
const poiResultMeta = (poi: MuseumPoi) => {
const categoryLabel = resolvePoiCategory(poi)?.label || poi.primaryCategory?.label || '其他'
- return isPoiLocatable(poi) ? categoryLabel : `${categoryLabel} · 暂无地图坐标`
+ const floorLabel = poi.floorLabel?.trim() || poi.floorId
+ const locationMeta = [floorLabel, categoryLabel].filter(Boolean).join(' · ')
+ return isPoiLocatable(poi) ? locationMeta : `${locationMeta} · 暂无地图坐标`
}
const encodeQueryValue = (value: string | number) => encodeURIComponent(String(value))
@@ -989,7 +1015,42 @@ watch([() => props.currentFloorId, () => props.currentFloorLabel], () => {
floor.id === props.currentFloorId
|| floor.label === props.currentFloorLabel
))
- if (!requestedFloor || requestedFloor.id === searchViewState.value?.floorId) return
+ if (!requestedFloor) return
+
+ // 折叠态只同步下一次查询的目标楼层;若查询界面已有请求在途,
+ // 则立即让旧楼层请求失效,并按当前搜索模式重发新楼层请求。
+ if (!searchViewState.value) {
+ activeFloor.value = requestedFloor.label
+ if (pendingSearchFloorId && pendingSearchFloorId !== requestedFloor.id) {
+ searchRequestSeq += 1
+ pendingSearchFloorId = ''
+ isLoading.value = false
+ pois.value = []
+
+ if (activeCategory.value) {
+ void searchShortcut(activeCategory.value, {
+ homeCategoryResults: homeCategoryMode.value
+ })
+ } else if (searchKeyword.value) {
+ void loadPois(searchKeyword.value)
+ } else if (showSearchContent.value) {
+ void loadInitialSpacePoints()
+ }
+ }
+ return
+ }
+
+ if (requestedFloor.id === searchViewState.value.floorId) {
+ if (pendingSearchFloorId && pendingSearchFloorId !== requestedFloor.id) {
+ searchRequestSeq += 1
+ pendingSearchFloorId = ''
+ isLoading.value = false
+ activeFloor.value = requestedFloor.label
+ pois.value = searchViewState.value.results
+ emitResultsState()
+ }
+ return
+ }
void refreshCurrentPoiSearchFloor(requestedFloor.label)
})
@@ -1002,12 +1063,17 @@ watch(showSearchContent, (expanded) => {
onMounted(async () => {
await loadFloors()
- await applyInitialKeyword(props.initialKeyword)
+ // 首页默认是折叠态。先只同步楼层,避免首屏为了填充未展开的搜索结果而读取全馆点位。
+ // 用户展开搜索、点击分类或提交关键词时再按需建立搜索状态。
+ if (props.variant === 'page' || props.initialKeyword.trim()) {
+ await applyInitialKeyword(props.initialKeyword)
+ }
if (props.autofocus) void focusSearchInput()
})
onUnmounted(() => {
searchRequestSeq += 1
+ pendingSearchFloorId = ''
// #ifdef H5
setH5HomeSearchLock(false)
// #endif
@@ -1132,9 +1198,9 @@ defineExpose({
}
.variant-home .search-box {
- background: #f5f5ed;
- border-color: rgba(224, 225, 0, 0.72);
- box-shadow: none;
+ background: rgba(255, 255, 251, 0.9);
+ border-color: rgba(224, 225, 0, 0.58);
+ box-shadow: 0 5px 14px rgba(38, 49, 43, 0.07);
}
.variant-home.is-collapsed .search-box {
@@ -1164,23 +1230,23 @@ defineExpose({
.home-category-chip {
min-width: calc((100% - 24px) / 5);
flex: 0 0 calc((100% - 24px) / 5);
- min-height: 68px;
+ min-height: 64px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
- gap: 4px;
- padding: 6px 2px 5px;
+ gap: 5px;
+ padding: 6px 2px 4px;
box-sizing: border-box;
- background: #f5f5ed;
- border: 1px solid #dfe2d7;
+ background: var(--shortcut-surface, rgba(255, 255, 251, 0.76));
+ border: 1px solid var(--shortcut-border, rgba(26, 35, 126, 0.12));
border-radius: 8px;
- box-shadow: 0 2px 5px rgba(21, 23, 19, 0.1);
- transition: background-color 0.18s ease, border-color 0.18s ease, transform 0.14s ease;
+ box-shadow: none;
+ transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease, transform 0.14s ease;
}
.variant-home.is-collapsed .home-category-chip {
- min-height: 68px;
+ min-height: 64px;
}
.home-category-strip::-webkit-scrollbar {
@@ -1189,32 +1255,93 @@ defineExpose({
.home-category-chip:active {
transform: translateY(1px);
- background: #eef06d;
- border-color: #c8ca00;
+ background: var(--shortcut-surface, #f5f8fb);
+ border-color: var(--shortcut-color, #7190a8);
}
.home-category-chip.active {
- background: #f2f48d;
- border-color: #bfc100;
+ background: #edf5ff;
+ border-color: #1565c0;
+ box-shadow: inset 0 0 0 1px rgba(21, 101, 192, 0.08);
}
.home-category-icon {
- width: 28px;
- height: 28px;
- flex: 0 0 28px;
+ width: 27px;
+ height: 27px;
+ flex: 0 0 27px;
}
.poi-category-icon {
display: block;
width: 100%;
height: 100%;
- color: #151713;
+ color: var(--shortcut-color, #2f5f82);
pointer-events: none;
}
.home-category-chip.active .poi-category-icon,
.category-item.active .poi-category-icon {
- color: #151713;
+ color: #1565c0;
+}
+
+[data-category='exhibition-hall'] {
+ --shortcut-color: #356f9c;
+ --shortcut-surface: #f1f7fb;
+ --shortcut-border: #bfd4e4;
+}
+
+[data-category='cinema'] {
+ --shortcut-color: #78649f;
+ --shortcut-surface: #f7f4fb;
+ --shortcut-border: #d7cce7;
+}
+
+[data-category='ticket-office'] {
+ --shortcut-color: #a7752f;
+ --shortcut-surface: #fcf8ef;
+ --shortcut-border: #ead8b8;
+}
+
+[data-category='dining'] {
+ --shortcut-color: #b96647;
+ --shortcut-surface: #fdf4ef;
+ --shortcut-border: #edcbbb;
+}
+
+[data-category='shopping'] {
+ --shortcut-color: #398c79;
+ --shortcut-surface: #eef8f5;
+ --shortcut-border: #b9dfd4;
+}
+
+[data-category='service-center'] {
+ --shortcut-color: #527fa6;
+ --shortcut-surface: #f0f6fb;
+ --shortcut-border: #bfd5e6;
+}
+
+[data-category='restroom'] {
+ --shortcut-color: #6386ab;
+ --shortcut-surface: #f2f6fa;
+ --shortcut-border: #c4d5e4;
+}
+
+[data-category='nursing-room'] {
+ --shortcut-color: #b46d8a;
+ --shortcut-surface: #fcf2f5;
+ --shortcut-border: #ecc6d5;
+}
+
+[data-category='elevator'] {
+ --shortcut-color: #9b7860;
+ --shortcut-surface: #faf5f0;
+ --shortcut-border: #dfcbbd;
+}
+
+[data-category='escalator'] {
+ --shortcut-color: #557b9d;
+ --shortcut-surface: #f0f5f9;
+ --shortcut-border: #c0d2e0;
}
.home-category-label {
@@ -1473,26 +1600,27 @@ defineExpose({
.category-item {
min-width: 0;
- min-height: 70px;
+ min-height: 68px;
position: relative;
- padding: 9px 4px 10px;
+ padding: 8px 4px 9px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
- gap: 7px;
+ gap: 6px;
box-sizing: border-box;
overflow: visible;
- background: #f5f5ed;
- border: 1px solid #dfe2d7;
+ background: var(--shortcut-surface, rgba(255, 255, 251, 0.76));
+ border: 1px solid var(--shortcut-border, rgba(26, 35, 126, 0.12));
border-radius: 8px;
- box-shadow: 0 2px 5px rgba(21, 23, 19, 0.08);
+ box-shadow: none;
transition: background-color 0.18s ease, border-color 0.18s ease, transform 0.14s ease;
}
.category-item.active {
- background: #f2f48d;
- border-color: #bfc100;
+ background: #edf5ff;
+ border-color: #1565c0;
+ box-shadow: inset 0 0 0 1px rgba(21, 101, 192, 0.08);
}
.home-category-chip.disabled,
@@ -1506,29 +1634,29 @@ defineExpose({
position: absolute;
left: 10px;
right: 10px;
- bottom: -1px;
- height: 3px;
- background: var(--museum-accent);
+ bottom: 0;
+ height: 2px;
+ background: #1565c0;
border-radius: 3px 3px 0 0;
}
.category-icon-shell {
- width: 28px;
- height: 28px;
- flex: 0 0 28px;
+ width: 27px;
+ height: 27px;
+ flex: 0 0 27px;
}
.category-item:active {
transform: translateY(1px);
- background: #eef06d;
- border-color: #c8ca00;
+ background: var(--shortcut-surface, #f5f8fb);
+ border-color: var(--shortcut-color, #7190a8);
}
.variant-home .home-shortcut-grid {
display: grid;
grid-auto-flow: row;
grid-template-columns: repeat(5, minmax(0, 1fr));
- grid-template-rows: repeat(2, 72px);
+ grid-template-rows: repeat(2, 68px);
grid-auto-columns: auto;
width: 100%;
min-width: 320px;
@@ -1536,10 +1664,10 @@ defineExpose({
}
.variant-home .category-item {
- min-height: 72px;
- height: 72px;
- padding: 6px 2px 5px;
- gap: 4px;
+ min-height: 68px;
+ height: 68px;
+ padding: 6px 2px 4px;
+ gap: 5px;
}
.category-label {
diff --git a/src/composables/useGlobalAudioPlayer.ts b/src/composables/useGlobalAudioPlayer.ts
index 0abcdb9..a6b661d 100644
--- a/src/composables/useGlobalAudioPlayer.ts
+++ b/src/composables/useGlobalAudioPlayer.ts
@@ -15,6 +15,8 @@ export interface GlobalAudioSource {
targetType?: AudioPlayTargetType
targetId?: string
lang?: AudioLanguage | string
+ channelCode?: string
+ voiceGender?: 'male' | 'female'
detailRoute?: string
title?: string
}
@@ -37,14 +39,12 @@ const error = ref('')
const currentTime = ref(0)
const duration = ref(0)
const displayMode = ref('mini')
+const playbackRate = ref(1)
+const muted = ref(false)
const pendingLanguage = ref('')
const activeHostId = ref('')
const lastClosedSource = ref(null)
const closeVersion = ref(0)
-const playbackRate = ref(1)
-const muted = ref(false)
-
-const PLAYBACK_RATES = [1, 1.25, 1.5, 2] as const
let audioElement: HTMLAudioElement | null = null
let retryOnError: GlobalAudioRetryHandler | null = null
@@ -78,17 +78,13 @@ const syncAudioDuration = () => {
: currentAudio.value?.duration || 0
}
-const applyAudioPreferences = (audio: HTMLAudioElement) => {
- audio.playbackRate = playbackRate.value
- audio.muted = muted.value
-}
-
const ensureAudioElement = () => {
if (audioElement || typeof window === 'undefined' || !window.Audio) return audioElement
const audio = new window.Audio()
audio.preload = 'metadata'
- applyAudioPreferences(audio)
+ audio.playbackRate = playbackRate.value
+ audio.muted = muted.value
audio.addEventListener('loadedmetadata', syncAudioDuration)
audio.addEventListener('play', () => {
playing.value = true
@@ -123,6 +119,25 @@ const ensureAudioElement = () => {
return audioElement
}
+const setPlaybackRate = (rate: number) => {
+ const nextRate = Math.max(0.5, Math.min(2, Number.isFinite(rate) ? rate : 1))
+ playbackRate.value = nextRate
+ if (audioElement) {
+ audioElement.playbackRate = nextRate
+ }
+}
+
+const setMuted = (nextMuted: boolean) => {
+ muted.value = nextMuted
+ if (audioElement) {
+ audioElement.muted = nextMuted
+ }
+}
+
+const toggleMute = () => {
+ setMuted(!muted.value)
+}
+
const resetState = () => {
currentAudio.value = null
currentSource.value = null
@@ -134,11 +149,6 @@ const resetState = () => {
duration.value = 0
displayMode.value = 'mini'
pendingLanguage.value = ''
- playbackRate.value = 1
- muted.value = false
- if (audioElement) {
- applyAudioPreferences(audioElement)
- }
retryOnError = null
retrying = false
retryUsed = false
@@ -242,7 +252,8 @@ const play = async (audio: AudioItem, options: GlobalAudioPlayOptions = {}) => {
element.src = audio.audioUrl
element.load()
}
- applyAudioPreferences(element)
+ element.playbackRate = playbackRate.value
+ element.muted = muted.value
try {
await element.play()
@@ -275,7 +286,7 @@ const resume = () => {
})
}
-const switchLanguage = async (lang: AudioLanguage, localAudio?: AudioItem | null) => {
+const switchLanguage = async (lang: AudioLanguage) => {
if (pendingLanguage.value) {
return false
}
@@ -307,35 +318,6 @@ const switchLanguage = async (lang: AudioLanguage, localAudio?: AudioItem | null
}
try {
- if (localAudio?.audioUrl) {
- currentSource.value = nextSource
- return await play(localAudio, {
- source: nextSource,
- retryOnError: retryOnError || undefined,
- displayMode: preservedMode
- })
- }
-
- if (localAudio) {
- stopAudioElement()
- currentSource.value = nextSource
- currentAudio.value = {
- ...localAudio,
- audioUrl: '',
- duration: 0,
- language: lang
- }
- visible.value = true
- displayMode.value = preservedMode
- playing.value = false
- loading.value = false
- currentTime.value = 0
- duration.value = 0
- error.value = '当前语言暂无语音讲解'
- showToast(error.value)
- return false
- }
-
const playInfo = await audioPlayInfoRepository.getPlayInfo({
targetType: source.targetType,
targetId: source.targetId,
@@ -464,22 +446,6 @@ const seekToPercent = (percent: number) => {
currentTime.value = targetTime
}
-const cyclePlaybackRate = () => {
- const currentIndex = PLAYBACK_RATES.indexOf(playbackRate.value as typeof PLAYBACK_RATES[number])
- const nextIndex = currentIndex >= 0 ? (currentIndex + 1) % PLAYBACK_RATES.length : 0
- playbackRate.value = PLAYBACK_RATES[nextIndex]
- if (audioElement) {
- audioElement.playbackRate = playbackRate.value
- }
-}
-
-const toggleMuted = () => {
- muted.value = !muted.value
- if (audioElement) {
- audioElement.muted = muted.value
- }
-}
-
const registerHost = () => {
hostSequence += 1
const hostId = `global-audio-host-${hostSequence}`
@@ -496,7 +462,7 @@ const unregisterHost = (hostId: string) => {
}
}
-const isCurrentSource = (source: Pick) => {
+const isCurrentSource = (source: Pick) => {
if (!currentAudio.value || !currentSource.value) return false
return Boolean(
@@ -505,6 +471,7 @@ const isCurrentSource = (source: Pick ({
currentTime,
duration,
displayMode,
+ playbackRate,
+ muted,
pendingLanguage,
activeHostId,
lastClosedSource,
closeVersion,
- playbackRate,
- muted,
hasAudio: computed(() => Boolean(currentAudio.value)),
play,
pause,
@@ -531,13 +498,14 @@ export const useGlobalAudioPlayer = () => ({
stop,
close,
setDisplayMode,
+ setPlaybackRate,
+ setMuted,
+ toggleMute,
collapse,
switchLanguage,
handleEnded,
handleError,
seekToPercent,
- cyclePlaybackRate,
- toggleMuted,
registerHost,
activateHost,
unregisterHost,
diff --git a/src/composables/useGuideSceneState.ts b/src/composables/useGuideSceneState.ts
new file mode 100644
index 0000000..1e3e23e
--- /dev/null
+++ b/src/composables/useGuideSceneState.ts
@@ -0,0 +1,174 @@
+import { computed, readonly, ref, shallowRef, type Ref, type WritableComputedRef } from 'vue'
+import {
+ getGuideViewportKey,
+ type GuideViewportState
+} from '@/domain/guideViewport'
+
+export type GuideSceneView = 'overview' | 'floor' | 'multi'
+export type GuideRenderMode = 'three-d' | 'two-d'
+export type { GuideViewportState } from '@/domain/guideViewport'
+
+export type GuideSceneSnapshot = Readonly<{
+ view: GuideSceneView
+ floorId: string
+ renderMode: GuideRenderMode
+ viewport: GuideViewportState | null
+ revision: number
+}>
+
+type GuideScenePatch = Partial>
+
+const freezeViewport = (viewport: GuideViewportState | null | undefined) => (
+ viewport
+ ? Object.freeze({
+ scene: viewport.scene,
+ floorId: viewport.scene === 'floor' ? String(viewport.floorId || '') : '',
+ centerX: viewport.centerX,
+ centerZ: viewport.centerZ,
+ visibleWorldSpan: viewport.visibleWorldSpan,
+ revision: viewport.revision
+ })
+ : null
+)
+
+const isSameViewport = (
+ left: GuideViewportState | null | undefined,
+ right: GuideViewportState | null | undefined
+) => (
+ left?.scene === right?.scene
+ && left?.floorId === right?.floorId
+ && left?.centerX === right?.centerX
+ && left?.centerZ === right?.centerZ
+ && left?.visibleWorldSpan === right?.visibleWorldSpan
+ && left?.revision === right?.revision
+)
+
+const createWritableField = (
+ state: Ref,
+ key: K,
+ commit: (patch: GuideScenePatch) => number
+) => computed({
+ get: () => state.value[key],
+ set: (value: GuideSceneSnapshot[K]) => {
+ commit({ [key]: value })
+ }
+}) as WritableComputedRef
+
+export const useGuideSceneState = (initial: {
+ view?: GuideSceneView
+ floorId?: string
+ renderMode?: GuideRenderMode
+ viewport?: GuideViewportState | null
+} = {}) => {
+ const initialViewport = freezeViewport(initial.viewport)
+ const viewportCache = shallowRef>>(
+ initialViewport
+ ? Object.freeze({
+ [getGuideViewportKey(initialViewport.scene, initialViewport.floorId)]: initialViewport
+ })
+ : Object.freeze({})
+ )
+ const state = ref(Object.freeze({
+ view: initial.view || 'overview',
+ floorId: initial.floorId || '',
+ renderMode: initial.renderMode || 'three-d',
+ viewport: initialViewport,
+ revision: 0
+ }))
+ const viewport = shallowRef(state.value.viewport)
+
+ const commit = (patch: GuideScenePatch) => {
+ const nextRenderMode = patch.renderMode ?? state.value.renderMode
+ const requestedView = patch.view ?? state.value.view
+ const nextView = nextRenderMode === 'two-d' && requestedView === 'multi'
+ ? 'floor'
+ : requestedView
+ const nextFloorId = patch.floorId ?? state.value.floorId
+ const viewportScene = nextView === 'overview' ? 'overview' : 'floor'
+ const cachedViewport = viewportCache.value[getGuideViewportKey(viewportScene, nextFloorId)] || null
+ const nextViewport = patch.viewport === undefined
+ ? (
+ nextView !== state.value.view || nextFloorId !== state.value.floorId
+ ? cachedViewport
+ : viewport.value
+ )
+ : freezeViewport(patch.viewport)
+ const changed = (
+ nextView !== state.value.view
+ || nextFloorId !== state.value.floorId
+ || nextRenderMode !== state.value.renderMode
+ )
+
+ if (!changed) {
+ if (!isSameViewport(nextViewport, viewport.value)) {
+ viewport.value = nextViewport
+ state.value = Object.freeze({ ...state.value, viewport: nextViewport })
+ }
+ return state.value.revision
+ }
+
+ viewport.value = nextViewport
+ state.value = Object.freeze({
+ view: nextView,
+ floorId: nextFloorId,
+ renderMode: nextRenderMode,
+ viewport: nextViewport,
+ revision: state.value.revision + 1
+ })
+ return state.value.revision
+ }
+
+ const commitRendererScene = (patch: Pick & {
+ requestRevision?: number
+ }) => {
+ if (
+ patch.requestRevision !== undefined
+ && patch.requestRevision !== state.value.revision
+ ) return false
+ commit(patch)
+ return true
+ }
+
+ const updateViewport = (snapshot: GuideViewportState | null) => {
+ const frozen = freezeViewport(snapshot)
+ if (!frozen) {
+ if (viewport.value === null) return
+ viewport.value = null
+ state.value = Object.freeze({ ...state.value, viewport: null })
+ return
+ }
+
+ const key = getGuideViewportKey(frozen.scene, frozen.floorId)
+ const previous = viewportCache.value[key]
+ if (previous && frozen.revision < previous.revision) return
+ if (!isSameViewport(previous, frozen)) {
+ viewportCache.value = Object.freeze({
+ ...viewportCache.value,
+ [key]: frozen
+ })
+ }
+
+ const activeScene = state.value.view === 'overview' ? 'overview' : 'floor'
+ const activeKey = getGuideViewportKey(activeScene, state.value.floorId)
+ if (key !== activeKey || isSameViewport(viewport.value, frozen)) return
+ viewport.value = frozen
+ state.value = Object.freeze({ ...state.value, viewport: frozen })
+ }
+
+ return {
+ state: readonly(state),
+ view: createWritableField(state, 'view', commit),
+ floorId: createWritableField(state, 'floorId', commit),
+ renderMode: createWritableField(state, 'renderMode', commit),
+ viewport: readonly(viewport),
+ viewportCache: readonly(viewportCache),
+ revision: computed(() => state.value.revision),
+ commit,
+ commitRendererScene,
+ updateViewport,
+ getCachedViewport: (scene: 'overview' | 'floor', floorId = '') => (
+ viewportCache.value[getGuideViewportKey(scene, floorId)] || null
+ ),
+ isCurrentRevision: (revision: number) => revision === state.value.revision
+ }
+}
diff --git a/src/data/adapters/backendExplainDataAdapter.ts b/src/data/adapters/backendExplainDataAdapter.ts
index 15f2e33..5c001ae 100644
--- a/src/data/adapters/backendExplainDataAdapter.ts
+++ b/src/data/adapters/backendExplainDataAdapter.ts
@@ -78,7 +78,6 @@ export interface BackendHall {
stopCount?: number | null
linkedExhibitCount?: number | null
audioReadyStopCount?: number | null
- audioOptionCount?: number | null
hasAudio?: boolean | null
audioStatus?: string | null
supportedLanguages?: string[] | null
@@ -159,7 +158,6 @@ export interface BackendCatalogStopItem {
hasAudio?: boolean | null
audioStatus?: string | null
supportedLanguages?: string[] | null
- audioOptionCount?: number | null
hasTextRecord?: boolean | null
playTargetType?: string | null
playTargetId?: string | number | null
@@ -289,7 +287,6 @@ export const toCatalogHall = (
stopCount: normalizeNumber(source.stopCount),
linkedExhibitCount: normalizeNumber(source.linkedExhibitCount),
audioReadyStopCount: normalizeNumber(source.audioReadyStopCount),
- audioOptionCount: normalizeNumber(source.audioOptionCount),
hasAudio: source.hasAudio === true,
audioStatus: normalizeCatalogAudioStatus(source.audioStatus),
supportedLanguages: normalizeSupportedLanguages(source.supportedLanguages),
@@ -362,7 +359,6 @@ export const toCatalogGuideStop = (
hasAudio: source.hasAudio === true,
audioStatus: normalizeCatalogAudioStatus(source.audioStatus),
supportedLanguages: normalizeSupportedLanguages(source.supportedLanguages),
- audioOptionCount: normalizeNumber(source.audioOptionCount),
hasTextRecord: source.hasTextRecord === true,
poiId: stringifyId(source.poiId) || undefined,
mapX: normalizeNumber(source.mapX),
@@ -589,7 +585,7 @@ export const toBackendHall = (
name: firstText(first?.hallName, fallback?.name, '展厅'),
floorId: firstText(first?.floorId, fallback?.floorId) || undefined,
floorLabel: firstText(first?.floorLabel, fallback?.floorLabel, '楼层待补充'),
- description: fallback?.description || '该展厅讲解内容来自后端展品接口。',
+ description: fallback?.description || '该免费讲解内容来自后端展品接口。',
image: fallback?.image || HALL_PLACEHOLDER_IMAGE,
exhibitCount: exhibits.length,
area: fallback?.area,
diff --git a/src/data/adapters/guideStopInfoAdapter.ts b/src/data/adapters/guideStopInfoAdapter.ts
index d2da604..18160a7 100644
--- a/src/data/adapters/guideStopInfoAdapter.ts
+++ b/src/data/adapters/guideStopInfoAdapter.ts
@@ -1,12 +1,13 @@
import type {
- AudioPlayTargetType
+ AudioPlayTargetType,
+ GuideAudioGender,
+ MuseumGuideAudioOption
} from '@/domain/museum'
import {
normalizeSameOriginPublicUrl
} from '@/utils/publicUrl'
export type GuideAudioLanguage = 'zh-CN' | 'yue-HK' | 'en-US'
-export type GuideAudioVoiceGender = 'female' | 'male'
export interface BackendGuideStopLinkedExhibit {
id?: string | number | null
@@ -19,6 +20,20 @@ export interface BackendGuideStopLinkedExhibit {
sortOrder?: number | string | null
}
+export interface BackendGuideAudioOption {
+ channelCode?: string | null
+ displayName?: string | null
+ version?: string | null
+ languageCode?: string | null
+ languageName?: string | null
+ gender?: string | null
+ playUrl?: string | null
+ duration?: number | string | null
+ format?: string | null
+ isDefault?: boolean | null
+ sortOrder?: number | string | null
+}
+
export interface BackendGuideStopInfo {
available?: boolean
targetType?: string | null
@@ -43,45 +58,13 @@ export interface BackendGuideStopInfo {
hasText?: boolean
supportedLanguages?: string[] | null
audioStatus?: string | null
- audioOptions?: BackendGuideStopAudioOption[] | null
- languageVariants?: BackendGuideStopLanguageVariant[] | null
+ audioOptions?: BackendGuideAudioOption[] | null
+ audioOptionCount?: number | string | null
reason?: string | null
linkedExhibitCount?: number | string | null
isSharedStop?: boolean | null
}
-export interface BackendGuideStopAudioOption {
- channelCode?: string | null
- displayName?: string | null
- languageCode?: string | null
- languageName?: string | null
- gender?: string | null
- playUrl?: string | null
- duration?: number | string | null
- format?: string | null
- isDefault?: boolean | null
- sortOrder?: number | string | null
-}
-
-export interface BackendGuideStopLanguageVariant {
- lang?: string | null
- enabled?: boolean | null
- playable?: boolean | null
- audioStatus?: string | null
- playUrl?: string | null
- duration?: number | string | null
- format?: string | null
- audioId?: string | number | null
- narrationTier?: 'STANDARD' | 'EXTENDED' | string | null
- hasText?: boolean | null
- textAvailable?: boolean | null
- text?: string | null
- textLength?: number | string | null
- textHash?: string | null
- fallback?: boolean | null
- reason?: string | null
-}
-
export interface BackendAudioPlayInfo {
playable?: boolean
targetType?: string | null
@@ -125,38 +108,6 @@ export interface GuideStopLinkedExhibit {
sortOrder?: number
}
-export interface GuideStopLanguageVariant {
- lang: GuideAudioLanguage
- enabled: boolean
- playable: boolean
- audioStatus: 'READY' | 'MISSING' | string
- playUrl?: string
- duration?: number
- format?: string
- audioId?: string
- narrationTier?: 'STANDARD' | 'EXTENDED'
- hasText: boolean
- textAvailable: boolean
- text?: string
- textLength?: number
- textHash?: string
- fallback: boolean
- reason?: string
-}
-
-export interface GuideStopAudioOption {
- channelCode: string
- displayName: string
- languageCode: GuideAudioLanguage
- languageName?: string
- gender: GuideAudioVoiceGender
- playUrl: string
- duration?: number
- format?: string
- isDefault: boolean
- sortOrder?: number
-}
-
export interface GuideStopInfo {
available: boolean
targetType: AudioPlayTargetType
@@ -179,9 +130,9 @@ export interface GuideStopInfo {
hasAudio: boolean
hasText: boolean
supportedLanguages: GuideAudioLanguage[]
- audioOptions: GuideStopAudioOption[]
- languageVariants: Record
audioStatus: 'READY' | 'MISSING' | string
+ audioOptions: MuseumGuideAudioOption[]
+ audioOptionCount?: number
reason?: string
linkedExhibitCount?: number
isSharedStop?: boolean
@@ -246,102 +197,47 @@ export const normalizeGuideAudioLanguage = (
return 'zh-CN'
}
-const isSupportedGuideAudioLanguage = (value: string | null | undefined) => (
- ['zh', 'zh-cn', 'en', 'en-us', 'yue', 'yue-cn', 'yue-hk'].includes(value?.trim().toLowerCase() || '')
-)
-
const normalizeSupportedLanguages = (languages: string[] | null | undefined): GuideAudioLanguage[] => (
Array.from(new Set((languages || [])
- .filter(isSupportedGuideAudioLanguage)
- .map(normalizeGuideAudioLanguage))) as GuideAudioLanguage[]
+ .map((language) => {
+ const normalized = language?.trim().toLowerCase()
+ if (!['zh', 'zh-cn', 'en', 'en-us', 'yue', 'yue-cn', 'yue-hk'].includes(normalized)) {
+ return null
+ }
+ return normalizeGuideAudioLanguage(language)
+ })
+ .filter(Boolean))) as GuideAudioLanguage[]
)
-const normalizeLanguageVariants = (
- variants: BackendGuideStopLanguageVariant[] | null | undefined
-): Record => {
- const normalizedVariants = {} as Record
-
- ;(variants || []).forEach((variant) => {
- if (!isSupportedGuideAudioLanguage(variant.lang)) return
-
- const lang = normalizeGuideAudioLanguage(variant.lang)
- const playUrl = normalizeSameOriginPublicUrl(variant.playUrl) || undefined
- const playable = variant.playable === true && Boolean(playUrl)
- const text = variant.text?.trim() || undefined
- const textAvailable = variant.textAvailable === true || Boolean(text)
-
- normalizedVariants[lang] = {
- lang,
- enabled: variant.enabled === true,
- playable,
- audioStatus: variant.audioStatus || (playable ? 'READY' : 'MISSING'),
- playUrl,
- duration: normalizeNumber(variant.duration),
- format: variant.format?.trim() || undefined,
- audioId: stringifyId(variant.audioId) || undefined,
- narrationTier: normalizeNarrationTier(variant.narrationTier),
- hasText: variant.hasText === true || textAvailable,
- textAvailable,
- text,
- textLength: normalizeNumber(variant.textLength),
- textHash: variant.textHash?.trim() || undefined,
- fallback: variant.fallback === true,
- reason: variant.reason?.trim() || undefined
- }
- })
-
- return normalizedVariants
-}
-
-const normalizeVoiceGender = (value: string | null | undefined): GuideAudioVoiceGender | undefined => {
+const normalizeAudioGender = (value: string | null | undefined): GuideAudioGender | null => {
const normalized = value?.trim().toLowerCase()
- return normalized === 'female' || normalized === 'male' ? normalized : undefined
+ return normalized === 'male' || normalized === 'female' ? normalized : null
}
-const normalizeAudioOptions = (
- options: BackendGuideStopAudioOption[] | null | undefined
-): GuideStopAudioOption[] => (
- (options || [])
- .map((option) => {
- if (!isSupportedGuideAudioLanguage(option.languageCode)) return null
-
- const channelCode = option.channelCode?.trim()
- const gender = normalizeVoiceGender(option.gender)
- const playUrl = normalizeSameOriginPublicUrl(option.playUrl) || undefined
+const normalizeAudioOptions = (items: BackendGuideAudioOption[] | null | undefined): MuseumGuideAudioOption[] => (
+ (items || [])
+ .map((item) => {
+ const channelCode = item.channelCode?.trim()
+ const languageCode = normalizeGuideAudioLanguage(item.languageCode)
+ const gender = normalizeAudioGender(item.gender)
+ const playUrl = normalizeSameOriginPublicUrl(item.playUrl)
if (!channelCode || !gender || !playUrl) return null
- const languageCode = normalizeGuideAudioLanguage(option.languageCode)
- const displayName = option.displayName?.trim()
- || `${languageCode === 'en-US' ? '英文' : languageCode === 'yue-HK' ? '粤语' : '普通话'}${gender === 'female' ? '女声' : '男声'}`
-
return {
channelCode,
- displayName,
+ displayName: item.displayName?.trim() || undefined,
+ version: item.version?.trim() || undefined,
languageCode,
- languageName: option.languageName?.trim() || undefined,
+ languageName: item.languageName?.trim() || undefined,
gender,
playUrl,
- duration: normalizeNumber(option.duration),
- format: option.format?.trim() || undefined,
- isDefault: option.isDefault === true,
- sortOrder: normalizeNumber(option.sortOrder)
+ duration: normalizeNumber(item.duration),
+ format: item.format?.trim() || undefined,
+ isDefault: item.isDefault === true,
+ sortOrder: normalizeNumber(item.sortOrder)
}
})
- .filter(Boolean)
- .sort((left, right) => (
- (left!.sortOrder ?? Number.MAX_SAFE_INTEGER) - (right!.sortOrder ?? Number.MAX_SAFE_INTEGER)
- || left!.channelCode.localeCompare(right!.channelCode)
- )) as GuideStopAudioOption[]
-)
-
-const supportedLanguagesFromVariants = (variants: Record) => (
- (Object.values(variants) as GuideStopLanguageVariant[])
- .filter((variant) => variant.enabled && (variant.playable || variant.textAvailable))
- .map((variant) => variant.lang)
-)
-
-const supportedLanguagesFromAudioOptions = (audioOptions: GuideStopAudioOption[]) => (
- Array.from(new Set(audioOptions.map((option) => option.languageCode)))
+ .filter(Boolean) as MuseumGuideAudioOption[]
)
const parseGalleryUrls = (value: BackendGuideStopInfo['galleryUrls'] | BackendGuideStopLinkedExhibit['galleryUrls']) => {
@@ -410,11 +306,11 @@ export const toGuideStopInfo = (
const coverImageUrl = canUseStopImages
? normalizeSameOriginPublicUrl(source.coverImageUrl) || undefined
: undefined
- const languageVariants = normalizeLanguageVariants(source.languageVariants)
const audioOptions = normalizeAudioOptions(source.audioOptions)
- const supportedLanguages = audioOptions.length
- ? supportedLanguagesFromAudioOptions(audioOptions)
- : supportedLanguagesFromVariants(languageVariants)
+ const supportedLanguages = Array.from(new Set([
+ ...normalizeSupportedLanguages(source.supportedLanguages),
+ ...audioOptions.map((option) => option.languageCode as GuideAudioLanguage)
+ ]))
return {
available: source.available === true,
@@ -437,12 +333,10 @@ export const toGuideStopInfo = (
playTargetId,
hasAudio: source.hasAudio === true,
hasText: source.hasText === true,
- supportedLanguages: supportedLanguages.length
- ? supportedLanguages
- : normalizeSupportedLanguages(source.supportedLanguages),
- audioOptions,
- languageVariants,
+ supportedLanguages,
audioStatus: source.audioStatus || 'MISSING',
+ audioOptions,
+ audioOptionCount: normalizeNumber(source.audioOptionCount),
reason: source.reason || undefined,
linkedExhibitCount: normalizeNumber(source.linkedExhibitCount),
isSharedStop: source.isSharedStop === true
diff --git a/src/data/adapters/navAssetsAdapter.ts b/src/data/adapters/navAssetsAdapter.ts
index dc35393..9003221 100644
--- a/src/data/adapters/navAssetsAdapter.ts
+++ b/src/data/adapters/navAssetsAdapter.ts
@@ -10,7 +10,6 @@ import {
isIndoorNavigableFloor
} from '@/domain/guideFloor'
import {
- isVisitorRestrictedPlaceName,
normalizePoiSemanticValue
} from '@/domain/poiCategories'
@@ -194,16 +193,6 @@ const isPoiAccessible = (poi: StaticNavPoiPayload) => (
|| poi.categories?.some((category) => category.topCategory === 'accessibility_special_service') === true
)
-const resolveStaticPoiVisitorVisible = (
- poi: StaticNavPoiPayload,
- semanticType: string
-) => (
- typeof poi.visitorVisible === 'boolean'
- ? poi.visitorVisible
- : semanticType !== 'service_space'
- && ![poi.name, poi.sourceObjectName].some(isVisitorRestrictedPlaceName)
-)
-
export const toMuseumPoi = (poi: StaticNavPoiPayload): MuseumPoi => {
const categoryFallbackIconType = poi.categories?.[0]?.iconType
const iconType = getStaticPoiSemanticType(poi, poi.iconType || categoryFallbackIconType)
@@ -225,7 +214,6 @@ export const toMuseumPoi = (poi: StaticNavPoiPayload): MuseumPoi => {
sourceObjectName: poi.sourceObjectName,
sourceConfidence: poi.sourceConfidence,
navigationReadiness: poi.navigationReadiness,
- visitorVisible: resolveStaticPoiVisitorVisible(poi, iconType),
accessible: isPoiAccessible(poi),
kind,
hallName: kind === 'hall' ? poi.name : undefined
diff --git a/src/data/adapters/navRouteAdapter.ts b/src/data/adapters/navRouteAdapter.ts
index 5e702b6..9274fc0 100644
--- a/src/data/adapters/navRouteAdapter.ts
+++ b/src/data/adapters/navRouteAdapter.ts
@@ -143,6 +143,7 @@ const toTarget = (
anchor: NavRouteAnchor,
poi?: StaticNavPoiPayload
): GuideRouteTarget => ({
+ routeTargetId: `${anchor.poiId}:${anchor.routeNodeId}`,
poiId: anchor.poiId,
name: poi?.name || anchor.name,
floorId: anchor.floorId,
diff --git a/src/data/adapters/sgsSdkGuideAdapter.ts b/src/data/adapters/sgsSdkGuideAdapter.ts
index 61b73cc..0b9bd54 100644
--- a/src/data/adapters/sgsSdkGuideAdapter.ts
+++ b/src/data/adapters/sgsSdkGuideAdapter.ts
@@ -6,173 +6,182 @@ import type {
GuideLocationPreview,
GuideMapDiagnostics,
GuideRouteReadiness,
- MuseumCategory,
- MuseumFloor,
- MuseumPoi,
- ExplainGuideStop,
- AudioPlayTargetType
-} from '@/domain/museum'
+ MuseumCategory,
+ MuseumFloor,
+ MuseumPoi,
+ ExplainGuideStop,
+ AudioPlayTargetType
+} from '@/domain/museum'
import {
NAV_ROUTE_UNAVAILABLE_MESSAGE
} from '@/domain/guideReadiness'
-import {
- isIndoorNavigableFloor
-} from '@/domain/guideFloor'
-import {
- isVisitorRestrictedPlaceName,
- isPoiSearchCategorySupported,
- normalizePoiSemanticValue
-} from '@/domain/poiCategories'
+import {
+ isIndoorNavigableFloor
+} from '@/domain/guideFloor'
+import {
+ isPoiSearchCategorySupported,
+ normalizePoiSemanticValue
+} from '@/domain/poiCategories'
import type {
- SgsFloorDiagnosticsPayload,
- SgsGuideStopPayload,
- SgsMapDiagnosticsPayload,
- SgsNavigablePlacePayload,
- SgsPoiPayload,
+ SgsFloorDiagnosticsPayload,
+ SgsGuideStopPayload,
+ SgsMapDiagnosticsPayload,
+ SgsNavigablePlacePayload,
+ SgsPoiPayload,
SgsPositionPayload,
SgsSdkFloorSummaryPayload,
SgsSdkManifestPayload,
SgsSpacePayload
} from '@/data/providers/sgsSdkApiProvider'
-const defaultCategory: MuseumCategory = {
- id: 'poi',
- label: '点位',
- iconType: 'poi'
-}
-
-const businessCategory: MuseumCategory = {
- id: 'business_poi',
- label: '运营服务',
- iconType: 'business'
-}
-
-const hallCategory: MuseumCategory = {
+const defaultCategory: MuseumCategory = {
+ id: 'poi',
+ label: '点位',
+ iconType: 'poi'
+}
+
+const businessCategory: MuseumCategory = {
+ id: 'business_poi',
+ label: '运营服务',
+ iconType: 'business'
+}
+
+const hallCategory: MuseumCategory = {
id: 'exhibition_hall',
label: '展厅',
iconType: 'exhibition_hall'
}
-const hallEntranceCategory: MuseumCategory = {
- id: 'exhibition_hall_entrance',
- label: '展厅出入口',
- iconType: 'hall_entrance'
-}
-
-const spaceCategory: MuseumCategory = {
- id: 'space_point',
- label: '空间点位',
- iconType: 'space'
-}
-
-const spaceCategoryBySgsType: Record = {
- exhibition_hall: hallCategory,
- theater: {
- id: 'space_theater',
- label: '剧场空间',
- iconType: 'theater'
- },
- education_activity: {
- id: 'space_education_activity',
- label: '教育活动空间',
- iconType: 'education_activity'
- },
- ramp: {
- id: 'space_ramp',
- label: '空间坡道',
- iconType: 'ramp'
- },
- public_area: {
- id: 'space_public_area',
- label: '公共空间',
- iconType: 'public_area'
- },
- service_space: {
- id: 'space_service',
- label: '服务空间',
- iconType: 'service_space'
- },
- commercial: {
- id: 'space_shop',
- label: '购物空间',
- iconType: 'shop'
- },
- restaurant: {
- id: 'space_restaurant',
- label: '餐饮空间',
- iconType: 'restaurant'
- },
- cafe: {
- id: 'space_restaurant',
- label: '餐饮空间',
- iconType: 'cafe'
- },
- shop: {
- id: 'space_shop',
- label: '购物空间',
- iconType: 'shop'
- },
- cultural_shop: {
- id: 'space_shop',
- label: '购物空间',
- iconType: 'cultural_shop'
- },
- bookstore: {
- id: 'space_shop',
- label: '购物空间',
- iconType: 'bookstore'
- },
- vending: {
- id: 'space_shop',
- label: '购物空间',
- iconType: 'vending'
- },
- parking: {
- id: 'space_parking',
- label: '停车空间',
- iconType: 'parking'
- },
- plaza: {
- id: 'space_plaza',
- label: '广场空间',
- iconType: 'plaza'
- },
- room: {
- id: 'space_room',
- label: '房间空间',
- iconType: 'room'
- }
-}
+const hallEntranceCategory: MuseumCategory = {
+ id: 'exhibition_hall_entrance',
+ label: '展厅出入口',
+ iconType: 'hall_entrance'
+}
-const categoryBySgsType: Record = {
- exhibition_hall: hallCategory,
- theater: spaceCategoryBySgsType.theater,
- service_space: spaceCategoryBySgsType.service_space,
- commercial: spaceCategoryBySgsType.commercial,
- restaurant: spaceCategoryBySgsType.restaurant,
- cafe: spaceCategoryBySgsType.cafe,
- shop: spaceCategoryBySgsType.shop,
- cultural_shop: spaceCategoryBySgsType.cultural_shop,
- bookstore: spaceCategoryBySgsType.bookstore,
- vending: spaceCategoryBySgsType.vending,
- poi: {
- id: 'poi',
- label: '点位',
- iconType: 'poi'
- },
- device_terminal: {
- id: 'poi',
- label: '点位',
- iconType: 'poi'
- },
- operation_experience: {
- id: 'operation_experience',
- label: '导览点位',
- iconType: 'guide'
- },
- toilet: {
- id: 'basic_service_facility',
- label: '卫生间',
+const floorExitCategory: MuseumCategory = {
+ id: 'navigation_anchor',
+ label: '楼层出入口',
+ iconType: 'entrance_exit'
+}
+
+const spaceCategory: MuseumCategory = {
+ id: 'space_point',
+ label: '空间点位',
+ iconType: 'space'
+}
+
+const spaceCategoryBySgsType: Record = {
+ exhibition_hall: hallCategory,
+ theater: {
+ id: 'space_theater',
+ label: '剧场空间',
+ iconType: 'theater'
+ },
+ education_activity: {
+ id: 'space_education_activity',
+ label: '教育活动空间',
+ iconType: 'education_activity'
+ },
+ ramp: {
+ id: 'space_ramp',
+ label: '空间坡道',
+ iconType: 'ramp'
+ },
+ public_area: {
+ id: 'space_public_area',
+ label: '公共空间',
+ iconType: 'public_area'
+ },
+ service_space: {
+ id: 'space_service',
+ label: '服务空间',
+ iconType: 'service_space'
+ },
+ storage: {
+ id: 'space_storage',
+ label: '办公空间',
+ iconType: 'office'
+ },
+ commercial: {
+ id: 'space_shop',
+ label: '购物空间',
+ iconType: 'shop'
+ },
+ restaurant: {
+ id: 'space_restaurant',
+ label: '餐饮空间',
+ iconType: 'restaurant'
+ },
+ cafe: {
+ id: 'space_restaurant',
+ label: '餐饮空间',
+ iconType: 'cafe'
+ },
+ shop: {
+ id: 'space_shop',
+ label: '购物空间',
+ iconType: 'shop'
+ },
+ cultural_shop: {
+ id: 'space_shop',
+ label: '购物空间',
+ iconType: 'cultural_shop'
+ },
+ bookstore: {
+ id: 'space_shop',
+ label: '购物空间',
+ iconType: 'bookstore'
+ },
+ vending: {
+ id: 'space_shop',
+ label: '购物空间',
+ iconType: 'vending'
+ },
+ parking: {
+ id: 'space_parking',
+ label: '停车空间',
+ iconType: 'parking'
+ },
+ plaza: {
+ id: 'space_plaza',
+ label: '广场空间',
+ iconType: 'plaza'
+ },
+ room: {
+ id: 'space_room',
+ label: '房间空间',
+ iconType: 'room'
+ }
+}
+
+const categoryBySgsType: Record = {
+ exhibition_hall: hallCategory,
+ theater: spaceCategoryBySgsType.theater,
+ commercial: spaceCategoryBySgsType.commercial,
+ restaurant: spaceCategoryBySgsType.restaurant,
+ cafe: spaceCategoryBySgsType.cafe,
+ shop: spaceCategoryBySgsType.shop,
+ cultural_shop: spaceCategoryBySgsType.cultural_shop,
+ bookstore: spaceCategoryBySgsType.bookstore,
+ vending: spaceCategoryBySgsType.vending,
+ poi: {
+ id: 'poi',
+ label: '点位',
+ iconType: 'poi'
+ },
+ device_terminal: {
+ id: 'poi',
+ label: '设备终端',
+ iconType: 'device_terminal'
+ },
+ operation_experience: {
+ id: 'operation_experience',
+ label: '导览点位',
+ iconType: 'guide'
+ },
+ toilet: {
+ id: 'basic_service_facility',
+ label: '卫生间',
iconType: 'toilet'
},
accessible_toilet: {
@@ -197,27 +206,27 @@ const categoryBySgsType: Record (
Number.isFinite(Number(value)) ? Number(value) : fallback
)
-const optionalNumber = (value: number | null | undefined) => (
- value !== null && typeof value !== 'undefined' && Number.isFinite(Number(value))
- ? Number(value)
- : undefined
-)
+const optionalNumber = (value: number | null | undefined) => (
+ value !== null && typeof value !== 'undefined' && Number.isFinite(Number(value))
+ ? Number(value)
+ : undefined
+)
-const normalizedText = (value?: string | null) => (value || '').trim()
-
-export const normalizeSgsPoiSemanticType = (value?: string | null) => normalizePoiSemanticValue(value)
+const normalizedText = (value?: string | null) => (value || '').trim()
-const normalizePositionSource = (source: SgsPositionPayload): [number, number, number] | undefined => {
- const x = optionalNumber(source.x)
- const y = optionalNumber(source.y)
- const z = optionalNumber(source.z)
-
- if (typeof x === 'undefined' || typeof y === 'undefined' || typeof z === 'undefined') return undefined
- return [x, y, z]
-}
+const parseExtParams = (value: SgsPoiPayload['extParams']): Record | null => {
+ if (value && typeof value === 'object' && !Array.isArray(value)) return value
+ if (typeof value !== 'string' || !value.trim()) return null
+
+ try {
+ const parsed = JSON.parse(value) as unknown
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
+ ? parsed as Record
+ : null
+ } catch {
+ return null
+ }
+}
+
+const normalizeSourceNodeNames = (value: unknown): string[] => {
+ const rawNames = Array.isArray(value)
+ ? value
+ : typeof value === 'string'
+ ? (() => {
+ const trimmed = value.trim()
+ if (!trimmed) return []
+ try {
+ const parsed = JSON.parse(trimmed) as unknown
+ return Array.isArray(parsed) ? parsed : [trimmed]
+ } catch {
+ return trimmed.split(/[,,\n]/)
+ }
+ })()
+ : []
+
+ return rawNames
+ .filter((name): name is string => typeof name === 'string')
+ .map((name) => name.trim())
+ .filter(Boolean)
+ .filter((name, index, names) => names.indexOf(name) === index)
+}
+
+export const resolveSgsPoiSourceObjectNames = (poi: SgsPoiPayload) => {
+ const extParams = parseExtParams(poi.extParams)
+ const extSourceNames = normalizeSourceNodeNames(extParams?.sourceNodeNames)
+ const extSourceName = normalizedText(
+ typeof extParams?.sourceNodeName === 'string' ? extParams.sourceNodeName : undefined
+ )
+ const deviceNameFallback = normalizeSgsPoiSemanticType(poi.type) === 'device_terminal'
+ ? normalizedText(poi.name)
+ : ''
+ const sourceNames = [
+ normalizedText(poi.anchorNodeName),
+ normalizedText(poi.sourceNodeName),
+ extSourceName,
+ ...extSourceNames
+ ]
+ .filter(Boolean)
+ .filter((name, index, candidates) => candidates.indexOf(name) === index)
+ const names = sourceNames.length ? sourceNames : [deviceNameFallback].filter(Boolean)
+
+ return {
+ sourceObjectName: names[0] || undefined,
+ mergedSourceObjectNames: names.slice(1)
+ }
+}
+
+export const normalizeSgsPoiSemanticType = (value?: string | null) => normalizePoiSemanticValue(value)
+
+const normalizePositionSource = (source: SgsPositionPayload): [number, number, number] | undefined => {
+ const x = optionalNumber(source.x)
+ const y = optionalNumber(source.y)
+ const z = optionalNumber(source.z)
+
+ if (typeof x === 'undefined' || typeof y === 'undefined' || typeof z === 'undefined') return undefined
+ return [x, y, z]
+}
const normalizeStatus = (status?: string): GuideDiagnosticsStatus => {
if (status === 'OK' || status === 'WARN' || status === 'ERROR') return status
@@ -285,8 +356,8 @@ export const formatSgsFloorLabel = (floorCode?: string | null, floorName?: strin
const basementMatch = floorCode?.match(/^L-(\d+(?:\.\d+)?)$/)
if (basementMatch) return `B${basementMatch[1]}`
- const floorMatch = floorCode?.match(/^L(\d+(?:\.\d+)?)$/)
- if (floorMatch) return floorMatch[1] === '1.5' ? 'MF' : `${floorMatch[1]}F`
+ const floorMatch = floorCode?.match(/^L(\d+(?:\.\d+)?)$/)
+ if (floorMatch) return floorMatch[1] === '1.5' ? 'MF' : `${floorMatch[1]}F`
return floorName || floorCode || '未知楼层'
}
@@ -306,32 +377,25 @@ export const buildSgsFloorAliases = (floors: SgsSdkFloorSummaryPayload[]) => {
const floorId = stringifyId(floor.floorId)
if (!floorId) return
- const label = formatSgsFloorLabel(floor.floorCode, floor.floorName)
- aliases.set(floorId, floorId)
- aliases.set(floorId.toLowerCase(), floorId)
- if (floor.floorCode) {
- aliases.set(String(floor.floorCode), floorId)
- aliases.set(String(floor.floorCode).toLowerCase(), floorId)
- }
- if (floor.floorName) {
- aliases.set(String(floor.floorName), floorId)
- aliases.set(String(floor.floorName).toLowerCase(), floorId)
- }
- aliases.set(label, floorId)
- aliases.set(label.toLowerCase(), floorId)
- })
+ const label = formatSgsFloorLabel(floor.floorCode, floor.floorName)
+ aliases.set(floorId, floorId)
+ aliases.set(floorId.toLowerCase(), floorId)
+ if (floor.floorCode) {
+ aliases.set(String(floor.floorCode), floorId)
+ aliases.set(String(floor.floorCode).toLowerCase(), floorId)
+ }
+ if (floor.floorName) {
+ aliases.set(String(floor.floorName), floorId)
+ aliases.set(String(floor.floorName).toLowerCase(), floorId)
+ }
+ aliases.set(label, floorId)
+ aliases.set(label.toLowerCase(), floorId)
+ })
return aliases
}
-const exhibitionSpaceTypeWhitelist = new Set([
- 'exhibition_hall',
- 'theater',
- 'education_activity',
- 'ramp'
-])
-
-const exhibitionHallKeywords = [
+const exhibitionHallKeywords = [
'展厅',
'临展',
'展览',
@@ -350,216 +414,197 @@ const exhibitionHallKeywords = [
'人类',
'生物',
'生态',
- '家园'
-]
-
-const nonHallNavigablePlaceTypes = new Set([
- 'elevator',
- 'stairs',
- 'stair',
- 'escalator',
- 'lift',
- 'toilet',
- 'accessible_toilet',
- 'restroom',
- 'route_node',
- 'navigable_place',
- '卫生间',
- '洗手间',
- '无障碍卫生间',
- '电梯',
- '楼梯',
- '扶梯'
-])
+ '家园'
+]
-export interface SgsHallPoiDiagnostics {
- spaceCount: number
- eligibleSpaceCount: number
- navigablePlaceCount: number
- hallPlaceCount: number
+const nonHallNavigablePlaceTypes = new Set([
+ 'elevator',
+ 'stairs',
+ 'stair',
+ 'escalator',
+ 'lift',
+ 'toilet',
+ 'accessible_toilet',
+ 'restroom',
+ 'route_node',
+ 'navigable_place',
+ '卫生间',
+ '洗手间',
+ '无障碍卫生间',
+ '电梯',
+ '楼梯',
+ '扶梯'
+])
+
+export interface SgsHallPoiDiagnostics {
+ spaceCount: number
+ eligibleSpaceCount: number
+ navigablePlaceCount: number
+ hallPlaceCount: number
hallPoiCount: number
hallPoiWithPositionCount: number
skippedSpaceCount: number
- skippedPlaceCount: number
-}
-
-const businessTypeLabels: Record = {
- shop: '购物',
- commercial: '购物',
- restaurant: '餐饮',
- cafe: '餐饮',
- vending: '购物',
- bookstore: '购物',
- cultural_shop: '购物',
- photo_spot: '打卡点'
-}
-
-const businessTypeIconTypes: Record = {
- shop: 'shop',
- commercial: 'shop',
- restaurant: 'restaurant',
- cafe: 'cafe',
- vending: 'vending',
- bookstore: 'bookstore',
- cultural_shop: 'cultural_shop',
- photo_spot: 'photo_spot'
-}
-
-const categoryForBusinessType = (businessType?: string | null): MuseumCategory => {
- const normalizedBusinessType = normalizeSgsPoiSemanticType(businessType)
- if (!normalizedBusinessType) return businessCategory
-
- return {
- ...businessCategory,
- label: businessTypeLabels[normalizedBusinessType] || businessCategory.label,
- iconType: businessTypeIconTypes[normalizedBusinessType] || normalizedBusinessType
- }
-}
-
-const resolveSgsPoiVisitorVisible = (
- source: {
- visitorVisible?: boolean | null
- name?: string | null
- sourceNodeName?: string | null
- anchorNodeName?: string | null
- },
- category: MuseumCategory
-) => (
- typeof source.visitorVisible === 'boolean'
- ? source.visitorVisible
- : category.id !== 'space_service'
- && ![
- source.name,
- source.sourceNodeName,
- source.anchorNodeName
- ].some(isVisitorRestrictedPlaceName)
-)
-
-interface SgsHallPoiBuildOptions {
- fallbackY?: number
-}
-
-const sgsSpaceCenterConfidence = 'backend-sgs-sdk-space-center'
-const sgsSpaceBoundaryCenterConfidence = 'backend-sgs-sdk-space-boundary-center'
-const sgsHallEntranceConfidence = 'backend-sgs-sdk-hall-entrance'
-
-const normalizeTypeId = (value?: string | null) => normalizeSgsPoiSemanticType(value)
- .replace(/[^a-z0-9_-]+/g, '_')
- .replace(/^_+|_+$/g, '')
-
-const hiddenSgsPoiTypes = new Set([
- 'entrance_exit',
- 'hall_entrance',
- 'entrance_anchor',
- 'route_node',
- 'navigable_place',
- 'operation_experience'
-])
-
-/** Raw navigable-place records may enrich a hall, but are never visitor results themselves. */
-export const isSgsNavigablePlaceVisitorResult = (_place: SgsNavigablePlacePayload) => false
-
-export const isSgsPoiHiddenFromVisitorSearch = (poi: Pick<
- SgsPoiPayload,
- 'type' | 'typeName'
->) => [poi.type, poi.typeName]
- .map(normalizeSgsPoiSemanticType)
- .some((type) => hiddenSgsPoiTypes.has(type))
+ skippedPlaceCount: number
+}
+
+const businessTypeLabels: Record = {
+ shop: '购物',
+ commercial: '购物',
+ restaurant: '餐饮',
+ cafe: '餐饮',
+ vending: '购物',
+ bookstore: '购物',
+ cultural_shop: '购物',
+ photo_spot: '打卡点'
+}
+
+const businessTypeIconTypes: Record = {
+ shop: 'shop',
+ commercial: 'shop',
+ restaurant: 'restaurant',
+ cafe: 'cafe',
+ vending: 'vending',
+ bookstore: 'bookstore',
+ cultural_shop: 'cultural_shop',
+ photo_spot: 'photo_spot'
+}
+
+const categoryForBusinessType = (businessType?: string | null): MuseumCategory => {
+ const normalizedBusinessType = normalizeSgsPoiSemanticType(businessType)
+ if (!normalizedBusinessType) return businessCategory
+
+ return {
+ ...businessCategory,
+ label: businessTypeLabels[normalizedBusinessType] || businessCategory.label,
+ iconType: businessTypeIconTypes[normalizedBusinessType] || normalizedBusinessType
+ }
+}
+
+interface SgsHallPoiBuildOptions {
+ fallbackY?: number
+}
+
+const sgsSpaceCenterConfidence = 'backend-sgs-sdk-space-center'
+const sgsSpaceBoundaryCenterConfidence = 'backend-sgs-sdk-space-boundary-center'
+const sgsHallEntranceConfidence = 'backend-sgs-sdk-hall-entrance'
+
+const normalizeTypeId = (value?: string | null) => normalizeSgsPoiSemanticType(value)
+ .replace(/[^a-z0-9_-]+/g, '_')
+ .replace(/^_+|_+$/g, '')
+
+const hiddenSgsPoiTypes = new Set([
+ 'entrance_exit',
+ 'hall_entrance',
+ 'entrance_anchor',
+ 'route_node',
+ 'navigable_place',
+ 'operation_experience'
+])
+
+/** Raw navigable-place records may enrich a hall, but are never visitor results themselves. */
+export const isSgsNavigablePlaceVisitorResult = (_place: SgsNavigablePlacePayload) => false
+
+export const isSgsPoiHiddenFromVisitorSearch = (poi: Pick<
+ SgsPoiPayload,
+ 'type' | 'typeName'
+>) => [poi.type, poi.typeName]
+ .map(normalizeSgsPoiSemanticType)
+ .some((type) => hiddenSgsPoiTypes.has(type))
const hasExhibitionKeyword = (value?: string | null) => (
exhibitionHallKeywords.some((keyword) => normalizedText(value).includes(keyword))
)
-const searchableSpaceText = (space: SgsSpacePayload) => [
- space.name,
- space.type,
- space.sourceNodeName
-]
- .map((value) => normalizedText(value))
- .filter(Boolean)
- .join(' ')
+const parseBoundaryWktCenter = (
+ boundaryWkt?: string | null,
+ fallbackY?: number
+): [number, number, number] | undefined => {
+ const matches = [...(boundaryWkt || '').matchAll(/(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)/g)]
+ const points = matches
+ .map((match) => [Number(match[1]), Number(match[2])] as const)
+ .filter(([x, z]) => Number.isFinite(x) && Number.isFinite(z))
-const parseBoundaryWktCenter = (
- boundaryWkt?: string | null,
- fallbackY?: number
-): [number, number, number] | undefined => {
- const matches = [...(boundaryWkt || '').matchAll(/(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)/g)]
- const points = matches
- .map((match) => [Number(match[1]), Number(match[2])] as const)
- .filter(([x, z]) => Number.isFinite(x) && Number.isFinite(z))
-
- if (!points.length || !Number.isFinite(fallbackY)) return undefined
-
- const xs = points.map(([x]) => x)
- const zs = points.map(([, z]) => z)
- const x = (Math.min(...xs) + Math.max(...xs)) / 2
- const z = (Math.min(...zs) + Math.max(...zs)) / 2
-
- return [x, fallbackY as number, z]
-}
-
-const normalizePositionSourceWithFallbackY = (
- source: SgsPositionPayload | null | undefined,
- fallbackY?: number
-): [number, number, number] | undefined => {
- if (!source) return undefined
-
- const x = optionalNumber(source.x)
- const y = optionalNumber(source.y) ?? optionalNumber(fallbackY)
- const z = optionalNumber(source.z)
-
- if (typeof x === 'undefined' || typeof y === 'undefined' || typeof z === 'undefined') return undefined
- return [x, y, z]
-}
-
-const normalizeSpacePosition = (
- space: SgsSpacePayload,
- fallbackY?: number
-) => {
- const centerPosition = normalizePositionSourceWithFallbackY(space.center, fallbackY)
- if (centerPosition) {
- return {
- position: centerPosition,
- sourceConfidence: sgsSpaceCenterConfidence
- }
- }
-
- const boundaryCenter = parseBoundaryWktCenter(space.boundaryWkt, fallbackY)
- return boundaryCenter
- ? {
- position: boundaryCenter,
- sourceConfidence: sgsSpaceBoundaryCenterConfidence
- }
- : undefined
-}
-
-export const isExhibitionHallSpace = (space: SgsSpacePayload) => {
- const type = normalizeSgsPoiSemanticType(space.type)
- const name = normalizedText(space.name)
- const searchableText = searchableSpaceText(space)
+ if (!points.length || !Number.isFinite(fallbackY)) return undefined
- if (!exhibitionSpaceTypeWhitelist.has(type)) return false
- if (type === 'exhibition_hall') return true
- if (type === 'ramp') return name.includes('展览坡道')
+ const xs = points.map(([x]) => x)
+ const zs = points.map(([, z]) => z)
+ const x = (Math.min(...xs) + Math.max(...xs)) / 2
+ const z = (Math.min(...zs) + Math.max(...zs)) / 2
- return hasExhibitionKeyword(searchableText)
+ return [x, fallbackY as number, z]
}
-export const isExhibitionHallNavigablePlace = (place: SgsNavigablePlacePayload) => {
- const placeTypes = [
- place.category,
- place.type,
- place.typeCode,
- place.typeName
- ]
- .map((value) => normalizeSgsPoiSemanticType(value))
- .filter(Boolean)
-
- if (placeTypes.some((type) => nonHallNavigablePlaceTypes.has(type))) return false
-
- const searchableText = [
- place.name,
- place.ownerName,
+const normalizePositionSourceWithFallbackY = (
+ source: SgsPositionPayload | null | undefined,
+ fallbackY?: number
+): [number, number, number] | undefined => {
+ if (!source) return undefined
+
+ const x = optionalNumber(source.x)
+ const y = optionalNumber(source.y) ?? optionalNumber(fallbackY)
+ const z = optionalNumber(source.z)
+
+ if (typeof x === 'undefined' || typeof y === 'undefined' || typeof z === 'undefined') return undefined
+ return [x, y, z]
+}
+
+const normalizeSpacePosition = (
+ space: SgsSpacePayload,
+ fallbackY?: number
+) => {
+ const positionSources = [
+ space.labelPosition,
+ space.meshCenter,
+ space.position,
+ space.center,
+ space.centerPoint
+ ]
+
+ for (const source of positionSources) {
+ const position = normalizePositionSourceWithFallbackY(source, fallbackY)
+ if (!position) continue
+
+ const floorPresentationY = optionalNumber(fallbackY)
+
+ return {
+ // Indoor space labels use the space's authored X/Z anchor but sit on the
+ // same presentation plane as the floor POIs. Space label/mesh Y values
+ // describe the volume and can otherwise float several metres too high.
+ position: typeof floorPresentationY === 'number'
+ ? [position[0], floorPresentationY, position[2]] as [number, number, number]
+ : position,
+ sourceConfidence: sgsSpaceCenterConfidence
+ }
+ }
+
+ const boundaryCenter = parseBoundaryWktCenter(space.boundaryWkt, fallbackY)
+ return boundaryCenter
+ ? {
+ position: boundaryCenter,
+ sourceConfidence: sgsSpaceBoundaryCenterConfidence
+ }
+ : undefined
+}
+
+export const isExhibitionHallSpace = (space: SgsSpacePayload) => (
+ normalizeSgsPoiSemanticType(space.type) === 'exhibition_hall'
+)
+
+export const isExhibitionHallNavigablePlace = (place: SgsNavigablePlacePayload) => {
+ const placeTypes = [
+ place.category,
+ place.type,
+ place.typeCode,
+ place.typeName
+ ]
+ .map((value) => normalizeSgsPoiSemanticType(value))
+ .filter(Boolean)
+
+ if (placeTypes.some((type) => nonHallNavigablePlaceTypes.has(type))) return false
+
+ const searchableText = [
+ place.name,
+ place.ownerName,
place.category,
place.type,
place.typeCode,
@@ -572,59 +617,108 @@ export const isExhibitionHallNavigablePlace = (place: SgsNavigablePlacePayload)
return hasExhibitionKeyword(searchableText)
}
+export const isFloorExitNavigablePlace = (place: SgsNavigablePlacePayload) => (
+ normalizedText(place.anchorType).toUpperCase() === 'FLOOR_EXIT'
+)
+
const normalizedHallName = (value?: string | null) => normalizedText(value)
.replace(/[\s()()【】[\]_-]/g, '')
-const normalizePosition = (poi: SgsPoiPayload): [number, number, number] | undefined => normalizePositionSource(
- poi.position || {
- x: poi.x,
- y: poi.y,
- z: poi.z
+const normalizePosition = (poi: SgsPoiPayload): [number, number, number] | undefined => {
+ const positionSources = [
+ poi.meshCenter,
+ poi.position,
+ poi.labelPosition,
+ {
+ x: poi.x,
+ y: poi.y,
+ z: poi.z
+ }
+ ]
+
+ for (const source of positionSources) {
+ if (!source) continue
+ const position = normalizePositionSource(source)
+ if (position) return position
}
+
+ return undefined
+}
+
+const normalizePlacePosition = (
+ place: SgsNavigablePlacePayload,
+ fallbackY?: number
+): [number, number, number] | undefined => normalizePositionSourceWithFallbackY(
+ place.position
+ ? {
+ x: place.position.x,
+ y: place.position.y,
+ z: place.position.z
+ }
+ : {
+ x: place.x,
+ y: place.y,
+ z: place.z
+ },
+ fallbackY
)
-const normalizePlacePosition = (
- place: SgsNavigablePlacePayload,
- fallbackY?: number
-): [number, number, number] | undefined => normalizePositionSourceWithFallbackY(
- place.position
- ? {
- x: place.position.x,
- y: place.position.y,
- z: place.position.z
- }
- : {
- x: place.x,
- y: place.y,
- z: place.z
- },
- fallbackY
-)
+export const toMuseumFloorExitPoisFromSgs = (
+ navigablePlaces: SgsNavigablePlacePayload[],
+ floors: SgsSdkFloorSummaryPayload[],
+ fallbackFloorId: string,
+ options: SgsHallPoiBuildOptions = {}
+): MuseumPoi[] => navigablePlaces
+ .filter(isFloorExitNavigablePlace)
+ .map((place): MuseumPoi | null => {
+ const floorId = stringifyId(place.floorId) || fallbackFloorId
+ const placeId = stringifyId(place.id || place.nodeId)
+ const name = normalizedText(place.name) || normalizedText(place.ownerName)
+ const positionGltf = normalizePlacePosition(place, options.fallbackY)
-const categoryFor = (poi: SgsPoiPayload): MuseumCategory & { accessible?: boolean } => {
- const normalizedType = normalizeSgsPoiSemanticType(poi.type)
- const normalizedTypeName = normalizeSgsPoiSemanticType(poi.typeName)
- const resolvedType = categoryBySgsType[normalizedType]
- ? normalizedType
- : normalizedTypeName || normalizedType
- const normalizedBusinessType = normalizeSgsPoiSemanticType(poi.businessType)
-
- if (String(poi.poiGroup || '').toUpperCase() === 'BUSINESS') {
- const category = categoryForBusinessType(normalizedBusinessType || resolvedType)
- return category.iconType === businessCategory.iconType && poi.typeName
- ? { ...category, label: poi.typeName }
- : category
- }
-
- if (String(poi.poiGroup || '').toUpperCase() === 'OTHER') {
- return categoryBySgsType[resolvedType] || defaultCategory
- }
-
- if (resolvedType && categoryBySgsType[resolvedType]) {
- return categoryBySgsType[resolvedType]
- }
-
- if (poi.typeName) {
+ if (!placeId || !name || !positionGltf) return null
+
+ return {
+ id: `floor-exit-${placeId}`,
+ name,
+ floorId,
+ floorLabel: floorLabelForSgs(floorId, floors, place.floorCode, place.floorName),
+ primaryCategory: floorExitCategory,
+ categories: [floorExitCategory],
+ positionGltf,
+ sourceObjectName: stringifyId(place.nodeId) || undefined,
+ sourcePlaceId: placeId,
+ navigationReadiness: '位置预览',
+ accessible: false,
+ kind: 'guide' as const
+ }
+ })
+ .filter((poi): poi is MuseumPoi => Boolean(poi))
+
+const categoryFor = (poi: SgsPoiPayload): MuseumCategory & { accessible?: boolean } => {
+ const normalizedType = normalizeSgsPoiSemanticType(poi.type)
+ const normalizedTypeName = normalizeSgsPoiSemanticType(poi.typeName)
+ const resolvedType = categoryBySgsType[normalizedType]
+ ? normalizedType
+ : normalizedTypeName || normalizedType
+ const normalizedBusinessType = normalizeSgsPoiSemanticType(poi.businessType)
+
+ if (String(poi.poiGroup || '').toUpperCase() === 'BUSINESS') {
+ const category = categoryForBusinessType(normalizedBusinessType || resolvedType)
+ return category.iconType === businessCategory.iconType && poi.typeName
+ ? { ...category, label: poi.typeName }
+ : category
+ }
+
+ if (String(poi.poiGroup || '').toUpperCase() === 'OTHER') {
+ return categoryBySgsType[resolvedType] || defaultCategory
+ }
+
+ if (resolvedType && categoryBySgsType[resolvedType]) {
+ return categoryBySgsType[resolvedType]
+ }
+
+ if (poi.typeName) {
return {
...defaultCategory,
label: poi.typeName,
@@ -632,38 +726,39 @@ const categoryFor = (poi: SgsPoiPayload): MuseumCategory & { accessible?: boolea
}
}
- return defaultCategory
-}
-
-const kindForSgsPoi = (poi: SgsPoiPayload, category: MuseumCategory) => {
- if (isSgsPoiHiddenFromVisitorSearch(poi)) return 'guide'
- if (category.id === 'operation_experience' || category.id === 'navigation_anchor') return 'guide'
- if (category.id === hallCategory.id) return 'hall'
-
- // Raw POI endpoints can use a space-like semantic type (restaurant, shop,
- // theater) without being a source space. Space records are adapted through
- // toMuseumSpacePointFromSgs; raw records remain facility POIs.
- return 'facility'
-}
+ return defaultCategory
+}
-export const toMuseumPoiFromSgs = (
- poi: SgsPoiPayload,
- floors: SgsSdkFloorSummaryPayload[]
-): MuseumPoi => {
- const sourceFloorId = stringifyId(poi.floorId)
- const matchedFloor = floors.find((floor) => stringifyId(floor.floorId) === sourceFloorId || floor.floorCode === poi.floorCode)
- const floorId = stringifyId(matchedFloor?.floorId)
- || sourceFloorId
- || stringifyId(poi.floorCode)
- const category = categoryFor(poi)
- const kind = kindForSgsPoi(poi, category)
- const spatialAreaId = stringifyId(poi.spatialAreaId)
- const spatialAreaName = normalizedText(poi.spatialAreaName)
- const name = poi.name?.trim() || `未命名点位 ${stringifyId(poi.id)}`
-
- return {
- id: stringifyId(poi.id),
- name,
+const kindForSgsPoi = (poi: SgsPoiPayload, category: MuseumCategory) => {
+ if (isSgsPoiHiddenFromVisitorSearch(poi)) return 'guide'
+ if (category.id === 'operation_experience' || category.id === 'navigation_anchor') return 'guide'
+ if (category.id === hallCategory.id) return 'hall'
+
+ // Raw POI endpoints can use a space-like semantic type (restaurant, shop,
+ // theater) without being a source space. Space records are adapted through
+ // toMuseumSpacePointFromSgs; raw records remain facility POIs.
+ return 'facility'
+}
+
+export const toMuseumPoiFromSgs = (
+ poi: SgsPoiPayload,
+ floors: SgsSdkFloorSummaryPayload[]
+): MuseumPoi => {
+ const sourceFloorId = stringifyId(poi.floorId)
+ const matchedFloor = floors.find((floor) => stringifyId(floor.floorId) === sourceFloorId || floor.floorCode === poi.floorCode)
+ const floorId = stringifyId(matchedFloor?.floorId)
+ || sourceFloorId
+ || stringifyId(poi.floorCode)
+ const category = categoryFor(poi)
+ const kind = kindForSgsPoi(poi, category)
+ const spatialAreaId = stringifyId(poi.spatialAreaId)
+ const spatialAreaName = normalizedText(poi.spatialAreaName)
+ const name = poi.name?.trim() || `未命名点位 ${stringifyId(poi.id)}`
+ const sourceObjectNames = resolveSgsPoiSourceObjectNames(poi)
+
+ return {
+ id: stringifyId(poi.id),
+ name,
floorId,
floorLabel: formatSgsFloorLabel(matchedFloor?.floorCode || poi.floorCode, matchedFloor?.floorName),
primaryCategory: {
@@ -679,47 +774,47 @@ export const toMuseumPoiFromSgs = (
}
],
positionGltf: normalizePosition(poi),
- sourceObjectName: poi.anchorNodeName || undefined,
- sourceConfidence: 'backend-sgs-sdk',
- navigationReadiness: '位置预览',
- visitorVisible: resolveSgsPoiVisitorVisible(poi, category),
- accessible: category.accessible === true,
- kind,
- hallId: kind === 'hall' ? spatialAreaId || stringifyId(poi.id) : undefined,
- hallName: spatialAreaName || (kind === 'hall' ? name : undefined),
- spaceId: spatialAreaId || undefined,
- sourceSpaceId: spatialAreaId || undefined
- }
-}
-
-const normalizeAudioTargetType = (targetType?: string | null): AudioPlayTargetType | undefined => {
- const normalized = targetType?.toUpperCase()
- if (normalized === 'ITEM' || normalized === 'STOP') return normalized
-
- return undefined
-}
-
-export const toExplainGuideStopFromSgs = (stop: SgsGuideStopPayload): ExplainGuideStop | null => {
- const id = stringifyId(stop.id)
- if (!id) return null
-
- return {
- id,
- name: normalizedText(stop.name) || `讲解点${id}`,
- hallId: stringifyId(stop.hallId) || undefined,
- hallName: normalizedText(stop.hallName) || undefined,
- floorId: stringifyId(stop.floorId) || undefined,
- targetType: normalizeAudioTargetType(stop.targetType) || 'STOP',
- targetId: stringifyId(stop.targetId) || id,
- coverImageUrl: normalizedText(stop.coverImageUrl) || undefined,
- description: normalizedText(stop.description) || undefined,
- hasAudio: stop.hasAudio === true || Boolean(normalizedText(stop.audioUrl)),
- poiId: stringifyId(stop.poiId) || undefined,
- outlineId: stringifyId(stop.outlineId || stop.routeId) || undefined,
- outlineName: normalizedText(stop.outlineName || stop.routeName) || undefined,
- sort: optionalNumber(stop.sort ?? stop.seqOrder)
- }
-}
+ sourceObjectName: sourceObjectNames.sourceObjectName,
+ mergedSourceObjectNames: sourceObjectNames.mergedSourceObjectNames,
+ sourceConfidence: 'backend-sgs-sdk',
+ navigationReadiness: '位置预览',
+ accessible: category.accessible === true,
+ kind,
+ hallId: kind === 'hall' ? spatialAreaId || stringifyId(poi.id) : undefined,
+ hallName: spatialAreaName || (kind === 'hall' ? name : undefined),
+ spaceId: spatialAreaId || undefined,
+ sourceSpaceId: spatialAreaId || undefined
+ }
+}
+
+const normalizeAudioTargetType = (targetType?: string | null): AudioPlayTargetType | undefined => {
+ const normalized = targetType?.toUpperCase()
+ if (normalized === 'ITEM' || normalized === 'STOP') return normalized
+
+ return undefined
+}
+
+export const toExplainGuideStopFromSgs = (stop: SgsGuideStopPayload): ExplainGuideStop | null => {
+ const id = stringifyId(stop.id)
+ if (!id) return null
+
+ return {
+ id,
+ name: normalizedText(stop.name) || `讲解点${id}`,
+ hallId: stringifyId(stop.hallId) || undefined,
+ hallName: normalizedText(stop.hallName) || undefined,
+ floorId: stringifyId(stop.floorId) || undefined,
+ targetType: normalizeAudioTargetType(stop.targetType) || 'STOP',
+ targetId: stringifyId(stop.targetId) || id,
+ coverImageUrl: normalizedText(stop.coverImageUrl) || undefined,
+ description: normalizedText(stop.description) || undefined,
+ hasAudio: stop.hasAudio === true || Boolean(normalizedText(stop.audioUrl)),
+ poiId: stringifyId(stop.poiId) || undefined,
+ outlineId: stringifyId(stop.outlineId || stop.routeId) || undefined,
+ outlineName: normalizedText(stop.outlineName || stop.routeName) || undefined,
+ sort: optionalNumber(stop.sort ?? stop.seqOrder)
+ }
+}
export const toLocationPreviewFromPoi = (poi: MuseumPoi): GuideLocationPreview => ({
poiId: poi.id,
@@ -778,149 +873,165 @@ const findMatchedHallSpace = (
return rankedMatches[0]?.space
}
-const floorLabelForSgs = (
- floorId: string,
- floors: SgsSdkFloorSummaryPayload[],
- floorCode?: string | null,
+const floorLabelForSgs = (
+ floorId: string,
+ floors: SgsSdkFloorSummaryPayload[],
+ floorCode?: string | null,
floorName?: string | null
) => {
const matchedFloor = floors.find((floor) => stringifyId(floor.floorId) === floorId || floor.floorCode === floorCode)
return formatSgsFloorLabel(
matchedFloor?.floorCode || floorCode,
matchedFloor?.floorName || floorName
- )
-}
-
-const categoryForSgsSpace = (space: SgsSpacePayload): MuseumCategory => {
- const normalizedType = normalizeTypeId(space.type)
-
- if (normalizedType && spaceCategoryBySgsType[normalizedType]) {
- return spaceCategoryBySgsType[normalizedType]
- }
-
- const typeLabel = normalizedText(space.type)
- if (typeLabel) {
- return {
- id: `space_${normalizedType || 'point'}`,
- label: typeLabel,
- iconType: normalizedType || spaceCategory.iconType
- }
- }
-
- return spaceCategory
-}
-
-const createCanonicalSpacePoiId = (space: SgsSpacePayload) => {
- const spaceId = stringifyId(space.id)
- if (!spaceId) return ''
-
- return `${isExhibitionHallSpace(space) ? 'hall' : 'space'}-${spaceId}`
-}
-
-export const toMuseumSpacePointFromSgs = (
- space: SgsSpacePayload,
- floors: SgsSdkFloorSummaryPayload[],
- fallbackFloorId: string,
- options: SgsHallPoiBuildOptions = {}
-): MuseumPoi | null => {
- const spaceId = stringifyId(space.id)
- const floorId = stringifyId(space.floorId) || fallbackFloorId
- const name = normalizedText(space.name)
- const spacePosition = normalizeSpacePosition(space, options.fallbackY)
-
- if (!spaceId || !name || !spacePosition) return null
-
- const category = categoryForSgsSpace(space)
- const poiId = createCanonicalSpacePoiId(space)
-
- return {
- // 可生成展厅类 marker 的空间统一使用 hall ID,确保弱网回退结果仍可直接定位。
- id: poiId,
- name,
- floorId,
- floorLabel: floorLabelForSgs(floorId, floors),
- primaryCategory: category,
- categories: [
- category
- ],
- positionGltf: spacePosition.position,
- sourceObjectName: space.sourceNodeName || undefined,
- sourceConfidence: spacePosition.sourceConfidence,
- navigationReadiness: '位置预览',
- visitorVisible: resolveSgsPoiVisitorVisible(space, category),
- accessible: false,
- kind: 'space',
- hallId: category.id === hallCategory.id ? spaceId : undefined,
- hallName: category.id === hallCategory.id ? name : undefined,
- spaceId,
- sourceSpaceId: spaceId
- }
-}
-
-const createHallEntrance = (
- place: SgsNavigablePlacePayload,
- floors: SgsSdkFloorSummaryPayload[],
- fallbackFloorId: string,
- fallbackY?: number
-) => {
+ )
+}
+
+const categoryForSgsSpace = (space: SgsSpacePayload): MuseumCategory => {
+ const normalizedType = normalizeTypeId(space.type)
+
+ if (normalizedType && spaceCategoryBySgsType[normalizedType]) {
+ return spaceCategoryBySgsType[normalizedType]
+ }
+
+ const typeLabel = normalizedText(space.type)
+ if (typeLabel) {
+ return {
+ id: `space_${normalizedType || 'point'}`,
+ label: typeLabel,
+ iconType: normalizedType || spaceCategory.iconType
+ }
+ }
+
+ return spaceCategory
+}
+
+const createCanonicalSpacePoiId = (space: SgsSpacePayload) => {
+ const spaceId = stringifyId(space.id)
+ if (!spaceId) return ''
+
+ return `${isExhibitionHallSpace(space) ? 'hall' : 'space'}-${spaceId}`
+}
+
+const resolveSgsSpaceSourceObjectName = (
+ space: SgsSpacePayload,
+ floors: SgsSdkFloorSummaryPayload[],
+ floorId: string,
+ name: string
+) => {
+ const explicitName = normalizedText(space.sourceNodeName)
+ if (explicitName && !/[??]/u.test(explicitName)) return explicitName
+
+ const floor = floors.find((candidate) => (
+ stringifyId(candidate.floorId) === floorId
+ || candidate.floorCode === space.floorCode
+ ))
+ const floorCode = normalizedText(space.floorCode || floor?.floorCode)
+ return floorCode && name ? `${floorCode}_${name}` : name || undefined
+}
+
+export const toMuseumSpacePointFromSgs = (
+ space: SgsSpacePayload,
+ floors: SgsSdkFloorSummaryPayload[],
+ fallbackFloorId: string,
+ options: SgsHallPoiBuildOptions = {}
+): MuseumPoi | null => {
+ const spaceId = stringifyId(space.id)
+ const floorId = stringifyId(space.floorId) || fallbackFloorId
+ const name = normalizedText(space.name)
+ const spacePosition = normalizeSpacePosition(space, options.fallbackY)
+
+ if (!spaceId || !name || !spacePosition) return null
+
+ const category = categoryForSgsSpace(space)
+ const poiId = createCanonicalSpacePoiId(space)
+ const sourceObjectName = resolveSgsSpaceSourceObjectName(space, floors, floorId, name)
+
+ return {
+ // 可生成展厅类 marker 的空间统一使用 hall ID,确保弱网回退结果仍可直接定位。
+ id: poiId,
+ name,
+ floorId,
+ floorLabel: floorLabelForSgs(floorId, floors),
+ primaryCategory: category,
+ categories: [
+ category
+ ],
+ positionGltf: spacePosition.position,
+ sourceObjectName,
+ sourceConfidence: spacePosition.sourceConfidence,
+ navigationReadiness: '位置预览',
+ accessible: false,
+ kind: 'space',
+ hallId: category.id === hallCategory.id ? spaceId : undefined,
+ hallName: category.id === hallCategory.id ? name : undefined,
+ spaceId,
+ sourceSpaceId: spaceId
+ }
+}
+
+const createHallEntrance = (
+ place: SgsNavigablePlacePayload,
+ floors: SgsSdkFloorSummaryPayload[],
+ fallbackFloorId: string,
+ fallbackY?: number
+) => {
const floorId = stringifyId(place.floorId) || fallbackFloorId
const placeId = stringifyId(place.id || place.nodeId)
const routeNodeId = stringifyId(place.nodeId) || undefined
return {
- id: placeId ? `hall-entrance-${placeId}` : `hall-entrance-${floorId}-${normalizedHallName(place.name)}`,
- name: normalizedText(place.name) || normalizedText(place.ownerName) || '展厅出入口',
- floorId,
- floorLabel: floorLabelForSgs(floorId, floors, place.floorCode, place.floorName),
- positionGltf: normalizePlacePosition(place, fallbackY),
+ id: placeId ? `hall-entrance-${placeId}` : `hall-entrance-${floorId}-${normalizedHallName(place.name)}`,
+ name: normalizedText(place.name) || normalizedText(place.ownerName) || '展厅出入口',
+ floorId,
+ floorLabel: floorLabelForSgs(floorId, floors, place.floorCode, place.floorName),
+ positionGltf: normalizePlacePosition(place, fallbackY),
sourceObjectName: routeNodeId || undefined,
sourcePlaceId: routeNodeId ? placeId || undefined : undefined,
routeNodeId
}
}
-const createHallPoiId = (
- space: SgsSpacePayload
-) => {
- const canonicalId = createCanonicalSpacePoiId(space)
- if (canonicalId) return canonicalId
-
- const ownerName = normalizedHallName(space.name)
- const floorIdentity = normalizedHallName(stringifyId(space.floorId) || 'unknown-floor')
- if (ownerName) return `hall-space-${floorIdentity}-${ownerName}`
-
- return `hall-space-${floorIdentity}-unknown`
-}
+const createHallPoiId = (
+ space: SgsSpacePayload
+) => {
+ const canonicalId = createCanonicalSpacePoiId(space)
+ if (canonicalId) return canonicalId
-const createSpaceFallbackHallPoi = (
- space: SgsSpacePayload,
- floors: SgsSdkFloorSummaryPayload[],
- fallbackFloorId: string,
- fallbackY?: number
-): MuseumPoi | null => {
- const floorId = stringifyId(space.floorId) || fallbackFloorId
- const spacePosition = normalizeSpacePosition(space, fallbackY)
- const position = spacePosition?.position
- const hallId = stringifyId(space.id)
+ const ownerName = normalizedHallName(space.name)
+ const floorIdentity = normalizedHallName(stringifyId(space.floorId) || 'unknown-floor')
+ if (ownerName) return `hall-space-${floorIdentity}-${ownerName}`
- if (!hallId || !normalizedText(space.name) || !position) return null
- const category = categoryForSgsSpace(space)
-
- return {
+ return `hall-space-${floorIdentity}-unknown`
+}
+
+const createSpaceFallbackHallPoi = (
+ space: SgsSpacePayload,
+ floors: SgsSdkFloorSummaryPayload[],
+ fallbackFloorId: string,
+ fallbackY?: number
+): MuseumPoi | null => {
+ const floorId = stringifyId(space.floorId) || fallbackFloorId
+ const spacePosition = normalizeSpacePosition(space, fallbackY)
+ const position = spacePosition?.position
+ const hallId = stringifyId(space.id)
+
+ if (!hallId || !normalizedText(space.name) || !position) return null
+ const category = categoryForSgsSpace(space)
+
+ return {
id: `hall-${hallId}`,
name: normalizedText(space.name),
floorId,
floorLabel: floorLabelForSgs(floorId, floors),
- primaryCategory: category,
- categories: [
- category
- ],
- positionGltf: position,
- sourceObjectName: space.sourceNodeName || undefined,
- sourceConfidence: spacePosition.sourceConfidence,
- navigationReadiness: '位置预览',
- visitorVisible: resolveSgsPoiVisitorVisible(space, category),
- accessible: false,
+ primaryCategory: category,
+ categories: [
+ category
+ ],
+ positionGltf: position,
+ sourceObjectName: space.sourceNodeName || undefined,
+ sourceConfidence: spacePosition.sourceConfidence,
+ navigationReadiness: '位置预览',
+ accessible: false,
kind: 'hall',
hallId,
hallName: normalizedText(space.name),
@@ -930,100 +1041,99 @@ const createSpaceFallbackHallPoi = (
}
}
-export const toMuseumHallPoisFromSgs = (
- spaces: SgsSpacePayload[],
- navigablePlaces: SgsNavigablePlacePayload[],
- floors: SgsSdkFloorSummaryPayload[],
- fallbackFloorId: string,
- options: SgsHallPoiBuildOptions = {}
-): MuseumPoi[] => {
+export const toMuseumHallPoisFromSgs = (
+ spaces: SgsSpacePayload[],
+ navigablePlaces: SgsNavigablePlacePayload[],
+ floors: SgsSdkFloorSummaryPayload[],
+ fallbackFloorId: string,
+ options: SgsHallPoiBuildOptions = {}
+): MuseumPoi[] => {
const hallSpaces = spaces.filter(isExhibitionHallSpace)
const hallPlaces = navigablePlaces.filter(isExhibitionHallNavigablePlace)
const halls = new Map()
- hallPlaces.forEach((place) => {
- const matchedSpace = findMatchedHallSpace(place, hallSpaces)
- if (!matchedSpace) return
-
- const entrance = createHallEntrance(place, floors, fallbackFloorId, options.fallbackY)
- const spacePosition = matchedSpace ? normalizeSpacePosition(matchedSpace, options.fallbackY) : undefined
- const spaceCenter = spacePosition?.position
- const entrancePosition = entrance.positionGltf
- const fallbackFloorIdForPoi = stringifyId(matchedSpace?.floorId) || entrance.floorId || fallbackFloorId
- const fallbackFloorLabel = floorLabelForSgs(
- fallbackFloorIdForPoi,
+ hallPlaces.forEach((place) => {
+ const matchedSpace = findMatchedHallSpace(place, hallSpaces)
+ if (!matchedSpace) return
+
+ const entrance = createHallEntrance(place, floors, fallbackFloorId, options.fallbackY)
+ const spacePosition = matchedSpace ? normalizeSpacePosition(matchedSpace, options.fallbackY) : undefined
+ const spaceCenter = spacePosition?.position
+ const entrancePosition = entrance.positionGltf
+ const fallbackFloorIdForPoi = stringifyId(matchedSpace?.floorId) || entrance.floorId || fallbackFloorId
+ const fallbackFloorLabel = floorLabelForSgs(
+ fallbackFloorIdForPoi,
floors
- )
- const hallId = stringifyId(matchedSpace?.id) || undefined
- const poiId = createHallPoiId(matchedSpace)
- const hallName = normalizedText(matchedSpace?.name) || placeHallName(place) || entrance.name
- const category = categoryForSgsSpace(matchedSpace)
- const existing = halls.get(poiId)
+ )
+ const hallId = stringifyId(matchedSpace?.id) || undefined
+ const poiId = createHallPoiId(matchedSpace)
+ const hallName = normalizedText(matchedSpace?.name) || placeHallName(place) || entrance.name
+ const category = categoryForSgsSpace(matchedSpace)
+ const existing = halls.get(poiId)
if (existing) {
- existing.entrances = [
- ...(existing.entrances || []),
- entrance
- ]
- if (spaceCenter) {
- existing.positionGltf = spaceCenter
- existing.floorId = fallbackFloorIdForPoi
- existing.floorLabel = fallbackFloorLabel
- existing.sourceObjectName = matchedSpace?.sourceNodeName || undefined
- existing.sourceConfidence = spacePosition.sourceConfidence
- return
- }
-
- if (!existing.positionGltf && entrancePosition) {
- existing.positionGltf = entrancePosition
- existing.floorId = entrance.floorId
- existing.floorLabel = entrance.floorLabel
- existing.sourceObjectName = entrance.sourceObjectName
- existing.sourcePlaceId = entrance.sourcePlaceId
- existing.sourceConfidence = sgsHallEntranceConfidence
- }
- return
- }
-
- const usesSpaceCenter = Boolean(spaceCenter)
- const positionGltf = spaceCenter || entrancePosition
- if (!positionGltf) return
-
- halls.set(poiId, {
- id: poiId,
- name: hallName,
- floorId: usesSpaceCenter ? fallbackFloorIdForPoi : entrance.floorId,
- floorLabel: usesSpaceCenter ? fallbackFloorLabel : entrance.floorLabel,
- primaryCategory: category,
- categories: [
- category,
- ...(category.id === hallCategory.id ? [hallEntranceCategory] : [])
- ],
- positionGltf,
- sourceObjectName: usesSpaceCenter
- ? matchedSpace?.sourceNodeName || undefined
- : entrance.sourceObjectName,
- sourceConfidence: usesSpaceCenter
- ? spacePosition?.sourceConfidence || sgsSpaceCenterConfidence
- : sgsHallEntranceConfidence,
- navigationReadiness: '位置预览',
- visitorVisible: resolveSgsPoiVisitorVisible(matchedSpace, category),
- accessible: false,
- kind: 'hall',
- hallId,
- hallName,
- spaceId: hallId,
- sourcePlaceId: usesSpaceCenter ? undefined : entrance.sourcePlaceId,
- sourceSpaceId: hallId,
- entrances: [entrance]
- })
+ existing.entrances = [
+ ...(existing.entrances || []),
+ entrance
+ ]
+ if (spaceCenter) {
+ existing.positionGltf = spaceCenter
+ existing.floorId = fallbackFloorIdForPoi
+ existing.floorLabel = fallbackFloorLabel
+ existing.sourceObjectName = matchedSpace?.sourceNodeName || undefined
+ existing.sourceConfidence = spacePosition.sourceConfidence
+ return
+ }
+
+ if (!existing.positionGltf && entrancePosition) {
+ existing.positionGltf = entrancePosition
+ existing.floorId = entrance.floorId
+ existing.floorLabel = entrance.floorLabel
+ existing.sourceObjectName = entrance.sourceObjectName
+ existing.sourcePlaceId = entrance.sourcePlaceId
+ existing.sourceConfidence = sgsHallEntranceConfidence
+ }
+ return
+ }
+
+ const usesSpaceCenter = Boolean(spaceCenter)
+ const positionGltf = spaceCenter || entrancePosition
+ if (!positionGltf) return
+
+ halls.set(poiId, {
+ id: poiId,
+ name: hallName,
+ floorId: usesSpaceCenter ? fallbackFloorIdForPoi : entrance.floorId,
+ floorLabel: usesSpaceCenter ? fallbackFloorLabel : entrance.floorLabel,
+ primaryCategory: category,
+ categories: [
+ category,
+ ...(category.id === hallCategory.id ? [hallEntranceCategory] : [])
+ ],
+ positionGltf,
+ sourceObjectName: usesSpaceCenter
+ ? matchedSpace?.sourceNodeName || undefined
+ : entrance.sourceObjectName,
+ sourceConfidence: usesSpaceCenter
+ ? spacePosition?.sourceConfidence || sgsSpaceCenterConfidence
+ : sgsHallEntranceConfidence,
+ navigationReadiness: '位置预览',
+ accessible: false,
+ kind: 'hall',
+ hallId,
+ hallName,
+ spaceId: hallId,
+ sourcePlaceId: usesSpaceCenter ? undefined : entrance.sourcePlaceId,
+ sourceSpaceId: hallId,
+ entrances: [entrance]
+ })
})
hallSpaces.forEach((space) => {
const hallId = stringifyId(space.id)
if (!hallId || halls.has(`hall-${hallId}`)) return
- const fallbackPoi = createSpaceFallbackHallPoi(space, floors, fallbackFloorId, options.fallbackY)
+ const fallbackPoi = createSpaceFallbackHallPoi(space, floors, fallbackFloorId, options.fallbackY)
if (fallbackPoi) {
halls.set(fallbackPoi.id, fallbackPoi)
}
@@ -1142,19 +1252,19 @@ const addIssue = (
})
}
-const missingFieldsForPoi = (poi: MuseumPoi) => {
- const fields: string[] = []
- if (!poi.id) fields.push('id')
- if (!poi.name) fields.push('name')
- if (!poi.floorId) fields.push('floorId')
- if (!poi.floorLabel) fields.push('floorLabel')
- if (
- !Array.isArray(poi.positionGltf)
- || poi.positionGltf.length !== 3
- || poi.positionGltf.some((value) => !Number.isFinite(value))
- ) fields.push('position')
- return fields
-}
+const missingFieldsForPoi = (poi: MuseumPoi) => {
+ const fields: string[] = []
+ if (!poi.id) fields.push('id')
+ if (!poi.name) fields.push('name')
+ if (!poi.floorId) fields.push('floorId')
+ if (!poi.floorLabel) fields.push('floorLabel')
+ if (
+ !Array.isArray(poi.positionGltf)
+ || poi.positionGltf.length !== 3
+ || poi.positionGltf.some((value) => !Number.isFinite(value))
+ ) fields.push('position')
+ return fields
+}
export const createGuideDataIntegrityReport = (
diagnostics: GuideMapDiagnostics,
@@ -1229,7 +1339,7 @@ export const createGuideDataIntegrityReport = (
}
if (poi.id) poiIds.add(poi.id)
- if (poi.floorId && !floorIds.has(poi.floorId)) {
+ if (poi.floorId && !floorIds.has(poi.floorId)) {
addIssue(issues, {
scope: 'poi',
severity: 'error',
@@ -1238,21 +1348,21 @@ export const createGuideDataIntegrityReport = (
floorLabel: poi.floorLabel,
poiId: poi.id,
fields: ['floorId']
- })
- }
-
- if (!isPoiSearchCategorySupported(poi)) {
- addIssue(issues, {
- scope: 'poi',
- severity: 'warn',
- message: `${poi.name || poi.id} 缺少有效搜索分类`,
- floorId: poi.floorId,
- floorLabel: poi.floorLabel,
- poiId: poi.id,
- fields: ['primaryCategory']
- })
- }
- })
+ })
+ }
+
+ if (!isPoiSearchCategorySupported(poi)) {
+ addIssue(issues, {
+ scope: 'poi',
+ severity: 'warn',
+ message: `${poi.name || poi.id} 缺少有效搜索分类`,
+ floorId: poi.floorId,
+ floorLabel: poi.floorLabel,
+ poiId: poi.id,
+ fields: ['primaryCategory']
+ })
+ }
+ })
const errorCount = issues.filter((issue) => issue.severity === 'error').length
const warningCount = issues.filter((issue) => issue.severity === 'warn').length
diff --git a/src/data/adapters/sgsSdkRouteAdapter.ts b/src/data/adapters/sgsSdkRouteAdapter.ts
index 67e7ea2..6d6b789 100644
--- a/src/data/adapters/sgsSdkRouteAdapter.ts
+++ b/src/data/adapters/sgsSdkRouteAdapter.ts
@@ -4,7 +4,8 @@ import type {
GuideRouteFloorSegment,
GuideRoutePoint,
GuideRouteResult,
- GuideRouteTarget
+ GuideRouteTarget,
+ GuideRouteTransition
} from '@/domain/museum'
import type {
SgsRoutePathPointPayload,
@@ -13,7 +14,6 @@ import type {
import type {
SgsRouteResult as SgsSdkRuntimeRouteResult
} from '@/types/sgs-map-sdk'
-import { toAppFloorId } from '@/services/sgs/SgsMapEventAdapter'
type SgsRoutePathNode = {
nodeId?: string | number
@@ -42,6 +42,87 @@ const stringifyRouteId = (value: unknown, fallback = '') => {
return String(value)
}
+/**
+ * Route responses can mix the published database floor id with a floor code
+ * such as `L5` or `5F`. The renderer uses the published id as its model key,
+ * so route data must be resolved to that id at the adapter boundary.
+ */
+const canonicalFloorKey = (value: unknown) => {
+ const normalized = stringifyRouteId(value).trim().toUpperCase()
+ if (!normalized || /^\d{6,}$/.test(normalized)) return ''
+
+ if (normalized === 'EXTERIOR' || normalized.includes('室外') || normalized.includes('外观')) {
+ return 'exterior'
+ }
+
+ const basementMatch = normalized.match(/^(?:B|L-?|负)\s*(\d+(?:\.\d+)?)(?:层|F)?$/)
+ if (basementMatch && (normalized.startsWith('B') || normalized.startsWith('L-') || normalized.startsWith('负'))) {
+ return `l-${Number(basementMatch[1])}`
+ }
+
+ if (normalized === 'MF') return 'l1.5'
+
+ const floorMatch = normalized.match(/^(?:L|F)?\s*(\d+(?:\.\d+)?)(?:F|层)?$/)
+ if (floorMatch) return `l${Number(floorMatch[1])}`
+
+ const chineseFloorMatch = normalized.match(/^(?:负\s*)?(\d+(?:\.\d+)?)\s*层$/)
+ if (chineseFloorMatch) {
+ return normalized.startsWith('负')
+ ? `l-${Number(chineseFloorMatch[1])}`
+ : `l${Number(chineseFloorMatch[1])}`
+ }
+
+ return ''
+}
+
+type RouteFloorIdResolver = (value: unknown, fallback?: string, hints?: unknown[]) => string
+
+const createRouteFloorIdResolver = (
+ startTarget: GuideRouteTarget,
+ endTarget: GuideRouteTarget,
+ backendSegments: SgsRoutePlanResponsePayload['segments'] = []
+): RouteFloorIdResolver => {
+ const aliases = new Map()
+
+ const addAlias = (value: unknown, floorId: string) => {
+ const raw = stringifyRouteId(value).trim()
+ if (!raw || aliases.has(raw.toLowerCase())) return
+ aliases.set(raw.toLowerCase(), floorId)
+ const canonical = canonicalFloorKey(raw)
+ if (canonical && !aliases.has(canonical)) aliases.set(canonical, floorId)
+ }
+
+ const registerFloor = (floorId: unknown, ...tokens: unknown[]) => {
+ const resolvedFloorId = stringifyRouteId(floorId)
+ if (!resolvedFloorId) return
+ addAlias(resolvedFloorId, resolvedFloorId)
+ tokens.forEach((token) => addAlias(token, resolvedFloorId))
+ }
+
+ registerFloor(startTarget.floorId, startTarget.floorLabel)
+ registerFloor(endTarget.floorId, endTarget.floorLabel)
+ ;(backendSegments || []).forEach((segment) => {
+ registerFloor(segment.floorId, segment.floorCode, segment.floorName)
+ registerFloor(segment.targetFloorId)
+ registerFloor(segment.fromFloorId)
+ })
+
+ return (value, fallback = '', hints = []) => {
+ const raw = stringifyRouteId(value)
+ if (raw && aliases.has(raw.toLowerCase())) return aliases.get(raw.toLowerCase()) || raw
+
+ const candidates = [raw, ...hints]
+ .map((candidate) => canonicalFloorKey(candidate))
+ .filter(Boolean)
+ for (const candidate of candidates) {
+ const resolved = aliases.get(candidate)
+ if (resolved) return resolved
+ }
+
+ return raw || fallback
+ }
+}
+
const samePosition = (
a: [number, number, number],
b: [number, number, number]
@@ -53,10 +134,13 @@ const pointPosition = (point: SgsRoutePathNode): [number, number, number] => [
finiteNumber(point.z) ?? 0
]
-const targetEndpoint = (target: GuideRouteTarget): GuideRouteEndpoint => ({
+const targetEndpoint = (
+ target: GuideRouteTarget,
+ resolveFloorId: RouteFloorIdResolver
+): GuideRouteEndpoint => ({
poiId: target.poiId,
name: target.name,
- floorId: target.floorId,
+ floorId: resolveFloorId(target.floorId),
floorLabel: target.floorLabel,
routeNodeId: target.routeNodeId,
position: target.positionGltf || [0, 0, 0]
@@ -65,7 +149,8 @@ const targetEndpoint = (target: GuideRouteTarget): GuideRouteEndpoint => ({
const normalizePath = (
route: CompatibleSgsRouteResult,
startFloorId: string,
- endFloorId: string
+ endFloorId: string,
+ resolveFloorId: RouteFloorIdResolver
): GuideRoutePoint[] => {
const pathPoints = route.pathPoints ?? route.path ?? []
@@ -76,15 +161,15 @@ const normalizePath = (
if (route.path?.length) {
return route.path.map((point, index) => ({
nodeId: String(point.nodeId || `sdk-route-point-${index}`),
- floorId: toAppFloorId(point.floorId ?? startFloorId),
+ floorId: resolveFloorId(point.floorId, startFloorId),
position: pointPosition(point)
}))
}
const isCrossFloor = startFloorId !== endFloorId
const totalPoints = pathPoints.length
- const normalizedStartFloorId = toAppFloorId(startFloorId)
- const normalizedEndFloorId = toAppFloorId(endFloorId)
+ const normalizedStartFloorId = resolveFloorId(startFloorId, startFloorId)
+ const normalizedEndFloorId = resolveFloorId(endFloorId, endFloorId)
return pathPoints.map((point, index) => {
const nodeId = `sdk-pp-${index}`
@@ -113,10 +198,11 @@ const normalizePath = (
const createFloorSegments = (
points: GuideRoutePoint[],
start: GuideRouteTarget,
- end: GuideRouteTarget
+ end: GuideRouteTarget,
+ resolveFloorId: RouteFloorIdResolver
): GuideRouteFloorSegment[] => {
- const normalizedStartFloorId = toAppFloorId(start.floorId)
- const normalizedEndFloorId = toAppFloorId(end.floorId)
+ const normalizedStartFloorId = resolveFloorId(start.floorId, start.floorId)
+ const normalizedEndFloorId = resolveFloorId(end.floorId, end.floorId)
const labels = new Map([
[normalizedStartFloorId, start.floorLabel],
@@ -125,15 +211,16 @@ const createFloorSegments = (
return points.reduce((segments, point) => {
const current = segments[segments.length - 1]
- if (current && current.floorId === point.floorId) {
+ const pointFloorId = resolveFloorId(point.floorId, normalizedStartFloorId)
+ if (current && current.floorId === pointFloorId) {
current.points.push(point)
return segments
}
segments.push({
- floorId: point.floorId,
- floorLabel: labels.get(point.floorId) || point.floorId,
- points: [point]
+ floorId: pointFloorId,
+ floorLabel: labels.get(pointFloorId) || pointFloorId,
+ points: [{ ...point, floorId: pointFloorId }]
})
return segments
}, [])
@@ -144,23 +231,25 @@ export const toGuideRouteResultFromSgs = (
startTarget: GuideRouteTarget,
endTarget: GuideRouteTarget
): GuideRouteResult => {
- const start = targetEndpoint(startTarget)
- const end = targetEndpoint(endTarget)
+ const resolveFloorId = createRouteFloorIdResolver(startTarget, endTarget)
+ const start = targetEndpoint(startTarget, resolveFloorId)
+ const end = targetEndpoint(endTarget, resolveFloorId)
const points = normalizePath(
route as CompatibleSgsRouteResult,
- startTarget.floorId,
- endTarget.floorId
+ start.floorId,
+ end.floorId,
+ resolveFloorId
)
const routePoints = points.length
? points
: [
{
- nodeId: start.routeNodeId,
+ nodeId: start.routeNodeId || `${start.poiId}-start`,
floorId: start.floorId,
position: start.position
},
{
- nodeId: end.routeNodeId,
+ nodeId: end.routeNodeId || `${end.poiId}-end`,
floorId: end.floorId,
position: end.position
}
@@ -173,27 +262,28 @@ export const toGuideRouteResultFromSgs = (
distanceMeters: Number(route.distance || 0),
nodeIds: routePoints.map((point) => point.nodeId),
points: routePoints,
- floorSegments: createFloorSegments(routePoints, startTarget, endTarget),
+ floorSegments: createFloorSegments(routePoints, startTarget, endTarget, resolveFloorId),
connectorPoints: []
}
}
const floorLabelFor = (
floorId: string,
- startTarget: GuideRouteTarget,
- endTarget: GuideRouteTarget,
+ start: Pick,
+ end: Pick,
fallbackLabel?: string | null
) => {
if (fallbackLabel) return fallbackLabel
- if (floorId === startTarget.floorId) return startTarget.floorLabel
- if (floorId === endTarget.floorId) return endTarget.floorLabel
+ if (floorId === start.floorId) return start.floorLabel
+ if (floorId === end.floorId) return end.floorLabel
return floorId
}
const pointFromBackendNode = (
node: NonNullable[number],
index: number,
- fallbackFloorId: string
+ fallbackFloorId: string,
+ resolveFloorId: RouteFloorIdResolver
): GuideRoutePoint | null => {
const x = finiteNumber(node.x)
const z = finiteNumber(node.y)
@@ -201,7 +291,7 @@ const pointFromBackendNode = (
return {
nodeId: stringifyRouteId(node.id, `sgs-route-node-${index}`),
- floorId: stringifyRouteId(node.floorId, fallbackFloorId),
+ floorId: resolveFloorId(node.floorId, fallbackFloorId),
position: [
x,
finiteNumber(node.z) ?? 0,
@@ -304,23 +394,63 @@ const appendIfDifferent = (
const createPlanFloorSegments = (
points: GuideRoutePoint[],
- startTarget: GuideRouteTarget,
- endTarget: GuideRouteTarget
+ start: GuideRouteEndpoint,
+ end: GuideRouteEndpoint,
+ resolveFloorId: RouteFloorIdResolver
): GuideRouteFloorSegment[] => points.reduce((segments, point) => {
+ const floorId = resolveFloorId(point.floorId, start.floorId)
const current = segments[segments.length - 1]
- if (current && current.floorId === point.floorId) {
- current.points.push(point)
+ if (current && current.floorId === floorId) {
+ current.points.push({ ...point, floorId })
return segments
}
segments.push({
- floorId: point.floorId,
- floorLabel: floorLabelFor(point.floorId, startTarget, endTarget),
- points: [point]
+ floorId,
+ floorLabel: floorLabelFor(floorId, start, end),
+ points: [{ ...point, floorId }]
})
return segments
}, [])
+const createRouteTransitions = (
+ floorSegments: GuideRouteFloorSegment[],
+ backendSegments: SgsRoutePlanResponsePayload['segments'],
+ resolveFloorId: RouteFloorIdResolver
+): GuideRouteTransition[] => {
+ const transferSegments = (backendSegments || []).filter((segment) => !isWalkSegment(segment))
+ const transitions: GuideRouteTransition[] = []
+
+ for (let index = 1; index < floorSegments.length; index += 1) {
+ const fromSegment = floorSegments[index - 1]
+ const toSegment = floorSegments[index]
+ const fromPoint = fromSegment.points[fromSegment.points.length - 1]
+ const toPoint = toSegment.points[0]
+ if (!fromPoint || !toPoint || fromSegment.floorId === toSegment.floorId) continue
+
+ const transfer = transferSegments[transitions.length]
+ const explicitFromFloorId = resolveFloorId(transfer?.fromFloorId, fromSegment.floorId)
+ const explicitToFloorId = resolveFloorId(transfer?.targetFloorId, toSegment.floorId)
+
+ // Prefer the formal transfer contract. The adjacent WALK segments are
+ // retained only as a fallback for route responses generated before it.
+ if (explicitFromFloorId !== fromSegment.floorId) continue
+ if (explicitToFloorId !== toSegment.floorId) continue
+
+ transitions.push({
+ id: `transition-${fromPoint.nodeId}-${toPoint.nodeId}`,
+ fromFloorId: explicitFromFloorId,
+ toFloorId: explicitToFloorId,
+ fromPosition: fromPoint.position,
+ toPosition: toPoint.position,
+ transferType: stringifyRouteId(transfer?.transferType || transfer?.segmentType || transfer?.type),
+ connectorName: transfer?.connectorName || transfer?.startNodeName || transfer?.endNodeName || undefined
+ })
+ }
+
+ return transitions
+}
+
const isWalkSegment = (
segment: NonNullable[number]
) => {
@@ -334,12 +464,17 @@ const createSegmentFloorSegments = (
end: GuideRouteEndpoint,
startTarget: GuideRouteTarget,
endTarget: GuideRouteTarget,
- nodePoints: GuideRoutePoint[]
+ nodePoints: GuideRoutePoint[],
+ resolveFloorId: RouteFloorIdResolver
): GuideRouteFloorSegment[] => {
const segments = (route.segments || [])
.filter(isWalkSegment)
.map((segment, segmentIndex) => {
- const floorId = stringifyRouteId(segment.floorId, start.floorId)
+ const floorId = resolveFloorId(
+ segment.floorId,
+ start.floorId,
+ [segment.floorCode, segment.floorName]
+ )
const nodeIdPrefix = `${floorId}-segment-${segmentIndex}`
const nodePathIds = new Set(
(segment.nodePathIds || [])
@@ -353,7 +488,7 @@ const createSegmentFloorSegments = (
const fallbackPoints = geoPoints.length
? geoPoints
: nodePoints.filter((point) => (
- point.floorId === floorId
+ resolveFloorId(point.floorId, start.floorId) === floorId
&& (!nodePathIds.size || nodePathIds.has(point.nodeId))
))
@@ -361,7 +496,7 @@ const createSegmentFloorSegments = (
return {
floorId,
- floorLabel: floorLabelFor(floorId, startTarget, endTarget, segment.floorName),
+ floorLabel: floorLabelFor(floorId, start, end, segment.floorName),
points: fallbackPoints.map((point, index) => ({
...point,
nodeId: point.nodeId || `${nodeIdPrefix}-${index}`
@@ -397,12 +532,13 @@ export const toGuideRouteResultFromSgsPlan = (
startTarget: GuideRouteTarget,
endTarget: GuideRouteTarget
): GuideRouteResult => {
- const start = targetEndpoint(startTarget)
- const end = targetEndpoint(endTarget)
+ const resolveFloorId = createRouteFloorIdResolver(startTarget, endTarget, route.segments)
+ const start = targetEndpoint(startTarget, resolveFloorId)
+ const end = targetEndpoint(endTarget, resolveFloorId)
const startPoint = routePointFromEndpoint(start, 'start')
const endPoint = routePointFromEndpoint(end, 'end')
const nodePoints = (route.nodePaths || [])
- .map((node, index) => pointFromBackendNode(node, index, start.floorId))
+ .map((node, index) => pointFromBackendNode(node, index, start.floorId, resolveFloorId))
.filter((point): point is GuideRoutePoint => Boolean(point))
const segmentFloorSegments = createSegmentFloorSegments(
route,
@@ -410,7 +546,8 @@ export const toGuideRouteResultFromSgsPlan = (
end,
startTarget,
endTarget,
- nodePoints
+ nodePoints,
+ resolveFloorId
)
const routeGeoPoints = pointsFromGeoJson(route.pathGeoJson, start.floorId, 'sgs-route')
const fallbackPoints = nodePoints.length
@@ -420,8 +557,9 @@ export const toGuideRouteResultFromSgsPlan = (
: [startPoint, endPoint]
const floorSegments = segmentFloorSegments.length
? segmentFloorSegments
- : createPlanFloorSegments(fallbackPoints, startTarget, endTarget)
+ : createPlanFloorSegments(fallbackPoints, start, end, resolveFloorId)
const routePoints = floorSegments.flatMap((segment) => segment.points)
+ const transitions = createRouteTransitions(floorSegments, route.segments, resolveFloorId)
return {
id: `sgs-api-route-${start.poiId}-${end.poiId}`,
@@ -431,21 +569,22 @@ export const toGuideRouteResultFromSgsPlan = (
nodeIds: routePoints.map((point) => point.nodeId),
points: routePoints,
floorSegments,
- connectorPoints: extractFloorConnectorPoints(routePoints, startTarget, endTarget)
+ connectorPoints: extractFloorConnectorPoints(routePoints, start, end),
+ transitions
}
}
const extractFloorConnectorPoints = (
points: GuideRoutePoint[],
- startTarget: GuideRouteTarget,
- endTarget: GuideRouteTarget
+ start: GuideRouteEndpoint,
+ end: GuideRouteEndpoint
): GuideRouteConnectorPoint[] => {
if (points.length < 2) return []
const connectors: GuideRouteConnectorPoint[] = []
const labels = new Map([
- [startTarget.floorId, startTarget.floorLabel],
- [endTarget.floorId, endTarget.floorLabel]
+ [start.floorId, start.floorLabel],
+ [end.floorId, end.floorLabel]
])
for (let i = 0; i < points.length - 1; i++) {
diff --git a/src/data/providers/backendExplainContentProvider.ts b/src/data/providers/backendExplainContentProvider.ts
index ce81ae1..8a6fdba 100644
--- a/src/data/providers/backendExplainContentProvider.ts
+++ b/src/data/providers/backendExplainContentProvider.ts
@@ -21,6 +21,10 @@ import {
type BackendCatalogOutlineItem,
type BackendCatalogStopItem
} from '@/data/adapters/backendExplainDataAdapter'
+import {
+ readPersistentJsonCache,
+ writePersistentJsonCache
+} from '@/utils/persistentJsonCache'
interface CommonResult {
code: number
@@ -78,6 +82,7 @@ const catalogLang = () => dataSourceConfig.audioLanguage
const cacheKey = (...parts: string[]) => [catalogLang(), ...parts].join(':')
const CATALOG_CACHE_TTL_MS = 60_000
+const CATALOG_PERSISTENT_CACHE_TTL_MS = 5 * 60_000
const CATALOG_CACHE_MAX_ENTRIES = 80
interface TimedCacheEntry {
@@ -148,6 +153,24 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
}
}
+ private persistentCacheKey(key: string) {
+ return [
+ 'sgs-mobile',
+ 'explain-catalog',
+ 'v1',
+ dataSourceConfig.apiBaseUrl,
+ key
+ ].map((part) => encodeURIComponent(part)).join(':')
+ }
+
+ private readPersistent(key: string, allowExpired = false) {
+ return readPersistentJsonCache(this.persistentCacheKey(key), allowExpired)
+ }
+
+ private writePersistent(key: string, value: T) {
+ writePersistentJsonCache(this.persistentCacheKey(key), value, CATALOG_PERSISTENT_CACHE_TTL_MS)
+ }
+
private async safeFallbackHalls() {
if (!this.isStaticFallbackEnabled()) return []
@@ -177,6 +200,12 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
const cached = this.getCached(this.hallListCache, key)
if (cached) return cached
+ const persistent = this.readPersistent(key)
+ if (persistent) {
+ this.setCached(this.hallListCache, key, persistent)
+ return persistent
+ }
+
const inflight = this.hallListInflight.get(key)
if (inflight) return inflight
@@ -196,8 +225,13 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
.filter((hall) => hall.id)
this.setCached(this.hallListCache, key, halls)
+ this.writePersistent(key, halls)
return halls
- })()
+ })().catch((error) => {
+ const stale = this.readPersistent(key, true)
+ if (stale) return stale
+ throw error
+ })
this.hallListInflight.set(key, promise)
try {
@@ -215,6 +249,12 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
const cached = this.getCached(this.outlineCache, key)
if (cached) return cached
+ const persistent = this.readPersistent(key)
+ if (persistent) {
+ this.setCached(this.outlineCache, key, persistent)
+ return persistent
+ }
+
const inflight = this.outlineInflight.get(key)
if (inflight) return inflight
@@ -227,8 +267,13 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
const response = await requestJson>(url)
const outlines = requireArrayData(response, '讲解单元目录加载失败')
this.setCached(this.outlineCache, key, outlines)
+ this.writePersistent(key, outlines)
return outlines
- })()
+ })().catch((error) => {
+ const stale = this.readPersistent(key, true)
+ if (stale) return stale
+ throw error
+ })
this.outlineInflight.set(key, promise)
try {
@@ -248,6 +293,12 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
const cached = this.getCached(this.guideStopPageCache, key)
if (cached) return cached
+ const persistent = this.readPersistent(key)
+ if (persistent) {
+ this.setCached(this.guideStopPageCache, key, persistent)
+ return persistent
+ }
+
const inflight = this.guideStopPageInflight.get(key)
if (inflight) return inflight
@@ -268,8 +319,13 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
hasMore: normalizedPageNo * normalizedPageSize < data.total
}
this.setCached(this.guideStopPageCache, key, page)
+ this.writePersistent(key, page)
return page
- })()
+ })().catch((error) => {
+ const stale = this.readPersistent(key, true)
+ if (stale) return stale
+ throw error
+ })
this.guideStopPageInflight.set(key, promise)
try {
@@ -286,6 +342,12 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
const cached = this.getCached(this.guideStopCache, key)
if (cached) return cached
+ const persistent = this.readPersistent(key)
+ if (persistent) {
+ this.setCached(this.guideStopCache, key, persistent)
+ return persistent
+ }
+
const inflight = this.guideStopInflight.get(key)
if (inflight) return inflight
@@ -303,8 +365,13 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
if (!page.hasMore || stops.length >= page.total) break
}
this.setCached(this.guideStopCache, key, stops)
+ this.writePersistent(key, stops)
return stops
- })()
+ })().catch((error) => {
+ const stale = this.readPersistent(key, true)
+ if (stale) return stale
+ throw error
+ })
this.guideStopInflight.set(key, promise)
try {
@@ -329,6 +396,12 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
const cached = this.getCached(this.guideStopCache, key)
if (cached) return cached
+ const persistent = this.readPersistent(key)
+ if (persistent) {
+ this.setCached(this.guideStopCache, key, persistent)
+ return persistent
+ }
+
const inflight = this.guideStopInflight.get(key)
if (inflight) return inflight
@@ -353,8 +426,13 @@ export class BackendExplainContentProvider implements ExplainContentProvider {
.filter(Boolean) as ExplainGuideStop[]
this.setCached(this.guideStopCache, key, stops)
+ this.writePersistent(key, stops)
return stops
- })()
+ })().catch((error) => {
+ const stale = this.readPersistent(key, true)
+ if (stale) return stale
+ throw error
+ })
this.guideStopInflight.set(key, promise)
try {
diff --git a/src/data/providers/sgsSdkApiProvider.ts b/src/data/providers/sgsSdkApiProvider.ts
index 9353785..4ec25d0 100644
--- a/src/data/providers/sgsSdkApiProvider.ts
+++ b/src/data/providers/sgsSdkApiProvider.ts
@@ -1,6 +1,13 @@
import {
dataSourceConfig
} from '@/config/dataSource'
+import {
+ startGuidePerformance
+} from '@/services/performance/guidePerformance'
+import {
+ readPersistentJsonCache,
+ writePersistentJsonCache
+} from '@/utils/persistentJsonCache'
export type SgsDiagnosticsStatusPayload = 'OK' | 'WARN' | 'ERROR'
@@ -10,6 +17,11 @@ export interface SgsSdkFloorSummaryPayload {
floorName?: string | null
sortOrder?: number | null
modelSizeBytes?: number | null
+ /** Manifest 首屏模型元数据,避免仅为模型地址读取整份楼层 Bundle。 */
+ modelUrl?: string | null
+ fallbackModelUrl?: string | null
+ compressionType?: 'draco' | 'none' | string | null
+ modelVersion?: string | null
poiCount?: number | null
spaceCount?: number | null
}
@@ -23,9 +35,23 @@ export interface SgsSdkManifestPayload {
updatedAt?: string | null
coordinateSystem?: string | null
floors: SgsSdkFloorSummaryPayload[]
+ routeAssets?: SgsRouteAssetPayload[]
capabilities?: Record
}
+export interface SgsRouteAssetPayload {
+ id?: string | number | null
+ floorId?: string | number | null
+ floorCode?: string | null
+ assetRole?: string | null
+ modelUrl?: string | null
+ sourceFileName?: string | null
+ sourceNodeName?: string | null
+ modelVersion?: string | null
+ coverageFloorCodes?: string[] | null
+ sortOrder?: number | null
+}
+
export type SgsPoiGroupPayload = 'SERVICE' | 'BUSINESS' | 'OTHER'
export type SgsBusinessPoiTypePayload =
@@ -60,13 +86,16 @@ export interface SgsPoiPayload {
typeName?: string | null
floorCode?: string | null
floorId?: string | number | null
+ meshCenter?: SgsPositionPayload | null
position?: SgsPositionPayload | null
+ labelPosition?: SgsPositionPayload | null
x?: number | null
y?: number | null
z?: number | null
status?: string | null
- visitorVisible?: boolean | null
anchorNodeName?: string | null
+ sourceNodeName?: string | null
+ extParams?: string | Record | null
description?: string | null
iconUrl?: string | null
poiGroup?: SgsPoiGroupPayload | string | null
@@ -82,13 +111,19 @@ export interface SgsPoiPayload {
export interface SgsSpacePayload {
id: string | number
name?: string | null
+ displayName?: string | null
type?: string | null
+ typeName?: string | null
floorId?: string | number | null
+ floorCode?: string | null
boundaryWkt?: string | null
+ labelPosition?: SgsPositionPayload | null
+ meshCenter?: SgsPositionPayload | null
+ position?: SgsPositionPayload | null
center?: SgsPositionPayload | null
+ centerPoint?: SgsPositionPayload | null
sourceNodeName?: string | null
status?: string | null
- visitorVisible?: boolean | null
colorHex?: string | null
}
@@ -107,6 +142,9 @@ export interface SgsNavigablePlacePayload {
y?: number | null
z?: number | null
nodeId?: string | number | null
+ anchorId?: string | null
+ anchorType?: string | null
+ sourceId?: string | number | null
ownerName?: string | null
}
@@ -182,6 +220,8 @@ export interface SgsFloorBundlePayload {
model?: SgsModelInfoPayload | null
pois?: SgsPoiPayload[]
spaces?: SgsSpacePayload[]
+ businessPois?: SgsPoiPayload[]
+ navigablePlaces?: SgsNavigablePlacePayload[]
guideStops?: SgsGuideStopPayload[]
routeSummary?: {
hasRouteNetwork?: boolean
@@ -213,6 +253,8 @@ export interface SgsRoutePlanRequestPayload {
endY: number
endNodeId?: number | string | null
wheelchair: 0 | 1
+ /** Public visitor navigation avoids stairs when an elevator or escalator route exists. */
+ verticalTransferPolicy?: 'PREFER_ELEVATOR_ESCALATOR'
}
export interface SgsRouteStepNodePayload {
@@ -233,6 +275,7 @@ export interface SgsRoutePathPointPayload {
export interface SgsRouteSegmentPayload {
floorId?: string | number | null
+ fromFloorId?: string | number | null
floorCode?: string | null
floorName?: string | null
startNodeId?: string | number | null
@@ -244,6 +287,8 @@ export interface SgsRouteSegmentPayload {
transferType?: string | null
distance?: number | null
duration?: number | null
+ connectorName?: string | null
+ targetFloorId?: string | number | null
pathGeoJson?: string | null
pathPoints?: SgsRoutePathPointPayload[]
nodePathIds?: Array
@@ -374,6 +419,9 @@ const parseJsonPayload = (payload: unknown, requestUrl: string, contentType:
}
const inFlightRequests = new Map>()
+// v3 invalidates floor metadata cached before mutable spaces became network-first.
+const SGS_SDK_PERSISTENT_CACHE_PREFIX = 'sgs-mobile:sdk-read:v3'
+const SGS_SDK_PERSISTENT_CACHE_TTL_MS = 5 * 60 * 1000
const requestJson = (
path: string,
@@ -382,6 +430,7 @@ const requestJson = (
const requestKey = `${options.method || 'GET'}:${path}:${JSON.stringify(options.data || {})}`
const existing = inFlightRequests.get(requestKey)
if (existing) return existing as Promise
+ const finishPerformance = startGuidePerformance('api', `${options.method || 'GET'} ${path}`)
const request = new Promise((resolve, reject) => {
const baseUrl = resolveAppApiBaseUrl()
@@ -401,6 +450,7 @@ const requestJson = (
const statusCode = Number(response.statusCode || 0)
const contentType = getHeaderValue(response.header as Record | undefined, 'content-type')
if (statusCode < 200 || statusCode >= 300) {
+ finishPerformance('failure', { statusCode })
reject(new Error(`SGS 数据接口请求失败: ${statusCode} ${requestUrl} content-type=${contentType || 'unknown'} body="${previewPayload(response.data)}"`))
return
}
@@ -408,16 +458,24 @@ const requestJson = (
try {
const body = parseJsonPayload>(response.data, requestUrl, contentType)
if (!body || body.code !== 0) {
+ finishPerformance('failure', { statusCode, code: body?.code, message: body?.msg || '' })
reject(new Error(`SGS 数据接口业务失败: ${requestUrl} code=${body?.code} msg=${body?.msg || ''}`))
return
}
+ finishPerformance('success', { statusCode })
resolve(body.data as T)
} catch (error) {
+ finishPerformance('failure', {
+ error: error instanceof Error ? error.message : String(error)
+ })
reject(error)
}
},
fail: (error) => {
+ finishPerformance('failure', {
+ error: JSON.stringify(error)
+ })
reject(new Error(`SGS 数据接口网络失败: ${path} ${JSON.stringify(error)}`))
}
})
@@ -427,6 +485,67 @@ const requestJson = (
return request
}
+const persistentCacheKeyFor = (path: string) => (
+ `${SGS_SDK_PERSISTENT_CACHE_PREFIX}:${encodeURIComponent(resolveAppApiBaseUrl())}:${path}`
+)
+
+const requestCachedJson = async (path: string): Promise => {
+ const cacheKey = persistentCacheKeyFor(path)
+ const cached = readPersistentJsonCache(cacheKey)
+ if (cached !== null) {
+ const finishPerformance = startGuidePerformance('api', `GET ${path}`)
+ finishPerformance('cache-hit', { layer: 'persistent' })
+ return cached
+ }
+
+ try {
+ const data = await requestJson(path)
+ writePersistentJsonCache(cacheKey, data, SGS_SDK_PERSISTENT_CACHE_TTL_MS)
+ return data
+ } catch (error) {
+ const stale = readPersistentJsonCache(cacheKey, true)
+ if (stale !== null) {
+ const finishPerformance = startGuidePerformance('api', `GET ${path}`)
+ finishPerformance('stale-fallback', { layer: 'persistent' })
+ return stale
+ }
+ throw error
+ }
+}
+
+const requestFreshJson = async (path: string): Promise => {
+ const cacheKey = persistentCacheKeyFor(path)
+
+ try {
+ const data = await requestJson(path)
+ writePersistentJsonCache(cacheKey, data, SGS_SDK_PERSISTENT_CACHE_TTL_MS)
+ return data
+ } catch (error) {
+ const stale = readPersistentJsonCache(cacheKey, true)
+ if (stale !== null) {
+ const finishPerformance = startGuidePerformance('api', `GET ${path}`)
+ finishPerformance('stale-fallback', { layer: 'persistent' })
+ return stale
+ }
+ throw error
+ }
+}
+
+const requestCachedCollectionJson = async (path: string): Promise => {
+ const cacheKey = persistentCacheKeyFor(path)
+ const cached = readPersistentJsonCache(cacheKey)
+ if (cached !== null && cached.length > 0) {
+ const finishPerformance = startGuidePerformance('api', `GET ${path}`)
+ finishPerformance('cache-hit', { layer: 'persistent' })
+ return cached
+ }
+
+ // Empty mutable collections are not authoritative across deployments. A
+ // fresh floor request can upgrade an old persisted [] without broadening the
+ // query to other floors.
+ return requestFreshJson(path)
+}
+
const buildQueryString = (params: object) => {
const query = new URLSearchParams()
@@ -467,7 +586,7 @@ export const createSgsSdkApiProvider = (): SgsSdkApiProvider => {
const cached = manifestCache.get(normalizedMapId)
if (cached) return cached
- const manifest = await requestJson(
+ const manifest = await requestCachedJson(
`/gis/sdk/maps/${encodeURIComponent(normalizedMapId)}/manifest`
)
manifestCache.set(normalizedMapId, manifest)
@@ -478,7 +597,7 @@ export const createSgsSdkApiProvider = (): SgsSdkApiProvider => {
const cached = mapDiagnosticsCache.get(normalizedMapId)
if (cached) return cached
- const diagnostics = await requestJson(
+ const diagnostics = await requestCachedJson(
`/gis/sdk/maps/${encodeURIComponent(normalizedMapId)}/diagnostics`
)
mapDiagnosticsCache.set(normalizedMapId, diagnostics)
@@ -488,7 +607,7 @@ export const createSgsSdkApiProvider = (): SgsSdkApiProvider => {
const cached = floorDiagnosticsCache.get(floorId)
if (cached) return cached
- const diagnostics = await requestJson(
+ const diagnostics = await requestCachedJson(
`/gis/sdk/floors/${floorIdFor(floorId)}/diagnostics`
)
floorDiagnosticsCache.set(floorId, diagnostics)
@@ -498,37 +617,51 @@ export const createSgsSdkApiProvider = (): SgsSdkApiProvider => {
const cached = floorBundleCache.get(floorId)
if (cached) return cached
- const bundle = await requestJson(
+ const bundle = await requestCachedJson(
`/gis/sdk/floors/${floorIdFor(floorId)}/bundle`
)
floorBundleCache.set(floorId, bundle)
+ // The floor model and search panel share these authoritative collections.
+ // Seed the per-floor caches so opening search after a model switch does
+ // not repeat the two slowest SDK requests.
+ if (bundle.pois?.length) floorPoiCache.set(floorId, bundle.pois)
+ if (bundle.spaces?.length) floorSpaceCache.set(floorId, bundle.spaces)
+ // Bundle snapshots are authoritative even for an empty collection. This
+ // differs from the standalone mutable endpoints, where an empty response
+ // remains retryable to recover from a stale browser cache.
+ if (Array.isArray(bundle.businessPois)) {
+ floorBusinessPoiCache.set(`${floorId}:${stableCacheKey({})}`, bundle.businessPois)
+ }
+ if (Array.isArray(bundle.navigablePlaces)) {
+ navigablePlaceCache.set(floorId, bundle.navigablePlaces)
+ }
return bundle
},
async getFloorPois(floorId) {
const cached = floorPoiCache.get(floorId)
- if (cached) return cached
+ if (cached?.length) return cached
- const pois = await requestJson(
+ const pois = await requestCachedCollectionJson(
`/gis/sdk/floors/${floorIdFor(floorId)}/pois`
)
- floorPoiCache.set(floorId, pois)
+ if (pois.length) floorPoiCache.set(floorId, pois)
return pois
},
async getFloorSpaces(floorId) {
const cached = floorSpaceCache.get(floorId)
- if (cached) return cached
+ if (cached?.length) return cached
- const spaces = await requestJson(
+ const spaces = await requestFreshJson(
`/gis/sdk/floors/${floorIdFor(floorId)}/spaces`
)
- floorSpaceCache.set(floorId, spaces)
+ if (spaces.length) floorSpaceCache.set(floorId, spaces)
return spaces
},
async getGuideStops(floorId) {
const cached = floorGuideStopCache.get(floorId)
if (cached) return cached
- const guideStops = await requestJson(
+ const guideStops = await requestCachedJson(
`/gis/sdk/floors/${floorIdFor(floorId)}/guide-stops`
)
floorGuideStopCache.set(floorId, guideStops)
@@ -538,31 +671,29 @@ export const createSgsSdkApiProvider = (): SgsSdkApiProvider => {
const cached = hallGuideStopCache.get(hallId)
if (cached) return cached
- const guideStops = await requestJson(
+ const guideStops = await requestCachedJson(
`/gis/sdk/halls/${hallIdFor(hallId)}/guide-stops`
)
hallGuideStopCache.set(hallId, guideStops)
return guideStops
},
async getNavigablePlaces(floorId) {
- const cached = navigablePlaceCache.get(floorId)
- if (cached) return cached
+ if (navigablePlaceCache.has(floorId)) return navigablePlaceCache.get(floorId) || []
- const places = await requestJson(
+ const places = await requestCachedCollectionJson(
`/gis/sdk/floors/${floorIdFor(floorId)}/navigable-places`
)
- navigablePlaceCache.set(floorId, places)
+ if (places.length) navigablePlaceCache.set(floorId, places)
return places
},
async getFloorBusinessPois(floorId, options = {}) {
const cacheKey = `${floorId}:${stableCacheKey(options)}`
- const cached = floorBusinessPoiCache.get(cacheKey)
- if (cached) return cached
+ if (floorBusinessPoiCache.has(cacheKey)) return floorBusinessPoiCache.get(cacheKey) || []
- const pois = await requestJson(
+ const pois = await requestCachedCollectionJson(
`/gis/sdk/floors/${floorIdFor(floorId)}/business-pois${buildQueryString(options)}`
)
- floorBusinessPoiCache.set(cacheKey, pois)
+ if (pois.length) floorBusinessPoiCache.set(cacheKey, pois)
return pois
},
async queryPois(params) {
@@ -570,7 +701,7 @@ export const createSgsSdkApiProvider = (): SgsSdkApiProvider => {
const cached = poiQueryCache.get(cacheKey)
if (cached) return cached
- const pois = await requestJson(
+ const pois = await requestCachedJson(
`/gis/sdk/pois${buildQueryString(params)}`
)
poiQueryCache.set(cacheKey, pois)
@@ -582,7 +713,7 @@ export const createSgsSdkApiProvider = (): SgsSdkApiProvider => {
const cached = featuredRoutesCache.get(cacheKey)
if (cached) return cached
- const routes = await requestJson(
+ const routes = await requestCachedJson(
`/gis/sdk/maps/${encodeURIComponent(normalizedMapId)}/featured-routes${buildQueryString(options)}`
)
featuredRoutesCache.set(cacheKey, routes)
@@ -592,7 +723,7 @@ export const createSgsSdkApiProvider = (): SgsSdkApiProvider => {
const cached = featuredRouteDetailCache.get(routeId)
if (cached) return cached
- const route = await requestJson(
+ const route = await requestCachedJson(
`/gis/sdk/featured-routes/${encodeURIComponent(routeId)}`
)
featuredRouteDetailCache.set(routeId, route)
diff --git a/src/data/providers/staticNavAssetsProvider.ts b/src/data/providers/staticNavAssetsProvider.ts
index a44e220..1dbee25 100644
--- a/src/data/providers/staticNavAssetsProvider.ts
+++ b/src/data/providers/staticNavAssetsProvider.ts
@@ -27,7 +27,6 @@ export interface StaticNavPoiPayload {
sourceObjectName?: string
navigationReadiness?: string
sourceConfidence?: string
- visitorVisible?: boolean | null
}
export interface StaticNavManifestFloorModelPayload {
diff --git a/src/domain/guideAudioOptions.ts b/src/domain/guideAudioOptions.ts
new file mode 100644
index 0000000..92036f7
--- /dev/null
+++ b/src/domain/guideAudioOptions.ts
@@ -0,0 +1,68 @@
+import type {
+ GuideAudioGender,
+ MuseumGuideAudioOption
+} from '@/domain/museum'
+import type { GuideAudioLanguage } from '@/data/adapters/guideStopInfoAdapter'
+
+export const GUIDE_AUDIO_LANGUAGE_OPTIONS: ReadonlyArray<{
+ value: GuideAudioLanguage
+ label: string
+}> = [
+ { value: 'zh-CN', label: '中文' },
+ { value: 'en-US', label: 'English' },
+ { value: 'yue-HK', label: '粤语' }
+]
+
+export const resolveGuideAudioGender = (
+ language: GuideAudioLanguage,
+ preferredGender: GuideAudioGender
+): GuideAudioGender => (
+ language === 'yue-HK' ? 'female' : preferredGender
+)
+
+const sortAudioOptions = (options: MuseumGuideAudioOption[]) => (
+ [...options].sort((left, right) => {
+ const defaultOrder = Number(Boolean(right.isDefault)) - Number(Boolean(left.isDefault))
+ if (defaultOrder) return defaultOrder
+ return (left.sortOrder || 0) - (right.sortOrder || 0)
+ })
+)
+
+export const resolveGuideAudioOption = (
+ options: MuseumGuideAudioOption[] | undefined,
+ language: GuideAudioLanguage,
+ preferredGender: GuideAudioGender
+): MuseumGuideAudioOption | undefined => {
+ const languageOptions = sortAudioOptions((options || []).filter((option) => (
+ option.languageCode === language && Boolean(option.playUrl)
+ )))
+ if (!languageOptions.length) return undefined
+
+ const expectedGender = resolveGuideAudioGender(language, preferredGender)
+ return languageOptions.find((option) => option.gender === expectedGender) || languageOptions[0]
+}
+
+export const resolveGuideAudioLanguages = (
+ options: MuseumGuideAudioOption[] | undefined,
+ fallback: string[] | undefined
+): GuideAudioLanguage[] => {
+ const optionLanguages = new Set((options || [])
+ .filter((option) => Boolean(option.playUrl))
+ .map((option) => option.languageCode))
+ const fallbackLanguages = new Set(fallback || [])
+
+ return GUIDE_AUDIO_LANGUAGE_OPTIONS
+ .map((option) => option.value)
+ .filter((language) => optionLanguages.has(language) || fallbackLanguages.has(language))
+}
+
+export const canToggleGuideAudioGender = (
+ options: MuseumGuideAudioOption[] | undefined,
+ language: GuideAudioLanguage
+) => {
+ if (language === 'yue-HK') return false
+ const genders = new Set((options || [])
+ .filter((option) => option.languageCode === language && Boolean(option.playUrl))
+ .map((option) => option.gender))
+ return genders.has('male') && genders.has('female')
+}
diff --git a/src/domain/guideModel.ts b/src/domain/guideModel.ts
index 30f3d9a..c30696b 100644
--- a/src/domain/guideModel.ts
+++ b/src/domain/guideModel.ts
@@ -12,7 +12,15 @@ export interface GuideRenderPoi {
primaryCategoryZh: string
iconType: string
positionGltf?: [number, number, number]
+ // A visitor-facing business may be assembled from multiple GLB anchors.
+ // It keeps one label/detail entry while every physical anchor participates
+ // in the selected-state model highlight.
+ mergedDisplayPositions?: [number, number, number][]
+ // Numbered facility series keep a marker at every physical anchor. Merged
+ // business spaces omit this field so one logical place has one marker.
+ mergedMarkerPositions?: [number, number, number][]
sourceObjectName?: string
+ mergedSourceObjectNames?: string[]
kind?: MuseumPoiKind
hallId?: string
hallName?: string
@@ -29,19 +37,33 @@ export interface GuideModelFloorAsset {
order: number
modelUrl: string
modelUrls?: string[]
+ modelVersion?: string
sharedModelAsset?: boolean
modelMatchKeys?: string[]
}
+export interface GuideModelRouteAsset {
+ role: 'same-ground-composite'
+ modelUrl: string
+ modelVersion?: string
+ coverageFloorCodes: string[]
+ coverageFloorIds: string[]
+}
+
export interface GuideModelRenderPackage {
overviewModelUrl: string
overviewModelUrls?: string[]
+ overviewModelVersion?: string
+ overviewFloorId?: string
floors: GuideModelFloorAsset[]
+ routeAssets?: GuideModelRouteAsset[]
}
export interface GuideModelSource {
loadPackage(): Promise
loadFloorPois(floorId: string): Promise
+ /** Low-latency core labels for the first visible floor paint. */
+ loadFloorPoisFast?(floorId: string): Promise
}
export interface AutoSwitchConfig {
diff --git a/src/domain/guideReadiness.ts b/src/domain/guideReadiness.ts
index 7950fd6..453d505 100644
--- a/src/domain/guideReadiness.ts
+++ b/src/domain/guideReadiness.ts
@@ -2,9 +2,11 @@ import type {
GuideRouteReadiness
} from '@/domain/museum'
-export const NAV_ROUTE_GRAPH_READY = false
+// 正式路网已由 SDK diagnostics 校验;小程序与大屏共用同一条规划接口。
+export const NAV_ROUTE_GRAPH_READY = true
export const NAV_ROUTE_UNAVAILABLE_MESSAGE = '当前可查看馆内三维位置预览和馆外入口参考'
+export const NAV_ROUTE_SERVICE_UNAVAILABLE_MESSAGE = '路线服务暂时不可用,请稍后重试'
export const NAV_ROUTE_READINESS: GuideRouteReadiness = {
ready: NAV_ROUTE_GRAPH_READY,
diff --git a/src/domain/guideTopView.ts b/src/domain/guideTopView.ts
new file mode 100644
index 0000000..3db523e
--- /dev/null
+++ b/src/domain/guideTopView.ts
@@ -0,0 +1,114 @@
+import * as THREE from 'three'
+
+export interface GuideTopViewPose {
+ position: THREE.Vector3
+ target: THREE.Vector3
+ up: THREE.Vector3
+ screenRight: THREE.Vector3
+ screenDown: THREE.Vector3
+ visibleWorldSpan: number
+ distance: number
+}
+
+export interface GuideObliqueReturnPose {
+ position: THREE.Vector3
+ target: THREE.Vector3
+ distance: number
+ viewportChanged: boolean
+}
+
+export const getPerspectiveHorizontalVisibleSpan = (
+ distance: number,
+ fovDegrees: number,
+ aspect: number
+) => (
+ 2
+ * Math.max(distance, 0)
+ * Math.tan(THREE.MathUtils.degToRad(fovDegrees) / 2)
+ * Math.max(aspect, 1e-6)
+)
+
+export const getPerspectiveDistanceForHorizontalSpan = (
+ visibleWorldSpan: number,
+ fovDegrees: number,
+ aspect: number
+) => (
+ Math.max(visibleWorldSpan, 0)
+ / Math.max(
+ 2 * Math.tan(THREE.MathUtils.degToRad(fovDegrees) / 2) * Math.max(aspect, 1e-6),
+ 1e-6
+ )
+)
+
+export const getGuideTopViewScreenBasis = (cameraQuaternion: THREE.Quaternion) => {
+ const screenRight = new THREE.Vector3(1, 0, 0).applyQuaternion(cameraQuaternion)
+ screenRight.y = 0
+ if (screenRight.lengthSq() < 1e-12) screenRight.set(1, 0, 0)
+ screenRight.normalize()
+
+ const forward = new THREE.Vector3(0, -1, 0)
+ const up = screenRight.clone().cross(forward).normalize()
+ const screenDown = up.clone().multiplyScalar(-1)
+ return { screenRight, screenDown, up }
+}
+
+export const createGuideTopViewPose = (options: {
+ position: THREE.Vector3
+ target: THREE.Vector3
+ quaternion: THREE.Quaternion
+ fov: number
+ aspect: number
+}): GuideTopViewPose => {
+ const sourceDistance = options.position.distanceTo(options.target)
+ const visibleWorldSpan = getPerspectiveHorizontalVisibleSpan(
+ sourceDistance,
+ options.fov,
+ options.aspect
+ )
+ const distance = getPerspectiveDistanceForHorizontalSpan(
+ visibleWorldSpan,
+ options.fov,
+ options.aspect
+ )
+ const basis = getGuideTopViewScreenBasis(options.quaternion)
+
+ return {
+ position: options.target.clone().add(new THREE.Vector3(0, distance, 0)),
+ target: options.target.clone(),
+ up: basis.up,
+ screenRight: basis.screenRight,
+ screenDown: basis.screenDown,
+ visibleWorldSpan,
+ distance
+ }
+}
+
+export const createGuideObliqueReturnPose = (options: {
+ originalPosition: THREE.Vector3
+ originalTarget: THREE.Vector3
+ currentTarget: THREE.Vector3
+ currentDistance: number
+ epsilon?: number
+}): GuideObliqueReturnPose => {
+ const originalDistance = options.originalPosition.distanceTo(options.originalTarget)
+ const epsilon = options.epsilon ?? 1e-6
+ const viewportChanged = options.currentTarget.distanceTo(options.originalTarget) > epsilon
+ || Math.abs(options.currentDistance - originalDistance) > epsilon
+
+ if (!viewportChanged || !Number.isFinite(options.currentDistance) || options.currentDistance <= 0) {
+ return {
+ position: options.originalPosition.clone(),
+ target: options.originalTarget.clone(),
+ distance: originalDistance,
+ viewportChanged: false
+ }
+ }
+
+ const direction = options.originalPosition.clone().sub(options.originalTarget).normalize()
+ return {
+ position: options.currentTarget.clone().addScaledVector(direction, options.currentDistance),
+ target: options.currentTarget.clone(),
+ distance: options.currentDistance,
+ viewportChanged: true
+ }
+}
diff --git a/src/domain/guideViewport.ts b/src/domain/guideViewport.ts
new file mode 100644
index 0000000..098421b
--- /dev/null
+++ b/src/domain/guideViewport.ts
@@ -0,0 +1,449 @@
+export type GuideViewportScene = 'overview' | 'floor'
+
+export interface GuideWorldBounds {
+ minX: number
+ maxX: number
+ minZ: number
+ maxZ: number
+ screenXAxis?: '+X' | '-X'
+ screenYAxis?: '+Z' | '-Z'
+ screenRight?: GuideViewportPoint
+ screenDown?: GuideViewportPoint
+ projectionCenterX?: number
+ projectionCenterZ?: number
+ projectionWidth?: number
+ projectionHeight?: number
+ initialVisibleWorldSpan?: number
+ maxVisibleWorldSpan?: number
+}
+
+export interface GuideViewportSize {
+ width: number
+ height: number
+}
+
+export interface GuideViewportPoint {
+ x: number
+ z: number
+}
+
+export interface GuideViewportScreenPoint {
+ x: number
+ y: number
+}
+
+export interface GuideViewportState {
+ scene: GuideViewportScene
+ floorId: string
+ centerX: number
+ centerZ: number
+ visibleWorldSpan: number
+ revision: number
+}
+
+export interface GuideViewportZoomResult {
+ viewport: GuideViewportState
+ changed: boolean
+ boundaryAttempt: boolean
+}
+
+export interface GuideBoundaryIntentState {
+ pendingKey: string
+ pendingAt: number
+ lastTransitionAt: number
+}
+
+export interface GuideBoundaryIntentResult {
+ state: GuideBoundaryIntentState
+ allowed: boolean
+ reason: 'not-boundary' | 'blocked' | 'armed' | 'cooldown' | 'confirmed'
+}
+
+export const GUIDE_VIEWPORT_MIN_SCALE = 1
+export const GUIDE_VIEWPORT_MAX_SCALE = 3
+export const GUIDE_VIEWPORT_ZOOM_FACTOR = 1.25
+export const GUIDE_AUTO_SWITCH_ENTER_RATIO = 0.15
+export const GUIDE_AUTO_SWITCH_EXIT_RATIO = 0.15
+
+export const getGuideAutoSwitchVisibleSpanThreshold = (
+ referenceVisibleWorldSpan: number,
+ direction: 'in' | 'out'
+) => {
+ if (!Number.isFinite(referenceVisibleWorldSpan) || referenceVisibleWorldSpan <= 0) return null
+ return referenceVisibleWorldSpan * (
+ direction === 'in'
+ ? 1 - GUIDE_AUTO_SWITCH_ENTER_RATIO
+ : 1 + GUIDE_AUTO_SWITCH_EXIT_RATIO
+ )
+}
+
+export const isGuideAutoSwitchVisibleSpanThresholdReached = (input: {
+ scene: GuideViewportScene
+ direction: 'in' | 'out'
+ currentVisibleWorldSpan: number
+ referenceVisibleWorldSpan: number
+}) => {
+ const isMatchingIntent = input.scene === 'overview'
+ ? input.direction === 'in'
+ : input.direction === 'out'
+ if (!isMatchingIntent || !Number.isFinite(input.currentVisibleWorldSpan)) return false
+
+ const threshold = getGuideAutoSwitchVisibleSpanThreshold(
+ input.referenceVisibleWorldSpan,
+ input.direction
+ )
+ if (threshold === null) return false
+ return input.direction === 'in'
+ ? input.currentVisibleWorldSpan <= threshold
+ : input.currentVisibleWorldSpan >= threshold
+}
+
+const safeDimension = (value: number) => Math.max(Number.isFinite(value) ? value : 0, 1)
+
+const normalizeVector = (value: GuideViewportPoint | undefined, fallback: GuideViewportPoint) => {
+ const length = Math.hypot(value?.x || 0, value?.z || 0)
+ if (!Number.isFinite(length) || length < 1e-9) return fallback
+ return { x: value!.x / length, z: value!.z / length }
+}
+
+const normalizeBounds = (bounds: GuideWorldBounds): Required => {
+ const minX = Math.min(bounds.minX, bounds.maxX)
+ const maxX = Math.max(bounds.minX, bounds.maxX)
+ const minZ = Math.min(bounds.minZ, bounds.maxZ)
+ const maxZ = Math.max(bounds.minZ, bounds.maxZ)
+ const screenXAxis = bounds.screenXAxis || '+X'
+ const screenYAxis = bounds.screenYAxis || '-Z'
+ const fallbackRight = { x: screenXAxis === '-X' ? -1 : 1, z: 0 }
+ const fallbackDown = { x: 0, z: screenYAxis === '+Z' ? -1 : 1 }
+
+ return {
+ minX,
+ maxX,
+ minZ,
+ maxZ,
+ screenXAxis,
+ screenYAxis,
+ screenRight: normalizeVector(bounds.screenRight, fallbackRight),
+ screenDown: normalizeVector(bounds.screenDown, fallbackDown),
+ projectionCenterX: Number.isFinite(bounds.projectionCenterX)
+ ? bounds.projectionCenterX!
+ : (minX + maxX) / 2,
+ projectionCenterZ: Number.isFinite(bounds.projectionCenterZ)
+ ? bounds.projectionCenterZ!
+ : (minZ + maxZ) / 2,
+ projectionWidth: Number.isFinite(bounds.projectionWidth) && bounds.projectionWidth! > 0
+ ? bounds.projectionWidth!
+ : maxX - minX,
+ projectionHeight: Number.isFinite(bounds.projectionHeight) && bounds.projectionHeight! > 0
+ ? bounds.projectionHeight!
+ : maxZ - minZ,
+ initialVisibleWorldSpan: Number.isFinite(bounds.initialVisibleWorldSpan)
+ && bounds.initialVisibleWorldSpan! > 0
+ ? bounds.initialVisibleWorldSpan!
+ : Math.max(maxX - minX, maxZ - minZ),
+ maxVisibleWorldSpan: Number.isFinite(bounds.maxVisibleWorldSpan)
+ && bounds.maxVisibleWorldSpan! > 0
+ ? bounds.maxVisibleWorldSpan!
+ : Math.max(maxX - minX, maxZ - minZ)
+ }
+}
+
+const toBasisCoordinates = (
+ point: GuideViewportPoint,
+ bounds: Required
+) => {
+ const deltaX = point.x - bounds.projectionCenterX
+ const deltaZ = point.z - bounds.projectionCenterZ
+ return {
+ right: deltaX * bounds.screenRight.x + deltaZ * bounds.screenRight.z,
+ down: deltaX * bounds.screenDown.x + deltaZ * bounds.screenDown.z
+ }
+}
+
+const fromBasisCoordinates = (
+ right: number,
+ down: number,
+ bounds: Required
+) => ({
+ x: bounds.projectionCenterX
+ + right * bounds.screenRight.x
+ + down * bounds.screenDown.x,
+ z: bounds.projectionCenterZ
+ + right * bounds.screenRight.z
+ + down * bounds.screenDown.z
+})
+
+export const getGuideViewportKey = (scene: GuideViewportScene, floorId = '') => (
+ scene === 'overview' ? 'overview' : `floor:${floorId}`
+)
+
+export const getGuideViewportFitSpan = (
+ boundsInput: GuideWorldBounds,
+ size: GuideViewportSize
+) => {
+ const bounds = normalizeBounds(boundsInput)
+ const aspect = safeDimension(size.width) / safeDimension(size.height)
+ const width = Math.max(bounds.projectionWidth, 1e-6)
+ const height = Math.max(bounds.projectionHeight, 1e-6)
+ return Math.max(width, height * aspect)
+}
+
+export const createGuideViewport = (options: {
+ scene: GuideViewportScene
+ floorId?: string
+ bounds: GuideWorldBounds
+ size: GuideViewportSize
+ revision?: number
+}): GuideViewportState => {
+ const bounds = normalizeBounds(options.bounds)
+ const fitSpan = getGuideViewportFitSpan(bounds, options.size)
+ return {
+ scene: options.scene,
+ floorId: options.scene === 'floor' ? String(options.floorId || '') : '',
+ centerX: bounds.projectionCenterX,
+ centerZ: bounds.projectionCenterZ,
+ visibleWorldSpan: Math.min(fitSpan, bounds.initialVisibleWorldSpan),
+ revision: options.revision || 0
+ }
+}
+
+export const clampGuideViewport = (
+ viewport: GuideViewportState,
+ boundsInput: GuideWorldBounds,
+ size: GuideViewportSize,
+ limits: { minScale?: number; maxScale?: number } = {}
+): GuideViewportState => {
+ const bounds = normalizeBounds(boundsInput)
+ const aspect = safeDimension(size.width) / safeDimension(size.height)
+ const fitSpan = getGuideViewportFitSpan(bounds, size)
+ const minScale = Math.max(limits.minScale || GUIDE_VIEWPORT_MIN_SCALE, 0.1)
+ const maxScale = Math.max(limits.maxScale || GUIDE_VIEWPORT_MAX_SCALE, minScale)
+ const initialSpan = Math.min(fitSpan, bounds.initialVisibleWorldSpan)
+ const minSpan = initialSpan / maxScale
+ const maxSpan = Math.min(fitSpan, Math.max(initialSpan, bounds.maxVisibleWorldSpan)) / minScale
+ const span = Math.min(maxSpan, Math.max(minSpan, viewport.visibleWorldSpan || fitSpan))
+ const visibleHeight = span / aspect
+ const center = toBasisCoordinates({ x: viewport.centerX, z: viewport.centerZ }, bounds)
+ const centerRight = span >= bounds.projectionWidth
+ ? 0
+ : Math.min(
+ bounds.projectionWidth / 2 - span / 2,
+ Math.max(-bounds.projectionWidth / 2 + span / 2, center.right)
+ )
+ const centerDown = visibleHeight >= bounds.projectionHeight
+ ? 0
+ : Math.min(
+ bounds.projectionHeight / 2 - visibleHeight / 2,
+ Math.max(-bounds.projectionHeight / 2 + visibleHeight / 2, center.down)
+ )
+ const worldCenter = fromBasisCoordinates(centerRight, centerDown, bounds)
+
+ return {
+ ...viewport,
+ centerX: worldCenter.x,
+ centerZ: worldCenter.z,
+ visibleWorldSpan: span
+ }
+}
+
+export const projectGuideWorldPoint = (
+ point: GuideViewportPoint,
+ viewport: GuideViewportState,
+ size: GuideViewportSize,
+ axes: Partial = {}
+): GuideViewportScreenPoint => {
+ const width = safeDimension(size.width)
+ const height = safeDimension(size.height)
+ const visibleHeight = viewport.visibleWorldSpan * height / width
+ const basis = normalizeBounds({
+ minX: viewport.centerX - viewport.visibleWorldSpan / 2,
+ maxX: viewport.centerX + viewport.visibleWorldSpan / 2,
+ minZ: viewport.centerZ - visibleHeight / 2,
+ maxZ: viewport.centerZ + visibleHeight / 2,
+ ...axes
+ })
+ const deltaX = point.x - viewport.centerX
+ const deltaZ = point.z - viewport.centerZ
+ return {
+ x: width / 2
+ + (deltaX * basis.screenRight.x + deltaZ * basis.screenRight.z)
+ / viewport.visibleWorldSpan * width,
+ y: height / 2
+ + (deltaX * basis.screenDown.x + deltaZ * basis.screenDown.z)
+ / visibleHeight * height
+ }
+}
+
+export const unprojectGuideViewportPoint = (
+ point: GuideViewportScreenPoint,
+ viewport: GuideViewportState,
+ size: GuideViewportSize,
+ axes: Partial = {}
+): GuideViewportPoint => {
+ const width = safeDimension(size.width)
+ const height = safeDimension(size.height)
+ const visibleHeight = viewport.visibleWorldSpan * height / width
+ const basis = normalizeBounds({
+ minX: viewport.centerX - viewport.visibleWorldSpan / 2,
+ maxX: viewport.centerX + viewport.visibleWorldSpan / 2,
+ minZ: viewport.centerZ - visibleHeight / 2,
+ maxZ: viewport.centerZ + visibleHeight / 2,
+ ...axes
+ })
+ const right = (point.x - width / 2) / width * viewport.visibleWorldSpan
+ const down = (point.y - height / 2) / height * visibleHeight
+ return {
+ x: viewport.centerX + right * basis.screenRight.x + down * basis.screenDown.x,
+ z: viewport.centerZ + right * basis.screenRight.z + down * basis.screenDown.z
+ }
+}
+
+export const panGuideViewport = (
+ viewport: GuideViewportState,
+ deltaPixels: GuideViewportScreenPoint,
+ bounds: GuideWorldBounds,
+ size: GuideViewportSize
+) => {
+ const normalized = normalizeBounds(bounds)
+ const rightDelta = deltaPixels.x / safeDimension(size.width) * viewport.visibleWorldSpan
+ const downDelta = deltaPixels.y / safeDimension(size.height)
+ * viewport.visibleWorldSpan * safeDimension(size.height) / safeDimension(size.width)
+ return clampGuideViewport({
+ ...viewport,
+ centerX: viewport.centerX
+ - rightDelta * normalized.screenRight.x
+ - downDelta * normalized.screenDown.x,
+ centerZ: viewport.centerZ
+ - rightDelta * normalized.screenRight.z
+ - downDelta * normalized.screenDown.z,
+ revision: viewport.revision + 1
+ }, bounds, size)
+}
+
+export const setGuideViewportSpanAt = (
+ viewport: GuideViewportState,
+ requestedSpan: number,
+ anchorBefore: GuideViewportScreenPoint,
+ size: GuideViewportSize,
+ bounds: GuideWorldBounds,
+ anchorAfter: GuideViewportScreenPoint = anchorBefore
+) => {
+ const worldAnchor = unprojectGuideViewportPoint(anchorBefore, viewport, size, bounds)
+ const provisional = clampGuideViewport({
+ ...viewport,
+ visibleWorldSpan: requestedSpan,
+ revision: viewport.revision + 1
+ }, bounds, size)
+ const projectedAnchor = projectGuideWorldPoint(worldAnchor, provisional, size, bounds)
+ const translated = panGuideViewport(provisional, {
+ x: anchorAfter.x - projectedAnchor.x,
+ y: anchorAfter.y - projectedAnchor.y
+ }, bounds, size)
+ return {
+ ...translated,
+ revision: viewport.revision + 1
+ }
+}
+
+export const zoomGuideViewport = (
+ viewport: GuideViewportState,
+ direction: 'in' | 'out',
+ bounds: GuideWorldBounds,
+ size: GuideViewportSize,
+ anchor: GuideViewportScreenPoint = {
+ x: safeDimension(size.width) / 2,
+ y: safeDimension(size.height) / 2
+ }
+): GuideViewportZoomResult => {
+ const requestedSpan = viewport.visibleWorldSpan * (
+ direction === 'in' ? 1 / GUIDE_VIEWPORT_ZOOM_FACTOR : GUIDE_VIEWPORT_ZOOM_FACTOR
+ )
+ const next = setGuideViewportSpanAt(viewport, requestedSpan, anchor, size, bounds)
+ const changed = Math.abs(next.visibleWorldSpan - viewport.visibleWorldSpan) > 1e-9
+ || Math.abs(next.centerX - viewport.centerX) > 1e-9
+ || Math.abs(next.centerZ - viewport.centerZ) > 1e-9
+ return {
+ viewport: changed ? next : viewport,
+ changed,
+ boundaryAttempt: !changed
+ }
+}
+
+export const createGuideBoundaryIntentState = (): GuideBoundaryIntentState => ({
+ pendingKey: '',
+ pendingAt: 0,
+ lastTransitionAt: Number.NEGATIVE_INFINITY
+})
+
+export const reduceGuideBoundaryIntent = (
+ state: GuideBoundaryIntentState,
+ input: {
+ scene: GuideViewportScene
+ direction: 'in' | 'out'
+ boundaryAttempt: boolean
+ blocked?: boolean
+ now: number
+ confirmationWindowMs?: number
+ cooldownMs?: number
+ }
+): GuideBoundaryIntentResult => {
+ const key = `${input.scene}:${input.direction}`
+ const confirmationWindowMs = input.confirmationWindowMs ?? 1200
+ const cooldownMs = input.cooldownMs ?? 1500
+
+ if (!input.boundaryAttempt) {
+ return {
+ state: { ...state, pendingKey: '', pendingAt: 0 },
+ allowed: false,
+ reason: 'not-boundary'
+ }
+ }
+ if (input.blocked) {
+ return {
+ state: { ...state, pendingKey: '', pendingAt: 0 },
+ allowed: false,
+ reason: 'blocked'
+ }
+ }
+ if (input.now - state.lastTransitionAt < cooldownMs) {
+ return {
+ state: { ...state, pendingKey: '', pendingAt: 0 },
+ allowed: false,
+ reason: 'cooldown'
+ }
+ }
+ if (state.pendingKey === key && input.now - state.pendingAt <= confirmationWindowMs) {
+ return {
+ state: {
+ pendingKey: '',
+ pendingAt: 0,
+ lastTransitionAt: input.now
+ },
+ allowed: true,
+ reason: 'confirmed'
+ }
+ }
+ return {
+ state: {
+ ...state,
+ pendingKey: key,
+ pendingAt: input.now
+ },
+ allowed: false,
+ reason: 'armed'
+ }
+}
+
+export const isGuideFloorAutoExitBlocked = (state: {
+ disableAutoExit?: boolean
+ hasQuery?: boolean
+ hasSelection?: boolean
+ hasRoute?: boolean
+ isNavigating?: boolean
+}) => Boolean(
+ state.disableAutoExit
+ || state.hasQuery
+ || state.hasSelection
+ || state.hasRoute
+ || state.isNavigating
+)
diff --git a/src/domain/museum.ts b/src/domain/museum.ts
index f58d713..1aa7744 100644
--- a/src/domain/museum.ts
+++ b/src/domain/museum.ts
@@ -94,10 +94,9 @@ export interface MuseumPoi {
categories: MuseumCategory[]
positionGltf?: [number, number, number]
sourceObjectName?: string
+ mergedSourceObjectNames?: string[]
sourceConfidence?: string
navigationReadiness?: string
- /** Explicit visitor-search eligibility supplied by the source or adapter policy. */
- visitorVisible?: boolean
accessible: boolean
kind?: MuseumPoiKind
hallId?: string
@@ -139,24 +138,25 @@ export interface MuseumHall {
hasAudio?: boolean
audioStatus?: string
supportedLanguages?: string[]
- audioOptionCount?: number
area?: string
poiId?: string
location?: GuideLocationResolution
}
-export type MuseumAudioVoiceGender = 'female' | 'male'
+export type GuideAudioGender = 'male' | 'female'
-export interface MuseumAudioOption {
+/** A directly playable guide-audio channel supplied by the stop-info API. */
+export interface MuseumGuideAudioOption {
channelCode: string
- displayName: string
+ displayName?: string
+ version?: string
languageCode: string
languageName?: string
- gender: MuseumAudioVoiceGender
- audioUrl: string
+ gender: GuideAudioGender
+ playUrl: string
duration?: number
format?: string
- isDefault: boolean
+ isDefault?: boolean
sortOrder?: number
}
@@ -195,28 +195,14 @@ export interface MuseumExhibit {
audioAvailable?: boolean
audioStatus?: string
supportedLanguages?: string[]
- audioOptions?: MuseumAudioOption[]
- audioChannelCode?: string
- audioVoiceGender?: MuseumAudioVoiceGender
- audioVoiceDisplayName?: string
+ audioOptions?: MuseumGuideAudioOption[]
audioVariants?: Record
imageStatus?: string
imageSource?: string
@@ -253,7 +239,6 @@ export interface ExplainGuideStop {
hasAudio?: boolean
audioStatus?: string
supportedLanguages?: string[]
- audioOptionCount?: number
hasTextRecord?: boolean
poiId?: string
mapX?: number
@@ -348,13 +333,19 @@ export interface GuideRouteReadiness {
}
export interface GuideRouteTarget {
+ // 接入点唯一标识。同一空间可拥有多个接入点,不能只按 poiId 选择。
+ routeTargetId?: string
poiId: string
+ // 业务对象 ID。门点的 poiId 可能是路网节点 ID,点击地图对象时应以此字段回连。
+ sourceId?: string
name: string
floorId: string
floorLabel: string
categoryLabel?: string
positionGltf?: [number, number, number]
- routeNodeId: string
+ // 起点必须是已接入路网的对象;终点允许是任意可定位的地图对象,
+ // 由后端按其坐标接入最近的正式路网节点。
+ routeNodeId?: string
}
export interface GuideRoutePoint {
@@ -377,12 +368,22 @@ export interface GuideRouteConnectorPoint {
connectorType?: string
}
+export interface GuideRouteTransition {
+ id: string
+ fromFloorId: string
+ toFloorId: string
+ fromPosition: [number, number, number]
+ toPosition: [number, number, number]
+ transferType?: string
+ connectorName?: string
+}
+
export interface GuideRouteEndpoint {
poiId: string
name: string
floorId: string
floorLabel: string
- routeNodeId: string
+ routeNodeId?: string
position: [number, number, number]
}
@@ -395,6 +396,9 @@ export interface GuideRouteResult {
points: GuideRoutePoint[]
floorSegments: GuideRouteFloorSegment[]
connectorPoints: GuideRouteConnectorPoint[]
+ // Optional until every route provider emits its native transfer segments.
+ // Consumers retain connectorPoints as a backwards-compatible fallback.
+ transitions?: GuideRouteTransition[]
}
export interface MediaAsset {
diff --git a/src/domain/navigationScene.ts b/src/domain/navigationScene.ts
new file mode 100644
index 0000000..fe3069a
--- /dev/null
+++ b/src/domain/navigationScene.ts
@@ -0,0 +1,72 @@
+import type {
+ GuideRouteResult
+} from '@/domain/museum'
+import type {
+ GuideModelRouteAsset
+} from '@/domain/guideModel'
+
+/**
+ * The navigation scene is intentionally derived from the confirmed route,
+ * never from the map currently being browsed. This prevents the ordinary
+ * indoor/outdoor auto-switch rules from changing a route presentation.
+ */
+export type NavigationSceneKind =
+ | 'single-floor'
+ | 'multi-floor'
+ | 'same-ground-composite'
+
+export interface NavigationScenePlan {
+ kind: NavigationSceneKind
+ floorIds: string[]
+ transitionCount: number
+ compositeAsset: GuideModelRouteAsset | null
+}
+
+const uniqueRouteFloorIds = (route: GuideRouteResult) => Array.from(new Set(
+ route.floorSegments
+ .map((segment) => String(segment.floorId))
+ .filter(Boolean)
+))
+
+const matchesCompositeCoverage = (
+ floorIds: string[],
+ asset: GuideModelRouteAsset
+) => {
+ const routeFloorSet = new Set(floorIds)
+ const coverageFloorIds = asset.coverageFloorIds.map(String).filter(Boolean)
+
+ return coverageFloorIds.length > 1
+ && coverageFloorIds.length === routeFloorSet.size
+ && coverageFloorIds.every((floorId) => routeFloorSet.has(floorId))
+}
+
+export const compileNavigationScene = (
+ route: GuideRouteResult,
+ routeAssets: GuideModelRouteAsset[] = []
+): NavigationScenePlan => {
+ const floorIds = uniqueRouteFloorIds(route)
+ const compositeAsset = routeAssets.find((asset) => (
+ asset.role === 'same-ground-composite'
+ && matchesCompositeCoverage(floorIds, asset)
+ )) || null
+
+ if (compositeAsset) {
+ return {
+ kind: 'same-ground-composite',
+ floorIds,
+ transitionCount: 0,
+ compositeAsset
+ }
+ }
+
+ const transitionCount = route.floorSegments.reduce((count, segment, index, segments) => (
+ index > 0 && segments[index - 1].floorId !== segment.floorId ? count + 1 : count
+ ), 0)
+
+ return {
+ kind: floorIds.length > 1 ? 'multi-floor' : 'single-floor',
+ floorIds,
+ transitionCount,
+ compositeAsset: null
+ }
+}
diff --git a/src/domain/overviewMapLabels.ts b/src/domain/overviewMapLabels.ts
new file mode 100644
index 0000000..7db5743
--- /dev/null
+++ b/src/domain/overviewMapLabels.ts
@@ -0,0 +1,100 @@
+export type OverviewMapLabelCategory = 'entrance' | 'parking' | 'place' | 'road' | 'transport'
+
+export interface OverviewMapLabelDefinition {
+ id: string
+ label: string
+ nodeNames: string[]
+ fallbackKeywords?: string[]
+ excludeKeywords?: string[]
+ fallbackPositionGltf?: readonly [number, number, number]
+ category: OverviewMapLabelCategory
+}
+
+export const OVERVIEW_MAP_LABEL_DEFINITIONS: readonly OverviewMapLabelDefinition[] = Object.freeze([
+ {
+ id: 'overview-parking-entry',
+ label: '停车场入口',
+ nodeNames: ['L1_停车场入口'],
+ fallbackKeywords: ['停车场入口', '停车入口'],
+ excludeKeywords: ['02'],
+ fallbackPositionGltf: [-117.34907150268555, 0.8, -76.9936294555664],
+ category: 'entrance'
+ },
+ {
+ id: 'overview-parking-exit',
+ label: '停车场出口',
+ nodeNames: ['L1_停车场入口02'],
+ fallbackKeywords: ['停车场入口02', '停车场出口', '停车出口'],
+ fallbackPositionGltf: [72.57579040527344, 0.8, 72.41608715057373],
+ category: 'entrance'
+ },
+ {
+ id: 'overview-parking',
+ label: '停车场',
+ nodeNames: ['L1_停车场'],
+ fallbackKeywords: ['停车场'],
+ excludeKeywords: ['入口', '出口', '楼梯'],
+ category: 'parking'
+ },
+ {
+ id: 'overview-sunken-plaza',
+ label: '室外下沉广场',
+ nodeNames: ['L1_室外下沉广场'],
+ fallbackKeywords: ['室外下沉广场', '下沉广场'],
+ category: 'place'
+ },
+ {
+ id: 'overview-honghua-road',
+ label: '红花潭路',
+ nodeNames: ['室外_红花潭路', '室外_红花路'],
+ fallbackKeywords: ['红花潭路', '红花路'],
+ category: 'road'
+ },
+ {
+ id: 'overview-wenxiang-road',
+ label: '文祥路',
+ nodeNames: ['室外_文祥路'],
+ fallbackKeywords: ['文祥路'],
+ category: 'road'
+ },
+ {
+ id: 'overview-bus-area',
+ label: '大巴区',
+ nodeNames: ['室外_大巴区'],
+ fallbackKeywords: ['大巴区'],
+ category: 'transport'
+ }
+])
+
+export const isOverviewMapLabelMatch = (
+ name: string,
+ definition: OverviewMapLabelDefinition
+) => {
+ const normalizedName = name.trim()
+ if (definition.nodeNames.includes(normalizedName)) return true
+
+ const isFallbackMatch = definition.fallbackKeywords?.some((keyword) => (
+ normalizedName.includes(keyword)
+ ))
+ if (!isFallbackMatch) return false
+
+ return !definition.excludeKeywords?.some((keyword) => normalizedName.includes(keyword))
+}
+
+export const getOverviewMapLabelDefinition = (
+ poi: {
+ name?: string | null
+ sourceObjectName?: string | null
+ mergedSourceObjectNames?: string[]
+ }
+) => {
+ const names = [
+ poi.sourceObjectName,
+ ...(poi.mergedSourceObjectNames || []),
+ poi.name
+ ].filter((name): name is string => Boolean(name))
+
+ return OVERVIEW_MAP_LABEL_DEFINITIONS.find((definition) => (
+ names.some((name) => isOverviewMapLabelMatch(name, definition))
+ )) || null
+}
diff --git a/src/domain/poiCategories.ts b/src/domain/poiCategories.ts
index 88a1ff6..6abfe97 100644
--- a/src/domain/poiCategories.ts
+++ b/src/domain/poiCategories.ts
@@ -153,7 +153,17 @@ export const HOME_POI_CATEGORIES = Object.freeze(
))
)
-const POI_CATEGORY_ICON_SYMBOLS: Readonly> = {
+export type PoiIconKey = PoiCategoryId
+ | 'stairs'
+ | 'poi'
+ | 'entrance'
+ | 'parking'
+ | 'place'
+ | 'road'
+ | 'transport'
+
+const POI_ICON_SYMBOLS: Readonly> = {
+ poi: 'poi-generic',
'exhibition-hall': 'poi-exhibition-hall',
cinema: 'poi-cinema',
'ticket-office': 'poi-ticket-office',
@@ -163,11 +173,21 @@ const POI_CATEGORY_ICON_SYMBOLS: Readonly> = {
restroom: 'poi-restroom',
'nursing-room': 'poi-nursing-room',
elevator: 'poi-elevator',
- escalator: 'poi-escalator'
+ escalator: 'poi-escalator',
+ stairs: 'poi-stairs',
+ entrance: 'poi-entrance',
+ parking: 'poi-parking',
+ place: 'poi-place',
+ road: 'poi-road',
+ transport: 'poi-transport'
}
+export const getPoiIconHref = (iconKey: PoiIconKey) => (
+ `/static/icons/poi/shortcut-icons.svg#${POI_ICON_SYMBOLS[iconKey]}`
+)
+
export const getPoiCategoryIconHref = (categoryId: PoiCategoryId) => (
- `/static/icons/poi/shortcut-icons.svg#${POI_CATEGORY_ICON_SYMBOLS[categoryId]}`
+ getPoiIconHref(categoryId)
)
export type PoiCategorySource = Pick<
@@ -183,7 +203,6 @@ export type PoiCategorySource = Pick<
| 'sourcePlaceId'
| 'sourceSpaceId'
| 'sourceObjectName'
- | 'visitorVisible'
>
const normalizeValue = (value?: string | null) => (value || '')
@@ -193,13 +212,6 @@ const normalizeValue = (value?: string | null) => (value || '')
.replace(/[\s-]+/g, '_')
.replace(/^_+|_+$/g, '')
-const visitorRestrictedPlaceNamePattern = /(?:贵宾|vip|员工|职工|后勤|办公|行政|库房|仓库|机房|设备间|配电|弱电|强电|保洁|值班|消防控制|监控室|staff|employee|back[_ -]?of[_ -]?house|maintenance)/i
-
-/** Legacy sources without an explicit flag must not expose staff-only places. */
-export const isVisitorRestrictedPlaceName = (value?: string | null) => (
- visitorRestrictedPlaceNamePattern.test(normalizeValue(value))
-)
-
const poiSemanticAliases: Readonly> = {
exhibition: 'exhibition_hall',
exhibition_hall: 'exhibition_hall',
@@ -295,6 +307,57 @@ export const normalizePoiSemanticValue = (value?: string | null) => {
return poiSemanticAliases[normalized] || normalized
}
+export interface PoiIconSource {
+ primaryCategory?: string | null
+ iconType?: string | null
+ name?: string | null
+}
+
+const outdoorIconKeyByValue: Readonly> = {
+ entrance: 'entrance',
+ entrance_exit: 'entrance',
+ parking: 'parking',
+ place: 'place',
+ road: 'road',
+ transport: 'transport'
+}
+
+const standaloneIconKeyByValue: Readonly> = {
+ stairs: 'stairs'
+}
+
+/** Resolve the shared icon content used by map labels and search shortcuts. */
+export const resolvePoiIconKey = (source: PoiIconSource): PoiIconKey => {
+ const values = [source.iconType, source.primaryCategory]
+ .map(normalizePoiSemanticValue)
+ .filter(Boolean)
+
+ const category = POI_CATEGORIES.find((candidate) => (
+ candidate.categoryIds.some((categoryId) => values.includes(normalizePoiSemanticValue(categoryId)))
+ || candidate.iconTypes.some((iconType) => values.includes(normalizePoiSemanticValue(iconType)))
+ ))
+ if (category) return category.id
+
+ const standaloneKey = values
+ .map((value) => standaloneIconKeyByValue[value])
+ .find((key): key is PoiIconKey => Boolean(key))
+ if (standaloneKey) return standaloneKey
+
+ const outdoorKey = values
+ .map((value) => outdoorIconKeyByValue[value])
+ .find((key): key is PoiIconKey => Boolean(key))
+ if (outdoorKey) return outdoorKey
+
+ const name = normalizeValue(source.name)
+ if (name.includes('停车场')) return 'parking'
+ if (name.includes('入口') || name.includes('出口') || name.includes('出入口')) return 'entrance'
+ if (name.includes('道路') || name.includes('路')) return 'road'
+ if (name.includes('广场')) return 'place'
+ if (name.includes('大巴') || name.includes('公交')) return 'transport'
+
+ return 'poi'
+}
+
const genericSourceCategoryValues = new Set([
'poi',
'basic_service_facility',
@@ -405,7 +468,7 @@ export const resolvePoiCategory = (poi: PoiCategorySource) => (
POI_CATEGORIES.find((category) => matchesPoiCategory(poi, category)) || null
)
-const hiddenVisitorTypes = new Set([
+const hiddenVisitorPrimaryTypes = new Set([
'entrance_exit',
'hall_entrance',
'entrance_anchor',
@@ -414,7 +477,7 @@ const hiddenVisitorTypes = new Set([
'operation_experience'
])
-const visitorTypeValues = (poi: PoiCategorySource) => [
+const primaryTypeValues = (poi: PoiCategorySource) => [
poi.primaryCategory.id,
poi.primaryCategory.label,
poi.primaryCategory.iconType || ''
@@ -422,18 +485,10 @@ const visitorTypeValues = (poi: PoiCategorySource) => [
.map(normalizePoiSemanticValue)
.filter(Boolean)
-const isHiddenVisitorType = (value: string) => {
- const normalizedValue = normalizePoiSemanticValue(value)
- const sourceWrappedValue = normalizedValue.replace(/^(?:space|poi|facility|business)_/, '')
- return hiddenVisitorTypes.has(normalizedValue)
- || hiddenVisitorTypes.has(sourceWrappedValue)
-}
-
/** A visitor result must never be a guide point, door, entrance anchor, or route node. */
export const isVisitorSearchPoi = (poi: PoiCategorySource) => {
- if (poi.visitorVisible === false) return false
if (poi.kind === 'guide' || poi.kind === 'hall_entrance') return false
- return !visitorTypeValues(poi).some(isHiddenVisitorType)
+ return !primaryTypeValues(poi).some((value) => hiddenVisitorPrimaryTypes.has(value))
}
/** Default floor browse only contains canonical halls and valid destination spaces. */
@@ -532,6 +587,8 @@ export const getPoiDataIssues = (poi: MuseumPoi): PoiDataIssue[] => {
} else if (!hasFinitePosition(poi.positionGltf)) {
issues.push({ code: 'invalid-position', ...issueBase })
}
+ if (!isPoiSearchCategorySupported(poi)) issues.push({ code: 'unsupported-category', ...issueBase })
+
return issues
}
@@ -562,6 +619,6 @@ export const warnPoiCollectionIssues = (source: string, pois: MuseumPoi[]) => {
const inspection = inspectPoiCollection(pois)
if (!inspection.issues.length && !inspection.duplicateIds.length) return
- // This is an aggregate development diagnostic, not a visitor-facing failure.
- console.debug(`[POI 数据诊断] ${source}`, inspection)
+ // 开发期集中输出数据契约问题,避免在页面组件中散落源数据校验。
+ console.warn(`[POI 数据校验] ${source}`, inspection)
}
diff --git a/src/domain/poiDisplay.ts b/src/domain/poiDisplay.ts
index f138667..60c1668 100644
--- a/src/domain/poiDisplay.ts
+++ b/src/domain/poiDisplay.ts
@@ -5,6 +5,8 @@ export type PoiVisibilityTier = 'tight' | 'balanced' | 'full'
export interface PoiDisplayPolicy {
markerVisible: boolean
labelVisible: boolean
+ /** Keep this label visible in the distant floor view, outside density limits. */
+ pinInOverview: boolean
minMarkerTier: PoiVisibilityTier
minLabelTier: PoiVisibilityTier
allowOverview: boolean
@@ -15,15 +17,127 @@ export interface PoiDisplayPolicy {
export interface PoiDisplayPolicyInput {
primaryCategory?: string
+ iconType?: string
kind?: MuseumPoiKind
}
+export type AmbientFacilityKind = 'restroom' | 'accessible' | 'nursing' | 'elevator' | 'escalator' | 'stairs'
+export type AmbientFacilityTier = 'balanced' | 'near-only'
+
const tierRank: Record = {
tight: 0,
balanced: 1,
full: 2
}
+export const getPoiVisibilityTier = (distanceRatio: number): PoiVisibilityTier => {
+ if (!Number.isFinite(distanceRatio)) return 'full'
+ if (distanceRatio >= 0.72) return 'tight'
+ if (distanceRatio >= 0.42) return 'balanced'
+ return 'full'
+}
+
+export const getPoiMarkerLimit = (tier: PoiVisibilityTier) => {
+ if (tier === 'tight') return 5
+ if (tier === 'balanced') return 9
+ return Number.POSITIVE_INFINITY
+}
+
+export const getAmbientFacilityLimit = (tier: PoiVisibilityTier) => {
+ if (tier === 'tight') return 0
+ if (tier === 'balanced') return 4
+ return 8
+}
+
+export const getPoiScreenSpacing = (tier: PoiVisibilityTier) => {
+ if (tier === 'tight') return 116
+ if (tier === 'balanced') return 78
+ return 0
+}
+
+const balancedFacilityIconTypes = new Set([
+ 'restroom',
+ 'toilet',
+ 'accessible',
+ 'accessible_restroom',
+ 'accessible_toilet',
+ 'restroom_accessible',
+ 'mother_baby_room',
+ 'nursing_room',
+ 'nursery',
+ 'elevator'
+])
+
+const fullFacilityIconTypes = new Set(['escalator', 'stairs', 'stair'])
+
+const normalizeIconType = (value?: string) => value?.trim().toLowerCase() || ''
+
+const normalizeDeviceMapLabel = (name: string) => {
+ const normalized = name.trim()
+ if (!normalized) return ''
+
+ // Model-backed device names carry a floor and often a technical group
+ // prefix (for example L1_H_). Keep those fields for Mesh matching, but do
+ // not expose them in the visitor-facing map label.
+ const withoutFloorPrefix = normalized.replace(
+ /^(?:L-?\d+(?:\.\d+)?|B\d+(?:\.\d+)?|MF|EXTERIOR)(?:[_-][A-Z]+)?[_-]?/i,
+ ''
+ )
+ return withoutFloorPrefix
+ .replace(/[_-]\d{1,4}$/u, '')
+ .replace(/(闸机|导览屏|导览机|安检机|售票机|自助售卖机|检票机|门禁|闸口)\d{1,4}$/u, '$1')
+ .trim() || normalized
+}
+
+export const getAmbientFacilityKind = (iconType?: string): AmbientFacilityKind | null => {
+ const normalized = normalizeIconType(iconType)
+ if (normalized === 'elevator') return 'elevator'
+ if (normalized === 'escalator') return 'escalator'
+ if (normalized === 'stairs' || normalized === 'stair') return 'stairs'
+ if (normalized === 'accessible' || normalized === 'accessible_toilet' || normalized === 'accessible_restroom' || normalized === 'restroom_accessible') return 'accessible'
+ if (normalized === 'mother_baby_room' || normalized === 'nursing_room' || normalized === 'nursery') return 'nursing'
+ if (normalized === 'restroom' || normalized === 'toilet' || normalized === 'restroom_basic') return 'restroom'
+ return null
+}
+
+export const getAmbientFacilityTier = (iconType?: string): AmbientFacilityTier | null => {
+ const kind = getAmbientFacilityKind(iconType)
+ if (!kind) return null
+ return kind === 'escalator' || kind === 'stairs' ? 'near-only' : 'balanced'
+}
+
+export const getPoiMapLabelText = ({
+ name = '',
+ iconType = ''
+}: Pick & { name?: string }): string => {
+ const kind = getAmbientFacilityKind(iconType)
+ if (normalizeIconType(iconType) === 'device_terminal') return normalizeDeviceMapLabel(name)
+ if (kind === 'elevator') return '电梯'
+ if (kind === 'escalator') return '扶梯'
+ if (kind === 'stairs') return '楼梯'
+ if (kind === 'accessible') return '无障碍卫生间'
+ if (kind === 'nursing') return '母婴室'
+ if (kind === 'restroom') {
+ const hasMale = name.includes('男')
+ const hasFemale = name.includes('女')
+ if (hasMale && hasFemale) return '男女卫生间'
+ if (hasMale) return '男卫生间'
+ if (hasFemale) return '女卫生间'
+ if (name.includes('无障碍')) return '无障碍卫生间'
+ return '卫生间'
+ }
+ return name.trim()
+}
+
+const getFacilityPriority = (kind: AmbientFacilityKind | null) => {
+ if (kind === 'elevator') return 70
+ if (kind === 'accessible') return 64
+ if (kind === 'nursing') return 62
+ if (kind === 'restroom') return 56
+ if (kind === 'escalator') return 52
+ return kind === 'stairs' ? 48 : 40
+}
+
export const isPoiVisibilityTierAtLeast = (
tier: PoiVisibilityTier,
minimum: PoiVisibilityTier
@@ -31,24 +145,39 @@ export const isPoiVisibilityTierAtLeast = (
export const getPoiDisplayPolicy = ({
primaryCategory = '',
+ iconType = '',
kind
}: PoiDisplayPolicyInput): PoiDisplayPolicy => {
const category = primaryCategory.trim().toLowerCase()
+ const normalizedIconType = normalizeIconType(iconType)
const isTarget = category === 'target_preview'
const isHall = kind === 'hall'
|| category === 'exhibition_hall'
|| category === 'exhibition_hall_entrance'
|| category === 'touring_poi'
const isSpace = kind === 'space' || category.startsWith('space_')
+ const isIndoorLandmarkSpace = isSpace && category !== 'space_road_area'
const isService = category === 'basic_service_facility'
|| category === 'accessibility_special_service'
|| category === 'business_poi'
+ const isBusinessLandmark = category === 'business_poi'
+ const isTicketLandmark = normalizedIconType === 'ticket_office'
+ const isServiceDeskLandmark = normalizedIconType === 'service_desk'
+ const isFloorExit = category === 'navigation_anchor'
+ && normalizedIconType === 'entrance_exit'
const isTransport = category === 'transport_circulation'
+ const isDeviceTerminal = normalizedIconType === 'device_terminal'
+ const facilityLabelTier: PoiVisibilityTier | null = balancedFacilityIconTypes.has(normalizedIconType)
+ ? 'balanced'
+ : fullFacilityIconTypes.has(normalizedIconType)
+ ? 'full'
+ : null
if (isTarget) {
return {
markerVisible: true,
labelVisible: true,
+ pinInOverview: true,
minMarkerTier: 'tight',
minLabelTier: 'tight',
allowOverview: true,
@@ -62,50 +191,116 @@ export const getPoiDisplayPolicy = ({
return {
markerVisible: true,
labelVisible: true,
+ pinInOverview: true,
minMarkerTier: 'tight',
// 原始 floor 视图中展厅 marker 可见即显示标签,不受更近一档缩放限制。
minLabelTier: 'tight',
- allowOverview: true,
+ allowOverview: false,
allowMulti: true,
priority: 120,
- labelCollisionSpacing: 64
+ labelCollisionSpacing: 10
}
}
- if (isSpace) {
+ if (isFloorExit) {
return {
markerVisible: true,
- labelVisible: false,
+ labelVisible: true,
+ pinInOverview: true,
minMarkerTier: 'tight',
- // 普通空间保留 marker,但不参与展厅/设施环境标签层。
- minLabelTier: 'full',
- allowOverview: true,
+ minLabelTier: 'tight',
+ allowOverview: false,
allowMulti: true,
- priority: 50,
- labelCollisionSpacing: 20
+ priority: 110,
+ labelCollisionSpacing: 10
+ }
+ }
+
+ if (isIndoorLandmarkSpace) {
+ return {
+ markerVisible: true,
+ // 影院、剧场、餐饮和商店等均属于游客可识别的室内地标。
+ // 默认楼层视图必须保留全部地标,不能因类型为 space 被直接裁掉。
+ labelVisible: true,
+ pinInOverview: true,
+ minMarkerTier: 'tight',
+ minLabelTier: 'tight',
+ allowOverview: false,
+ allowMulti: true,
+ priority: 100,
+ labelCollisionSpacing: 6
+ }
+ }
+
+ if (isDeviceTerminal) {
+ return {
+ markerVisible: true,
+ labelVisible: true,
+ pinInOverview: false,
+ minMarkerTier: 'tight',
+ minLabelTier: 'tight',
+ allowOverview: false,
+ allowMulti: false,
+ priority: 88,
+ labelCollisionSpacing: 10
}
}
if (isService) {
+ const isServiceLandmark = isBusinessLandmark || isTicketLandmark || isServiceDeskLandmark
+ if (facilityLabelTier) {
+ const isBalancedFacility = facilityLabelTier === 'balanced'
+ return {
+ markerVisible: true,
+ labelVisible: true,
+ pinInOverview: false,
+ minMarkerTier: facilityLabelTier,
+ minLabelTier: facilityLabelTier,
+ allowOverview: false,
+ allowMulti: true,
+ priority: getFacilityPriority(getAmbientFacilityKind(normalizedIconType)),
+ labelCollisionSpacing: isBalancedFacility ? 10 : 8
+ }
+ }
+
return {
markerVisible: true,
+ // Secondary services stay out of the distant layout, but still need a
+ // DOM label at close range or while selected so no legacy pin leaks out.
labelVisible: true,
- minMarkerTier: 'tight',
- minLabelTier: 'full',
- allowOverview: true,
+ pinInOverview: isServiceLandmark,
+ minMarkerTier: isServiceLandmark ? 'tight' : 'full',
+ minLabelTier: isServiceLandmark ? 'tight' : 'full',
+ allowOverview: false,
allowMulti: true,
- priority: 80,
- labelCollisionSpacing: 48
+ priority: isBusinessLandmark ? 100 : isServiceDeskLandmark ? 94 : isTicketLandmark ? 90 : 72,
+ labelCollisionSpacing: isServiceLandmark ? 10 : 12
}
}
if (isTransport) {
+ if (facilityLabelTier) {
+ const isBalancedFacility = facilityLabelTier === 'balanced'
+ return {
+ markerVisible: true,
+ labelVisible: true,
+ pinInOverview: false,
+ minMarkerTier: facilityLabelTier,
+ minLabelTier: facilityLabelTier,
+ allowOverview: false,
+ allowMulti: true,
+ priority: getFacilityPriority(getAmbientFacilityKind(normalizedIconType)),
+ labelCollisionSpacing: isBalancedFacility ? 10 : 8
+ }
+ }
+
return {
markerVisible: true,
labelVisible: false,
+ pinInOverview: false,
minMarkerTier: 'balanced',
minLabelTier: 'full',
- allowOverview: true,
+ allowOverview: false,
allowMulti: true,
priority: 40,
labelCollisionSpacing: 40
@@ -115,9 +310,10 @@ export const getPoiDisplayPolicy = ({
return {
markerVisible: true,
labelVisible: false,
+ pinInOverview: false,
minMarkerTier: 'tight',
minLabelTier: 'full',
- allowOverview: true,
+ allowOverview: false,
allowMulti: true,
priority: 20,
labelCollisionSpacing: 32
diff --git a/src/domain/poiSearch.ts b/src/domain/poiSearch.ts
index deb3ceb..d346ef2 100644
--- a/src/domain/poiSearch.ts
+++ b/src/domain/poiSearch.ts
@@ -28,8 +28,6 @@ export interface PoiSearchSelection {
export interface PoiCategoryResultState extends PoiSearchContext {
active: boolean
pending?: boolean
- /** Monotonic home-result event version used to reject stale cross-component state. */
- requestId?: number
}
export type GuidePoiSearchMode = 'default' | 'category' | 'keyword'
diff --git a/src/domain/poiSeriesGrouping.ts b/src/domain/poiSeriesGrouping.ts
new file mode 100644
index 0000000..4f54f27
--- /dev/null
+++ b/src/domain/poiSeriesGrouping.ts
@@ -0,0 +1,45 @@
+export interface PoiSeriesGroupInput {
+ floorId: string
+ name: string
+ category?: string
+ iconType?: string
+}
+
+export interface PoiSeriesGroup {
+ groupKey: string
+ displayName: string
+}
+
+const normalizeToken = (value: string) => value
+ .trim()
+ .normalize('NFKC')
+ .toLowerCase()
+ .replace(/[\s-]+/g, '_')
+ .replace(/^_+|_+$/g, '')
+
+const numberedTicketMachinePattern = /^(?:(?:l-?\d+(?:\.\d+)?|b\d+|f\d+|\d+f)[_\s-]+)?售票机[_\s-]+0*[1-9]\d*$/i
+
+export const resolvePoiSeriesGroup = ({
+ floorId,
+ name,
+ category = '',
+ iconType = ''
+}: PoiSeriesGroupInput): PoiSeriesGroup | null => {
+ const normalizedFloorId = normalizeToken(floorId)
+ const normalizedName = name.trim().normalize('NFKC')
+ const semanticTokens = new Set([
+ normalizeToken(category),
+ normalizeToken(iconType)
+ ])
+
+ if (
+ !normalizedFloorId
+ || !semanticTokens.has('ticket_office')
+ || !numberedTicketMachinePattern.test(normalizedName)
+ ) return null
+
+ return {
+ groupKey: `numbered-ticket-machine:${normalizedFloorId}`,
+ displayName: '售票机'
+ }
+}
diff --git a/src/pages.json b/src/pages.json
index 91b4cdf..b0ad303 100644
--- a/src/pages.json
+++ b/src/pages.json
@@ -3,7 +3,7 @@
{
"path": "pages/index/index",
"style": {
- "navigationBarTitleText": "地图导览",
+ "navigationBarTitleText": "智能导览",
"navigationStyle": "custom"
}
},
@@ -25,7 +25,7 @@
{
"path": "pages/explain/list",
"style": {
- "navigationBarTitleText": "免费讲解",
+ "navigationBarTitleText": "全部讲解",
"navigationBarBackgroundColor": "#FFFFFF",
"navigationStyle": "custom"
}
diff --git a/src/pages/exhibit/detail.vue b/src/pages/exhibit/detail.vue
index 9a5ccff..fa6ebfb 100644
--- a/src/pages/exhibit/detail.vue
+++ b/src/pages/exhibit/detail.vue
@@ -6,14 +6,24 @@
@back="handleBack"
>
-
- {{ detailState === 'loading' ? '正在加载讲解对象' : detailState === 'missing' ? '缺少讲解对象参数' : '讲解对象加载失败' }}
- {{ detailState === 'loading' ? '请稍候' : detailStateMessage }}
-
-
-
-
-
+
+
-
+
-
- {{ audioDockTitle }}
- {{ audioDockSubtitle }}
+
+ {{ detailAudioTimeLabel }}
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
- 查看位置
-
-
@@ -181,21 +161,20 @@
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { onHide, onLoad, onUnload } from '@dcloudio/uni-app'
import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue'
+import GuideFeedbackState from '@/components/navigation/GuideFeedbackState.vue'
+import GuideLoadingState from '@/components/navigation/GuideLoadingState.vue'
import type { AudioItem } from '@/components/audio/AudioPlayer.vue'
import { useGlobalAudioPlayer } from '@/composables/useGlobalAudioPlayer'
import {
explainUseCase
} from '@/usecases/explainUseCase'
-import {
- guideUseCase
-} from '@/usecases/guideUseCase'
import {
toExplainDetailPageViewModel,
type ExplainDetailPageViewModel
} from '@/view-models/explainViewModels'
import type {
AudioPlayTargetType,
- MuseumExhibit
+ GuideAudioGender
} from '@/domain/museum'
import type {
AudioLanguage
@@ -207,9 +186,17 @@ import {
import {
EXPLAIN_DETAIL_PLACEHOLDER_IMAGE
} from '@/utils/placeholders'
+import { normalizeSameOriginPublicUrl } from '@/utils/publicUrl'
import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
import { normalizeGuideAudioLanguage } from '@/data/adapters/guideStopInfoAdapter'
import { explainGuideStopListUrl } from '@/utils/explainNavigation'
+import {
+ canToggleGuideAudioGender,
+ GUIDE_AUDIO_LANGUAGE_OPTIONS,
+ resolveGuideAudioGender,
+ resolveGuideAudioLanguages,
+ resolveGuideAudioOption
+} from '@/domain/guideAudioOptions'
const defaultDetail: ExplainDetailPageViewModel = {
id: '',
@@ -228,16 +215,15 @@ const defaultDetail: ExplainDetailPageViewModel = {
}
const exhibit = ref(defaultDetail)
-const detailSource = ref(null)
const detailState = ref<'idle' | 'loading' | 'ready' | 'missing' | 'error'>('idle')
const detailStateMessage = ref('请检查链接后重试。')
const globalAudioPlayer = useGlobalAudioPlayer()
const activeTopTab = ref('explain')
-const resolvedHallId = ref('')
const retryingAudio = ref(false)
const languageSwitchLoading = ref(false)
-const voiceSwitchLoading = ref(false)
const selectedAudioLanguage = ref('zh-CN')
+const selectedAudioGender = ref('male')
+let languageSwitchSequence = 0
const detailEntryRequest = ref<{
exhibitId: string
targetType?: AudioPlayTargetType
@@ -253,55 +239,34 @@ const detailTextByLanguage = ref>({
'yue-HK': '当前语言暂无讲解词。',
'en-US': 'English narration text is not available yet.'
})
-// 讲解详情页位置入口暂未开放,避免误导用户进入位置预览流程。
-const isLocationActionVisible = false
+const detailTextLoadingByLanguage = ref>({
+ 'zh-CN': false,
+ 'yue-HK': false,
+ 'en-US': false
+})
+const detailTextLoadedByLanguage = ref>({
+ 'zh-CN': false,
+ 'yue-HK': false,
+ 'en-US': false
+})
const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram())
-const languageOptionDefinitions: Array<{
- value: AudioLanguage
- label: string
-}> = [
- {
- value: 'zh-CN',
- label: '中文'
- },
- {
- value: 'en-US',
- label: 'English'
- },
- {
- value: 'yue-HK',
- label: '粤语'
- }
-]
-
const isAudioLanguage = (value: unknown): value is AudioLanguage => (
value === 'zh-CN' || value === 'yue-HK' || value === 'en-US'
)
const supportedDetailLanguages = computed(() => (
- (exhibit.value.audio.supportedLanguages || []).filter(isAudioLanguage)
+ resolveGuideAudioLanguages(
+ exhibit.value.audio.audioOptions,
+ exhibit.value.audio.supportedLanguages
+ ).filter(isAudioLanguage)
))
const contentLanguageOptions = computed(() => {
const supportedLanguages = supportedDetailLanguages.value
- return languageOptionDefinitions.filter((option) => supportedLanguages.includes(option.value))
+ return GUIDE_AUDIO_LANGUAGE_OPTIONS.filter((option) => supportedLanguages.includes(option.value))
})
-const voiceOptionsForCurrentLanguage = computed(() => (
- (detailSource.value?.audioOptions || []).filter((option) => (
- option.languageCode === selectedAudioLanguage.value && Boolean(option.audioUrl?.trim())
- ))
-))
-
-const canSelectAudioVoice = computed(() => (
- new Set(voiceOptionsForCurrentLanguage.value.map((option) => option.gender)).size > 1
-))
-
-const currentVoiceLabel = computed(() => (
- detailSource.value?.audioVoiceGender === 'male' ? '男声' : '女声'
-))
-
const resolveSupportedDetailLanguage = (requestedLanguage: AudioLanguage) => {
const supportedLanguages = supportedDetailLanguages.value
if (!supportedLanguages.length || supportedLanguages.includes(requestedLanguage)) {
@@ -321,7 +286,18 @@ const isRealHeroImage = (image?: string) => (
Boolean(image && image !== EXPLAIN_DETAIL_PLACEHOLDER_IMAGE)
)
-const heroImage = computed(() => exhibit.value.coverImages.find(isRealHeroImage))
+const heroImageIndex = ref(0)
+const heroImageCandidates = computed(() => (
+ exhibit.value.coverImages
+ .map((image) => normalizeSameOriginPublicUrl(image))
+ .filter(isRealHeroImage)
+))
+const heroImage = computed(() => heroImageCandidates.value[heroImageIndex.value] || '')
+const handleHeroImageError = () => {
+ if (heroImageIndex.value < heroImageCandidates.value.length - 1) {
+ heroImageIndex.value += 1
+ }
+}
const displayTitle = computed(() => {
const withoutExplainType = exhibit.value.title
.replace(/\s*(?:(?:\[|[|【)\s*)?标准解说\s*(?:(?:\]|]|】))?\s*$/, '')
@@ -336,14 +312,35 @@ const detailMeta = computed(() => [
exhibit.value.hallName,
exhibit.value.floorLabel
].filter(Boolean).join(' · '))
-const hallLocationId = computed(() => exhibit.value.hallId || resolvedHallId.value)
const heroSubtitle = computed(() => [
detailMeta.value
].filter(Boolean).join(' · '))
+const selectedDetailAudioOption = computed(() => resolveGuideAudioOption(
+ exhibit.value.audio.audioOptions,
+ selectedAudioLanguage.value,
+ selectedAudioGender.value
+))
+const currentDetailAudioGender = computed(() => (
+ selectedDetailAudioOption.value?.gender
+ || resolveGuideAudioGender(selectedAudioLanguage.value, selectedAudioGender.value)
+))
+const canSwitchDetailAudioVoice = computed(() => canToggleGuideAudioGender(
+ exhibit.value.audio.audioOptions,
+ selectedAudioLanguage.value
+))
+const detailAudioVoiceLabel = computed(() => {
+ if (selectedAudioLanguage.value === 'yue-HK') return '粤语仅提供女声讲解'
+ return currentDetailAudioGender.value === 'male' ? '切换为女声讲解' : '切换为男声讲解'
+})
+const selectedDetailAudioAvailable = computed(() => (
+ Boolean(selectedDetailAudioOption.value?.playUrl)
+ || exhibit.value.audio.status === 'playable'
+))
const currentAudioTarget = computed(() => ({
targetType: exhibit.value.audio.playTargetType || 'ITEM',
targetId: exhibit.value.audio.playTargetId || exhibit.value.id,
- lang: selectedAudioLanguage.value
+ lang: selectedAudioLanguage.value,
+ channelCode: selectedDetailAudioOption.value?.channelCode
}))
const isCurrentDetailAudio = computed(() => globalAudioPlayer.isCurrentSource(currentAudioTarget.value))
const isCurrentDetailAudioTarget = computed(() => {
@@ -365,7 +362,6 @@ const closeDetailAudioOnExit = () => {
detailAudioClosedOnExit = true
globalAudioPlayer.close()
}
-const audioDockTitle = computed(() => displayTitle.value || '讲解内容')
const formatDetailAudioTime = (seconds?: number) => {
const normalizedSeconds = Number.isFinite(seconds || 0)
? Math.max(0, Math.floor(seconds || 0))
@@ -389,27 +385,6 @@ const detailAudioCurrentSeconds = computed(() => (
const detailAudioTimeLabel = computed(() => (
`${formatDetailAudioTime(detailAudioCurrentSeconds.value)} / ${formatDetailAudioTime(detailAudioDurationSeconds.value)}`
))
-const audioDockSubtitle = computed(() => {
- if (audioDockState.value === 'failed') {
- return globalAudioPlayer.error.value || '音频暂时无法播放'
- }
- if (audioDockState.value === 'unavailable') {
- return audioAvailabilityMessage.value
- }
- if (audioDockState.value === 'loading') {
- return '正在加载音频'
- }
-
- return detailAudioTimeLabel.value
-})
-const detailPlaybackRateLabel = computed(() => (
- globalAudioPlayer.playbackRate.value === 1
- ? '1.0'
- : String(globalAudioPlayer.playbackRate.value)
-))
-const detailAudioMuteActionLabel = computed(() => (
- globalAudioPlayer.muted.value ? '取消静音' : '静音'
-))
const detailAudioPlaying = computed(() => (
isCurrentDetailAudio.value
&& globalAudioPlayer.playing.value
@@ -425,6 +400,20 @@ const detailAudioProgressPercent = computed(() => {
return Math.max(0, Math.min(100, (detailAudioCurrentSeconds.value / total) * 100))
})
+const detailPlaybackRates = [1, 1.25, 1.5, 2]
+const detailPlaybackRate = computed(() => globalAudioPlayer.playbackRate.value || 1)
+const detailPlaybackRateLabel = computed(() => (
+ detailPlaybackRate.value.toFixed(2).replace(/0$/, '')
+))
+
+const handleCyclePlaybackRate = () => {
+ if (audioDockState.value !== 'playable') return
+
+ const currentIndex = detailPlaybackRates.findIndex((rate) => rate === detailPlaybackRate.value)
+ const nextRate = detailPlaybackRates[(currentIndex + 1) % detailPlaybackRates.length] || detailPlaybackRates[0]
+ globalAudioPlayer.setPlaybackRate(nextRate)
+}
+
const isCurrentDetailAudioError = computed(() => {
const source = globalAudioPlayer.currentSource.value
return Boolean(
@@ -435,27 +424,15 @@ const isCurrentDetailAudioError = computed(() => {
})
const isDetailAudioLoading = computed(() => (
languageSwitchLoading.value
- || voiceSwitchLoading.value
|| retryingAudio.value
|| (isCurrentDetailAudioTarget.value && globalAudioPlayer.loading.value)
))
const audioDockState = computed<'playable' | 'loading' | 'unavailable' | 'failed'>(() => {
if (isDetailAudioLoading.value) return 'loading'
if (isCurrentDetailAudioError.value) return 'failed'
- if (exhibit.value.audio.status !== 'playable') return 'unavailable'
+ if (!selectedDetailAudioAvailable.value) return 'unavailable'
return 'playable'
})
-// 讲解详情页只定位到所属展厅,不再追踪具体展品点位。
-const resolveHallGuidePoi = async () => {
- return guideUseCase.resolveContentLocationPreviewTarget({
- directPoiId: exhibit.value.location?.poiId,
- location: exhibit.value.location,
- hallId: exhibit.value.hallId,
- hallName: exhibit.value.hallName,
- targetName: exhibit.value.title
- })
-}
-
const fallbackTextForLanguage = (lang: AudioLanguage) => (
lang === 'en-US'
? 'English narration text is not available yet.'
@@ -468,22 +445,64 @@ const applyDetailText = (lang: AudioLanguage, text?: string) => {
...detailTextByLanguage.value,
[lang]: normalized || fallbackTextForLanguage(lang)
}
+ detailTextLoadedByLanguage.value = {
+ ...detailTextLoadedByLanguage.value,
+ [lang]: true
+ }
}
-const hydrateDetailTexts = (source: MuseumExhibit) => {
- const nextTexts: Record = {
- 'zh-CN': fallbackTextForLanguage('zh-CN'),
- 'yue-HK': fallbackTextForLanguage('yue-HK'),
- 'en-US': fallbackTextForLanguage('en-US')
+const buildTextSourceFromViewModel = (viewModel: ExplainDetailPageViewModel) => ({
+ id: viewModel.id,
+ name: viewModel.title,
+ hallId: viewModel.hallId,
+ hallName: viewModel.hallName,
+ floorId: viewModel.floorId,
+ floorLabel: viewModel.floorLabel,
+ image: viewModel.coverImages[0],
+ description: viewModel.summary,
+ guideText: viewModel.summary,
+ audioLanguage: viewModel.audio.language,
+ audioHasText: viewModel.audio.hasText,
+ audioOptions: viewModel.audio.audioOptions,
+ playTargetType: viewModel.audio.playTargetType,
+ playTargetId: viewModel.audio.playTargetId
+})
+
+const loadFullDetailTextForLanguage = async (
+ request: NonNullable,
+ lang: AudioLanguage,
+ preferredViewModel?: ExplainDetailPageViewModel
+) => {
+ detailTextLoadingByLanguage.value = {
+ ...detailTextLoadingByLanguage.value,
+ [lang]: true
}
- ;(['zh-CN', 'yue-HK', 'en-US'] as AudioLanguage[]).forEach((lang) => {
- const variant = source.audioVariants?.[lang]
- const text = variant?.text || (source.audioLanguage === lang ? source.guideText : undefined)
- nextTexts[lang] = text?.trim() || fallbackTextForLanguage(lang)
- })
+ try {
+ const viewModel = preferredViewModel || toExplainDetailPageViewModel(await explainUseCase.enterExplainDetail({
+ ...request,
+ lang
+ }))
- detailTextByLanguage.value = nextTexts
+ if (viewModel.audio.hasText) {
+ const selection = await explainUseCase.loadExplainDetailText(buildTextSourceFromViewModel(viewModel))
+ if (selection.available) {
+ const nextViewModel = toExplainDetailPageViewModel(selection.exhibit)
+ applyDetailText(lang, nextViewModel.body || nextViewModel.summary)
+ return
+ }
+ }
+
+ applyDetailText(lang, viewModel.body || viewModel.summary)
+ } catch (error) {
+ console.warn('讲解正文加载失败:', lang, error)
+ applyDetailText(lang)
+ } finally {
+ detailTextLoadingByLanguage.value = {
+ ...detailTextLoadingByLanguage.value,
+ [lang]: false
+ }
+ }
}
const loadExplainDetail = async (
@@ -496,29 +515,24 @@ const loadExplainDetail = async (
lang
})
- detailSource.value = exhibitData
- hydrateDetailTexts(exhibitData)
+ heroImageIndex.value = 0
exhibit.value = toExplainDetailPageViewModel(exhibitData)
const resolvedLanguage = resolveSupportedDetailLanguage(lang)
selectedAudioLanguage.value = resolvedLanguage
- if (exhibitData.hallId) {
- resolvedHallId.value = exhibitData.hallId
- }
-
if (resolvedLanguage !== lang) {
- const localizedExhibit = explainUseCase.selectExplainDetailLanguage(exhibitData, resolvedLanguage)
- detailSource.value = localizedExhibit
- exhibit.value = toExplainDetailPageViewModel(localizedExhibit)
- selectedAudioLanguage.value = resolvedLanguage
replaceDetailRouteLanguage(resolvedLanguage)
+ await loadExplainDetail(request, resolvedLanguage)
+ return
}
+ void loadFullDetailTextForLanguage(request, resolvedLanguage, exhibit.value)
detailState.value = 'ready'
}
onLoad(async (options: any = {}) => {
detailAudioClosedOnExit = false
+ selectedAudioGender.value = 'male'
const tab = Array.isArray(options.tab) ? options.tab[0] : options.tab
if (isGuideTopTab(tab)) {
activeTopTab.value = tab
@@ -632,18 +646,14 @@ const splitDetailTextIntoParagraphs = (text: string): string[] => {
const currentDetailText = computed(() => detailTextFor(selectedAudioLanguage.value))
const currentDetailParagraphs = computed(() => splitDetailTextIntoParagraphs(currentDetailText.value))
-const currentDetailTextLoading = computed(() => languageSwitchLoading.value)
+const currentDetailTextLoading = computed(() => (
+ languageSwitchLoading.value || detailTextLoadingByLanguage.value[selectedAudioLanguage.value]
+))
const detailLoadingMessage = computed(() => (
languageSwitchLoading.value
? `正在切换到${audioLanguageLabel(selectedAudioLanguage.value)}`
: '正在加载讲解正文'
))
-const audioAvailabilityMessage = computed(() => (
- exhibit.value.audio.status === 'playable'
- ? ''
- : exhibit.value.audio.unavailableReason || '当前语言暂无语音讲解'
-))
-
const replaceDetailRouteLanguage = (lang: AudioLanguage) => {
if (typeof window === 'undefined') return
@@ -660,86 +670,72 @@ const handleLanguageChange = async (lang: AudioLanguage, options: { syncAudio?:
|| (supportedDetailLanguages.value.length > 0 && !supportedDetailLanguages.value.includes(lang))
) return
- const source = detailSource.value
- if (!source) return
-
- const shouldSyncAudio = options.syncAudio !== false && isCurrentDetailAudioTarget.value
- const localizedExhibit = explainUseCase.selectExplainDetailLanguage(source, lang)
- const localizedViewModel = toExplainDetailPageViewModel(localizedExhibit)
+ const shouldContinueAudio = options.syncAudio !== false
+ && isCurrentDetailAudioTarget.value
+ && globalAudioPlayer.playing.value
+ const request = detailEntryRequest.value
+ const requestSequence = ++languageSwitchSequence
selectedAudioLanguage.value = lang
- detailSource.value = localizedExhibit
- exhibit.value = localizedViewModel
- applyDetailText(lang, localizedExhibit.audioText || localizedExhibit.guideText || localizedViewModel.body)
replaceDetailRouteLanguage(lang)
-
- if (localizedExhibit.hallId) {
- resolvedHallId.value = localizedExhibit.hallId
- }
-
- if (!shouldSyncAudio || globalAudioPlayer.currentSource.value?.lang === lang) return
-
languageSwitchLoading.value = true
- try {
- const nextAudio = localizedViewModel.audio.url
- ? {
- id: `stop-${localizedViewModel.audio.playTargetType || 'ITEM'}-${localizedViewModel.audio.playTargetId || localizedViewModel.id}-${lang}`,
- name: localizedViewModel.title,
- audioUrl: localizedViewModel.audio.url,
- image: heroImage.value,
- duration: localizedViewModel.audio.duration,
- language: lang,
- supportedLanguages: supportedDetailLanguages.value
- }
- : null
- await globalAudioPlayer.switchLanguage(lang, nextAudio)
- } finally {
- languageSwitchLoading.value = false
- }
-}
-
-const selectVoiceOption = async (channelCode: string) => {
- if (voiceSwitchLoading.value) return
-
- const source = detailSource.value
- if (!source) return
-
- const localizedExhibit = explainUseCase.selectExplainDetailAudioOption(source, channelCode)
- if (localizedExhibit === source) return
-
- const shouldRestartAudio = isCurrentDetailAudio.value && globalAudioPlayer.playing.value
- const shouldCloseAudio = isCurrentDetailAudioTarget.value
- voiceSwitchLoading.value = true
try {
- if (shouldCloseAudio) {
- globalAudioPlayer.close()
+ if (!request) {
+ applyDetailText(lang)
+ return
}
- detailSource.value = localizedExhibit
- exhibit.value = toExplainDetailPageViewModel(localizedExhibit)
+ const exhibitData = await explainUseCase.enterExplainDetail({
+ ...request,
+ lang
+ })
+ if (requestSequence !== languageSwitchSequence) return
- if (shouldRestartAudio) {
- voiceSwitchLoading.value = false
+ heroImageIndex.value = 0
+ const nextViewModel = toExplainDetailPageViewModel(exhibitData)
+ exhibit.value = nextViewModel
+ void loadFullDetailTextForLanguage(request, lang, nextViewModel)
+ if (shouldContinueAudio) {
+ // The player must become actionable before resuming with the new language track.
+ languageSwitchLoading.value = false
await handlePlayAudio()
}
+ } catch (error) {
+ if (requestSequence !== languageSwitchSequence) return
+
+ console.warn('讲解语言切换失败:', lang, error)
+ exhibit.value = {
+ ...exhibit.value,
+ audio: {
+ ...exhibit.value.audio,
+ status: 'unavailable',
+ url: undefined,
+ duration: undefined,
+ language: lang,
+ unavailableReason: '讲解服务暂不可用,请稍后重试'
+ }
+ }
+ applyDetailText(lang)
+ uni.showToast({
+ title: '讲解语言切换失败,请稍后重试',
+ icon: 'none'
+ })
} finally {
- voiceSwitchLoading.value = false
+ if (requestSequence === languageSwitchSequence) {
+ languageSwitchLoading.value = false
+ }
}
}
-const handleVoiceSelection = () => {
- const voiceOptions = voiceOptionsForCurrentLanguage.value
- if (!canSelectAudioVoice.value || voiceSwitchLoading.value) return
+const handleToggleDetailAudioVoice = async () => {
+ if (audioDockState.value !== 'playable' || !canSwitchDetailAudioVoice.value) return
- uni.showActionSheet({
- itemList: voiceOptions.map((option) => option.displayName),
- success: ({ tapIndex }) => {
- const selectedOption = voiceOptions[tapIndex]
- if (selectedOption) {
- void selectVoiceOption(selectedOption.channelCode)
- }
- }
- })
+ const shouldContinueAudio = isCurrentDetailAudioTarget.value && globalAudioPlayer.playing.value
+ selectedAudioGender.value = currentDetailAudioGender.value === 'male' ? 'female' : 'male'
+
+ if (shouldContinueAudio) {
+ await handlePlayAudio()
+ }
}
watch(
@@ -780,15 +776,17 @@ function buildDetailRoute(lang: AudioLanguage = selectedAudioLanguage.value) {
const toAudioItem = (
selection: NonNullable>>
): AudioItem | null => {
- if (!selection.playable || !selection.media?.url) return null
+ const media = selection.media
+ const audioUrl = normalizeSameOriginPublicUrl(media?.url)
+ if (!selection.playable || !media || !audioUrl) return null
return {
- id: selection.media.id,
+ id: media.id,
name: selection.playInfo?.title || selection.exhibit.name,
- audioUrl: selection.media.url,
+ audioUrl,
image: heroImage.value,
- duration: selection.media.duration,
- language: selection.media.language as AudioLanguage,
+ duration: media.duration,
+ language: media.language as AudioLanguage,
supportedLanguages: (selection.exhibit.supportedLanguages || exhibit.value.audio.supportedLanguages || [])
.filter(isAudioLanguage)
}
@@ -808,6 +806,7 @@ const refreshCurrentAudioOnce = async (message: string) => {
audioDuration: exhibit.value.audio.duration,
audioStatus: 'READY',
audioLanguage: selectedAudioLanguage.value,
+ audioOptions: exhibit.value.audio.audioOptions,
playTargetType: exhibit.value.audio.playTargetType,
playTargetId: exhibit.value.audio.playTargetId
}, {
@@ -837,6 +836,8 @@ const playDetailAudio = async (audio: AudioItem) => {
targetType: currentAudioTarget.value.targetType,
targetId: currentAudioTarget.value.targetId,
lang: selectedAudioLanguage.value,
+ channelCode: selectedDetailAudioOption.value?.channelCode,
+ voiceGender: currentDetailAudioGender.value,
title: audio.name,
detailRoute: buildDetailRoute()
},
@@ -848,6 +849,10 @@ const playDetailAudio = async (audio: AudioItem) => {
const handlePlayAudio = async (options: { forceRefresh?: boolean } = {}) => {
if (audioDockState.value === 'unavailable' || audioDockState.value === 'loading') return
+ if (audioDockState.value === 'failed' && !options.forceRefresh) {
+ options = { ...options, forceRefresh: true }
+ }
+
if (isCurrentDetailAudio.value && !options.forceRefresh) {
if (globalAudioPlayer.playing.value) {
globalAudioPlayer.pause()
@@ -878,8 +883,11 @@ const handlePlayAudio = async (options: { forceRefresh?: boolean } = {}) => {
audioStatus: exhibit.value.audio.status === 'playable' ? 'READY' : 'MISSING',
audioLanguage: selectedAudioLanguage.value,
audioUnavailableReason: exhibit.value.audio.unavailableReason,
+ audioOptions: exhibit.value.audio.audioOptions,
playTargetType: exhibit.value.audio.playTargetType,
playTargetId: exhibit.value.audio.playTargetId
+ }, {
+ voiceGender: currentDetailAudioGender.value
})
: null
@@ -897,18 +905,6 @@ const handlePlayAudio = async (options: { forceRefresh?: boolean } = {}) => {
await playDetailAudio(audio)
}
-const handleRetryAudio = async () => {
- await handlePlayAudio({ forceRefresh: true })
-}
-
-const handleCyclePlaybackRate = () => {
- globalAudioPlayer.cyclePlaybackRate()
-}
-
-const handleToggleMuted = () => {
- globalAudioPlayer.toggleMuted()
-}
-
const handleSeekAudio = (event: { detail?: { x?: number }; currentTarget?: { offsetWidth?: number } }) => {
if (audioDockState.value !== 'playable' || !detailAudioDurationSeconds.value) return
@@ -918,42 +914,6 @@ const handleSeekAudio = (event: { detail?: { x?: number }; currentTarget?: { off
globalAudioPlayer.seekToPercent(Math.max(0, Math.min(100, x / width * 100)))
}
-const handleNavigate = async () => {
- const matchedHallPoi = await resolveHallGuidePoi()
- if (!matchedHallPoi) {
- uni.showToast({
- title: '该讲解暂无所属展厅位置数据',
- icon: 'none'
- })
- return
- }
-
- resolvedHallId.value = matchedHallPoi.id
- exhibit.value = {
- ...exhibit.value,
- hallId: matchedHallPoi.id,
- hallName: matchedHallPoi.hallName || matchedHallPoi.name,
- location: exhibit.value.location || {
- status: 'hallFallback',
- poiId: matchedHallPoi.id,
- sourcePoiId: matchedHallPoi.id,
- actionText: '查看所属展厅',
- previewOnly: true,
- floorId: matchedHallPoi.floorId,
- floorLabel: matchedHallPoi.floorLabel,
- note: '已定位到该讲解所属展厅。'
- }
- }
-
- uni.navigateTo({
- url: `/pages/route/detail?facilityId=${encodeURIComponent(matchedHallPoi.id)}&target=${encodeURIComponent(exhibit.value.hallName || exhibit.value.title)}&state=preview`
- })
-}
-
-type ExplainDetailPageStackEntry = {
- route?: string
-}
-
const decodeRouteParam = (value: string) => {
let decoded = value
// Hash route parameters can accumulate extra encoding layers across H5 transitions.
@@ -987,14 +947,10 @@ const fallbackToExplainObjectList = () => {
const returnToExplainObjectList = () => {
closeDetailAudioOnExit()
- const pages = getCurrentPages()
- const previousPage = pages[pages.length - 2] as ExplainDetailPageStackEntry | undefined
- if (previousPage?.route === 'pages/explain/guide-stop-list') {
- uni.navigateBack({ delta: 1, fail: fallbackToExplainObjectList })
- return
- }
-
- fallbackToExplainObjectList()
+ uni.navigateBack({
+ delta: 1,
+ fail: fallbackToExplainObjectList
+ })
}
const handleBack = returnToExplainObjectList
@@ -1011,33 +967,33 @@ const handleBack = returnToExplainObjectList
.content {
position: absolute;
- top: 360px;
+ top: 392px;
right: 0;
bottom: 0;
left: 0;
height: auto;
padding: 0;
box-sizing: border-box;
- background: #fafbf8;
+ background: #f7f8f3;
}
.detail-language-bar {
position: absolute;
- top: 290px;
+ top: 320px;
right: 0;
left: 0;
z-index: 2;
- height: 70px;
- padding: 22px 24px 12px;
+ height: 72px;
+ padding: 25px 24px 7px;
box-sizing: border-box;
- background: #fafbf8;
+ background: #f7f8f3;
}
.immersive-hero {
position: relative;
min-height: 0;
- height: 290px;
- padding: calc(env(safe-area-inset-top) + 0px) 24px 16px;
+ height: 320px;
+ padding: calc(env(safe-area-inset-top) + 0px) 24px 24px;
box-sizing: border-box;
overflow: hidden;
background: #111a14;
@@ -1079,6 +1035,7 @@ const handleBack = returnToExplainObjectList
border-radius: 50%;
background: transparent;
color: #ffffff;
+ transform: translate(-12px, 10px);
}
.hero-back::after {
@@ -1086,10 +1043,23 @@ const handleBack = returnToExplainObjectList
}
.hero-back-icon {
- margin-top: -3px;
- font-size: 32px;
- line-height: 32px;
- font-weight: 400;
+ position: relative;
+ width: 20px;
+ height: 20px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0;
+ line-height: 0;
+}
+
+.hero-back-icon::before {
+ width: 10px;
+ height: 10px;
+ content: '';
+ border-left: 1.5px solid currentColor;
+ border-bottom: 1.5px solid currentColor;
+ transform: translateX(3px) rotate(45deg);
}
.hero-copy {
@@ -1118,8 +1088,8 @@ const handleBack = returnToExplainObjectList
.detail-meta {
display: block;
margin-top: 6px;
- font-size: 16px;
- line-height: 22px;
+ font-size: 14px;
+ line-height: 20px;
font-weight: 600;
color: rgba(255, 255, 255, 0.86);
overflow: hidden;
@@ -1130,10 +1100,10 @@ const handleBack = returnToExplainObjectList
.detail-chip-row { display: none; }
.detail-content {
- min-height: calc(100vh - 360px - 66px - env(safe-area-inset-bottom));
- padding: 12px 24px calc(32px + 66px + env(safe-area-inset-bottom));
+ min-height: calc(100vh - 392px - 72px - env(safe-area-inset-bottom));
+ padding: 24px 24px calc(32px + 72px + env(safe-area-inset-bottom));
box-sizing: border-box;
- background: #fafbf8;
+ background: #f7f8f3;
}
.detail-audio-play {
@@ -1149,7 +1119,7 @@ const handleBack = returnToExplainObjectList
border: 0;
border-radius: 50%;
background: #e8e800;
- color: #141412;
+ color: #1565c0;
}
.detail-audio-play svg {
@@ -1167,21 +1137,21 @@ const handleBack = returnToExplainObjectList
}
.language-switch {
- height: 36px;
+ height: 40px;
margin-top: 0;
padding: 0;
display: flex;
box-sizing: border-box;
background: transparent;
- border: 1px solid #d9ddd6;
- border-radius: 7px;
+ border: 1px solid #d8ddd2;
+ border-radius: 6px;
}
.language-option {
position: relative;
min-width: 0;
flex: 1;
- height: 36px;
+ height: 40px;
padding: 0 6px;
display: flex;
align-items: center;
@@ -1190,16 +1160,16 @@ const handleBack = returnToExplainObjectList
border: 0;
border-radius: 0;
background: transparent;
- color: #53615b;
+ color: #5d6659;
}
.language-option + .language-option {
- border-left: 1px solid #d9ddd6;
+ border-left: 1px solid #d8ddd2;
}
.language-option.active {
- background: #e7ed42;
- color: #315eb9;
+ background: #e8e800;
+ color: #1565c0;
}
.language-option.active::before {
@@ -1209,7 +1179,7 @@ const handleBack = returnToExplainObjectList
left: 10px;
height: 3px;
content: '';
- background: #3a65b6;
+ background: #1565c0;
}
.language-option.disabled {
@@ -1219,7 +1189,7 @@ const handleBack = returnToExplainObjectList
.language-option-text {
display: block;
max-width: 100%;
- font-size: 16px;
+ font-size: 14px;
line-height: 20px;
font-weight: 700;
overflow: hidden;
@@ -1235,18 +1205,14 @@ const handleBack = returnToExplainObjectList
.section-text {
display: block;
margin-top: 0;
- font-size: 17px;
- line-height: 28px;
- color: #242b26;
+ font-size: 16px;
+ line-height: 26px;
+ color: #31392e;
text-indent: 2em;
word-break: break-word;
overflow-wrap: anywhere;
}
-.section-text + .section-text {
- margin-top: 8px;
-}
-
.section-hint {
margin-top: 14px;
font-size: 14px;
@@ -1263,45 +1229,44 @@ const handleBack = returnToExplainObjectList
bottom: 0;
left: 0;
z-index: 10;
- height: calc(66px + env(safe-area-inset-bottom));
+ height: calc(72px + env(safe-area-inset-bottom));
padding: 0 16px env(safe-area-inset-bottom);
box-sizing: border-box;
background: #ffffff;
border-top: 1px solid #e8e5de;
}
-.detail-audio-dock-main {
- height: 66px;
- padding-left: 52px;
+.detail-audio-row {
+ height: 72px;
display: flex;
align-items: center;
- gap: 0;
+ gap: 8px;
}
-.detail-audio-main {
+.detail-audio-time-block {
min-width: 0;
- flex: 0 0 96px;
- z-index: 1;
+ flex: 0 0 66px;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ justify-content: center;
+ gap: 2px;
+ overflow: hidden;
}
-.detail-audio-title,
-.detail-audio-subtitle {
+.detail-audio-time {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
-.detail-audio-title {
- display: none;
-}
-
-.detail-audio-subtitle {
- margin-top: 0;
- font-size: 13px;
- line-height: 20px;
+.detail-audio-time {
+ flex: 0 0 auto;
+ font-size: 12px;
+ line-height: 18px;
font-weight: 600;
- color: #192d83;
+ color: #1a237e;
}
.detail-audio-play.disabled {
@@ -1309,125 +1274,11 @@ const handleBack = returnToExplainObjectList
color: #94948f;
}
-.detail-audio-play {
- position: absolute;
- top: 13px;
- left: 16px;
- z-index: 1;
- width: 40px;
- height: 40px;
- min-width: 40px;
- flex-basis: 40px;
-}
-
-.detail-audio-retry {
- position: absolute;
- top: 17px;
- right: 16px;
- z-index: 2;
- height: 32px;
- padding: 0 10px;
- flex-shrink: 0;
- border: 0;
- border-radius: 4px;
- background: #e8e800;
- color: #141412;
- font-size: 13px;
- line-height: 18px;
- font-weight: 600;
-}
-
-.detail-audio-retry::after {
- border: 0;
-}
-
-.detail-audio-voice,
-.detail-audio-rate,
-.detail-audio-mute {
- position: absolute;
- top: 13px;
- z-index: 2;
- height: 40px;
- padding: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- border: 0;
- background: transparent;
- color: #192d83;
-}
-
-.detail-audio-voice {
- right: 94px;
- width: 46px;
- color: #31524a;
-}
-
-.detail-audio-voice text {
- width: 42px;
- height: 30px;
- display: flex;
- align-items: center;
- justify-content: center;
- box-sizing: border-box;
- border: 1px solid #d9ddd6;
- border-radius: 4px;
- background: #ffffff;
- font-size: 12px;
- line-height: 16px;
- font-weight: 700;
-}
-
-.detail-audio-voice.disabled {
- color: #8b918c;
-}
-
-.detail-audio-voice.disabled text {
- border-color: #e6e7e4;
- background: #f8f8f7;
-}
-
-.detail-audio-rate {
- right: 50px;
- width: 40px;
-}
-
-.detail-audio-rate text {
- width: 34px;
- height: 34px;
- display: flex;
- align-items: center;
- justify-content: center;
- box-sizing: border-box;
- border: 1px solid currentColor;
- border-radius: 50%;
- font-size: 11px;
- line-height: 14px;
- font-weight: 700;
-}
-
-.detail-audio-mute {
- right: 12px;
- width: 32px;
-}
-
-.detail-audio-mute.muted {
- color: #7a807a;
-}
-
-.detail-audio-voice::after,
-.detail-audio-rate::after,
-.detail-audio-mute::after {
- border: 0;
-}
-
.detail-audio-progress-hit {
- position: absolute;
- top: 13px;
- right: 16px;
- left: 146px;
- height: 40px;
- margin: 0;
+ position: relative;
+ flex: 1 1 auto;
+ min-width: 28px;
+ height: 28px;
display: flex;
align-items: center;
}
@@ -1439,66 +1290,105 @@ const handleBack = returnToExplainObjectList
.detail-audio-progress {
position: relative;
width: 100%;
- height: 3px;
+ height: 4px;
overflow: visible;
- background: #a4a6a3;
- border-radius: 999px;
+ background: #94948f;
+ border-radius: 2px;
}
.detail-audio-progress-fill {
width: 0;
height: 100%;
- background: #3a65b6;
+ background: #e0e100;
border-radius: inherit;
}
.detail-audio-progress-thumb {
position: absolute;
top: 50%;
- width: 9px;
- height: 9px;
+ width: 10px;
+ height: 10px;
border-radius: 50%;
- background: #3a65b6;
+ background: #1565c0;
transform: translate(-50%, -50%);
}
-.detail-audio-progress.is-indeterminate {
- position: absolute;
- top: 31px;
- right: 16px;
- left: 126px;
- width: auto;
- height: 3px;
- margin: 0;
- overflow: hidden;
- background: #deded6;
-}
-
-.detail-audio-dock.is-playable .detail-audio-progress-hit {
- right: 140px;
-}
-
-.detail-audio-progress.is-indeterminate::after {
- display: block;
- width: 38%;
- height: 100%;
- content: '';
- background: #3a65b6;
- animation: audio-indeterminate 1.1s ease-in-out infinite;
-}
-
-.audio-loading-spinner {
- width: 18px;
- height: 18px;
+.detail-audio-speed,
+.detail-audio-voice {
+ flex: 0 0 auto;
+ height: 36px;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
box-sizing: border-box;
- border: 2px solid rgba(20, 20, 18, 0.28);
- border-top-color: #141412;
- border-radius: 50%;
- animation: audio-spinner 0.8s linear infinite;
+ border: 0;
+ background: transparent;
+ color: #1a237e;
}
-@keyframes audio-spinner { to { transform: rotate(360deg); } }
-@keyframes audio-indeterminate { from { transform: translateX(-110%); } to { transform: translateX(290%); } }
+.detail-audio-speed {
+ width: 42px;
+}
+
+.detail-speed-icon {
+ position: relative;
+ width: 26px;
+ height: 26px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
+ border: 1.5px solid currentColor;
+ border-radius: 50%;
+}
+
+.detail-speed-icon::after {
+ position: absolute;
+ top: -1px;
+ right: 1px;
+ width: 7px;
+ height: 7px;
+ content: '';
+ border-top: 1.5px solid currentColor;
+ border-right: 1.5px solid currentColor;
+ border-radius: 0 5px 0 0;
+ transform: rotate(18deg);
+}
+
+.detail-speed-label {
+ font-size: 7px;
+ line-height: 9px;
+ font-weight: 700;
+ color: currentColor;
+}
+
+.detail-audio-voice {
+ width: 36px;
+}
+
+.detail-audio-voice-avatar {
+ width: 28px;
+ height: 28px;
+ display: block;
+ border-radius: 50%;
+ box-shadow: 0 0 0 1px rgba(26, 35, 126, 0.12);
+ overflow: hidden;
+}
+
+.detail-audio-speed.disabled,
+.detail-audio-voice.disabled {
+ color: #94948f;
+}
+
+.detail-audio-voice.disabled .detail-audio-voice-avatar {
+ opacity: 0.52;
+}
+
+.detail-audio-speed::after,
+.detail-audio-voice::after {
+ border: 0;
+}
@media (min-width: 420px) {
.immersive-hero {
diff --git a/src/pages/explain/business-unit-list.vue b/src/pages/explain/business-unit-list.vue
new file mode 100644
index 0000000..3facfa4
--- /dev/null
+++ b/src/pages/explain/business-unit-list.vue
@@ -0,0 +1,137 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/explain/guide-stop-list.vue b/src/pages/explain/guide-stop-list.vue
index 2abde0c..089602a 100644
--- a/src/pages/explain/guide-stop-list.vue
+++ b/src/pages/explain/guide-stop-list.vue
@@ -65,21 +65,6 @@ const syncPageTitle = (title: string) => {
if (typeof document !== 'undefined') document.title = title
}
-const decodeRouteParam = (value: string) => {
- let decoded = value
- // H5 hash routes embedded in web-view can add multiple URL-encoding layers.
- for (let index = 0; index < 8; index += 1) {
- try {
- const next = decodeURIComponent(decoded)
- if (next === decoded) break
- decoded = next
- } catch {
- break
- }
- }
- return decoded
-}
-
const loadPage = async (pageNo: number, replace: boolean) => {
const hallId = selectedExplainHallId.value
if (!hallId || disposed || (!replace && (loadingMore.value || !hasMore.value))) return false
@@ -130,8 +115,8 @@ const loadNextPage = () => void loadPage(nextPageNo.value, false)
onLoad((options: Record = {}) => {
const first = (value: string | string[] | undefined) => Array.isArray(value) ? value[0] : value
selectedExplainHallId.value = first(options.hallId) || ''
- selectedExplainHallName.value = decodeRouteParam(first(options.hallName) || '讲解对象')
- syncPageTitle(selectedExplainHallName.value)
+ selectedExplainHallName.value = first(options.hallName) || '讲解对象'
+ syncPageTitle('讲解对象')
if (!selectedExplainHallId.value) {
explainError.value = '缺少展厅参数,请返回展厅列表后重试'
return
diff --git a/src/pages/explain/list.vue b/src/pages/explain/list.vue
index 0b154ec..6adead1 100644
--- a/src/pages/explain/list.vue
+++ b/src/pages/explain/list.vue
@@ -10,7 +10,7 @@
([])
const explainLoading = ref(false)
@@ -73,6 +71,8 @@ const buildExplainHallItems = (
const shouldUseHostNavigation = computed(() => isEmbeddedInWechatMiniProgram())
+const GUIDE_HOME_URL = '/pages/index/index?tab=guide'
+
type ExplainListHistoryState = {
museumGuidePage?: 'explain-list'
[key: string]: unknown
@@ -93,7 +93,9 @@ const getPageStack = () => (
typeof getCurrentPages === 'function' ? getCurrentPages() as PageStackEntry[] : []
)
-const guideHomeUrl = (tab: GuideTopTab = 'guide') => guideTopTabUrl(tab)
+const guideHomeUrl = () => (
+ shouldUseHostNavigation.value ? guideTopTabUrl('guide') : GUIDE_HOME_URL
+)
const isExplainListHistoryState = (state: unknown): state is ExplainListHistoryState => (
Boolean(
@@ -118,25 +120,6 @@ const reLaunchGuideHome = () => {
})
}
-const reLaunchPreviousGuideHome = (tab: GuideTopTab) => {
- uni.reLaunch({
- url: guideHomeUrl(tab),
- fail: showGuideHomeReturnError
- })
-}
-
-const returnToHostMiniProgram = async () => {
- isReturningToGuideHome = true
- const result = await returnToWechatMiniProgram()
- if (result.status === 'returned') return
-
- isReturningToGuideHome = false
- uni.showToast({
- title: '返回小程序失败,请重试',
- icon: 'none'
- })
-}
-
const consumeExplainListHistory = () => {
if (
typeof window === 'undefined'
@@ -161,27 +144,25 @@ const returnToGuideHome = () => {
const previousTab = Array.isArray(previousPage?.options?.tab)
? previousPage.options.tab[0]
: previousPage?.options?.tab
- const canNavigateBackToGuideHome = previousRoute === 'pages/index/index'
- const previousGuideHomeTab = isGuideTopTab(previousTab) ? previousTab : 'guide'
+ const canNavigateBackToGuideHome = (
+ !shouldUseHostNavigation.value
+ && previousRoute === 'pages/index/index'
+ && (!previousTab || previousTab === 'guide')
+ )
if (canNavigateBackToGuideHome) {
uni.navigateBack({
delta: 1,
- fail: () => reLaunchPreviousGuideHome(previousGuideHomeTab)
+ fail: reLaunchGuideHome
})
return
}
- if (shouldUseHostNavigation.value) {
- void returnToHostMiniProgram()
- return
- }
-
reLaunchGuideHome()
}
const pushExplainListHistory = () => {
- if (typeof window === 'undefined' || shouldUseHostNavigation.value || getPageStack().length > 1) return
+ if (typeof window === 'undefined' || getPageStack().length > 1) return
if (explainListHistoryPushed) return
if (isExplainListHistoryState(window.history.state)) {
diff --git a/src/pages/facility/detail.vue b/src/pages/facility/detail.vue
index c8c6a04..7de8d8a 100644
--- a/src/pages/facility/detail.vue
+++ b/src/pages/facility/detail.vue
@@ -35,9 +35,7 @@
class="detail-sheet"
data-testid="facility-detail-sheet"
:title="facility.name"
- :subtitle="`${facility.floor} · ${facility.category}`"
- :meta-items="facilitySummaryMetaItems"
- :description="facility.description"
+ :subtitle="facility.floor"
:error-message="locationErrorMessage"
close-test-id="facility-detail-close"
@close="handleCloseDetailPanel"
@@ -50,7 +48,7 @@