diff --git a/.cursor/rules/ls-foundry-core.mdc b/.cursor/rules/ls-foundry-core.mdc index 9f52a68..aa4b1f5 100644 --- a/.cursor/rules/ls-foundry-core.mdc +++ b/.cursor/rules/ls-foundry-core.mdc @@ -21,7 +21,7 @@ packages/ # publishable + internal packages | Scope | Use | Examples | |-------|-----|----------| -| `@jeffgo10/*` | **Published** to GitHub Packages (`npm.pkg.github.com`) | `shared-types`, `react-canvas-designer`, `canvas-upscaler`, `three-d-label-customizer`, `helpers`, `gl-viewer` (v1.0.0; consumed by **LiteShadeMedia**), `panorama-viewer` (v0.1.2; consumed by **Vantage**) | +| `@jeffgo10/*` | **Published** to GitHub Packages (`npm.pkg.github.com`) | `shared-types`, `react-canvas-designer`, `canvas-upscaler`, `three-d-label-customizer`, `helpers`, `gl-viewer` (v1.0.0; consumed by **LiteShadeMedia**), `panorama-viewer` (v0.2.0; consumed by **Vantage**) | | `@ls-foundry/*` | **Private** monorepo-only | `docs`, `tsconfig`, `ui`, `utils`, `maps` | Publish scope must match GitHub owner (`@jeffgo10`). Bump versions in `packages/*/package.json` before merge so CI can publish. @@ -58,7 +58,7 @@ Always bump `shared-types` first when changing layout/DPI APIs; keep dependent p | `@jeffgo10/history` | 1.0.0 | `packages/history/` — generic undo/redo snapshot stacks | | `@jeffgo10/three-d-label-customizer` | 0.1.0 | `docs/three-d-label-customizer/` | | `@jeffgo10/gl-viewer` | 1.0.0 | `packages/gl-viewer/` (consumer: **LiteShadeMedia**) | -| `@jeffgo10/panorama-viewer` | 0.1.2 | `docs/panorama-viewer/` (consumer: **Vantage**) | +| `@jeffgo10/panorama-viewer` | 0.2.0 | `docs/panorama-viewer/` (consumer: **Vantage**) | `three-d-label-customizer` is a **standalone** product mockup package. Obsidian: `LS Foundry/Notes — three-d-label-customizer`. diff --git a/apps/docs/src/app/panorama/page.tsx b/apps/docs/src/app/panorama/page.tsx index ab31878..c3ade8b 100644 --- a/apps/docs/src/app/panorama/page.tsx +++ b/apps/docs/src/app/panorama/page.tsx @@ -14,8 +14,8 @@ function PanoramaPage() {

Demo for{" "} @jeffgo10/panorama-viewer. - Upload an equirectangular 360 image, place navigation / info / label - hotspots in edit mode, and inspect package-owned content UI. + Upload an equirectangular 360 image, place or drag navigation / info / + label hotspots in edit mode, and inspect package-owned content UI.

- Click the sphere to place a hotspot of the selected kind. + Click the sphere to place a hotspot. Drag an existing hotspot to + reposition it on the 360 sphere.

) : null} @@ -214,11 +215,27 @@ function PanoramaViewerSection() { markers={markers} mode={mode} fitParent + orientationControl={mode === "view"} className="h-full min-h-0 w-full" onSphereClick={handleSphereClick} + onMarkerMove={(marker, pos) => { + setMarkers((prev) => + prev.map((m) => + m.id === marker.id + ? { ...m, yaw: pos.yaw, pitch: pos.pitch } + : m, + ), + ); + setLastEvent( + `Moved ${marker.kind} to yaw ${pos.yaw.toFixed(1)}, pitch ${pos.pitch.toFixed(1)}`, + ); + }} onMarkerClick={(m) => { setLastEvent(`Clicked ${m.kind} “${m.title ?? m.id}”`); }} + onOrientationChange={(active) => { + setLastEvent(active ? "Gyro on" : "Gyro off"); + }} /> diff --git a/docs/panorama-viewer/README.md b/docs/panorama-viewer/README.md index e5ad3a9..c6b817b 100644 --- a/docs/panorama-viewer/README.md +++ b/docs/panorama-viewer/README.md @@ -19,7 +19,7 @@ Platform-agnostic — no Supabase, R2, Next routes, or billing. Primary consumer | Item | Value | |------|-------| | npm | `@jeffgo10/panorama-viewer` | -| Version | `0.1.2` | +| Version | `0.2.0` | | Source | `packages/panorama-viewer/` | ## Browser demo @@ -38,8 +38,7 @@ See [packages/panorama-viewer/README.md](../../packages/panorama-viewer/README.m Issue/fix log: [engineering-notes.md](./engineering-notes.md) -## Follow-ups (not in 0.1.0) +## Follow-ups (not in 0.2.0) -- Drag-to-reposition markers in edit mode -- Gyroscope / VR - Multi-scene tour graph (stays in the consumer app) +- Host “Enable motion” chrome (RoamVantage / Vantage) — package only exposes control + events diff --git a/docs/panorama-viewer/engineering-notes.md b/docs/panorama-viewer/engineering-notes.md index a114c45..deb9210 100644 --- a/docs/panorama-viewer/engineering-notes.md +++ b/docs/panorama-viewer/engineering-notes.md @@ -1,6 +1,6 @@ # Panorama viewer — engineering notes -Running log for `@jeffgo10/panorama-viewer` (**v0.1.2**; first publish v0.1.0). +Running log for `@jeffgo10/panorama-viewer` (**v0.2.0**; first publish v0.1.0). Standalone ls-foundry package — not StickPak, not LiteShadeMedia / gl-viewer. Demo: `pnpm run dev --filter=@ls-foundry/docs` → `/panorama`. Obsidian: `LS Foundry/Notes — panorama-viewer`. @@ -9,23 +9,33 @@ Standalone ls-foundry package — not StickPak, not LiteShadeMedia / gl-viewer. | Item | Value | |------|-------| | npm | `@jeffgo10/panorama-viewer` | -| Version | `0.1.2` | +| Version | `0.2.0` | | Source | `packages/panorama-viewer/` | | Engine | Pannellum `^2.5.7` (npm assets, not CDN) | ## Architecture (v0.1.0) -1. **`PanoramaViewer`** — client-only React component; loads `pannellum/build/pannellum.js`, destroys on unmount, remounts when `imageUrl` or marker geometry changes. +1. **`PanoramaViewer`** — client-only React component; loads `pannellum/build/pannellum.js`, destroys on unmount, remounts when `imageUrl` or marker **structure** changes. 2. **`markersToHotSpots`** — pure mapper from `PanoramaMarker[]` to Pannellum `hotSpots` with `createTooltipFunc` for package-owned content DOM. 3. **Styles** — `dist/styles.css` bundles Pannellum CSS + pin/content classes; consumers import `@jeffgo10/panorama-viewer/styles.css`. 4. **Edit mode** — sphere click → `mouseEventToCoords` → `onSphereClick({ yaw, pitch })`. Drag-pan is ignored via pointer movement threshold. +## Architecture (v0.2.0) + +5. **Drag-to-reposition** — in `mode="edit"` with `onMarkerMove`, pointer-down on `[data-marker-id]` starts a drag; move remaps via `mouseEventToCoords` + `removeHotSpot`/`addHotSpot`; pointer-up fires `onMarkerMove(marker, { yaw, pitch })`. +6. **Structural vs position keys** — remount only on `markersStructuralKey` (id/kind/title/pin/content/data). Yaw/pitch sync via `markersPositionsKey` without remounting the panorama. +7. **View preserve** — camera yaw/pitch/hfov saved across structural remounts when `imageUrl` is unchanged. +8. **Device orientation** — `orientationControl` (view mode only) exposes Pannellum gyro / tilt-to-look. Props: `orientationOnByDefault`, `onOrientationChange`. Handle: `startOrientation` / `stopOrientation` / `isOrientationAvailable` / `isOrientationActive`. Built-in orientation button hidden unless `.ls-pv-orientation` is on the shell. iOS needs a user-gesture `startOrientation()` for permission. + ## Issues and fixes | Symptom | Cause | Fix | |---------|-------|-----| | Edit mode places marker while drag-panning | `click` fires after drag | Track pointer movement; skip `onSphereClick` when movement exceeds threshold | | Viewer flashes then collapses to 0 height | Pannellum `.pnlm-container { height: 100% }` with no sized parent | Outer shell with default `min(70vh, 520px)`; inner canvas absolute fill; docs use fixed-height wrapper + `fitParent` | +| Dragging a marker also pans / opens info | Hotspot click + sphere pan share the gesture | Edit drag uses pointer capture; suppress click after move; grab cursor in `.ls-pv-shell-edit` | +| Remount on every yaw tweak resets camera | Full geometry key remounted viewer | Split structural vs position keys; preserve view on remount | +| Gyro unavailable / no API on wrapper | Props/handle only covered markers | Expose `orientationControl` + imperative start/stop; document iOS permission | | (initial) | Vantage CDN scaffold | Publish reusable package with bundled pannellum assets | ## HTML content trust boundary diff --git a/packages/panorama-viewer/CHANGELOG.md b/packages/panorama-viewer/CHANGELOG.md new file mode 100644 index 0000000..4f0614c --- /dev/null +++ b/packages/panorama-viewer/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +## 0.2.0 + +### Added + +- **Device orientation (gyro / tilt-to-look)** for `mode="view"`: + - Props: `orientationControl`, `orientationOnByDefault`, `onOrientationChange` + - Handle: `startOrientation()`, `stopOrientation()`, `isOrientationAvailable()`, `isOrientationActive()` + - Helpers: `deviceOrientationPermissionRequired()`, `isOrientationControlEnabled()` +- **Edit-mode marker drag** via `onMarkerMove` (yaw/pitch sync without remount) + +### Notes for consumers (RoamVantage / Vantage) + +1. Bump to `@jeffgo10/panorama-viewer@0.2.0` (e.g. `/update-ls-foundry-packages` in roamvantage). +2. On public tour viewers (`/t/[slug]`, `/t/demo`), set `orientationControl` (and optionally wire an “Enable motion” button that calls `ref.startOrientation()` on iOS). +3. Keep `orientationControl` off in edit / designer mode. + +## 0.1.2 + +Prior published pin used by RoamVantage Phase 1. + +## 0.1.0 + +Initial package publish (Pannellum wrapper + markers). diff --git a/packages/panorama-viewer/README.md b/packages/panorama-viewer/README.md index 3cb258d..f0ae9aa 100644 --- a/packages/panorama-viewer/README.md +++ b/packages/panorama-viewer/README.md @@ -97,9 +97,13 @@ const markers: PanoramaMarker[] = [ | `fitParent` | `boolean?` | Skip default height; parent must size the shell (`h-full`) | | `initialYaw` / `initialPitch` / `initialHfov` | `number?` | Initial camera (degrees) | | `markers` | `PanoramaMarker[]?` | Hotspots | -| `mode` | `"view" \| "edit"` | Edit enables sphere click | +| `mode` | `"view" \| "edit"` | Edit enables sphere click + marker drag | +| `orientationControl` | `boolean?` | Enable Pannellum gyro / tilt-to-look (`mode="view"` only). Default `false` | +| `orientationOnByDefault` | `boolean?` | Start with gyro on when supported. Default `false`. Prefer a user-gesture `startOrientation()` on iOS | +| `onOrientationChange` | `(active: boolean) => void` | Gyro became active / inactive | | `onSphereClick` | `(pos) => void` | Edit: yaw/pitch of click | | `onMarkerClick` | `(marker) => void` | Marker click | +| `onMarkerMove` | `(marker, pos) => void` | Edit: drag finished — update `markers` | ### Marker kinds @@ -125,9 +129,51 @@ type PanoramaViewerHandle = { getView: () => { yaw: number; pitch: number; hfov: number } | null; lookAt: (yaw: number, pitch: number, hfov?: number) => void; destroy: () => void; + startOrientation: () => void; + stopOrientation: () => void; + isOrientationAvailable: () => boolean; + isOrientationActive: () => boolean; }; ``` +## Device orientation (gyro / tilt-to-look) + +Opt-in for **view mode** only (ignored in edit). Drag / pinch still work alongside gyro (Pannellum’s model). + +```tsx +const ref = useRef(null); + + setGyroOn(active)} +/> + + +``` + +### iOS Safari + +- Requires **HTTPS**. +- iOS 13+ prompts via `DeviceOrientationEvent.requestPermission()` — must run from a **user gesture**. Pannellum’s `startOrientation()` (and our handle method) perform that request; auto-start via `orientationOnByDefault` often fails with `autoLoad: true`. +- If permission is denied or the device has no IMU, methods no-op and `isOrientationAvailable()` / Pannellum’s support check stay false / inactive. +- Helper: `deviceOrientationPermissionRequired()` — `true` when the host should show an “Enable motion” button before calling `startOrientation()`. + +### Manual check + +- **iOS Safari:** tap Enable motion → allow → tilt phone; drag still pans. +- **Android Chrome:** `orientationControl` + optional `orientationOnByDefault`, or the built-in Pannellum orientation button when the shell has `ls-pv-orientation`. + ## Edit mode ```tsx @@ -141,10 +187,18 @@ type PanoramaViewerHandle = { { id: crypto.randomUUID(), yaw, pitch, kind: "info", content: { type: "text", body: "New" } }, ]); }} + onMarkerMove={(marker, { yaw, pitch }) => { + setMarkers((prev) => + prev.map((m) => (m.id === marker.id ? { ...m, yaw, pitch } : m)), + ); + }} /> ``` -Sphere clicks place markers; **drag-to-pan does not** (movement above a small pixel threshold is ignored). Drag-to-reposition existing markers is a follow-up (not in 0.1.x). +- **Place:** sphere click → `onSphereClick({ yaw, pitch })` (drag-to-pan is ignored via a small pixel threshold). +- **Reposition:** drag an existing hotspot → live preview on the sphere → `onMarkerMove(marker, { yaw, pitch })` on pointer up. Provide `onMarkerMove` to enable drag; the parent must update `markers` to persist. + +Yaw/pitch-only updates do **not** remount the viewer (structural changes still do). Camera pose is preserved across structural remounts when the image URL is unchanged. ## Demo diff --git a/packages/panorama-viewer/jest.config.cjs b/packages/panorama-viewer/jest.config.cjs index 4017c82..76c969a 100644 --- a/packages/panorama-viewer/jest.config.cjs +++ b/packages/panorama-viewer/jest.config.cjs @@ -41,6 +41,18 @@ module.exports = { lines: 90, statements: 90, }, + "./src/marker-drag.ts": { + branches: 85, + functions: 90, + lines: 90, + statements: 90, + }, + "./src/orientation.ts": { + branches: 85, + functions: 90, + lines: 90, + statements: 90, + }, "./src/PanoramaViewer.tsx": { branches: 40, functions: 50, diff --git a/packages/panorama-viewer/package.json b/packages/panorama-viewer/package.json index eee9461..cb566d9 100644 --- a/packages/panorama-viewer/package.json +++ b/packages/panorama-viewer/package.json @@ -1,6 +1,6 @@ { "name": "@jeffgo10/panorama-viewer", - "version": "0.1.2", + "version": "0.2.0", "description": "React 360 panorama viewer (Pannellum) with navigation, info, and label hotspots", "license": "MIT", "repository": { @@ -21,7 +21,7 @@ }, "./styles.css": "./dist/styles.css" }, - "files": ["dist", "README.md"], + "files": ["dist", "README.md", "CHANGELOG.md"], "scripts": { "build": "tsup && node ./scripts/bundle-styles.mjs", "dev": "tsup --watch", diff --git a/packages/panorama-viewer/src/PanoramaViewer.test.tsx b/packages/panorama-viewer/src/PanoramaViewer.test.tsx index 49bc4ff..476cc1d 100644 --- a/packages/panorama-viewer/src/PanoramaViewer.test.tsx +++ b/packages/panorama-viewer/src/PanoramaViewer.test.tsx @@ -1,7 +1,7 @@ import { act, fireEvent, render, waitFor } from "@testing-library/react"; import { createRef } from "react"; import { PanoramaViewer } from "./PanoramaViewer"; -import type { PanoramaViewerHandle } from "./types"; +import type { PanoramaMarker, PanoramaViewerHandle } from "./types"; const destroy = jest.fn(); const getYaw = jest.fn(() => 12); @@ -9,6 +9,12 @@ const getPitch = jest.fn(() => -3); const getHfov = jest.fn(() => 100); const lookAt = jest.fn(); const mouseEventToCoords = jest.fn(() => [-3, 12] as [number, number]); +const addHotSpot = jest.fn(); +const removeHotSpot = jest.fn(() => true); +const isOrientationSupported = jest.fn(() => true); +const startOrientation = jest.fn(); +const stopOrientation = jest.fn(); +const isOrientationActive = jest.fn(() => false); const viewerMock = { destroy, @@ -17,6 +23,12 @@ const viewerMock = { getHfov, lookAt, mouseEventToCoords, + addHotSpot, + removeHotSpot, + isOrientationSupported, + startOrientation, + stopOrientation, + isOrientationActive, }; const pannellumViewer = jest.fn(() => viewerMock); @@ -24,6 +36,16 @@ const pannellumViewer = jest.fn(() => viewerMock); beforeEach(() => { destroy.mockClear(); lookAt.mockClear(); + addHotSpot.mockClear(); + removeHotSpot.mockClear(); + mouseEventToCoords.mockClear(); + mouseEventToCoords.mockImplementation(() => [-3, 12] as [number, number]); + isOrientationSupported.mockClear(); + isOrientationSupported.mockReturnValue(true); + startOrientation.mockClear(); + stopOrientation.mockClear(); + isOrientationActive.mockClear(); + isOrientationActive.mockReturnValue(false); pannellumViewer.mockClear(); pannellumViewer.mockImplementation(() => viewerMock); window.pannellum = { viewer: pannellumViewer }; @@ -91,6 +113,48 @@ describe("PanoramaViewer", () => { expect(onSphereClick).toHaveBeenCalledWith({ yaw: 12, pitch: -3 }); }); + it("drags a marker in edit mode and fires onMarkerMove", async () => { + const onMarkerMove = jest.fn(); + const markers: PanoramaMarker[] = [ + { id: "seed", yaw: 0, pitch: 0, kind: "navigation", title: "Go" }, + ]; + const { container } = render( + , + ); + + await waitFor(() => expect(pannellumViewer).toHaveBeenCalled()); + + const canvas = container.querySelector(".ls-pv-canvas") as HTMLElement; + const pin = document.createElement("div"); + pin.setAttribute("data-marker-id", "seed"); + pin.className = "ls-pv-hotspot-root"; + canvas.appendChild(pin); + + mouseEventToCoords.mockReturnValue([-10, 55] as [number, number]); + + act(() => { + fireEvent.pointerDown(pin, { clientX: 10, clientY: 10, pointerId: 1 }); + fireEvent.pointerMove(canvas, { + clientX: 40, + clientY: 12, + pointerId: 1, + }); + fireEvent.pointerUp(canvas, { clientX: 40, clientY: 12, pointerId: 1 }); + }); + + expect(removeHotSpot).toHaveBeenCalled(); + expect(addHotSpot).toHaveBeenCalled(); + expect(onMarkerMove).toHaveBeenCalledWith(markers[0], { + yaw: 55, + pitch: -10, + }); + }); + it("exposes imperative handle helpers", async () => { const ref = createRef(); render( @@ -119,4 +183,133 @@ describe("PanoramaViewer", () => { }); expect(destroy).toHaveBeenCalled(); }); + + it("wires orientationOnByDefault when orientationControl is on in view mode", async () => { + render( + , + ); + + await waitFor(() => expect(pannellumViewer).toHaveBeenCalled()); + const config = pannellumViewer.mock.calls[0][1] as Pannellum.ConfigOptions; + expect(config.orientationOnByDefault).toBe(true); + }); + + it("does not enable orientationOnByDefault in edit mode", async () => { + render( + , + ); + + await waitFor(() => expect(pannellumViewer).toHaveBeenCalled()); + const config = pannellumViewer.mock.calls[0][1] as Pannellum.ConfigOptions; + expect(config.orientationOnByDefault).toBe(false); + }); + + it("adds ls-pv-orientation shell class only when control is enabled in view", async () => { + const { container, rerender } = render( + , + ); + await waitFor(() => expect(pannellumViewer).toHaveBeenCalled()); + expect(container.querySelector(".ls-pv-orientation")).not.toBeNull(); + + rerender( + , + ); + expect(container.querySelector(".ls-pv-orientation")).toBeNull(); + }); + + it("exposes orientation handle methods and fires onOrientationChange", async () => { + const onOrientationChange = jest.fn(); + const ref = createRef(); + render( + , + ); + + await waitFor(() => expect(pannellumViewer).toHaveBeenCalled()); + + expect(ref.current?.isOrientationAvailable()).toBe(true); + expect(ref.current?.isOrientationActive()).toBe(false); + + isOrientationActive.mockReturnValue(true); + act(() => { + ref.current?.startOrientation(); + }); + expect(startOrientation).toHaveBeenCalled(); + expect(onOrientationChange).toHaveBeenCalledWith(true); + + act(() => { + ref.current?.stopOrientation(); + }); + expect(stopOrientation).toHaveBeenCalled(); + expect(onOrientationChange).toHaveBeenCalledWith(false); + }); + + it("no-ops startOrientation when orientationControl is off", async () => { + const ref = createRef(); + render( + , + ); + + await waitFor(() => expect(pannellumViewer).toHaveBeenCalled()); + + expect(ref.current?.isOrientationAvailable()).toBe(false); + act(() => { + ref.current?.startOrientation(); + }); + expect(startOrientation).not.toHaveBeenCalled(); + }); + + it("stops orientation when switching to edit mode", async () => { + const onOrientationChange = jest.fn(); + isOrientationActive.mockReturnValue(true); + const { rerender } = render( + , + ); + + await waitFor(() => expect(pannellumViewer).toHaveBeenCalled()); + + rerender( + , + ); + + expect(stopOrientation).toHaveBeenCalled(); + expect(onOrientationChange).toHaveBeenCalledWith(false); + }); }); diff --git a/packages/panorama-viewer/src/PanoramaViewer.tsx b/packages/panorama-viewer/src/PanoramaViewer.tsx index 470f775..4b4e991 100644 --- a/packages/panorama-viewer/src/PanoramaViewer.tsx +++ b/packages/panorama-viewer/src/PanoramaViewer.tsx @@ -9,7 +9,14 @@ import { type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, } from "react"; -import { markersGeometryKey, markersToHotSpots } from "./markers"; +import { markerIdFromEventTarget, relocateHotSpot } from "./marker-drag"; +import { + markersPositionsKey, + markersStructuralKey, + markersToHotSpots, + type MarkerHotSpotHandlers, +} from "./markers"; +import { isOrientationControlEnabled } from "./orientation"; import { exceedsDragThreshold } from "./pointer"; import type { PanoramaMarker, @@ -32,6 +39,16 @@ const DEFAULT_SHELL_STYLE: CSSProperties = { overflow: "hidden", }; +type ViewPose = { yaw: number; pitch: number; hfov: number }; + +type MarkerDragState = { + markerId: string; + start: { x: number; y: number }; + didMove: boolean; + /** Latest yaw/pitch applied during the drag */ + pos: { yaw: number; pitch: number }; +}; + const PanoramaViewer = forwardRef( function PanoramaViewer( { @@ -44,8 +61,12 @@ const PanoramaViewer = forwardRef( initialHfov = 100, markers = [], mode = "view", + orientationControl = false, + orientationOnByDefault = false, + onOrientationChange, onSphereClick, onMarkerClick, + onMarkerMove, }, ref, ) { @@ -54,15 +75,51 @@ const PanoramaViewer = forwardRef( const viewerRef = useRef(null); const onSphereClickRef = useRef(onSphereClick); const onMarkerClickRef = useRef(onMarkerClick); + const onMarkerMoveRef = useRef(onMarkerMove); + const onOrientationChangeRef = useRef(onOrientationChange); const modeRef = useRef(mode); + const orientationControlRef = useRef(orientationControl); + const markersRef = useRef(markers); const pointerStartRef = useRef<{ x: number; y: number } | null>(null); const didDragRef = useRef(false); + const suppressMarkerClickRef = useRef(false); + const markerDragRef = useRef(null); + const savedViewRef = useRef(null); + const lastMountedImageUrlRef = useRef(null); + const lastSyncedPositionsKeyRef = useRef(null); + const hotSpotHandlersRef = useRef({}); + const lastOrientationActiveRef = useRef(false); onSphereClickRef.current = onSphereClick; onMarkerClickRef.current = onMarkerClick; + onMarkerMoveRef.current = onMarkerMove; + onOrientationChangeRef.current = onOrientationChange; modeRef.current = mode; + orientationControlRef.current = orientationControl; + markersRef.current = markers; - const geometryKey = markersGeometryKey(markers); + const orientationEnabled = isOrientationControlEnabled( + mode, + orientationControl, + ); + + const emitOrientationChange = (active: boolean) => { + if (lastOrientationActiveRef.current === active) return; + lastOrientationActiveRef.current = active; + onOrientationChangeRef.current?.(active); + }; + + hotSpotHandlersRef.current = { + onMarkerClick: (m) => onMarkerClickRef.current?.(m), + isClickSuppressed: () => { + if (!suppressMarkerClickRef.current) return false; + suppressMarkerClickRef.current = false; + return true; + }, + }; + + const structuralKey = markersStructuralKey(markers); + const positionsKey = markersPositionsKey(markers); useImperativeHandle( ref, @@ -83,7 +140,52 @@ const PanoramaViewer = forwardRef( viewerRef.current?.destroy(); viewerRef.current = null; }, + startOrientation: () => { + const v = viewerRef.current; + if ( + !v || + !isOrientationControlEnabled( + modeRef.current, + orientationControlRef.current, + ) + ) { + return; + } + if (!v.isOrientationSupported()) return; + v.startOrientation(); + // iOS permission is async inside Pannellum; poll briefly for active state. + emitOrientationChange(v.isOrientationActive()); + window.setTimeout(() => { + const current = viewerRef.current; + if (!current) return; + emitOrientationChange(current.isOrientationActive()); + }, 0); + }, + stopOrientation: () => { + const v = viewerRef.current; + if (!v) return; + if (!v.isOrientationActive()) return; + v.stopOrientation(); + emitOrientationChange(false); + }, + isOrientationAvailable: () => { + const v = viewerRef.current; + if ( + !v || + !isOrientationControlEnabled( + modeRef.current, + orientationControlRef.current, + ) + ) { + return false; + } + return Boolean(v.isOrientationSupported()); + }, + isOrientationActive: () => + Boolean(viewerRef.current?.isOrientationActive()), }), + // emitOrientationChange closes over stable refs + // eslint-disable-next-line react-hooks/exhaustive-deps [], ); @@ -96,47 +198,203 @@ const PanoramaViewer = forwardRef( await ensurePannellum(); if (cancelled || !containerRef.current || !window.pannellum) return; + const sameImage = + lastMountedImageUrlRef.current !== null && + lastMountedImageUrlRef.current === imageUrl; + const pose: ViewPose = + sameImage && savedViewRef.current + ? savedViewRef.current + : { + yaw: initialYaw, + pitch: initialPitch, + hfov: initialHfov, + }; + savedViewRef.current = null; + lastMountedImageUrlRef.current = imageUrl; + viewerRef.current?.destroy(); viewerRef.current = null; + const enableOrientation = isOrientationControlEnabled( + modeRef.current, + orientationControlRef.current, + ); + const config: Pannellum.ConfigOptions = { type: "equirectangular", panorama: imageUrl, autoLoad: true, - yaw: initialYaw, - pitch: initialPitch, - hfov: initialHfov, + yaw: pose.yaw, + pitch: pose.pitch, + hfov: pose.hfov, showZoomCtrl: true, showFullscreenCtrl: true, mouseZoom: true, - hotSpots: markersToHotSpots(markerSnapshot, { - onMarkerClick: (m) => onMarkerClickRef.current?.(m), - }), + orientationOnByDefault: + enableOrientation && orientationOnByDefault, + hotSpots: markersToHotSpots( + markerSnapshot, + hotSpotHandlersRef.current, + ), }; viewerRef.current = window.pannellum.viewer( containerRef.current, config, ); + lastSyncedPositionsKeyRef.current = markersPositionsKey(markerSnapshot); + lastOrientationActiveRef.current = false; + if (enableOrientation && orientationOnByDefault) { + emitOrientationChange( + Boolean(viewerRef.current.isOrientationActive()), + ); + } } void mount(); return () => { cancelled = true; - viewerRef.current?.destroy(); - viewerRef.current = null; + if (viewerRef.current) { + savedViewRef.current = { + yaw: viewerRef.current.getYaw(), + pitch: viewerRef.current.getPitch(), + hfov: viewerRef.current.getHfov(), + }; + viewerRef.current.destroy(); + viewerRef.current = null; + } + lastOrientationActiveRef.current = false; }; - // markers captured via geometryKey; callbacks via refs + // Position-only updates sync via the effect below (no remount). // eslint-disable-next-line react-hooks/exhaustive-deps - }, [imageUrl, geometryKey, initialYaw, initialPitch, initialHfov]); + }, [ + imageUrl, + structuralKey, + initialYaw, + initialPitch, + initialHfov, + orientationOnByDefault, + ]); + + // Sync yaw/pitch when parent updates markers without a structural remount + // (e.g. after onMarkerMove). Skip while the user is mid-drag. + useEffect(() => { + const viewer = viewerRef.current; + if (!viewer || markerDragRef.current) return; + if (lastSyncedPositionsKeyRef.current === positionsKey) return; + + for (const marker of markersRef.current) { + relocateHotSpot( + viewer, + marker, + { yaw: marker.yaw, pitch: marker.pitch }, + hotSpotHandlersRef.current, + ); + } + lastSyncedPositionsKeyRef.current = positionsKey; + }, [positionsKey]); + + // Stop gyro when leaving view mode or when orientationControl is turned off. + useEffect(() => { + const viewer = viewerRef.current; + if (!viewer) return; + if (orientationEnabled) return; + if (!viewer.isOrientationActive()) return; + viewer.stopOrientation(); + // Force notify even if we never saw an "active" callback (e.g. host + // started gyro via Pannellum's button before our ref tracked it). + lastOrientationActiveRef.current = true; + emitOrientationChange(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [orientationEnabled]); + + // Mirror Pannellum's built-in orientation button into onOrientationChange. + useEffect(() => { + if (!orientationEnabled) return; + const container = containerRef.current; + if (!container) return; + + const btn = container.querySelector(".pnlm-orientation-button"); + if (!btn) return; + + const onClick = () => { + window.setTimeout(() => { + const viewer = viewerRef.current; + if (!viewer) return; + emitOrientationChange(viewer.isOrientationActive()); + }, 0); + }; + btn.addEventListener("click", onClick); + return () => btn.removeEventListener("click", onClick); + // Remount recreates the button; structuralKey/imageUrl cover that. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [orientationEnabled, imageUrl, structuralKey]); const handlePointerDown = (event: ReactPointerEvent) => { pointerStartRef.current = { x: event.clientX, y: event.clientY }; didDragRef.current = false; + markerDragRef.current = null; + + if (modeRef.current !== "edit" || !onMarkerMoveRef.current) return; + + const markerId = markerIdFromEventTarget(event.target); + if (!markerId) return; + + const marker = markersRef.current.find((m) => m.id === markerId); + if (!marker) return; + + markerDragRef.current = { + markerId, + start: { x: event.clientX, y: event.clientY }, + didMove: false, + pos: { yaw: marker.yaw, pitch: marker.pitch }, + }; + + const root = (event.target as Element).closest?.("[data-marker-id]"); + root?.classList.add("ls-pv-dragging"); + + try { + event.currentTarget.setPointerCapture(event.pointerId); + } catch { + // Pointer capture can fail on some synthetic events in tests. + } + event.preventDefault(); + event.stopPropagation(); }; const handlePointerMove = (event: ReactPointerEvent) => { + const markerDrag = markerDragRef.current; + if (markerDrag) { + if ( + !markerDrag.didMove && + exceedsDragThreshold(markerDrag.start, { + x: event.clientX, + y: event.clientY, + }) + ) { + markerDrag.didMove = true; + didDragRef.current = true; + } + if (!markerDrag.didMove) return; + + const viewer = viewerRef.current; + const marker = markersRef.current.find( + (m) => m.id === markerDrag.markerId, + ); + if (!viewer || !marker) return; + + const [pitch, yaw] = viewer.mouseEventToCoords(event.nativeEvent); + markerDrag.pos = { yaw, pitch }; + relocateHotSpot( + viewer, + marker, + markerDrag.pos, + hotSpotHandlersRef.current, + ); + return; + } + const start = pointerStartRef.current; if (!start || didDragRef.current) return; if ( @@ -146,12 +404,45 @@ const PanoramaViewer = forwardRef( } }; - const handlePointerUp = () => { + const endMarkerDrag = (event: ReactPointerEvent) => { + const markerDrag = markerDragRef.current; + markerDragRef.current = null; + pointerStartRef.current = null; + + const root = containerRef.current?.querySelector( + `[data-marker-id="${markerDrag?.markerId ?? ""}"]`, + ); + root?.classList.remove("ls-pv-dragging"); + + try { + if (event.currentTarget.hasPointerCapture?.(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + } catch { + // ignore + } + + if (!markerDrag?.didMove) return; + + suppressMarkerClickRef.current = true; + const marker = markersRef.current.find( + (m) => m.id === markerDrag.markerId, + ); + if (!marker) return; + lastSyncedPositionsKeyRef.current = null; + onMarkerMoveRef.current?.(marker, markerDrag.pos); + }; + + const handlePointerUp = (event: ReactPointerEvent) => { + if (markerDragRef.current) { + endMarkerDrag(event); + return; + } pointerStartRef.current = null; }; const handleContainerClick = (event: ReactMouseEvent) => { - // Ignore click that follows a drag-pan on the sphere. + // Ignore click that follows a drag-pan on the sphere or a marker drag. if (didDragRef.current) { didDragRef.current = false; return; @@ -176,6 +467,8 @@ const PanoramaViewer = forwardRef( const shellClassName = [ "ls-pv-shell", fitParent ? "ls-pv-shell-fit" : "", + mode === "edit" ? "ls-pv-shell-edit" : "", + orientationEnabled ? "ls-pv-orientation" : "", className ?? "", ] .filter(Boolean) diff --git a/packages/panorama-viewer/src/index.ts b/packages/panorama-viewer/src/index.ts index c9da494..ad1da0b 100644 --- a/packages/panorama-viewer/src/index.ts +++ b/packages/panorama-viewer/src/index.ts @@ -1,4 +1,8 @@ export { PanoramaViewer } from "./PanoramaViewer"; +export { + markerIdFromEventTarget, + relocateHotSpot, +} from "./marker-drag"; export { buildContentElement, defaultTitleForMarker, @@ -7,11 +11,17 @@ export { isRichContent, isTextContent, markersGeometryKey, + markersPositionsKey, + markersStructuralKey, markersToHotSpots, mountMarkerTooltip, pinClassForMarker, PIN_CLASS_BY_KIND, } from "./markers"; +export { + deviceOrientationPermissionRequired, + isOrientationControlEnabled, +} from "./orientation"; export { exceedsDragThreshold, SPHERE_CLICK_DRAG_THRESHOLD_PX, diff --git a/packages/panorama-viewer/src/marker-drag.test.ts b/packages/panorama-viewer/src/marker-drag.test.ts new file mode 100644 index 0000000..5bb2747 --- /dev/null +++ b/packages/panorama-viewer/src/marker-drag.test.ts @@ -0,0 +1,48 @@ +import { + markerIdFromEventTarget, + relocateHotSpot, +} from "./marker-drag"; +import type { PanoramaMarker } from "./types"; + +describe("markerIdFromEventTarget", () => { + it("returns the nearest data-marker-id", () => { + const root = document.createElement("div"); + root.setAttribute("data-marker-id", "m1"); + const child = document.createElement("span"); + root.appendChild(child); + document.body.appendChild(root); + + expect(markerIdFromEventTarget(child)).toBe("m1"); + expect(markerIdFromEventTarget(root)).toBe("m1"); + expect(markerIdFromEventTarget(document.body)).toBeNull(); + expect(markerIdFromEventTarget(null)).toBeNull(); + + root.remove(); + }); +}); + +describe("relocateHotSpot", () => { + it("removes and re-adds the hotspot at the new pose", () => { + const removeHotSpot = jest.fn(() => true); + const addHotSpot = jest.fn(); + const viewer = { removeHotSpot, addHotSpot } as unknown as Pannellum.Viewer; + const marker: PanoramaMarker = { + id: "a", + yaw: 10, + pitch: -2, + kind: "info", + title: "A", + }; + + const updated = relocateHotSpot(viewer, marker, { yaw: 40, pitch: 5 }); + + expect(removeHotSpot).toHaveBeenCalledWith("a"); + expect(addHotSpot).toHaveBeenCalledTimes(1); + expect(addHotSpot.mock.calls[0][0]).toMatchObject({ + id: "a", + yaw: 40, + pitch: 5, + }); + expect(updated).toEqual({ ...marker, yaw: 40, pitch: 5 }); + }); +}); diff --git a/packages/panorama-viewer/src/marker-drag.ts b/packages/panorama-viewer/src/marker-drag.ts new file mode 100644 index 0000000..a66c710 --- /dev/null +++ b/packages/panorama-viewer/src/marker-drag.ts @@ -0,0 +1,34 @@ +import { markersToHotSpots, type MarkerHotSpotHandlers } from "./markers"; +import type { PanoramaMarker } from "./types"; + +/** Resolve a marker id from a hotspot (or child) under the pointer. */ +export function markerIdFromEventTarget( + target: EventTarget | null, +): string | null { + if (!(target instanceof Element)) return null; + const root = target.closest("[data-marker-id]"); + if (!root) return null; + const id = root.getAttribute("data-marker-id"); + return id && id.length > 0 ? id : null; +} + +/** + * Replace a Pannellum hotspot at a new yaw/pitch (live drag preview or sync). + * Returns the updated marker snapshot used for the hotspot config. + */ +export function relocateHotSpot( + viewer: Pannellum.Viewer, + marker: PanoramaMarker, + pos: { yaw: number; pitch: number }, + handlers: MarkerHotSpotHandlers = {}, +): PanoramaMarker { + const updated: PanoramaMarker = { + ...marker, + yaw: pos.yaw, + pitch: pos.pitch, + }; + viewer.removeHotSpot(marker.id); + const [hotSpot] = markersToHotSpots([updated], handlers); + viewer.addHotSpot(hotSpot); + return updated; +} diff --git a/packages/panorama-viewer/src/markers.test.ts b/packages/panorama-viewer/src/markers.test.ts index 81ab3e4..120a628 100644 --- a/packages/panorama-viewer/src/markers.test.ts +++ b/packages/panorama-viewer/src/markers.test.ts @@ -6,6 +6,8 @@ import { isRichContent, isTextContent, markersGeometryKey, + markersPositionsKey, + markersStructuralKey, markersToHotSpots, mountMarkerTooltip, pinClassForMarker, @@ -137,6 +139,30 @@ describe("markersToHotSpots", () => { expect(event.preventDefault).toHaveBeenCalled(); }); + it("sets data-marker-id on the hotspot root", () => { + const marker = baseMarker({ id: "pin-9", kind: "navigation" }); + const [spot] = markersToHotSpots([marker]); + const div = document.createElement("div"); + spot.createTooltipFunc?.(div, marker); + expect(div.getAttribute("data-marker-id")).toBe("pin-9"); + }); + + it("skips click handling when isClickSuppressed returns true", () => { + const onMarkerClick = jest.fn(); + const marker = baseMarker({ id: "n", kind: "navigation" }); + const [spot] = markersToHotSpots([marker], { + onMarkerClick, + isClickSuppressed: () => true, + }); + const event = { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + currentTarget: document.createElement("div"), + } as unknown as MouseEvent; + spot.clickHandlerFunc?.(event, marker); + expect(onMarkerClick).not.toHaveBeenCalled(); + }); + it("createTooltipFunc mounts tooltip DOM", () => { const marker = baseMarker({ id: "i", @@ -277,3 +303,18 @@ describe("markersGeometryKey", () => { expect(markersGeometryKey(a)).not.toBe(markersGeometryKey(b)); }); }); + +describe("markersStructuralKey / markersPositionsKey", () => { + it("structural key ignores yaw/pitch but positions key does not", () => { + const a = [baseMarker({ id: "1", kind: "info", yaw: 0, pitch: 0 })]; + const b = [baseMarker({ id: "1", kind: "info", yaw: 12, pitch: -4 })]; + expect(markersStructuralKey(a)).toBe(markersStructuralKey(b)); + expect(markersPositionsKey(a)).not.toBe(markersPositionsKey(b)); + }); + + it("structural key changes when kind or content changes", () => { + const a = [baseMarker({ id: "1", kind: "info" })]; + const b = [baseMarker({ id: "1", kind: "navigation" })]; + expect(markersStructuralKey(a)).not.toBe(markersStructuralKey(b)); + }); +}); diff --git a/packages/panorama-viewer/src/markers.ts b/packages/panorama-viewer/src/markers.ts index 5885381..76bf16b 100644 --- a/packages/panorama-viewer/src/markers.ts +++ b/packages/panorama-viewer/src/markers.ts @@ -43,7 +43,7 @@ export function defaultTitleForMarker(marker: PanoramaMarker): string { return "Label"; } -/** Geometry key used to remount the viewer when markers change. */ +/** Full geometry key (includes yaw/pitch) — useful for tests / deep compare. */ export function markersGeometryKey(markers: PanoramaMarker[]): string { return JSON.stringify( markers.map((m) => ({ @@ -58,8 +58,34 @@ export function markersGeometryKey(markers: PanoramaMarker[]): string { ); } +/** + * Structural key for remounting the viewer. Yaw/pitch changes alone do not + * remount — those sync via add/removeHotSpot so drag stays smooth. + */ +export function markersStructuralKey(markers: PanoramaMarker[]): string { + return JSON.stringify( + markers.map((m) => ({ + id: m.id, + kind: m.kind, + title: m.title, + pin: m.pin, + content: m.content, + data: m.data, + })), + ); +} + +/** Compact yaw/pitch fingerprint for position-sync effects. */ +export function markersPositionsKey(markers: PanoramaMarker[]): string { + return JSON.stringify( + markers.map((m) => ({ id: m.id, yaw: m.yaw, pitch: m.pitch })), + ); +} + export type MarkerHotSpotHandlers = { onMarkerClick?: (marker: PanoramaMarker) => void; + /** When true, skip click / info-panel toggle (e.g. after a drag). */ + isClickSuppressed?: () => boolean; }; /** @@ -97,6 +123,7 @@ export function markersToHotSpots( ) => { event.preventDefault(); event.stopPropagation(); + if (handlers.isClickSuppressed?.()) return; const m = args as PanoramaMarker; if (m.kind === "info") { toggleInfoPanel(event.currentTarget as HTMLElement | null); @@ -114,6 +141,7 @@ export function mountMarkerTooltip( div.classList.add("ls-pv-hotspot-root"); div.setAttribute("role", "button"); div.setAttribute("tabindex", "0"); + div.setAttribute("data-marker-id", marker.id); div.setAttribute("aria-label", defaultTitleForMarker(marker)); const pin = document.createElement("span"); diff --git a/packages/panorama-viewer/src/orientation.test.ts b/packages/panorama-viewer/src/orientation.test.ts new file mode 100644 index 0000000..a8c8504 --- /dev/null +++ b/packages/panorama-viewer/src/orientation.test.ts @@ -0,0 +1,56 @@ +import { + deviceOrientationPermissionRequired, + isOrientationControlEnabled, +} from "./orientation"; + +describe("isOrientationControlEnabled", () => { + it("is true only in view mode when the prop is on", () => { + expect(isOrientationControlEnabled("view", true)).toBe(true); + expect(isOrientationControlEnabled("view", false)).toBe(false); + expect(isOrientationControlEnabled("edit", true)).toBe(false); + expect(isOrientationControlEnabled("edit", false)).toBe(false); + }); +}); + +describe("deviceOrientationPermissionRequired", () => { + const original = globalThis.DeviceOrientationEvent; + + afterEach(() => { + Object.defineProperty(globalThis, "DeviceOrientationEvent", { + configurable: true, + writable: true, + value: original, + }); + }); + + it("returns false when DeviceOrientationEvent is missing", () => { + Object.defineProperty(globalThis, "DeviceOrientationEvent", { + configurable: true, + writable: true, + value: undefined, + }); + expect(deviceOrientationPermissionRequired()).toBe(false); + }); + + it("returns false when requestPermission is not present", () => { + Object.defineProperty(globalThis, "DeviceOrientationEvent", { + configurable: true, + writable: true, + value: class DeviceOrientationEvent {}, + }); + expect(deviceOrientationPermissionRequired()).toBe(false); + }); + + it("returns true when requestPermission exists (iOS-style)", () => { + const ctor = class DeviceOrientationEvent {}; + ( + ctor as unknown as { requestPermission: () => Promise<"granted"> } + ).requestPermission = async () => "granted"; + Object.defineProperty(globalThis, "DeviceOrientationEvent", { + configurable: true, + writable: true, + value: ctor, + }); + expect(deviceOrientationPermissionRequired()).toBe(true); + }); +}); diff --git a/packages/panorama-viewer/src/orientation.ts b/packages/panorama-viewer/src/orientation.ts new file mode 100644 index 0000000..4a75a96 --- /dev/null +++ b/packages/panorama-viewer/src/orientation.ts @@ -0,0 +1,28 @@ +/** + * Device-orientation (gyro / tilt-to-look) helpers for PanoramaViewer. + * Pannellum already implements the sensor math; we gate when it is exposed. + */ + +/** True when the React wrapper should enable Pannellum orientation control. */ +export function isOrientationControlEnabled( + mode: "view" | "edit", + orientationControl: boolean, +): boolean { + return mode === "view" && orientationControl; +} + +type DeviceOrientationEventStatic = { + requestPermission?: () => Promise<"granted" | "denied">; +}; + +/** + * iOS Safari (13+) requires a user-gesture call to + * `DeviceOrientationEvent.requestPermission()` before gyro events fire. + * Pannellum's `startOrientation()` already requests this; hosts should still + * invoke `startOrientation()` from a button tap when this returns true. + */ +export function deviceOrientationPermissionRequired(): boolean { + if (typeof DeviceOrientationEvent === "undefined") return false; + const ctor = DeviceOrientationEvent as unknown as DeviceOrientationEventStatic; + return typeof ctor.requestPermission === "function"; +} diff --git a/packages/panorama-viewer/src/styles.css b/packages/panorama-viewer/src/styles.css index 0e514cc..3766d27 100644 --- a/packages/panorama-viewer/src/styles.css +++ b/packages/panorama-viewer/src/styles.css @@ -32,6 +32,16 @@ height: auto !important; } +.ls-pv-shell-edit .ls-pv-hotspot-root { + cursor: grab; + touch-action: none; +} + +.ls-pv-shell-edit .ls-pv-hotspot-root.ls-pv-dragging { + cursor: grabbing; + opacity: 0.92; +} + .ls-pv-pin { display: inline-flex; align-items: center; @@ -130,3 +140,8 @@ .ls-pv-info-panel[hidden] { display: none !important; } + +/* Hide Pannellum's gyro button unless the host opts in (view mode only). */ +.ls-pv-shell:not(.ls-pv-orientation) .pnlm-orientation-button { + display: none !important; +} diff --git a/packages/panorama-viewer/src/types.ts b/packages/panorama-viewer/src/types.ts index ef54fcd..f6cf975 100644 --- a/packages/panorama-viewer/src/types.ts +++ b/packages/panorama-viewer/src/types.ts @@ -57,14 +57,47 @@ export type PanoramaViewerProps = { markers?: PanoramaMarker[]; /** Viewer vs editor behavior */ mode?: "view" | "edit"; + /** + * When `true` (and `mode="view"`), enable Pannellum device-orientation / + * compass control (gyro tilt-to-look). Default `false`. Ignored in edit mode. + */ + orientationControl?: boolean; + /** + * Start with gyro on when supported. Default `false`. Prefer calling + * `startOrientation()` from a user gesture on iOS Safari (permission prompt). + * Only applied when `orientationControl` is enabled in view mode. + */ + orientationOnByDefault?: boolean; + /** Fired when device orientation control becomes active or inactive. */ + onOrientationChange?: (active: boolean) => void; /** Fired when user clicks the sphere (edit mode) — yaw/pitch in degrees */ onSphereClick?: (pos: { yaw: number; pitch: number }) => void; /** Fired when a marker is clicked */ onMarkerClick?: (marker: PanoramaMarker) => void; + /** + * Edit mode: fired when the user finishes dragging a marker to a new + * yaw/pitch. Parent should update `markers` to persist the position. + */ + onMarkerMove?: ( + marker: PanoramaMarker, + pos: { yaw: number; pitch: number }, + ) => void; }; export type PanoramaViewerHandle = { getView: () => { yaw: number; pitch: number; hfov: number } | null; lookAt: (yaw: number, pitch: number, hfov?: number) => void; destroy: () => void; + /** + * Start device orientation (gyro). No-op when unsupported, denied, or + * orientation control is disabled / edit mode. On iOS Safari, call from a + * user gesture so `DeviceOrientationEvent.requestPermission()` can succeed. + */ + startOrientation: () => void; + /** Stop device orientation. No-op when inactive or unavailable. */ + stopOrientation: () => void; + /** Whether Pannellum reports device orientation support for this device. */ + isOrientationAvailable: () => boolean; + /** Whether device orientation control is currently active. */ + isOrientationActive: () => boolean; };