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:
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¶
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¶
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: 1. Locations outside viewport bounds 2. API returning empty array 3. 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: 1. HTTPS required for geolocation API 2. User denied permission 3. 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');
}
}
);
};