From d08825375814c2e8e8400af06827a43769f3ece5 Mon Sep 17 00:00:00 2001 From: Sebastien Riviere Date: Sun, 31 Aug 2025 17:33:18 +0200 Subject: [PATCH] Improvements on maps --- traque-back/zone_manager.js | 10 +- traque-front/app/admin/components/liveMap.jsx | 14 +- .../app/admin/login/components/loginForm.jsx | 2 +- ...rcleZoneMap.jsx => circleZoneSelector.jsx} | 45 ++---- ...gonZoneMap.jsx => polygonZoneSelector.jsx} | 128 ++++++++++-------- .../parameters/components/teamManager.jsx | 60 +++----- traque-front/app/admin/parameters/page.js | 16 ++- .../app/team/components/loginForm.jsx | 2 +- .../team/track/components/actionDrawer.jsx | 2 +- .../components/{textInput.jsx => input.jsx} | 0 traque-front/components/list.jsx | 61 ++++++++- .../components/{mapUtils.jsx => map.jsx} | 68 +++++++++- traque-front/hook/useLocation.jsx | 40 ++++-- 13 files changed, 264 insertions(+), 184 deletions(-) rename traque-front/app/admin/parameters/components/{circleZoneMap.jsx => circleZoneSelector.jsx} (71%) rename traque-front/app/admin/parameters/components/{polygonZoneMap.jsx => polygonZoneSelector.jsx} (56%) rename traque-front/components/{textInput.jsx => input.jsx} (100%) rename traque-front/components/{mapUtils.jsx => map.jsx} (52%) diff --git a/traque-back/zone_manager.js b/traque-back/zone_manager.js index 26cd2aa..b926553 100644 --- a/traque-back/zone_manager.js +++ b/traque-back/zone_manager.js @@ -22,7 +22,7 @@ function latlngEqual(latlng1, latlng2, epsilon = 1e-9) { /* -------------------------------- Polygon zones -------------------------------- */ -const defaultPolygonSettings = { polygons: [], durations: [] }; +const defaultPolygonSettings = []; function polygonZone(points, duration) { return { @@ -82,15 +82,9 @@ function mergePolygons(poly1, poly2) { } function polygonSettingsToZones(settings) { - const { polygons, durations } = settings; - const reversedPolygons = polygons.slice().reverse(); - const reversedDurations = durations.slice().reverse(); - const zones = []; - for (let i = 0; i < reversedPolygons.length; i++) { - const polygon =reversedPolygons[i]; - const duration = reversedDurations[i]; + for (const { polygon, duration } of settings.slice().reverse()) { const length = zones.length; if (length == 0) { diff --git a/traque-front/app/admin/components/liveMap.jsx b/traque-front/app/admin/components/liveMap.jsx index ff67d53..28ac044 100644 --- a/traque-front/app/admin/components/liveMap.jsx +++ b/traque-front/app/admin/components/liveMap.jsx @@ -1,13 +1,10 @@ import { useEffect, useState } from "react"; -import { MapContainer, Marker, TileLayer, Tooltip, Polyline, Polygon } from "react-leaflet"; +import { Marker, Tooltip, Polyline, Polygon } from "react-leaflet"; import "leaflet/dist/leaflet.css"; -import { MapPan } from "@/components/mapUtils"; -import useLocation from "@/hook/useLocation"; +import { CustomMapContainer } from "@/components/map"; import useAdmin from "@/hook/useAdmin"; import { GameState } from "@/util/gameState"; -const DEFAULT_ZOOM = 14; - const positionIcon = new L.Icon({ iconUrl: '/icons/marker/blue.png', iconSize: [30, 30], @@ -17,7 +14,6 @@ const positionIcon = new L.Icon({ }); export default function LiveMap({mapStyle, showZones, showNames, showArrows}) { - const location = useLocation(Infinity); const [timeLeftNextZone, setTimeLeftNextZone] = useState(null); const { zoneExtremities, teams, nextZoneDate, getTeam, gameState } = useAdmin(); @@ -56,9 +52,7 @@ export default function LiveMap({mapStyle, showZones, showNames, showArrows}) { return (
{gameState == GameState.PLAYING &&

{`Next zone in : ${formatTime(timeLeftNextZone)}`}

} - - - + {showZones && gameState == GameState.PLAYING && zoneExtremities.begin && } {showZones && gameState == GameState.PLAYING && zoneExtremities.end && } {teams.map((team) => team.currentLocation && !team.captured && @@ -67,7 +61,7 @@ export default function LiveMap({mapStyle, showZones, showNames, showArrows}) { {showArrows && } )} - +
) } diff --git a/traque-front/app/admin/login/components/loginForm.jsx b/traque-front/app/admin/login/components/loginForm.jsx index da40b9e..6688c8a 100644 --- a/traque-front/app/admin/login/components/loginForm.jsx +++ b/traque-front/app/admin/login/components/loginForm.jsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { BlueButton } from "@/components/button"; -import { TextInput } from "@/components/textInput"; +import { TextInput } from "@/components/input"; export default function LoginForm({ onSubmit, title, placeholder, buttonText}) { const [value, setValue] = useState(""); diff --git a/traque-front/app/admin/parameters/components/circleZoneMap.jsx b/traque-front/app/admin/parameters/components/circleZoneSelector.jsx similarity index 71% rename from traque-front/app/admin/parameters/components/circleZoneMap.jsx rename to traque-front/app/admin/parameters/components/circleZoneSelector.jsx index c9585ee..12a4450 100644 --- a/traque-front/app/admin/parameters/components/circleZoneMap.jsx +++ b/traque-front/app/admin/parameters/components/circleZoneSelector.jsx @@ -1,20 +1,18 @@ import { useEffect, useState } from "react"; -import { Circle, MapContainer, TileLayer } from "react-leaflet"; +import { Circle } from "react-leaflet"; import "leaflet/dist/leaflet.css"; import { BlueButton, GreenButton, RedButton } from "@/components/button"; -import { TextInput } from "@/components/textInput"; -import { MapPan, MapEventListener } from "@/components/mapUtils"; +import { CustomMapContainer, MapEventListener } from "@/components/map"; +import { TextInput } from "@/components/input"; import useAdmin from "@/hook/useAdmin"; -import useLocation from "@/hook/useLocation"; import useMapCircleDraw from "@/hook/useMapCircleDraw"; -const DEFAULT_ZOOM = 14; const EditMode = { MIN: 0, MAX: 1 } -function CircleDrawings({ minZone, setMinZone, maxZone, setMaxZone, editMode }) { +function Drawings({ minZone, setMinZone, maxZone, setMaxZone, editMode }) { const { center: maxCenter, radius: maxRadius, handleLeftClick: maxLeftClick, handleRightClick: maxRightClick, handleMouseMove: maxHover } = useMapCircleDraw(maxZone, setMaxZone); const { center: minCenter, radius: minRadius, handleLeftClick: minLeftClick, handleRightClick: minRightClick, handleMouseMove: minHover } = useMapCircleDraw(minZone, setMinZone); @@ -44,31 +42,14 @@ function CircleDrawings({ minZone, setMinZone, maxZone, setMaxZone, editMode }) return (
+ {minCenter && minRadius && } {maxCenter && maxRadius && } -
); } -export function CircleZonePicker({ minZone, maxZone, editMode, setMinZone, setMaxZone, ...props }) { - const location = useLocation(Infinity); - - return ( -
- - - - - -
- ); -} - -export default function CircleZoneMap() { +export default function CircleZoneSelector() { const [editMode, setEditMode] = useState(EditMode.MIN); const [minZone, setMinZone] = useState(null); const [maxZone, setMaxZone] = useState(null); @@ -85,11 +66,6 @@ export default function CircleZoneMap() { } }, [zoneSettings]); - function handleSettingsSubmit() { - const newSettings = {min:minZone, max:maxZone, reductionCount: Number(reductionCount), duration: Number(duration)}; - changeZoneSettings(newSettings); - } - // When the user set one zone, switch to the other useEffect(() => { if(editMode == EditMode.MIN) { @@ -100,12 +76,19 @@ export default function CircleZoneMap() { }, [minZone, maxZone]); + function handleSettingsSubmit() { + const newSettings = {min:minZone, max:maxZone, reductionCount: Number(reductionCount), duration: Number(duration)}; + changeZoneSettings(newSettings); + } + return (

Edit zones

{editMode == EditMode.MIN && setEditMode(EditMode.MAX)}>Click to edit first zone} {editMode == EditMode.MAX && setEditMode(EditMode.MIN)}>Click to edit last zone} - + + +

Number of zones

setReductionCount(e.target.value)}> diff --git a/traque-front/app/admin/parameters/components/polygonZoneMap.jsx b/traque-front/app/admin/parameters/components/polygonZoneSelector.jsx similarity index 56% rename from traque-front/app/admin/parameters/components/polygonZoneMap.jsx rename to traque-front/app/admin/parameters/components/polygonZoneSelector.jsx index c1b3382..74fe16e 100644 --- a/traque-front/app/admin/parameters/components/polygonZoneMap.jsx +++ b/traque-front/app/admin/parameters/components/polygonZoneSelector.jsx @@ -1,16 +1,14 @@ import { useEffect, useState } from "react"; -import { MapContainer, TileLayer, Polyline, Polygon, CircleMarker } from "react-leaflet"; +import { Polyline, Polygon, CircleMarker, Marker } from "react-leaflet"; import "leaflet/dist/leaflet.css"; import { GreenButton } from "@/components/button"; -import { TextInput } from "@/components/textInput"; -import { MapPan, MapEventListener } from "@/components/mapUtils"; +import { ReorderList } from "@/components/list"; +import { CustomMapContainer, MapEventListener } from "@/components/map"; +import { TextInput } from "@/components/input"; import useAdmin from "@/hook/useAdmin"; -import useLocation from "@/hook/useLocation"; import useMapPolygonDraw from "@/hook/useMapPolygonDraw"; -const DEFAULT_ZOOM = 14; - -function PolygonDrawings({ polygons, addPolygon, removePolygon }) { +function Drawings({ polygons, addPolygon, removePolygon }) { const { currentPolygon, highlightNodes, handleLeftClick, handleRightClick, handleMouseMove } = useMapPolygonDraw(polygons, addPolygon, removePolygon); const nodeSize = 5; // px const lineThickness = 3; // px @@ -45,110 +43,120 @@ function PolygonDrawings({ polygons, addPolygon, removePolygon }) { } } - function DrawPolygon({polygon}) { + function DrawPolygon({polygon, number}) { const length = polygon.length; - if (length > 2) { - return ( + if (length < 3) return null; + + const sum = polygon.reduce( + (acc, coord) => ({ + lat: acc.lat + coord.lat, + lng: acc.lng + coord.lng + }), + { lat: 0, lng: 0 } + ); + + // meanPoint can be out of the polygon + // Idea : take the mean point of the largest connected subpolygon + const meanPoint = {lat: sum.lat / length, lng: sum.lng / length} + + const numberIcon = L.divIcon({ + html: `
${number}
`, + className: 'custom-number-icon', + iconSize: [30, 30], + iconAnchor: [15, 15] + }); + + return ( +
- ); - } + +
+ ); } return (
- {polygons.map((polygon, i) => )} + {polygons.map((polygon, i) => )} {highlightNodes.map((node, i) => )}
); } -function PolygonZonePicker({ polygons, addPolygon, removePolygon, ...props }) { - const location = useLocation(Infinity); - - return ( -
- - - - - -
- ); -} - -export default function PolygonZoneMap() { +export default function PolygonZoneSelector() { const defaultDuration = 10; + const [zones, setZones] = useState([]); const [polygons, setPolygons] = useState([]); - const [durations, setDurations] = useState([]); const {zoneSettings, changeZoneSettings} = useAdmin(); const {penaltySettings, changePenaltySettings} = useAdmin(); const [allowedTimeOutOfZone, setAllowedTimeOutOfZone] = useState(""); + useEffect(() => { + setPolygons(zones.map((zone) => zone.polygon)); + }, [zones]) + useEffect(() => { if (zoneSettings) { - setPolygons(zoneSettings.polygons); - setDurations(zoneSettings.durations); + setZones(zoneSettings.map((zone) => ({id: idFromPolygon(zone.polygon), polygon: zone.polygon, duration: zone.duration}))); } if (penaltySettings) { setAllowedTimeOutOfZone(penaltySettings.allowedTimeOutOfZone.toString()); } }, [zoneSettings, penaltySettings]); + function idFromPolygon(polygon) { + return (polygon[0].lat + polygon[1].lat + polygon[2].lat).toString() + (polygon[0].lng + polygon[1].lng + polygon[2].lng).toString(); + } + function addPolygon(polygon) { - // Polygons - setPolygons([...polygons, polygon]); - // Durations - setDurations([...durations, defaultDuration]); + setZones([...zones, {id: idFromPolygon(polygon), polygon: polygon, duration: defaultDuration}]); } function removePolygon(i) { - // Polygons - const newPolygons = [...polygons]; - newPolygons.splice(i, 1); - setPolygons(newPolygons); - // Durations - const newDurations = [...durations]; - newDurations.splice(i, 1); - setDurations(newDurations); + setZones(zones.filter((_, index) => index !== i)); } function updateDuration(i, duration) { - const newDurations = [...durations]; - newDurations[i] = duration; - setDurations(newDurations); + setZones(zones.map((zone, index) => index === i ? {id: zone.id, polygon: zone.polygon, duration: duration} : zone)); } function handleSettingsSubmit() { - const newSettings = {polygons: polygons, durations: durations}; - changeZoneSettings(newSettings); + changeZoneSettings(zones); changePenaltySettings({allowedTimeOutOfZone: Number(allowedTimeOutOfZone)}); } return (
-
- +
+ + +

Reduction order

-
    - {durations.map((duration, i) => ( -
  • + + { (zone, i) => +

    Zone {i+1}

    - updateDuration(i, e.target.value)}/> + updateDuration(i, e.target.value)}/>
    -
  • - ))} -
+
+ } +

Timeout

diff --git a/traque-front/app/admin/parameters/components/teamManager.jsx b/traque-front/app/admin/parameters/components/teamManager.jsx index bc39377..709ee16 100644 --- a/traque-front/app/admin/parameters/components/teamManager.jsx +++ b/traque-front/app/admin/parameters/components/teamManager.jsx @@ -1,15 +1,7 @@ -import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd'; -import { List } from '@/components/list'; +import { ReorderList } from '@/components/list'; import useAdmin from '@/hook/useAdmin'; -function reorder(list, startIndex, endIndex) { - const result = Array.from(list); - const [removed] = result.splice(startIndex, 1); - result.splice(endIndex, 0, removed); - return result; -}; - -function TeamManagerItem({ team, index }) { +function TeamManagerItem({ team }) { const { updateTeam, removeTeam } = useAdmin(); function handleRemove() { @@ -17,47 +9,27 @@ function TeamManagerItem({ team, index }) { } return ( - - {provided => ( -
-
-

{team.name}

-
-

{String(team.id).padStart(6, '0').replace(/(\d{3})(\d{3})/, "$1 $2")}

- updateTeam(team.id, { captured: !team.captured })} /> - -
-
+
+
+

{team.name}

+
+

{String(team.id).padStart(6, '0').replace(/(\d{3})(\d{3})/, "$1 $2")}

+ updateTeam(team.id, { captured: !team.captured })} /> +
- )} - +
+
); } export default function TeamManager() { const { teams, reorderTeams } = useAdmin(); - - function onDragEnd(result) { - if (!result.destination) return; - if (result.destination.index === result.source.index) return; - const newTeams = reorder(teams, result.source.index, result.destination.index); - reorderTeams(newTeams); - } return ( - - - {provided => ( -
- - {(team, i) => ( - - )} - - {provided.placeholder} -
- )} -
-
+ + {(team) => ( + + )} + ); } diff --git a/traque-front/app/admin/parameters/page.js b/traque-front/app/admin/parameters/page.js index d5b0f5f..11fe72a 100644 --- a/traque-front/app/admin/parameters/page.js +++ b/traque-front/app/admin/parameters/page.js @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; import dynamic from "next/dynamic"; import Link from "next/link"; -import { TextInput } from "@/components/textInput"; +import { TextInput } from "@/components/input"; import { Section } from "@/components/section"; import { useAdminConnexion } from "@/context/adminConnexionContext"; import useAdmin from '@/hook/useAdmin'; @@ -10,13 +10,20 @@ import Messages from "./components/messages"; import TeamManager from './components/teamManager'; // Imported at runtime and not at compile time -const ZoneSelector = dynamic(() => import('./components/polygonZoneMap'), { ssr: false }); +const PolygonZoneSelector = dynamic(() => import('./components/polygonZoneSelector'), { ssr: false }); +const CircleZoneSelector = dynamic(() => import('./components/circleZoneSelector'), { ssr: false }); -export default function AdminPage() { +const zoneSelectors = { + circle: "circle", + polygon: "polygon" +} + +export default function ConfigurationPage() { const {penaltySettings, changePenaltySettings, addTeam} = useAdmin(); const { useProtect } = useAdminConnexion(); const [allowedTimeBetweenUpdates, setAllowedTimeBetweenUpdates] = useState(""); const [teamName, setTeamName] = useState(''); + const [zoneSelector, setZoneSelector] = useState(zoneSelectors.polygon); useProtect(); @@ -71,7 +78,8 @@ export default function AdminPage() {
- + {zoneSelector == zoneSelectors.circle && } + {zoneSelector == zoneSelectors.polygon && }
); diff --git a/traque-front/app/team/components/loginForm.jsx b/traque-front/app/team/components/loginForm.jsx index da40b9e..6688c8a 100644 --- a/traque-front/app/team/components/loginForm.jsx +++ b/traque-front/app/team/components/loginForm.jsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { BlueButton } from "@/components/button"; -import { TextInput } from "@/components/textInput"; +import { TextInput } from "@/components/input"; export default function LoginForm({ onSubmit, title, placeholder, buttonText}) { const [value, setValue] = useState(""); diff --git a/traque-front/app/team/track/components/actionDrawer.jsx b/traque-front/app/team/track/components/actionDrawer.jsx index 9fe90dd..04491fb 100644 --- a/traque-front/app/team/track/components/actionDrawer.jsx +++ b/traque-front/app/team/track/components/actionDrawer.jsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react" import { BlueButton, GreenButton } from "@/components/button"; -import { TextInput } from "@/components/textInput"; +import { TextInput } from "@/components/input"; import useTeamConnexion from "@/context/teamConnexionContext"; import useGame from "@/hook/useGame"; import EnemyTeamModal from "./enemyTeamModal"; diff --git a/traque-front/components/textInput.jsx b/traque-front/components/input.jsx similarity index 100% rename from traque-front/components/textInput.jsx rename to traque-front/components/input.jsx diff --git a/traque-front/components/list.jsx b/traque-front/components/list.jsx index 8f26c0d..40fc9f1 100644 --- a/traque-front/components/list.jsx +++ b/traque-front/components/list.jsx @@ -1,14 +1,67 @@ +import { useEffect, useState } from 'react'; +import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd'; + export function List({array, children}) { - // The elements of array have to be identified by a field id + // TODO : change key return ( -
-
    +
    +
      {array.map((elem, i) => ( -
    • +
    • {children(elem, i)} +
    • ))}
    ); } + +export function ReorderList({droppableId, array, setArray, children}) { + const [arrayLocal, setArrayLocal] = useState(array); + + useEffect(() => { + setArrayLocal(array); + }, [array]) + + function reorder(list, startIndex, endIndex) { + const result = Array.from(list); + const [removed] = result.splice(startIndex, 1); + result.splice(endIndex, 0, removed); + return result; + }; + + function onDragEnd(result) { + if (!result.destination) return; + if (result.destination.index === result.source.index) return; + const newArray = reorder(array, result.source.index, result.destination.index); + setArrayLocal(newArray); + setArray(newArray); + } + + return ( + + + {provided => ( +
    +
      + {arrayLocal.map((elem, i) => ( +
    • + + {provided => ( +
      + {children(elem, i)} +
      +
      + )} + +
    • + ))} +
    + {provided.placeholder} +
    + )} +
    +
    + ); +} diff --git a/traque-front/components/mapUtils.jsx b/traque-front/components/map.jsx similarity index 52% rename from traque-front/components/mapUtils.jsx rename to traque-front/components/map.jsx index 9a1c0d7..21130da 100644 --- a/traque-front/components/mapUtils.jsx +++ b/traque-front/components/map.jsx @@ -1,17 +1,28 @@ import { useEffect, useState } from "react"; -import { useMap } from "react-leaflet"; +import { MapContainer, TileLayer, useMap } from "react-leaflet"; import "leaflet/dist/leaflet.css"; -export function MapPan(props) { +const DEFAULT_ZOOM = 14; + +const mapStyles = { + default: { + url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", + attribution: '© OpenStreetMap' + }, + satellite: { + url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", + attribution: 'Tiles © Esri' + }, +} + +export function MapPan({center, zoom}) { const map = useMap(); - const [initialized, setInitialized] = useState(false); useEffect(() => { - if (!initialized && props.center) { - map.flyTo(props.center, props.zoom, { animate: false }); - setInitialized(true) + if (center, zoom) { + map.flyTo(center, zoom, { animate: false }); } - }, [props.center]); + }, [center, zoom]); return null; } @@ -21,6 +32,8 @@ export function MapEventListener({ onLeftClick, onRightClick, onMouseMove }) { // Handle the mouse click left useEffect(() => { + if (!onLeftClick) return; + let moved = false; let downButton = null; @@ -55,6 +68,7 @@ export function MapEventListener({ onLeftClick, onRightClick, onMouseMove }) { // Handle the right click useEffect(() => { + if (!onRightClick) return; const handleMouseDown = (e) => { if (e.originalEvent.button == 2) { @@ -71,6 +85,8 @@ export function MapEventListener({ onLeftClick, onRightClick, onMouseMove }) { // Handle the mouse move useEffect(() => { + if (!onMouseMove) return; + map.on('mousemove', onMouseMove); return () => { @@ -85,4 +101,42 @@ export function MapEventListener({ onLeftClick, onRightClick, onMouseMove }) { container.addEventListener('contextmenu', preventContextMenu); return () => container.removeEventListener('contextmenu', preventContextMenu); }, []); + + return null; +} + +export function CustomMapContainer({mapStyle, children}) { + const [location, setLocation] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!navigator.geolocation) { + console.log('Geolocation not supported'); + return; + } + + navigator.geolocation.getCurrentPosition( + (pos) => { + setLocation([pos.coords.latitude, pos.coords.longitude]); + setLoading(false); + }, + (err) => console.log("Error :", err), + { + enableHighAccuracy: true, + timeout: 10000, + maximumAge: 0 + } + ); + }, []); + + if (loading) { + return
    + } + + return ( + + + {children} + + ) } diff --git a/traque-front/hook/useLocation.jsx b/traque-front/hook/useLocation.jsx index 9e4e816..acc51c8 100644 --- a/traque-front/hook/useLocation.jsx +++ b/traque-front/hook/useLocation.jsx @@ -2,23 +2,37 @@ import { useEffect, useState } from "react"; export default function useLocation(interval) { - const [location, setLocation] = useState(); + const [location, setLocation] = useState(null); useEffect(() => { - function update() { - navigator.geolocation.getCurrentPosition( - (position) => { - setLocation([position.coords.latitude, position.coords.longitude]); - if(interval != Infinity) { - setTimeout(update, interval); - } - }, - () => { }, - { enableHighAccuracy: true, timeout: Infinity, maximumAge: 0 } - ); + if (!navigator.geolocation) { + console.log('Geolocation not supported'); + return; + } + + if (interval < 1000 || interval == Infinity) { + console.log('Localisation interval no supported'); + return; } - update(); + const watchId = navigator.geolocation.watchPosition( + (pos) => { + setLocation({ + lat: pos.coords.latitude, + lng: pos.coords.longitude, + accuracy: pos.coords.accuracy, + timestamp: pos.timestamp + }); + }, + (err) => console.log("Error :", err), + { + enableHighAccuracy: true, + timeout: 10000, + maximumAge: interval, + } + ); + + return () => navigator.geolocation.clearWatch(watchId); }, []); return location;