@ericchen1990/pano-view 0.2.0 → 1.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,134 @@ 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, and `@react-three/drei` 10 are peer dependencies. npm 7+ and pnpm install them automatically; they must be a single copy in the host app.
22
22
 
23
- ## Sphere panorama
23
+ ## Exported components
24
24
 
25
- `PanoView` owns the canvas, perspective camera, and controls. Give its container an explicit size and place one panorama source inside it.
25
+ ### Viewer
26
+
27
+ - [`PanoViewer`](#panoviewer) — canvas shell, camera, default controls, and imperative view API
28
+ - [`Sphere`](#sphere) — single 2:1 equirectangular image
29
+ - [`Tile`](#tile) — krpano-style multires cube tiles
30
+ - [`Scenes`](#scenes) — controlled multi-scene transitions
31
+
32
+ ### Controls
33
+
34
+ - [`MouseControls`](#mousecontrols) — drag look and wheel zoom
35
+ - [`TouchControls`](#touchcontrols) — one-finger drag and pinch zoom
36
+ - [`KeyboardControls`](#keyboardcontrols) — arrow-key look, FOV, and scene cycling
37
+ - [`AutoRotate`](#autorotate) — automatic yaw rotation
38
+
39
+ ### Events and chrome
40
+
41
+ - [`PanoEvents`](#panoevents) — viewer-level lifecycle and interaction callbacks
42
+ - [`PanoContextMenu`](#panocontextmenu) — right-click reset / fullscreen menu
43
+
44
+ ### Hotspots
45
+
46
+ - [`ImageHotspot`](#imagehotspot) — image texture at a spherical position
47
+ - [`GraphicHotspot`](#graphichotspot) — built-in shapes or SVG at a position
48
+ - [`SequenceHotspot`](#sequencehotspot) — sprite-sheet animation
49
+ - [`VideoHotspot`](#videohotspot) — HTML video texture
50
+ - [`TextHotspot`](#texthotspot) — rasterized plain text at a position
51
+ - [`IframeHotspot`](#iframehotspot) — embedded document overlay at a position
52
+ - [`PolygonHotspot`](#polygonhotspot) — closed spherical area
53
+ - [`PolylineHotspot`](#polylinehotspot) — open spherical path
54
+
55
+ ### Hooks and helpers
56
+
57
+ - `usePanoEvents` — subscribe to viewer events inside a custom child ([`PanoEvents`](#panoevents))
58
+ - Coordinate helpers — `normalizePanoPosition`, `normalizePanoYaw`, `clampPanoPitch`, `panoPositionToVector3`, `vector3ToPanoPosition` ([Panorama coordinate events](#panorama-coordinate-events))
59
+ - `cycleSceneId` — wrap previous/next scene ids ([`KeyboardControls`](#keyboardcontrols))
60
+ - Context menu helpers — `createPanoContextMenuPresets`, `composePanoContextMenuItems`, and related APIs ([`PanoContextMenu`](#panocontextmenu))
61
+ - Polygon / polyline validation — `validatePolygonVertices`, `validatePolylineVertices`, `unwrapPolygonVertices`
62
+
63
+ ## PanoViewer
64
+
65
+ `PanoViewer` owns the canvas, perspective camera, and controls. Give its container an explicit size and place one panorama source inside it.
66
+
67
+ Angles are public degrees. Positive yaw looks right and positive pitch looks up.
68
+
69
+ ```tsx
70
+ import { useRef } from "react";
71
+ import {
72
+ PanoViewer,
73
+ Sphere,
74
+ AutoRotate,
75
+ type PanoViewerHandle,
76
+ } from "@ericchen1990/pano-view";
77
+
78
+ export function ControlledExample() {
79
+ const ref = useRef<PanoViewerHandle>(null);
80
+
81
+ return (
82
+ <>
83
+ <button onClick={() => ref.current?.setView({ yaw: 90, fov: 55 })}>
84
+ Look right
85
+ </button>
86
+ <PanoViewer
87
+ ref={ref}
88
+ controls={{
89
+ inertia: true,
90
+ rotateDamping: 14,
91
+ zoomDamping: 16,
92
+ }}
93
+ initialView={{ yaw: 0, pitch: 0, fov: 75 }}
94
+ minFov={30}
95
+ maxFov={100}
96
+ onViewChange={(view) => console.log(view)}
97
+ style={{ height: 560 }}
98
+ >
99
+ <AutoRotate enabled speed={18} acceleration={18} startDelay={1_000} />
100
+ <Sphere src="/panoramas/room.webp" />
101
+ </PanoViewer>
102
+ </>
103
+ );
104
+ }
105
+ ```
106
+
107
+ 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).
108
+
109
+ 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.
110
+
111
+ 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.
112
+
113
+ ```tsx
114
+ <PanoViewer
115
+ cursors={{ default: "grab", dragging: "grabbing", hotspot: "pointer" }}
116
+ style={{ height: 560 }}
117
+ >
118
+ <ImageHotspot
119
+ id="door"
120
+ cursor="zoom-in"
121
+ position={{ yaw: 24, pitch: -6 }}
122
+ src="/hotspots/door.webp"
123
+ />
124
+ </PanoViewer>
125
+ ```
126
+
127
+ ## Sphere
128
+
129
+ `Sphere` expects a 2:1 equirectangular image. Use `yawOffset` when the source's forward direction needs horizontal adjustment.
26
130
 
27
131
  ```tsx
28
132
  "use client";
29
133
 
30
- import { PanoView, Sphere } from "@ericchen1990/pano-view";
134
+ import { PanoViewer, Sphere } from "@ericchen1990/pano-view";
31
135
 
32
136
  export function SphereExample() {
33
137
  return (
34
- <PanoView style={{ width: "100%", height: 560 }}>
138
+ <PanoViewer style={{ width: "100%", height: 560 }}>
35
139
  <Sphere src="/panoramas/room.webp" />
36
- </PanoView>
140
+ </PanoViewer>
37
141
  );
38
142
  }
39
143
  ```
40
144
 
41
- `Sphere` expects a 2:1 equirectangular image. Use `yawOffset` when the source's forward direction needs horizontal adjustment.
42
-
43
- ## Cube Tile panorama
145
+ ## Tile
44
146
 
45
147
  `Tile` renders six inward-facing cube faces and loads only tiles around the current view. The default layout matches krpano-style output:
46
148
 
@@ -51,16 +153,16 @@ tiles/{face}/l{level}/{row}/l{level}_{face}_{col}_{row}.webp
51
153
  Faces are `f`, `r`, `b`, `l`, `u`, and `d`; rows and columns are 1-based.
52
154
 
53
155
  ```tsx
54
- import { PanoView, Tile } from "@ericchen1990/pano-view";
156
+ import { PanoViewer, Tile } from "@ericchen1990/pano-view";
55
157
 
56
158
  export function TileExample() {
57
159
  return (
58
- <PanoView style={{ width: "100%", height: 560 }}>
160
+ <PanoViewer style={{ width: "100%", height: 560 }}>
59
161
  <Tile
60
162
  baseUrl="https://cdn.example.com/panoramas/room"
61
163
  multires="512,500,1000,2000"
62
164
  />
63
- </PanoView>
165
+ </PanoViewer>
64
166
  );
65
167
  }
66
168
  ```
@@ -103,9 +205,9 @@ horizontal index and `%00v` produces a three-digit vertical index. The stereo
103
205
  `%t` and frame `%f` placeholders are not applicable to `Tile`, which represents
104
206
  a single non-stereo cube panorama.
105
207
 
106
- ## Scene transitions
208
+ ## Scenes
107
209
 
108
- `PanoramaScenes` switches controlled sphere and cube-tile scenes with GPU-only
210
+ `Scenes` switches controlled sphere and cube-tile scenes with GPU-only
109
211
  snapshot blending. The target scene loads its sphere image or tile preview first;
110
212
  once ready, the current framebuffer becomes a temporary GPU texture, its source
111
213
  textures are released, and the target scene blends in. This avoids holding two
@@ -113,12 +215,12 @@ high-resolution tile scenes in WebGL memory at once.
113
215
 
114
216
  ```tsx
115
217
  import {
116
- PanoramaScenes,
117
- PanoView,
118
- type PanoramaScene,
218
+ Scenes,
219
+ PanoViewer,
220
+ type Scene,
119
221
  } from "@ericchen1990/pano-view";
120
222
 
121
- const scenes: PanoramaScene[] = [
223
+ const scenes: Scene[] = [
122
224
  { id: "lobby", type: "sphere", src: "/panoramas/lobby.webp" },
123
225
  {
124
226
  id: "terrace",
@@ -128,15 +230,15 @@ const scenes: PanoramaScene[] = [
128
230
  },
129
231
  ];
130
232
 
131
- <PanoView style={{ height: 560 }}>
132
- <PanoramaScenes
233
+ <PanoViewer style={{ height: 560 }}>
234
+ <Scenes
133
235
  scenes={scenes}
134
236
  activeSceneId={activeSceneId}
135
237
  transition="ellipticZoomOpen"
136
238
  renderHotspots={(scene) => <SceneHotspots sceneId={scene.id} />}
137
239
  onTransitionError={({ sceneId }) => console.warn("Could not load", sceneId)}
138
240
  />
139
- </PanoView>;
241
+ </PanoViewer>;
140
242
  ```
141
243
 
142
244
  Available presets are `none`, `crossfade`, `zoom`, `blackout`, `whiteFlash`,
@@ -147,58 +249,25 @@ Available presets are `none`, `crossfade`, `zoom`, `blackout`, `whiteFlash`,
147
249
  While a transition runs, panorama drag/zoom input is locked and
148
250
  `renderHotspots` is hidden. New `activeSceneId` values supersede a target that
149
251
  is still being prepared. `maxTextureMemoryMb` and `maxConcurrentTileLoads`
150
- apply to the whole `PanoramaScenes` viewer rather than to each tile scene.
252
+ apply to the whole `Scenes` viewer rather than to each tile scene.
151
253
 
152
- ## Controls and imperative API
254
+ ## AutoRotate
153
255
 
154
- Angles are public degrees. Positive yaw looks right and positive pitch looks up.
256
+ 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.
155
257
 
156
258
  ```tsx
157
- import { useRef } from "react";
158
- import {
159
- PanoView,
160
- Sphere,
161
- AutoRotate,
162
- type PanoViewHandle,
163
- } from "@ericchen1990/pano-view";
164
-
165
- export function ControlledExample() {
166
- const ref = useRef<PanoViewHandle>(null);
167
-
168
- return (
169
- <>
170
- <button onClick={() => ref.current?.setView({ yaw: 90, fov: 55 })}>
171
- Look right
172
- </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} />
187
- <Sphere src="/panoramas/room.webp" />
188
- </PanoView>
189
- </>
190
- );
191
- }
259
+ <PanoViewer style={{ height: 560 }}>
260
+ <AutoRotate enabled speed={12} acceleration={6} startDelay={2_000} />
261
+ <Sphere src="/panoramas/room.webp" />
262
+ </PanoViewer>
192
263
  ```
193
264
 
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).
195
-
196
265
  ## Mouse, touch, and keyboard controls
197
266
 
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).
267
+ `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
268
 
200
269
  ```tsx
201
- <PanoView
270
+ <PanoViewer
202
271
  controls={{
203
272
  invert: false,
204
273
  bouncingLimits: false,
@@ -209,36 +278,44 @@ The handle exposes `getView`, `setView`, `reset`, `startAutoRotate`, `stopAutoRo
209
278
  style={{ height: 560 }}
210
279
  >
211
280
  <Sphere src="/panoramas/room.webp" />
212
- </PanoView>
281
+ </PanoViewer>
213
282
  ```
214
283
 
215
284
  **Mouse** (pointer types `mouse` / `pen`): drag look and optional wheel zoom. Defaults: `rotateSpeed` `0.35`, `zoomSpeed` `0.08`, `wheel` `true`, `buttons` `["left"]`.
216
285
 
217
- **Touch**: one-finger drag and optional two-finger pinch zoom (`pinchZoom`, default `true`).
286
+ ### MouseControls
287
+
288
+ See the defaults above. Override through `controls.mouse` or by rendering `<MouseControls />` as a child.
289
+
290
+ ### TouchControls
218
291
 
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.
292
+ One-finger drag and optional two-finger pinch zoom (`pinchZoom`, default `true`). Override through `controls.touch` or by rendering `<TouchControls />` as a child.
293
+
294
+ ### KeyboardControls
295
+
296
+ 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
297
 
221
298
  Shared all-mode options on `controls`: `enabled`, `invert` (drag direction for mouse/touch), `bouncingLimits`, `fovSpeed`, `frictionStop` (default `0.01`), plus existing damping / inertia.
222
299
 
223
300
  ### Overriding a control channel
224
301
 
225
- Set the channel to `false` and render your own component when you need callbacks or fully custom behaviour:
302
+ Render the component with the props you need. The default instance for that channel is skipped:
226
303
 
227
304
  ```tsx
228
305
  import { useState } from "react";
229
306
  import {
230
307
  KeyboardControls,
231
- PanoramaScenes,
232
- PanoView,
308
+ Scenes,
309
+ PanoViewer,
233
310
  cycleSceneId,
234
- type PanoramaScene,
311
+ type Scene,
235
312
  } from "@ericchen1990/pano-view";
236
313
 
237
- export function KeyboardExample({ scenes }: { scenes: PanoramaScene[] }) {
314
+ export function KeyboardExample({ scenes }: { scenes: Scene[] }) {
238
315
  const [activeSceneId, setActiveSceneId] = useState(scenes[0]!.id);
239
316
 
240
317
  return (
241
- <PanoView controls={{ keyboard: false }} style={{ height: 560 }}>
318
+ <PanoViewer style={{ height: 560 }}>
242
319
  <KeyboardControls
243
320
  keys={{
244
321
  left: ["ArrowLeft", "a"],
@@ -261,15 +338,15 @@ export function KeyboardExample({ scenes }: { scenes: PanoramaScene[] }) {
261
338
  if (next) setActiveSceneId(next);
262
339
  }}
263
340
  />
264
- <PanoramaScenes scenes={scenes} activeSceneId={activeSceneId} />
265
- </PanoView>
341
+ <Scenes scenes={scenes} activeSceneId={activeSceneId} />
342
+ </PanoViewer>
266
343
  );
267
344
  }
268
345
  ```
269
346
 
270
347
  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
348
 
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}`).
349
+ Default bindings: arrows 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
350
 
274
351
  ## Panorama coordinate events
275
352
 
@@ -279,7 +356,7 @@ convention as the camera: positive yaw looks right and positive pitch looks up.
279
356
 
280
357
  ```tsx
281
358
  import {
282
- PanoView,
359
+ PanoViewer,
283
360
  Sphere,
284
361
  panoPositionToVector3,
285
362
  type HotspotPosition,
@@ -292,14 +369,14 @@ export function PlacementExample() {
292
369
  };
293
370
 
294
371
  return (
295
- <PanoView
372
+ <PanoViewer
296
373
  onPanoramaClick={({ position }) => place(position)}
297
374
  onPanoramaDoubleClick={({ position }) => console.log("double", position)}
298
375
  onPanoramaPointerMove={({ position }) => console.log("move", position)}
299
376
  style={{ height: 560 }}
300
377
  >
301
378
  <Sphere src="/panoramas/room.webp" />
302
- </PanoView>
379
+ </PanoViewer>
303
380
  );
304
381
  }
305
382
  ```
@@ -309,23 +386,25 @@ export function PlacementExample() {
309
386
  is normalized to `[-180, 180)`. Pitch is clamped to `[-90, 90]`; at either
310
387
  pole, yaw is normalized to `0` because it does not identify a unique point.
311
388
 
312
- ## Viewer-level events (`PanoEvents`)
389
+ ## PanoEvents
313
390
 
314
391
  `PanoEvents` is the React counterpart to krpano's global `<events>` element.
315
- Render one or more instances inside `PanoView` to subscribe to viewer-level
392
+ Render one or more instances inside `PanoViewer` to subscribe to viewer-level
316
393
  lifecycle and interaction callbacks. Multiple instances coexist (like named
317
394
  krpano events); each tracks its own `idleTime`. For composition inside a custom
318
395
  child, use `usePanoEvents` instead.
319
396
 
397
+ ### usePanoEvents
398
+
320
399
  ```tsx
321
400
  import {
322
401
  AutoRotate,
323
402
  PanoEvents,
324
- PanoView,
403
+ PanoViewer,
325
404
  Sphere,
326
405
  } from "@ericchen1990/pano-view";
327
406
 
328
- <PanoView style={{ height: 560 }}>
407
+ <PanoViewer style={{ height: 560 }}>
329
408
  <PanoEvents
330
409
  idleTime={3000}
331
410
  onIdle={() => console.log("idle")}
@@ -338,7 +417,7 @@ import {
338
417
  />
339
418
  <AutoRotate enabled />
340
419
  <Sphere src="/panoramas/room.webp" />
341
- </PanoView>;
420
+ </PanoViewer>;
342
421
  ```
343
422
 
344
423
  Supported callbacks (krpano names in parentheses where applicable):
@@ -351,41 +430,41 @@ Supported callbacks (krpano names in parentheses where applicable):
351
430
  - `onResize` — canvas content-box size via `ResizeObserver`
352
431
  - Auto-rotate: `onAutoRotateStart` / `onAutoRotateStop` / `onAutoRotateOneRound`
353
432
 
354
- `PanoView`'s existing `onViewChange` / `onPanoramaClick` / `onPanoramaDoubleClick` /
433
+ `PanoViewer`'s existing `onViewChange` / `onPanoramaClick` / `onPanoramaDoubleClick` /
355
434
  `onPanoramaPointerMove` props remain supported and share the same event bus.
356
435
 
357
436
  Resource loading and scene blending stay on their owners: use `Sphere` /
358
- `Tile` `onLoad` / `onError` / `onLoadProgress`, and `PanoramaScenes`
437
+ `Tile` `onLoad` / `onError` / `onLoadProgress`, and `Scenes`
359
438
  `onTransitionEnd` / `onTransitionError`. This package does not mirror krpano
360
439
  xml/VR/gyro/frame-render events.
361
440
 
362
- ## Context menu
441
+ ## PanoContextMenu
363
442
 
364
- `PanoView` mounts a default context menu that replaces the browser menu on
443
+ `PanoViewer` mounts a default context menu that replaces the browser menu on
365
444
  right-click. Default items: **Reset view** and **Enter fullscreen** / **Exit
366
445
  fullscreen** (label and icon follow the current fullscreen state), with a
367
446
  separator between them.
368
447
 
369
448
  ```tsx
370
- <PanoView style={{ height: 560 }}>
449
+ <PanoViewer style={{ height: 560 }}>
371
450
  <Sphere src="/panoramas/room.webp" />
372
- </PanoView>
451
+ </PanoViewer>
373
452
  ```
374
453
 
375
454
  Disable the default menu (restore the browser menu, or mount your own):
376
455
 
377
456
  ```tsx
378
- <PanoView contextMenu={false}>
457
+ <PanoViewer contextMenu={false}>
379
458
  <Sphere src="/panoramas/room.webp" />
380
- </PanoView>
459
+ </PanoViewer>
381
460
  ```
382
461
 
383
462
  Tune appearance while keeping the default items:
384
463
 
385
464
  ```tsx
386
- <PanoView contextMenu={{ appearance: { opacity: 0.92, borderRadius: 8 } }}>
465
+ <PanoViewer contextMenu={{ appearance: { opacity: 0.92, borderRadius: 8 } }}>
387
466
  <Sphere src="/panoramas/room.webp" />
388
- </PanoView>
467
+ </PanoViewer>
389
468
  ```
390
469
 
391
470
  ### Adding items without rebuilding defaults
@@ -394,7 +473,7 @@ Use `append` or `prepend` to keep the built-in Reset / Fullscreen entries and
394
473
  only add your own:
395
474
 
396
475
  ```tsx
397
- <PanoView
476
+ <PanoViewer
398
477
  contextMenu={{
399
478
  append: [
400
479
  "separator",
@@ -412,7 +491,7 @@ only add your own:
412
491
  style={{ height: 560 }}
413
492
  >
414
493
  <Sphere src="/panoramas/room.webp" />
415
- </PanoView>
494
+ </PanoViewer>
416
495
  ```
417
496
 
418
497
  ### Built-in presets
@@ -425,7 +504,7 @@ reimplementing fullscreen enter/exit state:
425
504
  - `"separator"` — a horizontal rule
426
505
 
427
506
  ```tsx
428
- <PanoView
507
+ <PanoViewer
429
508
  contextMenu={{
430
509
  items: [
431
510
  "resetView",
@@ -446,7 +525,7 @@ reimplementing fullscreen enter/exit state:
446
525
  style={{ height: 560 }}
447
526
  >
448
527
  <Sphere src="/panoramas/room.webp" />
449
- </PanoView>
528
+ </PanoViewer>
450
529
  ```
451
530
 
452
531
  Override presentation on a preset with `{ preset: "fullscreen", label: "…" }`.
@@ -463,10 +542,9 @@ stay opaque), hover/disabled colors, and icon size. For an explicit translucent
463
542
  fill without using `opacity`, you can still pass `background: "rgba(...)"`.
464
543
 
465
544
  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.
545
+ `PanoContextMenu` as a child. Do not mount both the default menu and a child
546
+ `PanoContextMenu` at once. Avoid combining the context menu with
547
+ `controls.mouse.buttons: ["right"]` — both claim the right mouse button.
470
548
 
471
549
  ## Shared hotspot contract and saved definitions
472
550
 
@@ -475,6 +553,8 @@ and semantic interaction callbacks. Point hotspots additionally use a
475
553
  controlled `position`, angular `width`/`height`, hotspot mode, and the zoom
476
554
  options described below. `interactive={false}` keeps a hotspot visible while
477
555
  letting a drawing tool receive the panorama pointer events beneath it.
556
+ Interactive hotspots set the canvas cursor to `pointer` while hovered, or to
557
+ the hotspot's `cursor` prop when that is set.
478
558
 
479
559
  `onClick` and `onHoverChange` receive a position and input source (`"pointer"`
480
560
  or `"keyboard"`). Point hotspots can use `draggable`, `onDragStart`,
@@ -482,9 +562,43 @@ or `"keyboard"`). Point hotspots can use `draggable`, `onDragStart`,
482
562
  back to its state. Polygon and polyline hotspots report the corresponding
483
563
  controlled `vertices` through `onVerticesChange`.
484
564
 
565
+ Every hotspot can show a DOM tooltip at its anchor. Pass a string or
566
+ `{ text?, image?, imageAlt? }` as `tooltip`. The default `tooltipTrigger` is
567
+ `"always"`: if content is present and the hotspot is visible, the bubble stays
568
+ on screen. Clicking the hotspot does not toggle it. Optional
569
+ `tooltipTrigger="hover"` shows the bubble on pointer hover or keyboard focus;
570
+ `"click"` pins it until the hotspot is clicked again or the empty panorama is
571
+ clicked. Hover and click triggers need `interactive`.
572
+
573
+ `tooltipPlacement` is `"top"` (default), `"bottom"`, `"left"`, or `"right"`.
574
+ The bubble sits outside that edge of the hotspot. `tooltipOffset` is the
575
+ screen-space gap in CSS pixels (default `12` / `DEFAULT_HOTSPOT_TOOLTIP_OFFSET`).
576
+
577
+ ```tsx
578
+ <ImageHotspot
579
+ id="gallery"
580
+ ariaLabel="Open gallery"
581
+ position={{ yaw: 28, pitch: -4 }}
582
+ src="/hotspots/gallery.webp"
583
+ tooltip={{ text: "Courtyard gallery", image: "/hotspots/gallery-thumb.webp" }}
584
+ tooltipPlacement="top"
585
+ tooltipOffset={16}
586
+ />
587
+ <GraphicHotspot
588
+ id="marker"
589
+ ariaLabel="Signal"
590
+ position={{ yaw: -18, pitch: 9 }}
591
+ graphic={{ kind: "ring" }}
592
+ tooltip="Hover for details"
593
+ tooltipTrigger="hover"
594
+ tooltipPlacement="right"
595
+ />
596
+ ```
597
+
485
598
  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:
599
+ `HotspotDefinition` union. Adding a variant is a TypeScript breaking change for
600
+ exhaustive switches; handle `text` and `iframe` alongside the existing point
601
+ and path categories:
488
602
 
489
603
  ```ts
490
604
  import type { HotspotDefinition } from "@ericchen1990/pano-view";
@@ -494,6 +608,8 @@ const hotspots: HotspotDefinition[] = [
494
608
  { type: "graphic", id: "marker", position: { yaw: -18, pitch: 9 }, graphic: { kind: "ring" } },
495
609
  { type: "sequence", id: "pulse", position: { yaw: -42, pitch: -7 }, src: "/hotspots/pulse.png", frameCount: 20 },
496
610
  { type: "video", id: "clip", position: { yaw: 48, pitch: 6 }, src: "/hotspots/clip.webm" },
611
+ { type: "text", id: "caption", position: { yaw: 0, pitch: -16 }, text: "Courtyard overlook" },
612
+ { type: "iframe", id: "guide", position: { yaw: -62, pitch: 4 }, src: "/hotspots/embed.html" },
497
613
  { type: "polygon", id: "zone", vertices: [{ yaw: 12, pitch: 4 }, { yaw: 22, pitch: 4 }, { yaw: 18, pitch: 14 }] },
498
614
  { type: "polyline", id: "route", vertices: [{ yaw: -8, pitch: 1 }, { yaw: 4, pitch: 8 }] },
499
615
  ];
@@ -503,15 +619,15 @@ Definitions intentionally contain data only. Render them with a switch in the
503
619
  host, where application-specific click actions, controlled media state, and
504
620
  error reporting belong.
505
621
 
506
- ## Image and graphic hotspots
622
+ ## ImageHotspot
507
623
 
508
624
  `ImageHotspot` renders an image at a controlled spherical position. Dimensions
509
625
  are angular degrees, so they remain independent of the canvas resolution.
510
626
 
511
627
  ```tsx
512
- import { ImageHotspot, PanoView, Sphere } from "@ericchen1990/pano-view";
628
+ import { ImageHotspot, PanoViewer, Sphere } from "@ericchen1990/pano-view";
513
629
 
514
- <PanoView style={{ height: 560 }}>
630
+ <PanoViewer style={{ height: 560 }}>
515
631
  <Sphere src="/panoramas/room.webp" />
516
632
  <ImageHotspot
517
633
  id="gallery"
@@ -523,9 +639,18 @@ import { ImageHotspot, PanoView, Sphere } from "@ericchen1990/pano-view";
523
639
  src="/hotspots/gallery.webp"
524
640
  onClick={({ position }) => console.log("gallery", position)}
525
641
  />
526
- </PanoView>;
642
+ </PanoViewer>;
527
643
  ```
528
644
 
645
+ Set `draggable` and update the controlled position from `onPositionChange` to
646
+ move a selected hotspot. Clickable hotspots need `ariaLabel`: PanoViewer creates
647
+ an internal semantic control for Tab, Enter, and Space activation, with a
648
+ visible WebGL focus outline.
649
+
650
+ `ImageHotspot` calls `onLoad(texture)` and `onError(error)`.
651
+
652
+ ## GraphicHotspot
653
+
529
654
  `GraphicHotspot` accepts built-in `circle`, `triangle`, `diamond`, `star`,
530
655
  `arrow`, `rectangle`, and `ring` graphics, an SVG URL, or safe SVG path data
531
656
  with an explicit viewBox. Built-in graphics support `fill`, `stroke`, and
@@ -577,13 +702,8 @@ property to orient them in another direction.
577
702
  square corners and `0.5` produces the maximum rounded corners. This replaces
578
703
  the previous Canvas-texture pixel interpretation.
579
704
 
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
705
+ `GraphicHotspot` uses the same `onLoad(texture)` and `onError(error)` callbacks
706
+ as `ImageHotspot`. Built-in paths are rasterized locally; only an SVG URL
587
707
  or SVG `path` data plus an explicit `viewBox` is accepted, never arbitrary SVG
588
708
  markup.
589
709
 
@@ -621,7 +741,7 @@ placement properties:
621
741
  />
622
742
  ```
623
743
 
624
- ## Polygon hotspots
744
+ ## PolygonHotspot
625
745
 
626
746
  `PolygonHotspot` renders a local spherical area from three or more controlled
627
747
  yaw/pitch vertices. Concave polygons and the `-180°/180°` seam are supported.
@@ -660,7 +780,7 @@ Set `fillOpacity={0}` for a closed outline-only polygon. The fill and outline
660
780
  share the same spherical edge sampling, so there is no intentional seam
661
781
  between them.
662
782
 
663
- ## Polyline hotspots
783
+ ## PolylineHotspot
664
784
 
665
785
  `PolylineHotspot` is an open path of at least two yaw/pitch vertices. It uses
666
786
  the same CSS-pixel `strokeWidth`, visibility, semantic interaction, controlled
@@ -684,7 +804,7 @@ import { PolylineHotspot } from "@ericchen1990/pano-view";
684
804
 
685
805
  `onInvalid` reports the only invalid runtime shape: fewer than two vertices.
686
806
 
687
- ## Sequence and video hotspots
807
+ ## SequenceHotspot
688
808
 
689
809
  `SequenceHotspot` animates a sprite sheet: one image containing equally sized
690
810
  frames in a vertical or horizontal strip. This matches the common krpano
@@ -712,6 +832,12 @@ import { SequenceHotspot } from "@ericchen1990/pano-view";
712
832
  />;
713
833
  ```
714
834
 
835
+ Sequence loading uses `onLoadProgress` and `onError`. The component does not
836
+ change `playing` by itself: update that prop in response to your UI, click
837
+ handler, `onEnded`, or playback error.
838
+
839
+ ## VideoHotspot
840
+
715
841
  `VideoHotspot` uses an `HTMLVideoElement` and `VideoTexture`. Its `playing`
716
842
  prop is likewise controlled: toggle it from your click handler or application
717
843
  state. On source change or unmount, the element is paused and its texture is
@@ -745,24 +871,72 @@ Browsers can reject unmuted or otherwise non-gesture playback. In that case
745
871
  the browser error; retain control of `playing` in the host and offer an
746
872
  explicit user action.
747
873
 
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.
874
+ Video reports media and poster failures through `onError`. Like
875
+ `SequenceHotspot`, it does not change `playing` by itself. This keeps source
876
+ changes, unmounts, and React StrictMode lifecycles deterministic.
753
877
 
754
- ## Automatic rotation
878
+ ## TextHotspot
755
879
 
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.
880
+ `TextHotspot` rasterizes plain text onto a canvas texture. It does not accept
881
+ HTML or Markdown. `\n` is a hard line break; `whiteSpace="normal"` (default)
882
+ wraps words to the hotspot's angular width. Dimensions stay in degrees like
883
+ the other point hotspots. `fontSize`, `padding`, and `borderRadius` are
884
+ fractions of the texture, so they scale with `width` / `height`.
757
885
 
758
886
  ```tsx
759
- <PanoView style={{ height: 560 }}>
760
- <AutoRotate enabled speed={12} acceleration={6} startDelay={2_000} />
761
- <Sphere src="/panoramas/room.webp" />
762
- </PanoView>
887
+ import { TextHotspot } from "@ericchen1990/pano-view";
888
+
889
+ <TextHotspot
890
+ id="caption"
891
+ ariaLabel="Courtyard overlook"
892
+ position={{ yaw: 0, pitch: -16 }}
893
+ width={16}
894
+ height={6}
895
+ mode="billboard"
896
+ scaleMode="fixed"
897
+ text={"Courtyard overlook\nNorth terrace"}
898
+ align="center"
899
+ verticalAlign="middle"
900
+ fontSize={0.18}
901
+ color="#f8fafc"
902
+ background="#111827"
903
+ backgroundOpacity={0.72}
904
+ />;
763
905
  ```
764
906
 
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.
907
+ Load a custom `fontFamily` on the page before the hotspot paints; canvas text
908
+ uses whatever faces the document already has. `onLoad(texture)` and
909
+ `onError(error)` match `ImageHotspot`.
910
+
911
+ ## IframeHotspot
912
+
913
+ `IframeHotspot` places an embedded document with `@react-three/drei` `Html`
914
+ `transform`, so it follows the same hotspot mode, rotation, and zoom scaling
915
+ as a texture plane. The iframe is a DOM overlay and is not occluded by the
916
+ panorama shell. There is no `srcdoc` prop.
917
+
918
+ ```tsx
919
+ import { IframeHotspot } from "@ericchen1990/pano-view";
920
+
921
+ <IframeHotspot
922
+ id="guide"
923
+ ariaLabel="Open visitor guide"
924
+ position={{ yaw: -62, pitch: 4 }}
925
+ width={18}
926
+ height={12}
927
+ mode="billboard"
928
+ src="/hotspots/embed.html"
929
+ pointerPolicy="hotspot"
930
+ />;
931
+ ```
932
+
933
+ `pointerPolicy="hotspot"` (default) sets the iframe to `pointer-events: none`
934
+ so clicks and drags hit the WebGL plane. `"content"` makes the page
935
+ interactive and blocks dragging through the iframe. `sandbox` defaults to
936
+ `allow-scripts allow-popups allow-forms` without `allow-same-origin`.
937
+ `referrerPolicy` defaults to `strict-origin-when-cross-origin`. Prefer a
938
+ same-origin document; many third-party sites send `X-Frame-Options` and will
939
+ not load.
766
940
 
767
941
  ## Next.js and SSR
768
942