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

2579 lines
66 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Interactive map viewer for NocoDB location data">
<title>NocoDB Map Viewer</title>
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="" />
<!-- Custom CSS -->
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<div id="app">
<!-- Header -->
<header class="header">
<h1>Location Map Viewer</h1>
<div class="header-actions">
<button id="refresh-btn" class="btn btn-secondary" title="Refresh locations">
🔄 Refresh
</button>
<span id="location-count" class="location-count">Loading...</span>
</div>
</header>
<!-- Map Container -->
<div id="map-container">
<div id="map"></div>
<!-- Map Controls -->
<div class="map-controls">
<button id="geolocate-btn" class="btn btn-primary" title="Find my location">
📍 My Location
</button>
<button id="add-location-btn" class="btn btn-success" title="Add location at map center">
Add Location Here
</button>
<button id="fullscreen-btn" class="btn btn-secondary" title="Toggle fullscreen">
⛶ Fullscreen
</button>
</div>
<!-- Crosshair for adding locations -->
<div id="crosshair" class="crosshair hidden">
<div class="crosshair-x"></div>
<div class="crosshair-y"></div>
<div class="crosshair-info">Click "Add Location Here" to save this point</div>
</div>
</div>
<!-- Status Messages -->
<div id="status-container" class="status-container"></div>
<!-- Add Location Modal -->
<div id="add-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h2>Add New Location</h2>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<form id="location-form">
<div class="form-group">
<label for="location-title">Title *</label>
<input type="text" id="location-title" name="title" required
placeholder="Enter location name">
</div>
<div class="form-row">
<div class="form-group">
<label for="location-lat">Latitude</label>
<input type="number" id="location-lat" name="latitude"
step="0.00000001" readonly>
</div>
<div class="form-group">
<label for="location-lng">Longitude</label>
<input type="number" id="location-lng" name="longitude"
step="0.00000001" readonly>
</div>
</div>
<div class="form-group">
<label for="location-description">Description</label>
<textarea id="location-description" name="description"
rows="3" placeholder="Additional details..."></textarea>
</div>
<div class="form-group">
<label for="location-category">Category</label>
<input type="text" id="location-category" name="category"
placeholder="e.g., Office, Site, POI">
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="closeModal()">
Cancel
</button>
<button type="submit" class="btn btn-primary">
Save Location
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Loading Overlay -->
<div id="loading" class="loading-overlay">
<div class="spinner"></div>
<p>Loading map...</p>
</div>
</div>
<!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<!-- Application JavaScript -->
<script src="js/map.js"></script>
</body>
</html>
```
#### 8. `app/public/css/style.css`
```css
/* 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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: CONFIG.MAX_ZOOM,
minZoom: CONFIG.MIN_ZOOM
}).addTo(map);
// Add scale control
L.control.scale({
position: 'bottomleft',
metric: true,
imperial: false
}).addTo(map);
// Hide loading overlay
document.getElementById('loading').classList.add('hidden');
}
// Set up event listeners
function setupEventListeners() {
// Geolocation button
document.getElementById('geolocate-btn').addEventListener('click', handleGeolocation);
// Add location button
document.getElementById('add-location-btn').addEventListener('click', toggleAddLocation);
// Refresh button
document.getElementById('refresh-btn').addEventListener('click', () => {
showStatus('Refreshing locations...', 'info');
loadLocations();
});
// Fullscreen button
document.getElementById('fullscreen-btn').addEventListener('click', toggleFullscreen);
// Form submission
document.getElementById('location-form').addEventListener('submit', handleLocationSubmit);
// Map click handler for adding locations
map.on('click', handleMapClick);
}
// Check API configuration
async function checkConfiguration() {
try {
const response = await fetch('/api/config-check');
const data = await response.json();
if (!data.configured) {
showStatus('Warning: API not fully configured. Check your .env file.', 'warning');
}
} catch (error) {
console.error('Configuration check failed:', error);
}
}
// Load locations from API
async function loadLocations() {
try {
const response = await fetch('/api/locations');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.success) {
displayLocations(data.locations);
updateLocationCount(data.count);
} else {
throw new Error(data.error || 'Failed to load locations');
}
} catch (error) {
console.error('Error loading locations:', error);
showStatus('Failed to load locations. Check your connection.', 'error');
updateLocationCount(0);
}
}
// Display locations on map
function displayLocations(locations) {
// Clear existing markers
markers.forEach(marker => map.removeLayer(marker));
markers = [];
// Add new markers
locations.forEach(location => {
if (location.latitude && location.longitude) {
const marker = createLocationMarker(location);
markers.push(marker);
}
});
// Fit map to show all markers if there are any
if (markers.length > 0) {
const group = new L.featureGroup(markers);
map.fitBounds(group.getBounds().pad(0.1));
}
}
// Create marker for location
function createLocationMarker(location) {
const marker = L.marker([location.latitude, location.longitude], {
title: location.title || 'Location',
riseOnHover: true
}).addTo(map);
// Create popup content
const popupContent = createPopupContent(location);
marker.bindPopup(popupContent);
return marker;
}
// Create popup content for marker
function createPopupContent(location) {
let content = '<div class="popup-content">';
if (location.title) {
content += `<h3>${escapeHtml(location.title)}</h3>`;
}
if (location.description) {
content += `<p>${escapeHtml(location.description)}</p>`;
}
if (location.category) {
content += `<p><strong>Category:</strong> ${escapeHtml(location.category)}</p>`;
}
content += '<div class="popup-meta">';
content += `<p><strong>Coordinates:</strong> ${location.latitude.toFixed(6)}, ${location.longitude.toFixed(6)}</p>`;
if (location.created_at) {
const date = new Date(location.created_at);
content += `<p><strong>Added:</strong> ${date.toLocaleDateString()}</p>`;
}
content += '</div>';
content += '</div>';
return content;
}
// Handle geolocation
function handleGeolocation() {
if (!navigator.geolocation) {
showStatus('Geolocation is not supported by your browser', 'error');
return;
}
showStatus('Getting your location...', 'info');
navigator.geolocation.getCurrentPosition(
(position) => {
const { latitude, longitude, accuracy } = position.coords;
// Center map on user location
map.setView([latitude, longitude], 15);
// Remove existing user marker
if (userLocationMarker) {
map.removeLayer(userLocationMarker);
}
// Add user location marker
userLocationMarker = L.marker([latitude, longitude], {
icon: L.divIcon({
html: '<div style="background-color: #2c5aa0; width: 20px; height: 20px; border-radius: 50%; border: 3px solid white; box-shadow: 0 2px 5px rgba(0,0,0,0.3);"></div>',
className: 'user-location-marker',
iconSize: [20, 20],
iconAnchor: [10, 10]
}),
title: 'Your location'
}).addTo(map);
// Add accuracy circle
L.circle([latitude, longitude], {
radius: accuracy,
color: '#2c5aa0',
fillColor: '#2c5aa0',
fillOpacity: 0.1,
weight: 1
}).addTo(map);
showStatus(`Location found (±${Math.round(accuracy)}m accuracy)`, 'success');
},
(error) => {
let message = 'Unable to get your location';
switch (error.code) {
case error.PERMISSION_DENIED:
message = 'Location permission denied';
break;
case error.POSITION_UNAVAILABLE:
message = 'Location information unavailable';
break;
case error.TIMEOUT:
message = 'Location request timed out';
break;
}
showStatus(message, 'error');
},
{
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 0
}
);
}
// Toggle add location mode
function toggleAddLocation() {
isAddingLocation = !isAddingLocation;
const btn = document.getElementById('add-location-btn');
const crosshair = document.getElementById('crosshair');
if (isAddingLocation) {
btn.textContent = '✕ Cancel';
btn.classList.remove('btn-success');
btn.classList.add('btn-secondary');
crosshair.classList.remove('hidden');
map.getContainer().style.cursor = 'crosshair';
} else {
btn.textContent = ' Add Location Here';
btn.classList.remove('btn-secondary');
btn.classList.add('btn-success');
crosshair.classList.add('hidden');
map.getContainer().style.cursor = '';
}
}
// Handle map click
function handleMapClick(e) {
if (!isAddingLocation) return;
const { lat, lng } = e.latlng;
// Toggle off add location mode
toggleAddLocation();
// Show modal with coordinates
showAddLocationModal(lat, lng);
}
// Show add location modal
function showAddLocationModal(lat, lng) {
const modal = document.getElementById('add-modal');
const latInput = document.getElementById('location-lat');
const lngInput = document.getElementById('location-lng');
// Set coordinates
latInput.value = lat.toFixed(8);
lngInput.value = lng.toFixed(8);
// Clear other fields
document.getElementById('location-title').value = '';
document.getElementById('location-description').value = '';
document.getElementById('location-category').value = '';
// Show modal
modal.classList.remove('hidden');
// Focus on title input
setTimeout(() => {
document.getElementById('location-title').focus();
}, 100);
}
// Close modal
function closeModal() {
document.getElementById('add-modal').classList.add('hidden');
}
// Handle location form submission
async function handleLocationSubmit(e) {
e.preventDefault();
const formData = new FormData(e.target);
const data = Object.fromEntries(formData);
// Validate required fields
if (!data.title || !data.title.trim()) {
showStatus('Title is required', 'error');
return;
}
try {
const response = await fetch('/api/locations', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok && result.success) {
showStatus('Location added successfully!', 'success');
closeModal();
// Reload locations
loadLocations();
// Center map on new location
map.setView([data.latitude, data.longitude], map.getZoom());
} else {
throw new Error(result.error || 'Failed to add location');
}
} catch (error) {
console.error('Error adding location:', error);
showStatus(error.message, 'error');
}
}
// Toggle fullscreen
function toggleFullscreen() {
const app = document.getElementById('app');
const btn = document.getElementById('fullscreen-btn');
if (!document.fullscreenElement) {
app.requestFullscreen().then(() => {
app.classList.add('fullscreen');
btn.textContent = '✕ Exit Fullscreen';
// Invalidate map size after transition
setTimeout(() => map.invalidateSize(), 300);
}).catch(err => {
showStatus('Unable to enter fullscreen', 'error');
});
} else {
document.exitFullscreen().then(() => {
app.classList.remove('fullscreen');
btn.textContent = '⛶ Fullscreen';
// Invalidate map size after transition
setTimeout(() => map.invalidateSize(), 300);
});
}
}
// Update location count
function updateLocationCount(count) {
const countElement = document.getElementById('location-count');
countElement.textContent = `${count} location${count !== 1 ? 's' : ''}`;
}
// Show status message
function showStatus(message, type = 'info') {
const container = document.getElementById('status-container');
const messageDiv = document.createElement('div');
messageDiv.className = `status-message ${type}`;
messageDiv.textContent = message;
container.appendChild(messageDiv);
// Auto-remove after 5 seconds
setTimeout(() => {
messageDiv.remove();
}, 5000);
}
// Escape HTML to prevent XSS
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Handle window resize
window.addEventListener('resize', () => {
map.invalidateSize();
});
// Clean up on page unload
window.addEventListener('beforeunload', () => {
if (refreshInterval) {
clearInterval(refreshInterval);
}
});
// Make closeModal function global for onclick handler
window.closeModal = closeModal;
```
#### 10. `README.md`
```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 <repository-url>
cd nocodb-map-viewer
# Configure environment
cp .env.example .env
nano .env # Edit with your values
# Start services
docker-compose up -d
# View logs
docker-compose logs -f
# Stop services
docker-compose down
```
#### Production Deployment
1. **Use Docker Swarm or Kubernetes** for orchestration
2. **Configure reverse proxy** (nginx/traefik) with SSL
3. **Set resource limits** in docker-compose.yml:
```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 <repository-url>
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