# Volunteer Map Page ## Overview **File Path:** `admin/src/pages/volunteer/VolunteerMapPage.tsx` (570 lines) **Route:** `/volunteer/canvass/:cutId` **Role Requirements:** Authenticated users (USER or TEMP role) **Purpose:** Full-screen GPS-enabled canvassing map for door-to-door volunteer work with visit recording, route navigation, location management, and session tracking. **Key Features:** - Full-screen map (no AppLayout wrapper) - Real-time GPS tracking with following mode - Canvass session management (start/end with auto-pause) - Walking route display with toggle - CanvassMarkerGroup for clustered address display - VisitRecordingForm in bottom drawer - AddLocationDrawer (crosshair + tap to add) - VolunteerMapDrawer (menu, stats, session picker) - VolunteerFooterNav (bottom navigation bar) - VolunteerSessionBar (active session indicator above footer) - TileLayerToggle (OpenStreetMap, CARTO, Satellite) - AddressSearchOverlay - Next door button (finds nearest unvisited location) - Cut polygon overlays with toggle controls - Admin edit mode (LocationEditDrawer for MAP_ADMIN users) - Tracking integration (links to GPS tracking sessions) **Layout:** No layout wrapper - full viewport with custom overlays --- ## Features ### 1. GPS Tracking Real-time position tracking with following mode: **Components:** - `GPSTracker` component (useEffect with watchPosition) - Blue pulsing circle marker at user's position - Accuracy circle (outer ring) - GPS path polyline (breadcrumb trail) - Follow mode toggle (auto-pan to user) **Code:** ```tsx { setUserPosition(position); if (followMode) { map.panTo(position.coords); } // Track position for session trackPosition(position); }} onError={(error) => { message.error('GPS unavailable: ' + error.message); }} /> ``` ### 2. Session Management Canvass session lifecycle: **States:** - ACTIVE: Session in progress - PAUSED: Session paused (GPS stopped) - ENDED: Session completed **Controls:** - Start Session button (green) - Pause Session button (yellow) - End Session button (red, with confirmation) - Auto-pause after 30min inactivity **Session Bar:** ```tsx setEndModalVisible(true)} style={{ position: 'fixed', bottom: 60, left: 0, right: 0, zIndex: 1000 }} /> ``` ### 3. Walking Route Display Optimized door-to-door route: **Algorithm:** - Nearest neighbor with deduplication - Starts from user's current position - Visits unvisited locations in order - Avoids backtracking **Visual:** - Blue polyline connecting locations - Dashed line style - Toggle button to show/hide - Route recalculates when locations visited **Code:** ```tsx {routeVisible && walkingRoute && ( )} ``` ### 4. Canvass Markers Location markers with clustering: **CanvassMarkerGroup Component:** - Clusters nearby markers (radius: 50px) - Color-coded by support level - Click to open VisitRecordingForm - Shows last visit outcome if visited - Purple markers for multi-unit buildings **Marker States:** - Unvisited: Gray circle - Visited: Color-coded by outcome - Selected: Larger radius + pulsing animation ### 5. Visit Recording Form Bottom drawer for recording visits: **Fields:** - Address (read-only, pre-filled) - Outcome (dropdown: 7 options) - Notes (TextArea, optional) - Contact Interest (checkbox) - Follow-up Required (checkbox) **Outcome Options:** 1. Strong Support 2. Leaning Support 3. Undecided 4. Leaning Opposed 5. Opposed 6. No Answer 7. Not Home **Submission:** - Creates CanvassVisit record - Updates location supportLevel - Closes drawer - Marker updates color - Next door button finds new nearest **Code:** ```tsx setRecordingDrawerVisible(false)} onSubmit={handleVisitSubmit} /> ``` ### 6. Add Location Mode Crosshair interface for adding missing addresses: **Activation:** - "Add Location" button in menu - Opens AddLocationDrawer - Crosshair appears at map center - User pans map to position crosshair - "Tap Here to Add" button **AddLocationDrawer:** - Address input (with geocoding suggestion) - Unit number (for multi-unit buildings) - Notes - Cancel / Confirm buttons **Code:** ```tsx setAddLocationMode(false)} /> ``` ### 7. Map Controls Floating control panels: **VolunteerMapDrawer (left side):** - Menu button (hamburger) - Session stats (visits today, doors knocked) - Session picker dropdown - Tile layer toggle - Cut overlays toggle - Address search - Add location button - End session button **Control Buttons (right side):** - Geolocate (find my location) - Toggle walking route - Next door (find nearest unvisited) - Fullscreen toggle ### 8. Tile Layer Toggle Three basemap options: 1. **OpenStreetMap**: Default, detailed streets 2. **CARTO Dark**: High contrast, good for day/night 3. **Satellite**: Aerial imagery from Esri **Component:** ```tsx ``` ### 9. Address Search Overlay Quick location lookup: **Features:** - Input with search icon - Autocomplete from locations in cut - Fly to location on select - Opens visit recording form **Code:** ```tsx { map.flyTo([location.latitude, location.longitude], 18); setSelectedLocation(location); setRecordingDrawerVisible(true); }} /> ``` ### 10. Next Door Button Intelligent location finder: **Algorithm:** 1. Filter unvisited locations in cut 2. Calculate distance from user position 3. Sort by distance (haversine) 4. Select nearest 5. Pan map and open recording form **Code:** ```tsx const handleNextDoor = () => { const unvisited = locations.filter(loc => !visits.some(v => v.locationId === loc.id) ); if (unvisited.length === 0) { message.info('All locations visited!'); return; } const nearest = unvisited.reduce((prev, curr) => { const prevDist = haversineDistance(userPosition, prev); const currDist = haversineDistance(userPosition, curr); return currDist < prevDist ? curr : prev; }); map.flyTo([nearest.latitude, nearest.longitude], 18); setSelectedLocation(nearest); setRecordingDrawerVisible(true); }; ``` ### 11. Admin Edit Mode MAP_ADMIN users can edit locations: **Features:** - Edit button on location popup - LocationEditDrawer with full form - Update address, support level, notes - Delete location (with confirmation) - Move location (drag marker) **Conditional Render:** ```tsx {user?.role === 'MAP_ADMIN' && ( )} ``` ### 12. Cut Overlay Toggle Show/hide cut boundaries: **Component:** ```tsx { setVisibleCuts(prev => { const next = new Set(prev); if (next.has(cutId)) { next.delete(cutId); } else { next.add(cutId); } return next; }); }} position="bottomleft" /> ``` --- ## User Workflow ### Starting a Canvass Session 1. Volunteer navigates to `/volunteer/canvass/:cutId` 2. Map loads centered on cut bounds 3. Locations load within cut 4. Volunteer clicks "Start Session" in drawer 5. GPS tracking activates 6. Session bar appears at bottom (above footer) 7. Walking route calculates and displays 8. User position marker appears and updates ### Recording a Visit 1. Volunteer walks to address 2. Clicks marker or uses "Next Door" button 3. VisitRecordingForm opens in bottom drawer 4. Volunteer selects outcome from dropdown 5. Volunteer adds notes (optional) 6. Volunteer checks "Follow-up Required" if needed 7. Volunteer clicks "Save Visit" 8. API creates CanvassVisit record 9. Marker updates to color-coded outcome 10. Drawer closes 11. Walking route recalculates ### Adding a Missing Location 1. Volunteer encounters unlisted address 2. Opens menu drawer 3. Clicks "Add Location" 4. Crosshair appears at map center 5. Volunteer pans map to position crosshair over address 6. Clicks "Tap Here to Add" 7. AddLocationDrawer opens 8. Volunteer enters address 9. Volunteer clicks "Confirm" 10. API creates Location record 11. New marker appears on map 12. Volunteer can immediately record visit ### Finding Next Door 1. Volunteer finishes current visit 2. Clicks "Next Door" button (right side) 3. Algorithm finds nearest unvisited location 4. Map animates (flyTo) to location 5. VisitRecordingForm opens automatically 6. Volunteer records visit 7. Repeats process ### Ending Session 1. Volunteer clicks "End Session" in drawer 2. Confirmation modal appears 3. Modal shows session stats (duration, visits, distance) 4. Volunteer clicks "End Session" confirm button 5. GPS tracking stops 6. Session marked as ENDED in database 7. Session bar disappears 8. Volunteer can start new session or navigate away --- ## Component Structure ```tsx import React, { useState, useEffect, useCallback } from 'react'; import { useParams } from 'react-router-dom'; import { MapContainer, TileLayer, useMap } from 'react-leaflet'; import { Button, message, Modal } from 'antd'; import { AimOutlined, PlusOutlined, ArrowRightOutlined, FullscreenOutlined } from '@ant-design/icons'; import GPSTracker from '../../components/canvass/GPSTracker'; import CanvassMarkerGroup from '../../components/canvass/CanvassMarkerGroup'; import WalkingRouteLine from '../../components/canvass/WalkingRouteLine'; import VisitRecordingForm from '../../components/canvass/VisitRecordingForm'; import AddLocationDrawer from '../../components/canvass/AddLocationDrawer'; import VolunteerMapDrawer from '../../components/canvass/VolunteerMapDrawer'; import VolunteerFooterNav from '../../components/canvass/VolunteerFooterNav'; import VolunteerSessionBar from '../../components/canvass/VolunteerSessionBar'; import { useCanvassStore } from '../../stores/canvass.store'; import { api } from '../../lib/api'; import 'leaflet/dist/leaflet.css'; const VolunteerMapPage: React.FC = () => { const { cutId } = useParams<{ cutId: string }>(); const [map, setMap] = useState(null); // Canvass store const { activeSession, locations, visits, walkingRoute, userPosition, setActiveSession, addVisit, updateLocation, setUserPosition } = useCanvassStore(); // UI state const [recordingDrawerVisible, setRecordingDrawerVisible] = useState(false); const [selectedLocation, setSelectedLocation] = useState(null); const [addLocationMode, setAddLocationMode] = useState(false); const [drawerVisible, setDrawerVisible] = useState(false); const [routeVisible, setRouteVisible] = useState(true); const [followMode, setFollowMode] = useState(true); // Fetch locations in cut useEffect(() => { const fetchLocations = async () => { try { const response = await api.get(`/api/map/canvass/locations/${cutId}`); // Store in Zustand } catch (error) { message.error('Failed to load locations'); } }; fetchLocations(); }, [cutId]); return (
{routeVisible && walkingRoute && ( )} setDrawerVisible(false)} onStartSession={handleStartSession} onEndSession={() => setEndModalVisible(true)} onAddLocation={() => setAddLocationMode(true)} session={activeSession} stats={sessionStats} /> setRecordingDrawerVisible(false)} onSubmit={handleVisitSubmit} /> setAddLocationMode(false)} /> {activeSession && ( setEndModalVisible(true)} /> )} {/* Floating controls */}
); }; export default VolunteerMapPage; ``` --- ## State Management ### Zustand Store (canvass.store.ts) ```typescript interface CanvassState { // Session activeSession: CanvassSession | null; setActiveSession: (session: CanvassSession | null) => void; // Locations locations: CanvassLocation[]; setLocations: (locations: CanvassLocation[]) => void; updateLocation: (id: string, updates: Partial) => void; // Visits visits: CanvassVisit[]; addVisit: (visit: CanvassVisit) => void; // Route walkingRoute: WalkingRoute | null; calculateRoute: () => void; // GPS userPosition: GPSPosition | null; setUserPosition: (position: GPSPosition) => void; gpsPath: GPSPosition[]; addGPSPoint: (position: GPSPosition) => void; } export const useCanvassStore = create((set, get) => ({ activeSession: null, locations: [], visits: [], walkingRoute: null, userPosition: null, gpsPath: [], setActiveSession: (session) => { set({ activeSession: session }); if (session) { get().calculateRoute(); } }, addVisit: (visit) => { set((state) => ({ visits: [...state.visits, visit] })); get().calculateRoute(); // Recalculate after visit }, calculateRoute: () => { const { locations, visits, userPosition } = get(); if (!userPosition) return; const unvisited = locations.filter(loc => !visits.some(v => v.locationId === loc.id) ); const route = calculateWalkingRoute(userPosition, unvisited); set({ walkingRoute: route }); } })); ``` ### Component State ```tsx // UI state const [recordingDrawerVisible, setRecordingDrawerVisible] = useState(false); const [selectedLocation, setSelectedLocation] = useState(null); const [addLocationMode, setAddLocationMode] = useState(false); const [drawerVisible, setDrawerVisible] = useState(false); const [endModalVisible, setEndModalVisible] = useState(false); // Map state const [map, setMap] = useState(null); const [routeVisible, setRouteVisible] = useState(true); const [followMode, setFollowMode] = useState(true); const [activeLayer, setActiveLayer] = useState<'osm' | 'carto' | 'satellite'>('osm'); ``` --- ## API Integration ### Endpoints #### 1. Get Locations in Cut ```http GET /api/map/canvass/locations/:cutId Authorization: Bearer {token} ``` Response: ```json [ { "id": "cm1loc123", "address": "123 Main St", "latitude": 45.5017, "longitude": -73.5673, "supportLevel": null, "lastVisitDate": null, "isMultiUnit": false } ] ``` #### 2. Start Session ```http POST /api/map/canvass/sessions/start Authorization: Bearer {token} Content-Type: application/json { "cutId": "cm1cut123" } ``` Response: ```json { "sessionId": "cm2session456", "startTime": "2025-02-12T10:00:00.000Z", "status": "ACTIVE" } ``` #### 3. Record Visit ```http POST /api/map/canvass/visits Authorization: Bearer {token} Content-Type: application/json { "sessionId": "cm2session456", "locationId": "cm1loc123", "outcome": "strong_support", "notes": "Very enthusiastic, requested yard sign", "contactInterested": true, "followUpRequired": false } ``` Response: ```json { "visitId": "cm3visit789", "createdAt": "2025-02-12T10:15:00.000Z" } ``` #### 4. Track GPS Position ```http POST /api/map/canvass/sessions/:sessionId/track Authorization: Bearer {token} Content-Type: application/json { "latitude": 45.5017, "longitude": -73.5673, "accuracy": 12.5, "timestamp": "2025-02-12T10:15:30.000Z" } ``` #### 5. Add Location ```http POST /api/map/locations Authorization: Bearer {token} Content-Type: application/json { "address": "125 Main St", "latitude": 45.5018, "longitude": -73.5672, "cutId": "cm1cut123", "notes": "Added during canvass" } ``` #### 6. End Session ```http POST /api/map/canvass/sessions/:sessionId/end Authorization: Bearer {token} ``` Response: ```json { "sessionId": "cm2session456", "endTime": "2025-02-12T12:00:00.000Z", "duration": 7200, "visitCount": 23, "distance": 2834 } ``` --- ## Code Examples ### GPS Tracker Component ```tsx // components/canvass/GPSTracker.tsx const GPSTracker: React.FC<{ enabled: boolean; onPositionUpdate: (position: GeolocationPosition) => void; onError?: (error: GeolocationPositionError) => void; }> = ({ enabled, onPositionUpdate, onError }) => { useEffect(() => { if (!enabled || !navigator.geolocation) return; const watchId = navigator.geolocation.watchPosition( onPositionUpdate, onError, { enableHighAccuracy: true, maximumAge: 5000, timeout: 10000 } ); return () => navigator.geolocation.clearWatch(watchId); }, [enabled, onPositionUpdate, onError]); return null; }; ``` ### Walking Route Calculation ```typescript // utils/walking-route.ts export const calculateWalkingRoute = ( start: GPSPosition, locations: CanvassLocation[] ): WalkingRoute => { const unvisited = [...locations]; const route: CanvassLocation[] = []; let current = { latitude: start.latitude, longitude: start.longitude }; // Nearest neighbor algorithm while (unvisited.length > 0) { let nearestIdx = 0; let nearestDist = Infinity; unvisited.forEach((loc, idx) => { const dist = haversineDistance(current, loc); if (dist < nearestDist) { nearestDist = dist; nearestIdx = idx; } }); const nearest = unvisited.splice(nearestIdx, 1)[0]; route.push(nearest); current = nearest; } return { locations: route, totalDistance: calculateTotalDistance(route) }; }; const haversineDistance = ( a: { latitude: number; longitude: number }, b: { latitude: number; longitude: number } ): number => { const R = 6371e3; // Earth radius in meters const φ1 = (a.latitude * Math.PI) / 180; const φ2 = (b.latitude * Math.PI) / 180; const Δφ = ((b.latitude - a.latitude) * Math.PI) / 180; const Δλ = ((b.longitude - a.longitude) * Math.PI) / 180; const a1 = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2); const c = 2 * Math.atan2(Math.sqrt(a1), Math.sqrt(1 - a1)); return R * c; // Distance in meters }; ``` ### Canvass Marker Group ```tsx // components/canvass/CanvassMarkerGroup.tsx const CanvassMarkerGroup: React.FC<{ locations: CanvassLocation[]; visits: CanvassVisit[]; onMarkerClick: (location: CanvassLocation) => void; }> = ({ locations, visits, onMarkerClick }) => { const getMarkerColor = (location: CanvassLocation) => { const visit = visits.find(v => v.locationId === location.id); if (!visit) return '#8c8c8c'; // Unvisited switch (visit.outcome) { case 'strong_support': return '#52c41a'; case 'leaning_support': return '#95de64'; case 'undecided': return '#fadb14'; case 'leaning_opposed': return '#ff7a45'; case 'opposed': return '#f5222d'; case 'no_answer': return '#8c8c8c'; case 'not_home': return '#d9d9d9'; default: return '#8c8c8c'; } }; return ( <> {locations.map(location => ( onMarkerClick(location) }} > {location.address} {location.unitNumber && ( <>
Unit: {location.unitNumber} )}
))} ); }; ``` --- ## Performance Considerations 1. **Zustand Store**: Global state prevents prop drilling 2. **Debounced GPS**: Position tracked every 5 seconds (not every update) 3. **Route Recalc**: Only recalculates when visits added 4. **Marker Clustering**: Reduces DOM nodes on dense maps 5. **Lazy Drawers**: Components mount only when opened --- ## Responsive Design - **Mobile-First**: Designed for phones (primary use case) - **Touch Gestures**: Native Leaflet touch support - **Bottom Drawers**: Accessible with thumb - **Large Touch Targets**: All buttons 44px+ minimum - **Portrait Orientation**: Optimized for vertical screens --- ## Accessibility - **GPS Feedback**: Audible alerts for position updates (optional) - **High Contrast**: CARTO Dark mode for low light - **Large Text**: All UI text 14px minimum - **Voice Input**: Notes field supports speech-to-text (browser) --- ## Troubleshooting ### Issue: GPS Not Working **Causes:** 1. HTTPS required (geolocation API restriction) 2. User denied permission 3. GPS unavailable (indoors, bad signal) **Solutions:** ```tsx const handleGPSError = (error: GeolocationPositionError) => { switch (error.code) { case error.PERMISSION_DENIED: Modal.error({ title: 'GPS Permission Required', content: 'Please enable location permissions in your browser settings.' }); break; case error.POSITION_UNAVAILABLE: message.warning('GPS unavailable. Try moving outdoors.'); break; case error.TIMEOUT: message.warning('GPS timeout. Check your device settings.'); break; } }; ``` ### Issue: Route Not Displaying **Causes:** 1. No unvisited locations 2. Route calculation error 3. Route toggle off **Solutions:** ```tsx // Add debug logging useEffect(() => { console.log('Route calculation:', { unvisited: locations.filter(l => !visits.some(v => v.locationId === l.id)).length, routeVisible, walkingRoute: walkingRoute?.locations.length }); }, [locations, visits, routeVisible, walkingRoute]); // Show message if no unvisited if (unvisitedCount === 0) { message.success('All locations visited! Great work!'); } ``` ### Issue: Session Not Ending **Causes:** 1. API timeout 2. Pending GPS uploads 3. Network disconnection **Solutions:** ```tsx const handleEndSession = async () => { try { // Upload any pending GPS points first await uploadPendingGPSPoints(); // Then end session await api.post(`/api/map/canvass/sessions/${activeSession.id}/end`, {}, { timeout: 10000 }); message.success('Session ended'); setActiveSession(null); } catch (error: any) { if (error.code === 'ECONNABORTED') { // Force local end if server timeout setActiveSession(null); message.warning('Session ended locally (server unreachable)'); } else { message.error('Failed to end session'); } } }; ``` --- ## Related Documentation - [Canvass System Architecture](../../../architecture/canvass-system.md) - [GPS Tracking](../../../architecture/gps-tracking.md) - [Walking Route Algorithm](../../../architecture/walking-route.md) - [Canvass Dashboard](../admin/canvass-dashboard-page.md) - [My Activity Page](./my-activity-page.md) - [My Routes Page](./my-routes-page.md)