Ever wanted to build a compass into your web application? In this tutorial, we'll walk through creating a React component that leverages the Device Orientation API and the Geolocation API to create a functional and interactive compass. We'll cover everything from handling permissions to calculating magnetic declination and building the UI.
Here's the compass in action
Before We Begin
A couple of important things to note before diving in:
- The Device Orientation API only works on secure (HTTPS) connections. This is a browser security requirement. If you're developing locally, you'll need to set up a development SSL certificate. Check out this article for a guide on how to do that in Next.js (the process is similar for other frameworks).
- The Device Orientation API is often not available in desktop browsers. You'll most likely need to test this component on a mobile device. You can do this by running
ipconfig
(Windows) orifconfig
(macOS/Linux) in your terminal to get your IPv4 address. Then, on your phone, navigate tohttps://{your_ipv4_address}:3000
(replace3000
with the port your app is running on).
With that out of the way, let's get started!
Project setup
If you don't have a React project already, you can easily start one using Vite:
npm create vite@latest compass-app -- --template react
cd compass-app
npm install
npm run dev
This will create a new React project using Vite.
The Game Plan
Here's what we'll be covering:
- Setting up Geolocation: Getting the user's location to calculate magnetic declination.
- Harnessing Device Orientation: Accessing the device's orientation data.
- Calculating True Heading: Combining orientation data, location, and magnetic declination.
- Building the UI: Creating a visual compass that responds to device movement and location.
- Addressing Caveats: Discussing limitations and potential issues with these APIs.
Geolocation: Finding Our Bearings
First, we need to get the user's location. This isn't just for displaying coordinates; we'll use it to calculate the magnetic declination (the difference between true north and magnetic north) at the user's location.
Let's start with the useGeolocation
custom hook:
/**
* Custom hook to handle geolocation functionality
* Manages device location tracking, permissions, and error states
*/
function useGeolocation() {
// Track the user's current position
const [position, setPosition] = useState(null);
// Track permission state: 'prompt', 'granted', or 'denied'
const [permission, setPermission] = useState("prompt");
// Track any errors that occur during geolocation
const [error, setError] = useState(null);
// Store the ID returned by watchPosition to cleanup later
const [watchId, setWatchId] = useState(null);
// Cleanup: Remove the position watcher when component unmounts
// or when watchId changes
useEffect(() => {
return () => {
if (watchId !== null) {
navigator.geolocation.clearWatch(watchId);
}
};
}, [watchId]);
/**
* Request permission to access user's location and start tracking
* This function handles both the initial permission request and
* continuous location tracking
*/
const requestPermission = async () => {
// Check if browser supports geolocation
if (!navigator.geolocation) {
setError("Geolocation is not supported by your browser");
return;
}
try {
// First, get initial position - this triggers the permission prompt
await new Promise((resolve, reject) => {
navigator.geolocation.getCurrentPosition(
// Success callback
(position) => {
setPermission("granted");
setPosition(position);
setError(null);
resolve();
},
// Error callback - handle various error scenarios
(error) => {
switch (error.code) {
case error.PERMISSION_DENIED:
setPermission("denied");
setError("Location permission denied");
break;
case error.POSITION_UNAVAILABLE:
setError("Location information is unavailable");
break;
case error.TIMEOUT:
setError("Location request timed out");
break;
default:
setError("An unknown error occurred");
}
reject(error);
},
// Options for getting position
{
enableHighAccuracy: true, // Use GPS if available
timeout: 5000, // Time to wait for position
maximumAge: 0, // Don't use cached position
},
);
});
// After permission granted, start watching position continuously
const id = navigator.geolocation.watchPosition(
// Success callback - update position when it changes
(position) => {
setPosition(position);
setError(null);
},
// Error callback - handle errors during watching
(error) => {
switch (error.code) {
case error.PERMISSION_DENIED:
setPermission("denied");
setError("Location permission denied");
break;
case error.POSITION_UNAVAILABLE:
setError("Location information is unavailable");
break;
case error.TIMEOUT:
setError("Location request timed out");
break;
default:
setError("An unknown error occurred");
}
setPosition(null);
},
// Same options as above
{
enableHighAccuracy: true,
maximumAge: 0,
timeout: 5000,
},
);
// Store the watch ID for cleanup
setWatchId(id);
} catch (err) {
setError("Error requesting location permission");
console.error("Error:", err);
}
};
// Return current state and request function
return {
position, // Current position data
permission, // Current permission status
error, // Any error messages
requestPermission, // Function to request permissions
};
}
This hook does the following:
- Checks for Geolocation Support: Confirms if the browser supports the Geolocation API.
- Requests Permission: Prompts the user for permission to access their location.
- Tracks Location: Uses
navigator.geolocation.watchPosition
to continuously monitor the user's location. - Handles Errors: Gracefully handles errors like permission denials, unavailable location data, and timeouts.
- Provides State: Returns the current
position
,permission
status, and anyerror
messages.
Device Orientation: Sensing Direction
Now, let's tap into the Device Orientation API to get the device's heading. This API provides information about the device's physical orientation in 3D space.
Here's the useCompass
custom hook:
/**
* Custom hook to handle compass/device orientation functionality
* Manages device orientation tracking, permissions, and magnetic declination
* @param {Object} props - Hook properties
* @param {GeolocationPosition} props.userPosition - User's current position for magnetic declination calculation
*/
function useCompass({ userPosition }) {
// Track permission state for device orientation API
const [permission, setPermission] = useState("unknown");
// Store current direction (degrees and cardinal direction)
const [direction, setDirection] = useState(null);
// Track whether device supports orientation API
const [hasSupport, setHasSupport] = useState(true);
// Store magnetic declination value (difference between true and magnetic north)
const magneticDeclinationRef = useRef(0);
// Store manual calibration offset
const offsetRef = useRef(0);
/**
* Checks if the device supports orientation events
* Sets hasSupport state and default direction if not supported
*/
const checkSupport = () => {
if (typeof window === "undefined") return false;
if (!window.DeviceOrientationEvent) {
alert("Your device does not support compass functionality.");
setHasSupport(false);
setDirection({ degrees: 0, cardinal: "N" });
return false;
}
return true;
};
/**
* Handles device orientation events
* Calculates heading based on device type (iOS vs Android)
* Applies magnetic declination and manual offset corrections
*/
const handleOrientation = (event) => {
let heading = 0;
// For iOS devices - uses native compass heading
if (event?.webkitCompassHeading) {
heading = event.webkitCompassHeading;
}
// For Android devices - uses alpha value and screen orientation
else if (event.alpha !== null) {
const screenOrientation = window.screen.orientation?.angle || 0;
heading = (360 - event.alpha + screenOrientation) % 360;
} else {
setHasSupport(false);
setDirection({ degrees: 0, cardinal: "N" });
}
if (heading !== undefined) {
// Apply magnetic declination and manual offset corrections
const adjustedHeading =
(heading + magneticDeclinationRef.current + 360 + offsetRef.current) %
360;
let cardinalDirection = getCardinalDirection(adjustedHeading);
setDirection({
degrees: Math.round(adjustedHeading),
cardinal: cardinalDirection,
});
}
};
/**
* Requests permission to use device orientation
* Handles different permission models for iOS and other devices
*/
const requestPermission = async () => {
if (!checkSupport()) return;
// iOS requires explicit permission request
if (
typeof DeviceOrientationEvent !== "undefined" &&
// @ts-expect-error requestPermission is supported in iOS
typeof DeviceOrientationEvent.requestPermission === "function"
) {
try {
// @ts-expect-error requestPermission is supported in iOS
const response = await DeviceOrientationEvent.requestPermission();
setPermission(response);
if (response === "granted") {
window.addEventListener("deviceorientation", handleOrientation);
}
} catch (error) {
console.error("Error requesting orientation permission:", error);
setPermission("error");
}
} else {
// Non-iOS devices - add listener directly
window.addEventListener("deviceorientation", handleOrientation);
setPermission("granted");
}
};
// Fetch magnetic declination when user position changes
useEffect(() => {
if (!userPosition || magneticDeclinationRef.current !== 0) return;
getMagneticDeclination(
userPosition.coords.latitude,
userPosition.coords.longitude,
).then((declination) => {
magneticDeclinationRef.current = declination;
});
}, [userPosition]);
// Cleanup orientation event listener
useEffect(() => {
return () => {
window.removeEventListener("deviceorientation", handleOrientation);
};
}, []);
return {
permission, // Current permission status
direction, // Current direction data
setDirection, // Function to manually set direction
requestPermission, // Function to request permissions
hasSupport, // Whether device supports orientation
magneticDeclination: magneticDeclinationRef.current, // Current magnetic declination
offsetRef, // Reference to manual calibration offset
};
}
Key features of the useCompass
hook:
- Checks for Device Orientation Support: Verifies if the
DeviceOrientationEvent
is available in the browser. - Handles Permissions: Specifically addresses the permission model required by iOS, using
DeviceOrientationEvent.requestPermission()
. - Listens for Orientation Changes: Attaches an event listener to
deviceorientation
to track changes in the device's orientation. - Calculates Heading: Determines the compass heading based on whether the device is iOS or Android. iOS provides a
webkitCompassHeading
property, while Android uses thealpha
value (rotation around the Z-axis). - Applies Corrections: Applies corrections for magnetic declination (using data fetched based on the user's location) and manual offsets.
- Provides State: Returns the current
permission
status,direction
(degrees and cardinal direction), a function torequestPermission
, and a boolean indicatinghasSupport
.
Magnetic Declination: Finding True North
The Earth's magnetic field isn't perfectly aligned with its geographic poles. This means that magnetic north (what a compass points to) is different from true north. The difference is called magnetic declination, and it varies depending on your location.
To get the magnetic declination, we'll use the NOAA's National Centers for Environmental Information API. We'll use the getMagneticDeclination
function:
/**
* Fetches magnetic declination from NOAA API
* @param {number} latitude - User's latitude
* @param {number} longitude - User's longitude
* @returns {Promise<number>} Magnetic declination value
*/
async function getMagneticDeclination(latitude, longitude) {
const response = await fetch(
`https://www.ngdc.noaa.gov/geomag-web/calculators/calculateDeclination?lat1=${latitude}&lon1=${longitude}&key=${process.env.NEXT_PUBLIC_NOAA_API_KEY}&resultFormat=json`,
);
const data = await response.json();
if (!data?.result || data?.result?.length === 0) return 0;
const declination = data.result[0].declination;
return declination;
}
Important: You'll need to get an API key from NOAA and store it in your environment variables as NEXT_PUBLIC_NOAA_API_KEY
.
This function fetches the magnetic declination from the NOAA API using the provided latitude and longitude.
Cardinal Direction:
Let's not forget the getCardinalDirection
function that simply takes a heading as input and spits out its equivalent cardinal direction.
/**
* Converts compass heading to cardinal direction
* @param {number} heading - Compass heading in degrees
* @returns {string} Cardinal direction (N, NE, E, SE, S, SW, W, NW)
*/
function getCardinalDirection(heading) {
let cardinalDirection;
if (heading >= 337.5 || heading < 22.5) {
cardinalDirection = "N";
} else if (heading >= 22.5 && heading < 67.5) {
cardinalDirection = "NE";
} else if (heading >= 67.5 && heading < 112.5) {
cardinalDirection = "E";
} else if (heading >= 112.5 && heading < 157.5) {
cardinalDirection = "SE";
} else if (heading >= 157.5 && heading < 202.5) {
cardinalDirection = "S";
} else if (heading >= 202.5 && heading < 247.5) {
cardinalDirection = "SW";
} else if (heading >= 247.5 && heading < 292.5) {
cardinalDirection = "W";
} else if (heading >= 292.5 && heading < 337.5) {
cardinalDirection = "NW";
}
return cardinalDirection;
}
Putting It All Together: The Compass Component
Now, let's combine the useGeolocation
and useCompass
hooks to create the main Compass
component:
export default function Compass() {
const { position, requestPermission: requestGeolocationPermission } =
useGeolocation();
const {
permission: compassPermission,
direction,
setDirection,
hasSupport: hasDeviceOrientationSupport,
requestPermission: requestCompassPermission,
magneticDeclination,
offsetRef,
} = useCompass({ userPosition: position });
async function onRequestPermission() {
await requestCompassPermission();
await requestGeolocationPermission();
}
// handling manual heading input
// i.e. on desktop devices you can click on the heading degrees and manually input your heading
function handleManualHeadingInput(e) {
const newDirection = e.currentTarget.value;
if (isNaN(parseFloat(newDirection))) return;
if (parseFloat(newDirection) < 0 || parseFloat(newDirection) > 360) return;
setDirection({
degrees: parseFloat(newDirection),
cardinal: getCardinalDirection(parseFloat(newDirection)),
});
}
function getNorthTransformValue() {
// Define the radius of the compass circle in pixels
// This determines how far the north indicator will move from the center
const maxRadius = 140;
const scaledRadius = maxRadius;
// Convert compass degrees to radians and negate for correct rotation direction
// We negate because CSS rotation goes clockwise, while compass degrees go counter-clockwise
const angleInRadians = -(direction?.degrees || 0) * (Math.PI / 180);
// Calculate the x and y coordinates for the north indicator
// Using trigonometry to position the indicator along the circle's circumference
const x = scaledRadius * Math.sin(angleInRadians);
const y = -scaledRadius * Math.cos(angleInRadians); // Negative because Y-axis is inverted in CSS
// Return CSS transform string that:
// 1. Translates (moves) the indicator to the calculated position
// 2. Rotates the indicator to maintain its orientation towards the center
return `translate(${x}px, ${y}px) rotate(${angleInRadians * (180 / Math.PI)}deg)`;
}
// Function to recalibrate the north indicator
// set the current direction as north
function onRecalibrateNorth() {
// If we're at 90°, we need -90° offset to get back to 0° (north)
const offset = -(direction?.degrees || 0) + offsetRef.current;
offsetRef.current = offset;
}
return (
<div className="max-w-screen-sm mx-auto space-y-4">
{/* Display a message if the device does not support the orientation API */}
{!hasDeviceOrientationSupport && (
<div className="font-mono w-full p-4 border-2 border-black">
<p className="text-red-500">
<b>Your device does not support the orientation API.</b>
<br />
You can manually input your heading by clicking on the heading
indicator in the centre of the compass.
</p>
</div>
)}
{compassPermission !== "granted" ? (
// Request permission to use compass and location
<Button onClick={onRequestPermission} className="w-full">
Enable Compass and Location
</Button>
) : (
<div className="p-4 w-full space-y-4 bg-black">
<div className="w-full flex flex-col items-center mt-4">
<div className="h-auto my-16 w-[280px] aspect-square relative text-white bg-white/10 flex items-center justify-center rounded-full">
<div className="flex flex-col items-center justify-center">
<p className="uppercase text-xs leading-tight mb-2">
Your heading and location
</p>
{/* Show the user's current heading */}
<div className="flex items-center">
<input
readOnly={hasDeviceOrientationSupport}
className="text-center text-5xl inline"
value={direction?.degrees || 0}
// this is the input that the user can manually input their heading
// it's read only if the device supports the orientation API
// i.e. on desktop devices you can manually input your heading
onChange={(e) => handleManualHeadingInput(e)}
style={{
width: `${String(direction?.degrees || 0).length}ch`,
}}
/>
<p className="text-5xl text-center">
° {direction?.cardinal}
</p>
</div>
{/* Show the user's current latitude and longitude */}
{position && (
<div className="flex gap-2">
<div className="w-full flex flex-row gap-2">
<span className="text-gray-400 uppercase">Lat</span>
<span>{position.coords.latitude.toFixed(2)}°</span>
</div>
<div className="w-full flex flex-row gap-2">
<span className="text-gray-400 uppercase">Lon</span>
<span>{position.coords.longitude.toFixed(2)}°</span>
</div>
</div>
)}
{/* Display the magnetic declination returned from NOAA API */}
{typeof magneticDeclination === "number" && (
<div className="flex justify-center">
<div className="w-full flex flex-row gap-2">
<span className="text-gray-400 uppercase">
Declination
</span>
<span>{magneticDeclination.toFixed(2)}°</span>
</div>
</div>
)}
{/* Recalibrate Button - clicking this sets the current direction the user is facing as north */}
<button
onClick={onRecalibrateNorth}
className="px-6 mt-3 w-full rounded-md cursor-pointer flex items-center justify-center gap-2 text-blue-400 leading-none text-center"
>
Recalibrate
</button>
</div>
{/* North Indicator */}
<div
className="absolute inline-flex flex-col items-center justify-center border-t-4 border-red-500 w-4"
style={{
transform: getNorthTransformValue(),
}}
/>
</div>
</div>
</div>
)}
</div>
);
}
Here's a breakdown of the Compass
component:
- Fetches Data: Uses the
useGeolocation
anduseCompass
hooks to get location and orientation data. - Requests Permissions: Combines the permission requests for both location and device orientation into a single function (
onRequestPermission
). - Handles UI State: Renders different UI elements based on the
compassPermission
state. If permission is not granted, it shows a button to request permission. Otherwise, it displays the compass data. - Displays Compass Data: Shows the user's current heading (degrees and cardinal direction), latitude, longitude, and magnetic declination.
- Recalibrates Compass: A "Recalibrate" button allows the user to manually set the current direction as north.
Visualizing the Compass: North Indicator
A key part of the UI is the north indicator. The getNorthTransformValue
function calculates the position and rotation of the north indicator based on the current heading:
function getNorthTransformValue() {
// Define the radius of the compass circle in pixels
// This determines how far the north indicator will move from the center
const maxRadius = 140;
const scaledRadius = maxRadius;
// Convert compass degrees to radians and negate for correct rotation direction
// We negate because CSS rotation goes clockwise, while compass degrees go counter-clockwise
const angleInRadians = -(direction?.degrees || 0) * (Math.PI / 180);
// Calculate the x and y coordinates for the north indicator
// Using trigonometry to position the indicator along the circle's circumference
const x = scaledRadius * Math.sin(angleInRadians);
const y = -scaledRadius * Math.cos(angleInRadians); // Negative because Y-axis is inverted in CSS
// Return CSS transform string that:
// 1. Translates (moves) the indicator to the calculated position
// 2. Rotates the indicator to maintain its orientation towards the center
return `translate(${x}px, ${y}px) rotate(${angleInRadians * (180 / Math.PI)}deg)`;
}
This function uses trigonometry to calculate the x
and y
coordinates for the north indicator, positioning it along the circumference of the compass circle. It also rotates the indicator to maintain its orientation towards the center.
Handling devices without Device Orientation API
For devices without Device Orientation API, the user can input their heading. This is controlled by:
function handleManualHeadingInput(e) {
const newDirection = e.currentTarget.value;
if (isNaN(parseFloat(newDirection))) return;
if (parseFloat(newDirection) < 0 || parseFloat(newDirection) > 360) return;
setDirection({
degrees: parseFloat(newDirection),
cardinal: getCardinalDirection(parseFloat(newDirection)),
});
}
This function is called when the value of the input changes and sets the direction accordingly. In the demo provided, users can manually input their heading by clicking on the heading indicator in the centre of the compass.
Recalibration: Fine-Tuning Accuracy
The onRecalibrateNorth
function provides a crucial feature for ensuring accuracy. Due to factors like device sensitivity and interference from nearby metal objects, the compass heading may sometimes drift. This function allows the user to easily recalibrate the compass, setting their current direction as North. While we strive for accuracy, limitations in the device's internal compass can sometimes affect precision.
// Function to recalibrate the north indicator
// set the current direction as north
function onRecalibrateNorth() {
// If we're at 90°, we need -90° offset to get back to 0° (north)
const offset = -(direction?.degrees || 0) + offsetRef.current;
offsetRef.current = offset;
}
The full code
"use client";
import { useEffect, useState, useRef } from "react";
// For the purpose of this example, we'll use a custom button component defined in this file
function Button({ children, onClick }) {
return (
<button
onClick={onClick}
className="bg-black border-black text-white font-mono max-h-[52px] max-w-full cursor-pointer flex justify-center items-center gap-1 py-3 px-10"
>
{children}
</button>
);
}
/**
* Converts compass heading to cardinal direction
* @param {number} heading - Compass heading in degrees
* @returns {string} Cardinal direction (N, NE, E, SE, S, SW, W, NW)
*/
function getCardinalDirection(heading) {
let cardinalDirection;
if (heading >= 337.5 || heading < 22.5) {
cardinalDirection = "N";
} else if (heading >= 22.5 && heading < 67.5) {
cardinalDirection = "NE";
} else if (heading >= 67.5 && heading < 112.5) {
cardinalDirection = "E";
} else if (heading >= 112.5 && heading < 157.5) {
cardinalDirection = "SE";
} else if (heading >= 157.5 && heading < 202.5) {
cardinalDirection = "S";
} else if (heading >= 202.5 && heading < 247.5) {
cardinalDirection = "SW";
} else if (heading >= 247.5 && heading < 292.5) {
cardinalDirection = "W";
} else if (heading >= 292.5 && heading < 337.5) {
cardinalDirection = "NW";
}
return cardinalDirection;
}
/**
* Fetches magnetic declination from NOAA API
* @param {number} latitude - User's latitude
* @param {number} longitude - User's longitude
* @returns {Promise<number>} Magnetic declination value
*/
async function getMagneticDeclination(latitude, longitude) {
const response = await fetch(
`https://www.ngdc.noaa.gov/geomag-web/calculators/calculateDeclination?lat1=${latitude}&lon1=${longitude}&key=${process.env.NEXT_PUBLIC_NOAA_API_KEY}&resultFormat=json`,
);
const data = await response.json();
if (!data?.result || data?.result?.length === 0) return 0;
const declination = data.result[0].declination;
return declination;
}
/**
* Custom hook to handle compass/device orientation functionality
* Manages device orientation tracking, permissions, and magnetic declination
* @param {Object} props - Hook properties
* @param {GeolocationPosition} props.userPosition - User's current position for magnetic declination calculation
*/
function useCompass({ userPosition }) {
// Track permission state for device orientation API
const [permission, setPermission] = useState("unknown");
// Store current direction (degrees and cardinal direction)
const [direction, setDirection] = useState(null);
// Track whether device supports orientation API
const [hasSupport, setHasSupport] = useState(true);
// Store magnetic declination value (difference between true and magnetic north)
const magneticDeclinationRef = useRef(0);
// Store manual calibration offset
const offsetRef = useRef(0);
/**
* Checks if the device supports orientation events
* Sets hasSupport state and default direction if not supported
*/
const checkSupport = () => {
if (typeof window === "undefined") return false;
if (!window.DeviceOrientationEvent) {
alert("Your device does not support compass functionality.");
setHasSupport(false);
setDirection({ degrees: 0, cardinal: "N" });
return false;
}
return true;
};
/**
* Handles device orientation events
* Calculates heading based on device type (iOS vs Android)
* Applies magnetic declination and manual offset corrections
*/
const handleOrientation = (event) => {
let heading = 0;
// For iOS devices - uses native compass heading
if (event?.webkitCompassHeading) {
heading = event.webkitCompassHeading;
}
// For Android devices - uses alpha value and screen orientation
else if (event.alpha !== null) {
const screenOrientation = window.screen.orientation?.angle || 0;
heading = (360 - event.alpha + screenOrientation) % 360;
} else {
setHasSupport(false);
setDirection({ degrees: 0, cardinal: "N" });
}
if (heading !== undefined) {
// Apply magnetic declination and manual offset corrections
const adjustedHeading =
(heading + magneticDeclinationRef.current + 360 + offsetRef.current) %
360;
let cardinalDirection = getCardinalDirection(adjustedHeading);
setDirection({
degrees: Math.round(adjustedHeading),
cardinal: cardinalDirection,
});
}
};
/**
* Requests permission to use device orientation
* Handles different permission models for iOS and other devices
*/
const requestPermission = async () => {
if (!checkSupport()) return;
// iOS requires explicit permission request
if (
typeof DeviceOrientationEvent !== "undefined" &&
// @ts-expect-error requestPermission is supported in iOS
typeof DeviceOrientationEvent.requestPermission === "function"
) {
try {
// @ts-expect-error requestPermission is supported in iOS
const response = await DeviceOrientationEvent.requestPermission();
setPermission(response);
if (response === "granted") {
window.addEventListener("deviceorientation", handleOrientation);
}
} catch (error) {
console.error("Error requesting orientation permission:", error);
setPermission("error");
}
} else {
// Non-iOS devices - add listener directly
window.addEventListener("deviceorientation", handleOrientation);
setPermission("granted");
}
};
// Fetch magnetic declination when user position changes
useEffect(() => {
if (!userPosition || magneticDeclinationRef.current !== 0) return;
getMagneticDeclination(
userPosition.coords.latitude,
userPosition.coords.longitude,
).then((declination) => {
magneticDeclinationRef.current = declination;
});
}, [userPosition]);
// Cleanup orientation event listener
useEffect(() => {
return () => {
window.removeEventListener("deviceorientation", handleOrientation);
};
}, []);
return {
permission, // Current permission status
direction, // Current direction data
setDirection, // Function to manually set direction
requestPermission, // Function to request permissions
hasSupport, // Whether device supports orientation
magneticDeclination: magneticDeclinationRef.current, // Current magnetic declination
offsetRef, // Reference to manual calibration offset
};
}
/**
* Custom hook to handle geolocation functionality
* Manages device location tracking, permissions, and error states
*/
function useGeolocation() {
// Track the user's current position
const [position, setPosition] = useState(null);
// Track permission state: 'prompt', 'granted', or 'denied'
const [permission, setPermission] = useState("prompt");
// Track any errors that occur during geolocation
const [error, setError] = useState(null);
// Store the ID returned by watchPosition to cleanup later
const [watchId, setWatchId] = useState(null);
// Cleanup: Remove the position watcher when component unmounts
// or when watchId changes
useEffect(() => {
return () => {
if (watchId !== null) {
navigator.geolocation.clearWatch(watchId);
}
};
}, [watchId]);
/**
* Request permission to access user's location and start tracking
* This function handles both the initial permission request and
* continuous location tracking
*/
const requestPermission = async () => {
// Check if browser supports geolocation
if (!navigator.geolocation) {
setError("Geolocation is not supported by your browser");
return;
}
try {
// First, get initial position - this triggers the permission prompt
await new Promise((resolve, reject) => {
navigator.geolocation.getCurrentPosition(
// Success callback
(position) => {
setPermission("granted");
setPosition(position);
setError(null);
resolve();
},
// Error callback - handle various error scenarios
(error) => {
switch (error.code) {
case error.PERMISSION_DENIED:
setPermission("denied");
setError("Location permission denied");
break;
case error.POSITION_UNAVAILABLE:
setError("Location information is unavailable");
break;
case error.TIMEOUT:
setError("Location request timed out");
break;
default:
setError("An unknown error occurred");
}
reject(error);
},
// Options for getting position
{
enableHighAccuracy: true, // Use GPS if available
timeout: 5000, // Time to wait for position
maximumAge: 0, // Don't use cached position
},
);
});
// After permission granted, start watching position continuously
const id = navigator.geolocation.watchPosition(
// Success callback - update position when it changes
(position) => {
setPosition(position);
setError(null);
},
// Error callback - handle errors during watching
(error) => {
switch (error.code) {
case error.PERMISSION_DENIED:
setPermission("denied");
setError("Location permission denied");
break;
case error.POSITION_UNAVAILABLE:
setError("Location information is unavailable");
break;
case error.TIMEOUT:
setError("Location request timed out");
break;
default:
setError("An unknown error occurred");
}
setPosition(null);
},
// Same options as above
{
enableHighAccuracy: true,
maximumAge: 0,
timeout: 5000,
},
);
// Store the watch ID for cleanup
setWatchId(id);
} catch (err) {
setError("Error requesting location permission");
console.error("Error:", err);
}
};
// Return current state and request function
return {
position, // Current position data
permission, // Current permission status
error, // Any error messages
requestPermission, // Function to request permissions
};
}
export default function Compass() {
const { position, requestPermission: requestGeolocationPermission } =
useGeolocation();
const {
permission: compassPermission,
direction,
setDirection,
hasSupport: hasDeviceOrientationSupport,
requestPermission: requestCompassPermission,
magneticDeclination,
offsetRef,
} = useCompass({ userPosition: position });
async function onRequestPermission() {
await requestCompassPermission();
await requestGeolocationPermission();
}
// handling manual heading input
// i.e. on desktop devices you can click on the heading degrees and manually input your heading
function handleManualHeadingInput(e) {
const newDirection = e.currentTarget.value;
if (isNaN(parseFloat(newDirection))) return;
if (parseFloat(newDirection) < 0 || parseFloat(newDirection) > 360) return;
setDirection({
degrees: parseFloat(newDirection),
cardinal: getCardinalDirection(parseFloat(newDirection)),
});
}
function getNorthTransformValue() {
// Define the radius of the compass circle in pixels
// This determines how far the north indicator will move from the center
const maxRadius = 140;
const scaledRadius = maxRadius;
// Convert compass degrees to radians and negate for correct rotation direction
// We negate because CSS rotation goes clockwise, while compass degrees go counter-clockwise
const angleInRadians = -(direction?.degrees || 0) * (Math.PI / 180);
// Calculate the x and y coordinates for the north indicator
// Using trigonometry to position the indicator along the circle's circumference
const x = scaledRadius * Math.sin(angleInRadians);
const y = -scaledRadius * Math.cos(angleInRadians); // Negative because Y-axis is inverted in CSS
// Return CSS transform string that:
// 1. Translates (moves) the indicator to the calculated position
// 2. Rotates the indicator to maintain its orientation towards the center
return `translate(${x}px, ${y}px) rotate(${angleInRadians * (180 / Math.PI)}deg)`;
}
// Function to recalibrate the north indicator
// set the current direction as north
function onRecalibrateNorth() {
// If we're at 90°, we need -90° offset to get back to 0° (north)
const offset = -(direction?.degrees || 0) + offsetRef.current;
offsetRef.current = offset;
}
return (
<div className="max-w-screen-sm mx-auto space-y-4">
{/* Display a message if the device does not support the orientation API */}
{!hasDeviceOrientationSupport && (
<div className="font-mono w-full p-4 border-2 border-black">
<p className="text-red-500">
<b>Your device does not support the orientation API.</b>
<br />
You can manually input your heading by clicking on the heading
indicator in the centre of the compass.
</p>
</div>
)}
{compassPermission !== "granted" ? (
// Request permission to use compass and location
<Button onClick={onRequestPermission} className="w-full">
Enable Compass and Location
</Button>
) : (
<div className="p-4 w-full space-y-4 bg-black">
<div className="w-full flex flex-col items-center mt-4">
<div className="h-auto my-16 w-[280px] aspect-square relative text-white bg-white/10 flex items-center justify-center rounded-full">
<div className="flex flex-col items-center justify-center">
<p className="uppercase text-xs leading-tight mb-2">
Your heading and location
</p>
{/* Show the user's current heading */}
<div className="flex items-center">
<input
readOnly={hasDeviceOrientationSupport}
className="text-center text-5xl inline"
value={direction?.degrees || 0}
// this is the input that the user can manually input their heading
// it's read only if the device supports the orientation API
// i.e. on desktop devices you can manually input your heading
onChange={(e) => handleManualHeadingInput(e)}
style={{
width: `${String(direction?.degrees || 0).length}ch`,
}}
/>
<p className="text-5xl text-center">
° {direction?.cardinal}
</p>
</div>
{/* Show the user's current latitude and longitude */}
{position && (
<div className="flex gap-2">
<div className="w-full flex flex-row gap-2">
<span className="text-gray-400 uppercase">Lat</span>
<span>{position.coords.latitude.toFixed(2)}°</span>
</div>
<div className="w-full flex flex-row gap-2">
<span className="text-gray-400 uppercase">Lon</span>
<span>{position.coords.longitude.toFixed(2)}°</span>
</div>
</div>
)}
{/* Display the magnetic declination returned from NOAA API */}
{typeof magneticDeclination === "number" && (
<div className="flex justify-center">
<div className="w-full flex flex-row gap-2">
<span className="text-gray-400 uppercase">
Declination
</span>
<span>{magneticDeclination.toFixed(2)}°</span>
</div>
</div>
)}
{/* Recalibrate Button - clicking this sets the current direction the user is facing as north */}
<button
onClick={onRecalibrateNorth}
className="px-6 mt-3 w-full rounded-md cursor-pointer flex items-center justify-center gap-2 text-blue-400 leading-none text-center"
>
Recalibrate
</button>
</div>
{/* North Indicator */}
<div
className="absolute inline-flex flex-col items-center justify-center border-t-4 border-red-500 w-4"
style={{
transform: getNorthTransformValue(),
}}
/>
</div>
</div>
</div>
)}
</div>
);
}