Cesium React.
This commit is contained in:
parent
757ba8dace
commit
593b23bf73
23 changed files with 6219 additions and 5 deletions
|
|
@ -24,3 +24,8 @@ services:
|
|||
build:
|
||||
context: ./../holochron-proxy
|
||||
dockerfile: Dockerfile.${TAG}
|
||||
|
||||
holochron-frontend:
|
||||
build:
|
||||
context: ./../holochron-frontend
|
||||
dockerfile: Dockerfile.${TAG}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
53
holochron-frontend/Dockerfile.dev
Normal file
53
holochron-frontend/Dockerfile.dev
Normal 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"]
|
||||
53
holochron-frontend/Dockerfile.local
Normal file
53
holochron-frontend/Dockerfile.local
Normal 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"]
|
||||
163
holochron-frontend/QUICKSTART.md
Normal file
163
holochron-frontend/QUICKSTART.md
Normal 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! 🎯**
|
||||
258
holochron-frontend/README.md
Normal file
258
holochron-frontend/README.md
Normal 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
|
||||
37
holochron-frontend/index.html
Normal file
37
holochron-frontend/index.html
Normal 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
3787
holochron-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
27
holochron-frontend/package.json
Normal file
27
holochron-frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
234
holochron-frontend/server.js
Normal file
234
holochron-frontend/server.js
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
127
holochron-frontend/src/App.css
Normal file
127
holochron-frontend/src/App.css
Normal 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;
|
||||
}
|
||||
}
|
||||
97
holochron-frontend/src/App.jsx
Normal file
97
holochron-frontend/src/App.jsx
Normal 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;
|
||||
264
holochron-frontend/src/components/CesiumViewer.jsx
Normal file
264
holochron-frontend/src/components/CesiumViewer.jsx
Normal 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">© MapTiler</a> ' +
|
||||
'<a href="https://www.openstreetmap.org/copyright" target="_blank">© 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
305
holochron-frontend/src/components/ControlPanel.css
Normal file
305
holochron-frontend/src/components/ControlPanel.css
Normal 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;
|
||||
}
|
||||
138
holochron-frontend/src/components/ControlPanel.jsx
Normal file
138
holochron-frontend/src/components/ControlPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
134
holochron-frontend/src/hooks/useAssetTracking.js
Normal file
134
holochron-frontend/src/hooks/useAssetTracking.js
Normal 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
|
||||
};
|
||||
}
|
||||
112
holochron-frontend/src/hooks/useWebSocket.js
Normal file
112
holochron-frontend/src/hooks/useWebSocket.js
Normal 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
|
||||
};
|
||||
}
|
||||
144
holochron-frontend/src/index.css
Normal file
144
holochron-frontend/src/index.css
Normal 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;
|
||||
}
|
||||
10
holochron-frontend/src/main.jsx
Normal file
10
holochron-frontend/src/main.jsx
Normal 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>
|
||||
);
|
||||
188
holochron-frontend/src/utils/militarySymbol.js
Normal file
188
holochron-frontend/src/utils/militarySymbol.js
Normal 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 "";
|
||||
}
|
||||
20
holochron-frontend/vite.config.js
Normal file
20
holochron-frontend/vite.config.js
Normal 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': {}
|
||||
}
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
28
mosquitto/config/mosquitto.conf
Normal file
28
mosquitto/config/mosquitto.conf
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue