Large update to geo-coding functions in order to support better matching of street addresses. Added premium mapbox option

This commit is contained in:
admin 2025-09-25 11:28:51 -06:00
parent d29ffa6300
commit 44298834ef
7 changed files with 1467 additions and 99 deletions

View File

@ -855,13 +855,35 @@ When connected to a MkDocs documentation site:
### Address Search
For geographic location search:
- **Geocoding Integration**: Powered by Nominatim/OpenStreetMap
- **Multi-Provider Geocoding**: Uses multiple geocoding providers for maximum accuracy
- **Mapbox Integration**: Premium provider with structured address input (when API key provided)
- **Fallback Providers**: Free providers (Nominatim, Photon, ArcGIS, LocationIQ) for comprehensive coverage
- **Multiple Results**: Returns up to 5 address matches
- **Map Integration**: Click results to view location on map
- **Temporary Markers**: Visual markers for search results
- **Quick Actions**: Add locations directly from search results
- **Coordinate Display**: Shows precise latitude/longitude coordinates
#### Geocoding Configuration
For improved geocoding accuracy, you can configure premium providers:
```env
# Mapbox API Key (Recommended for best accuracy)
MAPBOX_ACCESS_TOKEN=your_mapbox_access_token_here
# LocationIQ API Key (Alternative premium option)
LOCATIONIQ_API_KEY=your_locationiq_api_key_here
```
**Mapbox Setup:**
1. Sign up at [mapbox.com](https://www.mapbox.com/)
2. Get your access token from the dashboard
3. Add `MAPBOX_ACCESS_TOKEN=your_token_here` to your `.env` file
4. Restart the application
When Mapbox is configured, it will be used as the primary geocoding provider with automatic fallback to free providers if needed.
### Database Search
For searching through loaded location data:
- **Full-text Search**: Search through names, addresses, emails, phone numbers, and notes

View File

@ -75,14 +75,71 @@ class DataConvertController {
// Process each address with progress updates
for (let i = 0; i < results.length; i++) {
const row = results[i];
const address = row.address || row.Address || row.ADDRESS;
// Extract address - with better validation
const addressField = row.address || row.Address || row.ADDRESS ||
row.street_address || row['Street Address'] ||
row.full_address || row['Full Address'];
// Extract unit number if available
const unitField = row.unit || row.Unit || row.UNIT ||
row.unit_number || row['Unit Number'] || row.unit_no;
if (!addressField || addressField.trim() === '') {
logger.warn(`Row ${i + 1}: Empty or missing address field`);
const errorRow = {
...row,
latitude: '',
longitude: '',
'Geo-Location': '',
geocoded_address: '',
geocode_success: false,
geocode_status: 'FAILED',
geocode_error: 'Missing address field',
csv_filename: originalFilename,
row_number: i + 1
};
allResults.push(errorRow);
errors.push({
index: i,
address: 'No address provided',
error: 'Missing address field'
});
// Send progress update
res.write(`data: ${JSON.stringify({
type: 'progress',
current: i + 1,
total: total,
currentAddress: 'No address - skipping',
status: 'failed'
})}\n\n`);
res.flush && res.flush();
continue; // Skip to next row
}
// Send progress update
// Construct full address with unit if available
let address = addressField.trim();
if (unitField && unitField.toString().trim()) {
const unit = unitField.toString().trim();
// Add unit prefix if it doesn't already exist
if (!unit.toLowerCase().startsWith('unit') &&
!unit.toLowerCase().startsWith('apt') &&
!unit.toLowerCase().startsWith('#')) {
address = `Unit ${unit}, ${address}`;
} else {
address = `${unit}, ${address}`;
}
} // Send progress update
res.write(`data: ${JSON.stringify({
type: 'progress',
current: i + 1,
total: total,
address: address
currentAddress: address,
status: 'processing'
})}\n\n`);
res.flush && res.flush();
@ -93,6 +150,11 @@ class DataConvertController {
const geocodeResult = await forwardGeocode(address);
if (geocodeResult && geocodeResult.coordinates) {
// Check if result is malformed
const isMalformed = geocodeResult.validation && geocodeResult.validation.isMalformed;
const confidence = geocodeResult.validation ? geocodeResult.validation.confidence : 100;
const warnings = geocodeResult.validation ? geocodeResult.validation.warnings : [];
const processedRow = {
...row,
latitude: geocodeResult.coordinates.lat,
@ -100,30 +162,38 @@ class DataConvertController {
'Geo-Location': `${geocodeResult.coordinates.lat};${geocodeResult.coordinates.lng}`,
geocoded_address: geocodeResult.formattedAddress || address,
geocode_success: true,
geocode_status: 'SUCCESS',
geocode_status: isMalformed ? 'WARNING' : 'SUCCESS',
geocode_error: '',
csv_filename: originalFilename // Include filename for notes
confidence_score: confidence,
warnings: warnings.join('; '),
is_malformed: isMalformed,
provider: geocodeResult.provider || 'Unknown',
csv_filename: originalFilename,
row_number: i + 1
};
processedData.push(processedRow);
allResults.push(processedRow); // Add to full results for report
allResults.push(processedRow);
// Send success update
// Send success update with status
const successMessage = {
type: 'geocoded',
data: processedRow,
index: i
index: i,
status: isMalformed ? 'warning' : 'success',
confidence: confidence,
warnings: warnings
};
const successJson = JSON.stringify(successMessage);
logger.debug(`Sending geocoded update: ${successJson.length} chars`);
logger.info(`Successfully geocoded: ${address} (Confidence: ${confidence}%)`);
res.write(`data: ${successJson}\n\n`);
res.flush && res.flush(); // Ensure data is sent immediately
res.flush && res.flush();
} else {
throw new Error('Geocoding failed - no coordinates returned');
}
} catch (error) {
logger.error(`Failed to geocode address: ${address}`, error);
logger.error(`Failed to geocode address: ${address}`, error.message);
// Create error row with original data plus error info
const errorRow = {
@ -135,10 +205,14 @@ class DataConvertController {
geocode_success: false,
geocode_status: 'FAILED',
geocode_error: error.message,
csv_filename: originalFilename
confidence_score: 0,
warnings: '',
is_malformed: false,
csv_filename: originalFilename,
row_number: i + 1
};
allResults.push(errorRow); // Add to full results for report
allResults.push(errorRow);
const errorData = {
index: i,
@ -163,14 +237,21 @@ class DataConvertController {
}
// Store processing results for report generation
const successful = processedData.filter(r => r.geocode_status === 'SUCCESS').length;
const warnings = processedData.filter(r => r.geocode_status === 'WARNING').length;
const failed = errors.length;
const malformed = processedData.filter(r => r.is_malformed).length;
processingResults.set(sessionId, {
filename: originalFilename,
timestamp: new Date().toISOString(),
allResults: allResults,
summary: {
total: total,
successful: processedData.length,
failed: errors.length
successful: successful,
warnings: warnings,
failed: failed,
malformed: malformed
}
});
@ -178,7 +259,10 @@ class DataConvertController {
const completeMessage = {
type: 'complete',
processed: processedData.length,
successful: successful,
warnings: warnings,
errors: errors.length,
malformed: malformed,
total: total,
sessionId: sessionId // Include session ID for report download
};
@ -382,18 +466,18 @@ class DataConvertController {
const results = processingResults.get(sessionId);
const { filename, timestamp, allResults, summary } = results;
// Convert results to CSV format
const csvContent = this.generateReportCSV(allResults, filename, timestamp, summary);
// Generate comprehensive report content
const reportContent = this.generateComprehensiveReport(allResults, filename, timestamp, summary);
// Set headers for CSV download
const reportFilename = `geocoding-report-${sessionId}.csv`;
res.setHeader('Content-Type', 'text/csv');
// Set headers for text download
const reportFilename = `geocoding-report-${sessionId}.txt`;
res.setHeader('Content-Type', 'text/plain');
res.setHeader('Content-Disposition', `attachment; filename="${reportFilename}"`);
res.setHeader('Cache-Control', 'no-cache');
logger.info(`Generating report for session ${sessionId}: ${allResults.length} records`);
logger.info(`Generating comprehensive report for session ${sessionId}: ${allResults.length} records`);
res.send(csvContent);
res.send(reportContent);
// Clean up stored results after download (optional)
setTimeout(() => {
@ -410,6 +494,101 @@ class DataConvertController {
}
}
// Generate comprehensive text report
generateComprehensiveReport(results, originalFilename, timestamp, summary) {
let report = `Geocoding Processing Report\n`;
report += `Generated: ${timestamp}\n`;
report += `Original File: ${originalFilename}\n`;
report += `================================\n\n`;
report += `Summary:\n`;
report += `- Total Addresses: ${summary.total}\n`;
report += `- Successfully Geocoded: ${summary.successful}\n`;
report += `- Warnings (Low Confidence): ${summary.warnings}\n`;
report += `- Failed: ${summary.failed}\n`;
report += `- Potentially Malformed: ${summary.malformed}\n\n`;
// Section for malformed addresses requiring review
const malformedResults = results.filter(r => r.is_malformed);
if (malformedResults.length > 0) {
report += `ADDRESSES REQUIRING REVIEW (Potentially Malformed):\n`;
report += `================================================\n`;
malformedResults.forEach((result, index) => {
const originalAddress = result.address || result.Address || result.ADDRESS || 'N/A';
report += `\n${index + 1}. Original: ${originalAddress}\n`;
report += ` Result: ${result.geocoded_address || 'N/A'}\n`;
report += ` Confidence: ${result.confidence_score || 0}%\n`;
if (result.warnings) {
report += ` Warnings: ${result.warnings}\n`;
}
report += ` Coordinates: ${result.latitude || 'N/A'}, ${result.longitude || 'N/A'}\n`;
report += ` Row: ${result.row_number}\n`;
});
report += `\n`;
}
// Failed addresses section
const failedResults = results.filter(r => r.geocode_status === 'FAILED');
if (failedResults.length > 0) {
report += `FAILED GEOCODING ATTEMPTS:\n`;
report += `========================\n`;
failedResults.forEach((result, index) => {
const originalAddress = result.address || result.Address || result.ADDRESS || 'N/A';
report += `\n${index + 1}. Address: ${originalAddress}\n`;
report += ` Error: ${result.geocode_error}\n`;
report += ` Row: ${result.row_number}\n`;
});
report += `\n`;
}
// Successful geocoding with low confidence
const lowConfidenceResults = results.filter(r =>
r.geocode_status === 'SUCCESS' &&
r.confidence_score &&
r.confidence_score < 75
);
if (lowConfidenceResults.length > 0) {
report += `LOW CONFIDENCE SUCCESSFUL GEOCODING:\n`;
report += `==================================\n`;
lowConfidenceResults.forEach((result, index) => {
const originalAddress = result.address || result.Address || result.ADDRESS || 'N/A';
report += `\n${index + 1}. Original: ${originalAddress}\n`;
report += ` Result: ${result.geocoded_address}\n`;
report += ` Confidence: ${result.confidence_score}%\n`;
if (result.warnings) {
report += ` Warnings: ${result.warnings}\n`;
}
report += ` Row: ${result.row_number}\n`;
});
report += `\n`;
}
// Summary statistics
report += `DETAILED STATISTICS:\n`;
report += `==================\n`;
report += `Success Rate: ${((summary.successful / summary.total) * 100).toFixed(1)}%\n`;
report += `Warning Rate: ${((summary.warnings / summary.total) * 100).toFixed(1)}%\n`;
report += `Failure Rate: ${((summary.failed / summary.total) * 100).toFixed(1)}%\n`;
report += `Malformed Rate: ${((summary.malformed / summary.total) * 100).toFixed(1)}%\n\n`;
// Recommendations
report += `RECOMMENDATIONS:\n`;
report += `===============\n`;
if (summary.malformed > 0) {
report += `- Review ${summary.malformed} addresses marked as potentially malformed\n`;
}
if (summary.failed > 0) {
report += `- Check ${summary.failed} failed addresses for formatting issues\n`;
}
if (summary.warnings > 0) {
report += `- Verify ${summary.warnings} low confidence results manually\n`;
}
report += `- Consider using more specific address formats for better results\n`;
report += `- Ensure addresses include proper directional indicators (NW, SW, etc.)\n`;
return report;
}
// Generate CSV content for the report
generateReportCSV(allResults, originalFilename, timestamp, summary) {
if (!allResults || allResults.length === 0) {

View File

@ -1066,6 +1066,16 @@
<h2>Convert Data</h2>
<p>Upload a CSV file containing addresses to geocode and import into the map.</p>
<!-- Geocoding Provider Status -->
<div class="geocoding-status-container" id="geocoding-status" style="margin-bottom: 20px;">
<div class="info-box">
<h4>Geocoding Provider Status</h4>
<div id="provider-status">
<span class="loading-spinner">🔄</span> Checking provider availability...
</div>
</div>
</div>
<div class="data-convert-container">
<div class="upload-section" id="upload-section">
<h3>CSV Upload</h3>
@ -1227,6 +1237,7 @@
<th>Original Address</th>
<th>Geocoded Address</th>
<th>Coordinates</th>
<th>Provider</th>
</tr>
</thead>
<tbody id="results-tbody"></tbody>

View File

@ -1,6 +1,72 @@
/* Data Conversion Interface Styles */
/* CSV upload, processing, and results preview components */
/* Geocoding Provider Status */
.geocoding-status-container {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 6px;
padding: 15px;
}
.provider-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 10px;
}
.provider-item {
display: flex;
align-items: center;
gap: 5px;
padding: 5px 10px;
border-radius: 4px;
font-size: 14px;
background: white;
border: 1px solid #e9ecef;
}
.provider-item.provider-available {
border-color: #28a745;
background: #f8fff9;
}
.provider-item.provider-unavailable {
border-color: #dc3545;
background: #fff8f8;
opacity: 0.7;
}
.provider-premium {
background: #ffc107;
color: #212529;
font-size: 11px;
padding: 1px 4px;
border-radius: 3px;
font-weight: bold;
}
.provider-note {
margin-top: 10px;
padding: 10px;
background: #e8f4fd;
border: 1px solid #b8daff;
border-radius: 4px;
font-size: 13px;
}
.provider-note ul {
margin: 5px 0 0 20px;
}
.provider-note code {
background: #f8f9fa;
padding: 2px 4px;
border-radius: 3px;
font-family: monospace;
}
.data-convert-container {
display: flex;
flex-direction: column;
@ -192,11 +258,55 @@
animation: slideIn 0.3s ease-out;
}
.result-warning {
background: #fff3cd;
border-left: 4px solid #ffc107;
animation: slideIn 0.3s ease-out;
}
.result-error {
background: #f8d7da;
animation: slideIn 0.3s ease-out;
}
/* Status icons */
.status-icon {
font-weight: bold;
padding: 2px 6px;
border-radius: 3px;
font-size: 12px;
}
.status-icon.success {
background: #d1e7dd;
color: #0f5132;
}
.status-icon.warning {
background: #fff3cd;
color: #664d03;
}
.status-icon.error {
background: #f8d7da;
color: #721c24;
}
/* Custom markers for warnings */
.custom-marker {
display: flex;
align-items: center;
justify-content: center;
}
.warning-marker {
filter: drop-shadow(0 2px 4px rgba(255, 193, 7, 0.4));
}
.success-marker {
filter: drop-shadow(0 2px 4px rgba(40, 167, 69, 0.4));
}
.error-message {
color: #721c24;
font-style: italic;

View File

@ -4,6 +4,83 @@ let resultsMap = null;
let markers = [];
let eventListenersInitialized = false;
// Check and display geocoding provider status
async function checkGeocodingProviders() {
const statusElement = document.getElementById('provider-status');
if (!statusElement) return;
try {
const response = await fetch('/api/geocode/provider-status', {
method: 'GET',
credentials: 'same-origin',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.success) {
const providers = data.providers;
let statusHTML = '<div class="provider-list">';
providers.forEach(provider => {
const icon = provider.available ? '✅' : '❌';
const status = provider.available ? 'Available' : 'Not configured';
const className = provider.available ? 'provider-available' : 'provider-unavailable';
statusHTML += `
<div class="provider-item ${className}">
<span class="provider-icon">${icon}</span>
<strong>${provider.name}</strong>: ${status}
${provider.name === 'Mapbox' && provider.available ?
'<span class="provider-premium">🌟 Premium</span>' : ''}
</div>
`;
});
statusHTML += '</div>';
// Add configuration note if no premium providers
const hasPremium = providers.some(p => p.available && ['Mapbox', 'LocationIQ'].includes(p.name));
if (!hasPremium) {
statusHTML += `
<div class="provider-note">
<p><strong>💡 Tip:</strong> For better geocoding accuracy, configure a premium provider:</p>
<ul>
<li><strong>Mapbox</strong>: Add <code>MAPBOX_ACCESS_TOKEN</code> to your .env file</li>
<li><strong>LocationIQ</strong>: Add <code>LOCATIONIQ_API_KEY</code> to your .env file</li>
</ul>
</div>
`;
} else if (providers.find(p => p.name === 'Mapbox' && p.available)) {
statusHTML += `
<div class="provider-note" style="background: #d1ecf1; border-color: #bee5eb;">
<p><strong>🌟 Mapbox Configured!</strong> Using premium geocoding for better accuracy.</p>
</div>
`;
}
statusElement.innerHTML = statusHTML;
} else {
statusElement.innerHTML = '<span class="error">❌ Failed to check provider status</span>';
}
} catch (error) {
console.error('Failed to check geocoding providers:', error);
statusElement.innerHTML = `
<div class="provider-error">
<span class="error"> Unable to check provider status</span>
<small>Using fallback providers: Nominatim, Photon, ArcGIS</small>
</div>
`;
}
}
// Utility function to show status messages
function showDataConvertStatus(message, type = 'info') {
// Try to use the global showStatus from admin.js if available
@ -49,6 +126,9 @@ function setupDataConvertEventListeners() {
return;
}
// Check geocoding provider status when section loads
checkGeocodingProviders();
const fileInput = document.getElementById('csv-file-input');
const browseBtn = document.getElementById('browse-btn');
const uploadArea = document.getElementById('upload-area');
@ -290,15 +370,36 @@ function handleProcessingUpdate(data) {
case 'progress':
updateProgress(data.current, data.total);
document.getElementById('current-address').textContent = `Processing: ${data.address}`;
// Show current address with status
if (data.status === 'failed') {
document.getElementById('current-address').innerHTML = `<span style="color: red;">✗ ${data.currentAddress || data.address}</span>`;
} else if (data.status === 'processing') {
document.getElementById('current-address').innerHTML = `<span style="color: blue;">⟳ Processing: ${data.currentAddress || data.address}</span>`;
} else {
document.getElementById('current-address').innerHTML = `<span style="color: green;">✓ ${data.currentAddress || data.address}</span>`;
}
break;
case 'geocoded':
// Mark as successful and add to processing data
const successData = { ...data.data, geocode_success: true };
// Check if result has warnings
const isWarning = data.status === 'warning' || (data.data && data.data.is_malformed);
const confidence = data.confidence || (data.data && data.data.confidence_score) || 100;
const warnings = data.warnings || (data.data && data.data.warnings) || [];
// Mark data with appropriate status and add to processing data
const successData = {
...data.data,
geocode_success: true,
confidence_score: confidence,
warnings: Array.isArray(warnings) ? warnings.join('; ') : warnings
};
processingData.push(successData);
addResultToTable(successData, 'success');
addMarkerToMap(successData);
// Add to table with appropriate status
const resultStatus = isWarning ? 'warning' : 'success';
addResultToTable(successData, resultStatus, confidence, warnings);
addMarkerToMap(successData, isWarning);
updateProgress(data.index + 1, data.total);
break;
@ -312,6 +413,20 @@ function handleProcessingUpdate(data) {
case 'complete':
console.log('Received complete event:', data);
currentSessionId = data.sessionId; // Store session ID for report download
// Show comprehensive completion message
let completionMessage = `Complete! Processed ${data.total} addresses:\n`;
completionMessage += `${data.successful || 0} successful\n`;
if (data.warnings > 0) {
completionMessage += `${data.warnings} with warnings (low confidence)\n`;
}
if (data.malformed > 0) {
completionMessage += `🔍 ${data.malformed} potentially malformed (need review)\n`;
}
completionMessage += `${data.errors || data.failed || 0} failed`;
document.getElementById('current-address').innerHTML = completionMessage.replace(/\n/g, '<br>');
onProcessingComplete(data);
break;
@ -351,38 +466,85 @@ function initializeResultsMap() {
}, 100);
}
function addResultToTable(data, status) {
function addResultToTable(data, status, confidence = null, warnings = []) {
const tbody = document.getElementById('results-tbody');
const row = document.createElement('tr');
row.className = status === 'success' ? 'result-success' : 'result-error';
// Set row class based on status
if (status === 'success') {
row.className = 'result-success';
} else if (status === 'warning') {
row.className = 'result-warning';
} else {
row.className = 'result-error';
}
if (status === 'success' || status === 'warning') {
// Add confidence indicator for successful geocoding
let statusIcon = status === 'warning' ?
`<span class="status-icon warning" title="Low confidence result">⚠</span>` :
`<span class="status-icon success">✓</span>`;
if (confidence !== null && confidence < 100) {
statusIcon += ` <small>(${Math.round(confidence)}%)</small>`;
}
let addressCell = escapeHtml(data.geocoded_address || '');
if (warnings && warnings.length > 0) {
const warningText = Array.isArray(warnings) ? warnings.join(', ') : warnings;
addressCell += `<br><small style="color: orange;" title="${escapeHtml(warningText)}">⚠ ${escapeHtml(warningText)}</small>`;
}
row.innerHTML = `
<td><span class="status-icon success"></span></td>
<td>${statusIcon}</td>
<td>${escapeHtml(data.address || data.Address || '')}</td>
<td>${escapeHtml(data.geocoded_address || '')}</td>
<td>${addressCell}</td>
<td>${data.latitude.toFixed(6)}, ${data.longitude.toFixed(6)}</td>
<td>${data.provider ? escapeHtml(data.provider) : 'N/A'}</td>
`;
} else {
row.innerHTML = `
<td><span class="status-icon error"></span></td>
<td>${escapeHtml(data.address || '')}</td>
<td colspan="2" class="error-message">${escapeHtml(data.error || 'Geocoding failed')}</td>
<td>${escapeHtml(data.address || data.Address || '')}</td>
<td colspan="3" class="error-message">${escapeHtml(data.geocode_error || data.error || 'Geocoding failed')}</td>
`;
}
tbody.appendChild(row);
}
function addMarkerToMap(data) {
function addMarkerToMap(data, isWarning = false) {
if (!resultsMap || !data.latitude || !data.longitude) return;
const marker = L.marker([data.latitude, data.longitude])
.bindPopup(`
<strong>${escapeHtml(data.geocoded_address || data.address)}</strong><br>
${data.latitude.toFixed(6)}, ${data.longitude.toFixed(6)}
`);
// Choose marker color based on status
const markerColor = isWarning ? 'orange' : 'green';
const marker = L.marker([data.latitude, data.longitude], {
icon: L.divIcon({
className: `custom-marker ${isWarning ? 'warning-marker' : 'success-marker'}`,
html: `<div style="background-color: ${markerColor}; width: 12px; height: 12px; border-radius: 50%; border: 2px solid white; box-shadow: 0 1px 3px rgba(0,0,0,0.3);"></div>`,
iconSize: [16, 16],
iconAnchor: [8, 8]
})
});
// Create popup content with warning information
let popupContent = `<strong>${escapeHtml(data.geocoded_address || data.address)}</strong><br>`;
popupContent += `${data.latitude.toFixed(6)}, ${data.longitude.toFixed(6)}`;
if (data.provider) {
popupContent += `<br><small>Provider: ${data.provider}</small>`;
}
if (isWarning && data.confidence_score) {
popupContent += `<br><span style="color: orange;">⚠ Confidence: ${Math.round(data.confidence_score)}%</span>`;
}
if (isWarning && data.warnings) {
popupContent += `<br><small style="color: orange;">${escapeHtml(data.warnings)}</small>`;
}
marker.bindPopup(popupContent);
marker.addTo(resultsMap);
markers.push(marker);
@ -396,11 +558,9 @@ function addMarkerToMap(data) {
function onProcessingComplete(data) {
console.log('Processing complete called with data:', data);
document.getElementById('current-address').textContent =
`Complete! Processed ${data.processed || data.success || 0} addresses successfully, ${data.errors || data.failed || 0} errors.`;
const processedCount = data.processed || data.success || processingData.filter(item => item.geocode_success !== false).length;
const processedCount = data.processed || data.successful || processingData.filter(item => item.geocode_success !== false).length;
// Show comprehensive processing actions with download option
if (processedCount > 0) {
console.log('Showing processing actions for', processedCount, 'successful items');
const actionsDiv = document.getElementById('processing-actions');
@ -412,17 +572,105 @@ function onProcessingComplete(data) {
}
}
// Show download report button if we have a session ID
if (currentSessionId && data.total > 0) {
showDownloadReportButton(data.total, data.errors || data.failed || 0);
// Add download report button if session ID is available
if (currentSessionId) {
showDownloadReportButton(data);
}
const errorCount = data.errors || data.failed || 0;
showDataConvertStatus(`Processing complete: ${processedCount} successful, ${errorCount} errors`,
errorCount > 0 ? 'warning' : 'success');
// Update final message with detailed statistics
let finalMessage = `Geocoding Complete!\n\n`;
finalMessage += `📊 Summary:\n`;
finalMessage += `• Total Processed: ${data.total || 0}\n`;
finalMessage += `• ✅ Successful: ${data.successful || 0}\n`;
if (data.warnings > 0) {
finalMessage += `• ⚠️ Warnings: ${data.warnings} (low confidence, review recommended)\n`;
}
if (data.malformed > 0) {
finalMessage += `• 🔍 Potentially Malformed: ${data.malformed} (need manual review)\n`;
}
finalMessage += `• ❌ Failed: ${data.errors || data.failed || 0}\n\n`;
if (data.warnings > 0 || data.malformed > 0) {
finalMessage += `⚠️ Note: ${(data.warnings || 0) + (data.malformed || 0)} addresses may need manual verification.\n`;
finalMessage += `Please download the detailed report for review.`;
} else if ((data.successful || 0) > 0) {
finalMessage += `✅ All geocoded addresses appear to have high confidence results!`;
}
showDataConvertStatus(finalMessage, (data.errors || data.failed || 0) > 0 ? 'warning' : 'success');
}
// Enhanced save function with better feedback
// Add download report button function
function showDownloadReportButton(data) {
const processingActions = document.getElementById('processing-actions');
if (!processingActions) return;
// Check if download button already exists
let downloadBtn = document.getElementById('download-report-btn');
if (!downloadBtn) {
downloadBtn = document.createElement('button');
downloadBtn.id = 'download-report-btn';
downloadBtn.className = 'btn btn-info';
downloadBtn.innerHTML = '📄 Download Detailed Report';
downloadBtn.addEventListener('click', downloadProcessingReport);
// Insert download button before save results button
const saveBtn = document.getElementById('save-results-btn');
if (saveBtn && saveBtn.parentNode === processingActions) {
processingActions.insertBefore(downloadBtn, saveBtn);
} else {
processingActions.appendChild(downloadBtn);
}
}
// Update button text with statistics
const warningsCount = (data.warnings || 0) + (data.malformed || 0);
if (warningsCount > 0) {
downloadBtn.innerHTML = `📄 Download Report (${warningsCount} need review)`;
downloadBtn.className = 'btn btn-warning';
}
}
// Download processing report function
async function downloadProcessingReport() {
if (!currentSessionId) {
showDataConvertStatus('No report available to download', 'error');
return;
}
try {
const response = await fetch(`/api/admin/data-convert/download-report/${currentSessionId}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Get the filename from the response headers
const filename = response.headers.get('content-disposition')
?.split('filename=')[1]
?.replace(/"/g, '') || `geocoding-report-${currentSessionId}.txt`;
// Create blob and download
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
showDataConvertStatus('Report downloaded successfully', 'success');
} catch (error) {
console.error('Download report error:', error);
showDataConvertStatus('Failed to download report: ' + error.message, 'error');
}
}
async function saveGeocodedResults() {
const successfulData = processingData.filter(item => item.geocode_success !== false && item.latitude && item.longitude);

View File

@ -160,6 +160,54 @@ router.get('/search', geocodeLimiter, async (req, res) => {
}
});
/**
* Get geocoding provider status
* GET /api/geocode/provider-status
*/
router.get('/provider-status', (req, res) => {
try {
const providers = [
{
name: 'Mapbox',
available: !!(process.env.MAPBOX_ACCESS_TOKEN || process.env.MAPBOX_API_KEY),
type: 'premium'
},
{
name: 'Nominatim',
available: true,
type: 'free'
},
{
name: 'Photon',
available: true,
type: 'free'
},
{
name: 'LocationIQ',
available: !!process.env.LOCATIONIQ_API_KEY,
type: 'premium'
},
{
name: 'ArcGIS',
available: true,
type: 'free'
}
];
res.json({
success: true,
providers: providers,
hasPremium: providers.some(p => p.available && p.type === 'premium')
});
} catch (error) {
console.error('Error checking provider status:', error);
res.status(500).json({
success: false,
error: 'Failed to check provider status'
});
}
});
/**
* Get geocoding cache statistics (admin endpoint)
* GET /api/geocode/cache/stats

View File

@ -30,11 +30,586 @@ setInterval(() => {
}, 60 * 60 * 1000); // Run every hour
/**
* Reverse geocode coordinates to get address
* @param {number} lat - Latitude
* @param {number} lng - Longitude
* @returns {Promise<Object>} Geocoding result
* Multi-provider geocoding configuration
* Providers are tried in order until one succeeds with good confidence
*/
// Provider configuration - order matters (higher quality providers first)
const GEOCODING_PROVIDERS = [
// Premium provider (when API key is available)
{
name: 'Mapbox',
func: geocodeWithMapbox,
enabled: () => !!(process.env.MAPBOX_ACCESS_TOKEN || process.env.MAPBOX_API_KEY),
options: { timeout: 8000, delay: 0 }
},
// Free providers (fallbacks)
{
name: 'Nominatim',
func: geocodeWithNominatim,
enabled: () => true,
options: { timeout: 5000, delay: 1000 }
},
{
name: 'Photon',
func: geocodeWithPhoton,
enabled: () => true,
options: { timeout: 5000, delay: 500 }
},
{
name: 'LocationIQ',
func: geocodeWithLocationIQ,
enabled: () => !!process.env.LOCATIONIQ_API_KEY,
options: { timeout: 5000, delay: 0 }
},
{
name: 'ArcGIS',
func: geocodeWithArcGIS,
enabled: () => true,
options: { timeout: 8000, delay: 500 }
}
];
/**
* Geocode with Nominatim (OpenStreetMap)
*/
// Geocoding provider functions
async function geocodeWithMapbox(address, options = {}) {
const { timeout = 5000, delay = 0 } = options;
const apiKey = process.env.MAPBOX_ACCESS_TOKEN || process.env.MAPBOX_API_KEY;
if (!apiKey) {
throw new Error('Mapbox API key not configured');
}
// Rate limiting (Mapbox has generous rate limits)
if (delay > 0) {
await new Promise(resolve => setTimeout(resolve, delay));
}
// Parse address components for structured input
const addressComponents = parseAddressString(address);
let url;
if (addressComponents.hasComponents) {
// Use structured input for better accuracy
const params = new URLSearchParams({
access_token: apiKey,
limit: 1,
country: addressComponents.country || 'ca' // Default to Canada
});
if (addressComponents.address_number) params.append('address_number', addressComponents.address_number);
if (addressComponents.street) params.append('street', addressComponents.street);
if (addressComponents.place) params.append('place', addressComponents.place);
if (addressComponents.region) params.append('region', addressComponents.region);
if (addressComponents.postcode) params.append('postcode', addressComponents.postcode);
url = `https://api.mapbox.com/search/geocode/v6/forward?${params.toString()}`;
} else {
// Fallback to simple search
url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(address)}.json?access_token=${apiKey}&limit=1&country=ca`;
}
logger.info(`Geocoding with Mapbox: ${address}`);
try {
const response = await axios.get(url, { timeout });
const data = response.data;
// Debug: Log the API response structure
logger.debug(`Mapbox response structure:`, {
hasFeatures: !!(data.features && data.features.length > 0),
hasData: !!(data.data && data.data.length > 0),
featuresLength: data.features?.length || 0,
dataLength: data.data?.length || 0,
firstFeature: data.features?.[0] ? Object.keys(data.features[0]) : null,
firstData: data.data?.[0] ? Object.keys(data.data[0]) : null
});
let result;
// Handle direct feature response (newer format)
if (data.geometry && data.properties) {
result = data;
} else if (data.features && data.features.length > 0) {
// v5 API response format (legacy)
result = data.features[0];
} else if (data.data && data.data.length > 0) {
// v6 API response format (structured)
result = data.data[0];
}
if (!result) {
logger.info(`Mapbox returned no results for address: ${address}`);
return null;
}
// Extract coordinates - try multiple possible locations
let latitude, longitude;
if (result.properties?.coordinates?.latitude && result.properties?.coordinates?.longitude) {
// New format: properties.coordinates object
latitude = result.properties.coordinates.latitude;
longitude = result.properties.coordinates.longitude;
} else if (result.geometry?.coordinates && Array.isArray(result.geometry.coordinates) && result.geometry.coordinates.length >= 2) {
// GeoJSON format: geometry.coordinates [lng, lat]
longitude = result.geometry.coordinates[0];
latitude = result.geometry.coordinates[1];
} else if (result.center && Array.isArray(result.center) && result.center.length >= 2) {
// v5 format: center [lng, lat]
longitude = result.center[0];
latitude = result.center[1];
} else {
logger.error(`Mapbox result missing valid coordinates:`, {
hasPropsCoords: !!(result.properties?.coordinates),
hasGeomCoords: !!(result.geometry?.coordinates),
hasCenter: !!result.center,
result: result
});
return null;
}
// Extract formatted address
let formattedAddress = result.properties?.full_address ||
result.properties?.name_preferred ||
result.properties?.name ||
result.place_name ||
'Unknown Address';
// Calculate confidence from match_code if available
let confidence = 100;
if (result.properties?.match_code) {
const matchCode = result.properties.match_code;
if (matchCode.confidence === 'exact') confidence = 100;
else if (matchCode.confidence === 'high') confidence = 90;
else if (matchCode.confidence === 'medium') confidence = 70;
else if (matchCode.confidence === 'low') confidence = 50;
else confidence = (result.relevance || 1) * 100;
} else {
confidence = (result.relevance || 1) * 100;
}
return {
latitude: latitude,
longitude: longitude,
formattedAddress: formattedAddress,
provider: 'Mapbox',
confidence: confidence,
components: extractMapboxComponents(result),
raw: result
};
// No results found
logger.info(`Mapbox returned no results for address: ${address}`);
return null;
} catch (error) {
logger.error('Mapbox geocoding error:', error.message);
throw error;
}
}
async function geocodeWithNominatim(address, options = {}) {
const { timeout = 5000, delay = 1000 } = options;
// Rate limiting for Nominatim (1 request per second)
if (delay > 0) {
await new Promise(resolve => setTimeout(resolve, delay));
}
const url = `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(address)}&limit=1&addressdetails=1`;
logger.info(`Geocoding with Nominatim: ${address}`);
try {
const response = await axios.get(url, {
timeout,
headers: {
'User-Agent': 'MapApp/1.0'
}
});
const data = response.data;
if (!data || data.length === 0) {
return null;
}
const result = data[0];
return {
latitude: parseFloat(result.lat),
longitude: parseFloat(result.lon),
formattedAddress: result.display_name,
provider: 'Nominatim',
confidence: calculateNominatimConfidence(result),
components: extractAddressComponents(result.address || {}),
raw: result
};
} catch (error) {
logger.error('Nominatim geocoding error:', error.message);
throw error;
}
}
/**
* Geocode with Photon (OpenStreetMap-based)
*/
async function geocodeWithPhoton(address, options = {}) {
const { timeout = 15000, delay = 500 } = options;
// Rate limiting
if (delay > 0) {
await new Promise(resolve => setTimeout(resolve, delay));
}
const response = await axios.get('https://photon.komoot.io/api/', {
params: {
q: address,
limit: 1,
lang: 'en'
},
timeout
});
if (!response.data?.features || response.data.features.length === 0) {
return null;
}
const feature = response.data.features[0];
const coords = feature.geometry.coordinates;
const props = feature.properties;
return {
provider: 'photon',
latitude: coords[1],
longitude: coords[0],
formattedAddress: buildFormattedAddressFromPhoton(props),
components: extractPhotonComponents(props),
confidence: calculatePhotonConfidence(feature),
raw: feature
};
}
/**
* Geocode with LocationIQ (fallback)
*/
async function geocodeWithLocationIQ(address, options = {}) {
const { timeout = 15000, delay = 0 } = options;
const apiKey = process.env.LOCATIONIQ_API_KEY;
// Rate limiting
if (delay > 0) {
await new Promise(resolve => setTimeout(resolve, delay));
}
// LocationIQ can work without API key but has rate limits
const params = {
format: 'json',
q: address,
limit: 1,
addressdetails: 1,
countrycodes: 'ca'
};
// Add API key if available for better rate limits
if (apiKey) {
params.key = apiKey;
}
const response = await axios.get('https://us1.locationiq.com/v1/search.php', {
params,
headers: {
'User-Agent': 'NocoDB Map Viewer 1.0'
},
timeout
});
if (!response.data || response.data.length === 0) {
return null;
}
const data = response.data[0];
return {
provider: 'locationiq',
latitude: parseFloat(data.lat),
longitude: parseFloat(data.lon),
formattedAddress: buildFormattedAddress(data.address),
components: extractAddressComponents(data.address),
confidence: calculateNominatimConfidence(data), // Similar format to Nominatim
boundingBox: data.boundingbox,
raw: data
};
}
/**
* Geocode with ArcGIS World Geocoding Service
*/
async function geocodeWithArcGIS(address, options = {}) {
const { timeout = 15000, delay = 500 } = options;
// Rate limiting
if (delay > 0) {
await new Promise(resolve => setTimeout(resolve, delay));
}
const response = await axios.get('https://geocode-api.arcgis.com/arcgis/rest/services/World/GeocodeServer/findAddressCandidates', {
params: {
SingleLine: address,
f: 'json',
outFields: '*',
maxLocations: 1,
countryCode: 'CA'
},
timeout
});
if (!response.data?.candidates || response.data.candidates.length === 0) {
return null;
}
const candidate = response.data.candidates[0];
const location = candidate.location;
const attributes = candidate.attributes;
return {
provider: 'arcgis',
latitude: location.y,
longitude: location.x,
formattedAddress: attributes.LongLabel || candidate.address,
components: extractArcGISComponents(attributes),
confidence: candidate.score || 50, // ArcGIS provides score 0-100
raw: candidate
};
}
/**
* Helper functions for address processing
*/
function buildFormattedAddress(addressComponents) {
if (!addressComponents) return '';
let formattedAddress = '';
if (addressComponents.house_number) formattedAddress += addressComponents.house_number + ' ';
if (addressComponents.road) formattedAddress += addressComponents.road + ', ';
if (addressComponents.suburb) formattedAddress += addressComponents.suburb + ', ';
if (addressComponents.city) formattedAddress += addressComponents.city + ', ';
if (addressComponents.state) formattedAddress += addressComponents.state + ' ';
if (addressComponents.postcode) formattedAddress += addressComponents.postcode;
return formattedAddress.trim().replace(/,$/, '');
}
function buildFormattedAddressFromPhoton(props) {
let address = '';
if (props.housenumber) address += props.housenumber + ' ';
if (props.street) address += props.street + ', ';
if (props.district) address += props.district + ', ';
if (props.city) address += props.city + ', ';
if (props.state) address += props.state + ' ';
if (props.postcode) address += props.postcode;
return address.trim().replace(/,$/, '');
}
function extractAddressComponents(address) {
// If address is a string, parse it
if (typeof address === 'string') {
return parseAddressString(address);
}
// If address is already an object (from Nominatim/LocationIQ response)
return {
house_number: address?.house_number || '',
road: address?.road || '',
suburb: address?.suburb || address?.neighbourhood || '',
city: address?.city || address?.town || address?.village || '',
state: address?.state || address?.province || '',
postcode: address?.postcode || '',
country: address?.country || ''
};
}
function parseAddressString(addressStr) {
// Parse a string address into components for Mapbox structured input
const components = {
address_number: '',
street: '',
place: '',
region: '',
postcode: '',
country: 'ca',
hasComponents: false
};
if (!addressStr || typeof addressStr !== 'string') {
return components;
}
// Clean up the address string
const cleanAddress = addressStr.trim();
// Basic regex patterns for Canadian addresses
const patterns = {
// Match house number at the start
houseNumber: /^(\d+[A-Za-z]?)\s+(.+)/,
// Match postal code (Canadian format: A1A 1A1)
postalCode: /([A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d)$/i,
// Match province abbreviations
province: /,?\s*(AB|BC|MB|NB|NL|NT|NS|NU|ON|PE|QC|SK|YT|Alberta|British Columbia|Manitoba|New Brunswick|Newfoundland|Northwest Territories|Nova Scotia|Nunavut|Ontario|Prince Edward Island|Quebec|Saskatchewan|Yukon)\s*,?\s*CA(?:NADA)?$/i
};
let workingAddress = cleanAddress;
// Extract postal code
const postalMatch = workingAddress.match(patterns.postalCode);
if (postalMatch) {
components.postcode = postalMatch[1].replace(/\s/g, '');
workingAddress = workingAddress.replace(patterns.postalCode, '').trim();
}
// Extract province/region
const provinceMatch = workingAddress.match(patterns.province);
if (provinceMatch) {
components.region = provinceMatch[1];
workingAddress = workingAddress.replace(patterns.province, '').trim();
}
// Extract house number and street
const houseMatch = workingAddress.match(patterns.houseNumber);
if (houseMatch) {
components.address_number = houseMatch[1];
workingAddress = houseMatch[2];
components.hasComponents = true;
}
// The remaining part is likely the street and city
const parts = workingAddress.split(',').map(p => p.trim()).filter(p => p);
if (parts.length > 0) {
components.street = parts[0];
components.hasComponents = true;
}
if (parts.length > 1) {
components.place = parts[1];
}
return components;
}
function extractPhotonComponents(props) {
return {
house_number: props.housenumber || '',
road: props.street || '',
suburb: props.district || '',
city: props.city || '',
state: props.state || '',
postcode: props.postcode || '',
country: props.country || ''
};
}
function extractArcGISComponents(attributes) {
return {
house_number: attributes.AddNum || '',
road: attributes.StName || '',
suburb: attributes.District || '',
city: attributes.City || '',
state: attributes.Region || '',
postcode: attributes.Postal || '',
country: attributes.Country || ''
};
}
function extractMapboxComponents(result) {
// Universal Mapbox component extractor for multiple API formats
const components = {
house_number: '',
road: '',
suburb: '',
city: '',
state: '',
postcode: '',
country: ''
};
// New format: properties.context object
if (result.properties?.context) {
const ctx = result.properties.context;
components.house_number = ctx.address?.address_number || '';
components.road = ctx.address?.street_name || ctx.street?.name || '';
components.suburb = ctx.neighborhood?.name || '';
components.city = ctx.place?.name || '';
components.state = ctx.region?.name || '';
components.postcode = ctx.postcode?.name || '';
components.country = ctx.country?.name || '';
return components;
}
// Legacy v6 format: properties object
if (result.properties) {
const props = result.properties;
components.house_number = props.address_number || '';
components.road = props.street || '';
components.suburb = props.neighborhood || '';
components.city = props.place || '';
components.state = props.region || '';
components.postcode = props.postcode || '';
components.country = props.country || '';
return components;
}
// Legacy v5 format: context array
if (result.context && Array.isArray(result.context)) {
result.context.forEach(item => {
const id = item.id || '';
if (id.startsWith('postcode.')) components.postcode = item.text;
else if (id.startsWith('place.')) components.city = item.text;
else if (id.startsWith('region.')) components.state = item.text;
else if (id.startsWith('country.')) components.country = item.text;
else if (id.startsWith('neighborhood.')) components.suburb = item.text;
});
// Extract house number from place name (first part)
if (result.place_name) {
const match = result.place_name.match(/^(\d+[A-Za-z]?)\s+/);
if (match) components.house_number = match[1];
// Extract road from place name or address
if (components.house_number) {
const addressPart = result.place_name.replace(new RegExp(`^${components.house_number}\\s+`), '').split(',')[0];
components.road = addressPart.trim();
}
}
}
return components;
}
function calculateNominatimConfidence(data) {
// Basic confidence calculation for Nominatim results
let confidence = 100;
if (!data.address?.house_number) confidence -= 20;
if (!data.address?.road) confidence -= 30;
if (data.type === 'administrative') confidence -= 25;
if (data.class === 'place' && data.type === 'suburb') confidence -= 20;
return Math.max(confidence, 10);
}
function calculatePhotonConfidence(feature) {
// Basic confidence for Photon results
let confidence = 100;
const props = feature.properties;
if (!props.housenumber) confidence -= 20;
if (!props.street) confidence -= 30;
if (props.osm_type === 'relation') confidence -= 15;
return Math.max(confidence, 10);
}
async function reverseGeocode(lat, lng) {
// Create cache key - use full precision
const cacheKey = `${lat},${lng}`;
@ -168,11 +743,106 @@ async function forwardGeocodeSearch(address, limit = 5) {
}
/**
* Forward geocode address to get coordinates
* Validate geocoding result and calculate confidence score
* @param {string} originalAddress - The original address searched
* @param {Object} geocodeResult - The geocoding result
* @returns {Object} Validation result with confidence score
*/
function validateGeocodeResult(originalAddress, geocodeResult) {
const validation = {
isValid: true,
confidence: 100,
warnings: [],
isMalformed: false
};
if (!geocodeResult || !geocodeResult.formattedAddress) {
validation.isValid = false;
validation.confidence = 0;
return validation;
}
// Extract key components from original address
const originalLower = originalAddress.toLowerCase();
const resultLower = geocodeResult.formattedAddress.toLowerCase();
// Check for street number presence
const streetNumberMatch = originalAddress.match(/^\d+/);
const resultStreetNumber = geocodeResult.components.house_number;
if (streetNumberMatch && !resultStreetNumber) {
validation.warnings.push('Street number not found in result');
validation.confidence -= 30;
validation.isMalformed = true;
} else if (streetNumberMatch && resultStreetNumber) {
if (streetNumberMatch[0] !== resultStreetNumber) {
validation.warnings.push('Street number mismatch');
validation.confidence -= 40;
validation.isMalformed = true;
}
}
// Check for street name presence
const streetNameWords = originalLower
.replace(/^\d+\s*/, '') // Remove leading number
.replace(/,.*$/, '') // Remove everything after first comma
.trim()
.split(/\s+/)
.filter(word => !['nw', 'ne', 'sw', 'se', 'street', 'st', 'avenue', 'ave', 'road', 'rd', 'crescent', 'close'].includes(word));
let matchedWords = 0;
streetNameWords.forEach(word => {
if (resultLower.includes(word)) {
matchedWords++;
}
});
const matchPercentage = streetNameWords.length > 0 ? (matchedWords / streetNameWords.length) * 100 : 0;
if (matchPercentage < 50) {
validation.warnings.push('Poor street name match');
validation.confidence -= 30;
validation.isMalformed = true;
} else if (matchPercentage < 75) {
validation.warnings.push('Partial street name match');
validation.confidence -= 15;
}
// Check for generic/fallback results (often indicates geocoding failure)
const genericIndicators = ['castle downs', 'clover bar', 'downtown', 'city centre'];
const hasGenericResult = genericIndicators.some(indicator =>
resultLower.includes(indicator) && !originalLower.includes(indicator)
);
if (hasGenericResult) {
validation.warnings.push('Result appears to be generic area, not specific address');
validation.confidence -= 25;
validation.isMalformed = true;
}
// Final validation
validation.isValid = validation.confidence >= 50;
return validation;
}
/**
* Multi-provider forward geocode with fallback support
* @param {string} address - Address to geocode
* @returns {Promise<Object>} Geocoding result
* @returns {Promise<Object>} Geocoding result with provider info
*/
async function forwardGeocode(address) {
// Input validation
if (!address || typeof address !== 'string') {
logger.warn(`Invalid address provided for geocoding: ${address}`);
throw new Error('Invalid address: address must be a non-empty string');
}
address = address.trim();
if (address.length === 0) {
throw new Error('Invalid address: address cannot be empty');
}
// Create cache key
const cacheKey = `addr:${address.toLowerCase()}`;
@ -183,58 +853,138 @@ async function forwardGeocode(address) {
return cached.data;
}
try {
// Add delay to respect rate limits - increase delay for batch processing
await new Promise(resolve => setTimeout(resolve, 1500));
// Try different address format variations
const addressVariations = [
address, // Original
address.replace(/\s+NW\s*$/i, ' Northwest'), // Expand NW
address.replace(/\s+Northwest\s*$/i, ' NW'), // Contract Northwest
address.replace(/,\s*CA\s*$/i, ', Canada'), // Expand CA to Canada
address.replace(/\s+Street\s+/i, ' St '), // Abbreviate Street
address.replace(/\s+St\s+/i, ' Street '), // Expand St
address.replace(/\s+Avenue\s+/i, ' Ave '), // Abbreviate Avenue
address.replace(/\s+Ave\s+/i, ' Avenue '), // Expand Ave
];
// Provider functions mapping
const providerFunctions = {
'Mapbox': geocodeWithMapbox,
'Nominatim': geocodeWithNominatim,
'Photon': geocodeWithPhoton,
'LocationIQ': geocodeWithLocationIQ,
'ArcGIS': geocodeWithArcGIS
};
let bestResult = null;
let bestConfidence = 0;
const allErrors = [];
// Try each provider with each address variation
for (const provider of GEOCODING_PROVIDERS) {
logger.info(`Trying provider: ${provider.name}`);
logger.info(`Forward geocoding: ${address}`);
const response = await axios.get('https://nominatim.openstreetmap.org/search', {
params: {
format: 'json',
q: address,
limit: 1,
addressdetails: 1,
'accept-language': 'en'
},
headers: {
'User-Agent': 'NocoDB Map Viewer 1.0 (contact@example.com)'
},
timeout: 15000 // Increase timeout to 15 seconds
});
if (!response.data || response.data.length === 0) {
throw new Error('No results found');
for (let varIndex = 0; varIndex < addressVariations.length; varIndex++) {
const addressVariation = addressVariations[varIndex];
try {
logger.info(`${provider.name} - attempt ${varIndex + 1}/${addressVariations.length}: ${addressVariation}`);
const providerResult = await providerFunctions[provider.name](addressVariation, provider.options);
if (!providerResult) {
continue; // No results from this provider/variation
}
// Convert to standard format
const result = {
fullAddress: providerResult.formattedAddress,
formattedAddress: providerResult.formattedAddress,
components: providerResult.components,
coordinates: {
lat: providerResult.latitude,
lng: providerResult.longitude
},
latitude: providerResult.latitude,
longitude: providerResult.longitude,
provider: providerResult.provider,
providerConfidence: providerResult.confidence,
addressVariation: addressVariation,
variationIndex: varIndex,
boundingBox: providerResult.boundingBox || null,
raw: providerResult.raw
};
// Validate the result
const validation = validateGeocodeResult(address, result);
result.validation = validation;
// Calculate combined confidence (provider confidence + validation confidence)
const combinedConfidence = Math.round((providerResult.confidence + validation.confidence) / 2);
result.combinedConfidence = combinedConfidence;
logger.info(`${provider.name} result - Provider: ${providerResult.confidence}%, Validation: ${validation.confidence}%, Combined: ${combinedConfidence}%`);
// If this is a very high confidence result, use it immediately
if (combinedConfidence >= 90 && validation.confidence >= 80) {
logger.info(`High confidence result found with ${provider.name}, using immediately`);
// Cache and return the result
geocodeCache.set(cacheKey, {
data: result,
timestamp: Date.now()
});
return result;
}
// Keep track of the best result so far
if (combinedConfidence > bestConfidence) {
bestResult = result;
bestConfidence = combinedConfidence;
}
// If we have a decent result from the first variation, don't try more variations for this provider
if (varIndex === 0 && combinedConfidence >= 70) {
logger.info(`Good result from ${provider.name} with original address, skipping variations`);
break;
}
} catch (error) {
logger.error(`${provider.name} error with "${addressVariation}": ${error.message}`);
allErrors.push(`${provider.name}: ${error.message}`);
// If rate limited, wait extra time
if (error.response?.status === 429) {
await new Promise(resolve => setTimeout(resolve, 5000));
}
continue; // Try next variation or provider
}
}
// Process the first result
const result = processGeocodeResponse(response.data[0]);
// If we found a good result with this provider, we can stop trying other providers
if (bestConfidence >= 75) {
logger.info(`Acceptable result found with ${provider.name} (${bestConfidence}%), stopping provider search`);
break;
}
}
// If we have any result, return the best one
if (bestResult) {
logger.info(`Returning best result from ${bestResult.provider} with ${bestConfidence}% confidence`);
// Cache the result
geocodeCache.set(cacheKey, {
data: result,
data: bestResult,
timestamp: Date.now()
});
return result;
} catch (error) {
logger.error('Forward geocoding error:', error.message);
if (error.response?.status === 429) {
throw new Error('Rate limit exceeded. Please try again later.');
} else if (error.response?.status === 403) {
throw new Error('Access denied by geocoding service');
} else if (error.response?.status === 500) {
throw new Error('Geocoding service internal error');
} else if (error.code === 'ECONNABORTED') {
throw new Error('Geocoding request timeout');
} else if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') {
throw new Error('Cannot connect to geocoding service');
} else {
throw new Error(`Geocoding failed: ${error.message}`);
}
return bestResult;
}
// All providers failed
const errorMessage = `All geocoding providers failed: ${allErrors.join('; ')}`;
logger.error(errorMessage);
throw new Error('Geocoding failed: No providers could locate this address');
}
/**