925 lines
24 KiB
Markdown
925 lines
24 KiB
Markdown
# 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<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)
|
|
|
|
```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 (
|
|
<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)
|
|
|
|
```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<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**:
|
|
|
|
```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
|
|
<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**:
|
|
|
|
```typescript
|
|
// 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]]} />
|
|
```
|
|
|
|
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
|
|
<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:
|
|
|
|
```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
|