@ericchen1990/pano-view 1.0.0 → 2.1.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
@@ -7,10 +7,13 @@ Try the components in the [live playground](https://pano-view-playground.vercel.
7
7
  ## krpano-compatible tile output
8
8
 
9
9
  Use this package when you want a React-native viewer around equirectangular
10
- images or an existing krpano cube-tile pyramid. `Tile` understands the common
11
- krpano-style multires directory layout by default, while keeping rendering,
12
- controls, hotspots, and scene transitions in your React application. It is
13
- compatible with that tile output format but is not affiliated with krpano.
10
+ images or an existing krpano cube-tile pyramid. Copy the krpano `<cube>` `url`
11
+ and `multires` attributes into `Tile`'s `urlTemplate` and `multires`, and copy
12
+ `<preview url>` into `previewUrl`; krpano's on-disk naming is not the same as
13
+ this package's defaults, so omitting them loads the wrong tiles or preview.
14
+ Rendering, controls, hotspots, and scene transitions stay in your React
15
+ application. This package is compatible with that tile output format but is
16
+ not affiliated with krpano.
14
17
 
15
18
  ## Install
16
19
 
@@ -18,7 +21,25 @@ compatible with that tile output format but is not affiliated with krpano.
18
21
  npm install @ericchen1990/pano-view
19
22
  ```
20
23
 
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.
24
+ React 19, React DOM 19, Three.js, `@react-three/fiber` 9,
25
+ `@react-three/drei` 10, and `@react-three/xr` 6 are peer dependencies. npm 7+
26
+ and pnpm install them automatically; they must be a single copy in the host
27
+ app.
28
+
29
+ Tailwind CSS v4 is now required for the built-in HTML chrome: video controls,
30
+ captions, context menus, tooltips, and accessibility helpers render with
31
+ Tailwind utility classes instead of a bundled stylesheet. Add a source entry in
32
+ your app so Tailwind can scan this package:
33
+
34
+ ```css
35
+ @import "tailwindcss";
36
+ @source "../node_modules/@ericchen1990/pano-view";
37
+ ```
38
+
39
+ This package does not ship a separate CSS file. If your app does not scan
40
+ `@ericchen1990/pano-view`, those built-in overlays will render without their
41
+ default styling. Customization still works through `className`, `style`, and
42
+ component appearance props.
22
43
 
23
44
  ## Exported components
24
45
 
@@ -26,8 +47,10 @@ React 19, React DOM 19, Three.js, `@react-three/fiber` 9, and `@react-three/drei
26
47
 
27
48
  - [`PanoViewer`](#panoviewer) — canvas shell, camera, default controls, and imperative view API
28
49
  - [`Sphere`](#sphere) — single 2:1 equirectangular image
50
+ - [`PanoVideo`](#panovideo) — 2:1 equirectangular video on the panorama sphere
29
51
  - [`Tile`](#tile) — krpano-style multires cube tiles
30
52
  - [`Scenes`](#scenes) — controlled multi-scene transitions
53
+ - [`PanoFilter`](#panofilter) — color and artistic filters on the panorama source
31
54
 
32
55
  ### Controls
33
56
 
@@ -35,11 +58,14 @@ React 19, React DOM 19, Three.js, `@react-three/fiber` 9, and `@react-three/drei
35
58
  - [`TouchControls`](#touchcontrols) — one-finger drag and pinch zoom
36
59
  - [`KeyboardControls`](#keyboardcontrols) — arrow-key look, FOV, and scene cycling
37
60
  - [`AutoRotate`](#autorotate) — automatic yaw rotation
61
+ - [`Gyro`](#gyro) — opt-in device-orientation control
62
+ - [`WebVR`](#webvr) — WebXR-first immersive VR with MobileVR fallback
38
63
 
39
64
  ### Events and chrome
40
65
 
41
66
  - [`PanoEvents`](#panoevents) — viewer-level lifecycle and interaction callbacks
42
67
  - [`PanoContextMenu`](#panocontextmenu) — right-click reset / fullscreen menu
68
+ - [`PanoVideoControls`](#panovideo) — playback chrome for `PanoVideo` (mounted by default)
43
69
 
44
70
  ### Hotspots
45
71
 
@@ -97,7 +123,7 @@ export function ControlledExample() {
97
123
  style={{ height: 560 }}
98
124
  >
99
125
  <AutoRotate enabled speed={18} acceleration={18} startDelay={1_000} />
100
- <Sphere src="/panoramas/room.webp" />
126
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
101
127
  </PanoViewer>
102
128
  </>
103
129
  );
@@ -128,6 +154,12 @@ The canvas cursor defaults to `grab`, becomes `grabbing` while dragging with the
128
154
 
129
155
  `Sphere` expects a 2:1 equirectangular image. Use `yawOffset` when the source's forward direction needs horizontal adjustment.
130
156
 
157
+ `previewUrl` is required and shows a low-resolution 2:1 image while `src` loads.
158
+ Relative paths resolve against the directory of `src`; root-absolute and
159
+ `http(s)` URLs are used as-is. For krpano sphere scenes, copy
160
+ [`<preview url>`](https://krpano.com/docu/xml/#preview) into `previewUrl`. That
161
+ preview must be equirectangular, not a cube strip.
162
+
131
163
  ```tsx
132
164
  "use client";
133
165
 
@@ -136,21 +168,97 @@ import { PanoViewer, Sphere } from "@ericchen1990/pano-view";
136
168
  export function SphereExample() {
137
169
  return (
138
170
  <PanoViewer style={{ width: "100%", height: 560 }}>
139
- <Sphere src="/panoramas/room.webp" />
171
+ <Sphere
172
+ src="/panoramas/room.webp"
173
+ previewUrl="preview.webp"
174
+ />
175
+ </PanoViewer>
176
+ );
177
+ }
178
+ ```
179
+
180
+ ## PanoVideo
181
+
182
+ `PanoVideo` maps a 2:1 equirectangular video onto the same inward sphere as
183
+ `Sphere`. Pass one `src`, or `variants` with multiple files per quality so the
184
+ browser can pick mp4 / webm and the control bar can switch resolution. Changing
185
+ quality keeps the current time and play/pause state.
186
+
187
+ A default playback bar is mounted on the viewer overlay: play/pause, seek, time,
188
+ volume, speed, resolution, captions, and fullscreen. Volume is a hover (or tap)
189
+ vertical slider when the browser allows `HTMLMediaElement.volume`; iOS Safari
190
+ only exposes mute. The speed menu lists rates the current element accepts. The
191
+ captions menu also includes a settings panel for subtitle font size and
192
+ background opacity. When the viewer is narrow, speed, quality, and captions
193
+ move into a More menu.
194
+ Pass `controls={false}` to hide it, an appearance object to restyle the default
195
+ instance, or render `PanoVideoControls` as a `PanoViewer` child to replace that
196
+ instance.
197
+
198
+ Captions use WebVTT `tracks`. The video element is off-screen, so cue text is
199
+ rendered as a HUD overlay. `captions={false}` hides the overlay and the language
200
+ menu; an object merges with the default appearance (`color`, `background`,
201
+ `fontSize`, `fontFamily`, `textShadow`, `padding`, `borderRadius`, `maxWidth`,
202
+ `bottom`). Those values act as the initial caption appearance; viewers can still
203
+ adjust subtitle size and background from the default control-bar menu at
204
+ runtime.
205
+
206
+ ```tsx
207
+ "use client";
208
+
209
+ import { PanoViewer, PanoVideo } from "@ericchen1990/pano-view";
210
+
211
+ export function VideoExample() {
212
+ return (
213
+ <PanoViewer style={{ width: "100%", height: 560 }}>
214
+ <PanoVideo
215
+ defaultVariantId="1024"
216
+ variants={[
217
+ {
218
+ id: "1024",
219
+ label: "1024p",
220
+ poster: "/video/1024-poster.jpg",
221
+ sources: [
222
+ { src: "/video/1024.mp4", type: "video/mp4" },
223
+ { src: "/video/1024.webm", type: "video/webm" },
224
+ ],
225
+ },
226
+ {
227
+ id: "1920",
228
+ label: "1920p",
229
+ poster: "/video/1920-poster.jpg",
230
+ sources: [
231
+ { src: "/video/1920.mp4", type: "video/mp4" },
232
+ { src: "/video/1920.webm", type: "video/webm" },
233
+ ],
234
+ },
235
+ ]}
236
+ tracks={[
237
+ { src: "/video/en.vtt", srcLang: "en", label: "English", default: true },
238
+ { src: "/video/zh.vtt", srcLang: "zh", label: "中文" },
239
+ ]}
240
+ captions={{ fontSize: 16, color: "#fff" }}
241
+ muted
242
+ />
140
243
  </PanoViewer>
141
244
  );
142
245
  }
143
246
  ```
144
247
 
248
+ `muted` defaults to `true` so `autoPlay` has a chance under browser autoplay
249
+ rules. Unmuted playback still needs a user gesture; `onPlaybackStateChange`
250
+ receives `"blocked"` when `play()` is rejected. Remote videos and VTT files
251
+ that are not same-origin should set `crossOrigin` (usually `"anonymous"`).
252
+
145
253
  ## Tile
146
254
 
147
- `Tile` renders six inward-facing cube faces and loads only tiles around the current view. The default layout matches krpano-style output:
255
+ `Tile` renders six inward-facing cube faces and loads only tiles around the current view. When `urlTemplate` is omitted, paths relative to `baseUrl` use this package default:
148
256
 
149
257
  ```text
150
- tiles/{face}/l{level}/{row}/l{level}_{face}_{col}_{row}.webp
258
+ tiles/%s/l%l/%v/l%l_%s_%h_%v.webp
151
259
  ```
152
260
 
153
- Faces are `f`, `r`, `b`, `l`, `u`, and `d`; rows and columns are 1-based.
261
+ which expands to `tiles/{face}/l{level}/{row}/l{level}_{face}_{col}_{row}.webp`. Faces are `f`, `r`, `b`, `l`, `u`, and `d`; rows and columns are 1-based.
154
262
 
155
263
  ```tsx
156
264
  import { PanoViewer, Tile } from "@ericchen1990/pano-view";
@@ -161,33 +269,81 @@ export function TileExample() {
161
269
  <Tile
162
270
  baseUrl="https://cdn.example.com/panoramas/room"
163
271
  multires="512,500,1000,2000"
272
+ previewUrl="previews/cube-vertical.webp"
164
273
  />
165
274
  </PanoViewer>
166
275
  );
167
276
  }
168
277
  ```
169
278
 
170
- The first `multires` value is the tile size. Remaining values are ascending cube-face sizes for `l1`, `l2`, and later levels. The default preview is `${baseUrl}/previews/cube-vertical.webp`, with six square faces stacked from top to bottom as `l/f/r/b/u/d`. Use `previewFaceOrder` when an atlas uses a different order:
279
+ krpano cube-tile output does not follow that default. Read `url`, `multires`,
280
+ and the preview path from the krpano XML
281
+ ([`<cube>`](https://krpano.com/docu/xml/#image.cube),
282
+ [`<preview>`](https://krpano.com/docu/xml/#preview)) and pass them through. If
283
+ those values are missing or do not match the files on disk, tiles resolve to
284
+ the wrong face, level, or row/column and the panorama looks scrambled.
285
+
286
+ | krpano | `Tile` / `TileScene` prop |
287
+ |---|---|
288
+ | `<cube url>` | `urlTemplate` |
289
+ | `<cube multires>` | `multires` |
290
+ | `<preview url>` | `previewUrl` |
291
+ | `<preview striporder>` | `previewFaceOrder` |
292
+
293
+ `Sphere` uses the same `<preview url>` → `previewUrl` mapping; that file must be a 2:1 equirectangular image, not a cube strip.
294
+
295
+ krpano's documented short syntax:
296
+
297
+ ```xml
298
+ <preview url="preview.jpg" />
299
+ <image>
300
+ <cube url="pano_%s_%l_%v_%h.jpg"
301
+ multires="512,1024,2048,4096"
302
+ />
303
+ </image>
304
+ ```
305
+
306
+ maps to:
307
+
308
+ ```tsx
309
+ <Tile
310
+ baseUrl="/panoramas/room"
311
+ urlTemplate="pano_%s_%l_%v_%h.jpg"
312
+ multires="512,1024,2048,4096"
313
+ previewUrl="preview.jpg"
314
+ />
315
+ ```
316
+
317
+ A MAKE PANO (MULTIRES) folder layout is often
318
+ `tiles/%s/l%l/%v/l%l_%s_%v_%h.jpg` with `preview.jpg` at the scene root. That
319
+ filename is `%v_%h` (row then column). This package's default tile template is
320
+ `%h_%v` (column then row). Copy the XML values instead of assuming the two
321
+ conventions match.
322
+
323
+ The first `multires` value is the tile size. Remaining values are ascending cube-face sizes for `l1`, `l2`, and later levels. `previewUrl` is required. Relative paths resolve against `baseUrl`; root-absolute and `http(s)` URLs are used as-is. A typical krpano MAKE PANO (MULTIRES) scene looks like this:
171
324
 
172
325
  ```tsx
173
326
  <Tile
174
327
  baseUrl="/panoramas/room"
328
+ urlTemplate="tiles/%s/l%l/%v/l%l_%s_%v_%h.jpg"
175
329
  multires="512,1000,2000"
176
- previewUrl="/panoramas/room/previews/cube-vertical.webp"
330
+ previewUrl="preview.jpg"
177
331
  previewFaceOrder={["l", "f", "r", "b", "u", "d"]}
178
332
  />
179
333
  ```
180
334
 
181
- `previewFaceOrder` must list the six face codes in their top-to-bottom atlas order: `f`, `r`, `b`, `l`, `u`, and `d`.
335
+ `previewFaceOrder` must list the six face codes in their top-to-bottom atlas order: `f`, `r`, `b`, `l`, `u`, and `d`. The preview image is a vertical 1×6 cubestrip. krpano `thumb.jpg` is a small thumbnail and is not this atlas.
182
336
 
183
337
  During rapid rotation or zoom, loaded tiles remain visible while newly visible tiles use their parent level or the preview as a local fallback.
184
338
 
185
- Override storage conventions with either a krpano placeholder template or a resolver. Both return paths relative to `baseUrl`:
339
+ For layouts that are not krpano XML, override the default with a placeholder
340
+ template or a resolver. Both return paths relative to `baseUrl`:
186
341
 
187
342
  ```tsx
188
343
  <Tile
189
344
  baseUrl="/panoramas/room"
190
345
  multires={{ tileSize: 512, levels: [500, 1000, 2000] }}
346
+ previewUrl="previews/cube-vertical.webp"
191
347
  urlTemplate="assets/%s/%l/%v_%h.webp"
192
348
  resolveTileUrl={({ face, level, row, col }) =>
193
349
  `api/tile/${face}/${level}/${row}/${col}`
@@ -208,7 +364,7 @@ a single non-stereo cube panorama.
208
364
  ## Scenes
209
365
 
210
366
  `Scenes` switches controlled sphere and cube-tile scenes with GPU-only
211
- snapshot blending. The target scene loads its sphere image or tile preview first;
367
+ snapshot blending. The target scene loads its sphere or tile preview first;
212
368
  once ready, the current framebuffer becomes a temporary GPU texture, its source
213
369
  textures are released, and the target scene blends in. This avoids holding two
214
370
  high-resolution tile scenes in WebGL memory at once.
@@ -221,12 +377,14 @@ import {
221
377
  } from "@ericchen1990/pano-view";
222
378
 
223
379
  const scenes: Scene[] = [
224
- { id: "lobby", type: "sphere", src: "/panoramas/lobby.webp" },
380
+ { id: "lobby", type: "sphere", src: "/panoramas/lobby.webp", previewUrl: "preview.webp" },
225
381
  {
226
382
  id: "terrace",
227
383
  type: "tile",
228
384
  baseUrl: "/panoramas/terrace",
229
385
  multires: "512,1000,2000",
386
+ urlTemplate: "tiles/%s/l%l/%v/l%l_%s_%v_%h.webp",
387
+ previewUrl: "previews/cube-vertical.webp",
230
388
  },
231
389
  ];
232
390
 
@@ -241,16 +399,64 @@ const scenes: Scene[] = [
241
399
  </PanoViewer>;
242
400
  ```
243
401
 
244
- Available presets are `none`, `crossfade`, `zoom`, `blackout`, `whiteFlash`,
245
- `slideRightToLeft`, `slideTopToBottom`, `slideDiagonal`, `circleOpen`,
246
- `verticalOpen`, `horizontalOpen`, and `ellipticZoomOpen`. Pass
247
- `{ preset: "crossfade", duration: 0.6 }` to override a preset duration.
402
+ Available KRpano-style presets are `none`, `crossfade`, `zoom`, `blackout`,
403
+ `whiteFlash`, `slideRightToLeft`, `slideTopToBottom`, `slideDiagonal`,
404
+ `circleOpen`, `verticalOpen`, `horizontalOpen`, and `ellipticZoomOpen`.
405
+ Grid presets are `pixelate`, `gridWipe`, `gridWipeUp`, `gridWipeRight`,
406
+ `gridWipeDiagonal`, `gridWipeCenter`, `gridWipeChecker`, and `hexDissolve`.
407
+ Cinematic presets are `dissolve`, `shatter`, `particles`, `glitch`, `swirl`,
408
+ `clockWipe`, `ripple`, `zoomBlur`, and `filmBurn`. All of them still blend a
409
+ GPU framebuffer snapshot over the incoming panorama; they do not mix two full
410
+ 360 textures. Pass `{ preset: "crossfade", duration: 0.6 }` to override a
411
+ preset duration.
248
412
 
249
413
  While a transition runs, panorama drag/zoom input is locked and
250
414
  `renderHotspots` is hidden. New `activeSceneId` values supersede a target that
251
415
  is still being prepared. `maxTextureMemoryMb` and `maxConcurrentTileLoads`
252
416
  apply to the whole `Scenes` viewer rather than to each tile scene.
253
417
 
418
+ ## PanoFilter
419
+
420
+ Render `PanoFilter` inside `PanoViewer` to color-grade or stylize the panorama
421
+ source (`Sphere`, `Tile`, and `PanoVideo`). The filter runs on those materials
422
+ only: 3D hotspots, tooltips, video chrome, and iframe overlays keep their
423
+ original color. WebXR / MobileVR inherit the same look because the panorama
424
+ shader itself is patched.
425
+
426
+ `intensity` mixes the filtered result with the original image (`0`–`1`,
427
+ default `1`). Omit the component, pass `preset="none"`, or set `enabled={false}`
428
+ to leave the source unfiltered.
429
+
430
+ ```tsx
431
+ <PanoViewer style={{ height: 560 }}>
432
+ <PanoFilter preset="pencil" intensity={0.85} />
433
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
434
+ <ImageHotspot
435
+ id="door"
436
+ position={{ yaw: 24, pitch: -6 }}
437
+ src="/hotspots/door.webp"
438
+ />
439
+ </PanoViewer>
440
+ ```
441
+
442
+ | Preset | Look |
443
+ |---|---|
444
+ | `none` | Identity (no filter) |
445
+ | `grayscale` | Rec.709 black and white |
446
+ | `sepia` | Warm brown antique print |
447
+ | `vintage` | Faded film with grain |
448
+ | `cool` / `warm` | Blue or amber temperature shift |
449
+ | `pencil` | Graphite sketch on paper |
450
+ | `coloredPencil` | Tinted pencil lines |
451
+ | `crayon` | Posterized wax strokes |
452
+ | `watercolor` | Soft pigment on paper |
453
+ | `cartoon` | Cel shading with outlines |
454
+ | `crosshatch` | Luminance hatching |
455
+
456
+ Built-in artistic looks use screen-space edges and world-space grain so cube
457
+ tiles do not show filter seams. Scene-transition snapshots capture the filtered
458
+ panorama when a filter is active.
459
+
254
460
  ## AutoRotate
255
461
 
256
462
  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.
@@ -258,10 +464,140 @@ Auto-rotation is off by default. Render `AutoRotate` inside `PanoViewer` to enab
258
464
  ```tsx
259
465
  <PanoViewer style={{ height: 560 }}>
260
466
  <AutoRotate enabled speed={12} acceleration={6} startDelay={2_000} />
261
- <Sphere src="/panoramas/room.webp" />
467
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
262
468
  </PanoViewer>
263
469
  ```
264
470
 
471
+ ## Gyro
472
+
473
+ `Gyro` is an opt-in device-orientation control and is disabled by default.
474
+ Render it inside `PanoViewer` and control its `enabled` prop. Relative mode
475
+ (the default) treats the view at activation time as the starting direction.
476
+ Set `absolute` to follow compass-oriented headings and use `north` to identify
477
+ the panorama yaw that points North.
478
+
479
+ ```tsx
480
+ import { useRef, useState } from "react";
481
+ import {
482
+ Gyro,
483
+ PanoViewer,
484
+ Sphere,
485
+ type GyroHandle,
486
+ } from "@ericchen1990/pano-view";
487
+
488
+ export function GyroExample() {
489
+ const gyroRef = useRef<GyroHandle>(null);
490
+ const [enabled, setEnabled] = useState(false);
491
+
492
+ const toggle = async () => {
493
+ if (enabled) {
494
+ setEnabled(false);
495
+ return;
496
+ }
497
+ // iOS requires this call to run directly inside a user gesture.
498
+ setEnabled((await gyroRef.current?.requestPermission()) ?? false);
499
+ };
500
+
501
+ return (
502
+ <>
503
+ <button onClick={() => void toggle()}>
504
+ {enabled ? "Disable gyro" : "Enable gyro"}
505
+ </button>
506
+ <PanoViewer style={{ height: 560 }}>
507
+ <Gyro
508
+ ref={gyroRef}
509
+ enabled={enabled}
510
+ camroll
511
+ friction={0}
512
+ softstart={0.5}
513
+ touchMode="full"
514
+ onDenied={() => setEnabled(false)}
515
+ />
516
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
517
+ </PanoViewer>
518
+ </>
519
+ );
520
+ }
521
+ ```
522
+
523
+ `camroll` (default `true`) levels the camera from device roll. `friction`
524
+ accepts `0` to `0.99`; higher values smooth more strongly but add latency.
525
+ `softstart` is the activation blend duration in seconds (default `0.5`).
526
+ `desktopSupport` defaults to `false` because desktop browsers can expose the
527
+ API without a physical sensor.
528
+
529
+ `touchMode` controls how touch drag combines with device movement:
530
+
531
+ - `off` ignores touch yaw/pitch while leaving pinch FOV available.
532
+ - `horizontaloffset` adds only a horizontal offset.
533
+ - `full` adds horizontal and vertical offsets; the vertical offset returns
534
+ toward the physical orientation as the device moves.
535
+ - `disablegyro` disables the active gyro session on the next touch.
536
+
537
+ The `GyroHandle` exposes `resetSensor(yaw?, pitch?)`, `isAvailable()`,
538
+ `isEnabled()`, and `requestPermission()`. Gyro activity pauses `AutoRotate`
539
+ and counts as viewer interaction for idle tracking. Sensor APIs generally
540
+ require HTTPS. A cross-origin iframe must be permitted by the host page, for
541
+ example with `allow="gyroscope; accelerometer"` and a compatible
542
+ `Permissions-Policy` header.
543
+
544
+ ## WebVR
545
+
546
+ `WebVR` adds immersive viewing to the nearest `PanoViewer`. It prefers an
547
+ `immersive-vr` WebXR session on supported headsets. On phones without WebXR it
548
+ uses a stereoscopic split-screen view with device orientation, and on desktop
549
+ it can provide a mouse-driven simulated VR preview. The built-in chrome includes
550
+ Enter VR, Exit VR, and MobileVR setup controls.
551
+
552
+ ```tsx
553
+ import { useRef } from "react";
554
+ import {
555
+ PanoViewer,
556
+ Sphere,
557
+ WebVR,
558
+ type WebVRHandle,
559
+ } from "@ericchen1990/pano-view";
560
+
561
+ export function WebVRExample() {
562
+ const webVRRef = useRef<WebVRHandle>(null);
563
+
564
+ return (
565
+ <PanoViewer style={{ height: 560 }}>
566
+ <WebVR
567
+ ref={webVRRef}
568
+ chrome
569
+ fakeSupport
570
+ mobileVr
571
+ onEnterVR={(mode) => console.log("Entered", mode)}
572
+ />
573
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
574
+ </PanoViewer>
575
+ );
576
+ }
577
+ ```
578
+
579
+ The handle exposes `enterVR()`, `exitVR()`, `toggleVR()`, `isAvailable()`,
580
+ `isEnabled()`, `getMode()`, and `requestPermission()`. Modes are `"webxr"`,
581
+ `"mobilevr"`, and `"fake"`. `profile` accepts `"none"`, `"cardboard-v1"`,
582
+ `"cardboard-v2"`, `"gear-vr"`, `"daydream"`, or a custom
583
+ `{ fov, ipdMm, k1, k2 }` lens profile. MobileVR setup values are versioned and
584
+ stored in the current origin's `localStorage`. In VR, a center reticle shows a
585
+ crosshair; looking at an interactive hotspot draws a clockwise ring that fills
586
+ over `cursorDwellMs` (default 1500), then the hotspot click handler runs. Set
587
+ `cursorDwellMs={0}` to keep the reticle without auto-click. Simulated desktop
588
+ VR (`fake` mode) requests pointer lock by default (`mousePointerLock`) so the
589
+ cursor hides and mouse movement looks around; click the view again if the
590
+ browser releases the lock. Fallback sessions keep the screen awake
591
+ (`wakelock`) with the Screen Wake Lock API, or a looping hidden video on
592
+ browsers that lack it.
593
+
594
+ WebXR requires HTTPS and a user gesture. Mobile sensor access also requires
595
+ HTTPS and may require an explicit iOS permission prompt. Embedded viewers need
596
+ appropriate `xr-spatial-tracking`, `gyroscope`, and `accelerometer` permissions.
597
+ DOM overlays such as iframe hotspots, tooltips, and video controls are not
598
+ visible inside an immersive headset session; mesh-based hotspots remain
599
+ interactive through the default WebXR controller rays.
600
+
265
601
  ## Mouse, touch, and keyboard controls
266
602
 
267
603
  `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.
@@ -277,7 +613,7 @@ Auto-rotation is off by default. Render `AutoRotate` inside `PanoViewer` to enab
277
613
  }}
278
614
  style={{ height: 560 }}
279
615
  >
280
- <Sphere src="/panoramas/room.webp" />
616
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
281
617
  </PanoViewer>
282
618
  ```
283
619
 
@@ -346,7 +682,7 @@ export function KeyboardExample({ scenes }: { scenes: Scene[] }) {
346
682
 
347
683
  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
684
 
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.
685
+ 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.
350
686
 
351
687
  ## Panorama coordinate events
352
688
 
@@ -375,7 +711,7 @@ export function PlacementExample() {
375
711
  onPanoramaPointerMove={({ position }) => console.log("move", position)}
376
712
  style={{ height: 560 }}
377
713
  >
378
- <Sphere src="/panoramas/room.webp" />
714
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
379
715
  </PanoViewer>
380
716
  );
381
717
  }
@@ -416,7 +752,7 @@ import {
416
752
  onAutoRotateOneRound={() => console.log("full turn")}
417
753
  />
418
754
  <AutoRotate enabled />
419
- <Sphere src="/panoramas/room.webp" />
755
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
420
756
  </PanoViewer>;
421
757
  ```
422
758
 
@@ -429,6 +765,8 @@ Supported callbacks (krpano names in parentheses where applicable):
429
765
  - Fullscreen: `onEnterFullscreen` / `onExitFullscreen`
430
766
  - `onResize` — canvas content-box size via `ResizeObserver`
431
767
  - Auto-rotate: `onAutoRotateStart` / `onAutoRotateStop` / `onAutoRotateOneRound`
768
+ - Gyro: `onGyroAvailable` / `onGyroUnavailable` / `onGyroEnable` /
769
+ `onGyroDisable` / `onGyroDenied`
432
770
 
433
771
  `PanoViewer`'s existing `onViewChange` / `onPanoramaClick` / `onPanoramaDoubleClick` /
434
772
  `onPanoramaPointerMove` props remain supported and share the same event bus.
@@ -436,7 +774,7 @@ Supported callbacks (krpano names in parentheses where applicable):
436
774
  Resource loading and scene blending stay on their owners: use `Sphere` /
437
775
  `Tile` `onLoad` / `onError` / `onLoadProgress`, and `Scenes`
438
776
  `onTransitionEnd` / `onTransitionError`. This package does not mirror krpano
439
- xml/VR/gyro/frame-render events.
777
+ xml/VR/frame-render events.
440
778
 
441
779
  ## PanoContextMenu
442
780
 
@@ -447,7 +785,7 @@ separator between them.
447
785
 
448
786
  ```tsx
449
787
  <PanoViewer style={{ height: 560 }}>
450
- <Sphere src="/panoramas/room.webp" />
788
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
451
789
  </PanoViewer>
452
790
  ```
453
791
 
@@ -455,7 +793,7 @@ Disable the default menu (restore the browser menu, or mount your own):
455
793
 
456
794
  ```tsx
457
795
  <PanoViewer contextMenu={false}>
458
- <Sphere src="/panoramas/room.webp" />
796
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
459
797
  </PanoViewer>
460
798
  ```
461
799
 
@@ -463,7 +801,7 @@ Tune appearance while keeping the default items:
463
801
 
464
802
  ```tsx
465
803
  <PanoViewer contextMenu={{ appearance: { opacity: 0.92, borderRadius: 8 } }}>
466
- <Sphere src="/panoramas/room.webp" />
804
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
467
805
  </PanoViewer>
468
806
  ```
469
807
 
@@ -490,7 +828,7 @@ only add your own:
490
828
  }}
491
829
  style={{ height: 560 }}
492
830
  >
493
- <Sphere src="/panoramas/room.webp" />
831
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
494
832
  </PanoViewer>
495
833
  ```
496
834
 
@@ -524,7 +862,7 @@ reimplementing fullscreen enter/exit state:
524
862
  }}
525
863
  style={{ height: 560 }}
526
864
  >
527
- <Sphere src="/panoramas/room.webp" />
865
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
528
866
  </PanoViewer>
529
867
  ```
530
868
 
@@ -548,14 +886,25 @@ For a fully custom DOM menu, set `contextMenu={false}` and render
548
886
 
549
887
  ## Shared hotspot contract and saved definitions
550
888
 
551
- Every hotspot has an `id`, optional `visible`, `interactive`, `renderOrder`,
552
- and semantic interaction callbacks. Point hotspots additionally use a
553
- controlled `position`, angular `width`/`height`, hotspot mode, and the zoom
554
- options described below. `interactive={false}` keeps a hotspot visible while
555
- letting a drawing tool receive the panorama pointer events beneath it.
889
+ Every hotspot has an `id`, optional `visible`, `interactive`, `pointerEvents`,
890
+ `renderOrder`, and semantic interaction callbacks. Point hotspots additionally
891
+ use a controlled `position`, angular `width`/`height`, hotspot mode, and the
892
+ zoom options described below. `interactive={false}` keeps a hotspot visible
893
+ while letting a drawing tool receive the panorama pointer events beneath it.
894
+ `pointerEvents="none"` ignores mouse, touch, and pen so those events pass
895
+ through to the panorama even when the hotspot stays keyboard-interactive.
556
896
  Interactive hotspots set the canvas cursor to `pointer` while hovered, or to
557
897
  the hotspot's `cursor` prop when that is set.
558
898
 
899
+ ```tsx
900
+ <TextHotspot
901
+ id="caption"
902
+ pointerEvents="none"
903
+ position={{ yaw: 12, pitch: -8 }}
904
+ text="Decorative label"
905
+ />
906
+ ```
907
+
559
908
  `onClick` and `onHoverChange` receive a position and input source (`"pointer"`
560
909
  or `"keyboard"`). Point hotspots can use `draggable`, `onDragStart`,
561
910
  `onPositionChange`, and `onDragEnd`; the host must write the reported position
@@ -568,11 +917,18 @@ Every hotspot can show a DOM tooltip at its anchor. Pass a string or
568
917
  on screen. Clicking the hotspot does not toggle it. Optional
569
918
  `tooltipTrigger="hover"` shows the bubble on pointer hover or keyboard focus;
570
919
  `"click"` pins it until the hotspot is clicked again or the empty panorama is
571
- clicked. Hover and click triggers need `interactive`.
920
+ clicked. Hover and click triggers need `interactive` and `pointerEvents`
921
+ other than `"none"`.
572
922
 
573
923
  `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`).
924
+ These are screen directions: the bubble sits outside the hotspot's projected
925
+ screen bounds on that edge, so rotated graphics such as arrows are not covered.
926
+ `tooltipOffset` is the screen-space gap in CSS pixels (default `12` /
927
+ `DEFAULT_HOTSPOT_TOOLTIP_OFFSET`).
928
+
929
+ Optional `tooltipAppearance` overrides the bubble paint. Unset fields keep the
930
+ library default theme (`DEFAULT_HOTSPOT_TOOLTIP_APPEARANCE`): background, text
931
+ color, border, corner radius, shadow, padding, and font size.
576
932
 
577
933
  ```tsx
578
934
  <ImageHotspot
@@ -583,6 +939,15 @@ screen-space gap in CSS pixels (default `12` / `DEFAULT_HOTSPOT_TOOLTIP_OFFSET`)
583
939
  tooltip={{ text: "Courtyard gallery", image: "/hotspots/gallery-thumb.webp" }}
584
940
  tooltipPlacement="top"
585
941
  tooltipOffset={16}
942
+ tooltipAppearance={{
943
+ background: "rgba(12, 28, 36, 0.88)",
944
+ color: "#e7f2f5",
945
+ border: "1px solid rgba(56, 84, 91, 0.9)",
946
+ borderRadius: 10,
947
+ shadow: "0 10px 28px rgba(0, 0, 0, 0.42)",
948
+ padding: 10,
949
+ fontSize: 13,
950
+ }}
586
951
  />
587
952
  <GraphicHotspot
588
953
  id="marker"
@@ -628,7 +993,7 @@ are angular degrees, so they remain independent of the canvas resolution.
628
993
  import { ImageHotspot, PanoViewer, Sphere } from "@ericchen1990/pano-view";
629
994
 
630
995
  <PanoViewer style={{ height: 560 }}>
631
- <Sphere src="/panoramas/room.webp" />
996
+ <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
632
997
  <ImageHotspot
633
998
  id="gallery"
634
999
  ariaLabel="Open gallery"
@@ -765,6 +1130,8 @@ import { PolygonHotspot } from "@ericchen1990/pano-view";
765
1130
  stroke="#f5fbfc"
766
1131
  strokeWidth={2}
767
1132
  strokeOpacity={0.8}
1133
+ strokeDashSize={8}
1134
+ strokeGapSize={4}
768
1135
  draggable
769
1136
  onVerticesChange={({ vertices }) => setZoneVertices(vertices)}
770
1137
  onInvalid={(issues) => console.error(issues)}
@@ -772,9 +1139,11 @@ import { PolygonHotspot } from "@ericchen1990/pano-view";
772
1139
  ```
773
1140
 
774
1141
  `strokeWidth` is a screen-space CSS pixel value, so it remains visually
775
- consistent across FOV changes. Dragging moves all vertices together while
776
- keeping yaw normalized and pitch clamped. Individual vertex editing is provided
777
- by the authoring workflow, not the runtime hotspot component.
1142
+ consistent across FOV changes. `strokeDashSize` and `strokeGapSize` use the
1143
+ same CSS-pixel units; omit them or set `strokeDashSize` to `0` for a solid
1144
+ stroke. Dragging moves all vertices together while keeping yaw normalized and
1145
+ pitch clamped. Individual vertex editing is provided by the authoring
1146
+ workflow, not the runtime hotspot component.
778
1147
 
779
1148
  Set `fillOpacity={0}` for a closed outline-only polygon. The fill and outline
780
1149
  share the same spherical edge sampling, so there is no intentional seam
@@ -783,7 +1152,8 @@ between them.
783
1152
  ## PolylineHotspot
784
1153
 
785
1154
  `PolylineHotspot` is an open path of at least two yaw/pitch vertices. It uses
786
- the same CSS-pixel `strokeWidth`, visibility, semantic interaction, controlled
1155
+ the same CSS-pixel `strokeWidth`, optional dashed stroke (`strokeDashSize` /
1156
+ `strokeGapSize`), visibility, semantic interaction, controlled
787
1157
  whole-path drag, and vertex-change callback as `PolygonHotspot`, but it never
788
1158
  connects the final point to the first and has no fill.
789
1159
 
@@ -797,6 +1167,8 @@ import { PolylineHotspot } from "@ericchen1990/pano-view";
797
1167
  stroke="#75cbd3"
798
1168
  strokeWidth={2}
799
1169
  strokeOpacity={0.9}
1170
+ strokeDashSize={8}
1171
+ strokeGapSize={4}
800
1172
  draggable
801
1173
  onVerticesChange={({ vertices }) => setRouteVertices(vertices)}
802
1174
  />;
@@ -880,8 +1252,12 @@ changes, unmounts, and React StrictMode lifecycles deterministic.
880
1252
  `TextHotspot` rasterizes plain text onto a canvas texture. It does not accept
881
1253
  HTML or Markdown. `\n` is a hard line break; `whiteSpace="normal"` (default)
882
1254
  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`.
1255
+ the other point hotspots. Typography uses the same names as CSS
1256
+ (`fontFamily`, `fontSize`, `fontWeight`, `fontStyle`) but is painted onto the
1257
+ canvas, not applied as DOM style. `fontSize` is canvas pixels (default 96) and
1258
+ does not scale with panel height. `fontWeight` defaults to `600` and
1259
+ `fontStyle` to `"normal"`. `padding` and `borderRadius` remain fractions of
1260
+ the texture's shorter side, so they still follow `width` / `height`.
885
1261
 
886
1262
  ```tsx
887
1263
  import { TextHotspot } from "@ericchen1990/pano-view";
@@ -895,18 +1271,22 @@ import { TextHotspot } from "@ericchen1990/pano-view";
895
1271
  mode="billboard"
896
1272
  scaleMode="fixed"
897
1273
  text={"Courtyard overlook\nNorth terrace"}
1274
+ fontFamily="system-ui, sans-serif"
1275
+ fontSize={96}
1276
+ fontWeight={600}
1277
+ fontStyle="normal"
898
1278
  align="center"
899
1279
  verticalAlign="middle"
900
- fontSize={0.18}
901
1280
  color="#f8fafc"
902
1281
  background="#111827"
903
1282
  backgroundOpacity={0.72}
904
1283
  />;
905
1284
  ```
906
1285
 
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`.
1286
+ Load a custom `fontFamily` on the page before the hotspot paints. The hotspot
1287
+ waits on `document.fonts.load` when that API exists, then draws with whatever
1288
+ faces the document has. `onLoad(texture)` and `onError(error)` match
1289
+ `ImageHotspot`.
910
1290
 
911
1291
  ## IframeHotspot
912
1292
 
@@ -932,7 +1312,9 @@ import { IframeHotspot } from "@ericchen1990/pano-view";
932
1312
 
933
1313
  `pointerPolicy="hotspot"` (default) sets the iframe to `pointer-events: none`
934
1314
  so clicks and drags hit the WebGL plane. `"content"` makes the page
935
- interactive and blocks dragging through the iframe. `sandbox` defaults to
1315
+ interactive and blocks dragging through the iframe. A hotspot
1316
+ `pointerEvents="none"` also forces the iframe overlay not to capture pointer
1317
+ events. `sandbox` defaults to
936
1318
  `allow-scripts allow-popups allow-forms` without `allow-same-origin`.
937
1319
  `referrerPolicy` defaults to `strict-origin-when-cross-origin`. Prefer a
938
1320
  same-origin document; many third-party sites send `X-Frame-Options` and will