@ericchen1990/pano-view 3.9.0 → 4.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
@@ -230,17 +230,18 @@ Store this content structure in `project.json`; store media as separate files. P
230
230
 
231
231
  ```tsx
232
232
  import { useState } from "react";
233
- import { PanoViewer, ProjectScenes, type LoadedPanoProject, type ProjectPopupDefinition } from "@ericchen1990/pano-view";
233
+ import { PanoOverlay, PanoViewer, ProjectScenes, type LoadedPanoProject, type ProjectPopupDefinition } from "@ericchen1990/pano-view";
234
234
  import { ProjectPopupDialog } from "@ericchen1990/pano-view/ui";
235
235
  import "@ericchen1990/pano-view/ui/styles.css";
236
236
 
237
237
  export function Tour({ project }: { project: LoadedPanoProject }) {
238
238
  const [popup, setPopup] = useState<ProjectPopupDefinition | null>(null);
239
- return <PanoViewer renderOverlay={() => (
240
- <ProjectPopupDialog popup={popup} onOpenChange={(open) => { if (!open) setPopup(null); }} />
241
- )}>
239
+ return <PanoViewer>
242
240
  <ProjectScenes project={project} onPopupRequest={({ popup }) => setPopup(popup)}
243
241
  onActiveSceneIdChange={() => setPopup(null)} />
242
+ <PanoOverlay>
243
+ <ProjectPopupDialog popup={popup} onOpenChange={(open) => { if (!open) setPopup(null); }} />
244
+ </PanoOverlay>
244
245
  </PanoViewer>;
245
246
  }
246
247
  ```
@@ -258,12 +259,14 @@ An optional project-level `map` places scene spots on one floor image. Projects
258
259
  "maxScale": 4,
259
260
  "iconSize": 28,
260
261
  "presentation": {
261
- "mode": "docked",
262
- "corner": "bottom-left",
262
+ "mode": "panel",
263
+ "anchor": {
264
+ "align": "bottom-left",
265
+ "axis": "horizontal",
266
+ "inset": 16
267
+ },
263
268
  "width": 240,
264
- "height": 180,
265
- "offsetX": 16,
266
- "offsetY": 16
269
+ "height": 180
267
270
  },
268
271
  "controls": { "collapse": true, "zoom": true },
269
272
  "sector": {
@@ -289,70 +292,70 @@ An optional project-level `map` places scene spots on one floor image. Projects
289
292
 
290
293
  `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.
291
294
 
292
- `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.
295
+ `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 an edge tab that slides a panel off-screen. `iconSize` is the marker edge in CSS pixels, default 28. Icons and the sector keep that screen size while the floor image zooms.
293
296
 
294
297
  `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.
295
298
 
296
- `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.
299
+ `presentation.mode` is `"panel"` (the default) or `"dialog"`. Both accept `anchor`, plus `width` and `height`. `anchor` is the shared overlay anchor: `align` is `top`, `bottom`, `left`, `right`, or one of the four corners (default `bottom-left`); `axis` is `"horizontal"` or `"vertical"` and applies only to corners (default `"horizontal"`); `inset` is a number, a CSS length string, or `{ top, right, bottom, left }` (default `16`). Safe-area insets are added on top. A panel defaults to 220×160. Dialog mode places 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. Edges center a sized box on that edge. Corners pin to both adjacent edges. Collapsing keeps the panel size and slides it off the outer edge: edges slide toward that edge, and corners slide vertically when `axis` is horizontal or horizontally when `axis` is vertical. The tab stays on the inner side of that edge.
297
300
 
298
- 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.
301
+ 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`. Panel collapse state is owned by the overlay; dialog mode and `editorSurface` force the edge tab off. `PanoMap`'s own `collapsed` prop still hides the surface inside a host-owned frame.
299
302
 
300
303
  #### Mounting `PanoMapOverlay`
301
304
 
302
- Mount it from `PanoViewer.renderOverlay` 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.
305
+ 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.
303
306
 
304
- `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.
307
+ `PanoMap` also accepts controlled selection and collapse: `selectedSpotId` / `onSelectedSpotIdChange` (internal selection defaults to `null`); `collapsed` / `onCollapsedChange` (uncontrolled starts expanded and hides the surface inside the host frame); `renderChrome` to draw host controls above the surface. On `PanoMapOverlay`, `showCollapseControl` shows the shared edge tab instead. `className` / `style` attach to the surface root.
305
308
 
306
309
  ```tsx
307
310
  import { useState } from "react";
308
311
  import {
309
- PanoViewer, ProjectScenes, PanoMapOverlay, type LoadedPanoProject,
312
+ PanoOverlay, PanoViewer, ProjectScenes, PanoMapOverlay, type LoadedPanoProject,
310
313
  } from "@ericchen1990/pano-view";
311
314
  import "@ericchen1990/pano-view/styles.css";
312
315
 
313
316
  export function Tour({ project }: { project: LoadedPanoProject }) {
314
317
  const [activeSceneId, setActiveSceneId] = useState(project.initialSceneId);
315
318
  const map = project.map;
316
- return <PanoViewer style={{ height: 600 }} renderOverlay={map ? () => (
317
- <PanoMapOverlay
318
- map={map}
319
- activeSceneId={activeSceneId}
320
- onSelectScene={setActiveSceneId}
321
- />
322
- ) : undefined}>
319
+ return <PanoViewer style={{ height: 600 }}>
323
320
  <ProjectScenes
324
321
  project={project}
325
322
  activeSceneId={activeSceneId}
326
323
  onActiveSceneIdChange={setActiveSceneId}
327
324
  />
325
+ {map ? (
326
+ <PanoOverlay>
327
+ <PanoMapOverlay
328
+ map={map}
329
+ activeSceneId={activeSceneId}
330
+ onSelectScene={setActiveSceneId}
331
+ />
332
+ </PanoOverlay>
333
+ ) : null}
328
334
  </PanoViewer>;
329
335
  }
330
336
  ```
331
337
 
332
- #### Size, corner, and inset
338
+ #### Size, align, and inset
333
339
 
334
- - `presentation` — `"docked"` | `"dialog"` (defaults from the document, otherwise `"docked"`).
335
- - `corner` — `"top-left"` | `"top-right"` | `"bottom-left"` | `"bottom-right"` (default `"bottom-left"`).
336
- - `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.
337
- - `offsetX` / `offsetY` — inset from the chosen corner in CSS pixels (default 16).
340
+ - `presentation` — `"panel"` | `"dialog"` (defaults from the document, otherwise `"panel"`).
341
+ - `anchor` — shared `PanoOverlayAnchor`. `align` is one of eight positions (default `"bottom-left"`). `axis` is used for corners. `inset` defaults to `16`.
342
+ - `width` / `height` — panel size, or the dialog launcher size, in CSS pixels. Panel defaults to 220×160; the dialog launcher defaults to 48×48.
338
343
 
339
344
  ```tsx
340
345
  <PanoMapOverlay
341
346
  map={map}
342
347
  activeSceneId={activeSceneId}
343
348
  onSelectScene={setActiveSceneId}
344
- presentation="docked"
345
- corner="bottom-right"
349
+ presentation="panel"
350
+ anchor={{ align: "bottom-right", axis: "vertical", inset: 20 }}
346
351
  width={280}
347
352
  height={200}
348
- offsetX={20}
349
- offsetY={20}
350
353
  />
351
354
  ```
352
355
 
353
356
  #### Dialog mode
354
357
 
355
- 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:
358
+ Dialog mode places a launcher button and opens the map in a viewer-local modal. Launcher size uses `width` / `height`, and the launcher uses the same `anchor`. The dialog body uses a separate pair:
356
359
 
357
360
  - `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.
358
361
  - `openLabel` / `closeLabel` / `title` — launcher accessible name, close button label, and dialog heading (defaults `"Map"`, `"Close"`, `"Map"`).
@@ -365,7 +368,7 @@ Choosing a spot closes the dialog.
365
368
  activeSceneId={activeSceneId}
366
369
  onSelectScene={setActiveSceneId}
367
370
  presentation="dialog"
368
- corner="bottom-left"
371
+ anchor={{ align: "bottom-left", inset: 16 }}
369
372
  width={52}
370
373
  height={52}
371
374
  dialogWidth={640}
@@ -401,6 +404,36 @@ These props override the map document for the current mount:
401
404
  />
402
405
  ```
403
406
 
407
+ `toggle` replaces the panel edge-tab icon or restyles that button. Dialog mode has no edge tab. Omit `toggle` to keep the default chevron. Pass an element (`<Icon />`) for `icon`, not a component type. When the glyph depends on the collapsed state, pass a function of `{ collapsed }`. `className` is appended to the button. `style` overrides background, border, color, and size. Position and `transform` belong to the edge layout; do not set them in `style`. The default chevron uses `currentColor`, so `style.color` recolors it without replacing the icon.
408
+
409
+ ```tsx
410
+ <PanoMapOverlay
411
+ map={map}
412
+ activeSceneId={activeSceneId}
413
+ onSelectScene={setActiveSceneId}
414
+ showCollapseControl
415
+ toggle={{ style: { color: "#7dd3fc" } }}
416
+ />
417
+ ```
418
+
419
+ ```tsx
420
+ <PanoMapOverlay
421
+ map={map}
422
+ activeSceneId={activeSceneId}
423
+ onSelectScene={setActiveSceneId}
424
+ showCollapseControl
425
+ toggle={{
426
+ className: "map-tab",
427
+ style: {
428
+ background: "#0c4a6e",
429
+ color: "#7dd3fc",
430
+ border: "1px solid #7dd3fc",
431
+ },
432
+ icon: ({ collapsed }) => (collapsed ? "+" : "–"),
433
+ }}
434
+ />
435
+ ```
436
+
404
437
  #### Edit mode
405
438
 
406
439
  `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.
@@ -679,14 +712,15 @@ Presets and hotspot rendering are in [Scenes](#scenes).
679
712
  ### Events and chrome
680
713
 
681
714
  - [`PanoEvents`](#panoevents) — viewer-level lifecycle and interaction callbacks
715
+ - [`PanoOverlay`](#dom-overlays-and-dialogs) — screen-plane DOM wrapper for fullscreen-safe UI
682
716
  - [`PanoContextMenu`](#panocontextmenu) — right-click reset / fullscreen menu
683
- - [`PanoSceneMenu`](#panoscenemenu) — scrollable thumbnail scene picker for `renderOverlay`
717
+ - [`PanoSceneMenu`](#panoscenemenu) — scrollable thumbnail scene picker for `PanoOverlay`
684
718
  - [`PanoVideoControls`](#panovideo) — playback chrome for `PanoVideo` (mounted by default)
685
719
 
686
720
  ### Floor plan
687
721
 
688
722
  - [`PanoMap`](#floor-plan-maps) — floor image, scene spots, and the yaw-linked sector
689
- - [`PanoMapOverlay`](#floor-plan-maps) — docked panel or dialog launcher for `renderOverlay`
723
+ - [`PanoMapOverlay`](#floor-plan-maps) — panel or dialog launcher for `PanoOverlay`
690
724
 
691
725
  ### Hotspots
692
726
 
@@ -711,7 +745,25 @@ Presets and hotspot rendering are in [Scenes](#scenes).
711
745
 
712
746
  ## DOM overlays and dialogs
713
747
 
714
- Use `PanoViewer.renderOverlay` for ordinary DOM UI outside the Three.js tree. Call `usePanoOverlay()` inside the `renderOverlay` subtree 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.
748
+ 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.
749
+
750
+ ```tsx
751
+ <PanoViewer style={{ height: 560 }}>
752
+ <Sphere src="/pano.jpg" />
753
+ <PanoOverlay>
754
+ <PanoSceneMenu items={items} activeId={id} onSelect={setId} />
755
+ <button
756
+ type="button"
757
+ style={{ position: "absolute", right: 16, bottom: 16, pointerEvents: "auto" }}
758
+ onClick={() => void viewer.toggleFullscreen()}
759
+ >
760
+ Fullscreen
761
+ </button>
762
+ </PanoOverlay>
763
+ </PanoViewer>
764
+ ```
765
+
766
+ 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.
715
767
 
716
768
  ### Default dialog
717
769
 
@@ -798,7 +850,7 @@ which use their spherical center. Hotspot ids must be unique within a viewer.
798
850
 
799
851
  **Scenes** (requires `<Scenes />`) — `setScene(id)`, `nextScene`, `previousScene`, `getActiveSceneId`, `getSceneIds`, `isSceneTransitioning`. `Scenes` accepts `defaultActiveSceneId` (uncontrolled) or `activeSceneId` (controlled). In controlled mode imperative calls invoke `onActiveSceneIdChange`.
800
852
 
801
- **WebVR** (requires `<WebVR />`) — `enterVR`, `exitVR`, `toggleVR`, `isVRAvailable`, `isVREnabled`, `getVRMode`, `requestVRPermission`.
853
+ **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.
802
854
 
803
855
  **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`.
804
856
 
@@ -814,7 +866,7 @@ which use their spherical center. Hotspot ids must be unique within a viewer.
814
866
  - `cursors` — `true` / omit uses grab / grabbing / pointer / move; `false` leaves the canvas cursor unchanged; an object overrides those four states.
815
867
  - `contextMenu` — `true` / omit mounts Reset view + Fullscreen; `false` skips the default menu; an object can change appearance or `items` / `prepend` / `append`. See [PanoContextMenu](#panocontextmenu).
816
868
  - `mediaActivation` — enabled by default when a mounted child intends to play; `false` disables automatic activation.
817
- - `renderOverlay` — DOM UI outside the Three.js tree and inside fullscreen; the callback receives `portalContainer`, `size`, `visibleRect`, and `isFullscreen`.
869
+ - `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.
818
870
  - `onViewChange` — `{ yaw, pitch, fov }` in degrees.
819
871
  - `onPanoramaClick` / `onPanoramaDoubleClick` / `onPanoramaPointerMove` — panorama pointer events with `position` (yaw / pitch), `nativeEvent`, `button`, `buttons`, `clientX`, `clientY`, and optional `pointerId`.
820
872
  - Other standard `div` props (`className`, `style`, `id`, …) are accepted; conflicting `onChange` and `contextMenu` are omitted from the type.
@@ -913,25 +965,29 @@ function SceneExample() {
913
965
  ### WebVR
914
966
 
915
967
  `enterVR` and `requestVRPermission` must be called from a user gesture.
968
+ Drive entry from host UI with `getVRState` / `subscribeVR`; session chrome only
969
+ shows Exit VR and MobileVR Setup (which fade after a few idle seconds).
916
970
 
917
971
  ```tsx
972
+ import { useSyncExternalStore } from "react";
918
973
  import { PanoViewer, Sphere, WebVR, usePanoViewer } from "@ericchen1990/pano-view";
919
974
 
920
975
  function VRExample() {
921
976
  const viewer = usePanoViewer();
977
+ const vr = useSyncExternalStore(
978
+ viewer.subscribeVR,
979
+ viewer.getVRState,
980
+ viewer.getVRState,
981
+ );
922
982
 
923
983
  return (
924
984
  <>
925
985
  <button
926
- onClick={async () => {
927
- await viewer.requestVRPermission();
928
- await viewer.enterVR();
929
- }}
986
+ disabled={!vr.available}
987
+ onClick={() => void (vr.enabled ? viewer.exitVR() : viewer.enterVR())}
930
988
  >
931
- Enter VR
989
+ {vr.enabled ? "Exit VR" : "Enter VR"}
932
990
  </button>
933
- <button onClick={() => void viewer.exitVR()}>Exit VR</button>
934
- <button onClick={() => console.log(viewer.getVRMode())}>Mode?</button>
935
991
  <PanoViewer ref={viewer.ref} style={{ height: 560 }}>
936
992
  <WebVR />
937
993
  <Sphere src="/panoramas/room.webp" previewUrl="preview.webp" />
@@ -1470,8 +1526,8 @@ Scene `viewLimits` are covered in the FAQ; `yawOffset` matches the source compon
1470
1526
  ## PanoSceneMenu
1471
1527
 
1472
1528
  `PanoSceneMenu` is a scrollable thumbnail strip for switching scenes. Mount it
1473
- from `PanoViewer.renderOverlay` so it stays inside the viewer (including
1474
- fullscreen). Import `@ericchen1990/pano-view/styles.css` for the default layout.
1529
+ inside `PanoOverlay` so it stays inside the viewer (including fullscreen).
1530
+ Import `@ericchen1990/pano-view/styles.css` for the default layout.
1475
1531
 
1476
1532
  The menu is host-driven: you pass `items`, `activeId`, and `onSelect`. It does
1477
1533
  not read `Scenes` state or scene `previewUrl`. Cube `previewUrl` values are
@@ -1485,6 +1541,7 @@ Keep one controlled scene id and share it with `Scenes`:
1485
1541
  ```tsx
1486
1542
  import { useState } from "react";
1487
1543
  import {
1544
+ PanoOverlay,
1488
1545
  PanoSceneMenu,
1489
1546
  PanoViewer,
1490
1547
  Scenes,
@@ -1509,21 +1566,19 @@ export function Tour() {
1509
1566
  const [activeSceneId, setActiveSceneId] = useState("lobby");
1510
1567
 
1511
1568
  return (
1512
- <PanoViewer
1513
- style={{ height: 560 }}
1514
- renderOverlay={() => (
1515
- <PanoSceneMenu
1516
- activeId={activeSceneId}
1517
- items={menuItems}
1518
- onSelect={setActiveSceneId}
1519
- />
1520
- )}
1521
- >
1569
+ <PanoViewer style={{ height: 560 }}>
1522
1570
  <Scenes
1523
1571
  scenes={scenes}
1524
1572
  activeSceneId={activeSceneId}
1525
1573
  onActiveSceneIdChange={setActiveSceneId}
1526
1574
  />
1575
+ <PanoOverlay>
1576
+ <PanoSceneMenu
1577
+ activeId={activeSceneId}
1578
+ items={menuItems}
1579
+ onSelect={setActiveSceneId}
1580
+ />
1581
+ </PanoOverlay>
1527
1582
  </PanoViewer>
1528
1583
  );
1529
1584
  }
@@ -1535,18 +1590,21 @@ active item gets a selection ring above the thumbnail and `data-active="true"`.
1535
1590
  Scene names sit on a dark translucent bar at the bottom of each thumbnail;
1536
1591
  use `appearance.labelAlign` for `"center"` or `"end"` alignment.
1537
1592
 
1538
- ### Placement, inset, and size
1593
+ ### Align, inset, and size
1594
+
1595
+ `anchor` is the shared `PanoOverlayAnchor` used by the scene menu and the floor-plan map.
1539
1596
 
1540
- - `placement` — `"top"` | `"bottom"` | `"left"` | `"right"` (default `"bottom"`).
1541
- Top/bottom render a horizontal strip; left/right render a vertical strip.
1542
- - `align` — `"start"` | `"center"` | `"end"` (default `"center"`). When the menu
1543
- is smaller than the available dock space, aligns it along that edge. Short
1544
- lists are also aligned inside the strip the same way.
1545
- - `inset` — gap from the viewer edges (default `12`). A number is pixels; a
1597
+ - `anchor.align` — `"top"` | `"bottom"` | `"left"` | `"right"` | `"top-left"` | `"top-right"` | `"bottom-left"` | `"bottom-right"` (default `"bottom"`).
1598
+ Top and bottom are horizontal strips. Left and right are vertical strips.
1599
+ A corner uses `anchor.axis`.
1600
+ - `anchor.axis` — `"horizontal"` | `"vertical"`. Edges ignore it. Corners default to `"horizontal"`: a horizontal strip slides up or down, and a vertical strip slides left or right.
1601
+ - `anchor.inset` — gap from the viewer edges (default `12`). A number is pixels; a
1546
1602
  string keeps units such as `"1rem"`; an object sets `top` / `right` /
1547
- `bottom` / `left` independently.
1603
+ `bottom` / `left` independently. Safe-area insets are added.
1548
1604
  - `width` / `height` — menu size. A number is pixels; a string is used as CSS as-is,
1549
1605
  including `%`, `rem`, `calc()`, `min()`, `max()`, and `clamp()`.
1606
+ On an edge, a set main-axis size is centered and a missing one stretches between the cross-axis insets. A corner pins to both edges and shrinks to content when a size is omitted.
1607
+ Short lists pack toward the corner, or stay centered on an edge.
1550
1608
  - `dragMotion` — scroll inertia and edge bounce. Omit for both on (custom pointer
1551
1609
  scrolling, including desktop drag). Set `false` for native overflow scroll.
1552
1610
  Pass `{ inertia?: boolean; bounce?: boolean }` to toggle either flag. A short
@@ -1554,9 +1612,7 @@ use `appearance.labelAlign` for `"center"` or `"end"` alignment.
1554
1612
 
1555
1613
  ```tsx
1556
1614
  <PanoSceneMenu
1557
- placement="bottom"
1558
- align="center"
1559
- inset={16}
1615
+ anchor={{ align: "bottom", inset: 16 }}
1560
1616
  width="min(720px, 100%)"
1561
1617
  items={menuItems}
1562
1618
  activeId={activeSceneId}
@@ -1564,9 +1620,11 @@ use `appearance.labelAlign` for `"center"` or `"end"` alignment.
1564
1620
  />
1565
1621
 
1566
1622
  <PanoSceneMenu
1567
- placement="right"
1568
- align="start"
1569
- inset={{ top: 24, right: 12, bottom: 24, left: 12 }}
1623
+ anchor={{
1624
+ align: "top-left",
1625
+ axis: "vertical",
1626
+ inset: { top: 24, right: 12, bottom: 24, left: 12 },
1627
+ }}
1570
1628
  height="70%"
1571
1629
  width={160}
1572
1630
  items={menuItems}
@@ -1575,8 +1633,8 @@ use `appearance.labelAlign` for `"center"` or `"end"` alignment.
1575
1633
  />
1576
1634
  ```
1577
1635
 
1578
- If you omit `width` on a top/bottom menu, the strip stretches between the left
1579
- and right insets. If you omit `height` on a left/right menu, it stretches between
1636
+ If you omit `width` on a top or bottom menu, the strip stretches between the left
1637
+ and right insets. If you omit `height` on a left or right menu, it stretches between
1580
1638
  the top and bottom insets. The menu also applies a max size that subtracts
1581
1639
  `inset` and safe-area insets, so `100%` still stays inside the viewer.
1582
1640
 
@@ -1585,9 +1643,8 @@ Use `style` when you need one-off positioning overrides on top of these props.
1585
1643
  ### Collapsible menu and arrows
1586
1644
 
1587
1645
  By default the menu stays open. Set `collapsible` to show a toggle centered on
1588
- the inner edge of the strip (above a bottom menu, below a top menu, toward the
1589
- viewer for left/right). Collapsing slides the list off-screen along the dock
1590
- edge and leaves only the expand button on the safe-area edge:
1646
+ the inner side of the collapse edge. Collapsing slides the list off-screen by
1647
+ its size plus that edge's inset, and leaves the expand button on the viewer edge:
1591
1648
 
1592
1649
  ```tsx
1593
1650
  <PanoSceneMenu
@@ -1623,7 +1680,7 @@ via `label`. When collapsible, `defaultExpanded` defaults to `true`.
1623
1680
  showArrows
1624
1681
  loop
1625
1682
  collapsible
1626
- placement="bottom"
1683
+ anchor={{ align: "bottom" }}
1627
1684
  width="min(720px, 100%)"
1628
1685
  items={menuItems}
1629
1686
  activeId={activeSceneId}
@@ -1677,6 +1734,43 @@ The root also exposes `data-placement`, `data-align`, `data-orientation`
1677
1734
  (`horizontal` | `vertical`), `data-expanded`, and `data-collapsible`. The active
1678
1735
  item has `data-active="true"`.
1679
1736
 
1737
+ When `collapsible` is set, `toggle` replaces the edge-tab icon or restyles that
1738
+ button. Omit `toggle` to keep the default chevron. Pass an element (`<Icon />`)
1739
+ for `icon`, not a component type. When the glyph depends on the collapsed
1740
+ state, pass a function of `{ collapsed }` (`collapsed` is `false` while the
1741
+ menu is expanded). `className` is appended to the button. `style` overrides
1742
+ background, border, color, and size. Position and `transform` belong to the
1743
+ edge layout; do not set them in `style`. The default chevron uses
1744
+ `currentColor`, so `style.color` recolors it without replacing the icon.
1745
+
1746
+ ```tsx
1747
+ <PanoSceneMenu
1748
+ collapsible
1749
+ toggle={{ style: { color: "#7dd3fc" } }}
1750
+ items={menuItems}
1751
+ activeId={activeSceneId}
1752
+ onSelect={setActiveSceneId}
1753
+ />
1754
+ ```
1755
+
1756
+ ```tsx
1757
+ <PanoSceneMenu
1758
+ collapsible
1759
+ toggle={{
1760
+ className: "scene-tab",
1761
+ style: {
1762
+ background: "#0c4a6e",
1763
+ color: "#7dd3fc",
1764
+ border: "1px solid #7dd3fc",
1765
+ },
1766
+ icon: ({ collapsed }) => (collapsed ? "+" : "–"),
1767
+ }}
1768
+ items={menuItems}
1769
+ activeId={activeSceneId}
1770
+ onSelect={setActiveSceneId}
1771
+ />
1772
+ ```
1773
+
1680
1774
  ## PanoFilter
1681
1775
 
1682
1776
  Render `PanoFilter` inside `PanoViewer` to color-grade or stylize the panorama
@@ -1908,8 +2002,9 @@ These are props on `Gyro` itself. `PanoEvents` `onGyro*` callbacks subscribe to
1908
2002
  `WebVR` adds immersive viewing to the nearest `PanoViewer`. It prefers an
1909
2003
  `immersive-vr` WebXR session on supported headsets. On phones without WebXR it
1910
2004
  uses a stereoscopic split-screen view with device orientation, and on desktop
1911
- it can provide a mouse-driven simulated VR preview. The built-in chrome includes
1912
- Enter VR, Exit VR, and MobileVR setup controls.
2005
+ it can provide a mouse-driven simulated VR preview. Host apps provide Enter VR
2006
+ through the viewer handle; the built-in chrome only shows Exit VR and MobileVR
2007
+ setup controls while a session is active (they fade after idle interaction).
1913
2008
 
1914
2009
  ```tsx
1915
2010
  import { useRef } from "react";
@@ -1939,19 +2034,28 @@ export function WebVRExample() {
1939
2034
  ```
1940
2035
 
1941
2036
  The handle exposes `enterVR()`, `exitVR()`, `toggleVR()`, `isAvailable()`,
1942
- `isEnabled()`, `getMode()`, and `requestPermission()`. Modes are `"webxr"`,
1943
- `"mobilevr"`, and `"fake"`. `profile` accepts `"none"`, `"cardboard-v1"`,
1944
- `"cardboard-v2"`, `"gear-vr"`, `"daydream"`, or a custom
1945
- `{ fov, ipdMm, k1, k2 }` lens profile. MobileVR setup values are versioned and
1946
- stored in the current origin's `localStorage`. In VR, a center reticle shows a
1947
- crosshair; looking at an interactive hotspot draws a clockwise ring that fills
1948
- over `cursorDwellMs` (default 1500), then the hotspot click handler runs. Set
1949
- `cursorDwellMs={0}` to keep the reticle without auto-click. Simulated desktop
1950
- VR (`fake` mode) requests pointer lock by default (`mousePointerLock`) so the
1951
- cursor hides and mouse movement looks around; click the view again if the
1952
- browser releases the lock. Fallback sessions keep the screen awake
1953
- (`wakelock`) with the Screen Wake Lock API, or a looping hidden video on
1954
- browsers that lack it.
2037
+ `isEnabled()`, `getMode()`, `getState()`, `subscribe()`, `requestPermission()`,
2038
+ and `resetSettings()`. Modes are `"webxr"`, `"mobilevr"`, and `"fake"`.
2039
+ `profile` accepts `"none"`, `"cardboard-v1"`, `"cardboard-v2"`, `"gear-vr"`,
2040
+ `"daydream"`, or a custom `{ fov, ipdMm, k1, k2, screenToLensMm?, trayDistanceMm? }`
2041
+ lens profile. When the physical screen size is known, MobileVR places each eye's
2042
+ optical axis with the inter-lens distance and optional tray-to-lens height
2043
+ (`trayDistanceMm`; Cardboard V1 uses `35`). `0` or omitted keeps the vertical
2044
+ center. When `screensize` is `"auto"`, MobileVR loads the Cardboard Device
2045
+ Parameter Database (`databaseUrl`, default Google's public DPDB) to estimate
2046
+ diagonal inches; pass `""` or `null` to skip the request. Unknown devices fire
2047
+ `onUnknownDevice` and keep the lens-profile FOV (no tray offset until a size is
2048
+ known). MobileVR setup values are versioned and stored in the current origin's
2049
+ `localStorage`; `resetSettings()` clears that storage and restores prop or
2050
+ built-in defaults (also available from the Setup panel Reset control). In VR, a
2051
+ center reticle shows a crosshair; looking at an interactive hotspot draws a
2052
+ clockwise ring that fills over `cursorDwellMs` (default 1500), then the hotspot
2053
+ click handler runs. Set `cursorDwellMs={0}` to keep the reticle without
2054
+ auto-click. Simulated desktop VR (`fake` mode) requests pointer lock by default
2055
+ (`mousePointerLock`) so the cursor hides and mouse movement looks around; click
2056
+ the view again if the browser releases the lock. Fallback sessions keep the
2057
+ screen awake (`wakelock`) with the Screen Wake Lock API, or a looping hidden
2058
+ video on browsers that lack it.
1955
2059
 
1956
2060
  WebXR requires HTTPS and a user gesture. Mobile sensor access also requires
1957
2061
  HTTPS and may require an explicit iOS permission prompt. Embedded viewers need
@@ -1962,19 +2066,27 @@ interactive through the default WebXR controller rays.
1962
2066
 
1963
2067
  ### Props
1964
2068
 
1965
- - `chrome` — default `true` mounts Enter / Exit / Setup UI. An appearance object may set `background` (default `rgba(16, 24, 32, 0.78)`), `color` (`#ffffff`), `accent` (`#7dd3fc`), `borderRadius` (`6`), `fontSize` (`14`), and optional `setupTitle`.
2069
+ - `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.
1966
2070
  - `mobileVr` — default `true`.
1967
2071
  - `desktopSupport` — default `false` (independent of the same-named `Gyro` prop).
1968
2072
  - `fakeSupport` — default `true` for mouse-driven desktop simulation.
1969
2073
  - `screensize` — `"auto"` (factory / omitted storage default) or inches; numbers are clamped to **3–20** with one decimal place.
1970
- - `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?, label?, id? }`.
2074
+ - `databaseUrl` — Cardboard DPDB URL for `"auto"` screensize. Default `https://storage.googleapis.com/cardboard-dpdb/dpdb.json`. Pass `""` or `null` to disable the online lookup.
2075
+ - `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.
2076
+ - `absolute` — default `false`. Compass-oriented MobileVR heading (no effect in WebXR).
2077
+ - `north` — default `0`. Panorama yaw that represents North when `absolute` is enabled.
2078
+ - `friction` — default `0` (range `0–0.99`). MobileVR gyro smoothing; keep `0` for normal viewing.
2079
+ - `touchSupport` — default `false`. When `true`, allows horizontal touch dragging to correct MobileVR heading (`horizontaloffset`). Disabled by default because headset trays can trigger false touches.
2080
+ - `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).
2081
+ - `foveation` — optional WebXR fixed foveation in **0–1**. Omit to keep the device default; `0` is full resolution, `1` is maximum edge downsampling.
2082
+ - `frameRate` — optional WebXR target rate: `"low" | "mid" | "high"` or a numeric Hz (closest supported rate). Omit to keep the store default (`"high"`).
1971
2083
  - `cursor` — default `true`; `false` hides the center reticle.
1972
2084
  - `cursorDwellMs` — default `1500`; `0` disables auto-click.
1973
2085
  - `wakelock` / `mousePointerLock` — both default to `true`.
1974
2086
  - `onAvailable` / `onUnavailable` — VR capability changes.
1975
2087
  - `onEnterVR` / `onExitVR` — mode `"webxr" | "mobilevr" | "fake"`.
1976
2088
  - `onDenied` — permission or session denial, optional `error`.
1977
- - `onUnknownDevice` — when screen / lens parameters cannot be identified.
2089
+ - `onUnknownDevice` — when `"auto"` screensize cannot be resolved from the DPDB (disabled URL, no match, or fetch failure).
1978
2090
 
1979
2091
  ## Mouse, touch, and keyboard controls
1980
2092