mobile-device-orientation-ui

/home/avalon/.hermes/skills/software-development/mobile-device-orientation-ui/SKILL.md · raw

Mobile Device Orientation UI

When to Use

Use this when building or debugging a mobile web feature that depends on:

Core Model

Separate these concepts explicitly:

  1. Sensor heading — the raw/derived phone compass heading, normalized to 0..360 for readouts and calculations.
  2. World targets — fixed azimuths/bearings in the real world, such as planets, places, or cardinal directions.
  3. Visual heading — the heading value used for CSS transforms. This may need to be unwrapped across 359° ↔ 0° so animation remains smooth.
  4. Instruction math — shortest angular delta between target azimuth and current heading.
  5. Pitch/altitude alignment — for physical phone tilt, compare phone pitch to the target's local apparent altitude above the horizon, not to raw ecliptic latitude. Ecliptic latitude can be shown as symbolic/astrological metadata, but AR-lite sky pointing uses altitude/azimuth.

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.

iOS Permission Flow

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.

Heading Derivation

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.

Circular UI Rules

For a phone-pointing compass/sky finder:

Example radial transform:

function polarStyle(azimuth, visualHeading, radius) {
  const rotation = azimuth - visualHeading;
  return {
    transform: `rotate(${rotation}deg) translateY(${radius}) rotate(${-rotation}deg)`,
  };
}

0°/360° Wraparound Animation

CSS transforms can spin the long way around when values jump across North:

Maintain 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.

Planet Pitch / Altitude Alignment

When building a PWA sky-finder panel, combine:

Recommended UI pattern:

See references/pwa-planet-pitch-alignment.md for a concise implementation pattern and helper formulas.

Debugging Checklist

  1. Confirm permission state separately for location and compass.
  2. Confirm target data loads independently from compass data.
  3. If target data loads but heading is missing, show “waiting for compass,” not alignment instructions.
  4. Log or display whether heading events are arriving.
  5. Test direct Safari/PWA separately from in-app browsers; iOS webviews may behave differently.
  6. Cross North slowly and verify no full-spin wraparound.
  7. Rotate through each cardinal direction and verify N/E/S/W move with the targets under the fixed needle.

References