Cesium React.

This commit is contained in:
Eugeniu Semenciuc 2025-12-17 14:26:16 +02:00
commit 593b23bf73
23 changed files with 6219 additions and 5 deletions

View file

@ -24,3 +24,8 @@ services:
build:
context: ./../holochron-proxy
dockerfile: Dockerfile.${TAG}
holochron-frontend:
build:
context: ./../holochron-frontend
dockerfile: Dockerfile.${TAG}

View file

@ -55,5 +55,33 @@ services:
depends_on:
- holochron-keycloak
holochron-frontend:
image: ${IMAGE_PATH}holochron-frontend:${TAG}
container_name: holochron-frontend-${TAG}
restart: always
ports:
- "3001:3001" # HTTP/API Server
- "9002:9002" # WebSocket Server
environment:
- NODE_ENV=production
- PORT=3001
- WS_PORT=9002
- MQTT_BROKER=mqtt://mosquitto:1883
- MAPTILER_KEY=${MAPTILER_KEY:-jqMQRx4n9IUlwTxSd9PX}
depends_on:
- mosquitto
mosquitto:
image: eclipse-mosquitto:2
container_name: nato-mqtt-broker
ports:
- "1883:1883"
- "9001:9001"
volumes:
- ../mosquitto/config:/mosquitto/config
- ../mosquitto/data:/mosquitto/data
- ../mosquitto/log:/mosquitto/log
restart: unless-stopped
volumes:
keycloak_postgres_data:

View file

@ -0,0 +1,53 @@
# Stage 1: Build React application
FROM node:18-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies (use npm ci for faster, reproducible builds)
RUN npm ci
# Copy source code
COPY . .
# Build React application
RUN npm run build
# Stage 2: Production image
FROM node:18-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install only production dependencies
RUN npm ci --omit=dev
# Copy built React app from builder stage
COPY --from=builder /app/dist ./dist
# Copy server file
COPY server.js .
# Create a non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001 && \
chown -R nodejs:nodejs /app
USER nodejs
# Expose ports
# HTTP/API server
EXPOSE 3001
# WebSocket server
EXPOSE 9002
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3001/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
# Start the server
CMD ["node", "server.js"]

View file

@ -0,0 +1,53 @@
# Stage 1: Build React application
FROM node:18-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies (use npm ci for faster, reproducible builds)
RUN npm ci
# Copy source code
COPY . .
# Build React application
RUN npm run build
# Stage 2: Production image
FROM node:18-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install only production dependencies
RUN npm ci --omit=dev
# Copy built React app from builder stage
COPY --from=builder /app/dist ./dist
# Copy server file
COPY server.js .
# Create a non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001 && \
chown -R nodejs:nodejs /app
USER nodejs
# Expose ports
# HTTP/API server
EXPOSE 3001
# WebSocket server
EXPOSE 9002
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3001/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
# Start the server
CMD ["node", "server.js"]

View file

@ -0,0 +1,163 @@
# Quick Start Guide
Get the NATO Tactical Display running in 3 simple steps!
## Prerequisites
Make sure you have:
- ✅ Node.js v16+ installed
- ✅ npm or yarn installed
- ✅ MQTT broker running (e.g., Mosquitto on localhost:1883)
## Step 1: Install Dependencies
```bash
npm install
```
This will install all required packages including React, Cesium, and the backend dependencies.
## Step 2: Start the Application
### Option A: Start Everything at Once (Recommended)
```bash
npm start
```
This will start both the backend server and the React dev server simultaneously!
### Option B: Start Servers Separately
**Terminal 1 - Backend Server:**
```bash
npm run server
```
**Terminal 2 - React Frontend:**
```bash
npm run dev
```
## Step 3: Open Your Browser
Navigate to:
```
http://localhost:3000
```
You should see:
- 🌍 A 3D globe with satellite imagery
- 📊 Control panel on the left showing connection status
- 🎯 "Connected" status (green) when everything is working
## Testing with Sample Data
To test the application, publish MQTT messages to the `assets/status/#` topic:
### Using Mosquitto Client
```bash
mosquitto_pub -h localhost -t "assets/status/test" -m '{
"identifier": "TEST-001",
"description": {
"name": "Test Aircraft",
"context": "ContextEnum_REALITY",
"standard_identity": "StandardIdentityEnum_FRIEND",
"symbol_set": "SymbolSetEnum_AIR",
"status": "EntityStatusEnum_PRESENT"
},
"pose": {
"position": {
"latitude_longitude_altitude": {
"latitude": 46.72,
"longitude": 8.67,
"altitude": [{"value": 5000}]
}
}
},
"velocity": {
"speed_and_rate": {
"heading": 90
}
}
}'
```
You should see:
1. A new asset appear on the globe
2. Asset counter increase to 1
3. "Test Aircraft" appear in the asset list
4. A blue square indicator (Friend)
5. A yellow icon or point (Air asset)
## View Controls
- **🖱️ Left Click + Drag**: Rotate the globe
- **🖱️ Right Click + Drag**: Pan the view
- **🖱️ Scroll**: Zoom in/out
- **📋 Click Asset**: Fly to asset location
## View Modes
Try switching between different view modes:
- **3D**: Full 3D globe view
- **2.5D**: Columbus view (flat map with terrain)
- **2D**: Traditional 2D map
## Troubleshooting
### "Disconnected" Status
- ❌ Backend server not running → Run `npm run server`
- ❌ MQTT broker not running → Start Mosquitto or your MQTT broker
- ❌ Wrong port → Check ports in server.js and App.jsx
### No Assets Appearing
- ❌ No MQTT messages being published → Send test message (see above)
- ❌ Wrong MQTT topic → Ensure publishing to `assets/status/#`
- ❌ Invalid message format → Validate JSON structure
### Cesium Not Loading
- ❌ No internet connection → Cesium loads from CDN
- ❌ Firewall blocking → Check firewall settings
- ❌ Browser console errors → Check developer console (F12)
## Next Steps
1. ✅ Read the full [README.md](README.md) for detailed documentation
2. ✅ Configure environment variables in `.env`
3. ✅ Integrate with your MQTT data source
4. ✅ Customize symbology in `militarySymbol.js`
5. ✅ Build for production with `npm run build`
## Common Commands
```bash
# Development (both servers)
npm start
# Development (separate servers)
npm run server # Backend only
npm run dev # Frontend only
# Production build
npm run build
# Preview production build
npm run preview
```
## Need Help?
Check the console for errors:
- **Browser Console**: F12 → Console tab
- **Server Console**: Check the terminal where you ran `npm run server`
Look for:
- 🟢 "Connected to MQTT broker" → MQTT is working
- 🟢 "WebSocket client connected" → Frontend connected
- 🟢 "MQTT [assets/status/...]" → Messages being received
---
**Enjoy your NATO Tactical Display! 🎯**

View file

@ -0,0 +1,258 @@
# NATO Tactical Display - React Version
A real-time tactical display system built with React, Cesium, and MQTT for visualizing military assets on a 3D globe with MIL-STD-2525 symbology.
## 🚀 Features
- **Real-time Asset Tracking**: Live updates via MQTT and WebSocket
- **3D Visualization**: Interactive globe using Cesium with satellite imagery and terrain
- **Military Symbology**: MIL-STD-2525 compliant symbols using milsymbol.js
- **Multiple View Modes**: 3D, 2.5D (Columbus View), and 2D map views
- **Asset Management**: Track air, surface, and ground units with color-coded indicators
- **Trail Visualization**: Path history for each tracked asset
- **Responsive UI**: Modern, tactical-themed interface with status monitoring
## 📋 Prerequisites
- Node.js (v16 or higher)
- npm or yarn
- MQTT broker (e.g., Mosquitto) running on `localhost:1883`
## 🛠️ Installation
1. **Install dependencies**:
```bash
npm install
```
## 🏃 Running the Application
The application consists of two parts:
### 1. Backend Server (Node.js + WebSocket + MQTT)
Start the backend server that bridges MQTT and WebSocket:
```bash
npm run server
```
This will start:
- HTTP server on port `3001`
- WebSocket server on port `9002`
- MQTT connection to `mqtt://localhost:1883`
### 2. Frontend (React + Vite)
In a separate terminal, start the React development server:
```bash
npm run dev
```
This will start the Vite dev server on port `3000`.
### 3. Access the Application
Open your browser and navigate to:
```
http://localhost:3000
```
## 📁 Project Structure
```
nato-tactical-display/
├── src/
│ ├── components/
│ │ ├── CesiumViewer.jsx # 3D map component
│ │ ├── ControlPanel.jsx # Status and asset list
│ │ └── ControlPanel.css # Panel styling
│ ├── hooks/
│ │ ├── useWebSocket.js # WebSocket connection hook
│ │ └── useAssetTracking.js # Asset management hook
│ ├── utils/
│ │ └── militarySymbol.js # SIDC generation utilities
│ ├── App.jsx # Main application
│ ├── App.css # Application styles
│ ├── main.jsx # React entry point
│ └── index.css # Global styles
├── server.js # Backend server
├── index.html # HTML template
├── vite.config.js # Vite configuration
└── package.json # Dependencies
```
## 🔧 Configuration
### Environment Variables
You can customize the configuration using environment variables:
**Backend (server.js)**:
- `PORT`: HTTP server port (default: `3001`)
- `WS_PORT`: WebSocket server port (default: `9002`)
- `MQTT_BROKER`: MQTT broker URL (default: `mqtt://localhost:1883`)
- `MAPTILER_KEY`: MapTiler API key (optional)
**Frontend (src/App.jsx)**:
- Update `MQTT_WS_URL` to match your WebSocket server
- Update `MAPTILER_KEY` in CesiumViewer.jsx for custom map tiles
### MQTT Message Format
The system expects CATL (Common Advanced Tactical Language) format messages:
```json
{
"identifier": "ASSET-001",
"description": {
"name": "Fighter-01",
"context": "ContextEnum_REALITY",
"standard_identity": "StandardIdentityEnum_FRIEND",
"symbol_set": "SymbolSetEnum_AIR",
"status": "EntityStatusEnum_PRESENT"
},
"pose": {
"position": {
"latitude_longitude_altitude": {
"latitude": 46.72,
"longitude": 8.67,
"altitude": [{ "value": 5000 }]
}
}
},
"velocity": {
"speed_and_rate": {
"heading": 90
}
}
}
```
## 🎨 Features Breakdown
### Asset Visualization
- **Military Symbols**: Automatically generated from CATL standard identity codes
- **Fallback Display**: Colored points for assets without symbology data
- **Trail Rendering**: 60-second path history with glow effects
- **Label Display**: Asset names with dynamic positioning
### Control Panel
- **Connection Status**: Real-time WebSocket connection monitoring
- **Asset Counter**: Live count of tracked assets
- **Asset List**: Clickable list to fly to specific assets
- **View Mode Toggle**: Switch between 3D, 2.5D, and 2D views
### Asset Types & Colors
- 🟨 **Air Assets**: Yellow/Amber
- 🔵 **Surface Assets**: Blue/Cyan
- 🟢 **Ground Assets**: Green
- ⚪ **Unknown**: Gray/White
### Identity Indicators
- 🟦 **Friend**: Cyan
- 🟥 **Hostile**: Red
- 🟩 **Neutral**: Green
- 🟨 **Unknown**: Yellow
## 🔨 Build for Production
To create a production build:
```bash
npm run build
```
The built files will be in the `dist/` directory.
To preview the production build:
```bash
npm run preview
```
## 🐛 Troubleshooting
### WebSocket Connection Failed
- Ensure the backend server is running on port 3001
- Check that WebSocket port 9002 is not blocked by firewall
- Verify MQTT broker is running and accessible
### Assets Not Appearing
- Check MQTT broker is publishing to `assets/status/#` topic
- Verify message format matches CATL specification
- Check browser console for parsing errors
### Cesium Not Loading
- Ensure you have internet connection for CDN resources
- Check MapTiler API key is valid
- Verify no CSP (Content Security Policy) blocks
### Performance Issues
- Limit number of simultaneous assets (< 100 recommended)
- Reduce trail time in CesiumViewer.jsx
- Lower map tile resolution
## 📝 Development
### Adding New Features
1. **New Asset Types**: Update `getAssetType()` in `militarySymbol.js`
2. **Custom Symbols**: Modify `generateSIDC()` for additional SIDC codes
3. **UI Components**: Add to `src/components/`
4. **Hooks**: Add custom hooks to `src/hooks/`
### Code Style
- Use ES6+ features
- Follow React hooks best practices
- Use functional components
- Implement proper error handling
- Add PropTypes or TypeScript for type safety
## 🔐 Security Considerations
- WebSocket connections are not encrypted by default (use WSS in production)
- MQTT credentials should be secured with environment variables
- Implement authentication for production deployments
- Validate all incoming data
- Sanitize user inputs
## 📚 Technologies Used
- **React 18**: UI framework
- **Vite**: Build tool and dev server
- **Cesium**: 3D globe and mapping
- **WebSocket**: Real-time communication
- **MQTT**: Message broker integration
- **milsymbol.js**: Military symbol rendering
- **Express**: Backend HTTP server
## 📄 License
This project is for demonstration purposes. Ensure compliance with all applicable regulations when deploying in production environments.
## 🤝 Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Test thoroughly
5. Submit a pull request
## 💡 Tips
- Use `npm run dev` for hot module reloading during development
- Monitor the console for asset updates and errors
- Click on assets in the list to fly to their location
- Use the view mode buttons to switch perspectives
- Assets are automatically cleaned up after 5 minutes of inactivity
## 📞 Support
For issues or questions:
1. Check the browser console for errors
2. Verify MQTT messages are being received
3. Check network requests in DevTools
4. Review server logs for connection issues

View file

@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NATO Tactical Display</title>
<!-- Military symbology library -->
<script src="https://cdn.jsdelivr.net/npm/milsymbol@2.0.0/dist/milsymbol.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #root {
width: 100%;
height: 100%;
overflow: hidden;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

3787
holochron-frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,27 @@
{
"name": "nato-tactical-display",
"version": "1.0.0",
"description": "NATO Tactical Display with React and Cesium",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"server": "node server.js",
"start": "concurrently \"npm run server\" \"npm run dev\"",
"start:dev": "concurrently -n \"SERVER,CLIENT\" -c \"bgBlue.bold,bgMagenta.bold\" \"npm run server\" \"npm run dev\""
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"cesium": "^1.111.0",
"express": "^4.18.2",
"ws": "^8.14.2",
"mqtt": "^5.1.2"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.0",
"vite": "^5.0.0",
"vite-plugin-cesium": "^1.2.22",
"concurrently": "^8.2.2"
}
}

View file

@ -0,0 +1,234 @@
const express = require('express');
const http = require('http');
const WebSocket = require('ws');
const mqtt = require('mqtt');
const path = require('path');
const fs = require('fs');
// Configuration
const PORT = process.env.PORT || 3001;
const WS_PORT = process.env.WS_PORT || 9002;
const MQTT_BROKER = process.env.MQTT_BROKER || 'mqtt://localhost:1883';
// Create Express app
const app = express();
// Enable CORS for React dev server
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
next();
});
// Parse JSON bodies
app.use(express.json());
// Health check endpoint
app.get('/health', (req, res) => {
res.json({
status: 'ok',
mqtt: mqttClient.connected ? 'connected' : 'disconnected',
websockets: wss.clients.size
});
});
// Config endpoint - serves client configuration
app.get('/api/config', (req, res) => {
res.json({
maptilerKey: process.env.MAPTILER_KEY || ''
});
});
// ========================================
// SERVE STATIC FILES IN PRODUCTION
// ========================================
if (process.env.NODE_ENV === 'production') {
const distPath = path.join(__dirname, 'dist');
console.log('🎯 Production mode: Serving static files from', distPath);
// Check if dist folder exists
if (!fs.existsSync(distPath)) {
console.error('❌ ERROR: dist folder not found at', distPath);
console.error('📁 Directory contents:', fs.readdirSync(__dirname));
} else {
const distContents = fs.readdirSync(distPath);
console.log('✓ Found dist folder with', distContents.length, 'items');
console.log('📁 Dist contents:', distContents.slice(0, 10));
}
// Serve static files (CSS, JS, images, etc.)
app.use(express.static(distPath, {
index: false, // Don't serve index.html automatically
fallthrough: true
}));
// Serve index.html for all other routes (SPA support)
app.get('*', (req, res, next) => {
// Skip API routes and health check
if (req.path.startsWith('/api') || req.path === '/health') {
return next();
}
const indexPath = path.join(distPath, 'index.html');
if (fs.existsSync(indexPath)) {
console.log('📄 Serving SPA route:', req.path, '→ index.html');
res.sendFile(indexPath);
} else {
console.error('❌ index.html not found at', indexPath);
res.status(404).send('Frontend build not found. Please check Docker build logs.');
}
});
} else {
console.log('🔧 Development mode: Not serving static files (use Vite dev server)');
}
// Create HTTP server
const server = http.createServer(app);
// Create WebSocket server
const wss = new WebSocket.Server({ port: WS_PORT });
// Connect to MQTT broker
const mqttClient = mqtt.connect(MQTT_BROKER, {
reconnectPeriod: 5000
});
mqttClient.on('connect', () => {
console.log('✓ Connected to MQTT broker at', MQTT_BROKER);
mqttClient.subscribe('assets/status/#', (err) => {
if (err) {
console.error('✗ MQTT subscription error:', err);
} else {
console.log('✓ Subscribed to assets/status/#');
}
});
});
mqttClient.on('error', (error) => {
console.error('✗ MQTT error:', error);
});
mqttClient.on('message', (topic, message) => {
console.log(`📨 MQTT [${topic}]: ${message.length} bytes`);
console.log(` ℹ️ WebSocket clients connected: ${wss.clients.size}`);
try {
// Parse the MQTT message
let messageData = JSON.parse(message.toString());
// Check if this is a CATL message (has header and body)
// If so, extract the body since that's what the frontend expects
let actualData = messageData;
if (messageData.header && messageData.body) {
// This is a CATL message - use the body
actualData = messageData.body;
console.log(` → CATL message, extracted body for: ${actualData.identifier}`);
}
// Wrap it in the expected format
const wrappedMessage = {
type: 'asset_update',
topic: topic,
timestamp: new Date().toISOString(),
body: actualData
};
// Broadcast to all connected WebSocket clients
const messageStr = JSON.stringify(wrappedMessage);
let sentCount = 0;
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
try {
client.send(messageStr);
sentCount++;
console.log(` → Sent to WebSocket client`);
} catch (error) {
console.error(' ✗ Error sending to WebSocket client:', error);
}
}
});
if (sentCount === 0) {
console.log(` ⚠️ No clients in OPEN state to send to!`);
}
} catch (error) {
console.error('Error processing MQTT message:', error);
}
});
// WebSocket connection handling
wss.on('connection', (ws) => {
console.log('✓ WebSocket client connected. Total clients:', wss.clients.size);
// Send welcome message
ws.send(JSON.stringify({
type: 'connection',
status: 'connected',
timestamp: new Date().toISOString()
}));
ws.on('message', (message) => {
try {
const data = JSON.parse(message);
console.log('📨 WebSocket message:', data);
// Handle subscription requests
if (data.type === 'subscribe' && data.topic) {
mqttClient.subscribe(data.topic, (err) => {
if (err) {
ws.send(JSON.stringify({ type: 'error', message: 'Subscription failed' }));
} else {
ws.send(JSON.stringify({ type: 'subscribed', topic: data.topic }));
console.log(`✓ Subscribed to ${data.topic} for WebSocket client`);
}
});
}
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
});
ws.on('close', () => {
console.log('✗ WebSocket client disconnected. Total clients:', wss.clients.size);
});
ws.on('error', (error) => {
console.error('WebSocket error:', error);
});
});
// Start HTTP server
server.listen(PORT, () => {
console.log(`
╔════════════════════════════════════════╗
║ NATO Tactical Display Server ║
║ ║
║ HTTP Server: http://0.0.0.0:${PORT} ║
║ WebSocket Server: ws://0.0.0.0:${WS_PORT} ║
║ MQTT Broker: ${MQTT_BROKER.padEnd(19)} ║
║ ║
║ Status: Running ║
║ ║
║ Note: React dev server runs on :3000 ║
╚════════════════════════════════════════╝
`);
});
// Graceful shutdown
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
function shutdown() {
console.log('\nShutting down gracefully...');
server.close(() => {
console.log('HTTP server closed');
wss.close(() => {
console.log('WebSocket server closed');
mqttClient.end();
process.exit(0);
});
});
}

View file

@ -0,0 +1,127 @@
.app {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
background: var(--bg-primary);
}
/* Loading indicator */
.loading-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--bg-primary);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
animation: fadeIn 0.3s ease;
}
.loading-content {
text-align: center;
color: var(--text-primary);
}
.loading-spinner {
width: 48px;
height: 48px;
border: 4px solid var(--border-color);
border-top-color: var(--accent-cyan);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
.loading-text {
font-size: 14px;
color: var(--text-secondary);
margin-top: 8px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Error overlay */
.error-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--bg-secondary);
color: var(--text-primary);
padding: 32px;
border-radius: 12px;
box-shadow: var(--shadow-lg);
border: 1px solid var(--border-color);
z-index: 9999;
max-width: 400px;
text-align: center;
animation: fadeIn 0.3s ease;
}
.error-icon {
font-size: 48px;
margin-bottom: 16px;
}
.error-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
color: var(--accent-red);
}
.error-message {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 20px;
line-height: 1.6;
}
.error-button {
padding: 10px 24px;
background: var(--accent-cyan);
color: var(--bg-primary);
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all var(--transition-base);
}
.error-button:hover {
background: var(--accent-green);
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.error-button:active {
transform: translateY(0);
}
/* Responsive layout */
@media (max-width: 768px) {
.loading-overlay,
.error-overlay {
padding: 20px;
}
.error-overlay {
max-width: calc(100vw - 40px);
}
}
/* Print styles */
@media print {
.app {
background: white;
}
}

View file

@ -0,0 +1,97 @@
import { useState, useCallback, useRef } from 'react';
import CesiumViewer from './components/CesiumViewer';
import ControlPanel from './components/ControlPanel';
import { useWebSocket } from './hooks/useWebSocket';
import { useAssetTracking } from './hooks/useAssetTracking';
import * as Cesium from 'cesium';
import './App.css';
const MQTT_WS_URL = "ws://localhost:9002";
function App() {
const viewerRef = useRef(null);
const [sceneMode, setSceneMode] = useState('3D');
// Asset tracking
const {
assets,
assetCount,
lastUpdate,
updateAsset
} = useAssetTracking();
// Handle incoming WebSocket messages
const handleMessage = useCallback((data) => {
// Handle different message types
if (data.type === 'connection') {
console.log("✓ Connection acknowledged");
} else if (data.type === 'subscribed') {
console.log("✓ Subscribed to:", data.topic);
} else if (data.type === 'asset_update' && data.body) {
updateAsset(data.body);
} else if (data.body && data.body.identifier) {
// Direct CATL message format
updateAsset(data.body);
} else if (data.identifier) {
// Direct format (if MQTT sends data directly)
updateAsset(data);
}
}, [updateAsset]);
// WebSocket connection
const { status: connectionStatus } = useWebSocket(MQTT_WS_URL, handleMessage);
// Handle viewer ready
const handleViewerReady = useCallback((viewer) => {
viewerRef.current = viewer;
console.log("✓ Viewer ready");
}, []);
// Handle asset click - fly to asset
const handleAssetClick = useCallback((asset) => {
if (!viewerRef.current) return;
const viewer = viewerRef.current;
const entity = viewer.entities.getById(asset.identifier);
if (entity) {
viewer.flyTo(entity, {
duration: 2,
offset: new Cesium.HeadingPitchRange(
0,
Cesium.Math.toRadians(-45),
5000
)
}).catch(error => {
console.error("Error flying to asset:", error);
});
}
}, []);
// Handle scene mode change
const handleSceneModeChange = useCallback((mode) => {
setSceneMode(mode);
}, []);
return (
<div className="app">
<CesiumViewer
assets={assets}
onViewerReady={handleViewerReady}
sceneMode={sceneMode}
/>
<ControlPanel
connectionStatus={connectionStatus}
assetCount={assetCount}
lastUpdate={lastUpdate}
assets={assets}
onAssetClick={handleAssetClick}
sceneMode={sceneMode}
onSceneModeChange={handleSceneModeChange}
/>
</div>
);
}
export default App;

View file

@ -0,0 +1,264 @@
import { useEffect, useRef } from 'react';
import * as Cesium from 'cesium';
import { generateMilSymbol, getIdentityColor, getAssetType } from '../utils/militarySymbol';
import 'cesium/Build/Cesium/Widgets/widgets.css';
const MAPTILER_KEY = "jqMQRx4n9IUlwTxSd9PX";
/**
* CesiumViewer Component
* Renders the 3D globe with terrain and handles asset visualization
*/
export default function CesiumViewer({ assets, onViewerReady, sceneMode = '3D' }) {
const containerRef = useRef(null);
const viewerRef = useRef(null);
const entitiesRef = useRef(new Map());
// Initialize Cesium viewer
useEffect(() => {
if (!containerRef.current || viewerRef.current) return;
console.log("🚀 Initializing Cesium viewer...");
try {
// Create imagery provider
const imagery = new Cesium.ImageryLayer(
new Cesium.UrlTemplateImageryProvider({
url: `https://api.maptiler.com/tiles/satellite-v2/{z}/{x}/{y}.jpg?key=${MAPTILER_KEY}`,
minimumLevel: 0,
maximumLevel: 20,
tileWidth: 512,
tileHeight: 512,
credit: new Cesium.Credit(
'<a href="https://www.maptiler.com/copyright/" target="_blank">&copy; MapTiler</a> ' +
'<a href="https://www.openstreetmap.org/copyright" target="_blank">&copy; OpenStreetMap contributors</a>',
true
),
})
);
// Create viewer
const viewer = new Cesium.Viewer(containerRef.current, {
baseLayer: imagery,
animation: false,
baseLayerPicker: false,
navigationHelpButton: false,
sceneModePicker: false,
homeButton: false,
geocoder: false,
fullscreenButton: false,
timeline: false,
vrButton: false,
});
// Initialize terrain
Cesium.CesiumTerrainProvider.fromUrl(
`https://api.maptiler.com/tiles/terrain-quantized-mesh-v2/?key=${MAPTILER_KEY}`,
{ requestVertexNormals: true }
).then(terrainProvider => {
viewer.terrainProvider = terrainProvider;
console.log("✓ Terrain loaded");
}).catch(error => {
console.error("✗ Error loading terrain:", error);
});
// Set initial camera view
viewer.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(8.67, 46.72, 5000000),
orientation: {
heading: Cesium.Math.toRadians(0),
pitch: Cesium.Math.toRadians(-45),
roll: 0,
},
});
viewerRef.current = viewer;
console.log("✓ Cesium viewer initialized");
if (onViewerReady) {
onViewerReady(viewer);
}
} catch (error) {
console.error("✗ Error initializing Cesium:", error);
}
// Cleanup on unmount
return () => {
if (viewerRef.current) {
viewerRef.current.destroy();
viewerRef.current = null;
}
};
}, [onViewerReady]);
// Update assets on the map
useEffect(() => {
if (!viewerRef.current) return;
const viewer = viewerRef.current;
// Update or create entities for each asset
assets.forEach((asset, identifier) => {
let entity = entitiesRef.current.get(identifier);
if (!entity) {
// Create new entity
entity = createAssetEntity(viewer, asset);
entitiesRef.current.set(identifier, entity);
} else {
// Update existing entity
updateAssetEntity(entity, asset);
}
});
// Remove entities for assets that no longer exist
entitiesRef.current.forEach((entity, identifier) => {
if (!assets.has(identifier)) {
viewer.entities.remove(entity);
entitiesRef.current.delete(identifier);
}
});
}, [assets]);
// Handle scene mode changes
useEffect(() => {
if (!viewerRef.current) return;
const viewer = viewerRef.current;
switch(sceneMode) {
case '3D':
viewer.scene.morphTo3D(1.0);
break;
case '2.5D':
viewer.scene.morphToColumbusView(1.0);
break;
case '2D':
viewer.scene.morphTo2D(1.0);
break;
}
}, [sceneMode]);
return (
<div
ref={containerRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%'
}}
/>
);
}
/**
* Create a new Cesium entity for an asset
*/
function createAssetEntity(viewer, asset) {
const { identifier, description, position, velocity } = asset;
const assetType = getAssetType(description.symbol_set);
const identityColor = getIdentityColor(description.standard_identity);
// Try to generate military symbol
const symbolImage = generateMilSymbol(description, 64);
const entityConfig = {
id: identifier,
name: description.name || identifier,
position: Cesium.Cartesian3.fromDegrees(position.lon, position.lat, position.alt),
properties: {
identifier: identifier,
type: assetType,
description: description,
fullData: asset.data
}
};
// Add billboard with military symbol if available
if (symbolImage) {
entityConfig.billboard = {
image: symbolImage,
scale: 1.0,
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
heightReference: Cesium.HeightReference.NONE,
disableDepthTestDistance: Number.POSITIVE_INFINITY
};
console.log("✓ Using military symbol for", identifier);
} else {
// Fallback to colored point
let color = identityColor || Cesium.Color.WHITE;
if (!identityColor || identityColor.equals(Cesium.Color.WHITE)) {
if (assetType === "air") {
color = Cesium.Color.YELLOW;
} else if (assetType === "surface") {
color = Cesium.Color.BLUE;
} else if (assetType === "ground") {
color = Cesium.Color.GREEN;
}
}
entityConfig.point = {
pixelSize: 12,
color: color,
outlineColor: Cesium.Color.WHITE,
outlineWidth: 2,
heightReference: Cesium.HeightReference.NONE,
};
console.log("⚠ Using fallback point for", identifier);
}
// Add label
entityConfig.label = {
text: description.name || identifier,
font: "14pt sans-serif",
fillColor: Cesium.Color.WHITE,
outlineColor: Cesium.Color.BLACK,
outlineWidth: 2,
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
verticalOrigin: symbolImage ? Cesium.VerticalOrigin.TOP : Cesium.VerticalOrigin.BOTTOM,
pixelOffset: new Cesium.Cartesian2(0, symbolImage ? 10 : -15),
heightReference: Cesium.HeightReference.NONE,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
};
// Add path trail
const trailColor = identityColor || Cesium.Color.WHITE;
entityConfig.path = {
resolution: 1,
material: new Cesium.PolylineGlowMaterialProperty({
glowPower: 0.1,
color: trailColor,
}),
width: 2,
leadTime: 0,
trailTime: 60,
};
return viewer.entities.add(entityConfig);
}
/**
* Update an existing Cesium entity
*/
function updateAssetEntity(entity, asset) {
const { position, velocity } = asset;
// Update position
entity.position = Cesium.Cartesian3.fromDegrees(position.lon, position.lat, position.alt);
// Update orientation if heading is available
if (velocity?.heading !== undefined && !isNaN(velocity.heading)) {
const heading = Cesium.Math.toRadians(velocity.heading);
const currentPos = entity.position.getValue(Cesium.JulianDate.now());
if (currentPos) {
entity.orientation = Cesium.Transforms.headingPitchRollQuaternion(
currentPos,
new Cesium.HeadingPitchRoll(heading, 0, 0)
);
}
}
}

View file

@ -0,0 +1,305 @@
.control-panel {
position: absolute;
top: 16px;
left: 16px;
background: var(--bg-secondary);
color: var(--text-primary);
padding: 20px;
border-radius: 12px;
z-index: 1000;
min-width: 340px;
max-width: 400px;
box-shadow: var(--shadow-lg);
border: 1px solid var(--border-color);
backdrop-filter: blur(10px);
animation: slideIn 0.4s ease;
}
.control-panel-header {
margin-bottom: 16px;
}
.control-panel-header h2 {
font-size: 20px;
font-weight: 600;
border-bottom: 2px solid var(--border-color);
padding-bottom: 12px;
margin: 0;
letter-spacing: -0.02em;
}
.status-section {
margin: 16px 0;
}
.status-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
font-size: 14px;
transition: background var(--transition-fast);
}
.status-item:hover {
background: rgba(255, 255, 255, 0.03);
border-radius: 6px;
padding: 8px 8px;
margin: 0 -8px;
}
.status-label {
color: var(--text-secondary);
font-weight: 500;
}
.status-value {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.status-value.connected {
color: var(--accent-green);
animation: pulse 2s ease-in-out infinite;
}
.status-value.connecting {
color: var(--accent-yellow);
}
.status-value.disconnected {
color: var(--accent-red);
}
.scene-mode-section {
margin: 16px 0;
padding: 12px 0;
border-top: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color);
}
.scene-mode-section .status-label {
display: block;
margin-bottom: 8px;
font-size: 14px;
}
.scene-mode-buttons {
display: flex;
gap: 6px;
}
.mode-btn {
flex: 1;
padding: 8px 16px;
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all var(--transition-base);
position: relative;
overflow: hidden;
}
.mode-btn::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
transition: left var(--transition-slow);
}
.mode-btn:hover::before {
left: 100%;
}
.mode-btn:hover {
background: var(--bg-tertiary);
border-color: var(--accent-cyan);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(57, 208, 216, 0.2);
}
.mode-btn.active {
background: var(--accent-green);
border-color: var(--accent-green);
color: var(--bg-primary);
font-weight: 600;
box-shadow: 0 2px 8px rgba(63, 185, 80, 0.3);
}
.mode-btn:active {
transform: translateY(0);
}
.asset-list {
margin-top: 16px;
}
.asset-list strong {
display: block;
margin-bottom: 12px;
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
}
.asset-list-content {
max-height: 280px;
overflow-y: auto;
padding-right: 4px;
}
.no-assets {
color: var(--text-muted);
padding: 16px;
text-align: center;
font-size: 13px;
font-style: italic;
}
.asset-item {
display: flex;
align-items: center;
padding: 10px 12px;
margin: 4px 0;
background: var(--bg-tertiary);
border-radius: 8px;
cursor: pointer;
transition: all var(--transition-fast);
font-size: 13px;
border-left: 3px solid transparent;
position: relative;
overflow: hidden;
}
.asset-item::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 3px;
height: 100%;
transition: width var(--transition-base);
}
.asset-item:hover {
background: rgba(255, 255, 255, 0.08);
transform: translateX(4px);
box-shadow: var(--shadow-sm);
}
.asset-item:hover::before {
width: 100%;
opacity: 0.05;
}
.asset-item:active {
transform: translateX(2px);
}
.asset-item.asset-air {
border-left-color: var(--warning-amber);
}
.asset-item.asset-air::before {
background: var(--warning-amber);
}
.asset-item.asset-surface {
border-left-color: var(--accent-cyan);
}
.asset-item.asset-surface::before {
background: var(--accent-cyan);
}
.asset-item.asset-ground {
border-left-color: var(--accent-green);
}
.asset-item.asset-ground::before {
background: var(--accent-green);
}
.asset-item.asset-unknown {
border-left-color: var(--neutral-gray);
}
.asset-item.asset-unknown::before {
background: var(--neutral-gray);
}
.asset-indicator {
margin-right: 8px;
font-size: 14px;
flex-shrink: 0;
}
.asset-name {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
}
/* Scrollbar styling */
.asset-list-content::-webkit-scrollbar {
width: 6px;
}
.asset-list-content::-webkit-scrollbar-track {
background: var(--bg-secondary);
border-radius: 3px;
}
.asset-list-content::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
transition: background var(--transition-fast);
}
.asset-list-content::-webkit-scrollbar-thumb:hover {
background: var(--neutral-gray);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.control-panel {
min-width: 280px;
max-width: calc(100vw - 32px);
}
.asset-list-content {
max-height: 200px;
}
}
/* Loading state */
.control-panel.loading {
opacity: 0.7;
pointer-events: none;
}
/* Animation for new assets */
@keyframes assetAppear {
from {
opacity: 0;
transform: translateX(-10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.asset-item {
animation: assetAppear 0.3s ease;
}

View file

@ -0,0 +1,138 @@
import { getIdentityIndicator, getAssetType } from '../utils/militarySymbol';
import './ControlPanel.css';
/**
* ControlPanel Component
* Displays connection status, asset count, and list of tracked assets
*/
export default function ControlPanel({
connectionStatus,
assetCount,
lastUpdate,
assets,
onAssetClick,
sceneMode,
onSceneModeChange
}) {
const getStatusClass = () => {
switch(connectionStatus) {
case 'connected':
return 'connected';
case 'connecting':
return 'connecting';
case 'disconnected':
case 'error':
case 'failed':
return 'disconnected';
default:
return '';
}
};
const getStatusText = () => {
switch(connectionStatus) {
case 'connected':
return 'Connected';
case 'connecting':
return 'Connecting...';
case 'disconnected':
return 'Disconnected';
case 'error':
return 'Error';
case 'failed':
return 'Failed';
default:
return 'Unknown';
}
};
const formatTime = (date) => {
if (!date) return 'Never';
return date.toLocaleTimeString();
};
// Convert Map to Array and sort by identifier
const assetArray = Array.from(assets.values()).sort((a, b) =>
a.identifier.localeCompare(b.identifier)
);
return (
<div className="control-panel">
<div className="control-panel-header">
<h2>🎯 NATO Tactical Display</h2>
</div>
<div className="status-section">
<div className="status-item">
<span className="status-label">Connection:</span>
<span className={`status-value ${getStatusClass()}`}>
{getStatusText()}
</span>
</div>
<div className="status-item">
<span className="status-label">Active Assets:</span>
<span className="status-value">{assetCount}</span>
</div>
<div className="status-item">
<span className="status-label">Last Update:</span>
<span className="status-value">{formatTime(lastUpdate)}</span>
</div>
</div>
<div className="scene-mode-section">
<span className="status-label">View Mode:</span>
<div className="scene-mode-buttons">
<button
className={`mode-btn ${sceneMode === '3D' ? 'active' : ''}`}
onClick={() => onSceneModeChange('3D')}
>
3D
</button>
<button
className={`mode-btn ${sceneMode === '2.5D' ? 'active' : ''}`}
onClick={() => onSceneModeChange('2.5D')}
>
2.5D
</button>
<button
className={`mode-btn ${sceneMode === '2D' ? 'active' : ''}`}
onClick={() => onSceneModeChange('2D')}
>
2D
</button>
</div>
</div>
<div className="asset-list">
<strong>Assets:</strong>
<div className="asset-list-content">
{assetArray.length === 0 ? (
<div className="no-assets">
{connectionStatus === 'connected' ? 'Waiting for data...' : 'No connection'}
</div>
) : (
assetArray.map(asset => {
const type = getAssetType(asset.description.symbol_set);
const indicator = getIdentityIndicator(asset.description.standard_identity);
return (
<div
key={asset.identifier}
className={`asset-item asset-${type}`}
onClick={() => onAssetClick && onAssetClick(asset)}
>
<span className="asset-indicator">{indicator}</span>
<span className="asset-name">
{asset.description.name || asset.identifier}
</span>
</div>
);
})
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,134 @@
import { useState, useCallback, useEffect } from 'react';
/**
* Custom hook for tracking and managing assets
* @returns {Object} Asset state and management functions
*/
export function useAssetTracking() {
const [assets, setAssets] = useState(new Map());
const [lastUpdate, setLastUpdate] = useState(null);
/**
* Update or create an asset
*/
const updateAsset = useCallback((assetData) => {
try {
// Validate required fields
if (!assetData.identifier) {
console.warn('⚠️ Asset update missing identifier:', assetData);
return null;
}
// Check for position data
if (!assetData.pose || !assetData.pose.position || !assetData.pose.position.latitude_longitude_altitude) {
console.warn('⚠️ Asset update missing position data:', assetData.identifier);
return null;
}
const position = assetData.pose.position.latitude_longitude_altitude;
const velocity = assetData.velocity?.speed_and_rate || {};
const description = assetData.description || {};
const lat = position.latitude;
const lon = position.longitude;
const alt = position.altitude && position.altitude[0] ? position.altitude[0].value : 0;
if (isNaN(lat) || isNaN(lon) || isNaN(alt)) {
console.warn('⚠️ Invalid position data:', { lat, lon, alt });
return null;
}
const asset = {
identifier: assetData.identifier,
description,
position: { lat, lon, alt },
velocity,
heading: velocity.heading,
lastUpdate: new Date(),
data: assetData
};
setAssets(prev => new Map(prev).set(assetData.identifier, asset));
setLastUpdate(new Date());
console.log(`✓ Updated asset ${assetData.identifier} at [${lat.toFixed(4)}, ${lon.toFixed(4)}, ${alt.toFixed(0)}m]`);
return asset;
} catch (error) {
console.error('✗ Error updating asset:', error);
console.error('Asset data:', assetData);
return null;
}
}, []);
/**
* Remove an asset
*/
const removeAsset = useCallback((identifier) => {
setAssets(prev => {
const newMap = new Map(prev);
newMap.delete(identifier);
return newMap;
});
console.log('🗑️ Removed asset:', identifier);
}, []);
/**
* Clean up old/stale assets (not updated in the last 5 minutes)
*/
const cleanupStaleAssets = useCallback(() => {
const now = Date.now();
const maxAge = 5 * 60 * 1000; // 5 minutes
let removed = 0;
setAssets(prev => {
const newMap = new Map(prev);
newMap.forEach((asset, identifier) => {
if (now - asset.lastUpdate.getTime() > maxAge) {
console.log('🗑️ Removing stale asset:', identifier);
newMap.delete(identifier);
removed++;
}
});
return newMap;
});
if (removed > 0) {
console.log(`Cleaned up ${removed} stale assets`);
}
}, []);
/**
* Get asset by identifier
*/
const getAsset = useCallback((identifier) => {
return assets.get(identifier);
}, [assets]);
/**
* Clear all assets
*/
const clearAssets = useCallback(() => {
setAssets(new Map());
console.log('🗑️ Cleared all assets');
}, []);
// Cleanup interval
useEffect(() => {
const interval = setInterval(cleanupStaleAssets, 60000); // Run every minute
return () => clearInterval(interval);
}, [cleanupStaleAssets]);
return {
assets,
assetCount: assets.size,
lastUpdate,
updateAsset,
removeAsset,
getAsset,
clearAssets,
cleanupStaleAssets
};
}

View file

@ -0,0 +1,112 @@
import { useState, useEffect, useRef, useCallback } from 'react';
/**
* Custom hook for WebSocket connection management
* @param {string} url - WebSocket URL
* @param {Function} onMessage - Callback for incoming messages
* @returns {Object} Connection state and control functions
*/
export function useWebSocket(url, onMessage) {
const [status, setStatus] = useState('connecting');
const [reconnectAttempts, setReconnectAttempts] = useState(0);
const wsRef = useRef(null);
const reconnectTimeoutRef = useRef(null);
const MAX_RECONNECT_ATTEMPTS = 5;
const connect = useCallback(() => {
try {
console.log('🔌 Connecting to WebSocket:', url);
const ws = new WebSocket(url);
ws.onopen = () => {
console.log('✓ WebSocket connected');
setStatus('connected');
setReconnectAttempts(0);
// Subscribe to asset updates
ws.send(JSON.stringify({ type: 'subscribe', topic: 'assets/status/#' }));
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log('📨 Received:', data);
if (onMessage) {
onMessage(data);
}
} catch (error) {
console.error('✗ Error parsing WebSocket message:', error);
console.error('Raw message:', event.data);
}
};
ws.onerror = (error) => {
console.error('✗ WebSocket error:', error);
setStatus('error');
};
ws.onclose = () => {
console.log('✗ WebSocket closed');
setStatus('disconnected');
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
const nextAttempt = reconnectAttempts + 1;
setReconnectAttempts(nextAttempt);
const delay = Math.min(1000 * Math.pow(2, nextAttempt), 30000);
console.log(`Reconnecting in ${delay / 1000}s... (attempt ${nextAttempt}/${MAX_RECONNECT_ATTEMPTS})`);
reconnectTimeoutRef.current = setTimeout(() => {
connect();
}, delay);
} else {
console.error('Max reconnection attempts reached');
setStatus('failed');
}
};
wsRef.current = ws;
} catch (error) {
console.error('✗ Error creating WebSocket:', error);
setStatus('error');
}
}, [url, onMessage, reconnectAttempts]);
const disconnect = useCallback(() => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
setStatus('disconnected');
}, []);
const send = useCallback((data) => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(data));
} else {
console.warn('WebSocket is not open. Cannot send message.');
}
}, []);
useEffect(() => {
connect();
return () => {
disconnect();
};
}, [url]); // Only reconnect if URL changes
return {
status,
reconnectAttempts,
send,
disconnect
};
}

View file

@ -0,0 +1,144 @@
:root {
/* Tactical color palette */
--nato-blue: #003893;
--tactical-green: #00ff41;
--alert-red: #ff3b3b;
--warning-amber: #ffb627;
--neutral-gray: #8b9ba3;
/* Dark theme */
--bg-primary: #0a0e14;
--bg-secondary: #151b23;
--bg-tertiary: #1e2730;
--border-color: #2d3848;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--text-muted: #6e7681;
/* Accents */
--accent-cyan: #39d0d8;
--accent-green: #3fb950;
--accent-yellow: #d29922;
--accent-red: #f85149;
/* Shadows */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
/* Transitions */
--transition-fast: 150ms ease;
--transition-base: 250ms ease;
--transition-slow: 350ms ease;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #root {
width: 100%;
height: 100%;
overflow: hidden;
background: var(--bg-primary);
color: var(--text-primary);
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
line-height: 1.6;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
transition: background var(--transition-fast);
}
::-webkit-scrollbar-thumb:hover {
background: var(--neutral-gray);
}
/* Utility classes */
.text-success {
color: var(--accent-green);
}
.text-error {
color: var(--accent-red);
}
.text-warning {
color: var(--accent-yellow);
}
.text-info {
color: var(--accent-cyan);
}
/* Animations */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideIn {
from {
transform: translateX(-20px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.fade-in {
animation: fadeIn 0.3s ease;
}
.slide-in {
animation: slideIn 0.3s ease;
}
.pulse {
animation: pulse 2s ease-in-out infinite;
}
/* Focus styles for accessibility */
button:focus-visible,
a:focus-visible {
outline: 2px solid var(--accent-cyan);
outline-offset: 2px;
}

View file

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View file

@ -0,0 +1,188 @@
/**
* Military Symbol Generation Utilities
* Handles SIDC generation and symbol rendering for MIL-STD-2525
*/
/**
* Generate SIDC (Symbol Identification Code) from CATL message description
* @param {Object} description - Asset description from CATL message
* @returns {string} 15-character SIDC code
*/
export function generateSIDC(description) {
// SIDC structure: 15 characters for MIL-STD-2525C/D
// Version (0-1): Always "10" for 2525C
let sidc = "10";
// Context (2): Reality/Exercise/Simulation
const contextMap = {
"ContextEnum_REALITY": "0",
"ContextEnum_EXERCISE": "1",
"ContextEnum_SIMULATION": "2"
};
sidc += contextMap[description.context] || "0";
// Standard Identity (3): Friend/Hostile/Neutral/Unknown
const identityMap = {
"StandardIdentityEnum_PENDING": "0",
"StandardIdentityEnum_UNKNOWN": "1",
"StandardIdentityEnum_ASSUMED_FRIEND": "2",
"StandardIdentityEnum_FRIEND": "3",
"StandardIdentityEnum_NEUTRAL": "4",
"StandardIdentityEnum_SUSPECT": "5",
"StandardIdentityEnum_HOSTILE": "6"
};
sidc += identityMap[description.standard_identity] || "1";
// Symbol Set (4-5): Air/Ground/Sea/etc
const symbolSetMap = {
"SymbolSetEnum_AIR": "01",
"SymbolSetEnum_AIR_MISSILE": "02",
"SymbolSetEnum_SPACE": "05",
"SymbolSetEnum_SPACE_MISSILE": "06",
"SymbolSetEnum_GROUND_UNIT": "10",
"SymbolSetEnum_GROUND_EQUIPMENT": "15",
"SymbolSetEnum_GROUND_INSTALLATION": "20",
"SymbolSetEnum_SEA_SURFACE": "30",
"SymbolSetEnum_SEA_SUBSURFACE": "35",
"SymbolSetEnum_ACTIVITIES": "40",
"SymbolSetEnum_LAND": "10",
"SymbolSetEnum_SURFACE": "30"
};
sidc += symbolSetMap[description.symbol_set] || "00";
// Status (6): Present/Anticipated/etc
const statusMap = {
"EntityStatusEnum_ANTICIPATED_PLANNED": "0",
"EntityStatusEnum_PRESENT": "1",
"EntityStatusEnum_PRESENT_FULLY_CAPABLE": "2",
"EntityStatusEnum_PRESENT_DAMAGED": "3",
"EntityStatusEnum_PRESENT_DESTROYED": "4",
"EntityStatusEnum_PRESENT_FULL_TO_CAPACITY": "5"
};
sidc += statusMap[description.status] || "1";
// HQ/Task Force/Dummy (7)
const hqtfdMap = {
"HeadquartersTaskForceDummyEnum_NOT_APPLICABLE": "0",
"HeadquartersTaskForceDummyEnum_FEINT_DUMMY": "1",
"HeadquartersTaskForceDummyEnum_HEADQUARTERS": "2",
"HeadquartersTaskForceDummyEnum_FEINT_DUMMY_HEADQUARTERS": "3",
"HeadquartersTaskForceDummyEnum_TASK_FORCE": "4",
"HeadquartersTaskForceDummyEnum_FEINT_DUMMY_TASK_FORCE": "5",
"HeadquartersTaskForceDummyEnum_TASK_FORCE_HEADQUARTERS": "6"
};
sidc += hqtfdMap[description.headquarters_task_force_dummy] || "0";
// Echelon/Mobility (8-9)
const echelonMap = {
"UnitEchelonEquipmentMobilityEnum_UNKNOWN": "00",
"UnitEchelonEquipmentMobilityEnum_TEAM_CREW": "11",
"UnitEchelonEquipmentMobilityEnum_SQUAD": "12",
"UnitEchelonEquipmentMobilityEnum_SECTION": "13",
"UnitEchelonEquipmentMobilityEnum_PLATOON_DETACHMENT": "14",
"UnitEchelonEquipmentMobilityEnum_COMPANY_BATTERY_TROOP": "15",
"UnitEchelonEquipmentMobilityEnum_BATTALION_SQUADRON": "16",
"UnitEchelonEquipmentMobilityEnum_REGIMENT_GROUP": "17",
"UnitEchelonEquipmentMobilityEnum_BRIGADE": "18",
"UnitEchelonEquipmentMobilityEnum_DIVISION": "21",
"UnitEchelonEquipmentMobilityEnum_CORPS": "22",
"UnitEchelonEquipmentMobilityEnum_ARMY": "23"
};
sidc += echelonMap[description.unit_echelon_equipment_mobility] || "00";
// Entity/Type (10-15): Generic for now
sidc += "000000";
return sidc;
}
/**
* Generate military symbol as a data URL using milsymbol.js
* @param {Object} description - Asset description
* @param {number} size - Symbol size in pixels
* @returns {string|null} Data URL of the symbol or null if generation fails
*/
export function generateMilSymbol(description, size = 64) {
if (typeof window === 'undefined' || typeof window.ms === 'undefined') {
console.warn('milsymbol library not loaded');
return null;
}
try {
const sidc = generateSIDC(description);
// Create symbol with milsymbol.js
const symbol = new window.ms.Symbol(sidc, {
size: size,
civilianColor: false,
colorMode: description.standard_identity?.includes("FRIEND") ? "Light" : "Medium"
});
// Get symbol as data URL
return symbol.toDataURL();
} catch (error) {
console.error("Error generating military symbol:", error);
return null;
}
}
/**
* Get Cesium color based on standard identity
* @param {string} standardIdentity - Standard identity enum value
* @returns {Object} Cesium.Color object
*/
export function getIdentityColor(standardIdentity) {
// Import Cesium dynamically in component usage
if (typeof window === 'undefined' || !window.Cesium) {
return null;
}
const Cesium = window.Cesium;
const colorMap = {
"StandardIdentityEnum_FRIEND": Cesium.Color.CYAN,
"StandardIdentityEnum_HOSTILE": Cesium.Color.RED,
"StandardIdentityEnum_NEUTRAL": Cesium.Color.GREEN,
"StandardIdentityEnum_UNKNOWN": Cesium.Color.YELLOW,
"StandardIdentityEnum_ASSUMED_FRIEND": Cesium.Color.LIGHTBLUE,
"StandardIdentityEnum_SUSPECT": Cesium.Color.ORANGE
};
return colorMap[standardIdentity] || Cesium.Color.WHITE;
}
/**
* Determine asset type from symbol set
* @param {string} symbolSet - Symbol set enum value
* @returns {string} Asset type: 'air', 'surface', 'ground', or 'unknown'
*/
export function getAssetType(symbolSet) {
const symbolSetUpper = (symbolSet || "").toUpperCase();
if (symbolSetUpper.includes("AIR") || symbolSetUpper.includes("AIRCRAFT")) {
return "air";
} else if (symbolSetUpper.includes("SURFACE") || symbolSetUpper.includes("SEA") || symbolSetUpper.includes("SHIP")) {
return "surface";
} else if (symbolSetUpper.includes("GROUND") || symbolSetUpper.includes("LAND") || symbolSetUpper.includes("VEHICLE")) {
return "ground";
}
return "unknown";
}
/**
* Get identity indicator emoji for UI display
* @param {string} standardIdentity - Standard identity enum value
* @returns {string} Emoji indicator
*/
export function getIdentityIndicator(standardIdentity) {
if (!standardIdentity) return "";
if (standardIdentity.includes("FRIEND")) return "🟦 ";
if (standardIdentity.includes("HOSTILE")) return "🟥 ";
if (standardIdentity.includes("NEUTRAL")) return "🟩 ";
if (standardIdentity.includes("UNKNOWN")) return "🟨 ";
return "";
}

View file

@ -0,0 +1,20 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import cesium from 'vite-plugin-cesium';
export default defineConfig({
plugins: [
react(),
cesium()
],
server: {
port: 3000,
proxy: {
'/health': 'http://localhost:3001',
'/api': 'http://localhost:3001'
}
},
define: {
'process.env': {}
}
});

View file

@ -25,12 +25,14 @@ server {
proxy_pass http://holochron-keycloak-local:8090;
}
location = / {
add_header Content-Type text/plain;
location / {
include /etc/nginx/conf.d/x-forwarded-for.local.conf;
# It's not enough to add X-source at the server level as having add_header in location causes
# higher level add_headers to be ignored
proxy_pass http://holochron-frontend-local:3001/;
add_header X-source holochron-reverse-proxy;
return 200 'holochron root endpoint.';
add_header Strict-Transport-Security "max-age=63072000";
add_header X-Frame-Options SAMEORIGIN always;
add_header Referrer-Policy strict-origin-when-cross-origin;
add_header X-Content-Type-Options nosniff;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
}
}

View file

@ -0,0 +1,28 @@
# Mosquitto Configuration for Tactical Display
# Persistence
persistence true
persistence_location /mosquitto/data/
# Logging
log_dest file /mosquitto/log/mosquitto.log
log_dest stdout
log_type all
log_timestamp true
# Default listener (MQTT)
listener 1883
protocol mqtt
allow_anonymous true
# WebSocket listener
listener 9001
protocol websockets
allow_anonymous true
# Connection settings
max_connections -1
max_queued_messages 1000
# Message size
message_size_limit 10485760