map.viewer/nocodb-map-guide.md
2025-06-22 10:43:42 -06:00

66 KiB
Raw Permalink Blame History

NocoDB Map Viewer - Complete Implementation Guide

Table of Contents

  1. Executive Summary
  2. Technical Architecture
  3. Prerequisites & Requirements
  4. NocoDB Configuration
  5. Project Implementation
  6. API Reference
  7. Deployment Guide
  8. Testing & Validation
  9. Troubleshooting
  10. Security Best Practices
  11. 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
  • 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

# 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

# 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

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

# 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

{
  "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

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

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="description" content="Interactive map viewer for NocoDB location data">
    <title>NocoDB Map Viewer</title>
    
    <!-- Leaflet CSS -->
    <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" 
          integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" 
          crossorigin="" />
    
    <!-- Custom CSS -->
    <link rel="stylesheet" href="css/style.css">
</head>
<body>
    <div id="app">
        <!-- Header -->
        <header class="header">
            <h1>Location Map Viewer</h1>
            <div class="header-actions">
                <button id="refresh-btn" class="btn btn-secondary" title="Refresh locations">
                    🔄 Refresh
                </button>
                <span id="location-count" class="location-count">Loading...</span>
            </div>
        </header>

        <!-- Map Container -->
        <div id="map-container">
            <div id="map"></div>
            
            <!-- Map Controls -->
            <div class="map-controls">
                <button id="geolocate-btn" class="btn btn-primary" title="Find my location">
                    📍 My Location
                </button>
                <button id="add-location-btn" class="btn btn-success" title="Add location at map center">
                     Add Location Here
                </button>
                <button id="fullscreen-btn" class="btn btn-secondary" title="Toggle fullscreen">
                    ⛶ Fullscreen
                </button>
            </div>
            
            <!-- Crosshair for adding locations -->
            <div id="crosshair" class="crosshair hidden">
                <div class="crosshair-x"></div>
                <div class="crosshair-y"></div>
                <div class="crosshair-info">Click "Add Location Here" to save this point</div>
            </div>
        </div>

        <!-- Status Messages -->
        <div id="status-container" class="status-container"></div>

        <!-- Add Location Modal -->
        <div id="add-modal" class="modal hidden">
            <div class="modal-content">
                <div class="modal-header">
                    <h2>Add New Location</h2>
                    <button class="modal-close" onclick="closeModal()">&times;</button>
                </div>
                <div class="modal-body">
                    <form id="location-form">
                        <div class="form-group">
                            <label for="location-title">Title *</label>
                            <input type="text" id="location-title" name="title" required 
                                   placeholder="Enter location name">
                        </div>
                        
                        <div class="form-row">
                            <div class="form-group">
                                <label for="location-lat">Latitude</label>
                                <input type="number" id="location-lat" name="latitude" 
                                       step="0.00000001" readonly>
                            </div>
                            <div class="form-group">
                                <label for="location-lng">Longitude</label>
                                <input type="number" id="location-lng" name="longitude" 
                                       step="0.00000001" readonly>
                            </div>
                        </div>
                        
                        <div class="form-group">
                            <label for="location-description">Description</label>
                            <textarea id="location-description" name="description" 
                                      rows="3" placeholder="Additional details..."></textarea>
                        </div>
                        
                        <div class="form-group">
                            <label for="location-category">Category</label>
                            <input type="text" id="location-category" name="category" 
                                   placeholder="e.g., Office, Site, POI">
                        </div>
                        
                        <div class="form-actions">
                            <button type="button" class="btn btn-secondary" onclick="closeModal()">
                                Cancel
                            </button>
                            <button type="submit" class="btn btn-primary">
                                Save Location
                            </button>
                        </div>
                    </form>
                </div>
            </div>
        </div>

        <!-- Loading Overlay -->
        <div id="loading" class="loading-overlay">
            <div class="spinner"></div>
            <p>Loading map...</p>
        </div>
    </div>

    <!-- Leaflet JS -->
    <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" 
            integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" 
            crossorigin=""></script>
    
    <!-- Application JavaScript -->
    <script src="js/map.js"></script>
</body>
</html>

8. app/public/css/style.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

// 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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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 = '<div class="popup-content">';
    
    if (location.title) {
        content += `<h3>${escapeHtml(location.title)}</h3>`;
    }
    
    if (location.description) {
        content += `<p>${escapeHtml(location.description)}</p>`;
    }
    
    if (location.category) {
        content += `<p><strong>Category:</strong> ${escapeHtml(location.category)}</p>`;
    }
    
    content += '<div class="popup-meta">';
    content += `<p><strong>Coordinates:</strong> ${location.latitude.toFixed(6)}, ${location.longitude.toFixed(6)}</p>`;
    
    if (location.created_at) {
        const date = new Date(location.created_at);
        content += `<p><strong>Added:</strong> ${date.toLocaleDateString()}</p>`;
    }
    
    content += '</div>';
    content += '</div>';
    
    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: '<div style="background-color: #2c5aa0; width: 20px; height: 20px; border-radius: 50%; border: 3px solid white; box-shadow: 0 2px 5px rgba(0,0,0,0.3);"></div>',
                    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

# 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
  1. Edit .env with your NocoDB details:

    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
    
  2. Start the application:

    docker-compose up -d
    
  3. 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:

    cd app
    npm install
    
  2. Start with hot reload:

    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:

{
  "latitude": 53.5461,
  "longitude": -113.4938,
  "title": "New Location",
  "description": "Optional description",
  "category": "Office"
}

Response:

{
  "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:

{
  "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:

{
  "title": "Updated Name",
  "description": "Updated description"
}

DELETE /api/locations/:id

Delete a location.

Response:

{
  "success": true,
  "message": "Location deleted successfully"
}

Error Responses

All endpoints return consistent error responses:

{
  "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

# Clone repository
git clone <repository-url>
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:

    deploy:
      resources:
        limits:
          cpus: '0.5'
          memory: 512M
    
  4. Enable persistent volumes for logs:

    volumes:
      - ./logs:/app/logs
    

Manual Deployment (without Docker)

  1. Install Node.js 18+

  2. Clone and install dependencies:

    git clone <repository-url>
    cd nocodb-map-viewer/app
    npm install --production
    
  3. Set environment variables:

    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:

    npm install -g pm2
    pm2 start server.js --name nocodb-map
    pm2 save
    pm2 startup
    

Nginx Configuration

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:

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:

    curl -H "xc-token: YOUR_TOKEN" \
         https://app.nocodb.com/api/v1/db/data/v1/PROJECT_ID/TABLE_ID
    
  2. Test Location Creation:

    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:

# 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:

# Check logs
docker-compose logs map-viewer

# Rebuild container
docker-compose build --no-cache
docker-compose up -d

Permission denied errors:

# 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:

// 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:

DEBUG=true docker-compose up

Database Issues

Check PostgreSQL connection (if using PostgreSQL with NocoDB):

-- 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:

    // 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:
      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

# 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


Document Version: 1.0.0
Last Updated: June 2025
License: MIT