diff --git a/nocodb-map-viewer/app/Dockerfile b/nocodb-map-viewer/app/Dockerfile index f220609..a4200cd 100644 --- a/nocodb-map-viewer/app/Dockerfile +++ b/nocodb-map-viewer/app/Dockerfile @@ -24,6 +24,8 @@ COPY --from=builder /app/node_modules ./node_modules COPY package*.json ./ COPY server.js ./ COPY public ./public +COPY routes ./routes +COPY services ./services # Create non-root user RUN addgroup -g 1001 -S nodejs && \ diff --git a/nocodb-map-viewer/app/public/js/map.js b/nocodb-map-viewer/app/public/js/map.js index d83d08b..fd825c7 100644 --- a/nocodb-map-viewer/app/public/js/map.js +++ b/nocodb-map-viewer/app/public/js/map.js @@ -579,8 +579,11 @@ function showAddLocationModal(lat, lng) { addressInput.value = result.formattedAddress || result.fullAddress; } else { addressInput.value = ''; // Clear if lookup fails - showStatus('Could not fetch address automatically', 'warning'); + // Don't show warning for automatic lookups } + }).catch(error => { + console.error('Address lookup failed:', error); + addressInput.value = ''; }); // Focus on first name input @@ -633,7 +636,12 @@ function editLocation(locationId) { addressInput.value = result.formattedAddress || result.fullAddress; } else if (!location['Address']) { addressInput.value = ''; + // Don't show error - just silently fail } + }).catch(error => { + // Handle any unexpected errors + console.error('Address lookup failed:', error); + addressInput.value = ''; }); } @@ -923,53 +931,20 @@ window.addEventListener('beforeunload', () => { // Reverse geocode to get address from coordinates async function reverseGeocode(lat, lng) { try { - // Add a small delay to respect rate limits - await new Promise(resolve => setTimeout(resolve, 1000)); - - const response = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&zoom=18&addressdetails=1`, { - headers: { - 'User-Agent': 'NocoDB Map Viewer 1.0' - } - }); + const response = await fetch(`/api/geocode/reverse?lat=${lat}&lng=${lng}`); if (!response.ok) { - throw new Error('Geocoding service unavailable'); + const error = await response.json(); + throw new Error(error.error || 'Geocoding service unavailable'); } - const data = await response.json(); + const result = await response.json(); - if (data.error) { - throw new Error(data.error); + if (!result.success || !result.data) { + throw new Error('Geocoding failed'); } - // Format the address from the response - const address = data.display_name || ''; - - // You can also extract specific components if needed - const addressComponents = { - house_number: data.address?.house_number || '', - road: data.address?.road || '', - suburb: data.address?.suburb || data.address?.neighbourhood || '', - city: data.address?.city || data.address?.town || data.address?.village || '', - state: data.address?.state || '', - postcode: data.address?.postcode || '', - country: data.address?.country || '' - }; - - // Create a formatted address string - 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 { - fullAddress: address, - formattedAddress: formattedAddress.trim().replace(/,$/, ''), - components: addressComponents - }; + return result.data; } catch (error) { console.error('Reverse geocoding error:', error); @@ -977,45 +952,73 @@ async function reverseGeocode(lat, lng) { } } +// Add a new function for forward geocoding (address to coordinates) +async function forwardGeocode(address) { + try { + const response = await fetch(`/api/geocode/forward?address=${encodeURIComponent(address)}`); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Geocoding service unavailable'); + } + + const result = await response.json(); + + if (!result.success || !result.data) { + throw new Error('Geocoding failed'); + } + + return result.data; + + } catch (error) { + console.error('Forward geocoding error:', error); + return null; + } +} + // Manual address lookup for add form -function lookupAddressForAdd() { - const lat = parseFloat(document.getElementById('location-lat').value); - const lng = parseFloat(document.getElementById('location-lng').value); +async function lookupAddressForAdd() { + const latInput = document.getElementById('location-lat'); + const lngInput = document.getElementById('location-lng'); const addressInput = document.getElementById('location-address'); + const lat = parseFloat(latInput.value); + const lng = parseFloat(lngInput.value); + if (!isNaN(lat) && !isNaN(lng)) { addressInput.value = 'Looking up address...'; - reverseGeocode(lat, lng).then(result => { - if (result) { - addressInput.value = result.formattedAddress || result.fullAddress; - showStatus('Address found!', 'success'); - } else { - addressInput.value = ''; - showStatus('Could not find address for these coordinates', 'error'); - } - }); + const result = await reverseGeocode(lat, lng); + if (result) { + addressInput.value = result.formattedAddress || result.fullAddress; + showStatus('Address found!', 'success'); + } else { + addressInput.value = ''; + showStatus('Could not find address for these coordinates', 'warning'); + } } else { showStatus('Please enter valid coordinates first', 'warning'); } } // Manual address lookup for edit form -function lookupAddressForEdit() { - const lat = parseFloat(document.getElementById('edit-location-lat').value); - const lng = parseFloat(document.getElementById('edit-location-lng').value); +async function lookupAddressForEdit() { + const latInput = document.getElementById('edit-location-lat'); + const lngInput = document.getElementById('edit-location-lng'); const addressInput = document.getElementById('edit-location-address'); + const lat = parseFloat(latInput.value); + const lng = parseFloat(lngInput.value); + if (!isNaN(lat) && !isNaN(lng)) { addressInput.value = 'Looking up address...'; - reverseGeocode(lat, lng).then(result => { - if (result) { - addressInput.value = result.formattedAddress || result.fullAddress; - showStatus('Address found!', 'success'); - } else { - addressInput.value = ''; - showStatus('Could not find address for these coordinates', 'error'); - } - }); + const result = await reverseGeocode(lat, lng); + if (result) { + addressInput.value = result.formattedAddress || result.fullAddress; + showStatus('Address found!', 'success'); + } else { + addressInput.value = ''; + showStatus('Could not find address for these coordinates', 'warning'); + } } else { showStatus('Please enter valid coordinates first', 'warning'); } diff --git a/nocodb-map-viewer/app/routes/geocoding.js b/nocodb-map-viewer/app/routes/geocoding.js new file mode 100644 index 0000000..3d1347d --- /dev/null +++ b/nocodb-map-viewer/app/routes/geocoding.js @@ -0,0 +1,113 @@ +const express = require('express'); +const router = express.Router(); +const rateLimit = require('express-rate-limit'); +const { reverseGeocode, forwardGeocode, getCacheStats } = require('../services/geocoding'); + +// Rate limiter specifically for geocoding endpoints +const geocodeLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 30, // limit each IP to 30 requests per windowMs + message: 'Too many geocoding requests, please try again later.' +}); + +/** + * Reverse geocode endpoint + * GET /api/geocode/reverse?lat=&lng= + */ +router.get('/reverse', geocodeLimiter, async (req, res) => { + try { + const { lat, lng } = req.query; + + // Validate input + if (!lat || !lng) { + return res.status(400).json({ + success: false, + error: 'Latitude and longitude are required' + }); + } + + const latitude = parseFloat(lat); + const longitude = parseFloat(lng); + + if (isNaN(latitude) || isNaN(longitude)) { + return res.status(400).json({ + success: false, + error: 'Invalid latitude or longitude' + }); + } + + if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) { + return res.status(400).json({ + success: false, + error: 'Coordinates out of range' + }); + } + + // Perform reverse geocoding + const result = await reverseGeocode(latitude, longitude); + + res.json({ + success: true, + data: result + }); + + } catch (error) { + console.error('Reverse geocoding error:', error); + + const statusCode = error.message.includes('Rate limit') ? 429 : 500; + + res.status(statusCode).json({ + success: false, + error: error.message + }); + } +}); + +/** + * Forward geocode endpoint + * GET /api/geocode/forward?address=
+ */ +router.get('/forward', geocodeLimiter, async (req, res) => { + try { + const { address } = req.query; + + // Validate input + if (!address || address.trim().length === 0) { + return res.status(400).json({ + success: false, + error: 'Address is required' + }); + } + + // Perform forward geocoding + const result = await forwardGeocode(address); + + res.json({ + success: true, + data: result + }); + + } catch (error) { + console.error('Forward geocoding error:', error); + + const statusCode = error.message.includes('Rate limit') ? 429 : 500; + + res.status(statusCode).json({ + success: false, + error: error.message + }); + } +}); + +/** + * Get geocoding cache statistics (admin endpoint) + * GET /api/geocode/cache/stats + */ +router.get('/cache/stats', (req, res) => { + res.json({ + success: true, + data: getCacheStats() + }); +}); + +module.exports = router; diff --git a/nocodb-map-viewer/app/server.js b/nocodb-map-viewer/app/server.js index ce2f952..adc7b70 100644 --- a/nocodb-map-viewer/app/server.js +++ b/nocodb-map-viewer/app/server.js @@ -7,6 +7,9 @@ const winston = require('winston'); const path = require('path'); require('dotenv').config(); +// Import geocoding routes +const geocodingRoutes = require('./routes/geocoding'); + // Parse project and table IDs from view URL function parseNocoDBUrl(url) { if (!url) return { projectId: null, tableId: null }; @@ -142,6 +145,9 @@ app.use(express.json({ limit: '10mb' })); app.use(express.static(path.join(__dirname, 'public'))); app.use('/api/', limiter); +// Add geocoding routes +app.use('/api/geocode', geocodingRoutes); + // Health check endpoint app.get('/health', (req, res) => { res.json({ diff --git a/nocodb-map-viewer/app/services/geocoding.js b/nocodb-map-viewer/app/services/geocoding.js new file mode 100644 index 0000000..d26a6f1 --- /dev/null +++ b/nocodb-map-viewer/app/services/geocoding.js @@ -0,0 +1,231 @@ +const axios = require('axios'); +const winston = require('winston'); + +// Configure logger +const logger = winston.createLogger({ + level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', + format: winston.format.combine( + winston.format.timestamp(), + winston.format.json() + ), + transports: [ + new winston.transports.Console({ + format: winston.format.simple() + }) + ] +}); + +// Cache for geocoding results (simple in-memory cache) +const geocodeCache = new Map(); +const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours + +// Clean up old cache entries periodically +setInterval(() => { + const now = Date.now(); + for (const [key, value] of geocodeCache.entries()) { + if (now - value.timestamp > CACHE_TTL) { + geocodeCache.delete(key); + } + } +}, 60 * 60 * 1000); // Run every hour + +/** + * Reverse geocode coordinates to get address + * @param {number} lat - Latitude + * @param {number} lng - Longitude + * @returns {Promise} Geocoding result + */ +async function reverseGeocode(lat, lng) { + // Create cache key + const cacheKey = `${lat.toFixed(6)},${lng.toFixed(6)}`; + + // Check cache first + const cached = geocodeCache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + logger.debug(`Geocoding cache hit for ${cacheKey}`); + return cached.data; + } + + try { + // Add delay to respect Nominatim rate limits (max 1 request per second) + await new Promise(resolve => setTimeout(resolve, 1000)); + + logger.info(`Reverse geocoding: ${lat}, ${lng}`); + + const response = await axios.get('https://nominatim.openstreetmap.org/reverse', { + params: { + format: 'json', + lat: lat, + lon: lng, + zoom: 18, + addressdetails: 1, + 'accept-language': 'en' + }, + headers: { + 'User-Agent': 'NocoDB Map Viewer 1.0 (https://github.com/yourusername/nocodb-map-viewer)' + }, + timeout: 10000 + }); + + if (response.data.error) { + throw new Error(response.data.error); + } + + // Process the response + const result = processGeocodeResponse(response.data); + + // Cache the result + geocodeCache.set(cacheKey, { + data: result, + timestamp: Date.now() + }); + + return result; + + } catch (error) { + logger.error('Reverse geocoding error:', error.message); + + if (error.response?.status === 429) { + throw new Error('Rate limit exceeded. Please try again later.'); + } else if (error.code === 'ECONNABORTED') { + throw new Error('Geocoding service timeout'); + } else { + throw new Error('Geocoding service unavailable'); + } + } +} + +/** + * Forward geocode address to get coordinates + * @param {string} address - Address to geocode + * @returns {Promise} Geocoding result + */ +async function forwardGeocode(address) { + // Create cache key + const cacheKey = `addr:${address.toLowerCase()}`; + + // Check cache first + const cached = geocodeCache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + logger.debug(`Geocoding cache hit for ${cacheKey}`); + return cached.data; + } + + try { + // Add delay to respect rate limits + await new Promise(resolve => setTimeout(resolve, 1000)); + + 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 (https://github.com/yourusername/nocodb-map-viewer)' + }, + timeout: 10000 + }); + + if (!response.data || response.data.length === 0) { + throw new Error('No results found'); + } + + // Process the first result + const result = processGeocodeResponse(response.data[0]); + + // Cache the result + geocodeCache.set(cacheKey, { + data: result, + 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.code === 'ECONNABORTED') { + throw new Error('Geocoding service timeout'); + } else { + throw new Error('Geocoding service unavailable'); + } + } +} + +/** + * Process geocoding response into standardized format + * @param {Object} data - Raw geocoding response + * @returns {Object} Processed geocoding data + */ +function processGeocodeResponse(data) { + // Extract address components + const addressComponents = { + house_number: data.address?.house_number || '', + road: data.address?.road || '', + suburb: data.address?.suburb || data.address?.neighbourhood || '', + city: data.address?.city || data.address?.town || data.address?.village || '', + state: data.address?.state || data.address?.province || '', + postcode: data.address?.postcode || '', + country: data.address?.country || '' + }; + + // Create formatted address string + 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; + + // Clean up formatting + formattedAddress = formattedAddress.trim().replace(/,$/, ''); + + return { + fullAddress: data.display_name || '', + formattedAddress: formattedAddress, + components: addressComponents, + coordinates: { + lat: parseFloat(data.lat), + lng: parseFloat(data.lon) + }, + boundingBox: data.boundingbox || null, + placeId: data.place_id || null, + osmType: data.osm_type || null, + osmId: data.osm_id || null + }; +} + +/** + * Get cache statistics + * @returns {Object} Cache statistics + */ +function getCacheStats() { + return { + size: geocodeCache.size, + maxSize: 1000, // Could be made configurable + ttl: CACHE_TTL + }; +} + +/** + * Clear the geocoding cache + */ +function clearCache() { + geocodeCache.clear(); + logger.info('Geocoding cache cleared'); +} + +module.exports = { + reverseGeocode, + forwardGeocode, + getCacheStats, + clearCache +};