Add FOV cone visualization to map screens; refactor geoMath utility for heading and cone calculations

Have FOV config as it's own thing so it's easier to change the size of the FOV triangle without having to change multiple parts of the maths for it.

Fov triangle needed because for some reason maps doesn't let us have our own fov ??
This commit is contained in:
bobbert committed 2026-04-07 17:19:25 +01:00
1 parent c4c8fcf91d
commit 1e405bb87f
5 files changed
+64 -84

No files matched your search

+13 -27
View File
@@ -1,6 +1,6 @@
import React from 'react';
import {StyleSheet, View} from 'react-native';
import MapView, {Circle, Marker} from 'react-native-maps';
import MapView, {Circle, Polygon} from 'react-native-maps';
import {getFovCone} from '../../utils/geoMath';
const PlayerMapView = ({ userLocation, visibleCache, heading }) => {
// Initialisation -------------
@@ -10,6 +10,10 @@ const PlayerMapView = ({ userLocation, visibleCache, heading }) => {
if (!userLocation) return null;
const coneCoords = (heading !== null && heading !== undefined)
? getFovCone(userLocation, heading)
: null;
return (
<MapView
style={{flex: 1}}
@@ -32,35 +36,17 @@ const PlayerMapView = ({ userLocation, visibleCache, heading }) => {
strokeColor="rgba(250, 204, 21, 0.90)"
/>
) : null}
{heading !== null && heading !== undefined && (
<Marker
coordinate={userLocation}
flat={true}
rotation={heading}
anchor={{x: 0.5, y: 1.0}}
tracksViewChanges={false}
>
<View style={styles.coneWrap}>
<View style={styles.cone}/>
</View>
</Marker>
{coneCoords && (
<Polygon
coordinates={coneCoords}
fillColor="rgba(66,133,244,0.28)"
strokeColor="rgba(66,133,244,0.50)"
strokeWidth={1}
/>
)}
</MapView>
);
};
const styles = StyleSheet.create({
coneWrap: {width: 22, height: 24, alignItems: 'center'},
cone: {
width: 0,
height: 0,
borderTopWidth: 24,
borderLeftWidth: 11,
borderRightWidth: 11,
borderTopColor: 'rgba(37,99,235,0.70)',
borderLeftColor: 'transparent',
borderRightColor: 'transparent',
},
});
export default PlayerMapView;
+14 -26
View File
@@ -1,10 +1,10 @@
import React, {useEffect, useState} from 'react';
import {StyleSheet, View} from 'react-native';
import MapView, {Circle, Marker} from 'react-native-maps';
import MapView, {Circle, Marker, Polygon} from 'react-native-maps';
import * as Location from 'expo-location';
import {Magnetometer} from 'expo-sensors';
import Screen from '../layout/Screen';
import {toHeading} from '../../utils/geoMath';
import {getFovCone, toHeading} from '../../utils/geoMath';
const DEFAULT_REGION = {latitude: 51.5074, longitude: -0.1278, latitudeDelta: 0.01, longitudeDelta: 0.01};
@@ -54,6 +54,10 @@ const ExpandedMapScreen = ({route}) => {
// View -----------------------
const coneCoords = (userLocation && heading !== null)
? getFovCone(userLocation, heading)
: null;
return (
<Screen showBack={true} style={styles.container}>
<View style={styles.mapWrap}>
@@ -78,19 +82,14 @@ const ExpandedMapScreen = ({route}) => {
/>
</React.Fragment>
))}
{/* Heading cone for both admin and player */}
{userLocation && heading !== null && (
<Marker
coordinate={userLocation}
flat={true}
rotation={heading}
anchor={{x: 0.5, y: 1.0}}
tracksViewChanges={false}
>
<View style={styles.coneWrap}>
<View style={styles.cone}/>
</View>
</Marker>
{/* Heading FOV cone */}
{coneCoords && (
<Polygon
coordinates={coneCoords}
fillColor="rgba(66,133,244,0.28)"
strokeColor="rgba(66,133,244,0.50)"
strokeWidth={1}
/>
)}
</MapView>
</View>
@@ -101,17 +100,6 @@ const ExpandedMapScreen = ({route}) => {
const styles = StyleSheet.create({
container: {padding: 0},
mapWrap: {flex: 1},
coneWrap: {width: 22, height: 24, alignItems: 'center'},
cone: {
width: 0,
height: 0,
borderTopWidth: 24,
borderLeftWidth: 11,
borderRightWidth: 11,
borderTopColor: 'rgba(37,99,235,0.70)',
borderLeftColor: 'transparent',
borderRightColor: 'transparent',
},
});
export default ExpandedMapScreen;
+8 -25
View File
@@ -1,6 +1,6 @@
import React, {useCallback, useEffect, useState} from 'react';
import {ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, TextInput, View} from 'react-native';
import MapView, {Circle, Marker} from 'react-native-maps';
import MapView, {Circle, Marker, Polygon} from 'react-native-maps';
import * as Location from 'expo-location';
import {Magnetometer} from 'expo-sensors';
import Screen from '../layout/Screen';
@@ -11,7 +11,7 @@ import PlayerMapView from '../gameplay/PlayerMapView';
import useGameHook from '../../hooks/useGameHook';
import usePlayerGame from '../../hooks/usePlayerGame';
import {getSession, setSessionGroup, setSessionUser} from '../../hooks/SessionStore';
import {toHeading} from '../../utils/geoMath';
import {getFovCone, toHeading} from '../../utils/geoMath';
const DEFAULT_REGION = {latitude: 51.5074, longitude: -0.1278, latitudeDelta: 0.05, longitudeDelta: 0.05};
@@ -378,17 +378,12 @@ const MapScreen = ({navigation}) => {
</React.Fragment>
))}
{userLocation && heading !== null && (
<Marker
coordinate={userLocation}
flat={true}
rotation={heading}
anchor={{x: 0.5, y: 1.0}}
tracksViewChanges={false}
>
<View style={styles.headingConeWrap}>
<View style={styles.headingCone}/>
</View>
</Marker>
<Polygon
coordinates={getFovCone(userLocation, heading)}
fillColor="rgba(66,133,244,0.28)"
strokeColor="rgba(66,133,244,0.50)"
strokeWidth={1}
/>
)}
</MapView>
<Pressable style={styles.expandButton} onPress={handleExpandMap}>
@@ -563,18 +558,6 @@ const styles = StyleSheet.create({
cacheSection: {flex: 1, backgroundColor: '#f3f4f6', paddingHorizontal: 12, paddingTop: 10},
emptyText: {color: '#9ca3af', textAlign: 'center', marginTop: 20, fontSize: 14},
mapWrap: {flex: 1},
// Heading direction cone (like Google Maps)
headingConeWrap: {width: 22, height: 24, alignItems: 'center'},
headingCone: {
width: 0,
height: 0,
borderTopWidth: 24,
borderLeftWidth: 11,
borderRightWidth: 11,
borderTopColor: 'rgba(37,99,235,0.70)',
borderLeftColor: 'transparent',
borderRightColor: 'transparent',
},
});
export default MapScreen;
+28 -3
View File
@@ -1,9 +1,34 @@
import * as geolib from 'geolib';
// Convert raw Magnetometer {x, y} reading to a 0-360° heading
// --- FOV Configuration ---
const FOV_DISTANCE = 20; // metres — radius of arc + length of straight edges
const FOV_ANGLE = 45; // degrees — total field of view (halved for each side)
// Convert raw Magnetometer {x, y} reading to a 0-360° heading (0° = north)
const toHeading = ({x, y}) => {
const angle = Math.atan2(y, x) * (180 / Math.PI);
return angle < 0 ? angle + 360 : angle;
const heading = 90 - angle;
return heading < 0 ? heading + 360 : heading % 360;
};
// Build a pie-shaped FOV sector as polygon coordinates
const getFovCone = (location, headingDeg, radiusMeters = FOV_DISTANCE, halfAngle = FOV_ANGLE / 2, arcSegments = 20) => {
const origin = {latitude: location.latitude, longitude: location.longitude};
const startBearing = headingDeg - halfAngle;
const endBearing = headingDeg + halfAngle;
const step = (endBearing - startBearing) / arcSegments;
// Tip of the sector
const points = [location];
// Arc points at radiusMeters distance from origin
for (let i = 0; i <= arcSegments; i++) {
const bearing = startBearing + step * i;
const dest = geolib.computeDestinationPoint(origin, radiusMeters, bearing);
points.push({latitude: dest.latitude, longitude: dest.longitude});
}
return points;
};
const isWithinRadius = (playerCoords, cacheCoords, radius) => {
@@ -20,5 +45,5 @@ const isLookingAtCache = (playerHeading, playerCoords, cacheCoords, tolerance =
return diff <= tolerance || diff >= 360 - tolerance;
};
export { toHeading, isWithinRadius, isLookingAtCache };
export { toHeading, getFovCone, isWithinRadius, isLookingAtCache };