@ericchen1990/pano-view 0.1.6 → 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,51 +249,104 @@ 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";
259
+ <PanoViewer style={{ height: 560 }}>
260
+ <AutoRotate enabled speed={12} acceleration={6} startDelay={2_000} />
261
+ <Sphere src="/panoramas/room.webp" />
262
+ </PanoViewer>
263
+ ```
264
+
265
+ ## Mouse, touch, and keyboard controls
266
+
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.
268
+
269
+ ```tsx
270
+ <PanoViewer
271
+ controls={{
272
+ invert: false,
273
+ bouncingLimits: false,
274
+ mouse: { zoomSpeed: 0.12, wheel: true, buttons: ["left"] },
275
+ touch: { pinchZoom: true },
276
+ keyboard: { enabled: true, rotateSpeed: 90 },
277
+ }}
278
+ style={{ height: 560 }}
279
+ >
280
+ <Sphere src="/panoramas/room.webp" />
281
+ </PanoViewer>
282
+ ```
283
+
284
+ **Mouse** (pointer types `mouse` / `pen`): drag look and optional wheel zoom. Defaults: `rotateSpeed` `0.35`, `zoomSpeed` `0.08`, `wheel` `true`, `buttons` `["left"]`.
285
+
286
+ ### MouseControls
287
+
288
+ See the defaults above. Override through `controls.mouse` or by rendering `<MouseControls />` as a child.
289
+
290
+ ### TouchControls
291
+
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.
297
+
298
+ Shared all-mode options on `controls`: `enabled`, `invert` (drag direction for mouse/touch), `bouncingLimits`, `fovSpeed`, `frictionStop` (default `0.01`), plus existing damping / inertia.
299
+
300
+ ### Overriding a control channel
301
+
302
+ Render the component with the props you need. The default instance for that channel is skipped:
303
+
304
+ ```tsx
305
+ import { useState } from "react";
158
306
  import {
159
- PanoView,
160
- Sphere,
161
- AutoRotate,
162
- type PanoViewHandle,
307
+ KeyboardControls,
308
+ Scenes,
309
+ PanoViewer,
310
+ cycleSceneId,
311
+ type Scene,
163
312
  } from "@ericchen1990/pano-view";
164
313
 
165
- export function ControlledExample() {
166
- const ref = useRef<PanoViewHandle>(null);
314
+ export function KeyboardExample({ scenes }: { scenes: Scene[] }) {
315
+ const [activeSceneId, setActiveSceneId] = useState(scenes[0]!.id);
167
316
 
168
317
  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,
318
+ <PanoViewer style={{ height: 560 }}>
319
+ <KeyboardControls
320
+ keys={{
321
+ left: ["ArrowLeft", "a"],
322
+ right: ["ArrowRight", "d"],
323
+ up: ["ArrowUp", "w"],
324
+ down: ["ArrowDown", "s"],
325
+ zoomIn: ["=", "+"],
326
+ zoomOut: ["-", "_"],
327
+ previousScene: ["[", "PageUp"],
328
+ nextScene: ["]", "PageDown"],
179
329
  }}
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
- </>
330
+ rotateSpeed={60}
331
+ zoomSpeed={30}
332
+ onPreviousScene={() => {
333
+ const next = cycleSceneId(scenes, activeSceneId, -1);
334
+ if (next) setActiveSceneId(next);
335
+ }}
336
+ onNextScene={() => {
337
+ const next = cycleSceneId(scenes, activeSceneId, 1);
338
+ if (next) setActiveSceneId(next);
339
+ }}
340
+ />
341
+ <Scenes scenes={scenes} activeSceneId={activeSceneId} />
342
+ </PanoViewer>
190
343
  );
191
344
  }
192
345
  ```
193
346
 
194
- The handle exposes `getView`, `setView`, `reset`, `startAutoRotate`, `stopAutoRotate`, and `toggleFullscreen`. Built-in input supports pointer drag, touch drag, pinch, wheel, arrow keys, `+/-`, and `0` to reset. The two auto-rotation handle methods remain supported for compatibility; prefer rendering `AutoRotate` for new code.
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).
348
+
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.
195
350
 
196
351
  ## Panorama coordinate events
197
352
 
@@ -201,7 +356,7 @@ convention as the camera: positive yaw looks right and positive pitch looks up.
201
356
 
202
357
  ```tsx
203
358
  import {
204
- PanoView,
359
+ PanoViewer,
205
360
  Sphere,
206
361
  panoPositionToVector3,
207
362
  type HotspotPosition,
@@ -214,14 +369,14 @@ export function PlacementExample() {
214
369
  };
215
370
 
216
371
  return (
217
- <PanoView
372
+ <PanoViewer
218
373
  onPanoramaClick={({ position }) => place(position)}
219
374
  onPanoramaDoubleClick={({ position }) => console.log("double", position)}
220
375
  onPanoramaPointerMove={({ position }) => console.log("move", position)}
221
376
  style={{ height: 560 }}
222
377
  >
223
378
  <Sphere src="/panoramas/room.webp" />
224
- </PanoView>
379
+ </PanoViewer>
225
380
  );
226
381
  }
227
382
  ```
@@ -231,6 +386,166 @@ export function PlacementExample() {
231
386
  is normalized to `[-180, 180)`. Pitch is clamped to `[-90, 90]`; at either
232
387
  pole, yaw is normalized to `0` because it does not identify a unique point.
233
388
 
389
+ ## PanoEvents
390
+
391
+ `PanoEvents` is the React counterpart to krpano's global `<events>` element.
392
+ Render one or more instances inside `PanoViewer` to subscribe to viewer-level
393
+ lifecycle and interaction callbacks. Multiple instances coexist (like named
394
+ krpano events); each tracks its own `idleTime`. For composition inside a custom
395
+ child, use `usePanoEvents` instead.
396
+
397
+ ### usePanoEvents
398
+
399
+ ```tsx
400
+ import {
401
+ AutoRotate,
402
+ PanoEvents,
403
+ PanoViewer,
404
+ Sphere,
405
+ } from "@ericchen1990/pano-view";
406
+
407
+ <PanoViewer style={{ height: 560 }}>
408
+ <PanoEvents
409
+ idleTime={3000}
410
+ onIdle={() => console.log("idle")}
411
+ onIdleEnd={() => console.log("active again")}
412
+ onViewSettled={(view) => console.log("settled", view)}
413
+ onViewInteractionStart={({ source }) => console.log("drag", source)}
414
+ onClick={({ position }) => console.log("pano click", position)}
415
+ onEnterFullscreen={() => console.log("fullscreen")}
416
+ onAutoRotateOneRound={() => console.log("full turn")}
417
+ />
418
+ <AutoRotate enabled />
419
+ <Sphere src="/panoramas/room.webp" />
420
+ </PanoViewer>;
421
+ ```
422
+
423
+ Supported callbacks (krpano names in parentheses where applicable):
424
+
425
+ - View: `onViewChange` (`onviewchange`), `onViewSettled`, `onViewInteractionStart` / `onViewInteractionEnd`
426
+ - Pointer on the panorama shell (not hotspots): `onClick`, `onDoubleClick`, `onPointerDown`, `onPointerUp`, `onPointerMove`, `onContextMenu`
427
+ - `onWheel` — mouse wheel only; touch pinch does not synthesize a wheel event
428
+ - Idle: `onIdle` / `onIdleEnd` with per-instance `idleTime` (ms, default `2000`)
429
+ - Fullscreen: `onEnterFullscreen` / `onExitFullscreen`
430
+ - `onResize` — canvas content-box size via `ResizeObserver`
431
+ - Auto-rotate: `onAutoRotateStart` / `onAutoRotateStop` / `onAutoRotateOneRound`
432
+
433
+ `PanoViewer`'s existing `onViewChange` / `onPanoramaClick` / `onPanoramaDoubleClick` /
434
+ `onPanoramaPointerMove` props remain supported and share the same event bus.
435
+
436
+ Resource loading and scene blending stay on their owners: use `Sphere` /
437
+ `Tile` `onLoad` / `onError` / `onLoadProgress`, and `Scenes`
438
+ `onTransitionEnd` / `onTransitionError`. This package does not mirror krpano
439
+ xml/VR/gyro/frame-render events.
440
+
441
+ ## PanoContextMenu
442
+
443
+ `PanoViewer` mounts a default context menu that replaces the browser menu on
444
+ right-click. Default items: **Reset view** and **Enter fullscreen** / **Exit
445
+ fullscreen** (label and icon follow the current fullscreen state), with a
446
+ separator between them.
447
+
448
+ ```tsx
449
+ <PanoViewer style={{ height: 560 }}>
450
+ <Sphere src="/panoramas/room.webp" />
451
+ </PanoViewer>
452
+ ```
453
+
454
+ Disable the default menu (restore the browser menu, or mount your own):
455
+
456
+ ```tsx
457
+ <PanoViewer contextMenu={false}>
458
+ <Sphere src="/panoramas/room.webp" />
459
+ </PanoViewer>
460
+ ```
461
+
462
+ Tune appearance while keeping the default items:
463
+
464
+ ```tsx
465
+ <PanoViewer contextMenu={{ appearance: { opacity: 0.92, borderRadius: 8 } }}>
466
+ <Sphere src="/panoramas/room.webp" />
467
+ </PanoViewer>
468
+ ```
469
+
470
+ ### Adding items without rebuilding defaults
471
+
472
+ Use `append` or `prepend` to keep the built-in Reset / Fullscreen entries and
473
+ only add your own:
474
+
475
+ ```tsx
476
+ <PanoViewer
477
+ contextMenu={{
478
+ append: [
479
+ "separator",
480
+ {
481
+ id: "copy",
482
+ label: "Copy yaw / pitch",
483
+ onSelect: ({ position }) => {
484
+ void navigator.clipboard.writeText(
485
+ `${position.yaw.toFixed(1)}, ${position.pitch.toFixed(1)}`,
486
+ );
487
+ },
488
+ },
489
+ ],
490
+ }}
491
+ style={{ height: 560 }}
492
+ >
493
+ <Sphere src="/panoramas/room.webp" />
494
+ </PanoViewer>
495
+ ```
496
+
497
+ ### Built-in presets
498
+
499
+ When you need a custom order, reuse built-in presets by id instead of
500
+ reimplementing fullscreen enter/exit state:
501
+
502
+ - `"resetView"` — resets the view
503
+ - `"fullscreen"` — Enter / Exit fullscreen with the matching icon
504
+ - `"separator"` — a horizontal rule
505
+
506
+ ```tsx
507
+ <PanoViewer
508
+ contextMenu={{
509
+ items: [
510
+ "resetView",
511
+ "separator",
512
+ {
513
+ id: "copy",
514
+ label: "Copy yaw / pitch",
515
+ onSelect: ({ position }) => {
516
+ void navigator.clipboard.writeText(
517
+ `${position.yaw.toFixed(1)}, ${position.pitch.toFixed(1)}`,
518
+ );
519
+ },
520
+ },
521
+ "separator",
522
+ "fullscreen",
523
+ ],
524
+ }}
525
+ style={{ height: 560 }}
526
+ >
527
+ <Sphere src="/panoramas/room.webp" />
528
+ </PanoViewer>
529
+ ```
530
+
531
+ Override presentation on a preset with `{ preset: "fullscreen", label: "…" }`.
532
+ For fully manual assembly outside the prop (for example a custom
533
+ `PanoContextMenu` child), call `createPanoContextMenuPresets({ reset,
534
+ toggleFullscreen, isFullscreen })` and use `.resetView` / `.fullscreen` /
535
+ `.defaults`.
536
+
537
+ Concrete items may use an `icon` React node or an `image` URL. Separators
538
+ always come from the `"separator"` preset. Appearance covers background, text
539
+ color, border, radius, shadow, background-only `opacity` (clamped from
540
+ `MIN_PANO_CONTEXT_MENU_BACKGROUND_OPACITY` / `0.4` to `1`; labels and icons
541
+ stay opaque), hover/disabled colors, and icon size. For an explicit translucent
542
+ fill without using `opacity`, you can still pass `background: "rgba(...)"`.
543
+
544
+ For a fully custom DOM menu, set `contextMenu={false}` and render
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.
548
+
234
549
  ## Shared hotspot contract and saved definitions
235
550
 
236
551
  Every hotspot has an `id`, optional `visible`, `interactive`, `renderOrder`,
@@ -238,6 +553,8 @@ and semantic interaction callbacks. Point hotspots additionally use a
238
553
  controlled `position`, angular `width`/`height`, hotspot mode, and the zoom
239
554
  options described below. `interactive={false}` keeps a hotspot visible while
240
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.
241
558
 
242
559
  `onClick` and `onHoverChange` receive a position and input source (`"pointer"`
243
560
  or `"keyboard"`). Point hotspots can use `draggable`, `onDragStart`,
@@ -245,9 +562,43 @@ or `"keyboard"`). Point hotspots can use `draggable`, `onDragStart`,
245
562
  back to its state. Polygon and polyline hotspots report the corresponding
246
563
  controlled `vertices` through `onVerticesChange`.
247
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
+
248
598
  For persistence or a host-owned editor, use the exported discriminated
249
- `HotspotDefinition` union. It contains the original five categories plus the
250
- 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:
251
602
 
252
603
  ```ts
253
604
  import type { HotspotDefinition } from "@ericchen1990/pano-view";
@@ -257,6 +608,8 @@ const hotspots: HotspotDefinition[] = [
257
608
  { type: "graphic", id: "marker", position: { yaw: -18, pitch: 9 }, graphic: { kind: "ring" } },
258
609
  { type: "sequence", id: "pulse", position: { yaw: -42, pitch: -7 }, src: "/hotspots/pulse.png", frameCount: 20 },
259
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" },
260
613
  { type: "polygon", id: "zone", vertices: [{ yaw: 12, pitch: 4 }, { yaw: 22, pitch: 4 }, { yaw: 18, pitch: 14 }] },
261
614
  { type: "polyline", id: "route", vertices: [{ yaw: -8, pitch: 1 }, { yaw: 4, pitch: 8 }] },
262
615
  ];
@@ -266,15 +619,15 @@ Definitions intentionally contain data only. Render them with a switch in the
266
619
  host, where application-specific click actions, controlled media state, and
267
620
  error reporting belong.
268
621
 
269
- ## Image and graphic hotspots
622
+ ## ImageHotspot
270
623
 
271
624
  `ImageHotspot` renders an image at a controlled spherical position. Dimensions
272
625
  are angular degrees, so they remain independent of the canvas resolution.
273
626
 
274
627
  ```tsx
275
- import { ImageHotspot, PanoView, Sphere } from "@ericchen1990/pano-view";
628
+ import { ImageHotspot, PanoViewer, Sphere } from "@ericchen1990/pano-view";
276
629
 
277
- <PanoView style={{ height: 560 }}>
630
+ <PanoViewer style={{ height: 560 }}>
278
631
  <Sphere src="/panoramas/room.webp" />
279
632
  <ImageHotspot
280
633
  id="gallery"
@@ -286,13 +639,23 @@ import { ImageHotspot, PanoView, Sphere } from "@ericchen1990/pano-view";
286
639
  src="/hotspots/gallery.webp"
287
640
  onClick={({ position }) => console.log("gallery", position)}
288
641
  />
289
- </PanoView>;
642
+ </PanoViewer>;
290
643
  ```
291
644
 
292
- `GraphicHotspot` accepts built-in `circle`, `rectangle`, and `ring` graphics,
293
- an SVG URL, or safe SVG path data with an explicit viewBox. Built-in graphics
294
- support `fill`, `stroke`, and `strokeWidth`; rectangles also support
295
- `cornerRadius` and rings support `innerRadius`.
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
+
654
+ `GraphicHotspot` accepts built-in `circle`, `triangle`, `diamond`, `star`,
655
+ `arrow`, `rectangle`, and `ring` graphics, an SVG URL, or safe SVG path data
656
+ with an explicit viewBox. Built-in graphics support `fill`, `stroke`, and
657
+ `strokeWidth`; rectangles support a relative `cornerRadius` from `0` to `0.5`,
658
+ and rings support `innerRadius`.
296
659
 
297
660
  ```tsx
298
661
  import { GraphicHotspot } from "@ericchen1990/pano-view";
@@ -314,13 +677,33 @@ import { GraphicHotspot } from "@ericchen1990/pano-view";
314
677
  />
315
678
  ```
316
679
 
317
- Set `draggable` and update the controlled position from `onPositionChange` to
318
- move a selected hotspot. Clickable hotspots need `ariaLabel`: PanoView creates
319
- an internal semantic control for Tab, Enter, and Space activation, with a
320
- visible WebGL focus outline.
680
+ Triangle, diamond, star, and arrow graphics use the same paint properties. The
681
+ triangle and arrow point upward at `rotation={0}`; use the hotspot's `rotation`
682
+ property to orient them in another direction.
321
683
 
322
- `ImageHotspot` calls `onLoad(texture)` and `onError(error)`. `GraphicHotspot`
323
- uses the same callbacks. Built-in paths are rasterized locally; only an SVG URL
684
+ ```tsx
685
+ <GraphicHotspot
686
+ id="direction"
687
+ ariaLabel="Open next scene"
688
+ position={{ yaw: 8, pitch: 2 }}
689
+ rotation={45}
690
+ width={8}
691
+ height={8}
692
+ graphic={{
693
+ kind: "arrow",
694
+ fill: "#df6b42",
695
+ stroke: "#f5fbfc",
696
+ strokeWidth: 8,
697
+ }}
698
+ />
699
+ ```
700
+
701
+ `cornerRadius` is relative to the rendered graphic's shorter side: `0` keeps
702
+ square corners and `0.5` produces the maximum rounded corners. This replaces
703
+ the previous Canvas-texture pixel interpretation.
704
+
705
+ `GraphicHotspot` uses the same `onLoad(texture)` and `onError(error)` callbacks
706
+ as `ImageHotspot`. Built-in paths are rasterized locally; only an SVG URL
324
707
  or SVG `path` data plus an explicit `viewBox` is accepted, never arbitrary SVG
325
708
  markup.
326
709
 
@@ -337,9 +720,10 @@ placement properties:
337
720
  - `scaleMode="fov"` (default) lets a hotspot grow when the user zooms in.
338
721
  `scaleMode="fixed"` compensates for FOV changes and keeps its screen size
339
722
  close to the size at `referenceFov` (default `75`).
340
- - `rotation` rotates the hotspot around its own normal in degrees. `scale`
341
- applies an overall positive multiplier to its angular width and height and
342
- defaults to `1`.
723
+ - `rotation` rotates the hotspot around its own normal in degrees; positive
724
+ values rotate clockwise and negative values rotate counterclockwise.
725
+ `scale` applies an overall positive multiplier to its angular width and
726
+ height and defaults to `1`.
343
727
 
344
728
  ```tsx
345
729
  <ImageHotspot
@@ -357,7 +741,7 @@ placement properties:
357
741
  />
358
742
  ```
359
743
 
360
- ## Polygon hotspots
744
+ ## PolygonHotspot
361
745
 
362
746
  `PolygonHotspot` renders a local spherical area from three or more controlled
363
747
  yaw/pitch vertices. Concave polygons and the `-180°/180°` seam are supported.
@@ -396,7 +780,7 @@ Set `fillOpacity={0}` for a closed outline-only polygon. The fill and outline
396
780
  share the same spherical edge sampling, so there is no intentional seam
397
781
  between them.
398
782
 
399
- ## Polyline hotspots
783
+ ## PolylineHotspot
400
784
 
401
785
  `PolylineHotspot` is an open path of at least two yaw/pitch vertices. It uses
402
786
  the same CSS-pixel `strokeWidth`, visibility, semantic interaction, controlled
@@ -420,7 +804,7 @@ import { PolylineHotspot } from "@ericchen1990/pano-view";
420
804
 
421
805
  `onInvalid` reports the only invalid runtime shape: fewer than two vertices.
422
806
 
423
- ## Sequence and video hotspots
807
+ ## SequenceHotspot
424
808
 
425
809
  `SequenceHotspot` animates a sprite sheet: one image containing equally sized
426
810
  frames in a vertical or horizontal strip. This matches the common krpano
@@ -448,6 +832,12 @@ import { SequenceHotspot } from "@ericchen1990/pano-view";
448
832
  />;
449
833
  ```
450
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
+
451
841
  `VideoHotspot` uses an `HTMLVideoElement` and `VideoTexture`. Its `playing`
452
842
  prop is likewise controlled: toggle it from your click handler or application
453
843
  state. On source change or unmount, the element is paused and its texture is
@@ -481,24 +871,72 @@ Browsers can reject unmuted or otherwise non-gesture playback. In that case
481
871
  the browser error; retain control of `playing` in the host and offer an
482
872
  explicit user action.
483
873
 
484
- Sequence loading uses `onLoadProgress` and `onError`; video reports media and
485
- poster failures through `onError`. Neither component changes `playing` by
486
- itself: update that prop in response to your UI, click handler, `onEnded`, or
487
- playback error. This keeps source changes, unmounts, and React StrictMode
488
- 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.
489
877
 
490
- ## Automatic rotation
878
+ ## TextHotspot
491
879
 
492
- 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`.
493
885
 
494
886
  ```tsx
495
- <PanoView style={{ height: 560 }}>
496
- <AutoRotate enabled speed={12} acceleration={6} startDelay={2_000} />
497
- <Sphere src="/panoramas/room.webp" />
498
- </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
+ />;
499
905
  ```
500
906
 
501
- 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.
502
940
 
503
941
  ## Next.js and SSR
504
942