@ericchen1990/pano-view ·

English | 简体中文
用于等距圆柱(equirectangular)与 krpano 风格多分辨率全景查看器的可组合 React 组件。
在在线 playground 中试用这些组件。
目录
- pano-converter
- 数据来源与选型
- 静态项目与 JSON 数据
- 平面图地图
- 常见问题与故障排查
- 兼容 krpano 的瓦片输出
- 安装
- 示例
- 导出组件
- PanoViewer
- DOM Overlay 与弹窗
- Sphere
- PanoVideo
- Tile
- Scenes
- PanoSceneMenu
- PanoFilter
- AutoRotate
- BackgroundAudio
- Gyro
- WebVR
- 鼠标、触控与键盘控件
- 全景坐标事件
- PanoEvents
- PanoContextMenu
- 共享热点约定与可序列化定义
- ImageHotspot
- GraphicHotspot
- 热点模式与缩放行为
- PolygonHotspot
- PolylineHotspot
- SequenceHotspot
- VideoHotspot
- AudioHotspot
- TextHotspot
- IframeHotspot
- Next.js 与 SSR
- 球面多分辨率瓦片
- 无障碍颜色矩阵与标签排版
pano-converter
@ericchen1990/pano-converter 是本仓库中的另一个包。它在 Node.js 中,以及通过 pano-convert CLI,生成立方体与等距圆柱瓦片金字塔,并返回可供 Tile、SphereTile 与 Scenes 使用的 viewerConfig。转换器不依赖 React。
数据来源与选型
查看器接收配置对象,或通过 HTTP 加载 JSON,不直接连接数据库。数据库连接、查询、鉴权和响应映射由宿主后端负责。数据库 API 也可以返回 JSON;这里比较的是静态文件和动态提供的配置,而不是 JSON 格式本身。两种方式的预览图、瓦片和媒体都可以使用对象存储/CDN。
| 维度 | 静态 JSON + CDN | 数据库 + 后端 API |
|---|---|---|
| 配置读取 | 无需查询数据库;仍有存储、请求和流量成本 | 到达后端的请求需要处理,可能查询数据库;缓存可以减少这些开销 |
| 更新 | 生成并发布文件,再管理入口引用和缓存 | 无需发布静态文件;生效时间仍取决于后端和客户端缓存 |
| 权限与个性化 | 公开文件适合公开内容;私有分发需要托管/CDN 访问控制 | 后端可按用户、组织鉴权并返回不同配置 |
| 可用性与维护 | 依赖静态托管/CDN;维护发布及缓存更新流程 | 依赖 API 及其数据库/缓存服务;维护查询、鉴权及缓存失效 |
| 适用场景 | 公开、已发布的漫游;更新较少;读取流量大 | 编辑预览、私有内容、频繁更新和个性化 |
公开、已发布的漫游优先使用 JSON + CDN;编辑预览、私有和个性化内容优先使用数据库 + API。 同时需要两者时,以数据库作为编辑来源,发布时生成 JSON 快照。这种混合方式需要应用自己的发布流程;包不会自动生成或同步快照。
两条路径均不会自动轮询或订阅配置变化。更新已打开的页面时,宿主应用需要重新加载配置,并将新数据传给查看器。未命中缓存的 API 读取可以返回当前数据库值,但不会让已有查看器自动更新。
数据库配置接入
后端先查询漫游并完成鉴权,再将需要公开给查看器的配置字段映射为项目。下例的 readAuthorizedTour 由宿主应用实现,负责数据库读取和访问检查,不是包提供的 API。它返回的场景记录包含转换时保存的精简 viewerConfig、资源目录、视角和热点定义:
import { loadPanoProject } from "@ericchen1990/pano-view/data";
import { readAuthorizedTour } from "./tour-repository";
export async function loadTourFromDatabase(tourId: string) {
const tour = await readAuthorizedTour(tourId);
return loadPanoProject({
schemaVersion: 1,
initialSceneId: tour.initialSceneId,
scenes: tour.scenes.map((scene) => ({
id: scene.id,
title: scene.title,
source: {
viewerConfig: scene.viewerConfig,
baseUrl: scene.assetBaseUrl,
},
initialView: scene.initialView,
hotspots: scene.hotspots,
})),
}, { baseUrl: tour.assetBaseUrl });
}
对象入口必须提供 baseUrl:这里的 tour.assetBaseUrl 是绝对目录 URL,例如 https://cdn.example.com/tours/123/v12/。项目中相对的热点地址基于该目录解析;每个场景可用 source.baseUrl 覆盖全景瓦片/预览图目录。全部场景内嵌 source.viewerConfig 时,该调用不会请求项目或 viewer JSON;渲染时仍会加载预览图、瓦片和媒体。服务端数据工具从 @ericchen1990/pano-view/data 导入。
将返回的 LoadedPanoProject 传给查看器组件;在 Next.js 中,查看器需位于客户端边界:
"use client";
import {
PanoViewer, ProjectScenes, type LoadedPanoProject,
} from "@ericchen1990/pano-view";
export function DatabaseTour({ project }: { project: LoadedPanoProject }) {
return <PanoViewer style={{ height: 600 }}>
<ProjectScenes project={project} transition="dissolve" />
</PanoViewer>;
}
也可以由已鉴权的 JSON 接口返回 PanoProjectDefinition,再调用 loadPanoProject("https://api.example.com/tours/123", { baseUrl: "https://cdn.example.com/tours/123/v12/" });地址不必以 .json 结尾。单场景可用 sceneFromViewerConfig 处理已有配置,或用 loadSceneFromJson 加载返回 schemaVersion: 1 viewer 文档的 API,再通过 Scenes 渲染。请求选项与错误处理见 静态项目与 JSON 数据。私有图片/媒体需单独保护:JSON 请求的认证信息不会传到纹理请求。
缓存与发布更新
project.json(场景组织、视角和热点)、各个 viewer.json(全景渲染配置)和预览图/瓦片/媒体是分别缓存的资源。只刷新项目文件,不会同步刷新地址不变的场景配置或图片。数据库 API 同样可能被后端、CDN 和客户端缓存,因此两种方式都需要缓存策略。
- 优先使用版本化目录: 将完整版本发布到新路径,例如
/tours/123/v12/,包含project.json、场景配置和资源;全部上传完成后,再切换页面或入口引用。变化的资源也要更新版本,并在旧配置仍可能被使用期间保留旧资源。内容固定的版本化文件可用长期缓存;选择版本的可变页面或入口需要短缓存或重新验证。AWS 也建议使用版本化文件名或目录。 - 覆盖相同 URL 时: 等待设定的缓存有效期结束、要求重新验证,或上传后对受影响路径执行 CDN 失效。CDN 失效不会清除浏览器已有文件;新配置的响应头也无法追溯替换仍在有效期内的缓存响应。版本化路径可避免依赖这些副本过期。
?v=12等查询参数仅在 CDN 将其纳入缓存键时有效;JSON 的查询参数不会自动继承到引用的资源。 - 按资源选择响应头:
Cache-Control: no-cache允许存储,但复用前必须验证;no-store要求缓存不存储响应。可变的公开入口可配合ETag等验证器重新验证;私有/个性化 API 响应应明确配置策略,例如private, no-store,避免在用户间共享。还需要配置托管/CDN 规则以遵循预期策略。参见 MDN HTTP 缓存和 Cache-Control。
这些响应头和 CDN 操作由托管平台/后端配置。loader 的 requestInit 仅支持 headers、credentials 和 signal,不提供 Fetch 的 cache 选项,也不会使 CDN 缓存失效或自动刷新已加载的项目。
静态项目与 JSON 数据
无需数据库。转换器生成的 manifest.json 用于归档和还原;viewer.json 只保存渲染配置;应用编写 project.json 组织场景、视角和热点。不要把完整清单直接传给 Scenes。
例如,将下面的 project.json、两个转换输出目录和你的 React 应用部署为静态网站:
{
"schemaVersion": 1,
"initialSceneId": "entrance",
"scenes": [
{
"id": "entrance",
"source": { "viewerUrl": "./panoramas/entrance/viewer.json" },
"initialView": { "yaw": 0, "pitch": 0, "fov": 75 },
"hotspots": [{
"id": "to-hall", "type": "graphic", "label": "Enter hall",
"position": { "yaw": 30, "pitch": -10 },
"graphic": { "kind": "arrow" },
"action": { "type": "scene", "sceneId": "hall" }
}]
},
{
"id": "hall",
"source": { "viewerUrl": "./panoramas/hall/viewer.json" },
"hotspots": [{
"id": "caption", "type": "text",
"position": { "yaw": 0, "pitch": 5 }, "text": "Welcome"
}]
}
]
}
source 必须在 viewerUrl 与内嵌 viewerConfig 中二选一,可加 baseUrl 覆盖该场景的全景资源根目录。title、initialView、hotspots 可省略。未指定 initialSceneId 时使用第一项。场景 ID 全项目唯一,热点 ID 在各场景内唯一,跳转目标必须存在。
loadPanoProject 读取项目及各场景的轻量配置,以最多四个并发请求加载元数据;任一失败会整体报错,不加载所有纹理。返回的 LoadedPanoProject 包含 initialSceneId 和 scenes;每项保存运行时 scene、标题、初始视角和已解析资源地址的热点。可直接传给 ProjectScenes:
import { useEffect, useState } from "react";
import {
PanoViewer, ProjectScenes, loadPanoProject, PanoDataError,
type LoadedPanoProject,
} from "@ericchen1990/pano-view";
export function Tour() {
const [project, setProject] = useState<LoadedPanoProject>();
const [error, setError] = useState<string>();
useEffect(() => {
const controller = new AbortController();
loadPanoProject("/tour/project.json", {
requestInit: { signal: controller.signal },
}).then((result) => {
if (!controller.signal.aborted) setProject(result);
}).catch((cause: unknown) => {
if (!controller.signal.aborted) {
setError(cause instanceof PanoDataError
? `${cause.code}: ${cause.message}` : String(cause));
}
});
return () => controller.abort();
}, []);
if (error) return <p role="alert">{error}</p>;
if (!project) return <p role="status">Loading tour…</p>;
return <PanoViewer style={{ height: 600 }}>
<ProjectScenes project={project} transition="dissolve" />
</PanoViewer>;
}
ProjectScenes 放在 PanoViewer 内,复用全部九类现有热点。action: { type: "scene", sceneId } 点击后切换场景;没有 action 的音频、视频、序列热点点击切换播放。离开场景时卸载媒体,再次进入恢复 JSON 初始播放配置。浏览器仍可能阻止自动播放。
配置了 initialView 的场景在 onSceneReady 时应用视角,缺省字段为 yaw 0、pitch 0、fov 75;未配置时保持当前视角。过渡期间热点隐藏。使用 activeSceneId / onActiveSceneIdChange 可控制当前场景。未受控时内部状态从 project.initialSceneId 开始;project 引用变化会重置该选择。onHotspotClick(event) 中调用 event.preventDefault() 可取消默认动作;onHotspotError 提供 sceneId、热点和原始错误;媒体的原始错误可能是带有 error 字段的事件。renderHotspot(hotspot, context) 返回 undefined 使用默认渲染,返回 null 隐藏;context 包含 sceneId、playing、onClick、onHoverChange、togglePlayback、reportError 和 renderDefault。
ProjectScenes 在项目字段之外继承 Scenes 的过渡与缓存属性:transition、onSceneReady、transitionWaitForDetailMs、onTransitionEnd、onTransitionError、maxTextureMemoryMb、maxConcurrentTileLoads、snapshotMaxPixels(默认 3686400)。不能传 scenes、defaultActiveSceneId 或自行的 renderHotspots——场景列表来自 project,热点由组件渲染。
热点动作与共享弹窗内容
ProjectHotspotAction 是可辨别联合类型。场景跳转保持 { type: "scene", sceneId };{ type: "view", view: { yaw, pitch, fov }, duration?: number } 调整当前场景视角,默认动画 700 毫秒,0 表示立即应用;{ type: "popup", popupId } 请求共享内容。读取 sceneId 前先判断 action.type。显式动作优先于媒体播放,三种动作均可由 onHotspotClick(event).preventDefault() 取消。
项目新增可选 popups: ProjectPopupDefinition[]。每项包含唯一 id、非空 title 和有序 blocks;内容块 ID 在弹窗内唯一:
{
"id": "story",
"title": "About this place",
"blocks": [
{ "id": "intro", "type": "text", "text": "Plain text, including line breaks." },
{ "id": "photo", "type": "image", "src": "media/photo.webp", "alt": "The main hall" },
{ "id": "film", "type": "video", "src": "media/movie.mp4", "poster": "media/photo.webp" },
{ "id": "voice", "type": "audio", "src": "media/story.mp3" }
]
}
内容结构存在 project.json,媒体保留为独立文件。弹窗路径相对项目目录解析,不跟随各全景的 viewer.json。loadPanoProject 返回解析后的 popups;新增可选 resolvePopupAssetUrl(path, context),上下文是 { defaultUrl, popupId, blockId, field },field 为 src 或 poster;返回绝对 URL 或站点根路径。原有 resolveAssetUrl 回调继续只处理场景/热点资源。自定义加载器也可使用 /data 导出的 resolveProjectPopupAssets。JSON 请求的凭证不会自动保护媒体资源。
ProjectScenes.onPopupRequest({ sceneId, hotspot, popup }) 将展示交给宿主 Overlay。缺少处理器或目标弹窗时通过 onHotspotError 报错。独立 UI 入口提供 ProjectPopupDialog,查看器主入口不引入 Dialog 依赖:
import { useState } from "react";
import { PanoOverlay, PanoViewer, ProjectScenes, type LoadedPanoProject, type ProjectPopupDefinition } from "@ericchen1990/pano-view";
import { ProjectPopupDialog } from "@ericchen1990/pano-view/ui";
import "@ericchen1990/pano-view/ui/styles.css";
export function Tour({ project }: { project: LoadedPanoProject }) {
const [popup, setPopup] = useState<ProjectPopupDefinition | null>(null);
return <PanoViewer>
<ProjectScenes project={project} onPopupRequest={({ popup }) => setPopup(popup)}
onActiveSceneIdChange={() => setPopup(null)} />
<PanoOverlay>
<ProjectPopupDialog popup={popup} onOpenChange={(open) => { if (!open) setPopup(null); }} />
</PanoOverlay>
</PanoViewer>;
}
ProjectPopupDialog 接收 popup(null 关闭)、onOpenChange,以及可选 closeLabel 和 onMediaError(block)。文字按纯文本展示,音视频使用原生控件且不自动播放;关闭或切换内容时释放媒体。全屏、尺寸和焦点行为复用 PanoDialog。需要自定义渲染时可替换成自己的 Overlay。
平面图地图
项目可选字段 map 把场景点位放在一张平面图上。没有 map 的旧项目仍然有效,schemaVersion 仍为 1。loadPanoProject 返回的地图已按项目目录解析平面图和点位图标。自定义加载器也可使用 /data 导出的 parsePanoMap 与 resolvePanoMapAssets。
"map": {
"image": "assets/map/floor.webp",
"interaction": "zoom",
"maxScale": 4,
"iconSize": 28,
"presentation": {
"mode": "panel",
"anchor": {
"align": "bottom-left",
"axis": "horizontal",
"inset": 16
},
"width": 240,
"height": 180
},
"controls": { "collapse": true, "zoom": true },
"sector": {
"angle": 70,
"radius": 56,
"followFov": false,
"color": "#38bdf8",
"opacity": 0.55,
"strokeColor": "#ffffff",
"strokeWidth": 1.5,
"strokeOpacity": 0.9
},
"spots": [{
"id": "entrance-spot",
"sceneId": "entrance",
"x": 0.31,
"y": 0.5,
"heading": 20,
"icon": "assets/map/pin.webp"
}]
}
image 是相对项目目录的路径或绝对 URL。spots 必填。点位 id 唯一,每个 sceneId 必须指向项目中的场景。x、y 是归一化图像坐标,范围 [0, 1],原点在左上角。heading 是查看器 yaw 为 0 时、从图像向上方向起算的顺时针角度;扇形实际旋转为 heading + yaw。多个点位可以指向同一场景,扇形画在第一个 sceneId 与当前场景相同的点位上。icon 可选;省略时使用内置标记。
interaction 为 "fixed"(默认)或 "zoom"。固定模式把图像适配进面板,忽略滚轮、拖拽平移和缩放按钮。缩放模式允许滚轮缩放,并在比例大于适配尺寸后拖拽平移。地图上的滚轮不会带动页面滚动。maxScale 是相对适配尺寸的最大倍数,至少为 1,默认 8。dragMotion 控制放大后的平移惯性与边界回弹(默认均开启)。设为 false 可同时关闭,或传 { inertia?: boolean; bounce?: boolean } 分别开关。点位与朝向编辑仍是直接拖动。controls.zoom 仅在 "zoom" 时显示缩放按钮。controls.collapse 显示边缘拉手,把面板滑出屏幕。iconSize 是标记边长,单位为 CSS 像素,默认 28。图像缩放时,图标和扇形保持屏幕尺寸。
sector 可选。angle 是开口角度,大于 0 且小于 360。radius 单位为 CSS 像素,不随图像缩放。followFov 用查看器视场角和地图面板宽高比换算水平视场,代替 angle。没有 gradient 时,填充为纯色 color(默认 #ffffff),不透明度为 opacity(默认 0.55)。gradient.type 为 "radial" 或 "linear",至少两个色标。每个色标包含 [0, 1] 的 offset、color,以及可选 opacity。色标不透明度只描述渐隐形状。sector.opacity 在纯色和渐变两种模式下都缩放整块填充。strokeColor、strokeWidth、strokeOpacity 在宽度大于 0 时绘制描边,描边宽度保持 CSS 像素。
presentation.mode 为 "panel"(默认)或 "dialog"。两者都接受 anchor,以及 width 和 height。anchor 是场景菜单与地图共用的锚定:align 为 top、bottom、left、right 或四个角之一(默认 bottom-left);axis 为 "horizontal" 或 "vertical",只在四角生效(默认 "horizontal");inset 可以是数字、CSS 长度字符串,或 { top, right, bottom, left }(默认 16)。计算时会加上安全区。面板默认 220×160。对话框模式放置一个打开按钮(未写宽高时为 48×48),并在查看器内部打开同一张地图。选中点位后关闭对话框。地图界面锚定在整个查看器 overlay 上,父页面滚动不会移动或改变它的尺寸。四边在给定主轴尺寸时沿该边居中。四角贴住相邻两边。收起时面板保持尺寸,沿外侧滑出:四边朝该边,四角在水平轴向时朝上或下,垂直轴向时朝左或右。拉手留在该边内侧。
文档保存默认值(image、spots、sector、presentation、interaction、maxScale、dragMotion、controls、iconSize)。挂载 PanoMapOverlay 时可用组件 props 覆盖本次展示的尺寸、位置和缩放,不必克隆整份 map。优先级为:内置默认值 → 地图文档 → presentationOverride(形状与 map.presentation 相同)→ 单独 props。点位几何与扇形样式仍写在文档里。selectedSpotId / onSelectedSpotIdChange 会向下传给内部 PanoMap。面板的收起状态由 overlay 内部管理;对话框模式与 editorSurface 会强制关掉边缘拉手。直接使用 PanoMap 时,collapsed 仍只是在宿主框内藏起图面。
挂载 PanoMapOverlay
在 PanoOverlay 中挂载,并把交给 ProjectScenes 的同一个 activeSceneId 传进去。内置 pano-map-* 类名来自 @ericchen1990/pano-view/styles.css。overlay 从查看器读取 yaw 和视场角,不会自己切换场景:在 onSelectScene 里设置当前场景。相机状态来自查看器之外时传入 view;查看器外又不传 view 时,图面停在 yaw 0、pitch 0、fov 75。PanoMap 只负责图面,由宿主自己安排外框。
PanoMap 还可受控选中与收起:selectedSpotId / onSelectedSpotIdChange(省略时内部选中,默认 null);collapsed / onCollapsedChange(非受控初始为展开,并在宿主框内藏起图面);renderChrome 在图面之上渲染宿主控件。在 PanoMapOverlay 上,showCollapseControl 显示共用的边缘拉手。className / style 挂在表面根节点上。
import { useState } from "react";
import {
PanoOverlay, PanoViewer, ProjectScenes, PanoMapOverlay, type LoadedPanoProject,
} from "@ericchen1990/pano-view";
import "@ericchen1990/pano-view/styles.css";
export function Tour({ project }: { project: LoadedPanoProject }) {
const [activeSceneId, setActiveSceneId] = useState(project.initialSceneId);
const map = project.map;
return <PanoViewer style={{ height: 600 }}>
<ProjectScenes
project={project}
activeSceneId={activeSceneId}
onActiveSceneIdChange={setActiveSceneId}
/>
{map ? (
<PanoOverlay>
<PanoMapOverlay
map={map}
activeSceneId={activeSceneId}
onSelectScene={setActiveSceneId}
/>
</PanoOverlay>
) : null}
</PanoViewer>;
}
尺寸、对齐与边距
presentation—"panel"|"dialog"(默认取自文档,否则为"panel")。anchor— 共用的PanoOverlayAnchor。align是八个位置之一(默认"bottom-left")。axis用于四角。inset默认16。width/height— 面板尺寸,或对话框打开按钮尺寸,单位为 CSS 像素。面板默认 220×160;打开按钮默认 48×48。
<PanoMapOverlay
map={map}
activeSceneId={activeSceneId}
onSelectScene={setActiveSceneId}
presentation="panel"
anchor={{ align: "bottom-right", axis: "vertical", inset: 20 }}
width={280}
height={200}
/>
对话框模式
对话框模式放置一个打开按钮,并在查看器内部打开地图。打开按钮尺寸使用 width / height,位置使用同一个 anchor。对话框本体使用另一组尺寸:
dialogWidth/dialogHeight— 期望的对话框宽高,单位为 CSS 像素(默认 720×520)。两者都会按查看器可用区域夹紧,四周各留 16 像素边距。openLabel/closeLabel/title— 打开按钮无障碍名称、关闭按钮文案与对话框标题(默认分别为"Map"、"Close"、"Map")。
选中点位后关闭对话框。
<PanoMapOverlay
map={map}
activeSceneId={activeSceneId}
onSelectScene={setActiveSceneId}
presentation="dialog"
anchor={{ align: "bottom-left", inset: 16 }}
width={52}
height={52}
dialogWidth={640}
dialogHeight={480}
openLabel="Floor plan"
title="Floor plan"
closeLabel="Close"
/>
缩放与控件
下列 props 仅覆盖本次挂载,不写回地图文档:
interaction—"fixed"|"zoom"。maxScale— 相对适配尺寸的最大倍数;小于 1 时按 1 处理。dragMotion—interaction为"zoom"时的平移惯性 / 边界回弹。省略则两者都开;false同时关闭。initialScale— 打开时的缩放倍数,相对适配尺寸,限制在[1, maxScale]。这是视图状态,不会写回文档。图像测量完成后居中应用。仅在initialScale或interaction变化时重置;之后滚轮与拖拽保持用户操作。maxScale变小时只夹紧当前缩放。showZoomControls/showCollapseControl— 强制显示或隐藏缩放、收起控件。iconSize— 标记边长,单位为 CSS 像素。
<PanoMapOverlay
map={map}
activeSceneId={activeSceneId}
onSelectScene={setActiveSceneId}
interaction="zoom"
maxScale={4}
initialScale={1.5}
showZoomControls
showCollapseControl
iconSize={32}
/>
面板边缘拉手可用 toggle 换图标或改样式。对话框模式没有这条拉手。省略 toggle 时仍是默认箭头。icon 传元素(<Icon />),不要传组件类型。图标要随收起状态变化时传函数,参数为 { collapsed }。className 追加到按钮上。style 覆盖背景、边框、颜色和尺寸;位置和 transform 由贴边布局负责,不要在 style 里改。默认箭头使用 currentColor,只改 style.color 就会变色。
<PanoMapOverlay
map={map}
activeSceneId={activeSceneId}
onSelectScene={setActiveSceneId}
showCollapseControl
toggle={{ style: { color: "#7dd3fc" } }}
/>
<PanoMapOverlay
map={map}
activeSceneId={activeSceneId}
onSelectScene={setActiveSceneId}
showCollapseControl
toggle={{
className: "map-tab",
style: {
background: "#0c4a6e",
color: "#7dd3fc",
border: "1px solid #7dd3fc",
},
icon: ({ collapsed }) => (collapsed ? "+" : "–"),
}}
/>
编辑模式
mode="edit" 用于编辑:设置 placementSceneId 后点击地图会新增点位,其 heading 取当前 yaw 的相反数,因此在该 yaw 下扇形指向图像上方;拖动点位改变位置,拖动扇形设置 heading。onEditChange 返回 { spots?, selectedSpotId? }。访客播放保持默认的 mode="view"。editorSurface 会放大编辑面板;传入 width / height 可指定尺寸,否则按查看器比例计算。
运行 pnpm dev 并打开 /map 可查看示例。同一份文档的编辑方式见编辑器平面图。
继续使用 schemaVersion 1,旧项目仍可读取。包含新动作或平面图的项目需使用支持它们的查看器。parsePanoProject(input, { draft: true }) 仅用于编辑草稿,允许空场景列表、未完成的弹窗标题/媒体,以及空的平面图路径;加载、保存和导出仍严格校验。宿主可将同一份文档存入数据库或映射到表,查看器不直接连接数据库。
单图和已有配置可用纯数据入口;Next.js 的查看器组件需位于客户端边界,服务端加载数据则使用 /data:
import {
sceneFromViewerConfig, loadSceneFromJson, loadPanoProject,
} from "@ericchen1990/pano-view/data";
// A JSON API and a different asset/CDN origin.
const scene = await loadSceneFromJson(
"https://api.example.com/rooms/123/viewer",
{ id: "room", baseUrl: "https://cdn.example.com/rooms/123/" },
);
// Existing Node.js conversion results need no extra JSON request.
const inlineScene = sceneFromViewerConfig(result.viewerConfig, {
id: "room", baseUrl: "https://cdn.example.com/rooms/123/",
});
// Use source.viewerConfig instead of source.viewerUrl to inline scene data.
const project = await loadPanoProject({
schemaVersion: 1,
scenes: [{ id: "room", source: { viewerConfig: result.viewerConfig } }],
}, { baseUrl: "https://cdn.example.com/rooms/123/" });
地址与上传映射
| 引用 | 默认基准 |
|---|---|
项目中的 viewerUrl、热点资源、平面图与点位图标,以及 source.baseUrl | 项目根目录 |
外部 viewer.json 的瓦片与预览 | 该 JSON 的目录 |
| 内嵌配置的瓦片与预览 | 项目根目录 |
设置 source.baseUrl 的场景资源 | 指定目录 |
URL 入口以重定向后最终地址所在目录为默认根目录;对象入口必须提供 baseUrl,可用它覆盖项目根目录。目录末尾斜杠自动统一,域名和云前缀无需写回转换 JSON。浏览器接受相对入口地址,Node.js 需要绝对 HTTP(S) 地址。完整资源 URL 不重新拼接,/ 开头的地址相对资源基准的 origin 解析。
上传时若改名或打散目录,使用同步 resolveAssetUrl(path, context)。context 提供 defaultUrl、kind(tile、preview、hotspot 或 map)、sceneId 和可选 hotspotId。返回最终的完整 URL 或站点根路径;不会再次拼接。uploadedUrls 由业务提供:
const project = await loadPanoProject("/tour/project.json", {
resolveAssetUrl(path, context) {
// Map keys must distinguish scenes and, for hotspot assets, hotspot IDs.
const key = `${context.sceneId}/${context.hotspotId ?? ""}/${path}`;
return uploadedUrls[key] ?? context.defaultUrl;
},
});
热点映射涵盖图片、SVG 图片、音频数组、视频 poster、播放图标和 tooltip 图片;文本、CSS 和 SVG path 不作为地址处理。平面图和点位图标走同一个回调,kind 为 "map"。平面图的 sceneId 为空字符串,且没有 hotspotId;点位图标的 sceneId 和 hotspotId 都是点位 id。同一路径会被多种 kind 使用时,上传键要带上 kind。临时签名应由业务预先取得并通过映射返回,不要在每个瓦片请求中异步签名。
requestInit 只接受 headers、credentials、signal,仅用于 JSON 请求。它们不会传到纹理请求,JSON 地址的查询参数也不会自动继承。parsePanoProject(unknown) 可单独校验并复制配置。sceneFromViewerConfig 接受未包装的精简配置;loadSceneFromJson 接受带 schemaVersion: 1 的 viewer.json。
PanoDataError 提供稳定 code:INVALID_OPTIONS、FETCH_FAILED、HTTP_ERROR、INVALID_JSON、INVALID_CONFIG、UNSUPPORTED_SCHEMA_VERSION、UNSUPPORTED_PROJECTION、INVALID_PROJECT、INVALID_REFERENCE、ABORTED;HTTP 错误带 status,项目错误尽可能带 sceneId 和 field。加载成功仅代表配置就绪,纹理就绪仍由 onSceneReady 等回调表示。
完整静态示例见 playground 的 /project 页面和 public/fixtures/projects/tour/project.json。/map 在该漫游上展示平面图 overlay。
兼容 krpano 的瓦片输出
当你需要围绕等距圆柱图像或现有 krpano 立方体瓦片金字塔构建 React 原生查看器时,请使用本包。将 krpano <cube> 的 url 与 multires 属性复制到 Tile 的 urlTemplate 与 multires,并将 <preview url> 复制到 previewUrl;krpano 的磁盘命名与本包默认不同,省略这些值会加载错误的瓦片或预览图。渲染、控件、热点与场景过渡仍由你的 React 应用负责。本包兼容该瓦片输出格式,但与 krpano 无隶属关系。
安装
包
npm
npm install @ericchen1990/pano-view
pnpm
pnpm add @ericchen1990/pano-view
yarn
yarn add @ericchen1990/pano-view
bun
bun add @ericchen1990/pano-view
对等依赖
宿主应用必须提供这些包的兼容版本。对等依赖是否自动安装取决于包管理器及其配置;缺失的依赖应按下列版本范围显式安装。
| 包 | 支持范围 |
|---|---|
react | ^19.0.0 |
react-dom | ^19.0.0 |
three | ^0.185.1 |
@react-three/fiber | ^9.7.0 |
@react-three/drei | ^10.0.0 |
@react-three/xr | ^6.6.0 |
在宿主应用中保持 React、Three.js 与 React Three Fiber 相关包的单一副本。重复的 three 树是空白画布与上下文错误(R3F: Hooks can only be used within the Canvas component)的常见原因。若出现该问题,请检查 npm ls three、pnpm why three、yarn why three 或 bun pm ls 并去重。
样式表(HTML 叠加层)
在宿主应用中导入一次随包样式表。WebGL 全景(PanoViewer、Sphere、Tile、3D 热点)无需该文件即可渲染。内置 HTML 叠加层依赖该文件中带 pano- 前缀的类名完成布局。
这些叠加层包括视频控件、字幕、默认右键菜单、热点 tooltip、供辅助技术使用的界面元素、WebVR 会话 UI,以及平面图。
import "@ericchen1990/pano-view/styles.css";
或在 CSS 中:
@import "@ericchen1990/pano-view/styles.css";
仅当你不使用这些叠加层,或完全通过 className、style 与外观属性重设样式时,可跳过该导入。若不导入样式表,叠加层仍会挂载,但会无样式显示。
示例
为 PanoViewer 提供明确尺寸,并在其内部渲染一个全景源。使用内置 HTML 叠加层 (例如视频控件、字幕、tooltip、右键菜单、WebVR UI 或平面图)时,需导入一次样式表。 下面是每种源的基本用法,完整 props 见对应章节。
等距圆柱图像
import "@ericchen1990/pano-view/styles.css";
import { PanoViewer, Sphere } from "@ericchen1990/pano-view";
export function Panorama() {
return (
<PanoViewer style={{ height: 560 }}>
<Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
</PanoViewer>
);
}
previewUrl 须为低分辨率 2:1 图像,用于在完整图像加载期间显示预览。详见 Sphere。
立方体瓦片
从 krpano XML 复制 url、multires 与预览路径。把 Sphere 换成 Tile 之前,请阅读
迁移指南。
import { PanoViewer, Tile } from "@ericchen1990/pano-view";
export function TilePanorama() {
return (
<PanoViewer style={{ height: 560 }}>
<Tile
baseUrl="/panoramas/room"
urlTemplate="tiles/%s/l%l/%v/l%l_%s_%v_%h.jpg"
multires="512,1000,2000"
previewUrl="preview.jpg"
/>
</PanoViewer>
);
}
占位符与预览面顺序见 Tile。
360 视频
import { PanoViewer, PanoVideo } from "@ericchen1990/pano-view";
export function TourVideo() {
return (
<PanoViewer style={{ height: 560 }}>
<PanoVideo autoPlay muted src="/tour.mp4" />
</PanoViewer>
);
}
多档画质、字幕与自定义控件见 PanoVideo。
多场景
import { useState } from "react";
import { PanoViewer, Scenes, type Scene } from "@ericchen1990/pano-view";
const scenes: Scene[] = [
{ id: "lobby", type: "sphere", src: "/lobby.webp", previewUrl: "/lobby-preview.webp" },
{ id: "roof", type: "sphere", src: "/roof.webp", previewUrl: "/roof-preview.webp" },
];
export function Tour() {
const [activeSceneId, setActiveSceneId] = useState("lobby");
return (
<PanoViewer style={{ height: 560 }}>
<Scenes
activeSceneId={activeSceneId}
onActiveSceneIdChange={setActiveSceneId}
scenes={scenes}
transition="dissolve"
/>
</PanoViewer>
);
}
过渡预设与热点渲染见 Scenes。
导出组件
查看器
PanoViewer— 画布容器、相机、默认控件与命令式视图 APISphere— 单张 2:1 等距圆柱图像SphereTile— 多分辨率等距圆柱瓦片PanoVideo— 全景球面上的 2:1 等距圆柱视频Tile— krpano 风格多分辨率立方体瓦片Scenes— 支持受控与非受控模式的多场景切换BackgroundAudio— 全部场景共用、或按场景切换的背景声PanoFilter— 对全景源应用色彩与艺术滤镜
控件
MouseControls— 拖拽环视与滚轮缩放TouchControls— 单指拖拽与双指缩放KeyboardControls— 方向键环视、FOV 与场景切换AutoRotate— 自动偏航旋转Gyro— 可选的设备方向控制WebVR— 优先 WebXR 的沉浸式 VR,MobileVR 作为回退
事件与界面元素
PanoEvents— 查看器级生命周期与交互回调PanoOverlay— 查看器平面上的 DOM 包裹,全屏时仍可见PanoContextMenu— 右键重置 / 全屏菜单PanoSceneMenu— 供PanoOverlay使用的可滚动缩略图场景菜单PanoVideoControls—PanoVideo的播放界面(默认挂载)
平面图
PanoMap— 平面图、场景点位,以及随水平角转动的扇形PanoMapOverlay— 供PanoOverlay使用的停靠面板或对话框按钮
热点
ImageHotspot— 球面位置上的图像纹理GraphicHotspot— 内置形状或 SVGSequenceHotspot— 精灵图动画VideoHotspot— HTML 视频纹理AudioHotspot— 球面位置上的定向声源TextHotspot— 球面位置上的栅格化纯文本IframeHotspot— 球面位置上的嵌入文档叠加层PolygonHotspot— 闭合球面区域PolylineHotspot— 开放球面路径
Hook 与辅助工具
usePanoViewer— 将 viewer ref 封装成可复用的父组件侧命令usePanoEvents— 在自定义子组件内订阅查看器事件(PanoEvents)- 坐标辅助函数 —
normalizePanoPosition、normalizePanoYaw、clampPanoPitch、panoPositionToVector3、vector3ToPanoPosition(全景坐标事件) cycleSceneId— 循环上一/下一场景 id(KeyboardControls)- 右键菜单辅助 —
createPanoContextMenuPresets、composePanoContextMenuItems及相关 API(PanoContextMenu) - 多边形 / 折线校验 —
validatePolygonVertices、validatePolylineVertices、unwrapPolygonVertices
DOM Overlay 与弹窗
普通 DOM 界面优先使用 PanoOverlay,位于 Three.js 渲染树之外,挂在全屏根节点内的查看器平面上,因此全屏时仍可见。把它作为 PanoViewer 的直接子节点(或包在其下的 Fragment 里),与场景内容并列。renderOverlay 仍可用,适合回调式挂载:
<PanoViewer style={{ height: 560 }}>
<Sphere src="/pano.jpg" />
<PanoOverlay>
<PanoSceneMenu items={items} activeId={id} onSelect={setId} />
<button
type="button"
style={{ position: "absolute", right: 16, bottom: 16, pointerEvents: "auto" }}
onClick={() => void viewer.toggleFullscreen()}
>
Fullscreen
</button>
</PanoOverlay>
</PanoViewer>
该层已是 position: absolute; inset: 0; pointer-events: none。子节点自行定位,可点击节点设置 pointerEvents: "auto"。同时使用时,renderOverlay 先渲染,其后按出现顺序渲染各个 PanoOverlay。usePanoOverlay() 必须在 PanoOverlay(或 renderOverlay)子树内调用,返回 portalContainer、size(viewer 宽高)、visibleRect(可见矩形)与 isFullscreen。usePanoModal(open) 在 open === true 时占用该 viewer 的模态锁(暂停输入与自动旋转、背景 inert、关闭右键菜单);不负责焦点陷阱。PanoOverlayPortalProvider 接收必填的 container 与 children,只替换后代读到的 portalContainer;size / visibleRect 仍是整个 viewer。字段含义、引用计数与第三方 Dialog 接线见独立的 弹窗使用与自定义样式指南。可选 /ui 入口提供 PanoDialog 与组成组件。指南随 npm 包分发;在本仓库运行 pnpm dev,打开 /overlays 可体验示例。
默认弹窗
从快速开始接入,再定制默认外观。默认外观单独导入 @ericchen1990/pano-view/ui/styles.css,也可不导入。
多个热点使用场景/热点到内容的完整映射示例:五份内容、一个共享弹窗、对应项目 JSON,以及场景内热点 ID 的处理。
组成组件与已有 Dialog 接入
自定义标题栏、内容区和底部操作使用组成组件;已有 Dialog 使用本地 Portal 容器与 usePanoModal(open) 完成接入。全屏功能放在 viewer 全局界面,不放入弹窗内容。
PanoViewer
PanoViewer 负责画布、透视相机与控件。为其容器指定明确尺寸,并在内部放置一个全景源。
公开 API 中的角度以度为单位。偏航(yaw)为正表示向右看,俯仰(pitch)为正表示向上看。
import "@ericchen1990/pano-view/styles.css";
import {
PanoViewer,
Sphere,
AutoRotate,
usePanoViewer,
} from "@ericchen1990/pano-view";
export function ControlledExample() {
const viewer = usePanoViewer();
return (
<>
<button onClick={() => viewer.setView({ yaw: 90, fov: 55 })}>
Look right
</button>
<button onClick={() => void viewer.lookTo({ yaw: -45, pitch: 8 })}>
Animate left
</button>
<PanoViewer
ref={viewer.ref}
controls={{
inertia: true,
rotateDamping: 14,
zoomDamping: 16,
}}
initialView={{ yaw: 0, pitch: 0, fov: 75 }}
minFov={30}
maxFov={100}
onViewChange={(view) => console.log(view)}
style={{ height: 560 }}
>
<AutoRotate enabled speed={18} acceleration={18} startDelay={1_000} />
<Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
</PanoViewer>
</>
);
}
句柄通过单个 ref 提供视图、全屏、场景、VR、视频与背景音乐控制,供宿主构建自定义界面:
视图 — getView、setView、reset、lookTo、moveTo、zoomTo、lookToHotspot。
setView 与 reset 仍立即生效。导航方法返回
Promise<{ status: "completed" | "cancelled" | "not-found" }>;新的导航或用户操作会取消前一个导航。默认时长为 700ms,缓动为
easeInOutCubic,yaw 默认沿最短路径变化。lookTo 与 lookToHotspot
未传 fov 时保持当前 FOV。
const result = await viewer.lookToHotspot("visitor-guide", { fov: 55 });
if (result.status === "not-found") {
// 该热点尚未挂载在这个 PanoViewer 中。
}
await viewer.moveTo({ yaw: 170, pitch: 0 }, { shortestPath: false });
await viewer.zoomTo(48, { duration: 400, easing: "linear" });
所有已挂载热点都可作为目标,包括使用球面中心点的多边形与折线。每个 viewer 内的热点 id 必须唯一。
全屏 — enterFullscreen、exitFullscreen、toggleFullscreen、isFullscreen。
场景(需 <Scenes />)— setScene(id)、nextScene、previousScene、getActiveSceneId、getSceneIds、isSceneTransitioning。Scenes 支持 defaultActiveSceneId(非受控)或 activeSceneId(受控)。受控模式下命令式调用会触发 onActiveSceneIdChange。
WebVR(需 <WebVR />)— enterVR、exitVR、toggleVR、isVRAvailable、isVREnabled、getVRMode、getVRState、subscribeVR、requestVRPermission。用 subscribeVR 配合 useSyncExternalStore 做响应式进入 / 退出按钮;内置 chrome 不再渲染「进入 VR」。
视频(需 <PanoVideo />)— getVideo 返回 PanoVideoController,subscribeVideo 订阅视频控制器与播放状态变更,另有快捷方法 playVideo、pauseVideo、toggleVideo、seekVideo、setVideoVolume、setVideoMuted、toggleVideoMuted。
背景音乐(需 <BackgroundAudio />)— getBackgroundAudio 返回 BackgroundAudioController,subscribeBackgroundAudio 订阅背景音频控制器与播放状态变更,另有快捷方法 playBackgroundAudio、pauseBackgroundAudio、toggleBackgroundAudio、setBackgroundAudioVolume、setBackgroundAudioMuted、toggleBackgroundAudioMuted。BackgroundAudio 支持 playing(受控)或 defaultPlaying(非受控)。
属性
initialView— 省略时 yaw0、pitch0、fov75。只填部分字段时其余用上述默认值。minFov/maxFov— 默认30/100。未单独配置viewLimits.fov时作为 FOV 上下限。viewLimits— 相机中心的 yaw / pitch / fov 范围;场景级限制会覆盖对应轴。详见相机中心范围。dpr— 画布像素比,默认[1, 2](按设备像素比夹在 1–2)。也可传单个数字。controls—false关掉全部用户输入。对象可配置共享项与mouse/touch/keyboard通道;通道细节见鼠标、触控与键盘控件。共享项中:enabled默认true,inertia默认true,invert默认false,bouncingLimits默认false,frictionStop默认0.01,rotateSpeed默认0.35,zoomSpeed默认0.08,rotateDamping/zoomDamping默认14/16。fovSpeed省略时不额外限速;滚轮按单次位移deltaY * zoomSpeed夹到±fovSpeed,键盘按键按「度/秒 × 帧时间」再与fovSpeed比较。cursors—true/ 省略使用 grab / grabbing / pointer / move;false不改画布光标;对象可覆盖四个状态。contextMenu—true/ 省略挂载「重置视图 + 全屏」;false不挂默认菜单;对象可改外观或items/prepend/append。详见 PanoContextMenu。mediaActivation— 有播放意图的子组件时默认开启;false关闭自动激活。renderOverlay— 可选的回调式 DOM UI,在画布外、全屏内渲染;回调收到portalContainer、size、visibleRect、isFullscreen。优先用子组件<PanoOverlay>。onViewChange— 参数为{ yaw, pitch, fov }(度)。onPanoramaClick/onPanoramaDoubleClick/onPanoramaPointerMove— 参数为全景指针事件:position(yaw / pitch)、nativeEvent、button、buttons、clientX、clientY,以及可选的pointerId。- 其余标准
div属性(如className、style、id)可原样传入;类型已去掉冲突的onChange与contextMenu。
移动端媒体激活
默认情况下,只要子组件带有初始播放意图(PanoVideo autoPlay、播放中的 BackgroundAudio、VideoHotspot 或 AudioHotspot),查看器会在浏览器需要用户手势时于首次交互静默解锁有声播放;若自动播放策略已允许,则立即激活。静音视频仍会内联预览;激活在同一用户手势内恢复 Web Audio,再把播放选择交给宿主,避免视频、BGM 与定点声同时出声。传 mediaActivation={false} 可保留 2.x 的无自动激活行为。
<PanoViewer
mediaActivation={{
onActivate: (media) => {
// 不要 await;必须在这次点击的同步调用栈内启动有声媒体。
void media.playVideo({ unmute: true });
},
}}
style={{ height: "min(560px, 68dvh)" }}
>
<PanoVideo autoPlay src="/tour.mp4" />
</PanoViewer>
onActivate 的 media 提供 resumeAudio()、playVideo({ unmute }) 和 playBackgroundAudio()。背景音乐若使用受控 playing,仍由宿主在回调内更新该状态;viewer.activateMedia() 可供自定义控件复用同一流程。
依赖未挂载子组件的方法(如无 <WebVR /> 时调 enterVR)不会执行相应操作;返回值按方法约定为 false、undefined、null 或对应的 Promise。
全屏
import { PanoViewer, Sphere, usePanoViewer } from "@ericchen1990/pano-view";
function FullscreenExample() {
const viewer = usePanoViewer();
return (
<>
<button onClick={() => void viewer.enterFullscreen()}>进入全屏</button>
<button onClick={() => void viewer.exitFullscreen()}>退出全屏</button>
<button onClick={() => console.log(viewer.isFullscreen())}>是否全屏?</button>
<PanoViewer ref={viewer.ref} style={{ height: 560 }}>
<Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
</PanoViewer>
</>
);
}
iPhone Safari 不支持 HTML5 Fullscreen API。在 iPhone 上调用 enterFullscreen() 或进入 MobileVR 时,查看器会铺满 visual viewport,并在横屏滚动页面,以便收起 Safari 的地址栏和工具栏。若栏仍可见,界面会提示用户滑动。宿主页面应设置:
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
命令式切换场景
使用 usePanoViewer() 可将父组件侧命令缩短为 viewer.setScene("roof")。当宿主 UI 需要响应式显示当前场景 id 时,仍建议继续使用受控 activeSceneId。若使用非受控模式,defaultActiveSceneId 依然允许命令式调用直接更新内部状态。
import { useState } from "react";
import {
PanoViewer,
Scenes,
usePanoViewer,
type Scene,
} from "@ericchen1990/pano-view";
const scenes: Scene[] = [
{ id: "lobby", type: "sphere", src: "/lobby.webp", previewUrl: "/lobby-preview.webp" },
{ id: "roof", type: "sphere", src: "/roof.webp", previewUrl: "/roof-preview.webp" },
];
function SceneExample() {
const viewer = usePanoViewer();
const [activeSceneId, setActiveSceneId] = useState("lobby");
return (
<>
<button onClick={() => setActiveSceneId("roof")}>去天台</button>
<button onClick={() => viewer.nextScene()}>下一场景</button>
<button onClick={() => viewer.previousScene()}>上一场景</button>
<button onClick={() => viewer.setScene("roof")}>通过 hook 跳转</button>
<p>当前: {activeSceneId}</p>
<PanoViewer ref={viewer.ref} style={{ height: 560 }}>
<Scenes
activeSceneId={activeSceneId}
onActiveSceneIdChange={setActiveSceneId}
scenes={scenes}
transition="dissolve"
/>
</PanoViewer>
</>
);
}
WebVR
enterVR 与 requestVRPermission 须在用户手势中调用。通过 getVRState /
subscribeVR 由宿主 UI 驱动进入;会话 chrome 只显示退出 VR 与 MobileVR 设置
(空闲数秒后淡出)。
import { useSyncExternalStore } from "react";
import { PanoViewer, Sphere, WebVR, usePanoViewer } from "@ericchen1990/pano-view";
function VRExample() {
const viewer = usePanoViewer();
const vr = useSyncExternalStore(
viewer.subscribeVR,
viewer.getVRState,
viewer.getVRState,
);
return (
<>
<button
disabled={!vr.available}
onClick={() => void (vr.enabled ? viewer.exitVR() : viewer.enterVR())}
>
{vr.enabled ? "退出 VR" : "进入 VR"}
</button>
<PanoViewer ref={viewer.ref} style={{ height: 560 }}>
<WebVR />
<Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
</PanoViewer>
</>
);
}
自定义视频控件
将 PanoVideo 的 controls 设为 false,通过 usePanoViewer() 驱动播放。配合 useSyncExternalStore 使用 subscribeVideo 获取响应式快照。
import { useSyncExternalStore } from "react";
import {
PanoViewer,
PanoVideo,
usePanoViewer,
} from "@ericchen1990/pano-view";
function VideoExample() {
const viewer = usePanoViewer();
const snapshot = useSyncExternalStore(
viewer.subscribeVideo,
() => viewer.getVideo()?.getSnapshot() ?? null,
() => null,
);
return (
<>
<button onClick={() => void viewer.toggleVideo()}>
{snapshot?.playing ? "暂停" : "播放"}
</button>
<button onClick={() => viewer.toggleVideoMuted()}>
{snapshot?.muted ? "取消静音" : "静音"}
</button>
{snapshot && (
<input
type="range"
min={0}
max={snapshot.duration}
value={snapshot.currentTime}
onChange={(e) => viewer.seekVideo(Number(e.target.value))}
/>
)}
<PanoViewer ref={viewer.ref} style={{ height: 560 }}>
<PanoVideo controls={false} src="/tour.mp4" />
</PanoViewer>
</>
);
}
自定义背景音乐控件
BackgroundAudio 支持非受控模式 — 省略 playing,改用 defaultPlaying。通过 usePanoViewer() 驱动。
import { useSyncExternalStore } from "react";
import {
PanoViewer,
Sphere,
BackgroundAudio,
usePanoViewer,
} from "@ericchen1990/pano-view";
function BGMExample() {
const viewer = usePanoViewer();
const snapshot = useSyncExternalStore(
viewer.subscribeBackgroundAudio,
() => viewer.getBackgroundAudio()?.getSnapshot() ?? null,
() => null,
);
return (
<>
<button onClick={() => viewer.toggleBackgroundAudio()}>
{snapshot?.playing ? "暂停 BGM" : "播放 BGM"}
</button>
<button onClick={() => viewer.toggleBackgroundAudioMuted()}>
{snapshot?.muted ? "取消静音" : "静音"}
</button>
<input
type="range"
min={0}
max={1}
step={0.01}
value={snapshot?.volume ?? 1}
onChange={(e) => viewer.setBackgroundAudioVolume(Number(e.target.value))}
/>
<PanoViewer ref={viewer.ref} style={{ height: 560 }}>
<BackgroundAudio defaultPlaying src="/bgm.mp3" />
<Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
</PanoViewer>
</>
);
}
综合示例
import {
PanoViewer,
Scenes,
WebVR,
PanoVideo,
BackgroundAudio,
usePanoViewer,
} from "@ericchen1990/pano-view";
function TourPlayer() {
const viewer = usePanoViewer();
return (
<>
<nav>
<button onClick={() => viewer.previousScene()}>← 上一场景</button>
<button onClick={() => viewer.nextScene()}>下一场景 →</button>
<button onClick={() => void viewer.toggleFullscreen()}>全屏</button>
<button onClick={() => void viewer.enterVR()}>进入 VR</button>
<button onClick={() => viewer.toggleBackgroundAudio()}>BGM</button>
</nav>
<PanoViewer ref={viewer.ref} style={{ height: "100vh" }}>
<WebVR />
<BackgroundAudio defaultPlaying src="/bgm.mp3" />
<Scenes defaultActiveSceneId="lobby" scenes={scenes} transition="dissolve" />
</PanoViewer>
</>
);
}
两个已废弃的自动旋转方法(startAutoRotate、stopAutoRotate)继续保留以兼容。鼠标、触控与键盘输入默认启用 — 普通浏览无需再渲染控件组件。仅当需要覆盖属性时渲染 MouseControls、TouchControls 或 KeyboardControls(子组件会替换该通道的默认实例)。通过 controls 调节共享行为(inertia、invert、bouncingLimits、fovSpeed、frictionStop、rotateDamping、zoomDamping,以及顶层的 rotateSpeed / zoomSpeed)。将 controls.mouse / touch / keyboard 设为 false 可关闭某通道,或传入选项对象(含 enabled)在不挂载子组件的情况下覆盖默认值。自动旋转在渲染 AutoRotate 之前处于关闭状态。默认右键菜单(重置视图 / 进入全屏)也会挂载 — 见 PanoContextMenu。
鼠标、笔和单指拖拽会锚定按下的全景点,并在每个渲染帧更新旋转;使用短暂的时间平滑吸收输入采样的波动(匀速移动时约 10ms 跟随延迟)。指针停住并收敛后,按下点会与指针对齐;松手时也会立刻完成这次对齐。甩动惯性只跟随最近一段指针运动,松手前如果已经停住,不会把更早的速度继续带出去。视角边界或极点附近可能限制跟随。rotateSpeed 默认 0.35 表示 1:1 跟随,其他值按其相对倍率调整指针位移,0 禁止旋转;invert 反转方向。双指缩放和滚轮继续更新目标 FOV,相机平滑跟随。拖拽释放后保留惯性。rotateDamping 与 zoomDamping 控制跟随速度(旋转平滑不作用于锚定拖拽期间),单位为秒⁻¹(默认分别为 14 与 16);较小的正值使相机更缓慢地接近目标视角,0 表示该轴不做平滑。两者必须为非负有限数。命令式 setView() 与 reset() 仍为即时生效。
画布光标默认为 grab,鼠标拖拽时为 grabbing,悬停在可交互热点上为 pointer,拖拽热点时为 move。可通过 cursors(default、dragging、hotspot、hotspotDragging)覆盖,或传 cursors={false} 保持画布光标不变。热点可设置 cursor,仅替换该热点的悬停光标。
<PanoViewer
cursors={{ default: "grab", dragging: "grabbing", hotspot: "pointer" }}
style={{ height: 560 }}
>
<ImageHotspot
id="door"
cursor="zoom-in"
position={{ yaw: 24, pitch: -6 }}
src="/hotspots/door.webp"
/>
</PanoViewer>
Sphere
Sphere 需要 2:1 等距圆柱图像。当源图像的前向需要水平调整时,使用 yawOffset。
Sphere 将完整的 src 图像加载为单张纹理。高分辨率图像会增加下载、解码和 GPU 纹理内存开销;超出设备纹理尺寸或内存限制时,可能加载失败或导致 WebGL 上下文丢失,内存不足时也可能导致页面崩溃。当前球面渲染在顶部、底部极点附近仍可能出现可见瑕疵。
高分辨率全景优先使用立方体瓦片 Tile,通过 @ericchen1990/pano-converter 生成资源。查看器会按视野加载所需层级的瓦片,避免使用单张超大纹理。已有等距圆柱瓦片可使用 SphereTile。
previewUrl 为必填项,在 src 加载期间显示低分辨率 2:1 图像。应提供尺寸较小的预览图,以减少首次显示的下载、解码和纹理内存开销。相对路径相对于 src 所在目录解析;根绝对路径与 http(s)、blob:、data: URL 原样使用。对于 krpano 球面场景,将 <preview url> 复制到 previewUrl。该预览必须是等距圆柱,而非立方体条带。
"use client";
import { PanoViewer, Sphere } from "@ericchen1990/pano-view";
export function SphereExample() {
return (
<PanoViewer style={{ width: "100%", height: 560 }}>
<Sphere
src="/panoramas/room.webp"
previewUrl="preview.webp"
/>
</PanoViewer>
);
}
属性
src— 必填,完整 2:1 等距圆柱图像。previewUrl— 必填,加载期间显示的低分辨率 2:1 预览。yawOffset— 水平旋转,单位为度,默认0。源图前向与查看器 yaw0不一致时设置。visible— 默认true。false时保持挂载并预加载,但不绘制到球面。onReady— 首个可绘制纹理(预览或src)就绪时触发一次。onLoad—src纹理加载完成,参数为 Three.jsTexture。onError—src加载失败。onPreviewError— 预览加载失败;src仍会继续加载。
PanoVideo
PanoVideo 将 2:1 等距圆柱视频映射到与 Sphere 相同的内表面球体上。可传单个 src,或传含多档质量的 variants,以便浏览器选择 mp4 / webm,且控制栏可切换分辨率。切换质量时保留当前时间与播放/暂停状态。
默认播放栏挂载在查看器叠加层上:播放/暂停、进度、时间、音量、倍速、分辨率、字幕与全屏。当浏览器允许 HTMLMediaElement.volume 时,音量为悬停(或点击)垂直滑块;iOS Safari 仅暴露静音。倍速菜单列出当前元素接受的速率。字幕菜单还包含字幕字号与背景不透明度的设置面板。查看器较窄时,倍速、画质与字幕会收入「更多」菜单。传 controls={false} 可隐藏;传外观对象可重设默认实例样式;或将 PanoVideoControls 作为 PanoViewer 子组件以替换该实例。
字幕使用 WebVTT tracks。视频元素以透明的 1×1 像素节点挂载在查看器内,字幕提示文本(cue)通过屏幕空间叠加层渲染。captions={false} 会隐藏叠加层与语言菜单;对象会与默认外观合并(color、background、fontSize、fontFamily、textShadow、padding、borderRadius、maxWidth、bottom)。这些值作为字幕初始外观;观众仍可在运行时通过默认控制栏菜单调整字幕大小与背景。
"use client";
import { PanoViewer, PanoVideo } from "@ericchen1990/pano-view";
export function VideoExample() {
return (
<PanoViewer style={{ width: "100%", height: 560 }}>
<PanoVideo
defaultVariantId="1024"
variants={[
{
id: "1024",
label: "1024p",
poster: "/video/1024-poster.jpg",
sources: [
{ src: "/video/1024.mp4", type: "video/mp4" },
{ src: "/video/1024.webm", type: "video/webm" },
],
},
{
id: "1920",
label: "1920p",
poster: "/video/1920-poster.jpg",
sources: [
{ src: "/video/1920.mp4", type: "video/mp4" },
{ src: "/video/1920.webm", type: "video/webm" },
],
},
]}
tracks={[
{ src: "/video/en.vtt", srcLang: "en", label: "English", default: true },
{ src: "/video/zh.vtt", srcLang: "zh", label: "中文" },
]}
captions={{ fontSize: 16, color: "#fff" }}
muted
/>
</PanoViewer>
);
}
muted 默认为 true,以提高自动播放的兼容性;实际播放仍受浏览器策略约束。playsInline 也默认开启,并写入 playsinline、webkit-playsinline 以及 X5 的 h5-page 属性,向 iPhone Safari、微信、QQ 和 UC 请求页内播放;实际行为取决于浏览器及其版本。画中画和远程播放保持关闭,以便该元素持续作为球面纹理。元素会以 1×1 像素挂在查看器内。非静音播放可能需要用户手势;play() 被拒绝时 onPlaybackStateChange 会收到 "blocked"。非同源的远程视频与 VTT 文件应设置 crossOrigin(通常为 "anonymous")。移动端请提供 H.264 + AAC 的 MP4 备选源、poster、CORS 与 Range 支持。
属性
src— 单文件快捷方式;与variants二选一。poster— 封面图。多档时也可写在各variants[].poster。variants/defaultVariantId— 多分辨率列表;找不到defaultVariantId时回退到第一项。切换档位保留当前时间与播放/暂停状态。tracks— WebVTT 字幕轨。每项可含src、srcLang、label、default、id(省略时用srcLang)、kind("subtitles"或"captions",默认"subtitles")。captions—true/ 省略显示默认字幕层;false隐藏叠加层与语言菜单;对象与默认外观合并。默认外观:color#ffffff,backgroundrgba(0, 0, 0, 0.55),fontSize16,fontFamilyInter / system-ui,fontWeight500,textShadow0 1px 2px rgba(0, 0, 0, 0.8),padding"6px 10px",borderRadius6,maxWidth"72%",bottom72,lineHeight1.35。controls—true/ 省略挂载默认播放栏;false隐藏;对象重设默认实例外观。外观字段:background(默认rgba(16, 16, 16, 0.88))、color(#f4f4f4)、accent(#75cbd3)、borderRadius(8)、fontSize(12)、padding("8px 10px")。也可将PanoVideoControls作为PanoViewer子组件替换默认条(同样接收appearance)。yawOffset— 水平偏移,单位为度,默认0。loop— 默认false。muted— 默认true。volume— 默认1,范围 0–1。autoPlay— 默认false。为true时会作为媒体激活的触发条件。playsInline— 默认true。preload—"none"|"metadata"|"auto",默认"metadata"。crossOrigin—""|"anonymous"|"use-credentials";省略则不设。playbackRates— 倍速菜单列表,默认[0.5, 1, 1.25, 1.5, 2]。visible— 默认true;false时预加载但不绘制。onLoad— 视频可播放时。onError—{ source: "poster" | "video" | "track", error }。onPlaybackStateChange—"playing" | "paused" | "ended" | "blocked"。onEnded— 播放到结尾(未循环时)。onVariantChange/onTrackChange— 当前档位 id,或当前字幕轨 id(无轨时为null)。
Tile
Tile 渲染六个朝内的立方体面,并仅加载当前视野附近的瓦片。省略 urlTemplate 时,相对于 baseUrl 的路径使用本包默认:
tiles/%s/l%l/%v/l%l_%s_%h_%v.webp
展开为 tiles/{face}/l{level}/{row}/l{level}_{face}_{col}_{row}.webp。面为 f、r、b、l、u、d;行与列从 1 起计。
import { PanoViewer, Tile } from "@ericchen1990/pano-view";
export function TileExample() {
return (
<PanoViewer style={{ width: "100%", height: 560 }}>
<Tile
baseUrl="https://cdn.example.com/panoramas/room"
multires="512,500,1000,2000"
previewUrl="previews/cube-vertical.webp"
/>
</PanoViewer>
);
}
krpano 立方体瓦片输出不遵循该默认。从 krpano XML 读取 url、multires 与预览路径(<cube>、<preview>)并传入。若这些值缺失或与磁盘文件不匹配,瓦片会解析到错误的立方体面、层级或行列,导致画面缺失或错位。
| krpano | Tile / TileScene 属性 |
|---|---|
<cube url> | urlTemplate |
<cube multires> | multires |
<preview url> | previewUrl |
<preview striporder> | previewFaceOrder |
Sphere 使用相同的 <preview url> → previewUrl 映射;该文件必须是 2:1 等距圆柱,而非立方体条带。
krpano 文档中的简写语法:
<preview url="preview.jpg" />
<image>
<cube url="pano_%s_%l_%v_%h.jpg"
multires="512,1024,2048,4096"
/>
</image>
对应为:
<Tile
baseUrl="/panoramas/room"
urlTemplate="pano_%s_%l_%v_%h.jpg"
multires="512,1024,2048,4096"
previewUrl="preview.jpg"
/>
MAKE PANO (MULTIRES) 文件夹布局常为 tiles/%s/l%l/%v/l%l_%s_%v_%h.jpg,场景根目录有 preview.jpg。该文件名为 %v_%h(先行后列)。本包默认瓦片模板为 %h_%v(先列后行)。请复制 XML 中的值,不要假设两种约定一致。
multires 的第一个值为瓦片尺寸。其余值按 l1、l2 及更高层级的顺序,指定递增的立方体面边长。previewUrl 为必填。相对路径相对于 baseUrl 解析;根绝对路径与 http(s) URL 原样使用。典型的 krpano MAKE PANO (MULTIRES) 场景如下:
<Tile
baseUrl="/panoramas/room"
urlTemplate="tiles/%s/l%l/%v/l%l_%s_%v_%h.jpg"
multires="512,1000,2000"
previewUrl="preview.jpg"
previewFaceOrder={["l", "f", "r", "b", "u", "d"]}
/>
previewFaceOrder 必须按预览图从上到下的实际顺序列出六个面代码。默认顺序为 l、f、r、b、u、d。预览图为竖向 1×6 立方体条带。krpano 的 thumb.jpg 通常是独立缩略图,不能替代该预览条带。
快速旋转或缩放时,已加载瓦片保持可见;新进入视野的瓦片会使用其父级或预览作为局部回退。
对于非 krpano XML 的布局,可用占位符模板或解析器覆盖默认。模板与解析器均可提供相对于 baseUrl 的路径或绝对 URL:
<Tile
baseUrl="/panoramas/room"
multires={{ tileSize: 512, levels: [500, 1000, 2000] }}
previewUrl="previews/cube-vertical.webp"
urlTemplate="assets/%s/%l/%v_%h.webp"
resolveTileUrl={({ face, level, row, col }) =>
`api/tile/${face}/${level}/${row}/${col}`
}
/>
resolveTileUrl 优先于 urlTemplate。
对于 krpano 立方体瓦片模板,%s 为立方体面,%l 为多分辨率层级。水平瓦片索引支持等价的 %h、%x、%u、%c 占位符;垂直索引支持 %v、%y、%w、%r。在索引占位符前加零可填充:例如 %0h 为两位水平索引,%00v 为三位垂直索引。立体 %t 与帧 %f 占位符不适用于表示单一非立体立方体全景的 Tile。
属性
baseUrl/multires/previewUrl— 必填。multires可为字符串或{ tileSize, levels }。previewFaceOrder— 默认["l", "f", "r", "b", "u", "d"],须恰好六个面。urlTemplate— 省略时用本包默认路径;resolveTileUrl优先于模板。maxTextureMemoryMb— 独立Tile默认128。maxConcurrentLoads— 独立Tile默认8(不要与SphereTile/Scenes的4混淆)。retryCount— 首次失败后的重试次数,默认1。loadMode—"full"(默认)、"preview"或"base"。visible— 默认true;false时仅预加载。onReady— 预览图可绘制时触发(预览失败时仍可能由瓦片就绪触发,见实现)。onPreviewError— 预览加载失败。onLoadProgress—{ requested, loaded, failed, active, queued }。onTileError—{ address, url, error }。onLevelChange— 当前立方体层级,从 1 起计(SphereTile的逻辑层级从 0 起,不要混用)。
放进 Scenes 的 type: "tile" 场景后,共享纹理缓存会覆盖场景上的 maxTextureMemoryMb、maxConcurrentLoads、retryCount(共享提供者将 retry 写为 1)。可见性、loadMode 与上述回调由 Scenes 接管,不要写在场景对象上。
Scenes
Scenes 支持单张等距圆柱图像、立方体瓦片与等距圆柱瓦片场景之间的切换,并支持受控与非受控模式。过渡使用 GPU 帧缓冲快照与目标场景进行混合。目标场景在隐藏状态下加载预览;瓦片场景同时加载视野附近的最低分辨率层级。目标场景首次可绘制后,当前帧缓冲成为临时 GPU 纹理,并释放旧场景的源纹理。目标随即进入 full:转场期间已经清晰的瓦片立刻盖住预览,尚未就绪的区域继续显示父层瓦片或预览。旧场景释放后,目标场景才开始加载细节瓦片;瓦片选择范围包含当前视野及其周边的少量预留区域。
import {
Scenes,
PanoViewer,
type Scene,
} from "@ericchen1990/pano-view";
const scenes: Scene[] = [
{ id: "lobby", type: "sphere", src: "/panoramas/lobby.webp", previewUrl: "preview.webp" },
{
id: "terrace",
type: "tile",
baseUrl: "/panoramas/terrace",
multires: "512,1000,2000",
urlTemplate: "tiles/%s/l%l/%v/l%l_%s_%v_%h.webp",
previewUrl: "previews/cube-vertical.webp",
},
];
<PanoViewer style={{ height: 560 }}>
<Scenes
scenes={scenes}
activeSceneId={activeSceneId}
transition="ellipticZoomOpen"
renderHotspots={(scene) => <SceneHotspots sceneId={scene.id} />}
onTransitionError={({ sceneId }) => console.warn("Could not load", sceneId)}
/>
</PanoViewer>;
可用的 krpano 风格预设包括 none、crossfade、zoom、blackout、whiteFlash、slideRightToLeft、slideTopToBottom、slideDiagonal、circleOpen、verticalOpen、horizontalOpen、ellipticZoomOpen。网格类预设为 pixelate、gridWipe、gridWipeUp、gridWipeRight、gridWipeDiagonal、gridWipeCenter、gridWipeChecker、hexDissolve。电影感预设为 dissolve、shatter、particles、glitch、swirl、clockWipe、ripple、zoomBlur、filmBurn。它们均在目标全景之上混合 GPU 帧缓冲快照,不会混合两个完整 360 纹理。传 { preset: "crossfade", duration: 0.6 } 可覆盖预设时长。
transitionWaitForDetailMs 默认为 0:目标一旦可绘制就开始转场,高分辨率细节在转场期间继续加载。正数(例如 200)从旧画面快照完成、目标进入 full 之后开始计时;当前视野的目标层级就绪后可提前转场,否则最多再等待指定毫秒。该等待与转场动画时长分开计算。等待期间完整显示旧快照,目标在其下正常绘制并上传纹理。单张图像场景的细节就绪指完整图片可绘制;立方体和球面瓦片指按视口尺寸、设备像素比、FOV 和现有内存预算选出的当前视野目标层级全部可绘制,不要求视野外瓦片。负值或非有限值按 0 处理。transition="none" 使用同一条件,满足后直接切换。新的目标、中断、卸载或 WebGL 上下文丢失会取消尚未结束的等待,避免过期的就绪通知影响下一个场景。
onReady 表示源第一次可绘制。onSceneReady 在首个场景可绘制,或旧画面快照完成且新场景接管时触发,并不表示当前视野的完整图像或细节瓦片已加载完成。
细节加载等待或转场进行时,全景拖拽/缩放输入被锁定,renderHotspots 被隐藏。新的 activeSceneId 会取代仍在准备中的目标。maxTextureMemoryMb 与 maxConcurrentTileLoads 作用于整个 Scenes 查看器,而非每个瓦片场景单独计算。
属性
scenes— 必填。支持type: "sphere" | "tile" | "sphereTile"。activeSceneId— 受控当前场景。defaultActiveSceneId— 非受控初始场景。两者都省略时使用scenes[0].id。onActiveSceneIdChange— 受控模式下命令式切换会触发。transition— 省略时为"crossfade"、时长1秒。也可传{ preset, duration }。各预设默认秒数:none0,crossfade/whiteFlash/ 多数 slide 与 open /pixelate/dissolve/clockWipe/zoomBlur为 1,verticalOpen0.7,glitch0.8,hexDissolve1.1,六种gridWipe*/shatter/swirl/ripple为 1.2,filmBurn1.3,particles1.4,zoom/blackout为 2。transitionWaitForDetailMs— 默认0。renderHotspots— 仅在过渡结束后渲染;过渡中隐藏。onSceneReady— 首次可绘制,或快照后新场景接管;不等于细节加载完成。onTransitionEnd—{ previousSceneId, sceneId, preset }。onTransitionError—{ sceneId, error }。maxTextureMemoryMb— 共享预算,默认128。maxConcurrentTileLoads— 共享并发,默认4。snapshotMaxPixels— 旧画面 GPU 快照的最大物理像素面积,默认3686400(约 2560×1440)。
场景对象上的 viewLimits 见 FAQ;yawOffset 语义与对应源组件相同。TileScene 上的单场景缓存字段在共享缓存下会被忽略,见 Tile。
PanoSceneMenu
PanoSceneMenu 是可滚动的缩略图场景菜单。通过 PanoOverlay 挂载,这样在普通布局和全屏里都会留在 viewer 内。默认布局需导入 @ericchen1990/pano-view/styles.css。
菜单由宿主驱动:传入 items、activeId、onSelect。它不读取 Scenes 状态,也不使用场景的 previewUrl。立方体场景的 previewUrl 通常是 1×6 预览条,不适合直接当菜单缩略图;请改用独立缩略图(转换器输出可优先用 previews/200.webp 这类小图)。
基础接入
与 Scenes 共用同一个受控场景 id:
import { useState } from "react";
import {
PanoOverlay,
PanoSceneMenu,
PanoViewer,
Scenes,
type PanoSceneMenuItem,
} from "@ericchen1990/pano-view";
import "@ericchen1990/pano-view/styles.css";
const menuItems: PanoSceneMenuItem[] = [
{
id: "lobby",
title: "大厅",
thumbnailUrl: "/panoramas/lobby/thumb.webp",
},
{
id: "terrace",
title: "露台",
thumbnailUrl: "/panoramas/terrace/previews/200.webp",
},
];
export function Tour() {
const [activeSceneId, setActiveSceneId] = useState("lobby");
return (
<PanoViewer style={{ height: 560 }}>
<Scenes
scenes={scenes}
activeSceneId={activeSceneId}
onActiveSceneIdChange={setActiveSceneId}
/>
<PanoOverlay>
<PanoSceneMenu
activeId={activeSceneId}
items={menuItems}
onSelect={setActiveSceneId}
/>
</PanoOverlay>
</PanoViewer>
);
}
每项需要 id 与 thumbnailUrl。可选 title 显示在缩略图底部标签和控制提示上;省略时用 id。当前项带叠在缩略图上的选择边框和 data-active="true"。场景名默认叠在缩略图底部的暗色半透明条上,可用 appearance.labelAlign 改为居中或右对齐。
对齐、边距与尺寸
anchor 是场景菜单和平面图共用的 PanoOverlayAnchor。
anchor.align—"top"|"bottom"|"left"|"right"|"top-left"|"top-right"|"bottom-left"|"bottom-right"(默认"bottom")。上下是横条,左右是竖条。四角再看anchor.axis。anchor.axis—"horizontal"|"vertical"。四边忽略它。四角默认"horizontal":水平条带朝上或下收起,垂直条带朝左或右收起。anchor.inset— 与 viewer 边缘的间隙(默认12)。数字按像素;字符串可带单位(如"1rem");对象可分别设置top/right/bottom/left。计算时会加上安全区。width/height— 菜单宽高。数字按像素;字符串会原样写入 CSS,因此可用%、rem、calc()、min()、max()、clamp()等。四边给了主轴尺寸就沿该边居中,没给就在两侧 inset 之间撑满。四角贴住相邻两边,没给尺寸时收缩到内容。较短的列表靠向角落,在边上则居中。dragMotion— 滚动惯性与边界回弹。省略则两者都开(自定义指针滚动,桌面也可拖)。设为false使用原生 overflow 滚动。可传{ inertia?: boolean; bounce?: boolean }分别开关。短按仍会选中;拖过数像素后会抑制 click。
<PanoSceneMenu
anchor={{ align: "bottom", inset: 16 }}
width="min(720px, 100%)"
items={menuItems}
activeId={activeSceneId}
onSelect={setActiveSceneId}
/>
<PanoSceneMenu
anchor={{
align: "top-left",
axis: "vertical",
inset: { top: 24, right: 12, bottom: 24, left: 12 },
}}
height="70%"
width={160}
items={menuItems}
activeId={activeSceneId}
onSelect={setActiveSceneId}
/>
上下布局若省略 width,菜单会在左右 inset 之间拉伸;左右布局若省略 height,会在上下 inset 之间拉伸。组件还会按 inset 和安全区设置 max-width / max-height,所以即使用 100% 也不会超出 viewer。
需要临时覆盖停靠位置时,可再叠一层 style。
展开收起与箭头
默认始终展开。设 collapsible 后会出现开关,位于收起边的内侧正中。收起时列表按自身尺寸加上该边 inset 滑出屏幕,展开按钮留在查看器边缘:
<PanoSceneMenu
collapsible
defaultExpanded
items={menuItems}
activeId={activeSceneId}
onSelect={setActiveSceneId}
/>
// 受控
<PanoSceneMenu
collapsible
expanded={menuOpen}
onExpandedChange={setMenuOpen}
items={menuItems}
activeId={activeSceneId}
onSelect={setActiveSceneId}
/>
设 showArrows 会在列表两端显示上一场景 / 下一场景按钮。默认到首尾后停住;设 loop 后箭头与方向键会在首尾环绕(最后一项的下一项是第一项)。场景少于 2 个时箭头仍禁用。可用 previousLabel / nextLabel 自定义箭头无障碍名称,用 expandLabel / collapseLabel 自定义展开收起文案。默认文案为 "Previous scene"、"Next scene"、"Show scenes"、"Hide scenes"。单选组标签默认是 "Scenes",可通过 label 修改。defaultExpanded 在可折叠时默认为 true。
<PanoSceneMenu
showArrows
loop
collapsible
anchor={{ align: "bottom" }}
width="min(720px, 100%)"
items={menuItems}
activeId={activeSceneId}
onSelect={setActiveSceneId}
previousLabel="上一房间"
nextLabel="下一房间"
/>
外观与样式钩子
常用外观可用 appearance,不必手写 CSS。场景边框位于缩略图内侧:当前场景使用 selectionColor(默认白色),鼠标悬停和键盘焦点使用 focusColor(默认 "#7dd3fc"),交互颜色优先于选中颜色。三种状态共用 selectionWidth(默认 2px);设为 0 隐藏这些边框,但保留独立的键盘焦点提示。场景名默认左对齐,设 labelAlign: "center" 或 "end" 可改对齐。
设置 showVisited(默认 false)后,已浏览场景的缩略图右上角显示深色圆底的勾选图标,不增加可见文字。visitedLabel(默认 "Visited")只用于无障碍描述。Scenes / ProjectScenes 在场景首次可显示时记录,包括首个场景和当前场景;点击、预加载及加载失败不计入。同一 PanoViewer 内的普通和 VR 菜单共享本次会话记录,也包含启用开关前浏览过的场景。收起、隐藏、重新挂载菜单或进出 VR 均保留记录;刷新或重新挂载查看器后清空。独立菜单没有场景就绪来源时不会产生记录,单独改变 activeId 不会标记已浏览。
<PanoSceneMenu items={menuItems} activeId={activeSceneId} onSelect={setActiveSceneId} showVisited visitedLabel="已浏览" />
<PanoSceneMenu
appearance={{
background: "rgba(8, 12, 18, 0.88)",
itemWidth: 144,
itemHeight: 88,
gap: 10,
borderRadius: 10,
selectionColor: "#7dd3fc",
selectionWidth: 2,
labelBackground: "rgba(0, 0, 0, 0.6)",
labelColor: "#ffffff",
labelAlign: "center",
fontSize: 12,
controlColor: "#f8fafc",
controlBackground: "rgba(255, 255, 255, 0.14)",
}}
items={menuItems}
activeId={activeSceneId}
onSelect={setActiveSceneId}
/>
默认值见导出的 DEFAULT_PANO_SCENE_MENU_APPEARANCE(含 padding: "8px 10px"、labelAlign: "start")。更细的样式可用 className / style 覆盖这些稳定类名:
pano-scene-menu— 根容器pano-scene-menu-body— 箭头与列表所在行/列(承载条背景)pano-scene-menu-list— 可滚动列表pano-scene-menu-item/pano-scene-menu-thumb/pano-scene-menu-labelpano-scene-menu-control/pano-scene-menu-arrow/pano-scene-menu-toggle
根节点还带 data-placement、data-align、data-orientation(horizontal | vertical)、data-expanded、data-collapsible;当前项带 data-active="true"。启用 showVisited 时,已浏览项带 data-visited="true",图标类名为 pano-scene-menu-visited。
collapsible 时,边缘拉手可用 toggle 换图标或改样式。省略 toggle 时仍是默认箭头。icon 传元素(<Icon />),不要传组件类型。图标要随收起状态变化时传函数,参数为 { collapsed }(菜单展开时 collapsed 为 false)。className 追加到按钮上。style 覆盖背景、边框、颜色和尺寸;位置和 transform 由贴边布局负责,不要在 style 里改。默认箭头使用 currentColor,只改 style.color 就会变色。
<PanoSceneMenu
collapsible
toggle={{ style: { color: "#7dd3fc" } }}
items={menuItems}
activeId={activeSceneId}
onSelect={setActiveSceneId}
/>
<PanoSceneMenu
collapsible
toggle={{
className: "scene-tab",
style: {
background: "#0c4a6e",
color: "#7dd3fc",
border: "1px solid #7dd3fc",
},
icon: ({ collapsed }) => (collapsed ? "+" : "–"),
}}
items={menuItems}
activeId={activeSceneId}
onSelect={setActiveSceneId}
/>
PanoFilter
在 PanoViewer 内渲染 PanoFilter 可对全景源(Sphere、Tile、SphereTile、PanoVideo)调色或风格化。内置预设仅作用于全景材质;colorMatrix 默认仅作用于全景,设 colorMatrixScope="all" 可同时作用于热点纹理。tooltip、视频控件与 iframe 等 DOM 叠加层不受影响。WebXR / MobileVR 使用相同的全景着色器,因此继承相同的滤镜外观。
intensity 在滤镜结果与原图之间混合(0–1,默认 1)。省略组件或设 enabled={false} 可关闭全部滤镜;preset="none" 仅关闭内置预设,已配置的 colorMatrix 仍会生效。
<PanoViewer style={{ height: 560 }}>
<PanoFilter preset="pencil" intensity={0.85} />
<Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
<ImageHotspot
id="door"
position={{ yaw: 24, pitch: -6 }}
src="/hotspots/door.webp"
/>
</PanoViewer>
| 预设 | 效果 |
|---|---|
none | 恒等(无滤镜) |
grayscale | Rec.709 黑白 |
sepia | 暖棕色复古 |
vintage | 褪色胶片与颗粒 |
cool / warm | 冷/暖色温偏移 |
pencil | 纸上石墨素描 |
coloredPencil | 彩色铅笔线条 |
crayon | 海报化蜡笔笔触 |
watercolor | 纸上柔和水彩 |
cartoon | 赛璐珞着色与描边 |
crosshatch | 亮度交叉排线 |
内置艺术效果使用屏幕空间边缘与世界空间颗粒,避免立方体瓦片出现滤镜接缝。启用滤镜时,场景过渡快照会捕获带滤镜的全景。
AutoRotate
自动旋转默认关闭。在 PanoViewer 内渲染 AutoRotate 以启用,并将旋转配置与用户输入控件分离。speed 为度/秒;负值向左旋转。acceleration 为度/秒²,从零平滑加速到 speed(默认 18,即默认速度约 1 秒达到)。设为 0 则立即以固定速度旋转。startDelay 为 enabled 变为 true 起的毫秒延迟,默认 2。用户拖拽中,或拖拽惯性仍在衰减时,旋转暂停并在恢复时从零速度重新开始。组件挂载且 enabled 省略时按 true 处理;不渲染组件则不旋转。
<PanoViewer style={{ height: 560 }}>
<AutoRotate enabled speed={12} acceleration={6} startDelay={2_000} />
<Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
</PanoViewer>
BackgroundAudio
BackgroundAudio 在查看器里播放背景音乐或环境声。它不是热点:没有球面位置、没有标记、音量也不随视角变化。必须作为 PanoViewer 的子组件,与 Scenes、Sphere 或 Tile 并列。不要写进 Scenes 的 renderHotspots:场景过渡时热点会卸载,背景音频也会停止。
传入 playing 时,播放状态由宿主控制;省略时使用内部状态,初始值由 defaultPlaying 指定(默认 false)。loop 默认 true。浏览器可能阻止自动播放;此时 onPlaybackStateChange 收到 "blocked",播放意图仍为 true 时会在下一次点击或按键后重试。
可使用以下两种音轨配置方式。
全部场景共用一条音轨 — 只传 src,不要传 sources。切换场景时音频继续播放,不会从头播放。
import { BackgroundAudio, PanoViewer, Scenes } from "@ericchen1990/pano-view";
<PanoViewer style={{ height: 560 }}>
<BackgroundAudio src="/bgm/tour.mp3" playing={isBgmPlaying} />
<Scenes scenes={scenes} activeSceneId={activeSceneId} />
</PanoViewer>;
每个场景一条音轨 — 传 sources(键为 Scene.id)和当前的 sceneId。省略 sceneId 会抛错。切换场景时,若文件变了会按 fadeMs 交叉淡化(默认 400 毫秒;0 表示立即切换);若两个场景指向同一文件则连续播放,不重新开始。
<PanoViewer style={{ height: 560 }}>
<BackgroundAudio
sources={{
lobby: "/bgm/lobby.mp3",
terrace: "/bgm/terrace.mp3",
}}
sceneId={activeSceneId}
playing={isBgmPlaying}
/>
<Scenes scenes={scenes} activeSceneId={activeSceneId} />
</PanoViewer>;
可选:同时传 src 作为未列出场景的默认音轨。某个场景要静音时,把该 id 写成 ""(会覆盖 src,而不是回退到默认轨)。
<BackgroundAudio
src="/bgm/default.mp3"
sources={{ lobby: "/bgm/courtyard.mp3", terrace: "" }}
sceneId={activeSceneId}
playing={isBgmPlaying}
/>;
上例中:lobby 播放庭院环境声,terrace 静音,其余场景播放 default.mp3。
需要将声源定位在全景某一点,并使立体声像随视角变化时,使用 AudioHotspot。
属性
src/sources—src为全局轨,或sources缺项时的回退。二者均可为字符串,或字符串数组(Howler 多格式回退)。sources的键是Scene.id。sceneId— 使用sources时必填;省略会抛错。playing/defaultPlaying— 受控播放,或非受控初始值(默认false)。loop— 默认true。muted— 默认false。volume— 默认1,范围 0–1。pauseWhenHidden— 默认true;页面隐藏时暂停。fadeMs— 切轨交叉淡化毫秒,默认400;0为硬切。crossOrigin—""|"anonymous"|"use-credentials";省略则不设。onPlaybackStateChange—"playing" | "paused" | "ended" | "blocked"。onPlaybackError—play()等播放失败。onEnded— 播完;非受控时还会把内部播放设为false。onError— 资源加载失败,参数{ error }(与onPlaybackError不同)。
Gyro
Gyro 为可选的设备方向控制,默认关闭。在 PanoViewer 内渲染并通过 enabled 控制。相对模式(默认)将启用时的视图作为起始方向。设 absolute 可跟随罗盘朝向,用 north 指定指向北方的全景 yaw。
import { useRef, useState } from "react";
import {
Gyro,
PanoViewer,
Sphere,
type GyroHandle,
} from "@ericchen1990/pano-view";
export function GyroExample() {
const gyroRef = useRef<GyroHandle>(null);
const [enabled, setEnabled] = useState(false);
const toggle = async () => {
if (enabled) {
setEnabled(false);
return;
}
// iOS requires this call to run directly inside a user gesture.
setEnabled((await gyroRef.current?.requestPermission()) ?? false);
};
return (
<>
<button onClick={() => void toggle()}>
{enabled ? "Disable gyro" : "Enable gyro"}
</button>
<PanoViewer style={{ height: 560 }}>
<Gyro
ref={gyroRef}
enabled={enabled}
camroll
friction={0}
softstart={0.5}
touchMode="full"
onDenied={() => setEnabled(false)}
/>
<Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
</PanoViewer>
</>
);
}
camroll(默认 true)根据设备的翻滚角(roll)调整相机倾斜。friction 接受 0 到 0.99;越高越平滑但延迟更大。softstart 为启用时的混合时长(秒,默认 0.5)。desktopSupport 默认为 false,因桌面浏览器可能暴露 API 却无物理传感器。
touchMode 控制触控拖拽与设备运动的组合方式:
off忽略触控 yaw/pitch,仍保留双指 FOV。horizontaloffset仅加水平偏移。full加水平与垂直偏移;设备移动时垂直偏移会回到物理朝向。disablegyro在下一次触控时关闭当前陀螺仪会话。
GyroHandle 暴露 resetSensor(yaw?, pitch?)、isAvailable()、isEnabled() 与 requestPermission()。陀螺仪活动会暂停 AutoRotate,并计为查看器交互以用于空闲追踪。传感器 API 通常需要 HTTPS。跨域 iframe 需宿主页允许,例如 allow="gyroscope; accelerometer" 与兼容的 Permissions-Policy 头。
属性
enabled— 默认false。iOS 须在用户手势里先requestPermission()再设为true。camroll— 默认true。absolute— 默认false(相对模式);true跟随罗盘。north— 表示正北的全景 yaw,单位为度,默认0。friction—0–0.99,默认0。touchMode— 默认"full";取值见上文。softstart— 启用时的混合秒数,默认0.5。desktopSupport— 默认false。onAvailable/onUnavailable— 传感器能力探测结果变化时。onEnable/onDisable— 会话实际开启 / 关闭时。onDenied— 权限被拒绝时(示例中常用来把enabled设回false)。
这些是 Gyro 组件自身的回调。PanoEvents 上的 onGyro* 订阅同一事件总线,供查看器级监听使用。
WebVR
VR 设置按桌面模拟与手机分屏分别保存到 v2 存储,不继承旧版 v1。Reset 仅重置当前回退模式,未进入 VR 时重置两种