第06章:插件体系架构与自定义插件开发
Photo-Sphere-Viewer(PSV)的核心设计哲学之一是”核心精简,功能插件化“。Viewer本身只提供全景渲染、相机控制和基础交互,而标记、虚拟漫游、VR、陀螺仪、地图集成等高级功能全部通过插件实现。这种架构让开发者可以按需加载功能,也使得社区贡献和自定义扩展变得极其便利。本章将深入解析PSV的插件体系,并手把手教你编写自己的插件。
6.1 插件体系概述
6.1.1 设计理念
PSV v5.x 的插件架构遵循以下设计原则:
| 原则 | 说明 |
|---|---|
| 松耦合 | 插件之间相互独立,一个插件的故障不影响其他插件或Viewer |
| 统一接口 | 所有插件继承自 AbstractPlugin,拥有统一的构造、初始化和销毁流程 |
| Viewer引用 | 插件通过 this.viewer 访问Viewer实例的一切——配置、状态、事件、渲染器、Three.js场景 |
| 可组合 | 多个插件可以同时注册,协同工作(如 MarkersPlugin + TooltipPlugin) |
| 事件驱动 | 插件通过监听Viewer事件响应状态变化,而不是轮询 |
PSV 的源码结构清晰地体现了这一分层:
packages/
core/ # Viewer核心 + AbstractPlugin
markers-plugin/ # 标记插件
virtual-tour-plugin/ # 虚拟导览插件
gyroscope-plugin/ # 陀螺仪插件
...
6.1.2 插件生命周期
每个插件在PSV中的生命周期分为四个阶段:
注册(注册插件类) → 初始化(init) → 运行(监听事件、操作DOM) → 销毁(destroy)
-
注册阶段:在创建Viewer时,通过
plugins配置项将插件类(及其配置)传递给Viewer。Viewer在构造函数中实例化每个插件,调用其构造函数constructor(viewer, config)。 -
初始化阶段:Viewer完成场景初始化(Three.js渲染器就绪、全景图加载完毕)后,调用每个插件的
init()方法。这是插件开始工作的入口——在此订阅事件、创建DOM元素、初始化子组件。 -
运行阶段:插件处于活跃状态,响应Viewer事件、处理用户交互。插件可以随时通过
this.viewer调用Viewer的API。 -
销毁阶段:当Viewer调用
destroy()时,所有插件的destroy()方法被依次调用。插件必须在此阶段清理所有资源——移除事件监听、销毁DOM元素、释放Three.js对象。必须调用super.destroy()以触发基类清理。
6.1.3 插件与Viewer的关系
插件并非Viewer的子组件,而是对等协作的关系。它们之间唯一的纽带是 viewer 引用:
class MyPlugin extends AbstractPlugin {
constructor(viewer, config) {
super(viewer);
// this.viewer 是插件的"万能钥匙"
// 通过它访问:配置、状态、事件、渲染器、Three.js场景、其他插件
}
}
通过 this.viewer,插件可以访问以下层级:
| 属性 | 类型 | 说明 |
|---|---|---|
viewer.config |
ViewerConfig |
Viewer的完整配置 |
viewer.state |
ViewerState |
当前状态(位置、缩放等) |
viewer.renderer |
THREE.WebGLRenderer |
Three.js渲染器 |
viewer.scene |
THREE.Scene |
Three.js场景 |
viewer.getPlugin(id) |
AbstractPlugin |
获取已注册的其他插件实例 |
viewer.addEventListener(event, fn) |
— | 订阅Viewer事件 |
viewer.navbar |
Navbar |
导航栏控制器 |
viewer.panel |
Panel |
侧边面板控制器 |
viewer.tooltip |
Tooltip |
提示框控制器 |
6.2 插件的注册与配置
6.2.1 基本注册方式
在创建Viewer时,通过 plugins 数组注册插件。数组的每一项可以是:
方式一:仅传递插件类(使用默认配置)
import { Viewer } from '@photo-sphere-viewer/core';
import { MarkersPlugin } from '@photo-sphere-viewer/markers-plugin';
const viewer = new Viewer({
container: document.getElementById('viewer'),
panorama: 'path/to/panorama.jpg',
plugins: [
MarkersPlugin, // 使用默认配置
],
});
方式二:传递 [PluginClass, configObject](自定义配置)
plugins: [
[MarkersPlugin, {
markers: [
{ id: 'point1', position: { yaw: 0.5, pitch: 0.1 }, html: '标记1' }
],
}],
]
方式三:使用插件的静态 withConfig 方法
plugins: [
MarkersPlugin.withConfig({
markers: [{ id: 'point1', position: { yaw: 0.5, pitch: 0.1 }, html: '标记1' }],
}),
]
三种方式在功能上等价的,withConfig 方法只是语法糖:
// AbstractPlugin 基类提供的静态方法
static withConfig(config) {
return [this, config];
}
6.2.2 多插件同时注册
多个插件可以组合使用,它们按数组顺序依次实例化和初始化:
import { Viewer } from '@photo-sphere-viewer/core';
import { MarkersPlugin } from '@photo-sphere-viewer/markers-plugin';
import { GyroscopePlugin } from '@photo-sphere-viewer/gyroscope-plugin';
import { GalleryPlugin } from '@photo-sphere-viewer/gallery-plugin';
import { VirtualTourPlugin } from '@photo-sphere-viewer/virtual-tour-plugin';
const viewer = new Viewer({
container: document.getElementById('viewer'),
panorama: 'path/to/panorama.jpg',
plugins: [
[MarkersPlugin, {
markers: [
{ id: 'm1', position: { yaw: 0.2, pitch: 0.1 }, html: '入口' },
],
}],
GyroscopePlugin,
GalleryPlugin,
[VirtualTourPlugin, {
positionMode: 'manual',
renderMode: 'markers',
}],
],
});
插件间的交互通过Viewer的事件系统实现。例如,VirtualTourPlugin 监听了 MarkersPlugin 提供的标记点击事件来触发导航。
6.2.3 配置选项的传递机制
当使用 [PluginClass, configObject] 形式时,configObject最终会作为第二个参数传递给插件构造函数:
constructor(viewer, config) {
super(viewer);
// config === { markers: [...], className: 'custom-marker' }
this.config = { ...this.defaultConfig, ...config };
}
最佳实践:在插件内部定义 defaultConfig 并使用展开运算符合并,确保用户未配置的选项有合理的默认值。
6.3 AbstractPlugin 详解
AbstractPlugin 是所有PSV插件的基类,定义在 @photo-sphere-viewer/core 中。本节逐项解析其API。
6.3.1 构造函数 constructor(viewer, config)
class AbstractPlugin {
constructor(viewer) {
this.viewer = viewer;
this.config = null; // 子类负责赋值
}
}
viewer:Viewer实例的引用,是插件与外部世界的唯一桥梁。config:基类不做处理,由子类在构造函数中合并默认值后赋值给this.config。
6.3.2 init() 方法
这是插件生命周期的真正起点。 Viewer在所有插件实例化之后、全景图加载完成后调用 init()。
init() {
// 1. 创建DOM元素
this.container = document.createElement('div');
this.viewer.container.appendChild(this.container);
// 2. 订阅事件
this.viewer.addEventListener('position-updated', this._handlePositionChange);
// 3. 执行其他初始化逻辑
this._renderInitialState();
}
关键时序:init() 被调用时,Viewer的渲染器、场景、相机等已完全就绪,你可以安全地对它们进行操作。
6.3.3 destroy() 方法
当调用 viewer.destroy() 时,所有插件的 destroy() 被依次调用。这是插件清理资源的唯一出口:
destroy() {
// 1. 移除事件监听
this.viewer.removeEventListener('position-updated', this._handlePositionChange);
// 2. 移除DOM元素
if (this.container) {
this.container.remove();
this.container = null;
}
// 3. 释放Three.js对象
if (this.mesh) {
this.viewer.scene.remove(this.mesh);
this.mesh.geometry.dispose();
this.mesh.material.dispose();
this.mesh = null;
}
// 4. 必须调用基类销毁
super.destroy();
}
重要:必须调用 super.destroy(),基类会取消所有通过 viewer.addEventListener 注册的事件监听(前提是使用了对应的事件管理机制)。
6.3.4 viewer 属性
this.viewer 是插件的”万能钥匙”。通过它可以访问:
// 获取Viewer状态
const { yaw, pitch, zoom } = this.viewer.getPosition();
// 修改Viewer状态
this.viewer.rotate({ yaw: Math.PI / 2, pitch: 0 });
this.viewer.zoom(50);
// 操作导航栏
this.viewer.navbar.setButtons(['zoom', 'fullscreen', 'my-plugin-button']);
// 获取其他插件
const markersPlugin = this.viewer.getPlugin('markers');
if (markersPlugin) {
markersPlugin.toggleAllMarkers(true);
}
// 操作Three.js场景
const scene = this.viewer.scene;
const camera = this.viewer.camera;
6.3.5 静态 id 属性
每个插件必须定义一个唯一的静态 id,用于标识和查找:
export class MyPlugin extends AbstractPlugin {
static id = 'my-plugin';
// ...
}
// 其他插件可通过此ID查找
const myPlugin = viewer.getPlugin(MyPlugin.id);
// 或
const myPlugin = viewer.getPlugin('my-plugin');
6.3.6 this.config — 插件配置
插件配置由构造函数合并默认值后赋值:
export class MyPlugin extends AbstractPlugin {
static id = 'my-plugin';
constructor(viewer, config) {
super(viewer);
this.config = {
color: '#ff0000',
fontSize: 14,
position: 'top-left',
...config,
};
}
}
6.4 编写自定义插件(逐步教程)
本节通过创建一个名为 OverlayInfoPlugin 的插件来完整演示插件开发流程。这个插件将在全景图上显示当前的视角参数(yaw / pitch / fov)。
Step 1:创建插件类
// plugins/OverlayInfoPlugin.js
import { AbstractPlugin, CONSTANTS } from '@photo-sphere-viewer/core';
export class OverlayInfoPlugin extends AbstractPlugin {
static id = 'overlay-info';
/**
* 静态工厂方法,方便用户通过配置方式注册
*/
static withConfig(config) {
return [this, config];
}
/**
* 构造函数:接收Viewer引用和用户配置
*/
constructor(viewer, config) {
super(viewer);
// 合并默认配置与用户配置
this.config = {
color: '#ffffff',
backgroundColor: 'rgba(0, 0, 0, 0.6)',
fontSize: 14,
position: 'top-left', // 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
showYaw: true,
showPitch: true,
showFov: true,
...config,
};
}
/**
* 初始化:Viewer准备就绪后调用
*/
init() {
// 1. 创建DOM容器
this._container = document.createElement('div');
this._container.className = 'psv-overlay-info';
this._applyPositionStyles();
this.viewer.container.appendChild(this._container);
// 2. 订阅Viewer事件
this.viewer.addEventListener(CONSTANTS.EVENTS.POSITION_UPDATED, this);
this.viewer.addEventListener(CONSTANTS.EVENTS.ZOOM_UPDATED, this);
// 3. 初次渲染
this._updateDisplay();
// 4. 订阅窗口大小变化
this._boundResize = () => this._updateDisplay();
window.addEventListener('resize', this._boundResize);
}
/**
* 销毁:清理所有资源
*/
destroy() {
// 取消事件订阅
this.viewer.removeEventListener(CONSTANTS.EVENTS.POSITION_UPDATED, this);
this.viewer.removeEventListener(CONSTANTS.EVENTS.ZOOM_UPDATED, this);
window.removeEventListener('resize', this._boundResize);
// 移除DOM
if (this._container) {
this._container.remove();
this._container = null;
}
// 必须调用基类销毁
super.destroy();
}
/**
* 事件处理器:Viewer通过 handleEvent(e) 模式分发事件
* 当 this 作为listener传入时,Viewer会调用 this.handleEvent(e)
*/
handleEvent(e) {
switch (e.type) {
case CONSTANTS.EVENTS.POSITION_UPDATED:
case CONSTANTS.EVENTS.ZOOM_UPDATED:
this._updateDisplay();
break;
}
}
/**
* 更新显示的视角信息
*/
_updateDisplay() {
const position = this.viewer.getPosition();
const parts = [];
if (this.config.showYaw) {
parts.push(`Yaw: ${this._formatAngle(position.yaw)}`);
}
if (this.config.showPitch) {
parts.push(`Pitch: ${this._formatAngle(position.pitch)}`);
}
if (this.config.showFov) {
parts.push(`FOV: ${Math.round(this.viewer.state.hFov)}°`);
}
this._container.textContent = parts.join(' | ');
}
/**
* 将弧度格式化为度数字符串,保留一位小数
*/
_formatAngle(rad) {
const deg = THREE.MathUtils.radToDeg(rad);
return `${deg.toFixed(1)}°`;
}
/**
* 根据config.position设置容器CSS定位
*/
_applyPositionStyles() {
const style = this._container.style;
style.position = 'absolute';
style.color = this.config.color;
style.backgroundColor = this.config.backgroundColor;
style.fontSize = `${this.config.fontSize}px`;
style.padding = '6px 12px';
style.borderRadius = '4px';
style.fontFamily = 'monospace';
style.zIndex = '10';
style.pointerEvents = 'none';
switch (this.config.position) {
case 'top-left':
style.top = '10px';
style.left = '10px';
break;
case 'top-right':
style.top = '10px';
style.right = '10px';
break;
case 'bottom-left':
style.bottom = '10px';
style.left = '10px';
break;
case 'bottom-right':
style.bottom = '10px';
style.right = '10px';
break;
}
}
}
Step 2:使用插件
import { Viewer } from '@photo-sphere-viewer/core';
import { OverlayInfoPlugin } from './plugins/OverlayInfoPlugin';
const viewer = new Viewer({
container: document.getElementById('viewer'),
panorama: 'path/to/panorama.jpg',
plugins: [
[OverlayInfoPlugin, {
position: 'bottom-left',
color: '#00ff88',
backgroundColor: 'rgba(0, 0, 0, 0.8)',
fontSize: 16,
}],
],
});
Step 3:自定义导航栏按钮
PSV v5.x 提供了 AbstractButton 基类和 registerButton 函数来扩展导航栏。下面创建一个完整的自定义按钮:
// plugins/InfoToggleButton.js
import { AbstractButton } from '@photo-sphere-viewer/core';
const SVG_INFO = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" height="20"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="16" x2="12" y2="12"/>
<line x1="12" y1="8" x2="12.01" y2="8"/>
</svg>`;
export class InfoToggleButton extends AbstractButton {
static id = 'info-toggle';
constructor(viewer, config = {}) {
super(viewer, {
id: 'info-toggle',
className: 'psv-info-toggle-button',
title: config.title || '信息面板',
content: SVG_INFO,
disabled: false,
visible: true,
collapsable: false, // 不参与响应式折叠
onClick: () => {},
...config,
});
this._active = false;
}
/**
* 按钮被点击时的回调
*/
onClick() {
this._active = !this._active;
this.toggleActive(this._active);
const overlayPlugin = this.viewer.getPlugin('overlay-info');
if (overlayPlugin) {
overlayPlugin.toggle(); // 假设OverlayInfoPlugin导出了toggle方法
}
}
/**
* 销毁按钮时清理
*/
destroy() {
super.destroy();
}
}
在插件中注册并使用自定义按钮:
import { registerButton, utils } from '@photo-sphere-viewer/core';
import { InfoToggleButton } from './plugins/InfoToggleButton';
// 在插件的 init() 中注册按钮
init() {
registerButton(this.viewer, InfoToggleButton);
// 将按钮添加到导航栏(可放在内置按钮之间)
this.viewer.navbar.setButtons([
'zoom',
'info-toggle', // 自定义按钮ID
'move',
'fullscreen',
]);
}
Step 4:添加面板(Panel)
PSV的面板系统允许插件在侧边栏显示更复杂的内容:
// 在 OverlayInfoPlugin 中添加面板支持
init() {
// ... 前面的代码
// 注册按钮(用于打开/关闭面板)
registerButton(this.viewer, InfoToggleButton);
// 设置面板内容
this.viewer.panel.setContent(`
<div style="padding: 16px;">
<h3 style="margin: 0 0 12px 0;">实时视角信息</h3>
<div id="psv-overlay-panel-content"></div>
</div>
`);
this._panelContent = document.getElementById('psv-overlay-panel-content');
this._updatePanelContent();
}
_updatePanelContent() {
const position = this.viewer.getPosition();
this._panelContent.innerHTML = `
<table style="width:100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px; border-bottom: 1px solid #444;">水平偏航 (Yaw)</td>
<td style="padding: 8px; border-bottom: 1px solid #444;">${THREE.MathUtils.radToDeg(position.yaw).toFixed(2)}°</td>
</tr>
<tr>
<td style="padding: 8px; border-bottom: 1px solid #444;">垂直俯仰 (Pitch)</td>
<td style="padding: 8px; border-bottom: 1px solid #444;">${THREE.MathUtils.radToDeg(position.pitch).toFixed(2)}°</td>
</tr>
<tr>
<td style="padding: 8px;">视场角 (FOV)</td>
<td style="padding: 8px;">${Math.round(this.viewer.state.hFov)}°</td>
</tr>
</table>
`;
}
Step 5:使用SCSS自定义样式
如果项目使用SCSS,可以通过PSV暴露的变量体系定制样式:
// styles/psv-overrides.scss
@use '@photo-sphere-viewer/core/index' as psv with (
$psv-loader-color: #00ff88,
$psv-navbar-background: rgba(20, 20, 30, 0.85),
$psv-buttons-color: #e0e0e0,
$psv-buttons-hover-background: rgba(0, 255, 136, 0.2),
);
// 您还可以通过覆盖PSV内部的默认变量来实现更多定制
// 例如,更改面板背景、通知颜色、覆盖层样式等
// 自定义插件样式
.my-plugin-container {
// 如果需要额外样式,在此定义
}
注意:@use 必须在文件顶部,且 with 语法在编译时覆盖变量。如果需要在运行时动态改变样式,应使用CSS自定义属性(CSS Custom Properties)或在JavaScript中直接操作style。
6.5 插件开发实战:完整的”视角信息显示”插件
本节呈现一个完整、可独立运行的插件,包含所有生命周期方法和TypeScript类型。
6.5.1 JavaScript版本(ES模块)
// plugins/ViewInfoPlugin.js
import {
AbstractPlugin,
AbstractButton,
registerButton,
CONSTANTS,
} from '@photo-sphere-viewer/core';
import * as THREE from 'three';
// ============================================================
// 自定义按钮
// ============================================================
class ViewInfoButton extends AbstractButton {
static id = 'view-info-btn';
constructor(viewer) {
super(viewer, {
id: 'view-info-btn',
className: 'psv-view-info-btn',
title: '视角信息',
content: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>',
collapsable: false,
});
}
onClick() {
this.viewer.panel.toggle();
}
}
// ============================================================
// 主插件类
// ============================================================
export class ViewInfoPlugin extends AbstractPlugin {
static id = 'view-info';
static withConfig(config) {
return [this, config];
}
constructor(viewer, config) {
super(viewer);
this.config = {
// 浮层配置
color: '#ffffff',
backgroundColor: 'rgba(0, 0, 0, 0.65)',
fontSize: 14,
position: 'top-left',
// 显示选项
showYaw: true,
showPitch: true,
showFov: true,
showZoom: true,
// 面板配置
enablePanel: true,
panelTitle: '实时视角信息',
...config,
};
// 内部状态
this._container = null;
this._panelContent = null;
this._boundHandleResize = null;
}
// ========== 生命周期 ==========
init() {
// 1. 创建浮层DOM
this._createOverlay();
// 2. 订阅事件
this.viewer.addEventListener(CONSTANTS.EVENTS.POSITION_UPDATED, this);
this.viewer.addEventListener(CONSTANTS.EVENTS.ZOOM_UPDATED, this);
// 3. 注册按钮和面板
if (this.config.enablePanel) {
registerButton(this.viewer, ViewInfoButton);
this.viewer.navbar.setButtons([
...this.viewer.navbar.buttons.map(b => b.id),
'view-info-btn',
]);
this._setupPanel();
}
// 4. 初次渲染
this._updateOverlay();
if (this._panelContent) {
this._updatePanel();
}
// 5. 监听窗口变化
this._boundHandleResize = () => {
this._updateOverlay();
if (this._panelContent) this._updatePanel();
};
window.addEventListener('resize', this._boundHandleResize);
}
destroy() {
// 取消事件
this.viewer.removeEventListener(CONSTANTS.EVENTS.POSITION_UPDATED, this);
this.viewer.removeEventListener(CONSTANTS.EVENTS.ZOOM_UPDATED, this);
window.removeEventListener('resize', this._boundHandleResize);
// 移除DOM
if (this._container) {
this._container.remove();
this._container = null;
}
this._panelContent = null;
super.destroy();
}
handleEvent(e) {
switch (e.type) {
case CONSTANTS.EVENTS.POSITION_UPDATED:
case CONSTANTS.EVENTS.ZOOM_UPDATED:
this._updateOverlay();
if (this._panelContent) this._updatePanel();
break;
}
}
// ========== 浮层相关 ==========
_createOverlay() {
this._container = document.createElement('div');
this._container.className = 'psv-view-info-overlay';
Object.assign(this._container.style, {
position: 'absolute',
color: this.config.color,
backgroundColor: this.config.backgroundColor,
fontSize: `${this.config.fontSize}px`,
padding: '6px 14px',
borderRadius: '6px',
fontFamily: "'JetBrains Mono', 'Consolas', monospace",
zIndex: '10',
pointerEvents: 'none',
userSelect: 'none',
backdropFilter: 'blur(4px)',
transition: 'opacity 0.3s ease',
letterSpacing: '0.5px',
});
this._applyPosition();
this.viewer.container.appendChild(this._container);
}
_applyPosition() {
const s = this._container.style;
s.top = ''; s.bottom = ''; s.left = ''; s.right = '';
switch (this.config.position) {
case 'top-left':
s.top = '12px'; s.left = '12px'; break;
case 'top-right':
s.top = '12px'; s.right = '12px'; break;
case 'bottom-left':
s.bottom = '12px'; s.left = '12px'; break;
case 'bottom-right':
s.bottom = '12px'; s.right = '12px'; break;
}
}
_updateOverlay() {
const pos = this.viewer.getPosition();
const hFov = Math.round(this.viewer.state.hFov);
const zoomLvl = Math.round(this.viewer.state.zoomLvl);
const parts = [];
if (this.config.showYaw) parts.push(`Yaw: ${THREE.MathUtils.radToDeg(pos.yaw).toFixed(1)}°`);
if (this.config.showPitch) parts.push(`Pitch: ${THREE.MathUtils.radToDeg(pos.pitch).toFixed(1)}°`);
if (this.config.showFov) parts.push(`FOV: ${hFov}°`);
if (this.config.showZoom) parts.push(`Zoom: ${zoomLvl}`);
this._container.textContent = parts.join(' | ');
}
// ========== 面板相关 ==========
_setupPanel() {
this.viewer.panel.setContent(`
<div style="padding: 16px;">
<h3 style="margin: 0 0 16px 0; font-size: 16px; font-weight: 600;">
${this.config.panelTitle}
</h3>
<div id="psv-view-info-panel-content"></div>
</div>
`);
this._panelContent = document.getElementById('psv-view-info-panel-content');
}
_updatePanel() {
const pos = this.viewer.getPosition();
const hFov = Math.round(this.viewer.state.hFov);
const zoomLvl = Math.round(this.viewer.state.zoomLvl);
this._panelContent.innerHTML = `
<table style="width:100%; border-collapse: collapse; font-size: 14px;">
<tr>
<td style="padding: 10px 8px; border-bottom: 1px solid rgba(255,255,255,0.1);">水平偏航 (Yaw)</td>
<td style="padding: 10px 8px; border-bottom: 1px solid rgba(255,255,255,0.1); text-align: right;">
${THREE.MathUtils.radToDeg(pos.yaw).toFixed(2)}°
</td>
</tr>
<tr>
<td style="padding: 10px 8px; border-bottom: 1px solid rgba(255,255,255,0.1);">垂直俯仰 (Pitch)</td>
<td style="padding: 10px 8px; border-bottom: 1px solid rgba(255,255,255,0.1); text-align: right;">
${THREE.MathUtils.radToDeg(pos.pitch).toFixed(2)}°
</td>
</tr>
<tr>
<td style="padding: 10px 8px; border-bottom: 1px solid rgba(255,255,255,0.1);">视场角 (FOV)</td>
<td style="padding: 10px 8px; border-bottom: 1px solid rgba(255,255,255,0.1); text-align: right;">
${hFov}°
</td>
</tr>
<tr>
<td style="padding: 10px 8px;">缩放级别 (Zoom)</td>
<td style="padding: 10px 8px; text-align: right;">
${zoomLvl}
</td>
</tr>
</table>
`;
}
// ========== 公开API ==========
/**
* 切换浮层可见性
*/
toggleOverlay(visible) {
if (this._container) {
this._container.style.display = visible ? '' : 'none';
}
}
/**
* 切换面板
*/
togglePanel() {
this.viewer.panel.toggle();
}
/**
* 更新配置项(运行时)
*/
updateConfig(newConfig) {
Object.assign(this.config, newConfig);
if (this._container) {
this._container.style.color = this.config.color;
this._container.style.backgroundColor = this.config.backgroundColor;
this._container.style.fontSize = `${this.config.fontSize}px`;
this._applyPosition();
}
}
}
6.5.2 TypeScript版本(类型安全)
// plugins/ViewInfoPlugin.ts
import {
AbstractPlugin,
AbstractButton,
registerButton,
CONSTANTS,
PluginConstructor,
Viewer,
events,
} from '@photo-sphere-viewer/core';
import * as THREE from 'three';
// ---- 类型定义 ----
/**
* ViewInfoPlugin 配置接口
*/
export interface ViewInfoPluginConfig {
color?: string;
backgroundColor?: string;
fontSize?: number;
position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
showYaw?: boolean;
showPitch?: boolean;
showFov?: boolean;
showZoom?: boolean;
enablePanel?: boolean;
panelTitle?: string;
}
const DEFAULT_CONFIG: Required<ViewInfoPluginConfig> = {
color: '#ffffff',
backgroundColor: 'rgba(0, 0, 0, 0.65)',
fontSize: 14,
position: 'top-left',
showYaw: true,
showPitch: true,
showFov: true,
showZoom: true,
enablePanel: true,
panelTitle: '实时视角信息',
};
// ---- 自定义按钮 ----
class ViewInfoButton extends AbstractButton {
static override id = 'view-info-btn';
constructor(viewer: Viewer) {
super(viewer, {
id: 'view-info-btn',
className: 'psv-view-info-btn',
title: '视角信息',
content: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>`,
collapsable: false,
});
}
override onClick(): void {
this.viewer.panel.toggle();
}
}
// ---- 主插件 ----
export class ViewInfoPlugin extends AbstractPlugin {
static override id = 'view-info';
declare config: Required<ViewInfoPluginConfig>;
private _container: HTMLDivElement | null = null;
private _panelContent: HTMLElement | null = null;
private _boundHandleResize: (() => void) | null = null;
static withConfig(config?: ViewInfoPluginConfig): [PluginConstructor, ViewInfoPluginConfig] {
return [this, config ?? {}];
}
constructor(viewer: Viewer, config: ViewInfoPluginConfig = {}) {
super(viewer);
this.config = { ...DEFAULT_CONFIG, ...config };
}
// ========== 生命周期 ==========
override init(): void {
this._createOverlay();
this.viewer.addEventListener(CONSTANTS.EVENTS.POSITION_UPDATED, this);
this.viewer.addEventListener(CONSTANTS.EVENTS.ZOOM_UPDATED, this);
if (this.config.enablePanel) {
registerButton(this.viewer, ViewInfoButton);
const currentButtons = this.viewer.navbar.buttons.map(b => b.id);
this.viewer.navbar.setButtons([...currentButtons, ViewInfoButton.id]);
this._setupPanel();
}
this._updateOverlay();
if (this._panelContent) {
this._updatePanel();
}
this._boundHandleResize = () => {
this._updateOverlay();
if (this._panelContent) this._updatePanel();
};
window.addEventListener('resize', this._boundHandleResize);
}
override destroy(): void {
this.viewer.removeEventListener(CONSTANTS.EVENTS.POSITION_UPDATED, this);
this.viewer.removeEventListener(CONSTANTS.EVENTS.ZOOM_UPDATED, this);
if (this._boundHandleResize) {
window.removeEventListener('resize', this._boundHandleResize);
}
if (this._container) {
this._container.remove();
this._container = null;
}
this._panelContent = null;
super.destroy();
}
handleEvent(e: Event): void {
switch (e.type) {
case events.PositionUpdatedEvent.type:
case events.ZoomUpdatedEvent.type:
this._updateOverlay();
if (this._panelContent) this._updatePanel();
break;
}
}
// ========== 私有方法 ==========
private _createOverlay(): void {
this._container = document.createElement('div');
this._container.className = 'psv-view-info-overlay';
Object.assign(this._container.style, {
position: 'absolute',
color: this.config.color,
backgroundColor: this.config.backgroundColor,
fontSize: `${this.config.fontSize}px`,
padding: '6px 14px',
borderRadius: '6px',
fontFamily: "'JetBrains Mono', 'Consolas', monospace",
zIndex: '10',
pointerEvents: 'none',
userSelect: 'none',
backdropFilter: 'blur(4px)',
});
this._applyPosition();
this.viewer.container.appendChild(this._container);
}
private _applyPosition(): void {
if (!this._container) return;
const s = this._container.style;
s.top = ''; s.bottom = ''; s.left = ''; s.right = '';
switch (this.config.position) {
case 'top-left': s.top = '12px'; s.left = '12px'; break;
case 'top-right': s.top = '12px'; s.right = '12px'; break;
case 'bottom-left': s.bottom = '12px'; s.left = '12px'; break;
case 'bottom-right': s.bottom = '12px'; s.right = '12px'; break;
}
}
private _updateOverlay(): void {
if (!this._container) return;
const pos = this.viewer.getPosition();
const hFov = Math.round(this.viewer.state.hFov);
const zoomLvl = Math.round(this.viewer.state.zoomLvl);
const parts: string[] = [];
if (this.config.showYaw) parts.push(`Yaw: ${THREE.MathUtils.radToDeg(pos.yaw).toFixed(1)}°`);
if (this.config.showPitch) parts.push(`Pitch: ${THREE.MathUtils.radToDeg(pos.pitch).toFixed(1)}°`);
if (this.config.showFov) parts.push(`FOV: ${hFov}°`);
if (this.config.showZoom) parts.push(`Zoom: ${zoomLvl}`);
this._container.textContent = parts.join(' | ');
}
private _setupPanel(): void {
this.viewer.panel.setContent(`
<div style="padding: 16px;">
<h3 style="margin: 0 0 16px 0; font-size: 16px; font-weight: 600;">
${this.config.panelTitle}
</h3>
<div id="psv-view-info-panel-content"></div>
</div>
`);
this._panelContent = document.getElementById('psv-view-info-panel-content');
}
private _updatePanel(): void {
if (!this._panelContent) return;
const pos = this.viewer.getPosition();
const hFov = Math.round(this.viewer.state.hFov);
const zoomLvl = Math.round(this.viewer.state.zoomLvl);
this._panelContent.innerHTML = `
<table style="width:100%; border-collapse: collapse; font-size: 14px;">
<tr>
<td style="padding: 10px 8px; border-bottom: 1px solid rgba(255,255,255,0.1);">水平偏航 (Yaw)</td>
<td style="padding: 10px 8px; border-bottom: 1px solid rgba(255,255,255,0.1); text-align: right;">${THREE.MathUtils.radToDeg(pos.yaw).toFixed(2)}°</td>
</tr>
<tr>
<td style="padding: 10px 8px; border-bottom: 1px solid rgba(255,255,255,0.1);">垂直俯仰 (Pitch)</td>
<td style="padding: 10px 8px; border-bottom: 1px solid rgba(255,255,255,0.1); text-align: right;">${THREE.MathUtils.radToDeg(pos.pitch).toFixed(2)}°</td>
</tr>
<tr>
<td style="padding: 10px 8px; border-bottom: 1px solid rgba(255,255,255,0.1);">视场角 (FOV)</td>
<td style="padding: 10px 8px; border-bottom: 1px solid rgba(255,255,255,0.1); text-align: right;">${hFov}°</td>
</tr>
<tr>
<td style="padding: 10px 8px;">缩放级别 (Zoom)</td>
<td style="padding: 10px 8px; text-align: right;">${zoomLvl}</td>
</tr>
</table>
`;
}
// ========== 公开API ==========
toggleOverlay(visible: boolean): void {
if (this._container) {
this._container.style.display = visible ? '' : 'none';
}
}
togglePanel(): void {
this.viewer.panel.toggle();
}
updateConfig(newConfig: Partial<ViewInfoPluginConfig>): void {
Object.assign(this.config, newConfig);
if (this._container) {
this._container.style.color = this.config.color;
this._container.style.backgroundColor = this.config.backgroundColor;
this._container.style.fontSize = `${this.config.fontSize}px`;
this._applyPosition();
}
}
}
6.5.3 使用示例
import { Viewer } from '@photo-sphere-viewer/core';
import { ViewInfoPlugin } from './plugins/ViewInfoPlugin';
const viewer = new Viewer({
container: document.getElementById('viewer'),
panorama: 'panorama.jpg',
plugins: [
[ViewInfoPlugin, {
position: 'bottom-left',
color: '#00ff88',
fontSize: 15,
showZoom: false,
panelTitle: '我的全景视角',
}],
],
});
// 运行时修改配置
const plugin = viewer.getPlugin('view-info');
plugin.updateConfig({ color: '#ff6644', position: 'top-right' });
6.6 导航栏按钮详解
6.6.1 内置按钮列表
PSV v5.x 内置了以下按钮(可通过 navbar 的 buttons 配置排列/移除):
| 按钮ID | 类名 | 功能 |
|---|---|---|
zoom |
ZoomButton |
缩放控制(放大/缩小/重置) |
move |
MoveButton |
移动控制(上下左右) |
fullscreen |
FullscreenButton |
全屏切换 |
download |
DownloadButton |
下载当前全景图 |
description |
DescriptionButton |
显示/隐藏描述 |
caption |
CaptionButton |
显示/隐藏标题 |
autorotate |
AutorotateButton |
自动旋转开关 |
默认排列:['zoom', 'move', 'fullscreen']
6.6.2 自定义按钮配置项
AbstractButton 支持以下配置:
interface ButtonConfig {
id: string; // 按钮唯一标识(与静态 id 对应)
className?: string; // 自定义CSS类名
title: string; // 鼠标悬停提示文字
content: string; // 按钮内容(SVG字符串或HTML)
disabled?: boolean; // 是否禁用
visible?: boolean; // 是否可见
collapsable?: boolean; // 是否参与响应式折叠(窗口较小时自动隐藏)
onClick?: () => void; // 点击回调(可在构造函数中传入或在子类中覆盖)
}
6.6.3 registerButton 函数
import { registerButton } from '@photo-sphere-viewer/core';
// 注册自定义按钮类型(全局注册,只需调用一次)
registerButton(viewer, MyCustomButton);
注意:registerButton 必须在 viewer.navbar.setButtons() 之前调用,否则导航栏无法识别该按钮ID。
6.6.4 动态管理按钮
// 获取当前按钮列表
const currentButtons = viewer.navbar.buttons;
// 添加按钮
viewer.navbar.setButtons([...currentButtons.map(b => b.id), 'my-button']);
// 移除按钮
viewer.navbar.setButtons(
currentButtons
.filter(b => b.id !== 'download')
.map(b => b.id)
);
// 切换按钮禁用状态
const btn = viewer.navbar.getButton('my-button');
btn.disable();
btn.enable();
// 切换按钮可见性
btn.hide();
btn.show();
// 切换按钮激活状态
btn.toggleActive(true); // 高亮态(如"已激活"的切换开关)
btn.toggleActive(false); // 普通态
6.6.5 按钮管理最佳实践
- 在插件的
init()中注册按钮,在destroy()中无需手动移除(基类会处理导航栏按钮的注销)。 - 按钮ID应使用前缀避免冲突,如
my-plugin-toggle而非toggle。 - 使用
collapsable: false标记核心按钮,确保它们不会在小屏幕上被隐藏。 - 避免在按钮回调中做重操作,按钮点击应只是触发异步操作。
6.7 SCSS主题定制完全指南
6.7.1 PSV的SCSS变量体系
PSV v5.x 使用Sass模块系统(@use / @forward)组织样式,暴露了大量可定制变量。完整的变量定义在 @photo-sphere-viewer/core 的 index.scss 中。
变量组织层级:
core/src/styles/
index.scss # 总入口,收集所有变量并用 @forward 导出
_variables.scss # 核心变量定义
_navbar.scss # 导航栏变量
_loader.scss # 加载器变量
_panel.scss # 面板变量
_tooltip.scss # 工具提示变量
_notification.scss # 通知变量
_overlay.scss # 覆盖层变量
6.7.2 核心可定制变量分类
全局变量
$psv-loader-color: #007aff; // 加载器主色
$psv-loader-background: #000; // 加载器背景
$psv-loading-txt-color: #fff; // 加载文字颜色
$psv-transition-duration: 0.3s; // 过渡动画时长
导航栏变量
$psv-navbar-height: 40px; // 导航栏高度
$psv-navbar-background: rgba(0,0,0,0.5); // 导航栏背景
$psv-navbar-radius: null; // 导航栏圆角
// 按钮变量
$psv-buttons-background: transparent; // 按钮背景
$psv-buttons-color: rgba(255,255,255,0.7); // 按钮文字/图标颜色
$psv-buttons-hover-background: rgba(255,255,255,0.1); // 悬停背景
$psv-buttons-hover-color: #fff; // 悬停颜色
$psv-buttons-active-background: rgba(255,255,255,0.15); // 激活态背景
$psv-buttons-active-color: null; // 激活态颜色
$psv-buttons-disabled-opacity: 0.5; // 禁用态透明度
$psv-buttons-radius: 4px; // 按钮圆角
$psv-buttons-padding: 8px; // 按钮内边距
$psv-buttons-gap: 2px; // 按钮间距
$psv-buttons-separator-color: transparent; // 按钮分隔线颜色
$psv-buttons-separator-width: 0; // 按钮分隔线宽度
面板变量
$psv-panel-background: rgba(10,10,10,0.7); // 面板背景
$psv-panel-text-color: #e0e0e0; // 面板文字颜色
$psv-panel-width: 340px; // 面板宽度
$psv-panel-padding: 0; // 面板内边距
$psv-panel-close-button-background: rgba(0,0,0,0.1); // 关闭按钮背景
$psv-panel-close-button-color: #fff; // 关闭按钮颜色
$psv-panel-resizer-background: rgba(255,255,255,0.2); // 拖拽调整器颜色
$psv-panel-resizer-width: 6px; // 拖拽调整器宽度
$psv-panel-menu-background: transparent; // 面板菜单背景
工具提示变量
$psv-tooltip-background: rgba(0,0,0,0.8); // 提示背景
$psv-tooltip-color: #fff; // 提示文字颜色
$psv-tooltip-radius: 4px; // 提示圆角
$psv-tooltip-padding: 6px 12px; // 提示内边距
$psv-tooltip-font-size: 14px; // 提示字号
$psv-tooltip-arrow-size: 6px; // 提示箭头大小
$psv-tooltip-max-width: 240px; // 提示最大宽度
通知变量
$psv-notification-position-from: 'top'; // 通知位置 top/bottom
$psv-notification-font-size: 14px; // 通知字号
$psv-notification-max-width: 320px; // 通知最大宽度
$psv-notification-text-color: #fff; // 通知文字颜色
$psv-notification-background: rgba(0,0,0,0.8); // 通知背景
$psv-notification-border-radius: 4px; // 通知圆角
$psv-notification-padding: 10px 20px; // 通知内边距
覆盖层变量
$psv-overlay-image: null; // 自定义覆盖层背景图
$psv-overlay-opacity: 0.8; // 覆盖层透明度
$psv-overlay-title-color: #fff; // 覆盖层标题颜色
$psv-overlay-title-font-size: 2em; // 覆盖层标题字号
$psv-overlay-text-color: #fff; // 覆盖层文字颜色
$psv-overlay-text-font-size: 1em; // 覆盖层文字字号
$psv-overlay-button-background: #fff; // 覆盖层按钮背景
$psv-overlay-button-color: #000; // 覆盖层按钮颜色
$psv-overlay-button-radius: 4px; // 覆盖层按钮圆角
6.7.3 完整定制示例
// styles/psv-theme.scss
@use '@photo-sphere-viewer/core/index' as psv with (
// 加载器
$psv-loader-color: #10b981,
$psv-loader-background: #0f172a,
// 导航栏
$psv-navbar-background: linear-gradient(to top, rgba(15, 23, 42, 0), rgba(15, 23, 42, 0.85)),
$psv-navbar-height: 48px,
// 按钮
$psv-buttons-color: rgba(255, 255, 255, 0.65),
$psv-buttons-hover-background: rgba(16, 185, 129, 0.15),
$psv-buttons-hover-color: #10b981,
$psv-buttons-active-background: rgba(16, 185, 129, 0.25),
$psv-buttons-active-color: #10b981,
$psv-buttons-radius: 8px,
$psv-buttons-padding: 10px,
$psv-buttons-gap: 4px,
// 面板
$psv-panel-background: rgba(15, 23, 42, 0.92),
$psv-panel-text-color: #e2e8f0,
$psv-panel-width: 360px,
// 工具提示
$psv-tooltip-background: rgba(15, 23, 42, 0.95),
$psv-tooltip-radius: 8px,
// 通知
$psv-notification-background: rgba(15, 23, 42, 0.92),
$psv-notification-border-radius: 8px,
);
6.7.4 CSS覆盖方案(备选,不推荐)
如果项目不使用SCSS,可以通过CSS选择器覆盖默认样式:
/* 覆盖导航栏背景 */
.psv-navbar {
background: rgba(15, 23, 42, 0.85) !important;
}
/* 覆盖按钮悬停态 */
.psv-button:hover {
background: rgba(16, 185, 129, 0.2) !important;
color: #10b981 !important;
}
/* 覆盖加载器颜色 */
.psv-loader circle {
stroke: #10b981 !important;
}
不推荐CSS覆盖的原因:
- 需要
!important与PSV的内联样式和选择器优先级斗争 - 无法利用SCSS变量体系的便利性
- 版本升级时选择器可能变化
- 无法覆盖SCSS计算出的样式(如颜色变体)
6.8 插件发布
6.8.1 打包为独立npm包
当自定义插件足够通用时,可以考虑将其发布为独立的npm包。
推荐的目录结构:
psv-view-info-plugin/
src/
ViewInfoPlugin.ts # 主源码
index.ts # 导出入口
dist/ # 构建产物(gitignore)
index.js
index.d.ts
index.css
package.json
tsconfig.json
rollup.config.js # 或使用其他打包工具
6.8.2 package.json 配置建议
{
"name": "@your-scope/psv-view-info-plugin",
"version": "1.0.0",
"description": "Photo Sphere Viewer plugin - display real-time view angles",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": ["dist"],
"keywords": [
"photo-sphere-viewer",
"psv",
"psv-plugin",
"panorama",
"360",
"view-info"
],
"peerDependencies": {
"@photo-sphere-viewer/core": "^5.0.0",
"three": "^0.150.0"
},
"devDependencies": {
"@photo-sphere-viewer/core": "^5.0.0",
"three": "^0.150.0",
"typescript": "^5.0.0"
},
"scripts": {
"build": "rollup -c",
"prepublishOnly": "npm run build"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/your-scope/psv-view-info-plugin"
}
}
关键点说明:
peerDependencies声明对@photo-sphere-viewer/core和three的依赖,避免重复安装keywords中使用psv-plugin标签便于社区搜索prepublishOnly确保发布前构建产物是最新的
6.8.3 发布到npm
# 登录npm(首次)
npm login
# 确保构建通过
npm run build
# 发布
npm publish --access public # 作用域包需要显式指定 --access public
6.9 本章小结
本章系统讲解了Photo-Sphere-Viewer v5.x的插件体系架构与自定义插件开发。核心要点回顾:
-
插件架构:PSV采用”核心精简,功能插件化”的设计,所有插件继承自
AbstractPlugin,通过统一的constructor → init → destroy生命周期管理。 -
注册方式:支持
PluginClass、[PluginClass, config]和PluginClass.withConfig(config)三种等价注册形式。 -
AbstractPlugin基类:提供了
viewer引用(万能钥匙)、config配置、init()/destroy()生命周期钩子以及静态id标识。 -
自定义插件开发:遵循”创建类 → 合并配置 → init中订阅事件+创建DOM → destroy中清理一切 → 调用super.destroy()”的标准流程。
-
导航栏扩展:通过
AbstractButton+registerButton可以无缝扩展导航栏按钮,按钮支持禁用、隐藏、激活等状态。 -
SCSS主题定制:PSV暴露了完整的SCSS变量体系,覆盖加载器、导航栏、按钮、面板、工具提示、通知、覆盖层等所有UI组件,通过
@use ... with ()语法即可完全定制主题。 -
插件发布:成熟的插件可以打包为独立npm包发布到社区。
掌握了本章知识,你就能完全掌控PSV的扩展能力——无论是为自己的项目开发轻量级定制功能,还是为开源社区贡献通用插件。