24 KiB
Geographic Polygon Overlays (Cuts)
Overview
The cuts system provides polygon-based geographic organizing using customizable map overlays. Cuts enable campaigns to divide territories into canvassing zones, track completion progress, and assign volunteers to specific areas.
Key Capabilities:
- Polygon Drawing: Click-to-draw custom polygons on Leaflet maps
- GeoJSON Storage: Store complex polygons with coordinate precision
- Spatial Queries: Point-in-polygon filtering using ray-casting algorithm
- Cut Categories: CUSTOM, WARD, NEIGHBORHOOD, DISTRICT classification
- Visual Customization: Configurable colors and opacity for map overlays
- Bounds Calculation: Auto-calculate bounding box from polygon coordinates
- Completion Tracking: Track canvassing progress by cut
- Shift Assignment: Link shifts to cuts for volunteer scheduling
- Export Filtering: Generate walk sheets for specific cuts
Use Cases:
- Electoral district mapping (wards, polling divisions)
- Canvassing zone organization
- Neighborhood targeting
- Volunteer territory assignment
- Walk sheet generation by area
- Progress tracking by geographic zone
- Multi-volunteer coordination
Architecture
graph TD
A[Admin User] -->|Draws Polygon| B[CutDrawingMode]
B -->|Click Vertices| C[Leaflet Map]
C -->|Auto-Close Detection| D[GeoJSON Polygon]
D -->|POST /api/map/cuts| E[Cuts Service]
E -->|Calculate Bounds| F[Spatial Utils]
F -->|Save| G[(Cut Model)]
H[Public Map] -->|Load Cuts| I[GET /api/public/map/cuts]
I -->|Return GeoJSON| E
E -->|Query| G
I -->|Render| J[CutOverlays Component]
K[Canvass Session] -->|Start in Cut| L[Canvass Service]
L -->|Load Addresses| M[Locations Service]
M -->|Point-in-Polygon| F
F -->|Filter| N[(Location Model)]
O[Shift] -->|Assigned to Cut| G
G -->|1:N| O
P[Export Locations] -->|Filter by Cut| M
M -->|Query Polygon| F
style G fill:#e1f5ff
style N fill:#e1f5ff
style O fill:#e1f5ff
Flow Description:
- Admin draws cut → Click vertices on map, auto-close detection, generate GeoJSON
- Save cut → Calculate bounds from coordinates, store polygon in database
- Public map loads → Query public cuts, render as colored overlays with opacity
- Canvass session starts → Load addresses within cut polygon using ray-casting
- Shift assignment → Link shift to cut for volunteer scheduling
- Export locations → Filter by cut polygon to generate walk sheet
Database Models
Cut Model
See Cut Model Documentation for full schema.
Key Fields:
name: Cut display name (e.g., "Ward 5 - Downtown")description: Free-text notes about the cutgeojson: Polygon coordinates in GeoJSON format (TEXT field)bounds: Auto-calculated bounding box{minLat, maxLat, minLng, maxLng}(JSON)color: Hex color for map overlay (default:#3498db)opacity: Opacity 0.0-1.0 for map rendering (default: 0.3)category: CUSTOM | WARD | NEIGHBORHOOD | DISTRICTisPublic: Show on public mapisOfficial: Official electoral boundary (prevents accidental deletion)showLocations: Show location markers within cut on mapexportEnabled: Allow walk sheet export for this cutassignedTo: Free-text assigned volunteer/team namecompletionPercentage: Auto-calculated canvassing progress (0-100)
GeoJSON Format:
{
"type": "Polygon",
"coordinates": [
[
[-75.6972, 45.4215],
[-75.6980, 45.4220],
[-75.6960, 45.4230],
[-75.6950, 45.4225],
[-75.6972, 45.4215]
]
]
}
Bounds Format:
{
"minLat": 45.4215,
"maxLat": 45.4230,
"minLng": -75.6980,
"maxLng": -75.6950
}
Related Models:
- Shift — Volunteer shifts assigned to cut
- CanvassSession — Canvassing within cut
- Location — Filtered by cut polygon
API Endpoints
See Cuts Backend Module Documentation for full API reference.
Admin Endpoints:
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /api/map/cuts |
MAP_ADMIN | List cuts with pagination, search, category filter |
| GET | /api/map/cuts/stats |
MAP_ADMIN | Get cut statistics (total, by category) |
| GET | /api/map/cuts/:id |
MAP_ADMIN | Get cut details |
| POST | /api/map/cuts |
MAP_ADMIN | Create new cut with polygon |
| PATCH | /api/map/cuts/:id |
MAP_ADMIN | Update cut |
| DELETE | /api/map/cuts/:id |
MAP_ADMIN | Delete cut (blocked if isOfficial=true) |
| GET | /api/map/cuts/:id/locations |
MAP_ADMIN | Get locations within cut polygon |
| GET | /api/map/cuts/:id/progress |
MAP_ADMIN | Get canvassing progress for cut |
Public Endpoints:
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /api/public/map/cuts |
None | List public cuts (isPublic=true) |
| GET | /api/public/map/cuts/:id |
None | Get public cut details |
Configuration
Environment Variables
No specific environment variables for cuts. Uses standard database and map settings.
Cut Category Enum
enum CutCategory {
CUSTOM // User-defined boundary
WARD // Municipal ward boundary
NEIGHBORHOOD // Neighborhood association boundary
DISTRICT // Electoral district boundary
}
Default Values
| Field | Default | Description |
|---|---|---|
color |
#3498db |
Blue color for overlay |
opacity |
0.3 |
30% opacity (transparent) |
isPublic |
false |
Hidden from public map |
isOfficial |
false |
Can be deleted by admin |
showLocations |
true |
Show location markers within cut |
exportEnabled |
true |
Allow walk sheet export |
completionPercentage |
0 |
Auto-updated by canvass service |
Admin Workflow
Creating a Cut
Step 1: Navigate to Cuts Page
Navigate to Map → Cuts in the admin sidebar.
![CutsPage Screenshot Placeholder]
Step 2: Open Drawing Tab
Click Drawing tab to switch to map drawing mode.
Step 3: Activate Drawing Mode
Click Draw Cut button in the map controls. Map cursor changes to crosshair.
Step 4: Click Vertices
Click on the map to place polygon vertices:
- First Click: Start polygon
- Additional Clicks: Add vertices
- Auto-Close: When cursor near start point (within 10px), polygon auto-closes
Step 5: Configure Cut
Fill in the cut form (right sidebar):
- Name: "Ward 5 - Downtown"
- Description: "Central business district and residential blocks"
- Category: WARD
- Color: Choose from color picker (default: blue)
- Opacity: Slider 0-100 (default: 30%)
- Is Public: Toggle to show on public map
- Is Official: Toggle to prevent accidental deletion
Step 6: Save Cut
Click Save Cut. The system will:
- Generate GeoJSON from vertices
- Calculate bounding box
- Save to database
- Render polygon on map with configured color/opacity
Editing a Cut
Step 1: Select Cut
On Table tab, click Edit button for a cut.
Step 2: Update Fields
Modify cut properties:
- Name/Description: Update text fields
- Color/Opacity: Adjust visual appearance
- Category: Change classification
- Public/Official: Toggle flags
Step 3: Re-Draw Polygon (Optional)
To change polygon shape:
- Switch to Drawing tab
- Click Edit Cut button
- Delete old vertices (click vertices to remove)
- Add new vertices
- Auto-close polygon
Step 4: Save Changes
Click Update to save changes. Bounds are auto-recalculated if polygon changed.
Viewing Locations in Cut
Step 1: Select Cut
Click cut row in table to select.
Step 2: Click "View Locations"
Click View Locations button.
Step 3: View Filtered Table
System displays locations within cut polygon:
- Point-in-Polygon: Uses ray-casting algorithm to filter
- Count: Number of locations within cut
- Support Breakdown: Count by support level
Step 4: Export Locations
Click Export CSV to download locations for walk sheet generation.
Assigning Cut to Shift
Step 1: Create/Edit Shift
On Map → Shifts page, create or edit a shift.
Step 2: Select Cut
In shift form, choose cut from Cut dropdown.
Step 3: Save Shift
Shift is now linked to cut. Volunteers will see cut name on shift details.
Tracking Cut Completion
Step 1: View Cut Progress
On CutsPage, click Progress button for a cut.
Step 2: View Metrics
System displays:
- Completion Percentage: Auto-calculated from canvass visits
- Total Addresses: Count of addresses within cut
- Visited: Count of addresses with CanvassVisit records
- Outstanding: Remaining addresses to visit
Step 3: View Canvass Activity
Table shows recent canvass visits within cut:
- Volunteer Name: Who visited
- Visit Date: When visited
- Outcome: Visit result (SPOKE_WITH, NOT_HOME, etc.)
- Support Level: Updated support level (if applicable)
Public Workflow
Public users can view cut overlays on the interactive map.
Step 1: Navigate to Public Map
Visit /map (no authentication required).
Step 2: Toggle Cut Overlays
Click Cuts button in map controls to open overlay panel.
Step 3: Select Cuts
Check/uncheck cuts to show/hide on map:
- Color Legend: Shows cut name and color
- Opacity: Semi-transparent overlays don't obscure markers
- Multiple Cuts: Show multiple cuts simultaneously
Step 4: View Cut Details
Click on a cut polygon to view:
- Cut Name: Displayed in popup
- Category: Ward, Neighborhood, etc.
- Assigned To: Volunteer/team name (if configured)
Volunteer Workflow
Volunteers interact with cuts via shift assignments.
Step 1: View Assigned Shifts
On Volunteer → My Assignments page, view shifts with cut assignments.
Step 2: Start Canvass Session
Click Start Canvass on a shift. Redirects to /volunteer/canvass/:cutId.
Step 3: View Cut on Map
Full-screen map shows:
- Cut Polygon: Highlighted boundary
- Locations Within Cut: Filtered to cut polygon only
- Walking Route: Optimal route through cut locations
See Canvassing Documentation for full volunteer workflow.
Code Examples
Cut Service Create (Backend)
// api/src/modules/map/cuts/cuts.service.ts
import { parseGeoJsonPolygon, calculateBounds } from '../../../utils/spatial';
async create(data: CreateCutInput, userId: string) {
// Auto-calculate bounds from geojson if not provided
let boundsStr = data.bounds;
if (!boundsStr) {
try {
const rings = parseGeoJsonPolygon(data.geojson);
const allCoords = rings.flat();
const bounds = calculateBounds(allCoords);
boundsStr = JSON.stringify(bounds);
} catch {
// Bounds calculation optional
}
}
const cut = await prisma.cut.create({
data: {
name: data.name,
description: data.description,
color: data.color,
opacity: data.opacity,
category: data.category,
isPublic: data.isPublic,
isOfficial: data.isOfficial,
geojson: data.geojson,
bounds: boundsStr,
showLocations: data.showLocations,
exportEnabled: data.exportEnabled,
assignedTo: data.assignedTo,
createdByUserId: userId,
},
});
return cut;
}
Bounds Calculation (Backend)
// api/src/utils/spatial.ts
export function calculateBounds(coordinates: number[][]): {
minLat: number;
maxLat: number;
minLng: number;
maxLng: number;
} {
let minLat = Infinity;
let maxLat = -Infinity;
let minLng = Infinity;
let maxLng = -Infinity;
for (const coord of coordinates) {
const lng = coord[0]!;
const lat = coord[1]!;
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
if (lng < minLng) minLng = lng;
if (lng > maxLng) maxLng = lng;
}
return { minLat, maxLat, minLng, maxLng };
}
Point-in-Polygon Filter (Backend)
// api/src/modules/map/cuts/cuts.service.ts
import { isPointInPolygon, parseGeoJsonPolygon } from '../../../utils/spatial';
async getLocationsInCut(cutId: string) {
const cut = await prisma.cut.findUnique({
where: { id: cutId },
select: { geojson: true },
});
if (!cut?.geojson) {
throw new AppError(404, 'Cut not found', 'CUT_NOT_FOUND');
}
// Get all locations (or use bounds for optimization)
const locations = await prisma.location.findMany({
select: {
id: true,
latitude: true,
longitude: true,
address: true,
},
});
// Parse polygon coordinates
const polygons = parseGeoJsonPolygon(cut.geojson);
// Filter locations using ray-casting algorithm
const filtered = locations.filter((loc) => {
const lat = Number(loc.latitude);
const lng = Number(loc.longitude);
return polygons.some((poly) => isPointInPolygon(lat, lng, poly));
});
return filtered;
}
Ray-Casting Algorithm (Backend)
// api/src/utils/spatial.ts
export function isPointInPolygon(
lat: number,
lng: number,
polygonCoords: number[][]
): boolean {
let inside = false;
for (let i = 0, j = polygonCoords.length - 1; i < polygonCoords.length; j = i++) {
const xi = polygonCoords[i]![1]!; // lat
const yi = polygonCoords[i]![0]!; // lng
const xj = polygonCoords[j]![1]!;
const yj = polygonCoords[j]![0]!;
const intersect = ((yi > lng) !== (yj > lng)) &&
(lat < (xj - xi) * (lng - yi) / (yj - yi) + xi);
if (intersect) inside = !inside;
}
return inside;
}
Cut Drawing Mode (Frontend)
// admin/src/components/map/CutDrawingMode.tsx
import { useState, useEffect } from 'react';
import { useMapEvents } from 'react-leaflet';
import type { LatLng } from 'leaflet';
interface CutDrawingModeProps {
onPolygonComplete: (vertices: LatLng[]) => void;
}
export default function CutDrawingMode({ onPolygonComplete }: CutDrawingModeProps) {
const [vertices, setVertices] = useState<LatLng[]>([]);
const [isDrawing, setIsDrawing] = useState(true);
useMapEvents({
click(e) {
if (!isDrawing) return;
const newVertex = e.latlng;
// Auto-close detection: if click near first vertex (within 10px)
if (vertices.length >= 3) {
const firstVertex = vertices[0]!;
const map = e.target;
const firstPoint = map.latLngToContainerPoint(firstVertex);
const newPoint = map.latLngToContainerPoint(newVertex);
const distance = Math.sqrt(
Math.pow(firstPoint.x - newPoint.x, 2) +
Math.pow(firstPoint.y - newPoint.y, 2)
);
if (distance < 10) {
// Auto-close polygon
setIsDrawing(false);
onPolygonComplete(vertices);
return;
}
}
// Add vertex
setVertices([...vertices, newVertex]);
},
});
return (
<>
{/* Render temporary polygon while drawing */}
{vertices.length >= 2 && (
<Polygon positions={vertices} pathOptions={{ color: '#3498db', opacity: 0.5 }} />
)}
{/* Render vertex markers */}
{vertices.map((v, i) => (
<CircleMarker
key={i}
center={v}
radius={5}
pathOptions={{ color: '#e74c3c', fillColor: '#e74c3c', fillOpacity: 1 }}
/>
))}
</>
);
}
Cut Overlays Rendering (Frontend)
// admin/src/components/map/CutOverlays.tsx
import { Polygon, Popup } from 'react-leaflet';
import type { Cut } from '@/types/api';
interface CutOverlaysProps {
cuts: Cut[];
visibleCutIds: string[];
}
export default function CutOverlays({ cuts, visibleCutIds }: CutOverlaysProps) {
return (
<>
{cuts
.filter((cut) => visibleCutIds.includes(cut.id))
.map((cut) => {
const geojson = JSON.parse(cut.geojson);
// GeoJSON uses [lng, lat], Leaflet uses [lat, lng]
const positions = geojson.coordinates[0].map(([lng, lat]: number[]) => [lat, lng]);
return (
<Polygon
key={cut.id}
positions={positions}
pathOptions={{
color: cut.color,
fillColor: cut.color,
fillOpacity: cut.opacity,
weight: 2,
}}
>
<Popup>
<div>
<strong>{cut.name}</strong>
<br />
{cut.category}
{cut.assignedTo && (
<>
<br />
Assigned to: {cut.assignedTo}
</>
)}
</div>
</Popup>
</Polygon>
);
})}
</>
);
}
Convert Leaflet Polygon to GeoJSON (Frontend)
// admin/src/pages/CutsPage.tsx
const handleSaveCut = async (vertices: LatLng[]) => {
// Convert Leaflet [lat, lng] to GeoJSON [lng, lat]
const coordinates = vertices.map((v) => [v.lng, v.lat]);
// Close polygon (first vertex === last vertex)
coordinates.push(coordinates[0]!);
const geojson = {
type: 'Polygon',
coordinates: [coordinates],
};
try {
const { data } = await api.post<Cut>('/map/cuts', {
name: cutName,
description: cutDescription,
geojson: JSON.stringify(geojson),
color: cutColor,
opacity: cutOpacity,
category: cutCategory,
isPublic: isPublic,
isOfficial: isOfficial,
});
message.success('Cut created');
fetchCuts();
} catch (error) {
message.error('Failed to create cut');
}
};
Troubleshooting
Issue: Polygon Not Closing
Symptoms:
- Clicking near start point doesn't auto-close polygon
- Polygon remains open after many vertices
- "Save Cut" button disabled
Causes:
- Auto-close distance threshold too small
- Mouse click precision issues on mobile
- Map zoom level affecting pixel distance calculation
Solutions:
- Increase auto-close threshold:
// admin/src/components/map/CutDrawingMode.tsx
const AUTO_CLOSE_DISTANCE_PX = 15; // Was 10, increase to 15
if (distance < AUTO_CLOSE_DISTANCE_PX) {
// Auto-close polygon
}
- Manual close button:
Add explicit "Close Polygon" button for mobile users:
<Button onClick={() => {
if (vertices.length >= 3) {
onPolygonComplete(vertices);
}
}}>
Close Polygon
</Button>
Issue: Point-in-Polygon Returns Wrong Results
Symptoms:
- Locations outside cut polygon included in canvass session
- Locations inside cut polygon excluded
- Export CSV missing locations
Causes:
- Coordinate order mismatch (GeoJSON [lng, lat] vs Leaflet [lat, lng])
- Polygon not properly closed (first vertex !== last vertex)
- Ray-casting algorithm bug with edge cases
Solutions:
- Verify coordinate order:
// GeoJSON uses [lng, lat]
const geojson = {
type: 'Polygon',
coordinates: [
[
[-75.6972, 45.4215], // [lng, lat]
[-75.6980, 45.4220],
// ...
]
]
};
// Leaflet uses [lat, lng]
<Polygon positions={[[45.4215, -75.6972], [45.4220, -75.6980]]} />
- Verify polygon closure:
-- Check if polygon is properly closed
SELECT id, name,
geojson::json->'coordinates'->0->0 as first_vertex,
geojson::json->'coordinates'->0->-1 as last_vertex
FROM "Cut"
WHERE id = 'YOUR_CUT_ID';
-- First and last should be identical
- Test with known points:
# Test point-in-polygon directly
curl -X POST http://localhost:4000/api/map/cuts/YOUR_CUT_ID/test-point \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"latitude":45.4220,"longitude":-75.6975}'
Issue: Cut Rendering Performance Slow
Symptoms:
- Map lags when rendering multiple cuts
- Browser freezes with >10 cuts visible
- Polygon rendering takes >2 seconds
Causes:
- Too many polygon vertices (complex boundaries)
- Multiple cut overlays rendered simultaneously
- No polygon simplification
Solutions:
- Simplify complex polygons:
Use Turf.js simplify algorithm to reduce vertices:
import * as turf from '@turf/turf';
const simplified = turf.simplify(polygon, {
tolerance: 0.0001, // Adjust based on zoom level
highQuality: true
});
- Lazy render cuts:
Only render cuts within current map bounds:
const visibleCuts = cuts.filter((cut) => {
const bounds = JSON.parse(cut.bounds);
const mapBounds = map.getBounds();
return mapBounds.intersects([
[bounds.minLat, bounds.minLng],
[bounds.maxLat, bounds.maxLng]
]);
});
- Use Canvas renderer:
For large polygons, use Leaflet Canvas renderer instead of SVG:
<Polygon
positions={positions}
renderer={L.canvas()}
pathOptions={{ color: cut.color }}
/>
Performance Considerations
Spatial Query Optimization
Bounds Pre-Filter:
Always pre-filter by bounding box before point-in-polygon:
async getLocationsInCut(cutId: string) {
const cut = await prisma.cut.findUnique({ where: { id: cutId } });
const bounds = JSON.parse(cut.bounds);
// Pre-filter by bounds (fast, uses index)
const candidates = await prisma.location.findMany({
where: {
latitude: {
gte: new Prisma.Decimal(bounds.minLat),
lte: new Prisma.Decimal(bounds.maxLat),
},
longitude: {
gte: new Prisma.Decimal(bounds.minLng),
lte: new Prisma.Decimal(bounds.maxLng),
},
},
});
// Then apply point-in-polygon (slower, but fewer candidates)
const polygons = parseGeoJsonPolygon(cut.geojson);
return candidates.filter((loc) => {
const lat = Number(loc.latitude);
const lng = Number(loc.longitude);
return polygons.some((poly) => isPointInPolygon(lat, lng, poly));
});
}
Performance Impact:
- Without bounds pre-filter: 10,000 locations → 10,000 point-in-polygon checks
- With bounds pre-filter: 10,000 locations → 500 candidates → 500 point-in-polygon checks (20x faster)
Polygon Simplification
Reduce Vertices for Large Cuts:
Use Douglas-Peucker algorithm to simplify polygons while preserving shape:
import * as turf from '@turf/turf';
function simplifyPolygon(geojson: string, tolerance: number = 0.0001): string {
const polygon = JSON.parse(geojson);
const simplified = turf.simplify(polygon, { tolerance, highQuality: true });
return JSON.stringify(simplified);
}
// Usage: simplify when importing official boundaries (e.g., electoral districts)
const simplifiedGeojson = simplifyPolygon(officialBoundary, 0.0005);
Tolerance Guidelines:
- 0.00001: High precision (±1m), use for small neighborhoods
- 0.0001: Medium precision (±10m), use for wards
- 0.001: Low precision (±100m), use for large districts
Caching Cut Queries
Cache Frequently Used Cuts:
// Cache cut polygons in Redis for fast repeated queries
const CACHE_KEY = `CUT_POLYGON:${cutId}`;
const cached = await redis.get(CACHE_KEY);
if (cached) {
return JSON.parse(cached);
}
const cut = await prisma.cut.findUnique({ where: { id: cutId } });
await redis.setex(CACHE_KEY, 3600, JSON.stringify(cut)); // 1 hour TTL
return cut;
Related Documentation
Backend Modules:
- Cuts Backend Module — API implementation
- Spatial Utils — Point-in-polygon algorithms
- Locations Service — Spatial filtering
Frontend Pages:
- CutsPage — Admin CRUD interface
- CutDrawingMode — Polygon drawing
- CutOverlays — Map rendering
Database:
- Cut Model — Cut schema
- Spatial Queries — Optimization tips
Features:
- Locations — Location filtering by cut
- Shifts — Shift assignment to cuts
- Canvassing — Canvassing within cut boundaries
- Walk Sheets — Export locations by cut