Added apartment views and city of edmonton data import

This commit is contained in:
admin 2025-07-27 16:19:12 -06:00
parent 5da24aed56
commit 994440a2fd
11 changed files with 1357 additions and 11 deletions

View File

@ -16,6 +16,8 @@ Changemaker Lite is a streamlined documentation and development platform featuri
## Quick Start
The whole system can be set up in minutes using Docker Compose. It is recommended to run this on a server with at least 8GB of RAM and 4 CPU cores for optimal performance. Instructions to build to production are available in the mkdocs/docs/build directory, at cmlite.org, or in the site preview.
```bash
# Clone the repository
git clone https://gitea.bnkops.com/admin/changemaker.lite
@ -34,11 +36,6 @@ Instructions on how to build the map are available in the map directory.
Instructions on how to build for production are available in the mkdocs/docs/build directory or in the site preview.
## Services
Changemaker Lite is a collection of self-hosted services for creating, managing, and automating political campaign workflows.
## Service Access
After starting, access services at:

View File

@ -0,0 +1,132 @@
const socrataService = require('../services/socrata');
const logger = require('../utils/logger');
const EDMONTON_PARCEL_ADDRESSES_ID = 'nggt-rwac';
class ExternalDataController {
/**
* Fetches parcel addresses from the City of Edmonton open data portal.
* Uses a simple SoQL query to get points with valid locations.
*/
async getEdmontonParcelAddresses(req, res) {
try {
logger.info('Fetching Edmonton parcel addresses from Socrata API');
// Get query parameters for filtering and pagination
const {
bounds,
zoom = 10,
limit = 2000,
offset = 0,
neighborhood
} = req.query;
// Build dynamic query based on zoom level and bounds
let whereClause = 'location IS NOT NULL';
let selectFields = 'house_number, street_name, sub_address, neighbourhood_name, object_type, location, latitude, longitude';
// If bounds are provided, filter by geographic area
if (bounds) {
try {
const boundsArray = bounds.split(',').map(Number);
if (boundsArray.length === 4) {
const [south, west, north, east] = boundsArray;
whereClause += ` AND latitude BETWEEN ${south} AND ${north} AND longitude BETWEEN ${west} AND ${east}`;
logger.info(`Filtering by bounds: ${bounds}`);
}
} catch (error) {
logger.warn('Invalid bounds parameter:', bounds);
}
}
// Filter by neighborhood if specified
if (neighborhood) {
whereClause += ` AND neighbourhood_name = '${neighborhood.toUpperCase()}'`;
}
// Adjust limit based on zoom level - show fewer points when zoomed out
const dynamicLimit = Math.min(parseInt(zoom) < 12 ? 500 : parseInt(zoom) < 15 ? 1500 : 2000, parseInt(limit));
const params = {
'$select': selectFields,
'$where': whereClause,
'$limit': dynamicLimit,
'$offset': parseInt(offset),
'$order': 'house_number'
};
const data = await socrataService.get(EDMONTON_PARCEL_ADDRESSES_ID, params);
logger.info(`Successfully fetched ${data.length} Edmonton parcel addresses (zoom: ${zoom}, bounds: ${bounds})`);
// Group addresses by location to identify multi-unit buildings
const locationGroups = new Map();
data.filter(item => item.location && item.location.coordinates).forEach(item => {
const locationKey = `${item.latitude}_${item.longitude}`;
const address = `${item.house_number || ''} ${item.street_name || ''}`.trim();
if (!locationGroups.has(locationKey)) {
locationGroups.set(locationKey, {
address: address || 'No address',
location: item.location,
latitude: parseFloat(item.latitude),
longitude: parseFloat(item.longitude),
neighbourhood_name: item.neighbourhood_name || '',
suites: []
});
}
locationGroups.get(locationKey).suites.push({
suite: item.sub_address || item.suite || '',
object_type: item.object_type || 'SUITE',
record_id: item.record_id || '',
house_number: item.house_number || '',
street_name: item.street_name || ''
});
});
// Transform grouped data into GeoJSON FeatureCollection
const validFeatures = Array.from(locationGroups.values()).map(group => ({
type: 'Feature',
properties: {
address: group.address,
neighborhood: group.neighbourhood_name,
suites: group.suites,
suiteCount: group.suites.length,
isMultiUnit: group.suites.length > 3,
lat: group.latitude,
lng: group.longitude
},
geometry: group.location
}));
const geoJson = {
type: 'FeatureCollection',
features: validFeatures,
metadata: {
count: validFeatures.length,
zoom: zoom,
bounds: bounds,
hasMore: validFeatures.length === dynamicLimit // Indicates if there might be more data
}
};
logger.info(`Processed ${validFeatures.length} valid features`);
res.json({
success: true,
data: geoJson
});
} catch (error) {
logger.error('Error fetching Edmonton parcel addresses:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch external map data.'
});
}
}
}
module.exports = new ExternalDataController();

View File

@ -888,6 +888,13 @@ body {
color: white;
}
/* Active state for desktop map control buttons */
.map-controls .btn.active {
background-color: var(--dark-color);
color: white;
border-color: var(--dark-color);
}
.mobile-sidebar .btn:active {
transform: scale(0.95);
}
@ -1583,3 +1590,239 @@ path.leaflet-interactive {
margin: 0 !important;
}
}
/* Apartment Building Popup Styles */
.apartment-popup .leaflet-popup-content-wrapper {
background: white;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0,0,0,0.15);
border: 1px solid #e9ecef;
max-width: min(320px, calc(100vw - 40px)) !important;
min-width: min(280px, calc(100vw - 40px)) !important;
width: auto !important;
}
.apartment-popup .leaflet-popup-content {
margin: 12px 18px;
line-height: 1.4;
max-width: 100%;
overflow: hidden;
word-wrap: break-word;
overflow-wrap: break-word;
}
.apartment-building-popup .suite-selector {
transition: all 0.2s ease;
font-weight: 500;
border: 2px solid #e9ecef;
background: white;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6,9 12,15 18,9'%3e%3c/polyline%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 16px;
padding-right: 32px;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
width: 100%;
max-width: 100%;
box-sizing: border-box;
font-size: 12px;
padding: 8px 32px 8px 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.apartment-building-popup .suite-selector:hover {
border-color: #ff6b35;
box-shadow: 0 2px 8px rgba(255, 107, 53, 0.15);
}
.apartment-building-popup .suite-selector:focus {
outline: none;
border-color: #ff6b35;
box-shadow: 0 0 0 3px rgba(255, 107, 53, 0.1);
}
.apartment-building-popup .suite-content {
transition: all 0.3s ease;
max-width: 100%;
overflow: hidden;
box-sizing: border-box;
}
.apartment-building-popup .suite-details {
animation: fadeIn 0.3s ease;
word-wrap: break-word;
overflow-wrap: break-word;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(5px); }
to { opacity: 1; transform: translateY(0); }
}
/* General apartment popup container constraints */
.apartment-building-popup {
max-width: 100%;
box-sizing: border-box;
word-wrap: break-word;
overflow-wrap: break-word;
overflow: hidden;
}
.apartment-building-popup * {
box-sizing: border-box;
max-width: 100%;
}
/* Specific text overflow handling */
.apartment-building-popup select option {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.apartment-building-popup .suite-details > div,
.apartment-building-popup.app-data .unit-details > div {
word-wrap: break-word;
overflow-wrap: break-word;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
}
/* Allow long addresses to wrap */
.apartment-building-popup .building-header > div > div:first-child > div:first-child {
word-wrap: break-word;
overflow-wrap: break-word;
white-space: normal;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
.apartment-building-popup .building-header {
overflow: hidden;
text-overflow: ellipsis;
box-sizing: border-box;
margin: -12px -18px 16px -18px !important;
padding: 16px 20px !important;
background: linear-gradient(135deg, #ff6b35, #f7931e);
color: white;
border-radius: 8px 8px 0 0;
position: relative;
left: 0;
right: 0;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.apartment-building-popup.app-data .building-header {
background: linear-gradient(135deg, #a02c8d, #ba6cdf) !important;
}
.apartment-building-popup button {
width: 100%;
max-width: 100%;
box-sizing: border-box;
word-wrap: break-word;
white-space: normal;
}
/* Ensure dropdown options don't cause overflow */
.apartment-building-popup select option {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
/* Mobile-specific Leaflet popup positioning fixes */
@media (max-width: 768px) {
.leaflet-popup {
max-width: calc(100vw - 20px) !important;
}
.leaflet-popup-content-wrapper {
max-width: 100% !important;
overflow: hidden;
}
.leaflet-popup-content {
max-width: 100% !important;
overflow: hidden;
word-wrap: break-word;
overflow-wrap: break-word;
}
/* Ensure popups don't go off-screen on mobile */
.leaflet-popup-pane {
pointer-events: none;
}
.leaflet-popup {
pointer-events: auto;
margin: 0 10px;
}
}
@media (max-width: 480px) {
.leaflet-popup {
max-width: calc(100vw - 15px) !important;
margin: 0 7px;
}
}
@media (max-width: 320px) {
.leaflet-popup {
max-width: calc(100vw - 10px) !important;
margin: 0 5px;
}
}
/* Additional mobile button fixes for ultra-small screens */
@media (max-width: 280px) {
.apartment-building-popup.app-data .unit-details div[style*="display: flex"] {
flex-direction: column !important;
gap: 2px !important;
}
.apartment-building-popup.app-data .unit-details button {
flex: none !important;
width: 100% !important;
min-width: unset !important;
margin-bottom: 2px;
}
.apartment-building-popup button {
font-size: 8px !important;
padding: 3px 5px !important;
line-height: 1.2;
}
.apartment-building-popup .suite-selector,
.apartment-building-popup.app-data .unit-selector {
font-size: 8px !important;
padding: 4px 18px 4px 4px !important;
}
}
/* Prevent text selection on popup elements for better mobile UX */
.apartment-building-popup {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.apartment-building-popup input,
.apartment-building-popup select,
.apartment-building-popup textarea {
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
}

View File

@ -11,6 +11,10 @@
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="" />
<!-- Leaflet MarkerCluster CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css" />
<!-- Custom CSS -->
<link rel="stylesheet" href="css/style.css">
</head>
@ -18,7 +22,7 @@
<div id="app">
<!-- Header -->
<header class="header">
<h1>Map for CM-lite</h1>
<h1>Map</h1>
<!-- Add documentation search bar -->
<div class="docs-search-container">
@ -91,6 +95,10 @@
<span class="btn-icon">🏠</span>
<span class="btn-text">Hide Start Location</span>
</button>
<button id="toggle-edmonton-layer-btn" class="btn btn-secondary" title="Toggle City of Edmonton address layer">
<span class="btn-icon">🏙️</span>
<span class="btn-text">City Data</span>
</button>
<button id="refresh-btn" class="btn btn-primary">
<span class="btn-icon">🔄</span>
<span class="btn-text">Refresh</span>
@ -103,6 +111,7 @@
<span class="btn-icon"></span>
<span class="btn-text">Add Location Here</span>
</button>
<button id="fullscreen-btn" class="btn btn-secondary">
<span class="btn-icon"></span>
<span class="btn-text">Fullscreen</span>
@ -123,6 +132,9 @@
<button id="mobile-add-location-btn" class="btn btn-success" title="Add Location">
</button>
<button id="mobile-toggle-edmonton-layer-btn" class="btn btn-secondary" title="Toggle Edmonton Data">
🏙️
</button>
<button id="mobile-fullscreen-btn" class="btn btn-secondary" title="Fullscreen">
</button>
@ -377,6 +389,9 @@
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<!-- Leaflet MarkerCluster JS -->
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
<!-- Cache Management -->
<script src="js/cache-manager.js"></script>

View File

@ -0,0 +1,564 @@
import { map } from './map-manager.js';
import { showStatus } from './utils.js';
import { openAddModal } from './location-manager.js';
let edmontonParcelsLayer = null;
let isLoading = false;
let isLayerVisible = false;
let lastBounds = null;
let lastZoom = null;
/**
* Toggles the visibility of the Edmonton Parcel Addresses layer.
* Uses smart loading based on map bounds and zoom level.
*/
export async function toggleEdmontonParcelsLayer() {
const toggleBtn = document.getElementById('toggle-edmonton-layer-btn');
const mobileToggleBtn = document.getElementById('mobile-toggle-edmonton-layer-btn');
if (isLoading) {
console.log('Edmonton layer is already loading...');
return;
}
if (isLayerVisible) {
// Hide the layer
if (edmontonParcelsLayer && map.hasLayer(edmontonParcelsLayer)) {
map.removeLayer(edmontonParcelsLayer);
}
isLayerVisible = false;
toggleBtn?.classList.remove('active');
mobileToggleBtn?.classList.remove('active');
console.log('Edmonton addresses layer hidden');
// Remove map event listeners
map.off('moveend', onMapMoveEnd);
map.off('zoomend', onMapZoomEnd);
} else {
// Show the layer
isLayerVisible = true;
toggleBtn?.classList.add('active');
mobileToggleBtn?.classList.add('active');
console.log('Edmonton addresses layer enabled');
// Add map event listeners for dynamic loading
map.on('moveend', onMapMoveEnd);
map.on('zoomend', onMapZoomEnd);
// Load data for current view
await loadEdmontonData();
}
}
/**
* Loads Edmonton data for the current map bounds and zoom level
*/
async function loadEdmontonData(force = false) {
if (!isLayerVisible || isLoading) return;
const currentBounds = map.getBounds();
const currentZoom = map.getZoom();
// Check if we need to reload data
if (!force && lastBounds && lastZoom === currentZoom &&
lastBounds.contains(currentBounds)) {
return; // Current view is already covered
}
// Only load data if zoomed in enough (reduces server load)
if (currentZoom < 11) {
if (edmontonParcelsLayer && map.hasLayer(edmontonParcelsLayer)) {
map.removeLayer(edmontonParcelsLayer);
}
console.log('Zoom level too low for Edmonton data, zoom in to see addresses');
return;
}
isLoading = true;
const toggleBtn = document.getElementById('toggle-edmonton-layer-btn');
const mobileToggleBtn = document.getElementById('mobile-toggle-edmonton-layer-btn');
if (toggleBtn) toggleBtn.disabled = true;
if (mobileToggleBtn) mobileToggleBtn.disabled = true;
try {
// Expand bounds slightly to avoid edge loading issues
const expandedBounds = currentBounds.pad(0.1);
const boundsString = [
expandedBounds.getSouth(),
expandedBounds.getWest(),
expandedBounds.getNorth(),
expandedBounds.getEast()
].join(',');
const params = new URLSearchParams({
bounds: boundsString,
zoom: currentZoom.toString(),
limit: currentZoom > 15 ? '2000' : currentZoom > 12 ? '1000' : '500'
});
console.log(`Loading Edmonton addresses for zoom level ${currentZoom}, bounds: ${boundsString}`);
const response = await fetch(`/api/external/edmonton-parcels?${params}`);
if (!response.ok) {
throw new Error('Failed to fetch layer data from server.');
}
const result = await response.json();
if (result.success) {
// Remove existing layer
if (edmontonParcelsLayer && map.hasLayer(edmontonParcelsLayer)) {
map.removeLayer(edmontonParcelsLayer);
}
// Create new layer with clustering optimized for the zoom level
const clusterRadius = currentZoom > 15 ? 30 : currentZoom > 12 ? 50 : 80;
edmontonParcelsLayer = L.markerClusterGroup({
chunkedLoading: true,
maxClusterRadius: clusterRadius,
disableClusteringAtZoom: 18,
showCoverageOnHover: false,
zoomToBoundsOnClick: true,
spiderfyOnMaxZoom: true,
removeOutsideVisibleBounds: true
});
const geoJsonLayer = L.geoJSON(result.data, {
pointToLayer: (feature, latlng) => {
const isMultiUnit = feature.properties.isMultiUnit;
return L.circleMarker(latlng, {
radius: Math.max(3, Math.min(6, currentZoom - 10)),
fillColor: isMultiUnit ? "#ff6b35" : "#ba001e", // Orange for multi-unit buildings
color: "#ffffff",
weight: isMultiUnit ? 2 : 1,
opacity: 0.9,
fillOpacity: 0.7
});
},
onEachFeature: (feature, layer) => {
const props = feature.properties;
if (props.isMultiUnit) {
// Create apartment-style popup for multi-unit buildings
const popup = L.popup({
maxWidth: 320,
minWidth: 280,
closeButton: true,
className: 'apartment-popup'
}).setContent(createApartmentPopup(props));
layer.bindPopup(popup);
// Add event listener for when popup opens
layer.on('popupopen', function(e) {
setupApartmentPopupListeners(props);
});
} else {
// Simple popup for single units
const suite = props.suites && props.suites[0] ? props.suites[0] : {};
// Better suite/unit display logic
let suiteDisplay = '';
if (suite.suite && suite.suite.trim()) {
const unitLabel = (suite.object_type && suite.object_type.toLowerCase().includes('suite')) ? 'Suite' : 'Unit';
suiteDisplay = `<span style="color: #666;">${unitLabel}: ${suite.suite.trim()}</span><br>`;
}
const popupContent = `
<div style="font-size: 12px; line-height: 1.4; min-width: 160px;">
<b>🏠 ${props.address}</b><br>
${suiteDisplay}
${props.neighborhood ? `<span style="color: #666;">📍 ${props.neighborhood}</span><br>` : ''}
${suite.object_type ? `<span style="color: #888; font-size: 11px;">${suite.object_type}</span><br>` : ''}
<small style="color: #888;">City of Edmonton Data</small>
<div style="margin-top: 8px; padding-top: 8px; border-top: 1px solid #e9ecef;">
<button class="add-to-database-btn"
data-address="${props.address}"
data-lat="${props.lat}"
data-lng="${props.lng}"
data-neighborhood="${props.neighborhood || ''}"
data-suite="${suite.suite || ''}"
data-object-type="${suite.object_type || ''}"
style="background: #28a745; color: white; border: none; padding: 4px 8px; border-radius: 3px; font-size: 11px; cursor: pointer; width: 100%;">
Add to Database
</button>
</div>
</div>
`;
layer.bindPopup(popupContent, {
maxWidth: 200,
closeButton: true
});
// Add event listener for popup open to attach button listeners
layer.on('popupopen', function(e) {
setupSingleUnitPopupListeners();
});
}
}
});
edmontonParcelsLayer.addLayer(geoJsonLayer);
map.addLayer(edmontonParcelsLayer);
// Update tracking variables
lastBounds = expandedBounds;
lastZoom = currentZoom;
const statusMessage = result.data.metadata?.hasMore
? `Loaded ${result.data.features.length} addresses (more available at higher zoom)`
: `Loaded ${result.data.features.length} Edmonton addresses`;
console.log(statusMessage);
} else {
throw new Error(result.error || 'Could not load layer data.');
}
} catch (error) {
console.error('Error loading Edmonton parcels layer:', error);
// Only show error status, not success messages
showStatus(error.message, 'error');
} finally {
isLoading = false;
if (toggleBtn) toggleBtn.disabled = false;
if (mobileToggleBtn) mobileToggleBtn.disabled = false;
}
}
/**
* Handle map move events
*/
function onMapMoveEnd() {
// Debounce the loading to avoid too many requests
clearTimeout(onMapMoveEnd.timeout);
onMapMoveEnd.timeout = setTimeout(() => {
loadEdmontonData();
}, 500);
}
/**
* Handle map zoom events
*/
function onMapZoomEnd() {
// Force reload on zoom change
loadEdmontonData(true);
}
/**
* Creates an apartment-style popup for multi-unit buildings
*/
function createApartmentPopup(props) {
const suites = props.suites || [];
const totalSuites = suites.length;
const popupId = `apartment-popup-${Math.random().toString(36).substr(2, 9)}`;
// Truncate address if too long for mobile
const displayAddress = props.address.length > 30 ?
props.address.substring(0, 30) + '...' : props.address;
const popupContent = `
<div class="apartment-building-popup" data-popup-id="${popupId}" style="font-family: Arial, sans-serif;">
<div class="building-header">
<div style="display: flex; align-items: center; justify-content: space-between; gap: 8px;">
<div style="flex: 1; min-width: 0;">
<div style="font-weight: bold; font-size: 15px; margin-bottom: 2px; line-height: 1.3;" title="${props.address}">🏢 ${displayAddress}</div>
<div style="font-size: 12px; opacity: 0.9; line-height: 1.2;">${totalSuites} units ${props.neighborhood || 'Edmonton'}</div>
</div>
<div style="background: rgba(255,255,255,0.25); padding: 4px 8px; border-radius: 10px; font-size: 10px; font-weight: 500; white-space: nowrap;">
Apt
</div>
</div>
</div>
<div class="suite-navigator" style="margin-bottom: 12px;">
<div style="margin-bottom: 6px;">
<label style="font-size: 11px; color: #666; display: block; margin-bottom: 3px;">
Suite (${totalSuites} total):
</label>
<select class="suite-selector" style="width: 100%; padding: 6px; border: 1px solid #ddd; border-radius: 4px; font-size: 11px; background: white; cursor: pointer;">
${suites
.map((suite, originalIndex) => ({ ...suite, originalIndex }))
.sort((a, b) => {
// Extract numeric part for sorting
const aNum = a.suite ? parseInt(a.suite.replace(/\D/g, '')) || 0 : a.originalIndex;
const bNum = b.suite ? parseInt(b.suite.replace(/\D/g, '')) || 0 : b.originalIndex;
return aNum - bNum;
})
.map((suite) => {
const displayName = suite.suite ? suite.suite.trim() : `Unit ${suite.originalIndex + 1}`;
const objectType = suite.object_type || 'Residential';
// Truncate option text for mobile
const optionText = `${displayName} (${objectType})`;
const truncatedText = optionText.length > 25 ?
optionText.substring(0, 25) + '...' : optionText;
return `<option value="${suite.originalIndex}" title="${optionText}">${truncatedText}</option>`;
}).join('')}
</select>
</div>
<div class="suite-content" style="min-height: 70px; padding: 8px; background: #f8f9fa; border-radius: 4px; border-left: 3px solid #ff6b35;">
<div class="suite-details">
<div style="font-weight: bold; font-size: 13px; color: #333; margin-bottom: 3px;">
${suites[0].suite ? `Suite ${suites[0].suite.trim()}` : `Unit 1`}
</div>
<div style="font-size: 11px; color: #666; margin-bottom: 4px;">
${suites[0].object_type || 'Residential Unit'}
</div>
${suites[0].house_number && suites[0].street_name ? `
<div style="font-size: 10px; color: #888; margin-bottom: 3px;">
📍 ${suites[0].house_number} ${suites[0].street_name}
</div>` : ''}
${suites[0].record_id ? `
<div style="font-size: 9px; color: #888;">
ID: ${suites[0].record_id}
</div>` : ''}
</div>
</div>
</div>
<div style="text-align: center; font-size: 9px; color: #888; border-top: 1px solid #e9ecef; padding-top: 6px; margin-top: 10px;">
City of Edmonton Open Data
</div>
<div style="margin-top: 6px; padding-top: 6px; border-top: 1px solid #e9ecef;">
<button class="add-apartment-to-database-btn"
data-address="${props.address}"
data-lat="${props.lat}"
data-lng="${props.lng}"
data-neighborhood="${props.neighborhood || ''}"
data-suites='${JSON.stringify(suites)}'
style="background: #28a745; color: white; border: none; padding: 6px 10px; border-radius: 4px; font-size: 10px; cursor: pointer; width: 100%; font-weight: 500;">
Add Building to Database
</button>
</div>
</div>
`;
return popupContent;
}
/**
* Sets up event listeners for apartment popup suite selection
*/
function setupApartmentPopupListeners(props) {
const suites = props.suites || [];
// Find the popup container in the DOM
const container = document.querySelector('.apartment-building-popup');
if (!container) {
console.log('Apartment popup container not found');
return;
}
const suiteSelector = container.querySelector('.suite-selector');
const suiteDetails = container.querySelector('.suite-details');
if (!suiteSelector || !suiteDetails) {
console.log('Apartment popup elements not found');
return;
}
function updateSuiteDisplay(selectedIndex) {
const suite = suites[selectedIndex];
// Better logic for displaying unit/suite information
let unitLabel = 'Unit';
let unitNumber = suite.suite ? suite.suite.trim() : '';
// If no suite number but has object type that suggests it's a suite
if (!unitNumber && suite.object_type && suite.object_type.toLowerCase().includes('suite')) {
unitNumber = `${selectedIndex + 1}`;
}
// If still no unit number, use sequential numbering
if (!unitNumber) {
unitNumber = `${selectedIndex + 1}`;
}
// Determine if it's a suite or unit based on the data
if (suite.object_type && suite.object_type.toLowerCase().includes('suite')) {
unitLabel = 'Suite';
}
suiteDetails.innerHTML = `
<div style="font-weight: bold; font-size: 14px; color: #333; margin-bottom: 4px;">
${unitLabel} ${unitNumber}
</div>
<div style="font-size: 12px; color: #666; margin-bottom: 6px;">
${suite.object_type || 'Residential Unit'}
</div>
${suite.house_number && suite.street_name ? `
<div style="font-size: 11px; color: #888; margin-bottom: 4px;">
📍 ${suite.house_number} ${suite.street_name}
</div>` : ''}
${suite.record_id ? `
<div style="font-size: 10px; color: #888;">
ID: ${suite.record_id}
</div>` : ''}
`;
}
// Set up suite selector dropdown event listener
suiteSelector.addEventListener('change', (e) => {
e.stopPropagation();
const selectedIndex = parseInt(e.target.value);
console.log('Suite selected:', selectedIndex);
updateSuiteDisplay(selectedIndex);
});
// Initialize with first suite selected
updateSuiteDisplay(0);
console.log(`Apartment popup suite selector set up for ${suites.length} suites`);
// Set up the "Add to Database" button listener for apartments
const addBtn = container.querySelector('.add-apartment-to-database-btn');
if (addBtn) {
addBtn.addEventListener('click', (e) => {
e.stopPropagation();
const address = addBtn.getAttribute('data-address');
const lat = parseFloat(addBtn.getAttribute('data-lat'));
const lng = parseFloat(addBtn.getAttribute('data-lng'));
const neighborhood = addBtn.getAttribute('data-neighborhood');
const suitesData = JSON.parse(addBtn.getAttribute('data-suites') || '[]');
// Get the currently selected suite from dropdown
const selectedSuiteIndex = parseInt(suiteSelector.value);
const selectedSuite = suitesData[selectedSuiteIndex] || suitesData[0];
// Close the popup first
map.closePopup();
// Open the add modal with pre-filled data
openAddModal(lat, lng);
// Pre-fill the form with Edmonton data, focusing on the selected suite
setTimeout(() => {
prefillAddForm({
address: address,
neighborhood: neighborhood,
lat: lat,
lng: lng,
suite: selectedSuite.suite || '',
objectType: selectedSuite.object_type || '',
notes: `Selected unit from multi-unit building (${suitesData.length} total units). Imported from City of Edmonton data.`,
isMultiUnit: true,
suites: suitesData,
selectedSuite: selectedSuite
});
}, 100);
});
}
}
/**
* Sets up event listeners for single unit popup "Add to Database" button
*/
function setupSingleUnitPopupListeners() {
// Wait for popup to be rendered in DOM
setTimeout(() => {
const addBtn = document.querySelector('.add-to-database-btn');
if (addBtn) {
addBtn.addEventListener('click', (e) => {
e.stopPropagation();
const address = addBtn.getAttribute('data-address');
const lat = parseFloat(addBtn.getAttribute('data-lat'));
const lng = parseFloat(addBtn.getAttribute('data-lng'));
const neighborhood = addBtn.getAttribute('data-neighborhood');
const suite = addBtn.getAttribute('data-suite');
const objectType = addBtn.getAttribute('data-object-type');
// Close the popup first
map.closePopup();
// Open the add modal with pre-filled data
openAddModal(lat, lng);
// Pre-fill the form with Edmonton data
setTimeout(() => {
prefillAddForm({
address: address,
neighborhood: neighborhood,
lat: lat,
lng: lng,
suite: suite,
objectType: objectType,
notes: `Imported from City of Edmonton data.${objectType ? ` (${objectType})` : ''}`,
isMultiUnit: false
});
}, 100);
});
}
}, 50);
}
/**
* Pre-fills the add location form with city data
*/
function prefillAddForm(data) {
try {
// Basic form fields
if (data.address) {
const addressField = document.getElementById('location-address');
if (addressField) {
addressField.value = data.address;
}
}
// Put suite number in the unit field
if (data.suite && data.suite.trim()) {
const unitField = document.getElementById('location-unit');
if (unitField) {
unitField.value = data.suite.trim();
}
}
// Notes field with additional context
const notesField = document.getElementById('location-notes');
if (notesField && data.notes) {
let notes = data.notes;
// Add neighborhood information if available
if (data.neighborhood && data.neighborhood.trim()) {
notes += `\nNeighborhood: ${data.neighborhood}`;
}
// Add object type if available
if (data.objectType && data.objectType.trim()) {
notes += `\nUnit Type: ${data.objectType}`;
}
// For multi-unit buildings, add information about the selected suite
if (data.isMultiUnit && data.selectedSuite) {
notes += `\nSelected Suite: ${data.selectedSuite.suite || 'N/A'}`;
notes += `\nSuite Type: ${data.selectedSuite.object_type || 'N/A'}`;
}
// For single units, add basic suite information
else if (data.suite && data.suite.trim()) {
notes += `\nSuite: ${data.suite}`;
}
notesField.value = notes;
}
// Set coordinates (already set by openAddModal, but ensure they're correct)
const latField = document.getElementById('location-lat');
const lngField = document.getElementById('location-lng');
const geoField = document.getElementById('geo-location');
if (latField && data.lat) {
latField.value = data.lat.toFixed(8);
}
if (lngField && data.lng) {
lngField.value = data.lng.toFixed(8);
}
if (geoField && data.lat && data.lng) {
geoField.value = `${data.lat.toFixed(8)};${data.lng.toFixed(8)}`;
}
console.log('Form pre-filled with city data:', data);
} catch (error) {
console.error('Error pre-filling form:', error);
showStatus('Error pre-filling form with city data', 'error');
}
}

View File

@ -1,6 +1,19 @@
// Helper function to escape HTML to prevent XSS
function escapeHtml(text) {
if (typeof text !== 'string') return text;
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, function(m) { return map[m]; });
}
// Location management (CRUD operations)
import { map } from './map-manager.js';
import { showStatus, updateLocationCount, escapeHtml } from './utils.js';
import { showStatus, updateLocationCount } from './utils.js';
import { currentUser } from './auth.js';
import { resetAddressConfirmation } from './ui-controls.js';
@ -39,17 +52,45 @@ export function displayLocations(locations) {
});
markers = [];
// Add new markers
// Group locations by address to identify multi-unit buildings
const addressGroups = new Map();
locations.forEach(location => {
if (location.latitude && location.longitude) {
const marker = createLocationMarker(location);
const address = location.Address || 'No address';
const addressKey = address.toLowerCase().trim();
if (!addressGroups.has(addressKey)) {
addressGroups.set(addressKey, {
address: address,
locations: [],
lat: parseFloat(location.latitude),
lng: parseFloat(location.longitude)
});
}
addressGroups.get(addressKey).locations.push(location);
}
});
// Create markers for each address group
addressGroups.forEach(group => {
if (group.locations.length > 1) {
// Multi-unit building - create apartment-style marker
const marker = createMultiUnitMarker(group);
if (marker) {
markers.push(marker);
}
} else {
// Single unit - create regular marker
const marker = createLocationMarker(group.locations[0]);
if (marker) {
markers.push(marker);
}
}
});
console.log(`Displayed ${markers.length} locations`);
console.log(`Displayed ${markers.length} location markers (${locations.length} total locations)`);
}
function createLocationMarker(location) {
@ -653,3 +694,267 @@ function cleanupMoveState() {
originalPosition = null;
movingLocationData = null;
}
function createMultiUnitMarker(group) {
if (!map) {
console.warn('Map not initialized, skipping multi-unit marker creation');
return null;
}
const { lat, lng, locations, address } = group;
// Validate coordinates
if (!lat || !lng || isNaN(lat) || isNaN(lng)) {
console.warn('Invalid coordinates for multi-unit location:', group);
return null;
}
// Use orange color for multi-unit buildings (similar to Edmonton data)
const markerColor = '#ff6b35';
// Create circle marker with apartment building styling
const marker = L.circleMarker([lat, lng], {
radius: 10, // Slightly larger for multi-unit
fillColor: markerColor,
color: '#ffffff',
weight: 2,
opacity: 1,
fillOpacity: 0.8,
className: 'location-marker multi-unit'
});
// Add to map
marker.addTo(map);
// Create apartment-style popup content
const popupContent = createMultiUnitPopupContent(group);
const popup = L.popup({
maxWidth: 320,
minWidth: 280,
closeButton: true,
className: 'apartment-popup app-data'
}).setContent(popupContent);
marker.bindPopup(popup);
marker._locationData = group;
// Add event listener for when popup opens
marker.on('popupopen', function(e) {
setupAppApartmentPopupListeners(group);
});
console.log(`Created multi-unit marker at ${lat}, ${lng} with ${locations.length} units`);
return marker;
}
function createMultiUnitPopupContent(group) {
const { address, locations } = group;
const totalUnits = locations.length;
// Sort locations by unit number if available
const sortedLocations = locations.sort((a, b) => {
const aUnit = a['Unit Number'] || '';
const bUnit = b['Unit Number'] || '';
// Extract numeric part for sorting
const aNum = aUnit ? parseInt(aUnit.replace(/\D/g, '')) || 0 : 0;
const bNum = bUnit ? parseInt(bUnit.replace(/\D/g, '')) || 0 : 0;
return aNum - bNum;
});
const popupId = `app-apartment-popup-${Math.random().toString(36).substr(2, 9)}`;
// Truncate address if too long for mobile
const displayAddress = address.length > 30 ?
address.substring(0, 30) + '...' : address;
const popupContent = `
<div class="apartment-building-popup app-data" data-popup-id="${popupId}" style="font-family: Arial, sans-serif;">
<div class="building-header" style="background: linear-gradient(135deg, #a02c8d, #ba6cdf);">
<div style="display: flex; align-items: center; justify-content: space-between; gap: 8px;">
<div style="flex: 1; min-width: 0;">
<div style="font-weight: bold; font-size: 15px; margin-bottom: 2px; line-height: 1.3;" title="${escapeHtml(address)}">🏢 ${escapeHtml(displayAddress)}</div>
<div style="font-size: 12px; opacity: 0.9; line-height: 1.2;">${totalUnits} contacts</div>
</div>
<div style="background: rgba(255,255,255,0.25); padding: 4px 8px; border-radius: 10px; font-size: 10px; font-weight: 500; white-space: nowrap;">
Multi-Unit
</div>
</div>
</div>
<div class="unit-navigator" style="margin-bottom: 12px;">
<div style="margin-bottom: 6px;">
<label style="font-size: 11px; color: #666; display: block; margin-bottom: 3px;">
Contact (${totalUnits} total):
</label>
<select class="unit-selector" style="width: 100%; padding: 6px; border: 1px solid #ddd; border-radius: 4px; font-size: 11px; background: white; cursor: pointer;">
${sortedLocations.map((location, index) => {
const name = [location['First Name'], location['Last Name']].filter(Boolean).join(' ') || 'Unknown';
const unit = location['Unit Number'] || `Unit ${index + 1}`;
const optionText = `${unit} - ${name}`;
// Truncate option text for mobile
const truncatedText = optionText.length > 25 ?
optionText.substring(0, 25) + '...' : optionText;
return `<option value="${index}" title="${escapeHtml(optionText)}">${escapeHtml(truncatedText)}</option>`;
}).join('')}
</select>
</div>
<div class="unit-content" style="min-height: 90px; padding: 8px; background: #f8f9fa; border-radius: 4px; border-left: 3px solid #a02c8d;">
<div class="unit-details">
${createUnitDetailsHTML(sortedLocations[0], 0)}
</div>
</div>
</div>
<div style="text-align: center; font-size: 9px; color: #888; border-top: 1px solid #e9ecef; padding-top: 6px; margin-top: 10px;">
Your Campaign Database
</div>
</div>
`;
return popupContent;
}
function createUnitDetailsHTML(location, index) {
const locationId = location.Id || location.id || location.ID || location._id;
const name = [location['First Name'], location['Last Name']].filter(Boolean).join(' ') || 'Unknown';
const unit = location['Unit Number'] || `Unit ${index + 1}`;
const supportLevel = location['Support Level'] ? `Level ${location['Support Level']}` : 'Not specified';
const email = location.Email || '';
const phone = location.Phone || '';
// Truncate long values for mobile
const truncatedEmail = email.length > 25 ? email.substring(0, 25) + '...' : email;
const truncatedPhone = phone.length > 15 ? phone.substring(0, 15) + '...' : phone;
const truncatedNotes = location.Notes && location.Notes.length > 50 ?
location.Notes.substring(0, 50) + '...' : location.Notes;
return `
<div style="font-weight: bold; font-size: 13px; color: #333; margin-bottom: 3px;">
${escapeHtml(unit)} - ${escapeHtml(name)}
</div>
<div style="font-size: 11px; color: #666; margin-bottom: 4px;">
Support: ${escapeHtml(supportLevel)}
</div>
${email ? `
<div style="font-size: 10px; color: #888; margin-bottom: 3px;" title="${escapeHtml(email)}">
📧 ${escapeHtml(truncatedEmail)}
</div>` : ''}
${phone ? `
<div style="font-size: 10px; color: #888; margin-bottom: 3px;" title="${escapeHtml(phone)}">
📞 ${escapeHtml(truncatedPhone)}
</div>` : ''}
${location.Sign ? '<div style="font-size: 10px; color: #27ae60; margin-bottom: 3px;">🏁 Has sign</div>' : ''}
${location.Notes ? `
<div style="font-size: 9px; color: #888; margin-top: 4px; padding-top: 4px; border-top: 1px solid #e9ecef;" title="${escapeHtml(location.Notes)}">
${escapeHtml(truncatedNotes)}
</div>` : ''}
${currentUser ? `
<div style="margin-top: 6px; padding-top: 6px; border-top: 1px solid #e9ecef;">
<div style="display: flex; gap: 3px; flex-wrap: wrap;">
<button class="btn btn-primary btn-sm edit-unit-btn"
data-location='${escapeHtml(JSON.stringify(location))}'
style="background: #a02c8d; border: none; padding: 3px 6px; border-radius: 3px; font-size: 9px; cursor: pointer; flex: 1; min-width: 45%;">
Edit
</button>
<button class="btn btn-primary btn-sm move-unit-btn"
data-location='${escapeHtml(JSON.stringify(location))}'
style="background: #6c757d; border: none; padding: 3px 6px; border-radius: 3px; font-size: 9px; cursor: pointer; flex: 1; min-width: 45%;">
📍 Move
</button>
</div>
</div>
` : ''}
<div style="font-size: 8px; color: #999; margin-top: 4px;">
ID: ${locationId || 'Unknown'}
</div>
`;
}
function setupAppApartmentPopupListeners(group) {
const { locations } = group;
// Sort locations same as in popup creation
const sortedLocations = locations.sort((a, b) => {
const aUnit = a['Unit Number'] || '';
const bUnit = b['Unit Number'] || '';
const aNum = aUnit ? parseInt(aUnit.replace(/\D/g, '')) || 0 : 0;
const bNum = bUnit ? parseInt(bUnit.replace(/\D/g, '')) || 0 : 0;
return aNum - bNum;
});
// Find the popup container in the DOM
const container = document.querySelector('.apartment-building-popup.app-data');
if (!container) {
console.log('App apartment popup container not found');
return;
}
const unitSelector = container.querySelector('.unit-selector');
const unitDetails = container.querySelector('.unit-details');
if (!unitSelector || !unitDetails) {
console.log('App apartment popup elements not found');
return;
}
function updateUnitDisplay(selectedIndex) {
const location = sortedLocations[selectedIndex];
unitDetails.innerHTML = createUnitDetailsHTML(location, selectedIndex);
// Re-attach event listeners for the new buttons
setTimeout(() => {
attachUnitButtonListeners();
}, 10);
}
function attachUnitButtonListeners() {
const editBtn = container.querySelector('.edit-unit-btn');
const moveBtn = container.querySelector('.move-unit-btn');
if (editBtn) {
editBtn.addEventListener('click', (e) => {
e.stopPropagation();
const locationData = JSON.parse(editBtn.getAttribute('data-location'));
map.closePopup();
openEditForm(locationData);
});
}
if (moveBtn) {
moveBtn.addEventListener('click', (e) => {
e.stopPropagation();
const locationData = JSON.parse(moveBtn.getAttribute('data-location'));
map.closePopup();
// Find the marker for this location
const marker = markers.find(m => m._locationData &&
(m._locationData.locations ?
m._locationData.locations.some(loc =>
(loc.Id || loc.id || loc.ID || loc._id) === (locationData.Id || locationData.id || locationData.ID || locationData._id)
) :
(m._locationData.Id || m._locationData.id || m._locationData.ID || m._locationData._id) === (locationData.Id || locationData.id || locationData.ID || locationData._id)
)
);
if (marker) {
startMovingMarker(locationData, marker);
}
});
}
}
// Set up unit selector dropdown event listener
unitSelector.addEventListener('change', (e) => {
e.stopPropagation();
const selectedIndex = parseInt(e.target.value);
console.log('Unit selected:', selectedIndex);
updateUnitDisplay(selectedIndex);
});
// Initialize with first unit selected and attach initial listeners
updateUnitDisplay(0);
console.log(`App apartment popup unit selector set up for ${sortedLocations.length} units`);
}

View File

@ -2,6 +2,7 @@
import { showStatus, parseGeoLocation } from './utils.js';
import { map, toggleStartLocationVisibility } from './map-manager.js';
import { loadLocations, handleAddLocation, handleEditLocation, handleDeleteLocation, openEditForm, closeEditForm, closeAddModal, openAddModal } from './location-manager.js';
import { toggleEdmontonParcelsLayer } from './external-layers.js';
export let userLocationMarker = null;
export let isAddingLocation = false;
@ -431,6 +432,7 @@ export function setupEventListeners() {
document.getElementById('geolocate-btn')?.addEventListener('click', getUserLocation);
document.getElementById('toggle-start-location-btn')?.addEventListener('click', toggleStartLocationVisibility);
document.getElementById('add-location-btn')?.addEventListener('click', toggleAddLocationMode);
document.getElementById('toggle-edmonton-layer-btn')?.addEventListener('click', toggleEdmontonParcelsLayer);
document.getElementById('fullscreen-btn')?.addEventListener('click', toggleFullscreen);
// Add logout button event listeners
@ -446,6 +448,7 @@ export function setupEventListeners() {
document.getElementById('mobile-geolocate-btn')?.addEventListener('click', getUserLocation);
document.getElementById('mobile-toggle-start-location-btn')?.addEventListener('click', toggleStartLocationVisibility);
document.getElementById('mobile-add-location-btn')?.addEventListener('click', toggleAddLocationMode);
document.getElementById('mobile-toggle-edmonton-layer-btn')?.addEventListener('click', toggleEdmontonParcelsLayer);
document.getElementById('mobile-fullscreen-btn')?.addEventListener('click', toggleFullscreen);
// Mobile dropdown toggle

View File

@ -0,0 +1,12 @@
const express = require('express');
const router = express.Router();
const externalDataController = require('../controllers/externalDataController');
const { requireAuth } = require('../middleware/auth');
// All external data routes require authentication
router.use(requireAuth);
// Route for City of Edmonton Parcel Addresses
router.get('/edmonton-parcels', externalDataController.getEdmontonParcelAddresses);
module.exports = router;

View File

@ -12,6 +12,7 @@ const qrRoutes = require('./qr');
const debugRoutes = require('./debug');
const geocodingRoutes = require('../routes/geocoding'); // Existing geocoding routes
const shiftsRoutes = require('./shifts');
const externalDataRoutes = require('./external');
module.exports = (app) => {
// Health check (no auth)
@ -47,6 +48,7 @@ module.exports = (app) => {
app.use('/api/geocode', requireAuth, geocodingRoutes);
app.use('/api/settings', requireAuth, settingsRoutes);
app.use('/api/shifts', shiftsRoutes);
app.use('/api/external', externalDataRoutes);
// Admin routes
app.get('/admin.html', requireAdmin, (req, res) => {

View File

@ -51,6 +51,12 @@ const buildConnectSrc = () => {
sources.push(`http://localhost:${mkdocsPort}`);
sources.push(`http://localhost:${mkdocsSitePort}`);
// Add City of Edmonton Socrata API
sources.push('https://data.edmonton.ca');
// Add Stadia Maps for better tile coverage
sources.push('https://tiles.stadiamaps.com');
// Add production domains if in production
if (config.isProduction || process.env.NODE_ENV === 'production') {
// Add the main domain from environment
@ -69,7 +75,7 @@ app.use(helmet({
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com"],
scriptSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com", "https://cdn.jsdelivr.net"],
imgSrc: ["'self'", "data:", "https://*.tile.openstreetmap.org", "https://unpkg.com"],
imgSrc: ["'self'", "data:", "https://*.tile.openstreetmap.org", "https://tiles.stadiamaps.com", "https://unpkg.com"],
connectSrc: buildConnectSrc()
}
}

View File

@ -0,0 +1,67 @@
const axios = require('axios');
const logger = require('../utils/logger');
const SOCRATA_DOMAIN = 'https://data.edmonton.ca';
class SocrataService {
constructor() {
this.client = axios.create({
baseURL: SOCRATA_DOMAIN,
timeout: 30000 // 30 seconds for potentially large datasets
});
this.client.interceptors.response.use(
response => response,
error => {
logger.error('Socrata API Error:', {
message: error.message,
url: error.config?.url,
status: error.response?.status,
data: error.response?.data
});
throw error;
}
);
}
/**
* Fetches data from a Socrata dataset.
* @param {string} datasetId - The ID of the dataset (e.g., 'nggt-rwac').
* @param {object} params - SoQL query parameters.
* @returns {Promise<Array>} A promise that resolves to an array of records.
*/
async get(datasetId, params = {}) {
try {
logger.info(`Fetching Socrata dataset ${datasetId} with params:`, params);
// Socrata uses an app token for higher rate limits, but it's not required for public data.
// We can add an X-App-Token header here if one is obtained.
const response = await this.client.get(`/resource/${datasetId}.json`, { params });
logger.info(`Successfully fetched ${response.data.length} records from Socrata dataset ${datasetId}`);
return response.data;
} catch (error) {
logger.error(`Failed to fetch Socrata dataset ${datasetId}`, {
message: error.message,
status: error.response?.status,
statusText: error.response?.statusText,
data: error.response?.data,
url: error.config?.url,
params: params
});
// Provide more specific error messages
if (error.response?.status === 404) {
throw new Error(`Dataset ${datasetId} not found on Socrata API`);
} else if (error.response?.status === 400) {
throw new Error(`Invalid query parameters for dataset ${datasetId}`);
} else if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') {
throw new Error('Unable to connect to Edmonton Open Data Portal');
}
throw new Error('Could not retrieve data from the external source.');
}
}
}
module.exports = new SocrataService();