diff --git a/plans/geolocation-and-bounds-implementation.md b/plans/geolocation-and-bounds-implementation.md new file mode 100644 index 0000000..0271647 --- /dev/null +++ b/plans/geolocation-and-bounds-implementation.md @@ -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 +``` + +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 ( +
+ + + + + {/* markers */} + + +
+ ); +} +``` + +### 6. Aggiornamento di `App.tsx` + +Aggiungere lo stato per center e radius e passarlo all'API: + +```typescript +export function App() { + const [data, setData] = useState({ count: 0, plexts: [] }); + const [loading, setLoading] = useState(true); + const [timestampFrom, setTimestampFrom] = useState(null); + const [timestampTo, setTimestampTo] = useState(null); + const [playerName, setPlayerName] = useState("all"); + const [limit, setLimit] = useState(100); + const [mapCenter, setMapCenter] = useState<[number, number]>([45.57, 12.36]); + const [mapRadius, setMapRadius] = useState(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 ( + // ... + + // ... + ); +} +``` + +## 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 diff --git a/src/App.tsx b/src/App.tsx index b054221..e2e2836 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,11 +13,15 @@ export function App() { const [timestampTo, setTimestampTo] = useState(null); const [playerName, setPlayerName] = useState("all"); const [limit, setLimit] = useState(100); + const [mapCenter, setMapCenter] = useState<[number, number]>([45.57, 12.36]); + const [mapRadius, setMapRadius] = useState(10000); // Default 10km // Load data with filters const loadData = async () => { try { const params = { + center: mapCenter, + radius: mapRadius, timestamp_from: timestampFrom ? timestampFrom.getTime() : undefined, // Convert to milliseconds timestamp_to: timestampTo ? timestampTo.getTime() : undefined, // Convert to milliseconds player_name: playerName !== "all" ? playerName : undefined, @@ -38,7 +42,7 @@ export function App() { const interval = setInterval(loadData, 60000); return () => clearInterval(interval); - }, [timestampFrom, timestampTo, playerName, limit]); + }, [timestampFrom, timestampTo, playerName, limit, mapCenter, mapRadius]); // Extract unique player names from data const players = useMemo(() => { @@ -95,7 +99,11 @@ export function App() {

View player activity on the map

- +
diff --git a/src/api.ts b/src/api.ts index 29c7cfe..60f417c 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,6 +1,8 @@ import type { ApiResponse } from "./types"; 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; @@ -11,6 +13,12 @@ export async function fetchPlexts(base_url: string, endpoint: string, creds: str // Build query string 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) { searchParams.append("timestamp_from", params.timestamp_from.toString()); } diff --git a/src/components/PlayerMap.tsx b/src/components/PlayerMap.tsx index b62abcc..eec6971 100644 --- a/src/components/PlayerMap.tsx +++ b/src/components/PlayerMap.tsx @@ -5,6 +5,8 @@ import "leaflet/dist/leaflet.css"; import "leaflet.markercluster/dist/MarkerCluster.css"; import "leaflet.markercluster/dist/MarkerCluster.Default.css"; import type { Plext } from "../types"; +import { useMapGeolocation } from "../hooks/useMapGeolocation"; +import { useMapBounds } from "../hooks/useMapBounds"; // Fix Leaflet marker icons delete (L.Icon.Default.prototype as any)._getIconUrl; @@ -16,6 +18,8 @@ L.Icon.Default.mergeOptions({ interface PlayerMapProps { 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 const playerLocations = plexts .map((plext) => { @@ -77,6 +94,7 @@ export function PlayerMap({ plexts }: PlayerMapProps) { style={{ height: "100%", width: "100%" }} scrollWheelZoom={true} > + 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(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]); +} diff --git a/src/hooks/useMapGeolocation.ts b/src/hooks/useMapGeolocation.ts new file mode 100644 index 0000000..52bdf06 --- /dev/null +++ b/src/hooks/useMapGeolocation.ts @@ -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]); +} diff --git a/src/types.ts b/src/types.ts index 4d18324..48c1f61 100644 --- a/src/types.ts +++ b/src/types.ts @@ -57,3 +57,13 @@ export interface TeamStats { ENLIGHTENED: number; NEUTRAL: number; } + +export interface MapCenter { + lat: number; + lng: number; +} + +export interface MapBoundsParams { + center: [number, number]; // [lat, lng] + radius: number; // metri +}