25 KiB
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:
GPSTrackercomponent (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:
<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:
<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:
{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:
- Strong Support
- Leaning Support
- Undecided
- Leaning Opposed
- Opposed
- No Answer
- Not Home
Submission:
- Creates CanvassVisit record
- Updates location supportLevel
- Closes drawer
- Marker updates color
- Next door button finds new nearest
Code:
<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:
<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:
- OpenStreetMap: Default, detailed streets
- CARTO Dark: High contrast, good for day/night
- Satellite: Aerial imagery from Esri
Component:
<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:
<AddressSearchOverlay
locations={locations}
onSelect={(location) => {
map.flyTo([location.latitude, location.longitude], 18);
setSelectedLocation(location);
setRecordingDrawerVisible(true);
}}
/>
10. Next Door Button
Intelligent location finder:
Algorithm:
- Filter unvisited locations in cut
- Calculate distance from user position
- Sort by distance (haversine)
- Select nearest
- Pan map and open recording form
Code:
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:
{user?.role === 'MAP_ADMIN' && (
<Button onClick={() => setEditMode(true)}>
Edit Location
</Button>
)}
12. Cut Overlay Toggle
Show/hide cut boundaries:
Component:
<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
- Volunteer navigates to
/volunteer/canvass/:cutId - Map loads centered on cut bounds
- Locations load within cut
- Volunteer clicks "Start Session" in drawer
- GPS tracking activates
- Session bar appears at bottom (above footer)
- Walking route calculates and displays
- User position marker appears and updates
Recording a Visit
- Volunteer walks to address
- Clicks marker or uses "Next Door" button
- VisitRecordingForm opens in bottom drawer
- Volunteer selects outcome from dropdown
- Volunteer adds notes (optional)
- Volunteer checks "Follow-up Required" if needed
- Volunteer clicks "Save Visit"
- API creates CanvassVisit record
- Marker updates to color-coded outcome
- Drawer closes
- Walking route recalculates
Adding a Missing Location
- Volunteer encounters unlisted address
- Opens menu drawer
- Clicks "Add Location"
- Crosshair appears at map center
- Volunteer pans map to position crosshair over address
- Clicks "Tap Here to Add"
- AddLocationDrawer opens
- Volunteer enters address
- Volunteer clicks "Confirm"
- API creates Location record
- New marker appears on map
- Volunteer can immediately record visit
Finding Next Door
- Volunteer finishes current visit
- Clicks "Next Door" button (right side)
- Algorithm finds nearest unvisited location
- Map animates (flyTo) to location
- VisitRecordingForm opens automatically
- Volunteer records visit
- Repeats process
Ending Session
- Volunteer clicks "End Session" in drawer
- Confirmation modal appears
- Modal shows session stats (duration, visits, distance)
- Volunteer clicks "End Session" confirm button
- GPS tracking stops
- Session marked as ENDED in database
- Session bar disappears
- Volunteer can start new session or navigate away
Component Structure
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)
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
// 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
GET /api/map/canvass/locations/:cutId
Authorization: Bearer {token}
Response:
[
{
"id": "cm1loc123",
"address": "123 Main St",
"latitude": 45.5017,
"longitude": -73.5673,
"supportLevel": null,
"lastVisitDate": null,
"isMultiUnit": false
}
]
2. Start Session
POST /api/map/canvass/sessions/start
Authorization: Bearer {token}
Content-Type: application/json
{
"cutId": "cm1cut123"
}
Response:
{
"sessionId": "cm2session456",
"startTime": "2025-02-12T10:00:00.000Z",
"status": "ACTIVE"
}
3. Record Visit
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:
{
"visitId": "cm3visit789",
"createdAt": "2025-02-12T10:15:00.000Z"
}
4. Track GPS Position
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
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
POST /api/map/canvass/sessions/:sessionId/end
Authorization: Bearer {token}
Response:
{
"sessionId": "cm2session456",
"endTime": "2025-02-12T12:00:00.000Z",
"duration": 7200,
"visitCount": 23,
"distance": 2834
}
Code Examples
GPS Tracker Component
// 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
// 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
// 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
- Zustand Store: Global state prevents prop drilling
- Debounced GPS: Position tracked every 5 seconds (not every update)
- Route Recalc: Only recalculates when visits added
- Marker Clustering: Reduces DOM nodes on dense maps
- 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:
- HTTPS required (geolocation API restriction)
- User denied permission
- GPS unavailable (indoors, bad signal)
Solutions:
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:
- No unvisited locations
- Route calculation error
- Route toggle off
Solutions:
// 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:
- API timeout
- Pending GPS uploads
- Network disconnection
Solutions:
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');
}
}
};