608 lines
15 KiB
Markdown
608 lines
15 KiB
Markdown
# Public Map Page
|
|
|
|
## Overview
|
|
|
|
**File Path:** `admin/src/pages/public/MapPage.tsx` (474 lines)
|
|
|
|
**Route:** `/map`
|
|
|
|
**Role Requirements:** Public access (no authentication required)
|
|
|
|
**Purpose:** Interactive public-facing map displaying campaign locations with color-coded support levels, cut polygons, and multi-unit building support. Provides geographic visualization of campaign activity and volunteer canvass coverage.
|
|
|
|
**Key Features:**
|
|
|
|
- Full-viewport Leaflet map with minimal header (48px)
|
|
- OpenStreetMap tile layer
|
|
- Color-coded circle markers by support level (Strong/Leaning/Undecided/Opposed/No Answer)
|
|
- Multi-unit building popups with sorted unit lists
|
|
- Cut polygon overlays with toggle controls
|
|
- Geolocate button (find my location)
|
|
- Fullscreen button
|
|
- Viewport-based location loading with 800ms debounce
|
|
- GPS position marker when geolocation active
|
|
- Dark theme header consistent with public pages
|
|
|
|
**Layout:** Uses `PublicLayout` with custom header override (thin, 48px)
|
|
|
|
---
|
|
|
|
## Features
|
|
|
|
### 1. Thin Header Design
|
|
|
|
Minimal header to maximize map space:
|
|
|
|
- **Height**: 48px (vs standard 64px)
|
|
- **Background**: Dark blue (`#0d1b2a`)
|
|
- **Logo**: Organization name with map icon
|
|
- **No Navigation Menu**: Map is primary content
|
|
- **Mobile Responsive**: Hamburger menu available
|
|
|
|
### 2. Color-Coded Location Markers
|
|
|
|
Visual support level indication:
|
|
|
|
- **Strong Support**: Green (`#52c41a`)
|
|
- **Leaning Support**: Light green (`#95de64`)
|
|
- **Undecided**: Yellow (`#fadb14`)
|
|
- **Leaning Opposed**: Orange (`#ff7a45`)
|
|
- **Opposed**: Red (`#f5222d`)
|
|
- **No Answer**: Gray (`#8c8c8c`)
|
|
- **Not Home**: Light gray (`#d9d9d9`)
|
|
|
|
**Marker Styling:**
|
|
- Circle radius: 8px
|
|
- Stroke: White 2px
|
|
- Fill opacity: 0.8
|
|
- Hover: Increased opacity (1.0)
|
|
|
|
### 3. Multi-Unit Building Popups
|
|
|
|
Aggregated building display:
|
|
|
|
**Popup Header:**
|
|
- Purple background (`#722ed1`)
|
|
- Building address
|
|
- Total unit count badge
|
|
|
|
**Unit List:**
|
|
- Sorted by unit number (alphanumeric)
|
|
- Each row: Unit | Support Level | Notes
|
|
- Color-coded support badges
|
|
- Scrollable if >10 units
|
|
- Max height: 300px
|
|
|
|
**Example:**
|
|
```
|
|
123 Main St [5 units]
|
|
─────────────────────────
|
|
Unit 101 | Strong Support | Yard sign
|
|
Unit 102 | Undecided | -
|
|
Unit 201 | No Answer | Left flyer
|
|
```
|
|
|
|
### 4. Cut Polygon Overlays
|
|
|
|
Geographic boundary visualization:
|
|
|
|
**Polygon Rendering:**
|
|
- GeoJSON format from database
|
|
- Blue stroke (`#1890ff`)
|
|
- Semi-transparent fill (opacity: 0.2)
|
|
- Label at centroid (cut name)
|
|
|
|
**Toggle Controls:**
|
|
- Floating panel (bottom-left, above zoom)
|
|
- Checkbox per cut
|
|
- Select All / Deselect All buttons
|
|
- Collapse/expand panel
|
|
|
|
**Cut Label Styling:**
|
|
- White text with black outline
|
|
- Always visible (not obscured by fill)
|
|
- Click cut to toggle visibility
|
|
|
|
### 5. Viewport-Based Loading
|
|
|
|
Performance optimization for large datasets:
|
|
|
|
**Loading Strategy:**
|
|
- Fetch only locations in current map bounds
|
|
- Trigger on `moveend` event (pan/zoom complete)
|
|
- Debounce 800ms to prevent excessive requests
|
|
- Loading spinner in top-right during fetch
|
|
|
|
**Bounds Calculation:**
|
|
```typescript
|
|
const bounds = map.getBounds();
|
|
const params = {
|
|
minLat: bounds.getSouth(),
|
|
maxLat: bounds.getNorth(),
|
|
minLng: bounds.getWest(),
|
|
maxLng: bounds.getEast()
|
|
};
|
|
```
|
|
|
|
### 6. Geolocation
|
|
|
|
User position tracking:
|
|
|
|
**Features:**
|
|
- Blue pulsing circle marker at user's position
|
|
- Accuracy circle (outer ring)
|
|
- Automatic pan to location on click
|
|
- "Locating..." loading state
|
|
- Error handling for denied permissions
|
|
|
|
**Geolocate Button:**
|
|
- Floating control (top-right)
|
|
- Compass icon
|
|
- Primary color when active
|
|
- Error message if unavailable
|
|
|
|
### 7. Fullscreen Mode
|
|
|
|
Immersive map experience:
|
|
|
|
**Activation:**
|
|
- Fullscreen button (top-right, below geolocate)
|
|
- Browser Fullscreen API
|
|
- Fallback for Safari (`webkitRequestFullscreen`)
|
|
|
|
**Exit:**
|
|
- ESC key
|
|
- Exit fullscreen button (shows when active)
|
|
- Browser native controls
|
|
|
|
---
|
|
|
|
## User Workflow
|
|
|
|
### Initial Map View
|
|
|
|
1. User navigates to `/map`
|
|
2. PublicLayout renders with thin header
|
|
3. Map initializes at default center/zoom (from settings)
|
|
4. Viewport bounds calculated
|
|
5. API fetches locations within bounds
|
|
6. Circle markers render for each location
|
|
7. Cuts fetched and rendered (all visible by default)
|
|
|
|
### Exploring Locations
|
|
|
|
1. User pans map to new area
|
|
2. `moveend` event triggers after 800ms debounce
|
|
3. New viewport bounds calculated
|
|
4. API fetches locations in new bounds
|
|
5. Existing markers cleared
|
|
6. New markers rendered
|
|
7. User clicks marker to view popup
|
|
8. Popup shows address, support level, notes, last visit date
|
|
|
|
### Viewing Multi-Unit Buildings
|
|
|
|
1. User clicks purple building marker
|
|
2. Popup opens with building header
|
|
3. Unit list displays sorted units
|
|
4. User scrolls list (if >10 units)
|
|
5. User sees color-coded support levels per unit
|
|
6. User closes popup by clicking outside or X button
|
|
|
|
### Using Geolocation
|
|
|
|
1. User clicks geolocate button
|
|
2. Browser prompts for location permission
|
|
3. User grants permission
|
|
4. Blue pulsing marker appears at user's position
|
|
5. Map pans to center on user
|
|
6. Accuracy circle shows GPS precision
|
|
7. User can pan away (marker remains visible)
|
|
|
|
### Toggling Cut Visibility
|
|
|
|
1. User clicks "Cut Controls" button (bottom-left)
|
|
2. Panel expands showing cut checkboxes
|
|
3. User unchecks "Cut A"
|
|
4. "Cut A" polygon disappears from map
|
|
5. User clicks "Deselect All"
|
|
6. All polygons hidden
|
|
7. User clicks "Select All"
|
|
8. All polygons re-appear
|
|
|
|
### Fullscreen Mode
|
|
|
|
1. User clicks fullscreen button
|
|
2. Map expands to fill entire screen
|
|
3. Header hidden
|
|
4. Controls remain visible
|
|
5. User explores map at full size
|
|
6. User presses ESC key
|
|
7. Map returns to normal layout
|
|
|
|
---
|
|
|
|
## Component Structure
|
|
|
|
```tsx
|
|
import React, { useState, useEffect, useCallback } from 'react';
|
|
import { MapContainer, TileLayer, CircleMarker, Popup, useMap, useMapEvents, Polygon } from 'react-leaflet';
|
|
import { Button, Spin, Checkbox, Space, Typography, Badge } from 'antd';
|
|
import {
|
|
AimOutlined,
|
|
FullscreenOutlined,
|
|
FullscreenExitOutlined,
|
|
EnvironmentOutlined
|
|
} from '@ant-design/icons';
|
|
import { debounce } from 'lodash';
|
|
import PublicLayout from '../../components/PublicLayout';
|
|
import axios from 'axios';
|
|
import 'leaflet/dist/leaflet.css';
|
|
|
|
const { Text } = Typography;
|
|
|
|
interface Location {
|
|
id: string;
|
|
address: string;
|
|
latitude: number;
|
|
longitude: number;
|
|
supportLevel: string | null;
|
|
notes: string | null;
|
|
lastVisitDate: string | null;
|
|
isMultiUnit: boolean;
|
|
units?: Array<{
|
|
unitNumber: string;
|
|
supportLevel: string | null;
|
|
notes: string | null;
|
|
}>;
|
|
}
|
|
|
|
interface Cut {
|
|
id: string;
|
|
name: string;
|
|
color: string;
|
|
polygon: any; // GeoJSON
|
|
}
|
|
|
|
const MapPage: React.FC = () => {
|
|
const [locations, setLocations] = useState<Location[]>([]);
|
|
const [cuts, setCuts] = useState<Cut[]>([]);
|
|
const [visibleCuts, setVisibleCuts] = useState<Set<string>>(new Set());
|
|
const [loading, setLoading] = useState(false);
|
|
const [userPosition, setUserPosition] = useState<[number, number] | null>(null);
|
|
const [mapCenter, setMapCenter] = useState<[number, number]>([45.5017, -73.5673]);
|
|
const [mapZoom, setMapZoom] = useState(13);
|
|
|
|
// Component logic...
|
|
|
|
return (
|
|
<PublicLayout headerHeight={48}>
|
|
<MapContainer
|
|
center={mapCenter}
|
|
zoom={mapZoom}
|
|
style={{ height: 'calc(100vh - 48px)', width: '100%' }}
|
|
zoomControl={false}
|
|
>
|
|
<TileLayer
|
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
|
/>
|
|
|
|
{/* Locations */}
|
|
{/* Cuts */}
|
|
{/* User Position */}
|
|
{/* Controls */}
|
|
</MapContainer>
|
|
</PublicLayout>
|
|
);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## State Management
|
|
|
|
```tsx
|
|
// Location data
|
|
const [locations, setLocations] = useState<Location[]>([]);
|
|
const [cuts, setCuts] = useState<Cut[]>([]);
|
|
const [visibleCuts, setVisibleCuts] = useState<Set<string>>(new Set());
|
|
|
|
// Map state
|
|
const [mapCenter, setMapCenter] = useState<[number, number]>([45.5017, -73.5673]);
|
|
const [mapZoom, setMapZoom] = useState(13);
|
|
|
|
// User interaction
|
|
const [loading, setLoading] = useState(false);
|
|
const [userPosition, setUserPosition] = useState<[number, number] | null>(null);
|
|
const [fullscreen, setFullscreen] = useState(false);
|
|
```
|
|
|
|
---
|
|
|
|
## API Integration
|
|
|
|
### Endpoints
|
|
|
|
#### 1. Get Locations by Bounds
|
|
```http
|
|
GET /api/public/map/locations?minLat=45.4&maxLat=45.6&minLng=-73.7&maxLng=-73.4
|
|
```
|
|
|
|
Response:
|
|
```json
|
|
[
|
|
{
|
|
"id": "cm1abc123",
|
|
"address": "123 Main St",
|
|
"latitude": 45.5017,
|
|
"longitude": -73.5673,
|
|
"supportLevel": "strong_support",
|
|
"notes": "Yard sign requested",
|
|
"lastVisitDate": "2025-02-10T14:00:00.000Z",
|
|
"isMultiUnit": false
|
|
}
|
|
]
|
|
```
|
|
|
|
#### 2. Get Cuts
|
|
```http
|
|
GET /api/public/map/cuts
|
|
```
|
|
|
|
Response:
|
|
```json
|
|
[
|
|
{
|
|
"id": "cm2def456",
|
|
"name": "Downtown District",
|
|
"color": "#1890ff",
|
|
"polygon": {
|
|
"type": "Polygon",
|
|
"coordinates": [[[-73.6, 45.5], [-73.5, 45.5], [-73.5, 45.6], [-73.6, 45.6], [-73.6, 45.5]]]
|
|
}
|
|
}
|
|
]
|
|
```
|
|
|
|
---
|
|
|
|
## Code Examples
|
|
|
|
### Viewport-Based Loading with Debounce
|
|
|
|
```tsx
|
|
const MapEventsHandler = () => {
|
|
const map = useMap();
|
|
|
|
const fetchLocationsInBounds = useCallback(async () => {
|
|
const bounds = map.getBounds();
|
|
setLoading(true);
|
|
|
|
try {
|
|
const response = await axios.get('/api/public/map/locations', {
|
|
params: {
|
|
minLat: bounds.getSouth(),
|
|
maxLat: bounds.getNorth(),
|
|
minLng: bounds.getWest(),
|
|
maxLng: bounds.getEast()
|
|
}
|
|
});
|
|
setLocations(response.data);
|
|
} catch (error) {
|
|
console.error('Failed to fetch locations:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [map]);
|
|
|
|
const debouncedFetch = useCallback(
|
|
debounce(fetchLocationsInBounds, 800),
|
|
[fetchLocationsInBounds]
|
|
);
|
|
|
|
useMapEvents({
|
|
moveend: debouncedFetch
|
|
});
|
|
|
|
return null;
|
|
};
|
|
```
|
|
|
|
### Color-Coded Location Markers
|
|
|
|
```tsx
|
|
const getSupportLevelColor = (level: string | null): string => {
|
|
switch (level) {
|
|
case 'strong_support': return '#52c41a';
|
|
case 'leaning_support': return '#95de64';
|
|
case 'undecided': return '#fadb14';
|
|
case 'leaning_opposed': return '#ff7a45';
|
|
case 'opposed': return '#f5222d';
|
|
case 'no_answer': return '#8c8c8c';
|
|
case 'not_home': return '#d9d9d9';
|
|
default: return '#8c8c8c';
|
|
}
|
|
};
|
|
|
|
{locations.map(location => (
|
|
<CircleMarker
|
|
key={location.id}
|
|
center={[location.latitude, location.longitude]}
|
|
radius={8}
|
|
pathOptions={{
|
|
color: 'white',
|
|
weight: 2,
|
|
fillColor: getSupportLevelColor(location.supportLevel),
|
|
fillOpacity: 0.8
|
|
}}
|
|
>
|
|
<Popup>
|
|
<div style={{ minWidth: 200 }}>
|
|
<Text strong style={{ display: 'block', marginBottom: 8 }}>
|
|
{location.address}
|
|
</Text>
|
|
{location.supportLevel && (
|
|
<Text>Support: {location.supportLevel.replace('_', ' ')}</Text>
|
|
)}
|
|
{location.notes && (
|
|
<Text type="secondary" style={{ display: 'block', marginTop: 4, fontSize: 12 }}>
|
|
{location.notes}
|
|
</Text>
|
|
)}
|
|
</div>
|
|
</Popup>
|
|
</CircleMarker>
|
|
))}
|
|
```
|
|
|
|
### Multi-Unit Building Popup
|
|
|
|
```tsx
|
|
{location.isMultiUnit && location.units && (
|
|
<Popup>
|
|
<div style={{ minWidth: 300, maxHeight: 400, overflow: 'auto' }}>
|
|
<div style={{
|
|
background: '#722ed1',
|
|
color: 'white',
|
|
padding: 12,
|
|
margin: -12,
|
|
marginBottom: 12
|
|
}}>
|
|
<Text strong style={{ color: 'white', fontSize: 16 }}>
|
|
{location.address}
|
|
</Text>
|
|
<Badge
|
|
count={location.units.length}
|
|
style={{ marginLeft: 8, background: 'white', color: '#722ed1' }}
|
|
/>
|
|
</div>
|
|
|
|
<table style={{ width: '100%', fontSize: 12 }}>
|
|
<thead>
|
|
<tr style={{ borderBottom: '1px solid #f0f0f0' }}>
|
|
<th style={{ textAlign: 'left', padding: 4 }}>Unit</th>
|
|
<th style={{ textAlign: 'left', padding: 4 }}>Support</th>
|
|
<th style={{ textAlign: 'left', padding: 4 }}>Notes</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{location.units
|
|
.sort((a, b) => a.unitNumber.localeCompare(b.unitNumber, undefined, { numeric: true }))
|
|
.map((unit, idx) => (
|
|
<tr key={idx} style={{ borderBottom: '1px solid #f5f5f5' }}>
|
|
<td style={{ padding: 4 }}>{unit.unitNumber}</td>
|
|
<td style={{ padding: 4 }}>
|
|
<span style={{
|
|
background: getSupportLevelColor(unit.supportLevel),
|
|
color: 'white',
|
|
padding: '2px 6px',
|
|
borderRadius: 3,
|
|
fontSize: 11
|
|
}}>
|
|
{unit.supportLevel?.replace('_', ' ') || '-'}
|
|
</span>
|
|
</td>
|
|
<td style={{ padding: 4, fontSize: 11, color: '#666' }}>
|
|
{unit.notes || '-'}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Popup>
|
|
)}
|
|
```
|
|
|
|
---
|
|
|
|
## Performance Considerations
|
|
|
|
1. **Debounced Loading**: 800ms debounce prevents excessive API calls during panning
|
|
2. **Viewport Filtering**: Only loads visible locations (scalable to 10,000+ locations)
|
|
3. **React-Leaflet Optimization**: Uses `key` prop to prevent unnecessary re-renders
|
|
4. **Lazy Popup Rendering**: Popups created on-demand, not upfront
|
|
|
|
---
|
|
|
|
## Responsive Design
|
|
|
|
- **Mobile**: Full viewport height minus 48px header
|
|
- **Touch Gestures**: Native Leaflet touch support (pinch zoom, swipe pan)
|
|
- **Fullscreen**: Available on all devices via browser API
|
|
|
|
---
|
|
|
|
## Accessibility
|
|
|
|
- **Keyboard Navigation**: Map focusable, arrow keys pan
|
|
- **Button Labels**: All control buttons have aria-labels
|
|
- **Color Contrast**: Marker strokes ensure visibility on all backgrounds
|
|
- **Screen Reader**: Popup content readable, location count announced
|
|
|
|
---
|
|
|
|
## Troubleshooting
|
|
|
|
### Issue: Markers Not Appearing
|
|
|
|
**Causes:**
|
|
1. Locations outside viewport bounds
|
|
2. API returning empty array
|
|
3. Leaflet CSS not imported
|
|
|
|
**Solutions:**
|
|
```tsx
|
|
import 'leaflet/dist/leaflet.css'; // Must be imported
|
|
|
|
// Add debug logging
|
|
useEffect(() => {
|
|
console.log(`Loaded ${locations.length} locations`);
|
|
}, [locations]);
|
|
```
|
|
|
|
### Issue: Geolocation Not Working
|
|
|
|
**Causes:**
|
|
1. HTTPS required for geolocation API
|
|
2. User denied permission
|
|
3. Browser doesn't support geolocation
|
|
|
|
**Solutions:**
|
|
```tsx
|
|
const handleGeolocate = () => {
|
|
if (!navigator.geolocation) {
|
|
message.error('Geolocation not supported by your browser');
|
|
return;
|
|
}
|
|
|
|
navigator.geolocation.getCurrentPosition(
|
|
(position) => {
|
|
const pos: [number, number] = [
|
|
position.coords.latitude,
|
|
position.coords.longitude
|
|
];
|
|
setUserPosition(pos);
|
|
map.flyTo(pos, 16);
|
|
},
|
|
(error) => {
|
|
if (error.code === error.PERMISSION_DENIED) {
|
|
message.error('Location permission denied');
|
|
} else {
|
|
message.error('Unable to get your location');
|
|
}
|
|
}
|
|
);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## Related Documentation
|
|
|
|
- [Admin Map View](../admin/admin-map-view.md)
|
|
- [Locations Page](../admin/locations-page.md)
|
|
- [Cuts Page](../admin/cuts-page.md)
|
|
- [Map Settings](../admin/map-settings-page.md)
|