# 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([]); const [cuts, setCuts] = useState([]); const [visibleCuts, setVisibleCuts] = useState>(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 ( {/* Locations */} {/* Cuts */} {/* User Position */} {/* Controls */} ); }; ``` --- ## State Management ```tsx // Location data const [locations, setLocations] = useState([]); const [cuts, setCuts] = useState([]); const [visibleCuts, setVisibleCuts] = useState>(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 => (
{location.address} {location.supportLevel && ( Support: {location.supportLevel.replace('_', ' ')} )} {location.notes && ( {location.notes} )}
))} ``` ### Multi-Unit Building Popup ```tsx {location.isMultiUnit && location.units && (
{location.address}
{location.units .sort((a, b) => a.unitNumber.localeCompare(b.unitNumber, undefined, { numeric: true })) .map((unit, idx) => ( ))}
Unit Support Notes
{unit.unitNumber} {unit.supportLevel?.replace('_', ' ') || '-'} {unit.notes || '-'}
)} ``` --- ## 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)