Added apartment views and city of edmonton data import
This commit is contained in:
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
|
||||
|
||||
Reference in New Issue
Block a user