Implement 6 optional enhancements for the wifi-Mat module: 1. Hardware Integration (csi_receiver.rs + hardware_adapter.rs) - ESP32 CSI support via serial/UDP - Intel 5300 BFEE file parsing - Atheros CSI Tool integration - Live UDP packet streaming - PCAP replay capability 2. CLI Commands (wifi-densepose-cli/src/mat.rs) - `wifi-mat scan` - Run disaster detection scan - `wifi-mat status` - Check event status - `wifi-mat zones` - Manage scan zones - `wifi-mat survivors` - List detected survivors - `wifi-mat alerts` - View and acknowledge alerts - `wifi-mat export` - Export data in various formats 3. REST API (wifi-densepose-mat/src/api/) - Full CRUD for disaster events - Zone management endpoints - Survivor and alert queries - WebSocket streaming for real-time updates - Comprehensive DTOs and error handling 4. WASM Build (wifi-densepose-wasm/src/mat.rs) - Browser-based disaster dashboard - Real-time survivor tracking - Zone visualization - Alert management - JavaScript API bindings 5. Detection Benchmarks (benches/detection_bench.rs) - Single survivor detection - Multi-survivor detection - Full pipeline benchmarks - Signal processing benchmarks - Hardware adapter benchmarks 6. ML Models for Debris Penetration (ml/) - DebrisModel for material analysis - VitalSignsClassifier for triage - FFT-based feature extraction - Bandpass filtering - Monte Carlo dropout for uncertainty All 134 unit tests pass. Compilation verified for: - wifi-densepose-mat - wifi-densepose-cli - wifi-densepose-wasm (with mat feature)
1083 lines
36 KiB
HTML
1083 lines
36 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>WiFi-Mat Disaster Response Dashboard</title>
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
|
background: #1a1a2e;
|
|
color: #eee;
|
|
min-height: 100vh;
|
|
}
|
|
|
|
.header {
|
|
background: linear-gradient(135deg, #16213e 0%, #0f3460 100%);
|
|
padding: 1rem 2rem;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
|
|
}
|
|
|
|
.header h1 {
|
|
font-size: 1.5rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.header h1::before {
|
|
content: '';
|
|
width: 12px;
|
|
height: 12px;
|
|
background: #00ff88;
|
|
border-radius: 50%;
|
|
animation: pulse 2s infinite;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; transform: scale(1); }
|
|
50% { opacity: 0.5; transform: scale(0.8); }
|
|
}
|
|
|
|
.event-info {
|
|
font-size: 0.9rem;
|
|
color: #aaa;
|
|
}
|
|
|
|
.main-container {
|
|
display: grid;
|
|
grid-template-columns: 1fr 300px;
|
|
gap: 1rem;
|
|
padding: 1rem;
|
|
height: calc(100vh - 80px);
|
|
}
|
|
|
|
.map-section {
|
|
background: #16213e;
|
|
border-radius: 8px;
|
|
padding: 1rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.map-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.map-header h2 {
|
|
font-size: 1.1rem;
|
|
}
|
|
|
|
.map-controls {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.map-controls button {
|
|
padding: 0.5rem 1rem;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 0.85rem;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.btn-primary {
|
|
background: #0066ff;
|
|
color: white;
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
background: #0052cc;
|
|
}
|
|
|
|
.btn-secondary {
|
|
background: #333;
|
|
color: white;
|
|
}
|
|
|
|
.btn-secondary:hover {
|
|
background: #444;
|
|
}
|
|
|
|
.btn-danger {
|
|
background: #cc0000;
|
|
color: white;
|
|
}
|
|
|
|
.btn-danger:hover {
|
|
background: #aa0000;
|
|
}
|
|
|
|
.canvas-container {
|
|
flex: 1;
|
|
position: relative;
|
|
background: #0a0a1a;
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
#mapCanvas {
|
|
width: 100%;
|
|
height: 100%;
|
|
cursor: crosshair;
|
|
}
|
|
|
|
.sidebar {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.panel {
|
|
background: #16213e;
|
|
border-radius: 8px;
|
|
padding: 1rem;
|
|
}
|
|
|
|
.panel h3 {
|
|
font-size: 0.95rem;
|
|
margin-bottom: 0.75rem;
|
|
padding-bottom: 0.5rem;
|
|
border-bottom: 1px solid #333;
|
|
}
|
|
|
|
/* Statistics */
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, 1fr);
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.stat-item {
|
|
background: #0a0a1a;
|
|
padding: 0.75rem;
|
|
border-radius: 4px;
|
|
text-align: center;
|
|
}
|
|
|
|
.stat-value {
|
|
font-size: 1.5rem;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 0.75rem;
|
|
color: #888;
|
|
margin-top: 0.25rem;
|
|
}
|
|
|
|
.stat-immediate .stat-value { color: #ff0000; }
|
|
.stat-delayed .stat-value { color: #ffcc00; }
|
|
.stat-minor .stat-value { color: #00cc00; }
|
|
.stat-total .stat-value { color: #0096ff; }
|
|
|
|
/* Triage Legend */
|
|
.legend {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.legend-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.legend-color {
|
|
width: 16px;
|
|
height: 16px;
|
|
border-radius: 50%;
|
|
border: 2px solid white;
|
|
}
|
|
|
|
.legend-immediate { background: #ff0000; }
|
|
.legend-delayed { background: #ffcc00; }
|
|
.legend-minor { background: #00cc00; }
|
|
.legend-deceased { background: #333333; }
|
|
.legend-unknown { background: #999999; }
|
|
|
|
/* Alerts Panel */
|
|
.alerts-list {
|
|
max-height: 200px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.alert-item {
|
|
background: #0a0a1a;
|
|
padding: 0.75rem;
|
|
border-radius: 4px;
|
|
margin-bottom: 0.5rem;
|
|
border-left: 3px solid #ff0000;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.alert-item:hover {
|
|
background: #1a1a3a;
|
|
}
|
|
|
|
.alert-item.priority-critical { border-left-color: #ff0000; }
|
|
.alert-item.priority-high { border-left-color: #ff6600; }
|
|
.alert-item.priority-medium { border-left-color: #ffcc00; }
|
|
.alert-item.priority-low { border-left-color: #0066ff; }
|
|
|
|
.alert-title {
|
|
font-weight: bold;
|
|
font-size: 0.85rem;
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.alert-message {
|
|
font-size: 0.75rem;
|
|
color: #aaa;
|
|
}
|
|
|
|
.alert-time {
|
|
font-size: 0.7rem;
|
|
color: #666;
|
|
margin-top: 0.25rem;
|
|
}
|
|
|
|
/* Survivors Panel */
|
|
.survivors-list {
|
|
max-height: 250px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.survivor-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.5rem;
|
|
background: #0a0a1a;
|
|
border-radius: 4px;
|
|
margin-bottom: 0.5rem;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.survivor-item:hover {
|
|
background: #1a1a3a;
|
|
}
|
|
|
|
.survivor-marker {
|
|
width: 12px;
|
|
height: 12px;
|
|
border-radius: 50%;
|
|
border: 2px solid white;
|
|
}
|
|
|
|
.survivor-info {
|
|
flex: 1;
|
|
}
|
|
|
|
.survivor-id {
|
|
font-size: 0.8rem;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.survivor-details {
|
|
font-size: 0.7rem;
|
|
color: #888;
|
|
}
|
|
|
|
.survivor-vital {
|
|
font-size: 0.75rem;
|
|
padding: 0.25rem 0.5rem;
|
|
border-radius: 2px;
|
|
background: #333;
|
|
}
|
|
|
|
/* Notification Toast */
|
|
.toast-container {
|
|
position: fixed;
|
|
top: 1rem;
|
|
right: 1rem;
|
|
z-index: 1000;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.toast {
|
|
background: #16213e;
|
|
padding: 1rem;
|
|
border-radius: 8px;
|
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
|
border-left: 4px solid #ff0000;
|
|
animation: slideIn 0.3s ease;
|
|
max-width: 350px;
|
|
}
|
|
|
|
.toast.critical { border-left-color: #ff0000; background: #2a1a1a; }
|
|
.toast.high { border-left-color: #ff6600; }
|
|
.toast.medium { border-left-color: #ffcc00; }
|
|
.toast.low { border-left-color: #0066ff; }
|
|
|
|
@keyframes slideIn {
|
|
from { transform: translateX(100%); opacity: 0; }
|
|
to { transform: translateX(0); opacity: 1; }
|
|
}
|
|
|
|
.toast-title {
|
|
font-weight: bold;
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.toast-message {
|
|
font-size: 0.85rem;
|
|
color: #aaa;
|
|
}
|
|
|
|
.toast-close {
|
|
position: absolute;
|
|
top: 0.5rem;
|
|
right: 0.5rem;
|
|
background: none;
|
|
border: none;
|
|
color: #666;
|
|
cursor: pointer;
|
|
font-size: 1.2rem;
|
|
}
|
|
|
|
/* Modal */
|
|
.modal-overlay {
|
|
display: none;
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0, 0, 0, 0.7);
|
|
z-index: 1001;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
|
|
.modal-overlay.active {
|
|
display: flex;
|
|
}
|
|
|
|
.modal {
|
|
background: #16213e;
|
|
padding: 2rem;
|
|
border-radius: 8px;
|
|
width: 90%;
|
|
max-width: 500px;
|
|
}
|
|
|
|
.modal h2 {
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.modal-form {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.form-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.form-group label {
|
|
font-size: 0.85rem;
|
|
color: #aaa;
|
|
}
|
|
|
|
.form-group input,
|
|
.form-group select {
|
|
padding: 0.75rem;
|
|
border: 1px solid #333;
|
|
border-radius: 4px;
|
|
background: #0a0a1a;
|
|
color: white;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.form-actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
justify-content: flex-end;
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
/* Responsive */
|
|
@media (max-width: 900px) {
|
|
.main-container {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.sidebar {
|
|
flex-direction: row;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.sidebar .panel {
|
|
flex: 1;
|
|
min-width: 280px;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header class="header">
|
|
<h1>WiFi-Mat Disaster Response</h1>
|
|
<div class="event-info" id="eventInfo">No active event</div>
|
|
</header>
|
|
|
|
<div class="main-container">
|
|
<section class="map-section">
|
|
<div class="map-header">
|
|
<h2>Scan Zone Map</h2>
|
|
<div class="map-controls">
|
|
<button class="btn-primary" onclick="showCreateEventModal()">New Event</button>
|
|
<button class="btn-secondary" onclick="addRectZone()">+ Rectangle Zone</button>
|
|
<button class="btn-secondary" onclick="addCircleZone()">+ Circle Zone</button>
|
|
<button class="btn-secondary" onclick="simulateSurvivor()">Simulate Detection</button>
|
|
</div>
|
|
</div>
|
|
<div class="canvas-container">
|
|
<canvas id="mapCanvas"></canvas>
|
|
</div>
|
|
</section>
|
|
|
|
<aside class="sidebar">
|
|
<div class="panel">
|
|
<h3>Statistics</h3>
|
|
<div class="stats-grid">
|
|
<div class="stat-item stat-total">
|
|
<div class="stat-value" id="statTotal">0</div>
|
|
<div class="stat-label">Total Survivors</div>
|
|
</div>
|
|
<div class="stat-item stat-immediate">
|
|
<div class="stat-value" id="statImmediate">0</div>
|
|
<div class="stat-label">Immediate</div>
|
|
</div>
|
|
<div class="stat-item stat-delayed">
|
|
<div class="stat-value" id="statDelayed">0</div>
|
|
<div class="stat-label">Delayed</div>
|
|
</div>
|
|
<div class="stat-item stat-minor">
|
|
<div class="stat-value" id="statMinor">0</div>
|
|
<div class="stat-label">Minor</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<h3>Triage Legend</h3>
|
|
<div class="legend">
|
|
<div class="legend-item">
|
|
<div class="legend-color legend-immediate"></div>
|
|
<span>Immediate (Red) - Life-threatening</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<div class="legend-color legend-delayed"></div>
|
|
<span>Delayed (Yellow) - Serious</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<div class="legend-color legend-minor"></div>
|
|
<span>Minor (Green) - Walking wounded</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<div class="legend-color legend-deceased"></div>
|
|
<span>Deceased (Black)</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<div class="legend-color legend-unknown"></div>
|
|
<span>Unknown (Gray)</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<h3>Active Alerts</h3>
|
|
<div class="alerts-list" id="alertsList">
|
|
<p style="color: #666; font-size: 0.85rem;">No active alerts</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<h3>Detected Survivors</h3>
|
|
<div class="survivors-list" id="survivorsList">
|
|
<p style="color: #666; font-size: 0.85rem;">No survivors detected</p>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
|
|
<div class="toast-container" id="toastContainer"></div>
|
|
|
|
<!-- Create Event Modal -->
|
|
<div class="modal-overlay" id="createEventModal">
|
|
<div class="modal">
|
|
<h2>Create Disaster Event</h2>
|
|
<div class="modal-form">
|
|
<div class="form-group">
|
|
<label>Disaster Type</label>
|
|
<select id="disasterType">
|
|
<option value="earthquake">Earthquake</option>
|
|
<option value="building_collapse">Building Collapse</option>
|
|
<option value="landslide">Landslide</option>
|
|
<option value="avalanche">Avalanche</option>
|
|
<option value="flood">Flood</option>
|
|
<option value="mine_collapse">Mine Collapse</option>
|
|
<option value="industrial">Industrial Accident</option>
|
|
<option value="tunnel_collapse">Tunnel Collapse</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Location (Latitude)</label>
|
|
<input type="number" id="eventLat" value="37.7749" step="0.0001">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Location (Longitude)</label>
|
|
<input type="number" id="eventLng" value="-122.4194" step="0.0001">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Description</label>
|
|
<input type="text" id="eventDesc" placeholder="Enter event description">
|
|
</div>
|
|
<div class="form-actions">
|
|
<button class="btn-secondary" onclick="hideCreateEventModal()">Cancel</button>
|
|
<button class="btn-primary" onclick="createEvent()">Create Event</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script type="module">
|
|
// WiFi-Mat Dashboard JavaScript
|
|
// This connects to the WASM module for disaster response functionality
|
|
|
|
// Import WASM module (adjust path based on your build output)
|
|
// import init, { MatDashboard, initLogging } from './pkg/wifi_densepose_wasm.js';
|
|
|
|
// For demo purposes, we'll create a mock implementation
|
|
// In production, this would be the real WASM module
|
|
|
|
class MockMatDashboard {
|
|
constructor() {
|
|
this.eventId = null;
|
|
this.zones = new Map();
|
|
this.survivors = new Map();
|
|
this.alerts = [];
|
|
this.callbacks = {};
|
|
this.zoneCounter = 0;
|
|
}
|
|
|
|
createEvent(type, lat, lng, description) {
|
|
this.eventId = crypto.randomUUID();
|
|
this.eventStart = Date.now();
|
|
this.eventType = type;
|
|
this.description = description;
|
|
console.log(`Event created: ${this.eventId}`);
|
|
return this.eventId;
|
|
}
|
|
|
|
getEventId() {
|
|
return this.eventId;
|
|
}
|
|
|
|
addRectangleZone(name, x, y, width, height) {
|
|
const id = crypto.randomUUID();
|
|
this.zones.set(id, {
|
|
id, name, type: 'rectangle', x, y, width, height,
|
|
status: 0, scan_count: 0, detection_count: 0
|
|
});
|
|
if (this.callbacks.onZoneUpdated) {
|
|
this.callbacks.onZoneUpdated(this.zones.get(id));
|
|
}
|
|
return id;
|
|
}
|
|
|
|
addCircleZone(name, centerX, centerY, radius) {
|
|
const id = crypto.randomUUID();
|
|
this.zones.set(id, {
|
|
id, name, type: 'circle', centerX, centerY, radius,
|
|
status: 0, scan_count: 0, detection_count: 0
|
|
});
|
|
if (this.callbacks.onZoneUpdated) {
|
|
this.callbacks.onZoneUpdated(this.zones.get(id));
|
|
}
|
|
return id;
|
|
}
|
|
|
|
simulateSurvivorDetection(x, y, depth, triage, confidence) {
|
|
const id = crypto.randomUUID();
|
|
const colors = ['#ff0000', '#ffcc00', '#00cc00', '#333333', '#999999'];
|
|
const survivor = {
|
|
id,
|
|
zone_id: Array.from(this.zones.keys())[0] || '',
|
|
x, y, depth,
|
|
triage_status: triage,
|
|
triage_color: colors[triage] || '#999999',
|
|
confidence,
|
|
breathing_rate: 12 + Math.random() * 8,
|
|
heart_rate: 60 + Math.random() * 40,
|
|
first_detected: new Date().toISOString(),
|
|
last_updated: new Date().toISOString(),
|
|
is_deteriorating: Math.random() < 0.2
|
|
};
|
|
this.survivors.set(id, survivor);
|
|
|
|
if (this.callbacks.onSurvivorDetected) {
|
|
this.callbacks.onSurvivorDetected(survivor);
|
|
}
|
|
|
|
// Generate alert for urgent survivors
|
|
if (triage <= 1) {
|
|
const alert = {
|
|
id: crypto.randomUUID(),
|
|
survivor_id: id,
|
|
priority: triage === 0 ? 0 : 1,
|
|
title: triage === 0 ? 'CRITICAL: Survivor needs immediate attention' : 'URGENT: Survivor detected',
|
|
message: `Survivor at (${x.toFixed(0)}, ${y.toFixed(0)}) - Depth: ${Math.abs(depth).toFixed(1)}m`,
|
|
recommended_action: triage === 0 ? 'Dispatch rescue team immediately' : 'Schedule rescue team',
|
|
triage_status: triage,
|
|
location_x: x,
|
|
location_y: y,
|
|
created_at: new Date().toISOString(),
|
|
priority_color: triage === 0 ? '#ff0000' : '#ff6600'
|
|
};
|
|
this.alerts.push(alert);
|
|
if (this.callbacks.onAlertGenerated) {
|
|
this.callbacks.onAlertGenerated(alert);
|
|
}
|
|
}
|
|
|
|
return id;
|
|
}
|
|
|
|
getSurvivors() {
|
|
return Array.from(this.survivors.values());
|
|
}
|
|
|
|
getAlerts() {
|
|
return this.alerts.filter(a => !a.acknowledged);
|
|
}
|
|
|
|
acknowledgeAlert(alertId) {
|
|
const alert = this.alerts.find(a => a.id === alertId);
|
|
if (alert) {
|
|
alert.acknowledged = true;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
getStats() {
|
|
const survivors = Array.from(this.survivors.values());
|
|
return {
|
|
total_survivors: survivors.length,
|
|
immediate_count: survivors.filter(s => s.triage_status === 0).length,
|
|
delayed_count: survivors.filter(s => s.triage_status === 1).length,
|
|
minor_count: survivors.filter(s => s.triage_status === 2).length,
|
|
deceased_count: survivors.filter(s => s.triage_status === 3).length,
|
|
unknown_count: survivors.filter(s => s.triage_status === 4).length,
|
|
active_zones: this.zones.size,
|
|
total_scans: 0,
|
|
active_alerts: this.alerts.filter(a => !a.acknowledged).length,
|
|
elapsed_seconds: this.eventStart ? (Date.now() - this.eventStart) / 1000 : 0
|
|
};
|
|
}
|
|
|
|
onSurvivorDetected(callback) { this.callbacks.onSurvivorDetected = callback; }
|
|
onSurvivorUpdated(callback) { this.callbacks.onSurvivorUpdated = callback; }
|
|
onAlertGenerated(callback) { this.callbacks.onAlertGenerated = callback; }
|
|
onZoneUpdated(callback) { this.callbacks.onZoneUpdated = callback; }
|
|
|
|
renderZones(ctx) {
|
|
this.zones.forEach(zone => {
|
|
const colors = {
|
|
fill: 'rgba(0, 150, 255, 0.3)',
|
|
stroke: '#0096ff'
|
|
};
|
|
|
|
ctx.fillStyle = colors.fill;
|
|
ctx.strokeStyle = colors.stroke;
|
|
ctx.lineWidth = 2;
|
|
|
|
if (zone.type === 'rectangle') {
|
|
ctx.fillRect(zone.x, zone.y, zone.width, zone.height);
|
|
ctx.strokeRect(zone.x, zone.y, zone.width, zone.height);
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.font = '12px sans-serif';
|
|
ctx.fillText(zone.name, zone.x + 5, zone.y + 15);
|
|
} else if (zone.type === 'circle') {
|
|
ctx.beginPath();
|
|
ctx.arc(zone.centerX, zone.centerY, zone.radius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.font = '12px sans-serif';
|
|
ctx.fillText(zone.name, zone.centerX - 20, zone.centerY);
|
|
}
|
|
});
|
|
}
|
|
|
|
renderSurvivors(ctx) {
|
|
this.survivors.forEach(survivor => {
|
|
const radius = survivor.is_deteriorating ? 12 : 10;
|
|
|
|
// Glow for immediate
|
|
if (survivor.triage_status === 0) {
|
|
ctx.fillStyle = 'rgba(255, 0, 0, 0.3)';
|
|
ctx.beginPath();
|
|
ctx.arc(survivor.x, survivor.y, radius + 8, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
|
|
// Main marker
|
|
ctx.fillStyle = survivor.triage_color;
|
|
ctx.beginPath();
|
|
ctx.arc(survivor.x, survivor.y, radius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
// Border
|
|
ctx.strokeStyle = '#ffffff';
|
|
ctx.lineWidth = 2;
|
|
ctx.stroke();
|
|
|
|
// Deterioration ring
|
|
if (survivor.is_deteriorating) {
|
|
ctx.strokeStyle = '#ff0000';
|
|
ctx.lineWidth = 3;
|
|
ctx.beginPath();
|
|
ctx.arc(survivor.x, survivor.y, radius + 4, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
}
|
|
|
|
// Depth label
|
|
if (survivor.depth < 0) {
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.font = '10px sans-serif';
|
|
ctx.fillText(`${Math.abs(survivor.depth).toFixed(1)}m`, survivor.x + radius + 2, survivor.y + 4);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Global dashboard instance
|
|
let dashboard = null;
|
|
let canvas = null;
|
|
let ctx = null;
|
|
|
|
// Initialize
|
|
async function init() {
|
|
console.log('Initializing WiFi-Mat Dashboard...');
|
|
|
|
// In production, use real WASM:
|
|
// await init();
|
|
// initLogging('info');
|
|
// dashboard = new MatDashboard();
|
|
|
|
// For demo, use mock:
|
|
dashboard = new MockMatDashboard();
|
|
|
|
// Setup canvas
|
|
canvas = document.getElementById('mapCanvas');
|
|
ctx = canvas.getContext('2d');
|
|
|
|
// Handle resize
|
|
function resizeCanvas() {
|
|
const container = canvas.parentElement;
|
|
canvas.width = container.clientWidth;
|
|
canvas.height = container.clientHeight;
|
|
render();
|
|
}
|
|
window.addEventListener('resize', resizeCanvas);
|
|
resizeCanvas();
|
|
|
|
// Setup callbacks
|
|
dashboard.onSurvivorDetected((survivor) => {
|
|
console.log('Survivor detected:', survivor);
|
|
updateSurvivorsList();
|
|
updateStats();
|
|
render();
|
|
});
|
|
|
|
dashboard.onAlertGenerated((alert) => {
|
|
console.log('Alert generated:', alert);
|
|
showToast(alert);
|
|
updateAlertsList();
|
|
updateStats();
|
|
});
|
|
|
|
dashboard.onZoneUpdated((zone) => {
|
|
console.log('Zone updated:', zone);
|
|
render();
|
|
});
|
|
|
|
// Canvas click for placing survivors (demo)
|
|
canvas.addEventListener('click', (e) => {
|
|
if (!dashboard.getEventId()) return;
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
const y = e.clientY - rect.top;
|
|
|
|
// For demo, click to simulate detection
|
|
// const triage = Math.floor(Math.random() * 5);
|
|
// dashboard.simulateSurvivorDetection(x, y, -Math.random() * 5, triage, 0.7 + Math.random() * 0.3);
|
|
});
|
|
|
|
// Start render loop
|
|
requestAnimationFrame(renderLoop);
|
|
|
|
console.log('Dashboard initialized');
|
|
}
|
|
|
|
// Render loop
|
|
function renderLoop() {
|
|
render();
|
|
requestAnimationFrame(renderLoop);
|
|
}
|
|
|
|
function render() {
|
|
if (!ctx || !canvas) return;
|
|
|
|
// Clear
|
|
ctx.fillStyle = '#0a0a1a';
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
// Draw grid
|
|
ctx.strokeStyle = '#1a1a3a';
|
|
ctx.lineWidth = 1;
|
|
for (let x = 0; x < canvas.width; x += 50) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, 0);
|
|
ctx.lineTo(x, canvas.height);
|
|
ctx.stroke();
|
|
}
|
|
for (let y = 0; y < canvas.height; y += 50) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y);
|
|
ctx.lineTo(canvas.width, y);
|
|
ctx.stroke();
|
|
}
|
|
|
|
// Render zones and survivors
|
|
if (dashboard) {
|
|
dashboard.renderZones(ctx);
|
|
dashboard.renderSurvivors(ctx);
|
|
}
|
|
}
|
|
|
|
// UI Functions
|
|
window.showCreateEventModal = function() {
|
|
document.getElementById('createEventModal').classList.add('active');
|
|
};
|
|
|
|
window.hideCreateEventModal = function() {
|
|
document.getElementById('createEventModal').classList.remove('active');
|
|
};
|
|
|
|
window.createEvent = function() {
|
|
const type = document.getElementById('disasterType').value;
|
|
const lat = parseFloat(document.getElementById('eventLat').value);
|
|
const lng = parseFloat(document.getElementById('eventLng').value);
|
|
const desc = document.getElementById('eventDesc').value || `${type} event`;
|
|
|
|
const eventId = dashboard.createEvent(type, lat, lng, desc);
|
|
|
|
document.getElementById('eventInfo').textContent = `Event: ${desc} (${eventId.substring(0, 8)}...)`;
|
|
hideCreateEventModal();
|
|
|
|
showToast({
|
|
title: 'Event Created',
|
|
message: `Disaster event ${type} initialized`,
|
|
priority: 3,
|
|
priority_color: '#0066ff'
|
|
});
|
|
};
|
|
|
|
window.addRectZone = function() {
|
|
if (!dashboard.getEventId()) {
|
|
alert('Please create an event first');
|
|
return;
|
|
}
|
|
|
|
const name = prompt('Zone name:', `Zone ${String.fromCharCode(65 + dashboard.zones.size)}`);
|
|
if (!name) return;
|
|
|
|
const x = 50 + Math.random() * (canvas.width - 250);
|
|
const y = 50 + Math.random() * (canvas.height - 200);
|
|
const width = 100 + Math.random() * 150;
|
|
const height = 80 + Math.random() * 120;
|
|
|
|
dashboard.addRectangleZone(name, x, y, width, height);
|
|
};
|
|
|
|
window.addCircleZone = function() {
|
|
if (!dashboard.getEventId()) {
|
|
alert('Please create an event first');
|
|
return;
|
|
}
|
|
|
|
const name = prompt('Zone name:', `Zone ${String.fromCharCode(65 + dashboard.zones.size)}`);
|
|
if (!name) return;
|
|
|
|
const centerX = 100 + Math.random() * (canvas.width - 200);
|
|
const centerY = 100 + Math.random() * (canvas.height - 200);
|
|
const radius = 40 + Math.random() * 60;
|
|
|
|
dashboard.addCircleZone(name, centerX, centerY, radius);
|
|
};
|
|
|
|
window.simulateSurvivor = function() {
|
|
if (!dashboard.getEventId()) {
|
|
alert('Please create an event first');
|
|
return;
|
|
}
|
|
|
|
const x = 50 + Math.random() * (canvas.width - 100);
|
|
const y = 50 + Math.random() * (canvas.height - 100);
|
|
const depth = -Math.random() * 5;
|
|
const triage = Math.floor(Math.random() * 5);
|
|
const confidence = 0.5 + Math.random() * 0.5;
|
|
|
|
dashboard.simulateSurvivorDetection(x, y, depth, triage, confidence);
|
|
};
|
|
|
|
function updateStats() {
|
|
const stats = dashboard.getStats();
|
|
document.getElementById('statTotal').textContent = stats.total_survivors;
|
|
document.getElementById('statImmediate').textContent = stats.immediate_count;
|
|
document.getElementById('statDelayed').textContent = stats.delayed_count;
|
|
document.getElementById('statMinor').textContent = stats.minor_count;
|
|
}
|
|
|
|
function updateSurvivorsList() {
|
|
const survivors = dashboard.getSurvivors();
|
|
const container = document.getElementById('survivorsList');
|
|
|
|
if (survivors.length === 0) {
|
|
container.innerHTML = '<p style="color: #666; font-size: 0.85rem;">No survivors detected</p>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = survivors.map(s => {
|
|
const triageLabels = ['IMMEDIATE', 'DELAYED', 'MINOR', 'DECEASED', 'UNKNOWN'];
|
|
return `
|
|
<div class="survivor-item">
|
|
<div class="survivor-marker" style="background: ${s.triage_color}"></div>
|
|
<div class="survivor-info">
|
|
<div class="survivor-id">${s.id.substring(0, 8)}...</div>
|
|
<div class="survivor-details">
|
|
${triageLabels[s.triage_status]} | Depth: ${Math.abs(s.depth).toFixed(1)}m
|
|
</div>
|
|
</div>
|
|
<div class="survivor-vital">${s.breathing_rate.toFixed(0)} bpm</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function updateAlertsList() {
|
|
const alerts = dashboard.getAlerts();
|
|
const container = document.getElementById('alertsList');
|
|
|
|
if (alerts.length === 0) {
|
|
container.innerHTML = '<p style="color: #666; font-size: 0.85rem;">No active alerts</p>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = alerts.map(a => {
|
|
const priorityClass = ['critical', 'high', 'medium', 'low'][a.priority] || 'low';
|
|
return `
|
|
<div class="alert-item priority-${priorityClass}" onclick="acknowledgeAlert('${a.id}')">
|
|
<div class="alert-title">${a.title}</div>
|
|
<div class="alert-message">${a.message}</div>
|
|
<div class="alert-time">${new Date(a.created_at).toLocaleTimeString()}</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
window.acknowledgeAlert = function(alertId) {
|
|
dashboard.acknowledgeAlert(alertId);
|
|
updateAlertsList();
|
|
updateStats();
|
|
};
|
|
|
|
function showToast(alert) {
|
|
const container = document.getElementById('toastContainer');
|
|
const priorityClass = ['critical', 'high', 'medium', 'low'][alert.priority] || 'low';
|
|
|
|
const toast = document.createElement('div');
|
|
toast.className = `toast ${priorityClass}`;
|
|
toast.innerHTML = `
|
|
<button class="toast-close" onclick="this.parentElement.remove()">×</button>
|
|
<div class="toast-title">${alert.title}</div>
|
|
<div class="toast-message">${alert.message}</div>
|
|
`;
|
|
|
|
container.appendChild(toast);
|
|
|
|
// Auto-remove after 5 seconds
|
|
setTimeout(() => {
|
|
if (toast.parentElement) {
|
|
toast.remove();
|
|
}
|
|
}, 5000);
|
|
|
|
// Play alert sound for critical
|
|
if (alert.priority === 0) {
|
|
playAlertSound();
|
|
}
|
|
}
|
|
|
|
function playAlertSound() {
|
|
// Create a simple beep sound
|
|
try {
|
|
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
|
const oscillator = audioCtx.createOscillator();
|
|
const gainNode = audioCtx.createGain();
|
|
|
|
oscillator.connect(gainNode);
|
|
gainNode.connect(audioCtx.destination);
|
|
|
|
oscillator.frequency.value = 880;
|
|
oscillator.type = 'sine';
|
|
gainNode.gain.value = 0.3;
|
|
|
|
oscillator.start();
|
|
oscillator.stop(audioCtx.currentTime + 0.2);
|
|
|
|
setTimeout(() => {
|
|
const osc2 = audioCtx.createOscillator();
|
|
osc2.connect(gainNode);
|
|
osc2.frequency.value = 880;
|
|
osc2.type = 'sine';
|
|
osc2.start();
|
|
osc2.stop(audioCtx.currentTime + 0.2);
|
|
}, 250);
|
|
} catch (e) {
|
|
console.log('Audio not available');
|
|
}
|
|
}
|
|
|
|
// Initialize on load
|
|
init();
|
|
</script>
|
|
</body>
|
|
</html>
|