@ericchen1990/pano-view 3.8.0 → 3.10.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
@@ -11,6 +11,7 @@ Try the components in the [live playground](https://pano-view-playground.vercel.
11
11
  - [pano-converter](#pano-converter)
12
12
  - [Data sources and choosing an approach](#data-sources-and-choosing-an-approach)
13
13
  - [Static projects and JSON data](#static-projects-and-json-data)
14
+ - [Floor plan maps](#floor-plan-maps)
14
15
  - [FAQ and troubleshooting](#faq-and-troubleshooting)
15
16
  - [krpano-compatible tile output](#krpano-compatible-tile-output)
16
17
  - [Install](#install)
@@ -22,6 +23,7 @@ Try the components in the [live playground](https://pano-view-playground.vercel.
22
23
  - [PanoVideo](#panovideo)
23
24
  - [Tile](#tile)
24
25
  - [Scenes](#scenes)
26
+ - [PanoSceneMenu](#panoscenemenu)
25
27
  - [PanoFilter](#panofilter)
26
28
  - [AutoRotate](#autorotate)
27
29
  - [BackgroundAudio](#backgroundaudio)
@@ -199,7 +201,9 @@ export function Tour() {
199
201
 
200
202
  `ProjectScenes` belongs inside `PanoViewer` and reuses all nine existing hotspot types. A `scene` action navigates on click. Without an action, audio/video/sequence hotspots toggle playback. Media unmounts on leaving a scene and starts from its JSON settings on re-entry; browser autoplay restrictions still apply.
201
203
 
202
- Configured `initialView` values apply at `onSceneReady`, defaulting missing fields to yaw 0, pitch 0, fov 75. With no view configured, the current view is retained. Hotspots hide during transitions. Control selection with `activeSceneId` / `onActiveSceneIdChange`. Call `event.preventDefault()` in `onHotspotClick` to cancel the default action. `onHotspotError` receives the scene, hotspot and original error (media errors may be events containing an `error` field). `renderHotspot(hotspot, context)` returns `undefined` for the default renderer or `null` to hide an item. Context includes `sceneId`, `playing`, `onClick`, `togglePlayback`, `reportError` and `renderDefault`.
204
+ Configured `initialView` values apply at `onSceneReady`, defaulting missing fields to yaw 0, pitch 0, fov 75. With no view configured, the current view is retained. Hotspots hide during transitions. Control selection with `activeSceneId` / `onActiveSceneIdChange`. When uncontrolled, internal state starts at `project.initialSceneId`; a new `project` reference resets that selection. Call `event.preventDefault()` in `onHotspotClick` to cancel the default action. `onHotspotError` receives `sceneId`, the hotspot, and the original error (media errors may be events containing an `error` field). `renderHotspot(hotspot, context)` returns `undefined` for the default renderer or `null` to hide an item. Context includes `sceneId`, `playing`, `onClick`, `onHoverChange`, `togglePlayback`, `reportError`, and `renderDefault`.
205
+
206
+ Beyond project-specific props, `ProjectScenes` accepts the `Scenes` transition and cache props: `transition`, `onSceneReady`, `transitionWaitForDetailMs`, `onTransitionEnd`, `onTransitionError`, `maxTextureMemoryMb`, `maxConcurrentTileLoads`, and `snapshotMaxPixels` (default `3686400`). It does not accept `scenes`, `defaultActiveSceneId`, or a host `renderHotspots` — the scene list comes from `project`, and hotspots are rendered by the component.
203
207
 
204
208
  ### Hotspot actions and shared popup content
205
209
 
@@ -226,24 +230,188 @@ Store this content structure in `project.json`; store media as separate files. P
226
230
 
227
231
  ```tsx
228
232
  import { useState } from "react";
229
- import { PanoViewer, ProjectScenes, type LoadedPanoProject, type ProjectPopupDefinition } from "@ericchen1990/pano-view";
233
+ import { PanoOverlay, PanoViewer, ProjectScenes, type LoadedPanoProject, type ProjectPopupDefinition } from "@ericchen1990/pano-view";
230
234
  import { ProjectPopupDialog } from "@ericchen1990/pano-view/ui";
231
235
  import "@ericchen1990/pano-view/ui/styles.css";
232
236
 
233
237
  export function Tour({ project }: { project: LoadedPanoProject }) {
234
238
  const [popup, setPopup] = useState<ProjectPopupDefinition | null>(null);
235
- return <PanoViewer renderOverlay={() => (
236
- <ProjectPopupDialog popup={popup} onOpenChange={(open) => { if (!open) setPopup(null); }} />
237
- )}>
239
+ return <PanoViewer>
238
240
  <ProjectScenes project={project} onPopupRequest={({ popup }) => setPopup(popup)}
239
241
  onActiveSceneIdChange={() => setPopup(null)} />
242
+ <PanoOverlay>
243
+ <ProjectPopupDialog popup={popup} onOpenChange={(open) => { if (!open) setPopup(null); }} />
244
+ </PanoOverlay>
240
245
  </PanoViewer>;
241
246
  }
242
247
  ```
243
248
 
244
249
  `ProjectPopupDialog` accepts `popup` (or `null` to close), `onOpenChange`, optional `closeLabel` and `onMediaError(block)`. It renders text as text, uses native audio/video controls without autoplay, and releases media when content closes or changes. It inherits `PanoDialog` fullscreen, sizing and focus behavior. Replace this component with your own overlay to customize rendering.
245
250
 
246
- The schema remains version 1 and old projects remain valid. Projects using new actions require a viewer that supports them. `parsePanoProject(input, { draft: true })` is for authoring only: it permits empty scene lists and unfinished popup titles/media. Loading, saving and package export validate strictly. Host applications may save the same document in a database or map it to tables; the viewer has no database connection.
251
+ ### Floor plan maps
252
+
253
+ An optional project-level `map` places scene spots on one floor image. Projects without `map` stay valid, and `schemaVersion` remains 1. `loadPanoProject` returns the map with the floor image and spot icons resolved from the project directory. `parsePanoMap` and `resolvePanoMapAssets` are also exported from `/data` for custom loaders.
254
+
255
+ ```json
256
+ "map": {
257
+ "image": "assets/map/floor.webp",
258
+ "interaction": "zoom",
259
+ "maxScale": 4,
260
+ "iconSize": 28,
261
+ "presentation": {
262
+ "mode": "docked",
263
+ "corner": "bottom-left",
264
+ "width": 240,
265
+ "height": 180,
266
+ "offsetX": 16,
267
+ "offsetY": 16
268
+ },
269
+ "controls": { "collapse": true, "zoom": true },
270
+ "sector": {
271
+ "angle": 70,
272
+ "radius": 56,
273
+ "followFov": false,
274
+ "color": "#38bdf8",
275
+ "opacity": 0.55,
276
+ "strokeColor": "#ffffff",
277
+ "strokeWidth": 1.5,
278
+ "strokeOpacity": 0.9
279
+ },
280
+ "spots": [{
281
+ "id": "entrance-spot",
282
+ "sceneId": "entrance",
283
+ "x": 0.31,
284
+ "y": 0.5,
285
+ "heading": 20,
286
+ "icon": "assets/map/pin.webp"
287
+ }]
288
+ }
289
+ ```
290
+
291
+ `image` is a project-relative path or an absolute URL. `spots` is required. Spot IDs are unique, and each `sceneId` must name a scene in the project. `x` and `y` are normalized image coordinates in [0, 1], with the origin at the top-left. `heading` is clockwise degrees from image-up when viewer yaw is 0; the drawn sector uses `heading + yaw`. Several spots may share a scene. The sector is drawn on the first spot whose `sceneId` matches the active scene. `icon` is optional; a spot without one uses the built-in marker.
292
+
293
+ `interaction` is `"fixed"` (the default) or `"zoom"`. Fixed mode fits the image in the panel and ignores the wheel, drag-pan, and zoom buttons. Zoom mode allows wheel zoom, and drag-pan once the scale is above the fitted size. Wheel events on the map do not scroll the page. `maxScale` is the maximum multiple of that fitted size, at least 1, default 8. `dragMotion` controls pan inertia and edge bounce while zoomed (both on by default). Set `false` to disable both, or `{ inertia?: boolean; bounce?: boolean }` to toggle either flag. Spot and heading edits stay direct. `controls.zoom` shows the zoom buttons only when interaction is `"zoom"`. `controls.collapse` shows expand and collapse on a docked panel. `iconSize` is the marker edge in CSS pixels, default 28. Icons and the sector keep that screen size while the floor image zooms.
294
+
295
+ `sector` is optional. `angle` is the opening in degrees, greater than 0 and less than 360. `radius` is CSS pixels and does not scale with the image. `followFov` replaces `angle` with the horizontal field of view derived from the viewer FOV and the map panel's aspect ratio. Without `gradient`, the fill is solid `color` (default `#ffffff`) at `opacity` (default 0.55). `gradient.type` is `"radial"` or `"linear"`, with at least two stops. Each stop has `offset` in [0, 1], `color`, and an optional `opacity`. Stop opacities describe only the fade shape. `sector.opacity` scales the whole fill in both solid and gradient mode. `strokeColor`, `strokeWidth`, and `strokeOpacity` draw an outline when the width is greater than 0. The outline width stays in CSS pixels.
296
+
297
+ `presentation.mode` is `"docked"` (the default) or `"dialog"`. Both accept `corner` (`top-left`, `top-right`, `bottom-left`, or `bottom-right`; default `bottom-left`), `offsetX` and `offsetY` (default 16), plus `width` and `height`. A docked panel defaults to 220×160. Dialog mode docks a launcher (48×48 when width and height are omitted) and opens the same map in a viewer-local dialog. Choosing a spot closes that dialog. The chrome is positioned on the full viewer overlay, so scrolling a parent page does not move or resize it.
298
+
299
+ The document stores saved defaults (`image`, `spots`, `sector`, `presentation`, `interaction`, `maxScale`, `dragMotion`, `controls`, `iconSize`). Mount-time props on `PanoMapOverlay` override presentation and zoom for that render without cloning the map. Priority is built-in defaults → map document → `presentationOverride` (same shape as `map.presentation`) → individual props. Spot geometry and sector styling stay in the document. `selectedSpotId` / `onSelectedSpotIdChange` are forwarded to the inner `PanoMap`. Docked collapse state is owned by the overlay; dialog mode and `editorSurface` force collapse controls off, so `collapsed` cannot be driven from outside.
300
+
301
+ #### Mounting `PanoMapOverlay`
302
+
303
+ Mount it inside `PanoOverlay` and pass the same `activeSceneId` you pass to `ProjectScenes`. Import `@ericchen1990/pano-view/styles.css` for the built-in `pano-map-*` classes. The overlay reads yaw and FOV from the viewer. It does not change scenes by itself: set the active scene from `onSelectScene`. Pass `view` when the camera state comes from outside the viewer; outside the viewer with no `view`, the surface stays at yaw `0`, pitch `0`, fov `75`. `PanoMap` is the surface alone, for a host-owned frame.
304
+
305
+ `PanoMap` also accepts controlled selection and collapse: `selectedSpotId` / `onSelectedSpotIdChange` (internal selection defaults to `null`); `collapsed` / `onCollapsedChange` (uncontrolled starts expanded); `renderChrome` to draw host controls above the surface. `showZoomControls` / `showCollapseControl` work on `PanoMap` the same way as on the overlay. `className` / `style` attach to the surface root.
306
+
307
+ ```tsx
308
+ import { useState } from "react";
309
+ import {
310
+ PanoOverlay, PanoViewer, ProjectScenes, PanoMapOverlay, type LoadedPanoProject,
311
+ } from "@ericchen1990/pano-view";
312
+ import "@ericchen1990/pano-view/styles.css";
313
+
314
+ export function Tour({ project }: { project: LoadedPanoProject }) {
315
+ const [activeSceneId, setActiveSceneId] = useState(project.initialSceneId);
316
+ const map = project.map;
317
+ return <PanoViewer style={{ height: 600 }}>
318
+ <ProjectScenes
319
+ project={project}
320
+ activeSceneId={activeSceneId}
321
+ onActiveSceneIdChange={setActiveSceneId}
322
+ />
323
+ {map ? (
324
+ <PanoOverlay>
325
+ <PanoMapOverlay
326
+ map={map}
327
+ activeSceneId={activeSceneId}
328
+ onSelectScene={setActiveSceneId}
329
+ />
330
+ </PanoOverlay>
331
+ ) : null}
332
+ </PanoViewer>;
333
+ }
334
+ ```
335
+
336
+ #### Size, corner, and inset
337
+
338
+ - `presentation` — `"docked"` | `"dialog"` (defaults from the document, otherwise `"docked"`).
339
+ - `corner` — `"top-left"` | `"top-right"` | `"bottom-left"` | `"bottom-right"` (default `"bottom-left"`).
340
+ - `width` / `height` — docked panel size, or the dialog launcher size, in CSS pixels. Docked defaults to 220×160; the dialog launcher defaults to 48×48.
341
+ - `offsetX` / `offsetY` — inset from the chosen corner in CSS pixels (default 16).
342
+
343
+ ```tsx
344
+ <PanoMapOverlay
345
+ map={map}
346
+ activeSceneId={activeSceneId}
347
+ onSelectScene={setActiveSceneId}
348
+ presentation="docked"
349
+ corner="bottom-right"
350
+ width={280}
351
+ height={200}
352
+ offsetX={20}
353
+ offsetY={20}
354
+ />
355
+ ```
356
+
357
+ #### Dialog mode
358
+
359
+ Dialog mode docks a launcher button and opens the map in a viewer-local modal. Launcher size uses `width` / `height` (and `corner` / offsets). The dialog body uses a separate pair:
360
+
361
+ - `dialogWidth` / `dialogHeight` — preferred dialog size in CSS pixels (default 720×520). Both are clamped to the viewer with a 16 px margin on each side.
362
+ - `openLabel` / `closeLabel` / `title` — launcher accessible name, close button label, and dialog heading (defaults `"Map"`, `"Close"`, `"Map"`).
363
+
364
+ Choosing a spot closes the dialog.
365
+
366
+ ```tsx
367
+ <PanoMapOverlay
368
+ map={map}
369
+ activeSceneId={activeSceneId}
370
+ onSelectScene={setActiveSceneId}
371
+ presentation="dialog"
372
+ corner="bottom-left"
373
+ width={52}
374
+ height={52}
375
+ dialogWidth={640}
376
+ dialogHeight={480}
377
+ openLabel="Floor plan"
378
+ title="Floor plan"
379
+ closeLabel="Close"
380
+ />
381
+ ```
382
+
383
+ #### Zoom and controls
384
+
385
+ These props override the map document for the current mount:
386
+
387
+ - `interaction` — `"fixed"` | `"zoom"`.
388
+ - `maxScale` — maximum multiple of the contain-fit size; values below 1 are treated as 1.
389
+ - `dragMotion` — pan inertia / edge bounce when `interaction` is `"zoom"`. Omit for both on; `false` disables both.
390
+ - `initialScale` — opening zoom relative to the contain-fit size, clamped to `[1, maxScale]`. View state only; not written back to the document. Applies after the image is measured and recenters. Resets only when `initialScale` or `interaction` changes; later wheel and drag keep the user's view. Shrinking `maxScale` only clamps the current scale.
391
+ - `showZoomControls` / `showCollapseControl` — force zoom or collapse chrome on or off.
392
+ - `iconSize` — marker edge in CSS pixels.
393
+
394
+ ```tsx
395
+ <PanoMapOverlay
396
+ map={map}
397
+ activeSceneId={activeSceneId}
398
+ onSelectScene={setActiveSceneId}
399
+ interaction="zoom"
400
+ maxScale={4}
401
+ initialScale={1.5}
402
+ showZoomControls
403
+ showCollapseControl
404
+ iconSize={32}
405
+ />
406
+ ```
407
+
408
+ #### Edit mode
409
+
410
+ `mode="edit"` is the authoring surface: with `placementSceneId` set, a click adds a spot whose heading is the negation of the current yaw, so the sector points image-up at that yaw; dragging a spot moves it, and dragging the sector sets `heading`. `onEditChange` reports `{ spots?, selectedSpotId? }`. Visitor playback stays on the default `mode="view"`. `editorSurface` enlarges the panel for authoring; pass `width` / `height` to size that surface, otherwise it scales with the viewer.
411
+
412
+ Run `pnpm dev` and open `/map` for the playground. Authoring the same document is described in the [editor floor-plan section](../editor/README.md#floor-plan).
413
+
414
+ The schema remains version 1 and old projects remain valid. Projects using new actions or a floor-plan map require a viewer that supports them. `parsePanoProject(input, { draft: true })` is for authoring only: it permits empty scene lists, unfinished popup titles/media, and an empty floor-plan image. Loading, saving and package export validate strictly. Host applications may save the same document in a database or map it to tables; the viewer has no database connection.
247
415
 
248
416
  For individual panoramas and existing configs use the pure data entry. Next.js viewer components need a client boundary; server-side data loading uses `/data`:
249
417
 
@@ -274,14 +442,14 @@ const project = await loadPanoProject({
274
442
 
275
443
  | Reference | Default base |
276
444
  | --- | --- |
277
- | Project `viewerUrl`, hotspot assets, `source.baseUrl` | Project directory |
445
+ | Project `viewerUrl`, hotspot assets, floor-plan image and spot icons, `source.baseUrl` | Project directory |
278
446
  | Tiles and preview in external `viewer.json` | That JSON directory |
279
447
  | Tiles and preview in inline config | Project directory |
280
448
  | Panorama with `source.baseUrl` | Specified directory |
281
449
 
282
450
  URL inputs use the final response directory after redirects. Object inputs require `baseUrl`; it can also override the project directory. Directory trailing slashes are normalized. Domains and cloud prefixes stay outside converter JSON. Browsers accept relative entry URLs; Node.js requires absolute HTTP(S) entry URLs. Full resource URLs remain intact; leading `/` paths resolve against the resource base origin.
283
451
 
284
- If uploads rename files or scatter directories, provide synchronous `resolveAssetUrl(path, context)`. Context has `defaultUrl`, `kind` (`tile`, `preview`, `hotspot`), `sceneId` and optional `hotspotId`. Return a final absolute URL or site-root path; it is not joined again. `uploadedUrls` below is application-owned:
452
+ If uploads rename files or scatter directories, provide synchronous `resolveAssetUrl(path, context)`. Context has `defaultUrl`, `kind` (`tile`, `preview`, `hotspot`, `map`), `sceneId` and optional `hotspotId`. Return a final absolute URL or site-root path; it is not joined again. `uploadedUrls` below is application-owned:
285
453
 
286
454
  ```ts
287
455
  const project = await loadPanoProject("/tour/project.json", {
@@ -293,13 +461,13 @@ const project = await loadPanoProject("/tour/project.json", {
293
461
  });
294
462
  ```
295
463
 
296
- Hotspot mapping covers images, SVG image URLs, audio source arrays, video posters, playback icons and tooltip images; text, CSS and SVG path data remain unchanged. Acquire signed URLs ahead of time in your application and return them synchronously.
464
+ Hotspot mapping covers images, SVG image URLs, audio source arrays, video posters, playback icons and tooltip images; text, CSS and SVG path data remain unchanged. Floor-plan images and spot icons use the same callback with `kind: "map"`. The floor image has an empty `sceneId` and no `hotspotId`. A spot icon sets both `sceneId` and `hotspotId` to the spot id. Include `kind` in an upload key when one path is reused as more than one kind. Acquire signed URLs ahead of time in your application and return them synchronously.
297
465
 
298
466
  `requestInit` accepts only `headers`, `credentials` and `signal`, for JSON requests only. They are not forwarded to textures, and JSON query parameters are not inherited by assets. `parsePanoProject(unknown)` validates and copies project data independently. `sceneFromViewerConfig` accepts the unwrapped compact config; `loadSceneFromJson` accepts a versioned `viewer.json`.
299
467
 
300
468
  `PanoDataError` exposes stable codes: `INVALID_OPTIONS`, `FETCH_FAILED`, `HTTP_ERROR`, `INVALID_JSON`, `INVALID_CONFIG`, `UNSUPPORTED_SCHEMA_VERSION`, `UNSUPPORTED_PROJECTION`, `INVALID_PROJECT`, `INVALID_REFERENCE`, `ABORTED`. HTTP errors carry `status`; project errors include `sceneId` and `field` where available. Successful loading means metadata is ready, not that textures have loaded; use `onSceneReady` for render readiness.
301
469
 
302
- See the `/project` playground route and `public/fixtures/projects/tour/project.json` for a complete static example.
470
+ See the `/project` playground route and `public/fixtures/projects/tour/project.json` for a complete static example. `/map` shows the floor-plan overlay on that tour.
303
471
 
304
472
  ## krpano-compatible tile output
305
473
 
@@ -377,7 +545,8 @@ Import the shipped stylesheet once in the host app. The WebGL panorama
377
545
  **HTML** overlays depend on its prefixed `pano-*` classes for layout.
378
546
 
379
547
  Those overlays include video controls, captions, the default context menu,
380
- hotspot tooltips, controls for assistive technology, and WebVR session UI.
548
+ hotspot tooltips, controls for assistive technology, WebVR session UI, and the
549
+ floor-plan map.
381
550
 
382
551
  ```ts
383
552
  import "@ericchen1990/pano-view/styles.css";
@@ -397,8 +566,9 @@ the stylesheet, the overlays still mount, but they render unstyled.
397
566
 
398
567
  Give PanoViewer an explicit size and render one panorama source inside it.
399
568
  Import the stylesheet once when you use built-in HTML overlays such as video
400
- controls, captions, tooltips, the context menu, or WebVR UI. Each example
401
- below shows a basic setup; the linked section has the full props.
569
+ controls, captions, tooltips, the context menu, WebVR UI, or the floor-plan
570
+ map. Each example below shows a basic setup; the linked section has the full
571
+ props.
402
572
 
403
573
  ### Equirectangular image
404
574
 
@@ -513,9 +683,16 @@ Presets and hotspot rendering are in [Scenes](#scenes).
513
683
  ### Events and chrome
514
684
 
515
685
  - [`PanoEvents`](#panoevents) — viewer-level lifecycle and interaction callbacks
686
+ - [`PanoOverlay`](#dom-overlays-and-dialogs) — screen-plane DOM wrapper for fullscreen-safe UI
516
687
  - [`PanoContextMenu`](#panocontextmenu) — right-click reset / fullscreen menu
688
+ - [`PanoSceneMenu`](#panoscenemenu) — scrollable thumbnail scene picker for `PanoOverlay`
517
689
  - [`PanoVideoControls`](#panovideo) — playback chrome for `PanoVideo` (mounted by default)
518
690
 
691
+ ### Floor plan
692
+
693
+ - [`PanoMap`](#floor-plan-maps) — floor image, scene spots, and the yaw-linked sector
694
+ - [`PanoMapOverlay`](#floor-plan-maps) — docked panel or dialog launcher for `PanoOverlay`
695
+
519
696
  ### Hotspots
520
697
 
521
698
  - [`ImageHotspot`](#imagehotspot) — image texture at a spherical position
@@ -539,9 +716,25 @@ Presets and hotspot rendering are in [Scenes](#scenes).
539
716
 
540
717
  ## DOM overlays and dialogs
541
718
 
542
- Use `PanoViewer.renderOverlay` for ordinary DOM UI outside the Three.js tree. `usePanoOverlay()` supplies its stable Portal target, measured viewer/visible dimensions and fullscreen state. The optional `/ui` entry provides `PanoDialog` and composable parts; your application can also integrate an existing Dialog.
719
+ Prefer `PanoOverlay` for ordinary DOM UI outside the Three.js tree. It mounts on the viewer plane inside the fullscreen root, so chrome stays visible when the viewer goes fullscreen. Place it as a direct child of `PanoViewer` (or inside a Fragment under it) beside scene content. `renderOverlay` remains supported for callback-style mounts.
543
720
 
544
- See the separate **[Dialog and styling guide](./docs/dialogs.md)** for complete examples, CSS variable defaults and placement, custom layouts, third-party integration, nested portals, lifecycle, API tables and troubleshooting. The guide is included in the npm package. Run `pnpm dev` and open `/overlays` for the local examples.
721
+ ```tsx
722
+ <PanoViewer style={{ height: 560 }}>
723
+ <Sphere src="/pano.jpg" />
724
+ <PanoOverlay>
725
+ <PanoSceneMenu items={items} activeId={id} onSelect={setId} />
726
+ <button
727
+ type="button"
728
+ style={{ position: "absolute", right: 16, bottom: 16, pointerEvents: "auto" }}
729
+ onClick={() => void viewer.toggleFullscreen()}
730
+ >
731
+ Fullscreen
732
+ </button>
733
+ </PanoOverlay>
734
+ </PanoViewer>
735
+ ```
736
+
737
+ The overlay layer is `position: absolute; inset: 0; pointer-events: none`. Position children yourself and set `pointerEvents: "auto"` on interactive nodes. When both are used, `renderOverlay` renders first and each `PanoOverlay` follows in document order. Call `usePanoOverlay()` inside `PanoOverlay` (or `renderOverlay`) to get `portalContainer`, `size` (viewer width/height), `visibleRect`, and `isFullscreen`. `usePanoModal(open)` holds that viewer's modal lock while `open === true` (pauses input and auto-rotate, marks the backdrop inert, closes the context menu); it does not trap focus. `PanoOverlayPortalProvider` takes a required `container` and `children` and only remaps `portalContainer` for descendants; `size` / `visibleRect` still describe the whole viewer. Field meanings, reference counting, and third-party Dialog wiring live in the separate **[Dialog and styling guide](./docs/dialogs.md)**. The optional `/ui` entry provides `PanoDialog` and composable parts. The guide ships in the npm package; run `pnpm dev` and open `/overlays` for local examples.
545
738
 
546
739
  ### Default dialog
547
740
 
@@ -628,12 +821,27 @@ which use their spherical center. Hotspot ids must be unique within a viewer.
628
821
 
629
822
  **Scenes** (requires `<Scenes />`) — `setScene(id)`, `nextScene`, `previousScene`, `getActiveSceneId`, `getSceneIds`, `isSceneTransitioning`. `Scenes` accepts `defaultActiveSceneId` (uncontrolled) or `activeSceneId` (controlled). In controlled mode imperative calls invoke `onActiveSceneIdChange`.
630
823
 
631
- **WebVR** (requires `<WebVR />`) — `enterVR`, `exitVR`, `toggleVR`, `isVRAvailable`, `isVREnabled`, `getVRMode`, `requestVRPermission`.
824
+ **WebVR** (requires `<WebVR />`) — `enterVR`, `exitVR`, `toggleVR`, `isVRAvailable`, `isVREnabled`, `getVRMode`, `getVRState`, `subscribeVR`, `requestVRPermission`. Use `subscribeVR` with `useSyncExternalStore` for reactive Enter / Exit buttons; the built-in chrome no longer renders an Enter VR control.
632
825
 
633
826
  **Video** (requires `<PanoVideo />`) — `getVideo` returns the `PanoVideoController`, `subscribeVideo` subscribes to video controller and playback-state changes, plus convenience aliases `playVideo`, `pauseVideo`, `toggleVideo`, `seekVideo`, `setVideoVolume`, `setVideoMuted`, `toggleVideoMuted`.
634
827
 
635
828
  **Background Audio** (requires `<BackgroundAudio />`) — `getBackgroundAudio` returns the `BackgroundAudioController`, `subscribeBackgroundAudio` subscribes to background-audio controller and playback-state changes, plus convenience aliases `playBackgroundAudio`, `pauseBackgroundAudio`, `toggleBackgroundAudio`, `setBackgroundAudioVolume`, `setBackgroundAudioMuted`, `toggleBackgroundAudioMuted`. `BackgroundAudio` accepts `playing` (controlled) or `defaultPlaying` (uncontrolled).
636
829
 
830
+ ### Props
831
+
832
+ - `initialView` — when omitted, yaw `0`, pitch `0`, fov `75`. Partial objects fill the rest from those defaults.
833
+ - `minFov` / `maxFov` — default `30` / `100`. Used as FOV bounds when `viewLimits.fov` is not set.
834
+ - `viewLimits` — camera-center yaw / pitch / fov ranges; scene-level limits override matching axes. See [Camera-center limits](#camera-center-limits).
835
+ - `dpr` — canvas pixel ratio, default `[1, 2]` (clamped device pixel ratio). A single number is also accepted.
836
+ - `controls` — `false` disables all user input. An object configures shared options and `mouse` / `touch` / `keyboard` channels; see [Mouse, touch, and keyboard controls](#mouse-touch-and-keyboard-controls). Shared defaults: `enabled` `true`, `inertia` `true`, `invert` `false`, `bouncingLimits` `false`, `frictionStop` `0.01`, `rotateSpeed` `0.35`, `zoomSpeed` `0.08`, `rotateDamping` / `zoomDamping` `14` / `16`. Omit `fovSpeed` for no extra cap: mouse wheel clamps each step `deltaY * zoomSpeed` to `±fovSpeed`; keyboard zoom compares degrees-per-second × frame time against `fovSpeed`.
837
+ - `cursors` — `true` / omit uses grab / grabbing / pointer / move; `false` leaves the canvas cursor unchanged; an object overrides those four states.
838
+ - `contextMenu` — `true` / omit mounts Reset view + Fullscreen; `false` skips the default menu; an object can change appearance or `items` / `prepend` / `append`. See [PanoContextMenu](#panocontextmenu).
839
+ - `mediaActivation` — enabled by default when a mounted child intends to play; `false` disables automatic activation.
840
+ - `renderOverlay` — optional callback-style DOM UI outside the Three.js tree and inside fullscreen; the callback receives `portalContainer`, `size`, `visibleRect`, and `isFullscreen`. Prefer `<PanoOverlay>` as a child.
841
+ - `onViewChange` — `{ yaw, pitch, fov }` in degrees.
842
+ - `onPanoramaClick` / `onPanoramaDoubleClick` / `onPanoramaPointerMove` — panorama pointer events with `position` (yaw / pitch), `nativeEvent`, `button`, `buttons`, `clientX`, `clientY`, and optional `pointerId`.
843
+ - Other standard `div` props (`className`, `style`, `id`, …) are accepted; conflicting `onChange` and `contextMenu` are omitted from the type.
844
+
637
845
  ### Mobile media activation
638
846
 
639
847
  By default, when a child has an initial playback intent (`PanoVideo autoPlay`, an active `BackgroundAudio`, `VideoHotspot`, or `AudioHotspot`), the viewer silently unlocks audible playback on the first user gesture when the browser requires one, or immediately when autoplay policy already allows it. Muted video can still preview inline. The same gesture resumes Web Audio and gives the host the final choice of which media becomes audible, so video, music, and positional sound never all start by assumption. Pass `mediaActivation={false}` to retain the 2.x no-automatic-activation behavior.
@@ -728,25 +936,29 @@ function SceneExample() {
728
936
  ### WebVR
729
937
 
730
938
  `enterVR` and `requestVRPermission` must be called from a user gesture.
939
+ Drive entry from host UI with `getVRState` / `subscribeVR`; session chrome only
940
+ shows Exit VR and MobileVR Setup (which fade after a few idle seconds).
731
941
 
732
942
  ```tsx
943
+ import { useSyncExternalStore } from "react";
733
944
  import { PanoViewer, Sphere, WebVR, usePanoViewer } from "@ericchen1990/pano-view";
734
945
 
735
946
  function VRExample() {
736
947
  const viewer = usePanoViewer();
948
+ const vr = useSyncExternalStore(
949
+ viewer.subscribeVR,
950
+ viewer.getVRState,
951
+ viewer.getVRState,
952
+ );
737
953
 
738
954
  return (
739
955
  <>
740
956
  <button
741
- onClick={async () => {
742
- await viewer.requestVRPermission();
743
- await viewer.enterVR();
744
- }}
957
+ disabled={!vr.available}
958
+ onClick={() => void (vr.enabled ? viewer.exitVR() : viewer.enterVR())}
745
959
  >
746
- Enter VR
960
+ {vr.enabled ? "Exit VR" : "Enter VR"}
747
961
  </button>
748
- <button onClick={() => void viewer.exitVR()}>Exit VR</button>
749
- <button onClick={() => console.log(viewer.getVRMode())}>Mode?</button>
750
962
  <PanoViewer ref={viewer.ref} style={{ height: 560 }}>
751
963
  <WebVR />
752
964
  <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
@@ -924,7 +1136,7 @@ equirectangular tiles, use [`SphereTile`](#spherical-multiresolution-tiles).
924
1136
  Provide a small preview to reduce download, decoding, and texture memory costs
925
1137
  for the initial display.
926
1138
  Relative paths resolve against the directory of `src`; root-absolute and
927
- `http(s)` URLs are used as-is. For krpano sphere scenes, copy
1139
+ `http(s)`, `blob:`, and `data:` URLs are used as-is. For krpano sphere scenes, copy
928
1140
  [`<preview url>`](https://krpano.com/docu/xml/#preview) into `previewUrl`. That
929
1141
  preview must be equirectangular, not a cube strip.
930
1142
 
@@ -945,6 +1157,17 @@ export function SphereExample() {
945
1157
  }
946
1158
  ```
947
1159
 
1160
+ ### Props
1161
+
1162
+ - `src` — required full 2:1 equirectangular image.
1163
+ - `previewUrl` — required low-resolution 2:1 preview while `src` loads.
1164
+ - `yawOffset` — horizontal rotation in degrees, default `0`. Set when the source forward direction differs from viewer yaw `0`.
1165
+ - `visible` — default `true`. `false` keeps the source mounted for preload without drawing it.
1166
+ - `onReady` — once when the first paintable texture (preview or `src`) is ready.
1167
+ - `onLoad` — when the `src` texture finishes loading; argument is a Three.js `Texture`.
1168
+ - `onError` — when `src` fails to load.
1169
+ - `onPreviewError` — when the preview fails; `src` still continues loading.
1170
+
948
1171
  ## PanoVideo
949
1172
 
950
1173
  `PanoVideo` maps a 2:1 equirectangular video onto the same inward sphere as
@@ -1025,6 +1248,30 @@ rejected. Remote videos and VTT files that are not same-origin should set
1025
1248
  `crossOrigin` (usually `"anonymous"`). For mobile delivery, provide an H.264 +
1026
1249
  AAC MP4 fallback, poster, CORS, and Range support.
1027
1250
 
1251
+ ### Props
1252
+
1253
+ - `src` — single-file shortcut; mutually exclusive with `variants`.
1254
+ - `poster` — cover image. Variants may also set `variants[].poster`.
1255
+ - `variants` / `defaultVariantId` — multi-quality list; missing `defaultVariantId` falls back to the first item. Switching keeps current time and play/pause.
1256
+ - `tracks` — WebVTT tracks. Each may include `src`, `srcLang`, `label`, `default`, `id` (defaults to `srcLang`), and `kind` (`"subtitles"` or `"captions"`, default `"subtitles"`).
1257
+ - `captions` — `true` / omit shows the default overlay; `false` hides the overlay and language menu; an object merges with defaults. Defaults: `color` `#ffffff`, `background` `rgba(0, 0, 0, 0.55)`, `fontSize` `16`, `fontFamily` Inter / system-ui, `fontWeight` `500`, `textShadow` `0 1px 2px rgba(0, 0, 0, 0.8)`, `padding` `"6px 10px"`, `borderRadius` `6`, `maxWidth` `"72%"`, `bottom` `72`, `lineHeight` `1.35`.
1258
+ - `controls` — `true` / omit mounts the default bar; `false` hides it; an object restyles the default instance. Appearance keys: `background` (default `rgba(16, 16, 16, 0.88)`), `color` (`#f4f4f4`), `accent` (`#75cbd3`), `borderRadius` (`8`), `fontSize` (`12`), `padding` (`"8px 10px"`). Render `PanoVideoControls` as a `PanoViewer` child to replace the bar (same `appearance` prop).
1259
+ - `yawOffset` — horizontal offset in degrees, default `0`.
1260
+ - `loop` — default `false`.
1261
+ - `muted` — default `true`.
1262
+ - `volume` — default `1`, range 0–1.
1263
+ - `autoPlay` — default `false`. When `true`, counts as media-activation intent.
1264
+ - `playsInline` — default `true`.
1265
+ - `preload` — `"none"` | `"metadata"` | `"auto"`, default `"metadata"`.
1266
+ - `crossOrigin` — `""` | `"anonymous"` | `"use-credentials"`; omit to leave unset.
1267
+ - `playbackRates` — speed menu list, default `[0.5, 1, 1.25, 1.5, 2]`.
1268
+ - `visible` — default `true`; `false` preloads without drawing.
1269
+ - `onLoad` — when the video becomes playable.
1270
+ - `onError` — `{ source: "poster" | "video" | "track", error }`.
1271
+ - `onPlaybackStateChange` — `"playing" | "paused" | "ended" | "blocked"`.
1272
+ - `onEnded` — when playback reaches the end without looping.
1273
+ - `onVariantChange` / `onTrackChange` — current variant id, or current track id (`null` when none).
1274
+
1028
1275
  ## Tile
1029
1276
 
1030
1277
  `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:
@@ -1136,6 +1383,24 @@ horizontal index and `%00v` produces a three-digit vertical index. The stereo
1136
1383
  `%t` and frame `%f` placeholders are not applicable to `Tile`, which represents
1137
1384
  a single non-stereo cube panorama.
1138
1385
 
1386
+ ### Props
1387
+
1388
+ - `baseUrl` / `multires` / `previewUrl` — required. `multires` may be a string or `{ tileSize, levels }`.
1389
+ - `previewFaceOrder` — default `["l", "f", "r", "b", "u", "d"]`; must list exactly six faces.
1390
+ - `urlTemplate` — package default when omitted; `resolveTileUrl` wins over the template.
1391
+ - `maxTextureMemoryMb` — standalone `Tile` default `128`.
1392
+ - `maxConcurrentLoads` — standalone `Tile` default `8` (do not confuse with `SphereTile` / `Scenes` concurrency of `4`).
1393
+ - `retryCount` — retries after the first failure, default `1`.
1394
+ - `loadMode` — `"full"` (default), `"preview"`, or `"base"`.
1395
+ - `visible` — default `true`; `false` preloads only.
1396
+ - `onReady` — when the preview is paintable (tiles may still report ready if the preview failed).
1397
+ - `onPreviewError` — preview load failure.
1398
+ - `onLoadProgress` — `{ requested, loaded, failed, active, queued }`.
1399
+ - `onTileError` — `{ address, url, error }`.
1400
+ - `onLevelChange` — current cube level, **1-based** (`SphereTile` logical levels are 0-based).
1401
+
1402
+ Inside `Scenes` with `type: "tile"`, the shared texture cache overrides per-scene `maxTextureMemoryMb`, `maxConcurrentLoads`, and `retryCount` (the shared provider fixes retry at `1`). Visibility, `loadMode`, and the callbacks above are owned by `Scenes` and should not be set on the scene object.
1403
+
1139
1404
  ## Scenes
1140
1405
 
1141
1406
  `Scenes` switches between single-image equirectangular, cube-tile, and
@@ -1211,6 +1476,233 @@ While a detail wait or transition runs, panorama drag/zoom input is locked and
1211
1476
  is still being prepared. `maxTextureMemoryMb` and `maxConcurrentTileLoads`
1212
1477
  apply to the whole `Scenes` viewer rather than to each tile scene.
1213
1478
 
1479
+ ### Props
1480
+
1481
+ - `scenes` — required. Supports `type: "sphere" | "tile" | "sphereTile"`.
1482
+ - `activeSceneId` — controlled current scene.
1483
+ - `defaultActiveSceneId` — uncontrolled initial scene. When both are omitted, uses `scenes[0].id`.
1484
+ - `onActiveSceneIdChange` — fired by imperative switches in controlled mode.
1485
+ - `transition` — defaults to `"crossfade"` for `1` second. Also accepts `{ preset, duration }`. Preset default seconds: `none` 0; `crossfade` / `whiteFlash` / most slides and opens / `pixelate` / `dissolve` / `clockWipe` / `zoomBlur` 1; `verticalOpen` 0.7; `glitch` 0.8; `hexDissolve` 1.1; the six `gridWipe*` / `shatter` / `swirl` / `ripple` 1.2; `filmBurn` 1.3; `particles` 1.4; `zoom` / `blackout` 2.
1486
+ - `transitionWaitForDetailMs` — default `0`.
1487
+ - `renderHotspots` — rendered only after a transition finishes; hidden while blending.
1488
+ - `onSceneReady` — first paintable scene, or incoming scene after snapshot takeover; not full detail.
1489
+ - `onTransitionEnd` — `{ previousSceneId, sceneId, preset }`.
1490
+ - `onTransitionError` — `{ sceneId, error }`.
1491
+ - `maxTextureMemoryMb` — shared budget, default `128`.
1492
+ - `maxConcurrentTileLoads` — shared concurrency, default `4`.
1493
+ - `snapshotMaxPixels` — max physical-pixel area of the outgoing GPU snapshot, default `3686400` (~2560×1440).
1494
+
1495
+ Scene `viewLimits` are covered in the FAQ; `yawOffset` matches the source component. Per-scene cube cache fields are ignored under the shared cache — see [Tile](#tile).
1496
+
1497
+ ## PanoSceneMenu
1498
+
1499
+ `PanoSceneMenu` is a scrollable thumbnail strip for switching scenes. Mount it
1500
+ inside `PanoOverlay` so it stays inside the viewer (including fullscreen).
1501
+ Import `@ericchen1990/pano-view/styles.css` for the default layout.
1502
+
1503
+ The menu is host-driven: you pass `items`, `activeId`, and `onSelect`. It does
1504
+ not read `Scenes` state or scene `previewUrl`. Cube `previewUrl` values are
1505
+ often 1×6 preview strips and are not suitable thumbnails — use a dedicated
1506
+ thumbnail URL (for converter output, prefer something like `previews/200.webp`).
1507
+
1508
+ ### Basic wiring
1509
+
1510
+ Keep one controlled scene id and share it with `Scenes`:
1511
+
1512
+ ```tsx
1513
+ import { useState } from "react";
1514
+ import {
1515
+ PanoOverlay,
1516
+ PanoSceneMenu,
1517
+ PanoViewer,
1518
+ Scenes,
1519
+ type PanoSceneMenuItem,
1520
+ } from "@ericchen1990/pano-view";
1521
+ import "@ericchen1990/pano-view/styles.css";
1522
+
1523
+ const menuItems: PanoSceneMenuItem[] = [
1524
+ {
1525
+ id: "lobby",
1526
+ title: "Lobby",
1527
+ thumbnailUrl: "/panoramas/lobby/thumb.webp",
1528
+ },
1529
+ {
1530
+ id: "terrace",
1531
+ title: "Terrace",
1532
+ thumbnailUrl: "/panoramas/terrace/previews/200.webp",
1533
+ },
1534
+ ];
1535
+
1536
+ export function Tour() {
1537
+ const [activeSceneId, setActiveSceneId] = useState("lobby");
1538
+
1539
+ return (
1540
+ <PanoViewer style={{ height: 560 }}>
1541
+ <Scenes
1542
+ scenes={scenes}
1543
+ activeSceneId={activeSceneId}
1544
+ onActiveSceneIdChange={setActiveSceneId}
1545
+ />
1546
+ <PanoOverlay>
1547
+ <PanoSceneMenu
1548
+ activeId={activeSceneId}
1549
+ items={menuItems}
1550
+ onSelect={setActiveSceneId}
1551
+ />
1552
+ </PanoOverlay>
1553
+ </PanoViewer>
1554
+ );
1555
+ }
1556
+ ```
1557
+
1558
+ Each item needs `id` and `thumbnailUrl`. Optional `title` is shown on the
1559
+ thumbnail label and as the control tooltip; when omitted, `id` is used. The
1560
+ active item gets a selection ring above the thumbnail and `data-active="true"`.
1561
+ Scene names sit on a dark translucent bar at the bottom of each thumbnail;
1562
+ use `appearance.labelAlign` for `"center"` or `"end"` alignment.
1563
+
1564
+ ### Placement, inset, and size
1565
+
1566
+ - `placement` — `"top"` | `"bottom"` | `"left"` | `"right"` (default `"bottom"`).
1567
+ Top/bottom render a horizontal strip; left/right render a vertical strip.
1568
+ - `align` — `"start"` | `"center"` | `"end"` (default `"center"`). When the menu
1569
+ is smaller than the available dock space, aligns it along that edge. Short
1570
+ lists are also aligned inside the strip the same way.
1571
+ - `inset` — gap from the viewer edges (default `12`). A number is pixels; a
1572
+ string keeps units such as `"1rem"`; an object sets `top` / `right` /
1573
+ `bottom` / `left` independently.
1574
+ - `width` / `height` — menu size. A number is pixels; a string is used as CSS as-is,
1575
+ including `%`, `rem`, `calc()`, `min()`, `max()`, and `clamp()`.
1576
+ - `dragMotion` — scroll inertia and edge bounce. Omit for both on (custom pointer
1577
+ scrolling, including desktop drag). Set `false` for native overflow scroll.
1578
+ Pass `{ inertia?: boolean; bounce?: boolean }` to toggle either flag. A short
1579
+ press still selects; a drag past a few pixels suppresses the click.
1580
+
1581
+ ```tsx
1582
+ <PanoSceneMenu
1583
+ placement="bottom"
1584
+ align="center"
1585
+ inset={16}
1586
+ width="min(720px, 100%)"
1587
+ items={menuItems}
1588
+ activeId={activeSceneId}
1589
+ onSelect={setActiveSceneId}
1590
+ />
1591
+
1592
+ <PanoSceneMenu
1593
+ placement="right"
1594
+ align="start"
1595
+ inset={{ top: 24, right: 12, bottom: 24, left: 12 }}
1596
+ height="70%"
1597
+ width={160}
1598
+ items={menuItems}
1599
+ activeId={activeSceneId}
1600
+ onSelect={setActiveSceneId}
1601
+ />
1602
+ ```
1603
+
1604
+ If you omit `width` on a top/bottom menu, the strip stretches between the left
1605
+ and right insets. If you omit `height` on a left/right menu, it stretches between
1606
+ the top and bottom insets. The menu also applies a max size that subtracts
1607
+ `inset` and safe-area insets, so `100%` still stays inside the viewer.
1608
+
1609
+ Use `style` when you need one-off positioning overrides on top of these props.
1610
+
1611
+ ### Collapsible menu and arrows
1612
+
1613
+ By default the menu stays open. Set `collapsible` to show a toggle centered on
1614
+ the inner edge of the strip (above a bottom menu, below a top menu, toward the
1615
+ viewer for left/right). Collapsing slides the list off-screen along the dock
1616
+ edge and leaves only the expand button on the safe-area edge:
1617
+
1618
+ ```tsx
1619
+ <PanoSceneMenu
1620
+ collapsible
1621
+ defaultExpanded
1622
+ items={menuItems}
1623
+ activeId={activeSceneId}
1624
+ onSelect={setActiveSceneId}
1625
+ />
1626
+
1627
+ // Controlled
1628
+ <PanoSceneMenu
1629
+ collapsible
1630
+ expanded={menuOpen}
1631
+ onExpandedChange={setMenuOpen}
1632
+ items={menuItems}
1633
+ activeId={activeSceneId}
1634
+ onSelect={setActiveSceneId}
1635
+ />
1636
+ ```
1637
+
1638
+ Set `showArrows` to add previous/next buttons at both ends of the list. By
1639
+ default the buttons stop at the first and last items. Set `loop` so arrows and
1640
+ arrow keys wrap from the last item to the first (and vice versa). Arrows stay
1641
+ disabled when there are fewer than two scenes. Customize their accessible names
1642
+ with `previousLabel` / `nextLabel`, and the toggle with `expandLabel` /
1643
+ `collapseLabel`. Defaults are `"Previous scene"`, `"Next scene"`,
1644
+ `"Show scenes"`, and `"Hide scenes"`. The radiogroup label defaults to `"Scenes"`
1645
+ via `label`. When collapsible, `defaultExpanded` defaults to `true`.
1646
+
1647
+ ```tsx
1648
+ <PanoSceneMenu
1649
+ showArrows
1650
+ loop
1651
+ collapsible
1652
+ placement="bottom"
1653
+ width="min(720px, 100%)"
1654
+ items={menuItems}
1655
+ activeId={activeSceneId}
1656
+ onSelect={setActiveSceneId}
1657
+ previousLabel="Previous room"
1658
+ nextLabel="Next room"
1659
+ />
1660
+ ```
1661
+
1662
+ ### Appearance and CSS hooks
1663
+
1664
+ Pass `appearance` for common chrome tokens without writing CSS. The active-item
1665
+ ring sits above the thumbnail and is controlled by `selectionColor` /
1666
+ `selectionWidth` (`selectionWidth: 0` hides it). Scene titles default to start
1667
+ alignment; set `labelAlign: "center"` or `"end"` to change that.
1668
+
1669
+ ```tsx
1670
+ <PanoSceneMenu
1671
+ appearance={{
1672
+ background: "rgba(8, 12, 18, 0.88)",
1673
+ itemWidth: 144,
1674
+ itemHeight: 88,
1675
+ gap: 10,
1676
+ borderRadius: 10,
1677
+ selectionColor: "#7dd3fc",
1678
+ selectionWidth: 2,
1679
+ labelBackground: "rgba(0, 0, 0, 0.6)",
1680
+ labelColor: "#ffffff",
1681
+ labelAlign: "center",
1682
+ fontSize: 12,
1683
+ controlColor: "#f8fafc",
1684
+ controlBackground: "rgba(255, 255, 255, 0.14)",
1685
+ }}
1686
+ items={menuItems}
1687
+ activeId={activeSceneId}
1688
+ onSelect={setActiveSceneId}
1689
+ />
1690
+ ```
1691
+
1692
+ `DEFAULT_PANO_SCENE_MENU_APPEARANCE` exports the defaults (including
1693
+ `padding: "8px 10px"` and `labelAlign: "start"`). For deeper styling,
1694
+ use `className` / `style` against these stable classes:
1695
+
1696
+ - `pano-scene-menu` — root chrome
1697
+ - `pano-scene-menu-body` — arrows + list row/column (owns the strip background)
1698
+ - `pano-scene-menu-list` — scrollable item list
1699
+ - `pano-scene-menu-item` / `pano-scene-menu-thumb` / `pano-scene-menu-label`
1700
+ - `pano-scene-menu-control` / `pano-scene-menu-arrow` / `pano-scene-menu-toggle`
1701
+
1702
+ The root also exposes `data-placement`, `data-align`, `data-orientation`
1703
+ (`horizontal` | `vertical`), `data-expanded`, and `data-collapsible`. The active
1704
+ item has `data-active="true"`.
1705
+
1214
1706
  ## PanoFilter
1215
1707
 
1216
1708
  Render `PanoFilter` inside `PanoViewer` to color-grade or stylize the panorama
@@ -1257,7 +1749,7 @@ panorama when a filter is active.
1257
1749
 
1258
1750
  ## AutoRotate
1259
1751
 
1260
- 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.
1752
+ 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 (default `2`). While the user is dragging, or while drag inertia is still settling, rotation pauses and resumes from zero speed automatically. When the component is mounted and `enabled` is omitted, it is treated as `true`; omitting the component entirely means no auto-rotation.
1261
1753
 
1262
1754
  ```tsx
1263
1755
  <PanoViewer style={{ height: 560 }}>
@@ -1332,6 +1824,22 @@ every other scene plays `default.mp3`.
1332
1824
  For a sound attached to a point in the panorama that pans with the view, use
1333
1825
  `AudioHotspot`, not `BackgroundAudio`.
1334
1826
 
1827
+ ### Props
1828
+
1829
+ - `src` / `sources` — `src` is the shared track, or the fallback when a scene is missing from `sources`. Either value may be a string or a string array (Howler multi-format fallback). `sources` keys are `Scene.id` values.
1830
+ - `sceneId` — required when `sources` is set; omitting it throws.
1831
+ - `playing` / `defaultPlaying` — controlled playback, or uncontrolled initial value (default `false`).
1832
+ - `loop` — default `true`.
1833
+ - `muted` — default `false`.
1834
+ - `volume` — default `1`, range 0–1.
1835
+ - `pauseWhenHidden` — default `true`; pauses while the page is hidden.
1836
+ - `fadeMs` — crossfade milliseconds when the resolved track changes, default `400`; `0` is a hard cut.
1837
+ - `crossOrigin` — `""` | `"anonymous"` | `"use-credentials"`; omit to leave unset.
1838
+ - `onPlaybackStateChange` — `"playing" | "paused" | "ended" | "blocked"`.
1839
+ - `onPlaybackError` — `play()` and other playback failures.
1840
+ - `onEnded` — when the track finishes; uncontrolled mode also sets internal playing to `false`.
1841
+ - `onError` — resource load failure, `{ error }` (distinct from `onPlaybackError`).
1842
+
1335
1843
  ## Gyro
1336
1844
 
1337
1845
  `Gyro` is an opt-in device-orientation control and is disabled by default.
@@ -1405,13 +1913,30 @@ require HTTPS. A cross-origin iframe must be permitted by the host page, for
1405
1913
  example with `allow="gyroscope; accelerometer"` and a compatible
1406
1914
  `Permissions-Policy` header.
1407
1915
 
1916
+ ### Props
1917
+
1918
+ - `enabled` — default `false`. On iOS, call `requestPermission()` inside a user gesture before setting `true`.
1919
+ - `camroll` — default `true`.
1920
+ - `absolute` — default `false` (relative mode); `true` follows the compass.
1921
+ - `north` — panorama yaw that represents North, in degrees, default `0`.
1922
+ - `friction` — `0`–`0.99`, default `0`.
1923
+ - `touchMode` — default `"full"`; see values above.
1924
+ - `softstart` — blend seconds when enabling, default `0.5`.
1925
+ - `desktopSupport` — default `false`.
1926
+ - `onAvailable` / `onUnavailable` — when sensor capability detection changes.
1927
+ - `onEnable` / `onDisable` — when a session actually starts / stops.
1928
+ - `onDenied` — when permission is denied (examples often set `enabled` back to `false`).
1929
+
1930
+ These are props on `Gyro` itself. `PanoEvents` `onGyro*` callbacks subscribe to the same event bus for viewer-level listeners.
1931
+
1408
1932
  ## WebVR
1409
1933
 
1410
1934
  `WebVR` adds immersive viewing to the nearest `PanoViewer`. It prefers an
1411
1935
  `immersive-vr` WebXR session on supported headsets. On phones without WebXR it
1412
1936
  uses a stereoscopic split-screen view with device orientation, and on desktop
1413
- it can provide a mouse-driven simulated VR preview. The built-in chrome includes
1414
- Enter VR, Exit VR, and MobileVR setup controls.
1937
+ it can provide a mouse-driven simulated VR preview. Host apps provide Enter VR
1938
+ through the viewer handle; the built-in chrome only shows Exit VR and MobileVR
1939
+ setup controls while a session is active (they fade after idle interaction).
1415
1940
 
1416
1941
  ```tsx
1417
1942
  import { useRef } from "react";
@@ -1441,19 +1966,28 @@ export function WebVRExample() {
1441
1966
  ```
1442
1967
 
1443
1968
  The handle exposes `enterVR()`, `exitVR()`, `toggleVR()`, `isAvailable()`,
1444
- `isEnabled()`, `getMode()`, and `requestPermission()`. Modes are `"webxr"`,
1445
- `"mobilevr"`, and `"fake"`. `profile` accepts `"none"`, `"cardboard-v1"`,
1446
- `"cardboard-v2"`, `"gear-vr"`, `"daydream"`, or a custom
1447
- `{ fov, ipdMm, k1, k2 }` lens profile. MobileVR setup values are versioned and
1448
- stored in the current origin's `localStorage`. In VR, a center reticle shows a
1449
- crosshair; looking at an interactive hotspot draws a clockwise ring that fills
1450
- over `cursorDwellMs` (default 1500), then the hotspot click handler runs. Set
1451
- `cursorDwellMs={0}` to keep the reticle without auto-click. Simulated desktop
1452
- VR (`fake` mode) requests pointer lock by default (`mousePointerLock`) so the
1453
- cursor hides and mouse movement looks around; click the view again if the
1454
- browser releases the lock. Fallback sessions keep the screen awake
1455
- (`wakelock`) with the Screen Wake Lock API, or a looping hidden video on
1456
- browsers that lack it.
1969
+ `isEnabled()`, `getMode()`, `getState()`, `subscribe()`, `requestPermission()`,
1970
+ and `resetSettings()`. Modes are `"webxr"`, `"mobilevr"`, and `"fake"`.
1971
+ `profile` accepts `"none"`, `"cardboard-v1"`, `"cardboard-v2"`, `"gear-vr"`,
1972
+ `"daydream"`, or a custom `{ fov, ipdMm, k1, k2, screenToLensMm?, trayDistanceMm? }`
1973
+ lens profile. When the physical screen size is known, MobileVR places each eye's
1974
+ optical axis with the inter-lens distance and optional tray-to-lens height
1975
+ (`trayDistanceMm`; Cardboard V1 uses `35`). `0` or omitted keeps the vertical
1976
+ center. When `screensize` is `"auto"`, MobileVR loads the Cardboard Device
1977
+ Parameter Database (`databaseUrl`, default Google's public DPDB) to estimate
1978
+ diagonal inches; pass `""` or `null` to skip the request. Unknown devices fire
1979
+ `onUnknownDevice` and keep the lens-profile FOV (no tray offset until a size is
1980
+ known). MobileVR setup values are versioned and stored in the current origin's
1981
+ `localStorage`; `resetSettings()` clears that storage and restores prop or
1982
+ built-in defaults (also available from the Setup panel Reset control). In VR, a
1983
+ center reticle shows a crosshair; looking at an interactive hotspot draws a
1984
+ clockwise ring that fills over `cursorDwellMs` (default 1500), then the hotspot
1985
+ click handler runs. Set `cursorDwellMs={0}` to keep the reticle without
1986
+ auto-click. Simulated desktop VR (`fake` mode) requests pointer lock by default
1987
+ (`mousePointerLock`) so the cursor hides and mouse movement looks around; click
1988
+ the view again if the browser releases the lock. Fallback sessions keep the
1989
+ screen awake (`wakelock`) with the Screen Wake Lock API, or a looping hidden
1990
+ video on browsers that lack it.
1457
1991
 
1458
1992
  WebXR requires HTTPS and a user gesture. Mobile sensor access also requires
1459
1993
  HTTPS and may require an explicit iOS permission prompt. Embedded viewers need
@@ -1462,6 +1996,30 @@ DOM overlays such as iframe hotspots, tooltips, and video controls are not
1462
1996
  visible inside an immersive headset session; mesh-based hotspots remain
1463
1997
  interactive through the default WebXR controller rays.
1464
1998
 
1999
+ ### Props
2000
+
2001
+ - `chrome` — default `true` mounts session Exit / Setup UI (no Enter VR button). An appearance object may set `background` (default `rgba(16, 24, 32, 0.78)`), `color` (`#ffffff`), `accent` (`#7dd3fc`), `borderRadius` (`6`), `fontSize` (`14`), and optional `setupTitle`. Session buttons fade after ~3s idle and return on pointer / wheel / keyboard interaction with the viewer.
2002
+ - `mobileVr` — default `true`.
2003
+ - `desktopSupport` — default `false` (independent of the same-named `Gyro` prop).
2004
+ - `fakeSupport` — default `true` for mouse-driven desktop simulation.
2005
+ - `screensize` — `"auto"` (factory / omitted storage default) or inches; numbers are clamped to **3–20** with one decimal place.
2006
+ - `databaseUrl` — Cardboard DPDB URL for `"auto"` screensize. Default `https://storage.googleapis.com/cardboard-dpdb/dpdb.json`. Pass `""` or `null` to disable the online lookup.
2007
+ - `profile` — omitted values use stored settings; factory default is `"cardboard-v1"`. Also accepts `"none"`, `"cardboard-v2"`, `"gear-vr"`, `"daydream"`, or a custom `{ fov, ipdMm, k1, k2, screenToLensMm?, trayDistanceMm?, label?, id? }`. `trayDistanceMm` is millimetres from the headset tray bottom to the lens center; `0` / omitted keeps the vertical optical axis at screen center.
2008
+ - `absolute` — default `false`. Compass-oriented MobileVR heading (no effect in WebXR).
2009
+ - `north` — default `0`. Panorama yaw that represents North when `absolute` is enabled.
2010
+ - `friction` — default `0` (range `0–0.99`). MobileVR gyro smoothing; keep `0` for normal viewing.
2011
+ - `touchSupport` — default `false`. When `true`, allows horizontal touch dragging to correct MobileVR heading (`horizontaloffset`). Disabled by default because headset trays can trigger false touches.
2012
+ - `oversampling` — default `1`, clamped to **0.5–2**. Scales the MobileVR distortion render target only (viewports unchanged). For WebXR, sets the framebuffer scale factor before the session starts (cannot change mid-session).
2013
+ - `foveation` — optional WebXR fixed foveation in **0–1**. Omit to keep the device default; `0` is full resolution, `1` is maximum edge downsampling.
2014
+ - `frameRate` — optional WebXR target rate: `"low" | "mid" | "high"` or a numeric Hz (closest supported rate). Omit to keep the store default (`"high"`).
2015
+ - `cursor` — default `true`; `false` hides the center reticle.
2016
+ - `cursorDwellMs` — default `1500`; `0` disables auto-click.
2017
+ - `wakelock` / `mousePointerLock` — both default to `true`.
2018
+ - `onAvailable` / `onUnavailable` — VR capability changes.
2019
+ - `onEnterVR` / `onExitVR` — mode `"webxr" | "mobilevr" | "fake"`.
2020
+ - `onDenied` — permission or session denial, optional `error`.
2021
+ - `onUnknownDevice` — when `"auto"` screensize cannot be resolved from the DPDB (disabled URL, no match, or fetch failure).
2022
+
1465
2023
  ## Mouse, touch, and keyboard controls
1466
2024
 
1467
2025
  `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.
@@ -1485,15 +2043,36 @@ interactive through the default WebXR controller rays.
1485
2043
 
1486
2044
  ### MouseControls
1487
2045
 
1488
- See the defaults above. Override through `controls.mouse` or by rendering `<MouseControls />` as a child.
2046
+ Override the default channel through `controls.mouse` or by rendering `<MouseControls />` as a child. A child instance does **not** inherit `controls.fovSpeed`; pass `fovSpeed` on the component when you need the cap.
2047
+
2048
+ - `enabled` — default `true`.
2049
+ - `rotateSpeed` — default `0.35` (scales pointer displacement; `0` disables rotate).
2050
+ - `zoomSpeed` — default `0.08`; each wheel step is `deltaY * zoomSpeed`.
2051
+ - `wheel` — default `true`.
2052
+ - `invert` — default `false`; may override shared `controls.invert`.
2053
+ - `buttons` — default `["left"]`; values `"left" | "middle" | "right"`.
2054
+ - `fovSpeed` — omit for no cap; when set, clamps each wheel step to `±fovSpeed` (not a per-second rate).
1489
2055
 
1490
2056
  ### TouchControls
1491
2057
 
1492
- One-finger drag and optional two-finger pinch zoom (`pinchZoom`, default `true`). Override through `controls.touch` or by rendering `<TouchControls />` as a child.
2058
+ One-finger drag and optional two-finger pinch zoom. Override through `controls.touch` or by rendering `<TouchControls />`.
2059
+
2060
+ - `enabled` — default `true`.
2061
+ - `rotateSpeed` — default `0.35`, same meaning as mouse.
2062
+ - `invert` — default `false`.
2063
+ - `pinchZoom` — default `true`. Pinch changes FOV from the finger-distance ratio and is not limited by `fovSpeed`.
1493
2064
 
1494
2065
  ### KeyboardControls
1495
2066
 
1496
- 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.
2067
+ Hold arrows (or custom bindings) for continuous look / FOV. Override through `controls.keyboard` or by rendering `<KeyboardControls />`. Child instances likewise do not inherit `controls.fovSpeed`.
2068
+
2069
+ - `enabled` — default `true`.
2070
+ - `rotateSpeed` / `zoomSpeed` — default `60` / `30` (degrees per second).
2071
+ - `shiftMultiplier` — default `3`.
2072
+ - `invert` — default `false`; flips up/down only.
2073
+ - `fovSpeed` — omit for no cap; when set, limits with `min(zoom, fovSpeed * dt)`.
2074
+ - `keys` — replaces an action wholesale when that key is present; omitted actions keep defaults; an empty array unbinds the action. Matching compares both `event.key` and `event.code` (lowercased).
2075
+ - `onPreviousScene` / `onNextScene` — keys still `preventDefault` when bound; without a callback the scene does not change.
1497
2076
 
1498
2077
  Shared all-mode options on `controls`: `enabled`, `invert` (drag direction for mouse/touch), `bouncingLimits`, `fovSpeed`, `frictionStop` (default `0.01`), plus existing damping / inertia.
1499
2078
 
@@ -1546,7 +2125,7 @@ export function KeyboardExample({ scenes }: { scenes: Scene[] }) {
1546
2125
 
1547
2126
  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).
1548
2127
 
1549
- 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.
2128
+ Default bindings: arrows and WASD for look; zoom in with `+` and `=`, zoom out with `-` and `_`; `0` resets; `[` / `PageUp` and `]` / `PageDown` for previous/next scene (without callbacks the scene does not change, but the key may still be intercepted). 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.
1550
2129
 
1551
2130
  ## Panorama coordinate events
1552
2131
 
@@ -1622,25 +2201,28 @@ import {
1622
2201
 
1623
2202
  Supported callbacks (krpano names in parentheses where applicable):
1624
2203
 
1625
- - View: `onViewChange` (`onviewchange`), `onViewSettled`, `onViewInteractionStart` / `onViewInteractionEnd`
1626
- - Pointer on the panorama shell (not hotspots): `onClick`, `onDoubleClick`, `onPointerDown`, `onPointerUp`, `onPointerMove`, `onContextMenu`
1627
- - `onWheel` — mouse wheel only; touch pinch does not synthesize a wheel event
2204
+ - View: `onViewChange` (`onviewchange`), `onViewSettled` — both receive `{ yaw, pitch, fov }`. `viewchange` fires while the view moves; `viewsettled` fires when inertia/tween first stops or a commanded animation ends.
2205
+ - `onViewInteractionStart` / `onViewInteractionEnd` — `{ source }` with `source` `"mouse" | "touch" | "keyboard"`.
2206
+ - Pointer on the panorama shell (not hotspots): `onClick`, `onDoubleClick`, `onPointerDown`, `onPointerUp`, `onPointerMove`, `onContextMenu` — `PanoramaPointerEvent` with `position`, `nativeEvent`, `button`, `buttons`, `clientX`, `clientY`, and optional `pointerId`.
2207
+ - `onWheel` — mouse wheel only; touch pinch does not synthesize a wheel event. Payload `{ delta, nativeEvent }`; `delta` matches the sign of `WheelEvent.deltaY`.
1628
2208
  - Idle: `onIdle` / `onIdleEnd` with per-instance `idleTime` (ms, default `2000`)
1629
- - Fullscreen: `onEnterFullscreen` / `onExitFullscreen`
1630
- - `onResize` — canvas content-box size via `ResizeObserver`
1631
- - Auto-rotate: `onAutoRotateStart` / `onAutoRotateStop` / `onAutoRotateOneRound`
1632
- - Gyro: `onGyroAvailable` / `onGyroUnavailable` / `onGyroEnable` /
1633
- `onGyroDisable` / `onGyroDenied`
2209
+ - Fullscreen: `onEnterFullscreen` / `onExitFullscreen` (no payload)
2210
+ - `onResize` — `{ width, height }` from a content-box `ResizeObserver`
2211
+ - Auto-rotate: `onAutoRotateStart` / `onAutoRotateStop` / `onAutoRotateOneRound` (no payload)
2212
+ - Gyro: `onGyroAvailable` / `onGyroUnavailable` / `onGyroEnable` / `onGyroDisable` / `onGyroDenied` (no payload)
2213
+ - VR: `onVRAvailable` / `onVRUnavailable` / `onVREnter` / `onVRExit` / `onVRDenied` / `onVRUnknownDevice`. Enter/exit carry mode `"webxr" | "mobilevr" | "fake"`; `onVRDenied` may include an optional `error`.
1634
2214
 
1635
2215
  `PanoViewer`'s existing `onViewChange` / `onPanoramaClick` / `onPanoramaDoubleClick` /
1636
2216
  `onPanoramaPointerMove` props remain supported and share the same event bus.
2217
+ `usePanoEvents` accepts the same props as `<PanoEvents>` and returns nothing.
1637
2218
 
1638
2219
  Resource callbacks belong to each source: `Sphere` provides `onLoad`,
1639
2220
  `onError`, and `onPreviewError`; `Tile` / `SphereTile` provide `onLoadProgress`,
1640
2221
  `onTileError`, and `onPreviewError`. Sources report initial render readiness
1641
2222
  through `onReady`. Use `Scenes` callbacks `onSceneReady`, `onTransitionEnd`,
1642
- and `onTransitionError` for scene state. This package does not provide a
1643
- one-to-one mapping of krpano XML-loading, VR, or frame-render events.
2223
+ and `onTransitionError` for scene state. This package does not mirror krpano
2224
+ XML-loading or frame-render events one-to-one; use the `onVR*` callbacks above
2225
+ for VR lifecycle.
1644
2226
 
1645
2227
  ## PanoContextMenu
1646
2228
 
@@ -1734,18 +2316,32 @@ reimplementing fullscreen enter/exit state:
1734
2316
  </PanoViewer>
1735
2317
  ```
1736
2318
 
1737
- Override presentation on a preset with `{ preset: "fullscreen", label: "…" }`.
2319
+ Override presentation on a preset with `{ preset: "fullscreen", label: "…" }`;
2320
+ presets may also override `id`, `className`, `style`, `disabled`, and `hidden`.
1738
2321
  For fully manual assembly outside the prop (for example a custom
1739
2322
  `PanoContextMenu` child), call `createPanoContextMenuPresets({ reset,
1740
2323
  toggleFullscreen, isFullscreen })` and use `.resetView` / `.fullscreen` /
1741
2324
  `.defaults`.
1742
2325
 
1743
- Concrete items may use an `icon` React node or an `image` URL. Separators
1744
- always come from the `"separator"` preset. Appearance covers background, text
1745
- color, border, radius, shadow, background-only `opacity` (clamped from
1746
- `MIN_PANO_CONTEXT_MENU_BACKGROUND_OPACITY` / `0.4` to `1`; labels and icons
1747
- stay opaque), hover/disabled colors, and icon size. For an explicit translucent
1748
- fill without using `opacity`, you can still pass `background: "rgba(...)"`.
2326
+ Concrete items may use an `icon` React node or an `image` URL (optional
2327
+ `imageAlt`). Items may also set `disabled`, `hidden`, `className`, and `style`.
2328
+ `onSelect` receives `position`, `view`, `nativeEvent`, and `close()`. Separators
2329
+ always come from the `"separator"` preset.
2330
+
2331
+ When `items` is provided, `prepend` / `append` are ignored. `children` is a
2332
+ full custom render: `(props: { event, view, close }) => ReactNode`; it takes
2333
+ precedence over `items` while open/close/positioning stay with the component.
2334
+ `className`, `style`, `itemClassName`, and `separatorClassName` are also accepted.
2335
+
2336
+ Appearance fields and defaults: `background` `#161616`, `color` `#f4f4f4`,
2337
+ `border` `"1px solid #2e2e2e"`, `borderRadius` `8`,
2338
+ `shadow` `"0 8px 24px rgba(0,0,0,0.45)"`, `opacity` `1` (background only,
2339
+ clamped to `[0.4, 1]`; labels and icons stay opaque), `fontSize` `13`,
2340
+ `minWidth` `180`, `padding` `4`, `iconSize` `16`, `itemPadding` `"6px 10px"`,
2341
+ `itemHoverBackground` `#2a2a2a`, `itemDisabledColor` `#777777`,
2342
+ `separatorColor` `#333333`, `separatorMargin` `"4px 6px"`. For an explicit
2343
+ translucent fill without using `opacity`, you can still pass
2344
+ `background: "rgba(...)"`.
1749
2345
 
1750
2346
  For a fully custom DOM menu, set `contextMenu={false}` and render
1751
2347
  `PanoContextMenu` as a child. Do not mount both the default menu and a child
@@ -1754,15 +2350,34 @@ For a fully custom DOM menu, set `contextMenu={false}` and render
1754
2350
 
1755
2351
  ## Shared hotspot contract and saved definitions
1756
2352
 
1757
- Every hotspot has an `id`, optional `visible`, `interactive`, `pointerEvents`,
1758
- `renderOrder`, and semantic interaction callbacks. Point hotspots additionally
1759
- use a controlled `position`, angular `width`/`height`, hotspot mode, and the
1760
- zoom options described below. `interactive={false}` keeps a hotspot visible
1761
- while letting a drawing tool receive the panorama pointer events beneath it.
1762
- `pointerEvents="none"` ignores mouse, touch, and pen so those events pass
1763
- through to the panorama even when the hotspot stays keyboard-interactive.
1764
- Interactive hotspots set the canvas cursor to `pointer` while hovered, or to
1765
- the hotspot's `cursor` prop when that is set.
2353
+ Every hotspot has an `id`, optional shared fields, and semantic interaction
2354
+ callbacks. Point hotspots additionally use a controlled `position`, angular
2355
+ `width`/`height`, hotspot mode, and the zoom options described below.
2356
+ `interactive={false}` keeps a hotspot visible while letting a drawing tool
2357
+ receive the panorama pointer events beneath it. `pointerEvents="none"` ignores
2358
+ mouse, touch, and pen so those events pass through to the panorama even when
2359
+ the hotspot stays keyboard-interactive. Interactive hotspots set the canvas
2360
+ cursor to `pointer` while hovered, or to the hotspot's `cursor` prop when that
2361
+ is set.
2362
+
2363
+ Shared field defaults and usage:
2364
+
2365
+ - `visible` — default `true`.
2366
+ - `renderOrder` — default `0` for point hotspots; `10` for polygons / polylines.
2367
+ - `opacity` — default `1`; applies to point-hotspot textures / iframes.
2368
+ - `selected` — default `false`; when `true`, draws an ~3px selection outline (common in authoring).
2369
+ - `interactive` — default `true`; `pointerEvents` defaults to `"auto"`.
2370
+ - `onHoverChange(hovered, event)` — boolean hover state, then the interaction event.
2371
+ - `onClick` — position and input source (`"pointer"` or `"keyboard"`).
2372
+ - Point-hotspot drag: with `draggable`, `onDragStart` fires at the start,
2373
+ `onPositionChange` reports the new position while moving (the host must write
2374
+ it back), and `onDragEnd` fires on release. Polygon / polyline callbacks with
2375
+ the same names carry vertex sets — see those sections.
2376
+
2377
+ Default angular size when `width` / `height` are omitted: `ImageHotspot` /
2378
+ `SequenceHotspot` `12×8`; canvas `GraphicHotspot` `10×10` (`12×8` when
2379
+ `kind: "svg"` loads as an image); `VideoHotspot` `16×9`; `AudioHotspot` `8×8`;
2380
+ `TextHotspot` `16×6`; `IframeHotspot` `18×12`.
1766
2381
 
1767
2382
  ```tsx
1768
2383
  <TextHotspot
@@ -1773,11 +2388,10 @@ the hotspot's `cursor` prop when that is set.
1773
2388
  />
1774
2389
  ```
1775
2390
 
1776
- `onClick` and `onHoverChange` receive a position and input source (`"pointer"`
1777
- or `"keyboard"`). Point hotspots can use `draggable`, `onDragStart`,
1778
- `onPositionChange`, and `onDragEnd`; the host must write the reported position
1779
- back to its state. Polygon and polyline hotspots report the corresponding
1780
- controlled `vertices` through `onVerticesChange`.
2391
+ Point hotspots can use `draggable`, `onDragStart`, `onPositionChange`, and
2392
+ `onDragEnd`; the host must write the reported position back to its state.
2393
+ Polygon and polyline hotspots report controlled `vertices` through
2394
+ `onVerticesChange`.
1781
2395
 
1782
2396
  Every hotspot can show a DOM tooltip at its anchor. Pass a string or
1783
2397
  `{ text?, image?, imageAlt? }` as `tooltip`. The default `tooltipTrigger` is
@@ -1855,7 +2469,10 @@ const hotspots: HotspotDefinition[] = [
1855
2469
 
1856
2470
  Definitions intentionally contain data only. Render them with a switch in the
1857
2471
  host, where application-specific click actions, controlled media state, and
1858
- error reporting belong.
2472
+ error reporting belong. Project JSON `label` is not a component prop;
2473
+ `ProjectScenes` uses `hotspot.label || hotspot.id` as the accessible name
2474
+ (`ariaLabel`). `selected` exists only on runtime props and is not part of the
2475
+ saved definition.
1859
2476
 
1860
2477
  ## ImageHotspot
1861
2478
 
@@ -1885,15 +2502,19 @@ move a selected hotspot. Clickable hotspots need `ariaLabel`: PanoViewer creates
1885
2502
  an internal semantic control for Tab, Enter, and Space activation, with a
1886
2503
  visible WebGL focus outline.
1887
2504
 
1888
- `ImageHotspot` calls `onLoad(texture)` and `onError(error)`.
2505
+ `ImageHotspot` calls `onLoad(texture)` and `onError(error)`. When size is
2506
+ omitted, defaults are `width={12}` and `height={8}`.
1889
2507
 
1890
2508
  ## GraphicHotspot
1891
2509
 
1892
2510
  `GraphicHotspot` accepts built-in `circle`, `triangle`, `diamond`, `star`,
1893
2511
  `arrow`, `rectangle`, and `ring` graphics, an SVG URL, or safe SVG path data
1894
2512
  with an explicit viewBox. Built-in graphics support `fill`, `stroke`, and
1895
- `strokeWidth`; rectangles support a relative `cornerRadius` from `0` to `0.5`,
1896
- and rings support `innerRadius`.
2513
+ `strokeWidth`; rectangles support a relative `cornerRadius` from `0` to `0.5`;
2514
+ rings support `innerRadius` (fraction of the outer radius, default `0.58`).
2515
+ Canvas `strokeWidth` is line width on the **512** texture (default `8`), not
2516
+ the CSS-pixel stroke used by polygons. `kind: "path"` needs `path` and
2517
+ `viewBox: [x, y, width, height]`.
1897
2518
 
1898
2519
  ```tsx
1899
2520
  import { GraphicHotspot } from "@ericchen1990/pano-view";
@@ -1943,7 +2564,8 @@ relative proportion, not a texture-pixel measurement.
1943
2564
  `GraphicHotspot` uses the same `onLoad(texture)` and `onError(error)` callbacks
1944
2565
  as `ImageHotspot`. Built-in paths are rasterized locally; only an SVG URL
1945
2566
  or SVG `path` data plus an explicit `viewBox` is accepted, never arbitrary SVG
1946
- markup.
2567
+ markup. Canvas graphics default to angular size `10×10`; `kind: "svg"` image
2568
+ loads default to `12×8`.
1947
2569
 
1948
2570
  ## Hotspot mode and zoom behaviour
1949
2571
 
@@ -2018,6 +2640,13 @@ stroke. Dragging moves all vertices together while keeping yaw normalized and
2018
2640
  pitch clamped. Individual vertex editing is provided by the authoring
2019
2641
  workflow, not the runtime hotspot component.
2020
2642
 
2643
+ Defaults: `fill` `#df6b42`, `fillOpacity` `0.34`, `stroke` `#f5fbfc`,
2644
+ `strokeWidth` `2`, `strokeOpacity` `1`, `renderOrder` `10`. `onDragStart` /
2645
+ `onDragEnd` / `onVerticesChange` carry vertex events (`vertices`,
2646
+ `startVertices`, `position`, `startPosition`), not point-hotspot drag events.
2647
+ `onInvalid` codes are `too_few_vertices`, `self_intersection`,
2648
+ `contains_pole`, and `outside_hemisphere`.
2649
+
2021
2650
  Set `fillOpacity={0}` for a closed outline-only polygon. The fill and outline
2022
2651
  share the same spherical edge sampling so the outline follows the fill boundary.
2023
2652
 
@@ -2046,7 +2675,10 @@ import { PolylineHotspot } from "@ericchen1990/pano-view";
2046
2675
  />;
2047
2676
  ```
2048
2677
 
2049
- `onInvalid` reports the only invalid runtime shape: fewer than two vertices.
2678
+ `onInvalid` reports the only invalid runtime shape: fewer than two vertices
2679
+ (`code: "too_few_vertices"`). Defaults: `stroke` `#f5fbfc`, `strokeWidth` `2`,
2680
+ `strokeOpacity` `1`, `renderOrder` `10`. Drag callbacks likewise carry a
2681
+ vertex set (`PolylineVerticesChangeEvent`).
2050
2682
 
2051
2683
  ## SequenceHotspot
2052
2684
 
@@ -2054,7 +2686,8 @@ import { PolylineHotspot } from "@ericchen1990/pano-view";
2054
2686
  frames in a vertical or horizontal strip. This matches the common krpano
2055
2687
  animated-hotspot format. Give `width` and `height` the aspect ratio of a single
2056
2688
  frame, rather than the full strip. `playing` is controlled by the host; `fps`
2057
- defaults to `12` and `loop` defaults to `true`.
2689
+ defaults to `12`, `loop` to `true`, and `frameDirection` to `"vertical"`.
2690
+ Omitted size defaults to `12×8`.
2058
2691
 
2059
2692
  ```tsx
2060
2693
  import { SequenceHotspot } from "@ericchen1990/pano-view";
@@ -2076,7 +2709,10 @@ import { SequenceHotspot } from "@ericchen1990/pano-view";
2076
2709
  />;
2077
2710
  ```
2078
2711
 
2079
- Sequence loading uses `onLoadProgress` and `onError`. The component does not
2712
+ Sequence loading uses `onLoadProgress` (`{ id, total, loaded, failed }`) and
2713
+ `onError` (`{ id, src, error }`). Also available: `onFrameChange`
2714
+ (`{ id, index, src }`) and `onPlaybackStateChange`
2715
+ (`"playing" | "paused" | "ended"`, no `"blocked"`). The component does not
2080
2716
  change `playing` by itself: update that prop in response to your UI, click
2081
2717
  handler, `onEnded`, or playback error.
2082
2718
 
@@ -2088,7 +2724,9 @@ state. On source change or unmount, the element is paused and its texture is
2088
2724
  released. `playsInline` defaults to `true` and uses the same inline-playback
2089
2725
  attributes as `PanoVideo`. The element is mounted inside the viewer, hidden,
2090
2726
  so mobile browsers keep it in the page. `muted` defaults to `true`, `loop` to
2091
- `false`, and `preload` to `"metadata"`.
2727
+ `false`, `preload` to `"metadata"` (also `"none"` | `"auto"`), and `volume` to
2728
+ `1` (0–1). Omitted size defaults to `16×9`. Cross-origin media may set
2729
+ `crossOrigin` (`""` | `"anonymous"` | `"use-credentials"`).
2092
2730
 
2093
2731
  ```tsx
2094
2732
  import { VideoHotspot } from "@ericchen1990/pano-view";
@@ -2117,9 +2755,10 @@ Browsers can reject unmuted or otherwise non-gesture playback. In that case
2117
2755
  the browser error; retain control of `playing` in the host and offer an
2118
2756
  explicit user action.
2119
2757
 
2120
- Video reports media and poster failures through `onError`. Like
2121
- `SequenceHotspot`, it does not change `playing` by itself. This keeps source
2122
- changes, unmounts, and React StrictMode lifecycles deterministic.
2758
+ Video reports media and poster failures through `onError`, including
2759
+ `source: "poster" | "video"`. Like `SequenceHotspot`, it does not change
2760
+ `playing` by itself. This keeps source changes, unmounts, and React StrictMode
2761
+ lifecycles deterministic.
2123
2762
 
2124
2763
  ## AudioHotspot
2125
2764
 
@@ -2170,19 +2809,26 @@ import { AudioHotspot } from "@ericchen1990/pano-view";
2170
2809
  ```
2171
2810
 
2172
2811
  Like the other media hotspots, `AudioHotspot` does not change `playing` by
2173
- itself.
2812
+ itself. Omitted marker size defaults to `8×8`. `crossOrigin` is also accepted;
2813
+ load failures go to `onError({ id, error })`, distinct from `onPlaybackError`.
2174
2814
 
2175
2815
  ## TextHotspot
2176
2816
 
2177
2817
  `TextHotspot` rasterizes plain text onto a canvas texture. It does not accept
2178
2818
  HTML or Markdown. `\n` is a hard line break; `whiteSpace="normal"` (default)
2179
- wraps words to the hotspot's angular width. Dimensions stay in degrees like
2180
- the other point hotspots. Typography uses the same names as CSS
2181
- (`fontFamily`, `fontSize`, `fontWeight`, `fontStyle`) but is painted onto the
2182
- canvas, not applied as DOM style. `fontSize` is canvas pixels (default 96) and
2183
- does not scale with panel height. `fontWeight` defaults to `600` and
2184
- `fontStyle` to `"normal"`. `padding` and `borderRadius` remain fractions of
2185
- the texture's shorter side, so they still follow `width` / `height`.
2819
+ wraps words to the hotspot's angular width; `"nowrap"` is also accepted.
2820
+ Dimensions stay in degrees like the other point hotspots; the default panel is
2821
+ `16×6`. Typography uses the same names as CSS (`fontFamily`, `fontSize`,
2822
+ `fontWeight`, `fontStyle`) but is painted onto the canvas, not applied as DOM
2823
+ style. `fontSize` is canvas pixels (default 96) and does not scale with panel
2824
+ height. `fontWeight` defaults to `600` and `fontStyle` to `"normal"`. `align`
2825
+ defaults to `"center"` (`left` | `center` | `right`); `verticalAlign` defaults
2826
+ to `"middle"` (`top` | `middle` | `bottom`). `color` defaults to `#f8fafc`;
2827
+ `background` defaults to `#111827` (empty string skips the panel fill);
2828
+ `backgroundOpacity` defaults to `0.72`. `padding` defaults to `0.08` and
2829
+ `borderRadius` to `0.08` (fractions of the shorter side, `0`–`0.5`).
2830
+ `lineHeight` defaults to `1.25` (multiple of font size). `stroke` /
2831
+ `strokeWidth` draw an outline when `strokeWidth > 0` (default width `0`).
2186
2832
 
2187
2833
  ```tsx
2188
2834
  import { TextHotspot } from "@ericchen1990/pano-view";
@@ -2241,8 +2887,10 @@ interactive and blocks dragging through the iframe. A hotspot
2241
2887
  `pointerEvents="none"` also forces the iframe overlay not to capture pointer
2242
2888
  events. `sandbox` defaults to
2243
2889
  `allow-scripts allow-popups allow-forms` without `allow-same-origin`.
2244
- `referrerPolicy` defaults to `strict-origin-when-cross-origin`. Prefer a
2245
- same-origin document; many third-party sites send `X-Frame-Options` and will
2890
+ `referrerPolicy` defaults to `strict-origin-when-cross-origin`. Also accepted:
2891
+ `title` (falls back to `ariaLabel`), `allow`, `loading` (`"eager"` | `"lazy"`),
2892
+ `background`, and `onLoad` / `onError`. Omitted size defaults to `18×12`. Prefer
2893
+ a same-origin document; many third-party sites send `X-Frame-Options` and will
2246
2894
  not load.
2247
2895
 
2248
2896
  ## Next.js and SSR