Ajout zones en pavage + fix dockefiles

This commit is contained in:
Sébastien Rivière
2025-06-25 14:34:29 +02:00
parent adcf6f031e
commit 8919a49513
48 changed files with 1074 additions and 714 deletions

View File

@@ -5,7 +5,7 @@ FROM node:22-alpine
WORKDIR /app
# Copy package.json and package-lock.json to the /app directory
COPY package.json package-lock.json ./
COPY package.json package-lock.json* ./
# Install dependencies
RUN npm install

View File

@@ -8,7 +8,7 @@ WORKDIR /app
ENV NODE_ENV=development
# Copy package.json and package-lock.json to the /app directory
COPY package.json package-lock.json ./
COPY package.json package-lock.json* ./
# Install dependencies
RUN npm install

View File

@@ -5,7 +5,7 @@ This module also exposes functions to send messages via socket to all admins
*/
import { io } from "./index.js";
import game from "./game.js"
import zone from "./zone_manager.js"
import zoneManager from "./zone_manager.js"
import penaltyController from "./penalty_controller.js";
import { playersBroadcast, sendUpdatedTeamInformations } from "./team_socket.js";
import { createHash } from "crypto";
@@ -57,12 +57,11 @@ export function initAdminSocketHandler() {
// Other settings that need initialization
socket.emit("penalty_settings", penaltyController.settings)
socket.emit("game_settings", game.settings)
socket.emit("zone_settings", zone.zoneSettings)
socket.emit("zone", zone.currentZone)
socket.emit("new_zone", {
begin: zone.currentStartZone,
end: zone.nextZone,
endDate: zone.nextZoneDate,
socket.emit("zone_settings", zoneManager.settings)
socket.emit("current_zone", {
begin: zoneManager.getCurrentZone(),
end: zoneManager.getNextZone(),
endDate: zoneManager.currentZoneEndDate,
})
} else {
// Attempt unsuccessful
@@ -89,11 +88,11 @@ export function initAdminSocketHandler() {
socket.emit("error", "Not logged in");
return;
}
if (!game.setZoneSettings(settings)) {
if (!zoneManager.changeSettings(settings)) {
socket.emit("error", "Error changing zone");
socket.emit("zone_settings", zone.zoneSettings) // Still broadcast the old config to the client who submited an incorrect config to keep the client up to date
socket.emit("zone_settings", zoneManager.settings)
} else {
secureAdminBroadcast("zone_settings", zone.zoneSettings)
secureAdminBroadcast("zone_settings", zoneManager.settings)
}
})

View File

@@ -3,12 +3,44 @@ This module manages the main game state, the teams, the settings and the game lo
*/
import { secureAdminBroadcast } from "./admin_socket.js";
import { playersBroadcast, sendUpdatedTeamInformations } from "./team_socket.js";
import { isInCircle, getDistanceFromLatLon } from "./map_utils.js";
import timeoutHandler from "./timeoutHandler.js";
import penaltyController from "./penalty_controller.js";
import zoneManager from "./zone_manager.js";
import trajectory from "./trajectory.js";
/**
* Compute the distance between two points givent their longitude and latitude
* @param {Object} pos1 The first position
* @param {Object} pos2 The second position
* @returns the distance between the two positions in meters
* @see https://gist.github.com/miguelmota/10076960
*/
function getDistanceFromLatLon({ lat: lat1, lng: lon1 }, { lat: lat2, lng: lon2 }) {
const degToRad = (deg) => deg * (Math.PI / 180);
var R = 6371; // Radius of the earth in km
var dLat = degToRad(lat2 - lat1);
var dLon = degToRad(lon2 - lon1);
var a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(degToRad(lat1)) * Math.cos(degToRad(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2)
;
var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
var d = R * c; // Distance in km
return d * 1000;
}
/**
* Check if a GPS point is in a circle
* @param {Object} position The position to check, an object with lat and lng fields
* @param {Object} center The center of the circle, an object with lat and lng fields
* @param {Number} radius The radius of the circle in meters
* @returns
*/
function isInCircle(position, center, radius) {
return getDistanceFromLatLon(position, center) < radius;
}
/**
* The possible states of the game
*/
@@ -56,7 +88,7 @@ export default {
switch (newState) {
case GameState.SETUP:
trajectory.stop();
zoneManager.reset();
zoneManager.stop();
penaltyController.stop();
timeoutHandler.endAllSendPositionTimeout();
for (let team of this.teams) {
@@ -77,28 +109,38 @@ export default {
this.updateTeamChasing();
break;
case GameState.PLACEMENT:
if (this.teams.length < 3) {
secureAdminBroadcast("game_state", {state: this.state, startDate: this.startDate});
return false;
}
trajectory.stop();
zoneManager.reset();
zoneManager.stop();
penaltyController.stop();
timeoutHandler.endAllSendPositionTimeout();
this.startDate = null;
break;
case GameState.PLAYING:
if (!zoneManager.start()) {
if (this.teams.length < 3) {
secureAdminBroadcast("game_state", {state: this.state, startDate: this.startDate});
return false;
}
trajectory.start();
zoneManager.start();
penaltyController.start();
this.initLastSentLocations();
this.startDate = Date.now();
break;
case GameState.FINISHED:
if (this.state != GameState.PLAYING) {
secureAdminBroadcast("game_state", {state: this.state, startDate: this.startDate});
return false;
}
for (const team of this.teams) {
if (!team.finishDate) team.finishDate = Date.now();
}
trajectory.stop();
zoneManager.stop();
penaltyController.stop();
zoneManager.reset();
timeoutHandler.endAllSendPositionTimeout();
break;
}
@@ -379,28 +421,4 @@ export default {
timeoutHandler.endSendPositionTimeout(teamId);
this.updateTeamChasing();
},
/**
* Change the settings of the Zone manager
* The game should not be in PLAYING or FINISHED state
* @param {Object} newSettings The object containing the settings to be changed
* @returns false if failed
*/
setZoneSettings(newSettings) {
if ('min' in newSettings || 'max' in newSettings) {
const min = newSettings.min ?? zoneManager.zoneSettings.min;
const max = newSettings.max ?? zoneManager.zoneSettings.max;
// The end zone must be included in the start zone
if (!isInCircle(min.center, max.center, max.radius-min.radius)) {
return false;
}
}
zoneManager.udpateSettings(newSettings);
if (this.state == GameState.PLAYING || this.state == GameState.FINISHED) {
if (!zoneManager.start()) {
return false;
}
}
return true;
}
}

View File

@@ -1,40 +0,0 @@
/**
* Convert a angle from degree to radian
* @param {Number} deg angle in degree
* @returns angle in radian
*/
function degToRad(deg) {
return deg * (Math.PI / 180);
}
/**
* Compute the distance between two points givent their longitude and latitude
* @param {Object} pos1 The first position
* @param {Object} pos2 The second position
* @returns the distance between the two positions in meters
* @see https://gist.github.com/miguelmota/10076960
*/
export function getDistanceFromLatLon({ lat: lat1, lng: lon1 }, { lat: lat2, lng: lon2 }) {
var R = 6371; // Radius of the earth in km
var dLat = degToRad(lat2 - lat1);
var dLon = degToRad(lon2 - lon1);
var a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(degToRad(lat1)) * Math.cos(degToRad(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2)
;
var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
var d = R * c; // Distance in km
return d * 1000;
}
/**
* Check if a GPS point is in a circle
* @param {Object} position The position to check, an object with lat and lng fields
* @param {Object} center The center of the circle, an object with lat and lng fields
* @param {Number} radius The radius of the circle in meters
* @returns
*/
export function isInCircle(position, center, radius) {
return getDistanceFromLatLon(position, center) < radius;
}

View File

@@ -1,11 +1,10 @@
/*
This module manages the verification of the game rules and the penalties.
*/
import { isInCircle } from "./map_utils.js";
import { sendUpdatedTeamInformations, teamBroadcast } from "./team_socket.js";
import { secureAdminBroadcast } from "./admin_socket.js";
import game, { GameState } from "./game.js";
import zone from "./zone_manager.js";
import zoneManager from "./zone_manager.js";
export default {
// Object mapping team id to the date they left the zone as a UNIX millisecond timestamp
@@ -115,10 +114,10 @@ export default {
game.teams.forEach((team) => {
if (team.captured) { return }
//All the informations are not ready yet
if (team.currentLocation == null || zone.currentZone == null) {
if (team.currentLocation == null || !zoneManager.isRunning) {
return;
}
if (!isInCircle({ lat: team.currentLocation[0], lng: team.currentLocation[1] }, zone.currentZone.center, zone.currentZone.radius)) {
if (!zoneManager.isInCircle({ lat: team.currentLocation[0], lng: team.currentLocation[1] })) {
//The team was not previously out of the zone
if (!this.outOfBoundsSince[team.id]) {
this.outOfBoundsSince[team.id] = Date.now();

View File

@@ -6,7 +6,7 @@ This module also exposes functions to send messages via socket to all teams
import { secureAdminBroadcast } from "./admin_socket.js";
import { io } from "./index.js";
import game from "./game.js";
import zone from "./zone_manager.js";
import zoneManager from "./zone_manager.js";
/**
* Send a socket message to all the players of a team
@@ -97,11 +97,10 @@ export function initTeamSocket() {
socket.emit("login_response", true);
socket.emit("game_state", game.state);
socket.emit("game_settings", game.settings);
socket.emit("zone", zone.currentZone);
socket.emit("new_zone", {
begin: zone.currentStartZone,
end: zone.nextZone,
endDate: zone.nextZoneDate,
socket.emit("zone", {
begin: zoneManager.getCurrentZone(),
end: zoneManager.getNextZone(),
endDate: zoneManager.currentZoneEndDate,
})
callback({ isLoggedIn : true, message: "Logged in"});
});

View File

@@ -1,237 +1,230 @@
/*
This module manages the play area during the game, shrinking it over time based of some settings.
*/
import { randomCirclePoint } from 'random-location'
import { isInCircle } from './map_utils.js';
import { playersBroadcast } from './team_socket.js';
import { secureAdminBroadcast } from './admin_socket.js';
/**
* Scale a value that is known to be in a range to a new range
* for instance map(50,0,100,1000,2000) will return 1500 as 50 is halfway between 0 and 100 and 1500 is halfway through 1000 and 2000
* @param {Number} value value to map
* @param {Number} oldMin minimum value of the number
* @param {Number} oldMax maximum value of the number
* @param {Number} newMin minimum value of the output
* @param {Number} newMax maximum value of the output
* @returns
*/
function map(value, oldMin, oldMax, newMin, newMax) {
return ((value - oldMin) / (oldMax - oldMin)) * (newMax - newMin) + newMin;
/* -------------------------------- Useful functions and constants -------------------------------- */
const EARTH_RADIUS = 6_371_000; // Radius of the earth in m
function haversine_distance({ lat: lat1, lng: lon1 }, { lat: lat2, lng: lon2 }) {
const degToRad = (deg) => deg * (Math.PI / 180);
const dLat = degToRad(lat2 - lat1);
const dLon = degToRad(lon2 - lon1);
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(degToRad(lat1)) * Math.cos(degToRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return c * EARTH_RADIUS;
}
function latlngEqual(latlng1, latlng2, epsilon = 1e-9) {
return Math.abs(latlng1.lat - latlng2.lat) < epsilon && Math.abs(latlng1.lng - latlng2.lng) < epsilon;
}
/* -------------------------------- Polygon zones -------------------------------- */
const defaultPolygonSettings = { polygons: [], durations: [] };
function polygonZone(points, duration) {
return {
points: points,
duration: duration,
isInZone(location) {
const {lat: x, lng: y} = location;
let inside = false;
for (let i = 0, j = this.points.length - 1; i < this.points.length; j = i++) {
const {lat: xi, lng: yi} = this.points[i];
const {lat: xj, lng: yj} = this.points[j];
const intersects = ((yi > y) !== (yj > y)) && (x < ((xj - xi) * (y - yi)) / (yj - yi) + xi);
if (intersects) inside = !inside;
}
return inside;
}
}
}
function mergePolygons(poly1, poly2) {
// poly1 and poly2 are clockwise, not overlaping and touching polygons. If those two polygons were on a map, they would be
// one against each other, and the merge would make a new clockwise polygon out of the outer border of the two polygons.
// If it happens that poly1 and poly2 are not touching, poly1 would be returned untouched.
// Basically because polygons are clockwise, the alogorithm starts from a point A in poly1 not shared by poly2, and
// when a point is shared by poly1 and poly2, the algorithm continues in poly2, and so on until point A.
const getPointIndex = (point, array) => {
for (let i = 0; i < array.length; i++) {
if (latlngEqual(array[i], point)) return i;
}
return -1;
}
// Find the index of the first point of poly1 that doesn't belong to merge (it exists)
let i = 0;
while (getPointIndex(poly1[i], poly2) != -1) i++;
// Starting the merge from that point
const merge = [poly1[i]];
i = (i + 1) % poly1.length;
let currentArray = poly1;
let otherArray = poly2;
while (!latlngEqual(currentArray[i], merge[0])) {
const j = getPointIndex(currentArray[i], otherArray);
if (j != -1) {
[currentArray, otherArray] = [otherArray, currentArray];
i = j;
}
merge.push(currentArray[i]);
i = (i + 1) % currentArray.length;
}
return merge;
}
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];
const length = zones.length;
if (length == 0) {
zones.push(polygonZone(
polygon,
duration
));
} else {
zones.push(polygonZone(
mergePolygons(zones[length-1].points, polygon),
duration
));
}
}
return zones.slice().reverse();
}
/* -------------------------------- 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 {
//Setings storing where the zone will start, end and how it should evolve
//The zone will start by staying at its max value for reductionInterval minutes
//and then reduce during reductionDuration minutes, then wait again...
//The reduction factor is such that after reductionCount the zone will be the min zone
zoneSettings: {
min: { center: null, radius: null },
max: { center: null, radius: null },
reductionCount: 2,
reductionDuration: 1,
reductionInterval: 1,
updateIntervalSeconds: 1,
},
isRunning: false,
zones: [], // A zone has to be connected space that doesn't contain an earth pole
currentZone: { id: 0, timeoutId: null, endDate: null },
settings: defaultPolygonSettings,
nextZoneDecrement: null,
//Live location of the zone
currentZone: { center: null, radius: null },
//If the zone is shrinking, this is the target of the current shrinking
//If the zone is not shrinking, this will be the target of the next shrinking
nextZone: { center: null, radius: null },
//Zone at the begining of the shrinking
currentStartZone: { center: null, radius: null },
startDate: null,
started: false,
updateIntervalId: null,
nextZoneTimeoutId: null,
nextZoneDate: null,
/**
* Test if a given configuration object is valid, i.e if all needed values are well defined
* @param {Object} settings Settings object describing a config of a zone manager
* @returns if the config is correct
*/
validateSettings(settings) {
if (settings.reductionCount && (typeof settings.reductionCount != "number" || settings.reductionCount <= 0)) { return false }
if (settings.reductionDuration && (typeof settings.reductionDuration != "number" || settings.reductionDuration < 0)) { return false }
if (settings.reductionInterval && (typeof settings.reductionInterval != "number" || settings.reductionInterval < 0)) { return false }
if (settings.updateIntervalSeconds && (typeof settings.updateIntervalSeconds != "number" || settings.updateIntervalSeconds <= 0)) { return false }
if (settings.max && (typeof settings.max.radius != "number" || typeof settings.max.center.lat != "number" || typeof settings.max.center.lng != "number")) { return false }
if (settings.min && (typeof settings.min.radius != "number" || typeof settings.min.center.lat != "number" || typeof settings.min.center.lng != "number")) { return false }
return true;
},
/**
* Test if the zone manager is ready to start
* @returns true if the zone manager is ready to be started, false otherwise
*/
ready() {
return this.validateSettings(this.zoneSettings);
},
/**
* Update the settings of the zone, this can be done by passing an object containing the settings to change.
* Unless specified, the durations are in minutes
* Default config :
* `this.zoneSettings = {
* min: {center: null, radius: null},
* max: {center: null, radius: null},
* reductionCount: 2,
* reductionDuration: 10,
* reductionInterval: 10,
* updateIntervalSeconds: 10,
* }`
* @param {Object} newSettings The fields of the settings to udpate
* @returns
*/
udpateSettings(newSettings) {
//validate settings
this.zoneSettings = { ...this.zoneSettings, ...newSettings };
this.nextZoneDecrement = (this.zoneSettings.max.radius - this.zoneSettings.min.radius) / this.zoneSettings.reductionCount;
return true;
},
/**
* Reinitialize the object and stop all the tasks
*/
reset() {
this.currentZoneCount = 0;
this.started = false;
if (this.updateIntervalId != null) {
clearInterval(this.updateIntervalId);
this.updateIntervalId = null;
}
if (this.nextZoneTimeoutId != null) {
clearTimeout(this.nextZoneTimeoutId);
this.nextZoneTimeoutId = null;
}
},
/**
* Start the zone reduction sequence
*/
start() {
if (!this.ready()) return false;
this.reset();
this.started = true;
this.startDate = new Date();
//initialize the zone to its max value
this.nextZone = JSON.parse(JSON.stringify(this.zoneSettings.max));
this.currentStartZone = JSON.parse(JSON.stringify(this.zoneSettings.max));
this.currentZone = JSON.parse(JSON.stringify(this.zoneSettings.max));
return this.setNextZone();
this.isRunning = true;
this.currentZone.id = -1;
this.goNextZone();
},
/**
* Get the center of the next zone, this center need to satisfy two properties
* - it needs to be included in the current zone, this means that this new point should lie in the circle of center currentZone.center and radius currentZone.radius - newRadius
* - it needs to include the last zone, which means that the center must lie in the center of center min.center and of radius newRadius - min.radius
* @param newRadius the radius that the new zone will have
* @returns the coordinates of the new center as an object with lat and long fields
*/
getRandomNextCenter(newRadius) {
let ok = false;
let res = null
let tries = 0;
const MAX_TRIES = 100000
//take a random point satisfying both conditions
while (tries++ < MAX_TRIES && !ok) {
res = randomCirclePoint({ latitude: this.currentZone.center.lat, longitude: this.currentZone.center.lng }, this.currentZone.radius - newRadius);
ok = (isInCircle({ lat: res.latitude, lng: res.longitude }, this.zoneSettings.min.center, newRadius - this.zoneSettings.min.radius))
}
if (tries >= MAX_TRIES) {
return false;
}
return {
lat: res.latitude,
lng: res.longitude
stop() {
this.isRunning = false;
clearTimeout(this.currentZone.timeoutId);
},
goNextZone() {
this.currentZone.id++;
if (this.currentZone.id >= this.zones.length) return;
this.currentZone.timeoutId = setTimeout(() => this.goNextZone(), this.getCurrentZone().duration * 60 * 1000);
this.currentZone.endDate = Date.now() + this.getCurrentZone().duration * 60 * 1000;
this.zoneBroadcast();
},
getCurrentZone() {
return this.zones[this.currentZone.id];
},
getNextZone() {
if (this.currentZone.id + 1 < this.zones.length) {
return this.zones[this.currentZone.id + 1];
} else {
return this.zones[this.currentZone.id];
}
},
/**
* Compute the next zone satifying the given settings, update the nextZone and currentStartZone
* Wait for the appropriate duration before starting a new zone reduction if needed
*/
setNextZone() {
this.nextZoneDate = Date.now() + this.zoneSettings.reductionInterval * 60 * 1000;
//At this point, nextZone == currentZone, we need to update the next zone, the raidus decrement, and start a timer before the next shrink
//last zone
if (this.currentZoneCount == this.zoneSettings.reductionCount) {
this.nextZone = JSON.parse(JSON.stringify(this.zoneSettings.min))
this.currentStartZone = JSON.parse(JSON.stringify(this.zoneSettings.min))
} else if (this.currentZoneCount == this.zoneSettings.reductionCount - 1) {
this.currentStartZone = JSON.parse(JSON.stringify(this.currentZone))
this.nextZone = JSON.parse(JSON.stringify(this.zoneSettings.min))
this.nextZoneTimeoutId = setTimeout(() => this.startShrinking(), 1000 * 60 * this.zoneSettings.reductionInterval)
this.currentZoneCount++;
} else if (this.currentZoneCount < this.zoneSettings.reductionCount) {
this.nextZone.center = this.getRandomNextCenter(this.nextZone.radius - this.nextZoneDecrement)
//Next center cannot be found
if (this.nextZone.center === false) {
console.log("no center")
return false;
}
this.nextZone.radius -= this.nextZoneDecrement;
this.currentStartZone = JSON.parse(JSON.stringify(this.currentZone))
this.nextZoneTimeoutId = setTimeout(() => this.startShrinking(), 1000 * 60 * this.zoneSettings.reductionInterval)
this.currentZoneCount++;
isInZone(location) {
if (this.zones.length == 0) {
return true;
} else {
return this.getCurrentZone().isInZone(location);
}
this.onZoneUpdate(JSON.parse(JSON.stringify(this.currentStartZone)))
this.onNextZoneUpdate({
begin: JSON.parse(JSON.stringify(this.currentStartZone)),
end: JSON.parse(JSON.stringify(this.nextZone)),
endDate: JSON.parse(JSON.stringify(this.nextZoneDate)),
})
},
changeSettings(settings) {
const zones = polygonSettingsToZones(settings);
if (!zones) return false;
this.zones = zones;
this.settings = settings;
this.zoneBroadcast();
return true;
},
/*
* Start a task that will run periodically updatinng the zone size, and calling the onZoneUpdate callback
* This will also periodically check if the reduction is over or not
* If the reduction is over this function will call setNextZone
*/
startShrinking() {
this.nextZoneDate = Date.now() + this.zoneSettings.reductionDuration * 60 * 1000;
this.onZoneUpdateStart(JSON.parse(JSON.stringify(this.nextZoneDate)));
const startTime = new Date();
this.updateIntervalId = setInterval(() => {
const completed = ((new Date() - startTime) / (1000 * 60)) / this.zoneSettings.reductionDuration;
this.currentZone.radius = map(completed, 0, 1, this.currentStartZone.radius, this.nextZone.radius)
this.currentZone.center.lat = map(completed, 0, 1, this.currentStartZone.center.lat, this.nextZone.center.lat)
this.currentZone.center.lng = map(completed, 0, 1, this.currentStartZone.center.lng, this.nextZone.center.lng)
this.onZoneUpdate(JSON.parse(JSON.stringify(this.currentZone)))
//Zone shrinking is over
if (completed >= 1) {
clearInterval(this.updateIntervalId);
this.updateIntervalId = null;
this.setNextZone();
return;
}
}, this.zoneSettings.updateIntervalSeconds * 1000);
zoneBroadcast() {
const zone = {
begin: this.getCurrentZone(),
end: this.getNextZone(),
endDate:this.currentZone.endDate,
};
playersBroadcast("current_zone", zone);
secureAdminBroadcast("current_zone", zone);
},
//a call to onNextZoneUpdate will be made when the zone reduction ends and a new next zone is announced
onNextZoneUpdate(newZone) {
playersBroadcast("new_zone", newZone)
secureAdminBroadcast("new_zone", newZone)
},
//a call to onZoneUpdateStart will be made when the zone reduction starts
onZoneUpdateStart(date) {
playersBroadcast("zone_start", date)
secureAdminBroadcast("zone_start", date)
},
//a call to onZoneUpdate will be made every updateIntervalSeconds when the zone is changing
onZoneUpdate(zone) {
playersBroadcast("zone", zone)
secureAdminBroadcast("zone", zone)
},
}

View File

@@ -1,8 +1,8 @@
import { AdminConnexionProvider} from "@/context/adminConnexionContext";
import { AdminConnexionProvider } from "@/context/adminConnexionContext";
import { AdminProvider } from "@/context/adminContext";
import Link from "next/link";
export default function AdminLayout({ children}) {
export default function AdminLayout({ children }) {
return (
<AdminConnexionProvider>
<AdminProvider>

View File

@@ -1,15 +1,14 @@
"use client";
import { TeamReady } from "@/components/admin/teamReady";
import BlueButton, { GreenButton, RedButton } from "@/components/util/button";
import TeamReady from "@/components/admin/teamReady";
import { BlueButton, GreenButton, RedButton } from "@/components/util/button";
import { useAdminConnexion } from "@/context/adminConnexionContext";
import useAdmin from "@/hook/useAdmin";
import { GameState } from "@/util/gameState";
import dynamic from "next/dynamic";
import { TeamListFixed } from '@/components/admin/teamList';
const LiveMap = dynamic(() => import('@/components/admin/mapPicker').then((mod) => mod.LiveMap), {
ssr: false
});
// Imported at runtime and not at compile time
const LiveMap = dynamic(() => import('@/components/admin/liveMap'), { ssr: false });
export default function AdminPage() {
const { useProtect } = useAdminConnexion();
const { gameState, changeState } = useAdmin();

View File

@@ -1,12 +1,12 @@
"use client";
import { GameSettings } from "@/components/admin/gameSettings";
import { PenaltySettings } from "@/components/admin/penaltySettings";
import GameSettings from "@/components/admin/gameSettings";
import PenaltySettings from "@/components/admin/penaltySettings";
import { useAdminConnexion } from "@/context/adminConnexionContext";
import dynamic from "next/dynamic";
const ZoneSelector = dynamic(() => import('@/components/admin/zoneSelector').then((mod) => mod.ZoneSelector), {
ssr: false
});
// Imported at runtime and not at compile time
const ZoneSelector = dynamic(() => import('@/components/admin/polygonZoneMap'), { ssr: false });
export default function AdminPage() {
const { useProtect } = useAdminConnexion();
useProtect();

View File

@@ -1,8 +1,8 @@
"use client";
import ActionDrawer from '@/components/team/actionDrawer';
import { Notification } from '@/components/team/notification';
import Notification from '@/components/team/notification';
import PlacementOverlay from '@/components/team/placementOverlay';
import { WaitingScreen } from '@/components/team/waitingScreen';
import WaitingScreen from '@/components/team/waitingScreen';
import { LogoutButton } from '@/components/util/button';
import { useSocket } from '@/context/socketContext';
import { useTeamConnexion } from '@/context/teamConnexionContext';

View File

@@ -0,0 +1,110 @@
import { useEffect, useState } from "react";
import { BlueButton, GreenButton, RedButton } from "../util/button";
import { TextInput } from "../util/textInput";
import useAdmin from "@/hook/useAdmin";
import useLocation from "@/hook/useLocation";
import "leaflet/dist/leaflet.css";
import { Circle, MapContainer, TileLayer } from "react-leaflet";
import useMapCircleDraw from "@/hook/useMapCircleDraw";
import { MapPan, MapEventListener } from "./mapUtils";
const DEFAULT_ZOOM = 14;
const EditMode = {
MIN: 0,
MAX: 1
}
function CircleDrawings({ minZone, setMinZone, maxZone, setMaxZone, editMode }) {
const { handleClick: maxClick, handleMouseMove: maxHover, center: maxCenter, radius: maxRadius } = useMapCircleDraw(minZone, setMinZone);
const { handleClick: minClick, handleMouseMove: minHover, center: minCenter, radius: minRadius } = useMapCircleDraw(maxZone, setMaxZone);
function handleLeftClick(e) {
if (editMode == EditMode.MAX) {
maxClick(e);
} else {
minClick(e);
}
}
function handleMouseMove(e) {
if (editMode == EditMode.MAX) {
maxHover(e);
} else {
minHover(e);
}
}
return (
<div>
{minCenter && minRadius && <Circle center={minCenter} radius={minRadius} color="blue" fillColor="blue" />}
{maxCenter && maxRadius && <Circle center={maxCenter} radius={maxRadius} color="red" fillColor="red" />}
<MapEventListener onLeftClick={handleLeftClick} onRightClick={() => {}} onMouseMove={handleMouseMove} />
</div>
);
}
export function CircleZonePicker({ minZone, maxZone, editMode, setMinZone, setMaxZone, ...props }) {
const location = useLocation(Infinity);
return (
<div className='h-96'>
<MapContainer {...props} className='min-h-full w-full' center={location} zoom={DEFAULT_ZOOM} scrollWheelZoom={true}>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<MapPan center={location} zoom={DEFAULT_ZOOM} />
<CircleDrawings minZone={minZone} maxZone={maxZone} editMode={editMode} setMinZone={setMinZone} setMaxZone={setMaxZone} />
</MapContainer>
</div>
);
}
export default function CircleZoneMap() {
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]);
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) {
setEditMode(EditMode.MAX);
}else {
setEditMode(EditMode.MIN);
}
}, [minZone, maxZone]);
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>
{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>}
<CircleZonePicker minZone={minZone} maxZone={maxZone} editMode={editMode} setMinZone={setMinZone} setMaxZone={setMaxZone} />
<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>
<GreenButton onClick={handleSettingsSubmit}>Apply</GreenButton>
</div>
}

View File

@@ -3,7 +3,7 @@ import { TextArea } from "../util/textInput";
import { GreenButton } from "../util/button";
import { useEffect, useState } from "react";
export const GameSettings = () => {
export default function GameSettings() {
const {gameSettings, changeGameSettings} = useAdmin();
const [capturedMessage, setCapturedMessage] = useState("");
const [winnerEndMessage, setWinnerEndMessage] = useState("");
@@ -11,7 +11,6 @@ export const GameSettings = () => {
const [waitingMessage, setWaitingMessage] = useState("");
useEffect(() => {
console.log({gameSettings})
if (gameSettings) {
setCapturedMessage(gameSettings.capturedMessage);
setWinnerEndMessage(gameSettings.winnerEndGameMessage);

View File

@@ -0,0 +1,75 @@
import useLocation from "@/hook/useLocation";
import { useEffect, useState } from "react";
import "leaflet/dist/leaflet.css";
import { MapContainer, Marker, TileLayer, Tooltip, Polyline, Polygon } from "react-leaflet";
import useAdmin from "@/hook/useAdmin";
import { GameState } from "@/util/gameState";
import { MapPan } from "./mapUtils";
const DEFAULT_ZOOM = 14;
const positionIcon = new L.Icon({
iconUrl: '/icons/location.png',
iconSize: [30, 30],
iconAnchor: [15, 15],
popupAnchor: [0, -15],
shadowSize: [30, 30],
});
export default function LiveMap() {
const location = useLocation(Infinity);
const [timeLeftNextZone, setTimeLeftNextZone] = useState(null);
const { zoneExtremities, teams, nextZoneDate, getTeam, gameState } = useAdmin();
// Remaining time before sending position
useEffect(() => {
if (nextZoneDate) {
const updateTime = () => {
setTimeLeftNextZone(Math.max(0, Math.floor((nextZoneDate - Date.now()) / 1000)));
};
updateTime();
const interval = setInterval(updateTime, 1000);
return () => clearInterval(interval);
}
}, [nextZoneDate]);
function formatTime(time) {
// time is in seconds
if (time < 0) return "00:00";
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return String(minutes).padStart(2,"0") + ":" + String(seconds).padStart(2,"0");
}
function Arrow({pos1, pos2}) {
if (pos1 && pos2) {
return (
<Polyline positions={[pos1, pos2]} pathOptions={{ color: 'black', weight: 3 }}/>
)
} else {
return null;
}
}
return (
<div className='min-h-full w-full'>
{gameState == GameState.PLAYING && timeLeftNextZone && <p>{`Next zone in : ${formatTime(timeLeftNextZone)}`}</p>}
<MapContainer className='min-h-full w-full' center={location} zoom={DEFAULT_ZOOM} scrollWheelZoom={true}>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<MapPan center={location} zoom={DEFAULT_ZOOM} />
{gameState == GameState.PLAYING && zoneExtremities.begin && <Polygon positions={zoneExtremities.begin.points} pathOptions={{ color: 'blue', fillColor: 'blue', fillOpacity: '0.2', weight: 3 }} />}
{gameState == GameState.PLAYING && zoneExtremities.end && <Polygon positions={zoneExtremities.end.points} pathOptions={{ color: 'red', fillColor: 'red', fillOpacity: '0', weight: 3 }} />}
{teams.map((team) => team.currentLocation && !team.captured &&
<Marker key={team.id} position={team.currentLocation} icon={positionIcon}>
<Tooltip permanent direction="top" offset={[0, -5]} className="custom-tooltip">{team.name}</Tooltip>
<Arrow pos1={team.currentLocation} pos2={getTeam(team.chasing).currentLocation}/>
</Marker>
)}
</MapContainer>
</div>
)
}

View File

@@ -1,166 +0,0 @@
"use client";
import { useLocation } from "@/hook/useLocation";
import { useEffect, useState } from "react";
import "leaflet/dist/leaflet.css";
import { Circle, MapContainer, Marker, TileLayer, useMap, Tooltip, Polyline } from "react-leaflet";
import { useMapCircleDraw } from "@/hook/mapDrawing";
import useAdmin from "@/hook/useAdmin";
import { GameState } from "@/util/gameState";
const positionIcon = new L.Icon({
iconUrl: '/icons/location.png',
iconSize: [30, 30],
iconAnchor: [15, 15],
popupAnchor: [0, -15],
shadowSize: [30, 30],
});
function MapPan(props) {
const map = useMap();
const [initialized, setInitialized] = useState(false);
useEffect(() => {
if (!initialized && props.center) {
map.flyTo(props.center, props.zoom, { animate: false });
setInitialized(true)
}
}, [props.center]);
return null;
}
function MapEventListener({ onClick, onMouseMove }) {
const map = useMap();
useEffect(() => {
map.on('click', onClick);
return () => {
map.off('click', onClick);
}
}, [onClick]);
useEffect(() => {
map.on('mousemove', onMouseMove);
return () => {
map.off('mousemove', onMouseMove);
}
});
return null;
}
const DEFAULT_ZOOM = 14;
export function CircularAreaPicker({ area, setArea, markerPosition, ...props }) {
const location = useLocation(Infinity);
const { handleClick, handleMouseMove, center, radius } = useMapCircleDraw(area, setArea);
return (
<MapContainer {...props} className='min-h-full w-full ' center={location} zoom={DEFAULT_ZOOM} scrollWheelZoom={true}>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{center && radius && <Circle center={center} radius={radius} fillColor="blue" />}
{markerPosition && <Marker position={markerPosition} icon={positionIcon}>
</Marker>}
<MapPan center={location} zoom={DEFAULT_ZOOM} />
<MapEventListener onClick={handleClick} onMouseMove={handleMouseMove} />
</MapContainer>)
}
export const EditMode = {
MIN: 0,
MAX: 1
}
export function ZonePicker({ minZone, setMinZone, maxZone, setMaxZone, editMode, ...props }) {
const location = useLocation(Infinity);
const { handleClick: maxClick, handleMouseMove: maxHover, center: maxCenter, radius: maxRadius } = useMapCircleDraw(minZone, setMinZone);
const { handleClick: minClick, handleMouseMove: minHover, center: minCenter, radius: minRadius } = useMapCircleDraw(maxZone, setMaxZone);
function handleClick(e) {
if (editMode == EditMode.MAX) {
maxClick(e);
} else {
minClick(e);
}
}
function handleMouseMove(e) {
if (editMode == EditMode.MAX) {
maxHover(e);
} else {
minHover(e);
}
}
return (
<div>
<div className='h-96'>
<MapContainer {...props} className='min-h-full w-full ' center={location} zoom={DEFAULT_ZOOM} scrollWheelZoom={true}>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{minCenter && minRadius && <Circle center={minCenter} radius={minRadius} color="blue" fillColor="blue" />}
{maxCenter && maxRadius && <Circle center={maxCenter} radius={maxRadius} color="red" fillColor="red" />}
<MapPan center={location} zoom={DEFAULT_ZOOM} />
<MapEventListener onClick={handleClick} onMouseMove={handleMouseMove} />
</MapContainer>
</div>
{ maxCenter && minCenter && typeof maxCenter.distanceTo === 'function'
&& maxRadius + maxCenter.distanceTo(minCenter) >= minRadius
&& <p className="text-red-500">La zone de fin doit être incluse dans celle de départ</p>}
</div>
)
}
export function LiveMap() {
const location = useLocation(Infinity);
const [timeLeftNextZone, setTimeLeftNextZone] = useState(null);
const { zone, zoneExtremities, teams, nextZoneDate, isShrinking , getTeam, gameState } = useAdmin();
// Remaining time before sending position
useEffect(() => {
const updateTime = () => {
setTimeLeftNextZone(Math.max(0, Math.floor((nextZoneDate - Date.now()) / 1000)));
};
updateTime();
const interval = setInterval(updateTime, 1000);
return () => clearInterval(interval);
}, [nextZoneDate]);
function formatTime(time) {
// time is in seconds
if (time < 0) return "00:00";
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return String(minutes).padStart(2,"0") + ":" + String(seconds).padStart(2,"0");
}
function Arrow({pos1, pos2}) {
if (pos1 && pos2) {
return (
<Polyline positions={[pos1, pos2]} pathOptions={{ color: 'black', weight: 3 }}/>
)
} else {
return null;
}
}
return (
<div className='min-h-full w-full'>
{gameState == GameState.PLAYING && <p>{`${isShrinking ? "Fin" : "Début"} du rétrécissement de la zone dans : ${formatTime(timeLeftNextZone)}`}</p>}
<MapContainer className='min-h-full w-full' center={location} zoom={DEFAULT_ZOOM} scrollWheelZoom={true}>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<MapPan center={location} zoom={DEFAULT_ZOOM} />
{gameState == GameState.PLAYING && zone && <Circle center={zone.center} radius={zone.radius} color="blue" />}
{gameState == GameState.PLAYING && zoneExtremities && <Circle center={zoneExtremities.begin.center} radius={zoneExtremities.begin.radius} color='black' fill={false} />}
{gameState == GameState.PLAYING && zoneExtremities && <Circle center={zoneExtremities.end.center} radius={zoneExtremities.end.radius} color='red' fill={false} />}
{teams.map((team) => team.currentLocation && !team.captured &&
<Marker key={team.id} position={team.currentLocation} icon={positionIcon}>
<Tooltip permanent direction="top" offset={[0, -5]} className="custom-tooltip">{team.name}</Tooltip>
<Arrow pos1={team.currentLocation} pos2={getTeam(team.chasing).currentLocation}/>
</Marker>
)}
</MapContainer>
</div>
)
}

View File

@@ -0,0 +1,88 @@
import { useEffect, useState } from "react";
import "leaflet/dist/leaflet.css";
import { useMap } from "react-leaflet";
export function MapPan(props) {
const map = useMap();
const [initialized, setInitialized] = useState(false);
useEffect(() => {
if (!initialized && props.center) {
map.flyTo(props.center, props.zoom, { animate: false });
setInitialized(true)
}
}, [props.center]);
return null;
}
export function MapEventListener({ onLeftClick, onRightClick, onMouseMove }) {
const map = useMap();
// Handle the mouse click left
useEffect(() => {
let moved = false;
let downButton = null;
const handleMouseDown = (e) => {
moved = false;
downButton = e.originalEvent.button;
};
const handleMouseMove = () => {
moved = true;
};
const handleMouseUp = (e) => {
if (!moved) {
if (downButton == 0) {
onLeftClick(e);
}
}
downButton = null;
};
map.on('mousedown', handleMouseDown);
map.on('mousemove', handleMouseMove);
map.on('mouseup', handleMouseUp);
return () => {
map.off('mousedown', handleMouseDown);
map.off('mousemove', handleMouseMove);
map.off('mouseup', handleMouseUp);
};
}, [onLeftClick, onRightClick]);
// Handle the right click
useEffect(() => {
const handleMouseDown = (e) => {
if (e.originalEvent.button == 2) {
onRightClick(e);
}
};
map.on('mousedown', handleMouseDown);
return () => {
map.off('mousedown', handleMouseDown);
}
}, [onRightClick]);
// Handle the mouse move
useEffect(() => {
map.on('mousemove', onMouseMove);
return () => {
map.off('mousemove', onMouseMove);
}
}, [onMouseMove]);
// Prevent right click context menu
useEffect(() => {
const container = map.getContainer();
const preventContextMenu = (e) => e.preventDefault();
container.addEventListener('contextmenu', preventContextMenu);
return () => container.removeEventListener('contextmenu', preventContextMenu);
}, []);
}

View File

@@ -1,9 +1,9 @@
import useAdmin from "@/hook/useAdmin";
import TextInput from "../util/textInput";
import { TextInput } from "../util/textInput";
import { GreenButton } from "../util/button";
import { useEffect, useState } from "react";
export const PenaltySettings = () => {
export default function PenaltySettings() {
const {penaltySettings, changePenaltySettings} = useAdmin();
const [maxPenalties, setMaxPenalties] = useState("");
const [allowedTimeOutOfZone, setAllowedTimeOutOfZone] = useState("");

View File

@@ -0,0 +1,33 @@
import useLocation from "@/hook/useLocation";
import "leaflet/dist/leaflet.css";
import { Circle, MapContainer, Marker, TileLayer } from "react-leaflet";
import useMapCircleDraw from "@/hook/useMapCircleDraw";
import { MapPan, MapEventListener } from "./mapUtils";
const DEFAULT_ZOOM = 14;
const positionIcon = new L.Icon({
iconUrl: '/icons/location.png',
iconSize: [30, 30],
iconAnchor: [15, 15],
popupAnchor: [0, -15],
shadowSize: [30, 30],
});
export default function CircularAreaPicker({ area, setArea, markerPosition, ...props }) {
const location = useLocation(Infinity);
const { handleClick, handleMouseMove, center, radius } = useMapCircleDraw(area, setArea);
return (
<MapContainer {...props} className='min-h-full w-full ' center={location} zoom={DEFAULT_ZOOM} scrollWheelZoom={true}>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{center && radius && <Circle center={center} radius={radius} fillColor="blue" />}
{markerPosition && <Marker position={markerPosition} icon={positionIcon}>
</Marker>}
<MapPan center={location} zoom={DEFAULT_ZOOM} />
<MapEventListener onLeftClick={handleClick} onRightClick={() => {}} onMouseMove={handleMouseMove} />
</MapContainer>
);
}

View File

@@ -0,0 +1,142 @@
import { useEffect, useState } from "react";
import { GreenButton } from "../util/button";
import { TextInput } from "../util/textInput";
import useAdmin from "@/hook/useAdmin";
import useLocation from "@/hook/useLocation";
import "leaflet/dist/leaflet.css";
import { MapContainer, TileLayer, Polyline, Polygon, CircleMarker } from "react-leaflet";
import useMapPolygonDraw from "@/hook/useMapPolygonDraw";
import { MapPan, MapEventListener } from "./mapUtils";
const DEFAULT_ZOOM = 14;
function PolygonDrawings({ polygons, addPolygon, removePolygon }) {
const { currentPolygon, highlightNodes, handleLeftClick, handleRightClick, handleMouseMove } = useMapPolygonDraw(polygons, addPolygon, removePolygon);
const nodeSize = 5; // px
const lineThickness = 3; // px
function DrawNode({pos, color}) {
return (
<CircleMarker center={pos} radius={nodeSize} pathOptions={{ color: color, fillColor: color, fillOpacity: 1 }} />
);
}
function DrawLine({pos1, pos2, color}) {
return (
<Polyline positions={[pos1, pos2]} pathOptions={{ color: color, weight: lineThickness }} />
);
}
function DrawUnfinishedPolygon({polygon}) {
const length = polygon.length;
if (length > 0) {
return (
<div>
<DrawNode pos={polygon[0]} color={"red"} zIndexOffset={1000} />
{polygon.map((_, i) => {
if (i < length-1) {
return <DrawLine key={i} pos1={polygon[i]} pos2={polygon[i+1]} color={"red"} />;
} else {
return null;
}
})}
</div>
);
}
}
function DrawPolygon({polygon}) {
const length = polygon.length;
if (length > 2) {
return (
<Polygon positions={polygon} pathOptions={{ color: 'black', fillColor: 'black', fillOpacity: '0.5', weight: lineThickness }} />
);
}
}
return (
<div>
<MapEventListener onLeftClick={handleLeftClick} onRightClick={handleRightClick} onMouseMove={handleMouseMove} />
{polygons.map((polygon, i) => <DrawPolygon key={i} polygon={polygon} />)}
<DrawUnfinishedPolygon polygon={currentPolygon} />
{highlightNodes.map((node, i) => <DrawNode key={i} pos={node} color={"black"} />)}
</div>
);
}
function PolygonZonePicker({ polygons, addPolygon, removePolygon, ...props }) {
const location = useLocation(Infinity);
return (
<div className='h-96'>
<MapContainer {...props} className='min-h-full w-full' center={location} zoom={DEFAULT_ZOOM} scrollWheelZoom={true}>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<MapPan center={location} zoom={DEFAULT_ZOOM} />
<PolygonDrawings polygons={polygons} addPolygon={addPolygon} removePolygon={removePolygon} />
</MapContainer>
</div>
);
}
export default function PolygonZoneMap() {
const defaultDuration = 10;
const [polygons, setPolygons] = useState([]);
const [durations, setDurations] = useState([]);
const {zoneSettings, changeZoneSettings} = useAdmin();
useEffect(() => {
if (zoneSettings) {
setPolygons(zoneSettings.polygons);
setDurations(zoneSettings.durations);
}
}, [zoneSettings]);
function addPolygon(polygon) {
// Polygons
setPolygons([...polygons, polygon]);
// Durations
setDurations([...durations, defaultDuration]);
}
function removePolygon(i) {
// Polygons
const newPolygons = [...polygons];
newPolygons.splice(i, 1);
setPolygons(newPolygons);
// Durations
const newDurations = [...durations];
newDurations.splice(i, 1);
setDurations(newDurations);
}
function updateDuration(i, duration) {
const newDurations = [...durations];
newDurations[i] = duration;
setDurations(newDurations);
}
function handleSettingsSubmit() {
const newSettings = {polygons: polygons, durations: durations};
changeZoneSettings(newSettings);
}
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>
<PolygonZonePicker polygons={polygons} addPolygon={addPolygon} removePolygon={removePolygon} />
<ul>
{durations.map((duration, i) => (
<li key={i}>
<p>Zone {i+1}</p>
<TextInput value={duration} onChange={(e) => updateDuration(i, e.target.value)}/>
</li>
))}
</ul>
<GreenButton onClick={handleSettingsSubmit}>Apply</GreenButton>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import React from 'react'
import TextInput from '../util/textInput'
import BlueButton from '../util/button'
import { TextInput } from '../util/textInput'
import { BlueButton } from '../util/button'
export default function TeamAddForm({onAddTeam}) {
const [teamName, setTeamName] = React.useState('');

View File

@@ -1,15 +1,13 @@
import React, { useEffect, useRef, useState } from 'react'
import TextInput from '../util/textInput'
import BlueButton, { RedButton } from '../util/button';
import { TextInput } from '../util/textInput'
import { BlueButton, RedButton } from '../util/button';
import useAdmin from '@/hook/useAdmin';
import dynamic from 'next/dynamic';
import { env } from 'next-runtime-env';
import { GameState } from '@/util/gameState';
const CircularAreaPicker = dynamic(() => import('./mapPicker').then((mod) => mod.CircularAreaPicker), {
ssr: false
});
// Imported at runtime and not at compile time
const PlacementMap = dynamic(() => import('./placementMap'), { ssr: false });
export default function TeamEdit({ selectedTeamId, setSelectedTeamId }) {
const teamImage = useRef(null);
@@ -84,7 +82,7 @@ export default function TeamEdit({ selectedTeamId, setSelectedTeamId }) {
<RedButton onClick={handleRemove}>Remove</RedButton>
</div>
<p className='text-2xl text-center w-full'>Starting zone</p>
<CircularAreaPicker area={team.startingArea} setArea={(startingArea) => updateTeam(team.id, { startingArea })} markerPosition={team?.currentLocation} />
<PlacementMap area={team.startingArea} setArea={(startingArea) => updateTeam(team.id, { startingArea })} markerPosition={team?.currentLocation} />
</div>
<div className='flex w-1/2 flex-col h-min gap-2 items-center'>
<h2 className='text-2xl text-center'>Team details</h2>

View File

@@ -1,14 +1,12 @@
"use client";
import useAdmin from '@/hook/useAdmin';
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
import React from 'react'
const reorder = (list, startIndex, endIndex) => {
const result = Array.from(list);
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
return result;
function reorder(list, startIndex, endIndex) {
const result = Array.from(list);
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
return result;
};
function TeamListItem({ team, index, onSelected, itemSelected }) {
@@ -24,11 +22,12 @@ function TeamListItem({ team, index, onSelected, itemSelected }) {
)}
</Draggable>
)
);
}
export default function TeamList({selectedTeamId, onSelected}) {
const {teams, reorderTeams} = useAdmin();
function onDragEnd(result) {
if (!result.destination) {
return;
@@ -46,6 +45,7 @@ export default function TeamList({selectedTeamId, onSelected}) {
reorderTeams(newTeams);
}
return (
<DragDropContext onDragEnd={onDragEnd} >
<Droppable droppableId='team-list'>
@@ -61,5 +61,5 @@ export default function TeamList({selectedTeamId, onSelected}) {
)}
</Droppable>
</DragDropContext>
)
);
}

View File

@@ -1,16 +1,18 @@
import useAdmin from "@/hook/useAdmin"
export function TeamReady() {
export default function TeamReady() {
const {teams} = useAdmin();
return <div className='w-full h-full gap-1 bg-white p-10 flex flex-col text-center shadow-2xl overflow-y-scroll'>
<h2 className="text-2xl">Teams ready status</h2>
{teams.map((team) => team.ready ? (
<div key={team.id} className="p-2 text-white bg-green-500 shadow-md text-xl rounded flex flex-row">
<div>{team.name} : Ready</div>
</div>) : (
<div key={team.id} className="p-2 text-white bg-red-500 shadow-md text-xl rounded flex flex-row">
<div>{team.name} : Not ready</div>
</div>
return (
<div className='w-full h-full gap-1 bg-white p-10 flex flex-col text-center shadow-2xl overflow-y-scroll'>
<h2 className="text-2xl">Teams ready status</h2>
{teams.map((team) => team.ready ? (
<div key={team.id} className="p-2 text-white bg-green-500 shadow-md text-xl rounded flex flex-row">
<div>{team.name} : Ready</div>
</div>) : (
<div key={team.id} className="p-2 text-white bg-red-500 shadow-md text-xl rounded flex flex-row">
<div>{team.name} : Not ready</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -1,66 +0,0 @@
import { useEffect, useState } from "react";
import BlueButton, { GreenButton, RedButton } from "../util/button";
import { EditMode, ZonePicker } from "./mapPicker";
import TextInput from "../util/textInput";
import useAdmin from "@/hook/useAdmin";
export function ZoneSelector() {
const [editMode, setEditMode] = useState(EditMode.MIN);
const [minZone, setMinZone] = useState(null);
const [maxZone, setMaxZone] = useState(null);
const [reductionCount, setReductionCount] = useState("");
const [reductionDuration, setReductionDuration] = useState("");
const [reductionInterval, setReductionInterval] = useState("");
const {zoneSettings, changeZoneSettings} = useAdmin();
useEffect(() => {
if (zoneSettings) {
setMinZone(zoneSettings.min);
setMaxZone(zoneSettings.max);
setReductionCount(zoneSettings.reductionCount.toString());
setReductionDuration(zoneSettings.reductionDuration.toString());
setReductionInterval(zoneSettings.reductionInterval.toString());
}
}, [zoneSettings]);
function handleSettingsSubmit() {
const newSettings = {min:minZone, max:maxZone, reductionCount: Number(reductionCount), reductionDuration: Number(reductionDuration), reductionInterval: Number(reductionInterval)};
const changingSettings = {};
for (const key in newSettings) {
if (newSettings[key] != zoneSettings[key]) {
changingSettings[key] = newSettings[key];
}
}
changeZoneSettings(changingSettings);
}
//When the user set one zone, switch to the other
useEffect(() => {
if(editMode == EditMode.MIN) {
setEditMode(EditMode.MAX);
}else {
setEditMode(EditMode.MIN);
}
}, [minZone, maxZone]);
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>
{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>}
<ZonePicker minZone={minZone} maxZone={maxZone} editMode={editMode} setMinZone={setMinZone} setMaxZone={setMaxZone} />
<div>
<p>Number of reductions</p>
<TextInput value={reductionCount} onChange={(e) => setReductionCount(e.target.value)}></TextInput>
</div>
<div>
<p>Duration of each reduction</p>
<TextInput value={reductionDuration} onChange={(e) => setReductionDuration(e.target.value)}></TextInput>
</div>
<div>
<p>Interval between reductions</p>
<TextInput value={reductionInterval} onChange={(e) => setReductionInterval(e.target.value)}></TextInput>
</div>
<GreenButton onClick={handleSettingsSubmit}>Apply</GreenButton>
</div>
}

View File

@@ -1,10 +1,9 @@
import useGame from "@/hook/useGame";
import { useEffect, useState } from "react"
import BlueButton, { GreenButton, RedButton } from "../util/button";
import TextInput from "../util/textInput";
import { useTeamConnexion } from "@/context/teamConnexionContext";
import { EnemyTeamModal } from "./enemyTeamModal";
import Image from "next/image";
import { BlueButton, GreenButton } from "../util/button";
import { TextInput } from "../util/textInput";
import useTeamConnexion from "@/context/teamConnexionContext";
import EnemyTeamModal from "./enemyTeamModal";
export default function ActionDrawer() {
const [visible, setVisible] = useState(false);

View File

@@ -1,11 +1,9 @@
import useGame from "@/hook/useGame";
import { RedButton } from "../util/button";
import { useEffect, useRef } from "react";
import Image from "next/image";
import { env } from 'next-runtime-env';
export function EnemyTeamModal({ visible, onClose }) {
export default function EnemyTeamModal({ visible, onClose }) {
const { teamId, enemyName } = useGame();
const imageRef = useRef(null);

View File

@@ -1,7 +1,6 @@
"use client";
import { useState } from "react";
import BlueButton from "../util/button";
import TextInput from "../util/textInput";
import { BlueButton } from "../util/button";
import { TextInput } from "../util/textInput";
export default function LoginForm({ onSubmit, title, placeholder, buttonText}) {
const [value, setValue] = useState("");

View File

@@ -1,15 +1,13 @@
'use client';
import React, { useEffect, useState } from 'react'
import { Circle, MapContainer, Marker, Popup, TileLayer, useMap } from 'react-leaflet'
import 'leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.css'
import "leaflet-defaulticon-compatibility";
import "leaflet/dist/leaflet.css";
import useGame from '@/hook/useGame';
import { useTeamContext } from '@/context/teamContext';
import useTeamContext from '@/context/teamContext';
const DEFAULT_ZOOM = 14;
// Pan to the center of the map when the position of the user is updated for the first time
function MapPan(props) {
const map = useMap();

View File

@@ -1,26 +1,29 @@
import { useSocketListener } from "@/hook/useSocketListener";
import { useEffect, useState } from "react";
export function Notification({ socket }) {
export default function Notification({ socket }) {
const [visible, setVisible] = useState(false);
const [timeoutId, setTimeoutId] = useState(null);
const [notification, setNotification] = useState(null);
useSocketListener(socket, "error", (notification) => {
console.log("error", notification);
setNotification({ type: "error", text: notification });
setVisible(true);
});
useSocketListener(socket, "success", (notification) => {
console.log("success", notification);
setNotification({ type: "success", text: notification });
setVisible(true);
});
useSocketListener(socket, "warning", (notification) => {
console.log("warning", notification);
setNotification({ type: "warning", text: notification });
setVisible(true);
});
// Hide the notification after 5 seconds
useEffect(() => {
console.log({ visible });
@@ -34,12 +37,14 @@ export function Notification({ socket }) {
}
}, [visible]);
let bgColorMap = {
const bgColorMap = {
error: "bg-red-500 text-white",
success: "bg-green-500",
warning: "bg-yellow-500"
}
const classNames = 'fixed relative w-11/12 p-5 z-30 mx-auto inset-x-0 flex justify-center rounded-xl transition-all shadow-xl ' + (visible ? "top-5 " : "-translate-y-full ");
return (
Object.keys(bgColorMap).map((key) =>
notification?.type == key &&
@@ -47,5 +52,5 @@ export function Notification({ socket }) {
<p className="absolute top-2 right-2 p-2 rounded-l text-3xl bg-white">x</p>
<p className='text-center text-xl'>{notification?.text}</p>
</div>
));
));
}

View File

@@ -1,6 +1,5 @@
import { useTeamConnexion } from "@/context/teamConnexionContext";
import useTeamConnexion from "@/context/teamConnexionContext";
import useGame from "@/hook/useGame"
import Image from "next/image";
export default function PlacementOverlay() {
const { name, ready } = useGame();

View File

@@ -1,12 +1,10 @@
import useGame from "@/hook/useGame"
import { GreenButton, LogoutButton } from "../util/button";
import { useRef } from "react";
import Image from "next/image";
import { useTeamContext } from "@/context/teamContext";
import useTeamContext from "@/context/teamContext";
import { env } from 'next-runtime-env';
export function WaitingScreen() {
export default function WaitingScreen() {
const { name, teamId } = useGame();
const { gameSettings } = useTeamContext();
const imageRef = useRef(null);

View File

@@ -1,7 +1,6 @@
import { useTeamConnexion } from "@/context/teamConnexionContext";
import Image from "next/image";
export default function BlueButton({ children, ...props }) {
export function BlueButton({ children, ...props }) {
return (<button {...props} className="bg-blue-600 hover:bg-blue-500 text-lg ease-out duration-200 text-white w-full h-full p-4 shadow-sm rounded">
{children}
</button>)
@@ -20,6 +19,6 @@ export function GreenButton({ children, ...props }) {
}
export function LogoutButton() {
const { logout } = useTeamConnexion();
return <img src="/icons/logout.png" onClick={logout} className='w-12 h-12 bg-red-500 p-2 top-1 right-1 rounded-lg cursor-pointer bg-red fixed z-20' />
const { logout } = useTeamConnexion();
return <img src="/icons/logout.png" onClick={logout} className='w-12 h-12 bg-red-500 p-2 top-1 right-1 rounded-lg cursor-pointer bg-red fixed z-20' />
}

View File

@@ -1,6 +1,6 @@
import React from 'react'
export default function TextInput({...props}) {
export function TextInput({...props}) {
return (
<input {...props} type="text" className="block w-full h-full p-4 rounded text-center ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600" />
)

View File

@@ -1,11 +1,12 @@
"use client";
import { createContext, useContext, useMemo, } from "react";
import { createContext, useContext, useMemo } from "react";
import { useSocket } from "./socketContext";
import { useSocketAuth } from "@/hook/useSocketAuth";
import { usePasswordProtect } from "@/hook/usePasswordProtect";
import useSocketAuth from "@/hook/useSocketAuth";
import usePasswordProtect from "@/hook/usePasswordProtect";
const adminConnexionContext = createContext();
const AdminConnexionProvider = ({ children }) => {
export function AdminConnexionProvider({ children }) {
const { adminSocket } = useSocket();
const { login, loggedIn, loading } = useSocketAuth(adminSocket, "admin_password");
const useProtect = () => usePasswordProtect("/admin/login", "/admin", loading, loggedIn);
@@ -19,9 +20,6 @@ const AdminConnexionProvider = ({ children }) => {
);
}
function useAdminConnexion() {
export function useAdminConnexion() {
return useContext(adminConnexionContext);
}
export { AdminConnexionProvider, useAdminConnexion };

View File

@@ -1,53 +1,43 @@
"use client";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import { useSocket } from "./socketContext";
import { useSocketListener } from "@/hook/useSocketListener";
import useSocketListener from "@/hook/useSocketListener";
import { useAdminConnexion } from "./adminConnexionContext";
import { GameState } from "@/util/gameState";
const adminContext = createContext();
function AdminProvider({ children }) {
export function AdminProvider({ children }) {
const [teams, setTeams] = useState([]);
const [zoneSettings, setZoneSettings] = useState(null)
const [penaltySettings, setPenaltySettings] = useState(null);
const [gameSettings, setGameSettings] = useState(null);
const [zone, setZone] = useState(null);
const [zoneExtremities, setZoneExtremities] = useState(null);
const [nextZoneDate, setNextZoneDate] = useState(null);
const [isShrinking, setIsShrinking] = useState(false);
const { adminSocket } = useSocket();
const { loggedIn } = useAdminConnexion();
const [gameState, setGameState] = useState(GameState.SETUP);
const [startDate, setStartDate] = useState(null);
useSocketListener(adminSocket, "game_state", (data) => {setGameState(data.state); setStartDate(data.startDate)});
//Send a request to get the teams when the user logs in
// Send a request to get the teams when the user logs in
useEffect(() => {
adminSocket.emit("get_teams");
}, [loggedIn]);
function waiting(data) {
setIsShrinking(false);
function setCurrent_zone(data) {
setZoneExtremities({begin: data.begin, end: data.end});
setNextZoneDate(data.endDate);
}
function shrinking(data) {
setIsShrinking(true);
setNextZoneDate(data);
}
//Bind listeners to update the team list and the game status on socket message
// Bind listeners to update the team list and the game status on socket message
useSocketListener(adminSocket, "teams", setTeams);
useSocketListener(adminSocket, "zone_settings", setZoneSettings);
useSocketListener(adminSocket, "game_settings", setGameSettings);
useSocketListener(adminSocket, "penalty_settings", setPenaltySettings);
useSocketListener(adminSocket, "zone", setZone);
useSocketListener(adminSocket, "zone_start", shrinking);
useSocketListener(adminSocket, "new_zone", waiting);
useSocketListener(adminSocket, "current_zone", setCurrent_zone);
const value = useMemo(() => ({ zone, zoneExtremities, teams, zoneSettings, penaltySettings, gameSettings, gameState, nextZoneDate, isShrinking, startDate }), [zoneSettings, teams, gameState, zone, zoneExtremities, penaltySettings, gameSettings, nextZoneDate, isShrinking, startDate]);
const value = useMemo(() => ({ zoneExtremities, teams, zoneSettings, penaltySettings, gameSettings, gameState, nextZoneDate, startDate }), [zoneSettings, teams, gameState, zoneExtremities, penaltySettings, gameSettings, nextZoneDate, startDate]);
return (
<adminContext.Provider value={value}>
{children}
@@ -55,8 +45,6 @@ function AdminProvider({ children }) {
);
}
function useAdminContext() {
export function useAdminContext() {
return useContext(adminContext);
}
export { AdminProvider, useAdminContext };

View File

@@ -1,22 +1,17 @@
"use client";
import { createContext, useContext, useMemo } from "react";
import { env } from 'next-runtime-env';
import { io } from 'socket.io-client';
const { io } = require("socket.io-client");
var proto = "wss://";
const NEXT_PUBLIC_SOCKET_HOST = env("NEXT_PUBLIC_SOCKET_HOST");
if (NEXT_PUBLIC_SOCKET_HOST == "localhost") {
proto = "ws://";
}
const SOCKET_URL = proto + NEXT_PUBLIC_SOCKET_HOST;
const SOCKET_URL = (NEXT_PUBLIC_SOCKET_HOST == "localhost" ? "ws://" : "wss://") + NEXT_PUBLIC_SOCKET_HOST;
const USER_SOCKET_URL = SOCKET_URL + "/player";
const ADMIN_SOCKET_URL = SOCKET_URL + "/admin";
export const teamSocket = io(USER_SOCKET_URL, {
path: "/back/socket.io",
});
export const adminSocket = io(ADMIN_SOCKET_URL, {
path: "/back/socket.io",
});

View File

@@ -1,13 +1,14 @@
"use client";
import { createContext, useContext, useMemo } from "react";
import { useSocket } from "./socketContext";
import { useSocketAuth } from "@/hook/useSocketAuth";
import { usePasswordProtect } from "@/hook/usePasswordProtect";
import useSocketAuth from "@/hook/useSocketAuth";
import usePasswordProtect from "@/hook/usePasswordProtect";
const teamConnexionContext = createContext();
const TeamConnexionProvider = ({ children }) => {
export function TeamConnexionProvider({ children }) {
const { teamSocket } = useSocket();
const { login, password: teamId, loggedIn, loading, logout } = useSocketAuth(teamSocket, "team_password");
const { login, password: teamId, loggedIn, loading, logout } = useSocketAuth(teamSocket, "team_password");
const useProtect = () => usePasswordProtect("/team", "/team/track", loading, loggedIn);
const value = useMemo(() => ({ teamId, login, logout, loggedIn, loading, useProtect}), [teamId, login, loggedIn, loading]);
@@ -19,9 +20,6 @@ const TeamConnexionProvider = ({ children }) => {
);
}
function useTeamConnexion() {
export function useTeamConnexion() {
return useContext(teamConnexionContext);
}
export { TeamConnexionProvider, useTeamConnexion };

View File

@@ -1,14 +1,14 @@
"use client";
import { useLocation } from "@/hook/useLocation";
import { useSocketListener } from "@/hook/useSocketListener";
import { createContext, use, useContext, useEffect, useMemo, useRef, useState } from "react";
import useLocation from "@/hook/useLocation";
import useSocketListener from "@/hook/useSocketListener";
import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
import { useSocket } from "./socketContext";
import { useTeamConnexion } from "./teamConnexionContext";
import { GameState } from "@/util/gameState";
const teamContext = createContext();
const teamContext = createContext()
function TeamProvider({children}) {
export function TeamProvider({children}) {
const [teamInfos, setTeamInfos] = useState({});
const [gameState, setGameState] = useState(GameState.SETUP);
const [gameSettings, setGameSettings] = useState(null);
@@ -21,17 +21,12 @@ function TeamProvider({children}) {
teamInfosRef.current = teamInfos;
useSocketListener(teamSocket, "update_team", (newTeamInfos) => {
setTeamInfos({...teamInfosRef.current, ...newTeamInfos});
});
useSocketListener(teamSocket, "update_team", (newTeamInfos) => setTeamInfos({...teamInfosRef.current, ...newTeamInfos}) );
useSocketListener(teamSocket, "game_state", setGameState);
useSocketListener(teamSocket, "zone", setZone);
useSocketListener(teamSocket, "new_zone", setZoneExtremities);
useSocketListener(teamSocket, "game_settings", setGameSettings);
//Send the current position to the server when the user is logged in
useEffect(() => {
console.log("sending position", measuredLocation);
@@ -48,8 +43,6 @@ function TeamProvider({children}) {
);
}
function useTeamContext() {
export function useTeamContext() {
return useContext(teamContext);
}
export { TeamProvider, useTeamContext };

View File

@@ -1,3 +1,4 @@
"use client";
import { useAdminContext } from "@/context/adminContext";
import { useSocket } from "@/context/socketContext";

View File

@@ -1,5 +1,4 @@
"use client";
import { useSocket } from "@/context/socketContext";
import { useTeamConnexion } from "@/context/teamConnexionContext";
import { useTeamContext } from "@/context/teamContext";

View File

@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
export function useLocalStorage(key, initialValue) {
export default function useLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = useState(initialValue);
const [loading, setLoading] = useState(true);

View File

@@ -5,11 +5,10 @@ import { useEffect, useState } from "react";
* A hook that returns the location of the user and updates it periodically
* @returns {Object} The location of the user
*/
export function useLocation(interval) {
export default function useLocation(interval) {
const [location, setLocation] = useState();
useEffect(() => {
function update() {
console.log('Updating location');
navigator.geolocation.getCurrentPosition((position) => {
setLocation([position.coords.latitude, position.coords.longitude]);
if(interval != Infinity) {

View File

@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
export function useMapCircleDraw(area, setArea) {
export default function useMapCircleDraw(area, setArea) {
const [drawing, setDrawing] = useState(false);
const [center, setCenter] = useState(area?.center || null);
const [radius, setRadius] = useState(area?.radius || null);
@@ -12,7 +13,7 @@ export function useMapCircleDraw(area, setArea) {
}, [area])
function handleClick(e) {
if(!drawing) {
if (!drawing) {
setCenter(e.latlng);
setRadius(null);
setDrawing(true);
@@ -23,14 +24,10 @@ export function useMapCircleDraw(area, setArea) {
}
function handleMouseMove(e) {
if(drawing) {
if (drawing) {
setRadius(e.latlng.distanceTo(center));
}
}
return {
handleClick,
handleMouseMove,
center,
radius,
}
return { handleClick, handleMouseMove, center, radius };
}

View File

@@ -0,0 +1,214 @@
"use client";
import { useState } from "react";
import { useMap } from "react-leaflet";
export default function useMapPolygonDraw(polygons, addPolygon, removePolygon) {
const map = useMap();
const nodeCatchDistance = 30; // px
const nodeHighlightDistance = 30; // px
const [currentPolygon, setCurrentPolygon] = useState([]);
const [highlightNodes, setHighlightNodes] = useState([]);
function latlngEqual(latlng1, latlng2, epsilon = 1e-9) {
return Math.abs(latlng1.lat - latlng2.lat) < epsilon && Math.abs(latlng1.lng - latlng2.lng) < epsilon;
}
function layerDistance(latlng1, latlng2) {
// Return the pixel distance between latlng1 and latlng2 as they appear on the map
const {x: x1, y: y1} = map.latLngToLayerPoint(latlng1);
const {x: x2, y: y2} = map.latLngToLayerPoint(latlng2);
return Math.sqrt((x1 - x2)**2 + (y1 - y2)**2);
}
function isDrawing() {
return currentPolygon.length > 0;
}
function areSegmentsIntersecting(p1, p2, p3, p4) {
// Return true if the segments (p1, p2) and (p3, p4) are strictly intersecting, else false
const direction = (a, b, c) => {
return (c.lng - a.lng) * (b.lat - a.lat) - (b.lng - a.lng) * (c.lat - a.lat);
};
const d1 = direction(p3, p4, p1);
const d2 = direction(p3, p4, p2);
const d3 = direction(p1, p2, p3);
const d4 = direction(p1, p2, p4);
return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0));
}
function isIntersecting(segment, pointArray, isPolygon) {
// Return true if segment intersects one of the pointArray segments according to areSegmentsIntersecting
// Moreover if isPolygon, then it verifies if segment intersects the segment closing pointArray
const length = pointArray.length;
for (let i = 0; i < length-1; i++) {
if (areSegmentsIntersecting(segment[0], segment[1], pointArray[i], pointArray[i+1])) {
return true;
}
}
if (isPolygon && length > 2) {
return areSegmentsIntersecting(segment[0], segment[1], pointArray[length-1], pointArray[0]);
} else {
return false;
}
}
function isInPolygon(latlng, polygon) {
// Return true if latlng is strictly inside polygon
// Return false if latlng is outside polygon or on a vertex of the polygon
// Return true or false if latlng is on the border
if (latlngEqual(latlng, polygon[0])) return false;
const length = polygon.length;
const {lat: x, lng: y} = latlng;
let inside = false;
for (let i = 0, j = length - 1; i < length; j = i++) {
if (latlngEqual(latlng, polygon[j])) return false;
const {lat: xi, lng: yi} = polygon[i];
const {lat: xj, lng: yj} = polygon[j];
const intersects = ((yi > y) !== (yj > y)) && (x < ((xj - xi) * (y - yi)) / (yj - yi) + xi);
if (intersects) inside = !inside;
}
return inside;
}
function isClockwise(points) {
// Return true if the tab describes a clockwise polygon (Shoelace formula)
let sum = 0;
for (let i = 0; i < points.length; i++) {
const curr = points[i];
const next = points[(i + 1) % points.length];
sum += (next.lng - curr.lng) * (next.lat + curr.lat);
}
return sum > 0;
};
function getZoneIndex(latlng) {
// Return the index of the polygon where latlng is according to isInPolygon
for (let iPolygon = 0; iPolygon < polygons.length; iPolygon++) {
if (isInPolygon(latlng, polygons[iPolygon])) {
return iPolygon;
}
}
return -1;
}
function getEventLatLng(e) {
// Return the closest latlng to e.latlng among the existing nodes including the first node of currentPolygon
// If the closest distance is superior to nodeCatchDistance, then e.latlng is returned
const closeNodes = [];
// Existing nodes
for (const polygon of polygons) {
for (const node of polygon) {
const d = layerDistance(e.latlng, node);
if (d < nodeCatchDistance) {
closeNodes.push([d, node]);
}
}
}
// First node of currentPolygon
if (isDrawing()) {
const d = layerDistance(e.latlng, currentPolygon[0]);
if (d < nodeCatchDistance) {
closeNodes.push([d, currentPolygon[0]]);
}
}
// If there is no close node
if (closeNodes.length == 0) {
return e.latlng;
// Else return the closest close node
} else {
return closeNodes.reduce( (min, current) => { return current[0] < min[0] ? current : min } )[1];
}
}
function handleLeftClick(e) {
setHighlightNodes([]);
const latlng = getEventLatLng(e);
const length = currentPolygon.length;
// If it is the first node
if (!isDrawing()) {
// If the point is not in an existing polygon
if (getZoneIndex(latlng) == -1) {
setCurrentPolygon([latlng]);
}
// If it is the last node
} else if (latlngEqual(latlng, currentPolygon[0])) {
// If the current polygon is a polygon (at least 3 points)
if (length >= 3) {
// If the current polygon is not circling an existing polygon
for (const polygon of polygons) {
// meanPoint exists and is strictly inside polygon
const meanPoint = {
lat: (polygon[0].lat + polygon[1].lat + polygon[2].lat) / 3,
lng: (polygon[0].lng + polygon[1].lng + polygon[2].lng) / 3
};
if (isInPolygon(meanPoint, currentPolygon)) return;
}
// Making the new polygon clockwise to simplify some algorithms
if (!isClockwise(currentPolygon)) currentPolygon.reverse();
addPolygon(currentPolygon);
setCurrentPolygon([]);
}
// If it is an intermediate node
} else {
// Is the polygon closing to early ?
for (const point of currentPolygon) if (latlngEqual(point, latlng)) return;
// Is the new point making the current polygon intersect with itself ?
if (isIntersecting([latlng, currentPolygon[length-1]], currentPolygon, false)) return;
// Is the new point inside a polygon ?
if (getZoneIndex(latlng) != -1) return;
// Is the new point making the current polygon intersect with another polygon ?
for (const polygon of polygons) {
// Strict intersection
if (isIntersecting([latlng, currentPolygon[length-1]], polygon, true)) return;
// Intersection by joining two non adjacent nodes of polygon
let tab = [-1, -1];
for (let i = 0; i < polygon.length; i++) {
if (latlngEqual(latlng, polygon[i])) tab[0] = i;
if (latlngEqual(currentPolygon[length-1], polygon[i])) tab[1] = i;
}
if (
tab[0] != -1 && tab[1] != -1 &&
(tab[0] != (tab[1] + 1) % polygon.length) &&
(tab[1] != (tab[0] + 1) % polygon.length)
) return;
}
setCurrentPolygon([...currentPolygon, latlng]);
}
}
function handleRightClick(e) {
setHighlightNodes([]);
// If isDrawing, cancel the currentPolygon
if (isDrawing()) {
setCurrentPolygon([]);
// If not isDrawing, remove the clicked polygon
} else {
const i = getZoneIndex(e.latlng);
if (i != -1) removePolygon(i);
}
}
function handleMouseMove(e) {
const nodes = [];
for (const polygon of polygons) {
for (const node of polygon) {
if (layerDistance(node, e.latlng) < nodeHighlightDistance && node != currentPolygon[0]) nodes.push(node);
}
}
setHighlightNodes(nodes);
}
return { currentPolygon, highlightNodes, handleLeftClick, handleRightClick, handleMouseMove };
}

View File

@@ -2,7 +2,7 @@
import { redirect, usePathname } from "next/navigation";
import { useEffect } from "react";
export function usePasswordProtect(loginPath, redirectPath, loading, loggedIn) {
export default function usePasswordProtect(loginPath, redirectPath, loading, loggedIn) {
const path = usePathname();
useEffect(() => {
if (!loggedIn && !loading && path !== loginPath) {

View File

@@ -1,13 +1,13 @@
import {useEffect, useState} from 'react';
import { useSocketListener } from './useSocketListener';
import { useLocalStorage } from './useLocalStorage';
import { usePathname } from 'next/navigation';
"use client";
import { useEffect, useState } from 'react';
import useSocketListener from './useSocketListener';
import useLocalStorage from './useLocalStorage';
const LOGIN_MESSAGE = "login";
const LOGOUT_MESSAGE = "logout";
const LOGIN_RESPONSE_MESSAGE = "login_response";
export function useSocketAuth(socket, passwordName) {
export default function useSocketAuth(socket, passwordName) {
const [loggedIn, setLoggedIn] = useState(false);
const [loading, setLoading] = useState(true);
const [waitingForResponse, setWaitingForResponse] = useState(true);

View File

@@ -1,6 +1,7 @@
import { useEffect} from "react";
"use client";
import { useEffect } from "react";
export function useSocketListener(socket, event, callback) {
export default function useSocketListener(socket, event, callback) {
useEffect(() => {
socket.on(event,callback);
return () => {