Use this when building or debugging a mobile web feature that depends on:
DeviceOrientationEvent, deviceorientation, or deviceorientationabsolutewebkitCompassHeading, or motion/orientation permission promptsSeparate these concepts explicitly:
0..360 for readouts and calculations.359° ↔ 0° so animation remains smooth.Do not let a display fallback like heading ?? 0 drive real alignment instructions. If no heading event has arrived yet, show a waiting/pending state even if location and target data are loaded. Apply the same rule to pitch: show “waiting for phone pitch” until a real beta/pitch reading arrives.
On iOS/Safari, DeviceOrientationEvent.requestPermission() must be called during the active user gesture.
Recommended pattern for one button that needs both compass and location:
const enable = async () => {
const compassPromise = compass.request(); // starts immediately from tap
const locationPromise = geo.request();
const [okCompass, okLocation] = await Promise.all([compassPromise, locationPromise]);
};
Pitfall: if the code awaits geolocation first and only then asks for compass, the location prompt can consume the user gesture. The compass permission may silently fail or be marked denied.
Typical browser logic:
if (Number.isFinite(event.webkitCompassHeading)) {
heading = normalizeAngle(event.webkitCompassHeading);
} else if (Number.isFinite(event.alpha)) {
heading = normalizeAngle(360 - event.alpha);
}
Use webkitCompassHeading on iOS when available. Track accuracy if provided (webkitCompassAccuracy) and warn that readings can drift near magnets or indoors.
For a phone-pointing compass/sky finder:
targetAzimuth - heading.N = 0°E = 90°S = 180°W = 270°Example radial transform:
function polarStyle(azimuth, visualHeading, radius) {
const rotation = azimuth - visualHeading;
return {
transform: `rotate(${rotation}deg) translateY(${radius}) rotate(${-rotation}deg)`,
};
}
CSS transforms can spin the long way around when values jump across North:
359° → 0° should be +1°-359deg → 0deg and animate a full rotationMaintain an unwrapped visual heading for transforms:
function shortestAngleDelta(next, prev) {
return ((((next - prev) % 360) + 540) % 360) - 180;
}
setVisualHeading((prevVisual) => {
const nextVisual = prevVisual + shortestAngleDelta(sensorHeading, previousSensorHeading.current);
previousSensorHeading.current = sensorHeading;
return nextVisual;
});
Use normalized heading for readouts and alignment math; use unwrapped visual heading for animated transforms.
When building a PWA sky-finder panel, combine:
Recommended UI pattern:
Enable sky align button that starts orientation permission and location/sky-data fetch in parallel from the same user gesture.0° horizon line, target altitude marker, and phone pitch marker.beta axes and natural phone grip vary. Put it near the enable flow, not hidden behind a secondary control.beta/gamma before returning on missing heading so pitch UI can become ready while compass heading is still pending/noisy.navigator.vibrate() opportunistically with no hard dependency.±6° pitch and ±3..8° heading, rather than exact alignment.See references/pwa-planet-pitch-alignment.md for a concise implementation pattern and helper formulas.
references/sky-compass-ios-wraparound.md — session-derived notes from the Astral Hermes planet finder implementation and iPhone field feedback.