javascriptalgorithmmathtrigonometryatan2

How to change Math.atan2's zero axis to any given angle, while keeping it in the range of [-π, π]?


Is there a neat way to map the output of Math.atan2 to another range while keeping the result still in [-180°, 180°], as if the calculation does not start from the positive X-axis (rightwards = 0°), but from any given angle?

This question and this question have a similar gist, but it only concerned a particular solution.

IMO, the method of Math.atan2 is fairly quirky, because it does not align with cartographical convention to calculate azimuth angles, which should start from positive Y-axis (or upwards) instead. However, tweaking the mathematics behind arctan isn't obviously a good choice; I believe that mapping the output angle should be better.

My finding is, you need to classify the angles into at least 2 categories in order to ensure the result to be in the range of [-180°, 180°]. I know that rotate() can handle results out of this range and no rounding is needed. However, I was asking a way to let the output stay in that range.

For instance, an output of a 30-degree-clockwise shifted Math.atan2 should look like this:

Before (Right=0°) After (30°SE=0°)
-30°
30°
60° 30°
120° 90°
180° 150°
-180° 150°
-120° -150°
-60° -90°
-30° -60°

I noted that there is a discontinuity at -180°/180° in this mapping method, which caused most of the problem here. A small two-way conditional is probably sufficient to solve this problem, but is there any simple way to achieve this result (of any given rotate angle θ) without using any conditionals?

A visual depiction. Any coordinates that fall into the purple overlapping area need a different way to calculate.

Here is a short code I once used to solve this problem.

Context: the code's intention was to get a rough latitude & longitude coordinate after the user clicks inside a circle representing Earth. The guide map on it was rotatable, but to change the orientation of the map image would affect too much other stuff, so I wanted to find a simple way to process the output, regardless of in which direction the map was placed on the Earth panel. I have removed some irrelevant code here.

earth.addEventListener('click', function (e) {

            // The coordinates of the point of click within the circle is retrieved as (clickX, clickY).
            
            // If the final angle is positive, offset it with 180°. Otherwise offset it with -180°. Notice that it is a specific case.

            let longitude = Math.atan2(clickY, clickX) * 180 / Math.PI;

            if (longitude <= 0) {
                longitude = (-180 - longitude);
            } else {
                longitude = (180 - longitude);
            }

            console.log("Longitude: " + longitude);
}

If the offset was 30°, and we try to achieve the effect of the table above, the code should be written instead like:

if (longitude <= -150 && longitude > -180) {
    longitude = (longitude - 30);
} else {
    longitude = (longitude + 330);
}

Obviously, if we don't need the result to be within [-180°, 180°], the if-else was unnecessary at all. But for this case, are there any better ways?


Solution

  • I found a "double modulo" that helps on the issue Y.T. and Ture points out

    const tbody = document.getElementById('table-body');
    const offsetField = document.getElementById('offset');
    const testAngles = [0, 30, 60, 120, 180, -180, -120, -60, -30];
    const shiftedAtan2Deg = (y, x, zeroAxisDeg) => {
      const radToDeg = 180 / Math.PI;
      const degToRad = Math.PI / 180;
      let angle = Math.atan2(y, x) * radToDeg - zeroAxisDeg;
    
      // Normalize to [-180°, 180°] using double-modulo 
      angle = (((angle + 180) % 360) + 360) % 360 - 180;
      return angle;
    };
    
    
    const testTable = (zeroAxis) => {
      tbody.innerHTML = '';
      if (zeroAxis >  360) zeroAxis = 360;
      if (zeroAxis <  -360) zeroAxis = -360;
      offsetField.value = zeroAxis;
      for (const angle of testAngles) {
        const rad = angle * Math.PI / 180;
        const y = Math.sin(rad);
        const x = Math.cos(rad);
        const shifted = shiftedAtan2Deg(y, x, zeroAxis);
        tbody.innerHTML += `<tr><td>${angle}°</td><td>${shifted.toFixed(1)}°</td></tr>`;
      }
    };
    // generating your table with offset°
    offsetField.addEventListener('input', (e) => testTable(+e.target.value));
    testTable(30); // initial
    table {
      border-collapse: collapse;
      max-width: 210px;
    }
    
    td,
    th {
      text-align: right;
      padding: 4px 8px;
      border: 1px solid #ccc;
    }
    
    #offset {
      width: 40px;
    }
    <table>
      <thead>
        <tr>
          <th>Input</th>
          <th>Zero at <input type="number" id="offset" min="-360" max="360" step="0.1" value="30"/></label>°</th>
        </tr>
      </thead>
      <tbody id="table-body"></tbody>
    </table>