From 994440a2fdaacf3e5ce35b60f456cada5510b54f Mon Sep 17 00:00:00 2001 From: admin Date: Sun, 27 Jul 2025 16:19:12 -0600 Subject: [PATCH] Added apartment views and city of edmonton data import --- README.md | 7 +- map/app/controllers/externalDataController.js | 132 ++++ map/app/public/css/style.css | 243 ++++++++ map/app/public/index.html | 17 +- map/app/public/js/external-layers.js | 564 ++++++++++++++++++ map/app/public/js/location-manager.js | 313 +++++++++- map/app/public/js/ui-controls.js | 3 + map/app/routes/external.js | 12 + map/app/routes/index.js | 2 + map/app/server.js | 8 +- map/app/services/socrata.js | 67 +++ 11 files changed, 1357 insertions(+), 11 deletions(-) create mode 100644 map/app/controllers/externalDataController.js create mode 100644 map/app/public/js/external-layers.js create mode 100644 map/app/routes/external.js create mode 100644 map/app/services/socrata.js diff --git a/README.md b/README.md index 09108af..80df7dd 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/map/app/controllers/externalDataController.js b/map/app/controllers/externalDataController.js new file mode 100644 index 0000000..f16b777 --- /dev/null +++ b/map/app/controllers/externalDataController.js @@ -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(); diff --git a/map/app/public/css/style.css b/map/app/public/css/style.css index 192b164..aa762f9 100644 --- a/map/app/public/css/style.css +++ b/map/app/public/css/style.css @@ -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; +} diff --git a/map/app/public/index.html b/map/app/public/index.html index e84775a..e99a2ae 100644 --- a/map/app/public/index.html +++ b/map/app/public/index.html @@ -11,6 +11,10 @@ integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" /> + + + + @@ -18,7 +22,7 @@
-

Map for CM-lite

+

Map

@@ -91,6 +95,10 @@ 🏠 Hide Start Location + + + @@ -376,6 +388,9 @@ + + + diff --git a/map/app/public/js/external-layers.js b/map/app/public/js/external-layers.js new file mode 100644 index 0000000..dbdb6d1 --- /dev/null +++ b/map/app/public/js/external-layers.js @@ -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 = `${unitLabel}: ${suite.suite.trim()}
`; + } + + const popupContent = ` +
+ 🏠 ${props.address}
+ ${suiteDisplay} + ${props.neighborhood ? `📍 ${props.neighborhood}
` : ''} + ${suite.object_type ? `${suite.object_type}
` : ''} + City of Edmonton Data +
+ +
+
+ `; + 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 = ` +
+
+
+
+
🏢 ${displayAddress}
+
${totalSuites} units • ${props.neighborhood || 'Edmonton'}
+
+
+ Apt +
+
+
+ +
+
+ + +
+ +
+
+
+ ${suites[0].suite ? `Suite ${suites[0].suite.trim()}` : `Unit 1`} +
+
+ ${suites[0].object_type || 'Residential Unit'} +
+ ${suites[0].house_number && suites[0].street_name ? ` +
+ 📍 ${suites[0].house_number} ${suites[0].street_name} +
` : ''} + ${suites[0].record_id ? ` +
+ ID: ${suites[0].record_id} +
` : ''} +
+
+
+ +
+ City of Edmonton Open Data +
+ +
+ +
+
+ `; + + 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 = ` +
+ ${unitLabel} ${unitNumber} +
+
+ ${suite.object_type || 'Residential Unit'} +
+ ${suite.house_number && suite.street_name ? ` +
+ 📍 ${suite.house_number} ${suite.street_name} +
` : ''} + ${suite.record_id ? ` +
+ ID: ${suite.record_id} +
` : ''} + `; + } + + // 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'); + } +} diff --git a/map/app/public/js/location-manager.js b/map/app/public/js/location-manager.js index 30cf1bc..783169f 100644 --- a/map/app/public/js/location-manager.js +++ b/map/app/public/js/location-manager.js @@ -1,6 +1,19 @@ +// Helper function to escape HTML to prevent XSS +function escapeHtml(text) { + if (typeof text !== 'string') return text; + const map = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + 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) { @@ -652,4 +693,268 @@ function cleanupMoveState() { movingMarker = null; 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 = ` +
+
+
+
+
🏢 ${escapeHtml(displayAddress)}
+
${totalUnits} contacts
+
+
+ Multi-Unit +
+
+
+ +
+
+ + +
+ +
+
+ ${createUnitDetailsHTML(sortedLocations[0], 0)} +
+
+
+ +
+ Your Campaign Database +
+
+ `; + + 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 ` +
+ ${escapeHtml(unit)} - ${escapeHtml(name)} +
+
+ Support: ${escapeHtml(supportLevel)} +
+ ${email ? ` +
+ 📧 ${escapeHtml(truncatedEmail)} +
` : ''} + ${phone ? ` +
+ 📞 ${escapeHtml(truncatedPhone)} +
` : ''} + ${location.Sign ? '
🏁 Has sign
' : ''} + ${location.Notes ? ` +
+ ${escapeHtml(truncatedNotes)} +
` : ''} + ${currentUser ? ` +
+
+ + +
+
+ ` : ''} +
+ ID: ${locationId || 'Unknown'} +
+ `; +} + +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`); } \ No newline at end of file diff --git a/map/app/public/js/ui-controls.js b/map/app/public/js/ui-controls.js index 0d8d7c0..750485d 100644 --- a/map/app/public/js/ui-controls.js +++ b/map/app/public/js/ui-controls.js @@ -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 diff --git a/map/app/routes/external.js b/map/app/routes/external.js new file mode 100644 index 0000000..99f052b --- /dev/null +++ b/map/app/routes/external.js @@ -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; diff --git a/map/app/routes/index.js b/map/app/routes/index.js index 2b6fb12..b934bde 100644 --- a/map/app/routes/index.js +++ b/map/app/routes/index.js @@ -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) => { diff --git a/map/app/server.js b/map/app/server.js index 1dc6dce..5aad159 100644 --- a/map/app/server.js +++ b/map/app/server.js @@ -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() } } diff --git a/map/app/services/socrata.js b/map/app/services/socrata.js new file mode 100644 index 0000000..0acc1f8 --- /dev/null +++ b/map/app/services/socrata.js @@ -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} 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();