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 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

  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

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='&copy; <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

  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:

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');
      }
    }
  );
};