@ericchen1990/pano-view 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1111 @@
1
+ # @ericchen1990/pano-view
2
+
3
+ [English](./README.md) | **简体中文**
4
+
5
+ 用于等距圆柱(equirectangular)与 krpano 风格多分辨率全景查看器的可组合 React 组件。
6
+
7
+ 在[在线 playground](https://pano-view-playground.vercel.app/) 中试用这些组件。
8
+
9
+ ## 兼容 krpano 的 tile 输出
10
+
11
+ 当你需要围绕等距圆柱图像或现有 krpano 立方体 tile 金字塔构建 React 原生查看器时,请使用本包。将 krpano `<cube>` 的 `url` 与 `multires` 属性复制到 `Tile` 的 `urlTemplate` 与 `multires`,并将 `<preview url>` 复制到 `previewUrl`;krpano 的磁盘命名与本包默认不同,省略这些值会加载错误的 tile 或预览图。渲染、控件、热点与场景过渡仍由你的 React 应用负责。本包兼容该 tile 输出格式,但与 krpano 无隶属关系。
12
+
13
+ ## 安装
14
+
15
+ ### 包
16
+
17
+ ```bash
18
+ npm install @ericchen1990/pano-view
19
+ ```
20
+
21
+ pnpm 与 Yarn 用法相同(`pnpm add` / `yarn add`)。
22
+
23
+ ### 对等依赖
24
+
25
+ 这些包必须安装在**宿主应用**中,而不是嵌套在本库之下。npm 7+ 与 pnpm 会自动安装;Yarn Classic 与 npm 6 不会,需显式安装。
26
+
27
+ | 包 | 支持范围 |
28
+ | --- | --- |
29
+ | `react` | `>=19.0.0 <19.3.0` |
30
+ | `react-dom` | `>=19.0.0 <19.3.0` |
31
+ | `three` | `^0.185.1` |
32
+ | `@react-three/fiber` | `^9.7.0` |
33
+ | `@react-three/drei` | `^10.0.0` |
34
+ | `@react-three/xr` | `^6.6.0` |
35
+
36
+ 在宿主应用中保持 React、Three.js 与 React Three Fiber 相关包的**单一副本**。重复的 `three` 树是空白画布与上下文错误(`R3F: Hooks can only be used within the Canvas component`)的常见原因。若出现该问题,请检查 `npm ls three` / `pnpm why three` 并去重。
37
+
38
+ React 19.3 及更高版本不在支持范围内。
39
+
40
+ ### 样式表(HTML 叠加层)
41
+
42
+ 在宿主应用中导入一次随包样式表。WebGL 全景(`PanoViewer`、`Sphere`、`Tile`、3D 热点)无需该文件即可渲染。内置 **HTML** 叠加层则不行:它们使用带 `pano-` 前缀的类名做布局。
43
+
44
+ 这些叠加层包括视频控件、字幕、默认右键菜单、热点 tooltip、仅无障碍界面元素,以及 WebVR 会话 UI。
45
+
46
+ ```ts
47
+ import "@ericchen1990/pano-view/styles.css";
48
+ ```
49
+
50
+ 或在 CSS 中:
51
+
52
+ ```css
53
+ @import "@ericchen1990/pano-view/styles.css";
54
+ ```
55
+
56
+ 仅当你不使用这些叠加层,或完全通过 `className`、`style` 与外观属性重设样式时,可跳过该导入。若不导入样式表,叠加层仍会挂载,但会无样式显示。
57
+
58
+ ## 导出组件
59
+
60
+ ### 查看器
61
+
62
+ - [`PanoViewer`](#panoviewer) — 画布外壳、相机、默认控件与命令式视图 API
63
+ - [`Sphere`](#sphere) — 单张 2:1 等距圆柱图像
64
+ - [`PanoVideo`](#panovideo) — 全景球面上的 2:1 等距圆柱视频
65
+ - [`Tile`](#tile) — krpano 风格多分辨率立方体 tile
66
+ - [`Scenes`](#scenes) — 受控多场景过渡
67
+ - [`PanoFilter`](#panofilter) — 对全景源应用色彩与艺术滤镜
68
+
69
+ ### 控件
70
+
71
+ - [`MouseControls`](#mousecontrols) — 拖拽环视与滚轮缩放
72
+ - [`TouchControls`](#touchcontrols) — 单指拖拽与双指缩放
73
+ - [`KeyboardControls`](#keyboardcontrols) — 方向键环视、FOV 与场景切换
74
+ - [`AutoRotate`](#autorotate) — 自动偏航旋转
75
+ - [`Gyro`](#gyro) — 可选的设备方向控制
76
+ - [`WebVR`](#webvr) — 优先 WebXR 的沉浸式 VR,MobileVR 作为回退
77
+
78
+ ### 事件与界面元素
79
+
80
+ - [`PanoEvents`](#panoevents) — 查看器级生命周期与交互回调
81
+ - [`PanoContextMenu`](#panocontextmenu) — 右键重置 / 全屏菜单
82
+ - [`PanoVideoControls`](#panovideo) — `PanoVideo` 的播放界面(默认挂载)
83
+
84
+ ### 热点
85
+
86
+ - [`ImageHotspot`](#imagehotspot) — 球面位置上的图像纹理
87
+ - [`GraphicHotspot`](#graphichotspot) — 内置形状或 SVG
88
+ - [`SequenceHotspot`](#sequencehotspot) — 精灵图动画
89
+ - [`VideoHotspot`](#videohotspot) — HTML 视频纹理
90
+ - [`TextHotspot`](#texthotspot) — 球面位置上的栅格化纯文本
91
+ - [`IframeHotspot`](#iframehotspot) — 球面位置上的嵌入文档叠加层
92
+ - [`PolygonHotspot`](#polygonhotspot) — 闭合球面区域
93
+ - [`PolylineHotspot`](#polylinehotspot) — 开放球面路径
94
+
95
+ ### Hook 与辅助工具
96
+
97
+ - `usePanoEvents` — 在自定义子组件内订阅查看器事件([`PanoEvents`](#panoevents))
98
+ - 坐标辅助函数 — `normalizePanoPosition`、`normalizePanoYaw`、`clampPanoPitch`、`panoPositionToVector3`、`vector3ToPanoPosition`([全景坐标事件](#全景坐标事件))
99
+ - `cycleSceneId` — 循环上一/下一场景 id([`KeyboardControls`](#keyboardcontrols))
100
+ - 右键菜单辅助 — `createPanoContextMenuPresets`、`composePanoContextMenuItems` 及相关 API([`PanoContextMenu`](#panocontextmenu))
101
+ - 多边形 / 折线校验 — `validatePolygonVertices`、`validatePolylineVertices`、`unwrapPolygonVertices`
102
+
103
+ ## PanoViewer
104
+
105
+ `PanoViewer` 负责画布、透视相机与控件。为其容器指定明确尺寸,并在内部放置一个全景源。
106
+
107
+ 角度对外以度为单位。偏航(yaw)为正表示向右看,俯仰(pitch)为正表示向上看。
108
+
109
+ ```tsx
110
+ import { useRef } from "react";
111
+ import "@ericchen1990/pano-view/styles.css";
112
+ import {
113
+ PanoViewer,
114
+ Sphere,
115
+ AutoRotate,
116
+ type PanoViewerHandle,
117
+ } from "@ericchen1990/pano-view";
118
+
119
+ export function ControlledExample() {
120
+ const ref = useRef<PanoViewerHandle>(null);
121
+
122
+ return (
123
+ <>
124
+ <button onClick={() => ref.current?.setView({ yaw: 90, fov: 55 })}>
125
+ Look right
126
+ </button>
127
+ <PanoViewer
128
+ ref={ref}
129
+ controls={{
130
+ inertia: true,
131
+ rotateDamping: 14,
132
+ zoomDamping: 16,
133
+ }}
134
+ initialView={{ yaw: 0, pitch: 0, fov: 75 }}
135
+ minFov={30}
136
+ maxFov={100}
137
+ onViewChange={(view) => console.log(view)}
138
+ style={{ height: 560 }}
139
+ >
140
+ <AutoRotate enabled speed={18} acceleration={18} startDelay={1_000} />
141
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
142
+ </PanoViewer>
143
+ </>
144
+ );
145
+ }
146
+ ```
147
+
148
+ 句柄暴露 `getView`、`setView`、`reset`、`startAutoRotate`、`stopAutoRotate` 与 `toggleFullscreen`。鼠标、触控与键盘输入默认启用 — 普通浏览无需再渲染控件组件。仅当需要覆盖属性时渲染 `MouseControls`、`TouchControls` 或 `KeyboardControls`(子组件会替换该通道的默认实例)。通过 `controls` 调节共享行为(`inertia`、`invert`、`bouncingLimits`、`fovSpeed`、`frictionStop`、`rotateDamping`、`zoomDamping`,以及顶层的 `rotateSpeed` / `zoomSpeed`)。将 `controls.mouse` / `touch` / `keyboard` 设为 `false` 可关闭某通道,或传入选项对象(含 `enabled`)在不挂载子组件的情况下覆盖默认值。自动旋转在渲染 `AutoRotate` 之前处于关闭状态。两个自动旋转句柄方法仍保留以兼容旧用法。默认右键菜单(重置视图 / 进入全屏)也会挂载 — 见 [`PanoContextMenu`](#panocontextmenu)。
149
+
150
+ 拖拽与缩放会更新目标视图,相机平滑跟随。`rotateDamping` 与 `zoomDamping` 控制跟随速度,单位为秒⁻¹(默认分别为 `14` 与 `16`);数值越低手感越柔和,`0` 表示该轴不做平滑。两者必须为非负有限数。命令式 `setView()` 与 `reset()` 仍为即时生效。
151
+
152
+ 画布光标默认为 `grab`,鼠标拖拽时为 `grabbing`,悬停在可交互热点上为 `pointer`,拖拽热点时为 `move`。可通过 `cursors`(`default`、`dragging`、`hotspot`、`hotspotDragging`)覆盖,或传 `cursors={false}` 保持画布光标不变。热点可设置 `cursor`,仅替换该热点的悬停光标。
153
+
154
+ ```tsx
155
+ <PanoViewer
156
+ cursors={{ default: "grab", dragging: "grabbing", hotspot: "pointer" }}
157
+ style={{ height: 560 }}
158
+ >
159
+ <ImageHotspot
160
+ id="door"
161
+ cursor="zoom-in"
162
+ position={{ yaw: 24, pitch: -6 }}
163
+ src="/hotspots/door.webp"
164
+ />
165
+ </PanoViewer>
166
+ ```
167
+
168
+ ## Sphere
169
+
170
+ `Sphere` 需要 2:1 等距圆柱图像。当源图像的前向需要水平调整时,使用 `yawOffset`。
171
+
172
+ `previewUrl` 为必填项,在 `src` 加载期间显示低分辨率 2:1 图像。相对路径相对于 `src` 所在目录解析;根绝对路径与 `http(s)` URL 原样使用。对于 krpano 球面场景,将 [`<preview url>`](https://krpano.com/docu/xml/#preview) 复制到 `previewUrl`。该预览必须是等距圆柱,而非立方体条带。
173
+
174
+ ```tsx
175
+ "use client";
176
+
177
+ import { PanoViewer, Sphere } from "@ericchen1990/pano-view";
178
+
179
+ export function SphereExample() {
180
+ return (
181
+ <PanoViewer style={{ width: "100%", height: 560 }}>
182
+ <Sphere
183
+ src="/panoramas/room.webp"
184
+ previewUrl="preview.webp"
185
+ />
186
+ </PanoViewer>
187
+ );
188
+ }
189
+ ```
190
+
191
+ ## PanoVideo
192
+
193
+ `PanoVideo` 将 2:1 等距圆柱视频映射到与 `Sphere` 相同的内表面球体上。可传单个 `src`,或传含多档质量的 `variants`,以便浏览器选择 mp4 / webm,且控制栏可切换分辨率。切换质量时保留当前时间与播放/暂停状态。
194
+
195
+ 默认播放栏挂载在查看器叠加层上:播放/暂停、进度、时间、音量、倍速、分辨率、字幕与全屏。当浏览器允许 `HTMLMediaElement.volume` 时,音量为悬停(或点击)垂直滑块;iOS Safari 仅暴露静音。倍速菜单列出当前元素接受的速率。字幕菜单还包含字幕字号与背景不透明度的设置面板。查看器较窄时,倍速、画质与字幕会收入「更多」菜单。传 `controls={false}` 可隐藏;传外观对象可重设默认实例样式;或将 `PanoVideoControls` 作为 `PanoViewer` 子组件以替换该实例。
196
+
197
+ 字幕使用 WebVTT `tracks`。视频元素在屏幕外,因此 cue 文本以 HUD 叠加层渲染。`captions={false}` 会隐藏叠加层与语言菜单;对象会与默认外观合并(`color`、`background`、`fontSize`、`fontFamily`、`textShadow`、`padding`、`borderRadius`、`maxWidth`、`bottom`)。这些值作为字幕初始外观;观众仍可在运行时通过默认控制栏菜单调整字幕大小与背景。
198
+
199
+ ```tsx
200
+ "use client";
201
+
202
+ import { PanoViewer, PanoVideo } from "@ericchen1990/pano-view";
203
+
204
+ export function VideoExample() {
205
+ return (
206
+ <PanoViewer style={{ width: "100%", height: 560 }}>
207
+ <PanoVideo
208
+ defaultVariantId="1024"
209
+ variants={[
210
+ {
211
+ id: "1024",
212
+ label: "1024p",
213
+ poster: "/video/1024-poster.jpg",
214
+ sources: [
215
+ { src: "/video/1024.mp4", type: "video/mp4" },
216
+ { src: "/video/1024.webm", type: "video/webm" },
217
+ ],
218
+ },
219
+ {
220
+ id: "1920",
221
+ label: "1920p",
222
+ poster: "/video/1920-poster.jpg",
223
+ sources: [
224
+ { src: "/video/1920.mp4", type: "video/mp4" },
225
+ { src: "/video/1920.webm", type: "video/webm" },
226
+ ],
227
+ },
228
+ ]}
229
+ tracks={[
230
+ { src: "/video/en.vtt", srcLang: "en", label: "English", default: true },
231
+ { src: "/video/zh.vtt", srcLang: "zh", label: "中文" },
232
+ ]}
233
+ captions={{ fontSize: 16, color: "#fff" }}
234
+ muted
235
+ />
236
+ </PanoViewer>
237
+ );
238
+ }
239
+ ```
240
+
241
+ `muted` 默认为 `true`,以便在浏览器自动播放规则下有机会自动播放。非静音播放仍需要用户手势;`play()` 被拒绝时 `onPlaybackStateChange` 会收到 `"blocked"`。非同源的远程视频与 VTT 文件应设置 `crossOrigin`(通常为 `"anonymous"`)。
242
+
243
+ ## Tile
244
+
245
+ `Tile` 渲染六个朝内的立方体面,并仅加载当前视野附近的 tile。省略 `urlTemplate` 时,相对于 `baseUrl` 的路径使用本包默认:
246
+
247
+ ```text
248
+ tiles/%s/l%l/%v/l%l_%s_%h_%v.webp
249
+ ```
250
+
251
+ 展开为 `tiles/{face}/l{level}/{row}/l{level}_{face}_{col}_{row}.webp`。面为 `f`、`r`、`b`、`l`、`u`、`d`;行与列从 1 起计。
252
+
253
+ ```tsx
254
+ import { PanoViewer, Tile } from "@ericchen1990/pano-view";
255
+
256
+ export function TileExample() {
257
+ return (
258
+ <PanoViewer style={{ width: "100%", height: 560 }}>
259
+ <Tile
260
+ baseUrl="https://cdn.example.com/panoramas/room"
261
+ multires="512,500,1000,2000"
262
+ previewUrl="previews/cube-vertical.webp"
263
+ />
264
+ </PanoViewer>
265
+ );
266
+ }
267
+ ```
268
+
269
+ krpano 立方体 tile 输出不遵循该默认。从 krpano XML 读取 `url`、`multires` 与预览路径([`<cube>`](https://krpano.com/docu/xml/#image.cube)、[`<preview>`](https://krpano.com/docu/xml/#preview))并传入。若这些值缺失或与磁盘文件不匹配,tile 会解析到错误的 face、level 或行列,全景会看起来错乱。
270
+
271
+ | krpano | `Tile` / `TileScene` 属性 |
272
+ |---|---|
273
+ | `<cube url>` | `urlTemplate` |
274
+ | `<cube multires>` | `multires` |
275
+ | `<preview url>` | `previewUrl` |
276
+ | `<preview striporder>` | `previewFaceOrder` |
277
+
278
+ `Sphere` 使用相同的 `<preview url>` → `previewUrl` 映射;该文件必须是 2:1 等距圆柱,而非立方体条带。
279
+
280
+ krpano 文档中的简写语法:
281
+
282
+ ```xml
283
+ <preview url="preview.jpg" />
284
+ <image>
285
+ <cube url="pano_%s_%l_%v_%h.jpg"
286
+ multires="512,1024,2048,4096"
287
+ />
288
+ </image>
289
+ ```
290
+
291
+ 对应为:
292
+
293
+ ```tsx
294
+ <Tile
295
+ baseUrl="/panoramas/room"
296
+ urlTemplate="pano_%s_%l_%v_%h.jpg"
297
+ multires="512,1024,2048,4096"
298
+ previewUrl="preview.jpg"
299
+ />
300
+ ```
301
+
302
+ MAKE PANO (MULTIRES) 文件夹布局常为 `tiles/%s/l%l/%v/l%l_%s_%v_%h.jpg`,场景根目录有 `preview.jpg`。该文件名为 `%v_%h`(先行后列)。本包默认 tile 模板为 `%h_%v`(先列后行)。请复制 XML 中的值,不要假设两种约定一致。
303
+
304
+ `multires` 的第一个值为 tile 尺寸。其余值为 `l1`、`l2` 及更高层级的递增立方体面尺寸。`previewUrl` 为必填。相对路径相对于 `baseUrl` 解析;根绝对路径与 `http(s)` URL 原样使用。典型的 krpano MAKE PANO (MULTIRES) 场景如下:
305
+
306
+ ```tsx
307
+ <Tile
308
+ baseUrl="/panoramas/room"
309
+ urlTemplate="tiles/%s/l%l/%v/l%l_%s_%v_%h.jpg"
310
+ multires="512,1000,2000"
311
+ previewUrl="preview.jpg"
312
+ previewFaceOrder={["l", "f", "r", "b", "u", "d"]}
313
+ />
314
+ ```
315
+
316
+ `previewFaceOrder` 必须按自上而下 atlas 顺序列出六个面代码:`f`、`r`、`b`、`l`、`u`、`d`。预览图为竖向 1×6 立方体条带。krpano 的 `thumb.jpg` 是小缩略图,不是该 atlas。
317
+
318
+ 快速旋转或缩放时,已加载 tile 保持可见;新进入视野的 tile 会使用其父级或预览作为局部回退。
319
+
320
+ 对于非 krpano XML 的布局,可用占位符模板或解析器覆盖默认。两者均返回相对于 `baseUrl` 的路径:
321
+
322
+ ```tsx
323
+ <Tile
324
+ baseUrl="/panoramas/room"
325
+ multires={{ tileSize: 512, levels: [500, 1000, 2000] }}
326
+ previewUrl="previews/cube-vertical.webp"
327
+ urlTemplate="assets/%s/%l/%v_%h.webp"
328
+ resolveTileUrl={({ face, level, row, col }) =>
329
+ `api/tile/${face}/${level}/${row}/${col}`
330
+ }
331
+ />
332
+ ```
333
+
334
+ `resolveTileUrl` 优先于 `urlTemplate`。
335
+
336
+ 对于 krpano 立方体 tile 模板,`%s` 为立方体面,`%l` 为多分辨率层级。水平 tile 索引支持等价的 `%h`、`%x`、`%u`、`%c` 占位符;垂直索引支持 `%v`、`%y`、`%w`、`%r`。在索引占位符前加零可填充:例如 `%0h` 为两位水平索引,`%00v` 为三位垂直索引。立体 `%t` 与帧 `%f` 占位符不适用于表示单一非立体立方体全景的 `Tile`。
337
+
338
+ ## Scenes
339
+
340
+ `Scenes` 在受控的球面与立方体 tile 场景之间切换,采用纯 GPU 快照混合。目标场景先加载其球面或 tile 预览;就绪后,当前帧缓冲成为临时 GPU 纹理,释放其源纹理,目标场景再混合进入。这样避免在 WebGL 内存中同时持有两个高分辨率 tile 场景。
341
+
342
+ ```tsx
343
+ import {
344
+ Scenes,
345
+ PanoViewer,
346
+ type Scene,
347
+ } from "@ericchen1990/pano-view";
348
+
349
+ const scenes: Scene[] = [
350
+ { id: "lobby", type: "sphere", src: "/panoramas/lobby.webp", previewUrl: "preview.webp" },
351
+ {
352
+ id: "terrace",
353
+ type: "tile",
354
+ baseUrl: "/panoramas/terrace",
355
+ multires: "512,1000,2000",
356
+ urlTemplate: "tiles/%s/l%l/%v/l%l_%s_%v_%h.webp",
357
+ previewUrl: "previews/cube-vertical.webp",
358
+ },
359
+ ];
360
+
361
+ <PanoViewer style={{ height: 560 }}>
362
+ <Scenes
363
+ scenes={scenes}
364
+ activeSceneId={activeSceneId}
365
+ transition="ellipticZoomOpen"
366
+ renderHotspots={(scene) => <SceneHotspots sceneId={scene.id} />}
367
+ onTransitionError={({ sceneId }) => console.warn("Could not load", sceneId)}
368
+ />
369
+ </PanoViewer>;
370
+ ```
371
+
372
+ 可用的 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 }` 可覆盖预设时长。
373
+
374
+ 过渡进行时,全景拖拽/缩放输入被锁定,`renderHotspots` 被隐藏。新的 `activeSceneId` 会取代仍在准备中的目标。`maxTextureMemoryMb` 与 `maxConcurrentTileLoads` 作用于整个 `Scenes` 查看器,而非每个 tile 场景单独计算。
375
+
376
+ ## PanoFilter
377
+
378
+ 在 `PanoViewer` 内渲染 `PanoFilter` 可对全景源(`Sphere`、`Tile`、`PanoVideo`)调色或风格化。滤镜仅作用于这些材质:3D 热点、tooltip、视频界面与 iframe 叠加层保持原色。WebXR / MobileVR 继承相同外观,因为修改的是全景着色器本身。
379
+
380
+ `intensity` 在滤镜结果与原图之间混合(`0`–`1`,默认 `1`)。省略组件、传 `preset="none"` 或设 `enabled={false}` 则保持源图无滤镜。
381
+
382
+ ```tsx
383
+ <PanoViewer style={{ height: 560 }}>
384
+ <PanoFilter preset="pencil" intensity={0.85} />
385
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
386
+ <ImageHotspot
387
+ id="door"
388
+ position={{ yaw: 24, pitch: -6 }}
389
+ src="/hotspots/door.webp"
390
+ />
391
+ </PanoViewer>
392
+ ```
393
+
394
+ | 预设 | 效果 |
395
+ |---|---|
396
+ | `none` | 恒等(无滤镜) |
397
+ | `grayscale` | Rec.709 黑白 |
398
+ | `sepia` | 暖棕色复古 |
399
+ | `vintage` | 褪色胶片与颗粒 |
400
+ | `cool` / `warm` | 冷/暖色温偏移 |
401
+ | `pencil` | 纸上石墨素描 |
402
+ | `coloredPencil` | 彩色铅笔线条 |
403
+ | `crayon` | 海报化蜡笔笔触 |
404
+ | `watercolor` | 纸上柔和水彩 |
405
+ | `cartoon` | 赛璐珞着色与描边 |
406
+ | `crosshatch` | 亮度交叉排线 |
407
+
408
+ 内置艺术效果使用屏幕空间边缘与世界空间颗粒,避免立方体 tile 出现滤镜接缝。有过滤器激活时,场景过渡快照会捕获带滤镜的全景。
409
+
410
+ ## AutoRotate
411
+
412
+ 自动旋转默认关闭。在 `PanoViewer` 内渲染 `AutoRotate` 以启用,并将旋转配置与用户输入控件分离。`speed` 为度/秒;负值向左旋转。`acceleration` 为度/秒²,从零平滑加速到 `speed`(默认 `18`,即默认速度约 1 秒达到)。设为 `0` 则立即以固定速度旋转。`startDelay` 为 `enabled` 变为 true 起的毫秒延迟。用户拖拽中,或拖拽惯性仍在衰减时,旋转暂停并在恢复时从零速度重新开始。
413
+
414
+ ```tsx
415
+ <PanoViewer style={{ height: 560 }}>
416
+ <AutoRotate enabled speed={12} acceleration={6} startDelay={2_000} />
417
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
418
+ </PanoViewer>
419
+ ```
420
+
421
+ ## Gyro
422
+
423
+ `Gyro` 为可选的设备方向控制,默认关闭。在 `PanoViewer` 内渲染并通过 `enabled` 控制。相对模式(默认)将启用时的视图作为起始方向。设 `absolute` 可跟随罗盘朝向,用 `north` 指定指向北方的全景 yaw。
424
+
425
+ ```tsx
426
+ import { useRef, useState } from "react";
427
+ import {
428
+ Gyro,
429
+ PanoViewer,
430
+ Sphere,
431
+ type GyroHandle,
432
+ } from "@ericchen1990/pano-view";
433
+
434
+ export function GyroExample() {
435
+ const gyroRef = useRef<GyroHandle>(null);
436
+ const [enabled, setEnabled] = useState(false);
437
+
438
+ const toggle = async () => {
439
+ if (enabled) {
440
+ setEnabled(false);
441
+ return;
442
+ }
443
+ // iOS requires this call to run directly inside a user gesture.
444
+ setEnabled((await gyroRef.current?.requestPermission()) ?? false);
445
+ };
446
+
447
+ return (
448
+ <>
449
+ <button onClick={() => void toggle()}>
450
+ {enabled ? "Disable gyro" : "Enable gyro"}
451
+ </button>
452
+ <PanoViewer style={{ height: 560 }}>
453
+ <Gyro
454
+ ref={gyroRef}
455
+ enabled={enabled}
456
+ camroll
457
+ friction={0}
458
+ softstart={0.5}
459
+ touchMode="full"
460
+ onDenied={() => setEnabled(false)}
461
+ />
462
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
463
+ </PanoViewer>
464
+ </>
465
+ );
466
+ }
467
+ ```
468
+
469
+ `camroll`(默认 `true`)根据设备 roll 水平相机。`friction` 接受 `0` 到 `0.99`;越高越平滑但延迟更大。`softstart` 为启用时的混合时长(秒,默认 `0.5`)。`desktopSupport` 默认为 `false`,因桌面浏览器可能暴露 API 却无物理传感器。
470
+
471
+ `touchMode` 控制触控拖拽与设备运动的组合方式:
472
+
473
+ - `off` 忽略触控 yaw/pitch,仍保留双指 FOV。
474
+ - `horizontaloffset` 仅加水平偏移。
475
+ - `full` 加水平与垂直偏移;设备移动时垂直偏移会回到物理朝向。
476
+ - `disablegyro` 在下一次触控时关闭当前陀螺仪会话。
477
+
478
+ `GyroHandle` 暴露 `resetSensor(yaw?, pitch?)`、`isAvailable()`、`isEnabled()` 与 `requestPermission()`。陀螺仪活动会暂停 `AutoRotate`,并计为查看器交互以用于空闲追踪。传感器 API 通常需要 HTTPS。跨域 iframe 需宿主页允许,例如 `allow="gyroscope; accelerometer"` 与兼容的 `Permissions-Policy` 头。
479
+
480
+ ## WebVR
481
+
482
+ `WebVR` 为最近的 `PanoViewer` 增加沉浸式观看。支持的头戴设备优先使用 `immersive-vr` WebXR 会话。无 WebXR 的手机使用设备方向的立体分屏;桌面可提供鼠标驱动的模拟 VR 预览。内置界面包括进入 VR、退出 VR 与 MobileVR 设置控件。
483
+
484
+ ```tsx
485
+ import { useRef } from "react";
486
+ import {
487
+ PanoViewer,
488
+ Sphere,
489
+ WebVR,
490
+ type WebVRHandle,
491
+ } from "@ericchen1990/pano-view";
492
+
493
+ export function WebVRExample() {
494
+ const webVRRef = useRef<WebVRHandle>(null);
495
+
496
+ return (
497
+ <PanoViewer style={{ height: 560 }}>
498
+ <WebVR
499
+ ref={webVRRef}
500
+ chrome
501
+ fakeSupport
502
+ mobileVr
503
+ onEnterVR={(mode) => console.log("Entered", mode)}
504
+ />
505
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
506
+ </PanoViewer>
507
+ );
508
+ }
509
+ ```
510
+
511
+ 句柄暴露 `enterVR()`、`exitVR()`、`toggleVR()`、`isAvailable()`、`isEnabled()`、`getMode()` 与 `requestPermission()`。模式为 `"webxr"`、`"mobilevr"`、`"fake"`。`profile` 接受 `"none"`、`"cardboard-v1"`、`"cardboard-v2"`、`"gear-vr"`、`"daydream"`,或自定义 `{ fov, ipdMm, k1, k2 }` 镜头配置。MobileVR 设置值会版本化并保存在当前源的 `localStorage` 中。VR 中中心准星显示十字;注视可交互热点时绘制顺时针填充环,经 `cursorDwellMs`(默认 1500)后触发热点点击。设 `cursorDwellMs={0}` 可保留准星但不自动点击。模拟桌面 VR(`fake` 模式)默认请求指针锁定(`mousePointerLock`),隐藏光标并用鼠标环视;若浏览器释放锁定需再次点击视图。回退会话通过 Screen Wake Lock API 或在不支持时的循环隐藏视频保持屏幕常亮(`wakelock`)。
512
+
513
+ WebXR 需要 HTTPS 与用户手势。移动端传感器访问也需要 HTTPS,iOS 可能需显式权限提示。嵌入查看器需要适当的 `xr-spatial-tracking`、`gyroscope`、`accelerometer` 权限。iframe 热点、tooltip、视频控件等 DOM 叠加层在沉浸式头戴会话中不可见;基于 mesh 的热点仍可通过默认 WebXR 控制器射线交互。
514
+
515
+ ## 鼠标、触控与键盘控件
516
+
517
+ `PanoViewer` 默认启用 `MouseControls`、`TouchControls` 与 `KeyboardControls`。通过 `controls` 配置共享选项;仅当需要覆盖某通道属性时渲染对应控件组件。子实例会替换默认实例 — 无需先将通道设为 `false`。将 `controls.mouse` / `touch` / `keyboard` 设为 `false` 可关闭该通道。
518
+
519
+ ```tsx
520
+ <PanoViewer
521
+ controls={{
522
+ invert: false,
523
+ bouncingLimits: false,
524
+ mouse: { zoomSpeed: 0.12, wheel: true, buttons: ["left"] },
525
+ touch: { pinchZoom: true },
526
+ keyboard: { enabled: true, rotateSpeed: 90 },
527
+ }}
528
+ style={{ height: 560 }}
529
+ >
530
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
531
+ </PanoViewer>
532
+ ```
533
+
534
+ **鼠标**(指针类型 `mouse` / `pen`):拖拽环视与可选滚轮缩放。默认:`rotateSpeed` `0.35`,`zoomSpeed` `0.08`,`wheel` `true`,`buttons` `["left"]`。
535
+
536
+ ### MouseControls
537
+
538
+ 见上文默认项。通过 `controls.mouse` 或渲染子组件 `<MouseControls />` 覆盖。
539
+
540
+ ### TouchControls
541
+
542
+ 单指拖拽与可选双指缩放(`pinchZoom`,默认 `true`)。通过 `controls.touch` 或渲染 `<TouchControls />` 覆盖。
543
+
544
+ ### KeyboardControls
545
+
546
+ 按住方向键(或自定义绑定)连续环视 / FOV;`0` 重置;可选场景绑定。默认:`rotateSpeed` `60`,`zoomSpeed` `30`,`shiftMultiplier` `3`。`invert` 仅翻转上下方向。通过 `controls.keyboard` 或渲染 `<KeyboardControls />` 覆盖。
547
+
548
+ `controls` 上跨模式共享选项:`enabled`、`invert`(鼠标/触控拖拽方向)、`bouncingLimits`、`fovSpeed`、`frictionStop`(默认 `0.01`),以及既有阻尼 / 惯性选项。
549
+
550
+ ### 覆盖某个控件通道
551
+
552
+ 用所需 props 渲染组件。该通道的默认实例会被跳过:
553
+
554
+ ```tsx
555
+ import { useState } from "react";
556
+ import {
557
+ KeyboardControls,
558
+ Scenes,
559
+ PanoViewer,
560
+ cycleSceneId,
561
+ type Scene,
562
+ } from "@ericchen1990/pano-view";
563
+
564
+ export function KeyboardExample({ scenes }: { scenes: Scene[] }) {
565
+ const [activeSceneId, setActiveSceneId] = useState(scenes[0]!.id);
566
+
567
+ return (
568
+ <PanoViewer style={{ height: 560 }}>
569
+ <KeyboardControls
570
+ keys={{
571
+ left: ["ArrowLeft", "a"],
572
+ right: ["ArrowRight", "d"],
573
+ up: ["ArrowUp", "w"],
574
+ down: ["ArrowDown", "s"],
575
+ zoomIn: ["=", "+"],
576
+ zoomOut: ["-", "_"],
577
+ previousScene: ["[", "PageUp"],
578
+ nextScene: ["]", "PageDown"],
579
+ }}
580
+ rotateSpeed={60}
581
+ zoomSpeed={30}
582
+ onPreviousScene={() => {
583
+ const next = cycleSceneId(scenes, activeSceneId, -1);
584
+ if (next) setActiveSceneId(next);
585
+ }}
586
+ onNextScene={() => {
587
+ const next = cycleSceneId(scenes, activeSceneId, 1);
588
+ if (next) setActiveSceneId(next);
589
+ }}
590
+ />
591
+ <Scenes scenes={scenes} activeSceneId={activeSceneId} />
592
+ </PanoViewer>
593
+ );
594
+ }
595
+ ```
596
+
597
+ 按住移动与缩放键以度/秒连续运动(`rotateSpeed` / `zoomSpeed`)。按住 Shift 乘以倍率(`shiftMultiplier`,默认 `3`)。场景与重置绑定每次按键触发一次。画布须获得焦点才能接收按键(先点击查看器)。
598
+
599
+ 默认绑定:方向键与 WASD 环视,`+/-` 调 FOV,`0` 重置;提供回调时 `[`/`PageUp` 与 `]`/`PageDown` 为上一/下一场景。`MouseControls` 与 `TouchControls` 同样可用子组件覆盖。仅当需要关闭某通道时使用 `controls.mouse={false}` / `controls.touch={false}` / `controls.keyboard={false}`。
600
+
601
+ ## 全景坐标事件
602
+
603
+ 当创作 UI 需要光标下的球面位置时,使用全景指针回调。事件位置与相机使用相同的对外角度约定:yaw 为正向右,pitch 为正向上。
604
+
605
+ ```tsx
606
+ import {
607
+ PanoViewer,
608
+ Sphere,
609
+ panoPositionToVector3,
610
+ type HotspotPosition,
611
+ } from "@ericchen1990/pano-view";
612
+
613
+ export function PlacementExample() {
614
+ const place = (position: HotspotPosition) => {
615
+ const worldPosition = panoPositionToVector3(position, 10);
616
+ console.log(position, worldPosition);
617
+ };
618
+
619
+ return (
620
+ <PanoViewer
621
+ onPanoramaClick={({ position }) => place(position)}
622
+ onPanoramaDoubleClick={({ position }) => console.log("double", position)}
623
+ onPanoramaPointerMove={({ position }) => console.log("move", position)}
624
+ style={{ height: 560 }}
625
+ >
626
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
627
+ </PanoViewer>
628
+ );
629
+ }
630
+ ```
631
+
632
+ `normalizePanoPosition`、`normalizePanoYaw`、`clampPanoPitch` 与 `vector3ToPanoPosition` 也会导出,供受控创作流程使用。Yaw 归一化到 `[-180, 180)`。Pitch 钳制到 `[-90, 90]`;在任一极点,yaw 归一化为 `0`,因为该处 yaw 不唯一确定一点。
633
+
634
+ ## PanoEvents
635
+
636
+ `PanoEvents` 对应 krpano 全局 `<events>` 元素的 React 版本。在 `PanoViewer` 内渲染一个或多个实例以订阅查看器级生命周期与交互回调。多个实例可并存(类似 krpano 命名 events);各自维护 `idleTime`。在自定义子组件内组合时使用 `usePanoEvents`。
637
+
638
+ ### usePanoEvents
639
+
640
+ ```tsx
641
+ import {
642
+ AutoRotate,
643
+ PanoEvents,
644
+ PanoViewer,
645
+ Sphere,
646
+ } from "@ericchen1990/pano-view";
647
+
648
+ <PanoViewer style={{ height: 560 }}>
649
+ <PanoEvents
650
+ idleTime={3000}
651
+ onIdle={() => console.log("idle")}
652
+ onIdleEnd={() => console.log("active again")}
653
+ onViewSettled={(view) => console.log("settled", view)}
654
+ onViewInteractionStart={({ source }) => console.log("drag", source)}
655
+ onClick={({ position }) => console.log("pano click", position)}
656
+ onEnterFullscreen={() => console.log("fullscreen")}
657
+ onAutoRotateOneRound={() => console.log("full turn")}
658
+ />
659
+ <AutoRotate enabled />
660
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
661
+ </PanoViewer>;
662
+ ```
663
+
664
+ 支持的回调(适用处标注 krpano 名称):
665
+
666
+ - 视图:`onViewChange`(`onviewchange`)、`onViewSettled`、`onViewInteractionStart` / `onViewInteractionEnd`
667
+ - 全景外壳上的指针(非热点):`onClick`、`onDoubleClick`、`onPointerDown`、`onPointerUp`、`onPointerMove`、`onContextMenu`
668
+ - `onWheel` — 仅鼠标滚轮;触控捏合不会合成 wheel 事件
669
+ - 空闲:`onIdle` / `onIdleEnd`,每实例 `idleTime`(毫秒,默认 `2000`)
670
+ - 全屏:`onEnterFullscreen` / `onExitFullscreen`
671
+ - `onResize` — 通过 `ResizeObserver` 获取画布 content-box 尺寸
672
+ - 自动旋转:`onAutoRotateStart` / `onAutoRotateStop` / `onAutoRotateOneRound`
673
+ - 陀螺仪:`onGyroAvailable` / `onGyroUnavailable` / `onGyroEnable` / `onGyroDisable` / `onGyroDenied`
674
+
675
+ `PanoViewer` 现有的 `onViewChange` / `onPanoramaClick` / `onPanoramaDoubleClick` / `onPanoramaPointerMove` props 仍受支持,共用同一事件总线。
676
+
677
+ 资源加载与场景混合仍由各自组件负责:使用 `Sphere` / `Tile` 的 `onLoad` / `onError` / `onLoadProgress`,以及 `Scenes` 的 `onTransitionEnd` / `onTransitionError`。本包不镜像 krpano 的 xml/VR/帧渲染事件。
678
+
679
+ ## PanoContextMenu
680
+
681
+ `PanoViewer` 挂载默认右键菜单,右键时替换浏览器菜单。默认项:**Reset view** 与 **Enter fullscreen** / **Exit fullscreen**(文案与图标随全屏状态变化),中间有分隔线。
682
+
683
+ ```tsx
684
+ <PanoViewer style={{ height: 560 }}>
685
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
686
+ </PanoViewer>
687
+ ```
688
+
689
+ 关闭默认菜单(恢复浏览器菜单,或挂载自定义菜单):
690
+
691
+ ```tsx
692
+ <PanoViewer contextMenu={false}>
693
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
694
+ </PanoViewer>
695
+ ```
696
+
697
+ 保留默认项的同时调整外观:
698
+
699
+ ```tsx
700
+ <PanoViewer contextMenu={{ appearance: { opacity: 0.92, borderRadius: 8 } }}>
701
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
702
+ </PanoViewer>
703
+ ```
704
+
705
+ ### 在不重建默认项的情况下添加项
706
+
707
+ 使用 `append` 或 `prepend` 保留内置 Reset / Fullscreen 条目,仅添加自定义项:
708
+
709
+ ```tsx
710
+ <PanoViewer
711
+ contextMenu={{
712
+ append: [
713
+ "separator",
714
+ {
715
+ id: "copy",
716
+ label: "Copy yaw / pitch",
717
+ onSelect: ({ position }) => {
718
+ void navigator.clipboard.writeText(
719
+ `${position.yaw.toFixed(1)}, ${position.pitch.toFixed(1)}`,
720
+ );
721
+ },
722
+ },
723
+ ],
724
+ }}
725
+ style={{ height: 560 }}
726
+ >
727
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
728
+ </PanoViewer>
729
+ ```
730
+
731
+ ### 内置预设
732
+
733
+ 需要自定义顺序时,按 id 复用内置预设,无需重写全屏进入/退出状态:
734
+
735
+ - `"resetView"` — 重置视图
736
+ - `"fullscreen"` — 进入 / 退出全屏及对应图标
737
+ - `"separator"` — 水平分隔线
738
+
739
+ ```tsx
740
+ <PanoViewer
741
+ contextMenu={{
742
+ items: [
743
+ "resetView",
744
+ "separator",
745
+ {
746
+ id: "copy",
747
+ label: "Copy yaw / pitch",
748
+ onSelect: ({ position }) => {
749
+ void navigator.clipboard.writeText(
750
+ `${position.yaw.toFixed(1)}, ${position.pitch.toFixed(1)}`,
751
+ );
752
+ },
753
+ },
754
+ "separator",
755
+ "fullscreen",
756
+ ],
757
+ }}
758
+ style={{ height: 560 }}
759
+ >
760
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
761
+ </PanoViewer>
762
+ ```
763
+
764
+ 在预设上用 `{ preset: "fullscreen", label: "…" }` 覆盖展示。在 prop 外完全手动组装时(例如自定义 `PanoContextMenu` 子组件),调用 `createPanoContextMenuPresets({ reset, toggleFullscreen, isFullscreen })` 并使用 `.resetView` / `.fullscreen` / `.defaults`。
765
+
766
+ 具体项可使用 `icon` React 节点或 `image` URL。分隔线始终来自 `"separator"` 预设。外观涵盖背景、文字色、边框、圆角、阴影、仅背景的 `opacity`(从 `MIN_PANO_CONTEXT_MENU_BACKGROUND_OPACITY` / `0.4` 到 `1` 钳制;标签与图标保持不透明)、悬停/禁用色与图标尺寸。若需半透明填充而不使用 `opacity`,仍可传 `background: "rgba(...)"`。
767
+
768
+ 完全自定义 DOM 菜单时,设 `contextMenu={false}` 并渲染 `PanoContextMenu` 子组件。不要同时挂载默认菜单与子 `PanoContextMenu`。避免与 `controls.mouse.buttons: ["right"]` 同时使用 — 两者都会占用右键。
769
+
770
+ ## 共享热点约定与保存的定义
771
+
772
+ 每个热点有 `id`,以及可选的 `visible`、`interactive`、`pointerEvents`、`renderOrder` 与语义交互回调。点热点此外还使用受控 `position`、角度 `width`/`height`、热点 `mode` 与下文缩放选项。`interactive={false}` 保持热点可见,同时让绘制工具接收其下方的全景指针事件。`pointerEvents="none"` 忽略鼠标、触控与笔,事件穿透到全景,热点仍可键盘交互。可交互热点悬停时画布光标为 `pointer`;若设置了热点 `cursor` 则使用该值。
773
+
774
+ ```tsx
775
+ <TextHotspot
776
+ id="caption"
777
+ pointerEvents="none"
778
+ position={{ yaw: 12, pitch: -8 }}
779
+ text="Decorative label"
780
+ />
781
+ ```
782
+
783
+ `onClick` 与 `onHoverChange` 接收位置与输入源(`"pointer"` 或 `"keyboard"`)。点热点可使用 `draggable`、`onDragStart`、`onPositionChange`、`onDragEnd`;宿主须将报告的位置写回状态。多边形与折线热点通过 `onVerticesChange` 报告受控 `vertices`。
784
+
785
+ 每个热点可在锚点显示 DOM tooltip。将字符串或 `{ text?, image?, imageAlt? }` 作为 `tooltip` 传入。默认 `tooltipTrigger` 为 `"always"`:有内容且热点可见时气泡常驻屏幕。点击热点不会切换显示。可选 `tooltipTrigger="hover"` 在指针悬停或键盘焦点时显示;`"click"` 固定显示直至再次点击热点或点击空白全景。hover 与 click 触发需要 `interactive` 且 `pointerEvents` 不为 `"none"`。
786
+
787
+ `tooltipPlacement` 为 `"top"`(默认)、`"bottom"`、`"left"`、`"right"`。均为屏幕方向:气泡位于热点投影边界该侧之外,避免遮挡旋转后的图形(如箭头)。`tooltipOffset` 为屏幕空间间距(CSS 像素,默认 `12` / `DEFAULT_HOTSPOT_TOOLTIP_OFFSET`)。
788
+
789
+ 可选 `tooltipAppearance` 覆盖气泡绘制。未设字段保留库默认主题(`DEFAULT_HOTSPOT_TOOLTIP_APPEARANCE`):背景、文字色、边框、圆角、阴影、内边距与字号。
790
+
791
+ ```tsx
792
+ <ImageHotspot
793
+ id="gallery"
794
+ ariaLabel="Open gallery"
795
+ position={{ yaw: 28, pitch: -4 }}
796
+ src="/hotspots/gallery.webp"
797
+ tooltip={{ text: "Courtyard gallery", image: "/hotspots/gallery-thumb.webp" }}
798
+ tooltipPlacement="top"
799
+ tooltipOffset={16}
800
+ tooltipAppearance={{
801
+ background: "rgba(12, 28, 36, 0.88)",
802
+ color: "#e7f2f5",
803
+ border: "1px solid rgba(56, 84, 91, 0.9)",
804
+ borderRadius: 10,
805
+ shadow: "0 10px 28px rgba(0, 0, 0, 0.42)",
806
+ padding: 10,
807
+ fontSize: 13,
808
+ }}
809
+ />
810
+ <GraphicHotspot
811
+ id="marker"
812
+ ariaLabel="Signal"
813
+ position={{ yaw: -18, pitch: 9 }}
814
+ graphic={{ kind: "ring" }}
815
+ tooltip="Hover for details"
816
+ tooltipTrigger="hover"
817
+ tooltipPlacement="right"
818
+ />
819
+ ```
820
+
821
+ 持久化或宿主编辑器可使用导出的判别联合类型 `HotspotDefinition`。新增变体会破坏 exhaustive switch 的类型兼容;除现有点与路径类别外,还需处理 `text` 与 `iframe`:
822
+
823
+ ```ts
824
+ import type { HotspotDefinition } from "@ericchen1990/pano-view";
825
+
826
+ const hotspots: HotspotDefinition[] = [
827
+ { type: "image", id: "gallery", position: { yaw: 24, pitch: -5 }, src: "/hotspots/card.webp" },
828
+ { type: "graphic", id: "marker", position: { yaw: -18, pitch: 9 }, graphic: { kind: "ring" } },
829
+ { type: "sequence", id: "pulse", position: { yaw: -42, pitch: -7 }, src: "/hotspots/pulse.png", frameCount: 20 },
830
+ { type: "video", id: "clip", position: { yaw: 48, pitch: 6 }, src: "/hotspots/clip.webm" },
831
+ { type: "text", id: "caption", position: { yaw: 0, pitch: -16 }, text: "Courtyard overlook" },
832
+ { type: "iframe", id: "guide", position: { yaw: -62, pitch: 4 }, src: "/hotspots/embed.html" },
833
+ { type: "polygon", id: "zone", vertices: [{ yaw: 12, pitch: 4 }, { yaw: 22, pitch: 4 }, { yaw: 18, pitch: 14 }] },
834
+ { type: "polyline", id: "route", vertices: [{ yaw: -8, pitch: 1 }, { yaw: 4, pitch: 8 }] },
835
+ ];
836
+ ```
837
+
838
+ 定义仅含数据。在宿主中用 switch 渲染,应用特定的点击行为、受控媒体状态与错误报告属于宿主职责。
839
+
840
+ ## ImageHotspot
841
+
842
+ `ImageHotspot` 在受控球面位置渲染图像。尺寸为角度(度),与画布分辨率无关。
843
+
844
+ ```tsx
845
+ import { ImageHotspot, PanoViewer, Sphere } from "@ericchen1990/pano-view";
846
+
847
+ <PanoViewer style={{ height: 560 }}>
848
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
849
+ <ImageHotspot
850
+ id="gallery"
851
+ ariaLabel="Open gallery"
852
+ position={{ yaw: 28, pitch: -4 }}
853
+ width={18}
854
+ height={10}
855
+ mode="surface"
856
+ src="/hotspots/gallery.webp"
857
+ onClick={({ position }) => console.log("gallery", position)}
858
+ />
859
+ </PanoViewer>;
860
+ ```
861
+
862
+ 设 `draggable` 并在 `onPositionChange` 中更新受控位置以移动选中热点。可点击热点需要 `ariaLabel`:PanoViewer 会创建内部语义控件以支持 Tab、Enter 与 Space,并显示 WebGL 焦点轮廓。
863
+
864
+ `ImageHotspot` 调用 `onLoad(texture)` 与 `onError(error)`。
865
+
866
+ ## GraphicHotspot
867
+
868
+ `GraphicHotspot` 接受内置 `circle`、`triangle`、`diamond`、`star`、`arrow`、`rectangle`、`ring` 图形、SVG URL,或带显式 viewBox 的安全 SVG path 数据。内置图形支持 `fill`、`stroke`、`strokeWidth`;矩形支持相对 `cornerRadius`(`0` 到 `0.5`);环支持 `innerRadius`。
869
+
870
+ ```tsx
871
+ import { GraphicHotspot } from "@ericchen1990/pano-view";
872
+
873
+ <GraphicHotspot
874
+ id="wayfinding"
875
+ ariaLabel="Open wayfinding point"
876
+ position={{ yaw: -18, pitch: 9 }}
877
+ width={8}
878
+ height={8}
879
+ graphic={{
880
+ kind: "ring",
881
+ fill: "#df6b42",
882
+ stroke: "#f5fbfc",
883
+ strokeWidth: 8,
884
+ innerRadius: 0.64,
885
+ }}
886
+ onClick={({ position }) => console.log("wayfinding", position)}
887
+ />
888
+ ```
889
+
890
+ 三角形、菱形、星形与箭头使用相同绘制属性。三角形与箭头在 `rotation={0}` 时指向上方;用热点 `rotation` 调整方向。
891
+
892
+ ```tsx
893
+ <GraphicHotspot
894
+ id="direction"
895
+ ariaLabel="Open next scene"
896
+ position={{ yaw: 8, pitch: 2 }}
897
+ rotation={45}
898
+ width={8}
899
+ height={8}
900
+ graphic={{
901
+ kind: "arrow",
902
+ fill: "#df6b42",
903
+ stroke: "#f5fbfc",
904
+ strokeWidth: 8,
905
+ }}
906
+ />
907
+ ```
908
+
909
+ `cornerRadius` 相对于渲染图形较短边:`0` 为直角,`0.5` 为最大圆角。这取代了此前基于 Canvas 纹理像素的解释方式。
910
+
911
+ `GraphicHotspot` 与 `ImageHotspot` 相同,使用 `onLoad(texture)` 与 `onError(error)`。内置 path 本地栅格化;仅接受 SVG URL 或 SVG `path` 加显式 `viewBox`,不接受任意 SVG 标记。
912
+
913
+ ## 热点模式与缩放行为
914
+
915
+ 点热点使用统一的 `mode`,而非分离的方向与 placement 属性:
916
+
917
+ - `mode="surface"` 将平面附着于局部全景表面。
918
+ - `mode="billboard"` 使平面始终朝向相机并浮于壳层前方。用 `distance`(世界单位,默认 `10`)拉近。内置全景壳层约在 `1000` 单位;距离会自动上限,避免热点角点穿过壳层。
919
+ - `scaleMode="fov"`(默认)在用户放大时热点变大。`scaleMode="fixed"` 补偿 FOV 变化,屏幕尺寸接近 `referenceFov`(默认 `75`)时的大小。
920
+ - `rotation` 绕自身法线旋转(度);正值顺时针,负值逆时针。`scale` 为角向 width/height 的整体正倍率,默认 `1`。
921
+
922
+ ```tsx
923
+ <ImageHotspot
924
+ id="callout"
925
+ ariaLabel="Open callout"
926
+ position={{ yaw: 18, pitch: 5 }}
927
+ width={10}
928
+ height={6}
929
+ mode="billboard"
930
+ distance={14}
931
+ rotation={-12}
932
+ scale={1.25}
933
+ scaleMode="fixed"
934
+ src="/hotspots/callout.webp"
935
+ />
936
+ ```
937
+
938
+ ## PolygonHotspot
939
+
940
+ `PolygonHotspot` 用三个及以上受控 yaw/pitch 顶点渲染局部球面区域。支持凹多边形与 `-180°/180°` 接缝。组件会报告(而非渲染)自相交、包含极点或无法放入单半球的多边形。
941
+
942
+ ```tsx
943
+ import { PolygonHotspot } from "@ericchen1990/pano-view";
944
+
945
+ <PolygonHotspot
946
+ id="exhibit-zone"
947
+ ariaLabel="Open exhibit zone"
948
+ vertices={[
949
+ { yaw: 16, pitch: 8 },
950
+ { yaw: 31, pitch: 7 },
951
+ { yaw: 27, pitch: 19 },
952
+ { yaw: 19, pitch: 16 },
953
+ ]}
954
+ fill="#df6b42"
955
+ fillOpacity={0.32}
956
+ stroke="#f5fbfc"
957
+ strokeWidth={2}
958
+ strokeOpacity={0.8}
959
+ strokeDashSize={8}
960
+ strokeGapSize={4}
961
+ draggable
962
+ onVerticesChange={({ vertices }) => setZoneVertices(vertices)}
963
+ onInvalid={(issues) => console.error(issues)}
964
+ />
965
+ ```
966
+
967
+ `strokeWidth` 为屏幕空间 CSS 像素,FOV 变化时视觉一致。`strokeDashSize` 与 `strokeGapSize` 同为 CSS 像素;省略或设 `strokeDashSize` 为 `0` 为实线。拖拽整体平移所有顶点,同时归一化 yaw、钳制 pitch。单顶点编辑由创作工作流提供,非运行时热点组件职责。
968
+
969
+ 设 `fillOpacity={0}` 可得仅轮廓闭合多边形。填充与轮廓共用相同球面边采样,无意图性接缝。
970
+
971
+ ## PolylineHotspot
972
+
973
+ `PolylineHotspot` 为至少两个 yaw/pitch 顶点的开放路径。与 `PolygonHotspot` 相同,使用 CSS 像素 `strokeWidth`、可选虚线(`strokeDashSize` / `strokeGapSize`)、可见性、语义交互、受控整路径拖拽与顶点变更回调,但不连接末点与首点,且无填充。
974
+
975
+ ```tsx
976
+ import { PolylineHotspot } from "@ericchen1990/pano-view";
977
+
978
+ <PolylineHotspot
979
+ id="guided-route"
980
+ ariaLabel="Guided route"
981
+ vertices={routeVertices}
982
+ stroke="#75cbd3"
983
+ strokeWidth={2}
984
+ strokeOpacity={0.9}
985
+ strokeDashSize={8}
986
+ strokeGapSize={4}
987
+ draggable
988
+ onVerticesChange={({ vertices }) => setRouteVertices(vertices)}
989
+ />;
990
+ ```
991
+
992
+ `onInvalid` 仅报告一种无效运行时形状:少于两个顶点。
993
+
994
+ ## SequenceHotspot
995
+
996
+ `SequenceHotspot` 动画播放精灵图:等尺寸帧组成的竖向或横向条带。与常见 krpano 动画热点格式一致。`width` 与 `height` 应使用单帧宽高比,而非整条带。`playing` 由宿主受控;`fps` 默认 `12`,`loop` 默认 `true`。
997
+
998
+ ```tsx
999
+ import { SequenceHotspot } from "@ericchen1990/pano-view";
1000
+
1001
+ <SequenceHotspot
1002
+ id="pulse"
1003
+ ariaLabel="Play marker sequence"
1004
+ position={{ yaw: -42, pitch: -7 }}
1005
+ width={8}
1006
+ height={8}
1007
+ src="/hotspots/pulse-strip.png"
1008
+ frameCount={20}
1009
+ frameDirection="vertical"
1010
+ fps={12}
1011
+ loop
1012
+ playing={isPulsePlaying}
1013
+ onEnded={() => setPulsePlaying(false)}
1014
+ onError={({ error }) => console.error(error)}
1015
+ />;
1016
+ ```
1017
+
1018
+ 序列加载使用 `onLoadProgress` 与 `onError`。组件不会自行改变 `playing`:请在 UI、点击处理、`onEnded` 或播放错误中更新该 prop。
1019
+
1020
+ ## VideoHotspot
1021
+
1022
+ `VideoHotspot` 使用 `HTMLVideoElement` 与 `VideoTexture`。其 `playing` prop 同样受控:在点击处理或应用状态中切换。源变更或卸载时元素暂停并释放纹理。`playsInline` 默认 `true`,`muted` 默认 `true`,`loop` 默认 `false`,`preload` 默认 `"metadata"`。
1023
+
1024
+ ```tsx
1025
+ import { VideoHotspot } from "@ericchen1990/pano-view";
1026
+
1027
+ <VideoHotspot
1028
+ id="clip"
1029
+ ariaLabel="Play room video"
1030
+ position={{ yaw: 48, pitch: 6 }}
1031
+ width={18}
1032
+ height={10.125}
1033
+ mode="surface"
1034
+ src="/hotspots/room.webm"
1035
+ poster="/hotspots/room-poster.webp"
1036
+ playing={isClipPlaying}
1037
+ muted
1038
+ volume={0.8}
1039
+ onClick={() => setClipPlaying((playing) => !playing)}
1040
+ onEnded={() => setClipPlaying(false)}
1041
+ onPlaybackStateChange={(state) => console.log(state)}
1042
+ onPlaybackError={(error) => console.error(error)}
1043
+ />;
1044
+ ```
1045
+
1046
+ 浏览器可能拒绝非静音或非手势播放。此时 `onPlaybackStateChange` 收到 `"blocked"`,`onPlaybackError` 收到浏览器错误;宿主仍控制 `playing` 并提供显式用户操作。
1047
+
1048
+ 视频通过 `onError` 报告媒体与 poster 失败。与 `SequenceHotspot` 相同,不会自行改变 `playing`,以保证源变更、卸载与 React StrictMode 生命周期可预测。
1049
+
1050
+ ## TextHotspot
1051
+
1052
+ `TextHotspot` 将纯文本栅格化到 canvas 纹理。不接受 HTML 或 Markdown。`\n` 为硬换行;`whiteSpace="normal"`(默认)在热点角向宽度内自动换行。尺寸与其他点热点一样为度。排版使用与 CSS 相同的名称(`fontFamily`、`fontSize`、`fontWeight`、`fontStyle`),但绘制在 canvas 上,非 DOM 样式。`fontSize` 为 canvas 像素(默认 96),不随面板高度缩放。`fontWeight` 默认 `600`,`fontStyle` 默认 `"normal"`。`padding` 与 `borderRadius` 仍为纹理较短边的比例,故仍随 `width` / `height` 变化。
1053
+
1054
+ ```tsx
1055
+ import { TextHotspot } from "@ericchen1990/pano-view";
1056
+
1057
+ <TextHotspot
1058
+ id="caption"
1059
+ ariaLabel="Courtyard overlook"
1060
+ position={{ yaw: 0, pitch: -16 }}
1061
+ width={16}
1062
+ height={6}
1063
+ mode="billboard"
1064
+ scaleMode="fixed"
1065
+ text={"Courtyard overlook\nNorth terrace"}
1066
+ fontFamily="system-ui, sans-serif"
1067
+ fontSize={96}
1068
+ fontWeight={600}
1069
+ fontStyle="normal"
1070
+ align="center"
1071
+ verticalAlign="middle"
1072
+ color="#f8fafc"
1073
+ background="#111827"
1074
+ backgroundOpacity={0.72}
1075
+ />;
1076
+ ```
1077
+
1078
+ 在热点绘制前于页面加载自定义 `fontFamily`。存在 `document.fonts.load` 时热点会等待,再用文档已有字体绘制。`onLoad(texture)` 与 `onError(error)` 与 `ImageHotspot` 一致。
1079
+
1080
+ ## IframeHotspot
1081
+
1082
+ `IframeHotspot` 通过 `@react-three/drei` 的 `Html` `transform` 嵌入文档,故与纹理平面相同,遵循热点 mode、rotation 与缩放。iframe 为 DOM 叠加层,不会被全景壳层遮挡。无 `srcdoc` prop。
1083
+
1084
+ ```tsx
1085
+ import { IframeHotspot } from "@ericchen1990/pano-view";
1086
+
1087
+ <IframeHotspot
1088
+ id="guide"
1089
+ ariaLabel="Open visitor guide"
1090
+ position={{ yaw: -62, pitch: 4 }}
1091
+ width={18}
1092
+ height={12}
1093
+ mode="billboard"
1094
+ src="/hotspots/embed.html"
1095
+ pointerPolicy="hotspot"
1096
+ />;
1097
+ ```
1098
+
1099
+ `pointerPolicy="hotspot"`(默认)将 iframe 设为 `pointer-events: none`,点击与拖拽作用于 WebGL 平面。`"content"` 使页面可交互并阻止穿透拖拽。热点 `pointerEvents="none"` 也会强制 iframe 叠加层不捕获指针。`sandbox` 默认为 `allow-scripts allow-popups allow-forms`,不含 `allow-same-origin`。`referrerPolicy` 默认为 `strict-origin-when-cross-origin`。优先同源文档;许多第三方站点发送 `X-Frame-Options` 无法加载。
1100
+
1101
+ ## Next.js 与 SSR
1102
+
1103
+ 分发入口标记为客户端模块。请在 Client Component 边界之下渲染。全景资源在浏览器加载;无需 API 密钥或服务器配置。
1104
+
1105
+ 在 Client Component 或全局 CSS 中导入一次样式表:
1106
+
1107
+ ```ts
1108
+ import "@ericchen1990/pano-view/styles.css";
1109
+ ```
1110
+
1111
+ 见 [样式表(HTML 叠加层)](#样式表html-叠加层)。