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;
};