john-osborne-j commited on
Commit
cdfd467
·
0 Parent(s):

Initial commit of Splay Network application

Browse files
.agent/workflows/deploy_to_render.md ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ description: How to deploy the Splay Network application to Render
3
+ ---
4
+
5
+ This guide outlines the steps to deploy your Flask application to **Render**, a cloud platform that supports Python web apps easily.
6
+
7
+ ### Prerequisites
8
+
9
+ 1. **Gunicorn**: We have already added `gunicorn` to your `requirements.txt`. This is the production server required for deployment (app.run is only for development).
10
+ 2. **Procfile**: We have created a `Procfile` in your root directory containing `web: gunicorn app:app`. This tells Render how to start your app.
11
+ 3. **GitHub Account**: You will need to push your code to a GitHub repository.
12
+
13
+ ### Step-by-Step Deployment
14
+
15
+ 1. **Push Code to GitHub**:
16
+ * Initialize a git repository if you haven't already:
17
+ ```bash
18
+ git init
19
+ git add .
20
+ git commit -m "Initial commit for deployment"
21
+ ```
22
+ * Create a new repository on GitHub.
23
+ * Link and push your code:
24
+ ```bash
25
+ git remote add origin https://github.com/YOUR_USERNAME/YOUR_REPO_NAME.git
26
+ git branch -M main
27
+ git push -u origin main
28
+ ```
29
+
30
+ 2. **Create Service on Render**:
31
+ * Go to [dashboard.render.com](https://dashboard.render.com/).
32
+ * Click **New +** and select **Web Service**.
33
+ * Connect your GitHub account and select the repository you just pushed.
34
+
35
+ 3. **Configure Build & Start**:
36
+ * **Name**: Give your service a unique name (e.g., `splay-network-app`).
37
+ * **Region**: Choose the one closest to you.
38
+ * **Runtime**: Select **Python 3**.
39
+ * **Build Command**: `pip install -r requirements.txt`
40
+ * *Note*: Render requires `numpy<2` compatibility if your code was developed that way. Ensure requirements.txt is accurate.
41
+ * **Start Command**: `gunicorn app:app` (Render might auto-detect this from the Procfile).
42
+ * **Free Instance Type**: Select "Free" if just testing.
43
+
44
+ 4. **Environment Variables**:
45
+ * If you have any API keys or secrets (not used in this simple demo), add them under the "Environment" tab.
46
+ * For this app, ensure `PYTHON_VERSION` is set to `3.10.0` or similar if needed, typically default is fine.
47
+
48
+ 5. **Deploy**:
49
+ * Click **Create Web Service**.
50
+ * Render will start building your app. Watch the logs.
51
+ * Once the build finishes, you will see a green "Live" badge and a URL (e.g., `https://splay-network-app.onrender.com`).
52
+
53
+ ### Troubleshooting
54
+
55
+ * **Memory Issues**: The free tier has limited RAM (512MB). If loading PyTorch (Ultralytics) triggers an OOM (Out of Memory) kill, you might need to:
56
+ * Use a smaller model (we are already using `yolov8n.pt`, the nano version, which is good).
57
+ * Upgrade to a paid instance.
58
+ * **OpenCV Dependencies**: `opencv-python-headless` is already in requirements, which includes necessary binary dependencies for Linux environments on Render.
59
+
60
+ ### Docker Alternative (Advanced)
61
+
62
+ If you prefer using Docker, you can add a `Dockerfile`:
63
+
64
+ ```dockerfile
65
+ FROM python:3.9-slim
66
+
67
+ WORKDIR /app
68
+
69
+ # Install system dependencies for OpenCV
70
+ RUN apt-get update && apt-get install -y \
71
+ libgl1-mesa-glx \
72
+ libglib2.0-0 \
73
+ && rm -rf /var/lib/apt/lists/*
74
+
75
+ COPY requirements.txt .
76
+ RUN pip install --no-cache-dir -r requirements.txt
77
+
78
+ COPY . .
79
+
80
+ CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:10000"]
81
+ ```
82
+
83
+ If using Docker on Render, choose "Docker" as the runtime instead of Python.
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ venv/
2
+ __pycache__/
3
+ *.pyc
4
+ uploads/
5
+ .DS_Store
6
+ .env
Procfile ADDED
@@ -0,0 +1 @@
 
 
1
+ web: gunicorn app:app
app.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Flask, render_template, jsonify, request, Response
2
+ from simulation import Simulation
3
+ from detection import VideoProcessor
4
+ import os
5
+ from werkzeug.utils import secure_filename
6
+
7
+ app = Flask(__name__)
8
+ app.config['UPLOAD_FOLDER'] = 'uploads'
9
+ os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
10
+
11
+ sim = Simulation()
12
+ video_processor = VideoProcessor()
13
+
14
+ @app.route('/')
15
+ def index():
16
+ return render_template('index.html')
17
+
18
+ @app.route('/start', methods=['POST'])
19
+ def start_simulation():
20
+ data = request.json
21
+ n_nodes = data.get('n_nodes', 50)
22
+ sim.reset(n_nodes)
23
+ return jsonify({'status': 'ok', 'n_nodes': n_nodes})
24
+
25
+ @app.route('/step')
26
+ def step():
27
+ state = sim.step()
28
+ return jsonify(state)
29
+
30
+ @app.route('/upload_video', methods=['POST'])
31
+ def upload_video():
32
+ if 'video' not in request.files:
33
+ return jsonify({'error': 'No file part'}), 400
34
+ file = request.files['video']
35
+ if file.filename == '':
36
+ return jsonify({'error': 'No selected file'}), 400
37
+
38
+ filename = secure_filename(file.filename)
39
+ filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
40
+ file.save(filepath)
41
+
42
+ # Initialize processor with this video
43
+ video_processor.set_source(filepath)
44
+
45
+ return jsonify({'status': 'ok', 'filename': filename})
46
+
47
+ @app.route('/video_feed')
48
+ def video_feed():
49
+ return Response(video_processor.generate_frames(),
50
+ mimetype='multipart/x-mixed-replace; boundary=frame')
51
+
52
+
53
+ if __name__ == '__main__':
54
+ app.run(debug=True, port=5000)
detection.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cv2
2
+ import numpy as np
3
+ import torch
4
+ from ultralytics import YOLO
5
+ from deep_sort_realtime.deepsort_tracker import DeepSort
6
+ import os
7
+
8
+ # ==========================================
9
+ # 1. SwinIR Restoration Module (Server-Side)
10
+ # ==========================================
11
+ class SwinIRRestorer:
12
+ """
13
+ Simulates the SwinIR restoration step.
14
+ In the paper, this recovers 640x640 images from 64x64/128x128 thumbnails.
15
+ """
16
+ def __init__(self, target_size=(640, 640)):
17
+ self.target_size = target_size
18
+ self.device = 'cuda' if torch.cuda.is_available() else 'cpu'
19
+ print(f"Restoration Module loaded on: {self.device}")
20
+
21
+ def restore(self, low_res_image):
22
+ # Simulating restoration for the demo:
23
+ restored_img = cv2.resize(low_res_image, self.target_size, interpolation=cv2.INTER_CUBIC)
24
+
25
+ # Optional: Apply slight sharpening to mimic SwinIR texture recovery
26
+ kernel = np.array([[0, -1, 0], [-1, 5,-1], [0, -1, 0]])
27
+ restored_img = cv2.filter2D(restored_img, -1, kernel)
28
+
29
+ return restored_img
30
+
31
+ # ==========================================
32
+ # 2. Wildlife Analytics Module (Server-Side)
33
+ # ==========================================
34
+ class WildlifeAnalytics:
35
+ def __init__(self, confidence_threshold=0.4):
36
+ # Load YOLOv8 Model
37
+ print("Loading YOLOv8 Detector...")
38
+ try:
39
+ self.detector = YOLO('yolov8n.pt')
40
+ except Exception as e:
41
+ print(f"Failed to load YOLO model: {e}")
42
+ self.detector = None
43
+
44
+
45
+ # Load DeepSORT Tracker
46
+ print("Loading DeepSORT Tracker...")
47
+ # Reduced n_init to 1 to show boxes immediately for moving animals
48
+ self.tracker = DeepSort(max_age=30, n_init=1, nms_max_overlap=1.0)
49
+
50
+ # Paper specifies confidence > 0.4
51
+ self.conf_threshold = confidence_threshold
52
+
53
+ def process_frame(self, frame):
54
+ if self.detector is None:
55
+ return []
56
+
57
+ # 1. Detection
58
+ results = self.detector(frame, verbose=False)[0]
59
+ detections = []
60
+
61
+ for box in results.boxes:
62
+ conf = float(box.conf[0])
63
+ if conf > self.conf_threshold:
64
+ x1, y1, x2, y2 = box.xyxy[0].cpu().numpy()
65
+ cls = int(box.cls[0])
66
+ class_name = self.detector.names[cls]
67
+
68
+ # Filter for animals if possible, but for demo we take all
69
+ w, h = x2 - x1, y2 - y1
70
+ detections.append(([x1, y1, w, h], conf, class_name))
71
+
72
+ # 2. Tracking
73
+ tracks = self.tracker.update_tracks(detections, frame=frame)
74
+
75
+ return tracks
76
+
77
+ class VideoProcessor:
78
+ def __init__(self):
79
+ self.restorer = SwinIRRestorer(target_size=(640, 640))
80
+ self.analytics = WildlifeAnalytics(confidence_threshold=0.4)
81
+ self.current_video_path = None
82
+ self.cap = None
83
+
84
+ def set_source(self, video_path):
85
+ self.current_video_path = video_path
86
+ if self.cap:
87
+ self.cap.release()
88
+ self.cap = cv2.VideoCapture(video_path)
89
+
90
+ def generate_frames(self):
91
+ if not self.cap or not self.cap.isOpened():
92
+ return
93
+
94
+ while True:
95
+ ret, full_res_frame = self.cap.read()
96
+ if not ret:
97
+ # Loop video for demo purposes
98
+ self.cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
99
+ continue
100
+
101
+ # --- STEP 1: Simulate Node Capture (Edge) ---
102
+ node_thumbnail = cv2.resize(full_res_frame, (128, 128))
103
+
104
+ # --- STEP 2: Restore Image (Server) ---
105
+ restored_frame = self.restorer.restore(node_thumbnail)
106
+
107
+ # --- STEP 3: Detect & Track (Server) ---
108
+ tracks = self.analytics.process_frame(restored_frame)
109
+
110
+ # --- STEP 4: Visualization ---
111
+ for track in tracks:
112
+ if not track.is_confirmed() and track.time_since_update > 1:
113
+ continue
114
+
115
+ track_id = track.track_id
116
+ ltrb = track.to_ltrb()
117
+ class_name = track.det_class if track.det_class else "Object"
118
+
119
+ # Draw Bounding Box - Cyan for high visibility
120
+ # Using a dynamic thickness based on confidence or just thick enough
121
+ cv2.rectangle(restored_frame, (int(ltrb[0]), int(ltrb[1])),
122
+ (int(ltrb[2]), int(ltrb[3])), (255, 255, 0), 3)
123
+
124
+ # Label with background for readability
125
+ label = f"ID:{track_id} {class_name}"
126
+ (w, h), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.6, 2)
127
+
128
+ cv2.rectangle(restored_frame, (int(ltrb[0]), int(ltrb[1]) - 20), (int(ltrb[0]) + w, int(ltrb[1])), (255, 255, 0), -1)
129
+
130
+ cv2.putText(restored_frame, label, (int(ltrb[0]), int(ltrb[1])-5),
131
+ cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 0), 2)
132
+
133
+ # Combined View for the web feed
134
+ # Resize thumbnail to match height/scale for side-by-side
135
+ viz_thumbnail = cv2.resize(node_thumbnail, (640, 640), interpolation=cv2.INTER_NEAREST)
136
+
137
+ # Create a nice layout: Left (Low Res Mockup), Right (High Res Result)
138
+ # Add labels
139
+ cv2.putText(viz_thumbnail, "EDGE NODE (128px)", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 255), 2)
140
+ cv2.putText(restored_frame, "SERVER (Restored + AI)", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2)
141
+
142
+ combined_view = np.hstack((viz_thumbnail, restored_frame))
143
+
144
+ # Scale down slightly for web performance if needed
145
+ combined_view = cv2.resize(combined_view, (1000, 500))
146
+
147
+ # Encode JPEG
148
+ ret, buffer = cv2.imencode('.jpg', combined_view)
149
+ frame = buffer.tobytes()
150
+
151
+ yield (b'--frame\r\n'
152
+ b'Content-Type: image/jpeg\r\n\r\n' + frame + b'\r\n')
error_log.txt ADDED
Binary file (1.78 kB). View file
 
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ flask
2
+ opencv-python-headless
3
+ ultralytics
4
+ deep-sort-realtime
5
+ numpy
6
+ torch
7
+ torchvision
8
+ gunicorn
simulation.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ import random
3
+ from collections import defaultdict
4
+
5
+ # --- Configuration (Defaults) ---
6
+ AREA_SIZE = 1000.0
7
+ N_CLUSTERS = 5
8
+ T_MAX = 30
9
+ ALPHA = 0.5; BETA = 0.3; KAPPA = 0.1; ZETA = 0.1
10
+
11
+ def dist(a, b): return math.hypot(a[0]-b[0], a[1]-b[1])
12
+
13
+ class Node:
14
+ def __init__(self, idx, pos, cluster):
15
+ self.idx = idx; self.pos = pos; self.cluster = int(cluster)
16
+ self.batt = 100.0; self.dead = False; self.S = 0.0; self.fair = 0.0
17
+ self.is_head = False; self.head_since = 0
18
+ self.dead_since = None
19
+
20
+ def get_color(self):
21
+ if self.dead: return '#ff0000' # Red
22
+ if self.batt > 50: return '#00ff00' # Green
23
+ if self.batt > 20: return '#ffff00' # Yellow
24
+ return '#ff9900' # Orange
25
+
26
+ def consume(self, amount, sim_time):
27
+ if self.dead: return
28
+ self.batt -= amount
29
+ if self.batt <= 0:
30
+ self.batt = 0; self.dead = True; self.is_head = False
31
+ self.dead_since = sim_time
32
+
33
+ def calculate_utility(node, gateway):
34
+ term_S = min(node.S, 1.0); term_E = node.batt / 100.0
35
+ term_fair = min(node.fair, 1.0)
36
+ d_gate = dist(node.pos, gateway)
37
+ term_lq = 1.0 - (d_gate / (AREA_SIZE * 1.414))
38
+ return ALPHA*term_S + BETA*term_E + KAPPA*term_fair + ZETA*term_lq
39
+
40
+ class Simulation:
41
+ def __init__(self, n_nodes=50):
42
+ self.n_nodes = n_nodes
43
+ self.rng = random.Random() # New random instance
44
+ self.nodes = []
45
+ self.clusters = defaultdict(list)
46
+ self.current_heads = {}
47
+ self.sim_time = 0
48
+ self.gateway = (AREA_SIZE/2, AREA_SIZE/2)
49
+ self.reset(n_nodes)
50
+
51
+ def reset(self, n_nodes):
52
+ self.n_nodes = int(n_nodes)
53
+ self.sim_time = 0
54
+
55
+ # Setup Network
56
+ centers = [(self.rng.uniform(100, AREA_SIZE-100), self.rng.uniform(100, AREA_SIZE-100)) for _ in range(N_CLUSTERS)]
57
+ self.nodes = []
58
+ for i in range(self.n_nodes):
59
+ c_idx = self.rng.randint(0, N_CLUSTERS-1)
60
+ cx, cy = centers[c_idx]
61
+ nx = cx + self.rng.gauss(0, 80)
62
+ ny = cy + self.rng.gauss(0, 80)
63
+ pos = (max(0, min(AREA_SIZE, nx)), max(0, min(AREA_SIZE, ny)))
64
+ self.nodes.append(Node(i, pos, c_idx))
65
+
66
+ self.clusters = defaultdict(list)
67
+ for n in self.nodes: self.clusters[n.cluster].append(n)
68
+ self.current_heads = {c: None for c in self.clusters}
69
+
70
+ def step(self):
71
+ # Run logic 1x per frame to slow down backend progression too, or keep it 3x?
72
+ # User said "simulation is going really fast", often better to slow down updates.
73
+ # Let's reduce internal ticks to 1 per step call as well.
74
+ for _ in range(1):
75
+ self._run_simulation_step()
76
+
77
+ return self.get_state()
78
+
79
+ def _run_simulation_step(self):
80
+ self.sim_time += 1
81
+ ev_pos = (self.rng.uniform(0, AREA_SIZE), self.rng.uniform(0, AREA_SIZE))
82
+
83
+ for n in self.nodes:
84
+ if n.dead: continue
85
+ n.consume(0.2, self.sim_time)
86
+ n.S *= 0.9
87
+ if not n.is_head: n.fair += 0.02
88
+ if dist(n.pos, ev_pos) < 150: n.S += 0.8; n.consume(0.5, self.sim_time)
89
+
90
+ for c_id, members in self.clusters.items():
91
+ head = self.current_heads.get(c_id)
92
+ if head is None or head.dead or (head and (self.sim_time - head.head_since) > T_MAX):
93
+ candidates = [n for n in members if not n.dead]
94
+ if candidates:
95
+ winner = max(candidates, key=lambda n: calculate_utility(n, self.gateway))
96
+ if head and head != winner: head.is_head = False
97
+ self.current_heads[c_id] = winner; winner.is_head = True
98
+ winner.head_since = self.sim_time; winner.fair = 0.0; winner.consume(1.5, self.sim_time)
99
+
100
+ def get_state(self):
101
+ # Return serializable state
102
+ nodes_data = []
103
+ links = []
104
+ dead_nodes_stats = []
105
+
106
+ for n in self.nodes:
107
+ color = n.get_color()
108
+
109
+ # Logic for links: if not head, link to head
110
+ if not n.is_head and not n.dead:
111
+ head = self.current_heads.get(n.cluster)
112
+ if head and not head.dead:
113
+ links.append({
114
+ 'start': n.pos,
115
+ 'end': head.pos
116
+ })
117
+
118
+ if n.dead:
119
+ downtime = self.sim_time - n.dead_since if n.dead_since is not None else 0
120
+ dead_nodes_stats.append({
121
+ 'id': n.idx,
122
+ 'dead_since': n.dead_since,
123
+ 'downtime': downtime
124
+ })
125
+
126
+ nodes_data.append({
127
+ 'id': n.idx,
128
+ 'x': n.pos[0],
129
+ 'y': n.pos[1],
130
+ 'color': color,
131
+ 'is_head': n.is_head,
132
+ 'dead': n.dead,
133
+ 'batt': n.batt,
134
+ 'cluster': n.cluster
135
+ })
136
+
137
+ return {
138
+ 'sim_time': self.sim_time,
139
+ 'gateway': self.gateway,
140
+ 'nodes': nodes_data,
141
+ 'links': links,
142
+ 'dead_stats': dead_nodes_stats
143
+ }
static/script.js ADDED
@@ -0,0 +1,327 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ document.addEventListener('DOMContentLoaded', () => {
2
+ const canvas = document.getElementById('simCanvas');
3
+ const ctx = canvas.getContext('2d');
4
+ const startBtn = document.getElementById('startBtn');
5
+ const stopBtn = document.getElementById('stopBtn');
6
+ const nodeCountInput = document.getElementById('nodeCount');
7
+ const simTimeEl = document.getElementById('simTime');
8
+ const activeNodesEl = document.getElementById('activeNodes');
9
+
10
+ // Dashboard Elements
11
+ const deadCountEl = document.getElementById('deadCount');
12
+ const avgDowntimeEl = document.getElementById('avgDowntime');
13
+ const deadNodesListEl = document.getElementById('deadNodesList');
14
+
15
+ let isRunning = false;
16
+ let animationId = null;
17
+ const AREA_SIZE = 1000.0; // Must match backend
18
+
19
+ function resizeCanvas() {
20
+ const wrapper = document.getElementById('canvas-wrapper');
21
+ canvas.width = wrapper.clientWidth;
22
+ canvas.height = wrapper.clientHeight;
23
+ }
24
+
25
+ window.addEventListener('resize', resizeCanvas);
26
+ resizeCanvas();
27
+
28
+ startBtn.addEventListener('click', async () => {
29
+ if (isRunning) return;
30
+
31
+ const n_nodes = parseInt(nodeCountInput.value);
32
+ if (!n_nodes || n_nodes < 1) {
33
+ alert("Please enter a valid number of nodes.");
34
+ return;
35
+ }
36
+
37
+ try {
38
+ const res = await fetch('/start', {
39
+ method: 'POST',
40
+ headers: { 'Content-Type': 'application/json' },
41
+ body: JSON.stringify({ n_nodes: n_nodes })
42
+ });
43
+ const data = await res.json();
44
+
45
+ if (data.status === 'ok') {
46
+ isRunning = true;
47
+ startBtn.disabled = true;
48
+ stopBtn.disabled = false;
49
+ startBtn.classList.add('disabled');
50
+
51
+ // Reset Dashboard
52
+ if (deadNodesListEl) deadNodesListEl.innerHTML = '';
53
+ if (deadCountEl) deadCountEl.textContent = '0';
54
+ if (avgDowntimeEl) avgDowntimeEl.textContent = '0';
55
+
56
+ loop();
57
+ }
58
+ } catch (e) {
59
+ console.error("Error starting simulation:", e);
60
+ }
61
+ });
62
+
63
+ stopBtn.addEventListener('click', () => {
64
+ isRunning = false;
65
+ if (animationId) clearTimeout(animationId);
66
+ startBtn.disabled = false;
67
+ stopBtn.disabled = true;
68
+ startBtn.classList.remove('disabled');
69
+ });
70
+
71
+ async function loop() {
72
+ if (!isRunning) return;
73
+
74
+ try {
75
+ const res = await fetch('/step');
76
+ const state = await res.json();
77
+ draw(state);
78
+ updateDashboard(state);
79
+
80
+ simTimeEl.textContent = state.sim_time;
81
+ const liveNodes = state.nodes.filter(n => !n.dead).length;
82
+ activeNodesEl.textContent = liveNodes;
83
+
84
+ if (liveNodes === 0 && isRunning) {
85
+ isRunning = false;
86
+ startBtn.disabled = false;
87
+ stopBtn.disabled = true;
88
+ startBtn.classList.remove('disabled');
89
+ return;
90
+ }
91
+
92
+ // Throttled loop: 200ms delay ~ 5 FPS
93
+ animationId = setTimeout(loop, 200);
94
+ } catch (e) {
95
+ console.error("Error fetching step:", e);
96
+ isRunning = false;
97
+ }
98
+ }
99
+
100
+ function updateDashboard(state) {
101
+ if (!state.dead_stats) return;
102
+
103
+ const deadCount = state.dead_stats.length;
104
+ if (deadCountEl) deadCountEl.textContent = deadCount;
105
+
106
+ // Calculate Average Downtime
107
+ let totalDowntime = 0;
108
+ state.dead_stats.forEach(s => totalDowntime += s.downtime);
109
+ const avg = deadCount > 0 ? (totalDowntime / deadCount).toFixed(1) : 0;
110
+ if (avgDowntimeEl) avgDowntimeEl.textContent = avg;
111
+
112
+ // Populate List (Latest 5 failures?)
113
+ if (deadNodesListEl) {
114
+ // Clear current list to rebuild or append? Rebuilding is safer for sync
115
+ deadNodesListEl.innerHTML = '';
116
+
117
+ // Sort by most recent death (highest downtime implies earliest death, wait.
118
+ // We want recent failures. dead_since is the tick. Higher dead_since = more recent.)
119
+ // let's sort by dead_since descending.
120
+ const sorted = [...state.dead_stats].sort((a, b) => b.dead_since - a.dead_since);
121
+
122
+ sorted.slice(0, 10).forEach(stat => {
123
+ const li = document.createElement('li');
124
+ li.className = 'dead-node-item';
125
+ li.innerHTML = `
126
+ <span class="dead-node-id">Node ${stat.id}</span>
127
+ <span class="dead-node-time">Down for ${stat.downtime}t (Since: ${stat.dead_since})</span>
128
+ `;
129
+ deadNodesListEl.appendChild(li);
130
+ });
131
+ }
132
+ }
133
+
134
+ function draw(state) {
135
+ // Clear background
136
+ ctx.fillStyle = '#0a0a12'; // Or clearRect for transparency if using CSS bg
137
+ ctx.clearRect(0, 0, canvas.width, canvas.height); // Use CSS background
138
+
139
+ // Calculate Scale to Fit
140
+ const scaleX = canvas.width / AREA_SIZE;
141
+ const scaleY = canvas.height / AREA_SIZE;
142
+ const scale = Math.min(scaleX, scaleY) * 0.9; // 90% fit
143
+ const offsetX = (canvas.width - AREA_SIZE * scale) / 2;
144
+ const offsetY = (canvas.height - AREA_SIZE * scale) / 2;
145
+
146
+ const transform = (x, y) => ({
147
+ x: offsetX + x * scale,
148
+ y: offsetY + y * scale // Flip Y if needed? Matplotlib is cartesian bottom-up, but usually 0,0 is top-left in canvas.
149
+ // In the Python code: (0,0) to (1000, 1000).
150
+ // Matplotlib typically puts (0,0) at bottom-left. Canvas is top-left.
151
+ // If I want to match visual exactly, I might need to invert Y: AREA_SIZE - y.
152
+ // Let's assume standard top-left for now or just check.
153
+ // The python visual uses `ax.set_ylim(0, AREA_SIZE)`, so 0 is bottom.
154
+ // So: y_canvas = offsetY + (AREA_SIZE - y) * scale
155
+ });
156
+
157
+ // Helper to transform points
158
+ const t = (x, y) => {
159
+ return {
160
+ x: offsetX + x * scale,
161
+ y: offsetY + (AREA_SIZE - y) * scale
162
+ };
163
+ };
164
+
165
+ // Draw Links
166
+ if (state.links) {
167
+ state.links.forEach(link => {
168
+ const start = t(link.start[0], link.start[1]);
169
+ const end = t(link.end[0], link.end[1]);
170
+
171
+ ctx.beginPath();
172
+ ctx.moveTo(start.x, start.y);
173
+ ctx.lineTo(end.x, end.y);
174
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
175
+ ctx.lineWidth = 1;
176
+ ctx.stroke();
177
+ });
178
+ }
179
+
180
+ // Draw Gateway
181
+ const gw = t(state.gateway[0], state.gateway[1]);
182
+ ctx.fillStyle = '#00f3ff';
183
+ ctx.shadowColor = '#00f3ff';
184
+ ctx.shadowBlur = 20;
185
+ ctx.beginPath();
186
+ ctx.arc(gw.x, gw.y, 8, 0, Math.PI * 2);
187
+ ctx.fill();
188
+ ctx.shadowBlur = 0; // Reset
189
+
190
+ // Draw Nodes
191
+ state.nodes.forEach(node => {
192
+ const p = t(node.x, node.y);
193
+
194
+ // Outer glow for heads
195
+ if (node.is_head) {
196
+ ctx.beginPath();
197
+ ctx.arc(p.x, p.y, 12, 0, Math.PI * 2);
198
+ ctx.fillStyle = hexToRgba(node.color, 0.2);
199
+ ctx.fill();
200
+
201
+ ctx.beginPath();
202
+ ctx.arc(p.x, p.y, 8, 0, Math.PI * 2);
203
+ ctx.strokeStyle = node.color;
204
+ ctx.lineWidth = 2;
205
+ ctx.stroke();
206
+ }
207
+
208
+ // Core node
209
+ ctx.beginPath();
210
+ ctx.arc(p.x, p.y, node.is_head ? 6 : 4, 0, Math.PI * 2);
211
+ ctx.fillStyle = node.color;
212
+ ctx.fill();
213
+
214
+ // Dead marker
215
+ if (node.dead) {
216
+ ctx.strokeStyle = '#fff';
217
+ ctx.lineWidth = 1;
218
+ ctx.beginPath();
219
+ ctx.moveTo(p.x - 3, p.y - 3);
220
+ ctx.lineTo(p.x + 3, p.y + 3);
221
+ ctx.moveTo(p.x + 3, p.y - 3);
222
+ ctx.lineTo(p.x - 3, p.y + 3);
223
+ ctx.stroke();
224
+ }
225
+ });
226
+ }
227
+
228
+ function hexToRgba(hex, alpha) {
229
+ // Basic hex parsing
230
+ let c;
231
+ if (/^#([A-Fa-f0-9]{3}){1,2}$/.test(hex)) {
232
+ c = hex.substring(1).split('');
233
+ if (c.length === 3) {
234
+ c = [c[0], c[0], c[1], c[1], c[2], c[2]];
235
+ }
236
+ c = '0x' + c.join('');
237
+ return 'rgba(' + [(c >> 16) & 255, (c >> 8) & 255, c & 255].join(',') + ',' + alpha + ')';
238
+ }
239
+ return hex;
240
+ }
241
+
242
+ // --- Wildlife Detection Utils ---
243
+ const dropZone = document.getElementById('dropZone');
244
+ const videoInput = document.getElementById('videoInput');
245
+ const uploadStatus = document.getElementById('uploadStatus');
246
+ const videoPlaceholder = document.getElementById('videoPlaceholder');
247
+ const feedWrapper = document.querySelector('.video-feed-wrapper');
248
+
249
+ if (dropZone) {
250
+ dropZone.addEventListener('click', () => videoInput.click());
251
+
252
+ dropZone.addEventListener('dragover', (e) => {
253
+ e.preventDefault();
254
+ dropZone.style.borderColor = '#00f3ff';
255
+ });
256
+
257
+ dropZone.addEventListener('dragleave', (e) => {
258
+ e.preventDefault();
259
+ dropZone.style.borderColor = 'rgba(255, 255, 255, 0.1)';
260
+ });
261
+
262
+ dropZone.addEventListener('drop', (e) => {
263
+ e.preventDefault();
264
+ dropZone.style.borderColor = 'rgba(255, 255, 255, 0.1)';
265
+ if (e.dataTransfer.files.length) {
266
+ handleUpload(e.dataTransfer.files[0]);
267
+ }
268
+ });
269
+
270
+ videoInput.addEventListener('change', () => {
271
+ if (videoInput.files.length) {
272
+ handleUpload(videoInput.files[0]);
273
+ }
274
+ });
275
+ }
276
+
277
+ async function handleUpload(file) {
278
+ if (!file.type.startsWith('video/')) {
279
+ uploadStatus.textContent = "Error: Please upload a video file.";
280
+ uploadStatus.style.color = '#ff4444';
281
+ return;
282
+ }
283
+
284
+ uploadStatus.textContent = `Uploading ${file.name}... (This may take a moment)`;
285
+ uploadStatus.style.color = '#8892b0';
286
+
287
+ const formData = new FormData();
288
+ formData.append('video', file);
289
+
290
+ try {
291
+ const res = await fetch('/upload_video', {
292
+ method: 'POST',
293
+ body: formData
294
+ });
295
+
296
+ const data = await res.json();
297
+
298
+ if (data.status === 'ok') {
299
+ uploadStatus.textContent = "Processing Started! Stream below.";
300
+ uploadStatus.style.color = '#00ff00';
301
+ startVideoFeed();
302
+ } else {
303
+ uploadStatus.textContent = "Upload Failed: " + (data.error || 'Unknown error');
304
+ uploadStatus.style.color = '#ff4444';
305
+ }
306
+ } catch (e) {
307
+ console.error(e);
308
+ uploadStatus.textContent = "Network Error.";
309
+ uploadStatus.style.color = '#ff4444';
310
+ }
311
+ }
312
+
313
+ function startVideoFeed() {
314
+ // Clear placeholder
315
+ if (videoPlaceholder) videoPlaceholder.style.display = 'none';
316
+
317
+ // Remove existing img if any
318
+ const existing = document.getElementById('detectionFeed');
319
+ if (existing) existing.remove();
320
+
321
+ // Add MJPEG Stream Image
322
+ const img = document.createElement('img');
323
+ img.id = 'detectionFeed';
324
+ img.src = '/video_feed?' + new Date().getTime(); // Cache bust
325
+ feedWrapper.appendChild(img);
326
+ }
327
+ });
static/style.css ADDED
@@ -0,0 +1,547 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg-color: #040408;
3
+ --panel-bg: rgba(20, 20, 30, 0.6);
4
+ --glass-border: rgba(255, 255, 255, 0.1);
5
+ --primary-hsl: 250, 100%, 65%;
6
+ --primary-color: hsl(var(--primary-hsl));
7
+ --accent-color: #00f3ff;
8
+ --text-main: #ffffff;
9
+ --text-muted: #8892b0;
10
+ --font-family: 'Outfit', sans-serif;
11
+ }
12
+
13
+ * {
14
+ box-sizing: border-box;
15
+ margin: 0;
16
+ padding: 0;
17
+ }
18
+
19
+ body {
20
+ background-color: var(--bg-color);
21
+ background-image:
22
+ radial-gradient(circle at 10% 20%, rgba(76, 29, 149, 0.2) 0%, transparent 20%),
23
+ radial-gradient(circle at 90% 80%, rgba(0, 243, 255, 0.1) 0%, transparent 20%);
24
+ color: var(--text-main);
25
+ font-family: var(--font-family);
26
+ min-height: 100vh;
27
+ overflow-x: hidden;
28
+ /* Prevent horizontal scroll only */
29
+ overflow-y: auto;
30
+ /* Allow vertical scroll */
31
+ }
32
+
33
+ /* Page Layout Wrapper */
34
+ .page-wrapper {
35
+ max-width: 1400px;
36
+ margin: 0 auto;
37
+ padding: 2rem;
38
+ }
39
+
40
+ /* Header / Hero Section */
41
+ .hero {
42
+ text-align: center;
43
+ padding: 3rem 1rem;
44
+ margin-bottom: 2rem;
45
+ }
46
+
47
+ .hero h1 {
48
+ font-size: 3.5rem;
49
+ margin-bottom: 1rem;
50
+ background: linear-gradient(135deg, #fff 0%, #b8b8b8 100%);
51
+ -webkit-background-clip: text;
52
+ background-clip: text;
53
+ -webkit-text-fill-color: transparent;
54
+ }
55
+
56
+ .hero p {
57
+ font-size: 1.2rem;
58
+ color: var(--text-muted);
59
+ max-width: 600px;
60
+ margin: 0 auto;
61
+ line-height: 1.6;
62
+ }
63
+
64
+ /* Main App Container - Now a section within the page */
65
+ .app-container {
66
+ display: grid;
67
+ grid-template-columns: 350px 1fr;
68
+ gap: 2rem;
69
+ height: 800px;
70
+ /* Fixed height for the dashboard area to keep it contained */
71
+ margin-bottom: 4rem;
72
+ }
73
+
74
+ /* Responsive adjustments */
75
+ @media (max-width: 900px) {
76
+ .app-container {
77
+ grid-template-columns: 1fr;
78
+ height: auto;
79
+ }
80
+
81
+ .sidebar {
82
+ width: 100%;
83
+ margin-bottom: 2rem;
84
+ }
85
+
86
+ .viewport {
87
+ height: 500px;
88
+ /* Min height for canvas on mobile */
89
+ }
90
+ }
91
+
92
+ .sidebar {
93
+ width: 100%;
94
+ /* Take full width of grid column */
95
+ height: 100%;
96
+ padding: 0;
97
+ z-index: 10;
98
+ position: relative;
99
+ display: flex;
100
+ flex-direction: column;
101
+ }
102
+
103
+ .glass-panel {
104
+ background: var(--panel-bg);
105
+ backdrop-filter: blur(16px);
106
+ -webkit-backdrop-filter: blur(16px);
107
+ border: 1px solid var(--glass-border);
108
+ border-radius: 24px;
109
+ padding: 2rem;
110
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
111
+ height: 100%;
112
+ overflow-y: auto;
113
+ /* Allow sidebar internal scroll if needed */
114
+ }
115
+
116
+ h1 {
117
+ font-size: 1.8rem;
118
+ font-weight: 600;
119
+ margin-bottom: 0.5rem;
120
+ background: linear-gradient(135deg, #fff 0%, #a5a5a5 100%);
121
+ -webkit-background-clip: text;
122
+ background-clip: text;
123
+ -webkit-text-fill-color: transparent;
124
+ }
125
+
126
+ .subtitle {
127
+ color: var(--text-muted);
128
+ font-size: 0.9rem;
129
+ margin-bottom: 2rem;
130
+ text-transform: uppercase;
131
+ letter-spacing: 2px;
132
+ }
133
+
134
+ .control-group {
135
+ margin-bottom: 1.5rem;
136
+ }
137
+
138
+ label {
139
+ display: block;
140
+ margin-bottom: 0.5rem;
141
+ color: var(--text-muted);
142
+ font-size: 0.9rem;
143
+ }
144
+
145
+ .input-wrapper input {
146
+ width: 100%;
147
+ padding: 12px 16px;
148
+ background: rgba(0, 0, 0, 0.3);
149
+ border: 1px solid var(--glass-border);
150
+ border-radius: 12px;
151
+ color: white;
152
+ font-family: var(--font-family);
153
+ font-size: 1rem;
154
+ transition: all 0.3s ease;
155
+ }
156
+
157
+ .input-wrapper input:focus {
158
+ outline: none;
159
+ border-color: var(--accent-color);
160
+ box-shadow: 0 0 15px rgba(0, 243, 255, 0.2);
161
+ }
162
+
163
+ button {
164
+ width: 100%;
165
+ padding: 14px;
166
+ border-radius: 12px;
167
+ font-family: var(--font-family);
168
+ font-weight: 600;
169
+ font-size: 1rem;
170
+ cursor: pointer;
171
+ border: none;
172
+ margin-bottom: 1rem;
173
+ position: relative;
174
+ overflow: hidden;
175
+ transition: transform 0.2s;
176
+ }
177
+
178
+ button:active {
179
+ transform: scale(0.98);
180
+ }
181
+
182
+ .primary-btn {
183
+ background: linear-gradient(135deg, var(--primary-color), #8a2be2);
184
+ color: white;
185
+ box-shadow: 0 4px 15px rgba(138, 43, 226, 0.4);
186
+ }
187
+
188
+ .primary-btn:hover {
189
+ box-shadow: 0 6px 20px rgba(138, 43, 226, 0.6);
190
+ }
191
+
192
+ .primary-btn:disabled,
193
+ .primary-btn.disabled {
194
+ background: #444;
195
+ color: #888;
196
+ box-shadow: none;
197
+ cursor: not-allowed;
198
+ transform: none;
199
+ }
200
+
201
+ .secondary-btn {
202
+ background: rgba(255, 255, 255, 0.1);
203
+ color: var(--text-main);
204
+ }
205
+
206
+ .secondary-btn:hover:not(:disabled) {
207
+ background: rgba(255, 255, 255, 0.2);
208
+ }
209
+
210
+ .secondary-btn:disabled {
211
+ opacity: 0.5;
212
+ cursor: not-allowed;
213
+ }
214
+
215
+ .stats {
216
+ margin-top: 1rem;
217
+ padding-top: 1rem;
218
+ border-top: 1px solid var(--glass-border);
219
+ display: grid;
220
+ grid-template-columns: 1fr 1fr;
221
+ gap: 1rem;
222
+ }
223
+
224
+ .stat-item {
225
+ display: flex;
226
+ flex-direction: column;
227
+ }
228
+
229
+ .stat-item .label {
230
+ font-size: 0.75rem;
231
+ color: var(--text-muted);
232
+ text-transform: uppercase;
233
+ }
234
+
235
+ .stat-item .value {
236
+ font-size: 1.2rem;
237
+ font-weight: 600;
238
+ color: var(--accent-color);
239
+ }
240
+
241
+ .viewport {
242
+ flex: 1;
243
+ position: relative;
244
+ display: flex;
245
+ align-items: center;
246
+ justify-content: center;
247
+ padding: 2rem;
248
+ }
249
+
250
+ #canvas-wrapper {
251
+ width: 100%;
252
+ height: 100%;
253
+ position: relative;
254
+ border-radius: 24px;
255
+ overflow: hidden;
256
+ background: rgba(0, 0, 0, 0.2);
257
+ border: 1px solid var(--glass-border);
258
+ box-shadow: inset 0 0 50px rgba(0, 0, 0, 0.5);
259
+ }
260
+
261
+ canvas {
262
+ display: block;
263
+ width: 100%;
264
+ height: 100%;
265
+ }
266
+
267
+ /* Legend Styles */
268
+ .legend {
269
+ margin-top: 2rem;
270
+ border-top: 1px solid var(--glass-border);
271
+ padding-top: 1.5rem;
272
+ }
273
+
274
+ .legend h3 {
275
+ font-size: 0.85rem;
276
+ text-transform: uppercase;
277
+ color: var(--text-muted);
278
+ letter-spacing: 1.5px;
279
+ margin-bottom: 1rem;
280
+ }
281
+
282
+ .legend-item {
283
+ display: flex;
284
+ align-items: center;
285
+ margin-bottom: 0.8rem;
286
+ font-size: 0.9rem;
287
+ color: var(--text-main);
288
+ }
289
+
290
+ .legend-item .icon {
291
+ width: 12px;
292
+ height: 12px;
293
+ border-radius: 50%;
294
+ margin-right: 12px;
295
+ display: inline-block;
296
+ box-shadow: 0 0 5px rgba(0, 0, 0, 0.5);
297
+ }
298
+
299
+ .legend-item .icon.gateway {
300
+ background: #00f3ff;
301
+ box-shadow: 0 0 8px #00f3ff;
302
+ }
303
+
304
+ .legend-item .icon.head {
305
+ background: transparent;
306
+ border: 2px solid var(--primary-color);
307
+ box-shadow: 0 0 10px var(--primary-color);
308
+ width: 14px;
309
+ height: 14px;
310
+ }
311
+
312
+ .legend-item .icon.high-batt {
313
+ background: #00ff00;
314
+ }
315
+
316
+ .legend-item .icon.med-batt {
317
+ background: #ffff00;
318
+ }
319
+
320
+ .legend-item .icon.low-batt {
321
+ background: #ff9900;
322
+ }
323
+
324
+ .legend-item .icon.dead {
325
+ width: 10px;
326
+ height: 10px;
327
+ background: transparent;
328
+ position: relative;
329
+ }
330
+
331
+ .legend-item .icon.dead::before,
332
+ .legend-item .icon.dead::after {
333
+ content: '';
334
+ position: absolute;
335
+ top: 50%;
336
+ left: 50%;
337
+ width: 100%;
338
+ height: 2px;
339
+ background: red;
340
+ }
341
+
342
+ .legend-item .icon.dead::before {
343
+ transform: translate(-50%, -50%) rotate(45deg);
344
+ }
345
+
346
+ .legend-item .icon.dead::after {
347
+ transform: translate(-50%, -50%) rotate(-45deg);
348
+ }
349
+
350
+ /* --- Analytics Section --- */
351
+ .analytics-container {
352
+ max-width: 1200px;
353
+ margin: 4rem auto;
354
+ background: rgba(10, 10, 20, 0.6);
355
+ border: 1px solid var(--glass-border);
356
+ border-radius: 24px;
357
+ padding: 3rem;
358
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
359
+ }
360
+
361
+ .analytics-header {
362
+ text-align: center;
363
+ margin-bottom: 2rem;
364
+ }
365
+
366
+ .analytics-header h2 {
367
+ font-size: 2rem;
368
+ background: linear-gradient(90deg, #00f3ff, #00ff00);
369
+ -webkit-background-clip: text;
370
+ background-clip: text;
371
+ -webkit-text-fill-color: transparent;
372
+ margin-bottom: 0.5rem;
373
+ }
374
+
375
+ .analytics-header p {
376
+ color: var(--text-muted);
377
+ }
378
+
379
+ .analytics-content {
380
+ display: grid;
381
+ grid-template-columns: 1fr 2fr;
382
+ gap: 2rem;
383
+ align-items: start;
384
+ }
385
+
386
+ /* Upload Zone */
387
+ .upload-box {
388
+ border: 2px dashed var(--glass-border);
389
+ border-radius: 16px;
390
+ padding: 2rem;
391
+ text-align: center;
392
+ cursor: pointer;
393
+ transition: all 0.3s;
394
+ background: rgba(255, 255, 255, 0.02);
395
+ }
396
+
397
+ .upload-box:hover {
398
+ border-color: var(--accent-color);
399
+ background: rgba(0, 243, 255, 0.05);
400
+ }
401
+
402
+ .upload-box .icon {
403
+ display: block;
404
+ width: 48px;
405
+ height: 48px;
406
+ margin: 0 auto 1rem;
407
+ background: var(--text-muted);
408
+ /* Placeholder for an icon */
409
+ mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'/%3E%3Cpolyline points='17 8 12 3 7 8'/%3E%3Cline x1='12' y1='3' x2='12' y2='15'/%3E%3C/svg%3E") no-repeat center;
410
+ -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'/%3E%3Cpolyline points='17 8 12 3 7 8'/%3E%3Cline x1='12' y1='3' x2='12' y2='15'/%3E%3C/svg%3E") no-repeat center;
411
+ background-color: var(--accent-color);
412
+ }
413
+
414
+ .status-box {
415
+ margin-top: 1rem;
416
+ text-align: center;
417
+ color: var(--text-muted);
418
+ font-size: 0.9rem;
419
+ }
420
+
421
+ /* Video Feed */
422
+ .video-feed-wrapper {
423
+ width: 100%;
424
+ aspect-ratio: 16/9;
425
+ background: #000;
426
+ border-radius: 16px;
427
+ overflow: hidden;
428
+ position: relative;
429
+ border: 1px solid var(--glass-border);
430
+ box-shadow: 0 0 30px rgba(0, 243, 255, 0.1);
431
+ }
432
+
433
+ .video-placeholder {
434
+ display: flex;
435
+ align-items: center;
436
+ justify-content: center;
437
+ height: 100%;
438
+ color: var(--text-muted);
439
+ background: radial-gradient(circle at center, #111 0%, #000 100%);
440
+ }
441
+
442
+ #detectionFeed {
443
+ width: 100%;
444
+ height: 100%;
445
+ object-fit: contain;
446
+ }
447
+
448
+ @media (max-width: 900px) {
449
+ .analytics-content {
450
+ grid-template-columns: 1fr;
451
+ }
452
+ }
453
+
454
+ /* --- Dashboard Panel --- */
455
+ .dashboard-panel {
456
+ margin-top: 1rem;
457
+ background: var(--panel-bg);
458
+ border: 1px solid var(--glass-border);
459
+ border-radius: 16px;
460
+ padding: 1.5rem;
461
+ backdrop-filter: blur(12px);
462
+ -webkit-backdrop-filter: blur(12px);
463
+ display: flex;
464
+ flex-direction: column;
465
+ gap: 1.5rem;
466
+ }
467
+
468
+ .dashboard-panel h3 {
469
+ font-size: 1.1rem;
470
+ color: var(--text-main);
471
+ border-bottom: 1px solid var(--glass-border);
472
+ padding-bottom: 0.5rem;
473
+ margin-bottom: 0.5rem;
474
+ }
475
+
476
+ .dashboard-grid {
477
+ display: grid;
478
+ grid-template-columns: repeat(2, 1fr);
479
+ gap: 1rem;
480
+ }
481
+
482
+ .dash-card {
483
+ background: rgba(255, 255, 255, 0.05);
484
+ border-radius: 12px;
485
+ padding: 1rem;
486
+ text-align: center;
487
+ border: 1px solid var(--glass-border);
488
+ }
489
+
490
+ .dash-label {
491
+ display: block;
492
+ font-size: 0.8rem;
493
+ color: var(--text-muted);
494
+ margin-bottom: 0.5rem;
495
+ }
496
+
497
+ .dash-value {
498
+ font-size: 1.5rem;
499
+ font-weight: 700;
500
+ color: #ff4444;
501
+ /* Alert color for failures */
502
+ }
503
+
504
+ /* List container for dead nodes */
505
+ .dead-nodes-list-container {
506
+ max-height: 150px;
507
+ overflow-y: auto;
508
+ background: rgba(0, 0, 0, 0.2);
509
+ border-radius: 8px;
510
+ padding: 0.5rem;
511
+ }
512
+
513
+ .dead-nodes-list-container h4 {
514
+ font-size: 0.9rem;
515
+ color: var(--text-muted);
516
+ margin-bottom: 0.5rem;
517
+ position: sticky;
518
+ top: 0;
519
+ background: rgba(24, 24, 30, 0.9);
520
+ padding: 0.2rem 0;
521
+ }
522
+
523
+ .dead-nodes-list {
524
+ list-style: none;
525
+ padding: 0;
526
+ }
527
+
528
+ .dead-node-item {
529
+ display: flex;
530
+ justify-content: space-between;
531
+ padding: 0.4rem 0.8rem;
532
+ border-bottom: 1px solid var(--glass-border);
533
+ font-size: 0.85rem;
534
+ }
535
+
536
+ .dead-node-item:last-child {
537
+ border-bottom: none;
538
+ }
539
+
540
+ .dead-node-id {
541
+ color: #ff4444;
542
+ font-weight: 600;
543
+ }
544
+
545
+ .dead-node-time {
546
+ color: var(--text-muted);
547
+ }
templates/index.html ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>Splay Algorithm Visualization</title>
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600&display=swap" rel="stylesheet">
11
+ <link rel="stylesheet" href="/static/style.css">
12
+ </head>
13
+
14
+ <body>
15
+ <div class="page-wrapper">
16
+ <section class="hero">
17
+ <h1>Energy-Efficient Wildlife Tracking Using a Splay Tree Inspired Clustering Policy and Vision-Aided
18
+ Analytics</h1>
19
+ <p>Advanced Sensor Cluster Simulation & Visualization<br>Powered by Splay Network Algorithms</p>
20
+ </section>
21
+
22
+ <section class="app-container">
23
+ <aside class="sidebar">
24
+ <div class="glass-panel">
25
+ <p class="subtitle">Simulation Controls</p>
26
+
27
+ <div class="control-group">
28
+ <label for="nodeCount">Number of Nodes</label>
29
+ <div class="input-wrapper">
30
+ <input type="number" id="nodeCount" value="50" min="10" max="500">
31
+ </div>
32
+ </div>
33
+
34
+ <div class="control-group">
35
+ <button id="startBtn" class="primary-btn">
36
+ <span class="btn-text">Initialize & Start</span>
37
+ <div class="btn-shine"></div>
38
+ </button>
39
+ <button id="stopBtn" class="secondary-btn" disabled>Stop</button>
40
+ </div>
41
+
42
+ <div class="stats">
43
+ <div class="stat-item">
44
+ <span class="label">Time Step</span>
45
+ <span class="value" id="simTime">0</span>
46
+ </div>
47
+ <div class="stat-item">
48
+ <span class="label">Active Nodes</span>
49
+ <span class="value" id="activeNodes">0</span>
50
+ </div>
51
+ </div>
52
+
53
+ <div class="legend">
54
+ <h3>Network State Legend</h3>
55
+ <div class="legend-item">
56
+ <span class="icon gateway"></span>
57
+ <span class="text">Gateway</span>
58
+ </div>
59
+ <div class="legend-item">
60
+ <span class="icon head"></span>
61
+ <span class="text">Cluster Head</span>
62
+ </div>
63
+ <div class="legend-item">
64
+ <span class="icon high-batt"></span>
65
+ <span class="text">High Battery (>50%)</span>
66
+ </div>
67
+ <div class="legend-item">
68
+ <span class="icon med-batt"></span>
69
+ <span class="text">Med Battery (>20%)</span>
70
+ </div>
71
+ <div class="legend-item">
72
+ <span class="icon low-batt"></span>
73
+ <span class="text">Low Battery</span>
74
+ </div>
75
+ <div class="legend-item">
76
+ <span class="icon dead"></span>
77
+ <span class="text">Dead Node</span>
78
+ </div>
79
+ </div>
80
+ </div>
81
+ </aside>
82
+
83
+ <main class="viewport">
84
+ <!-- Canvas container for responsive sizing -->
85
+ <div id="canvas-wrapper">
86
+ <canvas id="simCanvas"></canvas>
87
+ </div>
88
+
89
+ <!-- Live Dashboard -->
90
+ <div id="dashboard" class="dashboard-panel">
91
+ <h3>Network Health Dashboard</h3>
92
+ <div class="dashboard-grid">
93
+ <div class="dash-card">
94
+ <span class="dash-label">Total Dead Nodes</span>
95
+ <span class="dash-value" id="deadCount">0</span>
96
+ </div>
97
+ <div class="dash-card">
98
+ <span class="dash-label">Avg Downtime (Ticks)</span>
99
+ <span class="dash-value" id="avgDowntime">0</span>
100
+ </div>
101
+ </div>
102
+ <div class="dead-nodes-list-container">
103
+ <h4>Recent Node Failures</h4>
104
+ <ul id="deadNodesList" class="dead-nodes-list">
105
+ <!-- Populated via JS -->
106
+ </ul>
107
+ </div>
108
+ </div>
109
+ </main>
110
+ </section>
111
+
112
+ <!-- New Section: Wildlife Analytics -->
113
+ <section class="analytics-container">
114
+ <div class="analytics-header">
115
+ <h2>Real-Time Wildlife Analytics</h2>
116
+ <p>Upload video footprint to detect and track species using Splay-Optimized Edge-Server Pipeline.</p>
117
+ </div>
118
+
119
+ <div class="analytics-content">
120
+ <div class="upload-zone">
121
+ <div class="upload-box" id="dropZone">
122
+ <span class="icon cloud-upload"></span>
123
+ <p>Drag & Drop Wildlife Video or Click to Browse</p>
124
+ <input type="file" id="videoInput" accept="video/*" hidden>
125
+ </div>
126
+ <div class="status-box" id="uploadStatus">Awaiting Upload...</div>
127
+ </div>
128
+
129
+ <div class="video-feed-wrapper">
130
+ <div class="video-placeholder" id="videoPlaceholder">
131
+ <p>Live Detection Feed will appear here</p>
132
+ </div>
133
+ </div>
134
+ </div>
135
+ </section>
136
+ </div>
137
+
138
+ <script src="/static/script.js"></script>
139
+ </body>
140
+
141
+ </html>