1041 lines
25 KiB
Markdown
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)
|