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)
- Navigate to
/app/map/locations - 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
- Table tab active by default (20 locations per page)
- 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)
- Expand row (click anywhere) if Total Units > 1:
- Shows Address units table (apartments)
- Columns: Unit, Name, Contact, Building Type, Notes
- Use pagination at bottom (10/20/50/100 per page)
Searching and Filtering
- Search bar (top left):
- Type address or postal code
- 300ms debounce (waits for typing pause)
- Search resets pagination to page 1
- Confidence filter (top right):
- Select High, Medium, Low, or Manual/None
- Filter resets pagination to page 1
- Clear to show all locations
- Filters persist during pagination
Creating a Location Manually
- Click "Add Location" button in page header
- Modal opens (600px width) with vertical form
- 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)
- Street Address (base building address, no unit number)
- Select Building Type (radio buttons, default: SINGLE_FAMILY):
- Single Family
- Multi-Unit
- Mixed Use
- Commercial
- Add Building Notes (optional):
- Access codes, manager contact, buzzer instructions
- Example: "Access code: 1234, Ring buzzer for manager"
- Click "Create" button
- Success message: "Location created"
- Modal closes, table refreshes to page 1, stats refresh
- If Map tab open, new marker appears
Using Inline Geocoding
- In create/edit form, type address in Street Address field
- Click "Geocode" button (AimOutlined icon in input addonAfter)
- Button shows loading spinner
- API calls multi-provider geocoding service
- On success:
- Latitude and Longitude fields auto-fill
- Success message: "Geocoded (Google, 95% confidence)"
- Provider name + confidence shown in message
- On failure:
- Error message: "Could not geocode address"
- Manually enter coordinates or try different address
Geocoding Missing Locations
- Click "Geocode Missing" button in page header
- Button shows loading state
- API geocodes all locations without coordinates (latitude = null OR longitude = null)
- Success message: "Geocoded 847 of 1250 locations (403 failed)"
- Table refreshes, stats update
- Failed locations remain without coordinates (low-quality addresses)
Bulk Re-Geocoding (Background Job)
- Click "Bulk Re-Geocode" button in page header
- 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)
- Click "Start Bulk Re-Geocode" button
- Background job starts (BullMQ queue)
- Modal shows live progress:
- Progress bar (percentage complete)
- Current address being processed
- Stats: Processed X / Total, Improved, Unchanged, Failed
- Job completes:
- Final stats shown
- Success message: "Bulk geocoding complete: 234 improved, 512 unchanged, 14 failed"
- Click "Close" button
- Table refreshes, stats update
- Only locations with IMPROVED results are updated (unchanged locations left alone)
Importing Standard CSV
- Click "Import CSV" button in page header
- Modal opens (620px width) with 3 radio buttons:
- Standard CSV (selected by default)
- NAR Upload
- NAR Server
- 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)
- Drag CSV file or click to upload
- 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)
- Success message: "Imported 450 of 500 locations (30 warnings, 20 failed)"
- If errors, warning modal shows error list (max 300px height, scrollable)
- Modal closes, table refreshes, stats update
Importing NAR Upload (Client-Side)
- Click "Import CSV" button
- Switch to "NAR Upload" radio button
- 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
- 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)
- Toggle "Residential only" switch:
- ON (default): Skip commercial/industrial addresses
- OFF: Import all addresses
- Drag CSV file or click to upload (max 100MB)
- 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
- Progress indicator during import
- Results shown in modal:
- 6 statistics cards (Total Rows, Created, Duplicates, Out of Bounds, Invalid, Errors)
- Error list (if any)
- Success message: "Created X of Y locations"
- Table refreshes, stats update
Importing NAR Server (Server-Side Streaming)
- Click "Import CSV" button
- Switch to "NAR Server" radio button
- Click "Scan Server Directory" button (first time only)
- Backend scans NAR_DATA_DIR (
./datavolume mount) for:Addresses/directory with Address_{provinceCode}part{N}.csv filesLocations/directory with Location_{provinceCode}.csv files
- Modal shows available provinces:
- Example: "ON — Ontario (6 files, 2.3 GB)"
- File count includes multi-part Address files
- Select province from dropdown
- 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
- Toggle "Residential only" switch (default: ON)
- Click "Import {Province} Addresses" button
- 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)
- 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
- 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"
- 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)
- Click "Map" tab (EnvironmentOutlined icon)
- Map loads with AdminMapView component
- Initial load fetches all locations (no bounds filter)
- Locations render as colored circle markers:
- Blue: Single Family
- Green: Multi-Unit
- Orange: Mixed Use
- Purple: Commercial
- Cut polygons overlay map (if any cuts exist)
- 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
- Pan/zoom map → auto-refreshes after 800ms debounce
- Click marker → location detail popup (address, building type, edit button)
Adding Location from Map
- In Map tab, click "Add" control button
- Click-to-add mode activated (cursor changes)
- Click anywhere on map
- Backend reverse geocodes coordinates (Nominatim)
- 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")
- Adjust values if needed
- Select building type
- Click "Create"
- New marker appears on map
- Table updates if viewing Table tab
Moving Location on Map
- In Map tab, click "Move" control button
- Drag-to-move mode activated
- Click and drag any marker to new position
- On release, coordinates update:
- PUT
/api/map/locations/:idwith new lat/lng - Marker snaps to new position
- Success message: "Location moved"
- PUT
- Table updates if viewing Table tab
Editing a Location
- From Table tab:
- Click Edit icon button (EditOutlined) in Actions column
- From Map tab:
- Click marker → popup → click Edit button
- Drawer opens on right side (700px width) with 2 tabs:
- Details tab (active by default)
- History tab (ClockCircleOutlined icon)
- Details tab shows edit form:
- Same fields as create form
- Pre-filled with current values
- Geocode button available
- Modify any fields
- Click "Save" button in drawer header
- Success message: "Location updated"
- Drawer closes, table refreshes, stats update
- Map refreshes if viewing Map tab
Viewing Location History
- Open location in edit drawer
- Click "History" tab (ClockCircleOutlined icon)
- 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)
- Pagination at bottom (20 per page)
- History sorted newest first (most recent at top)
Bulk Deleting Locations
- In Table tab, select checkbox for multiple rows
- "Delete Selected (N)" button appears above table
- Click button
- Popconfirm: "Delete N locations?"
- Click "OK"
- Success message: "Deleted N locations"
- Selection cleared, table refreshes, stats update
Exporting CSV
- Click "Export CSV" button in page header
- Browser downloads CSV file:
locations-YYYY-MM-DD.csv - File contains all locations (not just current page):
- Columns: id, address, latitude, longitude, buildingType, buildingNotes, postalCode, province, federalDistrict, buildingUse, geocodeProvider, geocodeConfidence, totalUnits, createdAt, updatedAt
- Open in Excel, Google Sheets, or text editor
- 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:
responsivearray controls column visibility on different screen sizesrenderfunctions for custom content (tags, icons, formatted values)align: 'center'for numeric columnswidthprop 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 scorepostalCode,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:
-
Google API key missing/invalid:
- Check .env:
GOOGLE_GEOCODING_API_KEY=your-key-here - Verify key has Geocoding API enabled
- Check quota limits
- Check .env:
-
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
-
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:
- Click "Bulk Re-Geocode" button
- Set threshold to 60
- Start job
- 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:
-
Data directory doesn't exist:
mkdir -p ./data -
NAR files not extracted:
- Download NAR zip from Statistics Canada
- Extract to
./data/directory - Ensure
Addresses/andLocations/subdirectories exist
-
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 -
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:
- Zoom in to reduce visible area
- Map auto-refreshes with smaller bounds
- 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:
-
Geocoding provider timeout:
- Google API rate limit exceeded (50 req/sec)
- Solution: Reduce job concurrency in backend
-
Redis connection lost:
- Check redis container:
docker compose ps redis - Solution: Restart redis:
docker compose restart redis
- Check redis container:
-
Job worker crashed:
- Check API logs for errors
- Solution: Restart API:
docker compose restart api
Solution:
Cancel stuck job:
- Close bulk geocode modal
- Restart API container:
docker compose restart api - 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:
-
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)
-
Invalid coordinates:
- Latitude out of range (-90 to 90)
- Longitude out of range (-180 to 180)
- Non-numeric values in lat/lng columns
-
Encoding issues:
- CSV not UTF-8 encoded
- Solution: Re-save CSV as UTF-8 in Excel/LibreOffice
Solution:
- Export failed rows to new CSV for fixing
- Clean data in spreadsheet:
- Fill missing addresses
- Fix coordinate ranges
- Remove invalid characters
- Re-import cleaned CSV
Related Documentation
- Locations Module (Backend) — API implementation, schemas, geocoding service
- Geocoding Service — Multi-provider geocoding, confidence scoring
- NAR Import Service — NAR format parsing, streaming import
- Cuts Module — Polygon boundaries, spatial queries
- AdminMapView Component — Interactive map with controls
- Public Map Page — Public-facing map view
- Map Feature Guide — End-to-end location management workflow
- NAR Import Guide — Step-by-step NAR import instructions
- Locations API Reference — Complete endpoint documentation
- Troubleshooting: Geocoding Issues — Geocoding debugging