410 lines
12 KiB
Markdown
410 lines
12 KiB
Markdown
# GPS Tracking System
|
|
|
|
## Overview
|
|
|
|
The GPS tracking system provides real-time volunteer location monitoring with breadcrumb trail recording, distance calculation, and route visualization. It integrates with canvassing sessions for field organizing oversight and volunteer safety.
|
|
|
|
**Key Capabilities:**
|
|
|
|
- **Live Tracking**: Real-time volunteer GPS positions
|
|
- **Breadcrumb Trails**: Auto-record GPS points every 10 seconds
|
|
- **Distance Calculation**: Haversine formula for accurate walking distance
|
|
- **Event Markers**: Mark key events (session start, visits, session end)
|
|
- **Route Visualization**: Leaflet polyline with color-coded event markers
|
|
- **1:1 Canvass Link**: Each TrackingSession linked to one CanvassSession
|
|
- **Admin Oversight**: View live volunteer positions on map
|
|
- **Privacy Controls**: Tracking only during active canvass sessions
|
|
|
|
## Architecture
|
|
|
|
```mermaid
|
|
graph TD
|
|
A[Volunteer GPS] -->|watchPosition| B[GPSTracker Component]
|
|
B -->|Buffer Points| C[Local Storage]
|
|
C -->|Submit Every 10s| D[POST /api/map/tracking/sessions/:id/points]
|
|
D -->|Batch Insert| E[Tracking Service]
|
|
E -->|Save Points| F[(TrackPoint Model)]
|
|
E -->|Calculate Distance| G[Haversine Formula]
|
|
G -->|Update Session| H[(TrackingSession Model)]
|
|
|
|
I[Canvass Session] -->|Start| J[Canvass Service]
|
|
J -->|Create 1:1| E
|
|
E -->|Create| H
|
|
|
|
K[Admin] -->|View Live Map| L[CanvassDashboardPage]
|
|
L -->|GET /api/map/tracking/admin/live| E
|
|
E -->|Query Active| H
|
|
E -->|Return Positions| L
|
|
|
|
M[Volunteer] -->|View Route History| N[MyRoutesPage]
|
|
N -->|GET /api/map/tracking/sessions/:id/route| E
|
|
E -->|Query Points| F
|
|
E -->|Generate Polyline| N
|
|
|
|
H -->|1:1| I
|
|
H -->|1:N| F
|
|
|
|
style H fill:#e1f5ff
|
|
style F fill:#e1f5ff
|
|
```
|
|
|
|
**Flow Description:**
|
|
|
|
1. **Canvass session starts** → Create TrackingSession linked 1:1
|
|
2. **GPS auto-tracking** → watchPosition submits points every 10s
|
|
3. **Distance calculation** → Haversine formula calculates incremental distance
|
|
4. **Event markers** → Mark visits, session start/end with eventType
|
|
5. **Admin oversight** → View live volunteer positions on dashboard
|
|
6. **Route history** → Generate polyline from saved TrackPoints
|
|
|
|
## Database Models
|
|
|
|
### TrackingSession Model
|
|
|
|
See [TrackingSession Model Documentation](../../database/models/canvass.md#trackingsession-model).
|
|
|
|
**Key Fields:**
|
|
|
|
- `userId`: Foreign key to volunteer User
|
|
- `canvassSessionId`: 1:1 foreign key to CanvassSession
|
|
- `startedAt`: Tracking start timestamp
|
|
- `endedAt`: Tracking end timestamp (null while active)
|
|
- `isActive`: Boolean - tracking currently running
|
|
- `totalPoints`: Count of TrackPoint records
|
|
- `totalDistanceM`: Total distance walked in meters
|
|
- `lastLatitude` / `lastLongitude`: Most recent GPS position
|
|
- `lastRecordedAt`: Timestamp of last GPS point
|
|
|
|
### TrackPoint Model
|
|
|
|
See [TrackPoint Model Documentation](../../database/models/canvass.md#trackpoint-model).
|
|
|
|
**Key Fields:**
|
|
|
|
- `trackingSessionId`: Foreign key to TrackingSession
|
|
- `latitude` / `longitude`: GPS coordinates (Decimal type)
|
|
- `accuracy`: GPS accuracy in meters (lower = better)
|
|
- `recordedAt`: When point was recorded (client timestamp)
|
|
- `eventType`: Optional event marker (LOCATION_ADDED, VISIT_RECORDED, SESSION_STARTED, SESSION_ENDED)
|
|
|
|
**Event Type Enum:**
|
|
|
|
```typescript
|
|
enum TrackPointEventType {
|
|
LOCATION_ADDED // Regular GPS breadcrumb
|
|
VISIT_RECORDED // Canvass visit recorded
|
|
SESSION_STARTED // Canvass session started
|
|
SESSION_ENDED // Canvass session ended
|
|
}
|
|
```
|
|
|
|
## API Endpoints
|
|
|
|
See [Tracking Backend Module Documentation](../../backend/modules/map/tracking.md).
|
|
|
|
**Volunteer Endpoints:**
|
|
|
|
| Method | Endpoint | Auth | Description |
|
|
|--------|----------|------|-------------|
|
|
| POST | `/api/map/tracking/sessions` | Any logged-in user | Start tracking session |
|
|
| PATCH | `/api/map/tracking/sessions/:id/end` | Any logged-in user | End tracking session |
|
|
| POST | `/api/map/tracking/sessions/:id/points` | Any logged-in user | Submit batch of GPS points |
|
|
| GET | `/api/map/tracking/sessions/:id` | Any logged-in user | Get tracking session details |
|
|
| GET | `/api/map/tracking/sessions/:id/route` | Any logged-in user | Get route polyline (all points) |
|
|
|
|
**Admin Endpoints:**
|
|
|
|
| Method | Endpoint | Auth | Description |
|
|
|--------|----------|------|-------------|
|
|
| GET | `/api/map/tracking/admin/live` | MAP_ADMIN | Get live volunteer positions |
|
|
| GET | `/api/map/tracking/admin/sessions/:id` | MAP_ADMIN | Get volunteer tracking session |
|
|
| GET | `/api/map/tracking/admin/sessions/:id/route` | MAP_ADMIN | Get volunteer route |
|
|
|
|
## Configuration
|
|
|
|
### GPS Tracking Settings
|
|
|
|
| Setting | Default | Description |
|
|
|---------|---------|-------------|
|
|
| `SUBMIT_INTERVAL_MS` | `10000` | Submit GPS points every 10 seconds |
|
|
| `MAX_DISTANCE_JUMP_M` | `1000` | Ignore GPS glitches >1km distance |
|
|
| `HIGH_ACCURACY` | `true` | Use GPS + WiFi + cellular (vs WiFi only) |
|
|
| `MAX_AGE_MS` | `0` | Don't use cached GPS position |
|
|
| `TIMEOUT_MS` | `10000` | GPS position timeout (10s) |
|
|
|
|
### Privacy & Security
|
|
|
|
- **Opt-In Only**: Tracking only enabled when volunteer starts canvass session
|
|
- **Session-Based**: Tracking ends when session ends (not continuous)
|
|
- **Admin-Only**: Only MAP_ADMIN can view live positions
|
|
- **Data Retention**: TrackPoints retained for analytics (consider GDPR compliance for EU campaigns)
|
|
|
|
## Code Examples
|
|
|
|
### Start Tracking Session (Backend)
|
|
|
|
```typescript
|
|
// api/src/modules/map/tracking/tracking.service.ts
|
|
async startSession(userId: string, data: StartTrackingInput) {
|
|
const { canvassSessionId, latitude, longitude } = data;
|
|
|
|
// Check for existing active session
|
|
const existing = await prisma.trackingSession.findFirst({
|
|
where: { userId, isActive: true },
|
|
});
|
|
|
|
if (existing) return existing; // Reuse existing session
|
|
|
|
return prisma.trackingSession.create({
|
|
data: {
|
|
userId,
|
|
canvassSessionId: canvassSessionId ?? null,
|
|
lastLatitude: latitude != null ? new Prisma.Decimal(latitude) : null,
|
|
lastLongitude: longitude != null ? new Prisma.Decimal(longitude) : null,
|
|
lastRecordedAt: latitude != null ? new Date() : null,
|
|
},
|
|
});
|
|
}
|
|
```
|
|
|
|
### Submit GPS Points (Backend)
|
|
|
|
```typescript
|
|
// api/src/modules/map/tracking/tracking.service.ts
|
|
const MAX_DISTANCE_JUMP_M = 1000;
|
|
|
|
async submitPoints(sessionId: string, userId: string, data: SubmitPointsInput) {
|
|
const session = await prisma.trackingSession.findFirst({
|
|
where: { id: sessionId, userId, isActive: true },
|
|
});
|
|
|
|
if (!session) {
|
|
throw new AppError(404, 'Active tracking session not found', 'SESSION_NOT_FOUND');
|
|
}
|
|
|
|
const { points } = data;
|
|
|
|
// Batch insert all points
|
|
await prisma.trackPoint.createMany({
|
|
data: points.map((p) => ({
|
|
trackingSessionId: sessionId,
|
|
latitude: new Prisma.Decimal(p.latitude),
|
|
longitude: new Prisma.Decimal(p.longitude),
|
|
accuracy: p.accuracy ?? null,
|
|
recordedAt: new Date(p.recordedAt),
|
|
eventType: p.eventType ?? null,
|
|
})),
|
|
});
|
|
|
|
// Calculate incremental distance
|
|
let addedDistance = 0;
|
|
let prevLat = session.lastLatitude ? Number(session.lastLatitude) : null;
|
|
let prevLng = session.lastLongitude ? Number(session.lastLongitude) : null;
|
|
|
|
const sorted = [...points].sort(
|
|
(a, b) => new Date(a.recordedAt).getTime() - new Date(b.recordedAt).getTime()
|
|
);
|
|
|
|
for (const p of sorted) {
|
|
if (prevLat != null && prevLng != null) {
|
|
const d = haversineDistance(prevLat, prevLng, p.latitude, p.longitude);
|
|
if (d <= MAX_DISTANCE_JUMP_M) {
|
|
addedDistance += d;
|
|
}
|
|
}
|
|
prevLat = p.latitude;
|
|
prevLng = p.longitude;
|
|
}
|
|
|
|
const lastPoint = sorted[sorted.length - 1]!;
|
|
|
|
// Update session summary
|
|
await prisma.trackingSession.update({
|
|
where: { id: sessionId },
|
|
data: {
|
|
totalPoints: { increment: points.length },
|
|
totalDistanceM: { increment: addedDistance },
|
|
lastLatitude: new Prisma.Decimal(lastPoint.latitude),
|
|
lastLongitude: new Prisma.Decimal(lastPoint.longitude),
|
|
lastRecordedAt: new Date(lastPoint.recordedAt),
|
|
},
|
|
});
|
|
|
|
return { accepted: points.length, distance: addedDistance };
|
|
}
|
|
```
|
|
|
|
### GPS Auto-Tracking (Frontend)
|
|
|
|
```typescript
|
|
// admin/src/components/canvass/GPSTracker.tsx
|
|
useEffect(() => {
|
|
if (!trackingSessionId || !enabled) return;
|
|
|
|
const pointsBuffer: TrackPoint[] = [];
|
|
|
|
const watchId = navigator.geolocation.watchPosition(
|
|
(position) => {
|
|
const point = {
|
|
latitude: position.coords.latitude,
|
|
longitude: position.coords.longitude,
|
|
accuracy: position.coords.accuracy,
|
|
recordedAt: new Date().toISOString(),
|
|
};
|
|
|
|
pointsBuffer.push(point);
|
|
setCurrentPosition([point.latitude, point.longitude]);
|
|
},
|
|
(error) => {
|
|
console.error('GPS error:', error);
|
|
message.error('GPS tracking failed');
|
|
},
|
|
{
|
|
enableHighAccuracy: true,
|
|
maximumAge: 0,
|
|
timeout: 10000,
|
|
}
|
|
);
|
|
|
|
// Submit buffered points every 10 seconds
|
|
const interval = setInterval(async () => {
|
|
if (pointsBuffer.length === 0) return;
|
|
|
|
try {
|
|
await api.post(`/map/tracking/sessions/${trackingSessionId}/points`, {
|
|
points: pointsBuffer.splice(0), // Drain buffer
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to submit GPS points:', error);
|
|
}
|
|
}, 10000);
|
|
|
|
return () => {
|
|
navigator.geolocation.clearWatch(watchId);
|
|
clearInterval(interval);
|
|
};
|
|
}, [trackingSessionId, enabled]);
|
|
```
|
|
|
|
### Route Visualization (Frontend)
|
|
|
|
```typescript
|
|
// admin/src/pages/volunteer/MyRoutesPage.tsx
|
|
const fetchRoute = async (sessionId: string) => {
|
|
const { data } = await api.get(`/map/tracking/sessions/${sessionId}/route`);
|
|
|
|
// Convert TrackPoints to polyline coordinates
|
|
const polyline = data.points.map((p: TrackPoint) => [p.latitude, p.longitude]);
|
|
|
|
// Extract event markers
|
|
const events = data.points
|
|
.filter((p: TrackPoint) => p.eventType)
|
|
.map((p: TrackPoint) => ({
|
|
position: [p.latitude, p.longitude],
|
|
eventType: p.eventType,
|
|
recordedAt: p.recordedAt,
|
|
}));
|
|
|
|
setRoute({ polyline, events, distance: data.totalDistanceM });
|
|
};
|
|
|
|
// Render route
|
|
<Polyline positions={route.polyline} pathOptions={{ color: '#3498db', weight: 3 }} />
|
|
{route.events.map((event, i) => (
|
|
<Marker
|
|
key={i}
|
|
position={event.position}
|
|
icon={getEventIcon(event.eventType)}
|
|
>
|
|
<Popup>{event.eventType} - {dayjs(event.recordedAt).format('HH:mm')}</Popup>
|
|
</Marker>
|
|
))}
|
|
```
|
|
|
|
## Troubleshooting
|
|
|
|
### Issue: GPS Tracking Draining Battery
|
|
|
|
**Solutions:**
|
|
|
|
1. Reduce accuracy: `enableHighAccuracy: false`
|
|
2. Increase submit interval: `SUBMIT_INTERVAL_MS = 30000` (30s)
|
|
3. Add pause/resume tracking buttons
|
|
|
|
### Issue: Distance Calculation Incorrect
|
|
|
|
**Symptoms:** Total distance much higher than expected
|
|
|
|
**Causes:** GPS glitches causing large jumps
|
|
|
|
**Solutions:**
|
|
|
|
Increase `MAX_DISTANCE_JUMP_M` threshold to ignore outliers:
|
|
|
|
```typescript
|
|
const MAX_DISTANCE_JUMP_M = 2000; // Was 1000, increase to 2000
|
|
```
|
|
|
|
### Issue: Route Polyline Jagged
|
|
|
|
**Symptoms:** Route looks zigzag instead of smooth
|
|
|
|
**Causes:** GPS accuracy poor (±20m)
|
|
|
|
**Solutions:**
|
|
|
|
Apply smoothing algorithm to polyline:
|
|
|
|
```typescript
|
|
import { simplify } from '@turf/turf';
|
|
|
|
const smoothed = simplify(polyline, { tolerance: 0.0001, highQuality: true });
|
|
```
|
|
|
|
## Performance Considerations
|
|
|
|
### Batch Point Insertion
|
|
|
|
**Efficient Bulk Insert:**
|
|
|
|
```typescript
|
|
// Insert all points in single transaction
|
|
await prisma.trackPoint.createMany({
|
|
data: points.map((p) => ({ ... })),
|
|
});
|
|
|
|
// Avoid N+1: single UPDATE instead of N UPDATEs
|
|
await prisma.trackingSession.update({
|
|
where: { id: sessionId },
|
|
data: {
|
|
totalPoints: { increment: points.length },
|
|
totalDistanceM: { increment: totalDistance },
|
|
},
|
|
});
|
|
```
|
|
|
|
### Query Optimization
|
|
|
|
**Index for Route Queries:**
|
|
|
|
```sql
|
|
CREATE INDEX idx_track_points_session_time ON "TrackPoint" ("trackingSessionId", "recordedAt");
|
|
```
|
|
|
|
**Efficient Route Query:**
|
|
|
|
```typescript
|
|
const points = await prisma.trackPoint.findMany({
|
|
where: { trackingSessionId: sessionId },
|
|
orderBy: { recordedAt: 'asc' },
|
|
select: { latitude: true, longitude: true, recordedAt: true, eventType: true },
|
|
});
|
|
```
|
|
|
|
## Related Documentation
|
|
|
|
- [Canvassing](./canvassing.md) — Canvass session integration
|
|
- [Tracking Backend Module](../../backend/modules/map/tracking.md)
|
|
- [MyRoutesPage](../../frontend/pages/volunteer/my-routes-page.md)
|
|
- [TrackingSession Model](../../database/models/canvass.md#trackingsession-model)
|