2579 lines
66 KiB
Markdown
2579 lines
66 KiB
Markdown
# 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()">×</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: '© <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
|
||
|