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