1041 lines
25 KiB
Markdown

# 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
<GPSTracker
enabled={sessionActive}
onPositionUpdate={(position) => {
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
<VolunteerSessionBar
session={activeSession}
onPause={handlePauseSession}
onEnd={() => 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 && (
<WalkingRouteLine
route={walkingRoute}
userPosition={userPosition}
/>
)}
```
### 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
<VisitRecordingForm
location={selectedLocation}
sessionId={activeSession?.id}
visible={recordingDrawerVisible}
onClose={() => 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
<AddLocationDrawer
visible={addLocationMode}
position={map.getCenter()}
onConfirm={handleAddLocation}
onCancel={() => 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
<TileLayerToggle
activeLayer={activeLayer}
onChange={setActiveLayer}
position="topright"
/>
```
### 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
<AddressSearchOverlay
locations={locations}
onSelect={(location) => {
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' && (
<Button onClick={() => setEditMode(true)}>
Edit Location
</Button>
)}
```
### 12. Cut Overlay Toggle
Show/hide cut boundaries:
**Component:**
```tsx
<CutOverlayControls
cuts={cuts}
visibleCuts={visibleCuts}
onToggle={(cutId) => {
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<L.Map | null>(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<Location | null>(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 (
<div style={{ height: '100vh', width: '100vw', position: 'relative' }}>
<MapContainer
center={[45.5017, -73.5673]}
zoom={16}
zoomControl={false}
style={{ height: '100%', width: '100%' }}
whenCreated={setMap}
>
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='OSM'
/>
<GPSTracker
enabled={!!activeSession}
onPositionUpdate={handlePositionUpdate}
/>
<CanvassMarkerGroup
locations={locations}
visits={visits}
onMarkerClick={handleMarkerClick}
/>
{routeVisible && walkingRoute && (
<WalkingRouteLine
route={walkingRoute}
userPosition={userPosition}
/>
)}
</MapContainer>
<VolunteerMapDrawer
visible={drawerVisible}
onClose={() => setDrawerVisible(false)}
onStartSession={handleStartSession}
onEndSession={() => setEndModalVisible(true)}
onAddLocation={() => setAddLocationMode(true)}
session={activeSession}
stats={sessionStats}
/>
<VisitRecordingForm
location={selectedLocation}
sessionId={activeSession?.id}
visible={recordingDrawerVisible}
onClose={() => setRecordingDrawerVisible(false)}
onSubmit={handleVisitSubmit}
/>
<AddLocationDrawer
visible={addLocationMode}
position={map?.getCenter()}
onConfirm={handleAddLocation}
onCancel={() => setAddLocationMode(false)}
/>
{activeSession && (
<VolunteerSessionBar
session={activeSession}
onPause={handlePause}
onEnd={() => setEndModalVisible(true)}
/>
)}
<VolunteerFooterNav activeKey="canvass" />
{/* Floating controls */}
<div style={{ position: 'absolute', right: 16, top: 16, zIndex: 1000 }}>
<Button
icon={<AimOutlined />}
onClick={handleGeolocate}
size="large"
style={{ display: 'block', marginBottom: 8 }}
/>
<Button
icon={<ArrowRightOutlined />}
onClick={handleNextDoor}
size="large"
/>
</div>
</div>
);
};
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<CanvassLocation>) => 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<CanvassState>((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<Location | null>(null);
const [addLocationMode, setAddLocationMode] = useState(false);
const [drawerVisible, setDrawerVisible] = useState(false);
const [endModalVisible, setEndModalVisible] = useState(false);
// Map state
const [map, setMap] = useState<L.Map | null>(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 => (
<CircleMarker
key={location.id}
center={[location.latitude, location.longitude]}
radius={10}
pathOptions={{
color: 'white',
weight: 2,
fillColor: getMarkerColor(location),
fillOpacity: 0.8
}}
eventHandlers={{
click: () => onMarkerClick(location)
}}
>
<Popup>
<Text strong>{location.address}</Text>
{location.unitNumber && (
<>
<br />
<Text>Unit: {location.unitNumber}</Text>
</>
)}
</Popup>
</CircleMarker>
))}
</>
);
};
```
---
## 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)