Building a Real-World Compass Component in React

CompassDevice Orientation APIGeolocation API

Harnessing Device Orientation and Location APIs to build a real-world compass component in React.

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) or ifconfig (macOS/Linux) in your terminal to get your IPv4 address. Then, on your phone, navigate to https://{your_ipv4_address}:3000 (replace 3000 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:

  1. Setting up Geolocation: Getting the user's location to calculate magnetic declination.
  2. Harnessing Device Orientation: Accessing the device's orientation data.
  3. Calculating True Heading: Combining orientation data, location, and magnetic declination.
  4. Building the UI: Creating a visual compass that responds to device movement and location.
  5. 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 any error 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 the alpha 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 to requestPermission, and a boolean indicating hasSupport.

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">
                    °&nbsp;{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 and useCompass 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">
                    °&nbsp;{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>
  );
}
© 2025 cdnkr