Large update to geo-coding functions in order to support better matching of street addresses. Added premium mapbox option
This commit is contained in:
parent
d29ffa6300
commit
44298834ef
@ -855,13 +855,35 @@ When connected to a MkDocs documentation site:
|
|||||||
### Address Search
|
### Address Search
|
||||||
|
|
||||||
For geographic location 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
|
- **Multiple Results**: Returns up to 5 address matches
|
||||||
- **Map Integration**: Click results to view location on map
|
- **Map Integration**: Click results to view location on map
|
||||||
- **Temporary Markers**: Visual markers for search results
|
- **Temporary Markers**: Visual markers for search results
|
||||||
- **Quick Actions**: Add locations directly from search results
|
- **Quick Actions**: Add locations directly from search results
|
||||||
- **Coordinate Display**: Shows precise latitude/longitude coordinates
|
- **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
|
### Database Search
|
||||||
For searching through loaded location data:
|
For searching through loaded location data:
|
||||||
- **Full-text Search**: Search through names, addresses, emails, phone numbers, and notes
|
- **Full-text Search**: Search through names, addresses, emails, phone numbers, and notes
|
||||||
|
|||||||
@ -75,14 +75,71 @@ class DataConvertController {
|
|||||||
// Process each address with progress updates
|
// Process each address with progress updates
|
||||||
for (let i = 0; i < results.length; i++) {
|
for (let i = 0; i < results.length; i++) {
|
||||||
const row = results[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({
|
res.write(`data: ${JSON.stringify({
|
||||||
type: 'progress',
|
type: 'progress',
|
||||||
current: i + 1,
|
current: i + 1,
|
||||||
total: total,
|
total: total,
|
||||||
address: address
|
currentAddress: address,
|
||||||
|
status: 'processing'
|
||||||
})}\n\n`);
|
})}\n\n`);
|
||||||
res.flush && res.flush();
|
res.flush && res.flush();
|
||||||
|
|
||||||
@ -93,6 +150,11 @@ class DataConvertController {
|
|||||||
const geocodeResult = await forwardGeocode(address);
|
const geocodeResult = await forwardGeocode(address);
|
||||||
|
|
||||||
if (geocodeResult && geocodeResult.coordinates) {
|
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 = {
|
const processedRow = {
|
||||||
...row,
|
...row,
|
||||||
latitude: geocodeResult.coordinates.lat,
|
latitude: geocodeResult.coordinates.lat,
|
||||||
@ -100,30 +162,38 @@ class DataConvertController {
|
|||||||
'Geo-Location': `${geocodeResult.coordinates.lat};${geocodeResult.coordinates.lng}`,
|
'Geo-Location': `${geocodeResult.coordinates.lat};${geocodeResult.coordinates.lng}`,
|
||||||
geocoded_address: geocodeResult.formattedAddress || address,
|
geocoded_address: geocodeResult.formattedAddress || address,
|
||||||
geocode_success: true,
|
geocode_success: true,
|
||||||
geocode_status: 'SUCCESS',
|
geocode_status: isMalformed ? 'WARNING' : 'SUCCESS',
|
||||||
geocode_error: '',
|
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);
|
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 = {
|
const successMessage = {
|
||||||
type: 'geocoded',
|
type: 'geocoded',
|
||||||
data: processedRow,
|
data: processedRow,
|
||||||
index: i
|
index: i,
|
||||||
|
status: isMalformed ? 'warning' : 'success',
|
||||||
|
confidence: confidence,
|
||||||
|
warnings: warnings
|
||||||
};
|
};
|
||||||
const successJson = JSON.stringify(successMessage);
|
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.write(`data: ${successJson}\n\n`);
|
||||||
res.flush && res.flush(); // Ensure data is sent immediately
|
res.flush && res.flush();
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Geocoding failed - no coordinates returned');
|
throw new Error('Geocoding failed - no coordinates returned');
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} 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
|
// Create error row with original data plus error info
|
||||||
const errorRow = {
|
const errorRow = {
|
||||||
@ -135,10 +205,14 @@ class DataConvertController {
|
|||||||
geocode_success: false,
|
geocode_success: false,
|
||||||
geocode_status: 'FAILED',
|
geocode_status: 'FAILED',
|
||||||
geocode_error: error.message,
|
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 = {
|
const errorData = {
|
||||||
index: i,
|
index: i,
|
||||||
@ -163,14 +237,21 @@ class DataConvertController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Store processing results for report generation
|
// 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, {
|
processingResults.set(sessionId, {
|
||||||
filename: originalFilename,
|
filename: originalFilename,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
allResults: allResults,
|
allResults: allResults,
|
||||||
summary: {
|
summary: {
|
||||||
total: total,
|
total: total,
|
||||||
successful: processedData.length,
|
successful: successful,
|
||||||
failed: errors.length
|
warnings: warnings,
|
||||||
|
failed: failed,
|
||||||
|
malformed: malformed
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -178,7 +259,10 @@ class DataConvertController {
|
|||||||
const completeMessage = {
|
const completeMessage = {
|
||||||
type: 'complete',
|
type: 'complete',
|
||||||
processed: processedData.length,
|
processed: processedData.length,
|
||||||
|
successful: successful,
|
||||||
|
warnings: warnings,
|
||||||
errors: errors.length,
|
errors: errors.length,
|
||||||
|
malformed: malformed,
|
||||||
total: total,
|
total: total,
|
||||||
sessionId: sessionId // Include session ID for report download
|
sessionId: sessionId // Include session ID for report download
|
||||||
};
|
};
|
||||||
@ -382,18 +466,18 @@ class DataConvertController {
|
|||||||
const results = processingResults.get(sessionId);
|
const results = processingResults.get(sessionId);
|
||||||
const { filename, timestamp, allResults, summary } = results;
|
const { filename, timestamp, allResults, summary } = results;
|
||||||
|
|
||||||
// Convert results to CSV format
|
// Generate comprehensive report content
|
||||||
const csvContent = this.generateReportCSV(allResults, filename, timestamp, summary);
|
const reportContent = this.generateComprehensiveReport(allResults, filename, timestamp, summary);
|
||||||
|
|
||||||
// Set headers for CSV download
|
// Set headers for text download
|
||||||
const reportFilename = `geocoding-report-${sessionId}.csv`;
|
const reportFilename = `geocoding-report-${sessionId}.txt`;
|
||||||
res.setHeader('Content-Type', 'text/csv');
|
res.setHeader('Content-Type', 'text/plain');
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="${reportFilename}"`);
|
res.setHeader('Content-Disposition', `attachment; filename="${reportFilename}"`);
|
||||||
res.setHeader('Cache-Control', 'no-cache');
|
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)
|
// Clean up stored results after download (optional)
|
||||||
setTimeout(() => {
|
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
|
// Generate CSV content for the report
|
||||||
generateReportCSV(allResults, originalFilename, timestamp, summary) {
|
generateReportCSV(allResults, originalFilename, timestamp, summary) {
|
||||||
if (!allResults || allResults.length === 0) {
|
if (!allResults || allResults.length === 0) {
|
||||||
|
|||||||
@ -1066,6 +1066,16 @@
|
|||||||
<h2>Convert Data</h2>
|
<h2>Convert Data</h2>
|
||||||
<p>Upload a CSV file containing addresses to geocode and import into the map.</p>
|
<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="data-convert-container">
|
||||||
<div class="upload-section" id="upload-section">
|
<div class="upload-section" id="upload-section">
|
||||||
<h3>CSV Upload</h3>
|
<h3>CSV Upload</h3>
|
||||||
@ -1227,6 +1237,7 @@
|
|||||||
<th>Original Address</th>
|
<th>Original Address</th>
|
||||||
<th>Geocoded Address</th>
|
<th>Geocoded Address</th>
|
||||||
<th>Coordinates</th>
|
<th>Coordinates</th>
|
||||||
|
<th>Provider</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="results-tbody"></tbody>
|
<tbody id="results-tbody"></tbody>
|
||||||
|
|||||||
@ -1,6 +1,72 @@
|
|||||||
/* Data Conversion Interface Styles */
|
/* Data Conversion Interface Styles */
|
||||||
/* CSV upload, processing, and results preview components */
|
/* 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 {
|
.data-convert-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -192,11 +258,55 @@
|
|||||||
animation: slideIn 0.3s ease-out;
|
animation: slideIn 0.3s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.result-warning {
|
||||||
|
background: #fff3cd;
|
||||||
|
border-left: 4px solid #ffc107;
|
||||||
|
animation: slideIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
.result-error {
|
.result-error {
|
||||||
background: #f8d7da;
|
background: #f8d7da;
|
||||||
animation: slideIn 0.3s ease-out;
|
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 {
|
.error-message {
|
||||||
color: #721c24;
|
color: #721c24;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
|
|||||||
@ -4,6 +4,83 @@ let resultsMap = null;
|
|||||||
let markers = [];
|
let markers = [];
|
||||||
let eventListenersInitialized = false;
|
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
|
// Utility function to show status messages
|
||||||
function showDataConvertStatus(message, type = 'info') {
|
function showDataConvertStatus(message, type = 'info') {
|
||||||
// Try to use the global showStatus from admin.js if available
|
// Try to use the global showStatus from admin.js if available
|
||||||
@ -49,6 +126,9 @@ function setupDataConvertEventListeners() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check geocoding provider status when section loads
|
||||||
|
checkGeocodingProviders();
|
||||||
|
|
||||||
const fileInput = document.getElementById('csv-file-input');
|
const fileInput = document.getElementById('csv-file-input');
|
||||||
const browseBtn = document.getElementById('browse-btn');
|
const browseBtn = document.getElementById('browse-btn');
|
||||||
const uploadArea = document.getElementById('upload-area');
|
const uploadArea = document.getElementById('upload-area');
|
||||||
@ -290,15 +370,36 @@ function handleProcessingUpdate(data) {
|
|||||||
|
|
||||||
case 'progress':
|
case 'progress':
|
||||||
updateProgress(data.current, data.total);
|
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;
|
break;
|
||||||
|
|
||||||
case 'geocoded':
|
case 'geocoded':
|
||||||
// Mark as successful and add to processing data
|
// Check if result has warnings
|
||||||
const successData = { ...data.data, geocode_success: true };
|
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);
|
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);
|
updateProgress(data.index + 1, data.total);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@ -312,6 +413,20 @@ function handleProcessingUpdate(data) {
|
|||||||
case 'complete':
|
case 'complete':
|
||||||
console.log('Received complete event:', data);
|
console.log('Received complete event:', data);
|
||||||
currentSessionId = data.sessionId; // Store session ID for report download
|
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);
|
onProcessingComplete(data);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@ -351,38 +466,85 @@ function initializeResultsMap() {
|
|||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addResultToTable(data, status) {
|
function addResultToTable(data, status, confidence = null, warnings = []) {
|
||||||
const tbody = document.getElementById('results-tbody');
|
const tbody = document.getElementById('results-tbody');
|
||||||
const row = document.createElement('tr');
|
const row = document.createElement('tr');
|
||||||
row.className = status === 'success' ? 'result-success' : 'result-error';
|
|
||||||
|
|
||||||
|
// Set row class based on status
|
||||||
if (status === 'success') {
|
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 = `
|
row.innerHTML = `
|
||||||
<td><span class="status-icon success">✓</span></td>
|
<td>${statusIcon}</td>
|
||||||
<td>${escapeHtml(data.address || data.Address || '')}</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.latitude.toFixed(6)}, ${data.longitude.toFixed(6)}</td>
|
||||||
|
<td>${data.provider ? escapeHtml(data.provider) : 'N/A'}</td>
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<td><span class="status-icon error">✗</span></td>
|
<td><span class="status-icon error">✗</span></td>
|
||||||
<td>${escapeHtml(data.address || '')}</td>
|
<td>${escapeHtml(data.address || data.Address || '')}</td>
|
||||||
<td colspan="2" class="error-message">${escapeHtml(data.error || 'Geocoding failed')}</td>
|
<td colspan="3" class="error-message">${escapeHtml(data.geocode_error || data.error || 'Geocoding failed')}</td>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody.appendChild(row);
|
tbody.appendChild(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addMarkerToMap(data) {
|
function addMarkerToMap(data, isWarning = false) {
|
||||||
if (!resultsMap || !data.latitude || !data.longitude) return;
|
if (!resultsMap || !data.latitude || !data.longitude) return;
|
||||||
|
|
||||||
const marker = L.marker([data.latitude, data.longitude])
|
// Choose marker color based on status
|
||||||
.bindPopup(`
|
const markerColor = isWarning ? 'orange' : 'green';
|
||||||
<strong>${escapeHtml(data.geocoded_address || data.address)}</strong><br>
|
|
||||||
${data.latitude.toFixed(6)}, ${data.longitude.toFixed(6)}
|
|
||||||
`);
|
|
||||||
|
|
||||||
|
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);
|
marker.addTo(resultsMap);
|
||||||
markers.push(marker);
|
markers.push(marker);
|
||||||
|
|
||||||
@ -396,11 +558,9 @@ function addMarkerToMap(data) {
|
|||||||
function onProcessingComplete(data) {
|
function onProcessingComplete(data) {
|
||||||
console.log('Processing complete called with data:', data);
|
console.log('Processing complete called with data:', data);
|
||||||
|
|
||||||
document.getElementById('current-address').textContent =
|
const processedCount = data.processed || data.successful || processingData.filter(item => item.geocode_success !== false).length;
|
||||||
`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;
|
|
||||||
|
|
||||||
|
// Show comprehensive processing actions with download option
|
||||||
if (processedCount > 0) {
|
if (processedCount > 0) {
|
||||||
console.log('Showing processing actions for', processedCount, 'successful items');
|
console.log('Showing processing actions for', processedCount, 'successful items');
|
||||||
const actionsDiv = document.getElementById('processing-actions');
|
const actionsDiv = document.getElementById('processing-actions');
|
||||||
@ -412,17 +572,105 @@ function onProcessingComplete(data) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show download report button if we have a session ID
|
// Add download report button if session ID is available
|
||||||
if (currentSessionId && data.total > 0) {
|
if (currentSessionId) {
|
||||||
showDownloadReportButton(data.total, data.errors || data.failed || 0);
|
showDownloadReportButton(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
const errorCount = data.errors || data.failed || 0;
|
// Update final message with detailed statistics
|
||||||
showDataConvertStatus(`Processing complete: ${processedCount} successful, ${errorCount} errors`,
|
let finalMessage = `Geocoding Complete!\n\n`;
|
||||||
errorCount > 0 ? 'warning' : 'success');
|
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() {
|
async function saveGeocodedResults() {
|
||||||
const successfulData = processingData.filter(item => item.geocode_success !== false && item.latitude && item.longitude);
|
const successfulData = processingData.filter(item => item.geocode_success !== false && item.latitude && item.longitude);
|
||||||
|
|
||||||
|
|||||||
@ -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 geocoding cache statistics (admin endpoint)
|
||||||
* GET /api/geocode/cache/stats
|
* GET /api/geocode/cache/stats
|
||||||
|
|||||||
@ -30,11 +30,586 @@ setInterval(() => {
|
|||||||
}, 60 * 60 * 1000); // Run every hour
|
}, 60 * 60 * 1000); // Run every hour
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reverse geocode coordinates to get address
|
* Multi-provider geocoding configuration
|
||||||
* @param {number} lat - Latitude
|
* Providers are tried in order until one succeeds with good confidence
|
||||||
* @param {number} lng - Longitude
|
|
||||||
* @returns {Promise<Object>} Geocoding result
|
|
||||||
*/
|
*/
|
||||||
|
// 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) {
|
async function reverseGeocode(lat, lng) {
|
||||||
// Create cache key - use full precision
|
// Create cache key - use full precision
|
||||||
const cacheKey = `${lat},${lng}`;
|
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
|
* @param {string} address - Address to geocode
|
||||||
* @returns {Promise<Object>} Geocoding result
|
* @returns {Promise<Object>} Geocoding result with provider info
|
||||||
*/
|
*/
|
||||||
async function forwardGeocode(address) {
|
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
|
// Create cache key
|
||||||
const cacheKey = `addr:${address.toLowerCase()}`;
|
const cacheKey = `addr:${address.toLowerCase()}`;
|
||||||
|
|
||||||
@ -183,58 +853,138 @@ async function forwardGeocode(address) {
|
|||||||
return cached.data;
|
return cached.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Try different address format variations
|
||||||
// Add delay to respect rate limits - increase delay for batch processing
|
const addressVariations = [
|
||||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
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}`);
|
for (let varIndex = 0; varIndex < addressVariations.length; varIndex++) {
|
||||||
|
const addressVariation = addressVariations[varIndex];
|
||||||
const response = await axios.get('https://nominatim.openstreetmap.org/search', {
|
|
||||||
params: {
|
try {
|
||||||
format: 'json',
|
logger.info(`${provider.name} - attempt ${varIndex + 1}/${addressVariations.length}: ${addressVariation}`);
|
||||||
q: address,
|
|
||||||
limit: 1,
|
const providerResult = await providerFunctions[provider.name](addressVariation, provider.options);
|
||||||
addressdetails: 1,
|
|
||||||
'accept-language': 'en'
|
if (!providerResult) {
|
||||||
},
|
continue; // No results from this provider/variation
|
||||||
headers: {
|
}
|
||||||
'User-Agent': 'NocoDB Map Viewer 1.0 (contact@example.com)'
|
|
||||||
},
|
// Convert to standard format
|
||||||
timeout: 15000 // Increase timeout to 15 seconds
|
const result = {
|
||||||
});
|
fullAddress: providerResult.formattedAddress,
|
||||||
|
formattedAddress: providerResult.formattedAddress,
|
||||||
if (!response.data || response.data.length === 0) {
|
components: providerResult.components,
|
||||||
throw new Error('No results found');
|
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
|
// If we found a good result with this provider, we can stop trying other providers
|
||||||
const result = processGeocodeResponse(response.data[0]);
|
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
|
// Cache the result
|
||||||
geocodeCache.set(cacheKey, {
|
geocodeCache.set(cacheKey, {
|
||||||
data: result,
|
data: bestResult,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return bestResult;
|
||||||
|
|
||||||
} 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}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user