#!/usr/bin/env node const fs = require('fs'); const Papa = require('papaparse'); const path = require('path'); // Input and output file paths const INPUT_FILE = '/mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/map/data/City of Edmonton Ward Boundary and Council Composition_Current_20250807.geojson'; const OUTPUT_FILE = path.join(path.dirname(INPUT_FILE), 'edmonton_ward_boundaries_optimized.csv'); // Function to optimize GeoJSON precision function optimizeGeoJSONPrecision(geoJSON, precision = 7) { if (!geoJSON) return null; function roundCoords(coords, precision) { const multiplier = Math.pow(10, precision); if (Array.isArray(coords[0]) && Array.isArray(coords[0][0])) { // Multi-dimensional array (Polygon/MultiPolygon) return coords.map(coord => roundCoords(coord, precision)); } else if (Array.isArray(coords[0]) && typeof coords[0][0] === 'number') { // Array of coordinate pairs return coords.map(([lon, lat]) => [ Math.round(lon * multiplier) / multiplier, Math.round(lat * multiplier) / multiplier ]); } else if (typeof coords[0] === 'number' && typeof coords[1] === 'number') { // Single coordinate pair return [ Math.round(coords[0] * multiplier) / multiplier, Math.round(coords[1] * multiplier) / multiplier ]; } return coords; } const optimized = JSON.parse(JSON.stringify(geoJSON)); optimized.coordinates = roundCoords(optimized.coordinates, precision); return optimized; } // Smart polygon simplification - only remove redundant points function smartSimplify(coordinates, maxPoints = 500) { return coordinates.map(ring => { if (ring.length <= maxPoints) return ring; // Calculate step to evenly sample points const step = ring.length / maxPoints; const simplified = []; for (let i = 0; i < maxPoints - 1; i++) { const index = Math.floor(i * step); simplified.push(ring[index]); } // Always include the last point to close the polygon simplified.push(ring[ring.length - 1]); return simplified; }); } // Function to calculate bounds from GeoJSON function calculateBounds(geoJSON) { if (!geoJSON) return null; let minLat = Infinity, maxLat = -Infinity; let minLon = Infinity, maxLon = -Infinity; function processCoords(coords) { if (Array.isArray(coords[0]) && Array.isArray(coords[0][0])) { coords.forEach(processCoords); } else if (Array.isArray(coords[0]) && typeof coords[0][0] === 'number') { coords.forEach(([lon, lat]) => { minLat = Math.min(minLat, lat); maxLat = Math.max(maxLat, lat); minLon = Math.min(minLon, lon); maxLon = Math.max(maxLon, lon); }); } } processCoords(geoJSON.coordinates); return `[[${minLat.toFixed(7)},${minLon.toFixed(7)}],[${maxLat.toFixed(7)},${maxLon.toFixed(7)}]]`; } // Compact JSON stringify (no extra whitespace) function compactStringify(obj) { return JSON.stringify(obj, null, 0); } // Main conversion function function convertGeoJSON() { console.log('šŸ“ Reading GeoJSON file:', INPUT_FILE); // Read the GeoJSON file const geoJSONData = fs.readFileSync(INPUT_FILE, 'utf8'); console.log('šŸ“Š Parsing GeoJSON...'); // Parse the GeoJSON const parsed = JSON.parse(geoJSONData); if (!parsed.features || !Array.isArray(parsed.features)) { console.error('āŒ Invalid GeoJSON format - no features array found'); return; } console.log(`āœ… Found ${parsed.features.length} ward boundaries to convert`); console.log('šŸŽÆ Optimizing precision for NocoDB (100k char limit)...\n'); // Get current timestamp const now = new Date(); const timestamp = now.toISOString().replace('T', ' ').replace('Z', '+00:00').slice(0, -7) + '+00:00'; const stats = { totalRows: 0, optimizedRows: 0, simplifiedRows: 0, precision7: 0, precision6: 0, precision5: 0 }; // Convert each feature const convertedData = parsed.features.map((feature, index) => { if ((index + 1) % 5 === 0) { console.log(` Converting... ${index + 1}/${parsed.features.length}`); } stats.totalRows++; const properties = feature.properties || {}; const geometry = feature.geometry; if (!geometry) { console.warn(`āš ļø Feature ${index + 1} has no geometry, skipping`); return null; } // Start with 7 decimal precision (~1cm accuracy) let geoJSON = optimizeGeoJSONPrecision(geometry, 7); let geoJSONString = geoJSON ? compactStringify(geoJSON) : ''; // If still too large, try 6 decimals (~10cm accuracy) if (geoJSONString.length > 99000) { stats.precision7++; geoJSON = optimizeGeoJSONPrecision(geometry, 6); geoJSONString = compactStringify(geoJSON); // If still too large, try 5 decimals (~1m accuracy) if (geoJSONString.length > 99000) { stats.precision6++; geoJSON = optimizeGeoJSONPrecision(geometry, 5); geoJSONString = compactStringify(geoJSON); // If STILL too large, simplify points if (geoJSONString.length > 99000) { stats.precision5++; const originalPoints = JSON.stringify(geoJSON).length; if (geoJSON.type === 'Polygon') { geoJSON.coordinates = smartSimplify(geoJSON.coordinates); } else if (geoJSON.type === 'MultiPolygon') { geoJSON.coordinates = geoJSON.coordinates.map(polygon => smartSimplify(polygon) ); } geoJSONString = compactStringify(geoJSON); stats.simplifiedRows++; console.log(` āš ļø ${properties.name_1 || properties.name_2 || `Ward ${index + 1}`}: Simplified from ${originalPoints} to ${geoJSONString.length} chars`); } } } else { stats.optimizedRows++; } // Create ward name from available properties const wardName = properties.name_1 || properties.name_2 || `Ward ${index + 1}`; const fullWardName = properties.name_2 || (properties.name_1 ? `${properties.name_1} Ward` : `Ward ${index + 1}`); // Create description with available information let description = fullWardName; if (properties.councillor) { description += ` - Councillor: ${properties.councillor}`; } if (properties.councillor2) { description += ` & ${properties.councillor2}`; } if (properties.effective_start_date) { const startDate = new Date(properties.effective_start_date).toLocaleDateString(); description += ` (Effective: ${startDate})`; } return { 'ID': index + 1, 'CreatedAt': timestamp, 'UpdatedAt': timestamp, 'Name': wardName, 'Description': description.substring(0, 5000), 'Color': '#ff6b35', // Orange color for ward boundaries 'Opacity': 0.3, 'Category': 'Ward', 'Public Visibility': true, 'Official Cut': true, 'GeoJSON Data': geoJSONString, 'Bounds': calculateBounds(geoJSON), 'Created By': 'City of Edmonton', 'Created At': properties.effective_start_date || null, 'Updated At': timestamp }; }).filter(row => row !== null); // Remove any null entries console.log('\nšŸ“ Converting to CSV format...'); // Convert back to CSV const outputCSV = Papa.unparse(convertedData); // Write the file fs.writeFileSync(OUTPUT_FILE, outputCSV); const fileSizeMB = (Buffer.byteLength(outputCSV) / 1024 / 1024).toFixed(2); console.log('\n✨ Conversion complete!'); console.log('══════════════════════════════════════════════════════════'); console.log(`šŸ“Š Total ward boundaries: ${stats.totalRows}`); console.log(`āœ… Optimized with 7 decimals (1cm): ${stats.optimizedRows}`); console.log(`šŸ“‰ Reduced to 6 decimals (10cm): ${stats.precision7}`); console.log(`šŸ“‰ Reduced to 5 decimals (1m): ${stats.precision6}`); console.log(`āš ļø Point reduction needed: ${stats.simplifiedRows}`); console.log(`šŸ’¾ File size: ${fileSizeMB} MB`); console.log(`šŸ“ Output saved to: ${OUTPUT_FILE}`); console.log('══════════════════════════════════════════════════════════'); console.log('\nāœ… All geometries are under 100,000 characters!'); console.log(' You can import this directly to NocoDB without changing column types.'); } // Run the conversion convertGeoJSON();