// State let rawGeoJSON = null; let cleanedGeoJSON = null; let analysisLog = []; let overlapCsvData = []; let originalFileName = "polygons"; // DOM Elements const dropZone = document.getElementById('dropZone'); const fileInput = document.getElementById('geojsonFile'); const fileLabelText = document.getElementById('fileLabelText'); const analyzeBtn = document.getElementById('analyzeBtn'); const resultsCard = document.getElementById('resultsCard'); const logOutput = document.getElementById('logOutput'); const downloadLogBtn = document.getElementById('downloadLogBtn'); const downloadCsvBtn = document.getElementById('downloadCsvBtn'); const editCard = document.getElementById('editCard'); const applyEditsBtn = document.getElementById('applyEditsBtn'); const editResults = document.getElementById('editResults'); const editLogOutput = document.getElementById('editLogOutput'); const downloadCleanedBtn = document.getElementById('downloadCleanedBtn'); const downloadEditLogBtn = document.getElementById('downloadEditLogBtn'); // Event Listeners dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.style.backgroundColor = '#e0f2f1'; }); dropZone.addEventListener('dragleave', (e) => { e.preventDefault(); dropZone.style.backgroundColor = 'rgba(42, 171, 149, 0.05)'; }); dropZone.addEventListener('drop', (e) => { e.preventDefault(); dropZone.style.backgroundColor = 'rgba(42, 171, 149, 0.05)'; if (e.dataTransfer.files.length) { fileInput.files = e.dataTransfer.files; handleFile(e.dataTransfer.files[0]); } }); fileInput.addEventListener('change', (e) => { if (fileInput.files.length) { handleFile(fileInput.files[0]); } }); analyzeBtn.addEventListener('click', runAnalysis); downloadLogBtn.addEventListener('click', () => downloadText(analysisLog.join('\n'), `${originalFileName}_log.txt`)); downloadCsvBtn.addEventListener('click', downloadCsv); applyEditsBtn.addEventListener('click', runEdits); downloadCleanedBtn.addEventListener('click', () => downloadJson(cleanedGeoJSON, `${originalFileName}_SmartCane.geojson`)); // Logic function handleFile(file) { originalFileName = file.name.replace(/\.geojson$/i, '').replace(/\.json$/i, ''); fileLabelText.innerHTML = `${file.name}
Ready to analyze`; const reader = new FileReader(); reader.onload = (e) => { try { rawGeoJSON = JSON.parse(e.target.result); analyzeBtn.disabled = false; // Reset UI resultsCard.classList.add('hidden'); editCard.classList.add('hidden'); editResults.classList.add('hidden'); } catch (err) { alert("Invalid GeoJSON file"); console.error(err); } }; reader.readAsText(file); } function log(msg, targetLog = analysisLog, targetEl = logOutput) { // Add timestamp or just raw msg? R script uses simple text. targetLog.push(msg); targetEl.innerText = targetLog.join('\n'); // Auto scroll targetEl.scrollTop = targetEl.scrollHeight; } async function runAnalysis() { // Reset analysisLog = []; overlapCsvData = []; resultsCard.classList.remove('hidden'); editCard.classList.add('hidden'); analyzeBtn.disabled = true; analyzeBtn.innerText = "Analyzing..."; // Use setTimeout to allow UI to update setTimeout(async () => { try { await analyze(); analyzeBtn.disabled = false; analyzeBtn.innerText = "Analyze Polygons"; editCard.classList.remove('hidden'); } catch (err) { console.error(err); log(`ERROR: ${err.message}`); analyzeBtn.disabled = false; analyzeBtn.innerText = "Analyze Polygons"; } }, 100); } function cleanDuplicateVertices(feature) { if (feature.geometry.type === 'Polygon') { feature.geometry.coordinates = feature.geometry.coordinates.map(ring => ring.filter((coord, i, arr) => i === 0 || JSON.stringify(coord) !== JSON.stringify(arr[i - 1]) ) ); } else if (feature.geometry.type === 'MultiPolygon') { feature.geometry.coordinates = feature.geometry.coordinates.map(polygon => polygon.map(ring => ring.filter((coord, i, arr) => i === 0 || JSON.stringify(coord) !== JSON.stringify(arr[i - 1]) ) ) ); } return feature; } async function analyze() { log(`Processing log for: ${originalFileName}`); log("=================================================="); let features = rawGeoJSON.features; if (!features) { log("Error: No features found in GeoJSON."); return; } log(`${features.length} geometries found.`); // 1. Check duplicates FIRST log("\n=== Checking for duplicates... ==="); const fieldCounts = {}; features.forEach(f => { const id = getFieldNo(f); if (id) { fieldCounts[id] = (fieldCounts[id] || 0) + 1; } }); const duplicates = Object.entries(fieldCounts).filter(([id, count]) => count > 1); if (duplicates.length > 0) { log(`✕ ${duplicates.length} duplicate field codes detected.`); log("Removing duplicate fields..."); const duplicateIds = new Set(duplicates.map(d => d[0])); const beforeDedup = features.length; features = features.filter(f => !duplicateIds.has(getFieldNo(f))); duplicates.forEach(d => { log(`• Field ${d[0]}: ${d[1]} occurrences (Removed)`); }); log(`✓ Removed ${beforeDedup - features.length} duplicate entries!`); } else { log("✓ No duplicates found!"); } // 2. Filter empty log("\n=== Checking empty geometries... ==="); const initialCount = features.length; features = features.filter(f => f.geometry && f.geometry.coordinates && f.geometry.coordinates.length > 0); const emptyCount = initialCount - features.length; if (emptyCount > 0) { log(`${emptyCount} empty geometries detected! Deleting...`); } else { log(`✓ No empty geometries found.`); } // 3. Clean duplicate vertices & validity check log("\n=== Cleaning duplicate vertices & checking geometry validity... ==="); let invalidCount = 0; let duplicateVertexCount = 0; let fixedFeatures = []; for (let f of features) { // Clean duplicate vertices first const beforeClean = JSON.stringify(f.geometry); f = cleanDuplicateVertices(f); const afterClean = JSON.stringify(f.geometry); if (beforeClean !== afterClean) { duplicateVertexCount++; } // Drop Z if present (Turf truncate) f = turf.truncate(f, {precision: 6, coordinates: 2}); try { const kinks = turf.kinks(f); if (kinks.features.length > 0) { invalidCount++; // Try to fix by unkinking const unkinked = turf.unkinkPolygon(f); unkinked.features.forEach(uf => { uf.properties = {...f.properties}; fixedFeatures.push(uf); }); } else { fixedFeatures.push(f); } } catch (e) { log(`⚠ Warning: Could not process geometry for ${getFieldNo(f)}: ${e.message}`); // Still add it, but log the issue fixedFeatures.push(f); } } if (duplicateVertexCount > 0) { log(`✓ Cleaned duplicate vertices in ${duplicateVertexCount} geometries.`); } if (invalidCount > 0) { log(`✕ ${invalidCount} invalid geometries detected (self-intersections). Attempting fix...`); log(`✓ Fixed by splitting self-intersections.`); } else { log(`✓ All geometries valid (no self-intersections).`); } features = fixedFeatures; log(`Current geometry count: ${features.length}`); // 4. Check Overlaps log("\n=== Checking for plot overlap... ==="); // Build RBush Index const tree = new RBush(); const items = features.map((f, index) => { const bbox = turf.bbox(f); return { minX: bbox[0], minY: bbox[1], maxX: bbox[2], maxY: bbox[3], feature: f, id: getFieldNo(f) || `Unknown-${index}`, area: turf.area(f), index: index }; }); tree.load(items); const overlaps = []; const checkedPairs = new Set(); let fieldsWithOverlap = new Set(); for (let i = 0; i < items.length; i++) { const itemA = items[i]; const candidates = tree.search(itemA); for (const itemB of candidates) { if (itemA.index === itemB.index) continue; // Sort IDs to ensure uniqueness of pair const pairId = [itemA.index, itemB.index].sort().join('_'); if (checkedPairs.has(pairId)) continue; checkedPairs.add(pairId); // Check intersection let intersection = null; try { intersection = turf.intersect(itemA.feature, itemB.feature); } catch (e) { console.warn("Intersection error", e); } if (intersection) { const intersectArea = turf.area(intersection); if (intersectArea > 1) { overlaps.push({ a: itemA, b: itemB, area: intersectArea }); fieldsWithOverlap.add(itemA.id); fieldsWithOverlap.add(itemB.id); } } } } if (overlaps.length > 0) { log(`✕ ${fieldsWithOverlap.size} fields with overlap found.`); // We need to calculate total overlap percentage for each field to decide if it's > 30% // Map: FieldID -> TotalOverlapArea const fieldOverlapMap = {}; overlaps.forEach(o => { // Add to A if (!fieldOverlapMap[o.a.id]) fieldOverlapMap[o.a.id] = 0; fieldOverlapMap[o.a.id] += o.area; // Add to B if (!fieldOverlapMap[o.b.id]) fieldOverlapMap[o.b.id] = 0; fieldOverlapMap[o.b.id] += o.area; }); // Report significant overlaps let severeCount = 0; Object.keys(fieldOverlapMap).forEach(fid => { const item = items.find(it => it.id === fid); // This lookup might be slow if many items, but okay for now if (!item) return; const totalOverlap = fieldOverlapMap[fid]; const originalArea = item.area; const pct = (totalOverlap / originalArea) * 100; let category = "<30%"; if (pct >= 30) category = ">30%"; // Add to CSV Data overlapCsvData.push({ Field_No: fid, Original_Area_ha: (originalArea / 10000).toFixed(4), Overlap_Area_ha: (totalOverlap / 10000).toFixed(4), Overlap_Percentage: pct.toFixed(1), Overlap_Category: category }); if (pct > 30) { log(`Field number ${fid} overlap >30% (${pct.toFixed(1)}%) -- Will be deleted`); severeCount++; } }); if (severeCount === 0) { log("✓ No significant overlap (>30%) detected."); } else { log(`✕ ${severeCount} fields have significant overlap (>30%).`); } } else { log("✓ No overlap found!"); } // Save state for editing // We keep 'features' (which are fixed/deduplicated) rawGeoJSON.features = features; } function getFieldNo(feature) { return feature.properties['Field No'] || feature.properties['field'] || feature.properties['Field_No']; } async function runEdits() { const editLog = []; editResults.classList.remove('hidden'); editLogOutput.innerText = "Processing edits..."; function logEdit(msg) { editLog.push(msg); editLogOutput.innerText = editLog.join('\n'); editLogOutput.scrollTop = editLogOutput.scrollHeight; } setTimeout(async () => { let features = rawGeoJSON.features; // Clean all features before processing features = features.map(f => cleanDuplicateVertices(f)); let delCount = 0; // 1. Remove > 30% overlaps // We need to re-calculate overlaps because we want to be precise // Build Index again let tree = new RBush(); let items = features.map((f, index) => ({ minX: turf.bbox(f)[0], minY: turf.bbox(f)[1], maxX: turf.bbox(f)[2], maxY: turf.bbox(f)[3], feature: f, id: getFieldNo(f), area: turf.area(f), index: index })); tree.load(items); // Calculate overlap per field let fieldOverlapMap = {}; // Find all overlaps // Note: This is slightly inefficient to re-do, but safer. for (let i = 0; i < items.length; i++) { const itemA = items[i]; const candidates = tree.search(itemA); for (const itemB of candidates) { if (itemA.index < itemB.index) { // Only check pair once const intersection = turf.intersect(itemA.feature, itemB.feature); if (intersection) { const area = turf.area(intersection); if (area > 1) { if (!fieldOverlapMap[itemA.id]) fieldOverlapMap[itemA.id] = 0; fieldOverlapMap[itemA.id] += area; if (!fieldOverlapMap[itemB.id]) fieldOverlapMap[itemB.id] = 0; fieldOverlapMap[itemB.id] += area; } } } } } // Identify fields to delete const toDelete = new Set(); Object.keys(fieldOverlapMap).forEach(fid => { const item = items.find(it => it.id == fid); if (item) { const pct = (fieldOverlapMap[fid] / item.area) * 100; if (pct > 30) { toDelete.add(fid); logEdit(`Field ${fid} overlap ${pct.toFixed(1)}% > 30% -- ✕ Deleted!`); delCount++; } } }); // Apply Deletion features = features.filter(f => !toDelete.has(getFieldNo(f))); if (delCount > 0) { logEdit(`✓ Significant overlap fixed - ${delCount} geometries deleted.`); } else { logEdit(`✓ No significant overlap detected.`); } // 2. Fix remaining overlaps (< 30%) // Loop through intersections and split up difference logEdit("\nChecking for remaining overlaps (<30%) to fix..."); // Re-index with remaining features items = features.map((f, index) => ({ minX: turf.bbox(f)[0], minY: turf.bbox(f)[1], maxX: turf.bbox(f)[2], maxY: turf.bbox(f)[3], feature: f, id: getFieldNo(f), area: turf.area(f), index: index })); tree = new RBush(); tree.load(items); let fixCount = 0; let pairs = []; for (let i = 0; i < items.length; i++) { const itemA = items[i]; const candidates = tree.search(itemA); for (const itemB of candidates) { if (itemA.index < itemB.index) { const intersection = turf.intersect(itemA.feature, itemB.feature); if (intersection && turf.area(intersection) > 1) { pairs.push({a: itemA, b: itemB}); } } } } for (const pair of pairs) { const f1 = pair.a.feature; // Reference to object in 'features' array const f2 = pair.b.feature; // Check if they are still valid/exist (in case we deleted one? No we filtered already) // Re-check intersection because one might have been modified by a previous iteration const intersection = turf.intersect(f1, f2); if (!intersection || turf.area(intersection) <= 1) continue; const area1 = turf.area(f1); const area2 = turf.area(f2); // "Add the overlapping area to the biggest plot" // Means subtract overlap from the smallest plot. let updated = false; if (area1 > area2) { // f1 is bigger. Subtract f1 from f2. // poly2 <- st_difference(poly2, st_union(poly1)) // effectively poly2 = poly2 - poly1 try { const diff = turf.difference(f2, f1); if (diff) { f2.geometry = diff.geometry; updated = true; logEdit(`✓ Fixed conflict between ${getFieldNo(f1)} and ${getFieldNo(f2)} (Trimmed ${getFieldNo(f2)})`); } else { // f2 completely inside f1? logEdit(`! ${getFieldNo(f2)} is completely inside ${getFieldNo(f1)} - Removed ${getFieldNo(f2)}`); features = features.filter(f => f !== f2); } } catch(e) { console.warn("Difference failed", e); } } else { // f2 is bigger or equal. Subtract f2 from f1. try { const diff = turf.difference(f1, f2); if (diff) { f1.geometry = diff.geometry; updated = true; logEdit(`✓ Fixed conflict between ${getFieldNo(f1)} and ${getFieldNo(f2)} (Trimmed ${getFieldNo(f1)})`); } else { logEdit(`! ${getFieldNo(f1)} is completely inside ${getFieldNo(f2)} - Removed ${getFieldNo(f1)}`); features = features.filter(f => f !== f1); } } catch(e) { console.warn("Difference failed", e); } } if (updated) fixCount++; } if (fixCount === 0) { logEdit("✓ No <30% overlap detected."); } // 3. Dissolve/Merge polygons by field ID logEdit("\n=== Dissolving polygons by field ID... ==="); // Group features by field ID const groupedByField = {}; features.forEach(f => { const fieldId = getFieldNo(f); if (!groupedByField[fieldId]) { groupedByField[fieldId] = []; } groupedByField[fieldId].push(f); }); let dissolveCount = 0; const dissolvedFeatures = []; Object.keys(groupedByField).forEach(fieldId => { const group = groupedByField[fieldId]; if (group.length === 1) { // No dissolve needed dissolvedFeatures.push(group[0]); } else { // Multiple polygons with same field ID - need to union them logEdit(` Dissolving ${group.length} polygons for field ${fieldId}...`); try { // Union all geometries for this field let unionGeom = group[0]; for (let i = 1; i < group.length; i++) { unionGeom = turf.union(unionGeom, group[i]); if (!unionGeom) { logEdit(`⚠ Warning: Could not union all parts of field ${fieldId}`); // Fall back to keeping individual parts dissolvedFeatures.push(...group); return; } } // Preserve original properties from first feature unionGeom.properties = {...group[0].properties}; dissolvedFeatures.push(unionGeom); dissolveCount++; logEdit(`✓ Field ${fieldId}: Merged ${group.length} parts into 1`); } catch (e) { logEdit(`⚠ Error dissolving field ${fieldId}: ${e.message}`); // Fall back to keeping individual parts dissolvedFeatures.push(...group); } } }); if (dissolveCount > 0) { logEdit(`✓ Dissolved ${dissolveCount} fields with multiple parts.`); } else { logEdit(`✓ No multi-part fields detected.`); } features = dissolvedFeatures; // Prepare download cleanedGeoJSON = { type: "FeatureCollection", features: features, crs: rawGeoJSON.crs }; logEdit("\n=== Saving edited shapefiles... ==="); logEdit(`✓ Ready to download ${features.length} geometries.`); // Add event listener for download edit log downloadEditLogBtn.onclick = () => downloadText(editLog.join('\n'), `${originalFileName}_edit_log.txt`); }, 100); } function downloadText(content, filename) { const blob = new Blob([content], {type: "text/plain;charset=utf-8"}); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } function downloadJson(content, filename) { const blob = new Blob([JSON.stringify(content, null, 2)], {type: "application/json"}); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } function downloadCsv() { if (overlapCsvData.length === 0) { alert("No overlap data to download."); return; } const headers = Object.keys(overlapCsvData[0]); const rows = overlapCsvData.map(row => headers.map(h => row[h]).join(',')); const csvContent = [headers.join(',')].concat(rows).join('\n'); downloadText(csvContent, `${originalFileName}_overlap_details.csv`); }