Skip to content

Commit

Permalink
Add source code for controls and its react component
Browse files Browse the repository at this point in the history
  • Loading branch information
9inpachi committed Sep 6, 2024
1 parent fa9c6a8 commit 322d94e
Show file tree
Hide file tree
Showing 6 changed files with 525 additions and 8 deletions.
1 change: 0 additions & 1 deletion lib/index.ts

This file was deleted.

56 changes: 56 additions & 0 deletions lib/panorama-controls-react.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { useFrame, useThree } from "@react-three/fiber";
import { forwardRef, useEffect, useMemo } from "react";
import { PerspectiveCamera } from "three";
import { PanoramaControls as PanoramaControlsImpl } from "./panorama-controls";

export type PanoramaControlsProps = {
makeDefault?: boolean;
enabled?: boolean;
zoomable?: boolean;
};

export const PanoramaControls = forwardRef<
PanoramaControlsImpl,
PanoramaControlsProps
>(({ makeDefault, enabled = true, zoomable = true }, ref) => {
const { camera, gl, events, set, get } = useThree();
const domElement = events.connected ?? gl.domElement;

// Recreate only when camera changes because recreating on a
// changed `domElement` removes the existing listeners added
// through a ref.
const controls = useMemo(
() => new PanoramaControlsImpl(camera as PerspectiveCamera, domElement),
// eslint-disable-next-line react-hooks/exhaustive-deps
[camera]
);

useEffect(() => void (controls.enabled = enabled), [controls, enabled]);
useEffect(() => void (controls.zoomable = zoomable), [controls, zoomable]);

useEffect(() => {
// This needs to be in a `useEffect` because the
// `PanoramaControlsImpl` object is created before the controls are
// disposed in the `useEffect` destructor function which leads to
// the zoom being reset to default by the `dispose` function as all
// `PanoramaControlsImpl` instances use the same `camera` instance
// from react-three-fiber.
controls.initializeZoom();
return () => controls.dispose();
}, [controls]);
// Reconnect controls when the domElement changes.
useEffect(() => controls.reconnect(domElement), [controls, domElement]);
useEffect(() => {
if (makeDefault) {
const oldControls = get().controls;
set({ controls });

return () => set({ controls: oldControls });
}
}, [controls, get, makeDefault, set]);

// Call the controls animation loop.
useFrame(() => controls.enabled && controls.update());

return <primitive ref={ref} object={controls} />;
});
193 changes: 193 additions & 0 deletions lib/panorama-controls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { EventDispatcher, MathUtils, PerspectiveCamera, Vector3 } from "three";

export type PanoramaEvents = {
change: { type: "change" };
start: { type: "start" };
end: { type: "end" };
};

export class PanoramaControls extends EventDispatcher<PanoramaEvents> {
private isEnabled = true;
public zoomable = true;

public minFov = 10;
public maxFov = 90;
private defaultFov = 50;

public zoomSpeed = 0.05;
public panSpeed = 0.1;

private onPointerDownMouseX = 0;
private onPointerDownMouseY = 0;
private onPointerDownLng = 0;
private onPointerDownLat = 0;

public lat = 0;
public lng = 0;

private controlsPosition: Vector3;

public set enabled(value: boolean) {
// If the controls were previously disabled and are being enabled
// now, then we set up the listeners again.
if (!this.isEnabled && value) {
this.setupListeners();
} else if (!value) {
// Disabling the controls by removing listeners.
this.disposeListeners();
}

this.isEnabled = value;
}

public get enabled() {
return this.isEnabled;
}

constructor(
public camera: PerspectiveCamera,
public domElement: HTMLElement
) {
super();

this.initializeZoom();
this.controlsPosition = this.camera.position.clone();
// Set the initial position of the camera.
this.updateCameraLookAt();

this.setupListeners();
}

public initializeZoom() {
// Max zoom out on initial load.
this.camera.fov = this.maxFov;
this.camera.updateProjectionMatrix();
}

private setupListeners() {
// To make touch work with pointer events.
this.domElement.style.touchAction = "none";
this.domElement.addEventListener("pointerdown", this.onPointerDown);
this.domElement.addEventListener("wheel", this.onDocumentMouseWheel);
}

public disposeListeners() {
this.domElement.removeEventListener("pointerdown", this.onPointerDown);
this.domElement.removeEventListener("wheel", this.onDocumentMouseWheel);

// Adding other events to the document as the mouse can flow outside
// the element during interaction after the `pointerdown` event.
document.removeEventListener("pointerup", this.onPointerUp);
document.removeEventListener("pointermove", this.onPointerMove);
}

public dispose() {
this.camera.fov = this.defaultFov;
this.camera.updateProjectionMatrix();

this.disposeListeners();
}

// Can be used to reconnect controls to a different DOM element.
public reconnect(domElement: HTMLElement) {
this.disposeListeners();

this.domElement = domElement;
// Set up listeners with the new DOM element.
this.setupListeners();
}

public update() {
// Dispatch the `change` event if the camera's position changes.
if (!this.camera.position.equals(this.controlsPosition)) {
this.controlsPosition = this.camera.position.clone();
this.dispatchEvent({ type: "change" });
}
}

// Mouse Events

private onPointerDown = (event: PointerEvent) => {
if (event.isPrimary === false) {
return;
}

// This is to avoid selection of text/elements when moving mouse.
event.preventDefault();

this.onPointerDownMouseX = event.clientX;
this.onPointerDownMouseY = event.clientY;

this.onPointerDownLat = this.lat;
this.onPointerDownLng = this.lng;

this.dispatchEvent({ type: "start" });

document.addEventListener("pointermove", this.onPointerMove);
document.addEventListener("pointerup", this.onPointerUp);
};

private onPointerUp = (event: PointerEvent) => {
if (event.isPrimary === false) {
return;
}

document.removeEventListener("pointermove", this.onPointerMove);
document.removeEventListener("pointerup", this.onPointerUp);

this.dispatchEvent({ type: "end" });
};

private onPointerMove = (event: PointerEvent) => {
if (event.isPrimary === false) {
return;
}

this.lat =
(event.clientY - this.onPointerDownMouseY) * this.panSpeed +
this.onPointerDownLat;
this.lng =
(this.onPointerDownMouseX - event.clientX) * this.panSpeed +
this.onPointerDownLng;

this.updateCameraLookAt();
this.dispatchEvent({ type: "change" });
};

private onDocumentMouseWheel = (event: WheelEvent) => {
if (!this.zoomable) {
return;
}

event.preventDefault();

const fov = this.camera.fov + event.deltaY * this.zoomSpeed;
const newFov = MathUtils.clamp(fov, this.minFov, this.maxFov);

// No update.
if (newFov === this.camera.fov) {
return;
}

this.camera.fov = newFov;
this.camera.updateProjectionMatrix();

this.dispatchEvent({ type: "change" });
// Dispatch the `end` event as well as there is no definite way to
// check when a mouse wheel has ended. One could use a timeout but
// this is how `OrbitControls` does it so should be fine.
this.dispatchEvent({ type: "end" });
};

private updateCameraLookAt() {
this.lat = Math.max(-85, Math.min(85, this.lat));
const phi = MathUtils.degToRad(90 - this.lat);
const theta = MathUtils.degToRad(this.lng);

const x = 500 * Math.sin(phi) * Math.cos(theta);
const y = 500 * Math.cos(phi);
const z = 500 * Math.sin(phi) * Math.sin(theta);

this.camera.lookAt(x, y, z);
}
}
30 changes: 24 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,19 @@
"bugs": {
"url": "https://github.com/9inpachi/three-panorama-controls/issues"
},
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/types/index.d.ts",
"main": "./dist/cjs/panorama-controls.js",
"module": "./dist/esm/panorama-controls.js",
"types": "./dist/types/panorama-controls.d.ts",
"exports": {
".": {
"types": "./dist/types/index.d.ts",
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js"
"types": "./dist/types/panorama-controls.d.ts",
"import": "./dist/esm/panorama-controls.js",
"require": "./dist/cjs/panorama-controls.js"
},
"./react": {
"types": "./dist/types/panorama-controls-react.d.ts",
"import": "./dist/esm/panorama-controls-react.js",
"require": "./dist/cjs/panorama-controls-react.js"
}
},
"scripts": {
Expand All @@ -45,13 +50,26 @@
"build:types": "pnpm tsc --emitDeclarationOnly true --declarationDir ./dist/types"
},
"peerDependencies": {
"@react-three/fiber": ">= 8",
"react": ">= 18",
"three": ">= 0.160"
},
"devDependencies": {
"@eslint/js": "^9.10.0",
"@react-three/fiber": "^8.17.6",
"@types/react": "^18.3.5",
"@types/three": "^0.168.0",
"eslint": "^9.10.0",
"globals": "^15.9.0",
"typescript": "^5.5.4",
"typescript-eslint": "^8.4.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"@react-three/fiber": {
"optional": true
}
}
}
Loading

0 comments on commit 322d94e

Please sign in to comment.