15 KiB
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
moveendevent (pan/zoom complete) - Debounce 800ms to prevent excessive requests
- Loading spinner in top-right during fetch
Bounds Calculation:
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
- User navigates to
/map - PublicLayout renders with thin header
- Map initializes at default center/zoom (from settings)
- Viewport bounds calculated
- API fetches locations within bounds
- Circle markers render for each location
- Cuts fetched and rendered (all visible by default)
Exploring Locations
- User pans map to new area
moveendevent triggers after 800ms debounce- New viewport bounds calculated
- API fetches locations in new bounds
- Existing markers cleared
- New markers rendered
- User clicks marker to view popup
- Popup shows address, support level, notes, last visit date
Viewing Multi-Unit Buildings
- User clicks purple building marker
- Popup opens with building header
- Unit list displays sorted units
- User scrolls list (if >10 units)
- User sees color-coded support levels per unit
- User closes popup by clicking outside or X button
Using Geolocation
- User clicks geolocate button
- Browser prompts for location permission
- User grants permission
- Blue pulsing marker appears at user's position
- Map pans to center on user
- Accuracy circle shows GPS precision
- User can pan away (marker remains visible)
Toggling Cut Visibility
- User clicks "Cut Controls" button (bottom-left)
- Panel expands showing cut checkboxes
- User unchecks "Cut A"
- "Cut A" polygon disappears from map
- User clicks "Deselect All"
- All polygons hidden
- User clicks "Select All"
- All polygons re-appear
Fullscreen Mode
- User clicks fullscreen button
- Map expands to fill entire screen
- Header hidden
- Controls remain visible
- User explores map at full size
- User presses ESC key
- Map returns to normal layout
Component Structure
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
// 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
GET /api/public/map/locations?minLat=45.4&maxLat=45.6&minLng=-73.7&maxLng=-73.4
Response:
[
{
"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
GET /api/public/map/cuts
Response:
[
{
"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
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
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
{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
- Debounced Loading: 800ms debounce prevents excessive API calls during panning
- Viewport Filtering: Only loads visible locations (scalable to 10,000+ locations)
- React-Leaflet Optimization: Uses
keyprop to prevent unnecessary re-renders - 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:
- Locations outside viewport bounds
- API returning empty array
- Leaflet CSS not imported
Solutions:
import 'leaflet/dist/leaflet.css'; // Must be imported
// Add debug logging
useEffect(() => {
console.log(`Loaded ${locations.length} locations`);
}, [locations]);
Issue: Geolocation Not Working
Causes:
- HTTPS required for geolocation API
- User denied permission
- Browser doesn't support geolocation
Solutions:
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');
}
}
);
};