Map resize correction + player focus + cleaning

This commit is contained in:
Sebastien Riviere
2025-09-04 18:58:41 +02:00
parent 31c43f6361
commit d0a3351f55
12 changed files with 132 additions and 116 deletions

View File

@@ -1,9 +1,10 @@
import { useEffect, useState } from "react";
import { Marker, Tooltip, Polyline, Polygon, Circle } from "react-leaflet";
import "leaflet/dist/leaflet.css";
import { CustomMapContainer } from "@/components/map";
import { CustomMapContainer, MapEventListener, MapPan } from "@/components/map";
import useAdmin from "@/hook/useAdmin";
import { GameState } from "@/util/gameState";
import { GameState, ZoneTypes } from "@/util/types";
import { mapZooms } from "@/util/configurations";
const positionIcon = new L.Icon({
iconUrl: '/icons/marker/blue.png',
@@ -13,12 +14,7 @@ const positionIcon = new L.Icon({
shadowSize: [30, 30],
});
const zoneTypes = {
circle: "circle",
polygon: "polygon"
}
export default function LiveMap({mapStyle, showZones, showNames, showArrows}) {
export default function LiveMap({ selectedTeamId, isFocusing, setIsFocusing, mapStyle, showZones, showNames, showArrows}) {
const { zoneType, zoneExtremities, teams, nextZoneDate, getTeam, gameState } = useAdmin();
const [timeLeftNextZone, setTimeLeftNextZone] = useState(null);
@@ -58,14 +54,14 @@ export default function LiveMap({mapStyle, showZones, showNames, showArrows}) {
if (!(showZones && gameState == GameState.PLAYING && zoneType)) return null;
switch (zoneType) {
case zoneTypes.circle:
case ZoneTypes.CIRCLE:
return (
<div>
{ zoneExtremities.begin && <Circle center={zoneExtremities.begin.center} radius={zoneExtremities.begin.radius} color="red" fillColor="red" />}
{ zoneExtremities.end && <Circle center={zoneExtremities.end.center} radius={zoneExtremities.end.radius} color="green" fillColor="green" />}
</div>
);
case zoneTypes.polygon:
case ZoneTypes.POLYGON:
return (
<div>
{ zoneExtremities.begin && <Polygon positions={zoneExtremities.begin.points} pathOptions={{ color: 'red', fillColor: 'red', fillOpacity: '0.1', weight: 3 }} />}
@@ -81,14 +77,16 @@ export default function LiveMap({mapStyle, showZones, showNames, showArrows}) {
<div className='h-full w-full flex flex-col'>
{gameState == GameState.PLAYING && <p>{`Next zone in : ${formatTime(timeLeftNextZone)}`}</p>}
<CustomMapContainer mapStyle={mapStyle}>
{isFocusing && <MapPan center={getTeam(selectedTeamId)?.currentLocation} zoom={mapZooms.high} animate />}
<MapEventListener onDragStart={() => setIsFocusing(false)}/>
<Zones/>
{teams.map((team) => team.currentLocation && !team.captured &&
<div>
<>
<Marker key={team.id} position={team.currentLocation} icon={positionIcon}>
{showNames && <Tooltip permanent direction="top" offset={[0.5, -15]} className="custom-tooltip">{team.name}</Tooltip>}
</Marker>
{showArrows && <Arrow key={team.id} pos1={team.currentLocation} pos2={getTeam(team.chased).currentLocation}/>}
</div>
</>
)}
</CustomMapContainer>
</div>

View File

@@ -1,7 +1,7 @@
import { env } from 'next-runtime-env';
import { useEffect, useState } from "react";
import useAdmin from "@/hook/useAdmin";
import { GameState } from '@/util/gameState';
import { getStatus } from '@/util/functions';
function DotLine({ label, value }) {
return (
@@ -27,28 +27,6 @@ function IconValue({ color, icon, value }) {
);
}
const TEAM_STATUS = {
playing: { label: "En jeu", color: "text-custom-green" },
captured: { label: "Capturée", color: "text-custom-red" },
outofzone: { label: "Hors zone", color: "text-custom-orange" },
ready: { label: "Placée", color: "text-custom-green" },
notready: { label: "Non placée", color: "text-custom-red" },
waiting: { label: "En attente", color: "text-custom-grey" },
};
function getStatus(team, gamestate) {
switch (gamestate) {
case GameState.SETUP:
return TEAM_STATUS.waiting;
case GameState.PLACEMENT:
return team.ready ? TEAM_STATUS.ready : TEAM_STATUS.notready;
case GameState.PLAYING:
return team.captured ? TEAM_STATUS.captured : team.outofzone ? TEAM_STATUS.outofzone : TEAM_STATUS.playing;
case GameState.FINISHED:
return team.captured ? TEAM_STATUS.captured : TEAM_STATUS.playing;
}
}
export default function TeamSidePanel({ selectedTeamId, onClose }) {
const { getTeam, startDate, gameState } = useAdmin();
const [imgSrc, setImgSrc] = useState("");

View File

@@ -1,28 +1,6 @@
import { List } from '@/components/list';
import useAdmin from '@/hook/useAdmin';
import { GameState } from '@/util/gameState';
const TEAM_STATUS = {
playing: { label: "En jeu", color: "text-custom-green" },
captured: { label: "Capturée", color: "text-custom-red" },
outofzone: { label: "Hors zone", color: "text-custom-orange" },
ready: { label: "Placée", color: "text-custom-green" },
notready: { label: "Non placée", color: "text-custom-red" },
waiting: { label: "En attente", color: "text-custom-grey" },
};
function getStatus(team, gamestate) {
switch (gamestate) {
case GameState.SETUP:
return TEAM_STATUS.waiting;
case GameState.PLACEMENT:
return team.ready ? TEAM_STATUS.ready : TEAM_STATUS.notready;
case GameState.PLAYING:
return team.captured ? TEAM_STATUS.captured : team.outofzone ? TEAM_STATUS.outofzone : TEAM_STATUS.playing;
case GameState.FINISHED:
return team.captured ? TEAM_STATUS.captured : TEAM_STATUS.playing;
}
}
import { getStatus } from '@/util/functions';
function TeamViewerItem({ team, itemSelected, onSelected }) {
const { gameState } = useAdmin();

View File

@@ -4,16 +4,18 @@ import { TextInput } from "@/components/input";
export default function LoginForm({ onSubmit, title, placeholder, buttonText}) {
const [value, setValue] = useState("");
function handleSubmit(e) {
e.preventDefault();
setValue("");
onSubmit(value);
}
return (
<form className="bg-white shadow-md max-w mx-auto p-5 mx-10 flex flex-col space-y-4" onSubmit={handleSubmit}>
<h1 className="text-2xl font-bold text-center text-gray-700">{title}</h1>
<TextInput inputMode="numeric" placeholder={placeholder} value={value} onChange={(e) => setValue(e.target.value)} name="team-id"/>
<BlueButton type="submit">{buttonText}</BlueButton>
</form>
)
);
}

View File

@@ -1,11 +1,12 @@
"use client";
import React, { useState } from 'react';
import { useState } from 'react';
import dynamic from "next/dynamic";
import Link from "next/link";
import { Section } from "@/components/section";
import { useAdminConnexion } from "@/context/adminConnexionContext";
import useAdmin from "@/hook/useAdmin";
import { GameState } from "@/util/gameState";
import { GameState } from "@/util/types";
import { mapStyles } from '@/util/configurations';
import TeamSidePanel from "./components/teamSidePanel";
import TeamViewer from './components/teamViewer';
import { MapButton, ControlButton } from './components/buttons';
@@ -13,33 +14,25 @@ import { MapButton, ControlButton } from './components/buttons';
// Imported at runtime and not at compile time
const LiveMap = dynamic(() => import('./components/liveMap'), { ssr: false });
const mapStyles = {
default: {
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
},
satellite: {
url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
attribution: 'Tiles &copy; Esri'
},
}
export default function AdminPage() {
const { useProtect } = useAdminConnexion();
const [selectedTeamId, setSelectedTeamId] = useState(null);
const { changeState } = useAdmin();
const { changeState, getTeam } = useAdmin();
const [mapStyle, setMapStyle] = useState(mapStyles.default);
const [showZones, setShowZones] = useState(true);
const [showNames, setShowNames] = useState(true);
const [showArrows, setShowArrows] = useState(false);
const [isFocusing, setIsFocusing] = useState(true);
useProtect();
function onSelected(id) {
if (selectedTeamId === id) {
if (selectedTeamId == id && (!getTeam(id)?.currentLocation || isFocusing)) {
setSelectedTeamId(null);
setIsFocusing(false);
} else {
setSelectedTeamId(id);
setIsFocusing(true);
}
}
@@ -82,7 +75,15 @@ export default function AdminPage() {
<div className='grow flex-1 flex flex-col bg-white p-3 gap-3 shadow-2xl'>
<div className="flex-1 flex flex-row gap-3">
<div className="flex-1 h-full">
<LiveMap mapStyle={mapStyle} showZones={showZones} showNames={showNames} showArrows={showArrows}/>
<LiveMap
selectedTeamId={selectedTeamId}
isFocusing={isFocusing}
setIsFocusing={setIsFocusing}
mapStyle={mapStyle}
showZones={showZones}
showNames={showNames}
showArrows={showArrows}
/>
</div>
{selectedTeamId &&
<div className="w-3/12 h-full">

View File

@@ -10,18 +10,12 @@ import useAdmin from '@/hook/useAdmin';
import Messages from "./components/messages";
import TeamManager from './components/teamManager';
import useLocalVariable from "@/hook/useLocalVariable";
import { ZoneTypes } from "@/util/types";
import { defaultZoneSettings } from "@/util/configurations";
// Imported at runtime and not at compile time
const PolygonZoneSelector = dynamic(() => import('./components/polygonZoneSelector'), { ssr: false });
const CircleZoneSelector = dynamic(() => import('./components/circleZoneSelector'), { ssr: false });
const zoneTypes = {
circle: "circle",
polygon: "polygon"
}
const defaultCircleSettings = {type: zoneTypes.circle, min: null, max: null, reductionCount: 4, duration: 10}
const defaultPolygonSettings = {type: zoneTypes.polygon, polygons: []}
const PolygonZoneSelector = dynamic(() => import('./components/polygonZoneSelector'), { ssr: false });
export default function ConfigurationPage() {
const { useProtect } = useAdminConnexion();
@@ -34,10 +28,10 @@ export default function ConfigurationPage() {
function modifyLocalZoneSettings(key, value) {
setLocalZoneSettings(prev => ({...prev, [key]: value}));
};
}
function handleChangeZoneType() {
setLocalZoneSettings(localZoneSettings.type == zoneTypes.circle ? defaultPolygonSettings : defaultCircleSettings)
setLocalZoneSettings(localZoneSettings.type == ZoneTypes.CIRCLE ? defaultZoneSettings.polygon : defaultZoneSettings.circle)
}
function handleTeamSubmit(e) {
@@ -83,10 +77,10 @@ export default function ConfigurationPage() {
{localZoneSettings && <BlueButton onClick={handleChangeZoneType}>Change zone type</BlueButton>}
</div>
<div className="w-full flex-1">
{localZoneSettings && localZoneSettings.type == zoneTypes.circle &&
{localZoneSettings && localZoneSettings.type == ZoneTypes.CIRCLE &&
<CircleZoneSelector zoneSettings={localZoneSettings} modifyZoneSettings={modifyLocalZoneSettings} applyZoneSettings={applyLocalZoneSettings}/>
}
{localZoneSettings && localZoneSettings.type == zoneTypes.polygon &&
{localZoneSettings && localZoneSettings.type == ZoneTypes.POLYGON &&
<PolygonZoneSelector zoneSettings={localZoneSettings} modifyZoneSettings={modifyLocalZoneSettings} applyZoneSettings={applyLocalZoneSettings}/>
}
</div>

View File

@@ -1,33 +1,21 @@
import { useEffect, useState } from "react";
import { MapContainer, TileLayer, useMap } from "react-leaflet";
import "leaflet/dist/leaflet.css";
import { mapLocations, mapZooms, mapStyles } from "@/util/configurations";
const DEFAULT_ZOOM = 14;
const mapStyles = {
default: {
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
},
satellite: {
url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
attribution: 'Tiles &copy; Esri'
},
}
export function MapPan({center, zoom}) {
export function MapPan({center, zoom, animate=false}) {
const map = useMap();
useEffect(() => {
if (center, zoom) {
map.flyTo(center, zoom, { animate: false });
if (center && zoom) {
map.flyTo(center, zoom, { animate: animate });
}
}, [center, zoom]);
return null;
}
export function MapEventListener({ onLeftClick, onRightClick, onMouseMove }) {
export function MapEventListener({ onLeftClick, onRightClick, onMouseMove, onDragStart }) {
const map = useMap();
// Handle the mouse click left
@@ -93,6 +81,17 @@ export function MapEventListener({ onLeftClick, onRightClick, onMouseMove }) {
map.off('mousemove', onMouseMove);
}
}, [onMouseMove]);
// Handle the drag start
useEffect(() => {
if (!onDragStart) return;
map.on('dragstart', onDragStart);
return () => {
map.off('dragstart', onDragStart);
}
}, [onDragStart]);
// Prevent right click context menu
useEffect(() => {
@@ -105,9 +104,23 @@ export function MapEventListener({ onLeftClick, onRightClick, onMouseMove }) {
return null;
}
function MapResizeWatcher() {
const map = useMap();
useEffect(() => {
const observer = new ResizeObserver(() => {
map.invalidateSize();
});
observer.observe(map.getContainer());
return () => observer.disconnect();
}, [map]);
return null;
}
export function CustomMapContainer({mapStyle, children}) {
const [location, setLocation] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!navigator.geolocation) {
@@ -118,7 +131,6 @@ export function CustomMapContainer({mapStyle, children}) {
navigator.geolocation.getCurrentPosition(
(pos) => {
setLocation([pos.coords.latitude, pos.coords.longitude]);
setLoading(false);
},
(err) => console.log("Error :", err),
{
@@ -129,13 +141,11 @@ export function CustomMapContainer({mapStyle, children}) {
);
}, []);
if (loading) {
return <div className="w-full h-full"/>
}
return (
<MapContainer className='w-full h-full' center={location} zoom={DEFAULT_ZOOM} scrollWheelZoom={true}>
<MapContainer className='w-full h-full' center={mapLocations.paris} zoom={mapZooms.low} scrollWheelZoom={true}>
<TileLayer url={(mapStyle || mapStyles.default).url} attribution={(mapStyle || mapStyles.default).attribution}/>
<MapPan center={location} zoom={mapZooms.high}/>
<MapResizeWatcher/>
{children}
</MapContainer>
)

View File

@@ -2,7 +2,7 @@
import { createContext, useContext, useMemo, useState } from "react";
import { useSocket } from "./socketContext";
import useSocketListener from "@/hook/useSocketListener";
import { GameState } from "@/util/gameState";
import { GameState } from "@/util/types";
const adminContext = createContext();

View File

@@ -0,0 +1,35 @@
import { ZoneTypes } from "./types";
export const mapLocations = {
paris: [48.86, 2.33]
}
export const mapZooms = {
low: 4,
high: 15,
}
export const mapStyles = {
default: {
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
},
satellite: {
url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
attribution: 'Tiles &copy; Esri'
},
}
export const defaultZoneSettings = {
circle: {type: ZoneTypes.CIRCLE, min: null, max: null, reductionCount: 4, duration: 10},
polygon: {type: ZoneTypes.POLYGON, polygons: []}
}
export const teamStatus = {
playing: { label: "En jeu", color: "text-custom-green" },
captured: { label: "Capturée", color: "text-custom-red" },
outofzone: { label: "Hors zone", color: "text-custom-orange" },
ready: { label: "Placée", color: "text-custom-green" },
notready: { label: "Non placée", color: "text-custom-red" },
waiting: { label: "En attente", color: "text-custom-grey" },
}

View File

@@ -0,0 +1,15 @@
import { GameState } from './types';
import { teamStatus } from './configurations';
export function getStatus(team, gamestate) {
switch (gamestate) {
case GameState.SETUP:
return teamStatus.waiting;
case GameState.PLACEMENT:
return team.ready ? teamStatus.ready : teamStatus.notready;
case GameState.PLAYING:
return team.captured ? teamStatus.captured : team.outofzone ? teamStatus.outofzone : teamStatus.playing;
case GameState.FINISHED:
return team.captured ? teamStatus.captured : teamStatus.playing;
}
}

View File

@@ -3,4 +3,9 @@ export const GameState = {
PLACEMENT: "placement",
PLAYING: "playing",
FINISHED: "finished"
}
}
export const ZoneTypes = {
CIRCLE: "circle",
POLYGON: "polygon"
}