fuzzyworld

north-star / commit

got north working, and a few minor changes

bb3374b77b36309b9d2e98cc285bd7d21015aa22 · Pavel Safronov · 2026-03-19 00:07:33 -0700 · parents 018eb20f1334

got north working, and a few minor changes
 mobile/App.tsx                             |  71 +++++-----------
 mobile/src/platform/heading.ts             |  65 ++++++++++-----
 mobile/src/screens/room_session_screen.tsx | 128 ++++++++++++++++++++---------
 3 files changed, 153 insertions(+), 111 deletions(-)

diff --git a/mobile/App.tsx b/mobile/App.tsx
index ec41f57..38a5165 100644
--- a/mobile/App.tsx
+++ b/mobile/App.tsx
@@ -1,11 +1,12 @@
 import { useEffect, useState } from 'react';
 import { StatusBar } from 'expo-status-bar';
-import { StyleSheet, Text, View } from 'react-native';
+import { StyleSheet, View } from 'react-native';
 import type { SymmetricKey } from './src/core/crypto';
 import { loadProfile, saveProfile, loadLastRoom, saveLastRoom } from './src/platform/profile';
 import { OnboardingScreen } from './src/screens/onboarding_screen';
 import { RoomSessionScreen } from './src/screens/room_session_screen';
 import { SafeAreaView } from 'react-native-safe-area-context';
+import { requestLocationPermission, watchPosition, type LatLon } from './src/platform/geolocation';
 
 const createDefaultDisplayName = () => `mobile-${Math.floor(1000 + Math.random() * 9000)}`;
 
@@ -14,6 +15,8 @@ export default function App() {
   const [displayColor, setDisplayColor] = useState('#3366cc');
   const [activeRoom, setActiveRoom] = useState<{ roomHash: string; roomKey: SymmetricKey | null } | null>(null);
   const [lastRoomHash, setLastRoomHash] = useState<string | null>(null);
+  const [position, setPosition] = useState<LatLon | null>(null);
+  const [locationStatus, setLocationStatus] = useState<'idle' | 'denied' | 'watching'>('idle');
 
   useEffect(() => {
     Promise.all([loadProfile(), loadLastRoom()]).then(([saved, lastHash]) => {
@@ -25,6 +28,19 @@ export default function App() {
     }).catch(() => { /* ignore storage errors on load */ });
   }, []);
 
+  // Acquire GPS as soon as the app opens so it is ready when entering a room
+  useEffect(() => {
+    let stopWatching: (() => void) | null = null;
+    const start = async () => {
+      const granted = await requestLocationPermission();
+      if (!granted) { setLocationStatus('denied'); return; }
+      setLocationStatus('watching');
+      stopWatching = watchPosition(setPosition, () => { /* surfaced in room screen */ });
+    };
+    void start();
+    return () => { stopWatching?.(); };
+  }, []);
+
   useEffect(() => {
     const id = setTimeout(() => {
       void saveProfile(displayName, displayColor);
@@ -32,8 +48,6 @@ export default function App() {
     return () => clearTimeout(id);
   }, [displayName, displayColor]);
 
-  const isInSession = !!activeRoom;
-
   const handleStartRoom = (params: { roomHash: string; roomKey: SymmetricKey | null }) => {
     setLastRoomHash(params.roomHash);
     void saveLastRoom(params.roomHash);
@@ -43,17 +57,6 @@ export default function App() {
   return (
     <SafeAreaView style={styles.root}>
       <StatusBar style="dark" />
-      <View style={styles.progressWrap}>
-        <View style={styles.progressHeaderRow}>
-          <View style={[styles.stepPill, !isInSession && styles.stepPillActive]}>
-            <Text style={[styles.stepPillText, !isInSession && styles.stepPillTextActive]}>1. Setup</Text>
-          </View>
-          <View style={[styles.stepConnector, isInSession && styles.stepConnectorActive]} />
-          <View style={[styles.stepPill, isInSession && styles.stepPillActive]}>
-            <Text style={[styles.stepPillText, isInSession && styles.stepPillTextActive]}>2. Live Room</Text>
-          </View>
-        </View>
-      </View>
       {!activeRoom && (
         <OnboardingScreen
           displayName={displayName}
@@ -70,6 +73,8 @@ export default function App() {
           roomKey={activeRoom.roomKey}
           displayName={displayName}
           displayColor={displayColor}
+          position={position}
+          locationStatus={locationStatus}
           onLeaveRoom={() => setActiveRoom(null)}
         />
       )}
@@ -81,43 +86,5 @@ const styles = StyleSheet.create({
   root: {
     flex: 1,
     backgroundColor: '#f5f1ea'
-  },
-  progressWrap: {
-    paddingHorizontal: 20,
-    paddingTop: 12,
-    paddingBottom: 8,
-    backgroundColor: '#efe8d9',
-    borderBottomWidth: 1,
-    borderBottomColor: '#ddd2bc'
-  },
-  progressHeaderRow: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    gap: 8
-  },
-  stepPill: {
-    borderRadius: 999,
-    backgroundColor: '#d9cfbb',
-    paddingHorizontal: 12,
-    paddingVertical: 6
-  },
-  stepPillActive: {
-    backgroundColor: '#7a4f2a'
-  },
-  stepPillText: {
-    color: '#5f5648',
-    fontSize: 12,
-    fontWeight: '700'
-  },
-  stepPillTextActive: {
-    color: '#f6ead9'
-  },
-  stepConnector: {
-    height: 2,
-    flex: 1,
-    backgroundColor: '#cdbfa6'
-  },
-  stepConnectorActive: {
-    backgroundColor: '#7a4f2a'
   }
 });
diff --git a/mobile/src/platform/heading.ts b/mobile/src/platform/heading.ts
index 65bee36..858f716 100644
--- a/mobile/src/platform/heading.ts
+++ b/mobile/src/platform/heading.ts
@@ -1,33 +1,54 @@
-import { Magnetometer } from 'expo-sensors';
+import * as Location from 'expo-location';
+
+// Lower alpha = smoother but slower to track movement. 0.15 is a good balance.
+const SMOOTH_ALPHA = 0.15;
 
 /**
- * Watches the device magnetometer and calls onHeading with the
- * compass bearing in degrees (0 = north, 90 = east, etc.).
+ * Watches device heading using the platform's tilt-compensated compass
+ * (CoreLocation on iOS, SensorManager fusion on Android).
+ * Raw magnetometer atan2 breaks when the phone is not horizontal;
+ * this API handles all tilt cases correctly at the OS level.
+ *
+ * Applies circular EMA smoothing (via sin/cos) to reduce jitter.
  * Returns an unsubscribe function.
  */
 export const watchHeading = (
     onHeading: (headingDeg: number) => void,
-    onError: (message: string) => void
+    onError: (message: string) => void,
+    onRawHeading?: (rawDeg: number) => void
 ): (() => void) => {
-    Magnetometer.setUpdateInterval(200);
+    let sSin = 0;
+    let sCos = 1;
+    let initialized = false;
+    let subscription: Location.LocationSubscription | null = null;
+    let disposed = false;
 
-    const subscription = Magnetometer.addListener(({ x, y }) => {
-        // atan2 gives angle from east; convert to bearing from north
-        let heading = Math.atan2(y, x) * (180 / Math.PI);
-        // Rotate: magnetic east is 90° from geographic north
-        heading = 90 - heading;
-        // Normalize to 0-360
-        heading = ((heading % 360) + 360) % 360;
-        onHeading(heading);
-    });
+    Location.watchHeadingAsync((locationHeading) => {
+        const rawDeg = locationHeading.magHeading;
+        onRawHeading?.(rawDeg);
 
-    Magnetometer.isAvailableAsync()
-        .then((available) => {
-            if (!available) onError('Magnetometer unavailable on this device');
-        })
-        .catch(() => {
-            onError('Could not check magnetometer availability');
-        });
+        const rad = rawDeg * Math.PI / 180;
+        if (!initialized) {
+            sSin = Math.sin(rad);
+            sCos = Math.cos(rad);
+            initialized = true;
+        } else {
+            sSin = SMOOTH_ALPHA * Math.sin(rad) + (1 - SMOOTH_ALPHA) * sSin;
+            sCos = SMOOTH_ALPHA * Math.cos(rad) + (1 - SMOOTH_ALPHA) * sCos;
+        }
+
+        const smoothedDeg = ((Math.atan2(sSin, sCos) * 180 / Math.PI) % 360 + 360) % 360;
+        onHeading(smoothedDeg);
+    }).then((sub) => {
+        if (disposed) sub.remove();
+        else subscription = sub;
+    }).catch((err: unknown) => {
+        const message = err instanceof Error ? err.message : 'Could not watch device heading';
+        onError(message);
+    });
 
-    return () => subscription.remove();
+    return () => {
+        disposed = true;
+        subscription?.remove();
+    };
 };
diff --git a/mobile/src/screens/room_session_screen.tsx b/mobile/src/screens/room_session_screen.tsx
index 112411d..488c9d8 100644
--- a/mobile/src/screens/room_session_screen.tsx
+++ b/mobile/src/screens/room_session_screen.tsx
@@ -4,29 +4,37 @@ import {
     Image,
     Pressable,
     ScrollView,
+    Share,
     StyleSheet,
     Text,
     View
 } from 'react-native';
 import AsyncStorage from '@react-native-async-storage/async-storage';
+import { Magnetometer, Accelerometer } from 'expo-sensors';
 import QRCode from 'react-native-qrcode-svg';
 import { ArrowsPanel, type ArrowFriend } from '../components/arrows_panel';
-import { getBearingDeg, getRelativeBearingDeg } from '../core/bearing';
+import { getBearingDeg, getRelativeBearingDeg, normalizeAngle } from '../core/bearing';
 import { parseRoomFromHash } from '../core/room';
 import type { SymmetricKey } from '../core/crypto';
 import { watchHeading } from '../platform/heading';
 import { subscribeToFriends, upsertSelfFriend, type MobileFriendRecord } from '../features/friends/sync';
 import { initFirebaseSession, type FirebaseSession } from '../platform/firebase';
-import { requestLocationPermission, watchPosition, type LatLon } from '../platform/geolocation';
+import type { LatLon } from '../platform/geolocation';
 import { PUBLIC_SETTINGS } from '../public_settings';
 
 const MOBILE_SYNC_INTERVAL_MS = 10000;
+const NORTH_FRIEND_ID = '__north__';
+const NORTH_COLOR = '#000000';
+// 100 yards in latitude degrees (~91.44 m)
+const NORTH_YARDS_LAT_OFFSET = 91.44 / 111320;
 
 type RoomSessionScreenProps = {
     roomHash: string;
     roomKey: SymmetricKey | null;
     displayName: string;
     displayColor: string;
+    position: LatLon | null;
+    locationStatus: 'idle' | 'denied' | 'watching';
     onLeaveRoom: () => void;
 };
 
@@ -43,32 +51,52 @@ export function RoomSessionScreen({
     roomKey,
     displayName,
     displayColor,
+    position,
+    locationStatus,
     onLeaveRoom
 }: RoomSessionScreenProps) {
     const [firebaseSession, setFirebaseSession] = useState<FirebaseSession | null>(null);
     const [friends, setFriends] = useState<MobileFriendRecord[]>([]);
     const [syncError, setSyncError] = useState<string | null>(null);
-    const [position, setPosition] = useState<LatLon | null>(null);
-    const [locationStatus, setLocationStatus] = useState<'idle' | 'denied' | 'watching'>('idle');
     const [headingDeg, setHeadingDeg] = useState(0);
+    const [headingOffset, setHeadingOffset] = useState(0);
+    const [rawHeadingDeg, setRawHeadingDeg] = useState<number | null>(null);
     const [showQr, setShowQr] = useState(false);
     const [customOrder, setCustomOrder] = useState<string[]>([]);
     const [hiddenFriendIds, setHiddenFriendIds] = useState<Set<string>>(new Set());
+    const [rawMag, setRawMag] = useState<{ x: number; y: number; z: number } | null>(null);
+    const [rawAccel, setRawAccel] = useState<{ x: number; y: number; z: number } | null>(null);
 
     const parsedRoom = useMemo(() => parseRoomFromHash(roomHash), [roomHash]);
     const inviteUrl = `${PUBLIC_SETTINGS.links.gitlabPagesBaseUrl}#${roomHash}`;
     const hiddenStorageKey = parsedRoom ? `friends:hidden:${parsedRoom.roomId}` : null;
 
+    // Virtual "North" friend — always 100 yards north of current position
+    const northVirtualFriend = useMemo((): MobileFriendRecord => ({
+        id: NORTH_FRIEND_ID,
+        name: 'North',
+        color: NORTH_COLOR,
+        position: position
+            ? { lat: position.lat + NORTH_YARDS_LAT_OFFSET, lon: position.lon }
+            : undefined
+    }), [position]);
+
+    const allFriends = useMemo(
+        () => [northVirtualFriend, ...friends],
+        [northVirtualFriend, friends]
+    );
+
     const orderedFriends = useMemo(() => {
-        const knownIds = customOrder.filter(id => friends.some(f => f.id === id));
-        const newFriends = friends.filter(f => !customOrder.includes(f.id));
+        const knownIds = customOrder.filter(id => allFriends.some(f => f.id === id));
+        const newFriends = allFriends.filter(f => !customOrder.includes(f.id));
         return [
-            ...knownIds.map(id => friends.find(f => f.id === id)!),
+            ...knownIds.map(id => allFriends.find(f => f.id === id)!),
             ...newFriends
         ];
-    }, [friends, customOrder]);
+    }, [allFriends, customOrder]);
 
     const arrowFriends = useMemo((): ArrowFriend[] => {
+        const calibrated = normalizeAngle(headingDeg - headingOffset);
         return orderedFriends
             .filter(f => !hiddenFriendIds.has(f.id))
             .map(f => {
@@ -76,11 +104,11 @@ export function RoomSessionScreen({
                     ? getBearingDeg(position, f.position)
                     : null;
                 const arrowDeg = bearingDeg !== null
-                    ? getRelativeBearingDeg(bearingDeg, headingDeg)
+                    ? getRelativeBearingDeg(bearingDeg, calibrated)
                     : null;
                 return { id: f.id, name: f.name || f.id, color: f.color ?? '#888888', arrowDeg };
             });
-    }, [orderedFriends, hiddenFriendIds, position, headingDeg]);
+    }, [orderedFriends, hiddenFriendIds, position, headingDeg, headingOffset]);
 
     const moveUp = (index: number) => {
         if (index <= 0) return;
@@ -109,6 +137,11 @@ export function RoomSessionScreen({
         });
     };
 
+    const calibrate = () => {
+        setHeadingOffset(headingDeg);
+        void AsyncStorage.setItem('compass:heading_offset', String(headingDeg));
+    };
+
     // Auto-connect Firebase on mount
     useEffect(() => {
         let disposed = false;
@@ -132,36 +165,30 @@ export function RoomSessionScreen({
         }).catch(() => { /* ignore */ });
     }, [hiddenStorageKey]);
 
-    // Start location watching on mount
+    // Raw sensor subscriptions for debug display
     useEffect(() => {
-        let stopWatching: (() => void) | null = null;
-
-        const start = async () => {
-            const granted = await requestLocationPermission();
-            if (!granted) {
-                setLocationStatus('denied');
-                return;
-            }
-            setLocationStatus('watching');
-            stopWatching = watchPosition(
-                (nextPosition) => setPosition(nextPosition),
-                (message) => setSyncError(message)
-            );
-        };
-
-        void start();
+        Magnetometer.setUpdateInterval(200);
+        Accelerometer.setUpdateInterval(200);
+        const subMag = Magnetometer.addListener(({ x, y, z }) => setRawMag({ x, y, z }));
+        const subAccel = Accelerometer.addListener(({ x, y, z }) => setRawAccel({ x, y, z }));
+        return () => { subMag.remove(); subAccel.remove(); };
+    }, []);
 
-        return () => {
-            stopWatching?.();
-            setLocationStatus('idle');
-        };
+    // Load persisted heading calibration offset
+    useEffect(() => {
+        AsyncStorage.getItem('compass:heading_offset').then((raw) => {
+            if (!raw) return;
+            const parsed = parseFloat(raw);
+            if (!isNaN(parsed)) setHeadingOffset(parsed);
+        }).catch(() => { /* ignore */ });
     }, []);
 
     // Start heading watching on mount
     useEffect(() => {
         return watchHeading(
             (deg) => setHeadingDeg(deg),
-            (msg) => setSyncError(msg)
+            (msg) => setSyncError(msg),
+            (raw) => setRawHeadingDeg(raw)
         );
     }, []);
 
@@ -219,14 +246,14 @@ export function RoomSessionScreen({
         };
     }, [displayColor, displayName, firebaseSession, parsedRoom, position, roomKey]);
 
-    // Absorb newly-seen friends into the custom order
+    // Absorb newly-seen friends (including virtual) into the custom order
     useEffect(() => {
         setCustomOrder(prev => {
-            const newIds = friends.filter(f => !prev.includes(f.id)).map(f => f.id);
+            const newIds = allFriends.filter(f => !prev.includes(f.id)).map(f => f.id);
             if (newIds.length === 0) return prev;
             return [...prev, ...newIds];
         });
-    }, [friends]);
+    }, [allFriends]);
 
     return (
         <ScrollView contentContainerStyle={styles.container}>
@@ -259,6 +286,10 @@ export function RoomSessionScreen({
                     )}
                     {locationStatus === 'denied' && <Text style={styles.errorText}>Location denied</Text>}
                     {locationStatus === 'watching' && !position && <Text style={styles.valueMuted}>waiting for GPS…</Text>}
+                    <View style={{ flex: 1 }} />
+                    <Pressable onPress={calibrate} style={({ pressed }) => [styles.calibrateButton, pressed && styles.buttonPressed]}>
+                        <Text style={styles.calibrateButtonText}>Calibrate</Text>
+                    </Pressable>
                 </View>
                 <ArrowsPanel friends={arrowFriends} />
                 {/* Friend rows with controls */}
@@ -272,7 +303,7 @@ export function RoomSessionScreen({
                                 <Text style={[styles.value, styles.friendName, isHidden && styles.friendNameHidden]} numberOfLines={1}>
                                     {friend.name || friend.id}
                                 </Text>
-                                {!hasPos && !isHidden && <Text style={styles.noPos}>no pos</Text>}
+                                {!hasPos && !isHidden && friend.id !== NORTH_FRIEND_ID && <Text style={styles.noPos}>no pos</Text>}
                                 <Pressable
                                     onPress={() => toggleHidden(friend.id)}
                                     style={({ pressed }) => [styles.hideButton, pressed && styles.buttonPressed]}
@@ -310,7 +341,9 @@ export function RoomSessionScreen({
             {/* ── Invite QR ── */}
             <View style={styles.card}>
                 <View style={styles.qrWrap}>
-                    <Text style={styles.valueMuted}>Invite others to join this room</Text>
+                    <Pressable onPress={() => void Share.share({ message: `Join me at Friend Compass: ${inviteUrl}` })}>
+                        <Text style={styles.shareLink}>Invite others...</Text>
+                    </Pressable>
                     <QRCode value={inviteUrl} size={200} />
                 </View>
             </View>
@@ -483,5 +516,26 @@ const styles = StyleSheet.create({
         fontSize: 10,
         color: '#2e2a23',
         lineHeight: 12
+    },
+    calibrateButton: {
+        backgroundColor: '#d7ccb9',
+        borderRadius: 6,
+        paddingHorizontal: 10,
+        paddingVertical: 4
+    },
+    calibrateButtonText: {
+        fontSize: 12,
+        color: '#5b4a3a',
+        fontWeight: '600'
+    },
+    shareLink: {
+        fontSize: 14,
+        color: '#7a4f2a',
+        textDecorationLine: 'underline',
+        fontWeight: '500'
+    },
+    debugText: {
+        fontSize: 12,
+        color: '#5b554c'
     }
 });