# NocoDB Map Viewer - Complete Implementation Guide ## Table of Contents 1. [Executive Summary](#executive-summary) 2. [Technical Architecture](#technical-architecture) 3. [Prerequisites & Requirements](#prerequisites--requirements) 4. [NocoDB Configuration](#nocodb-configuration) 5. [Project Implementation](#project-implementation) 6. [API Reference](#api-reference) 7. [Deployment Guide](#deployment-guide) 8. [Testing & Validation](#testing--validation) 9. [Troubleshooting](#troubleshooting) 10. [Security Best Practices](#security-best-practices) 11. [Future Enhancements](#future-enhancements) --- ## Executive Summary This document provides a complete implementation guide for building a containerized web application that visualizes geographic data from NocoDB on an interactive map. The solution uses open-source technologies exclusively and allows users to connect any NocoDB table containing properly formatted location data. ### Key Features - Real-time map visualization using Leaflet.js - Geolocation support with user position tracking - Ability to add new locations directly from the map - API-driven architecture for flexibility - Docker containerization for easy deployment - No proprietary dependencies (fully FOSS) ### Use Cases - Field data collection - Asset tracking - Location-based surveys - Geographic data visualization - Point of interest mapping --- ## Technical Architecture ### System Overview ``` ┌─────────────────────────────────────────────────────────────────┐ │ User Browser │ │ ┌─────────────────┐ ┌──────────────┐ ┌──────────────────┐ │ │ │ │ │ │ │ │ │ │ │ Leaflet.js │ │ JavaScript │ │ Geolocation │ │ │ │ Map Engine │ │ Application │ │ API │ │ │ │ │ │ │ │ │ │ │ └────────┬────────┘ └──────┬───────┘ └────────┬─────────┘ │ │ │ │ │ │ └───────────┼───────────────────┼────────────────────┼─────────────┘ │ │ │ ▼ ▼ ▼ ┌───────────────────────────────────────────────────────────────┐ │ Express.js Server (Port 3000) │ │ ┌─────────────────┐ ┌──────────────┐ ┌──────────────────┐ │ │ │ │ │ │ │ │ │ │ │ Static File │ │ API Proxy │ │ CORS & Security │ │ │ │ Serving │ │ Endpoints │ │ Middleware │ │ │ │ │ │ │ │ │ │ │ └─────────────────┘ └──────┬───────┘ └──────────────────┘ │ └───────────────────────────────┼────────────────────────────────┘ │ ▼ ┌───────────────────────┐ │ │ │ NocoDB Instance │ │ (External API) │ │ │ └───────────────────────┘ ``` ### Technology Stack | Component | Technology | Version | Purpose | |-----------|------------|---------|---------| | Frontend Framework | Leaflet.js | 1.9.4 | Interactive mapping | | Map Tiles | OpenStreetMap | Latest | Free map tile provider | | Backend Runtime | Node.js | 18 LTS | Server-side JavaScript | | Web Framework | Express.js | 4.18.x | HTTP server and routing | | HTTP Client | Axios | 1.6.x | API communication | | Container Platform | Docker | Latest | Application containerization | | Environment Config | dotenv | 16.3.x | Configuration management | ### Data Flow 1. User interacts with web interface 2. JavaScript sends requests to Express server 3. Express server proxies requests to NocoDB API 4. NocoDB returns data from PostgreSQL/MySQL/SQLite 5. Server formats and returns data to client 6. Leaflet.js renders locations on map --- ## Prerequisites & Requirements ### Developer Requirements - Docker and Docker Compose installed - Basic knowledge of: - JavaScript/Node.js - REST APIs - Docker containers - Geographic coordinates (latitude/longitude) - Text editor or IDE - Git (optional, for version control) ### System Requirements - **Minimum RAM**: 2GB - **Disk Space**: 1GB for application + data - **Network**: Internet connection for map tiles - **Ports**: 3000 (configurable) - **OS**: Linux, macOS, or Windows with WSL2 ### NocoDB Requirements - NocoDB instance (self-hosted or cloud) - Admin access to create tables and API tokens - Table with specific column structure (detailed below) --- ## NocoDB Configuration ### Step 1: Create Table Structure Users must create a table in NocoDB with these **exact** column names and types: #### Required Columns 1. **geodata** (Text/String Field) - **Purpose**: Stores combined coordinates - **Format**: `"latitude;longitude"` (semicolon-separated) - **Example**: `"53.5461;-113.4938"` - **Database Types**: - MySQL: `VARCHAR(255)`, `TEXT` - PostgreSQL: `VARCHAR(255)`, `TEXT` - SQLite: `TEXT` 2. **latitude** (Decimal Field) - **Purpose**: Stores latitude coordinate - **Configuration**: - Precision: 10 - Scale: 8 - Range: -90.00000000 to 90.00000000 - **Example**: `53.54610000` 3. **longitude** (Decimal Field) - **Purpose**: Stores longitude coordinate - **Configuration**: - Precision: 11 - Scale: 8 - Range: -180.00000000 to 180.00000000 - **Example**: `-113.49380000` #### Optional Recommended Columns - **title** (String): Location name or identifier - **description** (Long Text): Detailed information - **category** (Single Select): Classification - **created_at** (DateTime): Timestamp - **user_id** (String): Who added the location ### Step 2: Configure Column Properties in NocoDB 1. **Navigate to your table** in NocoDB 2. **For geodata column**: - Click "+" to add field - Select "SingleLineText" or "LongText" - Name: `geodata` (exactly as shown) 3. **For latitude column**: - Click "+" to add field - Select "Decimal" - Name: `latitude` (exactly as shown) - Precision: 10 - Scale: 8 - Validation: Min -90, Max 90 4. **For longitude column**: - Click "+" to add field - Select "Decimal" - Name: `longitude` (exactly as shown) - Precision: 11 - Scale: 8 - Validation: Min -180, Max 180 ### Step 3: Obtain API Credentials 1. **Get API Token**: - Click on user icon (top right) - Go to "Account Settings" - Navigate to "API Tokens" tab - Click "Add New Token" - Give it a name (e.g., "Map Viewer") - Copy the generated token immediately 2. **Find Project ID**: - Open your project in NocoDB - Look at the URL: `https://app.nocodb.com/#/nc/[PROJECT_ID]/...` - Copy the PROJECT_ID portion 3. **Find Table ID**: - Open your table - Look at the URL: `.../[PROJECT_ID]/[TABLE_ID]` - Copy the TABLE_ID portion 4. **Determine API URL**: - For NocoDB Cloud: `https://app.nocodb.com/api/v1` - For self-hosted: `https://your-domain.com/api/v1` --- ## Project Implementation ### Complete File Structure ``` nocodb-map-viewer/ ├── docker-compose.yml ├── .env.example ├── .env # Create from .env.example ├── README.md ├── .gitignore └── app/ ├── Dockerfile ├── package.json ├── package-lock.json # Generated after npm install ├── server.js └── public/ ├── index.html ├── favicon.ico # Optional ├── css/ │ └── style.css └── js/ └── map.js ``` ### File Contents #### 1. `.env.example` ```env # NocoDB API Configuration NOCODB_API_URL=https://app.nocodb.com/api/v1 NOCODB_API_TOKEN=your-api-token-here NOCODB_PROJECT_ID=p_xxxxxxxxxxxxx NOCODB_TABLE_ID=md_xxxxxxxxxxxxx # Server Configuration PORT=3000 NODE_ENV=production # Map Defaults (Edmonton, Alberta, Canada) DEFAULT_LAT=53.5461 DEFAULT_LNG=-113.4938 DEFAULT_ZOOM=11 # Optional: Map Boundaries (prevents users from adding points outside area) # BOUND_NORTH=53.7 # BOUND_SOUTH=53.4 # BOUND_EAST=-113.3 # BOUND_WEST=-113.7 ``` #### 2. `.gitignore` ```gitignore # Dependencies node_modules/ npm-debug.log* yarn-debug.log* yarn-error.log* # Environment files .env .env.local .env.*.local # IDE files .vscode/ .idea/ *.swp *.swo *~ # OS files .DS_Store Thumbs.db # Logs logs/ *.log # Build outputs dist/ build/ ``` #### 3. `docker-compose.yml` ```yaml version: '3.8' services: map-viewer: build: context: ./app dockerfile: Dockerfile container_name: nocodb-map-viewer ports: - "${PORT:-3000}:3000" environment: - NODE_ENV=${NODE_ENV:-production} - PORT=${PORT:-3000} env_file: - .env restart: unless-stopped healthcheck: test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/health"] interval: 30s timeout: 10s retries: 3 start_period: 40s logging: driver: "json-file" options: max-size: "10m" max-file: "3" ``` #### 4. `app/Dockerfile` ```dockerfile # Build stage FROM node:18-alpine AS builder WORKDIR /app # Copy package files COPY package*.json ./ # Install dependencies RUN npm ci --only=production && npm cache clean --force # Runtime stage FROM node:18-alpine # Install wget for healthcheck RUN apk add --no-cache wget WORKDIR /app # Copy dependencies from builder COPY --from=builder /app/node_modules ./node_modules # Copy application files COPY package*.json ./ COPY server.js ./ COPY public ./public # Create non-root user RUN addgroup -g 1001 -S nodejs && \ adduser -S nodejs -u 1001 # Change ownership RUN chown -R nodejs:nodejs /app USER nodejs EXPOSE 3000 CMD ["node", "server.js"] ``` #### 5. `app/package.json` ```json { "name": "nocodb-map-viewer", "version": "1.0.0", "description": "Interactive map viewer for NocoDB geographic data", "main": "server.js", "scripts": { "start": "node server.js", "dev": "nodemon server.js", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [ "nocodb", "map", "leaflet", "gis", "location" ], "author": "", "license": "MIT", "dependencies": { "express": "^4.18.2", "axios": "^1.6.0", "cors": "^2.8.5", "dotenv": "^16.3.1", "helmet": "^7.1.0", "express-rate-limit": "^7.1.4", "winston": "^3.11.0" }, "devDependencies": { "nodemon": "^3.0.1" }, "engines": { "node": ">=18.0.0" } } ``` #### 6. `app/server.js` ```javascript const express = require('express'); const axios = require('axios'); const cors = require('cors'); const helmet = require('helmet'); const rateLimit = require('express-rate-limit'); const winston = require('winston'); const path = require('path'); require('dotenv').config(); // 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() }) ] }); // Initialize Express app const app = express(); const PORT = process.env.PORT || 3000; // Security middleware app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com"], scriptSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com"], imgSrc: ["'self'", "data:", "https://*.tile.openstreetmap.org"], connectSrc: ["'self'"] } } })); // CORS configuration app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') || '*', credentials: true })); // Rate limiting const limiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100 // limit each IP to 100 requests per windowMs }); const strictLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20 // limit location creation to 20 per 15 minutes }); // Middleware app.use(express.json({ limit: '10mb' })); app.use(express.static(path.join(__dirname, 'public'))); app.use('/api/', limiter); // Health check endpoint app.get('/health', (req, res) => { res.json({ status: 'healthy', timestamp: new Date().toISOString(), version: process.env.npm_package_version || '1.0.0' }); }); // Configuration validation endpoint (for debugging) app.get('/api/config-check', (req, res) => { const config = { hasApiUrl: !!process.env.NOCODB_API_URL, hasApiToken: !!process.env.NOCODB_API_TOKEN, hasProjectId: !!process.env.NOCODB_PROJECT_ID, hasTableId: !!process.env.NOCODB_TABLE_ID, nodeEnv: process.env.NODE_ENV }; const isConfigured = Object.values(config).every(v => v === true || v === 'production' || v === 'development'); res.json({ configured: isConfigured, ...config }); }); // Get all locations from NocoDB app.get('/api/locations', async (req, res) => { try { const { limit = 1000, offset = 0, where } = req.query; const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${process.env.NOCODB_TABLE_ID}`; const params = new URLSearchParams({ limit, offset }); if (where) { params.append('where', where); } logger.info(`Fetching locations from NocoDB: ${url}`); const response = await axios.get(`${url}?${params}`, { headers: { 'xc-token': process.env.NOCODB_API_TOKEN, 'Content-Type': 'application/json' }, timeout: 10000 // 10 second timeout }); // Process locations to ensure they have required fields const locations = response.data.list || []; const validLocations = locations.filter(loc => { // Check if location has valid coordinates if (loc.latitude && loc.longitude) { return true; } // Try to parse from geodata if lat/lng missing if (loc.geodata && typeof loc.geodata === 'string') { const parts = loc.geodata.split(';'); if (parts.length === 2) { loc.latitude = parseFloat(parts[0]); loc.longitude = parseFloat(parts[1]); return !isNaN(loc.latitude) && !isNaN(loc.longitude); } } return false; }); logger.info(`Retrieved ${validLocations.length} valid locations out of ${locations.length} total`); res.json({ success: true, count: validLocations.length, total: response.data.pageInfo?.totalRows || validLocations.length, locations: validLocations }); } catch (error) { logger.error('Error fetching locations:', error.message); if (error.response) { // NocoDB API error res.status(error.response.status).json({ success: false, error: 'Failed to fetch data from NocoDB', details: error.response.data }); } else if (error.code === 'ECONNABORTED') { // Timeout res.status(504).json({ success: false, error: 'Request timeout' }); } else { // Other errors res.status(500).json({ success: false, error: 'Internal server error' }); } } }); // Get single location by ID app.get('/api/locations/:id', async (req, res) => { try { const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${process.env.NOCODB_TABLE_ID}/${req.params.id}`; const response = await axios.get(url, { headers: { 'xc-token': process.env.NOCODB_API_TOKEN } }); res.json({ success: true, location: response.data }); } catch (error) { logger.error(`Error fetching location ${req.params.id}:`, error.message); res.status(error.response?.status || 500).json({ success: false, error: 'Failed to fetch location' }); } }); // Create new location app.post('/api/locations', strictLimiter, async (req, res) => { try { const { latitude, longitude, ...additionalData } = req.body; // Validate coordinates if (!latitude || !longitude) { return res.status(400).json({ success: false, error: 'Latitude and longitude are required' }); } const lat = parseFloat(latitude); const lng = parseFloat(longitude); if (isNaN(lat) || isNaN(lng)) { return res.status(400).json({ success: false, error: 'Invalid coordinate values' }); } if (lat < -90 || lat > 90) { return res.status(400).json({ success: false, error: 'Latitude must be between -90 and 90' }); } if (lng < -180 || lng > 180) { return res.status(400).json({ success: false, error: 'Longitude must be between -180 and 180' }); } // Check bounds if configured if (process.env.BOUND_NORTH) { const bounds = { north: parseFloat(process.env.BOUND_NORTH), south: parseFloat(process.env.BOUND_SOUTH), east: parseFloat(process.env.BOUND_EAST), west: parseFloat(process.env.BOUND_WEST) }; if (lat > bounds.north || lat < bounds.south || lng > bounds.east || lng < bounds.west) { return res.status(400).json({ success: false, error: 'Location is outside allowed bounds' }); } } // Format geodata const geodata = `${lat};${lng}`; // Prepare data for NocoDB const locationData = { geodata, latitude: lat, longitude: lng, ...additionalData, created_at: new Date().toISOString() }; const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${process.env.NOCODB_TABLE_ID}`; logger.info('Creating new location:', { lat, lng }); const response = await axios.post(url, locationData, { headers: { 'xc-token': process.env.NOCODB_API_TOKEN, 'Content-Type': 'application/json' } }); logger.info('Location created successfully:', response.data.id); res.status(201).json({ success: true, location: response.data }); } catch (error) { logger.error('Error creating location:', error.message); if (error.response) { res.status(error.response.status).json({ success: false, error: 'Failed to save location to NocoDB', details: error.response.data }); } else { res.status(500).json({ success: false, error: 'Internal server error' }); } } }); // Update location app.put('/api/locations/:id', strictLimiter, async (req, res) => { try { const { latitude, longitude, ...additionalData } = req.body; const updateData = { ...additionalData }; // Update geodata if coordinates changed if (latitude !== undefined && longitude !== undefined) { const lat = parseFloat(latitude); const lng = parseFloat(longitude); if (!isNaN(lat) && !isNaN(lng)) { updateData.latitude = lat; updateData.longitude = lng; updateData.geodata = `${lat};${lng}`; } } updateData.updated_at = new Date().toISOString(); const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${process.env.NOCODB_TABLE_ID}/${req.params.id}`; const response = await axios.patch(url, updateData, { headers: { 'xc-token': process.env.NOCODB_API_TOKEN, 'Content-Type': 'application/json' } }); res.json({ success: true, location: response.data }); } catch (error) { logger.error(`Error updating location ${req.params.id}:`, error.message); res.status(error.response?.status || 500).json({ success: false, error: 'Failed to update location' }); } }); // Delete location app.delete('/api/locations/:id', strictLimiter, async (req, res) => { try { const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${process.env.NOCODB_TABLE_ID}/${req.params.id}`; await axios.delete(url, { headers: { 'xc-token': process.env.NOCODB_API_TOKEN } }); logger.info(`Location ${req.params.id} deleted`); res.json({ success: true, message: 'Location deleted successfully' }); } catch (error) { logger.error(`Error deleting location ${req.params.id}:`, error.message); res.status(error.response?.status || 500).json({ success: false, error: 'Failed to delete location' }); } }); // Error handling middleware app.use((err, req, res, next) => { logger.error('Unhandled error:', err); res.status(500).json({ success: false, error: 'Internal server error' }); }); // Start server app.listen(PORT, () => { logger.info(` ╔════════════════════════════════════════╗ ║ NocoDB Map Viewer Server ║ ╠════════════════════════════════════════╣ ║ Status: Running ║ ║ Port: ${PORT} ║ ║ Environment: ${process.env.NODE_ENV || 'development'} ║ ║ Time: ${new Date().toISOString()} ║ ╚════════════════════════════════════════╝ `); }); // Graceful shutdown process.on('SIGTERM', () => { logger.info('SIGTERM signal received: closing HTTP server'); app.close(() => { logger.info('HTTP server closed'); process.exit(0); }); }); ``` #### 7. `app/public/index.html` ```html NocoDB Map Viewer

Location Map Viewer

Loading...

Loading map...

``` #### 8. `app/public/css/style.css` ```css /* CSS Variables for theming */ :root { --primary-color: #2c5aa0; --success-color: #27ae60; --danger-color: #e74c3c; --warning-color: #f39c12; --secondary-color: #95a5a6; --dark-color: #2c3e50; --light-color: #ecf0f1; --border-radius: 4px; --transition: all 0.3s ease; --header-height: 60px; } /* Reset and base styles */ * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.5; color: var(--dark-color); background-color: var(--light-color); } /* App container */ #app { display: flex; flex-direction: column; height: 100vh; position: relative; } /* Header */ .header { height: var(--header-height); background-color: var(--dark-color); color: white; display: flex; align-items: center; justify-content: space-between; padding: 0 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); z-index: 1000; } .header h1 { font-size: 24px; font-weight: 600; } .header-actions { display: flex; align-items: center; gap: 15px; } .location-count { background-color: rgba(255,255,255,0.1); padding: 5px 15px; border-radius: 20px; font-size: 14px; } /* Map container */ #map-container { flex: 1; position: relative; overflow: hidden; } #map { width: 100%; height: 100%; background-color: #f0f0f0; } /* Map controls */ .map-controls { position: absolute; top: 20px; right: 20px; display: flex; flex-direction: column; gap: 10px; z-index: 1000; } /* Buttons */ .btn { padding: 10px 16px; border: none; border-radius: var(--border-radius); font-size: 14px; font-weight: 500; cursor: pointer; transition: var(--transition); display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; outline: none; } .btn:hover { transform: translateY(-1px); box-shadow: 0 4px 8px rgba(0,0,0,0.15); } .btn:active { transform: translateY(0); } .btn-primary { background-color: var(--primary-color); color: white; } .btn-primary:hover { background-color: #2471a3; } .btn-success { background-color: var(--success-color); color: white; } .btn-success:hover { background-color: #229954; } .btn-secondary { background-color: var(--secondary-color); color: white; } .btn-secondary:hover { background-color: #7f8c8d; } /* Crosshair for location selection */ .crosshair { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); pointer-events: none; z-index: 999; } .crosshair.hidden { display: none; } .crosshair-x, .crosshair-y { position: absolute; background-color: rgba(44, 90, 160, 0.8); } .crosshair-x { width: 40px; height: 2px; left: -20px; top: -1px; } .crosshair-y { width: 2px; height: 40px; left: -1px; top: -20px; } .crosshair-info { position: absolute; top: 30px; left: 50%; transform: translateX(-50%); background-color: rgba(44, 62, 80, 0.9); color: white; padding: 5px 10px; border-radius: var(--border-radius); font-size: 12px; white-space: nowrap; } /* Status messages */ .status-container { position: fixed; top: calc(var(--header-height) + 20px); left: 50%; transform: translateX(-50%); z-index: 2000; max-width: 400px; width: 90%; } .status-message { padding: 12px 20px; border-radius: var(--border-radius); margin-bottom: 10px; display: flex; align-items: center; gap: 10px; animation: slideIn 0.3s ease; box-shadow: 0 2px 8px rgba(0,0,0,0.1); } @keyframes slideIn { from { opacity: 0; transform: translateY(-20px); } to { opacity: 1; transform: translateY(0); } } .status-message.success { background-color: var(--success-color); color: white; } .status-message.error { background-color: var(--danger-color); color: white; } .status-message.warning { background-color: var(--warning-color); color: white; } .status-message.info { background-color: var(--primary-color); color: white; } /* Modal */ .modal { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 3000; animation: fadeIn 0.3s ease; } .modal.hidden { display: none; } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } .modal-content { background-color: white; border-radius: var(--border-radius); width: 90%; max-width: 500px; max-height: 90vh; overflow: auto; box-shadow: 0 4px 20px rgba(0,0,0,0.15); animation: slideUp 0.3s ease; } @keyframes slideUp { from { opacity: 0; transform: translateY(50px); } to { opacity: 1; transform: translateY(0); } } .modal-header { padding: 20px; border-bottom: 1px solid #e0e0e0; display: flex; justify-content: space-between; align-items: center; } .modal-header h2 { font-size: 20px; font-weight: 600; color: var(--dark-color); } .modal-close { background: none; border: none; font-size: 24px; cursor: pointer; color: var(--secondary-color); width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; border-radius: 50%; transition: var(--transition); } .modal-close:hover { background-color: var(--light-color); color: var(--dark-color); } .modal-body { padding: 20px; } /* Form styles */ .form-group { margin-bottom: 15px; } .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; } .form-group label { display: block; margin-bottom: 5px; font-weight: 500; color: var(--dark-color); font-size: 14px; } .form-group input, .form-group textarea { width: 100%; padding: 8px 12px; border: 1px solid #ddd; border-radius: var(--border-radius); font-size: 14px; transition: var(--transition); } .form-group input:focus, .form-group textarea:focus { outline: none; border-color: var(--primary-color); box-shadow: 0 0 0 2px rgba(44, 90, 160, 0.1); } .form-group input[readonly] { background-color: #f5f5f5; cursor: not-allowed; } .form-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; padding-top: 20px; border-top: 1px solid #e0e0e0; } /* Loading overlay */ .loading-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(255,255,255,0.9); display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 4000; } .loading-overlay.hidden { display: none; } .spinner { width: 50px; height: 50px; border: 3px solid var(--light-color); border-top-color: var(--primary-color); border-radius: 50%; animation: spin 1s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } .loading-overlay p { margin-top: 20px; color: var(--dark-color); font-size: 16px; } /* Leaflet customizations */ .leaflet-popup-content-wrapper { border-radius: var(--border-radius); box-shadow: 0 3px 10px rgba(0,0,0,0.2); } .leaflet-popup-content { margin: 13px 19px; line-height: 1.5; } .popup-content h3 { margin: 0 0 10px 0; color: var(--dark-color); font-size: 16px; } .popup-content p { margin: 5px 0; color: #666; font-size: 14px; } .popup-content .popup-meta { font-size: 12px; color: #999; margin-top: 10px; padding-top: 10px; border-top: 1px solid #eee; } /* Responsive design */ @media (max-width: 768px) { .header h1 { font-size: 20px; } .map-controls { top: 10px; right: 10px; } .btn { padding: 8px 12px; font-size: 13px; } .modal-content { width: 95%; margin: 10px; } .form-row { grid-template-columns: 1fr; } } /* Fullscreen styles */ .fullscreen #map-container { position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 5000; } .fullscreen .header { display: none; } /* Print styles */ @media print { .header, .map-controls, .status-container, .modal { display: none !important; } #map-container { height: 100vh; } } ``` #### 9. `app/public/js/map.js` ```javascript // Global configuration const CONFIG = { DEFAULT_LAT: parseFloat(document.querySelector('meta[name="default-lat"]')?.content) || 53.5461, DEFAULT_LNG: parseFloat(document.querySelector('meta[name="default-lng"]')?.content) || -113.4938, DEFAULT_ZOOM: parseInt(document.querySelector('meta[name="default-zoom"]')?.content) || 11, REFRESH_INTERVAL: 30000, // 30 seconds MAX_ZOOM: 19, MIN_ZOOM: 2 }; // Application state let map = null; let markers = []; let userLocationMarker = null; let isAddingLocation = false; let refreshInterval = null; // Initialize application when DOM is loaded document.addEventListener('DOMContentLoaded', () => { initializeMap(); loadLocations(); setupEventListeners(); checkConfiguration(); // Set up auto-refresh refreshInterval = setInterval(loadLocations, CONFIG.REFRESH_INTERVAL); }); // Initialize Leaflet map function initializeMap() { // Create map instance map = L.map('map', { center: [CONFIG.DEFAULT_LAT, CONFIG.DEFAULT_LNG], zoom: CONFIG.DEFAULT_ZOOM, zoomControl: true, attributionControl: true }); // Add OpenStreetMap tiles L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors', maxZoom: CONFIG.MAX_ZOOM, minZoom: CONFIG.MIN_ZOOM }).addTo(map); // Add scale control L.control.scale({ position: 'bottomleft', metric: true, imperial: false }).addTo(map); // Hide loading overlay document.getElementById('loading').classList.add('hidden'); } // Set up event listeners function setupEventListeners() { // Geolocation button document.getElementById('geolocate-btn').addEventListener('click', handleGeolocation); // Add location button document.getElementById('add-location-btn').addEventListener('click', toggleAddLocation); // Refresh button document.getElementById('refresh-btn').addEventListener('click', () => { showStatus('Refreshing locations...', 'info'); loadLocations(); }); // Fullscreen button document.getElementById('fullscreen-btn').addEventListener('click', toggleFullscreen); // Form submission document.getElementById('location-form').addEventListener('submit', handleLocationSubmit); // Map click handler for adding locations map.on('click', handleMapClick); } // Check API configuration async function checkConfiguration() { try { const response = await fetch('/api/config-check'); const data = await response.json(); if (!data.configured) { showStatus('Warning: API not fully configured. Check your .env file.', 'warning'); } } catch (error) { console.error('Configuration check failed:', error); } } // Load locations from API async function loadLocations() { try { const response = await fetch('/api/locations'); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); if (data.success) { displayLocations(data.locations); updateLocationCount(data.count); } else { throw new Error(data.error || 'Failed to load locations'); } } catch (error) { console.error('Error loading locations:', error); showStatus('Failed to load locations. Check your connection.', 'error'); updateLocationCount(0); } } // Display locations on map function displayLocations(locations) { // Clear existing markers markers.forEach(marker => map.removeLayer(marker)); markers = []; // Add new markers locations.forEach(location => { if (location.latitude && location.longitude) { const marker = createLocationMarker(location); markers.push(marker); } }); // Fit map to show all markers if there are any if (markers.length > 0) { const group = new L.featureGroup(markers); map.fitBounds(group.getBounds().pad(0.1)); } } // Create marker for location function createLocationMarker(location) { const marker = L.marker([location.latitude, location.longitude], { title: location.title || 'Location', riseOnHover: true }).addTo(map); // Create popup content const popupContent = createPopupContent(location); marker.bindPopup(popupContent); return marker; } // Create popup content for marker function createPopupContent(location) { let content = ''; return content; } // Handle geolocation function handleGeolocation() { if (!navigator.geolocation) { showStatus('Geolocation is not supported by your browser', 'error'); return; } showStatus('Getting your location...', 'info'); navigator.geolocation.getCurrentPosition( (position) => { const { latitude, longitude, accuracy } = position.coords; // Center map on user location map.setView([latitude, longitude], 15); // Remove existing user marker if (userLocationMarker) { map.removeLayer(userLocationMarker); } // Add user location marker userLocationMarker = L.marker([latitude, longitude], { icon: L.divIcon({ html: '
', className: 'user-location-marker', iconSize: [20, 20], iconAnchor: [10, 10] }), title: 'Your location' }).addTo(map); // Add accuracy circle L.circle([latitude, longitude], { radius: accuracy, color: '#2c5aa0', fillColor: '#2c5aa0', fillOpacity: 0.1, weight: 1 }).addTo(map); showStatus(`Location found (±${Math.round(accuracy)}m accuracy)`, 'success'); }, (error) => { let message = 'Unable to get your location'; switch (error.code) { case error.PERMISSION_DENIED: message = 'Location permission denied'; break; case error.POSITION_UNAVAILABLE: message = 'Location information unavailable'; break; case error.TIMEOUT: message = 'Location request timed out'; break; } showStatus(message, 'error'); }, { enableHighAccuracy: true, timeout: 10000, maximumAge: 0 } ); } // Toggle add location mode function toggleAddLocation() { isAddingLocation = !isAddingLocation; const btn = document.getElementById('add-location-btn'); const crosshair = document.getElementById('crosshair'); if (isAddingLocation) { btn.textContent = '✕ Cancel'; btn.classList.remove('btn-success'); btn.classList.add('btn-secondary'); crosshair.classList.remove('hidden'); map.getContainer().style.cursor = 'crosshair'; } else { btn.textContent = '➕ Add Location Here'; btn.classList.remove('btn-secondary'); btn.classList.add('btn-success'); crosshair.classList.add('hidden'); map.getContainer().style.cursor = ''; } } // Handle map click function handleMapClick(e) { if (!isAddingLocation) return; const { lat, lng } = e.latlng; // Toggle off add location mode toggleAddLocation(); // Show modal with coordinates showAddLocationModal(lat, lng); } // Show add location modal function showAddLocationModal(lat, lng) { const modal = document.getElementById('add-modal'); const latInput = document.getElementById('location-lat'); const lngInput = document.getElementById('location-lng'); // Set coordinates latInput.value = lat.toFixed(8); lngInput.value = lng.toFixed(8); // Clear other fields document.getElementById('location-title').value = ''; document.getElementById('location-description').value = ''; document.getElementById('location-category').value = ''; // Show modal modal.classList.remove('hidden'); // Focus on title input setTimeout(() => { document.getElementById('location-title').focus(); }, 100); } // Close modal function closeModal() { document.getElementById('add-modal').classList.add('hidden'); } // Handle location form submission async function handleLocationSubmit(e) { e.preventDefault(); const formData = new FormData(e.target); const data = Object.fromEntries(formData); // Validate required fields if (!data.title || !data.title.trim()) { showStatus('Title is required', 'error'); return; } try { const response = await fetch('/api/locations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); const result = await response.json(); if (response.ok && result.success) { showStatus('Location added successfully!', 'success'); closeModal(); // Reload locations loadLocations(); // Center map on new location map.setView([data.latitude, data.longitude], map.getZoom()); } else { throw new Error(result.error || 'Failed to add location'); } } catch (error) { console.error('Error adding location:', error); showStatus(error.message, 'error'); } } // Toggle fullscreen function toggleFullscreen() { const app = document.getElementById('app'); const btn = document.getElementById('fullscreen-btn'); if (!document.fullscreenElement) { app.requestFullscreen().then(() => { app.classList.add('fullscreen'); btn.textContent = '✕ Exit Fullscreen'; // Invalidate map size after transition setTimeout(() => map.invalidateSize(), 300); }).catch(err => { showStatus('Unable to enter fullscreen', 'error'); }); } else { document.exitFullscreen().then(() => { app.classList.remove('fullscreen'); btn.textContent = '⛶ Fullscreen'; // Invalidate map size after transition setTimeout(() => map.invalidateSize(), 300); }); } } // Update location count function updateLocationCount(count) { const countElement = document.getElementById('location-count'); countElement.textContent = `${count} location${count !== 1 ? 's' : ''}`; } // Show status message function showStatus(message, type = 'info') { const container = document.getElementById('status-container'); const messageDiv = document.createElement('div'); messageDiv.className = `status-message ${type}`; messageDiv.textContent = message; container.appendChild(messageDiv); // Auto-remove after 5 seconds setTimeout(() => { messageDiv.remove(); }, 5000); } // Escape HTML to prevent XSS function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // Handle window resize window.addEventListener('resize', () => { map.invalidateSize(); }); // Clean up on page unload window.addEventListener('beforeunload', () => { if (refreshInterval) { clearInterval(refreshInterval); } }); // Make closeModal function global for onclick handler window.closeModal = closeModal; ``` #### 10. `README.md` ```markdown # NocoDB Map Viewer A containerized web application that visualizes geographic data from NocoDB on an interactive map using Leaflet.js. ## Features - 🗺️ Interactive map visualization with OpenStreetMap - 📍 Real-time geolocation support - ➕ Add new locations directly from the map - 🔄 Auto-refresh every 30 seconds - 📱 Responsive design for mobile devices - 🔒 Secure API proxy to protect credentials - 🐳 Docker containerization for easy deployment - 🆓 100% open source (no proprietary dependencies) ## Quick Start ### Prerequisites - Docker and Docker Compose - NocoDB instance with a table containing location data - NocoDB API token ### NocoDB Table Setup 1. Create a table in NocoDB with these required columns: - `geodata` (Text): Format "latitude;longitude" - `latitude` (Decimal): Precision 10, Scale 8 - `longitude` (Decimal): Precision 11, Scale 8 2. Optional recommended columns: - `title` (Text): Location name - `description` (Long Text): Details - `category` (Single Select): Classification ### Installation 1. Clone this repository or create the file structure as shown 2. Copy the environment template: ```bash cp .env.example .env ``` 3. Edit `.env` with your NocoDB details: ```env NOCODB_API_URL=https://app.nocodb.com/api/v1 NOCODB_API_TOKEN=your-token-here NOCODB_PROJECT_ID=p_xxxxxxxxxxxxx NOCODB_TABLE_ID=md_xxxxxxxxxxxxx ``` 4. Start the application: ```bash docker-compose up -d ``` 5. Access the map at: http://localhost:3000 ## Finding NocoDB IDs ### API Token 1. Click user icon → Account Settings 2. Go to "API Tokens" tab 3. Create new token with read/write permissions ### Project ID - Found in URL: `https://app.nocodb.com/#/nc/[PROJECT_ID]/...` ### Table ID - Found in URL: `.../[PROJECT_ID]/[TABLE_ID]` ## API Endpoints - `GET /api/locations` - Fetch all locations - `POST /api/locations` - Create new location - `GET /api/locations/:id` - Get single location - `PUT /api/locations/:id` - Update location - `DELETE /api/locations/:id` - Delete location - `GET /health` - Health check ## Configuration All configuration is done via environment variables: | Variable | Description | Default | |----------|-------------|---------| | `NOCODB_API_URL` | NocoDB API base URL | Required | | `NOCODB_API_TOKEN` | API authentication token | Required | | `NOCODB_PROJECT_ID` | Project identifier | Required | | `NOCODB_TABLE_ID` | Table identifier | Required | | `PORT` | Server port | 3000 | | `DEFAULT_LAT` | Default map latitude | 53.5461 | | `DEFAULT_LNG` | Default map longitude | -113.4938 | | `DEFAULT_ZOOM` | Default map zoom level | 11 | ## Development To run in development mode: 1. Install dependencies: ```bash cd app npm install ``` 2. Start with hot reload: ```bash npm run dev ``` ## Security Considerations - API tokens are kept server-side only - CORS is configured for security - Rate limiting prevents abuse - Input validation on all endpoints - Helmet.js for security headers ## Troubleshooting ### Locations not showing - Verify table has `geodata`, `latitude`, and `longitude` columns - Check that coordinates are valid numbers - Ensure API token has read permissions ### Cannot add locations - Verify API token has write permissions - Check browser console for errors - Ensure coordinates are within valid ranges ### Connection errors - Verify NocoDB instance is accessible - Check API URL format - Confirm network connectivity ## License MIT License - See LICENSE file for details ## Support For issues or questions: 1. Check the troubleshooting section 2. Review NocoDB documentation 3. Open an issue on GitHub ``` --- ## API Reference ### Endpoints #### GET /api/locations Fetch all locations from NocoDB table. **Query Parameters:** - `limit` (number): Maximum records to return (default: 1000) - `offset` (number): Skip records for pagination (default: 0) - `where` (string): Filter conditions **Response:** ```json { "success": true, "count": 10, "total": 50, "locations": [ { "id": 1, "geodata": "53.5461;-113.4938", "latitude": 53.5461, "longitude": -113.4938, "title": "Location Name", "description": "Description text", "category": "Office", "created_at": "2024-01-20T10:30:00Z" } ] } ``` #### POST /api/locations Create a new location. **Request Body:** ```json { "latitude": 53.5461, "longitude": -113.4938, "title": "New Location", "description": "Optional description", "category": "Office" } ``` **Response:** ```json { "success": true, "location": { "id": 11, "geodata": "53.5461;-113.4938", "latitude": 53.5461, "longitude": -113.4938, "title": "New Location" } } ``` #### GET /api/locations/:id Get a single location by ID. **Response:** ```json { "success": true, "location": { "id": 1, "geodata": "53.5461;-113.4938", "latitude": 53.5461, "longitude": -113.4938, "title": "Location Name" } } ``` #### PUT /api/locations/:id Update an existing location. **Request Body:** ```json { "title": "Updated Name", "description": "Updated description" } ``` #### DELETE /api/locations/:id Delete a location. **Response:** ```json { "success": true, "message": "Location deleted successfully" } ``` ### Error Responses All endpoints return consistent error responses: ```json { "success": false, "error": "Error message", "details": { // Additional error details when available } } ``` **HTTP Status Codes:** - `200` - Success - `201` - Created - `400` - Bad Request (validation errors) - `401` - Unauthorized (invalid API token) - `404` - Not Found - `429` - Too Many Requests (rate limited) - `500` - Internal Server Error - `504` - Gateway Timeout --- ## Deployment Guide ### Docker Deployment #### Basic Deployment ```bash # Clone repository git clone cd nocodb-map-viewer # Configure environment cp .env.example .env nano .env # Edit with your values # Start services docker-compose up -d # View logs docker-compose logs -f # Stop services docker-compose down ``` #### Production Deployment 1. **Use Docker Swarm or Kubernetes** for orchestration 2. **Configure reverse proxy** (nginx/traefik) with SSL 3. **Set resource limits** in docker-compose.yml: ```yaml deploy: resources: limits: cpus: '0.5' memory: 512M ``` 4. **Enable persistent volumes** for logs: ```yaml volumes: - ./logs:/app/logs ``` ### Manual Deployment (without Docker) 1. **Install Node.js 18+** 2. **Clone and install dependencies:** ```bash git clone cd nocodb-map-viewer/app npm install --production ``` 3. **Set environment variables:** ```bash export NOCODB_API_URL=https://app.nocodb.com/api/v1 export NOCODB_API_TOKEN=your-token export NOCODB_PROJECT_ID=p_xxxxx export NOCODB_TABLE_ID=md_xxxxx ``` 4. **Start with PM2:** ```bash npm install -g pm2 pm2 start server.js --name nocodb-map pm2 save pm2 startup ``` ### Nginx Configuration ```nginx server { listen 80; server_name map.yourdomain.com; return 301 https://$server_name$request_uri; } server { listen 443 ssl http2; server_name map.yourdomain.com; ssl_certificate /path/to/cert.pem; ssl_certificate_key /path/to/key.pem; location / { proxy_pass http://localhost:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } ``` --- ## Testing & Validation ### Unit Tests Create `app/test/api.test.js`: ```javascript const request = require('supertest'); const app = require('../server'); describe('API Endpoints', () => { test('GET /health returns 200', async () => { const response = await request(app).get('/health'); expect(response.status).toBe(200); expect(response.body.status).toBe('healthy'); }); test('GET /api/locations requires authentication', async () => { const response = await request(app).get('/api/locations'); expect(response.status).toBe(401); }); }); ``` ### Integration Tests 1. **Test NocoDB Connection:** ```bash curl -H "xc-token: YOUR_TOKEN" \ https://app.nocodb.com/api/v1/db/data/v1/PROJECT_ID/TABLE_ID ``` 2. **Test Location Creation:** ```bash curl -X POST http://localhost:3000/api/locations \ -H "Content-Type: application/json" \ -d '{"latitude": 53.5, "longitude": -113.5, "title": "Test"}' ``` ### Load Testing Using Apache Bench: ```bash # Test read performance ab -n 1000 -c 10 http://localhost:3000/api/locations # Test write performance (be careful in production) ab -n 100 -c 5 -p location.json -T application/json \ http://localhost:3000/api/locations ``` ### Browser Testing 1. **Desktop Browsers:** - Chrome 90+ - Firefox 88+ - Safari 14+ - Edge 90+ 2. **Mobile Browsers:** - iOS Safari 14+ - Chrome for Android 3. **Features to Test:** - Map loads and displays markers - Geolocation works on mobile - Add location modal functions - Responsive design adapts properly --- ## Troubleshooting ### Common Issues #### Docker Issues **Container fails to start:** ```bash # Check logs docker-compose logs map-viewer # Rebuild container docker-compose build --no-cache docker-compose up -d ``` **Permission denied errors:** ```bash # Fix ownership docker-compose exec map-viewer chown -R nodejs:nodejs /app ``` #### API Connection Issues **"Failed to fetch data from NocoDB":** 1. Verify API URL includes `/api/v1` 2. Check API token is valid 3. Ensure NocoDB instance is accessible 4. Check for CORS issues if self-hosted **Rate limiting errors:** - Implement caching to reduce API calls - Increase rate limits in server.js - Use pagination for large datasets #### Map Display Issues **Markers not showing:** 1. Open browser console (F12) 2. Check for JavaScript errors 3. Verify coordinate format in database 4. Ensure latitude/longitude are numbers, not strings **Map tiles not loading:** - Check internet connectivity - Verify CSP headers allow tile server - Try alternative tile server ### Debug Mode Enable debug logging: ```javascript // In server.js const DEBUG = process.env.DEBUG === 'true'; if (DEBUG) { console.log('Request:', req.method, req.url); console.log('Response:', res.statusCode); } ``` Set in environment: ```bash DEBUG=true docker-compose up ``` ### Database Issues **Check PostgreSQL connection (if using PostgreSQL with NocoDB):** ```sql -- Connect to database psql -h localhost -U nocodb_user -d nocodb -- Check table structure \d+ your_table_name -- Verify data format SELECT geodata, latitude, longitude FROM your_table_name LIMIT 5; ``` --- ## Security Best Practices ### API Security 1. **Token Management:** - Rotate API tokens regularly - Use separate tokens for dev/prod - Never expose tokens in frontend code - Store tokens in environment variables 2. **Rate Limiting:** - Adjust limits based on usage patterns - Implement IP-based blocking for abuse - Log suspicious activity 3. **Input Validation:** ```javascript // Example validation function validateCoordinates(lat, lng) { const latitude = parseFloat(lat); const longitude = parseFloat(lng); if (isNaN(latitude) || isNaN(longitude)) { throw new Error('Invalid coordinates'); } if (latitude < -90 || latitude > 90) { throw new Error('Latitude out of range'); } if (longitude < -180 || longitude > 180) { throw new Error('Longitude out of range'); } return { latitude, longitude }; } ``` ### Infrastructure Security 1. **Container Security:** - Run as non-root user - Keep base images updated - Scan for vulnerabilities: ```bash docker scan nocodb-map-viewer ``` 2. **Network Security:** - Use HTTPS in production - Configure firewall rules - Implement VPN for admin access 3. **Monitoring:** - Set up alerts for failed auth attempts - Monitor API usage patterns - Track error rates ### GDPR Compliance If handling personal data: 1. Implement data retention policies 2. Add privacy policy 3. Enable data export/deletion 4. Log data access --- ## Future Enhancements ### Planned Features 1. **Advanced Mapping:** - Marker clustering for large datasets - Custom marker icons by category - Drawing tools for areas/polygons - Heatmap visualization - Route planning between locations 2. **User Features:** - User authentication - Personal location lists - Sharing and collaboration - Export to various formats (KML, GeoJSON) 3. **Integration:** - Webhook support for real-time updates - GraphQL API option - Mobile app (React Native) - Desktop app (Electron) 4. **Performance:** - Server-side marker clustering - Tile caching proxy - WebSocket for live updates - Progressive Web App (PWA) ### Contributing 1. Fork the repository 2. Create feature branch (`git checkout -b feature/amazing-feature`) 3. Commit changes (`git commit -m 'Add amazing feature'`) 4. Push to branch (`git push origin feature/amazing-feature`) 5. Open Pull Request ### Code Style - Use ESLint configuration - Follow JavaScript Standard Style - Write descriptive commit messages - Add tests for new features - Update documentation --- ## Appendix ### Environment Variable Reference | Variable | Type | Required | Default | Description | |----------|------|----------|---------|-------------| | `NOCODB_API_URL` | String | Yes | - | NocoDB API base URL | | `NOCODB_API_TOKEN` | String | Yes | - | API authentication token | | `NOCODB_PROJECT_ID` | String | Yes | - | NocoDB project ID | | `NOCODB_TABLE_ID` | String | Yes | - | NocoDB table ID | | `PORT` | Number | No | 3000 | Server port | | `NODE_ENV` | String | No | development | Environment mode | | `DEFAULT_LAT` | Number | No | 53.5461 | Default map center latitude | | `DEFAULT_LNG` | Number | No | -113.4938 | Default map center longitude | | `DEFAULT_ZOOM` | Number | No | 11 | Default map zoom level | | `BOUND_NORTH` | Number | No | - | Northern boundary limit | | `BOUND_SOUTH` | Number | No | - | Southern boundary limit | | `BOUND_EAST` | Number | No | - | Eastern boundary limit | | `BOUND_WEST` | Number | No | - | Western boundary limit | | `ALLOWED_ORIGINS` | String | No | * | CORS allowed origins (comma-separated) | | `DEBUG` | Boolean | No | false | Enable debug logging | ### Useful Commands ```bash # Docker commands docker-compose up -d # Start in background docker-compose down # Stop services docker-compose logs -f # View logs docker-compose ps # Check status docker-compose exec map-viewer sh # Access container shell # Development commands npm run dev # Start with hot reload npm test # Run tests npm run lint # Check code style # Debugging curl http://localhost:3000/health curl http://localhost:3000/api/config-check docker-compose exec map-viewer node --inspect server.js ``` ### Resources - [NocoDB Documentation](https://docs.nocodb.com/) - [Leaflet.js Documentation](https://leafletjs.com/reference.html) - [Express.js Guide](https://expressjs.com/en/guide/routing.html) - [Docker Compose Reference](https://docs.docker.com/compose/compose-file/) - [OpenStreetMap Tile Usage Policy](https://operations.osmfoundation.org/policies/tiles/) --- **Document Version:** 1.0.0 **Last Updated:** June 2025 **License:** MIT