add geo location
This commit is contained in:
428
plans/geolocation-and-bounds-implementation.md
Normal file
428
plans/geolocation-and-bounds-implementation.md
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
# Piano di Implementazione: Geolocalizzazione e Calcolo Bounds
|
||||||
|
|
||||||
|
## Panoramica
|
||||||
|
|
||||||
|
Questo documento descrive il piano per implementare le seguenti funzionalità nell'Ingress Dashboard:
|
||||||
|
|
||||||
|
1. **Geolocalizzazione dell'utente** al primo caricamento dell'applicazione
|
||||||
|
2. **Calcolo del center** della viewport della mappa
|
||||||
|
3. **Calcolo del radius** in metri in base allo zoom della mappa per coprire l'intera viewport
|
||||||
|
4. **Integrazione dei parametri center e radius** nell'API
|
||||||
|
|
||||||
|
## Architettura Proposta
|
||||||
|
|
||||||
|
### Componenti Coinvolti
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ App.tsx │
|
||||||
|
│ ┌────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Stato: center, radius, plexts, loading, filters │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ loadData() → fetchPlexts(center, radius, filters) │ │
|
||||||
|
│ └────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ props: onCenterChange, onRadiusChange
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ PlayerMap.tsx │
|
||||||
|
│ ┌────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ useMapGeolocation() │ │
|
||||||
|
│ │ - map.locate() al mount │ │
|
||||||
|
│ │ - onLocationFound → setView(lat, lng, zoom) │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ useMapBounds() │ │
|
||||||
|
│ │ - onMoveEnd → calcola center e radius │ │
|
||||||
|
│ │ - onZoomEnd → calcola center e radius │ │
|
||||||
|
│ │ - invoca callbacks onCenterChange, onRadiusChange │ │
|
||||||
|
│ └────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ params: center, radius, filters
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ api.ts │
|
||||||
|
│ ┌────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ fetchPlexts(base_url, endpoint, creds, { │ │
|
||||||
|
│ │ center: [lat, lng], │ │
|
||||||
|
│ │ radius: meters, │ │
|
||||||
|
│ │ timestamp_from, timestamp_to, │ │
|
||||||
|
│ │ player_name, limit │ │
|
||||||
|
│ │ }) │ │
|
||||||
|
│ └────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dettagli di Implementazione
|
||||||
|
|
||||||
|
### 1. Aggiornamento dei Tipi (`src/types.ts`)
|
||||||
|
|
||||||
|
Aggiungere le interfacce per i nuovi parametri:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface MapCenter {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapBoundsParams {
|
||||||
|
center: [number, number]; // [lat, lng]
|
||||||
|
radius: number; // metri
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Aggiornamento dell'API (`src/api.ts`)
|
||||||
|
|
||||||
|
Modificare la funzione `fetchPlexts` per accettare i nuovi parametri:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function fetchPlexts(
|
||||||
|
base_url: string,
|
||||||
|
endpoint: string,
|
||||||
|
creds: string,
|
||||||
|
params?: {
|
||||||
|
center?: [number, number]; // [lat, lng]
|
||||||
|
radius?: number; // metri
|
||||||
|
timestamp_from?: number;
|
||||||
|
timestamp_to?: number;
|
||||||
|
player_name?: string;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
): Promise<ApiResponse>
|
||||||
|
```
|
||||||
|
|
||||||
|
I parametri `center` e `radius` verranno aggiunti alla query string.
|
||||||
|
|
||||||
|
### 3. Hook Personalizzato: `useMapGeolocation`
|
||||||
|
|
||||||
|
Creare un nuovo file `src/hooks/useMapGeolocation.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useMap } from 'react-leaflet';
|
||||||
|
|
||||||
|
interface UseMapGeolocationOptions {
|
||||||
|
enableHighAccuracy?: boolean;
|
||||||
|
timeout?: number;
|
||||||
|
maximumAge?: number;
|
||||||
|
zoom?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMapGeolocation(options: UseMapGeolocationOptions = {}) {
|
||||||
|
const map = useMap();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
map.locate({
|
||||||
|
setView: true,
|
||||||
|
maxZoom: options.zoom || 13,
|
||||||
|
enableHighAccuracy: options.enableHighAccuracy ?? true,
|
||||||
|
timeout: options.timeout || 10000,
|
||||||
|
maximumAge: options.maximumAge || 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onLocationFound = (e: any) => {
|
||||||
|
console.log('Location found:', e.latlng);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onLocationError = (e: any) => {
|
||||||
|
console.error('Location error:', e.message);
|
||||||
|
// Fallback to default location
|
||||||
|
map.setView([45.57, 12.36], 12);
|
||||||
|
};
|
||||||
|
|
||||||
|
map.on('locationfound', onLocationFound);
|
||||||
|
map.on('locationerror', onLocationError);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.off('locationfound', onLocationFound);
|
||||||
|
map.off('locationerror', onLocationError);
|
||||||
|
};
|
||||||
|
}, [map, options]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Hook Personalizzato: `useMapBounds`
|
||||||
|
|
||||||
|
Creare un nuovo file `src/hooks/useMapBounds.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useMap } from 'react-leaflet';
|
||||||
|
import L from 'leaflet';
|
||||||
|
|
||||||
|
interface UseMapBoundsOptions {
|
||||||
|
onCenterChange?: (center: [number, number]) => void;
|
||||||
|
onRadiusChange?: (radius: number) => void;
|
||||||
|
debounceMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcola il raggio in metri dai bounds della mappa
|
||||||
|
* Il raggio è la distanza dal centro al bordo della viewport
|
||||||
|
*/
|
||||||
|
function calculateRadiusFromBounds(map: L.Map): number {
|
||||||
|
const bounds = map.getBounds();
|
||||||
|
const center = bounds.getCenter();
|
||||||
|
const northEast = bounds.getNorthEast();
|
||||||
|
|
||||||
|
// Calcola la distanza dal centro al bordo nord-est
|
||||||
|
const radius = center.distanceTo(northEast);
|
||||||
|
|
||||||
|
return Math.round(radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMapBounds(options: UseMapBoundsOptions = {}) {
|
||||||
|
const map = useMap();
|
||||||
|
const { onCenterChange, onRadiusChange, debounceMs = 500 } = options;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timeoutId: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
const updateBounds = () => {
|
||||||
|
const center = map.getCenter();
|
||||||
|
const radius = calculateRadiusFromBounds(map);
|
||||||
|
|
||||||
|
if (onCenterChange) {
|
||||||
|
onCenterChange([center.lat, center.lng]);
|
||||||
|
}
|
||||||
|
if (onRadiusChange) {
|
||||||
|
onRadiusChange(radius);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMoveEnd = () => {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
timeoutId = setTimeout(updateBounds, debounceMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onZoomEnd = () => {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
timeoutId = setTimeout(updateBounds, debounceMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calcola bounds iniziali
|
||||||
|
updateBounds();
|
||||||
|
|
||||||
|
map.on('moveend', onMoveEnd);
|
||||||
|
map.on('zoomend', onZoomEnd);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
map.off('moveend', onMoveEnd);
|
||||||
|
map.off('zoomend', onZoomEnd);
|
||||||
|
};
|
||||||
|
}, [map, onCenterChange, onRadiusChange, debounceMs]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Aggiornamento di `PlayerMap.tsx`
|
||||||
|
|
||||||
|
Aggiungere le nuove funzionalità:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet";
|
||||||
|
import MarkerClusterGroup from "react-leaflet-cluster";
|
||||||
|
import L from "leaflet";
|
||||||
|
import { useMapGeolocation } from "../hooks/useMapGeolocation";
|
||||||
|
import { useMapBounds } from "../hooks/useMapBounds";
|
||||||
|
|
||||||
|
interface PlayerMapProps {
|
||||||
|
plexts: Plext[];
|
||||||
|
onCenterChange?: (center: [number, number]) => void;
|
||||||
|
onRadiusChange?: (radius: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Componente interno per gli hooks
|
||||||
|
function MapContent({ onCenterChange, onRadiusChange }: {
|
||||||
|
onCenterChange?: (center: [number, number]) => void;
|
||||||
|
onRadiusChange?: (radius: number) => void;
|
||||||
|
}) {
|
||||||
|
useMapGeolocation({ zoom: 13 });
|
||||||
|
useMapBounds({ onCenterChange, onRadiusChange, debounceMs: 500 });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlayerMap({ plexts, onCenterChange, onRadiusChange }: PlayerMapProps) {
|
||||||
|
// ... codice esistente per i marker ...
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="map-container" style={{ height: "600px", width: "100%" }}>
|
||||||
|
<MapContainer
|
||||||
|
center={[45.57, 12.36]} // Default fallback
|
||||||
|
zoom={12}
|
||||||
|
style={{ height: "100%", width: "100%" }}
|
||||||
|
scrollWheelZoom={true}
|
||||||
|
>
|
||||||
|
<MapContent onCenterChange={onCenterChange} onRadiusChange={onRadiusChange} />
|
||||||
|
<TileLayer ... />
|
||||||
|
<MarkerClusterGroup ...>
|
||||||
|
{/* markers */}
|
||||||
|
</MarkerClusterGroup>
|
||||||
|
</MapContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Aggiornamento di `App.tsx`
|
||||||
|
|
||||||
|
Aggiungere lo stato per center e radius e passarlo all'API:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function App() {
|
||||||
|
const [data, setData] = useState<ApiResponse>({ count: 0, plexts: [] });
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [timestampFrom, setTimestampFrom] = useState<Date | null>(null);
|
||||||
|
const [timestampTo, setTimestampTo] = useState<Date | null>(null);
|
||||||
|
const [playerName, setPlayerName] = useState<string>("all");
|
||||||
|
const [limit, setLimit] = useState<number>(100);
|
||||||
|
const [mapCenter, setMapCenter] = useState<[number, number]>([45.57, 12.36]);
|
||||||
|
const [mapRadius, setMapRadius] = useState<number>(10000); // Default 10km
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
center: mapCenter,
|
||||||
|
radius: mapRadius,
|
||||||
|
timestamp_from: timestampFrom ? timestampFrom.getTime() : undefined,
|
||||||
|
timestamp_to: timestampTo ? timestampTo.getTime() : undefined,
|
||||||
|
player_name: playerName !== "all" ? playerName : undefined,
|
||||||
|
limit,
|
||||||
|
};
|
||||||
|
const result = await fetchPlexts(
|
||||||
|
process.env.PUBLIC_API_BASE_URL || "",
|
||||||
|
process.env.PUBLIC_API_ENDPOINT || "",
|
||||||
|
process.env.PUBLIC_API_AUTH_CREDENTIALS || "",
|
||||||
|
params
|
||||||
|
);
|
||||||
|
setData(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading data:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
const interval = setInterval(loadData, 60000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [timestampFrom, timestampTo, playerName, limit, mapCenter, mapRadius]);
|
||||||
|
|
||||||
|
// ... resto del codice ...
|
||||||
|
|
||||||
|
return (
|
||||||
|
// ...
|
||||||
|
<PlayerMap
|
||||||
|
plexts={data.plexts}
|
||||||
|
onCenterChange={setMapCenter}
|
||||||
|
onRadiusChange={setMapRadius}
|
||||||
|
/>
|
||||||
|
// ...
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Diagramma di Flusso
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[App Mount] --> B[PlayerMap Render]
|
||||||
|
B --> C[MapContainer Initialize]
|
||||||
|
C --> D[useMapGeolocation Hook]
|
||||||
|
D --> E{Geolocation Available?}
|
||||||
|
E -->|Yes| F[map.locate]
|
||||||
|
E -->|No| G[Use Default Center]
|
||||||
|
F --> H[locationfound Event]
|
||||||
|
H --> I[map.setView to User Location]
|
||||||
|
I --> J[useMapBounds Hook]
|
||||||
|
J --> K[Calculate Initial Center & Radius]
|
||||||
|
K --> L[onCenterChange Callback]
|
||||||
|
L --> M[App State Updated]
|
||||||
|
M --> N[loadData with center & radius]
|
||||||
|
N --> O[fetchPlexts API Call]
|
||||||
|
O --> P[Update Data State]
|
||||||
|
P --> Q[Re-render Map with New Data]
|
||||||
|
|
||||||
|
J --> R[User Moves/Zooms Map]
|
||||||
|
R --> S[moveend/zoomend Events]
|
||||||
|
S --> T[Debounced Update]
|
||||||
|
T --> U[Recalculate Center & Radius]
|
||||||
|
U --> L
|
||||||
|
```
|
||||||
|
|
||||||
|
## Considerazioni Tecniche
|
||||||
|
|
||||||
|
### Geolocalizzazione
|
||||||
|
|
||||||
|
1. **Permessi**: L'utente deve concedere il permesso di geolocalizzazione. Se rifiuta, usare la posizione di default.
|
||||||
|
2. **Timeout**: Impostare un timeout ragionevole (es. 10 secondi) per evitare attese infinite.
|
||||||
|
3. **Fallback**: Se la geolocalizzazione fallisce, mantenere la posizione di default (Veneto region: [45.57, 12.36]).
|
||||||
|
4. **Privacy**: Non memorizzare la posizione dell'utente in modo persistente.
|
||||||
|
|
||||||
|
### Calcolo del Radius
|
||||||
|
|
||||||
|
1. **Formula**: Il raggio viene calcolato come la distanza dal centro al bordo nord-est della viewport usando `center.distanceTo(northEast)`.
|
||||||
|
2. **Unità**: Il raggio è espresso in metri.
|
||||||
|
3. **Debouncing**: Aggiungere un debouncing di 500ms per evitare chiamate API eccessive durante lo spostamento della mappa.
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
1. **Debouncing**: Usare debouncing per gli eventi `moveend` e `zoomend` per ridurre le chiamate API.
|
||||||
|
2. **Refresh Interval**: Mantenere l'intervallo di refresh di 60 secondi per i dati.
|
||||||
|
3. **Lazy Loading**: I dati vengono caricati solo quando necessario (al cambio di filtri o posizione).
|
||||||
|
|
||||||
|
### API Integration
|
||||||
|
|
||||||
|
1. **Parametri**: I parametri `center` e `radius` vengono aggiunti alla query string dell'API.
|
||||||
|
2. **Formato**: `center` come `[lat, lng]` e `radius` come numero intero in metri.
|
||||||
|
3. **Compatibilità**: Mantenere la compatibilità con i parametri esistenti.
|
||||||
|
|
||||||
|
## Dipendenze
|
||||||
|
|
||||||
|
### Nuove Dipendenze (se necessarie)
|
||||||
|
|
||||||
|
Non sono richieste nuove dipendenze. React-Leaflet e Leaflet forniscono già tutte le funzionalità necessarie.
|
||||||
|
|
||||||
|
### Dipendenze Esistenti
|
||||||
|
|
||||||
|
- `react-leaflet`: Per la mappa e gli hooks
|
||||||
|
- `leaflet`: Per le funzioni di calcolo della distanza
|
||||||
|
- `react`: Per gli hooks e la gestione dello stato
|
||||||
|
|
||||||
|
## Rischi e Mitigazioni
|
||||||
|
|
||||||
|
| Rischio | Mitigazione |
|
||||||
|
|---------|-------------|
|
||||||
|
| Geolocalizzazione non disponibile | Fallback a posizione di default |
|
||||||
|
| Troppo chiamate API durante pan/zoom | Debouncing di 500ms |
|
||||||
|
| Calcolo radius impreciso | Usare `distanceTo` di Leaflet per precisione |
|
||||||
|
| Performance con molti marker | Clustering già implementato |
|
||||||
|
| Permessi geolocalizzazione negati | Gestire errore e usare default |
|
||||||
|
|
||||||
|
## Criteri di Successo
|
||||||
|
|
||||||
|
1. ✅ L'applicazione geolocalizza l'utente al primo caricamento
|
||||||
|
2. ✅ Il center della mappa viene calcolato e passato all'API
|
||||||
|
3. ✅ Il radius viene calcolato in metri e copre l'intera viewport
|
||||||
|
4. ✅ I dati vengono aggiornati quando la mappa viene spostata/zoomata
|
||||||
|
5. ✅ L'API riceve correttamente i parametri center e radius
|
||||||
|
6. ✅ L'esperienza utente rimane fluida (nessun lag eccessivo)
|
||||||
|
7. ✅ Il fallback funziona correttamente se la geolocalizzazione fallisce
|
||||||
|
|
||||||
|
## Ordine di Esecuzione
|
||||||
|
|
||||||
|
1. Aggiornare i tipi in `src/types.ts`
|
||||||
|
2. Aggiornare l'API in `src/api.ts`
|
||||||
|
3. Creare l'hook `useMapGeolocation.ts`
|
||||||
|
4. Creare l'hook `useMapBounds.ts`
|
||||||
|
5. Aggiornare `PlayerMap.tsx`
|
||||||
|
6. Aggiornare `App.tsx`
|
||||||
|
7. Testare l'integrazione completa
|
||||||
12
src/App.tsx
12
src/App.tsx
@@ -13,11 +13,15 @@ export function App() {
|
|||||||
const [timestampTo, setTimestampTo] = useState<Date | null>(null);
|
const [timestampTo, setTimestampTo] = useState<Date | null>(null);
|
||||||
const [playerName, setPlayerName] = useState<string>("all");
|
const [playerName, setPlayerName] = useState<string>("all");
|
||||||
const [limit, setLimit] = useState<number>(100);
|
const [limit, setLimit] = useState<number>(100);
|
||||||
|
const [mapCenter, setMapCenter] = useState<[number, number]>([45.57, 12.36]);
|
||||||
|
const [mapRadius, setMapRadius] = useState<number>(10000); // Default 10km
|
||||||
|
|
||||||
// Load data with filters
|
// Load data with filters
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
const params = {
|
const params = {
|
||||||
|
center: mapCenter,
|
||||||
|
radius: mapRadius,
|
||||||
timestamp_from: timestampFrom ? timestampFrom.getTime() : undefined, // Convert to milliseconds
|
timestamp_from: timestampFrom ? timestampFrom.getTime() : undefined, // Convert to milliseconds
|
||||||
timestamp_to: timestampTo ? timestampTo.getTime() : undefined, // Convert to milliseconds
|
timestamp_to: timestampTo ? timestampTo.getTime() : undefined, // Convert to milliseconds
|
||||||
player_name: playerName !== "all" ? playerName : undefined,
|
player_name: playerName !== "all" ? playerName : undefined,
|
||||||
@@ -38,7 +42,7 @@ export function App() {
|
|||||||
const interval = setInterval(loadData, 60000);
|
const interval = setInterval(loadData, 60000);
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [timestampFrom, timestampTo, playerName, limit]);
|
}, [timestampFrom, timestampTo, playerName, limit, mapCenter, mapRadius]);
|
||||||
|
|
||||||
// Extract unique player names from data
|
// Extract unique player names from data
|
||||||
const players = useMemo(() => {
|
const players = useMemo(() => {
|
||||||
@@ -95,7 +99,11 @@ export function App() {
|
|||||||
<p>View player activity on the map</p>
|
<p>View player activity on the map</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="map-wrapper">
|
<div className="map-wrapper">
|
||||||
<PlayerMap plexts={data.plexts} />
|
<PlayerMap
|
||||||
|
plexts={data.plexts}
|
||||||
|
onCenterChange={setMapCenter}
|
||||||
|
onRadiusChange={setMapRadius}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { ApiResponse } from "./types";
|
import type { ApiResponse } from "./types";
|
||||||
|
|
||||||
export async function fetchPlexts(base_url: string, endpoint: string, creds: string, params?: {
|
export async function fetchPlexts(base_url: string, endpoint: string, creds: string, params?: {
|
||||||
|
center?: [number, number]; // [lat, lng]
|
||||||
|
radius?: number; // metri
|
||||||
timestamp_from?: number;
|
timestamp_from?: number;
|
||||||
timestamp_to?: number;
|
timestamp_to?: number;
|
||||||
player_name?: string;
|
player_name?: string;
|
||||||
@@ -11,6 +13,12 @@ export async function fetchPlexts(base_url: string, endpoint: string, creds: str
|
|||||||
|
|
||||||
// Build query string
|
// Build query string
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
|
if (params?.center !== undefined) {
|
||||||
|
searchParams.append("center", `${params.center[0]},${params.center[1]}`);
|
||||||
|
}
|
||||||
|
if (params?.radius !== undefined) {
|
||||||
|
searchParams.append("radius", params.radius.toString());
|
||||||
|
}
|
||||||
if (params?.timestamp_from !== undefined) {
|
if (params?.timestamp_from !== undefined) {
|
||||||
searchParams.append("timestamp_from", params.timestamp_from.toString());
|
searchParams.append("timestamp_from", params.timestamp_from.toString());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import "leaflet/dist/leaflet.css";
|
|||||||
import "leaflet.markercluster/dist/MarkerCluster.css";
|
import "leaflet.markercluster/dist/MarkerCluster.css";
|
||||||
import "leaflet.markercluster/dist/MarkerCluster.Default.css";
|
import "leaflet.markercluster/dist/MarkerCluster.Default.css";
|
||||||
import type { Plext } from "../types";
|
import type { Plext } from "../types";
|
||||||
|
import { useMapGeolocation } from "../hooks/useMapGeolocation";
|
||||||
|
import { useMapBounds } from "../hooks/useMapBounds";
|
||||||
|
|
||||||
// Fix Leaflet marker icons
|
// Fix Leaflet marker icons
|
||||||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||||
@@ -16,6 +18,8 @@ L.Icon.Default.mergeOptions({
|
|||||||
|
|
||||||
interface PlayerMapProps {
|
interface PlayerMapProps {
|
||||||
plexts: Plext[];
|
plexts: Plext[];
|
||||||
|
onCenterChange?: (center: [number, number]) => void;
|
||||||
|
onRadiusChange?: (radius: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,7 +55,20 @@ const createClusterIcon = (cluster: any): L.DivIcon => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PlayerMap({ plexts }: PlayerMapProps) {
|
/**
|
||||||
|
* Internal component that uses the map hooks
|
||||||
|
* This component must be a child of MapContainer to access the map instance
|
||||||
|
*/
|
||||||
|
function MapContent({ onCenterChange, onRadiusChange }: {
|
||||||
|
onCenterChange?: (center: [number, number]) => void;
|
||||||
|
onRadiusChange?: (radius: number) => void;
|
||||||
|
}) {
|
||||||
|
useMapGeolocation({ zoom: 13 });
|
||||||
|
useMapBounds({ onCenterChange, onRadiusChange, debounceMs: 500 });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlayerMap({ plexts, onCenterChange, onRadiusChange }: PlayerMapProps) {
|
||||||
// Get player locations from plexts
|
// Get player locations from plexts
|
||||||
const playerLocations = plexts
|
const playerLocations = plexts
|
||||||
.map((plext) => {
|
.map((plext) => {
|
||||||
@@ -77,6 +94,7 @@ export function PlayerMap({ plexts }: PlayerMapProps) {
|
|||||||
style={{ height: "100%", width: "100%" }}
|
style={{ height: "100%", width: "100%" }}
|
||||||
scrollWheelZoom={true}
|
scrollWheelZoom={true}
|
||||||
>
|
>
|
||||||
|
<MapContent onCenterChange={onCenterChange} onRadiusChange={onRadiusChange} />
|
||||||
<TileLayer
|
<TileLayer
|
||||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
|||||||
110
src/hooks/useMapBounds.ts
Normal file
110
src/hooks/useMapBounds.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useMap } from "react-leaflet";
|
||||||
|
import L from "leaflet";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for the useMapBounds hook
|
||||||
|
*/
|
||||||
|
interface UseMapBoundsOptions {
|
||||||
|
/** Callback invoked when the map center changes */
|
||||||
|
onCenterChange?: (center: [number, number]) => void;
|
||||||
|
/** Callback invoked when the map radius changes */
|
||||||
|
onRadiusChange?: (radius: number) => void;
|
||||||
|
/** Debounce delay in milliseconds before invoking callbacks */
|
||||||
|
debounceMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the radius in meters from the map bounds
|
||||||
|
* The radius is the distance from the center to the edge of the viewport
|
||||||
|
*
|
||||||
|
* @param map - The Leaflet map instance
|
||||||
|
* @returns The radius in meters
|
||||||
|
*/
|
||||||
|
function calculateRadiusFromBounds(map: L.Map): number {
|
||||||
|
const bounds = map.getBounds();
|
||||||
|
const center = bounds.getCenter();
|
||||||
|
const northEast = bounds.getNorthEast();
|
||||||
|
|
||||||
|
// Calculate the distance from the center to the north-east edge
|
||||||
|
const radius = center.distanceTo(northEast);
|
||||||
|
|
||||||
|
return Math.round(radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to track map bounds and calculate center and radius
|
||||||
|
*
|
||||||
|
* This hook monitors the map's viewport and calculates:
|
||||||
|
* - The center coordinates [lat, lng]
|
||||||
|
* - The radius in meters (distance from center to viewport edge)
|
||||||
|
*
|
||||||
|
* The calculations are debounced to avoid excessive API calls during
|
||||||
|
* map panning and zooming.
|
||||||
|
*
|
||||||
|
* @param options - Configuration options for bounds tracking
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* function MapContent() {
|
||||||
|
* const [center, setCenter] = useState<[number, number]>([45.57, 12.36]);
|
||||||
|
* const [radius, setRadius] = useState<number>(10000);
|
||||||
|
*
|
||||||
|
* useMapBounds({
|
||||||
|
* onCenterChange: setCenter,
|
||||||
|
* onRadiusChange: setRadius,
|
||||||
|
* debounceMs: 500,
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* return null;
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useMapBounds(options: UseMapBoundsOptions = {}) {
|
||||||
|
const map = useMap();
|
||||||
|
const { onCenterChange, onRadiusChange, debounceMs = 500 } = options;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timeoutId: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
const updateBounds = () => {
|
||||||
|
const center = map.getCenter();
|
||||||
|
const radius = calculateRadiusFromBounds(map);
|
||||||
|
|
||||||
|
if (onCenterChange) {
|
||||||
|
onCenterChange([center.lat, center.lng]);
|
||||||
|
}
|
||||||
|
if (onRadiusChange) {
|
||||||
|
onRadiusChange(radius);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMoveEnd = () => {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
timeoutId = setTimeout(updateBounds, debounceMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onZoomEnd = () => {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
timeoutId = setTimeout(updateBounds, debounceMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate initial bounds
|
||||||
|
updateBounds();
|
||||||
|
|
||||||
|
map.on("moveend", onMoveEnd);
|
||||||
|
map.on("zoomend", onZoomEnd);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
map.off("moveend", onMoveEnd);
|
||||||
|
map.off("zoomend", onZoomEnd);
|
||||||
|
};
|
||||||
|
}, [map, onCenterChange, onRadiusChange, debounceMs]);
|
||||||
|
}
|
||||||
73
src/hooks/useMapGeolocation.ts
Normal file
73
src/hooks/useMapGeolocation.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useMap } from "react-leaflet";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for the useMapGeolocation hook
|
||||||
|
*/
|
||||||
|
interface UseMapGeolocationOptions {
|
||||||
|
/** Enable high accuracy mode for geolocation */
|
||||||
|
enableHighAccuracy?: boolean;
|
||||||
|
/** Timeout in milliseconds before giving up on geolocation */
|
||||||
|
timeout?: number;
|
||||||
|
/** Maximum age of cached position in milliseconds */
|
||||||
|
maximumAge?: number;
|
||||||
|
/** Zoom level to set when location is found */
|
||||||
|
zoom?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to geolocate the user and center the map on their position
|
||||||
|
*
|
||||||
|
* This hook uses Leaflet's built-in locate() method to get the user's
|
||||||
|
* current position and automatically centers the map on it.
|
||||||
|
* The geolocation is performed only once on mount.
|
||||||
|
*
|
||||||
|
* @param options - Configuration options for geolocation
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* function MapContent() {
|
||||||
|
* useMapGeolocation({ zoom: 13 });
|
||||||
|
* return null;
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useMapGeolocation(options: UseMapGeolocationOptions = {}) {
|
||||||
|
const map = useMap();
|
||||||
|
const hasLocated = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Only locate once
|
||||||
|
if (hasLocated.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasLocated.current = true;
|
||||||
|
|
||||||
|
map.locate({
|
||||||
|
setView: true,
|
||||||
|
maxZoom: options.zoom || 13,
|
||||||
|
enableHighAccuracy: options.enableHighAccuracy ?? true,
|
||||||
|
timeout: options.timeout || 10000,
|
||||||
|
maximumAge: options.maximumAge || 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onLocationFound = (e: any) => {
|
||||||
|
console.log("Location found:", e.latlng);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onLocationError = (e: any) => {
|
||||||
|
console.error("Location error:", e.message);
|
||||||
|
// Fallback to default location (Veneto region)
|
||||||
|
map.setView([45.57, 12.36], 12);
|
||||||
|
};
|
||||||
|
|
||||||
|
map.on("locationfound", onLocationFound);
|
||||||
|
map.on("locationerror", onLocationError);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.off("locationfound", onLocationFound);
|
||||||
|
map.off("locationerror", onLocationError);
|
||||||
|
};
|
||||||
|
}, [map]);
|
||||||
|
}
|
||||||
10
src/types.ts
10
src/types.ts
@@ -57,3 +57,13 @@ export interface TeamStats {
|
|||||||
ENLIGHTENED: number;
|
ENLIGHTENED: number;
|
||||||
NEUTRAL: number;
|
NEUTRAL: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MapCenter {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapBoundsParams {
|
||||||
|
center: [number, number]; // [lat, lng]
|
||||||
|
radius: number; // metri
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user