freealberta/map/app/public/js/external-layers.js

601 lines
26 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
if (isMultiUnit) {
// Use a custom HTML marker for multi-unit buildings (square)
const size = Math.max(12, Math.min(24, (currentZoom - 10) * 2));
const icon = L.divIcon({
className: 'multi-unit-marker',
html: `<div class="apartment-marker" style="
width: ${size}px;
height: ${size}px;
background-color: #ff6b35;
border: 2px solid #ffffff;
border-radius: 0;
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
"></div>`,
iconSize: [size, size],
iconAnchor: [size/2, size/2],
popupAnchor: [0, -size/2]
});
return L.marker(latlng, {
icon: icon,
zIndexOffset: -100 // Behind NocoDB locations
});
} else {
// Use circle marker for single units (round, red)
return L.circleMarker(latlng, {
radius: Math.max(3, Math.min(6, currentZoom - 10)),
fillColor: "#ba001e",
color: "#ffffff",
weight: 1,
opacity: 0.9,
fillOpacity: 0.7,
zIndexOffset: -100 // Behind NocoDB locations
});
}
},
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 but prevent the automatic address lookup
openAddModal(lat, lng, false);
// 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 but prevent the automatic address lookup
openAddModal(lat, lng, false);
// 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
*/
async function prefillAddForm(data) {
try {
// Import UI controls dynamically to avoid circular dependencies
const { resetAddressConfirmation } = await import('./ui-controls.js');
// First, reset any existing state
resetAddressConfirmation('add');
// 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) {
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.trim();
}
// 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)}`;
}
// Mark the address as confirmed since it's coming from a trusted source
const { setAddressConfirmed } = await import('./ui-controls.js');
setAddressConfirmed('add', true);
console.log('Form pre-filled and confirmed with city data:', data);
} catch (error) {
console.error('Error pre-filling form:', error);
showStatus('Error pre-filling form with city data', 'error');
}
}