Enhance map functionality: improve cache marker rendering for consistency across platforms, especially for iphone users, add fallback for proximity detection when heading is unavailable, and refactor visibility logic for better performance

This commit is contained in:
bobbert committed 2026-04-09 08:44:37 +01:00
1 parent a41f8ae6f8
commit 63c082bb8f
5 files changed
+138 -37

No files matched your search

+14 -4
View File
@@ -2,7 +2,7 @@ import {StyleSheet, Text, View} from 'react-native';
import Card from '../UI/Card'; import Card from '../UI/Card';
import {Button} from '../UI/Button'; import {Button} from '../UI/Button';
const CacheCardItem = ({cache, isAdmin, isClaimed, isSelected, onEdit, onDelete, onSelect}) => { const CacheCardItem = ({cache, isAdmin, isClaimed, isSelected, onEdit, onDelete, onSelect, disabled}) => {
// Initialisation ------------- // Initialisation -------------
const displayName = cache.name || cache.clue || 'Unnamed Cache'; const displayName = cache.name || cache.clue || 'Unnamed Cache';
@@ -12,16 +12,16 @@ const CacheCardItem = ({cache, isAdmin, isClaimed, isSelected, onEdit, onDelete,
// View ----------------------- // View -----------------------
return ( return (
<Card style={[isSelected && styles.selectedCard, isClaimed && styles.claimedCard]}> <Card style={[isSelected && styles.selectedCard, isClaimed && styles.claimedCard, disabled && styles.disabledCard]}>
<View style={styles.topRow}> <View style={styles.topRow}>
<View style={styles.nameWrap}> <View style={styles.nameWrap}>
<Text style={[styles.name, isClaimed && styles.claimedText]} numberOfLines={1}>{displayName}</Text> <Text style={[styles.name, isClaimed && styles.claimedText, disabled && styles.disabledText]} numberOfLines={1}>{displayName}</Text>
{cache.name ? ( {cache.name ? (
<Text style={styles.clue} numberOfLines={1}>{cache.clue}</Text> <Text style={styles.clue} numberOfLines={1}>{cache.clue}</Text>
) : null} ) : null}
</View> </View>
{/* Players see team-specific claim status; admins see no status */} {/* Players see team-specific claim status; admins see no status */}
{!isAdmin && ( {!isAdmin && !disabled && (
<Text style={[styles.status, {color: isClaimed ? '#9ca3af' : '#16a34a'}]}> <Text style={[styles.status, {color: isClaimed ? '#9ca3af' : '#16a34a'}]}>
{isClaimed ? 'Claimed' : 'Available'} {isClaimed ? 'Claimed' : 'Available'}
</Text> </Text>
@@ -43,6 +43,14 @@ const CacheCardItem = ({cache, isAdmin, isClaimed, isSelected, onEdit, onDelete,
styleLabel={styles.btnLabel} styleLabel={styles.btnLabel}
/> />
</> </>
) : disabled ? (
<Button
label="Join a Team First"
onClick={() => {}}
disabled={true}
styleButton={styles.claimedBtn}
styleLabel={styles.claimedBtnLabel}
/>
) : isClaimed ? ( ) : isClaimed ? (
<Button <Button
label="Already Claimed" label="Already Claimed"
@@ -123,6 +131,8 @@ const styles = StyleSheet.create({
selectedBtnLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13}, selectedBtnLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13},
selectedCard: {borderColor: '#2563eb', borderWidth: 2}, selectedCard: {borderColor: '#2563eb', borderWidth: 2},
claimedCard: {opacity: 0.5}, claimedCard: {opacity: 0.5},
disabledCard: {opacity: 0.45},
disabledText: {color: '#9ca3af'},
btnLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13}, btnLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13},
}); });
+44 -6
View File
@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import {Platform, StyleSheet, View} from 'react-native';
import MapView, {Circle, Marker, Polygon} from 'react-native-maps'; import MapView, {Circle, Marker, Polygon} from 'react-native-maps';
import {getFovCone} from '../../utils/geoMath'; import {getFovCone} from '../../utils/geoMath';
@@ -10,9 +11,8 @@ const PlayerMapView = ({userLocation, visibleCaches, heading, claimDistance}) =>
if (!userLocation) return null; if (!userLocation) return null;
const coneCoords = (heading !== null && heading !== undefined) const hasHeading = heading !== null && heading !== undefined;
? getFovCone(userLocation, heading) const coneCoords = hasHeading ? getFovCone(userLocation, heading) : null;
: null;
return ( return (
<MapView <MapView
@@ -24,14 +24,20 @@ const PlayerMapView = ({userLocation, visibleCaches, heading, claimDistance}) =>
pitchEnabled={false} pitchEnabled={false}
showsUserLocation showsUserLocation
> >
{/* Show cache markers for all caches currently inside the FOV cone */} {/* Cache markers — uses a custom View instead of pinColor so the
marker renders identically on Apple Maps (iOS) and Google Maps */}
{(visibleCaches || []).map((cache) => ( {(visibleCaches || []).map((cache) => (
<React.Fragment key={cache.id}> <React.Fragment key={cache.id}>
<Marker <Marker
coordinate={cache.coordinates} coordinate={cache.coordinates}
title={cache.clue} title={cache.clue}
pinColor="#facc15" anchor={{x: 0.5, y: 0.5}}
/> tracksViewChanges={Platform.OS === 'ios'}
>
<View style={styles.markerOuter}>
<View style={styles.markerInner} />
</View>
</Marker>
<Circle <Circle
center={cache.coordinates} center={cache.coordinates}
radius={claimDistance} radius={claimDistance}
@@ -40,6 +46,7 @@ const PlayerMapView = ({userLocation, visibleCaches, heading, claimDistance}) =>
/> />
</React.Fragment> </React.Fragment>
))} ))}
{/* FOV cone when heading is available */}
{coneCoords && ( {coneCoords && (
<Polygon <Polygon
coordinates={coneCoords} coordinates={coneCoords}
@@ -48,8 +55,39 @@ const PlayerMapView = ({userLocation, visibleCaches, heading, claimDistance}) =>
strokeWidth={1} strokeWidth={1}
/> />
)} )}
{/* Proximity circle when heading is NOT available (iOS fallback) */}
{!hasHeading && claimDistance > 0 && (
<Circle
center={userLocation}
radius={claimDistance}
fillColor="rgba(66,133,244,0.12)"
strokeColor="rgba(66,133,244,0.40)"
/>
)}
</MapView> </MapView>
); );
}; };
const styles = StyleSheet.create({
markerOuter: {
width: 26,
height: 26,
alignItems: 'center',
justifyContent: 'center',
},
markerInner: {
width: 18,
height: 18,
borderRadius: 9,
backgroundColor: '#f59e0b',
borderWidth: 2.5,
borderColor: '#ffffff',
shadowColor: '#000000',
shadowOffset: {width: 0, height: 1},
shadowOpacity: 0.3,
shadowRadius: 2,
elevation: 3,
},
});
export default PlayerMapView; export default PlayerMapView;
+50 -11
View File
@@ -1,9 +1,9 @@
import React, {useEffect, useState} from 'react'; import React, {useEffect, useState} from 'react';
import {StyleSheet, View} from 'react-native'; import {Platform, StyleSheet, View} from 'react-native';
import MapView, {Circle, Marker, Polygon} from 'react-native-maps'; import MapView, {Circle, Marker, Polygon} from 'react-native-maps';
import * as Location from 'expo-location'; import * as Location from 'expo-location';
import Screen from '../layout/Screen'; import Screen from '../layout/Screen';
import {getFovCone, isInClaimCone} from '../../utils/geoMath'; import {getFovCone, isInClaimCone, isWithinRadius} from '../../utils/geoMath';
const DEFAULT_REGION = {latitude: 51.5074, longitude: -0.1278, latitudeDelta: 0.01, longitudeDelta: 0.01}; const DEFAULT_REGION = {latitude: 51.5074, longitude: -0.1278, latitudeDelta: 0.01, longitudeDelta: 0.01};
@@ -66,13 +66,19 @@ const ExpandedMapScreen = ({route}) => {
// View ----------------------- // View -----------------------
const coneCoords = (userLocation && heading !== null) const hasHeading = heading !== null;
const coneCoords = (userLocation && hasHeading)
? getFovCone(userLocation, heading) ? getFovCone(userLocation, heading)
: null; : null;
// For players, show caches that currently fall within the FOV cone // For players — when heading is available use FOV cone, otherwise fall back
const visiblePlayerCaches = (!isAdmin && userLocation && heading !== null) // to proximity-only so the expanded map works on iPhones without compass data
? caches.filter((c) => isInClaimCone(heading, userLocation, c.coordinates, claimDistance)) const visiblePlayerCaches = (!isAdmin && userLocation)
? caches.filter((c) =>
hasHeading
? isInClaimCone(heading, userLocation, c.coordinates, claimDistance)
: isWithinRadius(userLocation, c.coordinates, claimDistance),
)
: []; : [];
return ( return (
@@ -89,8 +95,13 @@ const ExpandedMapScreen = ({route}) => {
<Marker <Marker
coordinate={cache.coordinates} coordinate={cache.coordinates}
title={cache.name || cache.clue} title={cache.name || cache.clue}
pinColor="#2563eb" anchor={{x: 0.5, y: 0.5}}
/> tracksViewChanges={Platform.OS === 'ios'}
>
<View style={styles.adminMarkerOuter}>
<View style={styles.adminMarkerInner} />
</View>
</Marker>
<Circle <Circle
center={cache.coordinates} center={cache.coordinates}
radius={claimDistance} radius={claimDistance}
@@ -99,14 +110,19 @@ const ExpandedMapScreen = ({route}) => {
/> />
</React.Fragment> </React.Fragment>
))} ))}
{/* Players only see caches that are inside the FOV cone */} {/* Players — custom View markers for Apple Maps compatibility */}
{!isAdmin && visiblePlayerCaches.map((cache) => ( {!isAdmin && visiblePlayerCaches.map((cache) => (
<React.Fragment key={cache.id}> <React.Fragment key={cache.id}>
<Marker <Marker
coordinate={cache.coordinates} coordinate={cache.coordinates}
title={cache.clue} title={cache.clue}
pinColor="#facc15" anchor={{x: 0.5, y: 0.5}}
/> tracksViewChanges={Platform.OS === 'ios'}
>
<View style={styles.cacheMarkerOuter}>
<View style={styles.cacheMarkerInner} />
</View>
</Marker>
<Circle <Circle
center={cache.coordinates} center={cache.coordinates}
radius={claimDistance} radius={claimDistance}
@@ -124,6 +140,15 @@ const ExpandedMapScreen = ({route}) => {
strokeWidth={1} strokeWidth={1}
/> />
)} )}
{/* Proximity circle when heading is unavailable (iOS fallback) */}
{!isAdmin && userLocation && !hasHeading && claimDistance > 0 && (
<Circle
center={userLocation}
radius={claimDistance}
fillColor="rgba(66,133,244,0.12)"
strokeColor="rgba(66,133,244,0.40)"
/>
)}
</MapView> </MapView>
</View> </View>
</Screen> </Screen>
@@ -133,6 +158,20 @@ const ExpandedMapScreen = ({route}) => {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: {padding: 0}, container: {padding: 0},
mapWrap: {flex: 1}, mapWrap: {flex: 1},
// Admin cache markers — blue dot
adminMarkerOuter: {width: 26, height: 26, alignItems: 'center', justifyContent: 'center'},
adminMarkerInner: {
width: 18, height: 18, borderRadius: 9,
backgroundColor: '#2563eb', borderWidth: 2.5, borderColor: '#ffffff',
shadowColor: '#000', shadowOffset: {width: 0, height: 1}, shadowOpacity: 0.3, shadowRadius: 2, elevation: 3,
},
// Player cache markers — orange dot
cacheMarkerOuter: {width: 26, height: 26, alignItems: 'center', justifyContent: 'center'},
cacheMarkerInner: {
width: 18, height: 18, borderRadius: 9,
backgroundColor: '#f59e0b', borderWidth: 2.5, borderColor: '#ffffff',
shadowColor: '#000', shadowOffset: {width: 0, height: 1}, shadowOpacity: 0.3, shadowRadius: 2, elevation: 3,
},
}); });
export default ExpandedMapScreen; export default ExpandedMapScreen;
+14 -8
View File
@@ -54,6 +54,9 @@ const MapScreen = ({navigation}) => {
const isPlayer = inGame && !isAdmin; const isPlayer = inGame && !isAdmin;
// Teams are required but the player hasn't joined one — block all claiming
const requiresTeam = Boolean(isPlayer && groupInfo?.TeamsEnabled && !session.currentTid);
// Stable empty array — avoids creating a new reference on every render // Stable empty array — avoids creating a new reference on every render
// which would trigger an infinite re-render loop in usePlayerGame // which would trigger an infinite re-render loop in usePlayerGame
const EMPTY_CACHES = useMemo(() => [], []); const EMPTY_CACHES = useMemo(() => [], []);
@@ -70,10 +73,11 @@ const MapScreen = ({navigation}) => {
}); });
}, [cacheRecords, isPlayer, session.currentTid, session.currentUid, EMPTY_CACHES]); }, [cacheRecords, isPlayer, session.currentTid, session.currentUid, EMPTY_CACHES]);
// When requiresTeam is true pass null/empty so usePlayerGame is inert
const {visibleCaches, isClaiming, setIsClaiming} = usePlayerGame( const {visibleCaches, isClaiming, setIsClaiming} = usePlayerGame(
isPlayer ? userLocation : null, (isPlayer && !requiresTeam) ? userLocation : null,
isPlayer ? heading : null, (isPlayer && !requiresTeam) ? heading : null,
activeCachesForPlayer, requiresTeam ? EMPTY_CACHES : activeCachesForPlayer,
claimDistance, claimDistance,
); );
@@ -516,13 +520,12 @@ const MapScreen = ({navigation}) => {
} }
// Player in game — use the first visible cache for the claim timer // Player in game — use the first visible cache for the claim timer
const teamsWarning = groupInfo?.TeamsEnabled && !session.currentTid;
const claimTarget = visibleCaches.length > 0 ? visibleCaches[0] : null; const claimTarget = visibleCaches.length > 0 ? visibleCaches[0] : null;
return ( return (
<Screen style={styles.containerMap}> <Screen style={styles.containerMap}>
{teamsWarning && ( {requiresTeam && (
<Text style={styles.warning}>You are not in a team. Teams are required for this game!</Text> <Text style={styles.warning}>You are not in a team. Join a team to start claiming caches!</Text>
)} )}
<View style={styles.mapContainer}> <View style={styles.mapContainer}>
<PlayerMapView <PlayerMapView
@@ -535,12 +538,14 @@ const MapScreen = ({navigation}) => {
<Text style={styles.expandIcon}>⛶</Text> <Text style={styles.expandIcon}>⛶</Text>
</Pressable> </Pressable>
</View> </View>
{!requiresTeam && (
<ClaimTimerView <ClaimTimerView
cache={claimTarget} cache={claimTarget}
isClaiming={isClaiming} isClaiming={isClaiming}
onClaimSuccess={handleClaim} onClaimSuccess={handleClaim}
showClaimedPopup={claimedPopupVisible} showClaimedPopup={claimedPopupVisible}
/> />
)}
<View style={styles.cacheSection}> <View style={styles.cacheSection}>
<ScrollView> <ScrollView>
{cacheRecords.map((cache) => { {cacheRecords.map((cache) => {
@@ -551,8 +556,9 @@ const MapScreen = ({navigation}) => {
cache={cache} cache={cache}
isAdmin={false} isAdmin={false}
isClaimed={claimed} isClaimed={claimed}
isSelected={!claimed && selectedCacheId === cache.id} isSelected={!claimed && !requiresTeam && selectedCacheId === cache.id}
onSelect={claimed ? undefined : handleSelectCache} onSelect={(claimed || requiresTeam) ? undefined : handleSelectCache}
disabled={requiresTeam}
/> />
); );
})} })}
+16 -8
View File
@@ -1,5 +1,5 @@
import {useEffect, useRef, useState} from 'react'; import {useEffect, useRef, useState} from 'react';
import {isInClaimCone} from '../utils/geoMath'; import {isInClaimCone, isWithinRadius} from '../utils/geoMath';
const usePlayerGame = (playerLocation, playerHeading, activeCaches, claimDistance) => { const usePlayerGame = (playerLocation, playerHeading, activeCaches, claimDistance) => {
// State ---------------------- // State ----------------------
@@ -11,7 +11,8 @@ const usePlayerGame = (playerLocation, playerHeading, activeCaches, claimDistanc
// Handlers ------------------- // Handlers -------------------
useEffect(() => { useEffect(() => {
if (!playerLocation || playerHeading === null || playerHeading === undefined) { // No location at all — clear everything
if (!playerLocation) {
if (prevIdsRef.current !== '') { if (prevIdsRef.current !== '') {
prevIdsRef.current = ''; prevIdsRef.current = '';
setVisibleCaches([]); setVisibleCaches([]);
@@ -20,17 +21,24 @@ const usePlayerGame = (playerLocation, playerHeading, activeCaches, claimDistanc
return; return;
} }
// Find all caches that fall inside the claim cone const hasHeading = playerHeading !== null && playerHeading !== undefined;
const inCone = (activeCaches || []).filter((cache) =>
isInClaimCone(playerHeading, playerLocation, cache.coordinates, claimDistance) // When heading is available use the full FOV cone check.
// When heading is NOT available (common on iOS / Apple Maps where the
// compass may be delayed or unavailable) fall back to proximity-only so
// iPhone users can still discover and claim caches within range.
const nearby = (activeCaches || []).filter((cache) =>
hasHeading
? isInClaimCone(playerHeading, playerLocation, cache.coordinates, claimDistance)
: isWithinRadius(playerLocation, cache.coordinates, claimDistance),
); );
// Only update state when the set of visible caches actually changes // Only update state when the set of visible caches actually changes
const newIds = inCone.map((c) => c.id).join(','); const newIds = nearby.map((c) => c.id).join(',');
if (newIds !== prevIdsRef.current) { if (newIds !== prevIdsRef.current) {
prevIdsRef.current = newIds; prevIdsRef.current = newIds;
setVisibleCaches(inCone); setVisibleCaches(nearby);
setIsClaiming(inCone.length > 0); setIsClaiming(nearby.length > 0);
} }
}, [playerLocation, playerHeading, activeCaches, claimDistance]); }, [playerLocation, playerHeading, activeCaches, claimDistance]);