From b3cd1a33315f0f37358e607f22ada575156bec3a Mon Sep 17 00:00:00 2001 From: admin Date: Sun, 7 Sep 2025 11:08:27 -0600 Subject: [PATCH] Semi working map cuts view; need to refactor and fix some stuff however stable enough to commit --- map/app/controllers/cutsController.js | 267 ++++++ map/app/public/admin.html | 132 +++ map/app/public/css/modules/cuts.css | 245 +++++ map/app/public/js/admin-cuts.js | 1257 +++++++++++++++++++++++++ map/app/routes/cuts.js | 6 + map/app/server.js | 5 +- map/app/utils/spatial.js | 188 ++++ map/build-nocodb.sh | 39 + mkdocs/docs/overrides/lander.html | 6 +- 9 files changed, 2141 insertions(+), 4 deletions(-) create mode 100644 map/app/utils/spatial.js diff --git a/map/app/controllers/cutsController.js b/map/app/controllers/cutsController.js index fa54e4f..bfbc552 100644 --- a/map/app/controllers/cutsController.js +++ b/map/app/controllers/cutsController.js @@ -1,6 +1,7 @@ const nocodbService = require('../services/nocodb'); const logger = require('../utils/logger'); const config = require('../config'); +const spatialUtils = require('../utils/spatial'); class CutsController { /** @@ -358,6 +359,272 @@ class CutsController { }); } } + + /** + * Get all locations within a cut boundary - admin only + */ + async getLocationsInCut(req, res) { + try { + const { id } = req.params; + const { isAdmin } = req.user || {}; + + if (!isAdmin) { + return res.status(403).json({ error: 'Admin access required' }); + } + + // Get the cut + const cut = await nocodbService.getById(config.CUTS_TABLE_ID, id); + if (!cut) { + return res.status(404).json({ error: 'Cut not found' }); + } + + // Get all locations + const locationsResponse = await nocodbService.getAll(config.LOCATIONS_TABLE_ID); + if (!locationsResponse || !locationsResponse.list) { + return res.json({ locations: [], statistics: { total_locations: 0 } }); + } + + // Apply filters from query params + const filters = { + support_level: req.query.support_level, + has_sign: req.query.has_sign === 'true' ? true : req.query.has_sign === 'false' ? false : undefined, + sign_size: req.query.sign_size, + has_email: req.query.has_email === 'true' ? true : req.query.has_email === 'false' ? false : undefined, + has_phone: req.query.has_phone === 'true' ? true : req.query.has_phone === 'false' ? false : undefined + }; + + // Filter locations within cut boundaries + const filteredLocations = spatialUtils.filterLocationsInCut( + locationsResponse.list, + cut, + filters + ); + + // Calculate statistics + const statistics = spatialUtils.calculateCutStatistics(filteredLocations); + + const cutName = cut.name || cut.Name || cut.title || cut.Title || 'Unknown'; + logger.info(`Found ${filteredLocations.length} locations in cut: ${cutName}`); + res.json({ + locations: filteredLocations, + statistics, + cut: { id: cut.id || cut.Id || cut.ID, name: cutName } + }); + + } catch (error) { + logger.error('Error getting locations in cut:', error); + res.status(500).json({ + error: 'Failed to get locations in cut', + details: error.message + }); + } + } + + /** + * Export locations within a cut as CSV - admin only + */ + async exportCutLocations(req, res) { + try { + const { id } = req.params; + const { isAdmin } = req.user || {}; + + if (!isAdmin) { + return res.status(403).json({ error: 'Admin access required' }); + } + + // Get the cut + const cut = await nocodbService.getById(config.CUTS_TABLE_ID, id); + if (!cut) { + return res.status(404).json({ error: 'Cut not found' }); + } + + // Check if export is enabled for this cut + if (cut.export_enabled === false) { + return res.status(403).json({ error: 'Export is disabled for this cut' }); + } + + // Get all locations + const locationsResponse = await nocodbService.getAll(config.LOCATIONS_TABLE_ID); + if (!locationsResponse || !locationsResponse.list) { + return res.json({ locations: [] }); + } + + // Apply filters from query params + const filters = { + support_level: req.query.support_level, + has_sign: req.query.has_sign === 'true' ? true : req.query.has_sign === 'false' ? false : undefined, + sign_size: req.query.sign_size, + has_email: req.query.has_email === 'true' ? true : req.query.has_email === 'false' ? false : undefined, + has_phone: req.query.has_phone === 'true' ? true : req.query.has_phone === 'false' ? false : undefined + }; + + // Filter locations within cut boundaries + const filteredLocations = spatialUtils.filterLocationsInCut( + locationsResponse.list, + cut, + filters + ); + + // Generate CSV content + const csvHeaders = [ + 'ID', 'First Name', 'Last Name', 'Email', 'Phone', 'Address', + 'Unit Number', 'Support Level', 'Has Sign', 'Sign Size', + 'Latitude', 'Longitude', 'Notes' + ]; + + const csvRows = filteredLocations.map(location => [ + location.id || '', + location.first_name || '', + location.last_name || '', + location.email || '', + location.phone || '', + location.address || '', + location.unit_number || '', + location.support_level || '', + location.sign ? 'Yes' : 'No', + location.sign_size || '', + location.latitude || '', + location.longitude || '', + (location.notes || '').replace(/"/g, '""') // Escape quotes in notes + ]); + + const csvContent = [ + csvHeaders.join(','), + ...csvRows.map(row => row.map(field => `"${field}"`).join(',')) + ].join('\n'); + + const cutName = (cut.name || 'cut').replace(/[^a-zA-Z0-9]/g, '_'); + const timestamp = new Date().toISOString().split('T')[0]; + const filename = `${cutName}_locations_${timestamp}.csv`; + + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send(csvContent); + + logger.info(`Exported ${filteredLocations.length} locations from cut: ${cut.name}`); + + } catch (error) { + logger.error('Error exporting cut locations:', error); + res.status(500).json({ + error: 'Failed to export cut locations', + details: error.message + }); + } + } + + /** + * Update cut settings (visibility, filters, etc.) - admin only + */ + async updateCutSettings(req, res) { + try { + const { id } = req.params; + const { isAdmin } = req.user || {}; + + if (!isAdmin) { + return res.status(403).json({ error: 'Admin access required' }); + } + + const { + show_locations, + export_enabled, + assigned_to, + filter_settings, + last_canvassed, + completion_percentage + } = req.body; + + const updateData = { + updated_at: new Date().toISOString() + }; + + // Only include fields that are provided + if (show_locations !== undefined) updateData.show_locations = show_locations; + if (export_enabled !== undefined) updateData.export_enabled = export_enabled; + if (assigned_to !== undefined) updateData.assigned_to = assigned_to; + if (filter_settings !== undefined) updateData.filter_settings = JSON.stringify(filter_settings); + if (last_canvassed !== undefined) updateData.last_canvassed = last_canvassed; + if (completion_percentage !== undefined) updateData.completion_percentage = completion_percentage; + + const response = await nocodbService.update( + config.CUTS_TABLE_ID, + id, + updateData + ); + + logger.info(`Updated cut settings for cut ID: ${id}`); + res.json(response); + + } catch (error) { + logger.error('Error updating cut settings:', error); + res.status(500).json({ + error: 'Failed to update cut settings', + details: error.message + }); + } + } + + /** + * Get cut statistics - admin only + */ + async getCutStatistics(req, res) { + try { + const { id } = req.params; + const { isAdmin } = req.user || {}; + + if (!isAdmin) { + return res.status(403).json({ error: 'Admin access required' }); + } + + // Get the cut + const cut = await nocodbService.getById(config.CUTS_TABLE_ID, id); + if (!cut) { + return res.status(404).json({ error: 'Cut not found' }); + } + + // Get all locations + const locationsResponse = await nocodbService.getAll(config.LOCATIONS_TABLE_ID); + if (!locationsResponse || !locationsResponse.list) { + return res.json({ + statistics: { total_locations: 0 }, + cut: { id: cut.id, name: cut.name } + }); + } + + // Get locations within cut boundaries (no additional filters) + const locationsInCut = spatialUtils.filterLocationsInCut( + locationsResponse.list, + cut, + {} // No additional filters for statistics + ); + + // Calculate statistics + const statistics = spatialUtils.calculateCutStatistics(locationsInCut); + + // Add cut metadata + const cutStats = { + ...statistics, + cut_metadata: { + id: cut.id, + name: cut.name, + category: cut.category, + assigned_to: cut.assigned_to, + completion_percentage: cut.completion_percentage || 0, + last_canvassed: cut.last_canvassed, + created_at: cut.created_at + } + }; + + logger.info(`Generated statistics for cut: ${cut.name}`); + res.json({ statistics: cutStats }); + + } catch (error) { + logger.error('Error getting cut statistics:', error); + res.status(500).json({ + error: 'Failed to get cut statistics', + details: error.message + }); + } + } } module.exports = new CutsController(); diff --git a/map/app/public/admin.html b/map/app/public/admin.html index 2487112..c666e45 100644 --- a/map/app/public/admin.html +++ b/map/app/public/admin.html @@ -845,6 +845,134 @@ + + +
@@ -1242,6 +1370,10 @@ + + + + diff --git a/map/app/public/css/modules/cuts.css b/map/app/public/css/modules/cuts.css index 89c607f..9d122fc 100644 --- a/map/app/public/css/modules/cuts.css +++ b/map/app/public/css/modules/cuts.css @@ -994,3 +994,248 @@ color: #666; margin-top: 2px; } + +/* Cut Location Management Styles */ +.cuts-location-section { + margin-top: 20px; +} + +.location-filters { + background: #f8f9fa; + padding: 15px; + border-radius: 8px; + margin-bottom: 20px; +} + +.location-filters h4 { + margin: 0 0 15px 0; + color: #495057; + font-size: 16px; + font-weight: 600; +} + +.filter-row { + display: flex; + flex-wrap: wrap; + gap: 15px; + align-items: end; +} + +.filter-group { + display: flex; + flex-direction: column; + min-width: 150px; +} + +.filter-group label { + font-size: 12px; + color: #6c757d; + margin-bottom: 5px; + font-weight: 500; +} + +.filter-group .form-control { + padding: 6px 10px; + font-size: 14px; + border: 1px solid #dee2e6; + border-radius: 4px; +} + +.filter-group button { + white-space: nowrap; +} + +/* Cut Statistics Styles */ +.cut-statistics { + background: #e9ecef; + padding: 15px; + border-radius: 8px; + margin-bottom: 20px; +} + +.cut-statistics h4 { + margin: 0 0 15px 0; + color: #495057; + font-size: 16px; + font-weight: 600; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 10px; +} + +.stat-item { + display: flex; + justify-content: space-between; + padding: 8px 12px; + background: white; + border-radius: 4px; + border-left: 4px solid #007bff; +} + +.stat-label { + font-size: 14px; + color: #6c757d; + font-weight: 500; +} + +.stat-value { + font-size: 14px; + color: #212529; + font-weight: 600; +} + +/* Cut Settings Styles */ +.cut-settings { + background: #f1f3f4; + padding: 15px; + border-radius: 8px; +} + +.cut-settings h4 { + margin: 0 0 15px 0; + color: #495057; + font-size: 16px; + font-weight: 600; +} + +.setting-row { + display: flex; + flex-wrap: wrap; + gap: 15px; + align-items: center; +} + +.setting-group { + display: flex; + flex-direction: column; + min-width: 150px; +} + +.setting-group label { + font-size: 14px; + color: #495057; + margin-bottom: 5px; + font-weight: 500; + display: flex; + align-items: center; + gap: 8px; +} + +.setting-group input[type="checkbox"] { + margin: 0; +} + +.setting-group input[type="text"], +.setting-group input[type="number"] { + padding: 6px 10px; + font-size: 14px; + border: 1px solid #dee2e6; + border-radius: 4px; +} + +.setting-group span { + font-size: 14px; + color: #6c757d; + margin-left: 5px; +} + +/* Button States for Location Management */ +#toggle-location-visibility.active { + background-color: #28a745; + border-color: #28a745; +} + +#toggle-location-visibility.inactive { + background-color: #6c757d; + border-color: #6c757d; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .filter-row { + flex-direction: column; + align-items: stretch; + } + + .filter-group { + min-width: 100%; + } + + .setting-row { + flex-direction: column; + align-items: stretch; + } + + .setting-group { + min-width: 100%; + } + + .stats-grid { + grid-template-columns: 1fr; + } +} + +/* Enhanced panel styling for location management */ +.cuts-location-section .panel-header { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; +} + +.cuts-location-section .panel-title { + color: white; +} + +.cuts-location-section .btn-sm { + font-size: 12px; + padding: 4px 8px; +} + +/* Print View Styles */ +@media print { + .cuts-location-section { + page-break-inside: avoid; + } + + .location-filters, + .cut-settings { + display: none; + } + + .cut-statistics { + border: 1px solid #000; + background: white !important; + } +} + +/* Print-specific map styles */ +@media screen { + .leaflet-container img { + max-width: none !important; + opacity: 1 !important; + } + + .leaflet-tile-pane { + opacity: 1 !important; + } + + .leaflet-overlay-pane { + opacity: 1 !important; + } +} + +/* Ensure tiles are visible during capture */ +.leaflet-tile { + opacity: 1 !important; + visibility: visible !important; +} + +.leaflet-container .leaflet-tile-pane { + z-index: 1 !important; +} + +.leaflet-container .leaflet-overlay-pane { + z-index: 2 !important; +} diff --git a/map/app/public/js/admin-cuts.js b/map/app/public/js/admin-cuts.js index 1647d7d..2664cc1 100644 --- a/map/app/public/js/admin-cuts.js +++ b/map/app/public/js/admin-cuts.js @@ -429,6 +429,12 @@ class AdminCutsManager { this.currentCutLayer = null; this.isInitialized = false; // Add initialization flag + // Location management properties + this.currentCutLocations = []; + this.currentFilters = {}; + this.locationMarkersLayer = null; + this.showingLocations = false; + // Bind event handler once to avoid issues with removing listeners this.boundHandleCutActionClick = this.handleCutActionClick.bind(this); } @@ -795,6 +801,9 @@ class AdminCutsManager { } }); } + + // Location management event listeners + this.setupLocationManagementControls(); } // Set up toolbar controls for real-time drawing feedback @@ -1193,6 +1202,9 @@ class AdminCutsManager { startDrawingBtn.classList.remove('danger'); } + // Hide location management panel + this.hideLocationManagement(); + console.log('Cut form and drawing reset complete'); } @@ -1526,6 +1538,7 @@ class AdminCutsManager { if (!cut) return; this.displayCut(cut); + this.showLocationManagement(cutId); const cutName = cut.name || cut.Name || 'Unknown'; this.showStatus(`Viewing cut: ${cutName}`, 'info'); } @@ -1942,6 +1955,1250 @@ class AdminCutsManager { console.log(`[${type.toUpperCase()}] ${message}`); } } + + // Location Management Methods + setupLocationManagementControls() { + // Toggle location visibility + const toggleLocationBtn = document.getElementById('toggle-location-visibility'); + if (toggleLocationBtn) { + toggleLocationBtn.addEventListener('click', () => this.toggleLocationVisibility()); + } + + // Export cut locations + const exportBtn = document.getElementById('export-cut-locations'); + if (exportBtn) { + exportBtn.addEventListener('click', () => this.exportCutLocations()); + } + + // Print cut view + const printBtn = document.getElementById('print-cut-view'); + if (printBtn) { + printBtn.addEventListener('click', () => this.printCutView()); + } + + // Apply filters + const applyFiltersBtn = document.getElementById('apply-filters'); + if (applyFiltersBtn) { + applyFiltersBtn.addEventListener('click', () => this.applyLocationFilters()); + } + + // Clear filters + const clearFiltersBtn = document.getElementById('clear-filters'); + if (clearFiltersBtn) { + clearFiltersBtn.addEventListener('click', () => this.clearLocationFilters()); + } + + // Save cut settings + const saveSettingsBtn = document.getElementById('save-cut-settings'); + if (saveSettingsBtn) { + saveSettingsBtn.addEventListener('click', () => this.saveCutSettings()); + } + } + + async toggleLocationVisibility() { + if (!this.currentCutId) { + this.showStatus('No cut selected', 'warning'); + return; + } + + const toggleBtn = document.getElementById('toggle-location-visibility'); + + if (this.showingLocations) { + // Hide locations + if (this.locationMarkersLayer) { + this.cutsMap.removeLayer(this.locationMarkersLayer); + } + this.showingLocations = false; + toggleBtn.textContent = 'Show Locations'; + toggleBtn.classList.remove('active'); + toggleBtn.classList.add('inactive'); + } else { + // Show locations + await this.loadCutLocations(); + toggleBtn.textContent = 'Hide Locations'; + toggleBtn.classList.add('active'); + toggleBtn.classList.remove('inactive'); + } + } + + async loadCutLocations() { + if (!this.currentCutId) return; + + try { + const filters = this.getCurrentFilters(); + const queryParams = new URLSearchParams(filters); + + const response = await fetch(`/api/cuts/${this.currentCutId}/locations?${queryParams}`, { + credentials: 'include' + }); + + if (!response.ok) { + throw new Error(`Failed to load locations: ${response.statusText}`); + } + + const data = await response.json(); + this.currentCutLocations = data.locations || []; + + // Update statistics + this.updateStatistics(data.statistics); + + // Display locations on map + this.displayLocationsOnMap(this.currentCutLocations); + + this.showingLocations = true; + this.showStatus(`Loaded ${this.currentCutLocations.length} locations`, 'success'); + + } catch (error) { + console.error('Error loading cut locations:', error); + this.showStatus('Failed to load locations', 'error'); + } + } + + displayLocationsOnMap(locations) { + // Remove existing markers + if (this.locationMarkersLayer) { + this.cutsMap.removeLayer(this.locationMarkersLayer); + } + + // Create new markers layer + this.locationMarkersLayer = L.layerGroup(); + + locations.forEach(location => { + if (location.latitude && location.longitude) { + const marker = L.marker([location.latitude, location.longitude], { + icon: this.createLocationIcon(location) + }); + + const popupContent = this.createLocationPopup(location); + marker.bindPopup(popupContent); + + this.locationMarkersLayer.addLayer(marker); + } + }); + + this.locationMarkersLayer.addTo(this.cutsMap); + } + + createLocationIcon(location) { + // Create different icons based on support level + const supportLevel = location.support_level || location['Support Level'] || 'unknown'; + const colors = { + '1': '#28a745', // Green - Strong support + '2': '#ffc107', // Yellow - Lean support + '3': '#fd7e14', // Orange - Lean opposition + '4': '#dc3545', // Red - Strong opposition + 'unknown': '#6c757d' // Grey - Unknown + }; + + const color = colors[supportLevel] || colors['unknown']; + + return L.divIcon({ + className: 'location-marker', + html: `
`, + iconSize: [16, 16], + iconAnchor: [8, 8] + }); + } + + createLocationPopup(location) { + // Handle different possible field names for NocoDB + const firstName = location.first_name || location['First Name'] || ''; + const lastName = location.last_name || location['Last Name'] || ''; + const name = [firstName, lastName].filter(Boolean).join(' ') || 'Unknown'; + + const address = location.address || location.Address || 'No address'; + const supportLevel = location.support_level || location['Support Level'] || 'Unknown'; + const hasSign = location.sign || location.Sign ? 'Yes' : 'No'; + const email = location.email || location.Email || ''; + const phone = location.phone || location.Phone || ''; + const notes = location.notes || location.Notes || ''; + + return ` +
+

${name}

+

Address: ${address}

+

Support Level: ${supportLevel}

+

Lawn Sign: ${hasSign}

+ ${email ? `

Email: ${email}

` : ''} + ${phone ? `

Phone: ${phone}

` : ''} + ${notes ? `

Notes: ${notes}

` : ''} +
+ `; + } + + getCurrentFilters() { + return { + support_level: document.getElementById('support-level-filter')?.value || '', + has_sign: document.getElementById('sign-status-filter')?.value || '', + sign_size: document.getElementById('sign-size-filter')?.value || '', + contact_filter: document.getElementById('contact-filter')?.value || '' + }; + } + + async applyLocationFilters() { + if (!this.currentCutId) { + this.showStatus('No cut selected', 'warning'); + return; + } + + await this.loadCutLocations(); + } + + clearLocationFilters() { + document.getElementById('support-level-filter').value = ''; + document.getElementById('sign-status-filter').value = ''; + document.getElementById('sign-size-filter').value = ''; + document.getElementById('contact-filter').value = ''; + + if (this.currentCutId && this.showingLocations) { + this.loadCutLocations(); + } + } + + updateStatistics(statistics) { + if (!statistics) return; + + document.getElementById('total-locations').textContent = statistics.total_locations || 0; + document.getElementById('support-1').textContent = statistics.support_levels?.['1'] || 0; + document.getElementById('support-2').textContent = statistics.support_levels?.['2'] || 0; + document.getElementById('has-signs').textContent = statistics.lawn_signs?.has_sign || 0; + document.getElementById('has-email').textContent = statistics.contact_info?.has_email || 0; + document.getElementById('has-phone').textContent = statistics.contact_info?.has_phone || 0; + + // Show statistics panel + document.getElementById('cut-statistics').style.display = 'block'; + } + + async exportCutLocations() { + if (!this.currentCutId) { + this.showStatus('No cut selected', 'warning'); + return; + } + + try { + const filters = this.getCurrentFilters(); + const queryParams = new URLSearchParams(filters); + + const response = await fetch(`/api/cuts/${this.currentCutId}/locations/export?${queryParams}`, { + credentials: 'include' + }); + + if (!response.ok) { + throw new Error(`Export failed: ${response.statusText}`); + } + + // Download the CSV file + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.style.display = 'none'; + a.href = url; + + // Get filename from response header or use default + const contentDisposition = response.headers.get('content-disposition'); + const filename = contentDisposition + ? contentDisposition.split('filename=')[1]?.replace(/"/g, '') + : `cut_locations_${new Date().toISOString().split('T')[0]}.csv`; + + a.download = filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + + this.showStatus('Locations exported successfully', 'success'); + + } catch (error) { + console.error('Error exporting locations:', error); + this.showStatus('Failed to export locations', 'error'); + } + } + + async printCutView() { + if (!this.currentCutId) { + this.showStatus('No cut selected', 'warning'); + return; + } + + try { + // Get cut information + const cut = this.allCuts.find(c => (c.id || c.Id || c.ID || c._id) == this.currentCutId); + const cutName = cut?.name || cut?.Name || 'Unknown Cut'; + + // Ensure locations are loaded before printing + if (!this.currentCutLocations || this.currentCutLocations.length === 0) { + this.showStatus('Loading locations for print...', 'info'); + try { + await this.loadCutLocations(); + } catch (loadError) { + console.log('Failed to load locations for print:', loadError); + this.currentCutLocations = []; + } + } + + console.log('Print: Using', this.currentCutLocations.length, 'locations'); + + // Try multiple methods to capture the map + let mapImageDataUrl = null; + let mapInfo = ''; + + try { + // Method 1: Try Leaflet's built-in screenshot capability using canvas + mapImageDataUrl = await this.captureMapWithLeaflet(); + + if (!mapImageDataUrl) { + // Method 2: Try using DOM-to-image if available + mapImageDataUrl = await this.captureMapWithDomToImage(); + } + + if (!mapImageDataUrl) { + // Method 3: Try browser's built-in canvas capture + mapImageDataUrl = await this.captureMapWithCanvas(); + } + + if (mapImageDataUrl) { + // We successfully captured the map + mapInfo = ` +
+

Cut Map View

+ Cut Map +

+ Map showing cut boundaries and ${this.currentCutLocations.length} location markers +

+
+ `; + } else { + // Method 4: Generate a static map visualization + const staticMap = await this.generateStaticMapImage(); + if (staticMap) { + mapInfo = staticMap; + } else { + // Fallback to map information + throw new Error('All map capture methods failed'); + } + } + + } catch (mapError) { + console.log('Map capture failed, using fallback info:', mapError); + // Get map bounds and center for basic map info + const mapBounds = this.cutsMap.getBounds(); + const center = this.cutsMap.getCenter(); + const zoom = this.cutsMap.getZoom(); + + mapInfo = ` +
+

Cut Boundaries & Location Summary

+
+
+

Map Center:
${center.lat.toFixed(6)}, ${center.lng.toFixed(6)}

+

Zoom Level: ${zoom}

+
+
+

Geographic Bounds:
+ North: ${mapBounds.getNorth().toFixed(6)}
+ South: ${mapBounds.getSouth().toFixed(6)}
+ East: ${mapBounds.getEast().toFixed(6)}
+ West: ${mapBounds.getWest().toFixed(6)} +

+
+
+

Cut Statistics:
+ Total Locations: ${this.currentCutLocations.length}
+ Active Filters: ${this.getActiveFiltersCount()}
+ Showing: ${this.currentCutLocations.length} locations +

+
+
+

+ Individual location coordinates and details are listed in the table below. +

+
+ `; + } + + // Create print content with map info and location data + this.generatePrintContent(cutName, cut, mapInfo); + + } catch (error) { + console.error('Error creating print view:', error); + this.showStatus('Failed to create print view', 'error'); + + // Fallback to simple print without map + this.printCutViewSimple(); + } + } + + async captureMapWithLeaflet() { + try { + // Simple approach: try to get the map's SVG or canvas content + const mapContainer = this.cutsMap.getContainer(); + + // Look for the leaflet pane that contains the actual map content + const mapPane = mapContainer.querySelector('.leaflet-map-pane'); + if (!mapPane) { + console.log('Leaflet map pane not found'); + return null; + } + + // Try to find any canvas elements in the map + const canvases = mapPane.querySelectorAll('canvas'); + if (canvases.length > 0) { + console.log('Found canvas elements, attempting to capture...'); + const canvas = canvases[0]; + try { + const dataUrl = canvas.toDataURL('image/png'); + console.log('Successfully captured canvas'); + return dataUrl; + } catch (canvasError) { + console.log('Canvas capture failed:', canvasError); + } + } + + // Try SVG capture + const svgs = mapPane.querySelectorAll('svg'); + if (svgs.length > 0) { + console.log('Found SVG elements, attempting to capture...'); + try { + const svg = svgs[0]; + const svgData = new XMLSerializer().serializeToString(svg); + const svgBlob = new Blob([svgData], {type: 'image/svg+xml;charset=utf-8'}); + const svgUrl = URL.createObjectURL(svgBlob); + + return new Promise((resolve) => { + const img = new Image(); + img.onload = () => { + const canvas = document.createElement('canvas'); + canvas.width = 800; + canvas.height = 600; + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#f8f9fa'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + URL.revokeObjectURL(svgUrl); + console.log('Successfully captured SVG'); + resolve(canvas.toDataURL('image/png')); + }; + img.onerror = () => { + URL.revokeObjectURL(svgUrl); + console.log('SVG image load failed'); + resolve(null); + }; + img.src = svgUrl; + }); + } catch (svgError) { + console.log('SVG capture failed:', svgError); + } + } + + console.log('No capturable elements found in Leaflet map'); + return null; + + } catch (error) { + console.log('Leaflet capture failed:', error); + return null; + } + } + + async captureMapWithDomToImage() { + try { + // Check if dom-to-image is available + if (typeof domtoimage === 'undefined') { + console.log('dom-to-image library not available'); + return null; + } + + const mapElement = this.cutsMap.getContainer(); + + // Add some options to improve capture quality + const options = { + width: 800, + height: 600, + quality: 0.95, + bgcolor: '#f8f9fa', + style: { + transform: 'scale(1)', + transformOrigin: 'top left', + width: '800px', + height: '600px' + }, + filter: (node) => { + // Skip some problematic elements + if (node.classList && node.classList.contains('leaflet-control-container')) { + return false; + } + return true; + } + }; + + console.log('Attempting to capture map with dom-to-image...'); + const dataUrl = await domtoimage.toPng(mapElement, options); + console.log('Map capture successful with dom-to-image'); + return dataUrl; + + } catch (error) { + console.log('Dom-to-image capture failed:', error); + return null; + } + } + + async captureMapWithCanvas() { + try { + // Try the simple canvas approach for SVG/Canvas elements + const mapElement = this.cutsMap.getContainer(); + + // Check if the map container has a canvas child + const canvasElements = mapElement.querySelectorAll('canvas'); + if (canvasElements.length > 0) { + // Use the first canvas + const sourceCanvas = canvasElements[0]; + return sourceCanvas.toDataURL('image/png'); + } + + // Try to capture SVG elements + const svgElements = mapElement.querySelectorAll('svg'); + if (svgElements.length > 0) { + const svg = svgElements[0]; + const serializer = new XMLSerializer(); + const svgData = serializer.serializeToString(svg); + const svgBlob = new Blob([svgData], {type: 'image/svg+xml;charset=utf-8'}); + const url = URL.createObjectURL(svgBlob); + + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + const canvas = document.createElement('canvas'); + canvas.width = svg.clientWidth || 800; + canvas.height = svg.clientHeight || 600; + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0); + URL.revokeObjectURL(url); + resolve(canvas.toDataURL('image/png')); + }; + img.onerror = () => { + URL.revokeObjectURL(url); + resolve(null); + }; + img.src = url; + }); + } + + return null; + + } catch (error) { + console.log('Canvas capture failed:', error); + return null; + } + } + + async generateStaticMapImage() { + try { + // Create a static map representation using CSS and HTML + const mapBounds = this.cutsMap.getBounds(); + const center = this.cutsMap.getCenter(); + const zoom = this.cutsMap.getZoom(); + + // Get current locations for this cut + const cutLocations = this.currentCutLocations || []; + console.log('Static map: Using locations:', cutLocations.length); + console.log('Static map: Sample location:', cutLocations[0]); + + // Calculate location positions within map bounds + const locationMarkers = cutLocations.map(location => { + const lat = parseFloat(location.latitude || location.Latitude || 0); + const lng = parseFloat(location.longitude || location.Longitude || 0); + + console.log('Processing location:', location.first_name || location['First Name'], 'Coords:', lat, lng); + + if (!lat || !lng) { + console.log('Invalid coordinates for location:', location); + return null; + } + + // Convert to percentage positions within the map bounds + const latPercent = ((lat - mapBounds.getSouth()) / (mapBounds.getNorth() - mapBounds.getSouth())) * 100; + const lngPercent = ((lng - mapBounds.getWest()) / (mapBounds.getEast() - mapBounds.getWest())) * 100; + + return { + ...location, + latPercent: Math.max(2, Math.min(98, 100 - latPercent)), // Invert Y for screen coordinates + lngPercent: Math.max(2, Math.min(98, lngPercent)) + }; + }).filter(Boolean); + + console.log('Static map: Calculated positions for', locationMarkers.length, 'locations'); + + // Get the cut geometry for visualization + let cutPath = ''; + let cutPolygonPoints = ''; + + if (this.currentCutLayer && this.currentCutLayer.getLatLngs) { + try { + const latLngs = this.currentCutLayer.getLatLngs(); + const coordinates = Array.isArray(latLngs[0]) ? latLngs[0] : latLngs; + + // Convert coordinates to percentages for the map visualization + const pathPoints = coordinates.map(coord => { + const lat = coord.lat; + const lng = coord.lng; + const latPercent = ((lat - mapBounds.getSouth()) / (mapBounds.getNorth() - mapBounds.getSouth())) * 100; + const lngPercent = ((lng - mapBounds.getWest()) / (mapBounds.getEast() - mapBounds.getWest())) * 100; + return `${Math.max(0, Math.min(100, lngPercent))}%,${Math.max(0, Math.min(100, 100 - latPercent))}%`; + }); + + cutPolygonPoints = pathPoints.join(' '); + + if (pathPoints.length > 0) { + cutPath = ` + + + + `; + } + } catch (cutError) { + console.log('Could not extract cut geometry:', cutError); + } + } + + // Create a realistic map background pattern + const mapBackground = ` + background-image: + /* Street grid pattern */ + linear-gradient(0deg, rgba(200,200,200,0.3) 1px, transparent 1px), + linear-gradient(90deg, rgba(200,200,200,0.3) 1px, transparent 1px), + /* Neighborhood blocks */ + linear-gradient(45deg, rgba(220,220,220,0.2) 25%, transparent 25%), + linear-gradient(-45deg, rgba(220,220,220,0.2) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, rgba(220,220,220,0.2) 75%), + linear-gradient(-45deg, transparent 75%, rgba(220,220,220,0.2) 75%); + background-size: + 40px 40px, + 40px 40px, + 80px 80px, + 80px 80px, + 80px 80px, + 80px 80px; + background-position: + 0 0, + 0 0, + 0 0, + 0 20px, + 20px -20px, + -20px 0px; + background-color: #f0f8ff; + `; + + // Create a simple visual map representation + const mapVisualization = ` +
+ + + ${cutPath} + + + ${!cutPath ? '
' : ''} + + + ${locationMarkers.map((location, index) => ` +
+
+
+ `).join('')} + + +
+
+
+ + +
+
N
+
+ + +
+ Zoom: ${zoom}
+ Center: ${center.lat.toFixed(4)}, ${center.lng.toFixed(4)} +
+ + +
+
Support Levels
+
Strong Support
+
Lean Support
+
Oppose
+
Strong Oppose
+
+ + +
+ Cut Boundary +
+
+ + + `; + + return ` +
+

Cut Map View (${locationMarkers.length} locations)

+ ${mapVisualization} +

+ Visual representation of cut boundaries and location positions with realistic map styling +

+
+ `; + + } catch (error) { + console.log('Static map generation failed:', error); + return null; + } + } + + getSupportColor(supportLevel) { + switch(supportLevel) { + case '1': return '#28a745'; // Strong Support - Green + case '2': return '#ffc107'; // Lean Support - Yellow + case '3': return '#fd7e14'; // Oppose - Orange + case '4': return '#dc3545'; // Strong Oppose - Red + default: return '#6c757d'; // Unknown - Gray + } + } + + generatePrintContent(cutName, cut, mapContent) { + const printWindow = window.open('', '_blank'); + + if (!printWindow) { + this.showStatus('Popup blocked - please allow popups for print view', 'warning'); + return; + } + + const printContent = ` + + + + Cut: ${cutName} + + + +

Cut: ${cutName}

+ +
+ Generated: ${new Date().toLocaleString()}
+ Cut Category: ${cut?.category || cut?.Category || 'Unknown'}
+ Assigned To: ${cut?.assigned_to || cut?.Assigned_to || cut['Assigned To'] || 'Unassigned'} +
+ + ${this.getCurrentFiltersDisplay()} + +

Cut Statistics

+
+
+
${document.getElementById('total-locations')?.textContent || '0'}
+
Total Locations
+
+
+
${document.getElementById('support-1')?.textContent || '0'}
+
Strong Support
+
+
+
${document.getElementById('support-2')?.textContent || '0'}
+
Lean Support
+
+
+
${document.getElementById('has-signs')?.textContent || '0'}
+
Lawn Signs
+
+
+
${document.getElementById('has-email')?.textContent || '0'}
+
Email Contacts
+
+
+
${document.getElementById('has-phone')?.textContent || '0'}
+
Phone Contacts
+
+
+ +

Map Information

+ ${mapContent} + +

Location Details

+ + + + + + + + + + + + + + ${this.currentCutLocations.map(location => { + const firstName = location.first_name || location['First Name'] || ''; + const lastName = location.last_name || location['Last Name'] || ''; + const name = [firstName, lastName].filter(Boolean).join(' ') || 'Unknown'; + const address = location.address || location.Address || ''; + const lat = location.latitude || location.Latitude || location.lat; + const lng = location.longitude || location.Longitude || location.lng; + const coordinates = (lat && lng) ? `${parseFloat(lat).toFixed(6)}, ${parseFloat(lng).toFixed(6)}` : 'N/A'; + const supportLevel = location.support_level || location['Support Level'] || ''; + const email = location.email || location.Email || ''; + const phone = location.phone || location.Phone || ''; + const contact = [email, phone].filter(Boolean).join(', '); + const hasSign = location.sign || location.Sign ? 'Yes' : 'No'; + const notes = location.notes || location.Notes || ''; + + return ` + + + + + + + + + + `; + }).join('')} + +
NameAddressCoordinatesSupport LevelContact InfoLawn SignNotes
${name}${address}${coordinates}${supportLevel}${contact}${hasSign}${notes.substring(0, 100)}${notes.length > 100 ? '...' : ''}
+ +
+

Generated by Campaign Map System - ${new Date().toLocaleDateString()}

+
+ + + `; + + try { + printWindow.document.write(printContent); + printWindow.document.close(); + + // Give the content time to render, then print + setTimeout(() => { + printWindow.print(); + }, 500); + + this.showStatus('Print view generated successfully', 'success'); + + } catch (printError) { + console.error('Error writing to print window:', printError); + printWindow.close(); + this.showStatus('Print window error - using fallback', 'warning'); + this.printCutViewSimple(); + } + } + + printCutViewSimple() { + // Simple print view without map image + if (!this.currentCutId) { + this.showStatus('No cut selected', 'warning'); + return; + } + + const cut = this.allCuts.find(c => (c.id || c.Id || c.ID || c._id) == this.currentCutId); + const cutName = cut?.name || cut?.Name || 'Unknown Cut'; + + const printWindow = window.open('', '_blank'); + + if (!printWindow) { + this.showStatus('Popup blocked - please allow popups for print view', 'warning'); + return; + } + + const printContent = ` + + + + Cut: ${cutName} + + + +

Cut: ${cutName}

+ +
+ Generated: ${new Date().toLocaleString()}
+ Cut Category: ${cut?.category || cut?.Category || 'Unknown'}
+ Assigned To: ${cut?.assigned_to || cut?.Assigned_to || cut['Assigned To'] || 'Unassigned'} +
+ + ${this.getCurrentFiltersDisplay()} + +

Cut Statistics

+
+
+
${document.getElementById('total-locations')?.textContent || '0'}
+
Total Locations
+
+
+
${document.getElementById('support-1')?.textContent || '0'}
+
Strong Support
+
+
+
${document.getElementById('support-2')?.textContent || '0'}
+
Lean Support
+
+
+
${document.getElementById('has-signs')?.textContent || '0'}
+
Lawn Signs
+
+
+
${document.getElementById('has-email')?.textContent || '0'}
+
Email Contacts
+
+
+
${document.getElementById('has-phone')?.textContent || '0'}
+
Phone Contacts
+
+
+ +

Location Details

+ + + + + + + + + + + + + + ${this.currentCutLocations.map(location => { + const firstName = location.first_name || location['First Name'] || ''; + const lastName = location.last_name || location['Last Name'] || ''; + const name = [firstName, lastName].filter(Boolean).join(' ') || 'Unknown'; + const address = location.address || location.Address || ''; + const lat = location.latitude || location.Latitude || location.lat; + const lng = location.longitude || location.Longitude || location.lng; + const coordinates = (lat && lng) ? `${parseFloat(lat).toFixed(6)}, ${parseFloat(lng).toFixed(6)}` : 'N/A'; + const supportLevel = location.support_level || location['Support Level'] || ''; + const email = location.email || location.Email || ''; + const phone = location.phone || location.Phone || ''; + const contact = [email, phone].filter(Boolean).join(', '); + const hasSign = location.sign || location.Sign ? 'Yes' : 'No'; + const notes = location.notes || location.Notes || ''; + + return ` + + + + + + + + + + `; + }).join('')} + +
NameAddressCoordinatesSupport LevelContact InfoLawn SignNotes
${name}${address}${coordinates}${supportLevel}${contact}${hasSign}${notes.substring(0, 100)}${notes.length > 100 ? '...' : ''}
+ +
+

Generated by Campaign Map System - ${new Date().toLocaleDateString()}

+
+ + + `; + + try { + if (!printWindow || printWindow.closed) { + this.showStatus('Print window unavailable', 'error'); + return; + } + + printWindow.document.write(printContent); + printWindow.document.close(); + + // Give content time to load then print + setTimeout(() => { + if (!printWindow.closed) { + printWindow.print(); + } + }, 300); + + this.showStatus('Simple print view generated', 'success'); + + } catch (printError) { + console.error('Error in simple print:', printError); + if (printWindow && !printWindow.closed) { + printWindow.close(); + } + this.showStatus('Print failed - please try again', 'error'); + } + } + + getCurrentFiltersDisplay() { + const activeFilters = []; + + // Check location filters + const supportFilter = document.getElementById('support-filter')?.value; + if (supportFilter && supportFilter !== 'all') { + activeFilters.push(`Support Level: ${supportFilter}`); + } + + const signFilter = document.getElementById('sign-filter')?.value; + if (signFilter && signFilter !== 'all') { + activeFilters.push(`Lawn Signs: ${signFilter === 'true' ? 'Yes' : 'No'}`); + } + + const contactFilter = document.getElementById('contact-filter')?.value; + if (contactFilter && contactFilter !== 'all') { + const contactLabels = { + 'email': 'Has Email', + 'phone': 'Has Phone', + 'both': 'Has Both Email & Phone', + 'none': 'No Contact Info' + }; + activeFilters.push(`Contact: ${contactLabels[contactFilter] || contactFilter}`); + } + + if (activeFilters.length === 0) { + return '
Filters: None (showing all locations)
'; + } + + return `
Active Filters: ${activeFilters.join(', ')}
`; + } + + getActiveFiltersCount() { + let count = 0; + + const supportFilter = document.getElementById('support-filter')?.value; + if (supportFilter && supportFilter !== 'all') count++; + + const signFilter = document.getElementById('sign-filter')?.value; + if (signFilter && signFilter !== 'all') count++; + + const contactFilter = document.getElementById('contact-filter')?.value; + if (contactFilter && contactFilter !== 'all') count++; + + return count; + } + + async saveCutSettings() { + if (!this.currentCutId) { + this.showStatus('No cut selected', 'warning'); + return; + } + + try { + const settings = { + show_locations: document.getElementById('show-locations-toggle').checked, + export_enabled: document.getElementById('export-enabled-toggle').checked, + assigned_to: document.getElementById('assigned-to').value, + completion_percentage: parseInt(document.getElementById('completion-percentage').value) || 0 + }; + + const response = await fetch(`/api/cuts/${this.currentCutId}/settings`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'include', + body: JSON.stringify(settings) + }); + + if (!response.ok) { + throw new Error(`Failed to save settings: ${response.statusText}`); + } + + this.showStatus('Cut settings saved successfully', 'success'); + + } catch (error) { + console.error('Error saving cut settings:', error); + this.showStatus('Failed to save cut settings', 'error'); + } + } + + showLocationManagement(cutId) { + console.log('showLocationManagement called with cutId:', cutId); + this.currentCutId = cutId; + const locationSection = document.getElementById('cut-location-management'); + console.log('Location section element:', locationSection); + + if (locationSection) { + console.log('Setting location section display to block'); + locationSection.style.display = 'block'; + + // Load cut data to populate settings - use multiple possible ID field names for NocoDB + const cut = this.allCuts.find(c => + (c.id || c.Id || c.ID || c._id) == cutId + ); + console.log('Found cut for location management:', cut); + console.log('Available cuts:', this.allCuts.map(c => ({ + id: c.id || c.Id || c.ID || c._id, + name: c.name || c.Name + }))); + + if (cut) { + const toggleElement = document.getElementById('show-locations-toggle'); + const exportElement = document.getElementById('export-enabled-toggle'); + const assignedElement = document.getElementById('assigned-to'); + const completionElement = document.getElementById('completion-percentage'); + + console.log('Setting up toggles:', { + toggleElement: !!toggleElement, + exportElement: !!exportElement, + assignedElement: !!assignedElement, + completionElement: !!completionElement + }); + + if (toggleElement) toggleElement.checked = (cut.show_locations || cut.Show_locations || cut['Show Locations']) !== false; + if (exportElement) exportElement.checked = (cut.export_enabled || cut.Export_enabled || cut['Export Enabled']) !== false; + if (assignedElement) assignedElement.value = cut.assigned_to || cut.Assigned_to || cut['Assigned To'] || ''; + if (completionElement) completionElement.value = cut.completion_percentage || cut.Completion_percentage || cut['Completion Percentage'] || ''; + } + } else { + console.error('Location management section not found!'); + } + } + + hideLocationManagement() { + const locationSection = document.getElementById('cut-location-management'); + if (locationSection) { + locationSection.style.display = 'none'; + } + + // Clear current data + this.currentCutId = null; + this.currentCutLocations = []; + + // Hide locations on map + if (this.locationMarkersLayer) { + this.cutsMap.removeLayer(this.locationMarkersLayer); + } + this.showingLocations = false; + + // Reset button state + const toggleBtn = document.getElementById('toggle-location-visibility'); + if (toggleBtn) { + toggleBtn.textContent = 'Show Locations'; + toggleBtn.classList.remove('active'); + toggleBtn.classList.remove('inactive'); + } + } } // Global instance diff --git a/map/app/routes/cuts.js b/map/app/routes/cuts.js index 325a5ec..6962ce1 100644 --- a/map/app/routes/cuts.js +++ b/map/app/routes/cuts.js @@ -27,4 +27,10 @@ router.post('/', requireAdmin, cutsController.create); router.put('/:id', requireAdmin, cutsController.update); router.delete('/:id', requireAdmin, cutsController.delete); +// New cut enhancement routes - admin only +router.get('/:id/locations', requireAdmin, cutsController.getLocationsInCut); +router.get('/:id/locations/export', requireAdmin, cutsController.exportCutLocations); +router.get('/:id/statistics', requireAdmin, cutsController.getCutStatistics); +router.put('/:id/settings', requireAdmin, cutsController.updateCutSettings); + module.exports = router; diff --git a/map/app/server.js b/map/app/server.js index 6962cdf..e83be1b 100644 --- a/map/app/server.js +++ b/map/app/server.js @@ -84,6 +84,9 @@ const buildConnectSrc = () => { // Add Nominatim for geocoding sources.push('https://nominatim.openstreetmap.org'); + // Add OpenStreetMap tile servers for dom-to-image map capture + sources.push('https://*.tile.openstreetmap.org'); + // Add localhost for development if (!config.isProduction) { sources.push('http://localhost:*'); @@ -100,7 +103,7 @@ app.use(helmet({ defaultSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com"], scriptSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com", "https://cdn.jsdelivr.net"], - imgSrc: ["'self'", "data:", "https://*.tile.openstreetmap.org", "https://tiles.stadiamaps.com", "https://unpkg.com"], + imgSrc: ["'self'", "data:", "blob:", "https://*.tile.openstreetmap.org", "https://tiles.stadiamaps.com", "https://unpkg.com"], connectSrc: buildConnectSrc() } } diff --git a/map/app/utils/spatial.js b/map/app/utils/spatial.js new file mode 100644 index 0000000..92372c4 --- /dev/null +++ b/map/app/utils/spatial.js @@ -0,0 +1,188 @@ + +/** + * Spatial Operations Utility + * Provides point-in-polygon calculations and location filtering within cut boundaries + */ + +/** + * Check if a point is inside a polygon using ray casting algorithm + * @param {number} lat - Point latitude + * @param {number} lng - Point longitude + * @param {Array} polygon - Array of [lng, lat] coordinates + * @returns {boolean} True if point is inside polygon + */ +function isPointInPolygon(lat, lng, polygon) { + let inside = false; + + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const xi = polygon[i][0], yi = polygon[i][1]; + const xj = polygon[j][0], yj = polygon[j][1]; + + if (((yi > lat) !== (yj > lat)) && + (lng < (xj - xi) * (lat - yi) / (yj - yi) + xi)) { + inside = !inside; + } + } + + return inside; +} + +/** + * Check if a location is within a cut's GeoJSON polygon + * @param {Object} location - Location object with latitude/longitude + * @param {Object} cutGeoJson - GeoJSON polygon object + * @returns {boolean} True if location is within cut + */ +function isLocationInCut(location, cutGeoJson) { + // Handle different possible field names for coordinates + const lat = location.latitude || location.Latitude || location.lat; + const lng = location.longitude || location.Longitude || location.lng || location.lon; + + if (!lat || !lng || !cutGeoJson) { + return false; + } + + try { + const geojson = typeof cutGeoJson === 'string' ? JSON.parse(cutGeoJson) : cutGeoJson; + + if (geojson.type === 'Polygon') { + return isPointInPolygon(lat, lng, geojson.coordinates[0]); + } else if (geojson.type === 'MultiPolygon') { + return geojson.coordinates.some(polygon => + isPointInPolygon(lat, lng, polygon[0]) + ); + } + + return false; + } catch (error) { + console.error('Error checking point in polygon:', error); + return false; + } +} + +/** + * Filter locations based on cut boundaries and additional criteria + * @param {Array} locations - Array of location objects + * @param {Object} cut - Cut object with geojson + * @param {Object} filters - Additional filter criteria + * @returns {Array} Filtered locations within cut + */ +function filterLocationsInCut(locations, cut, filters = {}) { + // Try multiple possible field names for the geojson data + const geojsonData = cut.geojson || cut.Geojson || cut.GeoJSON || cut['GeoJSON Data'] || cut.geojson_data; + + if (!geojsonData) { + return []; + } + + // First filter by geographic boundaries + let filteredLocations = locations.filter(location => + isLocationInCut(location, geojsonData) + ); + + // Apply additional filters + if (filters.support_level) { + filteredLocations = filteredLocations.filter(location => { + const supportLevel = location.support_level || location['Support Level']; + return supportLevel === filters.support_level; + }); + } + + if (filters.has_sign !== undefined) { + filteredLocations = filteredLocations.filter(location => { + const hasSign = location.sign || location.Sign; + return Boolean(hasSign) === filters.has_sign; + }); + } + + if (filters.sign_size) { + filteredLocations = filteredLocations.filter(location => { + const signSize = location.sign_size || location['Sign Size']; + return signSize === filters.sign_size; + }); + } + + if (filters.has_email !== undefined) { + filteredLocations = filteredLocations.filter(location => { + const email = location.email || location.Email; + return Boolean(email) === filters.has_email; + }); + } + + if (filters.has_phone !== undefined) { + filteredLocations = filteredLocations.filter(location => { + const phone = location.phone || location.Phone; + return Boolean(phone) === filters.has_phone; + }); + } + + return filteredLocations; +} + +/** + * Calculate statistics for locations within a cut + * @param {Array} locations - Array of location objects within cut + * @returns {Object} Statistics object + */ +function calculateCutStatistics(locations) { + const stats = { + total_locations: locations.length, + support_levels: { '1': 0, '2': 0, '3': 0, '4': 0, 'unknown': 0 }, + lawn_signs: { has_sign: 0, no_sign: 0, unknown: 0 }, + sign_sizes: { Regular: 0, Large: 0, Unsure: 0, unknown: 0 }, + contact_info: { has_email: 0, has_phone: 0, has_both: 0, has_neither: 0 } + }; + + locations.forEach(location => { + // Support level stats - handle different field names + const supportLevel = location.support_level || location['Support Level'] || 'unknown'; + if (stats.support_levels.hasOwnProperty(supportLevel)) { + stats.support_levels[supportLevel]++; + } else { + stats.support_levels.unknown++; + } + + // Lawn sign stats - handle different field names + const sign = location.sign || location.Sign; + if (sign === true || sign === 1 || sign === 'true') { + stats.lawn_signs.has_sign++; + } else if (sign === false || sign === 0 || sign === 'false') { + stats.lawn_signs.no_sign++; + } else { + stats.lawn_signs.unknown++; + } + + // Sign size stats - handle different field names + const signSize = location.sign_size || location['Sign Size'] || 'unknown'; + if (stats.sign_sizes.hasOwnProperty(signSize)) { + stats.sign_sizes[signSize]++; + } else { + stats.sign_sizes.unknown++; + } + + // Contact info stats - handle different field names + const email = location.email || location.Email; + const phone = location.phone || location.Phone; + const hasEmail = Boolean(email); + const hasPhone = Boolean(phone); + + if (hasEmail && hasPhone) { + stats.contact_info.has_both++; + } else if (hasEmail) { + stats.contact_info.has_email++; + } else if (hasPhone) { + stats.contact_info.has_phone++; + } else { + stats.contact_info.has_neither++; + } + }); + + return stats; +} + +module.exports = { + isPointInPolygon, + isLocationInCut, + filterLocationsInCut, + calculateCutStatistics +}; diff --git a/map/build-nocodb.sh b/map/build-nocodb.sh index 85440eb..4d9873c 100755 --- a/map/build-nocodb.sh +++ b/map/build-nocodb.sh @@ -883,6 +883,45 @@ create_cuts_table() { "title": "Updated At", "uidt": "DateTime", "rqd": false + }, + { + "column_name": "show_locations", + "title": "Show Locations", + "uidt": "Checkbox", + "rqd": false, + "cdf": true + }, + { + "column_name": "export_enabled", + "title": "Export Enabled", + "uidt": "Checkbox", + "rqd": false, + "cdf": true + }, + { + "column_name": "assigned_to", + "title": "Assigned To", + "uidt": "SingleLineText", + "rqd": false + }, + { + "column_name": "filter_settings", + "title": "Filter Settings", + "uidt": "LongText", + "rqd": false + }, + { + "column_name": "last_canvassed", + "title": "Last Canvassed", + "uidt": "DateTime", + "rqd": false + }, + { + "column_name": "completion_percentage", + "title": "Completion Percentage", + "uidt": "Number", + "rqd": false, + "cdf": "0" } ] }' diff --git a/mkdocs/docs/overrides/lander.html b/mkdocs/docs/overrides/lander.html index 14d2bd2..c0cd06f 100644 --- a/mkdocs/docs/overrides/lander.html +++ b/mkdocs/docs/overrides/lander.html @@ -1341,8 +1341,8 @@
-

Your Canvassers Are Struggling

-

Traditional campaign tools weren't built for the reality of door-to-door work

+

Your Supporters Are Struggling

+

Traditional campaign tools weren't built for the reality of political action

@@ -1383,7 +1383,7 @@
-

Documentation That Works

+

Political Documentation That Works

Everything your team needs, instantly searchable, always accessible, and easy to communicate