// Initialize map layers const osmLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors', maxNativeZoom: 19, maxZoom: 23 }); const satelliteLayer = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { attribution: '© Esri, DigitalGlobe, Earthstar Geographics, CNES/Airbus DS, USDA, USGS, AeroGRID, IGN, and the GIS User Community', maxNativeZoom: 18, maxZoom: 23 }); // Initialize map const map = L.map('map', { maxZoom: 23 }).setView([0, 0], 2); osmLayer.addTo(map); let currentLayer = 'osm'; let geojsonLayer = null; let labelsLayer = null; let millsLayer = null; let millMarkers = {}; let mills = []; let currentGeojsonData = null; let featuresList = []; let showLabels = false; let showMills = false; // Color scheme for countries (for mills) const countryColors = { 'South Africa': '#FF6B6B', 'Zimbabwe': '#4ECDC4', 'eSwatini': '#45B7D1', 'Mozambique': '#96CEB4', 'Malawi': '#FFEAA7', 'Zambia': '#DDA15E', 'Uganda': '#EE6C4D', 'Kenya': '#F1A208', 'Tanzania': '#A23B72', 'Rwanda': '#8E7DBE', 'Burundi': '#C9ADA7', 'Mexico': '#006846', }; // Toggle map layer const mapToggle = document.getElementById('mapToggle'); const mapTypeLabel = document.getElementById('mapTypeLabel'); mapToggle.addEventListener('change', () => { if (mapToggle.checked) { // Switch to satellite map.removeLayer(osmLayer); satelliteLayer.addTo(map); mapTypeLabel.textContent = 'SAT'; currentLayer = 'satellite'; } else { // Switch to OSM map.removeLayer(satelliteLayer); osmLayer.addTo(map); mapTypeLabel.textContent = 'OSM'; currentLayer = 'osm'; } }); // Toggle labels visibility const labelsToggle = document.getElementById('labelsToggle'); labelsToggle.addEventListener('change', () => { showLabels = labelsToggle.checked; if (!labelsLayer) { console.warn('Labels layer not initialized'); return; } if (showLabels) { labelsLayer.addTo(map); updateLabelsVisibility(); } else { map.removeLayer(labelsLayer); } }); // Toggle mills visibility const millsToggle = document.getElementById('millsToggle'); millsToggle.addEventListener('change', () => { showMills = millsToggle.checked; if (showMills) { if (mills.length === 0) { loadAndRenderMills(); } else if (!map.hasLayer(millsLayer)) { millsLayer.addTo(map); } } else { if (millsLayer && map.hasLayer(millsLayer)) { map.removeLayer(millsLayer); } } }); // Elements const geojsonInput = document.getElementById('geojsonFile'); const fileNameDisplay = document.getElementById('fileName'); const errorMessage = document.getElementById('errorMessage'); const successMessage = document.getElementById('successMessage'); const statsSection = document.getElementById('statsSection'); const featuresSection = document.getElementById('featuresSection'); const propertiesSection = document.getElementById('propertiesSection'); const propertiesContent = document.getElementById('propertiesContent'); const featureCountEl = document.getElementById('featureCount'); const geomTypeEl = document.getElementById('geomType'); const boundsEl = document.getElementById('bounds'); const featureLst = document.getElementById('featuresList'); const clearBtn = document.getElementById('clearBtn'); const downloadBtn = document.getElementById('downloadBtn'); const featureModal = document.getElementById('featureModal'); const modalTitle = document.getElementById('modalTitle'); const modalBody = document.getElementById('modalBody'); const summarySection = document.getElementById('summarySection'); const tableSection = document.getElementById('tableSection'); const clientNameEl = document.getElementById('clientName'); const totalFieldsEl = document.getElementById('totalFields'); const totalHectaresEl = document.getElementById('totalHectares'); const totalAcresEl = document.getElementById('totalAcres'); const featureSearch = document.getElementById('featureSearch'); // Show message function showMessage(message, type = 'error') { const el = type === 'error' ? errorMessage : successMessage; el.textContent = message; el.classList.add('active'); setTimeout(() => el.classList.remove('active'), 5000); } // Calculate area in square meters using Turf.js function getFeatureArea(feature) { try { if (!feature.geometry) return 0; const geomType = feature.geometry.type; if (geomType === 'Point') return 0; if (geomType === 'LineString' || geomType === 'MultiLineString') return 0; // Use Haversine formula for rough area calculation const coords = feature.geometry.coordinates; if (geomType === 'Polygon') { return calculatePolygonArea(coords[0]); } else if (geomType === 'MultiPolygon') { return coords.reduce((total, polygon) => total + calculatePolygonArea(polygon[0]), 0); } } catch (e) { console.warn('Error calculating area:', e); return 0; } return 0; } // Calculate polygon area using Shoelace formula function calculatePolygonArea(coords) { if (!coords || coords.length < 3) return 0; let area = 0; const R = 6371000; // Earth radius in meters for (let i = 0; i < coords.length - 1; i++) { const p1 = coords[i]; const p2 = coords[i + 1]; const lat1 = (p1[1] * Math.PI) / 180; const lat2 = (p2[1] * Math.PI) / 180; const dLng = ((p2[0] - p1[0]) * Math.PI) / 180; area += Math.sin(lat1) * Math.cos(lat1) * dLng; area += Math.sin(lat1) * Math.cos(lat2) * Math.sin(dLng); } area = Math.abs(area * R * R / 2); return area; } // Convert square meters to hectares and acres function convertArea(squareMeters) { const hectares = squareMeters / 10000; const acres = squareMeters / 4046.856; return { hectares, acres }; } // Get first property key (field code) function getFirstPropertyValue(properties) { if (!properties) return ''; const keys = Object.keys(properties); if (keys.length === 0) return ''; return properties[keys[0]]; } // Create labels layer from features function createLabelsLayer(features) { labelsLayer = L.featureGroup([]); // First pass: collect all label positions const labelPositions = []; features.forEach((feature, index) => { const props = feature.properties || {}; const fieldCode = getFirstPropertyValue(props); const displayLabel = fieldCode || `Feature ${index + 1}`; if (feature.geometry && feature.geometry.type !== 'Point') { // Get centroid for polygon features const bounds = L.geoJSON(feature).getBounds(); const center = bounds.getCenter(); labelPositions.push({ fieldName: displayLabel, latlng: center, originalLatlng: center, iconAnchor: [0, 0], isPoint: false }); } else if (feature.geometry && feature.geometry.type === 'Point') { // For points, add label above the marker const latlng = L.GeoJSON.coordsToLatLng(feature.geometry.coordinates); labelPositions.push({ fieldName: displayLabel, latlng: latlng, originalLatlng: latlng, iconAnchor: [0, 30], isPoint: true }); } }); // Create markers labelPositions.forEach((pos) => { const label = L.marker(pos.latlng, { icon: L.divIcon({ className: 'field-label', html: `
${pos.fieldName}
`, iconSize: [null, null], iconAnchor: pos.iconAnchor }), interactive: false }); labelsLayer.addLayer(label); }); } // Update labels visibility based on zoom level function updateLabelsVisibility() { const zoomLevel = map.getZoom(); const minZoomForLabels = 16; // Only show labels at zoom level 13 and above if (labelsLayer && showLabels) { if (zoomLevel >= minZoomForLabels) { labelsLayer.eachLayer(layer => { layer.setOpacity(1); }); } else { labelsLayer.eachLayer(layer => { layer.setOpacity(0); }); } } } // Load mills data and render on map async function loadAndRenderMills() { try { // Try to load from Google Sheets first let csvText = await fetchGoogleSheetData(); // Fallback to local CSV if Google Sheets fails if (!csvText) { console.log('Falling back to local CSV file...'); const response = await fetch('../sugar_mill_locator/sugar_cane_factories_africa.csv'); if (response.ok) { csvText = await response.text(); showMessage('Using local data (Google Sheet unavailable)', 'error'); } } else { showMessage('Sugar mills loaded from Google Sheets', 'success'); } if (csvText) { mills = parseMillsCSV(csvText); renderMills(); } } catch (error) { console.warn('Could not load mills data:', error); showMessage('Sugar mills data not available', 'error'); } } // Parse CSV for mills data function parseMillsCSV(csvText) { const lines = csvText.trim().split('\n'); const headers = lines[0].split(',').map(h => h.trim()); return lines.slice(1).map((line, index) => { const values = parseMillsCSVLine(line); const row = {}; headers.forEach((header, idx) => { row[header] = values[idx] ? values[idx].trim() : ''; }); row._id = index; return row; }).filter(row => row.Latitude && row.Longitude); } // Parse CSV line handling quoted values function parseMillsCSVLine(line) { const result = []; let current = ''; let inQuotes = false; for (let i = 0; i < line.length; i++) { const char = line[i]; const nextChar = line[i + 1]; if (char === '"') { if (inQuotes && nextChar === '"') { current += '"'; i++; } else { inQuotes = !inQuotes; } } else if (char === ',' && !inQuotes) { result.push(current); current = ''; } else { current += char; } } result.push(current); return result; } // Render mills on map function renderMills() { // Create or clear mills layer if (millsLayer) { map.removeLayer(millsLayer); } millsLayer = L.featureGroup(); millMarkers = {}; mills.forEach(mill => { const lat = parseFloat(mill.Latitude); const lng = parseFloat(mill.Longitude); if (!isNaN(lat) && !isNaN(lng)) { const production = parseFloat(String(mill['Annual Sugar Production (tons)'] || '0').replace(/[, ]/g, '')) || 0; const size = getMillCircleSize(production); const color = countryColors[mill.Country] || '#999'; const marker = L.circleMarker([lat, lng], { radius: size, fillColor: color, color: 'white', weight: 2, opacity: 0.8, fillOpacity: 0.7 }); const popup = createMillPopup(mill); marker.bindPopup(popup); millsLayer.addLayer(marker); millMarkers[mill._id] = marker; } }); millsLayer.addTo(map); } // Get circle size based on production function getMillCircleSize(production) { if (production > 150000) return 15; if (production > 50000) return 10; return 6; } // Create popup content for mills function createMillPopup(mill) { const production = mill['Annual Sugar Production (tons)'] || 'N/A'; const capacity = mill['Crushing Capacity (tons/year)'] || 'N/A'; const year = mill['Data Year'] || ''; return `
${mill['Mill/Factory']}
${mill['Country']} • ${mill['Province/Region']}
Company: ${mill.Company || 'N/A'}
Production: ${formatMillNumber(production)} tons/year
Capacity: ${formatMillNumber(capacity)} tons/year
Coordinates: ${parseFloat(mill.Latitude).toFixed(4)}, ${parseFloat(mill.Longitude).toFixed(4)}
Data Year: ${year}
`; } // Format numbers for display function formatMillNumber(num) { if (!num || num === 'N/A') return 'N/A'; const n = parseFloat(String(num).replace(/[, ]/g, '')); if (isNaN(n)) return 'N/A'; return n.toLocaleString('en-US', { maximumFractionDigits: 0 }); } // Handle file upload geojsonInput.addEventListener('change', (e) => { const file = e.target.files[0]; if (!file) return; fileNameDisplay.textContent = `📄 ${file.name}`; fileNameDisplay.classList.add('active'); const reader = new FileReader(); reader.onload = (event) => { try { const geojson = JSON.parse(event.target.result); const success = loadGeojson(geojson, file.name); if (success) { showMessage('GeoJSON loaded successfully!', 'success'); } } catch (error) { showMessage(`Error parsing file: ${error.message}`, 'error'); } }; reader.onerror = () => { showMessage('Error reading file', 'error'); }; reader.readAsText(file); }); // Validate CRS function validateCRS(geojson) { // If no CRS is specified, assume WGS84 (default for GeoJSON) if (!geojson.crs) { return { valid: true, message: 'No CRS specified (default WGS84)' }; } const crs = geojson.crs; let crsName = ''; // Extract CRS name from different possible formats if (crs.properties && crs.properties.name) { crsName = crs.properties.name; } else if (typeof crs === 'string') { crsName = crs; } // Check if it's WGS84 (various possible names) const wgs84Variations = [ 'EPSG:4326', 'epsg:4326', 'urn:ogc:def:crs:EPSG::4326', 'urn:ogc:def:crs:EPSG:4.3:4326', 'WGS84', 'wgs84', 'urn:ogc:def:crs:OGC:1.3:CRS84' ]; const isWGS84 = wgs84Variations.some(variant => crsName.toUpperCase().includes(variant.toUpperCase()) ); if (isWGS84) { return { valid: true, message: 'CRS: WGS84' }; } else { return { valid: false, message: `Non-supported CRS (${JSON.stringify(crs)})` }; } } // Load and display GeoJSON function loadGeojson(geojson, fileName) { // Validate CRS first const crsValidation = validateCRS(geojson); if (!crsValidation.valid) { showMessage(crsValidation.message, 'error'); return false; } // Clear previous layer if (geojsonLayer) { map.removeLayer(geojsonLayer); } // Clear previous labels if (labelsLayer) { map.removeLayer(labelsLayer); } currentGeojsonData = geojson; featuresList = []; // Handle both FeatureCollection and individual features const features = geojson.type === 'FeatureCollection' ? geojson.features : [geojson]; if (features.length === 0) { showMessage('GeoJSON contains no features', 'error'); return false; } // Create layer with popups geojsonLayer = L.geoJSON(geojson, { onEachFeature: (feature, layer) => { const index = featuresList.length; featuresList.push({ feature, layer, index }); // Add click handler to open modal layer.on('click', (e) => { L.DomEvent.stopPropagation(e); openFeatureModal(feature); selectFeature(index); }); // Hover effect layer.on('mouseover', () => { if (layer.setStyle) { layer.setStyle({ weight: 3, opacity: 1, dashArray: '' }); } }); layer.on('mouseout', () => { if (layer.setStyle) { geojsonLayer.resetStyle(layer); } }); }, style: { color: '#667eea', weight: 2, opacity: 0.7, fillOpacity: 0.5 }, pointToLayer: (feature, latlng) => { return L.circleMarker(latlng, { radius: 8, fillColor: '#667eea', color: '#fff', weight: 2, opacity: 1, fillOpacity: 0.8 }); } }).addTo(map); // Create labels layer createLabelsLayer(features); // Fit bounds const bounds = geojsonLayer.getBounds(); map.fitBounds(bounds, { padding: [50, 50] }); // Update labels visibility based on zoom level updateLabelsVisibility(); map.on('zoom', updateLabelsVisibility); // Update stats updateStats(geojson, features); // Display features list displayFeaturesList(); // Build attribute table buildAttributeTable(features); // Calculate and display summary updateSummary(geojson, features); // Show/hide elements summarySection.style.display = 'block'; statsSection.style.display = 'block'; featuresSection.style.display = 'block'; tableSection.style.display = 'block'; downloadBtn.style.display = 'block'; return true; } // Update statistics function updateStats(geojson, features) { featureCountEl.textContent = features.length; // Get geometry type const types = new Set(); features.forEach(f => { if (f.geometry) { types.add(f.geometry.type); } }); geomTypeEl.textContent = Array.from(types).join(', ') || '-'; // Get bounds const bounds = geojsonLayer.getBounds(); const sw = bounds.getSouthWest(); const ne = bounds.getNorthEast(); boundsEl.textContent = `[${sw.lat.toFixed(2)}, ${sw.lng.toFixed(2)}] - [${ne.lat.toFixed(2)}, ${ne.lng.toFixed(2)}]`; } // Display features list function displayFeaturesList() { featureLst.innerHTML = ''; featureSearch.value = ''; featuresList.forEach((item, index) => { const div = document.createElement('div'); div.className = 'feature-item'; // Get first property value (field code) const props = item.feature.properties || {}; const fieldCode = getFirstPropertyValue(props); const name = fieldCode || `Feature ${index + 1}`; div.textContent = name; div.dataset.index = index; div.addEventListener('click', () => selectFeature(index)); featureLst.appendChild(div); }); } // Select feature function selectFeature(index) { const item = featuresList[index]; if (!item) { console.error('Feature not found at index:', index); return; } // Update feature list UI document.querySelectorAll('.feature-item').forEach((el, i) => { el.classList.toggle('active', i === index); }); // Display properties in sidebar displayProperties(item.feature); // Open modal with properties openFeatureModal(item.feature); // Zoom to feature on map setTimeout(() => { try { if (item.feature.geometry.type === 'Point') { const latlng = item.layer.getLatLng(); map.setView(latlng, 14); } else { const bounds = L.geoJSON(item.feature).getBounds(); map.fitBounds(bounds, { padding: [100, 100] }); } } catch (e) { console.error('Error zooming to feature:', e); } }, 100); // Update map styling if (item.layer.setStyle) { item.layer.setStyle({ weight: 3, opacity: 1 }); } } // Display properties function displayProperties(feature) { propertiesSection.classList.add('active'); propertiesContent.innerHTML = ''; const props = feature.properties || {}; if (Object.keys(props).length === 0) { propertiesContent.innerHTML = '
No properties
'; return; } Object.entries(props).forEach(([key, value]) => { const row = document.createElement('div'); row.className = 'property-row'; const keyEl = document.createElement('div'); keyEl.className = 'property-key'; keyEl.textContent = key; const valueEl = document.createElement('div'); valueEl.className = 'property-value'; valueEl.textContent = typeof value === 'object' ? JSON.stringify(value, null, 2) : value; row.appendChild(keyEl); row.appendChild(valueEl); propertiesContent.appendChild(row); }); } // Build attribute table function buildAttributeTable(features) { const tableHeader = document.getElementById('tableHeader'); const tableBody = document.getElementById('tableBody'); tableHeader.innerHTML = ''; tableBody.innerHTML = ''; if (features.length === 0) return; // Collect all unique property keys const allKeys = new Set(); features.forEach(f => { if (f.properties) { Object.keys(f.properties).forEach(key => allKeys.add(key)); } }); // Add area columns allKeys.add('Area (ha)'); allKeys.add('Area (acres)'); // Create header const headerKeys = Array.from(allKeys); headerKeys.forEach(key => { const th = document.createElement('th'); th.textContent = key; tableHeader.appendChild(th); }); // Create rows features.forEach((feature, idx) => { const row = document.createElement('tr'); row.style.cursor = 'pointer'; row.addEventListener('click', () => selectFeature(idx)); row.addEventListener('mouseover', () => row.style.backgroundColor = '#f0f0f0'); row.addEventListener('mouseout', () => row.style.backgroundColor = ''); const props = feature.properties || {}; const areaM2 = getFeatureArea(feature); const { hectares, acres } = convertArea(areaM2); headerKeys.forEach(key => { const td = document.createElement('td'); if (key === 'Area (ha)') { td.textContent = hectares.toFixed(2); } else if (key === 'Area (acres)') { td.textContent = acres.toFixed(2); } else { td.textContent = props[key] !== undefined ? props[key] : '-'; } row.appendChild(td); }); tableBody.appendChild(row); }); } // Update summary section function updateSummary(geojson, features) { // Try to get client name from GeoJSON properties or filename let clientName = 'Unknown Project'; if (geojson.name) { clientName = geojson.name; } else if (geojson.properties && geojson.properties.name) { clientName = geojson.properties.name; } // Calculate totals let totalHa = 0; let totalAc = 0; features.forEach(feature => { const areaM2 = getFeatureArea(feature); const { hectares, acres } = convertArea(areaM2); totalHa += hectares; totalAc += acres; }); // Update UI clientNameEl.textContent = clientName; totalFieldsEl.textContent = features.length; totalHectaresEl.textContent = totalHa.toFixed(2); totalAcresEl.textContent = totalAc.toFixed(2); } // Open feature modal function openFeatureModal(feature) { const props = feature.properties || {}; const featureName = props.name || props.id || 'Feature Details'; modalTitle.textContent = featureName; modalBody.innerHTML = ''; if (Object.keys(props).length === 0) { modalBody.innerHTML = ''; featureModal.classList.add('active'); return; } Object.entries(props).forEach(([key, value]) => { const propDiv = document.createElement('div'); propDiv.className = 'modal-property'; const keyEl = document.createElement('div'); keyEl.className = 'modal-property-key'; keyEl.textContent = key; const valueEl = document.createElement('div'); valueEl.className = 'modal-property-value'; valueEl.textContent = typeof value === 'object' ? JSON.stringify(value, null, 2) : value; propDiv.appendChild(keyEl); propDiv.appendChild(valueEl); modalBody.appendChild(propDiv); }); featureModal.classList.add('active'); } // Close feature modal function closeFeatureModal() { featureModal.classList.remove('active'); } // Close modal when clicking outside featureModal.addEventListener('click', (e) => { if (e.target === featureModal) { closeFeatureModal(); } }); // Close modal with Escape key document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { closeFeatureModal(); } }); // Clear all clearBtn.addEventListener('click', () => { if (confirm('Are you sure you want to clear all data?')) { if (geojsonLayer) { map.removeLayer(geojsonLayer); geojsonLayer = null; } if (labelsLayer) { map.removeLayer(labelsLayer); labelsLayer = null; } currentGeojsonData = null; featuresList = []; geojsonInput.value = ''; fileNameDisplay.textContent = ''; fileNameDisplay.classList.remove('active'); summarySection.style.display = 'none'; statsSection.style.display = 'none'; tableSection.style.display = 'none'; featuresSection.style.display = 'none'; downloadBtn.style.display = 'none'; propertiesSection.classList.remove('active'); propertiesContent.innerHTML = '
Select a feature to view properties
'; map.setView([0, 0], 2); } }); // Download downloadBtn.addEventListener('click', () => { if (!currentGeojsonData) return; const dataStr = JSON.stringify(currentGeojsonData, null, 2); const dataBlob = new Blob([dataStr], { type: 'application/json' }); const url = URL.createObjectURL(dataBlob); const link = document.createElement('a'); link.href = url; link.download = 'geojson-export.geojson'; link.click(); URL.revokeObjectURL(url); showMessage('GeoJSON downloaded!', 'success'); }); // Search features featureSearch.addEventListener('input', (e) => { const searchTerm = e.target.value.toLowerCase(); const items = document.querySelectorAll('.feature-item'); items.forEach(item => { const text = item.textContent.toLowerCase(); if (text.includes(searchTerm)) { item.style.display = ''; } else { item.style.display = 'none'; } }); }); // Initialize Google Sheets integration initGoogleSheetsAutoRefresh(); showGoogleSheetsSetup();