40 KiB

LocationsPage

Overview

The LocationsPage is the centerpiece of the Map module, providing comprehensive location database management with dual-tab view (table + interactive map), multi-format CSV import (standard, NAR upload, NAR server), multi-provider geocoding, bulk operations, location history tracking, and expandable address units for multi-unit buildings. This is the most feature-rich CRUD page in the admin interface, handling millions of location records for canvassing operations.

Route: /app/map/locations Component: admin/src/pages/LocationsPage.tsx (1960 lines) Auth Required: Yes (SUPER_ADMIN, MAP_ADMIN roles) Layout: AppLayout

Screenshot

[Screenshot: Locations page with two rows of statistics cards at top (Total, Single Family, Multi-Unit, Mixed Use, Commercial, Geocoded percentage in first row; High/Medium/Low/Manual confidence levels + Avg Confidence in second row). Below stats are two tabs: "Table" (active) and "Map". Table tab shows search bar + confidence filter dropdown + "Delete Selected" button (when rows selected). Main table has columns: Address, Building Type (tags), Total Units, Coordinates, Geocode (confidence tags with provider), Created, Actions (edit + delete). Page header has 6 action buttons: Settings, Export CSV, Import CSV, Geocode Missing, Bulk Re-Geocode, Add Location.]

Features

Core Features

  • Dual-tab interface — Table view (CRUD operations) + Map view (visual management)
  • Full CRUD operations — Create, read, update, delete locations
  • Advanced search — 300ms debounced search by address or postal code
  • Confidence filtering — Filter by High (≥85%), Medium (60-84%), Low (<60%), Manual/None
  • Building type tracking — SINGLE_FAMILY, MULTI_UNIT, MIXED_USE, COMMERCIAL
  • Multi-unit support — Expandable rows show Address units (apartments) with contact info
  • Location history — Track all changes (created, updated, moved, geocoded) with user attribution
  • Bulk operations — Select multiple rows, bulk delete with confirmation

Geocoding Features

  • Multi-provider geocoding — 6 providers (Google, Nominatim, ArcGIS, Photon, Mapbox, Pelias)
  • Inline geocoding — "Geocode" button in create/edit forms
  • Geocode Missing — Batch geocode all locations without coordinates
  • Bulk Re-Geocode — Background job to improve low-confidence locations
  • Confidence scoring — 0-100% confidence with provider attribution
  • Reverse geocoding — Click map → auto-fill address from coordinates

Import Features

  • Standard CSV import — Simple address/contact CSV with flexible column mapping
  • NAR Upload import — Statistics Canada NAR format (2025 + legacy) with client-side upload (max 100MB)
  • NAR Server import — Server-side streaming import for multi-GB NAR datasets
  • Geographic filters — Import by cut boundary, city name, postal prefix, province, or map area
  • Residential filtering — Skip non-residential addresses (commercial, industrial)
  • Deduplication — 5m radius deduplication to prevent duplicate imports
  • Real-time progress — Live progress bars + stats during NAR server imports

Map Features (Map Tab)

  • Interactive Leaflet map — Click-to-add, drag-to-move, GPS locate, fullscreen
  • Color-coded markers — Visual building type distinction
  • Cut overlays — Polygon boundaries with toggle controls
  • Auto-refresh — Viewport-based loading (800ms debounce)
  • Bounds filtering — Only load locations in current view (max 5000 per request)
  • Click-to-add mode — Click map → reverse geocode → create form
  • Drag-to-move mode — Drag marker → update coordinates

Statistics Dashboard

  • Building type breakdown — 5 cards (Total, Single Family, Multi-Unit, Mixed Use, Commercial)
  • Geocode coverage — Percentage geocoded + average confidence
  • Confidence distribution — 4 cards (High, Medium, Low, Manual/None) with counts + icons

User Workflow

Viewing Locations (Table Tab)

  1. Navigate to /app/map/locations
  2. Page loads with statistics cards at top:
    • First row: Total count, building type breakdowns, geocode percentage
    • Second row: Confidence distribution (high/medium/low/none), average confidence
  3. Table tab active by default (20 locations per page)
  4. View location details:
    • Address (bold)
    • Building Type tag (color-coded)
    • Total Units (1 for single-family, 2+ for multi-unit)
    • Coordinates (lat, lng to 5 decimals)
    • Geocode confidence (tag + provider name)
    • Created date
    • Actions (edit, delete)
  5. Expand row (click anywhere) if Total Units > 1:
    • Shows Address units table (apartments)
    • Columns: Unit, Name, Contact, Building Type, Notes
  6. Use pagination at bottom (10/20/50/100 per page)

Searching and Filtering

  1. Search bar (top left):
    • Type address or postal code
    • 300ms debounce (waits for typing pause)
    • Search resets pagination to page 1
  2. Confidence filter (top right):
    • Select High, Medium, Low, or Manual/None
    • Filter resets pagination to page 1
    • Clear to show all locations
  3. Filters persist during pagination

Creating a Location Manually

  1. Click "Add Location" button in page header
  2. Modal opens (600px width) with vertical form
  3. Fill required fields:
    • Street Address (base building address, no unit number)
      • Example: "123 Main St, City, Province"
      • Click "Geocode" button (in input addonAfter) to auto-fill coordinates
    • Latitude (decimal degrees, 5 decimals, e.g. 45.42153)
    • Longitude (decimal degrees, 5 decimals, e.g. -75.69719)
  4. Select Building Type (radio buttons, default: SINGLE_FAMILY):
    • Single Family
    • Multi-Unit
    • Mixed Use
    • Commercial
  5. Add Building Notes (optional):
    • Access codes, manager contact, buzzer instructions
    • Example: "Access code: 1234, Ring buzzer for manager"
  6. Click "Create" button
  7. Success message: "Location created"
  8. Modal closes, table refreshes to page 1, stats refresh
  9. If Map tab open, new marker appears

Using Inline Geocoding

  1. In create/edit form, type address in Street Address field
  2. Click "Geocode" button (AimOutlined icon in input addonAfter)
  3. Button shows loading spinner
  4. API calls multi-provider geocoding service
  5. On success:
    • Latitude and Longitude fields auto-fill
    • Success message: "Geocoded (Google, 95% confidence)"
    • Provider name + confidence shown in message
  6. On failure:
    • Error message: "Could not geocode address"
    • Manually enter coordinates or try different address

Geocoding Missing Locations

  1. Click "Geocode Missing" button in page header
  2. Button shows loading state
  3. API geocodes all locations without coordinates (latitude = null OR longitude = null)
  4. Success message: "Geocoded 847 of 1250 locations (403 failed)"
  5. Table refreshes, stats update
  6. Failed locations remain without coordinates (low-quality addresses)

Bulk Re-Geocoding (Background Job)

  1. Click "Bulk Re-Geocode" button in page header
  2. Modal opens (600px width) with form:
    • Confidence Threshold (%): Only geocode below this (default: 60)
    • Building Type Filter: Optionally filter by type
    • Maximum Locations: Process up to N locations (default: 1000, max: 5000)
  3. Click "Start Bulk Re-Geocode" button
  4. Background job starts (BullMQ queue)
  5. Modal shows live progress:
    • Progress bar (percentage complete)
    • Current address being processed
    • Stats: Processed X / Total, Improved, Unchanged, Failed
  6. Job completes:
    • Final stats shown
    • Success message: "Bulk geocoding complete: 234 improved, 512 unchanged, 14 failed"
    • Click "Close" button
  7. Table refreshes, stats update
  8. Only locations with IMPROVED results are updated (unchanged locations left alone)

Importing Standard CSV

  1. Click "Import CSV" button in page header
  2. Modal opens (620px width) with 3 radio buttons:
    • Standard CSV (selected by default)
    • NAR Upload
    • NAR Server
  3. Read format instructions:
    • Columns: address, first name, last name, email, phone, unit number, support level (1-4), sign (yes/no), sign size, notes, latitude, longitude
    • Column names matched flexibly (case-insensitive, ignores punctuation)
  4. Drag CSV file or click to upload
  5. File uploads, backend processes:
    • Parses CSV rows
    • Creates Location records (with addresses)
    • Creates Address records (units) if unit number present
    • Geocodes if lat/lng missing (optional)
  6. Success message: "Imported 450 of 500 locations (30 warnings, 20 failed)"
  7. If errors, warning modal shows error list (max 300px height, scrollable)
  8. Modal closes, table refreshes, stats update

Importing NAR Upload (Client-Side)

  1. Click "Import CSV" button
  2. Switch to "NAR Upload" radio button
  3. Read format instructions:
    • Statistics Canada NAR Address CSV
    • Supports 2025 format (CIVIC_NO, OFFICIAL_STREET_NAME, BG_X/BG_Y) and legacy format (STR_NBR, STR_NME, LAT/LNG)
    • Auto-detects format
  4. Configure Geographic Filter dropdown:
    • No filter — import all rows
    • Map settings area — use configured center + zoom from Map Settings
    • City name — enter city (e.g. "Ottawa", "Edmonton")
    • Province / Territory — select from dropdown (13 provinces/territories)
    • Cut boundary — select from cuts dropdown (only locations inside polygon)
  5. Toggle "Residential only" switch:
    • ON (default): Skip commercial/industrial addresses
    • OFF: Import all addresses
  6. Drag CSV file or click to upload (max 100MB)
  7. File uploads, backend processes:
    • Parses NAR format (2025 or legacy)
    • Joins Address + Location files if NAR 2025 format
    • Converts BG_X/BG_Y (EPSG:3347 Lambert projection) to lat/lng using proj4
    • Applies geographic filters (cut, city, province, map area)
    • Deduplicates within 5m radius
    • Batches 1000 rows at a time
  8. Progress indicator during import
  9. Results shown in modal:
    • 6 statistics cards (Total Rows, Created, Duplicates, Out of Bounds, Invalid, Errors)
    • Error list (if any)
  10. Success message: "Created X of Y locations"
  11. Table refreshes, stats update

Importing NAR Server (Server-Side Streaming)

  1. Click "Import CSV" button
  2. Switch to "NAR Server" radio button
  3. Click "Scan Server Directory" button (first time only)
  4. Backend scans NAR_DATA_DIR (./data volume mount) for:
    • Addresses/ directory with Address_{provinceCode}part{N}.csv files
    • Locations/ directory with Location_{provinceCode}.csv files
  5. Modal shows available provinces:
    • Example: "ON — Ontario (6 files, 2.3 GB)"
    • File count includes multi-part Address files
  6. Select province from dropdown
  7. Configure Geographic Filter:
    • No filter — import all addresses
    • City name — enter city
    • Postal code prefix (FSA) — 3 chars (e.g. K1A, E3B)
    • Cut boundary — select from cuts dropdown
  8. Toggle "Residential only" switch (default: ON)
  9. Click "Import {Province} Addresses" button
  10. Backend starts streaming import:
    • Scans all Address files for province (multi-part files joined)
    • Loads Location file (lat/lng coordinates)
    • Joins Address + Location on LOC_GUID
    • Converts BG_X/BG_Y to lat/lng using proj4
    • Applies filters (city, postal, cut, residential)
    • Deduplicates within 5m radius
    • Batches 1000 rows at a time
    • Streams to DB (never loads full dataset in memory)
  11. Live progress display (polls every 2 seconds):
    • Progress bar (animated, 99.9% until complete)
    • Status text: "Processing Address_24_part_3..."
    • 3 statistics cards: Rows Processed, Locations Created, Skipped
  12. Import completes:
    • Final stats shown (Total Rows, Created, Duplicates, Out of Bounds, Non-Residential, Invalid)
    • Duration shown in seconds
    • Success message: "Imported 12,847 locations from Ontario in 43.2s"
  13. Table refreshes, stats update

NAR Server vs Upload:

  • NAR Server: For multi-GB datasets (1M+ addresses), streams from server disk, no file upload, no size limit
  • NAR Upload: For smaller datasets (<100MB), client uploads file, faster for small imports

Viewing Map (Map Tab)

  1. Click "Map" tab (EnvironmentOutlined icon)
  2. Map loads with AdminMapView component
  3. Initial load fetches all locations (no bounds filter)
  4. Locations render as colored circle markers:
    • Blue: Single Family
    • Green: Multi-Unit
    • Orange: Mixed Use
    • Purple: Commercial
  5. Cut polygons overlay map (if any cuts exist)
  6. Floating controls on map:
    • Add — Enter click-to-add mode
    • Move — Enter drag-to-move mode
    • GPS — Geolocate to current position
    • Fullscreen — Toggle fullscreen mode
    • Refresh — Reload locations in current view
    • Cut toggles — Show/hide cut overlays
  7. Pan/zoom map → auto-refreshes after 800ms debounce
  8. Click marker → location detail popup (address, building type, edit button)

Adding Location from Map

  1. In Map tab, click "Add" control button
  2. Click-to-add mode activated (cursor changes)
  3. Click anywhere on map
  4. Backend reverse geocodes coordinates (Nominatim)
  5. Create modal opens with pre-filled values:
    • Latitude (rounded to 5 decimals)
    • Longitude (rounded to 5 decimals)
    • Address (reverse geocoded, e.g. "123 Main St, City")
  6. Adjust values if needed
  7. Select building type
  8. Click "Create"
  9. New marker appears on map
  10. Table updates if viewing Table tab

Moving Location on Map

  1. In Map tab, click "Move" control button
  2. Drag-to-move mode activated
  3. Click and drag any marker to new position
  4. On release, coordinates update:
    • PUT /api/map/locations/:id with new lat/lng
    • Marker snaps to new position
    • Success message: "Location moved"
  5. Table updates if viewing Table tab

Editing a Location

  1. From Table tab:
    • Click Edit icon button (EditOutlined) in Actions column
  2. From Map tab:
    • Click marker → popup → click Edit button
  3. Drawer opens on right side (700px width) with 2 tabs:
    • Details tab (active by default)
    • History tab (ClockCircleOutlined icon)
  4. Details tab shows edit form:
    • Same fields as create form
    • Pre-filled with current values
    • Geocode button available
  5. Modify any fields
  6. Click "Save" button in drawer header
  7. Success message: "Location updated"
  8. Drawer closes, table refreshes, stats update
  9. Map refreshes if viewing Map tab

Viewing Location History

  1. Open location in edit drawer
  2. Click "History" tab (ClockCircleOutlined icon)
  3. Table loads with location history:
    • Columns: Action, Field, Change, User, When
    • Action tags (color-coded):
      • CREATED (green)
      • UPDATED (blue)
      • GEOCODED (cyan)
      • MOVED (orange)
    • Field shows which field changed (e.g. address, latitude)
    • Change shows old → new values (strikethrough old, bold new)
    • User shows email or "System"
    • When shows timestamp (MMM D, YYYY h:mm A)
  4. Pagination at bottom (20 per page)
  5. History sorted newest first (most recent at top)

Bulk Deleting Locations

  1. In Table tab, select checkbox for multiple rows
  2. "Delete Selected (N)" button appears above table
  3. Click button
  4. Popconfirm: "Delete N locations?"
  5. Click "OK"
  6. Success message: "Deleted N locations"
  7. Selection cleared, table refreshes, stats update

Exporting CSV

  1. Click "Export CSV" button in page header
  2. Browser downloads CSV file: locations-YYYY-MM-DD.csv
  3. File contains all locations (not just current page):
    • Columns: id, address, latitude, longitude, buildingType, buildingNotes, postalCode, province, federalDistrict, buildingUse, geocodeProvider, geocodeConfidence, totalUnits, createdAt, updatedAt
  4. Open in Excel, Google Sheets, or text editor
  5. Use for backups, analysis, or importing to other systems

Component Breakdown

Ant Design Components Used

  • Table — Main locations list with columns, pagination, row selection, expandable rows
  • Tabs — Dual-tab interface (Table, Map)
  • Input — Search bar with SearchOutlined prefix
  • Select — Confidence filter dropdown, province/cut filters in import modal
  • Button — 6 header actions (Settings, Export, Import, Geocode Missing, Bulk Re-Geocode, Add Location) + table actions (Edit, Delete)
  • Card — Statistics cards (9 cards in 2 rows)
  • Statistic — Numeric displays with icons, prefixes, suffixes
  • Progress — Bulk geocode + NAR import progress bars
  • Modal — Create location, import CSV (3 modes), bulk re-geocode
  • Drawer — Edit location (700px width, 2 tabs)
  • Form — Create/edit location forms, bulk geocode config
  • Form.Item — Field wrappers with labels, rules, help text
  • InputNumber — Numeric fields (latitude, longitude, max locations)
  • Radio.Group — Import format selector (3 buttons), building type selector (4 buttons)
  • Switch — Residential-only toggle in import modals
  • Upload.Dragger — CSV file upload UI
  • Row, Col — Responsive grid for stats cards, form fields
  • Tag — Building type tags, confidence tags, geocode provider, history action tags
  • Space — Action button grouping
  • Popconfirm — Delete confirmation (single + bulk)
  • DatePicker — (Not used, but imported for future features)
  • TimePicker — (Not used, but imported for future features)
  • Typography.Text — Labels, descriptions, secondary text

Table Columns

const columns: ColumnsType<Location> = [
  {
    title: 'Address',
    dataIndex: 'address',
    render: (addr) => <span style={{ fontWeight: 500 }}>{addr || '--'}</span>,
  },
  {
    title: 'Building Type',
    dataIndex: 'buildingType',
    render: (type: BuildingType) => (
      <Tag color={BUILDING_TYPE_COLORS[type]}>
        {BUILDING_TYPE_LABELS[type]}
      </Tag>
    ),
    responsive: ['md'],
  },
  {
    title: 'Total Units',
    dataIndex: 'totalUnits',
    align: 'center',
    width: 120,
    responsive: ['md'],
  },
  {
    title: 'Coordinates',
    render: (_, record) =>
      record.latitude && record.longitude
        ? `${Number(record.latitude).toFixed(5)}, ${Number(record.longitude).toFixed(5)}`
        : '--',
    responsive: ['lg'],
  },
  {
    title: 'Geocode',
    render: (_, record) => {
      if (record.geocodeConfidence != null && record.geocodeConfidence > 0) {
        const confidence = record.geocodeConfidence;
        let color, icon, label;
        if (confidence >= 85) {
          color = 'success';
          icon = <CheckCircleOutlined />;
          label = `High (${confidence}%)`;
        } else if (confidence >= 60) {
          color = 'warning';
          icon = <InfoCircleOutlined />;
          label = `Medium (${confidence}%)`;
        } else {
          color = 'error';
          icon = <WarningOutlined />;
          label = `Low (${confidence}%)`;
        }
        return (
          <Space direction="vertical" size={0}>
            <Tag color={color} icon={icon}>{label}</Tag>
            {record.geocodeProvider && (
              <Text type="secondary" style={{ fontSize: 11 }}>
                {record.geocodeProvider.toLowerCase()}
              </Text>
            )}
          </Space>
        );
      }
      if (record.latitude && record.longitude) {
        return <Tag color="blue">Manual</Tag>;
      }
      return <Tag>None</Tag>;
    },
    responsive: ['lg'],
  },
  {
    title: 'Created',
    dataIndex: 'createdAt',
    render: (date) => dayjs(date).format('YYYY-MM-DD'),
    responsive: ['xl'],
  },
  {
    title: 'Actions',
    width: 120,
    render: (_, record) => (
      <Space>
        <Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(record)} />
        <Popconfirm title="Delete this location?" onConfirm={() => handleDelete(record.id)}>
          <Button type="link" size="small" danger icon={<DeleteOutlined />} />
        </Popconfirm>
      </Space>
    ),
  },
];

Key patterns:

  • responsive array controls column visibility on different screen sizes
  • render functions for custom content (tags, icons, formatted values)
  • align: 'center' for numeric columns
  • width prop for fixed-width columns

Expandable Rows (Address Units)

const expandedRowRender = (location: Location) => {
  if (!location.addresses || location.addresses.length === 0) {
    return (
      <div style={{ padding: '12px 24px', textAlign: 'center' }}>
        <Text type="secondary">No units/addresses defined for this location yet.</Text>
        <div style={{ marginTop: 8, fontSize: 12 }}>
          <Text type="secondary">Units can be added during canvassing or imported via NAR data.</Text>
        </div>
      </div>
    );
  }

  const addressColumns: ColumnsType<Address> = [
    { title: 'Unit', dataIndex: 'unitNumber', width: 100, render: (unit) => unit || '--' },
    { title: 'Name', render: (_, addr) => [addr.firstName, addr.lastName].filter(Boolean).join(' ') || '--' },
    { title: 'Contact', render: (_, addr) => [addr.email, addr.phone].filter(Boolean).join(' • ') || '--', responsive: ['md'] },
    { title: 'Building Type', dataIndex: 'buildingType', render: (type) => <Tag color={colors[type]}>{labels[type]}</Tag> },
    { title: 'Notes', dataIndex: 'notes', ellipsis: true, render: (notes) => notes || '--', responsive: ['lg'] },
  ];

  return (
    <div style={{ padding: '0 24px 12px' }}>
      <Table<Address>
        columns={addressColumns}
        dataSource={location.addresses}
        rowKey="id"
        pagination={false}
        size="small"
        bordered
      />
    </div>
  );
};

// In main table:
<Table
  expandable={{
    expandedRowRender,
    rowExpandable: (record) => (record.totalUnits > 1 || (record.addresses && record.addresses.length > 0)),
  }}
/>

Pattern: Nested table shows Address units (apartments) for multi-unit buildings. Only expandable if totalUnits > 1 or addresses array exists.

Statistics Cards

{stats && (
  <Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
    <Col xs={12} sm={8} md={4}>
      <Card size="small">
        <Statistic title="Total" value={stats.total} />
      </Card>
    </Col>
    <Col xs={12} sm={8} md={4}>
      <Card size="small">
        <Statistic title="Single Family" value={stats.buildingTypes.SINGLE_FAMILY} valueStyle={{ color: '#1890ff' }} />
      </Card>
    </Col>
    {/* 4 more building type cards */}
  </Row>
)}

{stats && stats.confidence && (
  <Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
    <Col xs={12} sm={6} md={4}>
      <Card size="small">
        <Statistic
          title="High Confidence"
          value={stats.confidence.high}
          prefix={<CheckCircleOutlined />}
          valueStyle={{ color: '#52c41a' }}
          suffix={<Text type="secondary" style={{ fontSize: 12 }}>85%</Text>}
        />
      </Card>
    </Col>
    {/* 3 more confidence cards */}
  </Row>
)}

Layout:

  • First row: 6 cards (Total + 4 building types + Geocoded %)
  • Second row: 5 cards (High/Medium/Low/None confidence + Avg confidence)
  • Responsive: xs (2 columns), sm (3-4 columns), md+ (6 columns)

NAR Format Detection

NAR import supports two formats:

  • 2025 format: CIVIC_NO, OFFICIAL_STREET_NAME, BG_X, BG_Y (Lambert projection EPSG:3347)
  • Legacy format: STR_NBR, STR_NME, LAT, LNG (decimal degrees)

Backend auto-detects format by checking for presence of CIVIC_NO column.

2025 format with proj4 conversion:

import proj4 from 'proj4';

// Define Lambert Conformal Conic projection (Statistics Canada NAR)
proj4.defs('EPSG:3347', '+proj=lcc +lat_1=49 +lat_2=77 +lat_0=63.390675 +lon_0=-91.86666666666666 +x_0=6200000 +y_0=3000000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs');

// Convert BG_X, BG_Y (meters) to lat, lng (decimal degrees)
const [lng, lat] = proj4('EPSG:3347', 'EPSG:4326', [bgX, bgY]);

File join (2025 format only):

// Address file: CIVIC_NO, OFFICIAL_STREET_NAME, LOC_GUID (no coordinates)
// Location file: LOC_GUID, BG_LATITUDE, BG_LONGITUDE (coordinates only)
// Join on LOC_GUID to get address + coordinates

Map Auto-Refresh

const handleMapMove = useCallback((map: any) => {
  // Skip if map is animating (prevents disrupting zoom transitions)
  if (map._animatingZoom || map._moving) {
    return;
  }

  const b = map.getBounds();
  const newBounds = {
    minLat: b.getSouth(),
    maxLat: b.getNorth(),
    minLng: b.getWest(),
    maxLng: b.getEast(),
  };

  // Store current bounds for auto-refresh
  currentBoundsRef.current = newBounds;

  clearTimeout(fetchTimerRef.current);
  fetchTimerRef.current = setTimeout(() => {
    // Mark as background fetch to prevent loading state during viewport changes
    fetchAllLocations(newBounds, true);
  }, 800); // Increased debounce to 800ms to allow zoom animations to complete
}, [fetchAllLocations]);

Why 800ms debounce?

  • Allows zoom/pan animations to complete
  • Prevents API spam during dragging
  • Only fetches when user pauses
  • Background fetch (no loading spinner) for smooth UX

Safety limit:

if (data.length === 5000) {
  message.warning('Too many locations in view. Zoom in for more detail.', 3);
}

Backend returns max 5000 locations per request to prevent memory issues.

State Management

Zustand Stores Used

None — Locations fetched from API on each interaction. No global state required (unlike canvass or auth).

Local State

const [locations, setLocations] = useState<Location[]>([]);
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
const [loading, setLoading] = useState(false);
const [stats, setStats] = useState<LocationStats | null>(null);
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const [confidenceFilter, setConfidenceFilter] = useState<'high' | 'medium' | 'low' | 'none' | undefined>();

// Modals/Drawers
const [createModalOpen, setCreateModalOpen] = useState(false);
const [editDrawerOpen, setEditDrawerOpen] = useState(false);
const [editingLocation, setEditingLocation] = useState<Location | null>(null);
const [locationHistory, setLocationHistory] = useState<LocationHistory[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
const [historyPagination, setHistoryPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
const [importModalOpen, setImportModalOpen] = useState(false);
const [importing, setImporting] = useState(false);
const [geocodingMissing, setGeocodingMissing] = useState(false);
const [importFormat, setImportFormat] = useState<'standard' | 'nar' | 'server'>('standard');
const [bulkImportResult, setBulkImportResult] = useState<BulkImportResult | null>(null);
const [cuts, setCuts] = useState<Cut[]>([]);

// NAR Server Import state
const [narDatasets, setNarDatasets] = useState<NarDataset[]>([]);
const [narDatasetsLoading, setNarDatasetsLoading] = useState(false);
const [narDir, setNarDir] = useState<string | null>(null);
const [narSelectedProvince, setNarSelectedProvince] = useState<string | undefined>();
const [narImportResult, setNarImportResult] = useState<NarServerImportResult | null>(null);
const [narProgress, setNarProgress] = useState<NarImportProgress | null>(null);
const narPollRef = useRef<ReturnType<typeof setInterval>>(undefined);

// Bulk Re-Geocoding state
const [bulkGeocodeModalOpen, setBulkGeocodeModalOpen] = useState(false);
const [bulkGeocoding, setBulkGeocoding] = useState(false);
const [bulkGeocodeJobId, setBulkGeocodeJobId] = useState<string | null>(null);
const [bulkGeocodeStatus, setBulkGeocodeStatus] = useState<any>(null);
const bulkGeocodePollRef = useRef<ReturnType<typeof setInterval>>(undefined);
const [bulkGeocodeForm] = Form.useForm();

// Tabs + map
const [activeTab, setActiveTab] = useState('table');
const [allLocations, setAllLocations] = useState<Location[]>([]);
const [mapLoading, setMapLoading] = useState(false);
const fetchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const abortControllerRef = useRef<AbortController | null>(null);
const currentBoundsRef = useRef<{ minLat: number; maxLat: number; minLng: number; maxLng: number } | null>(null);

// Selection for bulk ops
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);

const [createForm] = Form.useForm();
const [editForm] = Form.useForm();
const [geocoding, setGeocoding] = useState(false);

Complexity: 40+ state variables for comprehensive feature set.

Polling Patterns

NAR Server Import Polling:

// Start polling
narPollRef.current = setInterval(async () => {
  try {
    const { data: progress } = await api.get<NarImportProgress>(`/map/nar-import/status/${importId}`);
    setNarProgress(progress);

    if (progress.status === 'complete') {
      stopNarPolling();
      setImporting(false);
      if (progress.result) {
        setNarImportResult(progress.result);
        message.success(`Imported ${progress.result.created} locations from ${progress.result.provinceName} in ${(progress.result.durationMs / 1000).toFixed(1)}s`);
      }
      fetchLocations({ page: 1 });
      fetchStats();
    } else if (progress.status === 'failed') {
      stopNarPolling();
      setImporting(false);
      message.error(progress.error || 'NAR import failed');
    }
  } catch {
    // Polling error — don't stop, might be transient
  }
}, 2000);  // Poll every 2 seconds

// Cleanup on unmount
useEffect(() => {
  return () => stopNarPolling();
}, [stopNarPolling]);

Bulk Geocode Polling:

Similar pattern for bulk geocoding background job. Polls job status every 2 seconds until complete/failed.

API Integration

Endpoints Used

Method Endpoint Purpose
GET /api/map/locations List locations (paginated, filtered)
GET /api/map/locations/stats Fetch statistics (counts, confidence)
GET /api/map/locations/all Fetch all locations (optionally bounds-filtered, max 5000)
GET /api/map/locations/:id Fetch single location with addresses
GET /api/map/locations/:id/history Fetch location history (paginated)
POST /api/map/locations Create location
PUT /api/map/locations/:id Update location
DELETE /api/map/locations/:id Delete location
POST /api/map/locations/bulk-delete Bulk delete locations
POST /api/map/locations/geocode Geocode single address
POST /api/map/locations/reverse-geocode Reverse geocode coordinates
POST /api/map/locations/geocode-missing Batch geocode all missing
POST /api/map/locations/bulk-geocode Start bulk re-geocode job
GET /api/map/locations/bulk-geocode/:jobId Poll bulk geocode status
GET /api/map/locations/export-csv Export all locations as CSV
POST /api/map/locations/import-csv Import standard CSV
POST /api/map/locations/import-bulk Import NAR CSV (client upload)
GET /api/map/nar-import/datasets Scan server NAR directory
POST /api/map/nar-import Start NAR server import
GET /api/map/nar-import/status/:importId Poll NAR import progress

List Locations

Request:

const { data } = await api.get<LocationsListResponse>('/map/locations', {
  params: {
    page: 1,
    limit: 20,
    search: '123 Main',           // Optional: search address or postal
    confidenceLevel: 'high',      // Optional: high, medium, low, none
  },
});

Response:

{
  "locations": [
    {
      "id": "loc-123",
      "address": "123 Main St, Ottawa, ON K1A 0A1",
      "buildingType": "MULTI_UNIT",
      "buildingNotes": "Access code: 1234",
      "latitude": "45.42153",
      "longitude": "-75.69719",
      "postalCode": "K1A0A1",
      "province": "ON",
      "federalDistrict": "Ottawa—Vanier",
      "buildingUse": "RESIDENTIAL",
      "geocodeProvider": "GOOGLE",
      "geocodeConfidence": 95,
      "geocodeAddress": "123 Main Street, Ottawa, Ontario K1A 0A1, Canada",
      "totalUnits": 12,
      "createdAt": "2026-01-15T10:00:00.000Z",
      "updatedAt": "2026-01-20T14:30:00.000Z",
      "addresses": [
        {
          "id": "addr-456",
          "unitNumber": "101",
          "firstName": "John",
          "lastName": "Doe",
          "email": "john@example.com",
          "phone": "613-555-1234",
          "buildingType": "MULTI_UNIT",
          "notes": "Friendly, supports campaign"
        },
        // ... 11 more units
      ]
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "total": 5847,
    "totalPages": 293
  }
}

Key fields:

  • addresses — Nested array of Address units (apartments)
  • totalUnits — Count of units (1 for single-family, 2+ for multi-unit)
  • geocodeProvider — Provider name (GOOGLE, NOMINATIM, etc.)
  • geocodeConfidence — 0-100% confidence score
  • postalCode, province, federalDistrict — NAR import fields

Fetch Statistics

Request:

const { data } = await api.get<LocationStats>('/map/locations/stats');

Response:

{
  "total": 5847,
  "buildingTypes": {
    "SINGLE_FAMILY": 4123,
    "MULTI_UNIT": 1234,
    "MIXED_USE": 345,
    "COMMERCIAL": 145
  },
  "geocoded": 5421,
  "confidence": {
    "high": 4567,
    "medium": 789,
    "low": 65,
    "none": 426,
    "average": 87.3
  }
}

Geocode Address

Request:

const { data } = await api.post<GeocodeResult>('/map/locations/geocode', {
  address: '123 Main St, Ottawa, ON',
});

Response:

{
  "latitude": 45.42153,
  "longitude": -75.69719,
  "provider": "GOOGLE",
  "confidence": 95,
  "geocodedAddress": "123 Main Street, Ottawa, Ontario K1A 0A1, Canada"
}

Reverse Geocode

Request:

const { data } = await api.post<ReverseGeocodeResult>('/map/locations/reverse-geocode', {
  latitude: 45.42153,
  longitude: -75.69719,
});

Response:

{
  "address": "123 Main St, Ottawa, ON K1A 0A1, Canada",
  "provider": "NOMINATIM"
}

NAR Server Import

Request:

const { data } = await api.post<{ importId: string }>('/map/nar-import', {
  provinceCode: '24',
  filterType: 'city',
  filterCity: 'Montreal',
  residentialOnly: true,
  deduplicateRadius: 5,
  batchSize: 1000,
});

Response:

{
  "importId": "import-789"
}

Poll status:

const { data } = await api.get<NarImportProgress>(`/map/nar-import/status/${importId}`);

Progress response:

{
  "importId": "import-789",
  "status": "processing",
  "currentFile": "Address_24_part_3.csv",
  "totalRows": 45678,
  "locationsCreated": 12345,
  "skippedDuplicate": 234,
  "skippedOutOfBounds": 156,
  "skippedNonResidential": 1234,
  "skippedInvalid": 45
}

Complete response:

{
  "importId": "import-789",
  "status": "complete",
  "result": {
    "provinceName": "Quebec",
    "totalRows": 67890,
    "created": 54321,
    "skippedDuplicate": 456,
    "skippedOutOfBounds": 234,
    "skippedNonResidential": 12345,
    "skippedInvalid": 78,
    "durationMs": 43200,
    "errors": []
  }
}

Troubleshooting

Geocode Confidence Always Low

Problem: All geocoded locations show Low (<60%) confidence tags.

Diagnosis:

Check geocoding provider priority in backend:

// api/src/modules/map/geocoding/geocoding.service.ts
const providers = ['GOOGLE', 'NOMINATIM', 'ARCGIS', 'PHOTON', 'MAPBOX', 'PELIAS'];

Common Issues:

  1. Google API key missing/invalid:

    • Check .env: GOOGLE_GEOCODING_API_KEY=your-key-here
    • Verify key has Geocoding API enabled
    • Check quota limits
  2. Poor address quality:

    • Addresses missing street number, city, or postal code
    • Example: "Main St" (missing number + city) → low confidence
    • Solution: Clean address data before import
  3. Provider fallback chain:

    • Google fails → tries Nominatim (lower confidence)
    • Nominatim fails → tries ArcGIS, etc.
    • Solution: Fix primary provider (Google)

Solution:

Run bulk re-geocode with confidence threshold 60:

  1. Click "Bulk Re-Geocode" button
  2. Set threshold to 60
  3. Start job
  4. Improved locations update with higher confidence

NAR Server Import Not Finding Files

Problem: Click "Scan Server Directory" → "No NAR datasets found in /data"

Diagnosis:

Check docker-compose volume mount:

volumes:
  - ./data:/data:ro

Common Issues:

  1. Data directory doesn't exist:

    mkdir -p ./data
    
  2. NAR files not extracted:

    • Download NAR zip from Statistics Canada
    • Extract to ./data/ directory
    • Ensure Addresses/ and Locations/ subdirectories exist
  3. Wrong directory structure:

    # Wrong (zip extracted to subdirectory):
    ./data/NAR_2025/Addresses/Address_24.csv
    
    # Correct (direct in ./data):
    ./data/Addresses/Address_24.csv
    ./data/Locations/Location_24.csv
    
  4. File permissions:

    chmod -R 755 ./data
    

Solution:

cd changemaker-lite
mkdir -p ./data
cd ./data
# Download NAR zip from Statistics Canada
unzip NAR_2025.zip
# Move Addresses/ and Locations/ to ./data root
mv NAR_2025/Addresses .
mv NAR_2025/Locations .
# Restart API container
docker compose restart api

Map Shows "Too Many Locations in View"

Problem: Zoom out on map → Warning: "Too many locations in view. Zoom in for more detail."

Diagnosis:

Backend safety limit triggered:

// Max 5000 locations per request
if (locations.length >= 5000) {
  return res.json(locations.slice(0, 5000));
}

Not an error: Protection against loading millions of markers.

Solution:

  1. Zoom in to reduce visible area
  2. Map auto-refreshes with smaller bounds
  3. Fewer locations load (no warning)

Alternative: Use Table tab + search/filters to find specific locations.


Bulk Re-Geocode Stuck at 99%

Problem: Start bulk re-geocode → progress bar reaches 99% → never completes.

Diagnosis:

Check BullMQ queue health:

docker compose logs -f api
# Look for: "Bulk geocode job failed: ETIMEDOUT"

Common Issues:

  1. Geocoding provider timeout:

    • Google API rate limit exceeded (50 req/sec)
    • Solution: Reduce job concurrency in backend
  2. Redis connection lost:

    • Check redis container: docker compose ps redis
    • Solution: Restart redis: docker compose restart redis
  3. Job worker crashed:

    • Check API logs for errors
    • Solution: Restart API: docker compose restart api

Solution:

Cancel stuck job:

  1. Close bulk geocode modal
  2. Restart API container: docker compose restart api
  3. Retry bulk geocode with smaller limit (e.g., 500 instead of 1000)

CSV Import Shows "Invalid" Errors

Problem: Import CSV → Result: "450 created, 50 invalid"

Diagnosis:

Check error list in import modal:

  • Row 23: "Missing required field: address"
  • Row 45: "Invalid coordinates: latitude > 90"
  • Row 67: "Address too long (max 500 chars)"

Common Issues:

  1. Missing required columns:

    • Standard CSV: address required, lat/lng optional
    • NAR CSV: Address file requires CIVIC_NO + OFFICIAL_STREET_NAME (2025) or STR_NBR + STR_NME (legacy)
  2. Invalid coordinates:

    • Latitude out of range (-90 to 90)
    • Longitude out of range (-180 to 180)
    • Non-numeric values in lat/lng columns
  3. Encoding issues:

    • CSV not UTF-8 encoded
    • Solution: Re-save CSV as UTF-8 in Excel/LibreOffice

Solution:

  1. Export failed rows to new CSV for fixing
  2. Clean data in spreadsheet:
    • Fill missing addresses
    • Fix coordinate ranges
    • Remove invalid characters
  3. Re-import cleaned CSV