Subh775 commited on
Commit
d9ebe88
·
1 Parent(s): a476183

heavy add-ons: 13+ features implemented

Browse files
backend/engine.py CHANGED
@@ -7,6 +7,9 @@ import numpy as np
7
  import cv2
8
  from collections import defaultdict
9
  from constants import MODEL_CLASSES
 
 
 
10
 
11
 
12
  def _side(p, a, b):
@@ -139,6 +142,7 @@ def run(model, video_path, line, config, on_frame, save_annotated=False, annotat
139
  flow_times = []
140
  conf_scores = []
141
  heatmap_points = []
 
142
  raw_events = [["frame_index", "timestamp_sec", "vehicle_id", "class_name", "direction"]]
143
 
144
  start = time.time()
@@ -148,7 +152,7 @@ def run(model, video_path, line, config, on_frame, save_annotated=False, annotat
148
  # ExecuTorch: https://docs.ultralytics.com/integrations/executorch/#what-are-the-system-requirements-for-executorch-export
149
  results = model.track(
150
  source=video_path,
151
- tracker="bytetrack.yaml",
152
  imgsz=736, # MUST match OpenVINO export imgsz — compiled graph is fixed shape
153
  conf=config.get("conf", 0.12),
154
  iou=config.get("iou", 0.6),
@@ -194,6 +198,7 @@ def run(model, video_path, line, config, on_frame, save_annotated=False, annotat
194
  cy = int((box[1] + box[3]) / 2)
195
 
196
  heatmap_points.append([cx, cy])
 
197
 
198
  current = _side((cx, cy), a, b)
199
 
@@ -253,6 +258,12 @@ def run(model, video_path, line, config, on_frame, save_annotated=False, annotat
253
  actual_fps = round(total / processing_time, 2) if processing_time > 0 else 0
254
  speed_vs_rt = round(actual_fps / fps, 2) if fps > 0 else 0
255
 
 
 
 
 
 
 
256
  result = {
257
  "class_in": dict(class_in),
258
  "class_out": dict(class_out),
@@ -264,6 +275,8 @@ def run(model, video_path, line, config, on_frame, save_annotated=False, annotat
264
  "processing_time": processing_time,
265
  "actual_fps": actual_fps,
266
  "speed_vs_realtime": speed_vs_rt,
 
 
267
  }
268
 
269
  if annotated_path and os.path.exists(annotated_path):
 
7
  import cv2
8
  from collections import defaultdict
9
  from constants import MODEL_CLASSES
10
+ from tracker_config import get_tracker_path
11
+ from speed import estimate_speeds
12
+ from pcu import compute_pcu
13
 
14
 
15
  def _side(p, a, b):
 
142
  flow_times = []
143
  conf_scores = []
144
  heatmap_points = []
145
+ track_positions = defaultdict(list)
146
  raw_events = [["frame_index", "timestamp_sec", "vehicle_id", "class_name", "direction"]]
147
 
148
  start = time.time()
 
152
  # ExecuTorch: https://docs.ultralytics.com/integrations/executorch/#what-are-the-system-requirements-for-executorch-export
153
  results = model.track(
154
  source=video_path,
155
+ tracker=get_tracker_path(),
156
  imgsz=736, # MUST match OpenVINO export imgsz — compiled graph is fixed shape
157
  conf=config.get("conf", 0.12),
158
  iou=config.get("iou", 0.6),
 
198
  cy = int((box[1] + box[3]) / 2)
199
 
200
  heatmap_points.append([cx, cy])
201
+ track_positions[obj_id].append((frame_idx, cx, cy))
202
 
203
  current = _side((cx, cy), a, b)
204
 
 
258
  actual_fps = round(total / processing_time, 2) if processing_time > 0 else 0
259
  speed_vs_rt = round(actual_fps / fps, 2) if fps > 0 else 0
260
 
261
+ speed_data = estimate_speeds(dict(track_positions))
262
+ pcu_data = compute_pcu(
263
+ {str(k): v for k, v in class_in.items()},
264
+ {str(k): v for k, v in class_out.items()},
265
+ )
266
+
267
  result = {
268
  "class_in": dict(class_in),
269
  "class_out": dict(class_out),
 
275
  "processing_time": processing_time,
276
  "actual_fps": actual_fps,
277
  "speed_vs_realtime": speed_vs_rt,
278
+ "speed": speed_data,
279
+ "pcu": pcu_data,
280
  }
281
 
282
  if annotated_path and os.path.exists(annotated_path):
backend/export_json.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Structured JSON artifact export with complete run metadata.
3
+
4
+ Produces a self-contained JSON document suitable for programmatic
5
+ consumption, API integration, and archival.
6
+ """
7
+ import json
8
+ import os
9
+ from datetime import datetime, timezone
10
+
11
+
12
+ def export_json(data, video_meta, engine_config, out_dir):
13
+ """Generate a comprehensive JSON export of the analysis run.
14
+
15
+ Args:
16
+ data: Engine result dict (counts, congestion, events, etc.)
17
+ video_meta: dict with filename, fps, frames, duration, resolution, pixels
18
+ engine_config: dict with imgsz, conf, iou, stride, etc.
19
+ out_dir: Output directory path
20
+
21
+ Returns:
22
+ Filename string or None
23
+ """
24
+ class_in = data.get("class_in", {})
25
+ class_out = data.get("class_out", {})
26
+ congestion = data.get("congestion", [])
27
+ raw_events = data.get("raw_events", [])
28
+ pcu_data = data.get("pcu", {})
29
+ speed_data = data.get("speed", {})
30
+
31
+ # Build events list from raw_events (skip header row)
32
+ events = []
33
+ for row in raw_events[1:]:
34
+ events.append({
35
+ "frame": row[0],
36
+ "timestamp_sec": row[1],
37
+ "vehicle_id": row[2],
38
+ "class_name": row[3],
39
+ "direction": row[4],
40
+ })
41
+
42
+ total_in = sum(class_in.values())
43
+ total_out = sum(class_out.values())
44
+
45
+ doc = {
46
+ "urbanflow_version": "1.1",
47
+ "generated_at": datetime.now(timezone.utc).isoformat(),
48
+ "video": video_meta,
49
+ "engine": {
50
+ "model": "VehicleNet-Y26s (OpenVINO INT8)",
51
+ "imgsz": engine_config.get("imgsz", 736),
52
+ "conf": engine_config.get("conf", 0.12),
53
+ "iou": engine_config.get("iou", 0.6),
54
+ "stride": engine_config.get("detect_stride", 2),
55
+ "tracker": "ByteTrack (custom)",
56
+ "batch": 2,
57
+ },
58
+ "performance": {
59
+ "processing_time_sec": data.get("processing_time", 0),
60
+ "actual_fps": data.get("actual_fps", 0),
61
+ "speed_vs_realtime": data.get("speed_vs_realtime", 0),
62
+ },
63
+ "counts": {
64
+ "total_in": total_in,
65
+ "total_out": total_out,
66
+ "total": total_in + total_out,
67
+ "per_class_in": {str(k): v for k, v in class_in.items()},
68
+ "per_class_out": {str(k): v for k, v in class_out.items()},
69
+ },
70
+ "pcu": pcu_data,
71
+ "speed_distribution": speed_data.get("distribution", {}),
72
+ "congestion": {
73
+ "peak": max(congestion) if congestion else 0,
74
+ "average": round(sum(congestion) / len(congestion), 1) if congestion else 0,
75
+ "timeline": congestion,
76
+ },
77
+ "flow_times": data.get("flow_times", []),
78
+ "events": events,
79
+ }
80
+
81
+ path = os.path.join(out_dir, "analysis.json")
82
+ with open(path, "w") as f:
83
+ json.dump(doc, f, indent=2, default=str)
84
+
85
+ return "analysis.json"
backend/pcu.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Passenger Car Unit (PCU) conversion using IRC:106-1990 guidelines.
3
+
4
+ PCU normalizes heterogeneous Indian traffic into a single unit of measurement
5
+ used by transport authorities for capacity analysis and road design.
6
+ """
7
+ from constants import MODEL_CLASSES
8
+
9
+ # IRC:106-1990 PCU equivalency factors
10
+ _PCU_TABLE = {
11
+ 0: 1.0, # Hatchback
12
+ 1: 1.0, # Sedan
13
+ 2: 1.0, # SUV
14
+ 3: 1.0, # MUV
15
+ 4: 3.0, # Bus
16
+ 5: 3.0, # Truck
17
+ 6: 1.2, # Three-wheeler (Auto-rickshaw)
18
+ 7: 0.5, # Two-wheeler
19
+ 8: 3.0, # LCV
20
+ 9: 3.0, # Mini-bus
21
+ 10: 3.0, # Tempo-traveller
22
+ 11: 0.5, # Bicycle
23
+ 12: 1.0, # Van
24
+ 13: 1.0, # Others
25
+ }
26
+
27
+
28
+ def get_pcu_factor(class_id):
29
+ return _PCU_TABLE.get(class_id, 1.0)
30
+
31
+
32
+ def compute_pcu(class_in, class_out):
33
+ """Convert raw vehicle counts to PCU values.
34
+
35
+ Args:
36
+ class_in: dict {class_id_str: count}
37
+ class_out: dict {class_id_str: count}
38
+
39
+ Returns:
40
+ dict with total_pcu, pcu_in, pcu_out, per_class breakdown
41
+ """
42
+ pcu_in = 0.0
43
+ pcu_out = 0.0
44
+ per_class = {}
45
+
46
+ all_ids = set(list(class_in.keys()) + list(class_out.keys()))
47
+ for cid_str in all_ids:
48
+ cid = int(cid_str)
49
+ factor = get_pcu_factor(cid)
50
+ in_count = class_in.get(cid_str, 0)
51
+ out_count = class_out.get(cid_str, 0)
52
+ cls_pcu_in = round(in_count * factor, 1)
53
+ cls_pcu_out = round(out_count * factor, 1)
54
+ pcu_in += cls_pcu_in
55
+ pcu_out += cls_pcu_out
56
+ per_class[MODEL_CLASSES.get(cid, f"cls_{cid}")] = {
57
+ "count": in_count + out_count,
58
+ "factor": factor,
59
+ "pcu": round(cls_pcu_in + cls_pcu_out, 1),
60
+ }
61
+
62
+ return {
63
+ "total_pcu": round(pcu_in + pcu_out, 1),
64
+ "pcu_in": round(pcu_in, 1),
65
+ "pcu_out": round(pcu_out, 1),
66
+ "per_class": per_class,
67
+ }
backend/server.py CHANGED
@@ -135,6 +135,10 @@ def get_report(video_id: str, name: str):
135
  media = "application/pdf"
136
  elif name.endswith(".mp4"):
137
  media = "video/mp4"
 
 
 
 
138
 
139
  return FileResponse(str(path), media_type=media)
140
 
@@ -180,6 +184,23 @@ def download_all_reports(video_id: str):
180
  return Response(content=str(e), status_code=500)
181
 
182
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  @app.websocket("/ws/run")
184
  async def ws_run(ws: WebSocket):
185
  await ws.accept()
@@ -223,6 +244,15 @@ async def ws_run(ws: WebSocket):
223
  result = task.result() # re-raises any exception from the engine
224
  result["report_format"] = report_format
225
  result["video_path"] = path
 
 
 
 
 
 
 
 
 
226
  run_results[video_id] = result
227
  await ws.send_text(json.dumps({
228
  "done": True,
@@ -230,6 +260,8 @@ async def ws_run(ws: WebSocket):
230
  "processing_time": result["processing_time"],
231
  "actual_fps": result["actual_fps"],
232
  "speed_vs_realtime": result["speed_vs_realtime"],
 
 
233
  }))
234
  await ws.close()
235
 
 
135
  media = "application/pdf"
136
  elif name.endswith(".mp4"):
137
  media = "video/mp4"
138
+ elif name.endswith(".json"):
139
+ media = "application/json"
140
+ elif name.endswith(".csv"):
141
+ media = "text/csv"
142
 
143
  return FileResponse(str(path), media_type=media)
144
 
 
184
  return Response(content=str(e), status_code=500)
185
 
186
 
187
+ FEEDBACK_PATH = Path(tempfile.gettempdir()) / "urbanflow_feedback.json"
188
+
189
+
190
+ @app.post("/api/feedback")
191
+ async def submit_feedback(request_data: dict = None):
192
+ from datetime import datetime, timezone
193
+ feedback = request_data or {}
194
+ entries = []
195
+ if FEEDBACK_PATH.exists():
196
+ entries = json.loads(FEEDBACK_PATH.read_text())
197
+ feedback["timestamp"] = datetime.now(timezone.utc).isoformat()
198
+ entries.append(feedback)
199
+ FEEDBACK_PATH.write_text(json.dumps(entries, indent=2))
200
+ print(f"[BACKEND] Feedback received: {feedback.get('type', 'general')}")
201
+ return {"status": "ok"}
202
+
203
+
204
  @app.websocket("/ws/run")
205
  async def ws_run(ws: WebSocket):
206
  await ws.accept()
 
244
  result = task.result() # re-raises any exception from the engine
245
  result["report_format"] = report_format
246
  result["video_path"] = path
247
+ result["video_meta"] = {
248
+ "filename": video_info.get(video_id, "unknown"),
249
+ "fps": cfg.get("video_fps", 0),
250
+ "frames": cfg.get("frames", 0),
251
+ "duration": cfg.get("duration", 0),
252
+ "resolution": cfg.get("resolution", [0, 0]),
253
+ "pixels": cfg.get("pixels", 0),
254
+ }
255
+ result["engine_config"] = cfg
256
  run_results[video_id] = result
257
  await ws.send_text(json.dumps({
258
  "done": True,
 
260
  "processing_time": result["processing_time"],
261
  "actual_fps": result["actual_fps"],
262
  "speed_vs_realtime": result["speed_vs_realtime"],
263
+ "pcu": result.get("pcu", {}),
264
+ "speed_distribution": result.get("speed", {}).get("distribution", {}),
265
  }))
266
  await ws.close()
267
 
backend/speed.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Lightweight speed estimation using pixel displacement between frames.
3
+
4
+ No camera calibration required — classifies vehicles into relative
5
+ speed categories (Slow/Normal/Fast) based on percentile ranking
6
+ of pixel-per-frame displacement within the video.
7
+ """
8
+ import numpy as np
9
+
10
+
11
+ def estimate_speeds(track_positions):
12
+ """Compute per-track speed category from centroid displacement history.
13
+
14
+ Args:
15
+ track_positions: dict {track_id: [(frame_idx, cx, cy), ...]}
16
+
17
+ Returns:
18
+ dict with per_track speeds and aggregate distribution
19
+ """
20
+ displacements = {}
21
+
22
+ for tid, positions in track_positions.items():
23
+ if len(positions) < 2:
24
+ continue
25
+ positions.sort(key=lambda x: x[0])
26
+ total_disp = 0.0
27
+ count = 0
28
+ for i in range(1, len(positions)):
29
+ dx = positions[i][1] - positions[i - 1][1]
30
+ dy = positions[i][2] - positions[i - 1][2]
31
+ total_disp += np.sqrt(dx * dx + dy * dy)
32
+ count += 1
33
+ displacements[tid] = total_disp / count if count > 0 else 0.0
34
+
35
+ if not displacements:
36
+ return {"per_track": {}, "distribution": {"slow": 0, "normal": 0, "fast": 0}}
37
+
38
+ speeds = np.array(list(displacements.values()))
39
+ p33 = np.percentile(speeds, 33)
40
+ p66 = np.percentile(speeds, 66)
41
+
42
+ per_track = {}
43
+ counts = {"slow": 0, "normal": 0, "fast": 0}
44
+
45
+ for tid, spd in displacements.items():
46
+ if spd <= p33:
47
+ cat = "slow"
48
+ elif spd <= p66:
49
+ cat = "normal"
50
+ else:
51
+ cat = "fast"
52
+ per_track[int(tid)] = {"px_per_frame": round(spd, 1), "category": cat}
53
+ counts[cat] += 1
54
+
55
+ total = sum(counts.values())
56
+ distribution = {
57
+ "slow": round(counts["slow"] / total * 100, 1) if total else 0,
58
+ "normal": round(counts["normal"] / total * 100, 1) if total else 0,
59
+ "fast": round(counts["fast"] / total * 100, 1) if total else 0,
60
+ }
61
+
62
+ return {"per_track": per_track, "distribution": distribution}
backend/tracker_config.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Custom ByteTrack configuration optimized for Indian traffic conditions.
3
+
4
+ Tuning rationale (vs Ultralytics defaults):
5
+ - Lower thresholds: Indian roads have heavy occlusion (autos behind buses)
6
+ - Higher track_buffer: Vehicles disappear behind large vehicles for longer
7
+ - Looser match_thresh: Apparent shape changes during partial occlusion
8
+
9
+ Reference: https://docs.ultralytics.com/modes/track/
10
+ """
11
+ import os
12
+ import tempfile
13
+
14
+ _YAML_CONTENT = """# UrbanFlow — ByteTrack config for Indian heterogeneous traffic
15
+ # Base: https://github.com/ultralytics/ultralytics/blob/main/ultralytics/cfg/trackers/bytetrack.yaml
16
+
17
+ tracker_type: bytetrack
18
+ track_high_thresh: 0.20
19
+ track_low_thresh: 0.08
20
+ new_track_thresh: 0.20
21
+ track_buffer: 45
22
+ match_thresh: 0.75
23
+ fuse_score: true
24
+ """
25
+
26
+ _CONFIG_PATH = os.path.join(tempfile.gettempdir(), "urbanflow_bytetrack.yaml")
27
+
28
+
29
+ def get_tracker_path():
30
+ """Write custom YAML once and return its path."""
31
+ if not os.path.exists(_CONFIG_PATH):
32
+ with open(_CONFIG_PATH, "w") as f:
33
+ f.write(_YAML_CONTENT)
34
+ print(f"[tracker] Custom ByteTrack config written: {_CONFIG_PATH}")
35
+ return _CONFIG_PATH
backend/visualize.py CHANGED
@@ -6,6 +6,7 @@ import matplotlib
6
  matplotlib.use("Agg")
7
  import matplotlib.pyplot as plt
8
  from matplotlib.ticker import MaxNLocator
 
9
 
10
  # Formal MIS palette
11
  C_PRIMARY = "#1e293b"
@@ -244,6 +245,12 @@ def generate_all(data, model_classes, out_dir, report_format="png"):
244
  lambda: confidence_dist(data.get("conf_scores", []), out_dir, fmt),
245
  lambda: spatial_heatmap(heatmap_points, video_path, out_dir, fmt),
246
  lambda: export_csv(raw_events, out_dir),
 
 
 
 
 
 
247
  ]:
248
  name = fn()
249
  if name:
 
6
  matplotlib.use("Agg")
7
  import matplotlib.pyplot as plt
8
  from matplotlib.ticker import MaxNLocator
9
+ from export_json import export_json
10
 
11
  # Formal MIS palette
12
  C_PRIMARY = "#1e293b"
 
245
  lambda: confidence_dist(data.get("conf_scores", []), out_dir, fmt),
246
  lambda: spatial_heatmap(heatmap_points, video_path, out_dir, fmt),
247
  lambda: export_csv(raw_events, out_dir),
248
+ lambda: export_json(
249
+ data,
250
+ data.get("video_meta", {}),
251
+ data.get("engine_config", {}),
252
+ out_dir,
253
+ ),
254
  ]:
255
  name = fn()
256
  if name:
frontend/initial.html CHANGED
@@ -80,6 +80,32 @@
80
  background-color: var(--cocoa) !important;
81
  color: var(--t1) !important;
82
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  </style>
84
  </head>
85
 
@@ -378,6 +404,53 @@
378
  window.location.href = '/';
379
  }
380
  </script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
  </body>
382
 
383
  </html>
 
80
  background-color: var(--cocoa) !important;
81
  color: var(--t1) !important;
82
  }
83
+
84
+ /* Onboarding */
85
+ .onboard-overlay {
86
+ position: fixed; inset: 0; z-index: 9999;
87
+ background: rgba(0,0,0,0.92);
88
+ display: flex; align-items: center; justify-content: center;
89
+ }
90
+ .onboard-card {
91
+ background: #0a0a0a; border: 1px solid #2a2a2a;
92
+ border-radius: 16px; max-width: 440px; width: 90%;
93
+ padding: 40px 32px; text-align: center;
94
+ }
95
+ .onboard-step { display: none; }
96
+ .onboard-step.active { display: block; }
97
+ .onboard-dots { display: flex; gap: 6px; justify-content: center; margin-top: 20px; }
98
+ .onboard-dot {
99
+ width: 8px; height: 8px; border-radius: 50%;
100
+ background: #333; transition: background 0.2s;
101
+ }
102
+ .onboard-dot.active { background: var(--cocoa-l); }
103
+
104
+ /* Mobile responsive */
105
+ @media (max-width: 768px) {
106
+ main { grid-template-columns: 1fr !important; padding: 16px !important; }
107
+ h1 { font-size: 2.2rem !important; }
108
+ }
109
  </style>
110
  </head>
111
 
 
404
  window.location.href = '/';
405
  }
406
  </script>
407
+
408
+ <!-- Onboarding Walkthrough -->
409
+ <div id="onboard-overlay" class="onboard-overlay" style="display:none">
410
+ <div class="onboard-card">
411
+ <div class="onboard-step active" data-step="0">
412
+ <i class="fa-solid fa-cloud-arrow-up text-4xl mb-4" style="color:var(--cocoa-l)"></i>
413
+ <h3 class="text-lg font-bold mb-2" style="color:#f0ece6">Upload a Traffic Video</h3>
414
+ <p class="text-xs" style="color:#777;line-height:1.7">Drag & drop or select a video file recorded from any traffic camera. MP4, MOV, AVI formats supported.</p>
415
+ </div>
416
+ <div class="onboard-step" data-step="1">
417
+ <i class="fa-solid fa-draw-polygon text-4xl mb-4" style="color:var(--cocoa-l)"></i>
418
+ <h3 class="text-lg font-bold mb-2" style="color:#f0ece6">Draw a Counting Boundary</h3>
419
+ <p class="text-xs" style="color:#777;line-height:1.7">Click two points on the first frame to define a spatial boundary. Vehicles crossing this line will be counted and classified.</p>
420
+ </div>
421
+ <div class="onboard-step" data-step="2">
422
+ <i class="fa-solid fa-chart-line text-4xl mb-4" style="color:var(--cocoa-l)"></i>
423
+ <h3 class="text-lg font-bold mb-2" style="color:#f0ece6">Review Analytics & Export</h3>
424
+ <p class="text-xs" style="color:#777;line-height:1.7">Watch real-time charts populate as inference runs. Download annotated video, reports, and structured JSON when complete.</p>
425
+ </div>
426
+ <div class="onboard-dots">
427
+ <span class="onboard-dot active"></span>
428
+ <span class="onboard-dot"></span>
429
+ <span class="onboard-dot"></span>
430
+ </div>
431
+ <div class="flex gap-3 justify-center mt-6">
432
+ <button onclick="closeOnboarding()" class="text-[10px] font-bold uppercase tracking-widest px-4 py-2 rounded-full" style="color:#555;border:1px solid #222">Skip</button>
433
+ <button id="onboard-next" onclick="nextOnboardStep()" class="text-[10px] font-bold uppercase tracking-widest px-6 py-2 rounded-full" style="background:var(--cocoa);color:#f0ece6">Next</button>
434
+ </div>
435
+ </div>
436
+ </div>
437
+ <script>
438
+ let _obStep = 0;
439
+ function nextOnboardStep() {
440
+ _obStep++;
441
+ if (_obStep >= 3) { closeOnboarding(); return; }
442
+ document.querySelectorAll('.onboard-step').forEach((s, i) => s.classList.toggle('active', i === _obStep));
443
+ document.querySelectorAll('.onboard-dot').forEach((d, i) => d.classList.toggle('active', i === _obStep));
444
+ if (_obStep === 2) document.getElementById('onboard-next').innerText = 'Get Started';
445
+ }
446
+ function closeOnboarding() {
447
+ document.getElementById('onboard-overlay').style.display = 'none';
448
+ localStorage.setItem('uf_onboarded', '1');
449
+ }
450
+ if (!localStorage.getItem('uf_onboarded')) {
451
+ document.getElementById('onboard-overlay').style.display = 'flex';
452
+ }
453
+ </script>
454
  </body>
455
 
456
  </html>
frontend/vehicles.html CHANGED
@@ -325,6 +325,101 @@
325
  padding-top: 0;
326
  border-top: none;
327
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
  </style>
329
  </head>
330
 
@@ -356,6 +451,10 @@
356
  class="flex items-center px-4 py-2.5 rounded-lg transition cursor-pointer nav-item-inactive">
357
  <i class="fa-solid fa-sliders w-6"></i> <span class="font-medium">Settings</span>
358
  </a>
 
 
 
 
359
  </nav>
360
  <div class="mt-auto border-t p-4 flex flex-col items-center gap-2 bg-black flex-shrink-0"
361
  style="border-color:#2a2a2a">
@@ -368,9 +467,26 @@
368
  style="color:#a89f97" onmouseover="this.style.color='#c89a6c'"
369
  onmouseout="this.style.color='#a89f97'">Terms &amp; Conditions</button>
370
  <p class="text-[9px] mt-1" style="color:#333">&#169; 2026 UrbanFlow</p>
 
371
  </div>
372
  </aside>
373
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
374
  <main class="flex-1 flex flex-col h-full min-w-0 p-4 gap-4">
375
 
376
  <!-- TAB: About -->
@@ -515,7 +631,14 @@
515
  </div>
516
 
517
  <!-- TAB: Overview -->
518
- <div id="tab-overview" class="hidden grid grid-cols-12 gap-4 flex-1 min-h-0 overflow-hidden">
 
 
 
 
 
 
 
519
 
520
  <!-- Congestion Index -->
521
  <div
@@ -545,6 +668,13 @@
545
  <div class="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
546
  <span class="text-3xl font-black text-slate-900" id="cnt-total">0</span>
547
  <span class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Total Units</span>
 
 
 
 
 
 
 
548
  </div>
549
  </div>
550
  </div>
@@ -655,6 +785,32 @@
655
  </div>
656
  </div>
657
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
658
  </div>
659
  </div>
660
 
@@ -852,6 +1008,54 @@
852
  </div>
853
  </div>
854
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
855
  </main>
856
 
857
  <script>
@@ -934,7 +1138,7 @@
934
 
935
  // =========== Tab switching ===========
936
  function switchTab(tab) {
937
- ['about', 'overview', 'run-details', 'reports', 'settings'].forEach(t => {
938
  const el = document.getElementById('tab-' + t);
939
  const nav = document.getElementById('nav-' + t);
940
  if (el) el.classList.toggle('hidden', tab !== t);
@@ -950,6 +1154,96 @@
950
  });
951
  }
952
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
953
  // =========== Run Details helpers ===========
954
  function detailRow(label, value, extra) {
955
  extra = extra || '';
@@ -1430,6 +1724,7 @@
1430
  ws.onerror = e => {
1431
  console.error('WS Error:', e);
1432
  document.getElementById('proc-label').innerText = 'Connection Error';
 
1433
  if (badge) {
1434
  badge.innerText = 'Pipeline Failed';
1435
  badge.className = 'px-2.5 py-1 bg-red-900/40 text-red-100 text-[10px] font-bold rounded-full uppercase tracking-tighter';
@@ -1465,7 +1760,9 @@
1465
  ws.onmessage = e => {
1466
  const d = JSON.parse(e.data);
1467
 
1468
- // Backend engine error surface it clearly
 
 
1469
  if (d.error) {
1470
  processingDone = true;
1471
  document.getElementById('proc-label').innerText = 'Engine Error';
@@ -1540,6 +1837,13 @@
1540
  const newWrap = document.getElementById('new-analysis-wrap');
1541
  if (newWrap) newWrap.classList.remove('hidden');
1542
 
 
 
 
 
 
 
 
1543
  return;
1544
  }
1545
 
@@ -1559,6 +1863,11 @@
1559
  const currTotal = parseInt(cntTotalEl.innerText) || 0;
1560
  animateValue(cntTotalEl, currTotal, totalIn + totalOut, 300);
1561
 
 
 
 
 
 
1562
  // Update doughnut
1563
  doughChart.data.datasets[0].data = [totalIn, totalOut];
1564
  doughChart.update();
@@ -1588,7 +1897,8 @@
1588
  'annotated.mp4': { title: 'Annotated Video Export', desc: 'Rendered video with tracking overlays' },
1589
  'heatmap.png': { title: 'Spatial Density Heatmap', desc: 'Aggregated path utilization mapping' },
1590
  'heatmap.pdf': { title: 'Spatial Density Heatmap', desc: 'Aggregated path utilization mapping' },
1591
- 'raw_data.csv': { title: 'Raw Analytics Export', desc: 'Comma-separated values of all crossings' }
 
1592
  };
1593
 
1594
  async function loadReports(videoId) {
@@ -1629,6 +1939,12 @@
1629
  <i class="fa-solid fa-file-csv text-6xl mb-4 text-white"></i>
1630
  <span class="text-xs font-bold uppercase tracking-widest text-slate-500">Raw Analytics Export</span>
1631
  </div>`;
 
 
 
 
 
 
1632
  } else {
1633
  previewHTML = `<img src="${url}" alt="${info.title}" class="max-w-full max-h-[320px] object-contain rounded">`;
1634
  }
 
325
  padding-top: 0;
326
  border-top: none;
327
  }
328
+
329
+ /* Toast Notifications */
330
+ #toast-container {
331
+ position: fixed;
332
+ bottom: 20px;
333
+ right: 20px;
334
+ z-index: 10000;
335
+ display: flex;
336
+ flex-direction: column;
337
+ gap: 8px;
338
+ pointer-events: none;
339
+ }
340
+ .toast {
341
+ background: #111;
342
+ border: 1px solid #2a2a2a;
343
+ color: #f0ece6;
344
+ font-size: 11px;
345
+ font-weight: 600;
346
+ padding: 10px 18px;
347
+ border-radius: 10px;
348
+ display: flex;
349
+ align-items: center;
350
+ gap: 8px;
351
+ pointer-events: auto;
352
+ animation: toastIn 0.3s ease-out;
353
+ max-width: 320px;
354
+ }
355
+ .toast.toast-out { animation: toastOut 0.3s ease-in forwards; }
356
+ .toast-success { border-color: #166534; }
357
+ .toast-success i { color: #22c55e; }
358
+ .toast-error { border-color: #7f1d1d; }
359
+ .toast-error i { color: #ef4444; }
360
+ .toast-info i { color: var(--cocoa-l); }
361
+ @keyframes toastIn { from { opacity: 0; transform: translateX(40px); } to { opacity: 1; transform: translateX(0); } }
362
+ @keyframes toastOut { from { opacity: 1; } to { opacity: 0; transform: translateX(40px); } }
363
+
364
+ /* Stats Empty State */
365
+ .stats-empty-overlay {
366
+ position: absolute;
367
+ inset: 0;
368
+ z-index: 10;
369
+ display: flex;
370
+ flex-direction: column;
371
+ align-items: center;
372
+ justify-content: center;
373
+ background: rgba(0,0,0,0.7);
374
+ border-radius: 12px;
375
+ }
376
+
377
+ /* Mobile Responsive */
378
+ @media (max-width: 1024px) {
379
+ aside.w-60 { display: none; }
380
+ .mobile-nav { display: flex !important; }
381
+ #tab-overview { grid-template-columns: repeat(1, 1fr) !important; }
382
+ #tab-overview > div { grid-column: span 1 !important; }
383
+ }
384
+ @media (max-width: 768px) {
385
+ .grid-cols-3 { grid-template-columns: 1fr !important; }
386
+ .grid-cols-2 { grid-template-columns: 1fr !important; }
387
+ main { padding: 8px !important; }
388
+ }
389
+
390
+ /* Feedback form */
391
+ .fb-textarea {
392
+ background: #111;
393
+ border: 1px solid #2a2a2a;
394
+ border-radius: 8px;
395
+ color: #f0ece6;
396
+ font-size: 12px;
397
+ padding: 12px;
398
+ width: 100%;
399
+ min-height: 120px;
400
+ resize: vertical;
401
+ font-family: 'Inter', sans-serif;
402
+ }
403
+ .fb-textarea:focus { outline: none; border-color: var(--cocoa-l); }
404
+ .fb-select {
405
+ background: #111;
406
+ border: 1px solid #2a2a2a;
407
+ border-radius: 8px;
408
+ color: #f0ece6;
409
+ font-size: 11px;
410
+ padding: 8px 12px;
411
+ width: 100%;
412
+ font-family: 'Inter', sans-serif;
413
+ }
414
+ .fb-select:focus { outline: none; border-color: var(--cocoa-l); }
415
+ .fb-stars { display: flex; gap: 6px; }
416
+ .fb-star {
417
+ font-size: 22px;
418
+ color: #333;
419
+ cursor: pointer;
420
+ transition: color 0.15s;
421
+ }
422
+ .fb-star.active, .fb-star:hover { color: var(--cocoa-l); }
423
  </style>
424
  </head>
425
 
 
451
  class="flex items-center px-4 py-2.5 rounded-lg transition cursor-pointer nav-item-inactive">
452
  <i class="fa-solid fa-sliders w-6"></i> <span class="font-medium">Settings</span>
453
  </a>
454
+ <a onclick="switchTab('feedback')" id="nav-feedback"
455
+ class="flex items-center px-4 py-2.5 rounded-lg transition cursor-pointer nav-item-inactive">
456
+ <i class="fa-solid fa-comment-dots w-6"></i> <span class="font-medium">Feedback</span>
457
+ </a>
458
  </nav>
459
  <div class="mt-auto border-t p-4 flex flex-col items-center gap-2 bg-black flex-shrink-0"
460
  style="border-color:#2a2a2a">
 
467
  style="color:#a89f97" onmouseover="this.style.color='#c89a6c'"
468
  onmouseout="this.style.color='#a89f97'">Terms &amp; Conditions</button>
469
  <p class="text-[9px] mt-1" style="color:#333">&#169; 2026 UrbanFlow</p>
470
+ <p class="text-[8px] mt-0.5" style="color:#222" title="Keyboard shortcuts: 1-6 switch tabs, D downloads artifacts">⌨ Press 1-6 for tabs, D for download</p>
471
  </div>
472
  </aside>
473
 
474
+ <!-- Mobile Navigation (hidden on desktop) -->
475
+ <div class="mobile-nav hidden fixed top-0 left-0 right-0 z-30 bg-black border-b border-slate-800 px-2 py-1.5 items-center justify-between">
476
+ <img src="uf_rf.png" alt="UF" class="h-8">
477
+ <div class="flex gap-1">
478
+ <button onclick="switchTab('about')" class="text-[9px] px-2 py-1 rounded" style="color:#a89f97"><i class="fa-solid fa-circle-info"></i></button>
479
+ <button onclick="switchTab('overview')" class="text-[9px] px-2 py-1 rounded" style="color:#a89f97"><i class="fa-solid fa-desktop"></i></button>
480
+ <button onclick="switchTab('run-details')" class="text-[9px] px-2 py-1 rounded" style="color:#a89f97"><i class="fa-solid fa-microchip"></i></button>
481
+ <button onclick="switchTab('reports')" class="text-[9px] px-2 py-1 rounded" style="color:#a89f97"><i class="fa-solid fa-file-lines"></i></button>
482
+ <button onclick="switchTab('settings')" class="text-[9px] px-2 py-1 rounded" style="color:#a89f97"><i class="fa-solid fa-sliders"></i></button>
483
+ <button onclick="switchTab('feedback')" class="text-[9px] px-2 py-1 rounded" style="color:#a89f97"><i class="fa-solid fa-comment-dots"></i></button>
484
+ </div>
485
+ </div>
486
+
487
+ <!-- Toast Container -->
488
+ <div id="toast-container"></div>
489
+
490
  <main class="flex-1 flex flex-col h-full min-w-0 p-4 gap-4">
491
 
492
  <!-- TAB: About -->
 
631
  </div>
632
 
633
  <!-- TAB: Overview -->
634
+ <div id="tab-overview" class="hidden grid grid-cols-12 gap-4 flex-1 min-h-0 overflow-hidden" style="position:relative">
635
+
636
+ <!-- Empty State Overlay -->
637
+ <div id="stats-empty-state" class="stats-empty-overlay">
638
+ <i class="fa-solid fa-chart-column text-4xl mb-4" style="color:#333"></i>
639
+ <span class="text-sm font-bold" style="color:#555">Processing not started</span>
640
+ <span class="text-[11px] mt-1" style="color:#444">Charts will populate in real-time once analysis begins</span>
641
+ </div>
642
 
643
  <!-- Congestion Index -->
644
  <div
 
668
  <div class="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
669
  <span class="text-3xl font-black text-slate-900" id="cnt-total">0</span>
670
  <span class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Total Units</span>
671
+ <span class="text-[9px] font-bold mt-0.5 uppercase tracking-wider flex items-center gap-1" style="color:#8b5e3c">
672
+ <span id="cnt-pcu">0</span> PCU
673
+ <span class="info-wrap" style="margin-left:2px">
674
+ <span class="info-btn" style="width:11px;height:11px;font-size:6px"><i class="fa-solid fa-info"></i></span>
675
+ <span class="info-tip">Passenger Car Units (IRC:106-1990). Normalizes mixed traffic into a single unit for capacity analysis.</span>
676
+ </span>
677
+ </span>
678
  </div>
679
  </div>
680
  </div>
 
785
  </div>
786
  </div>
787
 
788
+ <!-- Insights Panel (xAI) -->
789
+ <div id="insights-panel" class="hidden">
790
+ <div class="grid grid-cols-2 gap-6 mt-6">
791
+ <div class="bg-white rounded-xl border border-slate-200 shadow-sm flex flex-col overflow-hidden">
792
+ <div class="px-6 py-4 border-b border-slate-100 bg-slate-50/50">
793
+ <h3 class="font-bold text-slate-800 text-sm flex items-center">Speed Distribution
794
+ <span class="info-wrap"><span class="info-btn"><i class="fa-solid fa-info"></i></span>
795
+ <span class="info-tip">Relative speed categories based on pixel displacement. Slow/Normal/Fast are percentile-based within this video.</span></span>
796
+ </h3>
797
+ </div>
798
+ <div class="p-6" id="speed-dist-content">
799
+ <div class="flex gap-4 items-end justify-center h-32" id="speed-bars"></div>
800
+ </div>
801
+ </div>
802
+ <div class="bg-white rounded-xl border border-slate-200 shadow-sm flex flex-col overflow-hidden">
803
+ <div class="px-6 py-4 border-b border-slate-100 bg-slate-50/50">
804
+ <h3 class="font-bold text-slate-800 text-sm flex items-center">Congestion Insights
805
+ <span class="info-wrap"><span class="info-btn"><i class="fa-solid fa-info"></i></span>
806
+ <span class="info-tip">Automated peak detection and congestion classification.</span></span>
807
+ </h3>
808
+ </div>
809
+ <div class="p-6 space-y-3" id="congestion-insights"></div>
810
+ </div>
811
+ </div>
812
+ </div>
813
+
814
  </div>
815
  </div>
816
 
 
1008
  </div>
1009
  </div>
1010
 
1011
+ <!-- TAB: Feedback -->
1012
+ <div id="tab-feedback" class="hidden flex-1 min-h-0 overflow-y-auto" style="padding:16px">
1013
+ <div class="max-w-2xl mx-auto">
1014
+ <div class="bg-black border border-slate-800 rounded-xl p-8 shadow-2xl space-y-6">
1015
+ <div class="text-center">
1016
+ <h2 class="text-xl font-bold" style="color:#f0ece6">Share Your Feedback</h2>
1017
+ <p class="text-xs mt-2" style="color:#777">We're in the requirements-gathering phase. Your input directly shapes what gets built.</p>
1018
+ </div>
1019
+
1020
+ <div class="space-y-4">
1021
+ <div>
1022
+ <label class="text-[10px] font-bold uppercase tracking-widest block mb-2" style="color:#a89f97">Overall Experience</label>
1023
+ <div class="fb-stars" id="fb-stars">
1024
+ <span class="fb-star" data-v="1" onclick="setRating(1)">&#9733;</span>
1025
+ <span class="fb-star" data-v="2" onclick="setRating(2)">&#9733;</span>
1026
+ <span class="fb-star" data-v="3" onclick="setRating(3)">&#9733;</span>
1027
+ <span class="fb-star" data-v="4" onclick="setRating(4)">&#9733;</span>
1028
+ <span class="fb-star" data-v="5" onclick="setRating(5)">&#9733;</span>
1029
+ </div>
1030
+ </div>
1031
+
1032
+ <div>
1033
+ <label class="text-[10px] font-bold uppercase tracking-widest block mb-2" style="color:#a89f97">Category</label>
1034
+ <select class="fb-select" id="fb-type">
1035
+ <option value="general">General Feedback</option>
1036
+ <option value="bug">Bug Report</option>
1037
+ <option value="feature">Feature Request</option>
1038
+ <option value="accuracy">Detection Accuracy</option>
1039
+ <option value="ux">User Experience</option>
1040
+ </select>
1041
+ </div>
1042
+
1043
+ <div>
1044
+ <label class="text-[10px] font-bold uppercase tracking-widest block mb-2" style="color:#a89f97">Details</label>
1045
+ <textarea class="fb-textarea" id="fb-text" placeholder="Tell us what worked, what didn't, or what you'd like to see..."></textarea>
1046
+ </div>
1047
+
1048
+ <button onclick="submitFeedback()" class="w-full py-3 font-bold text-sm rounded-full transition hover:scale-[1.02] active:scale-95"
1049
+ style="background:#0a0a0a;border:1px solid var(--cocoa);color:var(--cocoa-l)">
1050
+ Submit Feedback <i class="fa-solid fa-paper-plane ml-2 text-[10px]"></i>
1051
+ </button>
1052
+ </div>
1053
+
1054
+ <p class="text-[10px] text-center" style="color:#444">Or email us directly at <strong style="color:#c89a6c">support@urbanflow.in</strong></p>
1055
+ </div>
1056
+ </div>
1057
+ </div>
1058
+
1059
  </main>
1060
 
1061
  <script>
 
1138
 
1139
  // =========== Tab switching ===========
1140
  function switchTab(tab) {
1141
+ ['about', 'overview', 'run-details', 'reports', 'settings', 'feedback'].forEach(t => {
1142
  const el = document.getElementById('tab-' + t);
1143
  const nav = document.getElementById('nav-' + t);
1144
  if (el) el.classList.toggle('hidden', tab !== t);
 
1154
  });
1155
  }
1156
 
1157
+ // =========== Toast System ===========
1158
+ function showToast(message, type) {
1159
+ type = type || 'info';
1160
+ const icons = { success: 'fa-check-circle', error: 'fa-circle-xmark', info: 'fa-circle-info' };
1161
+ const el = document.createElement('div');
1162
+ el.className = `toast toast-${type}`;
1163
+ el.innerHTML = `<i class="fa-solid ${icons[type] || icons.info}"></i> ${message}`;
1164
+ document.getElementById('toast-container').appendChild(el);
1165
+ setTimeout(() => { el.classList.add('toast-out'); setTimeout(() => el.remove(), 300); }, 3000);
1166
+ }
1167
+
1168
+ // =========== Keyboard Shortcuts ===========
1169
+ const TAB_KEYS = { '1': 'about', '2': 'overview', '3': 'run-details', '4': 'reports', '5': 'settings', '6': 'feedback' };
1170
+ document.addEventListener('keydown', function(e) {
1171
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
1172
+ if (TAB_KEYS[e.key]) { switchTab(TAB_KEYS[e.key]); return; }
1173
+ if (e.key === 'd' || e.key === 'D') {
1174
+ const vid = document.body.dataset.lastVideoId;
1175
+ if (vid) {
1176
+ window.open(`/bundle/${vid}`, '_blank');
1177
+ showToast('Download started', 'success');
1178
+ }
1179
+ }
1180
+ });
1181
+
1182
+ // =========== Feedback ===========
1183
+ let _fbRating = 0;
1184
+ function setRating(n) {
1185
+ _fbRating = n;
1186
+ document.querySelectorAll('.fb-star').forEach(s => {
1187
+ s.classList.toggle('active', parseInt(s.dataset.v) <= n);
1188
+ });
1189
+ }
1190
+ async function submitFeedback() {
1191
+ const type = document.getElementById('fb-type').value;
1192
+ const text = document.getElementById('fb-text').value.trim();
1193
+ if (!text && !_fbRating) { showToast('Please add a rating or comment', 'error'); return; }
1194
+ const res = await fetch('/api/feedback', {
1195
+ method: 'POST',
1196
+ headers: { 'Content-Type': 'application/json' },
1197
+ body: JSON.stringify({ rating: _fbRating, type, text })
1198
+ });
1199
+ if (res.ok) {
1200
+ showToast('Thank you for your feedback!', 'success');
1201
+ document.getElementById('fb-text').value = '';
1202
+ setRating(0);
1203
+ } else {
1204
+ showToast('Failed to submit — please try again', 'error');
1205
+ }
1206
+ }
1207
+
1208
+ // =========== PCU Calculation (client-side mirror) ===========
1209
+ const PCU_TABLE = {0:1,1:1,2:1,3:1,4:3,5:3,6:1.2,7:0.5,8:3,9:3,10:3,11:0.5,12:1,13:1};
1210
+ function calcPCU(classIn, classOut) {
1211
+ let total = 0;
1212
+ for (const [k, v] of Object.entries(classIn)) total += (PCU_TABLE[parseInt(k)] || 1) * v;
1213
+ for (const [k, v] of Object.entries(classOut)) total += (PCU_TABLE[parseInt(k)] || 1) * v;
1214
+ return Math.round(total * 10) / 10;
1215
+ }
1216
+
1217
+ // =========== Insights Rendering ===========
1218
+ function renderInsights(d) {
1219
+ const panel = document.getElementById('insights-panel');
1220
+ panel.classList.remove('hidden');
1221
+
1222
+ // Speed distribution bars
1223
+ const dist = d.speed_distribution || {};
1224
+ const bars = document.getElementById('speed-bars');
1225
+ const colors = { slow: '#ef4444', normal: '#eab308', fast: '#22c55e' };
1226
+ const labels = { slow: 'Slow', normal: 'Normal', fast: 'Fast' };
1227
+ bars.innerHTML = ['slow', 'normal', 'fast'].map(cat => {
1228
+ const pct = dist[cat] || 0;
1229
+ const h = Math.max(8, pct * 1.2);
1230
+ return `<div class="flex flex-col items-center gap-1">
1231
+ <span class="text-[10px] font-bold" style="color:${colors[cat]}">${pct}%</span>
1232
+ <div style="width:36px;height:${h}px;background:${colors[cat]};border-radius:6px;transition:height 0.5s"></div>
1233
+ <span class="text-[9px] font-bold text-slate-500 uppercase">${labels[cat]}</span>
1234
+ </div>`;
1235
+ }).join('');
1236
+
1237
+ // Congestion insights
1238
+ const ci = document.getElementById('congestion-insights');
1239
+ const pcu = d.pcu || {};
1240
+ ci.innerHTML = [
1241
+ infoRow('Total PCU', pcu.total_pcu || 0, 'Passenger Car Units (IRC:106-1990). Normalizes mixed traffic.'),
1242
+ infoRow('PCU In / Out', `${pcu.pcu_in || 0} / ${pcu.pcu_out || 0}`, 'Directional PCU split.'),
1243
+ infoRow('Speed Profile', `${dist.slow || 0}% slow · ${dist.normal || 0}% normal · ${dist.fast || 0}% fast`, 'Relative speed categories within this video.'),
1244
+ ].join('');
1245
+ }
1246
+
1247
  // =========== Run Details helpers ===========
1248
  function detailRow(label, value, extra) {
1249
  extra = extra || '';
 
1724
  ws.onerror = e => {
1725
  console.error('WS Error:', e);
1726
  document.getElementById('proc-label').innerText = 'Connection Error';
1727
+ showToast('Connection error — server may be busy', 'error');
1728
  if (badge) {
1729
  badge.innerText = 'Pipeline Failed';
1730
  badge.className = 'px-2.5 py-1 bg-red-900/40 text-red-100 text-[10px] font-bold rounded-full uppercase tracking-tighter';
 
1760
  ws.onmessage = e => {
1761
  const d = JSON.parse(e.data);
1762
 
1763
+ // Hide empty state on first data
1764
+ const emptyState = document.getElementById('stats-empty-state');
1765
+ if (emptyState) emptyState.style.display = 'none';
1766
  if (d.error) {
1767
  processingDone = true;
1768
  document.getElementById('proc-label').innerText = 'Engine Error';
 
1837
  const newWrap = document.getElementById('new-analysis-wrap');
1838
  if (newWrap) newWrap.classList.remove('hidden');
1839
 
1840
+ // Toast + Insights
1841
+ showToast('Processing complete — artifacts ready', 'success');
1842
+ renderInsights(d);
1843
+
1844
+ // Store video_id for keyboard shortcut download
1845
+ document.body.setAttribute('data-last-video-id', d.video_id);
1846
+
1847
  return;
1848
  }
1849
 
 
1863
  const currTotal = parseInt(cntTotalEl.innerText) || 0;
1864
  animateValue(cntTotalEl, currTotal, totalIn + totalOut, 300);
1865
 
1866
+ // Update PCU display
1867
+ const pcuVal = calcPCU(d.class_in, d.class_out);
1868
+ const pcuEl = document.getElementById('cnt-pcu');
1869
+ if (pcuEl) pcuEl.innerText = pcuVal;
1870
+
1871
  // Update doughnut
1872
  doughChart.data.datasets[0].data = [totalIn, totalOut];
1873
  doughChart.update();
 
1897
  'annotated.mp4': { title: 'Annotated Video Export', desc: 'Rendered video with tracking overlays' },
1898
  'heatmap.png': { title: 'Spatial Density Heatmap', desc: 'Aggregated path utilization mapping' },
1899
  'heatmap.pdf': { title: 'Spatial Density Heatmap', desc: 'Aggregated path utilization mapping' },
1900
+ 'raw_data.csv': { title: 'Raw Analytics Export', desc: 'Comma-separated values of all crossings' },
1901
+ 'analysis.json': { title: 'Structured JSON Export', desc: 'Complete analysis data with metadata for API consumption' }
1902
  };
1903
 
1904
  async function loadReports(videoId) {
 
1939
  <i class="fa-solid fa-file-csv text-6xl mb-4 text-white"></i>
1940
  <span class="text-xs font-bold uppercase tracking-widest text-slate-500">Raw Analytics Export</span>
1941
  </div>`;
1942
+ } else if (name.endsWith('.json')) {
1943
+ previewHTML = `
1944
+ <div class="flex flex-col items-center justify-center py-12 text-slate-700">
1945
+ <i class="fa-solid fa-code text-6xl mb-4 text-white"></i>
1946
+ <span class="text-xs font-bold uppercase tracking-widest text-slate-500">Structured JSON</span>
1947
+ </div>`;
1948
  } else {
1949
  previewHTML = `<img src="${url}" alt="${info.title}" class="max-w-full max-h-[320px] object-contain rounded">`;
1950
  }