Files
nur-falah-prevention/src/lib/QiblaFinder.svelte
T
wmj 9cb0793347 fix: Qibla not detecting device compass on iOS and Android
Two real bugs, both explaining 'didn't detect compass':

1. iOS: DeviceOrientationEvent.requestPermission() was called *after*
   the async geolocation round-trip resolved. Safari's user-activation
   grant for sensor permissions expires almost immediately, so by the
   time a GPS fix comes back (often several seconds), the permission
   call silently fails with no prompt ever shown. Now requested
   synchronously inside the button's click handler, before geolocation
   starts.

2. Android: the heading was only accepted when e.absolute === true, but
   many Android browsers fire plain 'deviceorientation' with
   absolute:false even though alpha is a perfectly usable heading —
   compass detection silently never succeeded on those devices at all.
   Now accepts alpha regardless of the absolute flag, falling back to
   it when webkitCompassHeading (iOS) isn't present.

Also fixed a duplicate-listener bug: clicking 'Refresh location'
repeatedly re-registered deviceorientation listeners without removing
the previous ones, stacking duplicates on every retry.

Verified live: dispatched a synthetic non-absolute deviceorientation
event (the exact Android failure shape) against the deployed app —
compass detection now activates correctly where it previously stayed
silently stuck on the no-sensor fallback. Added as a permanent
regression assertion in e2e-khairat-lifestyle.cjs (16/16).
2026-08-14 15:23:17 +08:00

158 lines
7.8 KiB
Svelte

<script>
// Qibla direction — pure client-side great-circle bearing calculation from
// the device's GPS to the Kaaba. No API, no key, no external service:
// this is closed-form spherical trigonometry, not a lookup.
import Disclaimer from './Disclaimer.svelte';
import InfoPanel from './InfoPanel.svelte';
const KAABA_LAT = 21.4225;
const KAABA_LON = 39.8262;
let status = $state('idle'); // idle | locating | ready | error
let errorMsg = $state('');
let bearing = $state(null); // degrees from true north, 0-360
let distanceKm = $state(null);
let compassHeading = $state(null); // device heading, if sensor available
let compassSupported = $state(false);
function toRad(deg) { return (deg * Math.PI) / 180; }
function toDeg(rad) { return (rad * 180) / Math.PI; }
function computeQibla(lat, lon) {
const phi1 = toRad(lat), phi2 = toRad(KAABA_LAT);
const dLambda = toRad(KAABA_LON - lon);
const y = Math.sin(dLambda) * Math.cos(phi2);
const x = Math.cos(phi1) * Math.sin(phi2) - Math.sin(phi1) * Math.cos(phi2) * Math.cos(dLambda);
let theta = toDeg(Math.atan2(y, x));
bearing = (theta + 360) % 360;
// Haversine distance
const R = 6371;
const dPhi = toRad(KAABA_LAT - lat);
const a = Math.sin(dPhi / 2) ** 2 + Math.cos(phi1) * Math.cos(phi2) * Math.sin(dLambda / 2) ** 2;
distanceKm = Math.round(R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)));
}
let listenersAttached = false;
function handleOrientation(e) {
let heading = e.webkitCompassHeading;
if (heading == null && e.alpha != null) {
// Prefer a true geomagnetic reading when the browser provides one
// (absolute: true), but don't require it — many Android browsers
// never set absolute:true even though alpha is still a usable
// heading, so requiring it strictly left the compass permanently
// "undetected" on those devices.
heading = 360 - e.alpha;
}
if (heading != null) { compassHeading = heading; compassSupported = true; }
}
function attachOrientationListeners() {
if (listenersAttached) return;
listenersAttached = true;
window.addEventListener('deviceorientationabsolute', handleOrientation, true);
window.addEventListener('deviceorientation', handleOrientation, true);
}
async function findQibla() {
status = 'locating';
errorMsg = '';
compassSupported = false;
compassHeading = null;
// Request device-orientation permission (iOS 13+) synchronously here,
// inside this click handler — Safari expires the user-activation grant
// almost immediately, so requesting it *after* the async geolocation
// round-trip (which can take several seconds) reliably fails silently
// with no prompt ever shown. Registering listeners for everyone else
// immediately too, so no early orientation events are missed while
// still waiting on the GPS fix.
if (typeof DeviceOrientationEvent !== 'undefined') {
if (typeof DeviceOrientationEvent.requestPermission === 'function') {
try {
const perm = await DeviceOrientationEvent.requestPermission();
if (perm === 'granted') attachOrientationListeners();
} catch { /* denied or unsupported — static bearing still shown */ }
} else {
attachOrientationListeners();
}
}
if (!navigator.geolocation) {
status = 'error'; errorMsg = 'Geolocation is not available on this device/browser.';
return;
}
navigator.geolocation.getCurrentPosition(
(pos) => { computeQibla(pos.coords.latitude, pos.coords.longitude); status = 'ready'; },
(err) => { status = 'error'; errorMsg = err.message || 'Could not get your location.'; },
{ enableHighAccuracy: true, timeout: 10000 }
);
}
const needleRotation = $derived(compassSupported && compassHeading != null && bearing != null ? bearing - compassHeading : bearing);
</script>
<div class="module">
<div class="module-header">
<h2>Qibla</h2>
<InfoPanel
title="Qibla"
what="The direction to face for prayer, calculated as the great-circle bearing from your current location to the Kaaba in Makkah — computed on your device, not looked up from a database."
how="Tap to find your direction. If your device has a compass sensor and grants permission, the arrow rotates live as you turn; otherwise you'll see the bearing in degrees from true north to align with a separate compass."
fields={[]}
/>
</div>
<p class="sub">Great-circle bearing to the Kaaba, calculated from your device's location.</p>
{#if status === 'idle'}
<button class="btn-primary" onclick={findQibla}>Find Qibla direction</button>
{:else if status === 'locating'}
<p class="status-text">Getting your location…</p>
{:else if status === 'error'}
<p class="error-text">{errorMsg}</p>
<button class="btn-primary" onclick={findQibla}>Try again</button>
{:else if status === 'ready'}
<div class="compass-card">
<div class="compass-rose">
<div class="needle" style="transform: rotate({needleRotation}deg)">
<div class="needle-tip"></div>
</div>
<span class="compass-n">N</span>
</div>
<div class="bearing-readout">
<strong>{Math.round(bearing)}°</strong>
<span>from true north</span>
</div>
{#if !compassSupported}
<p class="static-note">No live compass sensor detected — hold a compass app or physical compass and align it to {Math.round(bearing)}°.</p>
{/if}
<p class="distance-note">~{distanceKm?.toLocaleString()} km to Makkah</p>
</div>
<button class="btn-secondary" onclick={findQibla}>Refresh location</button>
{/if}
<Disclaimer text="A calculated bearing, not a certified qibla marker. Verify against a known qibla direction (e.g. your local mosque) where precision matters." />
</div>
<style>
.module { padding: 4px 0 40px; }
.module-header { display: flex; align-items: center; margin-bottom: 4px; }
h2 { font-family: 'DM Serif Display', serif; font-size: 24px; color: #E8E4DC; margin-bottom: 0; }
.sub { font-size: 13px; color: #8A8478; margin-bottom: 18px; }
.btn-primary { width: 100%; padding: 14px; border-radius: 8px; border: none; font-weight: 600; cursor: pointer; background: #C9A84C; color: #070A0D; }
.btn-secondary { width: 100%; padding: 12px; border-radius: 8px; border: 1px solid rgba(201,168,76,0.3); font-weight: 600; cursor: pointer; background: rgba(255,255,255,0.05); color: #C9A84C; margin-top: 14px; }
.status-text { font-size: 13px; color: #8A8478; text-align: center; padding: 30px 0; }
.error-text { font-size: 13px; color: #EF4444; margin-bottom: 12px; }
.compass-card { display: flex; flex-direction: column; align-items: center; padding: 20px; background: rgba(255,255,255,0.03); border-radius: 16px; }
.compass-rose { position: relative; width: 200px; height: 200px; border-radius: 50%; border: 2px solid rgba(201,168,76,0.3); display: flex; align-items: center; justify-content: center; margin-bottom: 16px; }
.needle { position: absolute; width: 4px; height: 90px; background: linear-gradient(#C9A84C, transparent); top: 10px; left: 50%; margin-left: -2px; transform-origin: bottom center; transition: transform 0.2s ease-out; }
.needle-tip { position: absolute; top: -14px; left: -9px; font-size: 20px; color: #C9A84C; }
.compass-n { position: absolute; top: 8px; font-size: 11px; color: #8A8478; }
.bearing-readout { display: flex; flex-direction: column; align-items: center; margin-bottom: 8px; }
.bearing-readout strong { font-family: 'DM Serif Display', serif; font-size: 32px; color: #C9A84C; }
.bearing-readout span { font-size: 11px; color: #8A8478; }
.static-note { font-size: 12px; color: #B8B2A6; text-align: center; margin-bottom: 8px; line-height: 1.5; }
.distance-note { font-size: 11px; color: #8A8478; }
</style>