Polygon zones AND circle zones

This commit is contained in:
Sebastien Riviere
2025-09-02 20:00:23 +02:00
parent d088253758
commit 062a69aae3
7 changed files with 198 additions and 137 deletions

View File

@@ -90,9 +90,9 @@ export function initAdminSocketHandler() {
}
if (!zoneManager.changeSettings(settings)) {
socket.emit("error", "Error changing zone");
socket.emit("zone_settings", zoneManager.settings)
socket.emit("zone_settings", settings)
} else {
secureAdminBroadcast("zone_settings", zoneManager.settings)
secureAdminBroadcast("zone_settings", settings)
}
})

View File

@@ -4,6 +4,11 @@ import { secureAdminBroadcast } from './admin_socket.js';
/* -------------------------------- Useful functions and constants -------------------------------- */
const zoneTypes = {
circle: "circle",
polygon: "polygon"
}
const EARTH_RADIUS = 6_371_000; // Radius of the earth in m
function haversine_distance({ lat: lat1, lng: lon1 }, { lat: lat2, lng: lon2 }) {
@@ -20,9 +25,64 @@ function latlngEqual(latlng1, latlng2, epsilon = 1e-9) {
}
/* -------------------------------- Circle zones -------------------------------- */
const defaultCircleSettings = {type: zoneTypes.circle, min: null, max: null, reductionCount: 4, duration: 10}
function circleZone(center, radius, duration) {
return {
center: center,
radius: radius,
duration: duration,
isInZone(location) {
return haversine_distance(center, location) < this.radius;
}
}
}
function circleSettingsToZones(settings) {
const {min, max, reductionCount, duration} = settings;
if (!min || !max) return [];
if (haversine_distance(max.center, min.center) > max.radius - min.radius) return [];
const zones = [circleZone(max.center, max.radius, duration)];
const radiusReductionLength = (max.radius - min.radius) / reductionCount;
let center = max.center;
let radius = max.radius;
for (let i = 1; i < reductionCount; i++) {
radius -= radiusReductionLength;
let new_center = null;
while (!new_center || haversine_distance(new_center, min.center) > radius - min.radius) {
const angle = Math.random() * 2 * Math.PI;
const angularDistance = Math.sqrt(Math.random()) * radiusReductionLength / EARTH_RADIUS;
const lat0Rad = center.lat * Math.PI / 180;
const lon0Rad = center.lng * Math.PI / 180;
const latRad = Math.asin(
Math.sin(lat0Rad) * Math.cos(angularDistance) +
Math.cos(lat0Rad) * Math.sin(angularDistance) * Math.cos(angle)
);
const lonRad = lon0Rad + Math.atan2(
Math.sin(angle) * Math.sin(angularDistance) * Math.cos(lat0Rad),
Math.cos(angularDistance) - Math.sin(lat0Rad) * Math.sin(latRad)
);
new_center = {lat: latRad * 180 / Math.PI, lng: lonRad * 180 / Math.PI};
}
center = new_center;
zones.push(circleZone(center, radius, duration))
}
zones.push(circleZone(min.center, min.radius, 0));
return zones;
}
/* -------------------------------- Polygon zones -------------------------------- */
const defaultPolygonSettings = [];
const defaultPolygonSettings = {type: zoneTypes.polygon, polygons: []}
function polygonZone(points, duration) {
return {
@@ -82,9 +142,11 @@ function mergePolygons(poly1, poly2) {
}
function polygonSettingsToZones(settings) {
const {polygons} = settings;
const zones = [];
for (const { polygon, duration } of settings.slice().reverse()) {
for (const { polygon, duration } of polygons.slice().reverse()) {
const length = zones.length;
if (length == 0) {
@@ -104,67 +166,13 @@ function polygonSettingsToZones(settings) {
}
/* -------------------------------- Circle zones -------------------------------- */
const defaultCircleSettings = { min: null, max: null, reductionCount: 4, duration: 1 };
function circleZone(center, radius, duration) {
return {
center: center,
radius: radius,
duration: duration,
isInZone(location) {
return haversine_distance(center, location) < this.radius;
}
}
}
function circleSettingsToZones(settings) {
const {min, max, reductionCount, duration} = settings;
if (haversine_distance(max.center, min.center) > max.radius - min.radius) {
return null;
}
const zones = [circleZone(max.center, max.radius, duration)];
const radiusReductionLength = (max.radius - min.radius) / reductionCount;
let center = max.center;
let radius = max.radius;
for (let i = 1; i < reductionCount; i++) {
radius -= radiusReductionLength;
let new_center = null;
while (!new_center || haversine_distance(new_center, min.center) > radius - min.radius) {
const angle = Math.random() * 2 * Math.PI;
const angularDistance = Math.sqrt(Math.random()) * radiusReductionLength / EARTH_RADIUS;
const lat0Rad = center.lat * Math.PI / 180;
const lon0Rad = center.lng * Math.PI / 180;
const latRad = Math.asin(
Math.sin(lat0Rad) * Math.cos(angularDistance) +
Math.cos(lat0Rad) * Math.sin(angularDistance) * Math.cos(angle)
);
const lonRad = lon0Rad + Math.atan2(
Math.sin(angle) * Math.sin(angularDistance) * Math.cos(lat0Rad),
Math.cos(angularDistance) - Math.sin(lat0Rad) * Math.sin(latRad)
);
new_center = {lat: latRad * 180 / Math.PI, lng: lonRad * 180 / Math.PI};
}
center = new_center;
zones.push(circleZone(center, radius, duration))
}
zones.push(circleZone(min.center, min.radius, 0));
return zones;
}
/* -------------------------------- Zone manager -------------------------------- */
export default {
isRunning: false,
zones: [], // A zone has to be connected space that doesn't contain an earth pole
currentZone: { id: 0, timeoutId: null, endDate: null },
zoneType: "polygon",
settings: defaultPolygonSettings,
settingsToZones: polygonSettingsToZones,
start() {
this.isRunning = true;
@@ -209,25 +217,18 @@ export default {
},
changeSettings(settings) {
const zones = this.settingsToZones(settings);
if (!zones) return false;
this.zones = zones;
switch (settings.type) {
case zoneTypes.circle:
this.zones = circleSettingsToZones(settings);
break;
case zoneTypes.polygon:
this.zones = polygonSettingsToZones(settings);
break;
default:
return;
}
this.settings = settings;
this.zoneBroadcast();
return true;
},
changeZoneType(type) {
if (this.zoneType == type) return;
if (type == "circle") {
this.zoneType = "circle";
this.settings = defaultCircleSettings;
this.settingsToZones = circleSettingsToZones;
} else if (type == "polygon") {
this.zoneType = "polygon";
this.settings = defaultPolygonSettings;
this.settingsToZones = polygonSettingsToZones;
}
},
zoneBroadcast() {

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { Marker, Tooltip, Polyline, Polygon } from "react-leaflet";
import { Marker, Tooltip, Polyline, Polygon, Circle } from "react-leaflet";
import "leaflet/dist/leaflet.css";
import { CustomMapContainer } from "@/components/map";
import useAdmin from "@/hook/useAdmin";
@@ -13,9 +13,14 @@ const positionIcon = new L.Icon({
shadowSize: [30, 30],
});
const zoneTypes = {
circle: "circle",
polygon: "polygon"
}
export default function LiveMap({mapStyle, showZones, showNames, showArrows}) {
const { zoneSettings, zoneExtremities, teams, nextZoneDate, getTeam, gameState } = useAdmin();
const [timeLeftNextZone, setTimeLeftNextZone] = useState(null);
const { zoneExtremities, teams, nextZoneDate, getTeam, gameState } = useAdmin();
// Remaining time before sending position
useEffect(() => {
@@ -49,12 +54,34 @@ export default function LiveMap({mapStyle, showZones, showNames, showArrows}) {
}
}
function Zones() {
if (!(showZones && gameState == GameState.PLAYING && zoneSettings)) return null;
switch (zoneSettings.type) {
case zoneTypes.circle:
return (
<div>
{ zoneExtremities.begin && <Circle center={zoneExtremities.begin.center} radius={zoneExtremities.begin.radius} color="red" fillColor="red" />}
{ zoneExtremities.end && <Circle center={zoneExtremities.end.center} radius={zoneExtremities.end.radius} color="green" fillColor="green" />}
</div>
);
case zoneTypes.polygon:
return (
<div>
{ zoneExtremities.begin && <Polygon positions={zoneExtremities.begin.points} pathOptions={{ color: 'red', fillColor: 'red', fillOpacity: '0.1', weight: 3 }} />}
{ zoneExtremities.end && <Polygon positions={zoneExtremities.end.points} pathOptions={{ color: 'green', fillColor: 'green', fillOpacity: '0.1', weight: 3 }} />}
</div>
);
default:
return null;
}
}
return (
<div className='h-full w-full flex flex-col'>
{gameState == GameState.PLAYING && <p>{`Next zone in : ${formatTime(timeLeftNextZone)}`}</p>}
<CustomMapContainer mapStyle={mapStyle}>
{showZones && gameState == GameState.PLAYING && zoneExtremities.begin && <Polygon positions={zoneExtremities.begin.points} pathOptions={{ color: 'red', fillColor: 'red', fillOpacity: '0.1', weight: 3 }} />}
{showZones && gameState == GameState.PLAYING && zoneExtremities.end && <Polygon positions={zoneExtremities.end.points} pathOptions={{ color: 'green', fillColor: 'green', fillOpacity: '0.1', weight: 3 }} />}
<Zones/>
{teams.map((team) => team.currentLocation && !team.captured &&
<Marker key={team.id} position={team.currentLocation} icon={positionIcon}>
{showNames && <Tooltip permanent direction="top" offset={[0.5, -15]} className="custom-tooltip">{team.name}</Tooltip>}

View File

@@ -49,55 +49,61 @@ function Drawings({ minZone, setMinZone, maxZone, setMaxZone, editMode }) {
);
}
export default function CircleZoneSelector() {
export default function CircleZoneSelector({zoneSettings, updateZoneSettings, applyZoneSettings}) {
const {penaltySettings, changePenaltySettings} = useAdmin();
const [allowedTimeOutOfZone, setAllowedTimeOutOfZone] = useState("");
const [editMode, setEditMode] = useState(EditMode.MIN);
const [minZone, setMinZone] = useState(null);
const [maxZone, setMaxZone] = useState(null);
const [reductionCount, setReductionCount] = useState("");
const [duration, setDuration] = useState("");
const {zoneSettings, changeZoneSettings} = useAdmin();
useEffect(() => {
if (zoneSettings) {
setMinZone(zoneSettings.min);
setMaxZone(zoneSettings.max);
setReductionCount(zoneSettings.reductionCount.toString());
setDuration(zoneSettings.duration.toString());
}
}, [zoneSettings]);
setEditMode(editMode == EditMode.MIN ? EditMode.MAX : EditMode.MIN);
}, [zoneSettings.min, zoneSettings.max])
// When the user set one zone, switch to the other
useEffect(() => {
if(editMode == EditMode.MIN) {
setEditMode(EditMode.MAX);
} else {
setEditMode(EditMode.MIN);
if (penaltySettings) {
setAllowedTimeOutOfZone(penaltySettings.allowedTimeOutOfZone.toString());
}
}, [minZone, maxZone]);
}, [penaltySettings]);
function handleSettingsSubmit() {
const newSettings = {min:minZone, max:maxZone, reductionCount: Number(reductionCount), duration: Number(duration)};
changeZoneSettings(newSettings);
console.log(zoneSettings)
applyZoneSettings();
changePenaltySettings({allowedTimeOutOfZone: Number(allowedTimeOutOfZone)});
}
return (
<div className='w-2/5 h-full gap-1 bg-white p-10 flex flex-col text-center shadow-2xl overflow-y-scroll'>
<h2 className="text-2xl">Edit zones</h2>
<div className='h-full w-full bg-white p-3 gap-3 flex flex-row shadow-2xl'>
<div className="h-full flex-1">
<CustomMapContainer>
<Drawings minZone={zoneSettings.min} setMinZone={(e) => updateZoneSettings("min", e)} maxZone={zoneSettings.max} setMaxZone={(e) => updateZoneSettings("max", e)} editMode={editMode} />
</CustomMapContainer>
</div>
<div className="h-full w-1/6 flex flex-col gap-3">
<div className="w-full h-15">
{editMode == EditMode.MIN && <BlueButton onClick={() => setEditMode(EditMode.MAX)}>Click to edit first zone</BlueButton>}
{editMode == EditMode.MAX && <RedButton onClick={() => setEditMode(EditMode.MIN)}>Click to edit last zone</RedButton>}
<CustomMapContainer>
<Drawings minZone={minZone} maxZone={maxZone} editMode={editMode} setMinZone={setMinZone} setMaxZone={setMaxZone} />
</CustomMapContainer>
<div>
<p>Number of zones</p>
<TextInput value={reductionCount} onChange={(e) => setReductionCount(e.target.value)}></TextInput>
</div>
<div>
<p>Duration of a zone</p>
<TextInput value={duration} onChange={(e) => setDuration(e.target.value)}></TextInput>
<div className="w-full flex flex-row gap-2 items-center justify-between">
<p>Number</p>
<div className="w-16 h-10">
<TextInput value={zoneSettings.reductionCount} onChange={(e) => updateZoneSettings("reductionCount", e.target.value)} />
</div>
</div>
<div className="w-full flex flex-row gap-2 items-center justify-between">
<p>Duration</p>
<div className="w-16 h-10">
<TextInput value={zoneSettings.duration} onChange={(e) => updateZoneSettings("duration", e.target.value)} />
</div>
</div>
<div className="w-full flex flex-row gap-2 items-center justify-between">
<p>Timeout</p>
<div className="w-16 h-10">
<TextInput value={allowedTimeOutOfZone} onChange={(e) => setAllowedTimeOutOfZone(e.target.value)} />
</div>
</div>
<div className="w-full h-15">
<GreenButton onClick={handleSettingsSubmit}>Apply</GreenButton>
</div>
</div>
</div>
);
}

View File

@@ -94,45 +94,46 @@ function Drawings({ polygons, addPolygon, removePolygon }) {
);
}
export default function PolygonZoneSelector() {
export default function PolygonZoneSelector({zoneSettings, updateZoneSettings, applyZoneSettings}) {
const defaultDuration = 10;
const [zones, setZones] = useState([]);
const [polygons, setPolygons] = useState([]);
const {zoneSettings, changeZoneSettings} = useAdmin();
const {penaltySettings, changePenaltySettings} = useAdmin();
const [allowedTimeOutOfZone, setAllowedTimeOutOfZone] = useState("");
useEffect(() => {
setPolygons(zones.map((zone) => zone.polygon));
}, [zones])
if (zoneSettings) {
const newPolygons = zoneSettings.polygons.map((zone) => ({id: idFromPolygon(zone.polygon), polygon: zone.polygon, duration: zone.duration}));
setPolygons(newPolygons.map((zone) => zone.polygon));
}
}, [zoneSettings]);
useEffect(() => {
if (zoneSettings) {
setZones(zoneSettings.map((zone) => ({id: idFromPolygon(zone.polygon), polygon: zone.polygon, duration: zone.duration})));
}
if (penaltySettings) {
setAllowedTimeOutOfZone(penaltySettings.allowedTimeOutOfZone.toString());
}
}, [zoneSettings, penaltySettings]);
}, [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) {
setZones([...zones, {id: idFromPolygon(polygon), polygon: polygon, duration: defaultDuration}]);
const newPolygons = [...zoneSettings.polygons, {id: idFromPolygon(polygon), polygon: polygon, duration: defaultDuration}];
updateZoneSettings("polygons", newPolygons);
}
function removePolygon(i) {
setZones(zones.filter((_, index) => index !== i));
const newPolygons = zoneSettings.polygons.filter((_, index) => index !== i);
updateZoneSettings("polygons", newPolygons);
}
function updateDuration(i, duration) {
setZones(zones.map((zone, index) => index === i ? {id: zone.id, polygon: zone.polygon, duration: duration} : zone));
const newPolygons = zoneSettings.polygons.map((zone, index) => index === i ? {id: zone.id, polygon: zone.polygon, duration: duration} : zone);
updateZoneSettings("polygons", newPolygons);
}
function handleSettingsSubmit() {
changeZoneSettings(zones);
applyZoneSettings();
changePenaltySettings({allowedTimeOutOfZone: Number(allowedTimeOutOfZone)});
}
@@ -147,7 +148,7 @@ export default function PolygonZoneSelector() {
<div className="w-full text-center">
<h2 className="text-xl">Reduction order</h2>
</div>
<ReorderList droppableId="zones-order" array={zones} setArray={setZones}>
<ReorderList droppableId="zones-order" array={zoneSettings.polygons} setArray={(polygons) => updateZoneSettings("polygons", polygons)}>
{ (zone, i) =>
<div className="w-full p-2 bg-white flex flex-row gap-2 items-center justify-between">
<p>Zone {i+1}</p>

View File

@@ -4,6 +4,7 @@ import dynamic from "next/dynamic";
import Link from "next/link";
import { TextInput } from "@/components/input";
import { Section } from "@/components/section";
import { BlueButton } from "@/components/button";
import { useAdminConnexion } from "@/context/adminConnexionContext";
import useAdmin from '@/hook/useAdmin';
import Messages from "./components/messages";
@@ -13,17 +14,20 @@ import TeamManager from './components/teamManager';
const PolygonZoneSelector = dynamic(() => import('./components/polygonZoneSelector'), { ssr: false });
const CircleZoneSelector = dynamic(() => import('./components/circleZoneSelector'), { ssr: false });
const zoneSelectors = {
const zoneTypes = {
circle: "circle",
polygon: "polygon"
}
const defaultCircleSettings = {type: zoneTypes.circle, min: null, max: null, reductionCount: 4, duration: 10}
const defaultPolygonSettings = {type: zoneTypes.polygon, polygons: []}
export default function ConfigurationPage() {
const {penaltySettings, changePenaltySettings, addTeam} = useAdmin();
const {zoneSettings, changeZoneSettings, penaltySettings, changePenaltySettings, addTeam} = useAdmin();
const { useProtect } = useAdminConnexion();
const [allowedTimeBetweenUpdates, setAllowedTimeBetweenUpdates] = useState("");
const [teamName, setTeamName] = useState('');
const [zoneSelector, setZoneSelector] = useState(zoneSelectors.polygon);
const [localZoneSettings, setLocalZoneSettings] = useState(zoneSettings);
useProtect();
@@ -33,12 +37,26 @@ export default function ConfigurationPage() {
}
}, [penaltySettings]);
useEffect(() => {
if (zoneSettings) {
setLocalZoneSettings(zoneSettings);
}
}, [zoneSettings]);
function updateLocalZoneSettings(key, value) {
setLocalZoneSettings(prev => ({...prev, [key]: value}));
};
function applySettings() {
if (Number(allowedTimeBetweenUpdates) != penaltySettings.allowedTimeBetweenPositionUpdate) {
changePenaltySettings({allowedTimeBetweenPositionUpdate: Number(allowedTimeBetweenUpdates)});
}
}
function handleChangeZoneType() {
setLocalZoneSettings(localZoneSettings.type == zoneTypes.circle ? defaultPolygonSettings : defaultCircleSettings)
}
function handleSubmit(e) {
e.preventDefault();
if (teamName !== "") {
@@ -77,9 +95,18 @@ export default function ConfigurationPage() {
</div>
</Section>
</div>
<div className="h-full flex-1">
{zoneSelector == zoneSelectors.circle && <CircleZoneSelector/>}
{zoneSelector == zoneSelectors.polygon && <PolygonZoneSelector/>}
<div className="h-full flex-1 flex flex-col">
<div className="w-full h-20">
{localZoneSettings && <BlueButton onClick={handleChangeZoneType}>Change zone type</BlueButton>}
</div>
<div className="w-full flex-1">
{localZoneSettings && localZoneSettings.type == zoneTypes.circle &&
<CircleZoneSelector zoneSettings={localZoneSettings} updateZoneSettings={updateLocalZoneSettings} applyZoneSettings={() => changeZoneSettings(localZoneSettings)}/>
}
{localZoneSettings && localZoneSettings.type == zoneTypes.polygon &&
<PolygonZoneSelector zoneSettings={localZoneSettings} updateZoneSettings={updateLocalZoneSettings} applyZoneSettings={() => changeZoneSettings(localZoneSettings)}/>
}
</div>
</div>
</div>
);

View File

@@ -2,12 +2,11 @@ import { useEffect, useState } from 'react';
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
export function List({array, children}) {
// TODO : change key
return (
<div className='w-full h-full bg-gray-300 overflow-y-scroll'>
<ul className="w-full p-1 pb-0">
{array.map((elem, i) => (
<li className="w-full" key={elem.id ?? i}>
<li className="w-full" key={elem.id}>
{children(elem, i)}
<div className="w-full h-1"/>
</li>