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:

  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 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:

{
  "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:

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:

  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 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:

  1. 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
}
  1. 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:

  1. 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]]} />
  1. 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
  1. 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:

  1. 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
});
  1. 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]
  ]);
});
  1. 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;

Backend Modules:

Frontend Pages:

Database:

Features: