# NocoDB Map Viewer - Complete Implementation Guide
## Table of Contents
1. [Executive Summary](#executive-summary)
2. [Technical Architecture](#technical-architecture)
3. [Prerequisites & Requirements](#prerequisites--requirements)
4. [NocoDB Configuration](#nocodb-configuration)
5. [Project Implementation](#project-implementation)
6. [API Reference](#api-reference)
7. [Deployment Guide](#deployment-guide)
8. [Testing & Validation](#testing--validation)
9. [Troubleshooting](#troubleshooting)
10. [Security Best Practices](#security-best-practices)
11. [Future Enhancements](#future-enhancements)
---
## Executive Summary
This document provides a complete implementation guide for building a containerized web application that visualizes geographic data from NocoDB on an interactive map. The solution uses open-source technologies exclusively and allows users to connect any NocoDB table containing properly formatted location data.
### Key Features
- Real-time map visualization using Leaflet.js
- Geolocation support with user position tracking
- Ability to add new locations directly from the map
- API-driven architecture for flexibility
- Docker containerization for easy deployment
- No proprietary dependencies (fully FOSS)
### Use Cases
- Field data collection
- Asset tracking
- Location-based surveys
- Geographic data visualization
- Point of interest mapping
---
## Technical Architecture
### System Overview
```
┌─────────────────────────────────────────────────────────────────┐
│ User Browser │
│ ┌─────────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ │ │ │ │ │ │
│ │ Leaflet.js │ │ JavaScript │ │ Geolocation │ │
│ │ Map Engine │ │ Application │ │ API │ │
│ │ │ │ │ │ │ │
│ └────────┬────────┘ └──────┬───────┘ └────────┬─────────┘ │
│ │ │ │ │
└───────────┼───────────────────┼────────────────────┼─────────────┘
│ │ │
▼ ▼ ▼
┌───────────────────────────────────────────────────────────────┐
│ Express.js Server (Port 3000) │
│ ┌─────────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ │ │ │ │ │ │
│ │ Static File │ │ API Proxy │ │ CORS & Security │ │
│ │ Serving │ │ Endpoints │ │ Middleware │ │
│ │ │ │ │ │ │ │
│ └─────────────────┘ └──────┬───────┘ └──────────────────┘ │
└───────────────────────────────┼────────────────────────────────┘
│
▼
┌───────────────────────┐
│ │
│ NocoDB Instance │
│ (External API) │
│ │
└───────────────────────┘
```
### Technology Stack
| Component | Technology | Version | Purpose |
|-----------|------------|---------|---------|
| Frontend Framework | Leaflet.js | 1.9.4 | Interactive mapping |
| Map Tiles | OpenStreetMap | Latest | Free map tile provider |
| Backend Runtime | Node.js | 18 LTS | Server-side JavaScript |
| Web Framework | Express.js | 4.18.x | HTTP server and routing |
| HTTP Client | Axios | 1.6.x | API communication |
| Container Platform | Docker | Latest | Application containerization |
| Environment Config | dotenv | 16.3.x | Configuration management |
### Data Flow
1. User interacts with web interface
2. JavaScript sends requests to Express server
3. Express server proxies requests to NocoDB API
4. NocoDB returns data from PostgreSQL/MySQL/SQLite
5. Server formats and returns data to client
6. Leaflet.js renders locations on map
---
## Prerequisites & Requirements
### Developer Requirements
- Docker and Docker Compose installed
- Basic knowledge of:
- JavaScript/Node.js
- REST APIs
- Docker containers
- Geographic coordinates (latitude/longitude)
- Text editor or IDE
- Git (optional, for version control)
### System Requirements
- **Minimum RAM**: 2GB
- **Disk Space**: 1GB for application + data
- **Network**: Internet connection for map tiles
- **Ports**: 3000 (configurable)
- **OS**: Linux, macOS, or Windows with WSL2
### NocoDB Requirements
- NocoDB instance (self-hosted or cloud)
- Admin access to create tables and API tokens
- Table with specific column structure (detailed below)
---
## NocoDB Configuration
### Step 1: Create Table Structure
Users must create a table in NocoDB with these **exact** column names and types:
#### Required Columns
1. **geodata** (Text/String Field)
- **Purpose**: Stores combined coordinates
- **Format**: `"latitude;longitude"` (semicolon-separated)
- **Example**: `"53.5461;-113.4938"`
- **Database Types**:
- MySQL: `VARCHAR(255)`, `TEXT`
- PostgreSQL: `VARCHAR(255)`, `TEXT`
- SQLite: `TEXT`
2. **latitude** (Decimal Field)
- **Purpose**: Stores latitude coordinate
- **Configuration**:
- Precision: 10
- Scale: 8
- Range: -90.00000000 to 90.00000000
- **Example**: `53.54610000`
3. **longitude** (Decimal Field)
- **Purpose**: Stores longitude coordinate
- **Configuration**:
- Precision: 11
- Scale: 8
- Range: -180.00000000 to 180.00000000
- **Example**: `-113.49380000`
#### Optional Recommended Columns
- **title** (String): Location name or identifier
- **description** (Long Text): Detailed information
- **category** (Single Select): Classification
- **created_at** (DateTime): Timestamp
- **user_id** (String): Who added the location
### Step 2: Configure Column Properties in NocoDB
1. **Navigate to your table** in NocoDB
2. **For geodata column**:
- Click "+" to add field
- Select "SingleLineText" or "LongText"
- Name: `geodata` (exactly as shown)
3. **For latitude column**:
- Click "+" to add field
- Select "Decimal"
- Name: `latitude` (exactly as shown)
- Precision: 10
- Scale: 8
- Validation: Min -90, Max 90
4. **For longitude column**:
- Click "+" to add field
- Select "Decimal"
- Name: `longitude` (exactly as shown)
- Precision: 11
- Scale: 8
- Validation: Min -180, Max 180
### Step 3: Obtain API Credentials
1. **Get API Token**:
- Click on user icon (top right)
- Go to "Account Settings"
- Navigate to "API Tokens" tab
- Click "Add New Token"
- Give it a name (e.g., "Map Viewer")
- Copy the generated token immediately
2. **Find Project ID**:
- Open your project in NocoDB
- Look at the URL: `https://app.nocodb.com/#/nc/[PROJECT_ID]/...`
- Copy the PROJECT_ID portion
3. **Find Table ID**:
- Open your table
- Look at the URL: `.../[PROJECT_ID]/[TABLE_ID]`
- Copy the TABLE_ID portion
4. **Determine API URL**:
- For NocoDB Cloud: `https://app.nocodb.com/api/v1`
- For self-hosted: `https://your-domain.com/api/v1`
---
## Project Implementation
### Complete File Structure
```
nocodb-map-viewer/
├── docker-compose.yml
├── .env.example
├── .env # Create from .env.example
├── README.md
├── .gitignore
└── app/
├── Dockerfile
├── package.json
├── package-lock.json # Generated after npm install
├── server.js
└── public/
├── index.html
├── favicon.ico # Optional
├── css/
│ └── style.css
└── js/
└── map.js
```
### File Contents
#### 1. `.env.example`
```env
# NocoDB API Configuration
NOCODB_API_URL=https://app.nocodb.com/api/v1
NOCODB_API_TOKEN=your-api-token-here
NOCODB_PROJECT_ID=p_xxxxxxxxxxxxx
NOCODB_TABLE_ID=md_xxxxxxxxxxxxx
# Server Configuration
PORT=3000
NODE_ENV=production
# Map Defaults (Edmonton, Alberta, Canada)
DEFAULT_LAT=53.5461
DEFAULT_LNG=-113.4938
DEFAULT_ZOOM=11
# Optional: Map Boundaries (prevents users from adding points outside area)
# BOUND_NORTH=53.7
# BOUND_SOUTH=53.4
# BOUND_EAST=-113.3
# BOUND_WEST=-113.7
```
#### 2. `.gitignore`
```gitignore
# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Environment files
.env
.env.local
.env.*.local
# IDE files
.vscode/
.idea/
*.swp
*.swo
*~
# OS files
.DS_Store
Thumbs.db
# Logs
logs/
*.log
# Build outputs
dist/
build/
```
#### 3. `docker-compose.yml`
```yaml
version: '3.8'
services:
map-viewer:
build:
context: ./app
dockerfile: Dockerfile
container_name: nocodb-map-viewer
ports:
- "${PORT:-3000}:3000"
environment:
- NODE_ENV=${NODE_ENV:-production}
- PORT=${PORT:-3000}
env_file:
- .env
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
```
#### 4. `app/Dockerfile`
```dockerfile
# Build stage
FROM node:18-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production && npm cache clean --force
# Runtime stage
FROM node:18-alpine
# Install wget for healthcheck
RUN apk add --no-cache wget
WORKDIR /app
# Copy dependencies from builder
COPY --from=builder /app/node_modules ./node_modules
# Copy application files
COPY package*.json ./
COPY server.js ./
COPY public ./public
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
# Change ownership
RUN chown -R nodejs:nodejs /app
USER nodejs
EXPOSE 3000
CMD ["node", "server.js"]
```
#### 5. `app/package.json`
```json
{
"name": "nocodb-map-viewer",
"version": "1.0.0",
"description": "Interactive map viewer for NocoDB geographic data",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"nocodb",
"map",
"leaflet",
"gis",
"location"
],
"author": "",
"license": "MIT",
"dependencies": {
"express": "^4.18.2",
"axios": "^1.6.0",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"helmet": "^7.1.0",
"express-rate-limit": "^7.1.4",
"winston": "^3.11.0"
},
"devDependencies": {
"nodemon": "^3.0.1"
},
"engines": {
"node": ">=18.0.0"
}
}
```
#### 6. `app/server.js`
```javascript
const express = require('express');
const axios = require('axios');
const cors = require('cors');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const winston = require('winston');
const path = require('path');
require('dotenv').config();
// Configure logger
const logger = winston.createLogger({
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.Console({
format: winston.format.simple()
})
]
});
// Initialize Express app
const app = express();
const PORT = process.env.PORT || 3000;
// Security middleware
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com"],
scriptSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com"],
imgSrc: ["'self'", "data:", "https://*.tile.openstreetmap.org"],
connectSrc: ["'self'"]
}
}
}));
// CORS configuration
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || '*',
credentials: true
}));
// Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100 // limit each IP to 100 requests per windowMs
});
const strictLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 20 // limit location creation to 20 per 15 minutes
});
// Middleware
app.use(express.json({ limit: '10mb' }));
app.use(express.static(path.join(__dirname, 'public')));
app.use('/api/', limiter);
// Health check endpoint
app.get('/health', (req, res) => {
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
version: process.env.npm_package_version || '1.0.0'
});
});
// Configuration validation endpoint (for debugging)
app.get('/api/config-check', (req, res) => {
const config = {
hasApiUrl: !!process.env.NOCODB_API_URL,
hasApiToken: !!process.env.NOCODB_API_TOKEN,
hasProjectId: !!process.env.NOCODB_PROJECT_ID,
hasTableId: !!process.env.NOCODB_TABLE_ID,
nodeEnv: process.env.NODE_ENV
};
const isConfigured = Object.values(config).every(v => v === true || v === 'production' || v === 'development');
res.json({
configured: isConfigured,
...config
});
});
// Get all locations from NocoDB
app.get('/api/locations', async (req, res) => {
try {
const { limit = 1000, offset = 0, where } = req.query;
const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${process.env.NOCODB_TABLE_ID}`;
const params = new URLSearchParams({
limit,
offset
});
if (where) {
params.append('where', where);
}
logger.info(`Fetching locations from NocoDB: ${url}`);
const response = await axios.get(`${url}?${params}`, {
headers: {
'xc-token': process.env.NOCODB_API_TOKEN,
'Content-Type': 'application/json'
},
timeout: 10000 // 10 second timeout
});
// Process locations to ensure they have required fields
const locations = response.data.list || [];
const validLocations = locations.filter(loc => {
// Check if location has valid coordinates
if (loc.latitude && loc.longitude) {
return true;
}
// Try to parse from geodata if lat/lng missing
if (loc.geodata && typeof loc.geodata === 'string') {
const parts = loc.geodata.split(';');
if (parts.length === 2) {
loc.latitude = parseFloat(parts[0]);
loc.longitude = parseFloat(parts[1]);
return !isNaN(loc.latitude) && !isNaN(loc.longitude);
}
}
return false;
});
logger.info(`Retrieved ${validLocations.length} valid locations out of ${locations.length} total`);
res.json({
success: true,
count: validLocations.length,
total: response.data.pageInfo?.totalRows || validLocations.length,
locations: validLocations
});
} catch (error) {
logger.error('Error fetching locations:', error.message);
if (error.response) {
// NocoDB API error
res.status(error.response.status).json({
success: false,
error: 'Failed to fetch data from NocoDB',
details: error.response.data
});
} else if (error.code === 'ECONNABORTED') {
// Timeout
res.status(504).json({
success: false,
error: 'Request timeout'
});
} else {
// Other errors
res.status(500).json({
success: false,
error: 'Internal server error'
});
}
}
});
// Get single location by ID
app.get('/api/locations/:id', async (req, res) => {
try {
const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${process.env.NOCODB_TABLE_ID}/${req.params.id}`;
const response = await axios.get(url, {
headers: {
'xc-token': process.env.NOCODB_API_TOKEN
}
});
res.json({
success: true,
location: response.data
});
} catch (error) {
logger.error(`Error fetching location ${req.params.id}:`, error.message);
res.status(error.response?.status || 500).json({
success: false,
error: 'Failed to fetch location'
});
}
});
// Create new location
app.post('/api/locations', strictLimiter, async (req, res) => {
try {
const { latitude, longitude, ...additionalData } = req.body;
// Validate coordinates
if (!latitude || !longitude) {
return res.status(400).json({
success: false,
error: 'Latitude and longitude are required'
});
}
const lat = parseFloat(latitude);
const lng = parseFloat(longitude);
if (isNaN(lat) || isNaN(lng)) {
return res.status(400).json({
success: false,
error: 'Invalid coordinate values'
});
}
if (lat < -90 || lat > 90) {
return res.status(400).json({
success: false,
error: 'Latitude must be between -90 and 90'
});
}
if (lng < -180 || lng > 180) {
return res.status(400).json({
success: false,
error: 'Longitude must be between -180 and 180'
});
}
// Check bounds if configured
if (process.env.BOUND_NORTH) {
const bounds = {
north: parseFloat(process.env.BOUND_NORTH),
south: parseFloat(process.env.BOUND_SOUTH),
east: parseFloat(process.env.BOUND_EAST),
west: parseFloat(process.env.BOUND_WEST)
};
if (lat > bounds.north || lat < bounds.south ||
lng > bounds.east || lng < bounds.west) {
return res.status(400).json({
success: false,
error: 'Location is outside allowed bounds'
});
}
}
// Format geodata
const geodata = `${lat};${lng}`;
// Prepare data for NocoDB
const locationData = {
geodata,
latitude: lat,
longitude: lng,
...additionalData,
created_at: new Date().toISOString()
};
const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${process.env.NOCODB_TABLE_ID}`;
logger.info('Creating new location:', { lat, lng });
const response = await axios.post(url, locationData, {
headers: {
'xc-token': process.env.NOCODB_API_TOKEN,
'Content-Type': 'application/json'
}
});
logger.info('Location created successfully:', response.data.id);
res.status(201).json({
success: true,
location: response.data
});
} catch (error) {
logger.error('Error creating location:', error.message);
if (error.response) {
res.status(error.response.status).json({
success: false,
error: 'Failed to save location to NocoDB',
details: error.response.data
});
} else {
res.status(500).json({
success: false,
error: 'Internal server error'
});
}
}
});
// Update location
app.put('/api/locations/:id', strictLimiter, async (req, res) => {
try {
const { latitude, longitude, ...additionalData } = req.body;
const updateData = { ...additionalData };
// Update geodata if coordinates changed
if (latitude !== undefined && longitude !== undefined) {
const lat = parseFloat(latitude);
const lng = parseFloat(longitude);
if (!isNaN(lat) && !isNaN(lng)) {
updateData.latitude = lat;
updateData.longitude = lng;
updateData.geodata = `${lat};${lng}`;
}
}
updateData.updated_at = new Date().toISOString();
const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${process.env.NOCODB_TABLE_ID}/${req.params.id}`;
const response = await axios.patch(url, updateData, {
headers: {
'xc-token': process.env.NOCODB_API_TOKEN,
'Content-Type': 'application/json'
}
});
res.json({
success: true,
location: response.data
});
} catch (error) {
logger.error(`Error updating location ${req.params.id}:`, error.message);
res.status(error.response?.status || 500).json({
success: false,
error: 'Failed to update location'
});
}
});
// Delete location
app.delete('/api/locations/:id', strictLimiter, async (req, res) => {
try {
const url = `${process.env.NOCODB_API_URL}/db/data/v1/${process.env.NOCODB_PROJECT_ID}/${process.env.NOCODB_TABLE_ID}/${req.params.id}`;
await axios.delete(url, {
headers: {
'xc-token': process.env.NOCODB_API_TOKEN
}
});
logger.info(`Location ${req.params.id} deleted`);
res.json({
success: true,
message: 'Location deleted successfully'
});
} catch (error) {
logger.error(`Error deleting location ${req.params.id}:`, error.message);
res.status(error.response?.status || 500).json({
success: false,
error: 'Failed to delete location'
});
}
});
// Error handling middleware
app.use((err, req, res, next) => {
logger.error('Unhandled error:', err);
res.status(500).json({
success: false,
error: 'Internal server error'
});
});
// Start server
app.listen(PORT, () => {
logger.info(`
╔════════════════════════════════════════╗
║ NocoDB Map Viewer Server ║
╠════════════════════════════════════════╣
║ Status: Running ║
║ Port: ${PORT} ║
║ Environment: ${process.env.NODE_ENV || 'development'} ║
║ Time: ${new Date().toISOString()} ║
╚════════════════════════════════════════╝
`);
});
// Graceful shutdown
process.on('SIGTERM', () => {
logger.info('SIGTERM signal received: closing HTTP server');
app.close(() => {
logger.info('HTTP server closed');
process.exit(0);
});
});
```
#### 7. `app/public/index.html`
```html
NocoDB Map Viewer
Click "Add Location Here" to save this point
```
#### 8. `app/public/css/style.css`
```css
/* CSS Variables for theming */
:root {
--primary-color: #2c5aa0;
--success-color: #27ae60;
--danger-color: #e74c3c;
--warning-color: #f39c12;
--secondary-color: #95a5a6;
--dark-color: #2c3e50;
--light-color: #ecf0f1;
--border-radius: 4px;
--transition: all 0.3s ease;
--header-height: 60px;
}
/* Reset and base styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 1.5;
color: var(--dark-color);
background-color: var(--light-color);
}
/* App container */
#app {
display: flex;
flex-direction: column;
height: 100vh;
position: relative;
}
/* Header */
.header {
height: var(--header-height);
background-color: var(--dark-color);
color: white;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
z-index: 1000;
}
.header h1 {
font-size: 24px;
font-weight: 600;
}
.header-actions {
display: flex;
align-items: center;
gap: 15px;
}
.location-count {
background-color: rgba(255,255,255,0.1);
padding: 5px 15px;
border-radius: 20px;
font-size: 14px;
}
/* Map container */
#map-container {
flex: 1;
position: relative;
overflow: hidden;
}
#map {
width: 100%;
height: 100%;
background-color: #f0f0f0;
}
/* Map controls */
.map-controls {
position: absolute;
top: 20px;
right: 20px;
display: flex;
flex-direction: column;
gap: 10px;
z-index: 1000;
}
/* Buttons */
.btn {
padding: 10px 16px;
border: none;
border-radius: var(--border-radius);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: var(--transition);
display: inline-flex;
align-items: center;
gap: 5px;
white-space: nowrap;
outline: none;
}
.btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.btn:active {
transform: translateY(0);
}
.btn-primary {
background-color: var(--primary-color);
color: white;
}
.btn-primary:hover {
background-color: #2471a3;
}
.btn-success {
background-color: var(--success-color);
color: white;
}
.btn-success:hover {
background-color: #229954;
}
.btn-secondary {
background-color: var(--secondary-color);
color: white;
}
.btn-secondary:hover {
background-color: #7f8c8d;
}
/* Crosshair for location selection */
.crosshair {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
z-index: 999;
}
.crosshair.hidden {
display: none;
}
.crosshair-x,
.crosshair-y {
position: absolute;
background-color: rgba(44, 90, 160, 0.8);
}
.crosshair-x {
width: 40px;
height: 2px;
left: -20px;
top: -1px;
}
.crosshair-y {
width: 2px;
height: 40px;
left: -1px;
top: -20px;
}
.crosshair-info {
position: absolute;
top: 30px;
left: 50%;
transform: translateX(-50%);
background-color: rgba(44, 62, 80, 0.9);
color: white;
padding: 5px 10px;
border-radius: var(--border-radius);
font-size: 12px;
white-space: nowrap;
}
/* Status messages */
.status-container {
position: fixed;
top: calc(var(--header-height) + 20px);
left: 50%;
transform: translateX(-50%);
z-index: 2000;
max-width: 400px;
width: 90%;
}
.status-message {
padding: 12px 20px;
border-radius: var(--border-radius);
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 10px;
animation: slideIn 0.3s ease;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.status-message.success {
background-color: var(--success-color);
color: white;
}
.status-message.error {
background-color: var(--danger-color);
color: white;
}
.status-message.warning {
background-color: var(--warning-color);
color: white;
}
.status-message.info {
background-color: var(--primary-color);
color: white;
}
/* Modal */
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 3000;
animation: fadeIn 0.3s ease;
}
.modal.hidden {
display: none;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal-content {
background-color: white;
border-radius: var(--border-radius);
width: 90%;
max-width: 500px;
max-height: 90vh;
overflow: auto;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(50px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
padding: 20px;
border-bottom: 1px solid #e0e0e0;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h2 {
font-size: 20px;
font-weight: 600;
color: var(--dark-color);
}
.modal-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: var(--secondary-color);
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: var(--transition);
}
.modal-close:hover {
background-color: var(--light-color);
color: var(--dark-color);
}
.modal-body {
padding: 20px;
}
/* Form styles */
.form-group {
margin-bottom: 15px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
color: var(--dark-color);
font-size: 14px;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: var(--border-radius);
font-size: 14px;
transition: var(--transition);
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(44, 90, 160, 0.1);
}
.form-group input[readonly] {
background-color: #f5f5f5;
cursor: not-allowed;
}
.form-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #e0e0e0;
}
/* Loading overlay */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255,255,255,0.9);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 4000;
}
.loading-overlay.hidden {
display: none;
}
.spinner {
width: 50px;
height: 50px;
border: 3px solid var(--light-color);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-overlay p {
margin-top: 20px;
color: var(--dark-color);
font-size: 16px;
}
/* Leaflet customizations */
.leaflet-popup-content-wrapper {
border-radius: var(--border-radius);
box-shadow: 0 3px 10px rgba(0,0,0,0.2);
}
.leaflet-popup-content {
margin: 13px 19px;
line-height: 1.5;
}
.popup-content h3 {
margin: 0 0 10px 0;
color: var(--dark-color);
font-size: 16px;
}
.popup-content p {
margin: 5px 0;
color: #666;
font-size: 14px;
}
.popup-content .popup-meta {
font-size: 12px;
color: #999;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #eee;
}
/* Responsive design */
@media (max-width: 768px) {
.header h1 {
font-size: 20px;
}
.map-controls {
top: 10px;
right: 10px;
}
.btn {
padding: 8px 12px;
font-size: 13px;
}
.modal-content {
width: 95%;
margin: 10px;
}
.form-row {
grid-template-columns: 1fr;
}
}
/* Fullscreen styles */
.fullscreen #map-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 5000;
}
.fullscreen .header {
display: none;
}
/* Print styles */
@media print {
.header,
.map-controls,
.status-container,
.modal {
display: none !important;
}
#map-container {
height: 100vh;
}
}
```
#### 9. `app/public/js/map.js`
```javascript
// Global configuration
const CONFIG = {
DEFAULT_LAT: parseFloat(document.querySelector('meta[name="default-lat"]')?.content) || 53.5461,
DEFAULT_LNG: parseFloat(document.querySelector('meta[name="default-lng"]')?.content) || -113.4938,
DEFAULT_ZOOM: parseInt(document.querySelector('meta[name="default-zoom"]')?.content) || 11,
REFRESH_INTERVAL: 30000, // 30 seconds
MAX_ZOOM: 19,
MIN_ZOOM: 2
};
// Application state
let map = null;
let markers = [];
let userLocationMarker = null;
let isAddingLocation = false;
let refreshInterval = null;
// Initialize application when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
initializeMap();
loadLocations();
setupEventListeners();
checkConfiguration();
// Set up auto-refresh
refreshInterval = setInterval(loadLocations, CONFIG.REFRESH_INTERVAL);
});
// Initialize Leaflet map
function initializeMap() {
// Create map instance
map = L.map('map', {
center: [CONFIG.DEFAULT_LAT, CONFIG.DEFAULT_LNG],
zoom: CONFIG.DEFAULT_ZOOM,
zoomControl: true,
attributionControl: true
});
// Add OpenStreetMap tiles
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
maxZoom: CONFIG.MAX_ZOOM,
minZoom: CONFIG.MIN_ZOOM
}).addTo(map);
// Add scale control
L.control.scale({
position: 'bottomleft',
metric: true,
imperial: false
}).addTo(map);
// Hide loading overlay
document.getElementById('loading').classList.add('hidden');
}
// Set up event listeners
function setupEventListeners() {
// Geolocation button
document.getElementById('geolocate-btn').addEventListener('click', handleGeolocation);
// Add location button
document.getElementById('add-location-btn').addEventListener('click', toggleAddLocation);
// Refresh button
document.getElementById('refresh-btn').addEventListener('click', () => {
showStatus('Refreshing locations...', 'info');
loadLocations();
});
// Fullscreen button
document.getElementById('fullscreen-btn').addEventListener('click', toggleFullscreen);
// Form submission
document.getElementById('location-form').addEventListener('submit', handleLocationSubmit);
// Map click handler for adding locations
map.on('click', handleMapClick);
}
// Check API configuration
async function checkConfiguration() {
try {
const response = await fetch('/api/config-check');
const data = await response.json();
if (!data.configured) {
showStatus('Warning: API not fully configured. Check your .env file.', 'warning');
}
} catch (error) {
console.error('Configuration check failed:', error);
}
}
// Load locations from API
async function loadLocations() {
try {
const response = await fetch('/api/locations');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.success) {
displayLocations(data.locations);
updateLocationCount(data.count);
} else {
throw new Error(data.error || 'Failed to load locations');
}
} catch (error) {
console.error('Error loading locations:', error);
showStatus('Failed to load locations. Check your connection.', 'error');
updateLocationCount(0);
}
}
// Display locations on map
function displayLocations(locations) {
// Clear existing markers
markers.forEach(marker => map.removeLayer(marker));
markers = [];
// Add new markers
locations.forEach(location => {
if (location.latitude && location.longitude) {
const marker = createLocationMarker(location);
markers.push(marker);
}
});
// Fit map to show all markers if there are any
if (markers.length > 0) {
const group = new L.featureGroup(markers);
map.fitBounds(group.getBounds().pad(0.1));
}
}
// Create marker for location
function createLocationMarker(location) {
const marker = L.marker([location.latitude, location.longitude], {
title: location.title || 'Location',
riseOnHover: true
}).addTo(map);
// Create popup content
const popupContent = createPopupContent(location);
marker.bindPopup(popupContent);
return marker;
}
// Create popup content for marker
function createPopupContent(location) {
let content = '';
return content;
}
// Handle geolocation
function handleGeolocation() {
if (!navigator.geolocation) {
showStatus('Geolocation is not supported by your browser', 'error');
return;
}
showStatus('Getting your location...', 'info');
navigator.geolocation.getCurrentPosition(
(position) => {
const { latitude, longitude, accuracy } = position.coords;
// Center map on user location
map.setView([latitude, longitude], 15);
// Remove existing user marker
if (userLocationMarker) {
map.removeLayer(userLocationMarker);
}
// Add user location marker
userLocationMarker = L.marker([latitude, longitude], {
icon: L.divIcon({
html: '',
className: 'user-location-marker',
iconSize: [20, 20],
iconAnchor: [10, 10]
}),
title: 'Your location'
}).addTo(map);
// Add accuracy circle
L.circle([latitude, longitude], {
radius: accuracy,
color: '#2c5aa0',
fillColor: '#2c5aa0',
fillOpacity: 0.1,
weight: 1
}).addTo(map);
showStatus(`Location found (±${Math.round(accuracy)}m accuracy)`, 'success');
},
(error) => {
let message = 'Unable to get your location';
switch (error.code) {
case error.PERMISSION_DENIED:
message = 'Location permission denied';
break;
case error.POSITION_UNAVAILABLE:
message = 'Location information unavailable';
break;
case error.TIMEOUT:
message = 'Location request timed out';
break;
}
showStatus(message, 'error');
},
{
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 0
}
);
}
// Toggle add location mode
function toggleAddLocation() {
isAddingLocation = !isAddingLocation;
const btn = document.getElementById('add-location-btn');
const crosshair = document.getElementById('crosshair');
if (isAddingLocation) {
btn.textContent = '✕ Cancel';
btn.classList.remove('btn-success');
btn.classList.add('btn-secondary');
crosshair.classList.remove('hidden');
map.getContainer().style.cursor = 'crosshair';
} else {
btn.textContent = '➕ Add Location Here';
btn.classList.remove('btn-secondary');
btn.classList.add('btn-success');
crosshair.classList.add('hidden');
map.getContainer().style.cursor = '';
}
}
// Handle map click
function handleMapClick(e) {
if (!isAddingLocation) return;
const { lat, lng } = e.latlng;
// Toggle off add location mode
toggleAddLocation();
// Show modal with coordinates
showAddLocationModal(lat, lng);
}
// Show add location modal
function showAddLocationModal(lat, lng) {
const modal = document.getElementById('add-modal');
const latInput = document.getElementById('location-lat');
const lngInput = document.getElementById('location-lng');
// Set coordinates
latInput.value = lat.toFixed(8);
lngInput.value = lng.toFixed(8);
// Clear other fields
document.getElementById('location-title').value = '';
document.getElementById('location-description').value = '';
document.getElementById('location-category').value = '';
// Show modal
modal.classList.remove('hidden');
// Focus on title input
setTimeout(() => {
document.getElementById('location-title').focus();
}, 100);
}
// Close modal
function closeModal() {
document.getElementById('add-modal').classList.add('hidden');
}
// Handle location form submission
async function handleLocationSubmit(e) {
e.preventDefault();
const formData = new FormData(e.target);
const data = Object.fromEntries(formData);
// Validate required fields
if (!data.title || !data.title.trim()) {
showStatus('Title is required', 'error');
return;
}
try {
const response = await fetch('/api/locations', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok && result.success) {
showStatus('Location added successfully!', 'success');
closeModal();
// Reload locations
loadLocations();
// Center map on new location
map.setView([data.latitude, data.longitude], map.getZoom());
} else {
throw new Error(result.error || 'Failed to add location');
}
} catch (error) {
console.error('Error adding location:', error);
showStatus(error.message, 'error');
}
}
// Toggle fullscreen
function toggleFullscreen() {
const app = document.getElementById('app');
const btn = document.getElementById('fullscreen-btn');
if (!document.fullscreenElement) {
app.requestFullscreen().then(() => {
app.classList.add('fullscreen');
btn.textContent = '✕ Exit Fullscreen';
// Invalidate map size after transition
setTimeout(() => map.invalidateSize(), 300);
}).catch(err => {
showStatus('Unable to enter fullscreen', 'error');
});
} else {
document.exitFullscreen().then(() => {
app.classList.remove('fullscreen');
btn.textContent = '⛶ Fullscreen';
// Invalidate map size after transition
setTimeout(() => map.invalidateSize(), 300);
});
}
}
// Update location count
function updateLocationCount(count) {
const countElement = document.getElementById('location-count');
countElement.textContent = `${count} location${count !== 1 ? 's' : ''}`;
}
// Show status message
function showStatus(message, type = 'info') {
const container = document.getElementById('status-container');
const messageDiv = document.createElement('div');
messageDiv.className = `status-message ${type}`;
messageDiv.textContent = message;
container.appendChild(messageDiv);
// Auto-remove after 5 seconds
setTimeout(() => {
messageDiv.remove();
}, 5000);
}
// Escape HTML to prevent XSS
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Handle window resize
window.addEventListener('resize', () => {
map.invalidateSize();
});
// Clean up on page unload
window.addEventListener('beforeunload', () => {
if (refreshInterval) {
clearInterval(refreshInterval);
}
});
// Make closeModal function global for onclick handler
window.closeModal = closeModal;
```
#### 10. `README.md`
```markdown
# NocoDB Map Viewer
A containerized web application that visualizes geographic data from NocoDB on an interactive map using Leaflet.js.
## Features
- 🗺️ Interactive map visualization with OpenStreetMap
- 📍 Real-time geolocation support
- ➕ Add new locations directly from the map
- 🔄 Auto-refresh every 30 seconds
- 📱 Responsive design for mobile devices
- 🔒 Secure API proxy to protect credentials
- 🐳 Docker containerization for easy deployment
- 🆓 100% open source (no proprietary dependencies)
## Quick Start
### Prerequisites
- Docker and Docker Compose
- NocoDB instance with a table containing location data
- NocoDB API token
### NocoDB Table Setup
1. Create a table in NocoDB with these required columns:
- `geodata` (Text): Format "latitude;longitude"
- `latitude` (Decimal): Precision 10, Scale 8
- `longitude` (Decimal): Precision 11, Scale 8
2. Optional recommended columns:
- `title` (Text): Location name
- `description` (Long Text): Details
- `category` (Single Select): Classification
### Installation
1. Clone this repository or create the file structure as shown
2. Copy the environment template:
```bash
cp .env.example .env
```
3. Edit `.env` with your NocoDB details:
```env
NOCODB_API_URL=https://app.nocodb.com/api/v1
NOCODB_API_TOKEN=your-token-here
NOCODB_PROJECT_ID=p_xxxxxxxxxxxxx
NOCODB_TABLE_ID=md_xxxxxxxxxxxxx
```
4. Start the application:
```bash
docker-compose up -d
```
5. Access the map at: http://localhost:3000
## Finding NocoDB IDs
### API Token
1. Click user icon → Account Settings
2. Go to "API Tokens" tab
3. Create new token with read/write permissions
### Project ID
- Found in URL: `https://app.nocodb.com/#/nc/[PROJECT_ID]/...`
### Table ID
- Found in URL: `.../[PROJECT_ID]/[TABLE_ID]`
## API Endpoints
- `GET /api/locations` - Fetch all locations
- `POST /api/locations` - Create new location
- `GET /api/locations/:id` - Get single location
- `PUT /api/locations/:id` - Update location
- `DELETE /api/locations/:id` - Delete location
- `GET /health` - Health check
## Configuration
All configuration is done via environment variables:
| Variable | Description | Default |
|----------|-------------|---------|
| `NOCODB_API_URL` | NocoDB API base URL | Required |
| `NOCODB_API_TOKEN` | API authentication token | Required |
| `NOCODB_PROJECT_ID` | Project identifier | Required |
| `NOCODB_TABLE_ID` | Table identifier | Required |
| `PORT` | Server port | 3000 |
| `DEFAULT_LAT` | Default map latitude | 53.5461 |
| `DEFAULT_LNG` | Default map longitude | -113.4938 |
| `DEFAULT_ZOOM` | Default map zoom level | 11 |
## Development
To run in development mode:
1. Install dependencies:
```bash
cd app
npm install
```
2. Start with hot reload:
```bash
npm run dev
```
## Security Considerations
- API tokens are kept server-side only
- CORS is configured for security
- Rate limiting prevents abuse
- Input validation on all endpoints
- Helmet.js for security headers
## Troubleshooting
### Locations not showing
- Verify table has `geodata`, `latitude`, and `longitude` columns
- Check that coordinates are valid numbers
- Ensure API token has read permissions
### Cannot add locations
- Verify API token has write permissions
- Check browser console for errors
- Ensure coordinates are within valid ranges
### Connection errors
- Verify NocoDB instance is accessible
- Check API URL format
- Confirm network connectivity
## License
MIT License - See LICENSE file for details
## Support
For issues or questions:
1. Check the troubleshooting section
2. Review NocoDB documentation
3. Open an issue on GitHub
```
---
## API Reference
### Endpoints
#### GET /api/locations
Fetch all locations from NocoDB table.
**Query Parameters:**
- `limit` (number): Maximum records to return (default: 1000)
- `offset` (number): Skip records for pagination (default: 0)
- `where` (string): Filter conditions
**Response:**
```json
{
"success": true,
"count": 10,
"total": 50,
"locations": [
{
"id": 1,
"geodata": "53.5461;-113.4938",
"latitude": 53.5461,
"longitude": -113.4938,
"title": "Location Name",
"description": "Description text",
"category": "Office",
"created_at": "2024-01-20T10:30:00Z"
}
]
}
```
#### POST /api/locations
Create a new location.
**Request Body:**
```json
{
"latitude": 53.5461,
"longitude": -113.4938,
"title": "New Location",
"description": "Optional description",
"category": "Office"
}
```
**Response:**
```json
{
"success": true,
"location": {
"id": 11,
"geodata": "53.5461;-113.4938",
"latitude": 53.5461,
"longitude": -113.4938,
"title": "New Location"
}
}
```
#### GET /api/locations/:id
Get a single location by ID.
**Response:**
```json
{
"success": true,
"location": {
"id": 1,
"geodata": "53.5461;-113.4938",
"latitude": 53.5461,
"longitude": -113.4938,
"title": "Location Name"
}
}
```
#### PUT /api/locations/:id
Update an existing location.
**Request Body:**
```json
{
"title": "Updated Name",
"description": "Updated description"
}
```
#### DELETE /api/locations/:id
Delete a location.
**Response:**
```json
{
"success": true,
"message": "Location deleted successfully"
}
```
### Error Responses
All endpoints return consistent error responses:
```json
{
"success": false,
"error": "Error message",
"details": {
// Additional error details when available
}
}
```
**HTTP Status Codes:**
- `200` - Success
- `201` - Created
- `400` - Bad Request (validation errors)
- `401` - Unauthorized (invalid API token)
- `404` - Not Found
- `429` - Too Many Requests (rate limited)
- `500` - Internal Server Error
- `504` - Gateway Timeout
---
## Deployment Guide
### Docker Deployment
#### Basic Deployment
```bash
# Clone repository
git clone
cd nocodb-map-viewer
# Configure environment
cp .env.example .env
nano .env # Edit with your values
# Start services
docker-compose up -d
# View logs
docker-compose logs -f
# Stop services
docker-compose down
```
#### Production Deployment
1. **Use Docker Swarm or Kubernetes** for orchestration
2. **Configure reverse proxy** (nginx/traefik) with SSL
3. **Set resource limits** in docker-compose.yml:
```yaml
deploy:
resources:
limits:
cpus: '0.5'
memory: 512M
```
4. **Enable persistent volumes** for logs:
```yaml
volumes:
- ./logs:/app/logs
```
### Manual Deployment (without Docker)
1. **Install Node.js 18+**
2. **Clone and install dependencies:**
```bash
git clone
cd nocodb-map-viewer/app
npm install --production
```
3. **Set environment variables:**
```bash
export NOCODB_API_URL=https://app.nocodb.com/api/v1
export NOCODB_API_TOKEN=your-token
export NOCODB_PROJECT_ID=p_xxxxx
export NOCODB_TABLE_ID=md_xxxxx
```
4. **Start with PM2:**
```bash
npm install -g pm2
pm2 start server.js --name nocodb-map
pm2 save
pm2 startup
```
### Nginx Configuration
```nginx
server {
listen 80;
server_name map.yourdomain.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name map.yourdomain.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
---
## Testing & Validation
### Unit Tests
Create `app/test/api.test.js`:
```javascript
const request = require('supertest');
const app = require('../server');
describe('API Endpoints', () => {
test('GET /health returns 200', async () => {
const response = await request(app).get('/health');
expect(response.status).toBe(200);
expect(response.body.status).toBe('healthy');
});
test('GET /api/locations requires authentication', async () => {
const response = await request(app).get('/api/locations');
expect(response.status).toBe(401);
});
});
```
### Integration Tests
1. **Test NocoDB Connection:**
```bash
curl -H "xc-token: YOUR_TOKEN" \
https://app.nocodb.com/api/v1/db/data/v1/PROJECT_ID/TABLE_ID
```
2. **Test Location Creation:**
```bash
curl -X POST http://localhost:3000/api/locations \
-H "Content-Type: application/json" \
-d '{"latitude": 53.5, "longitude": -113.5, "title": "Test"}'
```
### Load Testing
Using Apache Bench:
```bash
# Test read performance
ab -n 1000 -c 10 http://localhost:3000/api/locations
# Test write performance (be careful in production)
ab -n 100 -c 5 -p location.json -T application/json \
http://localhost:3000/api/locations
```
### Browser Testing
1. **Desktop Browsers:**
- Chrome 90+
- Firefox 88+
- Safari 14+
- Edge 90+
2. **Mobile Browsers:**
- iOS Safari 14+
- Chrome for Android
3. **Features to Test:**
- Map loads and displays markers
- Geolocation works on mobile
- Add location modal functions
- Responsive design adapts properly
---
## Troubleshooting
### Common Issues
#### Docker Issues
**Container fails to start:**
```bash
# Check logs
docker-compose logs map-viewer
# Rebuild container
docker-compose build --no-cache
docker-compose up -d
```
**Permission denied errors:**
```bash
# Fix ownership
docker-compose exec map-viewer chown -R nodejs:nodejs /app
```
#### API Connection Issues
**"Failed to fetch data from NocoDB":**
1. Verify API URL includes `/api/v1`
2. Check API token is valid
3. Ensure NocoDB instance is accessible
4. Check for CORS issues if self-hosted
**Rate limiting errors:**
- Implement caching to reduce API calls
- Increase rate limits in server.js
- Use pagination for large datasets
#### Map Display Issues
**Markers not showing:**
1. Open browser console (F12)
2. Check for JavaScript errors
3. Verify coordinate format in database
4. Ensure latitude/longitude are numbers, not strings
**Map tiles not loading:**
- Check internet connectivity
- Verify CSP headers allow tile server
- Try alternative tile server
### Debug Mode
Enable debug logging:
```javascript
// In server.js
const DEBUG = process.env.DEBUG === 'true';
if (DEBUG) {
console.log('Request:', req.method, req.url);
console.log('Response:', res.statusCode);
}
```
Set in environment:
```bash
DEBUG=true docker-compose up
```
### Database Issues
**Check PostgreSQL connection (if using PostgreSQL with NocoDB):**
```sql
-- Connect to database
psql -h localhost -U nocodb_user -d nocodb
-- Check table structure
\d+ your_table_name
-- Verify data format
SELECT geodata, latitude, longitude FROM your_table_name LIMIT 5;
```
---
## Security Best Practices
### API Security
1. **Token Management:**
- Rotate API tokens regularly
- Use separate tokens for dev/prod
- Never expose tokens in frontend code
- Store tokens in environment variables
2. **Rate Limiting:**
- Adjust limits based on usage patterns
- Implement IP-based blocking for abuse
- Log suspicious activity
3. **Input Validation:**
```javascript
// Example validation
function validateCoordinates(lat, lng) {
const latitude = parseFloat(lat);
const longitude = parseFloat(lng);
if (isNaN(latitude) || isNaN(longitude)) {
throw new Error('Invalid coordinates');
}
if (latitude < -90 || latitude > 90) {
throw new Error('Latitude out of range');
}
if (longitude < -180 || longitude > 180) {
throw new Error('Longitude out of range');
}
return { latitude, longitude };
}
```
### Infrastructure Security
1. **Container Security:**
- Run as non-root user
- Keep base images updated
- Scan for vulnerabilities:
```bash
docker scan nocodb-map-viewer
```
2. **Network Security:**
- Use HTTPS in production
- Configure firewall rules
- Implement VPN for admin access
3. **Monitoring:**
- Set up alerts for failed auth attempts
- Monitor API usage patterns
- Track error rates
### GDPR Compliance
If handling personal data:
1. Implement data retention policies
2. Add privacy policy
3. Enable data export/deletion
4. Log data access
---
## Future Enhancements
### Planned Features
1. **Advanced Mapping:**
- Marker clustering for large datasets
- Custom marker icons by category
- Drawing tools for areas/polygons
- Heatmap visualization
- Route planning between locations
2. **User Features:**
- User authentication
- Personal location lists
- Sharing and collaboration
- Export to various formats (KML, GeoJSON)
3. **Integration:**
- Webhook support for real-time updates
- GraphQL API option
- Mobile app (React Native)
- Desktop app (Electron)
4. **Performance:**
- Server-side marker clustering
- Tile caching proxy
- WebSocket for live updates
- Progressive Web App (PWA)
### Contributing
1. Fork the repository
2. Create feature branch (`git checkout -b feature/amazing-feature`)
3. Commit changes (`git commit -m 'Add amazing feature'`)
4. Push to branch (`git push origin feature/amazing-feature`)
5. Open Pull Request
### Code Style
- Use ESLint configuration
- Follow JavaScript Standard Style
- Write descriptive commit messages
- Add tests for new features
- Update documentation
---
## Appendix
### Environment Variable Reference
| Variable | Type | Required | Default | Description |
|----------|------|----------|---------|-------------|
| `NOCODB_API_URL` | String | Yes | - | NocoDB API base URL |
| `NOCODB_API_TOKEN` | String | Yes | - | API authentication token |
| `NOCODB_PROJECT_ID` | String | Yes | - | NocoDB project ID |
| `NOCODB_TABLE_ID` | String | Yes | - | NocoDB table ID |
| `PORT` | Number | No | 3000 | Server port |
| `NODE_ENV` | String | No | development | Environment mode |
| `DEFAULT_LAT` | Number | No | 53.5461 | Default map center latitude |
| `DEFAULT_LNG` | Number | No | -113.4938 | Default map center longitude |
| `DEFAULT_ZOOM` | Number | No | 11 | Default map zoom level |
| `BOUND_NORTH` | Number | No | - | Northern boundary limit |
| `BOUND_SOUTH` | Number | No | - | Southern boundary limit |
| `BOUND_EAST` | Number | No | - | Eastern boundary limit |
| `BOUND_WEST` | Number | No | - | Western boundary limit |
| `ALLOWED_ORIGINS` | String | No | * | CORS allowed origins (comma-separated) |
| `DEBUG` | Boolean | No | false | Enable debug logging |
### Useful Commands
```bash
# Docker commands
docker-compose up -d # Start in background
docker-compose down # Stop services
docker-compose logs -f # View logs
docker-compose ps # Check status
docker-compose exec map-viewer sh # Access container shell
# Development commands
npm run dev # Start with hot reload
npm test # Run tests
npm run lint # Check code style
# Debugging
curl http://localhost:3000/health
curl http://localhost:3000/api/config-check
docker-compose exec map-viewer node --inspect server.js
```
### Resources
- [NocoDB Documentation](https://docs.nocodb.com/)
- [Leaflet.js Documentation](https://leafletjs.com/reference.html)
- [Express.js Guide](https://expressjs.com/en/guide/routing.html)
- [Docker Compose Reference](https://docs.docker.com/compose/compose-file/)
- [OpenStreetMap Tile Usage Policy](https://operations.osmfoundation.org/policies/tiles/)
---
**Document Version:** 1.0.0
**Last Updated:** June 2025
**License:** MIT