9cb0793347
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).
158 lines
7.8 KiB
Svelte
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>
|