From e0aaeb63f76ea2aa429287d13f49abfc2cf66999 Mon Sep 17 00:00:00 2001 From: Sebastien Riviere Date: Mon, 16 Feb 2026 01:28:31 +0100 Subject: [PATCH] Polygonal zone fix + zones redesing + cleaning --- .../traque_14_12_25/compte_rendu.txt | 6 +- .../traque_20_09_25/compte_rendu.txt | 4 +- mobile/traque-app/app/interface.jsx | 97 +++++----- mobile/traque-app/components/drawer.jsx | 27 ++- mobile/traque-app/components/layer.jsx | 70 +++++++ mobile/traque-app/components/map.jsx | 175 ++++++++++++------ mobile/traque-app/components/timer.jsx | 2 +- .../util/{format.js => functions.js} | 18 ++ 8 files changed, 278 insertions(+), 121 deletions(-) create mode 100644 mobile/traque-app/components/layer.jsx rename mobile/traque-app/util/{format.js => functions.js} (53%) diff --git a/docs/historique/traque_14_12_25/compte_rendu.txt b/docs/historique/traque_14_12_25/compte_rendu.txt index ff24559..365976e 100644 --- a/docs/historique/traque_14_12_25/compte_rendu.txt +++ b/docs/historique/traque_14_12_25/compte_rendu.txt @@ -2,7 +2,7 @@ compte rendu traque 14-12-2025 Contexte -Cette traque s'est déroulé avec le même site et la même application que la traque de septembre. +Cette traque s'est déroulé avec le même serveur et la même application que la traque de septembre. Météo : 8°C, pas de pluie, brouillard (visibilité <400m), vent 10km/h @@ -16,7 +16,9 @@ automatique. [x] Il est pas évident de voir qu'elles équipes sont encore en jeu sur la page principale admin. Notamment, on voudrait que les voyants des équipes encore en jeu soit mis en avant. [x] La visibilité des éléments de la map en calque satellite est mauvaise. Il faut revoir les couleurs. -[ ] Les zones polygonales s'affichent mal sur la version prod de l'app mobile. Les anciennes n'ont pas l'air de disparaitre. +[x] Les zones polygonales s'affichent mal sur la version prod de l'app mobile. Les anciennes n'ont pas l'air de disparaitre. +[x] La zone de jeu (en rouge) est mal comprise, les joueurs évitent de rester dedans alors qu'ils ont le droit. +[ ] L'application n'est pas pleinement intuitive, par exemple le bouton pour actualiser sa position ou alors les icônes des stats. À faire diff --git a/docs/historique/traque_20_09_25/compte_rendu.txt b/docs/historique/traque_20_09_25/compte_rendu.txt index c8a3c39..658c7ac 100644 --- a/docs/historique/traque_20_09_25/compte_rendu.txt +++ b/docs/historique/traque_20_09_25/compte_rendu.txt @@ -14,14 +14,14 @@ sans raisons apparentes [ ] Une équipe avait deux téléphones connectés sauf que le premier ne marchait pas bien. Par ailleurs, l'équipe disaient n'avoir qu'un seul téléphone connecté. [ ] La photo d'une des équipes ne parvenait pas jusqu'au serveur. Tout semblait normal pour l'équipe. -[ ] Il y a apparement eu un problème de synchronisation de l'affichage entre admin concernant les zones de départs. Il s'est résolu +[x] Il y a apparement eu un problème de synchronisation de l'affichage entre admin concernant les zones de départs. Il s'est résolu de lui même. Peut être un problème passager de connection. [x] Le focus sur une équipe dans la page principale des admins est à revoir. Par exemple, le zoom seul devrait désactiver le focus automatique. [x] Il est pas évident de voir qu'elles équipes sont encore en jeu sur la page principale admin. Notamment, on voudrait que les voyants des équipes encore en jeu soit mis en avant. [x] La visibilité des éléments de la map en calque satellite est mauvaise. Il faut revoir les couleurs. -[ ] Les zones polygonales s'affichent mal sur la version prod de l'app mobile. Les anciennes n'ont pas l'air de disparaitre. +[x] Les zones polygonales s'affichent mal sur la version prod de l'app mobile. Les anciennes n'ont pas l'air de disparaitre. À faire diff --git a/mobile/traque-app/app/interface.jsx b/mobile/traque-app/app/interface.jsx index 74371b2..0f4f573 100644 --- a/mobile/traque-app/app/interface.jsx +++ b/mobile/traque-app/app/interface.jsx @@ -1,5 +1,5 @@ // React -import { useState, useEffect, Fragment } from 'react'; +import { useState, useEffect, useMemo, Fragment } from 'react'; import { View, Text, Image, Alert, StyleSheet, TouchableOpacity } from 'react-native'; // Expo import { useRouter } from 'expo-router'; @@ -15,18 +15,36 @@ import { useTimeDifference } from '../hook/useTimeDifference'; // Util import { GameState } from '../util/gameState'; import { TimerMMSS } from '../components/timer'; -import { secondsToMMSS } from '../util/format'; +import { secondsToMMSS } from '../util/functions'; import { Colors } from '../util/colors'; const Interface = () => { const router = useRouter(); const {messages, nextZoneDate, isShrinking, startLocationTracking, stopLocationTracking, gameState} = useTeamContext(); const {loggedIn, logout, loading} = useTeamConnexion(); - const {name, ready, captured, locationSendDeadline, outOfZone, outOfZoneDeadline, hasHandicap, enemyHasHandicap} = useGame(); + const {name, ready, captured, locationSendDeadline, sendCurrentPosition, outOfZone, outOfZoneDeadline, hasHandicap, enemyHasHandicap} = useGame(); const [timeLeftSendLocation] = useTimeDifference(locationSendDeadline, 1000); const [timeLeftNextZone] = useTimeDifference(nextZoneDate, 1000); const [timeLeftOutOfZone] = useTimeDifference(outOfZoneDeadline, 1000); const [bottomContainerHeight, setBottomContainerHeight] = useState(0); + + const statusMessage = useMemo(() => { + switch (gameState) { + case GameState.SETUP: + return messages?.waiting || "Préparation de la partie"; + case GameState.PLACEMENT: + return "Phase de placement"; + case GameState.PLAYING: + if (captured) return messages?.captured || "Vous avez été éliminé..."; + if (!outOfZone) return "La partie est en cours"; + if (!hasHandicap) return `Veuillez retourner dans la zone\nHandicap dans ${secondsToMMSS(-timeLeftOutOfZone)}`; + else return `Veuillez retourner dans la zone\nVotre position est révélée en continue`; + case GameState.FINISHED: + return `Vous avez ${captured ? (messages?.loser || "perdu...") : (messages?.winner || "gagné !")}`; + default: + return "Inconnue"; + } + }, [gameState, messages, outOfZone, hasHandicap, timeLeftOutOfZone, captured]); // Router useEffect(() => { @@ -61,45 +79,35 @@ const Interface = () => { {(name ?? "Indisponible")} - - { gameState == GameState.SETUP && {messages?.waiting || "Préparation de la partie"}} - { gameState == GameState.PLACEMENT && Phase de placement} - { gameState == GameState.PLAYING && !outOfZone && La partie est en cours} - { gameState == GameState.PLAYING && outOfZone && !hasHandicap && {`Veuillez retourner dans la zone\nHandicap dans ${secondsToMMSS(-timeLeftOutOfZone)}`}} - { gameState == GameState.PLAYING && hasHandicap && {`Veuillez retourner dans la zone\nVotre position est révélée en continue`}} - { gameState == GameState.FINISHED && La partie est terminée} + + {statusMessage} - { gameState == GameState.PLACEMENT && - + + { gameState == GameState.PLACEMENT && {ready ? "Placé" : "Non placé"} - - } - { gameState == GameState.PLAYING && !captured && - + } + { gameState == GameState.PLAYING && !captured && - - {enemyHasHandicap && - Position ennemie révélée en continue ! - } - } - { gameState == GameState.PLAYING && captured && - - {messages?.captured || "Vous avez été éliminé..."} - - } - { gameState == GameState.FINISHED && - - {captured && {captured ? (messages?.loser || "Vous avez perdu...") : (messages?.winner || "Vous avez gagné !")}} - + } + + { enemyHasHandicap && + Position ennemie révélée en continue ! } setBottomContainerHeight(event.nativeEvent.layout.height)}> - + { gameState == GameState.PLAYING && !captured && !hasHandicap && + + + + } + { gameState == GameState.PLAYING && !captured && + + } ); @@ -138,19 +146,9 @@ const styles = StyleSheet.create({ borderRadius: 10, width: "100%", backgroundColor: 'white', - fontSize: 18, padding: 10, }, - gameStateOutOfZone: { - borderWidth: 2, - borderRadius: 10, - width: "100%", - backgroundColor: 'white', - fontSize: 18, - padding: 10, - borderColor: 'red' - }, - timersContainer: { + infoContainer: { width: '100%', alignItems: 'center', justifyContent: 'center', @@ -168,5 +166,18 @@ const styles = StyleSheet.create({ }, bottomContainer: { flex: 1, - } + }, + updatePosition: { + position: 'absolute', + right: 30, + bottom: 80, + width: 60, + height: 60, + borderRadius: 30, + backgroundColor: 'white', + borderWidth: 4, + borderColor: 'black', + alignItems: 'center', + justifyContent: 'center', + }, }); diff --git a/mobile/traque-app/components/drawer.jsx b/mobile/traque-app/components/drawer.jsx index 7365a7a..a70f155 100644 --- a/mobile/traque-app/components/drawer.jsx +++ b/mobile/traque-app/components/drawer.jsx @@ -1,5 +1,5 @@ // React -import { useState, useEffect, Fragment } from 'react'; +import { useState, useEffect, useMemo, Fragment } from 'react'; import { ScrollView, View, Text, Image, StyleSheet, TouchableOpacity, TouchableHighlight, Alert } from 'react-native'; import Collapsible from 'react-native-collapsible'; import LinearGradient from 'react-native-linear-gradient'; @@ -16,7 +16,7 @@ import { useGame } from '../hook/useGame'; // Util import { GameState } from '../util/gameState'; import { Colors } from '../util/colors'; -import { secondsToHHMMSS } from '../util/format'; +import { secondsToHHMMSS } from '../util/functions'; export const Drawer = ({ height }) => { const [collapsibleState, setCollapsibleState] = useState(true); @@ -25,10 +25,18 @@ export const Drawer = ({ height }) => { const {gameState, startDate} = useTeamContext(); const {capture, enemyName, captureCode, name, teamId, distance, finishDate, nCaptures, nSentLocation, hasHandicap} = useGame(); const [timeSinceStart] = useTimeDifference(startDate, 1000); - const [avgSpeed, setAvgSpeed] = useState(0); // Speed in m/s const [enemyImageURI, setEnemyImageURI] = useState("../assets/images/missing_image.jpg"); const [captureStatus, setCaptureStatus] = useState(0); // 0 : no capture | 1 : waiting for response from server | 2 : capture failed | 3 : capture succesful const captureStatusColor = {0: "#777", 1: "#FFA500", 2: "#FF6B6B", 3: "#81C784"}; + + const avgSpeed = useMemo(() => { + const hours = (finishDate ? (finishDate - startDate) : timeSinceStart*1000) / 1000 / 3600; + if (hours <= 0 || distance <= 0) return 0; + const km = distance / 1000; + const speed = km / hours; + + return parseFloat(speed.toFixed(1)); + }, [finishDate, startDate, timeSinceStart, distance]); // Capture state update useEffect(() => { @@ -44,17 +52,8 @@ export const Drawer = ({ height }) => { useEffect(() => { setEnemyImageURI(`${SERVER_URL}/photo/enemy?team=${teamId}&t=${new Date().getTime()}`); }, [SERVER_URL, enemyName, teamId]); - - // Update the average speed - useEffect(() => { - const hours = (finishDate ? (finishDate - startDate) : timeSinceStart*1000) / 1000 / 3600; - const km = distance / 1000; - setAvgSpeed(Math.floor(km / hours * 10) / 10); - }, [distance, finishDate, startDate, timeSinceStart]); - - if (gameState != GameState.PLAYING) return; - function handleCapture() { + const handleCapture = () => { if (captureStatus != 1) { setCaptureStatus(1); capture(enemyCaptureCode) @@ -71,7 +70,7 @@ export const Drawer = ({ height }) => { }); setEnemyCaptureCode(""); } - } + }; return ( diff --git a/mobile/traque-app/components/layer.jsx b/mobile/traque-app/components/layer.jsx new file mode 100644 index 0000000..8790cd1 --- /dev/null +++ b/mobile/traque-app/components/layer.jsx @@ -0,0 +1,70 @@ +import { Fragment } from 'react'; +import { Polygon } from 'react-native-maps'; +import { circleToPolygon } from '../util/functions'; + +export const InvertedPolygon = ({id, coordinates, fillColor}) => { + // We create 3 rectangles covering earth, with the first rectangle centered on the hole + const shift = Math.floor(coordinates[0].longitude); + const lat = 85; + const lon = 60; + const worldOuterBounds1 = [ + { latitude: -lat, longitude: -lon + shift }, + { latitude: -lat, longitude: lon + shift }, + { latitude: lat, longitude: lon + shift }, + { latitude: lat, longitude: -lon + shift }, + ]; + const worldOuterBounds2 = [ + { latitude: -lat, longitude: -lon + 120 + shift }, + { latitude: -lat, longitude: lon + 120 + shift }, + { latitude: lat, longitude: lon + 120 + shift }, + { latitude: lat, longitude: -lon + 120 + shift }, + ]; + const worldOuterBounds3 = [ + { latitude: -lat, longitude: -lon + 240 + shift }, + { latitude: -lat, longitude: lon + 240 + shift }, + { latitude: lat, longitude: lon + 240 + shift }, + { latitude: lat, longitude: -lon + 240 + shift }, + ]; + + return + + + + ; +}; + +export const InvertedCircle = ({id, center, radius, fillColor}) => { + return ; +}; + +export const DashedCircle = ({id, center, radius, fillColor, strokeColor, strokeWidth, lineDashPattern}) => { + return ( + + ); +}; diff --git a/mobile/traque-app/components/map.jsx b/mobile/traque-app/components/map.jsx index cab16ad..06ffe89 100644 --- a/mobile/traque-app/components/map.jsx +++ b/mobile/traque-app/components/map.jsx @@ -1,8 +1,10 @@ // React -import { useState, useEffect, useRef, Fragment } from 'react'; +import { useState, useEffect, useMemo, useRef } from 'react'; import { View, Image, Alert, StyleSheet, TouchableOpacity } from 'react-native'; import MapView, { Marker, Circle, Polygon } from 'react-native-maps'; import LinearGradient from 'react-native-linear-gradient'; +// Components +import { DashedCircle, InvertedCircle, InvertedPolygon } from './layer'; // Contexts import { useTeamContext } from '../context/teamContext'; // Hooks @@ -13,77 +15,143 @@ import { ZoneTypes, InitialRegions } from '../util/constants'; export const CustomMap = () => { const {zoneType, zoneExtremities, location, gameState} = useTeamContext(); - const {sendCurrentPosition, enemyLocation, startingArea, captured, lastSentLocation, hasHandicap} = useGame(); - const mapRef = useRef(null); + const {enemyLocation, startingArea, lastSentLocation, hasHandicap} = useGame(); const [centerMap, setCenterMap] = useState(true); + const mapRef = useRef(null); // Center the map on user position useEffect(() => { if (centerMap && mapRef.current && location) { mapRef.current.animateToRegion({latitude: location[0], longitude: location[1], latitudeDelta: 0, longitudeDelta: 0.02}, 1000); } - }, [centerMap, mapRef, location]); - + }, [centerMap, location]); + + + // Map layers + const latToLatitude = (pos) => ({latitude: pos.lat, longitude: pos.lng}); + const startZone = useMemo(() => { + if (gameState != GameState.PLACEMENT || !startingArea) return null; + + return ( + + ); + }, [gameState, startingArea]); + + const gameZone = useMemo(() => { + if (gameState !== GameState.PLAYING || !zoneExtremities) return null; + + const items = []; + + const nextZoneStrokeColor = "rgb(90, 90, 90)"; + const zoneColor = "rgba(25, 83, 169, 0.4)"; + const strokeWidth = 3; + const lineDashPattern = [30, 10]; + + if (zoneType === ZoneTypes.circle) { + if (zoneExtremities.begin) items.push( + + ); + if (zoneExtremities.end) items.push( + + ); + } else if (zoneType === ZoneTypes.polygon) { + if (zoneExtremities.begin) items.push( + latToLatitude(pos))} + fillColor={zoneColor} + /> + ); + if (zoneExtremities.end) items.push( + latToLatitude(pos))} + strokeColor={nextZoneStrokeColor} + strokeWidth={strokeWidth} + lineDashPattern={lineDashPattern} + /> + ); + } + + return items.length ? items : null; + }, [gameState, zoneType, zoneExtremities]); + + const currentPositionMarker = useMemo(() => { + if (!location) return null; + + return ( + Alert.alert("Position actuelle", "Ceci est votre position")}> + + + ); + }, [location]); + + const lastPositionMarker = useMemo(() => { + if (gameState != GameState.PLAYING || !lastSentLocation || hasHandicap) return null; + + return ( + Alert.alert("Position envoyée", "Ceci est votre dernière position connue par le serveur")}> + + + ); + }, [gameState, hasHandicap, lastSentLocation]); + + const enemyPositionMarker = useMemo(() => { + if (gameState != GameState.PLAYING || !enemyLocation || hasHandicap) return null; + + return ( + Alert.alert("Position ennemie", "Ceci est la dernière position de vos ennemis connue")}> + + + ); + }, [gameState, hasHandicap, enemyLocation]); + + return ( - + setCenterMap(false)} toolbarEnabled={false}> - { gameState == GameState.PLACEMENT && startingArea && - - } - { gameState == GameState.PLAYING && zoneExtremities && - - { zoneType == ZoneTypes.circle && zoneExtremities.begin && } - { zoneType == ZoneTypes.circle && zoneExtremities.end && } - { zoneType == ZoneTypes.polygon && zoneExtremities.begin && latToLatitude(pos))} strokeColor="red" fillColor="rgba(255,0,0,0.1)" strokeWidth={2} /> } - { zoneType == ZoneTypes.polygon && zoneExtremities.end && latToLatitude(pos))} strokeColor="green" fillColor="rgba(0,255,0,0.1)" strokeWidth={2} /> } - - } - { location && - Alert.alert("Position actuelle", "Ceci est votre position")}> - - - } - { gameState == GameState.PLAYING && lastSentLocation && !hasHandicap && - Alert.alert("Position envoyée", "Ceci est votre dernière position connue par le serveur")}> - - - } - { gameState == GameState.PLAYING && enemyLocation && !hasHandicap && - - Alert.alert("Position ennemie", "Ceci est la dernière position de vos ennemis connue")}/> - - } + {startZone} + {gameZone} + {currentPositionMarker} + {lastPositionMarker} + {enemyPositionMarker} { !centerMap && - setCenterMap(true)}> + setCenterMap(true)}> } - { gameState == GameState.PLAYING && !captured && - - { !hasHandicap && - - - - } - - } ); }; const styles = StyleSheet.create({ - mapContainer: { + container: { flex: 1, width: '100%', borderTopLeftRadius: 30, borderTopRightRadius: 30, overflow: 'hidden', }, - centerMapContainer: { + centerMap: { position: 'absolute', right: 20, top: 20, @@ -96,19 +164,8 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, - toolBarRight: { - position: 'absolute', - right: 30, - bottom: 80 - }, - updatePositionContainer: { - width: 60, - height: 60, - borderRadius: 30, - backgroundColor: 'white', - borderWidth: 4, - borderColor: 'black', - alignItems: 'center', - justifyContent: 'center', - }, + markerImage: { + width: 24, + height: 24 + } }); diff --git a/mobile/traque-app/components/timer.jsx b/mobile/traque-app/components/timer.jsx index 8d69bc3..98a8920 100644 --- a/mobile/traque-app/components/timer.jsx +++ b/mobile/traque-app/components/timer.jsx @@ -1,7 +1,7 @@ // React import { View, Text, StyleSheet } from 'react-native'; // Util -import { secondsToMMSS } from '../util/format'; +import { secondsToMMSS } from '../util/functions'; export const TimerMMSS = ({ title, seconds, style }) => { return ( diff --git a/mobile/traque-app/util/format.js b/mobile/traque-app/util/functions.js similarity index 53% rename from mobile/traque-app/util/format.js rename to mobile/traque-app/util/functions.js index 1069750..9e8e5c4 100644 --- a/mobile/traque-app/util/format.js +++ b/mobile/traque-app/util/functions.js @@ -1,3 +1,21 @@ +export const circleToPolygon = (circle) => { + // circle : {center: {latitude: ..., longitude: ...}, radius: ...} + // polygon : [{latitude: ..., longitude: ...}, ...] + const polygon = []; + const center = circle.center; + const radiusInDegrees = circle.radius / 111320; // Approximation m -> deg + + for (let i = 0; i < 360; i += 5) { + const rad = (i * Math.PI) / 180; + polygon.push({ + latitude: center.latitude + radiusInDegrees * Math.sin(rad), + longitude: center.longitude + radiusInDegrees * Math.cos(rad) / Math.cos(center.latitude * Math.PI / 180), + }); + } + + return polygon; +}; + export const secondsToMMSS = (seconds) => { if (!Number.isInteger(seconds)) return "Inconnue"; if (seconds < 0) seconds = 0;