66 KiB
NocoDB Map Viewer - Complete Implementation Guide
Table of Contents
- Executive Summary
- Technical Architecture
- Prerequisites & Requirements
- NocoDB Configuration
- Project Implementation
- API Reference
- Deployment Guide
- Testing & Validation
- Troubleshooting
- Security Best Practices
- 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
- User interacts with web interface
- JavaScript sends requests to Express server
- Express server proxies requests to NocoDB API
- NocoDB returns data from PostgreSQL/MySQL/SQLite
- Server formats and returns data to client
- 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
-
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
- MySQL:
-
latitude (Decimal Field)
- Purpose: Stores latitude coordinate
- Configuration:
- Precision: 10
- Scale: 8
- Range: -90.00000000 to 90.00000000
- Example:
53.54610000
-
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
-
Navigate to your table in NocoDB
-
For geodata column:
- Click "+" to add field
- Select "SingleLineText" or "LongText"
- Name:
geodata(exactly as shown)
-
For latitude column:
- Click "+" to add field
- Select "Decimal"
- Name:
latitude(exactly as shown) - Precision: 10
- Scale: 8
- Validation: Min -90, Max 90
-
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
-
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
-
Find Project ID:
- Open your project in NocoDB
- Look at the URL:
https://app.nocodb.com/#/nc/[PROJECT_ID]/... - Copy the PROJECT_ID portion
-
Find Table ID:
- Open your table
- Look at the URL:
.../[PROJECT_ID]/[TABLE_ID] - Copy the TABLE_ID portion
-
Determine API URL:
- For NocoDB Cloud:
https://app.nocodb.com/api/v1 - For self-hosted:
https://your-domain.com/api/v1
- For NocoDB Cloud:
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()">×</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: '© <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
-
Edit
.envwith 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 -
Start the application:
docker-compose up -d -
Access the map at: http://localhost:3000
Finding NocoDB IDs
API Token
- Click user icon → Account Settings
- Go to "API Tokens" tab
- 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 locationsPOST /api/locations- Create new locationGET /api/locations/:id- Get single locationPUT /api/locations/:id- Update locationDELETE /api/locations/:id- Delete locationGET /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:
-
Install dependencies:
cd app npm install -
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, andlongitudecolumns - 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:
- Check the troubleshooting section
- Review NocoDB documentation
- 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- Success201- Created400- Bad Request (validation errors)401- Unauthorized (invalid API token)404- Not Found429- Too Many Requests (rate limited)500- Internal Server Error504- 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
-
Use Docker Swarm or Kubernetes for orchestration
-
Configure reverse proxy (nginx/traefik) with SSL
-
Set resource limits in docker-compose.yml:
deploy: resources: limits: cpus: '0.5' memory: 512M -
Enable persistent volumes for logs:
volumes: - ./logs:/app/logs
Manual Deployment (without Docker)
-
Install Node.js 18+
-
Clone and install dependencies:
git clone <repository-url> cd nocodb-map-viewer/app npm install --production -
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 -
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
-
Test NocoDB Connection:
curl -H "xc-token: YOUR_TOKEN" \ https://app.nocodb.com/api/v1/db/data/v1/PROJECT_ID/TABLE_ID -
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
-
Desktop Browsers:
- Chrome 90+
- Firefox 88+
- Safari 14+
- Edge 90+
-
Mobile Browsers:
- iOS Safari 14+
- Chrome for Android
-
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":
- Verify API URL includes
/api/v1 - Check API token is valid
- Ensure NocoDB instance is accessible
- 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:
- Open browser console (F12)
- Check for JavaScript errors
- Verify coordinate format in database
- 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
-
Token Management:
- Rotate API tokens regularly
- Use separate tokens for dev/prod
- Never expose tokens in frontend code
- Store tokens in environment variables
-
Rate Limiting:
- Adjust limits based on usage patterns
- Implement IP-based blocking for abuse
- Log suspicious activity
-
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
-
Container Security:
- Run as non-root user
- Keep base images updated
- Scan for vulnerabilities:
docker scan nocodb-map-viewer
-
Network Security:
- Use HTTPS in production
- Configure firewall rules
- Implement VPN for admin access
-
Monitoring:
- Set up alerts for failed auth attempts
- Monitor API usage patterns
- Track error rates
GDPR Compliance
If handling personal data:
- Implement data retention policies
- Add privacy policy
- Enable data export/deletion
- Log data access
Future Enhancements
Planned Features
-
Advanced Mapping:
- Marker clustering for large datasets
- Custom marker icons by category
- Drawing tools for areas/polygons
- Heatmap visualization
- Route planning between locations
-
User Features:
- User authentication
- Personal location lists
- Sharing and collaboration
- Export to various formats (KML, GeoJSON)
-
Integration:
- Webhook support for real-time updates
- GraphQL API option
- Mobile app (React Native)
- Desktop app (Electron)
-
Performance:
- Server-side marker clustering
- Tile caching proxy
- WebSocket for live updates
- Progressive Web App (PWA)
Contributing
- Fork the repository
- Create feature branch (
git checkout -b feature/amazing-feature) - Commit changes (
git commit -m 'Add amazing feature') - Push to branch (
git push origin feature/amazing-feature) - 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
- NocoDB Documentation
- Leaflet.js Documentation
- Express.js Guide
- Docker Compose Reference
- OpenStreetMap Tile Usage Policy
Document Version: 1.0.0
Last Updated: June 2025
License: MIT