znlgis 博客

GIS开发与技术分享 — GDAL · GeoServer · PostGIS · QGIS · OpenLayers · Cesium · FreeCAD · NPOI

第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 内置了以下按钮(可通过 navbarbuttons 配置排列/移除):

按钮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/coreindex.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/corethree 的依赖,避免重复安装
  • 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的扩展能力——无论是为自己的项目开发轻量级定制功能,还是为开源社区贡献通用插件。


← 上一章 下一章 →