fuzzyworld

north-star / files / src / features / compass / compass.ts

compass.ts

11.9 KB · history · blame

1import { normalizeAngle, normalizeSignedAngle } from '../../core/utils';
2import { PUBLIC_SETTINGS } from '../../public_settings';
3import { createToast } from '../../core/toast';
4
5type CompassControllerOptions = {
6 calibrateHeadingLink: HTMLElement | null;
7 initialHeadingCorrectionDeg: number;
8 onHeadingCorrectionDegChange: (next: number) => void;
9 requestRender: () => void;
10};
11
12type CompassPermissionState = 'granted' | 'denied';
13
14export const initCompassController = ({
15 calibrateHeadingLink,
16 initialHeadingCorrectionDeg,
17 onHeadingCorrectionDegChange,
18 requestRender
19}: CompassControllerOptions) => {
20 const HEADING_CORRECTION_STORAGE_KEY = 'friend-compass-heading-correction-deg';
21 const compassToast = createToast({ className: 'wake-lock-toast' });
22 const deviceOrientationCtor =
23 typeof DeviceOrientationEvent !== 'undefined'
24 ? (DeviceOrientationEvent as unknown as { requestPermission?: () => Promise<CompassPermissionState> })
25 : null;
26
27 const requiresExplicitCompassPermission =
28 typeof deviceOrientationCtor?.requestPermission === 'function';
29 const hasDeviceOrientationSupport = typeof DeviceOrientationEvent !== 'undefined';
30 const compassDefinitelyUnsupported = !hasDeviceOrientationSupport;
31
32 let headingCorrectionDeg = initialHeadingCorrectionDeg;
33 let selfHeading = 0;
34 let lastRawHeading: number | null = null;
35
36 let compassEnabled = !requiresExplicitCompassPermission;
37 let hasSeenOrientationEvent = false;
38 let hasSeenHeadingValue = false;
39 let compassButtonStatusLabel = '';
40 let compassButtonStatusTitle = '';
41 let compassButtonStatusError = false;
42 let compassStatusTimer: number | null = null;
43 let compassProbeTimer: number | null = null;
44 let hasAbsoluteOrientation = false;
45
46 const getHeadingUnavailableMessage = () => {
47 if (compassDefinitelyUnsupported) {
48 return 'This browser/device does not expose orientation sensors, so a compass is unavailable here.';
49 }
50
51 if (requiresExplicitCompassPermission && !compassEnabled) {
52 return 'Compass access is not enabled yet. Tap Enable Compass first.';
53 }
54
55 if (!hasSeenOrientationEvent) {
56 return 'No orientation sensor data has been detected on this device. This setup likely has no compass/motion sensor available (common on desktop/laptop hardware).';
57 }
58
59 if (!hasSeenHeadingValue) {
60 return 'Orientation events are arriving, but compass heading is unavailable in this browser/device context.';
61 }
62
63 return 'Heading sensor data is not ready yet. Try again in a moment.';
64 };
65
66 const getHeadingUnavailableStatus = () => {
67 if (compassDefinitelyUnsupported) {
68 return {
69 label: 'Compass Unavailable',
70 title: 'This browser/device does not expose orientation sensors, so a compass is unavailable here.',
71 isError: true
72 };
73 }
74
75 if (requiresExplicitCompassPermission && !compassEnabled) {
76 return {
77 label: 'Enable Compass First',
78 title: 'Compass access is not enabled yet. Tap Enable Compass first.',
79 isError: true
80 };
81 }
82
83 if (!hasSeenOrientationEvent) {
84 return {
85 label: 'No Sensor Data',
86 title: 'No orientation sensor data has been detected on this device. This setup likely has no compass/motion sensor available (common on desktop/laptop hardware).',
87 isError: true
88 };
89 }
90
91 if (!hasSeenHeadingValue) {
92 return {
93 label: 'Heading Unavailable',
94 title: 'Orientation events are arriving, but compass heading is unavailable in this browser/device context.',
95 isError: true
96 };
97 }
98
99 return {
100 label: 'Sensor Warming Up',
101 title: 'Heading sensor data is not ready yet. Try again in a moment.',
102 isError: false
103 };
104 };
105
106 const clearCompassButtonStatus = () => {
107 if (compassStatusTimer) {
108 clearTimeout(compassStatusTimer);
109 compassStatusTimer = null;
110 }
111
112 compassButtonStatusLabel = '';
113 compassButtonStatusTitle = '';
114 compassButtonStatusError = false;
115 };
116
117 const clearCompassProbeTimer = () => {
118 if (!compassProbeTimer) return;
119 clearTimeout(compassProbeTimer);
120 compassProbeTimer = null;
121 };
122
123 const showCompassButtonStatus = ({
124 label,
125 title = '',
126 isError = false,
127 durationMs = 0
128 }: {
129 label: string;
130 title?: string;
131 isError?: boolean;
132 durationMs?: number;
133 }) => {
134 if (compassStatusTimer) {
135 clearTimeout(compassStatusTimer);
136 compassStatusTimer = null;
137 }
138
139 compassButtonStatusLabel = label;
140 compassButtonStatusTitle = title;
141 compassButtonStatusError = isError;
142
143 if (durationMs > 0) {
144 compassStatusTimer = window.setTimeout(() => {
145 clearCompassButtonStatus();
146 updateCompassButtonState();
147 }, durationMs);
148 }
149
150 updateCompassButtonState();
151 };
152
153 const updateCompassButtonState = () => {
154 if (!calibrateHeadingLink) return;
155
156 if (compassDefinitelyUnsupported) {
157 calibrateHeadingLink.textContent = 'Compass Unavailable';
158 calibrateHeadingLink.classList.add('compass-required');
159 calibrateHeadingLink.classList.remove('compass-error');
160 calibrateHeadingLink.title = getHeadingUnavailableMessage();
161 (calibrateHeadingLink as HTMLButtonElement).disabled = true;
162 return;
163 }
164
165 (calibrateHeadingLink as HTMLButtonElement).disabled = false;
166 calibrateHeadingLink.textContent = compassButtonStatusLabel || (compassEnabled ? 'Calibrate North' : 'Enable Compass');
167 calibrateHeadingLink.title = compassButtonStatusTitle;
168 calibrateHeadingLink.classList.toggle('compass-required', !compassEnabled);
169 calibrateHeadingLink.classList.toggle('compass-error', compassButtonStatusError);
170 };
171
172 const scheduleCompassProbe = () => {
173 clearCompassProbeTimer();
174
175 if (compassDefinitelyUnsupported) return;
176 if (!compassEnabled) return;
177 if (hasSeenOrientationEvent || hasSeenHeadingValue) return;
178
179 compassProbeTimer = window.setTimeout(() => {
180 if (hasSeenOrientationEvent || hasSeenHeadingValue) return;
181
182 const status = getHeadingUnavailableStatus();
183 showCompassButtonStatus({
184 label: status.label,
185 title: status.title,
186 isError: status.isError
187 });
188 }, PUBLIC_SETTINGS.compass.probeTimeoutMs);
189 };
190
191 const requestCompassPermission = async () => {
192 if (!requiresExplicitCompassPermission) {
193 return true;
194 }
195
196 try {
197 const permissionState = await deviceOrientationCtor?.requestPermission?.();
198 return permissionState === 'granted';
199 } catch (err) {
200 console.error('Failed to request compass permission', err);
201 return false;
202 }
203 };
204
205 const getScreenOrientationAngle = () => {
206 if (window.screen?.orientation && typeof window.screen.orientation.angle === 'number') {
207 return window.screen.orientation.angle;
208 }
209 if (typeof (window as any).orientation === 'number') {
210 return (window as any).orientation as number;
211 }
212 return 0;
213 };
214
215 const getHeadingFromEvent = (orientationEvent: DeviceOrientationEvent) => {
216 const webkitEvent = orientationEvent as DeviceOrientationEvent & { webkitCompassHeading?: number };
217 if (typeof webkitEvent.webkitCompassHeading === 'number') {
218 return normalizeAngle(webkitEvent.webkitCompassHeading);
219 }
220
221 if (orientationEvent.alpha === null) {
222 return null;
223 }
224
225 const alphaHeading = normalizeAngle(360 - orientationEvent.alpha);
226 return normalizeAngle(alphaHeading + getScreenOrientationAngle());
227 };
228
229 updateCompassButtonState();
230 scheduleCompassProbe();
231
232 if (calibrateHeadingLink) {
233 calibrateHeadingLink.addEventListener('click', async (event: Event) => {
234 event.preventDefault();
235
236 if (!compassEnabled) {
237 const granted = await requestCompassPermission();
238 if (!granted) {
239 showCompassButtonStatus({
240 label: 'Compass Denied',
241 title: 'Compass permission was denied. Enable Motion & Orientation Access in Safari settings and try again.',
242 isError: true
243 });
244 compassToast.show('Compass permission denied. Enable Motion & Orientation Access in browser settings.');
245 return;
246 }
247
248 compassEnabled = true;
249 clearCompassButtonStatus();
250 updateCompassButtonState();
251 scheduleCompassProbe();
252 showCompassButtonStatus({
253 label: 'Compass Enabled',
254 title: 'Compass access is active. You can now calibrate north.',
255 durationMs: 1500
256 });
257 compassToast.show('Compass enabled. You can calibrate north now.');
258 return;
259 }
260
261 if (lastRawHeading === null) {
262 const status = getHeadingUnavailableStatus();
263 showCompassButtonStatus({
264 label: status.label,
265 title: status.title,
266 isError: status.isError,
267 durationMs: status.isError ? 0 : 1500
268 });
269 compassToast.show(status.label);
270 return;
271 }
272
273 headingCorrectionDeg = normalizeSignedAngle(-lastRawHeading);
274 localStorage.setItem(HEADING_CORRECTION_STORAGE_KEY, String(headingCorrectionDeg));
275 onHeadingCorrectionDegChange(headingCorrectionDeg);
276 selfHeading = normalizeAngle(lastRawHeading + headingCorrectionDeg);
277
278 showCompassButtonStatus({
279 label: 'North Calibrated',
280 title: 'Calibration applied.',
281 durationMs: 1500
282 });
283 compassToast.show('North calibrated.');
284 requestRender();
285 });
286 }
287
288 window.addEventListener('deviceorientationabsolute', (event: DeviceOrientationEvent) => {
289 clearCompassProbeTimer();
290 hasSeenOrientationEvent = true;
291 const heading = getHeadingFromEvent(event);
292 if (heading === null) return;
293
294 if (!compassEnabled) {
295 compassEnabled = true;
296 updateCompassButtonState();
297 }
298
299 hasSeenHeadingValue = true;
300 if (compassButtonStatusError) {
301 clearCompassButtonStatus();
302 updateCompassButtonState();
303 }
304
305 hasAbsoluteOrientation = true;
306 lastRawHeading = heading;
307 selfHeading = normalizeAngle(heading + headingCorrectionDeg);
308 requestRender();
309 });
310
311 window.addEventListener('deviceorientation', (event: DeviceOrientationEvent) => {
312 if (hasAbsoluteOrientation) return;
313
314 clearCompassProbeTimer();
315 hasSeenOrientationEvent = true;
316
317 const heading = getHeadingFromEvent(event);
318 if (heading === null) return;
319
320 if (!compassEnabled) {
321 compassEnabled = true;
322 updateCompassButtonState();
323 }
324
325 hasSeenHeadingValue = true;
326 if (compassButtonStatusError) {
327 clearCompassButtonStatus();
328 updateCompassButtonState();
329 }
330
331 lastRawHeading = heading;
332 selfHeading = normalizeAngle(heading + headingCorrectionDeg);
333 requestRender();
334 });
335
336 return {
337 getSelfHeading: () => selfHeading,
338 scheduleCompassProbe
339 };
340};