一、要解决的问题
虚拟漫游(街景、景区导览、看房)里常见一个需求:用户既能在 360° 全景里环顾四周,又能在小地图上知道"我现在朝哪、标记在哪个方位"。两个视图还要双向联动------点地图上的热点,全景自动转过去;点全景里的标记,地图高亮对应位置。
本文以一个纯前端实现为例,拆解其中的数学原理、第三方库集成坑点和状态管理设计。技术栈:
| 依赖 | 用途 |
|---|---|
| Vue 3.5 + TypeScript | UI 与类型系统 |
| Vite 8 | 构建 |
| Photo Sphere Viewer 5(PSV) | WebGL 全景渲染 |
| PSV MarkersPlugin | 全景标记 |
| PSV PlanPlugin + Leaflet | 嵌入式小地图 |
整体架构很薄:App.vue 负责编排,PsvContainer.vue 封装 PSV 生命周期,三个 composable 分管全局状态、添加标记流程、Toast 反馈。
二、核心原理:全景角度 ↔ 地理方位角的换算
这是整个联动功能的基石,也是最容易做错的部分。
2.1 三个坐标系
- 全景坐标 :PSV 用
yaw(水平角,弧度,正前方为 0)和pitch(俯仰角)描述球面上的方向; - 地理方位角:顺时针自正北的角度(0° = 北,90° = 东);
- 经纬度:地图上的位置。
拍摄照片时相机朝向的地理方位角称为 bearing。三者的桥梁公式:
真实方位角 = 场景 bearing + yaw
ts
export function geoBearingOf(scene: Scene, yaw: number): number {
return (parseBearing(scene.bearing) + (yaw * 180) / Math.PI + 360) % 360
}
2.2 最大的坑:经度和纬度不能按同样的"米/度"换算
把"方位角 + 距离"换算成经纬度偏移时,直觉写法是:
ts
// 错误:经纬度用同一个米/度系数
lng += dist * sin(bearing) / M_PER_DEG
lat += dist * cos(bearing) / M_PER_DEG
但地球是球:每度经度对应的地面距离随纬度收缩 (乘 cos(纬度)),而每度纬度基本恒定。在纬度 45° 处,两者相差约 √2 倍。如果直接按度数分解方位角,标记落在地图上的实际方向会整体偏转,中纬度地区偏差可达 10° 左右------全景里指着灯塔,地图上却指到海里。
正确做法是先在"米"空间按方位角分解,再各自换算回度数:
ts
const M_PER_DEG_LAT = 110574
function mPerDegLng(lat: number): number {
return 111320 * Math.cos((lat * Math.PI) / 180)
}
export function destination(from, bearingDeg, distM) {
const rad = (bearingDeg * Math.PI) / 180
const [lng, lat] = from
return [
lng + (distM * Math.sin(rad)) / mPerDegLng(lat),
lat + (distM * Math.cos(rad)) / M_PER_DEG_LAT,
]
}
短距离(几公里内)用平面近似足够精确,无需 Haversine。
2.3 正向:点全景 → 估算经纬度
用户点击全景空白处,拿到 (yaw, pitch)。方向用上面的桥梁公式;距离没有真实深度信息,用一个经验模型:仰角越大(仰望远处地平线)越远,俯角越大(看脚下)越近,随 pitch 线性变化并钳制范围:
ts
export function estimateGps(scene, yaw, pitch) {
const bearingRad = (geoBearingOf(scene, yaw) * Math.PI) / 180
const distM = clamp(450 + pitch * 800, 120, 1100)
// ...在米空间分解后换算回经纬度
}
2.4 反向:点地图 → 反推 yaw
反向流程必须与正向严格互逆 ,否则"地图选点 → 全景预览"方向会对不上。先求经纬度差对应的真实方位角(同样要先换算成米),再扣除场景 bearing,最后归一到 (-π, π] 匹配 PSV 的 yaw 约定:
ts
export function yawFromGps(scene, coords) {
const east = (coords[0] - lng) * mPerDegLng(lat)
const north = (coords[1] - lat) * M_PER_DEG_LAT
const yawDeg = (atan2(east, north) * 180 / Math.PI - bearing + 360) % 360
// 归一到 (-π, π]
}
还有一个隐蔽的对称性问题:弹窗里提供"距拍摄点距离"滑块(拖动时标记沿当前方向远近移动)。滑块改变的是距离,而预览 pin 的纵向位置由 pitch 决定。如果只改坐标不回推 pitch,预览 pin 会"钉"在原地不动,与地图距离脱节。所以要用正向模型的逆式回推:
ts
// 正向: dist = 450 + pitch * 800(钳制到 [120, 1100])
// 逆向: 钳制到同一范围后回推,保证两个方向模型一致
const distM = Math.min(1100, Math.max(120, distanceMeters(sceneCoords, coords)))
pendingPosition.value = { ...pendingPosition.value, pitch: (distM - 450) / 800 }
钳制范围必须与正向模型一致,否则滑块拖到极值时会出现"地图动了、全景没动"的撕裂。
三、在 Vue 中集成 PSV:生命周期与场景切换
3.1 封装原则
PSV 是命令式库,PsvContainer.vue 的职责是把它包成声明式组件:
onMounted创建 Viewer,onUnmounted销毁,防止 WebGL 上下文泄漏;- 对外只发语义化事件(
click-empty/map-pick/marker-click),用defineExpose暴露gotoMarker而不是插件实例本身------调用方不需要知道 PSV 的存在; - 点击事件里
e.data.marker非空时不派发click-empty,避免"点标记"被误判为"选点"。
3.2 场景切换:复用实例而非销毁重建
第一版切场景是销毁 Viewer 重建,问题明显:WebGL 上下文重建、Leaflet 瓦片缓存丢失、无过渡动画,体验割裂。改为复用实例,只更新数据:
ts
watch(() => props.scene.id, (id) => {
if (!viewer || viewerSceneId === id) return
viewerSceneId = id
planPlugin?.setOptions({ bearing: props.scene.bearing })
planPlugin?.setCoordinates(props.scene.coordinates)
viewer.setPanorama(props.scene.panorama, { caption: ..., showLoader: true })
})
这里有个容易踩的重复刷新 问题:切场景时 currentMarkers(按场景 filter 的 computed)返回新引用,markers 的 watcher 必然触发一次 setMarkers。如果场景 watcher 里也调 refreshMarkers(),就会重复执行两次。解法是职责单一------场景 watcher 只管地图参数和全景图,标记刷新完全交给 markers watcher。
同理,由于 currentMarkers / previewMarker 每次求值都返回新引用,watch 无需 deep: true,引用变化已足够触发,省掉深比较开销。
3.3 PlanPlugin 的一个文档里不显眼的坑
PlanPlugin 有个 configureLeaflet 选项,语义是完全接管 Leaflet 配置。一旦传入,默认的 OSM 底图图层不会被添加,地图直接变成灰色空面板。如果只是想给地图加个点击选点监听,正确姿势是创建后通过公开 API 挂载:
ts
planPlugin?.getLeaflet().on('click', (e) => {
emit('map-pick', [e.latlng.lng, e.latlng.lat])
})
这类"配置项的语义 ≠ 字面意思"的坑,只能靠读源码或试错发现,值得写进注释。
四、状态管理:模块级单例 + 防御性持久化
没有引入 Pinia------状态量级不需要。useAppState 把 ref 放在模块顶层,多次调用共享同一份响应式数据,天然是单例:
ts
// 模块级:与组件树解耦,watch 不随组件卸载而停止
const markers = ref<MarkerData[]>(initialMarkers)
watch(markers, (val) => {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(val)) }
catch { /* 超出配额等异常静默忽略 */ }
}, { deep: true })
几个防御性细节:
- 存储键带版本号 (
map-360-demo:markers:v1),未来结构变更时旧数据自然失效; - 加载时逐条类型守卫校验 (
isValidMarker),非法数据静默丢弃并回退预设------localStorage里的数据不可信,可能是旧版本写入甚至手工篡改的; - id 计数器从现有标记恢复 (扫描
m1..mN取最大值 +1),避免刷新后新增标记与已有标记撞 id; - 导入时重新分配 id 并要求
sceneId(link 标记还包括targetSceneId)指向已知场景,否则丢弃------导入文件同样不可信; - "恢复默认"要深拷贝 嵌套的
position/coordinates,否则后续编辑会污染DEFAULT_MARKERS常量。
五、添加标记:一个显式的状态机
"点击地图就加标记"听起来简单,但隐式触发极易误触。改成显式模式 :点按钮进入添加模式 → 选点出现红色脉冲预览 pin → 弹窗填写 → 确认。取消弹窗时保留预览和添加模式,方便重新选点。
整个流程收敛在 useAddMarkerFlow 里,添加与编辑复用同一条流水线 ,靠 editingId 区分:
进入模式 → 选点(全景点击 | 地图点击) → 预览 pin 同步 → 弹窗
↑_______________取消(保留预览)__________↓
确认 → onAdd / onUpdate → 退出
一个体验细节:编辑模式下,若用户没有重新选点,预览 pin 不显示------否则红色预览会和蓝色原标记重叠闪烁。通过比较 pending 位置与原标记位置是否完全一致来决定:
ts
if (ed.position.yaw === pending.yaw && ... 坐标也相同) return null
确认时还有一个兜底:若用户在弹窗里手改了经纬度,要用 yawFromGps 重新反推 yaw,保证 360 视图方向与地图一致------任何改变了坐标的路径,都必须走一遍反推,这是双向联动不出错的纪律。
六、容易忽略的边角
- XSS 防护 :标记名称/描述会被拼进 PSV 的 tooltip/content HTML。凡是用户输入进入
innerHTML,一律先转义& < > " '; - Blob 下载的释放时机 :
URL.revokeObjectURL要setTimeout延迟执行,立即释放可能导致浏览器还没来得及启动下载; - 模态框无障碍 :打开时记录
document.activeElement、聚焦首个输入框、Tab 循环圈定在弹窗内;关闭时恢复焦点。弹窗内的 Esc 要stopPropagation(),否则会和 App 层的全局 Esc(退出添加模式)处理器打架------全局兜底、局部拦截,两层各管各的; - 响应式地图面板 :PlanPlugin 尺寸用
min(300px, 72vw)之类的表达式,小屏不溢出。
七、小结
这个项目最有分享价值的三点:
- 跨坐标系联动,先在统一的物理空间(米)做向量分解,再各自换算回显示坐标 ------经纬度直接做三角分解会因
cos(纬度)因子产生方向偏转;正向模型与逆向模型必须严格互逆,包括钳制范围; - 命令式库进 Vue :封装层只暴露语义化接口;场景切换复用实例而非销毁重建;警惕"配置项语义 ≠ 字面意思"(
configureLeaflet); - 一切外部数据(localStorage、导入文件、用户输入)默认不可信:类型守卫校验、id 重新分配、HTML 转义,一个都不能少。