Added apartment views and city of edmonton data import
This commit is contained in:
parent
5da24aed56
commit
994440a2fd
@ -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:
|
||||
|
||||
132
map/app/controllers/externalDataController.js
Normal file
132
map/app/controllers/externalDataController.js
Normal 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();
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
@ -376,6 +388,9 @@
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||
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>
|
||||
|
||||
564
map/app/public/js/external-layers.js
Normal file
564
map/app/public/js/external-layers.js
Normal 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');
|
||||
}
|
||||
}
|
||||
@ -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 = `
|
||||
<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`);
|
||||
}
|
||||
@ -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
|
||||
|
||||
12
map/app/routes/external.js
Normal file
12
map/app/routes/external.js
Normal 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;
|
||||
@ -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) => {
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
67
map/app/services/socrata.js
Normal file
67
map/app/services/socrata.js
Normal 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();
|
||||
Loading…
x
Reference in New Issue
Block a user