From 538b4b3bf6f070f38d11cba20e2af3b93c8daf1c Mon Sep 17 00:00:00 2001 From: Quentin Roussel Date: Thu, 6 Jun 2024 11:04:55 +0200 Subject: [PATCH 1/3] implemented a map zone selector based on squares --- traque-front/components/admin/mapPicker.jsx | 45 +++++----- .../components/admin/mapZoneSelector.jsx | 29 +++++++ .../components/admin/zoneSelector.jsx | 48 +---------- traque-front/components/util/map.jsx | 20 +++++ traque-front/hook/mapDrawing.jsx | 82 ++++++++++++++++++- 5 files changed, 149 insertions(+), 75 deletions(-) create mode 100644 traque-front/components/admin/mapZoneSelector.jsx create mode 100644 traque-front/components/util/map.jsx diff --git a/traque-front/components/admin/mapPicker.jsx b/traque-front/components/admin/mapPicker.jsx index e511c3b..bcb9bb4 100644 --- a/traque-front/components/admin/mapPicker.jsx +++ b/traque-front/components/admin/mapPicker.jsx @@ -2,9 +2,12 @@ import { useLocation } from "@/hook/useLocation"; import { useEffect, useState } from "react"; import "leaflet/dist/leaflet.css"; -import { Circle, MapContainer, Marker, Popup, TileLayer, useMap } from "react-leaflet"; +import L from "leaflet"; +import { Circle, LayersControl, MapContainer, Marker, Popup, TileLayer, useMap} from "react-leaflet"; import { useMapCircleDraw } from "@/hook/mapDrawing"; import useAdmin from "@/hook/useAdmin"; +import { MapGridZoneSelector } from "./mapZoneSelector.jsx"; + function MapPan(props) { const map = useMap(); @@ -36,7 +39,7 @@ function MapEventListener({ onClick, onMouseMove }) { return null; } -const DEFAULT_ZOOM = 17; +const DEFAULT_ZOOM = 13; export function CircularAreaPicker({ area, setArea, markerPosition, ...props }) { const location = useLocation(Infinity); const { handleClick, handleMouseMove, center, radius } = useMapCircleDraw(area, setArea); @@ -58,38 +61,28 @@ export function CircularAreaPicker({ area, setArea, markerPosition, ...props }) ) } -export const EditMode = { - MIN: 0, - MAX: 1 -} -export function ZonePicker({ minZone, setMinZone, maxZone, setMaxZone, editMode, ...props }) { + +//https://stackoverflow.com/questions/71231865/show-fixed-100-m-x-100-m-grid-on-lowest-zoom-level + +export function ZonePicker({ ...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); - } - } + const [coloredTiles, setColoredTiles] = useState([]); + + useEffect(() => { + console.log(coloredTiles) + }, [coloredTiles]); return ( - {minCenter && minRadius && } - {maxCenter && maxRadius && } - + + + + + ) } diff --git a/traque-front/components/admin/mapZoneSelector.jsx b/traque-front/components/admin/mapZoneSelector.jsx new file mode 100644 index 0000000..ea3f57a --- /dev/null +++ b/traque-front/components/admin/mapZoneSelector.jsx @@ -0,0 +1,29 @@ +import { useEffect, useState } from 'react'; +import { useLeafletContext } from '@react-leaflet/core'; +import { useMapGrid } from '@/hook/mapDrawing'; +import { latLngToTileNumber } from '../util/map'; + +export function MapGridZoneSelector({ onSelectedTile, tileSize }) { + const [coloredTiles, setColoredTiles] = useState([]); + const { map } = useLeafletContext(); + + useEffect(() => { + map.on('click', (e) => { + const fractionalTileNumber = latLngToTileNumber(e.latlng, tileSize); + const tileNumber = new TileNumber(Math.floor(fractionalTileNumber.x), Math.floor(fractionalTileNumber.y)); + if (coloredTiles.some(t => t.equals(tileNumber))) { + setColoredTiles(coloredTiles.filter(t => !t.equals(tileNumber))); + } else { + setColoredTiles([...coloredTiles, tileNumber]); + } + }); + }); + + useEffect(() => { + onSelectedTile(coloredTiles); + }, [coloredTiles]); + + useMapGrid(coloredTiles, tileSize); + + return null; +} \ No newline at end of file diff --git a/traque-front/components/admin/zoneSelector.jsx b/traque-front/components/admin/zoneSelector.jsx index 2ef1781..1624892 100644 --- a/traque-front/components/admin/zoneSelector.jsx +++ b/traque-front/components/admin/zoneSelector.jsx @@ -5,57 +5,13 @@ 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() { - changeZoneSettings({min:minZone, max:maxZone, reductionCount: Number(reductionCount), reductionDuration: Number(reductionDuration), reductionInterval: Number(reductionInterval)}); - } //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

Edit zones

- {editMode == EditMode.MIN && setEditMode(EditMode.MAX)}>Edit end zone} - {editMode == EditMode.MAX && setEditMode(EditMode.MIN)}>Edit start zone}
- +
-
-

Number of reductions

- setReductionCount(e.target.value)}> -
-
-

Duration of each reduction

- setReductionDuration(e.target.value)}> -
-
-

Interval between reductions

- setReductionInterval(e.target.value)}> -
- Apply + Apply
} \ No newline at end of file diff --git a/traque-front/components/util/map.jsx b/traque-front/components/util/map.jsx new file mode 100644 index 0000000..39ca395 --- /dev/null +++ b/traque-front/components/util/map.jsx @@ -0,0 +1,20 @@ +export class TileNumber { + constructor(x, y) { + this.x = x; + this.y = y; + } + equals(other) { + return this.x === other.x && this.y === other.y; + } +} + +export function latLngToTileNumber(latLng, tileSize) { + const numTilesX = 2 ** tileSize; + const numTilesY = 2 ** tileSize; + const lngDegrees = latLng.lng; + const latRadians = latLng.lat * (Math.PI / 180); + return new L.Point( + numTilesX * ((lngDegrees + 180) / 360), + numTilesY * (1 - Math.log(Math.tan(latRadians) + 1 / Math.cos(latRadians)) / Math.PI) / 2 + ); +} \ No newline at end of file diff --git a/traque-front/hook/mapDrawing.jsx b/traque-front/hook/mapDrawing.jsx index 804a99a..0794849 100644 --- a/traque-front/hook/mapDrawing.jsx +++ b/traque-front/hook/mapDrawing.jsx @@ -1,4 +1,7 @@ +import { TileNumber, latLngToTileNumber } from "@/components/util/map"; import { useEffect, useState } from "react"; +import { useLeafletContext } from '@react-leaflet/core'; +import L from 'leaflet'; export function useMapCircleDraw(area, setArea) { const [drawing, setDrawing] = useState(false); @@ -12,18 +15,18 @@ export function useMapCircleDraw(area, setArea) { }, [area]) function handleClick(e) { - if(!drawing) { + if (!drawing) { setCenter(e.latlng); setRadius(null); setDrawing(true); } else { setDrawing(false); - setArea({center, radius}); + setArea({ center, radius }); } } function handleMouseMove(e) { - if(drawing) { + if (drawing) { setRadius(e.latlng.distanceTo(center)); } } @@ -33,4 +36,77 @@ export function useMapCircleDraw(area, setArea) { center, radius, } +} + +export function useMapGrid(coloredTiles, tileSize) { + const { layerContainer, map } = useLeafletContext(); + const [grid, setGrid] = useState(null); + + const Grid = L.GridLayer.extend({ + createTile: function (coords) { + const tile = L.DomUtil.create('canvas', 'leaflet-tile'); + const ctx = tile.getContext('2d'); + const size = this.getTileSize(); + tile.width = size.x + tile.height = size.y + + // calculate projection coordinates of top left tile pixel + const nwPoint = coords.scaleBy(size); + // calculate geographic coordinates of top left tile pixel + const nw = map.unproject(nwPoint, coords.z); + // calculate fraction tile number at top left point + const nwTile = latLngToTileNumber(nw, tileSize) + + // calculate projection coordinates of bottom right tile pixel + const sePoint = new L.Point(nwPoint.x + size.x - 1, nwPoint.y + size.y - 1) + // calculate geographic coordinates of bottom right tile pixel + const se = map.unproject(sePoint, coords.z); + // calculate fractional tile number at bottom right point + const seTile = latLngToTileNumber(se, tileSize) + + const minTileX = nwTile.x + const maxTileX = seTile.x + const minTileY = nwTile.y + const maxTileY = seTile.y + + for (let x = Math.ceil(minTileX) - 1; x <= Math.floor(maxTileX) + 1; x++) { + for (let y = Math.ceil(minTileY) - 1; y <= Math.floor(maxTileY) + 1; y++) { + + let tile = new TileNumber(x, y) + + const xMinPixel = Math.round(size.x * (x - minTileX) / (maxTileX - minTileX)); + const xMaxPixel = Math.round(size.x * (x + 1 - minTileX) / (maxTileX - minTileX)); + const yMinPixel = Math.round(size.y * (y - minTileY) / (maxTileY - minTileY)); + const yMaxPixel = Math.round(size.y * (y + 1 - minTileY) / (maxTileY - minTileY)); + + // fill the rectangle with a color + // if(coloredTiles.some(t => t.equals(tile))) { + // console.log(coloredTiles, "filling tile " + tile.x + " " + tile.y) + // } else { + // console.log(coloredTiles, "not filling tile " + tile.x + " " + tile.y) + // } + ctx.fillStyle = this.coloredTiles && this.coloredTiles.some(t => t.equals(tile)) + ? 'rgba(0, 0, 255, 0.3)' + : 'rgba(255, 255, 255, 0)'; + ctx.fillRect(xMinPixel, yMinPixel, xMaxPixel - xMinPixel, yMaxPixel - yMinPixel); + } + } + return tile; + }, + }); + + useEffect(() => { + let g = new Grid({ + minZoom: 12, + }); + setGrid(g); + layerContainer.addLayer(g); + }, []) + + useEffect(() => { + if (grid) { + grid.coloredTiles = coloredTiles; + grid.redraw(); + } + }, [coloredTiles]); } \ No newline at end of file From ba846acc0c0b4c09ae35a94c1083ef3926d63ef4 Mon Sep 17 00:00:00 2001 From: Quentin Roussel Date: Thu, 6 Jun 2024 23:55:36 +0200 Subject: [PATCH 2/3] MVP systeme de zone quadrillage --- traque-front/app/admin/page.js | 2 +- traque-front/components/admin/mapPicker.jsx | 118 ------------ .../components/admin/mapZoneSelector.jsx | 28 +-- traque-front/components/admin/maps.jsx | 180 ++++++++++++++++++ traque-front/components/admin/teamEdit.jsx | 2 +- .../components/admin/zoneSelector.jsx | 9 +- traque-front/components/team/actionDrawer.jsx | 1 - traque-front/components/team/map.jsx | 35 ++-- .../components/team/placementOverlay.jsx | 1 - traque-front/context/adminContext.jsx | 9 +- traque-front/context/teamContext.jsx | 4 +- traque-front/hook/mapDrawing.jsx | 38 ++-- traque-front/hook/useAdmin.jsx | 10 +- 13 files changed, 242 insertions(+), 195 deletions(-) delete mode 100644 traque-front/components/admin/mapPicker.jsx create mode 100644 traque-front/components/admin/maps.jsx diff --git a/traque-front/app/admin/page.js b/traque-front/app/admin/page.js index af4a7d7..1159612 100644 --- a/traque-front/app/admin/page.js +++ b/traque-front/app/admin/page.js @@ -11,7 +11,7 @@ import dynamic from "next/dynamic"; const ZoneSelector = dynamic(() => import('@/components/admin/zoneSelector').then((mod) => mod.ZoneSelector), { ssr: false }); -const LiveMap = dynamic(() => import('@/components/admin/mapPicker').then((mod) => mod.LiveMap), { +const LiveMap = dynamic(() => import('@/components/admin/maps').then((mod) => mod.ZoneEditor), { ssr: false }); export default function AdminPage() { diff --git a/traque-front/components/admin/mapPicker.jsx b/traque-front/components/admin/mapPicker.jsx deleted file mode 100644 index bcb9bb4..0000000 --- a/traque-front/components/admin/mapPicker.jsx +++ /dev/null @@ -1,118 +0,0 @@ -"use client"; -import { useLocation } from "@/hook/useLocation"; -import { useEffect, useState } from "react"; -import "leaflet/dist/leaflet.css"; -import L from "leaflet"; -import { Circle, LayersControl, MapContainer, Marker, Popup, TileLayer, useMap} from "react-leaflet"; -import { useMapCircleDraw } from "@/hook/mapDrawing"; -import useAdmin from "@/hook/useAdmin"; -import { MapGridZoneSelector } from "./mapZoneSelector.jsx"; - - -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 = 13; -export function CircularAreaPicker({ area, setArea, markerPosition, ...props }) { - const location = useLocation(Infinity); - const { handleClick, handleMouseMove, center, radius } = useMapCircleDraw(area, setArea); - return ( - - - {center && radius && } - {markerPosition && } - - - ) -} - -//https://stackoverflow.com/questions/71231865/show-fixed-100-m-x-100-m-grid-on-lowest-zoom-level - -export function ZonePicker({ ...props }) { - const location = useLocation(Infinity); - const [coloredTiles, setColoredTiles] = useState([]); - - useEffect(() => { - console.log(coloredTiles) - }, [coloredTiles]); - return ( - - - - - - - - - - ) -} - -export function LiveMap() { - const location = useLocation(Infinity); - const { zone, zoneExtremities, teams, getTeamName } = useAdmin(); - return ( - - - - {zone && } - {zoneExtremities && } - {zoneExtremities && } - {teams.map((team) => team.currentLocation && !team.captured && - - {team.name} -

Chasing : {getTeamName(team.chasing)}

-

Chased by : {getTeamName(team.chased)}

-
-
)} -
- ) -} \ No newline at end of file diff --git a/traque-front/components/admin/mapZoneSelector.jsx b/traque-front/components/admin/mapZoneSelector.jsx index ea3f57a..cf16fde 100644 --- a/traque-front/components/admin/mapZoneSelector.jsx +++ b/traque-front/components/admin/mapZoneSelector.jsx @@ -1,29 +1,15 @@ -import { useEffect, useState } from 'react'; -import { useLeafletContext } from '@react-leaflet/core'; import { useMapGrid } from '@/hook/mapDrawing'; -import { latLngToTileNumber } from '../util/map'; +import { TileNumber, latLngToTileNumber } from '../util/map'; +import { useMapEvent } from 'react-leaflet'; -export function MapGridZoneSelector({ onSelectedTile, tileSize }) { - const [coloredTiles, setColoredTiles] = useState([]); - const { map } = useLeafletContext(); - - useEffect(() => { - map.on('click', (e) => { +export function MapGridZoneSelector({ tilesColor, onClickTile, tileSize }) { + useMapEvent('click', (e) => { const fractionalTileNumber = latLngToTileNumber(e.latlng, tileSize); const tileNumber = new TileNumber(Math.floor(fractionalTileNumber.x), Math.floor(fractionalTileNumber.y)); - if (coloredTiles.some(t => t.equals(tileNumber))) { - setColoredTiles(coloredTiles.filter(t => !t.equals(tileNumber))); - } else { - setColoredTiles([...coloredTiles, tileNumber]); - } - }); + onClickTile(tileNumber); }); - useEffect(() => { - onSelectedTile(coloredTiles); - }, [coloredTiles]); - - useMapGrid(coloredTiles, tileSize); + useMapGrid(tilesColor, tileSize); return null; -} \ No newline at end of file +} diff --git a/traque-front/components/admin/maps.jsx b/traque-front/components/admin/maps.jsx new file mode 100644 index 0000000..7135b19 --- /dev/null +++ b/traque-front/components/admin/maps.jsx @@ -0,0 +1,180 @@ +"use client"; +import { useLocation } from "@/hook/useLocation"; +import { use, useEffect, useState } from "react"; +import "leaflet/dist/leaflet.css"; +import L from "leaflet"; +import { Circle, LayerGroup, LayersControl, MapContainer, Marker, Popup, TileLayer, useMap } from "react-leaflet"; +import { useMapCircleDraw, useTilesColor } from "@/hook/mapDrawing"; +import useAdmin from "@/hook/useAdmin"; +import { MapGridZoneSelector } from "./mapZoneSelector.jsx"; +import { useAdminContext } from "@/context/adminContext.jsx"; +import { GreenButton } from "../util/button.jsx"; +import TextInput from "../util/textInput.jsx"; + + +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 = 13; +export function CircularAreaPicker({ area, setArea, markerPosition, ...props }) { + const location = useLocation(Infinity); + const { handleClick, handleMouseMove, center, radius } = useMapCircleDraw(area, setArea); + return ( + + + {center && radius && } + {markerPosition && } + + + ) +} + +//https://stackoverflow.com/questions/71231865/show-fixed-100-m-x-100-m-grid-on-lowest-zoom-level + +export function ZoneInitializer({ ...props }) { + const location = useLocation(Infinity); + const { zone } = useAdminContext(); + const { initZone } = useAdmin(); + + const tilesColor = useTilesColor(zone); + + const handleClickTile = (tile) => { + if (zone) { + if (zone.some(t => t.x === tile.x && t.y === tile.y)) { + initZone(zone.filter(t => t.x !== tile.x || t.y !== tile.y)); + } else { + initZone([...zone, tile]); + } + } + } + + return ( + + + + + + + + + + + + ) +} + +export function ZoneEditor() { + const location = useLocation(Infinity); + const { zone, teams, getTeamName, removeZone } = useAdmin(); + const [zonesToDelete, setZonesToDelete] = useState([]); + const [tilesColor, setTilesColor] = useState([]); + const [timeBeforeDeletion, setTimeBeforeDeletion] = useState(0); + + function handleClickTile(tile) { + if (!zone.some(t => t.x === tile.x && t.y === tile.y)) return; + console.log(tile, "click", zonesToDelete); + if (!zonesToDelete.some(t => t.x === tile.x && t.y === tile.y)) { + setZonesToDelete([...zonesToDelete, tile]); + console.log("delete", tile); + } else { + setZonesToDelete(zonesToDelete.filter(t => t.x !== tile.x || t.y !== tile.y)); + } + } + + useEffect(() => { + console.log(zone); + setTilesColor([ + ...zonesToDelete.map(t => ({ x: t.x, y: t.y, color: 'rgba(255, 0, 0, 0.5)' })), + ...zone + .filter(t => !zonesToDelete.some(t2 => t.x == t2.x && t.y == t2.y)) + .map(t => { + if (t.removeDate == null) { + return { x: t.x, y: t.y, color: 'rgba(0, 0, 255, 0.5)' } + } else { + return { x: t.x, y: t.y, color: 'rgba(255, 255, 0, 0.5)' } + } + }), + ]); + }, [zone, zonesToDelete]); + + const handleSubmit = (e) => { + e.preventDefault(); + removeZone(zonesToDelete, timeBeforeDeletion); + setZonesToDelete([]); + setTimeBeforeDeletion(0); + } + + + return ( +
+ + + + {teams.map((team) => team.currentLocation && !team.captured && + + {team.name} +

Chasing : {getTeamName(team.chasing)}

+

Chased by : {getTeamName(team.chased)}

+
+
)} + + + + + + + +
+ setTimeBeforeDeletion(Number(e.target.value))}> + Delete selected zones +
+ ) +} \ No newline at end of file diff --git a/traque-front/components/admin/teamEdit.jsx b/traque-front/components/admin/teamEdit.jsx index e8f845a..bb8ea53 100644 --- a/traque-front/components/admin/teamEdit.jsx +++ b/traque-front/components/admin/teamEdit.jsx @@ -4,7 +4,7 @@ import BlueButton, { RedButton } from '../util/button'; import useAdmin from '@/hook/useAdmin'; import dynamic from 'next/dynamic'; -const CircularAreaPicker = dynamic(() => import('./mapPicker').then((mod) => mod.CircularAreaPicker), { +const CircularAreaPicker = dynamic(() => import('./maps').then((mod) => mod.CircularAreaPicker), { ssr: false }); diff --git a/traque-front/components/admin/zoneSelector.jsx b/traque-front/components/admin/zoneSelector.jsx index 1624892..32c7d89 100644 --- a/traque-front/components/admin/zoneSelector.jsx +++ b/traque-front/components/admin/zoneSelector.jsx @@ -1,8 +1,4 @@ -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"; +import { ZoneInitializer } from "./maps"; export function ZoneSelector() { @@ -10,8 +6,7 @@ export function ZoneSelector() { return

Edit zones

- +
- Apply
} \ No newline at end of file diff --git a/traque-front/components/team/actionDrawer.jsx b/traque-front/components/team/actionDrawer.jsx index 0828c00..6354139 100644 --- a/traque-front/components/team/actionDrawer.jsx +++ b/traque-front/components/team/actionDrawer.jsx @@ -16,7 +16,6 @@ export default function ActionDrawer() { useEffect(() => { const interval = setInterval(() => { - console.log(locationSendDeadline) const timeLeft = locationSendDeadline - Date.now(); setTimeLeftBeforePenalty(Math.floor(timeLeft / 1000 / 60)); }, 1000); diff --git a/traque-front/components/team/map.jsx b/traque-front/components/team/map.jsx index e5b505e..b6a11a8 100644 --- a/traque-front/components/team/map.jsx +++ b/traque-front/components/team/map.jsx @@ -1,11 +1,13 @@ 'use client'; import React, { useEffect, useState } from 'react' -import { Circle, MapContainer, Marker, Popup, TileLayer, useMap } from 'react-leaflet' +import { Circle, LayerGroup, LayersControl, 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 { useTilesColor } from '@/hook/mapDrawing'; +import { MapGridZoneSelector } from '../admin/mapZoneSelector'; const DEFAULT_ZOOM = 17; @@ -25,27 +27,11 @@ function MapPan(props) { return null; } -function LiveZone() { - const { zone } = useTeamContext(); - console.log('Zone', zone); - return zone && -} - -function ZoneExtremities() { - const { zoneExtremities } = useTeamContext(); - console.log('Zone extremities', zoneExtremities); - return zoneExtremities?.begin && zoneExtremities?.end && <> - {/* */} - - - -} - export function LiveMap({ ...props }) { + const {zone} = useTeamContext(); + const tilesColor = useTilesColor(zone); const { currentPosition, enemyPosition } = useGame(); - useEffect(() => { - console.log('Current position', currentPosition); - }, [currentPosition]); + return ( } - - + + + + {}} tileSize={16}/> + + + ) } diff --git a/traque-front/components/team/placementOverlay.jsx b/traque-front/components/team/placementOverlay.jsx index 63e6f68..c4bb5a6 100644 --- a/traque-front/components/team/placementOverlay.jsx +++ b/traque-front/components/team/placementOverlay.jsx @@ -1,6 +1,5 @@ import { useTeamConnexion } from "@/context/teamConnexionContext"; import useGame from "@/hook/useGame" -import Image from "next/image"; export default function PlacementOverlay() { const { name, ready } = useGame(); diff --git a/traque-front/context/adminContext.jsx b/traque-front/context/adminContext.jsx index 0c94189..d7f6511 100644 --- a/traque-front/context/adminContext.jsx +++ b/traque-front/context/adminContext.jsx @@ -4,15 +4,15 @@ import { useSocket } from "./socketContext"; import { useSocketListener } from "@/hook/useSocketListener"; import { useAdminConnexion } from "./adminConnexionContext"; import { GameState } from "@/util/gameState"; +import { TileNumber } from "@/components/util/map"; const adminContext = createContext(); function AdminProvider({ children }) { const [teams, setTeams] = useState([]); - const [zoneSettings, setZoneSettings] = useState(null) + const [zone, setZone] = useState([]) const [penaltySettings, setPenaltySettings] = useState(null); const [gameSettings, setGameSettings] = useState(null); - const [zone, setZone] = useState(null); const [zoneExtremities, setZoneExtremities] = useState(null); const { adminSocket } = useSocket(); const { loggedIn } = useAdminConnexion(); @@ -26,13 +26,12 @@ function AdminProvider({ children }) { //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", (zone) => setZone(zone.map(t => new TileNumber(t.x, t.y)))); useSocketListener(adminSocket, "new_zone", setZoneExtremities); - const value = useMemo(() => ({ zone, zoneExtremities, teams, zoneSettings, penaltySettings, gameSettings, gameState }), [zoneSettings, teams, gameState, zone, zoneExtremities, penaltySettings, gameSettings]); + const value = useMemo(() => ({ zone, zoneExtremities, teams, penaltySettings, gameSettings, gameState }), [teams, gameState, zone, zoneExtremities, penaltySettings, gameSettings]); return ( {children} diff --git a/traque-front/context/teamContext.jsx b/traque-front/context/teamContext.jsx index 5d66e9f..e22c13f 100644 --- a/traque-front/context/teamContext.jsx +++ b/traque-front/context/teamContext.jsx @@ -13,7 +13,6 @@ function TeamProvider({children}) { const [gameState, setGameState] = useState(GameState.SETUP); const [gameSettings, setGameSettings] = useState(null); const [zone, setZone] = useState(null); - const [zoneExtremities, setZoneExtremities] = useState(null); const measuredLocation = useLocation(5000); const {teamSocket} = useSocket(); const {loggedIn} = useTeamConnexion(); @@ -27,7 +26,6 @@ function TeamProvider({children}) { useSocketListener(teamSocket, "game_state", setGameState); useSocketListener(teamSocket, "zone", setZone); - useSocketListener(teamSocket, "new_zone", setZoneExtremities); useSocketListener(teamSocket, "game_settings", setGameSettings); @@ -40,7 +38,7 @@ function TeamProvider({children}) { } }, [loggedIn, measuredLocation]); - const value = useMemo(() => ({teamInfos, gameState, zone, zoneExtremities, gameSettings}), [gameSettings, teamInfos, gameState, zone, zoneExtremities]); + const value = useMemo(() => ({teamInfos, gameState, zone, gameSettings}), [gameSettings, teamInfos, gameState, zone]); return ( {children} diff --git a/traque-front/hook/mapDrawing.jsx b/traque-front/hook/mapDrawing.jsx index 0794849..17dbd97 100644 --- a/traque-front/hook/mapDrawing.jsx +++ b/traque-front/hook/mapDrawing.jsx @@ -38,7 +38,7 @@ export function useMapCircleDraw(area, setArea) { } } -export function useMapGrid(coloredTiles, tileSize) { +export function useMapGrid(tilesColor, tileSize) { const { layerContainer, map } = useLeafletContext(); const [grid, setGrid] = useState(null); @@ -79,15 +79,12 @@ export function useMapGrid(coloredTiles, tileSize) { const yMinPixel = Math.round(size.y * (y - minTileY) / (maxTileY - minTileY)); const yMaxPixel = Math.round(size.y * (y + 1 - minTileY) / (maxTileY - minTileY)); - // fill the rectangle with a color - // if(coloredTiles.some(t => t.equals(tile))) { - // console.log(coloredTiles, "filling tile " + tile.x + " " + tile.y) - // } else { - // console.log(coloredTiles, "not filling tile " + tile.x + " " + tile.y) - // } - ctx.fillStyle = this.coloredTiles && this.coloredTiles.some(t => t.equals(tile)) - ? 'rgba(0, 0, 255, 0.3)' - : 'rgba(255, 255, 255, 0)'; + if (this.tilesColor?.some(t => t.x == tile.x && t.y == tile.y)) { + ctx.fillStyle = this.tilesColor.find(t => t.x == tile.x && t.y == tile.y).color; + } + else { + ctx.fillStyle = 'rgba(255, 255, 255, 0)'; + } ctx.fillRect(xMinPixel, yMinPixel, xMaxPixel - xMinPixel, yMaxPixel - yMinPixel); } } @@ -105,8 +102,25 @@ export function useMapGrid(coloredTiles, tileSize) { useEffect(() => { if (grid) { - grid.coloredTiles = coloredTiles; + grid.tilesColor = tilesColor; grid.redraw(); } - }, [coloredTiles]); + }, [tilesColor,grid]); +} + +export function useTilesColor(zone) { + const [tilesColor, setTilesColor] = useState([]); + useEffect(() => { + if (zone) { + setTilesColor(zone.map(t => { + if(t.removeDate == null) { + return { x: t.x, y: t.y, color: 'rgba(0, 0, 255, 0.3'} + }else { + return { x: t.x, y: t.y, color: 'rgba(255, 255, 0, 0.3'} + } + + })); + } + }, [zone]); + return tilesColor; } \ No newline at end of file diff --git a/traque-front/hook/useAdmin.jsx b/traque-front/hook/useAdmin.jsx index 3441641..f031e1d 100644 --- a/traque-front/hook/useAdmin.jsx +++ b/traque-front/hook/useAdmin.jsx @@ -39,8 +39,12 @@ export default function useAdmin(){ adminSocket.emit("change_state", state); } - function changeZoneSettings(zone) { - adminSocket.emit("set_zone_settings", zone); + function initZone(zone) { + adminSocket.emit("set_zone", zone); + } + + function removeZone(zone, time) { + adminSocket.emit("remove_zone", zone, time); } function changePenaltySettings(penalties) { @@ -50,6 +54,6 @@ export default function useAdmin(){ function changeGameSettings(settings) { adminSocket.emit("set_game_settings", settings); } - return {...adminContext,changeGameSettings, changeZoneSettings, changePenaltySettings, pollTeams, getTeam, getTeamName, reorderTeams, addTeam, removeTeam, changeState, updateTeam }; + return {...adminContext,changeGameSettings, removeZone, initZone, changePenaltySettings, pollTeams, getTeam, getTeamName, reorderTeams, addTeam, removeTeam, changeState, updateTeam }; } \ No newline at end of file From cd2bba2aed135f9c56887ac5bc7f4653a98d5c39 Mon Sep 17 00:00:00 2001 From: Quentin Roussel Date: Sat, 8 Jun 2024 10:10:48 +0200 Subject: [PATCH 3/3] added live map --- traque-front/app/admin/layout.js | 1 + traque-front/app/admin/map/page.js | 9 ++ traque-front/app/admin/page.js | 4 +- traque-front/components/admin/maps.jsx | 126 +++++++++++++++++++------ traque-front/components/util/map.jsx | 1 + traque-front/context/adminContext.jsx | 6 +- traque-front/public/icons/clock.png | Bin 0 -> 9597 bytes 7 files changed, 113 insertions(+), 34 deletions(-) create mode 100644 traque-front/app/admin/map/page.js create mode 100644 traque-front/public/icons/clock.png diff --git a/traque-front/app/admin/layout.js b/traque-front/app/admin/layout.js index 9ff7fa6..c75d592 100644 --- a/traque-front/app/admin/layout.js +++ b/traque-front/app/admin/layout.js @@ -11,6 +11,7 @@ export default function AdminLayout({ children}) {
  • Admin
  • Teams
  • +
  • Map
diff --git a/traque-front/app/admin/map/page.js b/traque-front/app/admin/map/page.js new file mode 100644 index 0000000..8dfecf8 --- /dev/null +++ b/traque-front/app/admin/map/page.js @@ -0,0 +1,9 @@ +"use client"; +import dynamic from 'next/dynamic'; + +const LiveMap = dynamic(() => import('@/components/admin/maps').then((mod) => mod.LiveMap), { + ssr: false +}); +export default function LiveMapPage() { + return +} \ No newline at end of file diff --git a/traque-front/app/admin/page.js b/traque-front/app/admin/page.js index 1159612..6e0ff72 100644 --- a/traque-front/app/admin/page.js +++ b/traque-front/app/admin/page.js @@ -11,7 +11,7 @@ import dynamic from "next/dynamic"; const ZoneSelector = dynamic(() => import('@/components/admin/zoneSelector').then((mod) => mod.ZoneSelector), { ssr: false }); -const LiveMap = dynamic(() => import('@/components/admin/maps').then((mod) => mod.ZoneEditor), { +const ZoneEditor = dynamic(() => import('@/components/admin/maps').then((mod) => mod.ZoneEditor), { ssr: false }); export default function AdminPage() { @@ -36,7 +36,7 @@ export default function AdminPage() { {(gameState == GameState.SETUP || gameState == GameState.PLACEMENT) && } {(gameState == GameState.SETUP || gameState == GameState.PLACEMENT) && } {gameState == GameState.PLAYING &&
- +
}
) diff --git a/traque-front/components/admin/maps.jsx b/traque-front/components/admin/maps.jsx index 7135b19..1c0aff6 100644 --- a/traque-front/components/admin/maps.jsx +++ b/traque-front/components/admin/maps.jsx @@ -107,7 +107,7 @@ export function ZoneEditor() { const { zone, teams, getTeamName, removeZone } = useAdmin(); const [zonesToDelete, setZonesToDelete] = useState([]); const [tilesColor, setTilesColor] = useState([]); - const [timeBeforeDeletion, setTimeBeforeDeletion] = useState(0); + const [timeBeforeDeletion, setTimeBeforeDeletion] = useState(null); function handleClickTile(tile) { if (!zone.some(t => t.x === tile.x && t.y === tile.y)) return; @@ -127,6 +127,7 @@ export function ZoneEditor() { ...zone .filter(t => !zonesToDelete.some(t2 => t.x == t2.x && t.y == t2.y)) .map(t => { + console.log(t) if (t.removeDate == null) { return { x: t.x, y: t.y, color: 'rgba(0, 0, 255, 0.5)' } } else { @@ -137,44 +138,107 @@ export function ZoneEditor() { }, [zone, zonesToDelete]); const handleSubmit = (e) => { + if (timeBeforeDeletion == null) { + return; + } e.preventDefault(); removeZone(zonesToDelete, timeBeforeDeletion); setZonesToDelete([]); - setTimeBeforeDeletion(0); + setTimeBeforeDeletion(null); } return (
- - - - {teams.map((team) => team.currentLocation && !team.captured && - - {team.name} -

Chasing : {getTeamName(team.chasing)}

-

Chased by : {getTeamName(team.chased)}

-
-
)} - - - - - - - -
- setTimeBeforeDeletion(Number(e.target.value))}> - Delete selected zones +
+ + + + {teams.map((team) => team.currentLocation && !team.captured && + + {team.name} +

Chasing : {getTeamName(team.chasing)}

+

Chased by : {getTeamName(team.chased)}

+
+
)} + + + + + + + +
+
+
+ + setTimeBeforeDeletion(Number(e.target.value))}> + Delete selected zones +
) +} + +export function LiveMap({ ...props }) { + const location = useLocation(Infinity); + const { zone, teams, getTeamName } = useAdmin(); + const tilesColor = useTilesColor(zone); + + return ( + + + + + + + { }} tileSize={16} /> + + + + + {teams.map((team) => team.currentLocation && !team.captured && + + {team.name} +

Chasing : {getTeamName(team.chasing)}

+

Chased by : {getTeamName(team.chased)}

+
+
)} +
+
+ + + {teams.map((team) => team.currentLocation && !team.captured && + + Last position of {team.name} + + )} + + +
+
+ ) } \ No newline at end of file diff --git a/traque-front/components/util/map.jsx b/traque-front/components/util/map.jsx index 39ca395..bf19533 100644 --- a/traque-front/components/util/map.jsx +++ b/traque-front/components/util/map.jsx @@ -2,6 +2,7 @@ export class TileNumber { constructor(x, y) { this.x = x; this.y = y; + this.removeDate = null; } equals(other) { return this.x === other.x && this.y === other.y; diff --git a/traque-front/context/adminContext.jsx b/traque-front/context/adminContext.jsx index d7f6511..25f1e51 100644 --- a/traque-front/context/adminContext.jsx +++ b/traque-front/context/adminContext.jsx @@ -28,7 +28,11 @@ function AdminProvider({ children }) { useSocketListener(adminSocket, "teams", setTeams); useSocketListener(adminSocket, "game_settings", setGameSettings); useSocketListener(adminSocket, "penalty_settings", setPenaltySettings); - useSocketListener(adminSocket, "zone", (zone) => setZone(zone.map(t => new TileNumber(t.x, t.y)))); + useSocketListener(adminSocket, "zone", (zone) => setZone(zone.map(t => { + let tile = new TileNumber(t.x, t.y); + tile.removeDate = t.removeDate; + return tile; + }))); useSocketListener(adminSocket, "new_zone", setZoneExtremities); const value = useMemo(() => ({ zone, zoneExtremities, teams, penaltySettings, gameSettings, gameState }), [teams, gameState, zone, zoneExtremities, penaltySettings, gameSettings]); diff --git a/traque-front/public/icons/clock.png b/traque-front/public/icons/clock.png new file mode 100644 index 0000000000000000000000000000000000000000..6097257267bbdbabf14f4a52759498ebcd7c99e9 GIT binary patch literal 9597 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