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
|
## 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
|
```bash
|
||||||
# Clone the repository
|
# Clone the repository
|
||||||
git clone https://gitea.bnkops.com/admin/changemaker.lite
|
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.
|
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
|
## Service Access
|
||||||
|
|
||||||
After starting, access services at:
|
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;
|
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 {
|
.mobile-sidebar .btn:active {
|
||||||
transform: scale(0.95);
|
transform: scale(0.95);
|
||||||
}
|
}
|
||||||
@ -1583,3 +1590,239 @@ path.leaflet-interactive {
|
|||||||
margin: 0 !important;
|
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="
|
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||||
crossorigin="" />
|
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 -->
|
<!-- Custom CSS -->
|
||||||
<link rel="stylesheet" href="css/style.css">
|
<link rel="stylesheet" href="css/style.css">
|
||||||
</head>
|
</head>
|
||||||
@ -18,7 +22,7 @@
|
|||||||
<div id="app">
|
<div id="app">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<h1>Map for CM-lite</h1>
|
<h1>Map</h1>
|
||||||
|
|
||||||
<!-- Add documentation search bar -->
|
<!-- Add documentation search bar -->
|
||||||
<div class="docs-search-container">
|
<div class="docs-search-container">
|
||||||
@ -91,6 +95,10 @@
|
|||||||
<span class="btn-icon">🏠</span>
|
<span class="btn-icon">🏠</span>
|
||||||
<span class="btn-text">Hide Start Location</span>
|
<span class="btn-text">Hide Start Location</span>
|
||||||
</button>
|
</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">
|
<button id="refresh-btn" class="btn btn-primary">
|
||||||
<span class="btn-icon">🔄</span>
|
<span class="btn-icon">🔄</span>
|
||||||
<span class="btn-text">Refresh</span>
|
<span class="btn-text">Refresh</span>
|
||||||
@ -103,6 +111,7 @@
|
|||||||
<span class="btn-icon">➕</span>
|
<span class="btn-icon">➕</span>
|
||||||
<span class="btn-text">Add Location Here</span>
|
<span class="btn-text">Add Location Here</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button id="fullscreen-btn" class="btn btn-secondary">
|
<button id="fullscreen-btn" class="btn btn-secondary">
|
||||||
<span class="btn-icon">⛶</span>
|
<span class="btn-icon">⛶</span>
|
||||||
<span class="btn-text">Fullscreen</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 id="mobile-add-location-btn" class="btn btn-success" title="Add Location">
|
||||||
➕
|
➕
|
||||||
</button>
|
</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 id="mobile-fullscreen-btn" class="btn btn-secondary" title="Fullscreen">
|
||||||
⛶
|
⛶
|
||||||
</button>
|
</button>
|
||||||
@ -377,6 +389,9 @@
|
|||||||
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
||||||
crossorigin=""></script>
|
crossorigin=""></script>
|
||||||
|
|
||||||
|
<!-- Leaflet MarkerCluster JS -->
|
||||||
|
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
|
||||||
|
|
||||||
<!-- Cache Management -->
|
<!-- Cache Management -->
|
||||||
<script src="js/cache-manager.js"></script>
|
<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)
|
// Location management (CRUD operations)
|
||||||
import { map } from './map-manager.js';
|
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 { currentUser } from './auth.js';
|
||||||
import { resetAddressConfirmation } from './ui-controls.js';
|
import { resetAddressConfirmation } from './ui-controls.js';
|
||||||
|
|
||||||
@ -39,17 +52,45 @@ export function displayLocations(locations) {
|
|||||||
});
|
});
|
||||||
markers = [];
|
markers = [];
|
||||||
|
|
||||||
// Add new markers
|
// Group locations by address to identify multi-unit buildings
|
||||||
|
const addressGroups = new Map();
|
||||||
|
|
||||||
locations.forEach(location => {
|
locations.forEach(location => {
|
||||||
if (location.latitude && location.longitude) {
|
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) {
|
if (marker) {
|
||||||
markers.push(marker);
|
markers.push(marker);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Displayed ${markers.length} locations`);
|
console.log(`Displayed ${markers.length} location markers (${locations.length} total locations)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createLocationMarker(location) {
|
function createLocationMarker(location) {
|
||||||
@ -653,3 +694,267 @@ function cleanupMoveState() {
|
|||||||
originalPosition = null;
|
originalPosition = null;
|
||||||
movingLocationData = 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 { showStatus, parseGeoLocation } from './utils.js';
|
||||||
import { map, toggleStartLocationVisibility } from './map-manager.js';
|
import { map, toggleStartLocationVisibility } from './map-manager.js';
|
||||||
import { loadLocations, handleAddLocation, handleEditLocation, handleDeleteLocation, openEditForm, closeEditForm, closeAddModal, openAddModal } from './location-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 userLocationMarker = null;
|
||||||
export let isAddingLocation = false;
|
export let isAddingLocation = false;
|
||||||
@ -431,6 +432,7 @@ export function setupEventListeners() {
|
|||||||
document.getElementById('geolocate-btn')?.addEventListener('click', getUserLocation);
|
document.getElementById('geolocate-btn')?.addEventListener('click', getUserLocation);
|
||||||
document.getElementById('toggle-start-location-btn')?.addEventListener('click', toggleStartLocationVisibility);
|
document.getElementById('toggle-start-location-btn')?.addEventListener('click', toggleStartLocationVisibility);
|
||||||
document.getElementById('add-location-btn')?.addEventListener('click', toggleAddLocationMode);
|
document.getElementById('add-location-btn')?.addEventListener('click', toggleAddLocationMode);
|
||||||
|
document.getElementById('toggle-edmonton-layer-btn')?.addEventListener('click', toggleEdmontonParcelsLayer);
|
||||||
document.getElementById('fullscreen-btn')?.addEventListener('click', toggleFullscreen);
|
document.getElementById('fullscreen-btn')?.addEventListener('click', toggleFullscreen);
|
||||||
|
|
||||||
// Add logout button event listeners
|
// Add logout button event listeners
|
||||||
@ -446,6 +448,7 @@ export function setupEventListeners() {
|
|||||||
document.getElementById('mobile-geolocate-btn')?.addEventListener('click', getUserLocation);
|
document.getElementById('mobile-geolocate-btn')?.addEventListener('click', getUserLocation);
|
||||||
document.getElementById('mobile-toggle-start-location-btn')?.addEventListener('click', toggleStartLocationVisibility);
|
document.getElementById('mobile-toggle-start-location-btn')?.addEventListener('click', toggleStartLocationVisibility);
|
||||||
document.getElementById('mobile-add-location-btn')?.addEventListener('click', toggleAddLocationMode);
|
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);
|
document.getElementById('mobile-fullscreen-btn')?.addEventListener('click', toggleFullscreen);
|
||||||
|
|
||||||
// Mobile dropdown toggle
|
// 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 debugRoutes = require('./debug');
|
||||||
const geocodingRoutes = require('../routes/geocoding'); // Existing geocoding routes
|
const geocodingRoutes = require('../routes/geocoding'); // Existing geocoding routes
|
||||||
const shiftsRoutes = require('./shifts');
|
const shiftsRoutes = require('./shifts');
|
||||||
|
const externalDataRoutes = require('./external');
|
||||||
|
|
||||||
module.exports = (app) => {
|
module.exports = (app) => {
|
||||||
// Health check (no auth)
|
// Health check (no auth)
|
||||||
@ -47,6 +48,7 @@ module.exports = (app) => {
|
|||||||
app.use('/api/geocode', requireAuth, geocodingRoutes);
|
app.use('/api/geocode', requireAuth, geocodingRoutes);
|
||||||
app.use('/api/settings', requireAuth, settingsRoutes);
|
app.use('/api/settings', requireAuth, settingsRoutes);
|
||||||
app.use('/api/shifts', shiftsRoutes);
|
app.use('/api/shifts', shiftsRoutes);
|
||||||
|
app.use('/api/external', externalDataRoutes);
|
||||||
|
|
||||||
// Admin routes
|
// Admin routes
|
||||||
app.get('/admin.html', requireAdmin, (req, res) => {
|
app.get('/admin.html', requireAdmin, (req, res) => {
|
||||||
|
|||||||
@ -51,6 +51,12 @@ const buildConnectSrc = () => {
|
|||||||
sources.push(`http://localhost:${mkdocsPort}`);
|
sources.push(`http://localhost:${mkdocsPort}`);
|
||||||
sources.push(`http://localhost:${mkdocsSitePort}`);
|
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
|
// Add production domains if in production
|
||||||
if (config.isProduction || process.env.NODE_ENV === 'production') {
|
if (config.isProduction || process.env.NODE_ENV === 'production') {
|
||||||
// Add the main domain from environment
|
// Add the main domain from environment
|
||||||
@ -69,7 +75,7 @@ app.use(helmet({
|
|||||||
defaultSrc: ["'self'"],
|
defaultSrc: ["'self'"],
|
||||||
styleSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com"],
|
styleSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com"],
|
||||||
scriptSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com", "https://cdn.jsdelivr.net"],
|
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()
|
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