第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)
  1. 注册阶段:在创建Viewer时,通过 plugins 配置项将插件类(及其配置)传递给Viewer。Viewer在构造函数中实例化每个插件,调用其构造函数 constructor(viewer, config)。

  2. 初始化阶段:Viewer完成场景初始化(Three.js渲染器就绪、全景图加载完毕)后,调用每个插件的 init() 方法。这是插件开始工作的入口——在此订阅事件、创建DOM元素、初始化子组件。

  3. 运行阶段:插件处于活跃状态,响应Viewer事件、处理用户交互。插件可以随时通过 this.viewer 调用Viewer的API。

  4. 销毁阶段:当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 按钮管理最佳实践

  1. 在插件的 init() 中注册按钮,在 destroy() 中无需手动移除(基类会处理导航栏按钮的注销)。
  2. 按钮ID应使用前缀避免冲突,如 my-plugin-toggle 而非 toggle。
  3. 使用 collapsable: false 标记核心按钮,确保它们不会在小屏幕上被隐藏。
  4. 避免在按钮回调中做重操作,按钮点击应只是触发异步操作。

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覆盖的原因:

  1. 需要 !important 与PSV的内联样式和选择器优先级斗争
  2. 无法利用SCSS变量体系的便利性
  3. 版本升级时选择器可能变化
  4. 无法覆盖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的插件体系架构与自定义插件开发。核心要点回顾:

  1. 插件架构:PSV采用”核心精简,功能插件化”的设计,所有插件继承自 AbstractPlugin,通过统一的 constructor → init → destroy 生命周期管理。

  2. 注册方式:支持 PluginClass、[PluginClass, config] 和 PluginClass.withConfig(config) 三种等价注册形式。

  3. AbstractPlugin基类:提供了 viewer 引用(万能钥匙)、config 配置、init()/destroy() 生命周期钩子以及静态 id 标识。

  4. 自定义插件开发:遵循”创建类 → 合并配置 → init中订阅事件+创建DOM → destroy中清理一切 → 调用super.destroy()”的标准流程。

  5. 导航栏扩展:通过 AbstractButton + registerButton 可以无缝扩展导航栏按钮,按钮支持禁用、隐藏、激活等状态。

  6. SCSS主题定制:PSV暴露了完整的SCSS变量体系,覆盖加载器、导航栏、按钮、面板、工具提示、通知、覆盖层等所有UI组件,通过 @use ... with () 语法即可完全定制主题。

  7. 插件发布:成熟的插件可以打包为独立npm包发布到社区。

掌握了本章知识,你就能完全掌控PSV的扩展能力——无论是为自己的项目开发轻量级定制功能,还是为开源社区贡献通用插件。


← 上一章 下一章 →