@ericchen1990/pano-view 0.2.0 → 2.0.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.
package/README.md CHANGED
@@ -15,32 +15,230 @@ compatible with that tile output format but is not affiliated with krpano.
15
15
  ## Install
16
16
 
17
17
  ```bash
18
- npm install @ericchen1990/pano-view react react-dom three @react-three/fiber
18
+ npm install @ericchen1990/pano-view
19
19
  ```
20
20
 
21
- React 19, React DOM 19, `@react-three/fiber` 9, and Three.js are peer dependencies.
21
+ React 19, React DOM 19, Three.js, `@react-three/fiber` 9,
22
+ `@react-three/drei` 10, and `@react-three/xr` 6 are peer dependencies. npm 7+
23
+ and pnpm install them automatically; they must be a single copy in the host
24
+ app.
22
25
 
23
- ## Sphere panorama
26
+ Tailwind CSS v4 is now required for the built-in HTML chrome: video controls,
27
+ captions, context menus, tooltips, and accessibility helpers render with
28
+ Tailwind utility classes instead of a bundled stylesheet. Add a source entry in
29
+ your app so Tailwind can scan this package:
24
30
 
25
- `PanoView` owns the canvas, perspective camera, and controls. Give its container an explicit size and place one panorama source inside it.
31
+ ```css
32
+ @import "tailwindcss";
33
+ @source "../node_modules/@ericchen1990/pano-view";
34
+ ```
35
+
36
+ This package does not ship a separate CSS file. If your app does not scan
37
+ `@ericchen1990/pano-view`, those built-in overlays will render without their
38
+ default styling. Customization still works through `className`, `style`, and
39
+ component appearance props.
40
+
41
+ ## Exported components
42
+
43
+ ### Viewer
44
+
45
+ - [`PanoViewer`](#panoviewer) — canvas shell, camera, default controls, and imperative view API
46
+ - [`Sphere`](#sphere) — single 2:1 equirectangular image
47
+ - [`PanoVideo`](#panovideo) — 2:1 equirectangular video on the panorama sphere
48
+ - [`Tile`](#tile) — krpano-style multires cube tiles
49
+ - [`Scenes`](#scenes) — controlled multi-scene transitions
50
+ - [`PanoFilter`](#panofilter) — color and artistic filters on the panorama source
51
+
52
+ ### Controls
53
+
54
+ - [`MouseControls`](#mousecontrols) — drag look and wheel zoom
55
+ - [`TouchControls`](#touchcontrols) — one-finger drag and pinch zoom
56
+ - [`KeyboardControls`](#keyboardcontrols) — arrow-key look, FOV, and scene cycling
57
+ - [`AutoRotate`](#autorotate) — automatic yaw rotation
58
+ - [`Gyro`](#gyro) — opt-in device-orientation control
59
+ - [`WebVR`](#webvr) — WebXR-first immersive VR with MobileVR fallback
60
+
61
+ ### Events and chrome
62
+
63
+ - [`PanoEvents`](#panoevents) — viewer-level lifecycle and interaction callbacks
64
+ - [`PanoContextMenu`](#panocontextmenu) — right-click reset / fullscreen menu
65
+ - [`PanoVideoControls`](#panovideo) — playback chrome for `PanoVideo` (mounted by default)
66
+
67
+ ### Hotspots
68
+
69
+ - [`ImageHotspot`](#imagehotspot) — image texture at a spherical position
70
+ - [`GraphicHotspot`](#graphichotspot) — built-in shapes or SVG at a position
71
+ - [`SequenceHotspot`](#sequencehotspot) — sprite-sheet animation
72
+ - [`VideoHotspot`](#videohotspot) — HTML video texture
73
+ - [`TextHotspot`](#texthotspot) — rasterized plain text at a position
74
+ - [`IframeHotspot`](#iframehotspot) — embedded document overlay at a position
75
+ - [`PolygonHotspot`](#polygonhotspot) — closed spherical area
76
+ - [`PolylineHotspot`](#polylinehotspot) — open spherical path
77
+
78
+ ### Hooks and helpers
79
+
80
+ - `usePanoEvents` — subscribe to viewer events inside a custom child ([`PanoEvents`](#panoevents))
81
+ - Coordinate helpers — `normalizePanoPosition`, `normalizePanoYaw`, `clampPanoPitch`, `panoPositionToVector3`, `vector3ToPanoPosition` ([Panorama coordinate events](#panorama-coordinate-events))
82
+ - `cycleSceneId` — wrap previous/next scene ids ([`KeyboardControls`](#keyboardcontrols))
83
+ - Context menu helpers — `createPanoContextMenuPresets`, `composePanoContextMenuItems`, and related APIs ([`PanoContextMenu`](#panocontextmenu))
84
+ - Polygon / polyline validation — `validatePolygonVertices`, `validatePolylineVertices`, `unwrapPolygonVertices`
85
+
86
+ ## PanoViewer
87
+
88
+ `PanoViewer` owns the canvas, perspective camera, and controls. Give its container an explicit size and place one panorama source inside it.
89
+
90
+ Angles are public degrees. Positive yaw looks right and positive pitch looks up.
91
+
92
+ ```tsx
93
+ import { useRef } from "react";
94
+ import {
95
+ PanoViewer,
96
+ Sphere,
97
+ AutoRotate,
98
+ type PanoViewerHandle,
99
+ } from "@ericchen1990/pano-view";
100
+
101
+ export function ControlledExample() {
102
+ const ref = useRef<PanoViewerHandle>(null);
103
+
104
+ return (
105
+ <>
106
+ <button onClick={() => ref.current?.setView({ yaw: 90, fov: 55 })}>
107
+ Look right
108
+ </button>
109
+ <PanoViewer
110
+ ref={ref}
111
+ controls={{
112
+ inertia: true,
113
+ rotateDamping: 14,
114
+ zoomDamping: 16,
115
+ }}
116
+ initialView={{ yaw: 0, pitch: 0, fov: 75 }}
117
+ minFov={30}
118
+ maxFov={100}
119
+ onViewChange={(view) => console.log(view)}
120
+ style={{ height: 560 }}
121
+ >
122
+ <AutoRotate enabled speed={18} acceleration={18} startDelay={1_000} />
123
+ <Sphere src="/panoramas/room.webp" />
124
+ </PanoViewer>
125
+ </>
126
+ );
127
+ }
128
+ ```
129
+
130
+ The handle exposes `getView`, `setView`, `reset`, `startAutoRotate`, `stopAutoRotate`, and `toggleFullscreen`. Mouse, touch, and keyboard input are enabled by default — you do not need to render control components for ordinary viewing. Render `MouseControls`, `TouchControls`, or `KeyboardControls` only to override properties (the child replaces that channel's default instance). Tune shared behaviour through `controls` (`inertia`, `invert`, `bouncingLimits`, `fovSpeed`, `frictionStop`, `rotateDamping`, `zoomDamping`, and top-level `rotateSpeed` / `zoomSpeed`). Disable a channel with `controls.mouse` / `touch` / `keyboard` set to `false`, or pass an options object (including `enabled`) to override defaults without mounting a child. Auto-rotation is off until you render `AutoRotate`. The two auto-rotation handle methods remain supported for compatibility. A default context menu (Reset view / Enter fullscreen) is also mounted — see [`PanoContextMenu`](#panocontextmenu).
131
+
132
+ Drag and zoom update a target view that the camera follows smoothly. `rotateDamping` and `zoomDamping` control that following speed in seconds^-1 (defaults: `14` and `16` respectively); lower values feel softer, while `0` disables smoothing for that axis. Both values must be non-negative finite numbers. Imperative `setView()` and `reset()` remain immediate.
133
+
134
+ The canvas cursor defaults to `grab`, becomes `grabbing` while dragging with the mouse, `pointer` over an interactive hotspot, and `move` while dragging a hotspot. Override those values with `cursors` (`default`, `dragging`, `hotspot`, `hotspotDragging`), or pass `cursors={false}` to leave the canvas cursor unchanged. A hotspot may set `cursor` to replace the hover cursor for that hotspot only.
135
+
136
+ ```tsx
137
+ <PanoViewer
138
+ cursors={{ default: "grab", dragging: "grabbing", hotspot: "pointer" }}
139
+ style={{ height: 560 }}
140
+ >
141
+ <ImageHotspot
142
+ id="door"
143
+ cursor="zoom-in"
144
+ position={{ yaw: 24, pitch: -6 }}
145
+ src="/hotspots/door.webp"
146
+ />
147
+ </PanoViewer>
148
+ ```
149
+
150
+ ## Sphere
151
+
152
+ `Sphere` expects a 2:1 equirectangular image. Use `yawOffset` when the source's forward direction needs horizontal adjustment.
26
153
 
27
154
  ```tsx
28
155
  "use client";
29
156
 
30
- import { PanoView, Sphere } from "@ericchen1990/pano-view";
157
+ import { PanoViewer, Sphere } from "@ericchen1990/pano-view";
31
158
 
32
159
  export function SphereExample() {
33
160
  return (
34
- <PanoView style={{ width: "100%", height: 560 }}>
161
+ <PanoViewer style={{ width: "100%", height: 560 }}>
35
162
  <Sphere src="/panoramas/room.webp" />
36
- </PanoView>
163
+ </PanoViewer>
37
164
  );
38
165
  }
39
166
  ```
40
167
 
41
- `Sphere` expects a 2:1 equirectangular image. Use `yawOffset` when the source's forward direction needs horizontal adjustment.
168
+ ## PanoVideo
169
+
170
+ `PanoVideo` maps a 2:1 equirectangular video onto the same inward sphere as
171
+ `Sphere`. Pass one `src`, or `variants` with multiple files per quality so the
172
+ browser can pick mp4 / webm and the control bar can switch resolution. Changing
173
+ quality keeps the current time and play/pause state.
174
+
175
+ A default playback bar is mounted on the viewer overlay: play/pause, seek, time,
176
+ volume, speed, resolution, captions, and fullscreen. Volume is a hover (or tap)
177
+ vertical slider when the browser allows `HTMLMediaElement.volume`; iOS Safari
178
+ only exposes mute. The speed menu lists rates the current element accepts. The
179
+ captions menu also includes a settings panel for subtitle font size and
180
+ background opacity. When the viewer is narrow, speed, quality, and captions
181
+ move into a More menu.
182
+ Pass `controls={false}` to hide it, an appearance object to restyle the default
183
+ instance, or render `PanoVideoControls` as a `PanoViewer` child to replace that
184
+ instance.
185
+
186
+ Captions use WebVTT `tracks`. The video element is off-screen, so cue text is
187
+ rendered as a HUD overlay. `captions={false}` hides the overlay and the language
188
+ menu; an object merges with the default appearance (`color`, `background`,
189
+ `fontSize`, `fontFamily`, `textShadow`, `padding`, `borderRadius`, `maxWidth`,
190
+ `bottom`). Those values act as the initial caption appearance; viewers can still
191
+ adjust subtitle size and background from the default control-bar menu at
192
+ runtime.
193
+
194
+ ```tsx
195
+ "use client";
196
+
197
+ import { PanoViewer, PanoVideo } from "@ericchen1990/pano-view";
42
198
 
43
- ## Cube Tile panorama
199
+ export function VideoExample() {
200
+ return (
201
+ <PanoViewer style={{ width: "100%", height: 560 }}>
202
+ <PanoVideo
203
+ defaultVariantId="1024"
204
+ variants={[
205
+ {
206
+ id: "1024",
207
+ label: "1024p",
208
+ poster: "/video/1024-poster.jpg",
209
+ sources: [
210
+ { src: "/video/1024.mp4", type: "video/mp4" },
211
+ { src: "/video/1024.webm", type: "video/webm" },
212
+ ],
213
+ },
214
+ {
215
+ id: "1920",
216
+ label: "1920p",
217
+ poster: "/video/1920-poster.jpg",
218
+ sources: [
219
+ { src: "/video/1920.mp4", type: "video/mp4" },
220
+ { src: "/video/1920.webm", type: "video/webm" },
221
+ ],
222
+ },
223
+ ]}
224
+ tracks={[
225
+ { src: "/video/en.vtt", srcLang: "en", label: "English", default: true },
226
+ { src: "/video/zh.vtt", srcLang: "zh", label: "中文" },
227
+ ]}
228
+ captions={{ fontSize: 16, color: "#fff" }}
229
+ muted
230
+ />
231
+ </PanoViewer>
232
+ );
233
+ }
234
+ ```
235
+
236
+ `muted` defaults to `true` so `autoPlay` has a chance under browser autoplay
237
+ rules. Unmuted playback still needs a user gesture; `onPlaybackStateChange`
238
+ receives `"blocked"` when `play()` is rejected. Remote videos and VTT files
239
+ that are not same-origin should set `crossOrigin` (usually `"anonymous"`).
240
+
241
+ ## Tile
44
242
 
45
243
  `Tile` renders six inward-facing cube faces and loads only tiles around the current view. The default layout matches krpano-style output:
46
244
 
@@ -51,16 +249,16 @@ tiles/{face}/l{level}/{row}/l{level}_{face}_{col}_{row}.webp
51
249
  Faces are `f`, `r`, `b`, `l`, `u`, and `d`; rows and columns are 1-based.
52
250
 
53
251
  ```tsx
54
- import { PanoView, Tile } from "@ericchen1990/pano-view";
252
+ import { PanoViewer, Tile } from "@ericchen1990/pano-view";
55
253
 
56
254
  export function TileExample() {
57
255
  return (
58
- <PanoView style={{ width: "100%", height: 560 }}>
256
+ <PanoViewer style={{ width: "100%", height: 560 }}>
59
257
  <Tile
60
258
  baseUrl="https://cdn.example.com/panoramas/room"
61
259
  multires="512,500,1000,2000"
62
260
  />
63
- </PanoView>
261
+ </PanoViewer>
64
262
  );
65
263
  }
66
264
  ```
@@ -103,9 +301,9 @@ horizontal index and `%00v` produces a three-digit vertical index. The stereo
103
301
  `%t` and frame `%f` placeholders are not applicable to `Tile`, which represents
104
302
  a single non-stereo cube panorama.
105
303
 
106
- ## Scene transitions
304
+ ## Scenes
107
305
 
108
- `PanoramaScenes` switches controlled sphere and cube-tile scenes with GPU-only
306
+ `Scenes` switches controlled sphere and cube-tile scenes with GPU-only
109
307
  snapshot blending. The target scene loads its sphere image or tile preview first;
110
308
  once ready, the current framebuffer becomes a temporary GPU texture, its source
111
309
  textures are released, and the target scene blends in. This avoids holding two
@@ -113,12 +311,12 @@ high-resolution tile scenes in WebGL memory at once.
113
311
 
114
312
  ```tsx
115
313
  import {
116
- PanoramaScenes,
117
- PanoView,
118
- type PanoramaScene,
314
+ Scenes,
315
+ PanoViewer,
316
+ type Scene,
119
317
  } from "@ericchen1990/pano-view";
120
318
 
121
- const scenes: PanoramaScene[] = [
319
+ const scenes: Scene[] = [
122
320
  { id: "lobby", type: "sphere", src: "/panoramas/lobby.webp" },
123
321
  {
124
322
  id: "terrace",
@@ -128,77 +326,222 @@ const scenes: PanoramaScene[] = [
128
326
  },
129
327
  ];
130
328
 
131
- <PanoView style={{ height: 560 }}>
132
- <PanoramaScenes
329
+ <PanoViewer style={{ height: 560 }}>
330
+ <Scenes
133
331
  scenes={scenes}
134
332
  activeSceneId={activeSceneId}
135
333
  transition="ellipticZoomOpen"
136
334
  renderHotspots={(scene) => <SceneHotspots sceneId={scene.id} />}
137
335
  onTransitionError={({ sceneId }) => console.warn("Could not load", sceneId)}
138
336
  />
139
- </PanoView>;
337
+ </PanoViewer>;
140
338
  ```
141
339
 
142
- Available presets are `none`, `crossfade`, `zoom`, `blackout`, `whiteFlash`,
143
- `slideRightToLeft`, `slideTopToBottom`, `slideDiagonal`, `circleOpen`,
144
- `verticalOpen`, `horizontalOpen`, and `ellipticZoomOpen`. Pass
145
- `{ preset: "crossfade", duration: 0.6 }` to override a preset duration.
340
+ Available KRpano-style presets are `none`, `crossfade`, `zoom`, `blackout`,
341
+ `whiteFlash`, `slideRightToLeft`, `slideTopToBottom`, `slideDiagonal`,
342
+ `circleOpen`, `verticalOpen`, `horizontalOpen`, and `ellipticZoomOpen`.
343
+ Grid presets are `pixelate`, `gridWipe`, `gridWipeUp`, `gridWipeRight`,
344
+ `gridWipeDiagonal`, `gridWipeCenter`, `gridWipeChecker`, and `hexDissolve`.
345
+ Cinematic presets are `dissolve`, `shatter`, `particles`, `glitch`, `swirl`,
346
+ `clockWipe`, `ripple`, `zoomBlur`, and `filmBurn`. All of them still blend a
347
+ GPU framebuffer snapshot over the incoming panorama; they do not mix two full
348
+ 360 textures. Pass `{ preset: "crossfade", duration: 0.6 }` to override a
349
+ preset duration.
146
350
 
147
351
  While a transition runs, panorama drag/zoom input is locked and
148
352
  `renderHotspots` is hidden. New `activeSceneId` values supersede a target that
149
353
  is still being prepared. `maxTextureMemoryMb` and `maxConcurrentTileLoads`
150
- apply to the whole `PanoramaScenes` viewer rather than to each tile scene.
354
+ apply to the whole `Scenes` viewer rather than to each tile scene.
151
355
 
152
- ## Controls and imperative API
356
+ ## PanoFilter
153
357
 
154
- Angles are public degrees. Positive yaw looks right and positive pitch looks up.
358
+ Render `PanoFilter` inside `PanoViewer` to color-grade or stylize the panorama
359
+ source (`Sphere`, `Tile`, and `PanoVideo`). The filter runs on those materials
360
+ only: 3D hotspots, tooltips, video chrome, and iframe overlays keep their
361
+ original color. WebXR / MobileVR inherit the same look because the panorama
362
+ shader itself is patched.
363
+
364
+ `intensity` mixes the filtered result with the original image (`0`–`1`,
365
+ default `1`). Omit the component, pass `preset="none"`, or set `enabled={false}`
366
+ to leave the source unfiltered.
155
367
 
156
368
  ```tsx
157
- import { useRef } from "react";
369
+ <PanoViewer style={{ height: 560 }}>
370
+ <PanoFilter preset="pencil" intensity={0.85} />
371
+ <Sphere src="/panoramas/room.webp" />
372
+ <ImageHotspot
373
+ id="door"
374
+ position={{ yaw: 24, pitch: -6 }}
375
+ src="/hotspots/door.webp"
376
+ />
377
+ </PanoViewer>
378
+ ```
379
+
380
+ | Preset | Look |
381
+ |---|---|
382
+ | `none` | Identity (no filter) |
383
+ | `grayscale` | Rec.709 black and white |
384
+ | `sepia` | Warm brown antique print |
385
+ | `vintage` | Faded film with grain |
386
+ | `cool` / `warm` | Blue or amber temperature shift |
387
+ | `pencil` | Graphite sketch on paper |
388
+ | `coloredPencil` | Tinted pencil lines |
389
+ | `crayon` | Posterized wax strokes |
390
+ | `watercolor` | Soft pigment on paper |
391
+ | `cartoon` | Cel shading with outlines |
392
+ | `crosshatch` | Luminance hatching |
393
+
394
+ Built-in artistic looks use screen-space edges and world-space grain so cube
395
+ tiles do not show filter seams. Scene-transition snapshots capture the filtered
396
+ panorama when a filter is active.
397
+
398
+ ## AutoRotate
399
+
400
+ Auto-rotation is off by default. Render `AutoRotate` inside `PanoViewer` to enable it and keep rotation configuration separate from user-input controls. `speed` is measured in degrees per second; use a negative value to rotate left. `acceleration` is measured in degrees per second squared and smoothly ramps from zero to `speed` (default: `18`, so the default speed takes one second to reach). Set it to `0` for an immediate fixed speed. `startDelay` is measured in milliseconds from when `enabled` becomes true. While the user is dragging, or while drag inertia is still settling, rotation pauses and resumes from zero speed automatically.
401
+
402
+ ```tsx
403
+ <PanoViewer style={{ height: 560 }}>
404
+ <AutoRotate enabled speed={12} acceleration={6} startDelay={2_000} />
405
+ <Sphere src="/panoramas/room.webp" />
406
+ </PanoViewer>
407
+ ```
408
+
409
+ ## Gyro
410
+
411
+ `Gyro` is an opt-in device-orientation control and is disabled by default.
412
+ Render it inside `PanoViewer` and control its `enabled` prop. Relative mode
413
+ (the default) treats the view at activation time as the starting direction.
414
+ Set `absolute` to follow compass-oriented headings and use `north` to identify
415
+ the panorama yaw that points North.
416
+
417
+ ```tsx
418
+ import { useRef, useState } from "react";
158
419
  import {
159
- PanoView,
420
+ Gyro,
421
+ PanoViewer,
160
422
  Sphere,
161
- AutoRotate,
162
- type PanoViewHandle,
423
+ type GyroHandle,
163
424
  } from "@ericchen1990/pano-view";
164
425
 
165
- export function ControlledExample() {
166
- const ref = useRef<PanoViewHandle>(null);
426
+ export function GyroExample() {
427
+ const gyroRef = useRef<GyroHandle>(null);
428
+ const [enabled, setEnabled] = useState(false);
429
+
430
+ const toggle = async () => {
431
+ if (enabled) {
432
+ setEnabled(false);
433
+ return;
434
+ }
435
+ // iOS requires this call to run directly inside a user gesture.
436
+ setEnabled((await gyroRef.current?.requestPermission()) ?? false);
437
+ };
167
438
 
168
439
  return (
169
440
  <>
170
- <button onClick={() => ref.current?.setView({ yaw: 90, fov: 55 })}>
171
- Look right
441
+ <button onClick={() => void toggle()}>
442
+ {enabled ? "Disable gyro" : "Enable gyro"}
172
443
  </button>
173
- <PanoView
174
- ref={ref}
175
- controls={{
176
- inertia: true,
177
- rotateDamping: 14,
178
- zoomDamping: 16,
179
- }}
180
- initialView={{ yaw: 0, pitch: 0, fov: 75 }}
181
- minFov={30}
182
- maxFov={100}
183
- onViewChange={(view) => console.log(view)}
184
- style={{ height: 560 }}
185
- >
186
- <AutoRotate enabled speed={18} acceleration={18} startDelay={1_000} />
444
+ <PanoViewer style={{ height: 560 }}>
445
+ <Gyro
446
+ ref={gyroRef}
447
+ enabled={enabled}
448
+ camroll
449
+ friction={0}
450
+ softstart={0.5}
451
+ touchMode="full"
452
+ onDenied={() => setEnabled(false)}
453
+ />
187
454
  <Sphere src="/panoramas/room.webp" />
188
- </PanoView>
455
+ </PanoViewer>
189
456
  </>
190
457
  );
191
458
  }
192
459
  ```
193
460
 
194
- The handle exposes `getView`, `setView`, `reset`, `startAutoRotate`, `stopAutoRotate`, and `toggleFullscreen`. Mouse, touch, and keyboard input are enabled by default — you do not need to render control components for ordinary viewing. Tune shared behaviour through `controls` (`inertia`, `invert`, `bouncingLimits`, `fovSpeed`, `frictionStop`, `rotateDamping`, `zoomDamping`, and top-level `rotateSpeed` / `zoomSpeed`). Disable a channel with `controls.mouse` / `touch` / `keyboard` set to `false`, or pass an options object (including `enabled`) to override defaults without mounting a child. The two auto-rotation handle methods remain supported for compatibility; prefer rendering `AutoRotate` for new code. A default context menu (Reset view / Enter fullscreen) is also mounted — see [Context menu](#context-menu).
461
+ `camroll` (default `true`) levels the camera from device roll. `friction`
462
+ accepts `0` to `0.99`; higher values smooth more strongly but add latency.
463
+ `softstart` is the activation blend duration in seconds (default `0.5`).
464
+ `desktopSupport` defaults to `false` because desktop browsers can expose the
465
+ API without a physical sensor.
466
+
467
+ `touchMode` controls how touch drag combines with device movement:
468
+
469
+ - `off` ignores touch yaw/pitch while leaving pinch FOV available.
470
+ - `horizontaloffset` adds only a horizontal offset.
471
+ - `full` adds horizontal and vertical offsets; the vertical offset returns
472
+ toward the physical orientation as the device moves.
473
+ - `disablegyro` disables the active gyro session on the next touch.
474
+
475
+ The `GyroHandle` exposes `resetSensor(yaw?, pitch?)`, `isAvailable()`,
476
+ `isEnabled()`, and `requestPermission()`. Gyro activity pauses `AutoRotate`
477
+ and counts as viewer interaction for idle tracking. Sensor APIs generally
478
+ require HTTPS. A cross-origin iframe must be permitted by the host page, for
479
+ example with `allow="gyroscope; accelerometer"` and a compatible
480
+ `Permissions-Policy` header.
481
+
482
+ ## WebVR
483
+
484
+ `WebVR` adds immersive viewing to the nearest `PanoViewer`. It prefers an
485
+ `immersive-vr` WebXR session on supported headsets. On phones without WebXR it
486
+ uses a stereoscopic split-screen view with device orientation, and on desktop
487
+ it can provide a mouse-driven simulated VR preview. The built-in chrome includes
488
+ Enter VR, Exit VR, and MobileVR setup controls.
489
+
490
+ ```tsx
491
+ import { useRef } from "react";
492
+ import {
493
+ PanoViewer,
494
+ Sphere,
495
+ WebVR,
496
+ type WebVRHandle,
497
+ } from "@ericchen1990/pano-view";
498
+
499
+ export function WebVRExample() {
500
+ const webVRRef = useRef<WebVRHandle>(null);
501
+
502
+ return (
503
+ <PanoViewer style={{ height: 560 }}>
504
+ <WebVR
505
+ ref={webVRRef}
506
+ chrome
507
+ fakeSupport
508
+ mobileVr
509
+ onEnterVR={(mode) => console.log("Entered", mode)}
510
+ />
511
+ <Sphere src="/panoramas/room.webp" />
512
+ </PanoViewer>
513
+ );
514
+ }
515
+ ```
516
+
517
+ The handle exposes `enterVR()`, `exitVR()`, `toggleVR()`, `isAvailable()`,
518
+ `isEnabled()`, `getMode()`, and `requestPermission()`. Modes are `"webxr"`,
519
+ `"mobilevr"`, and `"fake"`. `profile` accepts `"none"`, `"cardboard-v1"`,
520
+ `"cardboard-v2"`, `"gear-vr"`, `"daydream"`, or a custom
521
+ `{ fov, ipdMm, k1, k2 }` lens profile. MobileVR setup values are versioned and
522
+ stored in the current origin's `localStorage`. In VR, a center reticle shows a
523
+ crosshair; looking at an interactive hotspot draws a clockwise ring that fills
524
+ over `cursorDwellMs` (default 1500), then the hotspot click handler runs. Set
525
+ `cursorDwellMs={0}` to keep the reticle without auto-click. Simulated desktop
526
+ VR (`fake` mode) requests pointer lock by default (`mousePointerLock`) so the
527
+ cursor hides and mouse movement looks around; click the view again if the
528
+ browser releases the lock. Fallback sessions keep the screen awake
529
+ (`wakelock`) with the Screen Wake Lock API, or a looping hidden video on
530
+ browsers that lack it.
531
+
532
+ WebXR requires HTTPS and a user gesture. Mobile sensor access also requires
533
+ HTTPS and may require an explicit iOS permission prompt. Embedded viewers need
534
+ appropriate `xr-spatial-tracking`, `gyroscope`, and `accelerometer` permissions.
535
+ DOM overlays such as iframe hotspots, tooltips, and video controls are not
536
+ visible inside an immersive headset session; mesh-based hotspots remain
537
+ interactive through the default WebXR controller rays.
195
538
 
196
539
  ## Mouse, touch, and keyboard controls
197
540
 
198
- `PanoView` mounts default `MouseControls`, `TouchControls`, and `KeyboardControls` instances. Configure them through `controls` for most apps; render the components yourself only when you need to replace a channel (for example scene-switch callbacks).
541
+ `PanoViewer` enables `MouseControls`, `TouchControls`, and `KeyboardControls` by default. Configure shared options through `controls`; render a control component only to override that channel's properties. A child instance replaces the default — you do not need to set the channel to `false` first. Set `controls.mouse` / `touch` / `keyboard` to `false` to turn a channel off.
199
542
 
200
543
  ```tsx
201
- <PanoView
544
+ <PanoViewer
202
545
  controls={{
203
546
  invert: false,
204
547
  bouncingLimits: false,
@@ -209,36 +552,44 @@ The handle exposes `getView`, `setView`, `reset`, `startAutoRotate`, `stopAutoRo
209
552
  style={{ height: 560 }}
210
553
  >
211
554
  <Sphere src="/panoramas/room.webp" />
212
- </PanoView>
555
+ </PanoViewer>
213
556
  ```
214
557
 
215
558
  **Mouse** (pointer types `mouse` / `pen`): drag look and optional wheel zoom. Defaults: `rotateSpeed` `0.35`, `zoomSpeed` `0.08`, `wheel` `true`, `buttons` `["left"]`.
216
559
 
217
- **Touch**: one-finger drag and optional two-finger pinch zoom (`pinchZoom`, default `true`).
560
+ ### MouseControls
561
+
562
+ See the defaults above. Override through `controls.mouse` or by rendering `<MouseControls />` as a child.
563
+
564
+ ### TouchControls
565
+
566
+ One-finger drag and optional two-finger pinch zoom (`pinchZoom`, default `true`). Override through `controls.touch` or by rendering `<TouchControls />` as a child.
567
+
568
+ ### KeyboardControls
218
569
 
219
- **Keyboard**: hold arrows (or custom bindings) for continuous look / FOV; `0` resets; optional scene bindings. Defaults: `rotateSpeed` `60`, `zoomSpeed` `30`, `shiftMultiplier` `3`. Set `invert` to flip up/down only.
570
+ Hold arrows (or custom bindings) for continuous look / FOV; `0` resets; optional scene bindings. Defaults: `rotateSpeed` `60`, `zoomSpeed` `30`, `shiftMultiplier` `3`. Set `invert` to flip up/down only. Override through `controls.keyboard` or by rendering `<KeyboardControls />` as a child.
220
571
 
221
572
  Shared all-mode options on `controls`: `enabled`, `invert` (drag direction for mouse/touch), `bouncingLimits`, `fovSpeed`, `frictionStop` (default `0.01`), plus existing damping / inertia.
222
573
 
223
574
  ### Overriding a control channel
224
575
 
225
- Set the channel to `false` and render your own component when you need callbacks or fully custom behaviour:
576
+ Render the component with the props you need. The default instance for that channel is skipped:
226
577
 
227
578
  ```tsx
228
579
  import { useState } from "react";
229
580
  import {
230
581
  KeyboardControls,
231
- PanoramaScenes,
232
- PanoView,
582
+ Scenes,
583
+ PanoViewer,
233
584
  cycleSceneId,
234
- type PanoramaScene,
585
+ type Scene,
235
586
  } from "@ericchen1990/pano-view";
236
587
 
237
- export function KeyboardExample({ scenes }: { scenes: PanoramaScene[] }) {
588
+ export function KeyboardExample({ scenes }: { scenes: Scene[] }) {
238
589
  const [activeSceneId, setActiveSceneId] = useState(scenes[0]!.id);
239
590
 
240
591
  return (
241
- <PanoView controls={{ keyboard: false }} style={{ height: 560 }}>
592
+ <PanoViewer style={{ height: 560 }}>
242
593
  <KeyboardControls
243
594
  keys={{
244
595
  left: ["ArrowLeft", "a"],
@@ -261,15 +612,15 @@ export function KeyboardExample({ scenes }: { scenes: PanoramaScene[] }) {
261
612
  if (next) setActiveSceneId(next);
262
613
  }}
263
614
  />
264
- <PanoramaScenes scenes={scenes} activeSceneId={activeSceneId} />
265
- </PanoView>
615
+ <Scenes scenes={scenes} activeSceneId={activeSceneId} />
616
+ </PanoViewer>
266
617
  );
267
618
  }
268
619
  ```
269
620
 
270
621
  Hold movement and zoom keys for continuous motion in degrees per second (`rotateSpeed` / `zoomSpeed`). Hold Shift to multiply those rates (`shiftMultiplier`, default `3`). Scene and reset bindings fire once per press. The canvas must be focused to receive keys (click the viewer first).
271
622
 
272
- Default bindings: arrows for look, `+/-` for FOV, `0` for reset, plus `[`/`PageUp` and `]`/`PageDown` for previous/next scene when callbacks are provided. The same override pattern works with `MouseControls` and `TouchControls` (`controls.mouse={false}` / `controls.touch={false}`).
623
+ Default bindings: arrows and WASD for look, `+/-` for FOV, `0` for reset, plus `[`/`PageUp` and `]`/`PageDown` for previous/next scene when callbacks are provided. The same child-override pattern works with `MouseControls` and `TouchControls`. Set `controls.mouse={false}` / `controls.touch={false}` / `controls.keyboard={false}` only when you want that channel off.
273
624
 
274
625
  ## Panorama coordinate events
275
626
 
@@ -279,7 +630,7 @@ convention as the camera: positive yaw looks right and positive pitch looks up.
279
630
 
280
631
  ```tsx
281
632
  import {
282
- PanoView,
633
+ PanoViewer,
283
634
  Sphere,
284
635
  panoPositionToVector3,
285
636
  type HotspotPosition,
@@ -292,14 +643,14 @@ export function PlacementExample() {
292
643
  };
293
644
 
294
645
  return (
295
- <PanoView
646
+ <PanoViewer
296
647
  onPanoramaClick={({ position }) => place(position)}
297
648
  onPanoramaDoubleClick={({ position }) => console.log("double", position)}
298
649
  onPanoramaPointerMove={({ position }) => console.log("move", position)}
299
650
  style={{ height: 560 }}
300
651
  >
301
652
  <Sphere src="/panoramas/room.webp" />
302
- </PanoView>
653
+ </PanoViewer>
303
654
  );
304
655
  }
305
656
  ```
@@ -309,23 +660,25 @@ export function PlacementExample() {
309
660
  is normalized to `[-180, 180)`. Pitch is clamped to `[-90, 90]`; at either
310
661
  pole, yaw is normalized to `0` because it does not identify a unique point.
311
662
 
312
- ## Viewer-level events (`PanoEvents`)
663
+ ## PanoEvents
313
664
 
314
665
  `PanoEvents` is the React counterpart to krpano's global `<events>` element.
315
- Render one or more instances inside `PanoView` to subscribe to viewer-level
666
+ Render one or more instances inside `PanoViewer` to subscribe to viewer-level
316
667
  lifecycle and interaction callbacks. Multiple instances coexist (like named
317
668
  krpano events); each tracks its own `idleTime`. For composition inside a custom
318
669
  child, use `usePanoEvents` instead.
319
670
 
671
+ ### usePanoEvents
672
+
320
673
  ```tsx
321
674
  import {
322
675
  AutoRotate,
323
676
  PanoEvents,
324
- PanoView,
677
+ PanoViewer,
325
678
  Sphere,
326
679
  } from "@ericchen1990/pano-view";
327
680
 
328
- <PanoView style={{ height: 560 }}>
681
+ <PanoViewer style={{ height: 560 }}>
329
682
  <PanoEvents
330
683
  idleTime={3000}
331
684
  onIdle={() => console.log("idle")}
@@ -338,7 +691,7 @@ import {
338
691
  />
339
692
  <AutoRotate enabled />
340
693
  <Sphere src="/panoramas/room.webp" />
341
- </PanoView>;
694
+ </PanoViewer>;
342
695
  ```
343
696
 
344
697
  Supported callbacks (krpano names in parentheses where applicable):
@@ -350,42 +703,44 @@ Supported callbacks (krpano names in parentheses where applicable):
350
703
  - Fullscreen: `onEnterFullscreen` / `onExitFullscreen`
351
704
  - `onResize` — canvas content-box size via `ResizeObserver`
352
705
  - Auto-rotate: `onAutoRotateStart` / `onAutoRotateStop` / `onAutoRotateOneRound`
706
+ - Gyro: `onGyroAvailable` / `onGyroUnavailable` / `onGyroEnable` /
707
+ `onGyroDisable` / `onGyroDenied`
353
708
 
354
- `PanoView`'s existing `onViewChange` / `onPanoramaClick` / `onPanoramaDoubleClick` /
709
+ `PanoViewer`'s existing `onViewChange` / `onPanoramaClick` / `onPanoramaDoubleClick` /
355
710
  `onPanoramaPointerMove` props remain supported and share the same event bus.
356
711
 
357
712
  Resource loading and scene blending stay on their owners: use `Sphere` /
358
- `Tile` `onLoad` / `onError` / `onLoadProgress`, and `PanoramaScenes`
713
+ `Tile` `onLoad` / `onError` / `onLoadProgress`, and `Scenes`
359
714
  `onTransitionEnd` / `onTransitionError`. This package does not mirror krpano
360
- xml/VR/gyro/frame-render events.
715
+ xml/VR/frame-render events.
361
716
 
362
- ## Context menu
717
+ ## PanoContextMenu
363
718
 
364
- `PanoView` mounts a default context menu that replaces the browser menu on
719
+ `PanoViewer` mounts a default context menu that replaces the browser menu on
365
720
  right-click. Default items: **Reset view** and **Enter fullscreen** / **Exit
366
721
  fullscreen** (label and icon follow the current fullscreen state), with a
367
722
  separator between them.
368
723
 
369
724
  ```tsx
370
- <PanoView style={{ height: 560 }}>
725
+ <PanoViewer style={{ height: 560 }}>
371
726
  <Sphere src="/panoramas/room.webp" />
372
- </PanoView>
727
+ </PanoViewer>
373
728
  ```
374
729
 
375
730
  Disable the default menu (restore the browser menu, or mount your own):
376
731
 
377
732
  ```tsx
378
- <PanoView contextMenu={false}>
733
+ <PanoViewer contextMenu={false}>
379
734
  <Sphere src="/panoramas/room.webp" />
380
- </PanoView>
735
+ </PanoViewer>
381
736
  ```
382
737
 
383
738
  Tune appearance while keeping the default items:
384
739
 
385
740
  ```tsx
386
- <PanoView contextMenu={{ appearance: { opacity: 0.92, borderRadius: 8 } }}>
741
+ <PanoViewer contextMenu={{ appearance: { opacity: 0.92, borderRadius: 8 } }}>
387
742
  <Sphere src="/panoramas/room.webp" />
388
- </PanoView>
743
+ </PanoViewer>
389
744
  ```
390
745
 
391
746
  ### Adding items without rebuilding defaults
@@ -394,7 +749,7 @@ Use `append` or `prepend` to keep the built-in Reset / Fullscreen entries and
394
749
  only add your own:
395
750
 
396
751
  ```tsx
397
- <PanoView
752
+ <PanoViewer
398
753
  contextMenu={{
399
754
  append: [
400
755
  "separator",
@@ -412,7 +767,7 @@ only add your own:
412
767
  style={{ height: 560 }}
413
768
  >
414
769
  <Sphere src="/panoramas/room.webp" />
415
- </PanoView>
770
+ </PanoViewer>
416
771
  ```
417
772
 
418
773
  ### Built-in presets
@@ -425,7 +780,7 @@ reimplementing fullscreen enter/exit state:
425
780
  - `"separator"` — a horizontal rule
426
781
 
427
782
  ```tsx
428
- <PanoView
783
+ <PanoViewer
429
784
  contextMenu={{
430
785
  items: [
431
786
  "resetView",
@@ -446,7 +801,7 @@ reimplementing fullscreen enter/exit state:
446
801
  style={{ height: 560 }}
447
802
  >
448
803
  <Sphere src="/panoramas/room.webp" />
449
- </PanoView>
804
+ </PanoViewer>
450
805
  ```
451
806
 
452
807
  Override presentation on a preset with `{ preset: "fullscreen", label: "…" }`.
@@ -463,18 +818,30 @@ stay opaque), hover/disabled colors, and icon size. For an explicit translucent
463
818
  fill without using `opacity`, you can still pass `background: "rgba(...)"`.
464
819
 
465
820
  For a fully custom DOM menu, set `contextMenu={false}` and render
466
- `PanoContextMenu` as a child (same override pattern as `MouseControls`). Do
467
- not mount both the default menu and a child `PanoContextMenu` at once. Avoid
468
- combining the context menu with `controls.mouse.buttons: ["right"]` — both
469
- claim the right mouse button.
821
+ `PanoContextMenu` as a child. Do not mount both the default menu and a child
822
+ `PanoContextMenu` at once. Avoid combining the context menu with
823
+ `controls.mouse.buttons: ["right"]` — both claim the right mouse button.
470
824
 
471
825
  ## Shared hotspot contract and saved definitions
472
826
 
473
- Every hotspot has an `id`, optional `visible`, `interactive`, `renderOrder`,
474
- and semantic interaction callbacks. Point hotspots additionally use a
475
- controlled `position`, angular `width`/`height`, hotspot mode, and the zoom
476
- options described below. `interactive={false}` keeps a hotspot visible while
477
- letting a drawing tool receive the panorama pointer events beneath it.
827
+ Every hotspot has an `id`, optional `visible`, `interactive`, `pointerEvents`,
828
+ `renderOrder`, and semantic interaction callbacks. Point hotspots additionally
829
+ use a controlled `position`, angular `width`/`height`, hotspot mode, and the
830
+ zoom options described below. `interactive={false}` keeps a hotspot visible
831
+ while letting a drawing tool receive the panorama pointer events beneath it.
832
+ `pointerEvents="none"` ignores mouse, touch, and pen so those events pass
833
+ through to the panorama even when the hotspot stays keyboard-interactive.
834
+ Interactive hotspots set the canvas cursor to `pointer` while hovered, or to
835
+ the hotspot's `cursor` prop when that is set.
836
+
837
+ ```tsx
838
+ <TextHotspot
839
+ id="caption"
840
+ pointerEvents="none"
841
+ position={{ yaw: 12, pitch: -8 }}
842
+ text="Decorative label"
843
+ />
844
+ ```
478
845
 
479
846
  `onClick` and `onHoverChange` receive a position and input source (`"pointer"`
480
847
  or `"keyboard"`). Point hotspots can use `draggable`, `onDragStart`,
@@ -482,9 +849,59 @@ or `"keyboard"`). Point hotspots can use `draggable`, `onDragStart`,
482
849
  back to its state. Polygon and polyline hotspots report the corresponding
483
850
  controlled `vertices` through `onVerticesChange`.
484
851
 
852
+ Every hotspot can show a DOM tooltip at its anchor. Pass a string or
853
+ `{ text?, image?, imageAlt? }` as `tooltip`. The default `tooltipTrigger` is
854
+ `"always"`: if content is present and the hotspot is visible, the bubble stays
855
+ on screen. Clicking the hotspot does not toggle it. Optional
856
+ `tooltipTrigger="hover"` shows the bubble on pointer hover or keyboard focus;
857
+ `"click"` pins it until the hotspot is clicked again or the empty panorama is
858
+ clicked. Hover and click triggers need `interactive` and `pointerEvents`
859
+ other than `"none"`.
860
+
861
+ `tooltipPlacement` is `"top"` (default), `"bottom"`, `"left"`, or `"right"`.
862
+ These are screen directions: the bubble sits outside the hotspot's projected
863
+ screen bounds on that edge, so rotated graphics such as arrows are not covered.
864
+ `tooltipOffset` is the screen-space gap in CSS pixels (default `12` /
865
+ `DEFAULT_HOTSPOT_TOOLTIP_OFFSET`).
866
+
867
+ Optional `tooltipAppearance` overrides the bubble paint. Unset fields keep the
868
+ library default theme (`DEFAULT_HOTSPOT_TOOLTIP_APPEARANCE`): background, text
869
+ color, border, corner radius, shadow, padding, and font size.
870
+
871
+ ```tsx
872
+ <ImageHotspot
873
+ id="gallery"
874
+ ariaLabel="Open gallery"
875
+ position={{ yaw: 28, pitch: -4 }}
876
+ src="/hotspots/gallery.webp"
877
+ tooltip={{ text: "Courtyard gallery", image: "/hotspots/gallery-thumb.webp" }}
878
+ tooltipPlacement="top"
879
+ tooltipOffset={16}
880
+ tooltipAppearance={{
881
+ background: "rgba(12, 28, 36, 0.88)",
882
+ color: "#e7f2f5",
883
+ border: "1px solid rgba(56, 84, 91, 0.9)",
884
+ borderRadius: 10,
885
+ shadow: "0 10px 28px rgba(0, 0, 0, 0.42)",
886
+ padding: 10,
887
+ fontSize: 13,
888
+ }}
889
+ />
890
+ <GraphicHotspot
891
+ id="marker"
892
+ ariaLabel="Signal"
893
+ position={{ yaw: -18, pitch: 9 }}
894
+ graphic={{ kind: "ring" }}
895
+ tooltip="Hover for details"
896
+ tooltipTrigger="hover"
897
+ tooltipPlacement="right"
898
+ />
899
+ ```
900
+
485
901
  For persistence or a host-owned editor, use the exported discriminated
486
- `HotspotDefinition` union. It contains the original five categories plus the
487
- open `polyline` extension:
902
+ `HotspotDefinition` union. Adding a variant is a TypeScript breaking change for
903
+ exhaustive switches; handle `text` and `iframe` alongside the existing point
904
+ and path categories:
488
905
 
489
906
  ```ts
490
907
  import type { HotspotDefinition } from "@ericchen1990/pano-view";
@@ -494,6 +911,8 @@ const hotspots: HotspotDefinition[] = [
494
911
  { type: "graphic", id: "marker", position: { yaw: -18, pitch: 9 }, graphic: { kind: "ring" } },
495
912
  { type: "sequence", id: "pulse", position: { yaw: -42, pitch: -7 }, src: "/hotspots/pulse.png", frameCount: 20 },
496
913
  { type: "video", id: "clip", position: { yaw: 48, pitch: 6 }, src: "/hotspots/clip.webm" },
914
+ { type: "text", id: "caption", position: { yaw: 0, pitch: -16 }, text: "Courtyard overlook" },
915
+ { type: "iframe", id: "guide", position: { yaw: -62, pitch: 4 }, src: "/hotspots/embed.html" },
497
916
  { type: "polygon", id: "zone", vertices: [{ yaw: 12, pitch: 4 }, { yaw: 22, pitch: 4 }, { yaw: 18, pitch: 14 }] },
498
917
  { type: "polyline", id: "route", vertices: [{ yaw: -8, pitch: 1 }, { yaw: 4, pitch: 8 }] },
499
918
  ];
@@ -503,15 +922,15 @@ Definitions intentionally contain data only. Render them with a switch in the
503
922
  host, where application-specific click actions, controlled media state, and
504
923
  error reporting belong.
505
924
 
506
- ## Image and graphic hotspots
925
+ ## ImageHotspot
507
926
 
508
927
  `ImageHotspot` renders an image at a controlled spherical position. Dimensions
509
928
  are angular degrees, so they remain independent of the canvas resolution.
510
929
 
511
930
  ```tsx
512
- import { ImageHotspot, PanoView, Sphere } from "@ericchen1990/pano-view";
931
+ import { ImageHotspot, PanoViewer, Sphere } from "@ericchen1990/pano-view";
513
932
 
514
- <PanoView style={{ height: 560 }}>
933
+ <PanoViewer style={{ height: 560 }}>
515
934
  <Sphere src="/panoramas/room.webp" />
516
935
  <ImageHotspot
517
936
  id="gallery"
@@ -523,9 +942,18 @@ import { ImageHotspot, PanoView, Sphere } from "@ericchen1990/pano-view";
523
942
  src="/hotspots/gallery.webp"
524
943
  onClick={({ position }) => console.log("gallery", position)}
525
944
  />
526
- </PanoView>;
945
+ </PanoViewer>;
527
946
  ```
528
947
 
948
+ Set `draggable` and update the controlled position from `onPositionChange` to
949
+ move a selected hotspot. Clickable hotspots need `ariaLabel`: PanoViewer creates
950
+ an internal semantic control for Tab, Enter, and Space activation, with a
951
+ visible WebGL focus outline.
952
+
953
+ `ImageHotspot` calls `onLoad(texture)` and `onError(error)`.
954
+
955
+ ## GraphicHotspot
956
+
529
957
  `GraphicHotspot` accepts built-in `circle`, `triangle`, `diamond`, `star`,
530
958
  `arrow`, `rectangle`, and `ring` graphics, an SVG URL, or safe SVG path data
531
959
  with an explicit viewBox. Built-in graphics support `fill`, `stroke`, and
@@ -577,13 +1005,8 @@ property to orient them in another direction.
577
1005
  square corners and `0.5` produces the maximum rounded corners. This replaces
578
1006
  the previous Canvas-texture pixel interpretation.
579
1007
 
580
- Set `draggable` and update the controlled position from `onPositionChange` to
581
- move a selected hotspot. Clickable hotspots need `ariaLabel`: PanoView creates
582
- an internal semantic control for Tab, Enter, and Space activation, with a
583
- visible WebGL focus outline.
584
-
585
- `ImageHotspot` calls `onLoad(texture)` and `onError(error)`. `GraphicHotspot`
586
- uses the same callbacks. Built-in paths are rasterized locally; only an SVG URL
1008
+ `GraphicHotspot` uses the same `onLoad(texture)` and `onError(error)` callbacks
1009
+ as `ImageHotspot`. Built-in paths are rasterized locally; only an SVG URL
587
1010
  or SVG `path` data plus an explicit `viewBox` is accepted, never arbitrary SVG
588
1011
  markup.
589
1012
 
@@ -621,7 +1044,7 @@ placement properties:
621
1044
  />
622
1045
  ```
623
1046
 
624
- ## Polygon hotspots
1047
+ ## PolygonHotspot
625
1048
 
626
1049
  `PolygonHotspot` renders a local spherical area from three or more controlled
627
1050
  yaw/pitch vertices. Concave polygons and the `-180°/180°` seam are supported.
@@ -645,6 +1068,8 @@ import { PolygonHotspot } from "@ericchen1990/pano-view";
645
1068
  stroke="#f5fbfc"
646
1069
  strokeWidth={2}
647
1070
  strokeOpacity={0.8}
1071
+ strokeDashSize={8}
1072
+ strokeGapSize={4}
648
1073
  draggable
649
1074
  onVerticesChange={({ vertices }) => setZoneVertices(vertices)}
650
1075
  onInvalid={(issues) => console.error(issues)}
@@ -652,18 +1077,21 @@ import { PolygonHotspot } from "@ericchen1990/pano-view";
652
1077
  ```
653
1078
 
654
1079
  `strokeWidth` is a screen-space CSS pixel value, so it remains visually
655
- consistent across FOV changes. Dragging moves all vertices together while
656
- keeping yaw normalized and pitch clamped. Individual vertex editing is provided
657
- by the authoring workflow, not the runtime hotspot component.
1080
+ consistent across FOV changes. `strokeDashSize` and `strokeGapSize` use the
1081
+ same CSS-pixel units; omit them or set `strokeDashSize` to `0` for a solid
1082
+ stroke. Dragging moves all vertices together while keeping yaw normalized and
1083
+ pitch clamped. Individual vertex editing is provided by the authoring
1084
+ workflow, not the runtime hotspot component.
658
1085
 
659
1086
  Set `fillOpacity={0}` for a closed outline-only polygon. The fill and outline
660
1087
  share the same spherical edge sampling, so there is no intentional seam
661
1088
  between them.
662
1089
 
663
- ## Polyline hotspots
1090
+ ## PolylineHotspot
664
1091
 
665
1092
  `PolylineHotspot` is an open path of at least two yaw/pitch vertices. It uses
666
- the same CSS-pixel `strokeWidth`, visibility, semantic interaction, controlled
1093
+ the same CSS-pixel `strokeWidth`, optional dashed stroke (`strokeDashSize` /
1094
+ `strokeGapSize`), visibility, semantic interaction, controlled
667
1095
  whole-path drag, and vertex-change callback as `PolygonHotspot`, but it never
668
1096
  connects the final point to the first and has no fill.
669
1097
 
@@ -677,6 +1105,8 @@ import { PolylineHotspot } from "@ericchen1990/pano-view";
677
1105
  stroke="#75cbd3"
678
1106
  strokeWidth={2}
679
1107
  strokeOpacity={0.9}
1108
+ strokeDashSize={8}
1109
+ strokeGapSize={4}
680
1110
  draggable
681
1111
  onVerticesChange={({ vertices }) => setRouteVertices(vertices)}
682
1112
  />;
@@ -684,7 +1114,7 @@ import { PolylineHotspot } from "@ericchen1990/pano-view";
684
1114
 
685
1115
  `onInvalid` reports the only invalid runtime shape: fewer than two vertices.
686
1116
 
687
- ## Sequence and video hotspots
1117
+ ## SequenceHotspot
688
1118
 
689
1119
  `SequenceHotspot` animates a sprite sheet: one image containing equally sized
690
1120
  frames in a vertical or horizontal strip. This matches the common krpano
@@ -712,6 +1142,12 @@ import { SequenceHotspot } from "@ericchen1990/pano-view";
712
1142
  />;
713
1143
  ```
714
1144
 
1145
+ Sequence loading uses `onLoadProgress` and `onError`. The component does not
1146
+ change `playing` by itself: update that prop in response to your UI, click
1147
+ handler, `onEnded`, or playback error.
1148
+
1149
+ ## VideoHotspot
1150
+
715
1151
  `VideoHotspot` uses an `HTMLVideoElement` and `VideoTexture`. Its `playing`
716
1152
  prop is likewise controlled: toggle it from your click handler or application
717
1153
  state. On source change or unmount, the element is paused and its texture is
@@ -745,24 +1181,82 @@ Browsers can reject unmuted or otherwise non-gesture playback. In that case
745
1181
  the browser error; retain control of `playing` in the host and offer an
746
1182
  explicit user action.
747
1183
 
748
- Sequence loading uses `onLoadProgress` and `onError`; video reports media and
749
- poster failures through `onError`. Neither component changes `playing` by
750
- itself: update that prop in response to your UI, click handler, `onEnded`, or
751
- playback error. This keeps source changes, unmounts, and React StrictMode
752
- lifecycles deterministic.
1184
+ Video reports media and poster failures through `onError`. Like
1185
+ `SequenceHotspot`, it does not change `playing` by itself. This keeps source
1186
+ changes, unmounts, and React StrictMode lifecycles deterministic.
753
1187
 
754
- ## Automatic rotation
1188
+ ## TextHotspot
755
1189
 
756
- Render `AutoRotate` inside `PanoView` to keep rotation configuration separate from user-input controls. `speed` is measured in degrees per second; use a negative value to rotate left. `acceleration` is measured in degrees per second squared and smoothly ramps from zero to `speed` (default: `18`, so the default speed takes one second to reach). Set it to `0` for an immediate fixed speed. `startDelay` is measured in milliseconds from when `enabled` becomes true. While the user is dragging, or while drag inertia is still settling, rotation pauses and resumes from zero speed automatically.
1190
+ `TextHotspot` rasterizes plain text onto a canvas texture. It does not accept
1191
+ HTML or Markdown. `\n` is a hard line break; `whiteSpace="normal"` (default)
1192
+ wraps words to the hotspot's angular width. Dimensions stay in degrees like
1193
+ the other point hotspots. Typography uses the same names as CSS
1194
+ (`fontFamily`, `fontSize`, `fontWeight`, `fontStyle`) but is painted onto the
1195
+ canvas, not applied as DOM style. `fontSize` is canvas pixels (default 96) and
1196
+ does not scale with panel height. `fontWeight` defaults to `600` and
1197
+ `fontStyle` to `"normal"`. `padding` and `borderRadius` remain fractions of
1198
+ the texture's shorter side, so they still follow `width` / `height`.
757
1199
 
758
1200
  ```tsx
759
- <PanoView style={{ height: 560 }}>
760
- <AutoRotate enabled speed={12} acceleration={6} startDelay={2_000} />
761
- <Sphere src="/panoramas/room.webp" />
762
- </PanoView>
1201
+ import { TextHotspot } from "@ericchen1990/pano-view";
1202
+
1203
+ <TextHotspot
1204
+ id="caption"
1205
+ ariaLabel="Courtyard overlook"
1206
+ position={{ yaw: 0, pitch: -16 }}
1207
+ width={16}
1208
+ height={6}
1209
+ mode="billboard"
1210
+ scaleMode="fixed"
1211
+ text={"Courtyard overlook\nNorth terrace"}
1212
+ fontFamily="system-ui, sans-serif"
1213
+ fontSize={96}
1214
+ fontWeight={600}
1215
+ fontStyle="normal"
1216
+ align="center"
1217
+ verticalAlign="middle"
1218
+ color="#f8fafc"
1219
+ background="#111827"
1220
+ backgroundOpacity={0.72}
1221
+ />;
763
1222
  ```
764
1223
 
765
- Drag and zoom update a target view that the camera follows smoothly. `rotateDamping` and `zoomDamping` control that following speed in seconds^-1 (defaults: `14` and `16` respectively); lower values feel softer, while `0` disables smoothing for that axis. Both values must be non-negative finite numbers. Imperative `setView()` and `reset()` remain immediate.
1224
+ Load a custom `fontFamily` on the page before the hotspot paints. The hotspot
1225
+ waits on `document.fonts.load` when that API exists, then draws with whatever
1226
+ faces the document has. `onLoad(texture)` and `onError(error)` match
1227
+ `ImageHotspot`.
1228
+
1229
+ ## IframeHotspot
1230
+
1231
+ `IframeHotspot` places an embedded document with `@react-three/drei` `Html`
1232
+ `transform`, so it follows the same hotspot mode, rotation, and zoom scaling
1233
+ as a texture plane. The iframe is a DOM overlay and is not occluded by the
1234
+ panorama shell. There is no `srcdoc` prop.
1235
+
1236
+ ```tsx
1237
+ import { IframeHotspot } from "@ericchen1990/pano-view";
1238
+
1239
+ <IframeHotspot
1240
+ id="guide"
1241
+ ariaLabel="Open visitor guide"
1242
+ position={{ yaw: -62, pitch: 4 }}
1243
+ width={18}
1244
+ height={12}
1245
+ mode="billboard"
1246
+ src="/hotspots/embed.html"
1247
+ pointerPolicy="hotspot"
1248
+ />;
1249
+ ```
1250
+
1251
+ `pointerPolicy="hotspot"` (default) sets the iframe to `pointer-events: none`
1252
+ so clicks and drags hit the WebGL plane. `"content"` makes the page
1253
+ interactive and blocks dragging through the iframe. A hotspot
1254
+ `pointerEvents="none"` also forces the iframe overlay not to capture pointer
1255
+ events. `sandbox` defaults to
1256
+ `allow-scripts allow-popups allow-forms` without `allow-same-origin`.
1257
+ `referrerPolicy` defaults to `strict-origin-when-cross-origin`. Prefer a
1258
+ same-origin document; many third-party sites send `X-Frame-Options` and will
1259
+ not load.
766
1260
 
767
1261
  ## Next.js and SSR
768
1262