@ericchen1990/pano-view 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,7 +18,25 @@ compatible with that tile output format but is not affiliated with krpano.
18
18
  npm install @ericchen1990/pano-view
19
19
  ```
20
20
 
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.
21
+ React 19, React DOM 19, Three.js, `@react-three/fiber` 9,
22
+ `@react-three/drei` 10, and `@react-three/xr` 6 are peer dependencies. npm 7+
23
+ and pnpm install them automatically; they must be a single copy in the host
24
+ app.
25
+
26
+ Tailwind CSS v4 is now required for the built-in HTML chrome: video controls,
27
+ captions, context menus, tooltips, and accessibility helpers render with
28
+ Tailwind utility classes instead of a bundled stylesheet. Add a source entry in
29
+ your app so Tailwind can scan this package:
30
+
31
+ ```css
32
+ @import "tailwindcss";
33
+ @source "../node_modules/@ericchen1990/pano-view";
34
+ ```
35
+
36
+ This package does not ship a separate CSS file. If your app does not scan
37
+ `@ericchen1990/pano-view`, those built-in overlays will render without their
38
+ default styling. Customization still works through `className`, `style`, and
39
+ component appearance props.
22
40
 
23
41
  ## Exported components
24
42
 
@@ -26,8 +44,10 @@ React 19, React DOM 19, Three.js, `@react-three/fiber` 9, and `@react-three/drei
26
44
 
27
45
  - [`PanoViewer`](#panoviewer) — canvas shell, camera, default controls, and imperative view API
28
46
  - [`Sphere`](#sphere) — single 2:1 equirectangular image
47
+ - [`PanoVideo`](#panovideo) — 2:1 equirectangular video on the panorama sphere
29
48
  - [`Tile`](#tile) — krpano-style multires cube tiles
30
49
  - [`Scenes`](#scenes) — controlled multi-scene transitions
50
+ - [`PanoFilter`](#panofilter) — color and artistic filters on the panorama source
31
51
 
32
52
  ### Controls
33
53
 
@@ -35,11 +55,14 @@ React 19, React DOM 19, Three.js, `@react-three/fiber` 9, and `@react-three/drei
35
55
  - [`TouchControls`](#touchcontrols) — one-finger drag and pinch zoom
36
56
  - [`KeyboardControls`](#keyboardcontrols) — arrow-key look, FOV, and scene cycling
37
57
  - [`AutoRotate`](#autorotate) — automatic yaw rotation
58
+ - [`Gyro`](#gyro) — opt-in device-orientation control
59
+ - [`WebVR`](#webvr) — WebXR-first immersive VR with MobileVR fallback
38
60
 
39
61
  ### Events and chrome
40
62
 
41
63
  - [`PanoEvents`](#panoevents) — viewer-level lifecycle and interaction callbacks
42
64
  - [`PanoContextMenu`](#panocontextmenu) — right-click reset / fullscreen menu
65
+ - [`PanoVideoControls`](#panovideo) — playback chrome for `PanoVideo` (mounted by default)
43
66
 
44
67
  ### Hotspots
45
68
 
@@ -142,6 +165,79 @@ export function SphereExample() {
142
165
  }
143
166
  ```
144
167
 
168
+ ## PanoVideo
169
+
170
+ `PanoVideo` maps a 2:1 equirectangular video onto the same inward sphere as
171
+ `Sphere`. Pass one `src`, or `variants` with multiple files per quality so the
172
+ browser can pick mp4 / webm and the control bar can switch resolution. Changing
173
+ quality keeps the current time and play/pause state.
174
+
175
+ A default playback bar is mounted on the viewer overlay: play/pause, seek, time,
176
+ volume, speed, resolution, captions, and fullscreen. Volume is a hover (or tap)
177
+ vertical slider when the browser allows `HTMLMediaElement.volume`; iOS Safari
178
+ only exposes mute. The speed menu lists rates the current element accepts. The
179
+ captions menu also includes a settings panel for subtitle font size and
180
+ background opacity. When the viewer is narrow, speed, quality, and captions
181
+ move into a More menu.
182
+ Pass `controls={false}` to hide it, an appearance object to restyle the default
183
+ instance, or render `PanoVideoControls` as a `PanoViewer` child to replace that
184
+ instance.
185
+
186
+ Captions use WebVTT `tracks`. The video element is off-screen, so cue text is
187
+ rendered as a HUD overlay. `captions={false}` hides the overlay and the language
188
+ menu; an object merges with the default appearance (`color`, `background`,
189
+ `fontSize`, `fontFamily`, `textShadow`, `padding`, `borderRadius`, `maxWidth`,
190
+ `bottom`). Those values act as the initial caption appearance; viewers can still
191
+ adjust subtitle size and background from the default control-bar menu at
192
+ runtime.
193
+
194
+ ```tsx
195
+ "use client";
196
+
197
+ import { PanoViewer, PanoVideo } from "@ericchen1990/pano-view";
198
+
199
+ export function VideoExample() {
200
+ return (
201
+ <PanoViewer style={{ width: "100%", height: 560 }}>
202
+ <PanoVideo
203
+ defaultVariantId="1024"
204
+ variants={[
205
+ {
206
+ id: "1024",
207
+ label: "1024p",
208
+ poster: "/video/1024-poster.jpg",
209
+ sources: [
210
+ { src: "/video/1024.mp4", type: "video/mp4" },
211
+ { src: "/video/1024.webm", type: "video/webm" },
212
+ ],
213
+ },
214
+ {
215
+ id: "1920",
216
+ label: "1920p",
217
+ poster: "/video/1920-poster.jpg",
218
+ sources: [
219
+ { src: "/video/1920.mp4", type: "video/mp4" },
220
+ { src: "/video/1920.webm", type: "video/webm" },
221
+ ],
222
+ },
223
+ ]}
224
+ tracks={[
225
+ { src: "/video/en.vtt", srcLang: "en", label: "English", default: true },
226
+ { src: "/video/zh.vtt", srcLang: "zh", label: "中文" },
227
+ ]}
228
+ captions={{ fontSize: 16, color: "#fff" }}
229
+ muted
230
+ />
231
+ </PanoViewer>
232
+ );
233
+ }
234
+ ```
235
+
236
+ `muted` defaults to `true` so `autoPlay` has a chance under browser autoplay
237
+ rules. Unmuted playback still needs a user gesture; `onPlaybackStateChange`
238
+ receives `"blocked"` when `play()` is rejected. Remote videos and VTT files
239
+ that are not same-origin should set `crossOrigin` (usually `"anonymous"`).
240
+
145
241
  ## Tile
146
242
 
147
243
  `Tile` renders six inward-facing cube faces and loads only tiles around the current view. The default layout matches krpano-style output:
@@ -241,16 +337,64 @@ const scenes: Scene[] = [
241
337
  </PanoViewer>;
242
338
  ```
243
339
 
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.
340
+ Available KRpano-style presets are `none`, `crossfade`, `zoom`, `blackout`,
341
+ `whiteFlash`, `slideRightToLeft`, `slideTopToBottom`, `slideDiagonal`,
342
+ `circleOpen`, `verticalOpen`, `horizontalOpen`, and `ellipticZoomOpen`.
343
+ Grid presets are `pixelate`, `gridWipe`, `gridWipeUp`, `gridWipeRight`,
344
+ `gridWipeDiagonal`, `gridWipeCenter`, `gridWipeChecker`, and `hexDissolve`.
345
+ Cinematic presets are `dissolve`, `shatter`, `particles`, `glitch`, `swirl`,
346
+ `clockWipe`, `ripple`, `zoomBlur`, and `filmBurn`. All of them still blend a
347
+ GPU framebuffer snapshot over the incoming panorama; they do not mix two full
348
+ 360 textures. Pass `{ preset: "crossfade", duration: 0.6 }` to override a
349
+ preset duration.
248
350
 
249
351
  While a transition runs, panorama drag/zoom input is locked and
250
352
  `renderHotspots` is hidden. New `activeSceneId` values supersede a target that
251
353
  is still being prepared. `maxTextureMemoryMb` and `maxConcurrentTileLoads`
252
354
  apply to the whole `Scenes` viewer rather than to each tile scene.
253
355
 
356
+ ## PanoFilter
357
+
358
+ Render `PanoFilter` inside `PanoViewer` to color-grade or stylize the panorama
359
+ source (`Sphere`, `Tile`, and `PanoVideo`). The filter runs on those materials
360
+ only: 3D hotspots, tooltips, video chrome, and iframe overlays keep their
361
+ original color. WebXR / MobileVR inherit the same look because the panorama
362
+ shader itself is patched.
363
+
364
+ `intensity` mixes the filtered result with the original image (`0`–`1`,
365
+ default `1`). Omit the component, pass `preset="none"`, or set `enabled={false}`
366
+ to leave the source unfiltered.
367
+
368
+ ```tsx
369
+ <PanoViewer style={{ height: 560 }}>
370
+ <PanoFilter preset="pencil" intensity={0.85} />
371
+ <Sphere src="/panoramas/room.webp" />
372
+ <ImageHotspot
373
+ id="door"
374
+ position={{ yaw: 24, pitch: -6 }}
375
+ src="/hotspots/door.webp"
376
+ />
377
+ </PanoViewer>
378
+ ```
379
+
380
+ | Preset | Look |
381
+ |---|---|
382
+ | `none` | Identity (no filter) |
383
+ | `grayscale` | Rec.709 black and white |
384
+ | `sepia` | Warm brown antique print |
385
+ | `vintage` | Faded film with grain |
386
+ | `cool` / `warm` | Blue or amber temperature shift |
387
+ | `pencil` | Graphite sketch on paper |
388
+ | `coloredPencil` | Tinted pencil lines |
389
+ | `crayon` | Posterized wax strokes |
390
+ | `watercolor` | Soft pigment on paper |
391
+ | `cartoon` | Cel shading with outlines |
392
+ | `crosshatch` | Luminance hatching |
393
+
394
+ Built-in artistic looks use screen-space edges and world-space grain so cube
395
+ tiles do not show filter seams. Scene-transition snapshots capture the filtered
396
+ panorama when a filter is active.
397
+
254
398
  ## AutoRotate
255
399
 
256
400
  Auto-rotation is off by default. Render `AutoRotate` inside `PanoViewer` to enable it and keep rotation configuration separate from user-input controls. `speed` is measured in degrees per second; use a negative value to rotate left. `acceleration` is measured in degrees per second squared and smoothly ramps from zero to `speed` (default: `18`, so the default speed takes one second to reach). Set it to `0` for an immediate fixed speed. `startDelay` is measured in milliseconds from when `enabled` becomes true. While the user is dragging, or while drag inertia is still settling, rotation pauses and resumes from zero speed automatically.
@@ -262,6 +406,136 @@ Auto-rotation is off by default. Render `AutoRotate` inside `PanoViewer` to enab
262
406
  </PanoViewer>
263
407
  ```
264
408
 
409
+ ## Gyro
410
+
411
+ `Gyro` is an opt-in device-orientation control and is disabled by default.
412
+ Render it inside `PanoViewer` and control its `enabled` prop. Relative mode
413
+ (the default) treats the view at activation time as the starting direction.
414
+ Set `absolute` to follow compass-oriented headings and use `north` to identify
415
+ the panorama yaw that points North.
416
+
417
+ ```tsx
418
+ import { useRef, useState } from "react";
419
+ import {
420
+ Gyro,
421
+ PanoViewer,
422
+ Sphere,
423
+ type GyroHandle,
424
+ } from "@ericchen1990/pano-view";
425
+
426
+ export function GyroExample() {
427
+ const gyroRef = useRef<GyroHandle>(null);
428
+ const [enabled, setEnabled] = useState(false);
429
+
430
+ const toggle = async () => {
431
+ if (enabled) {
432
+ setEnabled(false);
433
+ return;
434
+ }
435
+ // iOS requires this call to run directly inside a user gesture.
436
+ setEnabled((await gyroRef.current?.requestPermission()) ?? false);
437
+ };
438
+
439
+ return (
440
+ <>
441
+ <button onClick={() => void toggle()}>
442
+ {enabled ? "Disable gyro" : "Enable gyro"}
443
+ </button>
444
+ <PanoViewer style={{ height: 560 }}>
445
+ <Gyro
446
+ ref={gyroRef}
447
+ enabled={enabled}
448
+ camroll
449
+ friction={0}
450
+ softstart={0.5}
451
+ touchMode="full"
452
+ onDenied={() => setEnabled(false)}
453
+ />
454
+ <Sphere src="/panoramas/room.webp" />
455
+ </PanoViewer>
456
+ </>
457
+ );
458
+ }
459
+ ```
460
+
461
+ `camroll` (default `true`) levels the camera from device roll. `friction`
462
+ accepts `0` to `0.99`; higher values smooth more strongly but add latency.
463
+ `softstart` is the activation blend duration in seconds (default `0.5`).
464
+ `desktopSupport` defaults to `false` because desktop browsers can expose the
465
+ API without a physical sensor.
466
+
467
+ `touchMode` controls how touch drag combines with device movement:
468
+
469
+ - `off` ignores touch yaw/pitch while leaving pinch FOV available.
470
+ - `horizontaloffset` adds only a horizontal offset.
471
+ - `full` adds horizontal and vertical offsets; the vertical offset returns
472
+ toward the physical orientation as the device moves.
473
+ - `disablegyro` disables the active gyro session on the next touch.
474
+
475
+ The `GyroHandle` exposes `resetSensor(yaw?, pitch?)`, `isAvailable()`,
476
+ `isEnabled()`, and `requestPermission()`. Gyro activity pauses `AutoRotate`
477
+ and counts as viewer interaction for idle tracking. Sensor APIs generally
478
+ require HTTPS. A cross-origin iframe must be permitted by the host page, for
479
+ example with `allow="gyroscope; accelerometer"` and a compatible
480
+ `Permissions-Policy` header.
481
+
482
+ ## WebVR
483
+
484
+ `WebVR` adds immersive viewing to the nearest `PanoViewer`. It prefers an
485
+ `immersive-vr` WebXR session on supported headsets. On phones without WebXR it
486
+ uses a stereoscopic split-screen view with device orientation, and on desktop
487
+ it can provide a mouse-driven simulated VR preview. The built-in chrome includes
488
+ Enter VR, Exit VR, and MobileVR setup controls.
489
+
490
+ ```tsx
491
+ import { useRef } from "react";
492
+ import {
493
+ PanoViewer,
494
+ Sphere,
495
+ WebVR,
496
+ type WebVRHandle,
497
+ } from "@ericchen1990/pano-view";
498
+
499
+ export function WebVRExample() {
500
+ const webVRRef = useRef<WebVRHandle>(null);
501
+
502
+ return (
503
+ <PanoViewer style={{ height: 560 }}>
504
+ <WebVR
505
+ ref={webVRRef}
506
+ chrome
507
+ fakeSupport
508
+ mobileVr
509
+ onEnterVR={(mode) => console.log("Entered", mode)}
510
+ />
511
+ <Sphere src="/panoramas/room.webp" />
512
+ </PanoViewer>
513
+ );
514
+ }
515
+ ```
516
+
517
+ The handle exposes `enterVR()`, `exitVR()`, `toggleVR()`, `isAvailable()`,
518
+ `isEnabled()`, `getMode()`, and `requestPermission()`. Modes are `"webxr"`,
519
+ `"mobilevr"`, and `"fake"`. `profile` accepts `"none"`, `"cardboard-v1"`,
520
+ `"cardboard-v2"`, `"gear-vr"`, `"daydream"`, or a custom
521
+ `{ fov, ipdMm, k1, k2 }` lens profile. MobileVR setup values are versioned and
522
+ stored in the current origin's `localStorage`. In VR, a center reticle shows a
523
+ crosshair; looking at an interactive hotspot draws a clockwise ring that fills
524
+ over `cursorDwellMs` (default 1500), then the hotspot click handler runs. Set
525
+ `cursorDwellMs={0}` to keep the reticle without auto-click. Simulated desktop
526
+ VR (`fake` mode) requests pointer lock by default (`mousePointerLock`) so the
527
+ cursor hides and mouse movement looks around; click the view again if the
528
+ browser releases the lock. Fallback sessions keep the screen awake
529
+ (`wakelock`) with the Screen Wake Lock API, or a looping hidden video on
530
+ browsers that lack it.
531
+
532
+ WebXR requires HTTPS and a user gesture. Mobile sensor access also requires
533
+ HTTPS and may require an explicit iOS permission prompt. Embedded viewers need
534
+ appropriate `xr-spatial-tracking`, `gyroscope`, and `accelerometer` permissions.
535
+ DOM overlays such as iframe hotspots, tooltips, and video controls are not
536
+ visible inside an immersive headset session; mesh-based hotspots remain
537
+ interactive through the default WebXR controller rays.
538
+
265
539
  ## Mouse, touch, and keyboard controls
266
540
 
267
541
  `PanoViewer` enables `MouseControls`, `TouchControls`, and `KeyboardControls` by default. Configure shared options through `controls`; render a control component only to override that channel's properties. A child instance replaces the default — you do not need to set the channel to `false` first. Set `controls.mouse` / `touch` / `keyboard` to `false` to turn a channel off.
@@ -346,7 +620,7 @@ export function KeyboardExample({ scenes }: { scenes: Scene[] }) {
346
620
 
347
621
  Hold movement and zoom keys for continuous motion in degrees per second (`rotateSpeed` / `zoomSpeed`). Hold Shift to multiply those rates (`shiftMultiplier`, default `3`). Scene and reset bindings fire once per press. The canvas must be focused to receive keys (click the viewer first).
348
622
 
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.
623
+ Default bindings: arrows and WASD for look, `+/-` for FOV, `0` for reset, plus `[`/`PageUp` and `]`/`PageDown` for previous/next scene when callbacks are provided. The same child-override pattern works with `MouseControls` and `TouchControls`. Set `controls.mouse={false}` / `controls.touch={false}` / `controls.keyboard={false}` only when you want that channel off.
350
624
 
351
625
  ## Panorama coordinate events
352
626
 
@@ -429,6 +703,8 @@ Supported callbacks (krpano names in parentheses where applicable):
429
703
  - Fullscreen: `onEnterFullscreen` / `onExitFullscreen`
430
704
  - `onResize` — canvas content-box size via `ResizeObserver`
431
705
  - Auto-rotate: `onAutoRotateStart` / `onAutoRotateStop` / `onAutoRotateOneRound`
706
+ - Gyro: `onGyroAvailable` / `onGyroUnavailable` / `onGyroEnable` /
707
+ `onGyroDisable` / `onGyroDenied`
432
708
 
433
709
  `PanoViewer`'s existing `onViewChange` / `onPanoramaClick` / `onPanoramaDoubleClick` /
434
710
  `onPanoramaPointerMove` props remain supported and share the same event bus.
@@ -436,7 +712,7 @@ Supported callbacks (krpano names in parentheses where applicable):
436
712
  Resource loading and scene blending stay on their owners: use `Sphere` /
437
713
  `Tile` `onLoad` / `onError` / `onLoadProgress`, and `Scenes`
438
714
  `onTransitionEnd` / `onTransitionError`. This package does not mirror krpano
439
- xml/VR/gyro/frame-render events.
715
+ xml/VR/frame-render events.
440
716
 
441
717
  ## PanoContextMenu
442
718
 
@@ -548,14 +824,25 @@ For a fully custom DOM menu, set `contextMenu={false}` and render
548
824
 
549
825
  ## Shared hotspot contract and saved definitions
550
826
 
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.
827
+ Every hotspot has an `id`, optional `visible`, `interactive`, `pointerEvents`,
828
+ `renderOrder`, and semantic interaction callbacks. Point hotspots additionally
829
+ use a controlled `position`, angular `width`/`height`, hotspot mode, and the
830
+ zoom options described below. `interactive={false}` keeps a hotspot visible
831
+ while letting a drawing tool receive the panorama pointer events beneath it.
832
+ `pointerEvents="none"` ignores mouse, touch, and pen so those events pass
833
+ through to the panorama even when the hotspot stays keyboard-interactive.
556
834
  Interactive hotspots set the canvas cursor to `pointer` while hovered, or to
557
835
  the hotspot's `cursor` prop when that is set.
558
836
 
837
+ ```tsx
838
+ <TextHotspot
839
+ id="caption"
840
+ pointerEvents="none"
841
+ position={{ yaw: 12, pitch: -8 }}
842
+ text="Decorative label"
843
+ />
844
+ ```
845
+
559
846
  `onClick` and `onHoverChange` receive a position and input source (`"pointer"`
560
847
  or `"keyboard"`). Point hotspots can use `draggable`, `onDragStart`,
561
848
  `onPositionChange`, and `onDragEnd`; the host must write the reported position
@@ -568,11 +855,18 @@ Every hotspot can show a DOM tooltip at its anchor. Pass a string or
568
855
  on screen. Clicking the hotspot does not toggle it. Optional
569
856
  `tooltipTrigger="hover"` shows the bubble on pointer hover or keyboard focus;
570
857
  `"click"` pins it until the hotspot is clicked again or the empty panorama is
571
- clicked. Hover and click triggers need `interactive`.
858
+ clicked. Hover and click triggers need `interactive` and `pointerEvents`
859
+ other than `"none"`.
572
860
 
573
861
  `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`).
862
+ These are screen directions: the bubble sits outside the hotspot's projected
863
+ screen bounds on that edge, so rotated graphics such as arrows are not covered.
864
+ `tooltipOffset` is the screen-space gap in CSS pixels (default `12` /
865
+ `DEFAULT_HOTSPOT_TOOLTIP_OFFSET`).
866
+
867
+ Optional `tooltipAppearance` overrides the bubble paint. Unset fields keep the
868
+ library default theme (`DEFAULT_HOTSPOT_TOOLTIP_APPEARANCE`): background, text
869
+ color, border, corner radius, shadow, padding, and font size.
576
870
 
577
871
  ```tsx
578
872
  <ImageHotspot
@@ -583,6 +877,15 @@ screen-space gap in CSS pixels (default `12` / `DEFAULT_HOTSPOT_TOOLTIP_OFFSET`)
583
877
  tooltip={{ text: "Courtyard gallery", image: "/hotspots/gallery-thumb.webp" }}
584
878
  tooltipPlacement="top"
585
879
  tooltipOffset={16}
880
+ tooltipAppearance={{
881
+ background: "rgba(12, 28, 36, 0.88)",
882
+ color: "#e7f2f5",
883
+ border: "1px solid rgba(56, 84, 91, 0.9)",
884
+ borderRadius: 10,
885
+ shadow: "0 10px 28px rgba(0, 0, 0, 0.42)",
886
+ padding: 10,
887
+ fontSize: 13,
888
+ }}
586
889
  />
587
890
  <GraphicHotspot
588
891
  id="marker"
@@ -765,6 +1068,8 @@ import { PolygonHotspot } from "@ericchen1990/pano-view";
765
1068
  stroke="#f5fbfc"
766
1069
  strokeWidth={2}
767
1070
  strokeOpacity={0.8}
1071
+ strokeDashSize={8}
1072
+ strokeGapSize={4}
768
1073
  draggable
769
1074
  onVerticesChange={({ vertices }) => setZoneVertices(vertices)}
770
1075
  onInvalid={(issues) => console.error(issues)}
@@ -772,9 +1077,11 @@ import { PolygonHotspot } from "@ericchen1990/pano-view";
772
1077
  ```
773
1078
 
774
1079
  `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.
1080
+ consistent across FOV changes. `strokeDashSize` and `strokeGapSize` use the
1081
+ same CSS-pixel units; omit them or set `strokeDashSize` to `0` for a solid
1082
+ stroke. Dragging moves all vertices together while keeping yaw normalized and
1083
+ pitch clamped. Individual vertex editing is provided by the authoring
1084
+ workflow, not the runtime hotspot component.
778
1085
 
779
1086
  Set `fillOpacity={0}` for a closed outline-only polygon. The fill and outline
780
1087
  share the same spherical edge sampling, so there is no intentional seam
@@ -783,7 +1090,8 @@ between them.
783
1090
  ## PolylineHotspot
784
1091
 
785
1092
  `PolylineHotspot` is an open path of at least two yaw/pitch vertices. It uses
786
- the same CSS-pixel `strokeWidth`, visibility, semantic interaction, controlled
1093
+ the same CSS-pixel `strokeWidth`, optional dashed stroke (`strokeDashSize` /
1094
+ `strokeGapSize`), visibility, semantic interaction, controlled
787
1095
  whole-path drag, and vertex-change callback as `PolygonHotspot`, but it never
788
1096
  connects the final point to the first and has no fill.
789
1097
 
@@ -797,6 +1105,8 @@ import { PolylineHotspot } from "@ericchen1990/pano-view";
797
1105
  stroke="#75cbd3"
798
1106
  strokeWidth={2}
799
1107
  strokeOpacity={0.9}
1108
+ strokeDashSize={8}
1109
+ strokeGapSize={4}
800
1110
  draggable
801
1111
  onVerticesChange={({ vertices }) => setRouteVertices(vertices)}
802
1112
  />;
@@ -880,8 +1190,12 @@ changes, unmounts, and React StrictMode lifecycles deterministic.
880
1190
  `TextHotspot` rasterizes plain text onto a canvas texture. It does not accept
881
1191
  HTML or Markdown. `\n` is a hard line break; `whiteSpace="normal"` (default)
882
1192
  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`.
1193
+ the other point hotspots. Typography uses the same names as CSS
1194
+ (`fontFamily`, `fontSize`, `fontWeight`, `fontStyle`) but is painted onto the
1195
+ canvas, not applied as DOM style. `fontSize` is canvas pixels (default 96) and
1196
+ does not scale with panel height. `fontWeight` defaults to `600` and
1197
+ `fontStyle` to `"normal"`. `padding` and `borderRadius` remain fractions of
1198
+ the texture's shorter side, so they still follow `width` / `height`.
885
1199
 
886
1200
  ```tsx
887
1201
  import { TextHotspot } from "@ericchen1990/pano-view";
@@ -895,18 +1209,22 @@ import { TextHotspot } from "@ericchen1990/pano-view";
895
1209
  mode="billboard"
896
1210
  scaleMode="fixed"
897
1211
  text={"Courtyard overlook\nNorth terrace"}
1212
+ fontFamily="system-ui, sans-serif"
1213
+ fontSize={96}
1214
+ fontWeight={600}
1215
+ fontStyle="normal"
898
1216
  align="center"
899
1217
  verticalAlign="middle"
900
- fontSize={0.18}
901
1218
  color="#f8fafc"
902
1219
  background="#111827"
903
1220
  backgroundOpacity={0.72}
904
1221
  />;
905
1222
  ```
906
1223
 
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`.
1224
+ Load a custom `fontFamily` on the page before the hotspot paints. The hotspot
1225
+ waits on `document.fonts.load` when that API exists, then draws with whatever
1226
+ faces the document has. `onLoad(texture)` and `onError(error)` match
1227
+ `ImageHotspot`.
910
1228
 
911
1229
  ## IframeHotspot
912
1230
 
@@ -932,7 +1250,9 @@ import { IframeHotspot } from "@ericchen1990/pano-view";
932
1250
 
933
1251
  `pointerPolicy="hotspot"` (default) sets the iframe to `pointer-events: none`
934
1252
  so clicks and drags hit the WebGL plane. `"content"` makes the page
935
- interactive and blocks dragging through the iframe. `sandbox` defaults to
1253
+ interactive and blocks dragging through the iframe. A hotspot
1254
+ `pointerEvents="none"` also forces the iframe overlay not to capture pointer
1255
+ events. `sandbox` defaults to
936
1256
  `allow-scripts allow-popups allow-forms` without `allow-same-origin`.
937
1257
  `referrerPolicy` defaults to `strict-origin-when-cross-origin`. Prefer a
938
1258
  same-origin document; many third-party sites send `X-Frame-Options` and will