# 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 ```mermaid 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:** 1. **Admin draws cut** → Click vertices on map, auto-close detection, generate GeoJSON 2. **Save cut** → Calculate bounds from coordinates, store polygon in database 3. **Public map loads** → Query public cuts, render as colored overlays with opacity 4. **Canvass session starts** → Load addresses within cut polygon using ray-casting 5. **Shift assignment** → Link shift to cut for volunteer scheduling 6. **Export locations** → Filter by cut polygon to generate walk sheet ## Database Models ### Cut Model See [Cut Model Documentation](../../database/models/map.md#cut-model) for full schema. **Key Fields:** - `name`: Cut display name (e.g., "Ward 5 - Downtown") - `description`: Free-text notes about the cut - `geojson`: 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 | DISTRICT - `isPublic`: Show on public map - `isOfficial`: Official electoral boundary (prevents accidental deletion) - `showLocations`: Show location markers within cut on map - `exportEnabled`: Allow walk sheet export for this cut - `assignedTo`: Free-text assigned volunteer/team name - `completionPercentage`: Auto-calculated canvassing progress (0-100) **GeoJSON Format:** ```json { "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:** ```json { "minLat": 45.4215, "maxLat": 45.4230, "minLng": -75.6980, "maxLng": -75.6950 } ``` **Related Models:** - [Shift](../../database/models/map.md#shift-model) — Volunteer shifts assigned to cut - [CanvassSession](../../database/models/canvass.md#canvasssession-model) — Canvassing within cut - [Location](../../database/models/map.md#location-model) — Filtered by cut polygon ## API Endpoints See [Cuts Backend Module Documentation](../../backend/modules/map/cuts.md) 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 ```typescript 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: 1. Generate GeoJSON from vertices 2. Calculate bounding box 3. Save to database 4. 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: 1. Switch to **Drawing** tab 2. Click **Edit Cut** button 3. Delete old vertices (click vertices to remove) 4. Add new vertices 5. 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](./canvassing.md) for full volunteer workflow. ## Code Examples ### Cut Service Create (Backend) ```typescript // 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) ```typescript // 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) ```typescript // 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) ```typescript // 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) ```typescript // 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([]); 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 && ( )} {/* Render vertex markers */} {vertices.map((v, i) => ( ))} ); } ``` ### Cut Overlays Rendering (Frontend) ```typescript // 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 (
{cut.name}
{cut.category} {cut.assignedTo && ( <>
Assigned to: {cut.assignedTo} )}
); })} ); } ``` ### Convert Leaflet Polygon to GeoJSON (Frontend) ```typescript // 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('/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:** 1. **Increase auto-close threshold**: ```typescript // 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 } ``` 2. **Manual close button**: Add explicit "Close Polygon" button for mobile users: ```typescript ``` ### 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:** 1. **Verify coordinate order**: ```typescript // GeoJSON uses [lng, lat] const geojson = { type: 'Polygon', coordinates: [ [ [-75.6972, 45.4215], // [lng, lat] [-75.6980, 45.4220], // ... ] ] }; // Leaflet uses [lat, lng] ``` 2. **Verify polygon closure**: ```sql -- 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 ``` 3. **Test with known points**: ```bash # 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:** 1. **Simplify complex polygons**: Use Turf.js simplify algorithm to reduce vertices: ```typescript import * as turf from '@turf/turf'; const simplified = turf.simplify(polygon, { tolerance: 0.0001, // Adjust based on zoom level highQuality: true }); ``` 2. **Lazy render cuts**: Only render cuts within current map bounds: ```typescript 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] ]); }); ``` 3. **Use Canvas renderer**: For large polygons, use Leaflet Canvas renderer instead of SVG: ```typescript ``` ## Performance Considerations ### Spatial Query Optimization **Bounds Pre-Filter:** Always pre-filter by bounding box before point-in-polygon: ```typescript 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: ```typescript 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:** ```typescript // 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](../../backend/modules/map/cuts.md) — API implementation - [Spatial Utils](../../backend/modules/utils/spatial.md) — Point-in-polygon algorithms - [Locations Service](../../backend/modules/map/locations.md) — Spatial filtering **Frontend Pages:** - [CutsPage](../../frontend/pages/admin/cuts-page.md) — Admin CRUD interface - [CutDrawingMode](../../frontend/components/cut-drawing-mode.md) — Polygon drawing - [CutOverlays](../../frontend/components/cut-overlays.md) — Map rendering **Database:** - [Cut Model](../../database/models/map.md#cut-model) — Cut schema - [Spatial Queries](../../database/queries.md#spatial-queries) — Optimization tips **Features:** - [Locations](./locations.md) — Location filtering by cut - [Shifts](./shifts.md) — Shift assignment to cuts - [Canvassing](./canvassing.md) — Canvassing within cut boundaries - [Walk Sheets](./walk-sheets.md) — Export locations by cut